to-userscript
Version:
Converts simple browser extensions to userscripts
240 lines (195 loc) • 6.92 kB
JavaScript
const fs = require("fs").promises;
const path = require("path");
require("isomorphic-fetch");
const chalk = require("chalk");
const debug = require("debug")("to-userscript:downloader");
const { getCrxUrl } = require("./downloadExt");
async function getDownloadableUrl(sourceInfo) {
debug("Getting downloadable URL for: %o", sourceInfo);
switch (sourceInfo.type) {
case "chrome-store":
return getCrxUrl(sourceInfo.url);
case "firefox-store":
return getFirefoxAddonUrl(sourceInfo.url);
case "url":
return sourceInfo.url;
default:
throw new Error(
`Unsupported source type for download: ${sourceInfo.type}`,
);
}
}
async function getFirefoxAddonUrl(webstoreUrl) {
debug("Extracting Firefox addon download URL from: %s", webstoreUrl);
// Extract addon ID from Firefox addon URL
const match = webstoreUrl.match(/\/addon\/([^\/]+)/);
if (!match) {
throw new Error("Invalid Firefox addon URL format");
}
const addonId = match[1];
debug("Extracted addon ID: %s", addonId);
// Firefox addon API endpoint for latest version
// Note: This is a simplified approach. In production, you might want to:
// 1. Use the AMO API to get addon details
// 2. Parse the addon page HTML to find the download link
// 3. Handle addon permissions and compatibility
try {
// Try to get addon details from AMO API
const apiUrl = `https://addons.mozilla.org/api/v5/addons/addon/${addonId}/`;
debug("Fetching addon details from API: %s", apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(
`AMO API request failed: ${response.status} ${response.statusText}`,
);
}
const addonData = await response.json();
debug(
"Addon data received: %s v%s",
addonData.name?.en || addonData.name,
addonData.current_version?.version,
);
if (!addonData.current_version?.file?.url) {
throw new Error("No download URL found in addon data");
}
return addonData.current_version.file.url;
} catch (error) {
debug("AMO API failed, trying direct URL construction: %s", error.message);
// Fallback: construct direct download URL
// This might not always work, but it's worth trying
const directUrl = `https://addons.mozilla.org/firefox/downloads/latest/${addonId}/addon-${addonId}-latest.xpi`;
debug("Using direct download URL: %s", directUrl);
return directUrl;
}
}
const ProgressBar = require("cli-progress");
async function downloadFile(url, destinationPath) {
debug("Downloading file from: %s", url);
debug("Destination: %s", destinationPath);
// Ensure destination directory exists
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
const response = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
if (!response.ok) {
throw new Error(
`Download failed: ${response.status} ${response.statusText} for ${url}`,
);
}
const contentLength = parseInt(
response.headers.get("content-length") || "0",
10,
);
const contentType =
response.headers.get("content-type") || "application/octet-stream";
debug("Content-Length: %d bytes", contentLength);
debug("Content-Type: %s", contentType);
let progressBar = null;
if (contentLength > 0) {
progressBar = new ProgressBar.SingleBar({
format: "Downloading [{bar}] {percentage}% | {value}/{total} bytes",
barCompleteChar: "█",
barIncompleteChar: "░",
hideCursor: true,
});
progressBar.start(contentLength, 0);
} else {
console.log(chalk.yellow("Downloading... (size unknown)"));
}
const fileStream = await fs.open(destinationPath, "w");
let downloadedBytes = 0;
try {
for await (const chunk of response.body) {
await fileStream.write(chunk);
downloadedBytes += chunk.length;
if (contentLength > 0 && progressBar) {
progressBar.update(downloadedBytes);
}
}
if (progressBar) {
progressBar.stop();
console.log(chalk.green(`Downloaded ${downloadedBytes} bytes`));
} else {
console.log(chalk.green(`Downloaded ${downloadedBytes} bytes`));
}
debug(
"Download completed: %d bytes written to %s",
downloadedBytes,
destinationPath,
);
} finally {
await fileStream.close();
}
// Verify the downloaded file exists and has content
const stats = await fs.stat(destinationPath);
if (stats.size === 0) {
throw new Error("Downloaded file is empty");
}
debug("Download verification successful: %d bytes", stats.size);
return destinationPath;
}
function guessFileExtension(url, contentType) {
// Try to guess from URL first
const urlExt = path.extname(new URL(url).pathname).toLowerCase();
if ([".crx", ".xpi", ".zip"].includes(urlExt)) {
return urlExt;
}
// Guess from content type
if (contentType) {
if (contentType.includes("chrome-extension")) {
return ".crx";
} else if (contentType.includes("application/zip")) {
return ".zip";
} else if (contentType.includes("application/x-xpinstall")) {
return ".xpi";
}
}
// Default to .zip as most archives can be handled as zip
return ".zip";
}
async function downloadExtension(sourceInfo, downloadDir) {
debug("Starting extension download: %o", sourceInfo);
try {
// Ensure download directory exists
await fs.mkdir(downloadDir, { recursive: true });
// Get the actual download URL
const downloadUrl = await getDownloadableUrl(sourceInfo);
debug("Download URL resolved: %s", downloadUrl);
// Make a HEAD request to get content info
const headResponse = await fetch(downloadUrl, {
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
if (!headResponse.ok) {
debug(
"HEAD request failed, proceeding with GET: %s %s",
headResponse.status,
headResponse.statusText,
);
}
const contentType = headResponse.headers.get("content-type") || "";
const extension = guessFileExtension(downloadUrl, contentType);
const filename = `extension-${Date.now()}${extension}`;
const destinationPath = path.join(downloadDir, filename);
debug("Will save as: %s", destinationPath);
// Download the file
await downloadFile(downloadUrl, destinationPath);
return destinationPath;
} catch (error) {
const errorMsg = `Failed to download extension: ${error.message}`;
debug("Download error: %s", errorMsg);
throw new Error(errorMsg);
}
}
module.exports = {
downloadExtension,
getDownloadableUrl,
downloadFile,
guessFileExtension,
};