// ==UserScript== // @name 豆瓣条目 NeoDB 评分增强 // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description 在豆瓣条目页(书籍、电影、音乐、游戏)上添加 NeoDB.social 的评分展示(含条目链接) // @match https://book.douban.com/subject/* // @match https://movie.douban.com/subject/* // @match https://music.douban.com/subject/* // @match https://game.douban.com/subject/* // @match https://www.douban.com/game/* // @icon https://img3.doubanio.com/favicon.ico // @grant GM_xmlhttpRequest // @connect neodb.social // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/559133/%E8%B1%86%E7%93%A3%E6%9D%A1%E7%9B%AE%20NeoDB%20%E8%AF%84%E5%88%86%E5%A2%9E%E5%BC%BA.user.js // @updateURL https://update.greasyfork.icu/scripts/559133/%E8%B1%86%E7%93%A3%E6%9D%A1%E7%9B%AE%20NeoDB%20%E8%AF%84%E5%88%86%E5%A2%9E%E5%BC%BA.meta.js // ==/UserScript== (function () { 'use strict'; function log(...args) { console.log('[Douban-NeoDB]', ...args); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * 安全选择器:避免页面结构小幅变化时报错 */ function $(selector, root = document) { return root.querySelector(selector); } /** * 返回当前条目类型: * - book / movie / music / game / unknown */ function getEntryType() { 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/35764203/ if (host === 'www.douban.com' && path.startsWith('/game/')) return 'game'; return 'unknown'; } /** * 通用的标题获取 */ function getTitle() { const h1 = $('h1'); if (!h1) return null; return h1.textContent.trim(); } /** * 豆瓣条目 id(数字部分) * 例如 https://movie.douban.com/subject/1292052/ -> 1292052 */ function getDoubanId() { const match = location.pathname.match(/subject\/(\d+)/); return match ? match[1] : null; } /** * 解析豆瓣信息区域(书/电影/音乐/游戏结构略有不同,这里只抽取通用可用字段) */ function parseInfoBlock() { const infoEl = $('#info'); if (!infoEl) return {}; const text = infoEl.textContent || ''; const result = {}; // ISBN(书籍) const isbnMatch = text.match(/ISBN:\s*([\dXx-]+)/); if (isbnMatch) result.isbn = isbnMatch[1].replace(/-/g, ''); // 原作名(书/影视中可能出现) const originalTitleMatch = text.match(/原作名:\s*(.+)/); if (originalTitleMatch) { result.originalTitle = originalTitleMatch[1].trim(); } // 导演 / 作者 / 表演者等,先简单取第一个人名字段 // 为了可扩展,只抽象成 "mainCreator" 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') { result.mainCreator = next.textContent.trim(); } else if (creatorSpan.nextSibling) { result.mainCreator = creatorSpan.nextSibling.textContent.trim(); } } return result; } /** * 聚合一个统一的条目描述对象,为所有平台提供同一输入结构 */ function buildUnifiedEntry() { const type = getEntryType(); const doubanId = getDoubanId(); const title = getTitle(); const extra = parseInfoBlock(); const unified = { type, // 'book' | 'movie' | 'music' | 'game' | 'unknown' doubanId, title, isbn: extra.isbn || null, originalTitle: extra.originalTitle || null, mainCreator: extra.mainCreator || null, // 备用字段:方便未来扩展 _raw: { infoText: ($('#info') || {}).textContent || '', }, }; log('Parsed douban entry:', unified); return unified; } /** * 获取我们插入评分的目标容器 * 目前豆瓣大部分条目都在 #interest_sectl 下展示评分 */ function getRatingContainer() { return $('#interest_sectl') || $('#wrapper'); } function neodbCategoryFromType(type) { // NeoDB 的分类名与豆瓣略有不同,这里做一个简单映射 switch (type) { case 'book': return 'book'; case 'movie': return 'movie'; case 'music': // NeoDB 上音乐条目通常使用 /album/ 路径,这里用 album 作为搜索分类 return 'album'; case 'game': return 'game'; default: return 'all'; } } /** * 从 NeoDB 搜索结果中找到最匹配的条目 * 这里只做一个比较保守的匹配规则,后续你可以根据需要继续增强。 */ function findBestNeodbResult(doc, unifiedEntry, originalQuery) { const cards = doc.querySelectorAll('.entity-card, .catalog-card, .subject-card'); if (!cards || cards.length === 0) { return null; } // 如果是用完整豆瓣 URL 搜索,NeoDB 一般会把最相关结果排在最前面,直接取第一个即可 const isUrlQuery = typeof originalQuery === 'string' && /^https?:\/\//.test(originalQuery); if (isUrlQuery) { const firstCard = cards[0]; const titleEl = firstCard.querySelector('.title a') || firstCard.querySelector('.title') || firstCard.querySelector('a'); if (!titleEl) return null; return { element: firstCard, title: titleEl.textContent.trim(), link: titleEl.href || titleEl.getAttribute('href'), }; } const clean = (str) => (str || '') .toLowerCase() .replace(/[^\w\s\u4e00-\u9fff]/g, '') .replace(/\s+/g, ' ') .trim(); const targetTitle = clean(unifiedEntry.title); const targetOriginal = clean(unifiedEntry.originalTitle); let best = null; let bestScore = 0; cards.forEach((card) => { const titleEl = card.querySelector('.title a') || card.querySelector('.title') || card.querySelector('a'); if (!titleEl) return; const title = titleEl.textContent.trim(); const titleClean = clean(title); let score = 0; if (titleClean === targetTitle) score += 5; else if (titleClean.includes(targetTitle) || targetTitle.includes(titleClean)) score += 3; if (targetOriginal) { if (titleClean === targetOriginal) score += 4; else if (titleClean.includes(targetOriginal) || targetOriginal.includes(titleClean)) { score += 2; } } // 未来可以再加作者 / 年份等字段参与打分 if (score > bestScore) { bestScore = score; best = { element: card, title, link: titleEl.href || titleEl.getAttribute('href'), }; } }); return best; } /** * 解析 NeoDB 详情页上的评分与评分人数 */ function parseNeodbDetail(doc, url) { // 常见旧结构示例(可能随时间变化,这里尽量写宽松一些): // 8.7 // 1234 人评价 // 新结构示例(你提供的实际 HTML,如书籍/专辑页面): //
//
//

