from __future__ import annotations import re from html.parser import HTMLParser from urllib.parse import urljoin, urlparse from app.services.unpaywall.rate_limit import wait_for_unpaywall_slot from app.settings import settings PDF_MIME = "application/pdf" URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.I) class _LandingPdfParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.base_href: str | None = None self.candidates: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: attrs_map = {key.lower(): (value or "").strip() for key, value in attrs} if tag == "base": self.base_href = attrs_map.get("href") or self.base_href return if tag == "meta": self._append_meta_candidate(attrs_map) return if tag == "link": self._append_link_candidate(attrs_map) return if tag == "a": self._append_anchor_candidate(attrs_map) def _append_meta_candidate(self, attrs_map: dict[str, str]) -> None: meta_name = (attrs_map.get("name") or attrs_map.get("property") or "").lower() if meta_name != "citation_pdf_url": return content = attrs_map.get("content") if content: self.candidates.append(content) def _append_link_candidate(self, attrs_map: dict[str, str]) -> None: href = attrs_map.get("href") link_type = (attrs_map.get("type") or "").lower() if href and "pdf" in link_type: self.candidates.append(href) def _append_anchor_candidate(self, attrs_map: dict[str, str]) -> None: href = attrs_map.get("href") if href: self.candidates.append(href) def looks_like_pdf_url(url: str | None) -> bool: if not isinstance(url, str): return False value = url.strip() if not value: return False parsed = urlparse(value) path = (parsed.path or "").lower() query = (parsed.query or "").lower() return path.endswith(".pdf") or ".pdf" in query def _normalized_candidate_urls(*, page_url: str, html: str) -> list[str]: parser = _LandingPdfParser() parser.feed(html) parser.close() base_url = urljoin(page_url, parser.base_href) if parser.base_href else page_url raw_candidates = [*parser.candidates, *_text_url_candidates(html)] deduped: list[str] = [] seen: set[str] = set() for raw in raw_candidates: absolute = urljoin(base_url, raw.strip()) parsed = urlparse(absolute) if parsed.scheme not in {"http", "https"}: continue if absolute in seen: continue seen.add(absolute) deduped.append(absolute) return sorted(deduped, key=_candidate_sort_key) def _text_url_candidates(html: str) -> list[str]: candidates: list[str] = [] for match in URL_RE.findall(html): cleaned = match.rstrip(".,);]}>") if "pdf" not in cleaned.lower(): continue candidates.append(cleaned) return candidates def _candidate_sort_key(candidate: str) -> tuple[int, str]: lowered = candidate.lower() if looks_like_pdf_url(candidate): return (0, lowered) if any(token in lowered for token in ("doi", "full", "article", "download")): return (1, lowered) return (2, lowered) def _is_html_response(response) -> bool: content_type = str(response.headers.get("content-type") or "").lower() return "text/html" in content_type or "application/xhtml+xml" in content_type async def _fetch_page_html(client, *, page_url: str) -> str | None: await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) response = await client.get(page_url, follow_redirects=True) if response.status_code != 200 or not _is_html_response(response): return None text = response.text or "" return text[: max(int(settings.unpaywall_pdf_discovery_max_html_bytes), 0)] async def _candidate_is_pdf(client, *, candidate_url: str) -> bool: if looks_like_pdf_url(candidate_url): return True await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) response = await client.get(candidate_url, follow_redirects=True) content_type = str(response.headers.get("content-type") or "").lower() return response.status_code == 200 and PDF_MIME in content_type def _candidate_limit() -> int: return max(int(settings.unpaywall_pdf_discovery_max_candidates), 1) async def _resolve_pdf_from_candidate_page(client, *, candidate_url: str) -> str | None: html = await _fetch_page_html(client, page_url=candidate_url) if not html: return None nested_candidates = _normalized_candidate_urls(page_url=candidate_url, html=html) for nested in nested_candidates[: _candidate_limit()]: if await _candidate_is_pdf(client, candidate_url=nested): return nested return None async def resolve_pdf_from_landing_page(client, *, page_url: str) -> str | None: if not settings.unpaywall_pdf_discovery_enabled: return None html = await _fetch_page_html(client, page_url=page_url) if not html: return None candidates = _normalized_candidate_urls(page_url=page_url, html=html) for candidate in candidates[: _candidate_limit()]: if await _candidate_is_pdf(client, candidate_url=candidate): return candidate nested_pdf = await _resolve_pdf_from_candidate_page(client, candidate_url=candidate) if nested_pdf: return nested_pdf return None