Collect the links of a page
List the addresses quoted in a web page, resolved to absolute ones.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the address is written in the page and resolving it is a published set of rules. What this entry adds comes from the measurement it made: over thirty-eight addresses, urllib.parse.urljoin and new URL answer differently seven times. The most striking is the backslash — href="\\chemin" gives a path on your own site under RFC 3986 and the host `chemin` under the WHATWG standard, which is the browsers’. Since the question being asked is “where will the reader land”, the Python snippet was brought up to the browser’s answer: edge cleaning, backslashes, host case, punycode, default port, encoding of spaces and accents. The two snippets now return the same report over all thirty-eight addresses. The rest is discipline: the base is required and returned, the `<base href>` element replaces it, and what is not an address — `javascript:`, an empty href — is dropped with its reason rather than returned as a link.
The href attributes, resolved against the declared base the way a browser does
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
The links of a page, resolved to absolute addresses the way a browser does it.
Rung N0. An href is an attribute: reading it is parsing, not inference. The
work is elsewhere, and it is in two places.
The first is the base. A relative href means nothing on its own, and what it
is relative to is not in the page: it is the address the page was served from,
which only the caller knows. It is therefore required, not guessed — and the
`<base href>` element, when the page carries one, replaces it, which is the
first thing to check when every link comes out wrong.
The second is that the two languages do not resolve alike, and this entry
measures seven places where they part. `urllib.parse.urljoin` follows RFC 3986;
`new URL` follows the WHATWG standard, which is what browsers actually do, and
the difference is not cosmetic: on `href="\\chemin"`, RFC 3986 gives a path of
your own site and the WHATWG standard gives the host `chemin`. Since the
question being asked is « where will the reader land », this file follows the
browser: the cleaning and the normalisation below close the seven gaps. They
are not a complete WHATWG implementation — `urlstd` is, on PyPI, and it was
last published in 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
/**
* The links of a page, resolved to absolute addresses the way a browser does it.
*
* Rung N0. An href is an attribute: reading it is parsing, not inference. The
* work is elsewhere, and it is in two places.
*
* The first is the base. A relative href means nothing on its own, and what it
* is relative to is not in the page: it is the address the page was served
* from, which only the caller knows. It is therefore required, not guessed —
* and the `<base href>` element, when the page carries one, replaces it, which
* is the first thing to check when every link comes out wrong.
*
* The second is that the two languages do not resolve alike, and this entry
* measures seven places where they part. `urllib.parse.urljoin` follows
* RFC 3986; `new URL` follows the WHATWG standard, which is what browsers
* actually do, and the difference is not cosmetic: on `href="\\chemin"`,
* RFC 3986 gives a path of your own site and the WHATWG standard gives the
* host `chemin`. Since the question being asked is « where will the reader
* land », this file is the reference the other one had to be brought up to:
* `new URL` already is the browser.
*/
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';
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
Nothing goes out on the network: the page is parsed as given, and no address is visited. The base is the one the case declares.
An ordinary blog page
Given
<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>
What it returns
4 link(s), resolved against https://exemple.fr/blog/
In detail
Written in the page Where it leads Kind 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 Anchors, mail and what is not an address
Given
<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>
What it returns
3 link(s), resolved against https://exemple.fr/blog/ · 2 dropped
javascript:alert(1) : javascript is not an address · — : no address
In detail
Written in the page Where it leads Kind #ancre https://exemple.fr/blog/#ancre anchor mailto:jean@exemple.fr mailto:jean@exemple.fr mail tel:+33123456789 tel:+33123456789 phone A <base> element in the page changes everything
Given
<base href="https://exemple.fr/v2/"> <a href="article">L’article</a> <a href="/racine">Racine</a>
What it returns
2 link(s), resolved against https://exemple.fr/v2/
In detail
Written in the page Where it leads Kind article https://exemple.fr/v2/article page /racine https://exemple.fr/racine page The spellings a browser normalises
Given
<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>
What it returns
4 link(s), resolved against https://exemple.fr/blog/
In detail
Written in the page Where it leads Kind 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 A backslash is not a path character
Given
<a href="\\chemin">Deux barres inversées</a> <a href="\chemin">Une barre inversée</a> <a href="chemin">Rien du tout</a>
What it returns
3 link(s), resolved against https://exemple.fr/blog/
In detail
Written in the page Where it leads Kind \\chemin https://chemin/ page \chemin https://exemple.fr/chemin page chemin https://exemple.fr/blog/chemin page The same page, resolved against the wrong base
Given
<a href="article">L’article</a> <a href="/racine">Racine</a> <a href="../haut">Un cran au-dessus</a>
What it returns
3 link(s), resolved against https://www.exemple.fr/blog/2026/
In detail
Written in the page Where it leads Kind article https://www.exemple.fr/blog/2026/article page /racine https://www.exemple.fr/racine page ../haut https://www.exemple.fr/blog/haut page Here it breaksThe three addresses returned are valid, and all three are wrong: the page had been served from another host and another folder. The code resolves against what it is told and has no way to check that declaration — which is why it returns the base it used, and why that is the first thing to look at when every link goes elsewhere. The commonest cause is a redirect: the address requested was declared where the address landed on should have been.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the page is read inside your own process, and no address is visited
- Scraping a third-party site is a matter of its terms of use and of applicable law, which the technique used does not change
Breaking point
The code resolves against the base it is told, and it has no way of checking that declaration: an address resolved against the wrong base is still a valid address. In the test, the same page returns “https://exemple.fr/blog/article” or “https://www.exemple.fr/blog/2026/article” depending on what was declared, and nothing in the report says which is right — the common cause is a redirect, where the address requested is declared instead of the address landed on. The witness is in the same test: the base used is returned in the report, and the page’s `<base href>` element replaces it when the page carries one.
When to move up a level
Your page’s links are put there by its own JavaScript: they are not in the HTML served, and no amount of parsing the text will invent them. What follows is not a better parser, it is a headless browser.
The four options
- RecommendedN0The href attributes, resolved against the declared base the way a browser doesFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn: the address is written in an attribute the HTML standard names, and resolving it is a published set of rules, not an inference. A model trained on annotated pages would recover, at best, what the parser reads exactly, and would be wrong on the pages that do not look like its sample.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with budget on top. A news page is hundreds of kilobytes of markup; running it through a resident model costs orders of magnitude more than reading an attribute, for a result that is no easier to check. And it does not settle the one case that resists — the empty page its script has not filled yet — because there is nothing to read in what you would send it.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A general-purpose model returns strings that look like addresses, and looking like one is not enough: an address wrong by one character leads elsewhere, or nowhere. Add to that one bill per page and one data egress, for work your language’s parser does in a millisecond, with the resolution the standard describes.
Further reading
- 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
Where the figures come from
- 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
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.