Skip to content

Cookbook

These patterns are small, complete crawl building blocks. They use Ladon's current Source → Expander → Sink contracts: a source discovers root Refs, each expander returns an Expansion, and the sink turns leaf refs into records. run_plugin() drives the source and each discovered root; use the single-root run_crawl() only when your caller deliberately owns dispatch. Replace the parsing and URLs with those for the site you are allowed to crawl.

Flat crawl: one listing page to item records

Use one expander when every page in a paginated listing contains the leaves. Have your Source.discover() return one Ref per page, then call run_plugin() once. This example uses a public GitHub API listing page; the page value is context you can use in logs or persistence.

This example and the Hacker News tree crawl below use live external services, so they are verified by a weekly scheduled GitHub Actions check rather than the offline pytest suite.

import json
from collections.abc import Sequence
from dataclasses import dataclass

from ladon import (
    ChildListUnavailableError,
    Expansion,
    HttpClient,
    HttpClientConfig,
    LeafUnavailableError,
    PluginRunResult,
    Ref,
    RunConfig,
    SyncHttpClientProtocol,
    run_plugin,
)


class GitHubIssuesSource:
    def discover(self, client: SyncHttpClientProtocol) -> Sequence[Ref]:
        return [
            Ref(
                "https://api.github.com/repos/psf/requests/issues?per_page=5"
                f"&page={page}",
                {"page": page},
            )
            for page in (1, 2)
        ]


class IssueList:
    def expand(self, ref: Ref, client: SyncHttpClientProtocol) -> Expansion:
        response = client.get(ref.url)
        if not response.ok or response.value is None:
            raise ChildListUnavailableError(f"listing failed: {response.error}")
        issues = json.loads(response.value)
        return Expansion(
            {"page": ref.raw["page"]}, [Ref(issue["url"]) for issue in issues]
        )


class IssueSink:
    def consume(
        self, ref: Ref, client: SyncHttpClientProtocol
    ) -> dict[str, object]:
        response = client.get(ref.url)
        if not response.ok or response.value is None:
            raise LeafUnavailableError(f"issue failed: {response.error}")
        issue = json.loads(response.value)
        return {"number": issue["number"], "title": issue["title"]}


@dataclass(frozen=True)
class GitHubIssuesPlugin:
    name: str = "github-issues"
    source: GitHubIssuesSource = GitHubIssuesSource()
    expanders: Sequence[IssueList] = (IssueList(),)
    sink: IssueSink = IssueSink()


def run_example() -> PluginRunResult:
    plugin = GitHubIssuesPlugin()
    with HttpClient(
        HttpClientConfig(user_agent="example-crawler/1.0")
    ) as client:
        result = run_plugin(plugin, client, RunConfig())
        print(result.leaves_consumed, result.errors)
        return result


if __name__ == "__main__":
    run_example()

Multi-level tree crawl: Hacker News

The ladon-hackernews adapter is a working example of a front page → story → comment tree. Its HNSource discovers top-story refs, HNExpander fetches one story and emits direct comment refs, and HNSink fetches each comment. Install it with pip install ladon-hackernews before running this example.

from operator import attrgetter

from ladon_hackernews import HNPlugin

from ladon import (
    ExpansionNotReadyError,
    HttpClient,
    HttpClientConfig,
    RunConfig,
    RunResult,
    run_crawl,
)


def print_comment(comment: object, story: object) -> None:
    # Full precision requires ladon-hackernews to narrow its object signatures.
    get_id = attrgetter("id")
    print(get_id(story), get_id(comment))


def run_example() -> list[RunResult]:
    plugin = HNPlugin(top=10)
    results: list[RunResult] = []
    with HttpClient(
        HttpClientConfig(user_agent="my-hn-research-bot/1.0")
    ) as client:
        for story_ref in plugin.source.discover(client):
            try:
                result = run_crawl(
                    story_ref,
                    plugin,
                    client,
                    RunConfig(leaf_limit=50),
                    on_leaf=print_comment,
                )
            except ExpansionNotReadyError:
                continue  # Rediscover this story on the next scheduled run.
            results.append(result)
            print(result.leaves_consumed, result.leaves_failed, result.errors)
    return results


if __name__ == "__main__":
    run_example()

The adapter has one configured expander because the source is outside the runner: HNSource covers the front-page → story edge, and HNExpander covers the story → comment edge.

Carry listing context in ref.raw

When a listing already contains fields the sink needs, put them in the child ref rather than requesting the item page again. Ref is frozen, so construct a new ref with its raw mapping in the expander.

import json
from collections.abc import Sequence
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import cast

from ladon import (
    ChildListUnavailableError,
    CrawlPlugin,
    Expansion,
    HttpClient,
    HttpClientConfig,
    LeafUnavailableError,
    Ref,
    RunConfig,
    RunResult,
    SyncHttpClientProtocol,
    run_crawl,
)


class ProductHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:  # noqa: N802
        if self.path != "/products":
            self.send_error(404)
            return
        host, port = cast(tuple[str, int], self.server.server_address)
        products = {
            "products": [
                {
                    "url": f"http://{host}:{port}/product/tea",
                    "sku": "TEA-1",
                    "price": 7,
                },
                {
                    "url": f"http://{host}:{port}/product/mug",
                    "sku": "MUG-2",
                    "price": 12,
                },
            ]
        }
        body = json.dumps(products).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        pass


def build_mock_server() -> tuple[ThreadingHTTPServer, Thread]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), ProductHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, thread


class ProductList:
    def expand(self, ref: Ref, client: SyncHttpClientProtocol) -> Expansion:
        response = client.get(ref.url)
        if not response.ok or response.value is None:
            raise ChildListUnavailableError(f"listing failed: {response.error}")
        products = json.loads(response.value)["products"]
        children = [
            Ref(
                product["url"],
                raw={"sku": product["sku"], "price": product["price"]},
            )
            for product in products
        ]
        return Expansion(record={"category": ref.url}, child_refs=children)


class ProductSink:
    def consume(
        self, ref: Ref, client: SyncHttpClientProtocol
    ) -> dict[str, object]:
        if "sku" not in ref.raw:
            raise LeafUnavailableError("listing did not provide a SKU")
        return {"sku": ref.raw["sku"], "price": ref.raw["price"]}


class ProductSource:
    def discover(self, client: SyncHttpClientProtocol) -> Sequence[Ref]:
        return ()


@dataclass(frozen=True)
class ProductPlugin:
    name: str = "local-products"
    source: ProductSource = ProductSource()
    expanders: tuple[ProductList, ...] = (ProductList(),)
    sink: ProductSink = ProductSink()


def run_example() -> RunResult:
    server, thread = build_mock_server()
    host, port = cast(tuple[str, int], server.server_address)
    try:
        with HttpClient(
            HttpClientConfig(user_agent="example-crawler/1.0")
        ) as client:
            result = run_crawl(
                Ref(f"http://{host}:{port}/products"),
                cast("CrawlPlugin", ProductPlugin()),
                client,
                RunConfig(),
            )
        print(result.leaves_consumed, result.errors)
        return result
    finally:
        server.shutdown()
        server.server_close()
        thread.join()


if __name__ == "__main__":
    run_example()

This sink deliberately makes no HTTP request: its record is built from data the expander already fetched. The Hacker News adapter uses the same technique to carry story_id from HNExpander to HNSink.

Resume a not-ready or partial crawl

Catch runner-level expansion errors at the scheduling boundary. An ExpansionNotReadyError always propagates: do not retry it in the same run. A PartialExpansionError also propagates when the first expander raises it; at later levels the runner skips only that branch and adds its message to RunResult.errors.

import json
from collections.abc import Sequence
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import cast
from urllib.parse import parse_qs, urlparse

from ladon import (
    ChildListUnavailableError,
    CrawlPlugin,
    Expansion,
    ExpansionNotReadyError,
    HttpClient,
    HttpClientConfig,
    LeafUnavailableError,
    PartialExpansionError,
    Ref,
    RunConfig,
    RunResult,
    SyncHttpClientProtocol,
    run_crawl,
)


class CrawlStateHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        query = parse_qs(parsed.query)
        host, port = cast(tuple[str, int], self.server.server_address)
        base_url = f"http://{host}:{port}"
        if parsed.path == "/top":
            mode = query.get("mode", ["complete"])[0]
            body = {"status": mode, "categories": ["tea", "mugs"]}
        elif parsed.path == "/category/tea":
            body = {"status": "complete", "items": [f"{base_url}/item/tea"]}
        elif parsed.path == "/category/mugs":
            body = {"status": "partial", "items": [f"{base_url}/item/mug"]}
        elif parsed.path.startswith("/item/"):
            body = {"name": parsed.path.rsplit("/", 1)[1]}
        else:
            self.send_error(404)
            return
        encoded = json.dumps(body).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def log_message(self, format: str, *args: object) -> None:
        pass


def build_mock_server() -> tuple[ThreadingHTTPServer, Thread]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), CrawlStateHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, thread


def load_json(ref: Ref, client: SyncHttpClientProtocol) -> dict[str, object]:
    response = client.get(ref.url)
    if not response.ok or response.value is None:
        raise ChildListUnavailableError(f"request failed: {response.error}")
    return json.loads(response.value)


class CategoryList:
    def expand(self, ref: Ref, client: SyncHttpClientProtocol) -> Expansion:
        payload = load_json(ref, client)
        status = cast(str, payload["status"])
        if status == "not-ready":
            raise ExpansionNotReadyError("listing is not published yet")
        if status == "partial":
            raise PartialExpansionError("top-level listing is incomplete")
        return Expansion(
            record={"status": status},
            child_refs=[
                Ref(f"{ref.url.rsplit('/top', 1)[0]}/category/{name}")
                for name in cast(list[str], payload["categories"])
            ],
        )


class ItemList:
    def expand(self, ref: Ref, client: SyncHttpClientProtocol) -> Expansion:
        payload = load_json(ref, client)
        if payload["status"] == "partial":
            raise PartialExpansionError("category still has another page")
        return Expansion(
            record={"category": ref.url},
            child_refs=[Ref(url) for url in cast(list[str], payload["items"])],
        )


class ItemSink:
    def consume(
        self, ref: Ref, client: SyncHttpClientProtocol
    ) -> dict[str, object]:
        response = client.get(ref.url)
        if not response.ok or response.value is None:
            raise LeafUnavailableError(f"item failed: {response.error}")
        return json.loads(response.value)


class LocalSource:
    def discover(self, client: SyncHttpClientProtocol) -> Sequence[Ref]:
        return ()


@dataclass(frozen=True)
class LocalCatalogPlugin:
    name: str = "local-catalog"
    source: LocalSource = LocalSource()
    expanders: tuple[CategoryList, ItemList] = (CategoryList(), ItemList())
    sink: ItemSink = ItemSink()


def crawl_one(
    top_ref: Ref, plugin: LocalCatalogPlugin, client: SyncHttpClientProtocol
) -> RunResult | None:
    try:
        result = run_crawl(
            top_ref, cast("CrawlPlugin", plugin), client, RunConfig()
        )
    except ExpansionNotReadyError as exc:
        print(f"retry next scheduled run: {exc}")
        return None
    except PartialExpansionError as exc:
        print(f"retry after the listing is complete: {exc}")
        return None

    print(f"persisted {result.leaves_persisted} item(s)")
    branch_errors = [
        error for error in result.errors if error.startswith("expander branch")
    ]
    if branch_errors:
        print(f"partial branches: {branch_errors}")
    return result


def run_example() -> RunResult:
    server, thread = build_mock_server()
    host, port = cast(tuple[str, int], server.server_address)
    base_url = f"http://{host}:{port}"
    plugin = LocalCatalogPlugin()
    try:
        with HttpClient(
            HttpClientConfig(user_agent="example-crawler/1.0")
        ) as client:
            crawl_one(Ref(f"{base_url}/top?mode=not-ready"), plugin, client)
            crawl_one(Ref(f"{base_url}/top?mode=partial"), plugin, client)
            result = crawl_one(Ref(f"{base_url}/top"), plugin, client)
        assert result is not None
        return result
    finally:
        server.shutdown()
        server.server_close()
        thread.join()


if __name__ == "__main__":
    run_example()

Leaf failures are different: the runner records LeafUnavailableError and other non-fatal exceptions from Sink.consume() in result.errors, increments leaves_failed, and continues with other leaves. Make persistence idempotent so a scheduled retry can safely revisit successful leaves too. Cancellation and explicitly fatal AssetDownloadError still propagate.

Async leaf processing for high throughput

Implement the async protocols (async def expand and async def consume) and pass an AsyncHttpClientProtocol implementation. Expanders are awaited in tree order; leaf consume() calls run concurrently up to async_concurrency.

import asyncio
import json
from collections.abc import Sequence
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import cast

from ladon import (
    AsyncHttpClient,
    AsyncHttpClientProtocol,
    Expansion,
    HttpClientConfig,
    LeafUnavailableError,
    Ref,
    RunConfig,
    RunResult,
    async_run_crawl,
)


class AsyncCatalogHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:  # noqa: N802
        host, port = cast(tuple[str, int], self.server.server_address)
        if self.path == "/listing":
            body = {
                "items": [
                    f"http://{host}:{port}/item/{number}" for number in range(3)
                ]
            }
        elif self.path.startswith("/item/"):
            body = {"id": int(self.path.rsplit("/", 1)[1])}
        else:
            self.send_error(404)
            return
        encoded = json.dumps(body).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def log_message(self, format: str, *args: object) -> None:
        pass


def build_mock_server() -> tuple[ThreadingHTTPServer, Thread]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), AsyncCatalogHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, thread


class ListingExpander:
    async def expand(
        self, ref: Ref, client: AsyncHttpClientProtocol
    ) -> Expansion:
        response = await client.get(ref.url)
        if not response.ok or response.value is None:
            raise LeafUnavailableError(f"listing failed: {response.error}")
        payload = json.loads(response.value)
        return Expansion(
            record={"listing": ref.url},
            child_refs=[Ref(url) for url in payload["items"]],
        )


class ItemSink:
    async def consume(
        self, ref: Ref, client: AsyncHttpClientProtocol
    ) -> dict[str, object]:
        response = await client.get(ref.url)
        if not response.ok or response.value is None:
            raise LeafUnavailableError(f"item failed: {response.error}")
        return json.loads(response.value)


class LocalSource:
    async def discover(self, client: AsyncHttpClientProtocol) -> Sequence[Ref]:
        return ()


@dataclass(frozen=True)
class AsyncCatalogPlugin:
    name: str = "async-local-catalog"
    source: LocalSource = LocalSource()
    expanders: tuple[ListingExpander, ...] = (ListingExpander(),)
    sink: ItemSink = ItemSink()


async def crawl_one(base_url: str) -> RunResult:
    persisted: list[dict[str, object]] = []

    async def persist(
        leaf_record: dict[str, object], parent_record: object
    ) -> None:
        del parent_record
        persisted.append(leaf_record)

    config = HttpClientConfig(user_agent="my-async-crawler/1.0", retries=0)
    async with AsyncHttpClient(config) as client:
        result = await async_run_crawl(
            top_ref=Ref(f"{base_url}/listing"),
            plugin=AsyncCatalogPlugin(),
            client=client,
            config=RunConfig(leaf_limit=500, async_concurrency=20),
            on_leaf=persist,
        )
    print(result.leaves_consumed, result.leaves_failed, persisted)
    return result


def run_example() -> RunResult:
    server, thread = build_mock_server()
    host, port = cast(tuple[str, int], server.server_address)
    try:
        return asyncio.run(crawl_one(f"http://{host}:{port}"))
    finally:
        server.shutdown()
        server.server_close()
        thread.join()


if __name__ == "__main__":
    run_example()

