fix(bikegear): recycle browser context every 20 brands to prevent memory OOM restart

Running Playwright Chromium for 2+ hours with one shared context accumulates
memory until PM2/OOM kills the process with no error. Fix: save cookies and
close/reopen the context every 20 brands so Chromium memory is freed mid-run.
Cookie persistence means the new context resumes with the cf_clearance session.
Override interval with BIKEGEAR_RECYCLE_EVERY env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-07-24 16:27:00 +05:30
parent a8adf00732
commit 46d018c56e

View File

@ -27,10 +27,18 @@ console.log(`[BIKEGEAR] Fetch mode: ${FETCH_MODE}${PROXY_URL ? ` (proxy: ${PROXY
// ── Browser pool (Playwright) ────────────────────────────────────────────── // ── Browser pool (Playwright) ──────────────────────────────────────────────
let _browser = null; const path = require("node:path");
const fs = require("node:fs/promises");
const COOKIE_STATE_PATH = path.resolve("data/sources/bikegear/.browser-state.json");
let _browser = null;
// Shared persistent context — all requests reuse the same cookies/session
let _context = null;
async function getBrowserContext() {
if (_context) return _context;
async function getBrowser() {
if (_browser) return _browser;
const { chromium } = require("playwright"); const { chromium } = require("playwright");
_browser = await chromium.launch({ _browser = await chromium.launch({
headless: true, headless: true,
@ -40,26 +48,60 @@ async function getBrowser() {
"--disable-blink-features=AutomationControlled", "--disable-blink-features=AutomationControlled",
], ],
}); });
return _browser;
// Load saved cookies if they exist (skips Cloudflare challenge on warm runs)
let storageState;
try {
await fs.access(COOKIE_STATE_PATH);
storageState = COOKIE_STATE_PATH;
console.log("[BIKEGEAR] Loaded saved browser session (cookies).");
} catch {
// No saved state yet — will solve challenge fresh and save afterwards
} }
async function closeBrowser() { _context = await _browser.newContext({
if (_browser) {
await _browser.close().catch(() => {});
_browser = null;
}
}
async function fetchHtmlWithBrowser(url, attempt = 1) {
const browser = await getBrowser();
const context = await browser.newContext({
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
locale: "en-US", locale: "en-US",
viewport: { width: 1280, height: 800 }, viewport: { width: 1280, height: 800 },
extraHTTPHeaders: { extraHTTPHeaders: { "Accept-Language": "en-US,en;q=0.9" },
"Accept-Language": "en-US,en;q=0.9", ...(storageState ? { storageState } : {}),
},
}); });
return _context;
}
async function saveBrowserState() {
if (!_context) return;
try {
await fs.mkdir(path.dirname(COOKIE_STATE_PATH), { recursive: true });
await _context.storageState({ path: COOKIE_STATE_PATH });
console.log("[BIKEGEAR] Browser session (cookies) saved for next run.");
} catch (err) {
console.warn(`[BIKEGEAR] Could not save browser state: ${err.message}`);
}
}
async function closeBrowser() {
await saveBrowserState();
if (_browser) {
await _browser.close().catch(() => {});
_browser = null;
_context = null;
}
}
/** Recycle the browser context to free accumulated memory, keeping cookies. */
async function recycleBrowserContext() {
if (!_context) return;
console.log("[BIKEGEAR] Recycling browser context to free memory...");
await saveBrowserState();
await _context.close().catch(() => {});
_context = null;
// _browser stays alive — only the context is replaced
}
async function fetchHtmlWithBrowser(url, attempt = 1) {
const context = await getBrowserContext();
const page = await context.newPage(); const page = await context.newPage();
try { try {
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
@ -89,7 +131,6 @@ async function fetchHtmlWithBrowser(url, attempt = 1) {
throw err; throw err;
} finally { } finally {
await page.close().catch(() => {}); await page.close().catch(() => {});
await context.close().catch(() => {});
} }
} }
@ -365,10 +406,13 @@ async function scrapeProductDetail(productUrl, brandName) {
} }
} }
// Recycle the browser context every N brands to prevent memory accumulation
const BROWSER_RECYCLE_EVERY = Number(process.env.BIKEGEAR_RECYCLE_EVERY ?? 20);
/** Main entry point: scrape all configured brands and return flat product array. */ /** Main entry point: scrape all configured brands and return flat product array. */
async function scrapeBikeGear() { async function scrapeBikeGear() {
const allProducts = []; const allProducts = [];
let totalBrands = BRANDS.length; const totalBrands = BRANDS.length;
let brandsDone = 0; let brandsDone = 0;
for (const brand of BRANDS) { for (const brand of BRANDS) {
@ -401,8 +445,12 @@ async function scrapeBikeGear() {
allProducts.push(...products); allProducts.push(...products);
brandsDone++; brandsDone++;
// Polite delay between brands // Recycle browser context every N brands to free memory
if (brandsDone < totalBrands) await sleep(1000); if (FETCH_MODE === "browser" && brandsDone % BROWSER_RECYCLE_EVERY === 0 && brandsDone < totalBrands) {
await recycleBrowserContext();
} else if (brandsDone < totalBrands) {
await sleep(1000);
}
} }
return allProducts; return allProducts;