// ==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" // 游戏:
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