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
@@ -8,6 +8,7 @@ import json
8
8
  import re
9
9
  import time
10
10
  from typing import Any
11
+ from types import SimpleNamespace
11
12
  from urllib.parse import unquote, urljoin, urlparse
12
13
 
13
14
  from . import access_rules
@@ -65,20 +66,23 @@ class LoginMixin:
65
66
  self._wait_past_redirect_page(page, result)
66
67
  self._raise_on_publisher_content_error(page)
67
68
  self._dismiss_cookie_banners(page, result)
68
- if self._institution_pdf_entitlement_missing(page):
69
+ snapshot = self._access_snapshot(page) if not self._is_auth_page(str(page.url)) else None
70
+ if self._institution_pdf_entitlement_missing(page, snapshot=snapshot):
69
71
  return False
70
- if self._pdf_first_unless_signed_out(page, result):
72
+ if self._pdf_first_unless_signed_out(page, result, snapshot=snapshot):
71
73
  return True
72
74
  return self._complete_login_from_current_page(page, result)
73
75
 
74
- def _pdf_first_unless_signed_out(self, page: Any, result: DownloadResult) -> bool:
76
+ def _pdf_first_unless_signed_out(self, page: Any, result: DownloadResult,
77
+ snapshot: SimpleNamespace | None = None) -> bool:
75
78
  """True unless the page says the reader is signed out (access_rules OUT).
76
79
 
77
80
  With the institution's session, or an open-access article, the PDF is
78
81
  taken. With nothing either way, or with only the reader's access (also
79
82
  a personal account's or ClinicalKey's, J Hepatol 2026-10-31), the PDF is
80
83
  tried first and the school's login follows once if it fails (batch.py)."""
81
- verdict, basis, evidence = self._access_decision(page)
84
+ result._open_access_current = False
85
+ verdict, basis, evidence = self._access_decision(page, snapshot) if snapshot is not None else self._access_decision(page)
82
86
  if verdict != access_rules.UNKNOWN:
83
87
  detail = f"{verdict} ({basis}): {evidence}"[:240]
84
88
  if next((e["detail"] for e in reversed(result.events) if e["state"] == "access_decision"), None) != detail:
@@ -86,6 +90,7 @@ class LoginMixin:
86
90
  if verdict == access_rules.OUT:
87
91
  return False
88
92
  open_to_all = basis == "reader" and access_rules.OPEN_TO_ALL.search(evidence.split("badge: ", 1)[-1])
93
+ result._open_access_current = bool(open_to_all)
89
94
  if (basis != "session" and not open_to_all and not getattr(result, "_pdf_first", False)
90
95
  and not self._school_login_done()):
91
96
  result._pdf_first = True
@@ -122,32 +127,33 @@ class LoginMixin:
122
127
  return url[:200]
123
128
  return f"{url[:200]} | {verdict} ({basis or 'no rule'}): {evidence[:160]}"
124
129
 
125
- # How long a page that shows no text yet is given to draw it.
126
- PAGE_TEXT_WAIT_SEC = 20
127
130
  _PAGE_TEXT_JS = "() => document.contentType !== 'text/html' ? -1 : (document.body ? document.body.innerText.trim().length : 0)"
128
131
 
129
132
  def _wait_for_page_text(self, page: Any, result: DownloadResult) -> None:
130
- """Let a page without any text yet draw it before it is judged: De Gruyter's
131
- article page stayed blank for 9 s and was taken for one without an
132
- institution entry (2026-11-02). Still blank after ``PAGE_TEXT_WAIT_SEC``:
133
- the publisher's page did not load (``PublisherContentError``)."""
133
+ """Wait for the document before judging access, within the page budget.
134
+
135
+ A verification can disappear before the article draws. The old fixed
136
+ 20 s cut this wait short even with automation time left (AIP,
137
+ 2026-11-04). The enclosing page operation still bounds the whole step.
138
+ """
134
139
  def shown() -> bool:
135
140
  try:
136
141
  return page.evaluate(self._PAGE_TEXT_JS) != 0
137
142
  except Exception as exc:
138
143
  if not _is_browser_error(exc):
139
144
  raise
140
- return True # a page navigating meanwhile is judged by the steps after
145
+ return False # a document being replaced has not been read yet
141
146
  started = time.time()
142
147
  if shown():
143
148
  return
