watchdiff-core 0.2.2__tar.gz → 0.2.4__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.2 → watchdiff_core-0.2.4}/PKG-INFO +377 -11
  2. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/README.md +376 -10
  3. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/pyproject.toml +1 -1
  4. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/tests/test_watchdiff.py +217 -2
  5. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/uv.lock +1 -1
  6. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/__init__.py +6 -0
  7. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cli/main.py +176 -5
  8. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/core.py +19 -3
  9. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/fetcher/fetcher.py +8 -0
  10. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/models.py +43 -1
  11. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/scheduler/scheduler.py +229 -34
  12. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/.github/workflows/ci.yml +0 -0
  13. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/.github/workflows/release-testpypi.yml +0 -0
  14. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/.github/workflows/release.yml +0 -0
  15. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/.gitignore +0 -0
  16. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/.python-version +0 -0
  17. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/LICENSE +0 -0
  18. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/main.py +0 -0
  19. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/tests/__init__.py +0 -0
  20. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/tests/test_db.py +0 -0
  21. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/ai_summarizer/__init__.py +0 -0
  22. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cert_fetcher/__init__.py +0 -0
  23. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cert_models.py +0 -0
  24. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cert_scheduler/__init__.py +0 -0
  25. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cleaner/__init__.py +0 -0
  26. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cleaner/cleaner.py +0 -0
  27. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cli/__init__.py +0 -0
  28. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/cron_parser/__init__.py +0 -0
  29. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_diff/__init__.py +0 -0
  30. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/__init__.py +0 -0
  31. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/mysql.py +0 -0
  32. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/postgres.py +0 -0
  33. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/sqlite.py +0 -0
  34. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_models.py +0 -0
  35. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/db_scheduler/__init__.py +0 -0
  36. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/diff/__init__.py +0 -0
  37. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/diff/engine.py +0 -0
  38. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/exporter/__init__.py +0 -0
  39. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/exporter/exporter.py +0 -0
  40. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/fetcher/__init__.py +0 -0
  41. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/fetcher/browser.py +0 -0
  42. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/file_fetcher/__init__.py +0 -0
  43. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/json_path/__init__.py +0 -0
  44. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/mailer/__init__.py +0 -0
  45. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/notifier/__init__.py +0 -0
  46. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/notifier/notifier.py +0 -0
  47. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/parser/__init__.py +0 -0
  48. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/parser/parser.py +0 -0
  49. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/scheduler/__init__.py +0 -0
  50. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/sitemap/__init__.py +0 -0
  51. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/status_server/__init__.py +0 -0
  52. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/status_server/server.py +0 -0
  53. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/store/__init__.py +0 -0
  54. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/store/sqlite_store.py +0 -0
  55. {watchdiff_core-0.2.2 → watchdiff_core-0.2.4}/watchdiff/store/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: watchdiff-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -91,6 +91,12 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
