Kemono Fixer

Allow you to blacklist creators on Kemono, and more.

目前為 2025-10-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Kemono Fixer
// @namespace   DKKKNND
// @license     WTFPL
// @match       https://kemono.cr/*
// @match       https://coomer.st/*
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @version     1.0
// @author      Kaban
// @description Allow you to blacklist creators on Kemono, and more.
// ==/UserScript==
(function() {
"use strict";
// ==<Options>==
const OPTIONS = JSON.parse(GM_getValue("OPTIONS", `{
  "adapt_post_card_height_to_thumbnail": false,
  "sort_posts_by_publish_date": true,
  "hide_posts_with_no_thumbnail": true,
  "hide_posts_from_blacklisted_creators": true,
  "hide_posts_shown_on_previous_pages": true
}`));
let BLACKLIST_MODE = false;
let BLACKLIST_RAW = JSON.parse(GM_getValue("BLACKLIST", `{}`));
let BLACKLIST = Object.keys(BLACKLIST_RAW);
// ==</Options>==

// ==<Menu>==
updateMenu("Adaptive Post Card Height",
  "adapt_post_card_height_to_thumbnail", true);
updateMenu("Sort Posts by Publish Date",
  "sort_posts_by_publish_date", true);
updateMenu("Hide Posts with No Thumbnail",
  "hide_posts_with_no_thumbnail", false);
updateMenu("Hide Posts from Blacklisted Creators",
  "hide_posts_from_blacklisted_creators", false);
updateMenu("Hide Posts shown on Previous Pages",
  "hide_posts_shown_on_previous_pages", false);
GM_registerMenuCommand(
  "---------------------------------------", null, { autoClose: false });
GM_registerMenuCommand(
  "▶ Enter Blacklist Mode", blacklistMode, { id: "blacklistMode" });
GM_registerMenuCommand(
  "⬆ Export Blacklist to Clipboard", exportBlacklist);
GM_registerMenuCommand(
  "⬇ Import Blacklist from Clipboard", importBlacklist);

function updateMenu(menuName, optionName, reloadPage = false) {
  GM_registerMenuCommand(
    `${OPTIONS[optionName] ? "☑" : "☐"} ${menuName}`, () => {
      updateOption(optionName, menuName, reloadPage)
    }, {
      id: optionName,
      title: `${menuName}${reloadPage ? " (Reloads current page)" : ""}`
    }
  );
}

function updateOption(optionName, menuName, reloadPage) {
  OPTIONS[optionName] = !OPTIONS[optionName];
  updateMenu(menuName, optionName, reloadPage);
  GM_setValue("OPTIONS", JSON.stringify(OPTIONS));
  if (reloadPage) {
    window.location.reload();
  } else {
    updateStyles();
  }
}

function updateStyles() {
  if(OPTIONS.hide_posts_with_no_thumbnail) {
    addStyle(CSS.hideNoThumbnail, "hide_no_thumbnail");
  } else {
    removeStyle("hide_no_thumbnail");
  }
  if(OPTIONS.hide_posts_from_blacklisted_creators) {
    addStyle(CSS.hideBlacklisted, "hide_blacklisted");
  } else {
    removeStyle("hide_blacklisted");
  }
  if(OPTIONS.hide_posts_shown_on_previous_pages) {
    addStyle(CSS.hideAlreadyShown, "hide_already_shown");
  } else {
    removeStyle("hide_already_shown");
  }
  if (BLACKLIST_MODE) {
    addStyle(CSS.blacklistMode, "blacklist_mode");
    removeStyle("hide_blacklisted");
  } else {
    removeStyle("blacklist_mode");
    if(OPTIONS.hide_posts_from_blacklisted_creators)
      addStyle(CSS.hideBlacklisted, "hide_blacklisted");
  }
}

function blacklistMode() {
  BLACKLIST_MODE = !BLACKLIST_MODE;
  updateStyles();
  if (BLACKLIST_MODE) {
    GM_registerMenuCommand(
      "◀ Exit Blacklist Mode", blacklistMode, { id: "blacklistMode" });

  } else {
    GM_registerMenuCommand(
      "▶ Enter Blacklist Mode", blacklistMode, { id: "blacklistMode" });

  }
}

function exportBlacklist() {
  let length = BLACKLIST.length;
  if (length === 0) {
    alert("Blacklist is empty, nothing to export.")
  } else {
    let message = `Do you want to export blacklist (${length} ${length > 1 ? "entries" : "entry"}) to clipboard?`;
    if (confirm(message)) {
      setTimeout(exportToClipboard, 500);
    }
  }
}

async function exportToClipboard() {
  try {
    await navigator.clipboard.writeText(JSON.stringify(BLACKLIST_RAW, null, 2));
    alert(`Exported blacklist (${BLACKLIST.length} entries) to clipboard as JSON.`);
  } catch (error) {
    alert(`Export to clipboard failed:\n${error.message}`);
  }
}

function importBlacklist() {
  let message = "Do you want to import blacklist from clipboard?";
  if (confirm(message)) {
    let length = BLACKLIST.length;
    if (length > 0) {
      message = `Current blacklist (${length} ${length > 1 ? "entries" : "entry"}) will be overwritten!`;
      if (!confirm(message)) return;
    }
    setTimeout(importFromClipboard, 500);
  }
}

async function importFromClipboard() {
  try {
    let rawClipboard = await navigator.clipboard.readText();
    if (!rawClipboard) {
      alert("Clipboard read empty.\n(Permission denied?)");
      return;
    }
    let json;
    if (rawClipboard.startsWith('{')) {
      try {
        json = JSON.parse(rawClipboard);
      } catch (error) {
        alert(`Parse JSON failed:\n${error.message}`)
      }
    } else {
      // backward compatibility with data from old version script
      let lines = rawClipboard.split('\n');
      let regex = /"([^"]+)"[,\s]*\/\/\s*(.*?)\s*$/;
      json = {};
      for (let i = 0; i < lines.length; i++) {
        let match = lines[i].match(regex);
        if (match) {
          json[match[1]] = match[2];
        }
      }
    }
    let length = Object.keys(json).length;
    if (length === 0) {
      let message = "Found no valid entry from the clipboard.\nEnter \"clear\" to clear the blacklist.";
      if (prompt(message, "")?.toLowerCase() === "clear") {
        BLACKLIST_RAW = {};
        BLACKLIST = [];
        GM_setValue("BLACKLIST", "{}");
        alert("Blacklist cleared.");
      } else {
        alert("Import aborted.");
      }
    } else {
      BLACKLIST_RAW = json;
      BLACKLIST = Object.keys(BLACKLIST_RAW);
      GM_setValue("BLACKLIST", JSON.stringify(BLACKLIST_RAW));
      alert(`Imported ${length} blacklist ${length > 1 ? "entries" : "entry"} from clipboard.`)
    }
  } catch (error) {
    alert(`Import from clipboard failed:\n${error.message}`);
  }
}
// ==</Menu>==

