Aller au contenu
Hermès Skills
← Retour au catalogue

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

ApprocheTokens overheadActivation
Playwright MCP permanent~7K à chaque sessionToujours chargé
Outils navigateur natifs Hermes0 tokensAppel direct (browser_navigate, etc.)
Playwright CLI via skill0 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 :

OutilUsage
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=False avec 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 uniquebrowser.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_cdp peut 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étectionPatchRésultat
navigator.webdriverObject.definePropertyundefined
chrome.runtimeSimule l’absencePasse
navigator.pluginsAjoute plugins réalistes5+ plugins
WebGL vendor/rendererMême que vrai ChromePasse
navigator.languagesDéfinit [fr-CA, fr, en]Configurable
navigator.hardwareConcurrencyCache le nombre réel4 (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_sync attend les résultats, stealth_async les lance sans attente. stealth_sync est 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 HeadlessChrome dans 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 :

  1. Désactiver AutomationControlled : --disable-blink-features=AutomationControlled
  2. Spoof navigator.webdriver : via add_init_script (voir snippet ci-dessus)
  3. User-Agent réaliste : Chrome 130+ sur Linux
  4. Locale et fuseau horaire : fr-CA, America/Montreal
  5. Viewport standard : 1280x900

Stratégies qui ont échoué sur le portail Québec :

  • Headless mode → la page React redirige vers /deconnexion sans 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/connexion redirige vers /deconnexion (le route React)

Workflow exploratoire :

  1. page.goto(url, wait_until="load") + asyncio.sleep(8) pour laisser React + cookie banner charger
  2. Chercher les éléments par label plutôt que par type (React ne les rend pas toujours comme input[type=email])
  3. Utiliser page.evaluate() pour inspecter le DOM complet plutôt que page.query_selector()
  4. 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 :

  1. Naviguer, laisser React charger
  2. Vérifier le state ALTCHA via evaluate
  3. 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
  4. 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 type peut ne pas déclencher onChange
  • userId Camofox sur ce VPS : bf — requis en query param ?userId=bf sur 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 :

  1. SSH vers mx (voir tailscale-remote-access skill)
  2. Copier le script Playwright sur mx via SCP
  3. Exécuter avec headless=False — Chrome s’ouvre sur le bureau de l’utilisateur
  4. L’utilisateur voit et peut interagir (CAPTCHA, ALTCHA)
  5. 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 sur coroutine (méthode built-in cr_frame etc.) mais ne fait pas ce que tu crois
  • L’erreur arrive plus tard : TypeError: 'coroutine' object has no attribute '__getitem__' en essayant items[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égieRésultat typiqueCas 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 toujoursDé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émentScraper 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-window is Playwright’s default launch mode. On remote display (DISPLAY=:0), Chrome opens but doesn’t create a visible window until page.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>. Set StrictHostKeyChecking=accept-new for 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.