dsh-pisces 0.2.5 → 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/bili.js CHANGED
@@ -1,19 +1,24 @@
1
- // B 站:只读热门 + 在视频下说一句。网络与凭据都在这里,纯函数、可注入 fetch。
1
+ // B 站:登录(cookie)+ 只读热门 + 在视频下说一句。网络与凭据都在这里,纯函数、可注入 fetch。
2
2
  //
3
- // 三条口径(README「说话」、知识库 n23):
3
+ // 四条口径(README「说话」、知识库 n23 / n64):
4
4
  // 1. bvid 不能直接发评论,要先 `/x/web-interface/view?bvid=` 拿 aid;
5
5
  // 2. 凭据只从 `<root>/owner.json` 读,SESSDATA 与 bili_jct 缺任一都是"cookie 不完整";
6
- // 3. 所有失败都不抛,折成 { ok:false, reason },reason 只写人话、不打印 cookie。
7
- import { existsSync, readFileSync } from 'node:fs'
6
+ // 3. 登录只收 SESSDATA + bili_jct 两个键,保存前用 nav 接口验一次;0600 原子写,绝不回显;
7
+ // 4. 所有失败都不抛,折成 { ok:false, reason },reason 只写人话、不打印 cookie。
8
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
8
9
  import path from 'node:path'
9
10
 
10
11
  export const POPULAR_API = 'https://api.bilibili.com/x/web-interface/popular'
11
12
  export const VIEW_API = 'https://api.bilibili.com/x/web-interface/view'
12
13
  export const REPLY_API = 'https://api.bilibili.com/x/v2/reply/add'
14
+ export const NAV_API = 'https://api.bilibili.com/x/web-interface/nav'
13
15
  export const OWNER_FILE = 'owner.json'
16
+ export const OWNER_MODE = 0o600
14
17
 
15
18
  const BVID_RE = /^BV[0-9A-Za-z]{10}$/
16
19
  const SPEAK_MAX = 300
20
+ // 登录只认这两个键:SESSDATA 证明身份,bili_jct 是写操作的 csrf。
21
+ const LOGIN_KEYS = ['SESSDATA', 'bili_jct']
17
22
  const UA =
18
23
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
19
24
 
@@ -61,6 +66,176 @@ export function readOwner(root) {
61
66
  }
62
67
  }
63
68
 
