video-pipeline 1.0.4 → 1.2.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/.env.example CHANGED
@@ -27,10 +27,10 @@ EXCEL_FILE=data/export_2026-06-10_split.xlsx
27
27
 
28
28
  # ── 输出目录(相对于项目根目录)─────────────────────────────────────────────
29
29
  # 【自由】任意合法目录名
30
- DOWNLOADS_DIR=downloads # 下载的视频文件
31
- TRANSCODED_DIR=transcoded # 转码后的音频文件
32
- REPORTS_DIR=reports # 执行报告 JSON
33
- COOKIES_DIR=cookies # 站点 cookie 文件
30
+ DOWNLOADS_DIR=output/downloads # 下载的视频文件
31
+ TRANSCODED_DIR=output/transcoded # 转码后的音频文件
32
+ REPORTS_DIR=output/reports # 执行报告 JSON
33
+ COOKIES_DIR=cookies # 站点 cookie 文件
34
34
 
35
35
  # ── 外部工具 ────────────────────────────────────────────────────────────────
36
36
  # 【自由】可改为工具的绝对路径或自定义命令名
@@ -51,6 +51,9 @@ FFPROBE=ffprobe # ffprobe 媒体信息
51
51
  # 远程服务模式 - whisper.cpp server (默认)
52
52
  WHISPER_BACKEND=service # 【自由】service 或 local
53
53
  WHISPER_SERVICE=http://localhost:9588 # 【自由】服务地址
54
+ WHISPER_TEMPERATURE=0.0 # 【自由】whisper 推理温度 (0.0~1.0, 越低越确定)
55
+ WHISPER_TEMPERATURE_INC=0.2 # 【自由】whisper 温度增量 (fallback 时升温步长)
56
+ WHISPER_RESPONSE_FORMAT=json # 【自由】whisper 返回格式 (json/text/srt/vtt)
54
57
 
55
58
  # 本地模式 - openai-whisper CLI (取消下方注释并注释上方即可切换)
56
59
  # WHISPER_BACKEND=local
@@ -157,12 +160,14 @@ YOUKU_USER_AGENT= # 【自由】
157
160
  # ── AI 分析/关键词归纳 ─────────────────────────────────────────────────────
158
161
  # 【自由】是否启用 AI 分析环节(true/false),在 transcribe 之后执行
159
162
  AI_ENABLED=true
160
- # 【自由】OpenAI 兼容 API 配置
161
- AI_API_KEY=sk-jewvkpoAQNKAARQMYULWW1vv8x6UH4H9Qe4j10tp2AJFM3TY
162
- AI_BASE_URL=https://apihub.agnes-ai.com/v1
163
- AI_MODEL=agnes-2.0-flash
163
+ # 【自由】OpenAI 兼容 API 配置(⚠️ 请勿提交真实 API Key 到此文件)
164
+ AI_API_KEY=your-api-key-here
165
+ AI_BASE_URL=https://your-api-host/v1
166
+ AI_MODEL=your-model-name
164
167
  # 【自由】请求超时(秒),默认 300
165
168
  AI_TIMEOUT=300
169
+ # 【自由】AI 推理温度 (0.0~2.0, 越低越确定/保守, 越高越随机/创意)
170
+ AI_TEMPERATURE=0.3
166
171
  # 【关联】提示词模板,{content} 占位符会被识别文本替换
167
172
  # 【自由】提示词内容可随意修改,但必须保留 {content} 占位符
168
173
  AI_PROMPT_TPL=帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}
package/CHANGELOG.md CHANGED
@@ -1,20 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## [1.0.4] - 2026-06-11
3
+ ## [1.2.0] - 2026-06-11
4
+
5
+ ### Bug Fixes
6
+
7
+ - 安全漏洞修复 + dry-run 模式完善 + 全面测试套件 (`3677b6a`)
8
+
9
+ ### Refactoring
10
+
11
+ - 输出目录统一归入 output/ 并清理测试产物 (`f5cad03`)
12
+ - whisper/AI 硬编码参数改为 env 可配置 (`611b079`)
13
+
14
+
15
+ ## [1.1.0] - 2026-06-11
4
16
 
5
17
  ### Features
6
18
 
7
- - rename npm package to video-pipeline (`644cc8a`)
8
- - add --url option to Python version (parity with Node.js) (`e1df301`)
19
+ - add --input option for local file processing (Node.js + Python) (`4805e3b`)
9
20
 
10
21
  ### Bug Fixes
11
22
 
12
- - recreate v1.0.3 tag and regenerate CHANGELOG with correct scopes (`9c2db90`)
13
- - normalize CHANGELOG format to Keep a Changelog standard (`2b887f4`)
14
- - correct changelog per-version ranges + fix getLastTag for Windows (`755222a`)
23
+ - whisper transcribe FormData + multi --step + auto-find wav (`f8c9fa2`)
15
24
 
16
25
 
17
- ## [Unreleased]
26
+ ## [1.0.4] - 2026-06-11
18
27
 
19
28
  ### Features
20
29
 
@@ -23,6 +32,7 @@
23
32
 
24
33
  ### Bug Fixes
25
34
 
35
+ - recreate v1.0.3 tag and regenerate CHANGELOG with correct scopes (`9c2db90`)
26
36
  - normalize CHANGELOG format to Keep a Changelog standard (`2b887f4`)
27
37
  - correct changelog per-version ranges + fix getLastTag for Windows (`755222a`)
28
38
 
package/README.md CHANGED
@@ -1,6 +1,38 @@
1
- # 视频下载 / 转码 / 文本识别 / AI 分析 流程
1
+ # 视频处理流水线 (Video Pipeline)
2
2
 
3
- 基于 `process_videos.py`,一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
3
+ 基于 `process_videos.js` (Node.js) 或 `process_videos.py` (Python),一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
4
+
5
+ **两种使用方式:**
6
+ - **Excel 批量处理**:从 Excel 文件读取视频 ID,自动完成全流程
7
+ - **直链下载**:通过 `--url` 直接指定视频链接,自动识别平台并下载
8
+ - **本地文件**:通过 `--input` 指定本地视频文件,跳过下载直接转码分析
9
+
10
+ ---
11
+
12
+ ## 安装方式
13
+
14
+ ### Node.js 版本(推荐)
15
+
16
+ ```bash
17
+ # 全局安装
18
+ npm install -g video-pipeline
19
+
20
+ # 使用后可直接调用
21
+ video-pipeline --help
22
+ ```
23
+
24
+ ### Python 版本
25
+
26
+ ```bash
27
+ # 克隆或下载脚本
28
+ git clone https://gitee.com/siriussupreme/yt-dlp_ffmpeg_whisper_memo-ai.git
29
+ cd yt-dlp_ffmpeg_whisper_memo-ai
30
+
31
+ # 安装 Python 依赖
32
+ pip install pandas openpyxl requests python-dotenv questionary
33
+ ```
34
+
35
+ ---
4
36
 
5
37
  ## 环境依赖
6
38
 
@@ -110,21 +142,33 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
110
142
  ## 目录结构
111
143
 
112
144
  ```
113
- ├── process_videos.py # 主流程脚本
114
- ├── .env.example # 环境变量模板(可提交 Git)
115
- ├── .env # 实际环境变量(已 gitignore,按需修改)
116
- ├── export_2026-06-10_split.xlsx # 数据源(YouTube视频 / 普诺赛中文站 两个 sheet
117
- ├── cookies/
118
- ├── bilibili.txt # B站 cookie(Netscape 格式)
119
- │ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
120
- ├── downloads/ # yt-dlp 下载输出(mp4)
145
+ ├── process_videos.js # Node.js 主流程脚本(推荐)
146
+ ├── process_videos.py # Python 主流程脚本(备选)
147
+ ├── package.json # Node.js 项目配置(npm 包)
148
+ ├── .env.example # 环境变量模板(可提交 Git
149
+ ├── .env # 实际环境变量(已 gitignore,按需修改)
150
+ ├── data/ # 数据源目录
151
+ │ └── export_2026-06-10_split.xlsx # Excel 数据源
152
+ ├── cookies/ # 站点 cookie 文件
153
+ │ ├── bilibili.txt # B站 cookie(Netscape 格式)
154
+ │ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
155
+ ├── downloads/ # yt-dlp 下载输出(mp4)
121
156
  │ ├── YouTube视频/
122
157
  │ └── 普诺赛中文站/
123
- ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
158
+ ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
124
159
  │ ├── YouTube视频/
125
160
  │ └── 普诺赛中文站/
126
- └── reports/ # 执行报告(JSON)
127
- └── report_YYYYMMDD_HHMMSS.json
161
+ ├── reports/ # 执行报告(JSON)
162
+ └── report_YYYYMMDD_HHMMSS.json
163
+ ├── scripts/ # 辅助脚本
164
+ │ ├── release.js # 版本发布脚本
165
+ │ └── regenerate-changelog.js # CHANGELOG 重建脚本
166
+ ├── .github/ # GitHub Actions 工作流
167
+ ├── .husky/ # Git hooks(commit 消息检查)
168
+ ├── node_modules/ # Node.js 依赖(已 gitignore)
169
+ ├── CHANGELOG.md # 版本变更记录
170
+ ├── README.md # 使用文档
171
+ └── LICENSE # MIT 许可证
128
172
  ```
129
173
 
130
174
  ---
@@ -167,35 +211,39 @@ yt-dlp 可直接从 Firefox 浏览器读取 cookie,无需手动导出:
167
211
 
168
212
  ```bash
169
213
  # 下载 + 转码 + 识别 + AI分析,指定 sheet + extra.id
214
+ node process_videos.js --sheet "YouTube视频" --id 2143
215
+ # 或 Python 版本
170
216
  python process_videos.py --sheet "YouTube视频" --id 2143
171
217
 
172
218
  # 只跑下载
219
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step download
220
+ # 或 Python 版本
173
221
  python process_videos.py --sheet "普诺赛中文站" --id 16 --step download
174
222
 
175
223
  # 只跑转码(需要已有下载文件)
176
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step transcode
224
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcode
177
225
 
178
226
  # 只跑识别(需要已有转码文件)
179
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step transcribe
227
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcribe
180
228
 
181
229
  # 只跑 AI 分析(需要已有识别文本)
182
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step analyze
230
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step analyze
183
231
 
184
232
  # 强制重新下载(忽略已有文件)
185
- python process_videos.py --sheet "YouTube视频" --id 2143 --force
233
+ node process_videos.js --sheet "YouTube视频" --id 2143 --force
186
234
  ```
187
235
 
188
236
  ### 批量全量
189
237
 
190
238
  ```bash
191
239
  # 全量执行(2 个并发,失败重试 3 次)
192
- python process_videos.py --concurrency 2 --retry 3
240
+ node process_videos.js --concurrency 2 --retry 3
193
241
 
194
242
  # 只跑某一 sheet
195
- python process_videos.py --sheet "YouTube视频" --concurrency 2 --retry 3
243
+ node process_videos.js --sheet "YouTube视频" --concurrency 2 --retry 3
196
244
 
197
245
  # 先干跑预览
198
- python process_videos.py --dry-run
246
+ node process_videos.js --dry-run
199
247
  ```
200
248
 
201
249
  ### 重跑失败
@@ -203,10 +251,10 @@ python process_videos.py --dry-run
203
251
  ```bash
204
252
  # 第一次跑完后生成 reports/report_xxx.json
205
253
  # 查看失败项:
206
- python process_videos.py --retry-failed reports/report_20260610_143000.json --dry-run
254
+ node process_videos.js --retry-failed reports/report_20260610_143000.json --dry-run
207
255
 
208
256
  # 重跑:
209
- python process_videos.py --retry-failed reports/report_20260610_143000.json --concurrency 2 --retry 3
257
+ node process_videos.js --retry-failed reports/report_20260610_143000.json --concurrency 2 --retry 3
210
258
  ```
211
259
 
212
260
  ### 超时控制(防止任务卡死)
@@ -215,7 +263,7 @@ python process_videos.py --retry-failed reports/report_20260610_143000.json --co
215
263
 
216
264
  ```bash
217
265
  # 自定义超时(单位秒)
218
- python process_videos.py \
266
+ node process_videos.js \
219
267
  --download-timeout 900 \ # 下载 15 分钟
220
268
  --transcode-timeout 600 \ # 转码 10 分钟
221
269
  --transcribe-timeout 1200 \ # 识别 20 分钟
@@ -228,6 +276,51 @@ python process_videos.py \
228
276
  - 无论超时多少次,**不会阻塞其他并发任务**,失败项会记录到报告
229
277
  - 超时失败的任务可用 `--retry-failed` 单独重跑
230
278
 
279
+ ### 直接指定 URL 下载
280
+
281
+ ```bash
282
+ # 直接指定视频链接,自动识别平台(支持标准链接、短链接、内嵌链接)
283
+ node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c"
284
+ python process_videos.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
285
+
286
+ # 指定输出文件名(不含扩展名)
287
+ node process_videos.js --url "https://youtu.be/zzJmKPX8a3c" --name "产品介绍"
288
+
289
+ # 只执行部分步骤
290
+ node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c" --step transcode
291
+ ```
292
+
293
+ **支持的 URL 格式:**
294
+ - YouTube: 标准页、短链接、Shorts、内嵌页、直播
295
+ - B站: 标准页(BV/av号)、短链接、内嵌页、移动端
296
+ - 腾讯视频: 标准页、内嵌页、移动端
297
+ - 优酷: 标准页
298
+
299
+ **文件命名规则:**
300
+ - 默认:`{平台}_{视频ID}`(如 `youtube_zzJmKPX8a3c`)
301
+ - 自定义:通过 `--name` 指定(如 `--name "产品介绍"`)
302
+ - 冲突处理:自动提示选择(覆盖 / 跳过 / 自定义名称)
303
+
304
+ ### 处理本地文件
305
+
306
+ ```bash
307
+ # 指定本地视频文件,跳过下载,直接转码→识别→分析
308
+ node process_videos.js --input "downloads/产品介绍.mp4"
309
+ python process_videos.py --input "downloads/产品介绍.mp4"
310
+
311
+ # 指定输出文件名
312
+ node process_videos.js --input "downloads/产品介绍.mp4" --name "产品介绍_分析"
313
+
314
+ # 只执行部分步骤
315
+ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
316
+ ```
317
+
318
+ **文件校验:**
319
+ - 检查文件是否存在
320
+ - 检查文件格式是否支持(视频/音频)
321
+ - 检查是否可以正常读取
322
+ - 校验失败会提示错误并退出
323
+
231
324
  ### 工具预检(执行前自动检测)
232
325
 
233
326
  每次执行任务前,脚本会自动检测本次涉及步骤所需的工具/服务是否可用:
@@ -249,19 +342,25 @@ python process_videos.py \
249
342
 
250
343
  | 参数 | 类型 | 默认值 | 说明 |
251
344
  |---|---|---|---|
252
- | `--sheet` | str | 全部 | 指定 sheet:`YouTube视频` `普诺赛中文站` |
253
- | `--id` | str | — | 指定 extra.id 或 title(单条测试) |
254
- | `--step` | str | 全跑 | 只执行某步:`download` / `transcode` / `transcribe` |
345
+ | `--sheet <name>` | str | 全部 | 指定 sheet 名称 |
346
+ | `--id <id>` | str | — | 指定 extra.id 或 title(单条测试) |
347
+ | `--step <step>` | str | 全跑 | 只执行某步:`download` / `transcode` / `transcribe` / `analyze` |
255
348
  | `--force` | flag | off | 强制重做下载+转码,忽略已有文件 |
256
- | `--concurrency` | int | 1 | 并发数,建议 2~3 |
257
- | `--retry` | int | 0 | 每步失败最大重试次数 |
258
- | `--retry-delay` | float | 5 | 重试间隔基数(秒),指数退避 5→10→20 |
259
- | `--download-timeout` | int | 600 | 单个下载任务最长执行时间(秒) |
260
- | `--transcode-timeout` | int | 600 | 单个转码任务最长执行时间(秒) |
261
- | `--transcribe-timeout` | int | 600 | 单个识别任务最长执行时间(秒) |
262
- | `--analyze-timeout` | int | 300 | 单个 AI 分析任务最长执行时间(秒) |
349
+ | `--concurrency <n>` | int | 1 | 并发数,建议 2~3 |
350
+ | `--retry <n>` | int | 0 | 每步失败最大重试次数 |
351
+ | `--retry-delay <n>` | float | 5 | 重试间隔基数(秒),指数退避 5→10→20 |
352
+ | `--download-timeout <n>` | int | 600 | 单个下载任务最长执行时间(秒) |
353
+ | `--transcode-timeout <n>` | int | 600 | 单个转码任务最长执行时间(秒) |
354
+ | `--transcribe-timeout <n>` | int | 600 | 单个识别任务最长执行时间(秒) |
355
+ | `--analyze-timeout <n>` | int | 300 | 单个 AI 分析任务最长执行时间(秒) |
263
356
  | `--dry-run` | flag | off | 干跑模式,只列任务不执行 |
264
- | `--retry-failed` | path | — | 从报告 JSON 重跑失败项 |
357
+ | `--retry-failed <path>` | path | — | 从报告 JSON 重跑失败项 |
358
+ | `--init` | flag | off | 复制 .env.example 到当前目录并重命名为 .env |
359
+ | `--file <path>` | path | — | 指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量) |
360
+ | `--input <path>` | path | — | 指定本地视频文件路径(跳过下载,直接转码→识别→分析) |
361
+ | `--url <url>` | str | — | 直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接 |
362
+ | `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input 配合使用) |
363
+ | `--env-file <path>` | path | .env | 指定要加载的 .env 文件路径 |
265
364
 
