@casatwy/deyo 0.2.3 → 0.2.5
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 +28 -6
- package/deyo.mjs +290 -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
|
|
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 | kankanews | wechat_channels
|
|
91
100
|
--file <path> 可选,显式指定本地单个音视频文件
|
|
92
101
|
--mime-type <type> 可选,上传本地文件时显式指定 MIME,例如 audio/mpeg 或 video/mp4
|
|
93
102
|
--language <value> 可选,显式指定转写语言;省略时自动检测
|
|
@@ -117,6 +126,10 @@ API key 读取优先级:
|
|
|
117
126
|
本地文件说明:
|
|
118
127
|
v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
|
|
119
128
|
可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
|
|
129
|
+
|
|
130
|
+
B站试看说明:
|
|
131
|
+
B站专属视频会先显示完整时长、可转写时长和预计扣除分钟。
|
|
132
|
+
只有交互式终端中明确确认后才会创建任务;拒绝或非交互环境均不会扣分钟。
|
|
120
133
|
`)
|
|
121
134
|
}
|
|
122
135
|
|
|
@@ -345,6 +358,9 @@ function isRetryableRequestError(error) {
|
|
|
345
358
|
}
|
|
346
359
|
|
|
347
360
|
if (error instanceof HttpCliError) {
|
|
361
|
+
if (MAINTENANCE_ERROR_CODES.has(error.errorCode)) {
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
348
364
|
return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -471,15 +487,35 @@ function formatBytes(value) {
|
|
|
471
487
|
}
|
|
472
488
|
|
|
473
489
|
function redactUploadSourceUrl(sourceType, sourceUrl) {
|
|
474
|
-
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:
|
|
490
|
+
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:')) {
|
|
475
491
|
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
476
492
|
}
|
|
477
493
|
|
|
494
|
+
if (sourceType === 'direct_media') {
|
|
495
|
+
try {
|
|
496
|
+
const parsed = new URL(String(sourceUrl || ''))
|
|
497
|
+
|
|
498
|
+
if (
|
|
499
|
+
parsed.protocol !== 'https:'
|
|
500
|
+
|| parsed.username
|
|
501
|
+
|| parsed.password
|
|
502
|
+
|| parsed.hash
|
|
503
|
+
) {
|
|
504
|
+
return '音频/视频文件链接已隐藏'
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return `${parsed.origin}${parsed.pathname}`
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return '音频/视频文件链接已隐藏'
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
478
514
|
return sourceUrl || null
|
|
479
515
|
}
|
|
480
516
|
|
|
481
517
|
function isSensitiveUploadResultString(key, value, sourceType = null) {
|
|
482
|
-
if (value.startsWith('upload:
|
|
518
|
+
if (value.startsWith('upload:')) {
|
|
483
519
|
return true
|
|
484
520
|
}
|
|
485
521
|
|
|
@@ -507,6 +543,12 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
|
|
|
507
543
|
}
|
|
508
544
|
|
|
509
545
|
if (!value || typeof value !== 'object') {
|
|
546
|
+
if (inheritedSourceType === 'wechat_channels' && typeof value === 'string') {
|
|
547
|
+
return WECHAT_CHANNELS_SIGNED_MEDIA_PATTERN.test(value)
|
|
548
|
+
? '[视频号受保护媒体地址已隐藏]'
|
|
549
|
+
: value
|
|
550
|
+
}
|
|
551
|
+
|
|
510
552
|
if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
|
|
511
553
|
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
512
554
|
}
|
|
@@ -518,6 +560,10 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
|
|
|
518
560
|
const next = {}
|
|
519
561
|
|
|
520
562
|
for (const [key, item] of Object.entries(value)) {
|
|
563
|
+
if (sourceType === 'wechat_channels' && WECHAT_CHANNELS_SENSITIVE_RESULT_KEY_PATTERN.test(key)) {
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
|
|
521
567
|
if (key === 'sourceUrl') {
|
|
522
568
|
next[key] = redactUploadSourceUrl(sourceType, item)
|
|
523
569
|
continue
|
|
@@ -793,20 +839,59 @@ function resolveApiKey(explicitApiKey, storedConfig) {
|
|
|
793
839
|
return null
|
|
794
840
|
}
|
|
795
841
|
|
|
796
|
-
|
|
842
|
+
function normalizeWechatChannelsPresentationUrl(value) {
|
|
843
|
+
if (typeof value !== 'string' || value.length > 4096) return null
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const presentationUrl = new URL(value)
|
|
847
|
+
if (presentationUrl.protocol !== 'https:' || presentationUrl.username || presentationUrl.password || presentationUrl.hash) return null
|
|
848
|
+
if (presentationUrl.pathname !== '/parse') return null
|
|
849
|
+
if (presentationUrl.searchParams.size !== 2) return null
|
|
850
|
+
if (presentationUrl.searchParams.getAll('source').length !== 1) return null
|
|
851
|
+
if (presentationUrl.searchParams.getAll('token').length !== 1) return null
|
|
852
|
+
if ([...presentationUrl.searchParams.keys()].some(key => key !== 'source' && key !== 'token')) return null
|
|
853
|
+
if (presentationUrl.searchParams.get('source') !== 'wechat_channels') return null
|
|
854
|
+
|
|
855
|
+
const token = presentationUrl.searchParams.get('token') || ''
|
|
856
|
+
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
|
|
857
|
+
|
|
858
|
+
return presentationUrl.toString()
|
|
859
|
+
} catch {
|
|
860
|
+
return null
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function readErrorResponse(response) {
|
|
797
865
|
const contentType = response.headers.get('content-type') || ''
|
|
798
866
|
|
|
799
867
|
if (contentType.includes('application/json')) {
|
|
800
868
|
const data = await response.json().catch(() => null)
|
|
801
869
|
const message = data?.message || data?.statusMessage || data?.error?.message
|
|
870
|
+
const rawErrorCode = data?.data?.errorCode
|
|
871
|
+
const errorCode = typeof rawErrorCode === 'string' && /^[A-Z0-9_]{1,128}$/.test(rawErrorCode)
|
|
872
|
+
? rawErrorCode
|
|
873
|
+
: null
|
|
874
|
+
const presentationUrl = errorCode === WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE
|
|
875
|
+
? normalizeWechatChannelsPresentationUrl(data?.data?.presentationUrl)
|
|
876
|
+
: null
|
|
877
|
+
const rawRetryAfterMs = Number(data?.data?.retryAfterMs)
|
|
802
878
|
|
|
803
|
-
|
|
804
|
-
|
|
879
|
+
return {
|
|
880
|
+
message: message ? String(message) : `${response.status} ${response.statusText}`,
|
|
881
|
+
errorCode,
|
|
882
|
+
presentationUrl,
|
|
883
|
+
retryAfterMs: Number.isSafeInteger(rawRetryAfterMs) && rawRetryAfterMs >= 1000 && rawRetryAfterMs <= 5000
|
|
884
|
+
? rawRetryAfterMs
|
|
885
|
+
: null,
|
|
805
886
|
}
|
|
806
887
|
}
|
|
807
888
|
|
|
808
889
|
const text = await response.text().catch(() => '')
|
|
809
|
-
return text || `${response.status} ${response.statusText}`
|
|
890
|
+
return { message: text || `${response.status} ${response.statusText}` }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function readErrorMessage(response) {
|
|
894
|
+
return (await readErrorResponse(response)).message
|
|
810
895
|
}
|
|
811
896
|
|
|
812
897
|
async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
@@ -823,7 +908,8 @@ async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
|
823
908
|
})
|
|
824
909
|
|
|
825
910
|
if (!response.ok) {
|
|
826
|
-
|
|
911
|
+
const details = await readErrorResponse(response)
|
|
912
|
+
throw new HttpCliError(details.message, response.status, details)
|
|
827
913
|
}
|
|
828
914
|
|
|
829
915
|
return response.json()
|
|
@@ -971,6 +1057,27 @@ function buildTerminalMessage(status, errorMessage) {
|
|
|
971
1057
|
return errorMessage ? `任务结束:${errorMessage}` : `任务结束:${formatStatus(status)}`
|
|
972
1058
|
}
|
|
973
1059
|
|
|
1060
|
+
const SAFE_TRANSCRIPTION_FAILURE_MESSAGES = new Map([
|
|
1061
|
+
['ASR_CAPABILITY_UNSUPPORTED', '当前转写运行环境暂不支持这项任务,请等待服务升级后再试。'],
|
|
1062
|
+
['ASR_IDENTITY_CHANGED', '转写运行环境在任务期间发生变化,为保护结果一致性,本次任务已停止。'],
|
|
1063
|
+
['ASR_TIMELINE_EVIDENCE_MISMATCH', '转写时间轴证据无法安全对齐,可以使用当前订单完整重新转写。'],
|
|
1064
|
+
['ASR_CONTINUE_EVIDENCE_MISMATCH', '现有进度缺少可安全续传的证据,可以使用当前订单完整重新转写。'],
|
|
1065
|
+
['ASR_RESPONSE_CONTRACT_MISMATCH', '转写运行环境协议不兼容,可以在服务更新后使用当前订单完整重新转写。'],
|
|
1066
|
+
['SOURCE_MEDIA_REDIRECT_LIMIT_EXCEEDED', '媒体地址重定向次数过多,暂时无法下载,可以稍后使用当前订单完整重新转写。'],
|
|
1067
|
+
['SOURCE_MEDIA_TIMEOUT', '媒体下载超时,可以稍后使用当前订单完整重新转写。'],
|
|
1068
|
+
['SOURCE_MEDIA_DOWNLOAD_FAILED', '媒体下载失败,可以稍后使用当前订单完整重新转写。'],
|
|
1069
|
+
['SOURCE_MEDIA_ALL_CANDIDATES_FAILED', '所有可用媒体地址均下载失败,可以稍后使用当前订单完整重新转写。'],
|
|
1070
|
+
['TRANSCRIPTION_FAILED', '转写失败,请稍后再试。'],
|
|
1071
|
+
])
|
|
1072
|
+
|
|
1073
|
+
function getSafeTranscriptionFailureMessage(data) {
|
|
1074
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1075
|
+
return '转写失败'
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return SAFE_TRANSCRIPTION_FAILURE_MESSAGES.get(data.errorCode) || '转写失败'
|
|
1079
|
+
}
|
|
1080
|
+
|
|
974
1081
|
function normalizeProgressValue(progress, finishedChunks, totalChunks) {
|
|
975
1082
|
if (Number.isFinite(progress) && progress >= 0) {
|
|
976
1083
|
return Math.max(0, Math.min(1, progress))
|
|
@@ -1024,14 +1131,6 @@ function buildUploadProgressMessage(input = {}) {
|
|
|
1024
1131
|
return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
|
|
1025
1132
|
}
|
|
1026
1133
|
|
|
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
1134
|
function getUploadReadyMessage(input = {}) {
|
|
1036
1135
|
const title = input.title || input.filename || '上传文件'
|
|
1037
1136
|
const duration = Number(input.durationSeconds)
|
|
@@ -1054,7 +1153,7 @@ function getUploadTaskFromCheckResult(checkResult) {
|
|
|
1054
1153
|
return null
|
|
1055
1154
|
}
|
|
1056
1155
|
|
|
1057
|
-
function getUploadReadySummary(checkResult, fileInput
|
|
1156
|
+
function getUploadReadySummary(checkResult, fileInput) {
|
|
1058
1157
|
const task = getUploadTaskFromCheckResult(checkResult)
|
|
1059
1158
|
const upload = checkResult?.upload || {}
|
|
1060
1159
|
const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
|
|
@@ -1067,7 +1166,6 @@ function getUploadReadySummary(checkResult, fileInput, uploadId) {
|
|
|
1067
1166
|
const requiredMinutes = Number(checkResult?.requiredMinutes)
|
|
1068
1167
|
|
|
1069
1168
|
return {
|
|
1070
|
-
uploadId,
|
|
1071
1169
|
filename: upload.filename || fileInput.filename,
|
|
1072
1170
|
title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
|
|
1073
1171
|
durationSeconds: normalizedDurationSeconds,
|
|
@@ -1136,36 +1234,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1136
1234
|
writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
|
|
1137
1235
|
writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
|
|
1138
1236
|
},
|
|
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
1237
|
reportUploadStarted(input = {}) {
|
|
1170
1238
|
lastTextProgressMessage = ''
|
|
1171
1239
|
const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
|
|
@@ -1175,7 +1243,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1175
1243
|
filename: input.filename || null,
|
|
1176
1244
|
mimeType: input.mimeType || null,
|
|
1177
1245
|
totalBytes: Number(input.totalBytes) || 0,
|
|
1178
|
-
uploadId: input.uploadId || null,
|
|
1179
1246
|
partSize: Number(input.partSize) || null,
|
|
1180
1247
|
totalParts: Number(input.totalParts) || null,
|
|
1181
1248
|
})
|
|
@@ -1218,7 +1285,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1218
1285
|
|
|
1219
1286
|
emitTextOrJson(message, {
|
|
1220
1287
|
event: 'upload.completed',
|
|
1221
|
-
uploadId: input.uploadId || null,
|
|
1222
1288
|
filename: input.filename || null,
|
|
1223
1289
|
totalBytes: Number(input.totalBytes) || 0,
|
|
1224
1290
|
})
|
|
@@ -1228,7 +1294,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1228
1294
|
|
|
1229
1295
|
emitTextOrJson(message, {
|
|
1230
1296
|
event: 'upload.checking',
|
|
1231
|
-
uploadId: input.uploadId || null,
|
|
1232
1297
|
})
|
|
1233
1298
|
},
|
|
1234
1299
|
reportUploadReady(input = {}) {
|
|
@@ -1236,7 +1301,6 @@ function createProgressReporter(progressFormat) {
|
|
|
1236
1301
|
|
|
1237
1302
|
emitTextOrJson(message, {
|
|
1238
1303
|
event: 'upload.ready',
|
|
1239
|
-
uploadId: input.uploadId || null,
|
|
1240
1304
|
sourceType: 'upload',
|
|
1241
1305
|
sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
|
|
1242
1306
|
title: input.title || input.filename || null,
|
|
@@ -1250,14 +1314,12 @@ function createProgressReporter(progressFormat) {
|
|
|
1250
1314
|
|
|
1251
1315
|
emitTextOrJson(message, {
|
|
1252
1316
|
event: 'upload.aborted',
|
|
1253
|
-
uploadId: input.uploadId || null,
|
|
1254
1317
|
})
|
|
1255
1318
|
},
|
|
1256
1319
|
reportUploadFailed(message, input = {}) {
|
|
1257
1320
|
finishInline(outputState)
|
|
1258
1321
|
emitTextOrJson(message, {
|
|
1259
1322
|
event: 'upload.failed',
|
|
1260
|
-
uploadId: input.uploadId || null,
|
|
1261
1323
|
error: message,
|
|
1262
1324
|
})
|
|
1263
1325
|
},
|
|
@@ -1381,6 +1443,17 @@ function createProgressReporter(progressFormat) {
|
|
|
1381
1443
|
event: 'task.notice',
|
|
1382
1444
|
})
|
|
1383
1445
|
},
|
|
1446
|
+
reportWechatChannelsCredentialRequired(presentationUrl, browserOpened) {
|
|
1447
|
+
const message = browserOpened
|
|
1448
|
+
? `已尝试打开视频号扫码授权页;如浏览器未自动打开,请复制:${presentationUrl}`
|
|
1449
|
+
: `请复制到浏览器完成视频号扫码授权:${presentationUrl}`
|
|
1450
|
+
emitTextOrJson(message, {
|
|
1451
|
+
event: 'wechat_channels.credential_required',
|
|
1452
|
+
errorCode: WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE,
|
|
1453
|
+
presentationUrl,
|
|
1454
|
+
browserOpened,
|
|
1455
|
+
})
|
|
1456
|
+
},
|
|
1384
1457
|
close() {
|
|
1385
1458
|
finishInline(outputState)
|
|
1386
1459
|
},
|
|
@@ -1595,7 +1668,12 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
|
|
|
1595
1668
|
if (data?.status === 'transcribing') {
|
|
1596
1669
|
renderProgress(data)
|
|
1597
1670
|
} else if (TERMINAL_STATUSES.has(data?.status)) {
|
|
1598
|
-
applyTerminal(
|
|
1671
|
+
applyTerminal(
|
|
1672
|
+
data.status,
|
|
1673
|
+
data.status === 'failed'
|
|
1674
|
+
? getSafeTranscriptionFailureMessage(data)
|
|
1675
|
+
: data?.error || data?.message || null,
|
|
1676
|
+
)
|
|
1599
1677
|
} else {
|
|
1600
1678
|
applyStatus(data?.status)
|
|
1601
1679
|
}
|
|
@@ -1623,7 +1701,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
|
|
|
1623
1701
|
}
|
|
1624
1702
|
|
|
1625
1703
|
if (event === 'job.failed') {
|
|
1626
|
-
applyTerminal('failed', data
|
|
1704
|
+
applyTerminal('failed', getSafeTranscriptionFailureMessage(data))
|
|
1627
1705
|
return true
|
|
1628
1706
|
}
|
|
1629
1707
|
|
|
@@ -1707,48 +1785,6 @@ function assertNotAborted(signal) {
|
|
|
1707
1785
|
}
|
|
1708
1786
|
}
|
|
1709
1787
|
|
|
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
1788
|
async function readFileChunk(filePath, start, length, signal) {
|
|
1753
1789
|
assertNotAborted(signal)
|
|
1754
1790
|
|
|
@@ -1825,8 +1861,7 @@ function createRunContext(baseUrl, apiKey, reporter) {
|
|
|
1825
1861
|
}
|
|
1826
1862
|
|
|
1827
1863
|
if (!state.uploadAbortAllowed && state.uploadId) {
|
|
1828
|
-
|
|
1829
|
-
reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
|
|
1864
|
+
reporter.reportNotice('已停止本地等待,服务端正在处理上传')
|
|
1830
1865
|
reporter.close()
|
|
1831
1866
|
return
|
|
1832
1867
|
}
|
|
@@ -2019,7 +2054,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2019
2054
|
let uploadId = ''
|
|
2020
2055
|
|
|
2021
2056
|
try {
|
|
2022
|
-
const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
|
|
2023
2057
|
const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
|
|
2024
2058
|
method: 'POST',
|
|
2025
2059
|
signal: runContext.signal,
|
|
@@ -2028,7 +2062,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2028
2062
|
filename: fileInput.filename,
|
|
2029
2063
|
mimeType: fileInput.mimeType,
|
|
2030
2064
|
sizeBytes: fileInput.sizeBytes,
|
|
2031
|
-
fileHash,
|
|
2032
2065
|
},
|
|
2033
2066
|
})
|
|
2034
2067
|
|
|
@@ -2080,14 +2113,15 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2080
2113
|
runContext.state.uploadAbortAllowed = false
|
|
2081
2114
|
reporter.reportUploadChecking({ uploadId })
|
|
2082
2115
|
|
|
2116
|
+
const checkBody = { uploadId }
|
|
2117
|
+
if (args.language) {
|
|
2118
|
+
checkBody.language = args.language
|
|
2119
|
+
}
|
|
2083
2120
|
const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
|
|
2084
2121
|
method: 'POST',
|
|
2085
2122
|
signal: runContext.signal,
|
|
2086
2123
|
retry: true,
|
|
2087
|
-
body:
|
|
2088
|
-
uploadId,
|
|
2089
|
-
...(args.language ? { language: args.language } : {}),
|
|
2090
|
-
},
|
|
2124
|
+
body: checkBody,
|
|
2091
2125
|
})
|
|
2092
2126
|
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
2093
2127
|
|
|
@@ -2096,7 +2130,7 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
|
|
|
2096
2130
|
runContext.state.taskCreated = true
|
|
2097
2131
|
}
|
|
2098
2132
|
|
|
2099
|
-
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput
|
|
2133
|
+
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput))
|
|
2100
2134
|
|
|
2101
2135
|
return {
|
|
2102
2136
|
...checkResult,
|
|
@@ -2193,9 +2227,11 @@ async function handleAuthCommand(argv) {
|
|
|
2193
2227
|
function buildUrlTaskCreatePayload(args, input) {
|
|
2194
2228
|
const createPayload = {
|
|
2195
2229
|
url: input.url,
|
|
2196
|
-
...(args.language ? { language: args.language } : {}),
|
|
2197
2230
|
}
|
|
2198
2231
|
|
|
2232
|
+
if (args.language) {
|
|
2233
|
+
createPayload.language = args.language
|
|
2234
|
+
}
|
|
2199
2235
|
if (args.source) {
|
|
2200
2236
|
createPayload.source = args.source
|
|
2201
2237
|
}
|
|
@@ -2203,13 +2239,102 @@ function buildUrlTaskCreatePayload(args, input) {
|
|
|
2203
2239
|
return createPayload
|
|
2204
2240
|
}
|
|
2205
2241
|
|
|
2206
|
-
|
|
2207
|
-
|
|
2242
|
+
function shouldPreflightBilibiliUrl(args, input) {
|
|
2243
|
+
if (args.source === 'bilibili') return true
|
|
2244
|
+
if (typeof input?.url !== 'string') return false
|
|
2245
|
+
if (/^bilibili:\/\/video\//i.test(input.url)) return true
|
|
2246
|
+
|
|
2247
|
+
try {
|
|
2248
|
+
const hostname = new URL(input.url).hostname.toLowerCase()
|
|
2249
|
+
return hostname === 'bilibili.com'
|
|
2250
|
+
|| hostname.endsWith('.bilibili.com')
|
|
2251
|
+
|| hostname === 'b23.tv'
|
|
2252
|
+
} catch {
|
|
2253
|
+
return false
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function confirmBilibiliTrial(preflight) {
|
|
2258
|
+
process.stderr.write(`${preflight.message || '这是B站专属视频,Deyo 只能转写试看部分'}\n`)
|
|
2259
|
+
process.stderr.write(`完整时长:${preflight.fullDurationLabel || `${preflight.fullDurationSeconds} 秒`}\n`)
|
|
2260
|
+
process.stderr.write(`可转写时长:${preflight.transcribableDurationLabel || `${preflight.transcribableDurationSeconds} 秒`}\n`)
|
|
2261
|
+
process.stderr.write(`本次预计扣除:${preflight.requiredMinutes} 分钟\n`)
|
|
2262
|
+
|
|
2263
|
+
if (!process.stdin.isTTY) {
|
|
2264
|
+
process.stderr.write('当前不是交互式终端,已取消且不会创建任务或扣除分钟。请在交互终端中重新执行并确认。\n')
|
|
2265
|
+
return false
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const prompt = createInterface({
|
|
2269
|
+
input: process.stdin,
|
|
2270
|
+
output: process.stderr,
|
|
2271
|
+
})
|
|
2272
|
+
|
|
2273
|
+
try {
|
|
2274
|
+
const answer = (await prompt.question('仍然转写试看部分?请输入 y 确认 [y/N]:')).trim().toLowerCase()
|
|
2275
|
+
return answer === 'y' || answer === 'yes'
|
|
2276
|
+
} finally {
|
|
2277
|
+
prompt.close()
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
async function preflightBilibiliTask(baseUrl, apiKey, createPayload, runContext) {
|
|
2282
|
+
const preflight = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions/preflight', {
|
|
2208
2283
|
method: 'POST',
|
|
2209
2284
|
signal: runContext.signal,
|
|
2210
2285
|
body: createPayload,
|
|
2211
2286
|
})
|
|
2212
2287
|
|
|
2288
|
+
if (preflight?.confirmationRequired) {
|
|
2289
|
+
const confirmed = await confirmBilibiliTrial(preflight)
|
|
2290
|
+
|
|
2291
|
+
if (!confirmed) {
|
|
2292
|
+
if (process.stdin.isTTY) {
|
|
2293
|
+
process.stderr.write('已取消,不会创建任务或扣除分钟。\n')
|
|
2294
|
+
}
|
|
2295
|
+
throw new ReportedCliError('已取消')
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
if (!preflight?.parseToken) {
|
|
2300
|
+
throw new Error('服务端预检未返回有效的 parse token')
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
return createPayload.language
|
|
2304
|
+
? { parseToken: preflight.parseToken, language: createPayload.language }
|
|
2305
|
+
: { parseToken: preflight.parseToken }
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
|
|
2309
|
+
const createRequest = () => requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
|
|
2310
|
+
method: 'POST',
|
|
2311
|
+
signal: runContext.signal,
|
|
2312
|
+
body: createPayload,
|
|
2313
|
+
})
|
|
2314
|
+
let created
|
|
2315
|
+
|
|
2316
|
+
try {
|
|
2317
|
+
created = await createRequest()
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
if (!(error instanceof HttpCliError) || error.errorCode !== WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE) throw error
|
|
2320
|
+
if (!error.presentationUrl) throw new Error('服务端返回的视频号授权地址无效,请稍后重试')
|
|
2321
|
+
|
|
2322
|
+
const browserOpened = tryOpenSystemBrowser(error.presentationUrl)
|
|
2323
|
+
reporter.reportWechatChannelsCredentialRequired(error.presentationUrl, browserOpened)
|
|
2324
|
+
const parseToken = new URL(error.presentationUrl).searchParams.get('token')
|
|
2325
|
+
if (!parseToken) throw new Error('服务端返回的视频号解析地址无效,请稍后重试')
|
|
2326
|
+
await waitForParseResume(baseUrl, apiKey, parseToken, runContext, error.retryAfterMs)
|
|
2327
|
+
|
|
2328
|
+
try {
|
|
2329
|
+
created = await createRequest()
|
|
2330
|
+
} catch (replayError) {
|
|
2331
|
+
if (replayError instanceof HttpCliError && replayError.errorCode === WECHAT_CHANNELS_CREDENTIAL_ERROR_CODE) {
|
|
2332
|
+
throw new Error('视频号授权状态已变化,请重新执行原命令')
|
|
2333
|
+
}
|
|
2334
|
+
throw replayError
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2213
2338
|
runContext.state.taskId = created.taskId || ''
|
|
2214
2339
|
runContext.state.taskCreated = true
|
|
2215
2340
|
reporter.reportTaskCreated(created)
|
|
@@ -2217,6 +2342,49 @@ async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContex
|
|
|
2217
2342
|
return created
|
|
2218
2343
|
}
|
|
2219
2344
|
|
|
2345
|
+
function tryOpenSystemBrowser(url) {
|
|
2346
|
+
const command = process.platform === 'darwin'
|
|
2347
|
+
? { file: 'open', args: [url] }
|
|
2348
|
+
: process.platform === 'win32'
|
|
2349
|
+
? { file: 'cmd', args: ['/c', 'start', '', url] }
|
|
2350
|
+
: { file: 'xdg-open', args: [url] }
|
|
2351
|
+
|
|
2352
|
+
try {
|
|
2353
|
+
const child = spawn(command.file, command.args, {
|
|
2354
|
+
detached: true,
|
|
2355
|
+
stdio: 'ignore',
|
|
2356
|
+
})
|
|
2357
|
+
child.once('error', () => {})
|
|
2358
|
+
child.unref()
|
|
2359
|
+
return true
|
|
2360
|
+
} catch {
|
|
2361
|
+
return false
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
async function waitForParseResume(baseUrl, apiKey, parseToken, runContext, initialRetryAfterMs) {
|
|
2366
|
+
const startedAt = Date.now()
|
|
2367
|
+
let retryAfterMs = Number.isSafeInteger(initialRetryAfterMs) ? initialRetryAfterMs : POLL_INTERVAL_MS
|
|
2368
|
+
|
|
2369
|
+
while (Date.now() - startedAt < WECHAT_CHANNELS_AUTH_TIMEOUT_MS) {
|
|
2370
|
+
await sleep(retryAfterMs)
|
|
2371
|
+
if (runContext.signal.aborted) throw createAbortError()
|
|
2372
|
+
|
|
2373
|
+
const status = await requestJson(baseUrl, apiKey, `/api/cli/parse-status?token=${encodeURIComponent(parseToken)}`, {
|
|
2374
|
+
signal: runContext.signal,
|
|
2375
|
+
retry: true,
|
|
2376
|
+
})
|
|
2377
|
+
|
|
2378
|
+
if (status?.status === 'ready') return
|
|
2379
|
+
if (status?.status !== 'pending') throw new Error('服务端返回了未知的解析状态')
|
|
2380
|
+
retryAfterMs = Number.isSafeInteger(status.retryAfterMs) && status.retryAfterMs >= 1000 && status.retryAfterMs <= 5000
|
|
2381
|
+
? status.retryAfterMs
|
|
2382
|
+
: POLL_INTERVAL_MS
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
throw new Error('等待视频号扫码授权超时,请重新执行原命令')
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2220
2388
|
function reportTaskStatusTransition(reporter, previousStatus, task) {
|
|
2221
2389
|
if (task.status === previousStatus) {
|
|
2222
2390
|
return task.status
|
|
@@ -2482,6 +2650,15 @@ async function handleTranscription(argv) {
|
|
|
2482
2650
|
}
|
|
2483
2651
|
} else {
|
|
2484
2652
|
createPayload = buildUrlTaskCreatePayload(args, input)
|
|
2653
|
+
|
|
2654
|
+
if (shouldPreflightBilibiliUrl(args, input)) {
|
|
2655
|
+
createPayload = await preflightBilibiliTask(
|
|
2656
|
+
baseUrl,
|
|
2657
|
+
resolvedApiKey.apiKey,
|
|
2658
|
+
createPayload,
|
|
2659
|
+
runContext,
|
|
2660
|
+
)
|
|
2661
|
+
}
|
|
2485
2662
|
}
|
|
2486
2663
|
|
|
2487
2664
|
if (!created) {
|
package/package.json
CHANGED