on_leaf must be an async def callback. Keep its work bounded as well: each concurrency slot spans both sink.consume() and the callback.

Respect robots.txt on a public-web crawl

For third-party public sites, enable robots enforcement on the sync client before discovery. Ladon fetches and caches each origin's robots.txt, blocks disallowed URLs as RobotsBlockedError, and honours Crawl-delay.

client.get() returns a blocked request in response.error; plugins must check for RobotsBlockedError and re-raise it so run_crawl() callers can handle it. See PublicPage.expand() in examples/cookbook/robots_txt.py.

import json
from collections.abc import Sequence
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import cast

from ladon import (
    ChildListUnavailableError,
    CrawlPlugin,
    Expansion,
    HttpClient,
    HttpClientConfig,
    LeafUnavailableError,
    Ref,
    RobotsBlockedError,
    RunConfig,
    RunResult,
    SyncHttpClientProtocol,
    run_crawl,
)


class RobotsHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:  # noqa: N802
        if self.path == "/robots.txt":
            body = b"User-agent: example-crawler\nDisallow: /blocked\n"
        elif self.path == "/allowed":
            body = json.dumps({"item": "/item"}).encode()
        elif self.path == "/item":
            body = json.dumps({"title": "allowed local result"}).encode()
        elif self.path == "/blocked":
            body = b"this response must never be fetched"
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        pass


def build_mock_server() -> tuple[ThreadingHTTPServer, Thread]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), RobotsHandler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, thread


class PublicPageSource:
    def __init__(self, base_url: str) -> None:
        self._base_url = base_url

    def discover(self, client: SyncHttpClientProtocol) -> Sequence[Ref]:
        return [
            Ref(f"{self._base_url}/allowed"),
            Ref(f"{self._base_url}/blocked"),
        ]


class PublicPage:
    def expand(self, ref: Ref, client: SyncHttpClientProtocol) -> Expansion:
        response = client.get(ref.url)
        if isinstance(response.error, RobotsBlockedError):
            raise response.error
        if not response.ok or response.value is None:
            raise ChildListUnavailableError(f"page failed: {response.error}")
        payload = json.loads(response.value)
        return Expansion(
            record={"page": ref.url},
            child_refs=[Ref(f"{ref.url.rsplit('/', 1)[0]}{payload['item']}")],
        )


class PublicPageSink:
    def consume(
        self, ref: Ref, client: SyncHttpClientProtocol
    ) -> dict[str, object]:
        response = client.get(ref.url)
        if not response.ok or response.value is None:
            raise LeafUnavailableError(f"item failed: {response.error}")
        return json.loads(response.value)


@dataclass(frozen=True)
class PublicPagePlugin:
    name: str
    source: PublicPageSource
    expanders: tuple[PublicPage, ...] = (PublicPage(),)
    sink: PublicPageSink = PublicPageSink()


def run_example() -> list[RunResult]:
    server, thread = build_mock_server()
    host, port = cast(tuple[str, int], server.server_address)
    base_url = f"http://{host}:{port}"
    plugin = PublicPagePlugin("robots-local-site", PublicPageSource(base_url))
    results: list[RunResult] = []
    config = HttpClientConfig(
        user_agent="example-crawler/1.0",
        respect_robots_txt=True,
        min_request_interval_seconds=0.0,
    )
    try:
        with HttpClient(config) as client:
            for top_ref in plugin.source.discover(client):
                try:
                    result = run_crawl(
                        top_ref,
                        cast("CrawlPlugin", plugin),
                        client,
                        RunConfig(leaf_limit=100),
                    )
                except RobotsBlockedError as exc:
                    print(f"skipped {top_ref.url}: {exc}")
                    continue
                results.append(result)
                print(f"saved {result.leaves_consumed} result(s)")
        return results
    finally:
        server.shutdown()
        server.server_close()
        thread.join()


if __name__ == "__main__":
    run_example()

AsyncHttpClient currently raises NotImplementedError if respect_robots_txt=True; use the sync client for crawls that require Ladon's built-in robots enforcement.