papersearch 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.en.md +292 -0
  2. package/README.md +203 -125
  3. package/carsi/README.md +8 -2
  4. package/carsi/paper_search_carsi/__init__.py +1 -4
  5. package/carsi/paper_search_carsi/adapters/__init__.py +1 -1
  6. package/carsi/paper_search_carsi/adapters/base.py +2 -0
  7. package/carsi/paper_search_carsi/adapters/frontiers/__init__.py +45 -0
  8. package/carsi/paper_search_carsi/adapters/mdpi/__init__.py +33 -1
  9. package/carsi/paper_search_carsi/adapters/platforms/figshare/__init__.py +185 -0
  10. package/carsi/paper_search_carsi/adapters/thieme/__init__.py +6 -5
  11. package/carsi/paper_search_carsi/browser/__init__.py +8 -1
  12. package/carsi/paper_search_carsi/browser/backend.py +23 -147
  13. package/carsi/paper_search_carsi/browser/challenge_interaction.py +137 -0
  14. package/carsi/paper_search_carsi/browser/challenges.py +57 -0
  15. package/carsi/paper_search_carsi/browser/engine.py +2 -2
  16. package/carsi/paper_search_carsi/browser/preview.py +44 -26
  17. package/carsi/paper_search_carsi/browser/profiles.py +161 -0
  18. package/carsi/paper_search_carsi/browser/watchdog.py +71 -14
  19. package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
  20. package/carsi/paper_search_carsi/engine/batch.py +129 -68
  21. package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
  22. package/carsi/paper_search_carsi/engine/batch_capture.py +53 -8
  23. package/carsi/paper_search_carsi/engine/batch_common.py +27 -14
  24. package/carsi/paper_search_carsi/engine/batch_diagnostics.py +20 -15
  25. package/carsi/paper_search_carsi/engine/batch_login.py +47 -27
  26. package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
  27. package/carsi/paper_search_carsi/engine/batch_waits.py +82 -42
  28. package/carsi/paper_search_carsi/engine/bridge.py +16 -8
  29. package/carsi/paper_search_carsi/engine/supplements.py +22 -3
  30. package/carsi/paper_search_carsi/log.py +13 -7
  31. package/carsi/paper_search_carsi/orchestrator.py +7 -1
  32. package/carsi/paper_search_carsi/progress_bar.py +39 -18
  33. package/carsi/paper_search_carsi/server.py +8 -1
  34. package/carsi/pyproject.toml +1 -1
  35. package/dist/capabilities/citation-expansion/schemas.d.ts +2 -2
  36. package/dist/capabilities/metadata-search/MultiSourceSearchService.d.ts.map +1 -1
  37. package/dist/capabilities/metadata-search/MultiSourceSearchService.js +17 -4
  38. package/dist/capabilities/metadata-search/MultiSourceSearchService.js.map +1 -1
  39. package/dist/capabilities/metadata-search/handler.d.ts.map +1 -1
  40. package/dist/capabilities/metadata-search/handler.js +6 -0
  41. package/dist/capabilities/metadata-search/handler.js.map +1 -1
  42. package/dist/capabilities/metadata-search/schemas.d.ts +3 -3
  43. package/dist/capabilities/metadata-search/schemas.d.ts.map +1 -1
  44. package/dist/capabilities/metadata-search/schemas.js +1 -0
  45. package/dist/capabilities/metadata-search/schemas.js.map +1 -1
  46. package/dist/capabilities/metadata-search/tools.js +1 -1
  47. package/dist/capabilities/metadata-search/tools.js.map +1 -1
  48. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts +11 -0
  49. package/dist/capabilities/pdf-discovery/DownloadTier.d.ts.map +1 -1
  50. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.d.ts.map +1 -1
  51. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js +31 -1
  52. package/dist/capabilities/pdf-discovery/OpenAccessFallbackService.js.map +1 -1
  53. package/dist/capabilities/pdf-discovery/arxiv.d.ts +4 -0
  54. package/dist/capabilities/pdf-discovery/arxiv.d.ts.map +1 -0
  55. package/dist/capabilities/pdf-discovery/arxiv.js +42 -0
  56. package/dist/capabilities/pdf-discovery/arxiv.js.map +1 -0
  57. package/dist/capabilities/pdf-discovery/batch.d.ts +1 -1
  58. package/dist/capabilities/pdf-discovery/batch.d.ts.map +1 -1
  59. package/dist/capabilities/pdf-discovery/batch.js +56 -16
  60. package/dist/capabilities/pdf-discovery/batch.js.map +1 -1
  61. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.d.ts.map +1 -1
  62. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js +2 -0
  63. package/dist/capabilities/pdf-discovery/carsi/CarsiAttempt.js.map +1 -1
  64. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.d.ts.map +1 -1
  65. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js +3 -3
  66. package/dist/capabilities/pdf-discovery/carsi/CarsiMcpClient.js.map +1 -1
  67. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts +2 -1
  68. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.d.ts.map +1 -1
  69. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js +3 -1
  70. package/dist/capabilities/pdf-discovery/carsi/CarsiReadiness.js.map +1 -1
  71. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts +1 -1
  72. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.d.ts.map +1 -1
  73. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js +4 -4
  74. package/dist/capabilities/pdf-discovery/carsi/CarsiResultNormalizer.js.map +1 -1
  75. package/dist/capabilities/pdf-discovery/carsi/types.d.ts +2 -0
  76. package/dist/capabilities/pdf-discovery/carsi/types.d.ts.map +1 -1
  77. package/dist/capabilities/pdf-discovery/handler.d.ts.map +1 -1
  78. package/dist/capabilities/pdf-discovery/handler.js +3 -2
  79. package/dist/capabilities/pdf-discovery/handler.js.map +1 -1
  80. package/dist/capabilities/pdf-discovery/oaHosts.d.ts +1 -1
  81. package/dist/capabilities/pdf-discovery/oaHosts.d.ts.map +1 -1
  82. package/dist/capabilities/pdf-discovery/oaHosts.js +4 -4
  83. package/dist/capabilities/pdf-discovery/outputLayout.d.ts +29 -0
  84. package/dist/capabilities/pdf-discovery/outputLayout.d.ts.map +1 -0
  85. package/dist/capabilities/pdf-discovery/outputLayout.js +144 -0
  86. package/dist/capabilities/pdf-discovery/outputLayout.js.map +1 -0
  87. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.d.ts.map +1 -1
  88. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js +6 -0
  89. package/dist/capabilities/pdf-discovery/tiers/directPdfUrl.js.map +1 -1
  90. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts +2 -1
  91. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.d.ts.map +1 -1
  92. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js +8 -1
  93. package/dist/capabilities/pdf-discovery/tiers/entitledAccess.js.map +1 -1
  94. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts +5 -0
  95. package/dist/capabilities/pdf-discovery/tiers/preprint.d.ts.map +1 -0
  96. package/dist/capabilities/pdf-discovery/tiers/preprint.js +80 -0
  97. package/dist/capabilities/pdf-discovery/tiers/preprint.js.map +1 -0
  98. package/dist/capabilities/pdf-discovery/tiers/primary.js +2 -0
  99. package/dist/capabilities/pdf-discovery/tiers/primary.js.map +1 -1
  100. package/dist/capabilities/pdf-discovery/tiers/unpaywall.d.ts.map +1 -1
  101. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js +12 -3
  102. package/dist/capabilities/pdf-discovery/tiers/unpaywall.js.map +1 -1
  103. package/dist/capabilities/pdf-discovery/types.d.ts +6 -0
  104. package/dist/capabilities/pdf-discovery/types.d.ts.map +1 -1
  105. package/dist/core/handleToolCall.d.ts.map +1 -1
  106. package/dist/core/handleToolCall.js +12 -0
  107. package/dist/core/handleToolCall.js.map +1 -1
  108. package/dist/core/schemas.d.ts +26 -1
  109. package/dist/core/schemas.d.ts.map +1 -1
  110. package/dist/core/schemas.js +13 -0
  111. package/dist/core/schemas.js.map +1 -1
  112. package/dist/core/searchers.d.ts +2 -0
  113. package/dist/core/searchers.d.ts.map +1 -1
  114. package/dist/core/searchers.js.map +1 -1
  115. package/dist/core/tools.d.ts.map +1 -1
  116. package/dist/core/tools.js +30 -0
  117. package/dist/core/tools.js.map +1 -1
  118. package/dist/infrastructure/arxiv.d.ts +5 -0
  119. package/dist/infrastructure/arxiv.d.ts.map +1 -0
  120. package/dist/infrastructure/arxiv.js +36 -0
  121. package/dist/infrastructure/arxiv.js.map +1 -0
  122. package/dist/infrastructure/pdf/PdfDownload.d.ts +2 -0
  123. package/dist/infrastructure/pdf/PdfDownload.d.ts.map +1 -1
  124. package/dist/infrastructure/pdf/PdfDownload.js +14 -0
  125. package/dist/infrastructure/pdf/PdfDownload.js.map +1 -1
  126. package/dist/infrastructure/runlog/RunLedger.d.ts.map +1 -1
  127. package/dist/infrastructure/runlog/RunLedger.js +17 -1
  128. package/dist/infrastructure/runlog/RunLedger.js.map +1 -1
  129. package/dist/management/carsi/installer.d.ts +1 -1
  130. package/dist/platforms/ArxivSearcher.d.ts +62 -0
  131. package/dist/platforms/ArxivSearcher.d.ts.map +1 -0
  132. package/dist/platforms/ArxivSearcher.js +544 -0
  133. package/dist/platforms/ArxivSearcher.js.map +1 -0
  134. package/dist/platforms/SemanticScholarSearcher.d.ts.map +1 -1
  135. package/dist/platforms/SemanticScholarSearcher.js +4 -4
  136. package/dist/platforms/SemanticScholarSearcher.js.map +1 -1
  137. package/dist/registry/platformFactories.d.ts.map +1 -1
  138. package/dist/registry/platformFactories.js +2 -0
  139. package/dist/registry/platformFactories.js.map +1 -1
  140. package/dist/registry/platformMetadata.d.ts +1 -1
  141. package/dist/registry/platformMetadata.d.ts.map +1 -1
  142. package/dist/registry/platformMetadata.js +16 -0
  143. package/dist/registry/platformMetadata.js.map +1 -1
  144. package/package.json +2 -2
  145. package/skills/paper-search/SKILL.md +7 -4
  146. package/skills/paper-search/references/browser-download.md +7 -6
  147. package/skills/paper-search/references/capability-routing.md +4 -3
  148. package/skills/paper-search/references/cli-contract.md +10 -3
  149. package/skills/paper-search/references/publisher-support.md +6 -4
  150. package/skills/paper-search/references/troubleshooting.md +1 -1
  151. package/README.zh.md +0 -214
@@ -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
- button.click(timeout=5_000, no_wait_after=True)
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
- pass
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
- """browser tier of paper-search-carsi."""
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
- opts["firefox_user_prefs"] = {"browser.cache.disk.enable": False, **(opts.get("firefox_user_prefs") or {})}
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, which only the
34
- # Camoufox backend provides. Backend selection lives in backend.py.
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