#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const researchDir = path.resolve(scriptDir, "..");
const outputDir = path.join(researchDir, "PersonaTopApps");
const masterPath = path.join(researchDir, "MacCommonApps_CandidateTop5000_Master.csv");
const reviewPath = path.join(researchDir, "MacCommonApps_DefaultTags_SimplifiedReview.csv");
const rawOutputPath = path.join(outputDir, "MacPersonaTopApps_20x50.csv");
const uniqueOutputPath = path.join(outputDir, "MacPersonaTopApps_Unique.csv");
const reportOutputPath = path.join(outputDir, "MacPersonaTopApps_CrossCheckReport.md");
const stableTagOrder = [
"browser",
"communication",
"productivity",
"file-management",
"transfer",
"development",
"design",
"writing",
"media",
"video",
"audio",
"picture-photo",
"utilities",
"system",
"system-enhancement",
"entertainment",
"game",
"finance",
"education",
"ai-tools",
"security",
"other",
];
const stableTags = new Set(stableTagOrder);
function parseCSV(text) {
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === "\"") {
if (text[index + 1] === "\"") {
field += "\"";
index += 1;
} else {
quoted = false;
}
} else {
field += char;
}
continue;
}
if (char === "\"") {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
function stringifyCSV(rows) {
return rows
.map((row) =>
row
.map((field) => {
const value = String(field ?? "");
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, "\"\"")}"`;
}
return value;
})
.join(","),
)
.join("\n");
}
function readCSVObjects(filePath) {
const rows = parseCSV(fs.readFileSync(filePath, "utf8"));
const header = rows[0] ?? [];
return rows.slice(1).map((row) =>
Object.fromEntries(header.map((column, index) => [column, row[index] ?? ""])),
);
}
function slugify(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function splitTags(value) {
return String(value ?? "")
.split(/[|;,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function unique(values) {
const seen = new Set();
const output = [];
for (const value of values) {
const trimmed = String(value ?? "").trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
output.push(trimmed);
}
return output;
}
function orderedTags(tags) {
const cleaned = unique(tags).filter((tag) => stableTags.has(tag));
return stableTagOrder.filter((tag) => cleaned.includes(tag));
}
function matchesAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
const personas = [
{
rank: 1,
id: "software-developer",
name: "Software Developer",
zhName: "软件开发工程师",
angle: "code editors, terminals, API tools, databases, containers, and AI coding assistants",
tagWeights: { development: 10, "ai-tools": 5, utilities: 3, security: 2, productivity: 2, browser: 1 },
keywords: [/code|coding|developer|ide|terminal|shell|api|database|sql|git|diff|merge|docker|container|kubernetes|compiler|runtime|debug/],
seedNames: ["Visual Studio Code", "Cursor", "Xcode", "JetBrains Toolbox", "iTerm2", "Warp", "Docker Desktop", "Postman", "TablePlus", "GitHub Desktop", "Fork", "Sourcetree", "Sublime Text", "Zed", "Claude Code", "Codex", "Android Studio", "DBeaver", "MongoDB Compass", "RedisInsight"],
allowSupportTools: true,
},
{
rank: 2,
id: "web-developer",
name: "Web Developer",
zhName: "Web 前端/全栈开发者",
angle: "browsers, frontend tooling, API clients, local servers, and design handoff",
tagWeights: { development: 9, browser: 6, design: 3, "ai-tools": 3, utilities: 2, productivity: 2 },
keywords: [/web|frontend|browser|javascript|typescript|node|react|vue|angular|api|http|proxy|local server|debug|responsive|figma/],
seedNames: ["Google Chrome", "Firefox", "Microsoft Edge", "Brave Browser", "Safari", "Arc", "Visual Studio Code", "Cursor", "Postman", "Insomnia", "RapidAPI", "Proxyman", "Charles", "Figma", "Responsively", "TablePlus", "Docker Desktop", "ngrok", "Hoppscotch", "Sublime Text"],
allowSupportTools: true,
},
{
rank: 3,
id: "data-ai-engineer",
name: "Data / AI Engineer",
zhName: "数据/AI 工程师",
angle: "notebooks, model runners, database clients, data tools, and local AI utilities",
tagWeights: { development: 8, "ai-tools": 8, utilities: 4, productivity: 2, education: 2, security: 1 },
keywords: [/ai|llm|machine learning|model|notebook|python|data|database|sql|postgres|mongo|redis|jupyter|ollama|studio|analytics|visualization/],
seedNames: ["JupyterLab", "Anaconda", "Miniconda", "Python", "RStudio", "TablePlus", "DBeaver", "MongoDB Compass", "RedisInsight", "LM Studio", "Ollama", "ChatGPT", "Claude", "Msty", "AnythingLLM", "Orange", "KNIME", "Visual Studio Code", "Cursor", "Docker Desktop"],
allowSupportTools: true,
},
{
rank: 4,
id: "product-manager",
name: "Product Manager",
zhName: "产品经理",
angle: "planning, docs, communication, prototyping, analytics, and collaboration",
tagWeights: { productivity: 9, communication: 6, writing: 5, design: 4, browser: 3, "ai-tools": 3, utilities: 2 },
keywords: [/productivity|task|todo|project|kanban|calendar|mind map|whiteboard|doc|note|figma|prototype|analytics|meeting|chat|workflow/],
seedNames: ["Notion", "Linear", "Jira", "Confluence", "Trello", "Asana", "Slack", "Zoom", "Microsoft Teams", "Figma", "Miro", "FigJam", "Whimsical", "Xmind", "MindNode", "Obsidian", "Craft", "Google Chrome", "Raycast", "ChatGPT"],
},
{
rank: 5,
id: "ui-ux-designer",
name: "UI / UX Designer",
zhName: "UI/UX 设计师",
angle: "interface design, prototyping, screenshots, handoff, and design systems",
tagWeights: { design: 10, productivity: 4, "picture-photo": 4, browser: 3, communication: 2, "ai-tools": 2, utilities: 2 },
keywords: [/design|prototype|wireframe|ui|ux|figma|sketch|adobe|vector|screenshot|color|icon|handoff|whiteboard|diagram/],
seedNames: ["Figma", "Sketch", "Framer", "Principle", "Adobe XD", "Photoshop", "Illustrator", "Affinity Designer", "Pixelmator Pro", "Canva", "Miro", "FigJam", "CleanShot X", "Shottr", "Sip", "Eagle", "IconJar", "Abstract", "Zeplin", "Raycast"],
},
{
rank: 6,
id: "graphic-brand-designer",
name: "Graphic / Brand Designer",
zhName: "平面/品牌设计师",
angle: "raster editing, vector design, layout, asset management, typography, and presentation",
tagWeights: { design: 10, "picture-photo": 7, media: 3, productivity: 2, utilities: 2, "ai-tools": 2 },
keywords: [/graphic|brand|design|vector|photo|image|illustration|layout|font|color|asset|icon|svg|png|adobe|affinity|canva|pixelmator/],
seedNames: ["Photoshop", "Illustrator", "InDesign", "Affinity Designer", "Affinity Photo", "Affinity Publisher", "Pixelmator Pro", "Canva", "Figma", "Sketch", "Eagle", "IconJar", "FontBase", "Typeface", "RightFont", "Blender", "ImageOptim", "Acorn", "Krita", "GIMP"],
},
{
rank: 7,
id: "writer-editor",
name: "Writer / Editor",
zhName: "写作者/编辑",
angle: "long-form writing, notes, Markdown, references, grammar, and focused drafting",
tagWeights: { writing: 10, productivity: 6, education: 3, "ai-tools": 3, utilities: 2, communication: 1 },
keywords: [/writing|writer|markdown|note|notebook|journal|document|word processor|zettelkasten|reference|grammar|dictionary|pdf|read|book|text/],
seedNames: ["Ulysses", "Scrivener", "Obsidian", "Bear", "Craft", "Typora", "iA Writer", "Notion", "Logseq", "Joplin", "Evernote", "Zotero", "Mendeley", "Calibre", "Grammarly", "Microsoft Word", "Pages", "DEVONthink", "ChatGPT", "Claude"],
},
{
rank: 8,
id: "student-researcher",
name: "Student / Researcher",
zhName: "学生/研究者",
angle: "reading, notes, references, PDF workflows, learning, and spaced repetition",
tagWeights: { education: 9, writing: 7, productivity: 6, "ai-tools": 3, browser: 2, utilities: 2 },
keywords: [/education|learning|study|research|paper|pdf|reference|citation|flashcard|anki|dictionary|course|note|reading|latex|bibtex|academic/],
seedNames: ["Zotero", "Mendeley", "EndNote", "Papers", "MarginNote", "LiquidText", "Anki", "Calibre", "Kindle", "Obsidian", "Notion", "Logseq", "DEVONthink", "PDF Expert", "Skim", "Microsoft Word", "Pages", "ChatGPT", "Perplexity", "Google Chrome"],
},
{
rank: 9,
id: "photographer",
name: "Photographer",
zhName: "摄影师",
angle: "RAW processing, photo management, image repair, metadata, screenshots, and cloud delivery",
tagWeights: { "picture-photo": 10, media: 7, design: 4, transfer: 2, utilities: 2, productivity: 1 },
keywords: [/photo|photography|raw|image|picture|camera|metadata|exif|lightroom|capture one|darktable|rawtherapee|heic|jpeg|png|gallery|asset/],
seedNames: ["Adobe Lightroom", "Lightroom Classic", "Capture One", "Photoshop", "Pixelmator Pro", "Affinity Photo", "Darktable", "RawTherapee", "Photo Mechanic", "ImageOptim", "Eagle", "XnView MP", "ExifTool", "CleanShot X", "Shottr", "Google Drive", "Dropbox", "Transmit", "Cyberduck", "Photos"],
},
{
rank: 10,
id: "video-creator",
name: "Video Creator",
zhName: "视频创作者",
angle: "editing, recording, transcoding, subtitles, playback, streaming, and media management",
tagWeights: { video: 10, media: 8, audio: 4, "picture-photo": 3, design: 2, transfer: 2, utilities: 2 },
keywords: [/video|movie|screen recording|recording|stream|transcode|subtitle|media player|editor|camera|motion|youtube|obs|final cut|premiere|davinci|resolve/],
seedNames: ["Final Cut Pro", "DaVinci Resolve", "Adobe Premiere Pro", "After Effects", "OBS", "ScreenFlow", "Camtasia", "CapCut", "HandBrake", "IINA", "VLC", "mpv", "LosslessCut", "Subler", "Permute", "Compressor", "Shutter Encoder", "Plex", "YouTube Music", "CleanShot X"],
},
{
rank: 11,
id: "music-audio-producer",
name: "Music / Audio Producer",
zhName: "音乐/音频制作人",
angle: "DAWs, recording, MIDI, podcasting, audio routing, music libraries, and performance",
tagWeights: { audio: 10, media: 7, entertainment: 3, utilities: 3, video: 1 },
keywords: [/audio|music|sound|midi|podcast|daw|recording|synth|ableton|logic|garageband|reaper|fl studio|audacity|blackhole|spotify|tidal|radio/],
seedNames: ["Logic Pro", "GarageBand", "Ableton Live", "Reaper", "FL Studio", "Audacity", "Ardour", "djay Pro", "rekordbox", "Serato DJ Pro", "BlackHole", "Loopback", "Audio Hijack", "Spotify", "Apple Music", "Vox", "TIDAL", "Plexamp", "IINA", "VLC"],
},
{
rank: 12,
id: "marketing-growth",
name: "Marketing / Growth",
zhName: "市场/增长运营",
angle: "content creation, analytics, communication, social assets, documents, and automation",
tagWeights: { productivity: 8, communication: 6, design: 5, writing: 5, browser: 4, "ai-tools": 4, media: 2 },
keywords: [/marketing|analytics|content|social|campaign|email|design|copy|writing|presentation|spreadsheet|meeting|automation|seo|ads|crm/],
seedNames: ["Google Chrome", "Slack", "Zoom", "Canva", "Figma", "Notion", "ChatGPT", "Claude", "Grammarly", "Microsoft Excel", "Microsoft PowerPoint", "Keynote", "Numbers", "Miro", "Trello", "Asana", "Mailchimp", "Buffer", "Hootsuite", "Raycast"],
},
{
rank: 13,
id: "finance-accounting",
name: "Finance / Accounting",
zhName: "财务/会计/投资",
angle: "spreadsheets, budgeting, accounting, trading, wallets, security, and reporting",
tagWeights: { finance: 10, productivity: 6, security: 4, utilities: 3, writing: 2, browser: 2 },
keywords: [/finance|money|accounting|budget|invoice|trading|stock|crypto|wallet|bank|ledger|tax|spreadsheet|excel|report|portfolio/],
seedNames: ["Microsoft Excel", "Numbers", "LibreOffice", "TradingView", "Portfolio Performance", "MoneyMoney", "MoneyWiz", "Actual", "YNAB", "Ledger Live", "Trezor Suite", "Exodus", "Electrum", "MetaMask", "1Password", "Bitwarden", "PDF Expert", "Acrobat Reader", "Notion", "Google Chrome"],
},
{
rank: 14,
id: "project-operations-manager",
name: "Project / Operations Manager",
zhName: "项目/运营管理者",
angle: "project planning, tasks, diagrams, spreadsheets, meetings, and internal docs",
tagWeights: { productivity: 10, communication: 6, writing: 5, design: 2, browser: 2, utilities: 2 },
keywords: [/project|task|todo|kanban|calendar|spreadsheet|diagram|whiteboard|workflow|meeting|planning|operation|doc|note|mind map/],
seedNames: ["Notion", "Linear", "Jira", "Confluence", "Trello", "Asana", "Microsoft Teams", "Slack", "Zoom", "Miro", "Xmind", "MindNode", "Microsoft Excel", "Numbers", "Keynote", "PowerPoint", "Fantastical", "Things", "Todoist", "OmniFocus"],
},
{
rank: 15,
id: "teacher-educator",
name: "Teacher / Educator",
zhName: "教师/教育工作者",
angle: "course material, screen recording, reading, notes, communication, and classroom content",
tagWeights: { education: 9, productivity: 6, writing: 5, video: 4, communication: 4, "ai-tools": 3, media: 2 },
keywords: [/education|teaching|course|learning|classroom|student|screen recording|presentation|note|pdf|video|quiz|flashcard|dictionary|whiteboard/],
seedNames: ["Keynote", "PowerPoint", "Pages", "Microsoft Word", "Zoom", "Microsoft Teams", "Google Chrome", "Anki", "Notion", "Obsidian", "PDF Expert", "Skim", "ScreenFlow", "Camtasia", "OBS", "Miro", "Xmind", "ChatGPT", "Perplexity", "Calibre"],
},
{
rank: 16,
id: "consultant-freelancer",
name: "Consultant / Freelancer",
zhName: "顾问/自由职业者",
angle: "client communication, proposals, documents, finance, project tracking, and secure delivery",
tagWeights: { productivity: 8, communication: 6, writing: 5, finance: 4, browser: 3, security: 3, transfer: 2 },
keywords: [/client|proposal|invoice|time tracking|project|task|calendar|meeting|document|pdf|finance|budget|secure|cloud|sync|presentation/],
seedNames: ["Notion", "Slack", "Zoom", "Microsoft Teams", "Google Chrome", "1Password", "Bitwarden", "PDF Expert", "Acrobat Reader", "Microsoft Word", "Pages", "Keynote", "PowerPoint", "Microsoft Excel", "Numbers", "Dropbox", "Google Drive", "Todoist", "Things", "Fantastical"],
},
{
rank: 17,
id: "architect-3d-designer",
name: "Architect / 3D Designer",
zhName: "建筑/3D 设计师",
angle: "CAD, BIM, 3D modeling, rendering, references, and large file workflows",
tagWeights: { design: 10, "picture-photo": 4, media: 3, utilities: 3, productivity: 2, transfer: 2 },
keywords: [/cad|bim|3d|model|render|architect|architecture|drawing|vector|mesh|blender|sketchup|autocad|freecad|openscad|fusion|rendering/],
seedNames: ["Blender", "SketchUp", "FreeCAD", "OpenSCAD", "Fusion 360", "AutoCAD", "Rhinoceros", "Cinema 4D", "Maya", "ZBrush", "Vectorworks", "Figma", "Photoshop", "Illustrator", "Affinity Designer", "Eagle", "Dropbox", "Google Drive", "Transmit", "Cyberduck"],
},
{
rank: 18,
id: "game-developer-gamer",
name: "Game Developer / Gamer",
zhName: "游戏开发者/玩家",
angle: "game engines, launchers, emulators, graphics tools, streaming, and developer utilities",
tagWeights: { game: 10, entertainment: 8, development: 5, design: 4, media: 3, video: 2, utilities: 2 },
keywords: [/game|gaming|engine|unity|unreal|godot|steam|emulator|controller|stream|sprite|pixel|3d|shader|graphics|itch|gog|battle/],
seedNames: ["Steam", "Epic Games", "GOG Galaxy", "itch", "Battle.net", "Roblox", "Roblox Studio", "Minecraft", "Unity Hub", "Unreal Engine", "Godot", "Blender", "Aseprite", "Pixelmator Pro", "OBS", "Discord", "OpenEmu", "Dolphin", "Ryujinx", "Whisky"],
},
{
rank: 19,
id: "sales-customer-success",
name: "Sales / Customer Success",
zhName: "销售/客户成功",
angle: "email, CRM-style workflows, calls, presentations, docs, scheduling, and secure notes",
tagWeights: { communication: 8, productivity: 8, writing: 4, browser: 4, security: 3, "ai-tools": 3 },
keywords: [/sales|customer|crm|email|mail|calendar|meeting|call|chat|presentation|doc|note|schedule|contact|support|ticket/],
seedNames: ["Slack", "Zoom", "Microsoft Teams", "Outlook", "Spark", "Mimestream", "Google Chrome", "Notion", "Microsoft Word", "PowerPoint", "Keynote", "Microsoft Excel", "Calendly", "Fantastical", "1Password", "Bitwarden", "ChatGPT", "Grammarly", "Todoist", "Things"],
},
{
rank: 20,
id: "legal-admin-hr",
name: "Legal / Admin / HR",
zhName: "法务/行政/HR",
angle: "documents, PDF, signing, archives, spreadsheets, communication, and secure records",
tagWeights: { writing: 8, productivity: 7, security: 5, communication: 4, finance: 2, utilities: 2, browser: 2 },
keywords: [/legal|admin|hr|document|pdf|signature|sign|archive|zip|spreadsheet|invoice|password|secure|mail|calendar|note|ocr|scan/],
seedNames: ["PDF Expert", "Acrobat Reader", "Foxit PDF Editor", "Microsoft Word", "Pages", "Microsoft Excel", "Numbers", "Outlook", "Spark", "Mimestream", "Zoom", "Microsoft Teams", "1Password", "Bitwarden", "Keka", "The Unarchiver", "DEVONthink", "Notion", "Dropbox", "Google Drive"],
},
];
function loadApps() {
return readCSVObjects(masterPath).map((row) => {
const tags = orderedTags(splitTags(row.defaultTagIDs));
const artifactTypes = splitTags(row.artifactTypes);
const normalizedName = row.normalizedName || slugify(row.name);
const text = [
row.name,
normalizedName,
row.homebrewToken,
row.bundleIdentifier,
row.description,
row.defaultTagIDs,
row.notes,
].join(" ").toLowerCase();
const userFacing = artifactTypes.some((type) =>
["app", "suite", "pkg", "installer", "prefpane", "input_method", "screen_saver"].includes(type),
) || row.sourceEvidence.includes("existing_review_seed");
return {
...row,
rank: Number(row.rank || 999999),
tags,
normalizedName,
text,
artifactTypes,
homebrew30dCount: Number(row.homebrew30dCount || 0),
homebrew365dCount: Number(row.homebrew365dCount || 0),
userFacing,
};
});
}
function reviewMap() {
const rows = readCSVObjects(reviewPath);
const bySlug = new Map();
for (const row of rows) {
bySlug.set(slugify(row.name), row.defaultTagIDs);
}
return bySlug;
}
function scoreAppForPersona(app, persona) {
if (!app.name || app.tags.includes("other")) return null;
if (/^font\b/i.test(app.name) || /(?:nerd font|typeface collection)/i.test(app.name)) return null;
if (!app.userFacing && !persona.allowSupportTools) return null;
const seedSlugs = new Map(persona.seedNames.map((name, index) => [slugify(name), index]));
const seedIndex = seedSlugs.get(app.normalizedName);
let tagScore = 0;
const signalTags = [];
for (const tag of app.tags) {
const weight = persona.tagWeights[tag] ?? 0;
if (weight > 0) {
tagScore += weight * 120;
signalTags.push(tag);
}
}
const keywordHits = persona.keywords.filter((pattern) => pattern.test(app.text)).length;
const keywordScore = keywordHits * 160;
const seedScore = seedIndex === undefined ? 0 : 12000 - seedIndex * 90;
const popularityScore =
Math.log10(app.homebrew365dCount + 1) * 70 +
Math.log10(app.homebrew30dCount + 1) * 90 +
Math.max(0, 5000 - app.rank) * 0.018;
const userFacingScore = app.userFacing ? 120 : 0;
const narrowPenalty = tagScore === 0 && keywordHits === 0 && seedIndex === undefined ? -900 : 0;
const score = tagScore + keywordScore + seedScore + popularityScore + userFacingScore + narrowPenalty;
if (score <= 0) return null;
return {
score,
signalTags: orderedTags(signalTags.length > 0 ? signalTags : app.tags),
reasonParts: unique([
seedIndex !== undefined ? "seed app for persona" : "",
signalTags.length > 0 ? `matched tags: ${signalTags.join("|")}` : "",
keywordHits > 0 ? `keyword hits: ${keywordHits}` : "",
app.homebrew365dCount > 0 ? `homebrew365d=${app.homebrew365dCount}` : "",
]),
};
}
function compareTags(defaultTags, reviewedTags) {
if (!reviewedTags) return "missing_from_review_table";
if (defaultTags === reviewedTags) return "matches_reviewed_table";
const left = orderedTags(splitTags(defaultTags));
const right = orderedTags(splitTags(reviewedTags));
const leftOnly = left.filter((tag) => !right.includes(tag));
const rightOnly = right.filter((tag) => !left.includes(tag));
if (leftOnly.length === 0 && rightOnly.length === 0) return "matches_reviewed_table";
return `differs: output_only=${leftOnly.join("|") || "-"} review_only=${rightOnly.join("|") || "-"}`;
}
function build() {
fs.mkdirSync(outputDir, { recursive: true });
const apps = loadApps();
const reviewedTagsBySlug = reviewMap();
const rawRows = [[
"occupationRank",
"occupationID",
"occupationName",
"occupationNameZH",
"appRankInOccupation",
"appName",
"defaultTagIDs",
"personaSignalTagIDs",
"masterDefaultTagIDs",
"reviewTableDefaultTagIDs",
"tagComparison",
"masterRank",
"normalizedName",
"homebrewToken",
"bundleIdentifier",
"selectionScore",
"selectionReason",
"personaAngle",
]];
const rawObjects = [];
for (const persona of personas) {
const scored = apps
.map((app) => {
const scoredApp = scoreAppForPersona(app, persona);
if (!scoredApp) return null;
return { app, ...scoredApp };
})
.filter(Boolean)
.sort((left, right) => right.score - left.score || left.app.rank - right.app.rank || left.app.name.localeCompare(right.app.name));
const selected = scored.slice(0, 50);
if (selected.length < 50) {
throw new Error(`${persona.id} only selected ${selected.length} apps`);
}
selected.forEach((item, index) => {
const defaultTags = item.app.tags.join("|");
const reviewedTags = reviewedTagsBySlug.get(item.app.normalizedName) ?? "";
const tagComparison = compareTags(defaultTags, reviewedTags);
const rowObject = {
occupationRank: persona.rank,
occupationID: persona.id,
occupationName: persona.name,
occupationNameZH: persona.zhName,
appRankInOccupation: index + 1,
appName: item.app.name,
defaultTagIDs: defaultTags,
personaSignalTagIDs: item.signalTags.join("|"),
masterDefaultTagIDs: defaultTags,
reviewTableDefaultTagIDs: reviewedTags,
tagComparison,
masterRank: item.app.rank,
normalizedName: item.app.normalizedName,
homebrewToken: item.app.homebrewToken,
bundleIdentifier: item.app.bundleIdentifier,
selectionScore: item.score.toFixed(1),
selectionReason: item.reasonParts.join("; "),
personaAngle: persona.angle,
};
rawObjects.push(rowObject);
rawRows.push([
rowObject.occupationRank,
rowObject.occupationID,
rowObject.occupationName,
rowObject.occupationNameZH,
rowObject.appRankInOccupation,
rowObject.appName,
rowObject.defaultTagIDs,
rowObject.personaSignalTagIDs,
rowObject.masterDefaultTagIDs,
rowObject.reviewTableDefaultTagIDs,
rowObject.tagComparison,
rowObject.masterRank,
rowObject.normalizedName,
rowObject.homebrewToken,
rowObject.bundleIdentifier,
rowObject.selectionScore,
rowObject.selectionReason,
rowObject.personaAngle,
]);
});
}
const bySlug = new Map();
for (const row of rawObjects) {
const existing = bySlug.get(row.normalizedName);
if (!existing) {
bySlug.set(row.normalizedName, {
appName: row.appName,
normalizedName: row.normalizedName,
defaultTagIDs: row.defaultTagIDs,
reviewTableDefaultTagIDs: row.reviewTableDefaultTagIDs,
tagComparison: row.tagComparison,
masterRank: Number(row.masterRank),
homebrewToken: row.homebrewToken,
bundleIdentifier: row.bundleIdentifier,
personas: [row.occupationID],
personaNames: [row.occupationName],
personaNamesZH: [row.occupationNameZH],
bestPersonaRank: Number(row.appRankInOccupation),
});
continue;
}
existing.personas.push(row.occupationID);
existing.personaNames.push(row.occupationName);
existing.personaNamesZH.push(row.occupationNameZH);
existing.bestPersonaRank = Math.min(existing.bestPersonaRank, Number(row.appRankInOccupation));
}
const uniqueObjects = [...bySlug.values()].sort((left, right) =>
right.personas.length - left.personas.length ||
left.masterRank - right.masterRank ||
left.appName.localeCompare(right.appName),
);
const uniqueRows = [[
"appName",
"defaultTagIDs",
"reviewTableDefaultTagIDs",
"tagComparison",
"personaCount",
"personaIDs",
"personaNames",
"personaNamesZH",
"bestPersonaRank",
"masterRank",
"normalizedName",
"homebrewToken",
"bundleIdentifier",
]];
for (const row of uniqueObjects) {
uniqueRows.push([
row.appName,
row.defaultTagIDs,
row.reviewTableDefaultTagIDs,
row.tagComparison,
row.personas.length,
unique(row.personas).join("|"),
unique(row.personaNames).join("|"),
unique(row.personaNamesZH).join("|"),
row.bestPersonaRank,
row.masterRank,
row.normalizedName,
row.homebrewToken,
row.bundleIdentifier,
]);
}
fs.writeFileSync(rawOutputPath, `${stringifyCSV(rawRows)}\n`);
fs.writeFileSync(uniqueOutputPath, `${stringifyCSV(uniqueRows)}\n`);
const invalidRawRows = rawObjects.filter((row) =>
splitTags(row.defaultTagIDs).some((tag) => !stableTags.has(tag)),
);
const mismatchRows = rawObjects.filter((row) => row.tagComparison !== "matches_reviewed_table");
const tagCounts = new Map();
for (const row of rawObjects) {
for (const tag of splitTags(row.defaultTagIDs)) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const personaLines = personas.map((persona) => `- ${persona.rank}. ${persona.zhName} / ${persona.name} (\`${persona.id}\`): ${persona.angle}`).join("\n");
const topDuplicateLines = uniqueObjects
.slice(0, 30)
.map((row) => `- ${row.appName}: ${row.personas.length} personas; tags \`${row.defaultTagIDs}\``)
.join("\n");
const tagLines = [...tagCounts.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([tag, count]) => `- \`${tag}\`: ${count}`)
.join("\n");
const mismatchLines = mismatchRows.length === 0
? "- None"
: mismatchRows.slice(0, 100).map((row) => `- ${row.occupationID} / ${row.appName}: ${row.tagComparison}`).join("\n");
const report = `# Persona Top Apps Cross-check
Generated: ${new Date().toISOString()}
## Outputs
- Raw 20 x 50 table: \`Research/SmartStart/PersonaTopApps/MacPersonaTopApps_20x50.csv\`
- Unique deduped table: \`Research/SmartStart/PersonaTopApps/MacPersonaTopApps_Unique.csv\`
- Cross-check report: \`Research/SmartStart/PersonaTopApps/MacPersonaTopApps_CrossCheckReport.md\`
## Method
This is a persona-derived research pass, not a claim about exact global Mac occupation market share. The 20 occupations are common Mac-heavy professional/user personas. For each persona, the script scores the reviewed 5,000-app master library by:
- persona seed apps,
- stable tag relevance,
- keywords in app name, Homebrew token, bundle id, description, and notes,
- Homebrew cask install popularity already captured in the master table,
- user-facing app likelihood.
The exported \`defaultTagIDs\` stay product-level stable tags. \`personaSignalTagIDs\` explains why the app was selected for that occupation.
## Source Context
- Homebrew Formulae API and cask metadata/analytics are the primary machine-readable public data source: https://formulae.brew.sh/docs/api/
- Setapp's Mac Apps Report is used only as product-research context for Mac app behavior and discovery, not as a row-level source: https://setapp.com/mac-apps-report
- Stack Overflow's developer survey is used only as context for developer tooling and macOS relevance, not as a row-level source: https://survey.stackoverflow.co/
## Summary
- Persona rows: ${rawObjects.length}
- Personas: ${personas.length}
- Apps per persona: 50
- Unique apps after dedupe: ${uniqueObjects.length}
- Duplicate persona placements: ${rawObjects.length - uniqueObjects.length}
- Rows missing from reviewed table: ${mismatchRows.filter((row) => row.tagComparison === "missing_from_review_table").length}
- Tag mismatches vs reviewed table: ${mismatchRows.filter((row) => row.tagComparison !== "missing_from_review_table").length}
- Invalid tag rows: ${invalidRawRows.length}
## Personas
${personaLines}
## Tag Distribution Across 1,000 Persona Rows
${tagLines}
## Most Cross-persona Apps
${topDuplicateLines}
## Review-table Mismatches
${mismatchLines}
## Notes
- Because this pass is intentionally cross-checked against the reviewed master table, a zero-mismatch result means the persona table is consistent with the latest simplified review table.
- The raw table preserves duplicate apps across occupations. The unique table is for dedupe review and later merge planning.
- A future higher-rigor pass can add row-level web evidence URLs for the top 10-20 apps per occupation, but this pass is already useful for coverage and tag sanity checks.
`;
fs.writeFileSync(reportOutputPath, report);
return {
rawRows: rawObjects.length,
personas: personas.length,
uniqueApps: uniqueObjects.length,
duplicatePlacements: rawObjects.length - uniqueObjects.length,
invalidRows: invalidRawRows.length,
mismatches: mismatchRows.length,
rawOutputPath,
uniqueOutputPath,
reportOutputPath,
};
}
console.log(JSON.stringify(build(), null, 2));