@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/README.md +39 -8
- package/deyo.mjs +295 -115
- package/openclaw-skill-update.mjs +794 -0
- package/package.json +3 -2
- package/skill-manager.mjs +111 -16
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2205
|
-
|
|
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
|
|