Récupérer les liens d’une page

Lister les adresses citées dans une page web, ramenées à des adresses absolues.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que l’adresse est écrite dans la page et que la résoudre est une suite de règles publiées. Ce que cette fiche apporte tient à la mesure qu’elle a faite : sur trente-huit adresses, `urllib.parse.urljoin` et `new URL` répondent différemment sept fois. La plus frappante est l’antislash — `href="\\chemin"` donne un chemin de votre site avec la RFC 3986 et l’hôte `chemin` avec la norme WHATWG, qui est celle des navigateurs. Comme la question posée est « où le lecteur va-t-il atterrir », l’extrait Python a été amené à la réponse du navigateur : nettoyage des bords, antislash, casse de l’hôte, punycode, port par défaut, encodage des espaces et des accents. Les deux extraits rendent désormais le même rapport sur les trente-huit adresses. Le reste est de la discipline : la base est exigée et rendue, l’élément `<base href>` la remplace, et ce qui n’est pas une adresse — `javascript:`, un href vide — est écarté avec sa raison plutôt que rendu comme un lien.

Les attributs href, résolus contre la base déclarée comme le fait un navigateur

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-links-from-a-page/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Les liens d'une page, ramenés à des adresses absolues comme le fait un navigateur.

Niveau N0. Un href est un attribut : le lire, c'est analyser, pas déduire. Le
travail est ailleurs, et il est à deux endroits.

Le premier est la base. Un href relatif ne veut rien dire tout seul, et ce à
quoi il est relatif n'est pas dans la page : c'est l'adresse d'où la page a
été servie, que seul l'appelant connaît. Elle est donc exigée, pas devinée —
et l'élément `<base href>`, quand la page en porte un, la remplace, ce qui
est la première chose à regarder quand tous les liens sont faux.

Le second est que les deux langages ne résolvent pas pareil, et cette fiche
mesure sept endroits où ils divergent. `urllib.parse.urljoin` suit la
RFC 3986 ; `new URL` suit la norme WHATWG, qui est ce que les navigateurs
font réellement, et l'écart n'est pas cosmétique : sur `href="\\chemin"`, la
RFC 3986 rend un chemin de votre propre site et la norme WHATWG rend l'hôte
`chemin`. Comme la question posée est « où le lecteur va-t-il atterrir », ce
fichier suit le navigateur : le nettoyage et la normalisation ci-dessous
ferment les sept écarts. Ce n'est pas une implémentation complète de la
norme WHATWG — `urlstd` en est une, sur PyPI, publiée pour la dernière fois
en 2023.
"""

from __future__ import annotations

import re
from html.parser import HTMLParser
from urllib.parse import quote, urljoin, urlsplit, urlunsplit

# The schemes a browser treats as « special », and the port it leaves out.
SPECIAL = {"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21}

# What is stripped from an href before anything else, as the URL standard
# says: control characters and spaces at the edges, tabs and newlines anywhere.
C0_EDGES = re.compile(r"\A[\x00-\x20]+|[\x00-\x20]+\Z")
TABS = re.compile(r"[\t\n\r]")

# Characters left as they are when a component is percent-encoded. « % » is in
# the list so that an already-encoded address is not encoded twice.
SAFE = "/:@!$&'()*+,;=-._~%?\\"

SPACES = re.compile(r"\s+")


class _Links(HTMLParser):
    """Anchors and their text, plus the first <base href> of the document."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.anchors, self.base, self._open = [], None, None

    def handle_starttag(self, tag, attrs):
        values = dict(attrs)
        if tag == "base" and self.base is None and values.get("href"):
            self.base = values["href"]
        if tag == "a":
            self._open = {"href": values.get("href"), "rel": values.get("rel"), "text": []}
            self.anchors.append(self._open)

    def handle_endtag(self, tag):
        if tag == "a":
            self._open = None

    def handle_data(self, data):
        if self._open is not None:
            self._open["text"].append(data)


