156 lines
5.6 KiB
Python
156 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
from html.parser import HTMLParser
|
|
import re
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
from app.services.domains.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
|