您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a button at the bottom right of all kemono & coomer supported creator websites (but fansly) that redirects to the corresponding kemono/coomer page.
当前为
// ==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 }); }); }