Kemono Browser

Adds a button at the bottom right of all kemono, coomer & nekohouse supported creator websites that redirects to the corresponding page.

Versão de: 05/11/2024. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name        Kemono Browser
// @namespace   Violentmonkey Scripts
// @version     1.9.6
// @description Adds a button at the bottom right of all kemono, coomer & nekohouse supported creator websites that redirects to the corresponding page.
// @author      zWolfrost
// @license     MIT
// @match       *://*.patreon.com/*
// @match       *://*.fanbox.cc/*
// @match       *://*.pixiv.net/*
// @match       *://*.discord.com/*
// @match       *://*.fantia.jp/*
// @match       *://*.boosty.to/*
// @match       *://*.dlsite.com/*
// @match       *://*.gumroad.com/*
// @match       *://*.subscribestar.com/*
// @match       *://*.subscribestar.adult/*
// @match       *://*.onlyfans.com/*
// @match       *://*.fansly.com/*
// @match       *://*.candfans.jp/*
// @match       *://*.x.com/*
// @connect     kemono.su
// @connect     coomer.su
// @connect     nekohouse.su
// @connect     fansly.com
// @icon        https://kemono.su/static/favicon.ico
// @grant       GM.xmlHttpRequest
// @grant       GM.getResourceUrl
// @grant       GM.openInTab
// @resource    kemonoIcon https://i.postimg.cc/D0K6jqjV/icon.png
// @resource    coomerIcon https://i.postimg.cc/D0K6jqjV/icon.png
// @resource    nekohouseIcon https://i.postimg.cc/c6qfxKSp/icon.png
// @resource    updateIcon https://i.postimg.cc/YS0rkk7L/icon.png
// @noframes
// ==/UserScript==
"use strict";


///////////////// OPTIONS /////////////////

// Buttons to include
const BUTTONS = {
	KEMONO: true,
	COOMER: true,
	NEKOHOUSE: false
}

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

// Button classes to apply
const BUTTONS_CLASSES = {
	"include-icon": true,
	"include-text": true,
	"animate-click": true
}

// Buttons CSS
const BUTTONS_CSS = `
#_kemono-browser-container {
	--status-prefix: "Creator: ";
}

/* WEBSITE-SPECIFIC BUTTON TRANSLATION */
#_kemono-browser-container.discord { transform: translate(-248px, -75px); }


#_kemono-browser-container {
	display: block !important;
	position: fixed !important;

	z-index: 999999 !important;
	right: 0 !important;
	bottom: 0 !important;
}


#_kemono-browser-container > a {
	display: none !important;
	position: relative !important;

	min-width: 1vh !important;
	min-height: 1vh !important;
	max-width: max-content !important;
	max-height: max-content !important;

	align-items: center !important;
	justify-content: center !important;
	gap: 0.3vh !important;

	border: 0.2vh solid black !important;
	border-radius: 0.4vh !important;
	padding: 0.6vh !important;
	margin: 1.2vh !important;
	margin-left: auto !important;

	font-family: arial !important;
	font-weight: bold !important;
	font-size: 1.9vh !important;

	line-height: normal !important;
	text-decoration: none !important;
	cursor: pointer !important;
	user-select: none !important;

	transition-property: top, right, bottom, left, box-shadow, background-color, color !important;
	transition-duration: 0.05s !important;
	transition-timing-function: ease-out !important;
}
#_kemono-browser-container > a.disabled {
	pointer-events: none !important;
	opacity: 0.5 !important;
}
#_kemono-browser-container > a:hover { filter: brightness(90%); }
#_kemono-browser-container > a:active { filter: brightness(75%); }


#_kemono-browser-container > a > img {
	display: none !important;
}
#_kemono-browser-container > a.include-icon > img {
	display: inline-block !important;
	width: 2.3vh !important;
	height: 2.3vh !important;
}
#_kemono-browser-container > a.disabled[data-status=update] > img {
	animation: rotating 1s linear infinite !important;
}
@keyframes rotating {
	to { transform: rotate(360deg); }
}


#_kemono-browser-container > a.animate-click {
	bottom: 0.0vh;
	right: 0.0vh;
	box-shadow: black 0.05vh 0.05vh, black 0.1vh 0.1vh, black 0.15vh 0.15vh, black 0.2vh 0.2vh,
		black 0.25vh 0.25vh, black 0.3vh 0.3vh, black 0.35vh 0.35vh, black 0.4vh 0.4vh;
}
#_kemono-browser-container > a.animate-click:active {
	bottom: -0.4vh;
	right: -0.4vh;
	box-shadow: none;
}


#_kemono-browser-container > a[data-status] { display: flex !important; background-color: #444444; color: white; }
#_kemono-browser-container > a.include-text[data-status]::after { content: var(--status-prefix) "Unknown (error " attr(data-status) ")"; }

#_kemono-browser-container > a[data-status=found] { background-color: green; color: white; }
#_kemono-browser-container > a.include-text[data-status=found]::after { content: var(--status-prefix) "Found"; }

#_kemono-browser-container > a[data-status=incomplete] { background-color: gold; color: black; }
#_kemono-browser-container > a[data-status=incomplete] > img { filter: invert(1); }
#_kemono-browser-container > a.include-text[data-status=incomplete]::after { content: var(--status-prefix) "Incomplete"; }

#_kemono-browser-container > a[data-status=missing] { background-color: red; color: white; }
#_kemono-browser-container > a.include-text[data-status=missing]::after { content: var(--status-prefix) "Missing"; }

#_kemono-browser-container > a[data-status=pending] { background-color: gray; color: white; }
#_kemono-browser-container > a.include-text[data-status=pending]::after { content: var(--status-prefix) "Pending..."; }

#_kemono-browser-container > a[data-status=update] { background-color: gray; color: white; }
#_kemono-browser-container > a[data-status=update]::after { display: none; }
#_kemono-browser-container > a.disabled[data-status=update]::after { display: inline; content: "Waiting to avoid hitting rate-limit..."; }
`;


////////////// BUTTONS STUFF //////////////

// initialize buttons
function initButtons() {
	// get domain & classes to include
	const domain = window.location.hostname.split(".").slice(-2).join(".");

	// append css to head
	document.head.appendChild(document.createElement("style")).innerHTML = BUTTONS_CSS;

	// create button container
	const BUTTONS_CONTAINER = document.createElement("div");
	BUTTONS_CONTAINER.id = "_kemono-browser-container";
	BUTTONS_CONTAINER.classList.add(domain.split(".")[0]);
	document.body.prepend(BUTTONS_CONTAINER);

	// create update button
	BUTTONS.UPDATE = true;

	for (let key in BUTTONS) {
		if (BUTTONS[key]) {
			// create button element
			BUTTONS[key] = document.createElement("a");

			// set button icon
			let name = key.toLocaleLowerCase();
			BUTTONS[key].id = `_${name}-btn`;
			const ICON = document.createElement("img");
			GM.getResourceUrl(`${name}Icon`).then(url => {ICON.src = url});
			BUTTONS[key].prepend(ICON);

			// set button attributes
			let classes = Object.keys(BUTTONS_CLASSES).filter(key => BUTTONS_CLASSES[key]);
			BUTTONS[key].classList.add(...classes);
			BUTTONS[key].target = OPEN_IN_NEW_TAB ? "_blank" : "_self";
			BUTTONS[key].draggable = false;
			BUTTONS[key].querySelector("img").draggable = false;

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

					if (this.target == "_self") GM.openInTab(this.href);
					else window.open(this.href, "_self");
				}
			});

			// append button to body
			BUTTONS_CONTAINER.prepend(BUTTONS[key]);
		}
		else {
			delete BUTTONS[key];
		}
	}

	if (domain in rateLimitedDomainMethods) {
		BUTTONS.UPDATE.classList.add("include-icon");
		BUTTONS.UPDATE.dataset.status = "update";
		BUTTONS.UPDATE.addEventListener("click", function() {
			rateLimitedDomainMethods[domain]().then(updateButtons)

			// wait a bit before re-enabling the button to avoid hitting the rate limit
			this.classList.add("disabled");
			setTimeout(() => this.classList.remove("disabled"), 3000);
		});
		delete BUTTONS.UPDATE;
	}
	else if (domain in domainMethods) {
		setInterval(() => updateButtons(domainMethods[domain]()), 222);
	}
}

// update buttons
function updateButtons(urls) {
	for (let key in BUTTONS) {
		let newURL = urls[key] ?? "";
		if (newURL != BUTTONS[key].dataset.href) {
			if (newURL) {
				// set the button to the pending status, while waiting for a response
				BUTTONS[key].href = newURL;
				BUTTONS[key].dataset.href = newURL;
				BUTTONS[key].dataset.status = "pending";

				getCreatorStatus(BUTTONS[key].dataset.href).then(status => {
					if (status == "incomplete" && newURL.includes("/post")) {
						BUTTONS[key].href = newURL.split("/").slice(0, -2).join("/");
					}

					if (BUTTONS[key].dataset.href == newURL) {
						BUTTONS[key].dataset.status = status;
					}
				});
			}
			else {
				BUTTONS[key].href = "";
				delete BUTTONS[key].dataset.href;
				delete BUTTONS[key].dataset.status;
			}
		}
	}
}


