44查询示例: http://export.arxiv.org/api/query?search_query=all:electron&start=0&max_results=10
55"""
66
7+ import asyncio
8+ import logging
9+ import re
710import aiohttp
11+ import ssl
812import xml .etree .ElementTree as ET
9- from typing import List , Dict , Optional
1013from datetime import datetime
14+ from pathlib import Path
15+ from typing import Dict , List , Optional , Tuple
1116from urllib .parse import urlencode
17+
1218from app .core .config import settings
1319
20+ logger = logging .getLogger (__name__ )
1421
1522class ArxivProvider :
1623 """arXiv API 提供商"""
1724
1825 BASE_URL = settings .ARXIV_API_BASE
26+ RETRYABLE_STATUS = {429 , 500 , 502 , 503 , 504 }
1927
2028 # arXiv 命名空间
2129 NAMESPACES = {
@@ -68,14 +76,8 @@ async def search(
6876 }
6977
7078 url = f"{ self .BASE_URL } ?{ urlencode (params )} "
71-
72- async with aiohttp .ClientSession () as session :
73- async with session .get (url ) as response :
74- if response .status != 200 :
75- raise Exception (f"arXiv API 错误: { response .status } " )
76-
77- xml_content = await response .text ()
78- return self ._parse_xml_response (xml_content )
79+ xml_content = await self ._fetch_feed (url )
80+ return self ._parse_xml_response (xml_content )
7981
8082 async def get_by_id (self , arxiv_id : str ) -> Optional [Dict ]:
8183 """
@@ -96,14 +98,15 @@ async def get_by_id(self, arxiv_id: str) -> Optional[Dict]:
9698
9799 url = f"{ self .BASE_URL } ?{ urlencode (params )} "
98100
99- async with aiohttp .ClientSession () as session :
100- async with session .get (url ) as response :
101- if response .status != 200 :
102- return None
103-
104- xml_content = await response .text ()
105- results = self ._parse_xml_response (xml_content )
106- return results [0 ] if results else None
101+ try :
102+ xml_content = await self ._fetch_feed (url )
103+ except Exception as exc :
104+ if "404" in str (exc ):
105+ return None
106+ raise
107+
108+ results = self ._parse_xml_response (xml_content )
109+ return results [0 ] if results else None
107110
108111 async def search_by_author (
109112 self ,
@@ -140,13 +143,132 @@ def _build_query(self, query: str, category: Optional[str] = None) -> str:
140143
141144 布尔运算符: AND, OR, ANDNOT
142145 """
143- # 默认在标题和摘要中搜索
144- base_query = f'all:{ query } '
145-
146+ terms = self ._tokenize_terms (query )
147+ if not terms :
148+ base_query = 'all:*'
149+ else :
150+ formatted_terms : List [str ] = []
151+ for term , is_phrase in terms :
152+ formatted = self ._format_term (term , is_phrase )
153+ if formatted :
154+ formatted_terms .append (formatted )
155+ base_query = '+AND+' .join (formatted_terms ) if formatted_terms else 'all:*'
156+
146157 if category :
147158 base_query = f'({ base_query } )+AND+cat:{ category } '
148159
149160 return base_query
161+
162+ def _tokenize_terms (self , query : str ) -> List [Tuple [str , bool ]]:
163+ tokens = re .findall (r'"[^"]+"|\S+' , query or '' )
164+ parsed : List [Tuple [str , bool ]] = []
165+ for token in tokens :
166+ stripped = token .strip ()
167+ if not stripped :
168+ continue
169+ if stripped .startswith ('"' ) and stripped .endswith ('"' ) and len (stripped ) >= 2 :
170+ parsed .append ((stripped [1 :- 1 ].strip (), True ))
171+ else :
172+ parsed .append ((stripped , False ))
173+ return parsed
174+
175+ def _format_term (self , term : str , is_phrase : bool ) -> Optional [str ]:
176+ sanitized = term .replace ('"' , '' ).strip ()
177+ if not sanitized :
178+ return None
179+ if is_phrase or ' ' in sanitized :
180+ return f'all:"{ sanitized } "'
181+ return f'all:{ sanitized } '
182+
183+ def _build_session_kwargs (self ) -> Dict :
184+ """
185+ Construct aiohttp session kwargs honoring SSL-related settings.
186+ """
187+ connector = self ._build_connector ()
188+ kwargs : Dict = {
189+ "trust_env" : settings .ARXIV_TRUST_ENV ,
190+ "timeout" : aiohttp .ClientTimeout (total = settings .ARXIV_TIMEOUT ),
191+ }
192+ if connector is not None :
193+ kwargs ["connector" ] = connector
194+ return kwargs
195+
196+ def _build_connector (self ) -> Optional [aiohttp .TCPConnector ]:
197+ """
198+ Build a TCPConnector that respects custom CA bundles or disables SSL validation.
199+ """
200+ if not settings .ARXIV_VERIFY_SSL :
201+ return aiohttp .TCPConnector (ssl = False )
202+
203+ if settings .ARXIV_CA_BUNDLE :
204+ bundle_path = Path (settings .ARXIV_CA_BUNDLE )
205+ if bundle_path .is_file ():
206+ ssl_context = ssl .create_default_context (cafile = str (bundle_path ))
207+ return aiohttp .TCPConnector (ssl = ssl_context )
208+ logger .warning (
209+ "Configured ARXIV_CA_BUNDLE does not exist: %s. Falling back to default cert store." ,
210+ settings .ARXIV_CA_BUNDLE ,
211+ )
212+
213+ return None
214+
215+ def _build_headers (self ) -> Dict [str , str ]:
216+ return {
217+ "User-Agent" : settings .ARXIV_USER_AGENT ,
218+ }
219+
220+ async def _fetch_feed (self , url : str ) -> str :
221+ """
222+ 执行带有重试的 arXiv 请求,处理速率限制和暂时性错误。
223+ """
224+ session_kwargs = self ._build_session_kwargs ()
225+ headers = self ._build_headers ()
226+ retries = max (1 , settings .ARXIV_MAX_RETRIES )
227+ backoff = max (0.5 , settings .ARXIV_RETRY_BACKOFF_SECONDS )
228+
229+ async with aiohttp .ClientSession (** session_kwargs ) as session :
230+ for attempt in range (retries ):
231+ try :
232+ async with session .get (url , headers = headers ) as response :
233+ if response .status == 200 :
234+ return await response .text ()
235+
236+ if (
237+ response .status in self .RETRYABLE_STATUS
238+ and attempt < retries - 1
239+ ):
240+ await self ._respect_retry_after (response , attempt , backoff )
241+ continue
242+
243+ raise Exception (f"arXiv API 错误: { response .status } " )
244+
245+ except asyncio .TimeoutError as exc :
246+ if attempt >= retries - 1 :
247+ raise Exception ("arXiv 请求超时" ) from exc
248+ await asyncio .sleep (backoff * (2 ** attempt ))
249+
250+ except aiohttp .ClientError as exc :
251+ if attempt >= retries - 1 :
252+ raise Exception (f"arXiv 网络错误: { exc } " ) from exc
253+ await asyncio .sleep (backoff * (2 ** attempt ))
254+
255+ raise Exception ("arXiv API 错误: 请求失败" )
256+
257+ async def _respect_retry_after (
258+ self ,
259+ response : aiohttp .ClientResponse ,
260+ attempt : int ,
261+ backoff : float ,
262+ ) -> None :
263+ retry_after = response .headers .get ("Retry-After" )
264+ if retry_after :
265+ try :
266+ delay = float (retry_after )
267+ except ValueError :
268+ delay = backoff * (2 ** attempt )
269+ else :
270+ delay = backoff * (2 ** attempt )
271+ await asyncio .sleep (delay )
150272
151273 def _parse_xml_response (self , xml_content : str ) -> List [Dict ]:
152274 """
@@ -352,4 +474,4 @@ async def get_arxiv_paper(arxiv_id: str) -> Optional[Dict]:
352474 >>> paper = await get_arxiv_paper("2103.00020")
353475 """
354476 provider = ArxivProvider ()
355- return await provider .get_by_id (arxiv_id )
477+ return await provider .get_by_id (arxiv_id )
0 commit comments