Coverage for src/local_deep_research/web_search_engines/engines/search_engine_arxiv.py: 99%

207 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import re 

2from typing import Any, Dict, List, Optional 

3 

4from langchain_core.language_models import BaseLLM 

5from requests.exceptions import RequestException 

6 

7from ...constants import SNIPPET_LENGTH_SHORT 

8from ...security.secure_logging import logger 

9from ...utilities.arxiv_api import ( 

10 ArxivIdRequest, 

11 ArxivPaper, 

12 ArxivQueryRequest, 

13 ArxivSortCriterion, 

14 ArxivSortOrder, 

15 fetch_arxiv_results, 

16) 

17from ..rate_limiting import RateLimitError 

18from ..engine_availability import retry_after_from_error 

19from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

20 

21# Canonical arXiv identifier shapes, anchored so nothing else slips through 

22# before we build an egress URL from the value: 

23# - new style: 2301.12345 / 2301.12345v2 (4-or-5 digit sequence) 

24# - old style: math.GT/0309136 or cond-mat/0501234 (with optional version) 

25_ARXIV_ID_RE = re.compile( 

26 r"^(?:\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)$" 

27) 

28 

29 

30class FullTextNotExtractedError(Exception): 

31 """The paper's PDF was downloaded but produced no text. 

32 

33 Raised by ``ArXivSearchEngine._fetch_full_text`` so the caller can tell 

34 it apart from a failed fetch (``None``): the download and the 

35 extraction were paid for, so the full-text budget stays spent. 

36 """ 

37 

38 

39class FullTextFetchFailedError(Exception): 

40 """A full-text request to arXiv failed instead of being answered. 

41 

42 Raised by ``ArXivSearchEngine._fetch_full_text`` when the paper's HTML 

43 or PDF request timed out, could not connect, got a 5xx or other 

44 unexpected status, or hit the decoded-body cap, and no text arrived. 

45 Nothing says the paper lacks full text: the host is failing, so 

46 ``_get_full_content`` makes no further full-text fetches for the rest 

47 of that call instead of paying a request (and a timeout) per paper. 

48 """ 

49 

50 

51class FullTextRateLimitedError(FullTextFetchFailedError): 

52 """arXiv answered the paper's PDF request with HTTP 429 or 503. 

53 

54 Raised by ``ArXivSearchEngine._fetch_full_text``. The host is rate 

55 limiting or unavailable: as for any ``FullTextFetchFailedError``, 

56 ``_get_full_content`` makes no further full-text fetches for the rest 

57 of that call instead of sending a request per paper. 

58 """ 

59 

60 

61class ArXivSearchEngine(BaseSearchEngine): 

62 """arXiv search engine implementation with two-phase approach""" 

63 

64 # Mark as public search engine 

65 is_public = True 

66 egress_sensitivity = Sensitivity.NON_SENSITIVE 

67 egress_exposure = Exposure.EXPOSING 

68 # Not a generic search engine (specialized for academic papers) 

69 is_generic = False 

70 # Scientific/academic search engine 

71 is_scientific = True 

72 is_lexical = True 

73 needs_llm_relevance_filter = True 

74 

75 def __init__( 

76 self, 

77 max_results: int = 10, 

78 sort_by: str = "relevance", 

79 sort_order: str = "descending", 

80 include_full_text: bool = False, 

81 download_dir: Optional[str] = None, 

82 max_full_text: int = 1, 

83 llm: Optional[BaseLLM] = None, 

84 max_filtered_results: Optional[int] = None, 

85 settings_snapshot: Optional[Dict[str, Any]] = None, 

86 ): # Added this parameter 

87 """ 

88 Initialize the arXiv search engine. 

89 

90 Args: 

91 max_results: Maximum number of search results 

92 sort_by: Sorting criteria ('relevance', 'lastUpdatedDate', or 'submittedDate') 

93 sort_order: Sort order ('ascending' or 'descending') 

94 include_full_text: Whether to include full paper content in results (downloads PDF) 

95 download_dir: Gates full-text fetching -- full text is only 

96 attempted when include_full_text is True and this is set. 

97 The value itself is not used: _fetch_full_text routes 

98 through the shared HTML-first ArxivDownloader (issue #4783), 

99 which keeps the text in memory and writes nothing to disk. 

100 max_full_text: Maximum number of papers whose full text is 

101 fetched (default: 1). A paper counts once full text or its 

102 PDF arrived, even if the PDF yielded no text. A paper arXiv 

103 answered has no full text (no rendition, and its PDF answered 

104 404/410 or with no PDF) does not count, so a later paper 

105 still gets the attempt. A request that failed instead of 

106 being answered (timeout, connection error, 5xx, HTTP 

107 429/503) counts, and stops full-text fetching for the 

108 remaining papers of that search, so a failing host costs 

109 one paper's requests rather than one per paper. 

110 llm: Language model for relevance filtering 

111 max_filtered_results: Maximum number of results to keep after filtering 

112 settings_snapshot: Settings snapshot for thread context 

113 """ 

114 # Initialize the journal reputation filter if needed. 

115 # Runs as a preview filter (before LLM relevance) because Tiers 1-3 

116 # are instant data lookups — no point sending irrelevant journals 

117 # through the expensive LLM relevance filter. 

118 preview_filters = [] 

119 journal_filter = self._create_journal_filter( 

120 "arxiv", llm, settings_snapshot 

121 ) 

122 if journal_filter is not None: 

123 preview_filters.append(journal_filter) 

124 

125 super().__init__( 

126 llm=llm, 

127 max_filtered_results=max_filtered_results, 

128 max_results=max_results, 

129 preview_filters=preview_filters, # type: ignore[arg-type] 

130 settings_snapshot=settings_snapshot, 

131 ) 

132 self.max_results = max(self.max_results, 25) 

133 self.sort_by = sort_by 

134 self.sort_order = sort_order 

135 self.include_full_text = include_full_text 

136 self.download_dir = download_dir 

137 self.max_full_text = max_full_text 

138 

139 self.sort_criteria = { 

140 "relevance": ArxivSortCriterion.RELEVANCE, 

141 "lastUpdatedDate": ArxivSortCriterion.LAST_UPDATED_DATE, 

142 "submittedDate": ArxivSortCriterion.SUBMITTED_DATE, 

143 } 

144 

145 self.sort_directions = { 

146 "ascending": ArxivSortOrder.ASCENDING, 

147 "descending": ArxivSortOrder.DESCENDING, 

148 } 

149 

150 def _get_search_results(self, query: str) -> list[ArxivPaper]: 

151 """ 

152 Helper method to get search results from arXiv API. 

153 

154 Args: 

155 query: The search query 

156 

157 Returns: 

158 List of arXiv paper objects 

159 """ 

160 # Configure the search client 

161 sort_criteria = self.sort_criteria.get( 

162 self.sort_by, ArxivSortCriterion.RELEVANCE 

163 ) 

164 sort_order = self.sort_directions.get( 

165 self.sort_order, ArxivSortOrder.DESCENDING 

166 ) 

167 

168 request = ArxivQueryRequest( 

169 query=query, 

170 max_results=self.max_results, 

171 sort_by=sort_criteria, 

172 sort_order=sort_order, 

173 ) 

174 

175 # Apply rate limiting before making the request 

176 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

177 self.engine_type 

178 ) 

179 

180 return fetch_arxiv_results(request) 

181 

182 @staticmethod 

183 def _validated_arxiv_id(paper: Any) -> Optional[str]: 

184 """Return a canonical, validated arXiv id for ``paper`` or ``None``. 

185 

186 The id is derived from the arxiv-provided ``entry_id`` (e.g. 

187 ``http://arxiv.org/abs/2301.12345v1``) and matched against 

188 ``_ARXIV_ID_RE`` before it is ever used to build an egress URL, 

189 so a malformed or unexpected value cannot be interpolated into a 

190 request target. 

191 """ 

192 entry_id = getattr(paper, "entry_id", "") or "" 

193 match = re.search(r"arxiv\.org/abs/(.+)$", entry_id) 

194 candidate = (match.group(1) if match else entry_id).strip() 

195 if _ARXIV_ID_RE.match(candidate): 

196 return candidate 

197 return None 

198 

199 def _fetch_full_text(self, paper: Any) -> Optional[str]: 

200 """Fetch a paper's full text through the shared HTML-first downloader. 

201 

202 This routes ``ArXivSearchEngine``'s full-text step through 

203 ``ArxivDownloader.download_full_text`` (issue #4783): the 

204 equation-preserving official arXiv HTML rendition when one exists, 

205 then the arXiv PDF, and never the API metadata leg that 

206 ``download_text_with_source`` would add. That replaces the pypdf 

207 extraction this engine used to inline. 

208 

209 The downloader is imported lazily: importing 

210 ``research_library.downloaders.arxiv`` at module scope would 

211 pull the whole download stack into every engine import, and 

212 its base module imports back into 

213 ``web_search_engines.rate_limiting``. 

214 

215 The downloader fetches from ``export.arxiv.org``, the host arXiv 

216 sets aside for programmatic access, rather than ``arxiv.org``. 

217 

218 Returns the text only when it is full text: the HTML rendition or 

219 text extracted from the PDF. Returns ``None`` when arXiv answered 

220 that the paper has no full text (no usable rendition, and no PDF 

221 bytes). The downloader handles its own transport errors on every 

222 leg (HTTP errors, 429s, connection failures and the decoded-body 

223 cap all come back as a status, not as an exception), which this 

224 method turns into the exceptions below. The API metadata leg is 

225 never requested (``download_full_text``): it yields the abstract, 

226 which the engine already holds as the paper summary, so it would 

227 add an arXiv API request without adding text. 

228 

229 Raises ``FullTextNotExtractedError`` when the PDF arrived but 

230 produced no text (an image-only scan, or the extraction ceilings 

231 hit first), ``FullTextRateLimitedError`` when arXiv answered the 

232 PDF request with HTTP 429 or 503 (the PDF leg makes one attempt 

233 and does not retry), ``FullTextFetchFailedError`` when a request 

234 failed instead of being answered (timeout, connection error, 5xx, 

235 the decoded-body cap), and ``ValueError`` when the paper carries 

236 no valid arXiv id. 

237 """ 

238 arxiv_id = self._validated_arxiv_id(paper) 

239 if not arxiv_id: 

240 raise ValueError("Could not derive a valid arXiv id for full text") 

241 

242 from ...research_library.downloaders.arxiv import ( 

243 ARXIV_EXPORT_HOST, 

244 ArxivDownloader, 

245 ArxivFullTextStatus, 

246 ArxivTextSource, 

247 ) 

248 

249 downloader = getattr(self, "_arxiv_text_downloader", None) 

250 if downloader is None: 

251 downloader = ArxivDownloader(fetch_host=ARXIV_EXPORT_HOST) 

252 self._arxiv_text_downloader = downloader 

253 outcome = downloader.download_full_text( 

254 f"https://arxiv.org/abs/{arxiv_id}" 

255 ) 

256 if outcome.status == ArxivFullTextStatus.PDF_WITHOUT_TEXT: 

257 raise FullTextNotExtractedError( 

258 f"arXiv PDF for {arxiv_id} yielded no text" 

259 ) 

260 if outcome.status == ArxivFullTextStatus.RATE_LIMITED: 

261 raise FullTextRateLimitedError( 

262 f"arXiv rate limited the PDF request for {arxiv_id}" 

263 ) 

264 if outcome.status == ArxivFullTextStatus.FETCH_FAILED: 

265 raise FullTextFetchFailedError( 

266 f"arXiv full-text request failed for {arxiv_id}" 

267 ) 

268 result = outcome.result 

269 if result is None or result.source not in ( 

270 ArxivTextSource.ARXIV_HTML, 

271 ArxivTextSource.LOCAL_PDF, 

272 ): 

273 return None 

274 return result.text 

275 

276 def close(self) -> None: 

277 """Close the lazily-created full-text downloader, then the base 

278 engine's own resources. 

279 

280 ``_fetch_full_text`` caches an ``ArxivDownloader`` (which owns an 

281 HTTP session) on ``self._arxiv_text_downloader`` the first time 

282 full text is fetched; it is only ever created there, so nothing 

283 else in this engine would release it. The attribute is cleared after 

284 closing, so reuse after close() starts from a fresh downloader. 

285 """ 

286 from ...utilities.resource_utils import safe_close 

287 

288 downloader = getattr(self, "_arxiv_text_downloader", None) 

289 if downloader is not None: 

290 safe_close(downloader, "arXiv text downloader") 

291 # Drop the closed instance so a later full-text fetch builds a 

292 # fresh downloader instead of reusing one without a session. 

293 del self._arxiv_text_downloader 

294 super().close() 

295 

296 def _log_full_text_error( 

297 self, error: Exception, title: Optional[str] = None 

298 ) -> None: 

299 """Log an error raised by the full-text step without leaking paths. 

300 

301 An ``OSError`` message can carry a local filesystem path, which 

302 ``_scrub_error`` does not remove, and ``logger.exception`` output 

303 reaches the research owner's browser via ``frontend_progress_sink``. 

304 So an ``OSError`` is logged by its type name only (which is 

305 path-free and still tells an operator what kind of failure it 

306 was). ``requests``' ``RequestException`` subclasses ``OSError`` but 

307 carries no local path, so it keeps the scrubbed message, as does 

308 every other exception. 

309 """ 

310 paper = f"paper {title} " if title is not None else "paper " 

311 if isinstance(error, OSError) and not isinstance( 

312 error, RequestException 

313 ): 

314 logger.exception( 

315 f"Error downloading {paper}({type(error).__name__}): " 

316 "a filesystem error occurred" 

317 ) 

318 return 

319 safe_msg = self._scrub_error(error) 

320 logger.exception( 

321 f"Error downloading {paper}({type(error).__name__}): {safe_msg}" 

322 ) 

323 

324 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

325 """ 

326 Get preview information for arXiv papers. 

327 

328 Args: 

329 query: The search query 

330 

331 Returns: 

332 List of preview dictionaries 

333 """ 

334 logger.info("Getting paper previews from arXiv") 

335 

336 try: 

337 # Get search results from arXiv 

338 papers = self._get_search_results(query) 

339 

340 # Store the paper objects for later use 

341 self._papers = {paper.entry_id: paper for paper in papers} 

342 

343 # Format results as previews with basic information 

344 previews = [] 

345 for paper in papers: 

346 preview = { 

347 "id": paper.entry_id, # Use entry_id as ID 

348 "title": paper.title, 

349 "link": paper.entry_id, # arXiv URL 

350 "snippet": ( 

351 paper.summary[:SNIPPET_LENGTH_SHORT] + "..." 

352 if len(paper.summary) > SNIPPET_LENGTH_SHORT 

353 else paper.summary 

354 ), 

355 "authors": [ 

356 author.name for author in paper.authors[:3] 

357 ], # First 3 authors 

358 "published": ( 

359 paper.published.strftime("%Y-%m-%d") 

360 if paper.published 

361 else None 

362 ), 

363 "journal_ref": paper.journal_ref, 

364 "source": "arXiv", 

365 } 

366 

367 previews.append(preview) 

368 

369 return previews 

370 

371 except Exception as e: 

372 self._record_search_failure(e) 

373 error_msg = str(e) 

374 safe_msg = self._scrub_error(e) 

375 logger.exception( 

376 f"Error getting arXiv previews ({type(e).__name__}): {safe_msg}" 

377 ) 

378 

379 # Check for rate limiting patterns 

380 if ( 

381 "429" in error_msg 

382 or "too many requests" in error_msg.lower() 

383 or "rate limit" in error_msg.lower() 

384 or "service unavailable" in error_msg.lower() 

385 or "503" in error_msg 

386 ): 

387 # `from None` suppresses the implicit __context__ chain: 

388 # the original exception still carries the raw message, so 

389 # a full traceback render (chain=True) would re-leak the 

390 # secret that safe_msg just scrubbed. 

391 raise RateLimitError( 

392 f"arXiv rate limit hit: {safe_msg}", 

393 retry_after=retry_after_from_error(e), 

394 ) from None 

395 

396 return [] 

397 

398 def _get_full_content( 

399 self, relevant_items: List[Dict[str, Any]] 

400 ) -> List[Dict[str, Any]]: 

401 """ 

402 Get full content for the relevant arXiv papers. 

403 Fetches full text through the shared HTML-first arXiv downloader 

404 when include_full_text is True. At most max_full_text papers 

405 have their full text fetched; a paper arXiv answered has no full 

406 text is refunded, and a failed request stops further fetches (see 

407 the constructor's max_full_text). 

408 

409 Args: 

410 relevant_items: List of relevant preview dictionaries 

411 

412 Returns: 

413 List of result dictionaries with full content 

414 """ 

415 logger.info("Getting full content for relevant arXiv papers") 

416 

417 results = [] 

418 # Papers whose full-text fetch counts against max_full_text: full 

419 # text or the PDF arrived (text or not), or a request failed. Only 

420 # a paper arXiv answered has no full text is refunded below. 

421 pdf_count = 0 

422 # Set once a full-text request fails instead of being answered 

423 # (timeout, connection error, 5xx, 429/503): no further full-text 

424 # fetches are made in this call, so a failing host is not sent a 

425 # request (and a timeout) per remaining paper. 

426 host_failing = False 

427 

428 for item in relevant_items: 

429 # Start with the preview data 

430 result = item.copy() 

431 

432 # Get the paper ID 

433 paper_id = item.get("id") 

434 

435 # Try to get the full paper from our cache 

436 paper = None 

437 if hasattr(self, "_papers") and paper_id in self._papers: 

438 paper = self._papers[paper_id] 

439 

440 if paper: 

441 arxiv_id = self._validated_arxiv_id(paper) 

442 # Add complete paper information 

443 result.update( 

444 { 

445 "pdf_url": ( 

446 f"https://arxiv.org/pdf/{arxiv_id}" 

447 if arxiv_id 

448 else None 

449 ), 

450 "authors": [ 

451 author.name for author in paper.authors 

452 ], # All authors 

453 "published": ( 

454 paper.published.strftime("%Y-%m-%d") 

455 if paper.published 

456 else None 

457 ), 

458 "updated": ( 

459 paper.updated.strftime("%Y-%m-%d") 

460 if paper.updated 

461 else None 

462 ), 

463 "categories": paper.categories, 

464 "summary": paper.summary, # Full summary 

465 "comment": paper.comment, 

466 "doi": paper.doi, 

467 # Explicitly forward for journal quality filter 

468 "journal_ref": paper.journal_ref, 

469 } 

470 ) 

471 

472 # Default to using summary as content 

473 result["content"] = paper.summary 

474 result["full_content"] = paper.summary 

475 result["full_content_is_complete"] = False 

476 

477 # Fetch full text through the shared HTML-first arXiv 

478 # downloader (official HTML rendition with a PDF 

479 # fallback, issue #4783) when requested and within limit 

480 if ( 

481 self.include_full_text 

482 and self.download_dir 

483 and pdf_count < self.max_full_text 

484 and not host_failing 

485 ): 

486 pdf_count += 1 # Count the fetch before attempting it 

487 full_text = None 

488 fetched = False 

489 try: 

490 # Apply rate limiting before the full-text fetch 

491 self.rate_tracker.apply_rate_limit(self.engine_type) 

492 

493 full_text = self._fetch_full_text(paper) 

494 fetched = full_text is not None 

495 except FullTextNotExtractedError: 

496 # The PDF arrived but yielded no text: the download 

497 # and extraction were paid for, so the fetch counts. 

498 fetched = True 

499 logger.info( 

500 "arXiv PDF yielded no text; keeping the summary" 

501 ) 

502 except FullTextFetchFailedError as e: 

503 # The host is failing (429/503, timeout, connection 

504 # error, 5xx): the fetch counts against the budget 

505 # and no further full text is fetched in this call. 

506 fetched = True 

507 host_failing = True 

508 if isinstance(e, FullTextRateLimitedError): 

509 logger.warning( 

510 "arXiv rate limited the full-text fetch; " 

511 "using summaries for the remaining papers" 

512 ) 

513 else: 

514 logger.warning( 

515 "arXiv full-text fetch failed; using " 

516 "summaries for the remaining papers" 

517 ) 

518 except Exception as e: 

519 # The downloader handles its own transport errors 

520 # (see _fetch_full_text; a failed request comes 

521 # back as FullTextFetchFailedError, handled 

522 # above), so only errors outside 

523 # the transport reach this point: an invalid arXiv 

524 # id, the rate limiter, or a bug. None of them 

525 # fetched anything. 

526 self._log_full_text_error(e, paper.title) 

527 if full_text and full_text.strip(): 

528 result["content"] = full_text 

529 result["full_content"] = full_text 

530 result["full_content_is_complete"] = True 

531 logger.info( 

532 "Retrieved full text via the HTML-first arXiv downloader" 

533 ) 

534 else: 

535 if not fetched: 

536 # arXiv answered that the paper has no full 

537 # text, or an error stopped the fetch before 

538 # any request: refund the budget so a later 

539 # paper still gets a full-text attempt. 

540 pdf_count -= 1 

541 logger.info("Using paper summary as content instead") 

542 elif ( 

543 self.include_full_text 

544 and self.download_dir 

545 and pdf_count >= self.max_full_text 

546 ): 

547 # Reached full-text fetch limit 

548 logger.info( 

549 f"Maximum number of full-text fetches ({self.max_full_text}) reached. Skipping remaining papers." 

550 ) 

551 result["content"] = paper.summary 

552 result["full_content"] = paper.summary 

553 result["full_content_is_complete"] = False 

554 

555 results.append(result) 

556 

557 return results 

558 

559 def run( 

560 self, query: str, research_context: Dict[str, Any] | None = None 

561 ) -> List[Dict[str, Any]]: 

562 """ 

563 Execute a search using arXiv with the two-phase approach. 

564 

565 Args: 

566 query: The search query 

567 research_context: Context from previous research to use. 

568 

569 Returns: 

570 List of search results 

571 """ 

572 logger.info("---Execute a search using arXiv---") 

573 

574 # Use the implementation from the parent class which handles all phases 

575 results = super().run(query, research_context=research_context) 

576 

577 # Clean up 

578 if hasattr(self, "_papers"): 

579 del self._papers 

580 

581 return results 

582 

583 def get_paper_details(self, arxiv_id: str) -> Dict[str, Any]: 

584 """ 

585 Get detailed information about a specific arXiv paper. 

586 

587 Args: 

588 arxiv_id: arXiv ID of the paper (e.g., '2101.12345') 

589 

590 Returns: 

591 Dictionary with paper information 

592 """ 

593 try: 

594 # Apply rate limiting before fetching paper by ID 

595 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

596 self.engine_type 

597 ) 

598 

599 papers = fetch_arxiv_results(ArxivIdRequest(arxiv_id=arxiv_id)) 

600 if not papers: 

601 return {} 

602 

603 paper = papers[0] 

604 

605 # Format result based on config 

606 result: Dict[str, Any] = { 

607 "title": paper.title, 

608 "link": paper.entry_id, 

609 "snippet": ( 

610 paper.summary[:250] + "..." 

611 if len(paper.summary) > 250 

612 else paper.summary 

613 ), 

614 "authors": [ 

615 author.name for author in paper.authors[:3] 

616 ], # First 3 authors 

617 "journal_ref": paper.journal_ref, 

618 } 

619 

620 canonical_id = self._validated_arxiv_id(paper) 

621 result.update( 

622 { 

623 "pdf_url": ( 

624 f"https://arxiv.org/pdf/{canonical_id}" 

625 if canonical_id 

626 else None 

627 ), 

628 "authors": [ 

629 author.name for author in paper.authors 

630 ], # All authors 

631 "published": ( 

632 paper.published.strftime("%Y-%m-%d") 

633 if paper.published 

634 else None 

635 ), 

636 "updated": ( 

637 paper.updated.strftime("%Y-%m-%d") 

638 if paper.updated 

639 else None 

640 ), 

641 "categories": paper.categories, 

642 "summary": paper.summary, # Full summary 

643 "comment": paper.comment, 

644 "doi": paper.doi, 

645 "content": paper.summary, # Use summary as content 

646 "full_content": paper.summary, # For consistency 

647 "full_content_is_complete": False, 

648 } 

649 ) 

650 

651 # Fetch full text through the shared HTML-first downloader 

652 # (official HTML rendition with a PDF fallback, #4783) 

653 # if requested 

654 if self.include_full_text and self.download_dir: 

655 try: 

656 # Apply rate limiting before the full-text fetch 

657 self.rate_tracker.apply_rate_limit(self.engine_type) 

658 

659 full_text = self._fetch_full_text(paper) 

660 if full_text and full_text.strip(): 660 ↛ 694line 660 didn't jump to line 694 because the condition on line 660 was always true

661 result["content"] = full_text 

662 result["full_content"] = full_text 

663 result["full_content_is_complete"] = True 

664 logger.info( 

665 "Retrieved full text via the HTML-first arXiv downloader" 

666 ) 

667 except FullTextNotExtractedError: 

668 logger.info( 

669 "arXiv PDF yielded no text; keeping the summary" 

670 ) 

671 except FullTextRateLimitedError: 

672 # arXiv answered the PDF request with 429/503: nothing 

673 # arrived, so keep the summary. Not an error in this 

674 # code, so no traceback. 

675 logger.warning( 

676 "arXiv rate limited the full-text fetch; keeping " 

677 "the summary" 

678 ) 

679 except FullTextFetchFailedError: 

680 # A request timed out, could not connect or got a 5xx: 

681 # nothing arrived, so keep the summary. Logged by the 

682 # downloader as a warning; not an error here either. 

683 logger.warning( 

684 "arXiv full-text fetch failed; keeping the summary" 

685 ) 

686 except Exception as e: 

687 # Failed requests (429/503, timeouts, connection 

688 # errors, 5xx) are handled above (see 

689 # _fetch_full_text), so only errors outside 

690 # the transport reach this point: an invalid arXiv id, 

691 # the rate limiter, or a bug. 

692 self._log_full_text_error(e) 

693 

694 return result 

695 

696 except Exception as e: 

697 safe_msg = self._scrub_error(e) 

698 logger.exception( 

699 f"Error getting paper details ({type(e).__name__}): {safe_msg}" 

700 ) 

701 return {} 

702 

703 def search_by_author( 

704 self, author_name: str, max_results: Optional[int] = None 

705 ) -> List[Dict[str, Any]]: 

706 """ 

707 Search for papers by a specific author. 

708 

709 Args: 

710 author_name: Name of the author 

711 max_results: Maximum number of results (defaults to self.max_results) 

712 

713 Returns: 

714 List of papers by the author 

715 """ 

716 original_max_results = self.max_results 

717 

718 try: 

719 if max_results: 

720 self.max_results = max_results 

721 

722 query = f'au:"{author_name}"' 

723 return self.run(query) 

724 

725 finally: 

726 # Restore original value 

727 self.max_results = original_max_results 

728 

729 def search_by_category( 

730 self, category: str, max_results: Optional[int] = None 

731 ) -> List[Dict[str, Any]]: 

732 """ 

733 Search for papers in a specific arXiv category. 

734 

735 Args: 

736 category: arXiv category (e.g., 'cs.AI', 'physics.optics') 

737 max_results: Maximum number of results (defaults to self.max_results) 

738 

739 Returns: 

740 List of papers in the category 

741 """ 

742 original_max_results = self.max_results 

743 

744 try: 

745 if max_results: 

746 self.max_results = max_results 

747 

748 query = f"cat:{category}" 

749 return self.run(query) 

750 

751 finally: 

752 # Restore original value 

753 self.max_results = original_max_results