diff --git a/extension/popup.html b/extension/popup.html index 61d4025..bb0cea2 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -13,10 +13,10 @@

Browstack

-
本機服務檢查中…
-
待送佇列
-
累計擷取
-

資料只送往你電腦上的 127.0.0.1,永不進雲端。

+
本機服務檢查中…
+
待送佇列
+
累計擷取
+

資料只送往你電腦上的 127.0.0.1,永不進雲端。

diff --git a/extension/src/popup.ts b/extension/src/popup.ts index 8e16e40..8bfd9e3 100644 --- a/extension/src/popup.ts +++ b/extension/src/popup.ts @@ -1,5 +1,30 @@ import { SHARED } from "../../src/shared/settings.js"; +// The popup is a browser surface with no access to the Node-side content-language config, +// so it localizes off the browser UI language: zh* → Traditional Chinese, otherwise English. +const zh = (navigator.language || "").toLowerCase().startsWith("zh"); +const L = zh + ? { + server: "本機服務", + queue: "待送佇列", + sent: "累計擷取", + checking: "檢查中…", + running: "運作中 ✓", + error: "異常", + down: "未啟動(npm run serve)", + privacy: "資料只送往你電腦上的 127.0.0.1,永不進雲端。", + } + : { + server: "Local service", + queue: "Pending queue", + sent: "Captured total", + checking: "Checking…", + running: "Running ✓", + error: "Error", + down: "Not running (npm run serve)", + privacy: "Data goes only to 127.0.0.1 on your computer — never to the cloud.", + }; + function put(id: string, text: string, cls?: string): void { const el = document.getElementById(id); if (!el) return; @@ -8,6 +33,13 @@ function put(id: string, text: string, cls?: string): void { } async function main(): Promise { + document.documentElement.lang = zh ? "zh-Hant" : "en"; + put("label-server", L.server); + put("label-queue", L.queue); + put("label-sent", L.sent); + put("privacy", L.privacy); + put("server", L.checking); + const { queue, stats } = await chrome.storage.local.get(["queue", "stats"]); put("queue", String(Array.isArray(queue) ? queue.length : 0)); put("sent", String(stats?.totalSent ?? 0)); @@ -15,9 +47,9 @@ async function main(): Promise { const res = await fetch(`http://127.0.0.1:${SHARED.serverPort}/health`, { signal: AbortSignal.timeout(800), }); - put("server", res.ok ? "運作中 ✓" : "異常", res.ok ? "ok" : "bad"); + put("server", res.ok ? L.running : L.error, res.ok ? "ok" : "bad"); } catch { - put("server", "未啟動(npm run serve)", "bad"); + put("server", L.down, "bad"); } } diff --git a/scripts/heartbeat.mjs b/scripts/heartbeat.mjs index a39604c..9afe44c 100644 --- a/scripts/heartbeat.mjs +++ b/scripts/heartbeat.mjs @@ -25,11 +25,11 @@ if (fs.existsSync(servePlist)) { serverOk = false; } if (!serverOk) { - console.error(`[heartbeat] ${stamp} — 接收服務 127.0.0.1:8787 無回應`); + console.error(`[heartbeat] ${stamp} — receiver service 127.0.0.1:8787 not responding`); try { spawnSync("osascript", [ "-e", - 'display notification "接收服務未運行——擷取資料可能流失。請重跑 npm run schedule:weekly,或檢查 data/logs/serve.log" with title "Browstack" sound name "Basso"', + 'display notification "Receiver service is not running — capture data may be lost. Rerun npm run schedule:weekly, or check data/logs/serve.log" with title "Browstack" sound name "Basso"', ]); } catch { /* ignore */ @@ -44,7 +44,7 @@ function home() { // No claude CLI (user is on the Anthropic API) → no credentials to keep fresh, exit silently const which = spawnSync("which", ["claude"], { encoding: "utf8" }); if (which.status !== 0) { - console.log(`[heartbeat] ${stamp} — 未安裝 claude CLI,略過`); + console.log(`[heartbeat] ${stamp} — claude CLI not installed, skipping`); process.exit(0); } @@ -62,7 +62,7 @@ for (const key of Object.keys(env)) { } const result = spawnSync("claude", ["-p"], { - input: "回覆 ok", + input: "reply ok", env, encoding: "utf8", timeout: 120_000, @@ -77,14 +77,14 @@ if (!failed) { } console.error( - `[heartbeat] ${stamp} — Claude CLI 憑證異常:${(result.stderr || result.stdout || "").slice(0, 160)}`, + `[heartbeat] ${stamp} — Claude CLI credential error: ${(result.stderr || result.stdout || "").slice(0, 160)}`, ); // Only alert if it once succeeded — never having succeeded means the user doesn't use the CLI provider at all, so don't bother them if (fs.existsSync(okMarker)) { try { spawnSync("osascript", [ "-e", - 'display notification "Claude CLI 憑證已失效——請在終端機執行 claude /login,否則週六無法自動出刊" with title "Browstack" sound name "Basso"', + 'display notification "Claude CLI credentials have expired — run claude /login in your terminal, otherwise Saturday auto-publish will fail" with title "Browstack" sound name "Basso"', ]); } catch { /* ignore */ diff --git a/scripts/install-weekly.mjs b/scripts/install-weekly.mjs index a6e71e8..dbc8aeb 100644 --- a/scripts/install-weekly.mjs +++ b/scripts/install-weekly.mjs @@ -39,10 +39,10 @@ if (probe.status !== 0) { const hint = (probe.stderr || "").split("\n").find((l) => /NODE_MODULE_VERSION|dlopen|better_sqlite3/i.test(l)) || (probe.stderr || "").slice(0, 200); - console.error("⚠ better-sqlite3 無法在此 node 版本載入,若繼續安裝,常駐接收服務會無法啟動:"); + console.error("⚠ better-sqlite3 cannot load under this node version; if you continue, the resident receiver service won't start:"); console.error(` node: ${nodeBin}`); console.error(` ${hint.trim()}`); - console.error(" 修法:npm rebuild better-sqlite3 (或改用與模組建置版本相符的 node 再重跑本指令)"); + console.error(" Fix: npm rebuild better-sqlite3 (or switch to a node matching the module's build version and rerun this command)"); process.exit(1); } @@ -109,7 +109,7 @@ function installAgent(agentLabel, xml) { spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // bootout the old version first; failure is fine const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { encoding: "utf8" }); if (boot.status !== 0) { - console.error(`launchctl bootstrap ${agentLabel} 失敗:${boot.stderr || boot.stdout}`); + console.error(`launchctl bootstrap ${agentLabel} failed: ${boot.stderr || boot.stdout}`); process.exit(1); } return plistPath; @@ -128,11 +128,11 @@ installAgent( ), ); -const dayNames = ["日", "一", "二", "三", "四", "五", "六"]; +const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const hh = (h) => String(h).padStart(2, "0"); -console.log(`已排程 / Scheduled: 每週${dayNames[day]} ${hh(hour)}:${hh(minute)} 自動出刊(${hh(retryHour)}:${hh(minute)} 當日重試,成功則自動跳過)`); -console.log(`憑證心跳 / heartbeat: 每天 09:37 保鮮 Claude CLI 憑證,失效即通知(未安裝 CLI 則自動略過)`); -console.log(`接收服務 / receiver: 常駐 127.0.0.1:8787(登入即啟、當掉自動重啟)`); +console.log(`Scheduled: auto-publish every ${dayNames[day]} at ${hh(hour)}:${hh(minute)} (${hh(retryHour)}:${hh(minute)} same-day retry, auto-skipped on success)`); +console.log(`heartbeat: keeps the Claude CLI credentials fresh every day at 09:37, notifies on expiry (auto-skipped if the CLI isn't installed)`); +console.log(`receiver: resident on 127.0.0.1:8787 (starts at login, auto-restarts on crash)`); console.log(`plist: ${weeklyPlistPath}`); -console.log(`日誌 / logs: ${logDir}/{weekly,heartbeat,serve}.log`); -console.log(`解除全部 / uninstall: for a in weekly heartbeat serve; do launchctl bootout gui/$UID/com.browstack.$a; rm ~/Library/LaunchAgents/com.browstack.$a.plist; done`); +console.log(`logs: ${logDir}/{weekly,heartbeat,serve}.log`); +console.log(`uninstall: for a in weekly heartbeat serve; do launchctl bootout gui/$UID/com.browstack.$a; rm ~/Library/LaunchAgents/com.browstack.$a.plist; done`); diff --git a/scripts/weekly.mjs b/scripts/weekly.mjs index 088376c..a1bbdd3 100644 --- a/scripts/weekly.mjs +++ b/scripts/weekly.mjs @@ -28,7 +28,7 @@ try { const row = db.prepare("SELECT MAX(sent_at) AS t FROM issues").get(); db.close(); if (row?.t && Date.now() / 1000 - row.t < 26 * 3600) { - console.log("[weekly] 26 小時內已成功出刊,跳過本次執行(重試時段的冪等保護)"); + console.log("[weekly] Already published successfully within the last 26 hours, skipping this run (idempotency guard for the retry slot)"); process.exit(0); } } catch { @@ -40,18 +40,18 @@ function run(script, { tolerate = false } = {}) { const result = spawnSync("npm", ["run", script], { stdio: "inherit" }); if (result.status !== 0) { if (tolerate) { - console.warn(`[weekly] ${script} 失敗(exit ${result.status}),流程繼續 / failed, continuing`); + console.warn(`[weekly] ${script} failed (exit ${result.status}), continuing`); return; } - console.error(`[weekly] ${script} 失敗(exit ${result.status}),出刊中止 / failed, aborting`); + console.error(`[weekly] ${script} failed (exit ${result.status}), aborting the issue`); notify( - `本週出刊失敗於 ${script}。常見原因:Claude CLI 憑證過期(跑 claude /login)。詳見 data/logs/weekly.log`, + `This week's issue failed at ${script}. Common cause: expired Claude CLI credentials (run claude /login). See data/logs/weekly.log`, ); process.exit(result.status ?? 1); } } -console.log(`[weekly] Browstack 出刊開始 / issue run started — ${new Date().toString()}`); +console.log(`[weekly] Browstack issue run started — ${new Date().toString()}`); run("ingest"); // An occasional enrich failure (LLM timeout, etc.) doesn't kill the whole issue: content enriched earlier this week can still publish; // if there's ultimately no content at all, email/send refuses to send an empty issue (see the safeguard in email.ts) @@ -61,4 +61,4 @@ run("cover", { tolerate: true }); // The week's reading sketch (the collection-showcase subtitle): LLM-generated; a failure doesn't block publishing, the issue just has no sketch subtitle run("digest", { tolerate: true }); run("send"); -console.log(`[weekly] 出刊完成 / done — ${new Date().toString()}`); +console.log(`[weekly] done — ${new Date().toString()}`); diff --git a/src/classify/filter.ts b/src/classify/filter.ts index c217281..0364348 100644 --- a/src/classify/filter.ts +++ b/src/classify/filter.ts @@ -34,6 +34,8 @@ const NOISE_HOST = [ /^[a-p]{32}$/, // chrome-extension:// /^(claude\.(ai|com)|chatgpt\.com|perplexity\.ai)$/, // AI chat tools are a work interface, not reading content /(^|\.)(pchome\.com\.tw|momoshop\.com\.tw|shopee\.tw|ruten\.com\.tw)$/, // shopping + /(^|\.)vscinemas\.com/, // cinema showtimes / ticketing — entertainment logistics, not reading + /(^|\.)(kktix\.com|accupass\.com|opentix\.life|ibon\.com\.tw|famiticket\.com\.tw)$/, // event ticketing platforms /^(platform|analytics|status|billing)\./, // developer consoles, billing, monitoring /console\.aws\.amazon\.com$/, /(^|\.)(sentry\.io|discord\.com|canva\.com|figma\.com|notion\.so|slack\.com)$/, // work tools diff --git a/src/cli.ts b/src/cli.ts index 4e6dffb..078ac84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,18 +12,18 @@ import { function cmdIngest(): void { const s = ingestChromeHistory(); - console.log(`已處理 ${s.visitsProcessed} 筆造訪(增量)`); - console.log(`頁面:新增 ${s.pagesNew}、更新 ${s.pagesUpdated};敏感頁面略過不儲存 ${s.sensitiveSkipped} 筆`); + console.log(`Processed ${s.visitsProcessed} visits (incremental)`); + console.log(`Pages: ${s.pagesNew} added, ${s.pagesUpdated} updated; ${s.sensitiveSkipped} sensitive pages skipped (not stored)`); const kinds = Object.entries(s.kinds) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `${k} ${v}`) .join("、"); - if (kinds) console.log(`造訪分類:${kinds}`); + if (kinds) console.log(`Visit classification: ${kinds}`); } function cmdStats(): void { const db = getDb(); - console.log("== 頁面分類統計 =="); + console.log("== Page classification stats =="); const byKind = db .prepare("SELECT kind, COUNT(*) AS n FROM pages GROUP BY kind ORDER BY n DESC") .all() as Array<{ kind: string; n: number }>; @@ -32,14 +32,14 @@ function cmdStats(): void { const byDevice = db .prepare("SELECT devices, COUNT(*) AS n FROM pages GROUP BY devices ORDER BY n DESC") .all() as Array<{ devices: string; n: number }>; - console.log("== 裝置來源 =="); + console.log("== Device sources =="); for (const r of byDevice) console.log(` ${r.devices.padEnd(8)} ${r.n}`); const captures = db .prepare("SELECT COUNT(*) AS n, SUM(content_text IS NOT NULL) AS with_text FROM captures") .get() as { n: number; with_text: number | null }; - console.log(`== Extension 擷取 ==`); - console.log(` 共 ${captures.n} 筆(含正文 ${captures.with_text ?? 0} 筆)`); + console.log(`== Extension captures ==`); + console.log(` ${captures.n} total (${captures.with_text ?? 0} with body text)`); const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400; // Real reading signal (the extension's active-reading seconds) takes priority; history dwell time is secondary @@ -60,11 +60,11 @@ function cmdStats(): void { active_min: number; devices: string; }>; - console.log("== 近 7 天高價值內容候選 =="); + console.log("== High-value content candidates, last 7 days =="); for (const r of top) { const host = new URL(r.url).hostname; - const signal = r.active_min > 0 ? `⚡${r.active_min} 分實讀` : `${r.minutes} 分停留`; - console.log(` [${signal}] ${r.title ?? "(無標題)"} — ${host}(${r.total_visits} 次造訪,${r.devices})`); + const signal = r.active_min > 0 ? `⚡${r.active_min} min active read` : `${r.minutes} min dwell`; + console.log(` [${signal}] ${r.title ?? "(untitled)"} — ${host} (${r.total_visits} visits, ${r.devices})`); } } @@ -94,7 +94,7 @@ function cmdReclassify(): void { } } })(); - console.log(`重新分類完成:更新 ${changed} 頁、清除敏感頁 ${purged} 頁(含其造訪紀錄)`); + console.log(`Reclassification complete: ${changed} pages updated, ${purged} sensitive pages purged (including their visit logs)`); } const cmd = process.argv[2]; @@ -121,15 +121,15 @@ switch (cmd) { case "apply": { const file = process.argv[3]; if (!file) { - console.error("用法:apply "); + console.error("Usage: apply "); process.exit(1); } const records = JSON.parse(fs.readFileSync(file, "utf8")) as EnrichmentRecord[]; const { updated, upgraded } = applyEnrichment(records); - console.log(`已套用 ${updated} 筆(unknown 升級為文章 ${upgraded} 筆)`); + console.log(`Applied ${updated} records (${upgraded} unknowns upgraded to articles)`); break; } default: - console.log("用法:ingest | stats | reclassify | enrich | candidates | fetch-content | apply "); + console.log("Usage: ingest | stats | reclassify | enrich | candidates | fetch-content | apply "); process.exit(1); } diff --git a/src/fetch/extract.ts b/src/fetch/extract.ts index bd02ee1..168072f 100644 --- a/src/fetch/extract.ts +++ b/src/fetch/extract.ts @@ -21,7 +21,7 @@ export async function fetchArticle(url: string): Promise { }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const contentType = res.headers.get("content-type") ?? ""; - if (!contentType.includes("html")) throw new Error(`非 HTML:${contentType}`); + if (!contentType.includes("html")) throw new Error(`Not HTML: ${contentType}`); const html = await res.text(); // Silence jsdom's CSS/resource parsing noise. @@ -29,7 +29,7 @@ export async function fetchArticle(url: string): Promise { const dom = new JSDOM(html, { url, virtualConsole }); const parsed = new Readability(dom.window.document).parse(); const text = parsed?.textContent?.trim(); - if (!parsed || !text) throw new Error("Readability 抽不出正文"); + if (!parsed || !text) throw new Error("Readability could not extract body text"); return { title: parsed.title || null, text: text.slice(0, SHARED.capture.maxTextLength), diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..f4ddf9e --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,146 @@ +/** + * UI strings for the rendered surfaces (issue page, email, archive showcase). + * LLM-generated content follows the reader's language via src/locale.ts; this table + * covers the fixed chrome. Traditional Chinese values are the project's original + * strings verbatim, so zh-TW output is unchanged; other locales fall back to English. + * + * To add a locale, add an entry to TABLE. Anything missing falls back to English. + */ + +export interface UIStrings { + // section headers / stat labels + deepReads: string; + socialEchoes: string; + weekInFigures: string; + statDeepReads: string; + statSocial: string; + statMinutes: string; + // item chrome + viewOriginal: string; + otherTopic: string; + socialSource: string; + device: (d: string) => string; + signal: (activeMin: number, minutes: number, capped: boolean) => string; + // issue intro / figures / colophon + issueNote: (footprint: number | null, arts: number, socials: number) => string; + figFootprint: (n: number) => string; + figMobile: (pct: number) => string; + figPages: (n: number) => string; + figMinutes: (n: number) => string; + colophonAuto: string; + colophonAudience: string; + // dates + date: (sec: number) => string; + // archive showcase + archiveTagline: string; + counts: (arts: number, socials: number) => string; + statusSent: (dateStr: string) => string; + statusEditing: string; + coverAlt: (n: number) => string; + archiveFooter: string; + // issue №0's special title + inauguralTitle: string; + // email + emailArchiveButton: string; + emailArchiveCaption: string; + emailSubject: (n: number, title: string, digest: string | null) => string; +} + +const MONTHS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +const en: UIStrings = { + deepReads: "This Week's Deep Reads", + socialEchoes: "Social Echoes", + weekInFigures: "The Week in Figures", + statDeepReads: "deep reads", + statSocial: "social echoes", + statMinutes: "content minutes", + viewOriginal: "View original", + otherTopic: "Other", + socialSource: "Social", + device: (d) => (d === "both" ? "desktop + mobile" : d === "mobile" ? "mobile" : "desktop"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ You actively read this for ${activeMin} min this week` + : `You spent ${minutes}${capped ? "+" : ""} min on this this week`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `Selected from your browsing over the past seven days — ${arts} deep reads and ${socials} social echoes, with editor's summaries.` + : `Selected from your ${footprint.toLocaleString()} page visits over the past seven days — ${arts} deep reads and ${socials} social echoes, with editor's summaries.`, + figFootprint: (n) => `Page visits ${n.toLocaleString()}`, + figMobile: (pct) => `On mobile ${pct}%`, + figPages: (n) => `Content pages ${n}`, + figMinutes: (n) => `Content minutes ${n}`, + colophonAuto: "auto-edited from your browsing record", + colophonAudience: "YOUR DATA NEVER LEFT THIS MACHINE · PUBLISHED FOR AN AUDIENCE OF ONE", + date: (sec) => { + const d = new Date(sec * 1000); + return `${MONTHS_EN[d.getMonth()]} ${d.getDate()}`; + }, + archiveTagline: "Archive · Your Personal Weekly Digest", + counts: (arts, socials) => `${arts} deep reads · ${socials} social echoes`, + statusSent: (dateStr) => `Sent ${dateStr}`, + statusEditing: "In progress", + coverAlt: (n) => `Issue ${n} cover`, + archiveFooter: "YOUR DATA NEVER LEFT THIS MACHINE · PUBLISHED FOR AN AUDIENCE OF ONE", + inauguralTitle: "Inaugural Preview", + emailArchiveButton: "Open your archive in the browser →", + emailArchiveCaption: "Opens on this Mac while Browstack is running", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n} — ${digest}` + : `Browstack №${n}${title ? " — " + title : ""} — your week in reading, in print`, +}; + +const zhTW: UIStrings = { + deepReads: "本週深讀", + socialEchoes: "社群迴響", + weekInFigures: "一週圖譜", + statDeepReads: "本週深讀", + statSocial: "社群迴響", + statMinutes: "內容分鐘", + viewOriginal: "查看原文", + otherTopic: "其他", + socialSource: "社群", + device: (d) => (d === "both" ? "桌機+手機" : d === "mobile" ? "手機" : "桌機"), + signal: (activeMin, minutes, capped) => + activeMin > 0 + ? `⚡ 本週你實讀了 ${activeMin} 分鐘` + : `本週你停留了 ${minutes}${capped ? "+" : ""} 分鐘`, + issueNote: (footprint, arts, socials) => + footprint == null + ? `本期選輯自你過去七天的瀏覽足跡——${arts} 篇深讀與 ${socials} 則社群迴響,附編輯摘要。` + : `本期選輯自你過去七天的 ${footprint.toLocaleString()} 次瀏覽足跡——${arts} 篇深讀與 ${socials} 則社群迴響,附編輯摘要。`, + figFootprint: (n) => `瀏覽足跡 ${n.toLocaleString()} 次`, + figMobile: (pct) => `手機佔比 ${pct}%`, + figPages: (n) => `內容頁造訪 ${n} 頁`, + figMinutes: (n) => `內容停留 ${n} 分鐘`, + colophonAuto: "由你的瀏覽紀錄自動編輯", + colophonAudience: "資料未離開這台機器 · PUBLISHED FOR AN AUDIENCE OF ONE", + date: (sec) => { + const d = new Date(sec * 1000); + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; + }, + archiveTagline: "典藏 · Your Personal Weekly Digest", + counts: (arts, socials) => `${arts} 篇深讀 · ${socials} 則社群迴響`, + statusSent: (dateStr) => `已寄出 ${dateStr}`, + statusEditing: "編輯中", + coverAlt: (n) => `第 ${n} 期封面插畫`, + archiveFooter: "資料未離開這台機器 · PUBLISHED FOR AN AUDIENCE OF ONE", + inauguralTitle: "創刊預覽號", + emailArchiveButton: "在瀏覽器開啟你的典藏 →", + emailArchiveCaption: "在這台 Mac 上、Browstack 服務運行時開啟", + emailSubject: (n, title, digest) => + digest + ? `Browstack №${n}|${digest}` + : `Browstack №${n}${title ? " — " + title : ""}|你的一週閱讀,成刊了`, +}; + +const TABLE: Record = { en, "zh-tw": zhTW }; + +// Resolve UI strings for a BCP-47 code. zh-* → Traditional Chinese; everything else → English. +export function ui(localeCode: string): UIStrings { + const c = localeCode.trim().toLowerCase(); + if (c === "zh-tw" || c.startsWith("zh")) return zhTW; + return TABLE[c] ?? en; +} diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts index 5b6c8da..1a79201 100644 --- a/src/llm/anthropic.ts +++ b/src/llm/anthropic.ts @@ -7,7 +7,7 @@ export class AnthropicProvider implements LLMProvider { async complete(opts: { system?: string; prompt: string; maxTokens?: number }): Promise { const key = process.env.ANTHROPIC_API_KEY; - if (!key) throw new Error("需要 ANTHROPIC_API_KEY 環境變數(或改用 claude-cli provider)"); + if (!key) throw new Error("ANTHROPIC_API_KEY environment variable required (or switch to the claude-cli provider)"); const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { diff --git a/src/llm/claudeCli.ts b/src/llm/claudeCli.ts index 193e171..213ee0a 100644 --- a/src/llm/claudeCli.ts +++ b/src/llm/claudeCli.ts @@ -38,7 +38,7 @@ export class ClaudeCliProvider implements LLMProvider { const timeoutMs = this.cliOpts.timeoutMs ?? 600_000; const timer = setTimeout(() => { child.kill(); - reject(new Error(`claude-cli 逾時(${Math.round(timeoutMs / 1000)} 秒)`)); + reject(new Error(`claude-cli timed out (${Math.round(timeoutMs / 1000)}s)`)); }, timeoutMs); child.stdout.on("data", (d: Buffer) => (out += d.toString())); child.stderr.on("data", (d: Buffer) => (err += d.toString())); @@ -53,7 +53,7 @@ export class ClaudeCliProvider implements LLMProvider { } else { reject( new Error( - `claude-cli exit ${code}: ${(err || out).slice(0, 300)}(若顯示未登入,請先在終端機執行 claude /login)`, + `claude-cli exit ${code}: ${(err || out).slice(0, 300)} (if it shows not logged in, run claude /login in the terminal first)`, ), ); } diff --git a/src/llm/image.ts b/src/llm/image.ts index b1482e7..d3b0a21 100644 --- a/src/llm/image.ts +++ b/src/llm/image.ts @@ -20,9 +20,9 @@ function getOpenAIKey(): string { // Not in the Keychain; fall through to throw an explicit error } throw new Error( - "找不到 OpenAI 金鑰。建議存進 macOS Keychain:\n" + - ` security add-generic-password -s browstack-openai -a "$USER" -w '<你的 key>' -U\n` + - "(或以 OPENAI_API_KEY 環境變數提供)", + "OpenAI key not found. Recommended: store it in the macOS Keychain:\n" + + ` security add-generic-password -s browstack-openai -a "$USER" -w '' -U\n` + + "(or provide it via the OPENAI_API_KEY environment variable)", ); } @@ -50,7 +50,7 @@ export class OpenAIImageProvider implements ImageProvider { if (!res.ok) throw new Error(`OpenAI Images ${res.status}: ${(await res.text()).slice(0, 300)}`); const data = (await res.json()) as { data: Array<{ b64_json?: string }> }; const b64 = data.data?.[0]?.b64_json; - if (!b64) throw new Error("圖像生成回應中沒有影像資料"); + if (!b64) throw new Error("No image data in the generation response"); return Buffer.from(b64, "base64"); } } diff --git a/src/llm/provider.ts b/src/llm/provider.ts index 317e465..accfc8d 100644 --- a/src/llm/provider.ts +++ b/src/llm/provider.ts @@ -27,7 +27,7 @@ export function getProvider(): LLMProvider { case "anthropic": return new AnthropicProvider(CONFIG.llm.model); case "ollama": - throw new Error("OllamaProvider 尚未實作(本機模型通道,依決策 #2 保留)"); + throw new Error("OllamaProvider not yet implemented (local-model channel, reserved per decision #2)"); } } @@ -35,6 +35,6 @@ export function getProvider(): LLMProvider { export function parseJsonReply(reply: string): T { const cleaned = reply.replace(/```(?:json)?/g, "").trim(); const start = cleaned.search(/[[{]/); - if (start === -1) throw new Error(`回覆中找不到 JSON:${cleaned.slice(0, 120)}`); + if (start === -1) throw new Error(`No JSON found in the reply: ${cleaned.slice(0, 120)}`); return JSON.parse(cleaned.slice(start)) as T; } diff --git a/src/locale.ts b/src/locale.ts index 83b8323..b26ba7d 100644 --- a/src/locale.ts +++ b/src/locale.ts @@ -2,9 +2,10 @@ import { getDb } from "./db.js"; import { USER_CONFIG } from "./shared/userConfig.js"; /** - * Resolves the language used for LLM-generated content (topic labels, summaries, - * the weekly reading digest, the cover concept). This is a global-facing project, - * so the default follows the language the user actually reads. + * Resolves the locale for the issue. LLM-generated content (topic labels, summaries, + * the reading digest, the cover concept) is written in this language; the fixed UI chrome + * (src/i18n.ts) uses the matching code. This is a global-facing project, so the default + * follows the language the user actually reads. * * Resolution order: * 1. Explicit override — USER_CONFIG.contentLanguage, when set and not "auto". @@ -14,17 +15,16 @@ import { USER_CONFIG } from "./shared/userConfig.js"; * because Chrome/extension language tags are frequently empty. * 4. English as the ultimate fallback. * - * Image-generation prompts stay English regardless (image models expect English); - * only the human-readable concept text follows this language. + * Image-generation prompts stay English regardless (image models expect English). */ // BCP-47-ish code → human language name used inside prompts ("write in "). const LANG_NAMES: Record = { en: "English", - zh: "Traditional Chinese", "zh-tw": "Traditional Chinese", "zh-hant": "Traditional Chinese", "zh-hk": "Traditional Chinese", + zh: "Traditional Chinese", "zh-cn": "Simplified Chinese", "zh-hans": "Simplified Chinese", ja: "Japanese", @@ -38,15 +38,30 @@ const LANG_NAMES: Record = { ru: "Russian", }; -function nameForCode(code: string): string | null { - const c = code.trim().toLowerCase(); - if (LANG_NAMES[c]) return LANG_NAMES[c]; - const base = c.split("-")[0]; - return LANG_NAMES[base] ?? null; +// Native names a user might type in the override. +const NATIVE_TO_CODE: Record = { + "繁體中文": "zh-tw", + "简体中文": "zh-cn", + "日本語": "ja", + "한국어": "ko", + english: "en", +}; + +function nameForCode(code: string): string { + const c = code.toLowerCase(); + return LANG_NAMES[c] ?? LANG_NAMES[c.split("-")[0]] ?? "English"; } -// Dominant language among pages that carry a usable tag (empty/null/und/zz filtered out). -// Returns null when the signal is too thin to trust. +// Accept a BCP-47 code or a plain/native language name; return a code. +function normalizeToCode(input: string): string { + const s = input.trim().toLowerCase(); + if (LANG_NAMES[s]) return s; + const byName = Object.entries(LANG_NAMES).find(([, name]) => name.toLowerCase() === s); + if (byName) return byName[0]; + return NATIVE_TO_CODE[input.trim()] ?? NATIVE_TO_CODE[s] ?? s; +} + +// Dominant language code among pages that carry a usable tag; null if signal too thin. function fromLangColumn(): string | null { const rows = getDb() .prepare( @@ -56,8 +71,8 @@ function fromLangColumn(): string | null { ) .all() as Array<{ lang: string; n: number }>; const total = rows.reduce((a, r) => a + r.n, 0); - if (total < 5) return null; // too little signal — fall through to the script heuristic - return nameForCode(rows[0].lang); + if (total < 5) return null; + return rows[0].lang.toLowerCase(); } // Script heuristic over recent reading — robust when language tags are missing. @@ -84,17 +99,26 @@ function fromScript(): string | null { } const max = Math.max(kana, hangul, cjk, latin); if (max === 0) return null; - if (max === kana) return "Japanese"; - if (max === hangul) return "Korean"; - if (max === cjk) return "Traditional Chinese"; // can't tell trad/simp by script; lang column covers Simplified - return "English"; + if (max === kana) return "ja"; + if (max === hangul) return "ko"; + if (max === cjk) return "zh-tw"; // can't tell trad/simp by script; the lang column covers Simplified + return "en"; } -export function resolveContentLanguage(): string { +function detectCode(): string { const override = (USER_CONFIG as { contentLanguage?: string }).contentLanguage; - if (override && override.trim().toLowerCase() !== "auto") { - // Accept either a BCP-47 code ("en", "ja") or a plain language name ("English"). - return nameForCode(override) ?? override.trim(); - } - return fromLangColumn() ?? fromScript() ?? "English"; + if (override && override.trim().toLowerCase() !== "auto") return normalizeToCode(override); + return fromLangColumn() ?? fromScript() ?? "en"; +} + +// Single source of truth: a BCP-47 code (for the UI table + html lang) and the +// human language name (for LLM prompts). +export function resolveContentLocale(): { code: string; name: string } { + const code = detectCode(); + return { code, name: nameForCode(code) }; +} + +// Language name for LLM prompts ("write in "). +export function resolveContentLanguage(): string { + return resolveContentLocale().name; } diff --git a/src/pipeline/enrich.ts b/src/pipeline/enrich.ts index a08e978..b286915 100644 --- a/src/pipeline/enrich.ts +++ b/src/pipeline/enrich.ts @@ -78,13 +78,18 @@ export async function classifyCandidates(candidates: Candidate[]): Promise ({ id: c.id, kind: c.kind, title: c.title, host: new URL(c.url).hostname })); const reply = await provider.complete({ system: - "You are the commissioning editor of the Browstack personal weekly digest. Keep only knowledge-type " + - "content: technology, AI, industry analysis, business insight, substantive public-affairs pieces, " + - "professional knowledge, and social posts with a real point of view or informational value. " + - "Always exclude: entertainment gossip, lotteries, shopping promos, membership drives, site sections or " + - "list pages (not a single piece), pure chatter or venting, ad/marketing pages, and all 'quick lookup' " + - "behavior — encyclopedia entries, dictionary/word lookups, weather, prize checks — which is looking " + - "things up, not reading.", + "You are the commissioning editor of the Browstack personal weekly digest. Knowledge is a HARD gate: keep " + + "only a piece that teaches, explains, analyzes, or argues something with real substance worth remembering — " + + "technology, AI, industry analysis, business insight, substantive public-affairs pieces, professional " + + "knowledge, and social posts with a genuine point of view or informational value. " + + "Everything else is is_knowledge:false, no matter how long it was viewed. Always exclude: " + + "entertainment gossip; lotteries and prize checks; shopping promos, product/store pages and menus; " + + "membership drives and ads/marketing; movie showtimes, cinema/ticketing/booking/reservation and event " + + "signup pages; schedules, timetables and any transactional or logistics page (checkout, order status, " + + "seat selection); site sections or list/index pages (not a single piece); pure chatter or venting; and " + + "all 'quick lookup' behavior — encyclopedia entries, dictionary/word lookups, weather. If a page is about " + + "DOING or BUYING something (attending, booking, ordering, watching) rather than UNDERSTANDING something, " + + "it is not knowledge.", prompt: `Classify the candidates below. Return a JSON array; each item ` + `{"id": number, "is_knowledge": boolean, "topic": "a short topic label in ${lang} (2–4 words, or 2–6 characters for CJK)"} ` + @@ -117,7 +122,7 @@ export async function fetchMissingContent(limit = 12): Promise<{ fetched: number const article = await fetchArticle(t.url); save.run(article.text, article.title, t.id); fetched++; - console.log(` ✓ ${new URL(t.url).hostname}(${article.text.length} 字)`); + console.log(` ✓ ${new URL(t.url).hostname} (${article.text.length} chars)`); } catch (e) { failed++; console.log(` ✗ ${new URL(t.url).hostname}:${String(e).slice(0, 80)}`); @@ -231,21 +236,21 @@ export async function summarizeKnowledgePages(): Promise { // Fully automated enrich: called weekly by the scheduler export async function enrich(): Promise { const candidates = getCandidates(); - console.log(`候選 ${candidates.length} 項,交由 ${getProvider().name} 分類…`); + console.log(`${candidates.length} candidates, handing off to ${getProvider().name} for classification…`); if (candidates.length > 0) { let records: EnrichmentRecord[]; try { records = await classifyCandidates(candidates); } catch (e) { - console.log(`分類失敗(${String(e).slice(0, 120)}),重試一次…`); + console.log(`Classification failed (${String(e).slice(0, 120)}), retrying once…`); records = await classifyCandidates(candidates); } const { updated, upgraded } = applyEnrichment(records); - console.log(`已分類 ${updated} 項(unknown 升級為文章 ${upgraded} 項)`); + console.log(`Classified ${updated} items (${upgraded} unknowns upgraded to articles)`); } - console.log("補抓知識型文章正文…"); + console.log("Fetching body text for knowledge articles…"); const { fetched, failed } = await fetchMissingContent(); - console.log(`抓到 ${fetched} 篇、失敗 ${failed} 篇`); + console.log(`Fetched ${fetched}, failed ${failed}`); const summarized = await summarizeKnowledgePages(); - console.log(`完成 ${summarized} 份摘要`); + console.log(`Completed ${summarized} summaries`); } diff --git a/src/render/archive.ts b/src/render/archive.ts index 7f95152..5ab6df4 100644 --- a/src/render/archive.ts +++ b/src/render/archive.ts @@ -1,5 +1,7 @@ import { getDb } from "../db.js"; +import { ui } from "../i18n.js"; import { type Issue, issueDigest, listIssues } from "../issue.js"; +import { resolveContentLocale } from "../locale.js"; import { esc } from "../shared/html.js"; import { renderIssueDocument, type IssueStats } from "./issueView.js"; import type { IssueItem } from "./select.js"; @@ -14,10 +16,6 @@ import type { IssueItem } from "./select.js"; const CHROME_EPOCH_OFFSET_SEC = 11_644_473_600; const toChromeTime = (unixSec: number) => (unixSec + CHROME_EPOCH_OFFSET_SEC) * 1_000_000; -const fmtDate = (sec: number) => { - const d = new Date(sec * 1000); - return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; -}; // Recompute an issue's selected items of a given kind over the stored week window [start,end] (signals computed from in-window visits, consistent with the original publication). function reconstructItems(n: number, kind: "article" | "social", order: string): IssueItem[] { @@ -94,36 +92,47 @@ export function renderIssuePage(n: number): string | null { const articles = reconstructItems(n, "article", "active_min DESC, minutes DESC"); const socialPosts = reconstructItems(n, "social", "minutes DESC"); const stats = statsForWindow(issue.week_start, issue.week_end); + const locale = resolveContentLocale(); // Cover uses the same-origin route (findCover exactOnly: this issue's cover or the default, never borrow another issue's) - const coverHtml = `第 ${n} 期封面插畫`; - return renderIssueDocument({ issue, articles, socialPosts, stats, coverHtml, digest: issueDigest(n) }); + const coverHtml = `${ui(locale.code).coverAlt(n)}`; + return renderIssueDocument({ + issue, + articles, + socialPosts, + stats, + coverHtml, + digest: issueDigest(n), + localeCode: locale.code, + }); } // Archive showcase index (generated live from listIssues; links go to /issues/N, covers to /covers/N). export function renderArchiveIndex(): string { + const locale = resolveContentLocale(); + const t = ui(locale.code); const cards = listIssues() .map((i) => { - const label = i.title ? `№${i.number} · ${esc(i.title)}` : `№${i.number}`; - const status = i.sent_at ? `已寄出 ${fmtDate(i.sent_at)}` : "編輯中"; + const dt = i.number === 0 ? t.inauguralTitle : i.title; + const label = dt ? `№${i.number} · ${esc(dt)}` : `№${i.number}`; + const status = i.sent_at ? t.statusSent(t.date(i.sent_at)) : t.statusEditing; const { articles, social } = issueCounts(i.number); const digest = issueDigest(i.number); // Reading sketch for the week: a one-line editorial take on "what you were reading and thinking" this issue const digestHtml = digest ? `${esc(digest)}` : ""; // Stats subheading: how many deep reads, how many social echoes - const statsHtml = - articles + social > 0 ? `${articles} 篇深讀 · ${social} 則社群迴響` : ""; + const statsHtml = articles + social > 0 ? `${t.counts(articles, social)}` : ""; return ` -
${esc(label)} 封面
-
${label}${digestHtml}${statsHtml}${fmtDate(i.week_start)} — ${fmtDate(i.week_end)} · ${status}
+
${esc(t.coverAlt(i.number))}
+
${label}${digestHtml}${statsHtml}${t.date(i.week_start)} — ${t.date(i.week_end)} · ${status}
`; }) .join("\n"); return ` - + -Browstack 典藏 +Browstack ${locale.code.startsWith("zh") ? "典藏" : "Archive"} -

