agent_search_gateway.providers.academic.crossref
Crossref works API discovery adapter.
1"""Crossref works API discovery adapter.""" 2 3from __future__ import annotations 4 5import html 6import re 7from datetime import date 8 9from ...academic.normalization import normalize_doi 10from ...observability import SecretValue 11from ..contracts import PaperSearchHit 12from .common import ( 13 AcademicHttpExecutor, 14 as_list, 15 as_mapping, 16 join_url, 17 nonnegative_int, 18 protocol_failure, 19 reject_item, 20 text, 21) 22 23_DEFAULT_API_URL = "https://api.crossref.org" 24_TAG_RE = re.compile(r"<[^>]+>") 25_DATE_KEYS = ("published-print", "published-online", "published", "issued", "created") 26 27 28class CrossrefProvider: 29 name = "crossref" 30 31 def __init__( 32 self, 33 executor: AcademicHttpExecutor, 34 *, 35 api_url: str = _DEFAULT_API_URL, 36 contact_email: SecretValue | None = None, 37 ) -> None: 38 self._executor = executor 39 self._api_url = api_url 40 self._contact = contact_email 41 42 async def search(self, query: str) -> list[PaperSearchHit]: 43 params: dict[str, object] = { 44 "query": query, 45 "rows": 10, 46 "sort": "relevance", 47 "order": "desc", 48 } 49 if self._contact is not None: 50 reveal = self._contact.reveal 51 params["mailto"] = reveal() 52 payload = await self._executor.request_json( 53 "GET", 54 join_url(self._api_url, "works"), 55 stage="paper_search", 56 params=params, 57 ) 58 envelope = as_mapping(payload) 59 message = as_mapping(envelope.get("message")) if envelope is not None else None 60 items = as_list(message.get("items")) if message is not None else None 61 if items is None: 62 raise protocol_failure(self.name, "response message.items envelope was invalid") 63 hits: list[PaperSearchHit] = [] 64 for item in items: 65 mapped = self._map_item(item) 66 if mapped is None: 67 reject_item(self.name) 68 else: 69 hits.append(mapped) 70 return hits 71 72 def _map_item(self, value: object) -> PaperSearchHit | None: 73 item = as_mapping(value) 74 if item is None: 75 return None 76 doi = normalize_doi(text(item.get("DOI"))) 77 title = self._first_string(item.get("title")) 78 if doi is None or not title: 79 return None 80 authors = self._authors(item.get("author")) 81 venue = self._first_string(item.get("container-title")) 82 url = text(item.get("URL")) or f"https://doi.org/{doi}" 83 return PaperSearchHit( 84 source=self.name, 85 source_id=doi, 86 title=title, 87 authors=authors, 88 abstract=self._abstract(item.get("abstract")), 89 doi=doi, 90 published_date=self._date(item), 91 url=url, 92 pdf_url=self._pdf_url(item.get("link")), 93 venue=venue, 94 citation_count=nonnegative_int(item.get("is-referenced-by-count")), 95 ) 96 97 @staticmethod 98 def _first_string(value: object) -> str: 99 values = as_list(value) or [] 100 for raw in values: 101 candidate = text(raw) 102 if candidate: 103 return candidate 104 return "" 105 106 @staticmethod 107 def _authors(value: object) -> tuple[str, ...]: 108 authors: list[str] = [] 109 for raw_author in as_list(value) or []: 110 author = as_mapping(raw_author) 111 if author is None: 112 continue 113 given = text(author.get("given")) 114 family = text(author.get("family")) 115 name = " ".join(part for part in (given, family) if part) 116 if name: 117 authors.append(name) 118 return tuple(authors) 119 120 @staticmethod 121 def _abstract(value: object) -> str: 122 raw = text(value) 123 if not raw: 124 return "" 125 return " ".join(html.unescape(_TAG_RE.sub(" ", raw)).split()) 126 127 @classmethod 128 def _date(cls, item: object) -> date | None: 129 mapping = as_mapping(item) 130 if mapping is None: 131 return None 132 for key in _DATE_KEYS: 133 date_mapping = as_mapping(mapping.get(key)) 134 parts_rows = ( 135 as_list(date_mapping.get("date-parts")) if date_mapping is not None else None 136 ) 137 if not parts_rows: 138 continue 139 parts = as_list(parts_rows[0]) 140 if not parts or isinstance(parts[0], bool) or not isinstance(parts[0], int): 141 continue 142 year = parts[0] 143 month = ( 144 parts[1] 145 if len(parts) > 1 and isinstance(parts[1], int) and not isinstance(parts[1], bool) 146 else 1 147 ) 148 day = ( 149 parts[2] 150 if len(parts) > 2 and isinstance(parts[2], int) and not isinstance(parts[2], bool) 151 else 1 152 ) 153 try: 154 return date(year, month, day) 155 except ValueError: 156 continue 157 return None 158 159 @staticmethod 160 def _pdf_url(value: object) -> str: 161 for raw_link in as_list(value) or []: 162 link = as_mapping(raw_link) 163 if link is None: 164 continue 165 candidate = text(link.get("URL")) 166 content_type = text(link.get("content-type")).casefold() 167 if candidate and content_type == "application/pdf": 168 return candidate 169 return ""
class
CrossrefProvider:
29class CrossrefProvider: 30 name = "crossref" 31 32 def __init__( 33 self, 34 executor: AcademicHttpExecutor, 35 *, 36 api_url: str = _DEFAULT_API_URL, 37 contact_email: SecretValue | None = None, 38 ) -> None: 39 self._executor = executor 40 self._api_url = api_url 41 self._contact = contact_email 42 43 async def search(self, query: str) -> list[PaperSearchHit]: 44 params: dict[str, object] = { 45 "query": query, 46 "rows": 10, 47 "sort": "relevance", 48 "order": "desc", 49 } 50 if self._contact is not None: 51 reveal = self._contact.reveal 52 params["mailto"] = reveal() 53 payload = await self._executor.request_json( 54 "GET", 55 join_url(self._api_url, "works"), 56 stage="paper_search", 57 params=params, 58 ) 59 envelope = as_mapping(payload) 60 message = as_mapping(envelope.get("message")) if envelope is not None else None 61 items = as_list(message.get("items")) if message is not None else None 62 if items is None: 63 raise protocol_failure(self.name, "response message.items envelope was invalid") 64 hits: list[PaperSearchHit] = [] 65 for item in items: 66 mapped = self._map_item(item) 67 if mapped is None: 68 reject_item(self.name) 69 else: 70 hits.append(mapped) 71 return hits 72 73 def _map_item(self, value: object) -> PaperSearchHit | None: 74 item = as_mapping(value) 75 if item is None: 76 return None 77 doi = normalize_doi(text(item.get("DOI"))) 78 title = self._first_string(item.get("title")) 79 if doi is None or not title: 80 return None 81 authors = self._authors(item.get("author")) 82 venue = self._first_string(item.get("container-title")) 83 url = text(item.get("URL")) or f"https://doi.org/{doi}" 84 return PaperSearchHit( 85 source=self.name, 86 source_id=doi, 87 title=title, 88 authors=authors, 89 abstract=self._abstract(item.get("abstract")), 90 doi=doi, 91 published_date=self._date(item), 92 url=url, 93 pdf_url=self._pdf_url(item.get("link")), 94 venue=venue, 95 citation_count=nonnegative_int(item.get("is-referenced-by-count")), 96 ) 97 98 @staticmethod 99 def _first_string(value: object) -> str: 100 values = as_list(value) or [] 101 for raw in values: 102 candidate = text(raw) 103 if candidate: 104 return candidate 105 return "" 106 107 @staticmethod 108 def _authors(value: object) -> tuple[str, ...]: 109 authors: list[str] = [] 110 for raw_author in as_list(value) or []: 111 author = as_mapping(raw_author) 112 if author is None: 113 continue 114 given = text(author.get("given")) 115 family = text(author.get("family")) 116 name = " ".join(part for part in (given, family) if part) 117 if name: 118 authors.append(name) 119 return tuple(authors) 120 121 @staticmethod 122 def _abstract(value: object) -> str: 123 raw = text(value) 124 if not raw: 125 return "" 126 return " ".join(html.unescape(_TAG_RE.sub(" ", raw)).split()) 127 128 @classmethod 129 def _date(cls, item: object) -> date | None: 130 mapping = as_mapping(item) 131 if mapping is None: 132 return None 133 for key in _DATE_KEYS: 134 date_mapping = as_mapping(mapping.get(key)) 135 parts_rows = ( 136 as_list(date_mapping.get("date-parts")) if date_mapping is not None else None 137 ) 138 if not parts_rows: 139 continue 140 parts = as_list(parts_rows[0]) 141 if not parts or isinstance(parts[0], bool) or not isinstance(parts[0], int): 142 continue 143 year = parts[0] 144 month = ( 145 parts[1] 146 if len(parts) > 1 and isinstance(parts[1], int) and not isinstance(parts[1], bool) 147 else 1 148 ) 149 day = ( 150 parts[2] 151 if len(parts) > 2 and isinstance(parts[2], int) and not isinstance(parts[2], bool) 152 else 1 153 ) 154 try: 155 return date(year, month, day) 156 except ValueError: 157 continue 158 return None 159 160 @staticmethod 161 def _pdf_url(value: object) -> str: 162 for raw_link in as_list(value) or []: 163 link = as_mapping(raw_link) 164 if link is None: 165 continue 166 candidate = text(link.get("URL")) 167 content_type = text(link.get("content-type")).casefold() 168 if candidate and content_type == "application/pdf": 169 return candidate 170 return ""
CrossrefProvider( executor: agent_search_gateway.providers.academic.common.AcademicHttpExecutor, *, api_url: str = 'https://api.crossref.org', contact_email: agent_search_gateway.observability.SecretValue | None = None)
async def
search( self, query: str) -> list[agent_search_gateway.providers.contracts.PaperSearchHit]:
43 async def search(self, query: str) -> list[PaperSearchHit]: 44 params: dict[str, object] = { 45 "query": query, 46 "rows": 10, 47 "sort": "relevance", 48 "order": "desc", 49 } 50 if self._contact is not None: 51 reveal = self._contact.reveal 52 params["mailto"] = reveal() 53 payload = await self._executor.request_json( 54 "GET", 55 join_url(self._api_url, "works"), 56 stage="paper_search", 57 params=params, 58 ) 59 envelope = as_mapping(payload) 60 message = as_mapping(envelope.get("message")) if envelope is not None else None 61 items = as_list(message.get("items")) if message is not None else None 62 if items is None: 63 raise protocol_failure(self.name, "response message.items envelope was invalid") 64 hits: list[PaperSearchHit] = [] 65 for item in items: 66 mapped = self._map_item(item) 67 if mapped is None: 68 reject_item(self.name) 69 else: 70 hits.append(mapped) 71 return hits