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
JavaScript
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 };