#!/usr/bin/env python3
"""Parse arXiv Atom XML into JSON records, catching arXiv's HTTP-200 failures.
arXiv has no JSON output, and its Atom feed has four traps that make hand-rolled
parsing quietly wrong (all documented in references/arxiv.md):
- The feed carries its own `` before the first entry, so "the first link"
is the query URL, not a paper.
- A malformed parameter returns HTTP 200, `totalResults` **1**, and a single
entry titled `Error` -- which reads as a successful one-hit search.
- `` is now `https://` and carries a version suffix (`1706.03762v7`).
- `` and `` arrive hard-wrapped mid-sentence.
This script handles all four, exits **3** on the Error entry, and exits **5** when arXiv is
throttling -- which it signals with the bare plain-text body `Rate exceeded.`, not a feed.
curl -s "https://export.arxiv.org/api/query?id_list=1706.03762" | python3 arxiv_atom.py -
python3 arxiv_atom.py feed.xml --ids-only
"""
from __future__ import annotations
import argparse
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import InputError, collapse_ws, emit, fail, read_input # noqa: E402
NS = {
"atom": "http://www.w3.org/2005/Atom",
"arxiv": "http://arxiv.org/schemas/atom",
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
}
def split_version(arxiv_id: str) -> tuple[str, str | None]:
"""`1706.03762v7` -> `("1706.03762", "7")`.
The bare ID is what Semantic Scholar's `ARXIV:` prefix, a DOI, and a
user-supplied ID all use, so comparing the versioned form against any of them
fails. Both are returned rather than choosing one.
"""
base, separator, version = arxiv_id.rpartition("v")
if separator and version.isdigit() and base:
return base, version
return arxiv_id, None
def id_from_url(url: str) -> str:
"""The arXiv ID from an `` URL, scheme-agnostically.
Historically `http://arxiv.org/abs/...`, now `https://`. Taking the last path
segment survives the change; string-matching the scheme does not.
"""
return url.rstrip("/").rsplit("/", 1)[-1]
def link_for(entry: ET.Element, *, rel: str, mime: str | None = None) -> str | None:
for link in entry.findall("atom:link", NS):
if link.get("rel") != rel:
continue
if mime and link.get("type") != mime:
continue
return link.get("href")
return None
def parse_entry(entry: ET.Element) -> dict[str, Any]:
raw_id = collapse_ws(entry.findtext("atom:id", namespaces=NS))
versioned = id_from_url(raw_id) if raw_id else ""
arxiv_id, version = split_version(versioned)
categories = [
term
for term in (category.get("term") for category in entry.findall("atom:category", NS))
if term
]
primary = entry.find("arxiv:primary_category", NS)
return {
"arxiv_id": arxiv_id,
"arxiv_id_versioned": versioned or None,
"version": version,
"title": collapse_ws(entry.findtext("atom:title", namespaces=NS)),
"abstract": collapse_ws(entry.findtext("atom:summary", namespaces=NS)),
"authors": [
collapse_ws(name)
for name in (
author.findtext("atom:name", namespaces=NS)
for author in entry.findall("atom:author", NS)
)
if collapse_ws(name)
],
"published": collapse_ws(entry.findtext("atom:published", namespaces=NS)) or None,
"updated": collapse_ws(entry.findtext("atom:updated", namespaces=NS)) or None,
"primary_category": primary.get("term") if primary is not None else None,
"categories": categories,
"doi": collapse_ws(entry.findtext("arxiv:doi", namespaces=NS)) or None,
"journal_ref": collapse_ws(entry.findtext("arxiv:journal_ref", namespaces=NS)) or None,
"comment": collapse_ws(entry.findtext("arxiv:comment", namespaces=NS)) or None,
# Selected by rel/type, never by position: the feed's own precedes
# the entries and would otherwise be picked up as a paper URL.
"abstract_url": link_for(entry, rel="alternate", mime="text/html"),
"pdf_url": link_for(entry, rel="related", mime="application/pdf"),
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Parse an arXiv Atom feed into JSON records. Exits 3 when the feed is an "
"arXiv error response, which arrives as HTTP 200 with totalResults 1 and a "
"single entry titled 'Error'."
),
epilog='curl -s "https://export.arxiv.org/api/query?id_list=1706.03762" | %(prog)s -',
)
parser.add_argument("source", help="path to an arXiv Atom XML file, or - for stdin")
parser.add_argument("-o", "--output", help="write JSON here instead of stdout")
parser.add_argument(
"--ids-only",
action="store_true",
help="print one bare arXiv ID per line (version suffix stripped)",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
xml_text = read_input(args.source)
except InputError as error:
fail(str(error))
# Throttling is not XML. arXiv answers a rate-limited caller with the bare
# plain-text body "Rate exceeded." -- 14 bytes, no feed, no status to key on --
# so an XML parse error here is usually a pacing problem, not a bad response.
if xml_text.strip().startswith("Rate exceeded"):
fail(
"arXiv is throttling: it returned the plain-text body 'Rate exceeded.' instead of a "
"feed. Its limit is one request per three seconds; wait and retry, and serialize "
"arXiv calls rather than running them alongside other work.",
code=5,
)
try:
feed = ET.fromstring(xml_text)
except ET.ParseError as error:
fail(
f"not parseable as XML: {error}. The first 100 bytes were: "
f"{xml_text[:100]!r} -- arXiv returns plain text rather than a feed for "
"throttling and some gateway errors."
)
entries = feed.findall("atom:entry", NS)
# The error check must precede any other interpretation: the error feed is a
# structurally valid one-hit search result.
for entry in entries:
if collapse_ws(entry.findtext("atom:title", namespaces=NS)) == "Error":
reason = collapse_ws(entry.findtext("atom:summary", namespaces=NS))
fail(f"arXiv returned an error feed: {reason or 'no reason given'}", code=3)
total = collapse_ws(feed.findtext("opensearch:totalResults", namespaces=NS))
# The feed echoes the query as arXiv actually ran it. An unrecognized
# field prefix is silently rewritten to `all:`, so this is the only way to see
# that the executed query is not the one that was sent.
echoed_query = collapse_ws(feed.findtext("atom:title", namespaces=NS))
records = [parse_entry(entry) for entry in entries]
if args.ids_only:
text = "".join(f"{record['arxiv_id']}\n" for record in records if record["arxiv_id"])
if args.output:
Path(args.output).write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
payload: dict[str, Any] = {
"total_results": int(total) if total.isdigit() else None,
"returned": len(records),
"query_as_executed": echoed_query or None,
"entries": records,
}
if not records:
payload["note"] = (
"zero entries with HTTP 200 is arXiv's genuine no-match response, including for "
"an unknown ID in id_list; report it as 'not found in arXiv', not as a failure"
)
emit(payload, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())