Python, from zero to a working scanner

Prof. Dr. Dominik Herrmann

A practical introduction to Python

PSI-Sem-B · PSI-Sem-M

SoSe 2026

Welcome

Use a venv always, 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.

What you will build a link-health scanner, under 80 lines

By the end of this session you will have a small command-line tool that visits a URL, follows every link it finds, and prints a short report for each page it touches. Broken links, missing titles, and missing meta descriptions all get flagged on one line each.

The tool is short: under 80 lines of Python. It drives a real Chromium browser under the hood, so it sees JavaScript-rendered pages the way a human does.

We will build it up piece by piece. Each topic in this lecture contributes one or two lines of the final script. By the last slide you will be able to trace every character of the scanner back to something you have already seen.

What you already need three boxes to tick before we start

You bring: Python 3.11 or newer, a terminal you are comfortable in, and roughly three hours of patience. Prior Python experience is not required; prior programming experience in any language is.

You should already know what a variable, a function, and a loop are. The shape of an if and a for should feel familiar even if the syntax does not. If not, pair with someone who does – the pace assumes this baseline.

Setup

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 official script, curl -LsSf https://astral.sh/uv/install.sh | sh.

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 matters. After it, python and pip resolve to the binaries inside .venv/, not the ones on your system.

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

If you cannot install uv, the stdlib has everything you need. Both the venv module and pip ship with Python itself since 3.3 and 3.4 respectively – no extra tool required.

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

The only difference is speed: uv resolves and installs dependencies in parallel and caches aggressively; pip is sequential and cold-caches often. Pick one and stick with it for the rest of the session.

What a venv actually looks like on disk

my-project/ scanner.py your code pyproject.toml project metadata .venv/ the isolated environment bin/python bin/pip lib/python3.12/site-packages/ playwright, urllib3, ... source .venv/bin/activate → `python` resolves to .venv/bin/python

The activation script rewrites your shell's PATH so .venv/bin/ comes first. Deactivating just restores the previous PATH. There is no global state change, no service, no daemon – only a directory.

Python fundamentals

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, not 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.

F-strings self-documenting prints for debugging

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

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

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

The four core collections each one answers a different question

list is an ordered, mutable sequence. Use it when order matters and the contents change: urls = ["a", "b", "c"]. The workhorse for “a bunch of things in a row”.

tuple is an ordered, immutable sequence. Use it for fixed-shape records: (lat, lon), (host, port). Unpacks neatly into multiple names in one line.

dict is a key-value map. {"host": "example.com", "port": 443}. The workhorse for structured records when you have more than three fields and unpacking stops being readable.

set is an unordered, unique bag. Use it for membership checks and deduplicationseen = set(); seen.add(url). The scanner uses one to avoid visiting the same URL twice.

Collection operations boringly similar across all four

Most operations look the same on lists, tuples, dicts, and sets. in tests membership, len() gives size, iteration yields elements (for dicts: keys). The differences are in mutation and 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

Control flow indentation is the block delimiter

Python uses indentation instead of braces. Four spaces per level is the convention, enforced by every editor and lint tool. No end, no }, no semicolons.

status = 404

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

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

The continue and break keywords do what you expect. elif is the Python spelling of else if – no separate keyword.

Functions with type hints optional, but you should add them

Define a function with def, annotate parameters and return type, and you get 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, then keyword arguments. Defaults let callers omit what they don't need. A signature too long for one line wraps, one parameter per line, with a trailing comma – the form every formatter produces.

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

Type hints are documentation that compiles. A linter like ruff or a type checker like mypy reads them and flags mismatches before the code runs. Your future self will thank you.

More Python

Comprehensions one line from an iterable

Comprehensions build a list, dict, or set from an existing iterable in a single expression. They read like “{element} for each item in 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 over a for-loop with .append(). The comprehension form is more compact, slightly faster, and signals intent: “I am building a collection”, not “I am performing side effects”.

Exceptions errors are values you catch and inspect

Exceptions are Python's error channel. When something goes wrong, a function raises an exception; 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 you can inspect it. Drop it when you only care that something failed, not what failed: except ValueError:.

Read tracebacks from the bottom the last line is the failure

The last line of a traceback names the actual failure. Everything above it is the chain of calls that led to that line. Start at the bottom, read one frame up at a time, and stop at the first frame that is your own code.

The standard library

Why lean on the standard library? a dependency is a liability

Every pip install is a future maintenance cost. Transitive deps, security patches, breaking releases – all of it on your plate. The standard library is already there, already audited, already installed with Python itself.

pathlib paths are objects, not strings

pathlib replaces string path manipulation with path objects. The / operator joins segments; methods like .read_text(), .mkdir(parents=True), and .glob() do what their names suggest.

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 comes for free. Path normalizes slashes and drive letters so the same code runs on Linux, macOS, and Windows without os.path.join gymnastics.

urllib.parse URL surgery without regex

Parsing URLs with a regex is almost always a mistake. urllib.parse already knows about schemes, userinfo, punycode hosts, port defaults, and path normalization.

