| 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 |
| |
| |
| doi_clean = doi.strip() |
| |
| if doi_clean.lower().startswith("http"): |
| |
| 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 |
| 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" |
| |
| |
| section_paragraphs = [] |
| |
| current_paragraph_lines = [] |
| |
| |
| 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 |
| |
| |
| found_new_sec = None |
| if len(cleaned_line) < 60: |
| match_text = re.sub(r'^[0-9\.\s\-]+', '', cleaned_line).strip() |
| |
| 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_paragraph() |
| if section_paragraphs: |
| structured_parts.append(f"=== {current_section} ===\n" + "\n\n".join(section_paragraphs)) |
| |
| return "\n\n".join(structured_parts) |
|
|