HiAnime Watchlist Exporter (exports a json file) metadata slow

Exports your entire HiAnime.to watchlist. All categories with rich metadata pulled from each anime’s page.

// ==UserScript==
// @name         HiAnime Watchlist Exporter (exports a json file) metadata slow
// @author       ScriptKiddyMonkey
// @license      MIT
// @version      1
// @description  Exports your entire HiAnime.to watchlist. All categories with rich metadata pulled from each anime’s page.
// @match        https://hianime.to/user/watch-list*
// @grant        none
// @namespace    ScriptKiddyMonkey
// ==/UserScript==

(function () {
  'use strict';

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  const waitForSelector = async (selector) => {
    while (!document.querySelector(selector)) await sleep(200);
    return document.querySelector(selector);
  };

  const getCategoryFromElement = (el) => {
    const added = el.querySelector('.wl-item.added');
    if (!added) return 'Unknown';
    const map = {
      '1': 'Watching',
      '2': 'On-Hold',
      '3': 'Plan to Watch',
      '4': 'Dropped',
      '5': 'Completed',
    };
    return map[added.getAttribute('data-type')] || 'Unknown';
  };

  const getMetadataValue = (doc, label) => {
    const rows = doc.querySelectorAll('.anisc-info .item');
    for (let row of rows) {
      const title = row.querySelector('.item-head');
      if (title && title.textContent.trim().startsWith(label)) {
        const links = row.querySelectorAll('a');
        if (links.length > 0) {
          return Array.from(links).map((a) => a.textContent.trim());
        } else {
          const name = row.querySelector('.name');
          if (name) return name.textContent.trim();
        }
      }
    }
    return null;
  };

  const scrapeAnimeDetails = async (originalUrl) => {
    try {
      const url = originalUrl.replace('/watch/', '/');
      const res = await fetch(url);
      const html = await res.text();
      const doc = new DOMParser().parseFromString(html, 'text/html');

      const get = (selector) => {
        const el = doc.querySelector(selector);
        return el ? el.textContent.trim() : null;
      };

      const tick = {
        ageRating: null,
        subbedEpisodes: null,
        dubbedEpisodes: null,
        totalEpisodes: null,
        mediaType: null,
        duration: null,
      };

      const tickContainers = doc.querySelectorAll('.tick');

      tickContainers.forEach((container) => {
        container.querySelectorAll('.tick-item')?.forEach((item) => {
          const text = item.textContent.trim();
          if (item.classList.contains('tick-pg')) {
            tick.ageRating = text;
          } else if (item.classList.contains('tick-sub')) {
            tick.subbedEpisodes = parseInt(text) || null;
          } else if (item.classList.contains('tick-dub')) {
            tick.dubbedEpisodes = parseInt(text) || null;
          } else if (item.classList.contains('tick-eps')) {
            tick.totalEpisodes = parseInt(text) || null;
          }
        });

        container.querySelectorAll('span.item')?.forEach((span) => {
          const text = span.textContent.trim();
          if (!tick.mediaType && /^[A-Za-z\s]+$/.test(text)) {
            tick.mediaType = text;
          } else if (!tick.duration && /\d+m/.test(text)) {
            tick.duration = text;
          } else if (!tick.duration && tick.mediaType) {
            tick.duration = text;
          }
        });
      });

      return {
        description: get('.film-description .text') || get('.item .text'),
        genres: getMetadataValue(doc, 'Genres'),
        synonyms: getMetadataValue(doc, 'Synonyms') || [],
        japaneseTitle: getMetadataValue(doc, 'Japanese'),
        aired: getMetadataValue(doc, 'Aired'),
        premiered: getMetadataValue(doc, 'Premiered'),
        status: getMetadataValue(doc, 'Status'),
        score: getMetadataValue(doc, 'MAL Score'),
        studios: getMetadataValue(doc, 'Studios'),
        producers: getMetadataValue(doc, 'Producers'),
        duration: tick.duration,
        mediaType: tick.mediaType,
        totalEpisodes: tick.totalEpisodes,
        subbedEpisodes: tick.subbedEpisodes,
        dubbedEpisodes: tick.dubbedEpisodes,
        ageRating: tick.ageRating,
      };
    } catch (err) {
      console.error(`Failed to scrape details for ${originalUrl}`, err);
      return {};
    }
  };

  const scrapeEntireWatchlist = async () => {
    let page = 1;
    let animes = [];

    while (true) {
      const url = `${location.origin}${location.pathname}?page=${page}`;
      console.log(`Scraping page ${page}: ${url}`);
      const res = await fetch(url);
      const html = await res.text();
      const doc = new DOMParser().parseFromString(html, 'text/html');

      const cards = doc.querySelectorAll('.film_list-wrap .flw-item');
      if (!cards.length) break;

      for (const card of cards) {
        const a = card.querySelector('a.film-poster-ahref');
        const href = a?.href;
        const title = a?.getAttribute('oldtitle') || a?.title;
        const id = href?.split('/').pop();
        const img = card.querySelector('img.film-poster-img');
        const poster = img?.getAttribute('data-src') || img?.src || null;
        const category = getCategoryFromElement(card);

        animes.push({ id, title, url: href, category, poster });
      }

      page++;
      await sleep(1000);
    }

    return animes;
  };

  const scrapeDetailsForAll = async (list, btn) => {
    const updatedList = [];
    for (let i = 0; i < list.length; i++) {
      const entry = list[i];
      btn.textContent = `Scraping details... (${i + 1}/${list.length})`;
      const meta = await scrapeAnimeDetails(entry.url);
      updatedList.push({ ...entry, ...meta });
      await sleep(500);
    }
    return updatedList;
  };

  const exportWatchlist = async (btn) => {
    btn.disabled = true;
    btn.textContent = 'Gathering watchlist...';

    try {
      const baseList = await scrapeEntireWatchlist();
      const fullList = await scrapeDetailsForAll(baseList, btn);

      const blob = new Blob([JSON.stringify(fullList, null, 2)], {
        type: 'application/json',
      });

      const username = location.pathname.split('/')[2] || 'user';
      const date = new Date().toISOString().split('T')[0];
      const filename = `HiAnime_Watchlist_${date}.json`;

      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);

      btn.textContent = 'Export Complete!';
    } catch (e) {
      console.error('Export failed:', e);
      btn.textContent = 'Export Failed. Check console.';
    }
  };

  const addExportButton = () => {
    const btn = document.createElement('button');
    btn.textContent = 'Export Full Watchlist';
    btn.style =
      'position:fixed;bottom:20px;right:20px;padding:10px 20px;background:#201f2d;color:#fff;border:none;border-radius:8px;z-index:99999;font-weight:bold;box-shadow:0 4px 12px rgba(0,0,0,0.3);cursor:pointer';

    btn.onclick = () => exportWatchlist(btn);
    document.body.appendChild(btn);
  };

  waitForSelector('.film_list-wrap').then(addExportButton);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址