Kemono Browser

Adds a button at the bottom right of all kemono, coomer & nekohouse supported creator websites that redirects to the corresponding page.

  1. // ==UserScript==
  2. // @name Kemono Browser
  3. // @namespace Violentmonkey Scripts
  4. // @version 1.9.7
  5. // @description Adds a button at the bottom right of all kemono, coomer & nekohouse supported creator websites that redirects to the corresponding page.
  6. // @author zWolfrost
  7. // @license MIT
  8. // @match *://*.patreon.com/*
  9. // @match *://*.fanbox.cc/*
  10. // @match *://*.pixiv.net/*
  11. // @match *://*.discord.com/*
  12. // @match *://*.fantia.jp/*
  13. // @match *://*.boosty.to/*
  14. // @match *://*.dlsite.com/*
  15. // @match *://*.gumroad.com/*
  16. // @match *://*.subscribestar.com/*
  17. // @match *://*.subscribestar.adult/*
  18. // @match *://*.onlyfans.com/*
  19. // @match *://*.fansly.com/*
  20. // @match *://*.candfans.jp/*
  21. // @match *://*.x.com/*
  22. // @connect kemono.su
  23. // @connect coomer.su
  24. // @connect nekohouse.su
  25. // @connect fansly.com
  26. // @icon https://kemono.su/static/favicon.ico
  27. // @grant GM.xmlHttpRequest
  28. // @grant GM.getResourceUrl
  29. // @grant GM.openInTab
  30. // @resource kemonoIcon https://i.postimg.cc/D0K6jqjV/icon.png
  31. // @resource coomerIcon https://i.postimg.cc/D0K6jqjV/icon.png
  32. // @resource nekohouseIcon https://i.postimg.cc/c6qfxKSp/icon.png
  33. // @resource updateIcon https://i.postimg.cc/YS0rkk7L/icon.png
  34. // @noframes
  35. // ==/UserScript==
  36. "use strict";
  37.  
  38.  
  39. ///////////////// OPTIONS /////////////////
  40.  
  41. // Buttons to include
  42. const BUTTONS = {
  43. KEMONO: true,
  44. COOMER: true,
  45. NEKOHOUSE: false
  46. }
  47.  
  48. // Whether to open the url in a new tab by default
  49. // Note that ctrl+clicking the button does the opposite of the default behavior
  50. const OPEN_IN_NEW_TAB = true;
  51.  
  52. // Button classes to apply
  53. const BUTTONS_CLASSES = {
  54. "include-icon": true,
  55. "include-text": true,
  56. "animate-click": true
  57. }
  58.  
  59. // Buttons CSS
  60. const BUTTONS_CSS = `
  61. #_kemono-browser-container {
  62. --status-prefix: "Creator: ";
  63. }
  64.  
  65. /* WEBSITE-SPECIFIC BUTTON TRANSLATION */
  66. #_kemono-browser-container.discord { transform: translate(-248px, -75px); }
  67.  
  68.  
  69. #_kemono-browser-container {
  70. display: block !important;
  71. position: fixed !important;
  72.  
  73. z-index: 999999 !important;
  74. right: 0 !important;
  75. bottom: 0 !important;
  76. }
  77.  
  78.  
  79. #_kemono-browser-container > a {
  80. display: none !important;
  81. position: relative !important;
  82.  
  83. min-width: 1vh !important;
  84. min-height: 1vh !important;
  85. max-width: max-content !important;
  86. max-height: max-content !important;
  87.  
  88. align-items: center !important;
  89. justify-content: center !important;
  90. gap: 0.3vh !important;
  91.  
  92. border: 0.2vh solid black !important;
  93. border-radius: 0.4vh !important;
  94. padding: 0.6vh !important;
  95. margin: 1.2vh !important;
  96. margin-left: auto !important;
  97.  
  98. font-family: arial !important;
  99. font-weight: bold !important;
  100. font-size: 1.9vh !important;
  101.  
  102. line-height: normal !important;
  103. text-decoration: none !important;
  104. cursor: pointer !important;
  105. user-select: none !important;
  106.  
  107. transition-property: top, right, bottom, left, box-shadow !important;
  108. transition-duration: 0.05s !important;
  109. transition-timing-function: ease-out !important;
  110. }
  111. #_kemono-browser-container > a.disabled {
  112. pointer-events: none !important;
  113. opacity: 0.5 !important;
  114. }
  115. #_kemono-browser-container > a:hover { filter: brightness(90%); }
  116. #_kemono-browser-container > a:active { filter: brightness(75%); }
  117.  
  118.  
  119. #_kemono-browser-container > a > img {
  120. display: none !important;
  121. }
  122. #_kemono-browser-container > a.include-icon > img {
  123. display: inline-block !important;
  124. width: 2.3vh !important;
  125. height: 2.3vh !important;
  126. }
  127. #_kemono-browser-container > a.disabled[data-status=update] > img {
  128. animation: rotating 1s linear infinite !important;
  129. }
  130. @keyframes rotating {
  131. to { transform: rotate(360deg); }
  132. }
  133.  
  134.  
  135. #_kemono-browser-container > a.animate-click {
  136. bottom: 0.0vh;
  137. right: 0.0vh;
  138. box-shadow: black 0.05vh 0.05vh, black 0.1vh 0.1vh, black 0.15vh 0.15vh, black 0.2vh 0.2vh,
  139. black 0.25vh 0.25vh, black 0.3vh 0.3vh, black 0.35vh 0.35vh, black 0.4vh 0.4vh;
  140. }
  141. #_kemono-browser-container > a.animate-click:active {
  142. bottom: -0.4vh;
  143. right: -0.4vh;
  144. box-shadow: none;
  145. }
  146.  
  147.  
  148. #_kemono-browser-container > a[data-status] { display: flex !important; background-color: #444444; color: white; }
  149. #_kemono-browser-container > a.include-text[data-status]::after { content: var(--status-prefix) "Unknown (error " attr(data-status) ")"; }
  150.  
  151. #_kemono-browser-container > a[data-status=found] { background-color: green; color: white; }
  152. #_kemono-browser-container > a.include-text[data-status=found]::after { content: var(--status-prefix) "Found"; }
  153.  
  154. #_kemono-browser-container > a[data-status=incomplete] { background-color: gold; color: black; }
  155. #_kemono-browser-container > a[data-status=incomplete] > img { filter: invert(1); }
  156. #_kemono-browser-container > a.include-text[data-status=incomplete]::after { content: var(--status-prefix) "Incomplete"; }
  157.  
  158. #_kemono-browser-container > a[data-status=missing] { background-color: red; color: white; }
  159. #_kemono-browser-container > a.include-text[data-status=missing]::after { content: var(--status-prefix) "Missing"; }
  160.  
  161. #_kemono-browser-container > a[data-status=pending] { background-color: gray; color: white; }
  162. #_kemono-browser-container > a.include-text[data-status=pending]::after { content: var(--status-prefix) "Pending..."; }
  163.  
  164. #_kemono-browser-container > a[data-status=update] { background-color: gray; color: white; }
  165. #_kemono-browser-container > a[data-status=update]::after { display: none; }
  166. #_kemono-browser-container > a.disabled[data-status=update]::after { display: inline; content: "Waiting to avoid hitting rate-limit..."; }
  167. `;
  168.  
  169.  
  170. ////////////// BUTTONS STUFF //////////////
  171.  
  172. // initialize buttons
  173. function initButtons() {
  174. // get domain & classes to include
  175. const domain = window.location.hostname.split(".").slice(-2).join(".");
  176.  
  177. // append css to head
  178. document.head.appendChild(document.createElement("style")).innerHTML = BUTTONS_CSS;
  179.  
  180. // create button container
  181. const BUTTONS_CONTAINER = document.createElement("div");
  182. BUTTONS_CONTAINER.id = "_kemono-browser-container";
  183. BUTTONS_CONTAINER.classList.add(domain.split(".")[0]);
  184. document.body.prepend(BUTTONS_CONTAINER);
  185.  
  186. // create update button
  187. BUTTONS.UPDATE = true;
  188.  
  189. for (let key in BUTTONS) {
  190. if (BUTTONS[key]) {
  191. // create button element
  192. BUTTONS[key] = document.createElement("a");
  193.  
  194. // set button icon
  195. let name = key.toLocaleLowerCase();
  196. BUTTONS[key].id = `_${name}-btn`;
  197. const ICON = document.createElement("img");
  198. GM.getResourceUrl(`${name}Icon`).then(url => {ICON.src = url});
  199. BUTTONS[key].prepend(ICON);
  200.  
  201. // set button attributes
  202. let classes = Object.keys(BUTTONS_CLASSES).filter(key => BUTTONS_CLASSES[key]);
  203. BUTTONS[key].classList.add(...classes);
  204. BUTTONS[key].target = OPEN_IN_NEW_TAB ? "_blank" : "_self";
  205. BUTTONS[key].draggable = false;
  206. BUTTONS[key].querySelector("img").draggable = false;
  207.  
  208. // add ctrl+click event listener
  209. BUTTONS[key].addEventListener("click", function(e) {
  210. if (e.ctrlKey) {
  211. e.preventDefault();
  212.  
  213. if (this.target == "_self") GM.openInTab(this.href);
  214. else window.open(this.href, "_self");
  215. }
  216. });
  217.  
  218. // append button to body
  219. BUTTONS_CONTAINER.prepend(BUTTONS[key]);
  220. }
  221. else {
  222. delete BUTTONS[key];
  223. }
  224. }
  225.  
  226. if (domain in rateLimitedDomainMethods) {
  227. BUTTONS.UPDATE.classList.add("include-icon");
  228. BUTTONS.UPDATE.dataset.status = "update";
  229. BUTTONS.UPDATE.addEventListener("click", function() {
  230. rateLimitedDomainMethods[domain]().then(updateButtons)
  231.  
  232. // wait a bit before re-enabling the button to avoid hitting the rate limit
  233. this.classList.add("disabled");
  234. setTimeout(() => this.classList.remove("disabled"), 3000);
  235. });
  236. delete BUTTONS.UPDATE;
  237. }
  238. else if (domain in domainMethods) {
  239. setInterval(() => updateButtons(domainMethods[domain]()), 222);
  240. }
  241. }
  242.  
  243. // update buttons
  244. function updateButtons(urls) {
  245. for (let key in BUTTONS) {
  246. let newURL = urls[key] ?? "";
  247. if (newURL != BUTTONS[key].dataset.href) {
  248. if (newURL) {
  249. // set the button to the pending status, while waiting for a response
  250. BUTTONS[key].href = newURL;
  251. BUTTONS[key].dataset.href = newURL;
  252. BUTTONS[key].dataset.status = "pending";
  253.  
  254. getCreatorStatus(BUTTONS[key].dataset.href).then(status => {
  255. if (status == "incomplete" && newURL.includes("/post")) {
  256. BUTTONS[key].href = newURL.split("/").slice(0, -2).join("/");
  257. }
  258.  
  259. if (BUTTONS[key].dataset.href == newURL) {
  260. BUTTONS[key].dataset.status = status;
  261. }
  262. });
  263. }
  264. else {
  265. BUTTONS[key].href = "";
  266. delete BUTTONS[key].dataset.href;
  267. delete BUTTONS[key].dataset.status;
  268. }
  269. }
  270. }
  271. }
  272.  
  273.  
  274. ////////// URLs EXTRACTION STUFF //////////
  275.  
  276. const domainMethods = {
  277. "patreon.com": extractURLFromPatreon,
  278. "fanbox.cc": extractURLFromFanbox,
  279. "pixiv.net": extractURLFromPixiv,
  280. "discord.com": extractURLFromDiscord,
  281. "fantia.jp": extractURLFromFantia,
  282. "boosty.to": extractURLFromBoosty,
  283. "dlsite.com": extractURLFromDlsite,
  284. "gumroad.com": extractURLFromGumroad,
  285. "subscribestar.com": extractURLFromSubscribeStar,
  286. "subscribestar.adult": extractURLFromSubscribeStar,
  287. "onlyfans.com": extractURLFromOnlyFans,
  288. "candfans.jp": extractURLFromCandFans,
  289. "x.com": extractURLFromTwitter
  290. }
  291. const rateLimitedDomainMethods = {
  292. "fansly.com": extractURLFromFansly
  293. }
  294.  
  295. // create the creator url with the given parameters
  296. function compileURL({domains, service, userID=null, postID=null} = {}) {
  297. let obj = {};
  298.  
  299. for (let domain of domains) {
  300. let redirectURL = `https://${domain}/${service}`;
  301.  
  302. if (userID) {
  303. redirectURL += `/user/${userID}`;
  304.  
  305. if (postID) {
  306. redirectURL += `/post/${postID}`;
  307. }
  308. }
  309. else continue;
  310.  
  311. obj[domain.split(".").at(-2).toUpperCase()] = redirectURL;
  312. }
  313.  
  314. return obj;
  315. }
  316.  
  317. function extractURLFromPatreon() {
  318. return compileURL({
  319. domains: ["kemono.su"],
  320. service: "patreon",
  321. userID: extract(select("#__NEXT_DATA__"), '"creator":{"data":{"id":"', '"'),
  322. postID: extractNextUrlPath("posts")?.split("-")?.at(-1)
  323. })
  324. }
  325. function extractURLFromFanbox() {
  326. return compileURL({
  327. domains: ["kemono.su", "nekohouse.su"],
  328. service: "fanbox",
  329. userID: extract(select('meta[property="og:image"]', "content"), "/creator/", "/") ??
  330. extract(select(".styled__StyledUserIcon-sc-1upaq18-10[style]", "style"), "/user/", "/") ??
  331. extract(select('a[href^="https://www.pixiv.net/users/"]', "href"), "/users/", "/"),
  332. postID: extractNextUrlPath("posts")
  333. })
  334. }
  335. function extractURLFromPixiv() {
  336. return compileURL({
  337. domains: ["kemono.su", "nekohouse.su"],
  338. service: "fanbox",
  339. userID: extractNextUrlPath("users") ??
  340. select("button[data-gtm-user-id]", "data-gtm-user-id") ??
  341. select("a.user-details-icon[href]", "href")?.split("/").at(-1),
  342. })
  343. }
  344. function extractURLFromDiscord() {
  345. const pathname = window.location.pathname.split("/");
  346.  
  347. const serverID = /\d/.test(pathname[2]) ? `${pathname[2]}#${pathname?.[3] ?? ""}` : null
  348.  
  349. return serverID ? {
  350. "KEMONO": `https://kemono.su/discord/server/${serverID}`
  351. } : {}
  352. }
  353. function extractURLFromFantia() {
  354. return compileURL({
  355. domains: ["kemono.su", "nekohouse.su"],
  356. service: "fantia",
  357. userID: extractNextUrlPath("fanclubs") ?? extract(select(".fanclub-header > a[href]", "href"), "/fanclubs/", "/"),
  358. postID: extractNextUrlPath("posts") ?? extractNextUrlPath("products")
  359. })
  360. }
  361. function extractURLFromBoosty() {
  362. return compileURL({
  363. domains: ["kemono.su"],
  364. service: "boosty",
  365. userID: extractNextUrlPath("/"),
  366. postID: extractNextUrlPath("posts")
  367. })
  368. }
  369. function extractURLFromDlsite() {
  370. return compileURL({
  371. domains: ["kemono.su"],
  372. service: "dlsite",
  373. userID: extractNextUrlPath("maker_id", ".html") ?? extract(select(".maker_name[itemprop=brand] > a", "href"), "/maker_id/", "."),
  374. postID: concatOrFalsy("RE", extractNextUrlPath("product_id")?.replace(/\D/g, ""))
  375. })
  376. }
  377. function extractURLFromGumroad() {
  378. const json = select("script.js-react-on-rails-component[data-component-name^=Profile]")
  379.  
  380. return compileURL({
  381. domains: ["kemono.su"],
  382. service: "gumroad",
  383. userID: extract(json, '"external_id":"', '"') ?? extract(json, '"seller":{"id":"', '"'),
  384. postID: select('meta[property="product:retailer_item_id"]', "content")
  385. })
  386. }
  387. function extractURLFromSubscribeStar() {
  388. return compileURL({
  389. domains: ["kemono.su", "nekohouse.su"],
  390. service: "subscribestar",
  391. userID: select('img[data-type="avatar"][alt]', "alt").toLowerCase(),
  392. postID: extractNextUrlPath("posts")
  393. })
  394. }
  395. function extractURLFromOnlyFans() {
  396. return compileURL({
  397. domains: ["coomer.su"],
  398. service: "onlyfans",
  399. userID: select("#content .g-avatar[href]", "href")?.split("/")[1],
  400. postID: select("div.b-post:not(.is-not-post-page)", "id")?.replace(/\D/g, "")
  401. })
  402. }
  403. async function extractURLFromFansly() {
  404. let userID = null;
  405.  
  406. const userName = select("div.feed-item-name a.username-wrapper", "href")?.split("/").at(-1) ?? select("meta[property='og:title']", "content")?.slice(10);
  407. if (userName) {
  408. const res = await request({ method: "GET", url: `https://apiv3.fansly.com/api/v1/account?usernames=${userName}&ngsw-bypass=true` });
  409. userID = JSON.parse(res.responseText)?.response?.[0].id ?? null;
  410. }
  411.  
  412. return compileURL({
  413. domains: ["coomer.su"],
  414. service: "fansly",
  415. userID: userID,
  416. postID: extractNextUrlPath("post")
  417. })
  418. }
  419. function extractURLFromCandFans() {
  420. return compileURL({
  421. domains: ["coomer.su"],
  422. service: "candfans",
  423. userID: extract(select("div.v-main__wrap"), "user/", "/"),
  424. postID: extractNextUrlPath("show")
  425. })
  426. }
  427. function extractURLFromTwitter() {
  428. return compileURL({
  429. domains: ["nekohouse.su"],
  430. service: "twitter",
  431. userID: select('div[data-testid="UserName"]') ? extractNextUrlPath("/") : null,
  432. postID: extractNextUrlPath("status")
  433. })
  434. }
  435.  
  436.  
  437. ////////////// UTILITY STUFF //////////////
  438.  
  439. /**
  440. * get query element attribute shorthand
  441. * @returns {string}
  442. */
  443. function select(query, attribute=null) {
  444. const el = document.querySelector(query);
  445. return attribute ? el?.getAttribute(attribute) : el?.innerHTML
  446. }
  447.  
  448. /**
  449. * get string between a prefix and a suffix
  450. * @returns {string}
  451. */
  452. function extract(string, prefix, suffix) {
  453. if (string == null) return null;
  454.  
  455. let begIndex = string.indexOf(prefix);
  456. if (begIndex == -1) return null;
  457. else begIndex += prefix.length;
  458.  
  459. let endIndex = string.indexOf(suffix, begIndex);
  460. if (endIndex == -1) endIndex = undefined;
  461. let result = string.slice(begIndex, endIndex);
  462.  
  463. return result;
  464. }
  465.  
  466. /**
  467. * get next path segment in url pathname after a prefix.
  468. * if prefix is blank, return the first path segment.
  469. * @returns {string}
  470. */
  471. function extractNextUrlPath(prefix, suffix="/") {
  472. return extract(window.location.pathname, (prefix == "/") ? "/" : `/${prefix}/`, suffix);
  473. }
  474.  
  475. /**
  476. * concatenate strings if they are truthy, otherwise return the first falsy string
  477. * @returns {string}
  478. */
  479. function concatOrFalsy(...args)
  480. {
  481. for (let arg of args) {
  482. if (!arg) return arg;
  483. }
  484. return args.join("");
  485. }
  486.  
  487. /**
  488. * check if the creator exists on kemono
  489. * @returns {string}
  490. */
  491. async function getCreatorStatus(url) {
  492. if (url) {
  493. const Url = new URL(url);
  494.  
  495. if (Url.hostname == "kemono.su" || Url.hostname == "coomer.su") {
  496. if (Url.pathname.split("/")[1] == "discord") {
  497. const response = await request({ method: "GET", url: `https://${Url.hostname}/api/v1/discord/channel/lookup/${Url.pathname.split("/")[3]}` });
  498.  
  499. if (response.status == 200) {
  500. let channels = JSON.parse(response.responseText);
  501.  
  502. if (channels.length == 0) return "missing";
  503. else {
  504. if (channels.some(channel => channel.id == Url.hash.slice(1))) return "found";
  505. else return "incomplete";
  506. }
  507. }
  508. else return response.status;
  509. }
  510. else {
  511. const is_post = Url.pathname.includes("/post");
  512.  
  513. if (is_post) {
  514. const postResponse = await request({ method: "HEAD", url: `https://${Url.hostname}/api/v1${Url.pathname}` });
  515.  
  516. if (postResponse.status == 200 || postResponse.status == 202) return "found";
  517. Url.pathname = Url.pathname.split("/").slice(0, -2).join("/");
  518. }
  519.  
  520. const response = await request({ method: "HEAD", url: `https://${Url.hostname}/api/v1${Url.pathname}/profile` });
  521.  
  522. if (response.status == 200 || response.status == 202) return is_post ? "incomplete" : "found";
  523. else if (response.status == 404) return "missing";
  524. else return response.status;
  525. }
  526. }
  527. else if (Url.hostname == "nekohouse.su") {
  528. const response = await request({ method: "HEAD", url: url });
  529. const redirectUrl = response?.finalUrl;
  530.  
  531. if (response.status == 200) {
  532. if (redirectUrl == url) return "found";
  533. else if (redirectUrl.includes("user")) return "incomplete";
  534. else if (redirectUrl.includes("artists")) return "missing";
  535. }
  536. else return response.status;
  537. }
  538. }
  539.  
  540. return 400;
  541. }
  542.  
  543. /**
  544. * make a request
  545. * @returns {Promise}
  546. */
  547. function request(details) {
  548. return new Promise((resolve, reject) => {
  549. GM.xmlHttpRequest({ ...details, onload: resolve, onerror: reject });
  550. });
  551. }
  552.  
  553.  
  554. initButtons();