notanda 1.3.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.
- harvester/__init__.py +9 -0
- harvester/acquisition.py +245 -0
- harvester/advisor.py +541 -0
- harvester/cli.py +684 -0
- harvester/config.py +483 -0
- harvester/errors.py +270 -0
- harvester/evidence.py +310 -0
- harvester/http.py +632 -0
- harvester/identity.py +315 -0
- harvester/logging_setup.py +100 -0
- harvester/models.py +278 -0
- harvester/orchestrator.py +1711 -0
- harvester/preview.py +158 -0
- harvester/providers/__init__.py +12 -0
- harvester/providers/europepmc.py +355 -0
- harvester/providers/openalex.py +567 -0
- harvester/providers/unpaywall.py +188 -0
- harvester/reporting.py +170 -0
- harvester/state.py +2207 -0
- harvester/storage.py +258 -0
- harvester/util.py +46 -0
- harvester/validation.py +213 -0
- harvester/verify.py +143 -0
- harvester/vocabulary.py +523 -0
- harvester/webui/__init__.py +11 -0
- harvester/webui/api.py +1006 -0
- harvester/webui/runmanager.py +272 -0
- harvester/webui/server.py +335 -0
- harvester/webui/settings_store.py +361 -0
- harvester/webui/static/LICENSE-IBMPlex-OFL.txt +93 -0
- harvester/webui/static/LICENSE-SourceSerif4-OFL.txt +93 -0
- harvester/webui/static/app.js +2342 -0
- harvester/webui/static/favicon.ico +0 -0
- harvester/webui/static/favicon.svg +1 -0
- harvester/webui/static/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- harvester/webui/static/ibm-plex-sans-latin-400-italic.woff2 +0 -0
- harvester/webui/static/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- harvester/webui/static/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- harvester/webui/static/index.html +71 -0
- harvester/webui/static/notanda-tokens.css +38 -0
- harvester/webui/static/notanda_avatar_180.png +0 -0
- harvester/webui/static/notanda_emblem_farbig_dunkel.svg +1 -0
- harvester/webui/static/notanda_lockup_dunkel_pfade.svg +1 -0
- harvester/webui/static/source-serif-4-latin-wght-italic.woff2 +0 -0
- harvester/webui/static/source-serif-4-latin-wght-normal.woff2 +0 -0
- harvester/webui/static/styles.css +214 -0
- notanda-1.3.0.dist-info/METADATA +16 -0
- notanda-1.3.0.dist-info/RECORD +54 -0
- notanda-1.3.0.dist-info/WHEEL +5 -0
- notanda-1.3.0.dist-info/entry_points.txt +2 -0
- notanda-1.3.0.dist-info/licenses/LICENSE +21 -0
- notanda-1.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +54 -0
- notanda-1.3.0.dist-info/licenses/TRADEMARKS.md +23 -0
- notanda-1.3.0.dist-info/top_level.txt +1 -0
harvester/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Open-Access API Harvester.
|
|
2
|
+
|
|
3
|
+
A resumable, idempotent, headless CLI pipeline that discovers Open-Access scholarly
|
|
4
|
+
works via OpenAlex Topics, cross-checks them against Europe PMC, falls back to
|
|
5
|
+
Unpaywall for OA locations, and stores validated PDF/XML artifacts plus mandatory
|
|
6
|
+
JSON sidecars in a flat downstream corpus.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.3.0"
|
harvester/acquisition.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Artifact acquisition: bounded streaming download, validation, atomic publication.
|
|
2
|
+
|
|
3
|
+
MASTER_SPEC sections 14, 17, 37, 38, 39.
|
|
4
|
+
|
|
5
|
+
The invariant enforced here: **a final artifact filename never exists unless the bytes
|
|
6
|
+
behind it were fully downloaded, validated and hashed.** Everything is written to a
|
|
7
|
+
``.part`` file first and published with a single atomic rename.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .errors import (
|
|
22
|
+
BudgetExhaustedError,
|
|
23
|
+
DownloadError,
|
|
24
|
+
HarvesterError,
|
|
25
|
+
SizeLimitExceededError,
|
|
26
|
+
StorageError,
|
|
27
|
+
)
|
|
28
|
+
from .http import ProviderClient, redact_url
|
|
29
|
+
from .identity import artifact_path, validate_remote_url
|
|
30
|
+
from .models import ArtifactKind, ArtifactRecord, FulltextCandidate
|
|
31
|
+
from .util import coerce_int, utc_now_iso
|
|
32
|
+
from .validation import ValidationResult, content_type_is_plausible, validate_pdf, validate_xml
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger("harvester.acquisition")
|
|
35
|
+
|
|
36
|
+
PART_SUFFIX = ".part"
|
|
37
|
+
_CHUNK_SIZE = 64 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class AcquisitionOutcome:
|
|
42
|
+
artifact: ArtifactRecord
|
|
43
|
+
validation: ValidationResult
|
|
44
|
+
#: How many HTTP attempts this artifact needed (1 = no retry).
|
|
45
|
+
attempts: int = 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Acquirer:
|
|
49
|
+
"""Downloads and publishes one artifact at a time."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: Config, client: ProviderClient) -> None:
|
|
52
|
+
self._config = config
|
|
53
|
+
self._client = client
|
|
54
|
+
self._storage_root = Path(config.storage_root)
|
|
55
|
+
|
|
56
|
+
def acquire(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
document_id: str,
|
|
60
|
+
candidate: FulltextCandidate,
|
|
61
|
+
on_attempt: Callable[[int, HarvesterError | None, int | None], None] | None = None,
|
|
62
|
+
) -> AcquisitionOutcome:
|
|
63
|
+
"""Download, validate and atomically publish one candidate.
|
|
64
|
+
|
|
65
|
+
Retryable transport conditions (timeout, reset, 429, 5xx) are retried with the
|
|
66
|
+
configured backoff; a rejected *artifact* is not retried, because the same URL
|
|
67
|
+
will keep returning the same bytes. Raises a :class:`HarvesterError` subclass
|
|
68
|
+
on final failure. The temporary file is always removed, so an interrupted or
|
|
69
|
+
rejected download leaves nothing behind.
|
|
70
|
+
"""
|
|
71
|
+
url = validate_remote_url(
|
|
72
|
+
candidate.url, allow_private_hosts=self._config.downloads.allow_private_hosts
|
|
73
|
+
)
|
|
74
|
+
final_path = artifact_path(self._storage_root, document_id, candidate.kind.value)
|
|
75
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
max_attempts = max(1, self._config.retry.max_attempts)
|
|
77
|
+
|
|
78
|
+
for attempt in range(1, max_attempts + 1):
|
|
79
|
+
part_path = _new_part_path(final_path)
|
|
80
|
+
try:
|
|
81
|
+
resolved_url, status, content_type, _size = self._download(url, part_path)
|
|
82
|
+
validation = self._validate(candidate.kind, part_path)
|
|
83
|
+
if not content_type_is_plausible(candidate.kind.value, content_type):
|
|
84
|
+
LOGGER.info(
|
|
85
|
+
"artifact %s: content-type %r disagrees with validated %s content",
|
|
86
|
+
document_id,
|
|
87
|
+
content_type,
|
|
88
|
+
candidate.kind.value,
|
|
89
|
+
)
|
|
90
|
+
_publish(part_path, final_path)
|
|
91
|
+
except HarvesterError as exc:
|
|
92
|
+
_discard(part_path)
|
|
93
|
+
if on_attempt is not None:
|
|
94
|
+
on_attempt(attempt, exc, exc.http_status)
|
|
95
|
+
if (
|
|
96
|
+
isinstance(exc, BudgetExhaustedError)
|
|
97
|
+
or not exc.retryable
|
|
98
|
+
or attempt >= max_attempts
|
|
99
|
+
):
|
|
100
|
+
raise
|
|
101
|
+
self._client.sleep_before_retry(attempt, exc)
|
|
102
|
+
continue
|
|
103
|
+
except BaseException:
|
|
104
|
+
_discard(part_path)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
if on_attempt is not None:
|
|
108
|
+
on_attempt(attempt, None, status)
|
|
109
|
+
return AcquisitionOutcome(
|
|
110
|
+
artifact=ArtifactRecord(
|
|
111
|
+
kind=candidate.kind,
|
|
112
|
+
filename=final_path.name,
|
|
113
|
+
sha256=validation.sha256,
|
|
114
|
+
size_bytes=validation.size_bytes,
|
|
115
|
+
retrieved_at=utc_now_iso(),
|
|
116
|
+
source=candidate.source,
|
|
117
|
+
original_url=redact_url(candidate.url),
|
|
118
|
+
resolved_url=redact_url(resolved_url),
|
|
119
|
+
http_status=status,
|
|
120
|
+
content_type=content_type,
|
|
121
|
+
),
|
|
122
|
+
validation=validation,
|
|
123
|
+
attempts=attempt,
|
|
124
|
+
)
|
|
125
|
+
raise AssertionError("unreachable: the retry loop always returns or raises")
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------ internals
|
|
128
|
+
|
|
129
|
+
def _download(self, url: str, part_path: Path) -> tuple[str, int, str | None, int]:
|
|
130
|
+
limit = self._config.downloads.max_download_size_bytes
|
|
131
|
+
with self._client.stream("GET", url) as response:
|
|
132
|
+
status = response.status_code
|
|
133
|
+
content_type = response.headers.get("content-type")
|
|
134
|
+
resolved_url = str(response.url)
|
|
135
|
+
|
|
136
|
+
# Reject before transferring anything when the server announces a size we
|
|
137
|
+
# will not accept (MASTER_SPEC section 39).
|
|
138
|
+
declared = coerce_int(response.headers.get("content-length"))
|
|
139
|
+
if declared is not None and declared > limit:
|
|
140
|
+
raise SizeLimitExceededError(
|
|
141
|
+
f"server declared {declared} bytes, above the configured limit of {limit}",
|
|
142
|
+
http_status=status,
|
|
143
|
+
url=redact_url(url),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
written = 0
|
|
147
|
+
try:
|
|
148
|
+
with open(part_path, "wb") as handle:
|
|
149
|
+
for chunk in response.iter_bytes(_CHUNK_SIZE):
|
|
150
|
+
if not chunk:
|
|
151
|
+
continue
|
|
152
|
+
written += len(chunk)
|
|
153
|
+
# The limit is enforced during transfer too: Content-Length may
|
|
154
|
+
# be absent, wrong, or the response may be chunked.
|
|
155
|
+
if written > limit:
|
|
156
|
+
raise SizeLimitExceededError(
|
|
157
|
+
f"download exceeded the configured limit of {limit} bytes",
|
|
158
|
+
http_status=status,
|
|
159
|
+
url=redact_url(url),
|
|
160
|
+
)
|
|
161
|
+
handle.write(chunk)
|
|
162
|
+
handle.flush()
|
|
163
|
+
os.fsync(handle.fileno())
|
|
164
|
+
except SizeLimitExceededError:
|
|
165
|
+
raise
|
|
166
|
+
except HarvesterError:
|
|
167
|
+
raise
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
raise StorageError(
|
|
170
|
+
f"could not write {part_path.name}: {exc}", url=redact_url(url)
|
|
171
|
+
) from exc
|
|
172
|
+
except Exception as exc: # transport failure part-way through the stream
|
|
173
|
+
raise DownloadError(
|
|
174
|
+
f"download interrupted after {written} bytes: {exc}",
|
|
175
|
+
http_status=status,
|
|
176
|
+
url=redact_url(url),
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
if written == 0:
|
|
180
|
+
raise DownloadError(
|
|
181
|
+
"server returned an empty body", http_status=status, url=redact_url(url)
|
|
182
|
+
)
|
|
183
|
+
return resolved_url, status, content_type, written
|
|
184
|
+
|
|
185
|
+
def _validate(self, kind: ArtifactKind, path: Path) -> ValidationResult:
|
|
186
|
+
if kind is ArtifactKind.PDF:
|
|
187
|
+
return validate_pdf(path, min_size_bytes=self._config.downloads.min_pdf_size_bytes)
|
|
188
|
+
return validate_xml(path, min_size_bytes=self._config.downloads.min_xml_size_bytes)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _new_part_path(final_path: Path) -> Path:
|
|
192
|
+
"""A unique temporary path beside the final artifact.
|
|
193
|
+
|
|
194
|
+
Uniqueness matters: two workers must never share a ``.part`` file, and a stale
|
|
195
|
+
``.part`` left by a killed process must never be appended to or mistaken for
|
|
196
|
+
live work.
|
|
197
|
+
"""
|
|
198
|
+
handle, name = tempfile.mkstemp(
|
|
199
|
+
prefix=f"{final_path.name}.", suffix=PART_SUFFIX, dir=str(final_path.parent)
|
|
200
|
+
)
|
|
201
|
+
os.close(handle)
|
|
202
|
+
return Path(name)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _publish(part_path: Path, final_path: Path) -> None:
|
|
206
|
+
"""Atomically move the validated temporary file into place."""
|
|
207
|
+
try:
|
|
208
|
+
os.replace(part_path, final_path)
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
raise StorageError(f"could not publish {final_path.name}: {exc}") from exc
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _discard(part_path: Path) -> None:
|
|
214
|
+
try:
|
|
215
|
+
if part_path.exists():
|
|
216
|
+
part_path.unlink()
|
|
217
|
+
except OSError: # pragma: no cover - best effort cleanup
|
|
218
|
+
LOGGER.warning("could not remove temporary file %s", part_path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def sweep_stale_parts(storage_root: Path, *, min_age_seconds: float = 300.0) -> list[str]:
|
|
222
|
+
"""Remove ``.part`` files left behind by a terminated process.
|
|
223
|
+
|
|
224
|
+
MASTER_SPEC section 17: stale temporary files must be safely detectable and
|
|
225
|
+
recoverable. They carry no validated content, so removing them costs nothing —
|
|
226
|
+
the affected documents are re-acquired from their recorded state.
|
|
227
|
+
|
|
228
|
+
Only files untouched for *min_age_seconds* are removed. A second harvester
|
|
229
|
+
process working against the same corpus may have a download in flight, and its
|
|
230
|
+
temporary file must not be deleted underneath it.
|
|
231
|
+
"""
|
|
232
|
+
root = Path(storage_root)
|
|
233
|
+
if not root.exists():
|
|
234
|
+
return []
|
|
235
|
+
cutoff = time.time() - max(0.0, min_age_seconds)
|
|
236
|
+
removed: list[str] = []
|
|
237
|
+
for path in sorted(root.glob(f"*{PART_SUFFIX}")):
|
|
238
|
+
try:
|
|
239
|
+
if path.stat().st_mtime > cutoff:
|
|
240
|
+
continue
|
|
241
|
+
path.unlink()
|
|
242
|
+
removed.append(path.name)
|
|
243
|
+
except OSError: # pragma: no cover - concurrent sweep
|
|
244
|
+
LOGGER.warning("could not remove stale temporary file %s", path)
|
|
245
|
+
return removed
|