a URL a real browser every link on it one line per page

Python, from zero to a working scanner

One session, one file, a tool you keep

Prof. Dr. Dominik Herrmann

PSI-Sem-B · PSI-Sem-M

SoSe 2026

outline

The hour ahead

Nine short parts: the last three build the tool, and the six before them are the pieces it is made of.

  1. 1What we are building
  2. 2Setting up
  3. 3The language itself
  4. 4Idioms worth having
  5. 5The standard library
  6. 6Waiting well
  7. 7Driving a real browser
  8. 8The scanner
  9. 9Wrap-up

What we are building

A link-health scanner under eighty lines, and you will have read them

By the end of the session you will have a small command-line tool that visits a URL, follows every link it finds on that page, and prints one line about each page it touches.

  • Broken links
    any response at or above 400, with the URL that produced it
  • Missing titles
    a page whose <title> came back empty
  • No description
    no meta description tag in the head

Most of what we cover today turns up in the final script. By the last slide you will be able to read it without stopping, and the parts that do not reach the scanner – pathlib and re – are there because the next script needs them.

What you already need three boxes to tick before we start

  • Python 3.11 or newerpython3 --version in a terminal has to answer, and the answer has to start with a 3.11 or better
  • A terminal you are at home inwe install, activate and run from it all afternoon; which shell it is does not matter
  • The shape of a loopa variable, a function and a for should be familiar ideas, even if the Python spelling is not

Prior Python is not assumed. Prior programming in some language is. If that last box is not ticked, pair up with someone whose is – the pace takes it for granted.

principle

Use a venv from the very first import

Global Python belongs to the operating system, not to your project. pip install on the system interpreter edits a shared dependency tree that other programs read from.

A virtual environment is a directory with its own interpreter and its own site-packages. You activate it, install into it, throw it away. Your project stays reproducible, your machine stays clean.

Setting up

example

Setup with uv the fast modern path

uv is a modern Python package manager written in Rust. It replaces pip, virtualenv and pyenv with one binary and an order of magnitude more speed. Install it once, globally, or run the script on the uv site.

pip install uv
# or: brew install uv

Then, inside your project directory, create the venv, activate it, and install the one dependency we need.

uv venv
source .venv/bin/activate
uv pip install playwright

The activation step is the one that matters. After it, python and pip resolve to the binaries inside .venv/, not to the ones on your system.

example

Fallback with pip and venv same result, a few seconds slower

If you cannot install uv, the venv module and pip ship with Python itself. They have done since 3.3 and 3.4 respectively, so there is nothing to install before you start.

python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install playwright

The difference that matters here is speed: uv resolves and installs in parallel and caches aggressively, pip is sequential and cold-caches often. Pick one and stay with it for the rest of the session.

What activation actually does one directory, one line of PATH

PATH before activate /usr/local/bin /usr/bin PATH after activate .venv/bin /usr/local/bin /usr/bin python and pip are found here first

Activation prepends one directory to PATH. Deactivating restores the PATH it saved. There is no global state change, no service and no daemon – only a directory you are free to delete.

The language itself

definition

Variables carry values names do not carry types

Python is dynamically typed. A name is bound to a value, and the value carries its own type. The same name can point at an int on one line and a str on the next, although that is usually a bug rather than a feature.

name = "Ada"       # str
age = 36           # int
pi = 3.14159       # float
ready = True       # bool
unknown = None     # NoneType

The built-in type(x) tells you what you are holding right now; isinstance(x, str) answers the question you usually actually have.

example

F-strings self-documenting prints for debugging

F-strings are the modern way to build a string. A leading f tells Python to evaluate the expressions inside {} braces and insert the results.

name = "Ada"
age = 36
print(f"{name} is {age} years old.")
print(f"Next birthday: {age + 1}.")
print(f"{name=}, {age=}")

The last form – an = inside the braces – prints both the expression and its value: name='Ada', age=36. It exists for one purpose, throwaway debug prints that are still readable three weeks later.

The four core collections each one answers a different question

  • listordered and mutable, for a run of things whose order means something: urls = ["a", "b"]
  • tupleordered and immutable, for a fixed-shape record: (lat, lon), (host, port)
  • dicta map from keys to values, for a record with more fields than you can unpack: {"status": 200}
  • setunordered and unique, for membership tests and deduplication: seen.add(url)

The scanner uses a set so that it never visits one URL twice, and a dict in its place would say something the code does not mean.

example

Collection operations boringly similar across all four

Most operations look the same on lists, tuples, dicts and sets. in tests membership, len() gives the size, iteration yields elements – for a dict, its keys. The differences are in mutation and in shape.

urls = ["https://a.com", "https://b.com"]
urls.append("https://c.com")
"https://a.com" in urls        # True

seen = {"https://a.com"}
seen.add("https://b.com")

page = {"url": "https://a.com", "status": 200}
page["title"] = "A"   # dicts grow by assignment
example

Control flow indentation is the block delimiter

Python uses indentation where other languages use braces. Four spaces per level – what PEP 8 asks for and what black and ruff format produce. No end, no }, no semicolons.

status = 404

if status == 200:
    print("ok")
elif 300 <= status < 400:
    print("redirect")
else:
    print("problem")

elif is the Python spelling of else if. There is no separate keyword and no switch.

A for loop walks an iterable, never an index. continue skips to the next item, break leaves the loop entirely, and both do what you expect.

for url in urls:
    if "localhost" in url:
        continue
    print(url)
example

Functions with type hints optional, and you should write them anyway

Define a function with def, annotate the parameters and the return type, and you have documentation the editor can read. Hints are not enforced at runtime – they are advisory.

def greet(
    name: str,
    loud: bool = False,
) -> str:
    end = "!" if loud else "."
    return f"Hello, {name}{end}"

Call it like any other function. Positional arguments first, keyword arguments after them, and defaults let a caller leave out what it does not need. A signature too long for one line wraps one parameter per line with a trailing comma – the form black and ruff format both produce.

greet("Ada")
# Hello, Ada.
greet("Ada", loud=True)
# Hello, Ada!
greet(name="Ada")
# same call, keyword form

Type hints are documentation that a machine reads. ruff or mypy flags a mismatch before the code runs, and your future self is the one who benefits.

Idioms worth having

example

Comprehensions one line from an iterable

A comprehension builds a list, a dict or a set out of an existing iterable in one expression. It reads as “this element, for each item in that source, optionally filtered”.

urls = ["https://a.com/", "https://b.com", "mailto:x@y"]

https_only = [u for u in urls if u.startswith("https://")]
lengths = {u: len(u) for u in https_only}
domains = {u.split("/")[2] for u in https_only}

Prefer a comprehension to a for-loop with .append(). It is more compact, a little faster, and it says which of two things you are doing: building a collection, rather than performing side effects.

example

Exceptions errors are values you catch and inspect

Exceptions are Python’s error channel. When something goes wrong a function raises one; a caller further up the stack catches it with try/except and decides what to do.

try:
    value = int(user_input)
except ValueError as exc:
    print(f"Not a number: {exc}")
    value = 0

The as exc clause binds the exception object to a name so that you can inspect it. Drop it when you only care that something failed and not what failed: except ValueError:.

principle

Read a traceback from the bottom the last line is the failure

The last line names the actual failure. Everything above it is the chain of calls that led there.

Traceback (most recent call last):
  File "scanner.py", line 50, in main
    reports = [await scan_page(page, url) for url in links]
  File "scanner.py", line 18, in scan_page
    status = response.status
AttributeError: 'NoneType' object has no attribute 'status'

Read upwards, and stop at the first frame that is yours – line 18, where page.goto returned no response.

The standard library

Five modules – pathlib, urllib.parse, re, dataclasses, argparse – and three of them end up in the scanner.

question

Why lean on the standard library?

What is the argument against a second dependency? The scanner needs one package that is not Python’s own, and adding another would be one more line of typing.

A pip install is a future maintenance cost. Transitive dependencies, security patches, breaking releases – they land on your plate. The standard library is installed already, and somebody else is on the hook for it.

example

pathlib paths are objects, not strings

pathlib replaces string surgery on paths with path objects. The / operator joins segments, and .read_text(), .mkdir(parents=True) and .glob() do what their names say.

from pathlib import Path

here = Path(__file__).parent
report = here / "out" / "report.txt"
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text("hello\n")

for md in here.glob("**/*.md"):
    print(md.relative_to(here))

Cross-platform correctness costs no extra code. Path normalises slashes and drive letters, so the same lines run on Linux, macOS and Windows with no os.path.join gymnastics.

example

urllib.parse URL surgery without regex

Do not parse a URL with a regex. urllib.parse already knows about schemes, userinfo, punycode hosts, default ports and path normalisation.

https :// example.com:443 /docs/a ?x=1 #top scheme netloc path query fragment

urlparse hands each of those back as an attribute of one object.

u = urlparse(link)
u.netloc   # "example.com"
u.path     # "/docs/a"

urljoin resolves a relative reference against a base, the way a browser does when it meets an <a href>.

urljoin(base, "b/c")   # …/a/b/c
urljoin(base, "/d")    # …/d

The scanner uses both: urljoin to make a link absolute, urlparse to check it stays on the host.

example

re just enough regex