266
365
  ---
267
366
 
@@ -340,11 +439,13 @@ AI_TIMEOUT=300
340
439
 
341
440
  ```bash
342
441
  # 已有识别文本,只跑 AI 分析
442
+ node process_videos.js --sheet "普诺赛中文站" --id 427 --step analyze
443
+ # 或 Python 版本
343
444
  python process_videos.py --sheet "普诺赛中文站" --id 427 --step analyze
344
445
 
345
446
  # 单独跑 analyze 超过 16 条不会写入 Excel
346
447
  # 要想写入 Excel 跑完整流程 --step analyze
347
- python process_videos.py --sheet "YouTube视频" --step analyze --concurrency 2
448
+ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
348
449
  ```
349
450
 
350
451
  ### 禁用 AI 分析
@@ -442,16 +543,18 @@ python process_videos.py --sheet "YouTube视频" --step analyze --concurrency 2
442
543
 
443
544
  ```bash
444
545
  # 1. 干跑预览
546
+ node process_videos.js --dry-run
547
+ # 或 Python 版本
445
548
  python process_videos.py --dry-run
446
549
 
447
550
  # 2. 单条验证
448
- python process_videos.py --sheet "YouTube视频" --id 2143 --retry 2
551
+ node process_videos.js --sheet "YouTube视频" --id 2143 --retry 2
449
552
 
450
553
  # 3. 全量执行
451
- python process_videos.py --concurrency 3 --retry 3
554
+ node process_videos.js --concurrency 3 --retry 3
452
555
 
453
556
  # 4. 查看报告,重跑失败项
454
- python process_videos.py --retry-failed reports/report_xxx.json --concurrency 2 --retry 3
557
+ node process_videos.js --retry-failed reports/report_xxx.json --concurrency 2 --retry 3
455
558
  ```
456
559
 
457
560
  ---
@@ -568,22 +671,58 @@ python process_videos.py --retry-failed reports/report_xxx.json --concurrency 2
568
671
 
569
672
  ## 换电脑使用
570
673
 
571
- 1. 安装上述所有必装工具,确保 `yt-dlp`、`ffmpeg`、`ffprobe`、`node` 均在 PATH
572
- 2. `pip install pandas openpyxl requests python-dotenv`
573
- 3. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
574
- 4. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
575
- 5. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
576
- 6. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
577
- 7. `python process_videos.py --dry-run` 验证
674
+ ### Node.js 版本
675
+
676
+ 1. 安装 Node.js (18+):[nodejs.org](https://nodejs.org/)
677
+ 2. 安装视频处理工具:
678
+ ```bash
679
+ npm install -g video-pipeline
680
+ ```
681
+ 3. 克隆或下载项目文件(`.env.example`、`.env`、`cookies/` 等)
682
+ 4. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
683
+ 5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
684
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
685
+ 7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
686
+ 8. `video-pipeline --dry-run` 验证
687
+
688
+ ### Python 版本
689
+
690
+ 1. 安装 Python 3.9+:[python.org](https://www.python.org/)
691
+ 2. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
692
+ 3. 安装 Python 依赖:`pip install pandas openpyxl requests python-dotenv questionary`
693
+ 4. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
694
+ 5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
695
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
696
+ 7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
697
+ 8. `python process_videos.py --dry-run` 验证
698
+
699
+ ---
578
700
 
579
701
  ## 适配其他 Excel
580
702
 
581
703
  如果需要用这套脚本处理**其他项目的 Excel**(列名不同、平台不同):
582
704
 
705
+ **方法一:修改 .env 文件**
706
+
583
707
  1. 复制 `.env.example` 为新 `.env`(或修改现有 `.env`)
584
708
  2. 修改 `EXCEL_FILE` 指向新 Excel
585
709
  3. 修改列映射(`COL_ID`、`COL_TITLE`、`COL_CONTENT` 及各平台列名)
586
710
  4. 修改 `VIDEO_SHEETS` 为新的 sheet 名称
587
711
  5. 如需新平台,在 `PLATFORM_PRIORITY` 中添加 key,并配置对应的 `{KEY}_URL_TPL`
588
- 6. `python process_videos.py --dry-run` 验证配置
712
+ 6. `node process_videos.js --dry-run` 验证配置
589
713
  7. 跑全量
714
+
715
+ **方法二:使用 --file 选项(推荐)**
716
+
717
+ ```bash
718
+ # 直接指定 Excel 文件,无需修改 .env
719
+ node process_videos.js --file "data/其他项目.xlsx" --dry-run
720
+
721
+ # 配合 --env-file 使用自定义环境变量
722
+ node process_videos.js --file "data/其他项目.xlsx" --env-file ".env.其他项目" --dry-run
723
+ ```
724
+
725
+ **优点:**
726
+ - 无需修改 `.env` 文件
727
+ - 可以为不同项目创建不同的 `.env` 配置文件
728
+ - 命令行优先级高于环境变量
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "video-pipeline",
3
- "version": "1.0.4",
3
+ "version": "1.2.0",
4
4
  "description": "视频下载、转码、文本识别、AI 关键词分析一体化流程 CLI 工具",
5
5
  "keywords": [
6
6
  "video",
package/process_videos.js CHANGED
@@ -43,10 +43,10 @@ function envPath(key, defaultValue) {
43
43
  }
44
44
 
45
45
  let EXCEL_FILE = envPath('EXCEL_FILE', 'data/export_2026-06-10_split.xlsx');
46
- const DOWNLOADS_DIR = envPath('DOWNLOADS_DIR', 'downloads');
47
- const TRANSCODED_DIR = envPath('TRANSCODED_DIR', 'transcoded');
46
+ const DOWNLOADS_DIR = envPath('DOWNLOADS_DIR', 'output/downloads');
47
+ const TRANSCODED_DIR = envPath('TRANSCODED_DIR', 'output/transcoded');
48
48
  const COOKIES_DIR = envPath('COOKIES_DIR', 'cookies');
49
- const REPORTS_DIR = envPath('REPORTS_DIR', 'reports');
49
+ const REPORTS_DIR = envPath('REPORTS_DIR', 'output/reports');
50
50
 
51
51
  const YTDLP = process.env.YTDLP || 'yt-dlp';
52
52
  const FFMPEG = process.env.FFMPEG || 'ffmpeg';
@@ -57,6 +57,9 @@ const WHISPER_MODEL = process.env.WHISPER_MODEL || 'base';
57
57
  const WHISPER_DEVICE = process.env.WHISPER_DEVICE || 'cpu';
58
58
  const WHISPER_LANGUAGE = process.env.WHISPER_LANGUAGE || '';
59
59
  const WHISPER_SERVICE_MODEL = process.env.WHISPER_SERVICE_MODEL || '';
60
+ const WHISPER_TEMPERATURE = process.env.WHISPER_TEMPERATURE || '0.0';
61
+ const WHISPER_TEMPERATURE_INC = process.env.WHISPER_TEMPERATURE_INC || '0.2';
62
+ const WHISPER_RESPONSE_FORMAT = process.env.WHISPER_RESPONSE_FORMAT || 'json';
60
63
  let _SERVICE_MODEL_LOADED = null;
61
64
 
62
65
  const TRANSCODE_EXT = process.env.TRANSCODE_EXT || '.wav';
@@ -80,6 +83,21 @@ const PLATFORM_COL_MAP = {
80
83
  youkuId: COL_YOUKUID,
81
84
  };
82
85
 
86
+ // ============================== 工具函数 ==============================
87
+ function c(color, text) {
88
+ const colors = {
89
+ dim: '\x1b[2m',
90
+ yellow: '\x1b[33m',
91
+ cyan: '\x1b[36m',
92
+ green: '\x1b[32m',
93
+ red: '\x1b[31m',
94
+ blue: '\x1b[34m',
95
+ magenta: '\x1b[35m',
96
+ reset: '\x1b[0m',
97
+ };
98
+ return (colors[color] || '') + text + colors.reset;
99
+ }
100
+
83
101
  const PLATFORM_PRIORITY = (process.env.PLATFORM_PRIORITY || 'bilibiliBvid,youtubeId,tencentVid,youkuId')
84
102
  .split(',').map(s => s.trim()).filter(Boolean);
85
103
 
@@ -166,27 +184,6 @@ function timestamp() {
166
184
  return new Date().toTimeString().slice(0, 8);
167
185
  }
168
186
 
169
- // ============================== 锁 / 并发控制 ==============================
170
- let _printLock = false;
171
- const _printQueue = [];
172
- function printLock(fn) {
173
- return new Promise(resolve => {
174
- _printQueue.push(async () => {
175
- _printLock = true;
176
- try { fn(); } finally { _printLock = false; }
177
- resolve();
178
- });
179
- if (_printQueue.length === 1) processQueue();
180
- });
181
- }
182
- async function processQueue() {
183
- while (_printQueue.length) {
184
- await _printQueue[0]();
185
- _printQueue.shift();
186
- }
187
- }
188
-
189
- // 简化:Node.js 单线程,简单场景下不需要锁
190
187
  function lockedPrint(s) {
191
188
  console.log(s);
192
189
  }
@@ -259,7 +256,12 @@ class TaskResult {
259
256
 
260
257
  // ============================== 工具函数 ==============================
261
258
  function safeFilename(name) {
262
- return String(name).replace(/[\\/:*?"<>|]/g, '_').trim();
259
+ let safe = String(name).replace(/[\\/:*?"<>|]/g, '_').trim();
260
+ // 防止路径遍历:过滤 ..
261
+ while (safe.includes('..')) safe = safe.replace('..', '_');
262
+ // 防止以 . 开头(Unix 隐藏文件)
263
+ safe = safe.replace(/^\.+/, '');
264
+ return safe || 'unknown';
263
265
  }
264
266
 
265
267
  function readExcelSheet(sheetName) {
@@ -461,7 +463,7 @@ async function resolveUrlConflict(proposedPath) {
461
463
  console.log(c('yellow', '文件名不能为空,使用默认名称'));
462
464
  return { action: 'proceed', path: proposedPath };
463
465
  }
464
- const newPath = path.join(dir, `${customName}${ext}`);
466
+ const newPath = path.join(dir, `${safeFilename(customName)}${ext}`);
465
467
  return resolveUrlConflict(newPath);
466
468
  }
467
469
 
@@ -682,6 +684,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
682
684
  const baseUrl = (process.env.AI_BASE_URL || '').replace(/\/$/, '');
683
685
  const model = process.env.AI_MODEL || '';
684
686
  const promptTpl = process.env.AI_PROMPT_TPL || '帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}';
687
+ const aiTemperature = parseFloat(process.env.AI_TEMPERATURE || '0.3');
685
688
  const aiTimeout = parseInt(process.env.AI_TIMEOUT || String(timeout), 10);
686
689
 
687
690
  if (!apiKey || !baseUrl || !model) {
@@ -693,7 +696,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
693
696
  const payload = JSON.stringify({
694
697
  model,
695
698
  messages: [{ role: 'user', content: prompt }],
696
- temperature: 0.3,
699
+ temperature: aiTemperature,
697
700
  });
698
701
 
699
702
  let lastErr = null;
@@ -835,7 +838,7 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
835
838
  }
836
839
 
837
840
  try {
838
- const { result, retriesUsed, error } = await retryCall(doDownload, maxRetries, retryDelay, stem);
841
+ await retryCall(doDownload, maxRetries, retryDelay, stem);
839
842
  } catch (e) {
840
843
  logError(`[${stem}] yt-dlp download failed: ${(e.stderr || e.message).slice(-2000)}`);
841
844
  return { file: null, retries: maxRetries, error: (e.stderr || e.message).slice(0, 500) };
@@ -1033,14 +1036,12 @@ async function transcribeService(audioFile, stem, maxRetries, retryDelay, timeou
1033
1036
  }
1034
1037
 
1035
1038
  // Run inference
1036
- const fileStream = fs.createReadStream(audioFile);
1037
- const fileStat = fs.statSync(audioFile);
1039
+ const fileBlob = await fs.openAsBlob(audioFile);
1038
1040
  const form = new FormData();
1039
- // Use ReadStream directly - Node.js fetch supports it natively for FormData
1040
- form.append('file', fileStream, path.basename(audioFile));
1041
- form.append('temperature', '0.0');
1042
- form.append('temperature_inc', '0.2');
1043
- form.append('response_format', 'json');
1041
+ form.append('file', fileBlob, path.basename(audioFile));
1042
+ form.append('temperature', WHISPER_TEMPERATURE);
1043
+ form.append('temperature_inc', WHISPER_TEMPERATURE_INC);
1044
+ form.append('response_format', WHISPER_RESPONSE_FORMAT);
1044
1045
 
1045
1046
  const controller = new AbortController();
1046
1047
  const timer = setTimeout(() => controller.abort(), timeout * 1000);
@@ -1133,7 +1134,6 @@ function writeAllContentsToExcel(results, keywordsDict = null) {
1133
1134
  }
1134
1135
 
1135
1136
  // Write content column
1136
- writeColumn(null, COL_CONTENT, updates); // null sheetName means iterate all sheets
1137
1137
  for (const [sheetName, rowsObj] of groupBySheetMap(updates)) {
1138
1138
  writeColumn(sheetName, COL_CONTENT, Object.entries(rowsObj));
1139
1139
  }
@@ -1166,20 +1166,28 @@ function groupBySheetMap(updates) {
1166
1166
  }
1167
1167
 
1168
1168
  // ============================== 报告 ==============================
1169
+ function computeSummary(results) {
1170
+ let success = 0, partial = 0, failed = 0, noVideo = 0;
1171
+ for (const r of results) {
1172
+ if (r.overall_status === 'success') success++;
1173
+ else if (r.overall_status === 'partial') partial++;
1174
+ else if (r.overall_status === 'failed') failed++;
1175
+ else if (r.overall_status === 'no_video') noVideo++;
1176
+ }
1177
+ return { total: results.length, success, partial, failed, no_video: noVideo };
1178
+ }
1179
+
1169
1180
  function generateReport(results, config) {
1170
1181
  fs.mkdirSync(REPORTS_DIR, { recursive: true });
1171
1182
  const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15).replace(/(\d{8})(\d{6})/, '$1_$2');
1172
1183
  const reportFile = path.join(REPORTS_DIR, `report_${ts}.json`);
1173
1184
 
1174
- const success = results.filter(r => r.overall_status === 'success').length;
1175
- const partial = results.filter(r => r.overall_status === 'partial').length;
1176
- const failed = results.filter(r => r.overall_status === 'failed').length;
1177
- const noVideo = results.filter(r => r.overall_status === 'no_video').length;
1185
+ const summary = computeSummary(results);
1178
1186
 
1179
1187
  const report = {
1180
1188
  timestamp: new Date().toISOString(),
1181
1189
  config,
1182
- summary: { total: results.length, success, partial, failed, no_video: noVideo },
1190
+ summary,
1183
1191
  items: results.map(r => r.toJSON()),
1184
1192
  failed_items: results.filter(r => r.overall_status === 'failed' || r.overall_status === 'partial')
1185
1193
  .map(r => ({
@@ -1197,15 +1205,12 @@ function generateReport(results, config) {
1197
1205
  }
1198
1206
 
1199
1207
  function printReportSummary(results) {
1200
- const success = results.filter(r => r.overall_status === 'success').length;
1201
- const partial = results.filter(r => r.overall_status === 'partial').length;
1202
- const failed = results.filter(r => r.overall_status === 'failed').length;
1203
- const noVid = results.filter(r => r.overall_status === 'no_video').length;
1208
+ const { total, success, partial, failed, no_video: noVid } = computeSummary(results);
1204
1209
 
1205
1210
  console.log(`\n${'='.repeat(60)}`);
1206
1211
  console.log(` 执行摘要`);
1207
1212
  console.log(`${'='.repeat(60)}`);
1208
- console.log(` 总计: ${results.length}`);
1213
+ console.log(` 总计: ${total}`);
1209
1214
  console.log(` ✅ 成功: ${success}`);
1210
1215
  console.log(` ⚠️ 部分成功: ${partial}`);
1211
1216
  console.log(` ❌ 失败: ${failed}`);
@@ -1226,6 +1231,35 @@ function printReportSummary(results) {
1226
1231
  }
1227
1232
  }
1228
1233
 
1234
+
1235
+ // ============================== 环境预检 + 用户确认 ==============================
1236
+ async function checkAndConfirmEnv(envCheck, dryRun, confirmMsg) {
1237
+ if (envCheck.allOk) return true;
1238
+ console.log(`\n${'='.repeat(60)}`);
1239
+ console.log(' \u26a0\ufe0f 工具/服务预检:以下依赖不可用');
1240
+ console.log('='.repeat(60));
1241
+ for (const issue of envCheck.issues) console.log(` \u2022 ${issue}`);
1242
+ console.log('\n 涉及的步骤将失败。');
1243
+ if (dryRun) return true;
1244
+ try {
1245
+ const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
1246
+ const answer = await new Promise(resolve => {
1247
+ rl.question(`\n ${confirmMsg}(输入 yes 继续,其他任意键取消): `, ans => {
1248
+ rl.close();
1249
+ resolve(ans.trim().toLowerCase());
1250
+ });
1251
+ });
1252
+ if (answer !== 'yes') {
1253
+ console.log('用户取消执行(工具不可用)');
1254
+ return false;
1255
+ }
1256
+ return true;
1257
+ } catch (e) {
1258
+ console.log('非交互环境,取消执行');
1259
+ return false;
1260
+ }
1261
+ }
1262
+
1229
1263
  // ============================== 单任务处理 ==============================
1230
1264
  async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, force,
1231
1265
  whisperAvailable, positionLabel = '', downloadTimeout = 600, transcodeTimeout = 600,
@@ -1359,6 +1393,258 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1359
1393
  }
1360
1394
 
1361
1395
  // ============================== 主控流程 ==============================
1396
+ // ═══════════════════════════════════════════════════════════════════
1397
+ // 本地文件流水线(--input 模式)
1398
+ // ═══════════════════════════════════════════════════════════════════
1399
+
1400
+ /**
1401
+ * 验证本地视频文件,检测可执行步骤
1402
+ * 返回 { valid, format, hasVideo, hasAudio, videoCodec, audioCodec,
1403
+ * duration, width, height, errors, feasibleSteps }
1404
+ */
1405
+ function validateInputFile(filePath) {
1406
+ const result = {
1407
+ valid: false, format: '', hasVideo: false, hasAudio: false,
1408
+ videoCodec: '', audioCodec: '', duration: 0, width: 0, height: 0,
1409
+ errors: [], feasibleSteps: [],
1410
+ };
1411
+
1412
+ // 1. 文件存在性
1413
+ const absPath = path.resolve(filePath);
1414
+ if (!fs.existsSync(absPath)) {
1415
+ result.errors.push('文件不存在');
1416
+ return result;
1417
+ }
1418
+ const stat = fs.statSync(absPath);
1419
+ if (!stat.isFile()) {
1420
+ result.errors.push('不是一个文件');
1421
+ return result;
1422
+ }
1423
+ if (stat.size === 0) {
1424
+ result.errors.push('文件大小为 0');
1425
+ return result;
1426
+ }
1427
+
1428
+ // 2. ffprobe 分析
1429
+ if (!which(FFPROBE)) {
1430
+ result.errors.push(`ffprobe 不可用 (${FFPROBE})`);
1431
+ result.valid = true; // 文件本身有效,但无法探测流信息
1432
+ result.feasibleSteps = ['transcode', 'transcribe', 'analyze']; // 乐观推测
1433
+ return result;
1434
+ }
1435
+
1436
+ try {
1437
+ const probeRaw = execSync(
1438
+ `${FFPROBE} -v error -show_entries stream=codec_type,codec_name,width,height -show_entries format=format_name,duration -of json "${absPath}"`,
1439
+ { encoding: 'utf-8', timeout: 30000 }
1440
+ );
1441
+ const info = JSON.parse(probeRaw);
1442
+
1443
+ // 提取 format 信息
1444
+ if (info.format) {
1445
+ result.format = (info.format.format_name || '').split(',')[0];
1446
+ result.duration = parseFloat(info.format.duration || '0');
1447
+ }
1448
+
1449
+ // 提取 stream 信息
1450
+ if (info.streams) {
1451
+ for (const s of info.streams) {
1452
+ if (s.codec_type === 'video') {
1453
+ result.hasVideo = true;
1454
+ result.videoCodec = s.codec_name || '';
1455
+ result.width = s.width || 0;
1456
+ result.height = s.height || 0;
1457
+ }
1458
+ if (s.codec_type === 'audio') {
1459
+ result.hasAudio = true;
1460
+ result.audioCodec = s.codec_name || '';
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ result.valid = true;
1466
+
1467
+ // 3. 判断可执行步骤
1468
+ if (result.hasVideo) {
1469
+ result.feasibleSteps.push('transcode');
1470
+ }
1471
+ if (result.hasAudio) {
1472
+ result.feasibleSteps.push('transcribe', 'analyze');
1473
+ }
1474
+ // 无视频无音频 → 所有步骤不可行
1475
+ if (!result.hasVideo && !result.hasAudio) {
1476
+ result.feasibleSteps = [];
1477
+ result.errors.push('文件不包含视频或音频流,无法处理');
1478
+ }
1479
+
1480
+ // 如果只有视频没有音频:只能转码
1481
+ if (result.hasVideo && !result.hasAudio) {
1482
+ result.errors.push('文件不含音频轨道,将跳过语音识别和 AI 分析');
1483
+ }
1484
+
1485
+ } catch (e) {
1486
+ result.errors.push(`ffprobe 解析失败: ${(e.stderr || e.message || '').slice(0, 200)}`);
1487
+ result.valid = true; // 文件存在且不为空,让 ffmpeg 自行判断
1488
+ result.feasibleSteps = ['transcode', 'transcribe', 'analyze'];
1489
+ }
1490
+
1491
+ return result;
1492
+ }
1493
+
1494
+ /**
1495
+ * 处理 --input 模式下的文件冲突(已存在的转码/识别结果)
1496
+ * @param {string} proposedPath - 即将生成的输出文件路径
1497
+ * @returns {Promise<{action: 'overwrite'|'skip', path: string}>}
1498
+ */
1499
+ async function resolveInputConflict(proposedPath) {
1500
+ if (!fs.existsSync(proposedPath)) {
1501
+ return { action: 'overwrite', path: proposedPath };
1502
+ }
1503
+ const size = (fs.statSync(proposedPath).size / 1024 / 1024).toFixed(1);
1504
+ console.log(`\n⚠️ 文件已存在: ${proposedPath} (${size} MB)`);
1505
+ const choice = await select({
1506
+ message: '如何处理已有文件?',
1507
+ choices: [
1508
+ { name: '覆盖已有文件 (overwrite)', value: 'overwrite', description: '删除现有文件,重新生成' },
1509
+ { name: '跳过此步骤 (skip)', value: 'skip', description: '保留现有文件,不重新处理' },
1510
+ ],
1511
+ });
1512
+ return { action: choice, path: proposedPath };
1513
+ }
1514
+
1515
+ /**
1516
+ * --input 模式的独立流水线
1517
+ * 不通过 processOneTask(因为它依赖 findDownloadedFile 从 DOWNLOADS_DIR 找文件),
1518
+ * 直接串联 step 函数,保证输入文件路径准确传递。
1519
+ */
1520
+ async function runInputTask(opts) {
1521
+ const {
1522
+ inputPath, stem, sheetName, steps,
1523
+ maxRetries, retryDelay, force,
1524
+ transcodeTimeout, transcribeTimeout, analyzeTimeout,
1525
+ whisperAvailable, fileInfo,
1526
+ } = opts;
1527
+
1528
+ console.log(c('dim', '\n── 开始执行 ──\n'));
1529
+
1530
+ // ── download: 跳过(本地文件)──
1531
+ console.log(` [${stem}] 📥 下载: ${c('yellow', '已跳过 (本地文件)')}`);
1532
+
1533
+ // ── transcode ──
1534
+ let tcFile = null;
1535
+ if (steps.includes('transcode')) {
1536
+ console.log(` [${stem}] 🎵 开始转码...`);
1537
+ try {
1538
+ const { file, error } = await stepTranscode(inputPath, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
1539
+ tcFile = file;
1540
+ if (file && fs.existsSync(file)) {
1541
+ const size = (fs.statSync(file).size / 1024 / 1024).toFixed(1);
1542
+ console.log(` [${stem}] 🎵 转码完成: ${file} (${size} MB)`);
1543
+ } else {
1544
+ console.log(` [${stem}] 🎵 转码: ${c(file ? 'yellow' : 'red', file ? '已跳过 (文件已存在)' : '失败 — ' + (error || ''))}`);
1545
+ }
1546
+ } catch (e) {
1547
+ console.log(` [${stem}] 🎵 转码: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1548
+ }
1549
+ if (!tcFile) {
1550
+ console.log(c('yellow', '\n⚠️ 转码未产出文件,后续步骤将跳过\n'));
1551
+ }
1552
+ } else if (steps.includes('transcribe')) {
1553
+ // 无 transcode 步骤但有 transcribe:优先使用已有转码文件
1554
+ const tcDir = path.join(TRANSCODED_DIR, sheetName);
1555
+ const expectedTc = path.join(tcDir, stem + TRANSCODE_EXT);
1556
+ if (fs.existsSync(expectedTc)) {
1557
+ tcFile = expectedTc;
1558
+ console.log(` [${stem}] 🎵 转码: ${c('yellow', '使用已有文件 ' + path.basename(expectedTc))}`);
1559
+ } else {
1560
+ console.log(` [${stem}] 🎵 转码: ${c('red', '未找到转码文件,将尝试用原始文件识别(可能失败)')}`);
1561
+ tcFile = inputPath;
1562
+ }
1563
+ } else {
1564
+ tcFile = inputPath;
1565
+ }
1566
+
1567
+ // ── transcribe ──
1568
+ let transcribeText = '';
1569
+ if (steps.includes('transcribe') && tcFile) {
1570
+ if (!whisperAvailable) {
1571
+ console.log(` [${stem}] 📝 识别: ${c('red', 'whisper 不可用')}`);
1572
+ } else {
1573
+ console.log(` [${stem}] 📝 开始语音识别...`);
1574
+ try {
1575
+ const { text, error } = await stepTranscribe(tcFile, maxRetries, retryDelay, transcribeTimeout);
1576
+ if (text && typeof text === 'string') {
1577
+ transcribeText = text;
1578
+ console.log(` [${stem}] 📝 识别完成: ${text.length} 字符`);
1579
+ } else {
1580
+ console.log(` [${stem}] 📝 识别: ${c('red', '失败 — ' + (error || ''))}`);
1581
+ }
1582
+ } catch (e) {
1583
+ console.log(` [${stem}] 📝 识别: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ // ── AI analyze ──
1589
+ let analyzeText = '';
1590
+ if (steps.includes('analyze') && transcribeText) {
1591
+ const aiEnabled = (process.env.AI_ENABLED || 'true').toLowerCase() === 'true';
1592
+ if (aiEnabled) {
1593
+ console.log(` [${stem}] 🤖 开始 AI 分析...`);
1594
+ try {
1595
+ const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout);
1596
+ if (kw && typeof kw === 'string') {
1597
+ analyzeText = kw;
1598
+ console.log(` [${stem}] 🤖 AI分析完成: ${kw.length} 字符`);
1599
+ } else {
1600
+ console.log(` [${stem}] 🤖 AI分析: ${c('red', '失败 — ' + (error || ''))}`);
1601
+ }
1602
+ } catch (e) {
1603
+ console.log(` [${stem}] 🤖 AI分析: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1604
+ }
1605
+ } else {
1606
+ console.log(` [${stem}] 🤖 AI分析: ${c('yellow', '已禁用 (AI_ENABLED=false)')}`);
1607
+ }
1608
+ }
1609
+
1610
+ // ── 保存文本结果 ──
1611
+ if (transcribeText || analyzeText) {
1612
+ const outDir = path.join(REPORTS_DIR, 'input-tasks');
1613
+ fs.mkdirSync(outDir, { recursive: true });
1614
+ const outFile = path.join(outDir, `${stem}.txt`);
1615
+ const lines = [
1616
+ `文件: ${inputPath}`,
1617
+ `平台: local`,
1618
+ `文件格式: ${fileInfo.format || 'unknown'}`,
1619
+ `时长: ${fileInfo.duration ? fileInfo.duration.toFixed(1) + 's' : 'unknown'}`,
1620
+ '', '='.repeat(60), '',
1621
+ ];
1622
+ if (transcribeText) {
1623
+ lines.push('【语音识别内容】', '', transcribeText, '');
1624
+ }
1625
+ if (analyzeText) {
1626
+ lines.push('【AI 分析关键词】', '', analyzeText);
1627
+ }
1628
+ fs.writeFileSync(outFile, lines.join('\n'), 'utf-8');
1629
+ console.log(`\n 📄 报告已保存: ${outFile}`);
1630
+ }
1631
+
1632
+ // ── 总结 ──
1633
+ console.log('');
1634
+ const success = [];
1635
+ if (tcFile) success.push('transcode');
1636
+ if (transcribeText) success.push('transcribe');
1637
+ if (analyzeText) success.push('analyze');
1638
+ const failed = steps.filter(s => s !== 'download' && !success.includes(s));
1639
+ if (failed.length === 0) {
1640
+ console.log(c('green', '✅ 全部步骤执行成功'));
1641
+ } else {
1642
+ console.log(c('yellow', `⚠️ ${failed.length} 个步骤未成功: ${failed.join(', ')}`));
1643
+ }
1644
+ console.log('');
1645
+ }
1646
+
1647
+
1362
1648
  // ═══════════════════════════════════════════════════════════════════
1363
1649
  // URL 直链流水线(--url 模式)
1364
1650
  // ═══════════════════════════════════════════════════════════════════
@@ -1512,30 +1798,8 @@ async function run({
1512
1798
 
1513
1799
  logInfo(`tasks: ${tasks.length}, concurrency: ${concurrency}, max retries: ${maxRetries}`);
1514
1800
 
1515
- // ── 工具/服务预检 ──
1516
1801
  const envCheck = await checkEnvironmentAsync(steps);
1517
- if (!envCheck.allOk) {
1518
- console.log(`\n${'='.repeat(60)}`);
1519
- console.log(' ⚠️ 工具/服务预检:以下依赖不可用');
1520
- console.log('='.repeat(60));
1521
- for (const issue of envCheck.issues) {
1522
- console.log(` • ${issue}`);
1523
- }
1524
- console.log('\n 涉及的步骤将失败。');
1525
- if (!dryRun) {
1526
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1527
- const answer = await new Promise(resolve => {
1528
- rl.question('\n 是否继续执行?(输入 yes 继续,其他任意键取消): ', ans => {
1529
- rl.close();
1530
- resolve(ans.trim().toLowerCase());
1531
- });
1532
- });
1533
- if (answer !== 'yes') {
1534
- logInfo('用户取消执行(工具不可用)');
1535
- return;
1536
- }
1537
- }
1538
- }
1802
+ if (!await checkAndConfirmEnv(envCheck, dryRun, '是否继续执行?')) return;
1539
1803
 
1540
1804
  // ── 干跑模式 ──
1541
1805
  if (dryRun) {
@@ -1746,26 +2010,8 @@ async function runFromReport(reportPath, steps, maxRetries, retryDelay, concurre
1746
2010
  return;
1747
2011
  }
1748
2012
 
1749
- // ── 工具/服务预检 ──
1750
2013
  const envRfr = await checkEnvironmentAsync(steps);
1751
- if (!envRfr.allOk) {
1752
- console.log(`\n${'='.repeat(60)}`);
1753
- console.log(' ⚠️ 工具/服务预检:以下依赖不可用');
1754
- console.log('='.repeat(60));
1755
- for (const issue of envRfr.issues) console.log(` • ${issue}`);
1756
- console.log('\n 涉及的步骤将失败。');
1757
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1758
- const answer = await new Promise(resolve => {
1759
- rl.question('\n 是否继续重跑?(输入 yes 继续,其他任意键取消): ', ans => {
1760
- rl.close();
1761
- resolve(ans.trim().toLowerCase());
1762
- });
1763
- });
1764
- if (answer !== 'yes') {
1765
- logInfo('用户取消重跑(工具不可用)');
1766
- return;
1767
- }
1768
- }
2014
+ if (!await checkAndConfirmEnv(envRfr, dryRun, '是否继续重跑?')) return;
1769
2015
 
1770
2016
  let whisperAvailable = false;
1771
2017
  if (steps.includes('transcribe')) {
@@ -1823,13 +2069,13 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1823
2069
  .description('视频下载、转码、文本识别、AI分析一体化流程')
1824
2070
  .option('--sheet <name>', '指定 sheet 名称')
1825
2071
  .option('--id <id>', '指定 extra.id 或 title(单条测试)')
1826
- .option('--step <step>', '只执行某一步:download / transcode / transcribe / analyze', (val) => {
2072
+ .option('--step <step>', '指定执行步骤(可多次指定),如 --step transcode --step transcribe', (val, prev) => {
1827
2073
  const allowed = ['download', 'transcode', 'transcribe', 'analyze'];
1828
2074
  if (!allowed.includes(val)) {
1829
2075
  console.error(`Invalid step: ${val}. Must be one of: ${allowed.join(', ')}`);
1830
2076
  process.exit(1);
1831
2077
  }
1832
- return val;
2078
+ return [...(prev || []), val];
1833
2079
  })
1834
2080
  .option('--force', '强制重做下载+转码(忽略已有文件)')
1835
2081
  .option('--concurrency <n>', '并发数,默认 1', parseInt, 1)
@@ -1843,8 +2089,9 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1843
2089
  .option('--retry-failed <path>', '从报告 JSON 重跑失败项')
1844
2090
  .option('--init', '复制 .env.example 到当前目录并重命名为 .env')
1845
2091
  .option('--file <path>', '指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量)')
2092
+ .option('--input <path>', '指定本地视频文件路径(跳过下载,直接转码→识别→分析)')
1846
2093
  .option('--url <url>', '直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接')
1847
- .option('--name <name>', '指定下载文件名,不含扩展名(与 --url 配合使用)')
2094
+ .option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input 配合使用)')
1848
2095
  .option('--env-file <path>', '指定要加载的 .env 文件路径(默认: 当前目录 .env)');
1849
2096
 
1850
2097
  program.parse();
@@ -1906,7 +2153,7 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1906
2153
  EXCEL_FILE = path.resolve(opts.file);
1907
2154
  logInfo(`Excel 文件覆盖为: ${EXCEL_FILE}`);
1908
2155
  }
1909
- const steps = opts.step ? [opts.step] : ['download', 'transcode', 'transcribe', 'analyze'];
2156
+ const steps = opts.step?.length ? opts.step : ['download', 'transcode', 'transcribe', 'analyze'];
1910
2157
  // ── --url 模式:直接处理单个视频链接 ──
1911
2158
  if (opts.url) {
1912
2159
  const parsed = parseUrl(opts.url);
@@ -1926,11 +2173,19 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1926
2173
  console.log(` 视频ID: ${c('cyan', parsed.videoId)}`);
1927
2174
  console.log(` 链接: ${c('cyan', parsed.watchUrl)}`);
1928
2175
 
1929
- // 构建文件路径: downloads/<platform>/<name>.mp4
2176
+ // dry-run 模式
2177
+ if (opts.dryRun) {
2178
+ console.log(c('dim', '\n── 开始执行 (dry-run) ──\n'));
2179
+ console.log(` 将执行步骤: ${c('cyan', steps.join(' → '))}`);
2180
+ console.log(` 输出名称: ${c('cyan', opts.name || parsed.videoId)}`);
2181
+ process.exit(0);
2182
+ }
2183
+
2184
+ // 构建文件路径: output/downloads/<platform>/<name>.mp4
1930
2185
  fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
1931
2186
  const dlDir = path.join(DOWNLOADS_DIR, parsed.platform);
1932
2187
  fs.mkdirSync(dlDir, { recursive: true });
1933
- const fileName = opts.name || parsed.videoId;
2188
+ const fileName = safeFilename(opts.name || parsed.videoId);
1934
2189
  const proposedPath = path.join(dlDir, `${fileName}.mp4`);
1935
2190
 
1936
2191
  // 冲突处理(--force 时直接覆盖)
@@ -1982,6 +2237,123 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1982
2237
  process.exit(0);
1983
2238
  }
1984
2239
 
2240
+ // ── --input 模式:直接处理本地视频文件 ──
2241
+ if (opts.input) {
2242
+ const inputPath = path.resolve(opts.input);
2243
+ console.log(c('dim', '\n── 文件校验 ──'));
2244
+ console.log(` 文件: ${c('cyan', inputPath)}`);
2245
+
2246
+ const fileInfo = validateInputFile(inputPath);
2247
+ if (!fileInfo.valid) {
2248
+ console.log(c('red', `\n❌ 无法处理该文件:`));
2249
+ for (const e of fileInfo.errors) {
2250
+ console.log(c('red', ` ${e}`));
2251
+ }
2252
+ process.exit(1);
2253
+ }
2254
+
2255
+ // 展示文件信息
2256
+ console.log(` 格式: ${c('cyan', fileInfo.format || 'unknown')}`);
2257
+ if (fileInfo.hasVideo) {
2258
+ console.log(` 视频: ${c('cyan', fileInfo.videoCodec)} ${fileInfo.width}x${fileInfo.height}`);
2259
+ }
2260
+ if (fileInfo.hasAudio) {
2261
+ console.log(` 音频: ${c('cyan', fileInfo.audioCodec)}`);
2262
+ }
2263
+ if (fileInfo.duration > 0) {
2264
+ const dur = fileInfo.duration;
2265
+ const mm = Math.floor(dur / 60);
2266
+ const ss = Math.floor(dur % 60);
2267
+ console.log(` 时长: ${c('cyan', `${mm}:${String(ss).padStart(2, '0')}`)} (${dur.toFixed(1)}s)`);
2268
+ }
2269
+ if (fileInfo.errors.length > 0) {
2270
+ console.log('');
2271
+ for (const e of fileInfo.errors) {
2272
+ console.log(c('yellow', ` ⚠️ ${e}`));
2273
+ }
2274
+ }
2275
+
2276
+ // 展示可执行步骤
2277
+ const defaultSteps = fileInfo.feasibleSteps;
2278
+ // 用户可通过 --step 指定步骤,但只保留可行的
2279
+ let steps;
2280
+ if (opts.step?.length) {
2281
+ steps = opts.step.filter(s => defaultSteps.includes(s));
2282
+ if (steps.length === 0) {
2283
+ console.log(c('yellow', `\n⚠️ --step ${opts.step.join(', ')} 不可行(文件不支持)\n`));
2284
+ process.exit(1);
2285
+ }
2286
+ } else {
2287
+ steps = defaultSteps;
2288
+ }
2289
+ console.log(`\n 可执行步骤: ${c('green', steps.join(' → '))}`);
2290
+
2291
+ // dry-run 模式
2292
+ if (opts.dryRun) {
2293
+ console.log(c('dim', '\n── 开始执行 (dry-run) ──\n'));
2294
+ console.log(` [本地文件] 将执行步骤: ${c('cyan', steps.join(' → '))}`);
2295
+ console.log(` 输入文件: ${c('cyan', inputPath)}`);
2296
+ if (opts.name) {
2297
+ console.log(` 输出名称: ${c('cyan', opts.name)}`);
2298
+ }
2299
+ process.exit(0);
2300
+ }
2301
+
2302
+ // 确定输出文件名
2303
+ const sheetName = 'local';
2304
+ const baseName = safeFilename(opts.name || path.parse(inputPath).name);
2305
+ const stem = baseName;
2306
+
2307
+ // 检查转码输出文件是否已有冲突
2308
+ if (steps.includes('transcode') && !opts.force) {
2309
+ const tcDir = path.join(TRANSCODED_DIR, sheetName);
2310
+ const tcPath = path.join(tcDir, stem + TRANSCODE_EXT);
2311
+ const conflict = await resolveInputConflict(tcPath);
2312
+ if (conflict.action === 'skip') {
2313
+ console.log(c('yellow', '\n⏭️ 已跳过转码\n'));
2314
+ steps = steps.filter(s => s !== 'transcode');
2315
+ }
2316
+ }
2317
+
2318
+ if (steps.length === 0) {
2319
+ console.log(c('yellow', '\n无剩余步骤可执行\n'));
2320
+ process.exit(0);
2321
+ }
2322
+
2323
+ // 确保目录存在
2324
+ if (steps.includes('transcode')) {
2325
+ fs.mkdirSync(path.join(TRANSCODED_DIR, sheetName), { recursive: true });
2326
+ }
2327
+
2328
+ // 检查 whisper 可用性
2329
+ let whisperAvailable = false;
2330
+ if (steps.includes('transcribe')) {
2331
+ whisperAvailable = await checkWhisperAvailable();
2332
+ if (!whisperAvailable) {
2333
+ const backend = WHISPER_BACKEND === 'local' ? 'local CLI' : WHISPER_SERVICE;
2334
+ logWarn(`⚠️ whisper not available (${backend}), transcribe step will fail`);
2335
+ }
2336
+ }
2337
+
2338
+ // 执行流水线
2339
+ await runInputTask({
2340
+ inputPath,
2341
+ stem,
2342
+ sheetName,
2343
+ steps,
2344
+ maxRetries: opts.retry,
2345
+ retryDelay: opts.retryDelay,
2346
+ force: opts.force || false,
2347
+ transcodeTimeout: opts.transcodeTimeout,
2348
+ transcribeTimeout: opts.transcribeTimeout,
2349
+ analyzeTimeout: opts.analyzeTimeout,
2350
+ whisperAvailable,
2351
+ fileInfo,
2352
+ });
2353
+
2354
+ process.exit(0);
2355
+ }
2356
+
1985
2357
 
1986
2358
  run({
1987
2359
  targetSheet: opts.sheet || null,