video-pipeline 1.2.6 → 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 +20 -18
- package/CHANGELOG.md +35 -0
- package/README.md +231 -78
- package/package.json +1 -1
- package/process_videos.js +245 -48
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=
|
|
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.
|
|
81
|
-
COL_BILIBILIBVID=extra.
|
|
82
|
-
COL_YOUTUBEID=extra.
|
|
83
|
-
COL_YOUKUID=extra.
|
|
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
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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=
|
|
101
|
+
PLATFORM_PRIORITY=bilibili,youtube,tencent,youku
|
|
101
102
|
|
|
102
103
|
# ── 平台 URL 模板 ───────────────────────────────────────────────────────────
|
|
103
104
|
# 【关联】模板中的 {占位符} 必须与 PLATFORM_PRIORITY 中的 key 名一致
|
|
104
|
-
# 如 {
|
|
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/{
|
|
109
|
-
BILIBILI_URL_TPL=https://www.bilibili.com/video/{
|
|
110
|
-
YOUTUBE_URL_TPL=https://youtu.be/{
|
|
111
|
-
YOUKU_URL_TPL=https://v.youku.com/v_show/id_{
|
|
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)
|
|
@@ -127,7 +128,10 @@ YOUKU_URL_TPL=https://v.youku.com/v_show/id_{youkuId}.html
|
|
|
127
128
|
# {前缀}_URL_TPL 【自由】URL 模板 (见"平台 URL 模板"段)
|
|
128
129
|
|
|
129
130
|
# ── Bilibili 配置 ───────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
+
# 方案 A(推荐): 直接从 Firefox 浏览器读 cookie,yt-dlp 能稳定解密
|
|
132
|
+
BILIBILI_COOKIES_FROM_BROWSER=firefox # 【自由】firefox / chrome / edge
|
|
133
|
+
# 方案 B(备用): 从文件读取 cookie
|
|
134
|
+
# BILIBILI_COOKIE_FILE=cookies/bilibili.txt # 【自由】cookie 文件路径(方案 A 启用时此行无效)
|
|
131
135
|
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
136
|
BILIBILI_REFERER=https://www.bilibili.com/ # 【自由】
|
|
133
137
|
BILIBILI_CONCURRENT_FRAGMENTS=4 # 【自由】并发分片数
|
|
@@ -164,8 +168,6 @@ AI_ENABLED=true
|
|
|
164
168
|
AI_API_KEY=your-api-key-here
|
|
165
169
|
AI_BASE_URL=https://your-api-host/v1
|
|
166
170
|
AI_MODEL=your-model-name
|
|
167
|
-
# 【自由】请求超时(秒),默认 300
|
|
168
|
-
AI_TIMEOUT=300
|
|
169
171
|
# 【自由】AI 推理温度 (0.0~2.0, 越低越确定/保守, 越高越随机/创意)
|
|
170
172
|
AI_TEMPERATURE=0.3
|
|
171
173
|
# 【关联】提示词模板,{content} 占位符会被识别文本替换
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
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
|
+
|
|
22
|
+
## [1.2.7] - 2026-06-11
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
- 补充所有模式 README 示例 + 修复下载和AI分析进度显示 (`9aa2160`)
|
|
27
|
+
- --content / --content-column + B站 Firefox cookie (`e25a4a8`)
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
- 修复JS报告时间戳小数点导致文件名出现两个点的问题 (`c0abe90`)
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
- update (`df4d7b6`)
|
|
36
|
+
|
|
37
|
+
|
|
3
38
|
## [1.2.6] - 2026-06-11
|
|
4
39
|
|
|
5
40
|
### Features
|
package/README.md
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
基于 `process_videos.js` (Node.js) 或 `process_videos.py` (Python),一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
**五种使用方式,覆盖不同场景:**
|
|
6
|
+
|
|
7
|
+
| 模式 | 输入 | 跳过步骤 | 适用场景 |
|
|
8
|
+
|------|------|---------|----------|
|
|
9
|
+
| **Excel 批量** | Excel 行(多视频) | — | 批量处理全流程 |
|
|
10
|
+
| **--url 直链** | 单个视频 URL | — | 临时下载单个视频 |
|
|
11
|
+
| **--input 本地** | 本地视频/音频文件 | 下载 | 处理已有文件 |
|
|
12
|
+
| **--content 纯文本** | 文件路径或内联文本 | 下载+转码+识别 | 已有文本直接分析 |
|
|
13
|
+
| **--content-column** | Excel 列的已有文本 | 下载+转码+识别 | 批量分析 Excel 中的文本 |
|
|
9
14
|
|
|
10
15
|
---
|
|
11
16
|
|
|
@@ -39,7 +44,7 @@ pip install pandas openpyxl requests python-dotenv questionary
|
|
|
39
44
|
### 必装工具
|
|
40
45
|
|
|
41
46
|
| 工具 | 版本要求 | 安装方式 | 用途 |
|
|
42
|
-
|
|
47
|
+
|------|-----------|----------|------|
|
|
43
48
|
| Python | 3.9+ | [python.org](https://www.python.org/) | 脚本运行 |
|
|
44
49
|
| yt-dlp | 最新 | `pip install yt-dlp` 或 [GitHub Release](https://github.com/yt-dlp/yt-dlp/releases) | 视频下载 |
|
|
45
50
|
| ffmpeg + ffprobe | 4.0+ | [ffmpeg.org](https://ffmpeg.org/download.html) 或 `winget install ffmpeg` | 音频转码 + 时长检测 |
|
|
@@ -51,7 +56,7 @@ pip install pandas openpyxl requests python-dotenv questionary
|
|
|
51
56
|
YouTube 要求 JS 运行时解开 n-sig 挑战,否则无法提取视频格式。
|
|
52
57
|
|
|
53
58
|
| 方式 | 安装命令 |
|
|
54
|
-
|
|
59
|
+
|------|----------|
|
|
55
60
|
| Node.js(推荐) | [nodejs.org](https://nodejs.org/) 下载 LTS 版,安装后 `node --version` 验证 |
|
|
56
61
|
| Deno | `winget install DenoLand.Deno` 或 [deno.com](https://deno.com/) |
|
|
57
62
|
|
|
@@ -60,9 +65,11 @@ YouTube 要求 JS 运行时解开 n-sig 挑战,否则无法提取视频格式
|
|
|
60
65
|
### Python 依赖
|
|
61
66
|
|
|
62
67
|
```bash
|
|
63
|
-
pip install pandas openpyxl requests python-dotenv
|
|
68
|
+
pip install pandas openpyxl requests python-dotenv questionary
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
> `questionary` 为可选依赖(交互式确认时使用),建议一并安装。
|
|
72
|
+
|
|
66
73
|
### 环境变量配置(.env)
|
|
67
74
|
|
|
68
75
|
**从 v2 开始,所有路径、字段映射、平台参数均通过 `.env` 文件配置。** 这意味着同一套脚本可以直接用于其他 Excel 文件,只需修改 `.env` 中的值即可。
|
|
@@ -81,10 +88,10 @@ cp .env.example .env
|
|
|
81
88
|
|------|------|------|
|
|
82
89
|
| 输入 | `EXCEL_FILE` | Excel 文件路径 |
|
|
83
90
|
| 列映射 | `COL_ID` / `COL_TITLE` / `COL_CONTENT` / `COL_KEYWORDS` | 唯一标识列 / 标题列 / 识别文本输出列 / AI 关键词输出列 |
|
|
84
|
-
| 列映射 | `
|
|
91
|
+
| 列映射 | `COL_TENCENT` / `COL_BILIBILI` / `COL_YOUTUBE` / `COL_YOUKU` | 各平台视频 ID 所在列 |
|
|
85
92
|
| Sheet | `VIDEO_SHEETS` | 逗号分隔需要处理的 sheet(留空则全部) |
|
|
86
93
|
| 平台 | `PLATFORM_PRIORITY` | 平台重试优先级 |
|
|
87
|
-
| 平台 | `{平台}_URL_TPL` | URL 模板(如 `YOUTUBE_URL_TPL=https://youtu.be/{
|
|
94
|
+
| 平台 | `{平台}_URL_TPL` | URL 模板(如 `YOUTUBE_URL_TPL=https://youtu.be/{youtube}`) |
|
|
88
95
|
| 平台 | `{平台}_COOKIES_FROM_BROWSER` | 从浏览器直读 cookie(推荐 Firefox,替代手动导出文件) |
|
|
89
96
|
| 平台 | `{平台}_COOKIE_FILE` | cookie 文件路径(备用方案,需定期更新) |
|
|
90
97
|
| 平台 | `{平台}_PROXY` | 代理地址(如 `http://127.0.0.1:7897`,Clash Verge) |
|
|
@@ -99,7 +106,7 @@ cp .env.example .env
|
|
|
99
106
|
| AI 分析 | `AI_ENABLED` | `true` 启用 / `false` 跳过(默认 true) |
|
|
100
107
|
| AI 分析 | `AI_API_KEY` / `AI_BASE_URL` / `AI_MODEL` | OpenAI 兼容 API 配置 |
|
|
101
108
|
| AI 分析 | `AI_PROMPT_TPL` | 提示词模板,必须包含 `{content}` 占位符 |
|
|
102
|
-
| AI 分析 | `
|
|
109
|
+
| AI 分析 | `AI_TEMPERATURE` | AI 推理温度 (0.0~2.0) |
|
|
103
110
|
|
|
104
111
|
### .env 配置项变更权限
|
|
105
112
|
|
|
@@ -108,11 +115,11 @@ cp .env.example .env
|
|
|
108
115
|
| 标记 | 含义 | 涵盖的配置项 | 示例 |
|
|
109
116
|
|------|------|-------------|------|
|
|
110
117
|
| **【自由】** | 值可随意改为任意合法内容 | 路径、开关、数字、字符串、URL、UA、格式参数等 | `EXCEL_FILE`, `YOUTUBE_PROXY`, `WHISPER_MODEL` |
|
|
111
|
-
| **【调序】** | 只能从固定集合中增减/排序,不能用集合外的值 | `PLATFORM_PRIORITY` | 只能包含 `
|
|
112
|
-
| **【关联】** | 值需与脚本内约定的 Key 名一致 | URL 模板中的 `{占位符}` | `{
|
|
118
|
+
| **【调序】** | 只能从固定集合中增减/排序,不能用集合外的值 | `PLATFORM_PRIORITY` | 只能包含 `bilibili` / `youtube` / `tencent` / `youku` |
|
|
119
|
+
| **【关联】** | 值需与脚本内约定的 Key 名一致 | URL 模板中的 `{占位符}` | `{youtube}` 必须跟 `COL_YOUTUBE` 的后缀一致 |
|
|
113
120
|
| **【固定】** | 除非 Excel 列名或脚本内部逻辑改变,否则不应修改 | 列名映射 | `COL_ID=extra.id`、`COL_TITLE=title` 等 |
|
|
114
121
|
|
|
115
|
-
> **最容易混淆的是【调序】**:`PLATFORM_PRIORITY` 可以调整顺序、增减条目,但只能用脚本已定义的 4 个 key,新增 `
|
|
122
|
+
> **最容易混淆的是【调序】**:`PLATFORM_PRIORITY` 可以调整顺序、增减条目,但只能用脚本已定义的 4 个 key,新增 `tiktok`、`douyin` 等无效 key 会导致脚本无法识别。
|
|
116
123
|
|
|
117
124
|
### Whisper 语音识别
|
|
118
125
|
|
|
@@ -137,9 +144,7 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
|
|
|
137
144
|
```
|
|
138
145
|
脚本会直接调用 `whisper` CLI,无需额外服务进程。
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## 目录结构
|
|
147
|
+
### 目录结构
|
|
143
148
|
|
|
144
149
|
```
|
|
145
150
|
├── process_videos.js # Node.js 主流程脚本(推荐)
|
|
@@ -152,28 +157,32 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
|
|
|
152
157
|
├── cookies/ # 站点 cookie 文件
|
|
153
158
|
│ ├── bilibili.txt # B站 cookie(Netscape 格式)
|
|
154
159
|
│ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
|
|
155
|
-
├──
|
|
156
|
-
│ ├──
|
|
157
|
-
│
|
|
158
|
-
|
|
159
|
-
│ ├──
|
|
160
|
-
│
|
|
161
|
-
|
|
162
|
-
│
|
|
163
|
-
│
|
|
164
|
-
│
|
|
165
|
-
│
|
|
166
|
-
│
|
|
167
|
-
│
|
|
168
|
-
│
|
|
169
|
-
│
|
|
170
|
-
│
|
|
171
|
-
│
|
|
172
|
-
│
|
|
173
|
-
│
|
|
174
|
-
│ └──
|
|
175
|
-
│ ├──
|
|
176
|
-
│
|
|
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/
|
|
177
186
|
├── scripts/ # 辅助脚本
|
|
178
187
|
│ ├── release.js # 版本发布脚本
|
|
179
188
|
│ └── regenerate-changelog.js # CHANGELOG 重建脚本
|
|
@@ -213,9 +222,20 @@ yt-dlp 可直接从 Firefox 浏览器读取 cookie,无需手动导出:
|
|
|
213
222
|
|
|
214
223
|
### B站(bilibili)
|
|
215
224
|
|
|
216
|
-
|
|
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)
|
|
217
236
|
2. 访问 [bilibili.com](https://www.bilibili.com) 并登录
|
|
218
237
|
3. 点击扩展图标 → Export → 保存为 `cookies/bilibili.txt`
|
|
238
|
+
4. 在 `.env` 中注释掉 `BILIBILI_COOKIES_FROM_BROWSER`,启用 `BILIBILI_COOKIE_FILE=cookies/bilibili.txt`
|
|
219
239
|
|
|
220
240
|
---
|
|
221
241
|
|
|
@@ -339,6 +359,64 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
339
359
|
- 检查是否可以正常读取
|
|
340
360
|
- 校验失败会提示错误并退出
|
|
341
361
|
|
|
362
|
+
### 处理纯文本内容(跳过视频步骤)
|
|
363
|
+
|
|
364
|
+
如果你已经有了一段文本内容(比如爬虫爬取的、之前识别好的、或者从其他途径获取的),可以直接做 AI 分析,跳过下载、转码、识别三个步骤:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# ═══════════ --content 模式:纯文本 AI 分析 ═══════════
|
|
368
|
+
|
|
369
|
+
# 从文件读取内容,自动用文件名作为输出名
|
|
370
|
+
node process_videos.js --content "data/article.txt"
|
|
371
|
+
python process_videos.py --content "data/article.txt"
|
|
372
|
+
|
|
373
|
+
# 直接提供内联文本,自动取前 32 字符作为输出名
|
|
374
|
+
node process_videos.js --content "这是一段需要分析的内容..."
|
|
375
|
+
python process_videos.py --content "这是一段需要分析的内容..."
|
|
376
|
+
|
|
377
|
+
# 指定输出文件名(--name)
|
|
378
|
+
node process_videos.js --content "data/article.txt" --name "文章分析"
|
|
379
|
+
python process_videos.py --content "data/article.txt" --name "文章分析"
|
|
380
|
+
|
|
381
|
+
# 配合 --dry-run 预览
|
|
382
|
+
node process_videos.js --content "data/article.txt" --dry-run
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**输出文件命名规则:**
|
|
386
|
+
- 指定了 `--name` → 使用 `--name` 的值
|
|
387
|
+
- 内容是文件路径 → 使用文件名(不含扩展名)
|
|
388
|
+
- 内容是内联文本 → 使用前 32 个字符
|
|
389
|
+
|
|
390
|
+
**输出位置:** `output/reports/content/tasks/{name}.txt` + `output/reports/content/report_xxx.json`
|
|
391
|
+
|
|
392
|
+
### Excel 列文本批量 AI 分析
|
|
393
|
+
|
|
394
|
+
当 Excel 某列已经存好了文本内容(比如之前爬虫爬取的),可以批量对这些文本做 AI 关键词分析:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
# ═══════════ --content-column 模式:批量 AI 分析 ═══════════
|
|
398
|
+
|
|
399
|
+
# 对 Excel 中 "content" 列的文本逐行做 AI 关键词分析,结果写回 "keywords" 列
|
|
400
|
+
node process_videos.js --content-column "content"
|
|
401
|
+
|
|
402
|
+
# 指定其他列名
|
|
403
|
+
node process_videos.js --content-column "爬取文本"
|
|
404
|
+
|
|
405
|
+
# 指定特定 sheet
|
|
406
|
+
node process_videos.js --sheet "普诺赛中文站" --content-column "content"
|
|
407
|
+
|
|
408
|
+
# 配合 --dry-run 预览
|
|
409
|
+
node process_videos.js --content-column "content" --dry-run
|
|
410
|
+
|
|
411
|
+
# 配合 --offset / --limit 调试
|
|
412
|
+
node process_videos.js --content-column "content" --offset 0 --limit 3
|
|
413
|
+
node process_videos.js --content-column "content" --concurrency 2 --retry 2
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
> **注意**:`--content-column` 模式自动设置 `--step analyze`(仅 AI 分析),不会触发下载/转码/识别。
|
|
417
|
+
> 文本为空的行会自动跳过。
|
|
418
|
+
> 分析结果写入 Excel 的 `keywords` 列(由 `COL_KEYWORDS` 环境变量指定)。
|
|
419
|
+
|
|
342
420
|
### 工具预检(执行前自动检测)
|
|
343
421
|
|
|
344
422
|
每次执行任务前,脚本会自动检测本次涉及步骤所需的工具/服务是否可用:
|
|
@@ -359,7 +437,7 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
359
437
|
## 参数说明
|
|
360
438
|
|
|
361
439
|
| 参数 | 类型 | 默认值 | 说明 |
|
|
362
|
-
|
|
440
|
+
|------|------|---------|------|
|
|
363
441
|
| `--sheet <name>` | str | 全部 | 指定 sheet 名称 |
|
|
364
442
|
| `--id <id>` | str | — | 指定 extra.id 或 title(单条测试) |
|
|
365
443
|
| `--offset <n>` | int | 0 | 跳过前 N 条任务(从 0 开始),适合调试大量数据 |
|
|
@@ -379,7 +457,9 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
379
457
|
| `--file <path>` | path | — | 指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量) |
|
|
380
458
|
| `--input <path>` | path | — | 指定本地视频文件路径(跳过下载,直接转码→识别→分析) |
|
|
381
459
|
| `--url <url>` | str | — | 直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接 |
|
|
382
|
-
| `--
|
|
460
|
+
| `--content <text 或 path>` | str | — | 直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,仅做 AI 分析 |
|
|
461
|
+
| `--content-column <col>` | str | — | Excel 模式:指定包含已有文本的列名,批量做 AI 分析(自动设 --step analyze) |
|
|
462
|
+
| `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用) |
|
|
383
463
|
| `--env-file <path>` | path | .env | 指定要加载的 .env 文件路径 |
|
|
384
464
|
|
|
385
465
|
---
|
|
@@ -387,7 +467,7 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
387
467
|
## 重试规则
|
|
388
468
|
|
|
389
469
|
| 可重试 | 不重试 |
|
|
390
|
-
|
|
470
|
+
|----------|----------|
|
|
391
471
|
| 网络超时、连接拒绝 | HTTP 404 / 403 / 401 |
|
|
392
472
|
| yt-dlp 下载中断 | 视频已删除 / 私有 |
|
|
393
473
|
| whisper 服务超时 | 无效 URL、文件不存在 |
|
|
@@ -400,7 +480,7 @@ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
|
400
480
|
脚本默认不会重复处理已有文件,但会在以下情况自动触发重做:
|
|
401
481
|
|
|
402
482
|
| 步骤 | 跳过条件 | 自动重做条件 |
|
|
403
|
-
|
|
483
|
+
|------|-----------|----------------|
|
|
404
484
|
| 下载 | 同名文件已存在(非 `--force`) | `--force` 或文件不存在 |
|
|
405
485
|
| 转码 | WAV 已存在 **且** MP4 时间戳 ≤ WAV 时间戳 | `--force` 或 **MP4 比 WAV 新**(重新下载过) |
|
|
406
486
|
| 识别 | —(每次必跑,覆盖写入 Excel) | — |
|
|
@@ -443,8 +523,8 @@ AI_MODEL=agnes-2.0-flash
|
|
|
443
523
|
# 提示词模板({content} 会被识别文本替换)
|
|
444
524
|
AI_PROMPT_TPL=帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}
|
|
445
525
|
|
|
446
|
-
#
|
|
447
|
-
AI_TIMEOUT
|
|
526
|
+
# 请求超时(秒,通过 --analyze-timeout 参数设置)
|
|
527
|
+
# 不再使用 AI_TIMEOUT 环境变量(已统一为 --analyze-timeout 参数)
|
|
448
528
|
```
|
|
449
529
|
|
|
450
530
|
### 工作原理
|
|
@@ -482,21 +562,19 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
482
562
|
|--------|------|------|
|
|
483
563
|
| 1 | `{id}` | `2143` |
|
|
484
564
|
| 2 | `{id}_{title}` | `2143_产品介绍` |
|
|
485
|
-
| 3 | `{id}_{title}_{
|
|
565
|
+
| 3 | `{id}_{title}_{platform}` | `2143_产品介绍_bilibili` |
|
|
486
566
|
|
|
487
567
|
> 去重仅在同 sheet 内生效,不同 sheet 之间允许同名文件(存放在不同子目录)。
|
|
488
568
|
|
|
489
569
|
---
|
|
490
570
|
|
|
491
|
-
---
|
|
492
|
-
|
|
493
571
|
## 进度显示
|
|
494
572
|
|
|
495
573
|
执行时会同时展示**总体进度**和**单视频进度**:
|
|
496
574
|
|
|
497
575
|
```text
|
|
498
|
-
[1/91] [2143] 开始处理 (sheet=YouTube视频, platform=
|
|
499
|
-
[2143] 开始下载 (平台=
|
|
576
|
+
[1/91] [2143] 开始处理 (sheet=YouTube视频, platform=youtube, title=xxx)
|
|
577
|
+
[2143] 开始下载 (平台=youtube)
|
|
500
578
|
[2143] https://youtu.be/zzJmKPX8a3c
|
|
501
579
|
[2143] 解析页面...
|
|
502
580
|
[2143] 15.2% 2.50MiB/s ETA 00:17 ← 下载实时进度
|
|
@@ -509,16 +587,20 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
509
587
|
[2143] 开始识别 (文件 45.2MB)...
|
|
510
588
|
[2143] 识别中... 5s ← 识别每 5s 报时
|
|
511
589
|
[2143] 识别完成 (22s, 1234 字符)
|
|
590
|
+
[2143] AI 分析中... 5s ← AI 每 5s 报时
|
|
591
|
+
[2143] AI 分析中... 10s
|
|
592
|
+
[2143] AI 分析完成 (567 字符)
|
|
512
593
|
|
|
513
594
|
[总进度 1/91 (1.1%)] ✅1 ❌0 ⚠️0 ⏭️0 ← 每完成一个刷新
|
|
514
595
|
```
|
|
515
596
|
|
|
516
597
|
| 层级 | 显示内容 |
|
|
517
|
-
|
|
598
|
+
|------|----------|
|
|
518
599
|
| 总体进度 | 完成/总任务数、百分比、✅成功 ❌失败 ⚠️部分 ⏭️无视频 四维计数 |
|
|
519
600
|
| 下载 | yt-dlp 实时百分比 + 速度 + ETA |
|
|
520
601
|
| 转码 | 先 ffprobe 取时长,再实时解析 `time=` 算百分比(如 `25.3% (38s/150s)`) |
|
|
521
602
|
| 识别 | 每 5s 打印已用时间,完成时显示总耗时和文本长度 |
|
|
603
|
+
| AI 分析 | 每 5s 打印已用时间,完成时显示结果长度或失败原因 |
|
|
522
604
|
|
|
523
605
|
多线程并发时使用打印锁保证输出不交错。
|
|
524
606
|
|
|
@@ -526,10 +608,10 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
526
608
|
|
|
527
609
|
## 输出结构速查表
|
|
528
610
|
|
|
529
|
-
|
|
611
|
+
五种输入来源在不同处理环节的输出路径汇总如下。所有路径均以 `output/` 为根(可通过 `DOWNLOADS_DIR` / `TRANSCODED_DIR` / `REPORTS_DIR` 环境变量覆盖)。
|
|
530
612
|
|
|
531
613
|
> `{sheet}` = Excel 工作表名(如 `YouTube视频`、`普诺赛中文站`)
|
|
532
|
-
> `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`
|
|
614
|
+
> `{platform}` = 视频平台标识(如 `youtube`、`bilibili`、`tencent`、`youku`)
|
|
533
615
|
> `{stem}` = 去重后的安全文件名(不含扩展名)
|
|
534
616
|
|
|
535
617
|
### ① Excel 批量模式(默认)
|
|
@@ -565,19 +647,50 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
565
647
|
|
|
566
648
|
> `local` 是 `--input` 模式的固定目录名(与 Excel 模式的 sheet 名无关),所有本地文件处理结果统一归入此目录。
|
|
567
649
|
|
|
568
|
-
|
|
650
|
+
### ④ --content 纯文本模式
|
|
651
|
+
|
|
652
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
653
|
+
|------|---------|---------|------|
|
|
654
|
+
| 下载 | —(跳过) | — | 无需下载 |
|
|
655
|
+
| 转码 | —(跳过) | — | 无需转码 |
|
|
656
|
+
| 识别 | —(跳过) | — | 无需语音识别 |
|
|
657
|
+
| JSON 报告 | `output/reports/content/report_YYYYMMDD_HHMMSS.json` | JSON | 格式与 Excel 模式一致 |
|
|
658
|
+
| 文本报告 | `output/reports/content/tasks/{stem}.txt` | 文本 | 含源内容 + AI 关键词分析 |
|
|
659
|
+
|
|
660
|
+
> `content` 是固定目录名。{stem} = `--name` 值 > 文件名 stem > 内联文本前 32 字符。
|
|
661
|
+
|
|
662
|
+
### ⑤ --content-column Excel列文本批量模式
|
|
663
|
+
|
|
664
|
+
| 环节 | 输出路径 | 产物格式 | 说明 |
|
|
665
|
+
|------|---------|---------|------|
|
|
666
|
+
| 下载 | —(跳过) | — | 无需下载 |
|
|
667
|
+
| 转码 | —(跳过) | — | 无需转码 |
|
|
668
|
+
| 识别 | —(跳过) | — | 无需语音识别 |
|
|
669
|
+
| JSON 报告 | `output/reports/{sheet}/report_YYYYMMDD_HHMMSS.json` | JSON | 按 Excel sheet 分目录,格式与 Excel 模式一致 |
|
|
670
|
+
| 文本报告 | `output/reports/{sheet}/tasks/{stem}.txt` | 文本 | 含列文本 + AI 关键词分析 |
|
|
671
|
+
| Excel 写回 | `{EXCEL_FILE}` 的 `keywords` 列 | Excel | AI 关键词写入 Excel |
|
|
569
672
|
|
|
570
|
-
|
|
673
|
+
> 此模式自动设置 `--step analyze`,下载/转码/识别全跳过。AI 结果同时写入 Excel 和报告文件。
|
|
571
674
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
|
577
|
-
|
|
578
|
-
|
|
|
579
|
-
|
|
|
580
|
-
|
|
|
675
|
+
---
|
|
676
|
+
|
|
677
|
+
### 五种来源对比一览
|
|
678
|
+
|
|
679
|
+
| 维度 | Excel 批量 | --url 直链 | --input 本地文件 | --content 纯文本 | --content-column 列文本 |
|
|
680
|
+
|------|-----------|-----------|-----------------|-----------------|------------------------|
|
|
681
|
+
| 输入 | Excel 行(多视频批量) | 单个视频 URL | 本地视频/音频文件 | 文件路径或内联文本 | Excel 列的已有文本 |
|
|
682
|
+
| 下载 | ✅ yt-dlp | ✅ yt-dlp | ❌ 跳过 | ❌ 跳过 | ❌ 跳过 |
|
|
683
|
+
| 转码 | ✅ ffmpeg | ✅ ffmpeg | ✅ ffmpeg | ❌ 跳过 | ❌ 跳过 |
|
|
684
|
+
| 识别 | ✅ whisper | ✅ whisper | ✅ whisper | ❌ 跳过 | ❌ 跳过 |
|
|
685
|
+
| AI 分析 | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
686
|
+
| 下载目录 | `downloads/{sheet}/` | `downloads/{platform}/` | 无 | 无 | 无 |
|
|
687
|
+
| 转码目录 | `transcoded/{sheet}/` | `transcoded/{platform}/` | `transcoded/local/` | 无 | 无 |
|
|
688
|
+
| 报告目录 | `reports/{sheet}/` | `reports/{platform}/` | `reports/local/` | `reports/content/` | `reports/{sheet}/` |
|
|
689
|
+
| 分组依据 | Excel sheet 名 | URL 解析的平台名 | 固定 `local` | 固定 `content` | Excel sheet 名 |
|
|
690
|
+
| 并发支持 | ✅ 多线程 | ❌ 单任务 | ❌ 单任务 | ❌ 单任务 | ✅ 多线程 |
|
|
691
|
+
| 写入 Excel | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
692
|
+
| 支持 --retry-failed | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
693
|
+
| 适用场景 | 批量处理全流程 | 临时下载单个视频 | 处理已有视频文件 | 已有文本直接分析 | 批量分析Excel中的文本 |
|
|
581
694
|
|
|
582
695
|
---
|
|
583
696
|
|
|
@@ -618,6 +731,8 @@ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
|
|
|
618
731
|
|
|
619
732
|
## 典型工作流
|
|
620
733
|
|
|
734
|
+
### 场景一:Excel 批量处理视频
|
|
735
|
+
|
|
621
736
|
```bash
|
|
622
737
|
# 1. 干跑预览
|
|
623
738
|
node process_videos.js --dry-run
|
|
@@ -634,6 +749,44 @@ node process_videos.js --concurrency 3 --retry 3
|
|
|
634
749
|
node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --concurrency 2 --retry 3
|
|
635
750
|
```
|
|
636
751
|
|
|
752
|
+
### 场景二:临时下载单个视频
|
|
753
|
+
|
|
754
|
+
```bash
|
|
755
|
+
# 从 URL 下载 → 转码 → 识别 → AI 分析,一条龙
|
|
756
|
+
node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c"
|
|
757
|
+
|
|
758
|
+
# 指定输出文件名
|
|
759
|
+
node process_videos.js --url "https://www.bilibili.com/video/BV1xx411c7mD" --name "产品介绍视频"
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
### 场景三:处理本地视频文件
|
|
763
|
+
|
|
764
|
+
```bash
|
|
765
|
+
# 已有视频文件,直接转码分析
|
|
766
|
+
node process_videos.js --input "downloads/产品介绍.mp4"
|
|
767
|
+
|
|
768
|
+
# 只做 AI 分析(已有转码+识别结果)
|
|
769
|
+
node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### 场景四:纯文本 AI 分析
|
|
773
|
+
|
|
774
|
+
```bash
|
|
775
|
+
# 已有文本内容,跳过所有视频步骤,直接做关键词提取
|
|
776
|
+
node process_videos.js --content "data/article.txt"
|
|
777
|
+
|
|
778
|
+
# 内联文本直接分析
|
|
779
|
+
node process_videos.js --content "今天我们要讨论的是普诺赛产品..." --name "产品讨论"
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### 场景五:批量分析 Excel 中的已有文本
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
# Excel 某列已有文本(如爬虫爬取的),批量做 AI 关键词分析
|
|
786
|
+
node process_videos.js --content-column "content" --dry-run # 先预览
|
|
787
|
+
node process_videos.js --content-column "content" --concurrency 2 # 执行
|
|
788
|
+
```
|
|
789
|
+
|
|
637
790
|
---
|
|
638
791
|
|
|
639
792
|
## 平台适配说明
|
|
@@ -641,11 +794,11 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
641
794
|
脚本支持四个视频平台的下载,各有不同的反爬配置:
|
|
642
795
|
|
|
643
796
|
| 平台 | 字段 | 反爬措施 |
|
|
644
|
-
|
|
645
|
-
| B站 (bilibili) | `extra.
|
|
646
|
-
| YouTube | `extra.
|
|
647
|
-
| 腾讯视频 | `extra.
|
|
648
|
-
| 优酷 | `extra.
|
|
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` | 无需特殊配置(部分视频需会员) |
|
|
649
802
|
|
|
650
803
|
> YouTube 反爬最强:需要 **代理** + **登录态 cookie** + **JS runtime 解 n-sig** 三者配合。
|
|
651
804
|
> 脚本会自动给 yt-dlp 及其 node/ejs 子进程注入 `HTTPS_PROXY` 环境变量,确保所有流量走代理。
|
|
@@ -658,7 +811,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
658
811
|
#### YouTube
|
|
659
812
|
|
|
660
813
|
| 格式类型 | URL 示例 | 视频 ID 提取正则 |
|
|
661
|
-
|
|
814
|
+
|----------|-----------|-------------------|
|
|
662
815
|
| 标准观看页 | `https://www.youtube.com/watch?v=VIDEO_ID` | `youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})` |
|
|
663
816
|
| 短链接 | `https://youtu.be/VIDEO_ID` | `youtu\.be/([a-zA-Z0-9_-]{11})` |
|
|
664
817
|
| Shorts | `https://www.youtube.com/shorts/VIDEO_ID` | `youtube\.com/shorts/([a-zA-Z0-9_-]{11})` |
|
|
@@ -678,7 +831,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
678
831
|
#### B站(bilibili)
|
|
679
832
|
|
|
680
833
|
| 格式类型 | URL 示例 | 视频 ID 提取正则 |
|
|
681
|
-
|
|
834
|
+
|----------|-----------|-------------------|
|
|
682
835
|
| 标准页(BV 号) | `https://www.bilibili.com/video/BV1xx411c7mD` | `bilibili\.com\/video\/(BV[a-zA-Z0-9]{10})` |
|
|
683
836
|
| 标准页(av 号) | `https://www.bilibili.com/video/av170001` | `bilibili\.com\/video\/av(\d+)` |
|
|
684
837
|
| 短链接 | `https://b23.tv/BV1xx411c7mD` | `b23\.tv\/(BV[a-zA-Z0-9]{10})` |
|
|
@@ -699,7 +852,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
699
852
|
#### 腾讯视频
|
|
700
853
|
|
|
701
854
|
| 格式类型 | URL 示例 | 视频 ID 提取正则 |
|
|
702
|
-
|
|
855
|
+
|----------|-----------|-------------------|
|
|
703
856
|
| 标准页(x/page) | `https://v.qq.com/x/page/VIDEO_ID.html` | `v\.qq\.com\/x\/page\/([a-zA-Z0-9]+)\.html` |
|
|
704
857
|
| 标准页(x/cover) | `https://v.qq.com/x/cover/COVER/VIDEO_ID.html` | `v\.qq\.com\/x\/cover\/[^\/]+\/([a-zA-Z0-9]+)\.html` |
|
|
705
858
|
| 内嵌页 | `https://v.qq.com/txp/iframe/player.html?vid=VIDEO_ID` | `[?&]vid=([a-zA-Z0-9]+)` |
|
|
@@ -716,7 +869,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
716
869
|
#### 优酷(Youku)
|
|
717
870
|
|
|
718
871
|
| 格式类型 | URL 示例 | 视频 ID 提取正则 |
|
|
719
|
-
|
|
872
|
+
|----------|-----------|-------------------|
|
|
720
873
|
| 标准页(v_show) | `https://v.youku.com/v_show/id_VIDEO_ID.html` | `v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html` |
|
|
721
874
|
| 标准页(video) | `https://v.youku.com/video/VIDEO_ID` | `v\.youku\.com\/video\/([a-zA-Z0-9=]+)` |
|
|
722
875
|
| 标准页(www) | `https://www.youku.com/v_show/id_VIDEO_ID.html` | `www\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html` |
|
|
@@ -729,18 +882,18 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
729
882
|
- **格式互转**:
|
|
730
883
|
- 优酷内嵌格式较复杂,建议直接使用标准页链接(`{YOUKU_URL_TPL}`)
|
|
731
884
|
|
|
732
|
-
> **脚本使用提示**:Excel 中只需填入视频 ID(如 `zzJmKPX8a3c`、`BV1pg411b7Ug`、`o0325y3hqh`、`XMzgxNzExNTY4MA==`),脚本自动替换 URL 模板中的 `{
|
|
885
|
+
> **脚本使用提示**:Excel 中只需填入视频 ID(如 `zzJmKPX8a3c`、`BV1pg411b7Ug`、`o0325y3hqh`、`XMzgxNzExNTY4MA==`),脚本自动替换 URL 模板中的 `{youtube}`、`{bilibili}` 等占位符生成下载链接。
|
|
733
886
|
|
|
734
887
|
### 常见下载错误
|
|
735
888
|
|
|
736
889
|
| 错误 | 平台 | 原因 | 解决方案 |
|
|
737
|
-
|
|
890
|
+
|------|------|------|----------|
|
|
738
891
|
| `Sign in to confirm you're not a bot` | YouTube | cookie 过期或无效 | 检查 Firefox 登录态,或重新导出 cookie 文件 |
|
|
739
892
|
| `cookies does no longer seem to be valid` | YouTube | cookie 文件超过 48h | 用 Firefox cookies-from-browser 方案(免维护) |
|
|
740
893
|
| `Unable to download webpage: HTTP Error 403` | YouTube | IP 被识别为非 YouTube 地区 | 确保代理运行(端口 7897),检查 `YOUTUBE_PROXY` |
|
|
741
894
|
| `n challenge solving failed` | YouTube | 无 JS 运行时 | 安装 Node.js,确保 `YOUTUBE_JS_RUNTIMES=node` |
|
|
742
895
|
| `Requested format is not available` | YouTube | n-sig 未解开,格式不可用 | 同上,安装 JS 运行时 |
|
|
743
|
-
| `HTTP Error 412` | B站 | 缺少 Chrome UA 或 cookie 过期 | 重新导出 `cookies/bilibili.txt` |
|
|
896
|
+
| `HTTP Error 412` | B站 | 缺少 Chrome UA 或 cookie 过期 | 重新导出 `cookies/bilibili.txt` 或使用 Firefox 直读 |
|
|
744
897
|
| `HTTP Error 403` | B站 | 地区限制或视频已删除 | 检查视频是否可访问 |
|
|
745
898
|
| `dpapi decryption failed` | YouTube | Windows Chrome cookie 加密 | **改用 Firefox**(`.env` 中设 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`) |
|
|
746
899
|
|
|
@@ -758,7 +911,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
758
911
|
3. 克隆或下载项目文件(`.env.example`、`.env`、`cookies/` 等)
|
|
759
912
|
4. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
|
|
760
913
|
5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
|
|
761
|
-
6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
|
|
914
|
+
6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`(或设置 `BILIBILI_COOKIES_FROM_BROWSER=firefox`)
|
|
762
915
|
7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
|
|
763
916
|
8. `video-pipeline --dry-run` 验证
|
|
764
917
|
|
|
@@ -769,7 +922,7 @@ node process_videos.js --retry-failed reports/YouTube视频/report_xxx.json --co
|
|
|
769
922
|
3. 安装 Python 依赖:`pip install pandas openpyxl requests python-dotenv questionary`
|
|
770
923
|
4. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
|
|
771
924
|
5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
|
|
772
|
-
6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
|
|
925
|
+
6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`(或设置 `BILIBILI_COOKIES_FROM_BROWSER=firefox`)
|
|
773
926
|
7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
|
|
774
927
|
8. `python process_videos.py --dry-run` 验证
|
|
775
928
|
|
package/package.json
CHANGED
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.
|
|
75
|
-
const COL_BILIBILIBVID = process.env.COL_BILIBILIBVID || 'extra.
|
|
76
|
-
const COL_YOUTUBEID = process.env.COL_YOUTUBEID || 'extra.
|
|
77
|
-
const COL_YOUKUID = process.env.COL_YOUKUID || 'extra.
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 || '
|
|
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 === '
|
|
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 === '
|
|
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 === '
|
|
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,18 +182,19 @@ 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);
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
// Node.js 单线程模型下 console.log 是原子的,不会出现行内交错
|
|
188
198
|
function lockedPrint(s) {
|
|
189
199
|
console.log(s);
|
|
190
200
|
}
|
|
@@ -208,7 +218,13 @@ class OverallProgress {
|
|
|
208
218
|
}
|
|
209
219
|
summaryLine() {
|
|
210
220
|
const pct = this.total ? (this.completed / this.total * 100).toFixed(1) : '0.0';
|
|
211
|
-
|
|
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(' ');
|
|
212
228
|
}
|
|
213
229
|
}
|
|
214
230
|
|
|
@@ -327,7 +343,7 @@ function buildUrl(pkey, vid) {
|
|
|
327
343
|
const URL_PLATFORM_MAP = [
|
|
328
344
|
{
|
|
329
345
|
platform: 'bilibili',
|
|
330
|
-
pkey: '
|
|
346
|
+
pkey: 'bilibili',
|
|
331
347
|
patterns: [
|
|
332
348
|
/bilibili\.com\/video\/(BV[a-zA-Z0-9]{10})/,
|
|
333
349
|
/b23\.tv\/([a-zA-Z0-9]+)/,
|
|
@@ -336,14 +352,14 @@ const URL_PLATFORM_MAP = [
|
|
|
336
352
|
},
|
|
337
353
|
{
|
|
338
354
|
platform: 'youtube',
|
|
339
|
-
pkey: '
|
|
355
|
+
pkey: 'youtube',
|
|
340
356
|
patterns: [
|
|
341
357
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
|
342
358
|
],
|
|
343
359
|
},
|
|
344
360
|
{
|
|
345
361
|
platform: 'tencent',
|
|
346
|
-
pkey: '
|
|
362
|
+
pkey: 'tencent',
|
|
347
363
|
patterns: [
|
|
348
364
|
/v\.qq\.com\/x\/page\/([a-zA-Z0-9]+)\.html/,
|
|
349
365
|
/v\.qq\.com\/x\/cover\/[^/]+\/([a-zA-Z0-9]+)\.html/,
|
|
@@ -352,7 +368,7 @@ const URL_PLATFORM_MAP = [
|
|
|
352
368
|
},
|
|
353
369
|
{
|
|
354
370
|
platform: 'youku',
|
|
355
|
-
pkey: '
|
|
371
|
+
pkey: 'youku',
|
|
356
372
|
patterns: [
|
|
357
373
|
/v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html/,
|
|
358
374
|
],
|
|
@@ -655,6 +671,8 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
|
|
|
655
671
|
try { onProgress(line); } catch {}
|
|
656
672
|
});
|
|
657
673
|
child.stderr.on('end', () => rl.close());
|
|
674
|
+
// 同时消费 stdout,防止缓冲区填满阻塞子进程
|
|
675
|
+
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
658
676
|
} else {
|
|
659
677
|
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
660
678
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
@@ -676,7 +694,7 @@ function spawnWithTimeout(cmd, args, timeout, options = {}) {
|
|
|
676
694
|
}
|
|
677
695
|
|
|
678
696
|
// ============================== AI 分析 ==============================
|
|
679
|
-
async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
697
|
+
async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300, label = 'analyze') {
|
|
680
698
|
if (!text || !text.trim()) {
|
|
681
699
|
return { text: null, retries: 0, error: 'content empty, skip AI analysis' };
|
|
682
700
|
}
|
|
@@ -686,7 +704,7 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
686
704
|
const model = process.env.AI_MODEL || '';
|
|
687
705
|
const promptTpl = process.env.AI_PROMPT_TPL || '帮我归纳总结一下Keywords,尽可能全一点,这是内容:{content}';
|
|
688
706
|
const aiTemperature = parseFloat(process.env.AI_TEMPERATURE || '0.3');
|
|
689
|
-
const aiTimeout =
|
|
707
|
+
const aiTimeout = timeout;
|
|
690
708
|
|
|
691
709
|
if (!apiKey || !baseUrl || !model) {
|
|
692
710
|
return { text: null, retries: 0, error: 'AI config incomplete' };
|
|
@@ -703,6 +721,15 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
703
721
|
let lastErr = null;
|
|
704
722
|
const maxAttempts = maxRetries + 1;
|
|
705
723
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
724
|
+
let done = false;
|
|
725
|
+
const analyzeStart = Date.now();
|
|
726
|
+
const progressInterval = setInterval(() => {
|
|
727
|
+
if (!done) {
|
|
728
|
+
const elapsed = ((Date.now() - analyzeStart) / 1000).toFixed(0);
|
|
729
|
+
lockedPrint(` [${label}] ${c('green', 'AI analyzing...')} ${elapsed}s`);
|
|
730
|
+
}
|
|
731
|
+
}, 5000);
|
|
732
|
+
|
|
706
733
|
try {
|
|
707
734
|
const controller = new AbortController();
|
|
708
735
|
const timer = setTimeout(() => controller.abort(), aiTimeout * 1000);
|
|
@@ -716,6 +743,8 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
716
743
|
signal: controller.signal,
|
|
717
744
|
});
|
|
718
745
|
clearTimeout(timer);
|
|
746
|
+
done = true;
|
|
747
|
+
clearInterval(progressInterval);
|
|
719
748
|
if (!resp.ok) {
|
|
720
749
|
throw new Error(`HTTP ${resp.status}: ${await resp.text().catch(() => '')}`);
|
|
721
750
|
}
|
|
@@ -723,10 +752,12 @@ async function stepAnalyze(text, maxRetries, retryDelay, timeout = 300) {
|
|
|
723
752
|
const content = body.choices?.[0]?.message?.content || '';
|
|
724
753
|
return { text: content.trim(), retries: attempt, error: null };
|
|
725
754
|
} catch (e) {
|
|
755
|
+
done = true;
|
|
756
|
+
clearInterval(progressInterval);
|
|
726
757
|
lastErr = String(e.message).slice(0, 500);
|
|
727
758
|
if (attempt < maxAttempts - 1) {
|
|
728
759
|
const delay = Math.min(retryDelay * Math.pow(2, attempt), 30);
|
|
729
|
-
lockedPrint(` [
|
|
760
|
+
lockedPrint(` [${label}] AI attempt ${attempt + 1} ${c('red', 'failed')}: ${lastErr.slice(0, 100)}, retrying in ${delay}s...`);
|
|
730
761
|
await sleep(delay * 1000);
|
|
731
762
|
}
|
|
732
763
|
}
|
|
@@ -780,13 +811,13 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
|
|
|
780
811
|
if (!force) {
|
|
781
812
|
const existing = findDownloadedFile(dlDir, stem);
|
|
782
813
|
if (existing) {
|
|
783
|
-
lockedPrint(` [${stem}] exists ${path.basename(existing)}, skip download`);
|
|
814
|
+
lockedPrint(c('dim', ` [${stem}] exists ${path.basename(existing)}, skip download`));
|
|
784
815
|
return { file: existing, retries: 0, error: null };
|
|
785
816
|
}
|
|
786
817
|
}
|
|
787
818
|
|
|
788
819
|
const videoUrl = buildUrl(pkey, vid);
|
|
789
|
-
lockedPrint(` [${stem}] start download (platform=${pkey})`);
|
|
820
|
+
lockedPrint(` [${stem}] ${c('cyan', 'start download')} (platform=${pkey})`);
|
|
790
821
|
lockedPrint(` [${stem}] ${videoUrl}`);
|
|
791
822
|
|
|
792
823
|
const cfg = PLATFORM_CONFIG[pkey];
|
|
@@ -847,7 +878,7 @@ async function stepDownload(row, sheetName, maxRetries, retryDelay, force, timeo
|
|
|
847
878
|
|
|
848
879
|
const downloaded = findDownloadedFile(dlDir, stem);
|
|
849
880
|
if (downloaded) {
|
|
850
|
-
lockedPrint(` [${stem}] download done -> ${path.basename(downloaded)}`);
|
|
881
|
+
lockedPrint(` [${stem}] ${c('green', 'download done')} -> ${path.basename(downloaded)}`);
|
|
851
882
|
return { file: downloaded, retries: 0, error: null };
|
|
852
883
|
}
|
|
853
884
|
logError(`[${stem}] file not found after download`);
|
|
@@ -941,7 +972,7 @@ async function stepTranscode(srcFile, sheetName, maxRetries, retryDelay, force,
|
|
|
941
972
|
|
|
942
973
|
try {
|
|
943
974
|
await retryCall(doTranscode, maxRetries, retryDelay, stem);
|
|
944
|
-
lockedPrint(` [${stem}] transcode done`);
|
|
975
|
+
lockedPrint(` [${stem}] ${c('green', 'transcode done')}`);
|
|
945
976
|
return { file: outFile, retries: 0, error: null };
|
|
946
977
|
} catch (e) {
|
|
947
978
|
logError(`[${stem}] ffmpeg transcode failed: ${(e.stderr || e.message).slice(-2000)}`);
|
|
@@ -963,10 +994,10 @@ async function stepTranscribe(audioFile, maxRetries, retryDelay, timeout = 600)
|
|
|
963
994
|
const fileSizeMB = (fs.statSync(audioFile).size / (1024 * 1024)).toFixed(1);
|
|
964
995
|
if (WHISPER_BACKEND === 'local') {
|
|
965
996
|
const langLabel = WHISPER_LANGUAGE || 'auto';
|
|
966
|
-
lockedPrint(` [${stem}] start transcribe [local(${WHISPER_MODEL}/${langLabel})] (${fileSizeMB}MB)...`);
|
|
997
|
+
lockedPrint(` [${stem}] ${c('magenta', 'start transcribe')} [local(${WHISPER_MODEL}/${langLabel})] (${fileSizeMB}MB)...`);
|
|
967
998
|
} else {
|
|
968
999
|
const modelLabel = WHISPER_SERVICE_MODEL || WHISPER_MODEL || '(server default)';
|
|
969
|
-
lockedPrint(` [${stem}] start transcribe [service(${modelLabel})] (${fileSizeMB}MB)...`);
|
|
1000
|
+
lockedPrint(` [${stem}] ${c('magenta', 'start transcribe')} [service(${modelLabel})] (${fileSizeMB}MB)...`);
|
|
970
1001
|
}
|
|
971
1002
|
|
|
972
1003
|
if (WHISPER_BACKEND === 'local') {
|
|
@@ -1002,7 +1033,7 @@ async function transcribeLocal(audioFile, stem, maxRetries, retryDelay, timeout
|
|
|
1002
1033
|
const { result: text, retriesUsed, error } = await retryCall(doTranscribe, maxRetries, retryDelay, stem);
|
|
1003
1034
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
1004
1035
|
if (error) return { text: null, retries: retriesUsed, error };
|
|
1005
|
-
lockedPrint(` [${stem}] transcribe done (${elapsed}s, ${text.length} chars)`);
|
|
1036
|
+
lockedPrint(` [${stem}] ${c('green', 'transcribe done')} (${elapsed}s, ${text.length} chars)`);
|
|
1006
1037
|
return { text, retries: 0, error: null };
|
|
1007
1038
|
} catch (e) {
|
|
1008
1039
|
logError(`[${stem}] local whisper transcribe failed: ${e.message}`);
|
|
@@ -1066,7 +1097,7 @@ async function transcribeService(audioFile, stem, maxRetries, retryDelay, timeou
|
|
|
1066
1097
|
const { result: text, retriesUsed, error } = await retryCall(doTranscribe, maxRetries, retryDelay, stem);
|
|
1067
1098
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
1068
1099
|
if (error) return { text: null, retries: retriesUsed, error };
|
|
1069
|
-
lockedPrint(` [${stem}] transcribe done (${elapsed}s, ${text.length} chars)`);
|
|
1100
|
+
lockedPrint(` [${stem}] ${c('green', 'transcribe done')} (${elapsed}s, ${text.length} chars)`);
|
|
1070
1101
|
return { text, retries: 0, error: null };
|
|
1071
1102
|
} catch (e) {
|
|
1072
1103
|
logError(`[${stem}] whisper transcribe failed: ${e.message}`);
|
|
@@ -1201,7 +1232,7 @@ function generateReport(results, config, sheetName) {
|
|
|
1201
1232
|
// ── 单 sheet 报告 ──
|
|
1202
1233
|
const dir = path.join(REPORTS_DIR, sheetName);
|
|
1203
1234
|
fs.mkdirSync(dir, { recursive: true });
|
|
1204
|
-
const ts = new Date().toISOString().replace(/[-:T]/g, '').
|
|
1235
|
+
const ts = new Date().toISOString().split('.')[0].replace(/[-:T]/g, '').replace(/(\d{8})(\d{6})/, '$1_$2');
|
|
1205
1236
|
const reportFile = path.join(dir, `report_${ts}.json`);
|
|
1206
1237
|
|
|
1207
1238
|
const summary = computeSummary(results);
|
|
@@ -1287,6 +1318,8 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1287
1318
|
whisperAvailable, positionLabel = '', downloadTimeout = 600, transcodeTimeout = 600,
|
|
1288
1319
|
transcribeTimeout = 600, analyzeTimeout = 300) {
|
|
1289
1320
|
|
|
1321
|
+
const preContent = row.preContent || null;
|
|
1322
|
+
|
|
1290
1323
|
const { pkey, vid } = getVideoId(row);
|
|
1291
1324
|
const stem = stemName(row, sheetName);
|
|
1292
1325
|
const key = rowKey(row);
|
|
@@ -1296,12 +1329,19 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1296
1329
|
const result = new TaskResult(sheetName, key, title, pkey, videoUrl, stem);
|
|
1297
1330
|
|
|
1298
1331
|
const tag = positionLabel ? `${positionLabel} ` : '';
|
|
1299
|
-
lockedPrint(
|
|
1300
|
-
|
|
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)})`);
|
|
1301
1339
|
|
|
1302
1340
|
// ── download ──
|
|
1303
1341
|
let dlFile = null;
|
|
1304
|
-
if (
|
|
1342
|
+
if (preContent) {
|
|
1343
|
+
result.download = new StepResult('skipped', null, 'pre-content mode');
|
|
1344
|
+
} else if (steps.includes('download')) {
|
|
1305
1345
|
if (!pkey) {
|
|
1306
1346
|
result.download = new StepResult('skipped');
|
|
1307
1347
|
result.overall_status = 'no_video';
|
|
@@ -1332,7 +1372,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1332
1372
|
|
|
1333
1373
|
// ── transcode ──
|
|
1334
1374
|
let tcFile = null;
|
|
1335
|
-
if (
|
|
1375
|
+
if (preContent) {
|
|
1376
|
+
result.transcode = new StepResult('skipped', null, 'pre-content mode');
|
|
1377
|
+
} else if (steps.includes('transcode') && dlFile) {
|
|
1336
1378
|
try {
|
|
1337
1379
|
const { file, retries, error } = await stepTranscode(dlFile, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
|
|
1338
1380
|
tcFile = file;
|
|
@@ -1357,7 +1399,9 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1357
1399
|
}
|
|
1358
1400
|
|
|
1359
1401
|
// ── transcribe ──
|
|
1360
|
-
if (
|
|
1402
|
+
if (preContent) {
|
|
1403
|
+
result.transcribe = new StepResult('success', preContent);
|
|
1404
|
+
} else if (steps.includes('transcribe') && tcFile) {
|
|
1361
1405
|
if (!whisperAvailable) {
|
|
1362
1406
|
result.transcribe = new StepResult('failed', null, `whisper unreachable (${WHISPER_SERVICE})`);
|
|
1363
1407
|
result.overall_status = 'partial';
|
|
@@ -1387,12 +1431,12 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
|
|
|
1387
1431
|
const txt = result.transcribe.file;
|
|
1388
1432
|
if (txt) {
|
|
1389
1433
|
try {
|
|
1390
|
-
const { text: kw, retries, error } = await stepAnalyze(txt, maxRetries, retryDelay, analyzeTimeout);
|
|
1434
|
+
const { text: kw, retries, error } = await stepAnalyze(txt, maxRetries, retryDelay, analyzeTimeout, result.stem);
|
|
1391
1435
|
result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
|
|
1392
1436
|
if (kw) {
|
|
1393
|
-
lockedPrint(` [${result.stem}] AI analysis done (${kw.length} chars)`);
|
|
1437
|
+
lockedPrint(` [${result.stem}] ${c('green', 'AI analysis done')} (${kw.length} chars)`);
|
|
1394
1438
|
} else {
|
|
1395
|
-
lockedPrint(` [${result.stem}] AI analysis failed: ${error}`);
|
|
1439
|
+
lockedPrint(` [${result.stem}] ${c('red', 'AI analysis failed')}: ${error}`);
|
|
1396
1440
|
}
|
|
1397
1441
|
} catch (e) {
|
|
1398
1442
|
result.analyze = new StepResult('failed', null, String(e.message).slice(0, 500), maxRetries);
|
|
@@ -1658,7 +1702,7 @@ async function runInputTask(opts) {
|
|
|
1658
1702
|
if (aiEnabled) {
|
|
1659
1703
|
console.log(` [${usedStem}] 🤖 开始 AI 分析...`);
|
|
1660
1704
|
try {
|
|
1661
|
-
const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout);
|
|
1705
|
+
const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout, usedStem);
|
|
1662
1706
|
if (kw && typeof kw === 'string') {
|
|
1663
1707
|
analyzeText = kw;
|
|
1664
1708
|
console.log(` [${usedStem}] 🤖 AI分析完成: ${kw.length} 字符`);
|
|
@@ -1730,6 +1774,123 @@ async function runInputTask(opts) {
|
|
|
1730
1774
|
}
|
|
1731
1775
|
|
|
1732
1776
|
|
|
1777
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1778
|
+
// 文本内容流水线(--content 模式)
|
|
1779
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* --content 模式:纯文本 → AI 关键词提取
|
|
1783
|
+
* 文本来源可以是文件路径或内联文本,
|
|
1784
|
+
* --name 可指定输出文件名(不含扩展名)。
|
|
1785
|
+
* 不指定时:文件路径取 stem,内联文本取前 32 字符。
|
|
1786
|
+
*/
|
|
1787
|
+
async function runContentTask(opts) {
|
|
1788
|
+
const { content, name, steps, force,
|
|
1789
|
+
retry: maxRetries, retryDelay, analyzeTimeout } = opts;
|
|
1790
|
+
|
|
1791
|
+
// ── 1. 读取/确定文本 ──
|
|
1792
|
+
const contentPath = path.resolve(content);
|
|
1793
|
+
let contentText = '';
|
|
1794
|
+
let fromFile = false;
|
|
1795
|
+
if (fs.existsSync(contentPath) && fs.statSync(contentPath).isFile()) {
|
|
1796
|
+
contentText = fs.readFileSync(contentPath, 'utf-8').trim();
|
|
1797
|
+
fromFile = true;
|
|
1798
|
+
console.log(` 📖 从文件读取: ${contentPath} (${contentText.length} 字符)`);
|
|
1799
|
+
} else {
|
|
1800
|
+
contentText = content;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (!contentText || !contentText.trim()) {
|
|
1804
|
+
console.error(c('red', '错误: --content 文本内容为空'));
|
|
1805
|
+
process.exit(1);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// ── 2. 确定输出文件名 ──
|
|
1809
|
+
let stem = '';
|
|
1810
|
+
if (name) {
|
|
1811
|
+
stem = safeFilename(name);
|
|
1812
|
+
} else if (fromFile) {
|
|
1813
|
+
stem = safeFilename(path.parse(contentPath).name);
|
|
1814
|
+
} else {
|
|
1815
|
+
stem = safeFilename(contentText.replace(/\s+/g, ' ').slice(0, 32).trim());
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
if (steps.length === 0) steps = ['analyze'];
|
|
1819
|
+
|
|
1820
|
+
console.log(c('dim', '\n── 开始执行 (内容分析) ──\n'));
|
|
1821
|
+
console.log(` 输出名称: ${c('cyan', stem)}`);
|
|
1822
|
+
console.log(` 内容长度: ${c('cyan', contentText.length + ' 字符')}`);
|
|
1823
|
+
console.log(` 执行步骤: ${c('cyan', steps.join(' → '))}`);
|
|
1824
|
+
|
|
1825
|
+
if (opts.dryRun) {
|
|
1826
|
+
console.log('');
|
|
1827
|
+
process.exit(0);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const sheetName = 'content';
|
|
1831
|
+
|
|
1832
|
+
// ── 3. 构建 TaskResult ──
|
|
1833
|
+
const result = new TaskResult(sheetName, stem, stem.slice(0, 50), 'local', null, stem);
|
|
1834
|
+
result.download = new StepResult('skipped');
|
|
1835
|
+
result.transcode = new StepResult('skipped');
|
|
1836
|
+
result.transcribe = new StepResult('success', contentText);
|
|
1837
|
+
|
|
1838
|
+
// ── 4. AI 分析 ──
|
|
1839
|
+
if (steps.includes('analyze')) {
|
|
1840
|
+
const aiEnabled = (process.env.AI_ENABLED || 'true').toLowerCase() === 'true';
|
|
1841
|
+
if (!aiEnabled) {
|
|
1842
|
+
result.analyze = new StepResult('skipped');
|
|
1843
|
+
console.log(` [${stem}] AI 分析: ${c('yellow', '已禁用 (AI_ENABLED=false)')}`);
|
|
1844
|
+
} else {
|
|
1845
|
+
console.log(` [${stem}] 开始 AI 分析...`);
|
|
1846
|
+
try {
|
|
1847
|
+
const { text: kw, retries, error } = await stepAnalyze(
|
|
1848
|
+
contentText, maxRetries, retryDelay, analyzeTimeout, stem
|
|
1849
|
+
);
|
|
1850
|
+
result.analyze = new StepResult(kw ? 'success' : 'failed', kw, error, retries);
|
|
1851
|
+
if (kw) {
|
|
1852
|
+
console.log(` [${stem}] AI 分析完成 (${kw.length} 字符)`);
|
|
1853
|
+
} else {
|
|
1854
|
+
console.log(` [${stem}] AI 分析失败: ${error}`);
|
|
1855
|
+
}
|
|
1856
|
+
} catch (e) {
|
|
1857
|
+
result.analyze = new StepResult('failed', null, String(e.message).slice(0, 500), maxRetries);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
result.overall_status = (result.analyze && result.analyze.status === 'success') ? 'success' : 'partial';
|
|
1863
|
+
|
|
1864
|
+
// ── 5. 保存文本结果 ──
|
|
1865
|
+
const an = result.analyze;
|
|
1866
|
+
const analyzeText = an && an.file && an.status === 'success' ? an.file : '';
|
|
1867
|
+
if (contentText || analyzeText) {
|
|
1868
|
+
const outDir = path.join(REPORTS_DIR, sheetName, 'tasks');
|
|
1869
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1870
|
+
const outFile = path.join(outDir, `${stem}.txt`);
|
|
1871
|
+
const lines = [
|
|
1872
|
+
`来源: --content`,
|
|
1873
|
+
`文件名: ${stem}`,
|
|
1874
|
+
'', '='.repeat(60), '',
|
|
1875
|
+
'【源内容】', '', contentText, '',
|
|
1876
|
+
];
|
|
1877
|
+
if (analyzeText) {
|
|
1878
|
+
lines.push('【AI 分析关键词】', '', analyzeText);
|
|
1879
|
+
}
|
|
1880
|
+
fs.writeFileSync(outFile, lines.join('\n'), 'utf-8');
|
|
1881
|
+
console.log(`\n 报告已保存: ${outFile}`);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ── 6. 生成标准报告 JSON ──
|
|
1885
|
+
const config = { steps, max_retries: maxRetries, retry_delay: retryDelay, concurrency: 1, force: force || false };
|
|
1886
|
+
generateReport([result], config, sheetName);
|
|
1887
|
+
printReportSummary([result]);
|
|
1888
|
+
|
|
1889
|
+
console.log('');
|
|
1890
|
+
return result;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
|
|
1733
1894
|
// ═══════════════════════════════════════════════════════════════════
|
|
1734
1895
|
// URL 直链流水线(--url 模式)
|
|
1735
1896
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -1846,7 +2007,7 @@ async function runUrlTask(opts) {
|
|
|
1846
2007
|
}
|
|
1847
2008
|
|
|
1848
2009
|
async function run({
|
|
1849
|
-
targetSheet, targetId, steps, maxRetries, retryDelay,
|
|
2010
|
+
targetSheet, targetId, contentColumn, steps, maxRetries, retryDelay,
|
|
1850
2011
|
concurrency, force, dryRun, retryFailed,
|
|
1851
2012
|
downloadTimeout, transcodeTimeout, transcribeTimeout, analyzeTimeout,
|
|
1852
2013
|
offset = 0, rowLimit = 0,
|
|
@@ -1879,6 +2040,15 @@ async function run({
|
|
|
1879
2040
|
}
|
|
1880
2041
|
precomputeStems(rows, sheetName);
|
|
1881
2042
|
for (const row of rows) {
|
|
2043
|
+
// ── content-column 模式:从指定列读取预置文本 ──
|
|
2044
|
+
if (contentColumn) {
|
|
2045
|
+
const text = String(row[contentColumn] || '').trim();
|
|
2046
|
+
if (!text) {
|
|
2047
|
+
logWarn(`[${sheetName}] row ${row[COL_ID] || '?'}: contentColumn "${contentColumn}" 为空,跳过`);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
row.preContent = text;
|
|
2051
|
+
}
|
|
1882
2052
|
tasks.push({ row, sheetName });
|
|
1883
2053
|
}
|
|
1884
2054
|
}
|
|
@@ -1934,6 +2104,8 @@ async function run({
|
|
|
1934
2104
|
}
|
|
1935
2105
|
results.push(result);
|
|
1936
2106
|
overall.addResult(result.overall_status);
|
|
2107
|
+
lockedPrint('');
|
|
2108
|
+
lockedPrint(c('dim', '─'.repeat(62)));
|
|
1937
2109
|
console.log(`\n${overall.summaryLine()}\n`);
|
|
1938
2110
|
return result;
|
|
1939
2111
|
})
|
|
@@ -1942,7 +2114,7 @@ async function run({
|
|
|
1942
2114
|
await Promise.all(taskFns);
|
|
1943
2115
|
|
|
1944
2116
|
// ── 批量写回 Excel ──
|
|
1945
|
-
if (steps.includes('transcribe')) {
|
|
2117
|
+
if (steps.includes('transcribe') || contentColumn) {
|
|
1946
2118
|
const kwMap = new Map();
|
|
1947
2119
|
for (const r of results) {
|
|
1948
2120
|
if (r.analyze.status === 'success' && r.analyze.file) {
|
|
@@ -2134,6 +2306,8 @@ async function runFromReport(reportPath, steps, maxRetries, retryDelay, concurre
|
|
|
2134
2306
|
}
|
|
2135
2307
|
results.push(result);
|
|
2136
2308
|
overall.addResult(result.overall_status);
|
|
2309
|
+
lockedPrint('');
|
|
2310
|
+
lockedPrint(c('dim', '─'.repeat(62)));
|
|
2137
2311
|
console.log(`\n${overall.summaryLine()}\n`);
|
|
2138
2312
|
return result;
|
|
2139
2313
|
})
|
|
@@ -2188,8 +2362,10 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2188
2362
|
.option('--init', '复制 .env.example 到当前目录并重命名为 .env')
|
|
2189
2363
|
.option('--file <path>', '指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量)')
|
|
2190
2364
|
.option('--input <path>', '指定本地视频文件路径(跳过下载,直接转码→识别→分析)')
|
|
2365
|
+
.option('--content <text|path>', '直接提供文本内容(文件路径或内联文本),跳过下载/转码/识别,直接做 AI 分析')
|
|
2366
|
+
.option('--content-column <col>', 'Excel 模式:指定包含已爬取文本的列名,批量做 AI 分析')
|
|
2191
2367
|
.option('--url <url>', '直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接')
|
|
2192
|
-
.option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input 配合使用)')
|
|
2368
|
+
.option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input / --content 配合使用)')
|
|
2193
2369
|
.option('--env-file <path>', '指定要加载的 .env 文件路径(默认: 当前目录 .env)');
|
|
2194
2370
|
|
|
2195
2371
|
program.parse();
|
|
@@ -2252,6 +2428,12 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2252
2428
|
logInfo(`Excel 文件覆盖为: ${EXCEL_FILE}`);
|
|
2253
2429
|
}
|
|
2254
2430
|
const steps = opts.step?.length ? opts.step : ['download', 'transcode', 'transcribe', 'analyze'];
|
|
2431
|
+
// --content-column 模式:默认只跑 AI 分析
|
|
2432
|
+
if (opts.contentColumn && !opts.step?.length) {
|
|
2433
|
+
steps.length = 0;
|
|
2434
|
+
steps.push('analyze');
|
|
2435
|
+
logInfo('--content-column 模式:默认 --step analyze');
|
|
2436
|
+
}
|
|
2255
2437
|
// ── --url 模式:直接处理单个视频链接 ──
|
|
2256
2438
|
if (opts.url) {
|
|
2257
2439
|
const parsed = parseUrl(opts.url);
|
|
@@ -2466,10 +2648,25 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
|
|
|
2466
2648
|
process.exit(0);
|
|
2467
2649
|
}
|
|
2468
2650
|
|
|
2651
|
+
// ── --content 模式:纯文本 AI 分析 ──
|
|
2652
|
+
if (opts.content) {
|
|
2653
|
+
await runContentTask({
|
|
2654
|
+
content: opts.content,
|
|
2655
|
+
name: opts.name || null,
|
|
2656
|
+
steps,
|
|
2657
|
+
retry: opts.retry,
|
|
2658
|
+
retryDelay: opts.retryDelay,
|
|
2659
|
+
analyzeTimeout: opts.analyzeTimeout,
|
|
2660
|
+
force: opts.force || false,
|
|
2661
|
+
dryRun: opts.dryRun || false,
|
|
2662
|
+
});
|
|
2663
|
+
process.exit(0);
|
|
2664
|
+
}
|
|
2469
2665
|
|
|
2470
2666
|
run({
|
|
2471
2667
|
targetSheet: opts.sheet || null,
|
|
2472
2668
|
targetId: opts.id || null,
|
|
2669
|
+
contentColumn: opts.contentColumn || null,
|
|
2473
2670
|
steps,
|
|
2474
2671
|
offset: opts.offset || 0,
|
|
2475
2672
|
rowLimit: opts.limit || 0,
|