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.
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/PKG-INFO +475 -17
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/README.md +473 -15
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/pyproject.toml +1 -1
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/__init__.py +53 -1
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/core.py +72 -5
- watchdiff_core-0.2.2/watchdiff/cron_parser/__init__.py +85 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_models.py +0 -1
- watchdiff_core-0.2.2/watchdiff/json_path/__init__.py +30 -0
- watchdiff_core-0.2.2/watchdiff/mailer/__init__.py +37 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/models.py +21 -4
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/notifier/notifier.py +7 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/scheduler/scheduler.py +60 -8
- watchdiff_core-0.2.2/watchdiff/sitemap/__init__.py +262 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/ci.yml +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/release-testpypi.yml +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.github/workflows/release.yml +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.gitignore +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/.python-version +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/LICENSE +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/main.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/test_db.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/tests/test_watchdiff.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/uv.lock +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/ai_summarizer/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_models.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cert_scheduler/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cleaner/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cleaner/cleaner.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cli/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/cli/main.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_diff/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/mysql.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/postgres.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_fetcher/sqlite.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/db_scheduler/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/diff/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/diff/engine.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/exporter/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/exporter/exporter.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/browser.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/fetcher/fetcher.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/file_fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/notifier/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/parser/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/parser/parser.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/scheduler/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/status_server/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/status_server/server.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/__init__.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/sqlite_store.py +0 -0
- {watchdiff_core-0.2.1 → watchdiff_core-0.2.2}/watchdiff/store/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: watchdiff-core
|
|
3
|
-
Version: 0.2.
|
|
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
|
[](https://github.com/r-seize/watchdiff-py/actions/workflows/ci.yml)
|
|
54
54
|
[](LICENSE)
|
|
55
55
|
|
|
56
|
-
**Lightweight web change monitoring - clean diffs,
|
|
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
|
|
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
|
|
1453
|
-
report.target
|
|
1454
|
-
report.label
|
|
1455
|
-
report.has_changes
|
|
1456
|
-
report.added
|
|
1457
|
-
report.removed
|
|
1458
|
-
report.modified
|
|
1459
|
-
report.changes
|
|
1460
|
-
report.compared_at
|
|
1461
|
-
|
|
1462
|
-
report.
|
|
1463
|
-
|
|
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
|