Komga - Sync Comic Ratings (from ComicBookRoundup)

Fetches comic ratings from comicbookroundup.com and syncs them into Komga metadata using the API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Komga - Sync Comic Ratings (from ComicBookRoundup)
// @namespace    wreck.userscripts.komga.rating
// @version      2.0
// @description  Fetches comic ratings from comicbookroundup.com and syncs them into Komga metadata using the API
// @grant        GM_xmlhttpRequest
// @connect      comicbookroundup.com
// @author       wrecks-code
// @match        https://komga.org/*
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // !!! Paste your Komga API Key AND the URL in @match up top!
  const KOMGA_API_KEY = "YOUR_API_KEY_HERE";
  // !!!

  // This doesn't have to be changed
  const CBR_SEARCH_URL = "https://comicbookroundup.com/search_results.php?f_search=";
  const KOMGA_HOST = location.origin;

  /**
   * (1) Routing Helpers
   */
  function isSeriesPage() {
    return location.pathname.startsWith("/series/");
  }

  function isLibrarySeriesPage() {
    // e.g. /libraries/<ID>/series
    return /^\/libraries\/[^/]+\/series/.test(location.pathname);
  }

  function getSeriesId() {
    // /series/<id>
    return location.pathname.split("/")[2];
  }

  function getLibraryIdFromPath() {
    // /libraries/<libraryId>/series
    return location.pathname.split("/")[2];
  }

  /**
   * (2) Insert/Remove Buttons
   */
  function insertFetchSingleButton() {
    const downloadBtn = document.querySelector("a[title='Download series']");
    if (!downloadBtn) return;
    if (document.getElementById("fetch-ratings-button")) return;

    const parentDiv = downloadBtn.closest(".col");
    if (!parentDiv) return;

    const fetchButton = document.createElement("button");
    fetchButton.id = "fetch-ratings-button";
    fetchButton.classList.add("v-btn", "v-btn--is-elevated", "v-btn--has-bg", "theme--dark", "v-size--small");
    fetchButton.style.marginLeft = "10px";
    // Original button HTML with star icon:
    fetchButton.innerHTML = `
      <span class="v-btn__content">
        <i aria-hidden="true" class="v-icon notranslate v-icon--left mdi mdi-star-outline theme--dark" style="font-size:16px;"></i>
        Fetch Ratings
      </span>`;
    fetchButton.onclick = () => fetchRatingsForSeries(getSeriesId(), fetchButton);

    parentDiv.appendChild(fetchButton);
  }

  let fetchProcessActive = false; // Global flag to track fetching state

  function insertFetchAllButton() {
    // Locate the "mdi-view-grid-plus" button inside the toolbar
    const gridBtnIcon = document.querySelector('button.v-btn > span.v-btn__content > i.mdi-view-grid-plus');
    if (!gridBtnIcon) return;

    // Get the button itself (not just the icon)
    const gridBtn = gridBtnIcon.closest("button.v-btn");
    if (!gridBtn) return;

    // Check if our elements are already inserted
    if (document.getElementById("fetch-all-ratings-button")) return;

    // Create a wrapper div to hold both status counter and button
    const wrapper = document.createElement("div");
    wrapper.id = "fetch-all-ratings-wrapper";
    wrapper.style.display = "flex";
    wrapper.style.alignItems = "center";
    wrapper.style.gap = "8px"; // Space between counter and button

    // Create the progress counter
    const status = document.createElement("span");
    status.id = "fetch-status";
    status.style.fontSize = "14px";
    status.style.color = "#ffffff"; // Keep consistent with dark theme
    status.style.display = "none"; // Hidden until needed
    status.innerText = "0/0"; // Default

    // Create the button
    const btn = document.createElement("button");
    btn.id = "fetch-all-ratings-button";
    btn.className = "v-btn v-btn--icon v-btn--round theme--dark v-size--default";
    btn.title = "Fetch All Ratings";

    btn.innerHTML = `
        <span class="v-btn__content">
            <i id="fetch-icon" aria-hidden="true" class="v-icon notranslate mdi mdi-star theme--dark" style="font-size:22px;"></i>
        </span>`;

    // Append the counter and button to the wrapper
    wrapper.appendChild(status);
    wrapper.appendChild(btn);

    // Insert the wrapper before the grid button (keeping structure clean)
    const parentContainer = gridBtn.parentElement;
    if (parentContainer) {
      parentContainer.insertBefore(wrapper, gridBtn);
    }

    // Handle click event with a loading spinner and progress counter
    btn.addEventListener("click", () => {
      const libraryId = getLibraryIdFromPath();
      const icon = btn.querySelector("#fetch-icon");

      // Show spinner and progress counter
      icon.classList.add("mdi-loading", "mdi-spin");
      status.style.display = "inline"; // Show progress counter

      fetchAllRatingsInLibrary(libraryId, (current, total) => {
        // Update progress status
        status.innerText = `${current}/${total}`;

        // If complete, restore original icon
        if (current >= total) {
          icon.classList.remove("mdi-loading", "mdi-spin");
          status.style.display = "none"; // Hide progress counter
        }
      });
    });
  }

  function removeFetchSingleButton() {
    const btn = document.getElementById("fetch-ratings-button");
    if (btn) btn.remove();
  }

  function removeFetchAllButton() {
    const btn = document.getElementById("fetch-all-ratings-button");
    if (btn) btn.remove();
  }

  /**
   * (NEW) Insert/Remove Rating Row
   *
   * Inserts a new row that shows only the star widget (with larger 20px stars)
   * and loads any saved rating. It is inserted right above the row containing
   * the Download and Fetch Ratings buttons.
   */
  function insertRatingRow() {
    if (document.getElementById("user-rating-row")) return; // avoid duplicates

    // Locate the download button row by finding the "Download series" link.
    const downloadBtn = document.querySelector("a[title='Download series']");
    if (!downloadBtn) return;
    const buttonRow = downloadBtn.closest(".row");
    if (!buttonRow) return;

    // Create the rating row element.
    const ratingRow = document.createElement("div");
    ratingRow.id = "user-rating-row";
    ratingRow.className = "row align-center text-caption";

    // Create a full-width column for the stars.
    const fullCol = document.createElement("div");
    fullCol.className = "py-1 col-12";

    // Create the star widget container.
    const starContainer = document.createElement("div");
    starContainer.style.display = "inline-flex";
    starContainer.style.alignItems = "center";

    let currentRating = 0;
    const stars = [];
    function updateStars(rating) {
      stars.forEach((star, index) => {
        if (index < rating) {
          star.classList.remove("mdi-star-outline");
          star.classList.add("mdi-star");
        } else {
          star.classList.remove("mdi-star");
          star.classList.add("mdi-star-outline");
        }
      });
    }

    // Create 10 stars with hover and click handlers.
    for (let i = 1; i <= 10; i++) {
      const star = document.createElement("i");
      star.classList.add("v-icon", "notranslate", "mdi", "mdi-star-outline", "theme--dark");
      star.style.fontSize = "20px";
      star.style.cursor = "pointer";
      star.style.marginRight = "2px";

      star.addEventListener("mouseover", () => {
        updateStars(i);
      });
      star.addEventListener("mouseout", () => {
        updateStars(currentRating);
      });
      star.addEventListener("click", () => {
        currentRating = i;
        updateStars(currentRating);
        const seriesId = getSeriesId();
        // Save the rating immediately as an integer.
        addLinkToSeries(
          seriesId,
          "Your Rating",
          currentRating.toString(),
          location.href,
          "Your Rating",
          (result) => {
            console.log("Rating saved:", currentRating);
          }
        );
      });

      stars.push(star);
      starContainer.appendChild(star);
    }

    fullCol.appendChild(starContainer);
    ratingRow.appendChild(fullCol);

    // Insert the rating row above the download button row.
    buttonRow.parentNode.insertBefore(ratingRow, buttonRow);

    // Load any saved rating from Komga metadata.
    const seriesId = getSeriesId();
    fetch(`${KOMGA_HOST}/api/v1/series/${seriesId}`, {
      headers: { Authorization: `Bearer ${KOMGA_API_KEY}` }
    })
    .then(r => r.json())
    .then(series => {
      const ratingLink = series.metadata?.links?.find(link => link.label.startsWith("Your Rating:"));
      if (ratingLink) {
        // Expecting a label like "Your Rating: 5"
        const parts = ratingLink.label.split(":");
        if (parts.length > 1) {
          const savedRating = parseInt(parts[1].trim(), 10);
          if (!isNaN(savedRating)) {
            currentRating = savedRating;
            updateStars(currentRating);
          }
        }
      } else {
        currentRating = 0;
        updateStars(currentRating);
      }
    })
    .catch(err => {
      console.error("Error fetching series metadata for rating:", err);
    });
  }

  function removeRatingRow() {
    const row = document.getElementById("user-rating-row");
    if (row) row.remove();
  }

  /**
   * (3) SPA route detection
   */
  let prevPath = location.pathname;
  setInterval(() => {
    const currentPath = location.pathname;
    if (currentPath !== prevPath) {
      prevPath = currentPath;
      handleRouteChange();
    }
    handleRouteChange();
  }, 1000);

  function handleRouteChange() {
    if (isSeriesPage()) {
      removeFetchAllButton();
      insertRatingRow();
      insertFetchSingleButton();
    } else if (isLibrarySeriesPage()) {
      removeFetchSingleButton();
      removeRatingRow();
      insertFetchAllButton();
    } else {
      removeFetchSingleButton();
      removeFetchAllButton();
      removeRatingRow();
    }
  }

  // On load
  handleRouteChange();

  /******************************************************
   * (A) Single-series routine
   ******************************************************/
  function fetchRatingsForSeries(seriesId, button) {
    // Indicate fetching without losing inner HTML structure.
    button.innerHTML = "Fetching...";
    fetch(`${KOMGA_HOST}/api/v1/series/${seriesId}`, {
      headers: { Authorization: `Bearer ${KOMGA_API_KEY}` }
    })
    .then(r => r.json())
    .then(series => {
      // (A1) Determine the year.
      let releaseYear = series.metadata?.releaseYear;
      if (!releaseYear || releaseYear === 0) {
        const dateStr = series.booksMetadata?.releaseDate;
        if (dateStr) {
          const m = dateStr.match(/^(\d{4})/);
          if (m) {
            releaseYear = parseInt(m[1], 10);
          }
        }
      }
      // (A2) Prefer metadata.title, fallback to series.name.
      let rawTitle = series.metadata?.title || series.name;
      rawTitle = rawTitle.replace(/\(\d{4}\)\s*$/, "");
      const finalTitle = rawTitle.trim();

      searchComicBookRoundup(finalTitle, releaseYear, bestUrl => {
        if (!bestUrl) {
          // Restore original button HTML.
          button.innerHTML = `
            <span class="v-btn__content">
              <i aria-hidden="true" class="v-icon notranslate v-icon--left mdi mdi-star-outline theme--dark" style="font-size:16px;"></i>
              Fetch Ratings
            </span>`;
          return;
        }
        // Scrape ratings from matched CBR page.
        fetchComicRatings(bestUrl, (criticRating, userRating, criticReviews, userReviews) => {
          addLinkToSeries(
            seriesId,
            "Critic Rating",
            `${criticRating} (${criticReviews} review${criticReviews === 1 ? "" : "s"})`,
            bestUrl,
            "Critic Rating",
            () => {
              addLinkToSeries(
                seriesId,
                "User Rating",
                `${userRating} (${userReviews} review${userReviews === 1 ? "" : "s"})`,
                bestUrl,
                "User Rating",
                () => {
                  console.log(`✅ Ratings for ${finalTitle} (${releaseYear}) updated!`);
                  // Restore original button HTML.
                  button.innerHTML = `
                    <span class="v-btn__content">
                      <i aria-hidden="true" class="v-icon notranslate v-icon--left mdi mdi-star-outline theme--dark" style="font-size:16px;"></i>
                      Fetch Ratings
                    </span>`;
                }
              );
            }
          );
        });
      });
    })
    .catch(err => {
      console.error("❌ Error fetching Komga series:", err);
      button.innerHTML = `
        <span class="v-btn__content">
          <i aria-hidden="true" class="v-icon notranslate v-icon--left mdi mdi-star-outline theme--dark" style="font-size:16px;"></i>
          Fetch Ratings
        </span>`;
    });
  }

  /******************************************************
   * (B) Library-wide routine
   ******************************************************/
  function fetchAllRatingsInLibrary(libraryId, progressCallback) {
    const url = `${KOMGA_HOST}/api/v1/series?library_id=${libraryId}&page=0&size=9999`;
    fetch(url, { headers: { Authorization: `Bearer ${KOMGA_API_KEY}` } })
      .then(r => r.json())
      .then(data => {
        const seriesArr = data.content || [];
        console.log(`📚 Library ID: ${libraryId} → Found ${seriesArr.length} series to process!`);
        if (!seriesArr.length) {
          progressCallback(0, 0);
          return;
        }
        let index = 0;
        function processNext() {
          if (index >= seriesArr.length) {
            progressCallback(seriesArr.length, seriesArr.length);
            console.log("All series processed!");
            return;
          }
          progressCallback(index + 1, seriesArr.length);
          const s = seriesArr[index];
          index++;
          fetchRatingsForSingleSeriesObj(s, () => {
            processNext();
          });
        }
        processNext();
      })
      .catch(err => {
        console.error("❌ Error fetching library series:", err);
        progressCallback(0, 0);
      });
  }

  function fetchRatingsForSingleSeriesObj(seriesObj, doneCallback) {
    let releaseYear = seriesObj.metadata?.releaseYear;
    if (!releaseYear || releaseYear === 0) {
      const dateStr = seriesObj.booksMetadata?.releaseDate;
      if (dateStr) {
        const m = dateStr.match(/^(\d{4})/);
        if (m) {
          releaseYear = parseInt(m[1], 10);
        }
      }
    }
    let rawTitle = seriesObj.metadata?.title || seriesObj.name;
    rawTitle = rawTitle.replace(/\(\d{4}\)\s*$/, "");
    const finalTitle = rawTitle.trim();

    searchComicBookRoundup(finalTitle, releaseYear, bestUrl => {
      if (!bestUrl) {
        doneCallback();
        return;
      }
      fetchComicRatings(bestUrl, (criticRating, userRating, criticReviews, userReviews) => {
        addLinkToSeries(
          seriesObj.id,
          "Critic Rating",
          `${criticRating} (${criticReviews} review${criticReviews === 1 ? "" : "s"})`,
          bestUrl,
          "Critic Rating",
          () => {
            addLinkToSeries(
              seriesObj.id,
              "User Rating",
              `${userRating} (${userReviews} review${userReviews === 1 ? "" : "s"})`,
              bestUrl,
              "User Rating",
              () => {
                doneCallback();
              }
            );
          }
        );
      });
    });
  }

  /******************************************************
   * (C) fetchComicRatings + Searching + Matching
   ******************************************************/
  function fetchComicRatings(comicUrl, callback) {
    if (!comicUrl) {
      callback("N/A", "N/A", 0, 0);
      return;
    }
    GM_xmlhttpRequest({
      method: "GET",
      url: comicUrl,
      onload: (response) => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(response.responseText, "text/html");
        const criticEl = doc.querySelector(".review.green, .review.yellow, .review.red, .review.grey");
        const userEl   = doc.querySelector(".user-review.green, .user-review.yellow, .user-review.red, .user-review.grey");
        let criticRating = criticEl
          ? criticEl.textContent.replace("Critic Rating", "").trim()
          : "N/A";
        let userRating = userEl
          ? userEl.textContent.replace("User Rating", "").trim()
          : "N/A";
        let criticReviews = parseInt(
          doc.querySelector("span[itemprop='votes']")?.textContent.trim() || "0",
          10
        );
        let userReviews = 0;
        const userReviewsEl = [...doc.querySelectorAll("strong")]
          .find(e => e.textContent.includes("User Reviews:"));
        if (userReviewsEl) {
          userReviews = parseInt(userReviewsEl.nextSibling.textContent.trim() || "0", 10);
        }
        callback(criticRating, userRating, criticReviews, userReviews);
      }
    });
  }

  function searchComicBookRoundup(komgaTitle, komgaYear, callback) {
    const cleanedTitle = normalizeTitle(komgaTitle);
    console.log(`🔍 Searching ComicBookRoundup for: "${cleanedTitle}" (Year: ${komgaYear || "N/A"})`);
    GM_xmlhttpRequest({
      method: "GET",
      url: `${CBR_SEARCH_URL}${encodeURIComponent(cleanedTitle)}`,
      onload: (response) => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(response.responseText, "text/html");
        let anchors = doc.querySelectorAll("tr.search_results td.current a");
        if (!anchors.length) {
          console.log(`❌ No results found on ComicBookRoundup for: "${cleanedTitle}".`);
          callback(null);
          return;
        }
        anchors = Array.from(anchors).slice(0, 15);
        const rowTitles = anchors.map(a => ({
          text: a.textContent.trim(),
          href: a.getAttribute("href")
        }));
        const { bestMatch, bestScore } = findBestCbrMatch(komgaTitle, komgaYear, rowTitles);
        if (!bestMatch) {
          console.log(`⚠️ No strong match for "${komgaTitle}" (Year: ${komgaYear || "N/A"}); closest: "${rowTitles[0]?.text || "N/A"}" (Year: ${parseYearFromTitle(rowTitles[0]?.text) || "N/A"}, score: ${bestScore.toFixed(2)}). Skipping.`);
          callback(null);
        } else {
          const fullUrl = "https://comicbookroundup.com" + bestMatch.href;
          console.log(`✅ Best match: "${bestMatch.text}" (Score: ${bestScore.toFixed(2)}) 🔗 ${fullUrl}`);
          callback(fullUrl);
        }
      }
    });
  }

  function findBestCbrMatch(kTitle, kYear, rowArr) {
    let bestMatch = null;
    let bestScore = -999;
    rowArr.forEach(r => {
      const cYear = parseYearFromTitle(r.text);
      const score = computeMatchScore(kTitle, kYear, r.text, cYear);
      if (score > bestScore) {
        bestScore = score;
        bestMatch = { text: r.text, href: r.href, score };
      }
    });
    const THRESHOLD = 0.5;
    if (bestScore < THRESHOLD) {
      return { bestMatch: null, bestScore };
    }
    return { bestMatch, bestScore };
  }

  function computeMatchScore(kTitle, kYear, cTitle, cYear) {
    const normK = normalizeTitle(kTitle);
    const normC = normalizeTitle(cTitle);
    const queryTokens = normK.split(" ");
    const candidateTokens = normC.split(" ");
    const intersectionCount = queryTokens.filter(token => candidateTokens.includes(token)).length;
    const recall = intersectionCount / queryTokens.length;
    if (recall < 0.8) return -999;
    let textSim = jaccardSimilarity(normK, normC);
    if (textSim < 0.3) return -999;
    let score = textSim;
    if (normK === normC) {
      score += 1.0;
    } else if (normK.includes(normC) || normC.includes(normK)) {
      score += 0.2;
    }
    if (kYear) {
      if (cYear) {
        const diff = Math.abs(cYear - kYear);
        if (diff === 0) {
          if (textSim >= 0.5) score += 0.5;
        } else if (diff === 1) {
          if (textSim >= 0.5) score += 0.2;
        } else if (diff >= 10) {
          score -= diff * 0.15;
        } else {
          score -= diff * 0.1;
        }
      } else {
        let normKNoNumbers = normK.replace(/\b\d+\w*\b/g, "").trim();
        if (normKNoNumbers === normC) {
          score -= 0.1;
        } else {
          return -999;
        }
      }
    }
    return score;
  }

  function parseYearFromTitle(str) {
    const m = str.match(/\((\d{4})\)\s*$/);
    return m ? parseInt(m[1], 10) : null;
  }

  function normalizeTitle(str) {
    return str
      .toLowerCase()
      .replace(/\b(the|a|an)\b\s*/g, "")
      .replace(/\(\d{4}\)\s*$/, "")
      .replace(/\b(?:the\s+)?(new edition|deluxe edition|master edition|deluxe|anniversary edition|omnibus|compendium)\b/gi, "")
      .replace(/\bby\b.*$/i, "")
      .replace(/[':;#"!\?\(\)\[\]\.,]/g, " ")
      .replace(/(\s)-(\s)/g, "$1 $2")
      .replace(/\s+/g, " ")
      .trim();
  }

  function jaccardSimilarity(a, b) {
    const setA = new Set(a.split(" "));
    const setB = new Set(b.split(" "));
    const inter = [...setA].filter(x => setB.has(x)).length;
    const uni   = new Set([...setA, ...setB]).size;
    return uni ? inter / uni : 0;
  }

  /******************************************************
   * (D) Patch Komga
   ******************************************************/
  function addLinkToSeries(seriesId, label, linkLabel, linkUrl, labelCheck, callback = () => {}) {
    fetch(`${KOMGA_HOST}/api/v1/series/${seriesId}`, {
      headers: { Authorization: `Bearer ${KOMGA_API_KEY}` }
    })
    .then(r => r.json())
    .then(series => {
      let existingLinks = series.metadata.links || [];
      existingLinks = existingLinks.filter(link => !link.label.startsWith(labelCheck));
      const newLink = {
        label: `${label}: ${linkLabel}`,
        url: linkUrl
      };
      existingLinks.push(newLink);
      fetch(`${KOMGA_HOST}/api/v1/series/${seriesId}/metadata`, {
        method: "PATCH",
        headers: {
          Authorization: `Bearer ${KOMGA_API_KEY}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({ links: existingLinks })
      })
      .then(resp => {
        if (resp.ok) {
          callback();
        } else {
          console.error(`❌ Failed to update link: ${resp.status}`);
          callback("Failed");
        }
      })
      .catch(err => {
        console.error("❌ Error updating metadata:", err);
        callback("Error");
      });
    })
    .catch(err => {
      console.error("❌ Error fetching series from Komga:", err);
      callback("Error");
    });
  }

})();