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 @@ + + +
+ +