feat(bikegear): add local scraper with resume support for residential IP scraping
Standalone script for running bikegear scrape from a local machine (residential IP bypasses Cloudflare). Saves progress after every brand so Ctrl+C resumes cleanly. Output is written to data/sources/bikegear/01_products_aggregated.json in the exact same format the server pipeline expects — copy the file to the server and the pipeline uses it as a 24h cache, skipping straight to stage 2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b6115cc637
commit
f98febc5c5
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,9 @@ coverage/
|
|||||||
|
|
||||||
# Runtime/generated data
|
# Runtime/generated data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Local bikegear scraper progress (not committed — local machine only)
|
||||||
|
bikegear-local-scraper/progress.json
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
src/business-logic/import-pipeline/sources/outputs/
|
src/business-logic/import-pipeline/sources/outputs/
|
||||||
|
|||||||
193
bikegear-local-scraper/run.js
Normal file
193
bikegear-local-scraper/run.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* BikeGear Local Scraper — run this on your LOCAL machine (residential IP).
|
||||||
|
*
|
||||||
|
* Usage (from Race-Nation-Shopify-App-Backend/):
|
||||||
|
* node bikegear-local-scraper/run.js
|
||||||
|
*
|
||||||
|
* Ctrl+C at any time — progress is saved. Re-run to resume from last brand.
|
||||||
|
*
|
||||||
|
* When done, copy the output file to the server:
|
||||||
|
* scp data/sources/bikegear/01_products_aggregated.json \
|
||||||
|
* root@<server>:/home/dev/apps/Race-Nation-Shopify-Backend/data/sources/bikegear/
|
||||||
|
*
|
||||||
|
* Then trigger the pipeline on the server — it will see the 24h cache and
|
||||||
|
* skip straight to image download → watermark → Shopify upload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
|
||||||
|
// Default to HTTP mode for local runs — residential IP is not blocked
|
||||||
|
process.env.BIKEGEAR_FETCH_MODE = process.env.BIKEGEAR_FETCH_MODE || "http";
|
||||||
|
// Higher concurrency is safe from a residential IP
|
||||||
|
process.env.BIKEGEAR_LISTING_CONCURRENCY = process.env.BIKEGEAR_LISTING_CONCURRENCY || "5";
|
||||||
|
process.env.BIKEGEAR_DETAIL_CONCURRENCY = process.env.BIKEGEAR_DETAIL_CONCURRENCY || "5";
|
||||||
|
|
||||||
|
const { BRANDS } = require("../src/business-logic/import-pipeline/sources/bikegear/brands");
|
||||||
|
const { scrapeBrandProductUrls, scrapeProductDetail, closeBrowser } = require("../src/business-logic/import-pipeline/sources/bikegear/scraper");
|
||||||
|
|
||||||
|
const SOURCE_KEY = "bikegear";
|
||||||
|
const SOURCE_LABEL = "BikeGear India";
|
||||||
|
|
||||||
|
// Progress file — tracks which brands are done so we can resume
|
||||||
|
const PROGRESS_FILE = path.resolve(__dirname, "progress.json");
|
||||||
|
// Final output — same path the server pipeline uses (stage 1 cache)
|
||||||
|
const OUTPUT_FILE = path.resolve("data/sources/bikegear/01_products_aggregated.json");
|
||||||
|
|
||||||
|
async function loadProgress() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(PROGRESS_FILE, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { completedBrands: [], products: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProgress(progress) {
|
||||||
|
await fs.mkdir(path.dirname(PROGRESS_FILE), { recursive: true });
|
||||||
|
await fs.writeFile(PROGRESS_FILE, JSON.stringify(progress, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOutput(products) {
|
||||||
|
const successfulProducts = products.filter((p) => !(p.scraped || p).scrapeError);
|
||||||
|
const failedProducts = products.filter((p) => (p.scraped || p).scrapeError);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
sourceLabel: SOURCE_LABEL,
|
||||||
|
analysis: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalProducts: products.length,
|
||||||
|
totalProductsUnique: successfulProducts.length,
|
||||||
|
detailSuccess: successfulProducts.length,
|
||||||
|
detailFailed: failedProducts.length,
|
||||||
|
},
|
||||||
|
products,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(OUTPUT_FILE), { recursive: true });
|
||||||
|
await fs.writeFile(OUTPUT_FILE, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
console.log(`\n[OUTPUT] Written to ${OUTPUT_FILE}`);
|
||||||
|
console.log(`[OUTPUT] ${successfulProducts.length} products OK, ${failedProducts.length} failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapProduct(product) {
|
||||||
|
return {
|
||||||
|
productId: product.sku || product.url,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
brand: product.brand || "BikeGear",
|
||||||
|
bikeModel: "",
|
||||||
|
productSummary: {
|
||||||
|
id: product.sku,
|
||||||
|
name: product.name,
|
||||||
|
img: product.images || [],
|
||||||
|
cost: { mrp: product.price || 0 },
|
||||||
|
},
|
||||||
|
scraped: product,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`[BIKEGEAR-LOCAL] Fetch mode: ${process.env.BIKEGEAR_FETCH_MODE}`);
|
||||||
|
console.log(`[BIKEGEAR-LOCAL] ${BRANDS.length} brands total.`);
|
||||||
|
|
||||||
|
const progress = await loadProgress();
|
||||||
|
const completedSet = new Set(progress.completedBrands);
|
||||||
|
|
||||||
|
const remaining = BRANDS.filter((b) => !completedSet.has(b.name));
|
||||||
|
console.log(`[BIKEGEAR-LOCAL] ${completedSet.size} brands already done. ${remaining.length} remaining.\n`);
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function gracefulExit() {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log("\n[BIKEGEAR-LOCAL] Ctrl+C received — saving progress and exiting...");
|
||||||
|
await saveProgress(progress);
|
||||||
|
await writeOutput(progress.products);
|
||||||
|
await closeBrowser();
|
||||||
|
console.log("[BIKEGEAR-LOCAL] Progress saved. Re-run to continue.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", gracefulExit);
|
||||||
|
process.on("SIGTERM", gracefulExit);
|
||||||
|
|
||||||
|
for (const brand of remaining) {
|
||||||
|
if (shuttingDown) break;
|
||||||
|
|
||||||
|
const productUrls = await scrapeBrandProductUrls(brand);
|
||||||
|
|
||||||
|
if (!productUrls.length) {
|
||||||
|
console.warn(`[${brand.name}] No product URLs — skipping.`);
|
||||||
|
progress.completedBrands.push(brand.name);
|
||||||
|
await saveProgress(progress);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${brand.name}] Scraping ${productUrls.length} products...`);
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
// Scrape detail pages in batches (concurrency from env)
|
||||||
|
const concurrency = Number(process.env.BIKEGEAR_DETAIL_CONCURRENCY || 5);
|
||||||
|
const brandProducts = [];
|
||||||
|
|
||||||
|
async function runConcurrent(items, fn, limit) {
|
||||||
|
let idx = 0;
|
||||||
|
const results = new Array(items.length);
|
||||||
|
async function worker() {
|
||||||
|
while (idx < items.length) {
|
||||||
|
const i = idx++;
|
||||||
|
results[i] = await fn(items[i], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawProducts = await runConcurrent(productUrls, async (url) => {
|
||||||
|
if (shuttingDown) return { url, scrapeError: "interrupted" };
|
||||||
|
const product = await scrapeProductDetail(url, brand.name);
|
||||||
|
done++;
|
||||||
|
if (product.scrapeError) {
|
||||||
|
console.warn(` ${done}/${productUrls.length} ERR: ${product.scrapeError}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${done}/${productUrls.length} OK: "${product.name}"`);
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}, concurrency);
|
||||||
|
|
||||||
|
for (const raw of rawProducts) {
|
||||||
|
brandProducts.push(wrapProduct(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = rawProducts.filter((p) => !p.scrapeError).length;
|
||||||
|
console.log(`[${brand.name}] Done: ${ok}/${productUrls.length} OK\n`);
|
||||||
|
|
||||||
|
progress.products.push(...brandProducts);
|
||||||
|
progress.completedBrands.push(brand.name);
|
||||||
|
|
||||||
|
// Save progress after every brand
|
||||||
|
await saveProgress(progress);
|
||||||
|
// Also update the output file incrementally
|
||||||
|
await writeOutput(progress.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shuttingDown) {
|
||||||
|
console.log("\n[BIKEGEAR-LOCAL] All brands complete!");
|
||||||
|
await writeOutput(progress.products);
|
||||||
|
await closeBrowser();
|
||||||
|
|
||||||
|
// Clean up progress file — scrape is done
|
||||||
|
await fs.unlink(PROGRESS_FILE).catch(() => {});
|
||||||
|
console.log("[BIKEGEAR-LOCAL] Progress file removed (clean run).");
|
||||||
|
console.log(`\nNext step: copy ${OUTPUT_FILE} to the server and trigger the pipeline.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error("[BIKEGEAR-LOCAL] Fatal error:", err);
|
||||||
|
await closeBrowser();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -623,4 +623,4 @@ async function scrapeBikeGear() {
|
|||||||
return allProducts;
|
return allProducts;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { scrapeBikeGear, closeBrowser };
|
module.exports = { scrapeBikeGear, closeBrowser, scrapeBrandProductUrls, scrapeProductDetail };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user