@casatwy/deyo 0.2.3 → 0.2.6
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/README.md +32 -6
- package/deyo.mjs +293 -113
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
`deyo` 是 [https://deyo.miaobi.fun](https://deyo.miaobi.fun) 的命令行工具。你可以直接在终端里提交音视频链接或本地音视频文件进行转写,并把结果输出到终端或保存到本地文件。
|
|
4
4
|
|
|
5
|
-
CLI `0.2.
|
|
5
|
+
CLI `0.2.4` 保留省略 `--language` 时的自动检测和可选稳定正文事件,以及由 npm CLI 管理的 OpenClaw 更新登记、离线状态与显式确认流程。本地文件选定后会直接初始化并分片上传,不再预先计算文件 SHA-256。默认不会把正文写入进度日志;只有显式使用 `--stream-transcript --progress-format jsonl`,且最终格式为 `text` 时,才会在 stderr JSONL 中输出稳定的 Whisper 正文增量。stdout 或 `-O` 始终只接收最终 Whisper 原稿,CLI 本身不做 AI 整理。
|
|
6
6
|
|
|
7
|
-
当前支持
|
|
7
|
+
当前支持 11 类平台链接 + 音频/视频文件链接 + 本地音视频上传。可转写链接包括小宇宙单集、喜马拉雅单集(含 `xima.tv` 短链)、B 站具体视频、抖音单视频、小红书单视频笔记、YouTube 单视频、Apple Podcasts 单集、Twitter/X 视频推文、TikTok 单条公开视频、看看新闻单条视频、微信视频号公开单视频,以及未知平台的 HTTPS 音频/视频直链(无需以常见媒体扩展名结尾)。其中 YouTube 如果命中可直接使用的字幕,会直接输出字幕或文本结果,不进入完整转写、不扣分钟;喜马拉雅合集、Apple Podcasts 节目主页、小宇宙播客主页和 B 站 Bangumi / UGC 合集会返回 422 并提示选择具体单集或视频,不会扣分钟或创建转写任务;抖音图文内容、小红书图文笔记、Twitter/X 文本或图片推文以及 TikTok 图集也会返回不支持转写的提示。TikTok 支持标准、移动端和 vm/vt 单条短链;账号页、搜索页、直播、私密或地区受限内容不在支持范围,文字稿来自音轨 ASR。看看新闻支持 hostname 中包含 `kankanews.com` 的 HTTPS 视频页面。B 站的普通 BV、分 P、`b23.tv` 短链、带 `bvid` 的 `player.bilibili.com/player.html`、可由 Whisper 解出 BV 页面的 `bilibili://video/...` App 分享视频链接,以及 Bangumi `ep` 都保持具体视频流程;CLI 会识别并透传 App 链接,不在本地解码或推导 BV。B站专属视频会先进行无任务、无扣费的预检,展示完整与可转写时长;只有交互式终端明确确认后才转写试看部分并按可转写时长扣分钟,拒绝或非 TTY 环境会直接退出。
|
|
8
8
|
|
|
9
9
|
本地文件转写只支持单个普通音频或视频文件,固定作为 `upload` 来源提交。你可以直接传文件路径,也可以显式使用 `--file`;如果文件扩展名或系统识别不可靠,可以用 `--mime-type` 指定媒体类型。当前不支持目录、glob、stdin、批量队列或断点续传。
|
|
10
10
|
|
|
@@ -162,6 +162,8 @@ deyo --file ./audio.mp3 --mime-type audio/mpeg -O ./audio.txt
|
|
|
162
162
|
deyo -O ./episode.txt 'https://www.xiaoyuzhoufm.com/episode/xxxx'
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
小宇宙 `/podcast/:id` 播客主页不是具体单集。CLI 会在创建任务、复用结果或扣减分钟之前返回 422,并提示打开具体 `/episode/` 链接后重新提交。
|
|
166
|
+
|
|
165
167
|
处理喜马拉雅单集并保存为文本:
|
|
166
168
|
|
|
167
169
|
```bash
|
|
@@ -170,6 +172,8 @@ deyo -O ./ximalaya.txt 'https://www.ximalaya.com/sound/963656969'
|
|
|
170
172
|
|
|
171
173
|
也可以传入会跳转到单集页的 `xima.tv` 短链;合集链接会直接提示需要选择具体单集链接后再提交。
|
|
172
174
|
|
|
175
|
+
Apple Podcasts 带有效 `?i=` 的单集链接保持原转写流程;节目 `id<digits>` 主页会返回 422,并提示选择具体单集。
|
|
176
|
+
|
|
173
177
|
处理 Twitter/X 视频推文并保存为文本:
|
|
174
178
|
|
|
175
179
|
```bash
|
|
@@ -178,6 +182,16 @@ deyo --source twitter -O ./tweet.txt 'https://x.com/historyinmemes/status/179063
|
|
|
178
182
|
|
|
179
183
|
如果传入 Twitter/X 文本或图片推文,CLI 会提示当前不支持转写;这类内容不会进入支付或分钟扣减链路。
|
|
180
184
|
|
|
185
|
+
处理微信视频号公开单视频并保存为文本:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
deyo --source wechat_channels -O ./wechat.txt 'https://channels.weixin.qq.com/finder-preview/pages/sph?id=sphxxxx'
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
共享视频号凭据有效时,CLI 会直接沿用普通任务流程。需要扫码时,CLI 会打印只含公开规范链接的 Deyo HTTPS 页面地址并尝试用系统浏览器打开;无桌面的服务器可把打印出的地址复制到其他设备。CLI 只轮询安全的有效/待授权状态,授权成功后仅重放原任务请求一次,因此继续复用服务端原解析记录、订单和幂等扣费逻辑。
|
|
192
|
+
|
|
193
|
+
视频号 Cookie、凭据 generation、媒体直链及其签名不得通过 CLI 参数、stdin、环境变量、本地配置或剪贴板提供;CLI 也不会在文本、JSON 或 JSONL 输出中返回这些材料。API Key 只用于把请求映射到 Deyo 用户,不会把共享视频号凭据绑定到该用户。
|
|
194
|
+
|
|
181
195
|
处理本地音频文件并保存为文本:
|
|
182
196
|
|
|
183
197
|
```bash
|
|
@@ -217,6 +231,10 @@ deyo -O ./bilibili.txt 'https://player.bilibili.com/player.html?bvid=BVxxxx&page
|
|
|
217
231
|
deyo -O ./bilibili-app.txt 'bilibili://video/BVxxxx?page=2'
|
|
218
232
|
```
|
|
219
233
|
|
|
234
|
+
Bangumi `ep` 仍按具体视频处理。Bangumi `ss` / `md`、受支持的 UGC season / series 合集入口会返回 422,并提示选择具体视频;CLI 不会枚举或输出合集视频清单。
|
|
235
|
+
|
|
236
|
+
如果 B站具体视频被识别为B站专属视频,CLI 会先显示“这是B站专属视频,Deyo 只能转写试看部分”、完整时长、可转写时长和预计扣除分钟,并询问是否继续。输入 `y` 才会创建转写任务;其他输入、拒绝或非交互式终端都会退出,不创建任务也不扣分钟。完全没有可用试看媒体的付费 / 充电专属视频仍返回 422。
|
|
237
|
+
|
|
220
238
|
导出为 `srt` 字幕文件:
|
|
221
239
|
|
|
222
240
|
```bash
|
|
@@ -255,7 +273,7 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
|
|
|
255
273
|
|
|
256
274
|
`--language <value>`
|
|
257
275
|
|
|
258
|
-
显式指定转写语言,例如 `zh` 或 `en`。省略该参数时,CLI
|
|
276
|
+
显式指定转写语言,例如 `zh` 或 `en`。省略该参数时,CLI 会在链接、预检和本地文件请求体里省略 `language` 字段,由服务端和 Whisper 自动检测;不要根据终端语言或内容标题自行推断。
|
|
259
277
|
|
|
260
278
|
`-O, --output <path>`
|
|
261
279
|
|
|
@@ -304,7 +322,7 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
|
|
|
304
322
|
|
|
305
323
|
如果你希望 AI 或脚本稳定读取进度事件,建议使用 `--progress-format jsonl`。此时 CLI 会继续把最终结果输出到 stdout 或文件,只把进度事件按 JSON Lines 写到 stderr。AI 代跑上传或长转写任务时,应读取 JSONL 后用自然语言转述上传百分比、媒体检查、任务创建、状态变化和关键转写百分比,不要把原始 JSONL 直接贴给用户。
|
|
306
324
|
|
|
307
|
-
本地文件上传会额外输出这些上传事件:`upload.
|
|
325
|
+
本地文件上传会额外输出这些上传事件:`upload.started`、`upload.progress`、`upload.completed`、`upload.checking`、`upload.ready`、`upload.aborted`、`upload.failed`。文件选定后直接进入 `upload.started`,不计算或发送文件 hash;事件也不会包含签名 URL 或分片 ETag。
|
|
308
326
|
|
|
309
327
|
任务阶段会输出这些事件:`task.created`、`task.status_changed`、`task.progress`、`task.completed`、`task.failed`、`task.cancelled`、`task.result_written`、`task.notice`。如果 `task.created` 里出现 `mode: "subtitles"` 或 `resultReady: true`,通常表示命中字幕直出,不需要等待长时间完整转写。
|
|
310
328
|
|
|
@@ -340,11 +358,13 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
|
|
|
340
358
|
|
|
341
359
|
## 能力边界
|
|
342
360
|
|
|
343
|
-
- 支持输入:
|
|
361
|
+
- 支持输入:11 类平台链接来源、未知平台 HTTPS 音频/视频文件直链,或一个本地音频/视频文件;转写对象仍是具体单集、视频或单条内容。
|
|
344
362
|
- 不支持输入:目录、glob、stdin、多个文件批量队列、断点续传。
|
|
345
363
|
- 本地文件固定作为 `upload` 来源提交,不需要传 `--source`;如果强行传其他来源,CLI 会直接报错。
|
|
346
364
|
- API key 是 CLI 鉴权入口;没有 API key 时不能创建任务。
|
|
347
|
-
-
|
|
365
|
+
- 微信视频号扫码由 Deyo HTTPS 页面完成;CLI 不接收、保存或输出 Cookie、凭据 generation 和签名媒体直链。扫码完成后原任务最多自动重放一次。
|
|
366
|
+
- 完整转写会按账号分钟余额扣减;B站专属视频只按可转写时长向下取整计算分钟。YouTube 字幕直出、喜马拉雅合集、Apple Podcasts 节目主页、小宇宙播客主页、B 站 Bangumi / UGC 合集、抖音图文、小红书图文、Twitter/X 文本 / 图片推文等不进入完整转写的分支不会扣分钟或创建转写任务。
|
|
367
|
+
- Apple Podcasts 节目主页、小宇宙播客主页和 B 站合集的 422 会发生在任务复用、余额检查和分钟扣减之前;CLI/API 不会把它们当作可付费的整套转写任务。网页解析页可以展示这些入口的摘要,但 CLI 不返回单集或视频清单。
|
|
348
368
|
|
|
349
369
|
`-v, --version`
|
|
350
370
|
|
|
@@ -355,11 +375,13 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
|
|
|
355
375
|
- `缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login`:先在 `https://deyo.miaobi.fun/me/api-keys` 创建 key,再执行 `deyo auth login --api-key '...'`。
|
|
356
376
|
- `API key 无效或不存在`:重新创建新的 API key 后再登录。
|
|
357
377
|
- `剩余分钟不足`:完整转写会扣减分钟余额,需要先补充分钟包;YouTube 字幕直出不会走完整转写扣减。
|
|
378
|
+
- Apple Podcasts 节目主页、小宇宙播客主页或 B 站合集返回 422:请在原平台打开具体单集或视频,再把对应链接提交给 CLI;不要尝试把整套节目或合集作为一个任务重试。
|
|
358
379
|
- 本地文件无法识别 MIME:确认是单个普通音频或视频文件;必要时用 `--mime-type audio/mpeg`、`--mime-type video/mp4` 这类 `audio/*` 或 `video/*` 类型覆盖。
|
|
359
380
|
- 媒体检查失败、没有音频轨、ffprobe 失败:换一个包含可转写音轨的普通音视频文件后重试。
|
|
360
381
|
- 上传分片遇到签名过期或 403:CLI 会重新请求签名并重试;如果持续失败,重新运行命令。
|
|
361
382
|
- 任务创建后中断本地 CLI:这只会停止本地等待,不等于取消服务端转写任务;稍后可通过同一任务或历史记录继续查看结果。
|
|
362
383
|
- `--stream-transcript` 组合错误:确认最终格式是 `text`,并同时传入 `--progress-format jsonl`;SRT、VTT、JSON 和 `verbose_json` 不支持正文事件。
|
|
384
|
+
- 视频号提示扫码:桌面环境会尝试自动打开 Deyo 授权页;headless 环境请复制终端打印的 HTTPS 地址到浏览器完成扫码。如果扫码后仍提示授权状态变化,请重新执行一次原命令。
|
|
363
385
|
|
|
364
386
|
## 在 AI 工具中使用
|
|
365
387
|
|
|
@@ -385,3 +407,7 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
|
|
|
385
407
|
```bash
|
|
386
408
|
deyo --help
|
|
387
409
|
```
|
|
410
|
+
|
|
411
|
+
## 来源与合集
|
|
412
|
+
|
|
413
|
+
支持范围与网页一致:18 个命名平台(含 Instagram、看看新闻、腾讯视频等)、HTTP/HTTPS 通用媒体及单文件上传。视频号受功能开关控制。摘要合集返回 422 `UNSUPPORTED_CONTENT` 和 `mode`;旧 `COLLECTION_UNSUPPORTED` 兼容读取。红果整剧返回 `HONGGUO_EPISODE_REQUIRED` 与归属受控的 `parseToken`;多媒体候选返回 `MULTIPLE_MEDIA_SELECTION_REQUIRED`。请在网页明确选择单项或提交精确单集链接,不自动选集或批量转写。
|
package/deyo.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { createHash } from 'node:crypto'
|
|
|
5
5
|
import { createRequire } from 'node:module'
|
|
6
6
|
import { basename, dirname, extname, isAbsolute, resolve } from 'node:path'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
8
9
|
import process from 'node:process'
|
|
10
|
+
import { createInterface } from 'node:readline/promises'
|
|
9
11
|
import { handleSkillCommand } from './skill-manager.mjs'
|
|
10
12
|
|
|
11
13
|
const CLI_VERSION = createRequire(import.meta.url)('./package.json').version
|
|
@@ -18,13 +20,17 @@ const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
|
|
|
18
20
|
const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
|
|
19
21
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
|
20
22
|
const MAX_UPLOAD_CONCURRENCY = 3
|
|
21
|
-
const HASH_CHUNK_SIZE = 4 * 1024 * 1024
|
|
22
23
|
const UPLOAD_REDACTED_SOURCE_LABEL = 'upload:file'
|
|
23
24
|
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
|
|
24
25
|
const URL_SCHEME_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i
|
|
25
26
|
const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i
|
|
26
|
-
const SENSITIVE_UPLOAD_RESULT_KEY_PATTERN = /(?:^|_)(?:objectKey|object_key|audioUrl|audio_url|downloadUrl|download_url|signedUrl|signed_url|uploadUrl|upload_url|sourceUrl|source_url|url)$/i
|
|
27
|
+
const SENSITIVE_UPLOAD_RESULT_KEY_PATTERN = /(?:^|_)(?:uploadId|upload_id|objectKey|object_key|ossKey|oss_key|multipartUploadId|multipart_upload_id|etag|audioUrl|audio_url|downloadUrl|download_url|signedUrl|signed_url|uploadUrl|upload_url|sourceUrl|source_url|url)$/i
|
|
27
28
|
const SIGNED_URL_PARAM_PATTERN = /(?:OSSAccessKeyId|Signature|Expires|x-oss-signature|x-oss-credential|x-oss-expires|x-amz-signature)=/i
|
|
29
|
+
const WECHAT_CHANNELS_SENSITIVE_RESULT_KEY_PATTERN = /(?:cookie|credential|generation|mediaUrl|media_url|audioUrl|audio_url|generalToken|general_token|hy_source|hy_user|hy_token|(?:^|_)eid(?:$|_)|(?:^|_)token(?:$|_))/i
|
|
30
|
+
const WECHAT_CHANNELS_SIGNED_MEDIA_PATTERN = /(?:finder\.video\.qq\.com|encfilekey=|[?&]token=)/i
|
|
31
|
+
const WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE = 'WECHAT_CHANNELS_CREDENTIAL_REQUIRED'
|
|
32
|
+
const MAINTENANCE_ERROR_CODES = new Set(['SERVICE_MAINTENANCE', 'MAINTENANCE_STATE_UNAVAILABLE'])
|
|
33
|
+
const WECHAT_CHANNELS_AUTH_TIMEOUT_MS = 5 * 60 * 1000
|
|
28
34
|
const MIME_TYPES_BY_EXTENSION = new Map([
|
|
29
35
|
['.aac', 'audio/aac'],
|
|
30
36
|
['.aif', 'audio/aiff'],
|
|
@@ -58,9 +64,12 @@ class ReportedCliError extends Error {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
class HttpCliError extends Error {
|
|
61
|
-
constructor(message, status) {
|
|
67
|
+
constructor(message, status, details = {}) {
|
|
62
68
|
super(message)
|
|
63
69
|
this.status = status
|
|
70
|
+
this.errorCode = details.errorCode || null
|
|
71
|
+
this.presentationUrl = details.presentationUrl || null
|
|
72
|
+
this.retryAfterMs = details.retryAfterMs || null
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
|
|
@@ -87,7 +96,7 @@ function printUsage() {
|
|
|
87
96
|
转写选项:
|
|
88
97
|
--api-key <key> 可选,Deyo API key
|
|
89
98
|
-O, --output <path> 可选,输出文件路径
|
|
90
|
-
--source <name> 可选,显式指定来源:xiaoyuzhou | ximalaya | bilibili | douyin | xiaohongshu | youtube | apple-podcasts | twitter
|
|
99
|
+
--source <name> 可选,显式指定来源:xiaoyuzhou | ximalaya | bilibili | douyin | xiaohongshu | youtube | apple-podcasts | twitter | tiktok | instagram | kankanews | wechat_channels | wechat_official_account | hongguo | yanxiu | chaoxing | kuaishou | tencent_video | direct_media
|
|
91
100
|
--file <path> 可选,显式指定本地单个音视频文件
|
|
92
101
|
--mime-type <type> 可选,上传本地文件时显式指定 MIME,例如 audio/mpeg 或 video/mp4
|
|
93
102
|
--language <value> 可选,显式指定转写语言;省略时自动检测
|
|
@@ -114,9 +123,16 @@ API key 读取优先级:
|
|
|
114
123
|
2. 环境变量 DEYO_API_KEY
|
|
115
124
|
3. 本地配置文件(先执行 deyo auth login)
|
|
116
125
|
|
|
126
|
+
支持来源:
|
|
127
|
+
Bilibili、抖音、小红书、小宇宙、喜马拉雅、YouTube、Apple Podcasts、Twitter/X、TikTok、Instagram、微信视频号、微信公众号、红果、研直播、超星、快手、腾讯视频、看看新闻;HTTP/HTTPS 通用媒体链接;本地单文件。
|
|
128
|
+
合集不批量转写;红果及多媒体候选请在网页选一项或提交精确单集链接。
|
|
117
129
|
本地文件说明:
|
|
118
130
|
v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
|
|
119
131
|
可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
|
|
132
|
+
|
|
133
|
+
B站试看说明:
|
|
134
|
+
B站专属视频会先显示完整时长、可转写时长和预计扣除分钟。
|
|
135
|
+
只有交互式终端中明确确认后才会创建任务;拒绝或非交互环境均不会扣分钟。
|
|
120
136
|
`)
|
|
121
137
|
}
|
|
122
138
|
|
|
@@ -345,6 +361,9 @@ function isRetryableRequestError(error) {
|
|
|
345
361
|
}
|
|
346
362
|
|
|
347
363
|
if (error instanceof HttpCliError) {
|
|
364
|
+
if (MAINTENANCE_ERROR_CODES.has(error.errorCode)) {
|
|
365
|
+
return false
|
|
366
|
+
}
|
|
348
367
|
return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
|
|
349
368
|
}
|
|
350
369
|
|
|
@@ -471,15 +490,35 @@ function formatBytes(value) {
|
|
|
471
490
|
}
|
|
472
491
|
|
|
473
492
|
function redactUploadSourceUrl(sourceType, sourceUrl) {
|
|
474
|
-
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:
|
|
493
|
+
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:')) {
|
|
475
494
|
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
476
495
|
}
|
|
477
496
|
|
|
497
|
+
if (sourceType === 'direct_media') {
|
|
498
|
+
try {
|
|
499
|
+
const parsed = new URL(String(sourceUrl || ''))
|
|
500
|
+
|
|
501
|
+
if (
|
|
502
|
+
parsed.protocol !== 'https:'
|
|
503
|
+
|| parsed.username
|
|
504
|
+
|| parsed.password
|
|
505
|
+
|| parsed.hash
|
|
506
|
+
) {
|
|
507
|
+
return '音频/视频文件链接已隐藏'
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return `${parsed.origin}${parsed.pathname}`
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return '音频/视频文件链接已隐藏'
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
478
517
|
return sourceUrl || null
|
|
479
518
|
}
|
|
480
519
|
|
|
481
520
|
function isSensitiveUploadResultString(key, value, sourceType = null) {
|
|
482
|
-
if (value.startsWith('upload:
|
|
521
|
+
if (value.startsWith('upload:')) {
|
|
483
522
|
return true
|
|
484
523
|
}
|
|
485
524
|
|
|
@@ -507,6 +546,12 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
|
|
|
507
546
|
}
|
|
508
547
|
|
|
509
548
|
if (!value || typeof value !== 'object') {
|
|
549
|
+
if (inheritedSourceType === 'wechat_channels' && typeof value === 'string') {
|
|
550
|
+
return WECHAT_CHANNELS_SIGNED_MEDIA_PATTERN.test(value)
|
|
551
|
+
? '[视频号受保护媒体地址已隐藏]'
|
|
552
|
+
: value
|
|
553
|
+
}
|
|
554
|
+
|
|
510
555
|
if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
|
|
511
556
|
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
512
557
|
}
|
|
@@ -518,6 +563,10 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
|
|
|
518
563
|
const next = {}
|
|
519
564
|
|
|
520
565
|
for (const [key, item] of Object.entries(value)) {
|
|
566
|
+
if (sourceType === 'wechat_channels' && WECHAT_CHANNELS_SENSITIVE_RESULT_KEY_PATTERN.test(key)) {
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
|
|
521
570
|
if (key === 'sourceUrl') {
|
|
522
571
|
next[key] = redactUploadSourceUrl(sourceType, item)
|
|
523
572
|
continue
|
|
@@ -793,20 +842,59 @@ function resolveApiKey(explicitApiKey, storedConfig) {
|
|
|
793
842
|
return null
|
|
794
843
|
}
|
|
795
844
|
|
|
796
|
-
|
|
845
|
+
function normalizeWechatChannelsPresentationUrl(value) {
|
|
846
|
+
if (typeof value !== 'string' || value.length > 4096) return null
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
const presentationUrl = new URL(value)
|
|
850
|
+
if (presentationUrl.protocol !== 'https:' || presentationUrl.username || presentationUrl.password || presentationUrl.hash) return null
|
|
851
|
+
if (presentationUrl.pathname !== '/parse') return null
|
|
852
|
+
if (presentationUrl.searchParams.size !== 2) return null
|
|
853
|
+
if (presentationUrl.searchParams.getAll('source').length !== 1) return null
|
|
854
|
+
if (presentationUrl.searchParams.getAll('token').length !== 1) return null
|
|
855
|
+
if ([...presentationUrl.searchParams.keys()].some(key => key !== 'source' && key !== 'token')) return null
|
|
856
|
+
if (presentationUrl.searchParams.get('source') !== 'wechat_channels') return null
|
|
857
|
+
|
|
858
|
+
const token = presentationUrl.searchParams.get('token') || ''
|
|
859
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(token)) return null
|
|
860
|
+
|
|
861
|
+
return presentationUrl.toString()
|
|
862
|
+
} catch {
|
|
863
|
+
return null
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function readErrorResponse(response) {
|
|
797
868
|
const contentType = response.headers.get('content-type') || ''
|
|
798
869
|
|
|
799
870
|
if (contentType.includes('application/json')) {
|
|
800
871
|
const data = await response.json().catch(() => null)
|
|
801
872
|
const message = data?.message || data?.statusMessage || data?.error?.message
|
|
873
|
+
const rawErrorCode = data?.data?.errorCode
|
|
874
|
+
const errorCode = typeof rawErrorCode === 'string' && /^[A-Z0-9_]{1,128}$/.test(rawErrorCode)
|
|
875
|
+
? rawErrorCode
|
|
876
|
+
: null
|
|
877
|
+
const presentationUrl = errorCode === WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE
|
|
878
|
+
? normalizeWechatChannelsPresentationUrl(data?.data?.presentationUrl)
|
|
879
|
+
: null
|
|
880
|
+
const rawRetryAfterMs = Number(data?.data?.retryAfterMs)
|
|
802
881
|
|
|
803
|
-
|
|
804
|
-
|
|
882
|
+
return {
|
|
883
|
+
message: message ? String(message) : `${response.status} ${response.statusText}`,
|
|
884
|
+
errorCode,
|
|
885
|
+
presentationUrl,
|
|
886
|
+
retryAfterMs: Number.isSafeInteger(rawRetryAfterMs) && rawRetryAfterMs >= 1000 && rawRetryAfterMs <= 5000
|
|
887
|
+
? rawRetryAfterMs
|
|
888
|
+
: null,
|
|
805
889
|
}
|
|
806
890
|
}
|
|
807
891
|
|
|
808
892
|
const text = await response.text().catch(() => '')
|
|
809
|
-
return text || `${response.status} ${response.statusText}`
|
|
893
|
+
return { message: text || `${response.status} ${response.statusText}` }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function readErrorMessage(response) {
|
|
897
|
+
return (await readErrorResponse(response)).message
|
|
810
898
|
}
|
|
811
899
|
|
|
812
900
|
async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
@@ -823,7 +911,8 @@ async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
|
823
911
|
})
|
|
824
912
|
|
|
825
913
|
if (!response.ok) {
|
|
826
|
-
|
|
914
|
+
const details = await readErrorResponse(response)
|
|
915
|
+
throw new HttpCliError(details.message, response.status, details)
|
|
827
916
|
}
|
|
828
917
|
|
|
829
918
|
return response.json()
|
|
@@ -971,6 +1060,27 @@ function buildTerminalMessage(status, errorMessage) {
|
|
|
971
1060
|
return errorMessage ? `任务结束:${errorMessage}` : `任务结束:${formatStatus(status)}`
|
|
972
1061
|
}
|
|
973
1062
|
|
|
1063
|
+
const SAFE_TRANSCRIPTION_FAILURE_MESSAGES = new Map([
|
|
1064
|
+
['ASR_CAPABILITY_UNSUPPORTED', '当前转写运行环境暂不支持这项任务,请等待服务升级后再试。'],
|
|
1065
|
+
['ASR_IDENTITY_CHANGED', '转写运行环境在任务期间发生变化,为保护结果一致性,本次任务已停止。'],
|
|
1066
|
+
['ASR_TIMELINE_EVIDENCE_MISMATCH', '转写时间轴证据无法安全对齐,可以使用当前订单完整重新转写。'],
|
|
1067
|
+
['ASR_CONTINUE_EVIDENCE_MISMATCH', '现有进度缺少可安全续传的证据,可以使用当前订单完整重新转写。'],
|
|
1068
|
+
['ASR_RESPONSE_CONTRACT_MISMATCH', '转写运行环境协议不兼容,可以在服务更新后使用当前订单完整重新转写。'],
|
|
1069
|
+
['SOURCE_MEDIA_REDIRECT_LIMIT_EXCEEDED', '媒体地址重定向次数过多,暂时无法下载,可以稍后使用当前订单完整重新转写。'],
|
|
1070
|
+
['SOURCE_MEDIA_TIMEOUT', '媒体下载超时,可以稍后使用当前订单完整重新转写。'],
|
|
1071
|
+
['SOURCE_MEDIA_DOWNLOAD_FAILED', '媒体下载失败,可以稍后使用当前订单完整重新转写。'],
|
|
1072
|
+
['SOURCE_MEDIA_ALL_CANDIDATES_FAILED', '所有可用媒体地址均下载失败,可以稍后使用当前订单完整重新转写。'],
|
|
1073
|
+
['TRANSCRIPTION_FAILED', '转写失败,请稍后再试。'],
|
|
1074
|
+
])
|
|
1075
|
+
|
|
1076
|
+
function getSafeTranscriptionFailureMessage(data) {
|
|
1077
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1078
|
+
return '转写失败'
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return SAFE_TRANSCRIPTION_FAILURE_MESSAGES.get(data.errorCode) || '转写失败'
|
|
1082
|
+
}
|
|
1083
|
+
|
|
974
1084
|
function normalizeProgressValue(progress, finishedChunks, totalChunks) {
|
|
975
1085
|
if (Number.isFinite(progress) && progress >= 0) {
|
|
976
1086
|
return Math.max(0, Math.min(1, progress))
|
|
@@ -1024,14 +1134,6 @@ function buildUploadProgressMessage(input = {}) {
|
|
|
1024
1134
|
return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
|
|
1025
1135
|
}
|
|
1026
1136
|
|
|
1027
|
-
function buildHashingMessage(input = {}) {
|
|
1028
|
-
const hashedBytes = Number(input.hashedBytes) || 0
|
|
1029
|
-
const totalBytes = Number(input.totalBytes) || 0
|
|
1030
|
-
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
1031
|
-
|
|
1032
|
-
return `正在计算文件哈希 ${percent}%`
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
1137
|
function getUploadReadyMessage(input = {}) {
|
|
1036
1138
|
const title = input.title || input.filename || '上传文件'
|
|
1037
1139
|
const duration = Number(input.durationSeconds)
|
|
@@ -1054,7 +1156,7 @@ function getUploadTaskFromCheckResult(checkResult) {
|
|
|
1054
1156
|
return null
|
|
1055
1157
|
}
|
|
1056
1158
|
|
|
1057
|
-
function getUploadReadySummary(checkResult, fileInput
|
|
1159
|
+
function getUploadReadySummary(checkResult, fileInput) {
|
|
1058
1160
|
const task = getUploadTaskFromCheckResult(checkResult)
|
|
1059
1161
|
const upload = checkResult?.upload || {}
|
|
1060
1162
|
const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
|
|
@@ -1067,7 +1169,6 @@ function getUploadReadySummary(checkResult, fileInput, uploadId) {
|
|
|
1067
1169
|
const requiredMinutes = Number(checkResult?.requiredMinutes)
|
|
1068
1170
|
|
|
1069
1171
|
return {
|
|
1070
|
-
uploadId,
|
|
1071
1172
|
filename: upload.filename || fileInput.filename,
|
|
1072
1173
|
title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
|
|
1073
1174
|
durationSeconds: normalizedDurationSeconds,
|
|
@@ -1136,36 +1237,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1136
1237
|
writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
|
|
1137
1238
|
writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
|
|
1138
1239
|
},
|
|
1139
|
-
reportUploadHashing(input = {}) {
|
|
1140
|
-
const message = buildHashingMessage(input)
|
|
1141
|
-
const hashedBytes = Number(input.hashedBytes) || 0
|
|
1142
|
-
const totalBytes = Number(input.totalBytes) || 0
|
|
1143
|
-
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
1144
|
-
|
|
1145
|
-
if (resolvedMode === 'interactive') {
|
|
1146
|
-
writeInline(outputState, message)
|
|
1147
|
-
return
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
if (resolvedMode === 'jsonl') {
|
|
1151
|
-
writeJsonEvent({
|
|
1152
|
-
event: 'upload.hashing',
|
|
1153
|
-
message,
|
|
1154
|
-
filename: input.filename || null,
|
|
1155
|
-
hashedBytes,
|
|
1156
|
-
totalBytes,
|
|
1157
|
-
percent,
|
|
1158
|
-
})
|
|
1159
|
-
return
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
if (message === lastTextProgressMessage) {
|
|
1163
|
-
return
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
lastTextProgressMessage = message
|
|
1167
|
-
writeTextMessage(message)
|
|
1168
|
-
},
|
|
1169
1240
|
reportUploadStarted(input = {}) {
|
|
1170
1241
|
lastTextProgressMessage = ''
|
|
1171
1242
|
const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
|
|
@@ -1175,7 +1246,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1175
1246
|
filename: input.filename || null,
|
|
1176
1247
|
mimeType: input.mimeType || null,
|
|
1177
1248
|
totalBytes: Number(input.totalBytes) || 0,
|
|
1178
|
-
uploadId: input.uploadId || null,
|
|
1179
1249
|
partSize: Number(input.partSize) || null,
|
|
1180
1250
|
totalParts: Number(input.totalParts) || null,
|
|
1181
1251
|
})
|
|
@@ -1218,7 +1288,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1218
1288
|
|
|
1219
1289
|
emitTextOrJson(message, {
|
|
1220
1290
|
event: 'upload.completed',
|
|
1221
|
-
uploadId: input.uploadId || null,
|
|
1222
1291
|
filename: input.filename || null,
|
|
1223
1292
|
totalBytes: Number(input.totalBytes) || 0,
|
|
1224
1293
|
})
|
|
@@ -1228,7 +1297,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1228
1297
|
|
|
1229
1298
|
emitTextOrJson(message, {
|
|
1230
1299
|
event: 'upload.checking',
|
|
1231
|
-
uploadId: input.uploadId || null,
|
|
1232
1300
|
})
|
|
1233
1301
|
},
|
|
1234
1302
|
reportUploadReady(input = {}) {
|
|
@@ -1236,7 +1304,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1236
1304
|
|
|
1237
1305
|
emitTextOrJson(message, {
|
|
1238
1306
|
event: 'upload.ready',
|
|
1239
|
-
uploadId: input.uploadId || null,
|
|
1240
1307
|
sourceType: 'upload',
|
|
1241
1308
|
sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
|
|
1242
1309
|
title: input.title || input.filename || null,
|
|
@@ -1250,14 +1317,12 @@ function createProgressReporter(progressFormat) {
|
|
|
1250
1317
|
|
|
1251
1318
|
emitTextOrJson(message, {
|
|
1252
1319
|
event: 'upload.aborted',
|
|
1253
|
-
uploadId: input.uploadId || null,
|
|
1254
1320
|
})
|
|
1255
1321
|
},
|
|
1256
1322
|
reportUploadFailed(message, input = {}) {
|
|
1257
1323
|
finishInline(outputState)
|
|
1258
1324
|
emitTextOrJson(message, {
|
|
1259
1325
|
event: 'upload.failed',
|
|
1260
|
-
uploadId: input.uploadId || null,
|
|
1261
1326
|
error: message,
|
|
1262
1327
|
})
|
|
1263
1328
|
},
|
|
@@ -1381,6 +1446,17 @@ function createProgressReporter(progressFormat) {
|
|
|
1381
1446
|
event: 'task.notice',
|
|
1382
1447
|
})
|
|
1383
1448
|
},
|
|
1449
|
+
reportWechatChannelsCredentialRequired(presentationUrl, browserOpened) {
|
|
1450
|
+
const message = browserOpened
|
|
1451
|
+
? `已尝试打开视频号扫码授权页;如浏览器未自动打开,请复制:${presentationUrl}`
|
|
1452
|
+
: `请复制到浏览器完成视频号扫码授权:${presentationUrl}`
|
|
1453
|
+
emitTextOrJson(message, {
|
|
1454
|
+
event: 'wechat_channels.credential_required',
|
|
1455
|
+
errorCode: WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE,
|
|
1456
|
+
presentationUrl,
|
|
1457
|
+
browserOpened,
|
|
1458
|
+
})
|
|
1459
|
+
},
|
|
1384
1460
|
close() {
|
|
1385
1461
|
finishInline(outputState)
|
|
1386
1462
|
},
|
|
@@ -1595,7 +1671,12 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
|
|
|
1595
1671
|
if (data?.status === 'transcribing') {
|
|
1596
1672
|
renderProgress(data)
|
|
1597
1673
|
} else if (TERMINAL_STATUSES.has(data?.status)) {
|
|
1598
|
-
applyTerminal(
|
|
1674
|
+
applyTerminal(
|
|
1675
|
+
data.status,
|
|
1676
|
+
data.status === 'failed'
|
|
1677
|
+
? getSafeTranscriptionFailureMessage(data)
|
|
1678
|
+
: data?.error || data?.message || null,
|
|
1679
|
+
)
|
|
1599
1680
|
} else {
|
|
1600
1681
|
applyStatus(data?.status)
|
|
1601
1682
|
}
|
|
@@ -1623,7 +1704,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
|
|
|
1623
1704
|
}
|
|
1624
1705
|
|
|
1625
1706
|
if (event === 'job.failed') {
|
|
1626
|
-
applyTerminal('failed', data
|
|
1707
|
+
applyTerminal('failed', getSafeTranscriptionFailureMessage(data))
|
|
1627
1708
|
return true
|
|
1628
1709
|
}
|
|
1629
1710
|
|
|
@@ -1707,48 +1788,6 @@ function assertNotAborted(signal) {
|
|
|
1707
1788
|
}
|
|
1708
1789
|
}
|
|
1709
1790
|
|
|
1710
|
-
async function calculateFileSha256(fileInput, reporter, signal) {
|
|
1711
|
-
const hash = createHash('sha256')
|
|
1712
|
-
const fileHandle = await open(fileInput.path, 'r')
|
|
1713
|
-
let hashedBytes = 0
|
|
1714
|
-
|
|
1715
|
-
reporter.reportUploadHashing({
|
|
1716
|
-
filename: fileInput.filename,
|
|
1717
|
-
hashedBytes,
|
|
1718
|
-
totalBytes: fileInput.sizeBytes,
|
|
1719
|
-
})
|
|
1720
|
-
|
|
1721
|
-
try {
|
|
1722
|
-
while (hashedBytes < fileInput.sizeBytes) {
|
|
1723
|
-
assertNotAborted(signal)
|
|
1724
|
-
|
|
1725
|
-
const length = Math.min(HASH_CHUNK_SIZE, fileInput.sizeBytes - hashedBytes)
|
|
1726
|
-
const buffer = Buffer.allocUnsafe(length)
|
|
1727
|
-
const { bytesRead } = await fileHandle.read(buffer, 0, length, hashedBytes)
|
|
1728
|
-
|
|
1729
|
-
if (bytesRead <= 0) {
|
|
1730
|
-
break
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
hash.update(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead))
|
|
1734
|
-
hashedBytes += bytesRead
|
|
1735
|
-
reporter.reportUploadHashing({
|
|
1736
|
-
filename: fileInput.filename,
|
|
1737
|
-
hashedBytes,
|
|
1738
|
-
totalBytes: fileInput.sizeBytes,
|
|
1739
|
-
})
|
|
1740
|
-
}
|
|
1741
|
-
} finally {
|
|
1742
|
-
await fileHandle.close().catch(() => {})
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
if (hashedBytes !== fileInput.sizeBytes) {
|
|
1746
|
-
throw new Error('读取本地文件计算哈希时大小不一致,请重新选择文件')
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
return hash.digest('hex')
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
1791
|
async function readFileChunk(filePath, start, length, signal) {
|
|
1753
1792
|
assertNotAborted(signal)
|
|
1754
1793
|
|
|
@@ -1825,8 +1864,7 @@ function createRunContext(baseUrl, apiKey, reporter) {
|
|
|
1825
1864
|
}
|
|
1826
1865
|
|
|
1827
1866
|
if (!state.uploadAbortAllowed && state.uploadId) {
|
|
1828
|
-
|
|
1829
|
-
reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
|
|
1867
|
+
reporter.reportNotice('已停止本地等待,服务端正在处理上传')
|
|
1830
1868
|
reporter.close()
|
|
1831
1869
|
return
|
|
1832
1870
|
}
|
|
@@ -2019,7 +2057,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2019
2057
|
let uploadId = ''
|
|
2020
2058
|
|
|
2021
2059
|
try {
|
|
2022
|
-
const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
|
|
2023
2060
|
const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
|
|
2024
2061
|
method: 'POST',
|
|
2025
2062
|
signal: runContext.signal,
|
|
@@ -2028,7 +2065,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2028
2065
|
filename: fileInput.filename,
|
|
2029
2066
|
mimeType: fileInput.mimeType,
|
|
2030
2067
|
sizeBytes: fileInput.sizeBytes,
|
|
2031
|
-
fileHash,
|
|
2032
2068
|
},
|
|
2033
2069
|
})
|
|
2034
2070
|
|
|
@@ -2080,14 +2116,15 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2080
2116
|
runContext.state.uploadAbortAllowed = false
|
|
2081
2117
|
reporter.reportUploadChecking({ uploadId })
|
|
2082
2118
|
|
|
2119
|
+
const checkBody = { uploadId }
|
|
2120
|
+
if (args.language) {
|
|
2121
|
+
checkBody.language = args.language
|
|
2122
|
+
}
|
|
2083
2123
|
const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
|
|
2084
2124
|
method: 'POST',
|
|
2085
2125
|
signal: runContext.signal,
|
|
2086
2126
|
retry: true,
|
|
2087
|
-
body:
|
|
2088
|
-
uploadId,
|
|
2089
|
-
...(args.language ? { language: args.language } : {}),
|
|
2090
|
-
},
|
|
2127
|
+
body: checkBody,
|
|
2091
2128
|
})
|
|
2092
2129
|
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
2093
2130
|
|
|
@@ -2096,7 +2133,7 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2096
2133
|
runContext.state.taskCreated = true
|
|
2097
2134
|
}
|
|
2098
2135
|
|
|
2099
|
-
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput
|
|
2136
|
+
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput))
|
|
2100
2137
|
|
|
2101
2138
|
return {
|
|
2102
2139
|
...checkResult,
|
|
@@ -2193,9 +2230,11 @@ async function handleAuthCommand(argv) {
|
|
|
2193
2230
|
function buildUrlTaskCreatePayload(args, input) {
|
|
2194
2231
|
const createPayload = {
|
|
2195
2232
|
url: input.url,
|
|
2196
|
-
...(args.language ? { language: args.language } : {}),
|
|
2197
2233
|
}
|
|
2198
2234
|
|
|
2235
|
+
if (args.language) {
|
|
2236
|
+
createPayload.language = args.language
|
|
2237
|
+
}
|
|
2199
2238
|
if (args.source) {
|
|
2200
2239
|
createPayload.source = args.source
|
|
2201
2240
|
}
|
|
@@ -2203,13 +2242,102 @@ function buildUrlTaskCreatePayload(args, input) {
|
|
|
2203
2242
|
return createPayload
|
|
2204
2243
|
}
|
|
2205
2244
|
|
|
2206
|
-
|
|
2207
|
-
|
|
2245
|
+
function shouldPreflightBilibiliUrl(args, input) {
|
|
2246
|
+
if (args.source === 'bilibili') return true
|
|
2247
|
+
if (typeof input?.url !== 'string') return false
|
|
2248
|
+
if (/^bilibili:\/\/video\//i.test(input.url)) return true
|
|
2249
|
+
|
|
2250
|
+
try {
|
|
2251
|
+
const hostname = new URL(input.url).hostname.toLowerCase()
|
|
2252
|
+
return hostname === 'bilibili.com'
|
|
2253
|
+
|| hostname.endsWith('.bilibili.com')
|
|
2254
|
+
|| hostname === 'b23.tv'
|
|
2255
|
+
} catch {
|
|
2256
|
+
return false
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
async function confirmBilibiliTrial(preflight) {
|
|
2261
|
+
process.stderr.write(`${preflight.message || '这是B站专属视频,Deyo 只能转写试看部分'}\n`)
|
|
2262
|
+
process.stderr.write(`完整时长:${preflight.fullDurationLabel || `${preflight.fullDurationSeconds} 秒`}\n`)
|
|
2263
|
+
process.stderr.write(`可转写时长:${preflight.transcribableDurationLabel || `${preflight.transcribableDurationSeconds} 秒`}\n`)
|
|
2264
|
+
process.stderr.write(`本次预计扣除:${preflight.requiredMinutes} 分钟\n`)
|
|
2265
|
+
|
|
2266
|
+
if (!process.stdin.isTTY) {
|
|
2267
|
+
process.stderr.write('当前不是交互式终端,已取消且不会创建任务或扣除分钟。请在交互终端中重新执行并确认。\n')
|
|
2268
|
+
return false
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
const prompt = createInterface({
|
|
2272
|
+
input: process.stdin,
|
|
2273
|
+
output: process.stderr,
|
|
2274
|
+
})
|
|
2275
|
+
|
|
2276
|
+
try {
|
|
2277
|
+
const answer = (await prompt.question('仍然转写试看部分?请输入 y 确认 [y/N]:')).trim().toLowerCase()
|
|
2278
|
+
return answer === 'y' || answer === 'yes'
|
|
2279
|
+
} finally {
|
|
2280
|
+
prompt.close()
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
async function preflightBilibiliTask(baseUrl, apiKey, createPayload, runContext) {
|
|
2285
|
+
const preflight = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions/preflight', {
|
|
2208
2286
|
method: 'POST',
|
|
2209
2287
|
signal: runContext.signal,
|
|
2210
2288
|
body: createPayload,
|
|
2211
2289
|
})
|
|
2212
2290
|
|
|
2291
|
+
if (preflight?.confirmationRequired) {
|
|
2292
|
+
const confirmed = await confirmBilibiliTrial(preflight)
|
|
2293
|
+
|
|
2294
|
+
if (!confirmed) {
|
|
2295
|
+
if (process.stdin.isTTY) {
|
|
2296
|
+
process.stderr.write('已取消,不会创建任务或扣除分钟。\n')
|
|
2297
|
+
}
|
|
2298
|
+
throw new ReportedCliError('已取消')
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
if (!preflight?.parseToken) {
|
|
2303
|
+
throw new Error('服务端预检未返回有效的 parse token')
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
return createPayload.language
|
|
2307
|
+
? { parseToken: preflight.parseToken, language: createPayload.language }
|
|
2308
|
+
: { parseToken: preflight.parseToken }
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
|
|
2312
|
+
const createRequest = () => requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
|
|
2313
|
+
method: 'POST',
|
|
2314
|
+
signal: runContext.signal,
|
|
2315
|
+
body: createPayload,
|
|
2316
|
+
})
|
|
2317
|
+
let created
|
|
2318
|
+
|
|
2319
|
+
try {
|
|
2320
|
+
created = await createRequest()
|
|
2321
|
+
} catch (error) {
|
|
2322
|
+
if (!(error instanceof HttpCliError) || error.errorCode !== WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE) throw error
|
|
2323
|
+
if (!error.presentationUrl) throw new Error('服务端返回的视频号授权地址无效,请稍后重试')
|
|
2324
|
+
|
|
2325
|
+
const browserOpened = tryOpenSystemBrowser(error.presentationUrl)
|
|
2326
|
+
reporter.reportWechatChannelsCredentialRequired(error.presentationUrl, browserOpened)
|
|
2327
|
+
const parseToken = new URL(error.presentationUrl).searchParams.get('token')
|
|
2328
|
+
if (!parseToken) throw new Error('服务端返回的视频号解析地址无效,请稍后重试')
|
|
2329
|
+
await waitForParseResume(baseUrl, apiKey, parseToken, runContext, error.retryAfterMs)
|
|
2330
|
+
|
|
2331
|
+
try {
|
|
2332
|
+
created = await createRequest()
|
|
2333
|
+
} catch (replayError) {
|
|
2334
|
+
if (replayError instanceof HttpCliError && replayError.errorCode === WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE) {
|
|
2335
|
+
throw new Error('视频号授权状态已变化,请重新执行原命令')
|
|
2336
|
+
}
|
|
2337
|
+
throw replayError
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2213
2341
|
runContext.state.taskId = created.taskId || ''
|
|
2214
2342
|
runContext.state.taskCreated = true
|
|
2215
2343
|
reporter.reportTaskCreated(created)
|
|
@@ -2217,6 +2345,49 @@ async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContex
|
|
|
2217
2345
|
return created
|
|
2218
2346
|
}
|
|
2219
2347
|
|
|
2348
|
+
function tryOpenSystemBrowser(url) {
|
|
2349
|
+
const command = process.platform === 'darwin'
|
|
2350
|
+
? { file: 'open', args: [url] }
|
|
2351
|
+
: process.platform === 'win32'
|
|
2352
|
+
? { file: 'cmd', args: ['/c', 'start', '', url] }
|
|
2353
|
+
: { file: 'xdg-open', args: [url] }
|
|
2354
|
+
|
|
2355
|
+
try {
|
|
2356
|
+
const child = spawn(command.file, command.args, {
|
|
2357
|
+
detached: true,
|
|
2358
|
+
stdio: 'ignore',
|
|
2359
|
+
})
|
|
2360
|
+
child.once('error', () => {})
|
|
2361
|
+
child.unref()
|
|
2362
|
+
return true
|
|
2363
|
+
} catch {
|
|
2364
|
+
return false
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
async function waitForParseResume(baseUrl, apiKey, parseToken, runContext, initialRetryAfterMs) {
|
|
2369
|
+
const startedAt = Date.now()
|
|
2370
|
+
let retryAfterMs = Number.isSafeInteger(initialRetryAfterMs) ? initialRetryAfterMs : POLL_INTERVAL_MS
|
|
2371
|
+
|
|
2372
|
+
while (Date.now() - startedAt < WECHAT_CHANNELS_AUTH_TIMEOUT_MS) {
|
|
2373
|
+
await sleep(retryAfterMs)
|
|
2374
|
+
if (runContext.signal.aborted) throw createAbortError()
|
|
2375
|
+
|
|
2376
|
+
const status = await requestJson(baseUrl, apiKey, `/api/cli/parse-status?token=${encodeURIComponent(parseToken)}`, {
|
|
2377
|
+
signal: runContext.signal,
|
|
2378
|
+
retry: true,
|
|
2379
|
+
})
|
|
2380
|
+
|
|
2381
|
+
if (status?.status === 'ready') return
|
|
2382
|
+
if (status?.status !== 'pending') throw new Error('服务端返回了未知的解析状态')
|
|
2383
|
+
retryAfterMs = Number.isSafeInteger(status.retryAfterMs) && status.retryAfterMs >= 1000 && status.retryAfterMs <= 5000
|
|
2384
|
+
? status.retryAfterMs
|
|
2385
|
+
: POLL_INTERVAL_MS
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
throw new Error('等待视频号扫码授权超时,请重新执行原命令')
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2220
2391
|
function reportTaskStatusTransition(reporter, previousStatus, task) {
|
|
2221
2392
|
if (task.status === previousStatus) {
|
|
2222
2393
|
return task.status
|
|
@@ -2482,6 +2653,15 @@ async function handleTranscription(argv) {
|
|
|
2482
2653
|
}
|
|
2483
2654
|
} else {
|
|
2484
2655
|
createPayload = buildUrlTaskCreatePayload(args, input)
|
|
2656
|
+
|
|
2657
|
+
if (shouldPreflightBilibiliUrl(args, input)) {
|
|
2658
|
+
createPayload = await preflightBilibiliTask(
|
|
2659
|
+
baseUrl,
|
|
2660
|
+
resolvedApiKey.apiKey,
|
|
2661
|
+
createPayload,
|
|
2662
|
+
runContext,
|
|
2663
|
+
)
|
|
2664
|
+
}
|
|
2485
2665
|
}
|
|
2486
2666
|
|
|
2487
2667
|
if (!created) {
|
package/package.json
CHANGED