#!/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);
|
});
|