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.
Files changed (151) hide show
  1. package/README.en.md +292 -0
  2. package/README.md +203 -125
  3. package/carsi/README.md +8 -2
  4. package/carsi/paper_search_carsi/__init__.py +1 -4
  5. package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
  6. package/carsi/paper_search_carsi/adapters/base.py +2 -0
  7. package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
  8. package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
  9. package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
  10. package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
  11. package/carsi/paper_search_carsi/browser/__init__.py +8 -1
  12. package/carsi/paper_search_carsi/browser/backend.py +23 -147
  13. package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
  14. package/carsi/paper_search_carsi/browser/challenges.py +57 -0
  15. package/carsi/paper_search_carsi/browser/engine.py +2 -2
  16. package/carsi/paper_search_carsi/browser/preview.py +44 -26
  17. package/carsi/paper_search_carsi/browser/profiles.py +161 -0
  18. package/carsi/paper_search_carsi/browser/watchdog.py +71 -14
  19. package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
  20. package/carsi/paper_search_carsi/engine/batch.py +129 -68
  21. package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
  22. package/carsi/paper_search_carsi/engine/batch_capture.py +53 -8
  23. package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
  24. package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
  25. package/carsi/paper_search_carsi/engine/batch_login.py +47 -27
  26. package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
  27. package/carsi/paper_search_carsi/engine/batch_waits.py +82 -42
  28. package/carsi/paper_search_carsi/engine/bridge.py +16 -8
  29. package/carsi/paper_search_carsi/engine/supplements.py +22 -3
  30. package/carsi/paper_search_carsi/log.py +13 -7
  31. package/carsi/paper_search_carsi/orchestrator.py +7 -1
  32. package/carsi/paper_search_carsi/progress_bar.py +39 -18
  33. package/carsi/paper_search_carsi/server.py +8 -1
  34. package/carsi/pyproject.toml +1 -1
  35. package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
  36. package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
  37. package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
  38. package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
  39. package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
  40. package/dist/capabilities/metadata-search/handler.js +6 -0
  41. package/dist/capabilities/metadata-search/handler.js.map +1 -1
  42. package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
  43. package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
  44. package/dist/capabilities/metadata-search/schemas.js +1 -0
  45. package/dist/capabilities/metadata-search/schemas.js.map +1 -1
  46. package/dist/capabilities/metadata-search/tools.js +1 -1
  47. package/dist/capabilities/metadata-search/tools.js.map +1 -1
  48. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
  49. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
  50. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
  51. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
  52. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
  53. package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
  54. package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
  55. package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
  56. package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
  57. package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
  58. package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
  59. package/dist/capabilities/pdf-discovery/batch.js +56 -16
  60. package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
  61. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
  62. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
  63. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
  64. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
  65. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
  66. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
  67. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
  68. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
  69. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
  70. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
  71. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
  72. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
  73. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
  74. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
  75. package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
  76. package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
  77. package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
  78. package/dist/capabilities/pdf-discovery/handler.js +3 -2
  79. package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
  80. package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
  81. package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
  82. package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
  83. package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
  84. package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
  85. package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
  86. package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
  87. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
  88. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
  89. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
  90. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
  91. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
  92. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
  93. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
  94. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
  95. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
  96. package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
  97. package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
  98. package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
  99. package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
  100. package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
  101. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
  102. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
  103. package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
  104. package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
  105. package/dist/core/handleToolCall.d.ts.map +1 -1
  106. package/dist/core/handleToolCall.js +12 -0
  107. package/dist/core/handleToolCall.js.map +1 -1
  108. package/dist/core/schemas.d.ts +26 -1
  109. package/dist/core/schemas.d.ts.map +1 -1
  110. package/dist/core/schemas.js +13 -0
  111. package/dist/core/schemas.js.map +1 -1
  112. package/dist/core/searchers.d.ts +2 -0
  113. package/dist/core/searchers.d.ts.map +1 -1
  114. package/dist/core/searchers.js.map +1 -1
  115. package/dist/core/tools.d.ts.map +1 -1
  116. package/dist/core/tools.js +30 -0
  117. package/dist/core/tools.js.map +1 -1
  118. package/dist/infrastructure/arxiv.d.ts +5 -0
  119. package/dist/infrastructure/arxiv.d.ts.map +1 -0
  120. package/dist/infrastructure/arxiv.js +36 -0
  121. package/dist/infrastructure/arxiv.js.map +1 -0
  122. package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
  123. package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
  124. package/dist/infrastructure/pdf/PdfDownload.js +14 -0
  125. package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
  126. package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
  127. package/dist/infrastructure/runlog/RunLedger.js +17 -1
  128. package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
  129. package/dist/management/carsi/installer.d.ts +1 -1
  130. package/dist/platforms/ArxivSearcher.d.ts +62 -0
  131. package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
  132. package/dist/platforms/ArxivSearcher.js +544 -0
  133. package/dist/platforms/ArxivSearcher.js.map +1 -0
  134. package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
  135. package/dist/platforms/SemanticScholarSearcher.js +4 -4
  136. package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
  137. package/dist/registry/platformFactories.d.ts.map +1 -1
  138. package/dist/registry/platformFactories.js +2 -0
  139. package/dist/registry/platformFactories.js.map +1 -1
  140. package/dist/registry/platformMetadata.d.ts +1 -1
  141. package/dist/registry/platformMetadata.d.ts.map +1 -1
  142. package/dist/registry/platformMetadata.js +16 -0
  143. package/dist/registry/platformMetadata.js.map +1 -1
  144. package/package.json +2 -2
  145. package/skills/paper-search/SKILL.md +7 -4
  146. package/skills/paper-search/references/browser-download.md +7 -6
  147. package/skills/paper-search/references/capability-routing.md +4 -3
  148. package/skills/paper-search/references/cli-contract.md +10 -3
  149. package/skills/paper-search/references/publisher-support.md +6 -4
  150. package/skills/paper-search/references/troubleshooting.md +1 -1
  151. 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 ( # re-exported: other modules import these from batch
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._track_article_for_adapter(page, doi)
168
- links: list = []
169
- # The journal's own article page the main text came from (cell.com,
170
- # journal-of-hepatology.eu) lists every file it has: no detour to the
171
- # platform, also when it lists none.
172
- final_url = str(getattr(result, "final_url", "") or "")
173
- own_page = [final_url] if final_url and self.adapter.is_article_page_url(final_url) else []
174
- candidates = self.adapter.supplements_page_urls(doi) or [self._article_url(doi)]
175
- for url in dict.fromkeys(own_page + candidates):
176
- page.goto(url, wait_until="domcontentloaded", timeout=60_000)
177
- if self._challenge_shell(page) or self._is_challenge_page(page):
178
- if not self._wait_for_challenge(page, result):
179
- self._event(result, "supplements_failed", "verification not completed")
180
- return [{"status": "failed", "reason": "challenge_timeout", "url": url, "label": ""}]
181
- settle(page, 2_000)
182
- links = self._settled_supplement_links(page, doi)
183
- if links is None:
184
- self._event(result, "supplements_not_supported", self.profile.name)
185
- return []
186
- if links or url in own_page:
187
- break
188
- self._event(result, "supplements_found", str(len(links)))
189
- progress_reporter.update(supplements_total=len(links))
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
- return self.adapter.supplement_links(page, doi)
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
- try:
364
- self._event(result, "article_open", result.article_url)
365
- if not self._ensure_login(page, result):
366
- if self._article_access_available(page):
367
- self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
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
- self._return_from_other_article(page, result)
372
- settle(page, 2_000)
373
- if not self._pdf_first_unless_signed_out(page, result):
374
- self._event(result, "auth_wall_after_article_load", self._decision_text(page))
375
- # This item has been through the school's login and the article still
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
- self._capture_reported_issues(page, result, act=False)
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
- self._hold_after_run(page, result)
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(text, record, fallback_title=result.title)
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
- body, url, block = self._capture_pdf_channels(page, doi, result)
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
- candidates = (list(bound_pdf_urls) if bound_pdf_urls is not None
625
- else [*metadata_pdf_urls, *self._pdf_candidates(page, doi), *before_click])
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
- pass
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
- pass
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 ""), str(page.title() 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
- @staticmethod
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": ("等待人工安全验证", "waiting_verification"),
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 = ("等待人工安全验证", "waiting_verification")
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
- try:
193
- packet["access_state"], packet["access_evidence"] = self._access_state(page)
194
- except Exception as exc:
195
- packet["access_state"], packet["access_evidence"] = "unknown", [f"probe failed: {type(exc).__name__}"]
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: