Skip to content

Repository files navigation

ANANSI

Supplier catalogue integrity for B2B distributors. A scraper that breaks loudly is an outage you fix on Monday. A scraper that breaks quietly quotes your customers the wrong price for six weeks, and nothing in the logs says so.

ANANSI watches distributor catalogues, decides whether a change is a broken scraper or a real commercial event, and — when it really is the scraper — repairs it through Bright Data Scraper Studio without ever letting an unverified fix reach production.

Live scrape target: anmolgupta-24.github.io/anansi — the chaos catalogue, one page per failure mode, permanently reachable.

pip install -e . && anansi demo

No credentials, no network, no credits. The full failure matrix runs offline in about fifteen seconds.


The failure this is built around

A distributor rolls out a promotion banner. The struck-through list price is now emitted before the real price, sharing the same CSS class:

<td class="price-cell">
  <span class="price price--list">₹5,973.63</span>   <!-- what the scraper now takes -->
  <span class="price">₹4,594.62</span>               <!-- what it should take -->
</td>

Every downstream check passes. The value is present, it is a number, it is in range, it is in the right currency. It is simply the wrong number — 27% high on every discounted line. Schema validation cannot see this. Neither can a human skimming two preview rows.

This is the case ANANSI exists for, and it is the one every "self-healing scraper" demo skips.


What it does

Three layers run cheapest-first, and each one can only say what it is actually competent to say:

Layer Question Catches
1 - Contract Does the output match its declared schema? nulls, type breaks, out-of-range values, row collapse
2 - Drift Did the distribution move, and did structure move with it? real repricings, quiet value shifts, partial moves
3 - Semantic Do these values still look like what this field holds? a breadcrumb where a part title used to be

They feed one three-way verdict, which is the actual product:

  • structure_broken — the scraper is wrong. Heal it.
  • world_changed — the scraper is fine, the supplier repriced. Alert; heal nothing.
  • stable — nothing to do.

The middle verdict is the one that saves money. A system that cannot tell a 28% sale from a 28% breakage burns credits rewriting a scraper that was working perfectly, and overwrites correct extraction logic in the process.

When statistics abstain, the DOM decides

A shift across the whole catalogue is a real event. A shift confined to part of it is genuinely ambiguous — a promotion on some lines and a selector collision on some lines produce identical numbers, and no amount of statistics will separate them.

So drift does not guess. It flags the field as suspect and hands the question to the page, where the collision is plainly visible as two nodes matching one selector where there used to be one:

2 - drift    PASS   Part of the catalogue moved coherently while the rest held still
                    on price. That is equally consistent with a partial promotion and
                    with a selector collision, so this is not decided here —
                    escalating to a DOM check.

  STABLE  ->  STRUCTURE BROKEN  —  overruled by the DOM check

Statistics raise the question; the markup answers it.

The gate

heal is called without --auto-approve. The returned preview_result is scored against the same contract that caught the break, and only a passing preview is approved:

+  0.06s heal_returned    status=awaiting_approval, 2 preview rows
+  0.06s gate_evaluated   PASS (2 rows, 8 fields)
+  0.06s approved         preview satisfied the contract; fix is live

A fix can never enter production on weaker evidence than the check that caught the break. When the preview fails, ANANSI calls approve --reject, and production is left byte-identical.

This is not hypothetical. Against the live platform, a heal came back with eight of nine fields matching ground truth exactly and an empty object for the ninth on every row. A human reviewing two preview rows would have approved it. The contract rejected it.

Passing is not the same as proven

Running against a real distributor exposed the limit of that gate. A live heal came back with a one-row preview, and it passed cleanly — but the defect being repaired was a 15% null rate confined to out-of-stock tiles, and the single preview row was an in-stock product. It could not possibly demonstrate the fix.

Seeing only clean rows bounds a null rate but never proves it. With n clean observations the true rate is bounded at 1 - 0.05^(1/n):

clean preview rows null rate is at most
1 95.0%
2 77.6%
10 25.9%
30 9.5%
59 5.0%

So a two-row preview cannot establish a 5% ceiling — not approximately, not at all. The gate now says so out loud, and the loop treats approval as necessary rather than sufficient: after approving, it re-runs the full collector and validates that. A fix that passed preview but fails the real run is the worst case available — production is live and wrong — and it escalates rather than closing as healed.

+  0.0s gate_evaluated  PASS (1 rows, 5 fields)
+  0.0s gate_unproven   title, product_url, image_url pass on 1 preview row(s), too few to
                        demonstrate their null ceilings; confirming on a full run
+  3.3s approved        preview satisfied the contract; fix is live

And a passing contract is not the same as a fixed bug

The confirming run then produced the most useful failure in the project. It passed — and the defect was completely untouched.

The collector was dropping title, product_url and image_url on every out-of-stock tile: 46 of 300 rows, a 15% null rate, a clear contract failure. After the heal, the same contract passed at 1%. Nothing had been repaired. The distributor had restocked. Two out-of-stock tiles remained instead of forty-six, and they were still just as broken — 0 of 2 carried a title. The population had moved and dragged the statistic under the ceiling with it.

A null rate is a population statistic, so it is not safe to close an incident on. ANANSI now localises a failure when it detects one — a one-level decision stump over the low-cardinality fields, finding what the failing rows have in common:

+  0.1s localised   title is null on 100% of the 46 rows where in_stock=False,
                    and 0% of the rest

and confirmation re-measures that cohort rather than the average:

+ 51.4s confirm_failed   2 of 2 rows with in_stock=False are still missing title

A fix is confirmed only when the rows that were broken are present in the new run and are no longer broken. A cohort that has vanished from the run demonstrates nothing and escalates rather than closing. Identifier-like columns are excluded from the search, or a unique sku would "explain" every failure perfectly and explain nothing.


Quickstart

git clone https://github.com/ANMOLGUPTA-24/anansi.git
cd anansi
python -m venv .venv && source .venv/bin/activate
pip install -e .

anansi demo                       # every scenario, offline
anansi demo --only list_price_first

Driving the chaos catalogue by hand — locally:

anansi serve                      # http://127.0.0.1:8800
anansi chaos list
anansi chaos mutate rename_price_class
anansi chaos reset

…or against the published copy, where each mutation is simply a different page:

anansi export docs                                     # regenerate the static site
anansi check contracts/chaos_catalogue.json \
      --url https://anmolgupta-24.github.io/anansi/list_price_first.html

Against a real Bright Data collector (this one spends credits):

export SENSORS_COLLECTOR_ID=c_xxxxxxxxxxxx
anansi check contracts/electronicscomp_sensors.json    # one pass
anansi watch contracts/electronicscomp_sensors.json \
      --interval 900 --webhook https://hooks.example/anansi
anansi incidents

Production, with no deployment step at all:

anansi trigger c_xxxxxxxxxxxx https://www.electronicscomp.com/sensors-module/sensors
anansi fetch   j_yyyyyyyyyyyy

Production wiring

bdata scraper run is the development ergonomic: synchronous, chatty, made for a terminal. Production is the Collector ID, which is an API in its own right — POST /dca/trigger, callable from any language or scheduler, with nothing to deploy.

anansi trigger is that call, anansi fetch collects the result, and anansi watch puts the whole loop on a schedule, emitting exactly one structured event per pass — the same shape for a clean run, a suppressed real-world change, or a rejected fix:

{"collector_id": "c_mt5hk4zd19d24n40yt", "outcome": "no_action", "rows": 254,
 "severity": "info", "url": "https://www.electronicscomp.com/sensors-module/sensors",
 "verdict": "world_changed", "failed_fields": [], "incident_id": "a3f9c1d0e2b7",
 "rationale": "Values shifted on price_inr while structure held ..."}

That event is the product's actual output. It goes to stdout as one JSON line — pipe it anywhere — and to a webhook when ANANSI_WEBHOOK_URL is set, so a pricing system or a procurement dashboard consumes verdicts rather than raw scrapes. severity is what a pager routes on: info for healed and world-changed, warning for a stale contract, critical for a fix that was rejected and needs a human.

The API token is never a function argument. It is read from BRIGHTDATA_API_TOKEN, or from the CLI's own credentials file, which lives outside the repository.

What the structured output is for

Monitoring is a means. The end is a buyer asking three questions on Monday: what did my suppliers change, what came back into stock, and — the question nobody else answers — can I trust this week's numbers enough to quote from them?

anansi report before.json after.json --contract contracts/electronicscomp_sensors.json

Every figure carries the verdict from the same classifier the control plane uses, and the report has three trust states rather than two:

State Meaning
Trusted Structure held, movement attributed. Quote from it.
Unverified The movement is real in the data but unadjudicated — equally consistent with a partial promotion and with a selector picking the wrong node. Hold.
Untrusted The run failed its integrity check. Do not quote; a human needs to look at the scraper.

