Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
</head>
<body>
<h1>Browstack</h1>
<div class="row"><span>本機服務</span><span id="server">檢查中…</span></div>
<div class="row"><span>待送佇列</span><span id="queue">–</span></div>
<div class="row"><span>累計擷取</span><span id="sent">–</span></div>
<p class="privacy">資料只送往你電腦上的 127.0.0.1,永不進雲端。</p>
<div class="row"><span id="label-server">本機服務</span><span id="server">檢查中…</span></div>
<div class="row"><span id="label-queue">待送佇列</span><span id="queue">–</span></div>
<div class="row"><span id="label-sent">累計擷取</span><span id="sent">–</span></div>
<p class="privacy" id="privacy">資料只送往你電腦上的 127.0.0.1,永不進雲端。</p>
<script src="dist/popup.js"></script>
</body>
</html>
36 changes: 34 additions & 2 deletions extension/src/popup.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,16 +33,23 @@ function put(id: string, text: string, cls?: string): void {
}

async function main(): Promise<void> {
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));
try {
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");
}
}

Expand Down
12 changes: 6 additions & 6 deletions scripts/heartbeat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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);
}

Expand All @@ -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,
Expand All @@ -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 */
Expand Down
18 changes: 9 additions & 9 deletions scripts/install-weekly.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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`);
12 changes: 6 additions & 6 deletions scripts/weekly.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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()}`);
2 changes: 2 additions & 0 deletions src/classify/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const NOISE_HOST = [
/^[a-p]{32}$/, // chrome-extension://<id>
/^(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
Expand Down
28 changes: 14 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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
Expand All @@ -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})`);
}
}

Expand Down Expand Up @@ -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];
Expand All @@ -121,15 +121,15 @@ switch (cmd) {
case "apply": {
const file = process.argv[3];
if (!file) {
console.error("用法:apply <enrichment.json>");
console.error("Usage: apply <enrichment.json>");
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 <file>");
console.log("Usage: ingest | stats | reclassify | enrich | candidates | fetch-content | apply <file>");
process.exit(1);
}
4 changes: 2 additions & 2 deletions src/fetch/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export async function fetchArticle(url: string): Promise<ExtractedArticle> {
});
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.
const virtualConsole = new VirtualConsole();
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),
Expand Down
Loading
Loading