-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.js
More file actions
774 lines (733 loc) · 33.8 KB
/
Copy pathcontent.js
File metadata and controls
774 lines (733 loc) · 33.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
// content.js — 在 B 站视频页注入悬浮按钮与下载面板
(function () {
if (window.__BILI_DL_INJECTED__) return;
window.__BILI_DL_INJECTED__ = true;
const log = (...a) => console.log('[BiliDL]', ...a);
log('content script loaded at', location.href);
const VIDEO_PAGE_RE = /^https?:\/\/www\.bilibili\.com\/(video\/|bangumi\/play\/|list\/)/;
function isVideoPage() { return VIDEO_PAGE_RE.test(location.href); }
// ---------- 从页面拿 __playinfo__ / __INITIAL_STATE__ ----------
// content script 运行在 isolated world,拿不到 window 上的对象,需要注入脚本到 main world
function readPlayInfoFromMainWorld() {
return new Promise((resolve) => {
const id = 'bili_dl_probe_' + Date.now();
const handler = (e) => {
if (e.source !== window || !e.data || e.data.__bili_dl_probe !== id) return;
window.removeEventListener('message', handler);
resolve(e.data.payload);
};
window.addEventListener('message', handler);
const code = `(function(){
try {
var payload = {
playinfo: window.__playinfo__ || null,
initial: window.__INITIAL_STATE__ ? {
videoData: window.__INITIAL_STATE__.videoData || null,
epInfo: window.__INITIAL_STATE__.epInfo || null,
mediaInfo: window.__INITIAL_STATE__.mediaInfo || null,
h1Title: window.__INITIAL_STATE__.h1Title || null
} : null,
href: location.href,
title: document.title
};
window.postMessage({ __bili_dl_probe: '${id}', payload: payload }, '*');
} catch (e) {
window.postMessage({ __bili_dl_probe: '${id}', payload: { error: String(e) } }, '*');
}
})();`;
const s = document.createElement('script');
s.textContent = code;
(document.head || document.documentElement).appendChild(s);
s.remove();
setTimeout(() => {
window.removeEventListener('message', handler);
resolve(null);
}, 1500);
});
}
// 新版 B 站经常不再注入 window.__playinfo__,需要直接调 API
// 同域 fetch 会自动带 SESSDATA cookie,登录用户能拿到高清晰度
async function fetchPlayInfoViaApi() {
try {
// 1) 番剧 /bangumi/play/ep12345 或 ss12345
const epMatch = location.pathname.match(/\/bangumi\/play\/ep(\d+)/);
const ssMatch = location.pathname.match(/\/bangumi\/play\/ss(\d+)/);
if (epMatch || ssMatch) {
const q = epMatch ? `ep_id=${epMatch[1]}` : `season_id=${ssMatch[1]}`;
const seasonRes = await fetch(`https://api.bilibili.com/pgc/view/web/season?${q}`, { credentials: 'include' }).then(r => r.json());
const result = seasonRes && seasonRes.result;
if (!result) return null;
const episodes = result.episodes || [];
const targetEpId = epMatch ? epMatch[1] : null;
const ep = (targetEpId && episodes.find(e => String(e.ep_id) === targetEpId)) || episodes[0];
if (!ep) return null;
const playRes = await fetch(`https://api.bilibili.com/pgc/player/web/playurl?ep_id=${ep.ep_id}&cid=${ep.cid}&qn=120&fnval=4048&fourk=1`, { credentials: 'include' }).then(r => r.json());
if (!playRes || !playRes.result) return null;
return {
playinfo: { data: playRes.result },
initial: {
videoData: { title: ep.long_title || ep.title || result.title, pic: ep.cover || result.cover },
epInfo: ep,
mediaInfo: { title: result.title }
},
href: location.href
};
}
// 2) 普通视频 /video/BVxxxx
const bvMatch = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/);
if (bvMatch) {
const bvid = bvMatch[1];
const pMatch = location.search.match(/[?&]p=(\d+)/);
const pageIdx = pMatch ? Math.max(1, Number(pMatch[1])) - 1 : 0;
const viewRes = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { credentials: 'include' }).then(r => r.json());
const data = viewRes && viewRes.data;
if (!data) return null;
const page = (data.pages && data.pages[pageIdx]) || (data.pages && data.pages[0]);
const cid = page ? page.cid : data.cid;
if (!cid) return null;
const playRes = await fetch(`https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&qn=120&fnval=4048&fourk=1`, { credentials: 'include' }).then(r => r.json());
if (!playRes || !playRes.data) return null;
return {
playinfo: { data: playRes.data },
initial: { videoData: { title: page && page.part ? `${data.title} - ${page.part}` : data.title, pic: data.pic } },
href: location.href
};
}
} catch (e) {
console.error('[BiliDL] fetchPlayInfoViaApi failed', e);
}
return null;
}
function pickQualities(playinfo) {
// dash
if (playinfo && playinfo.data && playinfo.data.dash) {
const dash = playinfo.data.dash;
const accept = playinfo.data.accept_quality || [];
const desc = playinfo.data.accept_description || [];
const map = {};
for (let i = 0; i < accept.length; i++) map[accept[i]] = desc[i];
// 把每个清晰度的视频流挑出来(取 bandwidth 最高的同 id)
const byQ = {};
for (const v of (dash.video || [])) {
if (!byQ[v.id] || v.bandwidth > byQ[v.id].bandwidth) byQ[v.id] = v;
}
// 音频流取最高码率
let bestAudio = null;
for (const a of (dash.audio || [])) {
if (!bestAudio || a.bandwidth > bestAudio.bandwidth) bestAudio = a;
}
// 优先选 upos 主 CDN 的 URL(更稳),mcdn 边缘节点经常拒
function pickStableUrl(s) {
if (!s) return null;
const candidates = [s.baseUrl || s.base_url, ...(s.backupUrl || s.backup_url || [])].filter(Boolean);
const upos = candidates.find(u => /upos-[a-z]+-(mirror|estg)/.test(u));
return upos || candidates[0] || null;
}
const list = Object.keys(byQ)
.map(qid => ({
quality: Number(qid),
label: map[qid] || `清晰度${qid}`,
videoUrl: pickStableUrl(byQ[qid]),
audioUrl: pickStableUrl(bestAudio),
container: 'dash'
}))
.sort((a, b) => b.quality - a.quality);
return list;
}
// durl (mp4 / flv)
if (playinfo && playinfo.data && playinfo.data.durl && playinfo.data.durl.length) {
const format = playinfo.data.format || 'mp4';
const qid = playinfo.data.quality;
const accept = playinfo.data.accept_quality || [];
const desc = playinfo.data.accept_description || [];
const idx = accept.indexOf(qid);
const label = idx >= 0 ? desc[idx] : `清晰度${qid}`;
// durl 可能有多段(分P内部分片),这里只取第一段做演示,提示用户
return [{
quality: qid,
label,
videoUrl: playinfo.data.durl[0].url,
audioUrl: null,
container: format.includes('flv') ? 'flv' : 'mp4',
multiSegment: playinfo.data.durl.length > 1
}];
}
return [];
}
function extractMeta(probe) {
let title = document.title.replace(/_哔哩哔哩.*$/, '').replace(/-哔哩哔哩.*$/, '').trim();
let cover = '';
if (probe && probe.initial) {
const vd = probe.initial.videoData;
if (vd) {
title = vd.title || title;
cover = vd.pic || cover;
}
const ep = probe.initial.epInfo;
if (ep && ep.long_title) {
title = (probe.initial.mediaInfo && probe.initial.mediaInfo.title ? probe.initial.mediaInfo.title + ' - ' : '') + (ep.long_title || ep.title || '');
cover = ep.cover || cover;
}
}
if (!cover) {
const og = document.querySelector('meta[property="og:image"]');
if (og) cover = og.content;
}
return { title, cover };
}
// ---------- UI ----------
const root = document.createElement('div');
root.id = 'bili-dl-root';
root.innerHTML = `
<button id="bili-dl-fab" title="打开视频下载">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/>
<path d="m7 10 5 5 5-5"/>
<path d="M5 21h14"/>
</svg>
<span>下载</span>
</button>
<div id="bili-dl-panel" hidden>
<div class="bdl-header">
<button class="bdl-back" title="返回" hidden>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<div class="bdl-title">选择下载内容</div>
<button class="bdl-close" title="关闭">✕</button>
</div>
<!-- 一级选择面板 -->
<div class="bdl-view bdl-view-home">
<div class="bdl-choice" data-choice="video">
<div class="bdl-choice-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>
</div>
<div class="bdl-choice-text">
<div class="bdl-choice-title">下载视频</div>
<div class="bdl-choice-desc">下载当前视频为本地文件</div>
</div>
<div class="bdl-choice-arrow">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</div>
</div>
<div class="bdl-choice" data-choice="transcript">
<div class="bdl-choice-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/></svg>
</div>
<div class="bdl-choice-text">
<div class="bdl-choice-title">下载文稿</div>
<div class="bdl-choice-desc">识别视频语音并导出文字稿</div>
</div>
<div class="bdl-choice-arrow">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</div>
</div>
</div>
<!-- 视频下载面板 -->
<div class="bdl-view bdl-view-video" hidden>
<div class="bdl-body">
<div class="bdl-cover-wrap">
<img class="bdl-cover" alt="封面"/>
</div>
<div class="bdl-meta">
<div class="bdl-vtitle">加载中…</div>
<div class="bdl-row">
<label>清晰度</label>
<select class="bdl-quality"></select>
</div>
<button class="bdl-go">开始下载</button>
<div class="bdl-hint"></div>
</div>
</div>
<div class="bdl-tasks">
<div class="bdl-tasks-title">下载任务</div>
<div class="bdl-tasks-list"></div>
</div>
<div class="bdl-footer">DASH 流在浏览器内自动合并为 mp4 · 切换页面不会中断</div>
</div>
<!-- 文稿生成面板 -->
<div class="bdl-view bdl-view-transcript" hidden>
<div class="bdl-body">
<div class="bdl-cover-wrap">
<img class="bdl-cover" alt="封面"/>
</div>
<div class="bdl-meta">
<div class="bdl-vtitle">加载中…</div>
<div class="bdl-row">
<label>导出格式</label>
<div class="bdl-fmt">
<label class="bdl-fmt-opt"><input type="radio" name="bdl-ts-fmt" value="txt" checked><span>TXT</span></label>
<label class="bdl-fmt-opt"><input type="radio" name="bdl-ts-fmt" value="md"><span>Markdown</span></label>
</div>
</div>
<div class="bdl-row">
<label>带时间戳</label>
<label class="bdl-switch"><input type="checkbox" class="bdl-ts-time"><span class="bdl-switch-track"></span></label>
</div>
<button class="bdl-go-ts">开始生成文稿</button>
<div class="bdl-hint"></div>
</div>
</div>
<div class="bdl-footer">文稿来源为 B 站 AI 字幕 · 部分视频可能未提供字幕</div>
</div>
</div>
`;
document.documentElement.appendChild(root);
function updateVisibility() {
root.style.display = isVideoPage() ? '' : 'none';
}
updateVisibility();
// 监听 B 站 SPA 路由变化,确保切到视频页时按钮重新出现
let lastHref = location.href;
const _push = history.pushState;
const _replace = history.replaceState;
history.pushState = function () { _push.apply(this, arguments); queueMicrotask(onUrlMaybeChanged); };
history.replaceState = function () { _replace.apply(this, arguments); queueMicrotask(onUrlMaybeChanged); };
window.addEventListener('popstate', onUrlMaybeChanged);
setInterval(onUrlMaybeChanged, 1000);
function onUrlMaybeChanged() {
if (location.href === lastHref) return;
lastHref = location.href;
log('url changed →', lastHref);
updateVisibility();
closePanel();
}
const fab = root.querySelector('#bili-dl-fab');
const panel = root.querySelector('#bili-dl-panel');
const headerTitle = root.querySelector('.bdl-header .bdl-title');
const backBtn = root.querySelector('.bdl-back');
const closeBtn = root.querySelector('.bdl-close');
const homeView = root.querySelector('.bdl-view-home');
const videoView = root.querySelector('.bdl-view-video');
const tsView = root.querySelector('.bdl-view-transcript');
// 视频下载视图元素(作用域限定,避免与文稿视图同名 class 冲突)
const coverImg = videoView.querySelector('.bdl-cover');
const titleEl = videoView.querySelector('.bdl-vtitle');
const qSel = videoView.querySelector('.bdl-quality');
const goBtn = videoView.querySelector('.bdl-go');
const hintEl = videoView.querySelector('.bdl-hint');
const tasksList = videoView.querySelector('.bdl-tasks-list');
// 文稿视图元素
const tsCover = tsView.querySelector('.bdl-cover');
const tsTitle = tsView.querySelector('.bdl-vtitle');
const tsGoBtn = tsView.querySelector('.bdl-go-ts');
const tsHint = tsView.querySelector('.bdl-hint');
const tsTimeChk = tsView.querySelector('.bdl-ts-time');
let currentMeta = null;
let currentQualities = [];
let currentView = 'home';
const VIEW_TITLES = { home: '选择下载内容', video: '下载视频', transcript: '下载文稿' };
function showView(name) {
currentView = name;
homeView.toggleAttribute('hidden', name !== 'home');
videoView.toggleAttribute('hidden', name !== 'video');
tsView.toggleAttribute('hidden', name !== 'transcript');
backBtn.toggleAttribute('hidden', name === 'home');
headerTitle.textContent = VIEW_TITLES[name] || 'B站下载助手';
}
function openPanel() {
panel.removeAttribute('hidden');
showView('home');
}
function closePanel() {
panel.setAttribute('hidden', '');
showView('home');
}
fab.addEventListener('click', () => {
if (panel.hasAttribute('hidden')) openPanel();
else closePanel();
});
closeBtn.addEventListener('click', closePanel);
backBtn.addEventListener('click', () => showView('home'));
// 一级选择面板:进入对应子视图并懒加载数据
root.querySelectorAll('.bdl-choice').forEach(el => {
el.addEventListener('click', async () => {
const choice = el.getAttribute('data-choice');
if (choice === 'video') {
showView('video');
await refreshVideoInfo();
await refreshTasks();
} else if (choice === 'transcript') {
showView('transcript');
await refreshTranscriptInfo();
}
});
});
async function refreshVideoInfo() {
titleEl.textContent = '解析中…';
qSel.innerHTML = '';
hintEl.textContent = '';
let probe = await readPlayInfoFromMainWorld();
if (!probe || !probe.playinfo) {
log('window.__playinfo__ 不存在,回退到 API 解析');
probe = await fetchPlayInfoViaApi();
}
if (!probe || !probe.playinfo) {
titleEl.textContent = '未能解析到视频源';
hintEl.textContent = '可能是付费/会员视频,或需登录 B 站。请确认已登录后重试。';
goBtn.disabled = true;
return;
}
const meta = extractMeta(probe);
const quals = pickQualities(probe.playinfo);
currentMeta = meta;
currentQualities = quals;
titleEl.textContent = meta.title;
if (meta.cover) coverImg.src = meta.cover.replace(/^http:/, 'https:');
if (quals.length === 0) {
hintEl.textContent = '当前页面没有可用下载源(可能是付费/会员/试看片段)。';
goBtn.disabled = true;
return;
}
for (const q of quals) {
const opt = document.createElement('option');
opt.value = String(q.quality);
opt.textContent = `${q.label}(${q.container.toUpperCase()}${q.audioUrl ? ' + 音频' : ''})`;
qSel.appendChild(opt);
}
goBtn.disabled = false;
const first = quals[0];
if (first.container === 'dash') {
hintEl.textContent = '该清晰度为 DASH,将自动用 ffmpeg.wasm 合并为单个 mp4。';
} else if (first.multiSegment) {
hintEl.textContent = '该视频有多个分段,当前仅下载第一段。';
}
}
qSel.addEventListener('change', () => {
const q = currentQualities.find(x => String(x.quality) === qSel.value);
if (!q) return;
if (q.container === 'dash') {
hintEl.textContent = '该清晰度为 DASH,将自动用 ffmpeg.wasm 合并为单个 mp4。';
} else if (q.multiSegment) {
hintEl.textContent = '该视频有多个分段,当前仅下载第一段。';
} else {
hintEl.textContent = '';
}
});
goBtn.addEventListener('click', async () => {
const q = currentQualities.find(x => String(x.quality) === qSel.value);
if (!q || !currentMeta) return;
const taskId = 'tsk_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
const task = {
id: taskId,
title: currentMeta.title,
cover: currentMeta.cover,
quality: q.quality,
qualityLabel: q.label,
videoUrl: q.videoUrl,
audioUrl: q.audioUrl,
container: q.container,
pageUrl: location.href
};
goBtn.disabled = true;
goBtn.textContent = '已加入下载队列';
setTimeout(() => { goBtn.disabled = false; goBtn.textContent = '开始下载'; }, 1500);
await chrome.runtime.sendMessage({ type: 'CREATE_TASK', task });
await refreshTasks();
});
async function refreshTasks() {
const res = await chrome.runtime.sendMessage({ type: 'GET_TASKS' });
if (!res || !res.ok) return;
renderTasks(res.tasks);
}
function renderTasks(tasks) {
const arr = Object.values(tasks).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (arr.length === 0) {
tasksList.innerHTML = '<div class="bdl-empty">暂无下载任务</div>';
return;
}
tasksList.innerHTML = '';
for (const t of arr) {
const isDash = t.container === 'dash';
let pct = 0, line = '';
const phaseMap = { queued: '排队中', starting: '准备中', fetching: '拉取流', merging: '合并 mp4', saving: '保存中', done: '已完成', error: '失败' };
const statusMap = { downloading: phaseMap[t.merge?.phase] || '下载中', done: '已完成', error: '失败', canceled: '已取消', interrupted: '已中断' };
let statusLabel = statusMap[t.status] || t.status;
if (isDash) {
const m = t.merge || {};
const vRecv = m.videoReceived || 0, vTot = m.videoTotal || 0;
const aRecv = m.audioReceived || 0, aTot = m.audioTotal || 0;
const fetchedBytes = vRecv + aRecv;
const fetchTotal = (vTot || 0) + (aTot || 0);
// 进度条:拉流占 0-70%,合并占 70-95%,保存 95-100%
if (m.phase === 'merging') {
pct = 70 + Math.floor((m.mergeProgress || 0) * 25);
} else if (m.phase === 'saving') {
pct = 96;
} else if (m.phase === 'done') {
pct = 100;
} else if (m.phase === 'fetching' && fetchTotal > 0) {
pct = Math.min(70, Math.floor(fetchedBytes / fetchTotal * 70));
} else {
pct = 2;
}
if (m.phase === 'fetching') {
line = `${t.qualityLabel || ''} · 视频 ${formatBytes(vRecv)}/${vTot ? formatBytes(vTot) : '—'} · 音频 ${formatBytes(aRecv)}/${aTot ? formatBytes(aTot) : '—'}`;
} else if (m.phase === 'merging') {
line = `${t.qualityLabel || ''} · ffmpeg 合并中 ${Math.floor((m.mergeProgress || 0) * 100)}%`;
} else if (m.phase === 'saving') {
line = `${t.qualityLabel || ''} · 正在保存 mp4(${formatBytes(m.mp4Size || 0)})`;
} else if (m.phase === 'done') {
line = `${t.qualityLabel || ''} · mp4 ${formatBytes(m.mp4Size || 0)}`;
} else if (m.phase === 'error') {
line = `${t.qualityLabel || ''} · ${t.error || '合并失败'}`;
} else {
line = t.qualityLabel || '';
}
} else {
// 单文件 mp4/flv 路径
const parts = Object.values(t.parts || {});
const received = parts.reduce((s, p) => s + (p.bytesReceived || 0), 0);
const total = parts.reduce((s, p) => s + (p.totalBytes || 0), 0);
pct = total > 0 ? Math.min(100, Math.floor(received / total * 100)) : 0;
line = `${t.qualityLabel || ''} · ${formatBytes(received)} / ${total ? formatBytes(total) : '—'}`;
}
const item = document.createElement('div');
item.className = 'bdl-task ' + t.status;
item.innerHTML = `
<div class="bdl-t-row">
<div class="bdl-t-title" title="${escapeHtml(t.title)}">${escapeHtml(t.title)}</div>
<div class="bdl-t-status">${statusLabel}</div>
</div>
<div class="bdl-progress"><div class="bdl-progress-bar" style="width:${pct}%"></div></div>
<div class="bdl-t-row bdl-t-sub">
<span>${escapeHtml(line)}</span>
<span class="bdl-t-actions">
${t.status === 'downloading' ? `<button data-act="cancel" data-id="${t.id}">取消</button>` : ''}
<button data-act="remove" data-id="${t.id}">移除</button>
</span>
</div>
`;
tasksList.appendChild(item);
}
tasksList.querySelectorAll('button[data-act]').forEach(b => {
b.addEventListener('click', async () => {
const id = b.getAttribute('data-id');
const act = b.getAttribute('data-act');
if (act === 'cancel') await chrome.runtime.sendMessage({ type: 'CANCEL_TASK', taskId: id });
if (act === 'remove') await chrome.runtime.sendMessage({ type: 'REMOVE_TASK', taskId: id });
await refreshTasks();
});
});
}
// ---------- 文稿(B 站 AI 字幕) ----------
async function refreshTranscriptInfo() {
tsTitle.textContent = '解析中…';
tsHint.textContent = '';
tsGoBtn.disabled = true;
// SPA 切视频后 window.__playinfo__ 经常不更新,需要 API 兜底,否则文件名会是旧视频的
let probe = await readPlayInfoFromMainWorld();
if (!probe || !probe.initial || !probe.initial.videoData) {
log('[transcript] __playinfo__ 不可用,回落 API 取 meta');
probe = await fetchPlayInfoViaApi();
}
const meta = extractMeta(probe);
tsTitle.textContent = meta.title || '当前视频';
if (meta.cover) tsCover.src = meta.cover.replace(/^http:/, 'https:');
currentMeta = currentMeta || meta;
tsView.__meta = meta;
log('[transcript] meta', { title: meta.title, href: location.href });
tsGoBtn.disabled = false;
}
// 向主世界脚本(mainworld.js)询问"当前正在播"的视频信息。
// 自动连播/推荐换片后地址栏 BV 号会过期,主世界的 __INITIAL_STATE__.videoData 才是准的。
function getCurrentVideoData(timeout = 1200) {
return new Promise((resolve) => {
const reqId = 'cv_' + Date.now() + '_' + Math.random().toString(36).slice(2);
const handler = (e) => {
if (e.source !== window || !e.data || e.data.__bili_dl_resp !== 'cur_video' || e.data.reqId !== reqId) return;
window.removeEventListener('message', handler);
resolve(e.data.payload || null);
};
window.addEventListener('message', handler);
window.postMessage({ __bili_dl_req: 'cur_video', reqId }, '*');
setTimeout(() => { window.removeEventListener('message', handler); resolve(null); }, timeout);
});
}
// 拿到当前视频的 bvid + cid(番剧用 ep_id + cid)
async function getVideoIds() {
const epMatch = location.pathname.match(/\/bangumi\/play\/ep(\d+)/);
const ssMatch = location.pathname.match(/\/bangumi\/play\/ss(\d+)/);
if (epMatch || ssMatch) {
const q = epMatch ? `ep_id=${epMatch[1]}` : `season_id=${ssMatch[1]}`;
const seasonRes = await fetch(`https://api.bilibili.com/pgc/view/web/season?${q}`, { credentials: 'include' }).then(r => r.json());
const result = seasonRes && seasonRes.result;
if (!result) return null;
const episodes = result.episodes || [];
const targetEpId = epMatch ? epMatch[1] : null;
const ep = (targetEpId && episodes.find(e => String(e.ep_id) === targetEpId)) || episodes[0];
if (!ep) return null;
return { bvid: ep.bvid || '', cid: ep.cid, epId: ep.ep_id, aid: ep.aid || null };
}
// 普通视频:从"播放器当前正在播"取 BV 号(回退地址栏),再用它向接口拿权威 cid。
// 不直接信主世界的 cid——自动连播切换时 videoData 可能"标题已更新、cid 还残留旧视频"。
const cur = await getCurrentVideoData();
const bvMatch = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/);
const bvid = (cur && cur.bvid) || (bvMatch && bvMatch[1]);
if (!bvid) return null;
const pMatch = location.search.match(/[?&]p=(\d+)/);
const pageIdx = pMatch ? Math.max(1, Number(pMatch[1])) - 1 : 0;
const viewRes = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { credentials: 'include' }).then(r => r.json());
const data = viewRes && viewRes.data;
if (!data) return null;
const pages = data.pages || [];
// 仅当主世界给的 cid 确实属于这个 BV(出现在它的分 P 列表里)才采用,兼容多 P;
// 否则一律用接口返回的本视频 cid,杜绝串台。
let cid;
if (cur && cur.cid && pages.some(p => String(p.cid) === String(cur.cid))) {
cid = cur.cid;
} else {
const page = pages[pageIdx] || pages[0];
cid = page ? page.cid : data.cid;
}
if (!cid) return null;
log('[transcript] 解析视频', { bvid, cid, 主世界cid: cur && cur.cid, urlBvid: bvMatch && bvMatch[1] });
return { bvid, cid, epId: null, aid: data.aid || null, resolvedTitle: data.title || '' };
}
// 调 x/player/v2 拿字幕列表(需登录 cookie 才有 AI 字幕)
async function fetchSubtitleList(ids) {
const params = ids.bvid ? `bvid=${ids.bvid}&cid=${ids.cid}` : `ep_id=${ids.epId}&cid=${ids.cid}`;
try {
// 必须用 wbi/v2:非 wbi 的 x/player/v2 会返回“串台”的旧字幕 URL(接口回显 cid 正确,
// 但 subtitle_url 指向另一个视频的 AI 字幕文件)。wbi/v2 才返回与本视频匹配的字幕。
const res = await fetch(`https://api.bilibili.com/x/player/wbi/v2?${params}`, { credentials: 'include' }).then(r => r.json());
const data = res && res.data;
// 关键诊断:B站接口会回显它实际为哪个视频应答。若与我们请求的 cid 不一致,
// 说明拿到的是别的视频的字幕——这就是“内容对不上”的根因。
log('[transcript] player/v2 回显', {
请求cid: ids.cid, 回显cid: data && data.aid != null ? data.cid : '(无)',
请求bvid: ids.bvid, 回显bvid: data && data.bvid,
回显aid: data && data.aid
});
const subtitle = data && data.subtitle;
const list = (subtitle && subtitle.subtitles) || [];
const echoed = data ? { aid: data.aid, bvid: data.bvid, cid: data.cid } : null;
return { list, echoed };
} catch (e) {
console.error('[BiliDL] fetchSubtitleList failed', e);
return { list: [], echoed: null };
}
}
function pickSubtitle(list) {
if (!list.length) return null;
// 优先级:人工中文字幕(质量高) > AI 中文字幕 > 第一条
// B 站的 lan:人工中文 = 'zh-CN' / 'zh-Hans';AI 中文 = 'ai-zh'
const isAi = s => /^ai-/i.test(s.lan || '') || !!s.ai_type || !!s.ai_status;
const manualZh = list.find(s => /zh/i.test(s.lan) && !isAi(s));
if (manualZh) {
log('[transcript] 选用人工中文字幕', manualZh.lan, manualZh.lan_doc);
return manualZh;
}
const aiZh = list.find(s => /zh/i.test(s.lan));
if (aiZh) {
log('[transcript] 未找到人工中文,回落 AI 中文字幕', aiZh.lan);
return aiZh;
}
return list[0];
}
async function fetchSubtitleBody(subtitleUrl) {
const url = subtitleUrl.startsWith('//') ? 'https:' + subtitleUrl : subtitleUrl.replace(/^http:/, 'https:');
const res = await fetch(url, { credentials: 'omit' });
if (!res.ok) throw new Error(`字幕 HTTP ${res.status}`);
const json = await res.json();
return json.body || [];
}
function formatTimestamp(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = n => String(n).padStart(2, '0');
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
function buildTranscript(body, title, format, withTime) {
const lines = body.map(c => {
const text = (c.content || '').trim();
if (!text) return '';
if (!withTime) return text;
const ts = formatTimestamp(c.from);
return format === 'md' ? `- **[${ts}]** ${text}` : `[${ts}] ${text}`;
}).filter(Boolean);
if (format === 'md') {
const head = `# ${title || '视频文稿'}\n\n`;
return head + (withTime ? lines.join('\n') : lines.join('\n\n')) + '\n';
}
return lines.join('\n') + '\n';
}
tsGoBtn.addEventListener('click', async () => {
const format = (tsView.querySelector('input[name="bdl-ts-fmt"]:checked') || {}).value || 'txt';
const withTime = !!(tsTimeChk && tsTimeChk.checked);
const meta = tsView.__meta || extractMeta(null);
tsGoBtn.disabled = true;
const origText = tsGoBtn.textContent;
tsGoBtn.textContent = '生成中…';
tsHint.textContent = '正在解析视频信息…';
try {
const ids = await getVideoIds();
log('[transcript] ids', ids, 'pageHref=', location.href);
if (!ids) throw new Error('未能识别当前视频');
// 文件名标题优先用"当前视频"的真实标题,避免面板缓存的旧标题
const fileTitle = ids.resolvedTitle || meta.title || 'bilibili_video';
tsHint.textContent = '正在获取字幕…';
const { list, echoed } = await fetchSubtitleList(ids);
// 关键诊断:对比“我们请求的 cid”与“B站接口实际应答的 cid”。
const cidMatch = !echoed || echoed.cid == null || String(echoed.cid) === String(ids.cid);
log('[transcript] player/v2 回显', { 请求cid: ids.cid, 回显cid: echoed && echoed.cid, 请求bvid: ids.bvid, 回显bvid: echoed && echoed.bvid, cidMatch });
log('[transcript] subtitle list', list.map(s => ({ lan: s.lan, lan_doc: s.lan_doc, url: s.subtitle_url })));
const sub = pickSubtitle(list);
if (!sub || !sub.subtitle_url) {
tsHint.textContent = '当前视频未提供 AI 字幕(UP 主未开启或视频无语音识别字幕),无法生成文稿。';
return;
}
log('[transcript] picked', { lan: sub.lan, lan_doc: sub.lan_doc, url: sub.subtitle_url });
const body = await fetchSubtitleBody(sub.subtitle_url);
log('[transcript] body sample', {
count: body.length,
first3: body.slice(0, 3).map(c => c.content),
savedTitle: meta.title
});
if (!body.length) {
tsHint.textContent = '字幕内容为空,无法生成文稿。';
return;
}
const text = buildTranscript(body, fileTitle, format, withTime);
const ext = format === 'md' ? 'md' : 'txt';
const res = await chrome.runtime.sendMessage({
type: 'SAVE_TRANSCRIPT',
title: fileTitle,
ext,
text
});
if (!res || !res.ok) throw new Error((res && res.error) || '保存失败');
const firstLine = (body.find(c => (c.content || '').trim()) || {}).content || '';
tsHint.textContent = `已导出(${body.length} 句,${ext.toUpperCase()})\n视频:${(fileTitle || '').slice(0, 24)}\n首句:${firstLine.slice(0, 30)}`;
tsHint.style.whiteSpace = 'pre-line';
} catch (e) {
console.error('[BiliDL] transcript failed', e);
tsHint.textContent = '生成失败:' + String(e && e.message || e);
} finally {
tsGoBtn.disabled = false;
tsGoBtn.textContent = origText;
}
});
function sanitizeForCmd(s) {
return String(s || 'output').replace(/["\\/:*?<>|]/g, '_').slice(0, 80);
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function formatBytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0; let v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return v.toFixed(v < 10 ? 2 : 1) + ' ' + u[i];
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'TASK_UPDATED' || msg.type === 'TASK_REMOVED') {
if (!panel.hasAttribute('hidden')) refreshTasks();
}
if (msg.type === 'TOGGLE_PANEL') {
fab.click();
}
});
})();