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
@@ -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:
@@ -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"), "")
@@ -260,6 +269,7 @@ class LoginMixin:
260
269
  last_auto_action_at = 0.0
261
270
  visited_idp = False
262
271
  page_since, page_inputs = time.time(), ()
272
+ outside_since = time.time() # last seen on the user's own login step
263
273
  article_returns = 0
264
274
  session_returns = 0
265
275
  away_from_publisher = False # on an IdP, federation or chooser page since the last probe
@@ -273,6 +283,14 @@ class LoginMixin:
273
283
  result.reason = "institution_federation_unavailable"
274
284
  self._event(result, result.reason, "the school is not in the publisher's institution list")
275
285
  return False
286
+ over, outside_since = self._page_wait_over(page, outside_since)
287
+ if over:
288
+ # Off the school's login page for a whole page wait (a publisher
289
+ # page, a chooser, a redirect that goes nowhere): nobody is typing.
290
+ self._event(result, "page_wait_ended",
291
+ f"{self._login_wait_page(page)} after {self.page_wait_sec:.0f} s")
292
+ self._wait_ran_out(result)
293
+ return False
276
294
  marker = f"{self._title(page)} | {getattr(page, 'url', '')[:160]}"
277
295
  here = urlparse(str(getattr(page, "url", "") or ""))
278
296
  on_publisher = self._on_publisher_host(here.geturl())
@@ -622,17 +640,23 @@ class LoginMixin:
622
640
  host = urlparse(self._entity_id()).hostname or getattr(result, "_idp_host", "")
623
641
  return access_rules.registrable_domain(host) if host else ""
624
642
 
625
- 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:
626
645
  """The school's IdP or another page of its own site (its registrable domain),
627
- or a federation's or the publisher's school chooser: the user's part of the
628
- 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."""
629
649
  host = (urlparse(url).hostname or "").lower()
630
650
  if not host:
631
651
  return False
632
- if self._is_idp_host(host) or self._is_federation_chooser(url):
652
+ if self._is_idp_host(host):
633
653
  return True
634
654
  school = self._school_domain(result)
635
- 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)
636
660
 
637
661
  def _stuck_on_school_site(self, page: Any, result: DownloadResult) -> str:
638
662
  """The page's URL when the login has stayed on a page of the school's own
@@ -789,7 +813,9 @@ class LoginMixin:
789
813
  for (const el of document.querySelectorAll('button, [role="button"], a')) {
790
814
  if (!shown(el) || el.closest('form')) continue;
791
815
  const label = (el.innerText || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
792
- 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;
793
819
  const href = (el.getAttribute('href') || '').trim();
794
820
  if (el.tagName === 'A' && href && !href.startsWith('#') && !/^javascript:/i.test(href)
795
821
  && !TOGGLES.some(name => el.hasAttribute(name))) continue;
@@ -1143,6 +1169,26 @@ class LoginMixin:
1143
1169
  return True
1144
1170
  return self.adapter.is_article_page_url(url) is True
1145
1171
 
1172
+ def _page_wait_over(self, page: Any, outside_since: float) -> tuple[bool, float]:
1173
+ """Whether a login wait has been off the user's own login step for a whole
1174
+ page wait, and when that step was last seen (now, when on it)."""
1175
+ now = time.time()
1176
+ if self._users_login_step(page):
1177
+ return False, now
1178
+ return now - outside_since >= self.page_wait_sec, outside_since
1179
+
1180
+ def _users_login_step(self, page: Any) -> bool:
1181
+ """The user's own step of the login, which may take the long wait
1182
+ (``login_timeout_sec``): the school's login page (a password field, or the
1183
+ school's identity provider), or a school chooser when no school is
1184
+ configured or the user chooses by hand. Any other page off the publisher's
1185
+ site is not: Atypon's iam.atypon.com hub, where AHA's login stopped on
1186
+ 2026-11-03, was taken for the school's page and waited three minutes."""
1187
+ url = str(getattr(page, "url", "") or "")
1188
+ if self._school_password_box(page, url) or self._is_idp_host(urlparse(url).netloc):
1189
+ return True
1190
+ return self._is_auth_page(url) and (not self.institution_query or bool(getattr(self, "_manual_school", False)))
1191
+
1146
1192
  def _login_wait_page(self, page: Any) -> str:
1147
1193
  """Where a login that waited on the user ended (ADR 0012):
1148
1194
 
@@ -1155,7 +1201,9 @@ class LoginMixin:
1155
1201
  if page.is_closed() is True:
1156
1202
  return "closed"
1157
1203
  url = str(getattr(page, "url", "") or "")
1158
- if self._is_human_login_page(page):
1204
+ # The school's own page, by the same test as the long wait: a publisher's
1205
+ # SSO hub (iam.atypon.com) is no school login (2026-11-03).
1206
+ if self._school_password_box(page, url) or self._is_idp_host(urlparse(url).netloc):
1159
1207
  return "school_login"
1160
1208
  if self._is_auth_page(url):
1161
1209
  return "institution_chooser" if self.institution_query else "school_choice"
@@ -1165,13 +1213,17 @@ class LoginMixin:
1165
1213
  current_url = str(getattr(page, "url", "") or "")
1166
1214
  host = (urlparse(current_url).netloc or "").lower()
1167
1215
  if self.config.get("publisher_institution_federation") == "carsi":
1168
- if (page.locator('input[type="password"]:visible, input[autocomplete="one-time-code"]:visible').count()
1169
- and not self._personal_login_box(page, current_url)):
1216
+ if self._school_password_box(page, current_url):
1170
1217
  return True
1171
1218
  if not self._on_publisher_host(current_url):
1172
1219
  return not self._is_federation_chooser(current_url)
1173
1220
  return self._is_idp_host(host)
1174
1221
 
1222
+ def _school_password_box(self, page: Any, url: str) -> bool:
1223
+ """A password or one-time-code field that is not a personal account's box on the publisher's site."""
1224
+ return bool(page.locator('input[type="password"]:visible, input[autocomplete="one-time-code"]:visible').count()
1225
+ and not self._personal_login_box(page, url))
1226
+
1175
1227
  def _personal_login_box(self, page: Any, url: str) -> bool:
1176
1228
  """The password field is a personal account's login box on the publisher's
1177
1229
  own article page, nobody typing in it: BMJ shows one next to "Log in via
@@ -1339,8 +1391,9 @@ class LoginMixin:
1339
1391
  def _dismiss_cookie_banners(self, page: Any, result: DownloadResult | None = None, *,
1340
1392
  blocking: bool = False) -> bool:
1341
1393
  """Answer a cookie banner (``_COOKIE_BANNER_JS``) in the page or one of its
1342
- frames, at most ``BANNER_ANSWERS`` times per banner and page, never on the
1343
- 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``).
1344
1397
  ``blocking``: the banner covered a control the engine clicked. An
1345
1398
  advertisement over the page is closed first (``_close_advertisement_overlay``).
1346
1399
  True when either was done."""
@@ -1361,12 +1414,14 @@ class LoginMixin:
1361
1414
  pass
1362
1415
  for target in targets:
1363
1416
  where = str(getattr(target, "url", "") or "").split("#", 1)[0]
1364
- if self._school_or_federation_page(where, item):
1417
+ if self._school_or_federation_page(where, item, include_publisher_chooser=False):
1365
1418
  continue
1366
1419
  skip = [key for (url, key), count in answered.items() if url == where and count >= self.BANNER_ANSWERS]
1367
1420
  try:
1368
1421
  clicked = target.evaluate(self._COOKIE_BANNER_JS, {"blocking": bool(blocking), "skip": skip})
1369
- except Exception:
1422
+ except Exception as exc:
1423
+ if not _is_browser_error(exc):
1424
+ raise
1370
1425
  continue # a frame gone meanwhile; the banner is a convenience, never the item's end
1371
1426
  if not isinstance(clicked, dict):
1372
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:
@@ -35,7 +36,16 @@ class UserWaitMixin:
35
36
  return self._wait_for_challenge(page, result, deadline=deadline)
36
37
  return self._wait_for_challenge(page, result)
37
38
 
38
- SUSPECT_BLOCK_WAIT_SEC = 180
39
+ # A wait off the school's login page (verification, the publisher's pages and
40
+ # choosers, the PDF step): config ``browser_page_wait_timeout``, else this.
41
+ PAGE_WAIT_SEC = 60
42
+
43
+ @property
44
+ def page_wait_sec(self) -> float:
45
+ """How long a wait that is not the user's own login step may last; the
46
+ school's login page has ``login_timeout_sec`` (user's choice, 2026-11-03)."""
47
+ value = (getattr(self, "config", None) or {}).get("browser_page_wait_timeout")
48
+ return self.PAGE_WAIT_SEC if value is None else max(10.0, float(value))
39
49
 
