watchdiff-core 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/PKG-INFO +529 -14
  2. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/README.md +528 -13
  3. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/pyproject.toml +1 -1
  4. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/tests/test_watchdiff.py +221 -3
  5. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/uv.lock +1 -1
  6. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/__init__.py +6 -0
  7. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cli/main.py +367 -8
  8. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/core.py +59 -3
  9. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/exporter/exporter.py +45 -15
  10. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/fetcher/fetcher.py +7 -4
  11. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/models.py +47 -1
  12. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/notifier/notifier.py +67 -28
  13. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/scheduler/scheduler.py +335 -54
  14. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/store/sqlite_store.py +11 -0
  15. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/store/store.py +15 -0
  16. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/.github/workflows/ci.yml +0 -0
  17. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/.github/workflows/release-testpypi.yml +0 -0
  18. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/.github/workflows/release.yml +0 -0
  19. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/.gitignore +0 -0
  20. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/.python-version +0 -0
  21. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/LICENSE +0 -0
  22. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/main.py +0 -0
  23. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/tests/__init__.py +0 -0
  24. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/tests/test_db.py +0 -0
  25. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/ai_summarizer/__init__.py +0 -0
  26. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cert_fetcher/__init__.py +0 -0
  27. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cert_models.py +0 -0
  28. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cert_scheduler/__init__.py +0 -0
  29. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cleaner/__init__.py +0 -0
  30. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cleaner/cleaner.py +0 -0
  31. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cli/__init__.py +0 -0
  32. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/cron_parser/__init__.py +0 -0
  33. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_diff/__init__.py +0 -0
  34. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_fetcher/__init__.py +0 -0
  35. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_fetcher/mysql.py +0 -0
  36. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_fetcher/postgres.py +0 -0
  37. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_fetcher/sqlite.py +0 -0
  38. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_models.py +0 -0
  39. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/db_scheduler/__init__.py +0 -0
  40. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/diff/__init__.py +0 -0
  41. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/diff/engine.py +0 -0
  42. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/exporter/__init__.py +0 -0
  43. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/fetcher/__init__.py +0 -0
  44. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/fetcher/browser.py +0 -0
  45. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/file_fetcher/__init__.py +0 -0
  46. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/json_path/__init__.py +0 -0
  47. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/mailer/__init__.py +0 -0
  48. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/notifier/__init__.py +0 -0
  49. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/parser/__init__.py +0 -0
  50. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/parser/parser.py +0 -0
  51. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/scheduler/__init__.py +0 -0
  52. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/sitemap/__init__.py +0 -0
  53. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/status_server/__init__.py +0 -0
  54. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/status_server/server.py +0 -0
  55. {watchdiff_core-0.2.3 → watchdiff_core-0.2.5}/watchdiff/store/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: watchdiff-core
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Lightweight web change monitoring library - clean diffs, AI summaries, file/API/SSL/DB monitoring, structured alerts.
5
5
  Author: WatchDiff Contributors
6
6
  License: BSD-2-Clause
@@ -92,6 +92,19 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
92
92
  | Monitor a sitemap for URL changes | `.watch_sitemap("https://example.com/sitemap.xml")` |
93
93
  | Watch one JSON field only | `json_path="$.data.price"` |
94
94
  | Monitor authenticated pages | `cookies={"session": "abc123", "csrftoken": "xyz"}` |
95
+ | Give a watcher a stable ID | `id="product-price"` — use ID instead of URL in `.pause()`/`.resume()` |
96
+ | Limit concurrent checks | `WatchDiff(concurrency=5)` — cap parallel fetch workers |
97
+ | Skip checks during downtime | `maintenance_windows=[MaintenanceWindow(from_="2026-01-15T02:00Z", to="2026-01-15T04:00Z")]` |
98
+ | Restrict checks to business hours | `active_between=ActiveBetween(from_="09:00", to="17:00", days=[0,1,2,3,4], timezone="Europe/Paris")` |
99
+ | Suppress noisy error alerts | `failure_policy=FailurePolicy(consecutive_failures=3, recovery_checks=2)` |
100
+ | Run a callback on recovery | `on_recovery=lambda cfg: print(f"{cfg.label} is back")` |
101
+ | Cap exponential backoff | `max_retry_delay=30.0` — retry delay never exceeds 30 s regardless of attempt count |
102
+ | Alert on very first capture | `alert_on_first_check=True` — fires `on_change` even when no previous snapshot exists |
103
+ | Add custom webhook headers | `webhook_headers={"X-Api-Key": "secret"}` — merged into every webhook POST |
104
+ | Auto-delete old snapshots | `retention_days=30` — snapshots older than N days are pruned after each save |
105
+ | Export history as JSON | `.export_reports_json(url)` / `.export_snapshots_json(url)` |
106
+ | Show last stored snapshot from CLI | `watchdiff snapshot https://example.com` |
107
+ | Remove orphan storage files from CLI | `watchdiff clean` — deletes snap/report files not referenced by any active watcher |
95
108
  | Send email on change | `alert=AlertConfig(email=EmailConfig(to="...", smtp=SmtpConfig(...)))` |
96
109
  | AI summary of changes | `ai_summary=True, ai_provider=AiProvider(type="gemini", api_key="...")` |
97
110
  | Customize the AI prompt | `ai_prompt="Summarize in French."` or `ai_prompt=lambda r: ...` |
@@ -149,6 +162,18 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
149
162
  - [JSON path targeting](#json-path-targeting)
150
163
  - [Cookie-based authentication](#cookie-based-authentication)
151
164
  - [Email alerts](#email-alerts)
165
+ - [Watcher ID](#watcher-id)
166
+ - [Concurrency limiting](#concurrency-limiting)
167
+ - [Maintenance windows](#maintenance-windows)
168
+ - [Active hours](#active-hours)
169
+ - [Failure policy](#failure-policy)
170
+ - [Recovery callback](#recovery-callback)
171
+ - [Max retry delay](#max-retry-delay)
172
+ - [Alert on first check](#alert-on-first-check)
173
+ - [Webhook headers](#webhook-headers)
174
+ - [Retention days](#retention-days)
175
+ - [JSON export](#json-export)
176
+ - [Slack Block Kit](#slack-block-kit)
152
177
  - [API reference](#api-reference)
153
178
  - [`.watch()`](#watchurl--)
154
179
  - [`.watch_db()`](#watch_dbconnection_string-table--)
@@ -156,8 +181,13 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
156
181
  - [`.watch_api()`](#watch_apiurl--)
157
182
  - [`.watch_cert()`](#watch_certhost--port443-warning_days30-)
158
183
  - [`.watch_sitemap()`](#watch_sitemapurl--)
159
- - [`.start()` / `start_async()`](#startblock--startasync)
160
- - [`.stop()` / pause / resume / status](#stop--pause--resume--status)
184
+ - [`.on_change()`](#on_changecallback)
185
+ - [`.start()` / `start_async()` / `.stop()`](#startblocktrue--await-start_async--stop)
186
+ - [`.check_once()`](#check_onceurl)
187
+ - [`.compare_urls()`](#compare_urlsurl_a-url_b--)
188
+ - [`.start_status_server()` / `.stop_status_server()`](#start_status_serverport-host--stop_status_server)
189
+ - [`.pause()` / `.resume()` / `.status()` / `.db_status()`](#pauseurl--resumeurl--status--db_status)
190
+ - [`.history()` / `.reports()` / `.clear()` / `.export_*_json()`](#historyurl--reportsurl--clearurl--export_json)
161
191
  - [`DiffReport`](#diffreport)
162
192
  - [`Change`](#change)
163
193
  - [`Snapshot`](#snapshot)
@@ -165,6 +195,14 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
165
195
  - [`SilenceInfo`](#silenceinfo)
166
196
  - [`AlertConfig`](#alertconfig)
167
197
  - [`BrowserOptions`](#browseroptions)
198
+ - [`SpikeInfo`](#spikeinfo)
199
+ - [`StatusChangeInfo`](#statuschangeinfo)
200
+ - [`StatusServer`](#statusserver)
201
+ - [`EmailConfig` / `SmtpConfig`](#emailconfig--smtpconfig)
202
+ - [`SitemapDiffReport` / `SitemapEntry`](#sitemapdifreport--sitemapentry)
203
+ - [`MaintenanceWindow`](#maintenancewindow)
204
+ - [`ActiveBetween`](#activebetween)
205
+ - [`FailurePolicy`](#failurepolicy)
168
206
  - [`DbDiffReport`](#dbdiffreport)
169
207
  - [`DbChange`](#dbchange)
170
208
  - [`DbWatcherStatus`](#dbwatcherstatus)
@@ -297,6 +335,9 @@ watchdiff db "postgresql://user:pass@localhost/mydb" products \
297
335
  # Generate a config file
298
336
  watchdiff init
299
337
 
338
+ # Validate a config file without starting watchers
339
+ watchdiff validate watchdiff.config.json
340
+
300
341
  # Run from config file
301
342
  watchdiff run --config watchdiff.config.json
302
343
 
@@ -654,6 +695,8 @@ wd.watch(
654
695
 
655
696
  `SilenceInfo` fields: `url`, `label`, `seconds_since_last_change`.
656
697
 
698
+ > **Note:** even without `on_silence`, WatchDiff logs a `WARNING` when the silence threshold is exceeded. `on_silence` is optional — `alert_if_no_change_after` alone is enough to surface stale feeds in your log stream.
699
+
657
700
  ### Error callback
658
701
 
659
702
  Receive a callback whenever a fetch fails, without crashing the watcher:
@@ -1052,6 +1095,19 @@ path = wd.export_snapshots_xlsx("https://example.com", dest="snapshots.xlsx")
1052
1095
 
1053
1096
  All export methods accept `url`, `target` (optional), `limit` (default 500), and `dest`.
1054
1097
 
1098
+ Reports CSV schema — **one row per change** (compatible with the TypeScript export):
1099
+
1100
+ | Column | Description |
1101
+ |---|---|
1102
+ | `url` | Watched URL |
1103
+ | `label` | Watcher label |
1104
+ | `compared_at` | ISO 8601 timestamp of the diff |
1105
+ | `kind` | `added` \| `removed` \| `modified` |
1106
+ | `before` | Previous value (truncated to 500 chars) |
1107
+ | `after` | New value (truncated to 500 chars) |
1108
+
1109
+ Snapshots CSV schema — one row per snapshot: `url`, `target`, `captured_at`, `checksum`, `content_preview`.
1110
+
1055
1111
  ### Config file workflow
1056
1112
 
1057
1113
  Generate a ready-to-edit config file, then run all your watchers in one command:
@@ -1089,7 +1145,15 @@ Edit `watchdiff.config.json`:
1089
1145
  "ignore_selectors": [".cookie-banner", "#ad-container"],
1090
1146
  "ignore_patterns": ["\\d+ views"],
1091
1147
  "timeout": 15,
1092
- "headers": {}
1148
+ "headers": {},
1149
+ "schedule": null,
1150
+ "confirm_after": null,
1151
+ "json_path": null,
1152
+ "email": null,
1153
+ "id": null,
1154
+ "maintenance_windows": [],
1155
+ "active_between": null,
1156
+ "failure_policy": null
1093
1157
  },
1094
1158
  {
1095
1159
  "url": "https://hnrss.org/frontpage",
@@ -1104,6 +1168,8 @@ Edit `watchdiff.config.json`:
1104
1168
 
1105
1169
  Config fields are validated on load — invalid URLs, unknown diff modes, out-of-range values, and wrong types are all caught with clear error messages before any monitoring starts.
1106
1170
 
1171
+ > **TypeScript config compatibility:** Python accepts both `watchers` and `watches` as the top-level array key, and normalises camelCase field names to snake_case automatically (`diffMode` → `diff_mode`, `ignoreSelectors` → `ignore_selectors`, `maxSnapshots` → `max_snapshots`, etc.). A config file generated by the TypeScript CLI loads without modification.
1172
+
1107
1173
  ```bash
1108
1174
  # Explicit path
1109
1175
  watchdiff run --config watchdiff.config.json
@@ -1424,32 +1490,311 @@ wd.watch_api("https://api.example.com/account",
1424
1490
 
1425
1491
  Send email notifications via SMTP when a change is detected. Uses Python stdlib `smtplib` — no extra dependency required.
1426
1492
 
1493
+ ### Quickstart — `email=` shortcut
1494
+
1495
+ Pass an `EmailConfig` directly to `.watch()`:
1496
+
1427
1497
  ```python
1428
1498
  from watchdiff import WatchDiff
1429
- from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1499
+ from watchdiff.models import EmailConfig, SmtpConfig
1430
1500
 
1431
1501
  wd = WatchDiff()
1502
+ wd.watch(
1503
+ "https://example.com/prices",
1504
+ email=EmailConfig(
1505
+ to="alerts@example.com",
1506
+ smtp=SmtpConfig(
1507
+ host="smtp.gmail.com",
1508
+ port=465,
1509
+ user="you@gmail.com",
1510
+ password="app-password",
1511
+ ),
1512
+ ),
1513
+ )
1514
+ wd.start()
1515
+ ```
1516
+
1517
+ ### Via `AlertConfig` — combine with webhooks and callbacks
1518
+
1519
+ ```python
1520
+ from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1521
+
1432
1522
  wd.watch("https://example.com/prices",
1433
1523
  alert=AlertConfig(
1434
- on_change=[],
1435
- webhooks=[],
1524
+ on_change=[lambda r: print(r.summary())],
1525
+ webhooks=["https://ntfy.sh/my-topic"],
1436
1526
  min_changes=1,
1437
1527
  email=EmailConfig(
1438
- to="alerts@example.com",
1528
+ to=["alerts@example.com", "team@example.com"],
1529
+ from_="watchdiff@example.com",
1530
+ subject="Price change detected",
1439
1531
  smtp=SmtpConfig(
1440
1532
  host="smtp.gmail.com",
1441
- port=465,
1533
+ port=587, # STARTTLS
1442
1534
  user="you@gmail.com",
1443
1535
  password="app-password",
1444
1536
  ),
1445
1537
  ),
1446
1538
  ))
1539
+ ```
1540
+
1541
+ ### Config file (`watchdiff.config.json`)
1542
+
1543
+ ```json
1544
+ {
1545
+ "url": "https://example.com/prices",
1546
+ "email": {
1547
+ "to": "alerts@example.com",
1548
+ "from": "watchdiff@example.com",
1549
+ "subject": "Price change detected",
1550
+ "smtp": {
1551
+ "host": "smtp.gmail.com",
1552
+ "port": 465,
1553
+ "user": "you@gmail.com",
1554
+ "password": "app-password"
1555
+ }
1556
+ }
1557
+ }
1558
+ ```
1559
+
1560
+ **Notes:**
1561
+ - `from_` defaults to `user@host` when omitted
1562
+ - `subject` defaults to `"[WatchDiff] Change detected: {label}"`
1563
+ - Multiple recipients: `to=["a@x.com", "b@x.com"]`
1564
+ - Port `465` uses SSL from the start (`SMTP_SSL`). All other ports use `STARTTLS`.
1565
+
1566
+ ## Watcher ID
1567
+
1568
+ Assign a stable string identifier to any watcher. The ID is used instead of the URL for `.pause()`, `.resume()`, and `.status()` lookups, which is useful when two watchers share the same URL or when you want readable keys in your control code.
1569
+
1570
+ ```python
1571
+ from watchdiff import WatchDiff
1572
+
1573
+ wd = WatchDiff()
1574
+ wd.watch("https://example.com/prices", id="product-price", interval=60)
1575
+ wd.watch("https://example.com/prices", id="product-price-2", interval=120)
1576
+
1577
+ wd.pause("product-price") # pause by ID, not URL
1578
+ wd.resume("product-price")
1579
+ ```
1580
+
1581
+ Without `id`, `pause(url)` pauses **all** watchers registered for that URL. With `id`, each watcher is independent and can be paused or resumed individually.
1582
+
1583
+ IDs also appear in `WatcherStatus.id` and in the JSON returned by `status()`.
1584
+
1585
+ ## Concurrency limiting
1586
+
1587
+ Cap the number of checks that run simultaneously. Without a limit all watchers fire in parallel; with a limit the scheduler queues excess checks.
1588
+
1589
+ ```python
1590
+ wd = WatchDiff(concurrency=5) # at most 5 simultaneous fetches
1591
+ ```
1592
+
1593
+ Useful when you monitor hundreds of URLs and want to avoid hammering a shared proxy pool or saturating a network interface.
1594
+
1595
+ ## Maintenance windows
1596
+
1597
+ Skip checks during a known downtime window. Checks resume automatically once the window closes.
1598
+
1599
+ ```python
1600
+ from watchdiff import WatchDiff, MaintenanceWindow
1601
+
1602
+ wd = WatchDiff()
1603
+ wd.watch(
1604
+ "https://api.example.com/health",
1605
+ interval=60,
1606
+ maintenance_windows=[
1607
+ MaintenanceWindow(
1608
+ from_="2026-02-01T02:00:00+00:00", # ISO 8601 UTC start
1609
+ to="2026-02-01T04:00:00+00:00", # UTC end
1610
+ ),
1611
+ ],
1612
+ )
1613
+ wd.start()
1614
+ ```
1615
+
1616
+ - `from_` and `to` accept either an ISO 8601 string or a `datetime` object.
1617
+ - Multiple windows can be listed; any overlapping window suppresses the check.
1618
+ - The window is evaluated fresh on each tick — no restart needed after the window passes.
1619
+ - `WatcherStatus.in_maintenance` reflects the current state.
1620
+
1621
+ ## Active hours
1622
+
1623
+ Restrict checks to specific hours and/or days. Checks outside the window are silently skipped until the next window opens.
1624
+
1625
+ ```python
1626
+ from watchdiff import WatchDiff, ActiveBetween
1627
+
1628
+ wd = WatchDiff()
1629
+ wd.watch(
1630
+ "https://example.com/prices",
1631
+ interval=60,
1632
+ active_between=ActiveBetween(
1633
+ from_="09:00", # "HH:MM" — start of active window
1634
+ to="17:00", # "HH:MM" — end of active window
1635
+ days=[0, 1, 2, 3, 4], # Mon–Fri (0=Monday, 6=Sunday). None = every day
1636
+ timezone="Europe/Paris", # IANA timezone name. None = UTC
1637
+ ),
1638
+ )
1639
+ wd.start()
1640
+ ```
1641
+
1642
+ - `to` before `from_` (e.g. `from_="22:00"`, `to="06:00"`) is treated as an overnight window.
1643
+ - `days` defaults to all 7 days when omitted. `timezone` defaults to UTC when omitted.
1644
+ - Requires Python 3.9+ `zoneinfo` stdlib. Falls back to UTC if the IANA database is unavailable.
1645
+ - **Note:** the Python API uses integer weekday indices (`0`=Monday … `6`=Sunday, matching `datetime.weekday()`). The TypeScript port uses string names (`"monday"`, `"friday"`, …).
1646
+
1647
+ ## Failure policy
1648
+
1649
+ Gate the `on_error` callback until a URL has failed a configurable number of times in a row, and require a configurable number of consecutive successes before considering it recovered. Reduces alert noise from transient blips.
1650
+
1651
+ ```python
1652
+ from watchdiff import WatchDiff, FailurePolicy
1653
+
1654
+ wd = WatchDiff()
1655
+ wd.watch(
1656
+ "https://example.com/status",
1657
+ interval=30,
1658
+ failure_policy=FailurePolicy(
1659
+ consecutive_failures=3, # only fire on_error after 3 failures in a row
1660
+ recovery_checks=2, # require 2 consecutive successes to clear the failure state
1661
+ respect_retry_after=True, # honour Retry-After response header when present
1662
+ ),
1663
+ on_error=lambda exc, cfg: print(f"Confirmed failure: {exc}"),
1664
+ )
1447
1665
  wd.start()
1448
1666
  ```
1449
1667
 
1450
- Optional fields: `from_` (defaults to `user@host`), `subject` (defaults to `"[WatchDiff] Change detected: {label}"`). Multiple recipients: `to=["a@x.com", "b@x.com"]`.
1668
+ Without `failure_policy`, the first fetch error fires `on_error` immediately. With it:
1451
1669
 
1452
- Port `465` uses SSL from the start (`SMTP_SSL`). Other ports use `STARTTLS`.
1670
+ - Errors below the threshold are silently swallowed — no callback, no alert.
1671
+ - After `consecutive_failures` errors in a row, `on_error` fires **once**, then is suppressed until recovery.
1672
+ - Recovery requires `recovery_checks` consecutive successful fetches.
1673
+ - When `respect_retry_after=True` and the server returns a `Retry-After` header (e.g. on 429/503), the next check is postponed until the header deadline.
1674
+
1675
+ Default: `consecutive_failures=3`, `recovery_checks=1`, `respect_retry_after=False`.
1676
+
1677
+ > **Note:** the TypeScript port defaults `consecutiveFailures` to **1** (fire on the first error). Python defaults to **3**. A bare `FailurePolicy()` therefore behaves differently across the two implementations.
1678
+
1679
+ ## Recovery callback
1680
+
1681
+ Run a function the moment a watcher exits failure mode and successfully fetches again.
1682
+
1683
+ ```python
1684
+ from watchdiff import WatchDiff, FailurePolicy
1685
+
1686
+ wd = WatchDiff()
1687
+ wd.watch(
1688
+ "https://example.com/status",
1689
+ interval=30,
1690
+ failure_policy=FailurePolicy(consecutive_failures=3, recovery_checks=2),
1691
+ on_error=lambda exc, cfg: print(f"Failure: {exc}"),
1692
+ on_recovery=lambda cfg: print(f"{cfg.label} recovered!"),
1693
+ )
1694
+ wd.start()
1695
+ ```
1696
+
1697
+ `on_recovery` fires once per recovery event — after `recovery_checks` consecutive successes satisfy the `FailurePolicy`. It is called with the `WatchConfig` of the recovered watcher.
1698
+
1699
+ ## Max retry delay
1700
+
1701
+ Cap the exponential backoff delay so it never exceeds a given value:
1702
+
1703
+ ```python
1704
+ wd.watch(
1705
+ "https://example.com",
1706
+ retries=6,
1707
+ retry_delay=1.0, # base: 1 s, 2 s, 4 s, 8 s, 16 s, 32 s …
1708
+ max_retry_delay=10.0, # …but never more than 10 s
1709
+ )
1710
+ ```
1711
+
1712
+ Without `max_retry_delay` the delay grows unboundedly as `retry_delay * 2^attempt`. Setting it avoids very long waits on high `retries` counts.
1713
+
1714
+ ## Alert on first check
1715
+
1716
+ By default the first capture of a URL is stored silently — no alert fires because there is nothing to compare against. Enable `alert_on_first_check` to fire `on_change` (and webhooks) on that first capture anyway:
1717
+
1718
+ ```python
1719
+ wd.watch(
1720
+ "https://example.com/products.json",
1721
+ diff_mode="json",
1722
+ alert_on_first_check=True,
1723
+ on_change=lambda r: print("First snapshot:", r.summary()),
1724
+ )
1725
+ ```
1726
+
1727
+ The `DiffReport` is generated by comparing an empty snapshot against the first capture using the normal diff engine — so you get a realistic granular diff (one `ADDED` entry per line/word/key, depending on `diff_mode`) rather than a single blob entry.
1728
+
1729
+ ## Webhook headers
1730
+
1731
+ Merge custom HTTP headers into every webhook POST fired by a watcher — useful for auth tokens or service-specific headers:
1732
+
1733
+ ```python
1734
+ wd.watch(
1735
+ "https://example.com",
1736
+ webhooks=["https://hooks.example.com/notify"],
1737
+ webhook_headers={
1738
+ "X-Api-Key": "my-secret-token",
1739
+ "X-Source": "watchdiff",
1740
+ },
1741
+ on_change=lambda r: None,
1742
+ )
1743
+ ```
1744
+
1745
+ `webhook_headers` are merged on top of the service-specific headers WatchDiff already adds (e.g. `Content-Type`). They apply only to webhook requests, not to the monitoring fetch itself.
1746
+
1747
+ ## Retention days
1748
+
1749
+ Automatically delete snapshots older than N days after every save:
1750
+
1751
+ ```python
1752
+ wd.watch(
1753
+ "https://example.com",
1754
+ retention_days=30, # snapshots older than 30 days are removed after each check
1755
+ )
1756
+ ```
1757
+
1758
+ `retention_days` and `max_snapshots` can be combined — both pruning strategies run after each save. `retention_days` applies to both the JSON file store (`Store`) and the SQLite store (`SqliteStore`).
1759
+
1760
+ ## JSON export
1761
+
1762
+ Export snapshots and diff reports as JSON for programmatic consumption:
1763
+
1764
+ ```python
1765
+ wd = WatchDiff()
1766
+ wd.watch("https://example.com/prices")
1767
+ # … after some checks …
1768
+
1769
+ reports = wd.export_reports_json("https://example.com/prices", limit=100)
1770
+ snapshots = wd.export_snapshots_json("https://example.com/prices", limit=50)
1771
+
1772
+ import json
1773
+ print(json.dumps(reports, indent=2))
1774
+ ```
1775
+
1776
+ Both methods return a `list[dict]` (newest last). Each report dict matches `DiffReport.as_dict()`; each snapshot dict contains `url`, `target`, `captured_at`, `checksum`, and `content`.
1777
+
1778
+ From the CLI:
1779
+
1780
+ ```bash
1781
+ # Print JSON to stdout
1782
+ watchdiff export https://example.com --format json
1783
+ watchdiff export https://example.com --type snapshots --format json
1784
+
1785
+ # Write to file
1786
+ watchdiff export https://example.com --format json --output reports.json
1787
+ ```
1788
+
1789
+ ## Slack Block Kit
1790
+
1791
+ Slack webhook payloads now use the [Block Kit](https://api.slack.com/block-kit) format instead of plain `text`. Each message contains:
1792
+
1793
+ - A **header** block — `WatchDiff — <watcher label>`
1794
+ - A **section** block — added/removed/modified counts (e.g. `*3 added*, *1 removed*`)
1795
+ - A **context** block — URL and timestamp
1796
+
1797
+ The richer layout renders as an attachment card with a clear title and structured change list. No configuration needed; it is applied automatically to any `hooks.slack.com` webhook URL.
1453
1798
 
1454
1799
  ## API reference
1455
1800
 
@@ -1462,8 +1807,15 @@ from watchdiff.store import SqliteStore
1462
1807
  wd = WatchDiff() # JSON store in .watchdiff/
1463
1808
  wd = WatchDiff(storage_dir="/data/watchdiff") # custom JSON store path
1464
1809
  wd = WatchDiff(store=SqliteStore("db.sqlite")) # SQLite store
1810
+ wd = WatchDiff(concurrency=5) # at most 5 parallel fetch workers
1465
1811
  ```
1466
1812
 
1813
+ | Parameter | Type | Default | Description |
1814
+ |---|---|---|---|
1815
+ | `storage_dir` | `str` | `".watchdiff"` | Directory for JSON snapshot/report files |
1816
+ | `store` | `Store \| None` | `None` | Custom store implementation (e.g. `SqliteStore`) |
1817
+ | `concurrency` | `int \| None` | `None` | Max simultaneous check workers. `None` = unlimited |
1818
+
1467
1819
  #### `.watch(url, *, ...)`
1468
1820
 
1469
1821
  Register a URL to monitor. All keyword arguments are optional. Returns `self` (chainable).
@@ -1516,6 +1868,16 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
1516
1868
  | `schedule` | `str \| None` | `None` | 5-field cron expression. Overrides `interval` when set. |
1517
1869
  | `confirm_after` | `int \| None` | `None` | Re-verify after N seconds before alerting (flapping detection) |
1518
1870
  | `json_path` | `str \| None` | `None` | JSON path expression to extract a sub-value before diffing (e.g. `"$.data.price"`) |
1871
+ | `email` | `EmailConfig \| None` | `None` | SMTP email alert fired on every detected change — shortcut over building a full `AlertConfig` |
1872
+ | `id` | `str \| None` | `None` | Stable identifier for this watcher — used instead of URL in `.pause()`/`.resume()` |
1873
+ | `maintenance_windows` | `list[MaintenanceWindow]` | `[]` | One-time UTC time ranges during which checks are skipped |
1874
+ | `active_between` | `ActiveBetween \| None` | `None` | Restrict checks to a recurring daily/weekly time window |
1875
+ | `failure_policy` | `FailurePolicy \| None` | `None` | Gate `on_error` until N consecutive failures; require M consecutive successes to recover |
1876
+ | `on_recovery` | `Callable[[WatchConfig], None] \| None` | `None` | Called once when the watcher exits failure mode and successfully fetches again |
1877
+ | `max_retry_delay` | `float \| None` | `None` | Cap on the exponential backoff delay in seconds — `retry_delay * 2^attempt` is clamped to this value |
1878
+ | `alert_on_first_check` | `bool` | `False` | Fire `on_change` (and webhooks) on the very first capture even though there is no previous snapshot |
1879
+ | `webhook_headers` | `dict[str, str]` | `{}` | Extra HTTP headers merged into every webhook POST for this watcher |
1880
+ | `retention_days` | `int \| None` | `None` | Auto-delete snapshots older than N days after each save |
1519
1881
 
1520
1882
  ```python
1521
1883
  # Chainable
@@ -1754,12 +2116,16 @@ for s in wd.db_status():
1754
2116
  print(s.table, s.diff_mode, s.checks_count, s.changes_count, s.errors_count)
1755
2117
  ```
1756
2118
 
1757
- #### `.history(url)` / `.reports(url)` / `.clear(url)`
2119
+ #### `.history(url)` / `.reports(url)` / `.clear(url)` / `.export_*_json()`
1758
2120
 
1759
2121
  ```python
1760
2122
  snaps = wd.history("https://example.com", limit=10)
1761
2123
  reports = wd.reports("https://example.com", limit=10)
1762
2124
  wd.clear("https://example.com")
2125
+
2126
+ # JSON export — returns list[dict] (newest last)
2127
+ reports_json = wd.export_reports_json("https://example.com", limit=100)
2128
+ snapshots_json = wd.export_snapshots_json("https://example.com", limit=50)
1763
2129
  ```
1764
2130
 
1765
2131
  ### `DiffReport`
@@ -1823,6 +2189,8 @@ status.checks_count # int
1823
2189
  status.changes_count # int
1824
2190
  status.errors_count # int
1825
2191
  status.last_status_code # int — last known HTTP status (0 = unknown)
2192
+ status.id # str | None — stable watcher ID (set via id= on .watch())
2193
+ status.in_maintenance # bool — True if currently inside a maintenance window
1826
2194
 
1827
2195
  status.as_dict() # JSON-serialisable dict
1828
2196
  ```
@@ -1891,6 +2259,128 @@ server.start()
1891
2259
  server.stop()
1892
2260
  ```
1893
2261
 
2262
+ ### `EmailConfig` / `SmtpConfig`
2263
+
2264
+ ```python
2265
+ from watchdiff import EmailConfig, SmtpConfig
2266
+
2267
+ email = EmailConfig(
2268
+ to="ops@example.com", # str or list[str]
2269
+ smtp=SmtpConfig(
2270
+ host="smtp.example.com",
2271
+ port=587,
2272
+ user="alerts@example.com",
2273
+ password="secret",
2274
+ secure=None, # None = auto (SSL on 465, STARTTLS otherwise)
2275
+ ),
2276
+ from_="alerts@example.com", # optional sender address
2277
+ subject="[WatchDiff] change", # optional subject override
2278
+ )
2279
+ ```
2280
+
2281
+ Pass as `email=` to `.watch()` or inside `AlertConfig(email=...)`.
2282
+
2283
+ ---
2284
+
2285
+ ### `SitemapDiffReport` / `SitemapEntry`
2286
+
2287
+ Passed to callbacks registered with `.watch_sitemap()`:
2288
+
2289
+ ```python
2290
+ report.sitemap_url # str — URL of the sitemap
2291
+ report.label # str — human-readable label
2292
+ report.added # list[SitemapEntry] — URLs newly present in the sitemap
2293
+ report.removed # list[SitemapEntry] — URLs no longer in the sitemap
2294
+ report.compared_at # datetime — UTC timestamp of the comparison
2295
+ ```
2296
+
2297
+ Each `SitemapEntry`:
2298
+
2299
+ ```python
2300
+ entry.url # str
2301
+ entry.last_modified # str | None — <lastmod> value from the sitemap
2302
+ entry.change_freq # str | None — <changefreq> value
2303
+ entry.priority # str | None — <priority> value
2304
+ ```
2305
+
2306
+ ---
2307
+
2308
+ ### `MaintenanceWindow`
2309
+
2310
+ Defines a one-time UTC window during which checks are paused:
2311
+
2312
+ ```python
2313
+ from watchdiff import MaintenanceWindow
2314
+ from datetime import datetime, timezone
2315
+
2316
+ # From ISO 8601 strings (recommended)
2317
+ w = MaintenanceWindow(
2318
+ from_="2026-02-01T02:00:00+00:00",
2319
+ to="2026-02-01T04:00:00+00:00",
2320
+ )
2321
+
2322
+ # From datetime objects
2323
+ w = MaintenanceWindow(
2324
+ from_=datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc),
2325
+ to=datetime(2026, 2, 1, 4, 0, tzinfo=timezone.utc),
2326
+ )
2327
+ ```
2328
+
2329
+ | Field | Type | Description |
2330
+ |---|---|---|
2331
+ | `from_` | `datetime \| str` | UTC start; ISO 8601 string or `datetime` |
2332
+ | `to` | `datetime \| str` | UTC end |
2333
+
2334
+ ISO strings are parsed in `__post_init__`. `"Z"` suffix is accepted as `+00:00`.
2335
+
2336
+ ---
2337
+
2338
+ ### `ActiveBetween`
2339
+
2340
+ Restricts checks to a recurring daily or weekly time window:
2341
+
2342
+ ```python
2343
+ from watchdiff import ActiveBetween
2344
+
2345
+ ab = ActiveBetween(
2346
+ from_="09:00",
2347
+ to="17:00",
2348
+ days=[0, 1, 2, 3, 4], # 0=Monday … 6=Sunday. None = every day
2349
+ timezone="Europe/Paris", # IANA name. None = UTC
2350
+ )
2351
+ ```
2352
+
2353
+ | Field | Type | Default | Description |
2354
+ |---|---|---|---|
2355
+ | `from_` | `str` | — | Window start as `"HH:MM"` |
2356
+ | `to` | `str` | — | Window end as `"HH:MM"`. If before `from_`, treated as overnight |
2357
+ | `days` | `list[int] \| None` | `None` | Weekdays to restrict to (`0`=Mon). `None` = every day |
2358
+ | `timezone` | `str \| None` | `None` | IANA timezone name (e.g. `"America/New_York"`). `None` = UTC |
2359
+
2360
+ ---
2361
+
2362
+ ### `FailurePolicy`
2363
+
2364
+ Gates `on_error` to avoid alert noise from transient blips:
2365
+
2366
+ ```python
2367
+ from watchdiff import FailurePolicy
2368
+
2369
+ fp = FailurePolicy(
2370
+ consecutive_failures=3, # fire on_error only after this many consecutive failures
2371
+ recovery_checks=2, # require this many consecutive successes to clear failure state
2372
+ respect_retry_after=True, # honour Retry-After response header if present
2373
+ )
2374
+ ```
2375
+
2376
+ | Field | Type | Default | Description |
2377
+ |---|---|---|---|
2378
+ | `consecutive_failures` | `int` | `3` | Minimum consecutive failures before `on_error` fires (TypeScript default: `1`) |
2379
+ | `recovery_checks` | `int` | `1` | Consecutive successes required to exit failure mode |
2380
+ | `respect_retry_after` | `bool` | `False` | Use `Retry-After` header delay for the next check |
2381
+
2382
+ ---
2383
+
1894
2384
  ### `DbDiffReport`
1895
2385
 
1896
2386
  Returned by `on_change` and `DbDiffEngine.compare()`:
@@ -1994,16 +2484,21 @@ summary = db_report_summary(report) # "orders: 1 inserted"
1994
2484
  ```
1995
2485
  Commands:
1996
2486
  init Generate a watchdiff.config.json template
2487
+ validate Check a config file for errors without starting any watchers
1997
2488
  run Start continuous monitoring (URL or config file)
1998
2489
  db Monitor a database table for changes
1999
2490
  compare Fetch two URLs and compare their content
2000
2491
  check Run a single check and print the result
2001
2492
  diff Compare the last two stored snapshots for a URL
2002
- export Export history or reports to CSV or XLSX
2493
+ snapshot Show the last stored snapshot for a URL
2494
+ export Export history or reports to CSV, XLSX or JSON
2003
2495
  status Show snapshot state for all watchers in a config file
2004
2496
  history Show snapshot history for a URL
2005
2497
  reports Show diff reports for a URL
2498
+ clean Delete orphan snapshot/report files not referenced by any active watcher
2006
2499
  clear Delete all stored data for a URL
2500
+ pause Guidance: pause a watcher via the Python API
2501
+ resume Guidance: resume a watcher via the Python API
2007
2502
 
2008
2503
  Options for run:
2009
2504
  --target -t CSS selector or XPath
@@ -2028,11 +2523,18 @@ Options for run:
2028
2523
  --alert-if-no-change Fire silence alert after N seconds without change (0 = off)
2029
2524
  --proxy Proxy URL (repeatable)
2030
2525
  --user-agent User-Agent string (repeatable)
2526
+ --schedule 5-field cron expression — overrides --interval when set
2527
+ --confirm-after Re-fetch after N seconds before confirming a change (0 = off)
2528
+ --json-path $.dot.path expression to extract from JSON response before diffing
2031
2529
  --webhook -w Webhook URL (repeatable)
2032
2530
  --log-format Log format: text | json (default text)
2033
2531
  --verbose -v Enable debug logging
2034
2532
  --quiet -q Suppress change output
2035
2533
 
2534
+ Options for validate:
2535
+ --json Output result as JSON (exit code 0 = valid, 1 = invalid)
2536
+ --verbose -v Enable debug logging
2537
+
2036
2538
  Options for db:
2037
2539
  --diff-mode -m row | schema | aggregate | value (default row)
2038
2540
  --interval -i Seconds between checks (default 60)
@@ -2067,9 +2569,19 @@ Options for diff:
2067
2569
  --storage -s Storage directory
2068
2570
  --json Output raw JSON
2069
2571
 
2572
+ Options for snapshot:
2573
+ --target -t CSS selector or XPath
2574
+ --storage -s Storage directory
2575
+ --json Output snapshot as JSON
2576
+
2577
+ Options for clean:
2578
+ --config -c Config file to read active watchers from (default watchdiff.config.json)
2579
+ --storage -s Storage directory
2580
+ --yes -y Skip confirmation prompt
2581
+
2070
2582
  Options for export:
2071
2583
  --type What to export: reports | snapshots (default reports)
2072
- --format Output format: csv | xlsx (default csv)
2584
+ --format Output format: csv | xlsx | json (default csv)
2073
2585
  --output -o Output file path (prints to stdout if omitted)
2074
2586
  --limit -n Max entries to export (default 500)
2075
2587
 
@@ -2112,6 +2624,9 @@ Every CLI option can be set via environment variable — useful for Docker, CI,
2112
2624
  | `WATCHDIFF_ALERT_IF_NO_CHANGE` | `--alert-if-no-change` | `86400` |
2113
2625
  | `WATCHDIFF_PROXY` | `--proxy` | `http://proxy:8080` |
2114
2626
  | `WATCHDIFF_USER_AGENT` | `--user-agent` | `MyBot/1.0` |
2627
+ | `WATCHDIFF_SCHEDULE` | `--schedule` | `0 9 * * *` |
2628
+ | `WATCHDIFF_CONFIRM_AFTER` | `--confirm-after` | `30` |
2629
+ | `WATCHDIFF_JSON_PATH` | `--json-path` | `$.data.price` |
2115
2630
  | `WATCHDIFF_TARGET` | `--target` | `.price` |
2116
2631
  | `WATCHDIFF_QUIET` | `--quiet` | `true` |
2117
2632
  | `WATCHDIFF_LOG_FORMAT` | `--log-format` | `json` |