dsh-plugin-cicd 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
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
+ export const DEFAULT_TEMPLATE = '【更新 {tag}】{summary}'
46
+
47
+ /** The placeholders a template may use. Anything else is reported, not eaten. */
48
+ export const TEMPLATE_KEYS = ['tag', 'version', 'label', 'repo', 'title', 'summary', 'url', 'date']
49
+
50
+ /**
51
+ * Bilibili reply failures that mean something specific, and what to do about each.
52
+ *
53
+ * These are worth naming because the raw pairs are indistinguishable in practice:
54
+ * a rejected message and a risk-control block both arrive as `code != 0` with a
55
+ * Chinese sentence, and only one of them is fixed by editing the text.
56
+ */
57
+ const REPLY_FAILURES = new Map([
58
+ [-101, { kind: 'not-logged-in', advice: 'cookie 已失效或不是 Web 登录凭据:用面板的【登录 B 站】重新登录。' }],
59
+ [-400, { kind: 'rejected', advice: '请求被拒:多半是评论内容触发了过滤,或这个账号还不能发评论。' }],
60
+ [-403, { kind: 'forbidden', advice: '没有权限:检查视频是否关闭了评论区、稿件是否已删除。' }],
61
+ [-404, { kind: 'not-found', advice: '稿件不存在:检查绑定的 BV 号。' }],
62
+ [-412, { kind: 'risk-control', advice: '被 B 站风控拦截:等几分钟再试,别连续重试。' }],
63
+ [-509, { kind: 'rate-limited', advice: '请求过于频繁:等一段时间再发。' }],
64
+ [12015, { kind: 'captcha', advice: '需要验证码:先去网页端手动发一条,之后通常就恢复了。' }],
65
+ [12051, { kind: 'rate-limited', advice: '评论频率受限:等几分钟再试。' }],
66
+ [12061, { kind: 'content-rejected', advice: '内容含被过滤的词:改短一点、去掉链接再试。' }],
67
+ ])
68
+
69
+ /**
70
+ * Normalize a BV id, or return null.
71
+ * @param {unknown} value - candidate.
72
+ * @returns {string|null} the id as written when it is well formed.
73
+ */
74
+ export function normalizeBvid(value) {
75
+ if (typeof value !== 'string') return null
76
+ const trimmed = value.trim()
77
+ return BVID_PATTERN.test(trimmed) ? trimmed : null
78
+ }
79
+
80
+ /**
81
+ * Parse a cookie source into a flat jar.
82
+ *
83
+ * Three shapes are accepted because three genuinely occur here: biliup's
84
+ * `cookies.json` (`cookie_info.cookies[]`), the plugin's own file
85
+ * (`cookies: {name: value}`), and a raw header pasted from a browser
86
+ * (`SESSDATA=…; bili_jct=…`). Refusing two of them would mean telling the user
87
+ * their working file is the wrong format.
88
+ *
89
+ * @param {unknown} raw - file contents, or an already-parsed object.
90
+ * @returns {{ok: boolean, cookies: object, sessdata: string, csrf: string, uid: string, platform: string, expiresAt: number|null, message: string}}
91
+ */
92
+ export function parseCookieJar(raw) {
93
+ const empty = { ok: false, cookies: {}, sessdata: '', csrf: '', uid: '', platform: '', expiresAt: null, message: '' }
94
+ let source = raw
95
+ if (typeof source === 'string') {
96
+ const trimmed = source.trim()
97
+ if (trimmed === '') return { ...empty, message: 'the file is empty' }
98
+ if (trimmed.startsWith('{')) {
99
+ try {
100
+ source = JSON.parse(trimmed)
101
+ } catch (error) {
102
+ return { ...empty, message: `not valid JSON: ${error.message}` }
103
+ }
104
+ } else {
105
+ return fromHeader(trimmed)
106
+ }
107
+ }
108
+ if (source === null || typeof source !== 'object') return { ...empty, message: 'expected a JSON object or a cookie header' }
109
+
110
+ const platform = typeof source.platform === 'string' ? source.platform : ''
111
+ const listed = Array.isArray(source.cookie_info?.cookies)
112
+ ? source.cookie_info.cookies
113
+ : Array.isArray(source.cookies)
114
+ ? source.cookies
115
+ : null
116
+ if (listed !== null) {
117
+ const cookies = {}
118
+ let expiresAt = null
119
+ for (const entry of listed) {
120
+ const name = typeof entry?.name === 'string' ? entry.name : ''
121
+ if (name === '') continue
122
+ cookies[name] = typeof entry?.value === 'string' ? entry.value : String(entry?.value ?? '')
123
+ if (name === 'SESSDATA' && Number.isFinite(entry?.expires)) expiresAt = Number(entry.expires)
124
+ }
125
+ return finish(cookies, { platform, expiresAt, message: '' })
126
+ }
127
+ if (source.cookies !== null && typeof source.cookies === 'object') {
128
+ const cookies = {}
129
+ for (const [name, value] of Object.entries(source.cookies)) {
130
+ if (typeof value === 'string') cookies[name] = value
131
+ }
132
+ return finish(cookies, { platform, expiresAt: null, message: '' })
133
+ }
134
+ if (typeof source.cookie === 'string') return { ...fromHeader(source.cookie), ok: true }
135
+ const flat = {}
136
+ for (const name of ['SESSDATA', 'bili_jct', 'DedeUserID', 'buvid3', 'buvid4']) {
137
+ if (typeof source[name] === 'string') flat[name] = source[name]
138
+ }
139
+ if (Object.keys(flat).length > 0) return finish(flat, { platform, expiresAt: null, message: '' })
140
+ return { ...empty, message: 'no cookie found: expected cookie_info.cookies, cookies, or a "name=value" header' }
141
+ }
142
+
143
+ /** Parse a `name=value; name2=value2` header into a jar. */
144
+ function fromHeader(text) {
145
+ const cookies = {}
146
+ for (const part of text.split(';')) {
147
+ const index = part.indexOf('=')
148
+ if (index <= 0) continue
149
+ const name = part.slice(0, index).trim()
150
+ const value = part.slice(index + 1).trim()
151
+ if (name === '' || value === '') continue
152
+ cookies[name] = value.replace(/^"|"$/g, '')
153
+ }
154
+ return finish(cookies, { platform: '', expiresAt: null, message: '' })
155
+ }
156
+
157
+ /** Assemble the canonical jar result. */
158
+ function finish(cookies, { platform, expiresAt, message }) {
159
+ return {
160
+ ok: Object.keys(cookies).length > 0,
161
+ cookies,
162
+ sessdata: cookies.SESSDATA ?? '',
163
+ csrf: cookies.bili_jct ?? '',
164
+ uid: cookies.DedeUserID ?? '',
165
+ platform,
166
+ expiresAt,
167
+ message: message === '' && Object.keys(cookies).length === 0 ? 'no cookie found' : message,
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Render a jar back into a `Cookie:` header.
173
+ * @param {object} cookies - name to value.
174
+ * @returns {string} the header value.
175
+ */
176
+ export function cookieHeader(cookies) {
177
+ if (cookies === null || typeof cookies !== 'object') return ''
178
+ return Object.entries(cookies)
179
+ .filter(([name, value]) => typeof name === 'string' && name !== '' && typeof value === 'string' && value !== '')
180
+ .map(([name, value]) => `${name}=${value}`)
181
+ .join('; ')
182
+ }
183
+
184
+ /**
185
+ * Decide whether this credential can post a comment at all.
186
+ *
187
+ * The account answer is the only evidence that counts. `platform` is carried into
188
+ * the message because it explains the failure: a `BiliTV` jar is a real, live
189
+ * credential that the web endpoints still refuse, and without that word the
190
+ * `-101` reads as "you are not logged in", which sends the reader to re-login in
191
+ * the wrong place.
192
+ *
193
+ * @param {object} params - `{jar, account}`.
194
+ * @returns {{state: string, message: string, account: object|null}}
195
+ */
196
+ export function credentialVerdict({ jar = null, account = null } = {}) {
197
+ if (jar === null || jar.ok !== true) {
198
+ /* "There is no file" and "the file cannot be read" need different words: the
199
+ first is a setup step, the second is a problem to fix. */
200
+ if (jar?.absent === true) return { state: 'none', message: jar.message ?? '还没有 B 站凭据。', account: null }
201
+ return { state: 'unreadable', message: jar?.message ?? 'no credential file', account: null }
202
+ }
203
+ if (jar.sessdata === '' || jar.csrf === '') {
204
+ const missing = [jar.sessdata === '' ? 'SESSDATA' : null, jar.csrf === '' ? 'bili_jct' : null].filter(Boolean)
205
+ return { state: 'incomplete', message: `缺少 ${missing.join(' 与 ')}:发评论两者都需要。`, account: null }
206
+ }
207
+ if (account === null) return { state: 'unverified', message: '还没有验证这份凭据。', account: null }
208
+ if (account.ok === true) {
209
+ return { state: 'ready', message: '', account: { mid: account.mid, uname: account.uname } }
210
+ }
211
+ const appCredential = /tv|android|ios|app/i.test(jar.platform)
212
+ if (account.code === -101 && appCredential) {
213
+ return {
214
+ state: 'not-logged-in',
215
+ message: `这份凭据是 ${jar.platform} 登录(APP/TV),B 站 Web 会员接口回 -101——发评论走的是 Web 接口,所以它发不了。用面板的【登录 B 站】或粘贴浏览器里的 SESSDATA/bili_jct。`,
216
+ account: null,
217
+ }
218
+ }
219
+ return { state: 'not-logged-in', message: account.message === '' ? '凭据没有被 B 站接受。' : account.message, account: null }
220
+ }
221
+
222
+ /**
223
+ * Strip Markdown down to something a comment can carry.
224
+ *
225
+ * Comments are plain text: a release note pasted verbatim shows up with `**` and
226
+ * `##` in it. Links keep their label and lose the URL, which is also what keeps
227
+ * the auto-generated "by @user in https://…" tail from swallowing the summary.
228
+ *
229
+ * @param {unknown} value - Markdown source.
230
+ * @returns {string} one line of plain text.
231
+ */
232
+ export function stripMarkdown(value) {
233
+ if (typeof value !== 'string') return ''
234
+ return value
235
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
236
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
237
+ .replace(/<https?:\/\/[^>]*>/g, ' ')
238
+ .replace(/<[^>]+>/g, ' ')
239
+ .replace(/^\s{0,3}#{1,6}\s*/gm, '')
240
+ .replace(/^\s{0,3}>\s?/gm, '')
241
+ .replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, '')
242
+ .replace(/`{1,3}/g, '')
243
+ .replace(/\*\*|__/g, '')
244
+ .replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|$)/g, '$1$2')
245
+ .replace(/\s+/g, ' ')
246
+ .trim()
247
+ }
248
+
249
+ /**
250
+ * Whether a release-note line is GitHub's own boilerplate rather than content.
251
+ * @param {string} line - one stripped line.
252
+ * @returns {boolean} true when the line says nothing about the change.
253
+ */
254
+ function isBoilerplate(line) {
255
+ if (line === '') return true
256
+ if (/^(what'?s changed|full changelog|changelog|更新内容|变更内容|更新日志)\s*[::]?$/i.test(line)) return true
257
+ if (/^full changelog\b/i.test(line)) return true
258
+ if (/^https?:\/\/\S+$/i.test(line)) return true
259
+ return false
260
+ }
261
+
262
+ /**
263
+ * Turn a release into the one line a comment can carry.
264
+ *
265
+ * The name wins when it says something the tag does not: `v0.5.1` as a name is
266
+ * noise, while `发布台:B 站更新播报` is the summary. The body is the fallback,
267
+ * with the boilerplate and the co-author tails removed.
268
+ *
269
+ * @param {object} release - `{tag, name, body}`.
270
+ * @param {object} [options] - `{maxLength, fallback}`.
271
+ * @returns {string} a plain-text summary, never longer than `maxLength`.
272
+ */
273
+ export function summarizeRelease(release, { maxLength = MAX_SUMMARY_LENGTH, fallback = '' } = {}) {
274
+ const tag = typeof release?.tag === 'string' ? release.tag : ''
275
+ const version = tag.replace(/^v/, '')
276
+ const name = stripMarkdown(release?.name)
277
+ const nameIsNoise = name === '' || name === tag || name === version || name === `v${version}`
278
+ if (!nameIsNoise) return clamp(name, maxLength)
279
+
280
+ const lines = String(release?.body ?? '')
281
+ .split(/\r?\n/)
282
+ .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())
283
+ .filter((line) => !isBoilerplate(line))
284
+ const joined = lines.slice(0, 3).join(' ').trim()
285
+ if (joined !== '') return clamp(joined, maxLength)
286
+ return clamp(stripMarkdown(fallback), maxLength)
287
+ }
288
+
289
+ /** Cut a string to a length, on a word boundary when there is one. */
290
+ function clamp(value, maxLength) {
291
+ const text = typeof value === 'string' ? value : ''
292
+ if (text.length <= maxLength) return text
293
+ const cut = text.slice(0, Math.max(1, maxLength - 1))
294
+ const space = cut.lastIndexOf(' ')
295
+ return `${space > maxLength * 0.6 ? cut.slice(0, space) : cut}…`
296
+ }
297
+
298
+ /**
299
+ * Substitute `{name}` placeholders.
300
+ *
301
+ * Unknown placeholders are left in place and reported: silently deleting them is
302
+ * how a typo becomes a comment that reads fine and says nothing.
303
+ *
304
+ * @param {string} template - the template text.
305
+ * @param {object} vars - placeholder values.
306
+ * @returns {{text: string, unknown: string[]}} the rendered text and the names it did not know.
307
+ */
308
+ export function renderTemplate(template, vars) {
309
+ const unknown = []
310
+ const source = typeof template === 'string' && template.trim() !== '' ? template : DEFAULT_TEMPLATE
311
+ const text = source.replace(/\{([A-Za-z0-9_]+)\}/g, (match, name) => {
312
+ if (!Object.hasOwn(vars, name)) {
313
+ if (!unknown.includes(name)) unknown.push(name)
314
+ return match
315
+ }
316
+ return String(vars[name] ?? '')
317
+ })
318
+ return { text: text.replace(/[ \t]+/g, ' ').trim(), unknown }
319
+ }
320
+
321
+ /**
322
+ * Compose the comment for one release.
323
+ *
324
+ * @param {object} params - `{label, repo, tag, release, template, date, fallback}`.
325
+ * @returns {{text: string, unknown: string[], summary: string, truncated: boolean}} the comment and what it could not resolve.
326
+ */
327
+ export function composeComment({ label = '', repo = '', tag = '', release = null, template = '', date = '', fallback = '' } = {}) {
328
+ const effectiveTag = tag !== '' ? tag : (typeof release?.tag === 'string' ? release.tag : '')
329
+ const summary = summarizeRelease({ ...release, tag: effectiveTag }, { fallback })
330
+ const rendered = renderTemplate(template, {
331
+ tag: effectiveTag,
332
+ version: effectiveTag.replace(/^v/, ''),
333
+ label: label !== '' ? label : repo,
334
+ repo,
335
+ title: stripMarkdown(release?.name),
336
+ summary,
337
+ url: typeof release?.url === 'string' ? release.url : '',
338
+ date,
339
+ })
340
+ const truncated = rendered.text.length > MAX_COMMENT_LENGTH
341
+ return {
342
+ text: truncated ? `${rendered.text.slice(0, MAX_COMMENT_LENGTH - 1)}…` : rendered.text,
343
+ unknown: rendered.unknown,
344
+ summary,
345
+ truncated,
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The newest release a reader can actually see, or null.
351
+ *
352
+ * Drafts are excluded on purpose: a draft is invisible to everyone but the
353
+ * author, so announcing it would post "this is out" about something nobody can
354
+ * download. The console publishes drafts deliberately — that click is the moment
355
+ * an announcement becomes true.
356
+ *
357
+ * @param {object[]} releases - normalized releases.
358
+ * @returns {object|null} the newest non-draft release with a tag.
359
+ */
360
+ export function newestPublishedRelease(releases) {
361
+ const list = Array.isArray(releases) ? releases : []
362
+ const published = list.filter((release) => release !== null && typeof release === 'object' && release.draft !== true && typeof release.tag === 'string' && release.tag !== '')
363
+ if (published.length === 0) return null
364
+ return published.reduce((newest, release) => {
365
+ const left = Date.parse(release.createdAt ?? '')
366
+ const right = Date.parse(newest.createdAt ?? '')
367
+ if (!Number.isFinite(left)) return newest
368
+ if (!Number.isFinite(right)) return release
369
+ return left > right ? release : newest
370
+ })
371
+ }
372
+
373
+ /** An empty announcement ledger. */
374
+ export function emptyLedger() {
375
+ return { version: 1, entries: [] }
376
+ }
377
+
378
+ /**
379
+ * Parse the ledger file tolerantly.
380
+ *
381
+ * A ledger that cannot be read must never be treated as an empty one: the whole
382
+ * job of this file is to remember what was already said in public, and forgetting
383
+ * it re-posts every comment the next time the sweep runs.
384
+ *
385
+ * @param {unknown} raw - file contents.
386
+ * @returns {{ok: boolean, ledger: object, message: string}}
387
+ */
388
+ export function parseLedger(raw) {
389
+ if (raw === null || raw === undefined || (typeof raw === 'string' && raw.trim() === '')) {
390
+ return { ok: true, ledger: emptyLedger(), message: '' }
391
+ }
392
+ let parsed = raw
393
+ if (typeof raw === 'string') {
394
+ try {
395
+ parsed = JSON.parse(raw)
396
+ } catch (error) {
397
+ return { ok: false, ledger: emptyLedger(), message: `not valid JSON: ${error.message}` }
398
+ }
399
+ }
400
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
401
+ return { ok: false, ledger: emptyLedger(), message: 'expected an object with an "entries" array' }
402
+ }
403
+ const entries = parsed.entries.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.repo === 'string')
404
+ return { ok: true, ledger: { version: 1, entries }, message: '' }
405
+ }
406
+
407
+ /**
408
+ * The ledger entry for one repository and tag, if any.
409
+ * @param {object} ledger - parsed ledger.
410
+ * @param {string} repo - repository.
411
+ * @param {string} tag - release tag.
412
+ * @returns {object|null} the entry.
413
+ */
414
+ export function findLedgerEntry(ledger, repo, tag) {
415
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : []
416
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
417
+ const entry = entries[index]
418
+ if (entry.repo === repo && entry.tag === tag) return entry
419
+ }
420
+ return null
421
+ }
422
+
423
+ /**
424
+ * The most recent `baseline` entry for a repository.
425
+ *
426
+ * Binding a video must not fire a comment about the version that was already out
427
+ * when it was bound. That is what the baseline is: a marker written at bind time
428
+ * saying "everything up to here was already public before this video was wired up".
429
+ *
430
+ * @param {object} ledger - parsed ledger.
431
+ * @param {string} repo - repository.
432
+ * @returns {object|null} the newest baseline entry.
433
+ */
434
+ export function latestBaseline(ledger, repo) {
435
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : []
436
+ let newest = null
437
+ for (const entry of entries) {
438
+ if (entry.repo !== repo || entry.state !== 'baseline') continue
439
+ if (newest === null || String(entry.at ?? '') > String(newest.at ?? '')) newest = entry
440
+ }
441
+ return newest
442
+ }
443
+
444
+ /**
445
+ * Append or replace one entry, keeping the file bounded.
446
+ * @param {object} ledger - parsed ledger.
447
+ * @param {object} entry - the entry to store, keyed by `repo` + `tag`.
448
+ * @param {object} [options] - `{maxEntries}`.
449
+ * @returns {object} the next ledger.
450
+ */
451
+ export function recordLedgerEntry(ledger, entry, { maxEntries = 500 } = {}) {
452
+ const entries = (Array.isArray(ledger?.entries) ? ledger.entries : []).filter((candidate) => !(candidate.repo === entry.repo && candidate.tag === entry.tag))
453
+ entries.push(entry)
454
+ return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
455
+ }
456
+
457
+ /**
458
+ * Whether this repository's newest release should be announced right now.
459
+ *
460
+ * The order of the questions is the order of their cost: a missing binding costs
461
+ * nothing to notice, and "already announced" must be decided before anything is
462
+ * composed, because composing is what makes a duplicate look like a fresh job.
463
+ *
464
+ * @param {object} params - `{binding, release, ledger, force}`.
465
+ * @returns {{state: string, message: string, entry: object|null, attempts: number}} the verdict.
466
+ */
467
+ export function announcementVerdict({ binding = null, release = null, ledger = emptyLedger(), force = false } = {}) {
468
+ if (binding === null || typeof binding?.bvid !== 'string' || binding.bvid === '') {
469
+ return { state: 'unbound', message: '这个仓库还没有绑定 B 站视频。', entry: null, attempts: 0 }
470
+ }
471
+ if (release === null || typeof release?.tag !== 'string' || release.tag === '') {
472
+ return { state: 'no-release', message: '还没有已公开的 Release(草稿不算)。', entry: null, attempts: 0 }
473
+ }
474
+ const entry = findLedgerEntry(ledger, binding.repo ?? '', release.tag)
475
+ if (entry !== null && entry.state === 'announced' && force !== true) {
476
+ return { state: 'already', message: `${release.tag} 已经播报过了。`, entry, attempts: 0 }
477
+ }
478
+ const baseline = latestBaseline(ledger, binding.repo ?? '')
479
+ if (baseline !== null && force !== true) {
480
+ const sameTag = baseline.tag === release.tag
481
+ const at = Date.parse(String(baseline.at ?? ''))
482
+ const created = Date.parse(String(release.createdAt ?? ''))
483
+ // A baseline without a tag is a binding that could not read the release list;
484
+ // it then holds back everything created before it was written.
485
+ const older = baseline.tag === null && Number.isFinite(at) && Number.isFinite(created) && created <= at
486
+ if (sameTag || older) {
487
+ return { state: 'baseline', message: `${release.tag} 在绑定视频之前就已经发布了,不算这次更新。`, entry: baseline, attempts: 0 }
488
+ }
489
+ }
490
+ const attempts = Number.isFinite(entry?.attempts) ? Number(entry.attempts) : 0
491
+ if (entry !== null && entry.state === 'failed' && attempts >= 3 && force !== true) {
492
+ return { state: 'gave-up', message: `已经失败 ${attempts} 次,先看看原因再手动重试。`, entry, attempts }
493
+ }
494
+ return { state: 'ready', message: '', entry, attempts }
495
+ }
496
+
497
+ /**
498
+ * Name a reply failure.
499
+ * @param {number} code - Bilibili's `code`.
500
+ * @param {string} message - Bilibili's own sentence.
501
+ * @returns {{kind: string, advice: string, message: string}} the classification.
502
+ */
503
+ export function replyFailure(code, message = '') {
504
+ const known = REPLY_FAILURES.get(Number(code))
505
+ if (known === undefined) {
506
+ return { kind: 'unknown', advice: '没识别出具体原因,看 B 站返回的原话。', message: String(message ?? '') }
507
+ }
508
+ return { ...known, message: String(message ?? '') }
509
+ }
510
+
511
+ /**
512
+ * Read `Set-Cookie` headers off a fetch response.
513
+ *
514
+ * Node exposes `getSetCookie()`; a response from a fake in a test may only have
515
+ * `get('set-cookie')`. Both are read rather than requiring one shape.
516
+ *
517
+ * @param {object} headers - a `Headers`-like object.
518
+ * @returns {string[]} the raw header values.
519
+ */
520
+ export function readSetCookie(headers) {
521
+ if (headers === null || headers === undefined) return []
522
+ if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
523
+ if (typeof headers.get === 'function') {
524
+ const single = headers.get('set-cookie')
525
+ return typeof single === 'string' && single !== '' ? [single] : []
526
+ }
527
+ return []
528
+ }
529
+
530
+ /**
531
+ * Build a jar out of `Set-Cookie` header values, which is how the QR sign-in
532
+ * hands over the credential.
533
+ * @param {string[]} values - raw header values.
534
+ * @returns {object} name to value.
535
+ */
536
+ export function cookiesFromSetCookie(values) {
537
+ const cookies = {}
538
+ for (const value of Array.isArray(values) ? values : []) {
539
+ const first = String(value).split(';')[0]
540
+ const index = first.indexOf('=')
541
+ if (index <= 0) continue
542
+ const name = first.slice(0, index).trim()
543
+ const raw = first.slice(index + 1).trim()
544
+ if (name === '' || raw === '') continue
545
+ cookies[name] = raw
546
+ }
547
+ return cookies
548
+ }
549
+
550
+ /**
551
+ * Add the device cookies a browser would send.
552
+ *
553
+ * `buvid3` and `buvid4` are what make a request look like it came from a browser
554
+ * rather than from a script, and their absence is one of the things that earns a
555
+ * `-412`. Both are fetched together and both belong on the request — sending only
556
+ * the first was throwing half of the answer away.
557
+ *
558
+ * A credential's own value always wins: an account that already carries a device id
559
+ * has one for a reason, and replacing it would be inventing a device.
560
+ *
561
+ * This lives here rather than in the route because the route cannot be tested end to
562
+ * end: reaching a real post needs a release list, and the suite deliberately gives
563
+ * the Host a `gh` that cannot exist. A pure function is the only way this decision
564
+ * gets any coverage at all.
565
+ *
566
+ * @param {object} cookies - the credential's cookies.
567
+ * @param {object} fingerprint - `{buvid3, buvid4}` from `fingerPrint()`.
568
+ * @returns {object} a new object; the input is not mutated.
569
+ */
570
+ export function withDeviceIds(cookies, fingerprint) {
571
+ const next = { ...(cookies === null || typeof cookies !== 'object' ? {} : cookies) }
572
+ const found = fingerprint === null || typeof fingerprint !== 'object' ? {} : fingerprint
573
+ for (const name of ['buvid3', 'buvid4']) {
574
+ const value = typeof found[name] === 'string' ? found[name].trim() : ''
575
+ if (value !== '' && next[name] === undefined) next[name] = value
576
+ }
577
+ return next
578
+ }
579
+
580
+ /**
581
+ * The cookies Bilibili puts in the poll's success URL.
582
+ *
583
+ * The web sign-in hands the same credential over twice: as `Set-Cookie` headers,
584
+ * and as the query string of `data.url` — the cross-domain bounce the browser is
585
+ * meant to follow. Only one of them has to arrive, and which one does is not
586
+ * something this plugin controls: a proxy, a client that does not surface headers,
587
+ * or a change on Bilibili's side can drop either. Reading both is the difference
588
+ * between a completed sign-in and "B 站回了成功,但没有下发 Cookie" — a message that
589
+ * is honest and useless, because the credential was in the other half of the
590
+ * response all along.
591
+ *
592
+ * `gourl` and `Expires` travel in that query string and are not cookies.
593
+ *
594
+ * @param {unknown} raw - `data.url` from the poll.
595
+ * @returns {object} name to value, empty when there is nothing usable.
596
+ */
597
+ export function cookiesFromLoginUrl(raw) {
598
+ const value = typeof raw === 'string' ? raw.trim() : ''
599
+ if (value === '') return {}
600
+ let parsed
601
+ try {
602
+ parsed = new URL(value)
603
+ } catch {
604
+ return {}
605
+ }
606
+ const cookies = {}
607
+ for (const [name, parameter] of parsed.searchParams) {
608
+ if (name === 'gourl' || name === 'Expires') continue
609
+ if (name === '' || parameter === '') continue
610
+ cookies[name] = parameter
611
+ }
612
+ return cookies
613
+ }
614
+
615
+ 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'
616
+
617
+ /**
618
+ * The Bilibili transport.
619
+ *
620
+ * Every call returns a plain result object instead of throwing: the panel's job
621
+ * is to print what Bilibili said, and an exception carries no `code` to print.
622
+ *
623
+ * @param {object} [options] - `{fetchImpl, timeoutMs, userAgent}`.
624
+ * @returns {object} the client.
625
+ */
626
+ export function createBilibiliClient({ fetchImpl = globalThis.fetch, timeoutMs = 15_000, userAgent = DEFAULT_UA } = {}) {
627
+ /** One GET, with the headers a browser would send. */
628
+ const get = async (url, { referer = 'https://www.bilibili.com/', cookie = '' } = {}) => {
629
+ try {
630
+ const requestHeaders = { 'user-agent': userAgent, referer, origin: 'https://www.bilibili.com' }
631
+ if (cookie !== '') requestHeaders.cookie = cookie
632
+ const response = await fetchImpl(url, {
633
+ method: 'GET',
634
+ headers: requestHeaders,
635
+ signal: AbortSignal.timeout(timeoutMs),
636
+ })
637
+ const headers = response.headers ?? null
638
+ let payload = null
639
+ try {
640
+ payload = await response.json()
641
+ } catch {
642
+ payload = null
643
+ }
644
+ return { status: response.status, payload, headers }
645
+ } catch (error) {
646
+ return { status: 0, payload: null, headers: null, error: String(error?.message ?? error) }
647
+ }
648
+ }
649
+
650
+ return {
651
+ /**
652
+ * Who the credential belongs to — the only proof that it is a web session.
653
+ * @param {string} cookie - the `Cookie:` header value.
654
+ * @returns {Promise<object>} `{ok, mid, uname, code, message}`.
655
+ */
656
+ async readAccount(cookie) {
657
+ const result = await get('https://api.bilibili.com/x/member/web/account', { cookie })
658
+ if (result.payload === null) {
659
+ return { ok: false, mid: '', uname: '', code: null, message: result.error ?? `HTTP ${String(result.status)}` }
660
+ }
661
+ const payload = result.payload
662
+ if (payload.code !== 0) {
663
+ return { ok: false, mid: '', uname: '', code: Number(payload.code), message: String(payload.message ?? '') }
664
+ }
665
+ return {
666
+ ok: true,
667
+ mid: String(payload.data?.mid ?? ''),
668
+ uname: String(payload.data?.uname ?? ''),
669
+ code: 0,
670
+ message: '',
671
+ }
672
+ },
673
+
674
+ /**
675
+ * Resolve a video to the `aid` a comment is posted against.
676
+ * @param {string} cookie - the `Cookie:` header value.
677
+ * @param {string} bvid - the video.
678
+ * @returns {Promise<object>} `{ok, aid, title, owner, code, message}`.
679
+ */
680
+ async resolveVideo(cookie, bvid) {
681
+ const result = await get(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, {
682
+ cookie,
683
+ referer: `https://www.bilibili.com/video/${bvid}/`,
684
+ })
685
+ if (result.payload === null) {
686
+ return { ok: false, aid: null, title: '', owner: '', code: null, message: result.error ?? `HTTP ${String(result.status)}` }
687
+ }
688
+ const payload = result.payload
689
+ if (payload.code !== 0) {
690
+ return { ok: false, aid: null, title: '', owner: '', code: Number(payload.code), message: String(payload.message ?? '') }
691
+ }
692
+ return {
693
+ ok: true,
694
+ aid: Number(payload.data?.aid) || null,
695
+ title: String(payload.data?.title ?? ''),
696
+ owner: String(payload.data?.owner?.name ?? ''),
697
+ code: 0,
698
+ message: '',
699
+ }
700
+ },
701
+
702
+ /**
703
+ * Post one comment.
704
+ *
705
+ * `csrf` travels in both the query and the body, which is what the endpoint
706
+ * actually validates; sending it once is the documented shape and the shape
707
+ * that has been observed to fail.
708
+ *
709
+ * @param {object} params - `{cookie, csrf, aid, bvid, message}`.
710
+ * @returns {Promise<object>} `{ok, rpid, code, message, failure}`.
711
+ */
712
+ async postComment({ cookie, csrf, aid, bvid, message }) {
713
+ const body = new URLSearchParams({
714
+ oid: String(aid),
715
+ type: String(REPLY_TYPE_VIDEO),
716
+ message,
717
+ plat: '1',
718
+ csrf,
719
+ })
720
+ let result
721
+ try {
722
+ const response = await fetchImpl(`https://api.bilibili.com/x/v2/reply/add?csrf=${encodeURIComponent(csrf)}`, {
723
+ method: 'POST',
724
+ headers: {
725
+ 'user-agent': userAgent,
726
+ referer: `https://www.bilibili.com/video/${bvid}/`,
727
+ origin: 'https://www.bilibili.com',
728
+ 'content-type': 'application/x-www-form-urlencoded',
729
+ cookie,
730
+ },
731
+ body: body.toString(),
732
+ signal: AbortSignal.timeout(timeoutMs),
733
+ })
734
+ result = { status: response.status, payload: await response.json() }
735
+ } catch (error) {
736
+ return { ok: false, rpid: null, code: null, message: String(error?.message ?? error), failure: replyFailure(null, String(error?.message ?? error)) }
737
+ }
738
+ const payload = result.payload ?? {}
739
+ if (payload.code !== 0) {
740
+ return {
741
+ ok: false,
742
+ rpid: null,
743
+ code: Number(payload.code),
744
+ message: String(payload.message ?? ''),
745
+ failure: replyFailure(payload.code, payload.message),
746
+ }
747
+ }
748
+ const rpid = payload.data?.rpid ?? payload.data?.rpid_str ?? null
749
+ return {
750
+ ok: true,
751
+ rpid: rpid === null ? null : String(rpid),
752
+ code: 0,
753
+ message: '',
754
+ failure: null,
755
+ }
756
+ },
757
+
758
+ /**
759
+ * A device id, best effort.
760
+ *
761
+ * `buvid3` is what makes a request look like it came from a browser rather
762
+ * than from a script, and its absence is one of the things that earns a `-412`.
763
+ * An answer is not required: when this call fails the cookie is used as is.
764
+ * @returns {Promise<object>} `{buvid3, buvid4}`.
765
+ */
766
+ async fingerPrint() {
767
+ const result = await get('https://api.bilibili.com/x/frontend/finger/spi')
768
+ return { buvid3: String(result.payload?.data?.b_3 ?? ''), buvid4: String(result.payload?.data?.b_4 ?? '') }
769
+ },
770
+
771
+ /**
772
+ * Start the web QR sign-in: one URL and one key to poll.
773
+ * @returns {Promise<object>} `{ok, url, key, message}`.
774
+ */
775
+ async startQrLogin() {
776
+ const result = await get('https://passport.bilibili.com/x/passport-login/web/qrcode/generate')
777
+ const payload = result.payload
778
+ if (payload === null || payload.code !== 0) {
779
+ return { ok: false, url: '', key: '', message: payload === null ? (result.error ?? `HTTP ${String(result.status)}`) : String(payload.message ?? '') }
780
+ }
781
+ return { ok: true, url: String(payload.data?.url ?? ''), key: String(payload.data?.qrcode_key ?? ''), message: '' }
782
+ },
783
+
784
+ /**
785
+ * Ask once whether the QR code has been scanned and confirmed.
786
+ *
787
+ * The three states are distinct because they need different words on screen:
788
+ * `86101` is "still waiting", `86090` is "your phone is asking you to confirm",
789
+ * and `86038` is "this code is dead, start again".
790
+ *
791
+ * @param {string} key - the `qrcode_key` from `startQrLogin`.
792
+ * @returns {Promise<object>} `{ok, state, cookies, message}`.
793
+ */
794
+ async pollQrLogin(key) {
795
+ const result = await get(`https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${encodeURIComponent(key)}&source=main-fe-header`)
796
+ const payload = result.payload
797
+ if (payload === null) return { ok: false, state: 'failed', cookies: {}, message: result.error ?? `HTTP ${String(result.status)}` }
798
+ if (payload.code !== 0) return { ok: false, state: 'failed', cookies: {}, message: String(payload.message ?? '') }
799
+ const inner = Number(payload.data?.code)
800
+ if (inner === 0) {
801
+ /*
802
+ * Both halves of the answer, header first: a partial `Set-Cookie` is filled
803
+ * in from the URL rather than reported as a sign-in that stored nothing.
804
+ */
805
+ const cookies = {
806
+ ...cookiesFromLoginUrl(payload.data?.url),
807
+ ...cookiesFromSetCookie(readSetCookie(result.headers)),
808
+ }
809
+ if (Object.keys(cookies).length === 0) {
810
+ // Success with no credential is a contradiction worth naming: without
811
+ // this the panel would report a login that stored nothing.
812
+ return { ok: false, state: 'failed', cookies: {}, message: 'B 站回了成功,但既没有下发 Cookie,返回的链接里也没有凭据;再登录一次。' }
813
+ }
814
+ return { ok: true, state: 'succeeded', cookies, message: '' }
815
+ }
816
+ if (inner === 86090) return { ok: true, state: 'scanned', cookies: {}, message: '' }
817
+ if (inner === 86038) return { ok: true, state: 'expired', cookies: {}, message: '' }
818
+ if (inner === 86101) return { ok: true, state: 'waiting', cookies: {}, message: '' }
819
+ return { ok: false, state: 'failed', cookies: {}, message: String(payload.data?.message ?? payload.message ?? '') }
820
+ },
821
+ }
822
+ }