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 <brandt.greg@gmail.com> -# -# 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 <brandt.greg@gmail.com> -# -# 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 <brandt.greg@gmail.com> +# +# 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 <url> 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 <loc> values from a <sitemapindex> 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 <url> entries (with all standard metadata) from a <urlset> 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 <brandt.greg@gmail.com> +# +# 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 = """\ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/page1</loc></url> + <url><loc>https://example.com/page2</loc></url> + <url><loc>https://example.com/page3</loc></url> +</urlset> +""" + +URLSET_RICH_XML = """\ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://example.com/page1</loc> + <lastmod>2024-01-15</lastmod> + <changefreq>weekly</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>https://example.com/page2</loc> + <lastmod>2024-02-01</lastmod> + </url> + <url> + <loc>https://example.com/page3</loc> + </url> +</urlset> +""" + +SITEMAP_INDEX_XML = """\ +<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap><loc>https://example.com/sitemap-a.xml</loc></sitemap> + <sitemap><loc>https://example.com/sitemap-b.xml</loc></sitemap> +</sitemapindex> +""" + +URLSET_A_XML = """\ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/a/1</loc></url> + <url><loc>https://example.com/a/2</loc></url> +</urlset> +""" + +URLSET_B_XML = """\ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/b/1</loc></url> +</urlset> +""" + +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 = """\ +<urlset> + <url><loc>https://example.com/nons</loc></url> +</urlset>""" + 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 = """\ +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/page1</loc></url> + <url><loc>https://example.com/page1</loc></url> +</urlset>""" + 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 <brandt.greg@gmail.com> + +SPDX-License-Identifier: Apache-2.0