// ==UserScript==
// @name Kemono Browser
// @namespace Violentmonkey Scripts
// @version 1.9.0
// @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
// @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
// @run-at document-end
// @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;
// default button classes. remove them to customize the button!
const BTN_CLASSES = ["include-icon", "include-text", "animate-click"];
// buttons css. customize the button style here!
const BTN_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: 0px !important;
bottom: 0px !important;
}
#_kemono-browser-container > a {
display: none !important;
position: relative !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: max-content !important;
max-height: max-content !important;
align-items: center !important;
justify-content: center !important;
gap: 2px !important;
border-radius: 4px !important;
padding: 4px !important;
margin: 12px !important;
margin-left: auto !important;
transform: scale(1) !important;
transform-origin: bottom right !important;
font-family: arial !important;
font-weight: bold !important;
font-size: 20px !important;
line-height: normal !important;
text-decoration: none !important;
cursor: pointer !important;
user-select: none !important;
transition-property: top, right, bottom, left, box-shadow !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 > img {
display: none !important;
}
#_kemono-browser-container > a.include-icon > img {
display: inline-block !important;
width: 24px !important;
height: 24px !important;
margin: 0px 2px !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: 0px;
right: 0px;
box-shadow: black 0.4px 0.4px, black 0.8px 0.8px, black 1.2px 1.2px, black 1.6px 1.6px, black 2px 2px,
black 2.4px 2.4px, black 2.8px 2.8px, black 3.2px 3.2px, black 3.6px 3.6px, black 4px 4px;
}
#_kemono-browser-container > a.animate-click:active {
bottom: -4px;
right: -4px;
box-shadow: none;
}
#_kemono-browser-container > a[data-status] { display: flex !important; background-color: #444444; color: white; }
#_kemono-browser-container > a:hover { filter: brightness(90%); }
#_kemono-browser-container > a:active { filter: brightness(80%); }
#_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() {
const domain = window.location.hostname.split(".").slice(-2).join(".");
// append css to head
document.head.appendChild(document.createElement("style")).innerHTML = BTN_CSS;
// create button container
const BTN_CONTAINER = document.createElement("div");
BTN_CONTAINER.id = "_kemono-browser-container";
BTN_CONTAINER.classList.add(domain.split(".")[0]);
document.body.prepend(BTN_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
BUTTONS[key].classList.add(...BTN_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
BTN_CONTAINER.prepend(BUTTONS[key]);
}
else {
delete BUTTONS[key];
}
}
if (domain in rateLimitedDomainMethods) {
const RATELIMIT_WAIT = 5000
BUTTONS.UPDATE.classList.add("include-icon");
BUTTONS.UPDATE.dataset.status = "update";
BUTTONS.UPDATE.addEventListener("click", function() {
this.classList.add("disabled");
rateLimitedDomainMethods[domain]().then(updateButtons)
new Promise(res => setTimeout(res, RATELIMIT_WAIT)).then(() => this.classList.remove("disabled"));
});
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].getAttribute("href")) {
if (newURL) {
// set the button to the pending status, while waiting for a response
BUTTONS[key].href = newURL;
BUTTONS[key].dataset.status = "pending";
getCreatorStatus(BUTTONS[key].href).then(status => BUTTONS[key].dataset.status = status);
}
else {
BUTTONS[key].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")
})
}
function extractURLFromDiscord() {
const splitPathname = window.location.pathname.split("/");
let userID = null
if (splitPathname[1] == "channels" && splitPathname[2]) {
userID = splitPathname[2] + concatOrFalsy("#", splitPathname[3]);
}
return compileURL({
domains: ["kemono.su"],
service: "discord",
userID: userID
})
}
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: 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")
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() {
const res = await request({ method: "GET", url: `https://apiv3.fansly.com/api/v1/account?usernames=${extractNextUrlPath("/")}&ngsw-bypass=true` })
return compileURL({
domains: ["coomer.su"],
service: "fansly",
userID: JSON.parse(res.responseText)?.response?.[0].id ?? null,
})
}
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 splitPathname = new URL(url).pathname.split("/");
if (splitPathname[1] == "discord") {
const response = await request({ method: "GET", url: `https://kemono.su/api/v1/discord/channel/lookup/${splitPathname[3]}` });
if (response.status == 200) {
let channels = JSON.parse(response.responseText);
if (channels.length == 0) return "missing";
else {
if (url.includes("#") && channels.some(channel => channel.id == url.split("#")[1])) return "found";
else return "incomplete";
}
}
else return response.status;
}
else {
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();