video-pipeline 1.2.7 → 1.2.9

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/.env.example CHANGED
@@ -54,12 +54,13 @@ WHISPER_SERVICE=http://localhost:9588 # 【自由】服务地址
54
54
  WHISPER_TEMPERATURE=0.0 # 【自由】whisper 推理温度 (0.0~1.0, 越低越确定)
55
55
  WHISPER_TEMPERATURE_INC=0.2 # 【自由】whisper 温度增量 (fallback 时升温步长)
56
56
  WHISPER_RESPONSE_FORMAT=json # 【自由】whisper 返回格式 (json/text/srt/vtt)
57
+ WHISPER_SERVICE_MODEL=models/ggml-base.bin # 【自由】服务端模型文件路径(留空则使用服务器当前模型)
57
58
 
58
59
  # 本地模式 - openai-whisper CLI (取消下方注释并注释上方即可切换)
59
60
  # WHISPER_BACKEND=local
60
61
  # WHISPER_MODEL=base
61
62
  # WHISPER_DEVICE=cpu
62
- # WHISPER_LANGUAGE=zh
63
+ # WHISPER_LANGUAGE=
63
64
 
64
65
  # ── 转码参数 ────────────────────────────────────────────────────────────────
65
66
  # 【自由】wav 格式参数通常不变;如需其他格式(mp3/flac)可一起修改
@@ -88,27 +89,27 @@ VIDEO_SHEETS=YouTube视频,普诺赛中文站
88
89
 
89
90
  # ── 平台优先级 ──────────────────────────────────────────────────────────────
90
91
  # 【调序】可选值来自以下固定集合(与上方平台列名后缀对应):
91
- # bilibiliBvid = B 站 BV 号
92
- # youtubeId = YouTube 视频 ID
93
- # tencentVid = 腾讯视频 VID
94
- # youkuId = 优酷视频 ID
92
+ # bilibili = B 站 BV 号
93
+ # youtube = YouTube 视频 ID
94
+ # tencent = 腾讯视频 VID
95
+ # youku = 优酷视频 ID
95
96
  #
96
97
  # 你可以:
97
98
  # 1. 调整顺序 → 排前面的平台优先下载
98
99
  # 2. 增减条目 → 只保留你需要的平台
99
100
  # ✗ 不能使用集合之外的值(如 "tiktokId"、"douyinId" 等未定义的 key)
100
- PLATFORM_PRIORITY=bilibiliBvid,youtubeId,tencentVid,youkuId
101
+ PLATFORM_PRIORITY=bilibili,youtube,tencent,youku
101
102
 
102
103
  # ── 平台 URL 模板 ───────────────────────────────────────────────────────────
103
104
  # 【关联】模板中的 {占位符} 必须与 PLATFORM_PRIORITY 中的 key 名一致
104
- # 如 {youtubeId} 会被 COL_YOUTUBEID 列的值替换
105
+ # 如 {youtube} 会被 COL_YOUTUBEID 列的值替换
105
106
  # 【自由】URL 本身可随意修改(例如从 youtu.be 改为 www.youtube.com/watch?v=)
106
107
  #
107
108
  # 前缀规则: TENCENT=腾讯视频 BILIBILI=B站 YOUTUBE=YouTube YOUKU=优酷
108
- TENCENT_URL_TPL=https://v.qq.com/x/page/{tencentVid}.html
109
- BILIBILI_URL_TPL=https://www.bilibili.com/video/{bilibiliBvid}/
110
- YOUTUBE_URL_TPL=https://youtu.be/{youtubeId}
111
- YOUKU_URL_TPL=https://v.youku.com/v_show/id_{youkuId}.html
109
+ TENCENT_URL_TPL=https://v.qq.com/x/page/{tencent}.html
110
+ BILIBILI_URL_TPL=https://www.bilibili.com/video/{bilibili}/
111
+ YOUTUBE_URL_TPL=https://youtu.be/{youtube}
112
+ YOUKU_URL_TPL=https://v.youku.com/v_show/id_{youku}.html
112
113
 
113
114
  # ── 平台通用配置 ────────────────────────────────────────────────────────────
114
115
  # 以下变量对每个平台分别配置,前缀 = 平台前缀(BILIBILI / YOUTUBE / TENCENT / YOUKU)
@@ -131,7 +132,8 @@ YOUKU_URL_TPL=https://v.youku.com/v_show/id_{youkuId}.html
131
132
  BILIBILI_COOKIES_FROM_BROWSER=firefox # 【自由】firefox / chrome / edge
132
133
  # 方案 B(备用): 从文件读取 cookie
133
134
  # BILIBILI_COOKIE_FILE=cookies/bilibili.txt # 【自由】cookie 文件路径(方案 A 启用时此行无效)
134
- BILIBILI_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 # 【自由】
135
+ # 【自由】自定义 User-Agent (含特殊字符需用双引号包裹)
136
+ BILIBILI_USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
135
137
  BILIBILI_REFERER=https://www.bilibili.com/ # 【自由】
136
138
  BILIBILI_CONCURRENT_FRAGMENTS=4 # 【自由】并发分片数
137
139
  BILIBILI_FORMAT=bestvideo[height<=720]+bestaudio/bestvideo+bestaudio/best # 【自由】
@@ -145,7 +147,8 @@ YOUTUBE_COOKIES_FROM_BROWSER=firefox # 【自由】firefox / chrome /
145
147
  # ⚠️ Windows Chromium 系浏览器 DPAPI 解密已知失败,不建议用 --cookies-from-browser chrome
146
148
  YOUTUBE_PROXY=http://127.0.0.1:7897 # 【自由】代理地址 (Clash Verge 默认 7897, v2rayN 默认 10809)
147
149
  # ⚠️ 脚本会自动给 yt-dlp 及其 node/ejs 子进程注入 HTTPS_PROXY 环境变量
148
- YOUTUBE_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 # 【自由】
150
+ # 【自由】自定义 User-Agent (含特殊字符需用双引号包裹)
151
+ YOUTUBE_USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
149
152
  YOUTUBE_JS_RUNTIMES=node # 【自由】解 n-sig 的 JS 运行时
150
153
  YOUTUBE_REMOTE_COMPONENTS=ejs:github # 【自由】YouTube n-sig 需要的远程组件
151
154
  YOUTUBE_FORMAT=bestvideo[height<=720]+bestaudio/bestvideo+bestaudio/best # 【自由】
@@ -159,7 +162,6 @@ TENCENT_USER_AGENT= # 【自由】
159
162
  YOUKU_COOKIE_FILE= # 【自由】
160
163
  YOUKU_USER_AGENT= # 【自由】
161
164
 
162
-
163
165
  # ── AI 分析/关键词归纳 ─────────────────────────────────────────────────────
164
166
  # 【自由】是否启用 AI 分析环节(true/false),在 transcribe 之后执行
165
167
  AI_ENABLED=true
@@ -167,8 +169,6 @@ AI_ENABLED=true
167
169
  AI_API_KEY=your-api-key-here
168
170
  AI_BASE_URL=https://your-api-host/v1
169
171
  AI_MODEL=your-model-name
170
- # 【自由】请求超时(秒),默认 300
171
- AI_TIMEOUT=300
172
172
  # 【自由】AI 推理温度 (0.0~2.0, 越低越确定/保守, 越高越随机/创意)
173
173
  AI_TEMPERATURE=0.3
174
174
  # 【关联】提示词模板,{content} 占位符会被识别文本替换
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.9] - 2026-06-11
4
+
5
+ ### Bug Fixes
6
+
7
+ - yt-dlp download progress parsing and encoding (`e32acc9`)
8
+ - yt-dlp download progress not showing — listen stdout not stderr (`8cb99b0`)
9
+ - quote USER_AGENT values with double quotes to prevent semicolon parsing issues (`419dff2`)
10
+
11
+ ### Documentation
12
+
13
+ - update (`f761ad4`)
14
+
15
+
16
+ ## [1.2.8] - 2026-06-11
17
+
18
+ ### Features
19
+
20
+ - add colored console output and task separators (PY) (`d983920`)
21
+ - add colored console output and task separators (JS) (`3119a74`)
22
+
23
+ ### Bug Fixes
24
+
25
+ - add long-form platform key mappings + upgrade to bright ANSI colors (`f56cb1f`)
26
+ - remove AI_TIMEOUT env, unify platform keys (`142bc37`)
27
+
28
+ ### Documentation
29
+
30
+ - update (`82637ea`)
31
+ - add missing WHISPER_SERVICE_MODEL to .env.example (`de1d4d8`)
32
+ - restructure README with progressive layout and updated platform keys (`298f049`)
33
+
34
+
3
35
  ## [1.2.7] - 2026-06-11
4
36
 
5
37
  ### Features
package/README.md CHANGED
@@ -3,11 +3,14 @@
3
3
  基于 `process_videos.js` (Node.js) 或 `process_videos.py` (Python),一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
4
4
 
5
5
  **五种使用方式,覆盖不同场景:**
6
- - **Excel 批量处理**:从 Excel 文件读取视频 ID,自动完成全流程(最常用)
7
- - **直链下载**:通过 `--url` 直接指定视频链接,自动识别平台并下载
8
- - **本地文件**:通过 `--input` 指定本地视频文件,跳过下载直接转码分析
9
- - **纯文本分析**:通过 `--content` 直接提供文本内容,跳过所有视频步骤,直接做 AI 关键词分析
10
- - **Excel列文本批量分析**:通过 `--content-column` 读取 Excel 某列的已有文本,批量做 AI 分析
6
+
7
+ | 模式 | 输入 | 跳过步骤 | 适用场景 |
8
+ |------|------|---------|----------|
9
+ | **Excel 批量** | Excel 行(多视频) | — | 批量处理全流程 |
10
+ | **--url 直链** | 单个视频 URL | — | 临时下载单个视频 |
11
+ | **--input 本地** | 本地视频/音频文件 | 下载 | 处理已有文件 |
12
+ | **--content 纯文本** | 文件路径或内联文本 | 下载+转码+识别 | 已有文本直接分析 |
13
+ | **--content-column** | Excel 列的已有文本 | 下载+转码+识别 | 批量分析 Excel 中的文本 |
11
14
 
12
15
  ---
13
16
 
@@ -41,7 +44,7 @@ pip install pandas openpyxl requests python-dotenv questionary
41
44
  ### 必装工具
42
45
 
43
46
  | 工具 | 版本要求 | 安装方式 | 用途 |
