diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03a8611 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +.web-extension-id +node_modules/ + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..22f371e --- /dev/null +++ b/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Archive.org Link Grabber", + "version": "0.1.0", + "description": "Filter and export archive.org /download links; copy or send to aria2 RPC.", + "action": { + "default_title": "Archive.org Link Grabber", + "default_popup": "src/popup/index.html" + }, + "permissions": [ + "storage", + "clipboardWrite", + "activeTab" + ], + "host_permissions": [ + "https://archive.org/*", + "http://localhost:6800/*", + "https://localhost:6800/*" + ], + "background": { + "service_worker": "src/background/index.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://archive.org/download/*"], + "js": ["src/content/collect.js"] + } + ] +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba5aef0 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "archive-org-link-grabber", + "version": "0.1.0", + "private": true, + "description": "Firefox WebExtension to filter archive.org /download links and copy/send to aria2.", + "scripts": { + "dev": "web-ext run --start-url https://archive.org --source-dir .", + "build": "web-ext build -o -a dist", + "lint": "echo 'Add ESLint config to enable' && exit 0", + "format": "echo 'Add Prettier config to enable' && exit 0", + "test": "echo 'No tests yet' && exit 0" + } +} + diff --git a/src/background/index.js b/src/background/index.js new file mode 100644 index 0000000..217075e --- /dev/null +++ b/src/background/index.js @@ -0,0 +1,19 @@ +import { addUrisBatch } from "../lib/aria2.js"; + +browser.runtime.onMessage.addListener(async (msg, sender) => { + if (!msg || !msg.type) return; + + if (msg.type === 'aria2.send') { + const { endpoint, secret, uris, options } = msg.payload || {}; + if (!endpoint || !Array.isArray(uris) || uris.length === 0) { + return { ok: false, error: 'Missing endpoint or URIs' }; + } + try { + const res = await addUrisBatch({ endpoint, secret, uris, options }); + return { ok: true, result: res }; + } catch (err) { + return { ok: false, error: String(err && err.message || err) }; + } + } +}); + diff --git a/src/content/collect.js b/src/content/collect.js new file mode 100644 index 0000000..2eaadac --- /dev/null +++ b/src/content/collect.js @@ -0,0 +1,59 @@ +(() => { + function extFromName(name) { + const base = name.split('?')[0]; + const idx = base.lastIndexOf('.'); + return idx > -1 ? base.slice(idx + 1).toLowerCase() : ''; + } + + function toItem(a) { + try { + const url = a.href; + const u = new URL(url); + const name = decodeURIComponent(u.pathname.split('/').pop() || ''); + // Attempt to find size/date hints in the DOM (best-effort, optional) + let sizeText = ''; + let dateText = ''; + const row = a.closest('tr'); + if (row) { + const tds = Array.from(row.querySelectorAll('td')); + // Heuristics: look for cells containing size-like patterns or dates + for (const td of tds) { + const t = td.textContent.trim(); + if (!sizeText && /\b\d+(?:[\.,]\d+)?\s?(?:[KMG]B|bytes?)\b/i.test(t)) sizeText = t; + if (!dateText && /\d{4}-\d{2}-\d{2}/.test(t)) dateText = t.match(/\d{4}-\d{2}-\d{2}/)[0]; + } + } + return { + url, + name, + type: extFromName(name), + sizeText, + dateText + }; + } catch (e) { + return null; + } + } + + async function collectLinks() { + const anchors = Array.from(document.querySelectorAll('a[href]')); + const items = []; + for (const a of anchors) { + const href = a.getAttribute('href') || ''; + // Only include likely file links under /download/ + if (href.includes('/download/')) { + const item = toItem(a); + if (item && item.name) items.push(item); + } + } + return items; + } + + // Respond to messages from the popup to collect links + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg && msg.type === 'collectLinks') { + return collectLinks(); + } + }); +})(); + diff --git a/src/lib/aria2.js b/src/lib/aria2.js new file mode 100644 index 0000000..7509b4d --- /dev/null +++ b/src/lib/aria2.js @@ -0,0 +1,36 @@ +export async function addUri({ endpoint, secret, uri, options = {} }) { + const body = { + jsonrpc: "2.0", + id: "archive-org-link-grabber:" + Date.now(), + method: "aria2.addUri", + params: [ + secret ? `token:${secret}` : undefined, + [uri], + options + ].filter(v => v !== undefined) + }; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const text = await res.text().catch(() => String(res.status)); + throw new Error(`aria2 RPC error ${res.status}: ${text}`); + } + return res.json(); +} + +export async function addUrisBatch({ endpoint, secret, uris, options = {} }) { + const results = []; + for (const uri of uris) { + // Simple sequential send; keep it obvious and robust + // Consider concurrency/batching later if needed + // eslint-disable-next-line no-await-in-loop + const r = await addUri({ endpoint, secret, uri, options }); + results.push(r); + } + return results; +} + diff --git a/src/lib/filters.js b/src/lib/filters.js new file mode 100644 index 0000000..34d6d53 --- /dev/null +++ b/src/lib/filters.js @@ -0,0 +1,78 @@ +export function buildMatcher(input, { useRegex = false, caseSensitive = false } = {}) { + const value = (input || '').trim(); + if (!value) return () => true; + if (useRegex || /^\/.+\/[gimsuy]*$/.test(value)) { + // Allow /pattern/flags syntax + let pattern = value; + let flags = caseSensitive ? '' : 'i'; + const m = value.match(/^\/(.*)\/([gimsuy]*)$/); + if (m) { pattern = m[1]; flags = m[2] || flags; } + const re = new RegExp(pattern, flags); + return (s) => re.test(String(s || '')); + } + const needle = caseSensitive ? value : value.toLowerCase(); + return (s) => { + const hay = caseSensitive ? String(s || '') : String(s || '').toLowerCase(); + return hay.includes(needle); + }; +} + +export function parseSizeToBytes(v) { + if (v == null) return undefined; + const s = String(v).trim(); + if (!s) return undefined; + const m = s.match(/^(\d+(?:[\.,]\d+)?)\s*(B|KB|MB|GB|TB)?$/i); + if (!m) return Number(s) || undefined; + const n = parseFloat(m[1].replace(',', '.')); + const unit = (m[2] || 'B').toUpperCase(); + const mult = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 }[unit]; + return Math.round(n * mult); +} + +export function extFromName(name) { + const base = String(name || '').split('?')[0]; + const idx = base.lastIndexOf('.'); + return idx > -1 ? base.slice(idx + 1).toLowerCase() : ''; +} + +export function applyFilters(items, filters) { + const { + name = '', + type = '', + nameRegex = false, + typeRegex = false, + caseSensitive = false, + sizeMin = '', + sizeMax = '', + dateFrom = '', + dateTo = '' + } = filters || {}; + + const nameMatch = buildMatcher(name, { useRegex: nameRegex, caseSensitive }); + const typeMatch = buildMatcher(type, { useRegex: typeRegex, caseSensitive }); + const minBytes = parseSizeToBytes(sizeMin); + const maxBytes = parseSizeToBytes(sizeMax); + const fromTime = dateFrom ? new Date(dateFrom).getTime() : undefined; + const toTime = dateTo ? new Date(dateTo).getTime() : undefined; + + return (items || []).filter((it) => { + const okName = nameMatch(it.name); + const ext = it.type || extFromName(it.name); + const okType = typeMatch(ext); + let okSize = true; + if (minBytes != null || maxBytes != null) { + // Best-effort: parse from sizeText if provided + const s = it.sizeBytes != null ? it.sizeBytes : parseSizeToBytes(it.sizeText); + if (minBytes != null && (s == null || s < minBytes)) okSize = false; + if (maxBytes != null && (s != null && s > maxBytes)) okSize = false; + } + let okDate = true; + if (fromTime != null || toTime != null) { + const t = it.timeMs != null ? it.timeMs : (it.dateText ? new Date(it.dateText).getTime() : undefined); + if (fromTime != null && (t == null || t < fromTime)) okDate = false; + if (toTime != null && (t != null && t > toTime)) okDate = false; + } + return okName && okType && okSize && okDate; + }); +} + diff --git a/src/popup/index.html b/src/popup/index.html new file mode 100644 index 0000000..52fef09 --- /dev/null +++ b/src/popup/index.html @@ -0,0 +1,58 @@ + + + + + Archive.org Link Grabber + + + +
+

