Kemono Patcher

Workaround "Creator not found" error, and more.

// ==UserScript==
// @name        Kemono Patcher
// @namespace   DKKKNND
// @license     WTFPL
// @match       https://kemono.cr/*
// @match       https://coomer.st/*
// @run-at      document-start
// @grant       GM_registerMenuCommand
// @grant       GM_unregisterMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @version     1.4
// @author      Kaban
// @description Workaround "Creator not found" error, and more.
// ==/UserScript==
(function() {
"use strict";

// ==<User Script>==
const MISSING_PATREON = JSON.parse(GM_getValue("MISSING_PATREON", "[]"));
const RENAME_CREATORS = JSON.parse(GM_getValue("RENAME_CREATORS", "{}"));
const PATREON_METADATA_CACHE = JSON.parse(GM_getValue("PATREON_METADATA_CACHE", '{"postIds":[]}'));

function onMutation() {
  updatePageInfo();
  observer.disconnect();
  switch (pageInfo.pageType) {
    case "Post Details":
      updateImportedTime();
      break;
  }
  observer.observe(document, { childList: true, subtree: true });
}
const observer = new MutationObserver(onMutation);
observer.observe(document, { childList: true, subtree: true });

let pageInfo = {};
function updatePageInfo() {
  if (pageInfo.href === window.location.href) return;
  pageInfo = {};
  const pathname = window.location.pathname;
  const segments = pathname.split('/').filter(segment => segment);
  switch (segments.length) {
    case 3: {
      if (segments[1] === "user") {
        pageInfo.pageType = "Creator Posts";
        const service = segments[0];
        const userId = segments[2];
        pageInfo.userKey = `${service}-${userId}`;
      }
      break;
    }
    case 4: {
      if (segments[1] === "user" && segments[3] === "community") {
        pageInfo.pageType = "Creator Community";
        const service = segments[0];
        const userId = segments[2];
        pageInfo.userKey = `${service}-${userId}`;
      }
      break;
    }
    case 5:
    case 7: {
      if (segments[1] === "user" && segments[3] === "post" &&
         (segments[5] == undefined || segments[5] === "revision")) {
        pageInfo.pageType = "Post Details";
        const service = segments[0];
        const userId = segments[2];
        const postId = segments[4];
        pageInfo.userKey = `${service}-${userId}`;
        pageInfo.postKey = `${service}-${userId}-${postId}`;
      }
    }
  }
  pageInfo.href = window.location.href;
  updateScriptMenu();
}

function updateScriptMenu() {
  switch (pageInfo.pageType) {
    case "Creator Posts":
    case "Post Details":
      GM_registerMenuCommand("✎ Rename Creator", renameCreator, { id: "renameCreator" });
      break;
    default:
      GM_unregisterMenuCommand("renameCreator");
  }
}

let postImported = {};
function loadImportedTime(event) { // called from page script
  if (postImported.restoredKey === event.detail.postKey) return;
  postImported.postKey = event.detail.postKey;
  postImported.imported = event.detail.imported;
}
document.addEventListener("kp-user:load-imported-time", loadImportedTime);

function updateImportedTime() {
  if (postImported.postKey !== pageInfo.postKey) return;
  if (postImported.imported?.[0] === null) return; // Kemono bug (Pixiv Fanbox)

  const revisionSelection = document.getElementById("post-revision-selection");
  if (revisionSelection) {
    const revisionOptions = revisionSelection.getElementsByTagName("option");
    // switching revision causes text reset, need edit again
    if (postImported.restoredKey === postImported.postKey &&
        postImported.restoredText === revisionOptions[0].textContent) {
      return;
    }
    for (let i = 0; i < revisionOptions.length; i++) {
      const date = new Date(postImported.imported[i]);
      const importedTime = date.toLocaleString("en-CA", { hourCycle: "h23" });
      const suffix = revisionOptions[i].textContent.substring(7);
      revisionOptions[i].textContent = importedTime.replace(',', '') + suffix;
    }
    postImported.restoredKey = postImported.postKey;
    postImported.restoredText = revisionOptions[0].textContent;
    return;
  }

  const revisionSpan = document.querySelector(".post__added span");
  if (revisionSpan) {
    const date = new Date(postImported.imported[0]);
    const importedTime = date.toLocaleString("en-CA", { hourCycle: "h23" });
    revisionSpan.lastChild.textContent = importedTime.replace(',', '');
    postImported = { restoredKey: postImported.postKey };
  }
}

let saveTimeout = {};
function debouncedSave(gmKey, object) {
  clearTimeout(saveTimeout[gmKey]);
  saveTimeout[gmKey] = setTimeout(() => {
    // To Do: Make this Multi-Tab Safe
    GM_setValue(gmKey, JSON.stringify(object));
  }, 500);
}

function renameCreator(event) {
  if (event.type === "visibilitychange") {
    if (document.visibilityState !== "visible") return;
    if (!pageInfo.renameCreatorFlag) return;
    pageInfo.renameCreatorFlag = null;
  }
  if (document.visibilityState === "visible") {
    const creatorName = document.querySelector(".post__user-name") || 
                        document.querySelector(`span[itemprop="name"]`);
    const userKey = pageInfo.userKey;
    const name = RENAME_CREATORS[userKey] || creatorName.textContent;
  
    const input = prompt(`Enter new name for ${name} (${userKey}):\n(leave empty to reset)`, name);
    if (input === null || input === name) return;
    if (input === "") {
      delete RENAME_CREATORS[userKey];
    } else {
      RENAME_CREATORS[userKey] = input;
    }
    debouncedSave("RENAME_CREATORS", RENAME_CREATORS);
    document.dispatchEvent(new CustomEvent("kp-page:rename-creator", {
      detail: { userKey: userKey, newName: input }
    }));
    creatorName.textContent = input || userKey;
  } else {
    if (!pageInfo.renameCreatorFlag) pageInfo.renameCreatorFlag = true; // mobile workaround
  }
}
document.addEventListener("visibilitychange", renameCreator);

function addMissingPatreon(event) { // called from page script
  const userId = event.detail.userId;
  MISSING_PATREON.push(userId);
  debouncedSave("MISSING_PATREON", MISSING_PATREON);
}
document.addEventListener("kp-user:add-missing-patreon", addMissingPatreon);

function addPatreonCache(event) { // called from page script
  const postId = event.detail.postId;
  PATREON_METADATA_CACHE.postIds.push(postId);
  const userId = event.detail.userId;
  if (!PATREON_METADATA_CACHE[userId]) PATREON_METADATA_CACHE[userId] = [];
  const postJson = event.detail.postJson;
  const postMetadata = {
    id: postJson.id,
    user: postJson.user,
    service: "patreon",
    title: postJson.title,
    published: postJson.published,
    file: { path: postJson.file.path },
    attachments: '~'.repeat(postJson.attachments.length)
  };
  PATREON_METADATA_CACHE[userId].push(postMetadata);
  debouncedSave("PATREON_METADATA_CACHE", PATREON_METADATA_CACHE);
}
document.addEventListener("kp-user:add-patreon-cache", addPatreonCache);

function purgePatreonCache(event) { // called from page script
  const userId = event.detail.userId;
  if (PATREON_METADATA_CACHE[userId]) {
    delete PATREON_METADATA_CACHE[userId];
    debouncedSave("PATREON_METADATA_CACHE", PATREON_METADATA_CACHE);
  }
}
document.addEventListener("kp-user:purge-patreon-cache", purgePatreonCache);
// ==</User Script>==

// ==<Main>==
const injectScript = document.createElement("script");
injectScript.textContent = `(${patchFetch})();`;
document.documentElement.appendChild(injectScript);
document.dispatchEvent(new CustomEvent("kp-page:load-data", {
  detail: {
    missingPatreon: MISSING_PATREON,
    renameCreators: RENAME_CREATORS,
    cachedPatreonPosts: PATREON_METADATA_CACHE
  }
}));
injectScript.remove();
// ==</Main>==

// ==<Injected Function>==
function patchFetch() {
  let MISSING_PATREON;
  let RENAME_CREATORS;
  let PATREON_METADATA_CACHE;
  let PATREON_METADATA_CACHE_POST_IDS;

  function loadData(event) { // called from user script
    MISSING_PATREON = new Set(event.detail.missingPatreon);
    RENAME_CREATORS = event.detail.renameCreators;
    PATREON_METADATA_CACHE = event.detail.cachedPatreonPosts;
    PATREON_METADATA_CACHE_POST_IDS = new Set(PATREON_METADATA_CACHE.postIds);
  }
  document.addEventListener("kp-page:load-data", loadData);

  function renameCreator(event) { // called from user script
    const userKey = event.detail.userKey;
    const newName = event.detail.newName;
    if (newName === "") {
      delete RENAME_CREATORS[userKey];
    } else {
      RENAME_CREATORS[userKey] = newName;
    }
  }
  document.addEventListener("kp-page:rename-creator", renameCreator);

  function addMissingPatreon(userId) {
    if (!MISSING_PATREON.has(userId)) {
      MISSING_PATREON.add(userId);
      document.dispatchEvent(new CustomEvent("kp-user:add-missing-patreon", {
        detail: { userId: userId }
      }));
    }
  }

  const FAKE_PATREON_PROFILE = function(userId) {
    const userKey = `patreon-${userId}`;
    const name = RENAME_CREATORS[userKey] || userKey;
    const postCount = PATREON_METADATA_CACHE[userId]?.length || 0;
    const response = {
      id: userId,
      name: name,
      has_chats: true,
      post_count: postCount,
      service: "patreon"
    };
    return new Response(JSON.stringify(response));
  };

  const FAKE_PATREON_POSTS = function(userId, offset) {
    offset = parseInt(offset) || 0;
    const cachedPosts = PATREON_METADATA_CACHE[userId] || [];
    return new Response(JSON.stringify(cachedPosts.slice(offset, offset + 50)));
  };

  const nativeFetch = window.fetch.bind(window);

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

  const silent429Fetch = async function (input, init) {
    try {
      const response = await nativeFetch(input, init);
      if (response.status === 429) {
        const MAX_RETRIES = 3;
        let attempt = 0;
        let delay = 500;
        while (attempt < MAX_RETRIES) {
          attempt++;
          console.log(`HTTP 429: ${window.location.href}\nRetry (attempt ${attempt}/${MAX_RETRIES}) in ${delay} ms...`);
          await sleep(delay);
          delay += 500; // backoff for next retry
          const response = await nativeFetch(input, init);
          if (response.ok || response.status !== 429 || attempt === MAX_RETRIES) {
            return response;
          }
        }
      }
      return response;
    } catch (error) {
      throw error;
    }
  };

  window.fetch = async function(input, init) {
    let url;
    if (input instanceof Request) {
      url = new URL(input.url);
    } else if (typeof input === "string") {
      try {
        url = new URL(input, location.origin);
      } catch (error) {
        return nativeFetch(input, init);
      }
    } else {
      return nativeFetch(input, init);
    }
    if (!url.pathname.startsWith("/api/v1/")) {
      return nativeFetch(input, init);
    }
    switch (url.pathname) {
      case "/api/v1/posts":
      case "/api/v1/posts/popular": {
        return silent429Fetch(input, init);
      }
    }
    const segments = url.pathname.split('/').filter(segment => segment);
    if (segments.length < 6 || segments[3] !== "user") {
      return nativeFetch(input, init);
    }
    const service = segments[2];
    const userId  = segments[4];
    const apiName = segments[5];
    switch (apiName) {
      case "profile": {
        if (segments.length !== 6) {
          return nativeFetch(input, init);
        }

        if (service === "patreon" && MISSING_PATREON.has(userId)) {
          return FAKE_PATREON_PROFILE(userId);
        }
        try {
          const response = await nativeFetch(input, init);
          if (response.ok) {
            document.dispatchEvent(new CustomEvent("kp-user:purge-patreon-cache", {
              detail: { userId: userId }
            }));
            const newName = RENAME_CREATORS[`${service}-${userId}`];
            if (newName) {
              const responseJSON = await response.json();
              responseJSON.name = newName;
              return new Response(JSON.stringify(responseJSON),
                { status: response.status, headers: response.headers }
              );
            }
          } else if (response.status === 404 && service === "patreon") {
            addMissingPatreon(userId);
            return FAKE_PATREON_PROFILE(userId);
          }
          return response;
        } catch (error) {
          return nativeFetch(input, init);
        }
      }
      case "posts": {
        if (segments.length !== 6) {
          return nativeFetch(input, init);
        }

        const offset = new URLSearchParams(url.search).get("o");
        if (service === "patreon" && MISSING_PATREON.has(userId)) {
          return FAKE_PATREON_POSTS(userId, offset);
        }
        try {
          const response = await silent429Fetch(input, init);
          if (response.status === 404 && service === "patreon") {
            return FAKE_PATREON_POSTS(userId, offset);
          }
          return response;
        } catch (error) {
          return nativeFetch(input, init);
        }
      }
      case "post": {
        if (!(segments.length === 7 || 
             (segments.length === 9 && segments[7] === "revision"))) {
          return nativeFetch(input, init);
        }

        const postId = segments[6];
        try {
          const response = await nativeFetch(input, init);
          if (response.ok) {
            const responseJSON = await response.json();
            const imported = [];
            const revisions = responseJSON.props.revisions;
            for (const revision of responseJSON.props.revisions) {
              imported.push(revision[1].added); // second element is post object
            }
            // Kemono front end cuts off imported date, send raw data to user script
            document.dispatchEvent(new CustomEvent("kp-user:load-imported-time", {
              detail: { postKey: `${service}-${userId}-${postId}`, imported: imported }
            }));

            // To Do: make a "white list" for creators do exist so no need for caching
            if (service === "patreon" && !PATREON_METADATA_CACHE_POST_IDS.has(postId)) {
              document.dispatchEvent(new CustomEvent("kp-user:add-patreon-cache", {
                detail: { userId: userId, postId: postId, postJson: responseJSON["post"] }
              }));
              PATREON_METADATA_CACHE_POST_IDS.add(postId);
            }

            return new Response(JSON.stringify(responseJSON),
              { status: response.status, headers: response.headers }
            );
          }
        } catch (error) {
          return nativeFetch(input, init);
        }
      }
    }
    return nativeFetch(input, init);
  };
}
// ==</Injected Function>==
})();