Greasy Fork is available in English.

Kemono Browser

Adds a button at the bottom right of all kemono & coomer supported creator websites (but fansly) that redirects to the corresponding kemono/coomer page.

// ==UserScript==
// @name          Kemono Browser
// @namespace     Violentmonkey Scripts
// @version       1.7.1
// @description   Adds a button at the bottom right of all kemono & coomer supported creator websites (but fansly) 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. note that ctrl+clicking the button does the opposite of the default behavior.
const OPEN_IN_NEW_TAB = true;

// default button classes. remove them to customize the button!
const KEMONO_BTN_CLASSES = ["include-icon", "include-text", "animate-click"]

// 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;
}
#${KEMONO_BTN_ID}[data-status-text]
{
	display: flex !important;
	align-items: center !important;
	justify-content: center !important;
	gap: 2px !important;
}
#${KEMONO_BTN_ID}:hover { filter: brightness(90%); }
#${KEMONO_BTN_ID}:active { filter: brightness(80%); }


#${KEMONO_BTN_ID} img
{
	display: none !important;
}
#${KEMONO_BTN_ID}.include-icon img
{
	display: inline-block !important;
	width: 24px !important;
	height: 24px !important;
	margin: 0px 2px !important;
}


#${KEMONO_BTN_ID}.animate-click
{
	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}.animate-click:active
{
	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}[data-status-text=found] { background-color: green; color: white; }
#${KEMONO_BTN_ID}.include-text[data-status-text=found]::after { content: "creator: found"; }

#${KEMONO_BTN_ID}[data-status-text=incomplete] { background-color: gold; color: black; }
#${KEMONO_BTN_ID}[data-status-text=incomplete] img { filter: brightness(1) invert(1); }
#${KEMONO_BTN_ID}.include-text[data-status-text=incomplete]::after { content: "creator: incomplete"; }

#${KEMONO_BTN_ID}[data-status-text=missing] { background-color: red; color: white; }
#${KEMONO_BTN_ID}.include-text[data-status-text=missing]::after { content: "creator: missing"; }

#${KEMONO_BTN_ID}[data-status-text=pending] { background-color: gray; color: white; }
#${KEMONO_BTN_ID}.include-text[data-status-text=pending]::after { content: "creator: pending..."; }

#${KEMONO_BTN_ID}[data-status-text=error] { background-color: #444444; color: white; }
#${KEMONO_BTN_ID}[data-status-text=error]::after { content: "creator: unknown (error " attr(data-status-code) ")"; }
`

///////////////////////////////////////////



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";
	KEMONO_BTN.draggable = false;

	// set button classes
	KEMONO_BTN.classList.add(...KEMONO_BTN_CLASSES);

	// 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);

	// add ctrl+click event listener
	KEMONO_BTN.addEventListener("click", function(e)
	{
		if (e.ctrlKey)
		{
			e.preventDefault();

			window.open(this.href, this.target == "_blank" ? "_self" : "_blank");
		}
	});

	// 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 status, while waiting for kemono to respond
			KEMONO_BTN.href = url;
			Object.assign(KEMONO_BTN.dataset, {statusText: "pending", statusCode: 202});

			// check if the creator exists on kemono & set the button status accordingly
			getCreatorStatus(url).then(status => Object.assign(KEMONO_BTN.dataset, status));
		}
	}
	else
	{
		lastKemonoURL = null;

		delete KEMONO_BTN.dataset.statusText;
		delete KEMONO_BTN.dataset.statusCode;
	}
}



///////// 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.querySelector(".fanclub-header").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()
{
	const idText = document.querySelector(`script.js-react-on-rails-component`)?.innerText

	return getKemonoURL({
		domain: "kemono.su",
		service: "gumroad",
		userID: getStringBetween(idText, `"external_id":"`, `"`) ?? getStringBetween(idText, `"seller":{"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.: if url is "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 getCreatorStatus(url)
{
	if (url)
	{
		const response = await request({method: "HEAD", url: url});
		const redirectUrl = response?.finalUrl;

		//console.log(response);

		if (response.status == 200)
		{
			if (redirectUrl == url) return {statusText: "found", statusCode: 200};
			else if (redirectUrl.includes("user")) return {statusText: "incomplete", statusCode: 303};
			else if (redirectUrl.includes("artists")) return {statusText: "missing", statusCode: 404};
		}
		else return {statusText: "error", statusCode: response.status};
	}

	return {statusText: "error", statusCode: 400};
}

// make a request
function request(details)
{
	return new Promise((resolve, reject) =>
	{
		GM.xmlHttpRequest({
			...details,
			onload: resolve,
			onerror: reject
		});
	});
}