"""Phone check for a small website (Vonk, project 85). One page, on request of its owner. Usage: python site_check.py Writes /report.json, report.md, phone.png (portrait top of page), card.png (1200x675, <1 MB), share.png (mock link card, when there is a title or image). What it checks, on an emulated iPhone 13 (390x844, touch, DPR 3) and sideways (750x342): viewport present (else the phone shows a tiny desktop page) sideways page wider than the screen (sideways scroll), with the widest elements that stick out text visible text smaller than 12 px as the phone actually shows it taps links/buttons/inputs smaller than 24x24 CSS px (WCAG 2.2 target size, inline links in a sentence exempt) alt with no alt attribute at all (alt="" is fine: decorative) links same-site links that answer 4xx/5xx (at most 30 checked, 0.3 s apart) missing page resources (images, css, js) that failed or answered 4xx/5xx errors JavaScript errors while loading mixed http:// resources on an https page basics , <html lang>, total transfer size, load time share will a link card show? Reads the RAW html like Bluesky/Mastodon/Discord/X card fetchers (no JavaScript): og:title (or <title>), og:image present + loads + is an image, tags not only added by JavaScript. Notes (no fail): no description, small or oddly shaped image, relative image path, no twitter:card. Writes share.png, a mock of the card. It never logs in, fills forms or follows links beyond one request each. The user agent says who I am. """ import io import json import sys from html.parser import HTMLParser import time import urllib.request import urllib.error from pathlib import Path from urllib.parse import urljoin, urlparse, urldefrag from playwright.sync_api import sync_playwright UA_TAG = "VonkSiteCheck/1.1 (AI character, checks a page on its owner's request)" MAX_LINKS = 30 OWN_HOST = "npub1f3978m4usqmwtxs6alrzv2fg6076qlh4yku74xh4qrudzqqw4u9qjw3kqw.nsite.lol" # Vonk's own site PAGE_JS = r""" () => { const vw = window.innerWidth, scale = screen.width / document.documentElement.clientWidth; const out = {innerWidth: vw, clientWidth: document.documentElement.clientWidth, screenWidth: screen.width, scale, scrollWidth: document.documentElement.scrollWidth, title: document.title || '', lang: document.documentElement.getAttribute('lang') || ''}; const vp = document.querySelector('meta[name="viewport" i]'); out.viewport = vp ? (vp.getAttribute('content') || '') : null; const desc = el => { let s = el.tagName.toLowerCase(); if (el.id) s += '#' + el.id; else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.'); return s; }; const visible = el => { const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity === 0) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; // sideways: elements whose right edge passes the layout width (skip ones inside a scroll box) const wide = []; const lw = document.documentElement.clientWidth; for (const el of document.body ? document.body.querySelectorAll('*') : []) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.right <= lw + 1) continue; let p = el.parentElement, clipped = false; while (p && p !== document.body) { const ox = getComputedStyle(p).overflowX; if (ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip') { clipped = true; break; } p = p.parentElement; } if (clipped) continue; // report the outermost offender only let parentOff = el.parentElement && el.parentElement.getBoundingClientRect().right > lw + 1 && el.parentElement !== document.body; if (!parentOff) wide.push({el: desc(el), right: Math.round(r.right), width: Math.round(r.width)}); } wide.sort((a, b) => b.right - a.right); out.wide = wide.slice(0, 5); // small text: elements with their own visible text const small = []; let textEls = 0; for (const el of document.body ? document.body.querySelectorAll('*') : []) { const own = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(' ').trim(); if (!own || !visible(el)) continue; textEls++; const px = parseFloat(getComputedStyle(el).fontSize) * scale; if (px < 12) small.push({el: desc(el), px: Math.round(px * 10) / 10, text: own.slice(0, 40)}); } out.textEls = textEls; out.small = small; // tap targets const taps = []; let tapCount = 0; for (const el of document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, [role=button]')) { if (!visible(el)) continue; tapCount++; let r = el.getBoundingClientRect(); const lab = el.closest('label') || (el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]')); if (lab && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { // tapping the label works too const lr = lab.getBoundingClientRect(); if (lr.width * lr.height > r.width * r.height) r = lr; } const w = r.width * scale, h = r.height * scale; if (w >= 24 && h >= 24) continue; if (el.tagName === 'A' && getComputedStyle(el).display === 'inline') { const block = el.parentElement; const all = (block ? block.textContent : '').replace(/\s+/g, ' ').trim(); const mine = el.textContent.replace(/\s+/g, ' ').trim(); if (all.length > mine.length + 15) continue; // inline link inside a sentence: WCAG exception } taps.push({el: desc(el), w: Math.round(w), h: Math.round(h), text: ((el.textContent || '').trim() || el.value || el.getAttribute('aria-label') || (el.querySelector('img') ? 'image link' : '')).trim().slice(0, 30)}); } out.tapCount = tapCount; out.taps = taps; // images out.imgCount = document.images.length; out.noAlt = [...document.images].filter(i => !i.hasAttribute('alt')).map(i => (i.currentSrc || i.src).slice(-60)); out.brokenImg = [...document.images].filter(i => i.complete && i.naturalWidth === 0 && (i.currentSrc || i.src)) .map(i => (i.currentSrc || i.src).slice(-60)); out.links = [...document.querySelectorAll('a[href]')].map(a => a.href); // link-preview tags as they are AFTER JavaScript ran (compared with the raw HTML later) out.metas = {}; for (const m of document.querySelectorAll('meta[property], meta[name]')) { const k = (m.getAttribute('property') || m.getAttribute('name') || '').toLowerCase(); if (/^(og:|twitter:|description$)/.test(k) && !(k in out.metas)) out.metas[k] = m.getAttribute('content') || ''; } return out; } """ def head_status(url, ua): for method in ("HEAD", "GET"): req = urllib.request.Request(url, method=method, headers={"User-Agent": ua}) try: with urllib.request.urlopen(req, timeout=10) as r: return r.status except urllib.error.HTTPError as e: if method == "HEAD" and e.code in (403, 405, 501): continue return e.code except Exception as e: # timeouts, DNS return f"error: {type(e).__name__}" return None class _MetaParser(HTMLParser): """Collects <meta property/name=og:*, twitter:*, description> and <title> from raw HTML (no JavaScript).""" def __init__(self): super().__init__(convert_charrefs=True) self.metas, self.title, self._in_title = {}, "", False def handle_starttag(self, tag, attrs): a = {k.lower(): (v or "") for k, v in attrs} if tag == "meta": k = (a.get("property") or a.get("name") or "").lower() if (k.startswith("og:") or k.startswith("twitter:") or k == "description") and k not in self.metas: self.metas[k] = a.get("content", "") elif tag == "title" and not self.title: self._in_title = True def handle_endtag(self, tag): if tag == "title": self._in_title = False def handle_data(self, data): if self._in_title: self.title += data SHARE_UA = "Mozilla/5.0 (compatible; VonkSiteCheck/1.1; link-preview test like a card fetcher, no JavaScript)" def fetch_raw(url, limit=3_000_000): """What a link-card fetcher gets: the HTML as served, no JavaScript run.""" req = urllib.request.Request(url, headers={"User-Agent": SHARE_UA, "Accept": "text/html,*/*"}) with urllib.request.urlopen(req, timeout=15) as r: raw = r.read(limit) cs = r.headers.get_content_charset() or "utf-8" return r.status, r.geturl(), raw.decode(cs, errors="replace") def fetch_image(url, limit=12_000_000): """Status, content type, bytes and pixel size of the preview image.""" out = {"url": url, "status": None, "type": "", "bytes": 0, "w": 0, "h": 0, "error": ""} try: req = urllib.request.Request(url, headers={"User-Agent": SHARE_UA, "Accept": "image/*"}) with urllib.request.urlopen(req, timeout=15) as r: out["status"], out["type"] = r.status, (r.headers.get("Content-Type") or "").split(";")[0].strip() data = r.read(limit) out["bytes"] = len(data) from PIL import Image im = Image.open(io.BytesIO(data)) out["w"], out["h"] = im.size out["_img"] = im.convert("RGB") except urllib.error.HTTPError as e: out["status"] = e.code except Exception as e: out["error"] = f"{type(e).__name__}: {str(e)[:80]}" return out def share_check(url, rendered_metas): """Will a link card show when this URL is shared on Bluesky, Mastodon, Discord or X? Returns (ok, line, info) where info holds the raw tags, image facts and the notes.""" info = {"raw": {}, "rendered": rendered_metas or {}, "image": None, "notes": [], "faults": []} try: status, final, html = fetch_raw(url) except urllib.error.HTTPError as e: info["faults"].append(f"card fetchers get HTTP {e.code} for this page") return False, info["faults"][0], info except Exception as e: info["faults"].append(f"card fetchers could not load the page ({type(e).__name__})") return False, info["faults"][0], info p = _MetaParser() try: p.feed(html) except Exception: pass m = p.metas info["raw"], info["raw_title"] = m, " ".join(p.title.split()) title = m.get("og:title") or m.get("twitter:title") or info["raw_title"] desc = m.get("og:description") or m.get("twitter:description") or m.get("description", "") img = m.get("og:image") or m.get("og:image:url") or m.get("twitter:image") or "" info.update(title=title, desc=desc, domain=urlparse(final).netloc) r = info["rendered"] js_only = [k for k in ("og:title", "og:image", "og:description", "twitter:card") if r.get(k) and not m.get(k)] if js_only: info["js_only"] = js_only if not title: info["faults"].append("no title for the card (no og:title, no <title>)") if not img: info["faults"].append("no preview image (og:image)" + (": it is only added by JavaScript, which card fetchers don't run" if "og:image" in js_only else "")) else: if not urlparse(img).scheme: info["notes"].append(f"og:image is a relative path ({img[:40]}); the Open Graph spec asks for a full https:// address") absu = urljoin(final, img) fi = fetch_image(absu) info["image"] = {k: v for k, v in fi.items() if k != "_img"} info["_img"] = fi.get("_img") if fi["error"] and not fi["status"]: info["faults"].append(f"preview image did not load ({fi['error'][:50]})") elif not isinstance(fi["status"], int) or fi["status"] >= 400: info["faults"].append(f"preview image answers HTTP {fi['status']}: {absu.rsplit('/', 1)[-1][:40]}") elif not fi["w"]: info["faults"].append(f"preview image is not a readable image ({fi['type'] or 'unknown type'})") else: if fi["w"] < 600 or fi["h"] < 315: info["notes"].append(f"image is {fi['w']}x{fi['h']}; 1200x630 shows sharp in big cards") ratio = fi["w"] / fi["h"] if not 1.6 <= ratio <= 2.2: info["notes"].append(f"image ratio {ratio:.2f}:1 gets cropped to about 1.91:1 in big cards") if fi["bytes"] > 5_000_000: info["notes"].append(f"image is {fi['bytes'] // 1_000_000} MB; X refuses card images over 5 MB") if js_only and "og:image" not in js_only: info["faults"].append(f"{', '.join(js_only)} only added by JavaScript; card fetchers read the raw HTML") elif len(js_only) > 1: info["notes"].append(f"also only added by JavaScript: {', '.join(k for k in js_only if k != 'og:image')}") if not desc: info["notes"].append("no description (og:description or meta description)") if not m.get("twitter:card"): info["notes"].append('no twitter:card (X shows a big image only with content="summary_large_image")') ok = not info["faults"] if ok: im = info["image"] line = f"link card shows: title, {'description, ' if desc else ''}image {im['w']}x{im['h']}" else: line = "; ".join(info["faults"]) return ok, line, info def make_share_png(info, path): """A mock of the link card as Bluesky draws it: 1.91:1 image on top, title, description, domain.""" from PIL import Image, ImageDraw, ImageFont W, IH = 600, 314 F = "C:/Windows/Fonts/" f_b = ImageFont.truetype(F + "segoeuib.ttf", 19) f_r = ImageFont.truetype(F + "segoeui.ttf", 16) lines_d = _wrap(info.get("desc", ""), f_r, W - 32, 2) lines_t = _wrap(info.get("title", "") or "(no title)", f_b, W - 32, 2) img = info.get("_img") top = IH if img else 0 H = top + 16 + 26 * len(lines_t) + 22 * len(lines_d) + 40 card = Image.new("RGB", (W, H), (255, 255, 255)) if img: r = max(W / img.width, IH / img.height) sc = img.resize((max(W, round(img.width * r)), max(IH, round(img.height * r))), Image.LANCZOS) x, y = (sc.width - W) // 2, (sc.height - IH) // 2 card.paste(sc.crop((x, y, x + W, y + IH)), (0, 0)) dr = ImageDraw.Draw(card) y = top + 12 dr.text((16, y), info.get("domain", ""), font=f_r, fill=(110, 118, 130)) y += 26 for ln in lines_t: dr.text((16, y), ln, font=f_b, fill=(20, 24, 30)) y += 26 for ln in lines_d: dr.text((16, y), ln, font=f_r, fill=(70, 78, 90)) y += 22 out = Image.new("RGB", (W + 2, H + 2), (200, 205, 212)) out.paste(card, (1, 1)) out.save(path, optimize=True) return out def _wrap(text, font, width, max_lines): words, lines, cur = " ".join((text or "").split()).split(" "), [], "" for w in words: t = (cur + " " + w).strip() if font.getlength(t) <= width: cur = t continue if cur: lines.append(cur) cur = w if len(lines) == max_lines: break if cur and len(lines) < max_lines: lines.append(cur) if len(lines) == max_lines and " ".join(lines) != " ".join(words): while lines[-1] and font.getlength(lines[-1] + "…") > width: lines[-1] = lines[-1][:-1] lines[-1] = lines[-1].rstrip() + "…" return [l for l in lines if l] def load(p, url, device, ua_extra): br = p.chromium.launch() dev = dict(p.devices[device]) dev["user_agent"] = dev["user_agent"] + " " + ua_extra ctx = br.new_context(**dev) if OWN_HOST in url: # My own pages carry a hits.sh visit counter; my checks must not count as visits. ctx.route("https://hits.sh/**", lambda r: r.fulfill( status=200, content_type="image/svg+xml", body='<svg xmlns="http://www.w3.org/2000/svg" width="140" height="20"></svg>')) pg = ctx.new_page() errors, failed, sizes, mixed = [], [], [], [] pg.on("pageerror", lambda e: errors.append(str(e).split("\n")[0][:160])) def on_resp(r): try: if r.status >= 400 and r.request.resource_type != "document": failed.append({"url": r.url[-80:], "status": r.status, "type": r.request.resource_type}) except Exception: pass def on_fin(req): try: s = req.sizes() sizes.append(s.get("responseBodySize", 0) + s.get("responseHeadersSize", 0)) except Exception: pass if url.startswith("https://") and req.url.startswith("http://"): mixed.append(req.url[-80:]) pg.on("response", on_resp) def on_failed(r): why = r.failure or "" if "ERR_ABORTED" in why: # the browser cancelled it itself (video preload, superseded request): not a fault return failed.append({"url": r.url[-80:], "status": why or "failed", "type": r.resource_type}) pg.on("requestfailed", on_failed) pg.on("requestfinished", on_fin) t0 = time.time() resp = pg.goto(url, wait_until="load", timeout=30000) load_s = time.time() - t0 try: pg.wait_for_load_state("networkidle", timeout=5000) except Exception: pass data = pg.evaluate(PAGE_JS) data.update(status=resp.status if resp else None, final_url=pg.url, load_s=round(load_s, 2), errors=errors, failed=failed, bytes=sum(sizes), mixed=mixed) return br, pg, data def check(url, outdir, links_on=True): outdir = Path(outdir) outdir.mkdir(parents=True, exist_ok=True) with sync_playwright() as p: br, pg, d = load(p, url, "iPhone 13", UA_TAG) pg.screenshot(path=str(outdir / "phone.png")) # first screen, what a visitor sees ua = pg.evaluate("navigator.userAgent") br.close() br2, pg2, dl = load(p, url, "iPhone 13 landscape", UA_TAG) br2.close() # same-site links host = urlparse(d["final_url"]).netloc seen, links = set(), [] for h in d.pop("links"): h, _ = urldefrag(h) u = urlparse(h) if u.scheme in ("http", "https") and u.netloc == host and h not in seen and h != d["final_url"]: seen.add(h) links.append(h) bad_links = [] for h in (links[:MAX_LINKS] if links_on else []): st = head_status(h, ua) if not isinstance(st, int) or st >= 400: bad_links.append({"url": h, "status": st}) time.sleep(0.3) vp = d["viewport"] findings = [] # (key, ok, line) def add(key, ok, line): findings.append({"key": key, "ok": ok, "line": line}) if d["status"] and d["status"] >= 400: add("page", False, f"the page itself answers HTTP {d['status']}") add("viewport", bool(vp and "device-width" in vp), "viewport meta tag present" if vp and "device-width" in vp else "no viewport meta tag: phones show a shrunken desktop page" if vp is None else f"viewport is '{vp}' (no width=device-width)") over = d["scrollWidth"] > d["clientWidth"] + 1 over_l = dl["scrollWidth"] > dl["clientWidth"] + 1 wtxt = ", ".join(f"{w['el']} ({w['width']} px)" for w in d["wide"][:3]) add("sideways", not over and not over_l, "no sideways scroll, upright or sideways" if not (over or over_l) else f"page is {d['scrollWidth']} px wide on a {d['clientWidth']} px screen" + (f"; sticking out: {wtxt}" if wtxt else "") if over else f"sideways scroll only in landscape ({dl['scrollWidth']} > {dl['clientWidth']} px)") add("text", not d["small"], "all text 12 px or larger on the phone" if not d["small"] else f"{len(d['small'])} of {d['textEls']} text blocks under 12 px on the phone (smallest {min(s['px'] for s in d['small'])} px)") add("taps", not d["taps"], f"all {d['tapCount']} links/buttons at least 24x24 px" if not d["taps"] else f"{len(d['taps'])} of {d['tapCount']} links/buttons smaller than 24x24 px (e.g. " + ", ".join(f"'{t['text'] or t['el']}' {t['w']}x{t['h']}" for t in d["taps"][:3]) + ")") add("alt", not d["noAlt"], f"all {d['imgCount']} images have alt text (or alt=\"\")" if not d["noAlt"] else f"{len(d['noAlt'])} of {d['imgCount']} images have no alt attribute") add("links", not bad_links, (f"{min(len(links), MAX_LINKS)} same-site links checked, none broken" if links_on else f"{len(links)} same-site links found, not checked (owner did not ask)") if not bad_links else f"{len(bad_links)} broken same-site link(s): " + ", ".join(f"{urlparse(b['url']).path} ({b['status']})" for b in bad_links[:3])) miss = [{"url": u, "status": "image did not load", "type": "image"} for u in d["brokenImg"]] + d["failed"] dedup = {m["url"]: m for m in miss} miss = list(dedup.values()) add("missing", not miss, "every image, stylesheet and script loaded" if not miss else f"{len(miss)} file(s) failed to load: " + ", ".join(f"{m['url'].rsplit('/', 1)[-1] or m['url']} ({m['status']})" for m in miss[:3])) add("errors", not d["errors"], "no JavaScript errors" if not d["errors"] else f"{len(d['errors'])} JavaScript error(s): {d['errors'][0][:80]}") if d["final_url"].startswith("https://"): add("mixed", not d["mixed"], "no http:// files on the https page" if not d["mixed"] else f"{len(d['mixed'])} http:// file(s) on an https page (browsers may block them)") add("basics", bool(d["title"]) and bool(d["lang"]), f"title and <html lang> set" if d["title"] and d["lang"] else "missing: " + " and ".join(x for x, ok in (("<title>", d["title"]), ("<html lang> (screen readers guess the language)", d["lang"])) if not ok)) sh_ok, sh_line, share = share_check(d["final_url"], d.get("metas")) add("share", sh_ok, sh_line) if share.get("title") or share.get("_img") is not None: make_share_png(share, outdir / "share.png") share.pop("_img", None) kb = d["bytes"] / 1024 report = {"url": url, "final_url": d["final_url"], "status": d["status"], "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "load_s": d["load_s"], "kb": round(kb), "findings": findings, "portrait": d, "landscape": {k: dl[k] for k in ("scrollWidth", "clientWidth", "wide")}, "bad_links": bad_links, "share": share, "links_checked": min(len(links), MAX_LINKS) if links_on else 0, "links_found": len(links)} (outdir / "report.json").write_text(json.dumps(report, indent=1), encoding="utf-8") write_md(report, outdir / "report.md") make_card(report, outdir / "phone.png", outdir / "card.png") return report FIX = { "page": "Check the address; the server says the page is not there.", "viewport": 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> inside <head>.', "sideways": "Give wide things max-width:100% (images: img{max-width:100%;height:auto}); swap fixed widths like width:800px for max-width:800px.", "text": "Raise the smallest font-size to at least 12 px (body text 16 px reads best on phones).", "taps": "Pad small links/buttons (e.g. padding:6px 10px or min-height:24px) or space them 24 px apart.", "alt": 'Add alt="what the picture shows", or alt="" if it is decoration.', "links": "Fix or remove the links listed above.", "missing": "Check the file names listed above (case matters on most hosts).", "errors": "Open the browser console on the page to see the line.", "mixed": "Change those http:// addresses to https://.", "basics": 'Add <title>... and (or your language).', "share": ('In the raw HTML (not added by JavaScript): , ' ', ' '(a 1200x630 PNG or JPG that loads), and .'), } def write_md(r, path): bad = [f for f in r["findings"] if not f["ok"]] lines = [f"# Phone check: {r['final_url']}", "", f"Checked {r['checked_at']} on an emulated iPhone 13 (390x844) and sideways (750x342). " f"{r['kb']} KB transferred, loaded in {r['load_s']} s.", "", f"**{len(r['findings']) - len(bad)} of {len(r['findings'])} checks fine.**", ""] for f in r["findings"]: lines.append(f"- {'OK ' if f['ok'] else 'FIX'} {f['key']}: {f['line']}") if not f["ok"]: lines.append(f" - how: {FIX[f['key']]}") if f["key"] == "share": for n in r.get("share", {}).get("notes", []): lines.append(f" - note: {n}") lines += ["", "Checked by Vonk, an AI character (owner: Rick). One page load per orientation" + (" plus one light request per same-site link." if r["links_checked"] else "; links not requested.") + " The link-card check reads the raw HTML once and the preview image once, like a card fetcher (share.png shows the card)."] path.write_text("\n".join(lines) + "\n", encoding="utf-8") def make_card(r, phone_png, card_png): from PIL import Image, ImageDraw, ImageFont W, H = 1200, 675 bg, ink, amber, teal, red, dim = (16, 24, 38), (236, 232, 222), (247, 173, 61), (52, 168, 160), (232, 96, 80), (150, 160, 175) im = Image.new("RGB", (W, H), bg) dr = ImageDraw.Draw(im) F = "C:/Windows/Fonts/" f_t = ImageFont.truetype(F + "segoeuib.ttf", 34) f_u = ImageFont.truetype(F + "consola.ttf", 20) f_l = ImageFont.truetype(F + "segoeui.ttf", 21) f_s = ImageFont.truetype(F + "segoeui.ttf", 17) ph = Image.open(phone_png).convert("RGB") ph_h = 600 ph = ph.resize((round(ph.width * ph_h / ph.height), ph_h), Image.LANCZOS) px, py = 40, (H - ph_h) // 2 dr.rounded_rectangle((px - 12, py - 12, px + ph.width + 12, py + ph_h + 12), 28, fill=(40, 48, 62), outline=amber, width=3) im.paste(ph, (px, py)) x0 = px + ph.width + 50 dr.text((x0, 34), "Phone check", font=f_t, fill=amber) u = r["final_url"] u = u if len(u) <= 58 else u[:55] + "..." dr.text((x0, 84), u, font=f_u, fill=dim) y = 124 n = len(r["findings"]) step = min(50, (H - 60 - 124) // n) # 11 rows -> 44 px, 12 -> 40 px: the footer stays clear names = {"page": "Page loads", "viewport": "Viewport tag", "sideways": "No sideways scroll", "text": "Readable text", "taps": "Tap targets", "alt": "Image alt text", "links": "Links", "missing": "Files load", "errors": "No JS errors", "mixed": "No mixed content", "basics": "Title + language", "share": "Link card (Bluesky, Mastodon, X)"} for f in r["findings"]: col = teal if f["ok"] else red dr.ellipse((x0, y + 6, x0 + 16, y + 22), fill=col) dr.text((x0 + 28, y), names[f["key"]], font=f_l, fill=ink) line = f["line"] if len(f["line"]) <= 70 else f["line"][:67] + "..." dr.text((x0 + 28, y + 25), line, font=f_s, fill=dim) y += step ok = sum(f["ok"] for f in r["findings"]) dr.text((x0, H - 46), f"{ok}/{len(r['findings'])} fine · iPhone 13 emulated · by Vonk, an AI character", font=f_s, fill=amber) im.save(card_png, optimize=True) if card_png.stat().st_size > 950_000: im.convert("P", palette=Image.ADAPTIVE, colors=128).save(card_png, optimize=True) if __name__ == "__main__": rep = check(sys.argv[1], sys.argv[2], links_on="--no-links" not in sys.argv) print(Path(sys.argv[2], "report.md").read_text(encoding="utf-8"))