Archive.org Link Grabber

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + 0 matches +
+
+ +
+

aria2 RPC

+
+ +
+
+ +
+
+ + +
+
+ +
+
+ + + + + diff --git a/src/popup/index.js b/src/popup/index.js new file mode 100644 index 0000000..33869c3 --- /dev/null +++ b/src/popup/index.js @@ -0,0 +1,117 @@ +import { applyFilters } from "../lib/filters.js"; + +const els = { + fType: document.getElementById('f-type'), + fTypeRe: document.getElementById('f-type-re'), + fName: document.getElementById('f-name'), + fNameRe: document.getElementById('f-name-re'), + fSizeMin: document.getElementById('f-size-min'), + fSizeMax: document.getElementById('f-size-max'), + fDateFrom: document.getElementById('f-date-from'), + fDateTo: document.getElementById('f-date-to'), + fCase: document.getElementById('f-case'), + btnRefresh: document.getElementById('btn-refresh'), + btnCopy: document.getElementById('btn-copy'), + btnSend: document.getElementById('btn-send'), + rpcEndpoint: document.getElementById('rpc-endpoint'), + rpcSecret: document.getElementById('rpc-secret'), + count: document.getElementById('count'), + status: document.getElementById('status'), +}; + +let allItems = []; + +function getFilters() { + return { + type: els.fType.value, + name: els.fName.value, + typeRegex: els.fTypeRe.checked, + nameRegex: els.fNameRe.checked, + sizeMin: els.fSizeMin.value, + sizeMax: els.fSizeMax.value, + dateFrom: els.fDateFrom.value, + dateTo: els.fDateTo.value, + caseSensitive: els.fCase.checked, + }; +} + +async function getActiveTabId() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + return tabs[0]?.id; +} + +async function refresh() { + els.status.textContent = ''; + try { + const tabId = await getActiveTabId(); + if (!tabId) throw new Error('No active tab'); + const items = await browser.tabs.sendMessage(tabId, { type: 'collectLinks' }); + allItems = items || []; + updateCount(); + } catch (e) { + els.status.textContent = String(e?.message || e); + } +} + +function filtered() { + return applyFilters(allItems, getFilters()); +} + +function updateCount() { + els.count.textContent = String(filtered().length); +} + +async function copyLinks() { + const urls = filtered().map(i => i.url).join('\n'); + await navigator.clipboard.writeText(urls); + els.status.textContent = `Copied ${filtered().length} link(s) to clipboard.`; +} + +async function sendToAria2() { + const endpoint = els.rpcEndpoint.value.trim(); + const secret = els.rpcSecret.value.trim(); + const uris = filtered().map(i => i.url); + if (!endpoint) { els.status.textContent = 'Please set aria2 endpoint.'; return; } + if (uris.length === 0) { els.status.textContent = 'No links to send.'; return; } + + // Persist settings + await browser.storage.local.set({ aria2: { endpoint, secret } }); + els.status.textContent = 'Sending to aria2…'; + + const res = await browser.runtime.sendMessage({ + type: 'aria2.send', + payload: { endpoint, secret, uris } + }); + if (res?.ok) { + els.status.textContent = `Sent ${uris.length} link(s) to aria2.`; + } else { + els.status.textContent = `Error: ${res?.error || 'unknown'}`; + } +} + +async function restoreSettings() { + const { aria2 } = await browser.storage.local.get('aria2'); + if (aria2) { + if (aria2.endpoint) els.rpcEndpoint.value = aria2.endpoint; + if (aria2.secret) els.rpcSecret.value = aria2.secret; + } else { + els.rpcEndpoint.value = 'http://localhost:6800/jsonrpc'; + } +} + +function wire() { + els.btnRefresh.addEventListener('click', refresh); + els.btnCopy.addEventListener('click', copyLinks); + els.btnSend.addEventListener('click', sendToAria2); + [ + els.fType, els.fTypeRe, els.fName, els.fNameRe, els.fSizeMin, els.fSizeMax, + els.fDateFrom, els.fDateTo, els.fCase + ].forEach(el => el.addEventListener('input', updateCount)); +} + +document.addEventListener('DOMContentLoaded', async () => { + wire(); + await restoreSettings(); + await refresh(); +}); + diff --git a/src/popup/styles.css b/src/popup/styles.css new file mode 100644 index 0000000..b010d4b --- /dev/null +++ b/src/popup/styles.css @@ -0,0 +1,11 @@ +body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 10px; } +header h1 { font-size: 14px; margin: 0 0 8px; } +section { margin-bottom: 10px; } +.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; } +label { font-size: 12px; } +label input { margin-left: 6px; } +.chk { align-items: center; } +button { font-size: 12px; padding: 4px 10px; } +#count { font-weight: bold; margin: 0 4px; } +#status { font-size: 12px; white-space: pre-wrap; } +