Skip to content

Skip puppeteer when a static fetch can extract the article#74

Draft
nonumpa wants to merge 4 commits into
feat/cloudflare-browser-renderingfrom
feat/lightweight-extraction
Draft

Skip puppeteer when a static fetch can extract the article#74
nonumpa wants to merge 4 commits into
feat/cloudflare-browser-renderingfrom
feat/lightweight-extraction

Conversation

@nonumpa

@nonumpa nonumpa commented Jun 9, 2026

Copy link
Copy Markdown
Member

Base: feat/cloudflare-browser-rendering — merge that PR first.

Why

Booting puppeteer for every URL costs ~500 MB of Chromium overhead and 5–20× the wall-clock time of a plain HTTP fetch. For SSR-rendered pages, parseMeta already misses on rich content, but the article body is sitting right there in the HTML — we just need to parse it. Some URLs also can't possibly benefit from rendering: 5xx, 401, 403, 410, 451 won't return content no matter how long Chromium runs.

This PR inserts a cheaper extraction layer between parseMeta and scrap, and short-circuits puppeteer for cases where rendering can't recover.

Changes

Pipeline (in order, cheapest first)

  1. unshorten — now follows multi-hop redirect chains
  2. parseMeta — unchanged
  3. extractStatic (new) — jsdom + @mozilla/readability server-side
  4. scrap — puppeteer fallback, skipped on terminal HTTP errors

Each layer either fills the result or falls through to the next.

extractStatic (src/lib/extractStatic.js)

  • node-fetch HTML → jsdomReadability.parse()
  • Returns a complete ScrapResult when Readability succeeds
  • Returns null on non-HTML responses or jsdom parse errors so the puppeteer fallback can still run
  • Throws ResolveError on network failures (same surface as unshorten)

Adjacent fixes

  • unshorten multi-hop — followed chains like bit.ly → lihi → real URL only one hop. Now loops up to 5 hops with cycle detection, retries HEAD with GET on 405/501, resolves relative Location headers, and sends a CofactsBot UA so shorteners gated by default-UA filters cooperate. Return shape is now { url, status } so callers can act on the final response status.
  • Skip puppeteer on terminal status — when parseMeta is incomplete and the upstream returned 5xx/401/403/410/451, surface the status to the caller instead of paying for a render that can't recover. 404 still flows through because some sites serve soft-404s with valid OG metadata.

Bench harness (bench/)

node --expose-gc bench/compare.js runs both methods on a URL list and emits a per-URL CSV (status, elapsed_ms, RSS delta, V8 heap delta, puppeteer per-page JSHeapUsedSize) plus a method-level summary. The headline question it answers: what fraction of URLs are extractable by the static path alone? That fraction is the multiplier on how much puppeteer concurrency we can shrink in prod, and the ~500 MB Chromium overhead can scale down proportionally.

Memory caveats and recommended methodology (separate processes for clean baselines) are documented in bench/README.md.

bench/results/ is gitignored.

New direct dependencies

"@mozilla/readability": "^0.6.0",
"jsdom": "^29.1.1"

package-lock.json was regenerated with npm install --before=2026-05-09 so all transitive deps remain ≥30 days old, matching the supply-chain convention used by the upgrade-deps PR.

Tests

  • npm test60/60 pass (was 35/35 on cloudflare base; +25 from new extractStatic tests and extended unshorten / resolveUrls tests)
  • npx eslint index.js docker-test.js src/ — clean
  • Supply-chain audit — 810 unique packages, 0 newer than 2026-05-09

extractStatic.test.js mocks jsdom and @mozilla/readability because jsdom has ESM-only transitive deps that don't load under jest 23. Real-network integration is exercised by bench/compare.js.

Commits

  1. fix(unshorten): follow multi-hop redirects and HEAD-reject servers
  2. perf(resolver): skip puppeteer for terminal HTTP errors
  3. feat(resolver): add server-side jsdom + Readability extraction
  4. chore(bench): compare jsdom + puppeteer extraction

nonumpa added 4 commits June 9, 2026 11:02
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
- 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 scrap 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 jsdom + Readability avoids booting
puppeteer entirely.

The new 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 jsdom parse errors so the puppeteer fallback can
still run when needed.

Tests mock jsdom + @mozilla/readability so the test runner does not
need to load jsdom's transitive ESM-only deps; real-world integration
is exercised by bench/compare.js.
bench/compare.js runs extractStatic and scrap on a list of URLs and
records per-call elapsed_ms, RSS delta, V8 heap delta, and (for
puppeteer) the per-page JSHeapUsedSize from page.metrics(). Outputs a
per-URL CSV plus a method-level summary on stdout.

bench/README.md documents what each metric means, how to interpret the
results, and the memory-measurement caveats (separate processes recommended
for clean baselines; the persistent ~500 MB Chromium overhead is amortized
across pages and not counted per URL).

bench/results/ is gitignored so generated CSVs do not pollute the repo.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/extractStatic.js
Comment on lines +83 to +141
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,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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();
  }

Comment thread src/lib/extractStatic.js
Comment on lines +67 to +74
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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;
}

Comment thread src/lib/unshorten.js
Comment on lines +50 to +56
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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) {

Comment on lines 44 to 55
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)));
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)));
            }
          }
        }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant