Skip to main content

Chronicle

The web scraping library that remembers.

Chronicle is a data-science-native web scraping library. It stores every run, profiles the result, and compares it to history — so silent changes in a page's structure or content are visible before they poison your pipeline.

tests PyPI version GitHub main Python 3.10+ License: MIT Status: Alpha

Follow on LinkedIn

⚠️ Alpha software. Two versions are current:

  • v0.2.1 is on PyPI. Fine for text data; has known numeric-handling bugs.
  • v0.3.0-alpha is on main. Fixes the numeric bugs and adds run-to-run diff, date cleaning, and a JSON connector. Not on PyPI yet.

See Status & Limitations before relying on either.

New in v0.3.0-alpha

Run-to-run diff, a four-step date confidence ladder, a JSON API connector, robots.txt and Retry-After support, and fixes for every numeric-handling bug in v0.2.1. Jump to what's new →

Why Chronicle?

  • Reproducible: every run keeps raw HTML, parsed data, config, and a statistical profile
  • Two comparisons: a row-by-row diff vs. the last run, and a statistical drift check vs. a baseline
  • Honest data: ambiguous values are flagged with the original text, never guessed
  • DataFrame-native: one line from URL to a pandas DataFrame
  • Local-first: files plus SQLite, no server and no account

Quick Start

1. Install — see Installation for all options.

pip install chronicle-ds        # v0.2.1 (PyPI)
# or
pip install "git+https://github.com/TheDataNormad/chronicle.git"   # v0.3.0-alpha

2. Scrape a page into a DataFrame

from chronicle import Scrape

df = Scrape(
    "https://en.wikipedia.org/wiki/List_of_most-viewed_YouTube_videos"
).to_dataframe()

print(df.head())

Auto-detection supports HTML <table> elements only. For product grids, news lists, or SPAs, pass selectors={...} (see Usage Examples).

3. Run it again and see what changed

result = Scrape(url, selectors={...}, key="title").run()

if result.has_changes():
    print(result.changes.pretty())       # +3 added, -1 removed, ~5 changed
if result.has_drift():
    print(result.drift_report.pretty())  # SEVERE: price null % jumped

4. Use the command line

chronicle version
chronicle scrape <url>
chronicle runs

The CLI is still thin (no --selectors flag yet). Use the Python API for selector mode.


How It Works

 URL ──► Acquire ──► Refine ──► Remember ──► ScrapeResult
         fetch.py    parse.py    storage.py   .to_dataframe()
         playwright  clean.py    profile.py   .invalid_rows
         jsonapi.py  normalize   diff.py      .changes  .drift_report
                                 drift.py     .cleaning .errors

Five layers, each depending only on the ones below it: Foundation → Acquisition → Refinement → Memory → Orchestration → Interface. Full details in ARCHITECTURE.md.


Features

🌐 Acquisition

  • HTTP fetcher: retries, exponential backoff, per-host rate limiting, Retry-After, robots.txt (RFC 9309), opt-in cache
  • Headless Chromium via Playwright for JavaScript-rendered pages
  • JSON connector for API endpoints (Shopify and World Bank shapes included)

🧹 Refinement

  • Selector mode and auto table detection for HTML
  • Date cleaning with a four-step confidence ladder; ambiguous dates become NaT with the original text preserved
  • Schema validation with pydantic: coerces types, quarantines invalid rows instead of dropping them
  • Fail-fast selector validation

🕐 Memory

  • Versioned runs: raw HTML, parsed data, config, profile, diff, and cleaning report per run
  • Run-to-run diff: keyed and keyless comparison with honest warnings
  • Drift detection: zero-row, schema, null, mean, range, and selector-health checks
  • Quality reports: row counts, success rate, per-column null %, dtypes, numeric stats
  • URL redaction and microsecond-precision run IDs

📊 Developer experience

  • DataFrame-native; built for Jupyter, pandas, and real pipelines
  • 147 tests; CI on Ubuntu, Windows, and macOS across Python 3.10 to 3.12

Installation

Option Command
Stable (v0.2.1) pip install chronicle-ds
Latest (v0.3.0-alpha) pip install "git+https://github.com/TheDataNormad/chronicle.git"
JS-rendered pages pip install "chronicle-ds[browser]" then playwright install chromium
Development see Contributing

Chronicle requires Python 3.10+.

Scraping numeric data (prices, ratings, counts)? Install from git. v0.2.1 on PyPI has known numeric bugs.


Usage Examples

📄 Selector mode (any HTML page)
from chronicle import Scrape

result = Scrape(
    url="http://books.toscrape.com/",
    selectors={
        "_container": "article.product_pod",
        "title": "h3 > a@title",
        "price": "p.price_color",
        "stock": "p.instock.availability",
        "link":  "h3 > a@href",
    },
    rate_limit=2.0,
).run()

df = result.to_dataframe()

@attr reads an attribute. Pagination currently appends or replaces ?page=N; path-based and cursor-based pagination are on the roadmap.

⚡ JavaScript-rendered pages
result = Scrape(
    url="https://quotes.toscrape.com/js/",
    selectors={
        "_container": "div.quote",
        "text":   "span.text",
        "author": "small.author",
    },
    use_playwright=True,
    wait_for="div.quote",
).run()
Parameter What it does
use_playwright=True Use headless Chromium instead of httpx
wait_for="<css>" Wait for a selector before parsing
wait_until "domcontentloaded" (default), "load", or "networkidle"
cookies=[...] Pre-set cookies (consent walls, sessions)

Colab / Jupyter: Playwright's sync API cannot run inside a live asyncio loop. Run the scrape in a separate thread:

import threading
out = {}
def run():
    out["r"] = Scrape(url, selectors={...}, use_playwright=True).run()
t = threading.Thread(target=run); t.start(); t.join()
df = out["r"].to_dataframe()

Some sites (YouTube, Twitter, LinkedIn) detect headless browsers and serve reduced content. Use their official APIs. Chronicle does not evade bot detection.

🔌 JSON APIs (v0.3.0-alpha)
result = Scrape(
    url="https://api.example.com/products.json",
    records_path="products",          # omit if only one list exists
    fields={"name": "title", "price": "variants.0.price"},
).run()
  • Never guesses which list is the data; raises a clear error if several candidates exist
  • Values keep their JSON types: "00123" stays "00123"
  • Dotted paths index dicts by key and lists by integer
🔁 Changes since the last run (v0.3.0-alpha)
result = Scrape(url, selectors={...}, key="title").run()

if result.has_changes():
    print(result.changes.pretty())
    result.changes.to_dataframe()   # one row per change

Pass key= so Chronicle can tell an edited value from a new row. Without it, only added and removed rows are reported, and Chronicle never guesses a key. If a run had failed pages or returned zero rows, the report says so in warnings instead of presenting removed rows as confirmed deletions.

📅 Dates and the cleaning report (v0.3.0-alpha)

03/04/2026 could be April 3rd or March 4th, and a wrong guess looks valid. A date is converted only when exactly one reading is possible:

  1. Declared: date_formats={"published": "%d/%m/%Y"} or dayfirst=True
  2. Unambiguous: 2026-04-03, 3 Apr 2026, 25/04/2026, 05/05/2026
  3. Column evidence: one value like 25/04/2026 settles day-first for every value in that style
  4. Otherwise flagged: becomes NaT; original text kept in result.cleaning
result = Scrape(url, selectors={...}, dayfirst=True).run()
print(result.cleaning.pretty())
result.cleaning.to_dataframe()   # every flagged value, original preserved
✅ Schema validation
from pydantic import BaseModel
from chronicle import Scrape

class Video(BaseModel):
    title: str
    views: float | None = None

result = Scrape(
    url="https://en.wikipedia.org/wiki/List_of_most-viewed_YouTube_videos",
    schema=Video,
).run()

result.to_dataframe()
result.to_invalid_dataframe()   # quarantined rows

Type coercion rules are documented in docs/COERCION.md.

📈 Quality reports
result.quality_report()
# rows_total, rows_valid, rows_invalid, success_rate,
# per-column dtype / null_pct / numeric stats

The Core Idea: Every Run Is an Artifact

Every Scrape(...).run() writes to .chronicle/:

.chronicle/projects/<project_id>/
├── baseline.json           # reference profile for drift
└── runs/<run_id>/
    ├── manifest.json       # config, timestamps, library version
    ├── profile.json        # statistical fingerprint of each column
    ├── diff.json           # what changed since the previous run
    ├── cleaning.json       # what was converted, flagged, or left unconverted
    ├── data.parquet        # the validated DataFrame
    ├── invalid.parquet     # quarantined rows (if any)
    └── raw/page_1.html     # exact HTML received

Two comparisons, two questions

Question Result attribute Compares
What changed since last time? result.changes (DiffReport) current run vs previous run, row by row
Is the shape still healthy? result.drift_report (DriftReport) current profile vs long-term baseline, statistically

Both run automatically on every Scrape.run() when a prior run exists. They are deliberately separate so you always know which one fired.

Drift checks implemented today

Check Fires when
Row-count A run returns 0 rows after a nonzero baseline (SEVERE; catches broken selectors)
Schema Columns added or removed
Null Per-column null % moves above thresholds
Mean Numeric column mean shifts above thresholds (skipped if baseline mean is 0)
Range Values fall outside baseline min/max by more than 2×
Selector health A previously populated column is now almost entirely null

Politeness

robots.txt respected by default, Retry-After honoured on 429/503, per-host rate limiting. Caching is off by default because drift detection needs fresh data.


What's New

