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.
- package/README.en.md +292 -0
- package/README.md +203 -125
- package/carsi/README.md +8 -2
- 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 +71 -14
- package/carsi/paper_search_carsi/download_diagnostics.py +1 -1
- package/carsi/paper_search_carsi/engine/batch.py +129 -68
- package/carsi/paper_search_carsi/engine/batch_access.py +8 -3
- package/carsi/paper_search_carsi/engine/batch_capture.py +53 -8
- 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 +47 -27
- package/carsi/paper_search_carsi/engine/batch_runs.py +2 -7
- package/carsi/paper_search_carsi/engine/batch_waits.py +82 -42
- package/carsi/paper_search_carsi/engine/bridge.py +16 -8
- package/carsi/paper_search_carsi/engine/supplements.py +22 -3
- 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 +39 -18
- package/carsi/paper_search_carsi/server.py +8 -1
- 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 +56 -16
- 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/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 +7 -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 -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
self.
|
|
71
|
-
|
|
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
|
|
102
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
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(
|
|
149
|
-
|
|
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"
|
|
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("""
|