////////// URLs EXTRACTION STUFF //////////

const domainMethods = {
	"patreon.com": extractURLFromPatreon,
	"fanbox.cc": extractURLFromFanbox,
	"pixiv.net": extractURLFromPixiv,
	"discord.com": extractURLFromDiscord,
	"fantia.jp": extractURLFromFantia,
	"boosty.to": extractURLFromBoosty,
	"dlsite.com": extractURLFromDlsite,
	"gumroad.com": extractURLFromGumroad,
	"subscribestar.com": extractURLFromSubscribeStar,
	"subscribestar.adult": extractURLFromSubscribeStar,
	"onlyfans.com": extractURLFromOnlyFans,
	"candfans.jp": extractURLFromCandFans,
	"x.com": extractURLFromTwitter
}
const rateLimitedDomainMethods = {
	"fansly.com": extractURLFromFansly
}

// create the creator url with the given parameters
function compileURL({domains, service, userID=null, postID=null} = {}) {
	let obj = {};

	for (let domain of domains) {
		let redirectURL = `https://${domain}/${service}`;

		if (userID) {
			redirectURL += `/user/${userID}`;

			if (postID) {
				redirectURL += `/post/${postID}`;
			}
		}
		else continue;

		obj[domain.split(".").at(-2).toUpperCase()] = redirectURL;
	}

	return obj;
}

function extractURLFromPatreon() {
	return compileURL({
		domains: ["kemono.su"],
		service: "patreon",
		userID: extract(select("#__NEXT_DATA__"), '"creator":{"data":{"id":"', '"'),
		postID: extractNextUrlPath("posts")?.split("-")?.at(-1)
	})
}
function extractURLFromFanbox() {
	return compileURL({
		domains: ["kemono.su", "nekohouse.su"],
		service: "fanbox",
		userID: extract(select('meta[property="og:image"]', "content"), "/creator/", "/") ??
			extract(select(".styled__StyledUserIcon-sc-1upaq18-10[style]", "style"), "/user/", "/") ??
			extract(select('a[href^="https://www.pixiv.net/users/"]', "href"), "/users/", "/"),
		postID: extractNextUrlPath("posts")
	})
}
function extractURLFromPixiv() {
	return compileURL({
		domains: ["kemono.su", "nekohouse.su"],
		service: "fanbox",
		userID: extractNextUrlPath("users") ??
			select("button[data-gtm-user-id]", "data-gtm-user-id") ??
			select("a.user-details-icon[href]", "href")?.split("/").at(-1),
	})
}
function extractURLFromDiscord() {
	const pathname = window.location.pathname.split("/");

	const serverID = /\d/.test(pathname[2]) ? `${pathname[2]}/${pathname?.[3] ?? ""}` : null

	return serverID ? {
		"KEMONO": `https://kemono.su/discord/server/${serverID}`
	} : {}
}
function extractURLFromFantia() {
	return compileURL({
		domains: ["kemono.su", "nekohouse.su"],
		service: "fantia",
		userID: extractNextUrlPath("fanclubs") ?? extract(select(".fanclub-header > a[href]", "href"), "/fanclubs/", "/"),
		postID: extractNextUrlPath("posts") ?? extractNextUrlPath("products")
	})
}
function extractURLFromBoosty() {
	return compileURL({
		domains: ["kemono.su"],
		service: "boosty",
		userID: extractNextUrlPath("/"),
		postID: extractNextUrlPath("posts")
	})
}
function extractURLFromDlsite() {
	return compileURL({
		domains: ["kemono.su"],
		service: "dlsite",
		userID: extractNextUrlPath("maker_id", ".html") ?? extract(select(".maker_name[itemprop=brand] > a", "href"), "/maker_id/", "."),
		postID: concatOrFalsy("RE", extractNextUrlPath("product_id")?.replace(/\D/g, ""))
	})
}
function extractURLFromGumroad() {
	const json = select("script.js-react-on-rails-component[data-component-name^=Profile]")

	return compileURL({
		domains: ["kemono.su"],
		service: "gumroad",
		userID: extract(json, '"external_id":"', '"') ?? extract(json, '"seller":{"id":"', '"'),
		postID: select('meta[property="product:retailer_item_id"]', "content")
	})
}
function extractURLFromSubscribeStar() {
	return compileURL({
		domains: ["kemono.su", "nekohouse.su"],
		service: "subscribestar",
		userID: select('img[data-type="avatar"][alt]', "alt").toLowerCase(),
		postID: extractNextUrlPath("posts")
	})
}
function extractURLFromOnlyFans() {
	return compileURL({
		domains: ["coomer.su"],
		service: "onlyfans",
		userID: select("#content .g-avatar[href]", "href")?.split("/")[1],
		postID: select("div.b-post:not(.is-not-post-page)", "id")?.replace(/\D/g, "")
	})
}
async function extractURLFromFansly() {
	let userID = null;

	const userName = select("div.feed-item-name a.username-wrapper", "href")?.split("/").at(-1) ?? select("meta[property='og:title']", "content")?.slice(10);
	if (userName) {
		const res = await request({ method: "GET", url: `https://apiv3.fansly.com/api/v1/account?usernames=${userName}&ngsw-bypass=true` });
		userID = JSON.parse(res.responseText)?.response?.[0].id ?? null;
	}

	return compileURL({
		domains: ["coomer.su"],
		service: "fansly",
		userID: userID,
		postID: extractNextUrlPath("post")
	})
}
function extractURLFromCandFans() {
	return compileURL({
		domains: ["coomer.su"],
		service: "candfans",
		userID: extract(select("div.v-main__wrap"), "user/", "/"),
		postID: extractNextUrlPath("show")
	})
}
function extractURLFromTwitter() {
	return compileURL({
		domains: ["nekohouse.su"],
		service: "twitter",
		userID: select('div[data-testid="UserName"]') ? extractNextUrlPath("/") : null,
		postID: extractNextUrlPath("status")
	})
}


