Dlaczego surowy Puppeteer jest łatwy do wykrycia
Każdy, kto próbował scrapować stronę chronioną przez Cloudflare, Datadome czy PerimeterX, wie: domyślna instancja Puppeteer pada już przy pierwszym requescie. Dlaczego? Bo przeglądarka Chromium kontrolowana przez DevTools Protocol zostawia ślady, które bot-detection systemy czytają jak otwarte książki.
Najważniejsze sygnały deanonimizujące:
- navigator.webdriver — w automatyzowanym Chrome ustawione na
true; bot-detektory sprawdzają to jako pierwszy filtr. - plugins/mimeTypes — pusta tablica
navigator.pluginszamiast domyślnych wtyczek Chrome (PDF Viewer, Chrome PDF Viewer itd.). - iframe contentWindow — ramka z
chrome.runtimelubchrome.csinie istnieje w automatyzowanym Chrome, podczas gdy prawdziwa przeglądarka je posiada. - WebGL renderer — Headless Chrome raportuje „SwiftShader" lub „Mesa", co jest natychmiastowym czerwonym flagiem.
- User-Agent niespójny z platformą — Headless Chrome domyślnie wysyła
HeadlessChromew UA stringu. - window.chrome — obiekt obecny w normalnym Chrome, nieobecny w headless.
Bot-detection nie sprawdza jednego sygnału — buduje score. Jeśli navigator.webdriver === true, dostajesz +30 pkt. Jeśli WebGL renderer to SwiftShader, kolejne +20. Przy progu 50–70 pkt trafiasz na CAPTCHĘ lub blokadę. Dlatego patchowanie pojedynczych sygnałów nie wystarcza — potrzebujesz systemowej naprawy.
Puppeteer-Extra + Stealth Plugin: co dokładnie patchuje
puppeteer-extra to wrapper nad Puppeteerem z architekturą wtyczkową. Wtyczka puppeteer-extra-plugin-stealth aplikuje zestaw ewaluacji (page.evaluateOnNewDocument), które nadpisują właściwości przeglądarki przed załadowaniem jakiejkolwiek strony.
Oto sygnały, które stealth patchuje out-of-the-box:
| Sygnał | Co stealth robi |
|---|---|
navigator.webdriver | Nadpisuje na undefined / false |
navigator.plugins | Wstrzykuje realistyczną listę wtyczek Chrome |
navigator.languages | Ustawia spójną z UA listę języków |
window.chrome | Tworzy obiekt z runtime, loadTimes, csi |
iframe contentWindow.chrome | Replikuje chrome object w iframe'ach |
| WebGL vendor/renderer | Zmienia SwiftShader na realistyczny GPU |
navigator.permissions | Naprawia query API dla notifications/geolocation |
| Chrome DevTools artifacts | Usuwa ślady CDP z window |
Podstawowa konfiguracja jest trywialna:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
(async () => {
const browser = await puppeteer.launch({
headless: false,
args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'],
});
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com/');
// Zobaczysz zielone checkmarki zamiast czerwonych flag
await new Promise(r => setTimeout(r, 30000));
await browser.close();
})();
Flaga --disable-blink-features=AutomationControlled jest kluczowa — wyłącza ustawianie navigator.webdriver = true na poziomie Chromium, zanim stealth w ogóle zadziała. Bez niej niektóre detektory i tak wyłapują automatyzację.
Łączenie stealth z proxy residentalnymi — najmocniejszy stack
Stealth patchuje sygnały przeglądarki. Ale bot-detection bierze pod uwagę też sygnały sieciowe:
- IP z datacenter (ASN przypisane do AWS/GCP/OVH) = natychmiastowy +40 pkt do score.
- IP z regionu niespójnego z językiem UA (np. UA=pl-PL, IP z Lagos) = kolejne +20.
- Wiele requestów z jednego IP w krótkim czasie = rate-limit / CAPTCHA challenge.
Dlatego residential proxies to nie „opcja premium" — to fundament. IP z puli residentalnej ma ASN zwykłego ISP, przechodzi przez większość GeoIP checków i nie trafi na blocklisty datacenter-ASN.
Konfiguracja ProxyHat z puppeteer-extra:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
const PROXY_USER = 'user-country-US';
const PROXY_PASS = 'twoje_haslo';
(async () => {
const browser = await puppeteer.launch({
headless: false,
args: [
'--no-sandbox',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://gate.proxyhat.com:8080`,
],
});
const page = await browser.newPage();
await page.authenticate({
username: PROXY_USER,
password: PROXY_PASS,
});
await page.goto('https://httpbin.org/ip');
const ip = await page.$eval('pre', el => el.textContent);
console.log('Moje IP przez ProxyHat:', ip);
await browser.close();
})();
Geo-targetowanie w ProxyHat działa przez username — zmiana kraju to zmiana jednego ciągu znaków, nie zmiana endpointu. Chcesz IP z Niemiec? user-country-DE. Z Berlina? user-country-DE-city-berlin. To kluczowe dla spójności: Twój UA mówi „de-DE", Twoje IP jest z Frankfurtu, WebGL raportuje niemieckie GPU — detektor nie ma powodu do podejrzeń.
Custom evaluatory: randomizacja canvas i WebGL per sesja
Stealth plugin normalizuje sygnały, ale nie randomizuje fingerprintu. Jeśli 100 sesji z Twojego crawlera raportuje identyczny canvas hash, to z czasorem detektorzy to wyłapią jako bot-cluster. Potrzebujesz per-session noise injection.
Kluczowe obszary randomizacji:
- Canvas fingerprint — dodaj sub-pixelowy noise do operacji rysowania.
- WebGL fingerprint — zmień parametry RENDERER/VENDOR oraz dodaj noise do
readPixels. - AudioContext — delikatny noise do oscylatora.
function createFingerprintNoise(seed) {
const noise = (Math.random() * 0.01 - 0.005).toFixed(6);
return `
// Canvas noise — sub-pixel shift w toDataURL
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
const ctx = this.getContext('2d');
if (ctx) {
const imgData = ctx.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < imgData.data.length; i += 4) {
imgData.data[i] += Math.round(${noise} * 2); // R
imgData.data[i+1] += Math.round(${noise} * 3); // G
}
ctx.putImageData(imgData, 0, 0);
}
return origToDataURL.apply(this, arguments);
};
// WebGL noise — zmień UNMASKED_RENDERER
const getParamOrig = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {
if (param === 0x1F00) return 'NVIDIA GeForce GTX 1060'; // UNMASKED_VENDOR
if (param === 0x1F01) return 'NVIDIA GeForce GTX 1060'; // UNMASKED_RENDERER
return getParamOrig.call(this, param);
};
// Analogicznie dla WebGL2
if (typeof WebGL2RenderingContext !== 'undefined') {
const getParam2Orig = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function(param) {
if (param === 0x1F00) return 'NVIDIA GeForce GTX 1060';
if (param === 0x1F01) return 'NVIDIA GeForce GTX 1060';
return getParam2Orig.call(this, param);
};
}
`;
}
// Użycie per page:
const noiseScript = createFingerprintNoise(Date.now());
await page.evaluateOnNewDocument(noiseScript);
Ważne: noise musi być deterministyczny per sesję (aby kolejne wizyty tej samej sesji dawały ten sam fingerprint), ale różny między sesjami. Dlatego seedujesz go z session ID. Sticky session w ProxyHat (user-session-abc123:pass) + session-seeded noise = spójna tożsamość przeglądarki przez całą sesję.
Rotacja proxy per browser context
Puppeteer pozwala tworzyć wiele BrowserContextów (odpowiednik profili przeglądarki) w jednej instancji Chrome. Problem: --proxy-server ustawia się na poziomie launch, nie kontekstu. Jak rotować IP per kontekst?
Rozwiązanie: proxy per page z użyciem CDP lub osobna instancja browsera per kontekst. W produkcji ta druga opcja jest stabilniejsza.
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
const CREDENTIALS = [
{ username: 'user-country-US', password: 'twoje_haslo' },
{ username: 'user-country-DE-city-berlin', password: 'twoje_haslo' },
{ username: 'user-country-JP', password: 'twoje_haslo' },
];
async function createContextProxy(credentials) {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://gate.proxyhat.com:8080`,
],
});
const page = await browser.newPage();
await page.authenticate({
username: credentials.username,
password: credentials.password,
});
return { browser, page };
}
(async () => {
// Uruchom 3 izolowane konteksty z różnymi IP
const contexts = await Promise.all(
CREDENTIALS.map(creds => createContextProxy(creds))
);
for (const { page, browser } of contexts) {
await page.goto('https://httpbin.org/ip');
const ip = await page.$eval('pre', el => el.textContent);
console.log('IP:', ip.trim());
}
await Promise.all(contexts.map(c => c.browser.close()));
})();
Dla lepszego zarządzania zasobami, zbuduj browser pool:
class BrowserPool {
constructor(maxSize = 5) {
this.maxSize = maxSize;
this.available = [];
this.inUse = new Set();
this.creds = [
{ username: 'user-country-US', password: 'twoje_haslo' },
{ username: 'user-country-GB', password: 'twoje_haslo' },
{ username: 'user-country-FR', password: 'twoje_haslo' },
{ username: 'user-country-DE', password: 'twoje_haslo' },
{ username: 'user-country-ES', password: 'twoje_haslo' },
];
this.credIndex = 0;
}
async acquire() {
if (this.available.length > 0) {
const ctx = this.available.pop();
this.inUse.add(ctx);
return ctx;
}
if (this.inUse.size < this.maxSize) {
const creds = this.creds[this.credIndex % this.creds.length];
this.credIndex++;
const ctx = await this._launch(creds);
this.inUse.add(ctx);
return ctx;
}
// Czekaj na zwolnienie
return new Promise(resolve => {
const check = setInterval(async () => {
if (this.available.length > 0) {
clearInterval(check);
const ctx = this.available.pop();
this.inUse.add(ctx);
resolve(ctx);
}
}, 500);
});
}
async release(ctx) {
this.inUse.delete(ctx);
this.available.push(ctx);
}
async _launch(creds) {
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://gate.proxyhat.com:8080`,
],
});
const page = await browser.newPage();
await page.authenticate({ username: creds.username, password: creds.password });
return { browser, page, id: creds.username };
}
async drain() {
for (const ctx of [...this.inUse, ...this.available]) {
await ctx.browser.close();
}
this.inUse.clear();
this.available = [];
}
}
// Użycie:
const pool = new BrowserPool(5);
const ctx = await pool.acquire();
try {
await ctx.page.goto('https://example.com');
// ... scraping logic
} finally {
await pool.release(ctx);
}
Skalowanie: konteneryzacja, fleet management, resource management
Pojedyncza maszyna z 8 GB RAM utrzyma ~5–8 instancji Chrome (każda ~500 MB–1 GB). Do produkcyjnego scrapowania potrzebujesz fleet.
Docker + browser pool
- Każdy worker to kontener z Node.js + Chromium.
- Orkiestracja przez Docker Compose (małe fleet) lub Kubernetes (duże fleet).
- Limity pamięci per kontener:
mem_limit: 1.5g— Chrome z OOM killer to częsty problem.
# docker-compose.yml
version: '3.8'
services:
scraper-worker:
build: .
deploy:
replicas: 5
resources:
limits:
memory: 1500M
cpus: '1.0'
environment:
- PROXY_USER=user-country-US
- PROXY_PASS=twoje_haslo
- MAX_CONCURRENT=3
- REDIS_URL=redis://redis:6379
depends_on:
- redis
redis:
image: redis:7-alpine
Resource management — best practices
- Auto-close idle browsers: ustaw timer — jeśli strona nie wykonuje akcji przez 60s, zamknij kontekst. Wycieki pamięci to główna przyczyna crashy.
- Health checks: co 30s sprawdź
browser.version(). Jeśli timeout — zabij i odtwórz. - Queue-based architecture: Redis jako task queue, workerzy pobierają URL-i, scrapują, odkładają wyniki. Zero koordynacji między workerami.
- Sticky sessions: dla stron z logowaniem, użyj
user-session-abc123w ProxyHat, aby wszystkie requesty jednej sesji szły przez jedno IP. Czas życia sesji: dostosuj do TTL strony (zwykle 10–30 min). - Concurrency tuning: nie przekraczaj 3 stron per browser. Chrome współdzieli pamięć GPU między tabami — przy 10+ tabach crash jest kwestią czasu.
Porównanie strategii skalowania
| Strategia | Złożoność | Koszt | Niezawodność | Najlepsze dla |
|---|---|---|---|---|
| Pojedynczy server, 1 browser | Minimalna | $5–20/mies. | Niska | Prototypy, małe joby |
| Pojedynczy server, browser pool | Średnia | $50–100/mies. | Średnia | Średni traffic, 1 target |
| Docker Compose, 3–5 workerów | Średnia | $150–300/mies. | Wysoka | Multi-target, produkcja |
| Kubernetes, auto-scaling | Wysoka | $500+/mies. | Bardzo wysoka | Enterprise, 100k+ stron/dzień |
Etyka: stealth do legalnego scrapingu, nie do oszustwa
Techniki antydetekecji to potężne narzędzie. Używaj ich odpowiedzialnie:
- Scraping publicznych danych (ceny, wyniki wyszukiwania, dane kontaktowe) — legalny use case w większości jurysdykcji, pod warunkiem przestrzegania
robots.txti rate-limitów. - Omijanie paywalli, kradzież tożsamości, credential stuffing — nielegalne i nieetyczne. Stealth nie jest po to stworzony.
- GDPR/CCPA — jeśli scrapujesz dane osobowe, musisz mieć podstawę prawną. Technika nie zwalnia z obowiązków prawnych.
- ToS stron — łamanie regulaminu może skutkować blokadą konta lub pozwem cywilnym. Bądź świadomy ryzyka.
Stealth + residential proxy to stack do odpornego, niezawodnego scrapingu — nie do maskowania złośliwej aktywności. Szanuj granice prawne i etyczne.
Key Takeaways
- Surowy Puppeteer jest łatwo wykrywalny —
navigator.webdriver, pusteplugins, SwiftShader w WebGL to natychmiastowe flagi. puppeteer-extra-plugin-stealthpatchuje 9+ sygnałów, ale nie randomizuje fingerprintu — musisz dodać własny noise injection per sesja.- Residential proxies są niezbędne — datacenter IP jest wykrywane na podstawie ASN. ProxyHat z geo-targetowaniem w username daje spójność IP ↔ język ↔ lokalizacja.
- Rotacja IP per kontekst wymaga osobnej instancji browsera (lub CDP-level proxy override). Browser pool z limitem pamięci to produkcyjny standard.
- Skalowanie = konteneryzacja + queue + auto-recovery. Kubernetes dla dużych fleet, Docker Compose dla średnich.
- Etyka first — scrapuj legalnie, przestrzegaj robots.txt, GDPR i ToS.
Kolejne kroki
Gotowy, by zbudować niewykrywalny crawler? Zacznij od planu ProxyHat, który daje Ci dostęp do residentalnych IP w 190+ lokalizacjach, połącz go z puppeteer-extra-stealth i fingerprint randomization — i scrapuj z confidence, nie z lękiem przed CAPTCHĄ.
Więcej wzorców scrapingu znajdziesz w naszym przewodniku po best practices i na stronie use case web-scraping.






