parsek-cdp-server 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/PKG-INFO +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/launcher.py +59 -41
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/metrics.py +12 -3
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/proxy.py +78 -16
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/supervisor.py +10 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/PKG-INFO +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/requires.txt +1 -1
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/pyproject.toml +2 -2
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/LICENSE +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/README.md +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/__init__.py +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/py.typed +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server/reaper.py +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/SOURCES.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/dependency_links.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/top_level.txt +0 -0
- {parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsek-cdp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers.
|
|
5
5
|
Author: xa1era
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/xa1era/parsek-cdp
|
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: parsek-cdp~=0.1.
|
|
11
|
+
Requires-Dist: parsek-cdp~=0.1.2
|
|
12
12
|
Requires-Dist: aiohttp>=3.9
|
|
13
13
|
Requires-Dist: psutil>=5
|
|
14
14
|
Requires-Dist: prometheus-client>=0.19
|
|
@@ -10,19 +10,19 @@ drives this class.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import contextlib
|
|
13
14
|
import json
|
|
14
15
|
import os
|
|
15
16
|
import random
|
|
16
17
|
import shutil
|
|
17
|
-
import signal
|
|
18
18
|
import tempfile
|
|
19
19
|
import urllib.error
|
|
20
20
|
import urllib.request
|
|
21
|
-
from dataclasses import dataclass, field
|
|
22
21
|
from pathlib import Path
|
|
23
22
|
from typing import List, Optional
|
|
24
23
|
|
|
25
24
|
import psutil
|
|
25
|
+
from parsek_cdp.core.browser import LaunchOptions
|
|
26
26
|
|
|
27
27
|
#: Env var naming *directories* to search for browser binaries,
|
|
28
28
|
#: ``os.pathsep``-separated (like ``PATH``), e.g. ``/opt/browsers/chrome:/opt/browsers/brave``.
|
|
@@ -102,27 +102,26 @@ def _is_browser(filename: str) -> bool:
|
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
def _find_in_dirs(dirs: List[str]) -> List[str]:
|
|
105
|
-
"""
|
|
105
|
+
"""Collect browser executables directly under ``dirs`` (non-recursive)."""
|
|
106
106
|
found: List[str] = []
|
|
107
107
|
for directory in dirs:
|
|
108
108
|
directory = directory.strip()
|
|
109
109
|
if not directory or not os.path.isdir(directory):
|
|
110
110
|
continue
|
|
111
|
-
for
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
found.append(full)
|
|
111
|
+
for filename in os.listdir(directory):
|
|
112
|
+
if not _is_browser(filename):
|
|
113
|
+
continue
|
|
114
|
+
full = os.path.join(directory, filename)
|
|
115
|
+
if os.path.isfile(full) and os.access(full, os.X_OK):
|
|
116
|
+
found.append(full)
|
|
118
117
|
return found
|
|
119
118
|
|
|
120
119
|
|
|
121
120
|
def _detect_executable() -> str:
|
|
122
121
|
"""Pick a Chromium-based binary, or raise if none is available.
|
|
123
122
|
|
|
124
|
-
Precedence: a random browser executable found
|
|
125
|
-
|
|
123
|
+
Precedence: a random browser executable found directly in the directories
|
|
124
|
+
listed in :data:`CHROMES_PATH_ENV` (defaulting to :data:`_KNOWN_PATHS`),
|
|
126
125
|
else the first :data:`_CANDIDATES` name resolvable on ``PATH``.
|
|
127
126
|
"""
|
|
128
127
|
raw = os.environ.get(CHROMES_PATH_ENV)
|
|
@@ -140,18 +139,6 @@ def _detect_executable() -> str:
|
|
|
140
139
|
)
|
|
141
140
|
|
|
142
141
|
|
|
143
|
-
@dataclass
|
|
144
|
-
class LaunchOptions:
|
|
145
|
-
"""How to start the browser."""
|
|
146
|
-
|
|
147
|
-
executable: Optional[str] = None # autodetect if None
|
|
148
|
-
headless: bool = True
|
|
149
|
-
port: int = 0
|
|
150
|
-
user_data_dir: Optional[str] = None
|
|
151
|
-
extra_args: List[str] = field(default_factory=list)
|
|
152
|
-
default_args: bool = True
|
|
153
|
-
|
|
154
|
-
|
|
155
142
|
class ChromeLauncher:
|
|
156
143
|
"""Spawn a browser process and expose its browser-level websocket url.
|
|
157
144
|
|
|
@@ -173,8 +160,6 @@ class ChromeLauncher:
|
|
|
173
160
|
#: Temp profile dir we created (and must clean up); None if caller supplied one.
|
|
174
161
|
self._owned_user_data_dir: Optional[str] = None
|
|
175
162
|
|
|
176
|
-
# -- argv -------------------------------------------------------------- #
|
|
177
|
-
|
|
178
163
|
def _resolve_user_data_dir(self) -> str:
|
|
179
164
|
if self.options.user_data_dir is not None:
|
|
180
165
|
return self.options.user_data_dir
|
|
@@ -185,7 +170,7 @@ class ChromeLauncher:
|
|
|
185
170
|
def _build_argv(self, user_data_dir: str) -> List[str]:
|
|
186
171
|
executable = self.options.executable or _detect_executable()
|
|
187
172
|
argv = [executable, f"--remote-debugging-port={self.options.port}"]
|
|
188
|
-
if self.options.
|
|
173
|
+
if self.options.use_default_args:
|
|
189
174
|
argv += [
|
|
190
175
|
f"--user-data-dir={user_data_dir}",
|
|
191
176
|
"--no-first-run",
|
|
@@ -271,22 +256,48 @@ class ChromeLauncher:
|
|
|
271
256
|
return psutil.pid_exists(self.pid)
|
|
272
257
|
|
|
273
258
|
async def terminate(self, *, timeout: float = 5.0) -> None:
|
|
274
|
-
"""Terminate the browser process gracefully, then kill
|
|
259
|
+
"""Terminate the browser process tree gracefully, then kill survivors.
|
|
260
|
+
|
|
261
|
+
Chrome frequently re-execs or forks the real browser into a *separate*
|
|
262
|
+
process (headed mode, wrapper launch scripts, the relauncher), after which
|
|
263
|
+
the process we spawned has already exited. Signalling only that direct
|
|
264
|
+
child would then leave the real browser running as an orphan, so we walk
|
|
265
|
+
the whole tree rooted at :attr:`pid` (parent + recursive children) and
|
|
266
|
+
SIGTERM it, escalating to SIGKILL for anything still alive after the grace
|
|
267
|
+
period.
|
|
268
|
+
"""
|
|
269
|
+
procs = self._process_tree()
|
|
270
|
+
for p in procs:
|
|
271
|
+
with _suppress_lookup():
|
|
272
|
+
p.terminate() # SIGTERM
|
|
273
|
+
# psutil.wait_procs is blocking, so run it off the event loop.
|
|
274
|
+
_gone, alive = await asyncio.to_thread(
|
|
275
|
+
psutil.wait_procs, procs, timeout=timeout
|
|
276
|
+
)
|
|
277
|
+
for p in alive:
|
|
278
|
+
with _suppress_lookup():
|
|
279
|
+
p.kill() # SIGKILL
|
|
280
|
+
# Reap the asyncio child so its transport doesn't warn about a lost process.
|
|
275
281
|
proc = self._proc
|
|
276
282
|
if proc is not None and proc.returncode is None:
|
|
277
|
-
|
|
278
|
-
proc.
|
|
279
|
-
except ProcessLookupError:
|
|
280
|
-
pass
|
|
281
|
-
else:
|
|
282
|
-
try:
|
|
283
|
-
await asyncio.wait_for(proc.wait(), timeout)
|
|
284
|
-
except asyncio.TimeoutError:
|
|
285
|
-
with _suppress_lookup():
|
|
286
|
-
proc.kill()
|
|
287
|
-
await proc.wait()
|
|
283
|
+
with contextlib.suppress(ProcessLookupError):
|
|
284
|
+
await proc.wait()
|
|
288
285
|
self._cleanup_profile()
|
|
289
286
|
|
|
287
|
+
def _process_tree(self) -> List[psutil.Process]:
|
|
288
|
+
"""The browser process and all its descendants (empty if already gone)."""
|
|
289
|
+
if self.pid is None:
|
|
290
|
+
return []
|
|
291
|
+
try:
|
|
292
|
+
parent = psutil.Process(self.pid)
|
|
293
|
+
except psutil.NoSuchProcess:
|
|
294
|
+
return []
|
|
295
|
+
try:
|
|
296
|
+
children = parent.children(recursive=True)
|
|
297
|
+
except psutil.NoSuchProcess:
|
|
298
|
+
children = []
|
|
299
|
+
return [parent, *children]
|
|
300
|
+
|
|
290
301
|
def _cleanup_profile(self) -> None:
|
|
291
302
|
if self._owned_user_data_dir is not None:
|
|
292
303
|
shutil.rmtree(self._owned_user_data_dir, ignore_errors=True)
|
|
@@ -294,10 +305,17 @@ class ChromeLauncher:
|
|
|
294
305
|
|
|
295
306
|
|
|
296
307
|
class _suppress_lookup:
|
|
297
|
-
"""Context manager swallowing
|
|
308
|
+
"""Context manager swallowing "process already gone" errors.
|
|
309
|
+
|
|
310
|
+
Covers both :class:`ProcessLookupError` (from ``asyncio``/``os`` signalling)
|
|
311
|
+
and :class:`psutil.NoSuchProcess` (from the process-tree walk), which race
|
|
312
|
+
naturally: a process can exit between being listed and being signalled.
|
|
313
|
+
"""
|
|
298
314
|
|
|
299
315
|
def __enter__(self) -> "_suppress_lookup":
|
|
300
316
|
return self
|
|
301
317
|
|
|
302
318
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
303
|
-
return exc_type is not None and issubclass(
|
|
319
|
+
return exc_type is not None and issubclass(
|
|
320
|
+
exc_type, (ProcessLookupError, psutil.NoSuchProcess)
|
|
321
|
+
)
|
|
@@ -14,8 +14,12 @@ blocks the event loop:
|
|
|
14
14
|
|
|
15
15
|
Metrics:
|
|
16
16
|
|
|
17
|
+
Only *active* browsers (supervisor state ``READY``) are reported; browsers that
|
|
18
|
+
are still starting, crashed, restarting or closed contribute no series, and any
|
|
19
|
+
event counters they left behind are pruned -- so stale data never lingers.
|
|
20
|
+
|
|
17
21
|
================================== ===== ===========================================
|
|
18
|
-
``parsek_browsers`` gauge
|
|
22
|
+
``parsek_browsers`` gauge active (READY) browsers
|
|
19
23
|
``parsek_targets`` gauge targets, labelled ``browser``, ``type``
|
|
20
24
|
``parsek_nested_targets`` gauge targets with a ``parentId``, per ``browser``
|
|
21
25
|
``parsek_browser_cpu_percent`` gauge CPU% of the browser tree, per ``browser``
|
|
@@ -125,6 +129,11 @@ class ServerMetrics:
|
|
|
125
129
|
snapshot: Dict[str, _BrowserSnapshot] = {}
|
|
126
130
|
live_targets: Set[Tuple[str, str]] = set()
|
|
127
131
|
for browser_uuid, supervisor in list(self._server.supervisors.items()):
|
|
132
|
+
# Only active (READY) browsers contribute metrics; skipping the rest
|
|
133
|
+
# keeps their targets out of ``live_targets`` so their event series
|
|
134
|
+
# are pruned below instead of lingering.
|
|
135
|
+
if not supervisor.is_active:
|
|
136
|
+
continue
|
|
128
137
|
snap = _BrowserSnapshot()
|
|
129
138
|
origin = supervisor.http_origin
|
|
130
139
|
if origin is not None:
|
|
@@ -201,8 +210,8 @@ class _Collector:
|
|
|
201
210
|
m = self._metrics
|
|
202
211
|
snapshot = m._snapshot
|
|
203
212
|
|
|
204
|
-
browsers = GaugeMetricFamily("parsek_browsers", "Number of
|
|
205
|
-
browsers.add_metric([], float(len(
|
|
213
|
+
browsers = GaugeMetricFamily("parsek_browsers", "Number of active (READY) browsers")
|
|
214
|
+
browsers.add_metric([], float(len(snapshot)))
|
|
206
215
|
yield browsers
|
|
207
216
|
|
|
208
217
|
targets = GaugeMetricFamily(
|
|
@@ -30,16 +30,17 @@ import itertools
|
|
|
30
30
|
import json
|
|
31
31
|
import subprocess
|
|
32
32
|
import uuid
|
|
33
|
-
from typing import Callable, Dict, List, Optional, Set, Tuple, Type
|
|
33
|
+
from typing import Awaitable, Callable, Dict, List, Optional, Set, Tuple, Type
|
|
34
34
|
|
|
35
35
|
import websockets
|
|
36
36
|
from aiohttp import web
|
|
37
37
|
from parsek_cdp._logging import get_logger
|
|
38
|
+
from parsek_cdp.core.browser import LaunchOptions
|
|
38
39
|
from parsek_cdp.core.feature import Feature
|
|
39
40
|
from parsek_cdp.core.target import ProtocolError
|
|
41
|
+
from parsek_cdp.parsek import BrowserState
|
|
40
42
|
from parsek_cdp.parsek.events import BrowserStateChanged
|
|
41
43
|
|
|
42
|
-
from .launcher import LaunchOptions
|
|
43
44
|
from .metrics import ServerMetrics
|
|
44
45
|
from .reaper import spawn_reaper
|
|
45
46
|
from .supervisor import BrowserSupervisor
|
|
@@ -316,6 +317,9 @@ class ParsekServer:
|
|
|
316
317
|
browser_uuid, options=options, idle_timeout=idle_timeout
|
|
317
318
|
)
|
|
318
319
|
supervisor.on_state(self._state_broadcaster(browser_uuid))
|
|
320
|
+
# Registered after the broadcaster so clients still receive the CLOSED
|
|
321
|
+
# frame before the registries are dropped.
|
|
322
|
+
supervisor.on_state(self._forget_on_close(browser_uuid))
|
|
319
323
|
self.supervisors[browser_uuid] = supervisor
|
|
320
324
|
self._browser_features[browser_uuid] = feats
|
|
321
325
|
self._control_clients[browser_uuid] = set()
|
|
@@ -329,17 +333,8 @@ class ParsekServer:
|
|
|
329
333
|
body = await request.json() if request.can_read_body else {}
|
|
330
334
|
except Exception:
|
|
331
335
|
body = {}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
"headless",
|
|
335
|
-
"port",
|
|
336
|
-
"user_data_dir",
|
|
337
|
-
"extra_args",
|
|
338
|
-
"default_args",
|
|
339
|
-
}
|
|
340
|
-
kwargs = {k: body[k] for k in allowed if k in body}
|
|
341
|
-
if kwargs:
|
|
342
|
-
return LaunchOptions(**kwargs)
|
|
336
|
+
if body:
|
|
337
|
+
return LaunchOptions.from_json(body)
|
|
343
338
|
return self.launch_options
|
|
344
339
|
|
|
345
340
|
async def _idle_timeout(self, request: web.Request) -> Optional[float]:
|
|
@@ -384,12 +379,47 @@ class ParsekServer:
|
|
|
384
379
|
),
|
|
385
380
|
)
|
|
386
381
|
try:
|
|
387
|
-
await self._pipe(
|
|
382
|
+
await self._pipe(
|
|
383
|
+
ws,
|
|
384
|
+
supervisor.ws_url,
|
|
385
|
+
on_client_frame=self._on_control_frame(browser_uuid),
|
|
386
|
+
)
|
|
388
387
|
finally:
|
|
389
388
|
clients.discard(ws)
|
|
390
389
|
supervisor.client_disconnected()
|
|
391
390
|
return ws
|
|
392
391
|
|
|
392
|
+
def _on_control_frame(self, browser_uuid: str):
|
|
393
|
+
"""Watch a control channel for the client's ``Browser.close`` request.
|
|
394
|
+
|
|
395
|
+
The frame is still forwarded to the browser (it closes itself gracefully);
|
|
396
|
+
this handler additionally shuts the *supervisor* down so the resulting
|
|
397
|
+
process exit is recorded as an intentional ``CLOSED`` rather than a crash
|
|
398
|
+
to be relaunched.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
async def handle(data: str) -> None:
|
|
402
|
+
# Cheap substring gate first -- only bother parsing the frame that
|
|
403
|
+
# could actually be the close request, not every control message.
|
|
404
|
+
if "Browser.close" not in data:
|
|
405
|
+
return
|
|
406
|
+
try:
|
|
407
|
+
method = json.loads(data).get("method")
|
|
408
|
+
except Exception:
|
|
409
|
+
return
|
|
410
|
+
if method == "Browser.close":
|
|
411
|
+
await self._graceful_close(browser_uuid)
|
|
412
|
+
|
|
413
|
+
return handle
|
|
414
|
+
|
|
415
|
+
async def _graceful_close(self, browser_uuid: str) -> None:
|
|
416
|
+
"""Stop supervising ``browser_uuid`` and terminate it (idempotent)."""
|
|
417
|
+
supervisor = self.supervisors.pop(browser_uuid, None)
|
|
418
|
+
self._browser_features.pop(browser_uuid, None)
|
|
419
|
+
self._control_clients.pop(browser_uuid, None)
|
|
420
|
+
if supervisor is not None:
|
|
421
|
+
await supervisor.shutdown()
|
|
422
|
+
|
|
393
423
|
async def page_ws(self, request: web.Request) -> web.WebSocketResponse:
|
|
394
424
|
"""``ws /cdp/{browser}/page/{target}``: pipe to a target + aggregation."""
|
|
395
425
|
browser_uuid = request.match_info["browser"]
|
|
@@ -404,8 +434,10 @@ class ParsekServer:
|
|
|
404
434
|
feats = self._browser_features.get(browser_uuid, self.features)
|
|
405
435
|
on_event = None
|
|
406
436
|
if self.metrics is not None:
|
|
437
|
+
|
|
407
438
|
def on_event(domain: str) -> None:
|
|
408
439
|
self.metrics.record_event(browser_uuid, target_id, domain)
|
|
440
|
+
|
|
409
441
|
bridge = PageBridge(
|
|
410
442
|
ws,
|
|
411
443
|
supervisor.target_ws_url(target_id),
|
|
@@ -424,8 +456,18 @@ class ParsekServer:
|
|
|
424
456
|
|
|
425
457
|
# -- helpers ----------------------------------------------------------- #
|
|
426
458
|
|
|
427
|
-
async def _pipe(
|
|
428
|
-
|
|
459
|
+
async def _pipe(
|
|
460
|
+
self,
|
|
461
|
+
client_ws: web.WebSocketResponse,
|
|
462
|
+
chrome_url: str,
|
|
463
|
+
on_client_frame: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Relay raw frames both ways between a client socket and Chrome.
|
|
466
|
+
|
|
467
|
+
``on_client_frame`` (if given) is invoked with each text frame *after* it
|
|
468
|
+
has been forwarded, letting the caller react to specific commands (e.g.
|
|
469
|
+
``Browser.close``) without disturbing the passthrough.
|
|
470
|
+
"""
|
|
429
471
|
async with websockets.connect(
|
|
430
472
|
chrome_url, max_size=None, ping_interval=None
|
|
431
473
|
) as chrome:
|
|
@@ -436,6 +478,8 @@ class ParsekServer:
|
|
|
436
478
|
if msg.type is not web.WSMsgType.TEXT:
|
|
437
479
|
break
|
|
438
480
|
await chrome.send(msg.data)
|
|
481
|
+
if on_client_frame is not None:
|
|
482
|
+
await on_client_frame(msg.data)
|
|
439
483
|
except (websockets.ConnectionClosed, ConnectionError):
|
|
440
484
|
pass
|
|
441
485
|
|
|
@@ -453,6 +497,24 @@ class ParsekServer:
|
|
|
453
497
|
)
|
|
454
498
|
await _close_pending(pending)
|
|
455
499
|
|
|
500
|
+
def _forget_on_close(self, browser_uuid: str):
|
|
501
|
+
"""Drop a browser from the registries once it reaches ``CLOSED``.
|
|
502
|
+
|
|
503
|
+
This is how a self-initiated shutdown -- notably the supervisor's idle
|
|
504
|
+
timeout (:meth:`BrowserSupervisor._close_idle`) -- gets its supervisor
|
|
505
|
+
removed from :attr:`supervisors` (and the sibling registries), without the
|
|
506
|
+
supervisor needing a back-reference to the server. Idempotent, so a
|
|
507
|
+
``CLOSED`` also driven by :meth:`_graceful_close`/:meth:`stop` is harmless.
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
def callback(state, reason=None) -> None:
|
|
511
|
+
if state is BrowserState.CLOSED:
|
|
512
|
+
self.supervisors.pop(browser_uuid, None)
|
|
513
|
+
self._browser_features.pop(browser_uuid, None)
|
|
514
|
+
self._control_clients.pop(browser_uuid, None)
|
|
515
|
+
|
|
516
|
+
return callback
|
|
517
|
+
|
|
456
518
|
def _state_broadcaster(self, browser_uuid: str):
|
|
457
519
|
def callback(state, reason) -> None:
|
|
458
520
|
frame = json.dumps(
|
|
@@ -173,6 +173,16 @@ class BrowserSupervisor:
|
|
|
173
173
|
raise RuntimeError("supervisor not started")
|
|
174
174
|
return f"{self.host}/devtools/page/{target_id}"
|
|
175
175
|
|
|
176
|
+
@property
|
|
177
|
+
def is_active(self) -> bool:
|
|
178
|
+
"""Whether the browser is up and serving (state ``READY``).
|
|
179
|
+
|
|
180
|
+
Metrics report only active browsers, so a starting / crashed / restarting
|
|
181
|
+
/ closed browser contributes no series (and its stale event counters are
|
|
182
|
+
pruned), preventing leftover data from lingering.
|
|
183
|
+
"""
|
|
184
|
+
return self.state is BrowserState.READY
|
|
185
|
+
|
|
176
186
|
@property
|
|
177
187
|
def pid(self) -> Optional[int]:
|
|
178
188
|
"""PID of the supervised browser process (``None`` before launch)."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsek-cdp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers.
|
|
5
5
|
Author: xa1era
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/xa1era/parsek-cdp
|
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: parsek-cdp~=0.1.
|
|
11
|
+
Requires-Dist: parsek-cdp~=0.1.2
|
|
12
12
|
Requires-Dist: aiohttp>=3.9
|
|
13
13
|
Requires-Dist: psutil>=5
|
|
14
14
|
Requires-Dist: prometheus-client>=0.19
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "parsek-cdp-server"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Parsek CDP server — launch, supervise and proxy a browser; server-side feature producers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -13,7 +13,7 @@ license = "Apache-2.0"
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
# Reuses the shared layers (cdp/, core/, parsek/) from the client distribution,
|
|
15
15
|
# including internal modules -- so track the client's patch line (>=0.1.0,<0.2.0).
|
|
16
|
-
"parsek-cdp~=0.1.
|
|
16
|
+
"parsek-cdp~=0.1.2",
|
|
17
17
|
"aiohttp>=3.9", # ws + HTTP endpoints (/cdp/{browser}/control, /cdp/{browser}/page/{target}, /browsers)
|
|
18
18
|
"psutil>=5", # watchdog over the Chrome process + zombie reaper
|
|
19
19
|
"prometheus-client>=0.19", # /metrics exposition
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{parsek_cdp_server-0.1.0 → parsek_cdp_server-0.1.2}/parsek_cdp_server.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|