Edit on GitHub

agent_search_gateway.providers.academic.arxiv

arXiv Atom API discovery adapter.

  1"""arXiv Atom API discovery adapter."""
  2
  3from __future__ import annotations
  4
  5import xml.etree.ElementTree as ET
  6
  7from ...academic.normalization import normalize_arxiv_id, normalize_doi
  8from ..contracts import PaperSearchHit
  9from .common import AcademicHttpExecutor, parse_iso_date, protocol_failure, reject_item
 10
 11_ATOM = "http://www.w3.org/2005/Atom"
 12_ARXIV = "http://arxiv.org/schemas/atom"
 13_DEFAULT_API_URL = "https://export.arxiv.org/api/query"
 14
 15
 16class ArxivProvider:
 17    name = "arxiv"
 18
 19    def __init__(
 20        self,
 21        executor: AcademicHttpExecutor,
 22        *,
 23        api_url: str = _DEFAULT_API_URL,
 24    ) -> None:
 25        self._executor = executor
 26        self._api_url = api_url
 27
 28    async def search(self, query: str) -> list[PaperSearchHit]:
 29        body = await self._executor.request_text(
 30            "GET",
 31            self._api_url,
 32            stage="paper_search",
 33            params={
 34                "search_query": f"all:{query}",
 35                "max_results": 10,
 36                "sortBy": "relevance",
 37                "sortOrder": "descending",
 38            },
 39        )
 40        try:
 41            root = ET.fromstring(body)
 42        except ET.ParseError as exc:
 43            raise protocol_failure(self.name, "response was not valid Atom XML") from exc
 44        if root.tag != f"{{{_ATOM}}}feed":
 45            raise protocol_failure(self.name, "response Atom envelope was invalid")
 46
 47        hits: list[PaperSearchHit] = []
 48        for entry in root.findall(f"{{{_ATOM}}}entry"):
 49            mapped = self._map_entry(entry)
 50            if mapped is None:
 51                reject_item(self.name)
 52                continue
 53            hits.append(mapped)
 54        return hits
 55
 56    def _map_entry(self, entry: ET.Element) -> PaperSearchHit | None:
 57        raw_id = self._child_text(entry, _ATOM, "id")
 58        source_id = normalize_arxiv_id(raw_id)
 59        title = self._clean(self._child_text(entry, _ATOM, "title"))
 60        if source_id is None or not title:
 61            return None
 62
 63        landing_url = raw_id
 64        pdf_url = ""
 65        for link in entry.findall(f"{{{_ATOM}}}link"):
 66            href = (link.get("href") or "").strip()
 67            if not href:
 68                continue
 69            if link.get("rel") == "alternate":
 70                landing_url = href
 71            if link.get("title") == "pdf" or link.get("type") == "application/pdf":
 72                pdf_url = href
 73        if not landing_url:
 74            return None
 75
 76        authors = tuple(
 77            name
 78            for author in entry.findall(f"{{{_ATOM}}}author")
 79            if (name := self._clean(self._child_text(author, _ATOM, "name")))
 80        )
 81        topics = tuple(
 82            term
 83            for category in entry.findall(f"{{{_ATOM}}}category")
 84            if (term := (category.get("term") or "").strip())
 85        )
 86        return PaperSearchHit(
 87            source=self.name,
 88            source_id=source_id,
 89            title=title,
 90            authors=authors,
 91            abstract=self._clean(self._child_text(entry, _ATOM, "summary")),
 92            doi=normalize_doi(self._child_text(entry, _ARXIV, "doi")) or "",
 93            arxiv_id=source_id,
 94            published_date=parse_iso_date(self._child_text(entry, _ATOM, "published")),
 95            updated_date=parse_iso_date(self._child_text(entry, _ATOM, "updated")),
 96            url=landing_url,
 97            pdf_url=pdf_url,
 98            topics=topics,
 99        )
