@casatwy/deyo 0.2.1 → 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/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
 
@@ -82,12 +91,12 @@ function printUsage() {
82
91
  deyo [选项] <url|file>
83
92
  deyo [选项] --file <file>
84
93
  deyo auth <login|logout|status> [选项]
85
- deyo skill <install|status> [选项]
94
+ deyo skill <install|status|update> [选项]
86
95
 
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> 可选,显式指定转写语言;省略时自动检测
@@ -106,6 +115,8 @@ auth 子命令:
106
115
  Skill 管理:
107
116
  deyo skill install --platform codex|claude|gemini|openclaw [--scope user|workspace|global]
108
117
  deyo skill status [--platform codex|claude|gemini|openclaw] [--json]
118
+ deyo skill update --platform openclaw [--scope workspace|global]
119
+ --confirm-latest 'update @casatwy/deyo to latest now'
109
120
 
110
121
  API key 读取优先级:
111
122
  1. --api-key
@@ -115,6 +126,10 @@ API key 读取优先级:
115
126
  本地文件说明:
116
127
  v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
117
128
  可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
129
+
130
+ B站试看说明:
131
+ B站专属视频会先显示完整时长、可转写时长和预计扣除分钟。
132
+ 只有交互式终端中明确确认后才会创建任务;拒绝或非交互环境均不会扣分钟。
118
133
  `)
119
134
  }
120
135
 
@@ -343,6 +358,9 @@ function isRetryableRequestError(error) {
343
358
  }
344
359
 
345
360
  if (error instanceof HttpCliError) {
361
+ if (MAINTENANCE_ERROR_CODES.has(error.errorCode)) {
362
+ return false
363
+ }
346
364
  return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
347
365
  }
348
366
 
@@ -469,15 +487,35 @@ function formatBytes(value) {
469
487
  }
470
488
 
471
489
  function redactUploadSourceUrl(sourceType, sourceUrl) {
472
- if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:sha256:')) {
490
+ if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:')) {
473
491
  return UPLOAD_REDACTED_SOURCE_LABEL
474
492
  }
475
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
+
476
514
  return sourceUrl || null
477
515
  }
478
516
 
479
517
  function isSensitiveUploadResultString(key, value, sourceType = null) {
480
- if (value.startsWith('upload:sha256:')) {
518
+ if (value.startsWith('upload:')) {
481
519
  return true
482
520
  }
483
521
 
@@ -505,6 +543,12 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
505
543
  }
506
544
 
507
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
+
508
552
  if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
509
553
  return UPLOAD_REDACTED_SOURCE_LABEL
510
554
  }
@@ -516,6 +560,10 @@ function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey =
516
560
  const next = {}
517
561
 
518
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
+
519
567
  if (key === 'sourceUrl') {
520
568
  next[key] = redactUploadSourceUrl(sourceType, item)
521
569
  continue
@@ -791,20 +839,59 @@ function resolveApiKey(explicitApiKey, storedConfig) {
791
839
  return null
792
840
  }
793
841
 
794
- 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) {
795
865
  const contentType = response.headers.get('content-type') || ''
796
866
 
797
867
  if (contentType.includes('application/json')) {
798
868
  const data = await response.json().catch(() => null)
799
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)
800
878
 
801
- if (message) {
802
- 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,
803
886
  }
804
887
  }
805
888
 
806
889
  const text = await response.text().catch(() => '')
807
- 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
808
895
  }
809
896
 
810
897
  async function requestJson(baseUrl, apiKey, path, options = {}) {
@@ -821,7 +908,8 @@ async function requestJson(baseUrl, apiKey, path, options = {}) {
821
908
  })
822
909
 
823
910
  if (!response.ok) {
824
- throw new HttpCliError(await readErrorMessage(response), response.status)
911
+ const details = await readErrorResponse(response)
912
+ throw new HttpCliError(details.message, response.status, details)
825
913
  }
826
914
 
827
915
  return response.json()
@@ -969,6 +1057,27 @@ function buildTerminalMessage(status, errorMessage) {
969
1057
  return errorMessage ? `任务结束:${errorMessage}` : `任务结束:${formatStatus(status)}`
970
1058
  }
971
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
+
972
1081
  function normalizeProgressValue(progress, finishedChunks, totalChunks) {
973
1082
  if (Number.isFinite(progress) && progress >= 0) {
974
1083
  return Math.max(0, Math.min(1, progress))
@@ -1022,14 +1131,6 @@ function buildUploadProgressMessage(input = {}) {
1022
1131
  return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
1023
1132
  }
1024
1133
 
1025
- function buildHashingMessage(input = {}) {
1026
- const hashedBytes = Number(input.hashedBytes) || 0
1027
- const totalBytes = Number(input.totalBytes) || 0
1028
- const percent = normalizeUploadPercent(hashedBytes, totalBytes)
1029
-
1030
- return `正在计算文件哈希 ${percent}%`
1031
- }
1032
-
1033
1134
  function getUploadReadyMessage(input = {}) {
1034
1135
  const title = input.title || input.filename || '上传文件'
1035
1136
  const duration = Number(input.durationSeconds)
@@ -1052,7 +1153,7 @@ function getUploadTaskFromCheckResult(checkResult) {
1052
1153
  return null
1053
1154
  }
1054
1155
 
1055
- function getUploadReadySummary(checkResult, fileInput, uploadId) {
1156
+ function getUploadReadySummary(checkResult, fileInput) {
1056
1157
  const task = getUploadTaskFromCheckResult(checkResult)
1057
1158
  const upload = checkResult?.upload || {}
1058
1159
  const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
@@ -1065,7 +1166,6 @@ function getUploadReadySummary(checkResult, fileInput, uploadId) {
1065
1166
  const requiredMinutes = Number(checkResult?.requiredMinutes)
1066
1167
 
1067
1168
  return {
1068
- uploadId,
1069
1169
  filename: upload.filename || fileInput.filename,
1070
1170
  title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
1071
1171
  durationSeconds: normalizedDurationSeconds,
@@ -1134,36 +1234,6 @@ function createProgressReporter(progressFormat) {
1134
1234
  writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
1135
1235
  writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
1136
1236
  },
1137
- reportUploadHashing(input = {}) {
1138
- const message = buildHashingMessage(input)
1139
- const hashedBytes = Number(input.hashedBytes) || 0
1140
- const totalBytes = Number(input.totalBytes) || 0
1141
- const percent = normalizeUploadPercent(hashedBytes, totalBytes)
1142
-
1143
- if (resolvedMode === 'interactive') {
1144
- writeInline(outputState, message)
1145
- return
1146
- }
1147
-
1148
- if (resolvedMode === 'jsonl') {
1149
- writeJsonEvent({
1150
- event: 'upload.hashing',
1151
- message,
1152
- filename: input.filename || null,
1153
- hashedBytes,
1154
- totalBytes,
1155
- percent,
1156
- })
1157
- return
1158
- }
1159
-
1160
- if (message === lastTextProgressMessage) {
1161
- return
1162
- }
1163
-
1164
- lastTextProgressMessage = message
1165
- writeTextMessage(message)
1166
- },
1167
1237
  reportUploadStarted(input = {}) {
1168
1238
  lastTextProgressMessage = ''
1169
1239
  const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
@@ -1173,7 +1243,6 @@ function createProgressReporter(progressFormat) {
1173
1243
  filename: input.filename || null,
1174
1244
  mimeType: input.mimeType || null,
1175
1245
  totalBytes: Number(input.totalBytes) || 0,
1176
- uploadId: input.uploadId || null,
1177
1246
  partSize: Number(input.partSize) || null,
1178
1247
  totalParts: Number(input.totalParts) || null,
1179
1248
  })
@@ -1216,7 +1285,6 @@ function createProgressReporter(progressFormat) {
1216
1285
 
1217
1286
  emitTextOrJson(message, {
1218
1287
  event: 'upload.completed',
1219
- uploadId: input.uploadId || null,
1220
1288
  filename: input.filename || null,
1221
1289
  totalBytes: Number(input.totalBytes) || 0,
1222
1290
  })
@@ -1226,7 +1294,6 @@ function createProgressReporter(progressFormat) {
1226
1294
 
1227
1295
  emitTextOrJson(message, {
1228
1296
  event: 'upload.checking',
1229
- uploadId: input.uploadId || null,
1230
1297
  })
1231
1298
  },
1232
1299
  reportUploadReady(input = {}) {
@@ -1234,7 +1301,6 @@ function createProgressReporter(progressFormat) {
1234
1301
 
1235
1302
  emitTextOrJson(message, {
1236
1303
  event: 'upload.ready',
1237
- uploadId: input.uploadId || null,
1238
1304
  sourceType: 'upload',
1239
1305
  sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
1240
1306
  title: input.title || input.filename || null,
@@ -1248,14 +1314,12 @@ function createProgressReporter(progressFormat) {
1248
1314
 
1249
1315
  emitTextOrJson(message, {
1250
1316
  event: 'upload.aborted',
1251
- uploadId: input.uploadId || null,
1252
1317
  })
1253
1318
  },
1254
1319
  reportUploadFailed(message, input = {}) {
1255
1320
  finishInline(outputState)
1256
1321
  emitTextOrJson(message, {
1257
1322
  event: 'upload.failed',
1258
- uploadId: input.uploadId || null,
1259
1323
  error: message,
1260
1324
  })
1261
1325
  },
@@ -1379,6 +1443,17 @@ function createProgressReporter(progressFormat) {
1379
1443
  event: 'task.notice',
1380
1444
  })
1381
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
+ },
1382
1457
  close() {
1383
1458
  finishInline(outputState)
1384
1459
  },
@@ -1593,7 +1668,12 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
1593
1668
  if (data?.status === 'transcribing') {
1594
1669
  renderProgress(data)
1595
1670
  } else if (TERMINAL_STATUSES.has(data?.status)) {
1596
- 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
+ )
1597
1677
  } else {
1598
1678
  applyStatus(data?.status)
1599
1679
  }
@@ -1621,7 +1701,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter,
1621
1701
  }
1622
1702
 
1623
1703
  if (event === 'job.failed') {
1624
- applyTerminal('failed', data?.error || data?.message || '转写失败')
1704
+ applyTerminal('failed', getSafeTranscriptionFailureMessage(data))
1625
1705
  return true
1626
1706
  }
1627
1707
 
@@ -1705,48 +1785,6 @@ function assertNotAborted(signal) {
1705
1785
  }
1706
1786
  }
1707
1787
 
1708
- async function calculateFileSha256(fileInput, reporter, signal) {
1709
- const hash = createHash('sha256')
1710
- const fileHandle = await open(fileInput.path, 'r')
1711
- let hashedBytes = 0
1712
-
1713
- reporter.reportUploadHashing({
1714
- filename: fileInput.filename,
1715
- hashedBytes,
1716
- totalBytes: fileInput.sizeBytes,
1717
- })
1718
-
1719
- try {
1720
- while (hashedBytes < fileInput.sizeBytes) {
1721
- assertNotAborted(signal)
1722
-
1723
- const length = Math.min(HASH_CHUNK_SIZE, fileInput.sizeBytes - hashedBytes)
1724
- const buffer = Buffer.allocUnsafe(length)
1725
- const { bytesRead } = await fileHandle.read(buffer, 0, length, hashedBytes)
1726
-
1727
- if (bytesRead <= 0) {
1728
- break
1729
- }
1730
-
1731
- hash.update(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead))
1732
- hashedBytes += bytesRead
1733
- reporter.reportUploadHashing({
1734
- filename: fileInput.filename,
1735
- hashedBytes,
1736
- totalBytes: fileInput.sizeBytes,
1737
- })
1738
- }
1739
- } finally {
1740
- await fileHandle.close().catch(() => {})
1741
- }
1742
-
1743
- if (hashedBytes !== fileInput.sizeBytes) {
1744
- throw new Error('读取本地文件计算哈希时大小不一致,请重新选择文件')
1745
- }
1746
-
1747
- return hash.digest('hex')
1748
- }
1749
-
1750
1788
  async function readFileChunk(filePath, start, length, signal) {
1751
1789
  assertNotAborted(signal)
1752
1790
 
@@ -1823,8 +1861,7 @@ function createRunContext(baseUrl, apiKey, reporter) {
1823
1861
  }
1824
1862
 
1825
1863
  if (!state.uploadAbortAllowed && state.uploadId) {
1826
- const taskPart = state.taskId ? `,taskId=${state.taskId}` : ''
1827
- reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
1864
+ reporter.reportNotice('已停止本地等待,服务端正在处理上传')
1828
1865
  reporter.close()
1829
1866
  return
1830
1867
  }
@@ -2017,7 +2054,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
2017
2054
  let uploadId = ''
2018
2055
 
2019
2056
  try {
2020
- const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
2021
2057
  const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
2022
2058
  method: 'POST',
2023
2059
  signal: runContext.signal,
@@ -2026,7 +2062,6 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
2026
2062
  filename: fileInput.filename,
2027
2063
  mimeType: fileInput.mimeType,
2028
2064
  sizeBytes: fileInput.sizeBytes,
2029
- fileHash,
2030
2065
  },
2031
2066
  })
2032
2067
 
@@ -2078,14 +2113,15 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
2078
2113
  runContext.state.uploadAbortAllowed = false
2079
2114
  reporter.reportUploadChecking({ uploadId })
2080
2115
 
2116
+ const checkBody = { uploadId }
2117
+ if (args.language) {
2118
+ checkBody.language = args.language
2119
+ }
2081
2120
  const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
2082
2121
  method: 'POST',
2083
2122
  signal: runContext.signal,
2084
2123
  retry: true,
2085
- body: {
2086
- uploadId,
2087
- ...(args.language ? { language: args.language } : {}),
2088
- },
2124
+ body: checkBody,
2089
2125
  })
2090
2126
  const checkedTask = getUploadTaskFromCheckResult(checkResult)
2091
2127
 
@@ -2094,7 +2130,7 @@ async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runCo
2094
2130
  runContext.state.taskCreated = true
2095
2131
  }
2096
2132
 
2097
- reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput, uploadId))
2133
+ reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput))
2098
2134
 
2099
2135
  return {
2100
2136
  ...checkResult,
@@ -2191,9 +2227,11 @@ async function handleAuthCommand(argv) {
2191
2227
  function buildUrlTaskCreatePayload(args, input) {
2192
2228
  const createPayload = {
2193
2229
  url: input.url,
2194
- ...(args.language ? { language: args.language } : {}),
2195
2230
  }
2196
2231
 
2232
+ if (args.language) {
2233
+ createPayload.language = args.language
2234
+ }
2197
2235
  if (args.source) {
2198
2236
  createPayload.source = args.source
2199
2237
  }
@@ -2201,13 +2239,102 @@ function buildUrlTaskCreatePayload(args, input) {
2201
2239
  return createPayload
2202
2240
  }
2203
2241
 
2204
- async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
2205
- 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', {
2206
2283
  method: 'POST',
2207
2284
  signal: runContext.signal,
2208
2285
  body: createPayload,
2209
2286
  })
2210
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
+
2211
2338
  runContext.state.taskId = created.taskId || ''
2212
2339
  runContext.state.taskCreated = true
2213
2340
  reporter.reportTaskCreated(created)
@@ -2215,6 +2342,49 @@ async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContex
2215
2342
  return created
2216
2343
  }
2217
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
+
2218
2388
  function reportTaskStatusTransition(reporter, previousStatus, task) {
2219
2389
  if (task.status === previousStatus) {
2220
2390
  return task.status
@@ -2480,6 +2650,15 @@ async function handleTranscription(argv) {
2480
2650
  }
2481
2651
  } else {
2482
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
+ }
2483
2662
  }
2484
2663
 
2485
2664
  if (!created) {
@@ -2528,7 +2707,8 @@ async function main() {
2528
2707
  }
2529
2708
 
2530
2709
  if (primaryCommand === 'skill') {
2531
- await handleSkillCommand(argv, CLI_VERSION)
2710
+ const exitCode = await handleSkillCommand(argv, CLI_VERSION)
2711
+ if (Number.isSafeInteger(exitCode)) process.exitCode = exitCode
2532
2712
  return
2533
2713
  }
2534
2714