video-pipeline 1.2.6 → 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 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
- BILIBILI_COOKIE_FILE=cookies/bilibili.txt # 【自由】cookie 文件路径
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 # 【自由】并发分片数
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## [1.2.6] - 2026-06-11
4
20
 
5
21
  ### 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
 
@@ -339,6 +341,64 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
339
341
  - 检查是否可以正常读取
340
342
  - 校验失败会提示错误并退出
341
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
+
342
402
  ### 工具预检(执行前自动检测)
343
403
 
344
404
  每次执行任务前,脚本会自动检测本次涉及步骤所需的工具/服务是否可用:
@@ -379,7 +439,9 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
379
439
  | `--file <path>` | path | — | 指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量) |
380
440
  | `--input <path>` | path | — | 指定本地视频文件路径(跳过下载,直接转码→识别→分析) |
381
441
  | `--url <url>` | str | — | 直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接 |
382
- | `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input 配合使用) |
442
+ | `--content <text 或 path>` | str | — | 直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,仅做 AI 分析 |
443
+ | `--content-column <col>` | str | — | Excel 模式:指定包含已有文本的列名,批量做 AI 分析(自动设 --step analyze) |
444
+ | `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用) |
383
445
  | `--env-file <path>` | path | .env | 指定要加载的 .env 文件路径 |
384
446
 
385
447
  ---
@@ -509,6 +571,9 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
509
571
  [2143] 开始识别 (文件 45.2MB)...
510
572
  [2143] 识别中... 5s ← 识别每 5s 报时
511
573
  [2143] 识别完成 (22s, 1234 字符)
574
+ [2143] AI 分析中... 5s ← AI 每 5s 报时
575
+ [2143] AI 分析中... 10s
576
+ [2143] AI 分析完成 (567 字符)
512
577
 
513
578
  [总进度 1/91 (1.1%)] ✅1 ❌0 ⚠️0 ⏭️0 ← 每完成一个刷新
514
579
  ```
@@ -519,6 +584,7 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
519
584
  | 下载 | yt-dlp 实时百分比 + 速度 + ETA |
520
585
  | 转码 | 先 ffprobe 取时长,再实时解析 `time=` 算百分比(如 `25.3% (38s/150s)`) |
521
586
  | 识别 | 每 5s 打印已用时间,完成时显示总耗时和文本长度 |
587
+ | AI 分析 | 每 5s 打印已用时间,完成时显示结果长度或失败原因 |
522
588
 
523
589
  多线程并发时使用打印锁保证输出不交错。
524
590
 
@@ -526,7 +592,7 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
526
592
 
527
593
  ## 输出结构速查表
528
594
 
529
- 三种输入来源在不同处理环节的输出路径汇总如下。所有路径均以 `output/` 为根(可通过 `DOWNLOADS_DIR` / `TRANSCODED_DIR` / `REPORTS_DIR` 环境变量覆盖)。
595
+ 五种输入来源在不同处理环节的输出路径汇总如下。所有路径均以 `output/` 为根(可通过 `DOWNLOADS_DIR` / `TRANSCODED_DIR` / `REPORTS_DIR` 环境变量覆盖)。
530
596
 
531
597
  > `{sheet}` = Excel 工作表名(如 `YouTube视频`、`普诺赛中文站`)
532
598
  > `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`tencentVid`、`youku`)
@@ -565,19 +631,50 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
565
631
 
566
632
  > `local` 是 `--input` 模式的固定目录名(与 Excel 模式的 sheet 名无关),所有本地文件处理结果统一归入此目录。
567
633
 
568
- ---
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 关键词分析 |
569
643
 
570
- ### 三种来源对比一览
644
+ > `content` 是固定目录名。{stem} = `--name` 值 > 文件名 stem > 内联文本前 32 字符。
571
645
 
572
- | 维度 | Excel 批量 | --url 直链 | --input 本地文件 |
573
- |------|-----------|-----------|-----------------|
574
- | 输入 | Excel 行(多视频批量) | 单个视频 URL | 本地视频/音频文件 |
575
- | 下载目录 | `downloads/{sheet}/` | `downloads/{platform}/` | 无 |
576
- | 转码目录 | `transcoded/{sheet}/` | `transcoded/{platform}/` | `transcoded/local/` |
577
- | 报告目录 | `reports/{sheet}/` | `reports/{platform}/` | `reports/local/` |
578
- | 分组依据 | Excel sheet 名 | URL 解析的平台名 | 固定 `local` |
579
- | 并发支持 | 多线程 | 单任务 | 单任务 |
580
- | 支持 --retry-failed | | | |
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中的文本 |
581
678
 
582
679
  ---
583
680
 
@@ -618,6 +715,8 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
618
715
 
619
716
  ## 典型工作流
620
717
 
718
+ ### 场景一:Excel 批量处理视频
719
+
621
720
  ```bash
622
721
  # 1. 干跑预览
623
722
  node process_videos.js --dry-run
@@ -634,6 +733,44 @@ node process_videos.js --concurrency 3 --retry 3
634
733
  node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --concurrency 2 --retry 3
635
734
  ```
