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.
- package/README.en.md +292 -0
- package/README.md +203 -122
- package/carsi/README.md +9 -3
- package/carsi/paper_search_carsi/__init__.py +1 -4
- package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
- package/carsi/paper_search_carsi/adapters/base.py +2 -0
- package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
- package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
- package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
- package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
- package/carsi/paper_search_carsi/browser/__init__.py +8 -1
- package/carsi/paper_search_carsi/browser/backend.py +23 -147
- package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
- package/carsi/paper_search_carsi/browser/challenges.py +57 -0
- package/carsi/paper_search_carsi/browser/engine.py +2 -2
- package/carsi/paper_search_carsi/browser/preview.py +44 -26
- package/carsi/paper_search_carsi/browser/profiles.py +161 -0
- package/carsi/paper_search_carsi/browser/watchdog.py +74 -17
- package/carsi/paper_search_carsi/config.py +4 -1
- package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
- package/carsi/paper_search_carsi/engine/batch.py +140 -69
- package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
- package/carsi/paper_search_carsi/engine/batch_capture.py +54 -9
- package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
- package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
- package/carsi/paper_search_carsi/engine/batch_login.py +85 -30
- package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
- package/carsi/paper_search_carsi/engine/batch_waits.py +97 -47
- package/carsi/paper_search_carsi/engine/bridge.py +17 -8
- package/carsi/paper_search_carsi/engine/supplements.py +28 -5
- package/carsi/paper_search_carsi/entitlements.py +12 -2
- package/carsi/paper_search_carsi/log.py +13 -7
- package/carsi/paper_search_carsi/orchestrator.py +7 -1
- package/carsi/paper_search_carsi/progress_bar.py +156 -12
- package/carsi/paper_search_carsi/server.py +8 -1
- package/carsi/paper_search_carsi/user_settings.py +55 -0
- package/carsi/pyproject.toml +1 -1
- package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
- package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
- package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
- package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/handler.js +6 -0
- package/dist/capabilities/metadata-search/handler.js.map +1 -1
- package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
- package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
- package/dist/capabilities/metadata-search/schemas.js +1 -0
- package/dist/capabilities/metadata-search/schemas.js.map +1 -1
- package/dist/capabilities/metadata-search/tools.js +1 -1
- package/dist/capabilities/metadata-search/tools.js.map +1 -1
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
- package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
- package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
- package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
- package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
- package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
- package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/batch.js +87 -15
- package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
- package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
- package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
- package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
- package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/handler.js +3 -2
- package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
- package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
- package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
- package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
- package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
- package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
- package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
- package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
- package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
- package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
- package/dist/core/handleToolCall.d.ts.map +1 -1
- package/dist/core/handleToolCall.js +12 -0
- package/dist/core/handleToolCall.js.map +1 -1
- package/dist/core/schemas.d.ts +26 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +13 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/searchers.d.ts +2 -0
- package/dist/core/searchers.d.ts.map +1 -1
- package/dist/core/searchers.js.map +1 -1
- package/dist/core/tools.d.ts.map +1 -1
- package/dist/core/tools.js +30 -0
- package/dist/core/tools.js.map +1 -1
- package/dist/infrastructure/arxiv.d.ts +5 -0
- package/dist/infrastructure/arxiv.d.ts.map +1 -0
- package/dist/infrastructure/arxiv.js +36 -0
- package/dist/infrastructure/arxiv.js.map +1 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
- package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
- package/dist/infrastructure/pdf/PdfDownload.js +14 -0
- package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
- package/dist/infrastructure/pdf/PdfFirstPage.cjs +4 -1
- package/dist/infrastructure/pdf/PdfFirstPage.cjs.map +1 -1
- package/dist/infrastructure/pdf/PdfFirstPage.d.cts.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
- package/dist/infrastructure/runlog/RunLedger.js +17 -1
- package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
- package/dist/management/carsi/installer.d.ts +1 -1
- package/dist/platforms/ArxivSearcher.d.ts +62 -0
- package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
- package/dist/platforms/ArxivSearcher.js +544 -0
- package/dist/platforms/ArxivSearcher.js.map +1 -0
- package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
- package/dist/platforms/SemanticScholarSearcher.js +4 -4
- package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
- package/dist/registry/platformFactories.d.ts.map +1 -1
- package/dist/registry/platformFactories.js +2 -0
- package/dist/registry/platformFactories.js.map +1 -1
- package/dist/registry/platformMetadata.d.ts +1 -1
- package/dist/registry/platformMetadata.d.ts.map +1 -1
- package/dist/registry/platformMetadata.js +16 -0
- package/dist/registry/platformMetadata.js.map +1 -1
- package/package.json +2 -2
- package/skills/paper-search/SKILL.md +7 -4
- package/skills/paper-search/references/browser-download.md +10 -6
- package/skills/paper-search/references/capability-routing.md +4 -3
- package/skills/paper-search/references/cli-contract.md +10 -3
- package/skills/paper-search/references/publisher-support.md +6 -4
- package/skills/paper-search/references/troubleshooting.md +1 -1
- package/README.zh.md +0 -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
|
-
|
|
45
|
-
def _title(page: Any) -> str:
|
|
46
|
-
try:
|
|
47
|
-
return str(page.title() or "")
|
|
48
|
-
except Exception:
|
|
49
|
-
return ""
|
|
44
|
+
_title = staticmethod(page_title)
|
|
50
45
|
|
|
51
46
|
@staticmethod
|
|
52
47
|
def _body_text(page: Any, limit: int = 2_000) -> str:
|
|
@@ -54,7 +49,9 @@ class DiagnosticsMixin:
|
|
|
54
49
|
observe_page(page)
|
|
55
50
|
try:
|
|
56
51
|
text = page.locator("body").inner_text(timeout=3_000)
|
|
57
|
-
except Exception:
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
if not _is_browser_error(exc):
|
|
54
|
+
raise
|
|
58
55
|
return ""
|
|
59
56
|
return re.sub(r"\s+", " ", text).strip()[:limit]
|
|
60
57
|
|
|
@@ -92,7 +89,7 @@ class DiagnosticsMixin:
|
|
|
92
89
|
"login_navigation_interrupted": ("登录跳转中断,检查是否可重试", "waiting_login"),
|
|
93
90
|
"login_stopped": ("机构登录未完成", "route_failed"),
|
|
94
91
|
"challenge_manual_wait": ("等待人工安全验证", "waiting_verification"),
|
|
95
|
-
"challenge_wait": ("
|
|
92
|
+
"challenge_wait": ("正在检查安全验证", "checking_access"),
|
|
96
93
|
"challenge_resolved": ("安全验证已结束,继续检查页面", "checking_access"),
|
|
97
94
|
"article_access_ready": ("论文页面已就绪,检查正文入口", "checking_access"),
|
|
98
95
|
"publisher_content_error": ("出版社返回内容服务错误", "route_failed"),
|
|
@@ -101,11 +98,13 @@ class DiagnosticsMixin:
|
|
|
101
98
|
"browser_pdf_response_captured": ("PDF 已捕获,正在保存", "pdf_captured"),
|
|
102
99
|
"pdf_saved": ("PDF 已保存,待内容核验", "saved_pending_verification"),
|
|
103
100
|
}.get(state)
|
|
101
|
+
if state == "challenge_wait" and detail.startswith("manual "):
|
|
102
|
+
report = ("等待人工安全验证", "waiting_verification")
|
|
104
103
|
if state == "login_attempt_started" and detail in {"1/3", "2/3", "3/3"}:
|
|
105
104
|
report = (f"机构登录:第 {detail[0]} 次尝试,共 3 次", "waiting_login")
|
|
106
105
|
if state == "login_observed":
|
|
107
106
|
if detail == "verification":
|
|
108
|
-
report = ("
|
|
107
|
+
report = ("正在检查安全验证", "checking_access")
|
|
109
108
|
elif detail == "human_login":
|
|
110
109
|
report = ("等待在浏览器完成机构登录", "waiting_login")
|
|
111
110
|
else:
|
|
@@ -189,13 +188,19 @@ class DiagnosticsMixin:
|
|
|
189
188
|
"final_url": getattr(page, "url", result.final_url)}, diagnostic=True)
|
|
190
189
|
packet["adapter"] = type(self.adapter).__name__
|
|
191
190
|
packet["browser"] = getattr(self, "browser", {})
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
from ..browser import watchdog
|
|
192
|
+
stopped = bool(watchdog.fired) or result.state in {
|
|
193
|
+
"browser_unresponsive", "browser_operation_timeout", "user_skipped", "batch_stopped", "user_marked_no_entitlement"}
|
|
194
|
+
if stopped:
|
|
195
|
+
packet["access_state"], packet["access_evidence"] = "unknown", ["probe skipped: item stopped"]
|
|
196
|
+
else:
|
|
197
|
+
try:
|
|
198
|
+
packet["access_state"], packet["access_evidence"] = self._access_state(page)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
packet["access_state"], packet["access_evidence"] = "unknown", [f"probe failed: {type(exc).__name__}"]
|
|
196
201
|
packet_path = diag_dir / "diagnostic.json"
|
|
197
202
|
packet_path.write_text(json.dumps(packet, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
198
|
-
if self.adapter.diagnostic_screenshots and result.reason != "publisher_access_blocked":
|
|
203
|
+
if not stopped and self.adapter.diagnostic_screenshots and result.reason != "publisher_access_blocked":
|
|
199
204
|
try:
|
|
200
205
|
page.screenshot(path=str(diag_dir / "screenshot.png"), full_page=True)
|
|
201
206
|
except Exception:
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
628
|
-
|
|
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)
|
|
652
|
+
if self._is_idp_host(host):
|
|
633
653
|
return True
|
|
634
654
|
school = self._school_domain(result)
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1343
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
15
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
|
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
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
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",
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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))
|