// ==UserScript==
// @name Kemono Browser
// @namespace Violentmonkey Scripts
// @version 1.8.0
// @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 *://*.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 *://*.candfans.jp/*
// @connect kemono.su
// @connect coomer.su
// @icon https://kemono.su/static/favicon.ico
// @grant GM.xmlHttpRequest
// @grant GM.getResourceUrl
// @grant GM.openInTab
// @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";
let 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) ")"; }
`
if (window.location.hostname.includes("discord.com")) KEMONO_BTN_CSS += `#${KEMONO_BTN_ID} { transform: translateY(-75px); }`;
///////////////////////////////////////////
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();
if (this.target == "_self") GM.openInTab(this.href);
else window.open(this.href, "_self");
}
});
// 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,
"discord.com": getKemonoURLFromDiscord,
"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: extract(select("#__NEXT_DATA__"), '"creator":{"data":{"id":"', '"'),
postID: extractNextUrlPath("posts")?.split("-")?.at(-1)
})
}
function getKemonoURLFromFanbox()
{
return getKemonoURL({
domain: "kemono.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 getKemonoURLFromPixiv()
{
return getKemonoURL({
domain: "kemono.su",
service: "fanbox",
userID: extractNextUrlPath("users") ?? select("button[data-gtm-user-id]", "data-gtm-user-id")
})
}
function getKemonoURLFromDiscord()
{
const splitPathname = window.location.pathname.split("/");
if (splitPathname[1] == "channels" && splitPathname[2])
{
let redirectURL = `https://kemono.su/discord/server/${splitPathname[2]}`;
if (splitPathname[3]) redirectURL += `#${splitPathname[3]}`;
return redirectURL;
}
else return null;
}
function getKemonoURLFromFantia()
{
return getKemonoURL({
domain: "kemono.su",
service: "fantia",
userID: extractNextUrlPath("fanclubs") ?? extract(select(".fanclub-header > a[href]", "href"), "/fanclubs/", "/"),
postID: extractNextUrlPath("posts") ?? extractNextUrlPath("products")
})
}
function getKemonoURLFromBoosty()
{
return getKemonoURL({
domain: "kemono.su",
service: "boosty",
userID: extractNextUrlPath("/"),
postID: extractNextUrlPath("posts")
})
}
function getKemonoURLFromDLsite()
{
const addRE = str => str ? "RE" + str : null
return getKemonoURL({
domain: "kemono.su",
service: "dlsite",
userID: extract(select(".maker_name[itemprop=brand] > a", "href"), "/maker_id/", "."),
postID: addRE(extractNextUrlPath("product_id")?.replace(/\D/g, ""))
})
}
function getKemonoURLFromGumroad()
{
const json = select("script.js-react-on-rails-component")
return getKemonoURL({
domain: "kemono.su",
service: "gumroad",
userID: extract(json, '"external_id":"', '"') ?? extract(json, '"seller":{"id":"', '"'),
postID: select('meta[property="product:retailer_item_id"]', "content")
})
}
function getKemonoURLFromSubscribeStar()
{
return getKemonoURL({
domain: "kemono.su",
service: "subscribestar",
userID: select('img[data-type="avatar"]', "alt").toLowerCase(),
postID: extractNextUrlPath("posts")
})
}
function getKemonoURLFromOnlyFans()
{
return getKemonoURL({
domain: "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, "")
})
}
function getKemonoURLFromCandFans()
{
return getKemonoURL({
domain: "coomer.su",
service: "candfans",
userID: extract(select("div.v-main__wrap"), "user/", "/"),
postID: extractNextUrlPath("show")
})
}
/**
* 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
);
}
// check if the creator exists on kemono
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 {statusText: "missing", statusCode: 404};
else
{
if (url.includes("#") && channels.some(channel => channel.id == url.split("#")[1]))
{
return {statusText: "found", statusCode: 200};
}
else return {statusText: "incomplete", statusCode: 303};
}
}
else return {statusText: "error", statusCode: response.status};
}
else
{
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
});
});
}