File size: 15,812 Bytes
256c9c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
#!/usr/bin/env python3
"""
Fetch and parse an arxiv paper.

Usage:
    python fetch_paper.py <arxiv_id_or_url> <output_dir>

Examples:
    python fetch_paper.py 2106.09685 ./output/
    python fetch_paper.py https://arxiv.org/abs/2106.09685 ./output/
    python fetch_paper.py 2106.09685v2 ./output/

Outputs:
    {output_dir}/paper_text.md      — full paper text in markdown
    {output_dir}/paper_metadata.json — title, authors, abstract, categories
"""

import json
import re
import sys
from pathlib import Path

import requests


def normalize_arxiv_id(input_str: str) -> str:
    """Extract arxiv ID from a URL or bare ID string.

    Handles:
        https://arxiv.org/abs/2106.09685
        https://arxiv.org/pdf/2106.09685.pdf
        http://arxiv.org/abs/2106.09685v2
        2106.09685
        2106.09685v2
        cs/0601007  (old-style IDs)
    """
    input_str = input_str.strip().rstrip("/")

    # Remove common URL prefixes
    for prefix in [
        "https://arxiv.org/abs/",
        "http://arxiv.org/abs/",
        "https://arxiv.org/pdf/",
        "http://arxiv.org/pdf/",
    ]:
        if input_str.startswith(prefix):
            input_str = input_str[len(prefix):]
            break

    # Remove .pdf suffix if present
    if input_str.endswith(".pdf"):
        input_str = input_str[:-4]

    # Validate format: YYMM.NNNNN(vN) or archive/NNNNNNN
    new_style = re.match(r"^\d{4}\.\d{4,5}(v\d+)?$", input_str)
    old_style = re.match(r"^[a-z-]+/\d{7}(v\d+)?$", input_str)

    if not new_style and not old_style:
        print(f"WARNING: '{input_str}' may not be a valid arxiv ID.", file=sys.stderr)

    return input_str


def fetch_metadata(arxiv_id: str) -> dict:
    """Fetch paper metadata from the arxiv API."""
    # Strip version for API query
    base_id = re.sub(r"v\d+$", "", arxiv_id)
    api_url = f"http://export.arxiv.org/api/query?id_list={base_id}"

    try:
        resp = requests.get(api_url, timeout=30)
        resp.raise_for_status()
    except requests.RequestException as e:
        print(f"WARNING: Could not fetch metadata from arxiv API: {e}", file=sys.stderr)
        return {"arxiv_id": arxiv_id, "title": "Unknown", "authors": [], "abstract": "", "categories": []}

    text = resp.text

    # Simple XML parsing — avoid heavy dependencies
    def extract_tag(tag: str, content: str) -> str:
        pattern = rf"<{tag}[^>]*>(.*?)</{tag}>"
        match = re.search(pattern, content, re.DOTALL)
        return match.group(1).strip() if match else ""

    def extract_all_tags(tag: str, content: str) -> list:
        pattern = rf"<{tag}[^>]*>(.*?)</{tag}>"
        return [m.strip() for m in re.findall(pattern, content, re.DOTALL)]

    # Find the entry (skip the feed-level title)
    entry_match = re.search(r"<entry>(.*?)</entry>", text, re.DOTALL)
    if not entry_match:
        print("WARNING: No entry found in arxiv API response.", file=sys.stderr)
        return {"arxiv_id": arxiv_id, "title": "Unknown", "authors": [], "abstract": "", "categories": []}

    entry = entry_match.group(1)

    title = extract_tag("title", entry)
    title = re.sub(r"\s+", " ", title)  # collapse whitespace

    abstract = extract_tag("summary", entry)
    abstract = re.sub(r"\s+", " ", abstract)

    # Authors
    author_names = []
    for author_block in re.findall(r"<author>(.*?)</author>", entry, re.DOTALL):
        name = extract_tag("name", author_block)
        if name:
            author_names.append(name)

    # Categories
    categories = re.findall(r'<category[^>]*term="([^"]+)"', entry)

    return {
        "arxiv_id": arxiv_id,
        "title": title,
        "authors": author_names,
        "abstract": abstract,
        "categories": categories,
    }


def download_pdf(arxiv_id: str, output_path: Path) -> bool:
    """Download the PDF from arxiv."""
    pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf"
    print(f"Downloading PDF from {pdf_url}...")

    try:
        resp = requests.get(pdf_url, timeout=60, stream=True)
        resp.raise_for_status()

        with open(output_path, "wb") as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)

        file_size = output_path.stat().st_size
        print(f"  Downloaded: {file_size / 1024:.0f} KB")
        return True

    except requests.RequestException as e:
        print(f"  FAILED: {e}", file=sys.stderr)
        return False


def extract_with_pymupdf4llm(pdf_path: Path) -> str | None:
    """Extract text using pymupdf4llm (preserves math notation as LaTeX)."""
    try:
        import pymupdf4llm
        print("Extracting with pymupdf4llm (math-preserving)...")
        text = pymupdf4llm.to_markdown(str(pdf_path))
        if text and len(text) > 500:
            print(f"  Extracted: {len(text)} characters")
            return text
        print("  WARNING: pymupdf4llm produced insufficient text.", file=sys.stderr)
        return None
    except ImportError:
        print("  pymupdf4llm not available.", file=sys.stderr)
        return None
    except Exception as e:
        print(f"  pymupdf4llm failed: {e}", file=sys.stderr)
        return None


def extract_with_pdfplumber(pdf_path: Path) -> str | None:
    """Extract text using pdfplumber (fallback)."""
    try:
        import pdfplumber
        print("Extracting with pdfplumber (fallback)...")
        pages = []
        with pdfplumber.open(str(pdf_path)) as pdf:
            for i, page in enumerate(pdf.pages):
                text = page.extract_text()
                if text:
                    pages.append(f"<!-- Page {i + 1} -->\n{text}")
        if pages:
            full_text = "\n\n".join(pages)
            print(f"  Extracted: {len(full_text)} characters from {len(pages)} pages")
            return full_text
        print("  WARNING: pdfplumber produced no text.", file=sys.stderr)
        return None
    except ImportError:
        print("  pdfplumber not available.", file=sys.stderr)
        return None
    except Exception as e:
        print(f"  pdfplumber failed: {e}", file=sys.stderr)
        return None


def fetch_ar5iv_html(arxiv_id: str) -> str | None:
    """Fetch HTML version from ar5iv (renders math as readable text)."""
    base_id = re.sub(r"v\d+$", "", arxiv_id)
    html_url = f"https://ar5iv.labs.arxiv.org/html/{base_id}"
    print(f"Fetching HTML from {html_url}...")

    try:
        resp = requests.get(html_url, timeout=60)
        resp.raise_for_status()

        # Basic HTML to text conversion — strip tags but keep structure
        text = resp.text

        # Remove script and style blocks
        text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
        text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)

        # Convert headers to markdown
        for level in range(1, 7):
            text = re.sub(
                rf"<h{level}[^>]*>(.*?)</h{level}>",
                lambda m, lv=level: f"\n{'#' * lv} {m.group(1).strip()}\n",
                text,
                flags=re.DOTALL,
            )

        # Convert paragraphs to double newlines
        text = re.sub(r"<p[^>]*>", "\n\n", text)
        text = re.sub(r"</p>", "", text)

        # Convert list items
        text = re.sub(r"<li[^>]*>", "\n- ", text)

        # Preserve math elements (ar5iv uses MathML or LaTeX in alt text)
        text = re.sub(r'<math[^>]*alttext="([^"]*)"[^>]*>.*?</math>', r"$\1$", text, flags=re.DOTALL)

        # Strip remaining HTML tags
        text = re.sub(r"<[^>]+>", "", text)

        # Clean up whitespace
        text = re.sub(r"\n{3,}", "\n\n", text)
        text = text.strip()

        if len(text) > 500:
            print(f"  Extracted: {len(text)} characters from HTML")
            return text

        print("  WARNING: ar5iv HTML produced insufficient text.", file=sys.stderr)
        return None

    except requests.RequestException as e:
        print(f"  ar5iv fetch failed: {e}", file=sys.stderr)
        return None


