"""Domain-level paywall hints. We never fetch article pages, so a paywall can only be inferred from the host. This is a curated, conservative list of hard/soft paywalls — enough to label a card "subscription may be required" and to prefer readable stories for the lead and for replacements. It will never be perfect; it's an honest hint, not a gate. """ from __future__ import annotations from urllib.parse import urlsplit # Host suffixes considered paywalled. Subdomains match (news.nature.com → nature.com). PAYWALL_DOMAINS = { "newscientist.com", "nature.com", "nytimes.com", "wsj.com", "ft.com", "economist.com", "wired.com", "theatlantic.com", "washingtonpost.com", "bloomberg.com", "technologyreview.com", "newyorker.com", "scientificamerican.com", "nationalgeographic.com", "thetimes.co.uk", "telegraph.co.uk", "foreignpolicy.com", "hbr.org", "harpers.org", } def is_paywalled(url: str | None) -> bool: """Low-level DOMAIN rule. Keep this distinct from the source-aware decision so callers can tell 'domain says paywalled' from 'this source is overridden'.""" host = urlsplit(url or "").netloc.lower() if host.startswith("www."): host = host[4:] return any(host == d or host.endswith("." + d) for d in PAYWALL_DOMAINS) def is_paywalled_for_source(url: str | None, override: str | None = None) -> bool: """The EFFECTIVE paywall decision used for ranking/lead/badges: a per-source override (set in admin after inspecting the articles) wins over the domain rule — 'free' clears a false positive (e.g. NY Times Learning), 'paywalled' flags a false negative. NULL falls back to the domain rule.""" if override == "free": return False if override == "paywalled": return True return is_paywalled(url)