Greasy Fork

来自缓存

Greasy Fork is available in English.

豆瓣条目 NeoDB 评分增强

在豆瓣条目页(书籍、电影、音乐、游戏)上添加 NeoDB.social 的评分展示(含条目链接)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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) {
      // 下一兄弟元素有可能是 <a>,也可能是文本节点
      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) {
    // 常见旧结构示例(可能随时间变化,这里尽量写宽松一些):
    // <span class="rating-num">8.7</span>
    // <span class="rating-people">1234 人评价</span>
    // 新结构示例(你提供的实际 HTML,如书籍/专辑页面):
    // <div class="display">
    //   <hgroup>
    //     <h3>8.8 <small>/ 10</small></h3>
    //     <p>45 ratings</p>
    //   </hgroup>
    // </div>
    // 无评分时:
    // <div class="undisplay">
    //   <span>No enough ratings</span>
    // </div>

    // 先检查是否有评分显示区域(.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');

    // 若旧结构未命中,尝试新的 <p>45 ratings</p> 结构
    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
      ? `<span class="rating-count">(${result.ratingCount} 人评价)</span>`
      : '';

    span.innerHTML = `
      <span class="rating-row">
        <a href="${result.url}" target="_blank" class="rating-site">${result.site}</a>
        <span class="rating-value">${ratingText}</span>
        ${countPart}
      </span>
    `;

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