Dernière mise à jour : 30 octobre 2026.

Surveillance des prix e-commerce avec Playwright et les proxies résidentiels

Le prix affiché d’un produit n’est pas toujours une valeur unique et universelle. Une boutique en ligne peut montrer des informations différentes selon la page produit, la devise, la destination de livraison, la locale du navigateur, les cookies ou le compte connecté.

La surveillance des prix ne se réduit donc pas à lire un nombre dans le HTML. Un moniteur utile doit aussi consigner les conditions dans lesquelles la page a été contrôlée — et distinguer une observation de prix réelle d’une requête ou d’une extraction échouée.

Dans ce tutoriel, nous construirons un petit moniteur Python avec Playwright. Il ouvre un catalogue de produits, capture les noms, les prix affichés et la disponibilité, puis enregistre des fichiers JSON et CSV horodatés. Un proxy résidentiel est facultatif : il modifie la route réseau utilisée par le navigateur, mais il ne définit pas automatiquement le pays de livraison, la devise ou l’état du compte d’une boutique.

Portée de cette démo : l’exemple utilise Books to Scrape , un site d’entraînement dont les prix sont attribués au hasard et n’ont aucune signification réelle. Il convient pour apprendre le flux de surveillance, pas pour démontrer des différences de prix régionales. Pour étudier de véritables écarts de marché, utilisez une boutique à laquelle vous êtes autorisé à accéder et qui fait réellement varier ses données affichées selon le marché.

Quand utiliser Playwright ?

Privilégiez d’abord une API produit officielle ou un flux de données lorsqu’il fournit les informations et la couverture dont vous avez besoin. C’est en général plus stable et plus facile à maintenir que l’automatisation de navigateur.

Playwright est utile lorsque :

  • les informations produit pertinentes sont rendues par JavaScript ;
  • vous devez inspecter le contenu qu’un navigateur reçoit réellement ;
  • la disponibilité ou le prix visibles d’une boutique ne sont pas exposés via un flux adapté.

Pour une page statique, un client HTTP classique peut être plus simple et plus rapide. Ce guide se concentre sur une exécution de navigateur et un contexte de test configuré. Ce n’est pas un planificateur multi-pays prêt à l’emploi.

Pour une planification plus large, consultez notre guide de surveillance des prix e-commerce . Si vous avez besoin d’aide pour configurer un proxy dans Playwright, voir Comment utiliser des proxies résidentiels avec Playwright .

Que doit contenir un enregistrement de surveillance des prix ?

Une observation de prix n’est utile que si vous pouvez comprendre comment elle a été collectée. Au minimum, enregistrez :

ChampPourquoi c’est important
Nom du produit et URLIdentifie l’article surveillé
Horodatage UTCÉtablit le moment de l’observation
Libellé de marchéDécrit la configuration de test ; ce n’est pas une preuve de localisation IP
IP de sortie du proxyAide à diagnostiquer la route réseau, si un proxy est utilisé
Locale et fuseau horaire du navigateurConsigne les préférences du navigateur susceptibles d’affecter la présentation
Pays de livraisonConsigne le paramètre de livraison réellement utilisé, le cas échéant
État du compteDistingue les observations déconnectées et connectées
Texte brut du prixPréserve exactement ce que la page a affiché
Montant analysé et code deviseFacilite la comparaison des observations valides
Texte et statut de disponibilitéPréserve le libellé de la page et une interprétation normalisée
Statut d’exécution et d’extractionSépare un contrôle échoué d’une observation valide

Ne traitez pas ces variables comme interchangeables. Un résultat de géolocalisation IP ne prouve pas quel marché de livraison la boutique a utilisé. Une locale de navigateur ne définit pas une adresse de livraison. Un symbole monétaire seul peut être ambigu — par exemple, $ peut désigner plusieurs devises.

Le script d’exemple enregistre le code devise configuré. Il ne détecte pas automatiquement la devise à partir de la page.

1. Créer le projet

Créez un dossier avec ces fichiers :

price-monitor/
├── .env
├── requirements.txt
└── monitor.py

Ajoutez les dépendances dans requirements.txt :

playwright>=1.40
python-dotenv>=1.0

Installez-les ainsi que le navigateur Chromium :

python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m playwright install chromium

Sous Linux, installez les dépendances système de Chromium si Playwright signale des bibliothèques partagées manquantes :

sudo .venv/bin/playwright install-deps chromium

2. Configurer le contexte de test

Créez un fichier .env :

# Optional proxy settings. Leave all three blank for a direct practice run.
PROXY_SERVER=http://gw.kindproxy.com:12000
PROXY_USERNAME=your_dashboard_username
PROXY_PASSWORD=your_dashboard_password

PRODUCT_URL=https://books.toscrape.com/
REGION_LABEL=UK
BROWSER_LOCALE=en-GB
BROWSER_TIMEZONE=Europe/London
CURRENCY_CODE=GBP
DELIVERY_COUNTRY=
ACCOUNT_STATE=signed_out

Utilisez le point de terminaison et les identifiants fournis dans votre compte. Copiez exactement le nom d’utilisateur et le mot de passe générés ; n’ajoutez pas manuellement de paramètres de session ou de localisation d’un autre fournisseur. Conservez .env hors du contrôle de version et ne publiez jamais d’identifiants proxy dans des captures d’écran ou des journaux.

Pour un exercice en connexion directe, laissez PROXY_SERVER, PROXY_USERNAME et PROXY_PASSWORD vides.

Les valeurs REGION_LABEL, DELIVERY_COUNTRY et ACCOUNT_STATE sont des métadonnées enregistrées avec le résultat. Cette démo ne vérifie pas que le proxy sort dans le pays nommé, n’entre pas d’adresse de livraison et ne se connecte pas à une boutique. N’ajoutez ces étapes que si vous disposez d’un compte de test autorisé et d’un flux de travail adapté, et consignez l’état réellement utilisé.

Playwright prend en charge la configuration du proxy au lancement du navigateur, y compris des champs séparés pour le nom d’utilisateur et le mot de passe des proxies authentifiés. Voir la documentation réseau Playwright Python .

3. Exécuter le moniteur

Enregistrez le code suivant sous monitor.py. Les sélecteurs sont écrits pour les cartes du catalogue Books to Scrape. Ils devront être adaptés pour un autre site.

import asyncio
import csv
import json
import os
import re
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from urllib.parse import urljoin

from dotenv import load_dotenv
from playwright.async_api import async_playwright

BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")

PRODUCT_URL = os.getenv("PRODUCT_URL", "https://books.toscrape.com/").strip()
REGION = os.getenv("REGION_LABEL", "not-set").strip()
LOCALE = os.getenv("BROWSER_LOCALE", "en-GB").strip()
BROWSER_TIMEZONE = os.getenv("BROWSER_TIMEZONE", "Europe/London").strip()
CURRENCY = os.getenv("CURRENCY_CODE", "GBP").strip().upper() or None
DELIVERY_COUNTRY = os.getenv("DELIVERY_COUNTRY", "").strip() or None
ACCOUNT_STATE = os.getenv("ACCOUNT_STATE", "signed_out").strip()
OUTPUT_DIR = BASE_DIR / "output"
JSON_PATH = OUTPUT_DIR / "snapshots.json"
CSV_PATH = OUTPUT_DIR / "snapshots.csv"

# These selectors are specific to Books to Scrape's catalogue cards.
CARD_SELECTOR = "article.product_pod"
TITLE_SELECTOR = "h3 a"
PRICE_SELECTOR = ".price_color"
AVAILABILITY_SELECTOR = ".availability"

CSV_FIELDS = [
    "record_type", "checked_at", "status", "error",
    "product_name", "product_url", "region", "locale",
    "browser_timezone", "delivery_country", "account_state",
    "proxy_exit_ip", "price_text", "price_amount",
    "currency", "availability_text", "availability_status",
    "comparison",
]


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def parse_price(raw_text: str) -> str | None:
    """Parse a decimal amount from the demo site's GBP-style price text."""
    match = re.search(r"\d[\d,]*(?:\.\d{1,2})?", raw_text)
    if not match:
        return None
    try:
        return format(Decimal(match.group(0).replace(",", "")), ".2f")
    except InvalidOperation:
        return None


def normalize_availability(raw_text: str) -> str:
    value = raw_text.casefold()
    if "in stock" in value:
        return "in_stock"
    if "out of stock" in value:
        return "out_of_stock"
    return "unknown"


def load_records() -> list[dict]:
    if not JSON_PATH.exists():
        return []
    try:
        data = json.loads(JSON_PATH.read_text(encoding="utf-8"))
        return data if isinstance(data, list) else []
    except (OSError, json.JSONDecodeError):
        return []


