// ==UserScript==
// @name Kemono Browser
// @namespace Violentmonkey Scripts
// @version 1.3.1
// @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/*
// @icon https://kemono.su/static/favicon.ico
// @grant GM.xmlHttpRequest
// ==/UserScript==
"use strict"
const UPDATE_POLLING_MS = 222; //time in milliseconds for the script to update the button. i generally don't recommend setting it to less than 100.
const PROMPT_BTN = document.createElement("a");
initPromptButton();
let lastURL = "";
setInterval(update, UPDATE_POLLING_MS);
function initPromptButton()
{
const BTN_ANIMATIONS = true; //whether to enable button animations.
const OPEN_IN_NEW_TAB = true; //whether to open the url in a new tab by default.
const PROMPT_BTN_ID = "kemono-url-btn";
const BTN_BASE_CSS = `
#${PROMPT_BTN_ID}
{
display: none;
position: fixed !important;
bottom: 0px;
right: 0px;
z-index: 9999 !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;
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;
}
#${PROMPT_BTN_ID}:hover
{
filter: brightness(90%);
}
`
const BTN_ANIMATIONS_CSS = `
#${PROMPT_BTN_ID}:active
{
bottom: -4px;
right: -4px;
box-shadow: none;
filter: brightness(80%);
animation: press 0.05s ease-in-out;
}
@keyframes press
{
from
{
bottom: 0px;
right: 0px;
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;
}
to
{
bottom: -4px;
right: -4px;
}
}
`
appendCSS(BTN_BASE_CSS);
if (BTN_ANIMATIONS) appendCSS(BTN_ANIMATIONS_CSS);
PROMPT_BTN.id = PROMPT_BTN_ID;
PROMPT_BTN.target = OPEN_IN_NEW_TAB ? "_blank" : "_self";
document.body.prepend(PROMPT_BTN)
}
async function update()
{
const domain = window.location.hostname.split(".").slice(-2).join(".")
const url = domainMethods[domain]?.();
if (url)
{
if (url != lastURL)
{
//console.log(url)
lastURL = url
promptButton(url, buttonPresets["pending"])
const state = await getCreatorState(url)
promptButton(url, buttonPresets[state])
}
}
else
{
lastURL = null
PROMPT_BTN.style.display = "none"
}
}
const buttonPresets = {
"found": {stateText: "Found", textColor: "white", backgroundColor: "green"},
"incomplete": {stateText: "Incomplete", textColor: "black", backgroundColor: "gold"},
"missing": {stateText: "Missing", textColor: "white", backgroundColor: "red"},
"pending": {stateText: "Pending...", textColor: "white", backgroundColor: "gray"},
}
function promptButton(url, {stateText, textColor, backgroundColor})
{
PROMPT_BTN.href = url;
PROMPT_BTN.innerText = "Kemono Creator: " + stateText;
PROMPT_BTN.style.color = textColor;
PROMPT_BTN.style.backgroundColor = backgroundColor;
PROMPT_BTN.style.display = "block";
}
function appendCSS(css)
{
const style = document.createElement("style");
style.appendChild(document.createTextNode(css));
document.head.append(style);
}
async function getCreatorState(url)
{
if (url)
{
const response = await new Promise(resolve =>
{
GM.xmlHttpRequest({
url: url,
method: "HEAD",
onload: resolve
});
});
switch (response.finalUrl)
{
case url: return "found";
case "https://kemono.su/artists": case "https://coomer.su/artists": return "missing";
default: return "incomplete";
}
}
}
const domainMethods = {
"patreon.com": getRedirectURLFromPatreon,
"fanbox.cc": getRedirectURLFromFanbox,
"pixiv.net": getRedirectURLFromPixiv,
"fantia.jp": getRedirectURLFromFantia,
"boosty.to": getRedirectURLFromBoosty,
"dlsite.com": getRedirectURLFromDLsite,
"gumroad.com": getRedirectURLFromGumroad,
"subscribestar.com": getRedirectURLFromSubscribeStar,
"subscribestar.adult": getRedirectURLFromSubscribeStar,
"onlyfans.com": getRedirectURLFromOnlyFans,
}
function getRedirectURLFromPatreon()
{
function getPatreonUserID() //html
{
const userIDPrefix = `"creator":{"data":{"id":"`
const userIDSuffix = `"`
return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
}
function getPatreonPostID() //url
{
const postIDPrefix = `posts`
const postIDInternalPrefix = `-`
return getURLPathAfter(postIDPrefix)?.split(postIDInternalPrefix).at(-1) ?? null
}
return getRedirectURL({
domain: "kemono.su",
service: "patreon",
userID: getPatreonUserID(),
postID: getPatreonPostID()
})
}
function getRedirectURLFromFanbox()
{
function getFanboxUserID() //html
{
const userIDPrefix = `creator/`
const userIDSuffix = `/`
return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
}
function getFanboxPostID() //url
{
const postIDPrefix = `posts`
return getURLPathAfter(postIDPrefix)
}
return getRedirectURL({
domain: "kemono.su",
service: "fanbox",
userID: getFanboxUserID(),
postID: getFanboxPostID()
})
}
function getRedirectURLFromPixiv()
{
function getPixivUserID() //url
{
const userIDPrefix = `users`
return getURLPathAfter(userIDPrefix)
}
function getPixivUserID2nd() //html
{
const userIDPrefix = `users/`
const userIDSuffix = `"`
return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
}
return getRedirectURL({
domain: "kemono.su",
service: "fanbox",
userID: getPixivUserID() ?? getPixivUserID2nd()
})
}
function getRedirectURLFromFantia()
{
function getFantiaUserID() //url
{
const userIDPrefix = `fanclubs`
return getURLPathAfter(userIDPrefix)
}
function getFantiaUserID2nd() //html
{
const userIDPrefix = `fanclubs/`
const userIDSuffix = `"`
return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
}
function getFantiaPostID() //url
{
const postIDPrefix = `posts`
return getURLPathAfter(postIDPrefix)
}
return getRedirectURL({
domain: "kemono.su",
service: "fantia",
userID: getFantiaUserID() ?? getFantiaUserID2nd(),
postID: getFantiaPostID()
})
}
function getRedirectURLFromBoosty()
{
function getBoostyUserID() //url
{
const userIDPrefix = `boosty.to`
return getURLPathAfter(userIDPrefix)
}
function getBoostyPostID() //url
{
const postIDPrefix = `posts`
return getURLPathAfter(postIDPrefix)
}
return getRedirectURL({
domain: "kemono.su",
service: "boosty",
userID: getBoostyUserID(),
postID: getBoostyPostID()
})
}
function getRedirectURLFromDLsite()
{
function getDLsiteUserID() //html
{
const userIDPrefix = `maker_id/`
const userIDSuffix = `.`
return getStringBetween(document.documentElement.innerHTML, userIDPrefix, userIDSuffix)
}
function getDLsitePostID() //url
{
const postIDPrefix = `product_id`
const postIDMatch = `\\d`
let postID = getURLPathAfter(postIDPrefix).match(new RegExp(postIDMatch, "g")).join("")
return postID ? "RE" + postID : null
}
return getRedirectURL({
domain: "kemono.su",
service: "dlsite",
userID: getDLsiteUserID(),
postID: getDLsitePostID()
})
}
function getRedirectURLFromGumroad()
{
function getGumroadUserID() //html
{
const elementSelector = `script.js-react-on-rails-component`
const userIDPrefix = `id":"`
const userIDSuffix = `"`
return getStringBetween(document.querySelector(elementSelector)?.innerText, userIDPrefix, userIDSuffix)
}
function getGumroadPostID() //url
{
const postIDPrefix = `l`
return getURLPathAfter(postIDPrefix)
}
return getRedirectURL({
domain: "kemono.su",
service: "gumroad",
userID: getGumroadUserID(),
postID: getGumroadPostID()
})
}
function getRedirectURLFromSubscribeStar()
{
function getSubScribeStarUserID() //html
{
const elementSelector = `img[data-type="avatar"]`
return document.querySelector(elementSelector)?.alt.toLowerCase() ?? null
}
function getSubScribeStarPostID() //url
{
const postIDPrefix = `posts`
return getURLPathAfter(postIDPrefix)
}
return getRedirectURL({
domain: "kemono.su",
service: "subscribestar",
userID: getSubScribeStarUserID(),
postID: getSubScribeStarPostID()
})
}
function getRedirectURLFromOnlyFans()
{
function getOnlyFansUserID() //html
{
const elementSelector = `#content a.g-avatar`
return document.querySelector(elementSelector)?.getAttribute("href").slice(1)
}
function getOnlyFansPostID() //url
{
const elementSelector = `#content div.b-post`
return document.querySelector(elementSelector)?.id.slice(7)
}
return getRedirectURL({
domain: "coomer.su",
service: "onlyfans",
userID: getOnlyFansUserID(),
postID: getOnlyFansPostID()
})
}
function getStringBetween(string, prefix, suffix)
{
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
}
function getURLPathAfter(string)
{
let urlSplit = (window.location.hostname + window.location.pathname).split("/")
let pathIndex = urlSplit.indexOf(string) + 1
if (pathIndex == 0) return null
return urlSplit[pathIndex]
}
function getRedirectURL({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
}