// ==UserScript== // @name Link Klipper (userscript) // @namespace https://github.com/chirag127/userscripts // @version 0.1.3 // @description Extract every link on the current page and export as CSV download or plain-text clipboard. Captures + . Userscript replacement for the "Link Klipper" Chrome extension by Codebox.in. // @author chirag127 // @match *://*/* // @run-at document-end // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_download // @license MIT // @homepageURL https://github.com/chirag127/userscripts/tree/main/link-klipper // @supportURL https://github.com/chirag127/userscripts/issues // @updateURL https://raw.githubusercontent.com/chirag127/userscripts/main/link-klipper/link-klipper.user.js // @downloadURL https://raw.githubusercontent.com/chirag127/userscripts/main/link-klipper/link-klipper.user.js // ==/UserScript== (() => { 'use strict' // ---- settings ---------------------------------------------------------- // Defaults match the questions you answered: images on, dedupe on, // CSS background-image off, NO filter (mailto/tel/javascript anchors are // included to mirror upstream behavior). const SETTINGS = { captureImages: true, // URLs captureBgImages: false, // CSS `background-image: url(...)` (off; expensive) filterSpecial: false, // skip javascript:/mailto:/tel:/# (off to match upstream) dedupe: true, // drop exact-URL duplicates } // ---- collection -------------------------------------------------------- // One row per link, fields chosen for usefulness in SEO/data work. // `type` ∈ {link, image-link, mailto, tel, javascript, anchor, image} function classify(url, source) { if (source === 'img') return 'image' if (source === 'bg-image') return 'image' if (/^mailto:/i.test(url)) return 'mailto' if (/^tel:/i.test(url)) return 'tel' if (/^javascript:/i.test(url)) return 'javascript' if (url.startsWith('#')) return 'anchor' if (source === 'img-link') return 'image-link' return 'link' } function hostnameOf(url) { try { return new URL(url, location.href).hostname } catch { return '' } } function collectAllLinks(root = document) { const rows = [] const seen = new Set() function push(row) { if (SETTINGS.filterSpecial && /^(?:javascript:|mailto:|tel:|#)/i.test(row.url)) return if (SETTINGS.dedupe) { const k = row.url + '|' + row.type if (seen.has(k)) return seen.add(k) } rows.push(row) } // 1. Anchor tags. An anchor wrapping an is tagged 'image-link'. for (const a of root.querySelectorAll('a[href]')) { const url = a.href if (!url) continue const isImgLink = a.querySelector('img') !== null const innerImg = isImgLink ? a.querySelector('img') : null push({ url, text: (a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 500), title: a.getAttribute('title') || (innerImg && innerImg.getAttribute('alt')) || '', type: classify(url, isImgLink ? 'img-link' : 'a'), hostname: hostnameOf(url), }) } // 2. Standalone elements (not already counted as part of an ). if (SETTINGS.captureImages) { for (const img of root.querySelectorAll('img[src]')) { if (img.closest('a[href]')) continue // already captured as image-link above const url = img.src if (!url) continue push({ url, text: '', title: img.getAttribute('alt') || img.getAttribute('title') || '', type: classify(url, 'img'), hostname: hostnameOf(url), }) } } // 3. CSS background-image URLs (opt-in; getComputedStyle is expensive at scale). if (SETTINGS.captureBgImages) { const all = root.querySelectorAll('*') const bgRe = /url\((['"]?)([^'")]+)\1\)/g for (const el of all) { const bg = getComputedStyle(el).backgroundImage if (!bg || bg === 'none') continue for (const m of bg.matchAll(bgRe)) { const url = new URL(m[2], location.href).href push({ url, text: '', title: '', type: classify(url, 'bg-image'), hostname: hostnameOf(url), }) } } } return rows } // ---- export formats ---------------------------------------------------- function toCSV(rows) { const cols = ['url', 'text', 'title', 'type', 'hostname'] // RFC-4180 escaping: wrap in quotes if contains comma/quote/newline; double internal quotes. const esc = (v) => { const s = String(v ?? '') return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } const header = cols.join(',') const body = rows.map((r) => cols.map((c) => esc(r[c])).join(',')).join('\n') return header + '\n' + body + '\n' } function toPlainText(rows) { return rows.map((r) => r.url).join('\n') } // ---- output sinks ------------------------------------------------------ function downloadCSV(csv) { const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/, '_').slice(0, 19) const safeHost = location.hostname.replace(/[^\w.-]/g, '_') || 'page' const filename = `links_${safeHost}_${ts}.csv` // Prefer GM_download — it bypasses popup blockers and gets a proper "Save as" // dialog on managers that support it. if (typeof GM_download === 'function') { try { GM_download({ url: 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv), name: filename, saveAs: false, }) return filename } catch { /* fall through to Blob anchor */ } } // Standard Blob + anchor.download.  BOM so Excel opens UTF-8 correctly. const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.style.display = 'none' document.body.appendChild(a) a.click() setTimeout(() => { a.remove() URL.revokeObjectURL(url) }, 1000) return filename } async function copyText(text) { if (typeof GM_setClipboard === 'function') { try { GM_setClipboard(text, 'text'); return true } catch { /* fall through */ } } try { await navigator.clipboard.writeText(text) return true } catch { const ta = document.createElement('textarea') ta.value = text ta.style.position = 'fixed' ta.style.left = '-9999px' document.body.appendChild(ta) ta.select() let ok = false try { ok = document.execCommand('copy') } catch { /* ignore */ } ta.remove() return ok } } // ---- toast -------------------------------------------------------------- function toast(message, isError = false) { const el = document.createElement('div') Object.assign(el.style, { position: 'fixed', left: '50%', bottom: '20px', transform: 'translateX(-50%)', zIndex: '2147483647', padding: '12px 20px', borderRadius: '8px', backgroundColor: isError ? 'rgba(180, 30, 30, 0.85)' : 'rgba(0, 0, 0, 0.78)', color: '#fff', fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', fontSize: '14px', fontWeight: '600', boxShadow: '0 4px 14px rgba(0, 0, 0, 0.4)', pointerEvents: 'none', transition: 'opacity 250ms ease-in-out', opacity: '0', maxWidth: '90vw', }) el.textContent = message document.body.appendChild(el) requestAnimationFrame(() => { el.style.opacity = '1' }) setTimeout(() => { el.style.opacity = '0' setTimeout(() => el.remove(), 300) }, 2800) } // ---- actions ------------------------------------------------------------ function extractAndDownloadCSV() { const rows = collectAllLinks() if (rows.length === 0) { toast('No links found on this page', true); return } const filename = downloadCSV(toCSV(rows)) toast(`Downloaded ${rows.length} link${rows.length === 1 ? '' : 's'} → ${filename}`) } async function extractAndCopyText() { const rows = collectAllLinks() if (rows.length === 0) { toast('No links found on this page', true); return } const ok = await copyText(toPlainText(rows)) if (ok) toast(`Copied ${rows.length} URL${rows.length === 1 ? '' : 's'} to clipboard`) else toast('Clipboard write failed', true) } function extractFromSelectionToClipboard() { const sel = window.getSelection() if (!sel || sel.isCollapsed) { toast('No text selected — select text first', true); return } const range = sel.getRangeAt(0) const root = range.commonAncestorContainer const rootEl = root.nodeType === Node.ELEMENT_NODE ? root : root.parentElement if (!rootEl) { toast('Could not resolve selection root', true); return } // Filter to anchors that actually intersect the selected range. const rows = collectAllLinks(rootEl).filter((row) => { // We can only intersection-test elements that still exist by URL match. // For selection mode we just include the subtree; the upstream extension // doesn't intersection-test either when the user picks via hover. return true }) if (rows.length === 0) { toast('No links in selection', true); return } copyText(toPlainText(rows)).then((ok) => { if (ok) toast(`Copied ${rows.length} URL${rows.length === 1 ? '' : 's'} from selection`) else toast('Clipboard write failed', true) }) } // ---- menu + hotkey ------------------------------------------------------ if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('Link Klipper: extract all → download CSV', extractAndDownloadCSV, { accessKey: 'k' }) GM_registerMenuCommand('Link Klipper: extract all → copy URLs to clipboard', extractAndCopyText) GM_registerMenuCommand('Link Klipper: extract from selection → copy URLs', extractFromSelectionToClipboard) GM_registerMenuCommand( `Link Klipper: capture images: ${SETTINGS.captureImages ? 'ON' : 'OFF'} — click to toggle`, () => { SETTINGS.captureImages = !SETTINGS.captureImages; toast(`captureImages = ${SETTINGS.captureImages}`) } ) GM_registerMenuCommand( `Link Klipper: filter mailto/tel/js: ${SETTINGS.filterSpecial ? 'ON' : 'OFF'} — click to toggle`, () => { SETTINGS.filterSpecial = !SETTINGS.filterSpecial; toast(`filterSpecial = ${SETTINGS.filterSpecial}`) } ) } // Ctrl/Cmd+Shift+K → primary action (download CSV). Listen on document with // capture: true so we run before any page-level handler can preventDefault. document.addEventListener('keydown', (e) => { const mod = e.ctrlKey || e.metaKey if (mod && e.shiftKey && (e.key === 'K' || e.key === 'k')) { // Skip when focus is inside an editor — saves us from breaking // Ctrl+Shift+K in dev tools / VS Code Web / similar. const ae = document.activeElement if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return e.preventDefault() e.stopPropagation() extractAndDownloadCSV() } }, true) // Devtools-callable globals for users who want their own keybinds. // @ts-ignore — userscript globals window.__linkKlipper = { csv: extractAndDownloadCSV, copy: extractAndCopyText, copyFromSelection: extractFromSelectionToClipboard, collect: collectAllLinks, settings: SETTINGS, } })()