The middle state is the one a plain diff cannot express, and the one that matters. On the silent-failure run the report says "Hold — movement on price is unadjudicated (16 lines affected)" rather than cheerfully reporting a 27% promotion that never happened.


The scenario matrix

anansi demo runs all seven and grades itself. The expected outcomes live in anansi/demo.py, and tests/test_scenarios.py reads the same list — neither can quietly move the goalposts.

Scenario Family Correct action Result
baseline nothing; the catalogue is healthy no incident
rename_price_class breaking re-anchor price, approve healed
nest_stock breaking descend into the new node, approve healed
list_price_first silent notice the wrong-but-valid price, approve the fix healed
repricing real event report the move, heal nothing no_action
drop_moq breaking escalate — the field is gone, the contract is stale contract_stale
table_to_cards breaking reject the partial fix, page a human escalated

7/7. Three of them require not healing, which is the harder half.


Architecture

flowchart TD
    A[bdata scraper run] --> B[Layer 1: contract]
    B --> C[Layer 2: drift]
    C --> D[Layer 3: semantic]
    D --> E{three-way verdict}

    E -->|world_changed| F[alert only, heal suppressed]
    E -->|stable + suspect| G[DOM adjudication]
    E -->|structure_broken| H[DOM diagnosis]

    G -->|collision found| H
    G -->|no collision| F

    H --> I{field removed?}
    I -->|yes| J[contract_stale, needs a human]
    I -->|no| K[build evidence prompt]

    K --> L[bdata scraper heal, NO auto-approve]
    L --> M{preview passes the same contract?}
    M -->|yes| N[approve, fix is live]
    M -->|no| O[approve --reject, production untouched]
    O -->|attempts left| K
    O -->|exhausted| P[escalated]
Loading
Module Responsibility
anansi/contract.py declared schema, applied to live runs and heal previews
anansi/drift.py KS, chi-square, MAD z-score, paired ratio shift, three-way classification
anansi/semantic.py hashed n-gram + shape embeddings, percentile-calibrated per-field floors
anansi/diagnose.py DOM diff: where did a known-good value go, and does its selector now collide?
anansi/heal_prompt.py three prompt strategies — naive, validation, evidence
anansi/brightdata.py timed CLI wrapper; envelope status is authoritative, not the exit code
anansi/pipeline.py the closed loop and the approval gate
anansi/incident.py SQLite post-mortems with stage-by-stage timelines
anansi/trigger.py the Collector ID as production API: /dca/trigger and dataset collection
anansi/notify.py one structured event per check, to stdout and an optional webhook
anansi/cohort.py localises where a field goes missing, so a fix can be confirmed on those rows
anansi/report.py the buyer-facing product: what changed, and whether it is safe to quote
anansi/extract.py, anansi/replay.py offline stand-in so the loop is runnable without credits
anansi/cli.py, anansi/ui.py argument wiring and rendering, deliberately kept apart
chaos/ the mutation testbed: a distributor catalogue that breaks on command

How Scraper Studio is used

The scraper itself is built and hosted in Bright Data Scraper Studio, created and driven entirely from the CLI:

bdata scraper create  <url> <description>       # build the collector
bdata scraper run     <collector_id> <url> --sync
bdata scraper heal    <collector_id> <prompt>   # repair, WITHOUT --auto-approve
bdata scraper approve <collector_id>            # only after the gate passes
bdata scraper approve <collector_id> --reject   # when it does not
bdata scrape          <url>                     # Web Unlocker, for reconnaissance

The live target is electronicscomp.com, an Indian electronics distributor — 633 products across 26 pages in the sensors category alone, with MOQ bulk pricing. It is the long tail Rule 1 asks for: no public API, no pre-built connector, and the kind of catalogue a procurement team actually quotes from. One run returns 254 products with title, product URL, price, image and stock state.

A second contract targets the chaos catalogue, where failure modes are reproducible on demand.

The design decision that matters is the missing flag. heal --auto-approve is one call and ships whatever the model produced. Splitting it into heal, verify, approve turns Scraper Studio into something you can put in front of pricing data, because a proposed fix now has to prove itself against the contract before it can go live.

Measured against the live platform

Real collector, real credits, on the hosted CLI (v0.3.5):

Operation Observed
scraper create ~3 min
scraper run --sync 5.8 s
scraper heal 4-6 min
approve --reject 4.1 s
preview_result size 2 rows
Heal success rate ~1 in 3 (2 of 3 failed server-side)
Failed heal corrupts production? No — verified byte-identical
Non-zero exit on server-side failure? No

Two of these changed the code:

  • The CLI exits 0 when a heal fails server-side (status: "heal_trigger_failed"). Trusting the exit code would have marked broken scrapers healthy, so the envelope status is authoritative in CliResult.ok.
  • preview_result returns two rows. A production min_rows: 30 floor would have rejected every correct fix, so the gate validates previews with is_preview=True, which relaxes row counts and nothing else.

The chaos catalogue

A 48-part electronics distributor catalogue ("Meridian Components") that breaks on command. Every mutation is deterministic, seeded, and modelled on something that actually happens. The stylesheet deliberately styles both the original and the renamed classes, so the page looks identical to a human after a breaking change.

Key Family What it does Real-world analogue
baseline No mutation (known-good) healthy catalogue
rename_price_class breaking Rename .price to .product-price-v2 CSS refactor / design-system rename
nest_stock breaking Move stock text into a nested span[data-testid] component library upgrade
table_to_cards breaking Swap the table for a div card grid storefront redesign
lazy_load breaking Defer rows behind client-side JS migration to client-side rendering
drop_moq breaking Remove the MOQ column entirely the distributor stops publishing MOQ
reorder_columns breaking Reorder table columns A/B test on column order
list_price_first silent Emit the struck-through list price first, sharing the class promotion banner rollout
breadcrumb_title silent Inject a breadcrumb matching the part-title selector navigation redesign reuses a content class
repricing real event Every price drops 28% genuine catalogue repricing

The portal is a FastAPI app with a /chaos/truth endpoint carrying ground truth for grading. The control plane never reads it.


Tests

pytest              # 231 tests

The suite exists because several of the load-bearing ideas here were wrong the first time, and only a test found out:

  • The row-count rule was symmetric. A genuine surge in listings read as breakage. A collapse is structural; a surge is not — a broken scraper does not invent rows.
  • Unpaired KS missed a real 28% repricing (D=0.312 against a 0.35 effect floor). Two-sample KS treats a run as an unordered cloud, which dilutes a proportional shift across a wide price range. Matched on SKU, every record had moved by exactly 0.720 with zero spread.
  • Shape features were diluted. A breadcrumb scored 0.959 against a part-title centroid because its two slashes were a 0.077 density averaged across two dozen features. The fix holds across the entire n-gram weight sweep, not at one tuned point.
  • Evidence prompts carried raw HTML. A live heal died on sprintf invalid format %j; the same instruction without angle brackets succeeded. Prompts are now bracket-free structural descriptions, and a test asserts no angle bracket ever reaches one.
  • The silent scenario passed every layer until DOM adjudication was added. It was the case the project is named for and the one case that did not work.

Limitations

Stated plainly, because they bound what this currently proves:

  • The offline demo is offline. anansi demo runs a local extractor and a local repair derived from the diagnoser, not a hosted model. It exercises the real contract, the real drift and semantic layers, the real diagnoser and the real gate — but the healer is local. anansi check is the path that spends credits and calls Scraper Studio for real.
  • Heal reliability is the platform's, not ours. At roughly one success in three, the retry budget matters more than the prompt does.
  • Government portals are out of scope by policy. Bright Data blocks them; this was discovered the expensive way, mid-build, and forced a pivot from a recall-monitoring vertical to B2B distribution. Probe a target with bdata scrape before designing around it.
  • The semantic layer needs a clean baseline. Fit it on a bad run and it will defend the bad run.

How this was built

The hackathon permits coding agents on the condition that you understand what you shipped, verify the generated code, and can explain your technical decisions. Stating the arrangement plainly, since that condition is the interesting one:

Claude (via Claude Code) wrote most of the implementation and the tests. The direction, the problem domain, both pivots, and every live experiment against the Bright Data platform were mine, and the findings that shaped the design came out of running those experiments and reading the results — the two-row preview limit, the sprintf prompt failure, and the restock that made a contract pass while the bug sat untouched. Each of those changed the code, and each is written down at the point in the source where it applies.

Bright Data Scraper Studio's own AI features (scraper create from a natural-language description, and scraper heal) are used as the product's healing mechanism, which is the point of the project rather than a build-time convenience.

No benchmark number in this README is estimated — every measurement in the platform table came from an actual run, and the ones that contradicted my assumptions are the ones written up at greatest length.


License

MIT. See LICENSE.

About

Contract-gated self-healing for B2B supplier catalogue scrapers. Tells a broken scraper from a real repricing, and never ships a fix it cannot prove.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages