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
@@ -1,10 +1,10 @@
1
1
  """Live previews of the download browser, written by the owning thread (no debug port).
2
2
 
3
3
  On by default for institutional downloads: every page of the download context
4
- is screenshotted on navigation and whenever the workflow reads the page (login
4
+ is screenshotted when the workflow reads the page after navigation (login
5
5
  and verification waits included, at most every 4 s). Frames go to
6
6
  ``<data dir>/previews/<run_id>/`` (or ``browser_preview_dir``) as JPEG. A page
7
- that has not changed (same URL and title) keeps only its newest frame, earlier
7
+ that has not changed (same URL and document revision) keeps only its newest frame, earlier
8
8
  page states are kept up to ``MAX_KEPT_FRAMES`` per download, a successful
9
9
  download's frames are removed, and only the most recent runs are kept. ``browser_preview: false`` turns it off. Readers
10
10
  (``paper-search-carsi preview``) inspect artifacts only and report the
@@ -12,6 +12,7 @@ frame age explicitly. Screenshots are unmasked and stay on this machine.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
+ from contextlib import contextmanager
15
16
  import json
16
17
  import logging
17
18
  import os
@@ -22,6 +23,9 @@ from urllib.parse import urlsplit
22
23
  from uuid import UUID, uuid4
23
24
  from weakref import WeakKeyDictionary
24
25
 
26
+ from .watchdog import BrowserUnresponsive
27
+ from ..entitlements import UserMarkedNoEntitlement
28
+
25
29
  _OBSERVERS = WeakKeyDictionary()
26
30
  _ACTIVE = {}
27
31
  _KINDS = {'blank', 'article', 'pdf', 'login', 'other'}
@@ -49,12 +53,14 @@ class BrowserPreview:
49
53
  self.revision = 0
50
54
  self.last_observed = 0.0
51
55
  self.page_ids = {}
52
- self.last_frame = {} # page_id -> ((url, title), image name)
56
+ self.page_revisions = {}
57
+ self.last_frame = {} # page_id -> ((url, document revision), image name)
53
58
  self.kept = [] # frames of earlier page states, oldest first
54
59
  self.pages = []
55
60
  self.observed_at = time.time()
56
61
  self.closed = False
57
62
  self.observing = False
63
+ self.paused = 0
58
64
  self._cdp = WeakKeyDictionary()
59
65
  context.on('close', self.close)
60
66
  self._write([])
@@ -63,13 +69,12 @@ class BrowserPreview:
63
69
  self._attach_page(page)
64
70
 
65
71
  def _attach_page(self, page):
66
- page.on('domcontentloaded', lambda *_args: self._refresh())
67
-
68
- def _refresh(self):
69
- try:
70
- self.observe()
71
- except Exception:
72
- logging.getLogger(__name__).warning('browser_preview_unavailable')
72
+ def changed(*_args):
73
+ # A Playwright event callback must not make nested synchronous
74
+ # browser calls. Capture at the next workflow observation instead.
75
+ self.page_revisions[page] = self.page_revisions.get(page, 0) + 1
76
+ self.last_observed = 0.0
77
+ page.on('domcontentloaded', changed)
73
78
 
74
79
  def close(self, *_args):
75
80
  self.closed = True
@@ -98,14 +103,8 @@ class BrowserPreview:
98
103
  _atomic(self.directory / 'current.json', json.dumps(packet).encode())
99
104
 
100
105
  def observe(self, *, force=False):
101
- if force and self.observing and not self.closed:
102
- # A navigation callback can still be finishing its screenshot when
103
- # goto() returns. Pump the owning loop for that bounded operation.
104
- deadline = time.monotonic() + 2
105
- pages = list(self.context.pages)
106
- while self.observing and pages and not self.closed and time.monotonic() < deadline:
107
- pages[0].wait_for_timeout(50)
108
- if self.closed or not self._is_current() or self.observing or (not force and time.monotonic() - self.last_observed < OBSERVE_INTERVAL_SEC):
106
+ if self.closed or not self._is_current() or self.observing or (not force and (
107
+ self.paused or time.monotonic() - self.last_observed < OBSERVE_INTERVAL_SEC)):
109
108
  return
110
109
  self.observing = True
111
110
  try:
@@ -127,6 +126,8 @@ class BrowserPreview:
127
126
  try:
128
127
  session = page.context.new_cdp_session(page)
129
128
  self._cdp[page] = session
129
+ except (BrowserUnresponsive, UserMarkedNoEntitlement):
130
+ raise
130
131
  except Exception:
131
132
  session = False
132
133
  self._cdp[page] = session
@@ -169,12 +170,14 @@ class BrowserPreview:
169
170
  _atomic(self.directory / image_name, frame)
170
171
  row.update(image_name=image_name, captured_at=captured_at)
171
172
  row['screenshot_status'] = 'available'
172
- key = (captured_url, self._page_title(page))
173
+ key = (captured_url, self.page_revisions.get(page, 0))
173
174
  previous = self.last_frame.get(page_id)
174
175
  if previous:
175
176
  # The same page again replaces its frame; a changed page keeps the old one.
176
177
  (superseded if previous[0] == key else self.kept).append(previous[1])
177
178
  self.last_frame[page_id] = (key, image_name)
179
+ except (BrowserUnresponsive, UserMarkedNoEntitlement):
180
+ raise
178
181
  except Exception:
179
182
  # Preview failure is explicit and cannot change download success.
180
183
  pass
@@ -187,13 +190,6 @@ class BrowserPreview:
187
190
  for name in superseded:
188
191
  (self.directory / name).unlink(missing_ok=True)
189
192
 
190
- @staticmethod
191
- def _page_title(page) -> str:
192
- try:
193
- return str(page.title() or "")
194
- except Exception:
195
- return ""
196
-
197
193
  def discard_frames(self):
198
194
  """Remove this download's frames; a successful download needs no record."""
199
195
  for name in [*self.kept, *(frame for _key, frame in self.last_frame.values())]:
@@ -238,10 +234,32 @@ def observe_page(page, *, force=False) -> None:
238
234
  if observer is not None:
239
235
  try:
240
236
  observer.observe(force=force)
237
+ except (BrowserUnresponsive, UserMarkedNoEntitlement):
238
+ raise
241
239
  except Exception:
242
240
  logging.getLogger(__name__).warning('browser_preview_unavailable')
243
241
 
244
242
 
243
+ @contextmanager
244
+ def pause(context):
245
+ """Keep the last frame while a download operation needs the browser.
246
+
247
+ Optional screenshots must not spend a PDF operation's budget. Explicit
248
+ force observations still run; browser and user interrupts are not caught.
249
+ """
250
+ try:
251
+ observer = _OBSERVERS.get(context)
252
+ except TypeError:
253
+ observer = None
254
+ if observer is not None:
255
+ observer.paused += 1
256
+ try:
257
+ yield
258
+ finally:
259
+ if observer is not None:
260
+ observer.paused -= 1
261
+
262
+
245
263
  def snapshot(directory: str | Path, run_id: str, identifier: str) -> dict:
246
264
  """A requested run can return a clearly labelled retained or stale view."""
247
265
  from ..progress_status import normalize_identifier, snapshot as progress_snapshot
@@ -0,0 +1,161 @@
1
+ """Persistent browser profile state and ownership, independent of the launcher.
2
+
3
+ Session cookies use their existing per-profile file and expiry. Native lock
4
+ checks never remove another process's lock or change the profile location.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+ PROFILE_RELEASE_TIMEOUT_SEC = 20.0
18
+
19
+
20
+ SESSION_COOKIES_FILE = ".paper-search-session-cookies.json"
21
+ SESSION_COOKIES_MAX_AGE_SEC = 12 * 3600
22
+
23
+
24
+ def _save_session_cookies(context: Any, profile: Path) -> None:
25
+ """Keep session cookies (IdP and publisher logins) for the next launch.
26
+
27
+ Each paper runs in a new backend process and browser; Firefox drops
28
+ cookies without an expiry when it closes, so every paper asked for the
29
+ school login again. Only session cookies are kept: persistent ones are in
30
+ the profile already. The file is readable by the user only.
31
+ """
32
+ try:
33
+ cookies = [c for c in context.cookies() if not c.get("expires") or c.get("expires", -1) < 0]
34
+ except Exception:
35
+ return
36
+ path = profile / SESSION_COOKIES_FILE
37
+ try:
38
+ if not cookies:
39
+ path.unlink(missing_ok=True)
40
+ return
41
+ tmp = path.with_suffix(".tmp")
42
+ tmp.write_text(json.dumps({"saved_at": time.time(), "cookies": cookies}),
43
+ encoding="utf-8")
44
+ os.chmod(tmp, 0o600)
45
+ os.replace(tmp, path)
46
+ except Exception as exc:
47
+ logger.debug("browser_backend: session cookies not saved: %s", type(exc).__name__)
48
+
49
+
50
+ def _restore_session_cookies(context: Any, profile: Path) -> int:
51
+ path = profile / SESSION_COOKIES_FILE
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ except Exception:
55
+ return 0
56
+ if time.time() - float(data.get("saved_at", 0)) > SESSION_COOKIES_MAX_AGE_SEC:
57
+ return 0
58
+ restored = 0
59
+ for cookie in data.get("cookies") or []:
60
+ try:
61
+ context.add_cookies([{k: v for k, v in cookie.items() if k != "expires" or v not in (None, -1)}])
62
+ restored += 1
63
+ except Exception:
64
+ continue
65
+ return restored
66
+
67
+
68
+ def _profile_owner_pid(user_data_dir: str) -> int | None:
69
+ """PID of the browser holding this profile, if any.
70
+
71
+ Firefox (Camoufox) leaves a ``lock`` symlink to ``<host>:+<pid>``;
72
+ Chromium leaves ``SingletonLock`` to ``<host>-<pid>``.
73
+ """
74
+ for name in ("lock", "SingletonLock"):
75
+ try:
76
+ target = os.readlink(Path(user_data_dir) / name)
77
+ except OSError:
78
+ continue
79
+ match = re.search(r"[-+](\d+)$", target)
80
+ if match:
81
+ return int(match.group(1))
82
+ return None
83
+
84
+
85
+ def _lock_file_held(path: Path) -> bool:
86
+ """Another process holds this lock file: Firefox's ``.parentlock`` on macOS
87
+ and Linux (an fcntl lock; macOS has no ``lock`` symlink), its ``parent.lock``
88
+ on Windows (opened without sharing). The lock is tested, never kept."""
89
+ if not path.exists():
90
+ return False
91
+ try:
92
+ fd = os.open(str(path), os.O_RDWR)
93
+ except PermissionError:
94
+ return True # Windows: the running browser has it open
95
+ except OSError:
96
+ return False
97
+ try:
98
+ try:
99
+ import fcntl
100
+ except ImportError:
101
+ return False
102
+ try:
103
+ fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
104
+ except OSError:
105
+ return True
106
+ fcntl.lockf(fd, fcntl.LOCK_UN)
107
+ return False
108
+ finally:
109
+ os.close(fd)
110
+
111
+
112
+ def profile_holder(user_data_dir: str) -> str:
113
+ """Who still holds this profile ("" = nobody): a live ``lock`` owner, or a
114
+ lock file another process holds."""
115
+ pid = _profile_owner_pid(user_data_dir)
116
+ if pid is not None and _pid_alive(pid):
117
+ return f"lock of pid {pid}"
118
+ for name in (".parentlock", "parent.lock"):
119
+ if _lock_file_held(Path(user_data_dir) / name):
120
+ return f"{name} held"
121
+ return ""
122
+
123
+
124
+ def _pid_alive(pid: int) -> bool:
125
+ if pid <= 0 or pid == os.getpid():
126
+ return False
127
+ try:
128
+ os.kill(pid, 0)
129
+ except ProcessLookupError:
130
+ return False
131
+ except PermissionError:
132
+ return True
133
+ except OSError:
134
+ return False
135
+ return True
136
+
137
+
138
+ def wait_for_profile_release(user_data_dir: str, timeout: float = PROFILE_RELEASE_TIMEOUT_SEC) -> bool:
139
+ """Wait while a previous browser process still owns this persistent profile.
140
+
141
+ Back-to-back downloads reuse one profile; the previous Chrome can take a
142
+ few seconds to exit after its context closed. Launching meanwhile fails,
143
+ and the fallback launcher then reports a misleading "executable doesn't
144
+ exist". Returns True when the profile is free (or never locked).
145
+ """
146
+ import time
147
+
148
+ deadline = time.monotonic() + max(0.0, timeout)
149
+ waited = False
150
+ while True:
151
+ if not profile_holder(user_data_dir):
152
+ if waited:
153
+ logger.info("browser_backend: persistent profile released by previous browser")
154
+ return True
155
+ if time.monotonic() >= deadline:
156
+ logger.info("browser_backend: persistent profile still in use by another browser process")
157
+ return False
158
+ if not waited:
159
+ logger.info("browser_backend: waiting for the previous browser to release the profile")
160
+ waited = True
161
+ time.sleep(0.5)
@@ -24,6 +24,7 @@ import logging
24
24
  import threading
25
25
  import time
26
26
  import traceback
27
+ from contextlib import contextmanager
27
28
  from typing import Any
28
29
 
29
30
  log = logging.getLogger(__name__)
@@ -33,6 +34,8 @@ log = logging.getLogger(__name__)
33
34
  LIMIT_SEC = 90
34
35
 
35
36
  _inflight: dict[int, tuple[float, str, list[str], Any, Any]] = {}
37
+ _call_deadlines: dict[int, tuple[float, str]] = {}
38
+ _local = threading.local()
36
39
  _installed = False
37
40
  # A browser call longer than this is reported to ``on_slow_call(name, seconds,
38
41
  # ended, caller)``, set by the downloader for the article it runs: a Playwright
@@ -59,6 +62,48 @@ class BrowserUnresponsive(Exception):
59
62
  """A browser call did not return within the watchdog limit."""
60
63
 
61
64
 
65
+ class BrowserOperationTimeout(BrowserUnresponsive):
66
+ """The whole page operation exhausted its budget across browser calls."""
67
+
68
+
69
+ @contextmanager
70
+ def operation(seconds: float, label: str):
71
+ """One monotonic deadline shared by all calls and retries in this scope."""
72
+ stack = getattr(_local, "budgets", None)
73
+ if stack is None:
74
+ stack = _local.budgets = []
75
+ stack.append([time.monotonic() + seconds, label])
76
+ try:
77
+ yield
78
+ finally:
79
+ stack.pop()
80
+
81
+
82
+ @contextmanager
83
+ def pause_operation():
84
+ """Explicit human interaction does not spend the enclosing automation budget."""
85
+ stack = getattr(_local, "budgets", [])
86
+ started = time.monotonic()
87
+ _local.budgets = []
88
+ try:
89
+ yield
90
+ finally:
91
+ elapsed = time.monotonic() - started
92
+ for budget in stack:
93
+ budget[0] += elapsed
94
+ _local.budgets = stack
95
+
96
+
97
+ def failure_code() -> str:
98
+ return "browser_operation_timeout" if fired and fired.get("operation") else "browser_unresponsive"
99
+
100
+
101
+ def _timeout_error() -> BrowserUnresponsive:
102
+ if fired and fired.get("operation"):
103
+ return BrowserOperationTimeout(f"{fired['operation']} exceeded its page budget during {fired['call']}")
104
+ return BrowserUnresponsive(f"browser unresponsive since {fired['call']}")
105
+
106
+
62
107
  def install(limit_sec: float = LIMIT_SEC) -> None:
63
108
  """Time every sync Playwright call of this process (once)."""
64
109
  global _installed
@@ -72,7 +117,7 @@ def install(limit_sec: float = LIMIT_SEC) -> None:
72
117
  name = getattr(coro, "__qualname__", "") or type(coro).__name__
73
118
  if fired is not None:
74
119
  coro.close()
75
- raise BrowserUnresponsive(f"browser unresponsive since {fired['call']}; {name} not sent")
120
+ raise _timeout_error()
76
121
  if interrupted is not None:
77
122
  coro.close()
78
123
  raise interrupted
@@ -86,13 +131,21 @@ def install(limit_sec: float = LIMIT_SEC) -> None:
86
131
  started = time.monotonic()
87
132
  _inflight[key] = (started, name, traceback.format_stack(limit=8)[:-1],
88
133
  getattr(self, "_loop", None), coro)
134
+ budgets = getattr(_local, "budgets", [])
135
+ if budgets:
136
+ _call_deadlines[key] = tuple(min(budgets, key=lambda budget: budget[0]))
89
137
  ended = "returned"
90
138
  try:
139
+ # Refuse to start another candidate after the shared budget expired.
140
+ if key in _call_deadlines and started >= _call_deadlines[key][0]:
141
+ check(limit_sec)
142
+ coro.close()
143
+ raise _timeout_error()
91
144
  return original(self, coro)
92
145
  except asyncio.CancelledError:
93
146
  ended = "cancelled"
94
147
  if fired is not None:
95
- raise BrowserUnresponsive(f"{name} did not return within {int(limit_sec)} s") from None
148
+ raise _timeout_error() from None
96
149
  if interrupted is not None:
97
150
  raise interrupted from None
98
151
  raise
@@ -101,6 +154,7 @@ def install(limit_sec: float = LIMIT_SEC) -> None:
101
154
  raise
102
155
  finally:
103
156
  _inflight.pop(key, None)
157
+ _call_deadlines.pop(key, None)
104
158
  elapsed = time.monotonic() - started
105
159
  _period["calls"] += 1
106
160
  _period["seconds"] += elapsed
@@ -125,15 +179,9 @@ def _report_slow(name: str, seconds: float, ended: str) -> None:
125
179
  pass # a record, never the article's end
126
180
 
127
181
 
128
- def stuck_call(now: float, limit_sec: float) -> tuple[float, str, list[str], Any, Any] | None:
129
- """The oldest call running for longer than ``limit_sec``."""
130
- stuck = [entry for entry in list(_inflight.values()) if now - entry[0] > limit_sec]
131
- return min(stuck, key=lambda entry: entry[0]) if stuck else None
132
-
133
-
134
182
  def _watch(limit_sec: float) -> None:
135
183
  while not check(limit_sec):
136
- time.sleep(5)
184
+ time.sleep(0.25)
137
185
 
138
186
 
139
187
  def check(limit_sec: float) -> bool:
@@ -141,16 +189,25 @@ def check(limit_sec: float) -> bool:
141
189
  global fired
142
190
  if fired is not None:
143
191
  return True
144
- entry = stuck_call(time.monotonic(), limit_sec)
145
- if entry is None:
192
+ now = time.monotonic()
193
+ deadlines = dict(_call_deadlines)
194
+ overdue = [(key, entry) for key, entry in list(_inflight.items())
195
+ if now - entry[0] > limit_sec
196
+ or (key in deadlines and now >= deadlines[key][0])]
197
+ if not overdue:
146
198
  return False
199
+ key, entry = min(overdue, key=lambda pair: pair[1][0])
147
200
  started, name, stack, loop, coro = entry
148
- fired = {"call": name, "seconds": int(time.monotonic() - started)}
149
- log.warning("browser_unresponsive: %s waited %ss; caller:\n%s", name, fired["seconds"], "".join(stack))
201
+ fired = {"call": name, "seconds": int(now - started)}
202
+ deadline = deadlines.get(key)
203
+ if deadline and deadline[0] <= now and deadline[0] <= started + limit_sec:
204
+ fired["operation"] = deadline[1]
205
+ log.warning("%s: %s waited %ss; operation=%s; caller:\n%s", failure_code(), name,
206
+ fired["seconds"], fired.get("operation", ""), "".join(stack))
150
207
  # Every thread's stack, for the cause of the hang (still unknown, 2026-11-01).
151
208
  from ..log import dump_stacks
152
209
 
153
- dump_stacks(f"browser_unresponsive: {name} waited {fired['seconds']}s")
210
+ dump_stacks(f"{failure_code()}: {name} waited {fired['seconds']}s")
154
211
  if loop is not None and not loop.is_closed():
155
212
  loop.call_soon_threadsafe(_cancel, coro)
156
213
  return True
@@ -26,7 +26,7 @@ challenge_or_viewer_timeout ip_blocked access_blocked pdf_not_captured
26
26
  pdf_identity_mismatch pdf_identity_unverified invalid_pdf
27
27
  profile_busy cancelled interrupted skipped_cached_attempt target_verified_reached navigation_error
28
28
  unsupported_platform unsupported_publisher user_skipped user_marked_no_entitlement pdf_wrong_article
29
- institution_federation_unavailable browser_unresponsive login_not_reached idp_no_return_to_publisher
29
+ institution_federation_unavailable browser_unresponsive browser_operation_timeout login_not_reached idp_no_return_to_publisher
30
30
  generic_institution_entry_missing
31
31
  """.split())
32
32
  _STATES = _FAILURES | _STATUSES | frozenset("""