ArkhamDB: replace missing images

Replaces missing card images on arkhamdb. Supports card pages, modals and tooltips.

// ==UserScript==
// @name        ArkhamDB: replace missing images
// @namespace   Violentmonkey Scripts
// @match       https://*.arkhamdb.com/**
// @grant       none
// @version     1.1.7
// @author      @hiflix
// @run-at      document-end
// @license     MIT
// @description Replaces missing card images on arkhamdb. Supports card pages, modals and tooltips.
// ==/UserScript==

const IMAGE_BASE_PATH = "https://assets.arkham.build/optimized";

/**
 * Handles most places where card images are displayed (including tooltips and modals).
 * known limitations:
 * - might break at any point without warning.
 * - doesn't handle double-sided cards (e.g. acts).
 * - `reviews` and `faq` might not work in all cases.
 */
function init() {
  tryReplaceBrokenImages();
  replaceFullCards();
  replaceCardPage();
  replaceScansOnly();
  watchCardTooltip();
  watchCardModal();
}

/**
 * Generic handler for places where arkhamdb tries to render a broken image.
 * This might fail due to race conditions with the loading of the site.
 */
function tryReplaceBrokenImages() {
  const url = window.location.href;

  // ignore set pages: we have specialized logic for these.
  if (!isSetPage(url)) {
    document.querySelectorAll("img").forEach((node) => {
      if (node.onerror) {
        node.onerror = "this.dataset.replace = true";
      }
    });

    window.addEventListener(
      "error",
      (evt) => {
        if (evt.target instanceof HTMLImageElement) {
          const match = /.*\/cards\/(.*)\.(?:png|jpg)/.exec(evt.target.src);

          if (
            match?.length === 2 &&
            !evt.target.src.startsWith(IMAGE_BASE_PATH)
          ) {
            evt.target.src = imageUrl(match[1]);
          }
        }
      },
      true
    );
  }
}

/**
 * Replace the "no image" placeholder on card pages with an image.
 * CAVEAT: does not handle double-sided cards.
 */
function replaceCardPage() {
  const url = window.location.href;
  const node = document.querySelector(".no-image");

  if (!isSetPage(url) && node) {
    const id = idFromCardUrl(url);

    const image = htmlFromString(
      `<img class="img-responsive img-vertical-card" src="${imageUrl(id)}" />`
    );

    node.parentNode.replaceChild(image, node);
  }
}

/**
 * Replace empty images on the "scans only" page.
 */
function replaceScansOnly() {
  const url = window.location.href;

  if (isSetPage(url) && (url.includes("/scan") || url.includes("/find"))) {
    document.querySelectorAll("a[href*='/card/'] > img").forEach((image) => {
      // check for empty image "src" attributes, which default to the current url.
      if (image.src === window.location.href) {
        const id = idFromCardUrl(image.parentNode.getAttribute("href"));
        image.src = imageUrl(id);
      }
    });
  }
}

/**
 * Replace the "no image" placeholders on the "full cards" page.
 */
function replaceFullCards() {
  const url = window.location.href;

  if (isSetPage(url) && (url.includes("/card") || url.includes("/find"))) {
    document.querySelectorAll(".no-image").forEach((node) => {
      const id = node
        .closest(".card-block")
        ?.querySelector(".card-name")
        ?.getAttribute("data-code");

      if (id) {
        const image = htmlFromString(
          `<img loading="lazy" class="img-responsive img-vertical-card" src="${imageUrl(
            id
          )}" />`
        );

        node.parentNode.appendChild(image);
        node.remove();
      }
    });
  }
}

/**
 * UNUSED! I suspect we will need this again in the future.
 * Replaces missing FHV investigators in the deck builder / deck list.
 */
function replaceBrokenInvestigators() {
  const REGEX_FHV_INVESTIGATORS = /.*\/cards\/(10.*)\.(?:png|jpg)/;

  const url = window.location.href;

  if (url.includes("/deck/") || url.includes("/decks")) {
    document
      .querySelectorAll(".deck-list-investigator-image")
      .forEach((node) => {
        const match = REGEX_FHV_INVESTIGATORS.exec(
          node.style["background-image"]
        );

        if (match && match.length == 2) {
          node.style["background-image"] = `url(${imageUrl(match[1])})`;
        }
      });
  }

  if (url.endsWith("/decklists")) {
    document.querySelectorAll(".decklist-faction-image img").forEach((node) => {
      const match = REGEX_FHV_INVESTIGATORS.exec(node.src);

      if (match && match.length == 2) {
        node.src = imageUrl(match[1]);
      }
    });
  }

  if (!window.location.pathname || window.location.pathname === "/") {
    document
      .querySelectorAll(".card-thumbnail-investigator")
      .forEach((node) => {
        const match = REGEX_FHV_INVESTIGATORS.exec(
          node.style["background-image"]
        );

        if (match && match.length == 2) {
          node.style["background-image"] = `url(${imageUrl(match[1])})`;
        }
      });
  }
}

/**
 * Watch for the card modal opening, then replace the broken image inside.
 */
function watchCardModal() {
  const modal = document.querySelector("#cardModal");
  if (!modal) return;

  // watch for images being added to the modal DOM.
  // filter for images that have src "undefined".
  const observer = new MutationObserver((mutationList) => {
    const image = mutationList.reduce(
      (acc, { type, addedNodes }) =>
        acc || type !== "childList"
          ? acc
          : Array.from(addedNodes).find((node) =>
              node?.src?.includes("undefined")
            ),
      undefined
    );

    // traverse modal DOM to find card link and update image src.
    if (image) {
      const cardUrl = image
        .closest("#cardModal")
        .querySelector("a[href*='/card/']")
        .getAttribute("href");

      image.src = imageUrl(idFromCardUrl(cardUrl));
    }
  });

  observer.observe(modal, {
    attributes: false,
    characterData: false,
    childList: true,
    subtree: true,
  });
}

function watchCardTooltip() {
  const observer = new MutationObserver((mutationList) => {
    // watch for changes on links with a tooltip.
    // once changed, find the corresponding tooltip and update it.
    for (const { target, type } of mutationList) {
      if (
        type === "attributes" &&
        target instanceof HTMLAnchorElement &&
        target.dataset.hasqtip
      ) {
        const tooltipId = target.dataset.hasqtip;
        const id = idFromCardUrl(target.href) || target?.dataset.code;
        const tooltip = document.querySelector(`#qtip-${tooltipId}-content`);

        if (tooltip && !tooltip.querySelector(".card-thumbnail")) {
          const url = imageUrl(id);

          // different card types art cropped differently, try to infer card type from tooltip text.
          const cardType =
            tooltip
              .querySelector(".card-type")
              ?.textContent?.split(".")
              ?.at(0)
              ?.toLowerCase() || "skill";

          const image = htmlFromString(`
            <div class="card-thumbnail card-thumbnail-3x card-thumbnail-${cardType}" style="background-image: url(${url})" />
          `);

          tooltip.prepend(image);
        }
      }
    }
  });

  observer.observe(document.body, {
    attributes: true,
    childList: false,
    characterData: false,
    subtree: true,
  });
}

function imageUrl(id) {
  return `${IMAGE_BASE_PATH}/${id}.avif`;
}

function idFromCardUrl(href) {
  return href.split("/").at(-1);
}

function isSetPage(url) {
  return (
    url.includes("/set/") || url.includes("/cycle/") || url.includes("/find")
  );
}

function htmlFromString(html) {
  const template = document.createElement("template");
  html = html.trim();
  template.innerHTML = html;
  return template.content.firstChild;
}

init();