157 lines
6.0 KiB
Python
157 lines
6.0 KiB
Python
#!/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())
|