91
91
  | Confirm change before alerting | `confirm_after=30` (flapping detection) |
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
+ | 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)` |
94
100
  | Send email on change | `alert=AlertConfig(email=EmailConfig(to="...", smtp=SmtpConfig(...)))` |
95
101
  | AI summary of changes | `ai_summary=True, ai_provider=AiProvider(type="gemini", api_key="...")` |
96
102
  | Customize the AI prompt | `ai_prompt="Summarize in French."` or `ai_prompt=lambda r: ...` |
@@ -146,7 +152,13 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
146
152
  - [Flapping detection](#flapping-detection)
147
153
  - [Sitemap monitoring](#sitemap-monitoring)
148
154
  - [JSON path targeting](#json-path-targeting)
155
+ - [Cookie-based authentication](#cookie-based-authentication)
149
156
  - [Email alerts](#email-alerts)
157
+ - [Watcher ID](#watcher-id)
158
+ - [Concurrency limiting](#concurrency-limiting)
159
+ - [Maintenance windows](#maintenance-windows)
160
+ - [Active hours](#active-hours)
161
+ - [Failure policy](#failure-policy)
150
162
  - [API reference](#api-reference)
151
163
  - [`.watch()`](#watchurl--)
152
164
  - [`.watch_db()`](#watch_dbconnection_string-table--)
@@ -154,8 +166,13 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
154
166
  - [`.watch_api()`](#watch_apiurl--)
155
167
  - [`.watch_cert()`](#watch_certhost--port443-warning_days30-)
156
168
  - [`.watch_sitemap()`](#watch_sitemapurl--)
157
- - [`.start()` / `start_async()`](#startblock--startasync)
158
- - [`.stop()` / pause / resume / status](#stop--pause--resume--status)
169
+ - [`.on_change()`](#on_changecallback)
170
+ - [`.start()` / `start_async()` / `.stop()`](#startblocktrue--await-start_async--stop)
171
+ - [`.check_once()`](#check_onceurl)
172
+ - [`.compare_urls()`](#compare_urlsurl_a-url_b--)
173
+ - [`.start_status_server()` / `.stop_status_server()`](#start_status_serverport-host--stop_status_server)
174
+ - [`.pause()` / `.resume()` / `.status()` / `.db_status()`](#pauseurl--resumeurl--status--db_status)
175
+ - [`.history()` / `.reports()` / `.clear()`](#historyurl--reportsurl--clearurl)
159
176
  - [`DiffReport`](#diffreport)
160
177
  - [`Change`](#change)
161
178
  - [`Snapshot`](#snapshot)
@@ -163,6 +180,14 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
163
180
  - [`SilenceInfo`](#silenceinfo)
164
181
  - [`AlertConfig`](#alertconfig)
165
182
  - [`BrowserOptions`](#browseroptions)
183
+ - [`SpikeInfo`](#spikeinfo)
184
+ - [`StatusChangeInfo`](#statuschangeinfo)
185
+ - [`StatusServer`](#statusserver)
186
+ - [`EmailConfig` / `SmtpConfig`](#emailconfig--smtpconfig)
187
+ - [`SitemapDiffReport` / `SitemapEntry`](#sitemapdifreport--sitemapentry)
188
+ - [`MaintenanceWindow`](#maintenancewindow)
189
+ - [`ActiveBetween`](#activebetween)
190
+ - [`FailurePolicy`](#failurepolicy)
166
191
  - [`DbDiffReport`](#dbdiffreport)
167
192
  - [`DbChange`](#dbchange)
168
193
  - [`DbWatcherStatus`](#dbwatcherstatus)
@@ -295,6 +320,9 @@ watchdiff db "postgresql://user:pass@localhost/mydb" products \
295
320
  # Generate a config file
296
321
  watchdiff init
297
322
 
323
+ # Validate a config file without starting watchers
324
+ watchdiff validate watchdiff.config.json
325
+
298
326
  # Run from config file
299
327
  watchdiff run --config watchdiff.config.json
300
328
 
@@ -1087,7 +1115,15 @@ Edit `watchdiff.config.json`:
1087
1115
  "ignore_selectors": [".cookie-banner", "#ad-container"],
1088
1116
  "ignore_patterns": ["\\d+ views"],
1089
1117
  "timeout": 15,
1090
- "headers": {}
1118
+ "headers": {},
1119
+ "schedule": null,
1120
+ "confirm_after": null,
1121
+ "json_path": null,
1122
+ "email": null,
1123
+ "id": null,
1124
+ "maintenance_windows": [],
1125
+ "active_between": null,
1126
+ "failure_policy": null
1091
1127
  },
1092
1128
  {
1093
1129
  "url": "https://hnrss.org/frontpage",
@@ -1395,36 +1431,218 @@ value = extract_json_path('{"data": {"price": 42.5}}', "$.data.price")
1395
1431
  # "42.5"
1396
1432
  ```
1397
1433
 
1434
+ ## Cookie-based authentication
1435
+
1436
+ Pass cookies with every request to monitor pages that require a login session. Cookies are merged into the outgoing `Cookie` header on top of any `headers` you supply.
1437
+
1438
+ ```python
1439
+ wd.watch("https://app.example.com/dashboard",
1440
+ cookies={"session": "your-session-token", "csrftoken": "your-csrf-token"},
1441
+ interval=300,
1442
+ on_change=lambda r: print(r.changes))
1443
+ ```
1444
+
1445
+ Combine `cookies` with `headers` — the cookie string is appended to any existing `Cookie` header:
1446
+
1447
+ ```python
1448
+ wd.watch_api("https://api.example.com/account",
1449
+ headers={"Authorization": "Bearer token123"},
1450
+ cookies={"_ga": "GA1.1.0000000000.0000000000"},
1451
+ json_path="$.balance",
1452
+ interval=60)
1453
+ ```
1454
+
1455
+ > Tip: copy cookie values from your browser's DevTools → Network tab → Request Headers.
1456
+
1398
1457
  ## Email alerts
