tverplus

Adds Filmarks and MyDramaList ratings with links to their respective pages directly on TVer series pages. 1-1 matching is not guaranteed.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         tverplus
// @namespace    tver
// @description  Adds Filmarks and MyDramaList ratings with links to their respective pages directly on TVer series pages. 1-1 matching is not guaranteed.
// @author       e0406370
// @match        https://tver.jp/*
// @version      2026-02-18
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        window.onurlchange
// @noframes
// @license MIT
// ==/UserScript==

const SERIES_CONTENT_CLASS = "Series_info";
const SERIES_TITLE_CLASS = "Series_title";

const ASSETS_BASE_URL = "https://raw.githubusercontent.com/e0406370/tverplus/refs/heads/assets/";
const SPINNER_LIGHT_MODE = `${ASSETS_BASE_URL}spinner_light_mode.svg`;
const SPINNER_DARK_MODE = `${ASSETS_BASE_URL}spinner_dark_mode.svg`;
const FM_FAVICON_URL = `${ASSETS_BASE_URL}favicon_fm.png`;
const MDL_FAVICON_URL = `${ASSETS_BASE_URL}favicon_mdl.png`;

const TVER_SERIES_URL = "https://tver.jp/series/";
const TVER_EXCLUDED_COUNTRIES = ["中国", "韓国", "韓流"];
const FM_API_BASE_URLS = ["https://markuapi.vercel.app", "https://markuapi.apn.leapcell.app"];
const FM_COUNTRY_TYPE = "日本";
const MDL_API_BASE_URLS = ["https://kuryana-kappa.vercel.app", "https://kuryana.tbdh.app"];
const MDL_DRAMA_TYPES = ["Japanese Drama", "Japanese TV Show"];

const retrieveSelectorClassStartsWith = (className) => `[class^=${className}]`;
const retrieveSeriesIDFromSeriesURL = (url) => (url.match(/sr[a-z0-9]{8,9}/) || [])[0] || null;
const isTimestampExpired = (timestamp) => timestamp < Date.now() - 24 * 60 * 60 * 10 ** 3;
const isEmptyObject = (obj) => Object.keys(obj).length === 0;

const getFMSearchDramasEndpoint = (url, query) => `${url}/search/dramas?q=${query}`;
const getMDLSearchDramasEndpoint = (url, query) => `${url}/search/q/${query}`;
const getMDLGetDramaInfoEndpoint = (url, slug) => `${url}/id/${slug}`;
const normaliseTitle = (query) => query.normalize("NFKC").replace(/[-‐–—−―]/g, " ").replace(/[~~〜⁓∼˜˷﹏﹋]/g, " ").replace(/[\//∕⁄]/g, " ").replace(/[()()]/g, " ").replace(/\s+/g, "").trim();

let seriesData = {
  fm: {},
  mdl: {},
};
let seriesElements = {
  fm: {},
  mdl: {},
};
let seriesID;
let previousTitle;

function waitForTitle() {
  const contentSelector = retrieveSelectorClassStartsWith(SERIES_CONTENT_CLASS);
  const titleSelector = retrieveSelectorClassStartsWith(SERIES_TITLE_CLASS);

  return new Promise((res) => {
    const isTitleReady = () => {
      const contentElement = document.querySelector(contentSelector);
      const titleElement = document.querySelector(titleSelector);
      return contentElement && titleElement && titleElement.textContent !== previousTitle;
    };

    if (isTitleReady()) {
      previousTitle = document.querySelector(titleSelector).textContent;
      res(previousTitle);
      return;
    }

    const observer = new MutationObserver(() => {
      if (isTitleReady()) {
        previousTitle = document.querySelector(titleSelector).textContent;
        observer.disconnect();
        res(previousTitle);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  });
}

async function retrieveSeriesDataFM(title) {
  const seriesDataFM = {
    rating: "-",
    link: null,
    timestamp: Date.now(),
  };

  let toBreak = false;
  let urlPtr = 0;

  while (!toBreak && urlPtr < FM_API_BASE_URLS.length) {
    try {
      if (TVER_EXCLUDED_COUNTRIES.some(c => title.includes(c))) {
        toBreak = true;
        throw new Error(`[FM] Title contains excluded country, skipping ${title}`);
      }

      const url = getFMSearchDramasEndpoint(FM_API_BASE_URLS[urlPtr], title);
      const res = await Promise.race([
        fetch(url),
        new Promise((_, reject) => setTimeout(() => reject(new Error('[FM] Search API request timeout')), 10000))
      ]);

      if (!res.ok) {
        throw new Error(`[FM] Search API error ${res.status} for ${title}`);
      }

      const data = await res.json();

      if (data.results.dramas.length === 0) {
        toBreak = true;
        throw new Error(`[FM] No results for ${title}`);
      }

      for (const [idx, drama] of data.results.dramas.entries()) {
        if (idx === 5) break;

        const titleSearch = normaliseTitle(title);
        const titleFM = normaliseTitle(drama.title);

        if (drama.country_of_origin.includes(FM_COUNTRY_TYPE) && (titleSearch.includes(titleFM) || titleFM.includes(titleSearch))) {
          console.info(`[FM] ${drama.title} | ${drama.rating}`);

          seriesDataFM.rating = drama.rating;
          seriesDataFM.link = drama.link;
          await GM.setValue(`${seriesID}-fm`, JSON.stringify(seriesDataFM));
          return seriesDataFM;
        }
      }

      toBreak = true;
      throw new Error(`[FM] No 1-1 match found for ${title}`);
    }
    catch (err) {
      console.error(err);
      urlPtr++;
    }
  }

  return seriesDataFM;
}

async function retrieveSeriesDataMDL(title) {
  const seriesDataMDL = {
    rating: "N/A",
    link: null,
    timestamp: Date.now(),
  };

  let toBreak = false;
  let urlPtr = 0;

  while (!toBreak && urlPtr < MDL_API_BASE_URLS.length) {
    try {
      if (TVER_EXCLUDED_COUNTRIES.some(c => title.includes(c))) {
        toBreak = true;
        throw new Error(`[MDL] Title contains excluded country, skipping ${title}`);
      }

      const searchUrl = getMDLSearchDramasEndpoint(MDL_API_BASE_URLS[urlPtr], normaliseTitle(title));
      const searchRes = await Promise.race([
        fetch(searchUrl),
        new Promise((_, reject) => setTimeout(() => reject(new Error('[MDL] Search API request timeout')), 10000))
      ]);

      if (!searchRes.ok) {
        throw new Error(`[MDL] Search API error ${searchRes.status} for ${title}`);
      }

      const searchData = await searchRes.json();

      if (searchData.results.dramas.length === 0) {
        toBreak = true;
        throw new Error(`[MDL] No results for ${title}`);
      }

      let slug = null;

      for (const [idx, drama] of searchData.results.dramas.entries()) {
        if (idx === 5) break;

        if (MDL_DRAMA_TYPES.includes(drama.type)) {
          console.info(`[MDL] ${drama.title} | ${drama.year}`);

          slug = drama.slug;
          break;
        }
      }

      if (slug === null) {
        toBreak = true;
        throw new Error(`[MDL] No 1-1 match found for ${title}`);
      }

      const infoUrl = getMDLGetDramaInfoEndpoint(MDL_API_BASE_URLS[urlPtr], slug);
      const infoRes = await Promise.race([
        fetch(infoUrl),
        new Promise((_, reject) => setTimeout(() => reject(new Error('[MDL] Info API request timeout')), 10000))
      ]);

      if (!infoRes.ok) {
        throw new Error(`[MDL] Info API error ${infoRes.status} for ${title}`);
      }

      const infoData = await infoRes.json();

      seriesDataMDL.rating = infoData.data.rating;
      seriesDataMDL.link = infoData.data.link;
      await GM.setValue(`${seriesID}-mdl`, JSON.stringify(seriesDataMDL));
      return seriesDataMDL;
    }
    catch (err) {
      console.error(err);
      urlPtr++;
    }
  }

  return seriesDataMDL;
}

function initSeriesElements() {
  for (let type of ["fm", "mdl"]) {
    let element = seriesElements[type];

    if (isEmptyObject(element)) {
      const dataContainer = document.createElement("div");
      const linkWrapper = document.createElement("a");
      const faviconLabel = document.createElement("img");
      const ratingLabel = document.createElement("span");
      const colorMode = document.querySelector("html").getAttribute("class");
      const loadingSpinner = document.createElement("img");

      dataContainer.appendChild(linkWrapper);
      linkWrapper.appendChild(faviconLabel);
      linkWrapper.appendChild(ratingLabel);

      element = {
        dataContainer: dataContainer,
        linkWrapper: linkWrapper,
        faviconLabel: faviconLabel,
        ratingLabel: ratingLabel,
        colorMode: colorMode,
        loadingSpinner: loadingSpinner,
      };

      seriesElements[type] = element;

      element.loadingSpinner.setAttribute("src", element.colorMode === "light" ? SPINNER_LIGHT_MODE : SPINNER_DARK_MODE);

      element.linkWrapper.style.color = element.colorMode === "light" ? "#000000" : "#ffffff";
      element.linkWrapper.style.display = "inline-flex";
      element.linkWrapper.style.alignItems = "center";
      element.linkWrapper.style.gap = "4px";
      element.linkWrapper.style.marginTop = "5%";

      element.faviconLabel.setAttribute("src", type === "fm" ? FM_FAVICON_URL : MDL_FAVICON_URL);
      element.faviconLabel.setAttribute("width", "24");
      element.faviconLabel.setAttribute("height", "24");
    }

    element.ratingLabel.textContent = "";
    element.ratingLabel.appendChild(element.loadingSpinner);

    element.linkWrapper.removeAttribute("href");
    element.linkWrapper.removeAttribute("target");
    element.linkWrapper.removeAttribute("rel");

    const contentContainer = document.querySelector(retrieveSelectorClassStartsWith(SERIES_CONTENT_CLASS));
    contentContainer.appendChild(element.dataContainer);
  }
}

function includeSeriesData(type) {
  const element = seriesElements[type];
  const data = seriesData[type];

  element.ratingLabel.removeChild(element.loadingSpinner);

  if (data.link) {
    element.linkWrapper.setAttribute("href", data.link);
    element.linkWrapper.setAttribute("target", "_blank");
    element.linkWrapper.setAttribute("rel", "noopener noreferrer");
  }

  element.ratingLabel.textContent
    = data.rating === (type === "fm" ? "-" : "N/A")
    ? data.link ? "N/A (✓)" : "N/A (✗)"
    : Number.parseFloat(seriesData[type].rating).toFixed(1);
}

function resetSeriesData() {
  seriesData.fm = {};
  seriesData.mdl = {};
  seriesElements.fm = {};
  seriesElements.mdl = {};
  seriesID = null;
  previousTitle = null;
}

function runScript() {
  waitForTitle()
    .then(async (title) => {
      initSeriesElements();

      const promiseFM = (async () => {
        const cachedFM = await GM.getValue(`${seriesID}-fm`);
        const parsedFM = cachedFM && JSON.parse(cachedFM);
        seriesData.fm
          = cachedFM && !isTimestampExpired(parsedFM.timestamp)
          ? parsedFM
          : await retrieveSeriesDataFM(title);

        includeSeriesData("fm");
      })().then(() => console.info(`[FM] Series data added for ${title}: ${JSON.stringify(seriesData.fm)}`));

      const promiseMDL = (async () => {
        const cachedMDL = await GM.getValue(`${seriesID}-mdl`);
        const parsedMDL = cachedMDL && JSON.parse(cachedMDL);
        seriesData.mdl
          = cachedMDL && !isTimestampExpired(parsedMDL.timestamp)
          ? parsedMDL
          : await retrieveSeriesDataMDL(title);

        includeSeriesData("mdl");
      })().then(() => console.info(`[MDL] Series data added for ${title}: ${JSON.stringify(seriesData.mdl)}`));
    });
}

function matchScript({ url }) {
  if (url.startsWith(TVER_SERIES_URL)) {
    seriesID = retrieveSeriesIDFromSeriesURL(location.href);

    if (seriesID) {
      runScript();
    }
    else {
      console.warn("[ERROR] Invalid series ID");
      resetSeriesData();
    }
  }
  else {
    resetSeriesData();
  }
}

matchScript({ url: location.href });

window.addEventListener("urlchange", matchScript);