144
- drawn = pump_until(page, shown, self.PAGE_TEXT_WAIT_SEC * 1000, step_ms=500)
149
+ timeout_sec = self.page_wait_sec
150
+ drawn = pump_until(page, shown, int(timeout_sec * 1000), step_ms=500)
145
151
  # Recorded however short: a blank page is worth knowing about.
146
152
  self._event(result, "wait", f"blank page: {time.time() - started:.0f} s, ended: "
147
- f"{'text shown' if drawn else f'its {self.PAGE_TEXT_WAIT_SEC} s'}")
153
+ f"{'text shown' if drawn else f'its {timeout_sec:g} s'}")
148
154
  if not drawn:
149
155
  url = str(getattr(page, "url", "") or "")
150
- raise PublisherContentError(f"publisher_page_blank: {url[:160]} showed no text for {self.PAGE_TEXT_WAIT_SEC} s")
156
+ raise PublisherContentError(f"publisher_page_blank: {url[:160]} showed no text for {timeout_sec:g} s")
151
157
 
152
158
  def _wait_past_redirect_page(self, page: Any, result: DownloadResult, timeout_sec: float = 20) -> None:
153
159
  """Let a publisher's script redirect page (linkinghub "Redirecting") move on
@@ -187,9 +193,12 @@ class LoginMixin:
187
193
  self._phase(result, "login")
188
194
  started, signed_in = time.time(), False
189
195
  try:
190
- signed_in = self._complete_login_steps(page, result)
191
- if signed_in:
192
- self._return_to_article_after_login(page, result)
196
+ from ..browser import watchdog
197
+ # Human authentication has its own budget, not the automation's.
198
+ with watchdog.pause_operation():
199
+ signed_in = self._complete_login_steps(page, result)
200
+ if signed_in:
201
+ self._return_to_article_after_login(page, result)
193
202
  return signed_in
194
203
  finally:
195
204
  last = next((e["state"] for e in reversed(result.events) if e["state"] != "wait"), "")
@@ -631,17 +640,23 @@ class LoginMixin:
631
640
  host = urlparse(self._entity_id()).hostname or getattr(result, "_idp_host", "")
632
641
  return access_rules.registrable_domain(host) if host else ""
633
642
 
634
- def _school_or_federation_page(self, url: str, result: DownloadResult | None = None) -> bool:
643
+ def _school_or_federation_page(self, url: str, result: DownloadResult | None = None, *,
644
+ include_publisher_chooser: bool = True) -> bool:
635
645
  """The school's IdP or another page of its own site (its registrable domain),
636
- or a federation's or the publisher's school chooser: the user's part of the
637
- login, where the engine answers no banner and leaves the page where it is."""
646
+ or a federation's school chooser. Publisher-owned choosers are included
647
+ by default; their cookie prompts can be answered separately. The school's
648
+ own pages remain protected even if an adapter calls one a chooser."""
638
649
  host = (urlparse(url).hostname or "").lower()
639
650
  if not host:
640
651
  return False
641
- if self._is_idp_host(host) or self._is_federation_chooser(url):
652
+ if self._is_idp_host(host):
642
653
  return True
643
654
  school = self._school_domain(result)
644
- return bool(school) and access_rules.registrable_domain(host) == school
655
+ if school and access_rules.registrable_domain(host) == school:
656
+ return True
657
+ if not include_publisher_chooser and self.adapter.is_institution_chooser(url):
658
+ return False
659
+ return self._is_federation_chooser(url)
645
660
 
646
661
  def _stuck_on_school_site(self, page: Any, result: DownloadResult) -> str:
647
662
  """The page's URL when the login has stayed on a page of the school's own
@@ -798,7 +813,9 @@ class LoginMixin:
798
813
  for (const el of document.querySelectorAll('button, [role="button"], a')) {
799
814
  if (!shown(el) || el.closest('form')) continue;
800
815
  const label = (el.innerText || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
801
- if (!MENU.test(label)) continue;
816
+ // A screen-reader hint can be part of innerText ("Sign In Open Menu").
817
+ const menuLabel = label.replace(/\s+open menu$/i, '');
818
+ if (!MENU.test(menuLabel)) continue;
802
819
  const href = (el.getAttribute('href') || '').trim();
803
820
  if (el.tagName === 'A' && href && !href.startsWith('#') && !/^javascript:/i.test(href)
804
821
  && !TOGGLES.some(name => el.hasAttribute(name))) continue;
@@ -1374,8 +1391,9 @@ class LoginMixin:
1374
1391
  def _dismiss_cookie_banners(self, page: Any, result: DownloadResult | None = None, *,
1375
1392
  blocking: bool = False) -> bool:
1376
1393
  """Answer a cookie banner (``_COOKIE_BANNER_JS``) in the page or one of its
