Kemono Browser

Adds a button at the bottom right of all kemono-supported creator websites (+ onlyfans) that redirects to the corresponding kemono/coomer page.

Versione datata 05/01/2024. Vedi la nuova versione l'ultima versione.

// ==UserScript==
// @name         Kemono Browser
// @namespace    Violentmonkey Scripts
// @version      1.3.1
// @description  Adds a button at the bottom right of all kemono-supported creator websites (+ onlyfans) that redirects to the corresponding kemono/coomer page.
// @author       zWolfrost
// @license      MIT

// @match        https://*.patreon.com/*
// @match        https://*.fanbox.cc/*
// @match        https://*.pixiv.net/*
// @match        https://*.fantia.jp/*
// @match        https://*.boosty.to/*
// @match        https://*.dlsite.com/*
// @match        https://*.gumroad.com/*
// @match        https://*.subscribestar.com/*
// @match        https://*.subscribestar.adult/*
// @match        https://*.onlyfans.com/*

// @icon         https://kemono.su/static/favicon.ico
// @grant        GM.xmlHttpRequest
// ==/UserScript==
"use strict"



const UPDATE_POLLING_MS = 222; //time in milliseconds for the script to update the button. i generally don't recommend setting it to less than 100.
const PROMPT_BTN = document.createElement("a");
initPromptButton();

let lastURL = "";
setInterval(update, UPDATE_POLLING_MS);


function initPromptButton()
{
   const BTN_ANIMATIONS = true; //whether to enable button animations.
   const OPEN_IN_NEW_TAB = true; //whether to open the url in a new tab by default.

   const PROMPT_BTN_ID = "kemono-url-btn";

   const BTN_BASE_CSS = `
   #${PROMPT_BTN_ID}
   {
      display: none;
      position: fixed !important;
      bottom: 0px;
      right: 0px;
      z-index: 9999 !important;

      min-width: 0 !important;
      min-height: 0 !important;
      max-width: none !important;
      max-height: none !important;

      padding: 4px !important;
      margin: 12px !important;

      font-family: arial !important;
      font-weight: bold !important;
      font-size: 18px !important;

      line-height: normal !important;
      text-decoration: none !important;
      cursor: pointer !important;

      box-shadow: black 0.5px 0.5px, black 1px 1px, black 1.5px 1.5px, black 2px 2px, black 2.5px 2.5px, black 3px 3px, black 3.5px 3.5px, black 4px 4px;
   }

   #${PROMPT_BTN_ID}:hover
   {
      filter: brightness(90%);
   }
   `

   const BTN_ANIMATIONS_CSS = `
   #${PROMPT_BTN_ID}:active
   {
      bottom: -4px;
      right: -4px;

      box-shadow: none;

      filter: brightness(80%);

      animation: press 0.05s ease-in-out;
   }

   @keyframes press
   {
      from
      {
         bottom: 0px;
         right: 0px;
         box-shadow: black 0.5px 0.5px, black 1px 1px, black 1.5px 1.5px, black 2px 2px, black 2.5px 2.5px, black 3px 3px, black 3.5px 3.5px, black 4px 4px;
      }
      to
      {
         bottom: -4px;
         right: -4px;
      }
   }
   `

   appendCSS(BTN_BASE_CSS);
   if (BTN_ANIMATIONS) appendCSS(BTN_ANIMATIONS_CSS);

   PROMPT_BTN.id = PROMPT_BTN_ID;
   PROMPT_BTN.target = OPEN_IN_NEW_TAB ? "_blank" : "_self";

   document.body.prepend(PROMPT_BTN)
}

async function update()
{
   const domain = window.location.hostname.split(".").slice(-2).join(".")

   const url = domainMethods[domain]?.();

   if (url)
   {
      if (url != lastURL)
      {
         //console.log(url)

         lastURL = url

         promptButton(url, buttonPresets["pending"])

         const state = await getCreatorState(url)

         promptButton(url, buttonPresets[state])
      }
   }
   else
   {
      lastURL = null

      PROMPT_BTN.style.display = "none"
   }
}





