keble-scraper-api 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.
- keble_scraper_api/__init__.py +3 -0
- keble_scraper_api/api/dependencies.py +100 -0
- keble_scraper_api/api/errors.py +72 -0
- keble_scraper_api/api/router.py +473 -0
- keble_scraper_api/api/schemas.py +26 -0
- keble_scraper_api/application/admin.py +285 -0
- keble_scraper_api/application/artifacts.py +393 -0
- keble_scraper_api/application/auth.py +324 -0
- keble_scraper_api/application/failures.py +427 -0
- keble_scraper_api/application/fetching.py +72 -0
- keble_scraper_api/application/jobs.py +891 -0
- keble_scraper_api/application/proxy_health.py +126 -0
- keble_scraper_api/application/scheduling.py +24 -0
- keble_scraper_api/artifact_namespace.py +76 -0
- keble_scraper_api/container.py +361 -0
- keble_scraper_api/errors.py +25 -0
- keble_scraper_api/infrastructure/artifact_store.py +585 -0
- keble_scraper_api/infrastructure/callbacks.py +285 -0
- keble_scraper_api/infrastructure/celery_factory.py +63 -0
- keble_scraper_api/infrastructure/cloudflare.py +259 -0
- keble_scraper_api/infrastructure/credentials.py +75 -0
- keble_scraper_api/infrastructure/dispatcher.py +57 -0
- keble_scraper_api/infrastructure/http_fetcher.py +351 -0
- keble_scraper_api/infrastructure/mongo.py +2345 -0
- keble_scraper_api/infrastructure/proxy_router.py +460 -0
- keble_scraper_api/infrastructure/request_profiles.py +220 -0
- keble_scraper_api/infrastructure/url_policy.py +341 -0
- keble_scraper_api/main.py +162 -0
- keble_scraper_api/maintenance/__init__.py +1 -0
- keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
- keble_scraper_api/models.py +473 -0
- keble_scraper_api/protocols.py +947 -0
- keble_scraper_api/settings.py +484 -0
- keble_scraper_api/telemetry.py +109 -0
- keble_scraper_api/workers/celery_app.py +48 -0
- keble_scraper_api/workers/cli.py +106 -0
- keble_scraper_api/workers/runtime.py +78 -0
- keble_scraper_api/workers/tasks.py +97 -0
- keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
- keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
- keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
- keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Authenticated proxy, artifact-download, and cleanup admin services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from keble_scraper_contract import (
|
|
8
|
+
OperatorSession,
|
|
9
|
+
ProxyCandidateCreate,
|
|
10
|
+
ProxyCandidateSource,
|
|
11
|
+
ProxyCandidateView,
|
|
12
|
+
RouteBudgetView,
|
|
13
|
+
ScrapeArtifactDownload,
|
|
14
|
+
ScrapeArtifactPin,
|
|
15
|
+
ScrapeArtifactRef,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from keble_scraper_api.application.artifacts import ArtifactService
|
|
19
|
+
from keble_scraper_api.application.jobs import AuditService, MaintenanceService
|
|
20
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
21
|
+
from keble_scraper_api.models import AuditAction
|
|
22
|
+
from keble_scraper_api.protocols import ProxyRepositoryProtocol, ProxyRouterProtocol
|
|
23
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_mutator(*, actor: OperatorSession) -> None:
|
|
27
|
+
"""Enforce MEMBER read-only and ADMIN/SUPADMIN full parity.
|
|
28
|
+
|
|
29
|
+
Side effects if changes:
|
|
30
|
+
- every operations-console mutation and raw artifact download.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if not actor.can_mutate():
|
|
34
|
+
raise ScraperDomainError(
|
|
35
|
+
code=ScraperErrorCode.AUTH_FORBIDDEN,
|
|
36
|
+
message="operator role does not permit mutations",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProxyAdminService:
|
|
41
|
+
"""Manage quarantined candidates and credential-free route budgets.
|
|
42
|
+
|
|
43
|
+
Side effects if changes:
|
|
44
|
+
- future experimental egress, spend policy views, and audit records.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
repository: ProxyRepositoryProtocol,
|
|
51
|
+
router: ProxyRouterProtocol,
|
|
52
|
+
audit: AuditService,
|
|
53
|
+
settings: ScraperSettings,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Bind proxy persistence/router, audit, and feed allowlist policy.
|
|
56
|
+
|
|
57
|
+
Side effects if changes:
|
|
58
|
+
- all proxy admin endpoints.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
self._repository = repository
|
|
62
|
+
self._router = router
|
|
63
|
+
self._audit = audit
|
|
64
|
+
self._settings = settings
|
|
65
|
+
|
|
66
|
+
async def list_candidates(self, *, limit: int) -> tuple[ProxyCandidateView, ...]:
|
|
67
|
+
"""Return bounded credential-free candidate views.
|
|
68
|
+
|
|
69
|
+
Side effects if changes:
|
|
70
|
+
- proxy dashboard Mongo reads.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
return await self._repository.list_candidates(limit=limit)
|
|
74
|
+
|
|
75
|
+
async def import_candidate(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
command: ProxyCandidateCreate,
|
|
79
|
+
actor: OperatorSession,
|
|
80
|
+
request_id: str,
|
|
81
|
+
) -> ProxyCandidateView:
|
|
82
|
+
"""Validate provenance, quarantine one encrypted candidate, and audit.
|
|
83
|
+
|
|
84
|
+
Side effects if changes:
|
|
85
|
+
- encrypted proxy Mongo write and potential future experiment egress.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
require_mutator(actor=actor)
|
|
89
|
+
if command.source is ProxyCandidateSource.ALLOWLISTED_FEED:
|
|
90
|
+
allowlist = set(self._settings.public_proxy_feed_allowlist)
|
|
91
|
+
if command.source_reference not in allowlist:
|
|
92
|
+
raise ScraperDomainError(
|
|
93
|
+
code=ScraperErrorCode.AUTH_FORBIDDEN,
|
|
94
|
+
message="proxy feed is not allowlisted",
|
|
95
|
+
)
|
|
96
|
+
candidate = await self._repository.import_candidate(command=command)
|
|
97
|
+
await self._audit.record(
|
|
98
|
+
actor_id=actor.user_id,
|
|
99
|
+
actor_role=actor.role.value,
|
|
100
|
+
action=AuditAction.PROXY_IMPORT,
|
|
101
|
+
target_identity=str(candidate.candidate_id),
|
|
102
|
+
reason=f"proxy candidate imported from {command.source.value}",
|
|
103
|
+
request_id=request_id,
|
|
104
|
+
)
|
|
105
|
+
return candidate
|
|
106
|
+
|
|
107
|
+
async def budgets(self) -> tuple[RouteBudgetView, ...]:
|
|
108
|
+
"""Return current route spend/rate gates.
|
|
109
|
+
|
|
110
|
+
Side effects if changes:
|
|
111
|
+
- cost dashboard Redis reads.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
return await self._router.budgets()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ArtifactAdminService:
|
|
118
|
+
"""Authorize, sign, and audit private artifact downloads.
|
|
119
|
+
|
|
120
|
+
Side effects if changes:
|
|
121
|
+
- raw object access and immutable audit evidence.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
artifacts: ArtifactService,
|
|
128
|
+
audit: AuditService,
|
|
129
|
+
settings: ScraperSettings,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Bind artifact storage, audit, and signed-URL duration.
|
|
132
|
+
|
|
133
|
+
Side effects if changes:
|
|
134
|
+
- exact raw-download endpoint behavior.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
self._artifacts = artifacts
|
|
138
|
+
self._audit = audit
|
|
139
|
+
self._settings = settings
|
|
140
|
+
|
|
141
|
+
async def download(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
artifact_id: UUID,
|
|
145
|
+
actor: OperatorSession,
|
|
146
|
+
request_id: str,
|
|
147
|
+
reason: str,
|
|
148
|
+
) -> ScrapeArtifactDownload:
|
|
149
|
+
"""Issue one short-lived private download only to a mutating role.
|
|
150
|
+
|
|
151
|
+
Side effects if changes:
|
|
152
|
+
- signed OSS access and raw-download audit write.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
require_mutator(actor=actor)
|
|
156
|
+
audit_id = uuid4()
|
|
157
|
+
download = await self._artifacts.download(
|
|
158
|
+
artifact_id=artifact_id,
|
|
159
|
+
expires_seconds=self._settings.artifact_download_seconds,
|
|
160
|
+
audit_id=audit_id,
|
|
161
|
+
)
|
|
162
|
+
await self._audit.record(
|
|
163
|
+
actor_id=actor.user_id,
|
|
164
|
+
actor_role=actor.role.value,
|
|
165
|
+
action=AuditAction.ARTIFACT_DOWNLOAD,
|
|
166
|
+
target_identity=str(artifact_id),
|
|
167
|
+
reason=reason,
|
|
168
|
+
request_id=request_id,
|
|
169
|
+
audit_id=audit_id,
|
|
170
|
+
)
|
|
171
|
+
return download
|
|
172
|
+
|
|
173
|
+
async def download_service(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
artifact_id: UUID,
|
|
177
|
+
caller_identity: str,
|
|
178
|
+
request_id: str,
|
|
179
|
+
reason: str,
|
|
180
|
+
) -> ScrapeArtifactDownload:
|
|
181
|
+
"""Issue and audit one private download for the Shopify service audience.
|
|
182
|
+
|
|
183
|
+
Side effects if changes:
|
|
184
|
+
- signed OSS access and immutable service audit write.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
audit_id = uuid4()
|
|
188
|
+
download = await self._artifacts.download(
|
|
189
|
+
artifact_id=artifact_id,
|
|
190
|
+
expires_seconds=self._settings.artifact_download_seconds,
|
|
191
|
+
audit_id=audit_id,
|
|
192
|
+
)
|
|
193
|
+
await self._audit.record(
|
|
194
|
+
actor_id=caller_identity,
|
|
195
|
+
actor_role="SERVICE",
|
|
196
|
+
action=AuditAction.ARTIFACT_DOWNLOAD,
|
|
197
|
+
target_identity=str(artifact_id),
|
|
198
|
+
reason=reason,
|
|
199
|
+
request_id=request_id,
|
|
200
|
+
audit_id=audit_id,
|
|
201
|
+
)
|
|
202
|
+
return download
|
|
203
|
+
|
|
204
|
+
async def get_service(self, *, artifact_id: UUID) -> ScrapeArtifactRef:
|
|
205
|
+
"""Return safe artifact metadata to the authenticated Shopify service.
|
|
206
|
+
|
|
207
|
+
Side effects if changes:
|
|
208
|
+
- scraper Mongo metadata read; no object access is granted.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
return await self._artifacts.get(artifact_id=artifact_id)
|
|
212
|
+
|
|
213
|
+
async def pin_service(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
pin: ScrapeArtifactPin,
|
|
217
|
+
caller_identity: str,
|
|
218
|
+
request_id: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Synchronize and audit one version-fenced Shopify retention pin.
|
|
221
|
+
|
|
222
|
+
Steps:
|
|
223
|
+
1. Apply the retention command through the ACID artifact repository.
|
|
224
|
+
2. Record the accepted command revision and caller without secret data.
|
|
225
|
+
|
|
226
|
+
Side effects if changes:
|
|
227
|
+
- artifact reference count and orphan eligibility;
|
|
228
|
+
- immutable service audit evidence.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
await self._artifacts.pin(pin=pin)
|
|
232
|
+
await self._audit.record(
|
|
233
|
+
actor_id=caller_identity,
|
|
234
|
+
actor_role="SERVICE",
|
|
235
|
+
action=AuditAction.ARTIFACT_PIN,
|
|
236
|
+
target_identity=str(pin.artifact_id),
|
|
237
|
+
reason=(
|
|
238
|
+
f"artifact retention revision {pin.retention_revision} "
|
|
239
|
+
f"synchronized by {pin.owner_identity}"
|
|
240
|
+
),
|
|
241
|
+
request_id=request_id,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class MaintenanceAdminService:
|
|
246
|
+
"""Authorize and audit bounded manual cleanup runs.
|
|
247
|
+
|
|
248
|
+
Side effects if changes:
|
|
249
|
+
- lease recovery, cache/pin cleanup, object deletion, and task publication.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, *, maintenance: MaintenanceService, audit: AuditService) -> None:
|
|
253
|
+
"""Bind maintenance engine and immutable audit service.
|
|
254
|
+
|
|
255
|
+
Side effects if changes:
|
|
256
|
+
- manual cleanup endpoint.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
self._maintenance = maintenance
|
|
260
|
+
self._audit = audit
|
|
261
|
+
|
|
262
|
+
async def run(
|
|
263
|
+
self,
|
|
264
|
+
*,
|
|
265
|
+
actor: OperatorSession,
|
|
266
|
+
request_id: str,
|
|
267
|
+
reason: str,
|
|
268
|
+
) -> dict[str, int]:
|
|
269
|
+
"""Execute one bounded cleanup and record its operator identity.
|
|
270
|
+
|
|
271
|
+
Side effects if changes:
|
|
272
|
+
- persistent cleanup and audit history.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
require_mutator(actor=actor)
|
|
276
|
+
result = await self._maintenance.run()
|
|
277
|
+
await self._audit.record(
|
|
278
|
+
actor_id=actor.user_id,
|
|
279
|
+
actor_role=actor.role.value,
|
|
280
|
+
action=AuditAction.CLEANUP_RUN,
|
|
281
|
+
target_identity="scraper-maintenance",
|
|
282
|
+
reason=reason,
|
|
283
|
+
request_id=request_id,
|
|
284
|
+
)
|
|
285
|
+
return result
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Artifact capture, sanitization, content identity, and download services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from uuid import UUID, uuid4, uuid5
|
|
9
|
+
|
|
10
|
+
import nh3
|
|
11
|
+
|
|
12
|
+
from keble_scraper_contract import (
|
|
13
|
+
ScrapeArtifactDownload,
|
|
14
|
+
ScrapeArtifactKind,
|
|
15
|
+
ScrapeArtifactPin,
|
|
16
|
+
ScrapeArtifactRef,
|
|
17
|
+
ScrapeCaptureMode,
|
|
18
|
+
ScrapeRetentionClass,
|
|
19
|
+
utc_now,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from keble_scraper_api.application.fetching import FetchResult
|
|
23
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
24
|
+
from keble_scraper_api.models import ArtifactBatchLookup
|
|
25
|
+
from keble_scraper_api.protocols import (
|
|
26
|
+
ArtifactRepositoryProtocol,
|
|
27
|
+
ArtifactStoreProtocol,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_ARTIFACT_NAMESPACE = UUID("2dc62d8c-619b-4bba-81b1-6fba8854e84e")
|
|
31
|
+
_CHARSET_PATTERN = re.compile(
|
|
32
|
+
r"charset\s*=\s*[\"']?([A-Za-z0-9._-]+)",
|
|
33
|
+
flags=re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
_SUPPORTED_HTML_ENCODINGS = {
|
|
36
|
+
"utf-8": "utf-8",
|
|
37
|
+
"utf8": "utf-8",
|
|
38
|
+
"gbk": "gb18030",
|
|
39
|
+
"gb2312": "gb18030",
|
|
40
|
+
"gb18030": "gb18030",
|
|
41
|
+
"shift-jis": "shift_jis",
|
|
42
|
+
"shift_jis": "shift_jis",
|
|
43
|
+
"sjis": "shift_jis",
|
|
44
|
+
"euc-jp": "euc_jp",
|
|
45
|
+
"euc_jp": "euc_jp",
|
|
46
|
+
"iso-8859-1": "windows-1252",
|
|
47
|
+
"latin1": "windows-1252",
|
|
48
|
+
"windows-1252": "windows-1252",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ArtifactService:
|
|
53
|
+
"""Convert successful bounded fetches into private content-addressed artifacts.
|
|
54
|
+
|
|
55
|
+
Steps:
|
|
56
|
+
1. transform typed fetch results into capture-mode-specific bytes;
|
|
57
|
+
2. write content-addressed bytes before publishing metadata;
|
|
58
|
+
3. resolve, download, pin, and clean artifacts through owner protocols.
|
|
59
|
+
|
|
60
|
+
Side effects if changes:
|
|
61
|
+
- OSS writes, sanitizer output, Mongo metadata, and Shopify source evidence.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
repository: ArtifactRepositoryProtocol,
|
|
68
|
+
store: ArtifactStoreProtocol,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Bind metadata and binary stores behind independent protocols.
|
|
71
|
+
|
|
72
|
+
Side effects if changes:
|
|
73
|
+
- all artifact creation and download paths.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
self._repository = repository
|
|
77
|
+
self._store = store
|
|
78
|
+
|
|
79
|
+
async def capture(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
result: FetchResult,
|
|
83
|
+
capture_mode: ScrapeCaptureMode,
|
|
84
|
+
retention_class: ScrapeRetentionClass,
|
|
85
|
+
) -> tuple[ScrapeArtifactRef, ...]:
|
|
86
|
+
"""Create exactly the artifacts required by one bounded capture mode.
|
|
87
|
+
|
|
88
|
+
Steps:
|
|
89
|
+
1. preserve metadata-only and empty/304 outcomes without object writes;
|
|
90
|
+
2. always hash/store raw body when requested;
|
|
91
|
+
3. sanitize HTML into a distinct immutable artifact without scripts,
|
|
92
|
+
style blocks, stylesheet links, event attributes, or inline CSS.
|
|
93
|
+
|
|
94
|
+
Side effects if changes:
|
|
95
|
+
- private object count, retention, and offline reprocessing fidelity.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
if capture_mode is ScrapeCaptureMode.METADATA_ONLY or not result.body:
|
|
99
|
+
return ()
|
|
100
|
+
if capture_mode is ScrapeCaptureMode.RAW_BODY:
|
|
101
|
+
return (
|
|
102
|
+
await self._store_one(
|
|
103
|
+
content=result.body,
|
|
104
|
+
content_type=result.content_type or "application/octet-stream",
|
|
105
|
+
kind=ScrapeArtifactKind.RAW_BODY,
|
|
106
|
+
result=result,
|
|
107
|
+
retention_class=retention_class,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
if result.content_type is None or not result.content_type.startswith(
|
|
111
|
+
"text/html"
|
|
112
|
+
):
|
|
113
|
+
raise ScraperDomainError(
|
|
114
|
+
code=ScraperErrorCode.CONTENT_TYPE_REJECTED,
|
|
115
|
+
message="HTML capture mode requires an HTML response",
|
|
116
|
+
)
|
|
117
|
+
sanitized = self._sanitize_html(
|
|
118
|
+
content=result.body,
|
|
119
|
+
content_type=result.content_type,
|
|
120
|
+
)
|
|
121
|
+
if capture_mode is ScrapeCaptureMode.RENDERED_HTML:
|
|
122
|
+
return (
|
|
123
|
+
await self._store_one(
|
|
124
|
+
content=sanitized,
|
|
125
|
+
content_type="text/html; charset=utf-8",
|
|
126
|
+
kind=ScrapeArtifactKind.RENDERED_HTML,
|
|
127
|
+
result=result,
|
|
128
|
+
retention_class=retention_class,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
raw = await self._store_one(
|
|
132
|
+
content=result.body,
|
|
133
|
+
content_type=result.content_type,
|
|
134
|
+
kind=ScrapeArtifactKind.RAW_BODY,
|
|
135
|
+
result=result,
|
|
136
|
+
retention_class=retention_class,
|
|
137
|
+
)
|
|
138
|
+
clean = await self._store_one(
|
|
139
|
+
content=sanitized,
|
|
140
|
+
content_type="text/html; charset=utf-8",
|
|
141
|
+
kind=ScrapeArtifactKind.SANITIZED_HTML,
|
|
142
|
+
result=result,
|
|
143
|
+
retention_class=retention_class,
|
|
144
|
+
)
|
|
145
|
+
return raw, clean
|
|
146
|
+
|
|
147
|
+
async def download(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
artifact_id: UUID,
|
|
151
|
+
expires_seconds: int,
|
|
152
|
+
audit_id: UUID,
|
|
153
|
+
) -> ScrapeArtifactDownload:
|
|
154
|
+
"""Resolve metadata then issue an audit-correlated object signature.
|
|
155
|
+
|
|
156
|
+
Side effects if changes:
|
|
157
|
+
- raw artifact access; callers must append the matching audit event.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
await self._repository.get(artifact_id=artifact_id)
|
|
161
|
+
object_key = await self._repository.object_key(artifact_id=artifact_id)
|
|
162
|
+
return await self._store.signed_download(
|
|
163
|
+
artifact_id=artifact_id,
|
|
164
|
+
object_key=object_key,
|
|
165
|
+
expires_seconds=expires_seconds,
|
|
166
|
+
audit_id=audit_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def get(self, *, artifact_id: UUID) -> ScrapeArtifactRef:
|
|
170
|
+
"""Return safe private-object metadata without issuing object access.
|
|
171
|
+
|
|
172
|
+
Side effects if changes:
|
|
173
|
+
- Shopify polling recovery and artifact hash/trust validation.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
return await self._repository.get(artifact_id=artifact_id)
|
|
177
|
+
|
|
178
|
+
async def get_many_existing(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
artifact_ids: list[UUID],
|
|
182
|
+
) -> ArtifactBatchLookup:
|
|
183
|
+
"""Resolve ordered cache artifacts while preserving explicit absence.
|
|
184
|
+
|
|
185
|
+
Steps:
|
|
186
|
+
1. delegate one primary-key batch read to the repository;
|
|
187
|
+
2. preserve cache order for existing artifacts;
|
|
188
|
+
3. return missing identities without exception laundering.
|
|
189
|
+
|
|
190
|
+
Side effects if changes:
|
|
191
|
+
- cached job completion, invalidation, and callback artifact order.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
return await self._repository.get_many_existing(
|
|
195
|
+
artifact_ids=artifact_ids,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def pin(self, *, pin: ScrapeArtifactPin) -> None:
|
|
199
|
+
"""Create or strengthen one downstream artifact retention reference.
|
|
200
|
+
|
|
201
|
+
Side effects if changes:
|
|
202
|
+
- Mongo pin/reference count and orphan cleanup eligibility.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
await self._repository.pin(pin=pin)
|
|
206
|
+
|
|
207
|
+
async def release_expired_pins(self) -> int:
|
|
208
|
+
"""Release due downstream pins before orphan selection.
|
|
209
|
+
|
|
210
|
+
Side effects if changes:
|
|
211
|
+
- artifact reference counts and retention cleanup.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
return await self._repository.release_expired_pins(now=utc_now())
|
|
215
|
+
|
|
216
|
+
async def cleanup_orphans(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
limit: int = 1_000,
|
|
220
|
+
lease_seconds: int = 300,
|
|
221
|
+
) -> int:
|
|
222
|
+
"""Lease confirmed orphans, delete objects, then delete leased metadata.
|
|
223
|
+
|
|
224
|
+
Steps:
|
|
225
|
+
1. atomically lease only expired, unreferenced metadata;
|
|
226
|
+
2. let an object-store failure escape for supervision while its lease expires;
|
|
227
|
+
3. remove metadata only when the same lease still owns each row.
|
|
228
|
+
|
|
229
|
+
Side effects if changes:
|
|
230
|
+
- irreversible object removal and storage-cost reconciliation.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
lease_id = uuid4()
|
|
234
|
+
object_keys = await self._repository.lease_orphan_object_keys(
|
|
235
|
+
now=utc_now(),
|
|
236
|
+
limit=limit,
|
|
237
|
+
lease_id=lease_id,
|
|
238
|
+
lease_seconds=lease_seconds,
|
|
239
|
+
)
|
|
240
|
+
await self._store.delete_many(object_keys=object_keys)
|
|
241
|
+
return await self._repository.complete_orphan_deletion(
|
|
242
|
+
object_keys=object_keys,
|
|
243
|
+
lease_id=lease_id,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def _store_one(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
content: bytes,
|
|
250
|
+
content_type: str,
|
|
251
|
+
kind: ScrapeArtifactKind,
|
|
252
|
+
result: FetchResult,
|
|
253
|
+
retention_class: ScrapeRetentionClass,
|
|
254
|
+
) -> ScrapeArtifactRef:
|
|
255
|
+
"""Store/register one deterministic content-plus-kind identity.
|
|
256
|
+
|
|
257
|
+
Steps:
|
|
258
|
+
1. derive immutable content and artifact identities plus retention metadata;
|
|
259
|
+
2. durably write/verify content-addressed bytes before visibility;
|
|
260
|
+
3. register metadata only after the object key matches the expected namespace.
|
|
261
|
+
|
|
262
|
+
Side effects if changes:
|
|
263
|
+
- OSS bytes and content metadata deduplication;
|
|
264
|
+
- metadata must never point at an object write that did not complete.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
content_hash = hashlib.sha256(content).hexdigest()
|
|
268
|
+
artifact_id = uuid5(_ARTIFACT_NAMESPACE, f"{content_hash}:{kind.value}")
|
|
269
|
+
now = utc_now()
|
|
270
|
+
expires_at = {
|
|
271
|
+
ScrapeRetentionClass.TRANSIENT_24H: now + timedelta(hours=24),
|
|
272
|
+
ScrapeRetentionClass.CACHE_7D: now + timedelta(days=7),
|
|
273
|
+
ScrapeRetentionClass.REFERENCED_ARCHIVE: now + timedelta(days=365),
|
|
274
|
+
}[retention_class]
|
|
275
|
+
artifact = ScrapeArtifactRef(
|
|
276
|
+
artifact_id=artifact_id,
|
|
277
|
+
content_sha256=content_hash,
|
|
278
|
+
kind=kind,
|
|
279
|
+
content_type=content_type,
|
|
280
|
+
byte_length=len(content),
|
|
281
|
+
trust=result.trust,
|
|
282
|
+
retention_class=retention_class,
|
|
283
|
+
created_at=now,
|
|
284
|
+
expires_at=expires_at,
|
|
285
|
+
)
|
|
286
|
+
object_key = self._store.object_key(content_sha256=content_hash)
|
|
287
|
+
stored_key = await self._store.put_content_addressed(
|
|
288
|
+
content_sha256=content_hash,
|
|
289
|
+
content=content,
|
|
290
|
+
content_type=content_type,
|
|
291
|
+
)
|
|
292
|
+
if stored_key != object_key:
|
|
293
|
+
raise ScraperDomainError(
|
|
294
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
295
|
+
message="artifact store returned an inconsistent content key",
|
|
296
|
+
)
|
|
297
|
+
return await self._repository.register(
|
|
298
|
+
artifact=artifact,
|
|
299
|
+
object_key=object_key,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _sanitize_html(*, content: bytes, content_type: str | None) -> bytes:
|
|
304
|
+
"""Decode declared HTML correctly, then remove executable/style content.
|
|
305
|
+
|
|
306
|
+
Steps:
|
|
307
|
+
1. inspect the HTTP charset and the first HTML metadata window;
|
|
308
|
+
2. map only a finite reviewed charset family with UTF-8 fallback;
|
|
309
|
+
3. decode with replacement before sanitizing and emitting UTF-8 bytes.
|
|
310
|
+
|
|
311
|
+
Side effects if changes:
|
|
312
|
+
- sanitized artifact safety, international text fidelity, and downstream parsers.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
encoding = ArtifactService._html_encoding(
|
|
316
|
+
content=content,
|
|
317
|
+
content_type=content_type,
|
|
318
|
+
)
|
|
319
|
+
text = content.decode(encoding, errors="replace")
|
|
320
|
+
sanitized = nh3.clean(
|
|
321
|
+
text,
|
|
322
|
+
tags={
|
|
323
|
+
"html",
|
|
324
|
+
"head",
|
|
325
|
+
"title",
|
|
326
|
+
"meta",
|
|
327
|
+
"body",
|
|
328
|
+
"main",
|
|
329
|
+
"section",
|
|
330
|
+
"article",
|
|
331
|
+
"div",
|
|
332
|
+
"span",
|
|
333
|
+
"p",
|
|
334
|
+
"h1",
|
|
335
|
+
"h2",
|
|
336
|
+
"h3",
|
|
337
|
+
"h4",
|
|
338
|
+
"h5",
|
|
339
|
+
"h6",
|
|
340
|
+
"ul",
|
|
341
|
+
"ol",
|
|
342
|
+
"li",
|
|
343
|
+
"a",
|
|
344
|
+
"img",
|
|
345
|
+
"picture",
|
|
346
|
+
"source",
|
|
347
|
+
"table",
|
|
348
|
+
"thead",
|
|
349
|
+
"tbody",
|
|
350
|
+
"tr",
|
|
351
|
+
"th",
|
|
352
|
+
"td",
|
|
353
|
+
"strong",
|
|
354
|
+
"em",
|
|
355
|
+
"br",
|
|
356
|
+
},
|
|
357
|
+
attributes={
|
|
358
|
+
"*": {"id", "class", "data-product-id", "data-product-handle"},
|
|
359
|
+
"a": {"href"},
|
|
360
|
+
"img": {"src", "srcset", "alt", "width", "height"},
|
|
361
|
+
"source": {"src", "srcset", "type"},
|
|
362
|
+
"meta": {"name", "property", "content"},
|
|
363
|
+
},
|
|
364
|
+
url_schemes={"http", "https"},
|
|
365
|
+
strip_comments=True,
|
|
366
|
+
)
|
|
367
|
+
return sanitized.encode()
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _html_encoding(*, content: bytes, content_type: str | None) -> str:
|
|
371
|
+
"""Resolve one safe supported HTML encoding from header then metadata.
|
|
372
|
+
|
|
373
|
+
Steps:
|
|
374
|
+
1. inspect the response Content-Type before document bytes;
|
|
375
|
+
2. inspect a Latin-1-safe metadata window when the header is silent;
|
|
376
|
+
3. normalize aliases through the finite supported encoding table.
|
|
377
|
+
|
|
378
|
+
Side effects if changes:
|
|
379
|
+
- GBK/Shift-JIS/Western HTML normalization and artifact hash identity.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
sources = (
|
|
383
|
+
content_type or "",
|
|
384
|
+
content[:4_096].decode("latin-1", errors="ignore"),
|
|
385
|
+
)
|
|
386
|
+
for source in sources:
|
|
387
|
+
matched = _CHARSET_PATTERN.search(source)
|
|
388
|
+
if matched is not None:
|
|
389
|
+
normalized = matched.group(1).strip().lower()
|
|
390
|
+
supported = _SUPPORTED_HTML_ENCODINGS.get(normalized)
|
|
391
|
+
if supported is not None:
|
|
392
|
+
return supported
|
|
393
|
+
return "utf-8"
|