Copy Unvisited Links (Custom History v1.9 - Filter Pasted URLs)

Tracks clicked/visited links. Copies unvisited links from page and also Filters user-pasted URLs against history.

当前为 2025-05-20 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Copy Unvisited Links (Custom History v1.9 - Filter Pasted URLs)
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.9
  5. // @description Tracks clicked/visited links. Copies unvisited links from page and also Filters user-pasted URLs against history.
  6. // @author Greg Cromwell / Gemini AI
  7. // @license MIT
  8. // @match *://*/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_setClipboard
  12. // @run-at document-idle
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. // --- Configuration ---
  19. const SCRIPT_VERSION = "1.9";
  20. const CONSOLE_PREFIX = `[Custom History v${SCRIPT_VERSION}]`;
  21. const CUSTOM_HISTORY_KEY = `customUserVisitedLinks_v${SCRIPT_VERSION}`;
  22. const MAX_CUSTOM_HISTORY_SIZE = 10000;
  23. const COPY_BUTTON_ID = `copyCustomUnvisitedBtn_v${SCRIPT_VERSION}`;
  24. const PASTE_FILTER_BUTTON_ID = `pasteFilterBtn_v${SCRIPT_VERSION}`;
  25. const MODAL_ID = `pasteFilterModal_v${SCRIPT_VERSION}`;
  26. const TEXTAREA_ID = `pasteFilterTextarea_v${SCRIPT_VERSION}`;
  27.  
  28. console.log(`${CONSOLE_PREFIX} Script loading.`);
  29.  
  30. // --- 1. URL Normalization ---
  31. function normalizeUrl(url) {
  32. try {
  33. const urlObj = new URL(url, document.baseURI);
  34. let normalized = `${urlObj.protocol}//${urlObj.hostname}${urlObj.port ? ':' + urlObj.port : ''}${urlObj.pathname}`;
  35. if (normalized.length > 1 && normalized.endsWith('/')) {
  36. normalized = normalized.slice(0, -1);
  37. }
  38. return normalized.toLowerCase();
  39. } catch (e) {
  40. console.warn(`${CONSOLE_PREFIX} Could not normalize URL: "${url}"`, e.message);
  41. return null;
  42. }
  43. }
  44.  
  45. // --- 2. Custom History Management ---
  46. async function getCustomHistorySet() {
  47. const historyArray = await GM_getValue(CUSTOM_HISTORY_KEY, []);
  48. return new Set(Array.isArray(historyArray) ? historyArray : []);
  49. }
  50.  
  51. async function addUrlToCustomHistory(url) {
  52. const normalizedUrl = normalizeUrl(url);
  53. if (!normalizedUrl) return;
  54. let historyArray = await GM_getValue(CUSTOM_HISTORY_KEY, []);
  55. if (!Array.isArray(historyArray)) {
  56. historyArray = [];
  57. }
  58. const tempHistorySet = new Set(historyArray);
  59. if (!tempHistorySet.has(normalizedUrl)) {
  60. historyArray.push(normalizedUrl);
  61. if (historyArray.length > MAX_CUSTOM_HISTORY_SIZE) {
  62. historyArray = historyArray.slice(historyArray.length - MAX_CUSTOM_HISTORY_SIZE);
  63. }
  64. await GM_setValue(CUSTOM_HISTORY_KEY, historyArray);
  65. }
  66. }
  67.  
  68. // --- 3. Link Processing: Attach Click Listeners ---
  69. async function processLinksOnPageForHistory() {
  70. const links = document.querySelectorAll('a[href]');
  71. links.forEach(link => {
  72. const linkHref = link.href;
  73. link.addEventListener('click', function handleLinkClick() {
  74. addUrlToCustomHistory(linkHref);
  75. });
  76. });
  77. }
  78.  
  79. // --- 4. Button Functionality ---
  80.  
  81. // Function for the first button: Copy "New" Links from page
  82. async function handleCopyUnvisitedClick() {
  83. console.log(`${CONSOLE_PREFIX} handleCopyUnvisitedClick called.`);
  84. alert("Processing page links... Check console (F12) for progress.");
  85. const visitedSet = await getCustomHistorySet();
  86. const unvisitedLinksToCopy = [];
  87. let pageLinkCount = 0;
  88. const allPageLinks = document.querySelectorAll('a[href]');
  89. pageLinkCount = allPageLinks.length;
  90. allPageLinks.forEach(link => {
  91. const originalHref = link.href;
  92. const normalizedHref = normalizeUrl(originalHref);
  93. if (normalizedHref && !normalizedHref.startsWith('javascript:') && !normalizedHref.startsWith('mailto:')) {
  94. if (!visitedSet.has(normalizedHref)) {
  95. unvisitedLinksToCopy.push(originalHref);
  96. }
  97. }
  98. });
  99. const uniqueUnvisitedLinks = [...new Set(unvisitedLinksToCopy)];
  100. if (uniqueUnvisitedLinks.length > 0) {
  101. GM_setClipboard(uniqueUnvisitedLinks.join('\n'), 'text');
  102. alert(`Copied ${uniqueUnvisitedLinks.length} unique "new" link(s) (not in custom history) out of ${pageLinkCount} links to clipboard.`);
  103. } else {
  104. alert(`No "new" links found on this page (out of ${pageLinkCount} links) based on your custom history.`);
  105. }
  106. console.log(`${CONSOLE_PREFIX} handleCopyUnvisitedClick finished.`);
  107. }
  108.  
  109. // --- Modal for Pasting Links ---
  110. function showPasteModal(callback) {
  111. if (document.getElementById(MODAL_ID)) {
  112. document.getElementById(MODAL_ID).style.display = 'flex';
  113. document.getElementById(TEXTAREA_ID).value = ''; // Clear previous content
  114. document.getElementById(TEXTAREA_ID).focus();
  115. return;
  116. }
  117.  
  118. const modalOverlay = document.createElement('div');
  119. modalOverlay.id = MODAL_ID;
  120. Object.assign(modalOverlay.style, {
  121. position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
  122. backgroundColor: 'rgba(0,0,0,0.6)', display: 'flex',
  123. justifyContent: 'center', alignItems: 'center', zIndex: '10001'
  124. });
  125.  
  126. const modalContent = document.createElement('div');
  127. Object.assign(modalContent.style, {
  128. background: 'white', padding: '25px', borderRadius: '8px',
  129. boxShadow: '0 4px 15px rgba(0,0,0,0.2)', minWidth: '350px', maxWidth: '90%',
  130. display: 'flex', flexDirection: 'column', gap: '15px'
  131. });
  132.  
  133. const title = document.createElement('h3');
  134. title.textContent = 'Filter Pasted Links';
  135. Object.assign(title.style, { margin: '0 0 10px 0', textAlign: 'center' });
  136.  
  137. const instruction = document.createElement('p');
  138. instruction.textContent = 'Paste your list of URLs below (one URL per line):';
  139. Object.assign(instruction.style, { margin: '0 0 5px 0', fontSize: '14px' });
  140.  
  141.  
  142. const textarea = document.createElement('textarea');
  143. textarea.id = TEXTAREA_ID;
  144. Object.assign(textarea.style, {
  145. width: 'calc(100% - 20px)', minHeight: '150px', border: '1px solid #ccc',
  146. borderRadius: '4px', padding: '10px', fontSize: '13px', resize: 'vertical'
  147. });
  148.  
  149. const buttonContainer = document.createElement('div');
  150. Object.assign(buttonContainer.style, { display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '10px' });
  151.  
  152. const processButton = document.createElement('button');
  153. processButton.textContent = 'Filter & Copy';
  154. Object.assign(processButton.style, {
  155. padding: '8px 15px', background: '#4CAF50', color: 'white',
  156. border: 'none', borderRadius: '4px', cursor: 'pointer'
  157. });
  158.  
  159. const cancelButton = document.createElement('button');
  160. cancelButton.textContent = 'Cancel';
  161. Object.assign(cancelButton.style, {
  162. padding: '8px 15px', background: '#f44336', color: 'white',
  163. border: 'none', borderRadius: '4px', cursor: 'pointer'
  164. });
  165.  
  166. processButton.onclick = () => {
  167. const text = textarea.value;
  168. modalOverlay.style.display = 'none';
  169. callback(text);
  170. };
  171.  
  172. cancelButton.onclick = () => {
  173. modalOverlay.style.display = 'none';
  174. };
  175. modalOverlay.onclick = (event) => { // Close if backdrop is clicked
  176. if (event.target === modalOverlay) {
  177. modalOverlay.style.display = 'none';
  178. }
  179. };
  180.  
  181.  
  182. buttonContainer.append(cancelButton, processButton);
  183. modalContent.append(title, instruction, textarea, buttonContainer);
  184. modalOverlay.appendChild(modalContent);
  185. document.body.appendChild(modalOverlay);
  186. textarea.focus();
  187. }
  188.  
  189. // Function for the second button: Process Pasted Text
  190. async function handleFilterPastedContentClick() {
  191. console.log(`${CONSOLE_PREFIX} handleFilterPastedContentClick called.`);
  192. showPasteModal(async (pastedText) => {
  193. if (!pastedText || pastedText.trim() === "") {
  194. alert("No text was pasted or processed.");
  195. console.log(`${CONSOLE_PREFIX} No text provided in modal.`);
  196. return;
  197. }
  198.  
  199. const urlsFromPastedText = pastedText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
  200. if (urlsFromPastedText.length === 0) {
  201. alert("No valid URLs found in the pasted text after parsing.");
  202. console.log(`${CONSOLE_PREFIX} No URLs found in pasted content.`);
  203. return;
  204. }
  205. console.log(`${CONSOLE_PREFIX} Found ${urlsFromPastedText.length} lines/URLs in pasted text.`);
  206.  
  207. const visitedSet = await getCustomHistorySet();
  208. const unvisitedFromPasted = [];
  209. urlsFromPastedText.forEach(url => {
  210. const normalizedUrl = normalizeUrl(url);
  211. if (normalizedUrl && !normalizedUrl.startsWith('javascript:') && !normalizedUrl.startsWith('mailto:')) {
  212. if (!visitedSet.has(normalizedUrl)) {
  213. unvisitedFromPasted.push(url); // Store original URL
  214. }
  215. }
  216. });
  217.  
  218. const uniqueUnvisitedFromPasted = [...new Set(unvisitedFromPasted)];
  219. console.log(`${CONSOLE_PREFIX} Found ${uniqueUnvisitedFromPasted.length} unique unvisited links from pasted text.`);
  220.  
  221. if (uniqueUnvisitedFromPasted.length > 0) {
  222. GM_setClipboard(uniqueUnvisitedFromPasted.join('\n'), 'text');
  223. alert(`Filtered Pasted Text: ${uniqueUnvisitedFromPasted.length} unique "new" link(s) (not in your custom history) out of ${urlsFromPastedText.length} pasted links are now on your clipboard.`);
  224. } else {
  225. alert(`No "new" links found in your pasted text (out of ${urlsFromPastedText.length} lines) based on your custom history. Clipboard not changed.`);
  226. }
  227. console.log(`${CONSOLE_PREFIX} handleFilterPastedContentClick processing finished.`);
  228. });
  229. }
  230.  
  231. // --- UI Element Creation ---
  232. function createAndAddButtons() {
  233. // Button 1: Copy "New" Links from Page
  234. if (!document.getElementById(COPY_BUTTON_ID)) {
  235. const button1 = document.createElement('button');
  236. button1.id = COPY_BUTTON_ID;
  237. button1.textContent = `CpNew v${SCRIPT_VERSION.substring(0,3)}`; // Shorter text
  238. button1.title = `Copy "New" Links from Page (v${SCRIPT_VERSION})`;
  239. Object.assign(button1.style, {
  240. position: 'fixed', top: '75px', right: '1px', zIndex: '3001',
  241. fontSize: '10px', padding: '1px 2px', margin: '0', lineHeight: '1',
  242. minWidth: '30px', backgroundColor: 'lightblue', border: '1px solid gray',
  243. borderRadius: '2px', cursor: 'pointer', boxShadow: '1px 1px 1px rgba(0,0,0,0.1)'
  244. });
  245. button1.addEventListener('click', handleCopyUnvisitedClick);
  246. appendElementToBody(button1, "Copy New Links button");
  247. }
  248.  
  249. // Button 2: Paste & Filter
  250. if (!document.getElementById(PASTE_FILTER_BUTTON_ID)) {
  251. const button2 = document.createElement('button');
  252. button2.id = PASTE_FILTER_BUTTON_ID;
  253. button2.textContent = `Paste&Filt`; // Shorter text
  254. button2.title = `Paste and Filter URLs against Custom History (v${SCRIPT_VERSION})`;
  255. Object.assign(button2.style, {
  256. position: 'fixed', top: '95px', right: '1px', zIndex: '3000',
  257. fontSize: '10px', padding: '1px 2px', margin: '0', lineHeight: '1',
  258. minWidth: '30px', backgroundColor: 'lightgreen', border: '1px solid gray',
  259. borderRadius: '2px', cursor: 'pointer', boxShadow: '1px 1px 1px rgba(0,0,0,0.1)'
  260. });
  261. button2.addEventListener('click', handleFilterPastedContentClick);
  262. appendElementToBody(button2, "Paste & Filter button");
  263. }
  264. }
  265.  
  266. function appendElementToBody(element, elementName) {
  267. if (document.body) {
  268. document.body.appendChild(element);
  269. } else {
  270. window.addEventListener('DOMContentLoaded', () => {
  271. if (document.body) document.body.appendChild(element);
  272. else console.error(`${CONSOLE_PREFIX} ${elementName}: document.body still not found.`);
  273. }, { once: true });
  274. }
  275. }
  276.  
  277. // --- 5. Initialization and Dynamic Content Handling ---
  278. function initializeScript() {
  279. addUrlToCustomHistory(window.location.href);
  280. processLinksOnPageForHistory();
  281. createAndAddButtons();
  282. }
  283.  
  284. try {
  285. initializeScript();
  286. } catch(e) {
  287. console.error(`${CONSOLE_PREFIX} CRITICAL ERROR during script initialization:`, e);
  288. alert(`[Custom History v${SCRIPT_VERSION}] CRITICAL ERROR: ${e.message}. Check console.`);
  289. }
  290.  
  291. const observer = new MutationObserver((mutationsList) => {
  292. if (!document.getElementById(COPY_BUTTON_ID) || !document.getElementById(PASTE_FILTER_BUTTON_ID)) {
  293. createAndAddButtons();
  294. }
  295. for (const mutation of mutationsList) {
  296. if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
  297. let hasNewLinks = false;
  298. mutation.addedNodes.forEach(node => {
  299. if (node.nodeType === Node.ELEMENT_NODE && (node.matches('a[href]') || node.querySelector('a[href]'))) {
  300. hasNewLinks = true;
  301. }
  302. });
  303. if (hasNewLinks) {
  304. processLinksOnPageForHistory();
  305. break;
  306. }
  307. }
  308. }
  309. });
  310.  
  311. if (document.body) {
  312. observer.observe(document.body, { childList: true, subtree: true });
  313. } else {
  314. window.addEventListener('DOMContentLoaded', () => {
  315. if (document.body) observer.observe(document.body, { childList: true, subtree: true });
  316. else console.error(`${CONSOLE_PREFIX} MutationObserver: document.body not found.`);
  317. }, { once: true });
  318. }
  319.  
  320. window[`clearUserLinkHistory_v${SCRIPT_VERSION.replace(/\./g, '_')}`] = async () => {
  321. console.log(`${CONSOLE_PREFIX} clearUserLinkHistory called by user.`);
  322. if (confirm(`Are you sure you want to clear all custom link history for script version ${SCRIPT_VERSION}?`)) {
  323. await GM_setValue(CUSTOM_HISTORY_KEY, []);
  324. alert(`Custom link history (v${SCRIPT_VERSION}) cleared. Reload page for full effect.`);
  325. } else {
  326. alert(`Custom link history (v${SCRIPT_VERSION}) clearing cancelled.`);
  327. }
  328. };
  329. console.log(`${CONSOLE_PREFIX} Script loaded. Type 'clearUserLinkHistory_v${SCRIPT_VERSION.replace(/\./g, '_')}()' in console to clear history.`);
  330.  
  331. })();