// ==<Helper Functions>==
function gOne(id) {
  return document.getElementById(id);
}
function qOne(selector) {
  return document.querySelector(selector);
}
Element.prototype.qOne = function(selector) {
  return this.querySelector(selector);
}
function qAll(selector) {
  let results = document.querySelectorAll(selector);
  return results.length === 0 ? null : results;
}
Element.prototype.qAll = function(selector) {
  let results = this.querySelectorAll(selector);
  return results.length === 0 ? null : results;
}
function createKfElement(type, id = null, content = null) {
  let element = document.createElement(type);
  if (id) {
    element.id = `kf-${id}`;
    let oldElement = gOne(element.id);
    if (oldElement) oldElement.remove();
  }
  if (content) element.textContent = content;
  return element;
}
HTMLElement.prototype.addKfClass = function(className) {
  return this.classList.add(`kf-${className}`);
}
HTMLElement.prototype.setKfAttr = function(attributeName, value) {
  return this.setAttribute(`data-kf-${attributeName}`, value);
}
HTMLElement.prototype.getKfAttr = function(attributeName) {
  return this.getAttribute(`data-kf-${attributeName}`);
}
HTMLElement.prototype.getDataAttr = function(attributeName) {
  return this.getAttribute(`data-${attributeName}`);
}
// ==</Helper Functions>==

