Greasy Fork

Zibzab's GameDox/Rom Upload Helper

try to take over the world :)

目前为 2022-09-03 提交的版本。查看 最新版本

// ==UserScript==
// @name         Zibzab's GameDox/Rom Upload Helper
// @namespace    http://tampermonkey.net/
// @version      1.7
// @description  try to take over the world :)
// @author       BestGrapeLeaves
// @match        https://gazellegames.net/upload.php?groupid=*
// @match        https://gazellegames.net/torrents.php?id=*
// @icon         https://i.imgur.com/UFOk0Iu.png
// @grant        GM_xmlhttpRequest
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      datomatic.no-intro.org
// @license      MIT
// ==/UserScript==

// cache class based on GM_setValue, GM_getValue, GM_deleteValue, GM_listValues global functions. With expiration.
class GMCache {
  constructor(name) {
    this.cache = {};
    this.name = name;
  }

  getKeyName(key) {
    return `zibzabhelper.cache${this.name}.${key}`;
  }

  get(key, fallback) {
    const whenNotFound = () =>
      typeof fallback === "function" ? fallback() : fallback;

    const res = GM_getValue(this.getKeyName(key));
    if (res === undefined) {
      return whenNotFound();
    }
    const { value, expires } = res;
    if (expires && expires < Date.now()) {
      this.delete(key);
      return whenNotFound();
    }

    return value;
  }

  set(key, value, ttl) {
    const expires = Date.now() + ttl;
    GM_setValue(this.getKeyName(key), { value, expires });
  }

  delete(key) {
    GM_deleteValue(this.getKeyName(key));
  }

  cleanUp() {
    const keys = GM_listValues();
    keys.forEach((key) => {
      if (key.startsWith(this.getKeyName(""))) {
        const { expires } = GM_getValue(key);
        if (expires < Date.now()) {
          GM_deleteValue(key);
        }
      }
    });
  }
}

