HiAnime Links+ (Plus on Poster)

Add direct links to MyAnimeList and AniList on HiAnime watch pages ((and its poster))

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         HiAnime Links+ (Plus on Poster)
// @namespace    https://greasyfork.org/users/1470715
// @author       cattishly6060
// @author       forked_bytes
// @description  Add direct links to MyAnimeList and AniList on HiAnime watch pages ((and its poster))
// @icon         https://icons.duckduckgo.com/ip3/hianime.to.ico
// @match        *://hianime.to/*
// @match        *://hianimez.is/*
// @match        *://hianimez.to/*
// @match        *://hianime.nz/*
// @match        *://hianime.bz/*
// @match        *://hianime.pe/*
// @grant        GM_openInTab
// @version      1.2
// @license      0BSD
// ==/UserScript==

(function () {
  'use strict';

  // Add images
  const malBase64Img = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAFKUExURQAAAC5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5Roi5RojVWpTxdqDlbpzJUpDVXpTdZppCjzv////r7/Vh0tXmPw2uEvYicysfR5vL0+bnE4LXC3uDl8ThZpkporvT1+oSZyPDz+DBTo6+829LZ66q42V55uHSMwZys06Sz1+js9bfD36Gx1Z2u1F96uPHz+X2Txf39/mB7uP7+/+/x+HOKwTBSo3aNwqOz1svT6PP1+k9ssc7W6fj6/Pf4+4qdy9rg76i32IGWx42gzIKXx73I4TRWpdfe7bTB3qy62kxqr97k8L3I4nWMwvj5/FRws83V6cXP5cHL46+93KKx1dDY6vX3++Po82uDvTpbp/v8/YWZycrT59Xc7LjE3+Hm8i9Soq6725qr0n6Uxktpr/z8/t3i8Ft3tqm32f4+yygAAAAPdFJOUwEVh9P50YNT8RfzVfv1z7hGe4QAAAABYktHRBcL1piPAAAAB3RJTUUH6QYZDiIGG4HDQAAAAZhJREFUSMft1ldXwjAUAOCwh+MyxLhQZIgDF4gL98a9F4pa9/r/r94mtVBPW5oHXzzehyT35H60TU9JCCHEZnc4oWY4XW4bYeHx1q7m4fWweqvlcqCwWf59dg0fcYvUA9iJSww4iIX10awVEasH+Ae/BQLBYDCkpDgMhiuzIUybACLYRSqgmVLa0sqyNhzSdsV2RDvltAsghl1MC2h3HJNEsgJSrBqjJ60LaC9AuI9+g3Q/VWNAH9AMDFIVDLHR8MhoNpvNGYCx/LgKJiaxn5ouzESj0VkwAGogmMNufoFPLeqCJV67zEFCvsAKmIHVNQbWOUjJ3UaxWNw0Blvb2O4AB7vVd2gAYC9J9+MKOLAC4PDoGBRwYgkA1leB06L5MygpB2fYnoP5KmnABbaXP0DpSo5rfZCR30xZC1jclPVBvITdrQ64Y7eUlyTpXgE4lHLAXsTDI596AniWlHgx+kRf3+j7h9A3/ZkNgBAomP0JiMWfAIJbll90U3SJb7s+0Y1d+OhASJ3f8hN7+HEGjz/1tasbGtnx5wvNirJSwodULQAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNS0wNi0yNVQxNDozNDowNiswMDowMNrDr1EAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjUtMDYtMjVUMTQ6MzQ6MDYrMDA6MDCrnhftAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI1LTA2LTI1VDE0OjM0OjA2KzAwOjAw/Is2MgAAAABJRU5ErkJggg==';

  const anilstBase64Img = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAClUExURRkhLQAFHAAAGAAQIBoTFhkUGBkdJtfY2unp6qKjpweO1AOd6wOY4xQ/WwoWJFZbYv///wBPgQCx/wCq/wmGyK2vs/7+/lyRugCt/wAAFfLz9LDe/QCm/2VpcLq8v13C/xQdKnF1esXHyW3H/iEpNMfp/oCEiS40P4DN/gAAAI+SlgAADo3R/kNIUNzy/pudoTq6/+v4/hoNCQCI0xREYxgnNxFReJBTZkgAAAABYktHRBCVsg0sAAAAB3RJTUUH6QYaAyIo3TryMgAAAXNJREFUWMPtlWFXgjAUhoGpzMyxQEmQdJZKpaVW9v9/WrB7B9bpoJdz/Lbn4865z+4L253jWCwWy5VxPQ1zOt2SHrne55q+ezMouB0SDSKQAL8Lo4Jw1KEJvLGMNfK+rI8mVEGSGsE0ayMQD1hf0EqQzCqBnLcQKBbXAp2BKBCLOkEct+ggedQCsMinjCpQS10rfRCsyAK2hgbWPvQwoQoSKJQ5ip4zmkDlkIC/5FJiBpKAvcLGfXeDrYQ0gUkQCPMxthlFoN6gcZ4ogWHeSQJ3B9vOlnnuYDP7iCDY8NNjCEdhSxAUo8SUSWnu9CG7XFCNkl8t7EcfPcO5BOl/gunn8MtwbFRUCXAm4oU6RJOK6NiYAEdJPQ9OxgoQfje1IFL8iU6uWcI/wdF4XmCGoQyY0iR9iRkuE5hRwj1cKM7l3wxNgnKUaMZGUJ4rzSq7RCAWqX7Q0kCYJbaDN44PDKOmb6jgSfVUvcRwqVvhWCwWy3X4Aap4O8LCxI3vAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI1LTA2LTI2VDAzOjM0OjQwKzAwOjAw9O3HTQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0wNi0yNlQwMzozNDo0MCswMDowMIWwf/EAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjUtMDYtMjZUMDM6MzQ6NDArMDA6MDDSpV4uAAAAAElFTkSuQmCC';

  // Variables
  const iconSize = 30;

  // Add minimal loading CSS
  const style = document.createElement('style');
  style.textContent = `
    .mal-link-loading .tick {
      opacity: 0.3;
    }
  `;
  document.head.appendChild(style);

  /** @typedef {{mal_id: ?number, anilist_id: ?number}} Link */
  /** @type {Map<string, ?Link>} */
  const cachedLinkMap = new Map();

  /** @type {Object<string, string>} */
  const linkType = {
    MAL: 'mal',
    ANILIST: 'anilist',
  };

  function addHiAnimeBtnAndSubTitle() {
    Array.from(document.querySelectorAll("div.flw-item"))?.filter(e => e.querySelector('div.film-detail') && e.querySelector('div.film-poster') || []).forEach(e => {
      if (e.querySelector('.custom-jp-title')) return;

      const title = e.querySelector('.film-name > a')?.textContent || "";
      const titleJp = (e.querySelector('.film-name > a')?.getAttribute('data-jname') || title || "").replace('[Uncensored]', '').trim();
      const query = encodeURIComponent(titleJp.slice(0, 100));

      const uriMAL = `https://myanimelist.net/search/all?q=${query}&cat=all#anime`;
      const uriAnilist = `https://anilist.co/search/anime?search=${query}`;
      const endpoint = e.querySelector('a.film-poster-ahref')?.href;

      const topPad = e.querySelector('.tick-rate') ? 35 : 10;
      const imgSize = iconSize;

      const aHiAnime = createLink(uriMAL, titleJp, endpoint, linkType.MAL);
      aHiAnime.innerHTML = `<img class="tick" width="${imgSize}" height="${imgSize}" style="position: absolute; left: 10px; top: ${topPad}px;" src="${malBase64Img}">`;
      e.querySelector('div.film-poster').appendChild(aHiAnime);

      const aAnilist = createLink(uriAnilist, titleJp, endpoint, linkType.ANILIST);
      aAnilist.innerHTML = `<img class="tick" width="${imgSize}" height="${imgSize}" style="position: absolute; left: 10px; top: ${topPad + imgSize + 3}px;" src="${anilstBase64Img}">`;
      e.querySelector('div.film-poster').appendChild(aAnilist);

      const jpTitleElement = `<h3 class="film-name custom-jp-title" style="font-size: 12px; color: gray;">${titleJp}</h3>`;
      e.querySelector('.film-detail')?.children?.[0]?.insertAdjacentHTML('afterend', jpTitleElement);

      [aHiAnime, aAnilist].forEach(e => addLinkClickListener(e));
    });
  }

  function addLinkClickListener(anchorElement) {
    try {
      if (anchorElement.nodeName !== 'A') {
        return;
      }
      anchorElement.addEventListener('click', async (e) => {
        e.preventDefault(); // Prevent default behavior

        // Get configuration from data attributes
        const endpoint = anchorElement.dataset.endpoint;
        const cacheLinkKey = endpoint
          ? new URL(endpoint).pathname.split('/').filter(Boolean)?.at(-1)
          : null;
        const fallbackUrl = anchorElement.dataset.fallbackUrl;
        const type = anchorElement.dataset.type;

        // Get cached finalUrl
        const cachedFinalUrl = anchorElement.dataset.finalUrl;
        if (cachedFinalUrl) {
          GM_openInTab(cachedFinalUrl, {active: true});
          return;
        }

        // Get cached link
        /** @type {?Link} */
        const cachedLink = cachedLinkMap.get(cacheLinkKey);
        if (cachedLink) {
          let finalUrl;
          if (type === linkType.MAL && cachedLink.mal_id) {
            finalUrl = `https://myanimelist.net/anime/${cachedLink.mal_id}`;
          } else if (type === linkType.ANILIST && cachedLink.anilist_id) {
            finalUrl = `https://anilist.co/anime/${cachedLink.anilist_id}`;
          } else {
            finalUrl = fallbackUrl;
          }
          GM_openInTab(finalUrl || fallbackUrl, {active: true});
          return;
        }

        // Show loading state
        anchorElement.classList.add('mal-link-loading');
        anchorElement.style.pointerEvents = 'none';

        try {
          // Fetch required data
          const res = await fetch(endpoint);
          if (!res?.ok) {
            GM_openInTab(fallbackUrl, {active: true});
            return;
          }
          const data = await res.text();
          const malId = data.match(/"mal_id":"(\d+)",/)?.[1];
          const anilistId = data.match(/"anilist_id":"(\d+)",/)?.[1];
          if (malId || anilistId) {
            cachedLinkMap.set(cacheLinkKey, {
              mal_id: malId,
              anilist_id: anilistId
            });
          }

          if ((!malId && type === linkType.MAL)
            || (!anilistId && type === linkType.ANILIST)
            || (!anilistId && !malId)
            || !type) {
            GM_openInTab(fallbackUrl, {active: true});
            return;
          }

          // Open the link
          const finalUrl = type === linkType.MAL
            ? `https://myanimelist.net/anime/${malId}`
            : `https://anilist.co/anime/${anilistId}`;

          anchorElement.dataset.finalUrl = finalUrl;
          GM_openInTab(finalUrl, {active: true});

        } catch (error) {
          console.error('Failed to process link:', error);
        } finally {
          // Clean up (loading)
          anchorElement.classList.remove('mal-link-loading');
          anchorElement.style.pointerEvents = '';
        }
      });
    } catch (err) {
      console.error(`|| ERROR (.addLinkClickListener):`, err);
    }
  }

  /**
   * **********************
   * For anime poster
   * **********************
   */

  if (window.location.pathname === '/home') {
    /** @type {?Timeout} */
    let timeout;

    /** @type {?MutationObserver} */
    const observer = waitForElement('#widget-continue-watching .film_list-wrap', (_) => {
      addHiAnimeBtnAndSubTitle();
      clearTimeout(timeout);
    }, {
      rootElement: document.querySelector('#widget-continue-watching'),
    });

    // Remove observer after 5s
    timeout = setTimeout(() => {
      observer?.disconnect();
    }, 5_000);
  }

  addHiAnimeBtnAndSubTitle();

  /**
   * **********************
   * For tooltip
   * **********************
   */

  const qTipObs = new MutationObserver((mutations, _) => {
    for (const mutation of mutations) {
      if (mutation.addedNodes.length && mutation.removedNodes.length) {
        const el = Array.from(mutation.addedNodes)
          .find(e => e.className === 'pre-qtip-content');
        if (el) {
          const title = el.querySelector('.pre-qtip-title')?.textContent || "";
          const titleJp = Array.from(el.querySelectorAll('.pre-qtip-line'))
              .find(e => e.innerText.trim().startsWith('Japanese:'))
              ?.innerText
              ?.split(':')
              ?.[1]
              ?.trim()
            || title;

          const query = encodeURIComponent(titleJp.slice(0, 100));
          const uriMAL = `https://myanimelist.net/search/all?q=${query}&cat=all#anime`;
          const uriAnilist = `https://anilist.co/search/anime?search=${query}`;
          const endpoint = el.querySelector('.pre-qtip-button a')?.href;

          const preQtipDetail = document.createElement('div');
          preQtipDetail.className = 'pre-qtip-detail';
          preQtipDetail.style.display = 'flex';
          preQtipDetail.style.gap = '5px';

          const imgSize = iconSize;

          const aHiAnime = createLink(uriMAL, titleJp, endpoint, linkType.MAL);
          aHiAnime.innerHTML = `<img class="tick" width="${imgSize}" height="${imgSize}" src="${malBase64Img}">`;
          preQtipDetail.appendChild(aHiAnime);

          const aAnilist = createLink(uriAnilist, titleJp, endpoint, linkType.ANILIST);
          aAnilist.innerHTML = `<img class="tick" width="${imgSize}" height="${imgSize}" src="${anilstBase64Img}">`;
          preQtipDetail.appendChild(aAnilist);

          [aHiAnime, aAnilist].forEach(e => addLinkClickListener(e));
          el.querySelector('.pre-qtip-detail')?.insertAdjacentElement('afterend', preQtipDetail);
        }
      }
    }
  });

  document.querySelectorAll('.qtip-content').forEach((el) => {
    qTipObs.observe(el, {
      childList: true,
      subtree: true,
    });
  });

  /**
   * **********************
   * For anime pages
   * **********************
   */

  const syncData = JSON.parse(document.getElementById('syncData')?.textContent || null);
  if (!syncData) return;

  const title = document.getElementsByClassName('film-name')[0];
  if (!title) return;

  if (window.location.pathname.startsWith('/watch/')) {
    const pcRight = document.querySelector('.player-controls .pc-right');
    if (!pcRight) return;

    if (syncData.mal_id) {
      const a = createLink(`https://myanimelist.net/anime/${syncData.mal_id}`, 'MyAnimeList');
      a.style.float = 'left';
      a.style.padding = '6px';
      a.innerHTML = `<img width="25" height="25" src="${malBase64Img}">`;
      pcRight.insertBefore(a, pcRight.firstChild)
    }

    if (syncData.anilist_id) {
      const a = createLink(`https://anilist.co/anime/${syncData.anilist_id}`, 'AniList');
      a.style.float = 'left';
      a.style.padding = '6px';
      a.innerHTML = `<img width="25" height="25" src="${anilstBase64Img}">`;
      pcRight.insertBefore(a, pcRight.firstChild)
    }
  } else {
    if (syncData.mal_id) {
      const a = createLink(`https://myanimelist.net/anime/${syncData.mal_id}`, 'MyAnimeList');
      a.innerHTML = ` <img width="25" height="25" src="${malBase64Img}">`;
      title.appendChild(a);
    }

    if (syncData.anilist_id) {
      const a = createLink(`https://anilist.co/anime/${syncData.anilist_id}`, 'AniList');
      a.innerHTML = ` <img width="25" height="25" src="${anilstBase64Img}">`;
      title.appendChild(a);
    }
  }

  /**
   * **********************
   * Utility
   * **********************
   */

  /**
   * @param {string} href
   * @param {string} title
   * @param {string} [endpoint=null]
   * @param {string} [type=null]
   * @returns {HTMLAnchorElement}
   */
  function createLink(href, title, endpoint = null, type = null) {
    const a = document.createElement('a');
    a.target = '_blank';
    a.rel = 'noreferrer,noopener';
    a.href = href;
    a.title = title;
    if (endpoint) {
      a.href = "#";
      a.dataset.endpoint = endpoint;
      a.dataset.fallbackUrl = href;
      a.dataset.type = type;
    }
    return a;
  }

  /**
   * @param {string} selector
   * @param {function(HTMLElement): void} callback
   * @param {{rootElement: ?HTMLElement}} [options={}]
   * @returns {?MutationObserver}
   */
  function waitForElement(selector, callback, options = {}) {
    // Default to document.body if no root element is specified
    const rootElement = options.rootElement || document.body;
    delete options.rootElement; // Remove our custom option before passing to MutationObserver

    // First try immediately (element might already exist)
    const element = document.querySelector(selector);
    if (element) {
      callback(element);
      return;
    }

    const observer = new MutationObserver((mutations, obs) => {
      // Only query when nodes are added
      for (const mutation of mutations) {
        if (mutation.addedNodes.length) {
          const el = document.querySelector(selector);
          if (el) {
            obs.disconnect();
            callback(el);
            return;
          }
        }
      }
    });

    observer.observe(rootElement, {
      childList: true,
      subtree: true,
      ...options
    });

    // Return the observer so caller can disconnect if needed
    return observer;
  }

})();