dsh-bili-scroll 2.0.2 → 2.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/lib/login.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { spawn } from 'node:child_process'
18
18
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
19
+ import { execFileSync } from 'node:child_process'
19
20
  import { join } from 'node:path'
20
21
 
21
22
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
@@ -75,6 +76,23 @@ export async function loginPoll(key) {
75
76
  }
76
77
 
77
78
  /** 把凭证写进 MCP 读的那个文件(家目录里的 bili_credential.json) */
79
+ /**
80
+ * ⭐ 2026-10-23 安全加固(审计 + issue #67):凭证文件权限收紧到"只有自己能读" ——
81
+ * Linux/macOS: chmod 0600;Windows: icacls 去掉继承(只留当前用户)。
82
+ * 失败也不报错(有些文件系统不支持),但会返回 false 让调用方可以说明。
83
+ */
84
+ export function hardenPerms(file) {
85
+ try {
86
+ if (process.platform === 'win32') {
87
+ const user = process.env.USERNAME || ''
88
+ execFileSync('icacls', [file, '/inheritance:r', '/grant:r', user + ':F'], { stdio: 'ignore', windowsHide: true })
89
+ } else {
90
+ execFileSync('chmod', ['600', file], { stdio: 'ignore' })
91
+ }
92
+ return true
93
+ } catch { return false }
94
+ }
95
+
78
96
  export function saveCredential(stateDir, credential) {
79
97
  mkdirSync(stateDir, { recursive: true })
80
98
  const file = join(stateDir, 'bili_credential.json')
@@ -84,6 +102,7 @@ export function saveCredential(stateDir, credential) {
84
102
  buvid3: credential.buvid3 || '',
85
103
  dedeuserid: credential.dedeuserid ?? '',
86
104
  }, null, 2), 'utf8')
105
+ try { hardenPerms(file) } catch { /* 收紧失败也不影响登录 */ }
87
106
  return file
88
107
  }