const buttonPresets = {
   "found": {stateText: "Found", textColor: "white", backgroundColor: "green"},
   "incomplete": {stateText: "Incomplete", textColor: "black", backgroundColor: "gold"},
   "missing": {stateText: "Missing", textColor: "white", backgroundColor: "red"},
   "pending": {stateText: "Pending...", textColor: "white", backgroundColor: "gray"},
}


function promptButton(url, {stateText, textColor, backgroundColor})
{
   PROMPT_BTN.href = url;
   PROMPT_BTN.innerText = "Kemono Creator: " + stateText;

   PROMPT_BTN.style.color = textColor;
   PROMPT_BTN.style.backgroundColor = backgroundColor;

   PROMPT_BTN.style.display = "block";
}


function appendCSS(css)
{
   const style = document.createElement("style");
   style.appendChild(document.createTextNode(css));
   document.head.append(style);
}


async function getCreatorState(url)
{
   if (url)
   {
      const response = await new Promise(resolve =>
      {
         GM.xmlHttpRequest({
            url: url,
            method: "HEAD",
            onload: resolve
         });
      });

      switch (response.finalUrl)
      {
         case url: return "found";
         case "https://kemono.su/artists": case "https://coomer.su/artists": return "missing";
         default: return "incomplete";
      }
   }
}





const domainMethods = {
   "patreon.com": getRedirectURLFromPatreon,
   "fanbox.cc": getRedirectURLFromFanbox,
   "pixiv.net": getRedirectURLFromPixiv,
   "fantia.jp": getRedirectURLFromFantia,
   "boosty.to": getRedirectURLFromBoosty,
   "dlsite.com": getRedirectURLFromDLsite,
   "gumroad.com": getRedirectURLFromGumroad,
   "subscribestar.com": getRedirectURLFromSubscribeStar,
   "subscribestar.adult": getRedirectURLFromSubscribeStar,
   "onlyfans.com": getRedirectURLFromOnlyFans,
}


function getRedirectURLFromPatreon()
{
   function getPatreonUserID() //html
   {
      const userIDPrefix = `"creator":{"data":{"id":"`
      const userIDSuffix = `"`
      return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
   }
   function getPatreonPostID() //url
   {
      const postIDPrefix = `posts`
      const postIDInternalPrefix = `-`
      return getURLPathAfter(postIDPrefix)?.split(postIDInternalPrefix).at(-1) ?? null
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "patreon",
      userID: getPatreonUserID(),
      postID: getPatreonPostID()
   })
}

function getRedirectURLFromFanbox()
{
   function getFanboxUserID() //html
   {
      const userIDPrefix = `creator/`
      const userIDSuffix = `/`
      return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
   }
   function getFanboxPostID() //url
   {
      const postIDPrefix = `posts`
      return getURLPathAfter(postIDPrefix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "fanbox",
      userID: getFanboxUserID(),
      postID: getFanboxPostID()
   })
}

function getRedirectURLFromPixiv()
{
   function getPixivUserID() //url
   {
      const userIDPrefix = `users`
      return getURLPathAfter(userIDPrefix)
   }
   function getPixivUserID2nd() //html
   {
      const userIDPrefix = `users/`
      const userIDSuffix = `"`
      return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "fanbox",
      userID: getPixivUserID() ?? getPixivUserID2nd()
   })
}

function getRedirectURLFromFantia()
{
   function getFantiaUserID() //url
   {
      const userIDPrefix = `fanclubs`
      return getURLPathAfter(userIDPrefix)
   }
   function getFantiaUserID2nd() //html
   {
      const userIDPrefix = `fanclubs/`
      const userIDSuffix = `"`
      return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
   }
   function getFantiaPostID() //url
   {
      const postIDPrefix = `posts`
      return getURLPathAfter(postIDPrefix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "fantia",
      userID: getFantiaUserID() ?? getFantiaUserID2nd(),
      postID: getFantiaPostID()
   })
}

