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 ADDED
@@ -0,0 +1,3 @@
1
+ from watchdiff.core import WatchDiff
2
+
3
+ __all__ = ["WatchDiff"]
@@ -0,0 +1,3 @@
1
+ from watchdiff.cleaner.cleaner import Cleaner
2
+
3
+ __all__ = ["Cleaner"]
@@ -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.")
@@ -0,0 +1,3 @@
1
+ from watchdiff.diff.engine import DiffEngine
2
+
3
+ __all__ = ["DiffEngine"]
@@ -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