def check_text_quality(text: str) -> bool:
    """Check if extracted text is reasonable quality (not garbled)."""
    if not text or len(text) < 500:
        return False

    # Check first 1000 chars for readability
    sample = text[:1000]

    # Count non-ASCII, non-whitespace, non-LaTeX special chars
    weird_chars = sum(
        1 for c in sample
        if ord(c) > 127 and c not in "αβγδεζηθικλμνξπρστυφχψωΓΔΘΛΞΠΣΦΨΩ∑∏∫∂∇√∞±≤≥≠≈∈∉⊂⊃∪∩"
    )
    weird_ratio = weird_chars / max(len(sample), 1)

    if weird_ratio > 0.2:
        print(f"  WARNING: Text quality check failed ({weird_ratio:.0%} non-standard characters)")
        return False

    # Check for recognizable English words
    common_words = {"the", "and", "of", "in", "to", "we", "is", "for", "that", "with"}
    words_lower = set(re.findall(r"\b[a-z]+\b", sample.lower()))
    found_common = words_lower & common_words

    if len(found_common) < 3:
        print("  WARNING: Text quality check failed (few recognizable English words)")
        return False

    return True


def find_official_code(arxiv_id: str, paper_text: str | None, metadata: dict) -> list[dict]:
    """Search for official code repositories linked to this paper.

    Checks two sources:
    1. The paper text itself — GitHub/GitLab URLs, "code available at" phrases
    2. The arxiv abstract page — authors sometimes add code links there

    Returns a list of dicts with keys: url, source, context
    """
    found = []
    seen_urls = set()

    def add_link(url: str, source: str, context: str = "") -> None:
        normalized = url.rstrip("/").lower()
        if normalized not in seen_urls:
            seen_urls.add(normalized)
            found.append({"url": url.rstrip("/"), "source": source, "context": context.strip()})

    # --- Source 1: Scan paper text for code URLs ---
    if paper_text:
        # Match GitHub/GitLab/Bitbucket repo URLs
        repo_pattern = r"https?://(?:github\.com|gitlab\.com|bitbucket\.org)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"
        for match in re.finditer(repo_pattern, paper_text):
            url = match.group(0)
            # Grab surrounding context (up to 120 chars on each side)
            start = max(0, match.start() - 120)
            end = min(len(paper_text), match.end() + 120)
            context = paper_text[start:end].replace("\n", " ")
            add_link(url, "paper_text", context)

        # Match common phrases that precede code URLs
        code_phrases = [
            r"code\s+(?:is\s+)?(?:available|released|open[\s-]?sourced)\s+at\s+(https?://\S+)",
            r"(?:our|the)\s+code\s+(?:can be found|is hosted)\s+at\s+(https?://\S+)",
            r"implementation\s+(?:is\s+)?(?:available|released)\s+at\s+(https?://\S+)",
            r"source\s+code[:\s]+(https?://\S+)",
        ]
        for pattern in code_phrases:
            for match in re.finditer(pattern, paper_text, re.IGNORECASE):
                url = match.group(1).rstrip(".,;:)")
                add_link(url, "paper_text", match.group(0))

    # --- Source 2: Scan the arxiv abstract page ---
    base_id = re.sub(r"v\d+$", "", arxiv_id)
    abs_url = f"https://arxiv.org/abs/{base_id}"
    try:
        resp = requests.get(abs_url, timeout=30)
        resp.raise_for_status()
        html = resp.text

        # arxiv shows official code links in the "Code" or "GitHub" badges / sidebar
        # Look for GitHub links in the abstract page HTML
        page_repo_matches = re.findall(
            r'href="(https?://(?:github\.com|gitlab\.com|bitbucket\.org)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)"',
            html,
        )
        for url in page_repo_matches:
            add_link(url, "arxiv_page", "Link found on arxiv abstract page")

    except requests.RequestException as e:
        print(f"  WARNING: Could not fetch arxiv abstract page for code links: {e}", file=sys.stderr)

    return found


