// ==UserScript==
// @name 豆瓣观影记录导出工具
// @namespace http://tampermonkey.net/
// @version 2.7.0
// @description 将豆瓣电影的个人收藏(看过/想看)导出为结构化的 CSV 文件,包含 IMDB ID
// @author Gemini_Helper
// @match https://movie.douban.com/people/*/collect*
// @match https://movie.douban.com/people/*/wish*
// @grant GM_xmlhttpRequest
// @connect movie.douban.com
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- 核心配置 ---
const GLOBAL_SLEEP = 2000;
const CACHE_KEY = 'douban_export_v27_cache';
let isPaused = false;
let pauseResolver = null;
let captchaResolver = null;
let panelEl, statusText, movieDisplay, actionBtn, spinner;
const getCache = () => JSON.parse(localStorage.getItem(CACHE_KEY) || "{}");
const setCache = (id, data) => {
const cache = getCache();
cache[id] = data;
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
};
const extractEarliestDate = (introStr) => {
if (!introStr) return '';
const dateRegex = /\d{4}-\d{2}-\d{2}/g;
const matches = introStr.match(dateRegex);
if (!matches) return '';
const sorted = matches.sort((a, b) => new Date(a) - new Date(b));
return sorted[0];
};
const injectStyles = () => {
if (document.getElementById('db-export-styles')) return;
const style = document.createElement('style');
style.id = 'db-export-styles';
style.innerHTML = `
@keyframes db-spin { to { transform: rotate(360deg); } }
.db-spinner {
width: 18px; height: 18px;
border: 3px solid rgba(0,123,255,0.2);
border-top-color: #007bff;
border-radius: 50%;
animation: db-spin 0.8s linear infinite;
margin-right: 10px;
display: none;
}
.is-loading .db-spinner { display: inline-block; }
`;
document.head.appendChild(style);
};
const createStatusPanel = () => {
if (panelEl) { panelEl.style.display = 'block'; return; }
injectStyles();
panelEl = document.createElement('div');
panelEl.style = "position:fixed; top:20px; right:20px; width:360px; background:#fff; border-radius:12px; z-index:99999; box-shadow:0 10px 30px rgba(0,0,0,0.2); font-family:system-ui, -apple-system, sans-serif; overflow:hidden; border: 1px solid #eee;";
panelEl.innerHTML = `
豆瓣电影导出工具
V2.7
`;
document.body.appendChild(panelEl);
statusText = panelEl.querySelector('#export-status-text');
movieDisplay = panelEl.querySelector('#export-movie-display');
actionBtn = panelEl.querySelector('#export-main-btn');
spinner = panelEl.querySelector('#panel-body');
actionBtn.onclick = handleBtnClick;
};
async function handleBtnClick() {
const text = actionBtn.innerText;
if (text === "🚀 开始导出") {
actionBtn.innerText = "⏸️ 暂停任务";
startExport();
} else if (text === "⏸️ 暂停任务") {
isPaused = true;
actionBtn.innerText = "▶️ 继续任务";
actionBtn.style.background = "#fd7e14";
updateUI("任务已暂停", null, false, false);
} else if (text === "▶️ 继续任务") {
isPaused = false;
actionBtn.innerText = "⏸️ 暂停任务";
actionBtn.style.background = "#007bff";
if (pauseResolver) pauseResolver();
} else if (text === "✅ 我已完成验证") {
actionBtn.innerText = "⏸️ 暂停任务";
actionBtn.style.background = "#007bff";
if (captchaResolver) captchaResolver();
}
}
const updateUI = (status, movie = null, isLoading = false, isError = false) => {
if (!statusText) return;
statusText.innerText = status;
statusText.style.color = isError ? "#dc3545" : "#666";
if (movie) movieDisplay.innerText = movie;
if (isLoading) spinner.classList.add('is-loading');
else spinner.classList.remove('is-loading');
};
const initUI = () => {
const header = document.querySelector('.db-usr-profile .info h1') || document.querySelector('h1');
if (!header) return;
const startBtn = document.createElement('button');
startBtn.innerHTML = '📤 导出数据';
startBtn.style = "margin-left:10px; padding:6px 12px; font-size:13px; cursor:pointer; background:#007bff; color:#fff; border:none; border-radius:4px; font-weight:bold;";
startBtn.onclick = createStatusPanel;
header.appendChild(startBtn);
};
const sleep = ms => new Promise(res => setTimeout(res, ms));
function gmRequest(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url: url, timeout: 20000,
headers: { "User-Agent": navigator.userAgent },
onload: (res) => {
if (res.finalUrl.includes('sec.douban.com')) reject({ status: 'CAPTCHA' });
else resolve({ text: res.responseText, finalUrl: res.finalUrl });
},
onerror: (err) => reject(err)
});
});
}
// --- 修改点:仅通过豆瓣详情页获取 IMDb ---
async function getImdbId(doubanId, movieUrl, parser) {
const cache = getCache();
if (cache[doubanId]) return cache[doubanId].imdb;
try {
const dbResp = await gmRequest(movieUrl);
const doc = parser.parseFromString(dbResp.text, "text/html");
const infoText = doc.querySelector('#info')?.innerText || '';
const match = infoText.match(/IMDb:\s*(tt\d+)/);
const imdb = match ? match[1] : '';
if (imdb) setCache(doubanId, { imdb });
return imdb;
} catch (err) {
if (err.status === 'CAPTCHA') throw new Error("STOP_CAPTCHA");
return '';
}
}
async function startExport() {
const testWin = window.open('', '_blank', 'width=1,height=1,left=10000,top=10000');
if (!testWin) {
updateUI("⚠️ 弹窗被拦截,请允许弹窗后重试", "权限不足", false, true);
return;
} else { testWin.close(); }
const isWish = window.location.pathname.includes('wish');
const results = [];
const parser = new DOMParser();
let start = 0;
console.log(`%c[V2.7] 启动直链模式 | 间隔: ${GLOBAL_SLEEP}ms | 移除 NeoDB 依赖`, "color: #007bff; font-weight: bold;");
while (true) {
if (isPaused) await new Promise(r => { pauseResolver = r; });
try {
updateUI("扫描清单页...", `Offset: ${start}`, true);
const listResp = await gmRequest(`${window.location.origin}${window.location.pathname}?start=${start}&sort=time&mode=list`);
const doc = parser.parseFromString(listResp.text, 'text/html');
const items = doc.querySelectorAll('.item');
if (items.length === 0) break;
for (const item of items) {
if (isPaused) await new Promise(r => { pauseResolver = r; });
const linkEl = item.querySelector('.title a');
if (!linkEl) continue;
const movieUrl = linkEl.href;
const doubanId = movieUrl.match(/subject\/(\d+)\//)?.[1];
const movieFullTitle = linkEl.innerText.trim();
const markDate = item.querySelector('.date')?.innerText.trim() || '';
const ratingClass = item.querySelector('[class^="rating"]')?.className || '';
const myRating = ratingClass ? ratingClass.slice(6, 7) : '';
// 评价清洗:剔除“(x 有用)”
let rawComment = item.querySelector('.comment')?.innerText.trim() || '';
const myComment = rawComment.replace(/\s*\(\d+\s*有用\)$/, '');
const introEl = item.querySelector('.intro');
const releaseDate = extractEarliestDate(introEl ? introEl.innerText : '');
updateUI(`获取详情中... (${results.length + 1})`, movieFullTitle, true);
let imdb = '';
try {
imdb = await getImdbId(doubanId, movieUrl, parser);
} catch (err) {
if (err.message === "STOP_CAPTCHA") {
updateUI("⚠️ 豆瓣要求验证码", "请在弹出窗口验证", false, true);
actionBtn.innerText = "✅ 我已完成验证";
window.open(movieUrl, "_blank");
await new Promise(r => { captchaResolver = r; });
imdb = await getImdbId(doubanId, movieUrl, parser);
}
}
const cleanTitle = movieFullTitle.split(' / ')[0].trim();
results.push({ doubanId, title: cleanTitle, imdb, releaseDate, markDate, myRating, myComment, link: movieUrl });
// 完整字段日志
console.log(
`%c[${results.length}] %cID: %s | 标题: %s | IMDb: %s | 上映: %s | 标记: %s | 评分: %s | 评价: %s | 链接: %s`,
"color: #28a745; font-weight: bold;", "color: #333;",
doubanId, cleanTitle, imdb || 'N/A', releaseDate || '未知', markDate, myRating || '0', myComment || '无', movieUrl
);
// 抓取每一条后强制等待,模拟人类行为
await sleep(GLOBAL_SLEEP);
document.getElementById('export-progress-tag').innerText = `已获取 ${results.length} 条`;
}
start += items.length;
await sleep(500); // 换页额外缓冲
} catch (e) { break; }
}
updateUI("导出中...", "正在生成文件", true);
downloadCSV(results, isWish ? '豆瓣想看导出' : '豆瓣看过导出');
setTimeout(() => {
updateUI("🎉 任务圆满完成", `共导出 ${results.length} 条记录`, false, false);
actionBtn.style.display = 'none';
}, 1500);
}
function downloadCSV(data, fileName) {
const headers = ["豆瓣ID", "标题", "IMDb ID", "上映日期", "标记日期", "我的评分", "我的评价", "条目链接"];
const rows = data.map(i => [
`"${i.doubanId}"`,
`"${i.title.replace(/"/g, '""')}"`,
`"${i.imdb}"`,
`"${i.releaseDate}"`,
`"${i.markDate}"`,
`"${i.myRating}"`,
`"${i.myComment.replace(/"/g, '""')}"`,
`"${i.link}"`
]);
const csvContent = "\uFEFF" + headers.join(",") + "\r\n" + rows.map(e => e.join(",")).join("\r\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${fileName}_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
}
initUI();
})();