// ==<Mutation Observer>==
const observer = new MutationObserver(onMutation);
observer.observe(document.body, { childList: true, subtree: true });

function onMutation(mutations, observer) {
  // stop observer before DOM manipulation
  observer.disconnect();
  // pathname-based dispatch
  let path = window.location.pathname;
  if (path === "/posts" || path === "/posts/popular") {
    addStyle(CSS.posts);
    fixPosts();
  } else {
    let segments = path.split('/').filter(segment => segment);
    if (segments[1] === "user") {
      // {service}/user/{user_id}
      if (segments.length === 3) {
        //fixCreatorPosts();
      } else if (segments[3] === "post") {
        if (
          // {service}/user/{user_id}/post/{post_id}
          segments.length === 5 ||
          // {service}/user/{user_id}/post/{post_id}/revision/{revision_id}
          segments.length === 7 && segments[5] === "revision"
        ) {
          //fixCurrentPost();
        }
      // discord/server/{server_id}/{room_id}
      } else if (segments.length === 4 && segments[0] === "discord") {
        //fixDiscord();
      }
    }
  }
  // restart observer
  observer.observe(document.body, { childList: true, subtree: true });
}
// ==</Mutation Observer>==

// ==<Page Fixing Functions>==
function kfExist(id) {
  let element = gOne(`kf-${id}`);
  if (!element) return false;
  if (element.getKfAttr("href") === window.location.href) return true;
  element.remove();
  return false;
}

function addStyle(css, id = "style") {
  if (kfExist(id)) return;
  let style = createKfElement("style", id, css);
  style.setKfAttr("href", window.location.href);
  document.head.append(style);
}

function removeStyle(id = "style") {
  let style = gOne(`kf-${id}`);
  if(style) style.remove();
}

