feat(bikegear): add proxy-based image downloader with rate-limit delay
Routes image downloads through the webshare residential proxy to avoid 429 rate limiting from bikegear.in CDN on the VPS. Reads delay and concurrency from BIKEGEAR_IMAGE_DELAY / BIKEGEAR_REQUEST_DELAY env vars. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d287801551
commit
3d98c873cc
@ -1,5 +1,6 @@
|
|||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const axios = require("axios");
|
||||||
const { BRANDS } = require("./brands");
|
const { BRANDS } = require("./brands");
|
||||||
const { scrapeBikeGear, closeBrowser } = require("./scraper");
|
const { scrapeBikeGear, closeBrowser } = require("./scraper");
|
||||||
const { convertBikeGearJsonToShopifyProducts } = require("./converter");
|
const { convertBikeGearJsonToShopifyProducts } = require("./converter");
|
||||||
@ -163,6 +164,94 @@ function convertToShopifyProducts(input, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency(items, concurrency, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let index = 0;
|
||||||
|
async function runWorker() {
|
||||||
|
while (true) {
|
||||||
|
const current = index++;
|
||||||
|
if (current >= items.length) return;
|
||||||
|
results[current] = await worker(items[current], current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, runWorker));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImages({ aggregatedJsonPath, imagesDir }) {
|
||||||
|
const proxyHost = process.env.BIKEGEAR_PROXY_HOST;
|
||||||
|
const proxyPort = Number(process.env.BIKEGEAR_PROXY_PORT || 80);
|
||||||
|
const proxyUser = process.env.BIKEGEAR_PROXY_USERNAME;
|
||||||
|
const proxyPass = process.env.BIKEGEAR_PROXY_PASSWORD;
|
||||||
|
const delayMs = Number(process.env.BIKEGEAR_IMAGE_DELAY || process.env.BIKEGEAR_REQUEST_DELAY || 2000);
|
||||||
|
const concurrency = Number(process.env.BIKEGEAR_IMAGE_CONCURRENCY || 1);
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout: 60000,
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36" },
|
||||||
|
...(proxyHost ? { proxy: { host: proxyHost, port: proxyPort, auth: { username: proxyUser, password: proxyPass } } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const absJsonPath = path.resolve(process.cwd(), aggregatedJsonPath);
|
||||||
|
const absImagesDir = path.resolve(process.cwd(), imagesDir);
|
||||||
|
const raw = await fs.readFile(absJsonPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const products = Array.isArray(parsed?.products) ? parsed.products : [];
|
||||||
|
|
||||||
|
const usedFolders = new Set();
|
||||||
|
const tasks = [];
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
const data = product?.scraped || product;
|
||||||
|
if (data?.scrapeError) continue;
|
||||||
|
const imgs = product?.productSummary?.img || data?.images || [];
|
||||||
|
if (!imgs.length) continue;
|
||||||
|
|
||||||
|
const rawName = product?.productSummary?.name || data?.name || "unknown";
|
||||||
|
let folderName = sanitizeFileName(rawName) || product?.productId || "unknown";
|
||||||
|
if (usedFolders.has(folderName)) folderName = `${folderName}__${String(product?.productId || "").slice(0, 8)}`;
|
||||||
|
usedFolders.add(folderName);
|
||||||
|
const productDir = path.join(absImagesDir, folderName);
|
||||||
|
|
||||||
|
for (let i = 0; i < imgs.length; i++) {
|
||||||
|
const imgUrl = imgs[i];
|
||||||
|
let fileName;
|
||||||
|
try { fileName = path.basename(new URL(imgUrl).pathname) || `img_${i + 1}.jpg`; }
|
||||||
|
catch { fileName = `img_${i + 1}.jpg`; }
|
||||||
|
tasks.push({ url: imgUrl, productDir, fileName, name: rawName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloaded = 0, skipped = 0, failed = 0;
|
||||||
|
|
||||||
|
await mapWithConcurrency(tasks, concurrency, async (task) => {
|
||||||
|
await fs.mkdir(task.productDir, { recursive: true });
|
||||||
|
const filePath = path.join(task.productDir, task.fileName);
|
||||||
|
try {
|
||||||
|
try { await fs.access(filePath); skipped++; return; } catch {}
|
||||||
|
const res = await axios.get(task.url, axiosConfig);
|
||||||
|
await fs.writeFile(filePath, Buffer.from(res.data));
|
||||||
|
downloaded++;
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
console.log(`[IMAGE-FAIL] ${task.name} | ${task.url} -> ${e.message}`);
|
||||||
|
}
|
||||||
|
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[BIKEGEAR-IMAGES] done — downloaded=${downloaded} skipped=${skipped} failed=${failed} total=${tasks.length}`);
|
||||||
|
return { totalImagesFound: tasks.length, downloaded, skipped, failed, productsCount: products.length };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sourceKey,
|
sourceKey,
|
||||||
label,
|
label,
|
||||||
@ -172,5 +261,6 @@ module.exports = {
|
|||||||
envImageBaseUrl: "BIKEGEAR_IMAGE_BASE_URL",
|
envImageBaseUrl: "BIKEGEAR_IMAGE_BASE_URL",
|
||||||
paths,
|
paths,
|
||||||
fetchWebsiteData,
|
fetchWebsiteData,
|
||||||
|
downloadImages,
|
||||||
convertToShopifyProducts,
|
convertToShopifyProducts,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user