8.8 / 10

//

45 ratings

//
//
// 无评分时: //
// No enough ratings //
// 先检查是否有评分显示区域(.display),如果有,优先解析评分 // 只有当没有 .display 且有 .undisplay 时,才认为无评分 const displayBlock = doc.querySelector('#item-rating .display') || doc.querySelector('.rating .display') || doc.querySelector('section .display') || doc.querySelector('.display'); const undisplayEl = doc.querySelector('.undisplay'); // 如果只有 .undisplay 且没有 .display,才认为无评分 if (undisplayEl && !displayBlock) { const undisplayText = undisplayEl.textContent || ''; if (/no\s+enough\s+ratings?/i.test(undisplayText)) { // 确实是无评分提示 log('NeoDB: found "No enough ratings" hint and no display block'); return { site: 'NeoDB', hasRating: false, url }; } } // 如果有 .display 区域,即使也有 .undisplay,也优先尝试解析评分 // 尝试多种方式查找评分元素 let ratingEl = doc.querySelector('.rating-num') || doc.querySelector('[itemprop="ratingValue"]') || doc.querySelector('.rating > strong'); // 如果以上都没找到,尝试匹配新的 h3 结构 if (!ratingEl) { if (displayBlock) { ratingEl = displayBlock.querySelector('hgroup h3') || displayBlock.querySelector('h3'); } // 再退一步,全局找一个"数字 + / 10"形式的元素 if (!ratingEl) { ratingEl = Array.from(doc.querySelectorAll('h1,h2,h3,span,strong,div')).find((el) => /[\d.]+\s*\/\s*10/.test(el.textContent) ); } } // 如果还是找不到,尝试更宽松的匹配:任何包含数字评分格式的元素 if (!ratingEl) { ratingEl = Array.from(doc.querySelectorAll('*')).find((el) => { const text = el.textContent || ''; // 匹配 "8.8 / 10" 或 "8.8/10" 或单独的 "8.8"(在评分区域内) return /[\d.]+\s*\/\s*10/.test(text) || (el.closest('#item-rating, .rating, section') && /^[\d.]+$/.test(text.trim()) && parseFloat(text.trim()) >= 0 && parseFloat(text.trim()) <= 10); }); } if (!ratingEl) { log('NeoDB: rating element not found, URL:', url); // 找不到评分元素,但有条目 URL,返回"无评分"标记 return { site: 'NeoDB', hasRating: false, url }; } const ratingText = ratingEl.textContent.trim(); const ratingMatch = ratingText.match(/[\d.]+/); if (!ratingMatch) { log('NeoDB: rating text found but no number match:', ratingText); return { site: 'NeoDB', hasRating: false, url }; } const ratingValue = ratingMatch[0]; log('NeoDB: found rating value:', ratingValue); let ratingCountEl = doc.querySelector('.rating-people') || doc.querySelector('[itemprop="ratingCount"]') || doc.querySelector('.rating-people-count'); // 若旧结构未命中,尝试新的

45 ratings