function fixPosts() {
  if (kfExist("postCardContainer")) return;

  let srcPostCardContainer = qOne(".card-list__items");
  if (!srcPostCardContainer) return;
  let srcPostCards = srcPostCardContainer.qAll(".post-card");
  if (!srcPostCards) return;

  let newPostCardContainer = createKfElement("div", "postCardContainer");
  let newPostCards = [];
  for(const card of srcPostCards) {
    let newCard = card.cloneNode(true);
    if (OPTIONS.sort_posts_by_publish_date) {
      let timeStamp = new Date(newCard.qOne("time").dateTime).getTime();
      newCard.setKfAttr("published", timeStamp);
    }
    newPostCards.push(newCard);
  }
  if (OPTIONS.sort_posts_by_publish_date) {
    newPostCards = newPostCards.sort((a, b) =>
      b.getKfAttr("published") - a.getKfAttr("published")
    );
  }

  let hiddenPosts = {
    noThumbnail: 0,
    creatorBlacklisted: 0,
    blacklistedCreators: [],
    alreadyShown: 0
  };
  let shownPosts = [];
  let currentOffset = parseInt(new URLSearchParams(window.location.search).get("o")) || 0;
  if (currentOffset > parseInt(sessionStorage.getItem("lastOffset")))
    shownPosts = JSON.parse(sessionStorage.getItem("shownPosts")) || [];
  for(const card of newPostCards) {
    let thumbnail = card.qOne(".post-card__image");
    if (!thumbnail) {
      hiddenPosts.noThumbnail++;
      card.addKfClass("no_thumbnail");
    } else {
      thumbnail.addEventListener("load", fixThumbnailAspectRatio);
    }

    let blacklistControl = createKfElement("div", "blacklist_control")
    blacklistControl.addEventListener("click", preventDefault);
    let blacklistButton = document.createElement("input");
    blacklistButton.type = "button";
    blacklistControl.appendChild(blacklistButton);
    card.qOne("a").prepend(blacklistControl);
    let creatorKey = `${card.getDataAttr("service")}-${card.getDataAttr("user")}`;
    if (BLACKLIST.includes(creatorKey)) {
      hiddenPosts.creatorBlacklisted++;
      card.addKfClass("blacklisted");
      if (!hiddenPosts.blacklistedCreators.includes(creatorKey))
        hiddenPosts.blacklistedCreators.push(creatorKey);
      blacklistButton.value = "Remove from Blacklist";
      blacklistButton.addEventListener("click", unblacklistCreator);

    } else {
      blacklistButton.value = "Add to Blacklist";
      blacklistButton.addEventListener("click", blacklistCreator);
    }

    let postKey = `${creatorKey}-${card.getDataAttr("id")}`;
    if (shownPosts.includes(postKey)) {
      hiddenPosts.alreadyShown++;
      card.addKfClass("already_shown");
    } else {
      shownPosts.push(postKey);
    }

    newPostCardContainer.appendChild(card);
  }
  sessionStorage.setItem("lastOffset", currentOffset);
  sessionStorage.setItem("shownPosts", JSON.stringify(shownPosts));

  // reset all page buttons active state
  let pageButtons = qAll("menu > a");
  if (pageButtons) for (const button of pageButtons) {
    button.blur();
  }

  // add messages about hidden posts
  let srcMessage = qOne("#paginator-top small");
  if (srcMessage) {
    let messageContainer = createKfElement("div", "postMessageContainer");
    srcMessage.after(messageContainer);

    if (hiddenPosts.noThumbnail > 0) {
      let message;
      if (hiddenPosts.noThumbnail > 1) {
        message = `Hid ${hiddenPosts.noThumbnail} posts with no thumbnail.`;
      } else {
        message = "Hid 1 post with no thumbnail.";
      }
      let messageNoThumbnail = createKfElement("small", "no_thumbnail_msg", message);
      messageNoThumbnail.addEventListener("click", showPostsWithNoThumbnail);
      messageContainer.appendChild(messageNoThumbnail);
    }

    if (hiddenPosts.creatorBlacklisted > 0) {
      let message;
      if (hiddenPosts.creatorBlacklisted > 1) {
        message = `Hid ${hiddenPosts.creatorBlacklisted} posts from `;
        if (hiddenPosts.blacklistedCreators.length > 1) {
          message += `${hiddenPosts.blacklistedCreators.length} blacklisted creator.`;
        } else {
          message += "a blacklisted creator."
        }
      } else {
        message = "hid 1 post from a blacklisted creator."
      }
      let messageBlacklisted = createKfElement("small", "blacklisted_msg", message);
      messageBlacklisted.addEventListener("click", showPostsFromBlacklistedCreators);
      messageContainer.appendChild(messageBlacklisted);
    }

    if (hiddenPosts.alreadyShown > 0) {
      let message;
      if (hiddenPosts.alreadyShown > 1) {
        message = `Hid ${hiddenPosts.alreadyShown} posts already shown on previous pages.`;
      } else {
        message = "Hid 1 post already shown on previous pages.";
      }
      let messageAlreadyShown = createKfElement("small", "already_shown_msg", message);
      messageAlreadyShown.addEventListener("click", showPostsFromPreviousPages);
      messageContainer.appendChild(messageAlreadyShown);
    }

    updateStyles();
  }
  newPostCardContainer.setKfAttr("href", window.location.href);
  srcPostCardContainer.after(newPostCardContainer);
  srcPostCardContainer.style.display = "none";
}

function fixThumbnailAspectRatio(event) {
  let img = event.target;
  let aspectRatio = img.naturalWidth / img.naturalHeight;
  if (OPTIONS.adapt_post_card_height_to_thumbnail) {
    img.closest("article").style.aspectRatio = Math.min(aspectRatio, 1);
    if (aspectRatio - 1 > 0.167) img.style.objectFit = "contain";
  } else {
    if (aspectRatio - 2/3 > 0.167) img.style.objectFit = "contain";
  }
  img.style.opacity = 1;
}

function showPostsWithNoThumbnail(event) {
  removeStyle("hide_no_thumbnail");
}

function showPostsFromBlacklistedCreators(event) {
  removeStyle("hide_blacklisted");
}

function showPostsFromPreviousPages(event) {
  removeStyle("hide_already_shown");
}

function preventDefault(event) {
  event.preventDefault();
}

function blacklistCreator(event) {
  event.preventDefault();
  let card = event.target.closest("article");
  let creatorKey = `${card.getDataAttr("service")}-${card.getDataAttr("user")}`;
  let message = `Do you want to blacklist ${creatorKey}?`;
  let title = card.qOne("header").childNodes[0];
  if (title) {
    message += `\n(Creator of post ${title.textContent})`;
  }
  if (confirm(message)) {
    addToBlacklist(creatorKey, title ? title.textContent : "");
  }
}

