Greasy Fork

Greasy Fork is available in English.

HiAnime Links+ (Plus on Poster)

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

// ==UserScript==
// @name         HiAnime Links+ (Plus on Poster)
// @namespace    http://greasyfork.icu/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;
  }

})();