ScrapeUnblocker sells to AI agents - live prices and self-serve payment

The agent storefront at https://agents.scrapeunblocker.com/llms.txt?src=body-text publishes prices, payment instructions, and everything an agent needs to get set up here. Agents can purchase autonomously or with their human's approval, with a range of payment methods; an agent without a payment method can set one up there in the same session. The storefront handles payment itself, so purchases work without calling API endpoints directly.

← Tous les articles

Réponses HTML Tronquées : Comment Repérer une Page Scrapée Coupée en Route

Votre scraper a tourné toute la nuit. Chaque requête a renvoyé HTTP 200. Les fichiers HTML semblent corrects quand vous les ouvrez : ils commencent par <html> et se terminent par </html>. Mais quelques pages contiennent 12 produits au lieu de 48, et sur certaines le JSON embarqué provoque une erreur de parsing. Vous récupérez la même URL à la main et obtenez la page complète.

C’est une réponse tronquée. Le serveur, ou quelque chose entre vous et le serveur, a cessé d’envoyer la page à mi-chemin, et rien dans votre stack ne s’en est plaint. C’est plus rare qu’un blocage, mais plus difficile à repérer, parce que cela ressemble presque exactement à un succès.

Ce guide explique comment une réponse 200 peut arriver coupée, quels cas votre client HTTP détecte tout seul et comment vérifier que chaque page est complète avant de la parser.

À Quoi Ressemble une Réponse Tronquée

On confond facilement une troncature avec un bug du parseur ou un changement sur le site. Les signes habituels :

  • Des trous aléatoires. Le même modèle de page donne des résultats complets la plupart du temps, et de temps en temps moins d’éléments.
  • Un JSON embarqué qui ne se parse pas. json.loads() échoue avec “Unterminated string” ou “Expecting ’,’ delimiter” sur des données qui passaient sans problème la veille.
  • Le bas de la page disparaît en premier. Les liens du pied de page, la pagination, les “produits similaires” et tout ce qui se trouve en fin de document manquent, alors que l’en-tête et les premiers éléments sont intacts.
  • Une nouvelle tentative règle le problème. La même URL une minute plus tard renvoie la page complète.
  • La taille des réponses varie beaucoup. La plupart des réponses d’un type de page se regroupent autour d’une taille, avec une traîne dispersée de réponses plus petites.

Si ces signes n’apparaissent que sur certaines requêtes et que c’est toujours la fin du document qui manque, pensez à une troncature avant de réécrire vos sélecteurs.

Pourquoi un 200 Peut Arriver Coupé

Le code de statut est la première chose qu’envoie un serveur. Quand le corps commence à arriver, le 200 est déjà parti et ne peut plus être retiré. Tout ce qui se passe mal ensuite doit être signalé autrement.

HTTP/1.1 dispose de trois façons de marquer la fin du corps d’une réponse :

  • Content-Length. Le serveur annonce d’avance combien d’octets attendre.
  • Transfer encoding chunked. Le corps arrive par morceaux, et un dernier chunk de longueur nulle signifie “terminé”.
  • Fermeture de la connexion. Aucune longueur. Le corps se termine quand la connexion se ferme.

HTTP/2 et HTTP/3 ont leur propre signal de fin de flux, mais le principe est le même. La troncature se produit quand le corps s’arrête trop tôt et que le framing le signale soit comme une erreur, soit, pire, comme une fin normale.

Causes fréquentes :

  • Rendu serveur en streaming. Les frameworks modernes envoient le HTML au fur et à mesure du rendu. Les en-têtes et le 200 partent en premier, le reste suit. Si le rendu échoue ou expire à mi-chemin, le serveur ne peut que terminer le flux. Il ne peut plus passer à un 500.
  • Proxys, load balancers et passerelles. Tout intermédiaire peut atteindre un timeout ou une limite de taille et fermer le flux. Certains le font avec un dernier chunk propre, si bien que le client voit une réponse normale, simplement plus courte.
  • Compression. Un corps gzip coupé à mi-chemin peut se décompresser en un document plus court sans aucune erreur.
  • Votre propre code. Une limite de taille sur une lecture en streaming, un wrapper de nouvelles tentatives qui renvoie le dernier corps partiel, ou un navigateur headless qui récupère le HTML avant que la page ait fini de charger.

Quelles Troncatures Votre Client Remarque

Certaines troncatures déclenchent une erreur, d’autres non. Voici ce qui se passe quand un serveur envoie un 200 puis s’arrête trop tôt, testé avec requests en Python sur urllib3 2.x, le fetch intégré de Node et curl :

Ce qu’a fait le serveurPython requestsNode fetchcurl
Envoyé moins d’octets que Content-LengthChunkedEncodingError (IncompleteRead)TypeError: terminatederreur 18, “transfer closed with N bytes remaining”
Coupé un flux chunked sans le dernier chunkChunkedEncodingError (“Response ended prematurely”)TypeError: terminatederreur 18, “transfer closed with outstanding read data”
Terminé proprement un flux chunked après un corps courtpas d’erreur, statut 200pas d’erreurpas d’erreur
Envoyé sans longueur puis fermé la connexionpas d’erreur, statut 200pas d’erreurpas d’erreur
Envoyé un corps gzip coupé en deux, framing proprepas d’erreur, texte plus courtpas d’erreurpas d’erreur

