|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""One-shot favicon-first refinement for tiny provider artwork. |
| 3 | +
|
| 4 | +At Nuvio stream/plugin sizes, square site icons are often more readable than full |
| 5 | +wordmarks. This migration only uses provider site/hub URLs already known to the |
| 6 | +repository, prefers declared/apple/root favicons, writes the two committed WebP |
| 7 | +sizes, updates provenance, and is deleted by its workflow after success. |
| 8 | +""" |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import hashlib |
| 12 | +import io |
| 13 | +import json |
| 14 | +import re |
| 15 | +import ssl |
| 16 | +import time |
| 17 | +from html.parser import HTMLParser |
| 18 | +from pathlib import Path |
| 19 | +from typing import Any |
| 20 | +from urllib.parse import quote, urljoin, urlparse |
| 21 | +from urllib.request import Request, urlopen |
| 22 | + |
| 23 | +from PIL import Image |
| 24 | + |
| 25 | +try: |
| 26 | + import cairosvg |
| 27 | +except Exception: |
| 28 | + cairosvg = None |
| 29 | + |
| 30 | +ROOT = Path(__file__).resolve().parents[1] |
| 31 | +INDEX = ROOT / "assets/providers/index.json" |
| 32 | +MANIFEST = ROOT / "manifest.json" |
| 33 | +OVERRIDES = ROOT / "provider-overrides.json" |
| 34 | +HUBS = ROOT / "provider-hubs.json" |
| 35 | +TARGETS = ((72, 32), (96, 40)) |
| 36 | +RAW_BASE = "https://raw.githubusercontent.com/niakw/NiakVIO/main/assets/providers" |
| 37 | +UA = "Mozilla/5.0 (compatible; NiakVIO-FaviconRefine/1.0)" |
| 38 | +TIMEOUT = 8 |
| 39 | +MAX_BYTES = 3 * 1024 * 1024 |
| 40 | +PAGE_BYTES = 1024 * 1024 |
| 41 | + |
| 42 | + |
| 43 | +def load(path: Path, default: Any) -> Any: |
| 44 | + try: |
| 45 | + return json.loads(path.read_text(encoding="utf-8")) |
| 46 | + except Exception: |
| 47 | + return default |
| 48 | + |
| 49 | + |
| 50 | +def norm_id(value: Any) -> str: |
| 51 | + return str(value or "").strip().casefold() |
| 52 | + |
| 53 | + |
| 54 | +def fetch(url: str, limit: int = MAX_BYTES) -> tuple[bytes, str, str]: |
| 55 | + req = Request(url, headers={"User-Agent": UA, "Accept": "image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8"}) |
| 56 | + with urlopen(req, timeout=TIMEOUT, context=ssl.create_default_context()) as response: |
| 57 | + data = response.read(limit + 1) |
| 58 | + if len(data) > limit: |
| 59 | + raise ValueError("response_too_large") |
| 60 | + return data, str(response.headers.get("content-type") or "").split(";", 1)[0].casefold(), str(response.geturl() or url) |
| 61 | + |
| 62 | + |
| 63 | +class IconParser(HTMLParser): |
| 64 | + def __init__(self, base: str) -> None: |
| 65 | + super().__init__(convert_charrefs=True) |
| 66 | + self.base = base |
| 67 | + self.items: list[tuple[str, str, int]] = [] |
| 68 | + |
| 69 | + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: |
| 70 | + if tag.casefold() != "link": |
| 71 | + return |
| 72 | + values = {str(k).casefold(): str(v or "") for k, v in attrs} |
| 73 | + rel = values.get("rel", "").casefold() |
| 74 | + href = values.get("href", "").strip() |
| 75 | + if not href: |
| 76 | + return |
| 77 | + if "apple-touch-icon" in rel: |
| 78 | + self.items.append((urljoin(self.base, href), "page_apple_touch_icon", 170)) |
| 79 | + elif "icon" in rel: |
| 80 | + sizes = values.get("sizes", "").casefold() |
| 81 | + bonus = 10 if any(token in sizes for token in ("128", "192", "256", "512")) else 0 |
| 82 | + self.items.append((urljoin(self.base, href), "page_icon", 155 + bonus)) |
| 83 | + |
| 84 | + |
| 85 | +def page_icons(page: str) -> list[tuple[str, str, int]]: |
| 86 | + url = str(page or "").strip() |
| 87 | + if not url.startswith(("http://", "https://")): |
| 88 | + return [] |
| 89 | + final = url |
| 90 | + items: list[tuple[str, str, int]] = [] |
| 91 | + try: |
| 92 | + data, content_type, final = fetch(url, PAGE_BYTES) |
| 93 | + if "html" in content_type or data.lstrip()[:16].lower().startswith((b"<!doctype", b"<html")): |
| 94 | + parser = IconParser(final) |
| 95 | + parser.feed(data.decode("utf-8", errors="ignore")) |
| 96 | + items.extend(parser.items) |
| 97 | + except Exception: |
| 98 | + pass |
| 99 | + parsed = urlparse(final) |
| 100 | + if parsed.scheme and parsed.netloc: |
| 101 | + root = f"{parsed.scheme}://{parsed.netloc}/" |
| 102 | + items.extend([ |
| 103 | + (urljoin(root, "apple-touch-icon.png"), "root_apple_touch_icon", 150), |
| 104 | + (urljoin(root, "favicon-192x192.png"), "root_favicon_192", 148), |
| 105 | + (urljoin(root, "favicon-128x128.png"), "root_favicon_128", 146), |
| 106 | + (urljoin(root, "favicon.png"), "root_favicon_png", 140), |
| 107 | + (urljoin(root, "favicon.ico"), "root_favicon", 132), |
| 108 | + (f"https://www.google.com/s2/favicons?domain={quote(parsed.hostname or parsed.netloc)}&sz=128", "google_site_favicon", 120), |
| 109 | + ]) |
| 110 | + seen: set[str] = set() |
| 111 | + out: list[tuple[str, str, int]] = [] |
| 112 | + for item in items: |
| 113 | + if item[0] in seen: |
| 114 | + continue |
| 115 | + seen.add(item[0]) |
| 116 | + out.append(item) |
| 117 | + return out |
| 118 | + |
| 119 | + |
| 120 | +def open_image(data: bytes, content_type: str, url: str) -> Image.Image: |
| 121 | + is_svg = "svg" in content_type or urlparse(url).path.casefold().endswith(".svg") or data.lstrip().startswith(b"<svg") |
| 122 | + if is_svg: |
| 123 | + if cairosvg is None: |
| 124 | + raise ValueError("svg_without_cairosvg") |
| 125 | + data = cairosvg.svg2png(bytestring=data, output_width=512, output_height=512) |
| 126 | + with Image.open(io.BytesIO(data)) as source: |
| 127 | + source.load() |
| 128 | + image = source.convert("RGBA") |
| 129 | + bbox = image.getbbox() |
| 130 | + if bbox is None: |
| 131 | + raise ValueError("empty_image") |
| 132 | + image = image.crop(bbox) |
| 133 | + if min(image.size) < 16: |
| 134 | + raise ValueError("icon_too_small") |
| 135 | + return image |
| 136 | + |
| 137 | + |
| 138 | +def score(image: Image.Image, base: int) -> int: |
| 139 | + w, h = image.size |
| 140 | + ratio = w / max(1, h) |
| 141 | + square = 35 if 0.75 <= ratio <= 1.35 else 15 if 0.55 <= ratio <= 1.8 else -30 |
| 142 | + size_bonus = 35 if min(w, h) >= 128 else 25 if min(w, h) >= 64 else 12 if min(w, h) >= 32 else 0 |
| 143 | + return base + square + size_bonus |
| 144 | + |
| 145 | + |
| 146 | +def render(image: Image.Image, width: int, height: int) -> Image.Image: |
| 147 | + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) |
| 148 | + work = image.copy() |
| 149 | + # Icon-first rendering: fill the available height instead of shrinking a |
| 150 | + # complete wordmark across the full card width. |
| 151 | + work.thumbnail((height - 4, height - 4), Image.Resampling.LANCZOS) |
| 152 | + x = (width - work.width) // 2 |
| 153 | + y = (height - work.height) // 2 |
| 154 | + canvas.alpha_composite(work, (x, y)) |
| 155 | + return canvas |
| 156 | + |
| 157 | + |
| 158 | +def pages_for(provider_id: str, patches: dict[str, Any], hubs: dict[str, Any]) -> list[str]: |
| 159 | + patch = patches.get(provider_id) if isinstance(patches.get(provider_id), dict) else {} |
| 160 | + hub = hubs.get(provider_id) if isinstance(hubs.get(provider_id), dict) else {} |
| 161 | + values = [patch.get("official_site"), patch.get("official_hub"), hub.get("direct"), hub.get("hub")] |
| 162 | + out: list[str] = [] |
| 163 | + for value in values: |
| 164 | + url = str(value or "").strip() |
| 165 | + if url.startswith(("http://", "https://")) and url not in out: |
| 166 | + out.append(url) |
| 167 | + return out |
| 168 | + |
| 169 | + |
| 170 | +def main() -> int: |
| 171 | + index = load(INDEX, {}) |
| 172 | + manifest = load(MANIFEST, {}) |
| 173 | + overrides = load(OVERRIDES, {}) |
| 174 | + hubs_doc = load(HUBS, {}) |
| 175 | + patches_raw = overrides.get("provider_patches") if isinstance(overrides, dict) else {} |
| 176 | + hubs_raw = hubs_doc.get("providers") if isinstance(hubs_doc, dict) else {} |
| 177 | + patches = {norm_id(k): v for k, v in (patches_raw or {}).items()} |
| 178 | + hubs = {norm_id(k): v for k, v in (hubs_raw or {}).items()} |
| 179 | + providers = index.setdefault("providers", {}) |
| 180 | + changed = 0 |
| 181 | + attempted = 0 |
| 182 | + |
| 183 | + for row in manifest.get("scrapers") or []: |
| 184 | + if not isinstance(row, dict): |
| 185 | + continue |
| 186 | + provider_id = norm_id(row.get("id")) |
| 187 | + current = providers.get(provider_id) |
| 188 | + if not provider_id or not isinstance(current, dict): |
| 189 | + continue |
| 190 | + pages = pages_for(provider_id, patches, hubs) |
| 191 | + if not pages: |
| 192 | + continue |
| 193 | + attempted += 1 |
| 194 | + best: tuple[int, Image.Image, dict[str, Any]] | None = None |
| 195 | + failures: list[str] = [] |
| 196 | + candidates: list[tuple[str, str, int]] = [] |
| 197 | + for page in pages: |
| 198 | + candidates.extend(page_icons(page)) |
| 199 | + seen: set[str] = set() |
| 200 | + for url, kind, base in candidates[:36]: |
| 201 | + if url in seen: |
| 202 | + continue |
| 203 | + seen.add(url) |
| 204 | + try: |
| 205 | + data, content_type, final_url = fetch(url) |
| 206 | + image = open_image(data, content_type, final_url) |
| 207 | + value = score(image, base) |
| 208 | + meta = { |
| 209 | + "sourceUrl": final_url, |
| 210 | + "requestedUrl": url, |
| 211 | + "sourceKind": kind, |
| 212 | + "contentType": content_type, |
| 213 | + "originalWidth": image.width, |
| 214 | + "originalHeight": image.height, |
| 215 | + "sourceSha256": hashlib.sha256(data).hexdigest(), |
| 216 | + "score": value, |
| 217 | + "faviconRefined": True, |
| 218 | + "faviconSourcePage": pages[0], |
| 219 | + } |
| 220 | + if best is None or value > best[0]: |
| 221 | + best = (value, image, meta) |
| 222 | + except Exception as exc: |
| 223 | + failures.append(f"{kind}:{type(exc).__name__}") |
| 224 | + time.sleep(0.02) |
| 225 | + if best is None: |
| 226 | + continue |
| 227 | + value, image, meta = best |
| 228 | + # Require an icon-like source. A rectangular asset is likely another |
| 229 | + # wordmark and would not solve the tiny-text problem this pass targets. |
| 230 | + ratio = image.width / max(1, image.height) |
| 231 | + if not (0.55 <= ratio <= 1.8) or min(image.size) < 32: |
| 232 | + continue |
| 233 | + slug = str(current.get("slug") or re.sub(r"[^a-z0-9]+", "-", provider_id).strip("-")) |
| 234 | + for width, height in TARGETS: |
| 235 | + rel = ROOT / "assets" / "providers" / f"{width}x{height}" / f"{slug}.webp" |
| 236 | + render(image, width, height).save(rel, format="WEBP", lossless=True, method=6, exact=True) |
| 237 | + preserved = {k: current.get(k) for k in ("id", "name", "slug", "assets", "urls") if k in current} |
| 238 | + providers[provider_id] = { |
| 239 | + **preserved, |
| 240 | + **meta, |
| 241 | + "candidateCount": len(seen), |
| 242 | + "failures": failures[:12], |
| 243 | + "previousSourceKind": current.get("sourceKind"), |
| 244 | + "previousSourceUrl": current.get("sourceUrl"), |
| 245 | + } |
| 246 | + changed += 1 |
| 247 | + print(f"FIELD_PROVIDER_FAVICON_REFINE provider={provider_id} source={meta['sourceKind']} original={image.width}x{image.height}") |
| 248 | + |
| 249 | + index["faviconRefinement"] = { |
| 250 | + "mode": "one-shot-site-icon-preference", |
| 251 | + "attemptedProviders": attempted, |
| 252 | + "refinedProviders": changed, |
| 253 | + "completedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), |
| 254 | + } |
| 255 | + index["futurePolicy"] = "committed-assets-only-no-network-regeneration" |
| 256 | + INDEX.write_text(json.dumps(index, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 257 | + print(f"FIELD_PROVIDER_FAVICON_REFINEMENT attempted={attempted} refined={changed}") |
| 258 | + return 0 |
| 259 | + |
| 260 | + |
| 261 | +if __name__ == "__main__": |
| 262 | + raise SystemExit(main()) |
0 commit comments