1399
1458
 
1400
1459
  Send email notifications via SMTP when a change is detected. Uses Python stdlib `smtplib` — no extra dependency required.
1401
1460
 
1461
+ ### Quickstart — `email=` shortcut
1462
+
1463
+ Pass an `EmailConfig` directly to `.watch()`:
1464
+
1402
1465
  ```python
1403
1466
  from watchdiff import WatchDiff
1404
- from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1467
+ from watchdiff.models import EmailConfig, SmtpConfig
1405
1468
 
1406
1469
  wd = WatchDiff()
1470
+ wd.watch(
1471
+ "https://example.com/prices",
1472
+ email=EmailConfig(
1473
+ to="alerts@example.com",
1474
+ smtp=SmtpConfig(
1475
+ host="smtp.gmail.com",
1476
+ port=465,
1477
+ user="you@gmail.com",
1478
+ password="app-password",
1479
+ ),
1480
+ ),
1481
+ )
1482
+ wd.start()
1483
+ ```
1484
+
1485
+ ### Via `AlertConfig` — combine with webhooks and callbacks
1486
+
1487
+ ```python
1488
+ from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1489
+
1407
1490
  wd.watch("https://example.com/prices",
1408
1491
  alert=AlertConfig(
1409
- on_change=[],
1410
- webhooks=[],
1492
+ on_change=[lambda r: print(r.summary())],
1493
+ webhooks=["https://ntfy.sh/my-topic"],
1411
1494
  min_changes=1,
1412
1495
  email=EmailConfig(
1413
- to="alerts@example.com",
1496
+ to=["alerts@example.com", "team@example.com"],
1497
+ from_="watchdiff@example.com",
1498
+ subject="Price change detected",
1414
1499
  smtp=SmtpConfig(
1415
1500
  host="smtp.gmail.com",
1416
- port=465,
1501
+ port=587, # STARTTLS
1417
1502
  user="you@gmail.com",
1418
1503
  password="app-password",
1419
1504
  ),
1420
1505
  ),
1421
1506
  ))
1507
+ ```
1508
+
1509
+ ### Config file (`watchdiff.config.json`)
1510
+
1511
+ ```json
1512
+ {
1513
+ "url": "https://example.com/prices",
1514
+ "email": {
1515
+ "to": "alerts@example.com",
1516
+ "from": "watchdiff@example.com",
1517
+ "subject": "Price change detected",
1518
+ "smtp": {
1519
+ "host": "smtp.gmail.com",
1520
+ "port": 465,
1521
+ "user": "you@gmail.com",
1522
+ "password": "app-password"
1523
+ }
1524
+ }
1525
+ }
1526
+ ```
1527
+
1528
+ **Notes:**
1529
+ - `from_` defaults to `user@host` when omitted
1530
+ - `subject` defaults to `"[WatchDiff] Change detected: {label}"`
1531
+ - Multiple recipients: `to=["a@x.com", "b@x.com"]`
1532
+ - Port `465` uses SSL from the start (`SMTP_SSL`). All other ports use `STARTTLS`.
1533
+
1534
+ ## Watcher ID
1535
+
1536
+ 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.
1537
+
1538
+ ```python
1539
+ from watchdiff import WatchDiff
1540
+
1541
+ wd = WatchDiff()
1542
+ wd.watch("https://example.com/prices", id="product-price", interval=60)
1543
+ wd.watch("https://example.com/prices", id="product-price-2", interval=120)
1544
+
1545
+ wd.pause("product-price") # pause by ID, not URL
1546
+ wd.resume("product-price")
1547
+ ```
1548
+
1549
+ Without `id`, `pause(url)` pauses **all** watchers registered for that URL. With `id`, each watcher is independent and can be paused or resumed individually.
1550
+
1551
+ IDs also appear in `WatcherStatus.id` and in the JSON returned by `status()`.
1552
+
1553
+ ## Concurrency limiting
1554
+
1555
+ Cap the number of checks that run simultaneously. Without a limit all watchers fire in parallel; with a limit the scheduler queues excess checks.
1556
+
1557
+ ```python
1558
+ wd = WatchDiff(concurrency=5) # at most 5 simultaneous fetches
1559
+ ```
1560
+
1561
+ Useful when you monitor hundreds of URLs and want to avoid hammering a shared proxy pool or saturating a network interface.
1562
+
1563
+ ## Maintenance windows
1564
+
1565
+ Skip checks during a known downtime window. Checks resume automatically once the window closes.
1566
+
1567
+ ```python
1568
+ from watchdiff import WatchDiff, MaintenanceWindow
1569
+
1570
+ wd = WatchDiff()
1571
+ wd.watch(
1572
+ "https://api.example.com/health",
1573
+ interval=60,
1574
+ maintenance_windows=[
1575
+ MaintenanceWindow(
1576
+ from_="2026-02-01T02:00:00+00:00", # ISO 8601 UTC start
1577
+ to="2026-02-01T04:00:00+00:00", # UTC end
1578
+ ),
1579
+ ],
1580
+ )
1581
+ wd.start()
1582
+ ```
1583
+
1584
+ - `from_` and `to` accept either an ISO 8601 string or a `datetime` object.
1585
+ - Multiple windows can be listed; any overlapping window suppresses the check.
1586
+ - The window is evaluated fresh on each tick — no restart needed after the window passes.
1587
+ - `WatcherStatus.in_maintenance` reflects the current state.
1588
+
1589
+ ## Active hours
1590
+
1591
+ Restrict checks to specific hours and/or days. Checks outside the window are silently skipped until the next window opens.
1592
+
1593
+ ```python
1594
+ from watchdiff import WatchDiff, ActiveBetween
1595
+
1596
+ wd = WatchDiff()
1597
+ wd.watch(
1598
+ "https://example.com/prices",
1599
+ interval=60,
1600
+ active_between=ActiveBetween(
1601
+ from_="09:00", # "HH:MM" — start of active window
1602
+ to="17:00", # "HH:MM" — end of active window
1603
+ days=[0, 1, 2, 3, 4], # Mon–Fri (0=Monday, 6=Sunday). None = every day
1604
+ timezone="Europe/Paris", # IANA timezone name. None = UTC
1605
+ ),
1606
+ )
1422
1607
  wd.start()
1423
1608
  ```
