<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Petr Savenok]]></title><description><![CDATA[Petr Savenok]]></description><link>https://site2pdf.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad3739e55abbc3fcdf2cd82/e3957be1-d7be-416e-8876-c524dcbed859.png</url><title>Petr Savenok</title><link>https://site2pdf.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 18:14:58 GMT</lastBuildDate><atom:link href="https://site2pdf.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your Puppeteer screenshot is a Cloudflare block page — how to tell, and when to wait]]></title><description><![CDATA[How to tell a Cloudflare, Akamai or Imperva block page from real content in Puppeteer, wait out challenges, and catch 429s and Chrome error pages.]]></description><link>https://site2pdf.hashnode.dev/your-puppeteer-screenshot-is-a-cloudflare-block-page-how-to-tell-and-when-to-wait</link><guid isPermaLink="true">https://site2pdf.hashnode.dev/your-puppeteer-screenshot-is-a-cloudflare-block-page-how-to-tell-and-when-to-wait</guid><category><![CDATA[puppeteer]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[cloudflare]]></category><dc:creator><![CDATA[Peter Savenok]]></dc:creator><pubDate>Thu, 12 Nov 2026 16:11:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad3739e55abbc3fcdf2cd82/3acaf253-ab05-43c4-9012-dc83c0892005.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We run a service that turns web pages into PDFs and screenshots with headless Chrome. For months, one class of bug kept coming back in a new costume: the capture "succeeded", and the user got a beautiful, perfectly rendered PDF of <strong>"Sorry, you have been blocked."</strong></p>
<p>Puppeteer doesn't care what it renders. <code>page.goto()</code> resolves, <code>page.pdf()</code> produces bytes, your job reports success. If you screenshot, archive, monitor or scrape pages at any volume, some of those pages are an interstitial from Cloudflare, Akamai, Imperva or the site's own nginx — and nothing in the default pipeline notices.</p>
<p>This is the detector we ended up with, and more usefully, the cases that slipped past each earlier version.</p>
<h2>Rule 1: an HTTP status alone proves nothing</h2>
<p>The obvious first version is <code>if (response.status() &gt;= 400) fail()</code>. It is wrong in both directions:</p>
<ul>
<li>Plenty of real pages are served under <code>403</code> or <code>429</code> with full content behind them. Fail those and you refuse captures that would have been fine.</li>
<li>Many block pages come back as <strong>200</strong>. Imperva/Incapsula, for one, answers with a 200 page whose only content is a line inside an iframe.</li>
</ul>
<p>So the rule we settled on: a status must be <strong>corroborated</strong> by the page actually looking like an interstitial. Signatures are matched against the <em>interstitial's</em> wording, never the site's:</p>
<pre><code class="language-js">const BLOCK_SIGNATURES = [
  /sorry,?\s+you\s+have\s+been\s+blocked/i,
  /attention\s+required!?\s*\|\s*cloudflare/i,
  /just\s+a\s+moment\s*(\.\.\.|…)/i,            // Cloudflare managed challenge
  /checking\s+your\s+browser\s+before\s+accessing/i,
  /enable\s+javascript\s+and\s+cookies\s+to\s+continue/i,
  /verify\s+you\s+are\s+(a\s+)?human/i,
  /access\s+denied.{0,40}reference\s*#/i,        // Akamai
  /request\s+blocked.{0,40}request\s*id/i,       // AWS WAF
  /pardon\s+(the\s+)?interruption/i,             // PerimeterX / HUMAN
  /vercel\s+security\s+checkpoint/i,
  /we'?re\s+verifying\s+your\s+browser/i,        // Vercel, see below
  /request\s+unsuccessful\.?\s+incapsula\s+incident/i,
  /_Incapsula_Resource/i,
];

const BLOCK_STATUSES = [401, 403, 406, 429, 451, 503];
</code></pre>
<p>We match against <code>document.title</code> plus the first 4,000 characters of <code>document.body.innerText</code> — the <em>rendered</em> text, not the HTML. Interstitials hide their wording in scripts and iframes often enough that the source is a worse signal than what a human would actually see.</p>
<h2>Rule 2: a challenge is not a block — wait it out</h2>
<p>Cloudflare's "Just a moment…" is a <strong>managed challenge</strong>: it is designed to clear by itself once the browser passes the check. Our first version refused at <code>domcontentloaded</code>, which reported perfectly capturable sites as blocked.</p>
<p>We measured it: on a Cloudflare-protected page, modern headless (<code>headless: true</code>) cleared the challenge in <strong>under 5 seconds</strong> and rendered the real document. The old <code>headless: 'shell'</code> mode never did — still "Just a moment…" at 10 s, blank at 15 s. If you are still on the shell mode and seeing challenges, that alone may be your problem. (So is a default <code>HeadlessChrome/…</code> user agent: without a real one, the same URL gave a hard 403 instead of the softer challenge.)</p>
<p>So the detector returns two kinds of verdict, and only one of them is final:</p>
<pre><code class="language-js">const CHALLENGE_RE = /just\s+a\s+moment|checking\s+your\s+browser|we'?re\s+verifying\s+your\s+browser|vercel\s+security\s+checkpoint|verify\s+you\s+are\s+(a\s+)?human/i;

async function waitOutChallenge(page, verdict, { timeoutMs = 15000, stepMs = 1000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() &lt; deadline) {
    await new Promise(r =&gt; setTimeout(r, stepMs));
    let state;
    try { state = await readPageState(page); } catch { return verdict; }

    // No new response object here, so judge on content alone.
    const again = detectBlock({ status: 200, title: state.title, text: state.text });
    if (!again) {
      // Clearing a challenge navigates; for a moment there is no document.body.
      await page.waitForFunction(
        () =&gt; document.body &amp;&amp; document.readyState !== 'loading',
        { timeout: 10000 }
      ).catch(() =&gt; {});
      return null;                      // through — the real page is up
    }
    if (again.kind === 'blocked') return again;   // escalated, stop waiting
  }
  return verdict;
}
</code></pre>
<p>The <code>waitForFunction</code> at the end matters more than it looks. The moment the challenge clears, the browser navigates to the real page, and for a beat there is no <code>document.body</code>. Every step after this — removing cookie banners, measuring height, screenshotting — reaches straight into it and throws.</p>
<h2>Rule 3: catch Chrome's own error page first</h2>
<p>This one surprised us. When a server answers with an error status and <strong>no usable body</strong>, Chrome paints its own page: <em>"This page isn't working — HTTP ERROR 406."</em> Puppeteer happily screenshots that too.</p>
<p>Unlike a CDN interstitial, there is no site content on it at all, so it needs no corroboration beyond the status:</p>
<pre><code class="language-js">const BROWSER_ERROR_RE = /this\s+page\s+is\s*n.?t\s+working|this\s+site\s+can.?.?t\s+be\s+reached|http\s+error\s+\d{3}|\bERR_[A-Z_]{3,}/i;
</code></pre>
<p>It is gated on <code>status &gt;= 400</code> so an article that merely <em>discusses</em> HTTP errors still gets captured. We found it on <a href="http://scribd.com">scribd.com</a>, which answers document URLs from a datacenter IP with a bare <code>406</code>.</p>
<p>Next to it sits the empty refusal — a refusal status with almost nothing behind it:</p>
<pre><code class="language-js">if (BLOCK_STATUSES.includes(status) &amp;&amp; blob.replace(/\s+/g, '').length &lt; 40) {
  return { kind: 'blocked', message: `The site refused the request (HTTP ${status}) and sent no page.` };
}
</code></pre>
<h2>Rule 4: rate limits are their own case</h2>
<p><code>web.archive.org</code> answers our server, when it has had enough, with a bare nginx <strong>"429 Too Many Requests"</strong> page. It matched none of the CDN signatures, so a user got a one-page PDF of it.</p>
<p>A rate limit deserves a different message: the site works, it is refusing <em>you</em>, for now. Telling the user "this site blocks automated capture" is false; "try again later" is true.</p>
<pre><code class="language-js">const RATE_LIMIT_RE = /429\s+too\s+many\s+requests|too\s+many\s+requests|rate\s+limit(ed)?\s+exceeded/i;

if (status === 429 || (status &gt;= 400 &amp;&amp; RATE_LIMIT_RE.test(blob))) {
  return { kind: 'blocked', rateLimited: true, message: '…usually temporary — please try again later.' };
}
</code></pre>
<p>A small confession about that regex: for a while it read <code>429s+toos+manys+…</code>. The backslashes were lost in an edit, the pattern never matched anything, and only a literal <code>429</code> status was caught. A regex that silently matches nothing raises no error. Put your signature list under a test with one real sample per entry.</p>
<h2>The order, assembled</h2>
<pre><code class="language-js">function detectBlock({ status = 0, title = '', text = '' }) {
  const blob = `${title}\n${text.slice(0, 4000)}`;

  if (status &gt;= 400 &amp;&amp; BROWSER_ERROR_RE.test(blob))                 return blocked('no page');
  if (status === 429 || (status &gt;= 400 &amp;&amp; RATE_LIMIT_RE.test(blob))) return blocked('rate limited');
  if (BLOCK_STATUSES.includes(status) &amp;&amp; blob.replace(/\s+/g, '').length &lt; 40) return blocked('empty refusal');

  if (!BLOCK_SIGNATURES.some(re =&gt; re.test(blob))) return null;  // a real page, even under 403
  if (CHALLENGE_RE.test(blob))                    return challenge();   // wait it out
  return blocked('interstitial');
}
</code></pre>
<p>And at the call site, right after navigation and <strong>before</strong> anything that costs the user something:</p>
<pre><code class="language-js">const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
let verdict = detectBlock(await readPageState(page, response));
if (verdict?.kind === 'challenge') verdict = await waitOutChallenge(page, verdict);
if (verdict) throw new BlockedError(verdict.message);
</code></pre>
<p>We also run the same function in the crawler that discovers a site's pages. Otherwise an Imperva page gets listed as "the site's one page," and the user is offered a one-page archive of an error.</p>
<h2>The one-word miss</h2>
<p>The case that convinced us the list will never be finished: a docs site behind <strong>Vercel</strong> answered our server with <code>403</code> and the line <em>"We're verifying your browser."</em> We had Cloudflare's <em>"checking your browser"</em> and Vercel's <em>"Security Checkpoint"</em> title, but not this wording — one word off. The user got a blank 26 KB archive of the checkpoint.</p>
<p>So treat the signature list as a living log, not a finished design:</p>
<ol>
<li><strong>Log every verdict and every "success" under an error status.</strong> Our misses all showed up as captures that worked with a <code>4xx</code>, or as a tiny output file.</li>
<li><strong>Each new signature gets a comment saying where it was found.</strong> Six months later, that comment is the only thing stopping someone from "cleaning up" a pattern that looks redundant.</li>
<li><strong>Prefer a clear error to a clever bypass.</strong> Some sites block automated access on purpose. No amount of tuning changes that, and a useless PDF delivered as a success is worse than an honest "this site refused the request."</li>
</ol>
<hr />
<p><em>I build</em> <a href="https://site2pdf.online"><em>Site2PDF</em></a><em>, which archives web pages and whole sites as PDF, PNG or ZIP. The detector above runs before every capture there.</em></p>
]]></content:encoded></item></channel></rss>