From 60a75b8774a66ea661ab1ecbcb1ad758ef8b0b7d Mon Sep 17 00:00:00 2001 From: Jay Baywatch Date: Thu, 29 Oct 2026 20:23:56 -0400 Subject: [PATCH 1/5] Add pluggable fetchers with headless-browser support for SPAs Introduce a Fetcher abstraction so the transport step of a crawl can be swapped without touching robots.txt handling, rate limiting, retries, caching, or link parsing: - HttpFetcher (default) reproduces the existing aiohttp behaviour. - BrowserFetcher renders pages in headless Chromium via Playwright (optional `browser` extra; imported lazily). - RoutingFetcher fetches over HTTP first and escalates to the browser when the response looks like an un-rendered SPA shell, or when the URL matches render_patterns. Spider and Crawler accept fetcher= and keep_hash_routes= (preserve "#/..." fragments for hash-router apps). Document.fetcher records which transport produced a page and is exported by the serializers. Cache entries for non-default fetchers are namespaced so legacy caches keep working. Memory: Spider(flush_to="crawl.jsonl") streams each page to disk as it is crawled, and retain_documents controls whether Documents stay in memory. Streaming callbacks now release documents as the docs always claimed. Also close three documentation/code gaps: add Spider.run(), retry 5xx responses with backoff, and populate Document.broken_internal_links after the crawl. Docs updated throughout; 53 new tests (175 total), with browser tests running against a local page and skipping when Playwright is absent. --- README.md | 84 +++- docs/api-reference.md | 199 ++++++++- docs/core-concepts.md | 163 ++++++- docs/examples.md | 176 +++++++- docs/getting-started.md | 45 ++ docs/index.md | 2 + docs/troubleshooting.md | 132 +++++- linktrace/Crawler.py | 184 +++++--- linktrace/Serializers.py | 48 +- linktrace/Spider.py | 101 ++++- linktrace/__init__.py | 20 + linktrace/cache.py | 46 +- linktrace/fetchers/__init__.py | 24 + linktrace/fetchers/base.py | 113 +++++ linktrace/fetchers/browser.py | 217 +++++++++ linktrace/fetchers/http.py | 64 +++ linktrace/fetchers/routing.py | 116 +++++ pyproject.toml | 4 + tests/conftest.py | 44 ++ tests/test_browser_fetcher.py | 111 +++++ tests/test_fetchers.py | 447 +++++++++++++++++++ tests/test_rate_limiting_and_broken_links.py | 7 +- tests/test_spider_memory.py | 154 +++++++ uv.lock | 106 ++++- 24 files changed, 2443 insertions(+), 164 deletions(-) create mode 100644 linktrace/fetchers/__init__.py create mode 100644 linktrace/fetchers/base.py create mode 100644 linktrace/fetchers/browser.py create mode 100644 linktrace/fetchers/http.py create mode 100644 linktrace/fetchers/routing.py create mode 100644 tests/test_browser_fetcher.py create mode 100644 tests/test_fetchers.py create mode 100644 tests/test_spider_memory.py diff --git a/README.md b/README.md index 5c53fc3..5764e0a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ LinkTrace is a document-oriented crawler. Every crawled page becomes a rich Docu - 🎯 **URL filtering rules** β€” Include/exclude by regex, path prefix, extension, query param, or domain - 🎚️ **Configurable concurrency** β€” Tune `max_concurrency` and connection-pool limits for throughput - πŸ—ΊοΈ **Sitemap discovery** β€” Seed from `sitemap.xml` / robots.txt `Sitemap:`, including nested indexes +- 🧭 **Pluggable fetchers** β€” Plain HTTP by default; optional headless-browser rendering (Playwright) for JavaScript SPAs, or route per URL with automatic escalation - πŸ“„ **Rich document model** β€” Full HTML source, parsed links, metadata, headers - πŸ”„ **Persistent sessions** β€” Connection pooling for 10-100x faster same-domain crawls - πŸ” **Retries + backoff** β€” Exponential backoff for transient errors (timeouts, 5xx) @@ -32,6 +33,7 @@ LinkTrace is a document-oriented crawler. Every crawled page becomes a rich Docu - πŸ”€ **Traversal strategies** β€” BFS (broad) or DFS (deep) crawling - πŸ“Š **Multi-format export** β€” JSON, Pandas, Polars, PyArrow for data analysis - πŸ“ **Callbacks & streaming** β€” Process results as crawled without memory buildup +- πŸ—‚οΈ **Flush to disk** β€” `flush_to="crawl.jsonl"` streams every page to a JSONL file for crawls too big for RAM ## Quick Start @@ -63,6 +65,12 @@ pip install linktrace[serializers] # pandas + polars + pyarrow pip install linktrace[pandas] # Just pandas ``` +**Optional JavaScript rendering (SPAs):** +```bash +pip install linktrace[browser] # Playwright +playwright install chromium # one-time browser download +``` + ## Core Concepts ### Spider @@ -86,6 +94,9 @@ Declarative include/exclude policy for which discovered URLs the Spider follows ### SitemapParser Fetches and parses XML sitemaps (urlsets and nested indexes). Used when `Spider(use_sitemaps=True)`, or directly for sitemap inspection. +### Fetchers +The pluggable "get me the page" step. `HttpFetcher` (default) uses aiohttp; `BrowserFetcher` renders pages in headless Chromium; `RoutingFetcher` tries HTTP first and escalates to the browser only for pages that look like un-rendered SPA shells. Passed as `Spider(fetcher=...)`. + See [Core Concepts](https://github.com/JayBaywatch/linktrace/blob/main/docs/core-concepts.md) for more. ## Configuration @@ -183,6 +194,34 @@ Sitemap discovery is best-effort β€” if none exists, ordinary link-following sti runs. You can also inspect sitemaps directly via `SitemapParser` or `Crawler.discover_sitemap_urls(base_url)`. +### JavaScript-Rendered Sites (SPAs) + +Plain HTTP only sees the empty shell of a React/Vue/Angular app. Swap the +fetcher to render pages in a headless browser (requires `linktrace[browser]`): + +```python +from linktrace import Spider, BrowserFetcher, RoutingFetcher + +# Render everything in headless Chromium +spider = Spider( + start_url="https://app.example.com/", + fetcher=BrowserFetcher(wait_until="networkidle", max_pages=4), +) + +# Cheaper: plain HTTP first, browser only for pages that look like SPA shells +spider = Spider( + start_url="https://app.example.com/", + fetcher=RoutingFetcher(), +) +documents = await spider.run_async() +print({doc.url: doc.fetcher for doc in documents}) # "http" or "browser" +``` + +`RoutingFetcher` also takes `render_patterns=[r"/search"]` to send matching +URLs straight to the browser. Robots.txt, rate limiting, retries and caching +apply identically whichever fetcher is used. For hash-router apps +(`/#/listings/42`) add `keep_hash_routes=True` so each route is its own URL. + ### SSL & Corporate Proxies ```python @@ -243,9 +282,27 @@ results = await spider.run_async() # Returns list of URLs **Return Logic:** - No callback β†’ returns all documents (default) -- Callback + `accumulate_results=False` β†’ returns [] (streaming mode) +- Callback + `accumulate_results=False` β†’ returns [] (streaming mode; documents are not retained) - Callback + `accumulate_results=True` β†’ returns callback results +### Large Crawls: Flush to Disk + +For crawls that won't fit in memory, stream each page to a JSONL file as soon +as it's crawled. Nothing is retained in RAM beyond the URL bookkeeping: + +```python +spider = Spider( + start_url="https://example.com", + max_depth=5, + flush_to="crawl.jsonl", # one JSON object per line, same schema as to_json() + flush_html=False, # True to include page source +) +await spider.run_async() # returns [] +``` + +Streaming and flushing default to `retain_documents=False`; pass +`retain_documents=True` to keep the `Document` list as well. + ### Traversal Strategies **BFS (Breadth-First) β€” Default** @@ -305,6 +362,11 @@ for doc in error_pages: # Monitor disallowed pages (403 from robots.txt) disallowed = [doc for doc in documents if doc.status_code == 403] print(f"Disallowed by robots.txt: {len(disallowed)} pages") + +# Which pages link to broken internal pages (populated after the crawl) +for doc in documents: + for broken in doc.broken_internal_links: + print(f"{doc.url} -> {broken.url} (HTTP {broken.status_code})") ``` Stream crawl status in real-time: @@ -388,6 +450,9 @@ requests, and consider adding a `request_delay`. Defaults are conservative. ### "Crawler hits timeout on deep sites" Try DFS traversal instead of BFS, or increase `request_timeout`. +### "No links found on a JavaScript app" +Use `fetcher=RoutingFetcher()` or `fetcher=BrowserFetcher()` (requires `pip install linktrace[browser]` and `playwright install chromium`). + See [Troubleshooting](https://github.com/JayBaywatch/linktrace/blob/main/docs/troubleshooting.md) for more. ## Performance @@ -403,14 +468,17 @@ With persistent sessions + connection pooling, same-domain requests are 10-100x ``` Spider (orchestrator) - └─ Crawler (persistent session) - β”œβ”€ aiohttp (HTTP requests + connection pooling) + └─ Crawler (robots.txt, rate limiting, retries, cache, parsing) + β”œβ”€ Fetcher (pluggable transport) + β”‚ β”œβ”€ HttpFetcher β€” aiohttp (default) + β”‚ β”œβ”€ BrowserFetcher β€” Playwright / headless Chromium + β”‚ └─ RoutingFetcher β€” HTTP first, browser on escalation + β”œβ”€ aiohttp session (connection pooling, cookies, SSL; also robots + sitemaps) β”œβ”€ lxml (HTML parsing) - β”œβ”€ ResponseCache (optional disk caching) - └─ CookieJar (automatic cookie handling) + └─ ResponseCache (optional disk caching) ``` -Spider manages the crawl queue and traversal. Crawler handles individual document fetching/parsing. All requests share one persistent aiohttp session per Spider instance, so connection pooling, cookies, SSL configuration, and DNS caching are reused across the crawl. +Spider manages the crawl queue and traversal. Crawler handles individual document fetching/parsing and delegates the transport to a Fetcher. All HTTP requests share one persistent aiohttp session per Spider instance, so connection pooling, cookies, SSL configuration, and DNS caching are reused across the crawl. ## Why linktrace? @@ -428,7 +496,7 @@ That makes `linktrace` useful when your goal is to inspect site structure, trace **vs requests + BeautifulSoup:** Built-in async concurrency, automatic session reuse, retries, caching, rate limiting, and structured document objects. Better for crawling multiple pages. -**vs Selenium:** Pure HTTP crawler (no JS execution). Faster, lighter, but can't handle dynamic sites. +**vs Selenium / bare Playwright:** HTTP by default (fast, light), with an optional headless-browser fetcher for JavaScript-rendered sites. You get rendering *plus* the crawl machinery (queue, robots.txt, rate limiting, retries, caching, exports) instead of scripting a browser by hand. ## Testing @@ -437,7 +505,7 @@ just test # Run all tests just test-cov # Run with coverage report ``` -All 122 tests pass. 100% of core crawling paths tested (rate limiting, broken link tracking, robots.txt, callbacks, URL filtering rules, sitemap discovery). +All 175 tests pass. 100% of core crawling paths tested (rate limiting, broken link tracking, robots.txt, callbacks, URL filtering rules, sitemap discovery, fetchers, flushing). Browser tests run against a local page in headless Chromium and skip automatically when Playwright is not installed. ## Contributing diff --git a/docs/api-reference.md b/docs/api-reference.md index ca910e3..af78c99 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -30,7 +30,12 @@ Spider( max_connections: int = 100, max_connections_per_host: int = 10, rules: CrawlRules = None, - use_sitemaps: bool = False + use_sitemaps: bool = False, + fetcher: Fetcher = None, + keep_hash_routes: bool = False, + flush_to: str = None, + flush_html: bool = False, + retain_documents: bool = None ) ``` @@ -61,6 +66,11 @@ Spider( | `max_connections_per_host` | int | 10 | Max simultaneous connections to a single host | | `rules` | CrawlRules | None | URL include/exclude policy for discovered links (None = allow all) | | `use_sitemaps` | bool | False | Seed the queue from the site's sitemaps before link-following | +| `fetcher` | Fetcher | None | Transport used to fetch pages (None = `HttpFetcher()`). See [Fetchers](#fetchers) | +| `keep_hash_routes` | bool | False | Keep `#/...` and `#!...` fragments as distinct URLs (hash-router SPAs) | +| `flush_to` | str | None | Append each crawled page as one JSON line to this file as soon as it is crawled | +| `flush_html` | bool | False | Include page source in flushed lines | +| `retain_documents` | bool | None | Keep `Document` objects in memory. Defaults to False when `flush_to` is set or a callback is used without `accumulate_results`, else True | **Raises:** `ValueError` if `max_concurrency < 1`. @@ -82,7 +92,8 @@ documents = await spider.run_async() #### `run() -> List[Document]` -Crawl synchronously (blocking). Use `asyncio.run()` wrapper for async context. +Crawl synchronously (blocking). A convenience wrapper around `run_async()` for +scripts. ```python spider = Spider(start_url="https://example.com") @@ -91,6 +102,9 @@ documents = spider.run() # Blocks until complete **Returns:** List of Document objects +**Raises:** `RuntimeError` if called from inside a running event loop (Jupyter, +an async application). Use `await spider.run_async()` there. + ### Attributes | Attribute | Type | Description | @@ -115,6 +129,10 @@ documents = spider.run() # Blocks until complete | `max_connections_per_host` | int | Max connections to a single host | | `rules` | CrawlRules | URL filtering policy (always a CrawlRules; empty allows all) | | `use_sitemaps` | bool | Whether the queue is seeded from sitemaps | +| `fetcher` | Fetcher \| None | Fetcher passed to the Crawler (None = default HTTP) | +| `keep_hash_routes` | bool | Whether hash-router fragments are preserved | +| `flush_to` | str \| None | JSONL path pages are streamed to | +| `retain_documents` | bool | Whether Documents are kept in memory (resolved from the constructor args) | ### Callback Signature @@ -140,11 +158,15 @@ async def on_crawl_complete() -> None: ### Return Behavior -| Callback | `accumulate_results` | Return Value | -|----------|----------------------|--------------| -| None | Any | List of all Documents | -| Provided | False | Empty list [] | -| Provided | True | List of accumulated callback returns | +| Callback | `accumulate_results` | Return Value | Documents retained? | +|----------|----------------------|--------------|---------------------| +| None | Any | List of all Documents | Yes | +| Provided | False | Empty list [] | No (streaming) | +| Provided | True | List of accumulated callback returns | Yes | + +`flush_to` returns `[]` and retains nothing unless `retain_documents=True`. +When documents are not retained, `source` and `dom` are cleared after the +callback runs and `broken_internal_links` is not computed. --- @@ -168,7 +190,9 @@ Crawler( user_agent: str = "linktrace/0.1.0", respect_robots_txt: bool = True, max_connections: int = 100, - max_connections_per_host: int = 10 + max_connections_per_host: int = 10, + fetcher: Fetcher = None, + keep_hash_routes: bool = False ) ``` @@ -182,12 +206,15 @@ Same as Spider, plus: | `backoff_factor` | int | 2 | Exponential backoff multiplier (wait_time = 2^attempt * factor) | | `max_connections` | int | 100 | Total size of the aiohttp connection pool | | `max_connections_per_host` | int | 10 | Max simultaneous connections to a single host | +| `fetcher` | Fetcher | None | Transport used to fetch pages (None = `HttpFetcher()`) | +| `keep_hash_routes` | bool | False | Keep `#/...` and `#!...` fragments as distinct URLs | ### Methods #### `async __aenter__() -> Crawler` -Enter async context manager. Creates persistent aiohttp session. +Enter async context manager. Creates the persistent aiohttp session, then +calls `fetcher.open(FetchContext)`. ```python async with Crawler(...) as crawler: @@ -196,7 +223,7 @@ async with Crawler(...) as crawler: #### `async __aexit__(exc_type, exc_val, exc_tb)` -Exit async context manager. Closes session. +Exit async context manager. Calls `fetcher.close()`, then closes the session. #### `async crawl_document_async(url: str) -> Document` @@ -216,9 +243,10 @@ async with Crawler() as crawler: **Notes:** - Checks cache first if `cache_dir` configured -- Retries transient errors up to `max_retries` times -- Returns Document even on 4xx/5xx (check `status_code`) +- Retries `TransientFetchError` and 5xx responses up to `max_retries` times with exponential backoff +- Returns Document even on 4xx/5xx (check `status_code`); returns `None` when every attempt failed with an error - Automatic cookie handling +- `Document.fetcher` names the fetcher that produced the page #### `async discover_sitemap_urls(base_url: str) -> List[str]` @@ -238,6 +266,8 @@ async with Crawler() as crawler: | Attribute | Type | Description | |-----------|------|-------------| | `session` | aiohttp.ClientSession | HTTP session (None until context entered) | +| `fetcher` | Fetcher | The fetcher in use (an `HttpFetcher` by default) | +| `keep_hash_routes` | bool | Whether hash-router fragments are preserved | | `cache` | ResponseCache | Cache object (None if cache_dir not set) | | `ssl_verify` | bool\|str | SSL verification setting | | `verify_hostname` | bool | Hostname verification setting | @@ -249,6 +279,125 @@ async with Crawler() as crawler: --- +## Fetchers + +The pluggable transport used by the Crawler. All fetchers live in +`linktrace.fetchers` and are re-exported from `linktrace`. + +### Fetcher (base class) + +```python +class Fetcher(ABC): + name: str = "custom" + async def open(self, ctx: FetchContext) -> None: ... # optional + async def close(self) -> None: ... # optional + async def fetch(self, url: str, headers: dict[str, str]) -> FetchResult: ... +``` + +Subclass it and implement `fetch`. `open`/`close` are called once per crawl +by the Crawler's context manager. Raise `TransientFetchError` for failures the +Crawler should retry and `FetchError` for those it should not. `name` is +recorded on `Document.fetcher` and namespaces cache entries. + +### FetchResult + +```python +FetchResult(url: str, status: int, headers: dict = {}, body: str | None = None, fetcher: str = "http") +``` + +`body` should be `None` for non-200 responses. + +### FetchContext + +Passed to `Fetcher.open`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `session` | aiohttp.ClientSession | The Crawler's shared session (pooling, cookies, SSL) | +| `request_timeout` | float | Per-request timeout in seconds | +| `user_agent` | str | User-Agent for the crawl | +| `ssl_verify` | bool \| str | The Crawler's SSL setting | + +### HttpFetcher + +```python +HttpFetcher(session: aiohttp.ClientSession = None) +``` + +The default. One `GET` per URL through the Crawler's session (or the one +supplied). `name = "http"`. + +### BrowserFetcher + +```python +BrowserFetcher( + browser: "chromium" | "firefox" | "webkit" = "chromium", + headless: bool = True, + wait_until: "commit" | "domcontentloaded" | "load" | "networkidle" = "networkidle", + wait_for_selector: str = None, + settle_ms: int = 0, + max_pages: int = 4, + timeout: float = None, + block_resources: Iterable[str] = ("image", "media", "font"), + viewport: dict[str, int] = None, + launch_args: list[str] = None, + ignore_https_errors: bool = None, +) +``` + +Renders pages with Playwright. Requires `pip install linktrace[browser]` and +`playwright install chromium`. `name = "browser"`. + +| Parameter | Description | +|-----------|-------------| +| `wait_until` | When navigation counts as done. `"networkidle"` suits SPAs; use `"domcontentloaded"` + `wait_for_selector` for long-polling sites | +| `wait_for_selector` | CSS selector to wait for after navigation (e.g. `"a[href]"`) | +| `settle_ms` | Extra fixed wait after the above | +| `max_pages` | Max concurrent tabs, independent of the Spider's `max_concurrency` | +| `timeout` | Navigation timeout in seconds (defaults to the Crawler's `request_timeout`) | +| `block_resources` | Playwright resource types to abort; `()` loads everything | +| `ignore_https_errors` | Defaults to `True` only when the Crawler has `ssl_verify=False` | + +**Raises:** `ImportError` from `open()` if Playwright is missing; +`RuntimeError` if the browser binary is not installed. Navigation timeouts are +raised as `TransientFetchError` (retried); other Playwright errors as +`FetchError`. + +### RoutingFetcher + +```python +RoutingFetcher( + default: Fetcher = None, # HttpFetcher() + fallback: Fetcher = None, # BrowserFetcher() + render_patterns: Iterable[str] = (), + route: Callable[[str], bool] = None, + escalate: Callable[[FetchResult], bool] = looks_like_spa_shell, +) +``` + +Fetches with `default`, then re-fetches with `fallback` if `escalate(result)` +is true. URLs matching `render_patterns` or for which `route(url)` is true skip +`default`. `escalate=None` disables escalation. The `escalations` attribute +counts re-fetches. `name = "routing"`. + +#### `needs_render(url: str) -> bool` + +True if `url` is routed straight to `fallback`. + +### `looks_like_spa_shell(result: FetchResult) -> bool` + +Cheap regex heuristic for un-rendered SPA shells: a 200 body with a +`