dsh-plugin-cicd 0.0.0-stage → 0.5.1

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.
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * Bilibili update announcements: the cookie jar, the comment text, the verdicts,
3
+ * and the two HTTP calls that reach Bilibili.
4
+ *
5
+ * Why this is its own module rather than a section of `index.js`: everything that
6
+ * can be decided without a network — is this credential complete, what would the
7
+ * comment say, has this version already been announced — is the part that is
8
+ * expensive to get wrong and cheap to test. The transport is a factory that takes
9
+ * a `fetch`, so a test drives the whole flow against a fake and never touches the
10
+ * real account.
11
+ *
12
+ * Two facts about Bilibili shape all of it:
13
+ *
14
+ * - A comment is a WEB API. The credential biliup writes by default is an
15
+ * APP/TV one (`platform: BiliTV`), which the web member endpoints answer with
16
+ * `-101 账号未登录`. So the credential's usability is decided by asking the
17
+ * account endpoint, never by trusting that a `SESSDATA` line exists.
18
+ * - `oid` (not the BV id) is what the reply endpoint wants, so every post costs
19
+ * one `view` lookup first. That lookup is also the cheapest way to prove the
20
+ * bound video actually exists before anything is written to it.
21
+ *
22
+ * @module dsh-plugin-cicd/lib/bilibili
23
+ */
24
+
25
+ /** A BV id as Bilibili prints it: `BV` plus ten base58 characters. */
26
+ export const BVID_PATTERN = /^BV[0-9A-Za-z]{10}$/
27
+
28
+ /** The reply endpoint's own ceiling. Longer text is refused, not truncated. */
29
+ export const MAX_COMMENT_LENGTH = 1000
30
+
31
+ /** How much of a release body becomes the one-line summary. */
32
+ export const MAX_SUMMARY_LENGTH = 90
33
+
34
+ /** Video comments are `type=1`; the other types are dynamics and articles. */
35
+ export const REPLY_TYPE_VIDEO = 1
36
+
37
+ /**
38
+ * The default comment text.
39
+ *
40
+ * Deliberately one line and deliberately without a URL: Bilibili's comment filter
41
+ * treats external links as spam far more often than it treats a sentence as spam,
42
+ * and a filtered comment looks exactly like a posted one from this side. The
43
+ * release link is still available as `{url}` for anyone who wants it.
44
+ */
45
+ /**
46
+ * `{summary}` and not `{changes}`: the summary is the one that prefers whatever the
47
+ * release itself says, and only reaches for the commit list when the release is silent —
48
+ * which is what "介绍这次更新" wants in both cases. A template that would rather always
49
+ * show the commit subjects can say `{changes}`.
50
+ */
51
+ export const DEFAULT_TEMPLATE = '【更新 {tag}】{summary}'
52
+
53
+ /**
54
+ * How long a list of changes may be. Longer than a title, because this is the part that
55
+ * answers "更新了什么", and shorter than a comment can hold, because it is one line of
56
+ * a chat box rather than a changelog.
57
+ */
58
+ export const MAX_CHANGES_LENGTH = 240
59
+
60
+ /** What to say when the range held commits and none of them is news. */
61
+ export const MAINTENANCE_NOTE = '本次更新为发布流程与工程维护,功能未变'
62
+
63
+ /** Conventional-commit types, as words a viewer reads. */
64
+ const CHANGE_LABELS = [
65
+ [/^feat(\(.+\))?!?:\s*/i, '新功能'],
66
+ [/^fix(\(.+\))?!?:\s*/i, '修复'],
67
+ [/^perf(\(.+\))?!?:\s*/i, '性能'],
68
+ [/^refactor(\(.+\))?!?:\s*/i, '重构'],
69
+ ]
70
+
71
+ /**
72
+ * Types that describe the work rather than the plugin — including `docs`, which put
73
+ * "document the release procedure" in front of a viewer who came for the feature list.
74
+ */
75
+ const BOOKKEEPING_SUBJECT = /^(ci|chore|test|build|style|revert|release|docs)(\(.+\))?!?:\s*/i
76
+
77
+ /** `type(scope)!:` — the repository's convention, addressed to its authors. */
78
+ const CONVENTIONAL_PREFIX = /^[A-Za-z]+(\([^)]*\))?!?:\s*/
79
+
80
+ /** Sort key for a list that has room for three: what is new, before what was fixed. */
81
+ function changeRank(entry) {
82
+ const raw = typeof entry === 'string' ? entry : String(entry?.commit?.message ?? '')
83
+ const subject = raw.split(/\r?\n/)[0]
84
+ if (/^(feat|perf)(\(.+\))?!?:\s*/i.test(subject)) return 0
85
+ if (/^(fix|refactor)(\(.+\))?!?:\s*/i.test(subject)) return 1
86
+ /* A subject with no conventional type is prose somebody wrote on purpose. */
87
+ return 0
88
+ }
89
+
90
+ /** The placeholders a template may use. Anything else is reported, not eaten. */
91
+ export const TEMPLATE_KEYS = ['tag', 'version', 'label', 'repo', 'title', 'summary', 'url', 'date']
92
+
93
+ /**
94
+ * Bilibili reply failures that mean something specific, and what to do about each.
95
+ *
96
+ * These are worth naming because the raw pairs are indistinguishable in practice:
97
+ * a rejected message and a risk-control block both arrive as `code != 0` with a
98
+ * Chinese sentence, and only one of them is fixed by editing the text.
99
+ */
100
+ const REPLY_FAILURES = new Map([
101
+ [-101, { kind: 'not-logged-in', advice: 'cookie 已失效或不是 Web 登录凭据:用面板的【登录 B 站】重新登录。' }],
102
+ [-400, { kind: 'rejected', advice: '请求被拒:多半是评论内容触发了过滤,或这个账号还不能发评论。' }],
103
+ [-403, { kind: 'forbidden', advice: '没有权限:检查视频是否关闭了评论区、稿件是否已删除。' }],
104
+ [-404, { kind: 'not-found', advice: '稿件不存在:检查绑定的 BV 号。' }],
105
+ [-412, { kind: 'risk-control', advice: '被 B 站风控拦截:等几分钟再试,别连续重试。' }],
106
+ [-509, { kind: 'rate-limited', advice: '请求过于频繁:等一段时间再发。' }],
107
+ [12015, { kind: 'captcha', advice: '需要验证码:先去网页端手动发一条,之后通常就恢复了。' }],
108
+ [12051, { kind: 'rate-limited', advice: '评论频率受限:等几分钟再试。' }],
109
+ [12061, { kind: 'content-rejected', advice: '内容含被过滤的词:改短一点、去掉链接再试。' }],
110
+ ])
111
+
112
+ /**
113
+ * Normalize a BV id, or return null.
114
+ * @param {unknown} value - candidate.
115
+ * @returns {string|null} the id as written when it is well formed.
116
+ */
117
+ export function normalizeBvid(value) {
118
+ if (typeof value !== 'string') return null
119
+ const trimmed = value.trim()
120
+ return BVID_PATTERN.test(trimmed) ? trimmed : null
121
+ }
122
+
123
+ /**
124
+ * Parse a cookie source into a flat jar.
125
+ *
126
+ * Three shapes are accepted because three genuinely occur here: biliup's
127
+ * `cookies.json` (`cookie_info.cookies[]`), the plugin's own file
128
+ * (`cookies: {name: value}`), and a raw header pasted from a browser
129
+ * (`SESSDATA=…; bili_jct=…`). Refusing two of them would mean telling the user
130
+ * their working file is the wrong format.
131
+ *
132
+ * @param {unknown} raw - file contents, or an already-parsed object.
133
+ * @returns {{ok: boolean, cookies: object, sessdata: string, csrf: string, uid: string, platform: string, expiresAt: number|null, message: string}}
134
+ */
135
+ export function parseCookieJar(raw) {
136
+ const empty = { ok: false, cookies: {}, sessdata: '', csrf: '', uid: '', platform: '', expiresAt: null, message: '' }
137
+ let source = raw
138
+ if (typeof source === 'string') {
139
+ const trimmed = source.trim()
140
+ if (trimmed === '') return { ...empty, message: 'the file is empty' }
141
+ if (trimmed.startsWith('{')) {
142
+ try {
143
+ source = JSON.parse(trimmed)
144
+ } catch (error) {
145
+ return { ...empty, message: `not valid JSON: ${error.message}` }
146
+ }
147
+ } else {
148
+ return fromHeader(trimmed)
149
+ }
150
+ }
151
+ if (source === null || typeof source !== 'object') return { ...empty, message: 'expected a JSON object or a cookie header' }
152
+
153
+ const platform = typeof source.platform === 'string' ? source.platform : ''
154
+ const listed = Array.isArray(source.cookie_info?.cookies)
155
+ ? source.cookie_info.cookies
156
+ : Array.isArray(source.cookies)
157
+ ? source.cookies
158
+ : null
159
+ if (listed !== null) {
160
+ const cookies = {}
161
+ let expiresAt = null
162
+ for (const entry of listed) {
163
+ const name = typeof entry?.name === 'string' ? entry.name : ''
164
+ if (name === '') continue
165
+ cookies[name] = typeof entry?.value === 'string' ? entry.value : String(entry?.value ?? '')
166
+ if (name === 'SESSDATA' && Number.isFinite(entry?.expires)) expiresAt = Number(entry.expires)
167
+ }
168
+ return finish(cookies, { platform, expiresAt, message: '' })
169
+ }
170
+ if (source.cookies !== null && typeof source.cookies === 'object') {
171
+ const cookies = {}
172
+ for (const [name, value] of Object.entries(source.cookies)) {
173
+ if (typeof value === 'string') cookies[name] = value
174
+ }
175
+ return finish(cookies, { platform, expiresAt: null, message: '' })
176
+ }
177
+ if (typeof source.cookie === 'string') return { ...fromHeader(source.cookie), ok: true }
178
+ const flat = {}
179
+ for (const name of ['SESSDATA', 'bili_jct', 'DedeUserID', 'buvid3', 'buvid4']) {
180
+ if (typeof source[name] === 'string') flat[name] = source[name]
181
+ }
182
+ if (Object.keys(flat).length > 0) return finish(flat, { platform, expiresAt: null, message: '' })
183
+ return { ...empty, message: 'no cookie found: expected cookie_info.cookies, cookies, or a "name=value" header' }
184
+ }
185
+
186
+ /** Parse a `name=value; name2=value2` header into a jar. */
187
+ function fromHeader(text) {
188
+ const cookies = {}
189
+ for (const part of text.split(';')) {
190
+ const index = part.indexOf('=')
191
+ if (index <= 0) continue
192
+ const name = part.slice(0, index).trim()
193
+ const value = part.slice(index + 1).trim()
194
+ if (name === '' || value === '') continue
195
+ cookies[name] = value.replace(/^"|"$/g, '')
196
+ }
197
+ return finish(cookies, { platform: '', expiresAt: null, message: '' })
198
+ }
199
+
200
+ /** Assemble the canonical jar result. */
201
+ function finish(cookies, { platform, expiresAt, message }) {
202
+ return {
203
+ ok: Object.keys(cookies).length > 0,
204
+ cookies,
205
+ sessdata: cookies.SESSDATA ?? '',
206
+ csrf: cookies.bili_jct ?? '',
207
+ uid: cookies.DedeUserID ?? '',
208
+ platform,
209
+ expiresAt,
210
+ message: message === '' && Object.keys(cookies).length === 0 ? 'no cookie found' : message,
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Render a jar back into a `Cookie:` header.
216
+ * @param {object} cookies - name to value.
217
+ * @returns {string} the header value.
218
+ */
219
+ export function cookieHeader(cookies) {
220
+ if (cookies === null || typeof cookies !== 'object') return ''
221
+ return Object.entries(cookies)
222
+ .filter(([name, value]) => typeof name === 'string' && name !== '' && typeof value === 'string' && value !== '')
223
+ .map(([name, value]) => `${name}=${value}`)
224
+ .join('; ')
225
+ }
226
+
227
+ /**
228
+ * Decide whether this credential can post a comment at all.
229
+ *
230
+ * The account answer is the only evidence that counts. `platform` is carried into
231
+ * the message because it explains the failure: a `BiliTV` jar is a real, live
232
+ * credential that the web endpoints still refuse, and without that word the
233
+ * `-101` reads as "you are not logged in", which sends the reader to re-login in
234
+ * the wrong place.
235
+ *
236
+ * @param {object} params - `{jar, account}`.
237
+ * @returns {{state: string, message: string, account: object|null}}
238
+ */
239
+ export function credentialVerdict({ jar = null, account = null } = {}) {
240
+ if (jar === null || jar.ok !== true) {
241
+ /* "There is no file" and "the file cannot be read" need different words: the
242
+ first is a setup step, the second is a problem to fix. */
243
+ if (jar?.absent === true) return { state: 'none', message: jar.message ?? '还没有 B 站凭据。', account: null }
244
+ return { state: 'unreadable', message: jar?.message ?? 'no credential file', account: null }
245
+ }
246
+ if (jar.sessdata === '' || jar.csrf === '') {
247
+ const missing = [jar.sessdata === '' ? 'SESSDATA' : null, jar.csrf === '' ? 'bili_jct' : null].filter(Boolean)
248
+ return { state: 'incomplete', message: `缺少 ${missing.join(' 与 ')}:发评论两者都需要。`, account: null }
249
+ }
250
+ if (account === null) return { state: 'unverified', message: '还没有验证这份凭据。', account: null }
251
+ if (account.ok === true) {
252
+ return { state: 'ready', message: '', account: { mid: account.mid, uname: account.uname } }
253
+ }
254
+ const appCredential = /tv|android|ios|app/i.test(jar.platform)
255
+ if (account.code === -101 && appCredential) {
256
+ return {
257
+ state: 'not-logged-in',
258
+ message: `这份凭据是 ${jar.platform} 登录(APP/TV),B 站 Web 会员接口回 -101——发评论走的是 Web 接口,所以它发不了。用面板的【登录 B 站】或粘贴浏览器里的 SESSDATA/bili_jct。`,
259
+ account: null,
260
+ }
261
+ }
262
+ return { state: 'not-logged-in', message: account.message === '' ? '凭据没有被 B 站接受。' : account.message, account: null }
263
+ }
264
+
265
+ /**
266
+ * Strip Markdown down to something a comment can carry.
267
+ *
268
+ * Comments are plain text: a release note pasted verbatim shows up with `**` and
269
+ * `##` in it. Links keep their label and lose the URL, which is also what keeps
270
+ * the auto-generated "by @user in https://…" tail from swallowing the summary.
271
+ *
272
+ * @param {unknown} value - Markdown source.
273
+ * @returns {string} one line of plain text.
274
+ */
275
+ export function stripMarkdown(value) {
276
+ if (typeof value !== 'string') return ''
277
+ return value
278
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
279
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
280
+ .replace(/<https?:\/\/[^>]*>/g, ' ')
281
+ .replace(/<[^>]+>/g, ' ')
282
+ .replace(/^\s{0,3}#{1,6}\s*/gm, '')
283
+ .replace(/^\s{0,3}>\s?/gm, '')
284
+ .replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, '')
285
+ .replace(/`{1,3}/g, '')
286
+ .replace(/\*\*|__/g, '')
287
+ .replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|$)/g, '$1$2')
288
+ .replace(/\s+/g, ' ')
289
+ .trim()
290
+ }
291
+
292
+ /**
293
+ * Whether a release-note line is GitHub's own boilerplate rather than content.
294
+ * @param {string} line - one stripped line.
295
+ * @returns {boolean} true when the line says nothing about the change.
296
+ */
297
+ function isBoilerplate(line) {
298
+ if (line === '') return true
299
+ if (/^(what'?s changed|full changelog|changelog|更新内容|变更内容|更新日志)\s*[::]?$/i.test(line)) return true
300
+ if (/^full changelog\b/i.test(line)) return true
301
+ if (/^https?:\/\/\S+$/i.test(line)) return true
302
+ return false
303
+ }
304
+
305
+ /**
306
+ * Turn a release into the one line a comment can carry.
307
+ *
308
+ * The name wins when it says something the tag does not: `v0.5.1` as a name is
309
+ * noise, while `发布台:B 站更新播报` is the summary. The body is the fallback,
310
+ * with the boilerplate and the co-author tails removed.
311
+ *
312
+ * @param {object} release - `{tag, name, body}`.
313
+ * @param {object} [options] - `{maxLength, fallback}`.
314
+ * @returns {string} a plain-text summary, never longer than `maxLength`.
315
+ */
316
+ export function summarizeRelease(release, { commits = null, names = [], maxLength = MAX_SUMMARY_LENGTH, fallback = '' } = {}) {
317
+ const tag = typeof release?.tag === 'string' ? release.tag : ''
318
+ const version = tag.replace(/^v/, '')
319
+ const name = stripMarkdown(release?.name)
320
+ const nameIsNoise = name === '' || name === tag || name === version || name === `v${version}` ||
321
+ /* `<repo> v1.0.1` says exactly as much as `v1.0.1` does, and the release workflow
322
+ names every release that way — so treating it as a summary buries every real
323
+ change behind the one line that carries none. Which is what it did: the comments
324
+ read "【更新 v1.0.1】<repo> v1.0.1" and the commit list was never reached. */
325
+ names.some((candidate) => {
326
+ const base = stripMarkdown(candidate)
327
+ if (base === '') return false
328
+ return name === `${base} ${tag}` || name === `${base} ${version}` || name === `${base} v${version}` ||
329
+ name === `${base}: ${tag}` || name === `${base}: ${version}` || name === `${base}: v${version}`
330
+ })
331
+ if (!nameIsNoise) return clamp(name, maxLength)
332
+
333
+ const lines = String(release?.body ?? '')
334
+ .split(/\r?\n/)
335
+ .map((line) => stripMarkdown(line).replace(/\s+by\s+@\S+\s+in\s+https?:\/\/\S+\s*$/i, '').replace(/\s+in\s+https?:\/\/\S+\s*$/i, '').trim())
336
+ .filter((line) => !isBoilerplate(line))
337
+ const joined = lines.slice(0, 3).join(' ').trim()
338
+ if (joined !== '') return clamp(joined, maxLength)
339
+
340
+ /*
341
+ * The commits, when the release says nothing itself.
342
+ *
343
+ * This is the normal case for these repositories: their releases carry a body of
344
+ * exactly `**Full Changelog**: <url>` and nothing else, because the release workflow
345
+ * creates them without notes. So "介绍这次更新" cannot come from the release — the
346
+ * only place the changes exist is the history between the two tags.
347
+ */
348
+ const fromCommits = summarizeCommits(commits, { maxLength: MAX_CHANGES_LENGTH })
349
+ if (fromCommits !== '') return fromCommits
350
+ return clamp(stripMarkdown(fallback), maxLength)
351
+ }
352
+
353
+ /**
354
+ * What changed, out of the commits between two releases.
355
+ *
356
+ * Subjects only, and the ones a reader of a video comment would recognise: a merge
357
+ * commit, a release chore or a `[skip ci]` marker is bookkeeping, not news. Joined with
358
+ * `;` rather than newlines because a Bilibili comment is a chat box, not a changelog.
359
+ *
360
+ * @param {unknown} commits - GitHub compare entries (`{commit: {message}, author}`) or plain strings.
361
+ * @param {object} [options] - `{max, maxLength}`.
362
+ * @returns {string} the summary, empty when nothing survives the filter.
363
+ */
364
+ export function summarizeCommits(commits, { max = 3, maxLength = MAX_CHANGES_LENGTH } = {}) {
365
+ const list = Array.isArray(commits) ? commits : []
366
+ if (list.length === 0) return ''
367
+ const seen = new Set()
368
+ const kept = []
369
+ /*
370
+ * Newest first, and features before fixes.
371
+ *
372
+ * The compare API hands them oldest first, and taking the first three of that put the
373
+ * EARLIEST work of a release in the comment — for v0.5.0, three npm details, while the
374
+ * 提交 button, the Bilibili note and the one-page settings that the release is actually
375
+ * about were cut off the end. A version culminates in what it is for, and among equals
376
+ * a feature is what "what's new" means.
377
+ */
378
+ const ordered = [...list].reverse().sort((left, right) => changeRank(left) - changeRank(right))
379
+ for (const entry of ordered) {
380
+ const raw = typeof entry === 'string' ? entry : String(entry?.commit?.message ?? '')
381
+ const subject = raw.split(/\r?\n/)[0].trim()
382
+ if (subject === '') continue
383
+ if (/^merge\b/i.test(subject)) continue
384
+ /* A build tweak is not a change a viewer of the video can see. Filtering these out
385
+ is the difference between "what is new" and a wall of `ci:` — which is what the
386
+ first version of this produced, cut off mid-sentence at 90 characters. */
387
+ if (BOOKKEEPING_SUBJECT.test(subject)) continue
388
+ if (/\[skip ci\]/i.test(subject)) continue
389
+ let clean = stripMarkdown(subject).replace(/\s*\(#\d+\)\s*$/, '').trim()
390
+ if (clean === '') continue
391
+ /*
392
+ * `fix: stop the flashing` reads as a changelog line to its author and as jargon to
393
+ * everyone else, so a known type becomes a word. An UNKNOWN one still loses its
394
+ * prefix — `polish(bilibili): size the code` is a sentence with a scope in front of
395
+ * it, and the scope belongs to the repository's conventions, not to the viewer. The
396
+ * message itself is left exactly as written: these histories are in English, and
397
+ * translating them by machine would be inventing meaning.
398
+ */
399
+ let label = ''
400
+ for (const [pattern, text] of CHANGE_LABELS) {
401
+ if (pattern.test(clean)) {
402
+ label = text
403
+ break
404
+ }
405
+ }
406
+ clean = clean.replace(CONVENTIONAL_PREFIX, '').trim()
407
+ if (clean === '') continue
408
+ if (label !== '') clean = `${label}:${clean}`
409
+ if (seen.has(clean)) continue
410
+ seen.add(clean)
411
+ kept.push(clean)
412
+ if (kept.length >= Math.max(1, max)) break
413
+ }
414
+ /* Commits existed and none of them is news — that is itself worth saying, and it is
415
+ not the same as having no history to read (which answers '' and lets the caller fall
416
+ back). A version that only moved CI should not read as an empty announcement. */
417
+ if (kept.length === 0) return MAINTENANCE_NOTE
418
+ return clamp(kept.join(';'), maxLength)
419
+ }
420
+
421
+ /** Cut a string to a length, on a word boundary when there is one. */
422
+ function clamp(value, maxLength) {
423
+ const text = typeof value === 'string' ? value : ''
424
+ if (text.length <= maxLength) return text
425
+ const cut = text.slice(0, Math.max(1, maxLength - 1))
426
+ const space = cut.lastIndexOf(' ')
427
+ return `${space > maxLength * 0.6 ? cut.slice(0, space) : cut}…`
428
+ }
429
+
430
+ /**
431
+ * Substitute `{name}` placeholders.
432
+ *
433
+ * Unknown placeholders are left in place and reported: silently deleting them is
434
+ * how a typo becomes a comment that reads fine and says nothing.
435
+ *
436
+ * @param {string} template - the template text.
437
+ * @param {object} vars - placeholder values.
438
+ * @returns {{text: string, unknown: string[]}} the rendered text and the names it did not know.
439
+ */
440
+ export function renderTemplate(template, vars) {
441
+ const unknown = []
442
+ const source = typeof template === 'string' && template.trim() !== '' ? template : DEFAULT_TEMPLATE
443
+ const text = source.replace(/\{([A-Za-z0-9_]+)\}/g, (match, name) => {
444
+ if (!Object.hasOwn(vars, name)) {
445
+ if (!unknown.includes(name)) unknown.push(name)
446
+ return match
447
+ }
448
+ return String(vars[name] ?? '')
449
+ })
450
+ return { text: text.replace(/[ \t]+/g, ' ').trim(), unknown }
451
+ }
452
+
453
+ /**
454
+ * Compose the comment for one release.
455
+ *
456
+ * @param {object} params - `{label, repo, tag, release, template, date, fallback}`.
457
+ * @returns {{text: string, unknown: string[], summary: string, truncated: boolean}} the comment and what it could not resolve.
458
+ */
459
+ export function composeComment({ label = '', repo = '', tag = '', release = null, template = '', date = '', fallback = '', commits = null } = {}) {
460
+ const effectiveTag = tag !== '' ? tag : (typeof release?.tag === 'string' ? release.tag : '')
461
+ /* `{changes}` is the same material `{summary}` falls back to, exposed on its own so a
462
+ template can say "本版更新:{changes}" without depending on what the release body
463
+ happened to contain. */
464
+ const changes = summarizeCommits(commits)
465
+ /* The names a release title could be made of, so `dsh-ui-sound v0.1.1` counts as the
466
+ tag it repeats rather than as a description of the change. */
467
+ const names = [label !== '' ? label : repo, repo, String(repo).slice(String(repo).lastIndexOf('/') + 1)]
468
+ const summary = summarizeRelease({ ...release, tag: effectiveTag }, { commits, names, fallback })
469
+ const rendered = renderTemplate(template, {
470
+ tag: effectiveTag,
471
+ version: effectiveTag.replace(/^v/, ''),
472
+ label: label !== '' ? label : repo,
473
+ repo,
474
+ title: stripMarkdown(release?.name),
475
+ summary,
476
+ changes,
477
+ url: typeof release?.url === 'string' ? release.url : '',
478
+ date,
479
+ })
480
+ const truncated = rendered.text.length > MAX_COMMENT_LENGTH
481
+ return {
482
+ text: truncated ? `${rendered.text.slice(0, MAX_COMMENT_LENGTH - 1)}…` : rendered.text,
483
+ unknown: rendered.unknown,
484
+ summary,
485
+ truncated,
486
+ }
487
+ }
488
+
489
+ /**
490
+ * The newest release a reader can actually see, or null.
491
+ *
492
+ * Drafts are excluded on purpose: a draft is invisible to everyone but the
493
+ * author, so announcing it would post "this is out" about something nobody can
494
+ * download. The console publishes drafts deliberately — that click is the moment
495
+ * an announcement becomes true.
496
+ *
497
+ * @param {object[]} releases - normalized releases.
498
+ * @returns {object|null} the newest non-draft release with a tag.
499
+ */
500
+ export function newestPublishedRelease(releases) {
501
+ const list = Array.isArray(releases) ? releases : []
502
+ const published = list.filter((release) => release !== null && typeof release === 'object' && release.draft !== true && typeof release.tag === 'string' && release.tag !== '')
503
+ if (published.length === 0) return null
504
+ return published.reduce((newest, release) => {
505
+ const left = Date.parse(release.createdAt ?? '')
506
+ const right = Date.parse(newest.createdAt ?? '')
507
+ if (!Number.isFinite(left)) return newest
508
+ if (!Number.isFinite(right)) return release
509
+ return left > right ? release : newest
510
+ })
511
+ }
512
+
513
+ /** An empty announcement ledger. */
514
+ export function emptyLedger() {
515
+ return { version: 1, entries: [] }
516
+ }
517
+
518
+ /**
519
+ * Parse the ledger file tolerantly.
520
+ *
521
+ * A ledger that cannot be read must never be treated as an empty one: the whole
522
+ * job of this file is to remember what was already said in public, and forgetting
523
+ * it re-posts every comment the next time the sweep runs.
524
+ *
525
+ * @param {unknown} raw - file contents.
526
+ * @returns {{ok: boolean, ledger: object, message: string}}
527
+ */
528
+ export function parseLedger(raw) {
529
+ if (raw === null || raw === undefined || (typeof raw === 'string' && raw.trim() === '')) {
530
+ return { ok: true, ledger: emptyLedger(), message: '' }
531
+ }
532
+ let parsed = raw
533
+ if (typeof raw === 'string') {
534
+ try {
535
+ parsed = JSON.parse(raw)
536
+ } catch (error) {
537
+ return { ok: false, ledger: emptyLedger(), message: `not valid JSON: ${error.message}` }
538
+ }
539
+ }
540
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
541
+ return { ok: false, ledger: emptyLedger(), message: 'expected an object with an "entries" array' }
542
+ }
543
+ const entries = parsed.entries.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.repo === 'string')
544
+ return { ok: true, ledger: { version: 1, entries }, message: '' }
545
+ }
546
+
547
+ /**
548
+ * The ledger entry for one repository and tag, if any.
549
+ * @param {object} ledger - parsed ledger.
550
+ * @param {string} repo - repository.
551
+ * @param {string} tag - release tag.
552
+ * @returns {object|null} the entry.
553
+ */
554
+ export function findLedgerEntry(ledger, repo, tag) {
555
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : []
556
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
557
+ const entry = entries[index]
558
+ if (entry.repo === repo && entry.tag === tag) return entry
559
+ }
560
+ return null
561
+ }
562
+
563
+ /**
564
+ * The most recent `baseline` entry for a repository.
565
+ *
566
+ * Binding a video must not fire a comment about the version that was already out
567
+ * when it was bound. That is what the baseline is: a marker written at bind time
568
+ * saying "everything up to here was already public before this video was wired up".
569
+ *
570
+ * @param {object} ledger - parsed ledger.
571
+ * @param {string} repo - repository.
572
+ * @returns {object|null} the newest baseline entry.
573
+ */
574
+ export function latestBaseline(ledger, repo) {
575
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : []
576
+ let newest = null
577
+ for (const entry of entries) {
578
+ if (entry.repo !== repo || entry.state !== 'baseline') continue
579
+ if (newest === null || String(entry.at ?? '') > String(newest.at ?? '')) newest = entry
580
+ }
581
+ return newest
582
+ }
583
+
584
+ /**
585
+ * Append or replace one entry, keeping the file bounded.
586
+ * @param {object} ledger - parsed ledger.
587
+ * @param {object} entry - the entry to store, keyed by `repo` + `tag`.
588
+ * @param {object} [options] - `{maxEntries}`.
589
+ * @returns {object} the next ledger.
590
+ */
591
+ export function recordLedgerEntry(ledger, entry, { maxEntries = 500 } = {}) {
592
+ const entries = (Array.isArray(ledger?.entries) ? ledger.entries : []).filter((candidate) => !(candidate.repo === entry.repo && candidate.tag === entry.tag))
593
+ entries.push(entry)
594
+ return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
595
+ }
596
+
597
+ /**
598
+ * Whether this repository's newest release should be announced right now.
599
+ *
600
+ * The order of the questions is the order of their cost: a missing binding costs
601
+ * nothing to notice, and "already announced" must be decided before anything is
602
+ * composed, because composing is what makes a duplicate look like a fresh job.
603
+ *
604
+ * @param {object} params - `{binding, release, ledger, force}`.
605
+ * @returns {{state: string, message: string, entry: object|null, attempts: number}} the verdict.
606
+ */
607
+ export function announcementVerdict({ binding = null, release = null, ledger = emptyLedger(), force = false } = {}) {
608
+ if (binding === null || typeof binding?.bvid !== 'string' || binding.bvid === '') {
609
+ return { state: 'unbound', message: '这个仓库还没有绑定 B 站视频。', entry: null, attempts: 0 }
610
+ }
611
+ if (release === null || typeof release?.tag !== 'string' || release.tag === '') {
612
+ return { state: 'no-release', message: '还没有已公开的 Release(草稿不算)。', entry: null, attempts: 0 }
613
+ }
614
+ const entry = findLedgerEntry(ledger, binding.repo ?? '', release.tag)
615
+ if (entry !== null && entry.state === 'announced' && force !== true) {
616
+ return { state: 'already', message: `${release.tag} 已经播报过了。`, entry, attempts: 0 }
617
+ }
618
+ const baseline = latestBaseline(ledger, binding.repo ?? '')
619
+ if (baseline !== null && force !== true) {
620
+ const sameTag = baseline.tag === release.tag
621
+ const at = Date.parse(String(baseline.at ?? ''))
622
+ const created = Date.parse(String(release.createdAt ?? ''))
623
+ // A baseline without a tag is a binding that could not read the release list;
624
+ // it then holds back everything created before it was written.
625
+ const older = baseline.tag === null && Number.isFinite(at) && Number.isFinite(created) && created <= at
626
+ if (sameTag || older) {
627
+ return { state: 'baseline', message: `${release.tag} 在绑定视频之前就已经发布了,不算这次更新。`, entry: baseline, attempts: 0 }
628
+ }
629
+ }
630
+ const attempts = Number.isFinite(entry?.attempts) ? Number(entry.attempts) : 0
631
+ if (entry !== null && entry.state === 'failed' && attempts >= 3 && force !== true) {
632
+ return { state: 'gave-up', message: `已经失败 ${attempts} 次,先看看原因再手动重试。`, entry, attempts }
633
+ }
634
+ return { state: 'ready', message: '', entry, attempts }
635
+ }
636
+
637
+ /**
638
+ * Name a reply failure.
639
+ * @param {number} code - Bilibili's `code`.
640
+ * @param {string} message - Bilibili's own sentence.
641
+ * @returns {{kind: string, advice: string, message: string}} the classification.
642
+ */
643
+ export function replyFailure(code, message = '') {
644
+ const known = REPLY_FAILURES.get(Number(code))
645
+ if (known === undefined) {
646
+ return { kind: 'unknown', advice: '没识别出具体原因,看 B 站返回的原话。', message: String(message ?? '') }
647
+ }
648
+ return { ...known, message: String(message ?? '') }
649
+ }
650
+
651
+ /**
652
+ * Read `Set-Cookie` headers off a fetch response.
653
+ *
654
+ * Node exposes `getSetCookie()`; a response from a fake in a test may only have
655
+ * `get('set-cookie')`. Both are read rather than requiring one shape.
656
+ *
657
+ * @param {object} headers - a `Headers`-like object.
658
+ * @returns {string[]} the raw header values.
659
+ */
660
+ export function readSetCookie(headers) {
661
+ if (headers === null || headers === undefined) return []
662
+ if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
663
+ if (typeof headers.get === 'function') {
664
+ const single = headers.get('set-cookie')
665
+ return typeof single === 'string' && single !== '' ? [single] : []
666
+ }
667
+ return []
668
+ }
669
+
670
+ /**
671
+ * Build a jar out of `Set-Cookie` header values, which is how the QR sign-in
672
+ * hands over the credential.
673
+ * @param {string[]} values - raw header values.
674
+ * @returns {object} name to value.
675
+ */
676
+ export function cookiesFromSetCookie(values) {
677
+ const cookies = {}
678
+ for (const value of Array.isArray(values) ? values : []) {
679
+ const first = String(value).split(';')[0]
680
+ const index = first.indexOf('=')
681
+ if (index <= 0) continue
682
+ const name = first.slice(0, index).trim()
683
+ const raw = first.slice(index + 1).trim()
684
+ if (name === '' || raw === '') continue
685
+ cookies[name] = raw
686
+ }
687
+ return cookies
688
+ }
689
+
690
+ /**
691
+ * Add the device cookies a browser would send.
692
+ *
693
+ * `buvid3` and `buvid4` are what make a request look like it came from a browser
694
+ * rather than from a script, and their absence is one of the things that earns a
695
+ * `-412`. Both are fetched together and both belong on the request — sending only
696
+ * the first was throwing half of the answer away.
697
+ *
698
+ * A credential's own value always wins: an account that already carries a device id
699
+ * has one for a reason, and replacing it would be inventing a device.
700
+ *
701
+ * This lives here rather than in the route because the route cannot be tested end to
702
+ * end: reaching a real post needs a release list, and the suite deliberately gives
703
+ * the Host a `gh` that cannot exist. A pure function is the only way this decision
704
+ * gets any coverage at all.
705
+ *
706
+ * @param {object} cookies - the credential's cookies.
707
+ * @param {object} fingerprint - `{buvid3, buvid4}` from `fingerPrint()`.
708
+ * @returns {object} a new object; the input is not mutated.
709
+ */
710
+ export function withDeviceIds(cookies, fingerprint) {
711
+ const next = { ...(cookies === null || typeof cookies !== 'object' ? {} : cookies) }
712
+ const found = fingerprint === null || typeof fingerprint !== 'object' ? {} : fingerprint
713
+ for (const name of ['buvid3', 'buvid4']) {
714
+ const value = typeof found[name] === 'string' ? found[name].trim() : ''
715
+ if (value !== '' && next[name] === undefined) next[name] = value
716
+ }
717
+ return next
718
+ }
719
+
720
+ /**
721
+ * The cookies Bilibili puts in the poll's success URL.
722
+ *
723
+ * The web sign-in hands the same credential over twice: as `Set-Cookie` headers,
724
+ * and as the query string of `data.url` — the cross-domain bounce the browser is
725
+ * meant to follow. Only one of them has to arrive, and which one does is not
726
+ * something this plugin controls: a proxy, a client that does not surface headers,
727
+ * or a change on Bilibili's side can drop either. Reading both is the difference
728
+ * between a completed sign-in and "B 站回了成功,但没有下发 Cookie" — a message that
729
+ * is honest and useless, because the credential was in the other half of the
730
+ * response all along.
731
+ *
732
+ * `gourl` and `Expires` travel in that query string and are not cookies.
733
+ *
734
+ * @param {unknown} raw - `data.url` from the poll.
735
+ * @returns {object} name to value, empty when there is nothing usable.
736
+ */
737
+ export function cookiesFromLoginUrl(raw) {
738
+ const value = typeof raw === 'string' ? raw.trim() : ''
739
+ if (value === '') return {}
740
+ let parsed
741
+ try {
742
+ parsed = new URL(value)
743
+ } catch {
744
+ return {}
745
+ }
746
+ const cookies = {}
747
+ for (const [name, parameter] of parsed.searchParams) {
748
+ if (name === 'gourl' || name === 'Expires') continue
749
+ if (name === '' || parameter === '') continue
750
+ cookies[name] = parameter
751
+ }
752
+ return cookies
753
+ }
754
+
755
+ const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
756
+
757
+ /**
758
+ * The Bilibili transport.
759
+ *
760
+ * Every call returns a plain result object instead of throwing: the panel's job
761
+ * is to print what Bilibili said, and an exception carries no `code` to print.
762
+ *
763
+ * @param {object} [options] - `{fetchImpl, timeoutMs, userAgent}`.
764
+ * @returns {object} the client.
765
+ */
766
+ export function createBilibiliClient({ fetchImpl = globalThis.fetch, timeoutMs = 15_000, userAgent = DEFAULT_UA } = {}) {
767
+ /** One GET, with the headers a browser would send. */
768
+ const get = async (url, { referer = 'https://www.bilibili.com/', cookie = '' } = {}) => {
769
+ try {
770
+ const requestHeaders = { 'user-agent': userAgent, referer, origin: 'https://www.bilibili.com' }
771
+ if (cookie !== '') requestHeaders.cookie = cookie
772
+ const response = await fetchImpl(url, {
773
+ method: 'GET',
774
+ headers: requestHeaders,
775
+ signal: AbortSignal.timeout(timeoutMs),
776
+ })
777
+ const headers = response.headers ?? null
778
+ let payload = null
779
+ try {
780
+ payload = await response.json()
781
+ } catch {
782
+ payload = null
783
+ }
784
+ return { status: response.status, payload, headers }
785
+ } catch (error) {
786
+ return { status: 0, payload: null, headers: null, error: String(error?.message ?? error) }
787
+ }
788
+ }
789
+
790
+ return {
791
+ /**
792
+ * Who the credential belongs to — the only proof that it is a web session.
793
+ * @param {string} cookie - the `Cookie:` header value.
794
+ * @returns {Promise<object>} `{ok, mid, uname, code, message}`.
795
+ */
796
+ async readAccount(cookie) {
797
+ const result = await get('https://api.bilibili.com/x/member/web/account', { cookie })
798
+ if (result.payload === null) {
799
+ return { ok: false, mid: '', uname: '', code: null, message: result.error ?? `HTTP ${String(result.status)}` }
800
+ }
801
+ const payload = result.payload
802
+ if (payload.code !== 0) {
803
+ return { ok: false, mid: '', uname: '', code: Number(payload.code), message: String(payload.message ?? '') }
804
+ }
805
+ return {
806
+ ok: true,
807
+ mid: String(payload.data?.mid ?? ''),
808
+ uname: String(payload.data?.uname ?? ''),
809
+ code: 0,
810
+ message: '',
811
+ }
812
+ },
813
+
814
+ /**
815
+ * Resolve a video to the `aid` a comment is posted against.
816
+ * @param {string} cookie - the `Cookie:` header value.
817
+ * @param {string} bvid - the video.
818
+ * @returns {Promise<object>} `{ok, aid, title, owner, code, message}`.
819
+ */
820
+ async resolveVideo(cookie, bvid) {
821
+ const result = await get(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, {
822
+ cookie,
823
+ referer: `https://www.bilibili.com/video/${bvid}/`,
824
+ })
825
+ if (result.payload === null) {
826
+ return { ok: false, aid: null, title: '', owner: '', code: null, message: result.error ?? `HTTP ${String(result.status)}` }
827
+ }
828
+ const payload = result.payload
829
+ if (payload.code !== 0) {
830
+ return { ok: false, aid: null, title: '', owner: '', code: Number(payload.code), message: String(payload.message ?? '') }
831
+ }
832
+ return {
833
+ ok: true,
834
+ aid: Number(payload.data?.aid) || null,
835
+ title: String(payload.data?.title ?? ''),
836
+ owner: String(payload.data?.owner?.name ?? ''),
837
+ code: 0,
838
+ message: '',
839
+ }
840
+ },
841
+
842
+ /**
843
+ * Post one comment.
844
+ *
845
+ * `csrf` travels in both the query and the body, which is what the endpoint
846
+ * actually validates; sending it once is the documented shape and the shape
847
+ * that has been observed to fail.
848
+ *
849
+ * @param {object} params - `{cookie, csrf, aid, bvid, message}`.
850
+ * @returns {Promise<object>} `{ok, rpid, code, message, failure}`.
851
+ */
852
+ async postComment({ cookie, csrf, aid, bvid, message }) {
853
+ const body = new URLSearchParams({
854
+ oid: String(aid),
855
+ type: String(REPLY_TYPE_VIDEO),
856
+ message,
857
+ plat: '1',
858
+ csrf,
859
+ })
860
+ let result
861
+ try {
862
+ const response = await fetchImpl(`https://api.bilibili.com/x/v2/reply/add?csrf=${encodeURIComponent(csrf)}`, {
863
+ method: 'POST',
864
+ headers: {
865
+ 'user-agent': userAgent,
866
+ referer: `https://www.bilibili.com/video/${bvid}/`,
867
+ origin: 'https://www.bilibili.com',
868
+ 'content-type': 'application/x-www-form-urlencoded',
869
+ cookie,
870
+ },
871
+ body: body.toString(),
872
+ signal: AbortSignal.timeout(timeoutMs),
873
+ })
874
+ result = { status: response.status, payload: await response.json() }
875
+ } catch (error) {
876
+ return { ok: false, rpid: null, code: null, message: String(error?.message ?? error), failure: replyFailure(null, String(error?.message ?? error)) }
877
+ }
878
+ const payload = result.payload ?? {}
879
+ if (payload.code !== 0) {
880
+ return {
881
+ ok: false,
882
+ rpid: null,
883
+ code: Number(payload.code),
884
+ message: String(payload.message ?? ''),
885
+ failure: replyFailure(payload.code, payload.message),
886
+ }
887
+ }
888
+ const rpid = payload.data?.rpid ?? payload.data?.rpid_str ?? null
889
+ return {
890
+ ok: true,
891
+ rpid: rpid === null ? null : String(rpid),
892
+ code: 0,
893
+ message: '',
894
+ failure: null,
895
+ }
896
+ },
897
+
898
+ /**
899
+ * Read one comment back by its id.
900
+ *
901
+ * The only way to know a comment is really there: `x/v2/reply/add` answers `code: 0`
902
+ * with an rpid for a comment the public listing may not serve at all, and a removed
903
+ * comment and one still in review are indistinguishable through this endpoint —
904
+ * `12006 没有该评论` is Bilibili's answer to both. So the answer here is deliberately
905
+ * two-valued: it is readable, or it is not, and why not is Bilibili's own sentence.
906
+ *
907
+ * @param {string} cookie - the `Cookie:` header value.
908
+ * @param {number} aid - the video's oid.
909
+ * @param {string} rpid - the comment to look for.
910
+ * @returns {Promise<object>} `{ok, visible, code, message, text}`.
911
+ */
912
+ async readComment(cookie, aid, rpid) {
913
+ const query = `type=1&oid=${encodeURIComponent(String(aid))}&root=${encodeURIComponent(String(rpid))}&ps=20&pn=1`
914
+ const result = await get(`https://api.bilibili.com/x/v2/reply/reply?${query}`, {
915
+ cookie,
916
+ referer: 'https://www.bilibili.com/',
917
+ })
918
+ if (result.payload === null) {
919
+ return { ok: false, visible: false, code: null, message: result.error ?? `HTTP ${String(result.status)}`, text: '' }
920
+ }
921
+ const payload = result.payload
922
+ if (payload.code !== 0) {
923
+ return { ok: true, visible: false, code: Number(payload.code), message: String(payload.message ?? ''), text: '' }
924
+ }
925
+ const root = payload.data?.root ?? null
926
+ const visible = root !== null && root !== undefined && String(root.rpid ?? '') === String(rpid)
927
+ return {
928
+ ok: true,
929
+ visible,
930
+ code: 0,
931
+ message: visible ? '' : '这条评论不在公开列表里',
932
+ text: String(root?.content?.message ?? ''),
933
+ }
934
+ },
935
+
936
+ /**
937
+ * A device id, best effort.
938
+ *
939
+ * `buvid3` is what makes a request look like it came from a browser rather
940
+ * than from a script, and its absence is one of the things that earns a `-412`.
941
+ * An answer is not required: when this call fails the cookie is used as is.
942
+ * @returns {Promise<object>} `{buvid3, buvid4}`.
943
+ */
944
+ async fingerPrint() {
945
+ const result = await get('https://api.bilibili.com/x/frontend/finger/spi')
946
+ return { buvid3: String(result.payload?.data?.b_3 ?? ''), buvid4: String(result.payload?.data?.b_4 ?? '') }
947
+ },
948
+
949
+ /**
950
+ * Start the web QR sign-in: one URL and one key to poll.
951
+ * @returns {Promise<object>} `{ok, url, key, message}`.
952
+ */
953
+ async startQrLogin() {
954
+ const result = await get('https://passport.bilibili.com/x/passport-login/web/qrcode/generate')
955
+ const payload = result.payload
956
+ if (payload === null || payload.code !== 0) {
957
+ return { ok: false, url: '', key: '', message: payload === null ? (result.error ?? `HTTP ${String(result.status)}`) : String(payload.message ?? '') }
958
+ }
959
+ return { ok: true, url: String(payload.data?.url ?? ''), key: String(payload.data?.qrcode_key ?? ''), message: '' }
960
+ },
961
+
962
+ /**
963
+ * Ask once whether the QR code has been scanned and confirmed.
964
+ *
965
+ * The three states are distinct because they need different words on screen:
966
+ * `86101` is "still waiting", `86090` is "your phone is asking you to confirm",
967
+ * and `86038` is "this code is dead, start again".
968
+ *
969
+ * @param {string} key - the `qrcode_key` from `startQrLogin`.
970
+ * @returns {Promise<object>} `{ok, state, cookies, message}`.
971
+ */
972
+ async pollQrLogin(key) {
973
+ const result = await get(`https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${encodeURIComponent(key)}&source=main-fe-header`)
974
+ const payload = result.payload
975
+ if (payload === null) return { ok: false, state: 'failed', cookies: {}, message: result.error ?? `HTTP ${String(result.status)}` }
976
+ if (payload.code !== 0) return { ok: false, state: 'failed', cookies: {}, message: String(payload.message ?? '') }
977
+ const inner = Number(payload.data?.code)
978
+ if (inner === 0) {
979
+ /*
980
+ * Both halves of the answer, header first: a partial `Set-Cookie` is filled
981
+ * in from the URL rather than reported as a sign-in that stored nothing.
982
+ */
983
+ const cookies = {
984
+ ...cookiesFromLoginUrl(payload.data?.url),
985
+ ...cookiesFromSetCookie(readSetCookie(result.headers)),
986
+ }
987
+ if (Object.keys(cookies).length === 0) {
988
+ // Success with no credential is a contradiction worth naming: without
989
+ // this the panel would report a login that stored nothing.
990
+ return { ok: false, state: 'failed', cookies: {}, message: 'B 站回了成功,但既没有下发 Cookie,返回的链接里也没有凭据;再登录一次。' }
991
+ }
992
+ return { ok: true, state: 'succeeded', cookies, message: '' }
993
+ }
994
+ if (inner === 86090) return { ok: true, state: 'scanned', cookies: {}, message: '' }
995
+ if (inner === 86038) return { ok: true, state: 'expired', cookies: {}, message: '' }
996
+ if (inner === 86101) return { ok: true, state: 'waiting', cookies: {}, message: '' }
997
+ return { ok: false, state: 'failed', cookies: {}, message: String(payload.data?.message ?? payload.message ?? '') }
998
+ },
999
+ }
1000
+ }