Scaffold WebExtension: manifest, content collector, background aria2 RPC, popup UI, filters, and scripts
This commit is contained in:
parent
b81ca429d4
commit
8eeb1d3b20
10 changed files with 427 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
dist/
|
||||||
|
.web-extension-id
|
||||||
|
node_modules/
|
||||||
|
|
||||||
31
manifest.json
Normal file
31
manifest.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
src/background/index.js
Normal file
19
src/background/index.js
Normal file
|
|
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
59
src/content/collect.js
Normal file
59
src/content/collect.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
36
src/lib/aria2.js
Normal file
36
src/lib/aria2.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
78
src/lib/filters.js
Normal file
78
src/lib/filters.js
Normal file
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
58
src/popup/index.html
Normal file
58
src/popup/index.html
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Archive.org Link Grabber</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Archive.org Link Grabber</h1>
|
||||||
|
</header>
|
||||||
|
<section class="filters">
|
||||||
|
<div class="row">
|
||||||
|
<label>Type/Ext <input id="f-type" placeholder="mp4|mkv" /></label>
|
||||||
|
<label class="chk"><input type="checkbox" id="f-type-re" /> Regex</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Name <input id="f-name" placeholder="/^(movie|clip).*\\.mp4$/i" /></label>
|
||||||
|
<label class="chk"><input type="checkbox" id="f-name-re" /> Regex</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Size ≥ <input id="f-size-min" placeholder="100MB" size="8" /></label>
|
||||||
|
<label>≤ <input id="f-size-max" placeholder="2GB" size="8" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Date from <input id="f-date-from" type="date" /></label>
|
||||||
|
<label>to <input id="f-date-to" type="date" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label class="chk"><input type="checkbox" id="f-case" /> Case sensitive</label>
|
||||||
|
</div>
|
||||||
|
<div class="row actions">
|
||||||
|
<button id="btn-refresh">Load Links</button>
|
||||||
|
<span id="count">0</span> matches
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="aria2">
|
||||||
|
<h2>aria2 RPC</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label>Endpoint <input id="rpc-endpoint" placeholder="http://localhost:6800/jsonrpc" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Secret <input id="rpc-secret" type="password" placeholder="your-token" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="row actions">
|
||||||
|
<button id="btn-copy">Copy Links</button>
|
||||||
|
<button id="btn-send">Send to aria2</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<output id="status"></output>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script type="module" src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
117
src/popup/index.js
Normal file
117
src/popup/index.js
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
11
src/popup/styles.css
Normal file
11
src/popup/styles.css
Normal file
|
|
@ -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; }
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue