watchdiff-core 0.1.2__tar.gz → 0.1.3__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.3/PKG-INFO +601 -0
- watchdiff_core-0.1.3/README.md +562 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/main.py +6 -7
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/pyproject.toml +21 -14
- watchdiff_core-0.1.3/uv.lock +898 -0
- watchdiff_core-0.1.3/watchdiff/__init__.py +14 -0
- watchdiff_core-0.1.3/watchdiff/cli/main.py +362 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/core.py +146 -31
- watchdiff_core-0.1.3/watchdiff/diff/engine.py +149 -0
- watchdiff_core-0.1.3/watchdiff/exporter/__init__.py +3 -0
- watchdiff_core-0.1.3/watchdiff/exporter/exporter.py +222 -0
- watchdiff_core-0.1.3/watchdiff/fetcher/__init__.py +11 -0
- watchdiff_core-0.1.3/watchdiff/fetcher/browser.py +104 -0
- watchdiff_core-0.1.3/watchdiff/fetcher/fetcher.py +122 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/models.py +55 -35
- watchdiff_core-0.1.3/watchdiff/parser/parser.py +126 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/scheduler/scheduler.py +94 -50
- watchdiff_core-0.1.3/watchdiff/store/__init__.py +4 -0
- watchdiff_core-0.1.3/watchdiff/store/sqlite_store.py +173 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/store/store.py +20 -17
- watchdiff_core-0.1.2/PKG-INFO +0 -291
- watchdiff_core-0.1.2/README.md +0 -263
- watchdiff_core-0.1.2/uv.lock +0 -369
- watchdiff_core-0.1.2/watchdiff/__init__.py +0 -3
- watchdiff_core-0.1.2/watchdiff/cli/main.py +0 -217
- watchdiff_core-0.1.2/watchdiff/diff/engine.py +0 -131
- watchdiff_core-0.1.2/watchdiff/fetcher/__init__.py +0 -3
- watchdiff_core-0.1.2/watchdiff/fetcher/fetcher.py +0 -84
- watchdiff_core-0.1.2/watchdiff/parser/parser.py +0 -75
- watchdiff_core-0.1.2/watchdiff/store/__init__.py +0 -3
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/.github/workflows/ci.yml +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/.github/workflows/release-testpypi.yml +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/.github/workflows/release.yml +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/.gitignore +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/.python-version +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/LICENSE +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/tests/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/tests/test_watchdiff.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/cleaner/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/cleaner/cleaner.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/cli/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/diff/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/notifier/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/notifier/notifier.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/parser/__init__.py +0 -0
- {watchdiff_core-0.1.2 → watchdiff_core-0.1.3}/watchdiff/scheduler/__init__.py +0 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchdiff-core
|
|
3
|
+
Version: 0.1.3
|
|
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.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Requires-Dist: lxml>=4.9.0
|
|
24
|
+
Requires-Dist: rich>=13.0.0
|
|
25
|
+
Requires-Dist: typer>=0.9.0
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: openpyxl>=3.1.0; extra == 'all'
|
|
28
|
+
Requires-Dist: playwright>=1.40.0; extra == 'all'
|
|
29
|
+
Provides-Extra: browser
|
|
30
|
+
Requires-Dist: playwright>=1.40.0; extra == 'browser'
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-httpx>=0.22.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
36
|
+
Provides-Extra: xlsx
|
|
37
|
+
Requires-Dist: openpyxl>=3.1.0; extra == 'xlsx'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# WatchDiff
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/watchdiff-core/)
|
|
43
|
+
[](https://pypi.org/project/watchdiff-core/)
|
|
44
|
+
[](https://github.com/r-seize/watchdiff-py/actions/workflows/ci.yml)
|
|
45
|
+
[](https://www.gnu.org/licenses/gpl-3.0)
|
|
46
|
+
|
|
47
|
+
**Lightweight web change monitoring - clean diffs, structured alerts, no AI required.**
|
|
48
|
+
|
|
49
|
+
WatchDiff watches web pages and tells you **exactly what changed**, in plain language.
|
|
50
|
+
No noisy HTML diffs. No external services. No AI black boxes.
|
|
51
|
+
|
|
52
|
+
## At a glance
|
|
53
|
+
|
|
54
|
+
| What you want | How |
|
|
55
|
+
|---|---|
|
|
56
|
+
| Monitor a URL for changes | `.watch(url, target=".price", interval=300)` + `.start()` |
|
|
57
|
+
| Target a specific element | `target=".price"` (CSS) or `target="//span[@class='p']"` (XPath) |
|
|
58
|
+
| Get notified on change | `on_change=lambda r: print(r.summary())` or `webhooks=["https://discord.com/..."]` |
|
|
59
|
+
| Render JS-heavy pages | `browser=True` (requires `pip install "watchdiff-core[browser]"`) |
|
|
60
|
+
| Avoid notification spam | `cooldown=3600` (min seconds between alerts per URL) |
|
|
61
|
+
| Rotate proxies / UAs | `proxies=[...]`, `user_agents=[...]` |
|
|
62
|
+
| Diff at paragraph level | `diff_mode="semantic"` |
|
|
63
|
+
| Persist to SQLite | `WatchDiff(store=SqliteStore(".watchdiff.db"))` |
|
|
64
|
+
| Export history | `.export_reports_csv(url)` / `.export_reports_xlsx(url)` |
|
|
65
|
+
| CLI one-liner | `watchdiff run https://example.com --target .price --interval 60` |
|
|
66
|
+
| Multi-URL config file | `watchdiff init` then edit `watchdiff.config.json` |
|
|
67
|
+
|
|
68
|
+
### Quick navigation
|
|
69
|
+
|
|
70
|
+
- [Install](#install)
|
|
71
|
+
- [Quick start](#quick-start)
|
|
72
|
+
- [Feature details](#features)
|
|
73
|
+
- [JS-heavy pages (Playwright)](#javascript-pages-with-playwright)
|
|
74
|
+
- [Proxy and User-Agent rotation](#proxy-rotation-and-user-agent-rotation)
|
|
75
|
+
- [Semantic diff mode](#semantic-diff-mode)
|
|
76
|
+
- [XPath selectors](#xpath-selectors)
|
|
77
|
+
- [SQLite storage backend](#sqlite-storage-backend)
|
|
78
|
+
- [CSV and XLSX export](#csv-and-xlsx-export)
|
|
79
|
+
- [Alert cooldown](#cooldown-anti-spam)
|
|
80
|
+
- [Config file](#config-file-workflow-watchdiff-init)
|
|
81
|
+
- [API reference](#api-reference)
|
|
82
|
+
- [Webhooks](#webhooks)
|
|
83
|
+
- [CLI reference](#cli-reference)
|
|
84
|
+
- [Use cases](#use-cases)
|
|
85
|
+
|
|
86
|
+
## Why WatchDiff?
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
- **Deterministic** — same input always produces the same output
|
|
91
|
+
- **Human-readable diffs** — "Price changed: $19 → $24", not a wall of HTML
|
|
92
|
+
- **Zero external services** — snapshots stored locally (JSON or SQLite)
|
|
93
|
+
- **Async-ready** — sync and async schedulers included
|
|
94
|
+
- **Python 3.9+** — works on Debian Bullseye, Bookworm, and Trixie
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install watchdiff-core
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or with [uv](https://github.com/astral-sh/uv):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv add watchdiff-core
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Optional extras
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# JavaScript / SPA pages (Playwright headless browser)
|
|
113
|
+
pip install "watchdiff-core[browser]"
|
|
114
|
+
playwright install chromium
|
|
115
|
+
|
|
116
|
+
# XLSX export
|
|
117
|
+
pip install "watchdiff-core[xlsx]"
|
|
118
|
+
|
|
119
|
+
# Everything at once
|
|
120
|
+
pip install "watchdiff-core[all]"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Quick start
|
|
124
|
+
|
|
125
|
+
### Python API
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from watchdiff import WatchDiff
|
|
129
|
+
|
|
130
|
+
wd = WatchDiff()
|
|
131
|
+
|
|
132
|
+
wd.watch(
|
|
133
|
+
"https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
|
|
134
|
+
target=".price_color",
|
|
135
|
+
interval=60,
|
|
136
|
+
label="Book price",
|
|
137
|
+
on_change=lambda r: print(r.summary()),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
wd.start()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### CLI
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Generate a config file
|
|
147
|
+
watchdiff init
|
|
148
|
+
|
|
149
|
+
# Run from config file
|
|
150
|
+
watchdiff run --config watchdiff.config.json
|
|
151
|
+
|
|
152
|
+
# One-shot check
|
|
153
|
+
watchdiff check https://example.com --target .price
|
|
154
|
+
|
|
155
|
+
# Continuous monitoring (Ctrl+C to stop)
|
|
156
|
+
watchdiff run https://example.com --target .price --interval 60
|
|
157
|
+
|
|
158
|
+
# Snapshot history and reports
|
|
159
|
+
watchdiff history https://example.com
|
|
160
|
+
watchdiff reports https://example.com
|
|
161
|
+
|
|
162
|
+
# Clear stored data
|
|
163
|
+
watchdiff clear https://example.com
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
## Features
|
|
168
|
+
|
|
169
|
+
### JavaScript pages with Playwright
|
|
170
|
+
|
|
171
|
+
For pages that render content via JavaScript (SPAs, React, Vue, etc.), use the headless browser mode:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pip install "watchdiff-core[browser]"
|
|
175
|
+
playwright install chromium
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from watchdiff import WatchDiff
|
|
180
|
+
from watchdiff.models import BrowserOptions
|
|
181
|
+
|
|
182
|
+
wd = WatchDiff()
|
|
183
|
+
wd.watch(
|
|
184
|
+
"https://spa.example.com/pricing",
|
|
185
|
+
target=".price",
|
|
186
|
+
browser=True,
|
|
187
|
+
browser_options=BrowserOptions(
|
|
188
|
+
wait_for="networkidle", # wait until network is quiet
|
|
189
|
+
wait_for_selector=".price", # also wait for this element to appear
|
|
190
|
+
timeout=30000, # ms - max wait time
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
wd.start()
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`wait_for` accepts:
|
|
197
|
+
- `"load"` — default, waits for the `load` event
|
|
198
|
+
- `"domcontentloaded"` — faster, waits for DOM only
|
|
199
|
+
- `"networkidle"` — waits until no network requests for 500ms
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
### Proxy rotation and User-Agent rotation
|
|
203
|
+
|
|
204
|
+
Avoid blocks with automatic rotation on every request:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
wd.watch(
|
|
208
|
+
"https://example.com",
|
|
209
|
+
proxies=[
|
|
210
|
+
"http://proxy1.example.com:8080",
|
|
211
|
+
"http://proxy2.example.com:8080",
|
|
212
|
+
"socks5://proxy3.example.com:1080",
|
|
213
|
+
],
|
|
214
|
+
user_agents=[
|
|
215
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...",
|
|
216
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...",
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If `user_agents` is empty, WatchDiff rotates automatically among **4 built-in modern UA strings** (Chrome, Safari, Firefox, Chrome Linux). No configuration required.
|
|
222
|
+
|
|
223
|
+
Proxies also work in browser mode — Playwright passes the selected proxy to Chromium.
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
### Semantic diff mode
|
|
227
|
+
|
|
228
|
+
By default, WatchDiff diffs line by line. In **semantic mode**, it extracts meaningful HTML blocks — `<p>`, `<h1>`-`<h6>`, `<li>`, `<td>`, `<th>`, `<blockquote>` — and diffs those instead. This gives cleaner results on content-heavy pages where a single paragraph change doesn't shift dozens of lines.
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
wd.watch(
|
|
232
|
+
"https://blog.example.com/article",
|
|
233
|
+
diff_mode="semantic", # "line" (default) or "semantic"
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
If no semantic blocks are found, the engine falls back to line mode automatically.
|
|
238
|
+
|
|
239
|
+
In the CLI:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
watchdiff check https://blog.example.com/article --diff-mode semantic
|
|
243
|
+
watchdiff run https://blog.example.com/article --diff-mode semantic --interval 3600
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
### XPath selectors
|
|
248
|
+
|
|
249
|
+
`target` accepts both **CSS selectors** and **XPath expressions**. XPath is detected automatically by a leading `/` or `(`:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
# CSS selector (default)
|
|
253
|
+
wd.watch("https://example.com", target=".price")
|
|
254
|
+
wd.watch("https://example.com", target="#main > h1")
|
|
255
|
+
|
|
256
|
+
# XPath expressions
|
|
257
|
+
wd.watch("https://example.com", target="//div[@class='price']")
|
|
258
|
+
wd.watch("https://example.com", target="//table//tr[td[1]='Revenue']/td[2]")
|
|
259
|
+
wd.watch("https://example.com", target="(//h2)[1]") # first <h2> only
|
|
260
|
+
wd.watch("https://example.com", target="//p[contains(@class,'intro')]")
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
XPath is implemented via `lxml` (already a dependency — no extra install needed).
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
### SQLite storage backend
|
|
267
|
+
|
|
268
|
+
By default, WatchDiff stores data as JSON files. For larger datasets or concurrent access, switch to the built-in SQLite backend — no extra dependencies required:
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
from watchdiff import WatchDiff
|
|
272
|
+
from watchdiff.store import SqliteStore
|
|
273
|
+
|
|
274
|
+
wd = WatchDiff(store=SqliteStore(".watchdiff.db"))
|
|
275
|
+
wd.watch("https://example.com").start()
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`SqliteStore` is a **drop-in replacement** for the default `Store` — same interface, same behaviour. It runs in WAL mode for concurrent-read safety.
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
### CSV and XLSX export
|
|
282
|
+
|
|
283
|
+
Export your snapshot history and diff reports to CSV (no dependencies) or XLSX (requires `openpyxl`):
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from watchdiff import WatchDiff
|
|
287
|
+
|
|
288
|
+
wd = WatchDiff()
|
|
289
|
+
wd.watch("https://example.com", target=".price")
|
|
290
|
+
|
|
291
|
+
# CSV - always available, returns the CSV string
|
|
292
|
+
csv_text = wd.export_reports_csv("https://example.com", dest="reports.csv")
|
|
293
|
+
csv_text = wd.export_snapshots_csv("https://example.com", dest="snapshots.csv")
|
|
294
|
+
|
|
295
|
+
# XLSX - requires: pip install "watchdiff-core[xlsx]"
|
|
296
|
+
path = wd.export_reports_xlsx("https://example.com", dest="reports.xlsx")
|
|
297
|
+
path = wd.export_snapshots_xlsx("https://example.com", dest="snapshots.xlsx")
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
All export methods accept:
|
|
301
|
+
- `url` — the watched URL
|
|
302
|
+
- `target` — CSS/XPath filter (optional, `None` = full page)
|
|
303
|
+
- `limit` — max rows to include (default 500)
|
|
304
|
+
- `dest` — file path to write (optional for CSV, required for XLSX)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
### Cooldown anti-spam
|
|
308
|
+
|
|
309
|
+
Use `cooldown` to set a minimum delay in seconds between two alerts for the same URL. Useful when a page changes frequently but you don't want to be notified on every single check.
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
wd.watch(
|
|
313
|
+
"https://news.example.com/live",
|
|
314
|
+
target=".headline",
|
|
315
|
+
interval=30, # check every 30 seconds
|
|
316
|
+
cooldown=600, # but alert at most every 10 minutes
|
|
317
|
+
on_change=lambda r: print(r.summary()),
|
|
318
|
+
)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Important: **changes are still detected and stored** during the cooldown period. Only the alerts (callbacks, webhooks) are suppressed. The full history remains available via `.history()` and `.reports()`.
|
|
322
|
+
|
|
323
|
+
`cooldown=0` (default) disables the feature — every change triggers an alert immediately.
|
|
324
|
+
|
|
325
|
+
In the CLI:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
watchdiff run https://news.example.com --interval 30 --cooldown 600
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
In `watchdiff.config.json`:
|
|
332
|
+
|
|
333
|
+
```json
|
|
334
|
+
{
|
|
335
|
+
"url": "https://news.example.com/live",
|
|
336
|
+
"interval": 30,
|
|
337
|
+
"cooldown": 600
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
### Config file workflow (`watchdiff init`)
|
|
343
|
+
|
|
344
|
+
Generate a ready-to-edit config file, then run all your watchers in one command:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
watchdiff init
|
|
348
|
+
# Created watchdiff.config.json
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Edit `watchdiff.config.json`:
|
|
352
|
+
|
|
353
|
+
```json
|
|
354
|
+
{
|
|
355
|
+
"storage": ".watchdiff",
|
|
356
|
+
"watchers": [
|
|
357
|
+
{
|
|
358
|
+
"url": "https://store.example.com/product/42",
|
|
359
|
+
"target": ".price",
|
|
360
|
+
"interval": 300,
|
|
361
|
+
"label": "Product 42 price",
|
|
362
|
+
"diff_mode": "line",
|
|
363
|
+
"browser": false,
|
|
364
|
+
"cooldown": 0,
|
|
365
|
+
"webhooks": ["https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"],
|
|
366
|
+
"proxies": [],
|
|
367
|
+
"user_agents": [],
|
|
368
|
+
"ignore_selectors": [".cookie-banner", "#ad-container"],
|
|
369
|
+
"ignore_patterns": ["\\d+ views"],
|
|
370
|
+
"timeout": 15,
|
|
371
|
+
"headers": {}
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"url": "https://blog.example.com/changelog",
|
|
375
|
+
"target": "//article//p",
|
|
376
|
+
"interval": 3600,
|
|
377
|
+
"label": "Changelog",
|
|
378
|
+
"diff_mode": "semantic",
|
|
379
|
+
"browser": false,
|
|
380
|
+
"webhooks": []
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Run:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
# Explicit path
|
|
390
|
+
watchdiff run --config watchdiff.config.json
|
|
391
|
+
|
|
392
|
+
# Auto-discovery: if watchdiff.config.json exists in CWD, this also works
|
|
393
|
+
watchdiff run
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
## API reference
|
|
398
|
+
|
|
399
|
+
### `WatchDiff`
|
|
400
|
+
|
|
401
|
+
```python
|
|
402
|
+
from watchdiff import WatchDiff
|
|
403
|
+
from watchdiff.store import SqliteStore
|
|
404
|
+
|
|
405
|
+
wd = WatchDiff() # JSON store in .watchdiff/
|
|
406
|
+
wd = WatchDiff(storage_dir="/data/watchdiff") # custom JSON store path
|
|
407
|
+
wd = WatchDiff(store=SqliteStore("db.sqlite")) # SQLite store
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
#### `.watch(url, *, ...)`
|
|
411
|
+
|
|
412
|
+
Register a URL to monitor. All keyword arguments are optional. Returns `self` (chainable).
|
|
413
|
+
|
|
414
|
+
| Parameter | Type | Default | Description |
|
|
415
|
+
|---|---|---|---|
|
|
416
|
+
| `url` | `str` | - | URL to watch |
|
|
417
|
+
| `target` | `str \| None` | `None` | CSS selector or XPath. `None` = full page |
|
|
418
|
+
| `interval` | `int` | `300` | Seconds between checks |
|
|
419
|
+
| `label` | `str \| None` | URL | Human-readable name shown in logs |
|
|
420
|
+
| `headers` | `dict` | `{}` | Extra HTTP headers |
|
|
421
|
+
| `timeout` | `int` | `15` | Request timeout in seconds |
|
|
422
|
+
| `ignore_selectors` | `list[str]` | `[]` | CSS selectors to strip before diffing |
|
|
423
|
+
| `ignore_patterns` | `list[str]` | `[]` | Regex patterns to strip from text |
|
|
424
|
+
| `on_change` | `Callable \| list` | `None` | Callback(s) fired on each change |
|
|
425
|
+
| `webhooks` | `list[str]` | `[]` | Webhook URLs to POST on change |
|
|
426
|
+
| `min_changes` | `int` | `1` | Minimum number of changes to trigger alert |
|
|
427
|
+
| `diff_mode` | `str` | `"line"` | `"line"` or `"semantic"` |
|
|
428
|
+
| `browser` | `bool` | `False` | Use Playwright headless browser |
|
|
429
|
+
| `browser_options` | `BrowserOptions \| None` | `None` | Fine-tune Playwright behaviour |
|
|
430
|
+
| `proxies` | `list[str]` | `[]` | Proxy URLs - one picked randomly per request |
|
|
431
|
+
| `user_agents` | `list[str]` | `[]` | UA strings - rotated per request (built-ins used if empty) |
|
|
432
|
+
| `cooldown` | `int` | `0` | Min seconds between two alerts for this URL (0 = disabled) |
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
# Chainable
|
|
436
|
+
wd.watch("https://site.com/product", target=".price", interval=300) \
|
|
437
|
+
.watch("https://site.com/stock", target=".availability") \
|
|
438
|
+
.on_change(lambda r: print(r.summary())) \
|
|
439
|
+
.start()
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
#### `.on_change(callback)`
|
|
443
|
+
|
|
444
|
+
Register a global callback called whenever **any** watched URL changes.
|
|
445
|
+
|
|
446
|
+
```python
|
|
447
|
+
def handle(report):
|
|
448
|
+
print(report.summary())
|
|
449
|
+
for change in report.changes:
|
|
450
|
+
print(change.human())
|
|
451
|
+
|
|
452
|
+
wd.on_change(handle)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
#### `.start(block=True)`
|
|
456
|
+
|
|
457
|
+
Start the synchronous scheduler. Blocks until `Ctrl+C` by default.
|
|
458
|
+
Pass `block=False` to run in the background (daemon threads).
|
|
459
|
+
|
|
460
|
+
#### `await .start_async()`
|
|
461
|
+
|
|
462
|
+
Async variant — use inside an existing event loop (FastAPI, aiohttp, etc.):
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
import asyncio
|
|
466
|
+
from watchdiff import WatchDiff
|
|
467
|
+
|
|
468
|
+
async def main():
|
|
469
|
+
wd = WatchDiff()
|
|
470
|
+
wd.watch("https://example.com", target="h1", interval=30)
|
|
471
|
+
wd.on_change(lambda r: print(r.summary()))
|
|
472
|
+
await wd.start_async()
|
|
473
|
+
|
|
474
|
+
asyncio.run(main())
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
#### `.check_once(url)`
|
|
478
|
+
|
|
479
|
+
Run a single immediate check without starting the scheduler loop:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
report = wd.check_once("https://example.com")
|
|
483
|
+
if report:
|
|
484
|
+
print(report.summary())
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
#### `.history(url, limit=20)` / `.reports(url, limit=20)` / `.clear(url)`
|
|
488
|
+
|
|
489
|
+
Access stored data programmatically:
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
snaps = wd.history("https://example.com", limit=10)
|
|
493
|
+
reports = wd.reports("https://example.com", limit=10)
|
|
494
|
+
wd.clear("https://example.com")
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### `DiffReport`
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
report.url # str
|
|
501
|
+
report.target # str | None
|
|
502
|
+
report.label # str
|
|
503
|
+
report.has_changes # bool
|
|
504
|
+
report.added # list[Change]
|
|
505
|
+
report.removed # list[Change]
|
|
506
|
+
report.modified # list[Change]
|
|
507
|
+
report.changes # list[Change] - all changes
|
|
508
|
+
report.compared_at # datetime
|
|
509
|
+
|
|
510
|
+
report.summary() # "[Book price] 1 modified - 2024-01-15 10:30:00 UTC"
|
|
511
|
+
report.as_dict() # JSON-serialisable dict
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### `Change`
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
change.kind # ChangeType.ADDED | REMOVED | MODIFIED | UNCHANGED
|
|
518
|
+
change.before # str | None - previous value
|
|
519
|
+
change.after # str | None - new value
|
|
520
|
+
change.context # str | None - surrounding text hint
|
|
521
|
+
|
|
522
|
+
change.human() # "[~] Changed: '$19.00' - '$24.00'"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Webhooks
|
|
526
|
+
|
|
527
|
+
WatchDiff auto-detects the target service and adapts the payload:
|
|
528
|
+
|
|
529
|
+
| Service | Detection | Payload |
|
|
530
|
+
|---|---|---|
|
|
531
|
+
| Discord | `discord.com` in URL | `{"content": "..."}` (2000-char limit) |
|
|
532
|
+
| Slack | `hooks.slack.com` in URL | `{"text": "..."}` |
|
|
533
|
+
| Custom | anything else | full `report.as_dict()` |
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
wd.watch(
|
|
537
|
+
"https://example.com",
|
|
538
|
+
webhooks=[
|
|
539
|
+
"https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN",
|
|
540
|
+
"https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
|
|
541
|
+
"https://your-api.com/watchdiff-hook",
|
|
542
|
+
],
|
|
543
|
+
)
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## CLI reference
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
Usage: watchdiff [COMMAND] [OPTIONS]
|
|
550
|
+
|
|
551
|
+
Commands:
|
|
552
|
+
init Generate a watchdiff.config.json template
|
|
553
|
+
run Start continuous monitoring (URL or config file)
|
|
554
|
+
check Run a single check and print the result
|
|
555
|
+
history Show snapshot history for a URL
|
|
556
|
+
reports Show diff reports for a URL
|
|
557
|
+
clear Delete all stored data for a URL
|
|
558
|
+
|
|
559
|
+
Options for run / check:
|
|
560
|
+
--target -t CSS selector or XPath to watch
|
|
561
|
+
--storage -s Storage directory (default: .watchdiff)
|
|
562
|
+
--interval -i Seconds between checks (run only)
|
|
563
|
+
--config -c Path to a watchdiff.config.json file
|
|
564
|
+
--diff-mode Diff strategy: line (default) | semantic
|
|
565
|
+
--browser Use headless browser (requires playwright)
|
|
566
|
+
--cooldown Min seconds between alerts (0 = disabled)
|
|
567
|
+
--verbose -v Enable debug logging
|
|
568
|
+
|
|
569
|
+
Options for history / reports:
|
|
570
|
+
--limit -n Number of entries to show (default 20)
|
|
571
|
+
|
|
572
|
+
Options for clear:
|
|
573
|
+
--yes -y Skip confirmation prompt
|
|
574
|
+
|
|
575
|
+
Options for check:
|
|
576
|
+
--json Output raw JSON instead of formatted output
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Use cases
|
|
580
|
+
|
|
581
|
+
- **E-commerce** — track product prices and stock availability
|
|
582
|
+
- **News monitoring** — detect article updates or new publications
|
|
583
|
+
- **Documentation** — alert when API docs or changelogs change
|
|
584
|
+
- **Public APIs** — watch JSON endpoints for schema or value changes
|
|
585
|
+
- **SPA / React apps** — monitor JS-rendered content with `browser=True`
|
|
586
|
+
- **Compliance** — audit changes on public-facing pages over time
|
|
587
|
+
- **Research** — collect snapshots for longitudinal content analysis
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
## Contributing
|
|
591
|
+
|
|
592
|
+
Missing a feature? Found a bug? Pull requests are welcome on [GitHub](https://github.com/r-seize/watchdiff-py).
|
|
593
|
+
|
|
594
|
+
If you want a feature that is not yet in the project, open an issue or submit a PR directly - contributions of any size are appreciated.
|
|
595
|
+
|
|
596
|
+
## License
|
|
597
|
+
|
|
598
|
+
This project is licensed under the [GNU General Public License v3.0](LICENSE).
|
|
599
|
+
|
|
600
|
+
You are free to use, study, modify, and distribute this software under the terms of the GPL v3.
|
|
601
|
+
Any derivative work must also be distributed under the same license.
|