#!/usr/bin/env python3 """Build an abstract-level research corpus for one local paper topic.""" from __future__ import annotations import argparse import json import re import time import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parents[2] API_URL = "https://export.arxiv.org/api/query" NS = {"atom": "http://www.w3.org/2005/Atom"} ARXIV_ID_RE = re.compile(r"arxiv\.org/(?:abs|html|pdf)/([0-9]{4}\.[0-9]{4,5})") def normalize(value: str) -> str: return re.sub(r"\s+", " ", value).strip() def extract_arxiv_id(url: str) -> str | None: match = ARXIV_ID_RE.search(url or "") return match.group(1) if match else None def parse_feed(payload: bytes) -> dict[str, dict]: root = ET.fromstring(payload) records: dict[str, dict] = {} for entry in root.findall("atom:entry", NS): raw_id = entry.findtext("atom:id", default="", namespaces=NS) paper_id = raw_id.rsplit("/", 1)[-1].split("v", 1)[0] records[paper_id] = { "arxiv_id": paper_id, "title": normalize(entry.findtext("atom:title", default="", namespaces=NS)), "abstract": normalize(entry.findtext("atom:summary", default="", namespaces=NS)), "published": entry.findtext("atom:published", default="", namespaces=NS)[:10], "updated": entry.findtext("atom:updated", default="", namespaces=NS)[:10], "authors": [ normalize(author.findtext("atom:name", default="", namespaces=NS)) for author in entry.findall("atom:author", NS) ], "categories": [ node.attrib["term"] for node in entry.findall("atom:category", NS) if node.attrib.get("term") ], "url": f"https://arxiv.org/abs/{paper_id}", "pdf_url": f"https://arxiv.org/pdf/{paper_id}", } return records def fetch_batch(ids: list[str], retries: int, timeout: int) -> dict[str, dict]: query = urllib.parse.urlencode({"id_list": ",".join(ids), "max_results": len(ids)}) request = urllib.request.Request( f"{API_URL}?{query}", headers={"User-Agent": "agent-kb-research/0.1"}, ) for attempt in range(retries + 1): try: with urllib.request.urlopen(request, timeout=timeout) as response: return parse_feed(response.read()) except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError): if attempt >= retries: raise time.sleep(2 ** attempt) raise RuntimeError("unreachable retry state") def write_json(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp.replace(path) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--topic", default="memory") parser.add_argument("--index", type=Path, default=ROOT / "data" / "index.json") parser.add_argument("--output", type=Path) parser.add_argument("--batch-size", type=int, default=20) parser.add_argument("--sleep", type=float, default=1.0) parser.add_argument("--retries", type=int, default=4) parser.add_argument("--timeout", type=int, default=45) parser.add_argument("--refresh", action="store_true") args = parser.parse_args() output = args.output or ROOT / "data" / "research" / f"{args.topic}-corpus.json" index = json.loads(args.index.read_text(encoding="utf-8")) selected: dict[str, dict] = {} missing_ids: list[dict] = [] for item in index: meta = item.get("meta") or {} if item.get("collection") != "papers" or args.topic not in (meta.get("topics") or []): continue paper_id = extract_arxiv_id(str(meta.get("url") or "")) if not paper_id: missing_ids.append({"path": item.get("path"), "title": item.get("title")}) continue selected[paper_id] = { "path": item.get("path"), "local_title": item.get("title"), "local_status": meta.get("status"), "local_topics": meta.get("topics") or [], "collection_queries": meta.get("collection_queries"), "collection_score": meta.get("collection_score"), } cached: dict[str, dict] = {} if output.exists() and not args.refresh: old = json.loads(output.read_text(encoding="utf-8")) cached = {record["arxiv_id"]: record for record in old.get("papers", [])} fetched: dict[str, dict] = {} ids_to_fetch = [paper_id for paper_id in selected if paper_id not in cached] for offset in range(0, len(ids_to_fetch), args.batch_size): batch = ids_to_fetch[offset : offset + args.batch_size] fetched.update(fetch_batch(batch, retries=args.retries, timeout=args.timeout)) print(f"fetched {min(offset + len(batch), len(ids_to_fetch))}/{len(ids_to_fetch)}") if offset + args.batch_size < len(ids_to_fetch): time.sleep(args.sleep) papers = [] unresolved = [] for paper_id, local in selected.items(): remote = fetched.get(paper_id) or cached.get(paper_id) if not remote: unresolved.append({"arxiv_id": paper_id, **local}) continue papers.append({**remote, **local}) papers.sort(key=lambda item: (item.get("published") or "", item["arxiv_id"])) payload = { "topic": args.topic, "generated_at": date.today().isoformat(), "selected_from_index": len(selected), "resolved": len(papers), "missing_arxiv_id": missing_ids, "unresolved": unresolved, "papers": papers, } write_json(output, payload) print(json.dumps({key: payload[key] for key in ("selected_from_index", "resolved")}, indent=2)) return 0 if not unresolved else 2 if __name__ == "__main__": raise SystemExit(main())