"""Fetch author lists for every paper in the caption slice. ArxivCap does not ship author names, so the author filter needs a one-time metadata pass keyed on the arXiv IDs already in the slice. Uses the Semantic Scholar batch endpoint (500 papers per request, ~110 requests for the astro slice). Results append to authors.jsonl as they arrive, so an interrupted run resumes, and the final parquet is rebuilt from that file. export S2_API_KEY=... # optional but much faster python fetch_authors.py Output: authors.parquet with columns (arxiv_id, authors, author_ids). """ import argparse import json import os import time from pathlib import Path import duckdb import pyarrow as pa import pyarrow.parquet as pq import requests BATCH_URL = "https://api.semanticscholar.org/graph/v1/paper/batch" BATCH_SIZE = 500 def load_ids(slice_path: str) -> list[str]: con = duckdb.connect() rows = con.execute( f"SELECT DISTINCT arxiv_id FROM read_parquet('{slice_path}') WHERE arxiv_id IS NOT NULL" ).fetchall() return [r[0] for r in rows] def fetch_batch(ids: list[str], api_key: str) -> list: headers = {"x-api-key": api_key} if api_key else {} payload = {"ids": [f"ARXIV:{i}" for i in ids]} for attempt in range(6): try: r = requests.post(BATCH_URL, params={"fields": "authors"}, json=payload, headers=headers, timeout=60) if r.status_code == 429: wait = 10 * (attempt + 1) print(f" rate limited, sleeping {wait}s") time.sleep(wait) continue r.raise_for_status() return r.json() except requests.exceptions.RequestException as e: if attempt == 5: raise time.sleep(5 * (attempt + 1)) return [] def main(): ap = argparse.ArgumentParser() ap.add_argument("--slice", default="astro_captions.parquet") ap.add_argument("--out", default="authors.parquet") ap.add_argument("--cache", default="authors.jsonl") ap.add_argument("--sleep", type=float, default=1.0, help="seconds between requests; raise if you see 429s") args = ap.parse_args() api_key = os.environ.get("S2_API_KEY", "") if not api_key: print("No S2_API_KEY set. This will work but is slower and rate limited.") cache_path = Path(args.cache) done = set() if cache_path.exists(): for line in cache_path.open(): done.add(json.loads(line)["arxiv_id"]) ids = load_ids(args.slice) todo = [i for i in ids if i not in done] print(f"{len(ids):,} papers in slice, {len(done):,} already fetched, {len(todo):,} to go") with cache_path.open("a") as f: for start in range(0, len(todo), BATCH_SIZE): batch = todo[start:start + BATCH_SIZE] results = fetch_batch(batch, api_key) for arxiv_id, entry in zip(batch, results): authors, author_ids = [], [] if entry and entry.get("authors"): for a in entry["authors"]: if a.get("name"): authors.append(a["name"]) author_ids.append(a.get("authorId") or "") f.write(json.dumps({"arxiv_id": arxiv_id, "authors": authors, "author_ids": author_ids}) + "\n") f.flush() print(f" {min(start + BATCH_SIZE, len(todo)):,}/{len(todo):,}") time.sleep(args.sleep) rows = [json.loads(l) for l in cache_path.open()] found = sum(1 for r in rows if r["authors"]) pq.write_table(pa.table({ "arxiv_id": [r["arxiv_id"] for r in rows], "authors": [r["authors"] for r in rows], "author_ids": [r["author_ids"] for r in rows], }), args.out) print(f"\n{len(rows):,} records written to {args.out}") print(f"{found:,} with at least one author ({100*found/max(len(rows),1):.1f}%)") if __name__ == "__main__": main()