# Scraper PAP.fr avec Python : le tutoriel complet (curl_cffi + OpenScraper)

*Page HTML : https://openscraper.ai/fr/blog/scraper-pap-fr-python · Index pour agents : https://openscraper.ai/llms.txt*

> Un script Python qui récupère toutes les annonces PAP.fr d'une région (vente et location) en passant Cloudflare une seule fois, et qui ne télécharge ensuite que les nouvelles annonces.

Published 2026-10-11 · 15 min de lecture

## Ce que vous allez construire (en plus de votre empire immobilier)

À la fin de ce tutoriel, vous aurez un script Python qui récupère toutes les annonces PAP.fr d'une région (vente et location) dans un fichier exploitable, et qui ne télécharge à chaque lancement que les nouvelles annonces.

Votre script collectera les annonces automatiquement, sans vous demander de justifier trois garants, un CDI et un revenu mensuel équivalent au PIB du Luxembourg.

Le script s'appuie sur deux briques :

- **[curl_cffi](https://github.com/lexiforest/curl_cffi)** : un client HTTP Python qui imite l'empreinte TLS d'un vrai Chrome. C'est lui qui télécharge les pages.
- **Le [playground antibot-cookies d'OpenScraper](https://openscraper.ai/playground/antibot-cookies)** : il passe la protection Cloudflare de PAP une fois, à travers votre proxy, et renvoie des cookies que curl_cffi réutilise ensuite sur des milliers de pages.

Pour chaque annonce, vous obtenez ces champs :

| Groupe | Champs |
|---|---|
| Contexte | date du scraping, site, région, département, code postal, ville |
| Annonce | id, référence, URL, titre, catégorie (vente / location), type de bien, date de publication, « publiée depuis » |
| Bien | prix, surface, prix au m², classe énergie (DPE), GES, description, image principale |
| Annonceur | particulier ou professionnel |

L'exemple porte sur les appartements, immeubles et locaux commerciaux d'Île-de-France. Changer de zone ou de type de bien demande une ligne de configuration.

Ce tutoriel suppose que vous savez lancer un script Python. Aucune connaissance de Cloudflare n'est nécessaire.

## Comprendre PAP avant de coder

PAP envoie ses données dans le HTML brut : pas besoin de navigateur pour les lire, seulement de passer Cloudflare. Cinq constats, relevés en analysant le site, déterminent toute l'architecture du script.

1. **Cloudflare protège le site.** Une requête Python classique reçoit souvent un 403 et la page « Just a moment… ». Il faut un cookie `cf_clearance` valide, lié à l'adresse IP qui l'a obtenu.
2. **Deux types de pages.** Une page de liste (`/annonce/vente-appartements-ile-de-france-g471`) est une page de résultats de recherche : elle affiche une dizaine d'annonces en résumé, chacune avec un lien. Une fiche (`/annonces/appartement-le-raincy-93340-r463201778`) est la page complète d'une seule annonce, celle qui s'ouvre quand on clique sur un résultat : c'est là que se trouvent la description, les photos, la surface, le DPE et la date de publication. Dans son URL, le nombre après `-r` est l'identifiant de l'annonce.
3. **Les fiches embarquent un bloc JSON-LD.** Une balise `<script type="application/ld+json">` donne le titre, le prix, la surface, le type de bien, l'adresse, les photos et la description. Le DPE, le GES, la référence et la date de publication se lisent dans le HTML autour.
4. **Le « lazy load » est une pagination cachée.** Quand vous scrollez, le JavaScript de PAP télécharge simplement l'URL de la page suivante, écrite dans un lien masqué `#pagination-next`. Il suffit de demander `…-g471-2`, `…-g471-3`, etc.
5. **Une recherche s'arrête à 25 pages.** Au-delà, PAP renvoie la page 1 sans erreur, soit environ 200 annonces au maximum. Or l'Île-de-France compte plus de 3 600 appartements à vendre. La parade : découper la recherche par tranches de prix, avec des URLs du type `…-g471-entre-150000-et-250000-euros`.

Chaque recherche porte un compteur `annonces_total` dans le HTML. Le script le lit pour savoir s'il doit découper une tranche de prix en deux.

Parcours d'une annonce : 6 étapes, 1 filtre

Les étapes 1 à 3 trouvent les annonces, les étapes 4 à 6 décident lesquelles entrent en base ; les quatre sections suivantes les détaillent une par une.

## Prérequis et installation

Il vous faut Python 3.9 ou plus, trois bibliothèques, une clé OpenScraper et un proxy résidentiel français « sticky ». Ce tutoriel utilise les proxys de [Decodo](https://decodo.com).

Installez les bibliothèques :

```bash
pip install curl_cffi selectolax requests
```

- `curl_cffi` télécharge les pages en se faisant passer pour Chrome au niveau TLS.
- `selectolax` lit le HTML avec des sélecteurs CSS ; il remplit le même rôle que BeautifulSoup, en beaucoup plus rapide.
- `requests` sert uniquement à appeler l'API OpenScraper.

Récupérez ensuite une clé API sur [openscraper.ai](https://openscraper.ai) et testez le module sur le [playground antibot-cookies](https://openscraper.ai/playground/antibot-cookies) avec une URL PAP.

Enfin, le proxy. Le cookie `cf_clearance` est lié à l'IP qui l'a obtenu : la résolution Cloudflare et toutes les requêtes suivantes doivent sortir par la même IP. Prenez donc un proxy résidentiel français en mode **sticky** (session fixe). Avec un proxy qui change d'IP à chaque requête, chaque page redéclenche Cloudflare et consomme une résolution.

Nous utilisons ici les proxys résidentiels de [Decodo](https://decodo.com), avec le point d'entrée français `fr.decodo.com`. Chaque port de 40001 à 40020 y est une session sticky distincte, donc une IP fixe différente. Le script démarre sur le port 40001 et passe au port suivant quand une requête échoue (erreur réseau, 429 ou autre erreur HTTP) ; après 40020, il revient à 40001. Changer de port change d'IP : si Cloudflare présente alors un challenge, le script le résout à travers ce nouveau port.

Ne mettez ni la clé ni le proxy en dur dans le code (c’est mal !) : un script copié, partagé ou poussé sur GitHub emporterait vos identifiants avec lui. Passez-les par **variables d'environnement**, des valeurs que vous donnez au terminal et que le script lit au démarrage, sans qu'elles soient écrites dans le fichier.

Sous Linux ou macOS, tapez ces deux lignes dans le terminal, avec vos propres valeurs, avant de lancer le script :

```bash
export OPENSCRAPER_API_KEY="votre_cle"
export PROXY="http://utilisateur:motdepasse@fr.decodo.com:40001"
```

Sous Windows, dans PowerShell, la syntaxe est différente :

```powershell
$env:OPENSCRAPER_API_KEY = "votre_cle"
$env:PROXY = "http://utilisateur:motdepasse@fr.decodo.com:40001"
```

Ces variables ne durent que le temps de la fenêtre de terminal : si vous la fermez, il faut les redéfinir. Pour ne pas les retaper à chaque fois sous Linux ou macOS, ajoutez les deux lignes `export` à la fin du fichier `~/.bashrc` (ou `~/.zshrc` sur macOS), puis ouvrez un nouveau terminal.

Côté Python, le script les récupère avec `os.environ` :

```python
import os

KEY = os.environ.get("OPENSCRAPER_API_KEY", "")
PROXY = os.environ.get("PROXY", "")
```

Si l'une des deux manque, le script s'arrête tout de suite avec un message qui le dit.

## Étape 1 : passer Cloudflare une fois, puis rejouer avec curl_cffi

On résout Cloudflare une seule fois avec OpenScraper, puis curl_cffi réutilise les cookies obtenus sur toutes les pages suivantes. C'est la différence entre payer une résolution par page et en payer une pour des milliers de pages.

Le playground antibot-cookies correspond au module `antibot_cookies_matrix` de l'API. Vous lui donnez une URL et votre proxy ; il ouvre la page, passe le challenge et renvoie les cookies, le user-agent et le profil de navigateur à imiter :

```python
import os, requests

API = "https://api.openscraper.ai"
KEY = os.environ["OPENSCRAPER_API_KEY"]
PROXY = os.environ["PROXY"]

def solve_session(url):
    r = requests.post(f"{API}/runs",
        headers={"Authorization": f"Bearer {KEY}"},
        json={"module": "antibot_cookies_matrix",
              "params": {"url": url, "proxy": PROXY, "challenge": "cloudflare"},
              "sync": True, "sync_timeout_seconds": 300},
        timeout=330)
    r.raise_for_status()
    return r.json()["result"][0]   # {cookies, user_agent, impersonate, ...}
```

On rejoue ensuite les pages avec curl_cffi, avec le même proxy, le même user-agent et les mêmes cookies :

```python
from curl_cffi import requests as cc

sess = solve_session("https://www.pap.fr/annonce/vente-appartements-ile-de-france-g471")
client = cc.Session(impersonate=sess.get("impersonate") or "chrome")
client.headers["User-Agent"] = sess["user_agent"]
client.cookies.update(sess["cookies"])

r = client.get("https://www.pap.fr/annonce/vente-appartements-ile-de-france-g471-2",
               proxies={"http": PROXY, "https": PROXY}, timeout=40)
print(r.status_code, len(r.text))
```

Le script complet ajoute trois protections pour économiser les résolutions :

- **Résolution paresseuse.** Il tente d'abord la page sans cookies : PAP laisse souvent passer les premières requêtes.
- **Détection stricte.** Seul un vrai challenge déclenche une résolution : un 403/503 avec l'en-tête `cf-mitigated: challenge` ou la page « Just a moment ». Un 429, un timeout ou une erreur proxy donnent lieu à une pause puis un nouvel essai sur le port suivant du proxy.
- **Session conservée.** Les cookies sont enregistrés dans `.pap_session.json` et réutilisés au lancement suivant, tant qu'ils ont moins de 6 heures et que le proxy n'a pas changé.

## Étape 2 : paginer et contourner la limite des 25 pages

Chaque recherche est découpée en tranches de prix d'au plus 170 annonces, puis chaque tranche est paginée jusqu'à sa dernière page. On reste ainsi sous le plafond de 25 pages de PAP.

Le découpage est automatique. Le script lit `annonces_total` sur la page 1 d'une tranche ; s'il dépasse 170, il coupe la fourchette en deux et recommence :

```python
def plan_slices(f, base, ranges):
    out, stack = [], list(reversed(ranges))     # ex. [(1, 120000), (120000, 180000), ...]
    while stack:
        lo, hi = stack.pop()
        url = f"{base}-entre-{lo}-et-{hi}-euros"
        n = total_count(f.get(url))
        if n > 170 and hi - lo > 1:
            mid = int((lo * hi) ** 0.5)          # moyenne géométrique : les prix ne sont pas uniformes
            stack += [(mid, hi), (lo, mid)]
        elif n:
            out.append(url)
    return out
```

Pour les geeks des sciences

Où couper une tranche trop grosse ? Prenons celle de 100 000 à 400 000 €. Le réflexe est de couper au milieu, à 250 000 €. Mais les annonces ne sont pas réparties régulièrement : il y en a beaucoup plus dans les petits prix que dans les gros. La moitié basse (100 000 à 250 000 €) resterait donc bien plus chargée que la moitié haute, et il faudrait la recouper encore.

Le script coupe donc plus bas, à la **[moyenne géométrique](https://fr.wikipedia.org/wiki/Moyenne_g%C3%A9om%C3%A9trique)** des deux bornes : la racine carrée de leur produit, soit √(100 000 × 400 000) = 200 000 €. Vu autrement, le prix double de chaque côté : de 100 000 à 200 000 €, puis de 200 000 à 400 000 €. Les deux moitiés contiennent alors un nombre d'annonces plus proche, ce qui évite des requêtes de découpage inutiles.

Les bornes de départ sont réalistes (120 000, 180 000, 230 000 €…) et le plan obtenu est gardé 24 h dans `pap.plan.json`.

La pagination imite le lazy load de PAP. On demande `-2`, `-3`… et on s'arrête dans trois cas : le lien `#pagination-next` disparaît, PAP renvoie la page 1, ou la page 25 est atteinte.

```python
for page in range(1, 26):
    html = f.get(url if page == 1 else f"{url}-{page}")
    if page > 1 and active_page(html) == 1:   # PAP est revenu à la page 1 : fin
        break
    urls = listing_urls(html)                 # liens /annonces/...-r<id>
    ...
    if not has_next(html):
        break
```

Deux pièges à connaître :

- **Les URLs multi-types ignorent certains types.** En location, `locations-appartement-local-commercial-…` ne renvoie que les appartements. Le script lance donc une recherche par type de bien (appartements, immeubles, locaux commerciaux).
- **Des encarts publicitaires** (programmes neufs, partenaires) se glissent dans les listes. Le script ne garde que les liens `/annonces/…-r`.

## Étape 3 : extraire les données d'une fiche

L'étape 2 a fourni une liste d'URLs d'annonces, relevées dans les pages de liste. Chaque URL mène à une fiche : la page de détail d'une annonce, que le script télécharge avec curl_cffi comme les autres pages.

La plupart des champs viennent du bloc JSON-LD de cette fiche, un bloc de données structurées que PAP place dans le HTML et dont le format est stable. Le reste se lit avec quelques sélecteurs CSS : par exemple, `.energy-indice li.active` désigne la case allumée de l'étiquette énergie, donc la classe DPE du bien.

| Champ | Où le trouver |
|---|---|
| Titre, description, photos | JSON-LD : `name`, `description`, `image[0]` |
| Prix | JSON-LD : `offers.price` |
| Surface, type de bien | JSON-LD : `additionalProperty` (« Surface », « Type de bien ») |
| Code postal, ville | JSON-LD : `address.postalCode`, `address.addressLocality` |
| Vente ou location | JSON-LD : `sku`, qui commence par `vente-` ou `location-` |
| Particulier ou pro | JSON-LD : `offers.seller` (`@type: Person` = particulier) |
| Référence, date de publication | `p.item-date` : « Réf. : G32/1778 / Publié le 02 octobre 2026 » |
| Classe énergie, GES | `.energy-indice li.active`, `.ges-indice li.active` |
| Prix au m² | `ul.item-tags li` contenant « le m² » |
| Département | deux premiers chiffres du code postal |

La lecture du JSON-LD tient en quelques lignes :

```python
import json
from selectolax.parser import HTMLParser

tree = HTMLParser(html)
for s in tree.css('script[type="application/ld+json"]'):
    data = json.loads(s.text())
    if data.get("@type") == "Product":
        props = {p["name"]: p["value"] for p in data.get("additionalProperty", [])}
        print(data["name"], data["offers"]["price"], props.get("Surface"), props.get("Type de bien"))

dpe = tree.css_first(".energy-indice li.active")
print("DPE :", dpe.text(strip=True) if dpe else "")
```

Le champ « publiée depuis » est calculé à partir de la date de publication, par exemple « 3 jours ».

## Étape 4 : ne scraper que les nouvelles annonces

Une URL déjà vue n'est jamais rouverte : après le premier passage, un lancement ne télécharge plus que les pages de liste et les fiches des nouvelles annonces.

La base est un fichier `pap.jsonl` : une annonce par ligne, au format JSON. Ce format a trois avantages sur un CSV :

- **Il résiste aux plantages.** Chaque annonce est ajoutée et écrite sur disque aussitôt ; si le script s'arrête, rien n'est perdu et la reprise est exacte.
- **Il supporte les écritures parallèles.** Plusieurs threads ajoutent des lignes sans conflit.
- **Il garde le texte intact.** Les descriptions contiennent des retours à la ligne, des `;` et des guillemets, que le JSON encode sans ambiguïté.

Au démarrage, le script charge les URLs déjà connues :

```python
done = set()
for line in open("pap.jsonl", encoding="utf-8"):
    done.add(json.loads(line)["url"])              # URLs déjà en base
done.update(open("pap.seen.txt").read().split())   # URLs ouvertes puis écartées
```

Puis deux règles s'appliquent à chaque annonce rencontrée :

1. **URL déjà connue** : la fiche n'est pas téléchargée.
2. **Nouvelle URL** : la fiche est téléchargée, puis l'annonce est ajoutée à la base et au CSV du jour.

Toute fiche ouverte est notée dans `pap.seen.txt`, même écartée (mauvais type, hors zone). Sans ce fichier, les annonces écartées seraient retéléchargées à chaque lancement. Une fiche qui n'a pas pu se charger n'y figure pas : elle sera retentée la fois suivante.

## Le script complet

Copiez ce fichier sous le nom `pap_scraper.py`. Il est prêt à lancer une fois les deux variables d'environnement définies ; la configuration se trouve en haut, dans `GEOS` et `SEARCH_TEMPLATES`.

```python
#!/usr/bin/env python3
"""
Scraper PAP.fr — appartements, immeubles, locaux commerciaux (vente + location)

  1. Cloudflare est résolu UNE fois par OpenScraper (module antibot_cookies_matrix,
     https://openscraper.ai/playground/antibot-cookies), à travers VOTRE proxy sticky
     (ici Decodo : un port = une session sticky, le script change de port en cas d'échec).
  2. Toutes les pages sont rejouées en local avec curl_cffi, avec les cookies obtenus.
  3. Seules les nouvelles annonces sont scrapées : une URL déjà vue n'est jamais rouverte.

Installation
  pip install curl_cffi selectolax requests
  export OPENSCRAPER_API_KEY="votre_cle"
  export PROXY="http://utilisateur:motdepasse@fr.decodo.com:40001"   # proxy résidentiel FR, sticky

Utilisation
  python pap_scraper.py                     # toutes les zones de GEOS
  python pap_scraper.py --max-pages 1       # test rapide : 1 page par tranche de prix
  python pap_scraper.py --geo idf --only Vente
  python pap_scraper.py --find-geo "bouches-du-rhone"   # trouver l'id PAP d'une zone

Fichiers produits (dans le dossier du script)
  pap.jsonl                 la base : une annonce par ligne, tous lancements confondus
  pap_nouvelles_<date>.csv  les annonces ajoutées par CE lancement (ouvrable dans Excel)
  pap.seen.txt              toutes les URLs déjà ouvertes (jamais re-scrapées)
  pap.plan.json             plan des tranches de prix (cache 24 h)
  .pap_session.json         session Cloudflare (cache 6 h)
"""
import argparse, csv, html, json, os, re, sys, threading, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, date

import requests
from curl_cffi import requests as cc
from selectolax.parser import HTMLParser

API = "https://api.openscraper.ai"
KEY = os.environ.get("OPENSCRAPER_API_KEY", "")
PROXY = os.environ.get("PROXY", "")
PROXY_PORTS = range(40001, 40021)   # Decodo : un port = une session sticky (une IP fixe)
BASE = "https://www.pap.fr"

# ───────────────────────────── Configuration ─────────────────────────────
# Zones : (code, slug PAP avec son id géo, région, départements acceptés).
# Pour une autre zone : python pap_scraper.py --find-geo "nom" → id, puis ajoutez une ligne.
GEOS = [
    ("idf", "ile-de-france-g471",       "Île-de-France",              {"75", "77", "78", "91", "92", "93", "94", "95"}),
    # ("13",  "bouches-du-rhone-13-g376", "Provence-Alpes-Côte d'Azur", {"13"}),
    # ("06",  "alpes-maritimes-06-g369",  "Provence-Alpes-Côte d'Azur", {"06"}),
    # ("83",  "var-83-g447",              "Provence-Alpes-Côte d'Azur", {"83"}),
    # ("67",  "bas-rhin-67-g431",         "Grand Est",                  {"67"}),
    # ("57",  "moselle-57-g421",          "Grand Est",                  {"57"}),
    # ("60",  "oise-60-g424",             "Hauts-de-France",            {"60"}),
]
# Une recherche par type de bien (les URLs multi-types de PAP en ignorent certains).
SEARCH_TEMPLATES = [
    ("Vente",    "https://www.pap.fr/annonce/vente-appartements-{geo}",         "vente"),
    ("Vente",    "https://www.pap.fr/annonce/immeubles-{geo}",                  "vente"),
    ("Vente",    "https://www.pap.fr/annonce/vente-local-commercial-{geo}",     "vente"),
    ("Location", "https://www.pap.fr/annonce/locations-appartement-{geo}",      "location"),
    ("Location", "https://www.pap.fr/annonce/locations-local-commercial-{geo}", "location"),
]
TYPES_OK = ("appartement", "local commercial", "immeuble", "bâtiment", "batiment")

MAX_SLICE = 170      # annonces max par tranche (PAP s'arrête à 25 pages, ~8 annonces par page)
MAX_PAGES = 25
START_POINTS = {     # bornes de départ des tranches de prix (€)
    "vente": [1, 120_000, 180_000, 230_000, 280_000, 330_000, 390_000, 460_000, 550_000,
              680_000, 850_000, 1_100_000, 1_600_000, 100_000_000],
    "location": [1, 600, 800, 950, 1100, 1300, 1600, 2000, 3000, 1_000_000],
}

DEPTS = {"75": "Paris", "77": "Seine-et-Marne", "78": "Yvelines", "91": "Essonne",
         "92": "Hauts-de-Seine", "93": "Seine-Saint-Denis", "94": "Val-de-Marne", "95": "Val-d'Oise",
         "13": "Bouches-du-Rhône", "06": "Alpes-Maritimes", "83": "Var", "67": "Bas-Rhin",
         "57": "Moselle", "60": "Oise"}
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,
        "juillet": 7, "août": 8, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11,
        "décembre": 12, "decembre": 12}
FIELDS = ["date_scraping", "site", "region", "departement", "code_postal", "ville", "prix",
          "categorie", "titre", "url", "image", "type_bien", "surface", "classe_energie", "ges",
          "pro_ou_particulier", "publiee_depuis", "date_publication", "id", "reference", "prix_m2",
          "description"]

# ───────────────────────────── OpenScraper ─────────────────────────────
def _api(payload, timeout=330):
    r = requests.post(f"{API}/runs", headers={"Authorization": f"Bearer {KEY}"},
                      json=payload, timeout=timeout)
    r.raise_for_status()
    return r.json()

def solve_session(url, challenge, proxy):
    """Playground antibot-cookies : passe Cloudflare via VOTRE proxy → cookies rejouables."""
    data = _api({"module": "antibot_cookies_matrix",
                 "params": {"url": url, "proxy": proxy, "challenge": challenge},
                 "sync": True, "sync_timeout_seconds": 300})
    res = (data.get("result") or [{}])[0]
    if not res.get("cookies"):
        raise RuntimeError(f"Session non obtenue : {str(data)[:300]}")
    return res

def unlocker_fetch(url):
    """Dernier recours : une page via l'unlocker OpenScraper (sans votre proxy)."""
    data = _api({"module": "unlocker_matrix", "params": {"urls": [url]},
                 "sync": True, "sync_timeout_seconds": 180}, timeout=200)
    res = (data.get("result") or [{}])[0]
    if not res.get("ok") or not res.get("result_url"):
        return None
    r = requests.get(res["result_url"], headers={"Authorization": f"Bearer {KEY}"}, timeout=60)
    return r.text if r.ok else None

# ───────────────────────────── Téléchargement ─────────────────────────────
class Fetcher:
    """Rejoue les pages avec curl_cffi et ne résout Cloudflare que sur un vrai challenge."""

    def __init__(self, challenge, session_file, max_solves=15, rps=2.0,
                 min_solve_interval=90, session_ttl=6 * 3600):
        self.challenge, self.session_file, self.max_solves = challenge, session_file, max_solves
        self.min_solve_interval, self.session_ttl = min_solve_interval, session_ttl
        self.lock, self.ck_lock, self.rate_lock = threading.Lock(), threading.Lock(), threading.Lock()
        self.proxy_lock, self.proxy_port = threading.Lock(), PROXY_PORTS.start
        self.interval, self.next_slot = 1.0 / max(rps, 0.1), 0.0
        self.gen, self.local = 0, threading.local()
        self.solves, self.requests, self.challenges, self.last_solve = 0, 0, 0, 0.0
        self.sess = self._load_session() or {"cookies": {}, "user_agent": None, "impersonate": "chrome"}

    def _proxy_key(self):
        return re.sub(r"//[^@]*@", "//", PROXY)          # le proxy, sans identifiants

    def _load_session(self):
        try:
            d = json.load(open(self.session_file, encoding="utf-8"))
            if d.get("proxy") == self._proxy_key() and time.time() - d.get("ts", 0) < self.session_ttl:
                print(f"♻️  Session Cloudflare réutilisée ({int((time.time() - d['ts']) / 60)} min)")
                return d["sess"]
        except Exception:
            pass
        return None

    def _save_session(self):
        try:
            with self.ck_lock:
                json.dump({"proxy": self._proxy_key(), "ts": time.time(), "sess": self.sess},
                          open(self.session_file, "w", encoding="utf-8"))
        except Exception:
            pass

    def _proxy(self):
        return re.sub(r":\d+$", f":{self.proxy_port}", PROXY)   # même compte, port courant

    def _rotate_proxy(self):
        with self.proxy_lock:                        # port suivant = autre session sticky
            self.proxy_port = ((self.proxy_port - PROXY_PORTS.start + 1) % len(PROXY_PORTS)
                               + PROXY_PORTS.start)
            print(f"  ↻ proxy : port {self.proxy_port}", flush=True)

    def _solve(self, url):
        if self.solves >= self.max_solves:
            raise RuntimeError(f"budget de {self.max_solves} résolutions atteint")
        wait = self.min_solve_interval - (time.time() - self.last_solve)
        if self.last_solve and wait > 0:
            # challenge revenu trop vite : c'est le débit ou le proxy, pas les cookies
            print(f"  ⏳ challenge rapproché → pause {int(wait)} s et débit réduit", flush=True)
            self.interval = min(self.interval * 2, 5.0)
            time.sleep(wait)
        print(f"🔐 Résolution Cloudflare n°{self.solves + 1} via OpenScraper…", flush=True)
        res = solve_session(url, self.challenge, self._proxy())
        ck = res.get("cookies") or {}
        if isinstance(ck, list):
            ck = {x["name"]: x["value"] for x in ck}
        self.sess = {"cookies": ck, "user_agent": res.get("user_agent"),
                     "impersonate": res.get("impersonate") or "chrome"}
        self.solves += 1
        self.last_solve = time.time()
        self.gen += 1
        self._save_session()
        print(f"✅ Session prête ({self.requests} requêtes jusqu'ici)", flush=True)

    def _client(self):
        c = getattr(self.local, "c", None)
        if c is None or getattr(self.local, "gen", -1) != self.gen:
            c = cc.Session(impersonate=self.sess.get("impersonate") or "chrome")
            if self.sess.get("user_agent"):
                c.headers["User-Agent"] = self.sess["user_agent"]
            c.headers["Accept-Language"] = "fr-FR,fr;q=0.9"
            c.cookies.update(self.sess.get("cookies") or {})
            self.local.c, self.local.gen = c, self.gen
        return c

    def _throttle(self):
        with self.rate_lock:
            now = time.time()
            slot = max(now, self.next_slot)
            self.next_slot = slot + self.interval
        if slot > now:
            time.sleep(slot - now)

    def _keep_cookies(self, c):
        """PAP renouvelle cf_clearance / __cf_bm : la version fraîche sert à tous les threads."""
        try:
            fresh = dict(c.cookies.items())
        except Exception:
            return
        if fresh and fresh != self.sess.get("cookies"):
            with self.ck_lock:
                self.sess["cookies"] = {**(self.sess.get("cookies") or {}), **fresh}
            if self.requests % 50 == 0:
                self._save_session()

    @staticmethod
    def _is_challenge(r):
        if r.status_code not in (403, 503):
            return False
        if (r.headers.get("cf-mitigated") or "").lower() == "challenge":
            return True
        head = r.text[:6000]
        return "Just a moment" in head or "cf-chl" in head or "challenge-form" in head

    def get(self, url, tries=4):
        for attempt in range(tries):
            gen = self.gen
            self._throttle()
            try:
                c = self._client()
                proxy = self._proxy()
                r = c.get(url, proxies={"http": proxy, "https": proxy}, timeout=40)
                self.requests += 1
            except Exception as e:                   # réseau / proxy : pas de résolution
                print(f"  ⚠ réseau ({type(e).__name__}) {url} → nouvel essai", flush=True)
                self._rotate_proxy()
                time.sleep(3 * (attempt + 1))
                continue
            if r.status_code == 404:
                return None
            if r.status_code == 429:                 # trop de requêtes : pause, pas de résolution
                self.interval = min(self.interval * 1.5, 5.0)
                self._rotate_proxy()
                time.sleep(int(r.headers.get("Retry-After") or 0) or 15 * (attempt + 1))
                continue
            if self._is_challenge(r):
                self.challenges += 1
                with self.lock:                      # un seul thread résout, les autres attendent
                    if gen == self.gen:
                        try:
                            self._solve(url)
                        except Exception as e:
                            print(f"  ✗ résolution impossible : {e}", flush=True)
                            break
                continue
            if r.status_code >= 400:                 # 403 sans challenge, 5xx : pause + nouvel essai
                self._rotate_proxy()
                time.sleep(5 * (attempt + 1))
                continue
            self._keep_cookies(c)
            return r.text
        print(f"  ↪ repli unlocker : {url}", flush=True)
        try:
            return unlocker_fetch(url)
        except Exception as e:
            print(f"  ✗ unlocker KO : {e}", flush=True)
            return None

    def stats(self):
        per = self.requests // max(self.solves, 1)
        return (f"{self.requests} requêtes · {self.solves} résolution(s) Cloudflare"
                f" · {self.challenges} challenge(s) · ~{per} requêtes par résolution")

def check_sticky_proxy():
    """Une IP qui change = cookie Cloudflare invalide à chaque requête = résolutions en boucle."""
    ips = []
    for _ in range(3):
        try:
            ips.append(cc.get("https://api.ipify.org", proxies={"http": PROXY, "https": PROXY},
                              timeout=20).text.strip())
        except Exception:
            pass
    if len(set(ips)) > 1:
        print(f"⚠️  Le proxy change d'IP à chaque requête ({', '.join(ips)}).\n"
              f"   Utilisez un proxy STICKY (session fixe), sinon chaque page relance Cloudflare.")
        return False
    if ips:
        print(f"🌐 IP de sortie stable : {ips[0]}")
    return True

# ───────────────────────────── Listes et pagination ─────────────────────────────
def clean(s):
    return re.sub(r"\s+", " ", html.unescape(s or "").replace("\xa0", " ")).strip()

def listing_urls(page_html):
    """Liens des annonces PAP d'une page de liste (ignore les encarts partenaires)."""
    out = []
    for a in HTMLParser(page_html).css("div.search-list-item-alt a.item-title"):
        href = a.attributes.get("href") or ""
        if "/annonces/" in href and re.search(r"-r\d+$", href):
            u = href if href.startswith("http") else BASE + href
            if u not in out:
                out.append(u)
    return out

def total_count(page_html):
    m = re.search(r'"annonces_total":(\d+)', page_html or "")
    return int(m.group(1)) if m else 0

def active_page(page_html):
    n = HTMLParser(page_html).css_first("#pagination span.active")
    try:
        return int(n.text(strip=True)) if n else 1
    except ValueError:
        return 1

def has_next(page_html):
    return HTMLParser(page_html).css_first("#pagination-next") is not None

def plan_slices(f, base, kind, cache):
    """Découpe une recherche en tranches de prix qui tiennent dans les 25 pages de PAP."""
    if base in cache:
        print(f"  plan de tranches en cache ({len(cache[base])} tranches)")
        return [(u, None, n) for u, n in cache[base]]
    h = f.get(base)
    n = total_count(h)
    print(f"  total annoncé par PAP : {n}")
    if n <= MAX_SLICE:
        out = [(base, h, n)] if n else []
    else:
        pts = START_POINTS[kind]
        out, stack = [], list(reversed(list(zip(pts[:-1], pts[1:]))))
        while stack:
            lo, hi = stack.pop()
            url = f"{base}-entre-{lo}-et-{hi}-euros"
            h = f.get(url)
            n = total_count(h) if h else 0
            if n > MAX_SLICE and hi - lo > 1:
                mid = min(max(int((lo * hi) ** 0.5), lo + 1), hi - 1)   # moyenne géométrique
                stack += [(mid, hi), (lo, mid)]       # bornes partagées : dédoublonné ensuite
            elif n:
                if n > MAX_SLICE:
                    print(f"  ⚠ tranche {lo}-{hi} € : {n} annonces au même prix, partiel")
                out.append((url, h, n))
    cache[base] = [(u, n) for u, _, n in out]
    return out

# ───────────────────────────── Fiche annonce ─────────────────────────────
def since(d):
    if not d:
        return ""
    n = (date.today() - d).days
    return "aujourd'hui" if n <= 0 else ("1 jour" if n == 1 else f"{n} jours")

def parse_detail(url, page_html, categorie_hint, region_hint):
    t = HTMLParser(page_html)
    ld = {}
    for s in t.css('script[type="application/ld+json"]'):
        try:
            j = json.loads(s.text())
        except Exception:
            continue
        if j.get("@type") == "Product":
            ld = j

    props = {p.get("name"): p.get("value") for p in ld.get("additionalProperty", [])}
    offer = ld.get("offers") or {}
    addr = ld.get("address") or {}
    seller = offer.get("seller") or {}

    titre = clean(ld.get("name"))
    h1 = t.css_first("h1.item-title")
    if not titre and h1:
        price_node = h1.css_first(".item-price")
        titre = clean(h1.text().replace(price_node.text() if price_node else "", ""))

    cp = addr.get("postalCode") or ""
    if not cp:
        m = re.search(r"\((\d{5})\)", titre)
        cp = m.group(1) if m else ""
    dep_code = cp[:2]
    departement = f"{DEPTS.get(dep_code, '')} ({dep_code})" if dep_code else ""

    ville = clean(addr.get("addressLocality"))
    if not ville:
        m = re.search(r"\s([A-ZÉÈ][^()]+?)\s*\(\d{5}\)", titre)
        ville = m.group(1) if m else ""

    date_node = t.css_first("p.item-date")
    date_txt = clean(date_node.text()) if date_node else ""
    ref = re.search(r"Réf\.\s*:\s*(.+?)\s*(?:/\s*Publi|$)", date_txt)
    dp = None
    m = re.search(r"(\d{1,2})\s+([a-zéûô]+)\s+(\d{4})", date_txt.lower())
    if m and m.group(2) in MOIS:
        dp = date(int(m.group(3)), MOIS[m.group(2)], int(m.group(1)))

    def active(sel):
        n = t.css_first(f"{sel} li.active")
        return clean(n.text()) if n else ""

    prix_m2 = ""
    for li in t.css("ul.item-tags li"):
        tx = clean(li.text())
        if "le m²" in tx:
            prix_m2 = tx.replace(" le m²", "")

    desc_node = t.css_first("div.item-description div.margin-bottom-30")
    description = (clean(desc_node.text(separator=" ")) if desc_node
                   else clean(ld.get("description", "")).replace("**", ""))

    images = ld.get("image") or []
    image = images[0] if isinstance(images, list) and images else (images or "")
    if not image:
        og = t.css_first('meta[property="og:image"]')
        image = og.attributes.get("content", "") if og else ""

    sku = ld.get("sku", "")
    categorie = "Location" if sku.startswith("location") else ("Vente" if sku.startswith("vente") else categorie_hint)
    sname = (seller.get("name") or "").lower()
    pro = "Particulier" if (seller.get("@type") == "Person" or "particulier" in sname or not seller) else "Professionnel"

    surface = props.get("Surface")
    if surface:
        surface = str(surface).rstrip("0").rstrip(".").replace(".", ",") + " m²"

    m = re.search(r"-r(\d+)$", url)
    return {
        "date_scraping": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "site": "PAP.fr",
        "region": region_hint,
        "departement": departement,
        "dep_code": dep_code,
        "code_postal": cp,
        "ville": ville,
        "prix": offer.get("price", ""),
        "categorie": categorie,
        "titre": titre,
        "url": url,
        "image": image,
        "type_bien": props.get("Type de bien", ""),
        "surface": surface or "",
        "classe_energie": active(".energy-indice"),
        "ges": active(".ges-indice"),
        "pro_ou_particulier": pro,
        "publiee_depuis": since(dp),
        "date_publication": dp.isoformat() if dp else "",
        "id": m.group(1) if m else "",
        "reference": clean(ref.group(1)) if ref else "",
        "prix_m2": prix_m2,
        "description": description,
    }

# ───────────────────────────── Programme principal ─────────────────────────────
def main():
    ap = argparse.ArgumentParser(description="Scraper PAP.fr (curl_cffi + OpenScraper)")
    ap.add_argument("--out", default="pap", help="préfixe des fichiers produits")
    ap.add_argument("--geo", nargs="+", help="codes de zones à scraper (défaut : toutes)")
    ap.add_argument("--only", choices=["Vente", "Location"])
    ap.add_argument("--max-pages", type=int, default=0, help="pages max par tranche (0 = toutes)")
    ap.add_argument("--workers", type=int, default=4, help="fiches téléchargées en parallèle")
    ap.add_argument("--rps", type=float, default=2.0, help="requêtes par seconde max vers PAP")
    ap.add_argument("--max-solves", type=int, default=15, help="budget de résolutions Cloudflare")
    ap.add_argument("--challenge", default="cloudflare")
    ap.add_argument("--find-geo", metavar="NOM", help="affiche l'id PAP d'une zone puis quitte")
    args = ap.parse_args()

    if not KEY or not PROXY:
        sys.exit("❌ Définissez OPENSCRAPER_API_KEY et PROXY (proxy résidentiel FR sticky).")

    here = os.path.dirname(os.path.abspath(__file__))
    out_prefix = os.path.join(here, args.out)
    f = Fetcher(args.challenge, os.path.join(here, ".pap_session.json"),
                max_solves=args.max_solves, rps=args.rps)

    if args.find_geo:
        q = requests.utils.quote(args.find_geo)
        body = f.get(f"{BASE}/json/ac-geo?q={q}") or "[]"
        for z in json.loads(body)[:10]:
            print(f"g{z['id']:<7} {z['name']}")
        return

    if not check_sticky_proxy():
        sys.exit("❌ Arrêt pour ne pas consommer de crédits : passez un proxy sticky.")

    jsonl, seen_file = f"{out_prefix}.jsonl", f"{out_prefix}.seen.txt"
    plan_file = f"{out_prefix}.plan.json"

    done = set()                                     # URLs connues
    if os.path.exists(jsonl):
        for line in open(jsonl, encoding="utf-8"):
            try:
                r = json.loads(line)
            except Exception:
                continue
            done.add(r.get("url", ""))
    n_base = len(done)
    if os.path.exists(seen_file):
        done.update(l.strip() for l in open(seen_file, encoding="utf-8") if l.strip())
    done.discard("")
    print(f"↺ Base : {n_base} annonces · {len(done)} URLs déjà vues")

    plan_cache = {}
    try:
        d = json.load(open(plan_file, encoding="utf-8"))
        if time.time() - d.get("ts", 0) < 24 * 3600:
            plan_cache = d["plan"]
    except Exception:
        pass

    out = open(jsonl, "a", encoding="utf-8")
    seen_out = open(seen_file, "a", encoding="utf-8")
    wlock = threading.Lock()
    new_rows = []

    def work(u, categorie, region):
        dh = f.get(u)
        return parse_detail(u, dh, categorie, region) if dh else None

    for geo, slug, region, depts in GEOS:
        if args.geo and geo not in args.geo:
            continue
        for categorie, tpl, kind in SEARCH_TEMPLATES:
            if args.only and args.only != categorie:
                continue
            base = tpl.format(geo=slug)
            print(f"\n━━ {categorie} · {base}")
            slices = plan_slices(f, base, kind, plan_cache)
            json.dump({"ts": time.time(), "plan": plan_cache}, open(plan_file, "w", encoding="utf-8"))
            seen = set()
            for slice_url, first_html, expected in slices:
                print(f"  ▸ {slice_url}  (~{expected} annonces)")
                for page in range(1, MAX_PAGES + 1):
                    if args.max_pages and page > args.max_pages:
                        break
                    url = slice_url if page == 1 else f"{slice_url}-{page}"
                    h = first_html if page == 1 and first_html else f.get(url)
                    if not h:
                        print(f"    page {page} : échec de chargement, page suivante")
                        continue
                    if page > 1 and active_page(h) == 1:          # PAP revient à la page 1 = fin
                        break
                    new = [u for u in listing_urls(h) if u not in seen]
                    seen.update(new)
                    todo = [u for u in new if u not in done]
                    print(f"    page {page} : {len(new)} annonces, {len(todo)} nouvelles", flush=True)
                    with ThreadPoolExecutor(args.workers) as ex:
                        futures = [ex.submit(work, u, categorie, region) for u in todo]
                        for fut in as_completed(futures):
                            try:
                                row = fut.result()
                            except Exception as e:
                                print(f"    ⚠ parse : {e}")
                                continue
                            if not row:
                                continue                          # échec : retentée la prochaine fois
                            with wlock:
                                seen_out.write(row["url"] + "\n")  # ne sera plus jamais re-scrapée
                                seen_out.flush()
                                done.add(row["url"])
                                tb = row["type_bien"].lower()
                                if tb and not any(k in tb for k in TYPES_OK):
                                    continue
                                if row["dep_code"] and row["dep_code"] not in depts:
                                    continue                      # annonce « à proximité » hors zone
                                out.write(json.dumps(row, ensure_ascii=False) + "\n")
                                out.flush()
                                new_rows.append(row)
                    if not has_next(h):
                        break
                    time.sleep(0.5)
            print(f"  ✓ {len(seen)} annonces vues dans cette recherche")

    out.close(); seen_out.close()
    print("\n📊 " + f.stats())

    if new_rows:
        new_csv = f"{out_prefix}_nouvelles_{datetime.now():%Y-%m-%d_%H%M}.csv"
        with open(new_csv, "w", newline="", encoding="utf-8-sig") as fh:
            w = csv.DictWriter(fh, fieldnames=FIELDS, delimiter=";", extrasaction="ignore")
            w.writeheader()
            w.writerows(new_rows)
        print(f"🎉 {len(new_rows)} nouvelles annonces → {new_csv}")
    else:
        print("🎉 Aucune nouvelle annonce.")
    print(f"   Base : {jsonl}")

if __name__ == "__main__":
    main()
```

## Lancer le script

Commencez par un test d'une page par tranche, puis lancez le scraping complet. Les lancements suivants ne récupèrent que les nouveautés.

```bash
python pap_scraper.py --max-pages 1      # test rapide
python pap_scraper.py                    # tout
python pap_scraper.py --geo idf --only Location
```

| Option | Effet | Défaut |
|---|---|---|
| `--geo` | zones à scraper, par leur code dans `GEOS` | toutes |
| `--only` | `Vente` ou `Location` uniquement | les deux |
| `--max-pages` | pages maximum par tranche de prix | 0 (toutes) |
| `--workers` | fiches téléchargées en parallèle | 4 |
| `--rps` | requêtes par seconde maximum vers PAP | 2 |
| `--max-solves` | résolutions Cloudflare maximum par lancement | 15 |
| `--find-geo` | affiche l'identifiant PAP d'une zone, puis quitte | — |

Le script écrit ses fichiers dans son propre dossier :

| Fichier | Contenu |
|---|---|
| `pap.jsonl` | la base, une annonce par ligne, tous lancements confondus |
| `pap_nouvelles_<date>.csv` | les annonces ajoutées par ce lancement, à ouvrir dans Excel (séparateur `;`) |
| `pap.seen.txt` | toutes les URLs déjà ouvertes |
| `pap.plan.json` | le plan des tranches de prix, gardé 24 h |
| `.pap_session.json` | la session Cloudflare, gardée 6 h |

### Ajouter un département

Chaque zone PAP a un identifiant géographique (`g471` pour l'Île-de-France). Pour le trouver :

```bash
python pap_scraper.py --find-geo "bouches-du-rhone"
# g376     Bouches-du-Rhône - 13
```

Ajoutez ensuite une ligne dans `GEOS`, sur le modèle `slug-du-departement-<numéro>-g<id>`. Six exemples vérifiés sont déjà en commentaire dans le script (13, 06, 83, 67, 57, 60) :

```python
("13", "bouches-du-rhone-13-g376", "Provence-Alpes-Côte d'Azur", {"13"}),
```

Le dernier élément liste les départements acceptés. Quand une zone a peu d'annonces, PAP complète avec des annonces « à proximité » ; ce filtre les écarte.

### Tous les identifiants géographiques

Pour vous éviter la recherche, voici les identifiants des 13 régions et des 101 départements, relevés sur PAP le 11 octobre 2026 avec OpenScraper.

| Région | Identifiant | Région | Identifiant |
|---|---|---|---|
| Auvergne-Rhône-Alpes | g64311 | Île-de-France | g471 |
| Bourgogne-Franche-Comté | g53197 | Normandie | g53044 |
| Bretagne | g465 | Nouvelle-Aquitaine | g64310 |
| Centre-Val de Loire | g466 | Occitanie | g64309 |
| Corse | g468 | Pays de la Loire | g477 |
| Grand Est | g53190 | Provence-Alpes-Côte d'Azur | g480 |
| Hauts-de-France | g53191 |  |  |

| Département | Identifiant | Département | Identifiant | Département | Identifiant |
|---|---|---|---|---|---|
| 01 Ain | g364 | 34 Hérault | g398 | 68 Haut-Rhin | g432 |
| 02 Aisne | g365 | 35 Ille-et-Vilaine | g399 | 69 Rhône | g433 |
| 03 Allier | g366 | 36 Indre | g400 | 70 Haute-Saône | g434 |
| 04 Alpes-de-Haute-Provence | g367 | 37 Indre-et-Loire | g401 | 71 Saône-et-Loire | g435 |
| 05 Hautes-Alpes | g368 | 38 Isère | g402 | 72 Sarthe | g436 |
| 06 Alpes-Maritimes | g369 | 39 Jura | g403 | 73 Savoie | g437 |
| 07 Ardèche | g370 | 40 Landes | g404 | 74 Haute-Savoie | g438 |
| 08 Ardennes | g371 | 41 Loir-et-Cher | g405 | 75 Paris | g439 |
| 09 Ariège | g372 | 42 Loire | g406 | 76 Seine-Maritime | g440 |
| 10 Aube | g373 | 43 Haute-Loire | g407 | 77 Seine-et-Marne | g441 |
| 11 Aude | g374 | 44 Loire-Atlantique | g408 | 78 Yvelines | g442 |
| 12 Aveyron | g375 | 45 Loiret | g409 | 79 Deux-Sèvres | g443 |
| 13 Bouches-du-Rhône | g376 | 46 Lot | g410 | 80 Somme | g444 |
| 14 Calvados | g377 | 47 Lot-et-Garonne | g411 | 81 Tarn | g445 |
| 15 Cantal | g378 | 48 Lozère | g412 | 82 Tarn-et-Garonne | g446 |
| 16 Charente | g379 | 49 Maine-et-Loire | g413 | 83 Var | g447 |
| 17 Charente-Maritime | g380 | 50 Manche | g414 | 84 Vaucluse | g448 |
| 18 Cher | g381 | 51 Marne | g415 | 85 Vendée | g449 |
| 19 Corrèze | g382 | 52 Haute-Marne | g416 | 86 Vienne | g450 |
| 2A Corse-du-Sud | g383 | 53 Mayenne | g417 | 87 Haute-Vienne | g451 |
| 2B Haute-Corse | g384 | 54 Meurthe-et-Moselle | g418 | 88 Vosges | g452 |
| 21 Côte-d'Or | g385 | 55 Meuse | g419 | 89 Yonne | g453 |
| 22 Côtes-d'Armor | g386 | 56 Morbihan | g420 | 90 Territoire de Belfort | g454 |
| 23 Creuse | g387 | 57 Moselle | g421 | 91 Essonne | g455 |
| 24 Dordogne | g388 | 58 Nièvre | g422 | 92 Hauts-de-Seine | g456 |
| 25 Doubs | g389 | 59 Nord | g423 | 93 Seine-Saint-Denis | g457 |
| 26 Drôme | g390 | 60 Oise | g424 | 94 Val-de-Marne | g458 |
| 27 Eure | g391 | 61 Orne | g425 | 95 Val-d'Oise | g459 |
| 28 Eure-et-Loir | g392 | 62 Pas-de-Calais | g426 | 971 Guadeloupe | g91 |
| 29 Finistère | g393 | 63 Puy-de-Dôme | g427 | 972 Martinique | g93 |
| 30 Gard | g394 | 64 Pyrénées-Atlantiques | g428 | 973 Guyane | g94 |
| 31 Haute-Garonne | g395 | 65 Hautes-Pyrénées | g429 | 974 La Réunion | g95 |
| 32 Gers | g396 | 66 Pyrénées-Orientales | g430 | 976 Mayotte | g97 |
| 33 Gironde | g397 | 67 Bas-Rhin | g431 |  |  |

Seul l'identifiant compte : PAP affiche la même recherche quel que soit le texte placé avant `-g385` dans l'URL. Vous pouvez donc écrire le nom de la zone comme vous voulez dans `GEOS`, tant que l'identifiant est le bon.

### Le lancer chaque jour

Comme seules les nouvelles annonces sont traitées, un lancement quotidien est rapide. Pour l'automatiser, utilisez **cron** : le planificateur de tâches intégré à Linux et macOS, qui lance une commande à heure fixe sans que vous ayez à y penser.

Cron tourne en permanence sur la machine, ce qui le rend facile à utiliser sur un serveur : un petit serveur allumé jour et nuit lance le script chaque matin, même quand votre ordinateur est éteint.

Tapez `crontab -e` pour ouvrir la liste de vos tâches planifiées, puis ajoutez une ligne. Les cinq premiers champs donnent le moment du lancement (minute, heure, jour du mois, mois, jour de la semaine) ; ici, tous les jours à 7 h :

```bash
0 7 * * * cd /chemin/vers/le/dossier && OPENSCRAPER_API_KEY=... PROXY=... python3 pap_scraper.py >> pap.log 2>&1
```

## Économiser les crédits et dépanner

Un lancement bien réglé se contente d'une à trois résolutions Cloudflare pour des milliers de pages. Le bilan affiché en fin de lancement le dit, par exemple : `4812 requêtes · 2 résolution(s) Cloudflare · ~2406 requêtes par résolution`.

**Le script résout Cloudflare très souvent.** Le plus souvent, l'IP de sortie change. Premier cas : le proxy n'est pas en mode sticky. Le script vérifie l'IP de sortie trois fois au démarrage et s'arrête si elle varie ; utilisez bien les ports sticky de Decodo (40001 à 40020). Second cas : beaucoup de lignes `↻ proxy : port …` défilent, signe que les requêtes échouent et que le script change de port, donc d'IP. Baissez alors `--rps` et `--workers`.

**Des lignes « ⏳ challenge rapproché » apparaissent.** Le challenge revient moins de 90 secondes après une résolution : c'est le débit, pas les cookies. Le script ralentit tout seul ; vous pouvez aussi baisser `--rps` à 1 et `--workers` à 2.

**Le message « budget de 15 résolutions atteint » s'affiche.** Le script s'arrête pour ne pas consommer vos crédits. Corrigez le proxy ou le débit, puis relancez : il reprend où il en était.

**Le script ne trouve que 150 à 200 annonces.** C'est la limite des 25 pages de PAP. Vérifiez que les lignes `▸ …-entre-X-et-Y-euros` apparaissent : elles montrent que le découpage par prix fonctionne. Supprimez `pap.plan.json` pour forcer un nouveau découpage.

**Une tranche affiche « au même prix, partiel ».** Rappel : PAP ne montre que 25 pages par recherche, soit environ 200 annonces. Pour tout voir, le script découpe donc chaque recherche en tranches de prix, et recoupe en deux toute tranche qui dépasse 170 annonces.

Ce message apparaît quand une tranche ne peut plus être coupée : elle ne couvre plus qu'un seul prix, à l'euro près, et plus de 170 annonces affichent ce prix-là. Le script lit alors les 25 premières pages de cette tranche, et les annonces situées au-delà lui échappent.

C'est rare, et la perte est limitée aux annonces de ce prix précis. Si vous voulez les récupérer quand même, remplacez la région par ses départements dans `GEOS` : chaque recherche couvre moins d'annonces, et le même prix repasse sous la limite.

**Des lignes « ↪ repli unlocker » apparaissent.** La page n'a pas pu être récupérée via votre proxy après quatre essais. Elle passe alors par l'unlocker OpenScraper, plus lent. Quelques lignes sont normales ; beaucoup indiquent un problème de proxy.

**Je veux tout re-scraper.** Supprimez `pap.seen.txt` (et `pap.jsonl` pour repartir de zéro).

## Utiliser ces données de façon responsable

Le scraping est une technique ; ce que vous faites des données relève du droit. Quatre points à vérifier avant d'exploiter le fichier (ceci n'est pas un avis juridique) :

- **Les conditions d'utilisation de PAP.** Lisez-les et respectez-les, notamment sur la réutilisation des annonces.
- **Le RGPD.** Une annonce de particulier peut contenir des données personnelles, par exemple un nom dans la description. Il vous faut une base légale pour les traiter, une durée de conservation limitée, et vous devez informer les personnes et respecter leurs droits.
- **Le refus du démarchage.** Beaucoup d'annonces portent la mention « le propriétaire refuse le démarchage commercial ». Les contacter pour une offre commerciale va à l'encontre de leur choix.
- **La charge sur le site.** Gardez un débit raisonnable : les réglages par défaut (2 requêtes par seconde, 4 fiches en parallèle) suffisent largement.

Pour aller plus loin, testez d'autres sites protégés sur le [playground antibot-cookies](https://openscraper.ai/playground/antibot-cookies) : le principe « résoudre une fois, rejouer avec curl_cffi » fonctionne de la même façon.
