// ==UserScript==
// @name Kemono Browser
// @namespace Violentmonkey Scripts
// @version 1.5.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/*
// @match https://*.candfans.jp/*
// @connect kemono.su
// @connect coomer.su
// @icon https://kemono.su/static/favicon.ico
// @grant GM.xmlHttpRequest
// @grant GM.getResourceUrl
// @resource kemonoWhiteIcon https://i.postimg.cc/D0K6jqjV/icon.png
// ==/UserScript==
"use strict";
///////////////// OPTIONS /////////////////
//time in milliseconds for the script to update the button. i generally don't recommend setting it to less than 100.
const KEMONO_BTN_POLLING_MS = 222;
//whether to open the url in a new tab by default.
const OPEN_IN_NEW_TAB = true;
//kemono button css
const KEMONO_BTN_ID = "kemono-btn";
const KEMONO_BTN_CSS = `
#${KEMONO_BTN_ID}
{
display: none !important;
position: fixed !important;
bottom: 0px;
right: 0px;
z-index: 10000000 !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;
text-transform: capitalize !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;
}
#${KEMONO_BTN_ID}[class^="state"]
{
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 2px !important;
}
#${KEMONO_BTN_ID} img
{
width: 24px !important;
height: 24px !important;
margin: 0px 2px !important;
}
#${KEMONO_BTN_ID}:hover
{
filter: brightness(90%);
}
#${KEMONO_BTN_ID}:active
{
filter: brightness(80%);
bottom: -4px;
right: -4px;
box-shadow: none;
transition-property: bottom, right, box-shadow;
transition-duration: 0.05s;
transition-timing-function: ease-in-out;
}
#${KEMONO_BTN_ID}.state-found { background-color: green; color: white; }
#${KEMONO_BTN_ID}.state-found::after { content: "creator: found"; }
#${KEMONO_BTN_ID}.state-incomplete { background-color: gold; color: black; }
#${KEMONO_BTN_ID}.state-incomplete img { filter: brightness(1) invert(1); }
#${KEMONO_BTN_ID}.state-incomplete::after { content: "creator: incomplete"; }
#${KEMONO_BTN_ID}.state-missing { background-color: red; color: white; }
#${KEMONO_BTN_ID}.state-missing::after { content: "creator: missing"; }
#${KEMONO_BTN_ID}.state-pending { background-color: gray; color: white; }
#${KEMONO_BTN_ID}.state-pending::after { content: "creator: pending..."; }
#${KEMONO_BTN_ID}.state-error { background-color: gray; color: white; }
#${KEMONO_BTN_ID}.state-error::after { content: "creator: unknown (server error)"; }
`
///////////////////////////////////////////
let lastKemonoURL = "";
const KEMONO_BTN = document.createElement("a");
initKemonoButton();
setInterval(updateKemonoButton, KEMONO_BTN_POLLING_MS);
/////////// KEMONO BUTTON STUFF ///////////
// initialize kemono button
function initKemonoButton()
{
// append css to head
const appendCSS = css => document.head.appendChild(document.createElement("style")).innerHTML = css;
appendCSS(KEMONO_BTN_CSS);
// set button attributes
KEMONO_BTN.id = KEMONO_BTN_ID;
KEMONO_BTN.target = OPEN_IN_NEW_TAB ? "_blank" : "_self";
// add kemono icon
const KEMONO_ICON = document.createElement("img");
GM.getResourceUrl("kemonoWhiteIcon").then(url => KEMONO_ICON.src = url);
KEMONO_ICON.alt = "🐺";
KEMONO_BTN.prepend(KEMONO_ICON);
// append button to body
document.body.prepend(KEMONO_BTN);
}
// update kemono button
function updateKemonoButton()
{
// get page domain
const domain = window.location.hostname.split(".").slice(-2).join(".");
// get the respective kemono creator url using the correct domain method (see "domainMethods" object)
const url = domainMethods[domain]?.();
if (url)
{
if (url != lastKemonoURL)
{
// cache the url to prevent unnecessary requests
lastKemonoURL = url;
// set the button to the pending state, while waiting for kemono to respond
KEMONO_BTN.href = url;
KEMONO_BTN.className = "state-pending";
// check if the creator exists on kemono
getCreatorState(url).then(state =>
{
// set the button to the corresponding state (see "buttonPresets" object)
KEMONO_BTN.className = `state-${state}`;
})
}
}
else
{
lastKemonoURL = null;
KEMONO_BTN.className = "";
}
}
///////// WEBPAGE INSPECTION STUFF /////////
const domainMethods = {
"patreon.com": getKemonoURLFromPatreon,
"fanbox.cc": getKemonoURLFromFanbox,
"pixiv.net": getKemonoURLFromPixiv,
"fantia.jp": getKemonoURLFromFantia,
"boosty.to": getKemonoURLFromBoosty,
"dlsite.com": getKemonoURLFromDLsite,
"gumroad.com": getKemonoURLFromGumroad,
"subscribestar.com": getKemonoURLFromSubscribeStar,
"subscribestar.adult": getKemonoURLFromSubscribeStar,
"onlyfans.com": getKemonoURLFromOnlyFans,
"candfans.jp": getKemonoURLFromCandFans
}
// create the creator url with the given parameters
function getKemonoURL({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;
}
function getKemonoURLFromPatreon()
{
return getKemonoURL({
domain: "kemono.su",
service: "patreon",
userID: getStringBetween(document.documentElement.innerHTML, `"creator":{"data":{"id":"`, `"`),
postID: getURLNextPath(`posts`)?.split(`-`).at(-1)
})
}
function getKemonoURLFromFanbox()
{
return getKemonoURL({
domain: "kemono.su",
service: "fanbox",
userID: getStringBetween(document.querySelector(".styled__StyledUserIcon-sc-1upaq18-10[style]")?.style.backgroundImage, `user/`, `/`),
postID: getURLNextPath(`posts`)
})
}
function getKemonoURLFromPixiv()
{
return getKemonoURL({
domain: "kemono.su",
service: "fanbox",
userID: getURLNextPath(`users`) ?? document.querySelector("button[data-gtm-user-id]")?.getAttribute("data-gtm-user-id")
})
}
function getKemonoURLFromFantia()
{
return getKemonoURL({
domain: "kemono.su",
service: "fantia",
userID: getURLNextPath(`fanclubs`) ?? getStringBetween(document.getElementById(`main`).innerHTML, `fanclubs/`, `"`),
postID: getURLNextPath(`posts`) ?? getURLNextPath(`products`)
})
}
function getKemonoURLFromBoosty()
{
return getKemonoURL({
domain: "kemono.su",
service: "boosty",
userID: getURLNextPath(`boosty.to`),
postID: getURLNextPath(`posts`)
})
}
function getKemonoURLFromDLsite()
{
let addRE = str => str ? "RE" + str : null
return getKemonoURL({
domain: "kemono.su",
service: "dlsite",
userID: getStringBetween(document.documentElement.innerHTML, `maker_id/`, `.`),
postID: addRE(getURLNextPath(`product_id`)?.replace(/\D/g, ""))
})
}
function getKemonoURLFromGumroad()
{
return getKemonoURL({
domain: "kemono.su",
service: "gumroad",
userID: getStringBetween(document.querySelector(`script.js-react-on-rails-component`)?.innerText, `id":"`, `"`),
postID: document.querySelector(`meta[property="product:retailer_item_id"]`)?.content
})
}
function getKemonoURLFromSubscribeStar()
{
return getKemonoURL({
domain: "kemono.su",
service: "subscribestar",
userID: document.querySelector(`img[data-type="avatar"]`)?.alt.toLowerCase(),
postID: getURLNextPath(`posts`)
})
}
function getKemonoURLFromOnlyFans()
{
return getKemonoURL({
domain: "coomer.su",
service: "onlyfans",
userID: document.querySelector(`#content .g-avatar[href]`)?.getAttribute("href").split(`/`)[1],
postID: document.querySelector(`div.b-post:not(.is-not-post-page)`)?.id.replace(/\D/g, "")
})
}
function getKemonoURLFromCandFans()
{
return getKemonoURL({
domain: "coomer.su",
service: "candfans",
userID: getStringBetween(document.querySelector(`.v-main__wrap`).innerHTML, `user/`, `/`),
postID: getURLNextPath(`show`)
})
}
// get string between a prefix and a suffix
function getStringBetween(string, prefix, suffix)
{
if (string == null) return null;
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;
}
// get the url next path after a path. e.g.: "https://example.com/after/this", getURLNextPath("after") -> "this"
function getURLNextPath(string)
{
let urlSplit = (window.location.hostname + window.location.pathname).split("/");
let pathIndex = urlSplit.indexOf(string);
if (pathIndex == -1) return null;
return urlSplit[pathIndex + 1];
}
// check if the creator exists on kemono
async function getCreatorState(url)
{
if (url)
{
const response = await new Promise(resolve =>
{
GM.xmlHttpRequest({
url: url,
method: "HEAD",
onload: resolve
});
});
const redirectUrl = response?.finalUrl;
if (redirectUrl == url) return "found";
else if (redirectUrl.includes("user")) return "incomplete";
else if (redirectUrl.includes("artists")) return "missing";
}
return "error"
}