Greasy Fork 增强

增进 Greasyfork 浏览体验。

  1. // ==UserScript==
  2. // @name Greasy Fork Enhance
  3. // @name:zh-CN Greasy Fork 增强
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.9.4
  6. // @description Enhance your experience at Greasyfork.
  7. // @description:zh-CN 增进 Greasyfork 浏览体验。
  8. // @match https://greasyfork.org/*
  9. // @author PRO
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @grant GM_deleteValue
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_unregisterMenuCommand
  15. // @grant GM_addValueChangeListener
  16. // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.2/config.min.js#md5=c45f9b0d19ba69bb2d44918746c4d7ae
  17. // @icon https://raw.githubusercontent.com/greasyfork-org/greasyfork/main/public/images/blacklogo16.png
  18. // @icon64 https://raw.githubusercontent.com/greasyfork-org/greasyfork/main/public/images/blacklogo96.png
  19. // @license gpl-3.0
  20. // ==/UserScript==
  21.  
  22. (function () {
  23. "use strict";
  24. // Judge if the script should run
  25. const { contentType } = document;
  26. if (contentType !== "text/html") return;
  27.  
  28. const idPrefix = "greasyfork-enhance-";
  29. const name = GM_info.script.name;
  30.  
  31. GM_config.extend("password", {
  32. value: "",
  33. input: "prompt",
  34. processor: "same",
  35. formatter: (prop, value, desc) => `${desc.name}: ${value ? "*".repeat(value.length) : ""}`,
  36. });
  37.  
  38. // Config
  39. const configDesc = {
  40. $default: {
  41. autoClose: false,
  42. },
  43. filterAndSearch: {
  44. name: "🔎 Filter and Search",
  45. type: "folder",
  46. items: {
  47. anchor: {
  48. name: "*Anchor",
  49. title: "Show anchor for each heading",
  50. type: "bool",
  51. value: true,
  52. },
  53. outline: {
  54. name: "*Outline",
  55. title: "Show an outline for the page, if your screen is wide enough",
  56. type: "bool",
  57. value: true,
  58. },
  59. shortcut: {
  60. name: "Shortcut",
  61. title: "Enable keyboard shortcuts",
  62. type: "bool",
  63. value: true,
  64. },
  65. regexFilter: {
  66. name: "Regex filter",
  67. title: "Use regex to filter out matching scripts",
  68. value: "",
  69. },
  70. searchSyntax: {
  71. name: "*Search syntax",
  72. title: "Enable partial search syntax for Greasy Fork search bar",
  73. type: "bool",
  74. value: true,
  75. },
  76. },
  77. },
  78. codeblocks: {
  79. name: "📝 Code blocks",
  80. type: "folder",
  81. items: {
  82. toolbar: {
  83. name: "*Toolbar",
  84. title: "Show toolbar for code blocks, which allows copying and toggling code",
  85. type: "bool",
  86. value: true,
  87. },
  88. autoHideCode: {
  89. name: "Auto hide code",
  90. title: "Hide long code blocks by default",
  91. type: "bool",
  92. value: true,
  93. },
  94. autoHideRows: {
  95. name: "Min rows to hide",
  96. title: "Minimum number of rows to hide",
  97. type: "int",
  98. min: 1,
  99. value: 10,
  100. },
  101. tabSize: {
  102. name: "Tab size",
  103. title: "Set Tab indentation size",
  104. type: "int",
  105. min: 0,
  106. value: 4,
  107. },
  108. animation: {
  109. name: "Animation",
  110. title: "Enable animation for toggling code blocks",
  111. type: "bool",
  112. value: true,
  113. },
  114. metadata: {
  115. name: "Metadata",
  116. title: "Parses certain script metadata and displays it on the script code page",
  117. type: "bool",
  118. value: false,
  119. },
  120. }
  121. },
  122. display: {
  123. name: "🎨 Display",
  124. type: "folder",
  125. items: {
  126. hideButtons: {
  127. name: "Hide buttons",
  128. title: "Hide floating buttons added by this script",
  129. type: "bool",
  130. value: false,
  131. },
  132. stickyPagination: {
  133. name: "Sticky pagination",
  134. title: "Make pagination bar sticky",
  135. type: "bool",
  136. value: true,
  137. },
  138. flatLayout: {
  139. name: "Flat layout",
  140. title: "Use flat layout for script list and descriptions",
  141. type: "bool",
  142. value: false,
  143. },
  144. showVersion: {
  145. name: "Show version",
  146. title: "Show version number in script list",
  147. type: "bool",
  148. value: false,
  149. },
  150. navigationBar: {
  151. name: "Navigation bar",
  152. title: "Override navigation bar style",
  153. type: "enum",
  154. options: ["Default", "Desktop", "Mobile"],
  155. value: 0,
  156. },
  157. alwaysShowNotification: {
  158. name: "Always show notification",
  159. title: "Always show the notification widget",
  160. type: "bool",
  161. value: false,
  162. },
  163. },
  164. },
  165. credentials: {
  166. name: "🔑 Credentials",
  167. type: "folder",
  168. items: {
  169. autoLogin: {
  170. name: "*Auto login",
  171. title: "Automatically login to Greasy Fork, if not already (only support email/password login)",
  172. type: "enum",
  173. options: ["Never", "HomepageOnly", "Always"],
  174. },
  175. captureCredentials: {
  176. name: "Capture credentials",
  177. title: "Automatically save email and password after login attempt, overwriting existing values",
  178. type: "bool",
  179. value: false,
  180. },
  181. email: {
  182. name: "Email",
  183. title: "Email address for auto login",
  184. type: "text",
  185. value: "",
  186. },
  187. password: {
  188. name: "Password",
  189. title: "Password for auto login",
  190. type: "password",
  191. value: "",
  192. },
  193. },
  194. },
  195. other: {
  196. name: "🔧 Other",
  197. type: "folder",
  198. items: {
  199. shortLink: {
  200. name: "Short link",
  201. title: "Display a shortened link to current script",
  202. type: "bool",
  203. value: true,
  204. },
  205. libAlternativeUrl: {
  206. name: "Alternative URLs for library",
  207. title: "Show a list of alternative URLs for a given library",
  208. type: "bool",
  209. value: false,
  210. },
  211. imageProxy: {
  212. name: "*Image proxy",
  213. title: "Use `wsrv.nl` as proxy for user-uploaded images",
  214. type: "bool",
  215. value: false,
  216. },
  217. lazyImage: {
  218. name: "*Lazy image",
  219. title: "Load user images lazily",
  220. type: "bool",
  221. value: false,
  222. },
  223. debug: {
  224. name: "Debug",
  225. title: "Enable debug mode",
  226. type: "bool",
  227. value: false,
  228. },
  229. }
  230. }
  231. };
  232. const config = new GM_config(configDesc);
  233. // CSS
  234. /**
  235. * Dynamic styles for the bool type.
  236. * @type {Object<string, string>}
  237. */
  238. const dynamicStyles = {
  239. "codeblocks.animation": `
  240. /* Toggle code animation */
  241. pre > code { transition: height 0.5s ease-in-out 0s; }
  242. /* Adapted from animate.css - https://animate.style/ */
  243. :root { --animate-duration: 1s; --animate-delay: 1s; --animate-repeat: 1; }
  244. .animate__animated { animation-duration: var(--animate-duration); animation-fill-mode: both; }
  245. .animate__animated.animate__fastest { animation-duration: calc(var(--animate-duration) / 3); }
  246. @keyframes tada {
  247. from { transform: scale3d(1, 1, 1); }
  248. 10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
  249. 30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
  250. 40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
  251. to { transform: scale3d(1, 1, 1); }
  252. }
  253. .animate__tada { animation-name: tada; }
  254. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  255. .animate__fadeIn { animation-name: fadeIn; }
  256. @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
  257. .animate__fadeOut { -webkit-animation-name: fadeOut; animation-name: fadeOut; }
  258. `,
  259. "display.hideButtons": `div#float-buttons { display: none; }`,
  260. "display.stickyPagination": `.sidebarred-main-content > .pagination { position: sticky; bottom: 0; backdrop-filter: blur(5px); padding: 0.5em; }`,
  261. "display.flatLayout": `
  262. .script-list > li {
  263. &:not(.ad-entry) { padding-right: 0; }
  264. article {
  265. display: flex; flex-direction: row; justify-content: space-between; align-items: center;
  266. > .script-meta-block {
  267. width: 40%; column-gap: 0;
  268. > .inline-script-stats {
  269. margin: 0;
  270. > dd { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  271. }
  272. }
  273. > h2 {
  274. width: 60%; overflow: hidden; text-overflow: ellipsis; margin-right: 0.5em; padding-right: 0.5em; border-right: 1px solid #88888888;
  275. > .script-link { white-space: nowrap; }
  276. > .script-description { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  277. }
  278. }
  279. &[data-script-type="library"] > article {
  280. > h2 { width: 80%; }
  281. > .script-meta-block { width: 20%; column-count: 1; }
  282. }
  283. }
  284. @media (max-width: 600px) {
  285. .script-list > li {
  286. &[data-script-type="library"] > article > div.script-meta-block { width: 40%; }
  287. &:not([data-script-type="library"]) > article {
  288. display: block;
  289. > h2 { width: unset; border-right: none; }
  290. > .script-meta-block { column-count: 2; }
  291. }
  292. > article > div.script-meta-block { width: unset; column-gap: 0; }
  293. }
  294. }
  295. .showing-all-languages .badge-js, .showing-all-languages .badge-css, .script-type { display: none; }
  296. #script-info .script-meta-block { float: right; column-count: 1; max-width: 300px; border-left: 1px solid var(--content-border-color); margin-left: 1em; padding-left: 1em; }
  297. #additional-info { width: calc(100% - 2em - 2px); }
  298. `,
  299. "display.showVersion": `.script-list > li[data-script-version]::before { content: "@" attr(data-script-version); position: absolute; translate: 0 -1em; color: grey; font-size: smaller; }`,
  300. };
  301. /**
  302. * Dynamic styles for the enum type.
  303. * @type {Object<string, Array<string>>}
  304. */
  305. const enumStyles = {
  306. "display.navigationBar": [
  307. "/* Default */",
  308. "/* Desktop */ #main-header { #site-nav { display: block; } #mobile-nav { display: none; } }",
  309. "/* Mobile */ #main-header { #site-nav { display: none; } #mobile-nav { display: block; } }",
  310. ]
  311. };
  312. // Common Helper Functions
  313. const $ = document.querySelector.bind(document);
  314. const $$ = document.querySelectorAll.bind(document);
  315. const body = $("body");
  316. function log(...args) {
  317. if (config.get("other.debug")) {
  318. console.log(`[${name}]`, ...args);
  319. }
  320. }
  321. function injectCSS(id, css) {
  322. const style = document.head.appendChild(document.createElement("style"));
  323. style.id = idPrefix + id;
  324. style.textContent = css;
  325. return style;
  326. }
  327. function cssHelper(id, enable) {
  328. const current = document.getElementById(idPrefix + id);
  329. if (current) {
  330. current.disabled = !enable;
  331. } else if (enable) {
  332. injectCSS(id, dynamicStyles[id]);
  333. }
  334. }
  335. /**
  336. * Helper function to configure enum styles.
  337. * @param {string} id The ID of the style.
  338. * @param {string} mode The mode to set.
  339. */
  340. function enumStyleHelper(id, mode) {
  341. const style = document.getElementById(idPrefix + id) ?? injectCSS(id, "");
  342. style.textContent = enumStyles[id][mode];
  343. }
  344. // Basic css
  345. injectCSS("basic", `
  346. html { scroll-behavior: smooth; }
  347. a.anchor::before { content: "#"; }
  348. a.anchor { opacity: 0; text-decoration: none; padding: 0px 0.5em; transition: all 0.25s ease-in-out; }
  349. h1:hover>a.anchor, h2:hover>a.anchor, h3:hover>a.anchor,
  350. h4:hover>a.anchor, h5:hover>a.anchor, h6:hover>a.anchor { opacity: 1; transition: all 0.25s ease-in-out; }
  351. a.button { margin: 0.5em 0 0 0; display: flex; align-items: center; justify-content: center; text-decoration: none; color: black; background-color: #a42121ab; border-radius: 50%; width: 2em; height: 2em; font-size: 1.8em; font-weight: bold; }
  352. div.code-toolbar { display: flex; gap: 1em; }
  353. a.code-operation { cursor: pointer; font-style: italic; }
  354. div.lum-lightbox { z-index: 2; }
  355. #float-buttons { position: fixed; bottom: 1em; right: 1em; display: flex; flex-direction: column; user-select: none; z-index: 1; }
  356. aside.panel { display: none; }
  357. .dynamic-opacity { transition: opacity 0.2s ease-in-out; opacity: 0.2; }
  358. .dynamic-opacity:hover { opacity: 0.8; }
  359. input[type=file] { border-style: dashed; border-radius: 0.5em; border-color: gray; padding: 0.5em; background: rgba(169, 169, 169, 0.4); transition-property: border-color, background; transition-duration: 0.25s; transition-timing-function: ease-in-out; }
  360. input[type=file]:hover { border-color: black; background: rgba(169, 169, 169, 0.6); }
  361. input[type=file]::file-selector-button { border: 1px solid; border-radius: 0.3em; transition: background 0.25s ease-in-out; background: rgba(169, 169, 169, 0.7); }
  362. input[type=file]::file-selector-button:hover { background: rgba(169, 169, 169, 1); }
  363. table { border: 1px solid var(--content-border-color); border-collapse: collapse; width: auto; }
  364. table td, table th { padding: 0.5em 0.75em; vertical-align: middle; border: 1px solid var(--content-border-color); }
  365. @media (any-hover: none) { .dynamic-opacity { opacity: 0.8; } .dynamic-opacity:hover { opacity: 0.8; } }
  366. @media screen and (min-width: 767px) {
  367. aside.panel { display: contents; line-height: 1.5; }
  368. ul.outline { position: sticky; float: right; padding: 0 0 0 0.5em; margin: 0 0.5em -99vh; max-height: 80vh; border: 1px solid var(--content-border-color); box-shadow: 0 0 5px var(--content-box-shadow-color); background: var(--content-background-color); list-style: none; width: 10.5%; color: var(--overall-text-color); border-radius: 5px; overflow-y: scroll; z-index: 1; }
  369. ul.outline > li { overflow: hidden; text-overflow: ellipsis; }
  370. ul.outline > li > a { color: var(--overall-text-color); white-space: nowrap; text-decoration: none; }
  371. }
  372. pre > code { overflow: hidden; display: block; }
  373. ul { padding-left: 1.5em; }
  374. .script-list > .regex-filtered { display: none; }
  375. #greasyfork-enhance-regex-filter-tip { float: right; color: grey; }
  376. @media screen and (max-width: 800px) { #greasyfork-enhance-regex-filter-tip { display: none; } }`);
  377.  
  378. // Buttons
  379. const buttons = body.appendChild(document.createElement("div"));
  380. buttons.id = "float-buttons";
  381. const goToTop = buttons.appendChild(document.createElement("a"));
  382. goToTop.classList.add("button");
  383. goToTop.classList.add("dynamic-opacity");
  384. goToTop.href = "#top";
  385. goToTop.text = "↑";
  386. // Double click to get to top
  387. body.addEventListener("dblclick", (e) => {
  388. if (e.target === body) {
  389. goToTop.click();
  390. }
  391. });
  392. // Fix current tab link
  393. const tab = $("ul#script-links > li.current");
  394. if (tab) {
  395. const link = tab.appendChild(document.createElement("a"));
  396. link.href = window.location.pathname;
  397. link.appendChild(tab.firstChild);
  398. }
  399. const parts = window.location.pathname.split("/");
  400. if (parts.length <= 2 || (parts.length == 3 && parts[2] === "")) {
  401. const banner = $("header#main-header div#site-name");
  402. const img = banner.querySelector("img");
  403. const text = banner.querySelector("#site-name-text > h1");
  404. const link1 = document.createElement("a");
  405. link1.href = window.location.pathname;
  406. img.parentNode.replaceChild(link1, img);
  407. link1.appendChild(img);
  408. const link2 = document.createElement("a");
  409. link2.href = window.location.pathname;
  410. link2.textContent = text.textContent;
  411. text.textContent = "";
  412. text.appendChild(link2);
  413. }
  414.  
  415. // Filter and Search
  416. // Anchor & Outline
  417. if (config.get("filterAndSearch.anchor") || config.get("filterAndSearch.outline")) {
  418. function sanitify(s) {
  419. // Remove emojis (such a headache)
  420. s = s.replaceAll(/([\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2580-\u27BF]|\uD83E[\uDD10-\uDEFF]|\uFE0F)/g, "");
  421. // Trim spaces and newlines
  422. s = s.trim();
  423. // Replace spaces
  424. s = s.replaceAll(" ", "-");
  425. s = s.replaceAll("%20", "-");
  426. // No more multiple "-"
  427. s = s.replaceAll(/-+/g, "-");
  428. return s;
  429. }
  430. function process(outline, node) { // Add anchor and assign id to given node; Add to outline. Return true if node is actually processed.
  431. if (node.childElementCount > 1 || node.classList.length > 0) return false; // Ignore complex nodes
  432. const text = node.textContent;
  433. if (!node.id) { // If the node has no id
  434. node.id = sanitify(text); // Then assign id
  435. }
  436. // Add anchors
  437. if (config.get("filterAndSearch.anchor")) {
  438. const anchor = node.appendChild(document.createElement("a"));
  439. anchor.className = "anchor";
  440. anchor.href = "#" + node.id;
  441. }
  442. if (outline) {
  443. const link = outline.appendChild(document.createElement("li"))
  444. .appendChild(document.createElement("a"));
  445. link.href = "#" + node.id;
  446. link.text = text;
  447. }
  448. return true;
  449. }
  450.  
  451. // Outline & Anchors
  452. const isScript = /^\/[^\/]+\/scripts/;
  453. const isSpecificScript = /^\/[^\/]+\/scripts\/\d+/;
  454. const isDiscussion = /^\/[^\/]+\/discussions/;
  455. const path = window.location.pathname;
  456. if ((!isScript.test(path) && !isDiscussion.test(path)) || isSpecificScript.test(path)) {
  457. let panel = null, outline = null;
  458. if (config.get("filterAndSearch.outline")) {
  459. panel = body.insertBefore(document.createElement("aside"), $("body > div.width-constraint"));
  460. panel.className = "panel";
  461. const referenceNode = $("body > div.width-constraint > section");
  462. outline = panel.appendChild(document.createElement("ul"));
  463. outline.classList.add("outline");
  464. outline.classList.add("dynamic-opacity");
  465. outline.style.top = referenceNode ? getComputedStyle(referenceNode).marginTop : "1em";
  466. outline.style.marginTop = outline.style.top;
  467. }
  468. let flag = false;
  469. $$("body > div.width-constraint h1, h2, h3, h4, h5, h6").forEach((node) => {
  470. flag = process(outline, node) || flag; // Not `flag || process(node)`!
  471. });
  472. if (!flag) {
  473. panel?.remove();
  474. }
  475. }
  476. // Navigate to hash
  477. const hash = window.location.hash.slice(1);
  478. if (hash) {
  479. const ele = document.getElementById(decodeURIComponent(hash));
  480. if (ele) {
  481. ele.scrollIntoView();
  482. }
  483. }
  484. }
  485. // Shortcut
  486. function submitOnCtrlEnter(e) {
  487. const form = this.form;
  488. if (!form) return;
  489. // Ctrl + Enter to submit
  490. if (e.ctrlKey && e.key === "Enter") {
  491. form.submit();
  492. }
  493. }
  494. function handleShortcut(e) {
  495. const ele = document.activeElement;
  496. // Ignore key combinations
  497. if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) {
  498. return;
  499. }
  500. // Do not interfere with input elements
  501. if (ele.tagName === "INPUT" || ele.tagName === "TEXTAREA" || ele.getAttribute("contenteditable") === "true") {
  502. if (e.key === "Escape") {
  503. e.preventDefault();
  504. ele.blur(); // Escape to blur
  505. }
  506. return;
  507. }
  508. // Do not interfere with input methods
  509. if (e.isComposing || e.keyCode === 229) {
  510. return;
  511. }
  512. // Focus on search bar
  513. switch (e.key) {
  514. case "Enter": {
  515. const input = $("input[type=search]") || $("input[type=text]") || $("textarea");
  516. if (input) {
  517. e.preventDefault();
  518. input.focus();
  519. }
  520. break;
  521. }
  522. case "ArrowLeft":
  523. $("a.previous_page")?.click();
  524. break;
  525. case "ArrowRight":
  526. $("a.next_page")?.click();
  527. break;
  528. }
  529. }
  530. let shortcutEnabled = false;
  531. function shortcut(enable) {
  532. const textAreas = $$("textarea");
  533. if (!shortcutEnabled && enable) {
  534. for (const textarea of textAreas) {
  535. textarea.addEventListener("keyup", submitOnCtrlEnter);
  536. }
  537. document.addEventListener("keydown", handleShortcut);
  538. shortcutEnabled = true;
  539. } else if (shortcutEnabled && !enable) {
  540. for (const textarea of textAreas) {
  541. textarea.removeEventListener("keyup", submitOnCtrlEnter);
  542. }
  543. document.removeEventListener("keydown", handleShortcut);
  544. shortcutEnabled = false;
  545. }
  546. }
  547. shortcut(config.get("filterAndSearch.shortcut"));
  548. // Regex filter
  549. const regexFilterTip = $(".sidebarred > .sidebarred-main-content > .script-list#browse-script-list")
  550. ?.previousElementSibling?.appendChild?.(document.createElement("span"));
  551. if (regexFilterTip) {
  552. regexFilterTip.id = idPrefix + "regex-filter-tip";
  553. regexFilterTip.title = `[${name}] Number of scripts filtered by regex`;
  554. }
  555. function setRegexFilterTip(content) {
  556. if (regexFilterTip) {
  557. regexFilterTip.textContent = content;
  558. }
  559. }
  560. function regexFilterOne(regex, script) {
  561. const info = script.querySelector("article > h2");
  562. if (!info) return;
  563. const name = info.querySelector(".script-link").textContent;
  564. const result = regex.test(name);
  565. script.classList.toggle("regex-filtered", result);
  566. if (result) {
  567. log("Filtered:", name);
  568. }
  569. return result;
  570. }
  571. function regexFilter(regexStr) {
  572. const debug = config.get("other.debug");
  573. const scripts = $$(".script-list > li");
  574. if (regexStr === "" || scripts.length === 0) {
  575. scripts.forEach(script => script.classList.remove("regex-filtered"));
  576. setRegexFilterTip("");
  577. return;
  578. }
  579. const regex = new RegExp(regexStr, "i");
  580. let count = 0;
  581. debug && console.groupCollapsed(`[${name}] Regex filtered scripts`);
  582. scripts.forEach(script => {
  583. if (regexFilterOne(regex, script)) {
  584. count++;
  585. }
  586. });
  587. setRegexFilterTip(`Filtered: ${count}/${scripts.length}`);
  588. debug && console.groupEnd();
  589. }
  590. regexFilter(config.get("filterAndSearch.regexFilter"));
  591. // Search syntax
  592. const types = {
  593. "script": "scripts",
  594. "lib": "scripts/libraries",
  595. "library": "scripts/libraries",
  596. // "code": "scripts/code-search", // It uses a different search parameter `c` instead of `q`
  597. "user": "users"
  598. };
  599. const langs = {
  600. "js": "",
  601. "javascript": "",
  602. "css": "css",
  603. "*": "all",
  604. "any": "all",
  605. "all": "all",
  606. };
  607. const sorts = {
  608. "rel": "",
  609. "relevant": "",
  610. "relevance": "",
  611. "day": "daily_installs",
  612. "daily": "daily_installs",
  613. "daily_install": "daily_installs",
  614. "daily_installs": "daily_installs",
  615. "total": "total_installs",
  616. "total_install": "total_installs",
  617. "total_installs": "total_installs",
  618. "score": "ratings",
  619. "rate": "ratings",
  620. "rating": "ratings",
  621. "ratings": "ratings",
  622. "created": "created",
  623. "created_at": "created",
  624. "updated": "updated",
  625. "updated_at": "updated",
  626. "name": "name",
  627. "title": "name",
  628. };
  629. if (config.get("filterAndSearch.searchSyntax")) {
  630. function parseString(input) {
  631. // Regular expression to match key:value pairs, allowing for non-word characters in values
  632. const regex = /\b(\w+:[^\s]+)\b/g;
  633. // Extract all key:value pairs
  634. const pairs = input.match(regex) || [];
  635. // Remove the pairs from the input string
  636. const cleanedString = input.replace(regex, "").replace(/\s{2,}/g, " ").trim();
  637.  
  638. // Convert pairs to an object
  639. const parsedPairs = pairs.reduce((acc, pair) => {
  640. const [key, value] = pair.split(":");
  641. acc[key.toLowerCase()] = value.toLowerCase(); // Case-insensitive
  642. return acc;
  643. }, {});
  644.  
  645. return { cleanedString, parsedPairs };
  646. }
  647. function processSearch(search) {
  648. const form = search.form;
  649. if (form.method !== "get") {
  650. return;
  651. }
  652. form.addEventListener("submit", (e) => {
  653. const { cleanedString, parsedPairs } = parseString(search.value);
  654. if (cleanedString === search.value) return;
  655. search.value = cleanedString;
  656. if (!parsedPairs) return;
  657. e.preventDefault();
  658. const url = new URL(form.action, window.location.href);
  659. url.searchParams.set("q", cleanedString);
  660. if (parsedPairs["site"]) { // site:site-name
  661. url.pathname = `/scripts/by-site/${parsedPairs["site"]}`;
  662. } else if (parsedPairs["type"]) { // type:type, including "script", "lib"/"library", "code", "user"
  663. const typeUrl = types[parsedPairs["type"]];
  664. if (typeUrl) {
  665. url.pathname = `/${typeUrl}`;
  666. }
  667. }
  668. if (parsedPairs["lang"]) { // lang:language
  669. const lang = langs[parsedPairs["lang"]];
  670. if (lang === "") {
  671. url.searchParams.delete("language");
  672. } else if (lang) {
  673. url.searchParams.set("language", lang);
  674. }
  675. }
  676. if (parsedPairs["sort"]) { // sort:sort-by
  677. const sort = sorts[parsedPairs["sort"]];
  678. if (sort === "" || sort === "daily_installs" && cleanedString === "") {
  679. url.searchParams.delete("sort");
  680. } else if (sort) {
  681. url.searchParams.set("sort", sort);
  682. }
  683. }
  684. window.location.href = url.href;
  685. });
  686. }
  687. const searches = $$("input[type=search][name=q]");
  688. for (const search of searches) {
  689. processSearch(search);
  690. }
  691. }
  692.  
  693. // Code blocks
  694. const codeBlocks = document.getElementsByTagName("pre");
  695. // Toolbar
  696. const toolbarEnabled = config.get("codeblocks.toolbar");
  697. if (toolbarEnabled) {
  698. async function animate(node, animation) {
  699. return new Promise((resolve, reject) => {
  700. node.classList.add("animate__animated", "animate__" + animation);
  701. if (node.getAnimations().length == 0) {
  702. node.classList.remove("animate__animated", "animate__" + animation);
  703. reject("No animation available");
  704. }
  705. node.addEventListener("animationend", e => {
  706. e.stopPropagation();
  707. node.classList.remove("animate__animated", "animate__" + animation);
  708. resolve("Animation ended");
  709. }, { once: true });
  710. });
  711. }
  712. async function transition(node, height) {
  713. return new Promise((resolve, reject) => {
  714. node.style.height = height;
  715. if (node.getAnimations().length == 0) {
  716. resolve("No transition available");
  717. }
  718. node.addEventListener("transitionend", e => {
  719. e.stopPropagation();
  720. resolve("Transition ended");
  721. }, { once: true });
  722. });
  723. }
  724. function copyCode() {
  725. const code = this.parentNode.nextElementSibling;
  726. const text = code.textContent;
  727. navigator.clipboard.writeText(text).then(() => {
  728. this.textContent = "Copied!";
  729. animate(this, "tada").then(() => {
  730. this.textContent = "Copy code";
  731. }, () => {
  732. window.setTimeout(() => {
  733. this.textContent = "Copy code";
  734. }, 1000);
  735. });
  736. });
  737. }
  738. function toggleCode() {
  739. const code = this.parentNode.nextElementSibling;
  740. if (code.style.height == "0px") {
  741. code.style.willChange = "height";
  742. transition(code, code.getAttribute("data-height")).then(() => {
  743. code.style.willChange = "";
  744. });
  745. animate(this, "fadeOut").then(() => {
  746. this.textContent = "Hide code";
  747. animate(this, "fadeIn");
  748. }, () => {
  749. this.textContent = "Hide code";
  750. });
  751. } else {
  752. code.style.willChange = "height";
  753. transition(code, "0px").then(() => {
  754. code.style.willChange = "";
  755. });
  756. animate(this, "fadeOut").then(() => {
  757. this.textContent = "Show code";
  758. animate(this, "fadeIn");
  759. }, () => {
  760. this.textContent = "Show code";
  761. });
  762. }
  763. }
  764. function createToolbar() {
  765. const toolbar = document.createElement("div");
  766. const copy = toolbar.appendChild(document.createElement("a"));
  767. const toggle = toolbar.appendChild(document.createElement("a"));
  768. copy.textContent = "Copy code";
  769. copy.className = "code-operation";
  770. copy.title = "Copy code to clipboard";
  771. copy.addEventListener("click", copyCode);
  772. toggle.textContent = "Hide code";
  773. toggle.classList.add("code-operation", "animate__fastest");
  774. toggle.title = "Toggle code display";
  775. toggle.addEventListener("click", toggleCode);
  776. // Css
  777. toolbar.className = "code-toolbar";
  778. return toolbar;
  779. }
  780. for (const codeBlock of codeBlocks) {
  781. if (codeBlock.firstChild.tagName === "CODE") {
  782. const height = getComputedStyle(codeBlock.firstChild).getPropertyValue("height");
  783. codeBlock.firstChild.style.height = height;
  784. codeBlock.firstChild.setAttribute("data-height", height);
  785. codeBlock.insertAdjacentElement("afterbegin", createToolbar());
  786. }
  787. }
  788. }
  789. // Auto hide code blocks
  790. function autoHide() {
  791. if (!toolbarEnabled) return;
  792. if (!config.get("codeblocks.autoHideCode")) {
  793. for (const code_block of codeBlocks) {
  794. const toggle = code_block.firstChild.lastChild;
  795. if (!toggle) continue;
  796. if (toggle.textContent === "Show code") {
  797. toggle.click(); // Click the toggle button
  798. }
  799. }
  800. } else {
  801. for (const codeBlock of codeBlocks) {
  802. const m = codeBlock.lastChild.textContent.match(/\n/g);
  803. const rows = m ? m.length : 0;
  804. const toggle = codeBlock.firstChild.lastChild;
  805. if (!toggle) continue;
  806. const hidden = toggle.textContent === "Show code";
  807. if (rows >= config.get("codeblocks.autoHideRows") && !hidden || rows < config.get("codeblocks.autoHideRows") && hidden) {
  808. codeBlock.firstChild.lastChild.click(); // Click the toggle button
  809. }
  810. }
  811. }
  812. }
  813. document.addEventListener("readystatechange", (e) => {
  814. if (e.target.readyState === "complete") {
  815. autoHide();
  816. }
  817. }, { once: true });
  818. // Tab size
  819. function tabSize(value) {
  820. const style = $("style#" + idPrefix + "tab-size") ?? document.head.appendChild(document.createElement("style"));
  821. style.id = idPrefix + "tab-size";
  822. style.textContent = `pre { tab-size: ${value}; }`;
  823. }
  824. tabSize(config.get("codeblocks.tabSize"));
  825. // Metadata
  826. function extractUserScriptMetadata(code) {
  827. const result = {};
  828. const userScriptRegex = /\/\/\s*=+\s*UserScript\s*=+\s*([\s\S]*?)\s*=+\s*\/UserScript\s*=+\s*/;
  829. const match = code.match(userScriptRegex);
  830. if (match) {// If the UserScript block is found
  831. const content = match[1];// Extract the content within the UserScript block
  832. const lines = content.split("\n"); // Split the content by newline
  833.  
  834. lines.forEach(line => {
  835. // Regular expression to match "// @name value" pattern
  836. const matchLine = line.trim().match(/^\/\/\s*@(\S+)\s+(.+)$/);
  837. if (matchLine) {
  838. const name = matchLine[1]; // Extract the name
  839. const value = matchLine[2]; // Extract the value
  840. switch (typeof result[name]) {
  841. case "undefined": // First occurrence
  842. result[name] = value;
  843. break;
  844. case "string": // Second occurrence
  845. result[name] = [result[name], value];
  846. break;
  847. case "object": // Third or more occurrence
  848. result[name].push(value);
  849. break;
  850. }
  851. }
  852. });
  853. }
  854. return result;
  855. }
  856. function metadata(enable) {
  857. const id = idPrefix + "metadata";
  858. const current = document.getElementById(id);
  859. if (current && !enable) {
  860. current.remove();
  861. } else if (!current && enable) {
  862. const scriptCodeBlock = document.querySelector(".code-container > pre.prettyprint.lang-js");
  863. const description = $("div#script-content");
  864. if (!window.location.pathname.endsWith("/code") || !scriptCodeBlock || !description) return;
  865. const metaBlock = document.createElement("ul");
  866. description.prepend(metaBlock);
  867. metaBlock.id = id;
  868. const script = scriptCodeBlock.querySelector("ol") ? Array.from(scriptCodeBlock.querySelectorAll("ol > li")).map(li => li.textContent).join("\n") : scriptCodeBlock.textContent;
  869. const metadata = extractUserScriptMetadata(script);
  870. const commonHosts = {
  871. GreasyFork: /^https?:\/\/update\.greasyfork\.org\/scripts\/\d+\/(?<ver>\d+)\/(?<name>.+?)\.js$/,
  872. JsDelivr: /^https?:\/\/cdn\.jsdelivr\.net\/(?<reg>\w+)\/(@[^/]+\/)?(?<name>[^@]+)@(?<ver>[^/]+)/,
  873. Cloudflare: /^https?:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/(?<name>[^/]+)\/(?<ver>[^/]+)/,
  874. };
  875. const commonRegistries = {
  876. npm: "NPM",
  877. gh: "GitHub",
  878. };
  879. // We're interested in `@grant`, `@connect`, `@require`, `@resource`
  880. const interestedMetadata = {};
  881. const interestedKeys = {
  882. grant: {
  883. brief: "Required permissions",
  884. display: (value) => {
  885. const valueCode = document.createElement("code");
  886. valueCode.textContent = value;
  887. if (value !== "none") {
  888. const valueLink = document.createElement("a");
  889. valueLink.appendChild(valueCode);
  890. valueLink.href = `https://www.tampermonkey.net/documentation.php#api:${valueCode.textContent}`;
  891. valueLink.title = `See documentation about ${valueCode.textContent}`;
  892. return valueLink;
  893. } else {
  894. return valueCode;
  895. }
  896. }
  897. },
  898. connect: {
  899. brief: "Allowed URLs to connect",
  900. display: (value) => {
  901. const valueCode = document.createElement("code");
  902. valueCode.textContent = value;
  903. return valueCode;
  904. }
  905. },
  906. require: {
  907. brief: "External libraries",
  908. display: (value) => {
  909. const valueLink = document.createElement("a");
  910. valueLink.href = value;
  911. valueLink.textContent = value;
  912. for (const [host, regex] of Object.entries(commonHosts)) {
  913. const match = value.match(regex);
  914. if (match) {
  915. const { name, ver, reg } = match.groups;
  916. const optionalRegistry = commonRegistries[reg] ? `${commonRegistries[reg]} on ` : "";
  917. valueLink.textContent = `${decodeURIComponent(name)}@${ver} (${optionalRegistry}${host})`;
  918. break;
  919. }
  920. }
  921. return valueLink;
  922. }
  923. },
  924. resource: {
  925. brief: "External resources",
  926. display: (value) => {
  927. const valueCode = document.createElement("code");
  928. const [name, link] = value.split(" ");
  929. const valueLink = document.createElement("a");
  930. valueLink.appendChild(valueCode);
  931. valueLink.href = link.trim();
  932. valueCode.textContent = name.trim();
  933. return valueLink;
  934. }
  935. }
  936. };
  937. for (const key in interestedKeys) {
  938. const values = metadata[key] ?? [];
  939. interestedMetadata[key] = Array.isArray(values) ? values : [values];
  940. }
  941. log("Interested Metadata:", interestedMetadata);
  942. // Display
  943. for (const [key, values] of Object.entries(interestedMetadata)) {
  944. const keyInfo = interestedKeys[key];
  945. const li = metaBlock.appendChild(document.createElement("li"));
  946. const keyLink = li.appendChild(document.createElement("a"));
  947. keyLink.href = `https://www.tampermonkey.net/documentation.php#meta:${key}`;
  948. keyLink.title = keyInfo.brief;
  949. keyLink.textContent = `@${key}`;
  950. const separator = li.appendChild(document.createElement("span"));
  951. separator.textContent = ": ";
  952. for (const value of values) {
  953. li.appendChild(keyInfo.display(value));
  954. const separator = li.appendChild(document.createElement("span"));
  955. separator.textContent = ", ";
  956. }
  957. if (values.length > 0) {
  958. li.lastChild.remove(); // Remove the last separator
  959. } else {
  960. li.appendChild(document.createTextNode("none"));
  961. }
  962. }
  963. }
  964. }
  965. metadata(config.get("codeblocks.metadata"));
  966.  
  967. // Display
  968. // Flat layout
  969. function flatLayout(enable) {
  970. const meta_orig = $("#script-info > #script-content .script-meta-block");
  971. const meta_mod = $("#script-info > .script-meta-block");
  972. if (enable && meta_orig) {
  973. const header = $("#script-info > header");
  974. header.before(meta_orig);
  975. } else if (!enable && meta_mod) {
  976. const additional = $("#script-info > #script-content #additional-info");
  977. additional.before(meta_mod);
  978. }
  979. }
  980. flatLayout(config.get("display.flatLayout"));
  981. // Always show notification
  982. function alwaysShowNotification(enable) {
  983. const nav = $("#nav-user-info");
  984. const profile = nav?.querySelector(".user-profile-link");
  985. const existing = nav.querySelector(".notification-widget");
  986. if (!nav || !profile || existing && existing.textContent !== "0") return; // There's unread notification or user is not logged in
  987. if (enable && !existing) {
  988. const notification = nav.insertBefore(document.createElement("a"), profile);
  989. notification.className = "notification-widget";
  990. notification.textContent = "0";
  991. notification.href = profile.querySelector("a").href + "/notifications";
  992. } else if (!enable && existing) {
  993. existing.remove();
  994. }
  995. }
  996. alwaysShowNotification(config.get("display.alwaysShowNotification"));
  997.  
  998. // Credenials
  999. // Auto login
  1000. async function login(email, password) {
  1001. log("Login:", email, "*".repeat(password.length));
  1002. const initReq = await fetch("/users/sign_in", {
  1003. method: "GET",
  1004. credentials: "same-origin",
  1005. headers: {
  1006. "Accept": "text/html",
  1007. }
  1008. });
  1009. const text = await initReq.text();
  1010. const parser = new DOMParser();
  1011. const doc = parser.parseFromString(text, "text/html");
  1012. const fd = new FormData(doc.querySelector("form#new_user"));
  1013. fd.set("user[email]", email);
  1014. fd.set("user[password]", password);
  1015. fd.set("user[remember_me]", "1");
  1016.  
  1017. const loginReq = await fetch(initReq.url, {
  1018. method: "POST",
  1019. credentials: "same-origin",
  1020. body: fd,
  1021. headers: {
  1022. "Accept": "text/html",
  1023. }
  1024. });
  1025. log("Login request:", loginReq);
  1026. return loginReq.ok;
  1027. }
  1028. function autoLogin(mode) {
  1029. if (mode === 0 || $("#nav-user-info .user-profile-link")) return; // Not enabled or already logged in
  1030. if (mode === 1 && !$("#home-script-nav")) return; // Not on the home page
  1031. // Validate credentials
  1032. const email = config.get("credentials.email");
  1033. const password = config.get("credentials.password");
  1034. if (!email || !password || !email.includes("@")) {
  1035. log("Invalid credentials - skipping auto login");
  1036. return;
  1037. }
  1038. // Login
  1039. const hint = $("#nav-user-info > .sign-in-link > a");
  1040. hint.textContent = "[GFE] Logging in...";
  1041. hint.title = `[${name}] Auto login in progress`;
  1042. hint.setAttribute("href", "javascript:void(0)");
  1043. if (login(email, password)) {
  1044. log("Auto login successful, will refresh in a moment");
  1045. hint.textContent = "[GFE] Logged in, refreshing...";
  1046. hint.title = `[${name}] Auto login successful, will refresh in a moment`;
  1047. setTimeout(() => {
  1048. location.reload();
  1049. }, 3000);
  1050. } else {
  1051. log("Login failed, auto login disabled");
  1052. hint.textContent = "[GFE] Login failed";
  1053. hint.title = `[${name}] Login failed, auto login disabled`;
  1054. config.set("credentials.autoLogin", 0);
  1055. }
  1056. }
  1057. autoLogin(config.get("credentials.autoLogin"));
  1058. // Capture credentials
  1059. function onSubmit(e) {
  1060. log("Login attempt detected");
  1061. e.preventDefault(); // DEBUG
  1062. const fd = new FormData(e.target);
  1063. // Extract email and password
  1064. const email = fd.get("user[email]");
  1065. const password = fd.get("user[password]");
  1066. // If both are present...
  1067. if (email && password) {
  1068. // ...then capture the credentials
  1069. log("Captured credentials");
  1070. config.set("credentials.email", email);
  1071. config.set("credentials.password", password);
  1072. }
  1073. }
  1074. let captureEnabled = false;
  1075. function captureCredentials(enable) {
  1076. if (!location.pathname.endsWith("/users/sign_in") || captureEnabled === enable) return;
  1077. const form = $("form#new_user");
  1078. if (!form) return;
  1079. if (enable) {
  1080. form.addEventListener("submit", onSubmit);
  1081. } else {
  1082. form.removeEventListener("submit", onSubmit);
  1083. }
  1084. captureEnabled = enable;
  1085. }
  1086. captureCredentials(config.get("credentials.captureCredentials"));
  1087. // Other
  1088. // Short link
  1089. function shortLink(enable) {
  1090. const description = $("div#script-content");
  1091. const url = window.location.href;
  1092. const scriptId = url.match(/\/scripts\/(\d+)/)?.[1];
  1093. if (!scriptId || !description) return;
  1094. const id = idPrefix + "short-link";
  1095. const current = document.getElementById(id);
  1096. if (current && !enable) {
  1097. current.remove();
  1098. } else if (!current && enable) {
  1099. const short = `https://greasyfork.org/scripts/${scriptId}`;
  1100. const p = description.insertAdjacentElement("beforebegin", document.createElement("p"));
  1101. p.id = id;
  1102. p.textContent = "Short link: ";
  1103. const link = p.appendChild(document.createElement("a"));
  1104. link.href = short;
  1105. link.textContent = short;
  1106. const copy = p.appendChild(document.createElement("a"));
  1107. copy.textContent = "(Copy)";
  1108. copy.style.marginLeft = "1em";
  1109. copy.style.cursor = "pointer";
  1110. copy.title = "Copy short link to clipboard";
  1111. copy.addEventListener("click", () => {
  1112. if (copy.textContent === "(Copied!)") return;
  1113. navigator.clipboard.writeText(short).then(() => {
  1114. copy.textContent = "(Copied!)";
  1115. window.setTimeout(() => {
  1116. copy.textContent = "(Copy)";
  1117. }, 1000);
  1118. });
  1119. });
  1120. }
  1121. }
  1122. shortLink(config.get("other.shortLink"));
  1123. // Alternative URLs for library
  1124. function alternativeURLs(enable) {
  1125. if ($(".remove-attachments") || !$("div#script-content") || $("div#script-content > div#install-area")) return; // Not a library
  1126. const id = idPrefix + "lib-alternative-url";
  1127. const current = document.getElementById(id);
  1128. if (current && !enable) {
  1129. current.remove();
  1130. } else if (!current && enable) {
  1131. const description = $("div#script-content > p");
  1132. const trim = "// @require ";
  1133. const text = description?.querySelector("code")?.textContent;
  1134. if (!text || !text.startsWith(trim)) return; // Found no URL
  1135. const url = text.slice(trim.length);
  1136. const parts = url.split("/");
  1137. const scriptId = parts[4];
  1138. const scriptVersion = parts[5];
  1139. const fileName = parts[6];
  1140. const URLs = [
  1141. [`// @require https://update.greasyfork.org/scripts/${scriptId}/${fileName}`, "Latest version"],
  1142. [`// @require https://greasyfork.org/scripts/${scriptId}/code/${fileName}?version=${scriptVersion}`, "Current version (Legacy)"],
  1143. [`// @require https://greasyfork.org/scripts/${scriptId}/code/${fileName}`, "Latest version (Legacy)"],
  1144. ];
  1145.  
  1146. const detail = document.createElement("p").appendChild(document.createElement("details"));
  1147. description.after(detail.parentElement);
  1148. detail.parentElement.id = id;
  1149. detail.appendChild(document.createElement("summary")).textContent = "Alternative URLs";
  1150. const list = detail.appendChild(document.createElement("ul"));
  1151. for (const [url, text] of URLs) {
  1152. const link = list.appendChild(document.createElement("li")).appendChild(document.createElement("code"));
  1153. link.textContent = url;
  1154. link.title = text;
  1155. }
  1156. }
  1157. }
  1158. alternativeURLs(config.get("other.libAlternativeUrl"));
  1159. // Image proxy
  1160. if (config.get("other.imageProxy")) {
  1161. const PROXY = "https://wsrv.nl/?url=";
  1162. const images = $$("a[href^='/rails/active_storage/blobs/redirect/'] > img[src^='https://greasyfork.']");
  1163. for (const img of images) {
  1164. img.src = PROXY + img.src;
  1165. const link = img.parentElement;
  1166. link.href = PROXY + link.href;
  1167. }
  1168. }
  1169. // Lazy image
  1170. if (config.get("other.lazyImage")) {
  1171. const images = $$(".user-content img");
  1172. for (const image of images) {
  1173. image.loading = "lazy";
  1174. }
  1175. }
  1176.  
  1177. // Initialize css
  1178. for (const prop in dynamicStyles) {
  1179. cssHelper(prop, config.get(prop));
  1180. }
  1181. for (const prop in enumStyles) {
  1182. enumStyleHelper(prop, config.get(prop));
  1183. }
  1184. // Dynamically respond to config changes
  1185. const callbacks = {
  1186. "filterAndSearch.shortcut": shortcut,
  1187. "filterAndSearch.regexFilter": regexFilter,
  1188. "codeblocks.autoHideCode": autoHide,
  1189. "codeblocks.autoHideRows": autoHide,
  1190. "codeblocks.tabSize": tabSize,
  1191. "codeblocks.metadata": metadata,
  1192. "display.flatLayout": flatLayout,
  1193. "display.alwaysShowNotification": alwaysShowNotification,
  1194. "credentials.captureCredentials": captureCredentials,
  1195. "other.shortLink": shortLink,
  1196. "other.libAlternativeUrl": alternativeURLs,
  1197. };
  1198. config.addEventListener("set", e => {
  1199. if (e.detail.prop in dynamicStyles) {
  1200. cssHelper(e.detail.prop, e.detail.after);
  1201. }
  1202. if (e.detail.prop in enumStyles) {
  1203. enumStyleHelper(e.detail.prop, e.detail.after);
  1204. }
  1205. const callback = callbacks[e.detail.prop];
  1206. if (callback && (e.detail.before !== e.detail.after)) {
  1207. callback(e.detail.after);
  1208. }
  1209. });
  1210. })();