/**
* arXiv adapter utilities.
*
* arXiv exposes a public Atom/XML API — no key required.
* https://info.arxiv.org/help/api/index.html
*/
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
const ARXIV_CATEGORY_PATTERN = /^[a-z]+(?:-[a-z]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)?$/;
export async function arxivFetch(params) {
const resp = await fetch(`${ARXIV_BASE}?${params}`);
if (!resp.ok) {
throw new CommandExecutionError(`arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
}
return resp.text();
}
export function normalizeArxivLimit(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const limit = Number(raw);
if (!Number.isInteger(limit) || limit <= 0) {
throw new ArgumentError(`arxiv ${label} must be a positive integer`);
}
if (limit > maxValue) {
throw new ArgumentError(`arxiv ${label} must be <= ${maxValue}`);
}
return limit;
}
export function normalizeArxivCategory(value) {
const category = String(value || '').trim();
if (!ARXIV_CATEGORY_PATTERN.test(category)) {
throw new ArgumentError(`Invalid arXiv category "${value}". Examples: cs.CL, cs.LG, math.PR, q-bio.NC, physics.comp-ph`);
}
return category;
}
/** Decode the small set of XML entities arXiv emits in text fields. */
function decodeEntities(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
}
/** Extract the text content of the first matching XML tag. */
function extract(xml, tag) {
const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
return m ? m[1].trim() : '';
}
/** Extract all text contents of a repeated XML tag. */
function extractAll(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g');
const results = [];
let m;
while ((m = re.exec(xml)) !== null)
results.push(m[1].trim());
return results;
}
/** Extract the value of a named attribute from the first matching tag (open or self-closing). */
function extractAttr(xml, tag, attr) {
const m = xml.match(new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`));
return m ? m[1] : '';
}
/** Extract all values of a named attribute across repeated tags. */
function extractAllAttr(xml, tag, attr) {
const re = new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`, 'g');
const out = [];
let m;
while ((m = re.exec(xml)) !== null)
out.push(m[1]);
return out;
}
/** Find the href of the first tag matching a given rel. */
function findLinkHref(xml, rel) {
const re = /]*)\/?>/g;
let m;
while ((m = re.exec(xml)) !== null) {
const attrs = m[1];
if (new RegExp(`\\brel="${rel}"`).test(attrs)) {
const h = attrs.match(/\bhref="([^"]*)"/);
if (h)
return h[1];
}
}
return '';
}
/** Parse Atom XML feed into structured entries. */
export function parseEntries(xml) {
const entryRe = /([\s\S]*?)<\/entry>/g;
const entries = [];
let m;
while ((m = entryRe.exec(xml)) !== null) {
const e = m[1];
const rawId = extract(e, 'id');
const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
const pdf = findLinkHref(e, 'related') || `https://arxiv.org/pdf/${arxivId}`;
entries.push({
id: arxivId,
title: decodeEntities(extract(e, 'title').replace(/\s+/g, ' ')),
authors: decodeEntities(extractAll(e, 'name').join(', ')),
abstract: decodeEntities(extract(e, 'summary').replace(/\s+/g, ' ')),
published: extract(e, 'published').slice(0, 10),
updated: extract(e, 'updated').slice(0, 10),
primary_category: extractAttr(e, 'arxiv:primary_category', 'term'),
categories: extractAllAttr(e, 'category', 'term').join(', '),
comment: decodeEntities(extract(e, 'arxiv:comment').replace(/\s+/g, ' ')),
pdf,
url: `https://arxiv.org/abs/${arxivId}`,
});
}
return entries;
}