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.
- pyPaperFlow/__init__.py +1 -0
- pyPaperFlow/cli.py +662 -0
- pyPaperFlow/integrations/mineru_parser.py +1315 -0
- pyPaperFlow/integrations/pdf_fetch.py +2080 -0
- pyPaperFlow/preprint/arxiv_fetcher.py +549 -0
- pyPaperFlow/preprint/biorxiv_fetcher.py +404 -0
- pyPaperFlow/preprint/source_models.py +29 -0
- pyPaperFlow/preprint/source_utils.py +131 -0
- pyPaperFlow/pubmed/__init__.py +0 -0
- pyPaperFlow/pubmed/pubmed_fetcher.py +1910 -0
- pyPaperFlow/pubmed/pubmed_merger.py +958 -0
- pyPaperFlow/utils.py +70 -0
- pypaperflow-0.2.0.dist-info/METADATA +2025 -0
- pypaperflow-0.2.0.dist-info/RECORD +17 -0
- pypaperflow-0.2.0.dist-info/WHEEL +4 -0
- pypaperflow-0.2.0.dist-info/entry_points.txt +2 -0
- pypaperflow-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|