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.
⚠️ Alpha software. v0.3.0 is the current release on PyPI. It is stable enough for research and internal pipelines, but APIs may still shift before v1.0. See Status & Limitations before relying on it in production.
New in v0.3.0
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
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, passselectors={...}(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
--selectorsflag 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
NaTwith 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.3.0) | pip install chronicle-ds |
| JS-rendered pages | pip install "chronicle-ds[browser]" then playwright install chromium |
| Source development | see Contributing |
Chronicle requires Python 3.10+.
Displaying Output
Chronicle returns DataFrames (which Jupyter renders as rich tables) and strings (which it does not). The correct display form depends on your environment and the type.
The rule
Bare expression when Jupyter has a rich display for the type.
print()when it does not.
Jupyter and Colab render certain Python objects as formatted HTML (tables, key/value views). For everything else, they fall back to repr() — which shows escaped characters like \n literally. print() uses str() instead and renders newlines properly.
By return type
| What you have | Jupyter / Colab | Script / Terminal / PowerShell | PyCharm SciView |
|---|---|---|---|
DataFrame (result.to_dataframe()) |
result.to_dataframe().head() |
print(result.to_dataframe().head()) |
Bare (SciView renders it) |
String (result.drift_report.pretty()) |
print(result.drift_report.pretty()) |
print(result.drift_report.pretty()) |
print(...) |
Dict (result.quality_report()) |
Bare or print(...) |
print(...) |
Bare or print(...) |
ScrapeResult (result itself) |
Bare: <ScrapeResult ...> |
print(result) |
Bare |
Bool (result.has_drift()) |
Bare | print(...) |
Bare |
Concrete examples
Jupyter / Colab — DataFrames render as tables:
from chronicle import Scrape
result = Scrape("https://en.wikipedia.org/wiki/List_of_most-viewed_YouTube_videos").run()
# Bare expression → rich HTML table
result.to_dataframe().head(10)
Jupyter / Colab — strings need print():
# ❌ Bare expression — shows repr with literal \n
result.drift_report.pretty()
# ✅ print() — renders the formatted report
print(result.drift_report.pretty())
Drift needs two runs
.drift_report is None on the first run of a URL — there is no baseline to compare against yet. Guard with has_drift(), which is None-safe:
# First run — no baseline
Scrape(url).run()
# Second run — comparison available
result = Scrape(url).run()
if result.has_drift():
print(result.drift_report.pretty())
else:
print(f"No drift detected. Severity: {result.drift_report.severity}")
Same rule for .changes: use result.has_changes() before calling result.changes.pretty().
Colab persistence
Colab wipes its filesystem on every session restart. Point storage_root at Google Drive to keep run history across sessions:
from google.colab import drive
drive.mount("/content/drive")
result = Scrape(
url,
storage_root="/content/drive/MyDrive/chronicle",
).run()
Without this, every Colab session starts fresh — the first run in each session is always a "first run" with no baseline.
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
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
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
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:
- Declared:
date_formats={"published": "%d/%m/%Y"}ordayfirst=True - Unambiguous:
2026-04-03,3 Apr 2026,25/04/2026,05/05/2026 - Column evidence: one value like
25/04/2026settles day-first for every value in that style - Otherwise flagged: becomes
NaT; original text kept inresult.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 (Current Release)
- 🔁 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.txtandRetry-Aftersupport - 🔢 Fixes for all numeric-handling bugs from v0.2.1
- 🧪 147 tests (84% coverage)
Full history in CHANGELOG.md.
Status & Limitations
Current limitations (v0.3.0)
- 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
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 — numeric bug fixes, date cleaning, run-to-run diff, JSON connector,
robots.txtsupport - v0.3.x (planned) — drift detector rework (categorical, dtype, same-mean distribution), baseline management, SSRF protection, pagination strategies
- 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
- GitHub: @TheDataNormad
- LinkedIn: linkedin.com/company/chronicle-ds
- PyPI: pypi.org/project/chronicle-ds
Happy scraping, and remember everything. 🕰️
Metadata
Release files for chronicle-ds 0.3.0.post1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| chronicle_ds-0.3.0.post1.tar.gz | 87.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| chronicle_ds-0.3.0.post1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 141.5 kB
Release files / chronicle_ds-0.3.0.post1.tar.gz
| Download URL | chronicle_ds-0.3.0.post1.tar.gz |
|---|---|
| Size | 87.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1d0cc7b8e4bf6894d954b95fbf9e64f47907f5effb9ecdd75801f4dc2db75b9f
|
|
BLAKE2b-256 checksum How to use checksums |
e02070a7746c048a754fde839466686c88b223dd654451a67be238a9a804eb57
|
| 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.post1-py3-none-any.whl
| Download URL | chronicle_ds-0.3.0.post1-py3-none-any.whl |
|---|---|
| Size | 54.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b8b7add0b4a1254414c64c086ab7adb12f193e22375ca067b4be4bb1c147c2ab
|
|
BLAKE2b-256 checksum How to use checksums |
882e0a4d69064a6071b4ad9f91b6e93a83eca1706f717ad1505e59968a51c594
|
| 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.post1
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.