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.
- amagi_site_diff/__init__.py +3 -0
- amagi_site_diff/__main__.py +3 -0
- amagi_site_diff/capture.py +271 -0
- amagi_site_diff/cli.py +160 -0
- amagi_site_diff/imagediff.py +152 -0
- amagi_site_diff/locate.py +58 -0
- amagi_site_diff/report.py +182 -0
- amagi_site_diff/runner.py +262 -0
- amagi_site_diff/textdiff.py +43 -0
- amagi_site_diff/urls.py +131 -0
- amagi_site_diff-0.1.0.dist-info/METADATA +194 -0
- amagi_site_diff-0.1.0.dist-info/RECORD +16 -0
- amagi_site_diff-0.1.0.dist-info/WHEEL +5 -0
- amagi_site_diff-0.1.0.dist-info/entry_points.txt +2 -0
- amagi_site_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- amagi_site_diff-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 "")
|