preprint-fulltext 0.1.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.
- preprint_fulltext/__init__.py +8 -0
- preprint_fulltext/cli.py +324 -0
- preprint_fulltext/config.py +127 -0
- preprint_fulltext/core/__init__.py +1 -0
- preprint_fulltext/core/assemble.py +72 -0
- preprint_fulltext/core/cache.py +49 -0
- preprint_fulltext/core/chunk.py +170 -0
- preprint_fulltext/core/http.py +71 -0
- preprint_fulltext/core/ids.py +133 -0
- preprint_fulltext/core/jats.py +240 -0
- preprint_fulltext/core/licenses.py +131 -0
- preprint_fulltext/core/models.py +153 -0
- preprint_fulltext/mcp_server.py +270 -0
- preprint_fulltext/pipeline/__init__.py +1 -0
- preprint_fulltext/pipeline/export.py +84 -0
- preprint_fulltext/pipeline/ingest.py +153 -0
- preprint_fulltext/pipeline/resolve.py +45 -0
- preprint_fulltext/pipeline/router.py +176 -0
- preprint_fulltext/sources/__init__.py +1 -0
- preprint_fulltext/sources/arxiv.py +166 -0
- preprint_fulltext/sources/arxiv_html.py +202 -0
- preprint_fulltext/sources/base.py +47 -0
- preprint_fulltext/sources/biorxiv_api.py +124 -0
- preprint_fulltext/sources/biorxiv_html.py +200 -0
- preprint_fulltext/sources/biorxiv_s3.py +249 -0
- preprint_fulltext/sources/europepmc.py +169 -0
- preprint_fulltext/sources/openalex.py +170 -0
- preprint_fulltext-0.1.0.dist-info/METADATA +304 -0
- preprint_fulltext-0.1.0.dist-info/RECORD +32 -0
- preprint_fulltext-0.1.0.dist-info/WHEEL +4 -0
- preprint_fulltext-0.1.0.dist-info/entry_points.txt +3 -0
- preprint_fulltext-0.1.0.dist-info/licenses/LICENSE +40 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""preprint-fulltext — retrieve full text of bioRxiv/medRxiv preprints.
|
|
2
|
+
|
|
3
|
+
Ports-and-adapters toolkit: sources map into a canonical pydantic model
|
|
4
|
+
(:mod:`preprint_fulltext.core.models`); the CLI and MCP server are thin
|
|
5
|
+
frontends over the same core library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
preprint_fulltext/cli.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Typer CLI — thin frontend over the core library.
|
|
2
|
+
|
|
3
|
+
Subcommands: `get` (single full text), `discover`/`search` (SearchHit JSONL),
|
|
4
|
+
`ingest` (bulk chunked corpus). All heavy lifting lives in the core library and
|
|
5
|
+
`pipeline/`; this module only parses args, calls the router/pipeline, and formats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import orjson
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from .config import get_settings
|
|
20
|
+
from .core.models import FullText, SearchHit
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="preprint-fulltext",
|
|
24
|
+
help="Retrieve full text of bioRxiv/medRxiv preprints into embedding-ready corpora.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def _main() -> None:
|
|
32
|
+
"""preprint-fulltext — CLI frontend over the shared core library."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dumps(obj) -> bytes:
|
|
36
|
+
return orjson.dumps(obj, option=orjson.OPT_INDENT_2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write_hits_jsonl(hits: Iterable[SearchHit], out: Optional[Path], limit: int) -> int:
|
|
40
|
+
"""Stream SearchHit records as one JSON object per line; returns the count."""
|
|
41
|
+
fh = out.open("wb") if out else sys.stdout.buffer
|
|
42
|
+
n = 0
|
|
43
|
+
try:
|
|
44
|
+
for hit in hits:
|
|
45
|
+
fh.write(orjson.dumps(hit.model_dump(mode="json")))
|
|
46
|
+
fh.write(b"\n")
|
|
47
|
+
n += 1
|
|
48
|
+
if n >= limit:
|
|
49
|
+
break
|
|
50
|
+
finally:
|
|
51
|
+
if out:
|
|
52
|
+
fh.close()
|
|
53
|
+
return n
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _since_to_date(since: Optional[str]) -> Optional[dt.date]:
|
|
57
|
+
"""Accept YYYY-MM or YYYY-MM-DD; map YYYY-MM to the first of the month."""
|
|
58
|
+
if not since:
|
|
59
|
+
return None
|
|
60
|
+
parts = since.split("-")
|
|
61
|
+
if len(parts) == 2:
|
|
62
|
+
return dt.date(int(parts[0]), int(parts[1]), 1)
|
|
63
|
+
return dt.date.fromisoformat(since)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_markdown(ft: FullText) -> str:
|
|
67
|
+
p = ft.preprint
|
|
68
|
+
lines = [f"# {p.title or p.doi}", ""]
|
|
69
|
+
meta = [f"**DOI:** {p.doi}", f"**Server:** {p.server.value}"]
|
|
70
|
+
if p.version is not None:
|
|
71
|
+
meta.append(f"**Version:** {p.version}")
|
|
72
|
+
if p.authors:
|
|
73
|
+
meta.append(f"**Authors:** {', '.join(p.authors)}")
|
|
74
|
+
if p.license:
|
|
75
|
+
lic = p.license.spdx_id or p.license.raw or "unknown"
|
|
76
|
+
meta.append(f"**License:** {lic} (redistributable={p.license.redistributable})")
|
|
77
|
+
meta.append(f"**Retrieved from:** {ft.retrieved_from.value}")
|
|
78
|
+
lines.append(" \n".join(meta))
|
|
79
|
+
lines.append("")
|
|
80
|
+
for s in ft.sections:
|
|
81
|
+
heading = s.title or s.kind.value.capitalize()
|
|
82
|
+
lines.append(f"## {heading}")
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append(s.text)
|
|
85
|
+
lines.append("")
|
|
86
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def version() -> None:
|
|
91
|
+
"""Print the installed version."""
|
|
92
|
+
from . import __version__
|
|
93
|
+
|
|
94
|
+
typer.echo(__version__)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def get(
|
|
99
|
+
doi: str = typer.Argument(..., help="DOI, arXiv id, or a doi.org/bioRxiv/arxiv.org URL."),
|
|
100
|
+
version: Optional[int] = typer.Option(None, "--version", "-v", help="Specific version."),
|
|
101
|
+
source: Optional[str] = typer.Option(
|
|
102
|
+
None, "--source", help="Force a source: europepmc | s3 | html | arxiv (default: auto)."
|
|
103
|
+
),
|
|
104
|
+
server: Optional[str] = typer.Option(None, "--server", help="biorxiv | medrxiv (hint for S3/HTML)."),
|
|
105
|
+
html: bool = typer.Option(
|
|
106
|
+
False, "--html", help="Allow the public HTML page as a last-resort fallback (interactive only)."
|
|
107
|
+
),
|
|
108
|
+
markdown: bool = typer.Option(False, "--markdown", "-m", help="Render sections as markdown."),
|
|
109
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to file instead of stdout."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Retrieve one preprint's full text as structured JSON (default) or markdown.
|
|
112
|
+
|
|
113
|
+
Accepts a bioRxiv/medRxiv DOI or content URL, a https://doi.org/… URL, or an arXiv id
|
|
114
|
+
/ arxiv.org URL / 10.48550/arXiv.* DOI (routed to arXiv's LaTeXML full text).
|
|
115
|
+
"""
|
|
116
|
+
from .pipeline.router import Router
|
|
117
|
+
|
|
118
|
+
result = Router(get_settings()).get_fulltext(
|
|
119
|
+
doi, version=version, source=source, server=server, allow_html=html
|
|
120
|
+
)
|
|
121
|
+
if result.fulltext is None:
|
|
122
|
+
typer.secho(
|
|
123
|
+
f"No full text for {doi} (tried: {', '.join(result.tried) or 'none'}). "
|
|
124
|
+
f"{result.reason or ''}".strip(),
|
|
125
|
+
err=True,
|
|
126
|
+
fg=typer.colors.YELLOW,
|
|
127
|
+
)
|
|
128
|
+
raise typer.Exit(code=2)
|
|
129
|
+
|
|
130
|
+
if markdown:
|
|
131
|
+
payload = _render_markdown(result.fulltext).encode("utf-8")
|
|
132
|
+
else:
|
|
133
|
+
payload = _dumps(result.fulltext.model_dump(mode="json"))
|
|
134
|
+
|
|
135
|
+
if out:
|
|
136
|
+
out.write_bytes(payload)
|
|
137
|
+
typer.secho(f"wrote {out}", err=True, fg=typer.colors.GREEN)
|
|
138
|
+
else:
|
|
139
|
+
sys.stdout.buffer.write(payload)
|
|
140
|
+
if not payload.endswith(b"\n"):
|
|
141
|
+
sys.stdout.buffer.write(b"\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def search(
|
|
146
|
+
query: str = typer.Argument(..., help="Keyword query."),
|
|
147
|
+
field: str = typer.Option("fulltext", "--field", help="fulltext | title | abstract | author."),
|
|
148
|
+
source: str = typer.Option("europepmc", "--source", help="europepmc | openalex | arxiv."),
|
|
149
|
+
limit: int = typer.Option(25, "--limit", "-n", help="Max hits."),
|
|
150
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write JSONL to file."),
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Keyword search → SearchHit JSONL. --field scopes to title/abstract/author/fulltext."""
|
|
153
|
+
settings = get_settings()
|
|
154
|
+
if source == "europepmc":
|
|
155
|
+
from .sources.europepmc import EuropePMC
|
|
156
|
+
|
|
157
|
+
hits: Iterator[SearchHit] = EuropePMC(settings).search(query, limit, field=field)
|
|
158
|
+
elif source == "openalex":
|
|
159
|
+
from .sources.openalex import OpenAlex
|
|
160
|
+
|
|
161
|
+
hits = OpenAlex(settings).search(query, limit, field=field)
|
|
162
|
+
elif source == "arxiv":
|
|
163
|
+
from .sources.arxiv import ArxivAPI
|
|
164
|
+
|
|
165
|
+
hits = ArxivAPI(settings).search(query, limit, field=field)
|
|
166
|
+
else:
|
|
167
|
+
typer.secho(f"unknown source {source!r} (europepmc|openalex|arxiv)", err=True, fg="red")
|
|
168
|
+
raise typer.Exit(code=2)
|
|
169
|
+
_run_hits(hits, out, limit)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
def discover(
|
|
174
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="Free-text topic."),
|
|
175
|
+
category: Optional[str] = typer.Option(None, "--category", help="Subject/concept."),
|
|
176
|
+
since: Optional[str] = typer.Option(None, "--since", help="From date (YYYY-MM or YYYY-MM-DD)."),
|
|
177
|
+
to: Optional[str] = typer.Option(None, "--to", help="To date (YYYY-MM-DD)."),
|
|
178
|
+
source: str = typer.Option("openalex", "--source", help="openalex | europepmc | arxiv."),
|
|
179
|
+
limit: int = typer.Option(100, "--limit", "-n", help="Max hits."),
|
|
180
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write JSONL to file."),
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Discover preprints by topic/category/date window → SearchHit JSONL."""
|
|
183
|
+
from .sources.base import DiscoverQuery
|
|
184
|
+
|
|
185
|
+
settings = get_settings()
|
|
186
|
+
dq = DiscoverQuery(
|
|
187
|
+
query=query, category=category,
|
|
188
|
+
from_date=_since_to_date(since), to_date=_since_to_date(to), limit=limit,
|
|
189
|
+
)
|
|
190
|
+
if source == "openalex":
|
|
191
|
+
from .sources.openalex import OpenAlex
|
|
192
|
+
|
|
193
|
+
hits: Iterator[SearchHit] = OpenAlex(settings).discover(dq)
|
|
194
|
+
elif source == "europepmc":
|
|
195
|
+
from .sources.europepmc import EuropePMC
|
|
196
|
+
|
|
197
|
+
hits = EuropePMC(settings).discover(dq)
|
|
198
|
+
elif source == "arxiv":
|
|
199
|
+
from .sources.arxiv import ArxivAPI
|
|
200
|
+
|
|
201
|
+
hits = ArxivAPI(settings).discover(dq)
|
|
202
|
+
else:
|
|
203
|
+
typer.secho(f"unknown source {source!r} (openalex|europepmc|arxiv)", err=True, fg="red")
|
|
204
|
+
raise typer.Exit(code=2)
|
|
205
|
+
_run_hits(hits, out, limit)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def ingest(
|
|
210
|
+
out: Path = typer.Argument(..., help="Output corpus path (JSONL, or .parquet with --format parquet)."),
|
|
211
|
+
source: str = typer.Option("s3", "--source", help="s3 | europepmc."),
|
|
212
|
+
server: str = typer.Option("biorxiv", "--server", help="biorxiv | medrxiv | both (s3)."),
|
|
213
|
+
since: Optional[str] = typer.Option(None, "--since", help="From month YYYY-MM (Current_Content only)."),
|
|
214
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="EPMC query (source=europepmc)."),
|
|
215
|
+
include_back_content: bool = typer.Option(
|
|
216
|
+
False, "--include-back-content", help="Also ingest undated S3 Back_Content batches."
|
|
217
|
+
),
|
|
218
|
+
redistribution: bool = typer.Option(
|
|
219
|
+
False, "--redistribution", help="Gate for a shareable corpus (degrade non-CC to stubs)."
|
|
220
|
+
),
|
|
221
|
+
limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Max preprints (testing/sampling)."),
|
|
222
|
+
fmt: str = typer.Option("jsonl", "--format", help="jsonl | parquet."),
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Bulk full text → chunked corpus JSONL/Parquet. Resumable and incremental."""
|
|
225
|
+
from .pipeline.export import ExportMode
|
|
226
|
+
from .pipeline.ingest import Ingest, Target
|
|
227
|
+
|
|
228
|
+
settings = get_settings()
|
|
229
|
+
mode = ExportMode.REDISTRIBUTION if redistribution else ExportMode.ANALYSIS
|
|
230
|
+
since_date = _since_to_date(since)
|
|
231
|
+
|
|
232
|
+
targets: Iterator[Target]
|
|
233
|
+
bytes_source = None
|
|
234
|
+
if source == "s3":
|
|
235
|
+
from .core.models import Server
|
|
236
|
+
from .sources.biorxiv_s3 import BiorxivS3
|
|
237
|
+
|
|
238
|
+
s3 = BiorxivS3(settings)
|
|
239
|
+
bytes_source = s3
|
|
240
|
+
servers = [Server.BIORXIV, Server.MEDRXIV] if server == "both" else [Server(server)]
|
|
241
|
+
|
|
242
|
+
def _s3_targets() -> Iterator[Target]:
|
|
243
|
+
count = 0
|
|
244
|
+
for srv in servers:
|
|
245
|
+
for key in s3.iter_keys(srv, since=since_date, include_back_content=include_back_content):
|
|
246
|
+
yield Target(
|
|
247
|
+
id=f"{srv.value}:{key}",
|
|
248
|
+
load=lambda srv=srv, key=key: s3.parse_meca(s3.fetch_meca(srv, key), srv, key),
|
|
249
|
+
)
|
|
250
|
+
count += 1
|
|
251
|
+
if limit and count >= limit:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
targets = _s3_targets()
|
|
255
|
+
elif source == "europepmc":
|
|
256
|
+
from .sources.europepmc import EuropePMC
|
|
257
|
+
|
|
258
|
+
epmc = EuropePMC(settings)
|
|
259
|
+
q = query or "*"
|
|
260
|
+
|
|
261
|
+
def _epmc_targets() -> Iterator[Target]:
|
|
262
|
+
for i, hit in enumerate(epmc.search(q, limit or 1000)):
|
|
263
|
+
if not hit.doi:
|
|
264
|
+
continue
|
|
265
|
+
yield Target(id=f"doi:{hit.doi}", load=lambda doi=hit.doi: epmc.get_fulltext(doi))
|
|
266
|
+
if limit and i + 1 >= limit:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
targets = _epmc_targets()
|
|
270
|
+
elif source == "arxiv":
|
|
271
|
+
typer.secho(
|
|
272
|
+
"arXiv bulk ingest is not supported (arXiv full text is HTML, single-document). "
|
|
273
|
+
"Use `get`/`search` for arXiv, or arXiv's own S3 LaTeX-source bucket for bulk.",
|
|
274
|
+
err=True, fg="red",
|
|
275
|
+
)
|
|
276
|
+
raise typer.Exit(code=2)
|
|
277
|
+
else:
|
|
278
|
+
typer.secho(f"unknown source {source!r} (s3|europepmc)", err=True, fg="red")
|
|
279
|
+
raise typer.Exit(code=2)
|
|
280
|
+
|
|
281
|
+
ing = Ingest(out, mode=mode, settings=settings,
|
|
282
|
+
on_reminder=lambda m: typer.secho(m, err=True, fg="yellow"),
|
|
283
|
+
bytes_source=bytes_source)
|
|
284
|
+
report = ing.run(targets)
|
|
285
|
+
|
|
286
|
+
if fmt == "parquet":
|
|
287
|
+
_to_parquet(out)
|
|
288
|
+
|
|
289
|
+
typer.secho(
|
|
290
|
+
f"ingest done: {report.processed} processed, {report.skipped} skipped, "
|
|
291
|
+
f"{report.degraded} degraded, {report.not_found} not-found, "
|
|
292
|
+
f"{report.n_chunks} chunks, {report.bytes_downloaded} bytes (requester-pays).",
|
|
293
|
+
err=True, fg="green",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _to_parquet(jsonl_out: Path) -> None:
|
|
298
|
+
"""Convert the durable JSONL corpus to Parquet (best-effort, needs pyarrow)."""
|
|
299
|
+
try:
|
|
300
|
+
import pyarrow as pa
|
|
301
|
+
import pyarrow.parquet as pq
|
|
302
|
+
except ImportError:
|
|
303
|
+
typer.secho("pyarrow not installed; keeping JSONL only", err=True, fg="yellow")
|
|
304
|
+
return
|
|
305
|
+
rows = [orjson.loads(line) for line in jsonl_out.read_bytes().splitlines() if line.strip()]
|
|
306
|
+
if not rows:
|
|
307
|
+
return
|
|
308
|
+
table = pa.Table.from_pylist(rows)
|
|
309
|
+
pq.write_table(table, str(jsonl_out.with_suffix(".parquet")))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_hits(hits: Iterator[SearchHit], out: Optional[Path], limit: int) -> None:
|
|
313
|
+
from .sources.base import SourceError
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
n = _write_hits_jsonl(hits, out, limit)
|
|
317
|
+
except SourceError as e:
|
|
318
|
+
typer.secho(str(e), err=True, fg="red")
|
|
319
|
+
raise typer.Exit(code=2)
|
|
320
|
+
typer.secho(f"{n} hit(s)" + (f" → {out}" if out else ""), err=True, fg="green")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__": # pragma: no cover
|
|
324
|
+
app()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Runtime configuration.
|
|
2
|
+
|
|
3
|
+
Backed by ``pydantic-settings``: values come from (highest precedence first)
|
|
4
|
+
constructor args, environment variables, a ``.env`` file, then an optional
|
|
5
|
+
``preprint-fulltext.toml``. AWS credentials themselves are left to the standard
|
|
6
|
+
boto3 chain; only the region and bucket names live here.
|
|
7
|
+
|
|
8
|
+
Externally-standard names (``CONTACT_EMAIL``, ``OPENALEX_API_KEY``,
|
|
9
|
+
``AWS_REGION``) are accepted both bare and with the ``PREPRINT_FULLTEXT_`` prefix.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import AliasChoices, Field
|
|
18
|
+
from pydantic_settings import (
|
|
19
|
+
BaseSettings,
|
|
20
|
+
PydanticBaseSettingsSource,
|
|
21
|
+
SettingsConfigDict,
|
|
22
|
+
TomlConfigSettingsSource,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
# Both buckets confirmed against the openRxiv TDM pages (biorxiv.org/tdm,
|
|
28
|
+
# medrxiv.org/tdm, 2026-07-17): requester-pays, us-east-1, $0.09/GB (~6 TB total).
|
|
29
|
+
DEFAULT_BIORXIV_BUCKET = "biorxiv-src-monthly"
|
|
30
|
+
DEFAULT_MEDRXIV_BUCKET = "medrxiv-src-monthly"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Settings(BaseSettings):
|
|
34
|
+
"""Process-wide settings. Instantiate via :func:`get_settings`."""
|
|
35
|
+
|
|
36
|
+
model_config = SettingsConfigDict(
|
|
37
|
+
env_prefix="PREPRINT_FULLTEXT_",
|
|
38
|
+
env_file=".env",
|
|
39
|
+
env_file_encoding="utf-8",
|
|
40
|
+
toml_file="preprint-fulltext.toml",
|
|
41
|
+
extra="ignore",
|
|
42
|
+
populate_by_name=True, # accept the field name too, not only validation_alias
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# --- polite-pool identity -------------------------------------------------
|
|
46
|
+
contact_email: str | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
validation_alias=AliasChoices("PREPRINT_FULLTEXT_CONTACT_EMAIL", "CONTACT_EMAIL"),
|
|
49
|
+
description="Email for OpenAlex/EPMC polite pools; sent in User-Agent and mailto.",
|
|
50
|
+
)
|
|
51
|
+
openalex_api_key: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
validation_alias=AliasChoices("PREPRINT_FULLTEXT_OPENALEX_API_KEY", "OPENALEX_API_KEY"),
|
|
54
|
+
description="OpenAlex API key (required since 2026-02-13, still free).",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# --- AWS / S3 -------------------------------------------------------------
|
|
58
|
+
aws_region: str = Field(
|
|
59
|
+
default="us-east-1",
|
|
60
|
+
validation_alias=AliasChoices("PREPRINT_FULLTEXT_AWS_REGION", "AWS_REGION"),
|
|
61
|
+
description="Region for the requester-pays openRxiv buckets (us-east-1).",
|
|
62
|
+
)
|
|
63
|
+
biorxiv_bucket: str = DEFAULT_BIORXIV_BUCKET
|
|
64
|
+
medrxiv_bucket: str = DEFAULT_MEDRXIV_BUCKET
|
|
65
|
+
|
|
66
|
+
# --- arXiv ----------------------------------------------------------------
|
|
67
|
+
arxiv_api_base: str = "https://export.arxiv.org/api/query"
|
|
68
|
+
arxiv_html_base: str = "https://arxiv.org/html"
|
|
69
|
+
ar5iv_base: str = "https://ar5iv.labs.arxiv.org/html"
|
|
70
|
+
|
|
71
|
+
# --- cache ----------------------------------------------------------------
|
|
72
|
+
cache_dir: Path = Field(
|
|
73
|
+
default=Path.home() / ".cache" / "preprint-fulltext",
|
|
74
|
+
description="Content-addressed cache of fetched .meca/XML.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# --- chunking -------------------------------------------------------------
|
|
78
|
+
chunk_tokens: int = Field(default=512, ge=1, description="Max tokens per chunk.")
|
|
79
|
+
chunk_overlap: int = Field(default=64, ge=0, description="Token overlap within a section.")
|
|
80
|
+
tokenizer: str = Field(
|
|
81
|
+
default="tiktoken:cl100k_base",
|
|
82
|
+
description="Tokenizer spec, 'tiktoken:<encoding>'.",
|
|
83
|
+
)
|
|
84
|
+
cross_section: bool = Field(
|
|
85
|
+
default=False, description="Allow a chunk to span section boundaries."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def settings_customise_sources(
|
|
90
|
+
cls,
|
|
91
|
+
settings_cls: type[BaseSettings],
|
|
92
|
+
init_settings: PydanticBaseSettingsSource,
|
|
93
|
+
env_settings: PydanticBaseSettingsSource,
|
|
94
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
95
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
96
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
97
|
+
# env/.env win over the TOML file; TOML wins over hard-coded defaults.
|
|
98
|
+
return (
|
|
99
|
+
init_settings,
|
|
100
|
+
env_settings,
|
|
101
|
+
dotenv_settings,
|
|
102
|
+
TomlConfigSettingsSource(settings_cls),
|
|
103
|
+
file_secret_settings,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def user_agent(self) -> str:
|
|
108
|
+
"""User-Agent for polite-pool HTTP requests."""
|
|
109
|
+
base = f"preprint-fulltext/{__version__}"
|
|
110
|
+
return f"{base} (mailto:{self.contact_email})" if self.contact_email else base
|
|
111
|
+
|
|
112
|
+
def bucket_for(self, server: str) -> str:
|
|
113
|
+
"""Return the S3 bucket name for a server ('biorxiv' | 'medrxiv')."""
|
|
114
|
+
if server == "biorxiv":
|
|
115
|
+
return self.biorxiv_bucket
|
|
116
|
+
if server == "medrxiv":
|
|
117
|
+
return self.medrxiv_bucket
|
|
118
|
+
raise ValueError(f"unknown server: {server!r}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@lru_cache(maxsize=1)
|
|
122
|
+
def get_settings() -> Settings:
|
|
123
|
+
"""Return the process-wide settings singleton."""
|
|
124
|
+
return Settings()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["Settings", "get_settings", "DEFAULT_BIORXIV_BUCKET", "DEFAULT_MEDRXIV_BUCKET"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""core subpackage."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""assemble.py — map a pydantic-free :class:`ParsedArticle` into the canonical model.
|
|
2
|
+
|
|
3
|
+
This is the single seam between the shared JATS parser and the canonical model,
|
|
4
|
+
used by BOTH the Europe PMC and S3 paths so their `FullText` is byte-for-byte
|
|
5
|
+
identical for the same document. The abstract is emitted as an
|
|
6
|
+
``abstract``-kind :class:`Section` so the chunker treats it uniformly; sections
|
|
7
|
+
are renumbered from 1 with ``id = str(order)``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as dt
|
|
13
|
+
|
|
14
|
+
from .jats import ParsedArticle
|
|
15
|
+
from .licenses import parse_license
|
|
16
|
+
from .models import FullText, Preprint, Section, SectionKind, Server, SourceName
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_fulltext(
|
|
20
|
+
parsed: ParsedArticle,
|
|
21
|
+
*,
|
|
22
|
+
doi: str,
|
|
23
|
+
server: Server | str,
|
|
24
|
+
source: SourceName | str,
|
|
25
|
+
version: int | None = None,
|
|
26
|
+
raw_ref: str | None = None,
|
|
27
|
+
published_doi: str | None = None,
|
|
28
|
+
date: dt.date | None = None,
|
|
29
|
+
category: str | None = None,
|
|
30
|
+
provenance: dict | None = None,
|
|
31
|
+
) -> FullText:
|
|
32
|
+
"""Assemble a :class:`FullText` from parser output plus caller-supplied metadata."""
|
|
33
|
+
license_ = parse_license(parsed.license_raw)
|
|
34
|
+
|
|
35
|
+
preprint = Preprint(
|
|
36
|
+
doi=doi,
|
|
37
|
+
version=version,
|
|
38
|
+
server=Server(server),
|
|
39
|
+
title=parsed.title,
|
|
40
|
+
authors=list(parsed.authors),
|
|
41
|
+
date=date,
|
|
42
|
+
category=category,
|
|
43
|
+
abstract=parsed.abstract,
|
|
44
|
+
license=license_,
|
|
45
|
+
published_doi=published_doi,
|
|
46
|
+
provenance=provenance or {},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
sections: list[Section] = []
|
|
50
|
+
order = 0
|
|
51
|
+
if parsed.abstract:
|
|
52
|
+
order += 1
|
|
53
|
+
sections.append(
|
|
54
|
+
Section(id=str(order), kind=SectionKind.ABSTRACT, title="Abstract",
|
|
55
|
+
text=parsed.abstract, order=order)
|
|
56
|
+
)
|
|
57
|
+
for ps in parsed.sections:
|
|
58
|
+
order += 1
|
|
59
|
+
sections.append(
|
|
60
|
+
Section(id=str(order), kind=SectionKind(ps.kind), title=ps.title,
|
|
61
|
+
text=ps.text, order=order)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return FullText(
|
|
65
|
+
preprint=preprint,
|
|
66
|
+
sections=sections,
|
|
67
|
+
retrieved_from=SourceName(source),
|
|
68
|
+
raw_ref=raw_ref,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["build_fulltext"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""cache.py — content-addressed local cache of fetched bytes (.meca/XML).
|
|
2
|
+
|
|
3
|
+
Keyed by ``sha256(source | id | version)`` so re-runs skip S3/EPMC. Sharded into
|
|
4
|
+
256 subdirectories to keep any one directory small.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..config import Settings, get_settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cache:
|
|
17
|
+
def __init__(self, cache_dir: Path | str | None = None, settings: Settings | None = None):
|
|
18
|
+
settings = settings or get_settings()
|
|
19
|
+
self.dir = Path(cache_dir or settings.cache_dir)
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def key(*parts: object) -> str:
|
|
23
|
+
raw = "|".join("" if p is None else str(p) for p in parts)
|
|
24
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
25
|
+
|
|
26
|
+
def _path(self, key: str) -> Path:
|
|
27
|
+
return self.dir / key[:2] / key
|
|
28
|
+
|
|
29
|
+
def get(self, key: str) -> bytes | None:
|
|
30
|
+
p = self._path(key)
|
|
31
|
+
return p.read_bytes() if p.exists() else None
|
|
32
|
+
|
|
33
|
+
def put(self, key: str, data: bytes) -> None:
|
|
34
|
+
p = self._path(key)
|
|
35
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
tmp = p.with_suffix(".tmp")
|
|
37
|
+
tmp.write_bytes(data)
|
|
38
|
+
tmp.replace(p) # atomic within the same filesystem
|
|
39
|
+
|
|
40
|
+
def get_or_fetch(self, key: str, fetch_fn: Callable[[], bytes]) -> bytes:
|
|
41
|
+
cached = self.get(key)
|
|
42
|
+
if cached is not None:
|
|
43
|
+
return cached
|
|
44
|
+
data = fetch_fn()
|
|
45
|
+
self.put(key, data)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["Cache"]
|