89
108
 
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 = {}, { timeoutMs = 180000 } = {}) {
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 text = (result?.content ?? []).map((c) => c?.text ?? '').join('\n').trim()
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
- try { return text ? JSON.parse(text) : {} } catch { return text }
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
- const timer = setTimeout(() => {
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) => { clearTimeout(timer); resolve(v) },
112
- reject: (e) => { clearTimeout(timer); 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
- clearTimeout(timer); this.pending.delete(id); reject(e)
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
- if (shipped && sha(Buffer.from(onDisk, 'utf8')) === shipped) {
92
- // 家里那份就是我们上次发的 ⇒ 正常升级
93
- copyFileSync(from, to); writeMark(incoming); log(`已更新:${to}`)
94
- } else if (!shipped) {
95
- // 老版本升级上来,没有指纹:按老行为覆盖一次,从此开始记指纹
96
- copyFileSync(from, to); writeMark(incoming); log(`已更新(首次记录指纹):${to}`)
97
- } else {
98
- // 用户改过 ⇒ 不覆盖,新版放旁边
99
- const side = to + '.new'
100
- try { writeFileSync(side, incoming, 'utf8') } catch { /* 写不了就算了 */ }
101
- log(`注意:${to} 你改过,这次没覆盖;新版放在 ${side}`)
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
- const ASSETS = {
26
- win32: { name: 'uv-x86_64-pc-windows-msvc.zip', bin: 'uv.exe' },
27
- darwin: { name: 'uv-aarch64-apple-darwin.tar.gz', bin: 'uv' },
28
- linux: { name: 'uv-x86_64-unknown-linux-gnu.tar.gz', bin: 'uv' },
29
- }
30
-
31
- /** 算一个文件的 sha256 */
32
- function sha256Of(file) {
33
- return createHash('sha256').update(readFileSync(file)).digest('hex')
34
- }
35
-
36
- /**
37
- * 取"官方期望的 sha256":
38
- * · PyPI wheel:JSON 里自带 `digests.sha256` ✓
39
- * · GitHub 资产:同目录下有一份 `<文件名>.sha256` ✓(代理会一起转 ✓)
40
- * 取不到就返回 null(那时只记一句"未校验",不拦着)
41
- */
42
- async function expectedSha(source) {
43
- if (source.sha) return String(source.sha).toLowerCase()
44
- try {
45
- const r = await fetch(source.url + '.sha256')
46
- if (!r.ok) return null
47
- const m = /([0-9a-f]{64})/i.exec(await r.text())
48
- return m ? m[1].toLowerCase() : null
49
- } catch { return null }
50
- }
51
-
52
- /** 在目录树里按文件名找可执行文件(递归) */
53
- export function findBin(name, dir) {
54
- let entries = []
55
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
56
- for (const e of entries) {
57
- const p = join(dir, e.name)
58
- if (e.isDirectory()) { const hit = findBin(name, p); if (hit) return hit }
59
- else if (e.name === name) return p
60
- }
61
- return null
62
- }
63
-
64
- /** 解压一个压缩包并找出目标文件(tar 优先 —— Windows 自带那份能读 zip) */
65
- export function extractArchive(binName, archive, into) {
66
- const attempts = [['tar', ['-xf', archive, '-C', into]], ['unzip', ['-o', archive, '-d', into]]]
67
- for (const [cmd, args] of attempts) {
68
- try {
69
- const r = spawnSync(cmd, args, { stdio: 'ignore' })
70
- if (r.status === 0) { const hit = findBin(binName, into); if (hit) return hit }
71
- } catch { /* 换下一个工具 */ }
72
- }
73
- return null
74
- }
75
-
76
- /**
77
- * 确保家目录里有一份可用的 uv,返回它的路径(拿不到就返回 null)。
78
- * @param {{ dir: string, log?: (m: string) => void }} opts
79
- */
80
- export async function ensureUv({ dir, log = () => {} }) {
81
- const a = ASSETS[process.platform] || ASSETS.linux
82
- const target = join(dir, a.bin)
83
- if (existsSync(target)) return target
84
-
85
- mkdirSync(dir, { recursive: true })
86
- const tmp = join(dir, '_tmp')
87
- rmSync(tmp, { recursive: true, force: true })
88
- mkdirSync(tmp, { recursive: true })
89
-
90
- // 源按顺序试:直连 → 两个国内能用的 GitHub 加速代理(2026-10-14 实测这两条能出真 zip ✓)
91
- // → 最后兜底是 PyPI 镜像上的 uv wheel(⚠️ 那个停在 0.2.33/2024-08,能用但不是最新 ✗)
92
- const asset = `https://github.com/astral-sh/uv/releases/latest/download/${a.name}`
93
- const sources = [
94
- { label: 'GitHub 直连', url: asset, file: join(tmp, a.name) },
95
- { label: 'GitHub 加速代理 gh-proxy.com', url: `https://gh-proxy.com/${asset}`, file: join(tmp, a.name) },
96
- { label: 'GitHub 加速代理 ghproxy.net', url: `https://ghproxy.net/${asset}`, file: join(tmp, a.name) },
97
- ]
98
- if (process.platform === 'win32') {
99
- try {
100
- const r = await fetch('https://pypi.tuna.tsinghua.edu.cn/pypi/uv/json')
101
- const j = await r.json()
102
- // ⚠️ PyPI 的 urls 顺序**不保证最新在前** ⇒ 必须按版本号自己排序挑最新(踩过:取到了 0.2.33 的老版本 ✗)
103
- 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' }
104
- const cmpVer = (a, b) => {
105
- const A = verOf(a).split(/[.-]/).map(Number), B = verOf(b).split(/[.-]/).map(Number)
106
- for (let i = 0; i < 4; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (B[i] || 0) - (A[i] || 0) }
107
- return 0
108
- }
109
- const w = (j.urls || [])
110
- .filter((u) => /win_amd64\.whl$/.test(String(u.filename)))
111
- .sort((x, y) => cmpVer(x.filename, y.filename))[0]
112
- if (w?.url) {
113
- sources.push({ label: 'PyPI 清华镜像(uv wheel)', url: w.url, file: join(tmp, String(w.filename)), sha: w.digests?.sha256 })
114
- log(` (备选源已就绪:${w.filename})`)
115
- }
116
- } catch (e) {
117
- log(` 列 PyPI 版本时出错(不影响,继续试别的源):${e?.message ?? e}`)
118
- }
119
- }
120
-
121
- for (const s of sources) {
122
- try {
123
- log(`正在下载 uv —— ${s.label}`)
124
- const res = await fetch(s.url)
125
- if (!res.ok) { log(` → 返回 ${res.status},换下一个源`); continue }
126
- await pipeline(Readable.fromWeb(res.body), createWriteStream(s.file))
127
- const mb = (statSync(s.file).size / 1048576).toFixed(1)
128
- log(` → 下载完成 ${mb} MB`)
129
- // ⭐ 完整性校验:下载下来是要**被执行**的,必须跟官方 sha256 对上(防的是第三方代理动过包 ✗)
130
- const want = await expectedSha(s)
131
- if (want) {
132
- const gotSha = sha256Of(s.file)
133
- if (gotSha !== want) {
134
- log(` → ⚠️ sha256 校验不过(期望 ${want.slice(0, 12)}… 实际 ${gotSha.slice(0, 12)}…)⇒ 丢弃这个源`)
135
- continue
136
- }
137
- log(' → 官方 sha256 校验通过 ✓')
138
- } else {
139
- log(' → 没取到官方 sha256,跳过校验(下载来自 HTTPS 的官方地址时风险很小)')
140
- }
141
- log(' → 开始解压')
142
- const hit = extractArchive(a.bin, s.file, tmp)
143
- if (!hit) { log(' → 解压后没找到可执行文件,换下一个源'); continue }
144
- copyFileSync(hit, target)
145
- try { chmodSync(target, 0o755) } catch { /* Windows 上不需要 */ }
146
- try {
147
- const r = spawnSync(target, ['--version'], { stdio: 'ignore' })
148
- log(' → 版本检查:' + (r.status === 0 ? '能跑 ✓' : '跑不起来 ✗'))
149
- } catch { /* 跑不起来也别拦着,后面自己会报错 */ }
150
- rmSync(tmp, { recursive: true, force: true })
151
- log(` → uv 已就位:${target}`)
152
- return target
153
- } catch (e) {
154
- log(` → ${s.label} 失败:${e?.message ?? e}`)
155
- }
156
- }
157
-
158
- rmSync(tmp, { recursive: true, force: true })
159
- log('所有源都拿不到 uv(大概是网络到不了 GitHub / PyPI 镜像)')
160
- return null
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.2",
3
+ "version": "2.1.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",