aegis-stack-crawl4ai
Web crawling and scraping for Aegis Stack projects.
Wraps crawl4ai (Apache 2.0) and persists results
into a generic, query-friendly crawled_page table.
This is the reference third-party plugin: it exercises the plugin system end to end and
rides on per-plugin schema isolation. On Postgres the plugin's tables live in their own
crawler schema, apart from the rest of the stack; SQLite has no schemas, so there the
table lands unqualified in the single database file.
Install
Inside an Aegis Stack project:
pip install aegis-stack-crawl4ai
aegis add crawl4ai
That will:
- Add
crawl4ai>=0.8.6andalembic>=1.13to your project'spyproject.toml - Render the service (
app/services/crawler/), its router (app/components/backend/api/crawler/), the dashboard card and modal, and acrawlCLI - Generate an Alembic migration creating
crawler.crawled_page - Mount the routes below under
/api/v1/crawler - Add a
Crawlerrow to the dashboard health table, with its own card and modal
The install is HTTP-only: pages are fetched as the server sends them, with no browser. Client-rendered content is out of scope.
Why >=0.8.6?
Crawl4AI 0.8.5 and earlier pull in a transitive litellm dependency that was
compromised in a PyPI supply-chain attack.
0.8.6 is the hotfix release that swaps in a clean fork. Always pin >=0.8.6.
What you get
Dashboard. A Crawler card, and a modal with three tabs. Overview is the counts. Pages drills down: the sites you have crawled, then that site's pages, then one page's stored body with a copy button. Crawl starts a crawl and watches it run, with a row per page as it lands.
API. Six routes under /api/v1/crawler:
| route | does |
|---|---|
POST /crawl |
fetch one URL, store it, return the row |
POST /crawl-batch |
start a background crawl, return 202 + a job_id |
GET /sites |
crawled sites, with page and failure counts |
GET /pages |
recent pages, newest first; ?site= narrows, ?failed_only= filters |
GET /pages/{id} |
one stored page |
GET /stats |
counts, distinct URLs, content-type split, last fetch |
CLI. <project> crawl stats | fetch | site | list | show | retry-failed.
Progress. A batch crawl runs as a framework job, so progress streams over
/api/v1/jobs/{job_id}/events and the dashboard follows it without knowing anything about
the crawler.
Crawl options
Defaults reproduce a single-page fetch. crawl4ai's CrawlerRunConfig takes about a
hundred parameters; these are the handful that change what you actually get back, and the
API, the CLI and the dashboard share them.
| option | default | does |
|---|---|---|
depth |
0 |
how many links deep to follow (hard ceiling of 3) |
max_pages |
25 |
ceiling on pages per seed URL when depth > 0 |
stay_on_domain |
true |
follow links only within the seed's site |
selector |
none | CSS selector: keep only this part of each page |
min_words |
0 |
drop blocks shorter than this many words (nav, chrome) |
fresh |
false |
bypass the network cache and row-level dedup |
<project> crawl site example.com --depth 1 --max-pages 50 --selector "main article"
Settings
Composed into your project's Settings, so they are ordinary env vars:
| setting | default | does |
|---|---|---|
CRAWLER_DEFAULT_TIMEOUT_S |
30 |
per-URL fetch timeout |
CRAWLER_MAX_CONCURRENCY |
5 |
in-flight requests during a batch |
CRAWLER_USER_AGENT |
aegis-stack-crawl4ai/0.1 |
User-Agent sent on every request |
The crawled_page table
Generic by design: URL, content, content hash, JSONB metadata, status code, source kind. Future ingestion plugins (RSS, PDF, sitemap) can share the schema or follow the same shape.
| column | type | notes |
|---|---|---|
id |
int PK | autoincrement |
source_url |
text | indexed |
site |
varchar(255) | registrable host, www. stripped; indexed, and what the sites view groups by |
content |
text | nullable (failures still produce a row) |
content_hash |
varchar(64) | sha256, indexed (use for dedup queries) |
content_type |
varchar(32) | markdown / html / json / binary |
doc_metadata |
jsonb | caller-supplied tags, e.g. {"ign_id": 5208, "cluster": 9} |
status_code |
int | HTTP status; >= 400 is your retry queue |
source_kind |
varchar(32) | producer label (crawl4ai, rss, pdf_ingest, ...) |
fetched_at |
timestamptz | indexed |
Re-fetching a URL writes a new row; dedup-by-hash is a query you run when you care, not a write-path branch.
Use from Python
from app.core.db import AsyncSessionLocal
from app.services.crawler.deps import get_crawler
from app.services.crawler.options import CrawlOptions
crawler = get_crawler() # process-wide instance; the API and health check share it
async with AsyncSessionLocal() as session:
doc = await crawler.fetch(
session,
url="https://example.com",
doc_metadata={"campaign": "march"},
)
print(doc.id, doc.status_code, len(doc.content or ""))
# Follow links, one level, staying on the site.
pages = await crawler.crawl(
session,
url="https://example.com",
options=CrawlOptions(depth=1, max_pages=50),
)
A short-lived script that owns its crawler should build its own and close it:
from app.services.crawler.service import CrawlerService
crawler = CrawlerService()
try:
...
finally:
await crawler.aclose()
Use from the API
curl -X POST http://localhost:8000/api/v1/crawler/crawl \
-H 'Content-Type: application/json' \
-d '{"url": "example.com", "doc_metadata": {"campaign": "march"}}'
curl -X POST http://localhost:8000/api/v1/crawler/crawl-batch \
-H 'Content-Type: application/json' \
-d '{"urls": ["example.com"], "options": {"depth": 1, "max_pages": 50}}'
curl http://localhost:8000/api/v1/crawler/sites
curl "http://localhost:8000/api/v1/crawler/pages?site=example.com&limit=10"
A bare domain is fine anywhere a URL is taken: example.com becomes https://example.com
on the way into the request model.
Bulk fetch example: NWVault recovery
The motivating use case for the plugin: walk a local mirror of a deceased site, extract the identifiers each page references, fetch the corresponding archived snapshot from web.archive.org, and store both the body and the local-file pointer for later reconciliation.
import asyncio
from pathlib import Path
from app.core.db import AsyncSessionLocal
from app.services.crawler.service import CrawlerService
NWVAULT_LOCAL = Path("/path/to/nwvault-legacy")
def parse_ign_link(html_path: Path) -> tuple[int, int] | None:
"""Pull the IGN cluster + id out of a local html page. Returns None if the
page doesn't reference an archived ratings link."""
# ... your parser ...
return None
async def main() -> None:
crawler = CrawlerService()
try:
urls: list[str] = []
meta: dict[str, dict] = {}
for html in NWVAULT_LOCAL.rglob("*.html"):
parsed = parse_ign_link(html)
if not parsed:
continue
cluster, ign_id = parsed
url = (
"https://web.archive.org/web/2006/"
f"http://nwvault.ign.com/View.php?view=Ratings.Viewer"
f"&cluster={cluster}&id={ign_id}"
)
urls.append(url)
meta[url] = {
"local_file": str(html.relative_to(NWVAULT_LOCAL)),
"ign_id": ign_id,
"cluster": cluster,
"source": "ign_archive",
}
await crawler.batch_fetch(
AsyncSessionLocal,
urls=urls,
doc_metadata_for=meta,
)
finally:
await crawler.aclose()
asyncio.run(main())
Then query by your domain identifiers:
SELECT content
FROM crawler.crawled_page
WHERE doc_metadata->>'ign_id' = '5208'
ORDER BY fetched_at DESC
LIMIT 1;
Failed fetches don't tank the batch; the retry queue is just:
SELECT source_url
FROM crawler.crawled_page
WHERE status_code IS NULL OR status_code >= 400;
or <project> crawl retry-failed.
Layout
The plugin follows the host project's own conventions rather than inventing its
own: business logic under app/services/, reads in a queries module, the HTTP
layer with the other routers, display code with the other dashboard code.
aegis-stack-crawl4ai/
├── pyproject.toml # entry point: aegis.plugins:crawl4ai
├── src/aegis_stack_crawl4ai/
│ ├── plugin.py # get_spec() returns the PluginSpec
│ └── templates/{{ project_slug }}/
│ ├── app/services/crawler/
│ │ ├── service.py # AsyncWebCrawler wrapper
│ │ ├── queries.py # every read, so an N+1 audit is one file
│ │ ├── models.py # CrawledPage SQLModel
│ │ ├── schemas.py # Pydantic request / response
│ │ ├── options.py # CrawlOptions, the shared knobs
│ │ ├── urls.py # bare domain -> fetchable URL
│ │ ├── jobs.py # what a crawl job does
│ │ ├── dispatch.py # where it runs
│ │ ├── deps.py # FastAPI DI, one crawler per process
│ │ ├── settings.py # settings mixin (leaf module)
│ │ └── health.py # dashboard health check
│ ├── app/components/backend/api/crawler/
│ │ └── router.py # FastAPI router
│ ├── app/components/frontend/dashboard/
│ │ ├── crawler_ui.py # names, routes and cells the surfaces share
│ │ ├── cards/crawler_card.py
│ │ └── modals/crawler_*.py # modal, pages tab, crawl tab
│ ├── app/cli/crawl.py # crawl commands
│ └── tests/ # shipped into the project and run there
└── tests/
└── test_plugin.py # spec contract tests
Develop
make install # uv sync
make test # pytest
make check # lint + test
make fix # ruff format + autofix
tests/test_plugin.py checks the spec contract here. The tests under
templates/{{ project_slug }}/tests/ ship into the generated project and run
against the real service there, including a layout test that holds the plugin to
the host project's structure.
License
Apache 2.0, same as upstream crawl4ai.
Release files for aegis-stack-crawl4ai 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aegis_stack_crawl4ai-0.1.0.tar.gz | 90.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aegis_stack_crawl4ai-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 154.1 kB
Release files / aegis_stack_crawl4ai-0.1.0.tar.gz
| Download URL | aegis_stack_crawl4ai-0.1.0.tar.gz |
|---|---|
| Size | 90.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f19463563409ea048cf3a064d52cf86d3a04d11fecd64b7003ebde355e61bf61
|
|
BLAKE2b-256 checksum How to use checksums |
92b2e2efa31882e81bf83b4fffa29ea2accaf37f7c0888694d63e2063ad2804e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / aegis_stack_crawl4ai-0.1.0-py3-none-any.whl
| Download URL | aegis_stack_crawl4ai-0.1.0-py3-none-any.whl |
|---|---|
| Size | 63.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e13746065e1c358c36a8b09a57c2755355a46416dfb15b7e2acb6615c5b902cd
|
|
BLAKE2b-256 checksum How to use checksums |
4fba5a33a10d1f1acaff61a863817c0c75b576d65bacf008edac72faf8c38699
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log