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,404 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import re
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ import requests
10
+ from bs4 import BeautifulSoup
11
+
12
+ from .source_models import SourcePaper
13
+ from .source_utils import (
14
+ basic_boolean_text_match,
15
+ build_source_record_dir,
16
+ download_binary,
17
+ ensure_directory,
18
+ extract_year,
19
+ normalize_text,
20
+ safe_filename,
21
+ save_json,
22
+ )
23
+
24
+
25
+ BIO_RXIV_API_BASE = "https://api.biorxiv.org/details/biorxiv"
26
+ BIO_RXIV_CROSSREF_API = "https://api.crossref.org/works"
27
+ BIO_RXIV_CROSSREF_PREFIX = "10.64898"
28
+ BIO_RXIV_LANDING_BASE = "https://www.biorxiv.org/content"
29
+ BIO_RXIV_LAUNCH_DATE = datetime(2013, 1, 1)
30
+
31
+
32
+ class BioRxivFetcher:
33
+ def __init__(
34
+ self,
35
+ root_dir: str,
36
+ window_days: int = 365,
37
+ max_retries: int = 3,
38
+ request_timeout: float = 60.0,
39
+ ):
40
+ self.root_dir = root_dir
41
+ self.window_days = max(1, int(window_days))
42
+ self.max_retries = max(1, int(max_retries))
43
+ self.request_timeout = float(request_timeout)
44
+ self.headers = {
45
+ "User-Agent": "pyPaperFlow/0.1.0 (+https://github.com/MaybeBio/pyPaperFlow)",
46
+ "Accept": "application/json,text/html;q=0.9,*/*;q=0.8",
47
+ }
48
+
49
+ def search(
50
+ self,
51
+ query: str,
52
+ start_date: Optional[str] = None,
53
+ end_date: Optional[str] = None,
54
+ max_results: Optional[int] = None,
55
+ ) -> List[SourcePaper]:
56
+ query_text = normalize_text(query)
57
+ if not query_text:
58
+ raise ValueError("query must be non-empty")
59
+
60
+ records: List[SourcePaper] = []
61
+ start_dt, end_dt = self._normalize_date_range(start_date, end_date)
62
+
63
+ cursor = "*"
64
+ while True:
65
+ page_size = 1000
66
+ if max_results is not None:
67
+ remaining = max(1, int(max_results) - len(records))
68
+ page_size = min(page_size, remaining)
69
+
70
+ payload = self._request_crossref_page(
71
+ query_text=query_text,
72
+ cursor=cursor,
73
+ page_size=page_size,
74
+ start_dt=start_dt,
75
+ end_dt=end_dt,
76
+ )
77
+ message = payload.get("message") or {}
78
+ items = message.get("items") or []
79
+ if not items:
80
+ break
81
+
82
+ for raw_record in items:
83
+ if normalize_text(raw_record.get("publisher", "")).lower() != "openrxiv":
84
+ continue
85
+ if not basic_boolean_text_match(self._search_text_crossref(raw_record), query_text):
86
+ continue
87
+ record = self._normalize_crossref_record(raw_record, query=query_text)
88
+ records.append(record)
89
+ if max_results is not None and len(records) >= max_results:
90
+ return records
91
+
92
+ if len(items) < page_size:
93
+ break
94
+
95
+ next_cursor = normalize_text(message.get("next-cursor", ""))
96
+ if not next_cursor or next_cursor == cursor:
97
+ break
98
+ cursor = next_cursor
99
+
100
+ return records
101
+
102
+ def fetch_from_query(
103
+ self,
104
+ query: str,
105
+ output_dir: Optional[str] = None,
106
+ start_date: Optional[str] = None,
107
+ end_date: Optional[str] = None,
108
+ max_results: Optional[int] = None,
109
+ download_pdf: bool = True,
110
+ ) -> List[SourcePaper]:
111
+ records = self.search(
112
+ query=query,
113
+ start_date=start_date,
114
+ end_date=end_date,
115
+ max_results=max_results,
116
+ )
117
+
118
+ for record in records:
119
+ self._save_record(record, output_dir=output_dir, download_pdf=download_pdf)
120
+
121
+ return records
122
+
123
+ def _normalize_date_range(
124
+ self,
125
+ start_date: Optional[str],
126
+ end_date: Optional[str],
127
+ ) -> tuple[datetime, datetime]:
128
+ if start_date:
129
+ start_dt = datetime.strptime(start_date, "%Y-%m-%d")
130
+ else:
131
+ start_dt = BIO_RXIV_LAUNCH_DATE
132
+
133
+ if end_date:
134
+ end_dt = datetime.strptime(end_date, "%Y-%m-%d")
135
+ else:
136
+ end_dt = datetime.utcnow()
137
+
138
+ if start_dt < BIO_RXIV_LAUNCH_DATE:
139
+ start_dt = BIO_RXIV_LAUNCH_DATE
140
+ if start_dt > end_dt:
141
+ raise ValueError("start_date cannot be later than end_date")
142
+ return start_dt, end_dt
143
+
144
+ def _request_crossref_page(
145
+ self,
146
+ query_text: str,
147
+ cursor: str,
148
+ page_size: int,
149
+ start_dt: datetime,
150
+ end_dt: datetime,
151
+ ) -> Dict[str, Any]:
152
+ params: Dict[str, Any] = {
153
+ "filter": f"prefix:{BIO_RXIV_CROSSREF_PREFIX},type:posted-content",
154
+ "query.bibliographic": query_text,
155
+ "rows": page_size,
156
+ "cursor": cursor,
157
+ "sort": "relevance",
158
+ }
159
+ if start_dt:
160
+ params["filter"] += f",from-pub-date:{start_dt.strftime('%Y-%m-%d')}"
161
+ if end_dt:
162
+ params["filter"] += f",until-pub-date:{end_dt.strftime('%Y-%m-%d')}"
163
+
164
+ last_error: Optional[Exception] = None
165
+
166
+ for attempt in range(self.max_retries):
167
+ try:
168
+ response = requests.get(
169
+ BIO_RXIV_CROSSREF_API,
170
+ params=params,
171
+ headers=self.headers,
172
+ timeout=self.request_timeout,
173
+ )
174
+ response.raise_for_status()
175
+ return response.json()
176
+ except Exception as exc:
177
+ last_error = exc
178
+ if attempt + 1 < self.max_retries:
179
+ time.sleep(min(2.0, 0.5 * (attempt + 1)))
180
+
181
+ if last_error is not None:
182
+ raise last_error
183
+ raise RuntimeError("Failed to query bioRxiv Crossref search")
184
+
185
+ def _search_text_crossref(self, record: Dict[str, Any]) -> str:
186
+ pieces = [
187
+ record.get("title", ""),
188
+ record.get("doi", ""),
189
+ record.get("publisher", ""),
190
+ record.get("container-title", ""),
191
+ record.get("subject", ""),
192
+ record.get("abstract", ""),
193
+ ]
194
+ normalized_parts: List[str] = []
195
+ for piece in pieces:
196
+ if isinstance(piece, list):
197
+ normalized_piece = " ".join(normalize_text(item) for item in piece if normalize_text(item))
198
+ else:
199
+ normalized_piece = normalize_text(piece)
200
+ if normalized_piece:
201
+ normalized_parts.append(normalized_piece)
202
+ return " ".join(normalized_parts)
203
+
204
+ def _normalize_crossref_record(self, record: Dict[str, Any], query: str) -> SourcePaper:
205
+ title = normalize_text((record.get("title") or [""])[0])
206
+ abstract = self._clean_crossref_abstract(record.get("abstract", ""))
207
+ published_date = self._extract_crossref_date(record)
208
+ updated_date = normalize_text(record.get("created", {}).get("date-time", ""))
209
+
210
+ authors = self._normalize_crossref_authors(record.get("author", []))
211
+ doi = self._normalize_crossref_doi(record.get("DOI", ""))
212
+ landing_url = self._landing_url(doi, "")
213
+
214
+ source_id = doi or safe_filename(f"biorxiv_{published_date}_{title}")
215
+ keywords = self._normalize_crossref_keywords(record.get("subject", []))
216
+ pdf_url = f"{BIO_RXIV_LANDING_BASE}/{doi}.full.pdf" if doi else ""
217
+
218
+ return SourcePaper(
219
+ source="biorxiv",
220
+ source_id=source_id,
221
+ title=title,
222
+ doi=doi,
223
+ abstract=abstract,
224
+ authors=authors,
225
+ published_date=published_date,
226
+ updated_date=updated_date,
227
+ journal="bioRxiv",
228
+ category=", ".join(keywords),
229
+ landing_url=landing_url,
230
+ pdf_url=pdf_url,
231
+ query=query,
232
+ version="",
233
+ keywords=keywords,
234
+ extra={
235
+ "publisher": record.get("publisher", ""),
236
+ "prefix": record.get("prefix", ""),
237
+ "type": record.get("type", ""),
238
+ "raw_record": record,
239
+ },
240
+ )
241
+
242
+ def _normalize_crossref_authors(self, authors_value: Any) -> List[str]:
243
+ if not isinstance(authors_value, list):
244
+ return []
245
+
246
+ authors: List[str] = []
247
+ for author in authors_value:
248
+ if not isinstance(author, dict):
249
+ continue
250
+ name = normalize_text(
251
+ author.get("name")
252
+ or author.get("given")
253
+ or " ".join(
254
+ part
255
+ for part in [author.get("given", ""), author.get("family", "")]
256
+ if normalize_text(part)
257
+ )
258
+ )
259
+ if name:
260
+ authors.append(name)
261
+ return authors
262
+
263
+ def _normalize_crossref_keywords(self, keywords_value: Any) -> List[str]:
264
+ if isinstance(keywords_value, list):
265
+ return [normalize_text(item) for item in keywords_value if normalize_text(item)]
266
+ if isinstance(keywords_value, str) and keywords_value.strip():
267
+ return [part.strip() for part in keywords_value.split(",") if part.strip()]
268
+ return []
269
+
270
+ def _normalize_crossref_doi(self, doi: Any) -> str:
271
+ return normalize_text(doi)
272
+
273
+ def _extract_crossref_date(self, record: Dict[str, Any]) -> str:
274
+ for key in ("published-online", "published-print", "issued", "created"):
275
+ value = record.get(key)
276
+ if isinstance(value, dict):
277
+ date_parts = value.get("date-parts") or []
278
+ if date_parts and date_parts[0]:
279
+ parts = date_parts[0]
280
+ year = parts[0] if len(parts) > 0 else None
281
+ month = parts[1] if len(parts) > 1 else 1
282
+ day = parts[2] if len(parts) > 2 else 1
283
+ if year:
284
+ try:
285
+ return datetime(int(year), int(month), int(day)).strftime("%Y-%m-%d")
286
+ except Exception:
287
+ return normalize_text(year)
288
+ return ""
289
+
290
+ def _clean_crossref_abstract(self, abstract: Any) -> str:
291
+ text = normalize_text(abstract)
292
+ if not text:
293
+ return ""
294
+ soup = BeautifulSoup(text, "html.parser")
295
+ cleaned = soup.get_text(separator=" ")
296
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
297
+ return cleaned
298
+
299
+ def _normalize_authors(self, authors_value: Any) -> List[str]:
300
+ if isinstance(authors_value, list):
301
+ authors: List[str] = []
302
+ for author in authors_value:
303
+ if isinstance(author, dict):
304
+ name = normalize_text(
305
+ author.get("name")
306
+ or author.get("author_name")
307
+ or author.get("full_name")
308
+ or " ".join(
309
+ part
310
+ for part in [author.get("given", ""), author.get("family", "")]
311
+ if normalize_text(part)
312
+ )
313
+ )
314
+ else:
315
+ name = normalize_text(author)
316
+ if name:
317
+ authors.append(name)
318
+ return authors
319
+
320
+ if isinstance(authors_value, str):
321
+ parts = [part.strip() for part in authors_value.split(";") if part.strip()]
322
+ return parts or [authors_value]
323
+
324
+ return []
325
+
326
+ def _normalize_keywords(self, category_value: Any) -> List[str]:
327
+ if isinstance(category_value, list):
328
+ return [normalize_text(item) for item in category_value if normalize_text(item)]
329
+ if isinstance(category_value, str) and category_value.strip():
330
+ return [part.strip() for part in category_value.split(",") if part.strip()]
331
+ return []
332
+
333
+ def _normalize_doi(self, doi: Any, version: Any) -> str:
334
+ doi_text = normalize_text(doi)
335
+ version_text = normalize_text(version)
336
+ if doi_text and version_text and not doi_text.lower().endswith(f"v{version_text.lower()}"):
337
+ return f"{doi_text}v{version_text}"
338
+ return doi_text
339
+
340
+ def _landing_url(self, doi: str, version: str) -> str:
341
+ if not doi:
342
+ return ""
343
+ return f"{BIO_RXIV_LANDING_BASE}/{doi}"
344
+
345
+ def _candidate_pdf_urls(self, record: Dict[str, Any], doi: str, version: str) -> List[str]:
346
+ candidates: List[str] = []
347
+ for key in ("pdf_url", "full_text_url", "fulltext_url", "pdf"):
348
+ value = normalize_text(record.get(key, ""))
349
+ if value:
350
+ candidates.append(value)
351
+
352
+ if doi:
353
+ candidates.append(f"{BIO_RXIV_LANDING_BASE}/{doi}.full.pdf")
354
+ if version and not doi.lower().endswith(f"v{version.lower()}"):
355
+ candidates.insert(0, f"{BIO_RXIV_LANDING_BASE}/{doi}v{version}.full.pdf")
356
+
357
+ landing_url = self._landing_url(doi, version)
358
+ if landing_url:
359
+ candidates.append(landing_url)
360
+
361
+ deduped: List[str] = []
362
+ seen = set()
363
+ for candidate in candidates:
364
+ if candidate not in seen:
365
+ seen.add(candidate)
366
+ deduped.append(candidate)
367
+ return deduped
368
+
369
+ def _save_record(self, record: SourcePaper, output_dir: Optional[str], download_pdf: bool) -> None:
370
+ base_dir = output_dir or self.root_dir
371
+ year = extract_year(record.published_date)
372
+ record_dir = build_source_record_dir(base_dir, record.source, year, record.source_id)
373
+ file_stem = safe_filename(record.source_id)
374
+
375
+ if download_pdf and record.pdf_url:
376
+ pdf_path = record_dir / f"{file_stem}.pdf"
377
+ if self._download_pdf(record, pdf_path):
378
+ record.pdf_downloaded = True
379
+ record.pdf_path = str(pdf_path)
380
+
381
+ save_json(record_dir / f"{file_stem}.json", record.to_dict())
382
+
383
+ def _download_pdf(self, record: SourcePaper, pdf_path: Path) -> bool:
384
+ candidates = [candidate for candidate in [record.pdf_url, record.landing_url] if candidate]
385
+
386
+ for candidate in candidates:
387
+ if candidate.lower().endswith(".pdf"):
388
+ if download_binary(candidate, pdf_path, headers=self.headers, timeout=self.request_timeout):
389
+ return True
390
+ continue
391
+
392
+ try:
393
+ response = requests.get(candidate, headers=self.headers, timeout=self.request_timeout)
394
+ response.raise_for_status()
395
+ soup = BeautifulSoup(response.text, "html.parser")
396
+ meta = soup.find("meta", {"name": "citation_pdf_url"})
397
+ if meta and meta.get("content"):
398
+ pdf_url = normalize_text(meta.get("content"))
399
+ if download_binary(pdf_url, pdf_path, headers=self.headers, timeout=self.request_timeout):
400
+ return True
401
+ except Exception:
402
+ continue
403
+
404
+ return False
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any, Dict, List
5
+
6
+
7
+ @dataclass
8
+ class SourcePaper:
9
+ source: str
10
+ source_id: str
11
+ title: str = ""
12
+ doi: str = ""
13
+ abstract: str = ""
14
+ authors: List[str] = field(default_factory=list)
15
+ published_date: str = ""
16
+ updated_date: str = ""
17
+ journal: str = ""
18
+ category: str = ""
19
+ landing_url: str = ""
20
+ pdf_url: str = ""
21
+ pdf_path: str = ""
22
+ pdf_downloaded: bool = False
23
+ query: str = ""
24
+ version: str = ""
25
+ keywords: List[str] = field(default_factory=list)
26
+ extra: Dict[str, Any] = field(default_factory=dict)
27
+
28
+ def to_dict(self) -> Dict[str, Any]:
29
+ return asdict(self)
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ import requests
10
+
11
+
12
+ BOOLEAN_OR_SPLIT_RE = re.compile(r"\s+OR\s+", re.IGNORECASE)
13
+ BOOLEAN_AND_SPLIT_RE = re.compile(r"\s+AND\s+", re.IGNORECASE)
14
+ TOKEN_RE = re.compile(r'"([^"]+)"|\'([^\']+)\'|(\S+)')
15
+
16
+
17
+ def normalize_text(value: Any) -> str:
18
+ if value is None:
19
+ return ""
20
+ normalized = str(value).replace("\n", " ").strip()
21
+ normalized = re.sub(r"\s+", " ", normalized)
22
+ return normalized
23
+
24
+
25
+ def safe_filename(value: Any, fallback: str = "record") -> str:
26
+ text = normalize_text(value)
27
+ if not text:
28
+ text = fallback
29
+ text = text.replace("/", "_")
30
+ text = text.replace("\\", "_")
31
+ text = re.sub(r"[^A-Za-z0-9._-]+", "_", text)
32
+ text = re.sub(r"_+", "_", text).strip("._-")
33
+ return text or fallback
34
+
35
+
36
+ def extract_year(date_text: Any) -> str:
37
+ text = normalize_text(date_text)
38
+ if not text:
39
+ return "unknown"
40
+
41
+ for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m", "%Y/%m"):
42
+ try:
43
+ return datetime.strptime(text, fmt).strftime("%Y")
44
+ except Exception:
45
+ continue
46
+
47
+ match = re.search(r"(19|20)\d{2}", text)
48
+ if match:
49
+ return match.group(0)
50
+ return "unknown"
51
+
52
+
53
+ def ensure_directory(path: Path | str) -> Path:
54
+ directory = Path(path)
55
+ directory.mkdir(parents=True, exist_ok=True)
56
+ return directory
57
+
58
+
59
+ def build_source_record_dir(base_dir: str | Path, source: str, year: str, source_id: str) -> Path:
60
+ directory = Path(base_dir) / safe_filename(source) / safe_filename(year, fallback="unknown") / safe_filename(source_id)
61
+ return ensure_directory(directory)
62
+
63
+
64
+ def save_json(path: Path | str, payload: Dict[str, Any]) -> None:
65
+ output_path = Path(path)
66
+ ensure_directory(output_path.parent)
67
+ with output_path.open("w", encoding="utf-8") as handle:
68
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
69
+
70
+
71
+ def download_binary(url: str, output_path: Path | str, headers: Optional[Dict[str, str]] = None, timeout: float = 60.0) -> bool:
72
+ try:
73
+ response = requests.get(url, headers=headers, timeout=timeout)
74
+ response.raise_for_status()
75
+ content = response.content or b""
76
+ if not content.startswith(b"%PDF"):
77
+ return False
78
+
79
+ output_file = Path(output_path)
80
+ ensure_directory(output_file.parent)
81
+ with output_file.open("wb") as handle:
82
+ handle.write(content)
83
+ return True
84
+ except Exception:
85
+ return False
86
+
87
+
88
+ def parse_boolean_query(query: str) -> List[List[str]]:
89
+ text = normalize_text(query)
90
+ if not text:
91
+ return []
92
+
93
+ clauses: List[List[str]] = []
94
+ for or_clause in BOOLEAN_OR_SPLIT_RE.split(text):
95
+ and_parts = BOOLEAN_AND_SPLIT_RE.split(or_clause)
96
+ terms: List[str] = []
97
+ for part in and_parts:
98
+ for token_match in TOKEN_RE.findall(part):
99
+ token = next((group for group in token_match if group), "")
100
+ token = normalize_text(token).strip('"\'')
101
+ if not token:
102
+ continue
103
+ if token.upper() in {"AND", "OR", "NOT"}:
104
+ continue
105
+ terms.append(token.lower())
106
+ if terms:
107
+ clauses.append(terms)
108
+ return clauses
109
+
110
+
111
+ def basic_boolean_text_match(text: Any, query: str) -> bool:
112
+ haystack = normalize_text(text).lower()
113
+ clauses = parse_boolean_query(query)
114
+ if not clauses:
115
+ return True
116
+
117
+ for clause in clauses:
118
+ if all(term in haystack for term in clause):
119
+ return True
120
+ return False
121
+
122
+
123
+ def iter_date_windows(start_date: datetime, end_date: datetime, window_days: int) -> Iterable[tuple[datetime, datetime]]:
124
+ if window_days < 1:
125
+ raise ValueError(f"window_days must be >= 1, got {window_days}")
126
+
127
+ current_start = start_date
128
+ while current_start <= end_date:
129
+ current_end = min(current_start + timedelta(days=window_days - 1), end_date)
130
+ yield current_start, current_end
131
+ current_start = current_end + timedelta(days=1)
File without changes