def extract_links(html, base) -> dict:
    """
    Every anchor of `html`, with its address resolved against `base`.

    `base` is the address the page was served from. It is required: a relative
    href resolved against a guess is a valid address pointing somewhere else.
    """
    if not isinstance(html, str) or not isinstance(base, str) or not base.strip():
        return {"links": [], "skipped": [], "base": None,
                "reason": "the address the page was served from is required"}

    parser = _Links()
    parser.feed(html)
    used = urljoin(_clean(base), _clean(parser.base)) if parser.base else _clean(base)

    links, skipped = [], []
    for anchor in parser.anchors:
        href = anchor["href"]
        if href is None or not _clean(href):
            skipped.append({"href": href, "why": "no address"})
            continue
        scheme = urlsplit(_clean(href)).scheme.lower()
        if scheme in {"javascript", "data", "blob", "about"}:
            skipped.append({"href": href, "why": f"{scheme} is not an address"})
            continue
        url = _normalise(urljoin(used, _clean(href)), _clean(href))
        links.append({"href": href, "url": url, "text": SPACES.sub(" ", "".join(anchor["text"])).strip(),
                      "rel": anchor["rel"], "kind": _kind(url, used)})
    return {"links": links, "skipped": skipped, "base": used, "reason": None}


def _clean(href: str) -> str:
    """The edges and the backslashes, as the URL standard treats them."""
    href = TABS.sub("", C0_EDGES.sub("", href))
    cut = min([i for i in (href.find("?"), href.find("#")) if i >= 0] or [len(href)])
    return href[:cut].replace("\\", "/") + href[cut:]


def _normalise(url: str, href: str) -> str:
    """The seven differences this entry measures, closed on the Python side."""
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    if scheme not in SPECIAL:
        return url
    host = (parts.hostname or "").lower()
    if not host.isascii():
        try:
            host = host.encode("idna").decode("ascii")
        except UnicodeError:
            pass  # a label a browser would refuse; it is left as written
    netloc = host if parts.port in (None, SPECIAL[scheme]) else f"{host}:{parts.port}"
    if parts.username:
        secret = f":{parts.password}" if parts.password else ""
        netloc = f"{parts.username}{secret}@{netloc}"
    out = urlunsplit((scheme, netloc, quote(parts.path, safe=SAFE) or "/",
                      quote(parts.query, safe=SAFE), quote(parts.fragment, safe=SAFE)))
    # urlunsplit drops an empty query or fragment; a browser keeps the
    # delimiter the author wrote.
    if "?" in href.split("#")[0] and not parts.query:
        out = out.split("#")[0] + "?" + (f"#{parts.fragment}" if parts.fragment else "")
    if "#" in href and not parts.fragment:
        out += "#"
    return out


def _kind(url: str, base: str) -> str:
    scheme = urlsplit(url).scheme.lower()
    if scheme not in SPECIAL:
        return {"mailto": "mail", "tel": "phone"}.get(scheme, "other")
    return "anchor" if url.split("#")[0] == base.split("#")[0] else "page"

JavaScript

snippets/extract-links-from-a-page/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Les liens d'une page, ramenés à des adresses absolues comme le fait un navigateur.
 *
 * Niveau N0. Un href est un attribut : le lire, c'est analyser, pas déduire. Le
 * travail est ailleurs, et il est à deux endroits.
 *
 * Le premier est la base. Un href relatif ne veut rien dire tout seul, et ce à
 * quoi il est relatif n'est pas dans la page : c'est l'adresse d'où la page a
 * été servie, que seul l'appelant connaît. Elle est donc exigée, pas devinée —
 * et l'élément `<base href>`, quand la page en porte un, la remplace, ce qui
 * est la première chose à regarder quand tous les liens sont faux.
 *
 * Le second est que les deux langages ne résolvent pas pareil, et cette fiche
 * mesure sept endroits où ils divergent. `urllib.parse.urljoin` suit la
 * RFC 3986 ; `new URL` suit la norme WHATWG, qui est ce que les navigateurs
 * font réellement, et l'écart n'est pas cosmétique : sur `href="\\chemin"`, la
 * RFC 3986 rend un chemin de votre propre site et la norme WHATWG rend l'hôte
 * `chemin`. Comme la question posée est « où le lecteur va-t-il atterrir », ce
 * fichier est la référence à laquelle l'autre a dû être amené : `new URL` est
 * déjà le navigateur.
 */

import { parseHTML } from 'linkedom';

// The schemes a browser treats as « special ». The URL class leaves their
// default port out on its own.
export const SPECIAL = new Set(['http:', 'https:', 'ws:', 'wss:', 'ftp:']);

// What is never an address, whatever it is written in.
const NOT_ADDRESSES = new Set(['javascript:', 'data:', 'blob:', 'about:']);

const SPACES = /\s+/g;

/**
 * Every anchor of `html`, with its address resolved against `base`.
 *
 * `base` is the address the page was served from. It is required: a relative
 * href resolved against a guess is a valid address pointing somewhere else.
 */
export function extractLinks(html, base) {
  if (typeof html !== 'string' || typeof base !== 'string' || !base.trim()) {
    return {
      links: [],
      skipped: [],
      base: null,
      reason: 'the address the page was served from is required',
    };
  }

  const { document } = parseHTML(html);
  const declared = document.querySelector('base[href]')?.getAttribute('href');
  let used;
  try {
    used = declared ? new URL(declared, base).href : new URL(base).href;
  } catch {
    return { links: [], skipped: [], base: null, reason: 'the base is not an address' };
  }

  const links = [];
  const skipped = [];
  for (const anchor of document.querySelectorAll('a')) {
    const href = anchor.hasAttribute('href') ? anchor.getAttribute('href') : null;
    if (href === null || !href.trim()) {
      skipped.push({ href, why: 'no address' });
      continue;
    }
    let url;
    try {
      url = new URL(href, used);
    } catch {
      skipped.push({ href, why: 'not an address' });
      continue;
    }
    if (NOT_ADDRESSES.has(url.protocol)) {
      skipped.push({ href, why: `${url.protocol.slice(0, -1)} is not an address` });
      continue;
    }
    links.push({
      href,
      url: url.href,
      text: (anchor.textContent ?? '').replace(SPACES, ' ').trim(),
      rel: anchor.hasAttribute('rel') ? anchor.getAttribute('rel') : null,
      kind: kindOf(url, used),
    });
  }
  return { links, skipped, base: used, reason: null };
}

function kindOf(url, base) {
  if (!SPECIAL.has(url.protocol)) {
    return { 'mailto:': 'mail', 'tel:': 'phone' }[url.protocol] ?? 'other';
  }
  return url.href.split('#')[0] === base.split('#')[0] ? 'anchor' : 'page';
}

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Rien ne part sur le réseau : la page est analysée telle qu’elle est donnée, et aucune adresse n’est visitée. La base est celle que le cas déclare.

  1. Une page de blog ordinaire

    Donné

    <a href="article">L’article</a> <a href="/racine" rel="nofollow">Racine</a> <a href="../haut">Un cran au-dessus</a> <a href="https://autre.fr/page">Chez les voisins</a>

    Ce qu’il renvoie

    4 lien(s), résolus contre https://exemple.fr/blog/

    Le détail

    Écrit dans la pageOù cela mèneGenre
    articlehttps://exemple.fr/blog/articlepage
    /racinehttps://exemple.fr/racinepage
    ../hauthttps://exemple.fr/hautpage
    https://autre.fr/pagehttps://autre.fr/pagepage
  2. Ancres, courriels et ce qui n’est pas une adresse

    Donné

    <a href="#ancre">Ancre</a> <a href="mailto:jean@exemple.fr">Écrire</a> <a href="tel:+33123456789">Appeler</a> <a href="javascript:alert(1)">Script</a> <a>Sans href</a>

    Ce qu’il renvoie

    3 lien(s), résolus contre https://exemple.fr/blog/ · 2 écarté(s)

    javascript:alert(1) : javascript is not an address · — : no address

    Le détail

    Écrit dans la pageOù cela mèneGenre
    #ancrehttps://exemple.fr/blog/#ancreancre
    mailto:jean@exemple.frmailto:jean@exemple.frcourriel
    tel:+33123456789tel:+33123456789téléphone
  3. Un élément <base> dans la page change tout

    Donné

    <base href="https://exemple.fr/v2/"> <a href="article">L’article</a> <a href="/racine">Racine</a>

    Ce qu’il renvoie

    2 lien(s), résolus contre https://exemple.fr/v2/

    Le détail

    Écrit dans la pageOù cela mèneGenre
    articlehttps://exemple.fr/v2/articlepage
    /racinehttps://exemple.fr/racinepage
  4. Les écritures que le navigateur normalise

    Donné

    <a href="https://café.fr/page">Accent dans l’hôte</a> <a href="HTTPS://EXEMPLE.FR/MAJ">Majuscules</a> <a href="https://exemple.fr:443/p">Port par défaut</a> <a href="/chemin avec espaces/é.html">Espaces et accents</a>

    Ce qu’il renvoie

    4 lien(s), résolus contre https://exemple.fr/blog/

    Le détail

    Écrit dans la pageOù cela mèneGenre
    https://café.fr/pagehttps://xn--caf-dma.fr/pagepage
    HTTPS://EXEMPLE.FR/MAJhttps://exemple.fr/MAJpage
    https://exemple.fr:443/phttps://exemple.fr/ppage
    /chemin avec espaces/é.htmlhttps://exemple.fr/chemin%20avec%20espaces/%C3%A9.htmlpage
  5. Une barre inversée n’est pas un caractère de chemin

    Donné

    <a href="\\chemin">Deux barres inversées</a> <a href="\chemin">Une barre inversée</a> <a href="chemin">Rien du tout</a>

    Ce qu’il renvoie

    3 lien(s), résolus contre https://exemple.fr/blog/

    Le détail

    Écrit dans la pageOù cela mèneGenre
    \\cheminhttps://chemin/page
    \cheminhttps://exemple.fr/cheminpage
    cheminhttps://exemple.fr/blog/cheminpage
  6. La même page, résolue contre la mauvaise base

    Donné

    <a href="article">L’article</a> <a href="/racine">Racine</a> <a href="../haut">Un cran au-dessus</a>

    Ce qu’il renvoie

    3 lien(s), résolus contre https://www.exemple.fr/blog/2026/

    Le détail

    Écrit dans la pageOù cela mèneGenre
    articlehttps://www.exemple.fr/blog/2026/articlepage
    /racinehttps://www.exemple.fr/racinepage
    ../hauthttps://www.exemple.fr/blog/hautpage

    Là, ça casseLes trois adresses rendues sont valides, et les trois sont fausses : la page avait été servie depuis un autre hôte et un autre dossier. Le code résout contre ce qu’on lui déclare et n’a aucun moyen de vérifier cette déclaration — c’est pourquoi il rend la base employée dans son rapport, et c’est la première chose à regarder quand tous les liens partent ailleurs. Le cas le plus courant est une redirection : on déclare l’adresse demandée quand il fallait déclarer celle où l’on a atterri.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : la page est lue dans votre processus, et aucune adresse n’est visitée
  • L’aspiration d’un site tiers relève de ses conditions d’utilisation et du droit applicable, que la technique employée ne change pas

