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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Resolve DOI-bound Figshare supplementary articles/collections via the public API.
|
|
2
|
+
|
|
3
|
+
Works across publishers; never searches Figshare by title or accepts a reference
|
|
4
|
+
as proof that an unrelated dataset belongs to the paper.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote, urlparse
|
|
12
|
+
|
|
13
|
+
from ...base import SupplementLink
|
|
14
|
+
from ....browser.watchdog import BrowserUnresponsive
|
|
15
|
+
from ....entitlements import UserMarkedNoEntitlement
|
|
16
|
+
|
|
17
|
+
_API = "https://api.figshare.com/v2"
|
|
18
|
+
_MAX_RESOURCES = 5
|
|
19
|
+
_MAX_ARTICLES = 50
|
|
20
|
+
_MAX_BYTES = 200 * 1024 * 1024
|
|
21
|
+
_BUDGET_SEC = 30
|
|
22
|
+
_PAGE_LINKS = """() => ({
|
|
23
|
+
doi: document.querySelector('meta[name="citation_doi"]')?.content || '',
|
|
24
|
+
// A cited dataset is not a supplement of the article citing it.
|
|
25
|
+
links: [...document.querySelectorAll('a[href], iframe[src]')]
|
|
26
|
+
.filter(a => !a.closest('[role="doc-bibliography"], [role="doc-biblioentry"], '
|
|
27
|
+
+ '#references, #bibliography, .ref-list, .article-section__references'))
|
|
28
|
+
.map(a => a.href || a.src)
|
|
29
|
+
})"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _doi(value: Any) -> str:
|
|
33
|
+
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", str(value or "").strip(), flags=re.I).lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resource(url: str) -> tuple[str, str, str] | None:
|
|
37
|
+
"""Return kind/id/version only for official Figshare URLs and DOI links."""
|
|
38
|
+
parsed = urlparse(url)
|
|
39
|
+
if parsed.scheme not in {"http", "https"} or parsed.username or parsed.password:
|
|
40
|
+
return None
|
|
41
|
+
host, path = (parsed.hostname or "").lower(), unquote(parsed.path)
|
|
42
|
+
if host in {"doi.org", "dx.doi.org"}:
|
|
43
|
+
# Publishers can mint their own Figshare DOI prefixes, e.g.
|
|
44
|
+
# 10.60893/figshare.adv.c.8713366 (AIP). Check the returned resource
|
|
45
|
+
# DOI below as well as the parent paper DOI; the numeric ID alone
|
|
46
|
+
# is never evidence that this landing link denotes that resource.
|
|
47
|
+
stem = r"/10\.\d{4,9}/(?:m9\.)?figshare(?:\.[a-z][a-z0-9_-]*)*"
|
|
48
|
+
for kind, suffix in (("collections", r"\.c\."), ("articles", r"\.")):
|
|
49
|
+
m = re.fullmatch(stem + suffix + r"(\d+)(?:\.v(\d+))?/?", path, re.I)
|
|
50
|
+
if m:
|
|
51
|
+
return kind, m[1], m[2] or ""
|
|
52
|
+
return None
|
|
53
|
+
if host != "figshare.com" and not host.endswith(".figshare.com"):
|
|
54
|
+
return None
|
|
55
|
+
if host == "widgets.figshare.com":
|
|
56
|
+
widget = re.fullmatch(r"/articles/(\d+)/embed/?", path)
|
|
57
|
+
if widget:
|
|
58
|
+
return "articles", widget[1], ""
|
|
59
|
+
m = re.fullmatch(r"/(articles|collections)/(?:[^/]+/)*(\d+)(?:/(\d+))?/?", path)
|
|
60
|
+
if not m:
|
|
61
|
+
return None
|
|
62
|
+
# A numeric last segment after the resource ID denotes a pinned version.
|
|
63
|
+
parts = path.strip('/').split('/')
|
|
64
|
+
if len(parts) >= 3 and parts[-1].isdigit() and parts[-2].isdigit():
|
|
65
|
+
return parts[0], parts[-2], parts[-1]
|
|
66
|
+
return m[1], m[2], m[3] or ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _belongs(data: dict, doi: str) -> bool:
|
|
70
|
+
resource_doi = _doi(data.get("resource_doi"))
|
|
71
|
+
if resource_doi:
|
|
72
|
+
return resource_doi == doi
|
|
73
|
+
return any(isinstance(item, dict) and item.get("relation") == "IsSupplementTo"
|
|
74
|
+
and _doi(item.get("identifier")) == doi
|
|
75
|
+
for item in data.get("related_materials") or [])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def supplement_links(page: Any, doi: str, *, clock=time.monotonic, pause=time.sleep) -> list[SupplementLink] | None:
|
|
79
|
+
"""None means no Figshare route on this article; failures stay in the manifest."""
|
|
80
|
+
data = page.evaluate(_PAGE_LINKS)
|
|
81
|
+
if not isinstance(data, dict) or _doi(data.get("doi")) != _doi(doi):
|
|
82
|
+
return None
|
|
83
|
+
roots: dict[tuple[str, str, str], str] = {}
|
|
84
|
+
for url in data.get("links", []):
|
|
85
|
+
if isinstance(url, str) and (key := resource(url)):
|
|
86
|
+
roots.setdefault(key, url)
|
|
87
|
+
if not roots:
|
|
88
|
+
return None
|
|
89
|
+
started = clock()
|
|
90
|
+
links: list[SupplementLink] = []
|
|
91
|
+
fetched: dict[str, Any] = {}
|
|
92
|
+
last_request: float | None = None
|
|
93
|
+
doi = _doi(doi)
|
|
94
|
+
|
|
95
|
+
def get(path: str) -> Any:
|
|
96
|
+
nonlocal last_request
|
|
97
|
+
if path in fetched:
|
|
98
|
+
return fetched[path]
|
|
99
|
+
if last_request is not None:
|
|
100
|
+
pause(max(0, 1 - (clock() - last_request)))
|
|
101
|
+
remaining = _BUDGET_SEC - (clock() - started)
|
|
102
|
+
if remaining <= 0:
|
|
103
|
+
raise ValueError("figshare_time_budget")
|
|
104
|
+
last_request = clock()
|
|
105
|
+
response = page.context.request.get(_API + path, timeout=min(10_000, max(1, int(remaining * 1000))))
|
|
106
|
+
if not response.ok:
|
|
107
|
+
raise ValueError(f"figshare_http_{response.status}")
|
|
108
|
+
value = response.json()
|
|
109
|
+
fetched[path] = value
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
for index, ((kind, ident, version), root) in enumerate(roots.items()):
|
|
113
|
+
if index >= _MAX_RESOURCES:
|
|
114
|
+
links.append(SupplementLink(root, "Figshare resources", error="figshare_resource_limit"))
|
|
115
|
+
break
|
|
116
|
+
try:
|
|
117
|
+
base = f"/{kind}/{ident}" + (f"/versions/{version}" if version else "")
|
|
118
|
+
detail = get(base)
|
|
119
|
+
if not isinstance(detail, dict) or str(detail.get("id")) != ident:
|
|
120
|
+
raise ValueError("figshare_invalid_metadata")
|
|
121
|
+
if urlparse(root).hostname in {"doi.org", "dx.doi.org"}:
|
|
122
|
+
requested = unquote(urlparse(root).path).strip("/").lower()
|
|
123
|
+
actual = _doi(detail.get("doi"))
|
|
124
|
+
if (requested != actual if version else
|
|
125
|
+
re.sub(r"\.v\d+$", "", requested) != re.sub(r"\.v\d+$", "", actual)):
|
|
126
|
+
raise ValueError("figshare_resource_doi_mismatch")
|
|
127
|
+
if not _belongs(detail, doi):
|
|
128
|
+
raise ValueError("figshare_article_mismatch")
|
|
129
|
+
articles = [detail]
|
|
130
|
+
if kind == "collections":
|
|
131
|
+
# Fetch one more than the collector limit to report truncation.
|
|
132
|
+
members = get(base + f"/articles?page_size={_MAX_ARTICLES + 1}&page=1")
|
|
133
|
+
if not isinstance(members, list):
|
|
134
|
+
raise ValueError("figshare_invalid_metadata")
|
|
135
|
+
articles = []
|
|
136
|
+
for member in members[:_MAX_ARTICLES]:
|
|
137
|
+
article_id = member.get("id") if isinstance(member, dict) else None
|
|
138
|
+
if not isinstance(article_id, int) or article_id <= 0:
|
|
139
|
+
raise ValueError("figshare_invalid_metadata")
|
|
140
|
+
member_version = member.get("version")
|
|
141
|
+
suffix = f"/versions/{member_version}" if isinstance(member_version, int) and member_version > 0 else ""
|
|
142
|
+
item = get(f"/articles/{article_id}{suffix}")
|
|
143
|
+
if not isinstance(item, dict) or item.get("id") != article_id:
|
|
144
|
+
raise ValueError("figshare_invalid_metadata")
|
|
145
|
+
# Collection association is sufficient only if the member does
|
|
146
|
+
# not explicitly contradict it; its resource DOI takes precedence.
|
|
147
|
+
if item.get("resource_doi") and _doi(item["resource_doi"]) != doi:
|
|
148
|
+
raise ValueError("figshare_article_mismatch")
|
|
149
|
+
if any(isinstance(rel, dict) and rel.get("relation") == "IsSupplementTo"
|
|
150
|
+
and _doi(rel.get("identifier")) != doi
|
|
151
|
+
for rel in item.get("related_materials") or []):
|
|
152
|
+
raise ValueError("figshare_article_mismatch")
|
|
153
|
+
articles.append(item)
|
|
154
|
+
if len(members) > _MAX_ARTICLES:
|
|
155
|
+
links.append(SupplementLink(root, "Figshare collection", error="figshare_article_limit"))
|
|
156
|
+
count = 0
|
|
157
|
+
for article in articles:
|
|
158
|
+
files = article.get("files")
|
|
159
|
+
if not isinstance(files, list):
|
|
160
|
+
raise ValueError("figshare_invalid_metadata")
|
|
161
|
+
for file in files:
|
|
162
|
+
if not isinstance(file, dict) or not isinstance(file.get("id"), int) or file["id"] <= 0:
|
|
163
|
+
raise ValueError("figshare_invalid_file")
|
|
164
|
+
file_id = file["id"]
|
|
165
|
+
# Construct the documented public download endpoint; never
|
|
166
|
+
# follow arbitrary external URLs supplied by link-only datasets.
|
|
167
|
+
url = f"https://api.figshare.com/v2/file/download/{file_id}"
|
|
168
|
+
name = str(file.get("name") or f"Figshare file {file_id}")
|
|
169
|
+
error = ""
|
|
170
|
+
if file.get("is_link_only"):
|
|
171
|
+
error = "figshare_link_only"
|
|
172
|
+
elif not isinstance(file.get("size"), int) or file["size"] < 0:
|
|
173
|
+
error = "figshare_invalid_file"
|
|
174
|
+
elif file["size"] > _MAX_BYTES:
|
|
175
|
+
error = "too_large"
|
|
176
|
+
links.append(SupplementLink(url, name, error=error))
|
|
177
|
+
count += 1
|
|
178
|
+
if count == 0:
|
|
179
|
+
raise ValueError("figshare_no_files")
|
|
180
|
+
except (BrowserUnresponsive, UserMarkedNoEntitlement):
|
|
181
|
+
raise
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
reason = str(exc) if isinstance(exc, ValueError) and str(exc).startswith("figshare_") else "figshare_request_failed"
|
|
184
|
+
links.append(SupplementLink(root, "Figshare supplementary material", error=reason))
|
|
185
|
+
return list(dict.fromkeys(links))
|
|
@@ -16,7 +16,7 @@ from typing import Any
|
|
|
16
16
|
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
|
17
17
|
|
|
18
18
|
from ...engine.router import is_thieme_article_pdf_url
|
|
19
|
-
from ...engine.batch_common import open_page
|
|
19
|
+
from ...engine.batch_common import _is_browser_error, open_page
|
|
20
20
|
from ..base import PublisherAdapter, register
|
|
21
21
|
|
|
22
22
|
_HOSTS = frozenset({"thieme-connect.de", "www.thieme-connect.de", "thieme-connect.com", "www.thieme-connect.com"})
|
|
@@ -152,13 +152,14 @@ class ThiemeAdapter(PublisherAdapter):
|
|
|
152
152
|
button = page.locator(
|
|
153
153
|
"button:has-text('Continue'), input[type='submit'][value='Continue']").first
|
|
154
154
|
try:
|
|
155
|
-
if button.is_visible(timeout=1_500) is True
|
|
156
|
-
|
|
155
|
+
if (button.is_visible(timeout=1_500) is True
|
|
156
|
+
and d._click_control(page, button, result, "Thieme federation Continue")):
|
|
157
157
|
result._federation_continue_at = time.time()
|
|
158
158
|
d._event(result, "institution_federation_continue", "carsi")
|
|
159
159
|
return True
|
|
160
|
-
except Exception:
|
|
161
|
-
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
if not _is_browser_error(exc):
|
|
162
|
+
raise
|
|
162
163
|
if not any(event["state"] == "institution_selection_required" for event in result.events):
|
|
163
164
|
d._event(result, "institution_selection_required", "carsi")
|
|
164
165
|
return changed
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Browser support for paper-search-carsi.
|
|
2
|
+
|
|
3
|
+
backend: browser selection, launch and context lifecycle.
|
|
4
|
+
profiles: persistent session state and profile ownership.
|
|
5
|
+
challenges: verification evidence predicates, with no browser or task state.
|
|
6
|
+
watchdog: browser-call cancellation and generic operation budgets.
|
|
7
|
+
engine/login/cookies/preview: browser operations and application integration.
|
|
8
|
+
"""
|
|
@@ -27,13 +27,27 @@ from __future__ import annotations
|
|
|
27
27
|
import json
|
|
28
28
|
import logging
|
|
29
29
|
import os
|
|
30
|
-
import re
|
|
31
30
|
import shutil
|
|
32
31
|
import sys
|
|
33
32
|
import time
|
|
34
33
|
from pathlib import Path
|
|
35
34
|
from typing import Any
|
|
36
35
|
|
|
36
|
+
# Compatibility exports: existing launchers and cold-start tooling use these names.
|
|
37
|
+
# Their implementation is owned solely by profiles.py.
|
|
38
|
+
from .profiles import (
|
|
39
|
+
PROFILE_RELEASE_TIMEOUT_SEC,
|
|
40
|
+
SESSION_COOKIES_FILE,
|
|
41
|
+
SESSION_COOKIES_MAX_AGE_SEC,
|
|
42
|
+
_lock_file_held,
|
|
43
|
+
_pid_alive,
|
|
44
|
+
_profile_owner_pid,
|
|
45
|
+
_restore_session_cookies,
|
|
46
|
+
_save_session_cookies,
|
|
47
|
+
profile_holder,
|
|
48
|
+
wait_for_profile_release,
|
|
49
|
+
)
|
|
50
|
+
|
|
37
51
|
logger = logging.getLogger(__name__)
|
|
38
52
|
|
|
39
53
|
BACKEND_CAMOUFOX = "camoufox"
|
|
@@ -450,7 +464,14 @@ def _camoufox_options(headless: bool, proxy: Any, args: list[str] | None, humani
|
|
|
450
464
|
# comes back as 304 Not Modified, without a body to capture (Springer,
|
|
451
465
|
# 2026-10-31). The browser starts anew for every article, so its memory
|
|
452
466
|
# cache never holds an earlier article's PDF.
|
|
453
|
-
|
|
467
|
+
# Camoufox 156 with Fission loses cross-process Turnstile frames and sends
|
|
468
|
+
# input to their parent container. Keep CARSI's owned automation browser
|
|
469
|
+
# compatible with Juggler; explicit caller preferences still take priority.
|
|
470
|
+
opts["firefox_user_prefs"] = {
|
|
471
|
+
"browser.cache.disk.enable": False,
|
|
472
|
+
"fission.autostart": False,
|
|
473
|
+
**(opts.get("firefox_user_prefs") or {}),
|
|
474
|
+
}
|
|
454
475
|
opts.update(headless=headless, humanize=humanize, proxy=proxy)
|
|
455
476
|
pinned = pinned_camoufox_dir()
|
|
456
477
|
if pinned is not None and "executable_path" not in opts:
|
|
@@ -535,54 +556,6 @@ def _launch_camoufox_persistent(user_data_dir: str, headless: bool, proxy: Any,
|
|
|
535
556
|
return _close_with_driver(context, pw)
|
|
536
557
|
|
|
537
558
|
|
|
538
|
-
SESSION_COOKIES_FILE = ".paper-search-session-cookies.json"
|
|
539
|
-
SESSION_COOKIES_MAX_AGE_SEC = 12 * 3600
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
def _save_session_cookies(context: Any, profile: Path) -> None:
|
|
543
|
-
"""Keep session cookies (IdP and publisher logins) for the next launch.
|
|
544
|
-
|
|
545
|
-
Each paper runs in a new backend process and browser; Firefox drops
|
|
546
|
-
cookies without an expiry when it closes, so every paper asked for the
|
|
547
|
-
school login again. Only session cookies are kept: persistent ones are in
|
|
548
|
-
the profile already. The file is readable by the user only.
|
|
549
|
-
"""
|
|
550
|
-
try:
|
|
551
|
-
cookies = [c for c in context.cookies() if not c.get("expires") or c.get("expires", -1) < 0]
|
|
552
|
-
except Exception:
|
|
553
|
-
return
|
|
554
|
-
path = profile / SESSION_COOKIES_FILE
|
|
555
|
-
try:
|
|
556
|
-
if not cookies:
|
|
557
|
-
path.unlink(missing_ok=True)
|
|
558
|
-
return
|
|
559
|
-
tmp = path.with_suffix(".tmp")
|
|
560
|
-
tmp.write_text(json.dumps({"saved_at": time.time(), "cookies": cookies}),
|
|
561
|
-
encoding="utf-8")
|
|
562
|
-
os.chmod(tmp, 0o600)
|
|
563
|
-
os.replace(tmp, path)
|
|
564
|
-
except Exception as exc:
|
|
565
|
-
logger.debug("browser_backend: session cookies not saved: %s", type(exc).__name__)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
def _restore_session_cookies(context: Any, profile: Path) -> int:
|
|
569
|
-
path = profile / SESSION_COOKIES_FILE
|
|
570
|
-
try:
|
|
571
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
572
|
-
except Exception:
|
|
573
|
-
return 0
|
|
574
|
-
if time.time() - float(data.get("saved_at", 0)) > SESSION_COOKIES_MAX_AGE_SEC:
|
|
575
|
-
return 0
|
|
576
|
-
restored = 0
|
|
577
|
-
for cookie in data.get("cookies") or []:
|
|
578
|
-
try:
|
|
579
|
-
context.add_cookies([{k: v for k, v in cookie.items() if k != "expires" or v not in (None, -1)}])
|
|
580
|
-
restored += 1
|
|
581
|
-
except Exception:
|
|
582
|
-
continue
|
|
583
|
-
return restored
|
|
584
|
-
|
|
585
|
-
|
|
586
559
|
def camoufox_profile_dir(user_data_dir: str | Path) -> Path:
|
|
587
560
|
"""Camoufox's own profile directory next to the given (Chromium-era) one."""
|
|
588
561
|
path = Path(user_data_dir)
|
|
@@ -612,108 +585,11 @@ def launch(
|
|
|
612
585
|
return _launch_camoufox(headless=headless, proxy=proxy, args=args, humanize=humanize, **kwargs)
|
|
613
586
|
|
|
614
587
|
|
|
615
|
-
PROFILE_RELEASE_TIMEOUT_SEC = 20.0
|
|
616
588
|
# The last Camoufox launch of this process, for the article's record: who held
|
|
617
589
|
# the profile, how long the launch waited for it, whether it was released.
|
|
618
590
|
LAST_LAUNCH: dict[str, Any] = {}
|
|
619
591
|
|
|
620
592
|
|
|
621
|
-
def _profile_owner_pid(user_data_dir: str) -> int | None:
|
|
622
|
-
"""PID of the browser holding this profile, if any.
|
|
623
|
-
|
|
624
|
-
Firefox (Camoufox) leaves a ``lock`` symlink to ``<host>:+<pid>``;
|
|
625
|
-
Chromium leaves ``SingletonLock`` to ``<host>-<pid>``.
|
|
626
|
-
"""
|
|
627
|
-
for name in ("lock", "SingletonLock"):
|
|
628
|
-
try:
|
|
629
|
-
target = os.readlink(Path(user_data_dir) / name)
|
|
630
|
-
except OSError:
|
|
631
|
-
continue
|
|
632
|
-
match = re.search(r"[-+](\d+)$", target)
|
|
633
|
-
if match:
|
|
634
|
-
return int(match.group(1))
|
|
635
|
-
return None
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
def _lock_file_held(path: Path) -> bool:
|
|
639
|
-
"""Another process holds this lock file: Firefox's ``.parentlock`` on macOS
|
|
640
|
-
and Linux (an fcntl lock; macOS has no ``lock`` symlink), its ``parent.lock``
|
|
641
|
-
on Windows (opened without sharing). The lock is tested, never kept."""
|
|
642
|
-
if not path.exists():
|
|
643
|
-
return False
|
|
644
|
-
try:
|
|
645
|
-
fd = os.open(str(path), os.O_RDWR)
|
|
646
|
-
except PermissionError:
|
|
647
|
-
return True # Windows: the running browser has it open
|
|
648
|
-
except OSError:
|
|
649
|
-
return False
|
|
650
|
-
try:
|
|
651
|
-
try:
|
|
652
|
-
import fcntl
|
|
653
|
-
except ImportError:
|
|
654
|
-
return False
|
|
655
|
-
try:
|
|
656
|
-
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
657
|
-
except OSError:
|
|
658
|
-
return True
|
|
659
|
-
fcntl.lockf(fd, fcntl.LOCK_UN)
|
|
660
|
-
return False
|
|
661
|
-
finally:
|
|
662
|
-
os.close(fd)
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
def profile_holder(user_data_dir: str) -> str:
|
|
666
|
-
"""Who still holds this profile ("" = nobody): a live ``lock`` owner, or a
|
|
667
|
-
lock file another process holds."""
|
|
668
|
-
pid = _profile_owner_pid(user_data_dir)
|
|
669
|
-
if pid is not None and _pid_alive(pid):
|
|
670
|
-
return f"lock of pid {pid}"
|
|
671
|
-
for name in (".parentlock", "parent.lock"):
|
|
672
|
-
if _lock_file_held(Path(user_data_dir) / name):
|
|
673
|
-
return f"{name} held"
|
|
674
|
-
return ""
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
def _pid_alive(pid: int) -> bool:
|
|
678
|
-
if pid <= 0 or pid == os.getpid():
|
|
679
|
-
return False
|
|
680
|
-
try:
|
|
681
|
-
os.kill(pid, 0)
|
|
682
|
-
except ProcessLookupError:
|
|
683
|
-
return False
|
|
684
|
-
except PermissionError:
|
|
685
|
-
return True
|
|
686
|
-
except OSError:
|
|
687
|
-
return False
|
|
688
|
-
return True
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def wait_for_profile_release(user_data_dir: str, timeout: float = PROFILE_RELEASE_TIMEOUT_SEC) -> bool:
|
|
692
|
-
"""Wait while a previous browser process still owns this persistent profile.
|
|
693
|
-
|
|
694
|
-
Back-to-back downloads reuse one profile; the previous Chrome can take a
|
|
695
|
-
few seconds to exit after its context closed. Launching meanwhile fails,
|
|
696
|
-
and the fallback launcher then reports a misleading "executable doesn't
|
|
697
|
-
exist". Returns True when the profile is free (or never locked).
|
|
698
|
-
"""
|
|
699
|
-
import time
|
|
700
|
-
|
|
701
|
-
deadline = time.monotonic() + max(0.0, timeout)
|
|
702
|
-
waited = False
|
|
703
|
-
while True:
|
|
704
|
-
if not profile_holder(user_data_dir):
|
|
705
|
-
if waited:
|
|
706
|
-
logger.info("browser_backend: persistent profile released by previous browser")
|
|
707
|
-
return True
|
|
708
|
-
if time.monotonic() >= deadline:
|
|
709
|
-
logger.info("browser_backend: persistent profile still in use by another browser process")
|
|
710
|
-
return False
|
|
711
|
-
if not waited:
|
|
712
|
-
logger.info("browser_backend: waiting for the previous browser to release the profile")
|
|
713
|
-
waited = True
|
|
714
|
-
time.sleep(0.5)
|
|
715
|
-
|
|
716
|
-
|
|
717
593
|
def launch_persistent_context(
|
|
718
594
|
user_data_dir: str,
|
|
719
595
|
*,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""One bounded interaction with an observed Turnstile widget.
|
|
2
|
+
|
|
3
|
+
No navigation, token injection, session transfer or success judgement. The
|
|
4
|
+
engine owns waiting and decides whether verification has actually ended.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_OBSERVE_SCRIPT = """() => {
|
|
15
|
+
const key = Symbol.for('papersearch.turnstile.ready');
|
|
16
|
+
if (window[key]) return;
|
|
17
|
+
const ready = window[key] = new WeakSet();
|
|
18
|
+
addEventListener('message', event => {
|
|
19
|
+
if (event.origin !== 'https://challenges.cloudflare.com' || !event.source
|
|
20
|
+
|| event.data?.source !== 'cloudflare-challenge') return;
|
|
21
|
+
if (event.data.event === 'interactiveBegin') ready.add(event.source);
|
|
22
|
+
else if (['interactiveEnd', 'complete', 'fail', 'tokenExpired']
|
|
23
|
+
.includes(event.data.event)) ready.delete(event.source);
|
|
24
|
+
});
|
|
25
|
+
}"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def observe_checkbox(page: Any) -> None:
|
|
29
|
+
"""Observe readiness only; never read or write the verification response."""
|
|
30
|
+
page.evaluate(_OBSERVE_SCRIPT)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def prepare_context(context: Any) -> None:
|
|
34
|
+
"""Observe each owned page before navigation, including its PDF popups.
|
|
35
|
+
|
|
36
|
+
The ego context filters its pages/events to CARSI-owned tabs. Install per
|
|
37
|
+
page, never on the shared context (which would also affect the user's tabs).
|
|
38
|
+
"""
|
|
39
|
+
if getattr(context, "_papersearch_challenge_observer", False) is True:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
def prepare(page: Any) -> None:
|
|
43
|
+
if getattr(page, "_papersearch_challenge_observer", False) is not True:
|
|
44
|
+
page.add_init_script("(" + _OBSERVE_SCRIPT + ")()")
|
|
45
|
+
page._papersearch_challenge_observer = True
|
|
46
|
+
|
|
47
|
+
open_page = context.new_page
|
|
48
|
+
def new_page(*args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
page = open_page(*args, **kwargs)
|
|
50
|
+
prepare(page)
|
|
51
|
+
return page
|
|
52
|
+
|
|
53
|
+
context.new_page = new_page
|
|
54
|
+
context.on("page", prepare)
|
|
55
|
+
for page in context.pages:
|
|
56
|
+
prepare(page)
|
|
57
|
+
context._papersearch_challenge_observer = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_turnstile_frame(url: str) -> bool:
|
|
61
|
+
parsed = urlsplit(url)
|
|
62
|
+
return (parsed.scheme == "https" and parsed.hostname == "challenges.cloudflare.com"
|
|
63
|
+
and parsed.path.startswith("/cdn-cgi/challenge-platform/")
|
|
64
|
+
and "/turnstile/" in parsed.path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def click_checkbox(page: Any, *, timeout_ms: int, checkpoint: Callable[[], None]) -> Literal[
|
|
68
|
+
"absent", "clicked", "unavailable"
|
|
69
|
+
]:
|
|
70
|
+
"""Try one visible checkbox; never force a click through an overlay.
|
|
71
|
+
|
|
72
|
+
A closed shadow root hides the checkbox from locators. Only the normal,
|
|
73
|
+
compact-height Cloudflare widget with its identifying iframe title can
|
|
74
|
+
use its standard checkbox position. Expanded puzzles are left alone.
|
|
75
|
+
"""
|
|
76
|
+
from playwright.sync_api import Error, TimeoutError
|
|
77
|
+
|
|
78
|
+
until = time.monotonic() + max(0, timeout_ms) / 1000
|
|
79
|
+
for frame in page.frames:
|
|
80
|
+
checkpoint()
|
|
81
|
+
if time.monotonic() >= until:
|
|
82
|
+
return "absent"
|
|
83
|
+
if not is_turnstile_frame(str(frame.url or "")):
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
# An iframe can be present for seconds while only a spinner is up.
|
|
87
|
+
# Start observing before inspecting it, so a later call can act.
|
|
88
|
+
observe_checkbox(page)
|
|
89
|
+
element = frame.frame_element()
|
|
90
|
+
if not element.is_visible():
|
|
91
|
+
continue
|
|
92
|
+
controls = frame.locator('input[type="checkbox"], [role="checkbox"]')
|
|
93
|
+
count = controls.count()
|
|
94
|
+
position = None
|
|
95
|
+
if count:
|
|
96
|
+
if count != 1:
|
|
97
|
+
return "unavailable"
|
|
98
|
+
target = controls.first
|
|
99
|
+
if not target.is_visible() or not target.is_enabled() or target.is_checked():
|
|
100
|
+
return "unavailable"
|
|
101
|
+
else:
|
|
102
|
+
title = (element.get_attribute("title") or "").lower()
|
|
103
|
+
box = element.bounding_box()
|
|
104
|
+
if ("cloudflare" not in title or "challenge" not in title or not box
|
|
105
|
+
or not 250 <= box["width"] <= 350 or not 60 <= box["height"] <= 80):
|
|
106
|
+
return "unavailable"
|
|
107
|
+
if not element.evaluate("""iframe =>
|
|
108
|
+
window[Symbol.for('papersearch.turnstile.ready')]?.has(iframe.contentWindow) === true
|
|
109
|
+
"""):
|
|
110
|
+
return "absent" # loading/automatic check; no click has been attempted
|
|
111
|
+
target = element
|
|
112
|
+
position = {"x": 28, "y": box["height"] / 2}
|
|
113
|
+
checkpoint()
|
|
114
|
+
# One readiness signal permits only one closed-shadow click. Wait
|
|
115
|
+
# for a fresh interactiveBegin before retrying; otherwise a stale
|
|
116
|
+
# signal could toggle an already checked or processing widget.
|
|
117
|
+
if position and not element.evaluate("""iframe =>
|
|
118
|
+
window[Symbol.for('papersearch.turnstile.ready')]?.delete(iframe.contentWindow) === true
|
|
119
|
+
"""):
|
|
120
|
+
return "absent"
|
|
121
|
+
remaining_ms = int((until - time.monotonic()) * 1000)
|
|
122
|
+
if remaining_ms <= 0:
|
|
123
|
+
return "unavailable"
|
|
124
|
+
target.click(timeout=remaining_ms, **({"position": position} if position else {}))
|
|
125
|
+
return "clicked"
|
|
126
|
+
except TimeoutError:
|
|
127
|
+
return "unavailable" # hidden/covered/stale; no repeated forced clicks
|
|
128
|
+
except Error as exc:
|
|
129
|
+
# Navigation can remove the widget before its action. Browser close,
|
|
130
|
+
# crash, watchdog and cancellation errors must still reach the caller.
|
|
131
|
+
if any(text in str(exc).lower() for text in (
|
|
132
|
+
"frame was detached", "frame has been detached", "element is not attached",
|
|
133
|
+
"execution context was destroyed",
|
|
134
|
+
)):
|
|
135
|
+
return "unavailable"
|
|
136
|
+
raise
|
|
137
|
+
return "absent"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Browser-independent verification-page recognition from observed page data.
|
|
2
|
+
|
|
3
|
+
These predicates do not read the browser, wait, report progress or decide
|
|
4
|
+
article access. The caller checks explicit publisher blocks before the broader
|
|
5
|
+
path/text predicates. No match means no recognised challenge, not verified
|
|
6
|
+
access or a successfully completed check.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CHALLENGE_TITLES = ("just a moment", "请稍候")
|
|
14
|
+
_TEXT_MARKERS = (
|
|
15
|
+
"just a moment",
|
|
16
|
+
"attention required",
|
|
17
|
+
"verify you are human",
|
|
18
|
+
"checking your browser",
|
|
19
|
+
"verifying your request",
|
|
20
|
+
"automated-traffic protection",
|
|
21
|
+
"are you a robot",
|
|
22
|
+
"please confirm you are a human",
|
|
23
|
+
"confirm that you are not a robot",
|
|
24
|
+
)
|
|
25
|
+
_CLOUDFLARE_MARKERS = (
|
|
26
|
+
"ray id:",
|
|
27
|
+
"security verification",
|
|
28
|
+
"security service",
|
|
29
|
+
"not a robot",
|
|
30
|
+
"瀹夊叏楠岃瘉",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def looks_like_challenge(url: str, title: str) -> bool:
|
|
35
|
+
"""Recognise an interstitial without requiring a DOM/body read.
|
|
36
|
+
|
|
37
|
+
Reading the body of an active check can interfere with it. In particular,
|
|
38
|
+
'Attention Required! | Cloudflare' is not this lightweight shell: the engine
|
|
39
|
+
must first check whether it is an explicit block.
|
|
40
|
+
"""
|
|
41
|
+
url = str(url or "").lower()
|
|
42
|
+
return ("__cf_chl" in url or "/cdn-cgi/challenge-platform/" in url
|
|
43
|
+
or str(title or "").strip().lower().startswith(CHALLENGE_TITLES))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_challenge_path(url: str) -> bool:
|
|
47
|
+
"""Verification routes, excluding links in queries and article topic paths."""
|
|
48
|
+
path = urlparse(url).path.lower()
|
|
49
|
+
return "/crawlprevention/" in path or path == "/captcha" or path.startswith("/captcha/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_challenge_text(title: str, text: str) -> bool:
|
|
53
|
+
"""Recognise verification wording after explicit access blocks are excluded."""
|
|
54
|
+
haystack = f"{title} {text}".lower()
|
|
55
|
+
return (any(marker in haystack for marker in _TEXT_MARKERS)
|
|
56
|
+
or ("cloudflare" in haystack
|
|
57
|
+
and any(marker in haystack for marker in _CLOUDFLARE_MARKERS)))
|
|
@@ -30,8 +30,8 @@ logger = logging.getLogger(__name__)
|
|
|
30
30
|
# ---------------------------------------------------------------------------
|
|
31
31
|
# Browser backend (Camoufox via Playwright; ego-lite has its own driver)
|
|
32
32
|
# ---------------------------------------------------------------------------
|
|
33
|
-
# Everything in this module drives the Playwright sync API,
|
|
34
|
-
# Camoufox
|
|
33
|
+
# Everything in this module drives the Playwright sync API, shared by both
|
|
34
|
+
# Camoufox and ego-lite. Backend selection lives in backend.py.
|
|
35
35
|
# ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
from .backend import ( # noqa: E402
|