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.
- package/LICENSE +21 -0
- package/README.md +402 -2
- package/RELEASING.md +63 -0
- package/client.js +4417 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4938 -0
- package/lib/bilibili.mjs +1000 -0
- package/lib/config-store.mjs +159 -0
- package/package.json +62 -3
- package/scripts/configure.mjs +213 -0
- package/scripts/push-via-api.ps1 +111 -0
- package/scripts/verify-bundle.mjs +320 -0
- package/tests/bilibili-check.mjs +450 -0
- package/tests/bump-e2e.mjs +431 -0
- package/tests/client-render.mjs +1483 -0
- package/tests/host-checks.mjs +576 -0
- package/tests/mount-check.mjs +300 -0
package/lib/bilibili.mjs
ADDED
|
@@ -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
|
+
}
|