dsh-bili-scroll 2.0.4 → 2.2.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/AGENTS.md +3 -1
- package/CHANGELOG.md +710 -0
- package/CONTRIBUTORS.md +5 -1
- package/README.md +12 -2
- package/client.js +7 -2
- package/index.js +433 -44
- package/lib/diagnose.mjs +95 -0
- package/lib/mcp.mjs +34 -9
- package/lib/state.mjs +11 -12
- package/lib/uvfetch.mjs +217 -161
- package/package.json +4 -2
- package/profile-template/bili-duty/cordis.patch.yml +116 -0
- package/profile-template/bili-duty/cordis.yml +4 -0
- package/profile-template/bili-duty/package.json +13 -0
- package/prompts/duty.md +126 -13
- package/vendor/bilibili-mcp/mcp_server.py +706 -0
- package/vendor/bilibili-mcp/requirements.txt +2 -0
- package/LICENSE.bak-20261020 +0 -31
package/lib/diagnose.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把"这一轮为什么没跑起来"从日志尾部翻成人话。
|
|
3
|
+
*
|
|
4
|
+
* 起因(2026-10-24 一位桌面版用户实测):
|
|
5
|
+
* `dsh: NO_ADAPTER: no adapter registered for provider "baomiao-oneapi"`
|
|
6
|
+
* ⇒ 那一轮 1 秒就 exit 1,报告里只有一行 code=1 ⇒ 用户看不懂,以为插件坏了。
|
|
7
|
+
*
|
|
8
|
+
* 设计约束(刻意压到最小面积):
|
|
9
|
+
* · **纯函数**:只吃一段字符串、吐一个对象(或 null),不读文件、不写文件、不抛异常;
|
|
10
|
+
* · **只在"报告本来该有却没有"时被调用**(跑通的那条路根本不经过它);
|
|
11
|
+
* · **认不出来就返回 null** ⇒ 调用方走原来的兜底文案,行为跟以前完全一样。
|
|
12
|
+
*
|
|
13
|
+
* @param {string} tail 这一轮日志的尾部(几百~几千字即可)
|
|
14
|
+
* @returns {{ title: string, body: string, hint?: string } | null}
|
|
15
|
+
*/
|
|
16
|
+
export function diagnoseRound(tail) {
|
|
17
|
+
const t = String(tail || '')
|
|
18
|
+
if (!t) return null
|
|
19
|
+
|
|
20
|
+
// ① 模型提供方没有驱动 —— 最常见的"配了第三方中转、但那个 profile 没装对应插件"
|
|
21
|
+
const noAdapter =
|
|
22
|
+
t.match(/no adapter registered for provider\s+"([^"]+)"/i) ||
|
|
23
|
+
t.match(/NO_ADAPTER[^\n]*provider\s+"([^"]+)"/i)
|
|
24
|
+
if (noAdapter) {
|
|
25
|
+
const p = String(noAdapter[1] || '').trim()
|
|
26
|
+
return {
|
|
27
|
+
title: `这一轮的模型提供方没注册驱动:${p}`,
|
|
28
|
+
hint: 'NO_ADAPTER',
|
|
29
|
+
body: [
|
|
30
|
+
`它一启动就退出了 —— 不是不会刷,而是**那一轮要用的模型提供方 \`${p}\`,在它跑的那个 profile 里没有对应的驱动(adapter)**。`,
|
|
31
|
+
'',
|
|
32
|
+
'提供方的"配置"可能是全局的,但"注册这个提供方的驱动"是**每个 profile 各装各的** —— 主 profile 装了,不等于跑这一轮的 profile 也有。',
|
|
33
|
+
'',
|
|
34
|
+
'**怎么办(任选一种):**',
|
|
35
|
+
'',
|
|
36
|
+
'1. **最省事**:把那个 profile 的模型改回官方 —— 编辑 `~/.dsh/profiles/headless/cordis.patch.yml`(2.1.0 起是 `bili-duty`),加上:',
|
|
37
|
+
'',
|
|
38
|
+
' ```yaml',
|
|
39
|
+
' provider: deepseek-official',
|
|
40
|
+
' model: deepseek-flash',
|
|
41
|
+
' ```',
|
|
42
|
+
'',
|
|
43
|
+
' 或者在设置页「刷B站 → 高级 → headless profile」里,指到一个已经配好官方模型的 profile。',
|
|
44
|
+
`2. **继续用 \`${p}\`**:把注册它的那个提供方插件,也装进这个 profile。`,
|
|
45
|
+
'',
|
|
46
|
+
'> 这类失败是**环境配置**,不是插件坏了。改完不用重装插件,直接再点一次「现在跑一轮」。',
|
|
47
|
+
].join('\n'),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ② 没有可用凭据
|
|
52
|
+
if (/MISSING_CREDENTIAL|missing credential|no api key|Unauthorized|E401|\b401\b/i.test(t)) {
|
|
53
|
+
return {
|
|
54
|
+
title: '这一轮没有可用的模型凭据',
|
|
55
|
+
hint: 'NO_CREDENTIAL',
|
|
56
|
+
body: [
|
|
57
|
+
'它启动后没能拿到模型凭据就退了。',
|
|
58
|
+
'',
|
|
59
|
+
'**怎么办**:去 DSH 设置里确认已登录 / 已填 API key,然后再跑一次。',
|
|
60
|
+
'凭据是宿主那一层的事,插件读不到、也不该替用户填。',
|
|
61
|
+
].join('\n'),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ③ 找不到 dsh(桌面版常见)
|
|
66
|
+
if (/9009|不是内部或外部命令|could not be found|cannot find the file|ENOENT/i.test(t) === true &&
|
|
67
|
+
/dsh/i.test(t)) {
|
|
68
|
+
return {
|
|
69
|
+
title: '这一轮找不到 dsh 可执行文件',
|
|
70
|
+
hint: 'NO_DSH',
|
|
71
|
+
body: [
|
|
72
|
+
'桌面版多半不是 npm 全局安装,PATH 里没有 `dsh`。',
|
|
73
|
+
'',
|
|
74
|
+
'**怎么办**:在设置页把「dsh 路径」指到应用目录里的 `@deepseek-ai/dsh/lib/bin.js`;插件日志里会写出它试过的位置。',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ④ 提示词变量没有值
|
|
80
|
+
// ⚠️ 变量名可能长成 `{{model}}` 这种带花括号的写法 ⇒ 字符类里必须放行 {} $
|
|
81
|
+
const noVar = t.match(/prompt variable\s+"?([\w.{}$-]+)"?\s+has no value/i)
|
|
82
|
+
if (noVar) {
|
|
83
|
+
return {
|
|
84
|
+
title: `提示词变量 \`${noVar[1]}\` 没有值`,
|
|
85
|
+
hint: 'NO_VAR',
|
|
86
|
+
body: [
|
|
87
|
+
'多半是那个 profile 的模型 / 会话没绑好。',
|
|
88
|
+
'',
|
|
89
|
+
'**怎么办**:换一个新会话,或重启一次 DSH 再试。',
|
|
90
|
+
].join('\n'),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null
|
|
95
|
+
}
|
package/lib/mcp.mjs
CHANGED
|
@@ -67,12 +67,23 @@ export class McpClient {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** 调一个 MCP 工具:能解析成 JSON 就返回对象,否则返回字符串 */
|
|
70
|
-
async call(name, args = {},
|
|
70
|
+
async call(name, args = {}, opts = {}) {
|
|
71
|
+
return (await this.callRaw(name, args, opts)).json
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 同 call,但保留图片块:返回 { json, images },images 每项是 { data, mimeType } */
|
|
75
|
+
async callRaw(name, args = {}, { timeoutMs = 180000, signal } = {}) {
|
|
71
76
|
await this.start()
|
|
72
|
-
const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs)
|
|
73
|
-
const
|
|
77
|
+
const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs, signal)
|
|
78
|
+
const blocks = result?.content ?? []
|
|
79
|
+
const text = blocks.map((c) => c?.text ?? '').filter(Boolean).join('\n').trim()
|
|
74
80
|
if (result?.isError) throw new Error(text || `MCP 工具 ${name} 报错`)
|
|
75
|
-
|
|
81
|
+
const images = blocks
|
|
82
|
+
.filter((c) => c?.type === 'image' && c?.data)
|
|
83
|
+
.map((c) => ({ data: c.data, mimeType: c.mimeType || c.mime_type || 'image/jpeg' }))
|
|
84
|
+
let json = {}
|
|
85
|
+
try { json = text ? JSON.parse(text) : {} } catch { json = text }
|
|
86
|
+
return { json, images }
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
#onData(chunk) {
|
|
@@ -100,19 +111,33 @@ export class McpClient {
|
|
|
100
111
|
|
|
101
112
|
#notify(method, params) { this.#send({ jsonrpc: '2.0', method, params }) }
|
|
102
113
|
|
|
103
|
-
#request(method, params, timeoutMs = 60000) {
|
|
114
|
+
#request(method, params, timeoutMs = 60000, signal) {
|
|
104
115
|
const id = this.nextId++
|
|
105
116
|
return new Promise((resolve, reject) => {
|
|
106
|
-
|
|
117
|
+
if (signal?.aborted) {
|
|
118
|
+
reject(new Error(`MCP ${method} 已取消`))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
clearTimeout(timer)
|
|
107
123
|
this.pending.delete(id)
|
|
124
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
125
|
+
}
|
|
126
|
+
const onAbort = () => {
|
|
127
|
+
cleanup()
|
|
128
|
+
reject(new Error(`MCP ${method} 已取消`))
|
|
129
|
+
}
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
cleanup()
|
|
108
132
|
reject(new Error(`MCP ${method} 超时(${timeoutMs}ms)${this.stderr ? ':' + this.stderr.slice(-200) : ''}`))
|
|
109
133
|
}, timeoutMs)
|
|
110
134
|
this.pending.set(id, {
|
|
111
|
-
resolve: (v) => {
|
|
112
|
-
reject: (e) => {
|
|
135
|
+
resolve: (v) => { cleanup(); resolve(v) },
|
|
136
|
+
reject: (e) => { cleanup(); reject(e) },
|
|
113
137
|
})
|
|
138
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
114
139
|
try { this.#send({ jsonrpc: '2.0', id, method, params }) } catch (e) {
|
|
115
|
-
|
|
140
|
+
cleanup(); reject(e)
|
|
116
141
|
}
|
|
117
142
|
})
|
|
118
143
|
}
|
package/lib/state.mjs
CHANGED
|
@@ -88,18 +88,17 @@ export function ensureHomeFiles(pluginDir, stateDir, log = () => {}) {
|
|
|
88
88
|
}
|
|
89
89
|
const onDisk = readFileSync(to, 'utf8')
|
|
90
90
|
if (onDisk === incoming) { if (!shipped) writeMark(incoming); continue } // 本来就是同一份
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
91
|
+
// ⭐⭐ 2.1.0(2026-10-24 实证):剩下两种情况的处理可以并成一条 ——
|
|
92
|
+
// 这几个文件是**我们发的运行时代码**(mcp_server.py / asr.py / favops.py),
|
|
93
|
+
// 用户改过也不该拦着升级:否则"宿主新、Python 侧旧",新工具根本不出现 ✗
|
|
94
|
+
// (今天就这么栽的:家目录那份停在 11:50,值班轮里说"没有 bili_cover 这个工具")
|
|
95
|
+
// ⇒ **先备份 .bak-<时间戳>,再覆盖**,改了什么都能自己捞回来 ✓
|
|
96
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
|
|
97
|
+
const bak = to + ".bak-" + stamp
|
|
98
|
+
try { copyFileSync(to, bak) } catch { /* 备份不了也继续,原件在插件包里 */ }
|
|
99
|
+
copyFileSync(from, to)
|
|
100
|
+
writeMark(incoming)
|
|
101
|
+
log(`已覆盖:${to}(旧的那份备份在 ${bak})`)
|
|
103
102
|
} catch (e) { log(`铺 ${to} 失败:${e?.message ?? e}`) }
|
|
104
103
|
}
|
|
105
104
|
}
|
package/lib/uvfetch.mjs
CHANGED
|
@@ -1,161 +1,217 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/uvfetch.mjs —— 「机器上连 uv 和 Python 都没有」时,**插件自己把 uv 弄到手**
|
|
3
|
-
* ============================================================================
|
|
4
|
-
* 为什么值得单独做:uv 是**单个可执行文件**(Windows 上 ~32MB 的 uv.exe ✓),
|
|
5
|
-
* **解压就能用、不用管理员、不用进 PATH** ✓,而且它还能自己把 Python 拉下来 ✓
|
|
6
|
-
* ⇒ 只要拿到它,"首次安装"就从「先去装个 Python」变成「点一下就好」✓
|
|
7
|
-
*
|
|
8
|
-
* 两个源,按顺序试:
|
|
9
|
-
* ① **GitHub 官方 release**(`uv-x86_64-pc-windows-msvc.zip`)—— 有加速器时最快 ✓
|
|
10
|
-
* ② ⭐ **PyPI 清华镜像的 uv wheel** —— wheel 本身就是个 zip,里面装着 `uv.exe` ✓
|
|
11
|
-
* (国内速度快、不依赖 GitHub ✓)
|
|
12
|
-
*
|
|
13
|
-
* 解压:优先用 **系统自带的 `tar`**(Windows 10+ 的 bsdtar 能直接读 zip ✓),
|
|
14
|
-
* 再退到 `unzip`;然后**在解压出来的整棵树里按文件名找** ✓
|
|
15
|
-
* —— 不管官方把它放在哪一层都能找着 ✓
|
|
16
|
-
* ============================================================================
|
|
17
|
-
*/
|
|
18
|
-
import { spawnSync } from 'node:child_process'
|
|
19
|
-
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, chmodSync, rmSync, readFileSync } from 'node:fs'
|
|
20
|
-
import { createHash } from 'node:crypto'
|
|
21
|
-
import { join } from 'node:path'
|
|
22
|
-
import { Readable } from 'node:stream'
|
|
23
|
-
import { pipeline } from 'node:stream/promises'
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* lib/uvfetch.mjs —— 「机器上连 uv 和 Python 都没有」时,**插件自己把 uv 弄到手**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* 为什么值得单独做:uv 是**单个可执行文件**(Windows 上 ~32MB 的 uv.exe ✓),
|
|
5
|
+
* **解压就能用、不用管理员、不用进 PATH** ✓,而且它还能自己把 Python 拉下来 ✓
|
|
6
|
+
* ⇒ 只要拿到它,"首次安装"就从「先去装个 Python」变成「点一下就好」✓
|
|
7
|
+
*
|
|
8
|
+
* 两个源,按顺序试:
|
|
9
|
+
* ① **GitHub 官方 release**(`uv-x86_64-pc-windows-msvc.zip`)—— 有加速器时最快 ✓
|
|
10
|
+
* ② ⭐ **PyPI 清华镜像的 uv wheel** —— wheel 本身就是个 zip,里面装着 `uv.exe` ✓
|
|
11
|
+
* (国内速度快、不依赖 GitHub ✓)
|
|
12
|
+
*
|
|
13
|
+
* 解压:优先用 **系统自带的 `tar`**(Windows 10+ 的 bsdtar 能直接读 zip ✓),
|
|
14
|
+
* 再退到 `unzip`;然后**在解压出来的整棵树里按文件名找** ✓
|
|
15
|
+
* —— 不管官方把它放在哪一层都能找着 ✓
|
|
16
|
+
* ============================================================================
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, chmodSync, rmSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { Readable } from 'node:stream'
|
|
23
|
+
import { pipeline } from 'node:stream/promises'
|
|
24
|
+
|
|
25
|
+
/** 本机 Linux 是不是 musl libc(glibc 系统的 report header 会带 glibcVersionRuntime) */
|
|
26
|
+
function isMusl() {
|
|
27
|
+
if (process.platform !== 'linux') return false
|
|
28
|
+
try {
|
|
29
|
+
const v = process.report?.getReport?.()?.header?.glibcVersionRuntime
|
|
30
|
+
if (typeof v === 'string' && v.length) return false
|
|
31
|
+
} catch { /* 拿不到就继续,下面按动态链接器文件名判断 */ }
|
|
32
|
+
try {
|
|
33
|
+
return existsSync('/lib/ld-musl-aarch64.so.1') || existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-i386.so.1')
|
|
34
|
+
} catch { return false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 按 platform + arch 挑 uv 官方 release 的资产名(按优先级排序的候选列表)。
|
|
39
|
+
* 之前这里写死 x86_64(linux)/ aarch64(darwin)/ x86_64(win32)⇒ 别的架构会下错二进制,
|
|
40
|
+
* 解压后版本检查跑不起来(比如 arm64 机器拿到 x86-64 的包)。现在按 arch 选,linux 再把
|
|
41
|
+
* glibc/musl 各列一个候选,靠下面的版本检查兜底。
|
|
42
|
+
*/
|
|
43
|
+
function uvAssetCandidates() {
|
|
44
|
+
const p = process.platform
|
|
45
|
+
const a = process.arch
|
|
46
|
+
if (p === 'win32') {
|
|
47
|
+
if (a === 'arm64') return { bin: 'uv.exe', names: ['uv-aarch64-pc-windows-msvc.zip'] }
|
|
48
|
+
if (a === 'ia32') return { bin: 'uv.exe', names: ['uv-i686-pc-windows-msvc.zip'] }
|
|
49
|
+
return { bin: 'uv.exe', names: ['uv-x86_64-pc-windows-msvc.zip'] }
|
|
50
|
+
}
|
|
51
|
+
if (p === 'darwin') {
|
|
52
|
+
return a === 'arm64'
|
|
53
|
+
? { bin: 'uv', names: ['uv-aarch64-apple-darwin.tar.gz'] }
|
|
54
|
+
: { bin: 'uv', names: ['uv-x86_64-apple-darwin.tar.gz'] }
|
|
55
|
+
}
|
|
56
|
+
// linux(及其余落到这行的 unix);只列 uv 官方出资产的架构
|
|
57
|
+
const arch = {
|
|
58
|
+
x64: 'x86_64',
|
|
59
|
+
arm64: 'aarch64',
|
|
60
|
+
arm: 'armv7',
|
|
61
|
+
ia32: 'i686',
|
|
62
|
+
ppc64: 'powerpc64le',
|
|
63
|
+
s390x: 's390x',
|
|
64
|
+
riscv64: 'riscv64gc',
|
|
65
|
+
}[a]
|
|
66
|
+
if (!arch) return null
|
|
67
|
+
// armv7 的硬浮点是 gnueabihf / musleabihf,其余是 gnu / musl
|
|
68
|
+
const hf = arch === 'armv7' ? 'eabihf' : ''
|
|
69
|
+
const libcs = isMusl() ? ['musl', 'gnu'] : ['gnu', 'musl']
|
|
70
|
+
return { bin: 'uv', names: libcs.map((l) => `uv-${arch}-unknown-linux-${l}${hf}.tar.gz`) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 算一个文件的 sha256 */
|
|
74
|
+
function sha256Of(file) {
|
|
75
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 取"官方期望的 sha256":
|
|
80
|
+
* · PyPI wheel:JSON 里自带 `digests.sha256` ✓
|
|
81
|
+
* · GitHub 资产:同目录下有一份 `<文件名>.sha256` ✓(代理会一起转 ✓)
|
|
82
|
+
* 取不到就返回 null(那时只记一句"未校验",不拦着)
|
|
83
|
+
*/
|
|
84
|
+
async function expectedSha(source) {
|
|
85
|
+
if (source.sha) return String(source.sha).toLowerCase()
|
|
86
|
+
try {
|
|
87
|
+
const r = await fetch(source.url + '.sha256')
|
|
88
|
+
if (!r.ok) return null
|
|
89
|
+
const m = /([0-9a-f]{64})/i.exec(await r.text())
|
|
90
|
+
return m ? m[1].toLowerCase() : null
|
|
91
|
+
} catch { return null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 在目录树里按文件名找可执行文件(递归) */
|
|
95
|
+
export function findBin(name, dir) {
|
|
96
|
+
let entries = []
|
|
97
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
const p = join(dir, e.name)
|
|
100
|
+
if (e.isDirectory()) { const hit = findBin(name, p); if (hit) return hit }
|
|
101
|
+
else if (e.name === name) return p
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 解压一个压缩包并找出目标文件(tar 优先 —— Windows 自带那份能读 zip) */
|
|
107
|
+
export function extractArchive(binName, archive, into) {
|
|
108
|
+
const attempts = [['tar', ['-xf', archive, '-C', into]], ['unzip', ['-o', archive, '-d', into]]]
|
|
109
|
+
for (const [cmd, args] of attempts) {
|
|
110
|
+
try {
|
|
111
|
+
const r = spawnSync(cmd, args, { stdio: 'ignore' })
|
|
112
|
+
if (r.status === 0) { const hit = findBin(binName, into); if (hit) return hit }
|
|
113
|
+
} catch { /* 换下一个工具 */ }
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 确保家目录里有一份可用的 uv,返回它的路径(拿不到就返回 null)。
|
|
120
|
+
* @param {{ dir: string, log?: (m: string) => void }} opts
|
|
121
|
+
*/
|
|
122
|
+
export async function ensureUv({ dir, log = () => {} }) {
|
|
123
|
+
const a = uvAssetCandidates()
|
|
124
|
+
if (!a) { log(`这台机器的平台/架构没有对应的 uv 官方资产(${process.platform} ${process.arch}),请手动放一份 uv 到 bin/`); return null }
|
|
125
|
+
const target = join(dir, a.bin)
|
|
126
|
+
if (existsSync(target)) return target
|
|
127
|
+
|
|
128
|
+
mkdirSync(dir, { recursive: true })
|
|
129
|
+
const tmp = join(dir, '_tmp')
|
|
130
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
131
|
+
mkdirSync(tmp, { recursive: true })
|
|
132
|
+
|
|
133
|
+
// 源按顺序试:每个候选资产名 ×(直连 → 两个国内能用的 GitHub 加速代理)
|
|
134
|
+
// → 最后兜底是 PyPI 镜像上的 uv wheel(win32 才有,且只有 x64 的 wheel)
|
|
135
|
+
const sources = a.names.flatMap((name) => {
|
|
136
|
+
const asset = `https://github.com/astral-sh/uv/releases/latest/download/${name}`
|
|
137
|
+
return [
|
|
138
|
+
{ label: `GitHub 直连(${name})`, url: asset, file: join(tmp, name) },
|
|
139
|
+
{ label: `GitHub 加速代理 gh-proxy.com(${name})`, url: `https://gh-proxy.com/${asset}`, file: join(tmp, name) },
|
|
140
|
+
{ label: `GitHub 加速代理 ghproxy.net(${name})`, url: `https://ghproxy.net/${asset}`, file: join(tmp, name) },
|
|
141
|
+
]
|
|
142
|
+
})
|
|
143
|
+
if (process.platform === 'win32') {
|
|
144
|
+
try {
|
|
145
|
+
const r = await fetch('https://pypi.tuna.tsinghua.edu.cn/pypi/uv/json')
|
|
146
|
+
const j = await r.json()
|
|
147
|
+
// ⚠️ PyPI 的 urls 顺序**不保证最新在前** ⇒ 必须按版本号自己排序挑最新(踩过:取到了 0.2.33 的老版本 ✗)
|
|
148
|
+
const verOf = (name) => { const m = /-(\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?)-py3-none-win_amd64\.whl$/.exec(String(name)); return m ? m[1] : '0.0.0' }
|
|
149
|
+
const cmpVer = (a, b) => {
|
|
150
|
+
const A = verOf(a).split(/[.-]/).map(Number), B = verOf(b).split(/[.-]/).map(Number)
|
|
151
|
+
for (let i = 0; i < 4; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (B[i] || 0) - (A[i] || 0) }
|
|
152
|
+
return 0
|
|
153
|
+
}
|
|
154
|
+
const w = (j.urls || [])
|
|
155
|
+
.filter((u) => /win_amd64\.whl$/.test(String(u.filename)))
|
|
156
|
+
.sort((x, y) => cmpVer(x.filename, y.filename))[0]
|
|
157
|
+
if (w?.url) {
|
|
158
|
+
sources.push({ label: 'PyPI 清华镜像(uv wheel)', url: w.url, file: join(tmp, String(w.filename)), sha: w.digests?.sha256 })
|
|
159
|
+
log(` (备选源已就绪:${w.filename})`)
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
log(` 列 PyPI 版本时出错(不影响,继续试别的源):${e?.message ?? e}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const s of sources) {
|
|
167
|
+
try {
|
|
168
|
+
// 每个候选都从干净的解压目录开始,别让上一个候选的残留文件被 findBin 误判
|
|
169
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
170
|
+
mkdirSync(tmp, { recursive: true })
|
|
171
|
+
log(`正在下载 uv —— ${s.label}`)
|
|
172
|
+
const res = await fetch(s.url)
|
|
173
|
+
if (!res.ok) { log(` → 返回 ${res.status},换下一个源`); continue }
|
|
174
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(s.file))
|
|
175
|
+
const mb = (statSync(s.file).size / 1048576).toFixed(1)
|
|
176
|
+
log(` → 下载完成 ${mb} MB`)
|
|
177
|
+
// ⭐ 完整性校验:下载下来是要**被执行**的,必须跟官方 sha256 对上(防的是第三方代理动过包 ✗)
|
|
178
|
+
const want = await expectedSha(s)
|
|
179
|
+
if (want) {
|
|
180
|
+
const gotSha = sha256Of(s.file)
|
|
181
|
+
if (gotSha !== want) {
|
|
182
|
+
log(` → ⚠️ sha256 校验不过(期望 ${want.slice(0, 12)}… 实际 ${gotSha.slice(0, 12)}…)⇒ 丢弃这个源`)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
log(' → 官方 sha256 校验通过 ✓')
|
|
186
|
+
} else {
|
|
187
|
+
log(' → 没取到官方 sha256,跳过校验(下载来自 HTTPS 的官方地址时风险很小)')
|
|
188
|
+
}
|
|
189
|
+
log(' → 开始解压')
|
|
190
|
+
const hit = extractArchive(a.bin, s.file, tmp)
|
|
191
|
+
if (!hit) { log(' → 解压后没找到可执行文件,换下一个源'); continue }
|
|
192
|
+
copyFileSync(hit, target)
|
|
193
|
+
try { chmodSync(target, 0o755) } catch { /* Windows 上不需要 */ }
|
|
194
|
+
// ⭐ 版本检查:这个二进制得能**在这台机器上跑**——架构/ABI 不匹配(比如把 x86 的包下到了 arm64)会跑不起来 ✗
|
|
195
|
+
let runs = false
|
|
196
|
+
try {
|
|
197
|
+
const r = spawnSync(target, ['--version'], { stdio: 'ignore' })
|
|
198
|
+
runs = r.status === 0
|
|
199
|
+
log(' → 版本检查:' + (runs ? '能跑 ✓' : '跑不起来 ✗'))
|
|
200
|
+
} catch { runs = false }
|
|
201
|
+
if (!runs) {
|
|
202
|
+
log(' → 架构/ABI 不匹配,换下一个候选')
|
|
203
|
+
try { rmSync(target, { force: true }) } catch { /* 删不掉也不拦着 */ }
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
207
|
+
log(` → uv 已就位:${target}`)
|
|
208
|
+
return target
|
|
209
|
+
} catch (e) {
|
|
210
|
+
log(` → ${s.label} 失败:${e?.message ?? e}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
215
|
+
log('所有源都拿不到 uv(大概是网络到不了 GitHub / PyPI 镜像)')
|
|
216
|
+
return null
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bili-scroll",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "DSH 插件:每天替你刷一轮 B站 —— 读字幕挑出值得的、写一份日报,值得的就 @ 你",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./package.json": "./package.json"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
+
"profile-template",
|
|
25
26
|
"index.js",
|
|
26
27
|
"client.js",
|
|
27
28
|
"cordis.patch.yml",
|
|
@@ -32,7 +33,8 @@
|
|
|
32
33
|
"AGENTS.md",
|
|
33
34
|
"selfcheck.mjs",
|
|
34
35
|
"LICENSE",
|
|
35
|
-
"NOTICE.md",
|
|
36
|
+
"NOTICE.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
36
38
|
"CONTRIBUTORS.md"
|
|
37
39
|
],
|
|
38
40
|
"engines": {
|