1424
1609
 
1425
- Optional fields: `from_` (defaults to `user@host`), `subject` (defaults to `"[WatchDiff] Change detected: {label}"`). Multiple recipients: `to=["a@x.com", "b@x.com"]`.
1610
+ - `to` before `from_` (e.g. `from_="22:00"`, `to="06:00"`) is treated as an overnight window.
1611
+ - `days` defaults to all 7 days when omitted. `timezone` defaults to UTC when omitted.
1612
+ - Requires Python 3.9+ `zoneinfo` stdlib. Falls back to UTC if the IANA database is unavailable.
1613
+ - **Note:** the Python API uses integer weekday indices (`0`=Monday … `6`=Sunday, matching `datetime.weekday()`). The TypeScript port uses string names (`"monday"`, `"friday"`, …).
1426
1614
 
1427
- Port `465` uses SSL from the start (`SMTP_SSL`). Other ports use `STARTTLS`.
1615
+ ## Failure policy
1616
+
1617
+ 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.
1618
+
1619
+ ```python
1620
+ from watchdiff import WatchDiff, FailurePolicy
1621
+
1622
+ wd = WatchDiff()
1623
+ wd.watch(
1624
+ "https://example.com/status",
1625
+ interval=30,
1626
+ failure_policy=FailurePolicy(
1627
+ consecutive_failures=3, # only fire on_error after 3 failures in a row
1628
+ recovery_checks=2, # require 2 consecutive successes to clear the failure state
1629
+ respect_retry_after=True, # honour Retry-After response header when present
1630
+ ),
1631
+ on_error=lambda exc, cfg: print(f"Confirmed failure: {exc}"),
1632
+ )
1633
+ wd.start()
1634
+ ```
1635
+
1636
+ Without `failure_policy`, the first fetch error fires `on_error` immediately. With it:
1637
+
1638
+ - Errors below the threshold are silently swallowed — no callback, no alert.
1639
+ - After `consecutive_failures` errors in a row, `on_error` fires **once**, then is suppressed until recovery.
1640
+ - Recovery requires `recovery_checks` consecutive successful fetches.
1641
+ - 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.
1642
+
1643
+ Default: `consecutive_failures=3`, `recovery_checks=1`, `respect_retry_after=False`.
1644
+
1645
+ > **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.
1428
1646
 
