A small, deterministic mock storefront for stress-testing web scrapers and crawlers. Each request returns a real HTML page rendered with FastHTML, but the layout, element ids, copy, and form fields drift — predictably — based on two knobs you turn at startup. Reproduce the same drift twice and your scraper has a bug; let the drift escalate and you'll find where it breaks.
Realistic scrapers fail in messy, non-obvious ways:
- A category page silently renames a class name.
- A product detail injects an extra form field on Tuesdays.
- The "Buy now" button becomes "Checkout now! (only 12 items left!)".
- Two adjacent DOM sections swap places.
Chaotic Shop reproduces those failure modes on demand. The catalog is generated from a small word list × a product id, so the same parameters always produce the same site — making bugs reproducible and reviews boring.
- Deterministic, parameterised chaos — same env vars, byte-identical site.
- Two orthogonal chaos dimensions: category-level (shared by all products in a category) and product-level (unique per product).
- A typed, dependency-free domain model (
Catalog,Product,Skeleton) that can be exercised from a notebook without spinning up a server. - Server-rendered with FastHTML + Pico CSS — no JS build pipeline.
- Fully type-checked with
tyand linted/formatted withruff. - Containerised, with a multi-stage
uv-powered Dockerfile.
src/
├── chaotic_shop/ # web layer (FastHTML)
│ ├── __init__.py # app + entrypoint (uvicorn-importable as chaotic_shop:app)
│ ├── config.py # Settings dataclass, env-loaded, frozen
│ ├── components.py # header / breadcrumbs
│ ├── routes.py # HTTP handlers (registered on import)
│ └── product_page.py # product detail renderer
└── core/ # domain — no web dependencies
├── __init__.py # Catalog + Product (TypedDict)
├── names.py # static word lists
└── skeleton.py # chaos mutation engine
The boundary between core/ and chaotic_shop/ is strict — core/ doesn't
import from FastHTML, Starlette, or anything web-related, so the domain model
is trivially unit-testable.
core.skeleton defines four orthogonal mutation kinds, dispatched through
match statements:
| Mutation | Effect |
|---|---|
ReorderSections |
Swap two adjacent sections of the product page. |
AddSectionId |
Inject a stable DOM id on one section, derived from the category. |
ChangeText |
Rewrite a call-to-action label (Checkout / Submit Review). |
FlipFlag |
Toggle an optional form field (last name, email). |
choose_mutation() rolls a weighted lottery seeded from
(category_index, product_id, step) — so a single product always lands on
the same mutation sequence, but increasing chaos exposes more variants.
Requires Python 3.13+ and uv.
uv sync
uv run chaotic_shopOpen http://localhost:5001. Increase chaos:
CATEGORY_CHAOS=5 PRODUCT_CHAOS=2 uv run chaotic_shopAll settings come from environment variables, parsed by Settings.from_env():
| Variable | Default | Effect |
|---|---|---|
CATEGORY_CHAOS |
0 |
Mutations shared by every product in a category. Increases inter-category drift. |
PRODUCT_CHAOS |
0 |
Mutations unique per product. Increases intra-category drift. |
NUMBER_OF_PRODUCTS |
1000 |
Size of the catalog. |
REVIEW_DELAY_SECONDS |
4.0 |
Latency injected into POST /submit_review (for testing scraper timeouts). |
CHECKOUT_DELAY_SECONDS |
4.0 |
Latency injected into POST /checkout. |
LOG_LEVEL |
INFO |
Standard library logging level. |
Patterns:
# No drift at all — useful as a control.
CATEGORY_CHAOS=0 PRODUCT_CHAOS=0 uv run chaotic_shop
# Cross-category drift only — every product in "Compact" looks consistent,
# but "Compact" and "Advanced" differ.
CATEGORY_CHAOS=4 PRODUCT_CHAOS=0 uv run chaotic_shop
# Heavy intra-category drift — every product looks different even within
# the same category.
CATEGORY_CHAOS=2 PRODUCT_CHAOS=8 uv run chaotic_shopdocker build -t chaotic-shop .
docker run --rm -p 8000:5001 \
-e CATEGORY_CHAOS=5 -e PRODUCT_CHAOS=2 -e NUMBER_OF_PRODUCTS=1000 \
chaotic-shopServer at http://localhost:8000.
uv sync # install runtime + dev dependencies
uv run pytest # 52 tests, ~8 seconds
uv run ruff check # lint
uv run ruff format # auto-format
uv run ty check src tests # type-checkA few choices worth calling out:
- No global RNG. Every random draw uses a fresh
random.Random(seed)instance so the catalog never mutates process-global state — which means tests can run in parallel and imports stay side-effect-free.
MIT — see LICENSE.