🏠 返回首頁 

Greasy Fork is available in English.

pURLfy for Tampermonkey

The ultimate URL purifier - for Tampermonkey


Installer dette script?
  1. // ==UserScript==
  2. // @name pURLfy for Tampermonkey
  3. // @name:zh-CN pURLfy for Tampermonkey
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.5.5
  6. // @description The ultimate URL purifier - for Tampermonkey
  7. // @description:zh-cn 终极 URL 净化器 - Tampermonkey 版本
  8. // @icon https://github.com/PRO-2684/pURLfy/raw/main/images/logo.svg
  9. // @author PRO
  10. // @match *://*/*
  11. // @run-at document-start
  12. // @grant GM_setValue
  13. // @grant GM_getValue
  14. // @grant GM_deleteValue
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_unregisterMenuCommand
  17. // @grant GM_addValueChangeListener
  18. // @grant GM_getResourceText
  19. // @grant GM_setClipboard
  20. // @grant GM_xmlhttpRequest
  21. // @grant unsafeWindow
  22. // @connect *
  23. // @require https://cdn.jsdelivr.net/npm/@trim21/gm-fetch@0.2.3
  24. // @require https://update.greasyfork.org/scripts/492078/1499254/pURLfy.js
  25. // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.1/config.min.js#md5=525526b8f0b6b8606cedf08c651163c2
  26. // @resource rules-tracking https://cdn.jsdelivr.net/gh/PRO-2684/pURLfy-rules@core-0.3.x/tracking.min.json
  27. // @resource rules-outgoing https://cdn.jsdelivr.net/gh/PRO-2684/pURLfy-rules@core-0.3.x/outgoing.min.json
  28. // @resource rules-shortener https://cdn.jsdelivr.net/gh/PRO-2684/pURLfy-rules@core-0.3.x/shortener.min.json
  29. // @resource rules-alternative https://cdn.jsdelivr.net/gh/PRO-2684/pURLfy-rules@core-0.3.x/alternative.min.json
  30. // @resource rules-other https://cdn.jsdelivr.net/gh/PRO-2684/pURLfy-rules@core-0.3.x/other.min.json
  31. // @license gpl-3.0
  32. // ==/UserScript==
  33. (function () {
  34. const tag1 = "purlfy-purifying";
  35. const tag2 = "purlfy-purified";
  36. const eventName = "purlfy-purify-done";
  37. const window = unsafeWindow;
  38. const configDesc = {
  39. $default: {
  40. autoClose: false
  41. },
  42. rules: {
  43. name: "📖 Rules Settings",
  44. title: "Enable or disable rules",
  45. type: "folder",
  46. items: {
  47. tracking: {
  48. name: "Tracking",
  49. title: "Rules for purifying tracking links",
  50. type: "bool",
  51. value: true,
  52. },
  53. outgoing: {
  54. name: "Outgoing",
  55. title: "Rules for purifying outgoing links",
  56. type: "bool",
  57. value: true,
  58. },
  59. shortener: {
  60. name: "Shortener",
  61. title: "Rules for restoring shortened links",
  62. type: "bool",
  63. value: true,
  64. },
  65. alternative: {
  66. name: "Alternative",
  67. title: "Redirects you from some websites to their better alternatives",
  68. type: "bool",
  69. value: false,
  70. },
  71. other: {
  72. name: "Other",
  73. title: "Rules for purifying other types of links",
  74. type: "bool",
  75. value: false,
  76. },
  77. removeTextFragment: {
  78. name: "Remove Text Fragment",
  79. title: "Remove Text Fragments from URL",
  80. type: "bool",
  81. value: false,
  82. },
  83. },
  84. },
  85. hooks: {
  86. name: "🪝 Hooks Settings",
  87. title: "Enable or disable hooks",
  88. type: "folder",
  89. items: {
  90. locationHref: {
  91. name: "location.href",
  92. title: "Check location.href",
  93. type: "bool",
  94. value: true,
  95. },
  96. click: {
  97. name: "click",
  98. title: "Intercept `click` events",
  99. type: "bool",
  100. value: true,
  101. },
  102. mousedown: {
  103. name: "mousedown",
  104. title: "Intercept `mousedown` events",
  105. type: "bool",
  106. value: true,
  107. },
  108. auxclick: {
  109. name: "auxclick",
  110. title: "Intercept `auxclick` events",
  111. type: "bool",
  112. value: true,
  113. },
  114. touchstart: {
  115. name: "touchstart",
  116. title: "Intercept `touchstart` events",
  117. type: "bool",
  118. value: true,
  119. },
  120. windowOpen: {
  121. name: "window.open",
  122. title: "Hook `window.open` calls",
  123. type: "bool",
  124. value: true,
  125. },
  126. pushState: {
  127. name: "pushState",
  128. title: "Hook `history.pushState` calls",
  129. type: "bool",
  130. value: false,
  131. },
  132. replaceState: {
  133. name: "replaceState",
  134. title: "Hook `history.replaceState` calls",
  135. type: "bool",
  136. value: false,
  137. },
  138. bing: {
  139. name: "Bing",
  140. title: "Site-specific hook for Bing",
  141. type: "bool",
  142. value: true,
  143. },
  144. },
  145. },
  146. statistics: {
  147. name: "📊 Statistics",
  148. title: "Show statistics",
  149. type: "folder",
  150. items: {
  151. $default: {
  152. input: (prop, orig) => confirm(`Reset "${prop}"?`) ? 0 : orig,
  153. processor: "same",
  154. formatter: "normal",
  155. },
  156. url: {
  157. name: "URL",
  158. title: "Number of links purified",
  159. value: 0,
  160. },
  161. param: {
  162. name: "Parameter",
  163. title: "Number of parameters removed",
  164. value: 0,
  165. },
  166. decoded: {
  167. name: "Decoded",
  168. title: "Number of URLs decoded (`param` mode)",
  169. value: 0,
  170. },
  171. redirected: {
  172. name: "Redirected",
  173. title: "Number of URLs redirected (`redirect` mode)",
  174. value: 0,
  175. },
  176. visited: {
  177. name: "Visited",
  178. title: "Number of URLs visited (`visit` mode)",
  179. value: 0,
  180. },
  181. char: {
  182. name: "Character",
  183. title: "Number of characters deleted",
  184. value: 0,
  185. },
  186. },
  187. },
  188. advanced: {
  189. name: "⚙️ Advanced options",
  190. title: "Advanced options",
  191. type: "folder",
  192. items: {
  193. purify: {
  194. name: "Purify URL",
  195. title: "Manually purify a URL",
  196. type: "action",
  197. },
  198. senseless: {
  199. name: "Senseless Mode",
  200. title: "Enable senseless mode",
  201. type: "bool",
  202. value: true,
  203. },
  204. disableBeacon: {
  205. name: "Disable Beacon",
  206. title: "Overwrite `navigator.sendBeacon` to a no-op function",
  207. type: "bool",
  208. value: false,
  209. },
  210. debug: {
  211. name: "Debug Mode",
  212. title: "Enable debug mode",
  213. type: "bool",
  214. value: false,
  215. }
  216. },
  217. },
  218. };
  219. const config = new GM_config(configDesc);
  220. function log(...args) {
  221. if (config.get("advanced.debug")) console.log("[pURLfy for Tampermonkey]", ...args);
  222. }
  223. // Initialize pURLfy core
  224. const purifier = new Purlfy({
  225. fetchEnabled: true,
  226. lambdaEnabled: true,
  227. fetch: GM_fetch,
  228. log: config.get("advanced.debug") ? undefined : () => { },
  229. });
  230. async function purify(url) {
  231. if (config.get("rules.removeTextFragment")) { // Remove Text Fragment
  232. const index = url.indexOf("#:~:");
  233. if (index !== -1) url = url.slice(0, index);
  234. }
  235. return purifier.purify(url);
  236. }
  237. // Import rules
  238. for (const key of config.list("rules")) {
  239. const enabled = config.get(`rules.${key}`);
  240. if (enabled) {
  241. log(`Importing rules: ${key}`);
  242. const rules = JSON.parse(GM_getResourceText(`rules-${key}`));
  243. purifier.importRules(rules);
  244. }
  245. }
  246. // Senseless mode
  247. const senseless = config.get("advanced.senseless");
  248. log(`Senseless mode is ${senseless ? "enabled" : "disabled"}.`);
  249. // Statistics listener
  250. purifier.addEventListener("statisticschange", e => {
  251. log("Statistics increment:", e.detail);
  252. for (const [key, increment] of Object.entries(e.detail)) {
  253. config.set(`statistics.${key}`, config.get(`statistics.${key}`) + increment);
  254. }
  255. });
  256. // Hooks
  257. const hooks = [];
  258. class Hook { // Dummy class for hooks
  259. name;
  260. enabled;
  261. constructor(name) { // Register a hook
  262. this.name = name;
  263. // hooks.set(name, this);
  264. hooks.push(this);
  265. this.enabled = config.get(`hooks.${name}`);
  266. }
  267. toast(content) { // Indicate that a URL has been intercepted
  268. log(`Hook "${this.name}": ${content}`);
  269. }
  270. async enable() { // Enable the hook
  271. throw new Error("Over-ride me!");
  272. }
  273. async disable() { // Disable the hook
  274. throw new Error("Over-ride me!");
  275. }
  276. }
  277. // Check location.href (not really a hook, actually)
  278. const locationHook = new Hook("locationHref");
  279. locationHook.enable = async function () { // Intercept location.href
  280. const original = location.href;
  281. const purified = (await purify(original)).url;
  282. if (original !== purified) {
  283. window.stop(); // Stop loading
  284. this.toast(`Redirect: "${original}" -> "${purified}"`);
  285. location.replace(purified);
  286. }
  287. }.bind(locationHook);
  288. locationHook.disable = async function () { } // Do nothing
  289. // Mouse-related hooks
  290. const tagNames = new Set(["A", "AREA"]);
  291. function cloneAndStop(e) { // Clone an event and stop the original
  292. const newEvt = new e.constructor(e.type, e);
  293. e.preventDefault();
  294. e.stopImmediatePropagation();
  295. return newEvt;
  296. }
  297. async function mouseHandler(e) { // Intercept mouse events
  298. const ele = e.composedPath().find(ele => tagNames.has(ele.tagName));
  299. if (ele && !ele.hasAttribute(tag2) && ele.href && !ele.getAttribute("href").startsWith("#")) {
  300. ele.removeAttribute("ping"); // Remove `ping` attribute
  301. const href = ele.href;
  302. if (!href.startsWith("https://") && !href.startsWith("http://")) return; // Ignore non-HTTP(S) URLs
  303. if (!ele.hasAttribute(tag1)) { // The first to intercept
  304. ele.toggleAttribute(tag1, true);
  305. const newEvt = senseless ? null : cloneAndStop(e);
  306. this.toast(`Intercepted: "${href}"`);
  307. const purified = await purify(href);
  308. if (purified.url !== href) {
  309. ele.href = purified.url;
  310. // if (ele.innerHTML === href) ele.innerHTML = purified.url; // Update the text
  311. if (ele.childNodes?.length === 1
  312. && ele.firstChild.nodeType === Node.TEXT_NODE
  313. && ele.firstChild.textContent === href) { // Update the text
  314. ele.firstChild.textContent = purified.url;
  315. }
  316. this.toast(`Processed: "${ele.href}"`);
  317. } else {
  318. this.toast(`Same: "${ele.href}"`);
  319. }
  320. ele.toggleAttribute(tag2, true);
  321. ele.removeAttribute(tag1);
  322. senseless || ele.dispatchEvent(newEvt);
  323. ele.dispatchEvent(new Event(eventName, { bubbles: false, cancelable: true }));
  324. } else { // Someone else has intercepted
  325. if (!senseless) {
  326. const newEvt = cloneAndStop(e);
  327. this.toast(`Waiting: "${ele.href}"`);
  328. ele.addEventListener(eventName, function () {
  329. log(`Waited: "${ele.href}"`);
  330. ele.dispatchEvent(newEvt);
  331. }, { once: true });
  332. }
  333. }
  334. }
  335. }
  336. ["click", "mousedown", "auxclick"].forEach((name) => {
  337. const hook = new Hook(name);
  338. hook.handler = mouseHandler.bind(hook);
  339. hook.enable = async function () {
  340. document.addEventListener(name, this.handler, { capture: true });
  341. }
  342. hook.disable = async function () {
  343. document.removeEventListener(name, this.handler, { capture: true });
  344. }
  345. });
  346. // Listen to `touchstart` event
  347. async function touchstartHandler(e) { // Always "senseless"
  348. const ele = e.composedPath().find(ele => tagNames.has(ele.tagName));
  349. if (ele && !ele.hasAttribute(tag1) && !ele.hasAttribute(tag2) && ele.href && !ele.getAttribute("href").startsWith("#")) {
  350. ele.removeAttribute("ping"); // Remove `ping` attribute
  351. const href = ele.href;
  352. if (!href.startsWith("https://") && !href.startsWith("http://")) return; // Ignore non-HTTP(S) URLs
  353. ele.toggleAttribute(tag1, true);
  354. this.toast(`Intercepted: "${href}"`);
  355. const purified = await purify(href);
  356. if (purified.url !== href) {
  357. ele.href = purified.url;
  358. if (ele.innerHTML === href) ele.innerHTML = purified.url; // Update the text
  359. this.toast(`Processed: "${ele.href}"`);
  360. } else {
  361. this.toast(`Same: "${ele.href}"`);
  362. }
  363. ele.toggleAttribute(tag2, true);
  364. ele.removeAttribute(tag1);
  365. ele.dispatchEvent(new Event(eventName, { bubbles: false, cancelable: true }));
  366. }
  367. }
  368. const touchstartHook = new Hook("touchstart");
  369. touchstartHook.handler = touchstartHandler.bind(touchstartHook);
  370. touchstartHook.enable = async function () {
  371. document.addEventListener("touchstart", this.handler, { capture: true });
  372. }
  373. touchstartHook.disable = async function () {
  374. document.removeEventListener("touchstart", this.handler, { capture: true });
  375. }
  376. // Hook form submit
  377. // function submitHandler(e) { // Always "senseless"
  378. // let submitter = e.submitter;
  379. // const form = submitter.form;
  380. // if (!form || form.method !== "get" || form.hasAttribute(tag2)) return;
  381. // const url = new URL(form.action, location.href);
  382. // if (url.protocol !== "http:" && url.protocol !== "https:") return; // Ignore non-HTTP(S) URLs
  383. // if (!form.hasAttribute(tag1)) { // The first to intercept
  384. // e.preventDefault();
  385. // e.stopImmediatePropagation();
  386. // form.toggleAttribute(tag1, true);
  387. // for (const input of form.elements) {
  388. // url.searchParams.set(input.name, input.value);
  389. // }
  390. // this.toast(`Intercepted: "${url.href}"`);
  391. // purify(url.href).then(r###lt => {
  392. // this.toast(`Processed: "${r###lt.url}"`);
  393. // const purified = new URL(r###lt.url);
  394. // if (purified.href !== url.href) {
  395. // form.action = purified.origin + purified.pathname;
  396. // for (const input of form.elements) {
  397. // if (input.name) {
  398. // if (purified.searchParams.has(input.name)) {
  399. // input.value = purified.searchParams.get(input.name);
  400. // purified.searchParams.delete(input.name);
  401. // input.toggleAttribute("disabled", false);
  402. // } else {
  403. // input.value = "";
  404. // input.toggleAttribute("disabled", true);
  405. // if (submitter === input) submitter = undefined;
  406. // }
  407. // }
  408. // }
  409. // for (const [key, value] of purified.searchParams) {
  410. // const input = document.createElement("input");
  411. // input.type = "hidden";
  412. // input.name = key;
  413. // input.value = value;
  414. // form.appendChild(input);
  415. // }
  416. // } else {
  417. // this.toast(`Same: "${form.action}"`);
  418. // }
  419. // form.toggleAttribute(tag2, true);
  420. // form.removeAttribute(tag1);
  421. // form.dispatchEvent(new Event(eventName, { bubbles: false, cancelable: true }));
  422. // form.requestSubmit(submitter);
  423. // });
  424. // }
  425. // }
  426. // const submitHook = new Hook("submit");
  427. // submitHook.handler = submitHandler.bind(submitHook);
  428. // submitHook.enable = async function () {
  429. // document.addEventListener("submit", this.handler, { capture: true });
  430. // }
  431. // submitHook.disable = async function () {
  432. // document.removeEventListener("submit", this.handler, { capture: true });
  433. // }
  434. // Intercept window.open
  435. const openHook = new Hook("windowOpen");
  436. openHook.original = window.open.bind(window);
  437. openHook.patched = function (url, target, features) { // Intercept window.open
  438. url = url?.toString() ?? "about:blank";
  439. if (url && url !== "about:blank" && (url.startsWith("http://") || url.startsWith("https://"))) {
  440. this.toast(`Intercepted: "${url}"`);
  441. purify(url).then(purified => {
  442. this.toast(`Processed: "${purified.url}"`);
  443. this.original(purified.url, target, features);
  444. });
  445. return true; // Ideally, return a window object; however, it's impossible to do so
  446. } else {
  447. return this.original(url, target, features);
  448. }
  449. }.bind(openHook);
  450. openHook.enable = async function () {
  451. window.open = this.patched;
  452. }
  453. openHook.disable = async function () {
  454. window.open = this.original;
  455. }
  456. function patch(orig) { // Patch history functions
  457. function patched(...args) {
  458. const url = args[2];
  459. if (url && (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//") || url.startsWith("/") || url.startsWith("?"))) {
  460. this.toast(`Intercepted: "${url}"`);
  461. const resolved = new URL(url, location.href).href;
  462. purify(resolved).then(purified => {
  463. this.toast(`Processed: "${purified.url}"`);
  464. args[2] = purified.url;
  465. orig.apply(history, args);
  466. });
  467. } else {
  468. orig.apply(history, args);
  469. }
  470. }
  471. return patched;
  472. }
  473. const pushStateHook = new Hook("pushState");
  474. pushStateHook.original = history.pushState;
  475. pushStateHook.patched = patch(pushStateHook.original).bind(pushStateHook);
  476. pushStateHook.enable = async function () {
  477. history.pushState = pushStateHook.patched;
  478. }
  479. pushStateHook.disable = async function () {
  480. history.pushState = pushStateHook.original;
  481. }
  482. const replaceStateHook = new Hook("replaceState");
  483. replaceStateHook.original = history.replaceState;
  484. replaceStateHook.patched = patch(replaceStateHook.original).bind(replaceStateHook);
  485. replaceStateHook.enable = async function () {
  486. history.replaceState = replaceStateHook.patched;
  487. }
  488. replaceStateHook.disable = async function () {
  489. history.replaceState = replaceStateHook.original;
  490. }
  491. // Site-specific hooks
  492. switch (location.hostname) {
  493. case "www.bing.com":
  494. case "cn.bing.com": { // Bing
  495. // Hook `addEventListener`
  496. const bingHook = new Hook("bing");
  497. bingHook.blacklist = { "A": new Set(["mouseenter", "mouseleave", "mousedown"]), "P": new Set(["mouseover", "mouseout", "click"]) }
  498. bingHook.original = HTMLElement.prototype.addEventListener;
  499. bingHook.patched = function (type, listener, options) {
  500. if (bingHook.blacklist[this.tagName] && bingHook.blacklist[this.tagName].has(type)) { // Block events
  501. return;
  502. }
  503. return bingHook.original.call(this, type, listener, options);
  504. };
  505. bingHook.enable = async function () {
  506. HTMLElement.prototype.addEventListener = bingHook.patched;
  507. }
  508. bingHook.disable = async function () {
  509. HTMLElement.prototype.addEventListener = bingHook.original;
  510. }
  511. break;
  512. }
  513. default: {
  514. break;
  515. }
  516. }
  517. // Is there more hooks to add?
  518. // Enable hooks
  519. const promises = [];
  520. for (const hook of hooks) {
  521. hook.enabled && promises.push(hook.enable().then(() => {
  522. log(`Hook "${hook.name}" enabled.`);
  523. }));
  524. }
  525. Promise.all(promises).then(() => {
  526. log(`[core ${Purlfy.version}] Initialized successfully! 🎉`);
  527. });
  528. // advanced.disableBeacon
  529. if (config.get("advanced.disableBeacon")) {
  530. Object.defineProperty(navigator, "sendBeacon", {
  531. value: (...args) => {
  532. log("Blocked `navigator.sendBeacon`:", ...args);
  533. return false;
  534. },
  535. writable: false,
  536. configurable: false,
  537. });
  538. }
  539. // Manual purify
  540. function trim(url) { // Leave at most 100 characters
  541. return url.length > 100 ? url.slice(0, 100) + "..." : url;
  542. }
  543. function showPurify() {
  544. const url = prompt("Enter the URL to purify:", location.href);
  545. if (!url) return;
  546. purify(url).then(r###lt => {
  547. GM_setClipboard(r###lt.url);
  548. alert(`Original: ${trim(url)}\nR###lt (copied): ${trim(r###lt.url)}\nMatched rule: ${r###lt.rule}`);
  549. });
  550. };
  551. config.addEventListener("get", (e) => {
  552. if (e.detail.prop === "advanced.purify") {
  553. showPurify();
  554. }
  555. });
  556. })();