@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.
Files changed (3) hide show
  1. package/README.md +28 -6
  2. package/deyo.mjs +290 -113
  3. 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.2` 保留省略 `--language` 时的自动检测和可选稳定正文事件,并新增由 npm CLI 管理的 OpenClaw 更新登记、离线状态与显式确认流程。默认不会把正文写入进度日志;只有显式使用 `--stream-transcript --progress-format jsonl`,且最终格式为 `text` 时,才会在 stderr JSONL 中输出稳定的 Whisper 正文增量。stdout 或 `-O` 始终只接收最终 Whisper 原稿,CLI 本身不做 AI 整理。
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
- 当前支持 8 类链接来源 + 本地音视频上传。链接来源包括小宇宙单集、喜马拉雅单集(含 `xima.tv` 短链)、B 站单视频、抖音单视频、小红书单视频笔记、YouTube 单视频、Apple Podcasts 单集和 Twitter/X 单条推文。其中 YouTube 如果命中可直接使用的字幕,会直接输出字幕或文本结果,不进入完整转写、不扣分钟;喜马拉雅合集链接会返回 422 并提示选择单集,不会扣分钟或创建转写任务;抖音图文内容、小红书图文笔记、Twitter/X 文本或图片推文会返回不支持转写的提示。B 站支持普通 BV 链接、`b23.tv` 短链、带 `bvid` 的 `player.bilibili.com/player.html` 嵌入播放器链接,以及可由 Whisper 解出 BV 页面的 `bilibili://video/...` App 分享视频链接;CLI 会识别并透传 App 链接,不在本地解码或推导 BV。
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 不会在链接任务或本地文件任务的请求体里发送 `language` 字段,由服务端和 Whisper 自动检测;不要根据终端语言或内容标题自行推断。
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.hashing`、`upload.started`、`upload.progress`、`upload.completed`、`upload.checking`、`upload.ready`、`upload.aborted`、`upload.failed`。事件不会包含签名 URL、文件 hash 或分片 ETag。
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
- - 支持输入:8 类单条链接来源,或一个本地音频/视频文件。
361
+ - 支持输入:11 类平台链接来源、未知平台 HTTPS 音频/视频文件直链,或一个本地音频/视频文件;转写对象仍是具体单集、视频或单条内容。
344
362
  - 不支持输入:目录、glob、stdin、多个文件批量队列、断点续传。
345
363
  - 本地文件固定作为 `upload` 来源提交,不需要传 `--source`;如果强行传其他来源,CLI 会直接报错。
346
364
  - API key 是 CLI 鉴权入口;没有 API key 时不能创建任务。
347
- - 完整转写会按账号分钟余额扣减;YouTube 字幕直出、喜马拉雅合集、抖音图文、小红书图文、Twitter/X 文本 / 图片推文等不进入完整转写的分支不会扣分钟或创建转写任务。
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:sha256:')) {
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:sha256:')) {
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
- async function readErrorMessage(response) {
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
- if (message) {
804
- return String(message)
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
- throw new HttpCliError(await readErrorMessage(response), response.status)
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, uploadId) {
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(data.status, data?.error || data?.message || null)
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?.error || data?.message || '转写失败')
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
- const taskPart = state.taskId ? `,taskId=${state.taskId}` : ''
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, uploadId))
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
- async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
2207
- const created = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casatwy/deyo",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Deyo transcription CLI with link/file input, JSONL progress, and opt-in stable transcript streaming",
5
5
  "homepage": "https://deyo.miaobi.fun/docs",
6
6
  "license": "UNLICENSED",