Greasy Fork 增强

增进 Greasyfork 浏览体验。

当前为 2024-12-15 提交的版本,查看 最新版本

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