// ==UserScript==
// @name (适配新版页面)哔哩哔哩收藏夹导出
// @namespace https://github.com/vanilla-tiramisu/Bilibili-Favlist-Export
// @icon https://www.bilibili.com/favicon.ico
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @version 3.0.0
// @license GPL-3.0
// @description (适配新版页面)导出哔哩哔哩收藏夹为 CSV 或 HTML 文件,以便导入 Raindrop 或 Firefox。
// @author vanilla-tiramisu
// @match http*://space.bilibili.com/*/*
// @grant GM_addStyle
// @grant GM_download
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function () {
'use strict';
const DELAY = 2000;
let csvHeaderOptions = {
title: "\uFEFFtitle",
url: "url",
foldername: "folder",
created: "created"
};
let csvHeaderActive = ["\uFEFFtitle", "url", "folder", "created"];
function updateCSVHeader() {
csvHeaderActive = Object.keys(csvHeaderOptions).filter(option => csvInclude[option]).map(option => csvHeaderOptions[option]);
}
let csvContent = csvHeaderActive.join(",") + "\n";
let htmlTemplateStart = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>{BOOKMARK_TITLE}</H1>
<DL><p><DT><H3 ADD_DATE="{dateNow}" LAST_MODIFIED="{dateNow}">{globalFolderName}</H3>\n<DL><p>`;
const HTML_TEMPLATE_END = `</DL><p>`;
let htmlContent = "";
let globalParentFolderName = "";
let csvInclude = {
title: true,
url: true,
foldername: true,
created: true
};
let exportCurrentFolderOnly = false;
GM_addStyle(`
#bilibili-export-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #f6f8fa, #e9ecef);
border-radius: 24px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1), 0 1px 8px rgba(0, 0, 0, 0.06);
padding: 30px;
width: 90%;
max-width: 400px;
display: none;
z-index: 10000;
font-family: 'Segoe UI', 'Roboto', sans-serif;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
#bilibili-export-panel h2 {
margin: 0 0 20px;
color: #00a1d6;
font-size: 28px;
text-align: center;
font-weight: 700;
}
#current-exporting {
margin-bottom: 20px;
padding: 10px;
background-color: rgba(0, 161, 214, 0.1);
border-left: 4px solid #00a1d6;
border-right: 4px solid #00a1d6;
border-radius: 4px;
font-size: 14px;
color: #00a1d6;
text-align: center;
transition: all 0.5s ease;
}
#current-exporting.completed {
background-color: rgba(76, 175, 80, 0.1);
border-left-color: #4CAF50;
border-right-color: #4CAF50;
color: #4CAF50;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
#current-exporting.completed {
animation: pulse 0.5s ease-in-out;
}
#formatSelector {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 25px;
position: relative;
background-color: #e0e0e0;
border-radius: 20px;
padding: 5px;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.formatButton {
z-index: 1;
padding: 10px 20px;
font-size: 16px;
color: #3c4043;
cursor: pointer;
transition: color 0.3s ease-in-out;
font-weight: 600;
flex: 1;
text-align: center;
}
.formatButton.selected {
color: #FFF;
}
.slider {
position: absolute;
left: 5px;
top: 5px;
background-color: #00a1d6;
border-radius: 15px;
transition: transform 0.3s ease-in-out;
height: calc(100% - 10px);
width: calc(50% - 5px);
z-index: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.toggle-switch {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding: 10px 15px;
background-color: #f1f3f4;
border-radius: 12px;
transition: all 0.3s ease;
}
.toggle-switch:hover {
background-color: #e8eaed;
}
.toggle-switch label {
font-size: 16px;
color: #3c4043;
font-weight: 600;
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.switch-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .switch-slider {
background-color: #00a1d6;
}
input:checked + .switch-slider:before {
transform: translateX(24px);
}
#export-button {
display: block;
width: 100%;
padding: 12px;
background-color: #00a1d6;
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 20px;
position: relative;
overflow: hidden;
}
#export-button:hover {
background-color: #0091c2;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 161, 214, 0.3);
}
#export-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
#export-button::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
background-color: rgba(255,255,255,0.2);
transition: width 0.3s ease;
}
.input-group {
margin-bottom: 15px;
}
.input-group input {
width: calc(100% - 20px);
padding: 10px;
border: 1px solid #dadce0;
border-radius: 8px;
font-size: 14px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: translate(-50%, -60%); }
to { transform: translate(-50%, -50%); }
}
#bilibili-export-panel.show {
display: block;
animation: fadeIn 0.3s ease-out, slideIn 0.3s ease-out;
}
#panel-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: none;
}
`);
let gen = listGen();
let panel = null;
let exportButton = null;
let formatButtons = null;
let folderInputSection = null;
let bookmarkTitleInput = null;
let globalFolderNameInput = null;
let lastAddedFolderName = "";
let totalPage = 1;
let currentPage = 0;
let isExporting = false;
let exportFormat = GM_getValue('exportFormat', 'csv');
function getCSVFileName() {
let userName = $(".nickname").text();
return userName + "的收藏夹.csv";
}
function getHTMLFileName() {
let userName = $(".nickname").text();
return userName + "的收藏夹.html";
}
function getFolderName() {
return document.querySelector(".favlist-info-detail__title .vui_ellipsis").innerHTML;
}
function escapeCSV(field) {
return '"' + String(field).replace(/"/g, '""') + '"';
}
function getCurrentTimestamp() {
return Math.floor(Date.now() / 1000);
}
function addHTMLFolder(folderName) {
let dateNow = getCurrentTimestamp();
if (folderName !== lastAddedFolderName) {
if (lastAddedFolderName !== "") {
htmlContent += `</DL><p>\n`;
}
htmlContent += `<DT><H3 ADD_DATE="${dateNow}" LAST_MODIFIED="${dateNow}">${folderName}</H3>\n<DL><p>\n`;
lastAddedFolderName = folderName;
}
}
function addHTMLBookmark(folderName, title, url, created) {
addHTMLFolder(folderName);
let dateNow = new Date(created).getTime() / 1000;
htmlContent += `<DT><A HREF="${url}" ADD_DATE="${dateNow}" LAST_MODIFIED="${dateNow}">${title}</A>\n`;
}
function generateCSVLine(folderName, title, url, created) {
let parts = [];
if (csvInclude.title) parts.push(escapeCSV(title));
if (csvInclude.url) parts.push(escapeCSV(url));
if (csvInclude.foldername) parts.push(escapeCSV(folderName));
if (csvInclude.created) parts.push(escapeCSV(created));
return parts.join(',');
}
function parseTime(timeText) {
if (timeText.includes("刚刚") || timeText.includes("小时前")) {
return new Date().toISOString().slice(0, 10);
}
else if (timeText.match(/\d{2}-\d{2}/)) {
if (timeText.match(/\d{4}-\d{2}-\d{2}/)) {
return timeText.match(/\d{4}-\d{2}-\d{2}/)[0];
}
else {
return new Date().getFullYear() + "-" + timeText.match(/\d{2}-\d{2}/)[0];
}
}
}
function getVideosFromPage() {
var results = [];
var folderName = getFolderName().replace(/\//g, '\\');
$(".fav-list-main .items__item").each(function () {
var titleElement = this.querySelector(".bili-video-card__title");
var title = titleElement.title.replace(/,/g, '');
if (title !== "已失效视频") {
let url = this.querySelector(".bili-video-card__title a").href
let subtitleLink = this.querySelector(".bili-video-card__subtitle a")
let timeText = ''
if (subtitleLink) // 一般视频
timeText = subtitleLink.querySelector("div:last-child>span").title
else { // 链接不可点击
let subtitle = this.querySelector(".bili-video-card__subtitle>span").title
if (subtitle.includes("收藏于")) {// 特殊视频,如电影
timeText = subtitle;
title = timeText.trim().split("·")[0].trim();
}
else { // 某些订阅合集中的视频
timeText = subtitle;
title = titleElement.title.replace(/,/g, '');
}
}
var created = parseTime(timeText.trim().split("·").slice(-1)[0].trim());
results.push(generateCSVLine(folderName, title, url, created));
if (exportFormat === "html") {
addHTMLBookmark(folderName, title, url, created);
}
}
});
return results.join('\n');
}
function processVideos() {
if (isExporting) {
csvContent += getVideosFromPage() + '\n';
currentPage++;
updateProgress(Math.round((currentPage / totalPage) * 100));
let turnPage = document.querySelector(".vui_pagenation--btn-side:last-child");
if (currentPage < totalPage && turnPage) {
// console.log(`已导出:${currentPage},${totalPage}`);
turnPage.click();
setTimeout(processVideos, DELAY);
} else {
if (exportCurrentFolderOnly) {
finishExport();
} else {
setTimeout(changeList, DELAY);
}
}
}
}
function* listGen() {
for (let list of $(".fav-collapse:nth-child(-n+2) .vui_collapse_item .fav-sidebar-item .vui_sidebar-item").get()) {
yield list;
}
}
function changeList() {
if (isExporting) {
if (exportCurrentFolderOnly) {
processVideos();
} else {
let list = gen.next().value;
if (list) {
list.click();
setTimeout(() => {
let PageCountDesc = document.querySelector(".vui_pagenation-go__count")
if (PageCountDesc)
totalPage = parseInt(PageCountDesc.innerHTML.match(/\d+/)[0]) || 1;
currentPage = 0;
updateProgress(0);
let currentFolderName = getFolderName();
document.querySelector('#current-exporting').textContent = `正在导出:${currentFolderName}`;
document.querySelector('#current-exporting').classList.remove('completed');
processVideos();
}, DELAY);
} else {
finishExport();
}
}
}
}
function updateProgress(percentage) {
exportButton.textContent = `导出中... ${percentage}%`;
exportButton.style.setProperty('--progress', `${percentage}%`);
exportButton.style.backgroundImage = `linear-gradient(to right, rgba(255,255,255,0.2) ${percentage}%, transparent ${percentage}%)`;
}
function finishExport() {
isExporting = false;
exportButton.textContent = "立即下载";
exportButton.disabled = false;
let currentExporting = document.querySelector('#current-exporting');
currentExporting.textContent = "导出完成";
currentExporting.classList.add('completed');
exportButton.onclick = () => {
if (exportFormat === "csv") {
downloadCSV();
} else if (exportFormat === "html") {
downloadHTML();
}
exportButton.textContent = "开始导出";
exportButton.disabled = true;
setTimeout(() => {
exportButton.disabled = false;
}, 3000);
};
}
function startExport() {
if (exportFormat === "html" && (!bookmarkTitleInput.value || !globalFolderNameInput.value)) {
alert("请配置书签标题和全局父文件夹名称。");
return;
}
GM_setValue('bookmarkTitle', bookmarkTitleInput.value);
GM_setValue('globalFolderName', globalFolderNameInput.value);
exportButton.disabled = true;
exportButton.textContent = "导出中... 0%";
isExporting = true;
htmlContent = "";
csvContent = "\uFEFF" + csvHeaderActive.join(",") + "\n";
document.querySelector('#current-exporting').textContent = "准备开始导出...";
document.querySelector('#current-exporting').classList.remove('completed');
if (exportCurrentFolderOnly) {
let PageCountDesc = document.querySelector(".vui_pagenation-go__count")
if (PageCountDesc)
totalPage = parseInt(PageCountDesc.innerHTML.match(/\d+/)[0]) || 1;
currentPage = 0;
processVideos();
} else {
gen = listGen();
changeList();
}
}
function downloadCSV() {
let fileName = getCSVFileName();
let blobUrl = URL.createObjectURL(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }));
GM_download({
url: blobUrl,
name: fileName,
onload: () => {
hidePanel();
},
onerror: () => {
alert('下载失败,正在尝试弹出新标签页进行下载,请允许弹窗权限');
let htmlContent = `
<html>
<head><meta charset="UTF-8"></head>
<body><a href="${blobUrl}" download="${fileName}">点击下载 CSV 文件</a></body>
</html>`;
let htmlBlob = new Blob([htmlContent], { type: 'text/html;charset=utf-8;' });
let htmlBlobUrl = URL.createObjectURL(htmlBlob);
window.open(htmlBlobUrl, '_blank');
}
});
}
function downloadHTML() {
let fileName = getHTMLFileName();
let globalParentFolderName = globalFolderNameInput.value;
let htmlFinalContent = htmlTemplateStart.replace("{globalFolderName}", globalFolderNameInput.value).replace("{BOOKMARK_TITLE}", bookmarkTitleInput.value.trim()) + htmlContent + HTML_TEMPLATE_END;
let blobUrl = URL.createObjectURL(new Blob([htmlFinalContent], { type: 'text/html;charset=utf-8;' }));
GM_download({
url: blobUrl,
name: fileName,
onload: () => {
hidePanel();
},
onerror: () => {
alert('下载失败,正在尝试弹出新标签页进行下载,请允许弹窗权限');
let htmlContent = `
<html>
<head><meta charset="UTF-8"></head>
<body><a href="${blobUrl}" download="${fileName}">点击下载 HTML 文件</a></body>
</html>`;
let htmlBlob = new Blob([htmlContent], { type: 'text/html;charset=utf-8;' });
let htmlBlobUrl = URL.createObjectURL(htmlBlob);
window.open(htmlBlobUrl, '_blank');
}
});
}
function createPanel() {
panel = document.createElement("div");
panel.id = "bilibili-export-panel";
panel.innerHTML = `
<h2>收藏夹导出设置</h2>
<div id="current-exporting">点击下方按钮开始导出</div>
<div id="formatSelector">
<div class="slider"></div>
<div class="formatButton" data-format="csv">CSV 格式</div>
<div class="formatButton" data-format="html">HTML 格式</div>
</div>
<div id="csv-options">
<div class="toggle-switch">
<label for="include-title">包含标题</label>
<label class="switch">
<input type="checkbox" id="include-title" checked>
<span class="switch-slider"></span>
</label>
</div>
<div class="toggle-switch">
<label for="include-url">包含网址</label>
<label class="switch">
<input type="checkbox" id="include-url" checked>
<span class="switch-slider"></span>
</label>
</div>
<div class="toggle-switch">
<label for="include-foldername">包含收藏夹名称</label>
<label class="switch">
<input type="checkbox" id="include-foldername" checked>
<span class="switch-slider"></span>
</label>
</div>
<div class="toggle-switch">
<label for="include-created">包含收藏时间</label>
<label class="switch">
<input type="checkbox" id="include-created" checked>
<span class="switch-slider"></span>
</label>
</div>
</div>
<div id="html-options" style="display: none;">
<div class="input-group">
<input type="text" id="bookmark-title" placeholder="书签标题 (H1)">
</div>
<div class="input-group">
<input type="text" id="global-folder-name" placeholder="全局父文件夹名称">
</div>
</div>
<div class="toggle-switch">
<label for="export-current-folder-only">仅导出当前文件夹</label>
<label class="switch">
<input type="checkbox" id="export-current-folder-only">
<span class="switch-slider"></span>
</label>
</div>
<button id="export-button">开始导出</button>
`;
document.body.appendChild(panel);
let overlay = document.createElement("div");
overlay.id = "panel-overlay";
document.body.appendChild(overlay);
formatButtons = panel.querySelectorAll('.formatButton');
formatButtons.forEach(button => {
button.addEventListener('click', () => {
exportFormat = button.dataset.format;
GM_setValue('exportFormat', exportFormat);
updateFormatButtons();
toggleOptions();
});
});
folderInputSection = panel.querySelector('#html-options');
bookmarkTitleInput = panel.querySelector('#bookmark-title');
globalFolderNameInput = panel.querySelector('#global-folder-name');
['title', 'url', 'foldername', 'created'].forEach(option => {
panel.querySelector(`#include-${option}`).addEventListener('change', (e) => {
csvInclude[option] = e.target.checked;
GM_setValue(`include_${option}`, csvInclude[option]);
updateCSVHeader();
});
});
panel.querySelector('#export-current-folder-only').addEventListener('change', (e) => {
exportCurrentFolderOnly = e.target.checked;
GM_setValue('exportCurrentFolderOnly', exportCurrentFolderOnly);
});
exportButton = panel.querySelector('#export-button');
exportButton.onclick = startExport;
overlay.addEventListener('click', hidePanel);
updateFormatButtons();
toggleOptions();
loadSavedSettings();
}
function updateFormatButtons() {
formatButtons.forEach(button => {
button.classList.toggle('selected', button.dataset.format === exportFormat);
});
const slider = panel.querySelector('.slider');
slider.style.transform = exportFormat === 'csv' ? 'translateX(0)' : 'translateX(100%)';
}
function toggleOptions() {
panel.querySelector('#csv-options').style.display = exportFormat === 'csv' ? 'block' : 'none';
panel.querySelector('#html-options').style.display = exportFormat === 'html' ? 'block' : 'none';
}
function loadSavedSettings() {
['title', 'url', 'foldername', 'created'].forEach(option => {
const saved = GM_getValue(`include_${option}`);
if (saved !== undefined) {
csvInclude[option] = saved;
panel.querySelector(`#include-${option}`).checked = saved;
}
});
updateCSVHeader();
bookmarkTitleInput.value = GM_getValue('bookmarkTitle', '');
globalFolderNameInput.value = GM_getValue('globalFolderName', '');
exportCurrentFolderOnly = GM_getValue('exportCurrentFolderOnly', false);
panel.querySelector('#export-current-folder-only').checked = exportCurrentFolderOnly;
}
function showPanel() {
panel.style.opacity = 0;
panel.style.display = 'block';
document.getElementById('panel-overlay').style.display = 'block';
setTimeout(() => {
panel.style.opacity = 1;
}, 0);
}
function hidePanel() {
panel.style.opacity = 0;
document.getElementById('panel-overlay').style.display = 'none';
setTimeout(() => {
panel.style.display = 'none';
}, 300);
}
function init() {
createPanel();
GM_registerMenuCommand("导出 Bilibili 收藏夹", showPanel);
}
if (location.href.includes("https://space.bilibili.com/") && location.href.includes("/favlist")) {
init();
}
})();