Kemono Browser

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

Ekde 2024/02/23. Vidu La ĝisdata versio.

// ==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"
}