结构 if (!ratingCountEl) { if (displayBlock) { // 只在 rating 区域内找 p 文本包含 "rating" ratingCountEl = Array.from(displayBlock.querySelectorAll('p')).find((p) => /ratings?/i.test(p.textContent) ); } // 兜底:全局找含 "ratings" 的 p if (!ratingCountEl) { ratingCountEl = Array.from(doc.querySelectorAll('p')).find((p) => /ratings?/i.test(p.textContent) ); } } // 进一步兜底:全局搜索"XX 个评分 / 人评分 / ratings" if (!ratingCountEl) { ratingCountEl = Array.from( doc.querySelectorAll('p,span,div,small') ).find((el) => /(\d+)\s*(个评分|人评分|ratings?)/i.test(el.textContent)); } let ratingCount = 'N/A'; if (ratingCountEl) { const countMatch = ratingCountEl.textContent.replace(/,/g, '').match(/(\d+)/); if (countMatch) ratingCount = countMatch[1]; log('NeoDB: found rating count:', ratingCount); } else { log('NeoDB: rating count element not found'); } // 如果评分人数为 0,视为无有效评分 if (ratingCount === '0' || ratingCount === 0) { log('NeoDB: rating count is 0, treating as no rating'); return { site: 'NeoDB', hasRating: false, url }; } log('NeoDB: successfully parsed rating:', ratingValue, 'count:', ratingCount); return { site: 'NeoDB', rating: ratingValue, ratingCount, url, hasRating: true, }; } /** * 执行 NeoDB 搜索 + 详情解析 */ function fetchNeodbRating(unifiedEntry) { return new Promise((resolve) => { const primaryCategory = neodbCategoryFromType(unifiedEntry.type); const doubanUrl = window.location.href; // 先按 greasyfork 脚本的方式,用豆瓣完整 URL 搜索; // 如无结果,再回退到按标题搜索。 const queries = [doubanUrl]; if (unifiedEntry.title && unifiedEntry.title !== doubanUrl) { queries.push(unifiedEntry.title); } // 对于 movie 类型,如果主分类搜索失败,也尝试 tv 分类(因为豆瓣电影页可能包含剧集) const fallbackCategories = unifiedEntry.type === 'movie' ? ['tv', 'all'] : []; const trySearch = (queryIndex, categoryToUse = null, fallbackIndex = 0) => { // 如果所有查询都试完了 if (queryIndex >= queries.length) { // 如果还有备用分类,尝试下一个备用分类 if (fallbackIndex < fallbackCategories.length) { return trySearch(0, fallbackCategories[fallbackIndex], fallbackIndex + 1); } return resolve(null); } const query = queries[queryIndex]; const currentCategory = categoryToUse || primaryCategory; const searchUrl = 'https://neodb.social/search?' + new URLSearchParams({ q: query, category: currentCategory, }).toString(); log('Requesting NeoDB search:', searchUrl); GM_xmlhttpRequest({ method: 'GET', url: searchUrl, onload: (resp) => { try { if (resp.status < 200 || resp.status >= 300) { log('NeoDB search failed, status:', resp.status); return trySearch(queryIndex + 1, categoryToUse, fallbackIndex); } const parser = new DOMParser(); const doc = parser.parseFromString(resp.responseText, 'text/html'); // 情况一:搜索直接被重定向到具体条目详情页(常见于用豆瓣 URL 搜索时) const finalUrl = resp.finalUrl || searchUrl; const directMatch = /https?:\/\/neodb\.social\/(book|movie|album|music|game|tv\/season)\//.test( finalUrl ); if (directMatch) { log('NeoDB search redirected directly to entity page:', finalUrl); const parsed = parseNeodbDetail(doc, finalUrl); if (parsed) { // parsed 可能是 { hasRating: false, url } 或 { hasRating: true, rating, ratingCount, url } return resolve(parsed); } // 如果 parseNeodbDetail 返回 null(理论上不应该发生,但作为兜底) log('NeoDB entity page parse returned null, fallback to link-only result'); return resolve({ site: 'NeoDB', hasRating: false, url: finalUrl, }); } // 情况二:正常的搜索列表页 const best = findBestNeodbResult(doc, unifiedEntry, query); if (!best || !best.link) { log('NeoDB: no suitable search result on query:', query); return trySearch(queryIndex + 1, categoryToUse, fallbackIndex); } const detailUrl = best.link.startsWith('http') ? best.link : 'https://neodb.social' + best.link; log('NeoDB best match from search list:', best.title, detailUrl); // 再请求详情页 GM_xmlhttpRequest({ method: 'GET', url: detailUrl, onload: (detailResp) => { try { if (detailResp.status < 200 || detailResp.status >= 300) { log('NeoDB detail failed, status:', detailResp.status); return trySearch(queryIndex + 1, categoryToUse, fallbackIndex); } const dDoc = parser.parseFromString(detailResp.responseText, 'text/html'); const parsed = parseNeodbDetail(dDoc, detailUrl); if (!parsed) { // 如果 parseNeodbDetail 返回 null(理论上不应该发生,但作为兜底) log('NeoDB detail: parse returned null, fallback to link-only result'); return resolve({ site: 'NeoDB', hasRating: false, url: detailUrl, }); } // parsed 可能是 { hasRating: false, url } 或 { hasRating: true, rating, ratingCount, url } resolve(parsed); } catch (e) { console.error('[Douban-NeoDB] NeoDB detail parse error', e); trySearch(queryIndex + 1, categoryToUse, fallbackIndex); } }, onerror: () => { log('NeoDB detail request error'); trySearch(queryIndex + 1, categoryToUse, fallbackIndex); }, }); } catch (e) { console.error('[Douban-NeoDB] NeoDB search parse error', e); trySearch(queryIndex + 1, categoryToUse, fallbackIndex); } }, onerror: () => { log('NeoDB search request error'); trySearch(queryIndex + 1, categoryToUse, fallbackIndex); }, }); }; trySearch(0); }); } /** * =============================== * UI 层:在豆瓣页面上插入评分区域 * =============================== */ function ensureBaseStyles() { if (document.getElementById('douban-neodb-rating-style')) return; const style = document.createElement('style'); style.id = 'douban-neodb-rating-style'; style.textContent = ` .douban-thirdparty-rating { display: block; margin-top: 4px; font-size: 12px; } .douban-thirdparty-rating .rating-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; } .douban-thirdparty-rating .rating-site { color: #37a; text-decoration: none; border-radius: 3px; padding: 1px 3px; transition: color 0.3s ease, background-color 0.3s ease; } .douban-thirdparty-rating .rating-site:hover { color: #fff; background-color: #37a; } .douban-thirdparty-rating .rating-value { font-weight: bold; color: #333; } .douban-thirdparty-rating[data-tooltip] { position: relative; } .douban-thirdparty-rating[data-tooltip]:hover::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 0; background-color: #333; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; z-index: 9999; margin-bottom: 4px; } .douban-thirdparty-rating.loading { color: #999; } `; document.head.appendChild(style); } function insertLoadingIndicator(container) { const span = document.createElement('span'); span.id = 'douban-neodb-rating-loading'; span.className = 'douban-thirdparty-rating loading'; span.textContent = 'NeoDB 评分加载中...'; container.appendChild(span); return span; } function removeLoadingIndicator() { const el = document.getElementById('douban-neodb-rating-loading'); if (el && el.parentNode) { el.parentNode.removeChild(el); } } function addRatingRow(result) { if (!result) return; const container = getRatingContainer(); if (!container) return; const span = document.createElement('span'); span.className = 'douban-thirdparty-rating'; // 检查是否有有效评分(优先使用 hasRating 标记,如果没有则检查 rating 字段) const hasRating = result.hasRating !== undefined ? result.hasRating : result.rating !== null && result.rating !== undefined && result.rating !== ''; const ratingText = hasRating ? result.rating : '暂无评分'; const hasCount = hasRating && result.ratingCount && result.ratingCount !== 'N/A' && result.ratingCount !== '0'; const tooltip = hasRating ? `${result.site}:${result.rating}/10,${result.ratingCount} 人评价` : `${result.site}:暂无评分`; span.setAttribute('data-tooltip', tooltip); const countPart = hasCount ? `(${result.ratingCount} 人评价)` : ''; span.innerHTML = `
${result.site} ${ratingText} ${countPart} `; container.appendChild(span); } /** * =============================== * 初始化与主流程 * =============================== */ async function init() { try { ensureBaseStyles(); const container = getRatingContainer(); if (!container) { log('No rating container found, abort.'); return; } const unifiedEntry = buildUnifiedEntry(); if (!unifiedEntry.title) { log('No title parsed, abort.'); return; } const loadingEl = insertLoadingIndicator(container); // 目前只调用 NeoDB,将来可在此处扩展更多平台: // const results = await Promise.all([ // fetchNeodbRating(unifiedEntry), // fetchOtherPlatform(unifiedEntry), // ... // ]); const neodbResult = await fetchNeodbRating(unifiedEntry); if (loadingEl && loadingEl.parentNode) { loadingEl.parentNode.removeChild(loadingEl); } if (!neodbResult) { const fail = document.createElement('span'); fail.className = 'douban-thirdparty-rating'; fail.textContent = '暂未查到 NeoDB 评分'; container.appendChild(fail); return; } addRatingRow(neodbResult); } catch (e) { console.error('[Douban-NeoDB] init error', e); } } // 等待页面基本加载完成后再执行 if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', () => { // 再稍微等一下,确保 #interest_sectl 出现 delay(300).then(init); }); } else { delay(300).then(init); } })();