#!/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 existingReviewPath = path.join(researchDir, "AppDefaultTags_Review.csv");
const masterOutputPath = path.join(researchDir, "MacCommonApps_CandidateTop5000_Master.csv");
const simplifiedOutputPath = path.join(researchDir, "MacCommonApps_DefaultTags_SimplifiedReview.csv");
const notesOutputPath = path.join(researchDir, "ExpandedCandidateLibraryNotes.md");
const targetCount = 5000;
const urls = {
casks: "https://formulae.brew.sh/api/cask.json",
cask30d: "https://formulae.brew.sh/api/analytics/cask-install/homebrew-cask/30d.json",
cask365d: "https://formulae.brew.sh/api/analytics/cask-install/homebrew-cask/365d.json",
};
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) {
if (!fs.existsSync(filePath)) return [];
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 addTag(tags, tag) {
if (stableTags.has(tag) && !tags.includes(tag)) {
tags.push(tag);
}
}
function orderedTags(tags) {
const cleaned = unique(tags).filter((tag) => stableTags.has(tag));
if (cleaned.length > 1) {
return stableTagOrder.filter((tag) => tag !== "other" && cleaned.includes(tag));
}
return stableTagOrder.filter((tag) => cleaned.includes(tag));
}
function artifactTypes(cask) {
return unique((cask.artifacts ?? []).flatMap((artifact) => Object.keys(artifact)));
}
function hasArtifact(cask, type) {
return artifactTypes(cask).includes(type);
}
function primaryName(cask) {
if (Array.isArray(cask.name) && cask.name.length > 0) {
return cask.name[0];
}
return cask.token;
}
function analyticsCount(payload, token) {
const entries = payload?.formulae?.[token] ?? payload?.formulae?.[primaryTokenCase(token)] ?? [];
const first = Array.isArray(entries) ? entries[0] : undefined;
return Number(String(first?.count ?? "0").replace(/,/g, "")) || 0;
}
function primaryTokenCase(token) {
return token
.split("-")
.map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part)
.join("-");
}
function matchesAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function textFor(app) {
return [
app.name,
app.normalizedName,
app.homebrewToken,
app.bundleIdentifier,
app.description,
].join(" ").toLowerCase();
}
const tagRules = [
["browser", [/web browser|browser|(^|[^a-z])(chrome|chromium|firefox|safari|edge|brave|arc|vivaldi|opera|orion|duckduckgo|waterfox|librewolf|tor-browser|mullvad-browser)([^a-z]|$)/]],
["communication", [/chat|messag|video conferenc|meeting|mail|email|calendar|(^|[^a-z])(slack|discord|zoom|teams|telegram|signal|whatsapp|wechat|qq|skype|line|irc|matrix|mastodon|outlook|thunderbird|mimestream)([^a-z]|$)/]],
["ai-tools", [/artificial intelligence|large language|(^|[^a-z])(ai|llm|chatgpt|claude|gemini|perplexity|poe|copilot|ollama|lm-studio|diffusion|comfyui|stable-diffusion|midjourney|runway)([^a-z]|$)/]],
["development", [/developer|development|programming|coding|source code|code editor|(^|[^a-z])ide([^a-z]|$)|terminal|shell|api client|database|sql|git|diff|merge|docker|container|kubernetes|postman|insomnia|xcode|visual-studio-code|vscode|cursor|windsurf|zed|sublime|jetbrains|intellij|pycharm|webstorm|android-studio|emacs|vim|iterm|warp|ghostty|orbstack|tableplus|dbeaver|mongodb|redis/]],
["design", [/design|prototype|wireframe|vector|illustration|layout|figma|sketch|canva|adobe|photoshop|illustrator|indesign|affinity|pixelmator|blender|cinema|maya|freecad|openscad|draw\.?io|diagram/]],
["writing", [/writing|markdown|note|notebook|journal|word processor|text editor|document|zettelkasten|obsidian|notion|bear|ulysses|typora|scrivener|pages|craft|logseq|evernote|onenote|joplin|zotero|bibtex|reference manager/]],
["productivity", [/productivity|task|todo|project management|kanban|calendar|reminder|mind map|spreadsheet|presentation|office|workflow|automation|launcher|clipboard|raycast|alfred|things|todoist|omnifocus|fantastical|trello|asana|linear|jira|confluence|miro|xmind|mindnode|excel|powerpoint|keynote|numbers|libreoffice/]],
["file-management", [/file manager|file browser|finder|commander|ftp|sftp|file transfer|cloud storage|disk catalog|path-finder|forklift|transmit|cyberduck|filezilla|qspace|eagle|openmtp|mounty|cloudmounter|mountain-duck/]],
["transfer", [/download|upload|torrent|sync|backup|cloud storage|file transfer|ftp|sftp|bittorrent|transmission|qbittorrent|downie|jdownloader|folx|motrix|free-download-manager|localsend|syncthing|dropbox|google-drive|onedrive|baidu|aliyun/]],
["video", [/video|movie|media player|screen recording|screen recorder|video streaming|transcode|subtitle|(^|[^a-z])(vlc|iina|mpv|quicktime|final cut|premiere|davinci|resolve|handbrake|obs|screenflow|camtasia|capcut|plex|infuse|elmedia|movist|mkv|losslesscut)([^a-z]|$)/]],
["audio", [/audio|music|sound|podcast|radio|midi|daw|spotify|apple music|audacity|logic pro|garageband|ableton|reaper|fl studio|djay|rekordbox|vox|tidal|deezer|qobuz|blackhole/]],
["picture-photo", [/photo|picture|image|raw|screenshot|screen capture|(^|[^a-z])icon([^a-z]|$)|svg|png|jpg|jpeg|heic|preview|photos|lightroom|capture one|imageoptim|xnview|darktable|rawtherapee|shottr|cleanshot|snagit|sip/]],
["system-enhancement", [/window manager|menu bar|keyboard|mouse|trackpad|clipboard|display|monitor|dock|notch|touch bar|automation|hotkey|shortcut|(^|[^a-z])(bettertouchtool|karabiner|rectangle|magnet|bartender|hammerspoon|swiftbar|aerospace|alt-tab|betterdisplay|monitorcontrol|maccy|hidden-bar|dozer|ice|amphetamine|caffeine|hazel|logi-options|mos|dockdoor)([^a-z]|$)/]],
["security", [/password|vpn|firewall|security|encrypt|privacy|authenticator|keychain|gpg|pgp|yubico|1password|bitwarden|lastpass|dashlane|keepass|proton-vpn|nordvpn|expressvpn|mullvad|tailscale|wireguard|little-snitch|lulu|malwarebytes|authy|keybase|clash|surge/]],
["game", [/game|gaming|strategy game|(^|[^a-z])(minecraft|steam|epic games|gog|battle.net|roblox|itch|openemu|dolphin|ryujinx|pcsx|ppsspp|playcover|whisky|crossover|heroic|league of legends|riot)([^a-z]|$)/]],
["finance", [/finance|money|accounting|budget|trading|stock|crypto|wallet|bank|ledger|exodus|metamask|ynab|moneywiz|tradingview/]],
["education", [/education|learning|dictionary|flashcard|course|research|study|anki|duolingo|coursera|edx|kindle|calibre|marginnote|liquidtext|mendeley|endnote|papers/]],
["entertainment", [/entertainment|streaming|youtube|netflix|prime video|disney|hulu|bilibili|iqiyi|youku|twitch|plex|emby|jellyfin|spotify|podcast|steam|game/]],
["system", [/apple system seed|system settings|system preferences|activity monitor|disk utility|console|automator|time machine|keychain access|migration assistant/]],
["utilities", [/utility|utilities|menu bar|status bar|archive|compress|extract|zip|battery|clean|monitor|scanner|remote desktop|virtualization|virtual machine|hardware|driver|printer|firmware|unarchiver|keka|betterzip|istat|stats|cleanmymac|appcleaner|onyx|daisydisk|balenaetcher|raspberry pi imager|utm|virtualbox|parallels|vmware|rustdesk/]],
];
function inferTags(app, seed) {
const tags = [];
const seedTags = splitTags(seed?.defaultTagIDs).filter((tag) => tag !== "other");
for (const tag of seedTags) addTag(tags, tag);
const text = textFor(app);
for (const [tag, patterns] of tagRules) {
if (matchesAny(text, patterns)) addTag(tags, tag);
}
if (tags.includes("ai-tools") && !tags.includes("development") && matchesAny(text, [/coding|code|developer|terminal|llm studio|ollama|comfyui|diffusion/])) {
addTag(tags, "development");
}
if (tags.includes("video") || tags.includes("audio") || tags.includes("picture-photo")) {
addTag(tags, "media");
}
if (tags.includes("game")) {
addTag(tags, "entertainment");
}
if (tags.includes("security") || tags.includes("system-enhancement") || tags.includes("file-management") || tags.includes("transfer")) {
addTag(tags, "utilities");
}
if (tags.includes("communication") && matchesAny(text, [/meeting|conference|slack|teams|zoom|webex|lark|feishu|dingtalk/])) {
addTag(tags, "productivity");
}
if (text.includes("command-line") && app.normalizedName !== "line") {
const index = tags.indexOf("communication");
if (index >= 0) tags.splice(index, 1);
}
if (tags.length === 0 && app.guiLikely) {
addTag(tags, "utilities");
}
if (tags.length === 0) {
addTag(tags, "other");
}
return orderedTags(tags);
}
function reviewStatus(app, tags, seed) {
if (seed && !splitTags(seed.defaultTagIDs).includes("other")) return "seed_inherited_review";
if (!app.guiLikely) return "non_gui_or_support_review";
if (tags.includes("other")) return "needs_classification";
if (app.rank <= 500) return "top500_needs_human_review";
if (tags.length > 1) return "multi_tag_heuristic_review";
return "single_tag_heuristic_review";
}
function confidence(app, tags, seed) {
if (seed && !splitTags(seed.defaultTagIDs).includes("other")) return "0.78";
if (tags.includes("other")) return "0.24";
if (!app.guiLikely) return "0.36";
if (app.rank <= 500 && tags.length > 1) return "0.70";
if (app.rank <= 500) return "0.64";
if (tags.length > 1) return "0.58";
return "0.46";
}
function buildSeedMap() {
const rows = readCSVObjects(existingReviewPath);
const bySlug = new Map();
const byBundle = new Map();
for (const row of rows) {
const slug = row.normalizedName || slugify(row.name);
if (slug) bySlug.set(slug, row);
if (row.bundleIdentifier) byBundle.set(row.bundleIdentifier, row);
}
return { rows, bySlug, byBundle };
}
async function fetchJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} failed: ${response.status}`);
}
return response.json();
}
async function build() {
const [casks, cask30d, cask365d] = await Promise.all([
fetchJSON(urls.casks),
fetchJSON(urls.cask30d),
fetchJSON(urls.cask365d),
]);
const seed = buildSeedMap();
const candidates = [];
for (const cask of casks) {
const types = artifactTypes(cask);
if (types.includes("font")) continue;
const name = primaryName(cask);
const normalizedName = slugify(name);
const seedRow = seed.bySlug.get(normalizedName);
const count30d = analyticsCount(cask30d, cask.token);
const count365d = analyticsCount(cask365d, cask.token);
const guiLikely = types.some((type) => ["app", "suite", "pkg", "installer", "prefpane", "screen_saver", "input_method"].includes(type));
const score = (count365d * 1.0) + (count30d * 12) + (seedRow ? 1_000_000 : 0) + (guiLikely ? 25_000 : 0);
candidates.push({
name,
normalizedName,
bundleIdentifier: seedRow?.bundleIdentifier ?? "",
homebrewToken: cask.token,
description: cask.desc ?? "",
artifactTypes: types,
sourceEvidence: unique([
seedRow ? "existing_review_seed" : "",
"homebrew_cask_api",
count30d > 0 ? "homebrew_cask_30d" : "",
count365d > 0 ? "homebrew_cask_365d" : "",
]),
homebrew30dCount: count30d,
homebrew365dCount: count365d,
guiLikely,
score,
seedRow,
regionHints: seedRow?.regionHints ?? "",
notes: seedRow?.notes ?? "",
});
}
for (const row of seed.rows) {
const normalizedName = row.normalizedName || slugify(row.name);
if (candidates.some((candidate) => candidate.normalizedName === normalizedName)) continue;
candidates.push({
name: row.name,
normalizedName,
bundleIdentifier: row.bundleIdentifier ?? "",
homebrewToken: "",
description: "",
artifactTypes: [],
sourceEvidence: ["existing_review_seed"],
homebrew30dCount: 0,
homebrew365dCount: 0,
guiLikely: true,
score: 900_000 - Number(row.rank || 9999),
seedRow: row,
regionHints: row.regionHints ?? "",
notes: row.notes ?? "",
});
}
const seenNormalizedNames = new Set();
const selected = [];
for (const candidate of candidates.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name))) {
const key = candidate.normalizedName || slugify(candidate.name);
if (seenNormalizedNames.has(key)) continue;
seenNormalizedNames.add(key);
selected.push({ ...candidate, rank: selected.length + 1 });
if (selected.length >= targetCount) break;
}
const masterRows = [[
"rank",
"name",
"defaultTagIDs",
"bundleIdentifier",
"normalizedName",
"homebrewToken",
"description",
"artifactTypes",
"sourceEvidence",
"homebrew30dCount",
"homebrew365dCount",
"tagConfidence",
"reviewStatus",
"regionHints",
"notes",
]];
const simplifiedRows = [["name", "defaultTagIDs"]];
let otherCount = 0;
let multiTagCount = 0;
const tagCounts = new Map();
for (const app of selected) {
const tags = inferTags(app, app.seedRow);
if (tags.includes("other")) otherCount += 1;
if (tags.length > 1) multiTagCount += 1;
for (const tag of tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
masterRows.push([
app.rank,
app.name,
tags.join("|"),
app.bundleIdentifier,
app.normalizedName,
app.homebrewToken,
app.description,
app.artifactTypes.join("|"),
app.sourceEvidence.join("|"),
app.homebrew30dCount,
app.homebrew365dCount,
confidence(app, tags, app.seedRow),
reviewStatus(app, tags, app.seedRow),
app.regionHints,
app.notes,
]);
simplifiedRows.push([app.name, tags.join("|")]);
}
fs.writeFileSync(masterOutputPath, `${stringifyCSV(masterRows)}\n`);
fs.writeFileSync(simplifiedOutputPath, `${stringifyCSV(simplifiedRows)}\n`);
const topTags = [...tagCounts.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([tag, count]) => `- \`${tag}\`: ${count}`)
.join("\n");
const notes = `# Expanded Candidate Library Notes
Generated: ${new Date().toISOString()}
## Outputs
- Master table: \`Research/SmartStart/MacCommonApps_CandidateTop5000_Master.csv\`
- Simplified review table: \`Research/SmartStart/MacCommonApps_DefaultTags_SimplifiedReview.csv\`
## Scope
This pass expands Smart Start discovery from the old 1,000-row candidate list to a 5,000-row candidate library. It uses Homebrew's public cask API plus Homebrew 30-day and 365-day cask install analytics, then folds in the existing reviewed Apptag seed table.
The simplified review table intentionally has only:
- \`name\`
- \`defaultTagIDs\`
The master table keeps evidence, artifact type, confidence, and review status so we can audit decisions later.
## Counts
- Selected rows: ${selected.length}
- Multi-tag rows: ${multiTagCount}
- Rows still tagged \`other\`: ${otherCount}
- \`other\` rate: ${(otherCount / selected.length * 100).toFixed(1)}%
## Tag Distribution
${topTags}
## Quality Notes
- Rows marked \`seed_inherited_review\` came from the previous reviewed Apptag table where possible.
- Rows marked \`top500_needs_human_review\` should be reviewed before promotion into production Smart Start data.
- Rows marked \`non_gui_or_support_review\` are retained for discovery but should usually not ship unless verified as user-facing apps.
- The current pass intentionally reduces \`other\` by assigning broad but useful tags such as \`utilities\` when a GUI app has no stronger signal. Low-confidence rows remain visible in the master table.
`;
fs.writeFileSync(notesOutputPath, notes);
return {
selectedRows: selected.length,
multiTagRows: multiTagCount,
otherRows: otherCount,
otherRate: `${(otherCount / selected.length * 100).toFixed(1)}%`,
masterOutputPath,
simplifiedOutputPath,
};
}
build()
.then((summary) => console.log(JSON.stringify(summary, null, 2)))
.catch((error) => {
console.error(error);
process.exit(1);
});