Kemono Browser

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

Verzia zo dňa 23.02.2024. Pozri najnovšiu verziu.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name				Kemono Browser
// @namespace		Violentmonkey Scripts
// @version			1.5.0
// @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/*

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