// ==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(); })();