function getRedirectURLFromBoosty()
{
   function getBoostyUserID() //url
   {
      const userIDPrefix = `boosty.to`
      return getURLPathAfter(userIDPrefix)
   }
   function getBoostyPostID() //url
   {
      const postIDPrefix = `posts`
      return getURLPathAfter(postIDPrefix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "boosty",
      userID: getBoostyUserID(),
      postID: getBoostyPostID()
   })
}

function getRedirectURLFromDLsite()
{
   function getDLsiteUserID() //html
   {
      const userIDPrefix = `maker_id/`
      const userIDSuffix = `.`
      return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
   }
   function getDLsitePostID() //url
   {
      const postIDPrefix = `product_id`
      const postIDMatch = `\\d`
      let postID = getURLPathAfter(postIDPrefix).match(new RegExp(postIDMatch, "g")).join("")
      return postID ? "RE" + postID : null
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "dlsite",
      userID: getDLsiteUserID(),
      postID: getDLsitePostID()
   })
}

function getRedirectURLFromGumroad()
{
   function getGumroadUserID() //html
   {
      const elementSelector = `script.js-react-on-rails-component`
      const userIDPrefix = `id":"`
      const userIDSuffix = `"`
      return getStringBetween(document.querySelector(elementSelector)?.innerText, userIDPrefix, userIDSuffix)
   }
   function getGumroadPostID() //url
   {
      const postIDPrefix = `l`
      return getURLPathAfter(postIDPrefix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "gumroad",
      userID: getGumroadUserID(),
      postID: getGumroadPostID()
   })
}

function getRedirectURLFromSubscribeStar()
{
   function getSubScribeStarUserID() //html
   {
      const elementSelector = `img[data-type="avatar"]`
      return document.querySelector(elementSelector)?.alt.toLowerCase() ?? null
   }
   function getSubScribeStarPostID() //url
   {
      const postIDPrefix = `posts`
      return getURLPathAfter(postIDPrefix)
   }

   return getRedirectURL({
      domain: "kemono.su",
      service: "subscribestar",
      userID: getSubScribeStarUserID(),
      postID: getSubScribeStarPostID()
   })
}

function getRedirectURLFromOnlyFans()
{
   function getOnlyFansUserID() //html
   {
      const elementSelector = `#content a.g-avatar`
      return document.querySelector(elementSelector)?.getAttribute("href").slice(1)
   }
   function getOnlyFansPostID() //url
   {
      const elementSelector = `#content div.b-post`
      return document.querySelector(elementSelector)?.id.slice(7)
   }

   return getRedirectURL({
      domain: "coomer.su",
      service: "onlyfans",
      userID: getOnlyFansUserID(),
      postID: getOnlyFansPostID()
   })
}



function getStringBetween(string, prefix, suffix)
{
   let begIndex = string.indexOf(prefix)
   if (begIndex == -1) return null
   else begIndex += prefix.length

   let endIndex = string.indexOf(suffix, begIndex)
   if (endIndex == -1) endIndex = undefined;
   let result = string.slice(begIndex, endIndex)

   return result
}

function getURLPathAfter(string)
{
   let urlSplit = (window.location.hostname + window.location.pathname).split("/")
   let pathIndex = urlSplit.indexOf(string) + 1

   if (pathIndex == 0) return null

   return urlSplit[pathIndex]
}

function getRedirectURL({domain, service, userID=null, postID=null} = {})
{
   let redirectURL = `https://${domain}/${service}`

   if (userID)
   {
      redirectURL += `/user/${userID}`

      if (postID)
      {
         redirectURL += `/post/${postID}`
      }
   }
   else return null

   return redirectURL
}