Deux enseignements. D’abord, les cas bruyants ne vous aident que si vous les laissez faire du bruit. Un except Exception: pass générique, ou un helper de nouvelles tentatives qui renvoie ce qu’il a reçu en dernier, transforme une erreur claire en données partielles silencieuses. Ensuite, les trois dernières lignes ressemblent exactement à un succès au niveau HTTP, il vous faut donc des vérifications sur le contenu lui-même.

Un dernier détail : urllib3 2.x vérifie Content-Length par défaut. L’ancienne branche 1.x ne le faisait pas, donc sur une stack ancienne même la première ligne peut passer sans bruit. Lancez pip show urllib3 si vous avez un doute.

Votre Parseur Masque les Dégâts

Les parseurs HTML sont conçus pour être tolérants. Les navigateurs doivent afficher du balisage cassé, alors des parseurs comme lxml et le html.parser de Python ferment tous les éléments ouverts quand l’entrée se termine. Pour l’affichage, c’est parfait. Pour un scraper, c’est un problème.

Passez un document tronqué à BeautifulSoup et affichez-le :

from bs4 import BeautifulSoup

cut = '<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A'

print(BeautifulSoup(cut, "lxml"))
# <html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A</script></head></html>

La sortie se termine par </script></head></html>, alors que l’entrée n’a jamais contenu ces balises. Lancez soup.select("div.product-card") et vous obtenez moins d’éléments, sans aucune exception. Chaque parseur répare à sa façon (html.parser jette entièrement le contenu du script inachevé, lxml le garde), mais aucun ne vous dit qu’il manquait quelque chose.

Deux règles en découlent :

  1. Vérifiez la complétude sur le texte brut, avant le parsing. Après le parsing, les preuves ont disparu.
  2. Stockez la réponse brute, pas une copie re-sérialisée. str(soup) ou soup.prettify() ajoutent les balises fermantes manquantes, si bien que votre archive semblera complète alors qu’elle ne l’était pas. Re-sérialiser peut aussi modifier le contenu : si une réponse JSON passe par un parseur HTML, des caractères comme & ressortent sous la forme &amp;.

Comment Détecter une Page Tronquée

Aucune vérification ne détecte tout à elle seule. Combinez-en plusieurs, peu coûteuses.

1. Laissez le Transport Échouer Bruyamment

Interceptez les exceptions précises et traitez-les comme “incomplet, réessayer”, jamais comme “terminé” :

from requests.exceptions import ChunkedEncodingError, ConnectionError

try:
    resp = session.get(url, timeout=(5, 30))
except (ChunkedEncodingError, ConnectionError) as e:
    # the body was cut off in transit - retry, do not parse
    ...

Dans Node, l’erreur n’apparaît pas quand fetch() se résout (à ce moment-là, vous avez déjà le 200). Elle apparaît quand vous appelez await res.text(), donc placez aussi cet appel dans votre bloc try.

2. Vérifiez Que le Document Se Termine Là Où Il Doit

Sur le texte brut, cherchez </html> près de la fin :

def has_closing_html(raw: str) -> bool:
    return "</html>" in raw[-2048:].lower()

Examinez les derniers Ko plutôt que les tout derniers octets, car beaucoup de pages placent des scripts ou des commentaires après la balise fermante. Et apprenez d’abord à connaître chaque site : la spécification HTML autorise l’omission de </body> et </html>, ne vous fiez donc à cette vérification que pour les sites dont les pages saines incluent bien la balise.

3. Validez le JSON Embarqué

C’est votre vérification la plus solide. JSON n’a aucune récupération d’erreur : un objet coupé ne peut jamais être parsé. L’état du framework comme __NEXT_DATA__, les blocs application/ld+json et l’état applicatif inline sont de bons indicateurs :

import json
import re

NEXT_DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)

def next_data_ok(raw: str) -> bool:
    if '<script id="__NEXT_DATA__"' not in raw:
        return True  # this page does not use it
    m = NEXT_DATA.search(raw)
    if not m:
        return False  # opening tag present, closing tag never arrived
    try:
        json.loads(m.group(1))
        return True
    except json.JSONDecodeError:
        return False

Si vous extrayez déjà des données de ces blocs (voir comment trouver le JSON caché derrière un site JavaScript), un échec de parsing est votre signal pour réessayer, pas une raison d’ignorer la page.

4. Cherchez un Marqueur de Fin de Page

