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:
parent
a8adf00732
commit
46d018c56e
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeBrowser() {
|
// Load saved cookies if they exist (skips Cloudflare challenge on warm runs)
|
||||||
if (_browser) {
|
let storageState;
|
||||||
await _browser.close().catch(() => {});
|
try {
|
||||||
_browser = null;
|
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 fetchHtmlWithBrowser(url, attempt = 1) {
|
_context = await _browser.newContext({
|
||||||
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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user