// Code is a spaghetti mess, don't read it. Do something else with your time.
(function () {
  "use strict";
  const noIntroCache = new GMCache("no-intro");

  const PARENS_TAGS_REGEX = /\(.*?\)/g;
  const NO_INTRO_TAGS_REGEX =
    /\((Unl|Proto|Sample|Aftermarket|Homebrew)\)|\(Rev \d+\)|\(v[\d\.]+\)|\(Beta(?: \d+)?\)/;
  const GAME_DOX_INSERT = `[align=center] pdf pages
[/align]
`;
  const genRomInsert = (
    url = "xxx",
    filename = "xxx"
  ) => `[align=center]${filename} matches [url=${url}]No-Intro checksum[/url]
Compressed with [url=https://sourceforge.net/projects/trrntzip/]torrentzip.[/url][/align]
`;

  const regionToLanguage = {
    USA: "English",
    Europe: "English",
    Japan: "Japanese",
    World: "English",
    "USA, Europe": "English",
    Other: "English",
    Korea: "Korean",
    Taiwan: "Chinese",
  };

  const twoLetterLanguageCodeToGGn = {
    en: "English",
    de: "German",
    fr: "French",
    cz: "Czech",
    zh: "Chinese",
    it: "Italian",
    ja: "Japanese",
    ko: "Korean",
    pl: "Polish",
    pt: "Portuguese",
    ru: "Russian",
    es: "Spanish",
  };

  const parseLanguage = (region, possiblyLanguages) => {
    if (possiblyLanguages === undefined) {
      return regionToLanguage[region] || "Other";
    }

    const twoLetterCodes = possiblyLanguages
      .split(",")
      .map((l) => l.trim().toLowerCase());
    const isLanguages = twoLetterCodes.every((l) => l.length === 2);
    if (!isLanguages || twoLetterCodes.length === 0) {
      return regionToLanguage[region] || "Other";
    }

    if (twoLetterCodes.length > 1) {
      return "Multi-Language";
    }

    return twoLetterLanguageCodeToGGn[twoLetterCodes[0]] || "Other";
  };

  function noIntroLinkForTorrentId(torrentId) {
    const links = $(`#torrent_${torrentId} #description a`);
    return links
      .map(function () {
        return $(this).attr("href");
      })
      .get()
      .map((link) => {
        const url = new URL(link);
        url.protocol = "https:"; // Rarely descriptions have the http protocol
        return url.toString();
      })
      .find(link => link.startsWith("https://datomatic.no-intro.org/"))
  }

  function fetchNoIntro(url) {
    return new Promise((resolve, reject) => {
      const cached = noIntroCache.get(url);
      if (cached) {
        console.log("Using cached no-intro data", url, cached);
        resolve({ ...cached, cached: true });
        return;
      }

      GM_xmlhttpRequest({
        method: "GET",
        url,
        timeout: 5000,
        onload: ({ responseText }) => {
          try {
            const parser = new DOMParser();
            const scraped = parser.parseFromString(responseText, "text/html");

            // HTML is great
            const dumpsTitle = [
              ...scraped.querySelectorAll("td.TableTitle"),
            ].find((td) => td.innerText.trim() === "Dump(s)");

            if (!dumpsTitle) {
              window.GMPARSER = scraped;
              console.err('zibzab dumps title not found, set parser as global: GMPARSER', responseText);
              new Error("No dump's title found");
            }

            const filename =
              dumpsTitle.parentElement.parentElement.parentElement.nextElementSibling
                .querySelector(
                  "table > tbody > tr:nth-child(2) > td:last-child"
                )
                .innerText.trim();
            const title = scraped
              .querySelector("tr.romname_section > td")
              .innerText.trim();
            const parenMatches = title
              .match(/\(.+?\)/g)
              .map((p) => p.slice(1, -1));
            const [region, possiblyLanguages] = parenMatches;
            const matchedGGnRegion =
              [
                "USA",
                "Europe",
                "Japan",
                "Asia",
                "Australia",
                "France",
                "Germany",
                "Spain",
                "Italy",
                "UK",
                "Netherlands",
                "Sweden",
                "Russia",
                "China",
                "Korea",
                "Hong Kong",
                "Taiwan",
                "Brazil",
                "Canada",
                "Japan, USA",
                "Japan, Europe",
                "USA, Europe",
                "Europe, Australia",
                "Japan, Asia",
                "UK, Australia",
                "World",
                "Region-Free",
                "Other",
              ].find((r) => r === region) || "Other";
            const matchedGGnLanguage = parseLanguage(
              matchedGGnRegion,
              possiblyLanguages
            );
            const res = { filename, matchedGGnRegion, matchedGGnLanguage };
            // One hour seems appropriate
            noIntroCache.set(url, res, 1000 * 60 * 60);
            resolve({ ...res, cached: false });
          } catch (err) {
            console.error("zibzab helper failed to parse no-intro:", err);
            reject(
              new Error(
                "Failed to parse no-intro :/\nPlease report to BestGrapeLeaves,\nthe error was logged to the browser console"
              )
            );
          }
        },
        ontimeout: () => {
          reject(new Error("Request to no-intro timed out after 5 seconds"));
        },
      });
    });
  }

  // We are fetching files for checking, might as well reduce load and save to dom
  function fetchTorrentFilesWithoutShowing(torrentId) {
    const fromDOM = () =>
      $(
        `#files_${torrentId} > table > tbody > tr:not(.colhead_dark) > td:first-child`
      )
        .map(function () {
          return $(this).text();
        })
        .get();

    return new Promise((resolve) => {
      if ($("#files_" + torrentId).raw().innerHTML === "") {
        // $('#files_' + torrentId).gshow().raw().innerHTML = '<h4>Loading...</h4>';
        ajax.get(
          "torrents.php?action=torrentfilelist&torrentid=" + torrentId,
          function (response) {
            $("#files_" + torrentId).ghide();
            $("#files_" + torrentId).raw().innerHTML = response;

            resolve(fromDOM());
          }
        );
      } else {
        resolve(fromDOM());
      }
    });
  }

  async function checkForTrumpPossibility(torrentId) {
    const url = noIntroLinkForTorrentId(torrentId);
    if (!url) {
      return { trumpable: false, cached: false };
    }

    let info;
    try {
      info = await fetchNoIntro(url);
    } catch (err) {
      return {
        trumpable: true,
        cached: true, // Might as well wait if an error occurred
        inditermint:
          "Couldn't determine if the torrent is trumpable -\nFailed fetching No-Intro:\n" +
          err.message,
      };
    }

    const expectedFilename =
      info.filename.split(".").slice(0, -1).join(".") + ".zip";

    const files = await fetchTorrentFilesWithoutShowing(torrentId);
    if (files.length !== 1) {
      return {
        trumpable: true,
        expectedFilename,
        cached: info.cached,
        inditermint:
          "Couldn't determine if the torrent is trumpable -\nMultiple/No zip files found in torrent",
      };
    }

    const actualFilename = files[0];

    if (expectedFilename !== actualFilename) {
      return {
        trumpable: true,
        expectedFilename,
        actualFilename,
        cached: info.cached,
      };
    }

    return {
      trumpable: false,
      expectedFilename,
      actualFilename,
      cached: info.cached,
    };
  }

  function getNoIntroTorrentsOnPage() {
    return $('a[title="Permalink"]')
      .map(function () {
        const torrentId = $(this)
          .attr("href")
          .replace(/.*?\?torrentid=/, "");
        console.log("ac", torrentId);

        const noIntroLink = noIntroLinkForTorrentId(torrentId);
        if (!noIntroLink) {
          return false;
        }

        return { torrentId, a: $(this), noIntroLink };
      })
      .get()
      .filter((x) => x);
  }

  // Add Copy button, stolen boilerplate shamelessly from trump helper script
  function insertAddCopyHelpers() {
    getNoIntroTorrentsOnPage().forEach(({ torrentId, a, noIntroLink }) => {
      const editionInfo = a
        .parents(".group_torrent")
        .parent()
        .prev()
        .find(".group_torrent > td > strong")
        .text();
      // Convert to upload format of edition info
      const [editionYear, ...rest] = editionInfo.split(" - ");
      const editionName = rest.join(" - ");
      const formatedEditionInfo = `${editionName} (${editionYear})`;

      const groupId = window.location.href.replace(/.*?\?id=/, "");
      const params = new URLSearchParams(url.search);
      params.set("groupid", groupId);
      params.set("edition", formatedEditionInfo);
      params.set("no-intro", noIntroLink);

      const addCopyButton = $(
        `<a href="upload.php?${params.toString()}" title="Add Copy" id="ac_${torrentId}">AC</a>`
      );
      $([" | ", addCopyButton]).insertAfter(a);
    });
  }

  async function insertTrumpSuggestions(torrents) {
    const checkForTrumpsButton = $("#check-for-no-intro-trumps-button");
    checkForTrumpsButton.prop("disabled", true);
    checkForTrumpsButton.css("background-color", "pink");
    checkForTrumpsButton.css("color", "darkslategray");
    checkForTrumpsButton.css("box-shadow", "none");

    let trumps = 0;
    let prevCached = false;
    for (let i = 0; i < torrents.length; i++) {
      // timeout to avoid rate limiting
      if (!prevCached) {
        await new Promise((resolve) => setTimeout(resolve, 500));
      }

      const torrent = torrents[i];
      checkForTrumpsButton.val(
        `Checking For Trumps ${i + 1}/${torrents.length}...`
      );

      const insert = (details, inditermint) => {
        const trumpNotice = $(
          `<div>
          </div>`
        );
        const trumpNoticeDetails = $(
          `<div style="font-weight: normal; color: white;">${details}</div>`
        ).hide();
        const trumpNoticeTitle = $(`
            <span style="color: ${
              inditermint ? "pink" : "hotpink"
            }; font-size: 14px; font-weight: bold;">${
          inditermint
            ? "Unable to determine if torrent is trumpable:"
            : "This torrent can be trumped!"
        }</span>`);
        const trumpNoticeLinks = $(
          `<div class="trump-notice-links" id="trump-notice-links-${torrent.torrentId}" style="font-weight: normal; font-size: 11px; display: inline; margin: 5px;"></div>`
        );
        const trumpNoticeToggleDetailsLink = $(
          `<span style="cursor: pointer;">[Expand]</span>`
        );

        trumpNoticeToggleDetailsLink.click(() => {
          const collapsed = trumpNoticeToggleDetailsLink.text() === "[Expand]";

          if (collapsed) {
            trumpNoticeToggleDetailsLink.text("[Collapse]");
            trumpNoticeDetails.show();
          } else {
            trumpNoticeToggleDetailsLink.text("[Expand]");
            trumpNoticeDetails.hide();
          }
        });

        trumpNoticeLinks.append(trumpNoticeToggleDetailsLink);
        trumpNoticeTitle.append(trumpNoticeLinks);
        trumpNotice.append(trumpNoticeTitle);
        trumpNotice.append(trumpNoticeDetails);

        let currentlyAdaptedToSmallScreen;
        const placeTrumpNotice = () => {
          console.log("adapting", window.innerWidth);
          if (window.innerWidth <= 800) {
            if (currentlyAdaptedToSmallScreen) {
              return;
            }

            currentlyAdaptedToSmallScreen = true;
            $(`#torrent${torrent.torrentId}`).css("border-bottom", "none");
            trumpNotice.css("margin-left", "25px");
            trumpNotice.detach();
            trumpNotice.insertAfter(`#torrent${torrent.torrentId}`);
          } else {
            if (currentlyAdaptedToSmallScreen === false) {
              return;
            }

            currentlyAdaptedToSmallScreen = false;
            $(`#torrent${torrent.torrentId}`).css("border-bottom", "");
            trumpNotice.css("margin-left", "0px");
            trumpNotice.detach();
            trumpNotice.appendTo(
              `#torrent${torrent.torrentId} > td:first-child`
            );
          }
        };
        placeTrumpNotice();
        $(window).resize(placeTrumpNotice);
      };

      const res = await checkForTrumpPossibility(torrent.torrentId);
      if (!res.trumpable) {
        continue;
      }

      if (res.inditermint) {
        insert(res.inditermint, true);
        continue;
      }

      trumps++;
      const pre = (text, bgColor) =>
        `<pre style="
          padding: 0px;
          margin: 0;
          background-color: ${bgColor};
          color: black;
          font-weight: bold;
          font-size: 12px;
          padding-left: 3px;
          padding-right: 3px;
          width: fit-content;
        ">${text}</pre>`;
      insert(
        `The filename in the torrent is: ${pre(
          res.actualFilename,
          "lightcoral"
        )} but the desired filename, based on <i>No-Intro</i> is: ${pre(
          res.expectedFilename,
          "lightgreen"
        )}`
      );

      prevCached = res.cached;
    }

    if (trumps === 0) {
      checkForTrumpsButton.val("No Trumps Found");
    } else if (trumps === 1) {
      checkForTrumpsButton.val("1 Trump Found");
    } else {
      checkForTrumpsButton.val(`${trumps} Trumps Found`);
    }
  }

  function insertTrumpButtonAndMaybeCheck() {
    const torrents = getNoIntroTorrentsOnPage();

    if (torrents.length === 0) {
      return;
    }

    const checkForTrumpsButton = $(
      `<input id="check-for-no-intro-trumps-button" type="button" value="Check for No-Intro Trumps" style="background: hotpink; color: black; font-weight: bold; margin-left: 10px;"/>`
    );
    $(".torrent_table > tbody > tr:first-child > td:first-child")
      .first()
      .append(checkForTrumpsButton);

    if (torrents.length <= 4) {
      insertTrumpSuggestions(torrents);
    }

    checkForTrumpsButton.click((e) => {
      e.stopImmediatePropagation();
      insertTrumpSuggestions(torrents);
    });
  }

  // No Intro Button
  function makeNoIntro(filename) {
    const tags = filename
      ? filename
          .match(PARENS_TAGS_REGEX)
          .filter((p) => NO_INTRO_TAGS_REGEX.test(p))
          .join(" ")
      : "";

    // Release type = ROM
    $("select#miscellaneous").val("ROM").change();
    // It is a special edition
    if (!$("input#remaster").prop("checked")) {
      $("input#remaster").prop("checked", true);
      Remaster();
    }
    // Not a scene release
    $("#ripsrc_home").prop("checked", true);
    // Update title
    updateReleaseTitle($("#title").raw().value + " " + tags);

    // Get url params
    const params = new URLSearchParams(window.location.search);

    // Set correct edition (fallback to guessing)
    const setEdition = (edition) => {
      try {
        $("#groupremasters").val(edition).change();
        GroupRemaster();
      } catch {
        // group remaster always throws (regardless of the userscript)
      }
    };
    const editionInfo = params.get("edition");
    $("#groupremasters > option").each(function () {
      const title = $(this).text().toLowerCase();
      console.log("checking", title);
      if (editionInfo && title === editionInfo.toLowerCase()) {
        setEdition($(this).val());
        return false; // This breaks out of the jquery loop
      } else {
        if (title.includes("no-intro") || title.includes("nointro")) {
          setEdition($(this).val());
        }
      }
    });

    // Trigger no-intro link scraper
    const noIntroLink = params.get("no-intro");
    if (noIntroLink) {
      $("#no-intro-url-input").val(noIntroLink).change();
    }
  }

  function noIntroUI() {
    // elements
    const noIntroContainer = $(
      `<tr id="no-intro-url" name="no-intro-url">
      <td class="label">No-Intro Link</td>
    </tr>`
    );
    const noIntroInput = $(
      '<input type="text" id="no-intro-url-input" name="no-intro-url-input" size="70%" class="input_tog" value="">'
    );
    const noIntroError = $(
      '<p id="no-intro-url-error" name="no-intro-url-error" style="color: red; white-space:pre-line;"></p>'
    ).hide();
    const noIntroLoading = $(
      '<p id="no-intro-url-loading" name="no-intro-url-loading" style="color: green;">Loading...</p>'
    ).hide();
    // structure
    const td = $("<td></td>");
    td.append(noIntroInput);
    td.append(noIntroError);
    td.append(noIntroLoading);
    noIntroContainer.append(td);
    // utils
    const error = (msg) => {
      noIntroError.text(msg);
      noIntroError.show();
    };
    const loading = (isLoading) => {
      if (isLoading) {
        noIntroLoading.show();
      } else {
        noIntroLoading.hide();
      }
    };
    return { loading, error, noIntroContainer, noIntroInput, noIntroError };
  }

  function torrentViewPage() {
    insertAddCopyHelpers();
    insertTrumpButtonAndMaybeCheck();
  }

  function uploadPage() {
    // Insert No Intro button
    const nointro = $('<input type="button" value="No-Intro"></input>');
    nointro.click(() => makeNoIntro($("#file").val()));
    nointro.insertAfter("#file");

    // Link parser UI
    const { noIntroContainer, noIntroInput, noIntroError, error, loading } =
      noIntroUI();

    async function submitNoInput() {
      noIntroError.hide();
      const url = noIntroInput.val();

      if (justChecked === url) {
        return;
      }

      if (!url.startsWith("https://datomatic.no-intro.org/")) {
        error("Invalid URL");
        return;
      }

      justChecked = url;
      loading(true);

      try {
        const { filename, matchedGGnLanguage, matchedGGnRegion } =
          await fetchNoIntro(url);
        $("textarea#release_desc").val(genRomInsert(url, filename));
        $("select#region").val(matchedGGnRegion);
        $("select#language").val(matchedGGnLanguage);
      } catch (err) {
        error(err.message || err || "An unexpected error has occurred");
      } finally {
        loading(false);
      }
    }

    // watch link input
    let justChecked = "";
    noIntroInput.on("paste", (e) => {
      e.preventDefault();
      const text = e.originalEvent.clipboardData.getData("text/plain");
      noIntroInput.val(text);
      submitNoInput();
    });
    noIntroInput.change(submitNoInput);
    // React to release type change
    $("select#miscellaneous").change(function () {
      const selected = $("select#miscellaneous option:selected").text();
      if (selected === "GameDOX") {
        noIntroContainer.detach();
        $("input#release_title").val(
          $("input#release_title").val() + " - Manual"
        );
        $("select#gamedox").val("Guide").change();
        $("select#format").val("PDF").change();
        $("input#scan").click();
        Scan();
        $("textarea#release_desc").val(
          $("textarea#release_desc").val() + GAME_DOX_INSERT
        );
      } else if (selected === "ROM") {
        noIntroContainer.insertBefore("#regionrow");
        $("textarea#release_desc").val(genRomInsert());
      } else {
        noIntroContainer.detach();
      }
    });
  }

  if (window.location.pathname === "/torrents.php") {
    torrentViewPage();
  } else if (window.location.pathname === "/upload.php") {
    uploadPage();
  }
})();