v0.3.0-alpha (GitHub main)

  • 🔁 Run-to-run diff (result.changes), keyed and keyless
  • 📅 Date cleaning with a four-step confidence ladder and result.cleaning
  • 🔌 JSON connector (records_path, fields)
  • 🤝 robots.txt and Retry-After support
  • 🔢 Fixes for all numeric-handling bugs listed under v0.2.1
  • 🧪 147 tests

v0.2.1 (PyPI)

  • Versioned storage with SQLite run index, profile generation, drift detection, Playwright fetcher, pydantic schema validation, CLI, cache off by default

Full history in CHANGELOG.md.


Status & Limitations

v0.2.1 (on PyPI)

Numeric handling bugs (fixed in v0.3.0-alpha)
  • A numeric column containing 0 or 1 is read as booleans and the whole run fails to save
  • One N/A in a numeric column loses the whole run (mixed-type Parquet write fails)
  • "12,5" is read as 125; "2023-05-01" becomes 2023.0; "42.99" in an int field becomes 42; leading zeros are dropped

If you scrape prices, ratings, counts, or quantities, install from git.

Also: drift blind spots (categorical columns, same-mean distribution shifts, dtype changes), no robots.txt or Retry-After, no SSRF protection, ?page=N pagination only, CLI has no --selectors, and test_fetch.py and test_profile.py are empty.

v0.3.0-alpha (GitHub main)

  • Storage is not fully transactional. A crash mid-run can leave the run directory and SQLite index inconsistent; no content hashes or orphan recovery
  • No baseline management. The baseline is the first successful run until you delete it by hand
  • Drift blind spots: same-mean distribution changes, categorical drift, dtype drift, duplicate-rate changes; near-zero-mean columns can false-alarm
  • No SSRF protection: loopback, private, link-local, and cloud-metadata addresses are not blocked; redirects are not re-validated
  • No response-size cap or parse-time limit
  • No offline replay API, no async fetcher, thin CLI

v0.3.0-alpha is tagged but not published to PyPI and not recommended for production.


Roadmap

  • HTTP fetcher with retries, rate limits, robots.txt, Retry-After
  • Playwright fetcher for JS-rendered pages
  • JSON connector
  • Date cleaning and cleaning report
  • Schema validation with quarantine
  • Versioned storage, profiles, drift detection
  • Run-to-run diff
  • v0.3.0 (planned) — drift detector rework (categorical, dtype, same-mean distribution), baseline management, SSRF protection, pagination strategies
  • v0.3.x (planned) — transactional storage with run status, content hashes, chronicle verify
  • v0.4.0 (planned) — storage backend abstraction (local vs. cloud), async fetcher, RAD module
  • v0.5.0 (not yet scheduled) — data contracts, self-healing selectors

Documentation

Document What's inside
ARCHITECTURE.md Five-layer structure, data flow, design decisions
CORE.md The ten principles the library is built on
docs/COERCION.md The type coercion contract
CHANGELOG.md Every release and what changed

Contributing

Chronicle is in early, fast-moving development. Issues, ideas, and PRs are welcome. Found a bug? Open an issue with a minimal reproduction.

git clone git@github.com:TheDataNormad/chronicle.git
cd chronicle
python -m venv .venv
source .venv/Scripts/activate   # Windows Git Bash
# or: .\.venv\Scripts\Activate.ps1   # Windows PowerShell
pip install -e ".[dev]"
pytest

Before opening a PR, read ARCHITECTURE.md (layers import downward only) and CORE.md. Bug fixes should add a case to the matching end-to-end test.


License

MIT. See LICENSE.

Citation

@software{chronicle2026,
  author = {TheDataNormad},
  title  = {Chronicle: The web scraping library that remembers},
  year   = {2026},
  url    = {https://github.com/TheDataNormad/chronicle}
}

Contact


Happy scraping, and remember everything. 🕰️

Metadata

Release files for chronicle-ds 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for chronicle-ds 0.3.0
File Size Uploaded Provenance
chronicle_ds-0.3.0.tar.gz 86.8 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for chronicle-ds 0.3.0
File Interpreter ABI Platform
chronicle_ds-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 140.6 kB

Release files / chronicle_ds-0.3.0.tar.gz

Download URL chronicle_ds-0.3.0.tar.gz
Size 86.8 kB
Tags Source
SHA-256 checksum
How to use checksums
eb8b73db811e0929abcff9398dc872fa62fb2027c65ed40b37e2e79349b3114d
BLAKE2b-256 checksum
How to use checksums
8250ed0766d5a782b3dbdb0b5e67ba407a5faad8fb058995d39454c9d0ce5cba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release files / chronicle_ds-0.3.0-py3-none-any.whl

Download URL chronicle_ds-0.3.0-py3-none-any.whl
Size 53.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
156df788cc3574b5aff708f57a994b56da50690e2d970d1a7713ba9125abd44a
BLAKE2b-256 checksum
How to use checksums
0e0a664bc69a65cfa084a968aad81a1a755f8948a939b578c8049bf69f8edc5a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Security for chronicle-ds 0.3.0

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page