Point de rupture

Le code résout contre la base qu’on lui déclare, et il n’a aucun moyen de vérifier cette déclaration : une adresse résolue contre une base fausse reste une adresse valide. Dans le test, la même page rend « https://exemple.fr/blog/article » ou « https://www.exemple.fr/blog/2026/article » selon ce qu’on a déclaré, et rien dans le rapport ne dit laquelle est la bonne — le cas courant est une redirection, où l’on déclare l’adresse demandée au lieu de celle où l’on a atterri. Le témoin est dans le même test : la base employée est rendue dans le rapport, et l’élément `<base href>` de la page la remplace quand elle en porte un.

Quand passer au niveau suivant

Les liens de votre page sont posés par son propre JavaScript : ils ne sont pas dans le HTML servi, et aucune analyse du texte ne les inventera. La suite n’est pas un meilleur analyseur, c’est un navigateur sans affichage.

Les quatre options

  1. RecommandéN0Les attributs href, résolus contre la base déclarée comme le fait un navigateurGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre : l’adresse est écrite dans un attribut que la norme HTML nomme, et la résolution est une suite de règles publiées, pas une inférence. Un modèle appris sur des pages annotées retrouverait, au mieux, ce que l’analyseur lit exactement, et se tromperait sur les pages qui ne ressemblent pas à son échantillon.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, avec le budget en plus. Une page de presse fait des centaines de kilooctets de balisage ; la faire passer dans un modèle résident coûte des ordres de grandeur de plus que la lecture d’un attribut, pour un résultat qu’on ne peut pas vérifier plus facilement. Et cela ne règle pas le seul cas qui résiste — la page vide que son script n’a pas encore remplie —, puisqu’il n’y a rien à lire dans ce qu’on lui enverrait.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un modèle généraliste rend des chaînes qui ressemblent à des adresses, et la ressemblance ne suffit pas : une adresse fausse d’un caractère mène ailleurs, ou nulle part. S’y ajoutent une facture par page et une sortie de données pour un travail que l’analyseur de votre langage fait en une milliseconde, avec la résolution que la norme décrit.

Pour aller plus loin

Source des chiffres

Métadonnées