dsh-pisces 0.2.6 → 0.2.7

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/biliqr.js ADDED
@@ -0,0 +1,341 @@
1
+ // biliqr.js —— B 站扫码登录(宿主侧)。
2
+ //
3
+ // 移植说明(2026-11-10):取自 dsh-fish 的 `lib/biliqr.js`(MIT,Copyright (c) 2026
4
+ // 大肥鱼(dsh-fish)作者;扫码登录由 @Bacon-01 贡献,PR #17)。按本插件改了三处:
5
+ // 1. 落盘走 `lib/bili.js` 的 writeBili(owner.json,0600 原子写),不再单独存 account;
6
+ // 2. 认账号走 `checkBili`(nav 接口),不依赖 dsh-fish 的 createClient;
7
+ // 3. 没有 bili_jct 的登录不给存——这个插件的 B 站登录只为了说话,发评论必须有 csrf。
8
+ //
9
+ // 为什么不在浏览器里直接请求 B 站:
10
+ // generate / poll 这两个接口没有 CORS 头,页面里 fetch 一律被浏览器挡死。
11
+ // 所以二维码的生成、轮询、换 cookie 全在宿主这一侧做,页面只跟 `/dsh-pisces-api/bili/qr*` 说话。
12
+ //
13
+ // 三条安全约定(比功能本身重要):
14
+ // 1. **key 与 cookie 只活在内存里**。没点「保存」之前不落盘、不进日志、不回给页面。
15
+ // 页面拿到的只有「登的是谁」(昵称 + uid),永远拿不到 cookie 原文 —— 和粘贴那条路同一条规矩。
16
+ // 2. **确认才写盘**。轮询到 DONE 只是把 cookie 攥在 pending 里,写 owner.json 要人再点一次。
17
+ // 3. **过期就不认**。180 秒之后既不再轮询,也不接受 save;丢掉就是丢掉,下次重新生成。
18
+ //
19
+ // 状态机:waiting → scanned → confirmed → (人点保存)saved
20
+ // ↘ expired / error / gone
21
+ import { checkBili, writeBili } from './bili.js'
22
+ import { qrSvg } from './qr.js'
23
+
24
+ const GEN_URL =
25
+ 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
26
+ const pollUrl = (key) =>
27
+ `https://passport.bilibili.com/x/passport-login/web/qrcode/poll?source=main-fe-header&qrcode_key=${encodeURIComponent(key)}`
28
+
29
+ // B 站对这个接口比较挑 UA:拿 node 默认 UA 去问,偶尔会直接给 -412。
30
+ const UA =
31
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
32
+
33
+ export const QR_TTL_MS = 180_000
34
+ const FORGET_MS = QR_TTL_MS + 120_000 // 过期之后再留一会儿:让人还能看到"已过期"而不是"不存在"
35
+ const MAX_PENDING = 3
36
+ const MIN_POLL_GAP_MS = 800
37
+ const MIN_START_GAP_MS = 1_200
38
+
39
+ // 只留这几个键(和 dsh-fish 的 owner.js 对齐;buvid3 有时给、有时不给,有就留着)。
40
+ const WANT = ['SESSDATA', 'bili_jct', 'buvid3', 'DedeUserID', 'DedeUserID__ckMd5', 'sid']
41
+
42
+ /** 把 Set-Cookie 那几行里我们要的键抠出来。 */
43
+ function pickCookies(headers) {
44
+ let lines = []
45
+ try {
46
+ if (typeof headers?.getSetCookie === 'function') lines = headers.getSetCookie() ?? []
47
+ } catch {
48
+ lines = []
49
+ }
50
+ if (!lines.length) {
51
+ const raw = headers?.get?.('set-cookie') ?? ''
52
+ // 兜底:没有 getSetCookie 的老运行时。expires 里也有逗号,所以只在"逗号后面跟着 name="处切。
53
+ if (raw) lines = String(raw).split(/,(?=\s*[A-Za-z0-9_]+=)/)
54
+ }
55
+ const got = {}
56
+ for (const line of lines) {
57
+ const head = String(line).split(';')[0]
58
+ const i = head.indexOf('=')
59
+ if (i <= 0) continue
60
+ const name = head.slice(0, i).trim()
61
+ const value = head.slice(i + 1).trim()
62
+ if (WANT.includes(name) && value) got[name] = value
63
+ }
64
+ return got
65
+ }
66
+
67
+ /** 拼成 owner.json 里那种 `a=1; b=2` 的形状(顺序固定,测试好比对)。 */
68
+ function joinCookies(got) {
69
+ return WANT.filter((n) => got[n])
70
+ .map((n) => `${n}=${got[n]}`)
71
+ .join('; ')
72
+ }
73
+
74
+ /**
75
+ * 建一个扫码登录的小机器。fetch / 时钟 / 日志都做成可注入,单测不用打真网。
76
+ * @param {{fetchImpl?:Function, now?:()=>number, log?:(m:string)=>void}} [opts]
77
+ */
78
+ export function createQrLogin(opts = {}) {
79
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch
80
+ const now = opts.now ?? (() => Date.now())
81
+ const log = opts.log ?? (() => {})
82
+
83
+ /** @type {Map<string, {key:string,url:string,createdAt:number,lastPollAt:number,state:string,cookie:string,account:{mid:string,name:string}|null,note:string}>} */
84
+ const pending = new Map()
85
+ // -Infinity 起步:注入的时钟可能从 0 开始,别让"上一次生成"的初值把第一张码节流掉。
86
+ let lastStart = -Infinity
87
+
88
+ function sweep() {
89
+ const t = now()
90
+ for (const [k, e] of pending) {
91
+ if (t - e.createdAt > FORGET_MS) pending.delete(k)
92
+ }
93
+ // 超出上限就先丢最老的:这是本机上的登录窗口,不是缓存,不心疼。
94
+ while (pending.size > MAX_PENDING) {
95
+ const oldest = [...pending.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)[0]
96
+ if (!oldest) break
97
+ pending.delete(oldest[0])
98
+ }
99
+ }
100
+
101
+ async function request(url) {
102
+ const ctl = new AbortController()
103
+ const timer = setTimeout(() => ctl.abort(), 12_000)
104
+ try {
105
+ return await fetchImpl(url, {
106
+ signal: ctl.signal,
107
+ headers: {
108
+ 'user-agent': UA,
109
+ referer: 'https://www.bilibili.com/',
110
+ accept: 'application/json, text/plain, */*',
111
+ 'accept-language': 'zh-CN,zh;q=0.9',
112
+ },
113
+ })
114
+ } finally {
115
+ clearTimeout(timer)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 生成一张新码。返回里的 svg 直接给页面当图用:
121
+ * 登录 URL 里有 qrcode_key,走第三方图片服务等于把钥匙递出去。
122
+ */
123
+ async function start() {
124
+ sweep()
125
+ const t = now()
126
+ if (t - lastStart < MIN_START_GAP_MS) {
127
+ return {
128
+ ok: false,
129
+ why: '刚生成过,等一秒再点。',
130
+ retryAfterMs: MIN_START_GAP_MS - (t - lastStart),
131
+ }
132
+ }
133
+ lastStart = t
134
+
135
+ let res
136
+ try {
137
+ res = await request(GEN_URL)
138
+ } catch (err) {
139
+ return { ok: false, why: `连不上 B 站:${String(err?.message ?? err).slice(0, 60)}` }
140
+ }
141
+ let body
142
+ try {
143
+ body = JSON.parse(await res.text())
144
+ } catch {
145
+ return { ok: false, why: `B 站回的不是 JSON(HTTP ${res.status})` }
146
+ }
147
+ const url = String(body?.data?.url ?? '')
148
+ const key = String(body?.data?.qrcode_key ?? '')
149
+ if (body?.code !== 0 || !url || !key) {
150
+ return { ok: false, why: `B 站没给码:${body?.code ?? '?'} ${body?.message ?? ''}`.trim() }
151
+ }
152
+
153
+ let svg = ''
154
+ try {
155
+ svg = qrSvg(url, { ec: 'M', scale: 6, margin: 3 })
156
+ } catch (err) {
157
+ // 画不出来多半是 URL 长得超出预期:宁可当场说清楚,也别给一张扫不出的图。
158
+ return { ok: false, why: `二维码画不出来:${String(err?.message ?? err).slice(0, 60)}` }
159
+ }
160
+
161
+ pending.set(key, {
162
+ key,
163
+ url,
164
+ createdAt: t,
165
+ lastPollAt: 0,
166
+ state: 'waiting',
167
+ cookie: '',
168
+ account: null,
169
+ note: '',
170
+ })
171
+ // 放进去之后再收一次:MAX_PENDING 管的是"此刻攥着几把钥匙",不是"放之前有几把"。
172
+ sweep()
173
+ return { ok: true, key, svg, ttlMs: QR_TTL_MS, expiresAt: t + QR_TTL_MS }
174
+ }
175
+
176
+ /** 轮询一次。页面每秒问一次也没关系:真请求有 800ms 的下限。 */
177
+ async function poll(key) {
178
+ sweep()
179
+ const e = pending.get(String(key ?? ''))
180
+ if (!e) return { state: 'gone', why: '这张码已经不在了,重新点「扫码登录」。' }
181
+
182
+ if (e.state === 'confirmed') {
183
+ return { state: 'confirmed', account: e.account, note: e.note }
184
+ }
185
+ if (e.state === 'saved') return { state: 'saved', account: e.account }
186
+ if (e.state === 'expired') return { state: 'expired', why: '二维码过期了,点「换一张」重来。' }
187
+ if (e.state === 'error') return { state: 'error', why: e.note }
188
+
189
+ const t = now()
190
+ if (t - e.createdAt > QR_TTL_MS) {
191
+ e.state = 'expired'
192
+ return { state: 'expired', why: '二维码过期了,点「换一张」重来。' }
193
+ }
194
+ // 太密就回上次的结果,不打扰 B 站
195
+ if (t - e.lastPollAt < MIN_POLL_GAP_MS) {
196
+ return { state: e.state, throttled: true, expiresAt: e.createdAt + QR_TTL_MS }
197
+ }
198
+ e.lastPollAt = t
199
+
200
+ let res
201
+ try {
202
+ res = await request(pollUrl(e.key))
203
+ } catch (err) {
204
+ return {
205
+ state: e.state,
206
+ why: `连不上 B 站:${String(err?.message ?? err).slice(0, 60)}`,
207
+ expiresAt: e.createdAt + QR_TTL_MS,
208
+ }
209
+ }
210
+
211
+ let body
212
+ const text = await res.text().catch(() => '')
213
+ try {
214
+ body = JSON.parse(text)
215
+ } catch {
216
+ return {
217
+ state: e.state,
218
+ why: `B 站回的不是 JSON(HTTP ${res.status})`,
219
+ expiresAt: e.createdAt + QR_TTL_MS,
220
+ }
221
+ }
222
+ // 顶层 code 不对(-412 风控之类):这次不算数,接着轮询
223
+ if (body?.code !== 0) {
224
+ return {
225
+ state: e.state,
226
+ why: `B 站:${body?.code} ${body?.message ?? ''}`.trim(),
227
+ expiresAt: e.createdAt + QR_TTL_MS,
228
+ }
229
+ }
230
+
231
+ const code = Number(body?.data?.code ?? body?.code)
232
+ if (code === 86101) {
233
+ e.state = 'waiting'
234
+ return { state: 'waiting', expiresAt: e.createdAt + QR_TTL_MS }
235
+ }
236
+ if (code === 86090) {
237
+ e.state = 'scanned'
238
+ return {
239
+ state: 'scanned',
240
+ why: '扫到了,在手机上点一下「确认登录」。',
241
+ expiresAt: e.createdAt + QR_TTL_MS,
242
+ }
243
+ }
244
+ if (code === 86038) {
245
+ e.state = 'expired'
246
+ return { state: 'expired', why: '二维码过期了,点「换一张」重来。' }
247
+ }
248
+ if (code !== 0) {
249
+ return {
250
+ state: e.state,
251
+ why: `B 站:${code} ${body?.data?.message ?? ''}`.trim(),
252
+ expiresAt: e.createdAt + QR_TTL_MS,
253
+ }
254
+ }
255
+
256
+ // ---- 到这里就是登录成功了 ----
257
+ const cookie = joinCookies(pickCookies(res.headers))
258
+ if (!/SESSDATA=/.test(cookie)) {
259
+ e.state = 'error'
260
+ e.note = '扫码成功了,但 B 站没把 SESSDATA 发下来'
261
+ log('扫码成功但没有 SESSDATA,放弃这次登录')
262
+ return { state: 'error', why: e.note }
263
+ }
264
+ if (!/bili_jct=/.test(cookie)) {
265
+ // 这个插件的 B 站登录只为了说话;没有 csrf 的登录存了也没用,当场说清楚。
266
+ e.state = 'error'
267
+ e.note = '这份登录里没有 bili_jct(发不了评论),换一个号重扫吧。'
268
+ return { state: 'error', why: e.note }
269
+ }
270
+ e.cookie = cookie
271
+ e.state = 'confirmed'
272
+
273
+ // 认一下这是谁。认不出来(网络抖动)不算失败:cookie 已经拿到了。
274
+ let account = null
275
+ try {
276
+ const me = await checkBili({ cookie, fetchImpl })
277
+ if (me?.ok) account = { mid: String(me.mid ?? ''), name: String(me.name ?? '') }
278
+ else if (!e.note) e.note = 'cookie 拿到了,但暂时认不出是哪个账号。'
279
+ } catch (err) {
280
+ if (!e.note) e.note = `cookie 拿到了,但认账号时连不上 B 站:${String(err?.message ?? err).slice(0, 40)}`
281
+ }
282
+ e.account = account
283
+ log(`扫码登录确认:${account?.name ? `${account.name}(uid ${account.mid})` : '未知账号'}`)
284
+ return { state: 'confirmed', account, note: e.note }
285
+ }
286
+
287
+ /**
288
+ * 人点了「保存」才写盘。
289
+ * 写的是 owner.json 的 cookie + 只读的名字/uid;别的东西一个字都不碰。
290
+ */
291
+ async function save(key, root) {
292
+ sweep()
293
+ const e = pending.get(String(key ?? ''))
294
+ if (!e) return { ok: false, why: '这张码已经不在了,重新扫码。' }
295
+ // 过期先判:过期之后不管扫没扫到都只能说"过期",说"还没确认"会让人干等。
296
+ if (now() - e.createdAt > QR_TTL_MS) {
297
+ e.state = 'expired'
298
+ pending.delete(e.key)
299
+ return { ok: false, why: '这张码过期了,重新扫码再存。' }
300
+ }
301
+ if (e.state !== 'confirmed') return { ok: false, why: '还没扫码确认,先别存。' }
302
+ const id = Number(e.account?.mid)
303
+ try {
304
+ writeBili(root, {
305
+ cookie: e.cookie,
306
+ name: e.account?.name ?? '',
307
+ mid: Number.isFinite(id) && id > 0 ? id : undefined,
308
+ })
309
+ } catch {
310
+ return { ok: false, why: '没存上。' }
311
+ }
312
+ e.cookie = ''
313
+ e.state = 'saved'
314
+ pending.delete(e.key)
315
+ return { ok: true, name: e.account?.name ?? '', note: '存下了。' }
316
+ }
317
+
318
+ /** 关掉 / 点取消:把这张码连同攥着的 cookie 一起忘掉。 */
319
+ function drop(key) {
320
+ const e = pending.get(String(key ?? ''))
321
+ if (e) {
322
+ e.cookie = ''
323
+ pending.delete(e.key)
324
+ }
325
+ return { ok: true }
326
+ }
327
+
328
+ /** 插件停用时清干净(内存里的凭据不该比进程活得久)。 */
329
+ function clear() {
330
+ for (const e of pending.values()) e.cookie = ''
331
+ pending.clear()
332
+ }
333
+
334
+ /** 只看不写:单测和自检用。 */
335
+ function peek(key) {
336
+ const e = pending.get(String(key ?? ''))
337
+ return e ? { state: e.state, hasCookie: Boolean(e.cookie), account: e.account } : null
338
+ }
339
+
340
+ return { start, poll, save, drop, clear, peek, size: () => pending.size }
341
+ }
package/lib/console.js CHANGED
@@ -277,6 +277,21 @@ function route(request, deps) {
277
277
  if (method === 'POST') return baiduLoginRoute(deps, request)
278
278
  if (method === 'DELETE') return baiduLogoutRoute(deps)
279
279
  return methodNotAllowed('GET, POST, DELETE')
280
+ case '/bili':
281
+ // B 站登录:GET 只回状态(绝不回 cookie),POST 粘贴 cookie 验证并落盘,DELETE 清掉。
282
+ if (method === 'GET') return biliStatusRoute(deps)
283
+ if (method === 'POST') return biliLoginRoute(deps, request)
284
+ if (method === 'DELETE') return biliLogoutRoute(deps)
285
+ return methodNotAllowed('GET, POST, DELETE')
286
+ case '/bili/qr':
287
+ // 扫码登录:POST 生成码;GET ?key= 轮询状态。cookie 只活在宿主内存里,页面拿不到。
288
+ if (method === 'POST') return biliQrStartRoute(deps)
289
+ if (method === 'GET') return biliQrPollRoute(deps, query)
290
+ return methodNotAllowed('GET, POST')
291
+ case '/bili/qr/save':
292
+ return method === 'POST' ? biliQrSaveRoute(deps, request) : methodNotAllowed('POST')
293
+ case '/bili/qr/drop':
294
+ return method === 'POST' ? biliQrDropRoute(deps, request) : methodNotAllowed('POST')
280
295
  case '/qq':
281
296
  // 反馈页的加群二维码:从包里直接读 PNG,二进制回包。
282
297
  return method === 'GET' ? qqRoute() : methodNotAllowed('GET')
@@ -1213,8 +1228,156 @@ function baiduLogoutRoute(deps) {
1213
1228
  return maybe(r, () => json(200, { ok: true, loggedIn: false, name: null, hasCookie: false }))
1214
1229
  }
1215
1230
 
1216
- // ---- /qq ------------------------------------------------------------------
1231
+ // ---- /bili ----------------------------------------------------------------
1232
+
1233
+ /** GET:只回登录状态,**绝不回 cookie**(值在 owner.json 里,接口不给看)。 */
1234
+ function biliStatusRoute(deps) {
1235
+ return maybe(callDep(deps, 'biliStatus'), biliEnvelope)
1236
+ }
1237
+
1238
+ function biliEnvelope(r) {
1239
+ if (!r || typeof r !== 'object') return unavailable('biliStatus')
1240
+ return json(200, {
1241
+ ok: true,
1242
+ loggedIn: r.loggedIn === true,
1243
+ name: r.name == null || r.name === '' ? null : String(r.name),
1244
+ hasCookie: r.hasCookie === true,
1245
+ })
1246
+ }
1247
+
1248
+ /** POST {cookie}:粘贴 → 验证 → 落盘;失败回 400 + 后端 reason。 */
1249
+ function biliLoginRoute(deps, request) {
1250
+ const fn = depRaw(deps, 'biliLogin')
1251
+ if (typeof fn !== 'function') return unavailable('biliLogin')
1252
+ const body = bodyOf(request)
1253
+ if (body === null) return badJson()
1254
+ const text = typeof body.cookie === 'string' ? body.cookie : ''
1255
+ if (!text.trim()) return badRequest('cookie 是空的')
1256
+ let r
1257
+ try {
1258
+ r = fn(text)
1259
+ } catch (e) {
1260
+ return failure(e)
1261
+ }
1262
+ return maybe(r, biliLoginResult)
1263
+ }
1217
1264
 
1265
+ function biliLoginResult(r) {
1266
+ if (!r || typeof r !== 'object' || r.ok === false) {
1267
+ return json(400, { ok: false, error: String(r?.reason || '没登上') })
1268
+ }
1269
+ return json(200, {
1270
+ ok: true,
1271
+ loggedIn: true,
1272
+ name: r.name == null || r.name === '' ? null : String(r.name),
1273
+ hasCookie: true,
1274
+ })
1275
+ }
1276
+
1277
+ /** DELETE:清掉本地登录文件(可选入口,面板不做按钮)。 */
1278
+ function biliLogoutRoute(deps) {
1279
+ const fn = depRaw(deps, 'biliLogout')
1280
+ if (typeof fn !== 'function') return unavailable('biliLogout')
1281
+ let r
1282
+ try {
1283
+ r = fn()
1284
+ } catch (e) {
1285
+ return failure(e)
1286
+ }
1287
+ return maybe(r, () => json(200, { ok: true, loggedIn: false, name: null, hasCookie: false }))
1288
+ }
1289
+
1290
+ // ---- /bili/qr -------------------------------------------------------------
1291
+
1292
+ /** POST:生成一张登录二维码(SVG 字符串)。B 站只回 URL,图由 lib/qr.js 现画。 */
1293
+ function biliQrStartRoute(deps) {
1294
+ const fn = depRaw(deps, 'biliQrStart')
1295
+ if (typeof fn !== 'function') return unavailable('biliQrStart')
1296
+ let r
1297
+ try {
1298
+ r = fn()
1299
+ } catch (e) {
1300
+ return failure(e)
1301
+ }
1302
+ return maybe(r, (x) => {
1303
+ if (!x || x.ok === false) return json(502, { ok: false, error: String(x?.why || '没拿到二维码') })
1304
+ return json(200, {
1305
+ ok: true,
1306
+ key: String(x.key ?? ''),
1307
+ svg: String(x.svg ?? ''),
1308
+ ttlMs: Number(x.ttlMs) || 0,
1309
+ expiresAt: Number(x.expiresAt) || 0,
1310
+ })
1311
+ })
1312
+ }
1313
+
1314
+ /** GET ?key=:轮询一次。页面只拿到状态与「登的是谁」,cookie 原文一步都不出宿主。 */
1315
+ function biliQrPollRoute(deps, query) {
1316
+ const fn = depRaw(deps, 'biliQrPoll')
1317
+ if (typeof fn !== 'function') return unavailable('biliQrPoll')
1318
+ const key = String(query?.get?.('key') ?? '').trim()
1319
+ if (!key) return badRequest('缺 key')
1320
+ let r
1321
+ try {
1322
+ r = fn(key)
1323
+ } catch (e) {
1324
+ return failure(e)
1325
+ }
1326
+ return maybe(r, (x) =>
1327
+ json(200, {
1328
+ ok: true,
1329
+ state: String(x?.state ?? 'gone'),
1330
+ account: x?.account ?? null,
1331
+ why: x?.why != null ? String(x.why) : '',
1332
+ note: x?.note != null ? String(x.note) : '',
1333
+ expiresAt: Number(x?.expiresAt) || 0,
1334
+ }),
1335
+ )
1336
+ }
1337
+
1338
+ /** POST {key}:人点了「保存」才落盘 owner.json。 */
1339
+ function biliQrSaveRoute(deps, request) {
1340
+ const fn = depRaw(deps, 'biliQrSave')
1341
+ if (typeof fn !== 'function') return unavailable('biliQrSave')
1342
+ const body = bodyOf(request)
1343
+ if (body === null) return badJson()
1344
+ const key = String(body.key ?? '').trim()
1345
+ if (!key) return badRequest('缺 key')
1346
+ let r
1347
+ try {
1348
+ r = fn(key)
1349
+ } catch (e) {
1350
+ return failure(e)
1351
+ }
1352
+ return maybe(r, (x) => {
1353
+ if (!x || x.ok === false) return json(409, { ok: false, error: String(x?.why || '没存上') })
1354
+ return json(200, {
1355
+ ok: true,
1356
+ loggedIn: true,
1357
+ name: x.name == null || x.name === '' ? null : String(x.name),
1358
+ hasCookie: true,
1359
+ })
1360
+ })
1361
+ }
1362
+
1363
+ /** POST {key}:取消 / 关掉浮层;内存里攥着的 cookie 一起忘掉。 */
1364
+ function biliQrDropRoute(deps, request) {
1365
+ const fn = depRaw(deps, 'biliQrDrop')
1366
+ if (typeof fn !== 'function') return unavailable('biliQrDrop')
1367
+ const body = bodyOf(request)
1368
+ if (body === null) return badJson()
1369
+ const key = String(body.key ?? '').trim()
1370
+ if (!key) return badRequest('缺 key')
1371
+ let r
1372
+ try {
1373
+ r = fn(key)
1374
+ } catch (e) {
1375
+ return failure(e)
1376
+ }
1377
+ return maybe(r, () => json(200, { ok: true }))
1378
+ }
1379
+
1380
+ // ---- /qq ------------------------------------------------------------------
1218
1381
  /** 二维码文件随包走;路径从本文件算,不依赖宿主 cwd。 */
1219
1382
  const QQ_FILE = join(import.meta.dirname, '..', 'assets', 'qq-group.png')
1220
1383