69
+ /**
70
+ * 从浏览器复制的整段 cookie 里只挑 SESSDATA / bili_jct,并拼回规范形状。
71
+ * 缺哪个就说清缺哪个;换行/分号分隔都认。
72
+ */
73
+ export function parseBiliCookie(text) {
74
+ const raw = typeof text === 'string' ? text : String(text ?? '')
75
+ const values = new Map()
76
+ for (const piece of raw.split(/[;\r\n]+/)) {
77
+ const part = piece.trim()
78
+ const eq = part.indexOf('=')
79
+ if (eq <= 0) continue
80
+ const key = part.slice(0, eq).trim()
81
+ const value = part.slice(eq + 1).trim()
82
+ if (!LOGIN_KEYS.includes(key) || !value) continue
83
+ values.set(key, value)
84
+ }
85
+ const missing = LOGIN_KEYS.filter((key) => !values.has(key))
86
+ if (missing.length) return { ok: false, reason: `cookie 不完整:缺 ${missing.join('、')}` }
87
+ return {
88
+ ok: true,
89
+ cookie: `SESSDATA=${values.get('SESSDATA')}; bili_jct=${values.get('bili_jct')}`,
90
+ missing: [],
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 读登录态(登录卡片用):文件不在 / 坏 JSON / 缺键一律 null(调用方翻成人话)。
96
+ * 比 readOwner 多认可选的名字与 uid;cookie 本身不回显、不打印。
97
+ */
98
+ export function readBili(root) {
99
+ if (!root) return null
100
+ try {
101
+ const file = path.join(String(root), OWNER_FILE)
102
+ const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
103
+ const j = JSON.parse(raw)
104
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return null
105
+ const parsed = parseBiliCookie(typeof j.cookie === 'string' ? j.cookie : '')
106
+ if (!parsed.ok) return null
107
+ const at = Number(j.at)
108
+ const out = { cookie: parsed.cookie, at: Number.isFinite(at) && at > 0 ? at : 0 }
109
+ const name = typeof j.name === 'string' ? j.name.trim() : ''
110
+ if (name) out.name = name
111
+ const mid = Number(j.mid)
112
+ if (Number.isFinite(mid) && mid > 0) out.mid = Math.trunc(mid)
113
+ return out
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ /** 原子写:tmp → rename,tmp 用 0600 建;失败清掉 tmp 再抛。 */
120
+ export function writeBili(root, { cookie, name, mid } = {}) {
121
+ const parsed = parseBiliCookie(cookie)
122
+ if (!parsed.ok) throw new Error(parsed.reason)
123
+ if (!root) throw new Error('没有地方存')
124
+
125
+ const file = path.join(String(root), OWNER_FILE)
126
+ const cleanName = typeof name === 'string' ? name.trim().slice(0, 64) : ''
127
+ const id = Number(mid)
128
+ const record = {
129
+ cookie: parsed.cookie,
130
+ name: cleanName,
131
+ ...(Number.isFinite(id) && id > 0 ? { mid: Math.trunc(id) } : {}),
132
+ at: Date.now(),
133
+ }
134
+ const text = `${JSON.stringify(record, null, 2)}\n`
135
+ mkdirSync(path.dirname(file), { recursive: true })
136
+ // 覆盖之前先把旧文件留成 .bak:里面是上一份登录态,点错一次只能重新扫码。
137
+ // 备份失败不拦写盘(宁可写成功,也不能因为留副本失败就登不上)。
138
+ try {
139
+ if (existsSync(file)) copyFileSync(file, `${file}.bak`)
140
+ } catch {
141
+ /* 留不下备份就算了 */
142
+ }
143
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
144
+ try {
145
+ writeFileSync(tmp, text, { encoding: 'utf8', mode: OWNER_MODE })
146
+ try {
147
+ chmodSync(tmp, OWNER_MODE)
148
+ } catch {
149
+ /* Windows 不保留 POSIX 位,尽力而为 */
150
+ }
151
+ renameSync(tmp, file)
152
+ } catch (e) {
153
+ try {
154
+ unlinkSync(tmp)
155
+ } catch {
156
+ /* 没有 tmp 可清就算了 */
157
+ }
158
+ throw e
159
+ }
160
+ return record
161
+ }
162
+
163
+ /** 清掉登录文件;返回是否真的删掉了。 */
164
+ export function clearBili(root) {
165
+ if (!root) return false
166
+ try {
167
+ unlinkSync(path.join(String(root), OWNER_FILE))
168
+ return true
169
+ } catch {
170
+ return false
171
+ }
172
+ }
173
+
174
+ /** nav 接口的错误码 → 人话。 */
175
+ function navReason(code) {
176
+ if (code === -101) return '没登录'
177
+ if (code === -412 || code === -352 || code === -403) return '被风控了'
178
+ return `B 站回了个看不懂的(code ${code})`
179
+ }
180
+
181
+ /**
182
+ * 用 nav 接口验一次:`data.isLogin === true` 才算登录,顺手取 uname / mid。
183
+ * 失败只回「没登录 / cookie 不完整 / 被风控了 / 连不上 B 站」,不打印 cookie。
184
+ * @returns {Promise<{ok:true, name:string, mid:number}|{ok:false, reason:string}>}
185
+ */
186
+ export async function checkBili({ cookie, fetchImpl = globalThis.fetch } = {}) {
187
+ const parsed = parseBiliCookie(cookie)
188
+ if (!parsed.ok) return { ok: false, reason: 'cookie 不完整' }
189
+
190
+ const f = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
191
+ if (typeof f !== 'function') return { ok: false, reason: '连不上 B 站' }
192
+
193
+ let nav
194
+ try {
195
+ const res = await f(NAV_API, {
196
+ headers: {
197
+ 'user-agent': UA,
198
+ cookie: parsed.cookie,
199
+ referer: 'https://www.bilibili.com/',
200
+ },
201
+ })
202
+ nav = await readJson(res)
203
+ } catch {
204
+ return { ok: false, reason: '连不上 B 站' }
205
+ }
206
+ if (!nav || typeof nav !== 'object') return { ok: false, reason: '连不上 B 站' }
207
+
208
+ const code = Number(nav.code)
209
+ if (code !== 0) return { ok: false, reason: navReason(code) }
210
+ // code 0 也可能是"没登录":没有有效 SESSDATA 时 web 端回 isLogin:false。
211
+ if (nav.data?.isLogin !== true) return { ok: false, reason: '没登录' }
212
+
213
+ const name = typeof nav.data?.uname === 'string' ? nav.data.uname.trim() : ''
214
+ const mid = Number(nav.data?.mid)
215
+ return { ok: true, name, mid: Number.isFinite(mid) && mid > 0 ? Math.trunc(mid) : 0 }
216
+ }
217
+
218
+ /**
219
+ * 粘贴 → 解析 → 验证 → 写盘。验证不过一个字节都不写。
220
+ * @returns {Promise<{ok:true, name:string}|{ok:false, reason:string}>}
221
+ */
222
+ export async function loginBili({ root, text, fetchImpl = globalThis.fetch, log } = {}) {
223
+ if (!root) return { ok: false, reason: '没有地方存' }
224
+ const parsed = parseBiliCookie(text)
225
+ if (!parsed.ok) return { ok: false, reason: parsed.reason }
226
+
227
+ const chk = await checkBili({ cookie: parsed.cookie, fetchImpl })
228
+ if (!chk.ok) return chk
229
+
230
+ try {
231
+ writeBili(root, { cookie: parsed.cookie, name: chk.name, mid: chk.mid })
232
+ } catch {
233
+ return { ok: false, reason: '存不进去' }
234
+ }
235
+ safeLog(log, 'B 站登录:已保存(只在本机)')
236
+ return { ok: true, name: chk.name }
237
+ }
238
+
64
239
  async function readJson(res) {
65
240
  try {
66
241
  if (!res || typeof res.json !== 'function') return null
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
+ }