////////////// UTILITY STUFF //////////////

/**
 * get query element attribute shorthand
 * @returns {string}
 */
function select(query, attribute=null) {
	const el = document.querySelector(query);
	return attribute ? el?.getAttribute(attribute) : el?.innerHTML
}

/**
 * get string between a prefix and a suffix
 * @returns {string}
 */
function extract(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 next path segment in url pathname after a prefix.
 * if prefix is blank, return the first path segment.
 * @returns {string}
 */
function extractNextUrlPath(prefix, suffix="/") {
	return extract(window.location.pathname, (prefix == "/") ? "/" : `/${prefix}/`, suffix);
}

/**
 * concatenate strings if they are truthy, otherwise return the first falsy string
 * @returns {string}
 */
function concatOrFalsy(...args)
{
	for (let arg of args) {
		if (!arg) return arg;
	}
	return args.join("");
}

/**
 * check if the creator exists on kemono
 * @returns {string}
 */
async function getCreatorStatus(url) {
	if (url) {
		const Url = new URL(url);

		if (Url.hostname == "kemono.su") {
			if (Url.pathname.split("/")[1] == "discord") {
				const response = await request({ method: "GET", url: `https://${Url.hostname}/api/v1/discord/channel/lookup/${Url.pathname.split("/")[3]}` });

				if (response.status == 200) {
					let channels = JSON.parse(response.responseText);

					if (channels.length == 0) return "missing";
					else {
						if (channels.some(channel => channel.id == Url.pathname.split("/")?.[4])) return "found";
						else return "incomplete";
					}
				}
				else return response.status;
			}
			else {
				const is_post = Url.pathname.includes("/post");

				if (is_post) {
					const postResponse = await request({ method: "HEAD", url: `https://${Url.hostname}/api/v1${Url.pathname}` });

					if (postResponse.status == 200 || postResponse.status == 202) return "found";
					Url.pathname = Url.pathname.split("/").slice(0, -2).join("/");
				}

				const response = await request({ method: "HEAD", url: `https://${Url.hostname}/api/v1${Url.pathname}/profile` });

				if (response.status == 200 || response.status == 202) return is_post ? "incomplete" : "found";
				else if (response.status == 404) return "missing";
				else return response.status;
			}
		}
		else if (Url.hostname == "coomer.su" || Url.hostname == "nekohouse.su") {
			const response = await request({ method: "HEAD", url: url });
			const redirectUrl = response?.finalUrl;

			if (response.status == 200) {
				if (redirectUrl == url) return "found";
				else if (redirectUrl.includes("user")) return "incomplete";
				else if (redirectUrl.includes("artists")) return "missing";
			}
			else return response.status;
		}
	}

	return 400;
}

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


initButtons();