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
"""
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
/**
* 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';
}Essayer
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.
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 page Où cela mène Genre article https://exemple.fr/blog/article page /racine https://exemple.fr/racine page ../haut https://exemple.fr/haut page https://autre.fr/page https://autre.fr/page page 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 page Où cela mène Genre #ancre https://exemple.fr/blog/#ancre ancre mailto:jean@exemple.fr mailto:jean@exemple.fr courriel tel:+33123456789 tel:+33123456789 téléphone 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 page Où cela mène Genre article https://exemple.fr/v2/article page /racine https://exemple.fr/racine page 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 page Où cela mène Genre https://café.fr/page https://xn--caf-dma.fr/page page HTTPS://EXEMPLE.FR/MAJ https://exemple.fr/MAJ page https://exemple.fr:443/p https://exemple.fr/p page /chemin avec espaces/é.html https://exemple.fr/chemin%20avec%20espaces/%C3%A9.html page 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 page Où cela mène Genre \\chemin https://chemin/ page \chemin https://exemple.fr/chemin page chemin https://exemple.fr/blog/chemin page 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 page Où cela mène Genre article https://www.exemple.fr/blog/2026/article page /racine https://www.exemple.fr/racine page ../haut https://www.exemple.fr/blog/haut page 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
- RecommandéN0Les attributs href, résolus contre la base déclarée comme le fait un navigateurGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- 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
- La norme URL du WHATWG — celle que les navigateurs appliquent
- RFC 3986 — la norme que suit la bibliothèque standard de Python
- HTML Standard — l’élément base, et ce qu’il remplace
- Isoler le contenu principal d’une page web — la fiche voisine, quand ce sont les liens qu’il faut jeter
Source des chiffres
- Norme URL du WHATWG (consultée) : le nettoyage des espaces de tête et de queue, la suppression des tabulations et des sauts de ligne, et le traitement de l’antislash comme une barre oblique pour les schémas « spéciaux »
- RFC 3986, section 5 (consultée) : la résolution de référence que met en œuvre `urllib.parse.urljoin`, où l’antislash n’a aucun statut particulier
- HTML Standard, élément base (consulté) : seul le premier `<base href>` du document compte, et il remplace l’adresse du document pour toute résolution
- urlstd sur PyPI (consulté) : version 2023.7.26.1, publiée le 12 septembre 2023, licence MIT ; c’est la mise en œuvre Python de la norme WHATWG, et elle n’a pas été republiée depuis
- linkedom sur son dépôt (consulté) : version 0.18.13, licence ISC ; il fournit ici l’analyse du document côté JavaScript
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.