watchdiff-core 0.1.5__tar.gz → 0.1.6__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.1.5 → watchdiff_core-0.1.6}/PKG-INFO +78 -4
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/README.md +77 -3
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/pyproject.toml +1 -1
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/__init__.py +2 -1
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cli/main.py +97 -12
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/core.py +5 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/fetcher.py +18 -12
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/models.py +13 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/scheduler/scheduler.py +120 -4
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/ci.yml +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/release-testpypi.yml +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/release.yml +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.gitignore +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.python-version +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/LICENSE +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/main.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/tests/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/tests/test_watchdiff.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/uv.lock +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cleaner/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cleaner/cleaner.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cli/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/diff/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/diff/engine.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/exporter/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/exporter/exporter.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/browser.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/notifier/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/notifier/notifier.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/parser/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/parser/parser.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/scheduler/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/status_server/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/status_server/server.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/store/__init__.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/store/sqlite_store.py +0 -0
- {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/store/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: watchdiff-core
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Lightweight web change monitoring library - clean diffs, structured alerts, no AI required.
|
|
5
5
|
Author: WatchDiff Contributors
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -73,6 +73,7 @@ No noisy HTML diffs. No external services. No AI black boxes.
|
|
|
73
73
|
| Archive HTML on change | `archive_html=True` |
|
|
74
74
|
| Screenshot on change | `screenshot_on_change=True, browser=True` |
|
|
75
75
|
| Detect change spikes | `change_spike_window=60, change_spike_threshold=5` |
|
|
76
|
+
| Alert on HTTP status change | `alert_on_status_change=True` (200→503, 503→200, etc.) |
|
|
76
77
|
| Compare two different URLs | `.compare_urls(url_a, url_b)` / `watchdiff compare <urlA> <urlB>` |
|
|
77
78
|
| Persist to SQLite | `WatchDiff(store=SqliteStore(".watchdiff.db"))` |
|
|
78
79
|
| Export history | `.export_reports_csv(url)` / `.export_reports_xlsx(url)` |
|
|
@@ -105,6 +106,7 @@ No noisy HTML diffs. No external services. No AI black boxes.
|
|
|
105
106
|
- [HTML archiving](#html-archiving)
|
|
106
107
|
- [Screenshot on change](#screenshot-on-change)
|
|
107
108
|
- [Change spike detection](#change-spike-detection)
|
|
109
|
+
- [HTTP status code monitoring](#http-status-code-monitoring)
|
|
108
110
|
- [URL comparison](#url-comparison)
|
|
109
111
|
- [XPath selectors](#xpath-selectors)
|
|
110
112
|
- [SQLite storage backend](#sqlite-storage-backend)
|
|
@@ -197,6 +199,13 @@ watchdiff run https://example.com --interval 60 --status-port 9090
|
|
|
197
199
|
|
|
198
200
|
# Show status from config file
|
|
199
201
|
watchdiff status
|
|
202
|
+
watchdiff status --json
|
|
203
|
+
|
|
204
|
+
# Export history / reports
|
|
205
|
+
watchdiff export https://example.com # CSV to stdout
|
|
206
|
+
watchdiff export https://example.com --output reports.csv # write to file
|
|
207
|
+
watchdiff export https://example.com --type snapshots --output snap.csv # snapshots
|
|
208
|
+
watchdiff export https://example.com --format xlsx --output reports.xlsx # XLSX
|
|
200
209
|
|
|
201
210
|
# Snapshot history and reports
|
|
202
211
|
watchdiff history https://example.com
|
|
@@ -538,7 +547,7 @@ wd.start(block=False)
|
|
|
538
547
|
|
|
539
548
|
statuses = wd.status()
|
|
540
549
|
for s in statuses:
|
|
541
|
-
print(s.url, s.checks_count, s.changes_count, s.errors_count, s.paused)
|
|
550
|
+
print(s.url, s.checks_count, s.changes_count, s.errors_count, s.paused, s.last_status_code)
|
|
542
551
|
```
|
|
543
552
|
|
|
544
553
|
`WatcherStatus` fields:
|
|
@@ -556,6 +565,7 @@ for s in statuses:
|
|
|
556
565
|
| `checks_count` | `int` | Total checks since start |
|
|
557
566
|
| `changes_count` | `int` | Total changes detected since start |
|
|
558
567
|
| `errors_count` | `int` | Total fetch/parse errors since start |
|
|
568
|
+
| `last_status_code` | `int` | Last HTTP status code (0 = unknown/unreachable) |
|
|
559
569
|
|
|
560
570
|
Returns an empty list if `.start()` has not been called yet.
|
|
561
571
|
|
|
@@ -585,8 +595,9 @@ watchdiff_changes_total{url="...",label="..."} 3
|
|
|
585
595
|
watchdiff_errors_total{url="...",label="..."} 0
|
|
586
596
|
watchdiff_paused{url="...",label="..."} 0
|
|
587
597
|
watchdiff_interval_seconds{url="...",label="..."} 300
|
|
588
|
-
|
|
589
|
-
|
|
598
|
+
watchdiff_last_check_timestamp_seconds{url="...",label="..."} 1720000000.0
|
|
599
|
+
watchdiff_last_change_timestamp_seconds{url="...",label="..."} 1719999000.0
|
|
600
|
+
watchdiff_last_http_status{url="...",label="..."} 200
|
|
590
601
|
```
|
|
591
602
|
|
|
592
603
|
```bash
|
|
@@ -669,6 +680,40 @@ The spike alert fires at most once per window to avoid repeated callbacks.
|
|
|
669
680
|
watchdiff run https://example.com --spike-window 60 --spike-threshold 5
|
|
670
681
|
```
|
|
671
682
|
|
|
683
|
+
### HTTP status code monitoring
|
|
684
|
+
|
|
685
|
+
Alert when the HTTP status code of a URL changes — detect outages (200→503), maintenance pages (200→503), or recoveries (503→200) independently of content changes.
|
|
686
|
+
|
|
687
|
+
```python
|
|
688
|
+
from watchdiff import WatchDiff, StatusChangeInfo
|
|
689
|
+
|
|
690
|
+
def on_status_change(info: StatusChangeInfo) -> None:
|
|
691
|
+
print(f"[STATUS] {info.label}: {info.previous_status} → {info.current_status}")
|
|
692
|
+
|
|
693
|
+
wd = WatchDiff()
|
|
694
|
+
wd.watch(
|
|
695
|
+
"https://example.com",
|
|
696
|
+
interval=60,
|
|
697
|
+
alert_on_status_change=True, # enable status change detection
|
|
698
|
+
on_status_change=on_status_change, # optional callback
|
|
699
|
+
webhooks=["https://ntfy.sh/my-alerts"], # webhook fires on status change too
|
|
700
|
+
)
|
|
701
|
+
wd.start()
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
**How it works:**
|
|
705
|
+
- Status `0` means the URL is unreachable (network error, DNS failure, timeout)
|
|
706
|
+
- The first check initialises the baseline — no alert is fired
|
|
707
|
+
- Subsequent checks alert only when the code **changes** (e.g. 200 → 503, 503 → 200)
|
|
708
|
+
- When `webhooks` are configured, a webhook is sent with `context: "http_status"` in the change payload
|
|
709
|
+
- The current status is always visible in `.status()` as `last_status_code` and in the Prometheus metric `watchdiff_last_http_status`
|
|
710
|
+
|
|
711
|
+
`StatusChangeInfo` fields: `url`, `label`, `previous_status`, `current_status`.
|
|
712
|
+
|
|
713
|
+
```bash
|
|
714
|
+
watchdiff run https://example.com --alert-on-status-change --webhook https://ntfy.sh/my-alerts
|
|
715
|
+
```
|
|
716
|
+
|
|
672
717
|
### URL comparison
|
|
673
718
|
|
|
674
719
|
Fetch two different URLs and compare their content in one shot — without setting up a watcher or storing snapshots.
|
|
@@ -860,6 +905,8 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
|
|
|
860
905
|
| `change_spike_window` | `int \| None` | `None` | Spike detection rolling window in seconds |
|
|
861
906
|
| `change_spike_threshold` | `int \| None` | `None` | Alert when this many changes occur in the window |
|
|
862
907
|
| `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
|
|
908
|
+
| `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
|
|
909
|
+
| `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
|
|
863
910
|
|
|
864
911
|
```python
|
|
865
912
|
# Chainable
|
|
@@ -1005,6 +1052,15 @@ info.changes_in_window # int — number of changes detected in the window
|
|
|
1005
1052
|
info.window_seconds # int — the configured window size
|
|
1006
1053
|
```
|
|
1007
1054
|
|
|
1055
|
+
### `StatusChangeInfo`
|
|
1056
|
+
|
|
1057
|
+
```python
|
|
1058
|
+
info.url # str
|
|
1059
|
+
info.label # str
|
|
1060
|
+
info.previous_status # int — HTTP status code before the change (0 = was unreachable)
|
|
1061
|
+
info.current_status # int — HTTP status code after the change (0 = now unreachable)
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1008
1064
|
### `StatusServer`
|
|
1009
1065
|
|
|
1010
1066
|
```python
|
|
@@ -1024,6 +1080,7 @@ Commands:
|
|
|
1024
1080
|
compare Fetch two URLs and compare their content
|
|
1025
1081
|
check Run a single check and print the result
|
|
1026
1082
|
diff Compare the last two stored snapshots for a URL
|
|
1083
|
+
export Export history or reports to CSV or XLSX
|
|
1027
1084
|
status Show snapshot state for all watchers in a config file
|
|
1028
1085
|
history Show snapshot history for a URL
|
|
1029
1086
|
reports Show diff reports for a URL
|
|
@@ -1048,6 +1105,10 @@ Options for run:
|
|
|
1048
1105
|
--spike-window Spike detection rolling window in seconds (0 = off)
|
|
1049
1106
|
--spike-threshold Number of changes to trigger a spike alert
|
|
1050
1107
|
--status-port Start HTTP status server on this port (0 = off)
|
|
1108
|
+
--alert-on-status-change Alert when HTTP status code changes
|
|
1109
|
+
--alert-if-no-change Fire silence alert after N seconds without change (0 = off)
|
|
1110
|
+
--proxy Proxy URL (repeatable)
|
|
1111
|
+
--user-agent User-Agent string (repeatable)
|
|
1051
1112
|
--webhook -w Webhook URL (repeatable)
|
|
1052
1113
|
--log-format Log format: text | json (default text)
|
|
1053
1114
|
--verbose -v Enable debug logging
|
|
@@ -1071,9 +1132,16 @@ Options for diff:
|
|
|
1071
1132
|
--storage -s Storage directory
|
|
1072
1133
|
--json Output raw JSON
|
|
1073
1134
|
|
|
1135
|
+
Options for export:
|
|
1136
|
+
--type What to export: reports | snapshots (default reports)
|
|
1137
|
+
--format Output format: csv | xlsx (default csv)
|
|
1138
|
+
--output -o Output file path (prints to stdout if omitted)
|
|
1139
|
+
--limit -n Max entries to export (default 500)
|
|
1140
|
+
|
|
1074
1141
|
Options for status:
|
|
1075
1142
|
--storage -s Storage directory
|
|
1076
1143
|
--config -c Config file to read URLs from
|
|
1144
|
+
--json Output raw JSON
|
|
1077
1145
|
|
|
1078
1146
|
Options for history / reports:
|
|
1079
1147
|
--limit -n Number of entries to show (default 20)
|
|
@@ -1105,6 +1173,12 @@ Every CLI option can be set via environment variable — useful for Docker, CI,
|
|
|
1105
1173
|
| `WATCHDIFF_SPIKE_WINDOW` | `--spike-window` | `60` |
|
|
1106
1174
|
| `WATCHDIFF_SPIKE_THRESHOLD` | `--spike-threshold` | `5` |
|
|
1107
1175
|
| `WATCHDIFF_STATUS_PORT` | `--status-port` | `9090` |
|
|
1176
|
+
| `WATCHDIFF_ALERT_ON_STATUS_CHANGE` | `--alert-on-status-change` | `true` |
|
|
1177
|
+
| `WATCHDIFF_ALERT_IF_NO_CHANGE` | `--alert-if-no-change` | `86400` |
|
|
1178
|
+
| `WATCHDIFF_PROXY` | `--proxy` | `http://proxy:8080` |
|
|
1179
|
+
| `WATCHDIFF_USER_AGENT` | `--user-agent` | `MyBot/1.0` |
|
|
1180
|
+
| `WATCHDIFF_TARGET` | `--target` | `.price` |
|
|
1181
|
+
| `WATCHDIFF_QUIET` | `--quiet` | `true` |
|
|
1108
1182
|
| `WATCHDIFF_LOG_FORMAT` | `--log-format` | `json` |
|
|
1109
1183
|
| `WATCHDIFF_VERBOSE` | `--verbose` | `true` |
|
|
1110
1184
|
|
|
@@ -34,6 +34,7 @@ No noisy HTML diffs. No external services. No AI black boxes.
|
|
|
34
34
|
| Archive HTML on change | `archive_html=True` |
|
|
35
35
|
| Screenshot on change | `screenshot_on_change=True, browser=True` |
|
|
36
36
|
| Detect change spikes | `change_spike_window=60, change_spike_threshold=5` |
|
|
37
|
+
| Alert on HTTP status change | `alert_on_status_change=True` (200→503, 503→200, etc.) |
|
|
37
38
|
| Compare two different URLs | `.compare_urls(url_a, url_b)` / `watchdiff compare <urlA> <urlB>` |
|
|
38
39
|
| Persist to SQLite | `WatchDiff(store=SqliteStore(".watchdiff.db"))` |
|
|
39
40
|
| Export history | `.export_reports_csv(url)` / `.export_reports_xlsx(url)` |
|
|
@@ -66,6 +67,7 @@ No noisy HTML diffs. No external services. No AI black boxes.
|
|
|
66
67
|
- [HTML archiving](#html-archiving)
|
|
67
68
|
- [Screenshot on change](#screenshot-on-change)
|
|
68
69
|
- [Change spike detection](#change-spike-detection)
|
|
70
|
+
- [HTTP status code monitoring](#http-status-code-monitoring)
|
|
69
71
|
- [URL comparison](#url-comparison)
|
|
70
72
|
- [XPath selectors](#xpath-selectors)
|
|
71
73
|
- [SQLite storage backend](#sqlite-storage-backend)
|
|
@@ -158,6 +160,13 @@ watchdiff run https://example.com --interval 60 --status-port 9090
|
|
|
158
160
|
|
|
159
161
|
# Show status from config file
|
|
160
162
|
watchdiff status
|
|
163
|
+
watchdiff status --json
|
|
164
|
+
|
|
165
|
+
# Export history / reports
|
|
166
|
+
watchdiff export https://example.com # CSV to stdout
|
|
167
|
+
watchdiff export https://example.com --output reports.csv # write to file
|
|
168
|
+
watchdiff export https://example.com --type snapshots --output snap.csv # snapshots
|
|
169
|
+
watchdiff export https://example.com --format xlsx --output reports.xlsx # XLSX
|
|
161
170
|
|
|
162
171
|
# Snapshot history and reports
|
|
163
172
|
watchdiff history https://example.com
|
|
@@ -499,7 +508,7 @@ wd.start(block=False)
|
|
|
499
508
|
|
|
500
509
|
statuses = wd.status()
|
|
501
510
|
for s in statuses:
|
|
502
|
-
print(s.url, s.checks_count, s.changes_count, s.errors_count, s.paused)
|
|
511
|
+
print(s.url, s.checks_count, s.changes_count, s.errors_count, s.paused, s.last_status_code)
|
|
503
512
|
```
|
|
504
513
|
|
|
505
514
|
`WatcherStatus` fields:
|
|
@@ -517,6 +526,7 @@ for s in statuses:
|
|
|
517
526
|
| `checks_count` | `int` | Total checks since start |
|
|
518
527
|
| `changes_count` | `int` | Total changes detected since start |
|
|
519
528
|
| `errors_count` | `int` | Total fetch/parse errors since start |
|
|
529
|
+
| `last_status_code` | `int` | Last HTTP status code (0 = unknown/unreachable) |
|
|
520
530
|
|
|
521
531
|
Returns an empty list if `.start()` has not been called yet.
|
|
522
532
|
|
|
@@ -546,8 +556,9 @@ watchdiff_changes_total{url="...",label="..."} 3
|
|
|
546
556
|
watchdiff_errors_total{url="...",label="..."} 0
|
|
547
557
|
watchdiff_paused{url="...",label="..."} 0
|
|
548
558
|
watchdiff_interval_seconds{url="...",label="..."} 300
|
|
549
|
-
|
|
550
|
-
|
|
559
|
+
watchdiff_last_check_timestamp_seconds{url="...",label="..."} 1720000000.0
|
|
560
|
+
watchdiff_last_change_timestamp_seconds{url="...",label="..."} 1719999000.0
|
|
561
|
+
watchdiff_last_http_status{url="...",label="..."} 200
|
|
551
562
|
```
|
|
552
563
|
|
|
553
564
|
```bash
|
|
@@ -630,6 +641,40 @@ The spike alert fires at most once per window to avoid repeated callbacks.
|
|
|
630
641
|
watchdiff run https://example.com --spike-window 60 --spike-threshold 5
|
|
631
642
|
```
|
|
632
643
|
|
|
644
|
+
### HTTP status code monitoring
|
|
645
|
+
|
|
646
|
+
Alert when the HTTP status code of a URL changes — detect outages (200→503), maintenance pages (200→503), or recoveries (503→200) independently of content changes.
|
|
647
|
+
|
|
648
|
+
```python
|
|
649
|
+
from watchdiff import WatchDiff, StatusChangeInfo
|
|
650
|
+
|
|
651
|
+
def on_status_change(info: StatusChangeInfo) -> None:
|
|
652
|
+
print(f"[STATUS] {info.label}: {info.previous_status} → {info.current_status}")
|
|
653
|
+
|
|
654
|
+
wd = WatchDiff()
|
|
655
|
+
wd.watch(
|
|
656
|
+
"https://example.com",
|
|
657
|
+
interval=60,
|
|
658
|
+
alert_on_status_change=True, # enable status change detection
|
|
659
|
+
on_status_change=on_status_change, # optional callback
|
|
660
|
+
webhooks=["https://ntfy.sh/my-alerts"], # webhook fires on status change too
|
|
661
|
+
)
|
|
662
|
+
wd.start()
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**How it works:**
|
|
666
|
+
- Status `0` means the URL is unreachable (network error, DNS failure, timeout)
|
|
667
|
+
- The first check initialises the baseline — no alert is fired
|
|
668
|
+
- Subsequent checks alert only when the code **changes** (e.g. 200 → 503, 503 → 200)
|
|
669
|
+
- When `webhooks` are configured, a webhook is sent with `context: "http_status"` in the change payload
|
|
670
|
+
- The current status is always visible in `.status()` as `last_status_code` and in the Prometheus metric `watchdiff_last_http_status`
|
|
671
|
+
|
|
672
|
+
`StatusChangeInfo` fields: `url`, `label`, `previous_status`, `current_status`.
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
watchdiff run https://example.com --alert-on-status-change --webhook https://ntfy.sh/my-alerts
|
|
676
|
+
```
|
|
677
|
+
|
|
633
678
|
### URL comparison
|
|
634
679
|
|
|
635
680
|
Fetch two different URLs and compare their content in one shot — without setting up a watcher or storing snapshots.
|
|
@@ -821,6 +866,8 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
|
|
|
821
866
|
| `change_spike_window` | `int \| None` | `None` | Spike detection rolling window in seconds |
|
|
822
867
|
| `change_spike_threshold` | `int \| None` | `None` | Alert when this many changes occur in the window |
|
|
823
868
|
| `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
|
|
869
|
+
| `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
|
|
870
|
+
| `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
|
|
824
871
|
|
|
825
872
|
```python
|
|
826
873
|
# Chainable
|
|
@@ -966,6 +1013,15 @@ info.changes_in_window # int — number of changes detected in the window
|
|
|
966
1013
|
info.window_seconds # int — the configured window size
|
|
967
1014
|
```
|
|
968
1015
|
|
|
1016
|
+
### `StatusChangeInfo`
|
|
1017
|
+
|
|
1018
|
+
```python
|
|
1019
|
+
info.url # str
|
|
1020
|
+
info.label # str
|
|
1021
|
+
info.previous_status # int — HTTP status code before the change (0 = was unreachable)
|
|
1022
|
+
info.current_status # int — HTTP status code after the change (0 = now unreachable)
|
|
1023
|
+
```
|
|
1024
|
+
|
|
969
1025
|
### `StatusServer`
|
|
970
1026
|
|
|
971
1027
|
```python
|
|
@@ -985,6 +1041,7 @@ Commands:
|
|
|
985
1041
|
compare Fetch two URLs and compare their content
|
|
986
1042
|
check Run a single check and print the result
|
|
987
1043
|
diff Compare the last two stored snapshots for a URL
|
|
1044
|
+
export Export history or reports to CSV or XLSX
|
|
988
1045
|
status Show snapshot state for all watchers in a config file
|
|
989
1046
|
history Show snapshot history for a URL
|
|
990
1047
|
reports Show diff reports for a URL
|
|
@@ -1009,6 +1066,10 @@ Options for run:
|
|
|
1009
1066
|
--spike-window Spike detection rolling window in seconds (0 = off)
|
|
1010
1067
|
--spike-threshold Number of changes to trigger a spike alert
|
|
1011
1068
|
--status-port Start HTTP status server on this port (0 = off)
|
|
1069
|
+
--alert-on-status-change Alert when HTTP status code changes
|
|
1070
|
+
--alert-if-no-change Fire silence alert after N seconds without change (0 = off)
|
|
1071
|
+
--proxy Proxy URL (repeatable)
|
|
1072
|
+
--user-agent User-Agent string (repeatable)
|
|
1012
1073
|
--webhook -w Webhook URL (repeatable)
|
|
1013
1074
|
--log-format Log format: text | json (default text)
|
|
1014
1075
|
--verbose -v Enable debug logging
|
|
@@ -1032,9 +1093,16 @@ Options for diff:
|
|
|
1032
1093
|
--storage -s Storage directory
|
|
1033
1094
|
--json Output raw JSON
|
|
1034
1095
|
|
|
1096
|
+
Options for export:
|
|
1097
|
+
--type What to export: reports | snapshots (default reports)
|
|
1098
|
+
--format Output format: csv | xlsx (default csv)
|
|
1099
|
+
--output -o Output file path (prints to stdout if omitted)
|
|
1100
|
+
--limit -n Max entries to export (default 500)
|
|
1101
|
+
|
|
1035
1102
|
Options for status:
|
|
1036
1103
|
--storage -s Storage directory
|
|
1037
1104
|
--config -c Config file to read URLs from
|
|
1105
|
+
--json Output raw JSON
|
|
1038
1106
|
|
|
1039
1107
|
Options for history / reports:
|
|
1040
1108
|
--limit -n Number of entries to show (default 20)
|
|
@@ -1066,6 +1134,12 @@ Every CLI option can be set via environment variable — useful for Docker, CI,
|
|
|
1066
1134
|
| `WATCHDIFF_SPIKE_WINDOW` | `--spike-window` | `60` |
|
|
1067
1135
|
| `WATCHDIFF_SPIKE_THRESHOLD` | `--spike-threshold` | `5` |
|
|
1068
1136
|
| `WATCHDIFF_STATUS_PORT` | `--status-port` | `9090` |
|
|
1137
|
+
| `WATCHDIFF_ALERT_ON_STATUS_CHANGE` | `--alert-on-status-change` | `true` |
|
|
1138
|
+
| `WATCHDIFF_ALERT_IF_NO_CHANGE` | `--alert-if-no-change` | `86400` |
|
|
1139
|
+
| `WATCHDIFF_PROXY` | `--proxy` | `http://proxy:8080` |
|
|
1140
|
+
| `WATCHDIFF_USER_AGENT` | `--user-agent` | `MyBot/1.0` |
|
|
1141
|
+
| `WATCHDIFF_TARGET` | `--target` | `.price` |
|
|
1142
|
+
| `WATCHDIFF_QUIET` | `--quiet` | `true` |
|
|
1069
1143
|
| `WATCHDIFF_LOG_FORMAT` | `--log-format` | `json` |
|
|
1070
1144
|
| `WATCHDIFF_VERBOSE` | `--verbose` | `true` |
|
|
1071
1145
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "watchdiff-core"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Lightweight web change monitoring library - clean diffs, structured alerts, no AI required."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from watchdiff.core import WatchDiff
|
|
2
2
|
from watchdiff.exporter import Exporter
|
|
3
|
-
from watchdiff.models import BrowserOptions, DiffMode, SpikeInfo, WatchConfig
|
|
3
|
+
from watchdiff.models import BrowserOptions, DiffMode, SpikeInfo, StatusChangeInfo, WatchConfig
|
|
4
4
|
from watchdiff.status_server import StatusServer
|
|
5
5
|
from watchdiff.store import SqliteStore, Store
|
|
6
6
|
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
"Exporter",
|
|
11
11
|
"SqliteStore",
|
|
12
12
|
"SpikeInfo",
|
|
13
|
+
"StatusChangeInfo",
|
|
13
14
|
"StatusServer",
|
|
14
15
|
"Store",
|
|
15
16
|
"WatchConfig",
|
|
@@ -41,7 +41,8 @@ console = Console()
|
|
|
41
41
|
# ---------------------------------------------------------------------------
|
|
42
42
|
|
|
43
43
|
_URL_ARG = typer.Argument(..., help="URL to monitor.")
|
|
44
|
-
_TARGET_OPT = typer.Option(None, "--target", "-t", help="CSS selector or XPath to watch."
|
|
44
|
+
_TARGET_OPT = typer.Option(None, "--target", "-t", help="CSS selector or XPath to watch.",
|
|
45
|
+
envvar="WATCHDIFF_TARGET")
|
|
45
46
|
_INTERVAL_OPT = typer.Option(300, "--interval", "-i", help="Seconds between checks.",
|
|
46
47
|
envvar="WATCHDIFF_INTERVAL")
|
|
47
48
|
_STORAGE_OPT = typer.Option(".watchdiff", "--storage", "-s", help="Storage directory.",
|
|
@@ -260,6 +261,18 @@ def cmd_run(
|
|
|
260
261
|
status_port: int = typer.Option(0, "--status-port",
|
|
261
262
|
help="Start status HTTP server on this port (0 = off).",
|
|
262
263
|
envvar="WATCHDIFF_STATUS_PORT"),
|
|
264
|
+
alert_on_status_change: bool = typer.Option(False, "--alert-on-status-change",
|
|
265
|
+
help="Alert when HTTP status code changes.",
|
|
266
|
+
envvar="WATCHDIFF_ALERT_ON_STATUS_CHANGE"),
|
|
267
|
+
alert_if_no_change: int = typer.Option(0, "--alert-if-no-change",
|
|
268
|
+
help="Fire silence alert after N seconds without change (0 = off).",
|
|
269
|
+
envvar="WATCHDIFF_ALERT_IF_NO_CHANGE"),
|
|
270
|
+
proxy: list[str] = typer.Option([], "--proxy",
|
|
271
|
+
help="Proxy URL (repeatable).",
|
|
272
|
+
envvar="WATCHDIFF_PROXY"),
|
|
273
|
+
user_agent: list[str] = typer.Option([], "--user-agent",
|
|
274
|
+
help="User-Agent string (repeatable).",
|
|
275
|
+
envvar="WATCHDIFF_USER_AGENT"),
|
|
263
276
|
config_file: str | None = typer.Option(None, "--config", "-c",
|
|
264
277
|
help="Load watchers from a JSON config file."),
|
|
265
278
|
) -> None:
|
|
@@ -299,13 +312,18 @@ def cmd_run(
|
|
|
299
312
|
ignore_numbers = ignore_numbers,
|
|
300
313
|
archive_html = archive_html,
|
|
301
314
|
screenshot_on_change = screenshot,
|
|
302
|
-
change_spike_window
|
|
303
|
-
change_spike_threshold
|
|
315
|
+
change_spike_window = spike_window or None,
|
|
316
|
+
change_spike_threshold = spike_threshold or None,
|
|
317
|
+
alert_on_status_change = alert_on_status_change,
|
|
318
|
+
alert_if_no_change_after = alert_if_no_change or None,
|
|
319
|
+
proxies = proxy or [],
|
|
320
|
+
user_agents = user_agent or [],
|
|
304
321
|
)
|
|
305
322
|
wd.on_change(_print_report)
|
|
306
323
|
|
|
307
|
-
cooldown_label
|
|
308
|
-
dry_run_label
|
|
324
|
+
cooldown_label = f"{cooldown}s" if cooldown > 0 else "off"
|
|
325
|
+
dry_run_label = "[yellow]dry-run[/]" if dry_run else "off"
|
|
326
|
+
status_change_label = "[yellow]on[/]" if alert_on_status_change else "off"
|
|
309
327
|
status_line = ""
|
|
310
328
|
if status_port > 0:
|
|
311
329
|
status_line = f"\nStatus API: [cyan]http://localhost:{status_port}/status[/]"
|
|
@@ -320,7 +338,8 @@ def cmd_run(
|
|
|
320
338
|
f"Browser: [yellow]{browser}[/] "
|
|
321
339
|
f"Cooldown: [yellow]{cooldown_label}[/]\n"
|
|
322
340
|
f"Retries: [yellow]{retries}[/] "
|
|
323
|
-
f"Dry-run: {dry_run_label}"
|
|
341
|
+
f"Dry-run: {dry_run_label} "
|
|
342
|
+
f"Status alerts: {status_change_label}"
|
|
324
343
|
f"{status_line}\n"
|
|
325
344
|
f"Press [bold]Ctrl+C[/] to stop.",
|
|
326
345
|
title="WatchDiff",
|
|
@@ -480,6 +499,7 @@ def cmd_status(
|
|
|
480
499
|
storage: str = _STORAGE_OPT,
|
|
481
500
|
config_file: str | None = typer.Option(None, "--config", "-c",
|
|
482
501
|
help="Config file to read URLs from."),
|
|
502
|
+
output_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
483
503
|
) -> None:
|
|
484
504
|
"""Show last snapshot info for all watched URLs (reads from config file)."""
|
|
485
505
|
from watchdiff.store import Store
|
|
@@ -512,18 +532,32 @@ def cmd_status(
|
|
|
512
532
|
table.add_column("Last snapshot", style="green")
|
|
513
533
|
table.add_column("Snapshots", justify="right")
|
|
514
534
|
|
|
535
|
+
rows = []
|
|
515
536
|
for w in watchers:
|
|
516
|
-
url_w
|
|
537
|
+
url_w = w["url"]
|
|
517
538
|
target_w = w.get("target")
|
|
518
539
|
label_w = w.get("label") or url_w
|
|
519
540
|
snaps = store.load_history(url_w, target_w, limit=9999)
|
|
520
541
|
latest = snaps[-1] if snaps else None
|
|
542
|
+
rows.append({
|
|
543
|
+
"label": label_w,
|
|
544
|
+
"url": url_w,
|
|
545
|
+
"target": target_w,
|
|
546
|
+
"last_snapshot": latest.captured_at.isoformat() if latest else None,
|
|
547
|
+
"snapshots": len(snaps),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
if output_json:
|
|
551
|
+
typer.echo(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
for row in rows:
|
|
521
555
|
table.add_row(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
str(
|
|
556
|
+
row["label"],
|
|
557
|
+
row["url"],
|
|
558
|
+
row["target"] or "full page",
|
|
559
|
+
row["last_snapshot"][:19].replace("T", " ") if row["last_snapshot"] else "-",
|
|
560
|
+
str(row["snapshots"]),
|
|
527
561
|
)
|
|
528
562
|
|
|
529
563
|
console.print(table)
|
|
@@ -593,6 +627,56 @@ def cmd_reports(
|
|
|
593
627
|
)
|
|
594
628
|
|
|
595
629
|
|
|
630
|
+
@app.command("export")
|
|
631
|
+
def cmd_export(
|
|
632
|
+
url: str = _URL_ARG,
|
|
633
|
+
target: str | None = _TARGET_OPT,
|
|
634
|
+
storage: str = _STORAGE_OPT,
|
|
635
|
+
output: str | None = typer.Option(None, "--output", "-o",
|
|
636
|
+
help="Output file path (prints to stdout if omitted)."),
|
|
637
|
+
export_type: str = typer.Option("reports", "--type",
|
|
638
|
+
help="What to export: reports | snapshots."),
|
|
639
|
+
export_format: str = typer.Option("csv", "--format",
|
|
640
|
+
help="Output format: csv | xlsx."),
|
|
641
|
+
limit: int = typer.Option(500, "--limit", "-n",
|
|
642
|
+
help="Max entries to export."),
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Export snapshot history or diff reports to CSV or XLSX."""
|
|
645
|
+
from watchdiff.exporter import Exporter # noqa: PLC0415
|
|
646
|
+
from watchdiff.store import Store # noqa: PLC0415
|
|
647
|
+
from pathlib import Path as _Path # noqa: PLC0415
|
|
648
|
+
|
|
649
|
+
if export_type not in ("reports", "snapshots"):
|
|
650
|
+
console.print("[red]--type must be 'reports' or 'snapshots'[/]")
|
|
651
|
+
raise typer.Exit(1)
|
|
652
|
+
|
|
653
|
+
if export_format not in ("csv", "xlsx"):
|
|
654
|
+
console.print("[red]--format must be 'csv' or 'xlsx'[/]")
|
|
655
|
+
raise typer.Exit(1)
|
|
656
|
+
|
|
657
|
+
store = Store(storage)
|
|
658
|
+
exporter = Exporter(store)
|
|
659
|
+
|
|
660
|
+
if export_format == "csv":
|
|
661
|
+
if export_type == "reports":
|
|
662
|
+
result = exporter.reports_csv(url, target, limit=limit,
|
|
663
|
+
dest=_Path(output) if output else None)
|
|
664
|
+
else:
|
|
665
|
+
result = exporter.snapshots_csv(url, target, limit=limit,
|
|
666
|
+
dest=_Path(output) if output else None)
|
|
667
|
+
if not output:
|
|
668
|
+
typer.echo(result)
|
|
669
|
+
else:
|
|
670
|
+
console.print(f"[green]Exported[/] {export_type} to [yellow]{output}[/]")
|
|
671
|
+
else:
|
|
672
|
+
dest = _Path(output) if output else _Path(f"{export_type}.xlsx")
|
|
673
|
+
if export_type == "reports":
|
|
674
|
+
path = exporter.reports_xlsx(url, target, limit=limit, dest=dest)
|
|
675
|
+
else:
|
|
676
|
+
path = exporter.snapshots_xlsx(url, target, limit=limit, dest=dest)
|
|
677
|
+
console.print(f"[green]Exported[/] {export_type} to [yellow]{path}[/]")
|
|
678
|
+
|
|
679
|
+
|
|
596
680
|
@app.command("clear")
|
|
597
681
|
def cmd_clear(
|
|
598
682
|
url: str = _URL_ARG,
|
|
@@ -676,6 +760,7 @@ def _run_from_config(path: Path, on_change_cb: object) -> None:
|
|
|
676
760
|
change_threshold = w.get("change_threshold"),
|
|
677
761
|
ignore_numbers = w.get("ignore_numbers", False),
|
|
678
762
|
alert_if_no_change_after = w.get("alert_if_no_change_after"),
|
|
763
|
+
alert_on_status_change = w.get("alert_on_status_change", False),
|
|
679
764
|
)
|
|
680
765
|
|
|
681
766
|
wd.on_change(on_change_cb) # type: ignore[arg-type]
|
|
@@ -42,6 +42,7 @@ from watchdiff.models import (
|
|
|
42
42
|
DiffReport,
|
|
43
43
|
SilenceInfo,
|
|
44
44
|
SpikeInfo,
|
|
45
|
+
StatusChangeInfo,
|
|
45
46
|
WatchConfig,
|
|
46
47
|
WatcherStatus,
|
|
47
48
|
)
|
|
@@ -116,6 +117,8 @@ class WatchDiff:
|
|
|
116
117
|
change_spike_window: int | None = None,
|
|
117
118
|
change_spike_threshold: int | None = None,
|
|
118
119
|
on_spike: Callable[[SpikeInfo], None] | None = None,
|
|
120
|
+
alert_on_status_change: bool = False,
|
|
121
|
+
on_status_change: Callable[[StatusChangeInfo], None] | None = None,
|
|
119
122
|
) -> WatchDiff:
|
|
120
123
|
"""
|
|
121
124
|
Register a URL to monitor.
|
|
@@ -198,6 +201,8 @@ class WatchDiff:
|
|
|
198
201
|
change_spike_window = change_spike_window,
|
|
199
202
|
change_spike_threshold = change_spike_threshold,
|
|
200
203
|
on_spike = on_spike,
|
|
204
|
+
alert_on_status_change = alert_on_status_change,
|
|
205
|
+
on_status_change = on_status_change,
|
|
201
206
|
)
|
|
202
207
|
self._configs.append(config)
|
|
203
208
|
return self
|
|
@@ -66,6 +66,10 @@ def _backoff(attempt: int, retry_delay: float) -> float:
|
|
|
66
66
|
class FetchError(Exception):
|
|
67
67
|
"""Raised when an HTTP request fails."""
|
|
68
68
|
|
|
69
|
+
def __init__(self, message: str, status_code: int = 0) -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.status_code = status_code
|
|
72
|
+
|
|
69
73
|
|
|
70
74
|
class Fetcher:
|
|
71
75
|
"""Synchronous HTTP fetcher with UA/proxy rotation and retry backoff."""
|
|
@@ -103,15 +107,16 @@ class Fetcher:
|
|
|
103
107
|
resp.raise_for_status()
|
|
104
108
|
return resp.text
|
|
105
109
|
except httpx.HTTPStatusError as exc:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
code = exc.response.status_code
|
|
111
|
+
last_exc = FetchError(f"HTTP {code} for {config.url}", status_code=code)
|
|
112
|
+
if code not in _RETRYABLE_STATUS or attempt >= config.retries:
|
|
113
|
+
raise FetchError(f"HTTP {code} for {config.url}", status_code=code) from exc
|
|
109
114
|
except httpx.RequestError as exc:
|
|
110
|
-
last_exc = FetchError(f"Request failed for {config.url}: {exc}")
|
|
115
|
+
last_exc = FetchError(f"Request failed for {config.url}: {exc}", status_code=0)
|
|
111
116
|
if attempt >= config.retries:
|
|
112
|
-
raise FetchError(f"Request failed for {config.url}: {exc}") from exc
|
|
117
|
+
raise FetchError(f"Request failed for {config.url}: {exc}", status_code=0) from exc
|
|
113
118
|
|
|
114
|
-
raise last_exc or FetchError(f"All retries exhausted for {config.url}")
|
|
119
|
+
raise last_exc or FetchError(f"All retries exhausted for {config.url}", status_code=0)
|
|
115
120
|
|
|
116
121
|
|
|
117
122
|
class AsyncFetcher:
|
|
@@ -150,12 +155,13 @@ class AsyncFetcher:
|
|
|
150
155
|
resp.raise_for_status()
|
|
151
156
|
return resp.text
|
|
152
157
|
except httpx.HTTPStatusError as exc:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
code = exc.response.status_code
|
|
159
|
+
last_exc = FetchError(f"HTTP {code} for {config.url}", status_code=code)
|
|
160
|
+
if code not in _RETRYABLE_STATUS or attempt >= config.retries:
|
|
161
|
+
raise FetchError(f"HTTP {code} for {config.url}", status_code=code) from exc
|
|
156
162
|
except httpx.RequestError as exc:
|
|
157
|
-
last_exc = FetchError(f"Request failed for {config.url}: {exc}")
|
|
163
|
+
last_exc = FetchError(f"Request failed for {config.url}: {exc}", status_code=0)
|
|
158
164
|
if attempt >= config.retries:
|
|
159
|
-
raise FetchError(f"Request failed for {config.url}: {exc}") from exc
|
|
165
|
+
raise FetchError(f"Request failed for {config.url}: {exc}", status_code=0) from exc
|
|
160
166
|
|
|
161
|
-
raise last_exc or FetchError(f"All retries exhausted for {config.url}")
|
|
167
|
+
raise last_exc or FetchError(f"All retries exhausted for {config.url}", status_code=0)
|
|
@@ -80,6 +80,9 @@ class WatchConfig:
|
|
|
80
80
|
change_spike_window: int | None = None # spike detection window in seconds
|
|
81
81
|
change_spike_threshold: int | None = None # alert when this many changes in window
|
|
82
82
|
on_spike: Callable[[SpikeInfo], None] | None = None
|
|
83
|
+
# --- new in 0.1.6 ---
|
|
84
|
+
alert_on_status_change: bool = False
|
|
85
|
+
on_status_change: Callable[[StatusChangeInfo], None] | None = None
|
|
83
86
|
|
|
84
87
|
def __post_init__(self) -> None:
|
|
85
88
|
if not self.label:
|
|
@@ -261,3 +264,13 @@ class SpikeInfo:
|
|
|
261
264
|
label: str
|
|
262
265
|
changes_in_window: int
|
|
263
266
|
window_seconds: int
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class StatusChangeInfo:
|
|
271
|
+
"""Payload passed to the on_status_change callback."""
|
|
272
|
+
|
|
273
|
+
url: str
|
|
274
|
+
label: str
|
|
275
|
+
previous_status: int
|
|
276
|
+
current_status: int
|
|
@@ -35,7 +35,7 @@ from pathlib import Path
|
|
|
35
35
|
from watchdiff.cleaner import Cleaner
|
|
36
36
|
from watchdiff.diff import DiffEngine
|
|
37
37
|
from watchdiff.fetcher import AsyncFetcher, Fetcher
|
|
38
|
-
from watchdiff.models import DiffReport, SilenceInfo, SpikeInfo, WatchConfig, WatcherStatus
|
|
38
|
+
from watchdiff.models import DiffReport, SilenceInfo, SpikeInfo, StatusChangeInfo, WatchConfig, WatcherStatus
|
|
39
39
|
from watchdiff.notifier import Notifier
|
|
40
40
|
from watchdiff.parser import Parser, ParserError
|
|
41
41
|
|
|
@@ -75,6 +75,7 @@ class SyncScheduler:
|
|
|
75
75
|
self._silence_fired: dict[str, bool] = {}
|
|
76
76
|
self._recent_change_times: dict[str, list[float]] = {}
|
|
77
77
|
self._last_spike_at: dict[str, float] = {}
|
|
78
|
+
self._last_status_code: dict[str, int] = {}
|
|
78
79
|
|
|
79
80
|
def add_global_callback(self, callback: Callable[[DiffReport], None]) -> None:
|
|
80
81
|
"""Register a callback called for every DiffReport (regardless of config)."""
|
|
@@ -146,6 +147,7 @@ class SyncScheduler:
|
|
|
146
147
|
checks_count = self._checks_count.get(key, 0),
|
|
147
148
|
changes_count = self._changes_count.get(key, 0),
|
|
148
149
|
errors_count = self._errors_count.get(key, 0),
|
|
150
|
+
last_status_code = self._last_status_code.get(key, 0),
|
|
149
151
|
))
|
|
150
152
|
return result
|
|
151
153
|
|
|
@@ -167,6 +169,7 @@ class SyncScheduler:
|
|
|
167
169
|
self._silence_fired[key] = False
|
|
168
170
|
self._recent_change_times[key] = []
|
|
169
171
|
self._last_spike_at[key] = 0.0
|
|
172
|
+
self._last_status_code[key] = 0
|
|
170
173
|
|
|
171
174
|
while not stop_event.is_set():
|
|
172
175
|
if config.url not in self._paused:
|
|
@@ -203,6 +206,8 @@ class SyncScheduler:
|
|
|
203
206
|
except Exception as exc: # noqa: BLE001
|
|
204
207
|
self._errors_count[key] = self._errors_count.get(key, 0) + 1
|
|
205
208
|
logger.error("[%s] Fetch failed: %s", config.label, exc)
|
|
209
|
+
current_status = getattr(exc, "status_code", 0)
|
|
210
|
+
self._handle_status_change(key, current_status, config)
|
|
206
211
|
if config.on_error:
|
|
207
212
|
try:
|
|
208
213
|
config.on_error(exc, config)
|
|
@@ -210,6 +215,8 @@ class SyncScheduler:
|
|
|
210
215
|
logger.warning("[%s] on_error callback error: %s", config.label, cb_exc)
|
|
211
216
|
return None
|
|
212
217
|
|
|
218
|
+
self._handle_status_change(key, 200, config)
|
|
219
|
+
|
|
213
220
|
cleaner = Cleaner(
|
|
214
221
|
extra_selectors = config.ignore_selectors,
|
|
215
222
|
extra_patterns = extra_patterns,
|
|
@@ -367,6 +374,57 @@ class SyncScheduler:
|
|
|
367
374
|
except Exception as exc: # noqa: BLE001
|
|
368
375
|
logger.warning("[%s] on_silence callback error: %s", config.label, exc)
|
|
369
376
|
|
|
377
|
+
def _handle_status_change(self, key: str, current_status: int, config: WatchConfig) -> None:
|
|
378
|
+
prev = self._last_status_code.get(key, 0)
|
|
379
|
+
self._last_status_code[key] = current_status
|
|
380
|
+
|
|
381
|
+
if not config.alert_on_status_change or prev == 0 or prev == current_status:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
label = (
|
|
385
|
+
f"recovered ({prev} → 200)"
|
|
386
|
+
if current_status == 200
|
|
387
|
+
else f"{prev} → {current_status or 'unreachable'}"
|
|
388
|
+
)
|
|
389
|
+
logger.warning("[%s] HTTP status changed: %s", config.label, label)
|
|
390
|
+
|
|
391
|
+
info = StatusChangeInfo(
|
|
392
|
+
url = config.url,
|
|
393
|
+
label = config.label or config.url,
|
|
394
|
+
previous_status = prev,
|
|
395
|
+
current_status = current_status,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if config.on_status_change:
|
|
399
|
+
try:
|
|
400
|
+
config.on_status_change(info)
|
|
401
|
+
except Exception as exc: # noqa: BLE001
|
|
402
|
+
logger.warning("[%s] on_status_change callback error: %s", config.label, exc)
|
|
403
|
+
|
|
404
|
+
if not config.dry_run and config.alert:
|
|
405
|
+
from watchdiff.models import Change, ChangeType, Snapshot # noqa: PLC0415
|
|
406
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
407
|
+
now = datetime.now(timezone.utc)
|
|
408
|
+
empty = Snapshot(url=config.url, target=config.target, content="", raw_html="")
|
|
409
|
+
fake_report = DiffReport(
|
|
410
|
+
url = config.url,
|
|
411
|
+
target = config.target,
|
|
412
|
+
label = config.label or config.url,
|
|
413
|
+
before = empty,
|
|
414
|
+
after = empty,
|
|
415
|
+
changes = [Change(
|
|
416
|
+
kind = ChangeType.MODIFIED,
|
|
417
|
+
before = str(prev),
|
|
418
|
+
after = str(current_status) if current_status else "unreachable",
|
|
419
|
+
context = "http_status",
|
|
420
|
+
)],
|
|
421
|
+
compared_at = now,
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
self._notifier.notify(fake_report, config.alert)
|
|
425
|
+
except Exception as exc: # noqa: BLE001
|
|
426
|
+
logger.warning("[%s] Status change webhook error: %s", config.label, exc)
|
|
427
|
+
|
|
370
428
|
def _cooldown_ok(self, config: WatchConfig) -> bool:
|
|
371
429
|
if config.cooldown <= 0:
|
|
372
430
|
return True
|
|
@@ -402,6 +460,7 @@ class AsyncScheduler:
|
|
|
402
460
|
self._silence_fired: dict[str, bool] = {}
|
|
403
461
|
self._recent_change_times: dict[str, list[float]] = {}
|
|
404
462
|
self._last_spike_at: dict[str, float] = {}
|
|
463
|
+
self._last_status_code: dict[str, int] = {}
|
|
405
464
|
|
|
406
465
|
def add_global_callback(self, callback: Callable[[DiffReport], None]) -> None:
|
|
407
466
|
self._on_diff_callbacks.append(callback)
|
|
@@ -439,9 +498,10 @@ class AsyncScheduler:
|
|
|
439
498
|
last_check_at = datetime.fromtimestamp(last_check, tz=timezone.utc) if last_check else None,
|
|
440
499
|
next_check_at = datetime.fromtimestamp(next_check, tz=timezone.utc) if next_check else None,
|
|
441
500
|
last_change_at = datetime.fromtimestamp(last_change, tz=timezone.utc) if last_change else None,
|
|
442
|
-
checks_count
|
|
443
|
-
changes_count
|
|
444
|
-
errors_count
|
|
501
|
+
checks_count = self._checks_count.get(key, 0),
|
|
502
|
+
changes_count = self._changes_count.get(key, 0),
|
|
503
|
+
errors_count = self._errors_count.get(key, 0),
|
|
504
|
+
last_status_code = self._last_status_code.get(key, 0),
|
|
445
505
|
))
|
|
446
506
|
return result
|
|
447
507
|
|
|
@@ -458,6 +518,7 @@ class AsyncScheduler:
|
|
|
458
518
|
self._silence_fired[key] = False
|
|
459
519
|
self._recent_change_times[key] = []
|
|
460
520
|
self._last_spike_at[key] = 0.0
|
|
521
|
+
self._last_status_code[key] = 0
|
|
461
522
|
|
|
462
523
|
while True:
|
|
463
524
|
if config.url not in self._paused:
|
|
@@ -494,6 +555,8 @@ class AsyncScheduler:
|
|
|
494
555
|
except Exception as exc: # noqa: BLE001
|
|
495
556
|
self._errors_count[key] = self._errors_count.get(key, 0) + 1
|
|
496
557
|
logger.error("[%s] Fetch failed: %s", config.label, exc)
|
|
558
|
+
current_status = getattr(exc, "status_code", 0)
|
|
559
|
+
await self._handle_status_change(key, current_status, config)
|
|
497
560
|
if config.on_error:
|
|
498
561
|
try:
|
|
499
562
|
config.on_error(exc, config)
|
|
@@ -501,6 +564,8 @@ class AsyncScheduler:
|
|
|
501
564
|
logger.warning("[%s] on_error callback error: %s", config.label, cb_exc)
|
|
502
565
|
return None
|
|
503
566
|
|
|
567
|
+
await self._handle_status_change(key, 200, config)
|
|
568
|
+
|
|
504
569
|
cleaner = Cleaner(
|
|
505
570
|
extra_selectors = config.ignore_selectors,
|
|
506
571
|
extra_patterns = extra_patterns,
|
|
@@ -658,6 +723,57 @@ class AsyncScheduler:
|
|
|
658
723
|
except Exception as exc: # noqa: BLE001
|
|
659
724
|
logger.warning("[%s] on_silence callback error: %s", config.label, exc)
|
|
660
725
|
|
|
726
|
+
async def _handle_status_change(self, key: str, current_status: int, config: WatchConfig) -> None:
|
|
727
|
+
prev = self._last_status_code.get(key, 0)
|
|
728
|
+
self._last_status_code[key] = current_status
|
|
729
|
+
|
|
730
|
+
if not config.alert_on_status_change or prev == 0 or prev == current_status:
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
label = (
|
|
734
|
+
f"recovered ({prev} → 200)"
|
|
735
|
+
if current_status == 200
|
|
736
|
+
else f"{prev} → {current_status or 'unreachable'}"
|
|
737
|
+
)
|
|
738
|
+
logger.warning("[%s] HTTP status changed: %s", config.label, label)
|
|
739
|
+
|
|
740
|
+
info = StatusChangeInfo(
|
|
741
|
+
url = config.url,
|
|
742
|
+
label = config.label or config.url,
|
|
743
|
+
previous_status = prev,
|
|
744
|
+
current_status = current_status,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if config.on_status_change:
|
|
748
|
+
try:
|
|
749
|
+
config.on_status_change(info)
|
|
750
|
+
except Exception as exc: # noqa: BLE001
|
|
751
|
+
logger.warning("[%s] on_status_change callback error: %s", config.label, exc)
|
|
752
|
+
|
|
753
|
+
if not config.dry_run and config.alert:
|
|
754
|
+
from watchdiff.models import Change, ChangeType, Snapshot # noqa: PLC0415
|
|
755
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
756
|
+
now = datetime.now(timezone.utc)
|
|
757
|
+
empty = Snapshot(url=config.url, target=config.target, content="", raw_html="")
|
|
758
|
+
fake_report = DiffReport(
|
|
759
|
+
url = config.url,
|
|
760
|
+
target = config.target,
|
|
761
|
+
label = config.label or config.url,
|
|
762
|
+
before = empty,
|
|
763
|
+
after = empty,
|
|
764
|
+
changes = [Change(
|
|
765
|
+
kind = ChangeType.MODIFIED,
|
|
766
|
+
before = str(prev),
|
|
767
|
+
after = str(current_status) if current_status else "unreachable",
|
|
768
|
+
context = "http_status",
|
|
769
|
+
)],
|
|
770
|
+
compared_at = now,
|
|
771
|
+
)
|
|
772
|
+
try:
|
|
773
|
+
self._notifier.notify(fake_report, config.alert)
|
|
774
|
+
except Exception as exc: # noqa: BLE001
|
|
775
|
+
logger.warning("[%s] Status change webhook error: %s", config.label, exc)
|
|
776
|
+
|
|
661
777
|
def _cooldown_ok(self, config: WatchConfig) -> bool:
|
|
662
778
|
if config.cooldown <= 0:
|
|
663
779
|
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|