diff --git a/Makefile b/Makefile
index c8756f8..f234b1c 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0
-.PHONY: install test test-all test-coverage test-coverage-all lint lint-fix typecheck format build publish lock check license-check license bump clean help
+.PHONY: install test test-all test-coverage test-coverage-all lint fix typecheck format build publish lock check license-check license bump clean help
help: ## Display this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@@ -31,7 +31,7 @@ test-coverage-all: ## Run all tests with coverage report
lint: ## Run ruff linter
uv run ruff check .
-lint-fix: ## Run ruff linter and apply fixes
+fix: ## Run ruff linter and apply fixes
uv run ruff check --fix .
typecheck: ## Run type checker
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 9d25610..8ca81d8 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -54,7 +54,7 @@ EOF
Run Rubbernecker to crawl your URLs and save the raw HTML:
```bash
-poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro
+uv run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro
```
You'll see output like:
@@ -78,7 +78,7 @@ INFO:CrawlToolStats(count_input=2, count_output=2, count_error=0) (done)
Extract structured information from the raw HTML:
```bash
-poetry run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
+uv run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
tmp/output-raw.avro tmp/output-parsed.avro
```
@@ -94,7 +94,7 @@ This uses the built-in `StandardPageParser` to extract:
See what you scraped by converting the Avro file to JSON:
```bash
-poetry run avrokit tojson tmp/output-parsed.avro | head -50
+uv run avrokit tojson tmp/output-parsed.avro | head -50
```
You'll see JSON output like:
@@ -114,7 +114,7 @@ You'll see JSON output like:
**Pretty print with jq (optional):**
```bash
-poetry run avrokit tojson tmp/output-parsed.avro | jq '.'
+uv run avrokit tojson tmp/output-parsed.avro | jq '.'
```
## What Next?
@@ -124,7 +124,7 @@ poetry run avrokit tojson tmp/output-parsed.avro | jq '.'
By default, Rubbernecker runs with a visible Chrome window so you can supervise the crawl. For automated/background crawling, enable headless mode:
```bash
-poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro --headless
+uv run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro --headless
```
### Follow Links Automatically
@@ -133,7 +133,7 @@ Crawl deeper by following links (depth-based crawling):
```bash
# Follow links up to 2 levels deep
-poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro --max_depth 2
+uv run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro --max_depth 2
```
**Warning:** This can generate a lot of requests. Start with `--max_depth 1` to test.
@@ -154,18 +154,12 @@ EOF
Then use it during crawling:
```bash
-poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro \
+uv run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro \
--load_actions tmp/actions.txt
```
## Troubleshooting
-### "Chrome connection refused" errors
-
-- Rubbernecker automatically starts Chrome, but if you see this error, another process may be using port 9222
-- Check if port 9222 is in use: `lsof -i :9222`
-- Try a different port: `poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro --chrome_debug_port 9223`
-
### Empty or corrupted output
- URLs might be blocked or require JavaScript
@@ -187,6 +181,7 @@ poetry run rubbernecker crawl tmp/requests.txt tmp/output-raw.avro \
## Learn More
-- Full command reference: [README.md](../README.md)
-- Advanced usage (proxies, custom parsers, action scripts): [README.md](../README.md#advanced-usage)
-- Available parsers and output formats: [README.md](../README.md#output-formats)
+- Full command reference: [README.md](README.md)
+- Advanced crawl usage: [docs/commands/crawl.md](docs/commands/crawl.md)
+- Action scripts: [docs/actions.md](docs/actions.md)
+- Output formats and Avro schemas: [docs/output-formats.md](docs/output-formats.md)
diff --git a/README.md b/README.md
index c92625a..39f5a9a 100644
--- a/README.md
+++ b/README.md
@@ -6,16 +6,9 @@ SPDX-License-Identifier: Apache-2.0
# Rubbernecker
-A powerful web scraping engine built with Python and SeleniumBase that crawls web pages, stores raw HTML, and parses structured data. Rubbernecker supports configurable page actions, depth-based crawling, and proxy integration.
-
-## Overview
-
-Rubbernecker provides four main commands:
-
-- **`chrome`** - Launch a Chrome browser instance with debugging capabilities
-- **`crawl`** - Scrape websites and save raw HTML to Avro files
-- **`parse`** - Extract structured data from crawled HTML
-- **`proxy`** - Run a local proxy server for routing requests
+A web scraping engine built with Python and SeleniumBase that crawls web pages,
+stores raw HTML, and parses structured data. Supports configurable page actions
+and depth-based crawling.
## Installation
@@ -23,12 +16,8 @@ Rubbernecker provides four main commands:
**Python 3.12+**
-Rubbernecker requires Python 3.12 or higher.
-
**Google Chrome**
-Rubbernecker uses SeleniumBase with Chrome for web crawling.
-
_macOS:_
```bash
@@ -54,8 +43,6 @@ sudo apt install -y google-chrome-stable
### Setup
-**Install dependencies and set up environment:**
-
```bash
make install
```
@@ -63,353 +50,27 @@ make install
Or manually:
```bash
-poetry env use python3.12
-poetry install
+uv sync
```
## Quick Start
-See [QUICKSTART.md](QUICKSTART.md) for a step-by-step tutorial to get up and running in minutes.
+See [QUICKSTART.md](QUICKSTART.md) for a step-by-step tutorial.
## Commands
-### `rubbernecker chrome`
-
-Launch a Chrome browser instance with DevTools Protocol enabled.
-
-**Options:**
-
-- `--headless` - Run Chrome in headless mode (no GUI)
-- `--chrome_debug_port PORT` - Port for Chrome DevTools Protocol (default: 9222)
-- `--proxy_server URL` - Route traffic through a proxy server
-
-**Examples:**
+| Command | Description |
+|---|---|
+| [`crawl`](docs/commands/crawl.md) | Scrape websites and save raw HTML to Avro files |
+| [`fetch`](docs/commands/fetch.md) | Download assets from a list of URLs |
+| [`parse`](docs/commands/parse.md) | Extract structured data from crawled HTML |
+| [`sitemap`](docs/commands/sitemap.md) | Discover page URLs from sitemaps or robots.txt |
-```bash
-# Launch Chrome with visual interface
-poetry run rubbernecker chrome
-
-# Launch headless Chrome on custom port
-poetry run rubbernecker chrome --headless --chrome_debug_port 9223
-
-# Launch Chrome through a proxy
-poetry run rubbernecker chrome --proxy_server "http://localhost:3128"
-```
-
-### `rubbernecker crawl`
-
-Crawl web pages and save raw HTML to Avro files.
-
-**Syntax:**
-
-```bash
-rubbernecker crawl [OPTIONS] INPUT_URL OUTPUT_URL
-```
-
-**Arguments:**
-
-- `INPUT_URL` - File containing URLs to crawl (text, JSON, or Avro format)
-- `OUTPUT_URL` - Path where crawled data will be saved (Avro format)
-
-**Key Options:**
-
-- `--input_format FORMAT` - Input file format: TEXT, JSON, or AVRO
-- `--chrome_debug_port PORT` - Connect to Chrome on this port (default: 9222)
-- `--max_depth N` - Maximum crawl depth for following links (default: 0)
-- `--max_retries N` - Retry failed requests up to N times
-- `--sleep_success SECONDS` - Wait time after successful requests
-- `--sleep_error SECONDS` - Wait time after errors
-- `--load_actions FILE` - Actions to perform after page load
-- `--crawl_actions FILE` - Actions to discover and crawl additional links
-- `--use_bloom_filter` - Skip duplicate URLs (useful for large crawls)
-- `--max_errors N` - Stop after N errors
-- `--interactive` - Prompt before each crawl action
-
-**Examples:**
-
-```bash
-# Basic crawl
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro
-
-# Crawl with depth (follow links up to 2 levels)
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro --max_depth 2
-
-# Crawl with custom actions
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro \
- --load_actions tmp/load-actions.txt \
- --crawl_actions tmp/crawl-actions.txt
-
-# Crawl with error handling
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro \
- --max_retries 3 \
- --max_errors 10 \
- --sleep_error 5
-```
-
-### `rubbernecker parse`
-
-Extract structured data from crawled HTML using parsers.
-
-**Syntax:**
-
-```bash
-rubbernecker parse PARSER_CLASS INPUT_URL OUTPUT_URL
-```
-
-**Arguments:**
-
-- `PARSER_CLASS` - Fully qualified parser class name
-- `INPUT_URL` - Avro file from crawl command
-- `OUTPUT_URL` - Path for parsed output (Avro format)
-
-**Available Parsers:**
-
-- `rubbernecker.parse.standard.StandardPageParser` - Extracts title, headers, links, and body text
-
-**Examples:**
-
-```bash
-# Parse with standard parser
-poetry run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
- tmp/raw.avro tmp/parsed.avro
-
-# View parsed results
-poetry run avrokit tojson tmp/parsed.avro | jq .
-```
-
-### `rubbernecker proxy`
-
-Run a local proxy server to route requests through an upstream proxy.
-
-**Syntax:**
-
-```bash
-rubbernecker proxy UPSTREAM [LISTEN]
-```
-
-**Arguments:**
-
-- `UPSTREAM` - Upstream proxy (e.g., `username:password@proxy.example.com:8000`)
-- `LISTEN` - Local address to listen on (default: `127.0.0.1:3128`)
-
-**Example:**
-
-```bash
-# Start proxy server
-poetry run rubbernecker proxy "$PROXY_USER:$PROXY_PASS@proxy.example.com:8000" "127.0.0.1:3128"
-
-# Use proxy in Chrome
-poetry run rubbernecker chrome --proxy_server "http://127.0.0.1:3128" --headless
-
-# Use proxy in crawl
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro --chrome_debug_port 9222
-```
-
-## Action Scripts
-
-Action scripts define automated interactions with web pages using CSS selectors.
-
-### Action Script Format
-
-```
-[url_pattern_regex]
-ACTION_NAME selector arguments
-ACTION_NAME selector arguments
-...
-```
-
-### Available Actions
-
-- **SLEEP** `seconds` - Wait for specified duration
-- **SCROLL** `pixels` - Scroll page vertically
-- **INPUT** `selector text` - Fill form input with text
-- **CLICK** `selector` - Click an element
-- **CLICK_IF_EXISTS** `selector` - Click if element is present
-
-### Example: Load Actions
-
-Actions to perform after each page loads (use `--load_actions` flag):
-
-```bash
-cat > tmp/load-actions.txt << EOF
-[news\.ycombinator\.com]
-SLEEP 2
-SCROLL 500
-SLEEP 1
-EOF
-```
+## Documentation
-### Example: Crawl Actions
-
-Actions to discover additional URLs during crawling (use `--crawl_actions` flag):
-
-```bash
-cat > tmp/crawl-actions.txt << EOF
-[news\.ycombinator\.com]
-CLICK a.morelink
-EOF
-```
-
-This will click the "More" link on Hacker News to discover additional pages.
-
-## Advanced Usage
-
-### Full Crawl Example
-
-Complete example crawling Hacker News with actions:
-
-```bash
-# Prepare directories
-mkdir -p tmp
-
-# Create URL list
-cat > tmp/requests.txt << EOF
-https://news.ycombinator.com/
-EOF
-
-# Create load actions (wait for page to stabilize)
-cat > tmp/load-actions.txt << EOF
-[news\.ycombinator\.com]
-SLEEP 1
-SCROLL 500
-EOF
-
-# Create crawl actions (discover more pages)
-cat > tmp/crawl-actions.txt << EOF
-[news\.ycombinator\.com]
-CLICK a.morelink
-EOF
-
-# Start Chrome
-poetry run rubbernecker chrome --headless &
-
-# Crawl with depth 2
-poetry run rubbernecker crawl tmp/requests.txt tmp/hn-raw.avro \
- --load_actions tmp/load-actions.txt \
- --crawl_actions tmp/crawl-actions.txt \
- --max_depth 2 \
- --max_retries 2 \
- --sleep_success 1
-
-# Parse results
-poetry run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
- tmp/hn-raw.avro tmp/hn-parsed.avro
-
-# View results
-poetry run avrokit tojson tmp/hn-parsed.avro | jq '.title, .links | length'
-```
-
-### Using with Proxies
-
-Route traffic through a commercial proxy service:
-
-```bash
-# Start local proxy server
-poetry run rubbernecker proxy \
- "$PROXY_USER:$PROXY_PASS@residential.proxy.com:8000" \
- "127.0.0.1:3128" &
-
-# Start Chrome through proxy
-poetry run rubbernecker chrome \
- --proxy_server "http://127.0.0.1:3128" \
- --chrome_debug_port 9222 \
- --headless &
-
-# Crawl through proxy
-poetry run rubbernecker crawl tmp/urls.txt tmp/output.avro \
- --chrome_debug_port 9222
-```
-
-## Output Formats
-
-### Crawl Output (Raw HTML)
-
-Avro schema with fields:
-
-- `url` (string) - Crawled URL
-- `timestamp` (long) - Unix timestamp in milliseconds
-- `body` (string|null) - Raw HTML content
-- `error` (string|null) - Error message if request failed
-- `metadata` (map|null) - Custom metadata
-
-### Parse Output (StandardPageParser)
-
-Avro schema with fields:
-
-- `url` (string) - Page URL
-- `timestamp` (long) - Crawl timestamp
-- `title` (string|null) - Page title
-- `content_length` (int) - HTML content length
-- `body_text` (string|null) - Extracted text content
-- `headers` (array|null) - H1-H6 headers with level and text
-- `links` (array|null) - Links with text, URL, and external flag
-
-## Troubleshooting
-
-**Chrome connection issues:**
-
-- Ensure Chrome is running with `--chrome_debug_port` matching crawl command
-- Check if port 9222 is available: `lsof -i :9222`
-
-**SeleniumBase errors:**
-
-- Update Chrome to the latest version
-
-**Memory issues with large crawls:**
-
-- Use `--use_bloom_filter` to reduce memory for duplicate detection
-- Process in smaller batches with multiple crawl commands
-
-## Development
-
-Run tests:
-
-```bash
-make test
-```
-
-Run all tests (including integration tests):
-
-```bash
-make test-all
-```
-
-Run tests with coverage:
-
-```bash
-make test-coverage
-```
-
-Lint and type check:
-
-```bash
-make lint
-make typecheck
-```
-
-Format code:
-
-```bash
-make format
-```
-
-Build the package:
-
-```bash
-make build
-```
-
-Clean up build artifacts:
-
-```bash
-make clean
-```
-
-Run with debug logging:
-
-```bash
-poetry run rubbernecker --debug crawl tmp/urls.txt tmp/output.avro
-```
+- [Action Scripts](docs/actions.md) — Automate page interactions during crawls
+- [Output Formats](docs/output-formats.md) — Avro schemas for all command outputs
+- [Development](docs/development.md) — Testing, linting, and build commands
## License
diff --git a/docs/actions.md b/docs/actions.md
new file mode 100644
index 0000000..4ca52d6
--- /dev/null
+++ b/docs/actions.md
@@ -0,0 +1,79 @@
+
+
+# Action Scripts
+
+Action scripts define automated interactions with web pages using CSS selectors.
+They are passed to `crawl` via `--load_actions` and `--crawl_actions`.
+
+- **`--load_actions`** — Actions run after each page finishes loading (e.g. scroll, wait, fill forms).
+- **`--crawl_actions`** — Actions that discover additional URLs to crawl (e.g. click a "next page" link).
+
+## Format
+
+```
+[url_pattern_regex]
+ACTION_NAME selector_or_arg extra_arg
+ACTION_NAME selector_or_arg extra_arg
+...
+```
+
+- The `[url_pattern_regex]` header is a regular expression matched against the
+ current page URL. Actions beneath it only run on matching pages.
+- Multiple URL pattern blocks can appear in one file.
+
+## Available Actions
+
+| Action | Arguments | Description |
+|---|---|---|
+| `SLEEP` | `seconds` | Wait for the specified number of seconds |
+| `SCROLL` | `pixels` | Scroll the page vertically by `pixels` |
+| `INPUT` | `selector text` | Fill a form input matching `selector` with `text` |
+| `CLICK` | `selector` | Click an element matching `selector` |
+| `CLICK_IF_EXISTS` | `selector` | Click the element only if it exists on the page |
+
+## Examples
+
+### Load Actions
+
+Stabilise a page before grabbing its HTML (pass with `--load_actions`):
+
+```
+[news\.ycombinator\.com]
+SLEEP 2
+SCROLL 500
+SLEEP 1
+```
+
+### Crawl Actions
+
+Discover additional URLs by clicking pagination (pass with `--crawl_actions`):
+
+```
+[news\.ycombinator\.com]
+CLICK a.morelink
+```
+
+This clicks the "More" link on Hacker News, causing the crawler to follow the
+newly loaded page URL.
+
+### Form Login
+
+```
+[example\.com/login]
+INPUT #username myuser
+INPUT #password mypassword
+CLICK button[type=submit]
+SLEEP 2
+```
+
+## Usage
+
+```bash
+uv run rubbernecker crawl tmp/urls.txt tmp/output.avro \
+ --load_actions tmp/load-actions.txt \
+ --crawl_actions tmp/crawl-actions.txt
+```
diff --git a/docs/commands/crawl.md b/docs/commands/crawl.md
new file mode 100644
index 0000000..1b52cb6
--- /dev/null
+++ b/docs/commands/crawl.md
@@ -0,0 +1,100 @@
+
+
+# `rubbernecker crawl`
+
+Crawl web pages and save raw HTML to Avro files.
+
+## Syntax
+
+```bash
+uv run rubbernecker crawl [OPTIONS] INPUT_URL OUTPUT_URL
+```
+
+## Arguments
+
+- `INPUT_URL` - File containing URLs to crawl (text, JSON, or Avro format)
+- `OUTPUT_URL` - Path where crawled data will be saved (Avro format)
+
+## Options
+
+- `--input_format FORMAT` - Input file format: `TEXT`, `JSON`, or `AVRO` (default: `TEXT`)
+- `--max_depth N` - Maximum crawl depth for following links (default: `0`)
+- `--max_retries N` - Retry failed requests up to N times
+- `--sleep_success SECONDS` - Wait time after successful requests
+- `--sleep_error SECONDS` - Wait time after errors
+- `--load_actions FILE` - Actions to perform after page load (see [actions.md](../actions.md))
+- `--crawl_actions FILE` - Actions to discover and crawl additional links (see [actions.md](../actions.md))
+- `--use_bloom_filter` - Skip duplicate URLs (useful for large crawls)
+- `--max_errors N` - Stop after N errors
+- `--interactive` - Prompt before each crawl action
+
+## Examples
+
+```bash
+# Basic crawl
+uv run rubbernecker crawl tmp/urls.txt tmp/output.avro
+
+# Crawl with depth (follow links up to 2 levels)
+uv run rubbernecker crawl tmp/urls.txt tmp/output.avro --max_depth 2
+
+# Crawl with custom actions
+uv run rubbernecker crawl tmp/urls.txt tmp/output.avro \
+ --load_actions tmp/load-actions.txt \
+ --crawl_actions tmp/crawl-actions.txt
+
+# Crawl with error handling
+uv run rubbernecker crawl tmp/urls.txt tmp/output.avro \
+ --max_retries 3 \
+ --max_errors 10 \
+ --sleep_error 5
+```
+
+## Full Pipeline Example
+
+Complete example crawling Hacker News with actions:
+
+```bash
+mkdir -p tmp
+
+cat > tmp/requests.txt << EOF
+https://news.ycombinator.com/
+EOF
+
+cat > tmp/load-actions.txt << EOF
+[news\.ycombinator\.com]
+SLEEP 1
+SCROLL 500
+EOF
+
+cat > tmp/crawl-actions.txt << EOF
+[news\.ycombinator\.com]
+CLICK a.morelink
+EOF
+
+uv run rubbernecker crawl tmp/requests.txt tmp/hn-raw.avro \
+ --load_actions tmp/load-actions.txt \
+ --crawl_actions tmp/crawl-actions.txt \
+ --max_depth 2 \
+ --max_retries 2 \
+ --sleep_success 1
+
+uv run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
+ tmp/hn-raw.avro tmp/hn-parsed.avro
+
+uv run avrokit tojson tmp/hn-parsed.avro | jq '.title, .links | length'
+```
+
+## Output Format
+
+See [output-formats.md](../output-formats.md#crawl-output-raw-html) for the Avro schema.
+
+## Troubleshooting
+
+**Memory issues with large crawls:**
+
+- Use `--use_bloom_filter` to reduce memory for duplicate detection
+- Process in smaller batches with multiple crawl commands
diff --git a/docs/commands/fetch.md b/docs/commands/fetch.md
new file mode 100644
index 0000000..c8b2b85
--- /dev/null
+++ b/docs/commands/fetch.md
@@ -0,0 +1,26 @@
+
+
+# `rubbernecker fetch`
+
+Download assets from a list of URLs.
+
+## Syntax
+
+```bash
+uv run rubbernecker fetch [OPTIONS] INPUT_URL OUTPUT_URL
+```
+
+## Arguments
+
+- `INPUT_URL` - File containing URLs to fetch
+- `OUTPUT_URL` - Path where fetched data will be saved
+
+## Examples
+
+```bash
+uv run rubbernecker fetch tmp/urls.txt tmp/output/
+```
diff --git a/docs/commands/parse.md b/docs/commands/parse.md
new file mode 100644
index 0000000..12d4525
--- /dev/null
+++ b/docs/commands/parse.md
@@ -0,0 +1,52 @@
+
+
+# `rubbernecker parse`
+
+Extract structured data from crawled HTML using a parser class.
+
+## Syntax
+
+```bash
+uv run rubbernecker parse PARSER_CLASS INPUT_URL OUTPUT_URL
+```
+
+## Arguments
+
+- `PARSER_CLASS` - Fully qualified parser class name
+- `INPUT_URL` - Avro file produced by the `crawl` command
+- `OUTPUT_URL` - Path for parsed output (Avro format)
+
+## Available Parsers
+
+### `rubbernecker.parse.standard.StandardPageParser`
+
+Extracts common page elements:
+
+- Page title
+- Headers (H1–H6) with level and text
+- Links with text, URL, and external flag
+- Body text content
+
+## Examples
+
+```bash
+# Parse with the standard parser
+uv run rubbernecker parse rubbernecker.parse.standard.StandardPageParser \
+ tmp/raw.avro tmp/parsed.avro
+
+# View parsed results
+uv run avrokit tojson tmp/parsed.avro | jq .
+```
+
+## Output Format
+
+See [output-formats.md](../output-formats.md#parse-output-standardpageparser) for the Avro schema.
+
+## Writing a Custom Parser
+
+Implement a class with a `parse(page)` method that accepts a raw `Page` record
+and returns a parsed record. Pass the fully qualified class name as `PARSER_CLASS`.
diff --git a/docs/commands/sitemap.md b/docs/commands/sitemap.md
new file mode 100644
index 0000000..c29b3ef
--- /dev/null
+++ b/docs/commands/sitemap.md
@@ -0,0 +1,80 @@
+
+
+# `rubbernecker sitemap`
+
+Discover page URLs from sitemaps, sitemap indexes, or `robots.txt` files and
+write them to an output file ready to feed into `crawl`.
+
+Sitemap indexes are handled automatically by recursively fetching all linked
+sitemaps. All discovered URLs are deduplicated before writing.
+
+## Syntax
+
+```bash
+uv run rubbernecker sitemap URL [URL ...] --output OUTPUT [OPTIONS]
+```
+
+## Arguments
+
+- `URL` - One or more sitemap URLs, sitemap index URLs, `robots.txt` URLs, or
+ local file paths.
+
+## Options
+
+- `--output PATH` *(required)* - Destination file for discovered page URLs.
+- `--output-format FORMAT` - Output format (default: `text`):
+ - `text` — one URL per line; feeds directly into `crawl` as a URL list.
+ - `json` — JSONL; one `{"url": ..., "lastmod": ..., "changefreq": ..., "priority": ...}` object per line (metadata keys omitted when absent).
+ - `avro` — Avro records compatible with `crawl --input_format AVRO`.
+- `--save-sitemaps PATH` - Save each fetched sitemap/robots.txt document as an
+ Avro `Page` record (same schema as `crawl`) to this path. Useful for
+ archiving or inspecting raw sitemap XML.
+- `--parallelism N` - Number of concurrent sitemap fetches (default: `1`).
+
+## Examples
+
+```bash
+# Discover all URLs from a sitemap index, write a plain URL list
+uv run rubbernecker sitemap https://pypi.org/sitemap.xml \
+ --output tmp/pypi-urls.txt
+
+# JSONL output with metadata (lastmod, changefreq, priority)
+uv run rubbernecker sitemap https://pypi.org/sitemap.xml \
+ --output tmp/pypi-urls.jsonl \
+ --output-format json
+
+# Avro output, compatible with crawl --input_format AVRO
+uv run rubbernecker sitemap https://pypi.org/sitemap.xml \
+ --output tmp/pypi-urls.avro \
+ --output-format avro
+
+# Extract sitemaps from robots.txt
+uv run rubbernecker sitemap https://pypi.org/robots.txt \
+ --output tmp/pypi-urls.txt
+
+# Multiple input URLs, parallel fetching, save raw sitemap documents
+uv run rubbernecker sitemap \
+ https://pypi.org/sitemap.xml \
+ https://docs.python.org/sitemap.xml \
+ --output tmp/combined-urls.txt \
+ --save-sitemaps tmp/sitemaps.avro \
+ --parallelism 8
+```
+
+## Full Pipeline: Discover then Crawl
+
+```bash
+uv run rubbernecker sitemap https://pypi.org/sitemap.xml \
+ --output tmp/pypi-urls.txt \
+ --parallelism 4
+
+uv run rubbernecker crawl tmp/pypi-urls.txt tmp/pypi-raw.avro
+```
+
+## Output Format
+
+See [output-formats.md](../output-formats.md#sitemap-output) for schema details.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..b111672
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,63 @@
+
+
+# Development
+
+Common tasks for working on Rubbernecker locally.
+
+## Setup
+
+```bash
+make install
+# or
+uv sync
+```
+
+## Running Tests
+
+```bash
+# Unit tests only
+make test
+
+# All tests including integration tests
+make test-all
+
+# Tests with coverage report
+make test-coverage
+```
+
+## Linting and Type Checking
+
+```bash
+make lint
+make typecheck
+```
+
+## Formatting
+
+```bash
+make format
+```
+
+## Building
+
+```bash
+make build
+```
+
+## Cleaning Up
+
+```bash
+make clean
+```
+
+## Debug Logging
+
+Pass `--debug` before the subcommand to enable verbose logging:
+
+```bash
+uv run rubbernecker --debug crawl tmp/urls.txt tmp/output.avro
+```
diff --git a/docs/output-formats.md b/docs/output-formats.md
new file mode 100644
index 0000000..7d6505c
--- /dev/null
+++ b/docs/output-formats.md
@@ -0,0 +1,58 @@
+
+
+# Output Formats
+
+All Rubbernecker commands that produce data write Avro files. This page
+describes the schema for each command's output.
+
+## Crawl Output (Raw HTML)
+
+Produced by [`crawl`](commands/crawl.md). Each record represents one crawled URL.
+
+| Field | Type | Description |
+|---|---|---|
+| `url` | string | Crawled URL |
+| `timestamp` | long | Unix timestamp in milliseconds |
+| `body` | string \| null | Raw HTML content |
+| `error` | string \| null | Error message if the request failed |
+| `metadata` | map \| null | Custom metadata |
+
+## Parse Output (StandardPageParser)
+
+Produced by [`parse`](commands/parse.md) using `rubbernecker.parse.standard.StandardPageParser`.
+
+| Field | Type | Description |
+|---|---|---|
+| `url` | string | Page URL |
+| `timestamp` | long | Crawl timestamp |
+| `title` | string \| null | Page `
` |
+| `content_length` | int | HTML content length in bytes |
+| `body_text` | string \| null | Extracted visible text |
+| `headers` | array \| null | H1–H6 elements, each with `level` (int) and `text` (string) |
+| `links` | array \| null | Anchor elements, each with `text`, `url`, and `external` (bool) |
+
+## Sitemap Output
+
+Produced by [`sitemap`](commands/sitemap.md) when using `--output-format avro`.
+
+| Field | Type | Description |
+|---|---|---|
+| `url` | string | Page URL |
+| `lastmod` | string \| null | Last modification date (ISO 8601) |
+| `changefreq` | string \| null | Suggested crawl frequency (e.g. `weekly`, `daily`) |
+| `priority` | string \| null | Relative priority hint (0.0–1.0 as a string) |
+
+When using `--save-sitemaps`, raw sitemap documents are stored using the same
+`Page` schema as crawl output, with the raw XML in the `body` field.
+
+## Viewing Avro Files
+
+Convert any Avro file to JSON for inspection:
+
+```bash
+uv run avrokit tojson tmp/output.avro | jq .
+```
diff --git a/rubbernecker/__init__.py b/rubbernecker/__init__.py
index 8e0f9c8..305588f 100644
--- a/rubbernecker/__init__.py
+++ b/rubbernecker/__init__.py
@@ -2,14 +2,11 @@
#
# SPDX-License-Identifier: Apache-2.0
-from .browser import ChromeTool, ProxyTool
from .crawl import CrawlTool
from .parse import Parser, ParseTool
__all__ = [
- "ChromeTool",
"CrawlTool",
"ParseTool",
"Parser",
- "ProxyTool",
]
diff --git a/rubbernecker/__main__.py b/rubbernecker/__main__.py
index b67b556..d313d02 100644
--- a/rubbernecker/__main__.py
+++ b/rubbernecker/__main__.py
@@ -8,16 +8,16 @@
import logging
from .base import Tool
-from .browser import ProxyTool
from .crawl import CrawlTool
from .fetch import FetchTool
from .parse import ParseTool
+from .sitemap import SitemapTool
TOOLS: list[Tool] = [
CrawlTool(),
ParseTool(),
- ProxyTool(),
FetchTool(),
+ SitemapTool(),
]
diff --git a/rubbernecker/base.py b/rubbernecker/base.py
index dfc2bd9..6e01566 100644
--- a/rubbernecker/base.py
+++ b/rubbernecker/base.py
@@ -3,8 +3,11 @@
# SPDX-License-Identifier: Apache-2.0
import argparse
+import os
from typing import Protocol
+AVRO_CODEC = os.environ.get("AVRO_CODEC", "deflate")
+
class Tool(Protocol):
def name(self) -> str: ...
diff --git a/rubbernecker/browser/chrome.py b/rubbernecker/browser/chrome.py
deleted file mode 100644
index 4e18a45..0000000
--- a/rubbernecker/browser/chrome.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# SPDX-FileCopyrightText: 2026 Greg Brandt
-#
-# SPDX-License-Identifier: Apache-2.0
-
-import argparse
-import logging
-import subprocess
-import sys
-import tempfile
-import time
-
-import requests
-
-logger = logging.getLogger(__name__)
-
-DEFAULT_CHROME_DEBUG_PORT = 9222
-
-
-class ChromeTool:
- def __init__(self) -> None:
- self.proc: subprocess.Popen | None = None
-
- def name(self) -> str:
- return "chrome"
-
- def start_chrome(
- self,
- headless: bool = False,
- chrome_debug_port: int = DEFAULT_CHROME_DEBUG_PORT,
- proxy_server: str | None = None,
- wait: bool = False,
- ) -> None:
- with tempfile.TemporaryDirectory() as tmpdir:
- # Build the command to start Chrome
- chrome_command = [
- "google-chrome",
- f"--remote-debugging-port={chrome_debug_port}",
- f"--user-data-dir={tmpdir}",
- "--no-first-run",
- "--no-default-browser-check",
- ]
- if headless:
- chrome_command.append("--headless")
- if proxy_server:
- chrome_command.append(f"--proxy-server={proxy_server}")
- # Start Chrome with the specified command
- logger.info("Starting Chrome with command: %s", " ".join(chrome_command))
- self.proc = subprocess.Popen(
- chrome_command,
- stdout=sys.stdout,
- stderr=sys.stderr,
- )
- if wait:
- self.proc.wait()
-
- def wait_for_chrome(
- self,
- chrome_debug_port: int = DEFAULT_CHROME_DEBUG_PORT,
- timeout: int | None = None,
- ) -> None:
- start_time = time.time()
- while timeout is None or (time.time() - start_time) < timeout:
- try:
- res = requests.get(f"http://localhost:{chrome_debug_port}/json")
- if res.status_code == 200:
- logger.info("Chrome is ready on port %d", chrome_debug_port)
- return
- except requests.ConnectionError:
- pass
- time.sleep(1)
-
- def stop_chrome(self) -> None:
- if self.proc:
- logger.info("Stopping Chrome with PID %d", self.proc.pid)
- self.proc.terminate()
- self.proc.wait()
- self.proc = None
-
- def configure(self, subparsers: argparse._SubParsersAction) -> None:
- parser = subparsers.add_parser(self.name(), help="Runs Chrome")
- parser.add_argument(
- "--headless",
- action="store_true",
- help="Run Chrome in headless mode",
- )
- parser.add_argument(
- "--chrome_debug_port",
- type=int,
- default=DEFAULT_CHROME_DEBUG_PORT,
- help="Open Chrome DevTools Protocol on this port",
- )
- parser.add_argument(
- "--proxy_server",
- help="Proxy server to use (e.g., 'http://localhost:8080')",
- )
-
- def run(self, args: argparse.Namespace) -> None:
- try:
- self.start_chrome(
- headless=args.headless,
- chrome_debug_port=args.chrome_debug_port,
- proxy_server=args.proxy_server,
- wait=True,
- )
- except KeyboardInterrupt:
- self.stop_chrome()
diff --git a/rubbernecker/browser/proxy.py b/rubbernecker/browser/proxy.py
deleted file mode 100644
index 6da7380..0000000
--- a/rubbernecker/browser/proxy.py
+++ /dev/null
@@ -1,190 +0,0 @@
-# SPDX-FileCopyrightText: 2026 Greg Brandt
-#
-# SPDX-License-Identifier: Apache-2.0
-
-import argparse
-import asyncio
-import base64
-import contextlib
-import logging
-from dataclasses import dataclass
-from urllib.parse import urlparse
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ProxyAddress:
- host: str
- port: int
- auth: tuple[str, str] | None = None
-
- def auth_token(self) -> str | None:
- if self.auth:
- username, password = self.auth
- return base64.b64encode(f"{username}:{password}".encode()).decode()
- return None
-
-
-class ProxyTool:
- def name(self) -> str:
- return "proxy"
-
- def _create_connect_request(self, target: str, upstream_auth: str | None) -> str:
- if upstream_auth:
- return (
- f"CONNECT {target} HTTP/1.1\r\n"
- f"Host: {target}\r\n"
- f"Proxy-Connection: Keep-Alive\r\n"
- f"Proxy-Authorization: Basic {upstream_auth}\r\n"
- "\r\n"
- )
- else:
- return (
- f"CONNECT {target} HTTP/1.1\r\n"
- f"Host: {target}\r\n"
- f"Proxy-Connection: Keep-Alive\r\n"
- "\r\n"
- )
-
- def _create_handler(
- self,
- upstream: ProxyAddress,
- ):
- # Create HTTP basic authentication header for upstream proxy
- upstream_auth = upstream.auth_token()
-
- async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
- try:
- # Read the incoming request
- request_line = await reader.readuntil(b"\r\n")
- method, target, _ = request_line.decode().strip().split()
- headers = await reader.readuntil(b"\r\n\r\n")
- # Connect to upstream proxy
- try:
- upstream_reader, upstream_writer = await asyncio.open_connection(
- upstream.host, upstream.port
- )
- except Exception as e:
- logger.error("Upstream connection failed: %s", e)
- writer.close()
- await writer.wait_closed()
- return
- if method.upper() == "CONNECT":
- # Handle CONNECT method
- connect_req = self._create_connect_request(target, upstream_auth)
- upstream_writer.write(connect_req.encode())
- await upstream_writer.drain()
- # Relay response back to client
- response = await upstream_reader.readuntil(b"\r\n\r\n")
- writer.write(response)
- await writer.drain()
- else:
- # Proxy other HTTP methods
- upstream_writer.write(
- f"{method} {target} HTTP/1.1\r\n"
- f"Proxy-Authorization: Basic {upstream_auth}\r\n".encode()
- + headers
- )
- await upstream_writer.drain()
-
- async def pipe(src_reader, dst_writer):
- try:
- while True:
- data = await src_reader.read(4096)
- if not data:
- break
- dst_writer.write(data)
- await dst_writer.drain()
- except Exception as e:
- logger.error("Pipe error: %s", e)
- finally:
- try:
- dst_writer.close()
- await dst_writer.wait_closed()
- except Exception:
- pass
-
- # Proxy streams
- task1 = asyncio.create_task(pipe(reader, upstream_writer))
- task2 = asyncio.create_task(pipe(upstream_reader, writer))
- done, pending = await asyncio.wait(
- [task1, task2],
- return_when=asyncio.FIRST_COMPLETED,
- )
- for task in done:
- if task.exception():
- logger.error("Task error: %s", task.exception())
- for task in pending:
- task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await task
-
- except Exception as e:
- logger.error("Handler error: %s", e)
-
- return handle
-
- async def start_proxy(self, upstream: ProxyAddress, listen: ProxyAddress):
- handler = self._create_handler(upstream)
- server = await asyncio.start_server(
- handler,
- listen.host,
- listen.port,
- )
- logger.info("Listening on %s:%d", listen.host, listen.port)
- async with server:
- await server.serve_forever()
-
- def configure(self, subparsers: argparse._SubParsersAction) -> None:
- parser = subparsers.add_parser(self.name(), help="Run an asyncio proxy server")
- parser.add_argument(
- "upstream",
- type=str,
- help="Upstream proxy URL",
- )
- parser.add_argument(
- "listen",
- type=str,
- help="Local proxy URL",
- nargs="?",
- default="127.0.0.1:3128",
- )
-
- def _parse_address(self, url: str) -> ProxyAddress:
- if "://" not in url:
- url = "//" + url # Prepend dummy scheme if missing
- parsed = urlparse(url)
- if not parsed.hostname:
- raise ValueError("Invalid URL: missing hostname")
- if not parsed.port:
- raise ValueError("Invalid URL: missing port")
- if (
- parsed.username
- and not parsed.password
- or parsed.password
- and not parsed.username
- ):
- raise ValueError(
- "Invalid URL: must have both username and password for auth"
- )
- return ProxyAddress(
- host=parsed.hostname,
- port=parsed.port,
- auth=(
- (
- parsed.username,
- parsed.password,
- )
- if parsed.username and parsed.password
- else None
- ),
- )
-
- def run(self, args: argparse.Namespace) -> None:
- try:
- upstream = self._parse_address(args.upstream)
- listen = self._parse_address(args.listen)
- asyncio.run(self.start_proxy(upstream, listen))
- except KeyboardInterrupt:
- logger.info("Proxy shutdown requested by user.")
diff --git a/rubbernecker/crawl/tool.py b/rubbernecker/crawl/tool.py
index ba1562c..8a39630 100644
--- a/rubbernecker/crawl/tool.py
+++ b/rubbernecker/crawl/tool.py
@@ -23,6 +23,7 @@
)
from seleniumbase import SB
+from rubbernecker.base import AVRO_CODEC
from rubbernecker.crawl.bloomfilter import BloomFilter
from .actions import (
@@ -249,7 +250,9 @@ def crawl(
base_input_url, base_output_url
):
logger.info("Mapping %s to %s", input_url, output_url)
- with avro_writer(output_url.with_mode("a+b"), SCHEMA) as writer:
+ with avro_writer(
+ output_url.with_mode("a+b"), SCHEMA, codec=AVRO_CODEC
+ ) as writer:
# Execute requests
for url in self.load_requests(
input_url, input_format, bloom_filter=bloom_filter
diff --git a/rubbernecker/parse/tool.py b/rubbernecker/parse/tool.py
index 7770b0c..7319f89 100644
--- a/rubbernecker/parse/tool.py
+++ b/rubbernecker/parse/tool.py
@@ -17,6 +17,8 @@
from avro.schema import Schema
from avrokit import URL, avro_reader, avro_writer, create_url_mapping, parse_url
+from rubbernecker.base import AVRO_CODEC
+
from .base import Parser, list_parsers
logger = logging.getLogger("parsetool")
@@ -95,7 +97,7 @@ def writer_process(
logger.debug("Writer process starting, expecting %d workers", num_workers)
workers_done = 0
- with avro_writer(output_url.with_mode("wb"), schema) as writer:
+ with avro_writer(output_url.with_mode("wb"), schema, codec=AVRO_CODEC) as writer:
while workers_done < num_workers:
msg_type, seq_id, results, task_stats = result_queue.get()
@@ -174,7 +176,9 @@ def parse(
# Open the input URL and output URL as Avro files
with (
avro_reader(input_url.with_mode("rb")) as reader,
- avro_writer(output_url.with_mode("wb"), parser.schema()) as writer,
+ avro_writer(
+ output_url.with_mode("wb"), parser.schema(), codec=AVRO_CODEC
+ ) as writer,
):
for record in reader:
try:
diff --git a/rubbernecker/browser/__init__.py b/rubbernecker/sitemap/__init__.py
similarity index 52%
rename from rubbernecker/browser/__init__.py
rename to rubbernecker/sitemap/__init__.py
index a1d7dbf..9610a84 100644
--- a/rubbernecker/browser/__init__.py
+++ b/rubbernecker/sitemap/__init__.py
@@ -2,7 +2,6 @@
#
# SPDX-License-Identifier: Apache-2.0
-from .chrome import ChromeTool
-from .proxy import ProxyTool
+from .tool import SitemapTool
-__all__ = ["ChromeTool", "ProxyTool"]
+__all__ = ["SitemapTool"]
diff --git a/rubbernecker/sitemap/tool.py b/rubbernecker/sitemap/tool.py
new file mode 100644
index 0000000..08ba26b
--- /dev/null
+++ b/rubbernecker/sitemap/tool.py
@@ -0,0 +1,514 @@
+# SPDX-FileCopyrightText: 2026 Greg Brandt
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import argparse
+import json
+import logging
+import time
+import xml.etree.ElementTree as ET
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass, field
+from enum import StrEnum
+from typing import Any
+from urllib.parse import urlparse
+
+import requests
+from avrokit import avro_schema, avro_writer, parse_url
+
+from rubbernecker.base import AVRO_CODEC
+
+logger = logging.getLogger(__name__)
+
+# Namespace used in standard sitemap XML
+_SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
+
+# Reuse the same Page schema as crawl/fetch so --save-sitemaps output is
+# compatible with downstream tools (parse, etc.).
+PAGE_SCHEMA = avro_schema(
+ {
+ "name": "Page",
+ "type": "record",
+ "fields": [
+ {"name": "url", "type": "string"},
+ {"name": "timestamp", "type": "long"},
+ {"name": "body", "type": ["null", "string"], "default": None},
+ {"name": "error", "type": ["null", "string"], "default": None},
+ {
+ "name": "metadata",
+ "type": [
+ "null",
+ {
+ "type": "map",
+ "values": ["null", "string"],
+ },
+ ],
+ "default": None,
+ },
+ ],
+ }
+)
+
+# Schema for the --output Avro format.
+# url is required (compatible with CrawlTool's InputFormat.AVRO);
+# the standard sitemap metadata fields are optional.
+ENTRY_SCHEMA = avro_schema(
+ {
+ "name": "SitemapEntry",
+ "type": "record",
+ "fields": [
+ {"name": "url", "type": "string"},
+ {"name": "lastmod", "type": ["null", "string"], "default": None},
+ {"name": "changefreq", "type": ["null", "string"], "default": None},
+ {"name": "priority", "type": ["null", "string"], "default": None},
+ ],
+ }
+)
+
+
+@dataclass
+class SitemapEntry:
+ """A single page URL entry from a sitemap element."""
+
+ url: str
+ lastmod: str | None = None
+ changefreq: str | None = None
+ priority: str | None = None
+
+
+class OutputFormat(StrEnum):
+ TEXT = "text"
+ JSON = "json"
+ AVRO = "avro"
+
+
+@dataclass
+class SitemapToolStats:
+ count_input: int = 0
+ count_sitemaps: int = 0
+ count_output: int = 0
+ count_error: int = 0
+
+
+def _fetch_content(url: str) -> str:
+ """Fetch URL or read local file; return text content."""
+ parsed = urlparse(url)
+ if parsed.scheme in ("http", "https"):
+ response = requests.get(url, timeout=30)
+ response.raise_for_status()
+ return response.text
+ else:
+ # Local file path via avrokit
+ f_url = parse_url(url)
+ with f_url.with_mode("r") as f:
+ content = f.read()
+ return content
+
+
+def _is_robots_txt(url: str) -> bool:
+ return urlparse(url).path.lower().endswith("robots.txt")
+
+
+def _parse_robots(content: str) -> list[str]:
+ """Extract Sitemap: directives from a robots.txt file."""
+ urls = []
+ for line in content.splitlines():
+ stripped = line.strip()
+ if stripped.lower().startswith("sitemap:"):
+ sitemap_url = stripped[len("sitemap:") :].strip()
+ if sitemap_url:
+ urls.append(sitemap_url)
+ logger.info("Parsed robots.txt: found %d sitemap directive(s)", len(urls))
+ return urls
+
+
+def _parse_sitemap_index(root: ET.Element) -> list[str]:
+ """Extract values from a element."""
+ locs = []
+ for sitemap_el in root.findall(f"{{{_SITEMAP_NS}}}sitemap"):
+ loc_el = sitemap_el.find(f"{{{_SITEMAP_NS}}}loc")
+ if loc_el is not None and loc_el.text:
+ locs.append(loc_el.text.strip())
+ # Also try without namespace (non-standard but seen in the wild)
+ for sitemap_el in root.findall("sitemap"):
+ loc_el = sitemap_el.find("loc")
+ if loc_el is not None and loc_el.text:
+ locs.append(loc_el.text.strip())
+ return locs
+
+
+def _parse_urlset(root: ET.Element) -> list[SitemapEntry]:
+ """Extract entries (with all standard metadata) from a element."""
+
+ def _text(el: ET.Element, tag: str, ns: str | None = _SITEMAP_NS) -> str | None:
+ child = el.find(f"{{{ns}}}{tag}") if ns else el.find(tag)
+ if child is not None and child.text:
+ return child.text.strip()
+ # Fallback: try without namespace
+ if ns is not None:
+ child = el.find(tag)
+ if child is not None and child.text:
+ return child.text.strip()
+ return None
+
+ entries: list[SitemapEntry] = []
+
+ # Try namespaced elements first, then fall back to non-namespaced
+ url_els = root.findall(f"{{{_SITEMAP_NS}}}url") or root.findall("url")
+
+ for url_el in url_els:
+ loc = _text(url_el, "loc")
+ if not loc:
+ continue
+ entries.append(
+ SitemapEntry(
+ url=loc,
+ lastmod=_text(url_el, "lastmod"),
+ changefreq=_text(url_el, "changefreq"),
+ priority=_text(url_el, "priority"),
+ )
+ )
+ return entries
+
+
+@dataclass
+class _FetchResult:
+ """Return value from a single-URL fetch worker."""
+
+ url: str
+ timestamp: int
+ content: str | None
+ error: str | None
+ # Child sitemap URLs to enqueue for the next BFS wave
+ child_urls: list[str] = field(default_factory=list)
+ # Page entries discovered (leaf nodes)
+ page_entries: list[SitemapEntry] = field(default_factory=list)
+
+
+def _fetch_one(url: str) -> _FetchResult:
+ """
+ Fetch and parse a single sitemap/robots.txt URL.
+ Returns a _FetchResult describing what was found — no shared state touched.
+ """
+ timestamp = int(time.time())
+ content: str | None = None
+ error: str | None = None
+
+ try:
+ content = _fetch_content(url)
+ except Exception as e:
+ error = str(e)
+ logger.warning("Failed to fetch %s: %s", url, e)
+ return _FetchResult(url=url, timestamp=timestamp, content=None, error=error)
+
+ child_urls: list[str] = []
+ page_entries: list[SitemapEntry] = []
+
+ if _is_robots_txt(url):
+ child_urls = _parse_robots(content)
+ else:
+ try:
+ root = ET.fromstring(content)
+ except ET.ParseError as e:
+ logger.warning("Failed to parse XML from %s: %s", url, e)
+ return _FetchResult(
+ url=url,
+ timestamp=timestamp,
+ content=content,
+ error=str(e),
+ )
+
+ local_tag = root.tag.split("}")[-1] if "}" in root.tag else root.tag
+
+ if local_tag == "sitemapindex":
+ child_urls = _parse_sitemap_index(root)
+ elif local_tag == "urlset":
+ page_entries = _parse_urlset(root)
+ else:
+ logger.warning("Unrecognised XML root <%s> in %s, skipping", local_tag, url)
+
+ return _FetchResult(
+ url=url,
+ timestamp=timestamp,
+ content=content,
+ error=error,
+ child_urls=child_urls,
+ page_entries=page_entries,
+ )
+
+
+@dataclass
+class CrawlResult:
+ """The in-memory result of crawling one or more sitemaps."""
+
+ entries: dict[str, SitemapEntry] # keyed by URL for dedup
+ stats: SitemapToolStats
+
+
+def crawl_sitemap(
+ urls: list[str],
+ save_sitemaps_url_str: str | None = None,
+ parallelism: int = 1,
+) -> CrawlResult:
+ """
+ Fetch and parse one or more sitemap / robots.txt URLs recursively.
+
+ Returns all discovered page entries in memory without writing any output
+ file — callers can then pass the result to write_entries() in whatever
+ format(s) they need, without re-fetching.
+
+ Uses a BFS wave strategy so that the ThreadPoolExecutor workers never block
+ waiting on further submissions — avoiding deadlocks.
+ """
+ stats = SitemapToolStats(count_input=len(urls))
+
+ seen_sitemaps: set[str] = set()
+ # Keyed by URL for deduplication; later entries overwrite earlier ones
+ # (same URL in two sitemaps is uncommon, but we want deterministic output).
+ page_entries: dict[str, SitemapEntry] = {}
+
+ save_url = parse_url(save_sitemaps_url_str) if save_sitemaps_url_str else None
+ sitemap_writer_ctx = None
+ sitemap_writer: Any | None = None
+
+ logger.info(
+ "Starting sitemap crawl: %d input URL(s), parallelism=%d",
+ len(urls),
+ parallelism,
+ )
+ if save_sitemaps_url_str:
+ logger.info("Saving raw sitemap documents to: %s", save_sitemaps_url_str)
+
+ try:
+ if save_url is not None:
+ sitemap_writer_ctx = avro_writer(
+ save_url.with_mode("a+b"), PAGE_SCHEMA, codec=AVRO_CODEC
+ )
+ sitemap_writer = sitemap_writer_ctx.__enter__()
+
+ current_wave: list[str] = list(urls)
+ wave_num = 0
+
+ while current_wave:
+ wave_num += 1
+ # Deduplicate within the wave and against already-seen URLs
+ to_fetch: list[str] = []
+ for url in current_wave:
+ if url not in seen_sitemaps:
+ seen_sitemaps.add(url)
+ to_fetch.append(url)
+
+ if not to_fetch:
+ logger.info("Wave %d: no new URLs to fetch, stopping", wave_num)
+ break
+
+ logger.info(
+ "Wave %d: fetching %d URL(s) (%d skipped as already seen)",
+ wave_num,
+ len(to_fetch),
+ len(current_wave) - len(to_fetch),
+ )
+
+ next_wave: list[str] = []
+
+ with ThreadPoolExecutor(max_workers=parallelism) as executor:
+ future_map = {executor.submit(_fetch_one, url): url for url in to_fetch}
+ for future in as_completed(future_map):
+ try:
+ result = future.result()
+ except Exception as e:
+ logger.error("Unexpected error processing sitemap: %s", e)
+ stats.count_error += 1
+ continue
+
+ if result.error and result.content is None:
+ logger.error("Error fetching %s: %s", result.url, result.error)
+ stats.count_error += 1
+ else:
+ stats.count_sitemaps += 1
+ new_entries = [
+ e for e in result.page_entries if e.url not in page_entries
+ ]
+ for entry in result.page_entries:
+ page_entries[entry.url] = entry
+ logger.debug(
+ "Processed %s: +%d new page(s), %d child sitemap(s) enqueued",
+ result.url,
+ len(new_entries),
+ len(result.child_urls),
+ )
+ next_wave.extend(result.child_urls)
+
+ if sitemap_writer is not None:
+ sitemap_writer.append(
+ {
+ "url": result.url,
+ "timestamp": result.timestamp,
+ "body": result.content,
+ "error": result.error,
+ "metadata": None,
+ }
+ )
+
+ logger.info(
+ "Wave %d complete: %d total page(s) discovered, %d URL(s) in next wave",
+ wave_num,
+ len(page_entries),
+ len(next_wave),
+ )
+ current_wave = next_wave
+
+ finally:
+ if sitemap_writer_ctx is not None:
+ sitemap_writer_ctx.__exit__(None, None, None)
+
+ logger.info(
+ "Sitemap crawl complete: %d input(s), %d sitemap(s) fetched, "
+ "%d URL(s) discovered, %d error(s)",
+ stats.count_input,
+ stats.count_sitemaps,
+ len(page_entries),
+ stats.count_error,
+ )
+
+ return CrawlResult(entries=page_entries, stats=stats)
+
+
+def write_entries(
+ crawl_result: CrawlResult,
+ output_url_str: str,
+ output_format: OutputFormat = OutputFormat.TEXT,
+) -> int:
+ """
+ Write the entries from a CrawlResult to output_url_str in the requested
+ format. Returns the number of entries written. Updates crawl_result.stats.
+ """
+ sorted_entries = sorted(crawl_result.entries.values(), key=lambda e: e.url)
+ out_url = parse_url(output_url_str)
+
+ logger.info(
+ "Writing %d URL(s) to %s (format: %s)",
+ len(sorted_entries),
+ output_url_str,
+ output_format,
+ )
+
+ if output_format == OutputFormat.TEXT:
+ with out_url.with_mode("w") as f:
+ for entry in sorted_entries:
+ f.write(entry.url + "\n")
+ elif output_format == OutputFormat.JSON:
+ with out_url.with_mode("w") as f:
+ for entry in sorted_entries:
+ record: dict[str, Any] = {"url": entry.url}
+ if entry.lastmod is not None:
+ record["lastmod"] = entry.lastmod
+ if entry.changefreq is not None:
+ record["changefreq"] = entry.changefreq
+ if entry.priority is not None:
+ record["priority"] = entry.priority
+ f.write(json.dumps(record) + "\n")
+ elif output_format == OutputFormat.AVRO:
+ with avro_writer(
+ out_url.with_mode("a+b"), ENTRY_SCHEMA, codec=AVRO_CODEC
+ ) as writer:
+ for entry in sorted_entries:
+ writer.append(
+ {
+ "url": entry.url,
+ "lastmod": entry.lastmod,
+ "changefreq": entry.changefreq,
+ "priority": entry.priority,
+ }
+ )
+
+ crawl_result.stats.count_output = len(sorted_entries)
+ logger.info(
+ "Write complete: %d URL(s) written to %s", len(sorted_entries), output_url_str
+ )
+ return len(sorted_entries)
+
+
+def run_sitemap(
+ urls: list[str],
+ output_url_str: str,
+ output_format: OutputFormat = OutputFormat.TEXT,
+ save_sitemaps_url_str: str | None = None,
+ parallelism: int = 1,
+) -> SitemapToolStats:
+ """Crawl sitemaps and write discovered URLs to output_url_str."""
+ logger.info(
+ "run_sitemap: urls=%s output=%s format=%s save_sitemaps=%s parallelism=%d",
+ urls,
+ output_url_str,
+ output_format,
+ save_sitemaps_url_str,
+ parallelism,
+ )
+ crawl_result = crawl_sitemap(
+ urls=urls,
+ save_sitemaps_url_str=save_sitemaps_url_str,
+ parallelism=parallelism,
+ )
+ write_entries(crawl_result, output_url_str, output_format)
+ logger.info(
+ "Sitemap complete: %d input(s), %d sitemap(s) fetched, "
+ "%d URL(s) written, %d error(s)",
+ crawl_result.stats.count_input,
+ crawl_result.stats.count_sitemaps,
+ crawl_result.stats.count_output,
+ crawl_result.stats.count_error,
+ )
+ return crawl_result.stats
+
+
+class SitemapTool:
+ def name(self) -> str:
+ return "sitemap"
+
+ def configure(self, subparsers: argparse._SubParsersAction) -> None:
+ parser = subparsers.add_parser(
+ self.name(), help="Generate a crawl from a sitemap."
+ )
+ parser.add_argument(
+ "urls",
+ nargs="+",
+ help=(
+ "One or more sitemap URLs, sitemap index URLs, or robots.txt URLs. "
+ "Local file paths are also accepted."
+ ),
+ )
+ parser.add_argument(
+ "--output",
+ required=True,
+ help="Destination file for discovered page URLs.",
+ )
+ parser.add_argument(
+ "--output-format",
+ choices=[f.value for f in OutputFormat],
+ default=OutputFormat.TEXT.value,
+ help="Output format: text (default), json (JSONL), or avro.",
+ )
+ parser.add_argument(
+ "--save-sitemaps",
+ default=None,
+ help=(
+ "If provided, save each fetched sitemap/robots.txt document as an "
+ "Avro Page record (same schema as the crawl tool) to this path."
+ ),
+ )
+ parser.add_argument(
+ "--parallelism",
+ type=int,
+ default=1,
+ help="Number of concurrent sitemap fetches (default: 1).",
+ )
+
+ def run(self, args: argparse.Namespace) -> None:
+ run_sitemap(
+ urls=args.urls,
+ output_url_str=args.output,
+ output_format=OutputFormat(args.output_format),
+ save_sitemaps_url_str=args.save_sitemaps,
+ parallelism=args.parallelism,
+ )
diff --git a/tests/test_sitemap_tool.py b/tests/test_sitemap_tool.py
new file mode 100644
index 0000000..1ffdcca
--- /dev/null
+++ b/tests/test_sitemap_tool.py
@@ -0,0 +1,579 @@
+# SPDX-FileCopyrightText: 2026 Greg Brandt
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import xml.etree.ElementTree as ET
+from unittest.mock import patch
+
+import pytest
+from avrokit import avro_records, parse_url
+
+from rubbernecker.sitemap.tool import (
+ OutputFormat,
+ SitemapTool,
+ _is_robots_txt,
+ _parse_robots,
+ _parse_sitemap_index,
+ _parse_urlset,
+ crawl_sitemap,
+ run_sitemap,
+ write_entries,
+)
+
+# ---------------------------------------------------------------------------
+# Fixtures / helpers
+# ---------------------------------------------------------------------------
+
+URLSET_XML = """\
+
+
+ https://example.com/page1
+ https://example.com/page2
+ https://example.com/page3
+
+"""
+
+URLSET_RICH_XML = """\
+
+
+
+ https://example.com/page1
+ 2024-01-15
+ weekly
+ 0.8
+
+
+ https://example.com/page2
+ 2024-02-01
+
+
+ https://example.com/page3
+
+
+"""
+
+SITEMAP_INDEX_XML = """\
+
+
+ https://example.com/sitemap-a.xml
+ https://example.com/sitemap-b.xml
+
+"""
+
+URLSET_A_XML = """\
+
+
+ https://example.com/a/1
+ https://example.com/a/2
+
+"""
+
+URLSET_B_XML = """\
+
+
+ https://example.com/b/1
+
+"""
+
+ROBOTS_TXT = """\
+User-agent: *
+Disallow: /private/
+
+Sitemap: https://example.com/sitemap.xml
+Sitemap: https://example.com/sitemap2.xml
+"""
+
+ROBOTS_TXT_NO_SITEMAP = """\
+User-agent: *
+Disallow: /private/
+"""
+
+# ---------------------------------------------------------------------------
+# Unit tests: pure parsing helpers
+# ---------------------------------------------------------------------------
+
+
+def test_is_robots_txt_http():
+ assert _is_robots_txt("https://example.com/robots.txt") is True
+
+
+def test_is_robots_txt_local():
+ assert _is_robots_txt("/tmp/robots.txt") is True
+
+
+def test_is_robots_txt_case_insensitive():
+ assert _is_robots_txt("https://example.com/Robots.TXT") is True
+
+
+def test_is_robots_txt_false():
+ assert _is_robots_txt("https://example.com/sitemap.xml") is False
+
+
+def test_parse_robots_extracts_all_sitemaps():
+ result = _parse_robots(ROBOTS_TXT)
+ assert result == [
+ "https://example.com/sitemap.xml",
+ "https://example.com/sitemap2.xml",
+ ]
+
+
+def test_parse_robots_no_sitemaps():
+ result = _parse_robots(ROBOTS_TXT_NO_SITEMAP)
+ assert result == []
+
+
+def test_parse_sitemap_index():
+ root = ET.fromstring(SITEMAP_INDEX_XML)
+ result = _parse_sitemap_index(root)
+ assert result == [
+ "https://example.com/sitemap-a.xml",
+ "https://example.com/sitemap-b.xml",
+ ]
+
+
+def test_parse_urlset():
+ root = ET.fromstring(URLSET_XML)
+ result = _parse_urlset(root)
+ assert [e.url for e in result] == [
+ "https://example.com/page1",
+ "https://example.com/page2",
+ "https://example.com/page3",
+ ]
+ # No metadata in this fixture
+ for entry in result:
+ assert entry.lastmod is None
+ assert entry.changefreq is None
+ assert entry.priority is None
+
+
+def test_parse_urlset_metadata():
+ root = ET.fromstring(URLSET_RICH_XML)
+ result = _parse_urlset(root)
+ assert len(result) == 3
+
+ assert result[0].url == "https://example.com/page1"
+ assert result[0].lastmod == "2024-01-15"
+ assert result[0].changefreq == "weekly"
+ assert result[0].priority == "0.8"
+
+ assert result[1].url == "https://example.com/page2"
+ assert result[1].lastmod == "2024-02-01"
+ assert result[1].changefreq is None
+ assert result[1].priority is None
+
+ assert result[2].url == "https://example.com/page3"
+ assert result[2].lastmod is None
+
+
+def test_parse_urlset_no_namespace():
+ xml = """\
+
+ https://example.com/nons
+"""
+ root = ET.fromstring(xml)
+ result = _parse_urlset(root)
+ assert len(result) == 1
+ assert result[0].url == "https://example.com/nons"
+
+
+# ---------------------------------------------------------------------------
+# Unit tests: run_sitemap with mocked HTTP
+# ---------------------------------------------------------------------------
+
+
+def _make_fetch_map(mapping: dict[str, str]):
+ """Return a side_effect function for patching _fetch_content."""
+
+ def _fetch(url: str) -> str:
+ if url in mapping:
+ return mapping[url]
+ raise ValueError(f"Unexpected URL in test: {url}")
+
+ return _fetch
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_simple_urlset(mock_fetch, tmp_path):
+ mock_fetch.side_effect = _make_fetch_map(
+ {"https://example.com/sitemap.xml": URLSET_XML}
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=["https://example.com/sitemap.xml"],
+ output_url_str=output,
+ output_format=OutputFormat.TEXT,
+ )
+ assert stats.count_input == 1
+ assert stats.count_sitemaps == 1
+ assert stats.count_output == 3
+ assert stats.count_error == 0
+
+ with open(output) as f:
+ lines = [line.strip() for line in f if line.strip()]
+ assert sorted(lines) == [
+ "https://example.com/page1",
+ "https://example.com/page2",
+ "https://example.com/page3",
+ ]
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_index_drills_down(mock_fetch, tmp_path):
+ mock_fetch.side_effect = _make_fetch_map(
+ {
+ "https://example.com/sitemap-index.xml": SITEMAP_INDEX_XML,
+ "https://example.com/sitemap-a.xml": URLSET_A_XML,
+ "https://example.com/sitemap-b.xml": URLSET_B_XML,
+ }
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=["https://example.com/sitemap-index.xml"],
+ output_url_str=output,
+ )
+ assert stats.count_sitemaps == 3 # index + a + b
+ assert stats.count_output == 3
+ assert stats.count_error == 0
+
+ with open(output) as f:
+ lines = [line.strip() for line in f if line.strip()]
+ assert sorted(lines) == [
+ "https://example.com/a/1",
+ "https://example.com/a/2",
+ "https://example.com/b/1",
+ ]
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_robots_txt(mock_fetch, tmp_path):
+ mock_fetch.side_effect = _make_fetch_map(
+ {
+ "https://example.com/robots.txt": ROBOTS_TXT,
+ "https://example.com/sitemap.xml": URLSET_XML,
+ "https://example.com/sitemap2.xml": URLSET_B_XML,
+ }
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=["https://example.com/robots.txt"],
+ output_url_str=output,
+ )
+ # robots.txt itself counts as a sitemap fetch; plus the two linked sitemaps
+ assert stats.count_sitemaps == 3
+ assert stats.count_output == 4 # 3 from sitemap.xml + 1 from sitemap2.xml
+ assert stats.count_error == 0
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_deduplicates_urls(mock_fetch, tmp_path):
+ """Same page URL appearing in two different sitemaps should only appear once."""
+ duplicate_xml = """\
+
+
+ https://example.com/page1
+ https://example.com/page1
+"""
+ mock_fetch.side_effect = _make_fetch_map(
+ {"https://example.com/sitemap.xml": duplicate_xml}
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=["https://example.com/sitemap.xml"],
+ output_url_str=output,
+ )
+ assert stats.count_output == 1
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_deduplicates_across_inputs(mock_fetch, tmp_path):
+ """Two top-level inputs pointing to the same URL should only be fetched once."""
+ mock_fetch.side_effect = _make_fetch_map(
+ {"https://example.com/sitemap.xml": URLSET_XML}
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=[
+ "https://example.com/sitemap.xml",
+ "https://example.com/sitemap.xml",
+ ],
+ output_url_str=output,
+ )
+ assert mock_fetch.call_count == 1
+ assert stats.count_output == 3
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_fetch_error_continues(mock_fetch, tmp_path):
+ """A fetch error on one sitemap should not abort the run."""
+
+ def _fetch(url: str) -> str:
+ if url == "https://example.com/sitemap-ok.xml":
+ return URLSET_XML
+ raise requests.exceptions.ConnectionError("network error")
+
+ import requests
+
+ mock_fetch.side_effect = _fetch
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=[
+ "https://example.com/sitemap-ok.xml",
+ "https://example.com/sitemap-bad.xml",
+ ],
+ output_url_str=output,
+ )
+ assert stats.count_error == 1
+ assert stats.count_output == 3
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_output_format_json(mock_fetch, tmp_path):
+ import json as _json
+
+ mock_fetch.side_effect = _make_fetch_map(
+ {"https://example.com/sitemap.xml": URLSET_RICH_XML}
+ )
+ output = str(tmp_path / "out.jsonl")
+ run_sitemap(
+ urls=["https://example.com/sitemap.xml"],
+ output_url_str=output,
+ output_format=OutputFormat.JSON,
+ )
+ with open(output) as f:
+ records = [_json.loads(line) for line in f if line.strip()]
+ assert len(records) == 3
+ assert all("url" in r for r in records)
+ assert sorted(r["url"] for r in records) == [
+ "https://example.com/page1",
+ "https://example.com/page2",
+ "https://example.com/page3",
+ ]
+ # page1 has all metadata fields
+ page1 = next(r for r in records if r["url"] == "https://example.com/page1")
+ assert page1["lastmod"] == "2024-01-15"
+ assert page1["changefreq"] == "weekly"
+ assert page1["priority"] == "0.8"
+ # page2 has only lastmod
+ page2 = next(r for r in records if r["url"] == "https://example.com/page2")
+ assert page2["lastmod"] == "2024-02-01"
+ assert "changefreq" not in page2
+ assert "priority" not in page2
+ # page3 has no metadata fields at all
+ page3 = next(r for r in records if r["url"] == "https://example.com/page3")
+ assert "lastmod" not in page3
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_output_format_avro(mock_fetch, tmp_path):
+ mock_fetch.side_effect = _make_fetch_map(
+ {"https://example.com/sitemap.xml": URLSET_RICH_XML}
+ )
+ output = str(tmp_path / "out.avro")
+ run_sitemap(
+ urls=["https://example.com/sitemap.xml"],
+ output_url_str=output,
+ output_format=OutputFormat.AVRO,
+ )
+ records = list(avro_records(parse_url(output)))
+ assert len(records) == 3
+ assert sorted(r["url"] for r in records) == [
+ "https://example.com/page1",
+ "https://example.com/page2",
+ "https://example.com/page3",
+ ]
+ page1 = next(r for r in records if r["url"] == "https://example.com/page1")
+ assert page1["lastmod"] == "2024-01-15"
+ assert page1["changefreq"] == "weekly"
+ assert page1["priority"] == "0.8"
+ page3 = next(r for r in records if r["url"] == "https://example.com/page3")
+ assert page3["lastmod"] is None
+ assert page3["changefreq"] is None
+ assert page3["priority"] is None
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_save_sitemaps(mock_fetch, tmp_path):
+ mock_fetch.side_effect = _make_fetch_map(
+ {
+ "https://example.com/sitemap-index.xml": SITEMAP_INDEX_XML,
+ "https://example.com/sitemap-a.xml": URLSET_A_XML,
+ "https://example.com/sitemap-b.xml": URLSET_B_XML,
+ }
+ )
+ output = str(tmp_path / "out.txt")
+ sitemaps_output = str(tmp_path / "sitemaps.avro")
+ run_sitemap(
+ urls=["https://example.com/sitemap-index.xml"],
+ output_url_str=output,
+ save_sitemaps_url_str=sitemaps_output,
+ )
+ records = list(avro_records(parse_url(sitemaps_output)))
+ saved_urls = {r["url"] for r in records}
+ assert saved_urls == {
+ "https://example.com/sitemap-index.xml",
+ "https://example.com/sitemap-a.xml",
+ "https://example.com/sitemap-b.xml",
+ }
+ # Each record has the raw XML body
+ for r in records:
+ assert r["body"] is not None
+ assert r["error"] is None
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_save_sitemaps_records_errors(mock_fetch, tmp_path):
+ """Error records should be saved with error set and body null."""
+
+ def _fetch(url: str) -> str:
+ raise ValueError("oops")
+
+ mock_fetch.side_effect = _fetch
+ output = str(tmp_path / "out.txt")
+ sitemaps_output = str(tmp_path / "sitemaps.avro")
+ run_sitemap(
+ urls=["https://example.com/sitemap.xml"],
+ output_url_str=output,
+ save_sitemaps_url_str=sitemaps_output,
+ )
+ records = list(avro_records(parse_url(sitemaps_output)))
+ assert len(records) == 1
+ assert records[0]["error"] is not None
+ assert records[0]["body"] is None
+
+
+@patch("rubbernecker.sitemap.tool._fetch_content")
+def test_run_sitemap_parallelism(mock_fetch, tmp_path):
+ """Parallel mode should still produce the same results."""
+ mock_fetch.side_effect = _make_fetch_map(
+ {
+ "https://example.com/sitemap-index.xml": SITEMAP_INDEX_XML,
+ "https://example.com/sitemap-a.xml": URLSET_A_XML,
+ "https://example.com/sitemap-b.xml": URLSET_B_XML,
+ }
+ )
+ output = str(tmp_path / "out.txt")
+ stats = run_sitemap(
+ urls=["https://example.com/sitemap-index.xml"],
+ output_url_str=output,
+ parallelism=4,
+ )
+ assert stats.count_output == 3
+ assert stats.count_error == 0
+
+
+# ---------------------------------------------------------------------------
+# SitemapTool.configure smoke test
+# ---------------------------------------------------------------------------
+
+
+def test_sitemap_tool_configure():
+ import argparse
+
+ tool = SitemapTool()
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command")
+ tool.configure(subparsers)
+
+ args = parser.parse_args(
+ [
+ "sitemap",
+ "https://example.com/sitemap.xml",
+ "--output",
+ "/tmp/out.txt",
+ "--output-format",
+ "json",
+ "--parallelism",
+ "4",
+ ]
+ )
+ assert args.urls == ["https://example.com/sitemap.xml"]
+ assert args.output == "/tmp/out.txt"
+ assert args.output_format == "json"
+ assert args.parallelism == 4
+ assert args.save_sitemaps is None
+
+
+def test_sitemap_tool_configure_save_sitemaps():
+ import argparse
+
+ tool = SitemapTool()
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command")
+ tool.configure(subparsers)
+
+ args = parser.parse_args(
+ [
+ "sitemap",
+ "https://example.com/sitemap.xml",
+ "--output",
+ "/tmp/out.txt",
+ "--save-sitemaps",
+ "/tmp/sitemaps.avro",
+ ]
+ )
+ assert args.save_sitemaps == "/tmp/sitemaps.avro"
+
+
+# ---------------------------------------------------------------------------
+# Integration test: real HTTP (skipped by default)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+def test_run_sitemap_integration_all_formats(tmp_path):
+ """
+ Crawl https://pypi.org/sitemap.xml once, then write the same CrawlResult
+ in all three output formats and verify each one is well-formed and
+ consistent.
+ """
+ import json as _json
+
+ result = crawl_sitemap(
+ urls=["https://pypi.org/sitemap.xml"],
+ parallelism=4,
+ )
+
+ assert result.stats.count_output == 0, "count_output not set until write_entries"
+ assert result.stats.count_sitemaps > 0
+ assert result.stats.count_error == 0
+ assert len(result.entries) > 0
+
+ expected_count = len(result.entries)
+ expected_urls = sorted(result.entries.keys())
+
+ # --- text ---
+ text_out = str(tmp_path / "out.txt")
+ n = write_entries(result, text_out, OutputFormat.TEXT)
+ assert n == expected_count
+ with open(text_out) as f:
+ text_lines = [line.strip() for line in f if line.strip()]
+ assert text_lines == expected_urls
+
+ # --- json ---
+ json_out = str(tmp_path / "out.jsonl")
+ n = write_entries(result, json_out, OutputFormat.JSON)
+ assert n == expected_count
+ with open(json_out) as f:
+ json_records = [_json.loads(line) for line in f if line.strip()]
+ assert len(json_records) == expected_count
+ assert [r["url"] for r in json_records] == expected_urls
+ # All records must have a url key; metadata keys present only when non-null
+ for r in json_records:
+ assert "url" in r
+ for key in ("lastmod", "changefreq", "priority"):
+ if key in r:
+ assert isinstance(r[key], str)
+
+ # --- avro ---
+ avro_out = str(tmp_path / "out.avro")
+ n = write_entries(result, avro_out, OutputFormat.AVRO)
+ assert n == expected_count
+ avro_recs = list(avro_records(parse_url(avro_out)))
+ assert len(avro_recs) == expected_count
+ assert sorted(r["url"] for r in avro_recs) == expected_urls
+ # Avro always has all fields (null when absent)
+ for r in avro_recs:
+ assert "url" in r
+ assert "lastmod" in r
+ assert "changefreq" in r
+ assert "priority" in r
diff --git a/uv.lock.license b/uv.lock.license
new file mode 100644
index 0000000..0b50554
--- /dev/null
+++ b/uv.lock.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2026 Greg Brandt
+
+SPDX-License-Identifier: Apache-2.0