Skip to content

Commit 47521ba

Browse files
committed
assets: add one-shot favicon logo refinement
1 parent b0815ee commit 47521ba

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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

Comments
 (0)