"""Bounded hero-image enrichment. The grid stays purely typographic; the daily brief's items are the one place we make an exception and fetch a real image — because the hero is the single intentional visual doorway. We fetch ONLY the article page's
metadata (og:image / twitter:image), store ONLY the resulting image URL, and never touch the body. This is opt-in, brief-only, once per build. Security (this is the one place we fetch user-/source-supplied pages): - http(s) only; short timeout; byte cap; redirects followed manually and capped; - every hop's host is DNS-resolved and rejected if ANY resolved address is private / loopback / link-local / multicast / reserved / unspecified (SSRF). Failures are cached by the caller (image_checked_at) so an article is never retried forever. """ from __future__ import annotations import ipaddress import re import socket import sqlite3 import struct import urllib.error import urllib.request from urllib.parse import urljoin, urlsplit from .text import canonicalize_url USER_AGENT = "goodNews/0.1 (+local constructive news prototype)" TIMEOUT = 6 MAX_BYTES = 300_000 MAX_REDIRECTS = 3 # Below this, a feed thumbnail upscales to mush in the card banner. Real share # images (og:image) are ~1200×630; tiny RSS thumbnails (~90px) are what we reject. MIN_IMG_WIDTH = 450 MIN_IMG_HEIGHT = 250 _META_RE = re.compile(rb"]*>", re.IGNORECASE) _HEAD_END_RE = re.compile(rb"", re.IGNORECASE) # Substrings that mark a generic placeholder/default share image rather than the # article's own picture (e.g. NPR's facebook-default). We'd rather show no image # (typographic hero) than a generic logo card. NOTE: do NOT add "branded_news" — # that's BBC's normal CDN path for real article photos, so rejecting it threw away # every BBC hero image and fell back to the tiny RSS thumbnail. _GENERIC_IMAGE_MARKERS = ( "facebook-default", "default-wide", "default-fb", "og-default", "default-og", "twitter-default", "default-image", "/placeholder", "share-default", "social-default", # tracking pixels / spacers / data-URIs — never a real share image "data:image", "/pixel", "1x1", "spacer", "/blank.", "transparent.", ) def _is_generic_image(url: str) -> bool: lowered = url.lower() return any(marker in lowered for marker in _GENERIC_IMAGE_MARKERS) def _prefer_unbranded(url: str) -> str: """Swap BBC's logo-branded image variant for its clean one. BBC's og:image is served from the "branded_news" CDN path with a "BBC NEWS" logo baked into the picture (it shows as "…EWS" once the hero crops it). The identical photo is served under "cpsprodpb" with no logo, so prefer that — a clean hero at the same full resolution. """ if "ichef.bbci.co.uk" in url and "/branded_news/" in url: return url.replace("/branded_news/", "/cpsprodpb/") return url def _attr(tag: bytes, name: bytes) -> bytes | None: m = re.search(name + rb"""\s*=\s*["']([^"']*)["']""", tag, re.IGNORECASE) return m.group(1) if m else None def og_image_from_html(html: bytes) -> str | None: """Extract og:image / twitter:image from a page's bytes.""" head = html.split(b"", 1)[0] if _HEAD_END_RE.search(html) else html for tag in _META_RE.findall(head): key = _attr(tag, b"property") or _attr(tag, b"name") if key and key.lower() in (b"og:image", b"og:image:url", b"twitter:image"): content = _attr(tag, b"content") if not content: continue image = canonicalize_url(content.decode("utf-8", "replace")) # Skip generic placeholders; keep scanning for a real one. if image and not _is_generic_image(image): return _prefer_unbranded(image) return None def _host_is_public(host: str | None) -> bool: """True only if the host resolves and ALL its addresses are public.""" if not host: return False try: infos = socket.getaddrinfo(host, None) except (socket.gaierror, UnicodeError, OSError): return False addrs = {info[4][0] for info in infos} if not addrs: return False for addr in addrs: try: ip = ipaddress.ip_address(addr.split("%")[0]) # strip scope id except ValueError: return False if ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified ): return False return True class _NoRedirect(urllib.request.HTTPRedirectHandler): # Don't auto-follow — we re-validate each hop's host ourselves. def redirect_request(self, *args, **kwargs): return None def fetch_og_image(url: str | None) -> str | None: """Fetch a page's head metadata and return its og:image URL, or None. Best-effort and safe: returns None on any error, bad scheme, redirect loop, or a host that resolves to a non-public address. """ opener = urllib.request.build_opener(_NoRedirect) for _ in range(MAX_REDIRECTS + 1): if not url: return None parts = urlsplit(url) if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname): return None request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"}) try: response = opener.open(request, timeout=TIMEOUT) except (urllib.error.URLError, OSError, ValueError): return None status = getattr(response, "status", 200) or 200 if status in (301, 302, 303, 307, 308): location = response.headers.get("Location") response.close() if not location: return None url = urljoin(url, location) continue ctype = response.headers.get("Content-Type", "") if "html" not in ctype.lower(): response.close() return None try: body = response.read(MAX_BYTES) finally: response.close() image = og_image_from_html(body) # A stored URL is not proof it renders — confirm it actually loads. return image if (image and _image_loads(image)) else None return None # too many redirects def _image_dimensions(data: bytes) -> "tuple[int, int] | None": """Best-effort (width, height) from an image file's header bytes — PNG, GIF, JPEG, WebP. Returns None for formats we can't cheaply measure (e.g. SVG).""" if len(data) < 10: return None if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR": return struct.unpack(">II", data[16:24]) if data[:6] in (b"GIF87a", b"GIF89a"): return struct.unpack("