watchdiff-core 0.2.1__tar.gz → 0.2.2__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.1 → watchdiff_core-0.2.2}/PKG-INFO +475 -17
  2. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/README.md +473 -15
  3. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/pyproject.toml +1 -1
  4. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/__init__.py +53 -1
  5. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/core.py +72 -5
  6. watchdiff_core-0.2.2/watchdiff/cron_parser/__init__.py +85 -0
  7. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_models.py +0 -1
  8. watchdiff_core-0.2.2/watchdiff/json_path/__init__.py +30 -0
  9. watchdiff_core-0.2.2/watchdiff/mailer/__init__.py +37 -0
  10. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/models.py +21 -4
  11. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/notifier/notifier.py +7 -0
  12. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/scheduler/scheduler.py +60 -8
  13. watchdiff_core-0.2.2/watchdiff/sitemap/__init__.py +262 -0
  14. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/ci.yml +0 -0
  15. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/release-testpypi.yml +0 -0
  16. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/release.yml +0 -0
  17. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.gitignore +0 -0
  18. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.python-version +0 -0
  19. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/LICENSE +0 -0
  20. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/main.py +0 -0
  21. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/__init__.py +0 -0
  22. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/test_db.py +0 -0
  23. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/test_watchdiff.py +0 -0
  24. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/uv.lock +0 -0
  25. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/ai_summarizer/__init__.py +0 -0
  26. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_fetcher/__init__.py +0 -0
  27. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_models.py +0 -0
  28. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_scheduler/__init__.py +0 -0
  29. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cleaner/__init__.py +0 -0
  30. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cleaner/cleaner.py +0 -0
  31. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cli/__init__.py +0 -0
  32. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cli/main.py +0 -0
  33. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_diff/__init__.py +0 -0
  34. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/__init__.py +0 -0
  35. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/mysql.py +0 -0
  36. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/postgres.py +0 -0
  37. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/sqlite.py +0 -0
  38. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_scheduler/__init__.py +0 -0
  39. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/diff/__init__.py +0 -0
  40. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/diff/engine.py +0 -0
  41. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/exporter/__init__.py +0 -0
  42. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/exporter/exporter.py +0 -0
  43. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/__init__.py +0 -0
  44. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/browser.py +0 -0
  45. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/fetcher.py +0 -0
  46. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/file_fetcher/__init__.py +0 -0
  47. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/notifier/__init__.py +0 -0
  48. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/parser/__init__.py +0 -0
  49. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/parser/parser.py +0 -0
  50. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/scheduler/__init__.py +0 -0
  51. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/status_server/__init__.py +0 -0
  52. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/status_server/server.py +0 -0
  53. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/__init__.py +0 -0
  54. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/sqlite_store.py +0 -0
  55. {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/store.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: watchdiff-core
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -53,10 +53,9 @@ Description-Content-Type: text/markdown
53
53
  [![CI](https://github.com/r-seize/watchdiff-py/actions/workflows/ci.yml/badge.svg)](https://github.com/r-seize/watchdiff-py/actions/workflows/ci.yml)
54
54
  [![License: BSD-2-Clause](https://img.shields.io/badge/license-BSD--2--Clause-blue)](LICENSE)
55
55
 
56
- **Lightweight web change monitoring - clean diffs, structured alerts, no AI required.**
56
+ **Lightweight web change monitoring - clean diffs, AI summaries, file/API/SSL monitoring, structured alerts.**
57
57
 
58
- WatchDiff watches web pages and tells you **exactly what changed**, in plain language.
59
- No noisy HTML diffs. No external services. No AI black boxes.
58
+ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then tells you exactly what changed, in plain language or via an AI-generated summary.
60
59
 
61
60
  ## At a glance
62
61
 
@@ -83,6 +82,18 @@ No noisy HTML diffs. No external services. No AI black boxes.
83
82
  | Screenshot on change | `screenshot_on_change=True, browser=True` |
84
83
  | Detect change spikes | `change_spike_window=60, change_spike_threshold=5` |
85
84
  | Alert on HTTP status change | `alert_on_status_change=True` (200→503, 503→200, etc.) |
85
+ | Monitor a local file | `.watch_file("/etc/nginx/nginx.conf", interval=30)` |
86
+ | Monitor a REST API | `.watch_api("https://api.example.com/prices", expected_status=200)` |
87
+ | Track API response time | `track_response_time=True` → `report.response_time_ms` |
88
+ | Monitor SSL certificate | `.watch_cert("example.com", warn_days_before_expiry=14)` |
89
+ | Alert only on condition | `alert_if=lambda r: any("ERROR" in (c.after or "") for c in r.changes)` |
90
+ | Fire checks on a schedule | `schedule="0 9 * * *"` (cron expression) |
91
+ | Confirm change before alerting | `confirm_after=30` (flapping detection) |
92
+ | Monitor a sitemap for URL changes | `.watch_sitemap("https://example.com/sitemap.xml")` |
93
+ | Watch one JSON field only | `json_path="$.data.price"` |
94
+ | Send email on change | `alert=AlertConfig(email=EmailConfig(to="...", smtp=SmtpConfig(...)))` |
95
+ | AI summary of changes | `ai_summary=True, ai_provider=AiProvider(type="gemini", api_key="...")` |
96
+ | Customize the AI prompt | `ai_prompt="Summarize in French."` or `ai_prompt=lambda r: ...` |
86
97
  | Compare two different URLs | `.compare_urls(url_a, url_b)` / `watchdiff compare <urlA> <urlB>` |
87
98
  | Monitor a database table | `.watch_db("sqlite:///app.db", "orders")` |
88
99
  | DB diff mode | `diff_mode="row"` \| `"schema"` \| `"aggregate"` \| `"value"` |
@@ -97,6 +108,7 @@ No noisy HTML diffs. No external services. No AI black boxes.
97
108
 
98
109
  - [Install](#install)
99
110
  - [Quick start](#quick-start)
111
+ - [How it works](#how-it-works)
100
112
  - [Features](#features)
101
113
  - [Diff modes](#diff-modes)
102
114
  - [RSS / Atom feeds](#rss--atom-feeds)
@@ -125,18 +137,39 @@ No noisy HTML diffs. No external services. No AI black boxes.
125
137
  - [SQLite storage backend](#sqlite-storage-backend)
126
138
  - [CSV and XLSX export](#csv-and-xlsx-export)
127
139
  - [Config file](#config-file-workflow)
140
+ - [AI summaries](#ai-summaries)
141
+ - [File monitoring](#file-monitoring)
142
+ - [API monitoring](#api-monitoring)
143
+ - [SSL certificate monitoring](#ssl-certificate-monitoring)
144
+ - [Condition-based alerts](#condition-based-alerts---alert_if)
145
+ - [Cron scheduling](#cron-scheduling)
146
+ - [Flapping detection](#flapping-detection)
147
+ - [Sitemap monitoring](#sitemap-monitoring)
148
+ - [JSON path targeting](#json-path-targeting)
149
+ - [Email alerts](#email-alerts)
128
150
  - [API reference](#api-reference)
129
151
  - [`.watch()`](#watchurl--)
130
152
  - [`.watch_db()`](#watch_dbconnection_string-table--)
153
+ - [`.watch_file()`](#watch_filepath--)
154
+ - [`.watch_api()`](#watch_apiurl--)
155
+ - [`.watch_cert()`](#watch_certhost--port443-warning_days30-)
156
+ - [`.watch_sitemap()`](#watch_sitemapurl--)
131
157
  - [`.start()` / `start_async()`](#startblock--startasync)
132
158
  - [`.stop()` / pause / resume / status](#stop--pause--resume--status)
133
159
  - [`DiffReport`](#diffreport)
160
+ - [`Change`](#change)
161
+ - [`Snapshot`](#snapshot)
162
+ - [`WatcherStatus`](#watcherstatus)
163
+ - [`SilenceInfo`](#silenceinfo)
164
+ - [`AlertConfig`](#alertconfig)
165
+ - [`BrowserOptions`](#browseroptions)
134
166
  - [`DbDiffReport`](#dbdiffreport)
135
167
  - [`DbChange`](#dbchange)
136
168
  - [`DbWatcherStatus`](#dbwatcherstatus)
137
169
  - [`SchemaChangeInfo` / `ThresholdInfo`](#schemachangeinfo--thresholdinfo)
138
170
  - [CLI reference](#cli-reference)
139
171
  - [Environment variables](#environment-variables)
172
+ - [Advanced usage](#advanced-usage)
140
173
  - [Use cases](#use-cases)
141
174
 
142
175
  ## Why WatchDiff?
@@ -298,6 +331,38 @@ watchdiff reports https://example.com
298
331
  watchdiff clear https://example.com
299
332
  ```
300
333
 
334
+ ## How it works
335
+
336
+ ### Web pipeline
337
+
338
+ Every web check runs through a fixed pipeline:
339
+
340
+ ```
341
+ Fetcher / BrowserFetcher → Cleaner → Parser → DiffEngine → Store → Notifier
342
+ ```
343
+
344
+ 1. **Fetcher** — downloads the page via `httpx`, with proxy/UA rotation and optional retry
345
+ 2. **BrowserFetcher** — optional Playwright path for JS-rendered pages
346
+ 3. **Cleaner** — strips scripts, styles, ads and tracking noise (`beautifulsoup4`)
347
+ 4. **Parser** — extracts the target CSS selector or XPath expression (or full body)
348
+ 5. **DiffEngine** — compares content in line, word, semantic, JSON or RSS mode
349
+ 6. **Store** — persists snapshots and reports as JSON files or SQLite
350
+ 7. **Notifier** — fires callbacks and webhooks on detected changes
351
+
352
+ ### Database pipeline
353
+
354
+ Every database check runs through a parallel pipeline:
355
+
356
+ ```
357
+ DbFetcher (SQLite / PostgreSQL / MySQL) → DbDiffEngine → Store → callbacks / webhooks
358
+ ```
359
+
360
+ 1. **DbFetcher** — executes `SELECT * FROM <table>` (or a custom query) via the appropriate driver adapter
361
+ 2. **DbDiffEngine** — compares snapshots in one of four modes: `row`, `schema`, `aggregate`, or `value`
362
+ 3. **Store** — serialises the row snapshot to JSON and saves it via the existing `Store` interface
363
+ 4. **Dispatch** — fires `on_change`, `on_schema_change`, `on_threshold` callbacks and webhooks on detected changes
364
+
365
+
301
366
  ## Features
302
367
 
303
368
  ### Diff modes
@@ -1168,6 +1233,199 @@ except AiError as e:
1168
1233
  print(e.status_code) # 429
1169
1234
  ```
1170
1235
 
1236
+ ## File monitoring
1237
+
1238
+ Watch local files or config files for changes using the same diff pipeline as URL monitoring.
1239
+
1240
+ ```python
1241
+ wd.watch_file("/etc/nginx/nginx.conf", interval=30, diff_mode="line",
1242
+ on_change=lambda r: print("Config changed:", r.changes))
1243
+
1244
+ wd.watch_file("/var/log/app.log", interval=5,
1245
+ alert_if=lambda r: any("ERROR" in (c.after or "") for c in r.changes))
1246
+
1247
+ # With AI summary
1248
+ wd.watch_file("/tmp/prices.txt", interval=2, ai_summary=True,
1249
+ on_change=lambda r: print(r.ai_summary))
1250
+ ```
1251
+
1252
+ ## API monitoring
1253
+
1254
+ `watch_api()` is a convenience wrapper over `watch()` that defaults to JSON diff mode and tracks response time by default.
1255
+
1256
+ ```python
1257
+ wd.watch_api("https://api.example.com/v1/prices",
1258
+ interval=60,
1259
+ expected_status=200,
1260
+ on_change=lambda r: print(f"Response time: {r.response_time_ms:.0f}ms, diff: {r.changes}"))
1261
+ ```
1262
+
1263
+ `report.response_time_ms` is populated on every check (enabled by default on `watch_api()`).
1264
+
1265
+ ## SSL certificate monitoring
1266
+
1267
+ Alert before a certificate expires or when it is silently replaced (renewal, infrastructure change).
1268
+
1269
+ ```python
1270
+ wd.watch_cert("example.com",
1271
+ warn_days_before_expiry=30,
1272
+ alert_on_expiry=True,
1273
+ alert_on_change=True,
1274
+ webhooks=["https://discord.com/api/webhooks/..."],
1275
+ on_expiry=lambda i: print(f"{i.hostname} expires in {i.days_until_expiry} days"),
1276
+ on_change=lambda i: print(f"Cert replaced - new expiry {i.current_valid_to}"))
1277
+
1278
+ # Check status
1279
+ print(wd.get_cert_statuses())
1280
+ # [CertWatcherStatus(hostname="example.com", days_until_expiry=12, is_expiring_soon=True, ...)]
1281
+ ```
1282
+
1283
+ Default options: `port=443`, `interval=86400` (24h), `warn_days_before_expiry=30`.
1284
+
1285
+ ## Condition-based alerts - alert_if
1286
+
1287
+ Suppress alerts unless a custom condition is met. `alert_if` is evaluated after diffing and before dispatching webhooks/callbacks - avoids noisy alerts without sacrificing monitoring coverage.
1288
+
1289
+ ```python
1290
+ # Only alert when the price drops below a threshold
1291
+ wd.watch("https://shop.example.com/product", target=".price",
1292
+ alert_if=lambda r: any(
1293
+ float((c.after or "0").replace(",", ".").strip("€$ ")) < 25
1294
+ for c in r.changes if c.after
1295
+ ))
1296
+
1297
+ # Only alert on critical log lines
1298
+ wd.watch_file("/var/log/app.log",
1299
+ alert_if=lambda r: any(
1300
+ ("CRITICAL" in (c.after or "") or "FATAL" in (c.after or ""))
1301
+ for c in r.changes
1302
+ ))
1303
+
1304
+ # Only alert when a JSON API field exceeds a threshold
1305
+ wd.watch_api("https://api.example.com/stats",
1306
+ alert_if=lambda r: any(
1307
+ c.context == "errorRate" and float(c.after or 0) > 5
1308
+ for c in r.changes
1309
+ ))
1310
+ ```
1311
+
1312
+ ## Cron scheduling
1313
+
1314
+ Use a 5-field cron expression instead of a fixed interval. The watcher fires at each matching time instead of every N seconds.
1315
+
1316
+ ```python
1317
+ # Every day at 9am
1318
+ wd.watch("https://example.com/prices", schedule="0 9 * * *",
1319
+ on_change=lambda r: print("Morning check:", r.changes))
1320
+
1321
+ # Every Monday and Friday at 8:30am
1322
+ wd.watch("https://example.com/report", schedule="30 8 * * 1,5")
1323
+
1324
+ # Every 15 minutes during business hours (Mon-Fri, 9am-6pm)
1325
+ wd.watch("https://api.example.com/stock", schedule="*/15 9-18 * * 1-5")
1326
+ ```
1327
+
1328
+ Supported syntax: `*`, specific values, lists (`1,3,5`), ranges (`1-5`), and steps (`*/5`, `1-5/2`). When `schedule` is set, `interval` is ignored.
1329
+
1330
+ ```python
1331
+ from watchdiff import next_cron_run
1332
+ from datetime import datetime
1333
+
1334
+ next_run = next_cron_run("0 9 * * *", datetime(2026, 1, 1, 8, 0))
1335
+ # datetime(2026, 1, 1, 9, 0)
1336
+ ```
1337
+
1338
+ ## Flapping detection
1339
+
1340
+ Re-verify a change before firing alerts. If the content reverts within `confirm_after` seconds, the alert is suppressed.
1341
+
1342
+ ```python
1343
+ wd.watch("https://example.com/status",
1344
+ confirm_after=30, # wait 30s then re-check before alerting
1345
+ on_change=lambda r: print("Confirmed change:", r.changes))
1346
+ ```
1347
+
1348
+ Useful for pages with transient content (A/B tests, live scores, dashboards) where a single-check spike should not trigger an alert.
1349
+
1350
+ ## Sitemap monitoring
1351
+
1352
+ Watch a `sitemap.xml` for added or removed URLs. Handles sitemap index files automatically.
1353
+
1354
+ ```python
1355
+ from watchdiff import WatchDiff
1356
+
1357
+ wd = WatchDiff()
1358
+ wd.watch_sitemap(
1359
+ "https://example.com/sitemap.xml",
1360
+ interval=3600,
1361
+ on_added=lambda entries: print("New URLs:", [e.url for e in entries]),
1362
+ on_removed=lambda entries: print("Removed:", [e.url for e in entries]),
1363
+ on_change=lambda r: print(f"{len(r.added)} added, {len(r.removed)} removed"),
1364
+ )
1365
+ wd.start()
1366
+
1367
+ # Check status
1368
+ for s in wd.get_sitemap_statuses():
1369
+ print(s.url, s.entry_count, s.changes_count)
1370
+ ```
1371
+
1372
+ `SitemapEntry` fields: `url`, `lastmod`, `changefreq`, `priority`.
1373
+
1374
+ ## JSON path targeting
1375
+
1376
+ Extract a specific value from a JSON response before diffing. Only the targeted field is compared.
1377
+
1378
+ ```python
1379
+ # Only watch the "price" field, ignore all other fields
1380
+ wd.watch_api("https://api.example.com/product/42",
1381
+ json_path="$.data.price",
1382
+ on_change=lambda r: print("Price changed:", r.changes))
1383
+
1384
+ # Nested path with array index
1385
+ wd.watch_api("https://api.example.com/leaderboard",
1386
+ json_path="$.entries[0].score")
1387
+ ```
1388
+
1389
+ Supports `$`, `.key`, and `[n]` notation. Use standalone:
1390
+
1391
+ ```python
1392
+ from watchdiff import extract_json_path
1393
+
1394
+ value = extract_json_path('{"data": {"price": 42.5}}', "$.data.price")
1395
+ # "42.5"
1396
+ ```
1397
+
1398
+ ## Email alerts
1399
+
1400
+ Send email notifications via SMTP when a change is detected. Uses Python stdlib `smtplib` — no extra dependency required.
1401
+
1402
+ ```python
1403
+ from watchdiff import WatchDiff
1404
+ from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1405
+
1406
+ wd = WatchDiff()
1407
+ wd.watch("https://example.com/prices",
1408
+ alert=AlertConfig(
1409
+ on_change=[],
1410
+ webhooks=[],
1411
+ min_changes=1,
1412
+ email=EmailConfig(
1413
+ to="alerts@example.com",
1414
+ smtp=SmtpConfig(
1415
+ host="smtp.gmail.com",
1416
+ port=465,
1417
+ user="you@gmail.com",
1418
+ password="app-password",
1419
+ ),
1420
+ ),
1421
+ ))
1422
+ wd.start()
1423
+ ```
1424
+
1425
+ Optional fields: `from_` (defaults to `user@host`), `subject` (defaults to `"[WatchDiff] Change detected: {label}"`). Multiple recipients: `to=["a@x.com", "b@x.com"]`.
1426
+
1427
+ Port `465` uses SSL from the start (`SMTP_SSL`). Other ports use `STARTTLS`.
1428
+
1171
1429
  ## API reference
1172
1430
 
1173
1431
  ### `WatchDiff`
@@ -1222,12 +1480,16 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
1222
1480
  | `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
1223
1481
  | `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
1224
1482
  | `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
1483
+ | `alert` | `AlertConfig \| None` | `None` | Full alert config — use instead of `on_change`/`webhooks` to include email or fine-tune retries |
1225
1484
  | `alert_if` | `Callable[[DiffReport], bool] \| None` | `None` | Custom gate - only alert when this function returns `True` |
1226
1485
  | `expected_status` | `int \| None` | `None` | Fire `on_error` when actual HTTP status differs from this value |
1227
1486
  | `track_response_time` | `bool` | `False` | Record response time in milliseconds in `DiffReport.response_time_ms` |
1228
1487
  | `ai_summary` | `bool` | `False` | Generate an AI natural-language summary of detected changes |
1229
1488
  | `ai_provider` | `AiProvider \| None` | `None` | AI provider to use. Auto-detected from env vars when `None` |
1230
1489
  | `ai_prompt` | `str \| Callable[[DiffReport], str] \| None` | `None` | Custom prompt sent to the AI instead of the default template |
1490
+ | `schedule` | `str \| None` | `None` | 5-field cron expression. Overrides `interval` when set. |
1491
+ | `confirm_after` | `int \| None` | `None` | Re-verify after N seconds before alerting (flapping detection) |
1492
+ | `json_path` | `str \| None` | `None` | JSON path expression to extract a sub-value before diffing (e.g. `"$.data.price"`) |
1231
1493
 
1232
1494
  ```python
1233
1495
  # Chainable
@@ -1334,6 +1596,34 @@ for s in statuses:
1334
1596
  print(s.hostname, s.last_check_at, s.days_until_expiry, s.is_expiring_soon)
1335
1597
  ```
1336
1598
 
1599
+ #### `.watch_sitemap(url, *, ...)`
1600
+
1601
+ Monitor a `sitemap.xml` for added or removed URLs. Handles sitemap index files automatically.
1602
+
1603
+ | Parameter | Type | Default | Description |
1604
+ |---|---|---|---|
1605
+ | `url` | `str` | — | URL of the sitemap.xml |
1606
+ | `interval` | `int` | `3600` | Seconds between checks |
1607
+ | `label` | `str` | URL | Human-readable name |
1608
+ | `headers` | `dict` | `{}` | Extra HTTP headers |
1609
+ | `timeout` | `int` | `15` | HTTP timeout in seconds |
1610
+ | `on_added` | `Callable \| None` | `None` | Called with `list[SitemapEntry]` when new URLs appear |
1611
+ | `on_removed` | `Callable \| None` | `None` | Called with `list[SitemapEntry]` when URLs disappear |
1612
+ | `on_change` | `Callable \| None` | `None` | Called with `SitemapDiffReport` on any change |
1613
+ | `on_error` | `Callable \| None` | `None` | Called with `Exception` on fetch error |
1614
+
1615
+ ```python
1616
+ wd.watch_sitemap(
1617
+ "https://example.com/sitemap.xml",
1618
+ interval=3600,
1619
+ on_added=lambda entries: print("New URLs:", [e.url for e in entries]),
1620
+ on_removed=lambda entries: print("Removed:", [e.url for e in entries]),
1621
+ )
1622
+
1623
+ for s in wd.get_sitemap_statuses():
1624
+ print(s.url, s.entry_count, s.changes_count, s.last_check_at)
1625
+ ```
1626
+
1337
1627
  #### `.on_change(callback)`
1338
1628
 
1339
1629
  Register a global callback called whenever any watched URL changes:
@@ -1449,18 +1739,20 @@ wd.clear("https://example.com")
1449
1739
  ### `DiffReport`
1450
1740
 
1451
1741
  ```python
1452
- report.url # str
1453
- report.target # str | None
1454
- report.label # str
1455
- report.has_changes # bool
1456
- report.added # list[Change]
1457
- report.removed # list[Change]
1458
- report.modified # list[Change]
1459
- report.changes # list[Change] — all changes
1460
- report.compared_at # datetime
1461
-
1462
- report.summary() # "[Book price] 1 modified - 2024-01-15 10:30:00 UTC"
1463
- report.as_dict() # JSON-serialisable dict
1742
+ report.url # str
1743
+ report.target # str | None
1744
+ report.label # str
1745
+ report.has_changes # bool
1746
+ report.added # list[Change]
1747
+ report.removed # list[Change]
1748
+ report.modified # list[Change]
1749
+ report.changes # list[Change] — all changes
1750
+ report.compared_at # datetime
1751
+ report.ai_summary # str | None — populated when ai_summary=True
1752
+ report.response_time_ms # float | None — populated when track_response_time=True
1753
+
1754
+ report.summary() # "[Book price] 1 modified - 2024-01-15 10:30:00 UTC"
1755
+ report.as_dict() # JSON-serialisable dict
1464
1756
  ```
1465
1757
 
1466
1758
  ### `Change`
@@ -1475,6 +1767,76 @@ change.human() # "[~] Changed: '$19.00' - '$24.00'"
1475
1767
  str(change) # same as .human()
1476
1768
  ```
1477
1769
 
1770
+ ### `Snapshot`
1771
+
1772
+ ```python
1773
+ snap.url # str
1774
+ snap.target # str | None
1775
+ snap.content # str — cleaned plain-text content
1776
+ snap.raw_html # str — raw HTML of the extracted zone
1777
+ snap.captured_at # datetime — UTC timestamp
1778
+ snap.checksum # str — SHA-256 of content
1779
+
1780
+ snap.is_identical_to(other) # bool — compare by checksum
1781
+ ```
1782
+
1783
+ ### `WatcherStatus`
1784
+
1785
+ Returned by `.status()`:
1786
+
1787
+ ```python
1788
+ status.url # str
1789
+ status.label # str
1790
+ status.target # str | None
1791
+ status.interval # int — seconds between checks
1792
+ status.paused # bool
1793
+ status.last_check_at # datetime | None
1794
+ status.next_check_at # datetime | None
1795
+ status.last_change_at # datetime | None
1796
+ status.checks_count # int
1797
+ status.changes_count # int
1798
+ status.errors_count # int
1799
+ status.last_status_code # int — last known HTTP status (0 = unknown)
1800
+
1801
+ status.as_dict() # JSON-serialisable dict
1802
+ ```
1803
+
1804
+ ### `SilenceInfo`
1805
+
1806
+ Passed to the `on_silence` callback:
1807
+
1808
+ ```python
1809
+ info.url # str
1810
+ info.label # str
1811
+ info.seconds_since_last_change # float
1812
+ ```
1813
+
1814
+ ### `AlertConfig`
1815
+
1816
+ ```python
1817
+ from watchdiff import AlertConfig
1818
+
1819
+ AlertConfig(
1820
+ on_change=[lambda r: print(r.summary())], # list of callbacks
1821
+ webhooks=["https://hooks.slack.com/..."],
1822
+ min_changes=1,
1823
+ webhook_retries=3,
1824
+ email=EmailConfig(...), # optional — requires SmtpConfig
1825
+ )
1826
+ ```
1827
+
1828
+ ### `BrowserOptions`
1829
+
1830
+ ```python
1831
+ from watchdiff import BrowserOptions
1832
+
1833
+ BrowserOptions(
1834
+ wait_for="networkidle", # "load" | "domcontentloaded" | "networkidle"
1835
+ wait_for_selector=".price", # wait for CSS selector before capturing
1836
+ timeout=30000, # ms — Playwright page.goto timeout
1837
+ )
1838
+ ```
1839
+
1478
1840
  ### `SpikeInfo`
1479
1841
 
1480
1842
  ```python
@@ -1741,6 +2103,102 @@ ENV WATCHDIFF_STATUS_PORT=9090
1741
2103
  CMD ["watchdiff", "run", "--config", "/app/watchdiff.config.json"]
1742
2104
  ```
1743
2105
 
2106
+ ## Advanced usage
2107
+
2108
+ ### Use individual pipeline stages
2109
+
2110
+ All internal modules are exported and fully typed:
2111
+
2112
+ ```python
2113
+ from watchdiff import (
2114
+ Fetcher, BrowserFetcher, Cleaner, Parser, DiffEngine,
2115
+ Store, SqliteStore, Notifier,
2116
+ WatchConfig, Snapshot,
2117
+ )
2118
+
2119
+ config = WatchConfig(url="https://example.com", target=".price", diff_mode="word")
2120
+ fetcher = BrowserFetcher() if config.browser else Fetcher()
2121
+ html = fetcher.fetch(config)
2122
+
2123
+ soup = Cleaner().clean(html)
2124
+ snapshot = Parser().extract(soup, config)
2125
+
2126
+ store = Store(".watchdiff")
2127
+ previous = store.load_latest(config.url, config.target)
2128
+ if previous:
2129
+ report = DiffEngine().compare(previous, snapshot, config)
2130
+ print(report.summary())
2131
+
2132
+ store.save_snapshot(snapshot)
2133
+ ```
2134
+
2135
+ ### Custom store implementation
2136
+
2137
+ Implement the same interface as `Store` to use your own storage backend:
2138
+
2139
+ ```python
2140
+ from watchdiff import WatchDiff, Snapshot, DiffReport
2141
+
2142
+ class RedisStore:
2143
+ def save_snapshot(self, snapshot: Snapshot) -> None: ...
2144
+ def load_latest(self, url: str, target: str | None) -> Snapshot | None: ...
2145
+ def load_history(self, url: str, target: str | None, limit: int = 50) -> list[Snapshot]: ...
2146
+ def clear_history(self, url: str, target: str | None) -> None: ...
2147
+ def save_report(self, report: DiffReport) -> None: ...
2148
+ def load_reports(self, url: str, target: str | None, limit: int = 50) -> list[dict]: ...
2149
+
2150
+ wd = WatchDiff(store=RedisStore())
2151
+ wd.watch("https://example.com")
2152
+ wd.start()
2153
+ ```
2154
+
2155
+ ### Production-ready config
2156
+
2157
+ ```python
2158
+ from watchdiff import WatchDiff, SqliteStore, AlertConfig, EmailConfig, SmtpConfig
2159
+
2160
+ wd = WatchDiff(store=SqliteStore(".watchdiff.db"))
2161
+
2162
+ wd.watch(
2163
+ "https://shop.example.com/product/42",
2164
+ target=".price",
2165
+ label="Product 42 price",
2166
+ interval=120,
2167
+ jitter=0.15,
2168
+ retries=3,
2169
+ retry_delay=2.0,
2170
+ cooldown=1800,
2171
+ max_snapshots=200,
2172
+ change_threshold=0.01,
2173
+ diff_mode="word",
2174
+ alert_if_no_change_after=604800, # 1 week silence = page may be broken
2175
+ webhooks=[
2176
+ "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
2177
+ "https://ntfy.sh/my-price-monitor",
2178
+ ],
2179
+ on_error=lambda err, cfg: logger.error({"url": cfg.url, "err": str(err)}),
2180
+ on_silence=lambda info: logger.warning(
2181
+ f"{info.label} has not changed in {info.seconds_since_last_change / 86400:.1f} days"
2182
+ ),
2183
+ )
2184
+
2185
+ wd.start()
2186
+ ```
2187
+
2188
+ ### Integrate with a server shutdown hook
2189
+
2190
+ ```python
2191
+ import signal
2192
+ from watchdiff import WatchDiff
2193
+
2194
+ wd = WatchDiff()
2195
+ wd.watch("https://example.com")
2196
+ wd.start(block=False)
2197
+
2198
+ signal.signal(signal.SIGTERM, lambda *_: wd.stop())
2199
+ ```
2200
+
2201
+
1744
2202
  ## Use cases
1745
2203
 
1746
2204
  - **Database monitoring** — detect row inserts/deletes/updates, schema migrations, or count threshold crossings