papersearch 0.4.2 → 0.4.3
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.
- package/README.en.md +292 -0
- package/README.md +203 -125
- package/carsi/README.md +8 -2
- package/carsi/paper_search_carsi/__init__.py +1 -4
- package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
- package/carsi/paper_search_carsi/adapters/base.py +2 -0
- package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
- package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
- package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
- package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
- package/carsi/paper_search_carsi/browser/__init__.py +8 -1
- package/carsi/paper_search_carsi/browser/backend.py +23 -147
- package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
- package/carsi/paper_search_carsi/browser/challenges.py +57 -0
- package/carsi/paper_search_carsi/browser/engine.py +2 -2
- package/carsi/paper_search_carsi/browser/preview.py +44 -26
- package/carsi/paper_search_carsi/browser/profiles.py +161 -0
- package/carsi/paper_search_carsi/browser/watchdog.py +71 -14
- package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
- package/carsi/paper_search_carsi/engine/batch.py +129 -68
- package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
- package/carsi/paper_search_carsi/engine/batch_capture.py +53 -8
- package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
- package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
- package/carsi/paper_search_carsi/engine/batch_login.py +47 -27
- package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
- package/carsi/paper_search_carsi/engine/batch_waits.py +82 -42
- package/carsi/paper_search_carsi/engine/bridge.py +16 -8
- package/carsi/paper_search_carsi/engine/supplements.py +22 -3
- package/carsi/paper_search_carsi/log.py +13 -7
- package/carsi/paper_search_carsi/orchestrator.py +7 -1
- package/carsi/paper_search_carsi/progress_bar.py +39 -18
- package/carsi/paper_search_carsi/server.py +8 -1
- package/carsi/pyproject.toml +1 -1
- package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
- package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
- package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/handler.js +6 -0
- package/dist/capabilities/metadata-search/handler.js.map +1 -1
- package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
- package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/schemas.js +1 -0
- package/dist/capabilities/metadata-search/schemas.js.map +1 -1
- package/dist/capabilities/metadata-search/tools.js +1 -1
- package/dist/capabilities/metadata-search/tools.js.map +1 -1
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
- package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
- package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
- package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
- package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/batch.js +56 -16
- package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.js +3 -2
- package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
- package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
- package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
- package/dist/core/handleToolCall.d.ts.map +1 -1
- package/dist/core/handleToolCall.js +12 -0
- package/dist/core/handleToolCall.js.map +1 -1
- package/dist/core/schemas.d.ts +26 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +13 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/searchers.d.ts +2 -0
- package/dist/core/searchers.d.ts.map +1 -1
- package/dist/core/searchers.js.map +1 -1
- package/dist/core/tools.d.ts.map +1 -1
- package/dist/core/tools.js +30 -0
- package/dist/core/tools.js.map +1 -1
- package/dist/infrastructure/arxiv.d.ts +5 -0
- package/dist/infrastructure/arxiv.d.ts.map +1 -0
- package/dist/infrastructure/arxiv.js +36 -0
- package/dist/infrastructure/arxiv.js.map +1 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
- package/dist/infrastructure/pdf/PdfDownload.js +14 -0
- package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.js +17 -1
- package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
- package/dist/management/carsi/installer.d.ts +1 -1
- package/dist/platforms/ArxivSearcher.d.ts +62 -0
- package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
- package/dist/platforms/ArxivSearcher.js +544 -0
- package/dist/platforms/ArxivSearcher.js.map +1 -0
- package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
- package/dist/platforms/SemanticScholarSearcher.js +4 -4
- package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
- package/dist/registry/platformFactories.d.ts.map +1 -1
- package/dist/registry/platformFactories.js +2 -0
- package/dist/registry/platformFactories.js.map +1 -1
- package/dist/registry/platformMetadata.d.ts +1 -1
- package/dist/registry/platformMetadata.d.ts.map +1 -1
- package/dist/registry/platformMetadata.js +16 -0
- package/dist/registry/platformMetadata.js.map +1 -1
- package/package.json +2 -2
- package/skills/paper-search/SKILL.md +7 -4
- package/skills/paper-search/references/browser-download.md +7 -6
- package/skills/paper-search/references/capability-routing.md +4 -3
- package/skills/paper-search/references/cli-contract.md +10 -3
- package/skills/paper-search/references/publisher-support.md +6 -4
- package/skills/paper-search/references/troubleshooting.md +1 -1
- package/README.zh.md +0 -214
|
@@ -44,7 +44,8 @@ from .router import (
|
|
|
44
44
|
from ..publishers.profiles import ACS_PROFILE, PublisherProfile
|
|
45
45
|
from ..entitlements import UserMarkedNoEntitlement # noqa: E402 (re-exported)
|
|
46
46
|
from ..adapters import PublisherAdapter, adapter_class # noqa: E402
|
|
47
|
-
from .batch_common import (
|
|
47
|
+
from .batch_common import (
|
|
48
|
+
page_operation, # re-exported: other modules import these from batch
|
|
48
49
|
open_page,
|
|
49
50
|
settle,
|
|
50
51
|
_is_network_failure,
|
|
@@ -86,6 +87,7 @@ from .batch_pdf_urls import PdfUrlsMixin
|
|
|
86
87
|
from .batch_runs import RunsMixin
|
|
87
88
|
from .batch_diagnostics import DiagnosticsMixin
|
|
88
89
|
from .batch_user import PdfFoundWhileWaiting, UserProvidedPdf, UserRequestsMixin
|
|
90
|
+
from ..browser import watchdog
|
|
89
91
|
from ..entitlements import UserSkippedArticle, UserStoppedBatch
|
|
90
92
|
|
|
91
93
|
|
|
@@ -147,7 +149,7 @@ class PublisherBatchDownloader(
|
|
|
147
149
|
tab.close()
|
|
148
150
|
|
|
149
151
|
def collect_supplements(self, context: Any, doi: str, output_dir: Path,
|
|
150
|
-
result: "DownloadResult") -> list[dict[str, Any]]:
|
|
152
|
+
result: "DownloadResult", *, manifest_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
151
153
|
"""Open the article page in a new tab of ``context`` (already logged in)
|
|
152
154
|
and download every supplementary file the adapter lists there."""
|
|
153
155
|
from . import supplements
|
|
@@ -163,36 +165,56 @@ class PublisherBatchDownloader(
|
|
|
163
165
|
# Digest 2026-11-02): no supplements, and the article keeps its result.
|
|
164
166
|
self._event(result, "supplements_skipped", f"browser closed: {type(exc).__name__}")
|
|
165
167
|
return [{"status": "skipped", "reason": "browser_closed", "url": "", "label": ""}]
|
|
168
|
+
user_watch = self._start_user_watch(page, result)
|
|
166
169
|
try:
|
|
167
|
-
self.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if
|
|
179
|
-
self.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
self.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
170
|
+
with watchdog.operation(self.page_wait_sec, "supplement_discovery"):
|
|
171
|
+
self._track_article_for_adapter(page, doi)
|
|
172
|
+
links: list = []
|
|
173
|
+
# The journal's own article page the main text came from (cell.com,
|
|
174
|
+
# journal-of-hepatology.eu) lists every file it has: no detour to the
|
|
175
|
+
# platform, also when it lists none.
|
|
176
|
+
final_url = str(getattr(result, "final_url", "") or "")
|
|
177
|
+
own_page = [final_url] if final_url and self.adapter.is_article_page_url(final_url) else []
|
|
178
|
+
candidates = self.adapter.supplements_page_urls(doi) or [self._article_url(doi)]
|
|
179
|
+
for url in dict.fromkeys(own_page + candidates):
|
|
180
|
+
open_page(page, url, timeout_ms=60_000)
|
|
181
|
+
if self._challenge_shell(page) or self._is_challenge_page(page):
|
|
182
|
+
if not self._wait_for_challenge(page, result):
|
|
183
|
+
self._event(result, "supplements_failed", "verification not completed")
|
|
184
|
+
return [{"status": "failed", "reason": "challenge_timeout", "url": url, "label": ""}]
|
|
185
|
+
settle(page, 2_000)
|
|
186
|
+
self._wait_for_page_text(page, result)
|
|
187
|
+
links = self._settled_supplement_links(page, doi)
|
|
188
|
+
if links is None:
|
|
189
|
+
self._event(result, "supplements_not_supported", self.profile.name)
|
|
190
|
+
return []
|
|
191
|
+
if links or url in own_page:
|
|
192
|
+
break
|
|
193
|
+
self._event(result, "supplements_found", str(len(links)))
|
|
194
|
+
progress_reporter.update(supplements_total=len(links))
|
|
190
195
|
files = supplements.collect(page.context.request, links, Path(output_dir), doi,
|
|
196
|
+
manifest_dir=manifest_dir,
|
|
197
|
+
target_dir=Path(output_dir) / "supplements" if self.config.get("diagnostics_dir") else None,
|
|
191
198
|
download=lambda url: self._download_in_new_tab(page, url),
|
|
192
199
|
progress=lambda saved: progress_reporter.update(supplements_saved=saved))
|
|
193
200
|
saved = sum(1 for f in files if f.get("status") == "saved")
|
|
194
201
|
self._event(result, "supplements_saved", f"{saved}/{len(files)}")
|
|
195
202
|
return files
|
|
203
|
+
except PublisherContentError as exc:
|
|
204
|
+
self._event(result, "supplements_failed", str(exc))
|
|
205
|
+
return [{"status": "failed", "reason": "publisher_content_error", "url": "", "label": ""}]
|
|
206
|
+
except watchdog.BrowserUnresponsive as exc:
|
|
207
|
+
reason = ("browser_operation_timeout" if isinstance(exc, watchdog.BrowserOperationTimeout)
|
|
208
|
+
else "browser_unresponsive")
|
|
209
|
+
self._event(result, "supplements_failed", reason)
|
|
210
|
+
return [{"status": "failed", "reason": reason, "url": "", "label": ""}]
|
|
211
|
+
except UserMarkedNoEntitlement as exc:
|
|
212
|
+
reason = ("batch_stopped" if isinstance(exc, UserStoppedBatch) else
|
|
213
|
+
"user_skipped" if isinstance(exc, UserSkippedArticle) else "user_marked_no_entitlement")
|
|
214
|
+
self._event(result, "supplements_skipped", reason)
|
|
215
|
+
if not result.ok:
|
|
216
|
+
result.reason = result.state = reason
|
|
217
|
+
return [{"status": "skipped", "reason": reason, "url": "", "label": ""}]
|
|
196
218
|
except PublisherAccessBlocked:
|
|
197
219
|
# The publisher paused access (BJSM, 2026-11-01): the article ends as
|
|
198
220
|
# blocked when its main text failed, so the batch stops as for any block.
|
|
@@ -206,6 +228,8 @@ class PublisherBatchDownloader(
|
|
|
206
228
|
self._event(result, "supplements_failed", f"{type(exc).__name__}: {str(exc)[:160]}")
|
|
207
229
|
return [{"status": "failed", "reason": type(exc).__name__, "url": "", "label": ""}]
|
|
208
230
|
finally:
|
|
231
|
+
user_watch.set()
|
|
232
|
+
watchdog.clear_interrupt()
|
|
209
233
|
self._close_phase(result)
|
|
210
234
|
try:
|
|
211
235
|
page.close()
|
|
@@ -217,7 +241,13 @@ class PublisherBatchDownloader(
|
|
|
217
241
|
moves /pii/ to /abs/pii/ by script after the document has loaded)."""
|
|
218
242
|
for attempt in range(3):
|
|
219
243
|
try:
|
|
220
|
-
|
|
244
|
+
from ..adapters.platforms.figshare import supplement_links as external_links
|
|
245
|
+
|
|
246
|
+
own = self.adapter.supplement_links(page, doi)
|
|
247
|
+
external = external_links(page, doi)
|
|
248
|
+
if own is None and external is None:
|
|
249
|
+
return None
|
|
250
|
+
return list(dict.fromkeys([*(own or []), *(external or [])]))
|
|
221
251
|
except Exception as exc:
|
|
222
252
|
if (attempt == 2 or not _is_browser_error(exc)
|
|
223
253
|
or "Execution context was destroyed" not in str(exc)):
|
|
@@ -307,6 +337,8 @@ class PublisherBatchDownloader(
|
|
|
307
337
|
config["browser_headless"] = False
|
|
308
338
|
started = time.time()
|
|
309
339
|
ctx = get_persistent_context(profile_path, config)
|
|
340
|
+
from ..browser.challenge_interaction import prepare_context
|
|
341
|
+
prepare_context(ctx)
|
|
310
342
|
self._browser_start = {**dict(getattr(browser_backend, "LAST_LAUNCH", {}) or {}),
|
|
311
343
|
"seconds": round(time.time() - started, 1), **({"relaunch": relaunch} if relaunch else {})}
|
|
312
344
|
from ..browser.backend import browser_info
|
|
@@ -344,6 +376,7 @@ class PublisherBatchDownloader(
|
|
|
344
376
|
self._item_started = time.time()
|
|
345
377
|
self._item_result = result
|
|
346
378
|
self._record_title = record.title
|
|
379
|
+
self._expected_title = ""
|
|
347
380
|
self._manual_school = False
|
|
348
381
|
self._track_article_for_adapter(page, record.doi)
|
|
349
382
|
from ..browser import watchdog
|
|
@@ -360,46 +393,17 @@ class PublisherBatchDownloader(
|
|
|
360
393
|
result.browser_start = dict(start)
|
|
361
394
|
self._event(result, "browser_start", self._browser_start_line(start))
|
|
362
395
|
user_watch = self._start_user_watch(page, result)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
else:
|
|
369
|
-
return self._login_failed(page, result, run_dir)
|
|
396
|
+
# Reuse confirmed public access only for this document's initial PDF
|
|
397
|
+
# attempt. A redirect or reload (even at the same URL) invalidates it.
|
|
398
|
+
def invalidate_open_access(frame: Any) -> None:
|
|
399
|
+
if frame == page.main_frame:
|
|
400
|
+
result._open_access_current = False
|
|
370
401
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
# denies it, shows only its abstract or offers it: the school has no
|
|
377
|
-
# access, and a second login would only wait out the user's time
|
|
378
|
-
# (JAMA Oncology back from the store page, 2026-11-02).
|
|
379
|
-
evidence = self._no_access_after_login(page, result)
|
|
380
|
-
if evidence:
|
|
381
|
-
result.reason = result.state = "institution_pdf_entitlement_missing"
|
|
382
|
-
self._event(result, "no_access_after_login", evidence[:300])
|
|
383
|
-
self._snapshot(page, result, "not subscribed")
|
|
384
|
-
result.final_url = page.url
|
|
385
|
-
result.title = self._title(page)
|
|
386
|
-
self._write_diagnostic(page, result, run_dir)
|
|
387
|
-
return result
|
|
388
|
-
if not self._complete_login_from_current_page(page, result):
|
|
389
|
-
if self._article_access_available(page):
|
|
390
|
-
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
391
|
-
else:
|
|
392
|
-
return self._login_failed(page, result, run_dir)
|
|
393
|
-
settle(page, 2_000)
|
|
394
|
-
if not self._pdf_first_unless_signed_out(page, result):
|
|
395
|
-
return self._login_failed(page, result, run_dir)
|
|
396
|
-
|
|
397
|
-
self._return_from_other_article(page, result)
|
|
398
|
-
result.final_url = page.url
|
|
399
|
-
result.title = self._title(page)
|
|
400
|
-
result.state = "article_loaded"
|
|
401
|
-
self._event(result, "article_access_ready")
|
|
402
|
-
self._hold_after_login(page, result)
|
|
402
|
+
page.on("framenavigated", invalidate_open_access)
|
|
403
|
+
try:
|
|
404
|
+
preparation = self._prepare_article(page, result, run_dir)
|
|
405
|
+
if preparation is not None:
|
|
406
|
+
return preparation
|
|
403
407
|
ip_block: str | None = None
|
|
404
408
|
# Whether this capture comes after the school's login, and its first attempt.
|
|
405
409
|
signed_in, attempts_from = self._school_login_done(), len(result.pdf_attempts)
|
|
@@ -552,6 +556,12 @@ class PublisherBatchDownloader(
|
|
|
552
556
|
progress_reporter.update(phase="出版社内容服务错误,当前路径已停止")
|
|
553
557
|
self._write_diagnostic(page, result, run_dir)
|
|
554
558
|
return result
|
|
559
|
+
except watchdog.BrowserUnresponsive as exc:
|
|
560
|
+
result.reason = result.state = ("browser_operation_timeout" if isinstance(exc, watchdog.BrowserOperationTimeout)
|
|
561
|
+
else "browser_unresponsive")
|
|
562
|
+
self._event(result, result.state, str(exc))
|
|
563
|
+
self._write_diagnostic(page, result, run_dir)
|
|
564
|
+
return result
|
|
555
565
|
except Exception as exc:
|
|
556
566
|
import traceback
|
|
557
567
|
|
|
@@ -573,22 +583,72 @@ class PublisherBatchDownloader(
|
|
|
573
583
|
self._write_diagnostic(page, result, run_dir)
|
|
574
584
|
return result
|
|
575
585
|
finally:
|
|
586
|
+
page.remove_listener("framenavigated", invalidate_open_access)
|
|
576
587
|
user_watch.set()
|
|
577
588
|
watchdog.on_slow_call = None
|
|
578
589
|
if isinstance(watchdog.interrupted, UserMarkedNoEntitlement) and result.ok:
|
|
579
590
|
# The click came while the PDF was being saved.
|
|
580
591
|
self._tell_user("点击时这一篇已经保存好了,没有跳过", result)
|
|
581
592
|
watchdog.clear_interrupt()
|
|
582
|
-
|
|
593
|
+
stopped = bool(watchdog.fired) or result.state in {
|
|
594
|
+
"browser_unresponsive", "browser_operation_timeout", "user_skipped", "batch_stopped", "user_marked_no_entitlement"}
|
|
595
|
+
if not stopped:
|
|
596
|
+
self._capture_reported_issues(page, result, act=False)
|
|
583
597
|
if not result.ok:
|
|
584
598
|
progress_reporter.report_stage("route_failed", current=record.doi)
|
|
585
|
-
|
|
599
|
+
if not stopped:
|
|
600
|
+
self._hold_after_run(page, result)
|
|
586
601
|
if _owns_page:
|
|
587
602
|
try:
|
|
588
603
|
page.close()
|
|
589
604
|
except Exception:
|
|
590
605
|
pass
|
|
591
606
|
|
|
607
|
+
@page_operation
|
|
608
|
+
def _prepare_article(self, page: Any, result: DownloadResult, run_dir: Path) -> DownloadResult | None:
|
|
609
|
+
"""Reach the first PDF attempt within one shared page budget."""
|
|
610
|
+
self._event(result, "article_open", result.article_url)
|
|
611
|
+
if not self._ensure_login(page, result):
|
|
612
|
+
if self._article_access_available(page):
|
|
613
|
+
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
614
|
+
else:
|
|
615
|
+
return self._login_failed(page, result, run_dir)
|
|
616
|
+
|
|
617
|
+
self._return_from_other_article(page, result)
|
|
618
|
+
settle(page, 2_000)
|
|
619
|
+
if not (getattr(result, "_open_access_current", False)
|
|
620
|
+
or self._pdf_first_unless_signed_out(page, result)):
|
|
621
|
+
self._event(result, "auth_wall_after_article_load", self._decision_text(page))
|
|
622
|
+
# This item has been through the school's login and the article still
|
|
623
|
+
# denies it, shows only its abstract or offers it: the school has no
|
|
624
|
+
# access, and a second login would only wait out the user's time
|
|
625
|
+
# (JAMA Oncology back from the store page, 2026-11-02).
|
|
626
|
+
evidence = self._no_access_after_login(page, result)
|
|
627
|
+
if evidence:
|
|
628
|
+
result.reason = result.state = "institution_pdf_entitlement_missing"
|
|
629
|
+
self._event(result, "no_access_after_login", evidence[:300])
|
|
630
|
+
self._snapshot(page, result, "not subscribed")
|
|
631
|
+
result.final_url = page.url
|
|
632
|
+
result.title = self._title(page)
|
|
633
|
+
self._write_diagnostic(page, result, run_dir)
|
|
634
|
+
return result
|
|
635
|
+
if not self._complete_login_from_current_page(page, result):
|
|
636
|
+
if self._article_access_available(page):
|
|
637
|
+
self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
|
|
638
|
+
else:
|
|
639
|
+
return self._login_failed(page, result, run_dir)
|
|
640
|
+
settle(page, 2_000)
|
|
641
|
+
if not self._pdf_first_unless_signed_out(page, result):
|
|
642
|
+
return self._login_failed(page, result, run_dir)
|
|
643
|
+
|
|
644
|
+
self._return_from_other_article(page, result)
|
|
645
|
+
result.final_url = page.url
|
|
646
|
+
result.title = self._title(page)
|
|
647
|
+
result.state = "article_loaded"
|
|
648
|
+
self._event(result, "article_access_ready")
|
|
649
|
+
self._hold_after_login(page, result)
|
|
650
|
+
return None
|
|
651
|
+
|
|
592
652
|
@staticmethod
|
|
593
653
|
def _browser_start_line(start: dict) -> str:
|
|
594
654
|
"""``browser_start``'s detail: "profile free; launch 3.1 s", or the holder,
|
|
@@ -668,7 +728,8 @@ class PublisherBatchDownloader(
|
|
|
668
728
|
result.pdf_path = str(pdf_path)
|
|
669
729
|
result.size_bytes = len(pdf_bytes)
|
|
670
730
|
result.text_length = len(text or "")
|
|
671
|
-
result.verified_match = self._text_matches_record(
|
|
731
|
+
result.verified_match = self._text_matches_record(
|
|
732
|
+
text, record, fallback_title=self._identity_title() or result.title)
|
|
672
733
|
self._save_publisher_session(page, result)
|
|
673
734
|
if not any(e["state"] == "pdf_saved_unconfirmed" for e in result.events):
|
|
674
735
|
from ..browser.preview import discard_frames
|
|
@@ -267,19 +267,24 @@ class AccessMixin:
|
|
|
267
267
|
return result is not None and (getattr(result, "_visited_idp", False) or getattr(result, "_login_round_trip", False)
|
|
268
268
|
or any(e.get("state") in self._SCHOOL_CHOSEN for e in result.events))
|
|
269
269
|
|
|
270
|
-
def _institution_pdf_entitlement_missing(self, page: Any) -> bool:
|
|
270
|
+
def _institution_pdf_entitlement_missing(self, page: Any, snapshot: SimpleNamespace | None = None) -> bool:
|
|
271
271
|
"""The article page denies access (access_rules.denial_evidence) to a
|
|
272
272
|
reader who is the institution: its session shows, this item has been
|
|
273
273
|
through the school's login, or the denial names the configured school."""
|
|
274
274
|
url = str(getattr(page, "url", "") or "")
|
|
275
275
|
if not self._is_success_article_url(url) or self._is_auth_page(url):
|
|
276
276
|
return False
|
|
277
|
-
if self._is_challenge_page(page):
|
|
277
|
+
if snapshot is None and self._is_challenge_page(page):
|
|
278
278
|
return False
|
|
279
|
-
snap = self._access_snapshot(page)
|
|
279
|
+
snap = snapshot or self._access_snapshot(page)
|
|
280
280
|
evidence, named = access_rules.denial_evidence(url, snap.lines, snap.probe)
|
|
281
281
|
if not evidence:
|
|
282
282
|
return False
|
|
283
|
+
# A supplied snapshot already rules out a denial without live reads.
|
|
284
|
+
# Only a possible denial needs the verification guard; re-reading a
|
|
285
|
+
# slow article here otherwise delays even confirmed public access.
|
|
286
|
+
if snapshot is not None and self._is_challenge_page(page):
|
|
287
|
+
return False
|
|
283
288
|
if self._school_login_done() or self._institution_session(page, snap) == "ours":
|
|
284
289
|
return True
|
|
285
290
|
return bool(named) and self._names_configured_school(named)
|
|
@@ -4,6 +4,8 @@ Part of ``PublisherBatchDownloader`` (batch.py), which combines the mixins."""
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from ..browser import watchdog
|
|
8
|
+
|
|
7
9
|
import base64
|
|
8
10
|
import inspect
|
|
9
11
|
import json
|
|
@@ -18,6 +20,7 @@ import requests
|
|
|
18
20
|
|
|
19
21
|
from . import access_rules
|
|
20
22
|
from .batch_common import (
|
|
23
|
+
page_operation,
|
|
21
24
|
POLL_MS,
|
|
22
25
|
pump_until,
|
|
23
26
|
settle,
|
|
@@ -194,6 +197,7 @@ class CaptureMixin:
|
|
|
194
197
|
self._note_wait(result, "a PDF probe", probe_started, "done")
|
|
195
198
|
self._capture_doi, self._expected_title, self._fallback_pdf = saved
|
|
196
199
|
|
|
200
|
+
@page_operation
|
|
197
201
|
def _capture_pdf(self, page: Any, doi: str, result: DownloadResult) -> tuple[bytes | None, str, str | None]:
|
|
198
202
|
"""Run the capture channels; a PDF without DOI/title evidence is used
|
|
199
203
|
only when nothing better arrived, and is marked unconfirmed."""
|
|
@@ -203,7 +207,10 @@ class CaptureMixin:
|
|
|
203
207
|
self._fallback_pdf = None
|
|
204
208
|
tried_before = len(result.pdf_attempts)
|
|
205
209
|
try:
|
|
206
|
-
|
|
210
|
+
from ..browser.preview import pause as pause_preview
|
|
211
|
+
|
|
212
|
+
with pause_preview(getattr(page, "context", None)):
|
|
213
|
+
body, url, block = self._capture_pdf_channels(page, doi, result)
|
|
207
214
|
if body is None and not block and self._fallback_pdf is not None:
|
|
208
215
|
body, url = self._fallback_pdf
|
|
209
216
|
self._event(result, "pdf_saved_unconfirmed", f"{url} | no DOI or title on the first pages")
|
|
@@ -578,6 +585,22 @@ class CaptureMixin:
|
|
|
578
585
|
for pattern in native_pdf_patterns
|
|
579
586
|
]})
|
|
580
587
|
self._return_to_record_article_if_needed(page, result, doi)
|
|
588
|
+
# A login can return directly to a PDF before these listeners were
|
|
589
|
+
# installed. PDF.js has no article button or metadata to discover.
|
|
590
|
+
current_pdf_url = str(getattr(page, "url", "") or "")
|
|
591
|
+
if (self._is_pdf_candidate_url(current_pdf_url) and not self._is_supplementary_url(current_pdf_url)
|
|
592
|
+
and self._native_viewer_pdf_allowed(current_pdf_url, **fallback_options)):
|
|
593
|
+
if entitlement_denied():
|
|
594
|
+
return None, "", None
|
|
595
|
+
if not self._is_chromium(page):
|
|
596
|
+
begin("url", current_pdf_url, time.time())
|
|
597
|
+
body, final_url = self._capture_native_viewer_pdf(page, current_pdf_url, result, **fallback_options)
|
|
598
|
+
if body:
|
|
599
|
+
captured["bytes"], captured["url"] = body, final_url
|
|
600
|
+
return body, final_url, None
|
|
601
|
+
finish()
|
|
602
|
+
else:
|
|
603
|
+
current_pdf_url = ""
|
|
581
604
|
# The article page's own PDF links, read before the click: the click can
|
|
582
605
|
# end on a reader page that links no PDF (ASM's "PDF/EPUB", while the
|
|
583
606
|
# article page shows "Download PDF", 2026-11-02).
|
|
@@ -591,6 +614,8 @@ class CaptureMixin:
|
|
|
591
614
|
pump_until(page, lambda: bool(captured["bytes"] or captured["block_reason"]) or (
|
|
592
615
|
not self._is_chromium(page) and self._pdf_viewer_present(page)
|
|
593
616
|
and self._native_viewer_pdf_allowed(str(getattr(page, "url", "") or ""), **fallback_options)), 5_000)
|
|
617
|
+
if captured["bytes"] or captured["block_reason"]:
|
|
618
|
+
return captured["bytes"], str(captured["url"]), captured["block_reason"]
|
|
594
619
|
self._wait_past_redirect_page(page, result) # e.g. a sign-in round trip first
|
|
595
620
|
if not self._resolve_suspect_block(page, result, captured):
|
|
596
621
|
return None, "", captured["block_reason"]
|
|
@@ -621,8 +646,13 @@ class CaptureMixin:
|
|
|
621
646
|
captured["url"] = final_url
|
|
622
647
|
if block_reason and not captured["block_reason"]:
|
|
623
648
|
captured["block_reason"] = block_reason
|
|
624
|
-
|
|
625
|
-
|
|
649
|
+
# A validated PDF is already in hand. Reading the viewer DOM for
|
|
650
|
+
# more links can block (Royal Society, 2026-11-04) and turn this
|
|
651
|
+
# successful capture into a page-budget timeout.
|
|
652
|
+
if captured["bytes"] or captured["block_reason"]:
|
|
653
|
+
return captured["bytes"], str(captured["url"]), captured["block_reason"]
|
|
654
|
+
candidates = [current_pdf_url, *(list(bound_pdf_urls) if bound_pdf_urls is not None
|
|
655
|
+
else [*metadata_pdf_urls, *self._pdf_candidates(page, doi), *before_click])]
|
|
626
656
|
# In-page anchors (#pane-pcw-references, #addtoany …) are the same
|
|
627
657
|
# document; try each URL once.
|
|
628
658
|
candidates = list(dict.fromkeys(str(url).split("#", 1)[0] for url in candidates if url))
|
|
@@ -711,14 +741,21 @@ class CaptureMixin:
|
|
|
711
741
|
# Chrome's PDF having been kept out of it (a check at the PDF
|
|
712
742
|
# address, 2026-11-02).
|
|
713
743
|
break
|
|
744
|
+
response_before_wait = captured.get("nav")
|
|
714
745
|
if not self._wait_for_challenge(page, result):
|
|
715
746
|
return None, str(getattr(page, "url", "") or ""), captured["block_reason"]
|
|
716
747
|
if entitlement_denied():
|
|
717
748
|
return None, "", None
|
|
718
749
|
pump_until(page, lambda: bool(captured["bytes"]), 3_000)
|
|
719
750
|
if not captured["bytes"]:
|
|
751
|
+
# A new PDF during verification can leave the tab on its
|
|
752
|
+
# completion page. Only that new response warrants retrying
|
|
753
|
+
# the candidate through the existing validated fallback.
|
|
754
|
+
response_after_wait = captured.get("nav") or {}
|
|
755
|
+
retry = ([pdf_url] if response_after_wait is not response_before_wait
|
|
756
|
+
and str(response_after_wait.get("type", "")).lower() == "application/pdf" else [])
|
|
720
757
|
body, final_url, block_reason = self._fetch_page_state_pdf(
|
|
721
|
-
page, extra_urls=[str(captured["deferred_url"])], **fallback_options)
|
|
758
|
+
page, extra_urls=[*retry, str(captured["deferred_url"])], **fallback_options)
|
|
722
759
|
if body:
|
|
723
760
|
captured["bytes"] = body
|
|
724
761
|
captured["url"] = final_url
|
|
@@ -845,6 +882,8 @@ class CaptureMixin:
|
|
|
845
882
|
self._event(result, "pdf_button_clicked", detail)
|
|
846
883
|
return True
|
|
847
884
|
except Exception as exc:
|
|
885
|
+
if not _is_browser_error(exc):
|
|
886
|
+
raise
|
|
848
887
|
self._event(result, "pdf_button_error", f"{type(exc).__name__}: {str(exc).splitlines()[0][:160] if str(exc) else ''}")
|
|
849
888
|
return False
|
|
850
889
|
|
|
@@ -898,12 +937,14 @@ class CaptureMixin:
|
|
|
898
937
|
href = ""
|
|
899
938
|
try:
|
|
900
939
|
text = control.inner_text(timeout=1_000)
|
|
901
|
-
except Exception:
|
|
902
|
-
|
|
940
|
+
except Exception as exc:
|
|
941
|
+
if not _is_browser_error(exc):
|
|
942
|
+
raise
|
|
903
943
|
try:
|
|
904
944
|
href = control.get_attribute("href", timeout=1_000) or ""
|
|
905
|
-
except Exception:
|
|
906
|
-
|
|
945
|
+
except Exception as exc:
|
|
946
|
+
if not _is_browser_error(exc):
|
|
947
|
+
raise
|
|
907
948
|
if (self._is_supplementary_url(href)
|
|
908
949
|
or re.search(r"\b(?:supplement(?:ary|al)?|supporting information|disclosure form)\b", text, re.I)):
|
|
909
950
|
continue
|
|
@@ -913,6 +954,8 @@ class CaptureMixin:
|
|
|
913
954
|
self._event(result, "pdf_button_clicked", json.dumps(detail, ensure_ascii=False))
|
|
914
955
|
return True
|
|
915
956
|
except Exception as exc:
|
|
957
|
+
if not _is_browser_error(exc):
|
|
958
|
+
raise
|
|
916
959
|
# Kept, not dropped: three such 10 s click timeouts made Sage's silent
|
|
917
960
|
# 30 s before its PDF (a toolbar copy outside the viewport, 2026-11-02).
|
|
918
961
|
self._event(result, "pdf_button_error",
|
|
@@ -978,6 +1021,8 @@ class CaptureMixin:
|
|
|
978
1021
|
self._event(result, "pdf_button_clicked", json.dumps(clicked, ensure_ascii=False))
|
|
979
1022
|
return True
|
|
980
1023
|
except Exception as exc:
|
|
1024
|
+
if not _is_browser_error(exc):
|
|
1025
|
+
raise
|
|
981
1026
|
self._event(result, "pdf_button_error", f"{type(exc).__name__}: {exc}")
|
|
982
1027
|
return False
|
|
983
1028
|
|
|
@@ -8,12 +8,16 @@ import re
|
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from functools import wraps
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Callable
|
|
13
14
|
from urllib.parse import urlencode
|
|
14
15
|
|
|
15
16
|
import requests
|
|
16
17
|
|
|
18
|
+
from ..browser import watchdog
|
|
19
|
+
from ..browser.challenges import looks_like_challenge
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
EST_ISSN = "1520-5851"
|
|
19
23
|
MIN_PDF_BYTES = 5_000
|
|
@@ -23,6 +27,15 @@ PDF_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
|
|
|
23
27
|
logger = logging.getLogger(__package__ + ".batch") # the engine's one channel
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
def page_operation(method):
|
|
31
|
+
"""Bound an engine page operation by its existing page-wait setting."""
|
|
32
|
+
@wraps(method)
|
|
33
|
+
def bounded(self, *args, **kwargs):
|
|
34
|
+
with watchdog.operation(self.page_wait_sec, method.__name__):
|
|
35
|
+
return method(self, *args, **kwargs)
|
|
36
|
+
return bounded
|
|
37
|
+
|
|
38
|
+
|
|
26
39
|
def _resolve_profile_dir(config: dict[str, Any], explicit: str | Path | None = None) -> Path:
|
|
27
40
|
"""Fixed browser profile dir; explicit/config wins, default is data-dir scoped.
|
|
28
41
|
|
|
@@ -121,6 +134,19 @@ def _is_browser_error(exc: BaseException, name: str = "") -> bool:
|
|
|
121
134
|
and (not name or type(exc).__name__ == name))
|
|
122
135
|
|
|
123
136
|
|
|
137
|
+
def page_title(page: Any) -> str:
|
|
138
|
+
"""Read optional title text without consuming the whole page budget."""
|
|
139
|
+
try:
|
|
140
|
+
# Page.title has no timeout. A busy renderer held it for 30 s
|
|
141
|
+
# after access was ready, before the first PDF attempt (2026-11-04).
|
|
142
|
+
value = page.locator("head > title").first.text_content(timeout=1_000)
|
|
143
|
+
return re.sub(r"\s+", " ", str(value or "")).strip()
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
if not _is_browser_error(exc):
|
|
146
|
+
raise
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
124
150
|
# A browser call cut off by the page navigating: its document replaced, its frame gone.
|
|
125
151
|
_NAVIGATION_CUT = ("Execution context was destroyed", "Cannot find context with specified id",
|
|
126
152
|
"Frame was detached", "because of a navigation")
|
|
@@ -181,19 +207,6 @@ def pump_until(page: Any, done: Callable[[], bool], timeout_ms: int, step_ms: in
|
|
|
181
207
|
return bool(done())
|
|
182
208
|
|
|
183
209
|
|
|
184
|
-
# A Cloudflare interstitial, recognised from its URL and title alone: scripts
|
|
185
|
-
# reading the page (innerText, DOM queries) can make the check fail and come
|
|
186
|
-
# back in a loop; an untouched page passes. "Attention Required! | Cloudflare" is
|
|
187
|
-
# Cloudflare's block page, not a check.
|
|
188
|
-
CHALLENGE_TITLES = ("just a moment", "请稍候")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def looks_like_challenge(url: str, title: str) -> bool:
|
|
192
|
-
url = str(url or "").lower()
|
|
193
|
-
return ("__cf_chl" in url or "/cdn-cgi/challenge-platform/" in url
|
|
194
|
-
or str(title or "").strip().lower().startswith(CHALLENGE_TITLES))
|
|
195
|
-
|
|
196
|
-
|
|
197
210
|
# The page's document can be read: parsed (DOMContentLoaded), or its body text has
|
|
198
211
|
# stopped growing. pubs.acs.org showed its article and citation metadata 4.5 s
|
|
199
212
|
# after the request and held DOMContentLoaded back until 33 s on an ad script
|
|
@@ -213,7 +226,7 @@ def open_page(page: Any, url: str, *, timeout_ms: int = 60_000, ready_ms: int =
|
|
|
213
226
|
|
|
214
227
|
def readable() -> bool:
|
|
215
228
|
try:
|
|
216
|
-
if looks_like_challenge(str(getattr(page, "url", "") or ""),
|
|
229
|
+
if looks_like_challenge(str(getattr(page, "url", "") or ""), page_title(page)):
|
|
217
230
|
return True
|
|
218
231
|
state = page.evaluate(_DOCUMENT_STATE_JS)
|
|
219
232
|
except Exception as exc:
|
|
@@ -17,7 +17,7 @@ from ..extractors import pdf_extractor
|
|
|
17
17
|
from ..download_diagnostics import serialize_download_result
|
|
18
18
|
from .. import progress_reporter
|
|
19
19
|
from . import access_rules
|
|
20
|
-
from .batch_common import DownloadResult, PaperRecord, safe_name
|
|
20
|
+
from .batch_common import DownloadResult, PaperRecord, safe_name, _is_browser_error, page_title
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class DiagnosticsMixin:
|
|
@@ -41,12 +41,7 @@ class DiagnosticsMixin:
|
|
|
41
41
|
finally:
|
|
42
42
|
self._handling_reports = False
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
def _title(page: Any) -> str:
|
|
46
|
-
try:
|
|
47
|
-
return str(page.title() or "")
|
|
48
|
-
except Exception:
|
|
49
|
-
return ""
|
|
44
|
+
_title = staticmethod(page_title)
|
|
50
45
|
|
|
51
46
|
@staticmethod
|
|
52
47
|
def _body_text(page: Any, limit: int = 2_000) -> str:
|
|
@@ -54,7 +49,9 @@ class DiagnosticsMixin:
|
|
|
54
49
|
observe_page(page)
|
|
55
50
|
try:
|
|
56
51
|
text = page.locator("body").inner_text(timeout=3_000)
|
|
57
|
-
except Exception:
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
if not _is_browser_error(exc):
|
|
54
|
+
raise
|
|
58
55
|
return ""
|
|
59
56
|
return re.sub(r"\s+", " ", text).strip()[:limit]
|
|
60
57
|
|
|
@@ -92,7 +89,7 @@ class DiagnosticsMixin:
|
|
|
92
89
|
"login_navigation_interrupted": ("登录跳转中断,检查是否可重试", "waiting_login"),
|
|
93
90
|
"login_stopped": ("机构登录未完成", "route_failed"),
|
|
94
91
|
"challenge_manual_wait": ("等待人工安全验证", "waiting_verification"),
|
|
95
|
-
"challenge_wait": ("
|
|
92
|
+
"challenge_wait": ("正在检查安全验证", "checking_access"),
|
|
96
93
|
"challenge_resolved": ("安全验证已结束,继续检查页面", "checking_access"),
|
|
97
94
|
"article_access_ready": ("论文页面已就绪,检查正文入口", "checking_access"),
|
|
98
95
|
"publisher_content_error": ("出版社返回内容服务错误", "route_failed"),
|
|
@@ -101,11 +98,13 @@ class DiagnosticsMixin:
|
|
|
101
98
|
"browser_pdf_response_captured": ("PDF 已捕获,正在保存", "pdf_captured"),
|
|
102
99
|
"pdf_saved": ("PDF 已保存,待内容核验", "saved_pending_verification"),
|
|
103
100
|
}.get(state)
|
|
101
|
+
if state == "challenge_wait" and detail.startswith("manual "):
|
|
102
|
+
report = ("等待人工安全验证", "waiting_verification")
|
|
104
103
|
if state == "login_attempt_started" and detail in {"1/3", "2/3", "3/3"}:
|
|
105
104
|
report = (f"机构登录:第 {detail[0]} 次尝试,共 3 次", "waiting_login")
|
|
106
105
|
if state == "login_observed":
|
|
107
106
|
if detail == "verification":
|
|
108
|
-
report = ("
|
|
107
|
+
report = ("正在检查安全验证", "checking_access")
|
|
109
108
|
elif detail == "human_login":
|
|
110
109
|
report = ("等待在浏览器完成机构登录", "waiting_login")
|
|
111
110
|
else:
|
|
@@ -189,13 +188,19 @@ class DiagnosticsMixin:
|
|
|
189
188
|
"final_url": getattr(page, "url", result.final_url)}, diagnostic=True)
|
|
190
189
|
packet["adapter"] = type(self.adapter).__name__
|
|
191
190
|
packet["browser"] = getattr(self, "browser", {})
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
from ..browser import watchdog
|
|
192
|
+
stopped = bool(watchdog.fired) or result.state in {
|
|
193
|
+
"browser_unresponsive", "browser_operation_timeout", "user_skipped", "batch_stopped", "user_marked_no_entitlement"}
|
|
194
|
+
if stopped:
|
|
195
|
+
packet["access_state"], packet["access_evidence"] = "unknown", ["probe skipped: item stopped"]
|
|
196
|
+
else:
|
|
197
|
+
try:
|
|
198
|
+
packet["access_state"], packet["access_evidence"] = self._access_state(page)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
packet["access_state"], packet["access_evidence"] = "unknown", [f"probe failed: {type(exc).__name__}"]
|
|
196
201
|
packet_path = diag_dir / "diagnostic.json"
|
|
197
202
|
packet_path.write_text(json.dumps(packet, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
198
|
-
if self.adapter.diagnostic_screenshots and result.reason != "publisher_access_blocked":
|
|
203
|
+
if not stopped and self.adapter.diagnostic_screenshots and result.reason != "publisher_access_blocked":
|
|
199
204
|
try:
|
|
200
205
|
page.screenshot(path=str(diag_dir / "screenshot.png"), full_page=True)
|
|
201
206
|
except Exception:
|