1429
1647
  ## API reference
1430
1648
 
@@ -1437,8 +1655,15 @@ from watchdiff.store import SqliteStore
1437
1655
  wd = WatchDiff() # JSON store in .watchdiff/
1438
1656
  wd = WatchDiff(storage_dir="/data/watchdiff") # custom JSON store path
1439
1657
  wd = WatchDiff(store=SqliteStore("db.sqlite")) # SQLite store
1658
+ wd = WatchDiff(concurrency=5) # at most 5 parallel fetch workers
1440
1659
  ```
1441
1660
 
1661
+ | Parameter | Type | Default | Description |
1662
+ |---|---|---|---|
1663
+ | `storage_dir` | `str` | `".watchdiff"` | Directory for JSON snapshot/report files |
1664
+ | `store` | `Store \| None` | `None` | Custom store implementation (e.g. `SqliteStore`) |
1665
+ | `concurrency` | `int \| None` | `None` | Max simultaneous check workers. `None` = unlimited |
1666
+
1442
1667
  #### `.watch(url, *, ...)`
1443
1668
 
1444
1669
  Register a URL to monitor. All keyword arguments are optional. Returns `self` (chainable).
@@ -1480,6 +1705,7 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
1480
1705
  | `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
1481
1706
  | `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
1482
1707
  | `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
1708
+ | `cookies` | `dict[str, str]` | `{}` | Cookies sent with every request — merged into the `Cookie` header (e.g. `{"session": "abc"}`) |
1483
1709
  | `alert` | `AlertConfig \| None` | `None` | Full alert config — use instead of `on_change`/`webhooks` to include email or fine-tune retries |
1484
1710
  | `alert_if` | `Callable[[DiffReport], bool] \| None` | `None` | Custom gate - only alert when this function returns `True` |
1485
1711
  | `expected_status` | `int \| None` | `None` | Fire `on_error` when actual HTTP status differs from this value |
@@ -1490,6 +1716,11 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
1490
1716
  | `schedule` | `str \| None` | `None` | 5-field cron expression. Overrides `interval` when set. |
1491
1717
  | `confirm_after` | `int \| None` | `None` | Re-verify after N seconds before alerting (flapping detection) |
1492
1718
  | `json_path` | `str \| None` | `None` | JSON path expression to extract a sub-value before diffing (e.g. `"$.data.price"`) |
1719
+ | `email` | `EmailConfig \| None` | `None` | SMTP email alert fired on every detected change — shortcut over building a full `AlertConfig` |
1720
+ | `id` | `str \| None` | `None` | Stable identifier for this watcher — used instead of URL in `.pause()`/`.resume()` |
1721
+ | `maintenance_windows` | `list[MaintenanceWindow]` | `[]` | One-time UTC time ranges during which checks are skipped |
1722
+ | `active_between` | `ActiveBetween \| None` | `None` | Restrict checks to a recurring daily/weekly time window |
1723
+ | `failure_policy` | `FailurePolicy \| None` | `None` | Gate `on_error` until N consecutive failures; require M consecutive successes to recover |
1493
1724
 
1494
1725
  ```python
1495
1726
  # Chainable
@@ -1797,6 +2028,8 @@ status.checks_count # int
1797
2028
  status.changes_count # int
1798
2029
  status.errors_count # int
1799
2030
  status.last_status_code # int — last known HTTP status (0 = unknown)
2031
+ status.id # str | None — stable watcher ID (set via id= on .watch())
2032
+ status.in_maintenance # bool — True if currently inside a maintenance window
1800
2033
 
1801
2034
  status.as_dict() # JSON-serialisable dict
1802
2035
  ```
