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.
Files changed (38) hide show
  1. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/PKG-INFO +78 -4
  2. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/README.md +77 -3
  3. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/pyproject.toml +1 -1
  4. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/__init__.py +2 -1
  5. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cli/main.py +97 -12
  6. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/core.py +5 -0
  7. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/fetcher.py +18 -12
  8. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/models.py +13 -0
  9. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/scheduler/scheduler.py +120 -4
  10. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/ci.yml +0 -0
  11. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/release-testpypi.yml +0 -0
  12. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.github/workflows/release.yml +0 -0
  13. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.gitignore +0 -0
  14. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/.python-version +0 -0
  15. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/LICENSE +0 -0
  16. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/main.py +0 -0
  17. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/tests/__init__.py +0 -0
  18. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/tests/test_watchdiff.py +0 -0
  19. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/uv.lock +0 -0
  20. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cleaner/__init__.py +0 -0
  21. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cleaner/cleaner.py +0 -0
  22. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/cli/__init__.py +0 -0
  23. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/diff/__init__.py +0 -0
  24. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/diff/engine.py +0 -0
  25. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/exporter/__init__.py +0 -0
  26. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/exporter/exporter.py +0 -0
  27. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/__init__.py +0 -0
  28. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/fetcher/browser.py +0 -0
  29. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/notifier/__init__.py +0 -0
  30. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/notifier/notifier.py +0 -0
  31. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/parser/__init__.py +0 -0
  32. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/parser/parser.py +0 -0
  33. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/scheduler/__init__.py +0 -0
  34. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/status_server/__init__.py +0 -0
  35. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/status_server/server.py +0 -0
  36. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/store/__init__.py +0 -0
  37. {watchdiff_core-0.1.5 → watchdiff_core-0.1.6}/watchdiff/store/sqlite_store.py +0 -0
  38. {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.5
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
- watchdiff_last_check_timestamp{url="...",label="..."} 1720000000.0
589
- watchdiff_last_change_timestamp{url="...",label="..."} 1719999000.0
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
- watchdiff_last_check_timestamp{url="...",label="..."} 1720000000.0
550
- watchdiff_last_change_timestamp{url="...",label="..."} 1719999000.0
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.5"
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 = spike_window or None,
303
- change_spike_threshold = spike_threshold or None,
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 = f"{cooldown}s" if cooldown > 0 else "off"
308
- dry_run_label = "[yellow]dry-run[/]" if dry_run else "off"
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 = w["url"]
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
- label_w,
523
- url_w,
524
- target_w or "full page",
525
- latest.captured_at.strftime("%Y-%m-%d %H:%M:%S") if latest else "-",
526
- str(len(snaps)),
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
- last_exc = FetchError(f"HTTP {exc.response.status_code} for {config.url}")
107
- if exc.response.status_code not in _RETRYABLE_STATUS or attempt >= config.retries:
108
- raise FetchError(f"HTTP {exc.response.status_code} for {config.url}") from exc
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
- last_exc = FetchError(f"HTTP {exc.response.status_code} for {config.url}")
154
- if exc.response.status_code not in _RETRYABLE_STATUS or attempt >= config.retries:
155
- raise FetchError(f"HTTP {exc.response.status_code} for {config.url}") from exc
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 = self._checks_count.get(key, 0),
443
- changes_count = self._changes_count.get(key, 0),
444
- errors_count = self._errors_count.get(key, 0),
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