def main():
    if len(sys.argv) < 3:
        print(f"Usage: {sys.argv[0]} <arxiv_id_or_url> <output_dir>", file=sys.stderr)
        sys.exit(1)

    raw_input = sys.argv[1]
    output_dir = Path(sys.argv[2])
    output_dir.mkdir(parents=True, exist_ok=True)

    # Step 1: Normalize ID
    arxiv_id = normalize_arxiv_id(raw_input)
    print(f"Arxiv ID: {arxiv_id}")

    # Step 2: Fetch metadata
    print("\n--- Fetching metadata ---")
    metadata = fetch_metadata(arxiv_id)
    metadata_path = output_dir / "paper_metadata.json"
    with open(metadata_path, "w", encoding="utf-8") as f:
        json.dump(metadata, f, indent=2, ensure_ascii=False)
    print(f"  Title: {metadata['title']}")
    print(f"  Authors: {', '.join(metadata['authors'][:5])}{'...' if len(metadata['authors']) > 5 else ''}")
    print(f"  Categories: {', '.join(metadata['categories'])}")

    # Step 3: Download and extract PDF
    paper_text = None
    pdf_path = output_dir / "paper.pdf"

    print("\n--- Downloading PDF ---")
    if download_pdf(arxiv_id, pdf_path):
        # Try pymupdf4llm first
        print("\n--- Extracting text ---")
        paper_text = extract_with_pymupdf4llm(pdf_path)

        # Check quality
        if paper_text and not check_text_quality(paper_text):
            print("  pymupdf4llm text quality poor, trying pdfplumber...")
            paper_text = None

        # Fallback to pdfplumber
        if paper_text is None:
            paper_text = extract_with_pdfplumber(pdf_path)

        if paper_text and not check_text_quality(paper_text):
            print("  pdfplumber text quality poor, trying ar5iv HTML...")
            paper_text = None

    # Step 4: Fallback to ar5iv HTML
    if paper_text is None:
        print("\n--- Trying ar5iv HTML fallback ---")
        paper_text = fetch_ar5iv_html(arxiv_id)

    # Step 5: Save results
    if paper_text is None:
        print("\nERROR: All extraction methods failed.", file=sys.stderr)
        print("Please download the paper manually and provide the text.", file=sys.stderr)
        sys.exit(1)

    text_path = output_dir / "paper_text.md"
    with open(text_path, "w", encoding="utf-8") as f:
        f.write(f"# {metadata['title']}\n\n")
        f.write(f"**Authors:** {', '.join(metadata['authors'])}\n\n")
        f.write(f"**ArXiv:** https://arxiv.org/abs/{arxiv_id}\n\n")
        f.write("---\n\n")
        f.write(paper_text)

    # Step 6: Search for official code repositories
    code_links = find_official_code(arxiv_id, paper_text, metadata)
    if code_links:
        metadata["official_code"] = code_links
        # Re-save metadata with code links
        with open(metadata_path, "w", encoding="utf-8") as f:
            json.dump(metadata, f, indent=2, ensure_ascii=False)
        for link in code_links:
            print(f"  Found: {link['url']} (source: {link['source']})")
    else:
        metadata["official_code"] = []
        with open(metadata_path, "w", encoding="utf-8") as f:
            json.dump(metadata, f, indent=2, ensure_ascii=False)
        print("  No official code repositories found.")

    # Summary
    page_count = paper_text.count("<!-- Page")
    has_math = bool(re.search(r"[\$\\]|\\frac|\\sum|\\int|\\mathbb", paper_text))
    has_figures = bool(re.search(r"[Ff]igure\s+\d", paper_text))

    print(f"\n--- Extraction Summary ---")
    print(f"  Output: {text_path}")
    print(f"  Characters: {len(paper_text):,}")
    print(f"  Pages detected: {page_count if page_count > 0 else 'N/A (HTML extraction)'}")
    print(f"  Math preserved: {'Yes' if has_math else 'No'}")
    print(f"  Figure references found: {'Yes' if has_figures else 'No'}")
    print(f"  Metadata saved: {metadata_path}")
    print(f"  Official code links: {len(code_links)} found")
    print(f"\nDone.")


if __name__ == "__main__":
    main()