100
101    @staticmethod
102    def _child_text(parent: ET.Element, namespace: str, name: str) -> str:
103        child = parent.find(f"{{{namespace}}}{name}")
104        return child.text if child is not None and child.text is not None else ""
105
106    @staticmethod
107    def _clean(value: str) -> str:
108        return " ".join(value.split())
class ArxivProvider:
 17class ArxivProvider:
 18    name = "arxiv"
 19
 20    def __init__(
 21        self,
 22        executor: AcademicHttpExecutor,
 23        *,
 24        api_url: str = _DEFAULT_API_URL,
 25    ) -> None:
 26        self._executor = executor
 27        self._api_url = api_url
 28
 29    async def search(self, query: str) -> list[PaperSearchHit]:
 30        body = await self._executor.request_text(
 31            "GET",
 32            self._api_url,
 33            stage="paper_search",
 34            params={
 35                "search_query": f"all:{query}",
 36                "max_results": 10,
 37                "sortBy": "relevance",
 38                "sortOrder": "descending",
 39            },
 40        )
 41        try:
 42            root = ET.fromstring(body)
 43        except ET.ParseError as exc:
 44            raise protocol_failure(self.name, "response was not valid Atom XML") from exc
 45        if root.tag != f"{{{_ATOM}}}feed":
 46            raise protocol_failure(self.name, "response Atom envelope was invalid")
 47
 48        hits: list[PaperSearchHit] = []
 49        for entry in root.findall(f"{{{_ATOM}}}entry"):
 50            mapped = self._map_entry(entry)
 51            if mapped is None:
 52                reject_item(self.name)
 53                continue
 54            hits.append(mapped)
 55        return hits
 56
 57    def _map_entry(self, entry: ET.Element) -> PaperSearchHit | None:
 58        raw_id = self._child_text(entry, _ATOM, "id")
 59        source_id = normalize_arxiv_id(raw_id)
 60        title = self._clean(self._child_text(entry, _ATOM, "title"))
 61        if source_id is None or not title:
 62            return None
 63
 64        landing_url = raw_id
 65        pdf_url = ""
 66        for link in entry.findall(f"{{{_ATOM}}}link"):
 67            href = (link.get("href") or "").strip()
 68            if not href:
 69                continue
 70            if link.get("rel") == "alternate":
 71                landing_url = href
 72            if link.get("title") == "pdf" or link.get("type") == "application/pdf":
 73                pdf_url = href
 74        if not landing_url:
 75            return None
 76
 77        authors = tuple(
 78            name
 79            for author in entry.findall(f"{{{_ATOM}}}author")
 80            if (name := self._clean(self._child_text(author, _ATOM, "name")))
 81        )
 82        topics = tuple(
 83            term
 84            for category in entry.findall(f"{{{_ATOM}}}category")
 85            if (term := (category.get("term") or "").strip())
 86        )
 87        return PaperSearchHit(
 88            source=self.name,
 89            source_id=source_id,
 90            title=title,
 91            authors=authors,
 92            abstract=self._clean(self._child_text(entry, _ATOM, "summary")),
 93            doi=normalize_doi(self._child_text(entry, _ARXIV, "doi")) or "",
 94            arxiv_id=source_id,
 95            published_date=parse_iso_date(self._child_text(entry, _ATOM, "published")),
 96            updated_date=parse_iso_date(self._child_text(entry, _ATOM, "updated")),
 97            url=landing_url,
 98            pdf_url=pdf_url,
 99            topics=topics,
100        )
101
102    @staticmethod
103    def _child_text(parent: ET.Element, namespace: str, name: str) -> str:
104        child = parent.find(f"{{{namespace}}}{name}")
105        return child.text if child is not None and child.text is not None else ""
106
107    @staticmethod
108    def _clean(value: str) -> str:
109        return " ".join(value.split())
ArxivProvider( executor: agent_search_gateway.providers.academic.common.AcademicHttpExecutor, *, api_url: str = 'https://exportagent_search_gateway.providers.academic.arxiv.org/api/query')
20    def __init__(
21        self,
22        executor: AcademicHttpExecutor,
23        *,
24        api_url: str = _DEFAULT_API_URL,
25    ) -> None:
26        self._executor = executor
27        self._api_url = api_url
name = 'arxiv'
async def search( self, query: str) -> list[agent_search_gateway.providers.contracts.PaperSearchHit]:
29    async def search(self, query: str) -> list[PaperSearchHit]:
30        body = await self._executor.request_text(
31            "GET",
32            self._api_url,
33            stage="paper_search",
34            params={
35                "search_query": f"all:{query}",
36                "max_results": 10,
37                "sortBy": "relevance",
38                "sortOrder": "descending",
39            },
40        )
41        try:
42            root = ET.fromstring(body)
43        except ET.ParseError as exc:
44            raise protocol_failure(self.name, "response was not valid Atom XML") from exc
45        if root.tag != f"{{{_ATOM}}}feed":
46            raise protocol_failure(self.name, "response Atom envelope was invalid")
47
48        hits: list[PaperSearchHit] = []
49        for entry in root.findall(f"{{{_ATOM}}}entry"):
50            mapped = self._map_entry(entry)
51            if mapped is None:
52                reject_item(self.name)
53                continue
54            hits.append(mapped)
55        return hits