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,375 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { resolve, join } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { pathToFileURL } from "node:url";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ const GITHUB_TRENDING_URL = "https://github.com/trending?since=weekly";
9
+ const BILIBILI_SEARCH_URL = "https://search.bilibili.com/all?keyword=";
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ function usage() {
13
+ console.log(`
14
+ 用法:
15
+ npx ai-weekly discover [选项]
16
+
17
+ 选项:
18
+ --date YYYY-MM-DD 输出目录日期;默认今天
19
+ --project-limit N GitHub 项目数量;默认 10
20
+ --candidate-limit N 每项目保留候选数;默认 5
21
+ --raw-limit N 每个 Bilibili 查询读取的首屏视频数;默认 20
22
+ --max-duration-seconds N 最大时长;默认 1200(20 分钟)
23
+ --creator-labels PATH 人工博主标签;营销号不会进入候选
24
+ --profile PATH 专用 Chrome profile;默认 data/browser-profiles/bilibili
25
+ --headed 显示浏览器窗口(首次登录时使用)
26
+ --wait-for-enter 打开浏览器后等待完成登录或验证并按回车
27
+ --github-only 仅抓取 GitHub 周榜,不启动浏览器
28
+ --help 显示本帮助
29
+
30
+ 示例:
31
+ npx --package=ai-weekly playwright install chromium
32
+ npx ai-weekly discover
33
+ npx ai-weekly discover --headed
34
+ `);
35
+ }
36
+
37
+ function parseArgs(argv) {
38
+ const options = {
39
+ date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }),
40
+ projectLimit: 10,
41
+ candidateLimit: 5,
42
+ rawLimit: 20,
43
+ maxDurationSeconds: 20 * 60,
44
+ headed: false,
45
+ waitForEnter: false,
46
+ githubOnly: false,
47
+ profile: "data/browser-profiles/bilibili",
48
+ creatorLabels: null
49
+ };
50
+ const valueOptions = new Map([
51
+ ["--date", "date"],
52
+ ["--project-limit", "projectLimit"],
53
+ ["--candidate-limit", "candidateLimit"],
54
+ ["--raw-limit", "rawLimit"],
55
+ ["--max-duration-seconds", "maxDurationSeconds"],
56
+ ["--profile", "profile"],
57
+ ["--creator-labels", "creatorLabels"]
58
+ ]);
59
+ for (let index = 0; index < argv.length; index += 1) {
60
+ const arg = argv[index];
61
+ if (arg === "--help") return { help: true };
62
+ if (arg === "--headed" || arg === "--wait-for-enter") {
63
+ if (arg === "--headed") options.headed = true;
64
+ else options.waitForEnter = true;
65
+ continue;
66
+ }
67
+ if (arg === "--github-only") {
68
+ options.githubOnly = true;
69
+ continue;
70
+ }
71
+ const key = valueOptions.get(arg);
72
+ if (!key || index + 1 >= argv.length) throw new Error(`无效参数:${arg}`);
73
+ const value = argv[index + 1];
74
+ options[key] = ["projectLimit", "candidateLimit", "rawLimit", "maxDurationSeconds"].includes(key)
75
+ ? Number(value)
76
+ : value;
77
+ index += 1;
78
+ }
79
+ for (const key of ["projectLimit", "candidateLimit", "rawLimit", "maxDurationSeconds"]) {
80
+ if (!Number.isInteger(options[key]) || options[key] < 1) throw new Error(`${key} 必须是正整数`);
81
+ }
82
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
83
+ return options;
84
+ }
85
+
86
+ async function waitForEnter(message) {
87
+ const input = createInterface({ input: process.stdin, output: process.stdout });
88
+ try { await input.question(message); } finally { input.close(); }
89
+ }
90
+
91
+ async function getText(url) {
92
+ const { stdout } = await execFileAsync("curl", [
93
+ "-LfsS",
94
+ "--connect-timeout", "20",
95
+ "--max-time", "60",
96
+ "-A", "AIWeeklyDiscovery/0.1 (+local personal research)",
97
+ "--",
98
+ url
99
+ ], {
100
+ maxBuffer: 10 * 1024 * 1024
101
+ });
102
+ return stdout;
103
+ }
104
+
105
+ function decodeHtml(value) {
106
+ return value.replaceAll("&amp;", "&").replaceAll("&#39;", "'").replaceAll("&quot;", '"').replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
107
+ }
108
+
109
+ function parseTrending(html) {
110
+ const projects = [];
111
+ for (const match of html.matchAll(/<article class="Box-row">([\s\S]*?)<\/article>/g)) {
112
+ const article = match[1];
113
+ const repository = article.match(/href="\/([^"/]+\/[^"/]+)"[^>]*class="Link"/)?.[1];
114
+ const starsText = article.match(/([\d,]+)\s+stars this week/)?.[1];
115
+ if (!repository || !starsText) continue;
116
+ const descriptionMatch = article.match(/<p class="col-9 color-fg-muted my-1 pr-4">([\s\S]*?)<\/p>/);
117
+ projects.push({
118
+ repository,
119
+ weekly_stars: Number(starsText.replaceAll(",", "")),
120
+ description: descriptionMatch ? decodeHtml(descriptionMatch[1]) : null
121
+ });
122
+ }
123
+ return projects.sort((left, right) => right.weekly_stars - left.weekly_stars);
124
+ }
125
+
126
+ export function selectProjectsExcludingRepositories(projects, previousRepositories, projectLimit) {
127
+ return projects
128
+ .filter((project) => !previousRepositories.has(project.repository))
129
+ .slice(0, projectLimit)
130
+ .map((project, index) => ({ rank: index + 1, ...project }));
131
+ }
132
+
133
+ async function readPreviousTrendingRepositories(runsDirectory, currentRunDate) {
134
+ const repositories = new Set();
135
+ let entries;
136
+ try {
137
+ entries = await readdir(runsDirectory, { withFileTypes: true });
138
+ } catch (error) {
139
+ if (error.code === "ENOENT") return repositories;
140
+ throw error;
141
+ }
142
+
143
+ for (const entry of entries) {
144
+ if (!entry.isDirectory() || entry.name === currentRunDate) continue;
145
+ try {
146
+ const snapshot = JSON.parse(await readFile(join(runsDirectory, entry.name, "github-trending.json"), "utf8"));
147
+ for (const project of snapshot.projects || []) {
148
+ if (typeof project.repository === "string") repositories.add(project.repository);
149
+ }
150
+ } catch (error) {
151
+ if (error.code !== "ENOENT") console.warn(`忽略无法读取的历史 Trending 快照:${entry.name}/github-trending.json`);
152
+ }
153
+ }
154
+ return repositories;
155
+ }
156
+
157
+ function parseDuration(value) {
158
+ const parts = value.split(":").map(Number);
159
+ if (parts.some(Number.isNaN) || parts.length < 2 || parts.length > 3) return null;
160
+ return parts.reduce((seconds, part) => seconds * 60 + part, 0);
161
+ }
162
+
163
+ function parseViewCount(value) {
164
+ const normalized = value.replaceAll(",", "").trim();
165
+ const wan = normalized.match(/^([\d.]+)万$/);
166
+ if (wan) return Math.round(Number(wan[1]) * 10_000);
167
+ const amount = Number(normalized);
168
+ return Number.isFinite(amount) ? amount : null;
169
+ }
170
+
171
+ export function parseSearchCard(text, url, creatorProfile = {}) {
172
+ const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
173
+ const durationIndex = lines.findIndex((line) => /^\d{1,2}:\d{2}(?::\d{2})?$/.test(line));
174
+ if (durationIndex < 0 || !lines[durationIndex + 1]) return null;
175
+ const creatorAndDate = lines.slice(durationIndex + 2).join(" ").match(/^(.*?)\s*·\s*(.+)$/);
176
+ return {
177
+ title: lines[durationIndex + 1],
178
+ url,
179
+ creator: creatorAndDate?.[1]?.trim() || null,
180
+ creator_id: creatorProfile.creator_id || null,
181
+ creator_url: creatorProfile.creator_url || null,
182
+ cover_url: creatorProfile.cover_url || null,
183
+ published: creatorAndDate?.[2]?.trim() || null,
184
+ views_text: lines[0] || null,
185
+ views: parseViewCount(lines[0] || ""),
186
+ danmaku_text: lines[1] || null,
187
+ duration_seconds: parseDuration(lines[durationIndex])
188
+ };
189
+ }
190
+
191
+ function normalize(value) {
192
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
193
+ }
194
+
195
+ function freshnessPoints(published, runDate) {
196
+ if (!published) return 0;
197
+ if (/小时前|刚刚/.test(published)) return 15;
198
+ if (published === "昨天") return 14;
199
+ const match = published.match(/^(?:(\d{4})-)?(\d{1,2})-(\d{1,2})$/);
200
+ if (!match) return 3;
201
+ const [year, month, day] = [Number(match[1] || runDate.slice(0, 4)), Number(match[2]), Number(match[3])];
202
+ const publishedAt = new Date(`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T00:00:00+08:00`);
203
+ const runAt = new Date(`${runDate}T00:00:00+08:00`);
204
+ const ageDays = Math.max(0, Math.floor((runAt - publishedAt) / 86_400_000));
205
+ if (ageDays <= 2) return 15;
206
+ if (ageDays <= 7) return 12;
207
+ if (ageDays <= 30) return 8;
208
+ return 3;
209
+ }
210
+
211
+ function scoreCandidate(candidate, project, runDate) {
212
+ const slug = project.repository.split("/")[1];
213
+ const normalizedTitle = normalize(candidate.title);
214
+ const normalizedSlug = normalize(slug);
215
+ const normalizedRepository = normalize(project.repository);
216
+ const relevance = normalizedTitle.includes(normalizedRepository) || normalizedTitle.includes(normalizedSlug)
217
+ ? 55
218
+ : 35;
219
+ const duration = candidate.duration_seconds <= 5 * 60 ? 15 : candidate.duration_seconds <= 10 * 60 ? 13 : 10;
220
+ const freshness = freshnessPoints(candidate.published, runDate);
221
+ const popularity = candidate.views >= 50_000 ? 15 : candidate.views >= 10_000 ? 12 : candidate.views >= 1_000 ? 8 : candidate.views >= 100 ? 5 : 2;
222
+ return { total: relevance + duration + freshness + popularity, relevance, duration, freshness, popularity };
223
+ }
224
+
225
+ export function selectCandidates(rawResults, project, options) {
226
+ const eligibleResults = rawResults
227
+ .filter((candidate) => candidate.duration_seconds !== null && candidate.duration_seconds <= options.maxDurationSeconds)
228
+ .map((candidate) => ({ ...candidate, score: scoreCandidate(candidate, project, options.date) }))
229
+ .sort((left, right) => right.score.total - left.score.total);
230
+ const marketingCreators = options.marketingCreators || new Set();
231
+ const filteredResults = eligibleResults.filter((candidate) => !marketingCreators.has(candidate.creator_id || candidate.creator));
232
+ return { eligibleResults, filteredResults };
233
+ }
234
+
235
+ const pageExtraction = async ({ url, rawLimit }) => {
236
+ const response = await fetch(url, { credentials: "include" });
237
+ const html = await response.text();
238
+ const extract = (document) => [...document.querySelectorAll('a[href*="/video/BV"]')]
239
+ .filter((anchor, index, anchors) => anchors.findIndex((item) => item.href === anchor.href) === index)
240
+ .slice(0, rawLimit)
241
+ .map((anchor) => {
242
+ const card = anchor.parentElement?.parentElement;
243
+ const creatorAnchor = card?.querySelector('a[href*="space.bilibili.com"], a[href*="/space/"]');
244
+ const creatorId = creatorAnchor?.href?.match(/(?:space\.bilibili\.com|\/space)\/(\d+)/)?.[1] || null;
245
+ const creatorUrl = creatorId ? `https://space.bilibili.com/${creatorId}` : null;
246
+ const cover = card?.querySelector("img");
247
+ const coverUrl = cover?.currentSrc || cover?.src || cover?.getAttribute("data-src") || cover?.getAttribute("data-lazy-src") || null;
248
+ return { url: anchor.href, text: (card?.innerText || card?.textContent || "").trim(), creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl?.startsWith("//") ? `https:${coverUrl}` : coverUrl };
249
+ });
250
+ const document = new DOMParser().parseFromString(html, "text/html");
251
+ return { status: response.status, html, captcha: html.includes("验证码_哔哩哔哩"), cards: extract(document) };
252
+ };
253
+
254
+ async function searchBilibili(page, project, options, rawDirectory) {
255
+ const query = project.repository.split("/")[1];
256
+ const url = `${BILIBILI_SEARCH_URL}${encodeURIComponent(query)}`;
257
+ let extracted = await page.evaluate(pageExtraction, { url, rawLimit: options.rawLimit });
258
+ let extractionMode = "authenticated_fetch";
259
+ if (extracted.captcha) throw new Error(`${project.repository} 的 Bilibili 请求遇到验证码;请用 --headed 登录或稍后重试`);
260
+ let rawResults = extracted.cards.map(({ text, url: cardUrl, creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl }) => parseSearchCard(text, cardUrl, { creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl })).filter(Boolean);
261
+ if (rawResults.length === 0) {
262
+ extractionMode = "dom_fallback";
263
+ await page.goto(url, { waitUntil: "domcontentloaded" });
264
+ await page.waitForTimeout(1_500);
265
+ extracted.captcha = (await page.title()).includes("验证码");
266
+ extracted.cards = await page.evaluate(({ rawLimit }) => [...document.querySelectorAll('a[href*="/video/BV"]')]
267
+ .filter((anchor, index, anchors) => anchors.findIndex((item) => item.href === anchor.href) === index)
268
+ .slice(0, rawLimit)
269
+ .map((anchor) => {
270
+ const card = anchor.parentElement?.parentElement;
271
+ const creatorAnchor = card?.querySelector('a[href*="space.bilibili.com"], a[href*="/space/"]');
272
+ const creatorId = creatorAnchor?.href?.match(/(?:space\.bilibili\.com|\/space)\/(\d+)/)?.[1] || null;
273
+ const creatorUrl = creatorId ? `https://space.bilibili.com/${creatorId}` : null;
274
+ const cover = card?.querySelector("img");
275
+ const coverUrl = cover?.currentSrc || cover?.src || cover?.getAttribute("data-src") || cover?.getAttribute("data-lazy-src") || null;
276
+ return { url: anchor.href, text: (card?.innerText || "").trim(), creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl?.startsWith("//") ? `https:${coverUrl}` : coverUrl };
277
+ }), { rawLimit: options.rawLimit });
278
+ rawResults = extracted.cards.map(({ text, url: cardUrl, creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl }) => parseSearchCard(text, cardUrl, { creator_url: creatorUrl, creator_id: creatorId, cover_url: coverUrl })).filter(Boolean);
279
+ }
280
+ if (extracted.captcha) throw new Error(`${project.repository} 的 Bilibili 页面遇到验证码;请降低访问频率后重试`);
281
+ await writeFile(join(rawDirectory, `${query}.html`), extracted.html);
282
+ const eligibleResults = rawResults
283
+ .filter((candidate) => candidate.duration_seconds !== null && candidate.duration_seconds <= options.maxDurationSeconds)
284
+ .map((candidate) => ({ ...candidate, score: scoreCandidate(candidate, project, options.date) }))
285
+ .sort((left, right) => right.score.total - left.score.total);
286
+ const marketingCreators = options.marketingCreators || new Set();
287
+ const filteredResults = eligibleResults.filter((candidate) => !marketingCreators.has(candidate.creator_id || candidate.creator));
288
+ return {
289
+ query,
290
+ url,
291
+ extraction_mode: extractionMode,
292
+ raw_cards_read: rawResults.length,
293
+ eligible_cards: eligibleResults.length,
294
+ marketing_filtered_cards: eligibleResults.length - filteredResults.length,
295
+ raw_results: rawResults,
296
+ recommended_candidates: filteredResults.slice(0, options.candidateLimit)
297
+ };
298
+ }
299
+
300
+ async function readMarketingCreators(path) {
301
+ if (!path) return new Set();
302
+ try {
303
+ const labels = JSON.parse(await readFile(path, "utf8"));
304
+ return new Set((labels.creators || []).flatMap((creator) => creator.marketing_label === "营销号" || creator.is_marketing === true ? [creator.creator_id, creator.creator].filter(Boolean) : []));
305
+ } catch (error) {
306
+ if (error.code === "ENOENT") return new Set();
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ async function main() {
312
+ const options = parseArgs(process.argv.slice(2));
313
+ if (options.help) return usage();
314
+ const runDirectory = resolve("data", "runs", options.date);
315
+ options.marketingCreators = await readMarketingCreators(options.creatorLabels || join(runDirectory, "creator-labels.json"));
316
+ const rawDirectory = join(runDirectory, "bilibili-raw");
317
+ await mkdir(rawDirectory, { recursive: true });
318
+
319
+ const trendingHtml = await getText(GITHUB_TRENDING_URL);
320
+ await writeFile(join(runDirectory, "github-trending.html"), trendingHtml);
321
+ const trendingProjects = parseTrending(trendingHtml);
322
+ const previousRepositories = await readPreviousTrendingRepositories(resolve("data", "runs"), options.date);
323
+ const projects = selectProjectsExcludingRepositories(trendingProjects, previousRepositories, options.projectLimit);
324
+ const skippedPreviousProjects = trendingProjects.filter((project) => previousRepositories.has(project.repository)).length;
325
+ if (skippedPreviousProjects > 0) console.log(`已跳过历史周榜重复项目 ${skippedPreviousProjects} 个,本次保留 ${projects.length} 个。`);
326
+ await writeFile(join(runDirectory, "github-trending.json"), `${JSON.stringify({ source: GITHUB_TRENDING_URL, projects }, null, 2)}\n`);
327
+
328
+ const output = {
329
+ schema_version: "1.1",
330
+ run: {
331
+ run_date: options.date,
332
+ source_window: `Bilibili 综合搜索首屏前 ${options.rawLimit} 条视频卡片`,
333
+ status: options.githubOnly ? "github_only" : "completed"
334
+ },
335
+ sources: { github_trending_weekly: GITHUB_TRENDING_URL, bilibili_search_base: BILIBILI_SEARCH_URL },
336
+ selection_policy: {
337
+ max_duration_seconds: options.maxDurationSeconds,
338
+ candidate_limit_per_project: options.candidateLimit,
339
+ score_weights: { relevance: 55, duration: 15, freshness: 15, popularity: 15 },
340
+ github_trending_deduplication: "排除其他运行日期已有 github-trending.json 中的 repository;按本次周榜排序从后续项目补足。",
341
+ marketing_creator_policy: "人工标记为营销号的博主不会进入推荐候选;候选不足时仅从同一抓取结果中的其他合格视频补位。"
342
+ },
343
+ projects
344
+ };
345
+ if (!options.githubOnly) {
346
+ const { chromium } = await import("playwright");
347
+ const context = await chromium.launchPersistentContext(resolve(options.profile), {
348
+ channel: "chrome",
349
+ headless: !options.headed,
350
+ viewport: { width: 1440, height: 960 }
351
+ });
352
+ try {
353
+ const page = context.pages()[0] || await context.newPage();
354
+ await page.goto("https://search.bilibili.com/all?keyword=AI", { waitUntil: "domcontentloaded" });
355
+ if (options.waitForEnter) await waitForEnter("请在浏览器中完成 Bilibili 登录或验证,然后按 Enter 继续:");
356
+ if ((await page.title()).includes("验证码")) throw new Error("Bilibili 要求验证;请以 --headed 模式登录或完成验证后重新运行");
357
+ for (const project of output.projects) {
358
+ project.bilibili_search = await searchBilibili(page, project, options, rawDirectory);
359
+ console.log(`已完成:${project.repository}`);
360
+ await page.waitForTimeout(1_000);
361
+ }
362
+ } finally {
363
+ await context.close();
364
+ }
365
+ }
366
+ await writeFile(join(runDirectory, "discovery.json"), `${JSON.stringify(output, null, 2)}\n`);
367
+ console.log(`输出已保存:${join(runDirectory, "discovery.json")}`);
368
+ }
369
+
370
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
371
+ main().catch((error) => {
372
+ console.error(`运行失败:${error.message}`);
373
+ process.exitCode = 1;
374
+ });
375
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { realpathSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+
9
+ const PACKAGE_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
10
+
11
+ function usage() {
12
+ console.log(`
13
+ 用法:
14
+ npx ai-weekly [--date YYYY-MM-DD]
15
+
16
+ 依次执行发现、字幕、Luna 摘要、汇总、人工复核和离线报告。
17
+ 省略 --date 时使用上海时区的当天日期。
18
+ 仅在 Bilibili 登录受限时打开浏览器等待回车;人工复核保存后按回车继续。
19
+ `);
20
+ }
21
+
22
+ export function parseArgs(argv) {
23
+ const options = { date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }) };
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ if (argv[index] === "--help") return { help: true };
26
+ if (argv[index] !== "--date" || index + 1 >= argv.length) throw new Error(`无效参数:${argv[index]}`);
27
+ options.date = argv[index + 1];
28
+ index += 1;
29
+ }
30
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
31
+ return options;
32
+ }
33
+
34
+ function runCommand(script, args) {
35
+ return new Promise((resolvePromise, reject) => {
36
+ const child = spawn(process.execPath, [resolve(PACKAGE_ROOT, script), ...args], { stdio: ["inherit", "pipe", "pipe"] });
37
+ let output = "";
38
+ for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => {
39
+ output += chunk;
40
+ (stream === child.stdout ? process.stdout : process.stderr).write(chunk);
41
+ });
42
+ child.once("error", reject);
43
+ child.once("close", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${script} 退出码 ${code ?? signal}${output ? `:${output.slice(-500)}` : ""}`)));
44
+ });
45
+ }
46
+
47
+ async function readJson(path) {
48
+ return JSON.parse(await readFile(path, "utf8"));
49
+ }
50
+
51
+ export async function runWeekly(options, { run = runCommand, read = readJson, log = console.log } = {}) {
52
+ const dateArgs = ["--date", options.date];
53
+ try {
54
+ await run("scripts/run-discovery.mjs", dateArgs);
55
+ } catch (error) {
56
+ if (!/Bilibili.*(验证码|验证)|验证码|验证/.test(error.message)) throw error;
57
+ log("Bilibili 发现阶段需要人工登录或验证,已打开浏览器。");
58
+ await run("scripts/run-discovery.mjs", [...dateArgs, "--headed", "--wait-for-enter"]);
59
+ }
60
+
61
+ await run("scripts/fetch-subtitles.mjs", [...dateArgs, "--headless"]);
62
+ const subtitlesPath = resolve(join("data", "runs", options.date, "subtitles.json"));
63
+ let subtitles = await read(subtitlesPath);
64
+ if ((subtitles.summary?.login_required || 0) > 0) {
65
+ log("字幕读取需要 Bilibili 登录或验证,已打开浏览器。");
66
+ await run("scripts/fetch-subtitles.mjs", [...dateArgs, "--headed"]);
67
+ subtitles = await read(subtitlesPath);
68
+ if ((subtitles.summary?.login_required || 0) > 0) throw new Error("Bilibili 登录或验证尚未完成,已停止后续流程。");
69
+ }
70
+
71
+ await run("scripts/summarize-videos.mjs", [...dateArgs, "--concurrency", "2"]);
72
+ await run("scripts/synthesize-projects.mjs", dateArgs);
73
+ await run("scripts/review-creators.mjs", [...dateArgs, "--wait-for-enter"]);
74
+ await run("scripts/synthesize-projects.mjs", dateArgs);
75
+ await run("scripts/download-report-covers.mjs", dateArgs);
76
+ await run("scripts/build-report.mjs", dateArgs);
77
+ log(`完整周报已生成:${resolve(join("data", "runs", options.date, "report", "index.html"))}`);
78
+ }
79
+
80
+ async function main() {
81
+ const options = parseArgs(process.argv.slice(2));
82
+ if (options.help) return usage();
83
+ await runWeekly(options);
84
+ }
85
+
86
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
87
+ main().catch((error) => { console.error(`完整流程失败:${error.message}`); process.exitCode = 1; });
88
+ }