video-pipeline 1.2.7 → 1.2.8

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