function addToBlacklist(creatorKey, comment) {
  BLACKLIST_RAW[creatorKey] = comment;
  GM_setValue("BLACKLIST", JSON.stringify(BLACKLIST_RAW));
  alert(`${creatorKey} added to blacklist, reload to take effect.`);
}

function unblacklistCreator(event) {
  event.preventDefault();
  let card = event.target.closest("article");
  let creatorKey = `${card.getDataAttr("service")}-${card.getDataAttr("user")}`;
  let message = `Do you want to unblacklist ${creatorKey}?`;
  let title = card.qOne("header").childNodes[0];
  if (title) {
    message += `\n(Creator of post ${title.textContent})`;
  }
  if (confirm(message)) {
    removeFromBlacklist(creatorKey);
  }
}

function removeFromBlacklist(creatorKey) {
  delete BLACKLIST_RAW[creatorKey];
  GM_setValue("BLACKLIST", JSON.stringify(BLACKLIST_RAW));
  alert(`${creatorKey} removed from blacklist, reload to take effect.`);
}
// ==</Page Fixing Functions>==

// ==<CSS>==
const CSS = {};
CSS.posts = `#kf-postCardContainer {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 5px;
  padding: 0 5px;
  align-items: end;
  justify-content: center;
}
.card-list {
  container-type: inline-size;
  container-name: postCardContainerParent;
}
@container postCardContainerParent (min-width: 1845px) {
  #kf-postCardContainer {
    grid-template-columns: repeat(10, 1fr);
  }
}
#kf-postCardContainer article {
  width: auto;
  height: auto;
  aspect-ratio: 2/3;
  filter: drop-shadow(0 2px 2px #000);
}
article a {
  border: 0;
}
.post-card__image-container {
  overflow: hidden;
}
.post-card__image {
  opacity: 0;
  transition: opacity 0.5s ease-out, filter 0.5s ease-out;
}
.kf-no_thumbnail footer::before {
  content: "No Thumbnail";
}
.kf-blacklisted .post-card__image {
  filter: blur(10px);
}
.kf-blacklisted .post-card__image:hover {
  filter: blur(0);
}
.kf-already_shown {
  opacity: .5;
}
#kf-no_thumbnail_msg,
#kf-blacklisted_msg,
#kf-already_shown_msg {
  display: none;
}
#kf-blacklist_control {
  z-index: 99;
  position: absolute;
  width: 100%;
  height: 100%;
  display: none;
  justify-content: center;
  align-items: center;
  cursor: default;
}
#kf-blacklist_control input {
  border: none;
  border-radius: 10px;
  padding: 5px;
  font-size: 15px;
  background: #9c2121;
  color: white;
  font-weight: bold;
}
#kf-blacklist_control input:hover {
  background: #d94a4a;
  cursor: pointer;
}
#kf-blacklist_control input[value="Remove from Blacklist"] {
  background: #47d5a6;
}
#kf-blacklist_control input[value="Remove from Blacklist"]:hover {
  background: #9ae8ce;
}

`;
if (OPTIONS.adapt_post_card_height_to_thumbnail) CSS.posts += `
#kf-postCardContainer article {
  aspect-ratio: 1;
  transition: aspect-ratio 0.5s ease-out;
}
#kf-postCardContainer .kf-no_thumbnail {
  aspect-ratio: unset;
  height: fit-content;
}`;
CSS.hideNoThumbnail = `.kf-no_thumbnail { display: none; }
#kf-no_thumbnail_msg { display: block !important; }`;
CSS.hideBlacklisted = `.kf-blacklisted { display: none; }
#kf-blacklisted_msg { display: block !important; }`;
CSS.hideAlreadyShown = `.kf-already_shown { display: none; }
#kf-already_shown_msg { display: block !important; }`;
CSS.blacklistMode = `.kf-blacklisted .post-card__image { filter: blur(0) !important; }
#kf-blacklist_control { display: flex !important; }`;
// ==</CSS>==
})();