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,3 @@
1
+ from watchdiff.fetcher.fetcher import AsyncFetcher, FetchError, Fetcher
2
+
3
+ __all__ = ["AsyncFetcher", "FetchError", "Fetcher"]
@@ -0,0 +1,84 @@
1
+ """
2
+ Fetcher - downloads HTML from URLs.
3
+
4
+ Two implementations:
5
+ - Fetcher: synchronous, uses httpx.Client
6
+ - AsyncFetcher: async, uses httpx.AsyncClient
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ import httpx
14
+
15
+ from watchdiff.models import WatchConfig
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _DEFAULT_HEADERS = {
20
+ "User-Agent": (
21
+ "Mozilla/5.0 (compatible; WatchDiff/1.0; "
22
+ "+https://github.com/watchdiff/watchdiff-py)"
23
+ ),
24
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
25
+ "Accept-Language": "en-US,en;q=0.5",
26
+ }
27
+
28
+
29
+ class FetchError(Exception):
30
+ """Raised when an HTTP request fails."""
31
+
32
+
33
+ class Fetcher:
34
+ """Synchronous HTTP fetcher."""
35
+
36
+ def fetch(self, config: WatchConfig) -> str:
37
+ """
38
+ Download the HTML for the given config.
39
+
40
+ Returns:
41
+ Raw HTML string.
42
+
43
+ Raises:
44
+ FetchError: on network error or non-2xx response.
45
+ """
46
+ headers = {**_DEFAULT_HEADERS, **config.headers}
47
+ try:
48
+ with httpx.Client(timeout=config.timeout, follow_redirects=True) as client:
49
+ resp = client.get(config.url, headers=headers)
50
+ resp.raise_for_status()
51
+ return resp.text
52
+ except httpx.HTTPStatusError as exc:
53
+ raise FetchError(
54
+ f"HTTP {exc.response.status_code} for {config.url}"
55
+ ) from exc
56
+ except httpx.RequestError as exc:
57
+ raise FetchError(f"Request failed for {config.url}: {exc}") from exc
58
+
59
+
60
+ class AsyncFetcher:
61
+ """Asynchronous HTTP fetcher."""
62
+
63
+ async def fetch(self, config: WatchConfig) -> str:
64
+ """
65
+ Download the HTML for the given config (async).
66
+
67
+ Returns:
68
+ Raw HTML string.
69
+
70
+ Raises:
71
+ FetchError: on network error or non-2xx response.
72
+ """
73
+ headers = {**_DEFAULT_HEADERS, **config.headers}
74
+ try:
75
+ async with httpx.AsyncClient(timeout=config.timeout, follow_redirects=True) as client:
76
+ resp = await client.get(config.url, headers=headers)
77
+ resp.raise_for_status()
78
+ return resp.text
79
+ except httpx.HTTPStatusError as exc:
80
+ raise FetchError(
81
+ f"HTTP {exc.response.status_code} for {config.url}"
82
+ ) from exc
83
+ except httpx.RequestError as exc:
84
+ raise FetchError(f"Request failed for {config.url}: {exc}") from exc
watchdiff/models.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ WatchDiff models - shared data structures used across all modules.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
11
+ from typing import Any, Callable
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Enums
16
+ # ---------------------------------------------------------------------------
17
+
18
+ class ChangeType(str, Enum):
19
+ ADDED = "added"
20
+ REMOVED = "removed"
21
+ MODIFIED = "modified"
22
+ UNCHANGED = "unchanged"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Configuration
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @dataclass
30
+ class WatchConfig:
31
+ """Configuration for a single watched URL."""
32
+
33
+ url: str
34
+ target: str | None = None # CSS selector - None means full page
35
+ interval: int = 300 # seconds between checks
36
+ label: str | None = None # human-readable name for this watch
37
+ headers: dict[str, str] = field(default_factory=dict)
38
+ timeout: int = 15 # HTTP timeout in seconds
39
+ ignore_selectors: list[str] = field(default_factory=list) # CSS to strip
40
+ ignore_patterns: list[str] = field(default_factory=list) # regex patterns to strip
41
+ alert: AlertConfig | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.label:
45
+ self.label = self.url
46
+
47
+
48
+ @dataclass
49
+ class AlertConfig:
50
+ """Alert configuration attached to a WatchConfig."""
51
+
52
+ on_change: list[Callable[[DiffReport], Any]] = field(default_factory=list)
53
+ webhooks: list[str] = field(default_factory=list) # Discord / Slack / custom URLs
54
+ min_changes: int = 1 # trigger only if >= N changes
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Snapshots
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @dataclass
62
+ class Snapshot:
63
+ """A single captured version of a watched page/selector."""
64
+
65
+ url: str
66
+ target: str | None
67
+ content: str # cleaned text content
68
+ raw_html: str # original HTML of the extracted zone
69
+ captured_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
70
+ checksum: str = ""
71
+
72
+ def __post_init__(self) -> None:
73
+ self.checksum = hashlib.sha256(self.content.encode()).hexdigest()
74
+
75
+ def is_identical_to(self, other: "Snapshot") -> bool:
76
+ return self.checksum == other.checksum
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Diff
81
+ # ---------------------------------------------------------------------------
82
+
83
+ @dataclass
84
+ class Change:
85
+ """A single atomic change between two snapshots."""
86
+
87
+ kind: ChangeType
88
+ before: str | None = None # old value / text
89
+ after: str | None = None # new value / text
90
+ context: str | None = None # surrounding text hint
91
+
92
+ def human(self) -> str:
93
+ """Return a human-readable one-liner."""
94
+ match self.kind:
95
+ case ChangeType.ADDED:
96
+ return f"[+] Added: {self.after!r}"
97
+ case ChangeType.REMOVED:
98
+ return f"[-] Removed: {self.before!r}"
99
+ case ChangeType.MODIFIED:
100
+ return f"[~] Changed: {self.before!r} → {self.after!r}"
101
+ case _:
102
+ return "[=] Unchanged"
103
+
104
+ def __str__(self) -> str:
105
+ return self.human()
106
+
107
+
108
+ @dataclass
109
+ class DiffReport:
110
+ """Result of comparing two snapshots."""
111
+
112
+ url: str
113
+ target: str | None
114
+ label: str
115
+ before: Snapshot
116
+ after: Snapshot
117
+ changes: list[Change] = field(default_factory=list)
118
+ compared_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
119
+
120
+ @property
121
+ def has_changes(self) -> bool:
122
+ return bool(self.changes)
123
+
124
+ @property
125
+ def added(self) -> list[Change]:
126
+ return [c for c in self.changes if c.kind == ChangeType.ADDED]
127
+
128
+ @property
129
+ def removed(self) -> list[Change]:
130
+ return [c for c in self.changes if c.kind == ChangeType.REMOVED]
131
+
132
+ @property
133
+ def modified(self) -> list[Change]:
134
+ return [c for c in self.changes if c.kind == ChangeType.MODIFIED]
135
+
136
+ def summary(self) -> str:
137
+ if not self.has_changes:
138
+ return f"[{self.label}] No changes detected."
139
+ parts = []
140
+ if self.added:
141
+ parts.append(f"{len(self.added)} added")
142
+ if self.removed:
143
+ parts.append(f"{len(self.removed)} removed")
144
+ if self.modified:
145
+ parts.append(f"{len(self.modified)} modified")
146
+ return f"[{self.label}] {', '.join(parts)} - {self.compared_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
147
+
148
+ def as_dict(self) -> dict:
149
+ return {
150
+ "url": self.url,
151
+ "target": self.target,
152
+ "label": self.label,
153
+ "compared_at": self.compared_at.isoformat(),
154
+ "changes": [
155
+ {
156
+ "kind": c.kind.value,
157
+ "before": c.before,
158
+ "after": c.after,
159
+ "context": c.context,
160
+ }
161
+ for c in self.changes
162
+ ],
163
+ }
@@ -0,0 +1,3 @@
1
+ from watchdiff.notifier.notifier import Notifier
2
+
3
+ __all__ = ["Notifier"]
@@ -0,0 +1,81 @@
1
+ """
2
+ Notifier - dispatches alerts when a DiffReport has changes.
3
+
4
+ Supported channels:
5
+ - Python callbacks (synchronous)
6
+ - Webhooks (Discord, Slack, custom - via httpx POST)
7
+
8
+ Adding a new channel is as simple as adding a method and calling it
9
+ inside `notify()`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import httpx
17
+
18
+ from watchdiff.models import AlertConfig, DiffReport
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Notifier:
24
+ """Sends alerts for a DiffReport based on an AlertConfig."""
25
+
26
+ def notify(self, report: DiffReport, alert: AlertConfig) -> None:
27
+ """
28
+ Dispatch all configured alert channels.
29
+
30
+ Silently logs errors - a broken webhook must not crash the watcher.
31
+ """
32
+ if not report.has_changes:
33
+ return
34
+ if len(report.changes) < alert.min_changes:
35
+ return
36
+
37
+ # 1. Python callbacks
38
+ for callback in alert.on_change:
39
+ try:
40
+ callback(report)
41
+ except Exception as exc: # noqa: BLE001
42
+ logger.warning("Alert callback raised an error: %s", exc)
43
+
44
+ # 2. Webhooks
45
+ for url in alert.webhooks:
46
+ self._send_webhook(url, report)
47
+
48
+ # ------------------------------------------------------------------
49
+ # Channels
50
+ # ------------------------------------------------------------------
51
+
52
+ def _send_webhook(self, url: str, report: DiffReport) -> None:
53
+ """POST a JSON payload to a webhook URL.
54
+
55
+ Automatically adapts payload format for Discord vs Slack vs generic.
56
+ """
57
+ payload = self._build_payload(url, report)
58
+ try:
59
+ with httpx.Client(timeout=10) as client:
60
+ resp = client.post(url, json=payload)
61
+ if not resp.is_success:
62
+ logger.warning(
63
+ "Webhook %s returned %d", url, resp.status_code
64
+ )
65
+ except httpx.RequestError as exc:
66
+ logger.warning("Webhook request error (%s): %s", url, exc)
67
+
68
+ def _build_payload(self, url: str, report: DiffReport) -> dict:
69
+ """Build a webhook payload adapted to the target service."""
70
+ summary = report.summary()
71
+ change_lines = "\n".join(c.human() for c in report.changes[:20])
72
+ text = f"{summary}\n\n{change_lines}"
73
+
74
+ if "discord.com" in url:
75
+ return {"content": text[:2000]} # Discord 2000-char limit
76
+
77
+ if "hooks.slack.com" in url:
78
+ return {"text": text[:3000]}
79
+
80
+ # Generic JSON
81
+ return report.as_dict()
@@ -0,0 +1,3 @@
1
+ from watchdiff.parser.parser import Parser, ParserError
2
+
3
+ __all__ = ["Parser", "ParserError"]
@@ -0,0 +1,75 @@
1
+ """
2
+ Parser - extracts the content zone that the user actually wants to monitor.
3
+
4
+ If a CSS selector (target) is provided, only that zone is extracted.
5
+ Otherwise the full body text is used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from bs4 import BeautifulSoup
11
+
12
+ from watchdiff.models import Snapshot, WatchConfig
13
+
14
+
15
+ class Parser:
16
+ """Extracts the monitored zone from a cleaned BeautifulSoup tree."""
17
+
18
+ def extract(
19
+ self,
20
+ soup: BeautifulSoup,
21
+ config: WatchConfig,
22
+ ) -> Snapshot:
23
+ """
24
+ Extract a Snapshot from a cleaned BeautifulSoup document.
25
+
26
+ Args:
27
+ soup: Cleaned BeautifulSoup tree (output of Cleaner.clean).
28
+ config: The WatchConfig describing what to monitor.
29
+
30
+ Returns:
31
+ A Snapshot with content and raw_html fields populated.
32
+
33
+ Raises:
34
+ ParserError: if the target selector matched nothing.
35
+ """
36
+ if config.target:
37
+ elements = soup.select(config.target)
38
+ if not elements:
39
+ raise ParserError(
40
+ f"Selector {config.target!r} matched nothing on {config.url}"
41
+ )
42
+ # Combine all matched elements
43
+ raw_html = "\n".join(str(el) for el in elements)
44
+ content = "\n".join(
45
+ el.get_text(separator="\n").strip() for el in elements
46
+ )
47
+ else:
48
+ body = soup.find("body") or soup
49
+ raw_html = str(body)
50
+ content = body.get_text(separator="\n").strip()
51
+
52
+ # Final normalisation
53
+ content = _collapse_whitespace(content)
54
+
55
+ return Snapshot(
56
+ url = config.url,
57
+ target = config.target,
58
+ content = content,
59
+ raw_html = raw_html,
60
+ )
61
+
62
+
63
+ class ParserError(Exception):
64
+ """Raised when the target selector cannot be found."""
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def _collapse_whitespace(text: str) -> str:
72
+ import re
73
+ text = re.sub(r"[ \t]+", " ", text)
74
+ text = re.sub(r"\n{3,}", "\n\n", text)
75
+ return "\n".join(line.strip() for line in text.splitlines() if line.strip())
@@ -0,0 +1,3 @@
1
+ from watchdiff.scheduler.scheduler import AsyncScheduler, SyncScheduler
2
+
3
+ __all__ = ["AsyncScheduler", "SyncScheduler"]
@@ -0,0 +1,223 @@
1
+ """
2
+ Scheduler - drives the periodic check loop.
3
+
4
+ Two implementations:
5
+ - SyncScheduler: blocking loop using threading.Timer, suitable for scripts.
6
+ - AsyncScheduler: asyncio-based loop for async applications.
7
+
8
+ Both call the same internal pipeline:
9
+ Fetcher → Cleaner → Parser → DiffEngine → Store → Notifier
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import threading
17
+ import time
18
+ from typing import Callable
19
+
20
+ from watchdiff.cleaner import Cleaner
21
+ from watchdiff.diff import DiffEngine
22
+ from watchdiff.fetcher import AsyncFetcher, FetchError, Fetcher
23
+ from watchdiff.models import DiffReport, WatchConfig
24
+ from watchdiff.notifier import Notifier
25
+ from watchdiff.parser import Parser, ParserError
26
+ from watchdiff.store import Store
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class SyncScheduler:
32
+ """
33
+ Blocking multi-threaded scheduler.
34
+
35
+ Each WatchConfig runs in its own daemon thread with an independent
36
+ sleep interval, so different URLs can have different cadences.
37
+ """
38
+
39
+ def __init__(self, store: Store) -> None:
40
+ self._store = store
41
+ self._fetcher = Fetcher()
42
+ self._parser = Parser()
43
+ self._engine = DiffEngine()
44
+ self._notifier = Notifier()
45
+ self._on_diff_callbacks: list[Callable[[DiffReport], None]] = []
46
+ self._threads: list[threading.Thread] = []
47
+ self._stop_events: list[threading.Event] = []
48
+
49
+ def add_global_callback(self, callback: Callable[[DiffReport], None]) -> None:
50
+ """Register a callback called for every DiffReport (regardless of config)."""
51
+ self._on_diff_callbacks.append(callback)
52
+
53
+ def start(self, configs: list[WatchConfig], block: bool = True) -> None:
54
+ """
55
+ Start monitoring all configs.
56
+
57
+ Args:
58
+ configs: list of WatchConfig objects.
59
+ block: If True, blocks until KeyboardInterrupt. If False,
60
+ returns immediately (threads run as daemons).
61
+ """
62
+ for config in configs:
63
+ stop_event = threading.Event()
64
+ self._stop_events.append(stop_event)
65
+ thread = threading.Thread(
66
+ target = self._run_loop,
67
+ args = (config, stop_event),
68
+ daemon = True,
69
+ name = f"watchdiff-{config.label}",
70
+ )
71
+ self._threads.append(thread)
72
+ thread.start()
73
+ logger.info("Started watcher for %s (interval=%ds)", config.label, config.interval)
74
+
75
+ if block:
76
+ try:
77
+ while True:
78
+ time.sleep(1)
79
+ except KeyboardInterrupt:
80
+ self.stop()
81
+
82
+ def stop(self) -> None:
83
+ """Signal all watcher threads to stop."""
84
+ for event in self._stop_events:
85
+ event.set()
86
+ logger.info("Stopping all watchers…")
87
+
88
+ def check_once(self, config: WatchConfig) -> DiffReport | None:
89
+ """Run a single check for a config and return the DiffReport."""
90
+ return self._check(config)
91
+
92
+ # ------------------------------------------------------------------
93
+ # Internal
94
+ # ------------------------------------------------------------------
95
+
96
+ def _run_loop(self, config: WatchConfig, stop_event: threading.Event) -> None:
97
+ """Thread target: check, sleep, repeat."""
98
+ while not stop_event.is_set():
99
+ self._check(config)
100
+ stop_event.wait(timeout=config.interval)
101
+
102
+ def _check(self, config: WatchConfig) -> DiffReport | None:
103
+ try:
104
+ html = self._fetcher.fetch(config)
105
+ except FetchError as exc:
106
+ logger.error("[%s] Fetch failed: %s", config.label, exc)
107
+ return None
108
+
109
+ cleaner = Cleaner(
110
+ extra_selectors=config.ignore_selectors,
111
+ extra_patterns=config.ignore_patterns,
112
+ )
113
+ soup = cleaner.clean(html)
114
+
115
+ try:
116
+ snapshot = self._parser.extract(soup, config)
117
+ except ParserError as exc:
118
+ logger.error("[%s] Parse failed: %s", config.label, exc)
119
+ return None
120
+
121
+ previous = self._store.load_latest(config.url, config.target)
122
+
123
+ if previous is None:
124
+ # First run - just save and return
125
+ self._store.save_snapshot(snapshot)
126
+ logger.info("[%s] First snapshot captured.", config.label)
127
+ return None
128
+
129
+ report = self._engine.compare(previous, snapshot, config)
130
+ self._store.save_snapshot(snapshot)
131
+
132
+ if report.has_changes:
133
+ self._store.save_report(report)
134
+ logger.info("[%s] %s", config.label, report.summary())
135
+
136
+ # Global callbacks
137
+ for cb in self._on_diff_callbacks:
138
+ try:
139
+ cb(report)
140
+ except Exception as exc: # noqa: BLE001
141
+ logger.warning("Global callback error: %s", exc)
142
+
143
+ # Per-config alert
144
+ if config.alert:
145
+ self._notifier.notify(report, config.alert)
146
+ else:
147
+ logger.debug("[%s] No changes.", config.label)
148
+
149
+ return report
150
+
151
+
152
+ class AsyncScheduler:
153
+ """
154
+ Asyncio-based scheduler.
155
+
156
+ Use this inside async applications (FastAPI, aiohttp, etc.).
157
+ """
158
+
159
+ def __init__(self, store: Store) -> None:
160
+ self._store = store
161
+ self._fetcher = AsyncFetcher()
162
+ self._parser = Parser()
163
+ self._engine = DiffEngine()
164
+ self._notifier = Notifier()
165
+ self._on_diff_callbacks: list[Callable[[DiffReport], None]] = []
166
+
167
+ def add_global_callback(self, callback: Callable[[DiffReport], None]) -> None:
168
+ self._on_diff_callbacks.append(callback)
169
+
170
+ async def start(self, configs: list[WatchConfig]) -> None:
171
+ """Start all watchers as concurrent asyncio tasks."""
172
+ tasks = [asyncio.create_task(self._run_loop(cfg)) for cfg in configs]
173
+ await asyncio.gather(*tasks)
174
+
175
+ async def check_once(self, config: WatchConfig) -> DiffReport | None:
176
+ """Single async check."""
177
+ return await self._check(config)
178
+
179
+ async def _run_loop(self, config: WatchConfig) -> None:
180
+ while True:
181
+ await self._check(config)
182
+ await asyncio.sleep(config.interval)
183
+
184
+ async def _check(self, config: WatchConfig) -> DiffReport | None:
185
+ try:
186
+ html = await self._fetcher.fetch(config)
187
+ except FetchError as exc:
188
+ logger.error("[%s] Fetch failed: %s", config.label, exc)
189
+ return None
190
+
191
+ cleaner = Cleaner(
192
+ extra_selectors=config.ignore_selectors,
193
+ extra_patterns=config.ignore_patterns,
194
+ )
195
+ soup = cleaner.clean(html)
196
+
197
+ try:
198
+ snapshot = self._parser.extract(soup, config)
199
+ except ParserError as exc:
200
+ logger.error("[%s] Parse failed: %s", config.label, exc)
201
+ return None
202
+
203
+ previous = self._store.load_latest(config.url, config.target)
204
+
205
+ if previous is None:
206
+ self._store.save_snapshot(snapshot)
207
+ logger.info("[%s] First snapshot captured.", config.label)
208
+ return None
209
+
210
+ report = self._engine.compare(previous, snapshot, config)
211
+ self._store.save_snapshot(snapshot)
212
+
213
+ if report.has_changes:
214
+ self._store.save_report(report)
215
+ for cb in self._on_diff_callbacks:
216
+ try:
217
+ cb(report)
218
+ except Exception as exc: # noqa: BLE001
219
+ logger.warning("Global callback error: %s", exc)
220
+ if config.alert:
221
+ self._notifier.notify(report, config.alert)
222
+
223
+ return report
@@ -0,0 +1,3 @@
1
+ from watchdiff.store.store import Store
2
+
3
+ __all__ = ["Store"]