// ==UserScript== // @name TMDB评分增强大师 // @namespace doveboy_js // @version 6.0.0 // @description TMDB列表页显示TMDB评分;TMDB详情页海报下方显示IMDb与豆瓣评分 // @author 白鸽男孩 // @license Zlib/Libpng License // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACE1BMVEVWZHBtd4A1TF4dOVAVM0t1foQpQlcLK0V2foQLK0UdOVAVM0sUMksDJUECJEECI0AEJkIILEYILUYILUcHLUcGLUcGLEcGLEgFLEgELEgEK0gDJkIDIz8gS1hNiYFco5RZpJhTo5pOop1JoZ9EoKI+n6Q5nqc0nakvnKwqm64lm7AhmrIcmbQXmLcSlbcLfaAFRmUpVV13upyAzqt5zK5yyrJrybVkyLhexrtXxb5QxMJJwsVDwcg8wMs2v84vvtEpvNQju9cdutoXud0RueAKps8ETm8DJEAMMEdqpYyHz6h+y6p3ya1xyLBqx7NjxbZcxLpWw71PwsBIwMNCv8Y7vsk1vcwvvM8putIjudUduNgXt9oQtt0Kt+MFkr0DLksBI0AgSFWDw52FzacXt9sKteEEq9kDQmMhSVaExJ6FzKcWt9oErNoDRGQCJEAPNElxrZGHzqgFmcQDMU81Y2Z+xKGAzat4y65xybFryLRkx7ddxbpWxL5Qw8FJwsRCwMc8v8o1vs0vvdApvNMjutYdudkXuNwRuOAKrtgEWn0FJ0IuXWNdno5otJ9ktaNes6ZYsqhSsatMsK5Gr7FArrQ6rbY0rLkvq7wqqr4kqcEfqMMap8YUpMYNj7QGVXYDJ0MILEUQN00QOU4POE8OOE8NOFAMOFALOFEKN1EJN1IIN1IHN1MGN1MFNVIEK0cDJD////8aMDTXAAAADHRSTlMAD3bZ+w6U+Q741/rWbuZkAAAAAWJLR0SwQ2SuxAAAAAd0SU1FB+QDCRYML2Rma/kAAAEsSURBVDjLY2BgZGJm4cEKWJiZGBkYWNnYeflwAF52Ng4GJk4+XpyAj5OJgYsXL+Bi4ObDJ8/HzcCDXwHPUFLALyAoJCwsIiIiKiYuAQSSUtIyKApk5eQVFJWUVVTV1DU0tbR1dPX0DZAU8BsaGZuYmplbWFpZ29ja2Ts4Ojm7uLq5wxTweXh6efv4+vkHBAYFh4SGhUdERkXHxMbFu0MVJCQmJaMrSIlJTUuXgSnIyMxCV5Adk5qTC1OQl19QiMWKomKYFbx5JaVl5RWVVdU1tXX1DY1NzS2tbe0dne4Ib3Z19/T29U+YOGnylKnTps+YOWv2nLnz5qME1IKFixYvWbJ02fIVK1auWrV6zdp169GCOg8K3KFgEEY3HgUEkz3BjEMw63EQyryEsj8Ad3V9dC1JE+0AAAAldEVYdGRhdGU6Y3JlYXRlADIwMjAtMDMtMDlUMjI6MTI6NDcrMDE6MDDPqcePAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIwLTAzLTA5VDIyOjEyOjQ3KzAxOjAwvvR/MwAAAFd6VFh0UmF3IHByb2ZpbGUgdHlwZSBpcHRjAAB4nOPyDAhxVigoyk/LzEnlUgADIwsuYwsTIxNLkxQDEyBEgDTDZAMjs1Qgy9jUyMTMxBzEB8uASKBKLgDqFxF08kI1lQAAAABJRU5ErkJggg== // @match https://www.themoviedb.org/* // @match https://www.themoviedb.org/*/* // @match https://www.themoviedb.org/*/*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @connect themoviedb.org // @connect www.themoviedb.org // @connect api.themoviedb.org // @connect omdbapi.com // @connect www.omdbapi.com // @connect api.douban.com // @run-at document-idle // ==/UserScript== (() => { 'use strict'; const VERSION = '6.0.0'; const CACHE_PREFIX = 'tmdb-ratings-master-v10:'; const LIST_PROCESSED_ATTR = 'data-tmdb-ratings-master-v10-processed'; const DETAIL_ROW_ID = 'tmdb-ratings-master-v10-detail-row'; /* * 存储键。 * 继续保留旧 OMDb 键名,以兼容之前通过菜单保存的 OMDb Key。 */ const OMDB_KEY_STORAGE = 'tmdb-pr-v6-omdb-key'; const TMDB_CREDENTIAL_STORAGE = 'tmdb-ratings-master-tmdb-credential'; /* * 豆瓣旧开放接口使用的固定 Key。 * 它不是你的 OMDb Key 或 TMDB Key。 */ const DOUBAN_API_KEY = '0ac44ae016490db2204ce0a042db2916'; const CACHE_TTL = { tmdb: 30 * 24 * 60 * 60 * 1000, imdbId: 30 * 24 * 60 * 60 * 1000, imdb: 7 * 24 * 60 * 60 * 1000, douban: 7 * 24 * 60 * 60 * 1000 }; const pendingRequests = new Map(); let currentUrl = location.href; let scanTimer = null; let positionTimer = null; let currentPosterImage = null; injectStyle(); registerMenus(); initialize(); function injectStyle() { const style = document.createElement('style'); style.textContent = ` .tmdb-ratings-master-list-rating { position: absolute !important; top: 8px !important; left: 8px !important; z-index: 50 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; min-width: 42px !important; height: 28px !important; padding: 0 8px !important; box-sizing: border-box !important; border: 1px solid rgba(255,255,255,.28) !important; border-radius: 6px !important; background: rgba(3,37,65,.95) !important; color: #fff !important; font: 700 14px/1 Arial,sans-serif !important; box-shadow: 0 2px 8px rgba(0,0,0,.28) !important; pointer-events: none !important; } .tmdb-ratings-master-list-rating.is-na { color: #cbd5df !important; } /* * 评分栏插入 body 并绝对定位。 * 因而不会影响 TMDB 原生详情页 flex 布局。 */ #tmdb-ratings-master-v10-detail-row { position: absolute !important; z-index: 10000 !important; display: grid !important; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; gap: 8px !important; box-sizing: border-box !important; pointer-events: auto !important; } #tmdb-ratings-master-v10-detail-row .tmdb-ratings-master-badge { min-width: 0 !important; height: 40px !important; display: flex !important; align-items: center !important; justify-content: space-between !important; gap: 5px !important; padding: 0 9px !important; box-sizing: border-box !important; border: 1px solid rgba(255,255,255,.20) !important; border-radius: 6px !important; color: #fff !important; text-decoration: none !important; font: 600 13px/1 Arial,sans-serif !important; box-shadow: 0 2px 7px rgba(0,0,0,.30) !important; overflow: hidden !important; transition: transform .15s ease, filter .15s ease !important; } #tmdb-ratings-master-v10-detail-row .tmdb-ratings-master-badge:hover { color: #fff !important; transform: translateY(-1px) !important; filter: brightness(1.12) !important; } #tmdb-ratings-master-v10-detail-row .tmdb-ratings-master-badge[aria-disabled="true"] { cursor: default !important; opacity: .86 !important; } #tmdb-ratings-master-v10-detail-row .label { overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } #tmdb-ratings-master-v10-detail-row .value { flex: 0 0 auto !important; margin-left: 4px !important; font-weight: 800 !important; white-space: nowrap !important; } #tmdb-ratings-master-v10-detail-row .imdb { background: #202020 !important; } #tmdb-ratings-master-v10-detail-row .imdb .label { color: #f5c518 !important; font-weight: 800 !important; } #tmdb-ratings-master-v10-detail-row .douban { background: #087b3e !important; } @media (max-width: 700px) { #tmdb-ratings-master-v10-detail-row .tmdb-ratings-master-badge { height: 38px !important; padding: 0 7px !important; font-size: 12px !important; } } `; document.head.appendChild(style); } function registerMenus() { GM_registerMenuCommand('设置 OMDb API Key', async () => { const oldValue = String( GM_getValue(OMDB_KEY_STORAGE, '') || '' ); const input = prompt( '请输入 OMDb API Key:\n\n' + '申请地址:https://www.omdbapi.com/apikey.aspx', oldValue ); if (input === null) return; const apiKey = input.trim(); if (!apiKey) { GM_deleteValue(OMDB_KEY_STORAGE); alert('OMDb API Key 已删除。'); resetDetailRow(); scanPage(); return; } try { const result = await requestJson( `https://www.omdbapi.com/?apikey=${encodeURIComponent(apiKey)}&i=tt0111161&r=json` ); if (result?.Response !== 'True') { throw new Error(result?.Error || '验证失败'); } GM_setValue(OMDB_KEY_STORAGE, apiKey); clearCacheByPrefixPart('imdb:'); alert( `OMDb API Key 验证成功。\n` + `${result.Title}\nIMDb:${result.imdbRating}` ); resetDetailRow(); scanPage(); } catch (error) { alert( `OMDb API Key 验证失败:\n${error.message}\n\n` + '本次不会保存该 Key。' ); } }); GM_registerMenuCommand('检查 OMDb API Key', async () => { const apiKey = String( GM_getValue(OMDB_KEY_STORAGE, '') || '' ).trim(); if (!apiKey) { alert('尚未设置 OMDb API Key。'); return; } try { const result = await requestJson( `https://www.omdbapi.com/?apikey=${encodeURIComponent(apiKey)}&i=tt0111161&r=json` ); if (result?.Response !== 'True') { throw new Error(result?.Error || '验证失败'); } alert( `OMDb API Key 有效。\n` + `${result.Title}:IMDb ${result.imdbRating}` ); } catch (error) { alert(`OMDb API Key 无效:\n${error.message}`); } }); GM_registerMenuCommand('设置 TMDB API 凭据', () => { const oldValue = String( GM_getValue(TMDB_CREDENTIAL_STORAGE, '') || '' ); const input = prompt( '请输入 TMDB API 凭据:\n\n' + '可以填写任意一种:\n' + '1. API Read Access Token(通常以 eyJ 开头)\n' + '2. API Key(通常为约 32 位短字符串)\n\n' + '获取地址:https://www.themoviedb.org/settings/api', oldValue ); if (input === null) return; const credential = input .trim() .replace(/^Bearer\s+/i, ''); if (!credential) { GM_deleteValue(TMDB_CREDENTIAL_STORAGE); alert('TMDB API 凭据已删除。'); return; } GM_setValue(TMDB_CREDENTIAL_STORAGE, credential); clearCacheByPrefixPart('imdb-id:'); alert( 'TMDB API 凭据已保存。\n' + '请执行“清除评分缓存并重新获取”,然后刷新页面。' ); }); GM_registerMenuCommand('检查 TMDB API 凭据', async () => { const credential = String( GM_getValue(TMDB_CREDENTIAL_STORAGE, '') || '' ) .trim() .replace(/^Bearer\s+/i, ''); if (!credential) { alert('尚未设置 TMDB API 凭据。'); return; } const isReadAccessToken = credential.startsWith('eyJ'); const url = isReadAccessToken ? 'https://api.themoviedb.org/3/movie/597/external_ids' : `https://api.themoviedb.org/3/movie/597/external_ids?api_key=${encodeURIComponent(credential)}`; const headers = { Accept: 'application/json;charset=utf-8' }; if (isReadAccessToken) { headers.Authorization = `Bearer ${credential}`; } try { const response = await request({ method: 'GET', url, headers, responseType: 'json' }); const body = parseResponseJson(response); if (response.status < 200 || response.status >= 300) { throw new Error( `HTTP ${response.status}` + `${body?.status_message ? `:${body.status_message}` : ''}` ); } if (!/^tt\d{7,10}$/i.test(String(body?.imdb_id || ''))) { throw new Error('TMDB API 响应中未包含 IMDb ID'); } alert( 'TMDB API 凭据有效。\n' + `泰坦尼克号 IMDb ID:${body.imdb_id}` ); } catch (error) { alert(`TMDB API 凭据无效:\n${error.message}`); } }); GM_registerMenuCommand('清除评分缓存并重新获取', () => { let count = 0; for (const key of GM_listValues()) { if (key.startsWith(CACHE_PREFIX)) { GM_deleteValue(key); count += 1; } } resetAllInjectedElements(); scanPage(); alert(`已清除 ${count} 条缓存,正在重新获取评分。`); }); GM_registerMenuCommand(`脚本版本:${VERSION}`, () => { alert(`TMDB评分增强大师 ${VERSION}`); }); } function initialize() { scanPage(); const observer = new MutationObserver(() => { if (location.href !== currentUrl) { currentUrl = location.href; resetAllInjectedElements(); } clearTimeout(scanTimer); scanTimer = setTimeout(scanPage, 250); }); observer.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener('resize', scheduleDetailRowPosition); window.addEventListener('scroll', scheduleDetailRowPosition, true); window.addEventListener('popstate', () => { currentUrl = location.href; resetAllInjectedElements(); clearTimeout(scanTimer); scanTimer = setTimeout(scanPage, 300); }); } function scanPage() { const media = getCurrentDetailMedia(); if ( media && document.querySelector('.header.large, .header_poster_wrapper') ) { addDetailRatings(media); return; } addListRatings(); } function getCurrentDetailMedia() { const match = location.pathname.match( /^\/(movie|tv)\/(\d+)(?:-[^/]+)?\/?$/ ); return match ? { type: match[1], id: match[2] } : null; } function findMainPosterImage() { const selectors = [ '.header.large .poster_wrapper img[src*="/t/p/"]', '.header_poster_wrapper ~ .poster_wrapper img[src*="/t/p/"]', '.poster_wrapper img[src*="/t/p/"]' ]; const candidates = []; for (const selector of selectors) { candidates.push(...document.querySelectorAll(selector)); } for (const image of candidates) { if (image.closest('script, template, .ac_results, footer')) { continue; } const source = [ image.currentSrc, image.src, image.getAttribute('data-src'), image.getAttribute('srcset') ].filter(Boolean).join(' '); if (/w45_|w66_|w92_|w94_|w154_|profile/i.test(source)) { continue; } const rect = image.getBoundingClientRect(); if ( rect.width >= 150 && rect.height >= 220 && rect.height > rect.width * 1.15 && rect.top < 900 ) { return image; } } return null; } function positionDetailRow() { const row = document.getElementById(DETAIL_ROW_ID); const poster = currentPosterImage || findMainPosterImage(); if (!row || !poster || !row.isConnected) return; const rect = poster.getBoundingClientRect(); row.style.left = `${Math.round(rect.left + window.scrollX)}px`; row.style.top = `${Math.round(rect.top + window.scrollY + rect.height + 10)}px`; row.style.width = `${Math.round(rect.width)}px`; } function scheduleDetailRowPosition() { clearTimeout(positionTimer); positionTimer = setTimeout(positionDetailRow, 30); } async function addDetailRatings(media) { const mediaKey = `${media.type}:${media.id}`; const oldRow = document.getElementById(DETAIL_ROW_ID); if (oldRow?.isConnected && oldRow.dataset.mediaKey === mediaKey) { currentPosterImage = findMainPosterImage() || currentPosterImage; scheduleDetailRowPosition(); return; } resetDetailRow(); const poster = findMainPosterImage(); if (!poster) { setTimeout(() => { if (!document.getElementById(DETAIL_ROW_ID)) { addDetailRatings(media); } }, 1000); return; } currentPosterImage = poster; const row = document.createElement('div'); row.id = DETAIL_ROW_ID; row.dataset.mediaKey = mediaKey; row.innerHTML = ` IMDb ... 豆瓣 ... `; document.body.appendChild(row); positionDetailRow(); requestAnimationFrame(positionDetailRow); setTimeout(positionDetailRow, 300); setTimeout(positionDetailRow, 1000); const imdbBadge = row.querySelector('.imdb'); const doubanBadge = row.querySelector('.douban'); const imdbIdResult = await getImdbId(media); if (!row.isConnected || row.dataset.mediaKey !== mediaKey) return; if (!imdbIdResult.id) { updateDetailBadge( imdbBadge, 'N/A', '', `IMDb ID 获取失败:${imdbIdResult.error || '未知原因'}` ); updateDetailBadge( doubanBadge, 'N/A', '', `豆瓣查询依赖 IMDb ID:${imdbIdResult.error || '未知原因'}` ); return; } const imdbId = imdbIdResult.id; const [imdbResult, doubanResult] = await Promise.all([ getImdbRating(imdbId), getDoubanRating(imdbId) ]); if (!row.isConnected || row.dataset.mediaKey !== mediaKey) return; updateDetailBadge( imdbBadge, imdbResult.rating || 'N/A', `https://www.imdb.com/title/${imdbId}/`, imdbResult.error ? `IMDb:${imdbResult.error}` : `IMDb 评分:${imdbResult.rating}` ); updateDetailBadge( doubanBadge, doubanResult.rating || 'N/A', doubanResult.url || '', doubanResult.error ? `豆瓣:${doubanResult.error}` : `豆瓣评分:${doubanResult.rating}` ); positionDetailRow(); } function updateDetailBadge(element, value, url, title) { element.querySelector('.value').textContent = value; element.title = title || ''; if (url) { element.href = url; element.target = '_blank'; element.rel = 'noopener noreferrer'; element.removeAttribute('aria-disabled'); } else { element.removeAttribute('href'); element.removeAttribute('target'); element.setAttribute('aria-disabled', 'true'); } } async function getImdbId(media) { const cacheKey = `imdb-id:${media.type}:${media.id}`; const cached = readCache(cacheKey, CACHE_TTL.imdbId); if (cached?.id) { return cached; } let credential = String( GM_getValue(TMDB_CREDENTIAL_STORAGE, '') || '' ) .trim() .replace(/^Bearer\s+/i, ''); if (!credential) { return { id: '', error: '未设置 TMDB API 凭据' }; } const isReadAccessToken = credential.startsWith('eyJ'); const url = isReadAccessToken ? `https://api.themoviedb.org/3/${media.type}/${media.id}/external_ids` : `https://api.themoviedb.org/3/${media.type}/${media.id}/external_ids?api_key=${encodeURIComponent(credential)}`; const headers = { Accept: 'application/json;charset=utf-8' }; if (isReadAccessToken) { headers.Authorization = `Bearer ${credential}`; } try { const response = await request({ method: 'GET', url, headers, responseType: 'json' }); const data = parseResponseJson(response); if (response.status < 200 || response.status >= 300) { return { id: '', error: `TMDB API HTTP ${response.status}` + `${data?.status_message ? `:${data.status_message}` : ''}` }; } const imdbId = String(data?.imdb_id || '').trim(); if (!/^tt\d{7,10}$/i.test(imdbId)) { return { id: '', error: 'TMDB API 返回成功,但该条目没有 IMDb ID' }; } const result = { id: imdbId }; writeCache(cacheKey, result); return result; } catch (error) { return { id: '', error: `TMDB API 请求失败:${error.message}` }; } } async function getImdbRating(imdbId) { const cacheKey = `imdb:${imdbId}`; const cached = readCache(cacheKey, CACHE_TTL.imdb); if (cached?.rating) { return cached; } const apiKey = String( GM_getValue(OMDB_KEY_STORAGE, '') || '' ).trim(); if (!apiKey) { return { rating: '', error: '未设置 OMDb API Key' }; } try { const data = await requestJson( `https://www.omdbapi.com/?apikey=${encodeURIComponent(apiKey)}&i=${encodeURIComponent(imdbId)}&r=json` ); if (data?.Response !== 'True') { return { rating: '', error: data?.Error || 'OMDb 未返回有效影片资料' }; } if (!data.imdbRating || data.imdbRating === 'N/A') { return { rating: '', error: 'OMDb 已找到影片,但 IMDb 暂无评分' }; } const result = { rating: String(data.imdbRating) }; writeCache(cacheKey, result); return result; } catch (error) { return { rating: '', error: `OMDb 请求失败:${error.message}` }; } } async function getDoubanRating(imdbId) { const cacheKey = `douban:${imdbId}`; const cached = readCache(cacheKey, CACHE_TTL.douban); if (cached?.rating || cached?.url) { return cached; } try { const response = await request({ method: 'POST', url: `https://api.douban.com/v2/movie/imdb/${encodeURIComponent(imdbId)}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: 'application/json' }, data: `apikey=${encodeURIComponent(DOUBAN_API_KEY)}`, responseType: 'json' }); if (response.status < 200 || response.status >= 300) { throw new Error(`豆瓣接口 HTTP ${response.status}`); } const data = parseResponseJson(response); const subject = Array.isArray(data?.subjects) ? data.subjects[0] : data; let url = subject?.alt || subject?.url || subject?.mobile_url || ''; if (url) { url = url .replace(/^http:/i, 'https:') .replace('/movie/', '/subject/'); } const rawRating = subject?.rating?.average ?? subject?.rating?.value ?? subject?.score ?? ''; const rating = Number(rawRating) > 0 ? Number(rawRating).toFixed(1) : ''; if (!rating && !url) { return { rating: '', url: '', error: data?.msg || data?.message || '豆瓣没有返回对应条目' }; } const result = { rating, url }; if (rating || url) { writeCache(cacheKey, result); } return { ...result, error: rating ? '' : '已找到豆瓣条目,但暂无评分' }; } catch (error) { return { rating: '', url: '', error: error.message }; } } function addListRatings() { const cards = [ ...new Set([ ...document.querySelectorAll('[data-object-id]'), ...document.querySelectorAll('.card.style_1'), ...document.querySelectorAll('.card.style_2') ]) ]; for (const card of cards) { if (card.getAttribute(LIST_PROCESSED_ATTR) === '1') continue; if (card.closest('script, template')) continue; const media = getMediaFromCard(card); const posterBox = getPosterBoxFromCard(card); if (!media || !posterBox) continue; card.setAttribute(LIST_PROCESSED_ATTR, '1'); if (getComputedStyle(posterBox).position === 'static') { posterBox.style.position = 'relative'; } const badge = document.createElement('span'); badge.className = 'tmdb-ratings-master-list-rating'; badge.textContent = '...'; posterBox.appendChild(badge); getTmdbRating(media).then(result => { if (!badge.isConnected) return; badge.textContent = result.rating || 'N/A'; badge.title = result.error ? `TMDB:${result.error}` : `TMDB 评分:${result.rating}`; badge.classList.toggle('is-na', !result.rating); }); } } function getMediaFromCard(card) { const id = card.getAttribute('data-object-id'); const type = card.getAttribute('data-object-type'); if (id && /^(movie|tv)$/i.test(type || '')) { return { type: type.toLowerCase(), id }; } const link = card.querySelector( 'a[href^="/movie/"], a[href^="/tv/"]' ); const match = link?.getAttribute('href')?.match( /^\/(movie|tv)\/(\d+)/ ); return match ? { type: match[1], id: match[2] } : null; } function getPosterBoxFromCard(card) { const image = card.querySelector( 'img.poster, .image img[src*="/t/p/"], img[src*="media.themoviedb.org/t/p/"]' ); return ( image?.closest('.image') || image?.closest('.image_content') || image?.parentElement || null ); } async function getTmdbRating(media) { const cacheKey = `tmdb:${media.type}:${media.id}`; const cached = readCache(cacheKey, CACHE_TTL.tmdb); if (cached?.rating) { return cached; } try { const html = await requestText( `https://www.themoviedb.org/${media.type}/${media.id}` ); const rating = extractTmdbRating(html); if (!rating) { return { rating: '', error: '暂无 TMDB 评分' }; } const result = { rating }; writeCache(cacheKey, result); return result; } catch (error) { return { rating: '', error: error.message }; } } function extractTmdbRating(html) { const source = String(html || ''); const percentMatch = source.match( /class=["'][^"']*user_score_chart[^"']*["'][^>]*data-percent=["'](\d+(?:\.\d+)?)/i ); if (percentMatch) { return (Number(percentMatch[1]) / 10).toFixed(1); } const jsonMatch = source.match( /"ratingValue"\s*:\s*"?(\d+(?:\.\d+)?)"?/i ); if (jsonMatch) { return Number(jsonMatch[1]).toFixed(1); } return ''; } function requestText(url) { return deduplicateRequest(`text:${url}`, () => request({ method: 'GET', url, headers: { Accept: 'text/html,application/xhtml+xml' } }).then(response => { if (response.status < 200 || response.status >= 300) { throw new Error(`HTTP ${response.status}`); } return response.responseText; }) ); } function requestJson(url) { return deduplicateRequest(`json:${url}`, () => request({ method: 'GET', url, headers: { Accept: 'application/json' }, responseType: 'json' }).then(response => { if (response.status < 200 || response.status >= 300) { const body = parseResponseJson(response); throw new Error( `HTTP ${response.status}` + `${body?.Error ? `:${body.Error}` : ''}` ); } return parseResponseJson(response); }) ); } function request(options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 15000, ...options, onload: resolve, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), onabort: () => reject(new Error('请求已取消')) }); }); } function parseResponseJson(response) { if (response?.response && typeof response.response === 'object') { return response.response; } try { return JSON.parse(response?.responseText || '{}'); } catch (_) { return {}; } } function deduplicateRequest(key, factory) { if (pendingRequests.has(key)) { return pendingRequests.get(key); } const promise = factory().finally(() => { pendingRequests.delete(key); }); pendingRequests.set(key, promise); return promise; } function readCache(key, ttl) { const stored = GM_getValue(CACHE_PREFIX + key, null); if (!stored || typeof stored !== 'object') { return null; } if (!stored.savedAt || Date.now() - stored.savedAt > ttl) { GM_deleteValue(CACHE_PREFIX + key); return null; } return stored.data || null; } function writeCache(key, data) { /* * 不缓存纯失败结果。 * 因此网络/API 临时失败时,刷新后仍可重新尝试。 */ if (!data || (!data.id && !data.rating && !data.url)) { return; } GM_setValue(CACHE_PREFIX + key, { savedAt: Date.now(), data }); } function clearCacheByPrefixPart(part) { for (const key of GM_listValues()) { if (key.startsWith(CACHE_PREFIX + part)) { GM_deleteValue(key); } } } function resetDetailRow() { document.querySelectorAll( [ '#tmdb-ratings-master-v10-detail-row', '#tmdb-pr-v9-detail-ratings', '#tmdb-pr-v8-detail-ratings', '#tmdb-pr-v7-detail-ratings', '#tmdb-pr-v6-detail-ratings' ].join(',') ).forEach(node => node.remove()); currentPosterImage = null; } function resetAllInjectedElements() { resetDetailRow(); document.querySelectorAll( '.tmdb-ratings-master-list-rating, .tmdb-pr-list-rating' ).forEach(node => node.remove()); document.querySelectorAll( `[${LIST_PROCESSED_ATTR}]` ).forEach(node => node.removeAttribute(LIST_PROCESSED_ATTR)); } })();