// ==UserScript== // @name 豆瓣评分汇 | Douban Rating Hub // @author lzblack // @namespace https://github.com/lzblack // @homepageURL https://github.com/lzblack/userscripts // @supportURL https://github.com/lzblack/userscripts/issues // @version 1.1.9 // @description 豆瓣全品类(电影、剧集、图书、音乐、游戏、播客)评分聚合 — IMDB、烂番茄、Letterboxd、Goodreads、Trakt 等 17 个平台;在 title 上方显示外部权威榜单胶囊 // @match https://book.douban.com/subject/* // @match https://movie.douban.com/subject/* // @match https://music.douban.com/subject/* // @match https://www.douban.com/game/* // @match https://game.douban.com/subject/* // @match https://www.douban.com/location/drama/* // @match https://www.douban.com/podcast/* // @connect imdb.com // @connect p.media-imdb.com // @connect api.graphql.imdb.com // @connect rottentomatoes.com // @connect backend.metacritic.com // @connect www.metacritic.com // @connect letterboxd.com // @connect api.themoviedb.org // @connect neodb.social // @connect goodreads.com // @connect amazon.com // @connect weread.qq.com // @connect api.bgm.tv // @connect api.jikan.moe // @connect api.discogs.com // @connect store.steampowered.com // @connect itunes.apple.com // @connect podcasts.apple.com // @connect xyzrank.eddiehe.top // @connect rank.douban.zhili.dev // @connect api.trakt.tv // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @icon https://img3.doubanio.com/favicon.ico // @icon64 https://img3.doubanio.com/favicon.ico // @license MIT // @updateURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-rating-hub/douban-rating-hub.user.js // @downloadURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-rating-hub/douban-rating-hub.user.js // ==/UserScript== (function () { 'use strict'; // ============================================================ // Deps — 对 GM API 的迁移友好封装 // ============================================================ const deps = { request(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url, headers: opts.headers || {}, data: opts.data || undefined, timeout: opts.timeout || 15000, // 默认匿名 — 评分 channel 查的是公开数据,绝不能带用户的 // Amazon/Goodreads/weread 等站点 cookie,否则返回个性化结果会污染 // 通用评分视角。调用方若需带 cookie 显式传 anonymous:false。 anonymous: opts.anonymous !== false, onload(resp) { resolve(resp); }, onerror(err) { reject(new Error('Request failed: ' + url)); }, ontimeout() { reject(new Error('Request timeout: ' + url)); }, }); }); }, storage: { get(key, fallback = null) { const raw = GM_getValue(key); if (raw === undefined || raw === null) return fallback; try { return JSON.parse(raw); } catch { return raw; } }, set(key, value) { GM_setValue(key, JSON.stringify(value)); }, remove(key) { GM_deleteValue(key); }, listKeys() { return GM_listValues(); }, }, log(...args) { console.log('[RatingHub]', ...args); }, parseHTML(html) { return new DOMParser().parseFromString(html, 'text/html'); }, }; // ============================================================ // Util — 给榜单功能用的小工具(v1.1.0 新增) // ============================================================ /** * HTML 转义 — 用于安全地把字符串插入 innerHTML。 * @param {*} input * @returns {string} */ function escapeHtml(input) { return String(input == null ? '' : input) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function safeLinkUrl(input) { const url = String(input == null ? '' : input).trim(); return /^https?:\/\//i.test(url) ? url : '#'; } // ============================================================ // 跨平台标题/年份匹配 — 纯函数,无 DOM/网络副作用(见 test/match.test.cjs) // ============================================================ /** * 标题归一化 — 用于跨平台标题比较:& → and,转小写,去掉所有非字母数字字符。 * @param {*} s * @returns {string} */ function normalizeTitle(s) { return (s == null ? '' : String(s)).replace(/&/g, 'and').toLowerCase().replace(/[^a-z0-9]/g, ''); } /** * 两个年份是否在 ±1 内(视为同一部片)。外片在中国上映常滞后约 1 年,故留 1 年容差。 * 任一年份缺失或非法 → false(无法判定,不视为匹配)。 * @param {*} a * @param {*} b * @returns {boolean} */ function yearWithinOne(a, b) { const na = parseInt(a, 10); const nb = parseInt(b, 10); if (isNaN(na) || isNaN(nb)) return false; return Math.abs(na - nb) <= 1; } /** * 从搜索候选里挑出最匹配的条目,用「标题相关 → 年份 → 相关度排名」消歧。 * 通用于 RT 与 MC 的搜索结果。candidates: [{nameNorm, year, href}],顺序即搜索相关度排序。 * * 1. 先筛标题相关的候选:归一化精确相等,或两边互为**前缀/后缀**且「多出来的那段」 * 不是另一边的子串(v1.1.8 起,旧规则「任意 indexOf」会错配)。 * - 接受:"Lee Cronin's The Mummy" 后缀含 "The Mummy",多出 "leecronins" 不在 query 中 * - 拒绝:"Home Sweet Home" 后缀含 "Sweet Home",但多出 "home" 又出现在 query "sweethome" 中 * → token 重叠/伪关系,不是同一片名的修饰版本 * - 拒绝:"Home Sweet Home Alone" 完全是中段子串,无前缀/后缀关系 * MC 搜索结果尾部会混入大量无关条目(按 token 模糊匹配),必须先过滤, * 否则纯按年份会误命中无关的同年片。 * 2. 有 queryYear:在相关候选里取年份匹配(±1)的第一条(搜索已把最相关的排前)。 * 这既避开同名但错年份的经典老片(把 2026 版《木乃伊》配到 1999 版), * 也跳过同年但无关/无评分的条目。 * 3. 无 queryYear 或无年份匹配:相关候选里精确相等优先,否则取第一条(不回归缺年份条目)。 * @returns {{nameNorm:string, year:string, href:string}|null} */ function pickByYearThenTitle(candidates, queryNorm, queryYear) { if (!candidates || candidates.length === 0) return null; const relevant = []; for (let i = 0; i < candidates.length; i++) { const n = candidates[i].nameNorm; if (!n) continue; if (n === queryNorm) { relevant.push(candidates[i]); continue; } if (!queryNorm || queryNorm.length < 4) continue; // 必须是前缀/后缀关系:取长串和短串,看长串是否以短串开头或结尾 const longer = n.length >= queryNorm.length ? n : queryNorm; const shorter = n.length >= queryNorm.length ? queryNorm : n; let removed = null; if (longer.startsWith(shorter)) removed = longer.slice(shorter.length); else if (longer.endsWith(shorter)) removed = longer.slice(0, longer.length - shorter.length); if (removed === null) continue; // 「多出来的那段」反向包含于短串 → token 重叠/伪关系,拒绝 // 关键例:n="homesweethome", q="sweethome",removed="home","home" ⊂ "sweethome" → 拒绝 if (shorter.indexOf(removed) !== -1) continue; relevant.push(candidates[i]); } if (relevant.length === 0) return null; const qy = parseInt(queryYear, 10); if (!isNaN(qy)) { for (let i = 0; i < relevant.length; i++) { if (yearWithinOne(relevant[i].year, qy)) return relevant[i]; } } // 无 queryYear / 无年份匹配:精确相等优先,否则第一条相关候选 for (let i = 0; i < relevant.length; i++) { if (relevant[i].nameNorm === queryNorm) return relevant[i]; } return relevant[0]; } /** * 从 RT 详情页 HTML 提取上映年份(JSON 字段 "releaseYear":"2026")。 * 用于校验 fast-path 命中的缓存 URL 是否仍指向正确年份的片(治被污染的 slugMap)。 * @param {string} html * @returns {number|null} */ function extractRtDetailYear(html) { if (!html) return null; const m = html.match(/"releaseYear"\s*:\s*"?(\d{4})/); return m ? parseInt(m[1], 10) : null; } /** * 计算 RT 搜索行的「用于年份消歧的年份」。 * 电影行有 `release-year`,直接用。TV 行只有 `startyear` / `endyear`(多季播出区间)—— * 若豆瓣季页年份落在 [startYear, endYear || 当前年],把候选年视为豆瓣年份,避免 S2/S3 * 因 startyear 与 queryYear 差几年被 ±1 年份消歧拒掉。否则用 startyear。 * @param {number|string|null} releaseYear 电影行的 release-year(中划线属性) * @param {number|string|null} startYear TV 行的 startyear(无中划线) * @param {number|string|null} endYear TV 行的 endyear(无中划线,空=至今) * @param {number|string|null} queryYear 豆瓣条目年份 * @returns {string} 用作 candidate.year 的字符串(不可用时返回 '') */ function computeRtCandidateYear(releaseYear, startYear, endYear, queryYear) { if (releaseYear) return String(releaseYear); const startY = parseInt(startYear, 10); if (isNaN(startY)) return ''; const endY = parseInt(endYear, 10); const queryY = parseInt(queryYear, 10); const effectiveEnd = isNaN(endY) ? new Date().getFullYear() : endY; if (!isNaN(queryY) && queryY >= startY && queryY <= effectiveEnd) { return String(queryY); } return String(startY); } /** * 从 RT 详情页 HTML 抽取 critics / audience 分数与评论数。 * 兼容两种 JSON 形态: * - 电影页:"criticsScore": 83 * - TV 页 :"criticsScore": {"score":"83", "reviewCount":12, ...} * v1.1.8 之前仅匹配数字形态,TV 页走不上 JSON → DOM fallback 拿到的是季级分而非总评。 * @param {string} html * @returns {{criticsScore: number|null, audienceScore: number|null, criticsCount: number|null, audienceCount: number|null}} */ function extractRtScores(html) { let criticsScore = null; let audienceScore = null; let criticsCount = null; let audienceCount = null; if (!html) return { criticsScore, audienceScore, criticsCount, audienceCount }; const criticsNum = html.match(/"criticsScore"\s*:\s*(\d+)/); const audienceNum = html.match(/"audienceScore"\s*:\s*(\d+)/); if (criticsNum) criticsScore = parseInt(criticsNum[1], 10); if (audienceNum) audienceScore = parseInt(audienceNum[1], 10); const criticsObj = html.match(/"criticsScore"\s*:\s*\{[^}]+\}/); const audienceObj = html.match(/"audienceScore"\s*:\s*\{[^}]+\}/); if (criticsObj) { const cm = criticsObj[0].match(/"reviewCount"\s*:\s*(\d+)/); if (cm) criticsCount = parseInt(cm[1], 10); if (criticsScore == null) { const sm = criticsObj[0].match(/"score"\s*:\s*"?(\d+)/); if (sm) criticsScore = parseInt(sm[1], 10); } } if (audienceObj) { const am = audienceObj[0].match(/"reviewCount"\s*:\s*(\d+)/); if (am) audienceCount = parseInt(am[1], 10); if (audienceScore == null) { const sm = audienceObj[0].match(/"score"\s*:\s*"?(\d+)/); if (sm) audienceScore = parseInt(sm[1], 10); } } return { criticsScore, audienceScore, criticsCount, audienceCount }; } /** * 从豆瓣多条上映日期里取最早的年份(= 原始上映年,外部库 RT/MC/IMDB 均按此编目)。 * 豆瓣常把中国大陆重映日期排在最前(如《海上钢琴师》2019 重映在前、1998 意大利原版在后), * 直接取第一条会得到重映年,导致年份消歧把正确条目误判为错年份。取最早年规避之。 * @param {string[]} dateTexts * @returns {string|null} */ function earliestReleaseYear(dateTexts) { if (!dateTexts || !dateTexts.length) return null; let min = null; for (let i = 0; i < dateTexts.length; i++) { const m = String(dateTexts[i]).match(/(\d{4})/); if (m) { const y = parseInt(m[1], 10); if (min === null || y < min) min = y; } } return min === null ? null : String(min); } const DEFAULT_RANKING_PREFS = { showRankingMarks: true, enabledSources: {}, // 空对象 = 所有 source 默认启用 }; /** * 把用户配置归一化为合法形态,防止损坏数据导致崩溃。 * 未列在 enabledSources 里的 source 默认启用。 * @param {*} raw * @returns {{showRankingMarks: boolean, enabledSources: object}} */ function normalizeRankingPrefs(raw) { const next = { showRankingMarks: DEFAULT_RANKING_PREFS.showRankingMarks, enabledSources: { ...DEFAULT_RANKING_PREFS.enabledSources }, }; if (!raw || typeof raw !== 'object') return next; next.showRankingMarks = raw.showRankingMarks !== false; if (raw.enabledSources && typeof raw.enabledSources === 'object') { next.enabledSources = { ...raw.enabledSources }; } return next; } /** * 并发限流门 — 手工维护活跃数,避免 Promise.all 一次性发大量请求。 * v1 用不上(只请求 1-2 个 JSON),为 v2 多源并发预留。 * @param {number} maxConcurrency */ function ConcurrencyGate(maxConcurrency) { let active = 0; const queue = []; function next() { if (active >= maxConcurrency || queue.length === 0) return; active += 1; const { fn, resolve, reject } = queue.shift(); Promise.resolve().then(fn).then( (v) => { active -= 1; resolve(v); next(); }, (e) => { active -= 1; reject(e); next(); } ); } return { run(fn) { return new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); }); }, }; } // ============================================================ // PageAdapter — 识别条目类型,从 DOM 提取元信息 // ============================================================ /** * 返回当前页面对应的豆瓣条目类型。 * @returns {'book'|'movie'|'music'|'game'|'unknown'} */ function detectType() { const host = location.host; const path = location.pathname || ''; if (host.startsWith('book.')) return 'book'; if (host.startsWith('movie.')) return 'movie'; if (host.startsWith('music.')) return 'music'; if (host.startsWith('game.')) return 'game'; // 社区游戏条目:https://www.douban.com/game/xxxxxxx/ if (host === 'www.douban.com' && path.startsWith('/game/')) return 'game'; if (host === 'www.douban.com' && path.startsWith('/location/drama/')) return 'drama'; if (host === 'www.douban.com' && path.startsWith('/podcast/')) return 'podcast'; return 'unknown'; } /** * 读取豆瓣条目页 DOM,返回统一的元信息对象(ItemMeta)。 * * @returns {{ * type: string, * doubanId: string|null, * title: string|null, * originalTitle: string|null, * creator: string|null, * isbn: string|null, * imdbId: string|null, * year: string|null, * genres: string[], * }} */ function extractMeta() { const type = detectType(); // --- doubanId --- const subjectMatch = location.pathname.match(/\/subject\/(\d+)/); const gameMatch = location.pathname.match(/\/game\/(\d+)/); const dramaMatch = location.pathname.match(/\/location\/drama\/(\d+)/); const podcastMatch = location.pathname.match(/\/podcast\/(\d+)/); const doubanId = subjectMatch ? subjectMatch[1] : (gameMatch ? gameMatch[1] : (dramaMatch ? dramaMatch[1] : (podcastMatch ? podcastMatch[1] : null))); // --- title(h1 内容,去掉内嵌小字如 span.year)--- let title = null; const h1 = document.querySelector('h1'); if (h1) { // 克隆节点后移除 span.year,避免年份混入标题 const clone = h1.cloneNode(true); const yearSpan = clone.querySelector('span.year'); if (yearSpan) yearSpan.remove(); title = clone.textContent.trim(); } // --- 以下字段均从 #info 区域解析 --- const infoEl = document.querySelector('#info'); const infoText = infoEl ? (infoEl.textContent || '') : ''; // ISBN(书籍) let isbn = null; const isbnMatch = infoText.match(/ISBN:\s*([\dXx-]+)/); if (isbnMatch) isbn = isbnMatch[1].replace(/-/g, ''); // 英文标题提取:多层 fallback let originalTitle = null; // 1. #info 原作名(仅含拉丁字母时采用,否则 fallback 到又名提取英文) const originalTitleMatch = infoText.match(/原作名:\s*(.+)/); if (originalTitleMatch && /[a-zA-Z]/.test(originalTitleMatch[1])) { originalTitle = originalTitleMatch[1].trim(); } // 2. 又名/别名(取第一个纯 ASCII 项) // 电影/书籍:#info 内 "又名: A / B / C" // 游戏:
别名:
A / B / C
(无 #info) if (!originalTitle) { let aliasText = null; const alsoKnownMatch = infoText.match(/又名:\s*(.+)/); if (alsoKnownMatch) { aliasText = alsoKnownMatch[1]; } else { const dts = document.querySelectorAll('dt'); for (let i = 0; i < dts.length; i++) { if (dts[i].textContent.trim() === '别名:') { const dd = dts[i].nextElementSibling; if (dd) aliasText = dd.textContent.trim(); break; } } } if (aliasText) { const candidates = aliasText.split(/\s*\/\s*/); const englishName = candidates.find((c) => /^[\x20-\x7E]+$/.test(c.trim())); if (englishName) originalTitle = englishName.trim(); } } // h1 span[property="v:itemreviewed"] 的拉丁文段(参考:豆瓣资源下载大师的方法)。 // 单独算出来:既作为 originalTitle 的 fallback,也作为「备选英文名」供 RT/MC 二次搜索。 // 这是消除歧义的关键——又名首个英文别名可能是数字形(如「碟中谍8」给 "Mission: Impossible 8"), // 而 h1 拉丁段往往是规范英文官方名("Mission: Impossible - The Final Reckoning")。 // 反之外语片 h1 是原文名(意大利语等),又名才是 RT/MC 用的英文名——故二者都留作候选,按年份择优。 let h1LatinTitle = null; const reviewedEl = document.querySelector('#content h1 span[property="v:itemreviewed"]'); if (reviewedEl) { const engMatch = reviewedEl.textContent.trim().match(/([A-Za-z][A-Za-z0-9 :&'.,-]{2,})/); if (engMatch) h1LatinTitle = engMatch[1].trim(); } // 3. h1 拉丁段 if (!originalTitle && h1LatinTitle) originalTitle = h1LatinTitle; // 4. 最终 fallback:直接从 title 变量提取英文段 if (!originalTitle && title) { const engMatch = title.match(/([A-Za-z][A-Za-z0-9 :&'.,-]{2,})/); if (engMatch) originalTitle = engMatch[1].trim(); } // 备选英文名:h1 拉丁段若与 originalTitle 不同,则留作二次搜索候选 const altTitle = (h1LatinTitle && h1LatinTitle !== originalTitle) ? h1LatinTitle : null; // 主要创作者(作者/导演/表演者/艺术家/开发/制作人) let creator = null; if (infoEl) { const labels = Array.from(infoEl.querySelectorAll('span.pl')); const creatorSpan = labels.find((span) => { const t = span.textContent.trim(); return ( t.includes('作者') || t.includes('导演') || t.includes('表演者') || t.includes('艺术家') || t.includes('开发') || t.includes('制作人') ); }); if (creatorSpan) { // 下一兄弟元素可能是 ,也可能是文本节点 const next = creatorSpan.nextElementSibling; if (next && next.tagName === 'A') { creator = next.textContent.trim(); } else if (creatorSpan.nextSibling) { creator = creatorSpan.nextSibling.textContent.trim(); } // Sanitize: 取前导单一脚本字符串(前导 CJK 或前导 Latin), // 防御浏览器侧 DOM 注入(翻译扩展、注解插件等)污染作者名, // 例如把 "郑执" 变成 "郑gums",进而污染 Amazon/Goodreads 等的搜索查询 if (creator) { const m = creator.match(/^([一-鿿]+|[A-Za-z][A-Za-z\s.'\-]*)/); if (m) creator = m[1].trim(); } } } // IMDb ID — 用正则从 #info 全文提取,比 DOM 遍历更稳健 // 兼容多种格式:纯文本节点、 标签包裹、冒号在 span 内外 let imdbId = null; if (infoEl) { const imdbMatch = infoEl.textContent.match(/IMDb\s*(?:链接)?\s*:?\s*(tt\d+)/i); if (imdbMatch) { imdbId = imdbMatch[1]; } else { // 备选:从 链接 href 中提取 const imdbLink = infoEl.querySelector('a[href*="imdb.com/title/tt"]'); if (imdbLink) { const hrefMatch = imdbLink.href.match(/(tt\d+)/); if (hrefMatch) imdbId = hrefMatch[1]; } } } // 年份:电影用 [property="v:initialReleaseDate"],书籍用「出版年:」。 // 电影取所有上映日期里的最早年(= 原始上映年),规避豆瓣把中国重映排在最前的坑。 let year = null; const releaseEls = document.querySelectorAll('[property="v:initialReleaseDate"]'); if (releaseEls.length) { year = earliestReleaseYear(Array.from(releaseEls).map(function (el) { return el.textContent; })); } if (!year) { const pubYearMatch = infoText.match(/出版年:\s*(\d{4})/); if (pubYearMatch) year = pubYearMatch[1]; } // 类型标签(电影专属) const genreEls = document.querySelectorAll('[property="v:genre"]'); const genres = Array.from(genreEls).map((el) => el.textContent.trim()); // Fallback for drama pages (no #info, metadata in dl inside div.meta) if (!infoEl && detectType() === 'drama') { const metaDiv = document.querySelector('div.meta'); if (metaDiv) { const nameEl = metaDiv.querySelector('[itemprop="name"]'); if (nameEl && !title) title = nameEl.textContent.trim(); } } return { type, doubanId, title, originalTitle, altTitle, creator, isbn, imdbId, year, genres, }; } // ============================================================ // Cache — 按 channel 缓存评分结果 // ============================================================ const CACHE_TTL_SUCCESS = 7 * 24 * 60 * 60 * 1000; // 7 天 const CACHE_TTL_NEGATIVE = 24 * 60 * 60 * 1000; // 1 天 const CACHE_TTL_RATE_LIMITED = 5 * 60 * 1000; // 5 分钟 const CACHE_TTL_ERROR_SHORT = 30 * 60 * 1000; // 30 分钟(首次/偶发错误) const CACHE_TTL_ERROR_LONG = 7 * 24 * 60 * 60 * 1000; // 7 天(连续失败后升级) const ERROR_ESCALATE_THRESHOLD = 3; // 连续 N 次错误后升级到长 TTL const FAILURE_RECORD_TTL = 30 * 24 * 60 * 60 * 1000; // 失败计数本身 30 天后过期 const CACHE_PREFIX = 'rh2:'; function cacheKey(doubanId, channelKey, sourceVersion) { return CACHE_PREFIX + doubanId + ':' + channelKey + ':' + sourceVersion; } function failureKey(doubanId, channelKey) { return CACHE_PREFIX + 'fail:' + doubanId + ':' + channelKey; } function getFailureCount(doubanId, channelKey) { const key = failureKey(doubanId, channelKey); const entry = deps.storage.get(key); if (!entry) return 0; if (entry.fetchedAt && entry.ttl && Date.now() > entry.fetchedAt + entry.ttl) { deps.storage.remove(key); return 0; } return entry.count || 0; } function incrementFailureCount(doubanId, channelKey) { const current = getFailureCount(doubanId, channelKey); const newCount = current + 1; deps.storage.set(failureKey(doubanId, channelKey), { count: newCount, fetchedAt: Date.now(), ttl: FAILURE_RECORD_TTL, }); return newCount; } function resetFailureCount(doubanId, channelKey) { deps.storage.remove(failureKey(doubanId, channelKey)); } // SlugMap — 跨平台身份缓存(豆瓣 ID → 各 channel 详情页 URL) // 长 TTL(90 天),用于在 channel cache 过期后跳过 fuzzy 搜索步骤, // 直接拼出 URL 抓分。slug/detail-URL 几乎不变,但分数会变,因此分两层 TTL。 const SLUGMAP_TTL = 90 * 24 * 60 * 60 * 1000; // 90 天 function slugMapKey(doubanId) { return CACHE_PREFIX + 'slugmap:' + doubanId; } function getSlugMap(doubanId) { if (!doubanId) return null; const entry = deps.storage.get(slugMapKey(doubanId)); if (!entry) return null; if (entry.fetchedAt && entry.ttl && Date.now() > entry.fetchedAt + entry.ttl) { deps.storage.remove(slugMapKey(doubanId)); return null; } return entry.data || null; } function addChannelUrlToSlugMap(doubanId, channelKey, channelResult) { if (!doubanId || !channelKey || !channelResult || !channelResult.url) return; const existing = getSlugMap(doubanId) || { channelUrls: {}, source: 'auto' }; existing.channelUrls = existing.channelUrls || {}; // manual override 不被自动写覆盖(P2b 预留 — 当前 'auto' 总是会写) if (existing.source === 'manual' && existing.channelUrls[channelKey]) return; const entry = { url: channelResult.url, matchedBy: channelResult.matchedBy || null, confidence: channelResult.matchConfidence || 'fuzzy', }; const prev = existing.channelUrls[channelKey]; if (prev && prev.url === entry.url && prev.matchedBy === entry.matchedBy && prev.confidence === entry.confidence) { return; // 无变化跳过写 } existing.channelUrls[channelKey] = entry; deps.storage.set(slugMapKey(doubanId), { data: existing, fetchedAt: Date.now(), ttl: SLUGMAP_TTL, }); } function getCache(doubanId, channelKey, sourceVersion) { const key = cacheKey(doubanId, channelKey, sourceVersion); const entry = deps.storage.get(key); if (!entry) return null; const ttl = entry.ttl || 0; if (Date.now() > entry.fetchedAt + ttl) { deps.storage.remove(key); return null; } return entry.result; } function setCache(doubanId, channelKey, sourceVersion, channelResult) { const status = channelResult && channelResult.status; // disabled 不缓存(配置缺失时希望用户改完立即生效) if (!status || status === 'disabled') return; let ttl; if (status === 'error') { // 负缓存:默认 30 分钟,连续失败 3 次后升级到 7 天(视为暂不可用,避免 hammer) const failCount = incrementFailureCount(doubanId, channelKey); ttl = failCount >= ERROR_ESCALATE_THRESHOLD ? CACHE_TTL_ERROR_LONG : CACHE_TTL_ERROR_SHORT; } else { // 任何非 error 响应(success / no_match / no_rating / rate_limited)说明 channel 仍在响应, // 重置失败计数 resetFailureCount(doubanId, channelKey); if (status === 'rate_limited') { ttl = CACHE_TTL_RATE_LIMITED; } else if (status === 'no_match' || status === 'no_rating') { ttl = CACHE_TTL_NEGATIVE; } else { // success ttl = CACHE_TTL_SUCCESS; } } const key = cacheKey(doubanId, channelKey, sourceVersion); deps.storage.set(key, { fetchedAt: Date.now(), ttl: ttl, result: channelResult }); } function evictStale() { const keys = deps.storage.listKeys(); let removed = 0; const now = Date.now(); keys.forEach(function (key) { // 清理新旧前缀的缓存条目,跳过配置键和冷却键 if (!key.startsWith('rh:') && !key.startsWith('rh2:')) return; if (key === 'rh:config' || key.startsWith('rh:cooldown:')) return; const entry = deps.storage.get(key); if (entry && typeof entry.fetchedAt === 'number' && typeof entry.ttl === 'number') { if (now > entry.fetchedAt + entry.ttl) { deps.storage.remove(key); removed++; } } }); if (removed > 0) deps.log('evictStale: removed', removed, 'stale cache entries'); } // ============================================================ // Config — 用户设置面板与持久化 // ============================================================ const DEFAULT_CONFIG = { tmdbApiKey: '', traktClientId: '', enabledSources: {}, }; function readConfig() { const stored = deps.storage.get('rh:config', {}); return Object.assign({}, DEFAULT_CONFIG, stored, { enabledSources: Object.assign({}, DEFAULT_CONFIG.enabledSources, stored.enabledSources || {}), }); } function saveConfig(config) { deps.storage.set('rh:config', config); } let activeConfigKeydownHandler = null; function getSourceTypeLabels(source) { const typeLabels = { movie: '影视', book: '图书', music: '音乐', game: '游戏', drama: '舞台剧', podcast: '播客', }; return (source.types || []) .map(function (type) { return typeLabels[type] || type; }) .join(' / '); } function isSourceRelevantForMeta(source, meta) { if (!source.types || source.types.indexOf(meta.type) === -1) return false; return true; } function buildSourceToggleSection(title, description, items, config) { if (!items || items.length === 0) return null; const section = document.createElement('section'); section.className = 'rh-config-section'; const heading = document.createElement('h4'); heading.className = 'rh-config-section-title'; heading.textContent = title; section.appendChild(heading); if (description) { const desc = document.createElement('p'); desc.className = 'rh-config-section-desc'; desc.textContent = description; section.appendChild(desc); } const list = document.createElement('div'); list.className = 'rh-config-source-list'; section.appendChild(list); const checkboxes = {}; items.forEach(function (src) { const row = document.createElement('label'); row.className = 'rh-config-source'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'rh-config-checkbox'; cb.checked = config.enabledSources[src.key] !== false; checkboxes[src.key] = cb; const textWrap = document.createElement('span'); textWrap.className = 'rh-config-source-text'; const name = document.createElement('span'); name.className = 'rh-config-source-name'; name.textContent = src.label || src.key; const meta = document.createElement('span'); meta.className = 'rh-config-source-meta'; meta.textContent = getSourceTypeLabels(src); textWrap.appendChild(name); if (meta.textContent) textWrap.appendChild(meta); row.appendChild(cb); row.appendChild(textWrap); list.appendChild(row); }); section._checkboxes = checkboxes; return section; } function openConfigPanel(sources) { ensureStyles(); // 面板已存在则关闭(Toggle 行为) const existing = document.getElementById('rh-config-overlay'); if (existing) { if (activeConfigKeydownHandler) { document.removeEventListener('keydown', activeConfigKeydownHandler); activeConfigKeydownHandler = null; } existing.remove(); return; } const config = readConfig(); const meta = extractMeta(); const currentSources = []; const otherSources = []; const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; sources.forEach(function (src) { if (isSourceRelevantForMeta(src, meta)) currentSources.push(src); else otherSources.push(src); }); // 遮罩层 const overlay = document.createElement('div'); overlay.id = 'rh-config-overlay'; overlay.className = 'rh-config-overlay'; // 面板 const panel = document.createElement('div'); panel.className = 'rh-config-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-labelledby', 'rh-config-title'); panel.setAttribute('tabindex', '-1'); // 标题 const heading = document.createElement('h3'); heading.id = 'rh-config-title'; heading.className = 'rh-config-title'; heading.textContent = '评分汇设置'; panel.appendChild(heading); const intro = document.createElement('p'); intro.className = 'rh-config-intro'; intro.textContent = '这里仅控制评分来源的显示方式,条目主内容仍保持豆瓣原样。'; panel.appendChild(intro); // TMDB API Key 输入框 const tmdbSection = document.createElement('section'); tmdbSection.className = 'rh-config-section'; const tmdbHeading = document.createElement('h4'); tmdbHeading.className = 'rh-config-section-title'; tmdbHeading.textContent = 'TMDB Key(可选)'; tmdbSection.appendChild(tmdbHeading); const tmdbHelp = document.createElement('p'); tmdbHelp.id = 'rh-config-tmdb-help'; tmdbHelp.className = 'rh-config-section-desc'; tmdbHelp.textContent = '只在电影/剧集页用于显示 TMDB 评分。不填写也能正常使用,只是不显示这一项。'; tmdbSection.appendChild(tmdbHelp); const tmdbLabel = document.createElement('label'); tmdbLabel.className = 'rh-config-field-label'; tmdbLabel.textContent = 'TMDB API Key'; const tmdbInput = document.createElement('input'); tmdbInput.type = 'text'; tmdbInput.autocomplete = 'off'; tmdbInput.spellcheck = false; tmdbInput.value = config.tmdbApiKey || ''; tmdbInput.placeholder = '留空即可'; tmdbInput.className = 'rh-config-input'; tmdbInput.setAttribute('aria-describedby', 'rh-config-tmdb-help'); tmdbSection.appendChild(tmdbLabel); tmdbSection.appendChild(tmdbInput); panel.appendChild(tmdbSection); // Trakt Client ID 输入框(电影/剧集页可选) const traktSection = document.createElement('section'); traktSection.className = 'rh-config-section'; const traktHeading = document.createElement('h4'); traktHeading.className = 'rh-config-section-title'; traktHeading.textContent = 'Trakt Client ID(可选)'; traktSection.appendChild(traktHeading); const traktHelp = document.createElement('p'); traktHelp.id = 'rh-config-trakt-help'; traktHelp.className = 'rh-config-section-desc'; traktHelp.textContent = '只在电影/剧集页用于显示 Trakt 评分。需在 trakt.tv/oauth/applications/new 注册一个 app 获取 Client ID(无需 OAuth、不暴露个人信息)。留空则不显示这一行。'; traktSection.appendChild(traktHelp); const traktLabel = document.createElement('label'); traktLabel.className = 'rh-config-field-label'; traktLabel.textContent = 'Trakt Client ID'; const traktInput = document.createElement('input'); traktInput.type = 'text'; traktInput.autocomplete = 'off'; traktInput.spellcheck = false; traktInput.value = config.traktClientId || ''; traktInput.placeholder = '留空即可'; traktInput.className = 'rh-config-input'; traktInput.setAttribute('aria-describedby', 'rh-config-trakt-help'); traktSection.appendChild(traktLabel); traktSection.appendChild(traktInput); panel.appendChild(traktSection); // 数据来源启用/禁用 if (sources && sources.length > 0) { const currentLabelMap = { movie: '当前影视页会显示的来源', book: '当前图书页会显示的来源', music: '当前音乐页会显示的来源', game: '当前游戏页会显示的来源', drama: '当前舞台剧页会显示的来源', podcast: '当前播客页会显示的来源', }; const currentSection = buildSourceToggleSection( currentLabelMap[meta.type] || '当前页面会显示的来源', '先列出和当前页面最相关的来源,避免一次看到过多设置。', currentSources, config ); if (currentSection) panel.appendChild(currentSection); if (otherSources.length > 0) { const disclosure = document.createElement('details'); disclosure.className = 'rh-config-disclosure'; const summary = document.createElement('summary'); summary.className = 'rh-config-disclosure-summary'; summary.textContent = '其他条目类型的来源'; disclosure.appendChild(summary); const otherSection = buildSourceToggleSection( '其他来源', '这些来源不会出现在当前页面,但会影响书、影、音、游戏等其他条目页。', otherSources, config ); if (otherSection) disclosure.appendChild(otherSection); panel.appendChild(disclosure); } const mergedCheckboxes = {}; panel.querySelectorAll('.rh-config-section, .rh-config-disclosure').forEach(function (section) { if (!section._checkboxes) return; Object.keys(section._checkboxes).forEach(function (key) { mergedCheckboxes[key] = section._checkboxes[key]; }); }); panel._checkboxes = mergedCheckboxes; } const footnote = document.createElement('p'); footnote.className = 'rh-config-footnote'; footnote.textContent = '来源开关会在刷新当前页面后生效。'; panel.appendChild(footnote); // v1.1.0: 榜单显示 section buildRankingPrefsSection(panel); // 按钮行 const btnRow = document.createElement('div'); btnRow.className = 'rh-config-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.textContent = '关闭'; cancelBtn.className = 'rh-config-button rh-config-button-secondary'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.textContent = '保存并刷新'; saveBtn.className = 'rh-config-button rh-config-button-primary'; function closeOverlay() { if (activeConfigKeydownHandler) { document.removeEventListener('keydown', activeConfigKeydownHandler); activeConfigKeydownHandler = null; } overlay.remove(); if (previousActiveElement) previousActiveElement.focus(); } function onKeyDown(e) { if (e.key === 'Escape') { e.preventDefault(); closeOverlay(); } } cancelBtn.addEventListener('click', function () { closeOverlay(); }); saveBtn.addEventListener('click', function () { const newConfig = readConfig(); newConfig.tmdbApiKey = tmdbInput.value.trim(); newConfig.traktClientId = traktInput.value.trim(); if (panel._checkboxes) { Object.keys(panel._checkboxes).forEach(function (k) { newConfig.enabledSources[k] = panel._checkboxes[k].checked; }); } saveConfig(newConfig); closeOverlay(); location.reload(); }); btnRow.appendChild(cancelBtn); btnRow.appendChild(saveBtn); panel.appendChild(btnRow); // 点击遮罩背景关闭 overlay.addEventListener('click', function (e) { if (e.target === overlay) closeOverlay(); }); overlay.appendChild(panel); document.body.appendChild(overlay); activeConfigKeydownHandler = onKeyDown; document.addEventListener('keydown', activeConfigKeydownHandler); tmdbInput.focus(); } /** * v1.1.0: 在配置面板里添加"⭐ 榜单显示" section。 * 读 cache 里已识别的 source 列表生成 checkbox;每个 checkbox 变化立刻保存。 */ function buildRankingPrefsSection(panelEl) { const section = document.createElement('div'); section.className = 'rh-config-disclosure'; section.id = 'rh-config-ranking-section'; const heading = document.createElement('h4'); heading.textContent = '⭐ 榜单显示'; heading.style.cssText = 'margin:14px 0 8px;font-size:13px;color:#333;'; section.appendChild(heading); const prefs = normalizeRankingPrefs(deps.storage.get('rating_hub_ranking_prefs_v1')); // 总开关 const masterLabel = document.createElement('label'); masterLabel.className = 'rh-config-source'; masterLabel.innerHTML = '' + '' + '' + '显示榜单胶囊(总开关)' + ''; const masterCheckbox = masterLabel.querySelector('input'); masterCheckbox.addEventListener('change', function () { const cur = normalizeRankingPrefs(deps.storage.get('rating_hub_ranking_prefs_v1')); cur.showRankingMarks = !!masterCheckbox.checked; deps.storage.set('rating_hub_ranking_prefs_v1', cur); }); section.appendChild(masterLabel); // 汇总所有 category cache 里已识别的 source(v1 起不再只读 movie) const storageKeys = deps.storage.listKeys(); const CACHE_PREFIX = 'rating_hub_rankings_cache_v1:'; const sources = {}; const sourceCategory = {}; // 记录每个 source 属哪个 category,用于 "启用的榜单(movie · 电影)" for (let i = 0; i < storageKeys.length; i++) { const k = storageKeys[i]; if (k.indexOf(CACHE_PREFIX) !== 0) continue; const cat = k.slice(CACHE_PREFIX.length); const cached = deps.storage.get(k); const cs = cached && cached.data && cached.data.categories && cached.data.categories[cat] && cached.data.categories[cat].sources; if (!cs) continue; Object.keys(cs).forEach(function (sid) { sources[sid] = cs[sid]; sourceCategory[sid] = cat; }); } const sourceIds = Object.keys(sources); if (sourceIds.length === 0) { const hint = document.createElement('p'); hint.style.cssText = 'color:#888;font-size:12px;margin:8px 0 0;'; hint.textContent = '榜单数据尚未加载。访问一次豆瓣电影或音乐条目页后回来此处即可看到已识别的榜单。'; section.appendChild(hint); } else { const listLabel = document.createElement('div'); listLabel.style.cssText = 'color:#888;font-size:12px;margin:8px 0 4px;'; listLabel.textContent = '启用的榜单(已识别):'; section.appendChild(listLabel); // 先按 category 聚类,再按 priority 排序 sourceIds.sort(function (a, b) { const catCmp = (sourceCategory[a] || '').localeCompare(sourceCategory[b] || ''); if (catCmp !== 0) return catCmp; return (sources[a].priority || 99) - (sources[b].priority || 99); }); sourceIds.forEach(function (sid) { const src = sources[sid]; const enabled = prefs.enabledSources[sid] !== false; const label = document.createElement('label'); label.className = 'rh-config-source'; const kindText = src.kind === 'permanent' ? '永久' : (src.kind === 'yearly' ? '年度' : '时效'); const catText = sourceCategory[sid] || '?'; label.innerHTML = '' + '' + '' + '' + escapeHtml(src.titleZh || src.title || sid) + '' + '' + escapeHtml(catText + ' · ' + kindText + ' · ' + (src.itemCount || '?')) + '' + ''; const cb = label.querySelector('input'); cb.addEventListener('change', function () { const cur = normalizeRankingPrefs(deps.storage.get('rating_hub_ranking_prefs_v1')); cur.enabledSources[sid] = !!cb.checked; deps.storage.set('rating_hub_ranking_prefs_v1', cur); }); section.appendChild(label); }); } // 数据来源提示 + 刷新按钮 const footer = document.createElement('div'); footer.style.cssText = 'margin-top:10px;color:#888;font-size:12px;'; footer.innerHTML = '数据每周更新自 rank.douban.zhili.dev '; const refreshBtn = document.createElement('button'); refreshBtn.className = 'rh-config-button rh-config-button-secondary'; refreshBtn.style.cssText = 'padding:2px 10px;font-size:12px;min-height:24px;margin-left:6px;'; refreshBtn.textContent = '🔄 强制刷新缓存'; refreshBtn.addEventListener('click', function () { RankingData.forceRefresh(); alert('榜单缓存已清空。刷新页面后会重新拉取数据。'); }); footer.appendChild(refreshBtn); section.appendChild(footer); panelEl.appendChild(section); } function registerMenu(sources) { GM_registerMenuCommand('⚙ 评分汇设置', function () { openConfigPanel(sources); }); GM_registerMenuCommand('🔄 强制刷新榜单数据', function () { RankingData.forceRefresh(); if (confirm('榜单缓存已清空。立即刷新页面?')) { location.reload(); } }); GM_registerMenuCommand('🗑 清除当前条目评分缓存', function () { const m = location.pathname.match(/\/(?:subject|game|location\/drama|podcast)\/(\d+)/); if (!m) { alert('未识别到豆瓣条目 ID。请在条目页(如 /subject/12345/)执行。'); return; } const doubanId = m[1]; // 清三类 key: // 1. channel cache: rh2:{doubanId}:{channel}:{ver} // 2. slugMap: rh2:slugmap:{doubanId} // 3. 失败计数: rh2:fail:{doubanId}:{channel} const channelPrefix = CACHE_PREFIX + doubanId + ':'; const slugMapKeyExact = CACHE_PREFIX + 'slugmap:' + doubanId; const failPrefix = CACHE_PREFIX + 'fail:' + doubanId + ':'; const keys = deps.storage.listKeys(); let removed = 0; keys.forEach(function (key) { if (key.indexOf(channelPrefix) === 0 || key === slugMapKeyExact || key.indexOf(failPrefix) === 0) { deps.storage.remove(key); removed++; } }); if (confirm('已清除 ' + removed + ' 条评分缓存(条目 ' + doubanId + ')。立即刷新页面以重新拉取?')) { location.reload(); } }); } // ============================================================ // Renderer — 确定性插槽式 UI 渲染 // ============================================================ function ensureStyles() { if (document.getElementById('rating-hub-style')) return; const style = document.createElement('style'); style.id = 'rating-hub-style'; style.textContent = [ '.rating-hub-container { margin-top: 0; padding: 12px 0 0; border-top: 1px solid #eaeaea; font-size: 12px; color: #333; }', '.rating-hub-row { display: grid; grid-template-columns: 101px minmax(40px, auto) minmax(0, 1fr); align-items: center; column-gap: 3px; min-height: 24px; }', '.rating-hub-label { display: inline-flex; align-items: center; min-width: 0; color: #37a; text-decoration: none; border-radius: 3px; padding: 0 2px; transition: color 0.16s ease-out, background-color 0.16s ease-out, box-shadow 0.16s ease-out; font-size: 12px; line-height: 1.2; }', '.rating-hub-score { display: inline-flex; align-items: center; justify-self: start; gap: 0; color: #2f2f2f; font-variant-numeric: tabular-nums; min-width: 3.2em; letter-spacing: 0.01em; line-height: 1; white-space: nowrap; }', '.rating-hub-score-main { font-weight: 700; color: #2f2f2f; }', '.rating-hub-score-suffix { font-size: 11px; font-weight: 500; color: #8f8f8f; }', '.rating-hub-label:hover { color: #fff; background-color: #37a; }', '.rating-hub-label.no-link { cursor: default; }', '.rating-hub-label.no-link:hover { color: #37a; background-color: transparent; }', '.rating-hub-label:focus-visible, .rating-hub-status a:focus-visible, .rh-config-button:focus-visible, .rh-config-input:focus-visible, .rh-config-checkbox:focus-visible, .rh-config-disclosure-summary:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(55, 119, 170, 0.28); background-color: rgba(55, 119, 170, 0.08); }', '.rating-hub-count { color: #777; justify-self: start; margin-left: 0; font-variant-numeric: tabular-nums; min-width: 0; white-space: nowrap; line-height: 1; }', '.rating-hub-row[data-confidence="fuzzy"] .rating-hub-score { opacity: 0.72; }', '.rating-hub-fuzzy-mark { color: #b39a6c; font-weight: 400; margin-left: 3px; font-size: 11px; cursor: help; line-height: 1; user-select: none; }', '.rating-hub-status { color: #666; grid-column: 2 / span 2; min-width: 0; white-space: nowrap; line-height: 1.2; }', '.rating-hub-status a { color: #37a; text-decoration: none; }', '.rating-hub-status a:hover { text-decoration: underline; }', '.rating-hub-row[data-status="loading"] .rating-hub-status, .rating-hub-row[data-status="no_match"] .rating-hub-status, .rating-hub-row[data-status="no_rating"] .rating-hub-status { color: #777; }', '.rating-hub-row[data-status="rate_limited"] .rating-hub-status, .rating-hub-row[data-status="error"] .rating-hub-status { color: #7a6a55; }', '.rating-hub-row[data-status="disabled"] .rating-hub-status { color: #666; }', '.rating-hub-row-hidden { display: none; }', '.rating-hub-icon { width: 14px; height: 14px; vertical-align: middle; margin-right: 4px; border-radius: 2px; flex-shrink: 0; }', '.rating-hub-toggle { display: inline-block; margin-top: 4px; color: #37a; text-decoration: none; font-size: 12px; line-height: 1.4; }', '.rating-hub-toggle:hover { text-decoration: underline; }', '.rh-config-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0, 0, 0, 0.28); z-index: 999999; }', '.rh-config-panel { width: min(520px, calc(100vw - 24px)); max-height: min(78vh, 720px); overflow: auto; background: #fff; color: #333; border: 1px solid #d8d2c4; border-radius: 8px; box-shadow: 0 14px 34px rgba(26, 26, 26, 0.16); padding: 20px 22px 18px; font: 13px/1.65 Helvetica, Arial, sans-serif; }', '.rh-config-title { margin: 0; font-size: 16px; font-weight: 700; color: #494949; }', '.rh-config-intro { margin: 8px 0 14px; color: #666; }', '.rh-config-section { margin-top: 14px; }', '.rh-config-section-title { margin: 0 0 4px; font-size: 13px; font-weight: 700; color: #494949; }', '.rh-config-section-desc { margin: 0 0 10px; color: #777; }', '.rh-config-field-label { display: block; margin-bottom: 6px; color: #555; font-weight: 600; }', '.rh-config-input { display: block; width: 100%; box-sizing: border-box; padding: 7px 10px; border: 1px solid #c9c3b8; border-radius: 4px; color: #333; background: #fff; transition: border-color 0.16s ease-out, box-shadow 0.16s ease-out; }', '.rh-config-input:hover { border-color: #b5aea1; }', '.rh-config-source-list { display: grid; gap: 6px; }', '.rh-config-source { display: flex; align-items: flex-start; gap: 10px; padding: 7px 8px; border-radius: 6px; cursor: pointer; transition: background-color 0.16s ease-out; }', '.rh-config-source:hover { background: #f7f4ed; }', '.rh-config-checkbox { margin-top: 2px; accent-color: #4f946e; }', '.rh-config-source-text { display: flex; min-width: 0; flex: 1; align-items: baseline; justify-content: space-between; gap: 10px; }', '.rh-config-source-name { color: #333; }', '.rh-config-source-meta { color: #999; white-space: nowrap; }', '.rh-config-disclosure { margin-top: 14px; border-top: 1px solid #eee9dd; padding-top: 12px; }', '.rh-config-disclosure-summary { color: #37a; cursor: pointer; user-select: none; }', '.rh-config-disclosure-summary:hover { text-decoration: underline; }', '.rh-config-footnote { margin: 14px 0 0; color: #999; }', '.rh-config-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }', '.rh-config-button { min-height: 34px; padding: 0 16px; border-radius: 4px; border: 1px solid transparent; cursor: pointer; transition: background-color 0.16s ease-out, border-color 0.16s ease-out, color 0.16s ease-out; }', '.rh-config-button-secondary { border-color: #d8d2c4; background: #fff; color: #666; }', '.rh-config-button-secondary:hover { border-color: #c9c3b8; background: #faf8f2; }', '.rh-config-button-primary { border-color: #4f946e; background: #5c9d78; color: #fff; font-weight: 600; }', '.rh-config-button-primary:hover { border-color: #467f61; background: #508a69; }', '@media (max-width: 480px) { .rating-hub-container { font-size: 13px; padding-top: 10px; } .rating-hub-row { grid-template-columns: 93px minmax(38px, auto) minmax(0, 1fr); column-gap: 3px; min-height: 23px; } .rh-config-overlay { padding: 12px; } .rh-config-panel { max-height: calc(100vh - 24px); padding: 16px; } .rh-config-source-text { display: block; } .rh-config-source-meta { display: block; margin-top: 2px; white-space: normal; } .rh-config-actions { flex-wrap: wrap; } .rh-config-button { flex: 1 1 140px; } }', '@media (prefers-reduced-motion: reduce) { .rating-hub-label, .rh-config-source, .rh-config-input, .rh-config-button { transition: none; } }', // ========== 榜单胶囊(v1.1.0 新增) ========== // 让豆瓣原生 .top250(默认 block)变 inline-flex,这样我们的胶囊能和它并排 '.top250:has(+ .rating-hub-rank-marks), .rank-label.rank-label-other:has(+ .rating-hub-rank-marks) { display: inline-flex !important; vertical-align: middle; margin-right: 0 !important; }', '.rating-hub-rank-marks { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 5px 0; vertical-align: middle; }', // 我们容器紧跟豆瓣原生胶囊时,补 6px 左间距保持节奏一致 '.top250 + .rating-hub-rank-marks, .rank-label.rank-label-other + .rating-hub-rank-marks { margin-left: 6px; }', // ===== 内联豆瓣原生 rank-label CSS(带 base64 PNG 纹理)===== // 目的:即使条目页没有豆瓣原生 .rank-label 导致豆瓣 CSS 未加载时,我们的胶囊仍然正确渲染 '.rating-hub-rank-marks .rank-label { align-items:center; border-radius:3px; display:inline-flex; font:12px Helvetica,Arial,sans-serif; margin:5px 0; overflow:hidden; position:relative; }', '.rating-hub-rank-marks .rank-label:before { background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAkCAYAAACJ8xqgAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFKADAAQAAAABAAAAJAAAAAAT/Eh7AAABQElEQVRIDbXVQXLDIAyFYdPpCbvrtvc/A7QN+mCixjNdiCwCT8jW7yeM29fnx7h+fo+/30n8WpuTEQu09XVB5NFvK6Fo8p7J3Lf3udICjba+xnSDekKVBxOidLumOX30R4ReZDeTA4TRRs1SuAexOG39bqwntM+yh1d4uONYJ5t96Xr6BKFX4dmVwdsAo2W1uMw2pOsJOxOUCgT7zjqNUNfF6XrCEe/spU2BsLs7A1k7n/aDzdkBQqbwEmGUBp6W1wPpvrx6QqeNcw+wMZOJ5+7T9YS6xAsE/x1z9+sJt4fPTLzTPXplOXw8Yuh6wuXBUHIyKOxTs3QgOl3E6QOEUSJ3GbnThOZhjtP1hHddRpLJ7uLy6gm3d6nLsfG84zsP4+vxBOGs9IcggPeJ/pooRw8Q5hKhkS0PfXtu8oXLCb8B7eSfBHIa+p4AAAAASUVORK5CYII=) repeat-x 100%/auto 100%; content:""; height:100%; left:0; position:absolute; top:0; width:100%; }', '.rating-hub-rank-marks .rank-label span { height:18px; line-height:18px; position:relative; text-align:center; }', '.rating-hub-rank-marks .rank-label a { background:none; color:#ffc46c; display:inline-block; height:100%; text-decoration:none; }', '.rating-hub-rank-marks .rank-label .rank-label-no { border-radius:2px; color:#8d5500; overflow:hidden; position:relative; width:54px; }', '.rating-hub-rank-marks .rank-label .rank-label-no:before { background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAASCAYAAAAOsR1OAAAABGdBTUEAALGPC/xhBQAAAwRJREFUSA3VVz1rVFEQndlNJIVFGmutDBrQRvAvaGH6CEmlBPE/iJDfICJW2tiIRdKIIP4BOxEE0WBlYaHgxpDdvHedc/eet7N378tuYixy4e2ZO3PmzNyP/dKw825BOvtPROdXRHVRTuOo+g/0wo3NUusavr1+ZotbLwVFzRvsAWLQBv6Pcdx6qgOpd2/q+Vtv87ZsgW9+ntqT86vR8EOq+qqd5HfvnrPJooR0JMfdQa94FPsk6wU5Jxq2QgjXVbVmGx0R2OkJDr3N+FTERkED6G1Xw2v4GtG2HOIs+V4r2npNdrYfW/FmdIanlxqKJ4ki6UER+pqmE9fHGr4tJPK5UeAmH3WIpXwsLmoBve36OCwfmt0zd8PXV+tcoZ1gSmYiEf7aihBh5w9jQDzc/dhcyqXPc6njfdTwyFzqNYtnz1aj8SW7rlS6C4/C55fLWOTcsCmYLSMuuC0GP4oxTqMNQWdsmDqZzzdm0hzjp9ySD/QYtpdazkp3/oV5rmjY2XYVk+isgEzfD21gaaAxtSAbpA08iZH3M9i/lE6QBXJGXhU8clIMU47YuFtAs3qnH/kpiXajAYM1KOqRMac3xjc/e4hpfbuio/tlLlYienHY9BPzOArjvccGaAMxpsXB8drgY96ml/PdvB580KXbn+wEIeBFvY2EowzmEpFbsku+Uh3yiLlenkNe3ZP9/iqiaYEkgsBdg4922sFs2mwudafFJxJYl5gL0D8rWiPaCXKwe1+X1z4iy64orw9F2C3mtBNm05hBX4E+ES87onf4QjGiC81qHvSe6tLac9KHJ5gOaOoGM6sNpx0A+ianpMFYWz/T4jJ4Lxe/3PPStsDql1Ud/U36h83LD9wXauzD9BkjIqlkl3xiP7YHv1dUH45dyY5U/a2himXxAwfo7Vgl85Xi5LWhz/E2+d7nbcZzHHEG0u+t6uWNsX8S2J85Wag2ZO8PfsPZH97O4tinfEh3IiLoNvzu1TbBlzSwNEpXCjzSc6QWv/hn0cd5VXubunxn4r8gSv0Fuc0yllCH+SkAAAAASUVORK5CYII=) no-repeat 100%/auto 100%; content:""; height:100%; left:0; position:absolute; top:0; width:100%; }', '.rating-hub-rank-marks .rank-label-link { display:inline-block; padding:0 7px 0 5px; }', '.rating-hub-rank-marks .rank-label-other:before { background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAkCAYAAACJ8xqgAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFKADAAQAAAABAAAAJAAAAAAT/Eh7AAABMElEQVRIDbWWURLCIAxEiePxPZsnUqJT9sVpKONP8AeWAnksSav158Nb4e9WuNex1b35+3pPuE2P0SHHgLUxwdvQGwj764iZAJqZIneRSHMcY4FOgN5AKA/xAoLmIpNH6HhOB1LpHYRde6dQECza8JiTyOMdhOOWs4fkFeNogAWGDIt3EI5KWTm4Gg+01NlAqEr5lsYpFgpCNJPwkGXoekJrIw+J8CMQmxA8TYg8TLVeT+jykIgQRouJMTA6nsjQ9YTmeLhASWRITgQZup7QnVomtjySJP8yf7r0jbVsfPVIechA0LihzweZVL2HfJdnALnm1y7m9yR6HyEckyl/BvK6esJlLWMqt4+GGDQSVLqekPdhpDoE0z8GkDQBMuZL1xOShwSKFs9WHsbEc6ec8AO6ZIJn9ClyFgAAAABJRU5ErkJggg==); }', '.rating-hub-rank-marks .rank-label-other .rank-label-no:before { background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAASCAYAAAAOsR1OAAAABGdBTUEAALGPC/xhBQAAA0dJREFUSA2tVztrVUEQnrm5RrARAoJXkAjJL9DSSiwMBAQRBFECEhEsrO2CpPI/pBAECxuxECJW/gFLqzR5QIIp0lkEkrvOt2e/kz1z9uTcKxm4Z2bn8c3Mzp7H1RB+z8rhwbpIWJGxjETFRPuBKIP/F3kAAFNXAPRmunblp78MXun1+xsFRNHw58d7GYe3JePUumAZ1aoBB1EGB/XZK6/ua3f8sQzlrl578MsHazjY3LeJjSoDtyQV6L292Tfg/XvXLUCLoK4UTBs4KBuxyo5c0dt6demoslXXoYxPU3MMGKdArD1gUsEEYh7ySjvFFQXm+SiDT0lB5uWvfgohLKtqDTCUUMtTIhbcsw2N1sL+FKLOVH3xffYgS7L/bc0A3xFUw97XECcNjT/j+RGAvZXAOuAxjXZzIAbWlMFB9K3vSejsV5+Anh1pxRfyA21msKyjh5sxZdj7UsND0SBfYMNoi0kSEgOxlLsa9vh9/t32IxnqHR092sY9eAbL3QQHsXXylj0pQgqIhZvMBnKMXCZerqPMHFhDxh2U4Gs5v6tyLDhWNczJiXywyHsadj43XEw5OXXvYBljWv8ySrcWneQbNKvz1QR5T8SR5R4eizbwRI1ppe2uH1zOv9rdbMJmpy7CuQrdsnVLtOr1eDP2esTcecRaAexiUo6GzjtTKCCzU643xOdxBU/izx5Uf+qNJ7sX+5rw9fk1+udUYKMMfpGkciTh0gtATtdgX4F9dh7HxrHOOvNP5cwURb8hrXzmMBjgtfdcbz3dRow1aE9R3oN9AGNzgC84KRPjRKAv6aI/KsqOqC9wbLaID58CMTfr9fVE+8m6LqzGdyAQNGxtWMfIZISdhZzvcGWprr6g3FaUfQA6p85Et6xNcAG17GZgjdHu6lX9Lgur7lNNxwcGdPY9ClCSTwD9eXbW3lUgccnhlw00now4hZSEMgdAXw44j1fdkcv6LP8ORZqB7cjH+GTDEwovffL4AZB0sQqT41Ms8Vye1E4/8hwDct8PcYyBzBrD6bHIyWO9+bLxTwINDmVxbk22Di1QV+wyak4ojaR+jSAkp9KIc7uTvXuCj0czupoiNgCDEeXIK1XzSv+ZN7r4uvVfEL7/AK5h3BZxtIRYAAAAAElFTkSuQmCC); color:#835000; }', '.rating-hub-rank-marks .rank-label-other a { color:#835000; }', ].join('\n'); document.head.appendChild(style); } function getCollapsedChannelKeys(channels, meta) { if (!channels) return []; if (meta.type !== 'movie' && meta.type !== 'drama') return []; const isAnime = meta.type === 'movie' && meta.genres && meta.genres.indexOf('动画') !== -1; const visibleKeys = isAnime ? ['imdb', 'rt_critics', 'rt_audience', 'bangumi', 'mal', 'neodb'] : ['imdb', 'rt_critics', 'rt_audience', 'metacritic', 'letterboxd', 'trakt', 'neodb']; const visibleSet = new Set(visibleKeys); const hidden = channels .filter(function (ch) { return !visibleSet.has(ch.channelKey); }) .map(function (ch) { return ch.channelKey; }); // 只在隐藏 ≥ 2 条时才折叠:每折叠一组多一个 "展开更多" toggle 行, // 隐藏 1 条 = 净省 0 行(无意义);隐藏 2 条 = 净省 1 行;3 条 = 净省 2 行。 if (hidden.length < 2) return []; return hidden; } function createSlots(channels, meta) { const anchor = document.querySelector('#interest_sectl') || document.querySelector('.drama-info .meta .rating') // 话剧:评分区块后 || document.querySelector('.drama-info .meta') // 话剧:meta 容器 || document.querySelector('#interest_sect_level') || document.querySelector('#wrapper'); if (!anchor) return null; const container = document.createElement('div'); container.className = 'rating-hub-container'; container.setAttribute('data-rating-hub', '1'); const collapsedKeys = new Set(getCollapsedChannelKeys(channels, meta)); channels.forEach(function (ch) { const row = document.createElement('div'); row.className = 'rating-hub-row'; row.setAttribute('data-channel', ch.channelKey); row.setAttribute('data-status', 'loading'); if (collapsedKeys.has(ch.channelKey)) { row.classList.add('rating-hub-row-hidden'); } const label = document.createElement('span'); label.className = 'rating-hub-label no-link'; if (ch.icon) { const iconImg = document.createElement('img'); iconImg.className = 'rating-hub-icon'; iconImg.src = ch.icon; iconImg.alt = ''; iconImg.onerror = function () { this.style.display = 'none'; }; label.appendChild(iconImg); } label.appendChild(document.createTextNode(ch.label)); const status = document.createElement('span'); status.className = 'rating-hub-status'; status.textContent = '加载中...'; row.appendChild(label); row.appendChild(status); container.appendChild(row); }); if (collapsedKeys.size > 0) { const toggle = document.createElement('a'); toggle.href = '#'; toggle.className = 'rating-hub-toggle'; toggle.setAttribute('data-expanded', '0'); toggle.textContent = '展开更多评分来源(' + collapsedKeys.size + ')'; toggle.addEventListener('click', function (e) { e.preventDefault(); const expanded = toggle.getAttribute('data-expanded') === '1'; collapsedKeys.forEach(function (key) { const hiddenRow = container.querySelector('.rating-hub-row[data-channel="' + key + '"]'); if (!hiddenRow) return; hiddenRow.classList.toggle('rating-hub-row-hidden', expanded); }); toggle.setAttribute('data-expanded', expanded ? '0' : '1'); toggle.textContent = expanded ? '展开更多评分来源(' + collapsedKeys.size + ')' : '收起更多评分来源'; }); container.appendChild(toggle); } anchor.appendChild(container); return container; } function fillSlot(channelKey, result) { const row = document.querySelector('.rating-hub-row[data-channel="' + channelKey + '"]'); if (!row) return; // 重建行内容:label + 状态区 const label = row.querySelector('.rating-hub-label'); // 先清空旧状态区(label 保留) while (row.lastChild !== label) { row.removeChild(row.lastChild); } const status = result.status; row.setAttribute('data-status', status || 'error'); if (result.matchConfidence) { row.setAttribute('data-confidence', result.matchConfidence); } else { row.removeAttribute('data-confidence'); } if (status === 'success') { // Label → 可点击链接 const a = document.createElement('a'); a.className = 'rating-hub-label'; a.href = safeLinkUrl(result.url); a.target = '_blank'; a.rel = 'noopener noreferrer'; a.innerHTML = label.innerHTML; // preserves icon img + text row.replaceChild(a, label); const scoreEl = document.createElement('span'); scoreEl.className = 'rating-hub-score'; const scoreText = String(result.displayValue || result.score); const scoreMatch = scoreText.match(/^([^/]+)(\/.+)$/); if (scoreMatch) { const mainEl = document.createElement('span'); mainEl.className = 'rating-hub-score-main'; mainEl.textContent = scoreMatch[1]; const suffixEl = document.createElement('span'); suffixEl.className = 'rating-hub-score-suffix'; suffixEl.textContent = scoreMatch[2]; scoreEl.appendChild(mainEl); scoreEl.appendChild(suffixEl); } else { const mainEl = document.createElement('span'); mainEl.className = 'rating-hub-score-main'; mainEl.textContent = scoreText; scoreEl.appendChild(mainEl); } if (result.matchConfidence === 'fuzzy') { const mark = document.createElement('span'); mark.className = 'rating-hub-fuzzy-mark'; mark.textContent = '~'; mark.title = '此匹配为模糊匹配(按标题搜索),分数可能对应错的作品'; mark.setAttribute('aria-label', '模糊匹配'); scoreEl.appendChild(mark); } row.appendChild(scoreEl); if (result.count) { const countEl = document.createElement('span'); countEl.className = 'rating-hub-count'; let countDisplay; const c = result.count; if (c >= 100000000) countDisplay = Math.round(c / 100000000) + '亿'; else if (c >= 10000) countDisplay = (c / 10000).toFixed(c >= 1000000 ? 0 : 1) + '万'; else countDisplay = c.toLocaleString(); countEl.textContent = '(' + countDisplay + ')'; row.appendChild(countEl); } } else if (status === 'no_match' || status === 'no_rating') { if (result.url) { const a = document.createElement('a'); a.className = 'rating-hub-label'; a.href = safeLinkUrl(result.url); a.target = '_blank'; a.rel = 'noopener noreferrer'; a.innerHTML = label.innerHTML; // preserves icon img + text row.replaceChild(a, label); } const statusEl = document.createElement('span'); statusEl.className = 'rating-hub-status'; statusEl.textContent = status === 'no_match' ? '未收录' : '暂无评分'; row.appendChild(statusEl); } else if (status === 'rate_limited') { const statusEl = document.createElement('span'); statusEl.className = 'rating-hub-status'; statusEl.textContent = '访问过快,稍后再试'; row.appendChild(statusEl); } else if (status === 'disabled') { const statusEl = document.createElement('span'); statusEl.className = 'rating-hub-status'; const configLink = document.createElement('a'); configLink.href = '#'; configLink.textContent = '配置 TMDB Key'; configLink.addEventListener('click', function (e) { e.preventDefault(); openConfigPanel(sources); }); statusEl.appendChild(configLink); row.appendChild(statusEl); } else { // error (and any unknown status) const statusEl = document.createElement('span'); statusEl.className = 'rating-hub-status'; // Label → 链接(如果有 url) if (result.url) { const a = document.createElement('a'); a.className = 'rating-hub-label'; a.href = safeLinkUrl(result.url); a.target = '_blank'; a.rel = 'noopener noreferrer'; a.innerHTML = label.innerHTML; row.replaceChild(a, label); } statusEl.textContent = '暂时无法访问'; row.appendChild(statusEl); } } // ============================================================ // Registry — 评分来源注册表 // ============================================================ const sources = []; function getApplicableSources(type, config, meta) { return sources.filter(function (source) { // 必须支持当前条目类型 if (!source.types || source.types.indexOf(type) === -1) return false; // 用户已禁用 if (config.enabledSources[source.key] === false) return false; // bangumi/mal 仅在动画类型时启用 if ((source.key === 'bangumi' || source.key === 'mal') && (!meta.genres || meta.genres.indexOf('动画') === -1)) return false; return true; }); } // ============================================================ // Sources — 各平台评分获取定义 // ============================================================ // 去除标题中的季数信息,用于 RT/Metacritic 搜索 // "Louie Season 1" → "Louie", "Breaking Bad Season 5" → "Breaking Bad" function stripSeason(title) { return (title || '') .replace(/\s*[,:]?\s*Season\s+\d+/i, '') .replace(/\s*第.{1,3}季/g, '') .trim(); } function absolutizeUrl(href, baseUrl) { if (!href) return ''; return href.indexOf('http') === 0 ? href : baseUrl + href; } function responseOk(resp) { return resp && resp.status >= 200 && resp.status < 300; } /** * 标题搜索类来源的公共流程:搜索页 → 候选详情链接 → 详情页解析。 * 仅封装网络/DOM 串联;具体匹配、评分解析和 no_match/no_rating 仍由各 source 自己决定。 */ function fetchSearchDetail(deps, opts) { return deps.request(opts.searchUrl, opts.searchOpts || {}).then(function (searchResp) { if (!responseOk(searchResp)) { return { reachedDetail: false, url: opts.searchUrl }; } const finalSearchUrl = searchResp.finalUrl || opts.searchUrl; if (opts.isDetailUrl && opts.isDetailUrl(finalSearchUrl)) { return { reachedDetail: true, url: finalSearchUrl, parsed: opts.parseDetail(deps.parseHTML(searchResp.responseText), finalSearchUrl, searchResp), }; } const searchDoc = deps.parseHTML(searchResp.responseText); const href = opts.pickDetailHref(searchDoc, searchResp); if (!href) { return { reachedDetail: false, url: finalSearchUrl }; } const detailUrl = absolutizeUrl(href, opts.baseUrl); return deps.request(detailUrl, opts.detailOpts || {}).then(function (detailResp) { if (opts.acceptDetailResp && !opts.acceptDetailResp(detailResp)) { return { reachedDetail: false, url: detailUrl }; } const finalDetailUrl = detailResp.finalUrl || detailUrl; return { reachedDetail: true, url: finalDetailUrl, parsed: opts.parseDetail(deps.parseHTML(detailResp.responseText), finalDetailUrl, detailResp), }; }).catch(function () { return { reachedDetail: false, url: detailUrl }; }); }).catch(function () { return { reachedDetail: false, url: opts.searchUrl }; }); } // --- IMDB --- sources.push({ key: 'imdb', label: 'IMDB', version: 2, types: ['movie'], requiredConfig: null, channels: [{ channelKey: 'imdb', label: 'IMDB', icon: 'https://www.imdb.com/favicon.ico' }], fetch: function (meta, deps) { return new Promise(function (resolve) { if (!meta.imdbId) { resolve({ imdb: { channelKey: 'imdb', status: 'no_match', url: 'https://www.imdb.com/search/title/?title=' + encodeURIComponent(meta.title || '') } }); return; } const itemUrl = 'https://www.imdb.com/title/' + meta.imdbId + '/'; function buildSuccess(score, count) { resolve({ imdb: { channelKey: 'imdb', status: 'success', score: score, scoreMax: 10, displayValue: score.toFixed(1) + '/10', count: count, countText: count.toLocaleString(), url: itemUrl, matchedBy: 'imdb_id', matchConfidence: 'exact', externalId: meta.imdbId, }, }); } function errorResult() { resolve({ imdb: { channelKey: 'imdb', status: 'error', url: itemUrl } }); } function noRating() { resolve({ imdb: { channelKey: 'imdb', status: 'no_rating', url: itemUrl } }); } // 兜底:旧版 JSONP 端点 function tryJsonpFallback() { const ratingsUrl = 'https://p.media-imdb.com/static-content/documents/v1/title/' + meta.imdbId + '/ratings%3Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json'; deps.request(ratingsUrl).then(function (resp) { if (resp.status < 200 || resp.status >= 300) { errorResult(); return; } try { const jsonText = resp.responseText.replace(/^[^(]+\(/, '').replace(/\)\s*$/, ''); const data = JSON.parse(jsonText); const res = data.resource || data; const score = parseFloat(res.rating); const count = parseInt(res.ratingCount, 10) || 0; if (isNaN(score) || score === 0) { noRating(); return; } buildSuccess(score, count); } catch (e) { errorResult(); } }).catch(function () { errorResult(); }); } // 主路径:GraphQL API const body = JSON.stringify({ query: 'query($id:ID!){title(id:$id){ratingsSummary{aggregateRating voteCount}}}', variables: { id: meta.imdbId }, }); deps.request('https://api.graphql.imdb.com/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, data: body, }).then(function (resp) { if (resp.status < 200 || resp.status >= 300) { tryJsonpFallback(); return; } try { const json = JSON.parse(resp.responseText); const summary = json.data && json.data.title && json.data.title.ratingsSummary; const score = summary && parseFloat(summary.aggregateRating); const count = (summary && summary.voteCount) || 0; if (score == null || isNaN(score) || score === 0) { noRating(); return; } buildSuccess(score, count); } catch (e) { tryJsonpFallback(); } }).catch(function () { tryJsonpFallback(); }); }); }, }); // --- Rotten Tomatoes --- sources.push({ key: 'rottentomatoes', label: '烂番茄', version: 6, types: ['movie'], requiredConfig: null, channels: [ { channelKey: 'rt_critics', label: '烂番茄 专业', icon: 'https://www.rottentomatoes.com/assets/pizza-pie/images/favicon.ico' }, { channelKey: 'rt_audience', label: '烂番茄 观众', icon: 'https://www.rottentomatoes.com/assets/pizza-pie/images/favicon.ico' }, ], fetch: function (meta, deps) { return new Promise(function (resolve) { const titleRaw = meta.originalTitle || meta.title || ''; const titleForSearch = stripSeason(titleRaw); const searchUrl = 'https://www.rottentomatoes.com/search?search=' + encodeURIComponent(titleForSearch); // RT 无 IMDB ID 查询能力,所有匹配本质都是文本搜索,统一标 fuzzy const matchConfidence = 'fuzzy'; function noMatchBoth() { resolve({ rt_critics: { channelKey: 'rt_critics', status: 'no_match', url: searchUrl }, rt_audience: { channelKey: 'rt_audience', status: 'no_match', url: searchUrl }, }); } function buildResults(criticsScore, audienceScore, criticsCount, audienceCount, movieUrl) { const results = {}; if (criticsScore != null && !isNaN(criticsScore)) { results.rt_critics = { channelKey: 'rt_critics', status: 'success', score: criticsScore, scoreMax: 100, displayValue: criticsScore + '%', count: criticsCount || null, countText: criticsCount ? criticsCount.toLocaleString() : null, url: movieUrl, matchedBy: 'english_title', matchConfidence: matchConfidence, externalId: movieUrl, }; } else { results.rt_critics = { channelKey: 'rt_critics', status: 'no_rating', url: movieUrl }; } if (audienceScore != null && !isNaN(audienceScore)) { results.rt_audience = { channelKey: 'rt_audience', status: 'success', score: audienceScore, scoreMax: 100, displayValue: audienceScore + '%', count: audienceCount || null, countText: audienceCount ? audienceCount.toLocaleString() : null, url: movieUrl, matchedBy: 'english_title', matchConfidence: matchConfidence, externalId: movieUrl, }; } else { results.rt_audience = { channelKey: 'rt_audience', status: 'no_rating', url: movieUrl }; } return results; } // 抽出:取 RT 详情页 → 提取 critics/audience 分数 → resolve // 给 fast path 和 normal 搜索路径共用。 // validateYear=true 时(仅 fast path)校验详情页年份与豆瓣年份是否一致—— // 用于治理被污染的 slugMap(旧缓存 URL 指向错年份的同名片,如 1999 版《木乃伊》), // 冲突则 onFail 回退到 normalFlow 重搜并改写 slugMap。 function fetchDetailAndResolve(movieUrl, onFail, validateYear) { deps.request(movieUrl).then(function (movieResp) { if (movieResp.status < 200 || movieResp.status >= 300) { onFail(); return; } const html = movieResp.responseText; // v1.1.8: TV 详情页 releaseYear 是 startYear,不能直接拿来跟豆瓣季度年份比; // 跳过 /tv/* 的年份守卫,电影页(/m/*)仍按原逻辑校验 slugMap 是否指向正确年份的片 const isTvDetail = /\/tv\//.test(movieUrl); if (validateYear && meta.year && !isTvDetail) { const detailYear = extractRtDetailYear(html); if (detailYear && !yearWithinOne(detailYear, meta.year)) { onFail(); return; } } // Method A: JSON in