File size: 5,656 Bytes
2f25a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""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())