UNPKG

extdl

Version:

A command-line tool for downloading browser extensions. Supports Chrome, Firefox, Edge, Opera, and Thunderbird extensions.

245 lines (216 loc) 8.92 kB
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const axios = require('axios'); // --- Constants --- const BROWSERS = Object.freeze({ CHROME: 'chrome', EDGE: 'edge', OPERA: 'opera', FIREFOX: 'firefox', THUNDERBIRD: 'thunderbird' }); const FILE_EXTENSIONS = { [BROWSERS.CHROME]: { native: '.crx', zip: '.zip' }, [BROWSERS.EDGE]: { native: '.crx', zip: '.zip' }, [BROWSERS.OPERA]: { native: '.nex', zip: '.zip' }, [BROWSERS.FIREFOX]: { native: '.xpi', zip: '.zip' }, [BROWSERS.THUNDERBIRD]: { native: '.xpi', zip: '.zip' } }; const PATTERNS = { [BROWSERS.CHROME]: /^https?:\/\/(?:chrome\.google\.com\/webstore|chromewebstore\.google\.com)\/detail\/[^\/]+\/([a-z]{32})(?=[\/#!?]|$)/i, [BROWSERS.EDGE]: /^https?:\/\/microsoftedge\.microsoft\.com\/addons\/detail\/[^\/]+\/([a-z]{32})(?=[\/#!?]|$)/i, [BROWSERS.OPERA]: /^https?:\/\/addons\.opera\.com\/.*?\/(?:details|download)\/([^\/?#]+)/i, [BROWSERS.FIREFOX]: /^https?:\/\/addons(?:\-dev)?\.mozilla\.org\/.*?\/(?:addon|review)\/([^\/?#]+)/i, [BROWSERS.THUNDERBIRD]: /^https?:\/\/addons\.thunderbird\.net\/.*?\/addon\/([^\/?#]+)/i }; const BUILDERS = { [BROWSERS.CHROME]: (id, version, arch) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=${version}` + `&acceptformat=crx2,crx3&x=id%3D${id}%26uc%26nacl_arch=${arch}`, [BROWSERS.EDGE]: (id) => `https://edge.microsoft.com/extensionwebstorebase/v1/crx?response=redirect` + `&x=id%3D${id}%26installsource%3Dondemand%26uc`, [BROWSERS.OPERA]: (id) => `https://addons.opera.com/extensions/download/${id}/` }; const HTTP_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const DEFAULT_VERSION = '120.0.6099.224'; // Configure axios defaults axios.defaults.headers.common['User-Agent'] = HTTP_USER_AGENT; axios.defaults.timeout = 10000; // 10 seconds timeout // --- Helpers --- /** * Fetches JSON data from a URL. * @param {string} url - The URL to fetch JSON from. * @returns {Promise<Object|null>} The parsed JSON data or null if the request fails. */ async function fetchJson(url) { try { const response = await axios.get(url); return response.data; } catch (error) { return null; } } /** * Gets the current stable Chrome version for the platform. * @returns {Promise<string>} The Chrome version string or default version if fetch fails. */ async function getChromeVersion() { const platform = { win32: 'Windows', darwin: 'Mac' }[process.platform] || 'Linux'; const data = await fetchJson(`https://chromiumdash.appspot.com/fetch_releases?channel=Stable&platform=${platform}&num=1`); return data?.[0]?.version || DEFAULT_VERSION; } /** * Gets the architecture string for the current system. * @returns {string} The architecture string ('x86-64', 'x86-32', or 'arm'). */ function getArch() { return { x64: 'x86-64', ia32: 'x86-32' }[process.arch] || 'arm'; } /** * Converts text to a kebab-case slug. * @param {string} text - The text to slugify. * @returns {string} The slugified version of the text. */ function slugify(text) { return text .replace(/[^\w\s-]/g, '') // remove special chars except whitespace and hyphens .trim() .toLowerCase() .replace(/\s+/g, '-') // replace whitespace with hyphens } /** * Parses an extension URL to extract store type and extension ID. * @param {string} url - The extension URL to parse. * @returns {Object|null} An object with id, store, and url properties, or null if URL is invalid. */ function parseUrl(url) { for (const [store, regex] of Object.entries(PATTERNS)) { const match = regex.exec(url); if (match) return { id: match[1], store, url }; } return null; } /** * Ensures that the directory for a file path exists, creating it if necessary. * @param {string} filePath - The file path whose directory should be ensured. */ function ensureDir(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } /** * Downloads data from a URL and returns it as a Buffer. * @param {string} url - The URL to download from. * @returns {Promise<Buffer>} The downloaded data as a Buffer. * @throws {Error} If the download fails or returns a non-200 status code. */ async function downloadBuffer(url) { try { const response = await axios.get(url, { responseType: 'arraybuffer', maxRedirects: 5, validateStatus: (status) => status === 200 }); return Buffer.from(response.data); } catch (error) { if (error.response) { throw new Error(`HTTP ${error.response.status}`); } throw error; } } /** * Extracts the XPI download URL from a Firefox/Thunderbird addon page. * @param {string} pageUrl - The URL of the addon page. * @returns {Promise<string>} The XPI download URL. * @throws {Error} If the XPI URL cannot be found or extracted. */ async function extractXpi(pageUrl) { try { const response = await axios.get(pageUrl, { responseType: 'text' }); const html = response.data; const match = html.match(/https?:\/\/[^"']+\.xpi/); if (!match) throw new Error('XPI URL not found'); return match[0]; } catch (error) { throw new Error('Failed to extract XPI URL'); } } /** * Unpacks a CRX file by removing the header and returning the ZIP content. * @param {Buffer} buffer - The CRX file buffer. * @returns {Buffer} The ZIP content without the CRX header. */ function unpackCrx(buffer) { const b = new Uint8Array(buffer); const offset = b[4] === 2 ? 16 + (b[8] | (b[9] << 8) | (b[10] << 16) | (b[11] << 24)) + (b[12] | (b[13] << 8) | (b[14] << 16) | (b[15] << 24)) : 12 + (b[8] | (b[9] << 8) | (b[10] << 16) | ((b[11] << 24) >>> 0)); return buffer.slice(offset); } // --- Store Handlers --- const HANDLERS = { [BROWSERS.CHROME]: async ({ id }, version, arch) => downloadBuffer(BUILDERS[BROWSERS.CHROME](id, version, arch)), [BROWSERS.EDGE]: async ({ id }) => downloadBuffer(BUILDERS[BROWSERS.EDGE](id)), [BROWSERS.OPERA]: async ({ id }) => downloadBuffer(BUILDERS[BROWSERS.OPERA](id)), [BROWSERS.FIREFOX]: async ({ url }) => downloadBuffer(await extractXpi(url)), [BROWSERS.THUNDERBIRD]: async ({ url }) => downloadBuffer(await extractXpi(url)) }; /** * Downloads a browser extension from the given URL. * @param {string} extensionUrl - The URL of the extension. * @param {Object} options - Options for downloading. * @param {boolean} [options.zip=false] - Whether to convert to ZIP format. * @param {string} [options.outDir='.'] - Output directory for the downloaded file. * @param {string} [options.name=null] - Custom filename without extension. * @returns {Promise<string>} - The path to the downloaded file. * @throws {Error} - If the URL is invalid or download fails. */ async function downloadExt(extensionUrl, { zip = false, outDir = '.', name = null } = {}) { const info = parseUrl(extensionUrl); if (!info) throw new Error('Invalid extension URL'); const version = await getChromeVersion(); const arch = getArch(); let buffer = await HANDLERS[info.store](info, version, arch); if (zip && [BROWSERS.CHROME, BROWSERS.EDGE].includes(info.store)) { buffer = unpackCrx(buffer); } const filename = slugify(name || info.id) + (zip ? FILE_EXTENSIONS[info.store].zip : FILE_EXTENSIONS[info.store].native); const filePath = path.join(outDir, filename); ensureDir(filePath); fs.writeFileSync(filePath, buffer); console.log(`Saved: ${filePath}`); return filePath; } // --- CLI --- /** * Displays the help message for the CLI. */ function showHelp() { console.log(`Usage: extdl <url> [--zip] [--output dir] [--name file]\nOptions:\n -z, --zip convert to zip\n -o, --output output directory\n -n, --name custom filename (without extension)\n -h, --help show this help\n`); } /** * Parses command line arguments for the CLI. * @param {string[]} argv - The command line arguments array. * @returns {Object|null} Parsed arguments with url and options, or null if help should be shown. */ function parseArgs(argv) { if (!argv.length || argv.includes('-h') || argv.includes('--help')) return null; const opts = { zip: false, outDir: '.', name: null }; const [url, ...rest] = argv; for (let i = 0; i < rest.length; i++) { const arg = rest[i]; if (['-z', '--zip'].includes(arg)) opts.zip = true; if (['-o', '--output'].includes(arg)) opts.outDir = rest[++i]; if (['-n', '--name'].includes(arg)) opts.name = rest[++i]; } return { url, opts }; } if (require.main === module) { const parsed = parseArgs(process.argv.slice(2)); if (!parsed) { showHelp(); process.exit(0); } downloadExt(parsed.url, parsed.opts).catch((err) => { console.error('Error:', err.message); process.exit(1); }); } module.exports = { downloadExt, unpackCrx };