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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/ai-weekly.mjs +54 -0
- package/package.json +34 -0
- package/scripts/build-report.mjs +98 -0
- package/scripts/download-report-covers.mjs +145 -0
- package/scripts/fetch-subtitles.mjs +335 -0
- package/scripts/review-creators.mjs +170 -0
- package/scripts/run-discovery.mjs +375 -0
- package/scripts/run-weekly.mjs +88 -0
- package/scripts/summarize-videos.mjs +400 -0
- package/scripts/synthesize-projects.mjs +246 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shenhao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# AI 周报采集原型
|
|
2
|
+
|
|
3
|
+
抓取 GitHub 本周 Trending、按本周新增 Star 排序,随后在 Bilibili 检索前 N 个项目,筛选不超过 20 分钟的视频,并输出每个项目的前 5 个候选。
|
|
4
|
+
|
|
5
|
+
## 作为命令行工具安装
|
|
6
|
+
|
|
7
|
+
需要 Node.js 20 或更高版本。安装后在你希望保存数据的工作目录中运行;所有 `data/runs/` 和浏览器登录 profile 都会创建在当前目录,不会写入 npm 的安装目录。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx --package=ai-weekly playwright install chromium
|
|
11
|
+
npx ai-weekly --help
|
|
12
|
+
npx ai-weekly
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
首次运行需要在打开的浏览器中登录 Bilibili;视频摘要步骤还需要本机已登录的 Codex CLI。详见下方“首次使用”。
|
|
16
|
+
|
|
17
|
+
### 国内网络使用镜像
|
|
18
|
+
|
|
19
|
+
如果 npm 官方源在你的网络中不可用或速度较慢,可临时使用 npmmirror 安装;新发布版本同步到镜像可能有短暂延迟。
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install ai-weekly --registry=https://registry.npmmirror.com
|
|
23
|
+
npx --registry=https://registry.npmmirror.com ai-weekly --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
也可以安装 `cnpm` 后使用镜像安装全局命令:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g cnpm --registry=https://registry.npmmirror.com
|
|
30
|
+
cnpm install ai-weekly
|
|
31
|
+
npx ai-weekly --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
本包仍通过 npm 官方 registry 发布;npmmirror 和 `cnpm` 仅用于下载与安装。
|
|
35
|
+
|
|
36
|
+
## 一键完整流程
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx ai-weekly
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
该命令在同一日期目录中依次执行项目发现、字幕获取、Luna 摘要、项目汇总、博主人工复核和离线报告。发现或字幕阶段检测到 Bilibili 登录受限时,才会打开浏览器并等待你完成登录或验证码后按 Enter;博主复核页保存后按 Enter,流程会继续生成最终报告。
|
|
43
|
+
|
|
44
|
+
## 首次使用
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx --package=ai-weekly playwright install chromium
|
|
48
|
+
npx ai-weekly discover --headed
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
首次运行会使用一个独立 Chrome profile。请在打开的浏览器中登录 Bilibili 或完成验证;该 profile 已被 `.gitignore` 排除,Cookie 不会写入 JSON 或提交到仓库。
|
|
52
|
+
|
|
53
|
+
以后可使用同一 profile 继续执行项目发现:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx ai-weekly discover
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
若 Bilibili 出现验证码,请降低频率并改用 `--headed` 运行,在浏览器中完成验证后重试。
|
|
60
|
+
|
|
61
|
+
## 输出
|
|
62
|
+
|
|
63
|
+
每次执行写入 `data/runs/<日期>/`:
|
|
64
|
+
|
|
65
|
+
- `github-trending.html`:GitHub 原始页面快照。
|
|
66
|
+
- `github-trending.json`:排序后的项目快照。运行时会读取其他日期已生成的该文件,排除已出现的仓库,并从本次周榜后续项目补足至 `--project-limit`(默认 10)条。
|
|
67
|
+
- `bilibili-raw/<项目名>.html`:带登录态获取的原始搜索响应,不应提交到公开仓库。
|
|
68
|
+
- `discovery.json`:标准化结果,保留原始视频卡片、20 分钟过滤结果、前 5 个候选及评分拆分。
|
|
69
|
+
|
|
70
|
+
## 常用参数
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx ai-weekly discover --project-limit 10 --candidate-limit 5 --max-duration-seconds 1200
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
如果已经在复核页将博主标记为营销号,采集时会自动排除其视频,并从同一批次后续合格视频补位:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx ai-weekly discover --creator-labels data/runs/2026-08-29/creator-labels.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
可以用 `--github-only` 验证周榜抓取和排序,而不访问 Bilibili。
|
|
83
|
+
|
|
84
|
+
## 获取已有字幕(不转写)
|
|
85
|
+
|
|
86
|
+
`discovery.json` 生成后,可只读取其中每个推荐候选在 Bilibili 已提供的字幕轨:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx ai-weekly subtitles
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
默认会显示浏览器,并在开始采集前等待你完成登录或验证后按 Enter;已确认登录的 profile 可添加 `--headless` 直接运行。
|
|
93
|
+
|
|
94
|
+
该命令不会下载音频、不会调用 Whisper,也不会为无字幕视频生成内容。它会写入:
|
|
95
|
+
|
|
96
|
+
- `data/runs/<日期>/subtitles/<BV号>-<字幕 ID>-<语言>.json`:字幕原始 JSON 与来源元数据。
|
|
97
|
+
- `data/runs/<日期>/subtitles.json`:每条候选的获取状态;状态为 `downloaded`、`no_subtitles`、`login_required` 或 `error`。`login_required` 表示 Bilibili 将字幕列表限制为登录用户可见;用同一个专用 profile 在 `--headed` 模式完成登录后重新运行即可。
|
|
98
|
+
|
|
99
|
+
可先用少量候选验证登录态和接口:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx ai-weekly subtitles --limit 3
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
同一字幕文件默认不会重复下载;需要重新请求时添加 `--force`。
|
|
106
|
+
|
|
107
|
+
## 生成结构化视频摘要(只读已保存字幕)
|
|
108
|
+
|
|
109
|
+
字幕索引和工件已保存后,可生成供编辑审核的结构化视频摘要:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx ai-weekly summarize --date 2026-08-29
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
该命令只读取 `subtitles.json` 中标记为已保存的字幕工件,不打开浏览器、不下载音频、也不转写。它会写入:
|
|
116
|
+
|
|
117
|
+
- `data/runs/<日期>/summaries/<BV号>-<字幕 ID>-<语言>.json`:由本机已登录的 Codex CLI 根据字幕纯文本生成的事实、视频观点、演示或使用结论、限制条件摘要;同一次调用还会生成单项目判断和阅读页的三段短文,并保留原视频与字幕来源。
|
|
118
|
+
- `data/runs/<日期>/summaries.json`:每个视频的摘要状态;无字幕、需要重新登录和字幕获取失败会保留为待处理状态。
|
|
119
|
+
|
|
120
|
+
命令会把已保存字幕转换为纯文本并通过标准输入交给本机已登录的 `codex exec`,生成固定 JSON 结构;只保存临时 Schema 和模型输出,并在命令结束后删除。摘要子进程会忽略个人 Codex 配置,避免加载无关插件和技能,但仍使用当前 Codex 登录。默认模型为 `gpt-5.6-luna`,可用 `--model <模型名>` 覆盖。字幕内容会发送给 OpenAI 的 Codex 服务,请只处理你有权提交给该服务的字幕。
|
|
121
|
+
|
|
122
|
+
默认处理全部有已保存字幕的视频;每条视频会显示当前进度,单条超过三分钟会记录为失败并继续。需要只处理少量视频时使用 `--limit`:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npx ai-weekly summarize --date 2026-08-29 --limit 5
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
显式处理全部视频时,也可以使用:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npx ai-weekly summarize --date 2026-08-29 --all
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
已有且来源一致的摘要默认跳过;只在需要更新时添加 `--force`。并发默认固定为 1;ChatGPT Plus 没有可供脚本读取的固定 TPM 配额,建议保持 1,最多显式设为 2:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npx ai-weekly summarize --date 2026-08-29 --all --concurrency 2
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
完成后终端和 `summaries.json` 会显示模型名与 Codex CLI 报告的 token 总数;CLI 未返回 token 的条目会单独计数。
|
|
141
|
+
|
|
142
|
+
## 汇总项目与博主档案
|
|
143
|
+
|
|
144
|
+
摘要完成后,可以从本次运行的本地项目快照和摘要索引生成项目介绍及博主档案:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npx ai-weekly synthesize --date 2026-08-29
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
该命令只读取 `discovery.json`、`summaries.json` 和可选的 `creator-labels.json`,不会重新访问 GitHub 或 Bilibili。它会写入:
|
|
151
|
+
|
|
152
|
+
- `data/runs/<日期>/synthesis/project-synthesis.json`:按项目汇总的事实、观点、演示或使用结论、限制条件,以及项目快照、摘要文件和原视频链接。
|
|
153
|
+
- `data/runs/<日期>/synthesis/creator-profiles.json`:从成功摘要视频自动累积的博主、项目关联和处理统计。
|
|
154
|
+
|
|
155
|
+
项目候选不足、视频尚未处理或摘要失败会保留在 `processing.pending`,项目状态为 `incomplete`,不会补写不存在的内容。博主质量标签不会由播放量、候选评分或更新日期推断;如需人工确认,可在运行目录创建:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"creators": [
|
|
160
|
+
{ "creator": "某位 UP 主", "creator_id": "123456", "quality_label": "优质博主", "note": "人工复核" },
|
|
161
|
+
{ "creator": "另一位 UP 主", "creator_id": "789012", "marketing_label": "营销号", "note": "人工复核后排除" }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
也可以通过 `--creator-labels PATH` 指定标签文件。
|
|
167
|
+
|
|
168
|
+
## 可视化复核博主
|
|
169
|
+
|
|
170
|
+
项目汇总后,可启动本地博主复核工作台:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npx ai-weekly review-creators
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
然后打开终端显示的 `http://127.0.0.1:4173`。页面会展示已处理视频、关联项目和 Bilibili 主页链接。主页链接来自首次采集保存的 `creator_id`;旧数据没有 ID 时才退回博主搜索链接。勾选“标记为优质博主”或“标记为营销号”、填写备注并保存即可。营销号会在下一次 discovery 运行中从候选中排除,并由同一批次中后续合格视频补位;如果没有足够视频,仍保留候选不足状态。取消勾选并保存会移除对应人工标签。标签保存后重新运行 `npx ai-weekly synthesize`,让项目汇总和博主档案文件同步最新人工结果。
|
|
177
|
+
|
|
178
|
+
## 生成离线周报网页
|
|
179
|
+
|
|
180
|
+
`npx ai-weekly summarize` 会在每条视频的一次 Codex 调用中同时生成结构化摘要和阅读页的三段短文。若视频同时介绍多个项目,AI 只提取当前目标项目的内容,跳过其他项目的事实、评价与使用方法;视频本身仍会保留。随后 `npx ai-weekly synthesize` 直接写出 `synthesis/report-content.json`,不再需要额外调用 Codex。
|
|
181
|
+
|
|
182
|
+
此前按“多项目视频直接排除”生成的摘要不兼容这一规则,需要先重新摘要:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx ai-weekly summarize --date 2026-08-30 --all --force
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
生成无需实时第三方服务的单文件阅读页:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
npx ai-weekly report --date 2026-08-29
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
默认输出到 `data/runs/2026-08-29/report/index.html`,可直接用浏览器打开。页面按项目组织,每条视频独立展示三个 AI 摘要板块,并保留项目与博主筛选及仓库链接。页面只从 `report-content.json` 的白名单生成内容,不写入字幕、音频、视频文件、Cookie 或凭据。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const commandScripts = new Map([
|
|
7
|
+
["weekly", "../scripts/run-weekly.mjs"],
|
|
8
|
+
["discover", "../scripts/run-discovery.mjs"],
|
|
9
|
+
["subtitles", "../scripts/fetch-subtitles.mjs"],
|
|
10
|
+
["summarize", "../scripts/summarize-videos.mjs"],
|
|
11
|
+
["synthesize", "../scripts/synthesize-projects.mjs"],
|
|
12
|
+
["review-creators", "../scripts/review-creators.mjs"],
|
|
13
|
+
["covers", "../scripts/download-report-covers.mjs"],
|
|
14
|
+
["report", "../scripts/build-report.mjs"]
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
console.log(`
|
|
19
|
+
用法:
|
|
20
|
+
npx ai-weekly [--date YYYY-MM-DD]
|
|
21
|
+
npx ai-weekly <子命令> [选项]
|
|
22
|
+
|
|
23
|
+
省略子命令时执行完整周报流程;省略 --date 时使用上海时区的当天日期。
|
|
24
|
+
|
|
25
|
+
子命令:
|
|
26
|
+
discover 发现本周 GitHub Trending 项目和 Bilibili 候选视频
|
|
27
|
+
subtitles 获取已提供的 Bilibili 字幕
|
|
28
|
+
summarize 根据已保存字幕生成结构化摘要
|
|
29
|
+
synthesize 汇总项目和博主档案
|
|
30
|
+
review-creators 启动博主人工复核页
|
|
31
|
+
covers 下载周报视频封面到本地
|
|
32
|
+
report 生成离线周报网页
|
|
33
|
+
weekly 执行完整周报流程
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(script, args) {
|
|
38
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL(script, import.meta.url)), ...args], { stdio: "inherit" });
|
|
39
|
+
child.once("error", (error) => { console.error(error.message); process.exitCode = 1; });
|
|
40
|
+
child.once("close", (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
if (args.length === 0) run(commandScripts.get("weekly"), []);
|
|
45
|
+
else if (["--help", "help"].includes(args[0])) usage();
|
|
46
|
+
else if (args[0].startsWith("--")) run(commandScripts.get("weekly"), args);
|
|
47
|
+
else {
|
|
48
|
+
const script = commandScripts.get(args[0]);
|
|
49
|
+
if (!script) {
|
|
50
|
+
console.error(`未知子命令:${args[0]}`);
|
|
51
|
+
usage();
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
} else run(script, args.slice(1));
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-weekly",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "从 GitHub Trending 与 Bilibili 候选视频生成本地 AI 周报的命令行工具。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-weekly": "bin/ai-weekly.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"scripts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"discover": "node scripts/run-discovery.mjs",
|
|
21
|
+
"subtitles": "node scripts/fetch-subtitles.mjs",
|
|
22
|
+
"summarize": "node scripts/summarize-videos.mjs",
|
|
23
|
+
"synthesize": "node scripts/synthesize-projects.mjs",
|
|
24
|
+
"covers": "node scripts/download-report-covers.mjs",
|
|
25
|
+
"report": "node scripts/build-report.mjs",
|
|
26
|
+
"demo-report-layout": "open prototype/report-video-card-prototype.html",
|
|
27
|
+
"review-creators": "node scripts/review-creators.mjs",
|
|
28
|
+
"weekly": "node scripts/run-weekly.mjs",
|
|
29
|
+
"test": "node --test"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"playwright": "^1.58.2"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
const SECTIONS = [["project_facts", "项目事实", "facts"], ["video_viewpoints", "视频观点", "views"]];
|
|
6
|
+
|
|
7
|
+
export function parseArgs(argv) {
|
|
8
|
+
const options = { date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }), input: null, outputDir: null };
|
|
9
|
+
const valueOptions = new Map([["--date", "date"], ["--input", "input"], ["--output-dir", "outputDir"]]);
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
if (argv[index] === "--help") return { help: true };
|
|
12
|
+
const key = valueOptions.get(argv[index]);
|
|
13
|
+
if (!key || index + 1 >= argv.length) throw new Error(`无效参数:${argv[index]}`);
|
|
14
|
+
options[key] = argv[index + 1];
|
|
15
|
+
index += 1;
|
|
16
|
+
}
|
|
17
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
|
|
18
|
+
return options;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const text = (value) => typeof value === "string" ? value.trim() : "";
|
|
22
|
+
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character]));
|
|
23
|
+
|
|
24
|
+
function safeUrl(value) {
|
|
25
|
+
try { const url = new URL(text(value)); return url.protocol === "https:" ? url.href : ""; } catch { return ""; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeCoverFile(value, bvid) {
|
|
29
|
+
const path = text(value);
|
|
30
|
+
return /^BV[0-9A-Za-z]+$/.test(bvid) && new RegExp(`^covers/${bvid}\\.(?:jpg|png|webp)$`).test(path) ? path : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeVideo(video, covers) {
|
|
34
|
+
const bvid = text(video.bvid);
|
|
35
|
+
return { bvid, title: text(video.video_title) || bvid, url: safeUrl(video.video_url), creator: text(video.creator), coverFile: safeCoverFile(covers[bvid]?.file, bvid), summary: Object.fromEntries(SECTIONS.map(([key]) => [key, text(video.summary?.[key])])) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeProject(project, covers) {
|
|
39
|
+
const snapshot = project.project_snapshot || {};
|
|
40
|
+
return {
|
|
41
|
+
repository: text(project.repository), status: project.status === "complete" ? "complete" : "incomplete",
|
|
42
|
+
snapshot: { rank: Number.isFinite(snapshot.rank) ? snapshot.rank : null, weekly_stars: Number.isFinite(snapshot.weekly_stars) ? snapshot.weekly_stars : null, description: text(snapshot.description) },
|
|
43
|
+
videos: (Array.isArray(project.videos) ? project.videos : []).map((video) => safeVideo(video, covers)).filter((video) => video.bvid && video.url && SECTIONS.every(([key]) => video.summary[key]))
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sanitizeReportData(report, coverManifest = {}) {
|
|
48
|
+
const covers = coverManifest?.covers && typeof coverManifest.covers === "object" ? coverManifest.covers : {};
|
|
49
|
+
return { runDate: text(report.run?.run_date), summary: { projects: Number(report.summary?.projects) || 0, videos: Number(report.summary?.videos) || 0, errors: Number(report.summary?.errors) || 0 }, projects: (Array.isArray(report.projects) ? report.projects : []).map((project) => safeProject(project, covers)).filter((project) => project.repository) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function coverColors(seed) {
|
|
53
|
+
const palettes = [["#2866cf", "#14234c"], ["#7049cc", "#211738"], ["#09846a", "#102f34"], ["#c36a1f", "#402114"], ["#b33470", "#321629"]];
|
|
54
|
+
const hash = [...seed].reduce((total, character) => ((total << 5) - total + character.charCodeAt(0)) | 0, 0);
|
|
55
|
+
return palettes[Math.abs(hash) % palettes.length];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderReport(data) {
|
|
59
|
+
const projectOptions = data.projects.map((project) => `<option value="${escapeHtml(project.repository)}">${escapeHtml(project.repository)}</option>`).join("");
|
|
60
|
+
const creatorOptions = [...new Set(data.projects.flatMap((project) => project.videos.map((video) => video.creator)).filter(Boolean))].sort().map((creator) => `<option value="${escapeHtml(creator)}">${escapeHtml(creator)}</option>`).join("");
|
|
61
|
+
const cards = data.projects.map((project) => {
|
|
62
|
+
const creators = [...new Set(project.videos.map((video) => video.creator).filter(Boolean))];
|
|
63
|
+
const videos = project.videos.map((video, index) => {
|
|
64
|
+
const [start, end] = coverColors(video.bvid);
|
|
65
|
+
const summaries = SECTIONS.map(([key, label, className]) => `<section class="summary-panel ${className}"><h4>${label}</h4><p>${escapeHtml(video.summary[key])}</p></section>`).join("");
|
|
66
|
+
const image = video.coverFile ? `<img class="video-cover-image" src="${escapeHtml(video.coverFile)}" alt="${escapeHtml(video.title)} 封面">` : "";
|
|
67
|
+
return `<article class="video" data-video-slide${index ? " hidden" : ""}><div class="video-layout"><a class="video-cover${video.coverFile ? " has-image" : ""}" href="${escapeHtml(video.url)}" target="_blank" rel="noreferrer" style="--cover-start:${start};--cover-end:${end}">${image}<span>点击打开原视频</span><h3>${escapeHtml(video.title)}</h3><strong>在 Bilibili 查看 ↗</strong></a><div class="video-summary">${summaries}</div></div></article>`;
|
|
68
|
+
}).join("");
|
|
69
|
+
const carousel = project.videos.length ? `<div class="video-carousel" data-video-carousel>${videos}<nav class="carousel-controls" aria-label="切换项目视频"><button type="button" class="carousel-button" data-carousel-prev aria-label="上一条视频">←</button><span data-carousel-count>1 / ${project.videos.length}</span><button type="button" class="carousel-button" data-carousel-next aria-label="下一条视频">→</button></nav></div>` : "";
|
|
70
|
+
const noVideo = project.videos.length ? "" : `<p class="pending">本项目暂无可阅读的视频摘要。</p>`;
|
|
71
|
+
return `<article class="project" data-project-card data-project="${escapeHtml(project.repository)}" data-creators="${escapeHtml(creators.join("|"))}"><header><div><span class="rank">#${project.snapshot.rank ?? "—"}</span><h2>${escapeHtml(project.repository)}</h2></div><span class="status ${project.status}">${project.status === "complete" ? "已完成" : "待处理"}</span></header><div class="metrics"><strong>+${project.snapshot.weekly_stars ?? 0}</strong><span>本周新增 Star</span><span>${project.videos.length} 条视频</span></div>${project.snapshot.description ? `<p class="description">${escapeHtml(project.snapshot.description)}</p>` : ""}${carousel}${noVideo}<footer><a href="https://github.com/${escapeHtml(project.repository)}" target="_blank" rel="noreferrer">打开仓库 ↗</a></footer></article>`;
|
|
72
|
+
}).join("");
|
|
73
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AI 周报 · ${escapeHtml(data.runDate)}</title><style>
|
|
74
|
+
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#eaf0ff;background:#0c1120;line-height:1.6}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 50% -20%,#263e71 0,transparent 38rem),#0c1120}.wrap{max-width:1120px;margin:auto;padding:56px 24px}.hero{display:flex;justify-content:space-between;gap:24px;align-items:end;margin-bottom:32px}.eyebrow{color:#91a4cf;letter-spacing:.12em;text-transform:uppercase;font-size:12px;font-weight:700}.hero h1{font-size:clamp(34px,6vw,64px);line-height:1;margin:8px 0 12px;letter-spacing:-.05em}.hero p{margin:0;color:#b7c4e1}.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.summary div{background:#17233e;border:1px solid #314463;border-radius:14px;padding:14px}.summary strong{display:block;font-size:25px}.summary span{color:#aebde0;font-size:12px}.filters{display:flex;gap:12px;flex-wrap:wrap;background:#152440;border-radius:16px;padding:16px;margin:26px 0}.filters label{color:#dbe8f8;font-size:12px;display:grid;gap:4px}.filters select{min-width:220px;border:0;border-radius:8px;padding:9px;background:#f7faff;color:#152238}.grid{display:grid;gap:18px}.project{background:#111b30;border:1px solid #314463;border-radius:20px;padding:24px;box-shadow:0 12px 40px #0004}.project>header{display:flex;justify-content:space-between;gap:12px;align-items:start}.rank{color:#91a4cf;font-size:13px}h2{font-size:25px;margin:2px 0 0;letter-spacing:-.03em}.status{font-size:12px;border-radius:999px;padding:4px 9px;background:#153c35;color:#a7f1c5;white-space:nowrap}.status.incomplete{background:#4c3718;color:#ffd98d}.metrics{display:flex;align-items:baseline;gap:10px;color:#aebde0;margin:13px 0}.metrics strong{color:#9fe6ff;font-size:25px}.description{color:#c3d0e9}.video-carousel{border-top:1px solid #314463;margin-top:24px;padding-top:24px}.video{margin:0}.video-layout{display:grid;grid-template-columns:minmax(280px,1.1fr) minmax(320px,1fr);gap:14px}.video-cover{position:relative;isolation:isolate;overflow:hidden;aspect-ratio:16/9;border-radius:18px;padding:24px;text-decoration:none;color:white;background:linear-gradient(135deg,var(--cover-start),var(--cover-end));display:flex;flex-direction:column}.video-cover-image{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#08101b;z-index:-2}.video-cover.has-image:before{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(180deg,#08101b33 0%,#08101bdd 100%)}.video-cover:after{content:"";position:absolute;z-index:-1;width:390px;height:390px;border:1px solid #ffffff55;border-radius:50%;right:-130px;top:-205px;box-shadow:-72px 112px 0 -23px #ffffff24,-140px 178px 0 -48px #ffffff30}.video-cover.has-image:after{display:none}.video-cover span{color:#ffffffc9;font-size:12px;letter-spacing:.1em;text-transform:uppercase}.video-cover h3{font-size:clamp(26px,3.3vw,42px);line-height:1.1;letter-spacing:-.04em;margin:auto 0}.video-cover strong{align-self:flex-end;background:#0d1425bd;border-radius:999px;padding:6px 10px;font-size:13px}.video-summary{display:grid;gap:12px}.summary-panel{padding:20px;border-radius:16px}.summary-panel h4{font-size:12px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 8px}.summary-panel p{margin:0;color:#e3eaff}.facts{background:#172d48}.facts h4{color:#91d5ff}.views{background:#27224a}.views h4{color:#c5bbff}.carousel-controls{display:flex;justify-content:flex-end;align-items:center;gap:10px;margin-top:12px;color:#b7c4e1;font-size:13px}.carousel-button{width:34px;height:34px;border:1px solid #4a6087;border-radius:50%;background:#172a49;color:#eaf0ff;font-size:18px;cursor:pointer}.carousel-button:hover:not(:disabled){background:#24416d}.carousel-button:disabled{opacity:.42;cursor:default}.excluded,.pending{border-radius:8px;padding:8px 12px;margin-top:18px}.excluded{background:#203652;color:#cedcf4}.pending{background:#4c3718;color:#ffd98d}footer{border-top:1px solid #314463;margin-top:22px;padding-top:14px;font-size:13px}footer a{color:#a9c7ff}#empty{display:none;text-align:center;padding:48px;color:#b7c4e1;background:#17233e;border-radius:16px}@media(max-width:760px){.wrap{padding:32px 16px}.hero{flex-direction:column;align-items:start}.summary{grid-template-columns:repeat(2,1fr)}.metrics{flex-wrap:wrap}.video-layout{grid-template-columns:1fr}}
|
|
75
|
+
</style></head><body><main class="wrap"><header class="hero"><div><div class="eyebrow">Offline AI Weekly Report</div><h1>AI 周报</h1><p>项目快照与逐视频 AI 摘要 · ${escapeHtml(data.runDate)}</p></div><div class="summary"><div><strong>${data.summary.projects}</strong><span>项目</span></div><div><strong>${data.summary.videos}</strong><span>视频</span></div><div><strong>${data.summary.errors}</strong><span>处理失败</span></div></div></header><div class="filters"><label>按项目<select data-filter-project><option value="">全部项目</option>${projectOptions}</select></label><label>按博主<select data-filter-creator><option value="">全部博主</option>${creatorOptions}<option value="__none__">未关联博主</option></select></label></div><div class="grid">${cards}</div><div id="empty" data-empty-state>没有匹配的项目。</div></main><script>const cards=[...document.querySelectorAll('[data-project-card]')],project=document.querySelector('[data-filter-project]'),creator=document.querySelector('[data-filter-creator]'),empty=document.querySelector('[data-empty-state]');for(const carousel of document.querySelectorAll('[data-video-carousel]')){const slides=[...carousel.querySelectorAll('[data-video-slide]')],previous=carousel.querySelector('[data-carousel-prev]'),next=carousel.querySelector('[data-carousel-next]'),count=carousel.querySelector('[data-carousel-count]');let index=0;const render=()=>{slides.forEach((slide,slideIndex)=>{slide.hidden=slideIndex!==index});count.textContent=(index+1)+' / '+slides.length;previous.disabled=slides.length<2;next.disabled=slides.length<2};previous.addEventListener('click',()=>{index=(index-1+slides.length)%slides.length;render()});next.addEventListener('click',()=>{index=(index+1)%slides.length;render()});render()}function filter(){const p=project.value,c=creator.value;let visible=0;for(const card of cards){const names=card.dataset.creators?card.dataset.creators.split('|'):[];const match=(!p||card.dataset.project===p)&&(!c||(c==='__none__'?names.length===0:names.includes(c)));card.hidden=!match;if(match)visible++}empty.style.display=visible?'none':'block'}project.addEventListener('change',filter);creator.addEventListener('change',filter);</script></body></html>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function buildReport(options) {
|
|
79
|
+
const inputPath = resolve(options.input || join("data", "runs", options.date, "synthesis", "report-content.json"));
|
|
80
|
+
const outputDir = resolve(options.outputDir || join("data", "runs", options.date, "report"));
|
|
81
|
+
await mkdir(outputDir, { recursive: true });
|
|
82
|
+
const coverManifestPath = resolve(options.coverManifest || join(outputDir, "cover-manifest.json"));
|
|
83
|
+
let coverManifest = {};
|
|
84
|
+
try { coverManifest = JSON.parse(await readFile(coverManifestPath, "utf8")); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
85
|
+
const data = sanitizeReportData(JSON.parse(await readFile(inputPath, "utf8")), coverManifest);
|
|
86
|
+
const outputPath = join(outputDir, "index.html");
|
|
87
|
+
await writeFile(outputPath, renderReport(data));
|
|
88
|
+
return { outputPath, data };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
const options = parseArgs(process.argv.slice(2));
|
|
93
|
+
if (options.help) return console.log("用法:npx ai-weekly report [--date YYYY-MM-DD] [--input PATH] [--output-dir PATH]");
|
|
94
|
+
const result = await buildReport(options);
|
|
95
|
+
console.log(`离线周报已生成:${result.outputPath}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => { console.error(`运行失败:${error.message}`); process.exitCode = 1; });
|
|
@@ -0,0 +1,145 @@
|
|
|
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 MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const BVID_PATTERN = /^BV[0-9A-Za-z]+$/;
|
|
7
|
+
|
|
8
|
+
function usage() {
|
|
9
|
+
console.log(`
|
|
10
|
+
用法:
|
|
11
|
+
npx ai-weekly covers [选项]
|
|
12
|
+
|
|
13
|
+
从发现结果下载已入选视频的 Bilibili 封面,写入离线周报目录。
|
|
14
|
+
重复运行会跳过已下载的封面;使用 --force 可重新下载。
|
|
15
|
+
|
|
16
|
+
选项:
|
|
17
|
+
--date YYYY-MM-DD 读取 data/runs/<日期>/;默认今天
|
|
18
|
+
--input PATH report-content.json 路径
|
|
19
|
+
--discovery PATH discovery.json 路径
|
|
20
|
+
--output-dir PATH 报告目录;默认 data/runs/<日期>/report
|
|
21
|
+
--force 重新下载已存在的封面
|
|
22
|
+
--help 显示本帮助
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseArgs(argv) {
|
|
27
|
+
const options = { date: new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }), input: null, discovery: null, outputDir: null, force: false };
|
|
28
|
+
const valueOptions = new Map([["--date", "date"], ["--input", "input"], ["--discovery", "discovery"], ["--output-dir", "outputDir"]]);
|
|
29
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
30
|
+
if (argv[index] === "--help") return { help: true };
|
|
31
|
+
if (argv[index] === "--force") { options.force = true; continue; }
|
|
32
|
+
const key = valueOptions.get(argv[index]);
|
|
33
|
+
if (!key || index + 1 >= argv.length) throw new Error(`无效参数:${argv[index]}`);
|
|
34
|
+
options[key] = argv[index + 1];
|
|
35
|
+
index += 1;
|
|
36
|
+
}
|
|
37
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("--date 必须为 YYYY-MM-DD");
|
|
38
|
+
return options;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const text = (value) => typeof value === "string" ? value.trim() : "";
|
|
42
|
+
|
|
43
|
+
function bvidFromUrl(value) {
|
|
44
|
+
return text(value).match(/\/video\/(BV[0-9A-Za-z]+)/i)?.[1] || "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isBilibiliCoverUrl(value) {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(text(value));
|
|
50
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
51
|
+
if (url.hostname !== "hdslb.com" && !url.hostname.endsWith(".hdslb.com")) return "";
|
|
52
|
+
url.protocol = "https:";
|
|
53
|
+
return url.href;
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extensionFor(contentType) {
|
|
60
|
+
const type = text(contentType).toLowerCase().split(";", 1)[0];
|
|
61
|
+
return new Map([["image/jpeg", "jpg"], ["image/png", "png"], ["image/webp", "webp"]]).get(type) || "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function exists(path) {
|
|
65
|
+
try { await access(path); return true; } catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeJsonAtomically(path, value) {
|
|
69
|
+
const temporaryPath = `${path}.tmp-${process.pid}`;
|
|
70
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`);
|
|
71
|
+
await rename(temporaryPath, path);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function selectedBvids(report) {
|
|
75
|
+
return [...new Set((Array.isArray(report.projects) ? report.projects : []).flatMap((project) => Array.isArray(project.videos) ? project.videos.map((video) => text(video.bvid)).filter((bvid) => BVID_PATTERN.test(bvid)) : []))];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function coverSources(discovery) {
|
|
79
|
+
return new Map((Array.isArray(discovery.projects) ? discovery.projects : []).flatMap((project) => project.bilibili_search?.recommended_candidates || [])
|
|
80
|
+
.map((candidate) => [bvidFromUrl(candidate.url), { provided: Boolean(text(candidate.cover_url)), url: isBilibiliCoverUrl(candidate.cover_url) }])
|
|
81
|
+
.filter(([bvid]) => BVID_PATTERN.test(bvid)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function lookupCoverUrl(fetchImpl, bvid) {
|
|
85
|
+
const response = await fetchImpl(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
|
|
86
|
+
if (!response.ok) throw new Error(`封面元数据请求失败(HTTP ${response.status})`);
|
|
87
|
+
const payload = await response.json();
|
|
88
|
+
return payload?.code === 0 ? isBilibiliCoverUrl(payload.data?.pic) : "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function downloadReportCovers(options, { fetch: fetchImpl = fetch } = {}) {
|
|
92
|
+
const date = options.date || new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" });
|
|
93
|
+
const runDirectory = resolve("data", "runs", date);
|
|
94
|
+
const inputPath = resolve(options.input || join(runDirectory, "synthesis", "report-content.json"));
|
|
95
|
+
const discoveryPath = resolve(options.discovery || join(runDirectory, "discovery.json"));
|
|
96
|
+
const outputDir = resolve(options.outputDir || join(runDirectory, "report"));
|
|
97
|
+
const coversDir = join(outputDir, "covers");
|
|
98
|
+
const [report, discovery] = await Promise.all([readFile(inputPath, "utf8").then(JSON.parse), readFile(discoveryPath, "utf8").then(JSON.parse)]);
|
|
99
|
+
await mkdir(coversDir, { recursive: true });
|
|
100
|
+
const sources = coverSources(discovery);
|
|
101
|
+
const covers = {};
|
|
102
|
+
const summary = { downloaded: 0, skipped: 0, unavailable: 0, failed: 0 };
|
|
103
|
+
|
|
104
|
+
for (const bvid of selectedBvids(report)) {
|
|
105
|
+
// The filesystem check is intentionally sequential so that an existing local image never triggers a network request.
|
|
106
|
+
let existingExtension = "";
|
|
107
|
+
for (const extension of ["jpg", "png", "webp"]) if (await exists(join(coversDir, `${bvid}.${extension}`))) { existingExtension = extension; break; }
|
|
108
|
+
if (existingExtension && !options.force) {
|
|
109
|
+
covers[bvid] = { file: `covers/${bvid}.${existingExtension}`, status: "already_downloaded" };
|
|
110
|
+
summary.skipped += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const source = sources.get(bvid);
|
|
114
|
+
let coverUrl = source?.url || "";
|
|
115
|
+
try {
|
|
116
|
+
if (!coverUrl && (!source || !source.provided)) coverUrl = await lookupCoverUrl(fetchImpl, bvid);
|
|
117
|
+
if (!coverUrl) { covers[bvid] = { status: "unavailable" }; summary.unavailable += 1; continue; }
|
|
118
|
+
const response = await fetchImpl(coverUrl);
|
|
119
|
+
const extension = extensionFor(response.headers.get("content-type"));
|
|
120
|
+
if (!response.ok || !extension) throw new Error(response.ok ? "封面响应不是支持的图片格式" : `封面请求失败(HTTP ${response.status})`);
|
|
121
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
122
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) throw new Error("封面图片大小异常");
|
|
123
|
+
const filename = `${bvid}.${extension}`;
|
|
124
|
+
await writeFile(join(coversDir, filename), bytes);
|
|
125
|
+
covers[bvid] = { file: `covers/${filename}`, status: "downloaded" };
|
|
126
|
+
summary.downloaded += 1;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
covers[bvid] = { status: "failed", error: error.message };
|
|
129
|
+
summary.failed += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const manifestPath = join(outputDir, "cover-manifest.json");
|
|
133
|
+
await writeJsonAtomically(manifestPath, { schema_version: "1.0", generated_at: new Date().toISOString(), input: relative(outputDir, inputPath), covers, summary });
|
|
134
|
+
return { manifestPath, covers, summary };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function main() {
|
|
138
|
+
const options = parseArgs(process.argv.slice(2));
|
|
139
|
+
if (options.help) return usage();
|
|
140
|
+
const result = await downloadReportCovers(options);
|
|
141
|
+
console.log(`封面清单已生成:${result.manifestPath}`);
|
|
142
|
+
console.log(`封面:新增 ${result.summary.downloaded},跳过 ${result.summary.skipped},不可用 ${result.summary.unavailable},失败 ${result.summary.failed}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => { console.error(`运行失败:${error.message}`); process.exitCode = 1; });
|