video-pipeline 1.2.5 → 1.2.7
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 +5 -2
- package/CHANGELOG.md +34 -0
- package/README.md +220 -12
- package/package.json +1 -1
- package/process_videos.js +318 -46
package/.env.example
CHANGED
|
@@ -127,7 +127,10 @@ YOUKU_URL_TPL=https://v.youku.com/v_show/id_{youkuId}.html
|
|
|
127
127
|
# {前缀}_URL_TPL 【自由】URL 模板 (见"平台 URL 模板"段)
|
|
128
128
|
|
|
129
129
|
# ── Bilibili 配置 ───────────────────────────────────────────────────────────
|
|
130
|
-
|
|
130
|
+
# 方案 A(推荐): 直接从 Firefox 浏览器读 cookie,yt-dlp 能稳定解密
|
|
131
|
+
BILIBILI_COOKIES_FROM_BROWSER=firefox # 【自由】firefox / chrome / edge
|
|
132
|
+
# 方案 B(备用): 从文件读取 cookie
|
|
133
|
+
# BILIBILI_COOKIE_FILE=cookies/bilibili.txt # 【自由】cookie 文件路径(方案 A 启用时此行无效)
|
|
131
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 # 【自由】
|
|
132
135
|
BILIBILI_REFERER=https://www.bilibili.com/ # 【自由】
|
|
133
136
|
BILIBILI_CONCURRENT_FRAGMENTS=4 # 【自由】并发分片数
|
|
@@ -170,4 +173,4 @@ AI_TIMEOUT=300
|
|
|
170
173
|
AI_TEMPERATURE=0.3
|
|
171
174
|
# 【关联】提示词模板,{content} 占位符会被识别文本替换
|
|
172
175
|
# 【自由】提示词内容可随意修改,但必须保留 {content} 占位符
|
|
173
|
-
AI_PROMPT_TPL
|
|
176
|
+
AI_PROMPT_TPL=帮我归纳总结一下提供内容的关键词,尽可能全面,无遗漏,无重复,无幻想,关键词之间用英文逗号分隔开。如果内容为英文,则关键词全部是英文,如果内容是中文,则关键词以中文为主,可以附带一些英文关键词。这是内容:{content}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.7] - 2026-06-11
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- 补充所有模式 README 示例 + 修复下载和AI分析进度显示 (`9aa2160`)
|
|
8
|
+
- --content / --content-column + B站 Firefox cookie (`e25a4a8`)
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
- 修复JS报告时间戳小数点导致文件名出现两个点的问题 (`c0abe90`)
|
|
13
|
+
|
|
14
|
+
### Documentation
|
|
15
|
+
|
|
16
|
+
- update (`df4d7b6`)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [1.2.6] - 2026-06-11
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- 报告按 sheet/站点分目录存储 (`6610c57`)
|
|
24
|
+
- 统一三种来源报告格式 + 修复多处 bug (`2a6f606`)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
- groupBySheetMap 返回 Map 而非普通对象,修复 for...of 不可迭代错误 (`dfae532`)
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- update (`6ddc48b`)
|
|
33
|
+
- 修正 --input 模式的 {sheet} 表述为固定 local (`be61e29`)
|
|
34
|
+
- 输出结构速查表 — 三来源×四环节对照 (`248168c`)
|
|
35
|
+
|
|
36
|
+
|
|
3
37
|
## [1.2.5] - 2026-06-11
|
|
4
38
|
|
|
5
39
|
### Features
|
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
基于 `process_videos.js` (Node.js) 或 `process_videos.py` (Python),一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- **Excel 批量处理**:从 Excel 文件读取视频 ID
|
|
5
|
+
**五种使用方式,覆盖不同场景:**
|
|
6
|
+
- **Excel 批量处理**:从 Excel 文件读取视频 ID,自动完成全流程(最常用)
|
|
7
7
|
- **直链下载**:通过 `--url` 直接指定视频链接,自动识别平台并下载
|
|
8
8
|
- **本地文件**:通过 `--input` 指定本地视频文件,跳过下载直接转码分析
|
|
9
|
+
- **纯文本分析**:通过 `--content` 直接提供文本内容,跳过所有视频步骤,直接做 AI 关键词分析
|
|
10
|
+
- **Excel列文本批量分析**:通过 `--content-column` 读取 Excel 某列的已有文本,批量做 AI 分析
|
|
9
11
|
|
|
10
12
|
---
|
|
11
13
|
|
|
@@ -158,8 +160,22 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
|
|
|
158
160
|
├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
|
|
159
161
|
│ ├── YouTube视频/
|
|
160
162
|
│ └── 普诺赛中文站/
|
|
161
|
-
├── reports/ #
|
|
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/
|
|
163
179
|
├── scripts/ # 辅助脚本
|
|
164
180
|
│ ├── release.js # 版本发布脚本
|
|
165
181
|
│ └── regenerate-changelog.js # CHANGELOG 重建脚本
|
|
@@ -253,12 +269,12 @@ node process_videos.js --limit 3 --concurrency 1 # 只处理前3条
|
|
|
253
269
|
### 重跑失败
|
|
254
270
|
|
|
255
271
|
```bash
|
|
256
|
-
# 第一次跑完后生成 reports/report_xxx.json
|
|
272
|
+
# 第一次跑完后生成 reports/{sheet名称}/report_xxx.json
|
|
257
273
|
# 查看失败项:
|
|
258
|
-
node process_videos.js --retry-failed reports/report_20260610_143000.json --dry-run
|
|
274
|
+
node process_videos.js --retry-failed reports/YouTube视频/report_20260610_143000.json --dry-run
|
|
259
275
|
|
|
260
276
|
# 重跑:
|
|
261
|
-
node process_videos.js --retry-failed reports/report_20260610_143000.json --concurrency 2 --retry 3
|
|
277
|
+
node process_videos.js --retry-failed reports/YouTube视频/report_20260610_143000.json --concurrency 2 --retry 3
|
|
262
278
|
```
|
|
263
279
|
|
|
264
280
|
### 超时控制(防止任务卡死)
|
|
@@ -325,6 +341,64 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
325
341
|
- 检查是否可以正常读取
|
|
326
342
|
- 校验失败会提示错误并退出
|
|
327
343
|
|
|
344
|
+
### 处理纯文本内容(跳过视频步骤)
|
|
345
|
+
|
|
346
|
+
如果你已经有了一段文本内容(比如爬虫爬取的、之前识别好的、或者从其他途径获取的),可以直接做 AI 分析,跳过下载、转码、识别三个步骤:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
# ═══════════ --content 模式:纯文本 AI 分析 ═══════════
|
|
350
|
+
|
|
351
|
+
# 从文件读取内容,自动用文件名作为输出名
|
|
352
|
+
node process_videos.js --content "data/article.txt"
|
|
353
|
+
python process_videos.py --content "data/article.txt"
|
|
354
|
+
|
|
355
|
+
# 直接提供内联文本,自动取前 32 字符作为输出名
|
|
356
|
+
node process_videos.js --content "这是一段需要分析的内容..."
|
|
357
|
+
python process_videos.py --content "这是一段需要分析的内容..."
|
|
358
|
+
|
|
359
|
+
# 指定输出文件名(--name)
|
|
360
|
+
node process_videos.js --content "data/article.txt" --name "文章分析"
|
|
361
|
+
python process_videos.py --content "data/article.txt" --name "文章分析"
|
|
362
|
+
|
|
363
|
+
# 配合 --dry-run 预览
|
|
364
|
+
node process_videos.js --content "data/article.txt" --dry-run
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**输出文件命名规则:**
|
|
368
|
+
- 指定了 `--name` → 使用 `--name` 的值
|
|
369
|
+
- 内容是文件路径 → 使用文件名(不含扩展名)
|
|
370
|
+
- 内容是内联文本 → 使用前 32 个字符
|
|
371
|
+
|
|
372
|
+
**输出位置:** `output/reports/content/tasks/{name}.txt` + `output/reports/content/report_xxx.json`
|
|
373
|
+
|
|
374
|
+
### Excel 列文本批量 AI 分析
|
|
375
|
+
|
|
376
|
+
当 Excel 某列已经存好了文本内容(比如之前爬虫爬取的),可以批量对这些文本做 AI 关键词分析:
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
# ═══════════ --content-column 模式:批量 AI 分析 ═══════════
|
|
380
|
+
|
|
381
|
+
# 对 Excel 中 "content" 列的文本逐行做 AI 关键词分析,结果写回 "keywords" 列
|
|
382
|
+
node process_videos.js --content-column "content"
|
|
383
|
+
|
|
384
|
+
# 指定其他列名
|
|
385
|
+
node process_videos.js --content-column "爬取文本"
|
|
386
|
+
|
|
387
|
+
# 指定特定 sheet
|
|
388
|
+
node process_videos.js --sheet "普诺赛中文站" --content-column "content"
|
|
389
|
+
|
|
390
|
+
# 配合 --dry-run 预览
|
|
391
|
+
node process_videos.js --content-column "content" --dry-run
|
|
392
|
+
|
|
393
|
+
# 配合 --offset / --limit 调试
|
|
394
|
+
node process_videos.js --content-column "content" --offset 0 --limit 3
|
|
395
|
+
node process_videos.js --content-column "content" --concurrency 2 --retry 2
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
> **注意**:`--content-column` 模式自动设置 `--step analyze`(仅 AI 分析),不会触发下载/转码/识别。
|
|
399
|
+
> 文本为空的行会自动跳过。
|
|
400
|
+
> 分析结果写入 Excel 的 `keywords` 列(由 `COL_KEYWORDS` 环境变量指定)。
|
|
401
|
+
|
|
328
402
|
### 工具预检(执行前自动检测)
|
|
329
403
|
|
|
330
404
|
每次执行任务前,脚本会自动检测本次涉及步骤所需的工具/服务是否可用:
|
|
@@ -360,12 +434,14 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
360
434
|
| `--transcribe-timeout <n>` | int | 600 | 单个识别任务最长执行时间(秒) |
|
|
361
435
|
| `--analyze-timeout <n>` | int | 300 | 单个 AI 分析任务最长执行时间(秒) |
|
|
362
436
|
| `--dry-run` | flag | off | 干跑模式,只列任务不执行 |
|
|
363
|
-
| `--retry-failed <path>` | path | — | 从报告 JSON
|
|
437
|
+
| `--retry-failed <path>` | path | — | 从报告 JSON 重跑失败项(如 `reports/YouTube视频/report_xxx.json`) |
|
|
364
438
|
| `--init` | flag | off | 复制 .env.example 到当前目录并重命名为 .env |
|
|
365
439
|
| `--file <path>` | path | — | 指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量) |
|
|
366
440
|
| `--input <path>` | path | — | 指定本地视频文件路径(跳过下载,直接转码→识别→分析) |
|
|
367
441
|
| `--url <url>` | str | — | 直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接 |
|
|
368
|
-
| `--
|
|
442
|
+
| `--content <text 或 path>` | str | — | 直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,仅做 AI 分析 |
|
|
443
|
+
| `--content-column <col>` | str | — | Excel 模式:指定包含已有文本的列名,批量做 AI 分析(自动设 --step analyze) |
|
|
444
|
+
| `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用) |
|
|
369
445
|
| `--env-file <path>` | path | .env | 指定要加载的 .env 文件路径 |
|
|
370
446
|
|
|
371
447
|
---
|
|
@@ -495,6 +571,9 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
495
571
|
[2143] 开始识别 (文件 45.2MB)...
|
|
496
572
|
[2143] 识别中... 5s ← 识别每 5s 报时
|
|
497
573
|
[2143] 识别完成 (22s, 1234 字符)
|
|
574
|
+
[2143] AI 分析中... 5s ← AI 每 5s 报时
|
|
575
|
+
[2143] AI 分析中... 10s
|
|
576
|
+
[2143] AI 分析完成 (567 字符)
|
|
498
577
|
|
|
499
578
|
[总进度 1/91 (1.1%)] ✅1 ❌0 ⚠️0 ⏭️0 ← 每完成一个刷新
|
|
500
579
|
```
|
|
@@ -505,14 +584,101 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
505
584
|
| 下载 | yt-dlp 实时百分比 + 速度 + ETA |
|
|
506
585
|
| 转码 | 先 ffprobe 取时长,再实时解析 `time=` 算百分比(如 `25.3% (38s/150s)`) |
|
|
507
586
|
| 识别 | 每 5s 打印已用时间,完成时显示总耗时和文本长度 |
|
|
587
|
+
| AI 分析 | 每 5s 打印已用时间,完成时显示结果长度或失败原因 |
|
|
508
588
|
|
|
509
589
|
多线程并发时使用打印锁保证输出不交错。
|
|
510
590
|
|
|
511
591
|
---
|
|
512
592
|
|
|
513
|
-
##
|
|
593
|
+
## 输出结构速查表
|
|
594
|
+
|
|
595
|
+
五种输入来源在不同处理环节的输出路径汇总如下。所有路径均以 `output/` 为根(可通过 `DOWNLOADS_DIR` / `TRANSCODED_DIR` / `REPORTS_DIR` 环境变量覆盖)。
|
|
596
|
+
|
|
597
|
+
> `{sheet}` = Excel 工作表名(如 `YouTube视频`、`普诺赛中文站`)
|
|
598
|
+
> `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`tencentVid`、`youku`)
|
|
599
|
+
> `{stem}` = 去重后的安全文件名(不含扩展名)
|
|
600
|
+
|
|
601
|
+
### ① Excel 批量模式(默认)
|
|
602
|
+
|
|
603
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
604
|
+
|------|---------|---------|------|
|
|
605
|
+
| 下载 | `output/downloads/{sheet}/{stem}.mp4` | 视频 | yt-dlp 下载原始视频 |
|
|
606
|
+
| 转码 | `output/transcoded/{sheet}/{stem}.wav` | 音频 | ffmpeg 转 16kHz mono WAV |
|
|
607
|
+
| JSON 报告 | `output/reports/{sheet}/report_YYYYMMDD_HHMMSS.json` | JSON | 机器可读,含 summary + failed_items,可供 --retry-failed 重跑 |
|
|
608
|
+
| 文本报告 | `output/reports/{sheet}/tasks/{stem}.txt` | 文本 | 人类可读,含语音识别原文 + AI 关键词分析 |
|
|
609
|
+
|
|
610
|
+
> 多 sheet 同时执行时,每个 sheet 独立一个子目录,互不干扰。
|
|
611
|
+
|
|
612
|
+
### ② --url 直链模式
|
|
613
|
+
|
|
614
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
615
|
+
|------|---------|---------|------|
|
|
616
|
+
| 下载 | `output/downloads/{platform}/{name}.mp4` | 视频 | yt-dlp 下载单个视频 |
|
|
617
|
+
| 转码 | `output/transcoded/{platform}/{name}.wav` | 音频 | ffmpeg 转 16kHz mono WAV |
|
|
618
|
+
| JSON 报告 | `output/reports/{platform}/report_YYYYMMDD_HHMMSS.json` | JSON | 格式与 Excel 模式一致 |
|
|
619
|
+
| 文本报告 | `output/reports/{platform}/tasks/{name}.txt` | 文本 | 含识别原文 + AI 分析 |
|
|
620
|
+
|
|
621
|
+
> `{platform}` 由脚本自动从 URL 解析,如 `https://www.youtube.com/watch?v=xxx` → `youtube`。
|
|
622
|
+
|
|
623
|
+
### ③ --input 本地文件模式
|
|
624
|
+
|
|
625
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
626
|
+
|------|---------|---------|------|
|
|
627
|
+
| 下载 | —(跳过) | — | 本地文件无需下载 |
|
|
628
|
+
| 转码 | `output/transcoded/local/{stem}.wav` | 音频 | ffmpeg 转 16kHz mono WAV |
|
|
629
|
+
| JSON 报告 | `output/reports/local/report_YYYYMMDD_HHMMSS.json` | JSON | 格式与 Excel 模式一致 |
|
|
630
|
+
| 文本报告 | `output/reports/local/tasks/{stem}.txt` | 文本 | 含识别原文 + AI 分析 |
|
|
631
|
+
|
|
632
|
+
> `local` 是 `--input` 模式的固定目录名(与 Excel 模式的 sheet 名无关),所有本地文件处理结果统一归入此目录。
|
|
633
|
+
|
|
634
|
+
### ④ --content 纯文本模式
|
|
635
|
+
|
|
636
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
637
|
+
|------|---------|---------|------|
|
|
638
|
+
| 下载 | —(跳过) | — | 无需下载 |
|
|
639
|
+
| 转码 | —(跳过) | — | 无需转码 |
|
|
640
|
+
| 识别 | —(跳过) | — | 无需语音识别 |
|
|
641
|
+
| JSON 报告 | `output/reports/content/report_YYYYMMDD_HHMMSS.json` | JSON | 格式与 Excel 模式一致 |
|
|
642
|
+
| 文本报告 | `output/reports/content/tasks/{stem}.txt` | 文本 | 含源内容 + AI 关键词分析 |
|
|
643
|
+
|
|
644
|
+
> `content` 是固定目录名。{stem} = `--name` 值 > 文件名 stem > 内联文本前 32 字符。
|
|
645
|
+
|
|
646
|
+
### ⑤ --content-column Excel列文本批量模式
|
|
647
|
+
|
|
648
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
649
|
+
|------|---------|---------|------|
|
|
650
|
+
| 下载 | —(跳过) | — | 无需下载 |
|
|
651
|
+
| 转码 | —(跳过) | — | 无需转码 |
|
|
652
|
+
| 识别 | —(跳过) | — | 无需语音识别 |
|
|
653
|
+
| JSON 报告 | `output/reports/{sheet}/report_YYYYMMDD_HHMMSS.json` | JSON | 按 Excel sheet 分目录,格式与 Excel 模式一致 |
|
|
654
|
+
| 文本报告 | `output/reports/{sheet}/tasks/{stem}.txt` | 文本 | 含列文本 + AI 关键词分析 |
|
|
655
|
+
| Excel 写回 | `{EXCEL_FILE}` 的 `keywords` 列 | Excel | AI 关键词写入 Excel |
|
|
656
|
+
|
|
657
|
+
> 此模式自动设置 `--step analyze`,下载/转码/识别全跳过。AI 结果同时写入 Excel 和报告文件。
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
### 五种来源对比一览
|
|
662
|
+
|
|
663
|
+
| 维度 | Excel 批量 | --url 直链 | --input 本地文件 | --content 纯文本 | --content-column 列文本 |
|
|
664
|
+
|------|-----------|-----------|-----------------|-----------------|------------------------|
|
|
665
|
+
| 输入 | Excel 行(多视频批量) | 单个视频 URL | 本地视频/音频文件 | 文件路径或内联文本 | Excel 列的已有文本 |
|
|
666
|
+
| 下载 | ✅ yt-dlp | ✅ yt-dlp | ❌ 跳过 | ❌ 跳过 | ❌ 跳过 |
|
|
667
|
+
| 转码 | ✅ ffmpeg | ✅ ffmpeg | ✅ ffmpeg | ❌ 跳过 | ❌ 跳过 |
|
|
668
|
+
| 识别 | ✅ whisper | ✅ whisper | ✅ whisper | ❌ 跳过 | ❌ 跳过 |
|
|
669
|
+
| AI 分析 | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
670
|
+
| 下载目录 | `downloads/{sheet}/` | `downloads/{platform}/` | 无 | 无 | 无 |
|
|
671
|
+
| 转码目录 | `transcoded/{sheet}/` | `transcoded/{platform}/` | `transcoded/local/` | 无 | 无 |
|
|
672
|
+
| 报告目录 | `reports/{sheet}/` | `reports/{platform}/` | `reports/local/` | `reports/content/` | `reports/{sheet}/` |
|
|
673
|
+
| 分组依据 | Excel sheet 名 | URL 解析的平台名 | 固定 `local` | 固定 `content` | Excel sheet 名 |
|
|
674
|
+
| 并发支持 | ✅ 多线程 | ❌ 单任务 | ❌ 单任务 | ❌ 单任务 | ✅ 多线程 |
|
|
675
|
+
| 写入 Excel | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
676
|
+
| 支持 --retry-failed | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
677
|
+
| 适用场景 | 批量处理全流程 | 临时下载单个视频 | 处理已有视频文件 | 已有文本直接分析 | 批量分析Excel中的文本 |
|
|
678
|
+
|
|
679
|
+
---
|
|
514
680
|
|
|
515
|
-
|
|
681
|
+
### JSON 报告结构
|
|
516
682
|
|
|
517
683
|
```json
|
|
518
684
|
{
|
|
@@ -538,6 +704,8 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
538
704
|
}
|
|
539
705
|
```
|
|
540
706
|
|
|
707
|
+
### 状态含义
|
|
708
|
+
|
|
541
709
|
- **success**:下载 + 转码 + 识别全部成功(AI 分析失败不影响此状态)
|
|
542
710
|
- **partial**:下载 + 转码成功,识别或 AI 分析失败
|
|
543
711
|
- **failed**:下载或转码失败
|
|
@@ -547,6 +715,8 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
547
715
|
|
|
548
716
|
## 典型工作流
|
|
549
717
|
|
|
718
|
+
### 场景一:Excel 批量处理视频
|
|
719
|
+
|
|
550
720
|
```bash
|
|
551
721
|
# 1. 干跑预览
|
|
552
722
|
node process_videos.js --dry-run
|
|
@@ -560,7 +730,45 @@ node process_videos.js --sheet "YouTube视频" --id 2143 --retry 2
|
|
|
560
730
|
node process_videos.js --concurrency 3 --retry 3
|
|
561
731
|
|
|
562
732
|
# 4. 查看报告,重跑失败项
|
|
563
|
-
node process_videos.js --retry-failed reports/report_xxx.json --concurrency 2 --retry 3
|
|
733
|
+
node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --concurrency 2 --retry 3
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### 场景二:临时下载单个视频
|
|
737
|
+
|
|
738
|
+
```bash
|
|
739
|
+
# 从 URL 下载 → 转码 → 识别 → AI 分析,一条龙
|
|
740
|
+
node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c"
|
|
741
|
+
|
|
742
|
+
# 指定输出文件名
|
|
743
|
+
node process_videos.js --url "https://www.bilibili.com/video/BV1xx411c7mD" --name "产品介绍视频"
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### 场景三:处理本地视频文件
|
|
747
|
+
|
|
748
|
+
```bash
|
|
749
|
+
# 已有视频文件,直接转码分析
|
|
750
|
+
node process_videos.js --input "downloads/产品介绍.mp4"
|
|
751
|
+
|
|
752
|
+
# 只做 AI 分析(已有转码+识别结果)
|
|
753
|
+
node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### 场景四:纯文本 AI 分析
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
# 已有文本内容,跳过所有视频步骤,直接做关键词提取
|
|
760
|
+
node process_videos.js --content "data/article.txt"
|
|
761
|
+
|
|
762
|
+
# 内联文本直接分析
|
|
763
|
+
node process_videos.js --content "今天我们要讨论的是普诺赛产品..." --name "产品讨论"
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
### 场景五:批量分析 Excel 中的已有文本
|
|
767
|
+
|
|
768
|
+
```bash
|
|
769
|
+
# Excel 某列已有文本(如爬虫爬取的),批量做 AI 关键词分析
|
|
770
|
+
node process_videos.js --content-column "content" --dry-run # 先预览
|
|
771
|
+
node process_videos.js --content-column "content" --concurrency 2 # 执行
|
|
564
772
|
```
|
|
565
773
|
|
|
566
774
|
---
|
package/package.json
CHANGED
package/process_videos.js
CHANGED
|
@@ -185,6 +185,7 @@ function timestamp() {
|
|
|
185
185
|
return new Date().toTimeString().slice(0, 8);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Node.js 单线程模型下 console.log 是原子的,不会出现行内交错
|
|
188
189
|
function lockedPrint(s) {
|
|
189
190
|
console.log(s);
|
|
190
191
|
}
|
|
@@ -655,6 +656,8 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
|
|
|
655
656
|
try { onProgress(line); } catch {}
|
|
656
657
|
});
|
|
657
658
|
child.stderr.on('end', () => rl.close());
|
|
659
|
+
// 同时消费 stdout,防止缓冲区填满阻塞子进程
|
|
660
|
+
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
658
661
|
} else {
|
|
659
662
|
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
660
663
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
@@ -676,7 +679,7 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
|
|
|
676
679
|
}
|
|
677
680
|
|
|
678
681
|
// ============================== AI 分析 ==============================
|
|
679
|
-
async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
682
|
+
async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300, label = 'analyze') {
|
|
680
683
|
if (!text || !text.trim()) {
|
|
681
684
|
return { text: null, retries: 0, error: 'content empty, skip AI analysis' };
|
|
682
685
|
}
|
|
@@ -703,6 +706,15 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
703
706
|
let lastErr = null;
|
|
704
707
|
const maxAttempts = maxRetries + 1;
|
|
705
708
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
709
|
+
let done = false;
|
|
710
|
+
const analyzeStart = Date.now();
|
|
711
|
+
const progressInterval = setInterval(() => {
|
|
712
|
+
if (!done) {
|
|
713
|
+
const elapsed = ((Date.now() - analyzeStart) / 1000).toFixed(0);
|
|
714
|
+
lockedPrint(` [${label}] AI analyzing... ${elapsed}s`);
|
|
715
|
+
}
|
|
716
|
+
}, 5000);
|
|
717
|
+
|
|
706
718
|
try {
|
|
707
719
|
const controller = new AbortController();
|
|
708
720
|
const timer = setTimeout(() => controller.abort(), aiTimeout * 1000);
|
|
@@ -716,6 +728,8 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
716
728
|
signal: controller.signal,
|
|
717
729
|
});
|
|
718
730
|
clearTimeout(timer);
|
|
731
|
+
done = true;
|
|
732
|
+
clearInterval(progressInterval);
|
|
719
733
|
if (!resp.ok) {
|
|
720
734
|
throw new Error(`HTTP ${resp.status}: ${await resp.text().catch(() => '')}`);
|
|
721
735
|
}
|
|
@@ -723,10 +737,12 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
723
737
|
const content = body.choices?.[0]?.message?.content || '';
|
|
724
738
|
return { text: content.trim(), retries: attempt, error: null };
|
|
725
739
|
} catch (e) {
|
|
740
|
+
done = true;
|
|
741
|
+
clearInterval(progressInterval);
|
|
726
742
|
lastErr = String(e.message).slice(0, 500);
|
|
727
743
|
if (attempt < maxAttempts - 1) {
|
|
728
744
|
const delay = Math.min(retryDelay * Math.pow(2, attempt), 30);
|
|
729
|
-
lockedPrint(` [
|
|
745
|
+
lockedPrint(` [${label}] AI attempt ${attempt + 1} failed: ${lastErr.slice(0, 100)}, retrying in ${delay}s...`);
|
|
730
746
|
await sleep(delay * 1000);
|
|
731
747
|
}
|
|
732
748
|
}
|
|
@@ -1157,11 +1173,11 @@ function writeAllContentsToExcel(results, keywordsDict = null) {
|
|
|
1157
1173
|
}
|
|
1158
1174
|
|
|
1159
1175
|
function groupBySheetMap(updates) {
|
|
1160
|
-
const result =
|
|
1176
|
+
const result = new Map();
|
|
1161
1177
|
for (const [compositeKey, text] of updates) {
|
|
1162
1178
|
const [sheetName, key] = compositeKey.split('|');
|
|
1163
|
-
if (!result
|
|
1164
|
-
result
|
|
1179
|
+
if (!result.has(sheetName)) result.set(sheetName, {});
|
|
1180
|
+
result.get(sheetName)[key] = text;
|
|
1165
1181
|
}
|
|
1166
1182
|
return result;
|
|
1167
1183
|
}
|
|
@@ -1178,10 +1194,31 @@ function computeSummary(results) {
|
|
|
1178
1194
|
return { total: results.length, success, partial, failed, no_video: noVideo };
|
|
1179
1195
|
}
|
|
1180
1196
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1197
|
+
/**
|
|
1198
|
+
* 生成执行报告 JSON 文件。
|
|
1199
|
+
* - 提供 sheetName 时:报告存入 REPORTS_DIR/{sheetName}/report_{ts}.json
|
|
1200
|
+
* - 不提供时:按 r.sheet 分组,每 sheet 调用自身,返回路径数组
|
|
1201
|
+
*/
|
|
1202
|
+
function generateReport(results, config, sheetName) {
|
|
1203
|
+
if (!sheetName) {
|
|
1204
|
+
// ── 按 sheet 分组生成 ──
|
|
1205
|
+
const sheetGroups = new Map();
|
|
1206
|
+
for (const r of results) {
|
|
1207
|
+
if (!sheetGroups.has(r.sheet)) sheetGroups.set(r.sheet, []);
|
|
1208
|
+
sheetGroups.get(r.sheet).push(r);
|
|
1209
|
+
}
|
|
1210
|
+
const paths = [];
|
|
1211
|
+
for (const [sheet, items] of sheetGroups) {
|
|
1212
|
+
paths.push(generateReport(items, config, sheet));
|
|
1213
|
+
}
|
|
1214
|
+
return paths;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ── 单 sheet 报告 ──
|
|
1218
|
+
const dir = path.join(REPORTS_DIR, sheetName);
|
|
1219
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1220
|
+
const ts = new Date().toISOString().split('.')[0].replace(/[-:T]/g, '').replace(/(\d{8})(\d{6})/, '$1_$2');
|
|
1221
|
+
const reportFile = path.join(dir, `report_${ts}.json`);
|
|
1185
1222
|
|
|
1186
1223
|
const summary = computeSummary(results);
|
|
1187
1224
|
|
|
@@ -1266,6 +1303,8 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1266
1303
|
whisperAvailable, positionLabel = '', downloadTimeout = 600, transcodeTimeout = 600,
|
|
1267
1304
|
transcribeTimeout = 600, analyzeTimeout = 300) {
|
|
1268
1305
|
|
|
1306
|
+
const preContent = row.preContent || null;
|
|
1307
|
+
|
|
1269
1308
|
const { pkey, vid } = getVideoId(row);
|
|
1270
1309
|
const stem = stemName(row, sheetName);
|
|
1271
1310
|
const key = rowKey(row);
|
|
@@ -1280,7 +1319,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1280
1319
|
|
|
1281
1320
|
// ── download ──
|
|
1282
1321
|
let dlFile = null;
|
|
1283
|
-
if (
|
|
1322
|
+
if (preContent) {
|
|
1323
|
+
result.download = new StepResult('skipped', null, 'pre-content mode');
|
|
1324
|
+
} else if (steps.includes('download')) {
|
|
1284
1325
|
if (!pkey) {
|
|
1285
1326
|
result.download = new StepResult('skipped');
|
|
1286
1327
|
result.overall_status = 'no_video';
|
|
@@ -1311,7 +1352,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1311
1352
|
|
|
1312
1353
|
// ── transcode ──
|
|
1313
1354
|
let tcFile = null;
|
|
1314
|
-
if (
|
|
1355
|
+
if (preContent) {
|
|
1356
|
+
result.transcode = new StepResult('skipped', null, 'pre-content mode');
|
|
1357
|
+
} else if (steps.includes('transcode') && dlFile) {
|
|
1315
1358
|
try {
|
|
1316
1359
|
const { file, retries, error } = await stepTranscode(dlFile, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
|
|
1317
1360
|
tcFile = file;
|
|
@@ -1336,7 +1379,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1336
1379
|
}
|
|
1337
1380
|
|
|
1338
1381
|
// ── transcribe ──
|
|
1339
|
-
if (
|
|
1382
|
+
if (preContent) {
|
|
1383
|
+
result.transcribe = new StepResult('success', preContent);
|
|
1384
|
+
} else if (steps.includes('transcribe') && tcFile) {
|
|
1340
1385
|
if (!whisperAvailable) {
|
|
1341
1386
|
result.transcribe = new StepResult('failed', null, `whisper unreachable (${WHISPER_SERVICE})`);
|
|
1342
1387
|
result.overall_status = 'partial';
|
|
@@ -1366,7 +1411,7 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1366
1411
|
const txt = result.transcribe.file;
|
|
1367
1412
|
if (txt) {
|
|
1368
1413
|
try {
|
|
1369
|
-
const { text: kw, retries, error } = await stepAnalyze(txt, maxRetries, retryDelay, analyzeTimeout);
|
|
1414
|
+
const { text: kw, retries, error } = await stepAnalyze(txt, maxRetries, retryDelay, analyzeTimeout, result.stem);
|
|
1370
1415
|
result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
|
|
1371
1416
|
if (kw) {
|
|
1372
1417
|
lockedPrint(` [${result.stem}] AI analysis done (${kw.length} chars)`);
|
|
@@ -1528,62 +1573,106 @@ async function runInputTask(opts) {
|
|
|
1528
1573
|
|
|
1529
1574
|
console.log(c('dim', '\n── 开始执行 ──\n'));
|
|
1530
1575
|
|
|
1576
|
+
// ── 解决 stem 重名 ──
|
|
1577
|
+
let usedStem = stem;
|
|
1578
|
+
{
|
|
1579
|
+
let counter = 1;
|
|
1580
|
+
const tcDir = path.join(TRANSCODED_DIR, sheetName);
|
|
1581
|
+
fs.mkdirSync(tcDir, { recursive: true });
|
|
1582
|
+
let testPath = path.join(tcDir, usedStem + TRANSCODE_EXT);
|
|
1583
|
+
while (fs.existsSync(testPath) && !steps.includes('transcode')) {
|
|
1584
|
+
// 跳过转码但转码产物已存在 → 直接用
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
if (steps.includes('transcode') && !force) {
|
|
1588
|
+
while (fs.existsSync(testPath)) {
|
|
1589
|
+
usedStem = `${stem}_${counter}`;
|
|
1590
|
+
testPath = path.join(tcDir, usedStem + TRANSCODE_EXT);
|
|
1591
|
+
counter++;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (usedStem !== stem) {
|
|
1596
|
+
console.log(` ⚠️ stem "${stem}" 已存在 → 使用 "${usedStem}"`);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// ── 构建 TaskResult ──
|
|
1600
|
+
const result = new TaskResult(sheetName, usedStem, path.basename(inputPath), 'local', null, usedStem);
|
|
1601
|
+
result.download = new StepResult('skipped');
|
|
1602
|
+
|
|
1531
1603
|
// ── download: 跳过(本地文件)──
|
|
1532
|
-
console.log(` [${
|
|
1604
|
+
console.log(` [${usedStem}] 📥 下载: ${c('yellow', '已跳过 (本地文件)')}`);
|
|
1533
1605
|
|
|
1534
1606
|
// ── transcode ──
|
|
1535
1607
|
let tcFile = null;
|
|
1536
1608
|
if (steps.includes('transcode')) {
|
|
1537
|
-
console.log(` [${
|
|
1609
|
+
console.log(` [${usedStem}] 🎵 开始转码...`);
|
|
1538
1610
|
try {
|
|
1539
1611
|
const { file, error } = await stepTranscode(inputPath, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
|
|
1540
1612
|
tcFile = file;
|
|
1541
1613
|
if (file && fs.existsSync(file)) {
|
|
1542
1614
|
const size = (fs.statSync(file).size / 1024 / 1024).toFixed(1);
|
|
1543
|
-
console.log(` [${
|
|
1615
|
+
console.log(` [${usedStem}] 🎵 转码完成: ${file} (${size} MB)`);
|
|
1616
|
+
result.transcode = new StepResult('success', file);
|
|
1544
1617
|
} else {
|
|
1545
|
-
console.log(` [${
|
|
1618
|
+
console.log(` [${usedStem}] 🎵 转码: ${c(file ? 'yellow' : 'red', file ? '已跳过 (文件已存在)' : '失败 — ' + (error || ''))}`);
|
|
1619
|
+
result.transcode = new StepResult(file ? 'skipped' : 'failed', file, error);
|
|
1546
1620
|
}
|
|
1547
1621
|
} catch (e) {
|
|
1548
|
-
console.log(` [${
|
|
1622
|
+
console.log(` [${usedStem}] 🎵 转码: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
|
|
1623
|
+
result.transcode = new StepResult('failed', null, String(e.message).slice(0, 500));
|
|
1549
1624
|
}
|
|
1550
1625
|
if (!tcFile) {
|
|
1551
1626
|
console.log(c('yellow', '\n⚠️ 转码未产出文件,后续步骤将跳过\n'));
|
|
1627
|
+
result.overall_status = 'failed';
|
|
1628
|
+
result.error = 'transcode failed';
|
|
1629
|
+
return result;
|
|
1552
1630
|
}
|
|
1553
1631
|
} else if (steps.includes('transcribe')) {
|
|
1554
|
-
// 无 transcode 步骤但有 transcribe:优先使用已有转码文件
|
|
1555
1632
|
const tcDir = path.join(TRANSCODED_DIR, sheetName);
|
|
1556
|
-
const expectedTc = path.join(tcDir,
|
|
1633
|
+
const expectedTc = path.join(tcDir, usedStem + TRANSCODE_EXT);
|
|
1557
1634
|
if (fs.existsSync(expectedTc)) {
|
|
1558
1635
|
tcFile = expectedTc;
|
|
1559
|
-
|
|
1636
|
+
result.transcode = new StepResult('success', tcFile);
|
|
1637
|
+
console.log(` [${usedStem}] 🎵 转码: ${c('yellow', '使用已有文件 ' + path.basename(expectedTc))}`);
|
|
1560
1638
|
} else {
|
|
1561
|
-
console.log(` [${
|
|
1639
|
+
console.log(` [${usedStem}] 🎵 转码: ${c('red', '未找到转码文件,将尝试用原始文件识别(可能失败)')}`);
|
|
1562
1640
|
tcFile = inputPath;
|
|
1641
|
+
result.transcode = new StepResult('warning', inputPath, 'transcode file not found, using raw input');
|
|
1563
1642
|
}
|
|
1564
1643
|
} else {
|
|
1565
1644
|
tcFile = inputPath;
|
|
1645
|
+
result.transcode = new StepResult('success', inputPath);
|
|
1566
1646
|
}
|
|
1567
1647
|
|
|
1568
1648
|
// ── transcribe ──
|
|
1569
1649
|
let transcribeText = '';
|
|
1570
1650
|
if (steps.includes('transcribe') && tcFile) {
|
|
1571
1651
|
if (!whisperAvailable) {
|
|
1572
|
-
console.log(` [${
|
|
1652
|
+
console.log(` [${usedStem}] 📝 识别: ${c('red', 'whisper 不可用')}`);
|
|
1653
|
+
result.transcribe = new StepResult('failed', null, 'whisper unreachable');
|
|
1654
|
+
result.overall_status = 'failed';
|
|
1655
|
+
result.error = 'whisper unreachable';
|
|
1656
|
+
return result;
|
|
1573
1657
|
} else {
|
|
1574
|
-
console.log(` [${
|
|
1658
|
+
console.log(` [${usedStem}] 📝 开始语音识别...`);
|
|
1575
1659
|
try {
|
|
1576
1660
|
const { text, error } = await stepTranscribe(tcFile, maxRetries, retryDelay, transcribeTimeout);
|
|
1577
1661
|
if (text && typeof text === 'string') {
|
|
1578
1662
|
transcribeText = text;
|
|
1579
|
-
console.log(` [${
|
|
1663
|
+
console.log(` [${usedStem}] 📝 识别完成: ${text.length} 字符`);
|
|
1664
|
+
result.transcribe = new StepResult('success', text);
|
|
1580
1665
|
} else {
|
|
1581
|
-
console.log(` [${
|
|
1666
|
+
console.log(` [${usedStem}] 📝 识别: ${c('red', '失败 — ' + (error || ''))}`);
|
|
1667
|
+
result.transcribe = new StepResult('failed', null, error);
|
|
1582
1668
|
}
|
|
1583
1669
|
} catch (e) {
|
|
1584
|
-
console.log(` [${
|
|
1670
|
+
console.log(` [${usedStem}] 📝 识别: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
|
|
1671
|
+
result.transcribe = new StepResult('failed', null, String(e.message).slice(0, 500));
|
|
1585
1672
|
}
|
|
1586
1673
|
}
|
|
1674
|
+
} else {
|
|
1675
|
+
result.transcribe = new StepResult('skipped');
|
|
1587
1676
|
}
|
|
1588
1677
|
|
|
1589
1678
|
// ── AI analyze ──
|
|
@@ -1591,28 +1680,45 @@ async function runInputTask(opts) {
|
|
|
1591
1680
|
if (steps.includes('analyze') && transcribeText) {
|
|
1592
1681
|
const aiEnabled = (process.env.AI_ENABLED || 'true').toLowerCase() === 'true';
|
|
1593
1682
|
if (aiEnabled) {
|
|
1594
|
-
console.log(` [${
|
|
1683
|
+
console.log(` [${usedStem}] 🤖 开始 AI 分析...`);
|
|
1595
1684
|
try {
|
|
1596
|
-
const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout);
|
|
1685
|
+
const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout, usedStem);
|
|
1597
1686
|
if (kw && typeof kw === 'string') {
|
|
1598
1687
|
analyzeText = kw;
|
|
1599
|
-
console.log(` [${
|
|
1688
|
+
console.log(` [${usedStem}] 🤖 AI分析完成: ${kw.length} 字符`);
|
|
1689
|
+
result.analyze = new StepResult('success', kw);
|
|
1600
1690
|
} else {
|
|
1601
|
-
console.log(` [${
|
|
1691
|
+
console.log(` [${usedStem}] 🤖 AI分析: ${c('red', '失败 — ' + (error || ''))}`);
|
|
1692
|
+
result.analyze = new StepResult('failed', null, error);
|
|
1602
1693
|
}
|
|
1603
1694
|
} catch (e) {
|
|
1604
|
-
console.log(` [${
|
|
1695
|
+
console.log(` [${usedStem}] 🤖 AI分析: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
|
|
1696
|
+
result.analyze = new StepResult('failed', null, String(e.message).slice(0, 500));
|
|
1605
1697
|
}
|
|
1606
1698
|
} else {
|
|
1607
|
-
console.log(` [${
|
|
1699
|
+
console.log(` [${usedStem}] 🤖 AI分析: ${c('yellow', '已禁用 (AI_ENABLED=false)')}`);
|
|
1700
|
+
result.analyze = new StepResult('skipped');
|
|
1608
1701
|
}
|
|
1702
|
+
} else {
|
|
1703
|
+
result.analyze = new StepResult('skipped');
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ── 判定整体状态 ──
|
|
1707
|
+
if (result.transcode.status === 'failed') {
|
|
1708
|
+
result.overall_status = 'failed';
|
|
1709
|
+
} else if (result.transcribe.status === 'failed' && steps.includes('transcribe')) {
|
|
1710
|
+
result.overall_status = 'partial';
|
|
1711
|
+
} else if (result.analyze.status === 'failed') {
|
|
1712
|
+
result.overall_status = 'partial';
|
|
1713
|
+
} else {
|
|
1714
|
+
result.overall_status = 'success';
|
|
1609
1715
|
}
|
|
1610
1716
|
|
|
1611
1717
|
// ── 保存文本结果 ──
|
|
1612
1718
|
if (transcribeText || analyzeText) {
|
|
1613
|
-
const outDir = path.join(REPORTS_DIR, '
|
|
1719
|
+
const outDir = path.join(REPORTS_DIR, sheetName, 'tasks');
|
|
1614
1720
|
fs.mkdirSync(outDir, { recursive: true });
|
|
1615
|
-
const outFile = path.join(outDir, `${
|
|
1721
|
+
const outFile = path.join(outDir, `${usedStem}.txt`);
|
|
1616
1722
|
const lines = [
|
|
1617
1723
|
`文件: ${inputPath}`,
|
|
1618
1724
|
`平台: local`,
|
|
@@ -1643,6 +1749,125 @@ async function runInputTask(opts) {
|
|
|
1643
1749
|
console.log(c('yellow', `⚠️ ${failed.length} 个步骤未成功: ${failed.join(', ')}`));
|
|
1644
1750
|
}
|
|
1645
1751
|
console.log('');
|
|
1752
|
+
|
|
1753
|
+
return result;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1758
|
+
// 文本内容流水线(--content 模式)
|
|
1759
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* --content 模式:纯文本 → AI 关键词提取
|
|
1763
|
+
* 文本来源可以是文件路径或内联文本,
|
|
1764
|
+
* --name 可指定输出文件名(不含扩展名)。
|
|
1765
|
+
* 不指定时:文件路径取 stem,内联文本取前 32 字符。
|
|
1766
|
+
*/
|
|
1767
|
+
async function runContentTask(opts) {
|
|
1768
|
+
const { content, name, steps, force,
|
|
1769
|
+
retry: maxRetries, retryDelay, analyzeTimeout } = opts;
|
|
1770
|
+
|
|
1771
|
+
// ── 1. 读取/确定文本 ──
|
|
1772
|
+
const contentPath = path.resolve(content);
|
|
1773
|
+
let contentText = '';
|
|
1774
|
+
let fromFile = false;
|
|
1775
|
+
if (fs.existsSync(contentPath) && fs.statSync(contentPath).isFile()) {
|
|
1776
|
+
contentText = fs.readFileSync(contentPath, 'utf-8').trim();
|
|
1777
|
+
fromFile = true;
|
|
1778
|
+
console.log(` 📖 从文件读取: ${contentPath} (${contentText.length} 字符)`);
|
|
1779
|
+
} else {
|
|
1780
|
+
contentText = content;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if (!contentText || !contentText.trim()) {
|
|
1784
|
+
console.error(c('red', '错误: --content 文本内容为空'));
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// ── 2. 确定输出文件名 ──
|
|
1789
|
+
let stem = '';
|
|
1790
|
+
if (name) {
|
|
1791
|
+
stem = safeFilename(name);
|
|
1792
|
+
} else if (fromFile) {
|
|
1793
|
+
stem = safeFilename(path.parse(contentPath).name);
|
|
1794
|
+
} else {
|
|
1795
|
+
stem = safeFilename(contentText.replace(/\s+/g, ' ').slice(0, 32).trim());
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
if (steps.length === 0) steps = ['analyze'];
|
|
1799
|
+
|
|
1800
|
+
console.log(c('dim', '\n── 开始执行 (内容分析) ──\n'));
|
|
1801
|
+
console.log(` 输出名称: ${c('cyan', stem)}`);
|
|
1802
|
+
console.log(` 内容长度: ${c('cyan', contentText.length + ' 字符')}`);
|
|
1803
|
+
console.log(` 执行步骤: ${c('cyan', steps.join(' → '))}`);
|
|
1804
|
+
|
|
1805
|
+
if (opts.dryRun) {
|
|
1806
|
+
console.log('');
|
|
1807
|
+
process.exit(0);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const sheetName = 'content';
|
|
1811
|
+
|
|
1812
|
+
// ── 3. 构建 TaskResult ──
|
|
1813
|
+
const result = new TaskResult(sheetName, stem, stem.slice(0, 50), 'local', null, stem);
|
|
1814
|
+
result.download = new StepResult('skipped');
|
|
1815
|
+
result.transcode = new StepResult('skipped');
|
|
1816
|
+
result.transcribe = new StepResult('success', contentText);
|
|
1817
|
+
|
|
1818
|
+
// ── 4. AI 分析 ──
|
|
1819
|
+
if (steps.includes('analyze')) {
|
|
1820
|
+
const aiEnabled = (process.env.AI_ENABLED || 'true').toLowerCase() === 'true';
|
|
1821
|
+
if (!aiEnabled) {
|
|
1822
|
+
result.analyze = new StepResult('skipped');
|
|
1823
|
+
console.log(` [${stem}] AI 分析: ${c('yellow', '已禁用 (AI_ENABLED=false)')}`);
|
|
1824
|
+
} else {
|
|
1825
|
+
console.log(` [${stem}] 开始 AI 分析...`);
|
|
1826
|
+
try {
|
|
1827
|
+
const { text: kw, retries, error } = await stepAnalyze(
|
|
1828
|
+
contentText, maxRetries, retryDelay, analyzeTimeout, stem
|
|
1829
|
+
);
|
|
1830
|
+
result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
|
|
1831
|
+
if (kw) {
|
|
1832
|
+
console.log(` [${stem}] AI 分析完成 (${kw.length} 字符)`);
|
|
1833
|
+
} else {
|
|
1834
|
+
console.log(` [${stem}] AI 分析失败: ${error}`);
|
|
1835
|
+
}
|
|
1836
|
+
} catch (e) {
|
|
1837
|
+
result.analyze = new StepResult('failed', null, String(e.message).slice(0, 500), maxRetries);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
result.overall_status = (result.analyze && result.analyze.status === 'success') ? 'success' : 'partial';
|
|
1843
|
+
|
|
1844
|
+
// ── 5. 保存文本结果 ──
|
|
1845
|
+
const an = result.analyze;
|
|
1846
|
+
const analyzeText = an && an.file && an.status === 'success' ? an.file : '';
|
|
1847
|
+
if (contentText || analyzeText) {
|
|
1848
|
+
const outDir = path.join(REPORTS_DIR, sheetName, 'tasks');
|
|
1849
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1850
|
+
const outFile = path.join(outDir, `${stem}.txt`);
|
|
1851
|
+
const lines = [
|
|
1852
|
+
`来源: --content`,
|
|
1853
|
+
`文件名: ${stem}`,
|
|
1854
|
+
'', '='.repeat(60), '',
|
|
1855
|
+
'【源内容】', '', contentText, '',
|
|
1856
|
+
];
|
|
1857
|
+
if (analyzeText) {
|
|
1858
|
+
lines.push('【AI 分析关键词】', '', analyzeText);
|
|
1859
|
+
}
|
|
1860
|
+
fs.writeFileSync(outFile, lines.join('\n'), 'utf-8');
|
|
1861
|
+
console.log(`\n 报告已保存: ${outFile}`);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ── 6. 生成标准报告 JSON ──
|
|
1865
|
+
const config = { steps, max_retries: maxRetries, retry_delay: retryDelay, concurrency: 1, force: force || false };
|
|
1866
|
+
generateReport([result], config, sheetName);
|
|
1867
|
+
printReportSummary([result]);
|
|
1868
|
+
|
|
1869
|
+
console.log('');
|
|
1870
|
+
return result;
|
|
1646
1871
|
}
|
|
1647
1872
|
|
|
1648
1873
|
|
|
@@ -1738,7 +1963,7 @@ async function runUrlTask(opts) {
|
|
|
1738
1963
|
const analyzeText = (result.analyze && typeof result.analyze.file === 'string') ? result.analyze.file : '';
|
|
1739
1964
|
|
|
1740
1965
|
if (transcribeText || analyzeText) {
|
|
1741
|
-
const outDir = path.join(REPORTS_DIR, '
|
|
1966
|
+
const outDir = path.join(REPORTS_DIR, platform, 'tasks');
|
|
1742
1967
|
fs.mkdirSync(outDir, { recursive: true });
|
|
1743
1968
|
const outFile = path.join(outDir, `${stem}.txt`);
|
|
1744
1969
|
const lines = [
|
|
@@ -1758,10 +1983,11 @@ async function runUrlTask(opts) {
|
|
|
1758
1983
|
}
|
|
1759
1984
|
|
|
1760
1985
|
console.log(c('bold', c('green', `\n\uD83C\uDF89 \u5168\u90E8\u5B8C\u6210! (${successes.length}/${steps.length} \u6B65\u6210\u529F)\n`)));
|
|
1986
|
+
return result;
|
|
1761
1987
|
}
|
|
1762
1988
|
|
|
1763
1989
|
async function run({
|
|
1764
|
-
targetSheet, targetId, steps, maxRetries, retryDelay,
|
|
1990
|
+
targetSheet, targetId, contentColumn, steps, maxRetries, retryDelay,
|
|
1765
1991
|
concurrency, force, dryRun, retryFailed,
|
|
1766
1992
|
downloadTimeout, transcodeTimeout, transcribeTimeout, analyzeTimeout,
|
|
1767
1993
|
offset = 0, rowLimit = 0,
|
|
@@ -1794,6 +2020,15 @@ async function run({
|
|
|
1794
2020
|
}
|
|
1795
2021
|
precomputeStems(rows, sheetName);
|
|
1796
2022
|
for (const row of rows) {
|
|
2023
|
+
// ── content-column 模式:从指定列读取预置文本 ──
|
|
2024
|
+
if (contentColumn) {
|
|
2025
|
+
const text = String(row[contentColumn] || '').trim();
|
|
2026
|
+
if (!text) {
|
|
2027
|
+
logWarn(`[${sheetName}] row ${row[COL_ID] || '?'}: contentColumn "${contentColumn}" 为空,跳过`);
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
row.preContent = text;
|
|
2031
|
+
}
|
|
1797
2032
|
tasks.push({ row, sheetName });
|
|
1798
2033
|
}
|
|
1799
2034
|
}
|
|
@@ -1857,7 +2092,7 @@ async function run({
|
|
|
1857
2092
|
await Promise.all(taskFns);
|
|
1858
2093
|
|
|
1859
2094
|
// ── 批量写回 Excel ──
|
|
1860
|
-
if (steps.includes('transcribe')) {
|
|
2095
|
+
if (steps.includes('transcribe') || contentColumn) {
|
|
1861
2096
|
const kwMap = new Map();
|
|
1862
2097
|
for (const r of results) {
|
|
1863
2098
|
if (r.analyze.status === 'success' && r.analyze.file) {
|
|
@@ -1872,10 +2107,10 @@ async function run({
|
|
|
1872
2107
|
sheets, target_id: targetId, steps, max_retries: maxRetries,
|
|
1873
2108
|
retry_delay: retryDelay, concurrency, force,
|
|
1874
2109
|
};
|
|
1875
|
-
const
|
|
2110
|
+
const reportPaths = generateReport(results, config);
|
|
1876
2111
|
printReportSummary(results);
|
|
1877
2112
|
|
|
1878
|
-
logInfo(`all done!
|
|
2113
|
+
logInfo(`all done! reports: ${Array.isArray(reportPaths) ? reportPaths.join(', ') : reportPaths}`);
|
|
1879
2114
|
}
|
|
1880
2115
|
|
|
1881
2116
|
function printDryRun(tasks, steps, env) {
|
|
@@ -2068,9 +2303,9 @@ async function runFromReport(reportPath, steps, maxRetries, retryDelay, concurre
|
|
|
2068
2303
|
|
|
2069
2304
|
const config = { retry_from: reportPath, steps, max_retries: maxRetries,
|
|
2070
2305
|
retry_delay: retryDelay, concurrency, force };
|
|
2071
|
-
const
|
|
2306
|
+
const reportPaths = generateReport(results, config);
|
|
2072
2307
|
printReportSummary(results);
|
|
2073
|
-
logInfo(`all done!
|
|
2308
|
+
logInfo(`all done! reports: ${Array.isArray(reportPaths) ? reportPaths.join(', ') : reportPaths}`);
|
|
2074
2309
|
}
|
|
2075
2310
|
|
|
2076
2311
|
// ============================== CLI ==============================
|
|
@@ -2099,12 +2334,14 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2099
2334
|
.option('--transcribe-timeout <n>', '识别超时(秒),默认 600', parseInt, 600)
|
|
2100
2335
|
.option('--analyze-timeout <n>', 'AI 分析超时(秒),默认 300', parseInt, 300)
|
|
2101
2336
|
.option('--dry-run', '干跑模式,只列任务不执行')
|
|
2102
|
-
.option('--retry-failed <path>', '从报告 JSON
|
|
2337
|
+
.option('--retry-failed <path>', '从报告 JSON 重跑失败项(output/reports/{sheet}/report_xxx.json)')
|
|
2103
2338
|
.option('--init', '复制 .env.example 到当前目录并重命名为 .env')
|
|
2104
2339
|
.option('--file <path>', '指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量)')
|
|
2105
2340
|
.option('--input <path>', '指定本地视频文件路径(跳过下载,直接转码→识别→分析)')
|
|
2341
|
+
.option('--content <text|path>', '直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,直接做 AI 分析')
|
|
2342
|
+
.option('--content-column <col>', 'Excel 模式:指定包含已爬取文本的列名,批量做 AI 分析')
|
|
2106
2343
|
.option('--url <url>', '直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接')
|
|
2107
|
-
.option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input 配合使用)')
|
|
2344
|
+
.option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用)')
|
|
2108
2345
|
.option('--env-file <path>', '指定要加载的 .env 文件路径(默认: 当前目录 .env)');
|
|
2109
2346
|
|
|
2110
2347
|
program.parse();
|
|
@@ -2167,6 +2404,12 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2167
2404
|
logInfo(`Excel 文件覆盖为: ${EXCEL_FILE}`);
|
|
2168
2405
|
}
|
|
2169
2406
|
const steps = opts.step?.length ? opts.step : ['download', 'transcode', 'transcribe', 'analyze'];
|
|
2407
|
+
// --content-column 模式:默认只跑 AI 分析
|
|
2408
|
+
if (opts.contentColumn && !opts.step?.length) {
|
|
2409
|
+
steps.length = 0;
|
|
2410
|
+
steps.push('analyze');
|
|
2411
|
+
logInfo('--content-column 模式:默认 --step analyze');
|
|
2412
|
+
}
|
|
2170
2413
|
// ── --url 模式:直接处理单个视频链接 ──
|
|
2171
2414
|
if (opts.url) {
|
|
2172
2415
|
const parsed = parseUrl(opts.url);
|
|
@@ -2229,7 +2472,7 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2229
2472
|
}
|
|
2230
2473
|
|
|
2231
2474
|
// 执行流水线
|
|
2232
|
-
await runUrlTask({
|
|
2475
|
+
const urlResult = await runUrlTask({
|
|
2233
2476
|
watchUrl: parsed.watchUrl,
|
|
2234
2477
|
platform: parsed.platform,
|
|
2235
2478
|
pkey: parsed.pkey,
|
|
@@ -2247,6 +2490,13 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2247
2490
|
whisperAvailable,
|
|
2248
2491
|
});
|
|
2249
2492
|
|
|
2493
|
+
// 生成标准报告 JSON(与 Excel 模式格式一致)
|
|
2494
|
+
if (urlResult) {
|
|
2495
|
+
const config = { steps, max_retries: opts.retry, retry_delay: opts.retryDelay, concurrency: 1, force: opts.force || false };
|
|
2496
|
+
generateReport([urlResult], config, parsed.platform);
|
|
2497
|
+
printReportSummary([urlResult]);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2250
2500
|
process.exit(0);
|
|
2251
2501
|
}
|
|
2252
2502
|
|
|
@@ -2349,7 +2599,7 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2349
2599
|
}
|
|
2350
2600
|
|
|
2351
2601
|
// 执行流水线
|
|
2352
|
-
await runInputTask({
|
|
2602
|
+
const inputResult = await runInputTask({
|
|
2353
2603
|
inputPath,
|
|
2354
2604
|
stem,
|
|
2355
2605
|
sheetName,
|
|
@@ -2364,13 +2614,35 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2364
2614
|
fileInfo,
|
|
2365
2615
|
});
|
|
2366
2616
|
|
|
2617
|
+
// 生成标准报告 JSON(与 Excel 模式格式一致)
|
|
2618
|
+
if (inputResult) {
|
|
2619
|
+
const config = { steps, max_retries: opts.retry, retry_delay: opts.retryDelay, concurrency: 1, force: opts.force || false };
|
|
2620
|
+
generateReport([inputResult], config, sheetName);
|
|
2621
|
+
printReportSummary([inputResult]);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2367
2624
|
process.exit(0);
|
|
2368
2625
|
}
|
|
2369
2626
|
|
|
2627
|
+
// ── --content 模式:纯文本 AI 分析 ──
|
|
2628
|
+
if (opts.content) {
|
|
2629
|
+
await runContentTask({
|
|
2630
|
+
content: opts.content,
|
|
2631
|
+
name: opts.name || null,
|
|
2632
|
+
steps,
|
|
2633
|
+
retry: opts.retry,
|
|
2634
|
+
retryDelay: opts.retryDelay,
|
|
2635
|
+
analyzeTimeout: opts.analyzeTimeout,
|
|
2636
|
+
force: opts.force || false,
|
|
2637
|
+
dryRun: opts.dryRun || false,
|
|
2638
|
+
});
|
|
2639
|
+
process.exit(0);
|
|
2640
|
+
}
|
|
2370
2641
|
|
|
2371
2642
|
run({
|
|
2372
2643
|
targetSheet: opts.sheet || null,
|
|
2373
2644
|
targetId: opts.id || null,
|
|
2645
|
+
contentColumn: opts.contentColumn || null,
|
|
2374
2646
|
steps,
|
|
2375
2647
|
offset: opts.offset || 0,
|
|
2376
2648
|
rowLimit: opts.limit || 0,
|