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.
- watchdiff/__init__.py +3 -0
- watchdiff/cleaner/__init__.py +3 -0
- watchdiff/cleaner/cleaner.py +131 -0
- watchdiff/cli/__init__.py +0 -0
- watchdiff/cli/main.py +217 -0
- watchdiff/core.py +202 -0
- watchdiff/diff/__init__.py +3 -0
- watchdiff/diff/engine.py +131 -0
- watchdiff/fetcher/__init__.py +3 -0
- watchdiff/fetcher/fetcher.py +84 -0
- watchdiff/models.py +163 -0
- watchdiff/notifier/__init__.py +3 -0
- watchdiff/notifier/notifier.py +81 -0
- watchdiff/parser/__init__.py +3 -0
- watchdiff/parser/parser.py +75 -0
- watchdiff/scheduler/__init__.py +3 -0
- watchdiff/scheduler/scheduler.py +223 -0
- watchdiff/store/__init__.py +3 -0
- watchdiff/store/store.py +144 -0
- watchdiff_core-0.1.2.dist-info/METADATA +291 -0
- watchdiff_core-0.1.2.dist-info/RECORD +24 -0
- watchdiff_core-0.1.2.dist-info/WHEEL +4 -0
- watchdiff_core-0.1.2.dist-info/entry_points.txt +2 -0
- watchdiff_core-0.1.2.dist-info/licenses/LICENSE +674 -0
watchdiff/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cleaner - strips scripts, styles, ads, and other noise from raw HTML.
|
|
3
|
+
|
|
4
|
+
The goal: keep only the human-readable content that a user actually sees.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
from bs4 import BeautifulSoup, Tag
|
|
13
|
+
|
|
14
|
+
# Tags that are pure noise and should always be stripped
|
|
15
|
+
_NOISE_TAGS: tuple[str, ...] = (
|
|
16
|
+
"script",
|
|
17
|
+
"style",
|
|
18
|
+
"noscript",
|
|
19
|
+
"iframe",
|
|
20
|
+
"svg",
|
|
21
|
+
"canvas",
|
|
22
|
+
"video",
|
|
23
|
+
"audio",
|
|
24
|
+
"link", # <link rel="stylesheet"> etc.
|
|
25
|
+
"meta",
|
|
26
|
+
"head",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Common ad / tracking class/id fragments
|
|
30
|
+
_AD_PATTERNS: tuple[str, ...] = (
|
|
31
|
+
"ad",
|
|
32
|
+
"ads",
|
|
33
|
+
"advertisement",
|
|
34
|
+
"banner",
|
|
35
|
+
"cookie",
|
|
36
|
+
"gdpr",
|
|
37
|
+
"popup",
|
|
38
|
+
"overlay",
|
|
39
|
+
"modal",
|
|
40
|
+
"newsletter",
|
|
41
|
+
"promo",
|
|
42
|
+
"tracking",
|
|
43
|
+
"analytics",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Cleaner:
|
|
48
|
+
"""Strips noise from raw HTML and returns clean text or simplified HTML."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
extra_selectors: Sequence[str] | None = None,
|
|
53
|
+
extra_patterns: Sequence[str] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Args:
|
|
57
|
+
extra_selectors: Additional CSS selectors to remove (e.g. ".cookie-banner").
|
|
58
|
+
extra_patterns: Additional regex patterns applied to the final text.
|
|
59
|
+
"""
|
|
60
|
+
self.extra_selectors: list[str] = list(extra_selectors or [])
|
|
61
|
+
self.extra_patterns: list[re.Pattern] = [
|
|
62
|
+
re.compile(p) for p in (extra_patterns or [])
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def clean(self, html: str) -> BeautifulSoup:
|
|
66
|
+
"""
|
|
67
|
+
Parse the HTML and remove all noisy elements.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A BeautifulSoup tree of the cleaned document.
|
|
71
|
+
"""
|
|
72
|
+
soup = BeautifulSoup(html, "lxml")
|
|
73
|
+
|
|
74
|
+
# 1. Strip known noise tags
|
|
75
|
+
for tag in soup.find_all(_NOISE_TAGS):
|
|
76
|
+
tag.decompose()
|
|
77
|
+
|
|
78
|
+
# 2. Strip common ad / tracking containers by class / id heuristic
|
|
79
|
+
self._strip_ad_containers(soup)
|
|
80
|
+
|
|
81
|
+
# 3. Strip user-specified CSS selectors
|
|
82
|
+
for selector in self.extra_selectors:
|
|
83
|
+
for el in soup.select(selector):
|
|
84
|
+
el.decompose()
|
|
85
|
+
|
|
86
|
+
return soup
|
|
87
|
+
|
|
88
|
+
def clean_to_text(self, html: str) -> str:
|
|
89
|
+
"""Clean the HTML and return normalised plain text."""
|
|
90
|
+
soup = self.clean(html)
|
|
91
|
+
raw_text = soup.get_text(separator="\n")
|
|
92
|
+
return self._normalise_text(raw_text)
|
|
93
|
+
|
|
94
|
+
def clean_to_html(self, html: str) -> str:
|
|
95
|
+
"""Clean the HTML and return the simplified HTML string."""
|
|
96
|
+
soup = self.clean(html)
|
|
97
|
+
return str(soup)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Internal helpers
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _strip_ad_containers(self, soup: BeautifulSoup) -> None:
|
|
104
|
+
"""Remove elements whose id/class contains ad-related keywords."""
|
|
105
|
+
for tag in soup.find_all(True): # all tags
|
|
106
|
+
if not isinstance(tag, Tag) or tag.parent is None:
|
|
107
|
+
continue
|
|
108
|
+
raw_classes = tag.get("class") or []
|
|
109
|
+
classes = " ".join(
|
|
110
|
+
str(c) for c in (raw_classes if isinstance(raw_classes, list) else [raw_classes])
|
|
111
|
+
).lower()
|
|
112
|
+
tag_id = str(tag.get("id") or "").lower()
|
|
113
|
+
combined = f"{classes} {tag_id}"
|
|
114
|
+
if any(pat in combined for pat in _AD_PATTERNS):
|
|
115
|
+
tag.decompose()
|
|
116
|
+
|
|
117
|
+
def _normalise_text(self, text: str) -> str:
|
|
118
|
+
"""Collapse whitespace and apply extra regex strips."""
|
|
119
|
+
# Collapse blank lines
|
|
120
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
121
|
+
# Collapse horizontal spaces
|
|
122
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
123
|
+
# Strip leading/trailing space per line
|
|
124
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
125
|
+
text = "\n".join(line for line in lines if line)
|
|
126
|
+
|
|
127
|
+
# Apply user-defined patterns
|
|
128
|
+
for pattern in self.extra_patterns:
|
|
129
|
+
text = pattern.sub("", text)
|
|
130
|
+
|
|
131
|
+
return text.strip()
|
|
File without changes
|
watchdiff/cli/main.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WatchDiff CLI - monitor URLs from the terminal.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
watchdiff run https://example.com --target .price --interval 60
|
|
6
|
+
watchdiff check https://example.com --target .price
|
|
7
|
+
watchdiff history https://example.com --target .price
|
|
8
|
+
watchdiff reports https://example.com
|
|
9
|
+
watchdiff clear https://example.com
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from watchdiff.core import WatchDiff
|
|
23
|
+
from watchdiff.models import DiffReport
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="watchdiff",
|
|
27
|
+
help="WatchDiff - lightweight web change monitoring.",
|
|
28
|
+
add_completion=False,
|
|
29
|
+
)
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Shared options
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_URL_ARG = typer.Argument(..., help="URL to monitor.")
|
|
37
|
+
_TARGET_OPT = typer.Option(None, "--target", "-t", help="CSS selector to watch (e.g. .price).")
|
|
38
|
+
_INTERVAL_OPT = typer.Option(300, "--interval", "-i", help="Seconds between checks.")
|
|
39
|
+
_STORAGE_OPT = typer.Option(".watchdiff", "--storage", "-s", help="Storage directory.")
|
|
40
|
+
_LIMIT_OPT = typer.Option(20, "--limit", "-n", help="Number of entries to show.")
|
|
41
|
+
_VERBOSE_OPT = typer.Option(False, "--verbose", "-v", help="Enable debug logging.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Commands
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@app.command("run")
|
|
49
|
+
def cmd_run(
|
|
50
|
+
url: str = _URL_ARG,
|
|
51
|
+
target: str | None = _TARGET_OPT,
|
|
52
|
+
interval: int = _INTERVAL_OPT,
|
|
53
|
+
storage: str = _STORAGE_OPT,
|
|
54
|
+
webhook: list[str] = typer.Option([], "--webhook", "-w", help="Webhook URL (repeatable)."),
|
|
55
|
+
verbose: bool = _VERBOSE_OPT,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Start continuous monitoring of a URL."""
|
|
58
|
+
_setup_logging(verbose)
|
|
59
|
+
|
|
60
|
+
def _print_report(report: DiffReport) -> None:
|
|
61
|
+
_render_report(report)
|
|
62
|
+
|
|
63
|
+
wd = WatchDiff(storage_dir=storage)
|
|
64
|
+
wd.watch(url, target=target, interval=interval, webhooks=webhook or [])
|
|
65
|
+
wd.on_change(_print_report)
|
|
66
|
+
|
|
67
|
+
console.print(
|
|
68
|
+
Panel(
|
|
69
|
+
f"[bold cyan]WatchDiff[/] monitoring [green]{url}[/]\n"
|
|
70
|
+
f"Target: [yellow]{target or 'full page'}[/] "
|
|
71
|
+
f"Interval: [yellow]{interval}s[/]\n"
|
|
72
|
+
f"Press [bold]Ctrl+C[/] to stop.",
|
|
73
|
+
title="WatchDiff",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
wd.start(block=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("check")
|
|
80
|
+
def cmd_check(
|
|
81
|
+
url: str = _URL_ARG,
|
|
82
|
+
target: str | None = _TARGET_OPT,
|
|
83
|
+
storage: str = _STORAGE_OPT,
|
|
84
|
+
verbose: bool = _VERBOSE_OPT,
|
|
85
|
+
output_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Run a single check and print the result."""
|
|
88
|
+
_setup_logging(verbose)
|
|
89
|
+
|
|
90
|
+
wd = WatchDiff(storage_dir=storage)
|
|
91
|
+
wd.watch(url, target=target, interval=0)
|
|
92
|
+
|
|
93
|
+
report = wd.check_once(url)
|
|
94
|
+
|
|
95
|
+
if report is None:
|
|
96
|
+
console.print("[yellow]First snapshot captured - nothing to compare yet.[/]")
|
|
97
|
+
raise typer.Exit(0)
|
|
98
|
+
|
|
99
|
+
if output_json:
|
|
100
|
+
typer.echo(json.dumps(report.as_dict(), indent=2, ensure_ascii=False))
|
|
101
|
+
else:
|
|
102
|
+
_render_report(report)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("history")
|
|
106
|
+
def cmd_history(
|
|
107
|
+
url: str = _URL_ARG,
|
|
108
|
+
target: str | None = _TARGET_OPT,
|
|
109
|
+
storage: str = _STORAGE_OPT,
|
|
110
|
+
limit: int = _LIMIT_OPT,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Show snapshot history for a URL."""
|
|
113
|
+
from watchdiff.store import Store
|
|
114
|
+
|
|
115
|
+
store = Store(storage)
|
|
116
|
+
snapshots = store.load_history(url, target, limit=limit)
|
|
117
|
+
|
|
118
|
+
if not snapshots:
|
|
119
|
+
console.print("[yellow]No snapshots found.[/]")
|
|
120
|
+
raise typer.Exit(0)
|
|
121
|
+
|
|
122
|
+
table = Table(title=f"Snapshot history - {url}", show_lines=True)
|
|
123
|
+
table.add_column("Captured at", style="cyan")
|
|
124
|
+
table.add_column("Checksum", style="dim")
|
|
125
|
+
table.add_column("Content preview")
|
|
126
|
+
|
|
127
|
+
for snap in reversed(snapshots):
|
|
128
|
+
preview = snap.content[:80].replace("\n", " ")
|
|
129
|
+
table.add_row(
|
|
130
|
+
snap.captured_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
131
|
+
snap.checksum[:8],
|
|
132
|
+
preview,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
console.print(table)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command("reports")
|
|
139
|
+
def cmd_reports(
|
|
140
|
+
url: str = _URL_ARG,
|
|
141
|
+
target: str | None = _TARGET_OPT,
|
|
142
|
+
storage: str = _STORAGE_OPT,
|
|
143
|
+
limit: int = _LIMIT_OPT,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Show diff reports for a URL."""
|
|
146
|
+
from watchdiff.store import Store
|
|
147
|
+
|
|
148
|
+
store = Store(storage)
|
|
149
|
+
reports = store.load_reports(url, target, limit=limit)
|
|
150
|
+
|
|
151
|
+
if not reports:
|
|
152
|
+
console.print("[yellow]No reports found.[/]")
|
|
153
|
+
raise typer.Exit(0)
|
|
154
|
+
|
|
155
|
+
for r in reversed(reports):
|
|
156
|
+
changes = r.get("changes", [])
|
|
157
|
+
console.print(
|
|
158
|
+
Panel(
|
|
159
|
+
"\n".join(
|
|
160
|
+
f"[{c['kind']}] {c.get('before', '')} → {c.get('after', '')}"
|
|
161
|
+
for c in changes[:10]
|
|
162
|
+
) or "[dim]No changes[/]",
|
|
163
|
+
title=r['compared_at'],
|
|
164
|
+
subtitle=f"{len(changes)} change(s)",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("clear")
|
|
170
|
+
def cmd_clear(
|
|
171
|
+
url: str = _URL_ARG,
|
|
172
|
+
target: str | None = _TARGET_OPT,
|
|
173
|
+
storage: str = _STORAGE_OPT,
|
|
174
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Delete all stored snapshots and reports for a URL."""
|
|
177
|
+
if not yes:
|
|
178
|
+
confirmed = typer.confirm(f"Delete all history for {url!r}?")
|
|
179
|
+
if not confirmed:
|
|
180
|
+
raise typer.Abort()
|
|
181
|
+
|
|
182
|
+
from watchdiff.store import Store
|
|
183
|
+
|
|
184
|
+
store = Store(storage)
|
|
185
|
+
store.clear_history(url, target)
|
|
186
|
+
console.print(f"[green]Done.[/] History cleared for {url}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Helpers
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _render_report(report: DiffReport) -> None:
|
|
194
|
+
if not report.has_changes:
|
|
195
|
+
console.print(f"[green]OK[/] {report.summary()}")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
lines = [f"[bold]{report.summary()}[/]\n"]
|
|
199
|
+
for change in report.changes:
|
|
200
|
+
match change.kind.value:
|
|
201
|
+
case "added":
|
|
202
|
+
lines.append(f" [green][+][/] {change.after}")
|
|
203
|
+
case "removed":
|
|
204
|
+
lines.append(f" [red][-][/] {change.before}")
|
|
205
|
+
case "modified":
|
|
206
|
+
lines.append(f" [yellow][~][/] {change.before} [dim]→[/] {change.after}")
|
|
207
|
+
|
|
208
|
+
console.print(Panel("\n".join(lines), title="Changes detected", border_style="yellow"))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _setup_logging(verbose: bool) -> None:
|
|
212
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
213
|
+
logging.basicConfig(
|
|
214
|
+
level=level,
|
|
215
|
+
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
|
216
|
+
datefmt="%H:%M:%S",
|
|
217
|
+
)
|
watchdiff/core.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WatchDiff - high-level public facade.
|
|
3
|
+
|
|
4
|
+
This is what 99% of users will interact with.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from watchdiff import WatchDiff
|
|
8
|
+
|
|
9
|
+
wd = WatchDiff()
|
|
10
|
+
wd.watch("https://example.com/product", target=".price", interval=300)
|
|
11
|
+
wd.on_change(lambda report: print(report.summary()))
|
|
12
|
+
wd.start()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from watchdiff.models import AlertConfig, DiffReport, WatchConfig
|
|
22
|
+
from watchdiff.scheduler import AsyncScheduler, SyncScheduler
|
|
23
|
+
from watchdiff.store import Store
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WatchDiff:
|
|
29
|
+
"""
|
|
30
|
+
Main entry point for WatchDiff.
|
|
31
|
+
|
|
32
|
+
All methods are chainable.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, storage_dir: str | Path = ".watchdiff") -> None:
|
|
36
|
+
self._store = Store(storage_dir)
|
|
37
|
+
self._configs: list[WatchConfig] = []
|
|
38
|
+
self._global_callbacks: list[Callable[[DiffReport], None]] = []
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Configuration API
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def watch(
|
|
45
|
+
self,
|
|
46
|
+
url: str,
|
|
47
|
+
*,
|
|
48
|
+
target: str | None = None,
|
|
49
|
+
interval: int = 300,
|
|
50
|
+
label: str | None = None,
|
|
51
|
+
headers: dict[str, str] | None = None,
|
|
52
|
+
timeout: int = 15,
|
|
53
|
+
ignore_selectors: list[str] | None = None,
|
|
54
|
+
ignore_patterns: list[str] | None = None,
|
|
55
|
+
on_change: Callable[[DiffReport], None] | list[Callable[[DiffReport], None]] | None = None,
|
|
56
|
+
webhooks: list[str] | None = None,
|
|
57
|
+
min_changes: int = 1,
|
|
58
|
+
) -> "WatchDiff":
|
|
59
|
+
"""
|
|
60
|
+
Register a URL to monitor.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
url: URL to watch.
|
|
64
|
+
target: CSS selector to narrow monitoring (e.g. ".price").
|
|
65
|
+
If None, the full page body is monitored.
|
|
66
|
+
interval: Seconds between checks (default 300 = 5 min).
|
|
67
|
+
label: Human-readable name shown in logs and reports.
|
|
68
|
+
headers: Extra HTTP headers for this URL.
|
|
69
|
+
timeout: HTTP request timeout in seconds.
|
|
70
|
+
ignore_selectors: CSS selectors to strip before diffing.
|
|
71
|
+
ignore_patterns: Regex patterns to strip from text before diffing.
|
|
72
|
+
on_change: Callback(s) called with a DiffReport on each change.
|
|
73
|
+
webhooks: Webhook URLs (Discord/Slack/custom) to POST on change.
|
|
74
|
+
min_changes: Alert only if at least N changes are detected.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
self (chainable)
|
|
78
|
+
"""
|
|
79
|
+
callbacks = []
|
|
80
|
+
if on_change:
|
|
81
|
+
callbacks = on_change if isinstance(on_change, list) else [on_change]
|
|
82
|
+
|
|
83
|
+
alert = AlertConfig(
|
|
84
|
+
on_change = callbacks,
|
|
85
|
+
webhooks = webhooks or [],
|
|
86
|
+
min_changes = min_changes,
|
|
87
|
+
) if (callbacks or webhooks) else None
|
|
88
|
+
|
|
89
|
+
config = WatchConfig(
|
|
90
|
+
url = url,
|
|
91
|
+
target = target,
|
|
92
|
+
interval = interval,
|
|
93
|
+
label = label,
|
|
94
|
+
headers = headers or {},
|
|
95
|
+
timeout = timeout,
|
|
96
|
+
ignore_selectors = ignore_selectors or [],
|
|
97
|
+
ignore_patterns = ignore_patterns or [],
|
|
98
|
+
alert = alert,
|
|
99
|
+
)
|
|
100
|
+
self._configs.append(config)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def on_change(self, callback: Callable[[DiffReport], None]) -> "WatchDiff":
|
|
104
|
+
"""
|
|
105
|
+
Register a global callback called whenever ANY watched URL changes.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
callback: Function receiving a DiffReport.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
self (chainable)
|
|
112
|
+
"""
|
|
113
|
+
self._global_callbacks.append(callback)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Run API
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def start(self, block: bool = True) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Start the synchronous scheduler.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
block: If True (default), blocks until Ctrl+C.
|
|
126
|
+
If False, returns immediately (threads run as daemons).
|
|
127
|
+
"""
|
|
128
|
+
if not self._configs:
|
|
129
|
+
logger.warning("No URLs registered. Call .watch() first.")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
scheduler = SyncScheduler(self._store)
|
|
133
|
+
for cb in self._global_callbacks:
|
|
134
|
+
scheduler.add_global_callback(cb)
|
|
135
|
+
|
|
136
|
+
scheduler.start(self._configs, block=block)
|
|
137
|
+
|
|
138
|
+
async def start_async(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Start the async scheduler.
|
|
141
|
+
|
|
142
|
+
Use with `asyncio.run(wd.start_async())` or inside an existing event loop.
|
|
143
|
+
"""
|
|
144
|
+
if not self._configs:
|
|
145
|
+
logger.warning("No URLs registered. Call .watch() first.")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
scheduler = AsyncScheduler(self._store)
|
|
149
|
+
for cb in self._global_callbacks:
|
|
150
|
+
scheduler.add_global_callback(cb)
|
|
151
|
+
|
|
152
|
+
await scheduler.start(self._configs)
|
|
153
|
+
|
|
154
|
+
def check_once(self, url: str) -> DiffReport | None:
|
|
155
|
+
"""
|
|
156
|
+
Run a single immediate check for a registered URL.
|
|
157
|
+
|
|
158
|
+
Useful for testing or on-demand checks without the scheduler loop.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
url: URL to check (must have been registered via .watch()).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
DiffReport or None if it's the first check.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: if the URL is not registered.
|
|
168
|
+
"""
|
|
169
|
+
config = self._find_config(url)
|
|
170
|
+
scheduler = SyncScheduler(self._store)
|
|
171
|
+
for cb in self._global_callbacks:
|
|
172
|
+
scheduler.add_global_callback(cb)
|
|
173
|
+
return scheduler.check_once(config)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# History / audit API
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def history(self, url: str, limit: int = 20) -> list:
|
|
180
|
+
"""Return stored snapshots for a URL."""
|
|
181
|
+
config = self._find_config(url)
|
|
182
|
+
return self._store.load_history(config.url, config.target, limit=limit)
|
|
183
|
+
|
|
184
|
+
def reports(self, url: str, limit: int = 20) -> list[dict]:
|
|
185
|
+
"""Return stored diff reports for a URL."""
|
|
186
|
+
config = self._find_config(url)
|
|
187
|
+
return self._store.load_reports(config.url, config.target, limit=limit)
|
|
188
|
+
|
|
189
|
+
def clear(self, url: str) -> None:
|
|
190
|
+
"""Delete all stored snapshots and reports for a URL."""
|
|
191
|
+
config = self._find_config(url)
|
|
192
|
+
self._store.clear_history(config.url, config.target)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Internal
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _find_config(self, url: str) -> WatchConfig:
|
|
199
|
+
for config in self._configs:
|
|
200
|
+
if config.url == url:
|
|
201
|
+
return config
|
|
202
|
+
raise ValueError(f"URL not registered: {url!r}. Call .watch() first.")
|
watchdiff/diff/engine.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff Engine - compares two Snapshots and produces a human-readable DiffReport.
|
|
3
|
+
|
|
4
|
+
Strategy:
|
|
5
|
+
1. Split content into logical lines / sentences.
|
|
6
|
+
2. Use Python's difflib.SequenceMatcher for reliable LCS-based diffing.
|
|
7
|
+
3. Map opcodes to Change objects (added / removed / modified).
|
|
8
|
+
|
|
9
|
+
This produces clean, minimal diffs without HTML noise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
|
|
16
|
+
from watchdiff.models import Change, ChangeType, DiffReport, Snapshot, WatchConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiffEngine:
|
|
20
|
+
"""Compares two snapshots and returns a DiffReport."""
|
|
21
|
+
|
|
22
|
+
def compare(
|
|
23
|
+
self,
|
|
24
|
+
before: Snapshot,
|
|
25
|
+
after: Snapshot,
|
|
26
|
+
config: WatchConfig,
|
|
27
|
+
) -> DiffReport:
|
|
28
|
+
"""
|
|
29
|
+
Compare two snapshots.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
DiffReport with a list of Change objects.
|
|
33
|
+
"""
|
|
34
|
+
changes: list[Change] = []
|
|
35
|
+
|
|
36
|
+
if before.is_identical_to(after):
|
|
37
|
+
return DiffReport(
|
|
38
|
+
url = config.url,
|
|
39
|
+
target = config.target,
|
|
40
|
+
label = config.label or config.url,
|
|
41
|
+
before = before,
|
|
42
|
+
after = after,
|
|
43
|
+
changes = [],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
before_lines = _split_content(before.content)
|
|
47
|
+
after_lines = _split_content(after.content)
|
|
48
|
+
|
|
49
|
+
matcher = difflib.SequenceMatcher(
|
|
50
|
+
isjunk = None,
|
|
51
|
+
a = before_lines,
|
|
52
|
+
b = after_lines,
|
|
53
|
+
autojunk = False,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for opcode, a0, a1, b0, b1 in matcher.get_opcodes():
|
|
57
|
+
match opcode:
|
|
58
|
+
case "equal":
|
|
59
|
+
pass # no change
|
|
60
|
+
|
|
61
|
+
case "insert":
|
|
62
|
+
for line in after_lines[b0:b1]:
|
|
63
|
+
if line.strip():
|
|
64
|
+
changes.append(
|
|
65
|
+
Change(
|
|
66
|
+
kind = ChangeType.ADDED,
|
|
67
|
+
after = line.strip(),
|
|
68
|
+
context = _context(after_lines, b0),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
case "delete":
|
|
73
|
+
for line in before_lines[a0:a1]:
|
|
74
|
+
if line.strip():
|
|
75
|
+
changes.append(
|
|
76
|
+
Change(
|
|
77
|
+
kind = ChangeType.REMOVED,
|
|
78
|
+
before = line.strip(),
|
|
79
|
+
context = _context(before_lines, a0),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
case "replace":
|
|
84
|
+
old_block = " ".join(before_lines[a0:a1]).strip()
|
|
85
|
+
new_block = " ".join(after_lines[b0:b1]).strip()
|
|
86
|
+
if old_block and new_block:
|
|
87
|
+
changes.append(
|
|
88
|
+
Change(
|
|
89
|
+
kind = ChangeType.MODIFIED,
|
|
90
|
+
before = old_block,
|
|
91
|
+
after = new_block,
|
|
92
|
+
context = _context(after_lines, b0),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
elif old_block:
|
|
96
|
+
changes.append(
|
|
97
|
+
Change(kind=ChangeType.REMOVED, before=old_block)
|
|
98
|
+
)
|
|
99
|
+
elif new_block:
|
|
100
|
+
changes.append(
|
|
101
|
+
Change(kind=ChangeType.ADDED, after=new_block)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return DiffReport(
|
|
105
|
+
url = config.url,
|
|
106
|
+
target = config.target,
|
|
107
|
+
label = config.label or config.url,
|
|
108
|
+
before = before,
|
|
109
|
+
after = after,
|
|
110
|
+
changes = changes,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Helpers
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _split_content(text: str) -> list[str]:
|
|
119
|
+
"""Split text into meaningful units for diffing.
|
|
120
|
+
|
|
121
|
+
Splits on newlines; empty lines are preserved as markers.
|
|
122
|
+
"""
|
|
123
|
+
return text.splitlines()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _context(lines: list[str], index: int, window: int = 1) -> str | None:
|
|
127
|
+
"""Return a short surrounding context for a change."""
|
|
128
|
+
start = max(0, index - window)
|
|
129
|
+
end = min(len(lines), index + window + 1)
|
|
130
|
+
ctx = " | ".join(line.strip() for line in lines[start:end] if line.strip())
|
|
131
|
+
return ctx or None
|