diff --git a/extension/src/background.ts b/extension/src/background.ts index 7905836..9926b15 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,9 +1,10 @@ import { SHARED } from "../../src/shared/settings.js"; /** - * MV3 service worker:Chrome 閒置 ~30 秒即自動終結,天生不常駐記憶體。 - * 佇列放在磁碟上的 chrome.storage.local(有上限),送達本機服務後即刪除。 - * 唯一通訊對象:127.0.0.1。 + * MV3 service worker: Chrome auto-terminates it after ~30s idle, so it never + * stays resident in memory by nature. The queue lives in on-disk + * chrome.storage.local (bounded), and is deleted once delivered to the local + * service. Only communication target: 127.0.0.1. */ const ENDPOINT = `http://127.0.0.1:${SHARED.serverPort}`; @@ -15,7 +16,7 @@ interface Stats { lastError: string | null; } -// storage 讀改寫的簡易序列化,避免同 SW 實例內的競態 +// Simple serialization of storage read-modify-write to avoid races within the same SW instance. let chain: Promise = Promise.resolve(); function serialize(fn: () => Promise): Promise { const p = chain.then(fn); @@ -38,7 +39,7 @@ chrome.runtime.onMessage.addListener((msg: { event?: string }) => { void serialize(async () => { const queue = await getQueue(); queue.push(msg); - while (queue.length > MAX_QUEUE) queue.shift(); // 有界佇列:超過即丟最舊 + while (queue.length > MAX_QUEUE) queue.shift(); // Bounded queue: drop the oldest when exceeded. await chrome.storage.local.set({ queue }); }).then(() => flush()); }); @@ -61,7 +62,7 @@ function flush(): Promise { stats.lastFlushAt = Date.now(); stats.lastError = null; } catch (e) { - // 本機服務沒開:留在磁碟佇列,等 alarm 重試 + // Local service not running: leave it in the on-disk queue and retry on the next alarm. stats.lastError = String(e); } await chrome.storage.local.set({ stats }); diff --git a/extension/src/content.ts b/extension/src/content.ts index dc8db0d..973272c 100644 --- a/extension/src/content.ts +++ b/extension/src/content.ts @@ -3,10 +3,10 @@ import { classifyUrl, type PageKind } from "../../src/classify/filter.js"; import { SHARED } from "../../src/shared/settings.js"; /** - * 主動閱讀追蹤器。記憶體設計原則: - * - 每個分頁只維持一組計數器;分頁不可見時計時器完全停止 - * - 內文擷取只在跨過門檻那一刻執行一次,送出即丟,不在頁面端保留 - * - 敏感頁與雜訊頁從一開始就不追蹤 + * Active-reading tracker. Memory design principles: + * - Only one set of counters per tab; the timer fully stops when the tab is hidden. + * - Body extraction runs once at the moment the threshold is crossed, sent and discarded, never retained on the page. + * - Sensitive and noise pages are not tracked from the outset. */ const cfg = SHARED.capture; @@ -33,7 +33,7 @@ function send(msg: unknown): void { try { void chrome.runtime.sendMessage(msg); } catch { - // extension 重新載入後舊的 content script 會失去連線,靜默忽略 + // After the extension reloads, the old content script loses its connection; ignore silently. } } @@ -64,7 +64,7 @@ function stopTimer(): void { } } -// 只在分頁可見時跑 1 秒一次的計時器 +// Run the 1-second-interval timer only while the tab is visible. function syncTimer(): void { if (!tracker) return; const visible = document.visibilityState === "visible"; @@ -103,9 +103,9 @@ function extract(kind: PageKind): { title: string | null; excerpt: string | null } } } catch { - // Readability 失敗時走 fallback + // Fall back when Readability fails. } - // 社群貼文 / 非典型頁面:og meta +主要區塊文字(有上限) + // Social posts / atypical pages: og meta plus main-block text (capped). const meta = (name: string) => document.querySelector(`meta[property="${name}"], meta[name="${name}"]`)?.getAttribute("content") ?? null; const main = document.querySelector("article, main, [role='main']"); @@ -135,7 +135,7 @@ function capture(): void { }); } -// 離開/切走時回報最終閱讀量(僅已擷取的頁面) +// On leaving/switching away, report the final read amount (only for already-captured pages). function sendFinal(): void { if (!tracker?.captured) return; send({ @@ -158,7 +158,7 @@ document.addEventListener("visibilitychange", () => { }); addEventListener("pagehide", sendFinal); -// SPA 導航(Threads/FB/新聞站都是 SPA):URL 變了就結算上一頁、追蹤新頁 +// SPA navigation (Threads/FB/news sites are all SPAs): when the URL changes, finalize the previous page and track the new one. setInterval(() => { if (location.href !== currentUrl) { sendFinal(); diff --git a/scripts/ensure-config.mjs b/scripts/ensure-config.mjs index c61ac6d..90e893e 100644 --- a/scripts/ensure-config.mjs +++ b/scripts/ensure-config.mjs @@ -1,4 +1,4 @@ -// postinstall:首次安裝時建立個人設定檔(gitignored),讓 fresh clone 開箱即用 +// postinstall: on first install, create the personal config file (gitignored) so a fresh clone works out of the box import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/scripts/heartbeat.mjs b/scripts/heartbeat.mjs index ae51549..a39604c 100644 --- a/scripts/heartbeat.mjs +++ b/scripts/heartbeat.mjs @@ -1,8 +1,8 @@ -// Claude CLI 憑證保鮮心跳:每天以一個極小的呼叫讓 OAuth refresh 週期保持活躍, -// 避免閒置一週後過期害出刊失敗;偵測到「曾經正常、現在失效」時用通知中心告警。 +// Claude CLI credential-freshness heartbeat: a tiny daily call keeps the OAuth refresh cycle active, +// avoiding an idle-week expiry that would break publishing; alerts via Notification Center when it detects "was fine, now broken". // -// 對非 CLI 用戶無害:沒安裝 claude 就靜默跳過;從未成功過(代表用戶走 API key、 -// 從不使用 CLI)也不告警——只有真正的憑證衰變才會吵你。 +// Harmless for non-CLI users: silently skips if claude isn't installed; and if it never once succeeded (meaning the user is on an API key, +// never uses the CLI) it won't alert either — only genuine credential decay bothers you. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -12,13 +12,13 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const okMarker = path.join(repoRoot, "data", "logs", ".heartbeat-was-ok"); const stamp = new Date().toString(); -// 接收服務健康檢查:裝了常駐 serve agent 卻連不上 127.0.0.1:8787 → 擷取資料正在流失,當天告警。 -// 沒裝 serve agent 的用戶不檢查(避免對只手動使用的人天天誤報)。 +// Receiver health check: if the resident serve agent is installed but 127.0.0.1:8787 is unreachable → capture data is being lost; alert that day. +// Users without the serve agent aren't checked (avoids daily false alarms for manual-only users). const servePlist = path.join(home(), "Library", "LaunchAgents", "com.browstack.serve.plist"); if (fs.existsSync(servePlist)) { let serverOk = false; try { - // 埠號與 src/shared/settings.ts 的 SHARED.serverPort 綁定(皆為 8787);若那裡改埠,這裡要一起改。 + // The port is tied to SHARED.serverPort in src/shared/settings.ts (both 8787); if you change the port there, change it here too. const res = await fetch("http://127.0.0.1:8787/health", { signal: AbortSignal.timeout(2000) }); serverOk = res.ok; } catch { @@ -41,7 +41,7 @@ function home() { return process.env.HOME || ""; } -// 沒有 claude CLI(用戶走 Anthropic API)→ 無憑證可保鮮,靜默結束 +// 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,略過`); @@ -79,7 +79,7 @@ if (!failed) { console.error( `[heartbeat] ${stamp} — Claude CLI 憑證異常:${(result.stderr || result.stdout || "").slice(0, 160)}`, ); -// 只有「曾經成功過」才告警——從未成功代表用戶根本不用 CLI provider,不該吵他 +// 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", [ diff --git a/scripts/install-weekly.mjs b/scripts/install-weekly.mjs index 1840172..a6e71e8 100644 --- a/scripts/install-weekly.mjs +++ b/scripts/install-weekly.mjs @@ -1,6 +1,6 @@ -// 安裝 launchd 排程:每週自動出刊(macOS) +// Install the launchd schedule: automatic weekly publishing (macOS) // Usage: npm run schedule:weekly [-- --day 6 --hour 8 --minute 17] -// --day 0-6(0=週日…6=週六,預設 6) +// --day 0-6 (0=Sunday…6=Saturday, default 6) import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -24,13 +24,13 @@ const label = "com.browstack.weekly"; const logDir = path.join(repoRoot, "data", "logs"); fs.mkdirSync(logDir, { recursive: true }); -// PATH 需含 node/npm 與 claude CLI(launchd 環境極簡);含 Apple Silicon 的 /opt/homebrew +// PATH must include node/npm and the claude CLI (launchd's environment is minimal); includes Apple Silicon's /opt/homebrew const PATH = `${nodeDir}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:${home}/.local/bin`; -// 前置檢查:better-sqlite3 的原生模組必須能在「即將被釘用的這個 node」下載入。 -// 版本不符(例如從 Node 22 shell 執行,但模組是為 Node 20 建置)會讓常駐 server 靜默 crash-loop、 -// 落地資料流失。與其之後才發現,不如現在就擋下並給出明確修法。 -// 必須實際建構一個 DB——原生 .node 是在 new Database() 時才 dlopen,單純 require 不會觸發、會誤判為通過。 +// Preflight check: better-sqlite3's native module must load under the exact node we're about to pin. +// A version mismatch (e.g. run from a Node 22 shell but the module was built for Node 20) makes the resident server +// silently crash-loop and lose landed data. Better to block it now with a clear fix than discover it later. +// Must actually construct a DB — the native .node is only dlopen'd at new Database(); a bare require won't trigger it and would falsely pass. const probe = spawnSync(nodeBin, ["-e", "new (require('better-sqlite3'))(':memory:').close()"], { cwd: repoRoot, encoding: "utf8", @@ -46,7 +46,7 @@ if (probe.status !== 0) { process.exit(1); } -// 出刊有兩個時段:主跑+ 12 小時後的當日重試(weekly.mjs 有冪等保護,成功後重試自動跳過) +// Publishing has two slots: the main run + a same-day retry 12 hours later (weekly.mjs is idempotent, so the retry auto-skips after success) const retryHour = (hour + 12) % 24; const agentPlist = (agentLabel, programArgs, scheduleXml, logFile) => ` @@ -85,7 +85,7 @@ const weeklyCalendar = `StartCalendarInterval `; -// 心跳:每天一個極小的 claude 呼叫保鮮 CLI 憑證,失效時提前用通知中心告警 +// Heartbeat: a tiny daily claude call keeps the CLI credentials fresh, alerting via Notification Center before they expire const heartbeatLabel = "com.browstack.heartbeat"; const heartbeatCalendar = `StartCalendarInterval @@ -93,8 +93,8 @@ const heartbeatCalendar = `StartCalendarInterval Minute37 `; -// 閱讀訊號接收服務:extension 的落地端,常駐(登入即啟、當掉自動重啟) -// 只綁 127.0.0.1,記憶體佔用極小;不常駐的話 extension 的磁碟佇列(上限 300 筆)滿了會丟資料 +// Reading-signal receiver service: the extension's landing endpoint, resident (starts at login, auto-restarts on crash) +// Binds 127.0.0.1 only, tiny memory footprint; without a resident service the extension's disk queue (max 300 entries) fills up and drops data const serveLabel = "com.browstack.serve"; const serveSchedule = `RunAtLoad KeepAlive`; @@ -106,7 +106,7 @@ fs.mkdirSync(laDir, { recursive: true }); function installAgent(agentLabel, xml) { const plistPath = path.join(laDir, `${agentLabel}.plist`); fs.writeFileSync(plistPath, xml); - spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // 先卸舊版,失敗無妨 + 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}`); diff --git a/scripts/security-gates.sh b/scripts/security-gates.sh index 6a43d22..f405938 100644 --- a/scripts/security-gates.sh +++ b/scripts/security-gates.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# 安全不變式 grep 閘——任一命中即失敗。與 SECURITY.md 的「Security invariants」對應。 -# 開源專案的關鍵防迴歸:這些「看似無害」的改動,任一都可能危及每個安裝。 -# 本地執行:npm run security-gates +# Security-invariant grep gates — any hit fails. Mirrors the "Security invariants" in SECURITY.md. +# Key regression guard for an open-source project: any of these "harmless-looking" changes could endanger every install. +# Run locally: npm run security-gates set -uo pipefail cd "$(dirname "$0")/.." fail=0 -# 命中 pattern 即失敗(用於「不該出現」的東西)。 -# 忽略純註解行(//、*、#)——說明文字可以提到這些字面,只有實際程式碼才算違規。 +# Fail on a pattern hit (for things that "should not appear"). +# Ignore pure comment lines (//, *, #) — prose may mention these literals; only actual code counts as a violation. deny() { local desc="$1"; shift local pattern="$1"; shift @@ -23,17 +23,17 @@ deny() { fi } -# 綁定位址永遠本機迴環,絕不 0.0.0.0 +# The bind address is always the local loopback, never 0.0.0.0 deny "server binds 127.0.0.1 only (no 0.0.0.0)" '0\.0\.0\.0' src/ -# CSP:default-src 'none' 已封殺腳本,不得再出現 script-src / unsafe-eval +# CSP: default-src 'none' already blocks scripts; no script-src / unsafe-eval should appear deny "CSP has no script-src directive" 'script-src' src/ deny "CSP has no unsafe-eval" 'unsafe-eval' src/ -# jsdom 必須維持惰性預設(解析敵意 HTML;啟用腳本/資源=RCE/SSRF) +# jsdom must stay inert by default (it parses hostile HTML; enabling scripts/resources = RCE/SSRF) deny "jsdom stays inert (no runScripts / resources:usable)" "runScripts|resources:[[:space:]]*[\"']usable" src/ -# server.ts 的 Host 反 rebinding 檢查必須精確比對,不得用寬鬆字串比對 +# server.ts's Host anti-rebinding check must be an exact match, not a loose string comparison deny "server.ts Host check is exact (no includes/startsWith/endsWith)" '\.(includes|startsWith|endsWith)\(' src/server.ts -# PR2 起 archiveToken.ts 若存在:token 比對必須用 timingSafeEqual、且無字面 default +# From PR2 on, if archiveToken.ts exists: the token comparison must use timingSafeEqual, with no literal default if [ -f src/archiveToken.ts ]; then if ! grep -q "timingSafeEqual" src/archiveToken.ts; then echo "✗ GATE FAILED: archiveToken.ts must compare with crypto.timingSafeEqual" @@ -44,7 +44,7 @@ if [ -f src/archiveToken.ts ]; then deny "archive token has no hardcoded/default fallback" 'archive[_-]?token[^\n]*(\|\||\?\?)[[:space:]]*[\"'\''`]' src/ fi -# 個人資料檔絕不進版控 +# Personal data files must never be committed to version control tracked="$(git ls-files -- data/ out/ assets/covers/ src/shared/userConfig.ts 2>/dev/null || true)" if [ -n "$tracked" ]; then echo "✗ GATE FAILED: personal files are tracked:" @@ -54,7 +54,7 @@ else echo "✓ no personal data files tracked" fi -# .gitignore 仍涵蓋所有敏感路徑 +# .gitignore still covers all sensitive paths for p in data/ out/ assets/covers/ src/shared/userConfig.ts .env; do if git check-ignore -q "$p"; then echo "✓ ignored: $p" diff --git a/scripts/weekly.mjs b/scripts/weekly.mjs index eda544a..088376c 100644 --- a/scripts/weekly.mjs +++ b/scripts/weekly.mjs @@ -1,6 +1,6 @@ -// 每週出刊:ingest → enrich → cover → digest → send -// 由 launchd 排程呼叫(npm run schedule:weekly 安裝,每週兩個時段:主跑+當日重試), -// 也可手動 npm run weekly。 +// Weekly publishing: ingest → enrich → cover → digest → send +// Invoked by the launchd schedule (installed via npm run schedule:weekly, two slots per week: main run + same-day retry), +// or run manually with npm run weekly. import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,7 +9,7 @@ import { createRequire } from "node:module"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const require = createRequire(import.meta.url); -// 失敗絕不無聲:macOS 通知中心告警 +// Never fail silently: alert via macOS Notification Center function notify(message) { try { spawnSync("osascript", [ @@ -17,11 +17,11 @@ function notify(message) { `display notification ${JSON.stringify(message)} with title "Browstack" sound name "Basso"`, ]); } catch { - /* 通知失敗不影響流程 */ + /* a failed notification doesn't affect the flow */ } } -// 冪等保護:同一週已成功寄出 → 重試時段直接跳過,絕不重複寄送 +// Idempotency guard: if this week already sent successfully → the retry slot skips outright, never sending twice try { const Database = require("better-sqlite3"); const db = new Database(path.join(repoRoot, "data", "browstack.db"), { readonly: true }); @@ -32,7 +32,7 @@ try { process.exit(0); } } catch { - /* DB 尚不存在(全新安裝)→ 照常執行 */ + /* DB doesn't exist yet (fresh install) → run as usual */ } function run(script, { tolerate = false } = {}) { @@ -53,12 +53,12 @@ function run(script, { tolerate = false } = {}) { console.log(`[weekly] Browstack 出刊開始 / issue run started — ${new Date().toString()}`); run("ingest"); -// enrich 偶發失敗(LLM 逾時等)不殺整期:本週稍早已增潤的內容仍可出刊; -// 若最終完全沒有內容,email/send 會拒絕寄出空刊物(見 email.ts 保險) +// 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) run("enrich", { tolerate: true }); -// 封面渲染失敗(如金鑰未設)不擋出刊,沿用上一張封面 +// A cover render failure (e.g. missing key) doesn't block publishing; reuse the previous cover run("cover", { tolerate: true }); -// 當週閱讀速寫(典藏櫥窗副標):LLM 產出,失敗不擋出刊,該期就沒有速寫副標 +// 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()}`); diff --git a/src/archiveToken.ts b/src/archiveToken.ts index bebd056..b3572ed 100644 --- a/src/archiveToken.ts +++ b/src/archiveToken.ts @@ -3,21 +3,21 @@ import crypto from "node:crypto"; import { userInfo } from "node:os"; /** - * 典藏頁的 capability token——整套認證的唯一密鑰。 - * 開源前提:演算法全公開,安全只押在這顆每台各異的 256-bit 亂數上(Kerckhoffs)。 - * 存 macOS Keychain(service: browstack-archive),與 browstack-smtp / browstack-openai 一致, - * 不落在 data/ 或任何檔案裡——公開原始碼不會洩漏任何可用密鑰。 + * The archive pages' capability token — the sole secret for the entire authentication scheme. + * Open-source premise: the algorithm is fully public; security rests solely on this per-machine 256-bit random value (Kerckhoffs). + * Stored in the macOS Keychain (service: browstack-archive), consistent with browstack-smtp / browstack-openai, + * never landing in data/ or any file — publishing the source leaks no usable secret. * - * 鐵則(CI 與 CODEOWNERS 把關,見 SECURITY.md): - * - 只用 CSPRNG,絕無 hardcoded/預設/可推導的 token - * - 讀不到就 fail closed(handler 回 403),絕不在 HTTP handler 內 mint - * - 比對一律常數時間 + * Hard rules (enforced by CI and CODEOWNERS, see SECURITY.md): + * - CSPRNG only, never a hardcoded/default/derivable token + * - if it can't be read, fail closed (handler returns 403), never mint inside an HTTP handler + * - comparisons are always constant-time */ const SERVICE = "browstack-archive"; const TOKEN_RE = /^[0-9a-f]{64}$/; // 32 bytes hex -// 從 Keychain 讀 token;不存在/格式不符一律回 null(fail closed)。 +// Read the token from the Keychain; missing/malformed always returns null (fail closed). export function getArchiveToken(): string | null { try { const t = execFileSync("security", ["find-generic-password", "-s", SERVICE, "-w"], { @@ -26,13 +26,14 @@ export function getArchiveToken(): string | null { }).trim(); return TOKEN_RE.test(t) ? t : null; } catch { - return null; // Keychain 無此項或非 macOS + return null; // No such Keychain item, or not macOS } } -// 短 TTL 快取版:server 每個 archive 請求都要驗 token,若每次都 fork `security`, -// 一次索引頁(1 頁 + N 張封面)就同步 spawn N+1 次、阻塞事件迴圈,還會被未認證的洪水請求濫用來拖垮 /capture。 -// 快取數秒即可消除熱路徑上的子行程;rotate 後最多 TTL 秒生效(rotate 本就少見)。 +// Short-TTL cached version: the server verifies the token on every archive request; forking `security` each time +// means one index page (1 page + N covers) synchronously spawns N+1 times, blocking the event loop, and could be +// abused by unauthenticated request floods to bog down /capture. +// Caching for a few seconds eliminates the subprocess on the hot path; after a rotate it takes effect within at most TTL seconds (rotate is rare anyway). let tokenCache: { value: string | null; at: number } | null = null; const TOKEN_CACHE_TTL_MS = 5000; export function getArchiveTokenCached(nowMs: number = Date.now()): string | null { @@ -42,7 +43,7 @@ export function getArchiveTokenCached(nowMs: number = Date.now()): string | null return value; } -// 產生新 token 並寫入 Keychain。只由 render/rotate 流程呼叫,永遠不在 HTTP handler 內。 +// Generate a new token and write it to the Keychain. Called only by the render/rotate flows, never inside an HTTP handler. export function rotateArchiveToken(): string { const token = crypto.randomBytes(32).toString("hex"); execFileSync( @@ -53,32 +54,32 @@ export function rotateArchiveToken(): string { return token; } -// 取得可用 token:有就用,沒有就產生(供 send/rotate 呼叫;非 handler,故允許 mint)。 +// Get a usable token: use the existing one, or generate one (called by send/rotate; not a handler, so minting is allowed). export function ensureArchiveToken(): string { return getArchiveToken() ?? rotateArchiveToken(); } -// 先驗兩者格式為固定長度,再 sha256 後常數時間比對(timingSafeEqual 長度不等會 throw,故先 hash)。 +// First verify both are fixed-length, then compare in constant time after sha256 (timingSafeEqual throws on unequal lengths, so hash first). function constantTimeEqual(a: string, b: string): boolean { const ha = crypto.createHash("sha256").update(a).digest(); const hb = crypto.createHash("sha256").update(b).digest(); return crypto.timingSafeEqual(ha, hb); } -// 驗證信件連結帶來的 ?k=。stored 缺失/格式錯 → false(fail closed)。 +// Validate the ?k= carried in by the email link. Missing/malformed stored → false (fail closed). export function checkArchiveKey(presented: string | null | undefined, stored: string | null): boolean { if (!stored || !TOKEN_RE.test(stored)) return false; if (typeof presented !== "string" || presented.length === 0) return false; return constantTimeEqual(presented, stored); } -// 由 token 衍生的 session cookie 值:是 token 的 sha256,不是 token 本身。 -// 無狀態、KeepAlive 重啟後仍有效;萬一外洩到同機其他 loopback port,它也不能還原成 token。 +// The session cookie value derived from the token: the sha256 of the token, not the token itself. +// Stateless and still valid after a KeepAlive restart; even if leaked to another loopback port on the same machine, it cannot be reversed back into the token. export function sessionCookieValue(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } -// 驗證 cookie:比對「cookie 值」與「由 stored token 衍生的期望值」。 +// Validate the cookie: compare the cookie value against the expected value derived from the stored token. export function checkSessionCookie(cookieVal: string | null | undefined, stored: string | null): boolean { if (!stored || !TOKEN_RE.test(stored)) return false; if (typeof cookieVal !== "string" || cookieVal.length === 0) return false; diff --git a/src/classify/filter.ts b/src/classify/filter.ts index 80507d5..c217281 100644 --- a/src/classify/filter.ts +++ b/src/classify/filter.ts @@ -4,7 +4,7 @@ export type PageKind = "article" | "social" | "media" | "noise" | "unknown"; export interface Classification { kind: PageKind; - // 敏感頁面(金融、信箱、帳號):連本地 DB 都不寫入 + // Sensitive pages (finance, mail, accounts): not even written to the local DB sensitive: boolean; } @@ -17,27 +17,27 @@ const SENSITIVE_HOST = [ /^accounts\./, /paypal\.com$/, /^pay\./, - /^ebill\./, // 繳費平台 - /(^|\.)gov\.tw$/, // 政府個人業務(勞保、報稅等) - /^auth\./, // 登入/MFA 頁 - /(^|\.)(cathaybk|cathay-ins|taishinbank|firstbank|megabank)\.com\.tw$/, // 台灣金融機構(bank 關鍵字抓不到的) + /^ebill\./, // bill-payment platforms + /(^|\.)gov\.tw$/, // government personal services (labor insurance, tax filing, etc.) + /^auth\./, // login/MFA pages + /(^|\.)(cathaybk|cathay-ins|taishinbank|firstbank|megabank)\.com\.tw$/, // Taiwanese financial institutions (the ones the 'bank' keyword misses) ]; const NOISE_HOST = [ /^(www\.)?google\.com$/, /^(calendar|docs|drive|meet|keep|translate)\.google\.com$/, - /^news\.ycombinator\.com$/, // 連結樞紐頁,真正的內容在外部連結 - /^github\.com$/, // v0 先視為工作雜訊;README/技術文閱讀情境之後重新評估 + /^news\.ycombinator\.com$/, // link hub page; the real content is in the external links + /^github\.com$/, // treated as work noise for v0; revisit README/technical-doc reading scenarios later /^(dash|console|admin|app)\./, /^localhost(:\d+)?$/, /^127\.0\.0\.1(:\d+)?$/, /^[a-p]{32}$/, // chrome-extension:// - /^(claude\.(ai|com)|chatgpt\.com|perplexity\.ai)$/, // AI 對話工具是工作介面,不是閱讀內容 - /(^|\.)(pchome\.com\.tw|momoshop\.com\.tw|shopee\.tw|ruten\.com\.tw)$/, // 購物 - /^(platform|analytics|status|billing)\./, // 開發者主控台、帳務、監控 + /^(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 + /^(platform|analytics|status|billing)\./, // developer consoles, billing, monitoring /console\.aws\.amazon\.com$/, - /(^|\.)(sentry\.io|discord\.com|canva\.com|figma\.com|notion\.so|slack\.com)$/, // 工作工具 - /(^|\.)(wikipedia\.org|wiktionary\.org|hinative\.com|moedict\.tw)$/, // 百科/字典=快查行為,不是閱讀 + /(^|\.)(sentry\.io|discord\.com|canva\.com|figma\.com|notion\.so|slack\.com)$/, // work tools + /(^|\.)(wikipedia\.org|wiktionary\.org|hinative\.com|moedict\.tw)$/, // encyclopedia/dictionary = quick-lookup behavior, not reading ]; const SOCIAL_PERMALINK: Array<{ host: RegExp; path: RegExp }> = [ @@ -95,7 +95,7 @@ export function classifyUrl(rawUrl: string): Classification { } for (const { host: h, path: p } of SOCIAL_PERMALINK) { if (h.test(host)) { - // 符合 permalink 的是內容;其餘(動態牆、通知頁)是雜訊 + // A permalink match is content; everything else (feeds, notification pages) is noise return { kind: p.test(pathname) ? "social" : "noise", sensitive: false }; } } @@ -108,7 +108,7 @@ export function classifyUrl(rawUrl: string): Classification { return { kind: "noise", sensitive: false }; } if (ARTICLE_HOST_SUFFIX.some((s) => host === s || host.endsWith(`.${s}`))) { - // 內容站的首頁/列表頁不算文章 + // A content site's homepage/list page doesn't count as an article if (pathname === "/" || pathname === "") return { kind: "noise", sensitive: false }; return { kind: "article", sensitive: false }; } diff --git a/src/cli.ts b/src/cli.ts index d473f99..4e6dffb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,7 +42,7 @@ function cmdStats(): void { console.log(` 共 ${captures.n} 筆(含正文 ${captures.with_text ?? 0} 筆)`); const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400; - // 真實閱讀訊號(extension 的主動閱讀秒數)優先,history 停留時間次之 + // Real reading signal (the extension's active-reading seconds) takes priority; history dwell time is secondary const top = db .prepare( `SELECT title, url, total_visits, ROUND(total_duration_sec / 60.0, 1) AS minutes, @@ -68,7 +68,7 @@ function cmdStats(): void { } } -// 分類規則更新後,重跑既有頁面:修正 kind、清除已入庫的敏感頁 +// After the classification rules change, re-run over existing pages: fix kind, purge stored sensitive pages function cmdReclassify(): void { const db = getDb(); const pages = db.prepare("SELECT id, url, kind FROM pages").all() as Array<{ @@ -111,7 +111,7 @@ switch (cmd) { case "enrich": await enrich(); break; - // 以下三個是 enrich 的分解步驟,供除錯與人工編輯流程使用 + // The following three are enrich's decomposed steps, for debugging and manual editing workflows case "candidates": console.log(JSON.stringify(getCandidates(), null, 1)); break; diff --git a/src/config.ts b/src/config.ts index 9b8295c..6a7d859 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,7 +19,7 @@ export const CONFIG = { serverPort: SHARED.serverPort, llm: { - // claude-cli 走用戶既有 Claude 訂閱(需先 claude /login);anthropic 需 ANTHROPIC_API_KEY + // claude-cli uses the user's existing Claude subscription (requires claude /login first); anthropic needs ANTHROPIC_API_KEY provider: "claude-cli" as "claude-cli" | "anthropic" | "ollama", model: "claude-sonnet-5", }, @@ -27,7 +27,7 @@ export const CONFIG = { email: { from: USER_CONFIG.email.from, to: USER_CONFIG.email.to, - // Gmail SMTP;應用程式密碼存 Keychain(service: browstack-smtp) + // Gmail SMTP; the app password is stored in the Keychain (service: browstack-smtp) smtp: { host: "smtp.gmail.com", port: 465 }, }, }; diff --git a/src/db.ts b/src/db.ts index 3253890..517c1ae 100644 --- a/src/db.ts +++ b/src/db.ts @@ -70,7 +70,7 @@ export function getDb(): Database.Database { sent_at INTEGER ); - -- 本期選用了哪些頁面(email 渲染時寫入;封刊時據此標記 published_in) + -- Which pages were selected for this issue (written when rendering the email; used at close-out to mark published_in) CREATE TABLE IF NOT EXISTS issue_items ( issue_number INTEGER NOT NULL, page_id INTEGER NOT NULL REFERENCES pages(id), @@ -96,25 +96,25 @@ export function getDb(): Database.Database { return db; } -// 輕量 migration:既有 DB 補欄位 +// Lightweight migration: add columns to existing DBs function migrate(db: Database.Database): void { const cols = db.pragma("table_info(pages)") as Array<{ name: string }>; const addColumn = (name: string, ddl: string) => { if (!cols.some((c) => c.name === name)) db.exec(`ALTER TABLE pages ADD COLUMN ${ddl}`); }; addColumn("active_seconds_total", "active_seconds_total REAL NOT NULL DEFAULT 0"); - // 知識型內容判定(NULL=未分類):非知識型內容無論停留多久都不入刊 + // Knowledge-content flag (NULL = unclassified): non-knowledge content is never included regardless of dwell time addColumn("is_knowledge", "is_knowledge INTEGER"); addColumn("topic", "topic TEXT"); - // 已刊登於第 N 期(封刊時標記):刊登過的內容永不再入選, - // 避免「讀了自己的週刊 → 內容下週又被推薦」的自我迴圈 + // Published in issue N (marked at close-out): once published, content is never selected again, + // avoiding the self-loop of "read your own newsletter → content gets recommended again next week" addColumn("published_in", "published_in INTEGER"); } /** - * 冪等收緊本機資料檔權限——不只在建立時,每次開 DB 都跑一遍, - * 才能一併修好既有安裝的舊檔(多數是 umask 022 留下的 0644,同機其他 OS 帳號可讀)。 - * data/、out/、assets/covers/ → 0700;DB(含 -wal/-shm)與 logs → 0600。best-effort,失敗不擋。 + * Idempotently tighten local data-file permissions — run not only at creation but on every DB open, + * so it also fixes older files from existing installs (mostly 0644 left by umask 022, readable by other OS accounts on the machine). + * data/, out/, assets/covers/ → 0700; the DB (including -wal/-shm) and logs → 0600. Best-effort, failures don't block. */ export function hardenPerms(): void { const root = path.join(CONFIG.dataDir, ".."); @@ -122,7 +122,7 @@ export function hardenPerms(): void { try { if (fs.existsSync(p)) fs.chmodSync(p, mode); } catch { - // 權限無法變更(唯讀 volume 等)時不擋流程 + // Don't block the flow when permissions can't be changed (read-only volume, etc.) } }; for (const dir of [CONFIG.dataDir, path.join(root, "out"), path.join(root, "assets", "covers")]) { @@ -135,7 +135,7 @@ export function hardenPerms(): void { try { for (const f of fs.readdirSync(logsDir)) chmodSafe(path.join(logsDir, f), 0o600); } catch { - // logs/ 尚未建立 + // logs/ not yet created } } diff --git a/src/fetch/extract.ts b/src/fetch/extract.ts index a4a6751..bd02ee1 100644 --- a/src/fetch/extract.ts +++ b/src/fetch/extract.ts @@ -8,7 +8,7 @@ export interface ExtractedArticle { excerpt: string | null; } -// 事後補抓 history 頁面的正文(extension 上線後,新內容改由瀏覽當下擷取) +// Back-fill the body text for history pages (once the extension is live, new content is captured at browse time instead). export async function fetchArticle(url: string): Promise { const res = await fetch(url, { headers: { @@ -24,7 +24,7 @@ export async function fetchArticle(url: string): Promise { if (!contentType.includes("html")) throw new Error(`非 HTML:${contentType}`); const html = await res.text(); - // 靜音 jsdom 的 CSS/資源解析噪音 + // Silence jsdom's CSS/resource parsing noise. const virtualConsole = new VirtualConsole(); const dom = new JSDOM(html, { url, virtualConsole }); const parsed = new Readability(dom.window.document).parse(); diff --git a/src/ingest/chrome.ts b/src/ingest/chrome.ts index ea8e89e..33b247f 100644 --- a/src/ingest/chrome.ts +++ b/src/ingest/chrome.ts @@ -6,7 +6,7 @@ import { classifyUrl } from "../classify/filter.js"; import { getDb, getMeta, setMeta, type Device } from "../db.js"; import { normalizeUrl } from "../shared/urls.js"; -// Chrome 時間軸:自 1601-01-01 起算的微秒 +// Chrome timeline: microseconds since 1601-01-01. const CHROME_EPOCH_OFFSET_SEC = 11_644_473_600; const chromeToUnixSec = (t: number) => Math.floor(t / 1_000_000 - CHROME_EPOCH_OFFSET_SEC); @@ -27,7 +27,7 @@ export interface IngestSummary { kinds: Record; } -// Chrome 執行中會鎖住 History DB,一律先複製一份再讀 +// A running Chrome locks the History DB, so always copy it first and read the copy. function copyHistoryDb(): string { const src = path.join(CONFIG.chromeProfileDir, "History"); const tmpDir = path.join(CONFIG.dataDir, "tmp"); @@ -87,7 +87,7 @@ export function ingestChromeHistory(): IngestSummary { db.transaction(() => { for (const row of rows) { - // 正規化:剝除 fbclid/utm_* 等追蹤參數,同一篇內容的多次點擊合併為同一頁 + // Normalize: strip tracking params like fbclid/utm_*, merging multiple clicks on the same content into one page. const url = normalizeUrl(row.url); const { kind, sensitive } = classifyUrl(url); if (sensitive) { diff --git a/src/issue.ts b/src/issue.ts index b84efd9..3e0b9d3 100644 --- a/src/issue.ts +++ b/src/issue.ts @@ -5,8 +5,8 @@ import { CONFIG } from "./config.js"; import { getDb, getMeta } from "./db.js"; /** - * 期數與典藏:每一期有自己的編號、刊名、週期區間與封面。 - * 語義:寄出(send 成功)即封刊;下一次產出自動開新的一期。 + * Issues and archive: each issue has its own number, title, week range, and cover. + * Semantics: a successful send closes the issue; the next generation automatically opens a new one. */ export interface Issue { @@ -18,13 +18,13 @@ export interface Issue { sent_at: number | null; } -// 特殊刊名只保留給 №0(創刊預覽號);正式期數以編號 №N 呈現—— -// 進展由期數本身傳達,「創刊」字樣不跟著每期跑,也避免「№2 — 第 2 期」的同義重複 +// A special title is reserved only for №0 (the launch preview issue); regular issues are shown by number as №N — +// progression is conveyed by the number itself, so the "launch" wording doesn't tag along every issue, avoiding the redundant "№2 — issue 2" export function issueTitle(n: number): string { return n === 0 ? "創刊預覽號" : ""; } -// 目前這一期:沿用尚未寄出的最新一期;上一期已寄出則開新的一期 +// The current issue: reuse the latest unsent issue; if the previous one was already sent, open a new one export function getCurrentIssue(): Issue { const db = getDb(); seedLegacy(db); @@ -60,8 +60,8 @@ export function markIssueSent(n: number): void { const db = getDb(); db.transaction(() => { db.prepare("UPDATE issues SET sent_at = ? WHERE number = ?").run(Math.floor(Date.now() / 1000), n); - // 封刊:本期選用的頁面標記為「已刊登」,之後任何一期都不再入選 - // (否則用戶回頭讀自己的週刊,內容會在下週再次被推薦,形成自我迴圈) + // Close-out: mark this issue's selected pages as "published" so they're never selected in any future issue + // (otherwise, when the user rereads their own newsletter, the content gets recommended again next week, forming a self-loop) db.prepare( `UPDATE pages SET published_in = ? WHERE id IN (SELECT page_id FROM issue_items WHERE issue_number = ?)`, @@ -75,18 +75,18 @@ export function listIssues(): Issue[] { return db.prepare("SELECT * FROM issues ORDER BY number DESC").all() as Issue[]; } -// 當週閱讀速寫:生成封面 prompt 之前對本週閱讀內容的一句編輯理解(由 render/digest.ts 產生,存 meta)。 -// 供刊頭(issueView)、典藏櫥窗、信件引言共用。沒有就回 null。 +// The week's reading sketch: a one-line editorial understanding of this week's reading, produced before the cover prompt is generated (created by render/digest.ts, stored in meta). +// Shared by the masthead (issueView), the archive showcase, and the email intro. Returns null if absent. export function issueDigest(n: number): string | null { const d = getMeta(`issue_digest:${n}`); return d && d.trim().length > 0 ? d.trim() : null; } /** - * 本期封面檔案:優先 issue-N.(png|jpg|svg) → 最近一期的封面(點陣圖優先) - * → 隨庫附帶的預設封面(assets/cover-default.jpg,即創刊號封面)。 - * 全新 clone 尚未跑過 cover、或某週渲染失敗時,都能有一張完整封面,不擋出刊。 - * rasterOnly:email 的 CID 內嵌只吃點陣圖(png/jpg),svg 僅網頁版可用。 + * This issue's cover file: prefer issue-N.(png|jpg|svg) → the most recent issue's cover (raster preferred) + * → the default cover bundled with the repo (assets/cover-default.jpg, i.e. the launch-issue cover). + * A brand-new clone that hasn't run cover yet, or a week whose render failed, still gets a complete cover and doesn't block publishing. + * rasterOnly: the email's CID embedding only accepts raster images (png/jpg); svg is available only in the web version. */ export function findCover(n: number, opts: { rasterOnly?: boolean; exactOnly?: boolean } = {}): string | null { const exts = opts.rasterOnly ? (["png", "jpg"] as const) : (["png", "jpg", "svg"] as const); @@ -100,7 +100,7 @@ export function findCover(n: number, opts: { rasterOnly?: boolean; exactOnly?: b const exact = path.join(dir, `issue-${n}.${ext}`); if (fs.existsSync(exact)) return exact; } - // exactOnly:典藏頁需忠實呈現——沒有本期封面就退回預設封面,絕不借用其他期的插畫張冠李戴 + // exactOnly: the archive page must render faithfully — with no cover for this issue, fall back to the default cover, never borrowing another issue's illustration and mislabeling it if (opts.exactOnly) return defaultCover; if (!fs.existsSync(dir)) return orDefault(null); const pattern = opts.rasterOnly ? /^issue-\d+\.(png|jpg)$/ : /^issue-\d+\.(png|jpg|svg)$/; @@ -115,7 +115,7 @@ export function findCover(n: number, opts: { rasterOnly?: boolean; exactOnly?: b return orDefault(pick ? path.join(dir, pick) : null); } -// 單期時代的存量登記:issue-0 已產出並寄出過 → 記為已封刊,下一期從 №1 開始 +// Backfill for the single-issue era: issue-0 was already generated and sent → record it as closed, so the next issue starts from №1 function seedLegacy(db: Database.Database): void { const { n } = db.prepare("SELECT COUNT(*) AS n FROM issues").get() as { n: number }; if (n > 0) return; diff --git a/src/llm/claudeCli.ts b/src/llm/claudeCli.ts index 831712d..193e171 100644 --- a/src/llm/claudeCli.ts +++ b/src/llm/claudeCli.ts @@ -2,9 +2,9 @@ import { spawn } from "node:child_process"; import type { LLMProvider } from "./provider.js"; /** - * 用本機 Claude Code CLI(claude -p)當 LLM——走用戶既有訂閱,不需另管 API key。 - * 需要先在終端機執行過 claude /login。 - * 可選指定 model(如 "opus")與高思考等級——封面 SVG 後備等重活會用最強配置。 + * Uses the local Claude Code CLI (claude -p) as the LLM — rides the user's existing subscription, no separate API key to manage. + * Requires having run claude /login in the terminal first. + * Optionally specify a model (e.g. "opus") and a high thinking level — heavy jobs like the cover SVG fallback use the strongest configuration. */ export class ClaudeCliProvider implements LLMProvider { readonly name = "claude-cli"; @@ -16,7 +16,7 @@ export class ClaudeCliProvider implements LLMProvider { complete(opts: { system?: string; prompt: string; maxTokens?: number }): Promise { const full = opts.system ? `${opts.system}\n\n${opts.prompt}` : opts.prompt; return new Promise((resolve, reject) => { - // 保留完整環境(Keychain 憑證需要),只移除會干擾認證的 Claude session 變數 + // Keep the full environment (needed for Keychain credentials), only removing the Claude session variables that interfere with auth const env: Record = { ...process.env }; for (const key of Object.keys(env)) { if ( @@ -34,7 +34,7 @@ export class ClaudeCliProvider implements LLMProvider { const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] }); let out = ""; let err = ""; - // 批次工作(分類/摘要/繪圖)都是離線執行,冷啟動+token 刷新下 3 分鐘不夠——預設給 10 分鐘 + // Batch jobs (classify/summarize/draw) all run offline; with cold start + token refresh, 3 minutes isn't enough — default to 10 minutes const timeoutMs = this.cliOpts.timeoutMs ?? 600_000; const timer = setTimeout(() => { child.kill(); diff --git a/src/llm/image.ts b/src/llm/image.ts index 1bcb707..b1482e7 100644 --- a/src/llm/image.ts +++ b/src/llm/image.ts @@ -1,12 +1,12 @@ import { execFileSync } from "node:child_process"; /** - * 圖像生成供應商抽象層——封面引擎的渲染端。 - * 與 LLMProvider 同一設計哲學:介面固定,引擎可換。 + * Image-generation provider abstraction layer — the render side of the cover engine. + * Same design philosophy as LLMProvider: fixed interface, swappable engine. */ -// 金鑰來源優先序:環境變數 → macOS Keychain(service: browstack-openai) -// Keychain 寫入方式:security add-generic-password -s browstack-openai -a "$USER" -w '' -U +// Key lookup order: environment variable → macOS Keychain (service: browstack-openai) +// How to store in Keychain: security add-generic-password -s browstack-openai -a "$USER" -w '' -U function getOpenAIKey(): string { if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; try { @@ -17,7 +17,7 @@ function getOpenAIKey(): string { ).trim(); if (key) return key; } catch { - // Keychain 裡沒有,往下丟明確錯誤 + // Not in the Keychain; fall through to throw an explicit error } throw new Error( "找不到 OpenAI 金鑰。建議存進 macOS Keychain:\n" + @@ -28,7 +28,7 @@ function getOpenAIKey(): string { export interface ImageProvider { readonly name: string; - /** 產生一張直式封面圖,回傳 PNG buffer */ + /** Generate a portrait cover image, returned as a PNG buffer */ generate(prompt: string): Promise; } diff --git a/src/llm/provider.ts b/src/llm/provider.ts index 9bfc42c..317e465 100644 --- a/src/llm/provider.ts +++ b/src/llm/provider.ts @@ -3,11 +3,13 @@ import { AnthropicProvider } from "./anthropic.js"; import { ClaudeCliProvider } from "./claudeCli.js"; /** - * LLM 供應商抽象層。所有下游功能(知識分類、摘要、主題分組) - * 只依賴這個介面,確保雲端/本機模型可隨時切換(產品決策 #2)。 + * LLM provider abstraction layer. All downstream features (knowledge + * classification, summarization, topic grouping) depend only on this interface, + * ensuring cloud/local models can be swapped at any time (product decision #2). * - * 隱私約束:呼叫端只能傳入「已通過內容頁分類」的正文。 - * 敏感/雜訊頁面在 ingest 階段就被擋下,永遠不會到達這裡。 + * Privacy constraint: callers may only pass in body text that has already passed + * content-page classification. Sensitive/noise pages are blocked at the ingest + * stage and never reach here. */ export interface LLMProvider { readonly name: string; @@ -29,7 +31,7 @@ export function getProvider(): LLMProvider { } } -// LLM 回覆常包 ```json 圍欄;取出第一個 JSON 值 +// LLM replies often wrap output in a ```json fence; extract the first JSON value export function parseJsonReply(reply: string): T { const cleaned = reply.replace(/```(?:json)?/g, "").trim(); const start = cleaned.search(/[[{]/); diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..83b8323 --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,100 @@ +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. + * + * Resolution order: + * 1. Explicit override — USER_CONFIG.contentLanguage, when set and not "auto". + * 2. The `pages.lang` column, when enough pages carry a real language tag. + * 3. A zero-dependency script heuristic over recent titles + summaries + * (kana → Japanese, hangul → Korean, other CJK → Chinese, Latin → English), + * 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. + */ + +// 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-cn": "Simplified Chinese", + "zh-hans": "Simplified Chinese", + ja: "Japanese", + ko: "Korean", + es: "Spanish", + fr: "French", + de: "German", + pt: "Portuguese", + it: "Italian", + nl: "Dutch", + 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; +} + +// 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. +function fromLangColumn(): string | null { + const rows = getDb() + .prepare( + `SELECT lang, COUNT(*) AS n FROM pages + WHERE is_knowledge = 1 AND lang IS NOT NULL AND lang != '' AND lang != 'und' AND lang != 'zz' + GROUP BY lang ORDER BY n DESC`, + ) + .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); +} + +// Script heuristic over recent reading — robust when language tags are missing. +function fromScript(): string | null { + const rows = getDb() + .prepare( + `SELECT COALESCE(title, '') || ' ' || COALESCE(summary, '') AS t FROM pages + WHERE is_knowledge = 1 AND summary IS NOT NULL + ORDER BY last_seen DESC LIMIT 60`, + ) + .all() as Array<{ t: string }>; + let kana = 0; + let hangul = 0; + let cjk = 0; + let latin = 0; + for (const { t } of rows) { + for (const ch of t) { + const cp = ch.codePointAt(0) ?? 0; + if (cp >= 0x3040 && cp <= 0x30ff) kana++; + else if (cp >= 0xac00 && cp <= 0xd7a3) hangul++; + else if (cp >= 0x4e00 && cp <= 0x9fff) cjk++; + else if ((cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a)) latin++; + } + } + 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"; +} + +export function resolveContentLanguage(): 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"; +} diff --git a/src/pipeline/enrich.ts b/src/pipeline/enrich.ts index 25731dc..a08e978 100644 --- a/src/pipeline/enrich.ts +++ b/src/pipeline/enrich.ts @@ -1,11 +1,12 @@ import { getDb } from "../db.js"; import { fetchArticle } from "../fetch/extract.js"; import { getProvider, parseJsonReply } from "../llm/provider.js"; +import { resolveContentLanguage } from "../locale.js"; import { normalizeTitle } from "../shared/urls.js"; /** - * M2 enrich 管線:知識分類 → 正文補抓 → 摘要。 - * 核心編輯原則(產品決策):非知識型內容,無論停留多久都不入刊。 + * M2 enrich pipeline: knowledge classification → body backfill → summarization. + * Core editorial principle (product decision): non-knowledge content never gets published, no matter how long it was viewed. */ export interface Candidate { @@ -36,7 +37,7 @@ export function getCandidates(): Candidate[] { WHERE last_seen > ? AND is_knowledge IS NULL AND published_in IS NULL AND title IS NOT NULL AND LENGTH(title) > 8`; - // 文章與社群優先入池,不讓高停留的 unknown 雜訊把它們擠掉 + // Articles and social posts go into the pool first, so high-dwell unknown noise can't crowd them out const articleSocial = db .prepare(`${baseSelect} AND kind IN ('article', 'social') ORDER BY active_seconds_total DESC, total_duration_sec DESC LIMIT 30`) .all(weekAgo) as Candidate[]; @@ -45,7 +46,7 @@ export function getCandidates(): Candidate[] { .prepare(`${baseSelect} AND kind = 'unknown' ORDER BY active_seconds_total DESC, total_duration_sec DESC LIMIT 60`) .all(weekAgo) as Candidate[] ) - // 根路徑是入口頁不是單篇內容(掛著沒關的首頁常累積巨量停留) + // The root path is a landing page, not a single piece of content (an unclosed homepage tab often racks up huge dwell time) .filter((c) => new URL(c.url).pathname !== "/") .slice(0, 20); return [...articleSocial, ...unknowns]; @@ -56,7 +57,7 @@ export function applyEnrichment(records: EnrichmentRecord[]): { updated: number; const update = db.prepare( "UPDATE pages SET is_knowledge = ?, topic = COALESCE(?, topic), summary = COALESCE(?, summary) WHERE id = ?", ); - // LLM 判定為知識型的 unknown 頁面,升級為 article + // Unknown pages the LLM judges to be knowledge-type get upgraded to article const upgrade = db.prepare("UPDATE pages SET kind = 'article' WHERE id = ? AND kind = 'unknown'"); let updated = 0; let upgraded = 0; @@ -73,14 +74,21 @@ export function applyEnrichment(records: EnrichmentRecord[]): { updated: number; export async function classifyCandidates(candidates: Candidate[]): Promise { if (candidates.length === 0) return []; const provider = getProvider(); + const lang = resolveContentLanguage(); const list = candidates.map((c) => ({ id: c.id, kind: c.kind, title: c.title, host: new URL(c.url).hostname })); const reply = await provider.complete({ system: - "你是 Browstack 個人週刊的選題編輯。只收錄知識型內容:科技、AI、產業分析、商業洞察、深度公共議題、專業知識、有觀點或資訊價值的社群貼文。" + - "一律排除:娛樂八卦、彩券、購物促銷、會員活動、網站專區或列表頁(非單篇內容)、純聊天或情緒抒發、廣告宣傳頁、" + - "以及所有「快查行為」——百科條目、字典查詢、單字問答、天氣、對獎,這些是查資料不是閱讀。", + "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.", prompt: - `判斷以下候選內容,回傳 JSON array,每項格式 {"id": number, "is_knowledge": boolean, "topic": "2~6字中文主題標籤"}(非知識型的 topic 給 null)。只輸出 JSON。\n\n` + + `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)"} ` + + `(topic null when not knowledge-type). Output only JSON.\n\n` + JSON.stringify(list, null, 1), maxTokens: 4096, }); @@ -121,9 +129,10 @@ export async function fetchMissingContent(limit = 12): Promise<{ fetched: number export async function summarizeKnowledgePages(): Promise { const db = getDb(); const provider = getProvider(); + const lang = resolveContentLanguage(); const weekAgo = Math.floor(Date.now() / 1000) - DAYS * 86400; - // 文章:內文(或退而求其次用標題)→ 三個重點 + 一句 takeaway + // Articles: body (or the title as a fallback) → three bullets + one takeaway line const articles = db .prepare( `SELECT id, title, content_text FROM pages @@ -134,7 +143,7 @@ export async function summarizeKnowledgePages(): Promise { .all(weekAgo) as Array<{ id: number; title: string; content_text: string | null }>; const saveSummary = db.prepare("UPDATE pages SET summary = ? WHERE id = ?"); const demote = db.prepare("UPDATE pages SET is_knowledge = 0 WHERE id = ?"); - // 同文分身防浪費:已有摘要的知識文章標題集合,撞鍵者直接降級、不再花 LLM 摘要 + // Avoid duplicate work: set of titles of already-summarized knowledge articles; a key collision gets demoted instead of spending another LLM summary const knownArticles = new Set( ( db @@ -146,29 +155,32 @@ export async function summarizeKnowledgePages(): Promise { ); let done = 0; for (const a of articles) { - // 品管:正文太短=擷取失敗的空殼,寧缺勿濫,直接降級 + // Quality control: a too-short body is an empty shell from a failed extraction; better to drop it than publish it, so demote if (!a.content_text || a.content_text.length < 300) { demote.run(a.id); continue; } const titleKey = normalizeTitle(a.title); if (knownArticles.has(titleKey)) { - demote.run(a.id); // 追蹤參數分身:同標題已有摘要 + demote.run(a.id); // tracking-param duplicate: same title already summarized continue; } knownArticles.add(titleKey); const reply = await provider.complete({ - system: "你是週刊編輯,把文章濃縮成足以取代原文閱讀的摘要。", + system: `You are a weekly-digest editor. Condense the article into a summary written in ${lang} that can replace reading the original.`, prompt: - `輸出 JSON:{"bullets": ["…", "…", "…"], "takeaway": "…"}。三個 bullet 各 ≤ 42 字,takeaway 是一句「為什麼值得記住」≤ 32 字。只輸出 JSON。\n\n` + - `標題:${a.title}\n內文節錄:${a.content_text.slice(0, 6000)}`, + `Output JSON: {"bullets": ["…", "…", "…"], "takeaway": "…"}, written in ${lang}. ` + + `Three bullets, each a single tight line (≈ ≤ 14 words, or ≤ 42 characters for CJK); ` + + `the takeaway is one line on "why this is worth remembering" (≈ ≤ 11 words, or ≤ 32 characters for CJK). ` + + `Output only JSON.\n\n` + + `Title: ${a.title}\nBody excerpt: ${a.content_text.slice(0, 6000)}`, maxTokens: 1024, }); saveSummary.run(JSON.stringify(parseJsonReply(reply)), a.id); done++; } - // 社群貼文:title 已攜帶全文 → 一句脈絡 + // Social posts: the title already carries the full text → one line of context const normalizePost = normalizeTitle; const existingPosts = new Set( ( @@ -189,7 +201,7 @@ export async function summarizeKnowledgePages(): Promise { ) .all(weekAgo) as Array<{ id: number; title: string }> ).filter((p) => { - // 品管:同一貼文常有多個 URL(分享路徑不同),內容重複即降級 + // Quality control: the same post often has multiple URLs (different share paths); demote on duplicate content const key = normalizePost(p.title); if (existingPosts.has(key)) { demote.run(p.id); @@ -200,9 +212,11 @@ export async function summarizeKnowledgePages(): Promise { }); if (posts.length > 0) { const reply = await provider.complete({ - system: "你是週刊編輯。", + system: "You are a weekly-digest editor.", prompt: - `為每則社群貼文寫一句編輯脈絡(≤ 36 字,說明它在談什麼、為何值得記住)。回傳 JSON array:[{"id": number, "context": "…"}]。只輸出 JSON。\n\n` + + `For each social post, write one line of editorial context in ${lang} ` + + `(≈ ≤ 12 words, or ≤ 36 characters for CJK; what it is about and why it is worth remembering). ` + + `Return a JSON array: [{"id": number, "context": "…"}]. Output only JSON.\n\n` + JSON.stringify(posts.map((p) => ({ id: p.id, text: p.title.slice(0, 500) }))), maxTokens: 2048, }); @@ -214,7 +228,7 @@ export async function summarizeKnowledgePages(): Promise { return done; } -// 全自動 enrich:每週由排程呼叫 +// Fully automated enrich: called weekly by the scheduler export async function enrich(): Promise { const candidates = getCandidates(); console.log(`候選 ${candidates.length} 項,交由 ${getProvider().name} 分類…`); diff --git a/src/render/archive.ts b/src/render/archive.ts index 90533fa..7f95152 100644 --- a/src/render/archive.ts +++ b/src/render/archive.ts @@ -5,9 +5,11 @@ import { renderIssueDocument, type IssueStats } from "./issueView.js"; import type { IssueItem } from "./select.js"; /** - * 典藏頁的即時渲染(server 端):整個櫥窗與每一期都由當前 DB 重建,不落地檔案。 - * 過刊(含只寄過 email、沒網頁版的期數)由 issues 週期 + issue_items + 已持久化的 pages.summary - * 忠實重建;訊號(分鐘/實讀)以該期 stored 週窗重算,封面走同源 /covers/N。 + * Live rendering of the archive (server-side): the whole showcase and every issue are rebuilt + * from the current DB, never written to disk. Past issues (including ones only sent by email, + * with no web version) are faithfully rebuilt from the issues cycle + issue_items + persisted + * pages.summary; signals (minutes/active reading) are recomputed over that issue's stored week + * window, and covers use same-origin /covers/N. */ const CHROME_EPOCH_OFFSET_SEC = 11_644_473_600; @@ -17,7 +19,7 @@ const fmtDate = (sec: number) => { return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; }; -// 依 stored 週窗 [start,end] 重算某期某類的入選項目(訊號以窗內造訪計算,與當初出刊一致)。 +// 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[] { const db = getDb(); const issue = db.prepare("SELECT week_start, week_end FROM issues WHERE number = ?").get(n) as @@ -70,7 +72,7 @@ function statsForWindow(startUnix: number, endUnix: number): IssueStats { }; } -// 該期入選則數(櫥窗副標用)。№0 等無 issue_items 者回 0/0。 +// Count of selected items for an issue (used in the showcase subheading). Issues with no issue_items (e.g. №0) return 0/0. function issueCounts(n: number): { articles: number; social: number } { const row = getDb() .prepare( @@ -84,7 +86,7 @@ function issueCounts(n: number): { articles: number; social: number } { return { articles: row.articles ?? 0, social: row.social ?? 0 }; } -// 單期網頁(由 DB 重建)。查無此期回 null(→ server 404)。 +// Single-issue web page (rebuilt from DB). Returns null if the issue isn't found (→ server 404). export function renderIssuePage(n: number): string | null { const db = getDb(); const issue = db.prepare("SELECT * FROM issues WHERE number = ?").get(n) as Issue | undefined; @@ -92,12 +94,12 @@ 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); - // 封面走同源路由(findCover exactOnly:本期封面或預設,絕不借用他期) + // 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) }); } -// 典藏櫥窗索引(由 listIssues 即時生成,連結走 /issues/N、封面走 /covers/N)。 +// Archive showcase index (generated live from listIssues; links go to /issues/N, covers to /covers/N). export function renderArchiveIndex(): string { const cards = listIssues() .map((i) => { @@ -105,9 +107,9 @@ export function renderArchiveIndex(): string { const status = i.sent_at ? `已寄出 ${fmtDate(i.sent_at)}` : "編輯中"; 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} 則社群迴響` : ""; return ` diff --git a/src/render/cover.ts b/src/render/cover.ts index e4c97e3..cbc5e26 100644 --- a/src/render/cover.ts +++ b/src/render/cover.ts @@ -6,21 +6,22 @@ import { getCurrentIssue } from "../issue.js"; import { ClaudeCliProvider } from "../llm/claudeCli.js"; import { getImageProvider } from "../llm/image.js"; import { getProvider, parseJsonReply } from "../llm/provider.js"; +import { resolveContentLanguage } from "../locale.js"; /** - * 封面生成引擎:每期依內容主題,以 The New Yorker 的封面藝術語言生成插畫。 - * 兩段式:LLM 藝術總監(概念與完整 image prompt)→ 圖像生成引擎(渲染 PNG)。 + * Cover generation engine: each issue produces an illustration in The New Yorker's cover art language, driven by the issue's content themes. + * Two stages: LLM art director (concept and full image prompt) -> image generation engine (renders PNG). */ /** - * The New Yorker 封面風格規格(藝術總監的固定約束,逐期不變——這就是刊物的視覺識別): - * 1. 一個畫面、一個隱喻:封面是對時代的一則溫和評論,不是內容的圖解拼貼 - * 2. 扁平色塊與絹印質感:gouache/silkscreen 手感、無漸層無寫實光影、細微紙紋 - * 3. 有限色盤:5–7 色,靜謐偏暖的印刷色(深青、磚紅、芥黃、奶油、墨色系) - * 4. 慷慨的負空間:不對稱構圖、大量留白、畫面上緣 18% 保持簡潔供刊頭壓字 - * 5. 都市的親密時刻:人物小而精準,孤獨但不悲傷,帶一點機智 - * 6. 畫面內絕不出現文字 - * 譜系參照:Adrian Tomine 的都市觀察 × Malika Favre 的大膽負空間 × Christoph Niemann 的概念機智 + * The New Yorker cover style spec (the art director's fixed constraints, unchanged issue to issue — this is the publication's visual identity): + * 1. One image, one metaphor: the cover is a gentle commentary on the moment, not a diagrammatic collage of content + * 2. Flat color fields and silkscreen texture: gouache/silkscreen feel, no gradients or realistic lighting, subtle paper grain + * 3. Limited palette: 5–7 colors, quiet warm print tones (deep teal, brick red, mustard, cream, ink) + * 4. Generous negative space: asymmetric composition, plenty of white space, top 18% of the frame kept clean for the masthead + * 5. An intimate urban moment: figures small and precise, solitary but not sad, with a touch of wit + * 6. Never any text within the image + * Lineage reference: Adrian Tomine's urban observation × Malika Favre's bold negative space × Christoph Niemann's conceptual wit */ const ART_DIRECTION_EN = `Style: The New Yorker magazine cover illustration tradition. Flat gouache / silkscreen texture, matte paper grain, absolutely no gradients, no 3D, no photorealism. @@ -52,25 +53,29 @@ if (items.length === 0) { const provider = getProvider(); console.log(`本期主題素材 ${items.length} 項,請 ${provider.name} 擔任藝術總監…`); +const lang = resolveContentLanguage(); const reply = await provider.complete({ system: - "你是 The New Yorker 的封面藝術總監,為個人週刊 Browstack(把讀者自己的瀏覽閱讀編成刊物)設計本期封面。" + - "你的任務:從本期內容中找出『一個』值得評論的時代觀察,轉化成單一視覺隱喻。不要拼貼多個主題。", + "You are the cover art director for Browstack, a personal weekly digest that turns the reader's " + + "own browsing/reading into an issue. Your job: from this week's content, find ONE observation worth " + + "commenting on and turn it into a single visual metaphor. Do not collage multiple topics.", prompt: - `本期內容主題與標題:\n${JSON.stringify(items, null, 1)}\n\n` + - `固定風格規格(不可違反):\n${ART_DIRECTION_EN}\n\n` + - `輸出 JSON:{"concept_zh": "80 字內的概念說明(給編輯看)", "image_prompt_en": "給圖像生成模型的完整英文 prompt,含場景、構圖、色盤 hex、氛圍,並完整內嵌上述風格規格的要求"}。只輸出 JSON。`, + `This issue's topics and titles:\n${JSON.stringify(items, null, 1)}\n\n` + + `Fixed style spec (must not be violated):\n${ART_DIRECTION_EN}\n\n` + + `Output JSON: {"concept": "a concept note for the editor, <= ~60 words, written in ${lang}", ` + + `"image_prompt_en": "the full ENGLISH prompt for the image model — scene, composition, palette hex, ` + + `mood — with the style spec above fully embedded"}. Output only JSON.`, maxTokens: 2048, }); -const concept = parseJsonReply<{ concept_zh: string; image_prompt_en: string }>(reply); +const concept = parseJsonReply<{ concept: string; image_prompt_en: string }>(reply); const coversDir = path.join(CONFIG.dataDir, "..", "assets", "covers"); fs.mkdirSync(coversDir, { recursive: true }); fs.writeFileSync( path.join(coversDir, `issue-${issueNo}.concept.json`), JSON.stringify(concept, null, 2), ); -console.log(`\n本期封面概念:${concept.concept_zh}\n`); +console.log(`\n本期封面概念:${concept.concept}\n`); try { if (process.env.BROWSTACK_DISABLE_IMAGE) throw new Error("圖像引擎已由 BROWSTACK_DISABLE_IMAGE 停用"); @@ -81,7 +86,7 @@ try { fs.writeFileSync(outPath, png); console.log(`封面完成:${outPath}`); } catch (e) { - // 沒有圖像引擎金鑰時的後備:用訂閱制 AI(最強模型+高思考等級)直接畫 SVG 插畫 + // Fallback when no image engine key is available: draw an SVG illustration directly with the subscription AI (strongest model + high effort level) console.log(`圖像引擎未執行(${String(e).slice(0, 120)}),改用訂閱 AI 繪製 SVG 封面…`); try { const svg = await generateSvgCover(concept); @@ -95,8 +100,8 @@ try { } } -async function generateSvgCover(c: { concept_zh: string; image_prompt_en: string }): Promise { - // 偏好最強訂閱模型+高思考;不可用時退回預設模型。畫圖較慢,給 10 分鐘。 +async function generateSvgCover(c: { concept: string; image_prompt_en: string }): Promise { + // Prefer the strongest subscription model + high effort; fall back to the default model when unavailable. Drawing is slow, so allow 10 minutes. const artists = CONFIG.llm.provider === "claude-cli" ? [ @@ -109,16 +114,17 @@ async function generateSvgCover(c: { concept_zh: string; image_prompt_en: string try { const reply = await artist.complete({ system: - "你是頂尖的向量插畫家,以 The New Yorker 封面傳統作畫。你將直接用 SVG 作為畫布完成一幅完整、精緻、有構圖層次的插畫。", + "You are a top vector illustrator working in the New Yorker cover tradition. You will use SVG " + + "directly as your canvas to produce one complete, refined illustration with compositional depth.", prompt: - `依下列概念完成封面插畫:\n${c.concept_zh}\n\n場景參考(供理解,不必逐字照做):${c.image_prompt_en.slice(0, 800)}\n\n` + - `硬性規格:\n` + - `- 只輸出一個完整的 ,不要任何其他文字或圍欄\n` + - `- viewBox="0 0 1000 1500" 直式構圖;畫面上緣 18% 保持簡潔供刊頭壓字\n` + - `- 只用這些顏色:#1f4e5f #16394a #b5361c #e8a13d #f2e8d5 #211c15 #6b8f71 #d9cfb4\n` + - `- 扁平色塊、無漸層、無濾鏡;構圖要有前中後景與大量負空間\n` + - `- 禁止:任何文字/字母/數字、