Browstack

Archive · Your Personal Weekly Digest

+

Browstack

${t.archiveTagline}

    ${archiveRows}
`; fs.writeFileSync(path.join(outDir, "index.html"), indexHtml); -console.log(`已產出:${outPath}`); +console.log(`Generated: ${outPath}`); diff --git a/src/render/send.ts b/src/render/send.ts index 91a15e6..183b6b8 100644 --- a/src/render/send.ts +++ b/src/render/send.ts @@ -4,7 +4,9 @@ import path from "node:path"; import nodemailer from "nodemailer"; import { ensureArchiveToken } from "../archiveToken.js"; import { CONFIG } from "../config.js"; -import { findCover, getCurrentIssue, markIssueSent } from "../issue.js"; +import { ui } from "../i18n.js"; +import { findCover, getCurrentIssue, issueDigest, markIssueSent } from "../issue.js"; +import { resolveContentLocale } from "../locale.js"; import { SHARED } from "../shared/settings.js"; /** @@ -25,15 +27,16 @@ function getSmtpPassword(): string { // fall through } throw new Error( - "找不到 SMTP 密碼。請先建立 Gmail 應用程式密碼並存入 Keychain:\n" + - ` security add-generic-password -s browstack-smtp -a ${CONFIG.email.from} -w '<應用程式密碼>' -U`, + "SMTP password not found. Create a Gmail app password and store it in the Keychain first:\n" + + ` security add-generic-password -s browstack-smtp -a ${CONFIG.email.from} -w '' -U`, ); } const issue = getCurrentIssue(); +const t = ui(resolveContentLocale().code); const emailPath = path.join(CONFIG.dataDir, "..", "out", `browstack-issue-${issue.number}.email.html`); if (!fs.existsSync(emailPath)) { - console.error("找不到 email 版,先跑 npm run email"); + console.error("Email version not found; run npm run email first"); process.exit(1); } let html = fs.readFileSync(emailPath, "utf8"); @@ -46,7 +49,7 @@ if (coverPath?.endsWith(".png") || coverPath?.endsWith(".jpg")) { attachments.push({ filename, path: coverPath, cid: "issue-cover" }); html = html.replace( "", - `本期封面`, + `${t.coverAlt(issue.number)}`, ); } @@ -54,8 +57,8 @@ if (coverPath?.endsWith(".png") || coverPath?.endsWith(".jpg")) { // The link works only on the same Mac while the receiver service is running (opening on a phone yields a dead link, an architectural limitation). const archiveUrl = `http://127.0.0.1:${SHARED.serverPort}/archive?k=${ensureArchiveToken()}`; const archiveButton = `
- 在瀏覽器開啟你的典藏 → -
在這台 Mac 上、Browstack 服務運行時開啟
+ ${t.emailArchiveButton} +
${t.emailArchiveCaption}
`; html = html.replace("", archiveButton); @@ -69,9 +72,13 @@ const transporter = nodemailer.createTransport({ const info = await transporter.sendMail({ from: `Browstack <${CONFIG.email.from}>`, to: CONFIG.email.to, - subject: `Browstack №${issue.number}${issue.title ? " — " + issue.title : ""}|你的一週閱讀,成刊了`, + subject: t.emailSubject( + issue.number, + issue.number === 0 ? t.inauguralTitle : issue.title, + issueDigest(issue.number), + ), html, attachments, }); markIssueSent(issue.number); // close the issue: the next run automatically starts a new one -console.log(`已寄出 №${issue.number}:${info.messageId} → ${CONFIG.email.to}`); +console.log(`Sent №${issue.number}: ${info.messageId} → ${CONFIG.email.to}`); diff --git a/src/server.ts b/src/server.ts index 81557cc..c8fb4a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -293,14 +293,14 @@ export function createBrowstackServer(opts: { getToken?: () => string | null } = const parsed = JSON.parse(raw) as { items?: CaptureItem[] }; if (!Array.isArray(parsed.items)) return sendJson(400, { ok: false, error: "items required" }); const result = handleBatch(parsed.items); - console.log(`[capture] 收到 ${parsed.items.length} 筆:落地 ${result.accepted}、略過 ${result.skipped}`); + console.log(`[capture] received ${parsed.items.length} items: stored ${result.accepted}, skipped ${result.skipped}`); return sendJson(200, { ok: true, ...result }); } sendJson(404, { ok: false }); } catch (e) { // Log only the error code, never echoing request input (which may contain sensitive values like tokens) into the response or logs. const code = (e as NodeJS.ErrnoException)?.code; - if (code) console.error(`[server] 請求處理失敗:${code}`); + if (code) console.error(`[server] request handling failed: ${code}`); sendJson(400, { ok: false }); } }); @@ -313,6 +313,6 @@ const isMain = if (isMain) { hardenPerms(); // Tighten permissions on existing data files at startup (no need to wait for the first DB access) createBrowstackServer().listen(SHARED.serverPort, BIND_ADDRESS, () => { - console.log(`browstack 本機接收服務:http://${BIND_ADDRESS}:${SHARED.serverPort}(只綁本機,資料不出機器)`); + console.log(`browstack local receiver service: http://${BIND_ADDRESS}:${SHARED.serverPort} (bound to localhost only, data never leaves this machine)`); }); } diff --git a/src/tools/archiveOpen.ts b/src/tools/archiveOpen.ts index 9afd3ea..c6e94f0 100644 --- a/src/tools/archiveOpen.ts +++ b/src/tools/archiveOpen.ts @@ -6,9 +6,9 @@ import { SHARED } from "../shared/settings.js"; const url = `http://127.0.0.1:${SHARED.serverPort}/archive?k=${ensureArchiveToken()}`; try { execFileSync("open", [url], { stdio: "ignore" }); - console.log("已在瀏覽器開啟你的典藏。"); + console.log("Opened your archive in the browser."); } catch { // Don't print the URL with the token (avoid it landing in the terminal/history) - console.error("無法自動開啟瀏覽器。請確認接收服務運行中(npm run serve 或已排程),再重試 npm run archive:open。"); + console.error("Could not open the browser automatically. Make sure the receiver service is running (npm run serve, or scheduled), then retry npm run archive:open."); process.exit(1); } diff --git a/src/tools/rotateToken.ts b/src/tools/rotateToken.ts index 27a94f1..d208e63 100644 --- a/src/tools/rotateToken.ts +++ b/src/tools/rotateToken.ts @@ -2,6 +2,6 @@ import { rotateArchiveToken } from "../archiveToken.js"; // Rotate the archive token: buttons in old emails stop working immediately; the next issue ships with a new link. rotateArchiveToken(); -console.log("已更新典藏 token(存入 Keychain: browstack-archive)。"); -console.log("舊信件的典藏按鈕即刻失效;下一期出刊會帶新連結。"); -console.log("要現在就開啟典藏,執行: npm run archive:open"); +console.log("Archive token rotated (stored in Keychain: browstack-archive)."); +console.log("Archive buttons in old emails stop working immediately; the next issue ships with a new link."); +console.log("To open the archive now, run: npm run archive:open");