perf(emex): replace networkidle + 2s sleeps with targeted selector waits
EMEX leaf parts fetch was averaging 12.5s cold per category (Honda Civic
prod traces). Almost all of that lived in the scraper's two waterfall page
loads, each of which (a) waited for `networkidle` — which only fires after
analytics/ads stop chattering — then (b) slept an unconditional 2 seconds.
The actual extraction work runs in <100ms once the DOM is parsed.
Three focused changes in scripts/emex-vin-scraper.js:
1) getParts() resolves the Unit.aspx URL via a plain `fetch()` of
QuickDetails.aspx instead of opening it in the browser. Probe showed
the page is fully server-rendered and the anchor is present in the
initial HTML, so the browser stage is dead weight (~3s saved). Falls
back to a browser load if the fetch fails or the anchor isn't there,
so catalogs that gate the link behind JS still work.
2) Both remaining `page.goto()` calls in getParts/getCategories/
getCategoryTree drop `waitUntil:'networkidle'` for `'domcontentloaded'`
plus a targeted `waitForSelector('img.dragger' | 'a[href*=…]', {timeout:5000-8000})`.
`.catch(()=>null)` makes the wait advisory — the evaluate() below has
its own null-safe fallbacks — but in practice the selector is present
well before networkidle would have fired.
3) Removes the two unconditional `setTimeout(r,2000)` sleeps in getParts.
They predate the selector-wait pattern and were belt-and-braces.
Expected: ~12.5s → ~3-5s cold leaf fetch. Warm path (DB-cached) is
unchanged at ~45ms. Dev-only push for verification before main merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -746,8 +746,10 @@ class EmexVinScraper {
|
||||
if (!quickGroupsUrl) return [];
|
||||
|
||||
console.log(`\nFetching categories from: ${quickGroupsUrl}`);
|
||||
await this.page.goto(quickGroupsUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
// domcontentloaded + selector wait shaves ~3-5s vs networkidle (which
|
||||
// waits for analytics/ads to settle and adds nothing for our extraction).
|
||||
await this.page.goto(quickGroupsUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.timeout });
|
||||
await this.page.waitForSelector('a[href*="QuickDetails.aspx"], a[href*="QuickGroups.aspx"]', { timeout: 8000 }).catch(() => null);
|
||||
|
||||
// Screenshot for debugging
|
||||
await this.page.screenshot({ path: path.join(__dirname, 'categories-page.png'), fullPage: true });
|
||||
@@ -818,8 +820,9 @@ class EmexVinScraper {
|
||||
if (!quickGroupsUrl) return [];
|
||||
|
||||
console.log(`\nFetching category tree from QuickGroups.aspx: ${quickGroupsUrl}`);
|
||||
await this.page.goto(quickGroupsUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
// See getParts/getCategories: networkidle replaced with selector wait.
|
||||
await this.page.goto(quickGroupsUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.timeout });
|
||||
await this.page.waitForSelector('a[href*="QuickDetails.aspx"], a[href*="QuickGroups.aspx"]', { timeout: 8000 }).catch(() => null);
|
||||
|
||||
// Screenshot for debugging
|
||||
await this.page.screenshot({ path: path.join(__dirname, 'quickgroups-tree.png'), fullPage: true });
|
||||
@@ -908,27 +911,67 @@ class EmexVinScraper {
|
||||
}
|
||||
|
||||
// Get parts for a category
|
||||
// Navigates to QuickDetails.aspx, then follows the Unit.aspx link for full
|
||||
// interactive schema with hotspot overlays.
|
||||
// Two-stage navigation: QuickDetails.aspx → Unit.aspx (where the interactive
|
||||
// hotspot diagram lives). Both pages are fully server-rendered for the data
|
||||
// we need; networkidle previously waited 3-5s extra per page just for
|
||||
// analytics/ads to settle. Switched to domcontentloaded + targeted selector
|
||||
// waits, and skipped the QuickDetails browser load entirely by resolving the
|
||||
// Unit.aspx href via a single plain HTTP GET (~3s saved). Falls back to the
|
||||
// old browser-first path if the fetch can't find the Unit.aspx link, so
|
||||
// catalogs that gate the link behind JS still work.
|
||||
// Returns { parts, schemaImageUrl, hotspots, schemaWidth, schemaHeight }
|
||||
async getParts(detailsUrl) {
|
||||
if (!detailsUrl) return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
|
||||
console.log(`\nFetching parts from: ${detailsUrl}`);
|
||||
await this.page.goto(detailsUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// QuickDetails.aspx has a link to Unit.aspx which contains the full
|
||||
// interactive diagram with hotspot overlay divs. Navigate there.
|
||||
const unitUrl = await this.page.evaluate(() => {
|
||||
const link = document.querySelector('a[href*="Unit.aspx"]');
|
||||
return link ? link.href : null;
|
||||
});
|
||||
// Fast path: pull the QuickDetails.aspx HTML via plain HTTP (no JS,
|
||||
// no analytics-driven networkidle wait), grab the Unit.aspx link.
|
||||
let unitUrl = null;
|
||||
try {
|
||||
const resp = await fetch(detailsUrl, {
|
||||
headers: { 'User-Agent': CONFIG.userAgent },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (resp.ok) {
|
||||
const html = await resp.text();
|
||||
const m = html.match(/href="([^"]*Unit\.aspx[^"]*)"/i);
|
||||
if (m) {
|
||||
const raw = m[1].replace(/&/g, '&');
|
||||
unitUrl = raw.startsWith('http') ? raw : new URL(raw, detailsUrl).toString();
|
||||
console.log(` Resolved Unit.aspx via plain HTTP: ${unitUrl.substring(0, 120)}…`);
|
||||
}
|
||||
} else {
|
||||
console.log(` Plain HTTP QuickDetails failed (${resp.status}), falling back to browser`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` Plain HTTP QuickDetails error: ${err.message}, falling back to browser`);
|
||||
}
|
||||
|
||||
// Slow path: open QuickDetails in the browser only if plain HTTP
|
||||
// didn't yield a Unit.aspx URL. Keeps catalogs that gate the link
|
||||
// behind JS rendering working.
|
||||
if (!unitUrl) {
|
||||
await this.page.goto(detailsUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.timeout });
|
||||
await this.page.waitForSelector('a[href*="Unit.aspx"]', { timeout: 5000 }).catch(() => null);
|
||||
unitUrl = await this.page.evaluate(() => {
|
||||
const link = document.querySelector('a[href*="Unit.aspx"]');
|
||||
return link ? link.href : null;
|
||||
});
|
||||
}
|
||||
|
||||
if (unitUrl) {
|
||||
console.log(` Following Unit.aspx link: ${unitUrl}`);
|
||||
await this.page.goto(unitUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
console.log(` Loading Unit.aspx for hotspots…`);
|
||||
await this.page.goto(unitUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.timeout });
|
||||
// The img.dragger element is what the extractor reads to compute
|
||||
// hotspot scale; waiting on it is both necessary and sufficient.
|
||||
// Falls through after 5s if the catalog uses a different layout —
|
||||
// the evaluate() below has its own null-safe fallbacks.
|
||||
await this.page.waitForSelector('img.dragger', { timeout: 5000 }).catch(() => null);
|
||||
} else {
|
||||
// No Unit.aspx link found anywhere — operate on the already-loaded
|
||||
// QuickDetails.aspx page (which was loaded as a browser fallback).
|
||||
// Keep the existing extraction behaviour.
|
||||
}
|
||||
|
||||
const result = await this.page.evaluate(() => {
|
||||
|
||||
Reference in New Issue
Block a user