urlparse splits a URL into named parts – scheme, netloc, path, query, fragment – that you can read by attribute.

from urllib.parse import urlparse

u = urlparse("https://example.com/a?x=1")
u.scheme   # "https"
u.netloc   # "example.com"
u.path     # "/a"

urljoin resolves a relative reference against a base URL, exactly the way a browser does when it encounters <a href>.

from urllib.parse import urljoin

urljoin("https://ex.com/a/", "b/c")  # .../a/b/c
urljoin("https://ex.com/a/", "/d")   # .../d

The scanner uses both: urljoin to turn relative links into absolute URLs, and urlparse to check that a link stays on the same host before we visit it.

re just enough regex

Reach for re when pattern matching is the right tool, not before. If you only need “starts with” or “contains”, 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 caches the compiled pattern; calling .match() on the compiled object skips the compile step on every call.

dataclasses classes that are mostly data

@dataclass generates __init__, __repr__, and equality for you from the field annotations. Less boilerplate, fewer bugs in the boilerplate you don't write.

from dataclasses import dataclass

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

The generated __init__ takes each field as a keyword argument. __repr__ prints all fields; equality compares all fields. We will use this exact class for every page the scanner visits.

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

argparse --help for free, in three lines

argparse turns a list of argument descriptions into a full CLI. Help text, type coercion, default values, error messages – all generated from 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 gets a standards-conforming CLI with Unix-style flags and a readable usage block.

Async basics

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

Network calls spend almost all their time waiting. Async lets one thread start many waits and serve whichever one completes first. It does not make CPU-bound code faster – for that, you need processes.

The event loop a scheduler for coroutines

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

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

async and await three waits, one second total

async def defines a coroutine; await suspends it until the awaited operation completes. asyncio.gather starts several coroutines in parallel 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. That is the whole point of async I/O.

One thread, many overlapping waits

async – gather(a, b, c) fetch a fetch b fetch c ≈ 1 s total sync – fetch(a); fetch(b); fetch(c) fetch a fetch b fetch c ≈ 3 s 0 s 1 s 2 s 3 s time →

The same three network calls. The same single thread. The only difference is who gets to run while someone else waits. Synchronous code blocks the thread on every wait; async code releases the thread and lets other coroutines progress.

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. Useful text, links, and titles never arrive.

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 only would miss all of it.

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.

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.

Open a page the smallest useful Playwright script

The pattern is always the same: open a context, launch a browser, navigate, query, close. async with guarantees 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())

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

The scanner

What we are building a single-file CLI, four steps

scanner.py is one file, under 80 lines, and does exactly four things. Take a URL from the command line. Open it with Playwright and grab every <a href>. For each link, open it and record status, title, and whether a meta description exists. Print a one-line-per-page report.

Synchronous loop over pages – simple before fast. We visit one page, then the next, then the next. The exercise at the end asks you to make it concurrent.

No third-party libraries beyond Playwright. Everything else is urllib.parse, argparse, dataclasses – the stdlib modules we just covered.

Scanner pipeline data flows left to right, top to bottom

CLI url, --max page.goto(url) load start page response.status collect_links() evaluate a[href] same host, dedup, ≤max scan_page() goto · title · meta → PageReport report flags + url one line per page loop over links

Two Playwright calls per page. page.goto() returns a response so we can read its status; page.evaluate() runs a JS snippet inside the page for DOM queries. Everything else is plain Python manipulating the resulting strings and objects.

scanner.py everything we covered, 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())

Every line here is something we covered. Dataclasses for the report row. Type hints for documentation. async/await for concurrency-ready I/O. urljoin/urlparse for URL surgery. argparse for the CLI. About 55 lines, end to end.

Running it pipe into grep for the interesting cases

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

The output is deliberately grep-friendly. One line per page, flags first, URL last. Pipe it into grep -v '^ok' to see only the pages that have a problem.

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

Wrap-up

Small scripts beat big frameworks if you understand them end-to-end

A fifty-line script you understand is worth more than a five-hundred-line framework you do not. The whole point of this lecture was that the bar for “real tool” is much lower than the ecosystem suggests. Standard library plus one dependency plus type hints plus asyncio.run – that is a real tool.

Extend the scanner pick one, or two if you're bored

Each extension is ten to thirty extra lines. All of them use only what we covered today, plus one stdlib module you haven't touched yet.

Make it concurrent. Replace the sequential loop with asyncio.gather over scan_page calls, wrapped in a semaphore to cap concurrency at 5. Time both versions against the same site; the async version should win on any page with more than a handful of links.

Follow external links too, one hop deep. Add a --external flag. Be polite: one request per host per second, tracked in a small dict of host -> last_request_time.

Write the report as CSV. Add a --out report.csv option and use the stdlib csv module. Each PageReport becomes one row; field names come from dataclasses.fields(PageReport).

Flag broken images. Collect <img src> in addition to <a href> and fetch each with a HEAD request via httpx (async). Flag any non-2xx. Treat data: and blob: URLs as fine.

overview · drag pans · wheel zooms · selects · click or O/Enter lands · / search · Esc leaves