tverplus

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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);