一键上传b站视频到webarchive
// ==UserScript==
// @name Biliarchiver
// @namespace https://github.com/saveweb/biliarchiver
// @version 0.2.12
// @description 一键上传b站视频到webarchive
// @author saveweb/biliarchiver contributors
// @match *://*.bilibili.com/video/*
// @match *://*.bilibili.com/list/*
// @match *://www.bilibili.com/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant unsafeWindow
// @connect api.bilibili.com
// @connect www.bilibili.com
// @connect bilibili.com
// @connect hdslb.com
// @connect bilivideo.com
// @connect bilivideo.cn
// @connect biliapi.net
// @connect biliapi.com
// @connect bilicdn1.com
// @connect acgvideo.com
// @connect akamaized.net
// @connect archive.org
// @connect s3.us.archive.org
// @connect cdn.jsdelivr.net
// @connect *
// @license AGPL-3.0-or-later
// ==/UserScript==
(function () {
'use strict';
const APP = Object.freeze({
name: 'Biliarchiver',
version: '0.2.12',
scanner: 'biliarchiver-userscript v0.2.12',
iaS3Base: 'https://s3.us.archive.org',
iaMetaBase: 'https://archive.org/metadata',
iaCheckIdentifier: 'https://archive.org/services/check_identifier.php?output=json&identifier=',
storagePrefix: 'biliarchiver_userscript_',
mp4boxCdns: Object.freeze([
'https://cdn.jsdelivr.net/npm/[email protected]/dist/mp4box.all.min.js',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/mp4box.all.min.js',
]),
});
const DEFAULT_SETTINGS = Object.freeze({
iaAccessKey: '',
iaSecretKey: '',
collection: 'opensource_movies',
qn: 64,
codecPreference: 'av1', // av1 | hevc | avc | bandwidth
danmakuSource: 'xml', // xml | protobuf
queueDerive: true,
overwriteExisting: false,
strictDashMerge: true,
});
const QUALITY_OPTIONS = Object.freeze([
{ qn: 16, label: '360P 流畅', maxHeight: 360 },
{ qn: 32, label: '480P 清晰', maxHeight: 480 },
{ qn: 64, label: '720P 高清', maxHeight: 720 },
{ qn: 74, label: '720P60 高帧率', maxHeight: 720 },
{ qn: 80, label: '1080P 高清', maxHeight: 1080 },
{ qn: 112, label: '1080P+ 高码率', maxHeight: 1080 },
{ qn: 116, label: '1080P60 高帧率', maxHeight: 1080 },
{ qn: 120, label: '4K 超清', maxHeight: 2160 },
{ qn: 125, label: 'HDR 真彩色', maxHeight: 2160 },
{ qn: 126, label: '杜比视界', maxHeight: 2160 },
{ qn: 127, label: '8K 超高清', maxHeight: 4320 },
]);
const QUALITY_LABELS = Object.freeze(Object.fromEntries(QUALITY_OPTIONS.map(item => [item.qn, item.label])));
const CODEC_LABELS = Object.freeze({
av1: 'AV1 优先,默认推荐',
hevc: 'HEVC/H.265 优先,体积通常较小',
avc: 'AVC/H.264 优先,兼容性最高',
bandwidth: '同清晰度内最高码率优先',
});
const CODEC_FALLBACKS = Object.freeze({
av1: ['av1', 'hevc', 'avc', 'other'],
hevc: ['hevc', 'av1', 'avc', 'other'],
avc: ['avc', 'hevc', 'av1', 'other'],
bandwidth: ['other'],
});
const state = {
busy: false,
currentTask: null,
wakeWanted: false,
wakeLock: null,
wakeLockSupported: 'wakeLock' in navigator,
};
const VIDEO_ID_REGEX = /(?:video\/|bvid=)(BV[a-zA-Z0-9]+|av\d+)/i;
const BV_REGEX = /(BV[0-9A-Za-z]+)/;
function storageKey(key) {
return APP.storagePrefix + key;
}
function getSetting(key) {
return GM_getValue(storageKey(key), DEFAULT_SETTINGS[key]);
}
function setSetting(key, value) {
GM_setValue(storageKey(key), value);
}
function getSettings() {
const result = {};
for (const key of Object.keys(DEFAULT_SETTINGS)) result[key] = getSetting(key);
return result;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function nowIso() {
return new Date().toISOString();
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return '未知大小';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
function safeFileName(input, fallback = 'untitled') {
const text = String(input || fallback)
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
return text || fallback;
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
function xmlCharsLegalizeString(str) {
return String(str ?? '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '');
}
function xmlCharsLegalize(obj) {
if (typeof obj === 'string') return xmlCharsLegalizeString(obj);
if (Array.isArray(obj)) return obj.map(xmlCharsLegalize);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = xmlCharsLegalize(v);
return out;
}
return obj;
}
function humanReadableUpperPartMap(string, backward = true) {
let s = String(string || '');
if (backward) s = s.split('').reverse().join('');
let result = '';
let steps = 0;
for (const ch of s) {
if (ch >= 'A' && ch <= 'Z') {
result += steps === 0 ? ch : `${steps}${ch}`;
steps = 0;
} else {
steps++;
}
}
return result;
}
function buildIdentifier(bvid, pageNumber) {
return `BiliBili-${bvid}_p${pageNumber}-${humanReadableUpperPartMap(bvid, true)}`;
}
function getUnsafeWindow() {
try { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; }
catch (_) { return window; }
}
function extractBvidFromUrl() {
const match = window.location.href.match(VIDEO_ID_REGEX) || window.location.href.match(BV_REGEX);
if (!match) return null;
const raw = match[1];
if (/^av\d+$/i.test(raw)) return null;
return raw;
}
function getPageNumberFromUrl() {
const p = Number(new URL(location.href).searchParams.get('p') || '1');
return Number.isFinite(p) && p > 0 ? Math.floor(p) : 1;
}
function gmRequest(details) {
return new Promise((resolve, reject) => {
const req = GM_xmlhttpRequest({
method: details.method || 'GET',
url: details.url,
headers: details.headers || {},
data: details.data,
responseType: details.responseType,
timeout: details.timeout || 0,
fetch: details.fetch,
anonymous: details.anonymous,
onprogress: details.onprogress,
onloadstart: details.onloadstart,
onload: (res) => {
const ok = res.status >= 200 && res.status < 300;
if (!ok) {
const text = res.responseText || res.statusText || '';
reject(new Error(`${details.method || 'GET'} ${details.url} HTTP ${res.status}: ${text.slice(0, 500)}`));
return;
}
resolve(res);
},
ontimeout: () => reject(new Error(`${details.method || 'GET'} ${details.url} 请求超时`)),
onabort: () => reject(new Error(`${details.method || 'GET'} ${details.url} 请求被取消`)),
onerror: (err) => {
const rawMessage = String(err?.error || err?.message || 'unknown');
let hint = '';
if (/not a part of the @connect list|not permitted|refused to connect/i.test(rawMessage)) {
let host = '未知域名';
try { host = new URL(details.url).hostname; } catch (_) {}
hint = `
提示:Tampermonkey 拦截了跨域请求,请确认脚本头部 @connect 已包含 ${host} 所属根域,或安装 v0.2.1 及以上版本。`;
}
reject(new Error(`${details.method || 'GET'} ${details.url} 网络错误:${rawMessage}${hint}`));
},
});
if (details.signal) {
details.signal.addEventListener('abort', () => {
try { req.abort(); } catch (_) {}
}, { once: true });
}
});
}
async function gmJson(url, options = {}) {
const res = await gmRequest({
method: options.method || 'GET',
url,
headers: {
Accept: 'application/json, text/plain, */*',
Referer: location.href,
Origin: 'https://www.bilibili.com',
...(options.headers || {}),
},
data: options.data,
responseType: 'json',
timeout: options.timeout || 30000,
});
if (res.response && typeof res.response === 'object') return res.response;
return JSON.parse(res.responseText);
}
async function gmText(url, options = {}) {
const res = await gmRequest({
method: options.method || 'GET',
url,
headers: options.headers || {},
responseType: 'text',
timeout: options.timeout || 30000,
});
return res.responseText || res.response;
}
async function gmArrayBuffer(url, options = {}) {
const res = await gmRequest({
method: options.method || 'GET',
url,
headers: {
Referer: 'https://www.bilibili.com/',
Origin: 'https://www.bilibili.com',
...(options.headers || {}),
},
responseType: 'arraybuffer',
timeout: options.timeout || 0,
onprogress: options.onprogress,
signal: options.signal,
});
return res.response;
}
async function gmBlob(url, options = {}) {
const res = await gmRequest({
method: options.method || 'GET',
url,
headers: options.headers || {},
responseType: 'blob',
timeout: options.timeout || 0,
onprogress: options.onprogress,
signal: options.signal,
});
return res.response;
}
const UI = {
ensure() {
if (document.getElementById('biliarchiver-popup')) return;
GM_addStyle(`
:root {
--ba-bg: #16181d;
--ba-panel: #20232b;
--ba-panel-2: #2b303b;
--ba-text: #f4f5f7;
--ba-sub: #aab0bd;
--ba-border: rgba(255,255,255,0.10);
--ba-accent: #00aeec;
--ba-good: #31c48d;
--ba-warn: #f59e0b;
--ba-bad: #ef4444;
--ba-shadow: rgba(0,0,0,.45);
}
#biliarchiver-popup {
position: fixed;
right: 24px;
top: 90px;
width: min(430px, calc(100vw - 36px));
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ba-text);
background: var(--ba-bg);
border: 1px solid var(--ba-border);
border-radius: 16px;
box-shadow: 0 18px 50px var(--ba-shadow);
overflow: hidden;
transform: translateZ(0);
}
#biliarchiver-popup.ba-hidden { display: none; }
.ba-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
background: linear-gradient(135deg, rgba(0,174,236,.20), rgba(32,35,43,0));
border-bottom: 1px solid var(--ba-border);
}
.ba-title { font-weight: 700; font-size: 15px; line-height: 1.35; }
.ba-subtitle { color: var(--ba-sub); font-size: 12px; margin-top: 3px; }
.ba-close, .ba-btn {
border: 0;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.ba-close { background: transparent; color: var(--ba-sub); font-size: 22px; width: 30px; height: 30px; }
.ba-close:hover { color: var(--ba-text); background: rgba(255,255,255,.06); }
.ba-body { padding: 14px 16px 16px; }
.ba-line { margin: 8px 0; color: var(--ba-sub); font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
.ba-line strong { color: var(--ba-text); }
.ba-progress-wrap { margin: 12px 0; }
.ba-progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; color: var(--ba-sub); }
.ba-progress {
height: 9px;
border-radius: 999px;
background: rgba(255,255,255,.10);
overflow: hidden;
}
.ba-progress > div {
height: 100%; width: 0%; background: var(--ba-accent); transition: width .25s ease;
}
.ba-detail-progress > div { background: var(--ba-good); }
.ba-progress.ba-indeterminate > div {
width: 42% !important;
background: linear-gradient(90deg, rgba(52,211,153,.15), var(--ba-good), rgba(52,211,153,.15));
animation: ba-indeterminate 1.2s ease-in-out infinite;
}
@keyframes ba-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(260%); }
}
.ba-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.ba-btn {
padding: 8px 11px;
color: var(--ba-text);
background: var(--ba-panel-2);
}
.ba-btn:hover { filter: brightness(1.12); }
.ba-btn-primary { background: var(--ba-accent); color: #fff; }
.ba-btn-danger { background: rgba(239,68,68,.20); color: #fecaca; }
.ba-btn-good { background: rgba(49,196,141,.20); color: #b7f7dc; }
.ba-log {
margin-top: 12px;
max-height: 180px;
overflow: auto;
background: rgba(0,0,0,.22);
border: 1px solid var(--ba-border);
border-radius: 10px;
padding: 9px;
color: #cbd5e1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.ba-status-good { color: var(--ba-good); }
.ba-status-warn { color: var(--ba-warn); }
.ba-status-bad { color: var(--ba-bad); }
#biliarchiver-modal {
position: fixed;
inset: 0;
z-index: 2147483647;
background: rgba(0,0,0,.62);
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.ba-modal-box {
width: min(560px, calc(100vw - 30px));
background: var(--ba-bg);
color: var(--ba-text);
border: 1px solid var(--ba-border);
border-radius: 16px;
box-shadow: 0 18px 50px var(--ba-shadow);
padding: 20px;
}
.ba-modal-box h3 { margin: 0 0 14px; font-size: 18px; }
.ba-field { margin: 12px 0; }
.ba-field label { display:block; color: var(--ba-sub); font-size: 13px; margin-bottom: 6px; }
.ba-input, .ba-select {
width: 100%; box-sizing: border-box;
border: 1px solid var(--ba-border);
border-radius: 10px;
padding: 10px 11px;
background: var(--ba-panel);
color: var(--ba-text);
outline: none;
}
.ba-check { display:flex; align-items:center; gap: 8px; color: var(--ba-sub); font-size: 13px; margin: 8px 0; }
.ba-modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top: 16px; }
`);
const el = document.createElement('div');
el.id = 'biliarchiver-popup';
el.className = 'ba-hidden';
el.innerHTML = `
<div class="ba-head">
<div>
<div class="ba-title">Biliarchiver</div>
<div class="ba-subtitle" id="ba-subtitle">待命</div>
</div>
<button class="ba-close" id="ba-close" title="隐藏">×</button>
</div>
<div class="ba-body">
<div class="ba-line" id="ba-phase"><strong>状态:</strong>空闲</div>
<div class="ba-line" id="ba-detail">等待从 Tampermonkey 菜单启动。</div>
<div class="ba-progress-wrap">
<div class="ba-progress-label"><span>总体进度</span><span id="ba-total-label">0%</span></div>
<div class="ba-progress"><div id="ba-total-bar"></div></div>
</div>
<div class="ba-progress-wrap">
<div class="ba-progress-label"><span>当前步骤</span><span id="ba-step-label">0%</span></div>
<div class="ba-progress ba-detail-progress"><div id="ba-step-bar"></div></div>
</div>
<div class="ba-actions" id="ba-actions"></div>
<div class="ba-log" id="ba-log"></div>
</div>`;
document.documentElement.appendChild(el);
document.getElementById('ba-close').onclick = () => el.classList.add('ba-hidden');
},
show() {
UI.ensure();
document.getElementById('biliarchiver-popup').classList.remove('ba-hidden');
},
setSubtitle(text) {
UI.ensure();
document.getElementById('ba-subtitle').textContent = text;
},
setPhase(text, cls = '') {
UI.ensure();
const el = document.getElementById('ba-phase');
el.className = 'ba-line ' + cls;
el.innerHTML = `<strong>状态:</strong>${escapeHtml(text)}`;
},
setDetail(text) {
UI.ensure();
document.getElementById('ba-detail').textContent = text;
},
setTotal(percent) {
UI.ensure();
const p = clamp(percent, 0, 100);
document.getElementById('ba-total-bar').style.width = `${p}%`;
document.getElementById('ba-total-label').textContent = `${p.toFixed(1)}%`;
},
setStep(percent) {
UI.ensure();
const p = clamp(percent, 0, 100);
const wrap = document.querySelector('.ba-detail-progress');
if (wrap) wrap.classList.remove('ba-indeterminate');
const bar = document.getElementById('ba-step-bar');
bar.style.transform = '';
bar.style.width = `${p}%`;
document.getElementById('ba-step-label').textContent = `${p.toFixed(1)}%`;
},
setStepIndeterminate(label = '上传中') {
UI.ensure();
const wrap = document.querySelector('.ba-detail-progress');
const bar = document.getElementById('ba-step-bar');
if (wrap) wrap.classList.add('ba-indeterminate');
bar.style.width = '42%';
document.getElementById('ba-step-label').textContent = label;
},
log(message) {
UI.ensure();
const box = document.getElementById('ba-log');
const line = `[${new Date().toLocaleTimeString()}] ${message}`;
box.textContent += (box.textContent ? '\n' : '') + line;
box.scrollTop = box.scrollHeight;
console.log(`[BiliarchiverUserscript] ${message}`);
},
clearLog() {
UI.ensure();
document.getElementById('ba-log').textContent = '';
},
setActions(actions) {
UI.ensure();
const box = document.getElementById('ba-actions');
box.innerHTML = '';
for (const action of actions) {
const btn = document.createElement('button');
btn.className = `ba-btn ${action.className || ''}`;
btn.textContent = action.text;
btn.onclick = action.onclick;
box.appendChild(btn);
}
},
toast(msg, bad = false) {
UI.show();
UI.setPhase(msg, bad ? 'ba-status-bad' : 'ba-status-good');
UI.log(msg);
},
};
function escapeHtml(text) {
return String(text ?? '').replace(/[&<>"']/g, ch => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[ch]));
}
const WakeLockManager = {
async acquire() {
state.wakeWanted = true;
if (!('wakeLock' in navigator)) {
UI.log('当前浏览器不支持 Screen Wake Lock API;上传仍会继续,但无法请求阻止息屏。');
return;
}
if (document.visibilityState !== 'visible') {
UI.log('当前标签页不可见,暂不申请 Wake Lock;回到标签页时会自动重试。');
return;
}
try {
if (state.wakeLock && !state.wakeLock.released) return;
state.wakeLock = await navigator.wakeLock.request('screen');
state.wakeLock.addEventListener('release', () => UI.log('Wake Lock 已被浏览器/系统释放。'));
UI.log('已申请 Screen Wake Lock:当前标签页可见时会尽量阻止息屏。');
} catch (err) {
UI.log(`申请 Wake Lock 失败:${err.message || err}`);
}
},
async release() {
state.wakeWanted = false;
if (state.wakeLock && !state.wakeLock.released) {
try { await state.wakeLock.release(); } catch (_) {}
}
state.wakeLock = null;
},
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && state.wakeWanted) {
WakeLockManager.acquire();
} else if (document.visibilityState !== 'visible' && state.wakeWanted) {
UI.log('已切换到其它标签页:Web 标准不保证隐藏页继续持有 Wake Lock;任务状态会保留并继续尝试上传。');
}
});
async function getCurrentVideoContext() {
const w = getUnsafeWindow();
const initial = w.__INITIAL_STATE__ || {};
let bvid = initial.bvid || initial.videoData?.bvid || w.vd?.bvid || extractBvidFromUrl();
if (!bvid) throw new Error('无法从当前页面识别 BV 号;请确认在 Bilibili 视频页。');
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) throw new Error(`当前只支持 BV 号页面,识别到:${bvid}`);
const pageNumber = Number(initial.p || w.vd?.embedPlayer?.p || getPageNumberFromUrl() || 1);
const view = await getBiliView(bvid, initial.videoData);
const page = (view.pages || []).find(p => Number(p.page) === Number(pageNumber)) || (view.pages || [])[0];
if (!page) throw new Error('无法取得视频分 P / cid 信息。');
const tags = await getBiliTags(bvid).catch(err => {
UI.log(`获取 tag 失败,继续上传:${err.message}`);
return [];
});
return {
bvid,
aid: view.aid,
cid: page.cid,
pageNumber: Number(page.page || pageNumber || 1),
pagePart: page.part || `P${pageNumber}`,
view,
tags,
owner: view.owner || {},
title: view.title || bvid,
desc: view.desc || '',
pubdate: view.pubdate,
pic: view.pic,
identifier: buildIdentifier(bvid, Number(page.page || pageNumber || 1)),
};
}
async function getBiliView(bvid, maybeInitialVideoData) {
if (maybeInitialVideoData && maybeInitialVideoData.bvid === bvid && maybeInitialVideoData.pages) {
return maybeInitialVideoData;
}
const res = await gmJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
if (res.code !== 0) throw new Error(`Bilibili view API 返回错误:${res.message || res.code}`);
return res.data;
}
async function getBiliTags(bvid) {
const res = await gmJson(`https://api.bilibili.com/x/tag/archive/tags?bvid=${encodeURIComponent(bvid)}`);
if (res.code !== 0) throw new Error(`Bilibili tag API 返回错误:${res.message || res.code}`);
return Array.isArray(res.data) ? res.data : [];
}
async function getPlayInfo(ctx, settings) {
const w = getUnsafeWindow();
const pagePlayInfo = w.__playinfo__?.data || w.__playinfo__;
const targetQn = normaliseTargetQn(settings.qn);
const params = new URLSearchParams({
bvid: ctx.bvid,
cid: String(ctx.cid),
qn: String(targetQn),
fnval: String(buildFnval(settings)),
fnver: '0',
fourk: targetQn >= 120 ? '1' : '0',
otype: 'json',
type: '',
});
const url = `https://api.bilibili.com/x/player/playurl?${params.toString()}`;
try {
UI.log(`请求播放流:目标 ${qualityLabel(targetQn)},编码偏好 ${codecPreferenceLabel(settings.codecPreference)}。`);
const res = await gmJson(url, { headers: { Cookie: document.cookie || '' } });
if (res.code !== 0) throw new Error(`Bilibili playurl API 返回错误:${res.message || res.code}`);
return res.data;
} catch (err) {
if (pagePlayInfo?.dash?.video?.length && pagePlayInfo?.dash?.audio?.length) {
UI.log(`播放流 API 失败,退回页面内 __playinfo__:${err?.message || err}`);
return pagePlayInfo;
}
throw err;
}
}
function buildFnval(settings) {
const qn = normaliseTargetQn(settings.qn);
let fnval = 16; // DASH
if (String(settings.codecPreference || '').toLowerCase() === 'av1') fnval |= 2048;
if (qn >= 120) fnval |= 128;
if (qn === 125) fnval |= 64;
if (qn === 126) fnval |= 512;
if (qn === 127) fnval |= 1024;
return fnval;
}
function streamUrl(media) {
return media?.baseUrl || media?.base_url || media?.url || media?.backupUrl?.[0] || media?.backup_url?.[0] || null;
}
function selectDashStreams(playInfo, settings) {
const dash = playInfo?.dash;
if (!dash?.video?.length || !dash?.audio?.length) {
throw new Error('未取得 DASH 音视频分离流;为满足“必须音视频合并”,本脚本不会上传 durl/flv 单流。');
}
const videos = dash.video.filter(v => streamUrl(v));
const audios = dash.audio.filter(a => streamUrl(a));
if (!videos.length || !audios.length) throw new Error('DASH 信息缺少可用的视频或音频 URL。');
const targetQn = normaliseTargetQn(settings.qn);
const qualityCandidates = selectQualityBucket(videos, targetQn);
const videoCandidates = [...qualityCandidates].sort((a, b) => compareVideoStreams(a, b, settings));
const audioCandidates = [...audios].sort((a, b) => Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || Number(b.id || 0) - Number(a.id || 0));
const video = videoCandidates[0];
const audio = audioCandidates[0];
const actualQn = streamQuality(video);
if (actualQn !== targetQn) {
UI.log(`目标清晰度 ${qualityLabel(targetQn)} 不完全可用,实际选择 ${qualityLabel(actualQn)}。`);
}
return { video, audio };
}
function selectQualityBucket(videos, targetQn) {
const exact = videos.filter(v => streamQuality(v) === targetQn);
if (exact.length) return exact;
const lower = videos.filter(v => streamQuality(v) <= targetQn);
if (lower.length) {
const bestLowerQn = Math.max(...lower.map(streamQuality));
return lower.filter(v => streamQuality(v) === bestLowerQn);
}
const higher = videos.filter(v => streamQuality(v) > targetQn);
if (higher.length) {
const nearestHigherQn = Math.min(...higher.map(streamQuality));
return higher.filter(v => streamQuality(v) === nearestHigherQn);
}
return videos;
}
function compareVideoStreams(a, b, settings) {
if (settings.codecPreference === 'bandwidth') {
return Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || codecRank(a, settings) - codecRank(b, settings);
}
return codecRank(a, settings) - codecRank(b, settings) || Number(b.bandwidth || 0) - Number(a.bandwidth || 0);
}
function codecRank(media, settings) {
const pref = String(settings.codecPreference || DEFAULT_SETTINGS.codecPreference).toLowerCase();
const fallbacks = CODEC_FALLBACKS[pref] || CODEC_FALLBACKS.av1;
const codec = normaliseCodec(media);
const rank = fallbacks.indexOf(codec);
return rank === -1 ? 999 : rank;
}
function normaliseCodec(media) {
const codecid = Number(media?.codecid || media?.codec_id || 0);
if (codecid === 13) return 'av1';
if (codecid === 12) return 'hevc';
if (codecid === 7) return 'avc';
const codec = String(media?.codecs || media?.codec || '').toLowerCase();
if (codec.startsWith('av01')) return 'av1';
if (codec.startsWith('hev') || codec.startsWith('hvc')) return 'hevc';
if (codec.startsWith('avc')) return 'avc';
return 'other';
}
function streamQuality(media) {
const qn = Number(media?.id || media?.quality || media?.qn || 0);
if (Number.isFinite(qn) && qn > 0) return qn;
const height = Number(media?.height || 0);
if (height <= 360) return 16;
if (height <= 480) return 32;
if (height <= 720) return 64;
if (height <= 1080) return 80;
if (height <= 2160) return 120;
return 127;
}
function normaliseTargetQn(value) {
const qn = Number(value || DEFAULT_SETTINGS.qn);
return QUALITY_LABELS[qn] ? qn : DEFAULT_SETTINGS.qn;
}
function qualityLabel(qn) {
const value = Number(qn || 0);
return QUALITY_LABELS[value] || `qn=${value || 'unknown'}`;
}
function codecPreferenceLabel(pref) {
return CODEC_LABELS[String(pref || '').toLowerCase()] || CODEC_LABELS[DEFAULT_SETTINGS.codecPreference];
}
function normaliseDanmakuSource(value) {
const source = String(value || DEFAULT_SETTINGS.danmakuSource).toLowerCase();
return source === 'protobuf' ? 'protobuf' : 'xml';
}
function danmakuSourceLabel(value) {
return normaliseDanmakuSource(value) === 'protobuf' ? 'protobuf 分段接口' : 'XML 实时弹幕池';
}
function streamCodecLabel(media) {
const codec = normaliseCodec(media);
if (codec === 'av1') return 'AV1';
if (codec === 'hevc') return 'HEVC/H.265';
if (codec === 'avc') return 'AVC/H.264';
return media?.codecs || media?.codec || `codecid=${media?.codecid || 'unknown'}`;
}
async function checkIdentifier(identifier) {
const res = await gmJson(APP.iaCheckIdentifier + encodeURIComponent(identifier), { headers: { Origin: 'https://archive.org' } });
return res;
}
function buildSourceUrl(ctx) {
return `https://www.bilibili.com/video/${ctx.bvid}/?p=${ctx.pageNumber}`;
}
const WBI_MIXIN_KEY_ENC_TAB = Object.freeze([
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, 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, 21, 56, 59, 6, 63,
57, 62, 11, 36, 20, 34, 44, 52,
]);
const wbiKeyCache = { key: '', ts: 0 };
function extractWbiKeyFromUrl(url) {
if (!url) return '';
try {
const absolute = new URL(String(url), location.href);
const file = absolute.pathname.split('/').pop() || '';
return file.split('.')[0] || '';
} catch (_) {
const file = String(url).split('/').pop() || '';
return file.split('.')[0] || '';
}
}
async function getWbiMixinKey() {
const now = Date.now();
if (wbiKeyCache.key && now - wbiKeyCache.ts < 10 * 60 * 1000) return wbiKeyCache.key;
const nav = await gmJson('https://api.bilibili.com/x/web-interface/nav', {
headers: { Cookie: document.cookie || '' },
timeout: 30000,
});
if (nav.code !== 0) throw new Error(`Bilibili nav API 返回错误:${nav.message || nav.code}`);
const imgKey = extractWbiKeyFromUrl(nav?.data?.wbi_img?.img_url);
const subKey = extractWbiKeyFromUrl(nav?.data?.wbi_img?.sub_url);
const rawKey = `${imgKey}${subKey}`;
if (rawKey.length < 64) throw new Error('Bilibili WBI key 不完整,无法签名字幕请求。');
const mixinKey = WBI_MIXIN_KEY_ENC_TAB.map(i => rawKey[i]).join('').slice(0, 32);
wbiKeyCache.key = mixinKey;
wbiKeyCache.ts = now;
return mixinKey;
}
function md5(input) {
function rotateLeft(value, shift) { return (value << shift) | (value >>> (32 - shift)); }
function addUnsigned(x, y) {
const x4 = x & 0x40000000;
const y4 = y & 0x40000000;
const x8 = x & 0x80000000;
const y8 = y & 0x80000000;
const result = (x & 0x3fffffff) + (y & 0x3fffffff);
if (x4 & y4) return result ^ 0x80000000 ^ x8 ^ y8;
if (x4 | y4) return (result & 0x40000000) ? result ^ 0xc0000000 ^ x8 ^ y8 : result ^ 0x40000000 ^ x8 ^ y8;
return result ^ x8 ^ y8;
}
function f(x, y, z) { return (x & y) | (~x & z); }
function g(x, y, z) { return (x & z) | (y & ~z); }
function h(x, y, z) { return x ^ y ^ z; }
function i(x, y, z) { return y ^ (x | ~z); }
function ff(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
function gg(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
function hh(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
function ii(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
function convertToWordArray(str) {
const byteCount = str.length;
const wordCount = (((byteCount + 8) - ((byteCount + 8) % 64)) / 64 + 1) * 16;
const words = new Array(wordCount - 1).fill(0);
let bytePosition = 0;
let byteIndex = 0;
while (byteIndex < byteCount) {
const wordIndex = (byteIndex - (byteIndex % 4)) / 4;
bytePosition = (byteIndex % 4) * 8;
words[wordIndex] |= str.charCodeAt(byteIndex) << bytePosition;
byteIndex++;
}
const wordIndex = (byteIndex - (byteIndex % 4)) / 4;
bytePosition = (byteIndex % 4) * 8;
words[wordIndex] |= 0x80 << bytePosition;
words[wordCount - 2] = byteCount << 3;
words[wordCount - 1] = byteCount >>> 29;
return words;
}
function wordToHex(value) {
let out = '';
for (let count = 0; count <= 3; count++) {
out += (`0${((value >>> (count * 8)) & 255).toString(16)}`).slice(-2);
}
return out;
}
const x = convertToWordArray(unescape(encodeURIComponent(String(input))));
let a = 0x67452301;
let b = 0xefcdab89;
let c = 0x98badcfe;
let d = 0x10325476;
for (let k = 0; k < x.length; k += 16) {
const aa = a, bb = b, cc = c, dd = d;
a = ff(a, b, c, d, x[k + 0], 7, 0xd76aa478); d = ff(d, a, b, c, x[k + 1], 12, 0xe8c7b756); c = ff(c, d, a, b, x[k + 2], 17, 0x242070db); b = ff(b, c, d, a, x[k + 3], 22, 0xc1bdceee);
a = ff(a, b, c, d, x[k + 4], 7, 0xf57c0faf); d = ff(d, a, b, c, x[k + 5], 12, 0x4787c62a); c = ff(c, d, a, b, x[k + 6], 17, 0xa8304613); b = ff(b, c, d, a, x[k + 7], 22, 0xfd469501);
a = ff(a, b, c, d, x[k + 8], 7, 0x698098d8); d = ff(d, a, b, c, x[k + 9], 12, 0x8b44f7af); c = ff(c, d, a, b, x[k + 10], 17, 0xffff5bb1); b = ff(b, c, d, a, x[k + 11], 22, 0x895cd7be);
a = ff(a, b, c, d, x[k + 12], 7, 0x6b901122); d = ff(d, a, b, c, x[k + 13], 12, 0xfd987193); c = ff(c, d, a, b, x[k + 14], 17, 0xa679438e); b = ff(b, c, d, a, x[k + 15], 22, 0x49b40821);
a = gg(a, b, c, d, x[k + 1], 5, 0xf61e2562); d = gg(d, a, b, c, x[k + 6], 9, 0xc040b340); c = gg(c, d, a, b, x[k + 11], 14, 0x265e5a51); b = gg(b, c, d, a, x[k + 0], 20, 0xe9b6c7aa);
a = gg(a, b, c, d, x[k + 5], 5, 0xd62f105d); d = gg(d, a, b, c, x[k + 10], 9, 0x02441453); c = gg(c, d, a, b, x[k + 15], 14, 0xd8a1e681); b = gg(b, c, d, a, x[k + 4], 20, 0xe7d3fbc8);
a = gg(a, b, c, d, x[k + 9], 5, 0x21e1cde6); d = gg(d, a, b, c, x[k + 14], 9, 0xc33707d6); c = gg(c, d, a, b, x[k + 3], 14, 0xf4d50d87); b = gg(b, c, d, a, x[k + 8], 20, 0x455a14ed);
a = gg(a, b, c, d, x[k + 13], 5, 0xa9e3e905); d = gg(d, a, b, c, x[k + 2], 9, 0xfcefa3f8); c = gg(c, d, a, b, x[k + 7], 14, 0x676f02d9); b = gg(b, c, d, a, x[k + 12], 20, 0x8d2a4c8a);
a = hh(a, b, c, d, x[k + 5], 4, 0xfffa3942); d = hh(d, a, b, c, x[k + 8], 11, 0x8771f681); c = hh(c, d, a, b, x[k + 11], 16, 0x6d9d6122); b = hh(b, c, d, a, x[k + 14], 23, 0xfde5380c);
a = hh(a, b, c, d, x[k + 1], 4, 0xa4beea44); d = hh(d, a, b, c, x[k + 4], 11, 0x4bdecfa9); c = hh(c, d, a, b, x[k + 7], 16, 0xf6bb4b60); b = hh(b, c, d, a, x[k + 10], 23, 0xbebfbc70);
a = hh(a, b, c, d, x[k + 13], 4, 0x289b7ec6); d = hh(d, a, b, c, x[k + 0], 11, 0xeaa127fa); c = hh(c, d, a, b, x[k + 3], 16, 0xd4ef3085); b = hh(b, c, d, a, x[k + 6], 23, 0x04881d05);
a = hh(a, b, c, d, x[k + 9], 4, 0xd9d4d039); d = hh(d, a, b, c, x[k + 12], 11, 0xe6db99e5); c = hh(c, d, a, b, x[k + 15], 16, 0x1fa27cf8); b = hh(b, c, d, a, x[k + 2], 23, 0xc4ac5665);
a = ii(a, b, c, d, x[k + 0], 6, 0xf4292244); d = ii(d, a, b, c, x[k + 7], 10, 0x432aff97); c = ii(c, d, a, b, x[k + 14], 15, 0xab9423a7); b = ii(b, c, d, a, x[k + 5], 21, 0xfc93a039);
a = ii(a, b, c, d, x[k + 12], 6, 0x655b59c3); d = ii(d, a, b, c, x[k + 3], 10, 0x8f0ccc92); c = ii(c, d, a, b, x[k + 10], 15, 0xffeff47d); b = ii(b, c, d, a, x[k + 1], 21, 0x85845dd1);
a = ii(a, b, c, d, x[k + 8], 6, 0x6fa87e4f); d = ii(d, a, b, c, x[k + 15], 10, 0xfe2ce6e0); c = ii(c, d, a, b, x[k + 6], 15, 0xa3014314); b = ii(b, c, d, a, x[k + 13], 21, 0x4e0811a1);
a = ii(a, b, c, d, x[k + 4], 6, 0xf7537e82); d = ii(d, a, b, c, x[k + 11], 10, 0xbd3af235); c = ii(c, d, a, b, x[k + 2], 15, 0x2ad7d2bb); b = ii(b, c, d, a, x[k + 9], 21, 0xeb86d391);
a = addUnsigned(a, aa); b = addUnsigned(b, bb); c = addUnsigned(c, cc); d = addUnsigned(d, dd);
}
return `${wordToHex(a)}${wordToHex(b)}${wordToHex(c)}${wordToHex(d)}`.toLowerCase();
}
function makeQueryString(params) {
return Object.keys(params)
.sort()
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]).replace(/[!'()*]/g, ''))}`)
.join('&');
}
async function makeSignedWbiQuery(params) {
const mixinKey = await getWbiMixinKey();
const signedParams = { ...params, wts: Math.round(Date.now() / 1000) };
const query = makeQueryString(signedParams);
return `${query}&w_rid=${md5(query + mixinKey)}`;
}
function normalizeSubtitleUrl(url) {
if (!url) return '';
const text = String(url);
if (text.startsWith('//')) return `https:${text}`;
try { return new URL(text, location.href).href; }
catch (_) { return text; }
}
function safeSubtitleLang(input, fallback = 'und') {
const text = String(input || fallback).replace(/[^0-9A-Za-z_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32);
return text || fallback;
}
function formatSrtTime(seconds) {
const totalMs = Math.max(0, Math.round(Number(seconds || 0) * 1000));
const ms = totalMs % 1000;
const totalSeconds = Math.floor(totalMs / 1000);
const s = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const m = totalMinutes % 60;
const h = Math.floor(totalMinutes / 60);
const pad2 = n => String(n).padStart(2, '0');
const pad3 = n => String(n).padStart(3, '0');
return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(ms)}`;
}
function subtitleJsonToSrt(json) {
const body = Array.isArray(json?.body) ? json.body : [];
return body.map((line, idx) => {
const start = formatSrtTime(line.from);
const end = formatSrtTime(line.to);
const content = xmlCharsLegalizeString(line.content || '').replace(/\r\n?/g, '\n').trim();
return `${idx + 1}\n${start} --> ${end}\n${content}\n`;
}).join('\n');
}
async function getBiliSubtitleInfo(ctx) {
const baseParams = { bvid: ctx.bvid, cid: String(ctx.cid) };
const candidates = [];
try {
const signedQuery = await makeSignedWbiQuery(baseParams);
candidates.push(`https://api.bilibili.com/x/player/wbi/v2?${signedQuery}`);
} catch (err) {
UI.log(`WBI 签名字幕请求准备失败,将尝试无签名接口:${err.message || err}`);
}
candidates.push(`https://api.bilibili.com/x/player/wbi/v2?${makeQueryString(baseParams)}`);
candidates.push(`https://api.bilibili.com/x/player/v2?${makeQueryString(baseParams)}`);
let lastErr = null;
for (const url of candidates) {
try {
const info = await gmJson(url, {
headers: { Cookie: document.cookie || '' },
timeout: 30000,
});
if (info.code !== 0) throw new Error(`Bilibili player API 返回错误:${info.message || info.code}`);
return info.data || {};
} catch (err) {
lastErr = err;
UI.log(`字幕信息接口失败,尝试备用:${err.message || err}`);
}
}
throw lastErr || new Error('无法取得字幕信息。');
}
async function fetchSubtitleFiles(ctx) {
UI.setPhase('获取 CC 字幕', 'ba-status-warn');
UI.setStepIndeterminate('检查字幕');
const playerInfo = await getBiliSubtitleInfo(ctx);
if (playerInfo.need_login_subtitle) {
UI.log('该视频字幕接口提示需要登录;当前浏览器 Cookie 不足时可能无法取得字幕。');
}
const subtitles = Array.isArray(playerInfo?.subtitle?.subtitles) ? playerInfo.subtitle.subtitles : [];
const available = subtitles.filter(item => item?.subtitle_url);
if (!available.length) {
UI.log('未发现可下载的 CC/AI 字幕,跳过字幕文件。');
UI.setStep(100);
return [];
}
const files = [];
for (let idx = 0; idx < available.length; idx++) {
const item = available[idx];
const lan = safeSubtitleLang(item.lan || item.lan_doc || `sub${idx + 1}`);
const url = normalizeSubtitleUrl(item.subtitle_url);
try {
UI.setDetail(`下载字幕:${lan} (${idx + 1}/${available.length})`);
const json = await gmJson(url, {
headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com' },
timeout: 60000,
});
const srt = subtitleJsonToSrt(json);
if (!srt.trim()) {
UI.log(`字幕 ${lan} 内容为空,已跳过。`);
continue;
}
const name = `${safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`)}.${lan}.srt`;
files.push({
name,
blob: new Blob([srt], { type: 'application/x-subrip;charset=utf-8' }),
meta: {
id: item.id || item.id_str || '',
lan,
lan_doc: item.lan_doc || '',
ai_status: item.ai_status,
ai_type: item.ai_type,
original_url: url,
},
});
UI.log(`字幕已转换为 SRT:${name}`);
} catch (err) {
UI.log(`字幕 ${lan} 下载/转换失败,已跳过:${err.message || err}`);
}
UI.setStep(((idx + 1) / available.length) * 100);
}
return files;
}
async function fetchReplyFile(ctx) {
UI.setPhase('获取视频评论', 'ba-status-warn');
UI.setStepIndeterminate('获取评论');
const params = new URLSearchParams({
type: '1',
oid: String(ctx.aid),
sort: '1',
ps: '20',
pn: '1',
});
const url = `https://api.bilibili.com/x/v2/reply?${params.toString()}`;
const json = await gmJson(url, {
headers: { Cookie: document.cookie || '' },
timeout: 30000,
});
if (json.code !== 0) {
throw new Error(`Bilibili 评论 API 返回错误:${json.message || json.code}`);
}
const replies = Array.isArray(json?.data?.replies) ? json.data.replies : [];
const hots = Array.isArray(json?.data?.hots) ? json.data.hots : [];
const name = `${safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`)}.replies.json`;
const text = JSON.stringify(json, null, 2);
UI.setStep(100);
UI.log(`视频评论已保存:${name},普通评论 ${replies.length} 条,热评 ${hots.length} 条。`);
return {
name,
blob: new Blob([text], { type: 'application/json;charset=utf-8' }),
meta: {
source: url,
sort: '1',
ps: 20,
pn: 1,
replies_count: replies.length,
hot_replies_count: hots.length,
cursor_all_count: json?.data?.cursor?.all_count,
},
};
}
function parseBiliDanmakuXml(xmlText) {
const doc = new DOMParser().parseFromString(String(xmlText || ''), 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('弹幕 XML 解析失败。');
const nodes = Array.from(doc.getElementsByTagName('d'));
const items = [];
for (const node of nodes) {
const p = String(node.getAttribute('p') || '').split(',');
const time = Number(p[0]);
const mode = Number(p[1] || 1);
const fontsize = Number(p[2] || 25);
const color = Number(p[3] || 16777215);
const content = xmlCharsLegalizeString(node.textContent || '').trim();
if (!Number.isFinite(time) || !content) continue;
items.push({
time,
mode,
fontsize: Number.isFinite(fontsize) && fontsize > 0 ? fontsize : 25,
color: Number.isFinite(color) ? color : 16777215,
timestamp: Number(p[4] || 0),
pool: Number(p[5] || 0),
midHash: p[6] || '',
id: p[7] || '',
content,
});
}
items.sort((a, b) => a.time - b.time || Number(a.id || 0) - Number(b.id || 0));
return items;
}
function readProtoVarint(bytes, offset) {
let value = 0n;
let shift = 0n;
while (offset < bytes.length) {
const b = bytes[offset++];
value |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) return { value, offset };
shift += 7n;
if (shift > 70n) throw new Error('protobuf varint 过长。');
}
throw new Error('protobuf varint 截断。');
}
function skipProtoField(bytes, offset, wireType) {
if (wireType === 0) return readProtoVarint(bytes, offset).offset;
if (wireType === 1) return offset + 8;
if (wireType === 2) {
const len = readProtoVarint(bytes, offset);
return len.offset + Number(len.value);
}
if (wireType === 5) return offset + 4;
throw new Error(`不支持的 protobuf wire type:${wireType}`);
}
const protoUtf8Decoder = new TextDecoder('utf-8');
function protoBytesToString(bytes, start, length) {
return protoUtf8Decoder.decode(bytes.subarray(start, start + length));
}
function parseBiliDanmakuProtoElem(bytes) {
const item = {
time: 0,
mode: 1,
fontsize: 25,
color: 16777215,
timestamp: 0,
pool: 0,
midHash: '',
id: '',
content: '',
};
let offset = 0;
while (offset < bytes.length) {
const tag = readProtoVarint(bytes, offset);
offset = tag.offset;
const field = Number(tag.value >> 3n);
const wireType = Number(tag.value & 7n);
if (wireType === 0) {
const v = readProtoVarint(bytes, offset);
offset = v.offset;
if (field === 1) item.id = v.value.toString();
else if (field === 2) item.time = Number(v.value) / 1000;
else if (field === 3) item.mode = Number(v.value);
else if (field === 4) item.fontsize = Number(v.value);
else if (field === 5) item.color = Number(v.value);
else if (field === 8) item.timestamp = Number(v.value);
else if (field === 11) item.pool = Number(v.value);
} else if (wireType === 2) {
const len = readProtoVarint(bytes, offset);
offset = len.offset;
const n = Number(len.value);
if (offset + n > bytes.length) throw new Error('protobuf 字符串字段截断。');
if (field === 6) item.midHash = protoBytesToString(bytes, offset, n);
else if (field === 7) item.content = xmlCharsLegalizeString(protoBytesToString(bytes, offset, n)).trim();
else if (field === 12) item.id = protoBytesToString(bytes, offset, n) || item.id;
offset += n;
} else {
offset = skipProtoField(bytes, offset, wireType);
}
}
return item;
}
function parseBiliDanmakuProto(buffer) {
const bytes = new Uint8Array(buffer || new ArrayBuffer(0));
const items = [];
let offset = 0;
while (offset < bytes.length) {
const tag = readProtoVarint(bytes, offset);
offset = tag.offset;
const field = Number(tag.value >> 3n);
const wireType = Number(tag.value & 7n);
if (field === 1 && wireType === 2) {
const len = readProtoVarint(bytes, offset);
offset = len.offset;
const n = Number(len.value);
if (offset + n > bytes.length) throw new Error('protobuf elems 字段截断。');
const item = parseBiliDanmakuProtoElem(bytes.subarray(offset, offset + n));
if (Number.isFinite(item.time) && item.content) items.push(item);
offset += n;
} else {
offset = skipProtoField(bytes, offset, wireType);
}
}
items.sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
return items;
}
function dedupeDanmakuItems(items) {
const seen = new Set();
const out = [];
for (const item of items) {
const key = item.id || `${item.time}|${item.mode}|${item.fontsize}|${item.color}|${item.timestamp}|${item.pool}|${item.content}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
out.sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
return out;
}
function currentPageDurationSeconds(ctx) {
const page = (ctx?.view?.pages || []).find(p => Number(p.page) === Number(ctx.pageNumber));
const candidates = [page?.duration, ctx?.view?.duration, ctx?.duration];
for (const value of candidates) {
const n = Number(value);
if (Number.isFinite(n) && n > 0) return n;
}
return 360;
}
function secondsToAssTime(seconds) {
const totalCs = Math.max(0, Math.round(Number(seconds || 0) * 100));
const cs = totalCs % 100;
const totalSeconds = Math.floor(totalCs / 100);
const s = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const m = totalMinutes % 60;
const h = Math.floor(totalMinutes / 60);
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
}
function assColorFromRgbInt(color) {
const value = Number(color) >>> 0;
const r = (value >> 16) & 255;
const g = (value >> 8) & 255;
const b = value & 255;
const hex = n => n.toString(16).padStart(2, '0').toUpperCase();
return `&H${hex(b)}${hex(g)}${hex(r)}&`;
}
function escapeAssText(text) {
return String(text ?? '')
.replace(/[{}]/g, ch => ch === '{' ? '{' : '}')
.replace(/\\/g, '\')
.replace(/\r\n?|\n/g, '\\N');
}
function estimateDanmakuTextWidth(text, fontSize) {
const lines = String(text || '').split(/\r\n?|\n/);
let maxLen = 0;
for (const line of lines) maxLen = Math.max(maxLen, Array.from(line).length);
return maxLen * fontSize;
}
function assBlackOutlineOverride(color) {
return (Number(color) >>> 0) === 0x000000 ? '\\3c&H666666&' : '';
}
function danmakuInternalMode(mode) {
if (mode === 5) return 1; // top still
if (mode === 4) return 2; // bottom still
if (mode === 6) return 3; // reverse marquee
if (mode === 7) return 1; // keep previous userscript behaviour for special danmaku
return 0; // normal marquee
}
function makeDanmakuRows(height, reserveBlank) {
return Array.from({ length: 3 }, () =>
Array.from({ length: 4 }, () => new Array(Math.max(1, height - reserveBlank + 1)).fill(null))
);
}
function markDanmakuRows(rows, comment, row) {
const poolRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
const limit = Math.min(poolRows.length, row + Math.ceil(comment.partSize));
for (let i = row; i < limit; i++) poolRows[i] = comment;
}
function unmarkDanmakuRows(rows, pool, mode) {
const poolRows = rows[pool]?.[mode] || rows[0][mode];
for (let i = 0; i < poolRows.length; i++) poolRows[i] = null;
}
function testDanmakuFreeRow(rows, comment, row, width, height, reserveBlank) {
let free = 0;
const rowMax = height - reserveBlank;
const modeRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
let targetRow = null;
if (comment.mode === 1 || comment.mode === 2) {
while (row < rowMax && free < comment.partSize) {
if (targetRow !== modeRows[row]) {
targetRow = modeRows[row];
if (targetRow !== null && targetRow.progress + targetRow.duration - 0.1 > comment.progress) break;
}
row++;
free++;
}
} else {
let div = comment.maxLen + width;
const thresholdTime = div !== 0 ? comment.progress - comment.duration * (1 - width / div) : comment.progress - comment.duration;
while (row < rowMax && free < comment.partSize) {
if (targetRow !== modeRows[row]) {
targetRow = modeRows[row];
if (targetRow !== null) {
div = targetRow.maxLen + width;
if (div !== 0 && (targetRow.progress > thresholdTime || targetRow.progress + targetRow.maxLen * targetRow.duration / div > comment.progress)) break;
}
}
row++;
free++;
}
}
return free;
}
function findAlternativeDanmakuRow(rows, comment, height, reserveBlank) {
const modeRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
let result = 0;
const rowLimit = height - reserveBlank - Math.ceil(comment.partSize);
for (let row = 0; row < rowLimit; row++) {
if (modeRows[row] === null) return row;
if (modeRows[row].progress < modeRows[result].progress) result = row;
}
return result;
}
function placeDanmakuComment(rows, comment, width, height, reserveBlank) {
let row = 0;
const rowMax = height - reserveBlank - comment.partSize;
if (rowMax <= 0) {
if (comment.mode === 0 || comment.mode === 3) {
comment.row = Math.round((height - reserveBlank) / 2);
comment.align = 4;
} else {
comment.row = 0;
}
return;
}
let occupied = true;
while (row <= rowMax) {
const freeRow = testDanmakuFreeRow(rows, comment, row, width, height, reserveBlank);
if (freeRow >= comment.partSize) {
markDanmakuRows(rows, comment, row);
occupied = false;
break;
}
row += freeRow || 1;
}
if (occupied) {
row = findAlternativeDanmakuRow(rows, comment, height, reserveBlank);
if (row === 0) unmarkDanmakuRows(rows, comment.pool, comment.mode);
markDanmakuRows(rows, comment, row);
}
comment.row = row;
}
function danmakuItemsToAss(items, options = {}) {
const width = Math.max(320, Math.round(Number(options.width || 1920)));
const height = Math.max(240, Math.round(Number(options.height || 1080)));
const reserveBlank = 0;
const baseFontSize = Math.max(18, Math.round(width / 40));
const lines = [];
const header = [
'[Script Info]',
'; Generated by Biliarchiver userscript',
'ScriptType: v4.00+',
`PlayResX: ${width}`,
`PlayResY: ${height}`,
'WrapStyle: 2',
'ScaledBorderAndShadow: yes',
'YCbCr Matrix: TV.709',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
`Style: Danmaku,Microsoft YaHei,${baseFontSize},&H00FFFFFF,&H00FFFFFF,&H00000000,&H96000000,0,0,0,0,100,100,0,0,1,1.2,0,7,20,20,20,1`,
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
const comments = [...items].sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
const rows = makeDanmakuRows(height, reserveBlank);
for (const item of comments) {
const start = Number(item.time || 0);
const mode = danmakuInternalMode(item.mode);
const duration = mode === 1 || mode === 2 ? 4.5 : 7.5;
const end = start + duration;
const fontSize = Math.max(14, Math.round(baseFontSize * clamp(item.fontsize / 25, 0.72, 1.6)));
const rawContent = String(item.content || '');
const parts = rawContent.split(/\r\n?|\n/);
const comment = {
progress: start,
duration,
content: rawContent,
mode,
pool: Math.max(0, Math.min(2, Math.floor(Number(item.pool || 0)))),
size: fontSize,
color: Number(item.color) >>> 0,
row: 0,
lines: Math.max(1, parts.length),
partSize: Math.max(1, fontSize * Math.max(1, parts.length)),
maxLen: estimateDanmakuTextWidth(rawContent, fontSize),
align: 0,
deltaL: 0,
};
placeDanmakuComment(rows, comment, width, height, reserveBlank);
const colour = assColorFromRgbInt(comment.color);
const blackOutline = assBlackOutlineOverride(comment.color);
const text = escapeAssText(comment.content);
const styles = [];
if (comment.mode === 1) {
if (comment.lines > 1) {
styles.push(`\\pos(${Math.round((width - comment.maxLen) / 2)},${Math.round(comment.row)})`);
} else {
styles.push(`\\an8\\pos(${Math.round(width / 2)},${Math.round(comment.row)})`);
}
} else if (comment.mode === 2) {
const y = height - reserveBlank - comment.row;
if (comment.lines > 1) {
styles.push(`\\an1\\pos(${Math.round((width - comment.maxLen) / 2)},${Math.round(y)})`);
} else {
styles.push(`\\an2\\pos(${Math.round(width / 2)},${Math.round(y)})`);
}
} else if (comment.mode === 3) {
if (comment.align === 4) styles.push('\\an4');
styles.push(`\\move(${Math.round(-comment.maxLen - 20 + comment.deltaL)},${Math.round(comment.row)},${Math.round(width + 20)},${Math.round(comment.row)})`);
} else {
if (comment.align === 4) styles.push('\\an4');
styles.push(`\\move(${Math.round(width + 20 - comment.deltaL)},${Math.round(comment.row)},${Math.round(-comment.maxLen - 20)},${Math.round(comment.row)})`);
}
styles.push(`\\fs${fontSize}`);
styles.push(`\\c${colour}`);
if (blackOutline) styles.push(blackOutline);
lines.push(`Dialogue: 0,${secondsToAssTime(start)},${secondsToAssTime(end)},Danmaku,,0000,0000,0000,,{${styles.join('')}}${text}`);
}
return `${header.concat(lines).join('\n')}\n`;
}
function selectedVideoResolution(selectedStreams) {
const video = selectedStreams?.video || {};
const width = Number(video.width || video.video_width || video.track_width || 1920);
const height = Number(video.height || video.video_height || video.track_height || 1080);
return {
width: Number.isFinite(width) && width > 0 ? width : 1920,
height: Number.isFinite(height) && height > 0 ? height : 1080,
};
}
async function fetchDanmakuFiles(ctx, selectedStreams, settings = getSettings()) {
const source = normaliseDanmakuSource(settings.danmakuSource);
if (source === 'protobuf') return fetchDanmakuProtobufFiles(ctx, selectedStreams);
return fetchDanmakuXmlFiles(ctx, selectedStreams);
}
async function fetchDanmakuXmlFiles(ctx, selectedStreams) {
UI.setPhase('获取弹幕', 'ba-status-warn');
UI.setStepIndeterminate('下载 XML 弹幕');
const url = `https://api.bilibili.com/x/v1/dm/list.so?oid=${encodeURIComponent(ctx.cid)}`;
const xmlText = await gmText(url, {
headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com', Cookie: document.cookie || '' },
timeout: 60000,
});
if (!String(xmlText || '').trim()) throw new Error('弹幕 XML 内容为空。');
const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
const files = [{
name: `${baseName}.danmaku.xml`,
blob: new Blob([xmlText], { type: 'application/xml;charset=utf-8' }),
meta: { source: url, format: 'bilibili-xml' },
}];
const items = parseBiliDanmakuXml(xmlText);
if (!items.length) {
UI.log('弹幕 XML 中没有可转换条目,只上传原始 XML。');
UI.setStep(100);
return files;
}
const resolution = selectedVideoResolution(selectedStreams);
UI.setDetail(`转换弹幕 ASS:${items.length} 条,画布 ${resolution.width}x${resolution.height}`);
const assText = danmakuItemsToAss(items, resolution);
const estimatedWorkingSet = Math.max(0, String(xmlText).length * 2 + assText.length * 2 + items.length * 360);
const assName = `${baseName}.danmaku.ass`;
files.push({
name: assName,
blob: new Blob([assText], { type: 'text/x-ssa;charset=utf-8' }),
meta: {
source: url,
format: 'ass',
danmaku_source: 'xml',
danmaku_count: items.length,
width: resolution.width,
height: resolution.height,
estimated_working_set_bytes: estimatedWorkingSet,
},
});
UI.setStep(100);
UI.log(`弹幕已保存:${baseName}.danmaku.xml,并转换为 ASS:${assName}(${items.length} 条)。`);
return files;
}
async function fetchDanmakuProtobufFiles(ctx, selectedStreams) {
UI.setPhase('获取弹幕', 'ba-status-warn');
const duration = currentPageDurationSeconds(ctx);
const segmentCount = Math.max(1, Math.ceil(duration / 360));
const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
const allItems = [];
const sources = [];
UI.log(`使用 protobuf 分段弹幕接口:预计 ${segmentCount} 个 6 分钟分包。`);
for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex++) {
const params = new URLSearchParams({
type: '1',
oid: String(ctx.cid),
segment_index: String(segmentIndex),
});
if (ctx.aid) params.set('pid', String(ctx.aid));
const url = `https://api.bilibili.com/x/v2/dm/web/seg.so?${params.toString()}`;
UI.setDetail(`下载 protobuf 弹幕分包:${segmentIndex}/${segmentCount}`);
const buffer = await gmArrayBuffer(url, {
headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com', Cookie: document.cookie || '' },
timeout: 60000,
});
const items = parseBiliDanmakuProto(buffer);
allItems.push(...items);
sources.push(url);
UI.setStep((segmentIndex / segmentCount) * 70);
}
const items = dedupeDanmakuItems(allItems);
const jsonText = JSON.stringify({
source: 'https://api.bilibili.com/x/v2/dm/web/seg.so',
bvid: ctx.bvid,
aid: ctx.aid,
cid: ctx.cid,
page: ctx.pageNumber,
duration,
segment_count: segmentCount,
danmaku_count: items.length,
items,
}, null, 2);
const files = [{
name: `${baseName}.danmaku.protobuf.json`,
blob: new Blob([jsonText], { type: 'application/json;charset=utf-8' }),
meta: { source: 'https://api.bilibili.com/x/v2/dm/web/seg.so', format: 'bilibili-protobuf-json', segment_count: segmentCount, danmaku_count: items.length },
}];
if (!items.length) {
UI.log('protobuf 分段弹幕中没有可转换条目,只上传解析 JSON。');
UI.setStep(100);
return files;
}
const resolution = selectedVideoResolution(selectedStreams);
UI.setDetail(`转换 protobuf 弹幕 ASS:${items.length} 条,画布 ${resolution.width}x${resolution.height}`);
const assText = danmakuItemsToAss(items, resolution);
const estimatedWorkingSet = Math.max(0, jsonText.length * 2 + assText.length * 2 + items.length * 360);
const assName = `${baseName}.danmaku.ass`;
files.push({
name: assName,
blob: new Blob([assText], { type: 'text/x-ssa;charset=utf-8' }),
meta: {
source: 'https://api.bilibili.com/x/v2/dm/web/seg.so',
format: 'ass',
danmaku_source: 'protobuf',
danmaku_count: items.length,
segment_count: segmentCount,
width: resolution.width,
height: resolution.height,
estimated_working_set_bytes: estimatedWorkingSet,
},
});
UI.setStep(100);
UI.log(`protobuf 弹幕已保存:${baseName}.danmaku.protobuf.json,并转换为 ASS:${assName}(${items.length} 条,${segmentCount} 个分包)。`);
return files;
}
function normalizeTitleForCompare(text) {
return String(text ?? '')
.normalize('NFKC')
.replace(/[\u3000\s]+/g, ' ')
.replace(/[:﹕︓]/g, ':')
.trim();
}
function buildArchiveTitle(ctx) {
const mainTitle = String(ctx.title || ctx.bvid).trim();
const pageNumber = Number(ctx.pageNumber || 1);
const pagePart = String(ctx.pagePart || `P${pageNumber}`).trim();
const mainCmp = normalizeTitleForCompare(mainTitle);
const partCmp = normalizeTitleForCompare(pagePart);
if (!pagePart || partCmp === mainCmp || partCmp === `P${pageNumber}`) {
return `${mainTitle} P${pageNumber}`;
}
return `${mainTitle} P${pageNumber} ${pagePart}`;
}
function buildMetadata(ctx, settings, uploadState = 'uploading') {
const tags = ['BiliBili', 'video', ...ctx.tags.map(t => t.tag_name).filter(Boolean)];
const creators = [];
const mids = [];
if (Array.isArray(ctx.view.staff) && ctx.view.staff.length) {
for (const staff of ctx.view.staff) {
if (staff.name && !creators.includes(staff.name)) creators.push(staff.name);
if (staff.mid && !mids.includes(staff.mid)) mids.push(staff.mid);
}
} else {
if (ctx.owner.name) creators.push(ctx.owner.name);
if (ctx.owner.mid) mids.push(ctx.owner.mid);
}
const externalIds = [
`urn:bilibili:video:aid:${ctx.aid}`,
`urn:bilibili:video:bvid:${ctx.bvid}`,
`urn:bilibili:video:cid:${ctx.cid}`,
...mids.map(mid => `urn:bilibili:video:mid:${mid}`),
];
const date = ctx.pubdate ? new Date(ctx.pubdate * 1000).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '') : '';
const source = buildSourceUrl(ctx);
return xmlCharsLegalize({
mediatype: 'movies',
collection: settings.collection || 'opensource_movies',
title: buildArchiveTitle(ctx),
description: uploadState === 'uploaded' ? ctx.desc : `${ctx.identifier} uploading...`,
creator: creators.length > 1 ? creators : (creators[0] || ctx.owner.name || 'unknown'),
date,
subject: tags.join('; '),
'external-identifier': externalIds,
'upload-state': uploadState,
source,
originalurl: source,
scanner: APP.scanner,
});
}
function iaMetadataHeaders(metadata) {
const headers = {};
let serial = 1;
const add = (key, value) => {
const normalizedKey = key.replace(/_/g, '--');
headers[`x-archive-meta${String(serial).padStart(2, '0')}-${normalizedKey}`] = `uri(${encodeURIComponent(String(value ?? ''))})`;
serial++;
};
for (const [key, value] of Object.entries(metadata)) {
if (Array.isArray(value)) {
for (const item of value) add(key, item);
} else {
add(key, value);
}
}
return headers;
}
function normalizeMp4BoxExport(value) {
const seen = new Set();
const queue = [value];
while (queue.length) {
const item = queue.shift();
if (!item || (typeof item !== 'object' && typeof item !== 'function') || seen.has(item)) continue;
seen.add(item);
if (typeof item.createFile === 'function') return item;
queue.push(item.default, item.MP4Box, item.mp4box, item.exports);
}
return null;
}
function findLoadedMp4Box() {
const uw = getUnsafeWindow();
return normalizeMp4BoxExport(globalThis.MP4Box) ||
normalizeMp4BoxExport(uw.MP4Box) ||
normalizeMp4BoxExport(globalThis.mp4box) ||
normalizeMp4BoxExport(uw.mp4box) ||
null;
}
function exposeMp4Box(lib) {
if (!lib?.createFile) return null;
globalThis.MP4Box = lib;
try { getUnsafeWindow().MP4Box = lib; } catch (_) {}
return lib;
}
function getMp4Box() {
const lib = findLoadedMp4Box();
if (!lib?.createFile) {
throw new Error('MP4Box.js 未加载;请检查网络/CDN,或确认脚本版本不再使用 @require。');
}
return exposeMp4Box(lib);
}
function evaluateMp4BoxBundle(code, sourceUrl) {
const uw = getUnsafeWindow();
const run = new Function('unsafeGlobal', `
var exports = {};
var module = { exports: exports };
var self = unsafeGlobal || globalThis;
var window = unsafeGlobal || globalThis;
var global = unsafeGlobal || globalThis;
${code}
return (typeof MP4Box !== 'undefined' && MP4Box) ||
(typeof mp4box !== 'undefined' && mp4box) ||
(module && module.exports) ||
exports ||
(window && window.MP4Box) ||
(self && self.MP4Box) ||
(globalThis && globalThis.MP4Box) ||
null;
//# sourceURL=${sourceUrl}
`);
return normalizeMp4BoxExport(run.call(globalThis, uw));
}
async function ensureMp4BoxLoaded() {
const existing = findLoadedMp4Box();
if (existing?.createFile) return exposeMp4Box(existing);
const cdns = APP.mp4boxCdns || [];
const errors = [];
for (const url of cdns) {
UI.log(`正在懒加载 MP4Box.js:${url}`);
let code;
try {
const res = await gmRequest({
method: 'GET',
url,
responseType: 'text',
timeout: 30000,
});
code = typeof res.response === 'string' ? res.response : String(res.response || '');
} catch (err) {
errors.push(`${url} 下载失败:${err?.message || err}`);
UI.log(`MP4Box.js 候选下载失败,尝试下一个:${err?.message || err}`);
continue;
}
if (!code || code.length < 1000) {
errors.push(`${url} 下载内容异常`);
UI.log('MP4Box.js 下载内容异常,尝试下一个候选。');
continue;
}
try {
const lib = evaluateMp4BoxBundle(code, url);
if (lib?.createFile) {
exposeMp4Box(lib);
UI.log(`MP4Box.js 已加载:${url}`);
return lib;
}
errors.push(`${url} 未暴露 createFile`);
UI.log('该 MP4Box.js 候选未暴露 createFile,尝试下一个。');
} catch (err) {
errors.push(`${url} 执行失败:${err?.message || err}`);
UI.log(`MP4Box.js 候选执行失败,尝试下一个:${err?.message || err}`);
}
}
throw new Error(`MP4Box.js 已下载/尝试但没有可用的 createFile。已尝试 ${cdns.length} 个候选:${errors.join(';')}`);
}
function cloneArrayBuffer(buffer) {
if (buffer instanceof ArrayBuffer) return buffer.slice(0);
if (ArrayBuffer.isView(buffer)) {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
throw new Error('无法复制非 ArrayBuffer 数据。');
}
function boxToBuffer(box) {
try {
if (typeof DataStream !== 'undefined') {
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
box.write(stream);
return stream.buffer.slice(0, stream.position || stream.byteOffset || stream.buffer.byteLength);
}
} catch (_) {}
return null;
}
function cloneMp4BoxBox(box) {
if (!box) return null;
const bytes = boxToBuffer(box);
if (!bytes || typeof MP4BoxStream === 'undefined' || typeof BoxParser === 'undefined') return box;
try {
const stream = new MP4BoxStream(bytes);
const parsed = BoxParser.parseOneBox(stream);
return parsed?.box || box;
} catch (_) {
return box;
}
}
function findChildBox(box, names) {
if (!box || !Array.isArray(names)) return null;
for (const name of names) {
if (box[name]) return box[name];
}
const children = [];
if (Array.isArray(box.boxes)) children.push(...box.boxes);
if (Array.isArray(box.entries)) children.push(...box.entries);
for (const child of children) {
if (names.includes(child?.type)) return child;
}
return null;
}
function findDescriptionBoxes(description, trackType) {
const videoConfigNames = ['avcC', 'hvcC', 'av1C', 'vpcC', 'dvcC'];
const audioConfigNames = ['esds', 'dac3', 'dec3', 'dfLa', 'dOps', 'wave'];
const names = trackType === 'audio' ? audioConfigNames : videoConfigNames;
const boxes = [];
for (const name of names) {
const box = findChildBox(description, [name]);
if (box) boxes.push(cloneMp4BoxBox(box));
}
return boxes.filter(Boolean);
}
function sampleEntryType(track, sample) {
const fromDesc = sample?.description?.type;
if (fromDesc && /^[A-Za-z0-9 ]{4}$/.test(fromDesc)) return fromDesc;
const codec = String(track?.codec || '').trim();
if (codec.startsWith('avc1') || codec.startsWith('avc3')) return codec.slice(0, 4);
if (codec.startsWith('hev1') || codec.startsWith('hvc1')) return codec.slice(0, 4);
if (codec.startsWith('av01')) return 'av01';
if (codec.startsWith('vp09')) return 'vp09';
if (codec.startsWith('mp4a')) return 'mp4a';
return track?.type === 'audio' ? 'mp4a' : 'avc1';
}
function sampleDataBuffer(sample) {
const data = sample?.data;
if (data instanceof ArrayBuffer) return data;
if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
throw new Error('MP4Box.js 返回了无法识别的 sample.data。');
}
function parseMp4TrackSamples(buffer, wantedType, label, progressStart, progressWeight) {
const MP4Box = getMp4Box();
return new Promise((resolve, reject) => {
const file = MP4Box.createFile();
const samples = [];
let readyInfo = null;
let track = null;
let done = false;
const fail = (err) => {
if (done) return;
done = true;
reject(err instanceof Error ? err : new Error(String(err)));
};
file.onError = (e) => fail(new Error(`${label} 解析失败:${e?.message || e}`));
file.onReady = (info) => {
readyInfo = info;
const typedTracks = wantedType === 'video' ? info.videoTracks : info.audioTracks;
track = (typedTracks && typedTracks[0]) || info.tracks?.find(t => t.type === wantedType);
if (!track) return fail(new Error(`${label} 中没有 ${wantedType === 'video' ? '视频' : '音频'} track。`));
UI.log(`${label} MP4 信息:track=${track.id}, codec=${track.codec || 'unknown'}, samples=${track.nb_samples || 'unknown'}`);
file.setExtractionOptions(track.id, null, { nbSamples: 1000, rapAlignement: false });
file.start();
};
file.onSamples = (id, user, batch) => {
if (!track || id !== track.id) return;
samples.push(...batch);
const total = Number(track.nb_samples || 0);
if (total > 0) {
UI.setStep(clamp(progressStart + (samples.length / total) * progressWeight, 0, 100));
}
};
try {
const ab = cloneArrayBuffer(buffer);
ab.fileStart = 0;
file.appendBuffer(ab);
file.flush();
if (!readyInfo || !track) return fail(new Error(`${label} 未能解析出 moov/track 信息。`));
if (!samples.length) return fail(new Error(`${label} 没有抽取到 sample;该 DASH 片段可能不是 MP4Box.js 可处理的 fMP4。`));
done = true;
resolve({ info: readyInfo, track, samples, file });
} catch (err) {
fail(err);
}
});
}
function buildTrackOptions(parsed, type, id) {
const first = parsed.samples[0];
const track = parsed.track;
const desc = first.description;
const options = {
id,
type: sampleEntryType(track, first),
hdlr: type === 'video' ? 'vide' : 'soun',
name: type === 'video' ? 'VideoHandler' : 'SoundHandler',
language: track.language || 'und',
timescale: track.timescale || first.timescale || (type === 'video' ? 90000 : 48000),
duration: track.movie_duration || track.duration || 0,
media_duration: track.duration || 0,
nb_samples: parsed.samples.length,
brands: ['isom', 'iso6', 'mp41'],
description_boxes: findDescriptionBoxes(desc, type),
};
if (type === 'video') {
options.width = Math.round(track.video?.width || track.track_width || desc?.width || 1920);
options.height = Math.round(track.video?.height || track.track_height || desc?.height || 1080);
} else {
options.channel_count = track.audio?.channel_count || desc?.channel_count || 2;
options.samplesize = track.audio?.sample_size || desc?.samplesize || desc?.sample_size || 16;
const sampleRate = track.audio?.sample_rate || desc?.samplerate || desc?.sample_rate || 48000;
options.samplerate = sampleRate > 65535 ? sampleRate : (sampleRate << 16);
options.width = 0;
options.height = 0;
}
const avcC = findChildBox(desc, ['avcC']);
const hvcC = findChildBox(desc, ['hvcC']);
if (avcC) {
const buf = boxToBuffer(avcC);
if (buf) options.avcDecoderConfigRecord = buf;
}
if (hvcC) {
const buf = boxToBuffer(hvcC);
if (buf) options.hevcDecoderConfigRecord = buf;
}
return options;
}
function addSampleCompat(output, trackId, sample) {
const data = sampleDataBuffer(sample);
const opts = {
duration: sample.duration || 1,
dts: sample.dts || 0,
cts: Number.isFinite(sample.cts) ? sample.cts : (sample.dts || 0),
is_sync: Boolean(sample.is_sync || sample.is_rap),
is_leading: sample.is_leading || 0,
depends_on: sample.depends_on || 0,
is_depended_on: sample.is_depended_on || 0,
has_redundancy: sample.has_redundancy || 0,
degradation_priority: sample.degradation_priority || 0,
subsamples: sample.subsamples,
};
try {
return output.addSample(trackId, data, opts);
} catch (err) {
if (/Cannot read|undefined|byteLength|data/i.test(err?.message || String(err))) {
return output.addSample(trackId, { data, ...opts });
}
throw err;
}
}
function outputFileToArrayBuffer(output) {
if (typeof output.getBuffer === 'function') {
const buffer = output.getBuffer();
if (buffer instanceof ArrayBuffer) return buffer;
if (ArrayBuffer.isView(buffer)) return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
if (typeof DataStream !== 'undefined' && typeof output.write === 'function') {
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
output.write(stream);
return stream.buffer.slice(0, stream.position || stream.byteOffset || stream.buffer.byteLength);
}
throw new Error('MP4Box.js 当前构建不支持 getBuffer/write,无法导出 MP4。');
}
async function mergeDashToMp4(videoBuffer, audioBuffer, ctx, settings) {
UI.setPhase('合并音视频', 'ba-status-warn');
UI.setDetail('正在用 MP4Box.js 抽取 DASH 视频/音频 sample 并重新封装为单个 MP4,不进行转码。');
UI.setStep(0);
await ensureMp4BoxLoaded();
UI.log('加载 MP4Box.js muxer:无需 SharedArrayBuffer,不依赖 FFmpeg.wasm。');
const videoParsed = parseMp4TrackSamples(videoBuffer, 'video', '视频流', 0, 35);
const audioParsed = parseMp4TrackSamples(audioBuffer, 'audio', '音频流', 35, 25);
const [video, audio] = await Promise.all([videoParsed, audioParsed]);
UI.setStep(60);
const MP4Box = getMp4Box();
const output = MP4Box.createFile();
if (typeof output.onError !== 'undefined') output.onError = (e) => { throw new Error(`MP4Box.js 输出失败:${e}`); };
const videoTrackId = output.addTrack(buildTrackOptions(video, 'video', 1));
const audioTrackId = output.addTrack(buildTrackOptions(audio, 'audio', 2));
if (!videoTrackId || !audioTrackId) throw new Error('MP4Box.js 创建输出 track 失败;当前编码的 sample entry 可能暂不支持。');
UI.log(`MP4Box.js 输出 track:video=${videoTrackId}, audio=${audioTrackId}`);
const queue = [];
for (const sample of video.samples) queue.push({ kind: 'video', time: (sample.dts || 0) / (sample.timescale || video.track.timescale || 1), sample });
for (const sample of audio.samples) queue.push({ kind: 'audio', time: (sample.dts || 0) / (sample.timescale || audio.track.timescale || 1), sample });
queue.sort((a, b) => a.time - b.time || (a.kind === 'video' ? -1 : 1));
let added = 0;
for (const item of queue) {
addSampleCompat(output, item.kind === 'video' ? videoTrackId : audioTrackId, item.sample);
added++;
if (added % 500 === 0 || added === queue.length) {
UI.setStep(60 + (added / queue.length) * 35);
UI.setDetail(`MP4Box.js 正在写入 sample:${added} / ${queue.length}`);
await sleep(0);
}
}
const mp4Buffer = outputFileToArrayBuffer(output);
UI.setStep(100);
UI.log(`MP4Box.js 合并完成:${formatBytes(mp4Buffer.byteLength)},samples=${queue.length}`);
return new Blob([mp4Buffer], { type: 'video/mp4' });
}
function mimeFromFileName(name) {
const lower = name.toLowerCase();
if (lower.endsWith('.mp4')) return 'video/mp4';
if (lower.endsWith('.json')) return 'application/json; charset=utf-8';
if (lower.endsWith('.srt')) return 'application/x-subrip; charset=utf-8';
if (lower.endsWith('.ass') || lower.endsWith('.ssa')) return 'text/x-ssa; charset=utf-8';
if (lower.endsWith('.xml')) return 'application/xml; charset=utf-8';
if (lower.endsWith('.vtt')) return 'text/vtt; charset=utf-8';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
return 'application/octet-stream';
}
function xhrPutUpload(url, headers, blob, signal, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let started = false;
let loaded = 0;
let lastProgressAt = Date.now();
let watchdog = null;
const cleanup = () => {
if (watchdog) clearInterval(watchdog);
};
try {
xhr.open('PUT', url, true);
xhr.responseType = 'text';
xhr.timeout = 0;
xhr.withCredentials = false;
for (const [key, value] of Object.entries(headers || {})) {
if (value === undefined || value === null || value === '') continue;
xhr.setRequestHeader(key, String(value));
}
} catch (err) {
cleanup();
reject(err);
return;
}
xhr.upload.onloadstart = () => {
started = true;
lastProgressAt = Date.now();
UI.log('原生 XHR 上传已开始;如果浏览器允许 CORS,将显示真实上传进度。');
};
xhr.upload.onprogress = (ev) => {
started = true;
lastProgressAt = Date.now();
if (ev.lengthComputable && ev.total > 0) {
loaded = ev.loaded;
onProgress?.(ev.loaded, ev.total, true);
} else if (ev.loaded) {
loaded = ev.loaded;
onProgress?.(ev.loaded, blob.size || 0, false);
}
};
xhr.onload = () => {
cleanup();
if (xhr.status >= 200 && xhr.status < 300) {
resolve({ status: xhr.status, responseText: xhr.responseText || '' });
} else {
reject(new Error(`原生 XHR PUT HTTP ${xhr.status}: ${(xhr.responseText || xhr.statusText || '').slice(0, 500)}`));
}
};
xhr.onerror = () => {
cleanup();
reject(new Error('原生 XHR 上传失败:可能是 IA S3 CORS/preflight 被拒,或网络连接被浏览器阻断。'));
};
xhr.onabort = () => {
cleanup();
reject(new Error('原生 XHR 上传被取消'));
};
xhr.ontimeout = () => {
cleanup();
reject(new Error('原生 XHR 上传超时'));
};
watchdog = setInterval(() => {
const silentMs = Date.now() - lastProgressAt;
if (!started && silentMs > 45000) {
cleanup();
try { xhr.abort(); } catch (_) {}
reject(new Error('原生 XHR 上传 45 秒内没有开始发送数据,已切换备用上传通道。'));
} else if (started && loaded === 0 && silentMs > 90000) {
cleanup();
try { xhr.abort(); } catch (_) {}
reject(new Error('原生 XHR 上传 90 秒内没有上传任何字节,已切换备用上传通道。'));
}
}, 5000);
if (signal) {
signal.addEventListener('abort', () => {
try { xhr.abort(); } catch (_) {}
}, { once: true });
}
try {
xhr.send(blob);
} catch (err) {
cleanup();
reject(err);
}
});
}
async function gmPutUpload(url, headers, blob, signal, onProgress, options = {}) {
await gmRequest({
method: 'PUT',
url,
headers,
data: blob,
timeout: 0,
fetch: options.fetch === true,
anonymous: options.anonymous === true,
signal,
onprogress: (ev) => {
if (ev.lengthComputable && ev.total > 0) {
onProgress?.(ev.loaded, ev.total, true);
} else if (ev.loaded) {
onProgress?.(ev.loaded, blob.size || 0, false);
}
},
});
}
async function uploadToIa(identifier, fileName, blob, settings, metadata, isFirstFile, progressBase, progressWeight) {
const url = `${APP.iaS3Base}/${encodeURIComponent(identifier)}/${encodeURIComponent(fileName)}`;
const headers = {
Authorization: `LOW ${settings.iaAccessKey}:${settings.iaSecretKey}`,
'Content-Type': mimeFromFileName(fileName),
'x-archive-queue-derive': settings.queueDerive ? '1' : '0',
'x-archive-keep-old-version': '1',
...(isFirstFile ? { 'x-archive-auto-make-bucket': '1', 'x-archive-size-hint': String(blob.size || 0), ...iaMetadataHeaders(metadata) } : {}),
};
UI.setPhase(`上传 ${fileName}`, 'ba-status-warn');
UI.setDetail(`${fileName}:${formatBytes(blob.size)}`);
UI.setStepIndeterminate('准备上传');
UI.setTotal(progressBase);
UI.log(`PUT ${fileName} -> ${identifier}`);
UI.log('上传通道:优先使用原生 XMLHttpRequest(可显示真实 upload 进度);失败后自动切换 Tampermonkey fetch/GM 备用通道。');
const uploadStartedAt = Date.now();
let sawComputableProgress = false;
let lastLoaded = 0;
let lastTotal = blob.size || 0;
let currentTransport = '准备中';
let lastUploadDetailText = '';
const setUploadDetail = (text) => {
if (text === lastUploadDetailText) return;
lastUploadDetailText = text;
UI.setDetail(text);
};
const formatUploadProgressDetail = () => `${fileName}:${formatBytes(lastLoaded)} / ${formatBytes(lastTotal)}(${currentTransport})`;
const updateProgress = (loaded, total, computable) => {
lastLoaded = Number(loaded || 0);
lastTotal = Number(total || lastTotal || blob.size || 0);
if (computable && lastTotal > 0) {
sawComputableProgress = true;
const pct = clamp(lastLoaded / lastTotal, 0, 1);
UI.setStep(pct * 100);
UI.setTotal(progressBase + pct * progressWeight);
setUploadDetail(formatUploadProgressDetail());
} else if (lastLoaded > 0) {
UI.setStepIndeterminate('上传中');
setUploadDetail(`${fileName}:已发送至少 ${formatBytes(lastLoaded)}(${currentTransport})`);
}
};
const heartbeat = setInterval(() => {
const elapsed = Math.max(1, Math.round((Date.now() - uploadStartedAt) / 1000));
if (!sawComputableProgress && lastLoaded <= 0) {
UI.setStepIndeterminate('上传中');
setUploadDetail(`${fileName}:${formatBytes(blob.size)},${currentTransport},已等待 ${elapsed}s;若系统上行长期接近 0,说明通道可能未真正发送 body。`);
} else if (!sawComputableProgress) {
UI.setStepIndeterminate('上传中');
setUploadDetail(`${fileName}:已发送至少 ${formatBytes(lastLoaded)},${currentTransport},已等待 ${elapsed}s`);
} else if (currentTransport === '原生 XHR') {
// XHR 已经能给出真实 upload progress 时,界面只保留字节数/进度条;
// 这里必须与 updateProgress 使用完全相同的文案,避免 heartbeat 与 progress 事件抢写造成闪烁。
setUploadDetail(formatUploadProgressDetail());
} else {
setUploadDetail(`${fileName}:${formatBytes(lastLoaded)} / ${formatBytes(lastTotal)},${currentTransport},已等待 ${elapsed}s`);
}
}, 5000);
try {
const transports = [
{
name: '原生 XHR',
run: () => xhrPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress),
},
{
name: 'Tampermonkey fetch',
run: () => gmPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress, { fetch: true, anonymous: true }),
},
{
name: 'Tampermonkey GM_xmlhttpRequest',
run: () => gmPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress, { fetch: false }),
},
];
let lastErr = null;
for (let i = 0; i < transports.length; i++) {
const transport = transports[i];
currentTransport = transport.name;
UI.log(`尝试上传通道:${transport.name}`);
UI.setStepIndeterminate('上传中');
try {
await transport.run();
lastErr = null;
break;
} catch (err) {
lastErr = err;
const msg = err?.message || String(err);
if (/被取消|abort/i.test(msg)) throw err;
UI.log(`${transport.name} 上传失败:${msg}`);
if (i < transports.length - 1) {
UI.log('切换到下一个上传通道……');
lastLoaded = 0;
sawComputableProgress = false;
}
}
}
if (lastErr) throw lastErr;
} finally {
clearInterval(heartbeat);
}
UI.setStep(100);
UI.setTotal(progressBase + progressWeight);
UI.log(`上传完成:${fileName}`);
}
async function modifyMetadataUploaded(identifier, ctx, settings) {
UI.setPhase('更新 IA 元数据', 'ba-status-warn');
UI.setStep(0);
const source = buildSourceUrl(ctx);
const patch = [
{ op: 'add', path: '/upload-state', value: 'uploaded' },
{ op: 'add', path: '/description', value: xmlCharsLegalizeString(ctx.desc || '') },
{ op: 'add', path: '/source', value: source },
{ op: 'add', path: '/originalurl', value: source },
{ op: 'add', path: '/scanner', value: APP.scanner },
];
const body = new URLSearchParams();
body.set('-target', 'metadata');
body.set('-patch', JSON.stringify(patch));
body.set('access', settings.iaAccessKey);
body.set('secret', settings.iaSecretKey);
const res = await gmJson(`${APP.iaMetaBase}/${encodeURIComponent(identifier)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
Authorization: `LOW ${settings.iaAccessKey}:${settings.iaSecretKey}`,
Origin: 'https://archive.org',
},
data: body.toString(),
timeout: 60000,
});
if (res.success !== true) throw new Error(`IA 元数据更新失败:${res.error || JSON.stringify(res)}`);
UI.setStep(100);
UI.log(`元数据更新任务已提交:${res.task_id}`);
}
async function fetchCoverBlob(ctx) {
if (!ctx.pic) return null;
try {
const blob = await gmBlob(ctx.pic, { timeout: 60000 });
const suffix = new URL(ctx.pic).pathname.split('.').pop()?.split('?')[0] || 'jpg';
return { blob, suffix: suffix.toLowerCase() };
} catch (err) {
UI.log(`封面下载失败,跳过封面:${err.message}`);
return null;
}
}
function buildInfoJson(ctx, playInfo, selectedStreams, subtitleSummary = [], danmakuSummary = [], repliesSummary = null) {
const source = buildSourceUrl(ctx);
return JSON.stringify({
generated_at: nowIso(),
generator: APP.scanner,
source,
original_url: source,
identifier: ctx.identifier,
data: {
View: ctx.view,
Tags: ctx.tags,
SelectedStreams: {
video: sanitizeMediaForJson(selectedStreams.video),
audio: sanitizeMediaForJson(selectedStreams.audio),
},
PlayInfoSummary: {
format: playInfo.format,
quality: playInfo.quality,
accept_quality: playInfo.accept_quality,
support_formats: playInfo.support_formats,
},
Subtitles: subtitleSummary,
Danmaku: danmakuSummary,
Replies: repliesSummary,
},
}, null, 2);
}
function sanitizeMediaForJson(media) {
if (!media) return null;
const clone = { ...media };
for (const key of ['baseUrl', 'base_url', 'url', 'backupUrl', 'backup_url']) {
if (clone[key]) clone[key] = '[redacted: signed media url]';
}
return clone;
}
async function runUploadCurrentVideo() {
if (state.busy) {
UI.toast('已有上传任务正在运行。', true);
return;
}
const settings = getSettings();
UI.show();
UI.clearLog();
UI.setTotal(0);
UI.setStep(0);
UI.setActions([]);
if (!settings.iaAccessKey || !settings.iaSecretKey) {
UI.toast('请先配置 Internet Archive S3 Access Key / Secret Key。', true);
openSettingsModal();
return;
}
state.busy = true;
await WakeLockManager.acquire();
let ctx = null;
let videoBuffer = null;
let audioBuffer = null;
let mp4Blob = null;
let infoBlob = null;
let cover = null;
let files = [];
let subtitleFiles = [];
let danmakuFiles = [];
let replyFile = null;
try {
UI.setPhase('读取当前视频', 'ba-status-warn');
UI.setDetail('正在识别 BV、分 P、cid 与 Bilibili 元数据。');
ctx = await getCurrentVideoContext();
state.currentTask = { bvid: ctx.bvid, pageNumber: ctx.pageNumber, identifier: ctx.identifier, startedAt: nowIso() };
GM_setValue(storageKey('last_task'), state.currentTask);
UI.setSubtitle(`${ctx.bvid} P${ctx.pageNumber}`);
UI.log(`当前视频:${ctx.title} / P${ctx.pageNumber} ${ctx.pagePart}`);
UI.log(`IA identifier:${ctx.identifier}`);
UI.setTotal(4);
UI.setPhase('检查 IA identifier', 'ba-status-warn');
const idCheck = await checkIdentifier(ctx.identifier);
const available = idCheck.code === 'available';
if (!available && !settings.overwriteExisting) {
throw new Error(`IA item 已存在,且未开启“允许更新已存在 item”:${ctx.identifier}`);
}
UI.log(available ? 'IA identifier 可用。' : 'IA identifier 已存在,将按设置继续上传/更新。');
UI.setTotal(8);
UI.setPhase('解析播放流', 'ba-status-warn');
const playInfo = await getPlayInfo(ctx, settings);
const selected = selectDashStreams(playInfo, settings);
UI.log(`选择视频流:${qualityLabel(streamQuality(selected.video))},编码=${streamCodecLabel(selected.video)},bandwidth=${selected.video.bandwidth || 'unknown'}`);
UI.log(`选择音频流:id=${selected.audio.id}, codec=${selected.audio.codecs || selected.audio.codec || 'unknown'}, bandwidth=${selected.audio.bandwidth || 'unknown'}`);
UI.setTotal(12);
const videoUrl = streamUrl(selected.video);
const audioUrl = streamUrl(selected.audio);
const controller = new AbortController();
UI.setActions([{ text: '取消任务', className: 'ba-btn-danger', onclick: () => controller.abort() }]);
UI.setPhase('下载视频流', 'ba-status-warn');
videoBuffer = await gmArrayBuffer(videoUrl, {
signal: controller.signal,
onprogress: ev => {
if (ev.lengthComputable) {
const p = ev.loaded / ev.total;
UI.setStep(p * 100);
UI.setTotal(12 + p * 18);
UI.setDetail(`视频流:${formatBytes(ev.loaded)} / ${formatBytes(ev.total)}`);
}
},
});
UI.log(`视频流下载完成:${formatBytes(videoBuffer.byteLength)}`);
UI.setTotal(30);
UI.setPhase('下载音频流', 'ba-status-warn');
audioBuffer = await gmArrayBuffer(audioUrl, {
signal: controller.signal,
onprogress: ev => {
if (ev.lengthComputable) {
const p = ev.loaded / ev.total;
UI.setStep(p * 100);
UI.setTotal(30 + p * 14);
UI.setDetail(`音频流:${formatBytes(ev.loaded)} / ${formatBytes(ev.total)}`);
}
},
});
UI.log(`音频流下载完成:${formatBytes(audioBuffer.byteLength)}`);
UI.setTotal(44);
mp4Blob = await mergeDashToMp4(videoBuffer, audioBuffer, ctx, settings);
videoBuffer = null;
audioBuffer = null;
UI.log('已释放 DASH 原始音视频缓冲引用,等待浏览器按需回收内存。');
UI.setTotal(68);
UI.setPhase('准备随附文件', 'ba-status-warn');
const metadata = buildMetadata(ctx, settings, 'uploading');
UI.log(`IA title metadata:${metadata.title}`);
UI.log(`IA source metadata:${metadata.source}`);
const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
subtitleFiles = await fetchSubtitleFiles(ctx).catch(err => {
UI.log(`CC 字幕获取失败,继续上传其它文件:${err.message || err}`);
return [];
});
replyFile = await fetchReplyFile(ctx).catch(err => {
UI.log(`视频评论获取失败,继续上传其它文件:${err.message || err}`);
return null;
});
danmakuFiles = await fetchDanmakuFiles(ctx, selected, settings).catch(err => {
UI.log(`弹幕获取/转换失败,继续上传其它文件:${err.message || err}`);
return [];
});
const subtitleSummary = subtitleFiles.map(item => item.meta || { name: item.name });
const danmakuSummary = danmakuFiles.map(item => item.meta || { name: item.name });
const repliesSummary = replyFile?.meta || null;
const infoJson = buildInfoJson(ctx, playInfo, selected, subtitleSummary, danmakuSummary, repliesSummary);
infoBlob = new Blob([infoJson], { type: 'application/json;charset=utf-8' });
files = [];
files.push({ name: `${baseName}.mp4`, blob: mp4Blob });
files.push({ name: `${baseName}.info.json`, blob: infoBlob });
for (const subtitle of subtitleFiles) files.push({ name: subtitle.name, blob: subtitle.blob });
if (replyFile) files.push({ name: replyFile.name, blob: replyFile.blob });
for (const danmaku of danmakuFiles) files.push({ name: danmaku.name, blob: danmaku.blob });
cover = await fetchCoverBlob(ctx);
if (cover) {
files.push({ name: `${ctx.bvid}_p${ctx.pageNumber}.${cover.suffix}`, blob: cover.blob });
files.push({ name: `${ctx.bvid}_p${ctx.pageNumber}_itemimage.${cover.suffix}`, blob: cover.blob });
}
UI.log(`待上传文件:${files.map(f => `${f.name}(${formatBytes(f.blob.size)})`).join(', ')}`);
UI.setTotal(70);
const uploadWeight = 24 / files.length;
for (let i = 0; i < files.length; i++) {
const file = files[i];
await uploadToIa(ctx.identifier, file.name, file.blob, settings, metadata, i === 0, 70 + i * uploadWeight, uploadWeight);
}
await modifyMetadataUploaded(ctx.identifier, ctx, settings);
UI.setTotal(98);
UI.setPhase('验证上传结果', 'ba-status-warn');
await sleep(2000);
const iaMeta = await gmJson(`${APP.iaMetaBase}/${encodeURIComponent(ctx.identifier)}`, { headers: { Origin: 'https://archive.org' }, timeout: 60000 });
const uploadedNames = new Set((iaMeta.files || []).map(f => f.name));
const missing = files.filter(f => !uploadedNames.has(f.name)).map(f => f.name);
if (missing.length) UI.log(`IA metadata 暂未列出这些文件,可能仍在入库队列中:${missing.join(', ')}`);
UI.setTotal(100);
const detailsUrl = `https://archive.org/details/${ctx.identifier}`;
UI.setPhase('上传完成', 'ba-status-good');
UI.setDetail(`完成:${detailsUrl}`);
UI.log(`完成:${detailsUrl}`);
UI.setActions([
{ text: '打开 IA 页面', className: 'ba-btn-good', onclick: () => window.open(detailsUrl, '_blank', 'noopener') },
{ text: '复制链接', className: 'ba-btn-primary', onclick: () => navigator.clipboard?.writeText(detailsUrl) },
{ text: '隐藏', onclick: () => document.getElementById('biliarchiver-popup')?.classList.add('ba-hidden') },
]);
GM_setValue(storageKey('last_task'), { ...state.currentTask, finishedAt: nowIso(), status: 'finished', url: detailsUrl });
} catch (err) {
const message = err?.message || String(err);
UI.setPhase('上传失败', 'ba-status-bad');
UI.setDetail(message);
UI.log(`错误:${message}`);
UI.setActions([
{ text: '重试当前视频', className: 'ba-btn-primary', onclick: () => runUploadCurrentVideo() },
{ text: '打开设置', onclick: () => openSettingsModal() },
]);
if (ctx) GM_setValue(storageKey('last_task'), { ...state.currentTask, failedAt: nowIso(), status: 'failed', error: message });
} finally {
const hadLargeRefs = Boolean(videoBuffer || audioBuffer || mp4Blob || infoBlob || cover || replyFile || files.length || subtitleFiles.length || danmakuFiles.length);
try {
if (Array.isArray(files)) files.length = 0;
if (Array.isArray(subtitleFiles)) subtitleFiles.length = 0;
if (Array.isArray(danmakuFiles)) danmakuFiles.length = 0;
videoBuffer = null;
audioBuffer = null;
mp4Blob = null;
infoBlob = null;
cover = null;
replyFile = null;
if (hadLargeRefs) UI.log('已清理残留引用');
} catch (_) {}
state.busy = false;
await WakeLockManager.release();
}
}
function openSettingsModal() {
UI.ensure();
const settings = getSettings();
const old = document.getElementById('biliarchiver-modal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'biliarchiver-modal';
modal.innerHTML = `
<div class="ba-modal-box">
<h3>配置 Biliarchiver</h3>
<div class="ba-line">IA 密钥只保存在 Tampermonkey 本地存储中;不要在不可信浏览器环境中使用。</div>
<div class="ba-field">
<label>Internet Archive S3 Access Key</label>
<input class="ba-input" id="ba-set-access" autocomplete="off" value="${escapeHtml(settings.iaAccessKey)}">
</div>
<div class="ba-field">
<label>Internet Archive S3 Secret Key</label>
<input class="ba-input" id="ba-set-secret" type="password" autocomplete="off" value="${escapeHtml(settings.iaSecretKey)}">
</div>
<div class="ba-field">
<label>IA collection</label>
<input class="ba-input" id="ba-set-collection" value="${escapeHtml(settings.collection)}">
</div>
<div class="ba-field">
<label>目标清晰度</label>
<select class="ba-select" id="ba-set-qn">
${QUALITY_OPTIONS.map(item => `<option value="${item.qn}" ${normaliseTargetQn(settings.qn) === item.qn ? 'selected' : ''}>${item.label} / qn=${item.qn}</option>`).join('')}
</select>
<div class="ba-line">DASH 会返回多条流;这里是选择器目标值,脚本优先选目标清晰度,缺失时向下回退。</div>
</div>
<div class="ba-field">
<label>视频编码偏好</label>
<select class="ba-select" id="ba-set-codec">
<option value="av1" ${settings.codecPreference === 'av1' ? 'selected' : ''}>AV1 优先,默认推荐</option>
<option value="hevc" ${settings.codecPreference === 'hevc' ? 'selected' : ''}>HEVC/H.265 优先,体积通常较小</option>
<option value="avc" ${settings.codecPreference === 'avc' ? 'selected' : ''}>AVC/H.264 优先,兼容性最高</option>
<option value="bandwidth" ${settings.codecPreference === 'bandwidth' ? 'selected' : ''}>同清晰度内最高码率优先</option>
</select>
</div>
<div class="ba-field">
<label>弹幕来源</label>
<select class="ba-select" id="ba-set-danmaku-source">
<option value="xml" ${normaliseDanmakuSource(settings.danmakuSource) === 'xml' ? 'selected' : ''}>XML 实时弹幕池(默认)</option>
<option value="protobuf" ${normaliseDanmakuSource(settings.danmakuSource) === 'protobuf' ? 'selected' : ''}>protobuf 分段接口(6 分钟一包)</option>
</select>
<div class="ba-line">XML 保持旧行为;protobuf 会遍历 6 分钟分包,通常可取得更多弹幕。</div>
</div>
<label class="ba-check"><input type="checkbox" id="ba-set-derive" ${settings.queueDerive ? 'checked' : ''}> 上传后请求 IA 派生文件</label>
<label class="ba-check"><input type="checkbox" id="ba-set-overwrite" ${settings.overwriteExisting ? 'checked' : ''}> 允许更新已存在的 IA item</label>
<label class="ba-check"><input type="checkbox" id="ba-set-strict" ${settings.strictDashMerge ? 'checked' : ''} disabled> 严格要求 DASH 音视频合并后才上传</label>
<div class="ba-modal-actions">
<button class="ba-btn" id="ba-set-cancel">取消</button>
<button class="ba-btn ba-btn-danger" id="ba-set-clear">清除密钥</button>
<button class="ba-btn ba-btn-primary" id="ba-set-save">保存</button>
</div>
</div>`;
document.documentElement.appendChild(modal);
document.getElementById('ba-set-cancel').onclick = () => modal.remove();
document.getElementById('ba-set-clear').onclick = () => {
setSetting('iaAccessKey', '');
setSetting('iaSecretKey', '');
document.getElementById('ba-set-access').value = '';
document.getElementById('ba-set-secret').value = '';
UI.log('已清除 IA 密钥。');
};
document.getElementById('ba-set-save').onclick = () => {
setSetting('iaAccessKey', document.getElementById('ba-set-access').value.trim());
setSetting('iaSecretKey', document.getElementById('ba-set-secret').value.trim());
setSetting('collection', document.getElementById('ba-set-collection').value.trim() || DEFAULT_SETTINGS.collection);
setSetting('qn', normaliseTargetQn(document.getElementById('ba-set-qn').value));
setSetting('codecPreference', document.getElementById('ba-set-codec').value);
setSetting('danmakuSource', normaliseDanmakuSource(document.getElementById('ba-set-danmaku-source').value));
setSetting('queueDerive', document.getElementById('ba-set-derive').checked);
setSetting('overwriteExisting', document.getElementById('ba-set-overwrite').checked);
setSetting('strictDashMerge', true);
modal.remove();
UI.toast('设置已保存。');
};
}
function showLastTask() {
UI.show();
UI.clearLog();
const task = GM_getValue(storageKey('last_task'));
if (!task) {
UI.setPhase('暂无任务记录');
UI.setDetail('还没有上传任务。');
return;
}
UI.setPhase(`最近任务:${task.status || 'unknown'}`);
UI.setDetail(`${task.bvid || ''} P${task.pageNumber || ''} / ${task.identifier || ''}`);
UI.log(JSON.stringify(task, null, 2));
if (task.url) UI.setActions([{ text: '打开 IA 页面', className: 'ba-btn-good', onclick: () => window.open(task.url, '_blank', 'noopener') }]);
}
function registerMenus() {
if (typeof GM_registerMenuCommand !== 'function') {
console.error('[Biliarchiver] GM_registerMenuCommand 不可用:请确认脚本 @grant 未被修改。');
return;
}
GM_registerMenuCommand('上传当前视频到 Internet Archive', runUploadCurrentVideo);
GM_registerMenuCommand('配置 IA 密钥与上传选项', openSettingsModal);
GM_registerMenuCommand('查看最近上传任务', showLastTask);
GM_registerMenuCommand('显示上传 popup', () => UI.show());
}
try {
registerMenus();
UI.ensure();
console.info(`[Biliarchiver] ${APP.version} 初始化完成,菜单已注册。`);
} catch (err) {
console.error('[Biliarchiver] 初始化失败:', err);
}
})();