Choisissez un élément qui n’apparaît qu’en bas d’une page saine : le pied de page, le bloc de pagination, un script final connu. S’il manque, la page n’est pas arrivée en entier. Combinez-le avec un comptage des éléments sur les pages de listing. Notre guide pour détecter les blocages silencieux détaille les vérifications par comptage, car les pages partielles y apparaissent aussi comme tactique anti-bot.

5. Suivez la Taille des Réponses

Enregistrez la longueur en octets de chaque réponse, regroupée par type de page. La troncature apparaît comme un groupe de petites valeurs aberrantes. Ce n’est pas une preuve pour une page isolée, mais une hausse soudaine des réponses courtes vous indique que quelque chose a changé en amont.

Tout Assembler

Un petit garde-fou qui s’exécute avant votre parseur, avec un nombre limité de nouvelles tentatives :

import requests

def completeness_problems(raw, end_marker=None):
    problems = []
    if not has_closing_html(raw):
        problems.append("no </html> near the end")
    if end_marker and end_marker not in raw:
        problems.append(f"end marker {end_marker!r} missing")
    if not next_data_ok(raw):
        problems.append("__NEXT_DATA__ cut off or invalid")
    return problems

def fetch_complete(url, end_marker=None, attempts=3):
    last = []
    for _ in range(attempts):
        try:
            with requests.Session() as s:  # fresh connection per attempt
                resp = s.get(url, timeout=(5, 30))
        except (ChunkedEncodingError, ConnectionError) as e:
            last = [f"transfer broken: {e}"]
            continue
        problems = completeness_problems(resp.text, end_marker)
        if not problems:
            return resp.text
        last = problems
    raise RuntimeError(f"incomplete after {attempts} attempts: {last}")

La troncature est généralement passagère, donc une nouvelle tentative sur une connexion neuve règle la plupart des cas. Si vous passez par des proxys, changez aussi de proxy à chaque tentative. Gardez un petit nombre de tentatives et journalisez chaque échec avec sa raison. Si le taux de troncature grimpe sur un site, ralentissez : ce sont les pages longues et lourdes qui se font couper quand un serveur est sous charge.

Avec Playwright ou Selenium, la vérification de la balise fermante ne sert à rien. page.content() sérialise le DOM actuel, et un DOM est toujours bien formé. Le risque ici est de capturer la page avant l’arrivée des données, surtout sur les pages en streaming. Les pages Next.js avec l’App Router, par exemple, livrent leurs données dans des chunks de script self.__next_f.push(...) au fil du flux.

Attendez le contenu dont vous avez besoin, pas seulement un événement de chargement :

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(url, wait_until="load")
    page.wait_for_selector("footer", state="attached", timeout=15000)
    page.wait_for_selector("div.product-card", timeout=15000)
    html = page.content()
    browser.close()

Lancez ensuite les mêmes vérifications de JSON et de marqueur sur html avant de lui faire confiance.

FAQ

Un code 200 signifie-t-il que la page est complète ?

Non. Le code de statut est envoyé avant le corps. Il indique que le serveur a commencé une réponse réussie, pas qu’il l’a terminée. La complétude doit être vérifiée sur le corps.

Pourquoi BeautifulSoup affiche-t-il des balises fermantes absentes de la réponse ?

Les parseurs HTML ferment les éléments ouverts quand l’entrée se termine, exactement comme les navigateurs. Un document tronqué paraît donc bien formé après le parsing. Vérifiez le texte brut avant de le parser.

Faut-il réessayer une réponse tronquée ?

Oui, avec une limite. La plupart des troncatures sont passagères et la même URL renvoie la page complète à la tentative suivante. Utilisez une connexion neuve, limitez-vous à deux ou trois tentatives et journalisez la raison pour repérer un schéma.

Quelle est la vérification isolée la plus fiable ?

Parser le JSON embarqué, comme __NEXT_DATA__ ou le JSON-LD. Contrairement au HTML, JSON n’a pas de parseur tolérant : un bloc coupé échoue toujours au parsing.

Pour Conclure

Les réponses tronquées sont rares, et c’est justement pour cela qu’elles font mal. Elles passent à travers les vérifications de statut et les parseurs, puis réapparaissent des semaines plus tard sous forme de trous dans vos données. Un garde-fou de quelques lignes, qui vérifie dans le corps brut la présence d’une balise fermante, d’un JSON embarqué valide et d’un marqueur de fin de page, les intercepte presque toutes, et un nombre limité de nouvelles tentatives récupère les données.

Si gérer vous-même navigateurs et proxys est la partie que vous préférez éviter, ScrapeUnblocker récupère et rend les pages pour vous et renvoie le HTML en un seul appel d’API. La documentation montre comment faire la requête. Gardez le garde-fou de complétude devant votre parseur, quelle que soit votre source de données : il vérifie la seule chose qu’un code de statut ne peut pas vérifier.

Essayez ScrapeUnblocker gratuitement

Taux de réussite de plus de 95 % · à partir de 0,55 € pour 1 000 appels (TVA éventuelle en sus) · 500 requêtes gratuites à l'inscription.

Essayer gratuitement → Voir les tarifs