| import logging |
| import os |
| import re |
| from concurrent.futures import ThreadPoolExecutor |
| from typing import List, Optional |
|
|
| import requests |
|
|
| from app.modules.search.providers.models import ResearchPaper |
|
|
| _fallback_logger = logging.getLogger(__name__) |
|
|
| CONTACT_EMAIL = os.getenv("OPENALEX_MAILTO") or os.getenv("UNPAYWALL_EMAIL") |
| OPENALEX_API_KEY = os.getenv("OPENALEX_API_KEY") |
| OPENALEX_CONTENT_API_KEY = os.getenv("OPENALEX_CONTENT_API_KEY") or OPENALEX_API_KEY |
|
|
| _REQUEST_HEADERS = { |
| "User-Agent": "Mozilla/5.0 (compatible; research-pipeline/1.0; +mailto:contact@example.com)" |
| } |
| _PDF_MAGIC = b"%PDF" |
| _CITATION_PDF_URL_RE = re.compile( |
| r'<meta[^>]+name=["\']citation_pdf_url["\'][^>]+content=["\']([^"\']+)["\']' |
| r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']citation_pdf_url["\']', |
| re.IGNORECASE, |
| ) |
| _ARXIV_ID_RE = re.compile(r'arxiv\.org/(?:abs|pdf)/([^\s/?#]+)', re.IGNORECASE) |
| _ACL_ID_RE = re.compile(r'aclanthology\.org/([^\s/?#"\']+)', re.IGNORECASE) |
| _PMC_ID_RE = re.compile(r'(PMC\d+)', re.IGNORECASE) |
|
|
|
|
| def decode_abstract(inverted_index): |
| """ |
| Convert OpenAlex's abstract_inverted_index into a readable abstract. |
| Returns None if the index is missing or empty. |
| """ |
| if not inverted_index: |
| return None |
|
|
| try: |
| max_position = max( |
| pos for positions in inverted_index.values() for pos in positions |
| ) |
| except ValueError: |
| |
| return None |
|
|
| words = [""] * (max_position + 1) |
| for word, positions in inverted_index.items(): |
| for position in positions: |
| words[position] = word |
|
|
| result = " ".join(words).strip() |
| return result if result else None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def _is_verified_pdf(url: str, timeout: int = 8) -> bool: |
| if not url: |
| return False |
| try: |
| with requests.get( |
| url, headers=_REQUEST_HEADERS, stream=True, timeout=timeout, allow_redirects=True |
| ) as resp: |
| if resp.status_code != 200: |
| return False |
| chunk = next(resp.iter_content(chunk_size=16), b"") |
| return chunk.startswith(_PDF_MAGIC) |
| except requests.RequestException: |
| return False |
|
|
|
|
| def _extract_citation_pdf_url(landing_page_url: str, timeout: int = 8) -> Optional[str]: |
| """Scrape the citation_pdf_url meta tag off an HTML landing page.""" |
| try: |
| resp = requests.get(landing_page_url, headers=_REQUEST_HEADERS, timeout=timeout) |
| if resp.status_code != 200: |
| return None |
| match = _CITATION_PDF_URL_RE.search(resp.text[:20000]) |
| if not match: |
| return None |
| return match.group(1) or match.group(2) |
| except requests.RequestException: |
| return None |
|
|
|
|
| def _resolve_pmc_pdf(pmc_id: str, timeout: int = 8) -> Optional[str]: |
| """Try Europe PMC's render endpoint for a PubMedCentral ID.""" |
| clean_id = pmc_id if pmc_id.upper().startswith("PMC") else f"PMC{pmc_id}" |
| candidate = f"https://europepmc.org/articles/{clean_id}?pdf=render" |
| return candidate if _is_verified_pdf(candidate, timeout=timeout) else None |
|
|
|
|
| def _resolve_unpaywall_pdf(doi: str, email: str, timeout: int = 8) -> Optional[str]: |
| """Last-resort fallback: ask Unpaywall for the best OA location's direct PDF.""" |
| try: |
| resp = requests.get( |
| f"https://api.unpaywall.org/v2/{doi}", params={"email": email}, timeout=timeout |
| ) |
| if resp.status_code != 200: |
| return None |
| best_location = (resp.json() or {}).get("best_oa_location") or {} |
| return best_location.get("url_for_pdf") |
| except (requests.RequestException, ValueError): |
| return None |
|
|
|
|
| def _scan_locations_for_repo_ids(locations: List[dict]) -> dict: |
| """ |
| Walk every location OpenAlex knows about for this work and pull out |
| arXiv / ACL Anthology / PubMedCentral identifiers wherever they show up, |
| so a deterministic direct-PDF link can be reconstructed even when |
| best_oa_location isn't the repo that actually has one. |
| """ |
| found = {} |
| for loc in locations: |
| landing = (loc or {}).get("landing_page_url") or "" |
| pdf = (loc or {}).get("pdf_url") or "" |
| haystack = f"{landing} {pdf}" |
|
|
| if "arxiv" not in found: |
| m = _ARXIV_ID_RE.search(haystack) |
| if m: |
| found["arxiv"] = m.group(1) |
|
|
| if "acl" not in found: |
| m = _ACL_ID_RE.search(haystack) |
| if m: |
| acl_id = m.group(1) |
| if acl_id.lower().endswith(".pdf"): |
| acl_id = acl_id[:-4] |
| found["acl"] = acl_id |
|
|
| if "pmc" not in found: |
| m = _PMC_ID_RE.search(haystack) |
| if m: |
| found["pmc"] = m.group(1) |
|
|
| return found |
|
|
|
|
| def resolve_openalex_pdf( |
| paper: dict, |
| unpaywall_email: Optional[str] = None, |
| use_paid_content_api: bool = False, |
| openalex_content_api_key: Optional[str] = None, |
| log: Optional[logging.Logger] = None, |
| ) -> Optional[str]: |
| """Runs the full escalation ladder and returns a VERIFIED direct PDF url, or None.""" |
| log = log or _fallback_logger |
| primary = paper.get("primary_location") or {} |
| best_oa = paper.get("best_oa_location") or {} |
| locations = paper.get("locations") or [] |
| all_locations = [primary, best_oa] + locations |
|
|
| |
| seen = set() |
| direct_candidates = [] |
| for loc in all_locations: |
| u = (loc or {}).get("pdf_url") |
| if u and u not in seen: |
| seen.add(u) |
| direct_candidates.append(u) |
|
|
| for candidate in direct_candidates: |
| if _is_verified_pdf(candidate): |
| return candidate |
|
|
| |
| repo_ids = _scan_locations_for_repo_ids(all_locations) |
|
|
| if repo_ids.get("arxiv"): |
| candidate = f"https://arxiv.org/pdf/{repo_ids['arxiv']}" |
| if _is_verified_pdf(candidate): |
| return candidate |
|
|
| if repo_ids.get("acl"): |
| candidate = f"https://aclanthology.org/{repo_ids['acl']}.pdf" |
| if _is_verified_pdf(candidate): |
| return candidate |
|
|
| if repo_ids.get("pmc"): |
| candidate = _resolve_pmc_pdf(repo_ids["pmc"]) |
| if candidate: |
| return candidate |
|
|
| |
| |
| seen = set() |
| landing_pages = [] |
| for loc in [best_oa, primary] + locations: |
| lp = (loc or {}).get("landing_page_url") |
| if lp and lp not in seen: |
| seen.add(lp) |
| landing_pages.append(lp) |
|
|
| for page in landing_pages[:3]: |
| scraped = _extract_citation_pdf_url(page) |
| if scraped and _is_verified_pdf(scraped): |
| return scraped |
|
|
| |
| doi = paper.get("doi") |
| if doi and unpaywall_email: |
| doi_clean = doi.replace("https://doi.org/", "").replace("http://doi.org/", "") |
| candidate = _resolve_unpaywall_pdf(doi_clean, unpaywall_email) |
| if candidate and _is_verified_pdf(candidate): |
| return candidate |
|
|
| |
| has_content = paper.get("has_content") or {} |
| if use_paid_content_api and has_content.get("pdf") and openalex_content_api_key: |
| work_id = (paper.get("id") or "").rstrip("/").split("/")[-1] |
| if work_id: |
| candidate = ( |
| f"https://content.openalex.org/works/{work_id}.pdf" |
| f"?api_key={openalex_content_api_key}" |
| ) |
| if _is_verified_pdf(candidate): |
| return candidate |
|
|
| log.debug("No verified PDF resolved for OpenAlex work %s", paper.get("id")) |
| return None |
|
|
|
|
| def resolve_openalex_pdf_fast(paper: dict) -> Optional[str]: |
| """ |
| HYBRID fast path: a cheap, ZERO-network best-effort PDF link. |
| |
| Takes any direct pdf_url OpenAlex already handed us, or rebuilds a |
| deterministic repo URL (arXiv / ACL / PubMedCentral) from ids found in the |
| work's locations — WITHOUT downloading or verifying anything. The expensive |
| verify + landing-page scrape + Unpaywall ladder is deferred to 'Chat it out' |
| time (see resolve_openalex_pdf), so search stays fast. |
| """ |
| primary = paper.get("primary_location") or {} |
| best_oa = paper.get("best_oa_location") or {} |
| locations = paper.get("locations") or [] |
| all_locations = [best_oa, primary] + locations |
|
|
| for loc in all_locations: |
| u = (loc or {}).get("pdf_url") |
| if u: |
| return u |
|
|
| repo_ids = _scan_locations_for_repo_ids(all_locations) |
| if repo_ids.get("arxiv"): |
| return f"https://arxiv.org/pdf/{repo_ids['arxiv']}" |
| if repo_ids.get("acl"): |
| return f"https://aclanthology.org/{repo_ids['acl']}.pdf" |
| if repo_ids.get("pmc"): |
| clean = repo_ids["pmc"] if str(repo_ids["pmc"]).upper().startswith("PMC") else f"PMC{repo_ids['pmc']}" |
| return f"https://europepmc.org/articles/{clean}?pdf=render" |
| return None |
|
|
|
|
| def search_openalex( |
| query: str, |
| limit: int = 15, |
| sort="cited_by_count:desc", |
| logger: Optional[logging.Logger] = None, |
| max_candidates: int = 40, |
| use_paid_content_api: bool = False, |
| status_out: Optional[dict] = None, |
| fast: bool = True, |
| ) -> List[dict]: |
| """ |
| Search research papers using OpenAlex. Returns a list of validated paper dicts. |
| sort: 'cited_by_count:desc' for most-cited, 'publication_date:desc' for latest, |
| or None for relevance (OpenAlex default). |
| |
| Only papers for which a VERIFIED, direct PDF link could be resolved are |
| returned -- papers with no reachable PDF are filtered out entirely, |
| never returned with pdf_url=None. |
| |
| max_candidates: how many raw results to pull from OpenAlex and PDF-check |
| before stopping at `limit` verified papers, since each candidate |
| can cost several extra network calls to resolve/verify. |
| use_paid_content_api: opt-in only -- lets the resolution ladder's last |
| step (OpenAlex's own hosted, metered content API) run as a final |
| fallback. Requires OPENALEX_CONTENT_API_KEY to be set. Off by |
| default since it costs a small amount per download. |
| """ |
| log = logger or _fallback_logger |
| url = "https://api.openalex.org/works" |
| params = {"search": query, "per_page": max_candidates} |
| if sort: |
| params["sort"] = sort |
| if CONTACT_EMAIL: |
| |
| params["mailto"] = CONTACT_EMAIL |
|
|
| log.info("Searching OpenAlex for: %r (sort=%s)", query, sort or "relevance") |
|
|
| try: |
| response = requests.get(url, params=params, timeout=15) |
| except requests.RequestException as e: |
| log.warning("OpenAlex request failed: %s: %s", type(e).__name__, e) |
| if status_out is not None: |
| status_out.update(state="error", detail=f"{type(e).__name__}: {e}") |
| return [] |
|
|
| if response.status_code != 200: |
| log.warning("OpenAlex returned bad status: %s", response.status_code) |
| if status_out is not None: |
| state = "rate_limited" if response.status_code == 429 else "error" |
| status_out.update(state=state, http=response.status_code) |
| return [] |
|
|
| try: |
| data = response.json() |
| except ValueError as e: |
| log.warning("OpenAlex response failed to parse as JSON: %s", e) |
| if status_out is not None: |
| status_out.update(state="error", detail="bad JSON") |
| return [] |
|
|
| raw_papers = data.get("results", []) |
|
|
| |
| candidates = [] |
| for paper in raw_papers: |
| title = paper.get("display_name") |
| if not title or not title.strip(): |
| continue |
| authors = [] |
| for authorship in paper.get("authorships", []) or []: |
| author_obj = authorship.get("author") or {} |
| name = author_obj.get("display_name") |
| if name: |
| authors.append(name) |
| abstract = decode_abstract(paper.get("abstract_inverted_index")) |
| landing_url = (paper.get("primary_location") or {}).get("landing_page_url") |
| candidates.append((paper, title, authors, abstract, landing_url)) |
|
|
| |
| |
| |
| |
| if fast: |
| |
| |
| resolved_pdfs = [resolve_openalex_pdf_fast(paper) for (paper, *_rest) in candidates] |
| else: |
| |
| def _resolve(item): |
| return resolve_openalex_pdf( |
| item[0], |
| unpaywall_email=CONTACT_EMAIL, |
| use_paid_content_api=use_paid_content_api, |
| openalex_content_api_key=OPENALEX_CONTENT_API_KEY, |
| log=log, |
| ) |
| with ThreadPoolExecutor(max_workers=min(12, len(candidates))) as ex: |
| resolved_pdfs = list(ex.map(_resolve, candidates)) if candidates else [] |
|
|
| |
| papers = [] |
| for (paper, title, authors, abstract, landing_url), pdf_url in zip(candidates, resolved_pdfs): |
| if len(papers) >= limit: |
| break |
| if not fast and not pdf_url: |
| |
| continue |
| try: |
| paper_obj = ResearchPaper( |
| title=title.strip(), |
| authors=authors if authors else None, |
| abstract=abstract, |
| year=paper.get("publication_year"), |
| citation_count=paper.get("cited_by_count"), |
| url=landing_url, |
| pdf_url=pdf_url, |
| source="OpenAlex", |
| ) |
| papers.append(paper_obj.model_dump()) |
| except Exception as e: |
| log.warning("Skipped malformed OpenAlex paper: %s", e) |
| continue |
|
|
| if status_out is not None: |
| status_out.update(state="ok", count=len(papers)) |
| log.info("OpenAlex: %d paper(s) retrieved (%s) out of %d candidates", |
| len(papers), "fast/best-effort PDF" if fast else "verified PDF", len(raw_papers)) |
| return papers |