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