@casatwy/deyo 0.1.0
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 +100 -0
- package/deyo.mjs +844 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# deyo
|
|
2
|
+
|
|
3
|
+
`@casatwy/deyo` installs the `deyo` command for sending transcription jobs to Deyo from your terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @casatwy/deyo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add -g @casatwy/deyo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## First-time login
|
|
16
|
+
|
|
17
|
+
Create a Deyo API key from `https://deyo.miaobi.fun/me/api-keys`, then save it once locally:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
deyo auth login --api-key 'deyo_sk_xxx'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Check whether the local config is active:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
deyo auth status
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Remove the saved local config:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
deyo auth logout
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
The published CLI defaults to the production service at `https://deyo.miaobi.fun`.
|
|
38
|
+
|
|
39
|
+
After `deyo auth login`, you can run transcription commands without repeating `--api-key`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
deyo --language zh -O ./transcript.txt 'https://www.bilibili.com/video/BVxxxx'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
deyo --language zh --format srt -O ./transcript.srt 'https://www.youtube.com/watch?v=xxxx'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
deyo --language en 'https://www.youtube.com/watch?v=xxxx'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If you need local development, pass `--base-url` explicitly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
deyo auth login --api-key 'deyo_sk_xxx' --base-url 'http://deyo.mac-studio'
|
|
57
|
+
deyo --base-url 'http://deyo.mac-studio' --language zh -O ./transcript.txt 'https://www.bilibili.com/video/BVxxxx'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For a one-off override, `--api-key` still takes precedence over local config:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
deyo --api-key 'deyo_sk_other' --language zh 'https://www.youtube.com/watch?v=xxxx'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
By default, the CLI requests Chinese output, which is equivalent to `--language zh`.
|
|
67
|
+
|
|
68
|
+
Run `deyo --help` to see the full argument list.
|
|
69
|
+
|
|
70
|
+
## Install in AI tools
|
|
71
|
+
|
|
72
|
+
The official install pages live at:
|
|
73
|
+
|
|
74
|
+
- Codex: `https://deyo.miaobi.fun/ai/install/codex`
|
|
75
|
+
- Claude: `https://deyo.miaobi.fun/ai/install/claude`
|
|
76
|
+
|
|
77
|
+
These pages tell the AI how to install the `deyo` skill or plugin, ask for the user's API key once, and run `deyo auth login` locally.
|
|
78
|
+
|
|
79
|
+
## Publish
|
|
80
|
+
|
|
81
|
+
For maintainers, publish from the `cli_` directory:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cd cli_
|
|
85
|
+
make deploy
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If you need to publish a new version, pass it explicitly:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd cli_
|
|
92
|
+
make deploy VERSION=0.1.1
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With npm 2FA enabled, pass the current OTP code explicitly:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd cli_
|
|
99
|
+
make deploy OTP=123456
|
|
100
|
+
```
|
package/deyo.mjs
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, extname, resolve } from 'node:path'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BASE_URL = 'https://deyo.miaobi.fun'
|
|
9
|
+
const DEFAULT_LANGUAGE = 'zh'
|
|
10
|
+
const POLL_INTERVAL_MS = 1500
|
|
11
|
+
const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
|
|
12
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
|
13
|
+
|
|
14
|
+
function safeJsonParse(value) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(value)
|
|
17
|
+
} catch {
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fail(message, exitCode = 1) {
|
|
23
|
+
process.stderr.write(`${message}\n`)
|
|
24
|
+
process.exit(exitCode)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printUsage() {
|
|
28
|
+
process.stderr.write(`用法:
|
|
29
|
+
deyo [选项] <url>
|
|
30
|
+
deyo auth <login|logout|status> [选项]
|
|
31
|
+
|
|
32
|
+
转写选项:
|
|
33
|
+
--api-key <key> 可选,Deyo API key
|
|
34
|
+
-O, --output <path> 可选,输出文件路径
|
|
35
|
+
--source <name> 可选,显式指定来源
|
|
36
|
+
--language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
|
|
37
|
+
--format <value> 可选,text | srt | vtt | json | verbose_json
|
|
38
|
+
--base-url <url> 可选,默认 ${DEFAULT_BASE_URL}
|
|
39
|
+
-h, --help 显示帮助
|
|
40
|
+
|
|
41
|
+
auth 子命令:
|
|
42
|
+
deyo auth login --api-key <key> [--base-url <url>]
|
|
43
|
+
deyo auth logout
|
|
44
|
+
deyo auth status
|
|
45
|
+
|
|
46
|
+
API key 读取优先级:
|
|
47
|
+
1. --api-key
|
|
48
|
+
2. 环境变量 DEYO_API_KEY
|
|
49
|
+
3. 本地配置文件(先执行 deyo auth login)
|
|
50
|
+
`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printAuthUsage() {
|
|
54
|
+
process.stderr.write(`用法:
|
|
55
|
+
deyo auth login --api-key <key> [--base-url <url>]
|
|
56
|
+
deyo auth logout
|
|
57
|
+
deyo auth status
|
|
58
|
+
|
|
59
|
+
说明:
|
|
60
|
+
login 保存本地默认 API key,后续无需每次传 --api-key
|
|
61
|
+
logout 删除本地保存的配置
|
|
62
|
+
status 查看当前生效来源与本地配置状态
|
|
63
|
+
`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseTranscriptionArgs(argv) {
|
|
67
|
+
const args = {
|
|
68
|
+
apiKey: '',
|
|
69
|
+
output: '',
|
|
70
|
+
source: '',
|
|
71
|
+
language: '',
|
|
72
|
+
format: '',
|
|
73
|
+
baseUrl: '',
|
|
74
|
+
url: '',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
78
|
+
const current = argv[index]
|
|
79
|
+
|
|
80
|
+
if (current === '-h' || current === '--help') {
|
|
81
|
+
printUsage()
|
|
82
|
+
process.exit(0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (current === '-O' || current === '--output') {
|
|
86
|
+
args.output = argv[index + 1] || ''
|
|
87
|
+
index += 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (current === '--api-key') {
|
|
92
|
+
args.apiKey = argv[index + 1] || ''
|
|
93
|
+
index += 1
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (current === '--source') {
|
|
98
|
+
args.source = argv[index + 1] || ''
|
|
99
|
+
index += 1
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (current === '--language') {
|
|
104
|
+
args.language = argv[index + 1] || ''
|
|
105
|
+
index += 1
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (current === '--format') {
|
|
110
|
+
args.format = argv[index + 1] || ''
|
|
111
|
+
index += 1
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (current === '--base-url') {
|
|
116
|
+
args.baseUrl = argv[index + 1] || ''
|
|
117
|
+
index += 1
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (current.startsWith('-')) {
|
|
122
|
+
fail(`未知参数:${current}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (args.url) {
|
|
126
|
+
fail('只能提供一个内容链接')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
args.url = current
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return args
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseAuthArgs(argv) {
|
|
136
|
+
const action = argv[1] || ''
|
|
137
|
+
const args = {
|
|
138
|
+
action,
|
|
139
|
+
apiKey: '',
|
|
140
|
+
baseUrl: '',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!action) {
|
|
144
|
+
printAuthUsage()
|
|
145
|
+
fail('缺少 auth 子命令')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
149
|
+
const current = argv[index]
|
|
150
|
+
|
|
151
|
+
if (current === '-h' || current === '--help') {
|
|
152
|
+
printAuthUsage()
|
|
153
|
+
process.exit(0)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (current === '--api-key') {
|
|
157
|
+
args.apiKey = argv[index + 1] || ''
|
|
158
|
+
index += 1
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (current === '--base-url') {
|
|
163
|
+
args.baseUrl = argv[index + 1] || ''
|
|
164
|
+
index += 1
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fail(`未知参数:${current}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return args
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeBaseUrl(baseUrl) {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(baseUrl)
|
|
177
|
+
return url.toString().replace(/\/$/, '')
|
|
178
|
+
} catch {
|
|
179
|
+
fail(`--base-url 不是合法 URL:${baseUrl}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inferFormat(outputPath, explicitFormat) {
|
|
184
|
+
if (explicitFormat) {
|
|
185
|
+
if (!RESULT_FORMATS.has(explicitFormat)) {
|
|
186
|
+
fail(`--format 不支持:${explicitFormat}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return explicitFormat
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!outputPath) {
|
|
193
|
+
return 'text'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const extension = extname(outputPath).toLowerCase()
|
|
197
|
+
|
|
198
|
+
if (extension === '.srt') return 'srt'
|
|
199
|
+
if (extension === '.vtt') return 'vtt'
|
|
200
|
+
if (extension === '.json') return 'json'
|
|
201
|
+
return 'text'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getConfigFileCandidates() {
|
|
205
|
+
const home = homedir()
|
|
206
|
+
const xdgConfigHome = String(process.env.XDG_CONFIG_HOME || '').trim()
|
|
207
|
+
const candidates = []
|
|
208
|
+
|
|
209
|
+
if (xdgConfigHome) {
|
|
210
|
+
candidates.push(resolve(xdgConfigHome, 'deyo', 'config.json'))
|
|
211
|
+
} else if (home) {
|
|
212
|
+
candidates.push(resolve(home, '.config', 'deyo', 'config.json'))
|
|
213
|
+
candidates.push(resolve(home, '.deyo', 'config.json'))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!candidates.length) {
|
|
217
|
+
candidates.push(resolve(process.cwd(), '.deyo', 'config.json'))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return candidates
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeStoredConfig(rawConfig, path) {
|
|
224
|
+
if (!rawConfig || typeof rawConfig !== 'object') {
|
|
225
|
+
throw new Error(`本地配置文件格式无效:${path}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const apiKey = typeof rawConfig.apiKey === 'string' ? rawConfig.apiKey.trim() : ''
|
|
229
|
+
const baseUrl = typeof rawConfig.baseUrl === 'string' ? rawConfig.baseUrl.trim() : ''
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
apiKey,
|
|
233
|
+
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : '',
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readStoredConfig() {
|
|
238
|
+
const candidates = getConfigFileCandidates()
|
|
239
|
+
|
|
240
|
+
for (const path of candidates) {
|
|
241
|
+
try {
|
|
242
|
+
const content = await readFile(path, 'utf8')
|
|
243
|
+
return {
|
|
244
|
+
path,
|
|
245
|
+
config: normalizeStoredConfig(JSON.parse(content), path),
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (error instanceof SyntaxError) {
|
|
253
|
+
throw new Error(`本地配置文件不是合法 JSON:${path}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
throw error
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function writeStoredConfig(nextConfig) {
|
|
264
|
+
const candidates = getConfigFileCandidates()
|
|
265
|
+
const normalizedConfig = {
|
|
266
|
+
apiKey: String(nextConfig.apiKey || '').trim(),
|
|
267
|
+
baseUrl: nextConfig.baseUrl ? normalizeBaseUrl(nextConfig.baseUrl) : '',
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let lastError = null
|
|
271
|
+
|
|
272
|
+
for (const path of candidates) {
|
|
273
|
+
try {
|
|
274
|
+
await mkdir(dirname(path), { recursive: true })
|
|
275
|
+
await writeFile(path, `${JSON.stringify(normalizedConfig, null, 2)}\n`, 'utf8')
|
|
276
|
+
await chmod(path, 0o600).catch(() => {})
|
|
277
|
+
return path
|
|
278
|
+
} catch (error) {
|
|
279
|
+
lastError = error
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw lastError || new Error('写入本地配置失败')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function removeStoredConfig() {
|
|
287
|
+
const candidates = getConfigFileCandidates()
|
|
288
|
+
const removedPaths = []
|
|
289
|
+
|
|
290
|
+
for (const path of candidates) {
|
|
291
|
+
try {
|
|
292
|
+
await rm(path, { force: true })
|
|
293
|
+
removedPaths.push(path)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
throw error
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return removedPaths
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function maskApiKey(apiKey) {
|
|
307
|
+
const normalized = String(apiKey || '').trim()
|
|
308
|
+
|
|
309
|
+
if (!normalized) {
|
|
310
|
+
return ''
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (normalized.length <= 10) {
|
|
314
|
+
return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return `${normalized.slice(0, 6)}...${normalized.slice(-4)}`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function resolveBaseUrl(explicitBaseUrl, storedConfig) {
|
|
321
|
+
if (explicitBaseUrl) {
|
|
322
|
+
return {
|
|
323
|
+
baseUrl: normalizeBaseUrl(explicitBaseUrl),
|
|
324
|
+
source: '--base-url',
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const envBaseUrl = String(process.env.DEYO_BASE_URL || '').trim()
|
|
329
|
+
|
|
330
|
+
if (envBaseUrl) {
|
|
331
|
+
return {
|
|
332
|
+
baseUrl: normalizeBaseUrl(envBaseUrl),
|
|
333
|
+
source: 'DEYO_BASE_URL',
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (storedConfig?.config.baseUrl) {
|
|
338
|
+
return {
|
|
339
|
+
baseUrl: storedConfig.config.baseUrl,
|
|
340
|
+
source: '本地配置',
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
346
|
+
source: '默认值',
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolveApiKey(explicitApiKey, storedConfig) {
|
|
351
|
+
const directApiKey = String(explicitApiKey || '').trim()
|
|
352
|
+
|
|
353
|
+
if (directApiKey) {
|
|
354
|
+
return {
|
|
355
|
+
apiKey: directApiKey,
|
|
356
|
+
source: '--api-key',
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const envApiKey = String(process.env.DEYO_API_KEY || '').trim()
|
|
361
|
+
|
|
362
|
+
if (envApiKey) {
|
|
363
|
+
return {
|
|
364
|
+
apiKey: envApiKey,
|
|
365
|
+
source: 'DEYO_API_KEY',
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (storedConfig?.config.apiKey) {
|
|
370
|
+
return {
|
|
371
|
+
apiKey: storedConfig.config.apiKey,
|
|
372
|
+
source: '本地配置',
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function readErrorMessage(response) {
|
|
380
|
+
const contentType = response.headers.get('content-type') || ''
|
|
381
|
+
|
|
382
|
+
if (contentType.includes('application/json')) {
|
|
383
|
+
const data = await response.json().catch(() => null)
|
|
384
|
+
const message = data?.message || data?.statusMessage || data?.error?.message
|
|
385
|
+
|
|
386
|
+
if (message) {
|
|
387
|
+
return String(message)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const text = await response.text().catch(() => '')
|
|
392
|
+
return text || `${response.status} ${response.statusText}`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
396
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
397
|
+
method: options.method || 'GET',
|
|
398
|
+
headers: {
|
|
399
|
+
Authorization: `Bearer ${apiKey}`,
|
|
400
|
+
'Content-Type': 'application/json',
|
|
401
|
+
...(options.headers || {}),
|
|
402
|
+
},
|
|
403
|
+
body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
throw new Error(await readErrorMessage(response))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return response.json()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function requestResult(baseUrl, apiKey, taskId, format) {
|
|
414
|
+
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
|
|
415
|
+
method: 'GET',
|
|
416
|
+
headers: {
|
|
417
|
+
Authorization: `Bearer ${apiKey}`,
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
throw new Error(await readErrorMessage(response))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (format === 'json' || format === 'verbose_json') {
|
|
426
|
+
return JSON.stringify(await response.json(), null, 2)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return response.text()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function sleep(ms) {
|
|
433
|
+
return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function formatStatus(status) {
|
|
437
|
+
if (status === 'queued') return '排队中'
|
|
438
|
+
if (status === 'downloading') return '下载中'
|
|
439
|
+
if (status === 'preprocessing') return '预处理中'
|
|
440
|
+
if (status === 'chunking') return '切分中'
|
|
441
|
+
if (status === 'transcribing') return '转写中'
|
|
442
|
+
if (status === 'merging') return '合并中'
|
|
443
|
+
if (status === 'completed') return '已完成'
|
|
444
|
+
if (status === 'failed') return '失败'
|
|
445
|
+
if (status === 'cancelled') return '已取消'
|
|
446
|
+
return status || '未知状态'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function createOutputState() {
|
|
450
|
+
return {
|
|
451
|
+
inlineActive: false,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function clearInline(outputState) {
|
|
456
|
+
if (!outputState.inlineActive) {
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
process.stderr.write('\r\x1b[2K')
|
|
461
|
+
outputState.inlineActive = false
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function writeStatusLine(outputState, message) {
|
|
465
|
+
clearInline(outputState)
|
|
466
|
+
process.stderr.write(`${message}\n`)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function writeInline(outputState, message) {
|
|
470
|
+
process.stderr.write(`\r\x1b[2K${message}`)
|
|
471
|
+
outputState.inlineActive = true
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function finishInline(outputState) {
|
|
475
|
+
if (!outputState.inlineActive) {
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
process.stderr.write('\n')
|
|
480
|
+
outputState.inlineActive = false
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function normalizeProgressValue(progress, finishedChunks, totalChunks) {
|
|
484
|
+
if (Number.isFinite(progress) && progress >= 0) {
|
|
485
|
+
return Math.max(0, Math.min(1, progress))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (
|
|
489
|
+
Number.isFinite(finishedChunks)
|
|
490
|
+
&& Number.isFinite(totalChunks)
|
|
491
|
+
&& totalChunks > 0
|
|
492
|
+
) {
|
|
493
|
+
return Math.max(0, Math.min(1, finishedChunks / totalChunks))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return null
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function buildTranscribingProgressMessage(input = {}) {
|
|
500
|
+
const finishedChunks = Number(input.finishedChunks)
|
|
501
|
+
const totalChunks = Number(input.totalChunks)
|
|
502
|
+
const normalizedProgress = normalizeProgressValue(
|
|
503
|
+
Number(input.progress),
|
|
504
|
+
finishedChunks,
|
|
505
|
+
totalChunks,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if (normalizedProgress === null) {
|
|
509
|
+
return '转写中'
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const percent = Math.round(normalizedProgress * 100)
|
|
513
|
+
const chunksPart = Number.isFinite(finishedChunks) && Number.isFinite(totalChunks) && totalChunks > 0
|
|
514
|
+
? ` (${Math.max(0, finishedChunks)}/${totalChunks})`
|
|
515
|
+
: ''
|
|
516
|
+
|
|
517
|
+
return `转写中 ${percent}%${chunksPart}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function parseSseBlock(block) {
|
|
521
|
+
const lines = block.split('\n')
|
|
522
|
+
let event = 'message'
|
|
523
|
+
const dataLines = []
|
|
524
|
+
|
|
525
|
+
for (const line of lines) {
|
|
526
|
+
if (!line || line.startsWith(':')) {
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (line.startsWith('event:')) {
|
|
531
|
+
event = line.slice(6).trim()
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (line.startsWith('data:')) {
|
|
536
|
+
dataLines.push(line.slice(5).trimStart())
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
event,
|
|
542
|
+
data: safeJsonParse(dataLines.join('\n')),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputState) {
|
|
547
|
+
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
|
|
548
|
+
method: 'GET',
|
|
549
|
+
headers: {
|
|
550
|
+
Authorization: `Bearer ${apiKey}`,
|
|
551
|
+
},
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
if (!response.ok || !response.body) {
|
|
555
|
+
throw new Error(await readErrorMessage(response))
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const reader = response.body.getReader()
|
|
559
|
+
const decoder = new TextDecoder()
|
|
560
|
+
let buffer = ''
|
|
561
|
+
let task = {
|
|
562
|
+
...initialTask,
|
|
563
|
+
}
|
|
564
|
+
let currentStatus = task.status
|
|
565
|
+
|
|
566
|
+
function applyStatus(nextStatus) {
|
|
567
|
+
if (!nextStatus || nextStatus === currentStatus) {
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
currentStatus = nextStatus
|
|
572
|
+
task = {
|
|
573
|
+
...task,
|
|
574
|
+
status: nextStatus,
|
|
575
|
+
}
|
|
576
|
+
writeStatusLine(outputState, `状态更新:${formatStatus(nextStatus)}`)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function applyTerminal(nextStatus, errorMessage) {
|
|
580
|
+
currentStatus = nextStatus
|
|
581
|
+
task = {
|
|
582
|
+
...task,
|
|
583
|
+
status: nextStatus,
|
|
584
|
+
error: errorMessage || task.error || null,
|
|
585
|
+
}
|
|
586
|
+
finishInline(outputState)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderProgress(data) {
|
|
590
|
+
currentStatus = 'transcribing'
|
|
591
|
+
task = {
|
|
592
|
+
...task,
|
|
593
|
+
status: 'transcribing',
|
|
594
|
+
}
|
|
595
|
+
writeInline(outputState, buildTranscribingProgressMessage(data))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
while (true) {
|
|
599
|
+
const { value, done } = await reader.read()
|
|
600
|
+
|
|
601
|
+
if (done) {
|
|
602
|
+
break
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
buffer += decoder.decode(value, { stream: true })
|
|
606
|
+
buffer = buffer.replace(/\r\n/g, '\n')
|
|
607
|
+
|
|
608
|
+
let separatorIndex = buffer.indexOf('\n\n')
|
|
609
|
+
|
|
610
|
+
while (separatorIndex >= 0) {
|
|
611
|
+
const block = buffer.slice(0, separatorIndex).trim()
|
|
612
|
+
buffer = buffer.slice(separatorIndex + 2)
|
|
613
|
+
separatorIndex = buffer.indexOf('\n\n')
|
|
614
|
+
|
|
615
|
+
if (!block) {
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const { event, data } = parseSseBlock(block)
|
|
620
|
+
|
|
621
|
+
if (event === 'job.snapshot') {
|
|
622
|
+
if (data?.status === 'transcribing') {
|
|
623
|
+
renderProgress(data)
|
|
624
|
+
} else {
|
|
625
|
+
applyStatus(data?.status)
|
|
626
|
+
}
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (event === 'job.status_changed') {
|
|
631
|
+
applyStatus(data?.status)
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (event === 'job.progress') {
|
|
636
|
+
renderProgress(data)
|
|
637
|
+
continue
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (event === 'job.completed') {
|
|
641
|
+
applyTerminal('completed', null)
|
|
642
|
+
return task
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (event === 'job.failed') {
|
|
646
|
+
applyTerminal('failed', data?.error || data?.message || '转写失败')
|
|
647
|
+
return task
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (event === 'job.cancelled') {
|
|
651
|
+
applyTerminal('cancelled', '转写任务已取消')
|
|
652
|
+
return task
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
finishInline(outputState)
|
|
658
|
+
return task
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function writeOutput(outputPath, content) {
|
|
662
|
+
const resolvedPath = resolve(process.cwd(), outputPath)
|
|
663
|
+
await mkdir(dirname(resolvedPath), { recursive: true })
|
|
664
|
+
await writeFile(resolvedPath, content, 'utf8')
|
|
665
|
+
return resolvedPath
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function handleAuthLogin(args) {
|
|
669
|
+
if (!args.apiKey) {
|
|
670
|
+
printAuthUsage()
|
|
671
|
+
fail('缺少 --api-key')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const existingConfig = await readStoredConfig().catch(() => null)
|
|
675
|
+
const outputPath = await writeStoredConfig({
|
|
676
|
+
apiKey: args.apiKey,
|
|
677
|
+
baseUrl: args.baseUrl || existingConfig?.config.baseUrl || '',
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
process.stdout.write(`已保存本地配置:${outputPath}\n`)
|
|
681
|
+
process.stdout.write(`API key:${maskApiKey(args.apiKey)}\n`)
|
|
682
|
+
|
|
683
|
+
if (args.baseUrl || existingConfig?.config.baseUrl) {
|
|
684
|
+
const resolvedBaseUrl = normalizeBaseUrl(args.baseUrl || existingConfig?.config.baseUrl || DEFAULT_BASE_URL)
|
|
685
|
+
process.stdout.write(`默认服务地址:${resolvedBaseUrl}\n`)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function handleAuthLogout() {
|
|
690
|
+
const removedPaths = await removeStoredConfig()
|
|
691
|
+
|
|
692
|
+
if (!removedPaths.length) {
|
|
693
|
+
process.stdout.write('本地没有可删除的 Deyo 配置。\n')
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
process.stdout.write(`已删除本地配置:${removedPaths.join('、')}\n`)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function handleAuthStatus() {
|
|
701
|
+
const storedConfig = await readStoredConfig()
|
|
702
|
+
const activeApiKey = resolveApiKey('', storedConfig)
|
|
703
|
+
const activeBaseUrl = resolveBaseUrl('', storedConfig)
|
|
704
|
+
|
|
705
|
+
process.stdout.write(`当前 API key 来源:${activeApiKey?.source || '未配置'}\n`)
|
|
706
|
+
|
|
707
|
+
if (storedConfig?.config.apiKey) {
|
|
708
|
+
process.stdout.write(`本地配置文件:${storedConfig.path}\n`)
|
|
709
|
+
process.stdout.write(`本地 API key:${maskApiKey(storedConfig.config.apiKey)}\n`)
|
|
710
|
+
} else {
|
|
711
|
+
process.stdout.write('本地配置文件:未找到\n')
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
process.stdout.write(`当前服务地址:${activeBaseUrl.baseUrl}\n`)
|
|
715
|
+
process.stdout.write(`服务地址来源:${activeBaseUrl.source}\n`)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function handleAuthCommand(argv) {
|
|
719
|
+
const args = parseAuthArgs(argv)
|
|
720
|
+
|
|
721
|
+
if (args.action === 'login') {
|
|
722
|
+
await handleAuthLogin(args)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (args.action === 'logout') {
|
|
727
|
+
await handleAuthLogout()
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (args.action === 'status') {
|
|
732
|
+
await handleAuthStatus()
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
printAuthUsage()
|
|
737
|
+
fail(`不支持的 auth 子命令:${args.action}`)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function handleTranscription(argv) {
|
|
741
|
+
const args = parseTranscriptionArgs(argv)
|
|
742
|
+
const storedConfig = await readStoredConfig()
|
|
743
|
+
const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
|
|
744
|
+
|
|
745
|
+
if (!resolvedApiKey) {
|
|
746
|
+
printUsage()
|
|
747
|
+
fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!args.url) {
|
|
751
|
+
printUsage()
|
|
752
|
+
fail('缺少内容链接')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
|
|
756
|
+
const format = inferFormat(args.output, args.format)
|
|
757
|
+
const createPayload = {
|
|
758
|
+
url: args.url,
|
|
759
|
+
language: args.language || DEFAULT_LANGUAGE,
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (args.source) {
|
|
763
|
+
createPayload.source = args.source
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
|
|
767
|
+
method: 'POST',
|
|
768
|
+
body: createPayload,
|
|
769
|
+
})
|
|
770
|
+
const outputState = createOutputState()
|
|
771
|
+
|
|
772
|
+
process.stderr.write(`任务已创建:${created.taskId}\n`)
|
|
773
|
+
process.stderr.write(`标题:${created.title}\n`)
|
|
774
|
+
process.stderr.write(`来源:${created.sourceType}\n`)
|
|
775
|
+
process.stderr.write(`状态:${formatStatus(created.status)}\n`)
|
|
776
|
+
process.stderr.write(`本次消耗分钟:${created.consumedMinutes}\n`)
|
|
777
|
+
process.stderr.write(`剩余分钟:${created.remainingMinutes}\n`)
|
|
778
|
+
|
|
779
|
+
let task = created
|
|
780
|
+
let lastStatus = created.status
|
|
781
|
+
|
|
782
|
+
if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
|
|
783
|
+
try {
|
|
784
|
+
task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, outputState)
|
|
785
|
+
lastStatus = task.status
|
|
786
|
+
} catch (error) {
|
|
787
|
+
finishInline(outputState)
|
|
788
|
+
process.stderr.write(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}\n`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
while (!TERMINAL_STATUSES.has(task.status)) {
|
|
793
|
+
await sleep(POLL_INTERVAL_MS)
|
|
794
|
+
task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
|
|
795
|
+
|
|
796
|
+
if (task.status !== lastStatus) {
|
|
797
|
+
finishInline(outputState)
|
|
798
|
+
process.stderr.write(`状态更新:${formatStatus(task.status)}\n`)
|
|
799
|
+
lastStatus = task.status
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
finishInline(outputState)
|
|
804
|
+
|
|
805
|
+
if (task.status !== 'completed') {
|
|
806
|
+
throw new Error(task.error || `任务未成功完成:${formatStatus(task.status)}`)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
|
|
810
|
+
|
|
811
|
+
if (args.output) {
|
|
812
|
+
const outputPath = await writeOutput(args.output, result)
|
|
813
|
+
process.stderr.write(`结果已写入:${outputPath}\n`)
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
process.stdout.write(result)
|
|
818
|
+
|
|
819
|
+
if (!result.endsWith('\n')) {
|
|
820
|
+
process.stdout.write('\n')
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function main() {
|
|
825
|
+
const argv = process.argv.slice(2)
|
|
826
|
+
const primaryCommand = argv[0]
|
|
827
|
+
|
|
828
|
+
if (!primaryCommand || primaryCommand === '-h' || primaryCommand === '--help') {
|
|
829
|
+
printUsage()
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (primaryCommand === 'auth') {
|
|
834
|
+
await handleAuthCommand(argv)
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
await handleTranscription(argv)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
main().catch((error) => {
|
|
842
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
843
|
+
fail(`执行失败:${message}`)
|
|
844
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@casatwy/deyo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for submitting Deyo transcription jobs from the terminal",
|
|
5
|
+
"homepage": "https://deyo.miaobi.fun/docs",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"deyo.mjs",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"deyo",
|
|
14
|
+
"transcription",
|
|
15
|
+
"cli",
|
|
16
|
+
"subtitle",
|
|
17
|
+
"whisper"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"deyo": "./deyo.mjs"
|
|
27
|
+
}
|
|
28
|
+
}
|