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
|
@@ -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,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,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,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
|