Reach for re when pattern matching is the right tool, and not before. If what you need is “starts with” or “contains”, then str.startswith, str.endswith and in are faster to write and faster to read.

import re

pattern = re.compile(r"^https?://")
pattern.match("https://example.com")   # Match object
pattern.match("mailto:x@y")            # None

Compile once, match many. re.compile hands back a compiled pattern, and calling .match() on it skips the compile step every time round the loop.

example

dataclasses classes that are mostly data

@dataclass writes __init__, __repr__ and equality for you from the field annotations. Less boilerplate, and so fewer bugs in the boilerplate you did not write.

from dataclasses import dataclass

@dataclass
class PageReport:
    url: str
    status: int
    title: str | None
    has_description: bool

The generated __init__ takes every field as a keyword argument. __repr__ prints them all, and equality compares them all. This exact class is the one the scanner fills in for every page it visits.

r = PageReport(
    url="https://ex.com",
    status=200,
    title="Example",
    has_description=True,
)
print(r)
# PageReport(url='https://ex.com'...)
example

argparse the --help you never wrote, in three lines

argparse turns a list of argument descriptions into a whole CLI. Help text, type coercion, default values and error messages are all generated from the add_argument calls.

import argparse

p = argparse.ArgumentParser(description="Scan a page for link health.")
p.add_argument("url", help="URL to start from")
p.add_argument("--max", type=int, default=20, help="max links")
args = p.parse_args()

print(args.url, args.max)

python scanner.py --help already works. Three lines of setup, and the user has a standards-conforming CLI with Unix-style flags and a readable usage block.

Waiting well

The scanner spends its time waiting for somebody else.

principle

Async is for I/O, not for CPU overlapping waits, not overlapping work

A network call spends nearly all of its time waiting, so nn of them run one after another cost Tseq=itiT_{\text{seq}} = \sum_i t_i and the same nn started together cost only the longest of them.

TconcmaxitiT_{\text{conc}} \approx \max_i t_i

It does not make CPU-bound code faster. There is nothing to overlap when the thread is busy rather than idle – for that you need processes.

definition

The event loop a scheduler for coroutines

An event loop is a scheduler that runs coroutines – functions that can pause at an await and resume later. While one coroutine waits for a network response, the loop runs another. One thread, many overlapping waits.

You rarely touch the loop directly. asyncio.run(main()) starts it, runs your top-level coroutine to completion, and shuts it down again.

example

async and await three waits, one second in total

async def defines a coroutine and await suspends it until the awaited operation finishes. asyncio.gather starts several at once and waits for all of them.

import asyncio

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)  # a network call, pretend
    return f"done: {name}"
async def main() -> None:
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    print(results)

asyncio.run(main())

Three one-second sleeps, total runtime about one second. The three waits overlapped instead of queueing up behind one another.

One thread, many overlapping waits

about three seconds about one second blocking: the thread waits wait a wait b wait c awaiting: the thread is handed back wait a wait b wait c

The same three calls and the same single thread. What changes is who gets to run while somebody else waits: three one-second waits started together finish in max(1,1,1)=1\max(1, 1, 1) = 1 second.

Driving a real browser

Why Playwright the modern web is rendered, not served

A lot of the web is rendered by JavaScript in the browser. requests and plain urllib see only the HTML shell – often just <div id="app"></div> plus a pile of script tags. The text, the links and the title are not in it.

Playwright drives a real browser – Chromium, Firefox, or WebKit – over a debugging protocol. The page renders, scripts execute, the DOM settles, and then you query it. You see what a human sees.

For a link scanner this matters a lot. Navigation on many real sites is built client-side: menus, footers, and even the main content are injected after load. A scanner that speaks HTTP and nothing else does not see that navigation.

The cost is weight. A browser is a hundred megabytes of binaries and a few hundred of RAM per instance. For a lecture scanner that is fine; for a production crawler you would measure first.

The same page, fetched and rendered five lines of HTML, and the text is in none of them

An HTTP fetch hands back the whole file, and this is the whole file.

<!doctype html>
<title>Team handbook</title>
<link rel=stylesheet href=demo.css>
<div id="app"></div>
<script src="demo.js"></script>

A browser runs demo.js first, and then the page has something in it.

The heading, the sentence and the three links are written at run time. A scanner reading the left pane finds no links; one driving a browser finds three.

example

Install the browser once Playwright pins the build

After pip install playwright you still need the browser itself. Playwright ships a small CLI to download a pinned Chromium build into its cache.

playwright install chromium

Pinned means reproducible. The next developer on the project runs the same command and gets the same Chromium version, not whatever ships with today’s operating system.

example

Open a page the smallest useful Playwright script

Open a context, launch a browser, navigate, query, close. async with guarantees the cleanup even if the page raises.

import asyncio
from playwright.async_api import async_playwright

async def main() -> None:
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://example.com")
        print(await page.title())
        await browser.close()

asyncio.run(main())

There is an await on every browser call. Each one is a round trip over a socket to the browser process, so each one is an I/O wait – which is exactly what async is for.

The scanner

What we are building one file, four steps

scanner.py is a single file, under eighty lines, and it does four things.

  • Take a URL
    from the command line, plus an optional --max
  • Open it
    in Chromium, and read every link on the page
  • Visit each one
    status, title, description
  • Print
    one line per page, flags first

Simple before fast: the loop over the links is sequential, and making it concurrent is the first exercise at the end.

Nothing beyond Playwright: everything else in the file is urllib.parse, argparse and dataclasses.

How the scanner runs one pass over the page, then one visit per link

for each link the argumentsa URL and --max a browserand the starting page every link on that pageabsolute, same host, deduped one visit per linkstatus, title, description one PageReport each one printed line each

The first pass runs once. Parse the arguments, launch Chromium, open the starting page, and read every <a href> out of the rendered DOM. urljoin makes each one absolute, urlparse throws away anything on another host, and dict.fromkeys removes the duplicates while keeping the order.

The middle block runs once per link. page.goto hands back a response, which is where the status comes from; page.title() and one page.evaluate supply the other two fields. Each visit produces one PageReport.

The last line runs once again. The reports are printed in the order they were collected, flags first, so the output can be filtered with grep.

scanner.py the whole tool, in one file

import argparse
import asyncio
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse

from playwright.async_api import async_playwright


@dataclass
class PageReport:
    url: str
    status: int | None
    title: str | None
    has_description: bool
async def scan_page(page, url: str) -> PageReport:
    response = await page.goto(url, wait_until="domcontentloaded")
    status = response.status if response else None
    title = await page.title()
    has_desc = await page.evaluate(
        "() => !!document.querySelector('meta[name=\"description\"]')"
    )
    return PageReport(url=url, status=status, title=title or None,
                      has_description=has_desc)
async def collect_links(page, base_url: str) -> list[str]:
    hrefs = await page.evaluate(
        "() => Array.from(document.querySelectorAll('a[href]'))"
        "       .map(a => a.href)"
    )
    origin = urlparse(base_url).netloc
    return [urljoin(base_url, h) for h in hrefs
            if urlparse(h).netloc == origin]


async def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("url")
    parser.add_argument("--max", type=int, default=20)
    args = parser.parse_args()

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        await page.goto(args.url, wait_until="domcontentloaded")
        links = await collect_links(page, args.url)
        links = list(dict.fromkeys(links))[: args.max]

        reports = [await scan_page(page, url) for url in links]
        await browser.close()

    for r in reports:
        flags = []
        if r.status is None or r.status >= 400:
            flags.append(f"status={r.status}")
        if not r.title:
            flags.append("no-title")
        if not r.has_description:
            flags.append("no-description")
        marker = " ".join(flags) if flags else "ok"
        print(f"{marker:<40} {r.url}")


if __name__ == "__main__":
    asyncio.run(main())

The whole file is built out of what the last four parts put on the slides. A dataclass for the report row, type hints for documentation, async/await for the I/O, urljoin and urlparse for the URL surgery, argparse for the CLI. About 55 lines, end to end.

example

Running it pipe it into grep for the interesting cases

python scanner.py https://example.com --max 10

The output is grep-friendly: one line per page, flags first and the URL last, so grep -v '^ok' leaves only the pages with a problem.

ok                                       https://example.com/
no-description                           https://example.com/about
status=404 no-title                      https://example.com/oops

Wrap-up

principle

A small script you understand beats a framework you do not

Fifty lines you can read end to end are worth more than five hundred you cannot. The bar for a real tool is far lower than the ecosystem suggests: the standard library, one dependency, type hints and asyncio.run is already a real tool.

exercise

Extend the scanner pick one, or two if you are bored

Each extension is ten to thirty extra lines, and each uses only what we covered today plus one standard-library module you have not touched yet.

  • Concurrencyreplace the sequential loop with asyncio.gather over scan_page, behind a semaphore that caps it at five. Time both versions against one site
  • One hop outwardsadd --external and follow links off the host, politely: one request per host per second, tracked in a dict of host to timestamp
  • A CSV reportadd --out report.csv and use the csv module; the field names come from dataclasses.fields(PageReport)
  • Broken imagescollect <img src> as well, fetch each one, and flag anything that is not a 2xx. data: and blob: URLs are fine as they are

That is the whole tool

questions, and then the terminal

The tool is one file you can read in five minutes. The next thing to read is the Playwright Python guide.