Kemono Browser

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

От 23.02.2024. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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