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

snippets/extract-links-from-a-page/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/extract-links-from-a-page/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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';
}

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.

  1. 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 pageWhere it leadsKind
    articlehttps://exemple.fr/blog/articlepage
    /racinehttps://exemple.fr/racinepage
    ../hauthttps://exemple.fr/hautpage
    https://autre.fr/pagehttps://autre.fr/pagepage
  2. 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 pageWhere it leadsKind
    #ancrehttps://exemple.fr/blog/#ancreanchor
    mailto:jean@exemple.frmailto:jean@exemple.frmail
    tel:+33123456789tel:+33123456789phone
  3. 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 pageWhere it leadsKind
    articlehttps://exemple.fr/v2/articlepage
    /racinehttps://exemple.fr/racinepage
  4. 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 pageWhere it leadsKind
    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. 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 pageWhere it leadsKind
    \\cheminhttps://chemin/page
    \cheminhttps://exemple.fr/cheminpage
    cheminhttps://exemple.fr/blog/cheminpage
  6. 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 pageWhere it leadsKind
    articlehttps://www.exemple.fr/blog/2026/articlepage
    /racinehttps://www.exemple.fr/racinepage
    ../hauthttps://www.exemple.fr/blog/hautpage

    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

  1. RecommendedN0The href attributes, resolved against the declared base the way a browser doesFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata