papersearch 0.4.0 → 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 (157) hide show
  1. package/README.en.md +292 -0
  2. package/README.md +203 -122
  3. package/carsi/README.md +9 -3
  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 +74 -17
  19. package/carsi/paper_search_carsi/config.py +4 -1
  20. package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
  21. package/carsi/paper_search_carsi/engine/batch.py +140 -69
  22. package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
  23. package/carsi/paper_search_carsi/engine/batch_capture.py +54 -9
  24. package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
  25. package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
  26. package/carsi/paper_search_carsi/engine/batch_login.py +85 -30
  27. package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
  28. package/carsi/paper_search_carsi/engine/batch_waits.py +97 -47
  29. package/carsi/paper_search_carsi/engine/bridge.py +17 -8
  30. package/carsi/paper_search_carsi/engine/supplements.py +28 -5
  31. package/carsi/paper_search_carsi/entitlements.py +12 -2
  32. package/carsi/paper_search_carsi/log.py +13 -7
  33. package/carsi/paper_search_carsi/orchestrator.py +7 -1
  34. package/carsi/paper_search_carsi/progress_bar.py +156 -12
  35. package/carsi/paper_search_carsi/server.py +8 -1
  36. package/carsi/paper_search_carsi/user_settings.py +55 -0
  37. package/carsi/pyproject.toml +1 -1
  38. package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
  39. package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
  40. package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
  41. package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
  42. package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
  43. package/dist/capabilities/metadata-search/handler.js +6 -0
  44. package/dist/capabilities/metadata-search/handler.js.map +1 -1
  45. package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
  46. package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
  47. package/dist/capabilities/metadata-search/schemas.js +1 -0
  48. package/dist/capabilities/metadata-search/schemas.js.map +1 -1
  49. package/dist/capabilities/metadata-search/tools.js +1 -1
  50. package/dist/capabilities/metadata-search/tools.js.map +1 -1
  51. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
  52. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
  53. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
  54. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
  55. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
  56. package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
  57. package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
  58. package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
  59. package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
  60. package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
  61. package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
  62. package/dist/capabilities/pdf-discovery/batch.js +87 -15
  63. package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
  64. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
  65. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
  66. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
  67. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
  68. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
  69. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
  70. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
  71. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
  72. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
  73. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
  74. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
  75. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
  76. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
  77. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
  78. package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
  79. package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
  80. package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
  81. package/dist/capabilities/pdf-discovery/handler.js +3 -2
  82. package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
  83. package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
  84. package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
  85. package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
  86. package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
  87. package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
  88. package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
  89. package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
  90. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
  91. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
  92. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
  93. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
  94. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
  95. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
  96. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
  97. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
  98. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
  99. package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
  100. package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
  101. package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
  102. package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
  103. package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
  104. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
  105. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
  106. package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
  107. package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
  108. package/dist/core/handleToolCall.d.ts.map +1 -1
  109. package/dist/core/handleToolCall.js +12 -0
  110. package/dist/core/handleToolCall.js.map +1 -1
  111. package/dist/core/schemas.d.ts +26 -1
  112. package/dist/core/schemas.d.ts.map +1 -1
  113. package/dist/core/schemas.js +13 -0
  114. package/dist/core/schemas.js.map +1 -1
  115. package/dist/core/searchers.d.ts +2 -0
  116. package/dist/core/searchers.d.ts.map +1 -1
  117. package/dist/core/searchers.js.map +1 -1
  118. package/dist/core/tools.d.ts.map +1 -1
  119. package/dist/core/tools.js +30 -0
  120. package/dist/core/tools.js.map +1 -1
  121. package/dist/infrastructure/arxiv.d.ts +5 -0
  122. package/dist/infrastructure/arxiv.d.ts.map +1 -0
  123. package/dist/infrastructure/arxiv.js +36 -0
  124. package/dist/infrastructure/arxiv.js.map +1 -0
  125. package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
  126. package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
  127. package/dist/infrastructure/pdf/PdfDownload.js +14 -0
  128. package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
  129. package/dist/infrastructure/pdf/PdfFirstPage.cjs +4 -1
  130. package/dist/infrastructure/pdf/PdfFirstPage.cjs.map +1 -1
  131. package/dist/infrastructure/pdf/PdfFirstPage.d.cts.map +1 -1
  132. package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
  133. package/dist/infrastructure/runlog/RunLedger.js +17 -1
  134. package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
  135. package/dist/management/carsi/installer.d.ts +1 -1
  136. package/dist/platforms/ArxivSearcher.d.ts +62 -0
  137. package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
  138. package/dist/platforms/ArxivSearcher.js +544 -0
  139. package/dist/platforms/ArxivSearcher.js.map +1 -0
  140. package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
  141. package/dist/platforms/SemanticScholarSearcher.js +4 -4
  142. package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
  143. package/dist/registry/platformFactories.d.ts.map +1 -1
  144. package/dist/registry/platformFactories.js +2 -0
  145. package/dist/registry/platformFactories.js.map +1 -1
  146. package/dist/registry/platformMetadata.d.ts +1 -1
  147. package/dist/registry/platformMetadata.d.ts.map +1 -1
  148. package/dist/registry/platformMetadata.js +16 -0
  149. package/dist/registry/platformMetadata.js.map +1 -1
  150. package/package.json +2 -2
  151. package/skills/paper-search/SKILL.md +7 -4
  152. package/skills/paper-search/references/browser-download.md +10 -6
  153. package/skills/paper-search/references/capability-routing.md +4 -3
  154. package/skills/paper-search/references/cli-contract.md +10 -3
  155. package/skills/paper-search/references/publisher-support.md +6 -4
  156. package/skills/paper-search/references/troubleshooting.md +1 -1
  157. package/README.zh.md +0 -211
@@ -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,7 +87,8 @@ 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
89
- from ..entitlements import UserSkippedArticle
90
+ from ..browser import watchdog
91
+ from ..entitlements import UserSkippedArticle, UserStoppedBatch
90
92
 
91
93
 
92
94
  class PublisherBatchDownloader(
@@ -147,12 +149,13 @@ 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
154
156
 
155
157
  self._phase(result, "supplements")
158
+ progress_reporter.update(part="supplements", supplements_saved=0, supplements_total=None)
156
159
  try:
157
160
  page = context.new_page()
158
161
  except Exception as exc:
@@ -162,34 +165,56 @@ class PublisherBatchDownloader(
162
165
  # Digest 2026-11-02): no supplements, and the article keeps its result.
163
166
  self._event(result, "supplements_skipped", f"browser closed: {type(exc).__name__}")
164
167
  return [{"status": "skipped", "reason": "browser_closed", "url": "", "label": ""}]
168
+ user_watch = self._start_user_watch(page, result)
165
169
  try:
166
- self._track_article_for_adapter(page, doi)
167
- links: list = []
168
- # The journal's own article page the main text came from (cell.com,
169
- # journal-of-hepatology.eu) lists every file it has: no detour to the
170
- # platform, also when it lists none.
171
- final_url = str(getattr(result, "final_url", "") or "")
172
- own_page = [final_url] if final_url and self.adapter.is_article_page_url(final_url) else []
173
- candidates = self.adapter.supplements_page_urls(doi) or [self._article_url(doi)]
174
- for url in dict.fromkeys(own_page + candidates):
175
- page.goto(url, wait_until="domcontentloaded", timeout=60_000)
176
- if self._challenge_shell(page) or self._is_challenge_page(page):
177
- if not self._wait_for_challenge(page, result):
178
- self._event(result, "supplements_failed", "verification not completed")
179
- return [{"status": "failed", "reason": "challenge_timeout", "url": url, "label": ""}]
180
- settle(page, 2_000)
181
- links = self._settled_supplement_links(page, doi)
182
- if links is None:
183
- self._event(result, "supplements_not_supported", self.profile.name)
184
- return []
185
- if links or url in own_page:
186
- break
187
- self._event(result, "supplements_found", str(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))
188
195
  files = supplements.collect(page.context.request, links, Path(output_dir), doi,
189
- download=lambda url: self._download_in_new_tab(page, url))
196
+ manifest_dir=manifest_dir,
197
+ target_dir=Path(output_dir) / "supplements" if self.config.get("diagnostics_dir") else None,
198
+ download=lambda url: self._download_in_new_tab(page, url),
199
+ progress=lambda saved: progress_reporter.update(supplements_saved=saved))
190
200
  saved = sum(1 for f in files if f.get("status") == "saved")
191
201
  self._event(result, "supplements_saved", f"{saved}/{len(files)}")
192
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": ""}]
193
218
  except PublisherAccessBlocked:
194
219
  # The publisher paused access (BJSM, 2026-11-01): the article ends as
195
220
  # blocked when its main text failed, so the batch stops as for any block.
@@ -203,6 +228,8 @@ class PublisherBatchDownloader(
203
228
  self._event(result, "supplements_failed", f"{type(exc).__name__}: {str(exc)[:160]}")
204
229
  return [{"status": "failed", "reason": type(exc).__name__, "url": "", "label": ""}]
205
230
  finally:
231
+ user_watch.set()
232
+ watchdog.clear_interrupt()
206
233
  self._close_phase(result)
207
234
  try:
208
235
  page.close()
@@ -214,7 +241,13 @@ class PublisherBatchDownloader(
214
241
  moves /pii/ to /abs/pii/ by script after the document has loaded)."""
215
242
  for attempt in range(3):
216
243
  try:
217
- 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 [])]))
218
251
  except Exception as exc:
219
252
  if (attempt == 2 or not _is_browser_error(exc)
220
253
  or "Execution context was destroyed" not in str(exc)):
@@ -304,6 +337,8 @@ class PublisherBatchDownloader(
304
337
  config["browser_headless"] = False
305
338
  started = time.time()
306
339
  ctx = get_persistent_context(profile_path, config)
340
+ from ..browser.challenge_interaction import prepare_context
341
+ prepare_context(ctx)
307
342
  self._browser_start = {**dict(getattr(browser_backend, "LAST_LAUNCH", {}) or {}),
308
343
  "seconds": round(time.time() - started, 1), **({"relaunch": relaunch} if relaunch else {})}
309
344
  from ..browser.backend import browser_info
@@ -341,11 +376,13 @@ class PublisherBatchDownloader(
341
376
  self._item_started = time.time()
342
377
  self._item_result = result
343
378
  self._record_title = record.title
379
+ self._expected_title = ""
344
380
  self._manual_school = False
345
381
  self._track_article_for_adapter(page, record.doi)
346
382
  from ..browser import watchdog
347
383
 
348
384
  self._phase(result, "open")
385
+ progress_reporter.update(part="main", supplements_saved=0, supplements_total=None)
349
386
  self._run_dir = run_dir
350
387
  watchdog.on_slow_call = lambda name, seconds, ended, where: self._event(
351
388
  result, "wait", f"browser call {name}: {seconds:.0f} s, ended: {ended} ({where})")
@@ -356,46 +393,17 @@ class PublisherBatchDownloader(
356
393
  result.browser_start = dict(start)
357
394
  self._event(result, "browser_start", self._browser_start_line(start))
358
395
  user_watch = self._start_user_watch(page, result)
359
- try:
360
- self._event(result, "article_open", result.article_url)
361
- if not self._ensure_login(page, result):
362
- if self._article_access_available(page):
363
- self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
364
- else:
365
- return self._login_failed(page, result, run_dir)
366
-
367
- self._return_from_other_article(page, result)
368
- settle(page, 2_000)
369
- if not self._pdf_first_unless_signed_out(page, result):
370
- self._event(result, "auth_wall_after_article_load", self._decision_text(page))
371
- # This item has been through the school's login and the article still
372
- # denies it, shows only its abstract or offers it: the school has no
373
- # access, and a second login would only wait out the user's time
374
- # (JAMA Oncology back from the store page, 2026-11-02).
375
- evidence = self._no_access_after_login(page, result)
376
- if evidence:
377
- result.reason = result.state = "institution_pdf_entitlement_missing"
378
- self._event(result, "no_access_after_login", evidence[:300])
379
- self._snapshot(page, result, "not subscribed")
380
- result.final_url = page.url
381
- result.title = self._title(page)
382
- self._write_diagnostic(page, result, run_dir)
383
- return result
384
- if not self._complete_login_from_current_page(page, result):
385
- if self._article_access_available(page):
386
- self._event(result, "login_completed_after_timeout", getattr(page, "url", ""))
387
- else:
388
- return self._login_failed(page, result, run_dir)
389
- settle(page, 2_000)
390
- if not self._pdf_first_unless_signed_out(page, result):
391
- 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
392
401
 
393
- self._return_from_other_article(page, result)
394
- result.final_url = page.url
395
- result.title = self._title(page)
396
- result.state = "article_loaded"
397
- self._event(result, "article_access_ready")
398
- 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
399
407
  ip_block: str | None = None
400
408
  # Whether this capture comes after the school's login, and its first attempt.
401
409
  signed_in, attempts_from = self._school_login_done(), len(result.pdf_attempts)
@@ -512,6 +520,12 @@ class PublisherBatchDownloader(
512
520
  self._event(result, "pdf_found_while_waiting" if isinstance(provided, PdfFoundWhileWaiting)
513
521
  else "pdf_from_user_report", provided.url)
514
522
  return self._save_captured_pdf(page, record, result, run_dir, provided.body, provided.url)
523
+ except UserStoppedBatch as decision:
524
+ watchdog.clear_interrupt()
525
+ result.reason = result.state = "batch_stopped"
526
+ self._event(result, "batch_stopped", str(decision))
527
+ self._write_diagnostic(page, result, run_dir)
528
+ return result
515
529
  except UserSkippedArticle as decision:
516
530
  watchdog.clear_interrupt()
517
531
  result.reason = result.state = "user_skipped"
@@ -542,6 +556,12 @@ class PublisherBatchDownloader(
542
556
  progress_reporter.update(phase="出版社内容服务错误,当前路径已停止")
543
557
  self._write_diagnostic(page, result, run_dir)
544
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
545
565
  except Exception as exc:
546
566
  import traceback
547
567
 
@@ -563,22 +583,72 @@ class PublisherBatchDownloader(
563
583
  self._write_diagnostic(page, result, run_dir)
564
584
  return result
565
585
  finally:
586
+ page.remove_listener("framenavigated", invalidate_open_access)
566
587
  user_watch.set()
567
588
  watchdog.on_slow_call = None
568
589
  if isinstance(watchdog.interrupted, UserMarkedNoEntitlement) and result.ok:
569
590
  # The click came while the PDF was being saved.
570
591
  self._tell_user("点击时这一篇已经保存好了,没有跳过", result)
571
592
  watchdog.clear_interrupt()
572
- 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)
573
597
  if not result.ok:
574
598
  progress_reporter.report_stage("route_failed", current=record.doi)
575
- self._hold_after_run(page, result)
599
+ if not stopped:
600
+ self._hold_after_run(page, result)
576
601
  if _owns_page:
577
602
  try:
578
603
  page.close()
579
604
  except Exception:
580
605
  pass
581
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
+
582
652
  @staticmethod
583
653
  def _browser_start_line(start: dict) -> str:
584
654
  """``browser_start``'s detail: "profile free; launch 3.1 s", or the holder,
@@ -658,7 +728,8 @@ class PublisherBatchDownloader(
658
728
  result.pdf_path = str(pdf_path)
659
729
  result.size_bytes = len(pdf_bytes)
660
730
  result.text_length = len(text or "")
661
- 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)
662
733
  self._save_publisher_session(page, result)
663
734
  if not any(e["state"] == "pdf_saved_unconfirmed" for e in result.events):
664
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
 
@@ -1256,7 +1301,7 @@ class CaptureMixin:
1256
1301
  # Human verification gets its own budget: the user's time must
1257
1302
  # not use up the PDF step, and once the check clears the PDF
1258
1303
  # gets its full time again.
1259
- challenge_deadline = time.time() + max(self.SUSPECT_BLOCK_WAIT_SEC, timeout_sec)
1304
+ challenge_deadline = time.time() + max(self.page_wait_sec, timeout_sec)
1260
1305
  if not self._wait_for_challenge_with_deadline(page, result, challenge_deadline):
1261
1306
  return None, str(getattr(page, "url", "") or "")
1262
1307
  deadline = time.time() + timeout_sec
@@ -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: