pyPaperFlow 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import importlib
5
+ import time
6
+ import xml.etree.ElementTree as ET
7
+ from datetime import date, datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Iterable, List, Optional
11
+
12
+ import httpx
13
+
14
+ from .source_models import SourcePaper
15
+ from .source_utils import (
16
+ build_source_record_dir,
17
+ download_binary,
18
+ extract_year,
19
+ normalize_text,
20
+ safe_filename,
21
+ save_json,
22
+ )
23
+
24
+
25
+ ARXIV_API_URL = "https://export.arxiv.org/api/query"
26
+ ARXIV_ATOM_NS = {
27
+ "atom": "http://www.w3.org/2005/Atom",
28
+ "arxiv": "http://arxiv.org/schemas/atom",
29
+ }
30
+
31
+ PAPERSCRAPER_FIELDS = [
32
+ "title",
33
+ "authors",
34
+ "date",
35
+ "abstract",
36
+ "journal",
37
+ "doi",
38
+ "entry_id",
39
+ "pdf_url",
40
+ "updated",
41
+ "primary_category",
42
+ "comment",
43
+ ]
44
+
45
+
46
+ def _safe_lower(text: Any) -> str:
47
+ return normalize_text(text).lower()
48
+
49
+
50
+ class ArxivFetcher:
51
+ def __init__(
52
+ self,
53
+ root_dir: str,
54
+ backend: str = "native",
55
+ batch_size: int = 100,
56
+ max_retries: int = 3,
57
+ request_timeout: float = 60.0,
58
+ ):
59
+ self.root_dir = root_dir
60
+ if backend not in {"native", "paperscraper"}:
61
+ raise ValueError("backend must be 'native' or 'paperscraper'")
62
+ self.backend = backend
63
+ self.batch_size = max(1, int(batch_size))
64
+ self.max_retries = max(1, int(max_retries))
65
+ self.request_timeout = float(request_timeout)
66
+ self.headers = {
67
+ "User-Agent": "pyPaperFlow/0.1.0 (+https://github.com/MaybeBio/pyPaperFlow)",
68
+ "Accept": "application/atom+xml,application/xml;q=0.9,*/*;q=0.8",
69
+ }
70
+ self._http_client: Optional[httpx.Client] = None
71
+
72
+ def build_query(
73
+ self,
74
+ query: str,
75
+ start_date: Optional[str] = None,
76
+ end_date: Optional[str] = None,
77
+ ) -> str:
78
+ raw_query = normalize_text(query)
79
+ if not raw_query:
80
+ raise ValueError("query must be non-empty")
81
+
82
+ if any(marker in raw_query for marker in (":", "submittedDate:", "all:", "ti:", "au:", "abs:")):
83
+ search_query = raw_query
84
+ else:
85
+ tokens = re.findall(r'"[^"]+"|\'[^\']+\'|\S+', raw_query)
86
+ normalized_tokens: List[str] = []
87
+ for token in tokens:
88
+ clean = token.strip('"\'')
89
+ if not clean:
90
+ continue
91
+ if clean.upper() in {"AND", "OR", "NOT"}:
92
+ continue
93
+ if " " in clean:
94
+ normalized_tokens.append(f'all:"{clean}"')
95
+ else:
96
+ normalized_tokens.append(f"all:{clean}")
97
+ search_query = " AND ".join(normalized_tokens)
98
+
99
+ if start_date or end_date:
100
+ start_bound, end_bound = self._normalize_date_bounds(start_date, end_date)
101
+ start_text = self._format_arxiv_date(start_bound.isoformat() if start_bound else "1991-01-01", suffix="0000")
102
+ end_text = self._format_arxiv_date(end_bound.isoformat() if end_bound else datetime.now(timezone.utc).strftime("%Y-%m-%d"), suffix="2359")
103
+ date_filter = f"submittedDate:[{start_text} TO {end_text}]"
104
+ search_query = f"({search_query}) AND {date_filter}"
105
+
106
+ return search_query
107
+
108
+ def search(
109
+ self,
110
+ query: str,
111
+ max_results: int = 100,
112
+ start_date: Optional[str] = None,
113
+ end_date: Optional[str] = None,
114
+ ) -> List[SourcePaper]:
115
+ search_query = self.build_query(query, start_date=start_date, end_date=end_date)
116
+ if self.backend == "paperscraper":
117
+ return self._search_with_paperscraper(
118
+ search_query=search_query,
119
+ query=query,
120
+ max_results=max_results,
121
+ )
122
+ return self._search_native(
123
+ search_query=search_query,
124
+ query=query,
125
+ max_results=max_results,
126
+ start_date=start_date,
127
+ end_date=end_date,
128
+ )
129
+
130
+ def fetch_from_query(
131
+ self,
132
+ query: str,
133
+ output_dir: Optional[str] = None,
134
+ max_results: int = 100,
135
+ start_date: Optional[str] = None,
136
+ end_date: Optional[str] = None,
137
+ download_pdf: bool = True,
138
+ ) -> List[SourcePaper]:
139
+ records = self.search(
140
+ query=query,
141
+ max_results=max_results,
142
+ start_date=start_date,
143
+ end_date=end_date,
144
+ )
145
+
146
+ for record in records:
147
+ self._save_record(record, output_dir=output_dir, download_pdf=download_pdf)
148
+
149
+ return records
150
+
151
+ def close(self) -> None:
152
+ if self._http_client is not None:
153
+ self._http_client.close()
154
+ self._http_client = None
155
+
156
+ def __del__(self) -> None:
157
+ try:
158
+ self.close()
159
+ except Exception:
160
+ pass
161
+
162
+ def _search_native(
163
+ self,
164
+ search_query: str,
165
+ query: str,
166
+ max_results: int,
167
+ start_date: Optional[str] = None,
168
+ end_date: Optional[str] = None,
169
+ ) -> List[SourcePaper]:
170
+ results: List[SourcePaper] = []
171
+ start = 0
172
+ remaining = max(1, int(max_results))
173
+ tried_fallback = False
174
+
175
+ while remaining > 0:
176
+ page_size = min(self.batch_size, remaining)
177
+ feed = self._request_feed(search_query, start=start, max_results=page_size)
178
+ entries = feed.findall("atom:entry", ARXIV_ATOM_NS)
179
+ # If there are no entries on the first page, try some fallback query forms
180
+ if not entries and start == 0 and not tried_fallback:
181
+ tried_fallback = True
182
+ raw_query = normalize_text(query)
183
+ date_filter = ""
184
+ if ") AND " in search_query and "submittedDate:" in search_query:
185
+ try:
186
+ _, date_filter = search_query.split(") AND ", 1)
187
+ date_filter = " AND " + date_filter
188
+ except Exception:
189
+ date_filter = ""
190
+
191
+ stopwords = {"a", "an", "the", "for", "of", "in", "on", "and", "or", "to", "from", "by", "with"}
192
+ tokens = [t for t in re.findall(r'"[^"]+"|\'[^\']+\'|\S+', raw_query) if t]
193
+
194
+ alt_queries = []
195
+ alt_queries.append(raw_query)
196
+ alt_queries.append(f'all:"{raw_query}"')
197
+ # remove common stopwords and re-build tokenized form
198
+ cleaned = []
199
+ for t in tokens:
200
+ tclean = t.strip('"\'')
201
+ if not tclean:
202
+ continue
203
+ low = tclean.lower()
204
+ if low in stopwords:
205
+ continue
206
+ if " " in tclean:
207
+ cleaned.append(f'all:"{tclean}"')
208
+ else:
209
+ cleaned.append(f'all:{tclean}')
210
+ if cleaned:
211
+ alt_queries.append(" AND ".join(cleaned))
212
+
213
+ found = False
214
+ for alt in alt_queries:
215
+ try_query = alt + date_filter
216
+ feed = self._request_feed(try_query, start=0, max_results=page_size)
217
+ entries = feed.findall("atom:entry", ARXIV_ATOM_NS)
218
+ if entries:
219
+ found = True
220
+ break
221
+ if not found:
222
+ break
223
+
224
+ for entry in entries:
225
+ results.append(self._normalize_entry(entry, query=query))
226
+ remaining -= 1
227
+ if remaining <= 0:
228
+ break
229
+
230
+ if len(entries) < page_size or remaining <= 0:
231
+ break
232
+
233
+ start += len(entries)
234
+
235
+ return results
236
+
237
+ def _search_with_paperscraper(self, search_query: str, query: str, max_results: int) -> List[SourcePaper]:
238
+ api = self._load_paperscraper_api()
239
+ result = api(
240
+ search_query,
241
+ fields=PAPERSCRAPER_FIELDS,
242
+ max_results=max(1, int(max_results)),
243
+ verbose=False,
244
+ )
245
+ rows = self._records_from_paperscraper_result(result)
246
+
247
+ records: List[SourcePaper] = []
248
+ for row in rows[: max(1, int(max_results))]:
249
+ records.append(self._normalize_paperscraper_row(row, query=query))
250
+ return records
251
+
252
+ def _records_from_paperscraper_result(self, result: Any) -> List[Dict[str, Any]]:
253
+ if result is None:
254
+ return []
255
+ if isinstance(result, list):
256
+ return [row for row in result if isinstance(row, dict)]
257
+ if hasattr(result, "to_dict"):
258
+ try:
259
+ records = result.to_dict(orient="records")
260
+ except TypeError:
261
+ records = result.to_dict()
262
+ if isinstance(records, list):
263
+ return [row for row in records if isinstance(row, dict)]
264
+ return []
265
+
266
+ def _request_feed(self, search_query: str, start: int, max_results: int) -> ET.Element:
267
+ last_error: Optional[Exception] = None
268
+ params = {
269
+ "search_query": search_query,
270
+ "start": start,
271
+ "max_results": max_results,
272
+ "sortBy": "submittedDate",
273
+ "sortOrder": "descending",
274
+ }
275
+
276
+ for attempt in range(self.max_retries):
277
+ response: Optional[httpx.Response] = None
278
+ try:
279
+ response = self._get_http_client().get(
280
+ ARXIV_API_URL,
281
+ params=params,
282
+ headers=self.headers,
283
+ timeout=self.request_timeout,
284
+ )
285
+ if response.status_code == 429:
286
+ last_error = RuntimeError(
287
+ f"arXiv API rate limited request for query={search_query!r} start={start} max_results={max_results}"
288
+ )
289
+ if attempt + 1 < self.max_retries:
290
+ self._sleep_before_retry(response, attempt)
291
+ continue
292
+ break
293
+ response.raise_for_status()
294
+ try:
295
+ return ET.fromstring(response.text)
296
+ except ET.ParseError as exc:
297
+ last_error = exc
298
+ if attempt + 1 < self.max_retries:
299
+ self._sleep_before_retry(response, attempt)
300
+ continue
301
+ break
302
+ except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.TransportError) as exc:
303
+ last_error = exc
304
+ if attempt + 1 < self.max_retries:
305
+ self._sleep_before_retry(response, attempt)
306
+ continue
307
+ break
308
+ except Exception as exc:
309
+ last_error = exc
310
+ if attempt + 1 < self.max_retries:
311
+ self._sleep_before_retry(response, attempt)
312
+ continue
313
+ break
314
+
315
+ if last_error is not None:
316
+ raise last_error
317
+ raise RuntimeError("Failed to query arXiv API")
318
+
319
+ def _get_http_client(self) -> httpx.Client:
320
+ if self._http_client is not None:
321
+ return self._http_client
322
+
323
+ client_kwargs = {
324
+ "headers": self.headers,
325
+ "timeout": self.request_timeout,
326
+ "follow_redirects": True,
327
+ }
328
+ try:
329
+ self._http_client = httpx.Client(http2=True, **client_kwargs)
330
+ except ImportError:
331
+ self._http_client = httpx.Client(http2=False, **client_kwargs)
332
+ return self._http_client
333
+
334
+ def _sleep_before_retry(self, response: Optional[httpx.Response], attempt: int) -> None:
335
+ retry_after = self._retry_after_seconds(response)
336
+ delay = retry_after if retry_after is not None else min(30.0, 1.5 * (2**attempt))
337
+ time.sleep(max(0.0, delay))
338
+
339
+ def _retry_after_seconds(self, response: Optional[httpx.Response]) -> Optional[float]:
340
+ if response is None:
341
+ return None
342
+
343
+ raw_retry_after = normalize_text(response.headers.get("Retry-After", ""))
344
+ if not raw_retry_after:
345
+ return None
346
+
347
+ if raw_retry_after.isdigit():
348
+ return float(raw_retry_after)
349
+
350
+ try:
351
+ retry_after_dt = parsedate_to_datetime(raw_retry_after)
352
+ except (TypeError, ValueError, IndexError):
353
+ return None
354
+
355
+ if retry_after_dt.tzinfo is None:
356
+ retry_after_dt = retry_after_dt.replace(tzinfo=timezone.utc)
357
+ now = datetime.now(retry_after_dt.tzinfo)
358
+ return max(0.0, (retry_after_dt - now).total_seconds())
359
+
360
+ def _normalize_date_bounds(
361
+ self,
362
+ start_date: Optional[str],
363
+ end_date: Optional[str],
364
+ ) -> tuple[Optional[date], Optional[date]]:
365
+ start_bound = self._parse_date(start_date) if start_date else None
366
+ end_bound = self._parse_date(end_date) if end_date else None
367
+ today = datetime.now(timezone.utc).date()
368
+
369
+ if end_bound and end_bound > today:
370
+ end_bound = today
371
+ if start_bound and start_bound > today and end_bound is None:
372
+ end_bound = today
373
+ if start_bound and end_bound and start_bound > end_bound:
374
+ raise ValueError("start_date must not be after end_date after clamping future dates")
375
+
376
+ return start_bound, end_bound
377
+
378
+ def _parse_date(self, date_text: str) -> date:
379
+ return datetime.strptime(date_text, "%Y-%m-%d").date()
380
+
381
+ def _load_paperscraper_api(self):
382
+ try:
383
+ module = importlib.import_module("paperscraper.arxiv.arxiv")
384
+ except ImportError as exc:
385
+ raise ModuleNotFoundError(
386
+ "paperscraper backend requested but the 'paperscraper' package is not installed"
387
+ ) from exc
388
+ return module.get_arxiv_papers_api
389
+
390
+ def _normalize_paperscraper_row(self, row: Dict[str, Any], query: str) -> SourcePaper:
391
+ title = normalize_text(row.get("title", ""))
392
+ abstract = normalize_text(row.get("abstract", ""))
393
+ journal = normalize_text(row.get("journal", ""))
394
+ doi = normalize_text(row.get("doi", ""))
395
+ entry_id = normalize_text(row.get("entry_id", ""))
396
+ pdf_url = normalize_text(row.get("pdf_url", ""))
397
+ source_id = self._extract_source_id(entry_id=entry_id, doi=doi, pdf_url=pdf_url, title=title or query)
398
+ authors = self._normalize_authors(row.get("authors"))
399
+ published_date = self._normalize_date_value(row.get("date"))
400
+ updated_date = self._normalize_date_value(row.get("updated"))
401
+ category = normalize_text(row.get("primary_category", "") or row.get("category", ""))
402
+
403
+ if not pdf_url and source_id:
404
+ pdf_url = f"https://arxiv.org/pdf/{source_id}.pdf"
405
+
406
+ if not entry_id and source_id:
407
+ entry_id = f"https://arxiv.org/abs/{source_id}"
408
+
409
+ return SourcePaper(
410
+ source="arxiv",
411
+ source_id=source_id,
412
+ title=title,
413
+ doi=doi,
414
+ abstract=abstract,
415
+ authors=authors,
416
+ published_date=published_date,
417
+ updated_date=updated_date,
418
+ journal=journal,
419
+ category=category,
420
+ landing_url=entry_id,
421
+ pdf_url=pdf_url,
422
+ query=query,
423
+ version=source_id.rsplit("v", 1)[-1] if source_id and "v" in source_id else "",
424
+ keywords=[item for item in [category] if item],
425
+ extra={
426
+ "backend": "paperscraper",
427
+ "entry_id": entry_id,
428
+ "primary_category": category,
429
+ "comment": normalize_text(row.get("comment", "")),
430
+ },
431
+ )
432
+
433
+ def _normalize_authors(self, value: Any) -> List[str]:
434
+ if isinstance(value, list):
435
+ authors: List[str] = []
436
+ for item in value:
437
+ if isinstance(item, str):
438
+ name = normalize_text(item)
439
+ else:
440
+ name = normalize_text(getattr(item, "name", item))
441
+ if name:
442
+ authors.append(name)
443
+ return authors
444
+ text = normalize_text(value)
445
+ if not text:
446
+ return []
447
+ return [item.strip() for item in re.split(r"\s*,\s*", text) if item.strip()]
448
+
449
+ def _normalize_date_value(self, value: Any) -> str:
450
+ text = normalize_text(value)
451
+ if not text:
452
+ return ""
453
+ if len(text) >= 10:
454
+ return text[:10]
455
+ return text
456
+
457
+ def _extract_source_id(self, entry_id: str, doi: str, pdf_url: str, title: str) -> str:
458
+ if entry_id:
459
+ tail = entry_id.rstrip("/").rsplit("/", 1)[-1]
460
+ if tail:
461
+ return tail
462
+
463
+ if doi:
464
+ doi_match = re.search(r"10\.48550/arXiv\.(?P<source_id>[^\s]+)", doi, flags=re.IGNORECASE)
465
+ if doi_match:
466
+ return doi_match.group("source_id")
467
+
468
+ if pdf_url:
469
+ pdf_match = re.search(r"/pdf/(?P<source_id>[^/?#]+?)(?:\.pdf)?(?:[?#].*)?$", pdf_url, flags=re.IGNORECASE)
470
+ if pdf_match:
471
+ return pdf_match.group("source_id")
472
+
473
+ return safe_filename(title or "arxiv-paper")
474
+
475
+ def _normalize_entry(self, entry: ET.Element, query: str) -> SourcePaper:
476
+ entry_id = normalize_text(entry.findtext("atom:id", default="", namespaces=ARXIV_ATOM_NS))
477
+ source_id = entry_id.rsplit("/", 1)[-1] if entry_id else ""
478
+ title = normalize_text(entry.findtext("atom:title", default="", namespaces=ARXIV_ATOM_NS))
479
+ summary = normalize_text(entry.findtext("atom:summary", default="", namespaces=ARXIV_ATOM_NS))
480
+ published = normalize_text(entry.findtext("atom:published", default="", namespaces=ARXIV_ATOM_NS))
481
+ updated = normalize_text(entry.findtext("atom:updated", default="", namespaces=ARXIV_ATOM_NS))
482
+
483
+ authors = [
484
+ normalize_text(author.findtext("atom:name", default="", namespaces=ARXIV_ATOM_NS))
485
+ for author in entry.findall("atom:author", ARXIV_ATOM_NS)
486
+ ]
487
+ authors = [author for author in authors if author]
488
+
489
+ categories = [
490
+ normalize_text(category.attrib.get("term", ""))
491
+ for category in entry.findall("atom:category", ARXIV_ATOM_NS)
492
+ ]
493
+ categories = [category for category in categories if category]
494
+
495
+ doi = normalize_text(entry.findtext("arxiv:doi", default="", namespaces=ARXIV_ATOM_NS))
496
+ journal_ref = normalize_text(entry.findtext("arxiv:journal_ref", default="", namespaces=ARXIV_ATOM_NS))
497
+ landing_url = f"https://arxiv.org/abs/{source_id}" if source_id else ""
498
+
499
+ pdf_url = ""
500
+ for link in entry.findall("atom:link", ARXIV_ATOM_NS):
501
+ href = normalize_text(link.attrib.get("href", ""))
502
+ link_type = normalize_text(link.attrib.get("type", ""))
503
+ link_title = normalize_text(link.attrib.get("title", ""))
504
+ if href and (link_type == "application/pdf" or link_title.lower() == "pdf"):
505
+ pdf_url = href
506
+ break
507
+ if not pdf_url and source_id:
508
+ pdf_url = f"https://arxiv.org/pdf/{source_id}.pdf"
509
+
510
+ return SourcePaper(
511
+ source="arxiv",
512
+ source_id=source_id or safe_filename(title or query),
513
+ title=title,
514
+ doi=doi,
515
+ abstract=summary,
516
+ authors=authors,
517
+ published_date=published,
518
+ updated_date=updated,
519
+ journal=journal_ref,
520
+ category=", ".join(categories),
521
+ landing_url=landing_url,
522
+ pdf_url=pdf_url,
523
+ query=query,
524
+ version=source_id.rsplit("v", 1)[-1] if source_id and "v" in source_id else "",
525
+ keywords=categories,
526
+ extra={
527
+ "entry_id": entry_id,
528
+ "raw_categories": categories,
529
+ "journal_ref": journal_ref,
530
+ },
531
+ )
532
+
533
+ def _save_record(self, record: SourcePaper, output_dir: Optional[str], download_pdf: bool) -> None:
534
+ base_dir = output_dir or self.root_dir
535
+ year = extract_year(record.published_date)
536
+ record_dir = build_source_record_dir(base_dir, record.source, year, record.source_id)
537
+ file_stem = safe_filename(record.source_id)
538
+
539
+ if download_pdf and record.pdf_url:
540
+ pdf_path = record_dir / f"{file_stem}.pdf"
541
+ if download_binary(record.pdf_url, pdf_path, headers=self.headers, timeout=self.request_timeout):
542
+ record.pdf_downloaded = True
543
+ record.pdf_path = str(pdf_path)
544
+
545
+ save_json(record_dir / f"{file_stem}.json", record.to_dict())
546
+
547
+ def _format_arxiv_date(self, date_text: str, suffix: str) -> str:
548
+ parsed = datetime.strptime(date_text, "%Y-%m-%d")
549
+ return parsed.strftime("%Y%m%d") + suffix