44
- |---|---|---|---|
47
+ |------|-----------|----------|------|
45
48
  | Python | 3.9+ | [python.org](https://www.python.org/) | 脚本运行 |
46
49
  | yt-dlp | 最新 | `pip install yt-dlp` 或 [GitHub Release](https://github.com/yt-dlp/yt-dlp/releases) | 视频下载 |
47
50
  | ffmpeg + ffprobe | 4.0+ | [ffmpeg.org](https://ffmpeg.org/download.html) 或 `winget install ffmpeg` | 音频转码 + 时长检测 |
@@ -53,7 +56,7 @@ pip install pandas openpyxl requests python-dotenv questionary
53
56
  YouTube 要求 JS 运行时解开 n-sig 挑战,否则无法提取视频格式。
54
57
 
55
58
  | 方式 | 安装命令 |
56
- |---|---|
59
+ |------|----------|
57
60
  | Node.js(推荐) | [nodejs.org](https://nodejs.org/) 下载 LTS 版,安装后 `node --version` 验证 |
58
61
  | Deno | `winget install DenoLand.Deno` 或 [deno.com](https://deno.com/) |
59
62
 
@@ -62,9 +65,11 @@ YouTube 要求 JS 运行时解开 n-sig 挑战,否则无法提取视频格式
62
65
  ### Python 依赖
63
66
 
64
67
  ```bash
65
- pip install pandas openpyxl requests python-dotenv
68
+ pip install pandas openpyxl requests python-dotenv questionary
66
69
  ```
67
70
 
71
+ > `questionary` 为可选依赖(交互式确认时使用),建议一并安装。
72
+
68
73
  ### 环境变量配置(.env)
69
74
 
70
75
  **从 v2 开始,所有路径、字段映射、平台参数均通过 `.env` 文件配置。** 这意味着同一套脚本可以直接用于其他 Excel 文件,只需修改 `.env` 中的值即可。
@@ -83,10 +88,10 @@ cp .env.example .env
83
88
  |------|------|------|
84
89
  | 输入 | `EXCEL_FILE` | Excel 文件路径 |
85
90
  | 列映射 | `COL_ID` / `COL_TITLE` / `COL_CONTENT` / `COL_KEYWORDS` | 唯一标识列 / 标题列 / 识别文本输出列 / AI 关键词输出列 |
86
- | 列映射 | `COL_TENCENTVID` / `COL_BILIBILIBVID` / `COL_YOUTUBEID` / `COL_YOUKUID` | 各平台视频 ID 所在列 |
91
+ | 列映射 | `COL_TENCENT` / `COL_BILIBILI` / `COL_YOUTUBE` / `COL_YOUKU` | 各平台视频 ID 所在列 |
87
92
  | Sheet | `VIDEO_SHEETS` | 逗号分隔需要处理的 sheet(留空则全部) |
88
93
  | 平台 | `PLATFORM_PRIORITY` | 平台重试优先级 |
89
- | 平台 | `{平台}_URL_TPL` | URL 模板(如 `YOUTUBE_URL_TPL=https://youtu.be/{youtubeId}`) |
94
+ | 平台 | `{平台}_URL_TPL` | URL 模板(如 `YOUTUBE_URL_TPL=https://youtu.be/{youtube}`) |
90
95
  | 平台 | `{平台}_COOKIES_FROM_BROWSER` | 从浏览器直读 cookie(推荐 Firefox,替代手动导出文件) |
91
96
  | 平台 | `{平台}_COOKIE_FILE` | cookie 文件路径(备用方案,需定期更新) |
92
97
  | 平台 | `{平台}_PROXY` | 代理地址(如 `http://127.0.0.1:7897`,Clash Verge) |
@@ -101,7 +106,7 @@ cp .env.example .env
101
106
  | AI 分析 | `AI_ENABLED` | `true` 启用 / `false` 跳过(默认 true) |
102
107
  | AI 分析 | `AI_API_KEY` / `AI_BASE_URL` / `AI_MODEL` | OpenAI 兼容 API 配置 |
103
108
  | AI 分析 | `AI_PROMPT_TPL` | 提示词模板,必须包含 `{content}` 占位符 |
104
- | AI 分析 | `AI_TIMEOUT` | 单次分析请求超时(秒,默认 300) |
109
+ | AI 分析 | `AI_TEMPERATURE` | AI 推理温度 (0.0~2.0) |
105
110
 
106
111
  ### .env 配置项变更权限
107
112
 
@@ -110,11 +115,11 @@ cp .env.example .env
110
115
  | 标记 | 含义 | 涵盖的配置项 | 示例 |
111
116
  |------|------|-------------|------|
112
117
  | **【自由】** | 值可随意改为任意合法内容 | 路径、开关、数字、字符串、URL、UA、格式参数等 | `EXCEL_FILE`, `YOUTUBE_PROXY`, `WHISPER_MODEL` |
113
- | **【调序】** | 只能从固定集合中增减/排序,不能用集合外的值 | `PLATFORM_PRIORITY` | 只能包含 `bilibiliBvid` / `youtubeId` / `tencentVid` / `youkuId` |
114
- | **【关联】** | 值需与脚本内约定的 Key 名一致 | URL 模板中的 `{占位符}` | `{youtubeId}` 必须跟 `COL_YOUTUBEID` 的后缀一致 |
118
+ | **【调序】** | 只能从固定集合中增减/排序,不能用集合外的值 | `PLATFORM_PRIORITY` | 只能包含 `bilibili` / `youtube` / `tencent` / `youku` |
119
+ | **【关联】** | 值需与脚本内约定的 Key 名一致 | URL 模板中的 `{占位符}` | `{youtube}` 必须跟 `COL_YOUTUBE` 的后缀一致 |
115
120
  | **【固定】** | 除非 Excel 列名或脚本内部逻辑改变,否则不应修改 | 列名映射 | `COL_ID=extra.id`、`COL_TITLE=title` 等 |
116
121
 
117
- > **最容易混淆的是【调序】**:`PLATFORM_PRIORITY` 可以调整顺序、增减条目,但只能用脚本已定义的 4 个 key,新增 `tiktokId`、`douyinId` 等无效 key 会导致脚本无法识别。
122
+ > **最容易混淆的是【调序】**:`PLATFORM_PRIORITY` 可以调整顺序、增减条目,但只能用脚本已定义的 4 个 key,新增 `tiktok`、`douyin` 等无效 key 会导致脚本无法识别。
118
123
 
119
124
  ### Whisper 语音识别
120
125
 
@@ -139,9 +144,7 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
139
144
  ```
140
145
  脚本会直接调用 `whisper` CLI,无需额外服务进程。
141
146
 
142
- ---
143
-
144
- ## 目录结构
147
+ ### 目录结构
145
148
 
146
149
  ```
147
150
  ├── process_videos.js # Node.js 主流程脚本(推荐)
@@ -154,28 +157,32 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
154
157
  ├── cookies/ # 站点 cookie 文件
155
158
  │ ├── bilibili.txt # B站 cookie(Netscape 格式)
156
159
  │ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
157
- ├── downloads/ # yt-dlp 下载输出(mp4)
158
- │ ├── YouTube视频/
159
- └── 普诺赛中文站/
160
- ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
161
- │ ├── YouTube视频/
162
- └── 普诺赛中文站/
163
- ├── reports/ # 执行报告(按 sheet/站点分目录)
164
- ├── YouTube视频/
165
- ├── report_YYYYMMDD_HHMMSS.json # JSON 报告(机器可读,用于重跑)
166
- └── tasks/ # 人类可读文本摘要
167
- │ ├── 2143.txt
168
- └── ...
169
- ├── 普诺赛中文站/
170
- ├── report_YYYYMMDD_HHMMSS.json
171
- └── tasks/
172
- └── ...
173
- ├── youtube/ # --url 模式按平台名分目录
174
- ├── report_YYYYMMDD_HHMMSS.json
175
- └── tasks/
176
- │ └── local/ # --input 模式默认目录
177
- │ ├── report_YYYYMMDD_HHMMSS.json
178
- └── tasks/
160
+ ├── output/ # 输出根目录(可通过环境变量覆盖)
161
+ │ ├── downloads/ # yt-dlp 下载输出(mp4)
162
+ │ ├── youtube/ # 按平台分目录
163
+ │ │ └── bilibili/
164
+ │ ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
165
+ │ ├── youtube/
166
+ │ │ └── bilibili/
167
+ └── reports/ # 执行报告(按 sheet/平台分目录)
168
+ ├── YouTube视频/
169
+ ├── report_YYYYMMDD_HHMMSS.json # JSON 报告(机器可读,用于重跑)
170
+ └── tasks/ # 人类可读文本摘要
171
+ ├── 2143.txt
172
+ │ └── ...
173
+ ├── 普诺赛中文站/
174
+ ├── report_YYYYMMDD_HHMMSS.json
175
+ └── tasks/
176
+ │ └── ...
177
+ ├── youtube/ # --url 模式按平台名分目录
178
+ ├── report_YYYYMMDD_HHMMSS.json
179
+ └── tasks/
180
+ │ ├── local/ # --input 模式默认目录
181
+ │ ├── report_YYYYMMDD_HHMMSS.json
182
+ │ │ └── tasks/
183
+ │ └── content/ # --content 模式固定目录
184
+ │ ├── report_YYYYMMDD_HHMMSS.json
185
+ │ └── tasks/
179
186
  ├── scripts/ # 辅助脚本
180
187
  │ ├── release.js # 版本发布脚本
181
188
  │ └── regenerate-changelog.js # CHANGELOG 重建脚本
@@ -215,9 +222,20 @@ yt-dlp 可直接从 Firefox 浏览器读取 cookie,无需手动导出:
215
222
 
216
223
  ### B站(bilibili)
217
224
 
218
- 1. 同样使用上述 Chrome 扩展
225
+ **方案 A(推荐):直接从 Firefox 浏览器读 cookie**
226
+
227
+ 1. 用 Firefox 浏览器登录 [bilibili.com](https://www.bilibili.com)
228
+ 2. 在 `.env` 中设置 `BILIBILI_COOKIES_FROM_BROWSER=firefox`
229
+ 3. 脚本自动通过 `--cookies-from-browser firefox` 读取
230
+
231
+ > Firefox cookie 直读方案同样适用于 B站,无需手动导出。
232
+
233
+ **方案 B(备用):从文件读取 cookie**
234
+
235
+ 1. Chrome 安装扩展 [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
219
236
  2. 访问 [bilibili.com](https://www.bilibili.com) 并登录
220
237
  3. 点击扩展图标 → Export → 保存为 `cookies/bilibili.txt`
238
+ 4. 在 `.env` 中注释掉 `BILIBILI_COOKIES_FROM_BROWSER`,启用 `BILIBILI_COOKIE_FILE=cookies/bilibili.txt`
221
239
 
222
240
  ---
223
241
 
@@ -419,7 +437,7 @@ node process_videos.js --content-column "content" --concurrency 2 --retry 2
419
437
  ## 参数说明
420
438
 
421
439
  | 参数 | 类型 | 默认值 | 说明 |
422
- |---|---|---|---|
440
+ |------|------|---------|------|
423
441
  | `--sheet <name>` | str | 全部 | 指定 sheet 名称 |
424
442
  | `--id <id>` | str | — | 指定 extra.id 或 title(单条测试) |
425
443
  | `--offset <n>` | int | 0 | 跳过前 N 条任务(从 0 开始),适合调试大量数据 |
@@ -449,7 +467,7 @@ node process_videos.js --content-column "content" --concurrency 2 --retry 2
449
467
  ## 重试规则
450
468
 
451
469
  | 可重试 | 不重试 |
452
- |---|---|
470
+ |----------|----------|
453
471
  | 网络超时、连接拒绝 | HTTP 404 / 403 / 401 |
454
472
  | yt-dlp 下载中断 | 视频已删除 / 私有 |
455
473
  | whisper 服务超时 | 无效 URL、文件不存在 |
@@ -462,7 +480,7 @@ node process_videos.js --content-column "content" --concurrency 2 --retry 2
462
480
  脚本默认不会重复处理已有文件,但会在以下情况自动触发重做:
463
481
 
464
482
  | 步骤 | 跳过条件 | 自动重做条件 |
465
- |---|---|---|
483
+ |------|-----------|----------------|
466
484
  | 下载 | 同名文件已存在(非 `--force`) | `--force` 或文件不存在 |
467
485
  | 转码 | WAV 已存在 **且** MP4 时间戳 ≤ WAV 时间戳 | `--force` 或 **MP4 比 WAV 新**(重新下载过) |
468
486
  | 识别 | —(每次必跑,覆盖写入 Excel) | — |
@@ -505,8 +523,8 @@ AI_MODEL=agnes-2.0-flash
505
523
  # 提示词模板({content} 会被识别文本替换)
506
524
  AI_PROMPT_TPL=帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}
507
525
 
508
- # 请求超时(秒)
509
- AI_TIMEOUT=300
526
+ # 请求超时(秒,通过 --analyze-timeout 参数设置)
527
+ # 不再使用 AI_TIMEOUT 环境变量(已统一为 --analyze-timeout 参数)
510
528
  ```
511
529
 
512
530
  ### 工作原理
@@ -544,21 +562,19 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
544
562
  |--------|------|------|
545
563
  | 1 | `{id}` | `2143` |
546
564
  | 2 | `{id}_{title}` | `2143_产品介绍` |
547
- | 3 | `{id}_{title}_{platformVid}` | `2143_产品介绍_BV1xx4y1z7Ab` |
565
+ | 3 | `{id}_{title}_{platform}` | `2143_产品介绍_bilibili` |
548
566
 
549
567
  > 去重仅在同 sheet 内生效,不同 sheet 之间允许同名文件(存放在不同子目录)。
550
568
 
551
569
  ---
552
570
 
553
- ---
554
-
555
571
  ## 进度显示
556
572
 
557
573
  执行时会同时展示**总体进度**和**单视频进度**:
558
574
 
559
575
  ```text
560
- [1/91] [2143] 开始处理 (sheet=YouTube视频, platform=youtubeId, title=xxx)
561
- [2143] 开始下载 (平台=youtubeId)
576
+ [1/91] [2143] 开始处理 (sheet=YouTube视频, platform=youtube, title=xxx)
577
+ [2143] 开始下载 (平台=youtube)
562
578
  [2143] https://youtu.be/zzJmKPX8a3c
563
579
  [2143] 解析页面...
564
580
  [2143] 15.2% 2.50MiB/s ETA 00:17 ← 下载实时进度
@@ -579,7 +595,7 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
579
595
  ```
580
596
 
581
597
  | 层级 | 显示内容 |
582
- |---|---|
598
+ |------|----------|
583
599
  | 总体进度 | 完成/总任务数、百分比、✅成功 ❌失败 ⚠️部分 ⏭️无视频 四维计数 |
584
600
  | 下载 | yt-dlp 实时百分比 + 速度 + ETA |
585
601
  | 转码 | 先 ffprobe 取时长,再实时解析 `time=` 算百分比(如 `25.3% (38s/150s)`) |
@@ -595,7 +611,7 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
595
611
  五种输入来源在不同处理环节的输出路径汇总如下。所有路径均以 `output/` 为根(可通过 `DOWNLOADS_DIR` / `TRANSCODED_DIR` / `REPORTS_DIR` 环境变量覆盖)。
596
612
 
597
613
  > `{sheet}` = Excel 工作表名(如 `YouTube视频`、`普诺赛中文站`)
598
- > `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`tencentVid`、`youku`)
614
+ > `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`tencent`、`youku`)
599
615
  > `{stem}` = 去重后的安全文件名(不含扩展名)
600
616
 
601
617
  ### ① Excel 批量模式(默认)
@@ -778,11 +794,11 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
778
794
  脚本支持四个视频平台的下载,各有不同的反爬配置:
779
795
 
780
796
  | 平台 | 字段 | 反爬措施 |
781
- |---|---|---|
782
- | B站 (bilibili) | `extra.bilibiliBvid` | Chrome UA + Referer 头 + 有效 cookie + 并发分片 |
783
- | YouTube | `extra.youtubeId` | Chrome UA + Firefox cookie 直读 + 代理 + Node.js 解 n-sig |
784
- | 腾讯视频 | `extra.tencentVid` | 无需特殊配置 |
785
- | 优酷 | `extra.youkuId` | 无需特殊配置(部分视频需会员) |
797
+ |------|-------|----------|
798
+ | B站 (bilibili) | `extra.bilibili` | Chrome UA + Referer 头 + 有效 cookie + 并发分片 |
799
+ | YouTube | `extra.youtube` | Chrome UA + Firefox cookie 直读 + 代理 + Node.js 解 n-sig |
800
+ | 腾讯视频 | `extra.tencent` | 无需特殊配置 |
801
+ | 优酷 | `extra.youku` | 无需特殊配置(部分视频需会员) |
786
802
 
787
803
  > YouTube 反爬最强:需要 **代理** + **登录态 cookie** + **JS runtime 解 n-sig** 三者配合。
788
804
  > 脚本会自动给 yt-dlp 及其 node/ejs 子进程注入 `HTTPS_PROXY` 环境变量,确保所有流量走代理。
@@ -795,7 +811,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
795
811
  #### YouTube
796
812
 
797
813
  | 格式类型 | URL 示例 | 视频 ID 提取正则 |
798
- |---|---|---|
814
+ |----------|-----------|-------------------|
799
815
  | 标准观看页 | `https://www.youtube.com/watch?v=VIDEO_ID` | `youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})` |
800
816
  | 短链接 | `https://youtu.be/VIDEO_ID` | `youtu\.be/([a-zA-Z0-9_-]{11})` |
801
817
  | Shorts | `https://www.youtube.com/shorts/VIDEO_ID` | `youtube\.com/shorts/([a-zA-Z0-9_-]{11})` |
@@ -815,7 +831,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
815
831
  #### B站(bilibili)
816
832
 
817
833
  | 格式类型 | URL 示例 | 视频 ID 提取正则 |
818
- |---|---|---|
834
+ |----------|-----------|-------------------|
819
835
  | 标准页(BV 号) | `https://www.bilibili.com/video/BV1xx411c7mD` | `bilibili\.com\/video\/(BV[a-zA-Z0-9]{10})` |
820
836
  | 标准页(av 号) | `https://www.bilibili.com/video/av170001` | `bilibili\.com\/video\/av(\d+)` |
821
837
  | 短链接 | `https://b23.tv/BV1xx411c7mD` | `b23\.tv\/(BV[a-zA-Z0-9]{10})` |
@@ -836,7 +852,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
836
852
  #### 腾讯视频
837
853
 
838
854
  | 格式类型 | URL 示例 | 视频 ID 提取正则 |
839
- |---|---|---|
855
+ |----------|-----------|-------------------|
840
856
  | 标准页(x/page) | `https://v.qq.com/x/page/VIDEO_ID.html` | `v\.qq\.com\/x\/page\/([a-zA-Z0-9]+)\.html` |
841
857
  | 标准页(x/cover) | `https://v.qq.com/x/cover/COVER/VIDEO_ID.html` | `v\.qq\.com\/x\/cover\/[^\/]+\/([a-zA-Z0-9]+)\.html` |
842
858
  | 内嵌页 | `https://v.qq.com/txp/iframe/player.html?vid=VIDEO_ID` | `[?&]vid=([a-zA-Z0-9]+)` |
@@ -853,7 +869,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
853
869
  #### 优酷(Youku)
854
870
 
855
871
  | 格式类型 | URL 示例 | 视频 ID 提取正则 |
856
- |---|---|---|
872
+ |----------|-----------|-------------------|
857
873
  | 标准页(v_show) | `https://v.youku.com/v_show/id_VIDEO_ID.html` | `v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html` |
858
874
  | 标准页(video) | `https://v.youku.com/video/VIDEO_ID` | `v\.youku\.com\/video\/([a-zA-Z0-9=]+)` |
859
875
  | 标准页(www) | `https://www.youku.com/v_show/id_VIDEO_ID.html` | `www\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html` |
@@ -866,18 +882,18 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
866
882
  - **格式互转**:
867
883
  - 优酷内嵌格式较复杂,建议直接使用标准页链接(`{YOUKU_URL_TPL}`)
868
884
 
869
- > **脚本使用提示**:Excel 中只需填入视频 ID(如 `zzJmKPX8a3c`、`BV1pg411b7Ug`、`o0325y3hqh`、`XMzgxNzExNTY4MA==`),脚本自动替换 URL 模板中的 `{youtubeId}`、`{bilibiliBvid}` 等占位符生成下载链接。
885
+ > **脚本使用提示**:Excel 中只需填入视频 ID(如 `zzJmKPX8a3c`、`BV1pg411b7Ug`、`o0325y3hqh`、`XMzgxNzExNTY4MA==`),脚本自动替换 URL 模板中的 `{youtube}`、`{bilibili}` 等占位符生成下载链接。
870
886
 
871
887
  ### 常见下载错误
872
888
 
873
889
  | 错误 | 平台 | 原因 | 解决方案 |
874
- |---|---|---|---|
890
+ |------|------|------|----------|
875
891
  | `Sign in to confirm you're not a bot` | YouTube | cookie 过期或无效 | 检查 Firefox 登录态,或重新导出 cookie 文件 |
876
892
  | `cookies does no longer seem to be valid` | YouTube | cookie 文件超过 48h | 用 Firefox cookies-from-browser 方案(免维护) |
877
893
  | `Unable to download webpage: HTTP Error 403` | YouTube | IP 被识别为非 YouTube 地区 | 确保代理运行(端口 7897),检查 `YOUTUBE_PROXY` |
878
894
  | `n challenge solving failed` | YouTube | 无 JS 运行时 | 安装 Node.js,确保 `YOUTUBE_JS_RUNTIMES=node` |
879
895
  | `Requested format is not available` | YouTube | n-sig 未解开,格式不可用 | 同上,安装 JS 运行时 |
880
- | `HTTP Error 412` | B站 | 缺少 Chrome UA 或 cookie 过期 | 重新导出 `cookies/bilibili.txt` |
896
+ | `HTTP Error 412` | B站 | 缺少 Chrome UA 或 cookie 过期 | 重新导出 `cookies/bilibili.txt` 或使用 Firefox 直读 |
881
897
  | `HTTP Error 403` | B站 | 地区限制或视频已删除 | 检查视频是否可访问 |
882
898
  | `dpapi decryption failed` | YouTube | Windows Chrome cookie 加密 | **改用 Firefox**(`.env` 中设 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`) |
883
899
 
@@ -895,7 +911,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
895
911
  3. 克隆或下载项目文件(`.env.example`、`.env`、`cookies/` 等)
896
912
  4. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
897
913
  5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
898
- 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
914
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`(或设置 `BILIBILI_COOKIES_FROM_BROWSER=firefox`)
899
915
  7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
900
916
  8. `video-pipeline --dry-run` 验证
901
917
 
@@ -906,7 +922,7 @@ node process_videos.js --content-column "content" --concurrency 2 # 执行
906
922
  3. 安装 Python 依赖:`pip install pandas openpyxl requests python-dotenv questionary`
907
923
  4. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
908
924
  5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
909
- 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
925
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`(或设置 `BILIBILI_COOKIES_FROM_BROWSER=firefox`)
910
926
  7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
911
927
  8. `python process_videos.py --dry-run` 验证
912
928
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "video-pipeline",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "视频下载、转码、文本识别、AI 关键词分析一体化流程 CLI 工具",
5
5
  "keywords": [
6
6
  "video",
package/process_videos.js CHANGED
@@ -71,35 +71,40 @@ const COL_ID = process.env.COL_ID || 'extra.id';
71
71
  const COL_TITLE = process.env.COL_TITLE || 'title';
72
72
  const COL_CONTENT = process.env.COL_CONTENT || 'content';
73
73
  const COL_KEYWORDS = process.env.COL_KEYWORDS || 'keywords';
74
- const COL_TENCENTVID = process.env.COL_TENCENTVID || 'extra.tencentVid';
75
- const COL_BILIBILIBVID = process.env.COL_BILIBILIBVID || 'extra.bilibiliBvid';
76
- const COL_YOUTUBEID = process.env.COL_YOUTUBEID || 'extra.youtubeId';
77
- const COL_YOUKUID = process.env.COL_YOUKUID || 'extra.youkuId';
74
+ const COL_TENCENTVID = process.env.COL_TENCENTVID || 'extra.tencent';
75
+ const COL_BILIBILIBVID = process.env.COL_BILIBILIBVID || 'extra.bilibili';
76
+ const COL_YOUTUBEID = process.env.COL_YOUTUBEID || 'extra.youtube';
77
+ const COL_YOUKUID = process.env.COL_YOUKUID || 'extra.youku';
78
78
 
79
79
  // ============================== 平台配置 ==============================
80
80
  const PLATFORM_COL_MAP = {
81
+ tencent: COL_TENCENTVID,
81
82
  tencentVid: COL_TENCENTVID,
83
+ bilibili: COL_BILIBILIBVID,
82
84
  bilibiliBvid: COL_BILIBILIBVID,
85
+ youtube: COL_YOUTUBEID,
83
86
  youtubeId: COL_YOUTUBEID,
87
+ youku: COL_YOUKUID,
84
88
  youkuId: COL_YOUKUID,
85
89
  };
86
90
 
87
91
  // ============================== 工具函数 ==============================
88
92
  function c(color, text) {
89
93
  const colors = {
90
- dim: '\x1b[2m',
91
- yellow: '\x1b[33m',
92
- cyan: '\x1b[36m',
93
- green: '\x1b[32m',
94
- red: '\x1b[31m',
95
- blue: '\x1b[34m',
96
- magenta: '\x1b[35m',
97
- reset: '\x1b[0m',
94
+ bold: '\x1b[1m',
95
+ dim: '\x1b[2m',
96
+ yellow: '\x1b[93m',
97
+ cyan: '\x1b[96m',
98
+ green: '\x1b[92m',
99
+ red: '\x1b[91m',
100
+ blue: '\x1b[94m',
101
+ magenta: '\x1b[95m',
102
+ reset: '\x1b[0m',
98
103
  };
99
104
  return (colors[color] || '') + text + colors.reset;
100
105
  }
101
106
 
102
- const PLATFORM_PRIORITY = (process.env.PLATFORM_PRIORITY || 'bilibiliBvid,youtubeId,tencentVid,youkuId')
107
+ const PLATFORM_PRIORITY = (process.env.PLATFORM_PRIORITY || 'bilibili,youtube,tencent,youku')
103
108
  .split(',').map(s => s.trim()).filter(Boolean);
104
109
 
105
110
  const _VIDEO_SHEETS_RAW = process.env.VIDEO_SHEETS || '';
@@ -108,9 +113,13 @@ const VIDEO_SHEETS = _VIDEO_SHEETS_RAW
108
113
  : [];
109
114
 
110
115
  const _PKEY_ENV_PREFIX = {
116
+ tencent: 'TENCENT',
111
117
  tencentVid: 'TENCENT',
118
+ bilibili: 'BILIBILI',
112
119
  bilibiliBvid: 'BILIBILI',
120
+ youtube: 'YOUTUBE',
113
121
  youtubeId: 'YOUTUBE',
122
+ youku: 'YOUKU',
114
123
  youkuId: 'YOUKU',
115
124
  };
116
125
 
@@ -137,11 +146,11 @@ function buildPlatformConfig() {
137
146
  const ua = process.env[`${prefix}_USER_AGENT`] || '';
138
147
  const extraHeaders = [];
139
148
  if (ua) extraHeaders.push('--user-agent', ua);
140
- if (pkey === 'bilibiliBvid') {
149
+ if (pkey === 'bilibili') {
141
150
  const referer = process.env[`${prefix}_REFERER`] || '';
142
151
  if (referer) extraHeaders.push('--add-header', `Referer:${referer}`);
143
152
  }
144
- if (ua || (pkey === 'bilibiliBvid' && process.env[`${prefix}_REFERER`])) {
153
+ if (ua || (pkey === 'bilibili' && process.env[`${prefix}_REFERER`])) {
145
154
  extraHeaders.push('--add-header', 'Accept-Language:zh,en;q=0.9');
146
155
  }
147
156
  if (extraHeaders.length) cfg.extra_headers = extraHeaders;
@@ -151,7 +160,7 @@ function buildPlatformConfig() {
151
160
  if (cf) cfg.concurrent_fragments = parseInt(cf, 10);
152
161
 
153
162
  // Extra args (YouTube)
154
- if (pkey === 'youtubeId') {
163
+ if (pkey === 'youtube') {
155
164
  const jsRt = process.env[`${prefix}_JS_RUNTIMES`] || '';
156
165
  const rc = process.env[`${prefix}_REMOTE_COMPONENTS`] || '';
157
166
  const extraArgs = [];
@@ -173,13 +182,13 @@ const PLATFORM_CONFIG = buildPlatformConfig();
173
182
 
174
183
  // ============================== 日志 ==============================
175
184
  function logInfo(msg) {
176
- console.log(`${timestamp()} [INFO] ${msg}`);
185
+ console.log(`${timestamp()} ${c('cyan', '[INFO]')} ${msg}`);
177
186
  }
178
187
  function logWarn(msg) {
179
- console.log(`${timestamp()} [WARN] ${msg}`);
188
+ console.log(`${timestamp()} ${c('yellow', '[WARN]')} ${msg}`);
180
189
  }
181
190
  function logError(msg) {
182
- console.log(`${timestamp()} [ERROR] ${msg}`);
191
+ console.log(`${timestamp()} ${c('red', '[ERROR]')} ${msg}`);
183
192
  }
184
193
  function timestamp() {
185
194
  return new Date().toTimeString().slice(0, 8);
@@ -209,7 +218,13 @@ class OverallProgress {
209
218
  }
210
219
  summaryLine() {
211
220
  const pct = this.total ? (this.completed / this.total * 100).toFixed(1) : '0.0';
212
- return `[总进度 ${this.completed}/${this.total} (${pct}%)] 成功:${this.success} 失败:${this.failed} 部分:${this.partial} 无视频:${this.noVideo}`;
221
+ const parts = [];
222
+ parts.push(c('dim', `[总进度 ${this.completed}/${this.total} (${pct}%)]`));
223
+ parts.push(this.success > 0 ? c('green', `✅${this.success}`) : c('dim', '✅0'));
224
+ parts.push(this.failed > 0 ? c('red', `❌${this.failed}`) : c('dim', '❌0'));
225
+ parts.push(this.partial > 0 ? c('yellow', `⚠️${this.partial}`) : c('dim', '⚠️0'));
226
+ parts.push(this.noVideo > 0 ? c('cyan', `⏹️${this.noVideo}`) : c('dim', '⏹️0'));
227
+ return parts.join(' ');
213
228
  }
214
229
  }
215
230
 
@@ -328,7 +343,7 @@ function buildUrl(pkey, vid) {
328
343
  const URL_PLATFORM_MAP = [
329
344
  {
330
345
  platform: 'bilibili',
331
- pkey: 'bilibiliBvid',
346
+ pkey: 'bilibili',
332
347
  patterns: [
333
348
  /bilibili\.com\/video\/(BV[a-zA-Z0-9]{10})/,
334
349
  /b23\.tv\/([a-zA-Z0-9]+)/,
@@ -337,14 +352,14 @@ const URL_PLATFORM_MAP = [
337
352
  },
338
353
  {
339
354
  platform: 'youtube',
340
- pkey: 'youtubeId',
355
+ pkey: 'youtube',
341
356
  patterns: [
342
357
  /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
343
358
  ],
344
359
  },
345
360
  {
346
361
  platform: 'tencent',
347
- pkey: 'tencentVid',
362
+ pkey: 'tencent',
348
363
  patterns: [
349
364
  /v\.qq\.com\/x\/page\/([a-zA-Z0-9]+)\.html/,
350
365
  /v\.qq\.com\/x\/cover\/[^/]+\/([a-zA-Z0-9]+)\.html/,
@@ -353,7 +368,7 @@ const URL_PLATFORM_MAP = [
353
368
  },
354
369
  {
355
370
  platform: 'youku',
356
- pkey: 'youkuId',
371
+ pkey: 'youku',
357
372
  patterns: [
358
373
  /v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html/,
359
374
  ],
@@ -649,15 +664,13 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
649
664
  reject(Object.assign(new Error(`Timeout after ${timeout}s`), { name: 'TimeoutError', code: 'ETIMEDOUT' }));
650
665
  }, timeout * 1000);
651
666
 
652
- if (onProgress && child.stderr) {
653
- const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
654
- rl.on('line', line => {
655
- stderr += line + '\n';
656
- try { onProgress(line); } catch {}
657
- });
658
- child.stderr.on('end', () => rl.close());
659
- // 同时消费 stdout,防止缓冲区填满阻塞子进程
660
- child.stdout.on('data', d => { stdout += d.toString(); });
667
+ if (onProgress) {
668
+ // 同时监听 stdout stderr yt-dlp --newline 的 [download] 进度输出在 stdout
669
+ const onLine = (buf, line) => { try { onProgress(line); } catch {} };
670
+ const rlOut = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
671
+ rlOut.on('line', line => { stdout += line + '\n'; onLine('stdout', line); });
672
+ const rlErr = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
673
+ rlErr.on('line', line => { stderr += line + '\n'; onLine('stderr', line); });
661
674
  } else {
662
675
  child.stdout.on('data', d => { stdout += d.toString(); });
663
676
  child.stderr.on('data', d => { stderr += d.toString(); });
@@ -665,9 +678,6 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
665
678
 
666
679
  child.on('close', code => {
667
680
  clearTimeout(timer);
668
- if (!onProgress) {
669
- // Without onProgress, stderr was captured by the 'data' handler
670
- }
671
681
  if (code === 0) resolve({ stdout, stderr });
672
682
  else reject(Object.assign(new Error(`Exit code ${code}`), { code, stderr }));
673
683
  });
@@ -689,7 +699,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300, label =
689
699
  const model = process.env.AI_MODEL || '';
690
700
  const promptTpl = process.env.AI_PROMPT_TPL || '帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}';
691
701
  const aiTemperature = parseFloat(process.env.AI_TEMPERATURE || '0.3');
692
- const aiTimeout = parseInt(process.env.AI_TIMEOUT || String(timeout), 10);
702
+ const aiTimeout = timeout;
693
703
 
694
704
  if (!apiKey || !baseUrl || !model) {
695
705
  return { text: null, retries: 0, error: 'AI config incomplete' };
@@ -711,7 +721,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300, label =
711
721
  const progressInterval = setInterval(() => {
712
722
  if (!done) {
713
723
  const elapsed = ((Date.now() - analyzeStart) / 1000).toFixed(0);
714
- lockedPrint(` [${label}] AI analyzing... ${elapsed}s`);
724
+ lockedPrint(` [${label}] ${c('green', 'AI analyzing...')} ${elapsed}s`);
715
725
  }
716
726
  }, 5000);
717
727
 
@@ -742,7 +752,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300, label =
742
752
  lastErr = String(e.message).slice(0, 500);
743
753
  if (attempt < maxAttempts - 1) {
744
754
  const delay = Math.min(retryDelay * Math.pow(2, attempt), 30);
745
- lockedPrint(` [${label}] AI attempt ${attempt + 1} failed: ${lastErr.slice(0, 100)}, retrying in ${delay}s...`);
755
+ lockedPrint(` [${label}] AI attempt ${attempt + 1} ${c('red', 'failed')}: ${lastErr.slice(0, 100)}, retrying in ${delay}s...`);
746
756
  await sleep(delay * 1000);
747
757
  }
748
758
  }
@@ -773,13 +783,16 @@ function cleanupPartials(dlDir, stem) {
773
783
 
774
784
  // ============================== 下载 ==============================
775
785
  function parseYtdlpProgress(line) {
776
- // Parse yt-dlp progress line like "[download] 12.3% of ~50.00MiB at 2.5MiB/s ETA 00:15"
777
- const m = line.match(/\[download\]\s+([\d.]+%)\s+of\s+~?([\d.]+[KMG]iB)\s+at\s+([\d.]+[KMG]iB\/s)\s+ETA\s+([\d:]+)/);
778
- if (m) return `DL ${m[1]} of ${m[2]} @ ${m[3]} ETA ${m[4]}`;
779
- // Also try: "[download] 100% of 50.00MiB"
780
- const m2 = line.match(/\[download\]\s+([\d.]+%)\s+of\s+([\d.]+[KMG]iB)/);
781
- if (m2) return `DL ${m2[1]} of ${m2[2]}`;
782
- return null;
786
+ // Parse yt-dlp progress lines like:
787
+ // "[download] 12.3% of ~50.00MiB at 2.5MiB/s ETA 00:15"
788
+ // "[download] 0.0% of 61.66MiB at Unknown B/s ETA Unknown"
789
+ const m = line.match(/\[download\]\s+([\d.]+%)\s+of\s+~?\s*([\d.]+[KMG]iB)/);
790
+ if (!m) return null;
791
+ const pct = m[1]; // e.g. "12.3%"
792
+ const size = m[2]; // e.g. "50.00MiB"
793
+ const spd = (line.match(/at\s+([\d.]+ ?[KMG]?i?B\/s)/) || [])[1] || '?';
794
+ const eta = (line.match(/ETA\s+([\d:]+)/) || [])[1] || '?';
795
+ return `DL ${pct} of ${size} @ ${spd} ETA ${eta}`;
783
796
  }
784
797
 
785
798
  async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeout = 600) {
@@ -796,13 +809,13 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
796
809
  if (!force) {
797
810
  const existing = findDownloadedFile(dlDir, stem);
798
811
  if (existing) {
799
- lockedPrint(` [${stem}] exists ${path.basename(existing)}, skip download`);
812
+ lockedPrint(c('dim', ` [${stem}] exists ${path.basename(existing)}, skip download`));
800
813
  return { file: existing, retries: 0, error: null };
801
814
  }
802
815
  }
803
816
 
804
817
  const videoUrl = buildUrl(pkey, vid);
805
- lockedPrint(` [${stem}] start download (platform=${pkey})`);
818
+ lockedPrint(` [${stem}] ${c('cyan', 'start download')} (platform=${pkey})`);
806
819
  lockedPrint(` [${stem}] ${videoUrl}`);
807
820
 
808
821
  const cfg = PLATFORM_CONFIG[pkey];
@@ -815,6 +828,10 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
815
828
  '-f', cfg.format || 'bestvideo+bestaudio/best',
816
829
  ];
817
830
 
831
+ if (force) {
832
+ args.push('--force-overwrites');
833
+ }
834
+
818
835
  if (cfg.concurrent_fragments) {
819
836
  args.push('--concurrent-fragments', String(cfg.concurrent_fragments));
820
837
  }
@@ -863,7 +880,7 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
863
880
 
864
881
  const downloaded = findDownloadedFile(dlDir, stem);
865
882
  if (downloaded) {
866
- lockedPrint(` [${stem}] download done -> ${path.basename(downloaded)}`);
883
+ lockedPrint(` [${stem}] ${c('green', 'download done')} -> ${path.basename(downloaded)}`);
867
884
  return { file: downloaded, retries: 0, error: null };
868
885
  }
869
886
  logError(`[${stem}] file not found after download`);
@@ -957,7 +974,7 @@ async function stepTranscode(srcFile, sheetName, maxRetries, retryDelay, force,
957
974
 
958
975
  try {
959
976
  await retryCall(doTranscode, maxRetries, retryDelay, stem);
960
- lockedPrint(` [${stem}] transcode done`);
977
+ lockedPrint(` [${stem}] ${c('green', 'transcode done')}`);
961
978
  return { file: outFile, retries: 0, error: null };
962
979
  } catch (e) {
963
980
  logError(`[${stem}] ffmpeg transcode failed: ${(e.stderr || e.message).slice(-2000)}`);
@@ -979,10 +996,10 @@ async function stepTranscribe(audioFile, maxRetries, retryDelay, timeout = 600)
979
996
  const fileSizeMB = (fs.statSync(audioFile).size / (1024 * 1024)).toFixed(1);
980
997
  if (WHISPER_BACKEND === 'local') {
981
998
  const langLabel = WHISPER_LANGUAGE || 'auto';
982
- lockedPrint(` [${stem}] start transcribe [local(${WHISPER_MODEL}/${langLabel})] (${fileSizeMB}MB)...`);
999
+ lockedPrint(` [${stem}] ${c('magenta', 'start transcribe')} [local(${WHISPER_MODEL}/${langLabel})] (${fileSizeMB}MB)...`);
983
1000
  } else {
984
1001
  const modelLabel = WHISPER_SERVICE_MODEL || WHISPER_MODEL || '(server default)';
985
- lockedPrint(` [${stem}] start transcribe [service(${modelLabel})] (${fileSizeMB}MB)...`);
1002
+ lockedPrint(` [${stem}] ${c('magenta', 'start transcribe')} [service(${modelLabel})] (${fileSizeMB}MB)...`);
986
1003
  }
987
1004
 
988
1005
  if (WHISPER_BACKEND === 'local') {
@@ -1018,7 +1035,7 @@ async function transcribeLocal(audioFile, stem, maxRetries, retryDelay, timeout
1018
1035
  const { result: text, retriesUsed, error } = await retryCall(doTranscribe, maxRetries, retryDelay, stem);
1019
1036
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1020
1037
  if (error) return { text: null, retries: retriesUsed, error };
1021
- lockedPrint(` [${stem}] transcribe done (${elapsed}s, ${text.length} chars)`);
1038
+ lockedPrint(` [${stem}] ${c('green', 'transcribe done')} (${elapsed}s, ${text.length} chars)`);
1022
1039
  return { text, retries: 0, error: null };
1023
1040
  } catch (e) {
1024
1041
  logError(`[${stem}] local whisper transcribe failed: ${e.message}`);
@@ -1082,7 +1099,7 @@ async function transcribeService(audioFile, stem, maxRetries, retryDelay, timeou
1082
1099
  const { result: text, retriesUsed, error } = await retryCall(doTranscribe, maxRetries, retryDelay, stem);
1083
1100
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1084
1101
  if (error) return { text: null, retries: retriesUsed, error };
1085
- lockedPrint(` [${stem}] transcribe done (${elapsed}s, ${text.length} chars)`);
1102
+ lockedPrint(` [${stem}] ${c('green', 'transcribe done')} (${elapsed}s, ${text.length} chars)`);
1086
1103
  return { text, retries: 0, error: null };
1087
1104
  } catch (e) {
1088
1105
  logError(`[${stem}] whisper transcribe failed: ${e.message}`);
@@ -1314,8 +1331,13 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1314
1331
  const result = new TaskResult(sheetName, key, title, pkey, videoUrl, stem);
1315
1332
 
1316
1333
  const tag = positionLabel ? `${positionLabel} ` : '';
1317
- lockedPrint(`${tag}[${stem}] start (sheet=${sheetName}, platform=${pkey || 'N/A'}, title=${title.slice(0, 40)})`);
1318
- logInfo(`[${stem}] start (sheet=${sheetName}, platform=${pkey || 'N/A'})`);
1334
+ lockedPrint('');
1335
+ lockedPrint(c('dim', ''.repeat(62)));
1336
+ lockedPrint(c('bold', ` ▶ Task ${positionLabel || '?'}`)
1337
+ + c('dim', ` [${stem}] sheet=${sheetName} platform=${pkey || 'N/A'}`));
1338
+ if (title) lockedPrint(c('dim', ` title: ${title.slice(0, 50)}`));
1339
+ lockedPrint(c('dim', '─'.repeat(62)));
1340
+ logInfo(`[${stem}] start (sheet=${sheetName}, platform=${pkey || 'N/A'}, title=${title.slice(0, 40)})`);
1319
1341
 
1320
1342
  // ── download ──
1321
1343
  let dlFile = null;
@@ -1414,9 +1436,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1414
1436
  const { text: kw, retries, error } = await stepAnalyze(txt, maxRetries, retryDelay, analyzeTimeout, result.stem);
1415
1437
  result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
1416
1438
  if (kw) {
1417
- lockedPrint(` [${result.stem}] AI analysis done (${kw.length} chars)`);
1439
+ lockedPrint(` [${result.stem}] ${c('green', 'AI analysis done')} (${kw.length} chars)`);
1418
1440
  } else {
1419
- lockedPrint(` [${result.stem}] AI analysis failed: ${error}`);
1441
+ lockedPrint(` [${result.stem}] ${c('red', 'AI analysis failed')}: ${error}`);
1420
1442
  }
1421
1443
  } catch (e) {
1422
1444
  result.analyze = new StepResult('failed', null, String(e.message).slice(0, 500), maxRetries);
@@ -2084,6 +2106,8 @@ async function run({
2084
2106
  }
2085
2107
  results.push(result);
2086
2108
  overall.addResult(result.overall_status);
2109
+ lockedPrint('');
2110
+ lockedPrint(c('dim', '─'.repeat(62)));
2087
2111
  console.log(`\n${overall.summaryLine()}\n`);
2088
2112
  return result;
2089
2113
  })
@@ -2284,6 +2308,8 @@ async function runFromReport(reportPath, steps, maxRetries, retryDelay, concurre
2284
2308
  }
2285
2309
  results.push(result);
2286
2310
  overall.addResult(result.overall_status);
2311
+ lockedPrint('');
2312
+ lockedPrint(c('dim', '─'.repeat(62)));
2287
2313
  console.log(`\n${overall.summaryLine()}\n`);
2288
2314
  return result;
2289
2315
  })
@@ -2315,8 +2341,8 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
2315
2341
  .description('视频下载、转码、文本识别、AI分析一体化流程')
2316
2342
  .option('--sheet <name>', '指定 sheet 名称')
2317
2343
  .option('--id <id>', '指定 extra.id 或 title(单条测试)')
2318
- .option('--offset <n>', '跳过前 N 条任务(从 0 开始),默认 0', parseInt, 0)
2319
- .option('--limit <n>', '最多处理 N 条任务,默认无限制', parseInt, 0)
2344
+ .option('--offset <n>', '跳过前 N 条任务(从 0 开始),默认 0', v => parseInt(v, 10), 0)
2345
+ .option('--limit <n>', '最多处理 N 条任务,默认无限制', v => parseInt(v, 10), 0)
2320
2346
  .option('--step <step>', '指定执行步骤(可多次指定),如 --step transcode --step transcribe', (val, prev) => {
2321
2347
  const allowed = ['download', 'transcode', 'transcribe', 'analyze'];
2322
2348
  if (!allowed.includes(val)) {
@@ -2326,13 +2352,13 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
2326
2352
  return [...(prev || []), val];
2327
2353
  })
2328
2354
  .option('--force', '强制重做下载+转码(忽略已有文件)')
2329
- .option('--concurrency <n>', '并发数,默认 1', parseInt, 1)
2330
- .option('--retry <n>', '每步失败最大重试次数,默认 0', parseInt, 0)
2331
- .option('--retry-delay <n>', '重试间隔基数(秒),默认 5', parseFloat, 5.0)
2332
- .option('--download-timeout <n>', '下载超时(秒),默认 600', parseInt, 600)
2333
- .option('--transcode-timeout <n>', '转码超时(秒),默认 600', parseInt, 600)
2334
- .option('--transcribe-timeout <n>', '识别超时(秒),默认 600', parseInt, 600)
2335
- .option('--analyze-timeout <n>', 'AI 分析超时(秒),默认 300', parseInt, 300)
2355
+ .option('--concurrency <n>', '并发数,默认 1', v => parseInt(v, 10), 1)
2356
+ .option('--retry <n>', '每步失败最大重试次数,默认 0', v => parseInt(v, 10), 0)
2357
+ .option('--retry-delay <n>', '重试间隔基数(秒),默认 5', v => parseFloat(v), 5.0)
2358
+ .option('--download-timeout <n>', '下载超时(秒),默认 600', v => parseInt(v, 10), 600)
2359
+ .option('--transcode-timeout <n>', '转码超时(秒),默认 600', v => parseInt(v, 10), 600)
2360
+ .option('--transcribe-timeout <n>', '识别超时(秒),默认 600', v => parseInt(v, 10), 600)
2361
+ .option('--analyze-timeout <n>', 'AI 分析超时(秒),默认 300', v => parseInt(v, 10), 300)
2336
2362
  .option('--dry-run', '干跑模式,只列任务不执行')
2337
2363
  .option('--retry-failed <path>', '从报告 JSON 重跑失败项(output/reports/{sheet}/report_xxx.json)')
2338
2364
  .option('--init', '复制 .env.example 到当前目录并重命名为 .env')