ai-weekly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const BILIBILI_ORIGIN = "https://www.bilibili.com";
6
+
7
+ function usage() {
8
+ console.log(`
9
+ 用法:
10
+ npx ai-weekly subtitles [选项]
11
+
12
+ 只获取 Bilibili 已存在的字幕轨,不下载音频,也不执行转写。
13
+
14
+ 选项:
15
+ --date YYYY-MM-DD 读取和写入 data/runs/<日期>/;默认今天
16
+ --input PATH discovery.json 路径;默认 data/runs/<日期>/discovery.json
17
+ --output-dir PATH 输出目录;默认与输入文件同级的 subtitles/
18
+ --profile PATH 已登录或已验证的专用 Chrome profile;默认 data/browser-profiles/bilibili
19
+ --limit N 最多处理 N 条候选;默认全部
20
+ --delay-ms N 每条候选之间的等待时间;默认 800
21
+ --headed 显示浏览器窗口并在开始前等待完成登录(默认)
22
+ --headless 使用已登录 profile 直接运行,不显示浏览器窗口
23
+ --force 覆盖已保存的字幕文件
24
+ --help 显示本帮助
25
+
26
+ 示例:
27
+ npx ai-weekly subtitles
28
+ npx ai-weekly subtitles --limit 3 --headless
29
+ `);
30
+ }
31
+
32
+ export function parseArgs(argv) {
33
+ const options = {
34
+ date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }),
35
+ input: null,
36
+ outputDir: null,
37
+ profile: "data/browser-profiles/bilibili",
38
+ limit: Infinity,
39
+ delayMs: 800,
40
+ headed: true,
41
+ force: false
42
+ };
43
+ const valueOptions = new Map([
44
+ ["--date", "date"],
45
+ ["--input", "input"],
46
+ ["--output-dir", "outputDir"],
47
+ ["--profile", "profile"],
48
+ ["--limit", "limit"],
49
+ ["--delay-ms", "delayMs"]
50
+ ]);
51
+
52
+ for (let index = 0; index < argv.length; index += 1) {
53
+ const arg = argv[index];
54
+ if (arg === "--help") return { help: true };
55
+ if (arg === "--headed" || arg === "--force") {
56
+ options[arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = true;
57
+ continue;
58
+ }
59
+ if (arg === "--headless") {
60
+ options.headed = false;
61
+ continue;
62
+ }
63
+ const key = valueOptions.get(arg);
64
+ if (!key || index + 1 >= argv.length) throw new Error(`无效参数:${arg}`);
65
+ const value = argv[index + 1];
66
+ options[key] = ["limit", "delayMs"].includes(key) ? Number(value) : value;
67
+ index += 1;
68
+ }
69
+
70
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
71
+ if ((!Number.isInteger(options.limit) || options.limit < 1) && options.limit !== Infinity) throw new Error("--limit 必须是正整数");
72
+ if (!Number.isInteger(options.delayMs) || options.delayMs < 0) throw new Error("--delay-ms 必须是非负整数");
73
+ return options;
74
+ }
75
+
76
+ function getBvid(url) {
77
+ const match = url.match(/\/video\/(BV[0-9A-Za-z]+)/i);
78
+ if (!match) throw new Error(`无法从候选 URL 提取 BV 号:${url}`);
79
+ return match[1];
80
+ }
81
+
82
+ function getCandidates(discovery) {
83
+ return discovery.projects.flatMap((project) => (project.bilibili_search?.recommended_candidates || []).slice(0, 5).map((video) => ({
84
+ repository: project.repository,
85
+ project_rank: project.rank,
86
+ ...video,
87
+ bvid: getBvid(video.url)
88
+ })));
89
+ }
90
+
91
+ async function exists(path) {
92
+ try {
93
+ await access(path);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async function hasSavedSubtitle(path, candidate, subtitle) {
101
+ if (!await exists(path)) return false;
102
+ try {
103
+ const artifact = JSON.parse(await readFile(path, "utf8"));
104
+ return artifact.schema_version === "1.0"
105
+ && artifact.source?.bvid === candidate.bvid
106
+ && String(artifact.source?.subtitle_id) === String(subtitle.id)
107
+ && artifact.source?.language === subtitle.lan;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ async function writeJsonAtomically(path, value) {
114
+ const temporaryPath = `${path}.tmp-${process.pid}`;
115
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`);
116
+ await rename(temporaryPath, path);
117
+ }
118
+
119
+ async function waitForVisibleLogin() {
120
+ const { createInterface } = await import("node:readline/promises");
121
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
122
+ try {
123
+ await prompt.question("请在已打开的 Bilibili 窗口中登录或完成验证,完成后按 Enter 开始采集:");
124
+ } finally {
125
+ prompt.close();
126
+ }
127
+ }
128
+
129
+ async function fetchSubtitleMetadata(page, bvid) {
130
+ return page.evaluate(async ({ bvid }) => {
131
+ const getJson = async (url) => {
132
+ const response = await fetch(url, { credentials: "include" });
133
+ const text = await response.text();
134
+ let payload;
135
+ try {
136
+ payload = JSON.parse(text);
137
+ } catch {
138
+ throw new Error(`接口返回了非 JSON 内容(HTTP ${response.status})`);
139
+ }
140
+ if (!response.ok || payload.code !== 0) throw new Error(payload.message || `接口请求失败(HTTP ${response.status})`);
141
+ return payload.data;
142
+ };
143
+
144
+ const view = await getJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
145
+ if (!view.cid) throw new Error("视频没有可用的 cid");
146
+ const player = await getJson(`https://api.bilibili.com/x/player/wbi/v2?bvid=${encodeURIComponent(bvid)}&cid=${view.cid}`);
147
+ return {
148
+ cid: view.cid,
149
+ title: view.title,
150
+ needLoginSubtitle: player.need_login_subtitle === true,
151
+ subtitles: player.subtitle?.subtitles || []
152
+ };
153
+ }, { bvid });
154
+ }
155
+
156
+ async function downloadSubtitle(context, subtitleUrl) {
157
+ const response = await context.request.get(subtitleUrl, { failOnStatusCode: false });
158
+ if (!response.ok()) throw new Error(`字幕文件请求失败(HTTP ${response.status()})`);
159
+ try {
160
+ return await response.json();
161
+ } catch {
162
+ throw new Error("字幕文件不是 JSON");
163
+ }
164
+ }
165
+
166
+ function isBilibiliSubtitleHost(hostname) {
167
+ return ["bilibili.com", "bilivideo.com", "hdslb.com"].some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
168
+ }
169
+
170
+ function getSafeSubtitleDetails(outputDir, candidate, subtitle) {
171
+ const id = String(subtitle.id);
172
+ const language = String(subtitle.lan);
173
+ if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(language)) {
174
+ throw new Error("字幕标识或语言代码包含不安全字符");
175
+ }
176
+ const sourceUrl = new URL(subtitle.subtitle_url, BILIBILI_ORIGIN);
177
+ if (sourceUrl.protocol !== "https:" || !isBilibiliSubtitleHost(sourceUrl.hostname)) {
178
+ throw new Error("字幕 URL 必须是 Bilibili 的 HTTPS 地址");
179
+ }
180
+ return {
181
+ outputPath: join(outputDir, `${candidate.bvid}-${id}-${language}.json`),
182
+ sourceUrl: sourceUrl.href
183
+ };
184
+ }
185
+
186
+ function isLoginRequiredError(error) {
187
+ return /请先登录|需要登录|未登录|need[_ ]?login|not logged/i.test(error.message);
188
+ }
189
+
190
+ export async function runSubtitleCollection(options, { launchPersistentContext, waitForLogin = waitForVisibleLogin } = {}) {
191
+ const defaultRunDirectory = resolve("data", "runs", options.date);
192
+ const inputPath = resolve(options.input || join(defaultRunDirectory, "discovery.json"));
193
+ const outputDir = resolve(options.outputDir || join(resolve(inputPath, ".."), "subtitles"));
194
+ const manifestPath = join(resolve(outputDir, ".."), "subtitles.json");
195
+ const discovery = JSON.parse(await readFile(inputPath, "utf8"));
196
+ const candidates = getCandidates(discovery).slice(0, options.limit);
197
+ if (candidates.length === 0) throw new Error("discovery.json 中没有 recommended_candidates");
198
+
199
+ await mkdir(outputDir, { recursive: true });
200
+ if (!launchPersistentContext) {
201
+ const { chromium } = await import("playwright");
202
+ launchPersistentContext = chromium.launchPersistentContext.bind(chromium);
203
+ }
204
+ const context = await launchPersistentContext(resolve(options.profile), {
205
+ channel: "chrome",
206
+ headless: !options.headed,
207
+ viewport: { width: 1440, height: 960 }
208
+ });
209
+ const records = [];
210
+
211
+ try {
212
+ const page = context.pages()[0] || await context.newPage();
213
+ await page.goto(BILIBILI_ORIGIN, { waitUntil: "domcontentloaded" });
214
+ if (options.headed) await waitForLogin();
215
+ const sessionLoginRequired = (await page.title()).includes("验证码");
216
+
217
+ for (const [index, candidate] of candidates.entries()) {
218
+ const record = {
219
+ repository: candidate.repository,
220
+ project_rank: candidate.project_rank,
221
+ bvid: candidate.bvid,
222
+ video_url: candidate.url,
223
+ video_title: candidate.title,
224
+ creator: candidate.creator,
225
+ creator_id: candidate.creator_id,
226
+ creator_url: candidate.creator_url,
227
+ status: null,
228
+ subtitles: []
229
+ };
230
+ if (sessionLoginRequired) {
231
+ record.status = "login_required";
232
+ record.error = "Bilibili 要求验证;请用 --headed 完成验证后重新运行";
233
+ } else try {
234
+ const metadata = await fetchSubtitleMetadata(page, candidate.bvid);
235
+ record.bilibili_title = metadata.title;
236
+ record.cid = metadata.cid;
237
+ if (metadata.subtitles.length === 0) {
238
+ record.status = metadata.needLoginSubtitle ? "login_required" : "no_subtitles";
239
+ } else {
240
+ for (const subtitle of metadata.subtitles) {
241
+ const { outputPath, sourceUrl } = getSafeSubtitleDetails(outputDir, candidate, subtitle);
242
+ const outputRelativePath = relative(resolve(outputDir, ".."), outputPath);
243
+ if (!options.force && await hasSavedSubtitle(outputPath, candidate, subtitle)) {
244
+ record.subtitles.push({
245
+ id: subtitle.id,
246
+ language: subtitle.lan,
247
+ language_name: subtitle.lan_doc,
248
+ is_ai: subtitle.ai_status === 1,
249
+ source_url: sourceUrl,
250
+ file: outputRelativePath,
251
+ status: "already_downloaded"
252
+ });
253
+ continue;
254
+ }
255
+ const body = await downloadSubtitle(context, sourceUrl);
256
+ await writeJsonAtomically(outputPath, {
257
+ schema_version: "1.0",
258
+ source: {
259
+ platform: "bilibili",
260
+ bvid: candidate.bvid,
261
+ cid: metadata.cid,
262
+ video_url: candidate.url,
263
+ subtitle_id: subtitle.id,
264
+ language: subtitle.lan,
265
+ language_name: subtitle.lan_doc,
266
+ is_ai: subtitle.ai_status === 1,
267
+ subtitle_url: sourceUrl,
268
+ fetched_at: new Date().toISOString()
269
+ },
270
+ subtitle: body
271
+ });
272
+ record.subtitles.push({
273
+ id: subtitle.id,
274
+ language: subtitle.lan,
275
+ language_name: subtitle.lan_doc,
276
+ is_ai: subtitle.ai_status === 1,
277
+ source_url: sourceUrl,
278
+ file: outputRelativePath,
279
+ status: "downloaded"
280
+ });
281
+ }
282
+ record.status = "downloaded";
283
+ }
284
+ } catch (error) {
285
+ record.status = isLoginRequiredError(error) ? "login_required" : "error";
286
+ record.error = error.message;
287
+ }
288
+ records.push(record);
289
+ console.log(`[${index + 1}/${candidates.length}] ${candidate.bvid}: ${record.status}`);
290
+ if (record.status === "login_required") break;
291
+ if (index < candidates.length - 1 && options.delayMs > 0) await page.waitForTimeout(options.delayMs);
292
+ }
293
+ } finally {
294
+ await context.close();
295
+ }
296
+
297
+ const summary = {
298
+ candidates: records.length,
299
+ downloaded: records.filter((record) => record.status === "downloaded").length,
300
+ no_subtitles: records.filter((record) => record.status === "no_subtitles").length,
301
+ login_required: records.filter((record) => record.status === "login_required").length,
302
+ errors: records.filter((record) => record.status === "error").length,
303
+ subtitle_files: records.reduce((total, record) => total + record.subtitles.filter((subtitle) => subtitle.status === "downloaded" || subtitle.status === "already_downloaded").length, 0)
304
+ };
305
+ const manifest = {
306
+ schema_version: "1.0",
307
+ run: {
308
+ run_date: discovery.run?.run_date || options.date,
309
+ input: relative(resolve(manifestPath, ".."), inputPath),
310
+ subtitle_policy: "只读取 Bilibili 已提供的字幕轨;不下载音频,不执行转写。",
311
+ completed_at: new Date().toISOString()
312
+ },
313
+ summary,
314
+ videos: records
315
+ };
316
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
317
+ return manifest;
318
+ }
319
+
320
+ async function main() {
321
+ const options = parseArgs(process.argv.slice(2));
322
+ if (options.help) return usage();
323
+ const manifest = await runSubtitleCollection(options);
324
+ const { summary } = manifest;
325
+ const manifestPath = join(resolve(options.outputDir || join(resolve(options.input || join("data", "runs", options.date), "discovery.json"), "..", "subtitles")), "..");
326
+ console.log(`字幕索引已保存:${join(manifestPath, "subtitles.json")}`);
327
+ console.log(`结果:${summary.downloaded} 有字幕,${summary.login_required} 需要重新登录,${summary.no_subtitles} 无字幕,${summary.errors} 失败,共 ${summary.subtitle_files} 个字幕文件`);
328
+ }
329
+
330
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
331
+ main().catch((error) => {
332
+ console.error(`运行失败:${error.message}`);
333
+ process.exitCode = 1;
334
+ });
335
+ }
@@ -0,0 +1,170 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { createInterface } from "node:readline/promises";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ function usage() {
12
+ console.log(`
13
+ 用法:
14
+ npx ai-weekly review-creators [选项]
15
+
16
+ 在本机打开博主人工复核页面。
17
+
18
+ 选项:
19
+ --date YYYY-MM-DD 读取 data/runs/<日期>/;默认今天
20
+ --profiles PATH creator-profiles.json 路径;默认 data/runs/<日期>/synthesis/creator-profiles.json
21
+ --labels PATH creator-labels.json 路径;默认 data/runs/<日期>/creator-labels.json
22
+ --port N 本地端口;默认 4173
23
+ --wait-for-enter 保存复核结果后按回车关闭页面并继续
24
+ --help 显示帮助
25
+ `);
26
+ }
27
+
28
+ export function parseArgs(argv) {
29
+ const options = { date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }), profiles: null, labels: null, port: 4173, waitForEnter: false };
30
+ const valueOptions = new Map([["--date", "date"], ["--profiles", "profiles"], ["--labels", "labels"], ["--port", "port"]]);
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ const arg = argv[index];
33
+ if (arg === "--help") return { help: true };
34
+ if (arg === "--wait-for-enter") { options.waitForEnter = true; continue; }
35
+ const key = valueOptions.get(arg);
36
+ if (!key || index + 1 >= argv.length) throw new Error(`无效参数:${arg}`);
37
+ options[key] = key === "port" ? Number(argv[index + 1]) : argv[index + 1];
38
+ index += 1;
39
+ }
40
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
41
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) throw new Error("--port 必须是 0 到 65535 的整数");
42
+ return options;
43
+ }
44
+
45
+ async function readJson(path) {
46
+ return JSON.parse(await readFile(path, "utf8"));
47
+ }
48
+
49
+ async function readLabels(path) {
50
+ try {
51
+ const value = await readJson(path);
52
+ return Array.isArray(value.creators) ? value.creators : [];
53
+ } catch (error) {
54
+ if (error.code === "ENOENT") return [];
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ async function writeJsonAtomically(path, value) {
60
+ const temporaryPath = `${path}.tmp-${process.pid}`;
61
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`);
62
+ await rename(temporaryPath, path);
63
+ }
64
+
65
+ function escapeHtml(value) {
66
+ return String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[character]));
67
+ }
68
+
69
+ function profileLink(profile) {
70
+ const explicit = profile.profile_url || profile.homepage_url || profile.creator_url;
71
+ try {
72
+ const url = new URL(explicit);
73
+ if (url.protocol === "https:" && (url.hostname === "bilibili.com" || url.hostname.endsWith(".bilibili.com"))) return url.href;
74
+ } catch {}
75
+ return `https://search.bilibili.com/upuser?keyword=${encodeURIComponent(profile.creator)}`;
76
+ }
77
+
78
+ function externalLink(value) {
79
+ try {
80
+ const url = new URL(value);
81
+ if (url.protocol === "https:" && (url.hostname === "bilibili.com" || url.hostname.endsWith(".bilibili.com"))) return url.href;
82
+ } catch {}
83
+ return "#";
84
+ }
85
+
86
+ function mergeProfiles(profiles, labels) {
87
+ const labelMap = new Map(labels.flatMap((label) => [[label.creator, label], ...(label.creator_id ? [[label.creator_id, label]] : [])]));
88
+ return profiles.map((profile) => ({ ...profile, label: labelMap.get(profile.creator_id) || labelMap.get(profile.creator) || null, profile_url: profileLink(profile) }));
89
+ }
90
+
91
+ function renderPage(profiles) {
92
+ const cards = profiles.map((profile) => {
93
+ const label = profile.label;
94
+ const videos = (profile.videos || []).map((video) => `<li><a href="${escapeHtml(externalLink(video.video_url))}" target="_blank" rel="noreferrer">${escapeHtml(video.bvid || "打开视频")}</a><span>${escapeHtml(video.repository || "")}</span></li>`).join("");
95
+ const isMarketing = label?.marketing_label === "营销号" || label?.is_marketing === true;
96
+ return `<article class="creator-card" data-creator="${escapeHtml(profile.creator)}" data-quality="${label?.quality_label === "优质博主" ? "yes" : "no"}">
97
+ <div class="card-top"><div><p class="eyebrow">博主档案</p><h2>${escapeHtml(profile.creator)}</h2></div><span class="status ${label?.quality_label === "优质博主" ? "marked" : ""}">${isMarketing ? "已标记营销号" : label?.quality_label === "优质博主" ? "已人工标记" : "待复核"}</span></div>
98
+ <a class="home-link" href="${escapeHtml(profile.profile_url)}" target="_blank" rel="noreferrer">打开 Bilibili 主页 / 博主搜索 ↗</a>
99
+ <div class="stats"><span><strong>${Number(profile.processed_video_count || 0)}</strong> 条已处理视频</span><span><strong>${Number(profile.project_count || 0)}</strong> 个关联项目</span></div>
100
+ <details><summary>查看视频来源</summary><ul>${videos || "<li>暂无视频来源</li>"}</ul></details>
101
+ <form class="review-form"><input type="hidden" name="creator" value="${escapeHtml(profile.creator)}"><input type="hidden" name="creator_id" value="${escapeHtml(profile.creator_id || "")}"><label class="check"><input type="checkbox" name="quality_label" value="优质博主" ${label?.quality_label === "优质博主" ? "checked" : ""}> 标记为优质博主</label><label class="check"><input type="checkbox" name="marketing_label" value="营销号" ${isMarketing ? "checked" : ""}> 标记为营销号(后续排除候选)</label><label>人工备注<textarea name="note" maxlength="500" placeholder="写下复核依据(可选)">${escapeHtml(label?.note || "")}</textarea></label><button type="submit">保存复核结果</button><span class="saved" role="status"></span></form>
102
+ </article>`;
103
+ }).join("");
104
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>博主人工复核 · AI 周报</title><style>
105
+ :root{color-scheme:light;background:#f5f1ea;color:#20221f;font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% 0,#fffaf1 0,transparent 34%),#f5f1ea}main{max-width:1100px;margin:0 auto;padding:56px 24px 80px}.masthead{display:flex;justify-content:space-between;gap:24px;align-items:end;margin-bottom:34px}.eyebrow{color:#9b5b32;font-size:12px;letter-spacing:.16em;text-transform:uppercase;margin:0 0 8px}h1{font:600 clamp(32px,5vw,58px)/1.05 Georgia,"Songti SC",serif;letter-spacing:-.04em;margin:0;max-width:680px}.intro{color:#6b6d68;line-height:1.7;max-width:430px;margin:0}.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;border-top:1px solid #d9d2c7;border-bottom:1px solid #d9d2c7;padding:15px 0;margin-bottom:18px;color:#777}.toolbar strong{color:#20221f}.creator-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:16px}.creator-card{background:#fffdf8;border:1px solid #ddd5c9;border-radius:18px;padding:22px;box-shadow:0 8px 22px #513d2110}.card-top{display:flex;justify-content:space-between;gap:12px;align-items:start}.creator-card h2{font:600 25px/1.2 Georgia,"Songti SC",serif;margin:0}.status{font-size:12px;border:1px solid #d6cfc4;border-radius:999px;padding:6px 9px;color:#777;white-space:nowrap}.status.marked{background:#f2e3c8;border-color:#dbb674;color:#80551d}.home-link{display:inline-block;color:#9b5b32;text-decoration:none;margin:17px 0 20px}.home-link:hover{text-decoration:underline}.stats{display:flex;gap:24px;border-top:1px solid #ebe5dc;border-bottom:1px solid #ebe5dc;padding:13px 0;color:#777;font-size:13px}.stats strong{display:block;color:#20221f;font-size:22px;font-weight:600;margin-bottom:2px}.creator-card details{margin:15px 0;color:#777;font-size:13px}.creator-card summary{cursor:pointer;color:#555}.creator-card ul{padding-left:18px;line-height:1.8}.creator-card li{display:flex;justify-content:space-between;gap:10px}.creator-card li a{color:#315b73}.creator-card li span{color:#999}.review-form{display:grid;gap:12px;margin-top:17px}.check{font-weight:600;cursor:pointer}.check input{accent-color:#9b5b32;width:16px;height:16px;vertical-align:-3px;margin-right:7px}label:not(.check){display:grid;gap:6px;color:#777;font-size:13px}textarea{resize:vertical;min-height:72px;border:1px solid #d9d2c7;border-radius:10px;background:#fff;padding:10px;font:inherit;font-size:13px}button{border:0;border-radius:10px;padding:11px 14px;background:#20221f;color:#fff;cursor:pointer;font-weight:600}button:hover{background:#9b5b32}.saved{color:#4b765d;font-size:13px;min-height:18px}@media(max-width:650px){main{padding:32px 16px 56px}.masthead{display:block}.intro{margin-top:18px}.creator-grid{grid-template-columns:1fr}.creator-card li{display:block}}
106
+ </style></head><body><main><header class="masthead"><div><p class="eyebrow">AI 周报 · 编辑工作台</p><h1>人工复核博主,<br>把判断留给编辑。</h1></div><p class="intro">这里的标签只代表你的人工确认。播放量、候选评分和更新日期不会替你做质量判断。</p></header><div class="toolbar"><span>共 <strong>${profiles.length}</strong> 位已处理过视频的博主</span><span>本地保存 · 无需登录</span></div><section class="creator-grid">${cards || "<p>暂无博主档案,请先运行 synthesize。</p>"}</section></main><script>document.querySelectorAll('.review-form').forEach(form=>form.addEventListener('submit',async event=>{event.preventDefault();const button=form.querySelector('button');const saved=form.querySelector('.saved');button.disabled=true;saved.textContent='保存中…';try{const body=Object.fromEntries(new FormData(form));body.quality_label=form.querySelector('[name=quality_label]').checked?'优质博主':'';body.marketing_label=form.querySelector('[name=marketing_label]').checked?'营销号':'';const response=await fetch('/api/creator-label',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});if(!response.ok)throw new Error(await response.text());saved.textContent='已保存';const status=form.closest('.creator-card').querySelector('.status');status.textContent=body.marketing_label?'已标记营销号':body.quality_label?'已人工标记':'待复核';status.classList.toggle('marked',Boolean(body.quality_label));}catch(error){saved.textContent='保存失败:'+error.message}finally{button.disabled=false}}));</script></body></html>`;
107
+ }
108
+
109
+ export function createReviewServer({ profilesPath, labelsPath }) {
110
+ return createServer(async (request, response) => {
111
+ try {
112
+ if (request.method === "GET" && request.url === "/") {
113
+ const profilesArtifact = await readJson(profilesPath);
114
+ const profiles = mergeProfiles(Array.isArray(profilesArtifact.creators) ? profilesArtifact.creators : [], await readLabels(labelsPath));
115
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
116
+ response.end(renderPage(profiles));
117
+ return;
118
+ }
119
+ if (request.method === "POST" && request.url === "/api/creator-label") {
120
+ let body = "";
121
+ for await (const chunk of request) { body += chunk; if (body.length > 32_000) throw new Error("请求过大"); }
122
+ const payload = JSON.parse(body);
123
+ if (typeof payload.creator !== "string" || !payload.creator.trim() || typeof payload.note !== "string" || payload.note.length > 500) throw new Error("博主名称或备注无效");
124
+ const labels = await readLabels(labelsPath);
125
+ const matches = (label) => payload.creator_id ? label.creator_id === payload.creator_id : label.creator === payload.creator;
126
+ const previous = labels.find(matches) || {};
127
+ const remaining = labels.filter((label) => !matches(label));
128
+ const next = {
129
+ creator: payload.creator.trim(),
130
+ ...(payload.creator_id ? { creator_id: payload.creator_id } : {}),
131
+ ...(payload.quality_label === "优质博主" ? { quality_label: "优质博主" } : previous.quality_label === "优质博主" && payload.quality_label !== "" ? { quality_label: previous.quality_label } : {}),
132
+ ...(payload.marketing_label === "营销号" ? { marketing_label: "营销号" } : previous.marketing_label === "营销号" && payload.marketing_label !== "" ? { marketing_label: previous.marketing_label } : {}),
133
+ ...(payload.note.trim() ? { note: payload.note.trim() } : {})
134
+ };
135
+ if (next.quality_label || next.marketing_label || next.note) remaining.push(next);
136
+ await writeJsonAtomically(labelsPath, { creators: remaining });
137
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
138
+ response.end(JSON.stringify({ ok: true }));
139
+ return;
140
+ }
141
+ response.writeHead(404); response.end("Not found");
142
+ } catch (error) {
143
+ response.writeHead(error.message === "请求过大" ? 413 : 400, { "content-type": "text/plain; charset=utf-8" });
144
+ response.end(error.message);
145
+ }
146
+ });
147
+ }
148
+
149
+ async function main() {
150
+ const options = parseArgs(process.argv.slice(2));
151
+ if (options.help) return usage();
152
+ const runDirectory = join("data", "runs", options.date);
153
+ const profilesPath = resolve(options.profiles || join(runDirectory, "synthesis", "creator-profiles.json"));
154
+ const labelsPath = resolve(options.labels || (options.profiles ? `${dirname(profilesPath)}/creator-labels.json` : join(runDirectory, "creator-labels.json")));
155
+ const server = createReviewServer({ profilesPath, labelsPath });
156
+ await new Promise((resolvePromise) => server.listen(options.port, "127.0.0.1", resolvePromise));
157
+ const url = `http://127.0.0.1:${server.address().port}`;
158
+ console.log(`博主复核页已启动:${url}`);
159
+ try {
160
+ await execFileAsync("open", [url]);
161
+ } catch (error) {
162
+ console.warn(`无法自动打开浏览器,请手动访问 ${url}:${error.message}`);
163
+ }
164
+ if (!options.waitForEnter) return;
165
+ const input = createInterface({ input: process.stdin, output: process.stdout });
166
+ try { await input.question("请完成博主复核并保存,按 Enter 继续:"); } finally { input.close(); }
167
+ await new Promise((resolvePromise) => server.close(resolvePromise));
168
+ }
169
+
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => { console.error(`启动失败:${error.message}`); process.exitCode = 1; });