"""Download canonical RL papers from arXiv into data/papers/. Idempotent: papers already on disk (with valid PDF content) are skipped. Truncated/corrupt PDFs are auto-deleted and retried. Uses `requests` with streaming + chunked writes — the `arxiv` library's built-in downloader was truncating large PDFs at power-of-2 byte boundaries on Windows. Usage: uv run python scripts/fetch_corpus.py """ from __future__ import annotations import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import pymupdf import requests from rich.console import Console from rich.table import Table from researchpath.corpus import CANONICAL_RL_PAPERS console = Console() DATA_DIR = Path(__file__).resolve().parents[1] / "data" / "papers" USER_AGENT = "ResearchPath/0.1.0 (mailto:chetanchowdary01@gmail.com)" MIN_PDF_BYTES = 50_000 INTER_REQUEST_DELAY = 12 # arXiv asks for >=3s; PDF endpoint rate-limits harder MAX_ATTEMPTS = 3 RETRY_BACKOFF_SEC = 30 DOWNLOAD_TIMEOUT = 180 def validate_pdf(path: Path) -> tuple[bool, str]: if not path.exists(): return False, "file missing" if path.stat().st_size < MIN_PDF_BYTES: return False, f"too small ({path.stat().st_size} bytes)" try: with pymupdf.open(path) as doc: if doc.page_count < 1: return False, "0 pages" _ = doc[0].get_text() return True, "ok" except Exception as e: return False, f"parse error: {e}" def download_pdf(arxiv_id: str, dest: Path) -> tuple[bool, str]: """Stream a single PDF from arXiv. Returns (success, message).""" url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" headers = {"User-Agent": USER_AGENT} try: with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as r: if r.status_code == 429: return False, "rate limited (429)" r.raise_for_status() expected = int(r.headers.get("Content-Length", "0") or "0") written = 0 with open(dest, "wb") as f: for chunk in r.iter_content(chunk_size=64 * 1024): if chunk: f.write(chunk) written += len(chunk) if expected and written < expected: dest.unlink(missing_ok=True) return False, f"truncated: {written}/{expected} bytes" return True, f"ok ({written} bytes)" except requests.exceptions.HTTPError as e: return False, f"http {e.response.status_code}" except Exception as e: return False, f"network: {e}" def fetch_with_retry(arxiv_id: str, dest: Path) -> tuple[bool, str]: for attempt in range(1, MAX_ATTEMPTS + 1): ok, msg = download_pdf(arxiv_id, dest) if not ok: if attempt < MAX_ATTEMPTS: console.print(f" [dim]attempt {attempt}: {msg}; backing off {RETRY_BACKOFF_SEC}s[/dim]") time.sleep(RETRY_BACKOFF_SEC) continue return False, msg valid, vmsg = validate_pdf(dest) if valid: return True, msg dest.unlink(missing_ok=True) if attempt < MAX_ATTEMPTS: console.print(f" [dim]attempt {attempt}: invalid pdf ({vmsg}); backing off {RETRY_BACKOFF_SEC}s[/dim]") time.sleep(RETRY_BACKOFF_SEC) return False, "exhausted retries" def main() -> int: DATA_DIR.mkdir(parents=True, exist_ok=True) n_ok = 0 n_skip = 0 n_fail = 0 failures: list[tuple[str, str]] = [] pending = [] for paper in CANONICAL_RL_PAPERS: target = DATA_DIR / f"{paper.arxiv_id}.pdf" if target.exists(): ok, reason = validate_pdf(target) if ok: console.print(f"[yellow]SKIP[/yellow] {paper.arxiv_id} ({paper.tag})") n_skip += 1 continue console.print(f"[magenta]REDO[/magenta] {paper.arxiv_id} ({paper.tag}) — {reason}") target.unlink() pending.append(paper) if not pending: console.print("\n[bold green]All papers already present and valid.[/bold green]") return 0 console.print(f"\n[bold]Need to download {len(pending)} paper(s). Spacing requests by {INTER_REQUEST_DELAY}s.[/bold]\n") for i, paper in enumerate(pending): target = DATA_DIR / f"{paper.arxiv_id}.pdf" console.print(f"[cyan]FETCH[/cyan] {paper.arxiv_id} {paper.tag} - {paper.title[:60]}") ok, msg = fetch_with_retry(paper.arxiv_id, target) if ok: console.print(f" [green]ok[/green] {msg}") n_ok += 1 else: console.print(f"[red]FAIL[/red] {paper.arxiv_id}: {msg}") failures.append((paper.arxiv_id, msg)) n_fail += 1 # Spacing between successful papers (within retry, the backoff is already 30s) if i + 1 < len(pending): time.sleep(INTER_REQUEST_DELAY) console.print() table = Table(title="Corpus fetch summary") table.add_column("Outcome", style="bold") table.add_column("Count", justify="right") table.add_row("[green]Downloaded[/green]", str(n_ok)) table.add_row("[yellow]Skipped (already valid)[/yellow]", str(n_skip)) table.add_row("[red]Failed[/red]", str(n_fail)) console.print(table) if failures: console.print("\n[bold red]Failures (re-run later to retry):[/bold red]") for arxiv_id, err in failures: console.print(f" {arxiv_id}: {err}") return 0 if n_fail == 0 else 1 if __name__ == "__main__": sys.exit(main())