playwright-automation
Playwright via CLI et outils navigateur Hermes natifs — scraping, tests E2E, QA automatisée. Zero MCP, zero overhead token.
Quand l'utiliser (Trigger)
Déclenchement standard selon le contexte de l'écosystème Hermès.
Mode d'emploi (Usage)
Mode d'emploi standard via l'agent Hermès. Playwright — Automatisation navigateur (0 MCP)
Pourquoi pas un MCP Playwright
| Approche | Tokens overhead | Activation |
|---|---|---|
| Playwright MCP permanent | ~7K à chaque session | Toujours chargé |
| Outils navigateur natifs Hermes | 0 tokens | Appel direct (browser_navigate, etc.) |
| Playwright CLI via skill | 0 tokens sauf invocation | À la demande |
Règle : Jamais de MCP Playwright. Utiliser les outils natifs Hermes d’abord, Playwright CLI en second recours.
Mode 1 — Outils navigateur natifs Hermes (recommandé)
Pour scraping, navigation, extraction : utiliser directement les outils intégrés :
| Outil | Usage |
|---|---|
browser_navigate(url) | Charger une page |
browser_snapshot(full=true/false) | Lire le contenu textuel |
browser_vision(question) | Analyser visuellement (CAPTCHA, layouts) |
browser_click(ref) | Cliquer sur éléments interactifs |
browser_type(ref, text) | Remplir des formulaires |
browser_console(expression) | Exécuter JS dans la page |
web_extract(urls) | Pages simples/statiques (plus rapide) |
Quand utiliser ce mode : Scraping ad-hoc, navigation site interactif, QA visuelle, formulaires.
Pondération : 95% des cas. Les outils natifs couvrent tout le besoin sans coût additionnel.
Mode 6 — Chrome CDP (connexion à une instance Chrome déjà lancée)
Quand utiliser ce mode : Quand Chrome tourne déjà sur la machine avec
--remote-debugging-port=9222 (via socat ou Chrome Remote Debugging).
Utile pour :
- Utiliser le navigateur avec session utilisateur réelle (cookies, historique)
- Mode
headless=Falseavec DISPLAY=:0 sur une machine avec bureau (mx) - Éviter de lancer un nouveau processus Chrome (économie mémoire)
- Les sites qui bloquent les instances Playwright fraîches
Connexion à un Chrome existant
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
# Connexion à Chrome déjà lancé avec --remote-debugging-port=9222
browser = await p.chromium.connect_over_cdp("http://localhost:9222")
# Récupérer le contexte par défaut (déjà connecté si l'utilisateur est loggé)
context = browser.contexts[0] # contexte par défaut (gardes cookies)
page = await context.new_page()
await page.goto("https://example.com")
print(await page.title())
# Ne PAS fermer browser — on ne fait que l'emprunter
await context.close() # fermer seulement le contexte
asyncio.run(main())
Règles :
context = browser.contexts[0]→ contexte par défaut (cookies persistants)browser.new_page()→ crée un onglet dans le contexte existant- Ne PAS appeler
browser.close()— on emprunte Chrome, on ne le tue pas - Fermer seulement
context.close()ou juste les pages créées
Script type (mx — Chrome CDP lancé via socat)
Sur mx, Chrome tourne via socat avec --remote-debugging-port=9222 :
# Lancement Chrome (via start-chrome-remote.sh)
google-chrome --remote-debugging-port=9222 --no-first-run --no-default-browser-check
# socat expose le port sur le réseau
socat TCP-LISTEN:9222,bind=100.124.230.67,fork TCP:localhost:9222 &
Script Playwright correspondant :
#!/usr/bin/env python3
import asyncio, os, random, time
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp("http://localhost:9222")
context = browser.contexts[0]
page = await context.new_page()
# Comportement humain : scroll naturel progressif
await page.goto("https://www.centris.ca", wait_until="networkidle")
await asyncio.sleep(random.uniform(1.5, 3.5))
# Scrolling humain (progressif, pas instantané)
for _ in range(2):
await page.evaluate("window.scrollBy(0, 300)")
await asyncio.sleep(random.uniform(0.5, 1.5))
# Extraire le contenu
title = await page.title()
print(f"Page title: {title}")
await context.close()
asyncio.run(main())
Pitfalls Chrome CDP
- Pas de
browser.close()— cela tuerait le Chrome de l’utilisateur - Contexte unique —
browser.contexts[0]est le seul contexte. Si plusieurs scripts s’exécutent en parallèle, ils partagent les cookies et sessions - CDP timeout — si Chrome est lent ou freeze, le
connect_over_cdppeut timeout. Démarrer Chrome avec--remote-debugging-timeout=30000 - Port déjà utilisé — vérifier :
ss -tlnp | grep 9222 - Chrome pas lancé — le script plante immédiatement. Toujours faire un
health check avant :
curl -s http://localhost:9222/json/version
Vérification
# Chrome CDP est-il accessible ?
curl -s http://localhost:9222/json/version | python3 -m json.tool
# Test python rapide
python3 -c "
import asyncio
from playwright.async_api import async_playwright
async def t():
async with async_playwright() as p:
b = await p.chromium.connect_over_cdp('http://localhost:9222')
ctx = b.contexts[0]
p = await ctx.new_page()
await p.goto('https://example.com')
print('OK:', await p.title())
await ctx.close()
asyncio.run(t())
"
Mode 7 — Playwright avec playwright-stealth (anti-détection avancée)
Quand utiliser ce mode : Sites qui bloquent les navigateurs headless ou les instances Playwright standard (navigator.webdriver, chrome.runtime, WebGL vendor, etc.). Complète les stratégies anti-détection de base.
Installation
pip install playwright-stealth # ou uv pip install playwright-stealth
Usage avec stealth_sync (recommandé)
Utilise le context manager stealth_sync qui applique automatiquement tous
les patches anti-détection au lancement du contexte :
from playwright.async_api import async_playwright
from playwright_stealth import stealth_sync
import asyncio
async def main():
async with async_playwright() as p:
# Connexion à Chrome CDP déjà lancé (ou launch headless)
browser = await p.chromium.connect_over_cdp("http://localhost:9222")
context = browser.contexts[0]
# Appliquer stealth automatiquement sur chaque nouvelle page
page = await context.new_page()
await stealth_sync(page) # patch unique
await page.goto("https://bot.sannysoft.com", wait_until="load")
# Vérifier : navigator.webdriver = undefined
result = await page.evaluate("navigator.webdriver")
print(f"webdriver={result} (attendu: None)")
await context.close()
asyncio.run(main())
Usage avec stealth_async (pattern alternatif)
from playwright_stealth import stealth_async
page = await context.new_page()
await stealth_async(page)
Ce que playwright-stealth patche
| Détection | Patch | Résultat |
|---|---|---|
navigator.webdriver | Object.defineProperty | undefined |
chrome.runtime | Simule l’absence | Passe |
navigator.plugins | Ajoute plugins réalistes | 5+ plugins |
WebGL vendor/renderer | Même que vrai Chrome | Passe |
navigator.languages | Définit [fr-CA, fr, en] | Configurable |
navigator.hardwareConcurrency | Cache le nombre réel | 4 (ou autre) |
Pitfalls playwright-stealth
- À appeler une seule fois par page — après
page.goto()ou avant, ça n’a pas d’importance (les patches sont sur le contexte, pas le DOM) - stealth_sync vs stealth_async — les deux font la même chose à la
différence près que
stealth_syncattend les résultats,stealth_asyncles lance sans attente.stealth_syncest plus fiable - Ne remplace PAS un User-Agent réaliste — toujours définir un UA Chrome 130+
- Version 2.0.3+ inclut les patches WebGL — vérifier la version installée
avec
pip show playwright-stealth | grep Version - playwright-stealth + Chrome CDP (connect_over_cdp) = meilleur combo pour les sites anti-bot : le navigateur est un vrai Chrome avec session utilisateur, et stealth empêche la détection par JS
Comportement humain — Patron pour scrapers multi-sites (obligatoire)
Quand tu écris des scripts de scraping qui visitent plusieurs sites, chaque interaction doit simuler un humain réaliste :
import random, asyncio
# Variables globales de timing
HUMAN_DELAY = lambda: random.uniform(1.5, 5.5)
SCROLL_DELAY = lambda: random.uniform(0.5, 1.5)
PAGE_DELAY = lambda: random.uniform(2.0, 4.0)
async def human_scroll(page, steps=3):
\"\"\"Scroll progressif comme un humain qui lit.\"\"\"
for _ in range(steps):
await page.evaluate(f"window.scrollBy(0, {random.randint(200, 500)})")
await asyncio.sleep(SCROLL_DELAY())
async def human_visit(page, url, wait_until="load"):
\"\"\"Visite une page avec délai naturel. Utiliser \"load\" (pas \"networkidle\")
pour éviter les timeouts sur sites avec trackers tiers. \"\"\"
await page.goto(url, wait_until=wait_until, timeout=45000)
await asyncio.sleep(HUMAN_DELAY())
await human_scroll(page, random.randint(1, 3))
# Usage dans un scraper multi-sites :
sites = ["https://site1.ca", "https://site2.ca", "https://site3.ca"]
for site in sites:
await human_visit(page, site)
# Extraire données ici
await asyncio.sleep(random.uniform(1.0, 3.0)) # pause entre sites
Principes :
- Délais aléatoires dans une fourchette réaliste (pas de
sleep(2)fixe) - Scroll progressif (pas instantané) — l’œil humain lit en descendant
- Pause entre les sites — un humain ne clique pas 10 sites en 3 secondes
- User-Agent réaliste — pas de
HeadlessChromedans le UA - Pas de parallélisme de navigation — un humain ne visite qu’un onglet à la fois
- Rotation des horaires — ne pas scraper les mêmes sites aux mêmes secondes
⚠️ Environnement VPS : $HOME est redirigé
Sur ce VPS, la variable d’environnement $HOME pointe vers /home/bf/.hermes/home/, pas /home/bf/. Cela impacte Playwright :
echo "HOME=$HOME" # → /home/bf/.hermes/home
echo "USER_HOME=$(getent passwd bf | cut -d: -f6)" # → /home/bf
Conséquence : Les .cache/ms-playwright sous ~/ résoudra vers /home/bf/.hermes/home/.cache/, pas /home/bf/.cache/.
Solution : Toujours définir PLAYWRIGHT_BROWSERS_PATH explicitement :
PLAYWRIGHT_BROWSERS_PATH=/home/bf/.cache/ms-playwright python3 script.py
Vérification des caches disponibles :
# Cache Hermes (n'a que chromium normal)
ls /home/bf/.hermes/home/.cache/ms-playwright/
# → chromium-1223
# Cache utilisateur (a chromium_headless_shell + ffmpeg)
ls /home/bf/.cache/ms-playwright/
# → chromium-1223, chromium_headless_shell-1223, chromium_headless_shell-1187, ffmpeg-1011
La cache utilisateur a le chromium_headless_shell nécessaire pour le mode headless — c’est celui qu’il faut utiliser.
Mode 2 — Playwright CLI (tests reproductibles)
Pour tests E2E automatisés, captures d’écran, parcours utilisateur programmatiques.
Installation
# Méthode 1 : via npx (Node.js)
which npx
npx playwright install chromium 2>&1 | tail -5
# Méthode 2 : via Python (recommandé sur ce VPS pour les scripts complexes)
uv pip install playwright
PLAYWRIGHT_BROWSERS_PATH=/home/bf/.cache/ms-playwright python3 -c "from playwright.sync_api import sync_playwright; print('OK')" 2>&1
# ⚠️ npx playwright install peut timeout (surtout si dpkg est locké)
# Docker dpkg --configure -a prend >120s. Si ça bloque, utiliser Python + la cache existante.
Vérification
PLAYWRIGHT_BROWSERS_PATH=/home/bf/.cache/ms-playwright python3 -c "
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'])
page = await browser.new_page()
await page.goto('https://example.com', wait_until='domcontentloaded')
print('OK - Playwright fonctionne:', await page.title())
await browser.close()
asyncio.run(main())
"
Scripts Python (recommandé sur ce VPS)
Pour les tâches complexes (formulaires, anti-détection, scraping JS lourd), utiliser Python plutôt que npx/tsx :
#!/usr/bin/env python3
import asyncio, os
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = "/home/bf/.cache/ms-playwright"
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
"--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"]
)
context = await browser.new_context(
viewport={"width": 1280, "height": 900},
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36",
locale="fr-CA", timezone_id="America/Montreal",
)
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
""")
page = await context.new_page()
await page.goto("https://example.com", wait_until="load", timeout=30000)
await browser.close()
asyncio.run(main())
Le chemin absolu pour PLAYWRIGHT_BROWSERS_PATH dans l’environ est critique — os.path.expanduser("~") résoudrait /home/bf/.hermes/home/ et trouverait le mauvais cache.
Anti-détection (portails qui bloquent les bots)
Certains portails (notamment emplois.carrieres.gouv.qc.ca) bloquent les navigateurs headless :
Stratégies qui fonctionnent :
- Désactiver AutomationControlled :
--disable-blink-features=AutomationControlled - Spoof navigator.webdriver : via
add_init_script(voir snippet ci-dessus) - User-Agent réaliste : Chrome 130+ sur Linux
- Locale et fuseau horaire : fr-CA, America/Montreal
- Viewport standard : 1280x900
Stratégies qui ont échoué sur le portail Québec :
- Headless mode → la page React redirige vers
/deconnexionsans rendre le formulaire de login - La bannière cookies (ALTCHA + cookie consent) doit être fermée AVANT que le formulaire n’apparaisse
Limitation connue : Le portail Québec (emplois.carrieres.gouv.qc.ca) est une app React SPA avec ALTCHA CAPTCHA. Même avec anti-détection, la soumission automatisée des candidatures reste problématique :
- Le formulaire de login ne se rend pas complètement en headless (React SPA détecte l’automatisation)
- ALTCHA nécessite un vrai navigateur avec exécution JS complète
- Les interactions (click, type) via Hermes browser tools timeout
- L’URL
/libre-service/offres-emploi/connexionredirige vers/deconnexion(le route React)
Workflow exploratoire :
page.goto(url, wait_until="load")+asyncio.sleep(8)pour laisser React + cookie banner charger- Chercher les éléments par label plutôt que par type (React ne les rend pas toujours comme
input[type=email]) - Utiliser
page.evaluate()pour inspecter le DOM complet plutôt quepage.query_selector() - Le checkbox ALTCHA est souvent visible en DOM même si les champs email/password ne le sont pas encore
Scripts de test (npx/tsx legacy)
# Tester l'accès à un site
cat > /tmp/test-url.ts << 'TSEOF'
import { test, expect } from '@playwright/test';
test('vérifier accès au site', async ({ page }) => {
await page.goto('$URL', { waitUntil: 'networkidle' });
console.log('Titre:', await page.title());
console.log('URL:', page.url());
});
TSEOF
npx playwright test /tmp/test-url.ts --reporter=list --project=chromium 2>&1
Capture d’écran
cat > /tmp/screenshot.ts << 'TSEOF'
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await page.goto('$URL', { waitUntil: 'networkidle' });
await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true });
console.log('Screenshot saved to /tmp/screenshot.png');
await browser.close();
})();
TSEOF
npx tsx /tmp/screenshot.ts 2>&1
Scraping JS-heavy
cat > /tmp/scrape.ts << 'TSEOF'
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('$URL', { timeout: 30000 });
// Attendre que le contenu dynamique se charge
await page.waitForTimeout(3000);
// Extraire données
const data = await page.evaluate(() => {
// Manipulation DOM directement ici
return document.querySelectorAll('h1, h2, .listing, .result').length;
});
console.log('Elements:', data);
await browser.close();
})();
TSEOF
npx tsx /tmp/scrape.ts 2>&1
Mode 5 — Camofox API direct (quand Hermes browser tools timeout)
Quand utiliser ce mode : Les outils browser_click / browser_type timeout
systématiquement, ou le site est une SPA React qui ne réagit pas aux interactions
des outils Hermes natifs. Les portails gouvernementaux (notamment le Québec) sont
concernés.
Approche : Utiliser le evaluate endpoint de Camofox pour exécuter du
JavaScript directement dans la page navigateur — contourne les limitations des
abstractions Hermes.
Health check
curl -s http://localhost:9377/health
Créer un onglet (avec URL directe)
curl -s -X POST http://localhost:9377/tabs \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
⚠️ Tab reaper ~2s — Camofox ferme les onglets inactifs après ~2s. Créer l’onglet AVEC l’URL cible. Ne pas créer un onglet vide puis naviguer séparément.
Exécuter du JS dans la page (evaluate)
curl -s -X POST http://localhost:9377/tabs/{tabId}/evaluate \
-H "Content-Type: application/json" \
-d '{"expression":"document.title"}'
L’expression JS est évaluée dans le contexte de la page, comme DevTools console.
Remplir un champ
curl -s -X POST http://localhost:9377/tabs/{tabId}/evaluate \
-H "Content-Type: application/json" \
-d '{"expression":"document.querySelector('"'"'input[name=\"email\"]'"'"').value='"'"'user@example.com'"'"'"}'
Cliquer sur un élément
curl -s -X POST http://localhost:9377/tabs/{tabId}/evaluate \
-H "Content-Type: application/json" \
-d '{"expression":"document.querySelector('"'"'button:has-text(\"J'accepte\")'"'"').click()"}'
ALTCHA CAPTCHA — workflow complet
Le portail Québec utilise ALTCHA (code-challenge) pour le CAPTCHA. Le widget
injecte un élément <altcha-widget> avec les attributs challengeurl,
state et hasinput.
Workflow :
- Naviguer, laisser React charger
- Vérifier le state ALTCHA via evaluate
- Si
hasInput=true(code-challenge) :- Extraire l’URL du challenge depuis
challengeurl - Fetch le JSON → extraire
codeChallenge.image(base64 PNG) - Décoder et sauvegarder l’image
- Présenter à l’utilisateur via MEDIA: et demander le code
- Taper le code reçu dans
.altcha-code-challenge-input
- Extraire l’URL du challenge depuis
- Soumettre
Human-in-the-loop : L’utilisateur lit l’image CAPTCHA et te renvoie le code. Ne pas lui demander d’ouvrir le navigateur ou cliquer.
Pitfalls :
- Quotes JS dans curl :
'"'"'pour échapper les simples quotes dans l’expression JSON - Tab reaper : enchaîner les appels rapidemment
- Type endpoint vs evaluate : préférer evaluate pour les champs React car
typepeut ne pas déclencher onChange - userId Camofox sur ce VPS :
bf— requis en query param?userId=bfsur l’endpoint/tabs/{id}/evaluate - Python 3.11 f-string : pas de backslash ou d’expression dict directe
(
{data['key']}→ SyntaxError). Préférer une variable intermédiaire
Référence ALTCHA : references/altcha-captcha-bypass.md
Technique clé : L’API ALTCHA retourne 403 depuis curl. Utiliser
async fetch() depuis le contexte navigateur via evaluate pour obtenir
l’image challenge. Le navigateur a les bons cookies/en-têtes.
Pitfall : L’ID du checkbox ALTCHA est dynamique — le trouver avec
altcha-widget input[type=checkbox]. Les éléments ALTCHA sont en light
DOM (pas shadowRoot). Deux boutons : “Vérifier” (code ALTCHA) puis
“Se connecter” (formulaire login).
Human handoff protocol : Voir references/altcha-captcha-bypass.md section
“Human-in-the-loop : Protocole de handoff (3 phases)” pour le pattern
formel d’interaction agent → humain → agent (Phase 1 pré-remplissage →
Phase 2 humain gère Altcha/2FA → Phase 3 agent continue).
Camofox recovery : Voir references/altcha-captcha-bypass.md section
“Camofox : Fiabilité et recovery” si Camofox devient instable
(session_expired, recovering, timeout).
Mode 4 — Remote execution sur mx (headless=False)
Pour les sites qui détectent/bloquent les navigateurs headless (portails
gouvernementaux type emplois.carrieres.gouv.qc.ca), la seule solution
fiable est de lancer Playwright avec headless=False directement sur la
machine locale du postulant, mx (100.124.230.67).
Quand utiliser ce mode : Portails d’emploi, sites CAPTCHA-protégés, sites qui bloquent les bots headless.
Workflow :
- SSH vers
mx(voirtailscale-remote-accessskill) - Copier le script Playwright sur
mxvia SCP - Exécuter avec
headless=False— Chrome s’ouvre sur le bureau de l’utilisateur - L’utilisateur voit et peut interagir (CAPTCHA, ALTCHA)
- Récupérer les résultats via SCP
Référence : job-automation/references/mx-remote-postulation.md
Script type (pour mx) :
from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=False, # ⚠️ OUVRE CHROME SUR LE BUREAU
args=["--no-sandbox"]
)
context = await browser.new_context(
viewport={"width": 1280, "height": 900},
locale="fr-CA"
)
page = await context.new_page()
await page.goto("https://emplois.carrieres.gouv.qc.ca/plateforme-emploi/poste/XXXX")
# Laisser l'utilisateur voir et interagir
input("Appuyez sur Entrée quand la candidature est soumise...")
await browser.close()
asyncio.run(main())
Mode 3 — Playwright Codegen (enregistrement interactif)
Pour générer des scripts de test en cliquant sur le site :
npx playwright codegen "$URL"
⚠️ Nécessite un display X11 (pas en headless SSH). Utiliser en local sur le MacBook.
Cas #1 — Centris.ca (recherche immobilière JS-heavy)
# Étape 1 : Utiliser browser_native pour charger la page
# browser_navigate("https://www.centris.ca/")
# Étape 2 : browser_vision pour repérer les champs
# browser_vision("Trouve le champ de recherche et les boutons")
# Étape 3 : browser_click / browser_type pour interagir
# browser_type("@e12", "Laval")
# browser_click("@e15")
# Extraire les résultats
# browser_console("JSON.stringify(window.__INITIAL_STATE__)")
Cas #2 — Tests E2E sur app auto-hébergée
cat > /tmp/e2e.ts << 'TSEOF'
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// Login
await page.goto('https://cool.iatuto.com');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', '*** await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Vérifier les services
const services = await page.textContent('.services-count');
console.log('Services actifs:', services);
await browser.close();
})();
TSEOF
npx tsx /tmp/e2e.ts 2>&1
Vérification
# Vérifier que Playwright est fonctionnel
npx playwright --version
# Test rapide
cat > /tmp/check.ts << 'TSEOF'
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
console.log('OK - Playwright fonctionne:', await page.title());
await browser.close();
})();
TSEOF
npx tsx /tmp/check.ts 2>&1
Pitfalls :
⚠️ Piège n°1 — Locator.all() est async (manquer await = bug silencieux)
page.locator(...).all() retourne une coroutine, pas une liste. Sans await, tu obtiens un objet coroutine qui passe le if test (truthy) mais n’a pas les méthodes de liste.
❌ Bug (le plus fréquent dans les scrapers multi-sites)
# ❌ items est une coroutine, pas une liste !
items = page.locator(".card").all()
count = await items.count() if items else 0 # items est toujours truthy
for i in range(count if count else 0):
# ⚠️ items[i] échoue silencieusement ou lève TypeError
card = items[i]
✅ Correct
# ✅ await sur .all() donne une vraie liste
items = await page.locator(".card").all()
count = len(items)
for i in range(count):
card = items[i]
Pourquoi ça passe inaperçu
items.count()existe surcoroutine(méthode built-incr_frameetc.) mais ne fait pas ce que tu crois- L’erreur arrive plus tard :
TypeError: 'coroutine' object has no attribute '__getitem__'en essayantitems[i] - Parfois pas d’erreur du tout si
count=0— la boucle ne s’exécute pas et le bug dort
Règle absolue : page.locator(...).all() nécessite await. Toujours.
⚠️ Piège n°2 — wait_until vs timeout dans les scrapers multi-sites
| Stratégie | Résultat typique | Cas d’usage |
|---|---|---|
wait_until="networkidle", timeout=30000 | ❌ Timeout sur sites lourds (Centris, DuProprio) | Éviter sauf site rapide certifié |
wait_until="load", timeout=45000 | ✅ Passe presque toujours | Défaut recommandé pour scraper |
wait_until="domcontentloaded", timeout=30000 | ✅ Très rapide, mais JS pas toujours chargé | Pages statiques, API |
page.wait_for_selector(".card") après goto | ✅ Patiente juste pour un vrai élément | Scraper patient |
Pattern recommandé
# 1. Charge vite
await page.goto(url, wait_until="load", timeout=45000)
# 2. Attends le vrai contenu
try:
await page.wait_for_selector("[data-id], .card, article", timeout=10000)
except:
log("⚠️ Contenu non trouvé, résultats vides possibles")
# 3. Délai humain
await asyncio.sleep(random.uniform(2, 4))
# 4. Extrais
items = await page.locator("[data-id], .card").all()
wait_until="networkidle" attend que TOUTES les connexions réseau cessent — images tierces, analytics, pubs. Sur des sites comme Centris qui chargent des tuiles Mapbox et des trackers, ça ne se stabilise jamais. "load" suffit.
⚠️ Piège n°3 — Variable shadowing (for i in range + for i in compréhension)
# ❌ BUG
for i in range(min(count, 30)): # i = index externe
items = cards[i]
# ...
total = sum(len(r) for r in data.values()) # i = dernier index (ex: 29)
# for i in i dans la compréhension — i est à la fois itérateur et itérable
result = [i for s in data.values() for i in i if score(i) > 30]
# ⬆️ UnboundLocalError
Racine : for i in i — Python voit i comme variable de boucle ET itérable, conflit.
✅ Correct : noms distincts
for idx in range(min(count, 30)):
item = cards[idx]
# ...
result = [off for src in data.values() for off in src if score(off) > 30]
Règle : Jamais i dans boucle extérieure + compréhension imbriquée. Utiliser idx, item, off, card.
🔄 Exécution persistante sur machine distante (tmux)
Quand tu lances un scraper via SSH, il meurt à la déconnexion. Solution : tmux.
# Lancement en arrière-plan
ssh mx 'tmux new-session -d -s scrape-job "python3 script.py 2>&1 | tee /tmp/log"'
# Monitoring
ssh mx 'tmux capture-pane -t scrape-job -p -S -20'
# Vérification alive
ssh mx 'tmux ls | grep scrape'
ssh mx 'ps aux | grep python3.*script'
# Récupération après reconnexion
ssh mx 'tmux attach -t scrape-job'
Pour plusieurs scrapers en parallèle : une session tmux par scraper.
tmux new-session -d -s scrape-a "python3 a.py"
tmux new-session -d -s scrape-b "python3 b.py"
⚠️ input() blocks SSH sessions — Playwright scripts with input("Press Enter...")
calls block indefinitely on SSH sessions with no interactive terminal. The SSH
connection times out and the script is killed (exit 143/SIGTERM). Never use
input() in scripts destined for remote execution. Use asyncio.sleep()
for delays and rely on automatic flow instead.
--no-startup-windowis Playwright’s default launch mode. On remote display (DISPLAY=:0), Chrome opens but doesn’t create a visible window untilpage.goto()opens the first URL. On Getscreen.me remote sessions, the window may not appear at all.- Host key verification failures — after killing Playwright processes on a
remote machine, subsequent SSH may fail with host key verification errors.
Clear with
ssh-keygen -R <host>. SetStrictHostKeyChecking=accept-newfor one-shot connections.
Références
references/portail-quebec-connexion.md— Détails spécifiques au portail emplois Québec : anti-détection, structure React SPA, ALTCHA, formulaires cachés par bannière cookies, pièges headless.references/altcha-captcha-bypass.md— Workflow complet ALTCHA code-challenge : détection du widget, extraction d’image, human-in-the-loop, soumission via Camofox evaluate.references/mx-remote-postulation.md— Délégation au SSH mx (mode headless=False) pour portails bloquants.