636
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 # 执行
772
+ ```
773
+
637
774
  ---
638
775
 
639
776
  ## 平台适配说明
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "video-pipeline",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "视频下载、转码、文本识别、AI 关键词分析一体化流程 CLI 工具",
5
5
  "keywords": [
6
6
  "video",
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(` [analyze] attempt ${attempt + 1} failed: ${lastErr.slice(0, 100)}, retrying in ${delay}s...`);
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
  }
@@ -1201,7 +1217,7 @@ function generateReport(results, config, sheetName) {
1201
1217
  // ── 单 sheet 报告 ──
1202
1218
  const dir = path.join(REPORTS_DIR, sheetName);
1203
1219
  fs.mkdirSync(dir, { recursive: true });
1204
- const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15).replace(/(\d{8})(\d{6})/, '$1_$2');
1220
+ const ts = new Date().toISOString().split('.')[0].replace(/[-:T]/g, '').replace(/(\d{8})(\d{6})/, '$1_$2');
1205
1221
  const reportFile = path.join(dir, `report_${ts}.json`);
1206
1222
 
1207
1223
  const summary = computeSummary(results);
@@ -1287,6 +1303,8 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1287
1303
  whisperAvailable, positionLabel = '', downloadTimeout = 600, transcodeTimeout = 600,
1288
1304
  transcribeTimeout = 600, analyzeTimeout = 300) {
1289
1305
 
1306
+ const preContent = row.preContent || null;
1307
+
1290
1308
  const { pkey, vid } = getVideoId(row);
1291
1309
  const stem = stemName(row, sheetName);
1292
1310
  const key = rowKey(row);
@@ -1301,7 +1319,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1301
1319
 
1302
1320
  // ── download ──
1303
1321
  let dlFile = null;
1304
- if (steps.includes('download')) {
1322
+ if (preContent) {
1323
+ result.download = new StepResult('skipped', null, 'pre-content mode');
1324
+ } else if (steps.includes('download')) {
1305
1325
  if (!pkey) {
1306
1326
  result.download = new StepResult('skipped');
1307
1327
  result.overall_status = 'no_video';
@@ -1332,7 +1352,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1332
1352
 
1333
1353
  // ── transcode ──
1334
1354
  let tcFile = null;
1335
- if (steps.includes('transcode') && dlFile) {
1355
+ if (preContent) {
1356
+ result.transcode = new StepResult('skipped', null, 'pre-content mode');
1357
+ } else if (steps.includes('transcode') && dlFile) {
1336
1358
  try {
1337
1359
  const { file, retries, error } = await stepTranscode(dlFile, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
1338
1360
  tcFile = file;
@@ -1357,7 +1379,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1357
1379
  }
1358
1380
 
1359
1381
  // ── transcribe ──
1360
- if (steps.includes('transcribe') && tcFile) {
1382
+ if (preContent) {
1383
+ result.transcribe = new StepResult('success', preContent);
1384
+ } else if (steps.includes('transcribe') && tcFile) {
1361
1385
  if (!whisperAvailable) {
1362
1386
  result.transcribe = new StepResult('failed', null, `whisper unreachable (${WHISPER_SERVICE})`);
1363
1387
  result.overall_status = 'partial';
@@ -1387,7 +1411,7 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1387
1411
  const txt = result.transcribe.file;
1388
1412
  if (txt) {
1389
1413
  try {
1390
- 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);
1391
1415
  result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
1392
1416
  if (kw) {
1393
1417
  lockedPrint(` [${result.stem}] AI analysis done (${kw.length} chars)`);
@@ -1658,7 +1682,7 @@ async function runInputTask(opts) {
1658
1682
  if (aiEnabled) {
1659
1683
  console.log(` [${usedStem}] 🤖 开始 AI 分析...`);
1660
1684
  try {
1661
- const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout);
1685
+ const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout, usedStem);
1662
1686
  if (kw && typeof kw === 'string') {
1663
1687
  analyzeText = kw;
1664
1688
  console.log(` [${usedStem}] 🤖 AI分析完成: ${kw.length} 字符`);
@@ -1730,6 +1754,123 @@ async function runInputTask(opts) {
1730
1754
  }
1731
1755
 
1732
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;
1871
+ }
1872
+
1873
+
1733
1874
  // ═══════════════════════════════════════════════════════════════════
1734
1875
  // URL 直链流水线(--url 模式)
1735
1876
  // ═══════════════════════════════════════════════════════════════════
@@ -1846,7 +1987,7 @@ async function runUrlTask(opts) {
1846
1987
  }
1847
1988
 
1848
1989
  async function run({
1849
- targetSheet, targetId, steps, maxRetries, retryDelay,
1990
+ targetSheet, targetId, contentColumn, steps, maxRetries, retryDelay,
1850
1991
  concurrency, force, dryRun, retryFailed,
1851
1992
  downloadTimeout, transcodeTimeout, transcribeTimeout, analyzeTimeout,
1852
1993
  offset = 0, rowLimit = 0,
@@ -1879,6 +2020,15 @@ async function run({
1879
2020
  }
1880
2021
  precomputeStems(rows, sheetName);
1881
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
+ }
1882
2032
  tasks.push({ row, sheetName });
1883
2033
  }
1884
2034
  }
@@ -1942,7 +2092,7 @@ async function run({
1942
2092
  await Promise.all(taskFns);
1943
2093
 
1944
2094
  // ── 批量写回 Excel ──
1945
- if (steps.includes('transcribe')) {
2095
+ if (steps.includes('transcribe') || contentColumn) {
1946
2096
  const kwMap = new Map();
1947
2097
  for (const r of results) {
1948
2098
  if (r.analyze.status === 'success' && r.analyze.file) {
@@ -2188,8 +2338,10 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
2188
2338
  .option('--init', '复制 .env.example 到当前目录并重命名为 .env')
2189
2339
  .option('--file <path>', '指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量)')
2190
2340
  .option('--input <path>', '指定本地视频文件路径(跳过下载,直接转码→识别→分析)')
2341
+ .option('--content <text|path>', '直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,直接做 AI 分析')
2342
+ .option('--content-column <col>', 'Excel 模式:指定包含已爬取文本的列名,批量做 AI 分析')
2191
2343
  .option('--url <url>', '直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接')
2192
- .option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input 配合使用)')
2344
+ .option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用)')
2193
2345
  .option('--env-file <path>', '指定要加载的 .env 文件路径(默认: 当前目录 .env)');
2194
2346
 
2195
2347
  program.parse();
@@ -2252,6 +2404,12 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
2252
2404
  logInfo(`Excel 文件覆盖为: ${EXCEL_FILE}`);
2253
2405
  }
2254
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
+ }
2255
2413
  // ── --url 模式:直接处理单个视频链接 ──
2256
2414
  if (opts.url) {
2257
2415
  const parsed = parseUrl(opts.url);
@@ -2466,10 +2624,25 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
2466
2624
  process.exit(0);
2467
2625
  }
2468
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
+ }
2469
2641
 
2470
2642
  run({
2471
2643
  targetSheet: opts.sheet || null,
2472
2644
  targetId: opts.id || null,
2645
+ contentColumn: opts.contentColumn || null,
2473
2646
  steps,
2474
2647
  offset: opts.offset || 0,
2475
2648
  rowLimit: opts.limit || 0,