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
One session, one file, a tool you keep
Prof. Dr. Dominik Herrmann
PSI-Sem-B · PSI-Sem-M
SoSe 2026
Nine short parts: the last three build the tool, and the six before them are the pieces it is made of.
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.
<title> came back emptymeta description tag in the headMost 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.
python3 --version in a terminal has to answer, and the answer has to start with a 3.11 or betterfor should be familiar ideas, even if the Python spelling is notPrior 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.
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.
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.
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.
PATHActivation 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.
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.
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.
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.
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
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)
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.
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.
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:.
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.
Five modules – pathlib, urllib.parse, re, dataclasses, argparse –
and three of them end up in the scanner.
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.
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.
Do not parse a URL with a regex. urllib.parse already knows about
schemes, userinfo, punycode hosts, default ports and path normalisation.
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.
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.
@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'...)
--help you never wrote, in three linesargparse 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.
The scanner spends its time waiting for somebody else.
A network call spends nearly all of its time waiting, so of them run one after another cost and the same started together cost only the longest of them.
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.
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.
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.
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 second.
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.
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.
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 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.
page.evaluate bridges Python and JSpage.evaluate runs JavaScript inside the page and returns the result as
plain Python data. Anything JSON-serialisable crosses the boundary.
hrefs = await page.evaluate("""
() => Array.from(document.querySelectorAll('a[href]'))
.map(a => a.href)
""")
hrefs comes back as a Python list of strings, already absolute. The
browser resolves a relative <a href> against the current page URL before it
hands them over.
scanner.py is a single file, under eighty lines, and it does four things.
--maxSimple 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.
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 fileimport 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.
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
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.
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.
asyncio.gather over scan_page, behind a semaphore that caps it at five. Time both versions against one site--external and follow links off the host, politely: one request per host per second, tracked in a dict of host to timestamp--out report.csv and use the csv module; the field names come from dataclasses.fields(PageReport)<img src> as well, fetch each one, and flag anything that is not a 2xx. data: and blob: URLs are fine as they arequestions, 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.