获取哔哩哔哩字幕保存到Dinxo
使用方法
安装脚本猫浏览器插件;
复制脚本代码;
将脚本代码粘贴到脚本猫编辑器中;
保存(快捷键:Ctrl + S),关闭编辑器页面;
脚本
// ==UserScript==
// @name Bilibili 字幕助手 (dinox版-带批量收集)
// @namespace https://docs.scriptcat.org/
// @version 4.5.0
// @description 先收集B站视频地址到列表,统一批量获取字幕并保存到Dinox,保留格式与视频链接。修复批量处理状态丢失问题。
// @author RunningCheese (Modified by dinox)、友野YouyEr
// @match http*://www.bilibili.com/video/*
// @match https://www.bilibili.com/*
// @icon https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://www.bilibili.com
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ------------------- 配置区域 -------------------
const DINEX_API_TOKEN = ''; // 替换为你的dinox API Token
const DINEX_API_URL = 'https://aisdk.chatgo.pro/api/openapi/createNote';
// ------------------- 地址收集配置 -------------------
const STORAGE_KEY = 'bilibili_video_list'; // 本地存储列表的key
const BATCH_STATE_KEY = 'bilibili_batch_state'; // 批量处理状态存储key
// --------------------------------------------------------------
const elements = {
createAs(nodeType, config, appendTo) {
const element = document.createElement(nodeType);
if (config) Object.entries(config).forEach(([key, value]) => { element[key] = value; });
if (appendTo) appendTo.appendChild(element);
return element;
},
getAs(selector) { return document.body.querySelector(selector); }
};
// 网络请求工具
function oldFetch(url, option = {}) {
return new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
req.onreadystatechange = () => {
if (req.readyState === 4) {
resolve({
ok: req.status >= 200 && req.status <= 299,
status: req.status,
statusText: req.statusText,
json: () => Promise.resolve(JSON.parse(req.responseText)),
text: () => Promise.resolve(req.responseText)
});
}
};
if (option.credentials == 'include') req.withCredentials = true;
req.onerror = reject;
req.open('GET', url);
req.send();
});
}
// ------------------- 地址列表与批量状态管理工具 -------------------
const videoListManager = {
// 获取本地存储的视频列表
getList() {
const list = localStorage.getItem(STORAGE_KEY);
return list ? JSON.parse(list) : [];
},
// 添加视频地址到列表(去重)
addVideo(url) {
const cleanUrl = url.split('?')[0]; // 去除参数,避免同一视频重复添加
const list = this.getList();
if (!list.includes(cleanUrl)) {
list.push(cleanUrl);
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
return true; // 添加成功
}
return false; // 已存在,添加失败
},
// 从列表中删除地址
removeVideo(url) {
const list = this.getList();
const newList = list.filter(item => item !== url);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newList));
},
// 清空列表
clearList() {
localStorage.removeItem(STORAGE_KEY);
},
// 获取列表长度
getLength() {
return this.getList().length;
},
// 批量处理状态管理(核心修复:持久化状态)
saveBatchState(state) {
localStorage.setItem(BATCH_STATE_KEY, JSON.stringify(state));
},
getBatchState() {
const state = localStorage.getItem(BATCH_STATE_KEY);
return state ? JSON.parse(state) : { isProcessing: false, currentIndex: 0, videoList: [] };
},
clearBatchState() {
localStorage.removeItem(BATCH_STATE_KEY);
}
};
// ------------------- 核心逻辑 -------------------
const bilibiliViewer = {
window: typeof unsafeWindow !== "undefined" ? unsafeWindow : window,
cid: undefined,
subtitle: undefined,
pcid: undefined,
buttonAdded: false,
listPanelAdded: false,
progressToast: null,
statusIndicator: null,
statusText: null,
progressBarInner: null,
// 创建UI元素(进度提示+列表面板)
createUI() {
if (this.progressToast) return;
// 进度提示UI
this.progressToast = elements.createAs("div", { id: "gm-progress-toast" }, document.body);
const contentWrapper = elements.createAs("div", { style: `display: flex; align-items: center; margin-bottom: 8px;` }, this.progressToast);
this.statusIndicator = elements.createAs("span", { id: "gm-status-indicator" }, contentWrapper);
this.statusText = elements.createAs("div", { id: "gm-status-text" }, contentWrapper);
const progressBar = elements.createAs("div", { id: "gm-progress-bar" }, this.progressToast);
this.progressBarInner = elements.createAs("div", { id: "gm-progress-bar-inner" }, progressBar);
// 样式定义
elements.createAs('style', {
textContent: `
#gm-progress-toast {
position: fixed; top: 20px; right: 20px; width: 280px;
background-color: rgba(20, 20, 20, 0.7); backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%); border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.2);
z-index: 99999; overflow: hidden; opacity: 0; transform: translateY(-50px);
transition: opacity 0.4s ease, transform 0.4s ease; padding: 12px 15px;
font-size: 14px; color: #f0f0f0;
}
#gm-status-indicator { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; transition: background-color 0.4s ease; }
#gm-status-text { flex-grow: 1; line-height: 1.4; }
#gm-progress-bar { width: 100%; height: 4px; background-color: rgba(255, 255, 255, 0.2); border-radius: 2px; }
#gm-progress-bar-inner { width: 0%; height: 100%; border-radius: 2px; transition: width 0.4s ease, background-color 0.4s ease; }
.bili-subtitle-btn-dragon { display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 22px; text-decoration: none; background-color: transparent; border: none; transition: transform 0.2s ease-in-out, color 0.2s; padding: 0; width: 36px; height: 36px; color: #61666D; }
.bili-subtitle-btn-dragon:hover { transform: scale(1.15); color: #00AEEC; }
/* 地址列表面板样式 */
#gm-video-list-panel {
position: fixed; bottom: 20px; right: 20px; width: 320px; max-height: 400px;
background-color: rgba(20, 20, 20, 0.8); backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%); border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.2);
z-index: 99998; overflow: hidden; padding: 15px; color: #f0f0f0;
}
#gm-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px; }
#gm-list-title { font-size: 16px; font-weight: bold; }
#gm-list-count { color: #00AEEC; }
#gm-list-buttons { display: flex; gap: 8px; }
.gm-list-btn { padding: 4px 8px; border-radius: 4px; border: none; background-color: rgba(255,255,255,0.1); color: #f0f0f0; cursor: pointer; transition: background-color 0.2s; }
.gm-list-btn:hover { background-color: #00AEEC; }
#gm-list-content { max-height: 300px; overflow-y: auto; padding-right: 5px; }
.gm-list-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.gm-list-url { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 220px; }
.gm-delete-btn { color: #ff3b30; cursor: pointer; font-size: 14px; }
.gm-delete-btn:hover { text-decoration: underline; }
`
}, document.head);
// 创建地址列表面板
this.createListPanel();
},
// 创建地址列表面板
createListPanel() {
if (this.listPanelAdded) return;
const panel = elements.createAs("div", { id: "gm-video-list-panel" }, document.body);
// 面板头部(标题+数量+按钮)
const header = elements.createAs("div", { id: "gm-list-header" }, panel);
elements.createAs("div", { id: "gm-list-title", textContent: "B站视频收集列表" }, header);
const count = elements.createAs("div", { id: "gm-list-count", textContent: `(${videoListManager.getLength()}个)` }, header);
const btnGroup = elements.createAs("div", { id: "gm-list-buttons" }, header);
// 添加当前视频到列表
const addBtn = elements.createAs("button", { className: "gm-list-btn", textContent: "添加当前视频" }, btnGroup);
addBtn.onclick = () => {
const currentUrl = window.location.href.split('?')[0];
const added = videoListManager.addVideo(currentUrl);
if (added) {
this.showProgress(`已添加视频到列表`, 100, 'success');
this.updateListPanel();
} else {
this.showProgress(`当前视频已在列表中`, 100, 'processing');
}
this.hideProgress(1500);
};
// 批量采集保存(核心修改:初始化批量状态)
const batchBtn = elements.createAs("button", { className: "gm-list-btn", textContent: "采集保存" }, btnGroup);
batchBtn.onclick = () => {
const list = videoListManager.getList();
if (list.length === 0) {
this.showProgress(`列表为空,无法采集`, 100, 'error');
this.hideProgress(2000);
return;
}
if (confirm(`确认开始采集?共${list.length}个视频,将自动跳转并保存字幕到Dinox`)) {
// 保存批量状态到localStorage(关键:避免页面跳转丢失状态)
videoListManager.saveBatchState({
isProcessing: true,
currentIndex: 0,
videoList: [...list] // 备份当前列表
});
// 跳转第一个视频
window.location.href = list[0];
}
};
// 清空列表
const clearBtn = elements.createAs("button", { className: "gm-list-btn", textContent: "清空列表" }, btnGroup);
clearBtn.onclick = () => {
if (confirm("确认清空列表?所有已添加的视频地址将删除")) {
videoListManager.clearList();
this.updateListPanel();
this.showProgress(`列表已清空`, 100, 'success');
this.hideProgress(1500);
}
};
// 面板内容(视频列表)
elements.createAs("div", { id: "gm-list-content" }, panel);
this.updateListPanel();
this.listPanelAdded = true;
},
// 更新列表面板内容
updateListPanel() {
const content = elements.getAs("#gm-list-content");
const countEl = elements.getAs("#gm-list-count");
if (!content || !countEl) return;
content.innerHTML = '';
const list = videoListManager.getList();
countEl.textContent = `(${list.length}个)`;
list.forEach(url => {
const item = elements.createAs("div", { className: "gm-list-item" }, content);
elements.createAs("div", { className: "gm-list-url", textContent: url, title: url }, item);
const deleteBtn = elements.createAs("div", { className: "gm-delete-btn", textContent: "删除" }, item);
deleteBtn.onclick = () => {
videoListManager.removeVideo(url);
this.updateListPanel();
};
});
},
// 批量处理逻辑(核心修复:从localStorage恢复状态)
async processBatch() {
// 从localStorage获取批量状态(关键:页面跳转后恢复状态)
const { isProcessing, currentIndex, videoList } = videoListManager.getBatchState();
const total = videoList.length;
// 未在批量处理中,直接结束
if (!isProcessing || total === 0) {
videoListManager.clearBatchState();
return;
}
// 所有视频处理完成
if (currentIndex >= total) {
videoListManager.clearBatchState();
this.showProgress(`批量采集完成!共处理${total}个视频`, 100, 'success');
this.hideProgress(3000);
// 清除已处理的视频(可选:保留原列表则删除此行)
videoListManager.clearList();
this.updateListPanel();
return;
}
// 处理当前视频
const currentUrl = videoList[currentIndex];
this.showProgress(`正在处理 ${currentIndex+1}/${total}:${currentUrl}`,
Math.round((currentIndex/total)*100), 'processing');
try {
// 动态等待页面加载(替换固定延迟,确保核心数据加载完成)
await new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
// 检测B站视频核心数据是否加载
if (window.__INITIAL_STATE__?.videoData || window.__INITIAL_STATE__?.epInfo) {
clearInterval(checkInterval);
resolve();
}
}, 500);
// 超时保护(10秒)
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error('页面加载超时'));
}, 10000);
});
// 获取并保存字幕到Dinox
await this.processAndSaveSubtitle();
// 处理下一个视频:更新状态并跳转
const newIndex = currentIndex + 1;
videoListManager.saveBatchState({
isProcessing: true,
currentIndex: newIndex,
videoList: videoList // 列表不变,通过索引控制进度
});
// 从本地列表中移除已处理视频(可选)
videoListManager.removeVideo(currentUrl);
this.updateListPanel();
// 跳转下一个视频(如果还有)
if (newIndex < total) {
this.showProgress(`处理完成,跳转下一个(${newIndex+1}/${total})`, 100, 'success');
setTimeout(() => {
window.location.href = videoList[newIndex];
}, 1500); // 延迟1.5秒确保保存完成
}
} catch (error) {
console.error(`批量处理失败(${currentIndex+1}/${total}):`, error);
this.showProgress(`处理失败:${error.message},继续下一个`,
Math.round((currentIndex/total)*100), 'error');
// 继续处理下一个
const newIndex = currentIndex + 1;
videoListManager.saveBatchState({
isProcessing: true,
currentIndex: newIndex,
videoList: videoList
});
setTimeout(() => {
if (newIndex < total) {
window.location.href = videoList[newIndex];
} else {
videoListManager.clearBatchState();
}
}, 3000);
}
},
// 显示进度提示
showProgress(message, percentage, status = 'processing') {
this.statusText.textContent = message;
this.progressBarInner.style.width = `${percentage}%`;
let indicatorColor = '#007aff';
if (status === 'success') indicatorColor = '#34c759';
else if (status === 'error') indicatorColor = '#ff3b30';
this.statusIndicator.style.backgroundColor = indicatorColor;
this.progressBarInner.style.backgroundColor = indicatorColor;
this.progressToast.style.opacity = '1';
this.progressToast.style.transform = 'translateY(0)';
},
// 隐藏进度提示
hideProgress(delay = 2000) {
setTimeout(() => {
this.progressToast.style.opacity = '0';
this.progressToast.style.transform = 'translateY(-50px)';
}, delay);
},
// 处理并保存字幕到Dinox(核心功能)
async processAndSaveSubtitle() {
try {
if (!DINEX_API_TOKEN || DINEX_API_TOKEN === 'YOUR_API_TOKEN_HERE') {
throw new Error('请先在脚本中配置 dinox API Token!');
}
this.showProgress('正在初始化...', 10, 'processing');
if (this.isInitializing) { await new Promise(resolve => setTimeout(resolve, 500)); }
if (!this.subtitle) { await this.setupData(); }
if (!this.subtitle || this.subtitle.count === 0) { throw new Error('当前视频没有可用字幕'); }
this.showProgress('查找中文字幕...', 30, 'processing');
// 优先选择中文字幕
let preferredSubtitle = null;
const chineseKeywords = ['zh-hans', 'zh-cn', '简体中文', 'zh-hant', 'zh-tw', 'zh-hk', '繁体中文', '中文'];
for (const item of this.subtitle.subtitles) {
const lan = (item.lan || '').toLowerCase();
const lanDoc = (item.lan_doc || '').toLowerCase();
if (chineseKeywords.some(keyword => lan.includes(keyword) || lanDoc.includes(keyword))) {
preferredSubtitle = item; break;
}
}
const targetSubtitle = preferredSubtitle || this.subtitle.subtitles[0];
if (!targetSubtitle) { throw new Error('无法获取有效的字幕信息'); }
// 获取字幕内容
this.showProgress(`获取 ${targetSubtitle.lan_doc}...`, 50, 'processing');
const data = await this.getSubtitle(targetSubtitle.lan);
if (!data || !(data.body instanceof Array)) throw new Error('字幕数据格式错误');
// 构建保存内容(带视频链接)
this.showProgress('正在保存到 dinox...', 75, 'processing');
const subtitleText = data.body.map(item => item.content).join(' \n');
const videoTitle = this.getInfo('title') || this.getInfo('videoData')?.title || document.title;
const videoLink = window.location.href.split('?')[0];
const finalContent = `${subtitleText}\n\n---\n\n视频原始链接 (Video Link):\n${videoLink}`;
// 调用Dinox API保存
console.log('开始调用Dinox API,标题:', videoTitle);
const response = await fetch(DINEX_API_URL, {
method: 'POST',
headers: {
'Authorization': DINEX_API_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: videoTitle, content: finalContent, tags: ["bilibili"] })
});
// 处理API响应(增加详细日志)
console.log('Dinox API响应状态:', response.status);
if (response.ok) {
const result = await response.json();
console.log('Dinox保存成功:', result);
this.showProgress('保存成功!', 100, 'success');
} else {
const errorResult = await response.json().catch(() => ({ message: response.statusText }));
console.error('Dinox API错误详情:', errorResult);
throw new Error(`保存失败: ${errorResult.message || '未知错误'}(状态码:${response.status})`);
}
} catch (error) {
console.error('自动保存失败:', error);
this.showProgress(error.message, 100, 'error');
throw error; // 让批量处理感知错误
}
},
// 获取字幕内容
getSubtitle(lan) {
const item = this.subtitle.subtitles.find(s => s.lan === lan);
if (!item) throw new Error('找不到所选语言字幕: ' + lan);
return oldFetch(item.subtitle_url).then(res => res.json());
},
// 获取视频信息
getInfo(name) {
return this.window[name]
|| this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__[name]
|| this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.epInfo && this.window.__INITIAL_STATE__.epInfo[name]
|| this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData && this.window.__INITIAL_STATE__.videoData[name];
},
// 获取视频ID信息
getEpid() {
return this.getInfo('id')
|| /ep(\d+)/.test(location.pathname) && +RegExp.$1
|| /ss\d+/.test(location.pathname);
},
// 获取视频详细信息(cid等)
getEpInfo() {
const bvid = this.getInfo('bvid'), epid = this.getEpid(), cidMap = this.getInfo('cidMap'), page = this?.window?.__INITIAL_STATE__?.p;
let ep = cidMap?.[bvid];
if (ep) { this.aid = ep.aid; this.bvid = ep.bvid; this.cid = ep.cids[page]; return this.cid; }
ep = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries?.find(query => query?.queryKey?.[0] == "pgc/view/web/season")?.state?.data;
ep = (ep?.seasonInfo ?? ep)?.mediaInfo?.episodes?.find(ep => epid == true || ep.ep_id == epid);
if (ep) { this.epid = ep.ep_id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; }
ep = this.window.__INITIAL_STATE__?.epInfo;
if (ep) { this.epid = ep.id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; }
ep = this.window.playerRaw?.getManifest();
if (ep) { this.epid = ep.episodeId; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; }
},
// 初始化字幕数据
async setupData() {
this.isInitializing = true;
try {
const currentPcid = this.getEpInfo();
if (this.subtitle && (this.pcid === currentPcid)) { this.isInitializing = false; return this.subtitle; }
this.pcid = currentPcid;
if ((!this.cid && !this.epid) || (!this.aid && !this.bvid)) { throw new Error("无法获取视频信息"); }
this.subtitle = { count: 0, subtitles: [] };
const res = await oldFetch(`https://api.bilibili.com/x/player${this.cid ? '/wbi' : ''}/v2?${this.cid ? `cid=${this.cid}` : `&ep_id=${this.epid}`}${this.aid ? `&aid=${this.aid}` : `&bvid=${this.bvid}`}`, { credentials: 'include' });
if (!res.ok) { throw new Error('请求字幕配置失败'); }
const ret = await res.json();
if (ret.code === -404) {
const fallbackRes = await oldFetch(`//api.bilibili.com/x/v2/dm/view?${this.aid ? `aid=${this.aid}` : `bvid=${this.bvid}`}&oid=${this.cid}&type=1`, { credentials: 'include' });
const fallbackRet = await fallbackRes.json();
if (fallbackRet.code !== 0) throw new Error('无法读取APP字幕配置');
this.subtitle = fallbackRet.data && fallbackRet.data.subtitle || { subtitles: [] };
this.subtitle.count = this.subtitle.subtitles.length;
this.subtitle.subtitles.forEach(item => (item.subtitle_url = item.subtitle_url.replace(/https?:\/\//, '//')));
return this.subtitle;
}
if (ret.code !== 0 || !ret.data || !ret.data.subtitle) { this.subtitle = { count: 0, subtitles: [] }; return this.subtitle; }
this.subtitle = ret.data.subtitle;
this.subtitle.count = this.subtitle.subtitles.length;
return this.subtitle;
} catch (error) { console.error("setupData 失败:", error); this.subtitle = null; } finally { this.isInitializing = false; }
},
// 添加单个保存按钮
addButtons() {
if (elements.getAs('#subtitle-viewer-btn')) return;
const insertionPoint = elements.getAs('.video-toolbar-left');
if (!insertionPoint) return;
elements.createAs('a', {
id: 'subtitle-viewer-btn', className: 'bili-subtitle-btn-dragon', title: '保存当前字幕到 dinox', innerHTML: '🐉',
onclick: () => this.processAndSaveSubtitle()
}, insertionPoint);
this.buttonAdded = true;
},
// 重置状态
reset() {
const btn = elements.getAs('#subtitle-viewer-btn');
if (btn) btn.remove();
this.buttonAdded = false; this.subtitle = null; this.pcid = null; this.cid = null; this.epid = null; this.aid = null; this.bvid = null;
},
// 初始化脚本核心逻辑
async runInitialization() {
if (this.isInitializing) return;
await new Promise(resolve => {
const interval = setInterval(() => {
if ((this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData) || this.window.__NEXT_DATA__) {
clearInterval(interval);
resolve();
}
}, 500);
});
await this.setupData();
this.addButtons();
// 检查是否处于批量处理中,如果是则继续处理
const { isProcessing } = videoListManager.getBatchState();
if (isProcessing) {
setTimeout(() => this.processBatch(), 1000);
}
},
// 监听页面变化(用于单页应用跳转)
handlePageChanges() {
let lastUrl = location.href;
const observer = new MutationObserver(async () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
this.reset();
setTimeout(() => { this.runInitialization(); }, 1000);
}
});
observer.observe(document.body, { childList: true, subtree: true });
},
// 初始化入口
init() {
this.createUI();
this.handlePageChanges();
this.runInitialization();
}
};
// 启动脚本
bilibiliViewer.init();
})();
