amagi-site-diff 0.1.0__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
+ """amagi-site-diff: compare two versions of a website page by page."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,271 @@
1
+ """Load pages in headless Chromium and capture screenshot, text, links and element boxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+
9
+ FREEZE_CSS = """
10
+ *, *::before, *::after {
11
+ animation-duration: 0s !important; animation-delay: 0s !important;
12
+ transition-duration: 0s !important; transition-delay: 0s !important;
13
+ caret-color: transparent !important; scroll-behavior: auto !important;
14
+ }
15
+ """
16
+
17
+ # Runs in the page after it settles. Returns everything the diff needs in one round trip.
18
+ PAGE_JS = r"""
19
+ () => {
20
+ const sx = window.scrollX, sy = window.scrollY;
21
+ const keep = new Set(['HEADER','NAV','MAIN','SECTION','ARTICLE','ASIDE','FOOTER','H1','H2','H3',
22
+ 'H4','H5','H6','P','A','BUTTON','IMG','PICTURE','VIDEO','svg','LI','UL','OL','FORM','INPUT',
23
+ 'SELECT','TEXTAREA','LABEL','TABLE','TR','TD','TH','FIGURE','FIGCAPTION','BLOCKQUOTE','DIV',
24
+ 'SPAN','STRONG','EM','DL','DT','DD','IFRAME','CANVAS','PRE','CODE']);
25
+ const part = (el) => {
26
+ let s = el.tagName.toLowerCase();
27
+ if (el.id) return s + '#' + el.id;
28
+ const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
29
+ if (cls.length) s += '.' + cls.join('.');
30
+ const parent = el.parentElement;
31
+ if (parent) {
32
+ const same = Array.from(parent.children).filter(c => c.tagName === el.tagName);
33
+ if (same.length > 1) s += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
34
+ }
35
+ return s;
36
+ };
37
+ const describe = (el) => {
38
+ const parts = [];
39
+ let node = el;
40
+ while (node && node.nodeType === 1 && parts.length < 4) {
41
+ if (node.tagName === 'BODY' || node.tagName === 'HTML') break;
42
+ parts.unshift(part(node));
43
+ if (node.id) break;
44
+ node = node.parentElement;
45
+ }
46
+ return parts.join(' > ') || el.tagName.toLowerCase();
47
+ };
48
+ const elements = [];
49
+ for (const el of document.body ? document.body.querySelectorAll('*') : []) {
50
+ if (elements.length >= 5000) break;
51
+ if (!keep.has(el.tagName)) continue;
52
+ const r = el.getBoundingClientRect();
53
+ if (r.width < 2 || r.height < 2) continue;
54
+ const cs = getComputedStyle(el);
55
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
56
+ const text = (el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '')
57
+ .replace(/\s+/g, ' ').trim().slice(0, 120);
58
+ elements.push({sel: describe(el), x: Math.round(r.left + sx), y: Math.round(r.top + sy),
59
+ w: Math.round(r.width), h: Math.round(r.height), text});
60
+ }
61
+ const links = Array.from(document.querySelectorAll('a[href]')).map(a => a.href);
62
+ const meta = (name) => {
63
+ const el = document.querySelector('meta[name="' + name + '"]');
64
+ return el ? (el.getAttribute('content') || '') : null;
65
+ };
66
+ const canonical = document.querySelector('link[rel="canonical"]');
67
+ return {
68
+ title: document.title || '',
69
+ description: meta('description'),
70
+ robots: meta('robots'),
71
+ canonical: canonical ? canonical.href : null,
72
+ text: document.body ? document.body.innerText : '',
73
+ links, elements,
74
+ };
75
+ }
76
+ """
77
+
78
+ # Scroll through the page once so lazy-loaded images are in the full-page screenshot.
79
+ SCROLL_JS = r"""
80
+ async (step) => {
81
+ const limit = 30000;
82
+ let y = 0;
83
+ while (y < Math.min(document.documentElement.scrollHeight, limit)) {
84
+ window.scrollTo(0, y);
85
+ await new Promise(r => setTimeout(r, 60));
86
+ y += step;
87
+ }
88
+ window.scrollTo(0, 0);
89
+ }
90
+ """
91
+
92
+
93
+ @dataclass
94
+ class PageCapture:
95
+ url: str
96
+ final_url: str | None = None
97
+ status: int | None = None
98
+ error: str | None = None
99
+ title: str = ""
100
+ description: str | None = None
101
+ robots: str | None = None
102
+ canonical: str | None = None
103
+ text: str = ""
104
+ links: list[str] = field(default_factory=list)
105
+ elements: list[dict] = field(default_factory=list)
106
+ console_errors: list[str] = field(default_factory=list)
107
+ failed_requests: list[str] = field(default_factory=list)
108
+ screenshot: bytes | None = None
109
+
110
+ @property
111
+ def ok(self) -> bool:
112
+ return self.error is None and self.status is not None and self.status < 400
113
+
114
+ def problem(self) -> str:
115
+ if self.error:
116
+ return self.error
117
+ if self.status is None:
118
+ return "no response"
119
+ return f"HTTP {self.status}"
120
+
121
+
122
+ class BrowserMissing(RuntimeError):
123
+ pass
124
+
125
+
126
+ def install_chromium() -> None:
127
+ """Download Playwright's Chromium build (one time, about 150 MB)."""
128
+ subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
129
+
130
+
131
+ class Capturer:
132
+ """One headless Chromium, one isolated browser context per side ("a" and "b")."""
133
+
134
+ def __init__(self, viewport=(1280, 800), timeout_ms=30000, wait_ms=300, hide=(),
135
+ channel=None, full_page=True, scroll=True, auto_install=True, log=None):
136
+ self.viewport = {"width": viewport[0], "height": viewport[1]}
137
+ self.timeout_ms = timeout_ms
138
+ self.wait_ms = wait_ms
139
+ self.hide = list(hide)
140
+ self.channel = channel
141
+ self.full_page = full_page
142
+ self.scroll = scroll
143
+ self.auto_install = auto_install
144
+ self.log = log or (lambda msg: None)
145
+ self._pw = None
146
+ self._browser = None
147
+ self._contexts = {}
148
+
149
+ def __enter__(self) -> "Capturer":
150
+ from playwright.sync_api import sync_playwright
151
+
152
+ self._pw = sync_playwright().start()
153
+ try:
154
+ self._browser = self._launch()
155
+ for side in ("a", "b"):
156
+ self._contexts[side] = self._browser.new_context(
157
+ viewport=self.viewport, device_scale_factor=1, reduced_motion="reduce",
158
+ color_scheme="light", locale="en-US", timezone_id="UTC",
159
+ )
160
+ except BaseException:
161
+ self.__exit__(None, None, None)
162
+ raise
163
+ return self
164
+
165
+ def _launch(self):
166
+ kwargs = {"headless": True}
167
+ if self.channel:
168
+ kwargs["channel"] = self.channel
169
+ try:
170
+ return self._pw.chromium.launch(**kwargs)
171
+ except Exception as exc: # playwright raises its own Error type
172
+ if "Executable doesn't exist" not in str(exc) or self.channel:
173
+ raise
174
+ if not self.auto_install:
175
+ raise BrowserMissing(
176
+ "Chromium for Playwright is not installed. Run: python -m playwright install chromium"
177
+ ) from exc
178
+ self.log("Chromium for Playwright not found; downloading it once (about 150 MB)...")
179
+ install_chromium()
180
+ return self._pw.chromium.launch(**kwargs)
181
+
182
+ @property
183
+ def browser_version(self) -> str:
184
+ return self._browser.version if self._browser else ""
185
+
186
+ def __exit__(self, *exc) -> None:
187
+ for ctx in self._contexts.values():
188
+ try:
189
+ ctx.close()
190
+ except Exception:
191
+ pass
192
+ self._contexts = {}
193
+ if self._browser:
194
+ self._browser.close()
195
+ self._browser = None
196
+ if self._pw:
197
+ self._pw.stop()
198
+ self._pw = None
199
+
200
+ def capture(self, side: str, url: str, screenshot: bool = True) -> PageCapture:
201
+ cap = PageCapture(url=url)
202
+ page = self._contexts[side].new_page()
203
+ page.set_default_timeout(self.timeout_ms)
204
+
205
+ def on_console(msg):
206
+ if msg.type == "error" and len(cap.console_errors) < 50:
207
+ cap.console_errors.append(msg.text)
208
+
209
+ def on_pageerror(err):
210
+ if len(cap.console_errors) < 50:
211
+ cap.console_errors.append(f"Uncaught: {err}")
212
+
213
+ def on_response(resp):
214
+ if (resp.status >= 400 and resp.request.resource_type != "document"
215
+ and len(cap.failed_requests) < 50):
216
+ cap.failed_requests.append(f"{resp.status} {resp.url}")
217
+
218
+ def on_requestfailed(req):
219
+ if len(cap.failed_requests) < 50:
220
+ cap.failed_requests.append(f"failed ({req.failure}) {req.url}")
221
+
222
+ page.on("console", on_console)
223
+ page.on("pageerror", on_pageerror)
224
+ page.on("response", on_response)
225
+ page.on("requestfailed", on_requestfailed)
226
+ try:
227
+ try:
228
+ resp = page.goto(url, wait_until="load", timeout=self.timeout_ms)
229
+ except Exception as exc:
230
+ cap.error = _short_error(exc)
231
+ return cap
232
+ cap.status = resp.status if resp else None
233
+ cap.final_url = page.url
234
+ if not cap.ok:
235
+ return cap
236
+ _settle(page, min(self.timeout_ms, 10000))
237
+ css = FREEZE_CSS + "".join(f"{sel} {{ visibility: hidden !important; }}\n" for sel in self.hide)
238
+ page.add_style_tag(content=css)
239
+ if self.scroll and self.full_page:
240
+ page.evaluate(SCROLL_JS, self.viewport["height"])
241
+ _settle(page, 5000)
242
+ if self.wait_ms:
243
+ page.wait_for_timeout(self.wait_ms)
244
+ info = page.evaluate(PAGE_JS)
245
+ cap.title = info["title"]
246
+ cap.description = info["description"]
247
+ cap.robots = info["robots"]
248
+ cap.canonical = info["canonical"]
249
+ cap.text = info["text"]
250
+ cap.links = info["links"]
251
+ cap.elements = info["elements"]
252
+ if screenshot:
253
+ cap.screenshot = page.screenshot(full_page=self.full_page, animations="disabled", caret="hide")
254
+ except Exception as exc:
255
+ cap.error = _short_error(exc)
256
+ finally:
257
+ page.close()
258
+ return cap
259
+
260
+
261
+ def _settle(page, timeout_ms: int) -> None:
262
+ try:
263
+ page.wait_for_load_state("networkidle", timeout=timeout_ms)
264
+ except Exception:
265
+ pass # long-polling or analytics beacons; the page is as settled as it will get
266
+
267
+
268
+ def _short_error(exc: Exception) -> str:
269
+ text = str(exc).strip()
270
+ first = text.splitlines()[0] if text else type(exc).__name__
271
+ return first[:200]
amagi_site_diff/cli.py ADDED
@@ -0,0 +1,160 @@
1
+ """Command line: amagi-site-diff A B [options]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import os
8
+ import re
9
+ import sys
10
+
11
+ from . import __version__
12
+ from .report import render_markdown, summary_line, to_json
13
+ from .runner import Options, run
14
+ from .urls import LocalSite, normalize_path, parse_base
15
+
16
+ EPILOG = """examples:
17
+ amagi-site-diff https://example.com https://preview.example.com
18
+ amagi-site-diff https://example.com https://staging.example.com --path /pricing --no-crawl
19
+ amagi-site-diff ./dist-old ./dist-new --out diff-report --exit-code
20
+
21
+ A and B can be base URLs or local folders (a folder is served on 127.0.0.1 for the run).
22
+ Exit codes: 0 done, 1 differences found (only with --exit-code), 2 usage or browser error,
23
+ or a site that never responded.
24
+ """
25
+
26
+
27
+ def _viewport(value: str) -> tuple[int, int]:
28
+ try:
29
+ w, h = value.lower().split("x")
30
+ size = int(w), int(h)
31
+ except ValueError:
32
+ raise argparse.ArgumentTypeError(f"expected WIDTHxHEIGHT, got {value!r}") from None
33
+ if min(size) < 100 or max(size) > 4000:
34
+ raise argparse.ArgumentTypeError("viewport sides must be between 100 and 4000 px")
35
+ return size
36
+
37
+
38
+ def _positive(value: str) -> int:
39
+ n = int(value)
40
+ if n < 1:
41
+ raise argparse.ArgumentTypeError("must be 1 or more")
42
+ return n
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(
47
+ prog="amagi-site-diff",
48
+ description="Compare two versions of a website page by page: screenshots, pixel diffs "
49
+ "and text diffs in one markdown report.",
50
+ epilog=EPILOG,
51
+ formatter_class=argparse.RawDescriptionHelpFormatter,
52
+ )
53
+ p.add_argument("a", help="before: base URL (https://example.com) or local folder")
54
+ p.add_argument("b", help="after: base URL (https://preview.example.com) or local folder")
55
+ p.add_argument("-o", "--out", default="site-diff-report", help="report folder (default: site-diff-report)")
56
+ p.add_argument("--path", action="append", dest="paths", metavar="PATH",
57
+ help="page to start from, relative to the base (repeatable; default: /)")
58
+ p.add_argument("--no-crawl", action="store_true", help="compare only the --path pages, follow no links")
59
+ p.add_argument("--max-pages", type=_positive, default=20, help="stop after this many pages (default: 20)")
60
+ p.add_argument("--keep-query", action="store_true", help="treat ?query variants as separate pages")
61
+ p.add_argument("--viewport", type=_viewport, default=(1280, 800), metavar="WxH",
62
+ help="browser window size (default: 1280x800)")
63
+ p.add_argument("--viewport-only", action="store_true",
64
+ help="screenshot only the first screenful instead of the full page")
65
+ p.add_argument("--hide", action="append", default=[], metavar="SELECTOR",
66
+ help="CSS selector to hide on both sides before the screenshot, e.g. a cookie "
67
+ "banner or a live clock (repeatable)")
68
+ p.add_argument("--wait", type=int, default=300, metavar="MS",
69
+ help="extra wait after the page settles, in ms (default: 300)")
70
+ p.add_argument("--timeout", type=_positive, default=30, metavar="SECONDS",
71
+ help="page load timeout (default: 30)")
72
+ p.add_argument("--threshold", type=int, default=24, metavar="0-255",
73
+ help="per-channel colour difference that counts as a changed pixel (default: 24)")
74
+ p.add_argument("--min-pixels", type=int, default=25, metavar="N",
75
+ help="changed pixels needed before a page counts as visually changed (default: 25)")
76
+ p.add_argument("--channel", choices=["chrome", "msedge"],
77
+ help="use an installed Chrome or Edge instead of Playwright's Chromium")
78
+ p.add_argument("--exit-code", action="store_true", help="exit 1 when any page differs (for CI)")
79
+ p.add_argument("--version", action="version", version=f"amagi-site-diff {__version__}")
80
+ return p
81
+
82
+
83
+ def main(argv: list[str] | None = None) -> int:
84
+ for stream in (sys.stdout, sys.stderr):
85
+ with contextlib.suppress(AttributeError, ValueError):
86
+ stream.reconfigure(encoding="utf-8", errors="replace")
87
+ args = build_parser().parse_args(argv)
88
+ if not 0 <= args.threshold <= 255:
89
+ print("error: --threshold must be between 0 and 255", file=sys.stderr)
90
+ return 2
91
+ for path in args.paths or []:
92
+ # Git Bash on Windows rewrites "/pricing" to "C:/Program Files/Git/pricing" before we see it.
93
+ if re.match(r"^[A-Za-z]:[\\/]", path):
94
+ print(f"error: --path {path!r} looks like a Windows file path. If your shell converted "
95
+ f"'/page' into it (Git Bash does), write the path without the leading slash "
96
+ f"(--path pricing) or set MSYS_NO_PATHCONV=1.", file=sys.stderr)
97
+ return 2
98
+
99
+ from .capture import BrowserMissing, Capturer
100
+
101
+ opts = Options(
102
+ paths=[normalize_path(p) for p in (args.paths or ["/"])],
103
+ crawl=not args.no_crawl,
104
+ max_pages=args.max_pages,
105
+ keep_query=args.keep_query,
106
+ full_page=not args.viewport_only,
107
+ color_threshold=args.threshold,
108
+ min_pixels=max(args.min_pixels, 1),
109
+ )
110
+ with contextlib.ExitStack() as stack:
111
+ bases = []
112
+ for arg in (args.a, args.b):
113
+ if os.path.isdir(arg):
114
+ site = stack.enter_context(LocalSite(arg))
115
+ bases.append(parse_base(site.url, label=f"{arg} (local folder)"))
116
+ else:
117
+ try:
118
+ bases.append(parse_base(arg))
119
+ except ValueError as exc:
120
+ print(f"error: {exc}", file=sys.stderr)
121
+ return 2
122
+ base_a, base_b = bases
123
+ print(f"A: {base_a.label}")
124
+ print(f"B: {base_b.label}")
125
+ scope = "the given page(s) only" if args.no_crawl else f"up to {args.max_pages} pages, following links"
126
+ print(f"Comparing {scope}...")
127
+ capturer = Capturer(
128
+ viewport=args.viewport, timeout_ms=args.timeout * 1000, wait_ms=max(args.wait, 0),
129
+ hide=args.hide, channel=args.channel, full_page=not args.viewport_only,
130
+ log=lambda m: print(m, file=sys.stderr),
131
+ )
132
+ try:
133
+ stack.enter_context(capturer)
134
+ except BrowserMissing as exc:
135
+ print(f"error: {exc}", file=sys.stderr)
136
+ return 2
137
+ except Exception as exc:
138
+ print(f"error: could not start the browser: {str(exc).splitlines()[0]}", file=sys.stderr)
139
+ return 2
140
+ result = run(base_a, base_b, capturer, opts, args.out, log=print)
141
+
142
+ report_md = os.path.join(args.out, "report.md")
143
+ with open(report_md, "w", encoding="utf-8") as fh:
144
+ fh.write(render_markdown(result))
145
+ with open(os.path.join(args.out, "report.json"), "w", encoding="utf-8") as fh:
146
+ fh.write(to_json(result))
147
+ print(summary_line(result))
148
+ if result.stopped_at_limit:
149
+ print(f"Stopped at --max-pages {args.max_pages}; raise it to compare more.")
150
+ print(f"Report: {os.path.normpath(report_md)}")
151
+ if result.pages and all(p.status == "broken on both" for p in result.pages):
152
+ print("error: no page loaded on either side; check the URLs", file=sys.stderr)
153
+ return 2
154
+ for label, side in (("A", "a"), ("B", "b")):
155
+ # Not a single HTTP response: a typo in the URL or a server that is down, not missing pages.
156
+ if result.pages and all(getattr(p, side).status is None for p in result.pages):
157
+ print(f"error: {label} never responded ({getattr(result.pages[0], side).problem}); "
158
+ f"check the URL", file=sys.stderr)
159
+ return 2
160
+ return 1 if (args.exit_code and result.has_changes) else 0
@@ -0,0 +1,152 @@
1
+ """Pixel comparison of two screenshots: changed-pixel count, changed regions, diff image."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from PIL import Image, ImageChops, ImageDraw
8
+
9
+ DIFF_COLOR = (230, 0, 60)
10
+ BOX_COLOR = (0, 110, 255)
11
+
12
+
13
+ @dataclass
14
+ class Region:
15
+ x: int
16
+ y: int
17
+ w: int
18
+ h: int
19
+ pixels: int = 0 # changed pixels inside the box
20
+
21
+ @property
22
+ def area(self) -> int:
23
+ return self.w * self.h
24
+
25
+ def as_dict(self) -> dict:
26
+ return {"x": self.x, "y": self.y, "w": self.w, "h": self.h, "pixels": self.pixels}
27
+
28
+
29
+ @dataclass
30
+ class ImageDiff:
31
+ width: int
32
+ height: int
33
+ size_a: tuple[int, int]
34
+ size_b: tuple[int, int]
35
+ changed_pixels: int
36
+ regions: list[Region] = field(default_factory=list)
37
+ mask: Image.Image | None = None
38
+
39
+ @property
40
+ def total_pixels(self) -> int:
41
+ return self.width * self.height
42
+
43
+ @property
44
+ def ratio(self) -> float:
45
+ return self.changed_pixels / self.total_pixels if self.total_pixels else 0.0
46
+
47
+
48
+ def _pad(img: Image.Image, width: int, height: int) -> Image.Image:
49
+ if img.size == (width, height):
50
+ return img
51
+ canvas = Image.new("RGB", (width, height), (255, 255, 255))
52
+ canvas.paste(img, (0, 0))
53
+ return canvas
54
+
55
+
56
+ def change_mask(a: Image.Image, b: Image.Image, threshold: int = 24) -> tuple[Image.Image, tuple[int, int]]:
57
+ """Return an 'L' mask (255 = changed) over the union canvas of a and b.
58
+
59
+ A pixel counts as changed when any RGB channel differs by more than threshold
60
+ (0-255). Area that exists in only one image (a page got taller or wider) is
61
+ always marked changed.
62
+ """
63
+ a = a.convert("RGB")
64
+ b = b.convert("RGB")
65
+ width, height = max(a.width, b.width), max(a.height, b.height)
66
+ pa, pb = _pad(a, width, height), _pad(b, width, height)
67
+ r, g, bl = ImageChops.difference(pa, pb).split()
68
+ peak = ImageChops.lighter(ImageChops.lighter(r, g), bl)
69
+ mask = peak.point(lambda v: 255 if v > threshold else 0)
70
+ if a.size != b.size:
71
+ draw = ImageDraw.Draw(mask)
72
+ for img in (a, b):
73
+ if img.width < width:
74
+ draw.rectangle([img.width, 0, width - 1, height - 1], fill=255)
75
+ if img.height < height:
76
+ draw.rectangle([0, img.height, width - 1, height - 1], fill=255)
77
+ return mask, (width, height)
78
+
79
+
80
+ def find_regions(mask: Image.Image, cell: int = 16, gap: int = 0, limit: int = 12) -> list[Region]:
81
+ """Group changed pixels into boxes.
82
+
83
+ The mask is reduced to a grid of cell x cell blocks; blocks with any change are
84
+ joined with their neighbours (including diagonals, and across up to `gap` empty
85
+ blocks) and each group becomes one bounding box. Largest boxes first.
86
+ """
87
+ width, height = mask.size
88
+ cols, rows = (width + cell - 1) // cell, (height + cell - 1) // cell
89
+ # Pad to whole cells, then average each cell exactly; one changed pixel still averages to 1.
90
+ grid = Image.new("L", (cols * cell, rows * cell), 0)
91
+ grid.paste(mask, (0, 0))
92
+ grid_img = grid.reduce(cell) if cell > 1 else grid
93
+ data = grid_img.load()
94
+ hot = {(cx, cy) for cy in range(rows) for cx in range(cols) if data[cx, cy] > 0}
95
+ seen: set[tuple[int, int]] = set()
96
+ regions: list[Region] = []
97
+ reach = gap + 1
98
+ for start in sorted(hot):
99
+ if start in seen:
100
+ continue
101
+ stack, members = [start], []
102
+ seen.add(start)
103
+ while stack:
104
+ cx, cy = stack.pop()
105
+ members.append((cx, cy))
106
+ for dx in range(-reach, reach + 1):
107
+ for dy in range(-reach, reach + 1):
108
+ nb = (cx + dx, cy + dy)
109
+ if nb in hot and nb not in seen:
110
+ seen.add(nb)
111
+ stack.append(nb)
112
+ x0 = min(c[0] for c in members) * cell
113
+ y0 = min(c[1] for c in members) * cell
114
+ x1 = min((max(c[0] for c in members) + 1) * cell, width)
115
+ y1 = min((max(c[1] for c in members) + 1) * cell, height)
116
+ box = mask.crop((x0, y0, x1, y1))
117
+ tight = box.getbbox()
118
+ if tight:
119
+ x0, y0, x1, y1 = x0 + tight[0], y0 + tight[1], x0 + tight[2], y0 + tight[3]
120
+ box = mask.crop((x0, y0, x1, y1))
121
+ changed = box.histogram()[255]
122
+ if changed:
123
+ regions.append(Region(x0, y0, x1 - x0, y1 - y0, changed))
124
+ regions.sort(key=lambda r: (-r.pixels, r.y, r.x))
125
+ return regions[:limit] if limit else regions
126
+
127
+
128
+ def compare(a: Image.Image, b: Image.Image, threshold: int = 24, cell: int = 16, limit: int = 12) -> ImageDiff:
129
+ mask, (width, height) = change_mask(a, b, threshold)
130
+ changed = mask.histogram()[255]
131
+ regions = find_regions(mask, cell=cell, limit=limit) if changed else []
132
+ return ImageDiff(width, height, a.size, b.size, changed, regions, mask)
133
+
134
+
135
+ def render_diff(b: Image.Image, diff: ImageDiff) -> Image.Image:
136
+ """B faded to light grey, changed pixels in red, numbered blue boxes around regions."""
137
+ base = _pad(b.convert("RGB"), diff.width, diff.height).convert("L")
138
+ base = base.point(lambda v: int(v * 0.35 + 160)).convert("RGB")
139
+ out = base
140
+ if diff.mask is not None and diff.changed_pixels:
141
+ out = Image.composite(Image.new("RGB", base.size, DIFF_COLOR), base, diff.mask)
142
+ draw = ImageDraw.Draw(out)
143
+ for n, r in enumerate(diff.regions, 1):
144
+ pad = 3
145
+ box = [max(r.x - pad, 0), max(r.y - pad, 0),
146
+ min(r.x + r.w + pad, diff.width - 1), min(r.y + r.h + pad, diff.height - 1)]
147
+ draw.rectangle(box, outline=BOX_COLOR, width=2)
148
+ label = str(n)
149
+ tx, ty = box[0], max(box[1] - 14, 0)
150
+ draw.rectangle([tx, ty, tx + 8 * len(label) + 6, ty + 13], fill=BOX_COLOR)
151
+ draw.text((tx + 3, ty + 1), label, fill=(255, 255, 255))
152
+ return out
@@ -0,0 +1,58 @@
1
+ """Name the page element behind a changed region, so the report says what changed, not just where."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .imagediff import Region
6
+
7
+ # Wrappers that contain almost everything; naming them tells the reader nothing.
8
+ _TOO_BROAD = {"html", "body"}
9
+
10
+
11
+ def _box(el: dict) -> tuple[float, float, float, float]:
12
+ return el["x"], el["y"], el["x"] + el["w"], el["y"] + el["h"]
13
+
14
+
15
+ def _tag(el: dict) -> str:
16
+ return el.get("sel", "").split(" > ")[-1].split("#")[0].split(".")[0].lower()
17
+
18
+
19
+ def element_for(region: Region, elements: list[dict], tolerance: float = 4.0) -> dict | None:
20
+ """Pick the element that best explains a changed region.
21
+
22
+ Candidates are the smallest element whose box contains the whole region
23
+ (within `tolerance` px) and the element whose box overlaps the region best
24
+ (intersection over union). The container wins unless it is much bigger than
25
+ the region and some element overlaps it well, which happens when an element
26
+ changed size: the region is then the union of its old and new boxes.
27
+ """
28
+ rx0, ry0, rx1, ry1 = region.x, region.y, region.x + region.w, region.y + region.h
29
+ container, container_area = None, None
30
+ overlap, overlap_score = None, 0.0
31
+ for el in elements:
32
+ if _tag(el) in _TOO_BROAD:
33
+ continue
34
+ x0, y0, x1, y1 = _box(el)
35
+ area = el["w"] * el["h"]
36
+ if x0 - tolerance <= rx0 and y0 - tolerance <= ry0 and x1 + tolerance >= rx1 and y1 + tolerance >= ry1:
37
+ if container_area is None or area < container_area:
38
+ container, container_area = el, area
39
+ ix = max(0.0, min(x1, rx1) - max(x0, rx0))
40
+ iy = max(0.0, min(y1, ry1) - max(y0, ry0))
41
+ inter = ix * iy
42
+ if inter:
43
+ union = area + region.area - inter
44
+ score = inter / union if union else 0.0
45
+ if score > overlap_score:
46
+ overlap, overlap_score = el, score
47
+ if container is not None and (container_area <= 8 * max(region.area, 1) or overlap_score < 0.3):
48
+ return container
49
+ return overlap or container
50
+
51
+
52
+ def describe(el: dict | None, max_text: int = 60) -> str:
53
+ if not el:
54
+ return "(no element found)"
55
+ text = (el.get("text") or "").strip()
56
+ if len(text) > max_text:
57
+ text = text[: max_text - 3].rstrip() + "..."
58
+ return f"`{el['sel']}`" + (f' "{text}"' if text else "")