DEV Community

Vitalii Holben
Vitalii Holben

Posted on

How to Handle Anti-Bot Measures When Taking Screenshots Programmatically

How to Handle Anti-Bot Measures When Taking Screenshots Programmatically

You send a request. The page loads. The screenshot comes back blank, or shows a CAPTCHA, or captures a "Please verify you're human" wall. This is one of the most common problems when building any screenshot pipeline.

Here's what's actually happening and how to deal with it.

Why headless browsers get flagged

Bot detection works by looking for patterns that differ from real users. Headless Chrome has several tells:

  • navigator.webdriver returns true by default
  • Missing Chrome-specific properties like window.chrome
  • Inconsistent screen dimensions (no monitor attached means no GPU info)
  • Mouse events fire at pixel-perfect coordinates with no jitter
  • Font fingerprints differ from headed browsers

Modern detection services (Cloudflare, Akamai, Datadome) look for combinations of these signals, not individual flags. Spoofing one without the others often makes the fingerprint more suspicious, not less.

The practical spectrum of detection

Most sites fall into one of three categories:

No active detection — a basic bot check via User-Agent string at most. Simple fix: set a realistic UA.

Passive fingerprinting — loads a detection script, collects signals, blocks on second or third visit. You'll see this on news sites, e-commerce, media platforms.

Active challenges — Cloudflare Turnstile, hCaptcha, reCAPTCHA v3 score-based. These require real interaction or a solving service.

Know which category your target falls into before spending time on it.

Fixes that work for most cases

1. Use a stealth plugin

For Playwright, playwright-extra with puppeteer-extra-plugin-stealth patches the most common fingerprinting vectors:

npm install playwright-extra puppeteer-extra-plugin-stealth
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

chromium.use(StealthPlugin());
const browser = await chromium.launch();

This handles navigator.webdriver, window.chrome, and several other flags automatically.

2. Set realistic headers

await page.setExtraHTTPHeaders({
  'Accept-Language': 'en-US,en;q=0.9',
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'sec-ch-ua': '"Chromium";v="120", "Google Chrome";v="120"',
  'sec-ch-ua-mobile': '?0',
  'sec-ch-ua-platform': '"macOS"',
});

3. Use a real Chrome binary, not Chromium

Chromium's font set differs from Chrome. Detection services track which fonts are available. Running headful Chrome via executablePath gives you a more convincing fingerprint than the bundled Chromium.

4. Add realistic delays and mouse movement

// Move mouse before clicking
await page.mouse.move(100 + Math.random() * 50, 200 + Math.random() * 30);
await page.waitForTimeout(300 + Math.random() * 500);
await page.click('#target');

Perfectly timed, pixel-precise interactions are a dead giveaway. Add jitter.

When the above isn't enough

Some sites run heavy fingerprinting that's hard to spoof at the browser level. Options at this point:

Residential proxies — rotate real residential IPs. The IP reputation matters as much as the browser fingerprint. Datacenter IPs are often pre-blocked.

Browser profiles — maintain persistent browser profiles with cookies, browsing history, and localStorage. A "fresh" headless browser looks different from a browser with 3 weeks of history.

Playwright with persistent context:

const context = await chromium.launchPersistentContext('./user-data-dir', {
  headless: false, // or true with stealth
});

The ethical and legal line

There's a difference between taking screenshots of public pages for archival, testing, or display purposes — which is generally fine — and bypassing authentication or rate limits to scrape data at scale. The former is what most screenshot tools do. The latter runs into Terms of Service issues and, in some jurisdictions, legal risk.

Worth being clear on which side of that line your use case sits before investing in bypass techniques.

What to monitor

Once you have a working pipeline, track failure reasons explicitly:

const response = await page.goto(url, { waitUntil: 'networkidle2' });
if (response.status() === 403 || response.status() === 429) {
  // Bot detection hit — log, rotate IP, back off
}

const pageTitle = await page.title();
if (pageTitle.toLowerCase().includes('captcha') || pageTitle.includes('verify')) {
  // Challenge page — flag for manual review
}

Silent failures — a 200 status but a CAPTCHA screenshot — are harder to catch. Use a content hash comparison or check for expected elements before saving the screenshot.

The problem doesn't go away entirely, but being systematic about detection and response turns a reliability nightmare into something manageable.

Top comments (0)