40
50
  @staticmethod
41
51
  def _visible_text_excerpt(body: str | bytes | None, limit: int = 600) -> str:
@@ -103,7 +113,11 @@ class UserWaitMixin:
103
113
 
104
114
  def _wait_out_suspect_block(self, page: Any, result: DownloadResult, captured: dict[str, Any], suspect: str,
105
115
  suspect_host: str, started: float) -> bool:
106
- deadline = started + min(max(self.login_timeout_sec, 30), self.SUSPECT_BLOCK_WAIT_SEC)
116
+ deadline = started + self.page_wait_sec
117
+ automatic: dict[str, Any] = {}
118
+ interaction = ExitStack()
119
+ manual = False
120
+ state = ""
107
121
  reporter = None
108
122
  attention_key = f"pdf-step:{id(self)}:{result.doi}"
109
123
  try:
@@ -147,6 +161,8 @@ class UserWaitMixin:
147
161
  if self._pdf_step_needs_login(candidate):
148
162
  state, tab = "login", candidate
149
163
  break
164
+ except (watchdog.BrowserUnresponsive, UserMarkedNoEntitlement):
165
+ raise
150
166
  except Exception:
151
167
  continue
152
168
  if not state and time.time() - started < self.SUSPECT_UNKNOWN_GRACE_SEC:
@@ -158,8 +174,8 @@ class UserWaitMixin:
158
174
  except Exception:
159
175
  continue
160
176
  if not state:
161
- if shown:
162
- 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")
163
179
  captured["suspect_block"] = None
164
180
  return True # the capture loop retries the PDF now
165
181
  if state == "login" and tab == page:
@@ -170,6 +186,13 @@ class UserWaitMixin:
170
186
  self._event(result, "pdf_step_login_required", str(getattr(page, "url", "") or ""))
171
187
  captured["suspect_block"] = None
172
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
173
196
  if state != shown:
174
197
  if state in ("verification", "login"):
175
198
  deadline = min(deadline, self._wait_deadline(result))
@@ -189,14 +212,19 @@ class UserWaitMixin:
189
212
  except Exception:
190
213
  pass
191
214
  shown = state
215
+ if not manual:
216
+ interaction.enter_context(watchdog.pause_operation())
217
+ manual = True
192
218
  try:
193
219
  _observe_preview(page) # what the user is asked to act on
194
- 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
195
223
  except Exception:
196
- time.sleep(1)
197
- if shown in ("verification", "login"):
224
+ time.sleep(min(1, max(0, deadline - time.time())))
225
+ if state in ("verification", "login"):
198
226
  self._wait_ran_out(result)
199
- if shown == "verification":
227
+ if state == "verification":
200
228
  # The verification page stayed up: nobody completed it. That
201
229
  # is a timeout, not evidence that the publisher blocked us.
202
230
  captured["block_reason"] = "challenge_or_viewer_timeout"
@@ -206,6 +234,7 @@ class UserWaitMixin:
206
234
  self._event(result, "ip_blocked_response", f"{suspect} | no PDF after waiting ({shown or 'nothing shown'})")
207
235
  return False
208
236
  finally:
237
+ interaction.close()
209
238
  if shown and reporter is not None:
210
239
  try:
211
240
  reporter.clear_attention(attention_key)
@@ -302,9 +331,10 @@ class UserWaitMixin:
302
331
  return deadline
303
332
 
304
333
  def _wait_deadline(self, result: DownloadResult, deadline: float | None = None) -> float:
305
- """End of a wait on the user that starts now: the caller's bound, the
306
- article's total, and when the user seems away the short wait."""
307
- bounds = [self._user_wait_deadline(result)]
334
+ """End of a wait on the user that starts now, off the school's login page
335
+ (verification pages): the caller's bound, the article's total, the page
336
+ wait, and when the user seems away the short wait."""
337
+ bounds = [self._user_wait_deadline(result), time.time() + self.page_wait_sec]
308
338
  if deadline is not None:
309
339
  bounds.append(deadline)
310
340
  if self._user_away:
@@ -317,8 +347,43 @@ class UserWaitMixin:
317
347
  result._user_wait_expired = True
318
348
  self._event(result, "user_wait_expired", "short wait" if self._user_away else "")
319
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
+
320
382
  def _wait_for_challenge(self, page: Any, result: DownloadResult, *, deadline: float | None = None) -> bool:
321
383
  """Wait while a verification page is up, until ``_wait_deadline``."""
384
+ interaction = ExitStack()
385
+ automatic: dict[str, Any] = {}
386
+ manual = False
322
387
  wait_interval_sec = 1 # a cleared verification goes on within a second
323
388
  reporter = None
324
389
  attention_key = f"publisher-challenge:{id(self)}:{result.doi}"
@@ -342,6 +407,14 @@ class UserWaitMixin:
342
407
  if not waited:
343
408
  deadline = self._wait_deadline(result, deadline)
344
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
345
418
  self._event(result, "challenge_manual_wait", "complete verification in visible browser")
346
419
  if not any(s["why"] == "waiting for the user: verification" for s in result.snapshots):
347
420
  self._snapshot(page, result, "waiting for the user: verification")
@@ -353,10 +426,8 @@ class UserWaitMixin:
353
426
  phase="人工验证",
354
427
  )
355
428
  attention_open = True
356
- waited = True
357
- result.state = "challenge_or_viewer_timeout"
358
429
  checks += 1
359
- self._event(result, "challenge_wait", str(checks))
430
+ self._event(result, "challenge_wait", f"{'manual' if manual else 'automatic'} {checks}")
360
431
  # Pump browser events while the user completes verification.
361
432
  # Plain sleep can leave sync-API navigation state stale.
362
433
  wait_ms = min(wait_interval_sec * 1000, max(0, (deadline - time.time()) * 1000))
@@ -379,6 +450,7 @@ class UserWaitMixin:
379
450
  self._wait_ran_out(result)
380
451
  return passed
381
452
  finally:
453
+ interaction.close()
382
454
  if waited:
383
455
  self._phase(result, phase_before or "open")
384
456
  self._note_wait(result, "verification", started, "passed" if result.state != "challenge_or_viewer_timeout"
@@ -387,45 +459,23 @@ class UserWaitMixin:
387
459
  reporter.clear_attention(attention_key)
388
460
 
389
461
  def _challenge_shell(self, page: Any) -> bool:
390
- """A Cloudflare interstitial recognised from title and URL alone.
391
-
392
- Scripts reading the challenge page (innerText, DOM queries) can make
393
- Cloudflare fail and re-issue the check (``__cf_chl_rt_tk``) in a loop;
394
- an untouched page passes. Callers waiting on a challenge use this and
395
- nothing else.
396
- """
462
+ """Read only URL/title while a verification interstitial is active."""
397
463
  try:
398
464
  url = str(getattr(page, "url", "") or "")
399
465
  except Exception:
400
466
  url = ""
401
- return looks_like_challenge(url, self._title(page))
467
+ return challenges.looks_like_challenge(url, self._title(page))
402
468
 
403
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
+ """
404
475
  if self._challenge_shell(page):
405
476
  return True
406
477
  if self._publisher_access_blocked(page):
407
478
  return False
408
- # Silverchair sites (OUP and others) send unusual traffic to their own
409
- # reCAPTCHA page, /crawlprevention/governor?content=<the article> ("Validate
410
- # User", 2026-11-01); it leads back to the article once the user passes it.
411
- if "/crawlprevention/" in urlparse(str(getattr(page, "url", "") or "")).path.lower():
479
+ if challenges.is_challenge_path(str(getattr(page, "url", "") or "")):
412
480
  return True
413
- haystack = f"{self._title(page)} {self._body_text(page, 1_200)}".lower()
414
- direct_markers = (
415
- "just a moment",
416
- "attention required",
417
- "verify you are human",
418
- "checking your browser",
419
- "are you a robot",
420
- "please confirm you are a human",
421
- "confirm that you are not a robot",
422
- )
423
- if any(marker in haystack for marker in direct_markers):
424
- return True
425
- return "cloudflare" in haystack and (
426
- "ray id:" in haystack
427
- or "security verification" in haystack
428
- or "security service" in haystack
429
- or "not a robot" in haystack
430
- or "瀹夊叏楠岃瘉" in haystack
431
- )
481
+ return challenges.has_challenge_text(self._title(page), self._body_text(page, 1_200))