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/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
 
package/lib/qr.js ADDED
@@ -0,0 +1,450 @@
1
+ // 移植说明(2026-11-10):本文件原样取自 dsh-fish 的 `lib/qr.js`(MIT,Copyright (c) 2026
2
+ // 大肥鱼(dsh-fish)作者)。B 站扫码登录与这个零依赖二维码编码器由 @Bacon-01 贡献(dsh-fish PR #17)。
3
+ // 只补了这一行说明,实现未改;来源与许可写在 THIRD-PARTY-NOTICES.md。
4
+ /**
5
+ * qr.js —— 把一串字画成二维码(纯 JS,零依赖)
6
+ *
7
+ * 为什么要自己写(2026-11-01):
8
+ * 设置页要加「扫码登录」,B 站只给一个登录 URL,二维码图得自己画。
9
+ * 仓库第一条铁律是零依赖(AGENTS.md §1),所以不能引 `qrcode`;
10
+ * 也不能把登录 URL 拼给第三方图片服务——那等于把一次性的钥匙递给别人。
11
+ *
12
+ * 只做了什么:
13
+ * - 字节模式(UTF-8),不写数字/字母数字/汉字模式。登录 URL 是 ASCII,
14
+ * 其它地方也用不上;少一种模式就少一处会算错的地方。
15
+ * - 版本 1–10 自动选(够装 213 字节,B 站 URL 约 110 字节)。
16
+ * - 纠错级默认 M(屏幕上扫,容错不用到 H),四档全支持。
17
+ *
18
+ * 没做什么:结构化追加、多符号、Micro QR。用不到。
19
+ *
20
+ * 正确性怎么保证:`test/qr.test.mjs` 拿几个已知的参考矩阵比对(不是"看着像二维码"
21
+ * 就算过)。算法本身照 ISO/IEC 18004 写的,分块表见 ECC_BLOCKS。
22
+ */
23
+
24
+ // ---------------------------------------------------------------- GF(256)
25
+ // 生成多项式用;本原多项式 x^8+x^4+x^3+x^2+1 = 0x11D。
26
+ const EXP = new Uint8Array(512)
27
+ const LOG = new Uint8Array(256)
28
+ ;(() => {
29
+ let x = 1
30
+ for (let i = 0; i < 255; i += 1) {
31
+ EXP[i] = x
32
+ LOG[x] = i
33
+ x <<= 1
34
+ if (x & 0x100) x ^= 0x11d
35
+ }
36
+ for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255]
37
+ })()
38
+
39
+ function gfMul(a, b) {
40
+ if (a === 0 || b === 0) return 0
41
+ return EXP[LOG[a] + LOG[b]]
42
+ }
43
+
44
+ /** 生成多项式 ∏(x - α^i),i = 0..deg-1。系数从高次到低次,首项恒为 1。 */
45
+ function rsGenerator(deg) {
46
+ let poly = [1]
47
+ for (let i = 0; i < deg; i += 1) {
48
+ const next = new Array(poly.length + 1).fill(0)
49
+ for (let j = 0; j < poly.length; j += 1) {
50
+ next[j] ^= poly[j]
51
+ next[j + 1] ^= gfMul(poly[j], EXP[i])
52
+ }
53
+ poly = next
54
+ }
55
+ return poly
56
+ }
57
+
58
+ /** 给一块数据算 ecLen 个纠错码字。 */
59
+ function rsEncode(data, ecLen) {
60
+ const gen = rsGenerator(ecLen)
61
+ const rem = new Uint8Array(data.length + ecLen)
62
+ rem.set(data)
63
+ for (let i = 0; i < data.length; i += 1) {
64
+ const factor = rem[i]
65
+ if (factor === 0) continue
66
+ for (let j = 0; j < gen.length; j += 1) rem[i + j] ^= gfMul(gen[j], factor)
67
+ }
68
+ return rem.slice(data.length)
69
+ }
70
+
71
+ // ---------------------------------------------------------------- 分块表
72
+ // 每个版本 × 纠错级:[每块纠错码字数, 第一组块数, 第一组每块数据码字数, 第二组块数, 第二组每块数据码字数]
73
+ // 数值照 ISO/IEC 18004 Table 9。版本 1–10;B 站登录 URL 用不到更大的。
74
+ const ECC_BLOCKS = {
75
+ 1: { L: [7, 1, 19, 0, 0], M: [10, 1, 16, 0, 0], Q: [13, 1, 13, 0, 0], H: [17, 1, 9, 0, 0] },
76
+ 2: { L: [10, 1, 34, 0, 0], M: [16, 1, 28, 0, 0], Q: [22, 1, 22, 0, 0], H: [28, 1, 16, 0, 0] },
77
+ 3: { L: [15, 1, 55, 0, 0], M: [26, 1, 44, 0, 0], Q: [18, 2, 17, 0, 0], H: [22, 2, 13, 0, 0] },
78
+ 4: { L: [20, 1, 80, 0, 0], M: [18, 2, 32, 0, 0], Q: [26, 2, 24, 0, 0], H: [16, 4, 9, 0, 0] },
79
+ 5: { L: [26, 1, 108, 0, 0], M: [24, 2, 43, 0, 0], Q: [18, 2, 15, 2, 16], H: [22, 2, 11, 2, 12] },
80
+ 6: { L: [18, 2, 68, 0, 0], M: [16, 4, 27, 0, 0], Q: [24, 4, 19, 0, 0], H: [28, 4, 15, 0, 0] },
81
+ 7: { L: [20, 2, 78, 0, 0], M: [18, 4, 31, 0, 0], Q: [18, 2, 14, 4, 15], H: [26, 4, 13, 1, 14] },
82
+ 8: { L: [24, 2, 97, 0, 0], M: [22, 2, 38, 2, 39], Q: [22, 4, 18, 2, 19], H: [26, 4, 14, 2, 15] },
83
+ 9: { L: [30, 2, 116, 0, 0], M: [22, 3, 36, 2, 37], Q: [20, 4, 16, 4, 17], H: [24, 4, 12, 4, 13] },
84
+ 10: { L: [18, 2, 68, 2, 69], M: [26, 4, 43, 1, 44], Q: [24, 6, 19, 2, 20], H: [28, 6, 15, 2, 16] },
85
+ }
86
+
87
+ const ALIGN = {
88
+ 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34],
89
+ 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
90
+ }
91
+
92
+ const EC_BITS = { L: 0b01, M: 0b00, Q: 0b11, H: 0b10 }
93
+ const MAX_VERSION = 10
94
+
95
+ // ---------------------------------------------------------------- 位流
96
+ function bitBuffer() {
97
+ const bits = []
98
+ return {
99
+ push(value, len) {
100
+ for (let i = len - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1)
101
+ },
102
+ get length() { return bits.length },
103
+ bits,
104
+ }
105
+ }
106
+
107
+ /** 每个版本能装多少个数据位(字节模式的容量由它减掉模式/长度头得到)。 */
108
+ function dataCodewords(version, ec) {
109
+ const [ecLen, blocks1, size1, blocks2, size2] = ECC_BLOCKS[version][ec]
110
+ return { ecLen, blocks1, size1, blocks2, size2, total: blocks1 * size1 + blocks2 * size2 }
111
+ }
112
+
113
+ function chooseVersion(bytes, ec) {
114
+ // UTF-8 字节数 + 模式头 4 位 + 长度指示(版本 1–9 是 8 位,10 起是 16 位)
115
+ for (let v = 1; v <= MAX_VERSION; v += 1) {
116
+ const cap = dataCodewords(v, ec).total * 8
117
+ const header = 4 + (v <= 9 ? 8 : 16)
118
+ if (header + bytes.length * 8 <= cap) return v
119
+ }
120
+ return 0
121
+ }
122
+
123
+ /** 数据码字 + 纠错码字,按块交错成一串。 */
124
+ function buildCodewords(bytes, version, ec) {
125
+ const { ecLen, blocks1, size1, blocks2, size2 } = dataCodewords(version, ec)
126
+ const total = blocks1 * size1 + blocks2 * size2
127
+
128
+ const buf = bitBuffer()
129
+ buf.push(0b0100, 4) // 字节模式
130
+ buf.push(bytes.length, version <= 9 ? 8 : 16)
131
+ for (const b of bytes) buf.push(b, 8)
132
+ // 终止符最多 4 位;已经满了就不加
133
+ const capBits = total * 8
134
+ const term = Math.min(4, capBits - buf.length)
135
+ if (term > 0) buf.push(0, term)
136
+ // 补到字节边界
137
+ while (buf.length % 8 !== 0) buf.bits.push(0)
138
+ // 补语(两个标准填充字节交替),直到装满数据码字
139
+ const padBits = []
140
+ let k = 0
141
+ while (buf.bits.length + padBits.length < capBits) {
142
+ padBits.push(...(k % 2 === 0 ? [1, 1, 1, 0, 1, 1, 0, 0] : [0, 0, 0, 1, 0, 0, 0, 1]))
143
+ k += 1
144
+ }
145
+ const all = buf.bits.concat(padBits)
146
+ const bytesOut = new Uint8Array(total)
147
+ for (let i = 0; i < total; i += 1) {
148
+ let v = 0
149
+ for (let j = 0; j < 8; j += 1) v = (v << 1) | all[i * 8 + j]
150
+ bytesOut[i] = v
151
+ }
152
+
153
+ // 切块 → 每块算 ec → 交错
154
+ const dataBlocks = []
155
+ const ecBlocks = []
156
+ let p = 0
157
+ for (let b = 0; b < blocks1; b += 1) {
158
+ const chunk = bytesOut.slice(p, p + size1)
159
+ p += size1
160
+ dataBlocks.push(chunk)
161
+ ecBlocks.push(rsEncode(chunk, ecLen))
162
+ }
163
+ for (let b = 0; b < blocks2; b += 1) {
164
+ const chunk = bytesOut.slice(p, p + size2)
165
+ p += size2
166
+ dataBlocks.push(chunk)
167
+ ecBlocks.push(rsEncode(chunk, ecLen))
168
+ }
169
+
170
+ const out = []
171
+ const maxData = Math.max(size1, size2)
172
+ for (let i = 0; i < maxData; i += 1) {
173
+ for (const blk of dataBlocks) if (i < blk.length) out.push(blk[i])
174
+ }
175
+ for (let i = 0; i < ecLen; i += 1) {
176
+ for (const blk of ecBlocks) if (i < blk.length) out.push(blk[i])
177
+ }
178
+ return out
179
+ }
180
+
181
+ // ---------------------------------------------------------------- 矩阵
182
+ function blank(size) {
183
+ return Array.from({ length: size }, () => new Array(size).fill(null))
184
+ }
185
+
186
+ function placeFinder(m, r, c) {
187
+ // 7×7 的黑框白底黑心 + 一圈分隔符
188
+ for (let dr = -1; dr <= 7; dr += 1) {
189
+ for (let dc = -1; dc <= 7; dc += 1) {
190
+ const y = r + dr
191
+ const x = c + dc
192
+ if (y < 0 || y >= m.length || x < 0 || x >= m.length) continue
193
+ const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6
194
+ const dark = inRing && (dr === 0 || dr === 6 || dc === 0 || dc === 6
195
+ || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4))
196
+ m[y][x] = dark ? 1 : 0
197
+ }
198
+ }
199
+ }
200
+
201
+ function placeAlignment(m, version) {
202
+ const pos = ALIGN[version]
203
+ for (const r of pos) {
204
+ for (const c of pos) {
205
+ // 和三个 finder 重合的位置要跳过(规范里就是这么画的)
206
+ if ((r === 6 && c === 6) || (r === 6 && c === m.length - 7) || (r === m.length - 7 && c === 6)) continue
207
+ for (let dr = -2; dr <= 2; dr += 1) {
208
+ for (let dc = -2; dc <= 2; dc += 1) {
209
+ const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1
210
+ m[r + dr][c + dc] = dark ? 1 : 0
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ function placeTiming(m) {
218
+ for (let i = 8; i < m.length - 8; i += 1) {
219
+ const bit = i % 2 === 0 ? 1 : 0
220
+ if (m[6][i] === null) m[6][i] = bit
221
+ if (m[i][6] === null) m[i][6] = bit
222
+ }
223
+ }
224
+
225
+ /** 15 位格式信息:2 位纠错级 + 3 位掩码号,BCH(15,5),再异或常数。 */
226
+ function formatBits(ec, mask) {
227
+ const data = (EC_BITS[ec] << 3) | mask
228
+ let rem = data
229
+ for (let i = 0; i < 10; i += 1) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
230
+ return ((data << 10) | rem) ^ 0x5412
231
+ }
232
+
233
+ /** 18 位版本信息(版本 7 起才有),BCH(18,6)。 */
234
+ function versionBits(version) {
235
+ let rem = version
236
+ for (let i = 0; i < 12; i += 1) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
237
+ return (version << 12) | rem
238
+ }
239
+
240
+ function reserveFormat(m) {
241
+ const n = m.length
242
+ // 先占位(null → 2 表示"留给格式信息"),真正写入在选完掩码之后
243
+ for (let i = 0; i <= 8; i += 1) {
244
+ if (m[8][i] === null) m[8][i] = 2
245
+ if (m[i][8] === null) m[i][8] = 2
246
+ }
247
+ for (let i = 0; i < 8; i += 1) {
248
+ if (m[8][n - 1 - i] === null) m[8][n - 1 - i] = 2
249
+ if (m[n - 1 - i][8] === null) m[n - 1 - i][8] = 2
250
+ }
251
+ m[n - 8][8] = 1 // 固定深色模块
252
+ for (let i = 0; i <= 8; i += 1) if (m[8][i] === null) m[8][i] = 2
253
+ }
254
+
255
+ function reserveVersion(m, version) {
256
+ if (version < 7) return
257
+ const n = m.length
258
+ const bits = versionBits(version)
259
+ for (let i = 0; i < 18; i += 1) {
260
+ const bit = (bits >>> i) & 1
261
+ const a = Math.floor(i / 3)
262
+ const b = i % 3
263
+ m[n - 11 + b][a] = bit
264
+ m[a][n - 11 + b] = bit
265
+ }
266
+ }
267
+
268
+ function placeData(m, codewords) {
269
+ const n = m.length
270
+ // 哪些格子是"数据格"要单独记:掩码只能盖数据格,盖到定位图案就废了。
271
+ const isData = Array.from({ length: n }, () => new Array(n).fill(false))
272
+ let bitIndex = 0
273
+ const totalBits = codewords.length * 8
274
+ let upward = true
275
+ for (let right = n - 1; right >= 1; right -= 2) {
276
+ if (right === 6) right = 5 // 时序列不参与
277
+ for (let vert = 0; vert < n; vert += 1) {
278
+ const y = upward ? n - 1 - vert : vert
279
+ for (let j = 0; j < 2; j += 1) {
280
+ const x = right - j
281
+ if (m[y][x] !== null) continue
282
+ let bit = 0
283
+ if (bitIndex < totalBits) bit = (codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1
284
+ m[y][x] = bit
285
+ isData[y][x] = true
286
+ bitIndex += 1
287
+ }
288
+ }
289
+ upward = !upward
290
+ }
291
+ return isData
292
+ }
293
+
294
+ const MASKS = [
295
+ (y, x) => (y + x) % 2 === 0,
296
+ (y) => y % 2 === 0,
297
+ (_, x) => x % 3 === 0,
298
+ (y, x) => (y + x) % 3 === 0,
299
+ (y, x) => (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0,
300
+ (y, x) => ((y * x) % 2) + ((y * x) % 3) === 0,
301
+ (y, x) => (((y * x) % 2) + ((y * x) % 3)) % 2 === 0,
302
+ (y, x) => (((y + x) % 2) + ((y * x) % 3)) % 2 === 0,
303
+ ]
304
+
305
+ function applyMask(m, mask, isData) {
306
+ const n = m.length
307
+ for (let y = 0; y < n; y += 1) {
308
+ for (let x = 0; x < n; x += 1) {
309
+ if (!isData[y][x]) continue
310
+ if (MASKS[mask](y, x)) m[y][x] ^= 1
311
+ }
312
+ }
313
+ }
314
+
315
+ function writeFormat(m, ec, mask) {
316
+ const n = m.length
317
+ const bits = formatBits(ec, mask)
318
+ // 左上那一份:先竖着往上走,到第 8 行拐成横的(第 6 列是时序列,跳过)
319
+ for (let i = 0; i <= 5; i += 1) m[i][8] = (bits >>> i) & 1
320
+ m[7][8] = (bits >>> 6) & 1
321
+ m[8][8] = (bits >>> 7) & 1
322
+ m[8][7] = (bits >>> 8) & 1
323
+ for (let i = 9; i <= 14; i += 1) m[8][14 - i] = (bits >>> i) & 1
324
+ // 右上角 / 左下角
325
+ for (let i = 0; i <= 7; i += 1) m[8][n - 1 - i] = (bits >>> i) & 1
326
+ for (let i = 8; i <= 14; i += 1) m[n - 15 + i][8] = (bits >>> i) & 1
327
+ m[n - 8][8] = 1
328
+ }
329
+
330
+ // 惩罚分(标准里那四条),只用来挑掩码。
331
+ function penalty(m) {
332
+ const n = m.length
333
+ let score = 0
334
+ const line = (get) => {
335
+ let run = 1
336
+ for (let i = 1; i < n; i += 1) {
337
+ if (get(i) === get(i - 1)) {
338
+ run += 1
339
+ continue
340
+ }
341
+ if (run >= 5) score += 3 + (run - 5)
342
+ run = 1
343
+ }
344
+ if (run >= 5) score += 3 + (run - 5)
345
+ }
346
+ for (let y = 0; y < n; y += 1) line((x) => m[y][x])
347
+ for (let x = 0; x < n; x += 1) line((y) => m[y][x])
348
+
349
+ for (let y = 0; y < n - 1; y += 1) {
350
+ for (let x = 0; x < n - 1; x += 1) {
351
+ const v = m[y][x]
352
+ if (v === m[y][x + 1] && v === m[y + 1][x] && v === m[y + 1][x + 1]) score += 3
353
+ }
354
+ }
355
+
356
+ const finderLike = (a, b, c, d, e, f, g) => a && !b && c && d && e && !f && g
357
+ for (let y = 0; y < n; y += 1) {
358
+ for (let x = 0; x + 6 < n; x += 1) {
359
+ if (finderLike(m[y][x], m[y][x + 1], m[y][x + 2], m[y][x + 3], m[y][x + 4], m[y][x + 5], m[y][x + 6])) {
360
+ const before = x - 1 < 0 || m[y][x - 1] === 0
361
+ const after = x + 7 >= n || m[y][x + 7] === 0
362
+ if (before || after) score += 40
363
+ }
364
+ }
365
+ }
366
+ for (let x = 0; x < n; x += 1) {
367
+ for (let y = 0; y + 6 < n; y += 1) {
368
+ if (finderLike(m[y][x], m[y + 1][x], m[y + 2][x], m[y + 3][x], m[y + 4][x], m[y + 5][x], m[y + 6][x])) {
369
+ const before = y - 1 < 0 || m[y - 1][x] === 0
370
+ const after = y + 7 >= n || m[y + 7][x] === 0
371
+ if (before || after) score += 40
372
+ }
373
+ }
374
+ }
375
+
376
+ const dark = m.flat().filter((v) => v === 1).length
377
+ const ratio = (dark * 100) / (n * n)
378
+ score += Math.floor(Math.abs(ratio - 50) / 5) * 10
379
+ return score
380
+ }
381
+
382
+ /**
383
+ * 编码。返回 { version, ec, size, modules },modules 是 0/1 的二维数组(含静区吗?不含)。
384
+ */
385
+ export function encodeQr(text, opts = {}) {
386
+ const ec = String(opts.ec ?? 'M').toUpperCase()
387
+ if (!ECC_BLOCKS[1][ec]) throw new Error(`不认识的纠错级:${ec}`)
388
+ const bytes = new TextEncoder().encode(String(text ?? ''))
389
+ if (!bytes.length) throw new Error('二维码内容是空的')
390
+
391
+ let version = opts.version ?? 0
392
+ if (version) {
393
+ const cap = dataCodewords(version, ec).total * 8
394
+ const header = 4 + (version <= 9 ? 8 : 16)
395
+ if (header + bytes.length * 8 > cap) throw new Error(`内容太长,装不进版本 ${version}(${ec})`)
396
+ } else {
397
+ version = chooseVersion(bytes, ec)
398
+ if (!version) throw new Error(`内容太长,装不进版本 ${MAX_VERSION}(${ec})`)
399
+ }
400
+
401
+ const codewords = buildCodewords(bytes, version, ec)
402
+ const size = 17 + version * 4
403
+
404
+ // opts.mask 只在测试里用:把八种掩码逐个和参考实现比对,比"挑了一个"强得多。
405
+ const forced = Number.isInteger(opts.mask) ? opts.mask : null
406
+ let best = null
407
+ for (let mask = 0; mask < 8; mask += 1) {
408
+ if (forced !== null && mask !== forced) continue
409
+ const m = blank(size)
410
+ placeFinder(m, 0, 0)
411
+ placeFinder(m, 0, size - 7)
412
+ placeFinder(m, size - 7, 0)
413
+ placeAlignment(m, version)
414
+ placeTiming(m)
415
+ reserveFormat(m)
416
+ reserveVersion(m, version)
417
+ const isData = placeData(m, codewords)
418
+ applyMask(m, mask, isData)
419
+ writeFormat(m, ec, mask)
420
+ const score = penalty(m)
421
+ if (!best || score < best.score) best = { score, mask, modules: m }
422
+ }
423
+
424
+ return { version, ec, size, modules: best.modules, mask: best.mask }
425
+ }
426
+
427
+ /**
428
+ * 直接给一段 SVG 字符串(设置页拿它当 <img src="data:..."> 或走一条路由)。
429
+ * 深色用 currentColor 会跟着主题变,但二维码用主题色反而更难扫——所以默认写死黑白。
430
+ */
431
+ export function qrSvg(text, opts = {}) {
432
+ const { modules, size, version } = encodeQr(text, opts)
433
+ const margin = opts.margin ?? 4
434
+ const scale = opts.scale ?? 8
435
+ const dim = (size + margin * 2) * scale
436
+ let path = ''
437
+ for (let y = 0; y < size; y += 1) {
438
+ for (let x = 0; x < size; x += 1) {
439
+ if (modules[y][x] === 1) {
440
+ path += `M${(x + margin) * scale} ${(y + margin) * scale}h${scale}v${scale}h-${scale}z`
441
+ }
442
+ }
443
+ }
444
+ const dark = opts.dark ?? '#000'
445
+ const light = opts.light ?? '#fff'
446
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges" data-version="${version}" data-modules="${size}">`
447
+ + `<rect width="${dim}" height="${dim}" fill="${light}"/>`
448
+ + `<path d="${path}" fill="${dark}"/>`
449
+ + '</svg>'
450
+ }
package/lib/settings.js CHANGED
@@ -5,8 +5,8 @@
5
5
  // 2. SCHEMA 是唯一规则表:面板只按 schema 渲染,服务端只按 schema 校验,两边都不写死。
6
6
  // 3. 写盘原子(tmp → rename)且尽力 0600;Windows 不保留 POSIX 位,读回可能是 0666。
7
7
  //
8
- // 只做「容器与开关」:工具收窄、B 站扫码这类行为留给后面的模块接;
9
- // `bili.qrLogin` 是只读状态(idle / logged-in),面板写入会被忽略并回一句提示。
8
+ // 只做「容器与开关」:工具收窄这类行为留给后面的模块接;B 站登录状态走 `/bili` 与
9
+ // `owner.json`,不进 settings.json(cookie 不落在面板可写的文件里)。
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
12
 
@@ -20,7 +20,7 @@ export const DEFAULTS = {
20
20
  nightly: { auto: true, localModel: false },
21
21
  panel: { affinity: true, reveal: true },
22
22
  home: { auto: true },
23
- bili: { speak: false, qrLogin: 'idle' }, // qrLogin 只读状态:idle/logged-in
23
+ bili: { speak: false }, // 登录态在 owner.json(/bili),这里只留说话开关
24
24
  console: { enabled: true },
25
25
  // 隐藏项:语音回复总开关。默认允许,但没适配器时完全无效果;不进 SCHEMA。
26
26
  voice: { enabled: true },
@@ -117,14 +117,6 @@ export const SCHEMA = [
117
117
  hint: '允许她在 B 站视频下面说一句',
118
118
  restart: true,
119
119
  },
120
- {
121
- path: 'bili.qrLogin',
122
- kind: 'string',
123
- label: 'B 站登录状态',
124
- hint: '只读:idle / logged-in;扫码登录下一步接',
125
- readOnly: true,
126
- restart: false,
127
- },
128
120
  {
129
121
  path: 'console.enabled',
130
122
  kind: 'bool',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pisces",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "description": "鱼肥生双:一只会长大的本地小鱼(DSH 插件)。事件流记忆、渐进揭开、好感、新手引导;派希丝是养成目标。",
6
6
  "type": "module",
@@ -58,4 +58,4 @@
58
58
  "bugs": {
59
59
  "url": "https://github.com/Xiaocanglan-hub/dsh-pisces/issues"
60
60
  }
61
- }
61
+ }