Reduce Puppeteer usage with static article extraction - #74
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a fast-path static extraction mechanism (extractStatic) using jsdom and @mozilla/readability to avoid booting Puppeteer for SSR-rendered pages, alongside a benchmarking suite to compare performance. It also refactors the unshorten utility to follow redirect chains up to a maximum number of hops with HEAD/GET fallback. The code review feedback highlights critical opportunities to prevent memory and socket leaks, specifically by explicitly closing the JSDOM instance, consuming unread response bodies in node-fetch early-exit paths, and wrapping the static extraction call in a try-catch block to ensure robust fallback to Puppeteer if a network error occurs.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let dom; | ||
| try { | ||
| dom = new JSDOM(html, { url: finalUrl }); | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
|
|
||
| const document = dom.window.document; | ||
|
|
||
| const canonicalEl = document.querySelector('link[rel=canonical]'); | ||
| const canonical = | ||
| (canonicalEl && canonicalEl.href) || | ||
| pickMeta(document, 'meta[property="og:url"]') || | ||
| finalUrl; | ||
|
|
||
| let topImageUrl = ''; | ||
| const ogImage = pickMeta( | ||
| document, | ||
| 'meta[property="og:image"]', | ||
| 'meta[property="og:image:url"]', | ||
| 'meta[name="twitter:image"]' | ||
| ); | ||
| if (ogImage) { | ||
| try { | ||
| topImageUrl = new URL(ogImage, canonical).href; | ||
| } catch (e) { | ||
| topImageUrl = ''; | ||
| } | ||
| } | ||
|
|
||
| let article = null; | ||
| try { | ||
| article = new Readability(document).parse(); | ||
| } catch (e) { | ||
| article = null; | ||
| } | ||
|
|
||
| const title = | ||
| (article && article.title && article.title.trim()) || | ||
| pickMeta(document, 'meta[property="og:title"]') || | ||
| (document.title || '').trim(); | ||
|
|
||
| const summary = | ||
| (article && article.textContent && article.textContent.trim()) || | ||
| pickMeta( | ||
| document, | ||
| 'meta[property="og:description"]', | ||
| 'meta[name=description]' | ||
| ); | ||
|
|
||
| return new ScrapResult({ | ||
| canonical, | ||
| title: title || undefined, | ||
| summary: summary || undefined, | ||
| topImageUrl: topImageUrl || undefined, | ||
| html, | ||
| status, | ||
| }); | ||
| } |
There was a problem hiding this comment.
To prevent memory leaks in a long-running server process, the JSDOM instance should be explicitly closed using dom.window.close(). Additionally, if og:url is relative, resolving canonical against finalUrl ensures it is always an absolute URL, preventing errors when resolving topImageUrl.
let dom;
try {
dom = new JSDOM(html, { url: finalUrl });
} catch (e) {
return null;
}
try {
const document = dom.window.document;
const canonicalEl = document.querySelector('link[rel=canonical]');
let canonical =
(canonicalEl && canonicalEl.href) ||
pickMeta(document, 'meta[property="og:url"]') ||
finalUrl;
try {
canonical = new URL(canonical, finalUrl).href;
} catch (e) {
canonical = finalUrl;
}
let topImageUrl = '';
const ogImage = pickMeta(
document,
'meta[property="og:image"]',
'meta[property="og:image:url"]',
'meta[name="twitter:image"]'
);
if (ogImage) {
try {
topImageUrl = new URL(ogImage, canonical).href;
} catch (e) {
topImageUrl = '';
}
}
let article = null;
try {
article = new Readability(document).parse();
} catch (e) {
article = null;
}
const title =
(article && article.title && article.title.trim()) ||
pickMeta(document, 'meta[property="og:title"]') ||
(document.title || '').trim();
const summary =
(article && article.textContent && article.textContent.trim()) ||
pickMeta(
document,
'meta[property="og:description"]',
'meta[name=description]'
);
return new ScrapResult({
canonical,
title: title || undefined,
summary: summary || undefined,
topImageUrl: topImageUrl || undefined,
html,
status,
});
} finally {
dom.window.close();
}| if (!res.ok) { | ||
| return new ScrapResult({ canonical: finalUrl, status }); | ||
| } | ||
|
|
||
| const ct = (res.headers.get('content-type') || '').toLowerCase(); | ||
| if (!ct.startsWith('text/html') && !ct.startsWith('application/xhtml')) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
When returning early from extractStatic (either due to a non-OK status or a non-HTML content type), the response body stream must be resumed/consumed to release the underlying socket and prevent connection/memory leaks in node-fetch.
| if (!res.ok) { | |
| return new ScrapResult({ canonical: finalUrl, status }); | |
| } | |
| const ct = (res.headers.get('content-type') || '').toLowerCase(); | |
| if (!ct.startsWith('text/html') && !ct.startsWith('application/xhtml')) { | |
| return null; | |
| } | |
| if (!res.ok) { | |
| if (res.body) res.body.resume(); | |
| return new ScrapResult({ canonical: finalUrl, status }); | |
| } | |
| const ct = (res.headers.get('content-type') || '').toLowerCase(); | |
| if (!ct.startsWith('text/html') && !ct.startsWith('application/xhtml')) { | |
| if (res.body) res.body.resume(); | |
| return null; | |
| } |
| let res; | ||
| try { | ||
| res = await fetch(current, { method: 'HEAD', ...FETCH_OPTS }); | ||
| if (res.status === 405 || res.status === 501) { | ||
| res = await fetch(current, { method: 'GET', ...FETCH_OPTS }); | ||
| } | ||
| } catch (e) { |
There was a problem hiding this comment.
When falling back to a GET request in unshorten, the response body is never consumed. In node-fetch, leaving the response body unconsumed can leak sockets and memory. Resuming the body stream ensures resources are freed.
| let res; | |
| try { | |
| res = await fetch(current, { method: 'HEAD', ...FETCH_OPTS }); | |
| if (res.status === 405 || res.status === 501) { | |
| res = await fetch(current, { method: 'GET', ...FETCH_OPTS }); | |
| } | |
| } catch (e) { | |
| let res; | |
| try { | |
| res = await fetch(current, { method: 'HEAD', ...FETCH_OPTS }); | |
| if (res.status === 405 || res.status === 501) { | |
| res = await fetch(current, { method: 'GET', ...FETCH_OPTS }); | |
| } | |
| if (res.body) res.body.resume(); | |
| } catch (e) { | |
| if (fetchResult.isIncomplete) { | ||
| fetchResult.merge(await limit(() => scrap(unshortened))); | ||
| if (isTerminalHttpError(finalStatus)) { | ||
| fetchResult.status = finalStatus; | ||
| } else { | ||
| const staticResult = await extractStatic(unshortened); | ||
| if (staticResult) fetchResult.merge(staticResult); | ||
|
|
||
| if (fetchResult.isIncomplete) { | ||
| fetchResult.merge(await limit(() => scrap(unshortened))); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
If extractStatic throws a ResolveError (e.g., due to a timeout or temporary network issue), the entire URL resolution will fail immediately. Wrapping it in a try-catch block allows the system to gracefully fall back to the more robust scrap (puppeteer) layer.
if (fetchResult.isIncomplete) {
if (isTerminalHttpError(finalStatus)) {
fetchResult.status = finalStatus;
} else {
let staticResult = null;
try {
staticResult = await extractStatic(unshortened);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[extractStatic]', unshortened, e);
}
if (staticResult) fetchResult.merge(staticResult);
if (fetchResult.isIncomplete) {
fetchResult.merge(await limit(() => scrap(unshortened)));
}
}
}76d358b to
c4ed8df
Compare
Cloudflare backend connects lazily and clears browserPromise on disconnect, so getBrowserStats can await undefined and crash with TypeError on browser.pages(). Return empty stats instead so BrowserStats RPC stays available before first scrape and after keep_alive timeout.
Single-hop unshorten breaks chains like bit.ly to lihi to real URL,
and silently falls back to the original on servers that 405 HEAD.
- Loop up to 5 hops with cycle detection
- Retry HEAD with GET on 405/501
- Resolve relative Location against the current URL
- Send a CofactsBot User-Agent so shorteners gated by default-UA
filters return Location instead of 401/403
- Drain HEAD/GET response bodies so node-fetch releases sockets
- Return { url, status } so callers can act on the final response
status
When parseMeta returns incomplete and the upstream URL responds 5xx, 401, 403, 410, or 451, rendering it in puppeteer cannot recover the content. Surface the status to the caller and skip the scrape step. 404 still flows through because some sites serve soft-404s with valid OG metadata that puppeteer can extract from the rendered DOM.
For SSR-rendered pages a plain HTTP fetch already contains the article body; extracting it server-side via linkedom + Readability avoids booting puppeteer entirely. linkedom is a lighter server-side DOM than jsdom (no script execution, no CSS engine, no fetch, no cookie jar). Readability only needs DOM tree operations, so the swap is correctness-neutral while cutting cold-start memory ~5x and per-extraction latency ~1.5-3x. See bench/ for the A/B comparison. The extractStatic step runs after parseMeta and before the puppeteer fallback. It throws ResolveError on the same network failures as unshorten so callers see consistent errors, and returns null on non-HTML responses or parse errors so the puppeteer fallback can still run when needed. Response bodies are drained on early-return paths so node-fetch releases sockets. resolveUrls wraps the extractStatic call in try/catch so a static-path failure falls through to puppeteer instead of failing the whole URL. Tests mock linkedom + @mozilla/readability so the test runner does not need to load their transitive deps; real-world integration is exercised by bench/compare.js.
c4ed8df to
8927526
Compare
Base:
feat/cloudflare-browser-rendering— merge that PR first.Why
Launching Chromium for every URL adds roughly 500 MB of browser overhead and is much slower than a plain HTTP fetch. For SSR pages, the article body is already present in the HTML even when
parseMetacannot produce a complete result.This PR adds a lightweight static extraction step before the Puppeteer fallback, and avoids rendering responses that a GET request has confirmed are terminal HTTP errors.
Pipeline
unshorten— follows redirect chains (up to 5 hops)parseMeta— unchangedextractStatic—node-fetch+linkedom+@mozilla/readabilityscrape— Puppeteer fallbackEach layer fills the result or falls through. Static extraction errors, non-HTML responses, and DOM parse failures always fall through to Puppeteer.
extractStaticlinkedomand extracts article text with Readability.nullfor unsupported responses or parse failures.ResolveErrorsurface.Terminal HTTP errors
Puppeteer is skipped for
5xx,401,403,410, and451only whenextractStatic's GET request observed that status.The status returned by
unshortenis based primarily on HEAD and is not used for this decision: many CDNs and anti-bot systems reject HEAD while serving GET normally. If static extraction throws or returnsnull, the pipeline still tries Puppeteer.Tests cover both directions:
403, static GET200→ use the static result.200, static GET500→ skip Puppeteer.403, static extraction throws or returnsnull→ fall through to Puppeteer.Why linkedom instead of jsdom
Readability needs DOM tree operations but not script execution, CSS layout, subresource fetching, or a cookie jar.
linkedomprovides the required DOM surface with substantially lower startup and per-document cost.The comparison harness lives on experiment/linkedom-vs-jsdom, directly on top of this PR. It uses committed HTML fixtures so both backends parse identical input.
Results on the 11-URL fixture set
Measured on Node 24 / Ubuntu; absolute timing and RSS vary by machine, while the same-input comparison is the relevant signal.
Both backends produced a structurally complete
ScrapeResultfor 8/11 fixtures. This measures the existingisIncompleteheuristic, not content quality—for example, the Threads login wall satisfies the heuristic. Production impact should be measured again on a representative Cofacts URL sample.Detailed methodology, per-URL timing, parity classification, and memory caveats are documented in
bench/README.mdon the experiment branch.Reproduce
bench/e2e.js --runs 3is also available for the noisier live-network comparison.Other changes
Multi-hop
unshortenLocationheaders.405/501.{ url, status }.Dependencies
package-lock.jsonwas regenerated withnpm install --before=2026-05-09, following the repository's 30-day supply-chain convention.