watchdiff-core 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,144 @@
1
+ """
2
+ Store - persists snapshots and diff reports to disk.
3
+
4
+ Format: one JSON file per watched URL, stored in a configurable directory.
5
+ Each file contains an ordered list of Snapshot dicts (newest last).
6
+
7
+ Why JSON and not SQLite?
8
+ → Zero external dependencies for storage.
9
+ → Easy to inspect, back up, or pipe to other tools.
10
+ → Fine for typical monitoring workloads (< thousands of entries).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from watchdiff.models import DiffReport, Snapshot
21
+
22
+
23
+ class Store:
24
+ """Filesystem-based snapshot store."""
25
+
26
+ def __init__(self, directory: str | Path = ".watchdiff") -> None:
27
+ self.directory = Path(directory)
28
+ self.directory.mkdir(parents=True, exist_ok=True)
29
+
30
+ # ------------------------------------------------------------------
31
+ # Snapshots
32
+ # ------------------------------------------------------------------
33
+
34
+ def save_snapshot(self, snapshot: Snapshot) -> None:
35
+ """Append a snapshot to the history for its URL + target combo."""
36
+ path = self._snapshot_path(snapshot.url, snapshot.target)
37
+ history = self._load_raw(path)
38
+ history.append(_snapshot_to_dict(snapshot))
39
+ self._save_raw(path, history)
40
+
41
+ def load_latest(self, url: str, target: str | None) -> Snapshot | None:
42
+ """Return the most recent snapshot, or None if no history exists."""
43
+ path = self._snapshot_path(url, target)
44
+ if not path.exists():
45
+ return None
46
+ history = self._load_raw(path)
47
+ if not history:
48
+ return None
49
+ return _dict_to_snapshot(history[-1])
50
+
51
+ def load_history(
52
+ self,
53
+ url: str,
54
+ target: str | None,
55
+ limit: int = 50,
56
+ ) -> list[Snapshot]:
57
+ """Return up to `limit` most recent snapshots (newest last)."""
58
+ path = self._snapshot_path(url, target)
59
+ if not path.exists():
60
+ return []
61
+ history = self._load_raw(path)
62
+ return [_dict_to_snapshot(d) for d in history[-limit:]]
63
+
64
+ def clear_history(self, url: str, target: str | None) -> None:
65
+ """Delete all stored snapshots for a URL + target combo."""
66
+ path = self._snapshot_path(url, target)
67
+ if path.exists():
68
+ path.unlink()
69
+
70
+ # ------------------------------------------------------------------
71
+ # Diff reports
72
+ # ------------------------------------------------------------------
73
+
74
+ def save_report(self, report: DiffReport) -> None:
75
+ """Persist a DiffReport for audit purposes."""
76
+ path = self._report_path(report.url, report.target)
77
+ reports = self._load_raw(path)
78
+ reports.append(report.as_dict())
79
+ self._save_raw(path, reports)
80
+
81
+ def load_reports(
82
+ self,
83
+ url: str,
84
+ target: str | None,
85
+ limit: int = 50,
86
+ ) -> list[dict]:
87
+ """Return raw report dicts (newest last)."""
88
+ path = self._report_path(url, target)
89
+ if not path.exists():
90
+ return []
91
+ return self._load_raw(path)[-limit:]
92
+
93
+ # ------------------------------------------------------------------
94
+ # Internal
95
+ # ------------------------------------------------------------------
96
+
97
+ def _key(self, url: str, target: str | None) -> str:
98
+ """Stable short key derived from URL + target."""
99
+ raw = f"{url}::{target or ''}"
100
+ return hashlib.md5(raw.encode()).hexdigest()[:12]
101
+
102
+ def _snapshot_path(self, url: str, target: str | None) -> Path:
103
+ return self.directory / f"snap_{self._key(url, target)}.json"
104
+
105
+ def _report_path(self, url: str, target: str | None) -> Path:
106
+ return self.directory / f"report_{self._key(url, target)}.json"
107
+
108
+ def _load_raw(self, path: Path) -> list:
109
+ if not path.exists():
110
+ return []
111
+ try:
112
+ return json.loads(path.read_text(encoding="utf-8"))
113
+ except (json.JSONDecodeError, OSError):
114
+ return []
115
+
116
+ def _save_raw(self, path: Path, data: list) -> None:
117
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Serialisation helpers
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _snapshot_to_dict(s: Snapshot) -> dict:
125
+ return {
126
+ "url": s.url,
127
+ "target": s.target,
128
+ "content": s.content,
129
+ "raw_html": s.raw_html,
130
+ "captured_at": s.captured_at.isoformat(),
131
+ "checksum": s.checksum,
132
+ }
133
+
134
+
135
+ def _dict_to_snapshot(d: dict) -> Snapshot:
136
+ snap = Snapshot(
137
+ url = d["url"],
138
+ target = d.get("target"),
139
+ content = d["content"],
140
+ raw_html = d.get("raw_html", ""),
141
+ )
142
+ snap.captured_at = datetime.fromisoformat(d["captured_at"])
143
+ snap.checksum = d["checksum"]
144
+ return snap
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.4
2
+ Name: watchdiff-core
3
+ Version: 0.1.2
4
+ Summary: Lightweight web change monitoring library - clean diffs, structured alerts, no AI required.
5
+ Author: WatchDiff Contributors
6
+ License: GPL-3.0-or-later
7
+ License-File: LICENSE
8
+ Keywords: automation,change-detection,diff,monitoring,scraping,web
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.14
17
+ Requires-Dist: beautifulsoup4>=4.14.3
18
+ Requires-Dist: httpx>=0.28.1
19
+ Requires-Dist: lxml>=6.1.0
20
+ Requires-Dist: rich>=15.0.0
21
+ Requires-Dist: typer>=0.25.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
24
+ Requires-Dist: pytest-httpx>=0.36.2; extra == 'dev'
25
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
26
+ Requires-Dist: ruff>=0.15.12; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # WatchDiff
30
+
31
+ **Lightweight web change monitoring - clean diffs, structured alerts, no AI required.**
32
+
33
+ WatchDiff watches web pages and tells you **exactly what changed**, in plain language.
34
+ No noisy HTML diffs. No external services. No AI black boxes.
35
+
36
+
37
+ ## Why WatchDiff?
38
+
39
+ Most change detection tools compare raw HTML - which means every minor script reload or ad rotation triggers a false positive. WatchDiff strips the noise first, then diffs only the content that matters.
40
+
41
+ - **Deterministic** - same input always produces the same output
42
+ - **Human-readable diffs** - "Price changed: $19 → $24", not a wall of HTML
43
+ - **Zero external services** - snapshots stored locally as JSON
44
+ - **Async-ready** - sync and async schedulers included
45
+ - **Configurable** - target any CSS selector, ignore patterns, set webhooks
46
+
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install watchdiff-core
52
+ ```
53
+
54
+ Or with [uv](https://github.com/astral-sh/uv):
55
+
56
+ ```bash
57
+ uv add watchdiff-core
58
+ ```
59
+
60
+
61
+ ## Quick start
62
+
63
+ ### Python API
64
+
65
+ ```python
66
+ from watchdiff import WatchDiff
67
+
68
+ wd = WatchDiff()
69
+
70
+ wd.watch(
71
+ "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
72
+ target=".price_color",
73
+ interval=60,
74
+ label="Book price",
75
+ on_change=lambda r: print(r.summary()),
76
+ )
77
+
78
+ wd.start()
79
+ ```
80
+
81
+ ### CLI
82
+
83
+ ```bash
84
+ # One-shot check
85
+ watchdiff check https://example.com --target .price
86
+
87
+ # Continuous monitoring (Ctrl+C to stop)
88
+ watchdiff run https://example.com --target .price --interval 60
89
+
90
+ # Snapshot history
91
+ watchdiff history https://example.com
92
+
93
+ # Diff reports
94
+ watchdiff reports https://example.com
95
+
96
+ # Clear stored data
97
+ watchdiff clear https://example.com
98
+ ```
99
+
100
+
101
+ ## How it works
102
+
103
+ Every check runs through a fixed pipeline:
104
+
105
+ ```
106
+ Fetcher → Cleaner → Parser → DiffEngine → Store → Notifier
107
+ ```
108
+
109
+ 1. **Fetcher** - downloads the page via `httpx` (sync or async)
110
+ 2. **Cleaner** - strips scripts, styles, ads, and tracking noise
111
+ 3. **Parser** - extracts the target CSS selector (or full body)
112
+ 4. **DiffEngine** - compares content using Python's `difflib.SequenceMatcher`
113
+ 5. **Store** - persists snapshots and reports as local JSON files
114
+ 6. **Notifier** - fires callbacks and/or webhooks on detected changes
115
+
116
+
117
+ ## API reference
118
+
119
+ ### `WatchDiff`
120
+
121
+ ```python
122
+ wd = WatchDiff(storage_dir=".watchdiff") # default storage directory
123
+ ```
124
+
125
+ #### `.watch(url, *, ...)`
126
+
127
+ Register a URL to monitor. All keyword arguments are optional.
128
+
129
+ | Parameter | Type | Default | Description |
130
+ |---|---|---|---|
131
+ | `url` | `str` | - | URL to watch |
132
+ | `target` | `str \| None` | `None` | CSS selector (e.g. `.price`). `None` = full page |
133
+ | `interval` | `int` | `300` | Seconds between checks |
134
+ | `label` | `str \| None` | URL | Human-readable name shown in logs |
135
+ | `headers` | `dict` | `{}` | Extra HTTP headers |
136
+ | `timeout` | `int` | `15` | Request timeout in seconds |
137
+ | `ignore_selectors` | `list[str]` | `[]` | CSS selectors to strip before diffing |
138
+ | `ignore_patterns` | `list[str]` | `[]` | Regex patterns to strip from text |
139
+ | `on_change` | `Callable \| list` | `None` | Callback(s) fired on each change |
140
+ | `webhooks` | `list[str]` | `[]` | Webhook URLs to POST on change |
141
+ | `min_changes` | `int` | `1` | Minimum number of changes to trigger alert |
142
+
143
+ All methods are **chainable**:
144
+
145
+ ```python
146
+ wd.watch("https://site.com/product", target=".price", interval=300) \
147
+ .watch("https://site.com/stock", target=".availability") \
148
+ .on_change(lambda r: print(r.summary())) \
149
+ .start()
150
+ ```
151
+
152
+ #### `.on_change(callback)`
153
+
154
+ Register a global callback called whenever **any** watched URL changes.
155
+
156
+ ```python
157
+ def handle(report):
158
+ print(report.summary())
159
+ for change in report.changes:
160
+ print(change.human())
161
+
162
+ wd.on_change(handle)
163
+ ```
164
+
165
+ #### `.start(block=True)`
166
+
167
+ Start the synchronous scheduler. Blocks until `Ctrl+C` by default.
168
+ Pass `block=False` to run in the background (daemon threads).
169
+
170
+ #### `await .start_async()`
171
+
172
+ Async variant - use inside an existing event loop (FastAPI, aiohttp, etc.).
173
+
174
+ ```python
175
+ import asyncio
176
+ asyncio.run(wd.start_async())
177
+ ```
178
+
179
+ #### `.check_once(url)`
180
+
181
+ Run a single immediate check without starting the scheduler loop.
182
+
183
+ ```python
184
+ report = wd.check_once("https://example.com")
185
+ if report:
186
+ print(report.summary())
187
+ ```
188
+
189
+ ---
190
+
191
+ ### `DiffReport`
192
+
193
+ ```python
194
+ report.url # str
195
+ report.target # str | None
196
+ report.label # str
197
+ report.has_changes # bool
198
+ report.added # list[Change]
199
+ report.removed # list[Change]
200
+ report.modified # list[Change]
201
+ report.changes # list[Change] (all changes)
202
+ report.compared_at # datetime
203
+
204
+ report.summary() # "[Book price] 1 modified - 2024-01-15 10:30:00 UTC"
205
+ report.as_dict() # JSON-serialisable dict
206
+ ```
207
+
208
+ ### `Change`
209
+
210
+ ```python
211
+ change.kind # ChangeType.ADDED | REMOVED | MODIFIED | UNCHANGED
212
+ change.before # str | None - previous value
213
+ change.after # str | None - new value
214
+ change.context # str | None - surrounding text hint
215
+
216
+ change.human() # "[~] Changed: '$19.00' → '$24.00'"
217
+ ```
218
+
219
+ ## Webhooks
220
+
221
+ WatchDiff auto-detects the target service and adapts the payload format:
222
+
223
+ | Service | Detection | Payload |
224
+ |---|---|---|
225
+ | Discord | `discord.com` in URL | `{"content": "..."}` (2000-char limit) |
226
+ | Slack | `hooks.slack.com` in URL | `{"text": "..."}` |
227
+ | Custom | anything else | full `report.as_dict()` |
228
+
229
+ ```python
230
+ wd.watch(
231
+ "https://example.com",
232
+ webhooks=[
233
+ "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN",
234
+ "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
235
+ "https://your-api.com/watchdiff-hook",
236
+ ],
237
+ )
238
+ ```
239
+
240
+ ## Async usage
241
+
242
+ ```python
243
+ import asyncio
244
+ from watchdiff import WatchDiff
245
+
246
+ async def main():
247
+ wd = WatchDiff()
248
+ wd.watch("https://example.com", target="h1", interval=30)
249
+ wd.on_change(lambda r: print(r.summary()))
250
+ await wd.start_async()
251
+
252
+ asyncio.run(main())
253
+ ```
254
+
255
+ ## CLI reference
256
+
257
+ ```
258
+ Usage: watchdiff [COMMAND] [OPTIONS]
259
+
260
+ Commands:
261
+ run Start continuous monitoring
262
+ check Run a single check and print the result
263
+ history Show snapshot history for a URL
264
+ reports Show diff reports for a URL
265
+ clear Delete all stored data for a URL
266
+
267
+ Options (shared):
268
+ --target -t CSS selector to watch
269
+ --storage -s Storage directory (default: .watchdiff)
270
+ --interval -i Seconds between checks (run only)
271
+ --limit -n Number of entries to show (history/reports)
272
+ --verbose -v Enable debug logging
273
+ --json Output raw JSON (check only)
274
+ --yes -y Skip confirmation (clear only)
275
+ ```
276
+
277
+ ## Use cases
278
+
279
+ - **E-commerce** - track product prices and stock availability
280
+ - **News monitoring** - detect article updates or new publications
281
+ - **Documentation** - alert when API docs change
282
+ - **Public APIs** - watch JSON endpoints for schema or value changes
283
+ - **Compliance** - audit changes on public-facing pages over time
284
+
285
+
286
+ ## License
287
+
288
+ This project is licensed under the [GNU General Public License v3.0](LICENSE).
289
+
290
+ You are free to use, study, modify, and distribute this software under the terms of the GPL v3.
291
+ Any derivative work must also be distributed under the same license.
@@ -0,0 +1,24 @@
1
+ watchdiff/__init__.py,sha256=HlBhsOpCcnYJ-f9m0zLg9gAa4TRfCwlVpq2STxSmFU8,62
2
+ watchdiff/core.py,sha256=UMqorOkCIxr7Vwu-k1_1E9M5F3N0x5PpWmlYXOgagXU,7544
3
+ watchdiff/models.py,sha256=Wata-ijRhZprdqnivPtQCBjrRE565VHd7wg6MkHAlY4,5446
4
+ watchdiff/cleaner/__init__.py,sha256=00jw16wtAFiYqza2ntmP0exIbut2kG7EfZTR-omSaVM,69
5
+ watchdiff/cleaner/cleaner.py,sha256=FYwyWb3qRH6skhQG22yjE2hO85ZVEBVQYNZtkT-Kn4Y,4010
6
+ watchdiff/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ watchdiff/cli/main.py,sha256=DSlbeSUevjYhMWkHwpX9rjrTnIXj_tELDf2GWBFKzKo,6960
8
+ watchdiff/diff/__init__.py,sha256=zP2rL-zhlQy117isgd4JHkpKXGqBJl3DR3WAkaW_WvY,71
9
+ watchdiff/diff/engine.py,sha256=AIvqusPjwvSLIb74dZZKiF2D0QM7o2aZzLILYQK-Hng,4539
10
+ watchdiff/fetcher/__init__.py,sha256=q1i_WZbjfnhB5mbOF4DE6DojY-Tzv12gR1oBqHESb_s,125
11
+ watchdiff/fetcher/fetcher.py,sha256=z_B4gwxqfrorsVo6K9dd0HDkQE1IudVqVZEHmfq-RQc,2463
12
+ watchdiff/notifier/__init__.py,sha256=1Q4V121FfjLFdk9XFgfWZZ_jz-QNfYCV1_PomJmKIZ4,73
13
+ watchdiff/notifier/notifier.py,sha256=o7vAFAHEdArBg6BH2brMKtbyTSMjlYQb_jQdfbtU6tI,2580
14
+ watchdiff/parser/__init__.py,sha256=mK2TosyMo0nDfRP6UqUNls5QAelbwbVbMUHqsZQvylU,93
15
+ watchdiff/parser/parser.py,sha256=Ij3QyeiXxXQHwySjRaDu1P8li9xcV4jkzYaQo2hnp8A,2300
16
+ watchdiff/scheduler/__init__.py,sha256=hgbIPr9-gZa_VOdjeHe01Ji7kEBUlfR6PUdSd9Gctfk,119
17
+ watchdiff/scheduler/scheduler.py,sha256=8gQd0n72XH4QDCjP7o9y2sBARvU9zcLFfKdetHSvpMQ,8142
18
+ watchdiff/store/__init__.py,sha256=hr7rwyCqdAeACffzWKC3e8mM16QYZa4iWzRU5PdreJs,61
19
+ watchdiff/store/store.py,sha256=JTt6uQPhJ1rgz6_zmZ-Xyvrit4VSRjMi60rMgIqAOwA,4944
20
+ watchdiff_core-0.1.2.dist-info/METADATA,sha256=eLOgkzboib63dnJkDv1XhetYDpJ7FzU22aFG52cXjdY,8197
21
+ watchdiff_core-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
22
+ watchdiff_core-0.1.2.dist-info/entry_points.txt,sha256=qlFOiDMDG_QyzMD_-uu1YfpGKA_xHm23pIqGpiYZxWk,53
23
+ watchdiff_core-0.1.2.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
24
+ watchdiff_core-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ watchdiff = watchdiff.cli.main:app