@@ -1865,6 +2098,128 @@ server.start()
1865
2098
  server.stop()
1866
2099
  ```
1867
2100
 
2101
+ ### `EmailConfig` / `SmtpConfig`
2102
+
2103
+ ```python
2104
+ from watchdiff import EmailConfig, SmtpConfig
2105
+
2106
+ email = EmailConfig(
2107
+ to="ops@example.com", # str or list[str]
2108
+ smtp=SmtpConfig(
2109
+ host="smtp.example.com",
2110
+ port=587,
2111
+ user="alerts@example.com",
2112
+ password="secret",
2113
+ secure=None, # None = auto (SSL on 465, STARTTLS otherwise)
2114
+ ),
2115
+ from_="alerts@example.com", # optional sender address
2116
+ subject="[WatchDiff] change", # optional subject override
2117
+ )
2118
+ ```
2119
+
2120
+ Pass as `email=` to `.watch()` or inside `AlertConfig(email=...)`.
2121
+
2122
+ ---
2123
+
2124
+ ### `SitemapDiffReport` / `SitemapEntry`
2125
+
2126
+ Passed to callbacks registered with `.watch_sitemap()`:
2127
+
2128
+ ```python
2129
+ report.sitemap_url # str — URL of the sitemap
2130
+ report.label # str — human-readable label
2131
+ report.added # list[SitemapEntry] — URLs newly present in the sitemap
2132
+ report.removed # list[SitemapEntry] — URLs no longer in the sitemap
2133
+ report.compared_at # datetime — UTC timestamp of the comparison
2134
+ ```
2135
+
2136
+ Each `SitemapEntry`:
2137
+
2138
+ ```python
2139
+ entry.url # str
2140
+ entry.last_modified # str | None — <lastmod> value from the sitemap
2141
+ entry.change_freq # str | None — <changefreq> value
2142
+ entry.priority # str | None — <priority> value
2143
+ ```
2144
+
2145
+ ---
2146
+
2147
+ ### `MaintenanceWindow`
2148
+
2149
+ Defines a one-time UTC window during which checks are paused:
2150
+
2151
+ ```python
2152
+ from watchdiff import MaintenanceWindow
2153
+ from datetime import datetime, timezone
2154
+
2155
+ # From ISO 8601 strings (recommended)
2156
+ w = MaintenanceWindow(
2157
+ from_="2026-02-01T02:00:00+00:00",
2158
+ to="2026-02-01T04:00:00+00:00",
2159
+ )
2160
+
2161
+ # From datetime objects
2162
+ w = MaintenanceWindow(
2163
+ from_=datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc),
2164
+ to=datetime(2026, 2, 1, 4, 0, tzinfo=timezone.utc),
2165
+ )
2166
+ ```
2167
+
2168
+ | Field | Type | Description |
2169
+ |---|---|---|
2170
+ | `from_` | `datetime \| str` | UTC start; ISO 8601 string or `datetime` |
2171
+ | `to` | `datetime \| str` | UTC end |
2172
+
2173
+ ISO strings are parsed in `__post_init__`. `"Z"` suffix is accepted as `+00:00`.
2174
+
2175
+ ---
2176
+
2177
+ ### `ActiveBetween`
2178
+
2179
+ Restricts checks to a recurring daily or weekly time window:
2180
+
2181
+ ```python
2182
+ from watchdiff import ActiveBetween
2183
+
2184
+ ab = ActiveBetween(
2185
+ from_="09:00",
2186
+ to="17:00",
2187
+ days=[0, 1, 2, 3, 4], # 0=Monday … 6=Sunday. None = every day
2188
+ timezone="Europe/Paris", # IANA name. None = UTC
2189
+ )
2190
+ ```
2191
+
2192
+ | Field | Type | Default | Description |
2193
+ |---|---|---|---|
2194
+ | `from_` | `str` | — | Window start as `"HH:MM"` |
2195
+ | `to` | `str` | — | Window end as `"HH:MM"`. If before `from_`, treated as overnight |
2196
+ | `days` | `list[int] \| None` | `None` | Weekdays to restrict to (`0`=Mon). `None` = every day |
2197
+ | `timezone` | `str \| None` | `None` | IANA timezone name (e.g. `"America/New_York"`). `None` = UTC |
2198
+
2199
+ ---
2200
+
2201
+ ### `FailurePolicy`
2202
+
2203
+ Gates `on_error` to avoid alert noise from transient blips:
2204
+
2205
+ ```python
2206
+ from watchdiff import FailurePolicy
2207
+
2208
+ fp = FailurePolicy(
2209
+ consecutive_failures=3, # fire on_error only after this many consecutive failures
2210
+ recovery_checks=2, # require this many consecutive successes to clear failure state
2211
+ respect_retry_after=True, # honour Retry-After response header if present
2212
+ )
2213
+ ```
2214
+
2215
+ | Field | Type | Default | Description |
2216
+ |---|---|---|---|
2217
+ | `consecutive_failures` | `int` | `3` | Minimum consecutive failures before `on_error` fires (TypeScript default: `1`) |
2218
+ | `recovery_checks` | `int` | `1` | Consecutive successes required to exit failure mode |
2219
+ | `respect_retry_after` | `bool` | `False` | Use `Retry-After` header delay for the next check |
2220
+
2221
+ ---
2222
+
1868
2223
  ### `DbDiffReport`
1869
2224
 
1870
2225
  Returned by `on_change` and `DbDiffEngine.compare()`:
@@ -1968,6 +2323,7 @@ summary = db_report_summary(report) # "orders: 1 inserted"
1968
2323
  ```