def save_records(records: list[dict]) -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    JSON_PATH.write_text(
        json.dumps(records, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    with CSV_PATH.open("w", newline="", encoding="utf-8-sig") as file:
        writer = csv.DictWriter(file, fieldnames=CSV_FIELDS, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(records)


def compare_with_previous(records: list[dict], current: dict) -> str:
    if current.get("status") != "success" or not current.get("price_amount"):
        return "not_compared"

    dimensions = (
        "product_url", "region", "locale", "browser_timezone",
        "delivery_country", "account_state", "currency",
    )
    previous = next(
        (
            item for item in reversed(records)
            if item.get("record_type") == "product"
            and item.get("status") == "success"
            and item.get("price_amount") is not None
            and all(item.get(key) == current.get(key) for key in dimensions)
        ),
        None,
    )
    if previous is None:
        return "baseline"

    old_price = Decimal(str(previous["price_amount"]))
    new_price = Decimal(str(current["price_amount"]))
    if new_price < old_price:
        return "price_decreased"
    if new_price > old_price:
        return "price_increased"
    return "no_change"


def base_record(record_type: str, status: str, **extra) -> dict:
    record = {
        "record_type": record_type,
        "checked_at": utc_now(),
        "status": status,
        "error": None,
        "product_name": None,
        "product_url": None,
        "region": REGION,
        "locale": LOCALE,
        "browser_timezone": BROWSER_TIMEZONE,
        "delivery_country": DELIVERY_COUNTRY,
        "account_state": ACCOUNT_STATE,
        "proxy_exit_ip": None,
        "price_text": None,
        "price_amount": None,
        "currency": CURRENCY,
        "availability_text": None,
        "availability_status": "unknown",
        "comparison": "not_compared",
    }
    record.update(extra)
    return record


async def main() -> None:
    if not PRODUCT_URL:
        raise ValueError("PRODUCT_URL must be set.")

    proxy_server = os.getenv("PROXY_SERVER", "").strip()
    proxy_username = os.getenv("PROXY_USERNAME", "").strip()
    proxy_password = os.getenv("PROXY_PASSWORD", "").strip()

    if bool(proxy_username) != bool(proxy_password):
        raise ValueError("Set both PROXY_USERNAME and PROXY_PASSWORD, or neither.")
    if (proxy_username or proxy_password) and not proxy_server:
        raise ValueError("Set PROXY_SERVER when proxy credentials are provided.")

    proxy = None
    if proxy_server:
        proxy = {"server": proxy_server}
        if proxy_username:
            proxy["username"] = proxy_username
            proxy["password"] = proxy_password

    records = load_records()
    run_record = base_record("run", "started")
    new_records = [run_record]

    async with async_playwright() as playwright:
        browser = await playwright.chromium.launch(proxy=proxy)
        try:
            context = await browser.new_context(
                locale=LOCALE,
                timezone_id=BROWSER_TIMEZONE,
            )
            page = await context.new_page()
            exit_ip = None

            if proxy:
                try:
                    ip_response = await page.goto(
                        "https://api.ipify.org?format=json",
                        wait_until="domcontentloaded",
                        timeout=30_000,
                    )
                    if ip_response and ip_response.ok:
                        ip_data = json.loads(await page.locator("body").inner_text())
                        exit_ip = ip_data.get("ip")
                except Exception as exc:
                    new_records.append(
                        base_record(
                            "diagnostic", "failed",
                            error=f"Proxy exit-IP check failed: {type(exc).__name__}: {exc}",
                        )
                    )

            try:
                response = await page.goto(
                    PRODUCT_URL,
                    wait_until="domcontentloaded",
                    timeout=45_000,
                )
                if response and response.status >= 400:
                    raise RuntimeError(f"Target returned HTTP {response.status}")

                body_text = (await page.locator("body").inner_text()).casefold()
                challenge_markers = (
                    "verify you are human",
                    "unusual traffic",
                    "captcha",
                    "access denied",
                    "automated requests",
                )
                if any(marker in body_text for marker in challenge_markers):
                    raise RuntimeError(
                        "Challenge or access-restriction page detected; "
                        "the monitor stopped without retrying or rotating IPs."
                    )

                cards = page.locator(CARD_SELECTOR)
                await cards.first.wait_for(state="visible", timeout=15_000)
                count = await cards.count()

                for index in range(count):
                    card = cards.nth(index)
                    try:
                        title_link = card.locator(TITLE_SELECTOR)
                        title = (await title_link.get_attribute("title")) or (
                            await title_link.inner_text()
                        ).strip()
                        href = await title_link.get_attribute("href")
                        product_url = urljoin(PRODUCT_URL, href or "")

                        price_text = (await card.locator(PRICE_SELECTOR).inner_text()).strip()
                        price_amount = parse_price(price_text)
                        availability_text = (
                            await card.locator(AVAILABILITY_SELECTOR).inner_text()
                        ).strip()

                        if price_amount is None:
                            item = base_record(
                                "product", "extraction_failed",
                                error="Price text was present but could not be parsed.",
                                product_name=title,
                                product_url=product_url,
                                proxy_exit_ip=exit_ip,
                                price_text=price_text,
                                availability_text=availability_text,
                                availability_status=normalize_availability(availability_text),
                            )
                        else:
                            item = base_record(
                                "product", "success",
                                product_name=title,
                                product_url=product_url,
                                proxy_exit_ip=exit_ip,
                                price_text=price_text,
                                price_amount=price_amount,
                                availability_text=availability_text,
                                availability_status=normalize_availability(availability_text),
                            )
                            item["comparison"] = compare_with_previous(records + new_records, item)

                        new_records.append(item)
                    except Exception as exc:
                        new_records.append(
                            base_record(
                                "product", "extraction_failed",
                                error=f"{type(exc).__name__}: {exc}",
                                proxy_exit_ip=exit_ip,
                            )
                        )

                run_record["status"] = "success"
                run_record["error"] = None
                run_record["products_seen"] = count

            except Exception as exc:
                run_record["status"] = "check_failed"
                run_record["error"] = f"{type(exc).__name__}: {exc}"
                run_record["products_seen"] = 0

            finally:
                await context.close()
        finally:
            await browser.close()

    records.extend(new_records)
    save_records(records)

    print(f"Run status: {run_record['status']}")
    print(f"Records written: {len(new_records) - 1} product/diagnostic records")
    print(f"JSON: {JSON_PATH}")
    print(f"CSV:  {CSV_PATH}")
    if run_record["error"]:
        print(f"Error: {run_record['error']}")


if __name__ == "__main__":
    asyncio.run(main())

Exécutez-le :

python monitor.py

L’exécution réussie de la démo s’est terminée avec 20 enregistrements produits et a enregistré les deux fichiers de sortie :

Moniteur de prix Playwright s’exécutant avec succès dans le terminal, avec 20 enregistrements produits écrits en JSON et CSV

Le script écrit :

output/
├── snapshots.json
└── snapshots.csv

Le fichier JSON conserve les enregistrements complets. Le fichier CSV est pratique pour une revue dans un tableur. Un enregistrement au niveau de l’exécution est sauvegardé même lorsque la navigation échoue ou qu’une page de défi est détectée ; une exécution infructueuse n’est pas convertie en observation à prix zéro.

Voici un exemple d’enregistrement produit extrait avec succès. Il inclut le prix affiché d’origine, le montant analysé, la devise configurée, la disponibilité et l’IP de sortie du proxy :

Instantané JSON montrant un produit extrait avec succès, son prix GBP, le statut de disponibilité et l’IP de sortie du proxy

4. Comprendre la sortie et les règles de comparaison

Le moniteur conserve le texte de prix d’origine et un montant numérique analysé. Par exemple, une page peut afficher £51.77 ; l’analyseur stocke 51.77, tandis que le code devise provient de CURRENCY_CODE=GBP.

Cet analyseur est volontairement étroit. Il convient au format numérique de type GBP de la démo, pas à chaque locale. Un analyseur de production doit être adapté au format numérique et aux métadonnées de devise de la boutique cible.

La comparaison n’utilise que les enregistrements réussis antérieurs ayant la même URL produit, le même libellé de marché, la même locale, le même fuseau horaire, le même paramètre de pays de livraison, le même état de compte et la même devise configurée. Un prix manquant ou non analysable est marqué extraction_failed ; il n’est jamais traité comme zéro.

Le libellé de marché n’est encore qu’un libellé. Avant de comparer de vrais marchés, confirmez indépendamment le lieu de sortie réel du proxy et les paramètres de livraison et de compte de la boutique.

Dans la démo, la première exécution a créé la baseline pour 20 produits. Une deuxième exécution a ajouté 20 autres enregistrements, et les prix inchangés ont été correctement étiquetés no_change. Le champ delivery_country est resté vide faute de pays de livraison configuré ; c’est attendu pour le site d’entraînement Books to Scrape.

Feuille CSV montrant deux exécutions de surveillance, avec des libellés baseline au premier passage et no_change au second

5. Dépannage

SymptômeÀ vérifier
Chromium ne démarre pasInstaller le navigateur et les dépendances système Linux
L’authentification du proxy échoueVérifier le point de terminaison et copier exactement le nom d’utilisateur/mot de passe générés
Le contrôle IP fonctionne mais la page cible échoueLa destination peut avoir des règles d’accès ou restrictions distinctes
Aucune carte de catalogue trouvéeConfirmer le chargement de la page et mettre à jour les sélecteurs CSS pour ce site
Le texte du prix est présent mais le montant est videAdapter parse_price() au format numérique du site
La disponibilité est unknownExaminer le libellé de disponibilité du site et mettre à jour le normaliseur
Une page CAPTCHA, unusual-traffic ou access-denied apparaîtArrêter le contrôle, enregistrer l’échec et examiner les règles du site ainsi que votre cadence de requêtes

Ne répondez pas à un défi en changeant répétitivement d’IP ou en cherchant à automatiser le défi. Un proxy résidentiel ne garantit pas l’accès ni n’empêche les pages CAPTCHA.

6. API, automatisation de navigateur ou proxy ?

Ces outils résolvent des problèmes différents :

  • API ou flux officiel : meilleur point de départ lorsqu’il fournit des données produit structurées et autorisées.
  • Playwright : utile lorsque les informations pertinentes sont rendues dans un navigateur ou que vous devez inspecter la page visible de la boutique.
  • Proxy résidentiel : modifie la route réseau. Il peut aider à tester la réponse d’un site aux requêtes depuis différentes sorties réseau, mais il ne définit pas à lui seul le pays de livraison, la devise, les cookies ou l’état du compte.

Pour des catalogues plus volumineux, ajoutez planification, limites de débit, politiques de nouvelle tentative pour les erreurs réseau transitoires, alertes et rétention des données seulement une fois le flux à exécution unique fiable. Respectez les conditions du site cible, les indications robots le cas échéant, et le droit applicable. Préférez l’accès officiel aux données lorsqu’il est disponible.

Si vous évaluez des options de proxy pour un flux de surveillance autorisé, consultez les offres de proxies résidentiels KindProxy .

FAQ

Playwright peut-il surveiller les prix e-commerce ?

Oui. Playwright peut lire les champs visibles de prix et de disponibilité sur les pages auxquelles vous êtes autorisé à accéder. Les sélecteurs doivent correspondre au balisage du site cible, et chaque observation doit être validée avant d’être comparée aux données antérieures.

Un proxy résidentiel affiche-t-il le prix d’un autre pays ?

Pas automatiquement. Une boutique peut prendre en compte le lieu de sortie réseau, l’adresse de livraison, la devise, la locale, les cookies et l’état du compte. Un proxy ne modifie que la route réseau.

Un proxy empêche-t-il les pages CAPTCHA ?

Non. Un site peut toujours renvoyer une page CAPTCHA, un défi, un blocage ou une page incomplète. Enregistrez le contrôle échoué et examinez les règles d’accès plutôt que de faire tourner les IP de façon répétée.

Que faire si le prix ne peut pas être analysé ?

Conservez le texte brut, marquez l’enregistrement comme échec d’extraction et laissez le montant numérique vide. Ne transformez jamais une valeur manquante en zéro et ne la comptez pas comme une baisse de prix.

Réflexions finales

Une surveillance des prix fiable dépend de la qualité de l’observation, pas seulement du code de scraping. Consignez les conditions de test, préservez les valeurs brutes de la page, validez chaque extraction et ne comparez que des enregistrements réussis comparables.

L’exemple Books to Scrape démontre le flux de travail. Il n’établit pas un comportement tarifaire régional réel. Pour la production, adaptez les sélecteurs et les règles d’analyse à une boutique autorisée, vérifiez indépendamment les conditions de marché et traitez les restrictions d’accès comme des contrôles échoués — pas comme une raison de les contourner.