1377
- frames, at most ``BANNER_ANSWERS`` times per banner and page, never on the
1378
- school's or a federation's pages (``_school_or_federation_page``);
1394
+ frames, at most ``BANNER_ANSWERS`` times per banner and page. The school's
1395
+ and external federation pages stay protected; a publisher-owned chooser
1396
+ may answer its own cookie prompt (``_school_or_federation_page``).
1379
1397
  ``blocking``: the banner covered a control the engine clicked. An
1380
1398
  advertisement over the page is closed first (``_close_advertisement_overlay``).
1381
1399
  True when either was done."""
@@ -1396,12 +1414,14 @@ class LoginMixin:
1396
1414
  pass
1397
1415
  for target in targets:
1398
1416
  where = str(getattr(target, "url", "") or "").split("#", 1)[0]
1399
- if self._school_or_federation_page(where, item):
1417
+ if self._school_or_federation_page(where, item, include_publisher_chooser=False):
1400
1418
  continue
1401
1419
  skip = [key for (url, key), count in answered.items() if url == where and count >= self.BANNER_ANSWERS]
1402
1420
  try:
1403
1421
  clicked = target.evaluate(self._COOKIE_BANNER_JS, {"blocking": bool(blocking), "skip": skip})
1404
- except Exception:
1422
+ except Exception as exc:
1423
+ if not _is_browser_error(exc):
1424
+ raise
1405
1425
  continue # a frame gone meanwhile; the banner is a convenience, never the item's end
1406
1426
  if not isinstance(clicked, dict):
1407
1427
  continue
@@ -610,13 +610,8 @@ class RunsMixin:
610
610
  if not any(kw in url_lower for kw in ("login", "cas", "sso", "signin", "sign-in")):
611
611
  return True
612
612
  # Check for SSO-specific indicators
613
- try:
614
- title = (page.title() or "").lower()
615
- if any(kw in title for kw in ("登录成功", "welcome", "dashboard")):
616
- return True
617
- except Exception:
618
- pass
619
- return False
613
+ title = self._title(page).lower()
614
+ return any(kw in title for kw in ("登录成功", "welcome", "dashboard"))
620
615
 
621
616
  def _save_publisher_session(self, page: Any, result: DownloadResult) -> None:
622
617
  if not self.profile.base_domains:
@@ -7,12 +7,13 @@ from __future__ import annotations
7
7
  import inspect
8
8
  import re
9
9
  import time
10
+ from contextlib import ExitStack
10
11
  from typing import Any
11
12
  from urllib.parse import urlparse
12
13
 
13
14
  from ..entitlements import UserMarkedNoEntitlement
14
- from .batch_common import (AWAY_WAIT_SEC, DownloadResult, _IP_BLOCK_STATUS_CODES, _observe_preview,
15
- looks_like_challenge)
15
+ from ..browser import challenge_interaction, challenges, watchdog
16
+ from .batch_common import (AWAY_WAIT_SEC, DownloadResult, _IP_BLOCK_STATUS_CODES, _observe_preview)
16
17
 
17
18
 
18
19
  class UserWaitMixin:
@@ -113,6 +114,10 @@ class UserWaitMixin:
113
114
  def _wait_out_suspect_block(self, page: Any, result: DownloadResult, captured: dict[str, Any], suspect: str,
114
115
  suspect_host: str, started: float) -> bool:
115
116
  deadline = started + self.page_wait_sec
117
+ automatic: dict[str, Any] = {}
118
+ interaction = ExitStack()
119
+ manual = False
120
+ state = ""
116
121
  reporter = None
117
122
  attention_key = f"pdf-step:{id(self)}:{result.doi}"
118
123
  try:
@@ -156,6 +161,8 @@ class UserWaitMixin:
156
161
  if self._pdf_step_needs_login(candidate):
157
162
  state, tab = "login", candidate
158
163
  break
164
+ except (watchdog.BrowserUnresponsive, UserMarkedNoEntitlement):
165
+ raise
159
166
  except Exception:
160
167
  continue
161
168
  if not state and time.time() - started < self.SUSPECT_UNKNOWN_GRACE_SEC:
@@ -167,8 +174,8 @@ class UserWaitMixin:
167
174
  except Exception:
168
175
  continue
169
176
  if not state:
170
- if shown:
171
- self._event(result, "challenge_resolved", f"{shown} page gone")
177
+ if shown or automatic:
178
+ self._event(result, "challenge_resolved", f"{shown or 'verification'} page gone")
172
179
  captured["suspect_block"] = None
173
180
  return True # the capture loop retries the PDF now
174
181
  if state == "login" and tab == page:
@@ -179,6 +186,13 @@ class UserWaitMixin:
179
186
  self._event(result, "pdf_step_login_required", str(getattr(page, "url", "") or ""))
180
187
  captured["suspect_block"] = None
181
188
  return False
189
+ if state == "verification":
190
+ self._raise_on_publisher_access_blocked(tab)
191
+ if not automatic:
192
+ deadline = min(deadline, self._wait_deadline(result))
193
+ if self._automatic_challenge_step(tab, result, automatic, deadline):
194
+ page.wait_for_timeout(min(1000, max(0, (deadline - time.time()) * 1000)))
195
+ continue
182
196
  if state != shown:
183
197
  if state in ("verification", "login"):
184
198
  deadline = min(deadline, self._wait_deadline(result))
@@ -198,14 +212,19 @@ class UserWaitMixin:
198
212
  except Exception:
199
213
  pass
200
214
  shown = state
215
+ if not manual:
216
+ interaction.enter_context(watchdog.pause_operation())
217
+ manual = True
201
218
  try:
202
219
  _observe_preview(page) # what the user is asked to act on
203
- page.wait_for_timeout(1000) # pump browser events while the user acts
220
+ page.wait_for_timeout(min(1000, max(0, (deadline - time.time()) * 1000)))
221
+ except (watchdog.BrowserUnresponsive, UserMarkedNoEntitlement):
222
+ raise
204
223
  except Exception:
205
- time.sleep(1)
206
- if shown in ("verification", "login"):
224
+ time.sleep(min(1, max(0, deadline - time.time())))
225
+ if state in ("verification", "login"):
207
226
  self._wait_ran_out(result)
208
- if shown == "verification":
227
+ if state == "verification":
209
228
  # The verification page stayed up: nobody completed it. That
210
229
  # is a timeout, not evidence that the publisher blocked us.
211
230
  captured["block_reason"] = "challenge_or_viewer_timeout"
@@ -215,6 +234,7 @@ class UserWaitMixin:
215
234
  self._event(result, "ip_blocked_response", f"{suspect} | no PDF after waiting ({shown or 'nothing shown'})")
216
235
  return False
217
236
  finally:
237
+ interaction.close()
218
238
  if shown and reporter is not None:
219
239
  try:
220
240
  reporter.clear_attention(attention_key)
@@ -327,8 +347,43 @@ class UserWaitMixin:
327
347
  result._user_wait_expired = True
328
348
  self._event(result, "user_wait_expired", "short wait" if self._user_away else "")
329
349
 
350
+ def _automatic_challenge_step(self, page: Any, result: DownloadResult,
351
+ attempt: dict[str, Any], deadline: float) -> bool:
352
+ """A short automatic phase inside the existing wait, shared by both entry points.
353
+
354
+ True means keep observing, never that verification passed. Each wait
355
+ allows at most three interactions, including failed clicks, without a
356
+ new budget. Observe for at least three seconds after each attempt.
357
+ """
358
+ now = time.time()
359
+ if not attempt:
360
+ attempt.update(started=now, attempts=0, retry_at=now + 2)
361
+ self._event(result, "challenge_wait", "automatic verification check")
362
+ # Live widgets may not ask for a click until ~14 s after navigation.
363
+ # Reserve up to 2 s for manual handling without extending short waits.
364
+ manual_reserve = min(2, max(0, deadline - attempt["started"]) * 0.2)
365
+ automatic_end = min(deadline - manual_reserve, attempt["started"] + 20)
366
+ if now >= automatic_end:
367
+ return False
368
+ if now >= attempt["retry_at"] and attempt["attempts"] < 3:
369
+ # Camoufox's native humanized move can take 1.5 s before mouse-down.
370
+ # Leave time for that action, still inside the original wait budget.
371
+ outcome = challenge_interaction.click_checkbox(
372
+ page, timeout_ms=min(2500, max(1, int((automatic_end - now) * 1000))),
373
+ checkpoint=lambda: self._check_user_decision(result, page),
374
+ )
375
+ if outcome != "absent":
376
+ attempt["attempts"] += 1
377
+ attempt["retry_at"] = time.time() + 3
378
+ self._event(result, "challenge_wait",
379
+ f"checkbox {outcome}; attempt {attempt['attempts']}/3; awaiting verification")
380
+ return time.time() < automatic_end
381
+
330
382
  def _wait_for_challenge(self, page: Any, result: DownloadResult, *, deadline: float | None = None) -> bool:
331
383
  """Wait while a verification page is up, until ``_wait_deadline``."""
384
+ interaction = ExitStack()
385
+ automatic: dict[str, Any] = {}
386
+ manual = False
332
387
  wait_interval_sec = 1 # a cleared verification goes on within a second
333
388
  reporter = None
334
389
  attention_key = f"publisher-challenge:{id(self)}:{result.doi}"
@@ -352,6 +407,14 @@ class UserWaitMixin:
352
407
  if not waited:
353
408
  deadline = self._wait_deadline(result, deadline)
354
409
  started, phase_before = time.time(), self._phase(result, "verification")
410
+ waited = True
411
+ result.state = "challenge_or_viewer_timeout"
412
+ if time.time() >= deadline:
413
+ break
414
+ auto_pending = self._automatic_challenge_step(page, result, automatic, deadline)
415
+ if not auto_pending and not manual:
416
+ interaction.enter_context(watchdog.pause_operation())
417
+ manual = True
355
418
  self._event(result, "challenge_manual_wait", "complete verification in visible browser")
356
419
  if not any(s["why"] == "waiting for the user: verification" for s in result.snapshots):
357
420
  self._snapshot(page, result, "waiting for the user: verification")
@@ -363,10 +426,8 @@ class UserWaitMixin:
363
426
  phase="人工验证",
364
427
  )
365
428
  attention_open = True
366
- waited = True
367
- result.state = "challenge_or_viewer_timeout"
368
429
  checks += 1
369
- self._event(result, "challenge_wait", str(checks))
430
+ self._event(result, "challenge_wait", f"{'manual' if manual else 'automatic'} {checks}")
370
431
  # Pump browser events while the user completes verification.
371
432
  # Plain sleep can leave sync-API navigation state stale.
372
433
  wait_ms = min(wait_interval_sec * 1000, max(0, (deadline - time.time()) * 1000))
@@ -389,6 +450,7 @@ class UserWaitMixin:
389
450
  self._wait_ran_out(result)
390
451
  return passed
391
452
  finally:
453
+ interaction.close()
392
454
  if waited:
393
455
  self._phase(result, phase_before or "open")
394
456
  self._note_wait(result, "verification", started, "passed" if result.state != "challenge_or_viewer_timeout"
@@ -397,45 +459,23 @@ class UserWaitMixin:
397
459
  reporter.clear_attention(attention_key)
398
460
 
399
461
  def _challenge_shell(self, page: Any) -> bool:
400
- """A Cloudflare interstitial recognised from title and URL alone.
401
-
402
- Scripts reading the challenge page (innerText, DOM queries) can make
403
- Cloudflare fail and re-issue the check (``__cf_chl_rt_tk``) in a loop;
404
- an untouched page passes. Callers waiting on a challenge use this and
405
- nothing else.
406
- """
462
+ """Read only URL/title while a verification interstitial is active."""
407
463
  try:
408
464
  url = str(getattr(page, "url", "") or "")
409
465
  except Exception:
410
466
  url = ""
411
- return looks_like_challenge(url, self._title(page))
467
+ return challenges.looks_like_challenge(url, self._title(page))
412
468
 
413
469
  def _is_challenge_page(self, page: Any) -> bool:
470
+ """Apply engine block precedence before the independent recogniser.
471
+
472
+ Read lazily: an interstitial or crawl-prevention URL needs no body read;
473
+ an explicit publisher block must never turn into a verification wait.
474
+ """
414
475
  if self._challenge_shell(page):
415
476
  return True
416
477
  if self._publisher_access_blocked(page):
417
478
  return False
418
- # Silverchair sites (OUP and others) send unusual traffic to their own
419
- # reCAPTCHA page, /crawlprevention/governor?content=<the article> ("Validate
420
- # User", 2026-11-01); it leads back to the article once the user passes it.
421
- if "/crawlprevention/" in urlparse(str(getattr(page, "url", "") or "")).path.lower():
422
- return True
423
- haystack = f"{self._title(page)} {self._body_text(page, 1_200)}".lower()
424
- direct_markers = (
425
- "just a moment",
426
- "attention required",
427
- "verify you are human",
428
- "checking your browser",
429
- "are you a robot",
430
- "please confirm you are a human",
431
- "confirm that you are not a robot",
432
- )
433
- if any(marker in haystack for marker in direct_markers):
479
+ if challenges.is_challenge_path(str(getattr(page, "url", "") or "")):
434
480
  return True
435
- return "cloudflare" in haystack and (
436
- "ray id:" in haystack
437
- or "security verification" in haystack
438
- or "security service" in haystack
439
- or "not a robot" in haystack
440
- or "瀹夊叏楠岃瘉" in haystack
441
- )
481
+ return challenges.has_challenge_text(self._title(page), self._body_text(page, 1_200))
@@ -36,6 +36,7 @@ _FAILURE_KINDS = {
36
36
  "publisher_content_error": ("publisher_content_error", "review_publisher_error"),
37
37
  "unsupported_platform": ("unsupported_platform", "review_publisher_error"),
38
38
  "browser_unresponsive": ("browser_unresponsive", "review_browser"),
39
+ "browser_operation_timeout": ("browser_operation_timeout", "review_browser"),
39
40
  "publisher_access_blocked": ("access_blocked", "review_access"),
40
41
  "publisher_unreachable": ("publisher_unreachable", "review_publisher_error"),
41
42
  "sso_required": ("paywall", "login_required"),
@@ -509,7 +510,8 @@ def _try_browser_batch_download(
509
510
  require_interactive_browser=True,
510
511
  )
511
512
 
512
- run_dir = output_path.parent / ".browser_runs"
513
+ from uuid import uuid4
514
+ run_dir = Path(config.get("diagnostics_dir") or output_path.parent / "diagnostics") / uuid4().hex[:12]
513
515
  run_dir.mkdir(parents=True, exist_ok=True)
514
516
 
515
517
  log.info(f" [Institutional] Browser batch download: {doi} via {profile.name}")
@@ -537,10 +539,10 @@ def _try_browser_batch_download(
537
539
  if watchdog.fired and not result.ok:
538
540
  # The browser call never returned and the driver was stopped:
539
541
  # whatever the engine reported after that is a consequence.
540
- result.reason = result.state = "browser_unresponsive"
542
+ result.reason = result.state = watchdog.failure_code()
541
543
  if supplements_mode in ("with", "only"):
542
- if result.state == "browser_unresponsive":
543
- result.supplements = [{"status": "skipped", "reason": "browser_unresponsive",
544
+ if result.state in ("browser_unresponsive", "browser_operation_timeout"):
545
+ result.supplements = [{"status": "skipped", "reason": result.state,
544
546
  "url": "", "label": ""}]
545
547
  elif result.state == "challenge_or_viewer_timeout":
546
548
  # The verification page was never completed: the article
@@ -548,13 +550,16 @@ def _try_browser_batch_download(
548
550
  result.supplements = [{"status": "skipped", "reason": "challenge_timeout",
549
551
  "url": "", "label": ""}]
550
552
  elif result.state in ("unsupported_platform", "publisher_access_blocked", "publisher_unreachable",
551
- "login_page_closed"):
553
+ "login_page_closed", "user_skipped", "batch_stopped",
554
+ "user_marked_no_entitlement"):
552
555
  # Nothing more to fetch there now.
553
556
  result.supplements = [{"status": "skipped", "reason": result.state,
554
557
  "url": "", "label": ""}]
555
558
  else:
556
559
  written = len(result.events)
557
- result.supplements = downloader.collect_supplements(context, doi, output_path.parent, result)
560
+ result.supplements = downloader.collect_supplements(context, doi, output_path.parent, result, manifest_dir=run_dir)
561
+ if supplements_mode == "only" and (run_dir / "manifest.json").is_file():
562
+ result.record_path = str(run_dir / "manifest.json")
558
563
  _note_supplements(result, written)
559
564
  finally:
560
565
  try:
@@ -577,6 +582,8 @@ def _try_browser_batch_download(
577
582
  if output_path.exists():
578
583
  output_path.unlink()
579
584
  src.rename(output_path)
585
+ result.pdf_path = str(output_path)
586
+ _note_supplements(result, len(getattr(result, "events", [])))
580
587
  log.info(f" [Institutional] Browser download succeeded: {result.size_bytes:,} bytes")
581
588
  return {
582
589
  "success": True, "identifier": doi, "doi": doi,
@@ -618,8 +625,9 @@ def _note_supplements(result: Any, written: int) -> None:
618
625
  if not path.is_file():
619
626
  continue
620
627
  data = json.loads(path.read_text(encoding="utf-8"))
621
- data["events"] = list(data.get("events") or []) + list(result.events[written:])
622
- data["supplements"] = result.supplements
628
+ data["events"] = list(data.get("events") or []) + list(getattr(result, "events", [])[written:])
629
+ data["supplements"] = getattr(result, "supplements", [])
630
+ data["pdf_path"] = str(getattr(result, "pdf_path", "") or "")
623
631
  if getattr(result, "phases", None):
624
632
  data["phases"] = {k: round(float(v), 1) for k, v in result.phases.items()}
625
633
  path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -21,6 +21,8 @@ from typing import Any, Callable, Iterable
21
21
  from urllib.parse import unquote, urlparse
22
22
 
23
23
  from ..adapters.base import SupplementLink
24
+ from ..browser.watchdog import BrowserUnresponsive, BrowserOperationTimeout
25
+ from ..entitlements import UserMarkedNoEntitlement, UserSkippedArticle, UserStoppedBatch
24
26
 
25
27
  MAX_FILES = 50
26
28
  MAX_FILE_BYTES = 200 * 1024 * 1024
@@ -87,6 +89,8 @@ def _get(request: Any, url: str, entry: dict[str, Any], sleep) -> Any:
87
89
 
88
90
  def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi: str, *,
89
91
  clock=time.monotonic, sleep=time.sleep,
92
+ manifest_dir: Path | None = None,
93
+ target_dir: Path | None = None,
90
94
  download: Callable[[str], tuple[bytes, str]] | None = None,
91
95
  progress: Callable[[int], None] | None = None) -> list[dict[str, Any]]:
92
96
  """Fetch ``links`` with ``request`` (a Playwright APIRequestContext). A file
@@ -101,14 +105,19 @@ def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi
101
105
  unique.append(link)
102
106
  if not unique:
103
107
  return []
104
- target = supplements_dir(output_dir, doi)
108
+ target = target_dir or supplements_dir(output_dir, doi)
105
109
  target.mkdir(parents=True, exist_ok=True)
106
110
  started = clock()
107
111
  taken: set[str] = set()
108
112
  files: list[dict[str, Any]] = []
113
+ stopped_reason = ""
109
114
  for index, link in enumerate(unique):
110
115
  entry: dict[str, Any] = {"url": link.url, "label": link.label}
111
- if index >= MAX_FILES:
116
+ if stopped_reason:
117
+ entry.update(status="skipped", reason=stopped_reason)
118
+ elif link.error:
119
+ entry.update(status="failed", reason=link.error)
120
+ elif index >= MAX_FILES:
112
121
  entry.update(status="skipped", reason="file_limit")
113
122
  elif clock() - started > TIME_BUDGET_SEC:
114
123
  entry.update(status="skipped", reason="time_budget")
@@ -138,11 +147,21 @@ def collect(request: Any, links: Iterable[SupplementLink], output_dir: Path, doi
138
147
  path.write_bytes(body)
139
148
  entry.update(status="saved", file=str(path), size=len(body),
140
149
  content_type=content_type.split(";")[0].strip())
150
+ except (BrowserUnresponsive, UserMarkedNoEntitlement) as exc:
151
+ stopped_reason = ("batch_stopped" if isinstance(exc, UserStoppedBatch) else
152
+ "user_skipped" if isinstance(exc, UserSkippedArticle) else
153
+ "user_marked_no_entitlement" if isinstance(exc, UserMarkedNoEntitlement) else
154
+ "browser_operation_timeout" if isinstance(exc, BrowserOperationTimeout) else
155
+ "browser_unresponsive")
156
+ entry.update(status="skipped" if isinstance(exc, UserMarkedNoEntitlement) else "failed",
157
+ reason=stopped_reason)
141
158
  except Exception as exc:
142
159
  entry.update(status="failed", reason=f"{type(exc).__name__}: {str(exc)[:160]}")
143
160
  files.append(entry)
144
161
  if progress is not None:
145
162
  progress(sum(1 for f in files if f.get("status") == "saved"))
146
- (target / "manifest.json").write_text(
163
+ manifest_target = manifest_dir or target
164
+ manifest_target.mkdir(parents=True, exist_ok=True)
165
+ (manifest_target / "manifest.json").write_text(
147
166
  json.dumps({"doi": doi, "files": files}, ensure_ascii=False, indent=2), encoding="utf-8")
148
167
  return files
@@ -29,9 +29,8 @@ def _file_handler() -> logging.Handler | None:
29
29
 
30
30
 
31
31
  def enable_stack_dump() -> None:
32
- """``kill -USR1 <pid>`` appends every thread's Python stack to
33
- ``<data dir>/logs/stacks-<date>.txt``: a backend waiting forever on a browser
34
- call (Camoufox, 2026-10-31) shows where it waits."""
32
+ """Prepare watchdog dumps on every OS; POSIX also supports ``kill -USR1``.
33
+ Stacks go to ``<data dir>/logs/stacks-<date>.txt``."""
35
34
  global _stack_file
36
35
  import faulthandler
37
36
  import os
@@ -39,15 +38,22 @@ def enable_stack_dump() -> None:
39
38
  from datetime import date
40
39
  from pathlib import Path
41
40
 
42
- if _stack_file is not None or not hasattr(signal, "SIGUSR1") or os.environ.get("CARSI_FILE_LOG") == "0":
41
+ if _stack_file is not None or os.environ.get("CARSI_FILE_LOG") == "0":
43
42
  return
44
43
  try:
45
44
  log_dir = Path(os.environ.get("CARSI_DATA_DIR", str(Path.home() / ".local" / "share" / "paper-search-cli" / "carsi"))) / "logs"
46
45
  log_dir.mkdir(parents=True, exist_ok=True)
47
46
  _stack_file = open(log_dir / f"stacks-{date.today().isoformat()}.txt", "a", encoding="utf-8")
48
- faulthandler.register(signal.SIGUSR1, file=_stack_file, all_threads=True)
49
- except Exception:
50
- _stack_file = None
47
+ except OSError:
48
+ get_logger().warning("Could not open the browser watchdog stack log")
49
+ return
50
+ # Windows has no SIGUSR1/register, but dump_traceback works there. A failed
51
+ # optional signal hook must not disable explicit watchdog dumps either.
52
+ if hasattr(signal, "SIGUSR1"):
53
+ try:
54
+ faulthandler.register(signal.SIGUSR1, file=_stack_file, all_threads=True)
55
+ except (OSError, RuntimeError, ValueError):
56
+ get_logger().warning("Stack log ready, but the SIGUSR1 hook is unavailable")
51
57
 
52
58
 
53
59
  def dump_stacks(reason: str) -> bool:
@@ -32,6 +32,7 @@ def download_institutional(
32
32
  strategy: str | None = None,
33
33
  supplements: str | None = None,
34
34
  user_wait: str | None = None,
35
+ diagnostics_dir: str | None = None,
35
36
  ) -> dict[str, Any]:
36
37
  """Download one DOI through institutional access only.
37
38
 
@@ -66,6 +67,8 @@ def download_institutional(
66
67
  extra = {"supplements": supplements} if supplements in ("with", "only") else {}
67
68
  if user_wait == "short":
68
69
  extra["user_wait"] = user_wait
70
+ if diagnostics_dir:
71
+ extra["diagnostics_dir"] = diagnostics_dir
69
72
  return _download_impl(doi, output_dir, **extra)
70
73
  finally:
71
74
  with _INFLIGHT_LOCK:
@@ -73,7 +76,8 @@ def download_institutional(
73
76
 
74
77
 
75
78
  def _download_impl(doi: str, output_dir: str | Path | None, *,
76
- supplements: str | None = None, user_wait: str | None = None) -> dict[str, Any]:
79
+ supplements: str | None = None, user_wait: str | None = None,
80
+ diagnostics_dir: str | None = None) -> dict[str, Any]:
77
81
  config = load_config()
78
82
  if user_wait == "short":
79
83
  config["user_wait"] = "short"
@@ -82,6 +86,8 @@ def _download_impl(doi: str, output_dir: str | Path | None, *,
82
86
 
83
87
  target_dir = Path(output_dir) if output_dir else Path(config["output_dir"])
84
88
  target_dir.mkdir(parents=True, exist_ok=True)
89
+ if diagnostics_dir:
90
+ config["diagnostics_dir"] = str(Path(diagnostics_dir).resolve())
85
91
  output_path = target_dir / f"{safe_filename(doi)}.pdf"
86
92
 
87
93
  cached = cache_get(doi, config)