1969
2324
  Commands:
1970
2325
  init Generate a watchdiff.config.json template
2326
+ validate Check a config file for errors without starting any watchers
1971
2327
  run Start continuous monitoring (URL or config file)
1972
2328
  db Monitor a database table for changes
1973
2329
  compare Fetch two URLs and compare their content
@@ -2002,11 +2358,18 @@ Options for run:
2002
2358
  --alert-if-no-change Fire silence alert after N seconds without change (0 = off)
2003
2359
  --proxy Proxy URL (repeatable)
2004
2360
  --user-agent User-Agent string (repeatable)
2361
+ --schedule 5-field cron expression — overrides --interval when set
2362
+ --confirm-after Re-fetch after N seconds before confirming a change (0 = off)
2363
+ --json-path $.dot.path expression to extract from JSON response before diffing
2005
2364
  --webhook -w Webhook URL (repeatable)
2006
2365
  --log-format Log format: text | json (default text)
2007
2366
  --verbose -v Enable debug logging
2008
2367
  --quiet -q Suppress change output
2009
2368
 
2369
+ Options for validate:
2370
+ --json Output result as JSON (exit code 0 = valid, 1 = invalid)
2371
+ --verbose -v Enable debug logging
2372
+
2010
2373
  Options for db:
2011
2374
  --diff-mode -m row | schema | aggregate | value (default row)
2012
2375
  --interval -i Seconds between checks (default 60)
@@ -2086,6 +2449,9 @@ Every CLI option can be set via environment variable — useful for Docker, CI,
2086
2449
  | `WATCHDIFF_ALERT_IF_NO_CHANGE` | `--alert-if-no-change` | `86400` |
2087
2450
  | `WATCHDIFF_PROXY` | `--proxy` | `http://proxy:8080` |
2088
2451
  | `WATCHDIFF_USER_AGENT` | `--user-agent` | `MyBot/1.0` |
2452
+ | `WATCHDIFF_SCHEDULE` | `--schedule` | `0 9 * * *` |
2453
+ | `WATCHDIFF_CONFIRM_AFTER` | `--confirm-after` | `30` |
2454
+ | `WATCHDIFF_JSON_PATH` | `--json-path` | `$.data.price` |
2089
2455
  | `WATCHDIFF_TARGET` | `--target` | `.price` |
2090
2456
  | `WATCHDIFF_QUIET` | `--quiet` | `true` |
2091
2457
  | `WATCHDIFF_LOG_FORMAT` | `--log-format` | `json` |