RSCE / src /ingestion /pdf_fallback.py
LP1205's picture
Deploy RSCE backend to Hugging Face Space
5e952bb
Raw
History Blame Contribute Delete
5.38 kB
import logging
import re
import aiohttp
from src.config import settings
logger = logging.getLogger(__name__)
async def fetch_pdf_url_from_unpaywall(doi: str, session: aiohttp.ClientSession) -> str | None:
"""Query the Unpaywall API to find an open-access PDF URL for a given DOI."""
if not doi:
return None
# Standardize DOI formatting
doi_clean = doi.strip()
# If the DOI is inside a URL, extract it
if doi_clean.lower().startswith("http"):
# Match doi from URL e.g. doi.org/10.1038/...
match = re.search(r'doi\.org/(10\.\d{4,9}/[-._;()/:A-Z0-9]+)', doi_clean, re.IGNORECASE)
if match:
doi_clean = match.group(1)
url = f"https://api.unpaywall.org/v2/{doi_clean}"
email = settings.pubmed_email or "rsce_agent@example.com"
params = {"email": email}
try:
async with session.get(url, params=params, timeout=15) as resp:
if resp.status == 200:
data = await resp.json()
best_oa = data.get("best_oa_location")
if best_oa:
pdf_url = best_oa.get("url_for_pdf")
if pdf_url:
return pdf_url
else:
logger.debug(f"Unpaywall returned status {resp.status} for DOI {doi_clean}")
except Exception as e:
logger.warning(f"Unpaywall request failed for DOI {doi_clean}: {e}")
return None
async def download_and_extract_pdf_text(pdf_url: str, session: aiohttp.ClientSession) -> str | None:
"""Download PDF from URL and extract text using PyMuPDF."""
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
async with session.get(pdf_url, headers=headers, timeout=30) as resp:
resp.raise_for_status()
pdf_bytes = await resp.read()
import fitz # PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
text_parts = []
for page in doc:
page_text = page.get_text()
if page_text:
text_parts.append(page_text)
raw_text = "\n\n".join(text_parts)
if not raw_text.strip():
return None
return structure_pdf_text(raw_text)
except Exception as e:
logger.warning(f"Failed to download/parse PDF from {pdf_url}: {e}")
return None
def structure_pdf_text(raw_text: str) -> str:
"""Structure raw text from PDF into pseudo-PMC-XML sections (Introduction, Methods, Results, Discussion)."""
raw_text_clean = raw_text.replace("\r\n", "\n")
lines = raw_text_clean.split("\n")
structured_parts = []
current_section = "INTRODUCTION"
# Store paragraphs for the current section
section_paragraphs = []
# Store lines of the current paragraph block being built
current_paragraph_lines = []
# Heuristics for common section headers
section_patterns = {
"INTRODUCTION": re.compile(r'^(?:(?:[0-9]+\.?\s*)?(?:introduction|background))$', re.IGNORECASE),
"METHODS": re.compile(r'^(?:(?:[0-9]+\.?\s*)?(?:methods|methodology|materials\s*and\s*methods|methods\s*and\s*materials|experimental\s*procedures?))$', re.IGNORECASE),
"RESULTS": re.compile(r'^(?:(?:[0-9]+\.?\s*)?(?:results|findings))$', re.IGNORECASE),
"DISCUSSION": re.compile(r'^(?:(?:[0-9]+\.?\s*)?(?:discussion|conclusions?|summary|discussion\s*and\s*conclusions?))$', re.IGNORECASE),
}
def flush_paragraph():
if current_paragraph_lines:
paragraph_text = " ".join([line_str.strip() for line_str in current_paragraph_lines if line_str.strip()])
normalized_text = re.sub(r'\s+', ' ', paragraph_text)
if normalized_text:
section_paragraphs.append(normalized_text)
current_paragraph_lines.clear()
def flush_section(new_sec_name):
nonlocal current_section
flush_paragraph()
if section_paragraphs:
structured_parts.append(f"=== {current_section} ===\n" + "\n\n".join(section_paragraphs))
section_paragraphs.clear()
current_section = new_sec_name
for line in lines:
cleaned_line = line.strip()
if not cleaned_line:
flush_paragraph()
continue
# Check if the line is a section header
found_new_sec = None
if len(cleaned_line) < 60:
match_text = re.sub(r'^[0-9\.\s\-]+', '', cleaned_line).strip()
# Remove trailing/leading punctuation
match_text = re.sub(r'^[\:\-\s\.]+|[\:\-\s\.]+$', '', match_text).strip()
for sec_name, pattern in section_patterns.items():
if pattern.match(match_text):
found_new_sec = sec_name
break
if found_new_sec:
flush_section(found_new_sec)
else:
current_paragraph_lines.append(cleaned_line)
# Flush remaining text
flush_paragraph()
if section_paragraphs:
structured_parts.append(f"=== {current_section} ===\n" + "\n\n".join(section_paragraphs))
return "\n\n".join(structured_parts)