dsh-hackertube 0.1.5 → 0.2.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.
- package/README.en.md +93 -3
- package/README.md +93 -3
- package/client.js +3635 -2568
- package/index.js +4126 -3215
- package/mirror.js +768 -0
- package/package.json +7 -2
- package/recommend.js +431 -0
package/mirror.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 备用片源解析(Mirror Source)。
|
|
3
|
+
*
|
|
4
|
+
* 给定一条主站视频的标识、标题与时长,判断镜像站上是否存在同一支片子,
|
|
5
|
+
* 并在存在多个版本时挑出最好的那个。判定与选取是纯函数,可单独测试;
|
|
6
|
+
* 网络与缓存由调用方注入缓存目录,方便用 fixture 跑。
|
|
7
|
+
*
|
|
8
|
+
* 设计口径:宁可漏,不可错。判定用合取,一个无关视频不能靠时长巧合混进来;
|
|
9
|
+
* 档位只决定同源多版本时选哪个,不决定显不显示。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* ==========================================================================
|
|
13
|
+
一、文本工具
|
|
14
|
+
========================================================================== */
|
|
15
|
+
|
|
16
|
+
const PUNCT = /[\s\u3000!-/:-@[-`{-~。,、;:?!""''()【】《》〈〉—…·||+*#&@]+/g
|
|
17
|
+
|
|
18
|
+
export function normalize(s) {
|
|
19
|
+
return String(s || '')
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/'/g, "'")
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(PUNCT, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CJK = /[\u4e00-\u9fff]/
|
|
30
|
+
|
|
31
|
+
export function hasCJK(s) {
|
|
32
|
+
return CJK.test(String(s || ''))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 最长公共子串。返回长度与实际子串——子串内容要参与判定,
|
|
37
|
+
* 纯英文品牌名巧合(Harley Davidson)必须能被识别出来。
|
|
38
|
+
*/
|
|
39
|
+
export function commonRun(a, b) {
|
|
40
|
+
const A = normalize(a)
|
|
41
|
+
const B = normalize(b)
|
|
42
|
+
if (!A.length || !B.length) return { len: 0, sub: '' }
|
|
43
|
+
const m = B.length
|
|
44
|
+
let prev = new Int32Array(m + 1)
|
|
45
|
+
let cur = new Int32Array(m + 1)
|
|
46
|
+
let best = 0
|
|
47
|
+
let endInA = 0
|
|
48
|
+
for (let i = 1; i <= A.length; i += 1) {
|
|
49
|
+
const ai = A.charCodeAt(i - 1)
|
|
50
|
+
for (let j = 1; j <= m; j += 1) {
|
|
51
|
+
if (ai === B.charCodeAt(j - 1)) {
|
|
52
|
+
const v = prev[j - 1] + 1
|
|
53
|
+
cur[j] = v
|
|
54
|
+
if (v > best) {
|
|
55
|
+
best = v
|
|
56
|
+
endInA = i
|
|
57
|
+
}
|
|
58
|
+
} else cur[j] = 0
|
|
59
|
+
}
|
|
60
|
+
const t = prev
|
|
61
|
+
prev = cur
|
|
62
|
+
cur = t
|
|
63
|
+
cur.fill(0)
|
|
64
|
+
}
|
|
65
|
+
return { len: best, sub: A.slice(endInA - best, endInA) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 中文用字符 bigram 重合度:比子串匹配更抗意译改写。 */
|
|
69
|
+
export function bigramScore(a, b) {
|
|
70
|
+
const grams = (s) => {
|
|
71
|
+
const t = normalize(s).replace(/[^\u4e00-\u9fff]/g, '')
|
|
72
|
+
const out = new Set()
|
|
73
|
+
for (let i = 0; i < t.length - 1; i += 1) out.add(t.slice(i, i + 2))
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
const A = grams(a)
|
|
77
|
+
const B = grams(b)
|
|
78
|
+
if (!A.size || !B.size) return 0
|
|
79
|
+
let inter = 0
|
|
80
|
+
for (const g of A) if (B.has(g)) inter += 1
|
|
81
|
+
return inter / Math.min(A.size, B.size)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseDuration(s) {
|
|
85
|
+
if (typeof s === 'number') return s
|
|
86
|
+
if (!s) return null
|
|
87
|
+
const p = String(s).split(':').map(Number)
|
|
88
|
+
if (p.some(Number.isNaN)) return null
|
|
89
|
+
return p.reduce((x, y) => x * 60 + y, 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ==========================================================================
|
|
93
|
+
二、类型标记
|
|
94
|
+
========================================================================== */
|
|
95
|
+
|
|
96
|
+
const SUBTITLE_MARKS = [
|
|
97
|
+
'熟肉', '中字', '中文字幕', '中英字幕', '中英双字', '双语', '官方双语',
|
|
98
|
+
'中文配音', '中配', '字幕版', '全熟', '精校字幕', 'AI字幕', '汉化',
|
|
99
|
+
]
|
|
100
|
+
const RAW_MARKS = ['生肉', '无字幕', '英文原版', '无中字']
|
|
101
|
+
|
|
102
|
+
/** 标题是否声明了本地化字幕 */
|
|
103
|
+
export function hasSubtitles(title) {
|
|
104
|
+
const t = String(title || '')
|
|
105
|
+
if (RAW_MARKS.some((k) => t.includes(k))) return false
|
|
106
|
+
return SUBTITLE_MARKS.some((k) => t.toLowerCase().includes(k.toLowerCase()))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 二次创作:借用了原片素材但做了新内容。
|
|
111
|
+
* 「万字解析」不列入——搬运号常拿它当自家噱头,实测会误伤真熟肉。
|
|
112
|
+
*/
|
|
113
|
+
const DERIVED_MARKS = [
|
|
114
|
+
'改编', '解说', '讲解', '点评', '杂谈', '解读', '吐槽', '速看',
|
|
115
|
+
'一口气', '看完', '分钟看懂', '深度拆解', '带你', '聊一聊',
|
|
116
|
+
'reaction', 'react to', '观后感', '影评',
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
export function isDerived(title) {
|
|
120
|
+
const t = String(title || '').toLowerCase()
|
|
121
|
+
return DERIVED_MARKS.some((k) => t.includes(k.toLowerCase()))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 频道名主词:`Kurzgesagt – In a Nutshell` → `kurzgesagt` */
|
|
125
|
+
function primaryName(s) {
|
|
126
|
+
const raw = String(s || '')
|
|
127
|
+
const first = raw.split(/\s+[–—|·:]\s+|\s*\|\s*/)[0] || raw
|
|
128
|
+
return normalize(first) || normalize(raw)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 官方入驻号:以名字为准,不以投稿类型为准。
|
|
133
|
+
* 实测同一频道有的标自制(copyright=1)、有的标转载,用投稿类型卡会漏一半。
|
|
134
|
+
*/
|
|
135
|
+
export function isOfficialAccount(remote, channel) {
|
|
136
|
+
if (!remote) return false
|
|
137
|
+
const up = normalize(remote.owner?.name)
|
|
138
|
+
const ch = normalize(channel)
|
|
139
|
+
if (!up || !ch) return false
|
|
140
|
+
const upMain = primaryName(remote.owner?.name)
|
|
141
|
+
const chMain = primaryName(channel)
|
|
142
|
+
|
|
143
|
+
if (up === ch) return true
|
|
144
|
+
if (upMain && chMain && upMain === chMain) return true
|
|
145
|
+
if (chMain.length >= 4 && up.startsWith(chMain) && up.length - chMain.length <= 6) return true
|
|
146
|
+
if (upMain.length >= 4 && ch.startsWith(upMain) && ch.length - upMain.length <= 6) return true
|
|
147
|
+
if (up.length >= 4 && ch.length >= 4 && (up.includes(ch) || ch.includes(up))) return true
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ==========================================================================
|
|
152
|
+
三、判定
|
|
153
|
+
========================================================================== */
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 收集证据。
|
|
157
|
+
* E1/E2 是外部凭据,直接 CONFIRMED;E3~E5 是内容相似,只给 STRONG。
|
|
158
|
+
* 弱信号权重归零,仅用于排查,不参与判定。
|
|
159
|
+
*/
|
|
160
|
+
export function collectEvidence(remote, local, opts = {}) {
|
|
161
|
+
const hits = []
|
|
162
|
+
const desc = String(remote.desc || '')
|
|
163
|
+
.replace(/&/g, '&')
|
|
164
|
+
.replace(/'/g, "'")
|
|
165
|
+
.replace(/"/g, '"')
|
|
166
|
+
const title = String(remote.title || '')
|
|
167
|
+
const vid = String(local.id || '')
|
|
168
|
+
|
|
169
|
+
/* 反证机制:简介里出现的视频 ID 是双向证据。
|
|
170
|
+
出现本条 → 就是这支;只出现别的 → 是同一作者的另一支,直接否决。
|
|
171
|
+
实测踩过:某频道《这不是邦联国旗?》的简介写着另一支视频的 ID,
|
|
172
|
+
被同频道一条时长巧合的片子顶上来。 */
|
|
173
|
+
const ids = [...desc.matchAll(/(?:youtu\.be\/|[?&]v=)([A-Za-z0-9_-]{11})/g)].map((m) => m[1])
|
|
174
|
+
const uniq = [...new Set(ids)]
|
|
175
|
+
const hasThisId = uniq.includes(vid)
|
|
176
|
+
const hasOtherOnly = uniq.length > 0 && !hasThisId
|
|
177
|
+
|
|
178
|
+
if (hasThisId) hits.push({ code: 'E1', weight: 3, why: '简介含本条源视频标识' })
|
|
179
|
+
else if (hasOtherOnly) hits.push({ code: 'NEG', weight: 0, why: `简介指向别的视频 ${uniq.slice(0, 2).join(',')}` })
|
|
180
|
+
|
|
181
|
+
const declares = /转载|搬运|原作者|原视频|原链接|来源[::]|source[::]|credit|转自/i.test(desc)
|
|
182
|
+
const channelHit = String(local.channel || '').length > 2
|
|
183
|
+
&& desc.toLowerCase().includes(String(local.channel).toLowerCase())
|
|
184
|
+
if (declares && channelHit && !hasOtherOnly) {
|
|
185
|
+
hits.push({ code: 'E2', weight: 3, why: '简介声明转载且提及原频道' })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* 原标题被保留。除覆盖率外还要求绝对长度:短标题六个字符就能刷到 0.6。 */
|
|
189
|
+
const en = commonRun(local.title, title)
|
|
190
|
+
const enRatio = en.len / Math.max(1, Math.min(normalize(local.title).length, normalize(title).length))
|
|
191
|
+
if (enRatio >= 0.6 && en.len >= 12 && (en.len >= 20 || enRatio >= 0.8)) {
|
|
192
|
+
hits.push({ code: 'E3', weight: 2.5, why: `原标题 LCS ${en.len}「${en.sub.slice(0, 30)}」` })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* 译名吻合。重合片段必须含中文,否则品牌名会对上无关视频。 */
|
|
196
|
+
if (local.titleZh) {
|
|
197
|
+
const zh = commonRun(local.titleZh, title)
|
|
198
|
+
const zhRatio = zh.len / Math.max(1, Math.min(normalize(local.titleZh).length, normalize(title).length))
|
|
199
|
+
const bigram = bigramScore(local.titleZh, title)
|
|
200
|
+
if ((bigram >= 0.5 || zhRatio >= 0.5) && zh.len >= 6 && (hasCJK(zh.sub) || zh.len >= 20)) {
|
|
201
|
+
hits.push({ code: 'E4', weight: 2, why: `译名 bigram ${bigram.toFixed(2)} / LCS ${zh.len}「${zh.sub.slice(0, 22)}」` })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (opts.pageHit) {
|
|
206
|
+
hits.push({ code: 'E5', weight: 2.5, why: `分P ${opts.pageHit.index} 时长命中` })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return hits
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 主判定。
|
|
214
|
+
* @returns {{ ok: boolean, level: string, reason: string, hits: Array, gate: object }}
|
|
215
|
+
* level: CONFIRMED | STRONG | DERIVED | CONTRADICTED | REJECT
|
|
216
|
+
*/
|
|
217
|
+
export function match(remote, local, opts = {}) {
|
|
218
|
+
const gate = {}
|
|
219
|
+
const localDur = local.duration
|
|
220
|
+
/* 详情接口给的是 duration,搜索结果是 dur——两边都得认 */
|
|
221
|
+
const remoteDur = opts.pageHit
|
|
222
|
+
? opts.pageHit.duration
|
|
223
|
+
: parseDuration(remote.dur ?? remote.duration)
|
|
224
|
+
|
|
225
|
+
gate.delta = remoteDur == null || !localDur ? null : Math.abs(remoteDur - localDur)
|
|
226
|
+
gate.durationOk = gate.delta != null && gate.delta <= 2
|
|
227
|
+
|
|
228
|
+
const remoteAt = Number(remote.pubdate || 0)
|
|
229
|
+
const localAt = Date.parse(local.published || '') / 1000
|
|
230
|
+
gate.timeOk = !remoteAt || !localAt || remoteAt >= localAt - 30 * 86400
|
|
231
|
+
|
|
232
|
+
gate.valid = !!remoteDur && remoteDur > 10
|
|
233
|
+
|
|
234
|
+
if (!gate.durationOk || !gate.timeOk || !gate.valid) {
|
|
235
|
+
const why = []
|
|
236
|
+
if (!gate.durationOk) why.push(`时长不符 Δ${gate.delta ?? '?'}s`)
|
|
237
|
+
if (!gate.timeOk) why.push('早于原片')
|
|
238
|
+
if (!gate.valid) why.push('时长无效')
|
|
239
|
+
return { ok: false, level: 'REJECT', reason: why.join(' / '), hits: [], gate }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const hits = collectEvidence(remote, local, opts)
|
|
243
|
+
const strong = hits.filter((h) => h.weight >= 2)
|
|
244
|
+
const credential = hits.some((h) => h.code === 'E1' || h.code === 'E2')
|
|
245
|
+
|
|
246
|
+
const negative = hits.find((h) => h.code === 'NEG')
|
|
247
|
+
if (negative) return { ok: false, level: 'CONTRADICTED', reason: negative.why, hits, gate }
|
|
248
|
+
|
|
249
|
+
if (!strong.length) {
|
|
250
|
+
return { ok: false, level: 'REJECT', reason: '证据不足', hits, gate }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* 二创过滤只在没有外部凭据时生效:简介里有本条标识就说明是原片本身。 */
|
|
254
|
+
if (!credential && isDerived(remote.title)) {
|
|
255
|
+
return { ok: false, level: 'DERIVED', reason: '二创', hits, gate }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (credential) {
|
|
259
|
+
const why = hits.filter((h) => h.code === 'E1' || h.code === 'E2').map((h) => h.why).join(';')
|
|
260
|
+
return { ok: true, level: 'CONFIRMED', reason: why, hits, gate }
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, level: 'STRONG', reason: strong.map((h) => h.why).join(';'), hits, gate }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ==========================================================================
|
|
266
|
+
四、选取
|
|
267
|
+
========================================================================== */
|
|
268
|
+
|
|
269
|
+
export const RANK = { OFFICIAL: 0, LOCALIZED: 1, RAW: 2 }
|
|
270
|
+
|
|
271
|
+
const RANK_LABEL = { 0: '官方', 1: '中字', 2: '转载' }
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 同源多版本时挑一个。
|
|
275
|
+
* 官方号是独立优先级层,不参与打分;其余按本地化程度分档,
|
|
276
|
+
* 同档内显式字幕 > 播放量。转载不会因为播放量低被丢弃——
|
|
277
|
+
* 有的频道根本没有官方入驻,高质量转载就是唯一选项。
|
|
278
|
+
*/
|
|
279
|
+
export function pickBest(candidates, channel) {
|
|
280
|
+
if (!Array.isArray(candidates) || !candidates.length) return null
|
|
281
|
+
|
|
282
|
+
const scored = candidates.map((c) => {
|
|
283
|
+
const play = Number(c.remote?.stat?.view ?? c.play ?? 0)
|
|
284
|
+
const title = String(c.remote?.title || c.title || '')
|
|
285
|
+
const subtitles = hasSubtitles(title)
|
|
286
|
+
const chineseTitle = hasCJK(title)
|
|
287
|
+
const popular = play >= 10000
|
|
288
|
+
const official = isOfficialAccount(c.remote, channel)
|
|
289
|
+
const rank = official ? RANK.OFFICIAL
|
|
290
|
+
: (subtitles || chineseTitle || popular) ? RANK.LOCALIZED
|
|
291
|
+
: RANK.RAW
|
|
292
|
+
return { ...c, play, subtitles, chineseTitle, official, rank }
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
scored.sort((a, b) => {
|
|
296
|
+
if (a.rank !== b.rank) return a.rank - b.rank
|
|
297
|
+
if (a.subtitles !== b.subtitles) return a.subtitles ? -1 : 1
|
|
298
|
+
return b.play - a.play
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const best = scored[0]
|
|
302
|
+
return {
|
|
303
|
+
...best,
|
|
304
|
+
label: best.rank === RANK.OFFICIAL ? '官方' : best.subtitles ? '中字' : best.chineseTitle ? '中文' : RANK_LABEL[2],
|
|
305
|
+
alternates: scored.slice(1),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ==========================================================================
|
|
310
|
+
五、访问层
|
|
311
|
+
========================================================================== */
|
|
312
|
+
|
|
313
|
+
const CLIENT_IDS = [
|
|
314
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
315
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
316
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
317
|
+
]
|
|
318
|
+
const LANGUAGES = ['zh-CN,zh;q=0.9', 'zh-CN,zh;q=0.9,en;q=0.8', 'zh-CN,zh;q=0.9,en-US;q=0.7']
|
|
319
|
+
|
|
320
|
+
const mixinTable = [
|
|
321
|
+
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28,
|
|
322
|
+
14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54,
|
|
323
|
+
21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
function pick(list) {
|
|
327
|
+
return list[Math.floor(Math.random() * list.length)]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function mixin(name) {
|
|
331
|
+
return mixinTable.map((n) => name[n]).join('').slice(0, 32)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function signQuery(params, mixinKey, hashFn) {
|
|
335
|
+
const all = { ...params, wts: Math.round(Date.now() / 1000) }
|
|
336
|
+
const q = Object.keys(all)
|
|
337
|
+
.sort()
|
|
338
|
+
.map((k) => {
|
|
339
|
+
const v = String(all[k]).replace(/[!'()*]/g, '')
|
|
340
|
+
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`
|
|
341
|
+
})
|
|
342
|
+
.join('&')
|
|
343
|
+
return `${q}&w_rid=${hashFn(q + mixinKey)}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* 设备指纹哑值。注意不要带 dm_img_inter——实测带上必被判风控。 */
|
|
347
|
+
const DEVICE_HINTS = {
|
|
348
|
+
dm_img_list: '[]',
|
|
349
|
+
dm_img_str: 'V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ',
|
|
350
|
+
dm_cover_img_str:
|
|
351
|
+
'QU5HTEUgKEludGVsLCBJbnRlbChSKSBVSEQgR3JhcGhpY3MgNjMwICgweDAwMDAzRTlCKSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzApLCBvciBzaW1pbGFy',
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 会话:一套匿名标识 + 一份签名口令。
|
|
356
|
+
* 标题里不写站点名,调用方只需要知道这是「备用源」。
|
|
357
|
+
*/
|
|
358
|
+
export class Session {
|
|
359
|
+
constructor({ fetchImpl, hashFn, base, timeoutMs = 8000 }) {
|
|
360
|
+
this.fetch = fetchImpl
|
|
361
|
+
this.hash = hashFn
|
|
362
|
+
this.base = base
|
|
363
|
+
this.timeout = timeoutMs
|
|
364
|
+
this.cookie = ''
|
|
365
|
+
this.mixinKey = ''
|
|
366
|
+
this.used = 0
|
|
367
|
+
this.limit = 90
|
|
368
|
+
this.ready = null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async ensure() {
|
|
372
|
+
if (!this.ready) this.ready = this.#handshake().catch((e) => { this.ready = null; throw e })
|
|
373
|
+
return this.ready
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async #handshake() {
|
|
377
|
+
const ua = pick(CLIENT_IDS)
|
|
378
|
+
const res = await this.fetch(`${this.base}/x/frontend/finger/spi`, {
|
|
379
|
+
headers: { 'User-Agent': ua, Referer: `${this.base.replace('api.', 'www.')}/` },
|
|
380
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
381
|
+
})
|
|
382
|
+
const spi = await res.json()
|
|
383
|
+
const d = spi?.data || {}
|
|
384
|
+
this.cookie = [
|
|
385
|
+
`buvid3=${d.b_3}`,
|
|
386
|
+
`buvid4=${d.b_4}`,
|
|
387
|
+
`b_nut=${Math.round(Date.now() / 1000)}`,
|
|
388
|
+
].join('; ')
|
|
389
|
+
|
|
390
|
+
const nav = await this.fetch(`${this.base}/x/web-interface/nav`, {
|
|
391
|
+
headers: { 'User-Agent': ua, Referer: `${this.base.replace('api.', 'www.')}/`, Cookie: this.cookie },
|
|
392
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
393
|
+
})
|
|
394
|
+
const navJson = await nav.json()
|
|
395
|
+
const img = navJson?.data?.wbi_img?.img_url?.split('/').pop()?.split('.')[0] || ''
|
|
396
|
+
const sub = navJson?.data?.wbi_img?.sub_url?.split('/').pop()?.split('.')[0] || ''
|
|
397
|
+
if (!img || !sub) throw new Error('handshake failed')
|
|
398
|
+
this.mixinKey = mixin(img + sub)
|
|
399
|
+
this.used = 0
|
|
400
|
+
return this
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async rotate() {
|
|
404
|
+
this.ready = null
|
|
405
|
+
this.cookie = ''
|
|
406
|
+
this.mixinKey = ''
|
|
407
|
+
return this.ensure()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
headers(referer) {
|
|
411
|
+
const h = {
|
|
412
|
+
'User-Agent': pick(CLIENT_IDS),
|
|
413
|
+
'Accept': 'application/json, text/plain, */*',
|
|
414
|
+
'Accept-Language': pick(LANGUAGES),
|
|
415
|
+
Cookie: this.cookie,
|
|
416
|
+
}
|
|
417
|
+
if (referer) {
|
|
418
|
+
h.Referer = referer
|
|
419
|
+
try { h.Origin = new URL(referer).origin } catch { /* 非法 URL 就不带 Origin */ }
|
|
420
|
+
}
|
|
421
|
+
return h
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ==========================================================================
|
|
426
|
+
六、解析器
|
|
427
|
+
========================================================================== */
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {object} opts
|
|
431
|
+
* fetchImpl 注入的 fetch(Node 18+ 默认即可)
|
|
432
|
+
* hashFn md5 文本 → 十六进制
|
|
433
|
+
* cacheFile 缓存落盘路径;为 null 时不落盘,只保留内存
|
|
434
|
+
* readFile/writeFile/mkdir 注入的文件操作,测试时可用 stub
|
|
435
|
+
*/
|
|
436
|
+
export function createResolver(opts = {}) {
|
|
437
|
+
const {
|
|
438
|
+
fetchImpl = globalThis.fetch,
|
|
439
|
+
hashFn,
|
|
440
|
+
cacheFile = null,
|
|
441
|
+
readFile = null,
|
|
442
|
+
writeFile = null,
|
|
443
|
+
mkdir = null,
|
|
444
|
+
now = () => Date.now(),
|
|
445
|
+
base = 'https://api.bilibili.com',
|
|
446
|
+
timeoutMs = 8000,
|
|
447
|
+
overallMs = 20000,
|
|
448
|
+
ttlFound = 180 * 86400e3,
|
|
449
|
+
ttlMiss = 7 * 86400e3,
|
|
450
|
+
} = opts
|
|
451
|
+
|
|
452
|
+
if (!hashFn) throw new Error('createResolver: hashFn is required')
|
|
453
|
+
|
|
454
|
+
const session = new Session({ fetchImpl, hashFn, base, timeoutMs })
|
|
455
|
+
const memory = new Map()
|
|
456
|
+
const inflight = new Map()
|
|
457
|
+
let loaded = null
|
|
458
|
+
let dirty = false
|
|
459
|
+
|
|
460
|
+
/* ---------- 缓存 ---------- */
|
|
461
|
+
|
|
462
|
+
async function load() {
|
|
463
|
+
if (loaded) return loaded
|
|
464
|
+
if (!cacheFile || !readFile) { loaded = { v: 1, entries: {} }; return loaded }
|
|
465
|
+
try {
|
|
466
|
+
const raw = await readFile(cacheFile, 'utf8')
|
|
467
|
+
const parsed = JSON.parse(raw)
|
|
468
|
+
loaded = parsed && parsed.v === 1 && parsed.entries ? parsed : { v: 1, entries: {} }
|
|
469
|
+
} catch {
|
|
470
|
+
loaded = { v: 1, entries: {} }
|
|
471
|
+
}
|
|
472
|
+
return loaded
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function flush() {
|
|
476
|
+
if (!cacheFile || !writeFile || !dirty) return
|
|
477
|
+
dirty = false
|
|
478
|
+
try {
|
|
479
|
+
if (mkdir) await mkdir(cacheFile.replace(/[\\/][^\\/]+$/, ''), { recursive: true })
|
|
480
|
+
await writeFile(cacheFile, JSON.stringify(await load()), 'utf8')
|
|
481
|
+
} catch {
|
|
482
|
+
/* 落盘失败不影响本次返回 */
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function fresh(entry) {
|
|
487
|
+
if (!entry) return false
|
|
488
|
+
const age = now() - Number(entry.at || 0)
|
|
489
|
+
if (entry.status === 'found') return age < ttlFound
|
|
490
|
+
if (entry.status === 'none') return age < ttlMiss
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* ---------- 访问 ---------- */
|
|
495
|
+
|
|
496
|
+
let swapping = false
|
|
497
|
+
|
|
498
|
+
async function call(path, params, referer) {
|
|
499
|
+
await session.ensure()
|
|
500
|
+
/* 配额按会话计。并发下可能同时越线,用 swapping 标记避免重复握手。 */
|
|
501
|
+
if (session.used > session.limit && !swapping) {
|
|
502
|
+
swapping = true
|
|
503
|
+
try { await session.rotate() } finally { swapping = false }
|
|
504
|
+
}
|
|
505
|
+
const q = signQuery(params, session.mixinKey, hashFn)
|
|
506
|
+
const res = await fetchImpl(`${base}${path}?${q}`, {
|
|
507
|
+
headers: session.headers(referer),
|
|
508
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
509
|
+
})
|
|
510
|
+
const text = await res.text()
|
|
511
|
+
let json = null
|
|
512
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
513
|
+
if (res.status === 412 || json?.code === -412 || json?.code === -352) {
|
|
514
|
+
await session.rotate()
|
|
515
|
+
const err = new Error('throttled')
|
|
516
|
+
err.throttled = true
|
|
517
|
+
throw err
|
|
518
|
+
}
|
|
519
|
+
if (!json || json.code !== 0) {
|
|
520
|
+
const err = new Error(`upstream ${json?.code ?? res.status}`)
|
|
521
|
+
err.code = json?.code
|
|
522
|
+
throw err
|
|
523
|
+
}
|
|
524
|
+
session.used += 1
|
|
525
|
+
return json.data
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 搜索:失败重试一次,风控时换会话。 */
|
|
529
|
+
async function search(keyword, page) {
|
|
530
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
531
|
+
try {
|
|
532
|
+
const data = await call(
|
|
533
|
+
'/x/web-interface/wbi/search/type',
|
|
534
|
+
{ search_type: 'video', keyword, page, page_size: 50 },
|
|
535
|
+
`https://search.bilibili.com/all?keyword=${encodeURIComponent(keyword)}`,
|
|
536
|
+
)
|
|
537
|
+
return Array.isArray(data?.result) ? data.result : []
|
|
538
|
+
} catch (e) {
|
|
539
|
+
if (!e.throttled) return []
|
|
540
|
+
await new Promise((r) => setTimeout(r, 700 + Math.floor(Math.random() * 900)))
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return []
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function detail(bvid) {
|
|
547
|
+
try {
|
|
548
|
+
const data = await call(
|
|
549
|
+
'/x/web-interface/view',
|
|
550
|
+
{ bvid },
|
|
551
|
+
`https://www.bilibili.com/video/${bvid}`,
|
|
552
|
+
)
|
|
553
|
+
return data || null
|
|
554
|
+
} catch {
|
|
555
|
+
return null
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/* ---------- 查询词 ---------- */
|
|
560
|
+
|
|
561
|
+
function queries(local) {
|
|
562
|
+
const out = []
|
|
563
|
+
const channel = String(local.channel || '')
|
|
564
|
+
if (channel.length > 2) out.push(channel)
|
|
565
|
+
|
|
566
|
+
const raw = String(local.title || '')
|
|
567
|
+
out.push(raw.length > 70 ? raw.slice(0, 70) : raw)
|
|
568
|
+
const beforePipe = raw.split(/\s*[|—–]\s*/)[0]
|
|
569
|
+
if (beforePipe.length >= 8 && beforePipe !== raw) out.push(beforePipe)
|
|
570
|
+
|
|
571
|
+
const zh = String(local.titleZh || '')
|
|
572
|
+
if (zh) {
|
|
573
|
+
out.push(zh)
|
|
574
|
+
const noPunct = zh.replace(/[::,,。.、「」【】《》"'()()!??·—–]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
575
|
+
if (noPunct !== zh && noPunct.length >= 3) out.push(noPunct)
|
|
576
|
+
const head = zh.split(/[::,,。.、]/)[0].trim()
|
|
577
|
+
if (head.length >= 3 && head !== zh) out.push(head)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* 控制词数:每个词两页,四个词就是八个并发请求。
|
|
581
|
+
再多会挤在同一秒打出去,得不偿失。 */
|
|
582
|
+
return [...new Set(out.filter((s) => String(s || '').trim().length > 2))].slice(0, 4)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* ---------- 主流程 ---------- */
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {object} local
|
|
589
|
+
* id 源视频标识
|
|
590
|
+
* title 原标题
|
|
591
|
+
* titleZh 中文译名(可选,但显著提升命中率)
|
|
592
|
+
* channel 频道名
|
|
593
|
+
* duration 时长(秒)
|
|
594
|
+
* published 发布时间(ISO 字符串,可选)
|
|
595
|
+
* @returns {Promise<{url,label,play,bvid,title,up,reason,alternates}|null>}
|
|
596
|
+
*/
|
|
597
|
+
async function resolve(local) {
|
|
598
|
+
const vid = String(local?.id || '')
|
|
599
|
+
if (!/^[A-Za-z0-9_-]{6,20}$/.test(vid)) return null
|
|
600
|
+
if (!local?.title || !local?.duration) return null
|
|
601
|
+
|
|
602
|
+
const cache = await load()
|
|
603
|
+
const hit = cache.entries[vid]
|
|
604
|
+
if (fresh(hit)) {
|
|
605
|
+
if (hit.status !== 'found') { memory.set(vid, null); return null }
|
|
606
|
+
const value = {
|
|
607
|
+
url: hit.url,
|
|
608
|
+
bvid: hit.bvid,
|
|
609
|
+
label: hit.label,
|
|
610
|
+
play: hit.play,
|
|
611
|
+
title: hit.title,
|
|
612
|
+
up: hit.up,
|
|
613
|
+
reason: hit.reason,
|
|
614
|
+
alternates: [],
|
|
615
|
+
}
|
|
616
|
+
memory.set(vid, value)
|
|
617
|
+
return value
|
|
618
|
+
}
|
|
619
|
+
if (memory.has(vid)) {
|
|
620
|
+
const cached = memory.get(vid)
|
|
621
|
+
if (cached) return cached
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (inflight.has(vid)) return inflight.get(vid)
|
|
625
|
+
|
|
626
|
+
const task = (async () => {
|
|
627
|
+
const deadline = now() + overallMs
|
|
628
|
+
const pool = new Map()
|
|
629
|
+
|
|
630
|
+
/* 查询词与分页全部并发:3 个词 × 2 页 = 6 个请求同时发,
|
|
631
|
+
只等一个往返。串行发要三倍时间。 */
|
|
632
|
+
const plan = []
|
|
633
|
+
for (const q of queries(local)) {
|
|
634
|
+
plan.push([q, 1], [q, 2])
|
|
635
|
+
}
|
|
636
|
+
const results = await Promise.all(plan.map(([q, p]) => search(q, p)))
|
|
637
|
+
for (const list of results) {
|
|
638
|
+
for (const item of list) {
|
|
639
|
+
const bvid = String(item?.bvid || '')
|
|
640
|
+
if (!bvid || pool.has(bvid)) continue
|
|
641
|
+
pool.set(bvid, {
|
|
642
|
+
bvid,
|
|
643
|
+
title: String(item.title || '').replace(/<[^>]+>/g, ''),
|
|
644
|
+
dur: parseDuration(item.duration),
|
|
645
|
+
play: Number(item.play || 0),
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (now() > deadline) { /* 超时也继续往下走,已有结果足够判定 */ }
|
|
650
|
+
|
|
651
|
+
/* 只有时长对得上的才值得拉详情——平均 2.3 个。 */
|
|
652
|
+
const near = [...pool.values()]
|
|
653
|
+
.filter((c) => c.dur != null && Math.abs(c.dur - local.duration) <= 2)
|
|
654
|
+
.slice(0, 8)
|
|
655
|
+
|
|
656
|
+
const details = await Promise.all(near.map((c) => detail(c.bvid)))
|
|
657
|
+
const accepted = []
|
|
658
|
+
for (let i = 0; i < near.length; i += 1) {
|
|
659
|
+
const remote = details[i]
|
|
660
|
+
if (!remote) continue
|
|
661
|
+
const pages = Array.isArray(remote.pages) ? remote.pages : []
|
|
662
|
+
let pageHit = null
|
|
663
|
+
if (pages.length > 1) {
|
|
664
|
+
const idx = pages.findIndex((p) => Math.abs(p.duration - local.duration) <= 2)
|
|
665
|
+
if (idx >= 0) pageHit = { index: idx + 1, duration: pages[idx].duration }
|
|
666
|
+
}
|
|
667
|
+
const verdict = match(remote, local, { pageHit })
|
|
668
|
+
if (verdict.ok) accepted.push({ remote, pageHit, verdict, bvid: near[i].bvid })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const best = pickBest(accepted, local.channel)
|
|
672
|
+
const entry = { at: now() }
|
|
673
|
+
if (best) {
|
|
674
|
+
const page = best.pageHit?.index || null
|
|
675
|
+
Object.assign(entry, {
|
|
676
|
+
status: 'found',
|
|
677
|
+
url: `https://www.bilibili.com/video/${best.bvid}${page ? `?p=${page}` : ''}`,
|
|
678
|
+
bvid: best.bvid,
|
|
679
|
+
label: best.label,
|
|
680
|
+
play: best.play,
|
|
681
|
+
title: String(best.remote?.title || '').slice(0, 200),
|
|
682
|
+
up: String(best.remote?.owner?.name || '').slice(0, 80),
|
|
683
|
+
reason: best.verdict.reason,
|
|
684
|
+
})
|
|
685
|
+
} else {
|
|
686
|
+
entry.status = 'none'
|
|
687
|
+
}
|
|
688
|
+
cache.entries[vid] = entry
|
|
689
|
+
dirty = true
|
|
690
|
+
void flush()
|
|
691
|
+
|
|
692
|
+
if (!best) { memory.set(vid, null); return null }
|
|
693
|
+
const value = {
|
|
694
|
+
url: entry.url,
|
|
695
|
+
bvid: entry.bvid,
|
|
696
|
+
label: entry.label,
|
|
697
|
+
play: entry.play,
|
|
698
|
+
title: entry.title,
|
|
699
|
+
up: entry.up,
|
|
700
|
+
reason: entry.reason,
|
|
701
|
+
alternates: (best.alternates || []).map((a) => ({
|
|
702
|
+
bvid: a.bvid,
|
|
703
|
+
label: a.label,
|
|
704
|
+
play: a.play,
|
|
705
|
+
title: String(a.remote?.title || '').slice(0, 120),
|
|
706
|
+
})),
|
|
707
|
+
}
|
|
708
|
+
memory.set(vid, value)
|
|
709
|
+
return value
|
|
710
|
+
})()
|
|
711
|
+
|
|
712
|
+
inflight.set(vid, task)
|
|
713
|
+
try {
|
|
714
|
+
return await task
|
|
715
|
+
} finally {
|
|
716
|
+
inflight.delete(vid)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 标记失效:链接被删或迁移时调用,下次访问会重新解析。 */
|
|
721
|
+
async function invalidate(vid) {
|
|
722
|
+
const cache = await load()
|
|
723
|
+
if (cache.entries[vid]) {
|
|
724
|
+
delete cache.entries[vid]
|
|
725
|
+
dirty = true
|
|
726
|
+
memory.delete(vid)
|
|
727
|
+
await flush()
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** 批量预热,供后台补数据用。命中缓存的直接跳过。 */
|
|
732
|
+
async function warm(list, concurrency = 2) {
|
|
733
|
+
const queue = [...list]
|
|
734
|
+
const out = { scanned: 0, found: 0, missed: 0 }
|
|
735
|
+
const worker = async () => {
|
|
736
|
+
for (;;) {
|
|
737
|
+
const item = queue.shift()
|
|
738
|
+
if (!item) return
|
|
739
|
+
out.scanned += 1
|
|
740
|
+
const r = await resolve(item)
|
|
741
|
+
if (r) out.found += 1
|
|
742
|
+
else out.missed += 1
|
|
743
|
+
await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 250)))
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker))
|
|
747
|
+
return out
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function stats() {
|
|
751
|
+
const cache = await load()
|
|
752
|
+
const all = Object.values(cache.entries)
|
|
753
|
+
return {
|
|
754
|
+
total: all.length,
|
|
755
|
+
found: all.filter((e) => e.status === 'found').length,
|
|
756
|
+
none: all.filter((e) => e.status === 'none').length,
|
|
757
|
+
memory: memory.size,
|
|
758
|
+
inflight: inflight.size,
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function snapshot() {
|
|
763
|
+
const cache = await load()
|
|
764
|
+
return JSON.parse(JSON.stringify(cache))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return { resolve, invalidate, warm, stats, snapshot, session }
|
|
768
|
+
}
|