refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Restructure the Python API and internals around composable namespaces and a standalone transport/endpoint layer. Bump to 0.12.0. Python API: - Replace flat methods (b.tabs_list(), b.group_list()) with namespaces: b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage, b.cookies, b.session, b.perf, b.extension. - Shrink browser_cli/__init__.py to a thin composition root; move all behaviour into browser_cli/sdk/ (one module per namespace + factories, base, routing). Internals: - Add browser_cli/transport.py and remote_transport.py to isolate IPC from command logic; client.py now delegates instead of owning transport. - Add browser_cli/endpoints.py for endpoint resolution and browser_cli/errors.py for shared error types. - Extract markdown rendering into browser_cli/markdown.py (out of extract). - Add USER_AGENT to version_manager. Tooling & tests: - Add justfile with common dev tasks. - Update CLI commands and demo to the namespaced API. - Rework tests for the new layout; add test_transport.py and test_refactor_boundaries.py to lock in module boundaries. BREAKING CHANGE: flat API methods are removed in favour of namespaces (e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
+61
-821
@@ -5,34 +5,66 @@ Usage:
|
||||
from browser_cli import BrowserCLI
|
||||
b = BrowserCLI()
|
||||
|
||||
tabs = b.tabs_list() # list[Tab]
|
||||
tabs = b.tabs.list() # list[Tab]
|
||||
tabs[0].close()
|
||||
tabs[0].move(forward=True)
|
||||
|
||||
groups = b.group_list() # list[Group]
|
||||
groups = b.groups.list() # list[Group]
|
||||
groups[0].tabs()
|
||||
groups[0].add_tab("https://example.com")
|
||||
|
||||
b.nav.open("https://example.com")
|
||||
b.dom.click("#submit")
|
||||
b.session.save("work")
|
||||
|
||||
# When multiple browser instances are active, pass the alias:
|
||||
b = BrowserCLI(browser="brave")
|
||||
"""
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
Commands are grouped into namespaces on the client:
|
||||
b.nav navigation (open, reload, back, forward, focus, search)
|
||||
b.tabs tabs (list, open, close, move, status, mute, sort, ...)
|
||||
b.groups tab groups (list, create, add_tab, move, close)
|
||||
b.windows browser windows (list, open, close, rename)
|
||||
b.dom page elements (query, click, type, wait_for, eval, ...)
|
||||
b.extract content extraction (links, images, text, json, markdown)
|
||||
b.page page info
|
||||
b.storage localStorage / sessionStorage
|
||||
b.cookies cookies (list, get, set)
|
||||
b.session sessions (save, load, list, diff, ...)
|
||||
b.perf performance profile + background jobs
|
||||
b.extension control the extension itself
|
||||
"""
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from browser_cli.models import Group, Tab
|
||||
from browser_cli.models import BrowserCounts, Group, Tab
|
||||
from browser_cli.sdk import (
|
||||
CookiesNS,
|
||||
DomNS,
|
||||
ExtensionNS,
|
||||
ExtractNS,
|
||||
GroupsNS,
|
||||
NavigationNS,
|
||||
PageNS,
|
||||
PerfNS,
|
||||
SessionNS,
|
||||
StorageNS,
|
||||
TabsNS,
|
||||
WindowsNS,
|
||||
)
|
||||
from browser_cli.sdk.factories import FactoryMixin
|
||||
from browser_cli.sdk.routing import RoutingMixin
|
||||
|
||||
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
|
||||
|
||||
class BrowserCLI(FactoryMixin, RoutingMixin):
|
||||
"""Client for a running browser, with commands grouped into namespaces.
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserCounts:
|
||||
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
|
||||
total: int
|
||||
by_browser: dict[str, int]
|
||||
The client itself holds the connection target (browser/remote/key) and the
|
||||
shared machinery; the actual commands live on namespace accessors such as
|
||||
:attr:`tabs`, :attr:`dom`, and :attr:`session`. Object construction
|
||||
(``Tab``/``Group``) comes from :class:`~browser_cli.sdk.factories.FactoryMixin`
|
||||
and multi-browser fan-out from :class:`~browser_cli.sdk.routing.RoutingMixin`.
|
||||
"""
|
||||
|
||||
|
||||
class BrowserCLI:
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
|
||||
"""
|
||||
Args:
|
||||
@@ -50,6 +82,20 @@ class BrowserCLI:
|
||||
self._remote = remote
|
||||
self._key = key if key else None
|
||||
|
||||
# Command namespaces.
|
||||
self.nav = NavigationNS(self)
|
||||
self.tabs = TabsNS(self)
|
||||
self.groups = GroupsNS(self)
|
||||
self.windows = WindowsNS(self)
|
||||
self.dom = DomNS(self)
|
||||
self.extract = ExtractNS(self)
|
||||
self.page = PageNS(self)
|
||||
self.storage = StorageNS(self)
|
||||
self.cookies = CookiesNS(self)
|
||||
self.session = SessionNS(self)
|
||||
self.perf = PerfNS(self)
|
||||
self.extension = ExtensionNS(self)
|
||||
|
||||
@property
|
||||
def browser(self) -> str | None:
|
||||
"""Target browser/profile alias, equivalent to ``--browser``."""
|
||||
@@ -72,816 +118,10 @@ class BrowserCLI:
|
||||
"""Send a raw browser-cli command and return its response.
|
||||
|
||||
This is the SDK escape hatch for commands that do not have a dedicated
|
||||
convenience method yet.
|
||||
namespace method yet.
|
||||
"""
|
||||
return self._cmd(command, args or {})
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
if self._remote:
|
||||
targets = remote_browser_targets(self._remote, key=self._key)
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
|
||||
else:
|
||||
data = send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
if targets:
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||
)
|
||||
return []
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────
|
||||
|
||||
def _make_tab(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
window_id=data.get("windowId", 0),
|
||||
active=data.get("active", False),
|
||||
muted=data.get("muted", False),
|
||||
title=data.get("title") or "",
|
||||
url=data.get("url") or "",
|
||||
group_id=data.get("groupId") or None,
|
||||
browser=browser_name,
|
||||
)
|
||||
tab._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return tab
|
||||
|
||||
def _make_group(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
title=data.get("title") or "",
|
||||
color=data.get("color") or "",
|
||||
collapsed=data.get("collapsed", False),
|
||||
tab_count=data.get("tabCount", 0),
|
||||
browser=browser_name,
|
||||
)
|
||||
group._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return group
|
||||
|
||||
# ── Navigation ────────────────────────────────────────────────────────
|
||||
|
||||
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
||||
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
|
||||
def open_tab(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
wait: bool = False,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> "Tab":
|
||||
"""Open URL in a new tab and return a bound :class:`Tab` object.
|
||||
|
||||
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||
"""
|
||||
if wait:
|
||||
return self.open_wait(url, timeout=timeout, background=background, window=window, group=group)
|
||||
data = self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.open returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def reload(self, tab_id: int | None = None) -> None:
|
||||
self._cmd("navigate.reload", {"tabId": tab_id})
|
||||
|
||||
def hard_reload(self, tab_id: int | None = None) -> None:
|
||||
self._cmd("navigate.hard_reload", {"tabId": tab_id})
|
||||
|
||||
def back(self, tab_id: int | None = None) -> None:
|
||||
self._cmd("navigate.back", {"tabId": tab_id})
|
||||
|
||||
def forward(self, tab_id: int | None = None) -> None:
|
||||
self._cmd("navigate.forward", {"tabId": tab_id})
|
||||
|
||||
def focus_url(self, pattern: str) -> None:
|
||||
self._cmd("navigate.focus", {"pattern": pattern})
|
||||
|
||||
def navigate_tab(self, tab_id: int, url: str) -> None:
|
||||
"""Navigate a specific tab to *url*."""
|
||||
self._cmd("navigate.to", {"tabId": tab_id, "url": url})
|
||||
|
||||
def open_wait(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> "Tab":
|
||||
"""Open URL in a new tab and block until fully loaded. Returns the Tab."""
|
||||
data = self._cmd("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": background, "window": window, "group": group,
|
||||
})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.open_wait returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def wait_for_load(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
ready_state: str = "complete",
|
||||
) -> Tab:
|
||||
"""Block until the tab finishes loading. Returns the Tab when ready.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
ready_state: ``"complete"`` (default) or ``"interactive"``.
|
||||
"""
|
||||
data = self._cmd("navigate.wait", {
|
||||
"tabId": tab_id,
|
||||
"timeout": int(timeout * 1000),
|
||||
"readyState": ready_state,
|
||||
})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.wait returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────
|
||||
|
||||
def search(
|
||||
self, engine: str, query: str, *,
|
||||
background: bool = False, window: str | None = None, group: str | None = None,
|
||||
) -> None:
|
||||
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.commands.search import ENGINES
|
||||
template = ENGINES.get(engine)
|
||||
if template is None:
|
||||
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
||||
url = template.format(query=quote_plus(query))
|
||||
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
def tabs(self) -> list[Tab]:
|
||||
"""Alias for :meth:`tabs_list`."""
|
||||
return self.tabs_list()
|
||||
|
||||
def tab(self, tab_id: int) -> Tab:
|
||||
"""Return a specific tab by ID."""
|
||||
return self.tabs_status(tab_id)
|
||||
|
||||
def active_tab(self) -> Tab:
|
||||
"""Return the active tab."""
|
||||
return self.tabs_status()
|
||||
|
||||
def find_tabs(self, search: str) -> list[Tab]:
|
||||
"""Alias for :meth:`tabs_query`."""
|
||||
return self.tabs_query(search)
|
||||
|
||||
def find_tab(self, search: str) -> Tab | None:
|
||||
"""Return the first tab matching *search*, or ``None``."""
|
||||
matches = self.tabs_query(search)
|
||||
return matches[0] if matches else None
|
||||
|
||||
def close_tab(self, tab: int | Tab) -> int:
|
||||
"""Close a tab by ID or :class:`Tab` object. Returns count closed."""
|
||||
tab_id = tab.id if isinstance(tab, Tab) else tab
|
||||
return self.tabs_close(tab_id)
|
||||
|
||||
def tabs_list(self) -> list[Tab]:
|
||||
"""Return all open tabs across all windows.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Tab
|
||||
includes ``tab.browser`` naming its source browser.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("tabs.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
self._make_tab(
|
||||
tab,
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
)
|
||||
for target, tabs in multi_results
|
||||
for tab in (tabs or [])
|
||||
]
|
||||
return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])]
|
||||
|
||||
def tabs_close(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
tab_ids: Iterable[int | Tab] | None = None,
|
||||
inactive: bool = False,
|
||||
duplicates: bool = False,
|
||||
) -> int:
|
||||
"""Close tab(s). Returns the number of tabs closed.
|
||||
|
||||
Pass ``tab_ids`` to close many tabs in a single round-trip instead of
|
||||
calling :meth:`close_tab` per tab. Accepts tab IDs or :class:`Tab`
|
||||
objects. The extension throttles large batches automatically.
|
||||
"""
|
||||
ids = None
|
||||
if tab_ids is not None:
|
||||
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
||||
result = self._cmd("tabs.close", {
|
||||
"tabId": tab_id,
|
||||
"tabIds": ids,
|
||||
"inactive": inactive,
|
||||
"duplicates": duplicates,
|
||||
})
|
||||
return result.get("closed", 1) if isinstance(result, dict) else 1
|
||||
|
||||
def tabs_move(
|
||||
self, tab_id: int, *,
|
||||
forward: bool = False, backward: bool = False,
|
||||
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
|
||||
) -> None:
|
||||
self._cmd("tabs.move", {
|
||||
"tabId": tab_id, "forward": forward, "backward": backward,
|
||||
"groupId": group_id, "windowId": window_id, "index": index,
|
||||
})
|
||||
|
||||
def tabs_active(self, tab_id: int) -> None:
|
||||
"""Switch browser focus to a tab by ID."""
|
||||
self._cmd("tabs.active", {"tabId": tab_id})
|
||||
|
||||
def tabs_status(self, tab_id: int | None = None) -> Tab:
|
||||
"""Return status for the active tab or a specific tab."""
|
||||
data = self._cmd("tabs.status", {"tabId": tab_id})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("No tab status returned")
|
||||
return self._make_tab(data)
|
||||
|
||||
def tabs_mute(self, tab_id: int | None = None) -> int:
|
||||
"""Mute the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.mute", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_unmute(self, tab_id: int | None = None) -> int:
|
||||
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.unmute", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_pin(self, tab_id: int | None = None) -> int:
|
||||
"""Pin the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.pin", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_unpin(self, tab_id: int | None = None) -> int:
|
||||
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.unpin", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_watch_url(
|
||||
self,
|
||||
pattern: str,
|
||||
*,
|
||||
tab_id: int | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> "Tab":
|
||||
"""Block until the tab URL matches regex pattern. Returns the Tab."""
|
||||
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("tabs.watch_url returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def tabs_screenshot(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
format: str = "png",
|
||||
quality: int | None = None,
|
||||
) -> str:
|
||||
"""Capture the visible area of a tab. Returns a base64 data URL.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to capture. Defaults to the active tab.
|
||||
format: ``"png"`` (default) or ``"jpeg"``.
|
||||
quality: JPEG quality 0-100 (ignored for PNG).
|
||||
"""
|
||||
result = self._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
|
||||
return result.get("dataUrl", "") if isinstance(result, dict) else str(result)
|
||||
|
||||
def window_active_tab(self, window_id: int) -> Tab:
|
||||
"""Return active tab for a specific browser window."""
|
||||
data = self._cmd("tabs.active_in_window", {"windowId": window_id})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError(f"No active tab found for window {window_id}")
|
||||
return self._make_tab(data)
|
||||
|
||||
def tabs_filter(self, pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
|
||||
"""Return tabs filtered by pattern or a Python callable."""
|
||||
if isinstance(pattern_or_filter, str):
|
||||
return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
|
||||
return self._apply_tab_filter(pattern_or_filter)
|
||||
|
||||
def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts:
|
||||
"""Count open tabs, optionally filtered by URL pattern.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("tabs.count", {"pattern": pattern})
|
||||
if multi_results:
|
||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
return self._cmd("tabs.count", {"pattern": pattern})
|
||||
|
||||
def tabs_query(self, search: str) -> list[Tab]:
|
||||
"""Search tabs by URL or title."""
|
||||
return [self._make_tab(t) for t in (self._cmd("tabs.query", {"search": search}) or [])]
|
||||
|
||||
def tabs_html(self, tab_id: int | None = None) -> str:
|
||||
"""Return the full HTML source of the active (or specified) tab."""
|
||||
return self._cmd("tabs.html", {"tabId": tab_id}) or ""
|
||||
|
||||
def tabs_dedupe(self) -> int:
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
|
||||
result = self._cmd("tabs.dedupe", {})
|
||||
return result.get("closed", 0) if isinstance(result, dict) else 0
|
||||
|
||||
def tabs_sort(self, by: str = "domain") -> None:
|
||||
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
|
||||
self._cmd("tabs.sort", {"by": by})
|
||||
|
||||
def tabs_merge_windows(self) -> int:
|
||||
"""Move all tabs into the focused window. Returns count moved."""
|
||||
result = self._cmd("tabs.merge_windows", {})
|
||||
return result.get("moved", 0) if isinstance(result, dict) else 0
|
||||
|
||||
def tabs_close_inactive(self) -> int:
|
||||
"""Close all inactive tabs. Returns count closed."""
|
||||
result = self._cmd("tabs.close", {"inactive": True})
|
||||
return result.get("closed", 0) if isinstance(result, dict) else 0
|
||||
|
||||
def tabs_close_duplicates(self) -> int:
|
||||
"""Close duplicate tabs. Returns count closed."""
|
||||
result = self._cmd("tabs.close", {"duplicates": True})
|
||||
return result.get("closed", 0) if isinstance(result, dict) else 0
|
||||
|
||||
# ── Tab Groups ────────────────────────────────────────────────────────
|
||||
|
||||
def groups(self) -> list[Group]:
|
||||
"""Alias for :meth:`group_list`."""
|
||||
return self.group_list()
|
||||
|
||||
def groups_list(self) -> list[Group]:
|
||||
"""Alias for :meth:`group_list`."""
|
||||
return self.group_list()
|
||||
|
||||
def group_list(self) -> list[Group]:
|
||||
"""Return all tab groups.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Group
|
||||
includes ``group.browser`` naming its source browser.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("group.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
self._make_group(
|
||||
group,
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
)
|
||||
for target, groups in multi_results
|
||||
for group in (groups or [])
|
||||
]
|
||||
return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])]
|
||||
|
||||
def group_tabs(self, group_id: int) -> list[Tab]:
|
||||
"""Return all tabs inside a group."""
|
||||
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
|
||||
|
||||
def groups_count(self) -> int | BrowserCounts:
|
||||
"""Alias for :meth:`group_count`."""
|
||||
return self.group_count()
|
||||
|
||||
def group_count(self) -> int | BrowserCounts:
|
||||
"""Return the number of tab groups.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("group.count", {})
|
||||
if multi_results:
|
||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
return self._cmd("group.count", {})
|
||||
|
||||
def groups_query(self, search: str) -> list[Group]:
|
||||
"""Alias for :meth:`group_query`."""
|
||||
return self.group_query(search)
|
||||
|
||||
def group_query(self, search: str) -> list[Group]:
|
||||
"""Search groups by name."""
|
||||
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
|
||||
|
||||
def group_close(self, group_id: int) -> None:
|
||||
"""Ungroup (and close) a tab group by ID."""
|
||||
self._cmd("group.close", {"groupId": group_id})
|
||||
|
||||
def groups_create(self, name: str) -> Group:
|
||||
"""Alias for :meth:`group_create`."""
|
||||
return self.group_create(name)
|
||||
|
||||
def group_open(self, name: str) -> Group:
|
||||
"""Alias for :meth:`group_create`."""
|
||||
return self.group_create(name)
|
||||
|
||||
def group_create(self, name: str) -> Group:
|
||||
"""Create a new tab group with *name*. Returns the created Group."""
|
||||
data = self._cmd("group.open", {"name": name})
|
||||
return self._make_group(data) if isinstance(data, dict) else Group(id=data, title=name, color="", collapsed=False, tab_count=0)
|
||||
|
||||
def group_add_tab(self, group: str | int, url: str | None = None) -> int | None:
|
||||
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
|
||||
result = self._cmd("group.add_tab", {"group": str(group), "url": url})
|
||||
return result.get("tabId") if isinstance(result, dict) else result
|
||||
|
||||
def group_move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> None:
|
||||
"""Move a tab group forward or backward."""
|
||||
self._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
|
||||
|
||||
# ── Windows ───────────────────────────────────────────────────────────
|
||||
|
||||
def windows_list(self) -> list[dict]:
|
||||
"""Return browser windows.
|
||||
|
||||
In implicit multi-browser mode each window dict includes a ``browser`` key.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("windows.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
{**window, "browser": target.display_name}
|
||||
for target, windows in multi_results
|
||||
for window in (windows or [])
|
||||
]
|
||||
return self._cmd("windows.list", {}) or []
|
||||
|
||||
def windows_rename(self, window_id: int, name: str) -> None:
|
||||
self._cmd("windows.rename", {"windowId": window_id, "name": name})
|
||||
|
||||
def windows_close(self, window_id: int) -> None:
|
||||
self._cmd("windows.close", {"windowId": window_id})
|
||||
|
||||
def windows_open(self, url: str | None = None) -> dict:
|
||||
"""Open a new browser window, optionally on a URL."""
|
||||
return self._cmd("windows.open", {"url": url}) or {}
|
||||
|
||||
# ── DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
def dom_query(self, selector: str) -> list[dict]:
|
||||
return self._cmd("dom.query", {"selector": selector}) or []
|
||||
|
||||
def dom_click(self, selector: str) -> None:
|
||||
self._cmd("dom.click", {"selector": selector})
|
||||
|
||||
def dom_type(self, selector: str, text: str) -> None:
|
||||
self._cmd("dom.type", {"selector": selector, "text": text})
|
||||
|
||||
def dom_attr(self, selector: str, attr: str) -> list[str]:
|
||||
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
|
||||
|
||||
def dom_text(self, selector: str) -> list[str]:
|
||||
return self._cmd("dom.text", {"selector": selector}) or []
|
||||
|
||||
def dom_exists(self, selector: str) -> bool:
|
||||
return self._cmd("dom.exists", {"selector": selector}) or False
|
||||
|
||||
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
|
||||
"""Scroll to a CSS selector or to pixel coordinates."""
|
||||
self._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
|
||||
def dom_select(self, selector: str, value: str) -> None:
|
||||
"""Set the value of a <select> element."""
|
||||
self._cmd("dom.select", {"selector": selector, "value": value})
|
||||
|
||||
def dom_eval(self, code: str, tab_id: int | None = None):
|
||||
"""Evaluate JavaScript in the page's main world and return the result."""
|
||||
return self._cmd("dom.eval", {"code": code, "tabId": tab_id})
|
||||
|
||||
def dom_key(self, key: str, selector: str | None = None) -> None:
|
||||
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
|
||||
self._cmd("dom.key", {"key": key, "selector": selector})
|
||||
|
||||
def dom_hover(self, selector: str) -> None:
|
||||
"""Dispatch mouseover/mouseenter on an element."""
|
||||
self._cmd("dom.hover", {"selector": selector})
|
||||
|
||||
def dom_check(self, selector: str) -> None:
|
||||
"""Check a checkbox."""
|
||||
self._cmd("dom.check", {"selector": selector})
|
||||
|
||||
def dom_uncheck(self, selector: str) -> None:
|
||||
"""Uncheck a checkbox."""
|
||||
self._cmd("dom.uncheck", {"selector": selector})
|
||||
|
||||
def dom_clear(self, selector: str) -> None:
|
||||
"""Clear the value of an input element."""
|
||||
self._cmd("dom.clear", {"selector": selector})
|
||||
|
||||
def dom_focus(self, selector: str) -> None:
|
||||
"""Focus an element."""
|
||||
self._cmd("dom.focus", {"selector": selector})
|
||||
|
||||
def dom_submit(self, selector: str) -> None:
|
||||
"""Submit the form containing the matched element."""
|
||||
self._cmd("dom.submit", {"selector": selector})
|
||||
|
||||
def dom_poll(
|
||||
self,
|
||||
selector: str,
|
||||
pattern: str,
|
||||
*,
|
||||
attr: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
interval: float = 0.5,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Poll selector's text/value until it matches regex pattern.
|
||||
|
||||
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
|
||||
"""
|
||||
return self._cmd("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
def wait_for_selector(
|
||||
self,
|
||||
selector: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
visible: bool = False,
|
||||
hidden: bool = False,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Alias for :meth:`dom_wait_for`."""
|
||||
return self.dom_wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
|
||||
|
||||
def dom_wait_for(
|
||||
self,
|
||||
selector: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
visible: bool = False,
|
||||
hidden: bool = False,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Wait until a CSS selector appears (or disappears) in the DOM.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to watch.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
visible: Wait until the element has non-zero dimensions.
|
||||
hidden: Wait until the element is absent or has ``offsetParent == null``.
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
"""
|
||||
return self._cmd("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
# ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
def page_info(self) -> dict:
|
||||
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
|
||||
return self._cmd("page.info", {}) or {}
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────
|
||||
|
||||
def storage_get(
|
||||
self,
|
||||
key: str | None = None,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> str | dict | None:
|
||||
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
|
||||
return self._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
|
||||
|
||||
def storage_set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> None:
|
||||
"""Set a localStorage/sessionStorage entry."""
|
||||
self._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
|
||||
|
||||
# ── Cookies ───────────────────────────────────────────────────────────
|
||||
|
||||
def cookies_list(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
domain: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List cookies, optionally filtered by url, domain, or name."""
|
||||
return self._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
|
||||
def cookies_get(self, url: str, name: str) -> dict | None:
|
||||
"""Get a single cookie by url and name."""
|
||||
return self._cmd("cookies.get", {"url": url, "name": name})
|
||||
|
||||
def cookies_set(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
value: str,
|
||||
*,
|
||||
domain: str | None = None,
|
||||
path: str | None = None,
|
||||
secure: bool | None = None,
|
||||
http_only: bool | None = None,
|
||||
expiration_date: float | None = None,
|
||||
same_site: str | None = None,
|
||||
) -> dict:
|
||||
"""Set a cookie. Returns the created cookie dict."""
|
||||
return self._cmd("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure, "httpOnly": http_only,
|
||||
"expirationDate": expiration_date, "sameSite": same_site,
|
||||
})
|
||||
|
||||
# ── Extract ───────────────────────────────────────────────────────────
|
||||
|
||||
def extract_links(self) -> list[dict]:
|
||||
return self._cmd("extract.links", {}) or []
|
||||
|
||||
def extract_images(self) -> list[dict]:
|
||||
return self._cmd("extract.images", {}) or []
|
||||
|
||||
def extract_text(self) -> str:
|
||||
return self._cmd("extract.text", {}) or ""
|
||||
|
||||
def extract_json(self, selector: str):
|
||||
return self._cmd("extract.json", {"selector": selector})
|
||||
|
||||
def extract_markdown(self, selector: str | None = None) -> str:
|
||||
return self._cmd("extract.markdown", {"selector": selector}) or ""
|
||||
|
||||
# ── Session ───────────────────────────────────────────────────────────
|
||||
|
||||
def session_save(self, name: str) -> None:
|
||||
self._cmd("session.save", {"name": name})
|
||||
|
||||
def session_load(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> None:
|
||||
self._cmd("session.load", {
|
||||
"name": name,
|
||||
"gentleMode": gentle_mode,
|
||||
"discardBackgroundTabs": discard_background_tabs,
|
||||
"lazy": lazy,
|
||||
"eagerTabs": eager_tabs,
|
||||
})
|
||||
|
||||
def session_load_background(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> dict:
|
||||
return self._cmd("session.load", {
|
||||
"name": name,
|
||||
"gentleMode": gentle_mode,
|
||||
"discardBackgroundTabs": discard_background_tabs,
|
||||
"lazy": lazy,
|
||||
"eagerTabs": eager_tabs,
|
||||
"__background": True,
|
||||
}) or {}
|
||||
|
||||
def job_status(self, job_id: str) -> dict:
|
||||
return self._cmd("jobs.status", {"jobId": job_id}) or {}
|
||||
|
||||
def job_cancel(self, job_id: str) -> dict:
|
||||
return self._cmd("jobs.cancel", {"jobId": job_id}) or {}
|
||||
|
||||
def reload_extension(self) -> None:
|
||||
"""Reload the browser-cli extension service worker.
|
||||
|
||||
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
|
||||
immediately. The extension restarts ~200 ms later and reconnects via the
|
||||
keepalive alarm within ~25 seconds.
|
||||
"""
|
||||
self._cmd("extension.reload", {})
|
||||
|
||||
def perf_status(self) -> dict:
|
||||
return self._cmd("perf.status", {}) or {}
|
||||
|
||||
def set_performance_profile(self, profile: str) -> dict:
|
||||
return self._cmd("perf.set_profile", {"profile": profile}) or {}
|
||||
|
||||
def session_diff(self, name_a: str, name_b: str) -> dict:
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
def session_list(self) -> list[dict]:
|
||||
"""Return saved sessions.
|
||||
|
||||
In implicit multi-browser mode each session dict includes a ``browser`` key.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser("session.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
{**session, "browser": target.display_name}
|
||||
for target, sessions in multi_results
|
||||
for session in (sessions or [])
|
||||
]
|
||||
return self._cmd("session.list", {}) or []
|
||||
|
||||
def session_remove(self, name: str) -> None:
|
||||
self._cmd("session.remove", {"name": name})
|
||||
|
||||
def session_auto_save(self, enabled: bool) -> None:
|
||||
self._cmd("session.auto_save", {"enabled": enabled})
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────
|
||||
|
||||
def clients(self) -> list[dict]:
|
||||
"""Return the active browser clients known to this connection."""
|
||||
return self._cmd("clients.list", {})
|
||||
|
||||
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
|
||||
tabs = self.tabs_list()
|
||||
|
||||
try:
|
||||
transformed = filter_fn(tabs)
|
||||
except (AttributeError, TypeError):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
if isinstance(transformed, list):
|
||||
return transformed
|
||||
if isinstance(transformed, tuple):
|
||||
return list(transformed)
|
||||
if isinstance(transformed, set):
|
||||
return list(transformed)
|
||||
if transformed is tabs:
|
||||
return tabs
|
||||
if isinstance(transformed, bool):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
try:
|
||||
return list(transformed)
|
||||
except TypeError:
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
+10
-33
@@ -88,7 +88,6 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
||||
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
|
||||
}
|
||||
|
||||
|
||||
def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||
if target_browser:
|
||||
return target_browser
|
||||
@@ -98,7 +97,6 @@ def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||
return active[0].profile
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
|
||||
target_profile = _rename_target_profile(target_browser)
|
||||
|
||||
@@ -107,14 +105,12 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
|
||||
def _native_host_exe() -> Path:
|
||||
base = install_base_dir()
|
||||
if is_windows():
|
||||
return base / "libexec" / "browser-cli-native-host.cmd"
|
||||
return base / "libexec" / "browser-cli-native-host"
|
||||
|
||||
|
||||
def _write_native_host_exe(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_windows():
|
||||
@@ -128,13 +124,11 @@ def _write_native_host_exe(path: Path) -> None:
|
||||
)
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
|
||||
|
||||
def _windows_registry_views():
|
||||
import winreg
|
||||
|
||||
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
|
||||
|
||||
|
||||
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
|
||||
import winreg
|
||||
|
||||
@@ -152,7 +146,6 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
|
||||
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
|
||||
return installed
|
||||
|
||||
|
||||
def _project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
@@ -168,7 +161,6 @@ def _project_version() -> str:
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _print_version(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
@@ -214,14 +206,12 @@ def main(ctx, browser, remote, key):
|
||||
os.environ["BROWSER_CLI_KEY"] = key
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@click.group("auth")
|
||||
def auth_group():
|
||||
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
|
||||
|
||||
|
||||
@auth_group.command("keygen")
|
||||
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
|
||||
@click.option("--force", is_flag=True, help="Overwrite existing key.")
|
||||
@@ -243,7 +233,6 @@ def cmd_auth_keygen(output, force):
|
||||
console.print(f"\nOn the serve host, trust this key:")
|
||||
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
|
||||
|
||||
|
||||
@auth_group.command("trust")
|
||||
@click.argument("pubkey")
|
||||
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
||||
@@ -290,7 +279,6 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||
|
||||
|
||||
@auth_group.command("show")
|
||||
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
|
||||
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
|
||||
@@ -325,7 +313,6 @@ def cmd_auth_show(key_src):
|
||||
console.print(f"[red]Failed to load key:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@auth_group.command("keys")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
@@ -362,7 +349,6 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
table.add_row(name, entry.get("pubkey", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
main.add_command(auth_group)
|
||||
|
||||
# ── Sub-command groups ─────────────────────────────────────────────────────────
|
||||
@@ -381,9 +367,15 @@ main.add_command(perf_group)
|
||||
main.add_command(extension_group)
|
||||
main.add_command(cmd_serve)
|
||||
|
||||
|
||||
# ── clients ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None):
|
||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = label
|
||||
into.append(c)
|
||||
|
||||
@click.group("clients", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def clients_group(ctx):
|
||||
@@ -409,10 +401,7 @@ def clients_group(ctx):
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
elif remote:
|
||||
@@ -432,10 +421,7 @@ def clients_group(ctx):
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
result = send_command("clients.list", profile=profile_name)
|
||||
for c in (result or []):
|
||||
c["profile"] = display_profile
|
||||
all_clients.append(c)
|
||||
_append_clients(all_clients, display_profile, profile=profile_name)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
# Socket registered but browser no longer connected
|
||||
all_clients.append({
|
||||
@@ -449,10 +435,7 @@ def clients_group(ctx):
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=target.remote)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
_append_clients(all_clients, target.display_name, profile=target.profile, remote=target.remote)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
@@ -475,10 +458,8 @@ def clients_group(ctx):
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
main.add_command(clients_group)
|
||||
|
||||
|
||||
@clients_group.command("rename")
|
||||
@click.option(
|
||||
"--browser", "target_browser", default=None, metavar="ALIAS",
|
||||
@@ -495,7 +476,6 @@ def cmd_clients_rename(target_browser, alias):
|
||||
sys.exit(1)
|
||||
console.print(f"[green]Profile renamed to '{alias}'[/green]")
|
||||
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@main.command("install")
|
||||
@@ -567,7 +547,6 @@ def cmd_install(browser):
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
|
||||
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
|
||||
|
||||
@main.command("native-host", hidden=True)
|
||||
@@ -576,7 +555,6 @@ def cmd_native_host():
|
||||
from browser_cli.native_host import main as _main
|
||||
_main()
|
||||
|
||||
|
||||
# ── completion ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@main.command("completion")
|
||||
@@ -624,6 +602,5 @@ def cmd_completion(shell, script):
|
||||
console.print()
|
||||
console.print(f" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+17
-182
@@ -10,10 +10,8 @@ Profile selection order:
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
@@ -21,57 +19,26 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||
from browser_cli.registry import load_registry
|
||||
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
|
||||
except Exception:
|
||||
_USER_AGENT = "browser-cli/0"
|
||||
# Re-exported for backward compatibility — these used to live here and are still
|
||||
# referenced as ``browser_cli.client.<name>`` by callers, serve.py, and tests.
|
||||
from browser_cli.errors import BrowserNotConnected # noqa: F401
|
||||
from browser_cli.endpoints import ( # noqa: F401
|
||||
_DEFAULT_REMOTE_PORT,
|
||||
_looks_like_domain,
|
||||
_normalize_endpoint,
|
||||
_remote_display_name,
|
||||
_resolve_connect_endpoint,
|
||||
display_browser_name,
|
||||
)
|
||||
from browser_cli.remote_transport import _recv_all, _recv_exact, _send_remote # noqa: F401
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
|
||||
|
||||
_DEFAULT_REMOTE_PORT = 443
|
||||
|
||||
|
||||
def _looks_like_domain(host: str) -> bool:
|
||||
"""True if host looks like a domain name rather than an IP address or localhost."""
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
|
||||
return False
|
||||
return '.' in host and any(c.isalpha() for c in host)
|
||||
|
||||
|
||||
def _normalize_endpoint(endpoint: str) -> str:
|
||||
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and port == "443" and _looks_like_domain(host):
|
||||
return host
|
||||
return endpoint
|
||||
|
||||
|
||||
def _resolve_connect_endpoint(endpoint: str) -> str:
|
||||
"""Return host:port for TCP connection; domain without port defaults to :443."""
|
||||
_, sep, _ = endpoint.rpartition(":")
|
||||
if not sep:
|
||||
if _looks_like_domain(endpoint):
|
||||
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
|
||||
raise BrowserNotConnected(
|
||||
f"Invalid remote endpoint '{endpoint}': expected host:port"
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
class BrowserNotConnected(Exception):
|
||||
"""Raised when the native host socket is not available."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserTarget:
|
||||
profile: str
|
||||
@@ -98,12 +65,6 @@ def _active_endpoints(reg: dict) -> dict:
|
||||
return dict(reg)
|
||||
return {k: v for k, v in reg.items() if _is_reachable_unix_endpoint(v)}
|
||||
|
||||
def display_browser_name(profile_name: str, sock_path: str) -> str:
|
||||
if profile_name != "default":
|
||||
return profile_name
|
||||
return Path(sock_path).stem or profile_name
|
||||
|
||||
|
||||
def _load_remotes() -> dict[str, dict[str, str]]:
|
||||
if not REMOTE_REGISTRY_PATH.exists():
|
||||
return {}
|
||||
@@ -116,13 +77,10 @@ def _load_remotes() -> dict[str, dict[str, str]]:
|
||||
# normalize keys so old entries stored as "domain:443" match current lookups
|
||||
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
|
||||
|
||||
|
||||
|
||||
def _is_valid_key_spec(s: str) -> bool:
|
||||
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
|
||||
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
|
||||
|
||||
|
||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
||||
if not endpoint or not key_spec:
|
||||
@@ -138,7 +96,6 @@ def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def key_for_remote(endpoint: str | None) -> str | None:
|
||||
if not endpoint:
|
||||
return None
|
||||
@@ -152,16 +109,6 @@ def key_for_remote(endpoint: str | None) -> str | None:
|
||||
return None
|
||||
return key_str
|
||||
|
||||
|
||||
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
|
||||
display_endpoint = host
|
||||
else:
|
||||
display_endpoint = endpoint # normalized domain (no port) or non-default port
|
||||
return f"{display_endpoint}:{display_name or profile_name}"
|
||||
|
||||
|
||||
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
"""Return browser targets advertised by a single remote endpoint."""
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
|
||||
@@ -179,7 +126,6 @@ def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in _load_remotes():
|
||||
@@ -189,7 +135,6 @@ def _remote_browser_targets(key=None) -> list[BrowserTarget]:
|
||||
continue
|
||||
return targets
|
||||
|
||||
|
||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||
if not alias:
|
||||
@@ -228,7 +173,6 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
if REGISTRY_PATH.exists():
|
||||
@@ -241,7 +185,6 @@ def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[Br
|
||||
targets.extend(_remote_browser_targets(key=key))
|
||||
return targets
|
||||
|
||||
|
||||
def _is_active_local_profile(profile: str | None) -> bool:
|
||||
"""Return True when profile names a reachable local browser endpoint."""
|
||||
if not profile:
|
||||
@@ -257,7 +200,6 @@ def _is_active_local_profile(profile: str | None) -> bool:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_socket(profile: str | None = None) -> str:
|
||||
"""Return the socket path for the given profile (or auto-detect)."""
|
||||
target = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
@@ -292,7 +234,6 @@ def _resolve_socket(profile: str | None = None) -> str:
|
||||
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||
)
|
||||
|
||||
|
||||
def _load_private_key(key_path: "Path | str | None" = None):
|
||||
"""Load an Ed25519 signing key.
|
||||
|
||||
@@ -320,96 +261,6 @@ def _load_private_key(key_path: "Path | str | None" = None):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
port = int(port_str)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
try:
|
||||
raw_sock.connect((host, port))
|
||||
if port == 443:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
|
||||
# receive challenge
|
||||
challenge_raw = _recv_all(sock)
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected(f"No challenge received from {endpoint}")
|
||||
try:
|
||||
challenge = json.loads(challenge_raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if min_ver:
|
||||
from browser_cli.version_manager import parse_version
|
||||
try:
|
||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(min_ver):
|
||||
raise BrowserNotConnected(
|
||||
f"Client version {client_ver} is too old for this server "
|
||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pq_shared_secret = None
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
|
||||
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
|
||||
if pq_shared_secret is not None:
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
msg = {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": public_key_hex(private_key),
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
sock.sendall(framed)
|
||||
response = _recv_all(sock)
|
||||
if response is not None and pq_shared_secret is not None:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt
|
||||
envelope = json.loads(response)
|
||||
if isinstance(envelope, dict) and "encrypted" in envelope:
|
||||
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
||||
except Exception as e:
|
||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
||||
return response
|
||||
|
||||
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
@@ -423,7 +274,6 @@ def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
@@ -443,7 +293,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
"args": args or {},
|
||||
}
|
||||
if remote_endpoint:
|
||||
from browser_cli import transport
|
||||
msg["user_agent"] = _USER_AGENT
|
||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
|
||||
key_spec = key if key is not None else key_for_remote(remote_endpoint)
|
||||
private_key = _load_private_key(key_spec)
|
||||
@@ -493,25 +345,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
|
||||
if response is None:
|
||||
raise ConnectionError("Connection closed before full response received")
|
||||
result = json.loads(response)
|
||||
from browser_cli import transport
|
||||
result = transport.decode_response(response)
|
||||
if not result.get("success", True):
|
||||
raise RuntimeError(result.get("error", "unknown error from browser"))
|
||||
return result.get("data")
|
||||
|
||||
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
raw_len = _recv_exact(sock, 4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
raise ConnectionError(f"Response too large ({msg_len} bytes)")
|
||||
return _recv_exact(sock, msg_len)
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed before full message received")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
@@ -1,40 +1,79 @@
|
||||
"""Shared helpers for the Click CLI command modules.
|
||||
|
||||
Every CLI command is a thin presentation layer over the Python SDK: it builds a
|
||||
:class:`~browser_cli.BrowserCLI` from the global ``--browser/--remote/--key``
|
||||
options, calls the matching SDK namespace method, and renders the result. The
|
||||
SDK is the single source of truth for command strings, argument shapes, and
|
||||
multi-browser routing.
|
||||
"""
|
||||
import functools
|
||||
import click
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from browser_cli import BrowserCLI, BrowserCounts
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
|
||||
_console = Console()
|
||||
|
||||
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
_console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
_console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
|
||||
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
def gentle_mode_option(help_text: str):
|
||||
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
|
||||
return click.option(
|
||||
"--gentle-mode",
|
||||
type=click.Choice(GENTLE_MODES),
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help=help_text,
|
||||
)
|
||||
|
||||
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
||||
"""Render a count result.
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
remote = root.obj.get("remote")
|
||||
key = root.obj.get("key")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, key=key)
|
||||
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
|
||||
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
|
||||
"""
|
||||
if isinstance(result, BrowserCounts):
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Browser")
|
||||
table.add_column(f"{noun.capitalize()}s", justify="right")
|
||||
for name, count in result.by_browser.items():
|
||||
table.add_row(name, str(count))
|
||||
table.add_row("Total", str(result.total))
|
||||
_console.print(table)
|
||||
else:
|
||||
targets = active_browser_targets(key=key)
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
|
||||
|
||||
def client_from_ctx() -> BrowserCLI:
|
||||
"""Build a BrowserCLI from the root context's global options.
|
||||
|
||||
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
|
||||
Falls back to an unconfigured client when a command group is invoked
|
||||
standalone (e.g. in unit tests).
|
||||
"""
|
||||
obj = click.get_current_context().find_root().obj or {}
|
||||
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
|
||||
|
||||
def handle_errors(fn):
|
||||
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
|
||||
|
||||
Apply as the innermost decorator (directly above ``def``) so Click's option
|
||||
decorators attach their params to the wrapper.
|
||||
"""
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except BrowserNotConnected as e:
|
||||
_console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
_console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("cookies")
|
||||
def cookies_group():
|
||||
"""Manage browser cookies."""
|
||||
|
||||
|
||||
@cookies_group.command("list")
|
||||
@click.option("--url", default=None, help="Filter by URL")
|
||||
@click.option("--domain", default=None, help="Filter by domain")
|
||||
@click.option("--name", default=None, help="Filter by cookie name")
|
||||
@handle_errors
|
||||
def cookies_list(url, domain, name):
|
||||
"""List cookies, optionally filtered by URL, domain, or name."""
|
||||
cookies = _handle("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
cookies = client_from_ctx().cookies.list(url=url, domain=domain, name=name)
|
||||
if not cookies:
|
||||
console.print("[yellow]No cookies found[/yellow]")
|
||||
return
|
||||
@@ -39,19 +38,18 @@ def cookies_list(url, domain, name):
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cookies_group.command("get")
|
||||
@click.argument("url")
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def cookies_get(url, name):
|
||||
"""Get the value of a single cookie by URL and NAME."""
|
||||
cookie = _handle("cookies.get", {"url": url, "name": name})
|
||||
cookie = client_from_ctx().cookies.get(url, name)
|
||||
if cookie is None:
|
||||
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
|
||||
raise SystemExit(1)
|
||||
console.print(cookie.get("value", ""))
|
||||
|
||||
|
||||
@cookies_group.command("set")
|
||||
@click.argument("url")
|
||||
@click.argument("name")
|
||||
@@ -62,14 +60,13 @@ def cookies_get(url, name):
|
||||
@click.option("--http-only", "http_only", is_flag=True)
|
||||
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
|
||||
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
|
||||
@handle_errors
|
||||
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
|
||||
"""Set a cookie on URL."""
|
||||
_handle("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure or None,
|
||||
"httpOnly": http_only or None,
|
||||
"expirationDate": expiration_date,
|
||||
"sameSite": same_site,
|
||||
})
|
||||
client_from_ctx().cookies.set(
|
||||
url, name, value,
|
||||
domain=domain, path=path,
|
||||
secure=secure or None, http_only=http_only or None,
|
||||
expiration_date=expiration_date, same_site=same_site,
|
||||
)
|
||||
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
|
||||
|
||||
+40
-57
@@ -1,22 +1,21 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
import json
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("dom")
|
||||
def dom_group():
|
||||
"""Query and interact with page DOM elements."""
|
||||
|
||||
|
||||
@dom_group.command("query")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_query(selector):
|
||||
"""Return elements matching CSS SELECTOR (like mini DevTools)."""
|
||||
elements = _handle("dom.query", {"selector": selector})
|
||||
elements = client_from_ctx().dom.query(selector)
|
||||
if not elements:
|
||||
console.print("[yellow]No elements found[/yellow]")
|
||||
return
|
||||
@@ -29,180 +28,164 @@ def dom_query(selector):
|
||||
table.add_row(el.get("tag", ""), (el.get("text") or "")[:60], attrs[:80])
|
||||
console.print(table)
|
||||
|
||||
|
||||
@dom_group.command("click")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_click(selector):
|
||||
"""Click the first element matching CSS SELECTOR."""
|
||||
_handle("dom.click", {"selector": selector})
|
||||
client_from_ctx().dom.click(selector)
|
||||
console.print(f"[green]Clicked:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("type")
|
||||
@click.argument("selector")
|
||||
@click.argument("text")
|
||||
@handle_errors
|
||||
def dom_type(selector, text):
|
||||
"""Type TEXT into the element matching CSS SELECTOR."""
|
||||
_handle("dom.type", {"selector": selector, "text": text})
|
||||
client_from_ctx().dom.type(selector, text)
|
||||
console.print(f"[green]Typed into:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("attr")
|
||||
@click.argument("selector")
|
||||
@click.argument("attr_name")
|
||||
@handle_errors
|
||||
def dom_attr(selector, attr_name):
|
||||
"""Get attribute ATTR_NAME from elements matching CSS SELECTOR."""
|
||||
values = _handle("dom.attr", {"selector": selector, "attr": attr_name})
|
||||
for v in (values or []):
|
||||
for v in client_from_ctx().dom.attr(selector, attr_name):
|
||||
console.print(v)
|
||||
|
||||
|
||||
@dom_group.command("text")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_text(selector):
|
||||
"""Get text content of elements matching CSS SELECTOR."""
|
||||
values = _handle("dom.text", {"selector": selector})
|
||||
for v in (values or []):
|
||||
for v in client_from_ctx().dom.text(selector):
|
||||
console.print(v)
|
||||
|
||||
|
||||
@dom_group.command("exists")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_exists(selector):
|
||||
"""Check if an element matching CSS SELECTOR exists on the page."""
|
||||
exists = _handle("dom.exists", {"selector": selector})
|
||||
if exists:
|
||||
if client_from_ctx().dom.exists(selector):
|
||||
console.print(f"[green]exists[/green]: {selector}")
|
||||
else:
|
||||
console.print(f"[red]not found[/red]: {selector}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@dom_group.command("scroll")
|
||||
@click.argument("selector", required=False)
|
||||
@click.option("--x", type=int, default=None, help="Horizontal scroll position (px)")
|
||||
@click.option("--y", type=int, default=None, help="Vertical scroll position (px)")
|
||||
@handle_errors
|
||||
def dom_scroll(selector, x, y):
|
||||
"""Scroll to a CSS SELECTOR or to an X/Y coordinate."""
|
||||
_handle("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
client_from_ctx().dom.scroll(selector, x=x, y=y)
|
||||
target = selector or f"({x or 0}, {y or 0})"
|
||||
console.print(f"[green]Scrolled to:[/green] {target}")
|
||||
|
||||
|
||||
@dom_group.command("select")
|
||||
@click.argument("selector")
|
||||
@click.argument("value")
|
||||
@handle_errors
|
||||
def dom_select(selector, value):
|
||||
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
|
||||
_handle("dom.select", {"selector": selector, "value": value})
|
||||
client_from_ctx().dom.select(selector, value)
|
||||
console.print(f"[green]Selected '{value}' in:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("eval")
|
||||
@click.argument("code")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@handle_errors
|
||||
def dom_eval(code, tab_id):
|
||||
"""Evaluate JavaScript CODE in the page and print the result."""
|
||||
result = _handle("dom.eval", {"code": code, "tabId": tab_id})
|
||||
result = client_from_ctx().dom.eval(code, tab_id)
|
||||
if result is None:
|
||||
console.print("[dim]null[/dim]")
|
||||
else:
|
||||
console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result))
|
||||
|
||||
|
||||
@dom_group.command("wait-for")
|
||||
@click.argument("selector")
|
||||
@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)")
|
||||
@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@handle_errors
|
||||
def dom_wait_for(selector, timeout, visible, hidden, tab_id):
|
||||
"""Wait until CSS SELECTOR appears (or disappears) in the DOM."""
|
||||
_handle("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
client_from_ctx().dom.wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
|
||||
state = "hidden" if hidden else ("visible" if visible else "present")
|
||||
console.print(f"[green]Ready ({state}):[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("key")
|
||||
@click.argument("key")
|
||||
@click.option("--selector", default=None, help="CSS selector to target (default: focused element)")
|
||||
@handle_errors
|
||||
def dom_key(key, selector):
|
||||
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
|
||||
_handle("dom.key", {"key": key, "selector": selector})
|
||||
client_from_ctx().dom.key(key, selector)
|
||||
target = selector or "active element"
|
||||
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
|
||||
|
||||
|
||||
@dom_group.command("hover")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_hover(selector):
|
||||
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
|
||||
_handle("dom.hover", {"selector": selector})
|
||||
client_from_ctx().dom.hover(selector)
|
||||
console.print(f"[green]Hovered:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("check")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_check(selector):
|
||||
"""Check a checkbox matching CSS SELECTOR."""
|
||||
_handle("dom.check", {"selector": selector})
|
||||
client_from_ctx().dom.check(selector)
|
||||
console.print(f"[green]Checked:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("uncheck")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_uncheck(selector):
|
||||
"""Uncheck a checkbox matching CSS SELECTOR."""
|
||||
_handle("dom.uncheck", {"selector": selector})
|
||||
client_from_ctx().dom.uncheck(selector)
|
||||
console.print(f"[green]Unchecked:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("clear")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_clear(selector):
|
||||
"""Clear the value of an input matching CSS SELECTOR."""
|
||||
_handle("dom.clear", {"selector": selector})
|
||||
client_from_ctx().dom.clear(selector)
|
||||
console.print(f"[green]Cleared:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("focus")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_focus(selector):
|
||||
"""Focus the element matching CSS SELECTOR."""
|
||||
_handle("dom.focus", {"selector": selector})
|
||||
client_from_ctx().dom.focus(selector)
|
||||
console.print(f"[green]Focused:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("submit")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def dom_submit(selector):
|
||||
"""Submit the form that contains the element matching CSS SELECTOR."""
|
||||
_handle("dom.submit", {"selector": selector})
|
||||
client_from_ctx().dom.submit(selector)
|
||||
console.print(f"[green]Submitted form for:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("poll")
|
||||
@click.argument("selector")
|
||||
@click.argument("pattern")
|
||||
@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@handle_errors
|
||||
def dom_poll(selector, pattern, attr, timeout, interval, tab_id):
|
||||
"""Poll SELECTOR until its text/value matches regex PATTERN."""
|
||||
result = _handle("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
result = client_from_ctx().dom.poll(selector, pattern, attr=attr, timeout=timeout, interval=interval, tab_id=tab_id)
|
||||
value = result.get("value", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import time
|
||||
import click
|
||||
from rich.console import Console
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -10,6 +9,7 @@ def extension_group():
|
||||
"""Manage the browser-cli browser extension."""
|
||||
|
||||
@extension_group.command("reload")
|
||||
@handle_errors
|
||||
def extension_reload():
|
||||
"""Reload the browser-cli extension service worker.
|
||||
|
||||
@@ -17,5 +17,5 @@ def extension_reload():
|
||||
The command returns immediately; the extension restarts ~200 ms later.
|
||||
Re-connects automatically via the keepalive alarm within ~25 seconds.
|
||||
"""
|
||||
_handle("extension.reload")
|
||||
client_from_ctx().extension.reload()
|
||||
console.print("[green]Extension reloading…[/green] reconnects automatically")
|
||||
|
||||
+16
-435
@@ -1,437 +1,24 @@
|
||||
import json
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
# Re-exported for backward compatibility: the HTML→Markdown engine now lives in
|
||||
# browser_cli.markdown and is applied by the SDK (ExtractNS.markdown).
|
||||
from browser_cli.markdown import _clean_markdown_output, _convert_html_to_markdown # noqa: F401
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
|
||||
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
|
||||
|
||||
|
||||
class _HtmlNode:
|
||||
def __init__(self, tag=None, attrs=None, text=None):
|
||||
self.tag = tag
|
||||
self.attrs = attrs or {}
|
||||
self.text = text
|
||||
self.children = []
|
||||
|
||||
|
||||
class _HtmlTreeBuilder(HTMLParser):
|
||||
_VOID_TAGS = {"br", "hr", "img"}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = _HtmlNode(tag="document")
|
||||
self._stack = [self.root]
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||
self._stack[-1].children.append(node)
|
||||
if node.tag not in self._VOID_TAGS:
|
||||
self._stack.append(node)
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||
self._stack[-1].children.append(node)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
lowered = tag.lower()
|
||||
for index in range(len(self._stack) - 1, 0, -1):
|
||||
if self._stack[index].tag == lowered:
|
||||
del self._stack[index:]
|
||||
break
|
||||
|
||||
def handle_data(self, data):
|
||||
if data:
|
||||
self._stack[-1].children.append(_HtmlNode(text=data))
|
||||
|
||||
|
||||
def _normalize_text(value):
|
||||
return re.sub(r"\s+", " ", value or "").strip()
|
||||
|
||||
|
||||
def _normalize_inline(value):
|
||||
value = value.replace("\xa0", " ")
|
||||
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
||||
value = re.sub(r" *\n *", "\n", value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _collapse_blank_lines(value):
|
||||
value = re.sub(r"[ \t]+\n", "\n", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _escape_markdown(text):
|
||||
return re.sub(r"([\\`[\]])", r"\\\1", text)
|
||||
|
||||
|
||||
def _escape_table_cell(text):
|
||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def _iter_descendants(node):
|
||||
for child in getattr(node, "children", []):
|
||||
yield child
|
||||
yield from _iter_descendants(child)
|
||||
|
||||
|
||||
def _has_class(node, class_name):
|
||||
classes = (node.attrs.get("class") or "").split()
|
||||
return class_name in classes
|
||||
|
||||
|
||||
def _is_code_block_node(node):
|
||||
if not node or not node.tag:
|
||||
return False
|
||||
if node.attrs.get("data-is-code-block-view") == "true":
|
||||
return True
|
||||
return node.tag == "pre"
|
||||
|
||||
|
||||
def _inline_text(node):
|
||||
if node.text is not None:
|
||||
return _escape_markdown(node.text)
|
||||
if not node.tag:
|
||||
return ""
|
||||
|
||||
tag = node.tag
|
||||
if tag == "br":
|
||||
return "\n"
|
||||
if tag == "img":
|
||||
src = node.attrs.get("src") or ""
|
||||
alt = _normalize_text(node.attrs.get("alt") or "")
|
||||
if not src:
|
||||
return ""
|
||||
return f"" if alt else f""
|
||||
if tag == "a":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
href = node.attrs.get("href") or ""
|
||||
return f"[{text or href}]({href})" if href else text
|
||||
if tag == "code":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"`{text.replace('`', r'\\`')}`" if text else ""
|
||||
if tag in {"strong", "b"}:
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"**{text}**" if text else ""
|
||||
if tag in {"em", "i"}:
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"*{text}*" if text else ""
|
||||
|
||||
chunks = []
|
||||
for child in node.children:
|
||||
rendered = _inline_text(child)
|
||||
if rendered:
|
||||
chunks.append(rendered)
|
||||
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
|
||||
chunks.append("\n")
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
def _text_block(node):
|
||||
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
|
||||
|
||||
|
||||
def _inner_text_preserve(node):
|
||||
if node.text is not None:
|
||||
return node.text
|
||||
if not node.tag:
|
||||
return ""
|
||||
if node.tag == "br":
|
||||
return ""
|
||||
return "".join(_inner_text_preserve(child) for child in node.children)
|
||||
|
||||
|
||||
def _table_to_markdown(node):
|
||||
rows = []
|
||||
for descendant in _iter_descendants(node):
|
||||
if descendant.tag != "tr":
|
||||
continue
|
||||
row = []
|
||||
for cell in descendant.children:
|
||||
if cell.tag in {"td", "th"}:
|
||||
row.append(_escape_table_cell(_text_block(cell)))
|
||||
if row:
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
widths = max(len(row) for row in rows)
|
||||
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
|
||||
|
||||
headers = normalized_rows[0]
|
||||
body_rows = normalized_rows[1:]
|
||||
first_row_blank = all(not cell.strip() for cell in headers)
|
||||
if first_row_blank and len(normalized_rows) > 1:
|
||||
headers = normalized_rows[1]
|
||||
body_rows = normalized_rows[2:]
|
||||
|
||||
has_thead = any(child.tag == "thead" for child in node.children)
|
||||
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
|
||||
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
|
||||
if not (has_thead or first_row_has_th or first_row_blank):
|
||||
headers = [""] * widths
|
||||
body_rows = normalized_rows
|
||||
|
||||
separator = ["---"] * widths
|
||||
lines = [
|
||||
f"| {' | '.join(headers)} |",
|
||||
f"| {' | '.join(separator)} |",
|
||||
]
|
||||
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _list_to_markdown(node, depth=0):
|
||||
ordered = node.tag == "ol"
|
||||
items = []
|
||||
index = 1
|
||||
for child in node.children:
|
||||
if child.tag != "li":
|
||||
continue
|
||||
marker = f"{index}. " if ordered else "- "
|
||||
index += 1
|
||||
content = []
|
||||
nested = []
|
||||
for item_child in child.children:
|
||||
if item_child.tag in {"ul", "ol"}:
|
||||
nested.append(_list_to_markdown(item_child, depth + 1))
|
||||
else:
|
||||
content.append(_inline_text(item_child))
|
||||
line = _collapse_blank_lines(_normalize_inline("".join(content)))
|
||||
indent = " " * depth
|
||||
if line:
|
||||
line_parts = line.splitlines()
|
||||
items.append(f"{indent}{marker}{line_parts[0]}")
|
||||
continuation_indent = f"{indent}{' ' * len(marker)}"
|
||||
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
|
||||
items.extend(block for block in nested if block)
|
||||
return "\n".join(items)
|
||||
|
||||
|
||||
def _code_block_to_markdown(node):
|
||||
if node.tag == "pre":
|
||||
text = _inner_text_preserve(node).rstrip("\n")
|
||||
return f"```\n{text}\n```" if text else ""
|
||||
|
||||
lines = []
|
||||
for descendant in _iter_descendants(node):
|
||||
if descendant.tag and _has_class(descendant, "cm-line"):
|
||||
lines.append(_inner_text_preserve(descendant))
|
||||
code = "\n".join(lines).rstrip("\n")
|
||||
return f"```\n{code}\n```" if code else ""
|
||||
|
||||
|
||||
def _block_to_markdown(node):
|
||||
if node.text is not None:
|
||||
return _normalize_text(node.text)
|
||||
if not node.tag:
|
||||
return ""
|
||||
if _is_code_block_node(node):
|
||||
return _code_block_to_markdown(node)
|
||||
if node.tag == "table":
|
||||
return _table_to_markdown(node)
|
||||
if node.tag in {"ul", "ol"}:
|
||||
return _list_to_markdown(node)
|
||||
if re.fullmatch(r"h[1-6]", node.tag):
|
||||
text = _text_block(node)
|
||||
return f"{'#' * int(node.tag[1])} {text}" if text else ""
|
||||
if node.tag in {"p", "figcaption"}:
|
||||
return _text_block(node)
|
||||
if node.tag == "blockquote":
|
||||
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
|
||||
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
|
||||
if node.tag == "hr":
|
||||
return "---"
|
||||
if node.tag == "img":
|
||||
return _inline_text(node)
|
||||
|
||||
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
|
||||
if child_blocks:
|
||||
return _collapse_blank_lines("\n\n".join(child_blocks))
|
||||
return _text_block(node)
|
||||
|
||||
|
||||
def _parse_table_row(line):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|") or not stripped.endswith("|"):
|
||||
return None
|
||||
return [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
|
||||
|
||||
def _repair_table_headers(lines):
|
||||
repaired = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
if (
|
||||
index + 2 < len(lines)
|
||||
and _parse_table_row(lines[index]) is not None
|
||||
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
|
||||
and _parse_table_row(lines[index + 2]) is not None
|
||||
):
|
||||
first = _parse_table_row(lines[index])
|
||||
third = _parse_table_row(lines[index + 2])
|
||||
if first and all(not cell for cell in first) and any(cell for cell in third):
|
||||
repaired.append(lines[index + 2].strip())
|
||||
repaired.append(lines[index + 1].strip())
|
||||
index += 3
|
||||
continue
|
||||
repaired.append(lines[index].strip())
|
||||
index += 1
|
||||
return repaired
|
||||
|
||||
|
||||
def _repair_list_continuations(lines):
|
||||
repaired = []
|
||||
previous_was_list_item = False
|
||||
previous_continuation_indent = ""
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
|
||||
is_markdown_block_start = (
|
||||
not stripped
|
||||
or stripped.startswith(("```", "#", ">", "|"))
|
||||
or _TABLE_SEPARATOR_RE.match(stripped)
|
||||
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
|
||||
)
|
||||
|
||||
if previous_was_list_item and stripped and not is_markdown_block_start:
|
||||
repaired.append(f"{previous_continuation_indent}{stripped}")
|
||||
previous_was_list_item = False
|
||||
continue
|
||||
|
||||
repaired.append(stripped)
|
||||
if list_match:
|
||||
marker = list_match.group(2)
|
||||
base_indent = list_match.group(1)
|
||||
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
|
||||
previous_was_list_item = True
|
||||
else:
|
||||
previous_was_list_item = False
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _repair_flattened_diagram(text):
|
||||
if "\n" in text:
|
||||
return text
|
||||
if sum(text.count(char) for char in "│▼├└") < 2:
|
||||
return text
|
||||
|
||||
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
|
||||
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
|
||||
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
|
||||
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
|
||||
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
|
||||
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
|
||||
|
||||
|
||||
def _convert_dash_lists_to_branches(lines):
|
||||
converted = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
|
||||
if not match:
|
||||
converted.append(lines[index])
|
||||
index += 1
|
||||
continue
|
||||
|
||||
indent = match.group(1)
|
||||
items = []
|
||||
while index < len(lines):
|
||||
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
|
||||
if not next_match:
|
||||
break
|
||||
items.append(next_match.group(1))
|
||||
index += 1
|
||||
|
||||
for item_index, item in enumerate(items):
|
||||
branch = "└" if item_index == len(items) - 1 else "├"
|
||||
converted.append(f"{indent}{branch} {item}")
|
||||
return converted
|
||||
|
||||
|
||||
def _clean_code_block(code):
|
||||
lines = [line.rstrip() for line in code.splitlines()]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
|
||||
flattened = _repair_flattened_diagram("\n".join(lines))
|
||||
lines = flattened.splitlines() if flattened else []
|
||||
lines = [
|
||||
f" {line.strip()}"
|
||||
if line.strip() in {"│", "▼"} and not re.match(r"^\s+[│▼]\s*$", line)
|
||||
else line
|
||||
for line in lines
|
||||
]
|
||||
lines = _convert_dash_lists_to_branches(lines)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _clean_markdown_output(markdown):
|
||||
if not markdown:
|
||||
return ""
|
||||
|
||||
pieces = []
|
||||
last_index = 0
|
||||
for match in _FENCE_RE.finditer(markdown):
|
||||
prose = markdown[last_index:match.start()]
|
||||
if prose:
|
||||
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
|
||||
lines = [line.strip() for line in cleaned.splitlines()]
|
||||
lines = _repair_table_headers(lines)
|
||||
lines = _repair_list_continuations(lines)
|
||||
cleaned = "\n".join(lines)
|
||||
cleaned = _collapse_blank_lines(cleaned)
|
||||
if cleaned:
|
||||
pieces.append(cleaned)
|
||||
|
||||
fence = match.group(0)
|
||||
header, _, tail = fence.partition("\n")
|
||||
body, _, _ = tail.rpartition("\n")
|
||||
cleaned_body = _clean_code_block(body)
|
||||
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
|
||||
last_index = match.end()
|
||||
|
||||
trailing = markdown[last_index:]
|
||||
if trailing:
|
||||
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
|
||||
lines = [line.strip() for line in cleaned.splitlines()]
|
||||
lines = _repair_table_headers(lines)
|
||||
lines = _repair_list_continuations(lines)
|
||||
cleaned = "\n".join(lines)
|
||||
cleaned = _collapse_blank_lines(cleaned)
|
||||
if cleaned:
|
||||
pieces.append(cleaned)
|
||||
|
||||
return "\n\n".join(piece for piece in pieces if piece)
|
||||
|
||||
|
||||
def _convert_html_to_markdown(html):
|
||||
parser = _HtmlTreeBuilder()
|
||||
parser.feed(html or "")
|
||||
markdown = _block_to_markdown(parser.root)
|
||||
return _clean_markdown_output(markdown)
|
||||
|
||||
|
||||
@click.group("extract")
|
||||
def extract_group():
|
||||
"""Extract content from the active tab."""
|
||||
|
||||
|
||||
@extract_group.command("links")
|
||||
@handle_errors
|
||||
def extract_links():
|
||||
"""Extract all links from the active tab."""
|
||||
links = _handle("extract.links")
|
||||
links = client_from_ctx().extract.links()
|
||||
if not links:
|
||||
console.print("[yellow]No links found[/yellow]")
|
||||
return
|
||||
@@ -442,11 +29,11 @@ def extract_links():
|
||||
table.add_row((lnk.get("text") or "")[:60], lnk.get("href") or "")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@extract_group.command("images")
|
||||
@handle_errors
|
||||
def extract_images():
|
||||
"""Extract all images from the active tab."""
|
||||
images = _handle("extract.images")
|
||||
images = client_from_ctx().extract.images()
|
||||
if not images:
|
||||
console.print("[yellow]No images found[/yellow]")
|
||||
return
|
||||
@@ -457,36 +44,30 @@ def extract_images():
|
||||
table.add_row((img.get("alt") or "")[:40], img.get("src") or "")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@extract_group.command("text")
|
||||
@handle_errors
|
||||
def extract_text():
|
||||
"""Extract all visible text from the active tab."""
|
||||
text = _handle("extract.text")
|
||||
console.print(text or "")
|
||||
|
||||
console.print(client_from_ctx().extract.text())
|
||||
|
||||
@extract_group.command("json")
|
||||
@click.argument("selector")
|
||||
@handle_errors
|
||||
def extract_json(selector):
|
||||
"""Parse and pretty-print JSON content inside SELECTOR."""
|
||||
data = _handle("extract.json", {"selector": selector})
|
||||
data = client_from_ctx().extract.json(selector)
|
||||
console.print_json(json.dumps(data))
|
||||
|
||||
|
||||
@extract_group.command("html")
|
||||
@handle_errors
|
||||
def extract_html():
|
||||
"""Print the full HTML of the active tab to stdout."""
|
||||
html = _handle("extract.html")
|
||||
click.echo(html or "")
|
||||
|
||||
click.echo(client_from_ctx().extract.html())
|
||||
|
||||
@extract_group.command("markdown")
|
||||
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
|
||||
@handle_errors
|
||||
def extract_markdown(selector):
|
||||
"""Extract the page's main content as Markdown."""
|
||||
markdown = _handle("extract.markdown", {"selector": selector})
|
||||
if (markdown or "").lstrip().startswith("<"):
|
||||
markdown = _convert_html_to_markdown(markdown)
|
||||
else:
|
||||
markdown = _clean_markdown_output(markdown or "")
|
||||
markdown = client_from_ctx().extract.markdown(selector)
|
||||
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
|
||||
def _print_groups(groups, *, show_browser: bool = False) -> None:
|
||||
if not groups:
|
||||
console.print("[yellow]No groups found[/yellow]")
|
||||
return
|
||||
@@ -20,128 +19,88 @@ def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
|
||||
table.add_column("Tabs", width=6)
|
||||
for g in groups:
|
||||
row = [
|
||||
g.get("browser", "") if show_browser else None,
|
||||
str(g.get("id", "")),
|
||||
g.get("title") or "",
|
||||
g.get("color") or "",
|
||||
"yes" if g.get("collapsed") else "no",
|
||||
str(g.get("tabCount", "")),
|
||||
(g.browser or "") if show_browser else None,
|
||||
str(g.id),
|
||||
g.title or "",
|
||||
g.color or "",
|
||||
"yes" if g.collapsed else "no",
|
||||
str(g.tab_count),
|
||||
]
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
|
||||
|
||||
@click.group("groups")
|
||||
def group_group():
|
||||
"""Manage tab groups."""
|
||||
|
||||
|
||||
@group_group.command("list")
|
||||
@handle_errors
|
||||
def group_list():
|
||||
"""List all tab groups."""
|
||||
targets = _multi_browser_targets()
|
||||
if targets:
|
||||
groups = []
|
||||
for target in targets:
|
||||
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
groups.extend({**group, "browser": target.display_name} for group in result)
|
||||
if not groups:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
_print_groups(groups, show_browser=True)
|
||||
return
|
||||
groups = _handle("group.list")
|
||||
_print_groups(groups or [])
|
||||
|
||||
groups = client_from_ctx().groups.list()
|
||||
_print_groups(groups, show_browser=any(g.browser for g in groups))
|
||||
|
||||
@group_group.command("tabs")
|
||||
@click.argument("group_id", type=int)
|
||||
@handle_errors
|
||||
def group_tabs(group_id):
|
||||
"""List tabs inside a group."""
|
||||
from browser_cli.commands.tabs import _print_tabs
|
||||
tabs = _handle("group.tabs", {"groupId": group_id})
|
||||
_print_tabs(tabs or [])
|
||||
|
||||
_print_tabs(client_from_ctx().groups.tabs(group_id))
|
||||
|
||||
@group_group.command("count")
|
||||
@handle_errors
|
||||
def group_count():
|
||||
"""Count all tab groups."""
|
||||
targets = _multi_browser_targets()
|
||||
if targets:
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Browser")
|
||||
table.add_column("Groups", justify="right")
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
total += count
|
||||
rows += 1
|
||||
table.add_row(target.display_name, str(count))
|
||||
if rows == 0:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
table.add_row("Total", str(total))
|
||||
console.print(table)
|
||||
return
|
||||
count = _handle("group.count")
|
||||
console.print(f"[bold]{count}[/bold] group(s)")
|
||||
|
||||
print_counts(client_from_ctx().groups.count(), "group")
|
||||
|
||||
@group_group.command("query")
|
||||
@click.argument("search")
|
||||
@handle_errors
|
||||
def group_query(search):
|
||||
"""Search groups by name."""
|
||||
groups = _handle("group.query", {"search": search})
|
||||
_print_groups(groups or [])
|
||||
|
||||
_print_groups(client_from_ctx().groups.query(search))
|
||||
|
||||
@group_group.command("close")
|
||||
@click.argument("group_id", type=int)
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.")
|
||||
@gentle_mode_option("Throttle mode for large group operations.")
|
||||
@handle_errors
|
||||
def group_close(group_id, gentle_mode):
|
||||
"""Close (ungroup and optionally close) a tab group."""
|
||||
_handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
|
||||
client_from_ctx().groups.close(group_id, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Group {group_id} closed[/green]")
|
||||
|
||||
|
||||
@group_group.command("create")
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def group_create(name):
|
||||
"""Create a new tab group with NAME."""
|
||||
result = _handle("group.open", {"name": name})
|
||||
gid = result.get("id") if isinstance(result, dict) else result
|
||||
console.print(f"[green]Created group '{name}'[/green] (id: {gid})")
|
||||
|
||||
group = client_from_ctx().groups.create(name)
|
||||
console.print(f"[green]Created group '{name}'[/green] (id: {group.id})")
|
||||
|
||||
@group_group.command("add-tab")
|
||||
@click.argument("group")
|
||||
@click.argument("url", required=False)
|
||||
@handle_errors
|
||||
def group_add_tab(group, url):
|
||||
"""Open a new tab (optionally at URL) inside GROUP (name or ID)."""
|
||||
result = _handle("group.add_tab", {"group": group, "url": url})
|
||||
tab_id = result.get("tabId") if isinstance(result, dict) else result
|
||||
tab_id = client_from_ctx().groups.add_tab(group, url)
|
||||
label = url or "new tab"
|
||||
console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})")
|
||||
|
||||
|
||||
@group_group.command("move")
|
||||
@click.argument("group")
|
||||
@click.option("-f", "--forward", "forward", is_flag=True, help="Move group one position to the right")
|
||||
@click.option("-b", "--backward", "backward", is_flag=True, help="Move group one position to the left")
|
||||
@click.option("-r", "--right", "forward", is_flag=True, help="Move group one position to the right")
|
||||
@click.option("-l", "--left", "backward", is_flag=True, help="Move group one position to the left")
|
||||
@handle_errors
|
||||
def group_move(group, forward, backward):
|
||||
"""Move a tab group forward/backward or right/left (name or ID)."""
|
||||
if not forward and not backward:
|
||||
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
|
||||
raise SystemExit(1)
|
||||
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
|
||||
result = client_from_ctx().groups.move(group, forward=forward, backward=backward)
|
||||
if isinstance(result, dict) and not result.get("moved"):
|
||||
console.print(f"[yellow]Group '{group}' is already at the {'end' if forward else 'start'}[/yellow]")
|
||||
else:
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("nav")
|
||||
def nav_group():
|
||||
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
|
||||
|
||||
|
||||
@nav_group.command("open")
|
||||
@click.argument("url")
|
||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
||||
@click.option("--window", "window_name", default=None, help="Open in named window")
|
||||
@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
|
||||
@handle_errors
|
||||
def cmd_open(url, bg, window_name, group_name):
|
||||
"""Open URL in a new tab."""
|
||||
_handle("navigate.open", {"url": url, "background": bg, "window": window_name, "group": group_name})
|
||||
client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name)
|
||||
suffix = ""
|
||||
if group_name:
|
||||
suffix = f" in group '{group_name}'"
|
||||
@@ -25,71 +24,67 @@ def cmd_open(url, bg, window_name, group_name):
|
||||
suffix = f" in window '{window_name}'"
|
||||
console.print(f"[green]Opened:[/green] {url}{suffix}")
|
||||
|
||||
|
||||
@nav_group.command("reload")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def cmd_reload(tab_id):
|
||||
"""Reload the active (or specified) tab."""
|
||||
_handle("navigate.reload", {"tabId": tab_id})
|
||||
client_from_ctx().nav.reload(tab_id)
|
||||
console.print("[green]Reloaded[/green]")
|
||||
|
||||
|
||||
@nav_group.command("hard-reload")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def cmd_hard_reload(tab_id):
|
||||
"""Hard reload (bypass cache) the active (or specified) tab."""
|
||||
_handle("navigate.hard_reload", {"tabId": tab_id})
|
||||
client_from_ctx().nav.hard_reload(tab_id)
|
||||
console.print("[green]Hard reloaded[/green]")
|
||||
|
||||
|
||||
@nav_group.command("back")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def cmd_back(tab_id):
|
||||
"""Navigate back in the active (or specified) tab."""
|
||||
_handle("navigate.back", {"tabId": tab_id})
|
||||
client_from_ctx().nav.back(tab_id)
|
||||
console.print("[green]Navigated back[/green]")
|
||||
|
||||
|
||||
@nav_group.command("forward")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def cmd_forward(tab_id):
|
||||
"""Navigate forward in the active (or specified) tab."""
|
||||
_handle("navigate.forward", {"tabId": tab_id})
|
||||
client_from_ctx().nav.forward(tab_id)
|
||||
console.print("[green]Navigated forward[/green]")
|
||||
|
||||
|
||||
@nav_group.command("focus")
|
||||
@click.argument("pattern")
|
||||
@handle_errors
|
||||
def cmd_focus(pattern):
|
||||
"""Jump to a tab by URL pattern or tab ID."""
|
||||
result = _handle("navigate.focus", {"pattern": pattern})
|
||||
result = client_from_ctx().nav.focus(pattern)
|
||||
if result:
|
||||
console.print(f"[green]Focused:[/green] {result.get('url', result)}")
|
||||
else:
|
||||
console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
|
||||
|
||||
|
||||
@nav_group.command("open-wait")
|
||||
@click.argument("url")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
|
||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
||||
@click.option("--window", "window_name", default=None, help="Open in named window")
|
||||
@click.option("--group", "group_name", default=None, help="Open in tab group")
|
||||
@handle_errors
|
||||
def cmd_open_wait(url, timeout, bg, window_name, group_name):
|
||||
"""Open URL in a new tab and wait until fully loaded."""
|
||||
result = _handle("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": bg, "window": window_name, "group": group_name,
|
||||
})
|
||||
title = result.get("title", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]Loaded:[/green] {url}" + (f" — {title}" if title else ""))
|
||||
|
||||
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name)
|
||||
console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
|
||||
|
||||
@nav_group.command("wait")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
|
||||
@handle_errors
|
||||
def cmd_wait(tab_id, timeout, ready_state):
|
||||
"""Wait until tab finishes loading."""
|
||||
result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state})
|
||||
console.print(f"[green]Ready:[/green] {result.get('url', '')} — {result.get('title', '')}")
|
||||
tab = client_from_ctx().tabs.wait_for_load(tab_id, timeout=timeout, ready_state=ready_state)
|
||||
console.print(f"[green]Ready:[/green] {tab.url} — {tab.title}")
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("page")
|
||||
def page_group():
|
||||
"""Inspect current page metadata."""
|
||||
|
||||
|
||||
@page_group.command("info")
|
||||
@handle_errors
|
||||
def page_info():
|
||||
"""Show title, URL, readyState, language, and meta tags of the active tab."""
|
||||
info = _handle("page.info") or {}
|
||||
info = client_from_ctx().page.info()
|
||||
table = Table(show_header=False)
|
||||
table.add_column("Field", style="bold cyan", no_wrap=True)
|
||||
table.add_column("Value")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -10,9 +10,10 @@ def perf_group():
|
||||
"""Inspect and tune browser-cli performance behavior."""
|
||||
|
||||
@perf_group.command("status")
|
||||
@handle_errors
|
||||
def perf_status():
|
||||
"""Show performance profile, throttle and running jobs."""
|
||||
result = _handle("perf.status") or {}
|
||||
result = client_from_ctx().perf.status()
|
||||
console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
|
||||
console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
|
||||
throttle = result.get("throttle") or {}
|
||||
@@ -39,7 +40,8 @@ def perf_status():
|
||||
|
||||
@perf_group.command("profile")
|
||||
@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
|
||||
@handle_errors
|
||||
def perf_profile(profile):
|
||||
"""Set global performance profile."""
|
||||
result = _handle("perf.set_profile", {"profile": profile}) or {}
|
||||
result = client_from_ctx().perf.set_profile(profile)
|
||||
console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import click
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
@@ -61,23 +60,21 @@ _SUBCOMMANDS = [
|
||||
def search_group():
|
||||
"""Search the web — open a query in a search engine."""
|
||||
|
||||
|
||||
def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||
@click.command(engine_key, help=help_text)
|
||||
@click.argument("query", nargs=-1, required=True)
|
||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
||||
@click.option("--window", "window", default=None, help="Open in named window")
|
||||
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
|
||||
@handle_errors
|
||||
def _cmd(query, bg, window, group):
|
||||
terms = " ".join(query)
|
||||
url = ENGINES[engine_key].format(query=quote_plus(terms))
|
||||
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
||||
client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group)
|
||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||
|
||||
return _cmd
|
||||
|
||||
|
||||
for _name, _help in _SUBCOMMANDS:
|
||||
search_group.add_command(_build_command(_name, _help))
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.client import _recv_exact, _recv_all
|
||||
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
|
||||
@@ -12,7 +13,6 @@ _UA_PATTERN = re.compile(r"^browser-cli/\d")
|
||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||
console = Console()
|
||||
|
||||
|
||||
def _framed_send(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
@@ -25,11 +25,12 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
|
||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
response_secret = None
|
||||
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
|
||||
|
||||
def _send_payload(data: bytes) -> None:
|
||||
if response_secret is not None:
|
||||
@@ -38,16 +39,17 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
_framed_send(client_sock, data)
|
||||
|
||||
def _send_error(msg_id, msg:str) -> None:
|
||||
# errors stay plain JSON: tiny, and safe for any client
|
||||
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||
try:
|
||||
_send_payload(err)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send_ok(msg_id, payload) -> None:
|
||||
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
|
||||
def _send_ok(msg_id, payload, command=None) -> None:
|
||||
obj = {"id": msg_id, "success": True, "data": payload}
|
||||
try:
|
||||
_send_payload(out)
|
||||
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -141,13 +143,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
return
|
||||
response_secret = pq_shared_secret if transport_encrypted else None
|
||||
|
||||
# client advertises what response encodings it can decode (signed, then stripped)
|
||||
accept_encoding = msg.get("accept_encoding")
|
||||
|
||||
if command == "browser-cli.targets":
|
||||
from browser_cli.client import active_browser_targets
|
||||
targets = [
|
||||
{"profile": target.profile, "displayName": target.display_name}
|
||||
for target in active_browser_targets(include_remotes=False)
|
||||
]
|
||||
_send_ok(msg_id, targets)
|
||||
_send_ok(msg_id, targets, command)
|
||||
_log(addr, command, None, "OK")
|
||||
return
|
||||
|
||||
@@ -158,7 +163,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
return
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
|
||||
_send_ok(msg_id, entries)
|
||||
_send_ok(msg_id, entries, command)
|
||||
_log(addr, command, None, "OK")
|
||||
return
|
||||
|
||||
@@ -176,14 +181,14 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
_log(addr, command, None, "ERROR", "invalid pubkey")
|
||||
return
|
||||
added = add_authorized_key(auth_keys_path, pubkey, name)
|
||||
_send_ok(msg_id, {"added": added})
|
||||
_send_ok(msg_id, {"added": added}, command)
|
||||
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
return
|
||||
|
||||
resolved_profile = msg.get("_route") or profile
|
||||
|
||||
# ── strip protocol fields, apply request compat shim, forward ─────────────
|
||||
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
|
||||
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in strip}
|
||||
clean_msg = adapt_request(clean_msg, client_ver)
|
||||
clean_payload = json.dumps(clean_msg).encode()
|
||||
@@ -203,16 +208,19 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
pipe.send_bytes(clean_payload)
|
||||
resp_payload = pipe.recv_bytes()
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(resp_payload)
|
||||
else:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||
local.connect(sock_path)
|
||||
local.sendall(clean_header + clean_payload)
|
||||
resp_payload = _recv_all(local)
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(resp_payload)
|
||||
|
||||
# parse once: drives both the access log and (re-)encoding for the client
|
||||
resp_data = json.loads(resp_payload)
|
||||
if compress:
|
||||
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
|
||||
else:
|
||||
_send_payload(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
_log(addr, command, resolved_profile, "OK")
|
||||
else:
|
||||
@@ -221,7 +229,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None", compress:bool=True) -> None:
|
||||
if not _CONN_LIMIT.acquire(blocking=False):
|
||||
client_sock.close()
|
||||
return
|
||||
@@ -253,7 +261,7 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
_framed_send(client_sock, challenge)
|
||||
except OSError:
|
||||
return
|
||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
|
||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
||||
finally:
|
||||
_CONN_LIMIT.release()
|
||||
|
||||
@@ -264,10 +272,13 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
|
||||
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
|
||||
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.")
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
|
||||
@@ -302,18 +313,25 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
|
||||
n = len(load_authorized_keys(auth_keys_path))
|
||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
|
||||
else:
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
if compress:
|
||||
codecs = "+".join(transport.supported_compression())
|
||||
sers = "+".join(transport.supported_serialization())
|
||||
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
|
||||
else:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
finally:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
@@ -10,41 +10,47 @@ def session_group():
|
||||
|
||||
@session_group.command("save")
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def session_save(name):
|
||||
"""Save all current tabs as session NAME."""
|
||||
result = _handle("session.save", {"name": name})
|
||||
result = client_from_ctx().session.save(name)
|
||||
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
||||
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
|
||||
|
||||
@session_group.command("load")
|
||||
@click.argument("name")
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.")
|
||||
@gentle_mode_option("Throttle mode for large restores.")
|
||||
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
|
||||
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
|
||||
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
|
||||
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
|
||||
@handle_errors
|
||||
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
|
||||
"""Restore session NAME (opens all saved tabs)."""
|
||||
result = _handle("session.load", {
|
||||
"name": name,
|
||||
"gentleMode": gentle_mode,
|
||||
"discardBackgroundTabs": discard_background_tabs,
|
||||
"lazy": lazy,
|
||||
"eagerTabs": eager_tabs,
|
||||
"__background": background_job,
|
||||
})
|
||||
if background_job and isinstance(result, dict) and result.get("jobId"):
|
||||
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
|
||||
return
|
||||
b = client_from_ctx()
|
||||
if background_job:
|
||||
result = b.session.load_background(
|
||||
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
|
||||
lazy=lazy, eager_tabs=eager_tabs,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("jobId"):
|
||||
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
|
||||
return
|
||||
else:
|
||||
result = b.session.load(
|
||||
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
|
||||
lazy=lazy, eager_tabs=eager_tabs,
|
||||
)
|
||||
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
||||
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
|
||||
|
||||
@session_group.command("diff")
|
||||
@click.argument("name_a")
|
||||
@click.argument("name_b")
|
||||
@handle_errors
|
||||
def session_diff(name_a, name_b):
|
||||
"""Show tabs added/removed between two saved sessions."""
|
||||
diff = _handle("session.diff", {"nameA": name_a, "nameB": name_b})
|
||||
diff = client_from_ctx().session.diff(name_a, name_b)
|
||||
if not diff:
|
||||
console.print("[yellow]No diff data returned[/yellow]")
|
||||
return
|
||||
@@ -66,26 +72,16 @@ def session_diff(name_a, name_b):
|
||||
console.print("[green]Sessions are identical[/green]")
|
||||
|
||||
@session_group.command("list")
|
||||
@handle_errors
|
||||
def session_list():
|
||||
"""List all saved sessions."""
|
||||
from datetime import datetime
|
||||
from rich.table import Table
|
||||
targets = _multi_browser_targets()
|
||||
show_browser = bool(targets)
|
||||
if targets:
|
||||
sessions = []
|
||||
for target in targets:
|
||||
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
sessions.extend({**session, "browser": target.display_name} for session in result)
|
||||
if not sessions:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
sessions = _handle("session.list")
|
||||
sessions = client_from_ctx().session.list()
|
||||
if not sessions:
|
||||
console.print("[yellow]No saved sessions[/yellow]")
|
||||
return
|
||||
show_browser = any("browser" in s for s in sessions)
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
if show_browser:
|
||||
table.add_column("Browser")
|
||||
@@ -93,7 +89,6 @@ def session_list():
|
||||
table.add_column("Tabs", width=6)
|
||||
table.add_column("Saved at")
|
||||
for s in sessions:
|
||||
from datetime import datetime
|
||||
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
|
||||
row = [s.get("browser", "")] if show_browser else []
|
||||
row.extend([s["name"], str(s["tabs"]), saved])
|
||||
@@ -102,16 +97,18 @@ def session_list():
|
||||
|
||||
@session_group.command("remove")
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def session_remove(name):
|
||||
"""Delete a saved session."""
|
||||
_handle("session.remove", {"name": name})
|
||||
client_from_ctx().session.remove(name)
|
||||
console.print(f"[green]Session '{name}' removed[/green]")
|
||||
|
||||
@session_group.command("job-status")
|
||||
@click.argument("job_id")
|
||||
@handle_errors
|
||||
def session_job_status(job_id):
|
||||
"""Show status for a background session job."""
|
||||
result = _handle("jobs.status", {"jobId": job_id}) or {}
|
||||
result = client_from_ctx().perf.job_status(job_id)
|
||||
status = result.get("status", "unknown")
|
||||
console.print(f"[bold]{job_id}[/bold]: {status}")
|
||||
if result.get("error"):
|
||||
@@ -121,15 +118,17 @@ def session_job_status(job_id):
|
||||
|
||||
@session_group.command("job-cancel")
|
||||
@click.argument("job_id")
|
||||
@handle_errors
|
||||
def session_job_cancel(job_id):
|
||||
"""Cancel a running background job."""
|
||||
_handle("jobs.cancel", {"jobId": job_id})
|
||||
client_from_ctx().perf.job_cancel(job_id)
|
||||
console.print(f"[green]Cancel requested for {job_id}[/green]")
|
||||
|
||||
@session_group.command("auto-save")
|
||||
@click.argument("state", type=click.Choice(["on", "off"]))
|
||||
@handle_errors
|
||||
def session_auto_save(state):
|
||||
"""Enable or disable automatic session saving."""
|
||||
enabled = state == "on"
|
||||
_handle("session.auto_save", {"enabled": enabled})
|
||||
client_from_ctx().session.auto_save(enabled)
|
||||
console.print(f"[green]Auto-save {state}[/green]")
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import json
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("storage")
|
||||
def storage_group():
|
||||
"""Read and write the page's localStorage / sessionStorage."""
|
||||
|
||||
|
||||
@storage_group.command("get")
|
||||
@click.argument("key", required=False)
|
||||
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@handle_errors
|
||||
def storage_get(key, store_type, tab_id):
|
||||
"""Get a localStorage KEY (or dump all keys if omitted)."""
|
||||
result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id})
|
||||
result = client_from_ctx().storage.get(key, type=store_type, tab_id=tab_id)
|
||||
if result is None:
|
||||
console.print("[dim]null[/dim]")
|
||||
elif isinstance(result, dict):
|
||||
@@ -25,13 +24,13 @@ def storage_get(key, store_type, tab_id):
|
||||
else:
|
||||
console.print(str(result))
|
||||
|
||||
|
||||
@storage_group.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@handle_errors
|
||||
def storage_set(key, value, store_type, tab_id):
|
||||
"""Set localStorage KEY to VALUE."""
|
||||
_handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id})
|
||||
client_from_ctx().storage.set(key, value, type=store_type, tab_id=tab_id)
|
||||
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
|
||||
|
||||
+62
-111
@@ -1,14 +1,13 @@
|
||||
import base64
|
||||
import binascii
|
||||
import click
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
|
||||
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
||||
if not tabs:
|
||||
console.print("[yellow]No tabs found[/yellow]")
|
||||
return
|
||||
@@ -22,58 +21,42 @@ def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
|
||||
table.add_column("Title")
|
||||
table.add_column("URL")
|
||||
for t in tabs:
|
||||
active = "[green]✓[/green]" if t.get("active") else ""
|
||||
muted = "[yellow]✓[/yellow]" if t.get("muted") else ""
|
||||
active = "[green]✓[/green]" if t.active else ""
|
||||
muted = "[yellow]✓[/yellow]" if t.muted else ""
|
||||
row = [
|
||||
t.get("browser", "") if show_browser else None,
|
||||
str(t.get("id", "")),
|
||||
str(t.get("windowId", "")),
|
||||
(t.browser or "") if show_browser else None,
|
||||
str(t.id),
|
||||
str(t.window_id),
|
||||
active,
|
||||
muted,
|
||||
(t.get("title") or "")[:60],
|
||||
(t.get("url") or "")[:80],
|
||||
(t.title or "")[:60],
|
||||
(t.url or "")[:80],
|
||||
]
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
|
||||
|
||||
@click.group("tabs")
|
||||
def tabs_group():
|
||||
"""Manage browser tabs."""
|
||||
|
||||
|
||||
@tabs_group.command("list")
|
||||
@handle_errors
|
||||
def tabs_list():
|
||||
"""List all open tabs across all windows."""
|
||||
targets = _multi_browser_targets()
|
||||
if targets:
|
||||
tabs = []
|
||||
for target in targets:
|
||||
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
tabs.extend({**tab, "browser": target.display_name} for tab in result)
|
||||
if not tabs:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
_print_tabs(tabs, show_browser=True)
|
||||
return
|
||||
tabs = _handle("tabs.list")
|
||||
_print_tabs(tabs or [])
|
||||
|
||||
tabs = client_from_ctx().tabs.list()
|
||||
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
||||
|
||||
@tabs_group.command("close")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
|
||||
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.")
|
||||
@gentle_mode_option("Throttle mode for large close operations.")
|
||||
@handle_errors
|
||||
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
||||
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
||||
result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode})
|
||||
count = result.get("closed", 0) if isinstance(result, dict) else 1
|
||||
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} tab(s)[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("move")
|
||||
@click.argument("tab_id", type=int)
|
||||
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
|
||||
@@ -83,180 +66,148 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
||||
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
|
||||
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
|
||||
@click.option("--index", type=int, default=None, help="Absolute position index in target")
|
||||
@handle_errors
|
||||
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
|
||||
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
||||
_handle("tabs.move", {
|
||||
"tabId": tab_id, "forward": forward, "backward": backward,
|
||||
"groupId": group_id, "windowId": window_id, "index": index,
|
||||
})
|
||||
client_from_ctx().tabs.move(
|
||||
tab_id, forward=forward, backward=backward,
|
||||
group_id=group_id, window_id=window_id, index=index,
|
||||
)
|
||||
console.print("[green]Tab moved[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("active")
|
||||
@click.argument("tab_id", type=int)
|
||||
@handle_errors
|
||||
def tabs_active(tab_id):
|
||||
"""Switch browser focus to a tab."""
|
||||
_handle("tabs.active", {"tabId": tab_id})
|
||||
client_from_ctx().tabs.activate(tab_id)
|
||||
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("status")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_status(tab_id):
|
||||
"""Show status for the active tab or a specific tab."""
|
||||
tab = _handle("tabs.status", {"tabId": tab_id}) or {}
|
||||
tab = client_from_ctx().tabs.status(tab_id)
|
||||
table = Table(show_header=False)
|
||||
table.add_column("Field", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
table.add_row("ID", str(tab.get("id", "")))
|
||||
table.add_row("Window", str(tab.get("windowId", "")))
|
||||
table.add_row("Active", "yes" if tab.get("active") else "no")
|
||||
table.add_row("Muted", "yes" if tab.get("muted") else "no")
|
||||
table.add_row("Title", tab.get("title") or "")
|
||||
table.add_row("URL", tab.get("url") or "")
|
||||
table.add_row("ID", str(tab.id))
|
||||
table.add_row("Window", str(tab.window_id))
|
||||
table.add_row("Active", "yes" if tab.active else "no")
|
||||
table.add_row("Muted", "yes" if tab.muted else "no")
|
||||
table.add_row("Title", tab.title or "")
|
||||
table.add_row("URL", tab.url or "")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@tabs_group.command("filter")
|
||||
@click.argument("pattern")
|
||||
@handle_errors
|
||||
def tabs_filter(pattern):
|
||||
"""List tabs whose URL contains PATTERN."""
|
||||
tabs = _handle("tabs.filter", {"pattern": pattern})
|
||||
_print_tabs(tabs or [])
|
||||
|
||||
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
||||
|
||||
@tabs_group.command("count")
|
||||
@click.argument("pattern", required=False)
|
||||
@handle_errors
|
||||
def tabs_count(pattern):
|
||||
"""Count open tabs, optionally filtered by URL PATTERN."""
|
||||
targets = _multi_browser_targets()
|
||||
if targets:
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Browser")
|
||||
table.add_column("Tabs", justify="right")
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
total += count
|
||||
rows += 1
|
||||
table.add_row(target.display_name, str(count))
|
||||
if rows == 0:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
table.add_row("Total", str(total))
|
||||
console.print(table)
|
||||
return
|
||||
count = _handle("tabs.count", {"pattern": pattern})
|
||||
label = f" matching '{pattern}'" if pattern else ""
|
||||
console.print(f"[bold]{count}[/bold] tab(s){label}")
|
||||
|
||||
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
||||
|
||||
@tabs_group.command("query")
|
||||
@click.argument("search")
|
||||
@handle_errors
|
||||
def tabs_query(search):
|
||||
"""Search tabs by URL or title."""
|
||||
tabs = _handle("tabs.query", {"search": search})
|
||||
_print_tabs(tabs or [])
|
||||
|
||||
_print_tabs(client_from_ctx().tabs.query(search))
|
||||
|
||||
@tabs_group.command("html")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_html(tab_id):
|
||||
"""Print the full HTML of a tab."""
|
||||
html = _handle("tabs.html", {"tabId": tab_id})
|
||||
console.print(html or "")
|
||||
|
||||
console.print(client_from_ctx().tabs.html(tab_id))
|
||||
|
||||
@tabs_group.command("dedupe")
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.")
|
||||
@gentle_mode_option("Throttle mode for large dedupe operations.")
|
||||
@handle_errors
|
||||
def tabs_dedupe(gentle_mode):
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
||||
result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
|
||||
count = result.get("closed", 0) if isinstance(result, dict) else 0
|
||||
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("sort")
|
||||
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.")
|
||||
@gentle_mode_option("Throttle mode for large sort operations.")
|
||||
@handle_errors
|
||||
def tabs_sort(by, gentle_mode):
|
||||
"""Sort tabs within each window."""
|
||||
_handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
|
||||
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Tabs sorted by {by}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("merge-windows")
|
||||
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.")
|
||||
@gentle_mode_option("Throttle mode for large merge operations.")
|
||||
@handle_errors
|
||||
def tabs_merge_windows(gentle_mode):
|
||||
"""Move all tabs into the focused window."""
|
||||
result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
|
||||
count = result.get("moved", 0) if isinstance(result, dict) else 0
|
||||
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("mute")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_mute(tab_id):
|
||||
"""Mute the active tab or a specific tab."""
|
||||
result = _handle("tabs.mute", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
target = client_from_ctx().tabs.mute(tab_id)
|
||||
console.print(f"[green]Muted tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("unmute")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_unmute(tab_id):
|
||||
"""Unmute the active tab or a specific tab."""
|
||||
result = _handle("tabs.unmute", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
target = client_from_ctx().tabs.unmute(tab_id)
|
||||
console.print(f"[green]Unmuted tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("pin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_pin(tab_id):
|
||||
"""Pin the active tab or a specific tab."""
|
||||
result = _handle("tabs.pin", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
target = client_from_ctx().tabs.pin(tab_id)
|
||||
console.print(f"[green]Pinned tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("unpin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_unpin(tab_id):
|
||||
"""Unpin the active tab or a specific tab."""
|
||||
result = _handle("tabs.unpin", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
target = client_from_ctx().tabs.unpin(tab_id)
|
||||
console.print(f"[green]Unpinned tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("watch-url")
|
||||
@click.argument("pattern")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@handle_errors
|
||||
def tabs_watch_url(pattern, tab_id, timeout):
|
||||
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
||||
result = _handle("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
|
||||
url = result.get("url", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]URL matched:[/green] {url}")
|
||||
|
||||
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||
console.print(f"[green]URL matched:[/green] {tab.url}")
|
||||
|
||||
@tabs_group.command("screenshot")
|
||||
@click.argument("output", required=False, metavar="FILE")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@tab_option
|
||||
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
|
||||
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
||||
@handle_errors
|
||||
def tabs_screenshot(output, tab_id, fmt, quality):
|
||||
"""Capture a screenshot of the active (or specified) tab.
|
||||
|
||||
Saves to FILE if given, otherwise prints the base64 data URL.
|
||||
"""
|
||||
result = _handle("tabs.screenshot", {"tabId": tab_id, "format": fmt, "quality": quality})
|
||||
data_url = result.get("dataUrl", "") if isinstance(result, dict) else ""
|
||||
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
||||
if output:
|
||||
header = f"data:image/{fmt};base64,"
|
||||
if not data_url.startswith(header):
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||
if not windows:
|
||||
console.print("[yellow]No windows found[/yellow]")
|
||||
@@ -28,53 +27,39 @@ def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
|
||||
|
||||
@click.group("windows")
|
||||
def windows_group():
|
||||
"""Manage browser windows."""
|
||||
|
||||
|
||||
@windows_group.command("list")
|
||||
@handle_errors
|
||||
def windows_list():
|
||||
"""List all browser windows."""
|
||||
targets = _multi_browser_targets()
|
||||
if targets:
|
||||
windows = []
|
||||
for target in targets:
|
||||
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
windows.extend({**window, "browser": target.display_name} for window in result)
|
||||
if not windows:
|
||||
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
|
||||
raise SystemExit(1)
|
||||
_print_windows(windows, show_browser=True)
|
||||
return
|
||||
windows = _handle("windows.list")
|
||||
_print_windows(windows or [])
|
||||
|
||||
windows = client_from_ctx().windows.list()
|
||||
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
||||
|
||||
@windows_group.command("rename")
|
||||
@click.argument("window_id", type=int)
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def windows_rename(window_id, name):
|
||||
"""Give a window a local alias NAME (stored in native host)."""
|
||||
_handle("windows.rename", {"windowId": window_id, "name": name})
|
||||
client_from_ctx().windows.rename(window_id, name)
|
||||
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
||||
|
||||
|
||||
@windows_group.command("close")
|
||||
@click.argument("window_id", type=int)
|
||||
@handle_errors
|
||||
def windows_close(window_id):
|
||||
"""Close a browser window."""
|
||||
_handle("windows.close", {"windowId": window_id})
|
||||
client_from_ctx().windows.close(window_id)
|
||||
console.print(f"[green]Window {window_id} closed[/green]")
|
||||
|
||||
|
||||
@windows_group.command("open")
|
||||
@click.argument("url", required=False)
|
||||
@handle_errors
|
||||
def windows_open(url):
|
||||
"""Open a new browser window."""
|
||||
result = _handle("windows.open", {"url": url})
|
||||
result = client_from_ctx().windows.open(url)
|
||||
wid = result.get("id") if isinstance(result, dict) else result
|
||||
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Remote endpoint string handling — parsing, normalization, display.
|
||||
|
||||
Pure helpers (no sockets, no I/O) for turning user-facing ``host[:port]``
|
||||
strings into the canonical forms the rest of the client uses, and back into the
|
||||
short forms shown to humans. Re-exported from :mod:`browser_cli.client` for
|
||||
backward compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
|
||||
_DEFAULT_REMOTE_PORT = 443
|
||||
|
||||
def _looks_like_domain(host: str) -> bool:
|
||||
"""True if host looks like a domain name rather than an IP address or localhost."""
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
|
||||
return False
|
||||
return '.' in host and any(c.isalpha() for c in host)
|
||||
|
||||
def _normalize_endpoint(endpoint: str) -> str:
|
||||
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and port == "443" and _looks_like_domain(host):
|
||||
return host
|
||||
return endpoint
|
||||
|
||||
def _resolve_connect_endpoint(endpoint: str) -> str:
|
||||
"""Return host:port for TCP connection; domain without port defaults to :443."""
|
||||
_, sep, _ = endpoint.rpartition(":")
|
||||
if not sep:
|
||||
if _looks_like_domain(endpoint):
|
||||
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
|
||||
raise BrowserNotConnected(
|
||||
f"Invalid remote endpoint '{endpoint}': expected host:port"
|
||||
)
|
||||
return endpoint
|
||||
|
||||
def display_browser_name(profile_name: str, sock_path: str) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
if profile_name != "default":
|
||||
return profile_name
|
||||
return Path(sock_path).stem or profile_name
|
||||
|
||||
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
|
||||
display_endpoint = host
|
||||
else:
|
||||
display_endpoint = endpoint # normalized domain (no port) or non-default port
|
||||
return f"{display_endpoint}:{display_name or profile_name}"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Shared exception types for the browser-cli client stack.
|
||||
|
||||
Kept dependency-free so the transport/endpoint modules and ``client`` itself can
|
||||
import it without creating an import cycle. ``BrowserNotConnected`` is re-exported
|
||||
from :mod:`browser_cli.client` for backward compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
class BrowserNotConnected(Exception):
|
||||
"""Raised when the native host socket is not available."""
|
||||
@@ -0,0 +1,413 @@
|
||||
"""HTML → Markdown conversion and Markdown clean-up.
|
||||
|
||||
Pure, presentation-agnostic text transforms shared by the SDK
|
||||
(:meth:`browser_cli.sdk.dom.ExtractNS.markdown`) and the ``extract markdown``
|
||||
CLI command. No Click/Rich/IPC dependencies — just an HTML tree walker plus a
|
||||
set of repair passes for the markdown the page (or a markdown editor like
|
||||
Obsidian/CodeMirror) hands back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
|
||||
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
|
||||
|
||||
class _HtmlNode:
|
||||
def __init__(self, tag=None, attrs=None, text=None):
|
||||
self.tag = tag
|
||||
self.attrs = attrs or {}
|
||||
self.text = text
|
||||
self.children = []
|
||||
|
||||
class _HtmlTreeBuilder(HTMLParser):
|
||||
_VOID_TAGS = {"br", "hr", "img"}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = _HtmlNode(tag="document")
|
||||
self._stack = [self.root]
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||
self._stack[-1].children.append(node)
|
||||
if node.tag not in self._VOID_TAGS:
|
||||
self._stack.append(node)
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||
self._stack[-1].children.append(node)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
lowered = tag.lower()
|
||||
for index in range(len(self._stack) - 1, 0, -1):
|
||||
if self._stack[index].tag == lowered:
|
||||
del self._stack[index:]
|
||||
break
|
||||
|
||||
def handle_data(self, data):
|
||||
if data:
|
||||
self._stack[-1].children.append(_HtmlNode(text=data))
|
||||
|
||||
def _normalize_text(value):
|
||||
return re.sub(r"\s+", " ", value or "").strip()
|
||||
|
||||
def _normalize_inline(value):
|
||||
value = value.replace("\xa0", " ")
|
||||
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
||||
value = re.sub(r" *\n *", "\n", value)
|
||||
return value.strip()
|
||||
|
||||
def _collapse_blank_lines(value):
|
||||
value = re.sub(r"[ \t]+\n", "\n", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
|
||||
def _escape_markdown(text):
|
||||
return re.sub(r"([\\`[\]])", r"\\\1", text)
|
||||
|
||||
def _escape_table_cell(text):
|
||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
||||
|
||||
def _iter_descendants(node):
|
||||
for child in getattr(node, "children", []):
|
||||
yield child
|
||||
yield from _iter_descendants(child)
|
||||
|
||||
def _has_class(node, class_name):
|
||||
classes = (node.attrs.get("class") or "").split()
|
||||
return class_name in classes
|
||||
|
||||
def _is_code_block_node(node):
|
||||
if not node or not node.tag:
|
||||
return False
|
||||
if node.attrs.get("data-is-code-block-view") == "true":
|
||||
return True
|
||||
return node.tag == "pre"
|
||||
|
||||
def _inline_text(node):
|
||||
if node.text is not None:
|
||||
return _escape_markdown(node.text)
|
||||
if not node.tag:
|
||||
return ""
|
||||
|
||||
tag = node.tag
|
||||
if tag == "br":
|
||||
return "\n"
|
||||
if tag == "img":
|
||||
src = node.attrs.get("src") or ""
|
||||
alt = _normalize_text(node.attrs.get("alt") or "")
|
||||
if not src:
|
||||
return ""
|
||||
return f"" if alt else f""
|
||||
if tag == "a":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
href = node.attrs.get("href") or ""
|
||||
return f"[{text or href}]({href})" if href else text
|
||||
if tag == "code":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"`{text.replace('`', r'\\`')}`" if text else ""
|
||||
if tag in {"strong", "b"}:
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"**{text}**" if text else ""
|
||||
if tag in {"em", "i"}:
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
return f"*{text}*" if text else ""
|
||||
|
||||
chunks = []
|
||||
for child in node.children:
|
||||
rendered = _inline_text(child)
|
||||
if rendered:
|
||||
chunks.append(rendered)
|
||||
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
|
||||
chunks.append("\n")
|
||||
return "".join(chunks)
|
||||
|
||||
def _text_block(node):
|
||||
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
|
||||
|
||||
def _inner_text_preserve(node):
|
||||
if node.text is not None:
|
||||
return node.text
|
||||
if not node.tag:
|
||||
return ""
|
||||
if node.tag == "br":
|
||||
return ""
|
||||
return "".join(_inner_text_preserve(child) for child in node.children)
|
||||
|
||||
def _table_to_markdown(node):
|
||||
rows = []
|
||||
for descendant in _iter_descendants(node):
|
||||
if descendant.tag != "tr":
|
||||
continue
|
||||
row = []
|
||||
for cell in descendant.children:
|
||||
if cell.tag in {"td", "th"}:
|
||||
row.append(_escape_table_cell(_text_block(cell)))
|
||||
if row:
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
widths = max(len(row) for row in rows)
|
||||
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
|
||||
|
||||
headers = normalized_rows[0]
|
||||
body_rows = normalized_rows[1:]
|
||||
first_row_blank = all(not cell.strip() for cell in headers)
|
||||
if first_row_blank and len(normalized_rows) > 1:
|
||||
headers = normalized_rows[1]
|
||||
body_rows = normalized_rows[2:]
|
||||
|
||||
has_thead = any(child.tag == "thead" for child in node.children)
|
||||
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
|
||||
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
|
||||
if not (has_thead or first_row_has_th or first_row_blank):
|
||||
headers = [""] * widths
|
||||
body_rows = normalized_rows
|
||||
|
||||
separator = ["---"] * widths
|
||||
lines = [
|
||||
f"| {' | '.join(headers)} |",
|
||||
f"| {' | '.join(separator)} |",
|
||||
]
|
||||
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _list_to_markdown(node, depth=0):
|
||||
ordered = node.tag == "ol"
|
||||
items = []
|
||||
index = 1
|
||||
for child in node.children:
|
||||
if child.tag != "li":
|
||||
continue
|
||||
marker = f"{index}. " if ordered else "- "
|
||||
index += 1
|
||||
content = []
|
||||
nested = []
|
||||
for item_child in child.children:
|
||||
if item_child.tag in {"ul", "ol"}:
|
||||
nested.append(_list_to_markdown(item_child, depth + 1))
|
||||
else:
|
||||
content.append(_inline_text(item_child))
|
||||
line = _collapse_blank_lines(_normalize_inline("".join(content)))
|
||||
indent = " " * depth
|
||||
if line:
|
||||
line_parts = line.splitlines()
|
||||
items.append(f"{indent}{marker}{line_parts[0]}")
|
||||
continuation_indent = f"{indent}{' ' * len(marker)}"
|
||||
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
|
||||
items.extend(block for block in nested if block)
|
||||
return "\n".join(items)
|
||||
|
||||
def _code_block_to_markdown(node):
|
||||
if node.tag == "pre":
|
||||
text = _inner_text_preserve(node).rstrip("\n")
|
||||
return f"```\n{text}\n```" if text else ""
|
||||
|
||||
lines = []
|
||||
for descendant in _iter_descendants(node):
|
||||
if descendant.tag and _has_class(descendant, "cm-line"):
|
||||
lines.append(_inner_text_preserve(descendant))
|
||||
code = "\n".join(lines).rstrip("\n")
|
||||
return f"```\n{code}\n```" if code else ""
|
||||
|
||||
def _block_to_markdown(node):
|
||||
if node.text is not None:
|
||||
return _normalize_text(node.text)
|
||||
if not node.tag:
|
||||
return ""
|
||||
if _is_code_block_node(node):
|
||||
return _code_block_to_markdown(node)
|
||||
if node.tag == "table":
|
||||
return _table_to_markdown(node)
|
||||
if node.tag in {"ul", "ol"}:
|
||||
return _list_to_markdown(node)
|
||||
if re.fullmatch(r"h[1-6]", node.tag):
|
||||
text = _text_block(node)
|
||||
return f"{'#' * int(node.tag[1])} {text}" if text else ""
|
||||
if node.tag in {"p", "figcaption"}:
|
||||
return _text_block(node)
|
||||
if node.tag == "blockquote":
|
||||
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
|
||||
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
|
||||
if node.tag == "hr":
|
||||
return "---"
|
||||
if node.tag == "img":
|
||||
return _inline_text(node)
|
||||
|
||||
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
|
||||
if child_blocks:
|
||||
return _collapse_blank_lines("\n\n".join(child_blocks))
|
||||
return _text_block(node)
|
||||
|
||||
def _parse_table_row(line):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|") or not stripped.endswith("|"):
|
||||
return None
|
||||
return [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
|
||||
def _repair_table_headers(lines):
|
||||
repaired = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
if (
|
||||
index + 2 < len(lines)
|
||||
and _parse_table_row(lines[index]) is not None
|
||||
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
|
||||
and _parse_table_row(lines[index + 2]) is not None
|
||||
):
|
||||
first = _parse_table_row(lines[index])
|
||||
third = _parse_table_row(lines[index + 2])
|
||||
if first and all(not cell for cell in first) and any(cell for cell in third):
|
||||
repaired.append(lines[index + 2].strip())
|
||||
repaired.append(lines[index + 1].strip())
|
||||
index += 3
|
||||
continue
|
||||
repaired.append(lines[index].strip())
|
||||
index += 1
|
||||
return repaired
|
||||
|
||||
def _repair_list_continuations(lines):
|
||||
repaired = []
|
||||
previous_was_list_item = False
|
||||
previous_continuation_indent = ""
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
|
||||
is_markdown_block_start = (
|
||||
not stripped
|
||||
or stripped.startswith(("```", "#", ">", "|"))
|
||||
or _TABLE_SEPARATOR_RE.match(stripped)
|
||||
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
|
||||
)
|
||||
|
||||
if previous_was_list_item and stripped and not is_markdown_block_start:
|
||||
repaired.append(f"{previous_continuation_indent}{stripped}")
|
||||
previous_was_list_item = False
|
||||
continue
|
||||
|
||||
repaired.append(stripped)
|
||||
if list_match:
|
||||
marker = list_match.group(2)
|
||||
base_indent = list_match.group(1)
|
||||
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
|
||||
previous_was_list_item = True
|
||||
else:
|
||||
previous_was_list_item = False
|
||||
|
||||
return repaired
|
||||
|
||||
def _repair_flattened_diagram(text):
|
||||
if "\n" in text:
|
||||
return text
|
||||
if sum(text.count(char) for char in "│▼├└") < 2:
|
||||
return text
|
||||
|
||||
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
|
||||
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
|
||||
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
|
||||
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
|
||||
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
|
||||
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
|
||||
|
||||
def _convert_dash_lists_to_branches(lines):
|
||||
converted = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
|
||||
if not match:
|
||||
converted.append(lines[index])
|
||||
index += 1
|
||||
continue
|
||||
|
||||
indent = match.group(1)
|
||||
items = []
|
||||
while index < len(lines):
|
||||
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
|
||||
if not next_match:
|
||||
break
|
||||
items.append(next_match.group(1))
|
||||
index += 1
|
||||
|
||||
for item_index, item in enumerate(items):
|
||||
branch = "└" if item_index == len(items) - 1 else "├"
|
||||
converted.append(f"{indent}{branch} {item}")
|
||||
return converted
|
||||
|
||||
def _clean_code_block(code):
|
||||
lines = [line.rstrip() for line in code.splitlines()]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
|
||||
flattened = _repair_flattened_diagram("\n".join(lines))
|
||||
lines = flattened.splitlines() if flattened else []
|
||||
lines = [
|
||||
f" {line.strip()}"
|
||||
if line.strip() in {"│", "▼"} and not re.match(r"^\s+[│▼]\s*$", line)
|
||||
else line
|
||||
for line in lines
|
||||
]
|
||||
lines = _convert_dash_lists_to_branches(lines)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _clean_markdown_output(markdown):
|
||||
if not markdown:
|
||||
return ""
|
||||
|
||||
pieces = []
|
||||
last_index = 0
|
||||
for match in _FENCE_RE.finditer(markdown):
|
||||
prose = markdown[last_index:match.start()]
|
||||
if prose:
|
||||
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
|
||||
lines = [line.strip() for line in cleaned.splitlines()]
|
||||
lines = _repair_table_headers(lines)
|
||||
lines = _repair_list_continuations(lines)
|
||||
cleaned = "\n".join(lines)
|
||||
cleaned = _collapse_blank_lines(cleaned)
|
||||
if cleaned:
|
||||
pieces.append(cleaned)
|
||||
|
||||
fence = match.group(0)
|
||||
header, _, tail = fence.partition("\n")
|
||||
body, _, _ = tail.rpartition("\n")
|
||||
cleaned_body = _clean_code_block(body)
|
||||
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
|
||||
last_index = match.end()
|
||||
|
||||
trailing = markdown[last_index:]
|
||||
if trailing:
|
||||
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
|
||||
lines = [line.strip() for line in cleaned.splitlines()]
|
||||
lines = _repair_table_headers(lines)
|
||||
lines = _repair_list_continuations(lines)
|
||||
cleaned = "\n".join(lines)
|
||||
cleaned = _collapse_blank_lines(cleaned)
|
||||
if cleaned:
|
||||
pieces.append(cleaned)
|
||||
|
||||
return "\n\n".join(piece for piece in pieces if piece)
|
||||
|
||||
def _convert_html_to_markdown(html):
|
||||
parser = _HtmlTreeBuilder()
|
||||
parser.feed(html or "")
|
||||
markdown = _block_to_markdown(parser.root)
|
||||
return _clean_markdown_output(markdown)
|
||||
|
||||
def render_markdown(raw: str | None) -> str:
|
||||
"""Normalize *raw* extractor output into clean Markdown.
|
||||
|
||||
If the payload looks like HTML (first non-space char is ``<``) it is run
|
||||
through the HTML→Markdown converter; otherwise it is treated as Markdown and
|
||||
only the clean-up/repair passes are applied.
|
||||
"""
|
||||
raw = raw or ""
|
||||
if raw.lstrip().startswith("<"):
|
||||
return _convert_html_to_markdown(raw)
|
||||
return _clean_markdown_output(raw)
|
||||
+17
-9
@@ -4,11 +4,11 @@ Typed dataclasses returned by the BrowserCLI Python API.
|
||||
Each object is bound to a BrowserCLI instance so you can call actions
|
||||
directly on it:
|
||||
|
||||
tabs = b.tabs_list()
|
||||
tabs = b.tabs.list()
|
||||
tabs[0].close()
|
||||
tabs[0].move(forward=True)
|
||||
|
||||
groups = b.group_list()
|
||||
groups = b.groups.list()
|
||||
groups[0].tabs()
|
||||
groups[0].add_tab("https://example.com")
|
||||
"""
|
||||
@@ -21,6 +21,14 @@ if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCLI
|
||||
|
||||
|
||||
# ── BrowserCounts ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserCounts:
|
||||
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
|
||||
total: int
|
||||
by_browser: dict[str, int]
|
||||
|
||||
# ── Tab ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -97,7 +105,7 @@ class Tab:
|
||||
|
||||
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
|
||||
"""Capture this tab's visible area. Returns a base64 data URL."""
|
||||
return self._b().tabs_screenshot(self.id, format=format, quality=quality)
|
||||
return self._b().tabs.screenshot(self.id, format=format, quality=quality)
|
||||
|
||||
def pin(self) -> None:
|
||||
"""Pin this tab."""
|
||||
@@ -109,19 +117,19 @@ class Tab:
|
||||
|
||||
def refresh(self) -> Tab:
|
||||
"""Return a fresh snapshot of this tab."""
|
||||
return self._b().tabs_status(self.id)
|
||||
return self._b().tabs.status(self.id)
|
||||
|
||||
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
|
||||
"""Wait until this tab reaches the requested readyState."""
|
||||
return self._b().wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
|
||||
return self._b().tabs.wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
|
||||
|
||||
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
|
||||
"""Wait until this tab's URL matches regex *pattern*."""
|
||||
return self._b().tabs_watch_url(pattern, tab_id=self.id, timeout=timeout)
|
||||
return self._b().tabs.watch_url(pattern, tab_id=self.id, timeout=timeout)
|
||||
|
||||
def open(self, url: str, *, background: bool = False) -> None:
|
||||
"""Navigate this tab to *url* in place."""
|
||||
self._b().navigate_tab(self.id, url)
|
||||
self._b().nav.to(self.id, url)
|
||||
|
||||
|
||||
# ── Group ─────────────────────────────────────────────────────────────────────
|
||||
@@ -148,7 +156,7 @@ class Group:
|
||||
|
||||
def tabs(self) -> list[Tab]:
|
||||
"""Return all tabs inside this group."""
|
||||
return self._b().group_tabs(self.id)
|
||||
return self._b().groups.tabs(self.id)
|
||||
|
||||
def move(self, *, forward: bool = False, backward: bool = False) -> None:
|
||||
"""Move this group forward or backward among groups."""
|
||||
@@ -160,4 +168,4 @@ class Group:
|
||||
|
||||
def add_tab(self, url: str | None = None) -> int | None:
|
||||
"""Open a new tab inside this group. Returns the new tab ID."""
|
||||
return self._b().group_add_tab(self.id, url)
|
||||
return self._b().groups.add_tab(self.id, url)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
|
||||
|
||||
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
|
||||
complete the signed challenge/response handshake with an optional post-quantum
|
||||
key exchange, frame the request, and read the framed (possibly encrypted)
|
||||
response. The higher-level "which endpoint / which profile / which key"
|
||||
decisions stay in :mod:`browser_cli.client`, which re-exports these for
|
||||
backward compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
|
||||
|
||||
_PQ_WARNING = (
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
|
||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed before full message received")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
raw_len = _recv_exact(sock, 4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
raise ConnectionError(f"Response too large ({msg_len} bytes)")
|
||||
return _recv_exact(sock, msg_len)
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
port = int(port_str)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
try:
|
||||
raw_sock.connect((host, port))
|
||||
if port == 443:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
|
||||
# receive challenge
|
||||
challenge_raw = _recv_all(sock)
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected(f"No challenge received from {endpoint}")
|
||||
try:
|
||||
challenge = json.loads(challenge_raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if min_ver:
|
||||
from browser_cli.version_manager import parse_version
|
||||
try:
|
||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(min_ver):
|
||||
raise BrowserNotConnected(
|
||||
f"Client version {client_ver} is too old for this server "
|
||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pq_shared_secret = None
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
|
||||
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
sys.stderr.write(_PQ_WARNING)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
|
||||
if pq_shared_secret is not None:
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
msg = {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": public_key_hex(private_key),
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
else:
|
||||
sys.stderr.write(_PQ_WARNING)
|
||||
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
sock.sendall(framed)
|
||||
response = _recv_all(sock)
|
||||
if response is not None and pq_shared_secret is not None:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt
|
||||
envelope = json.loads(response)
|
||||
if isinstance(envelope, dict) and "encrypted" in envelope:
|
||||
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
||||
except Exception as e:
|
||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
||||
return response
|
||||
@@ -0,0 +1,30 @@
|
||||
"""SDK command namespaces for :class:`browser_cli.BrowserCLI`.
|
||||
|
||||
Each namespace groups related browser commands under a short accessor on the
|
||||
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
|
||||
in the browser extension.
|
||||
"""
|
||||
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
|
||||
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
|
||||
from browser_cli.sdk.extension import ExtensionNS
|
||||
from browser_cli.sdk.groups import GroupsNS
|
||||
from browser_cli.sdk.navigation import NavigationNS
|
||||
from browser_cli.sdk.perf import PerfNS
|
||||
from browser_cli.sdk.session import SessionNS
|
||||
from browser_cli.sdk.tabs import TabsNS
|
||||
from browser_cli.sdk.windows import WindowsNS
|
||||
|
||||
__all__ = [
|
||||
"NavigationNS",
|
||||
"TabsNS",
|
||||
"GroupsNS",
|
||||
"WindowsNS",
|
||||
"DomNS",
|
||||
"ExtractNS",
|
||||
"PageNS",
|
||||
"StorageNS",
|
||||
"CookiesNS",
|
||||
"SessionNS",
|
||||
"PerfNS",
|
||||
"ExtensionNS",
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Base class for SDK command namespaces.
|
||||
|
||||
Each namespace (``b.tabs``, ``b.dom``, ...) is a thin object bound to its
|
||||
:class:`~browser_cli.BrowserCLI` client. Namespaces hold no state of their own;
|
||||
they delegate to the client's shared infrastructure (``_cmd``, the multi-browser
|
||||
helpers, and the ``Tab``/``Group`` factories).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCLI
|
||||
|
||||
class Namespace:
|
||||
"""A group of related SDK methods, bound to a BrowserCLI client."""
|
||||
|
||||
def __init__(self, client: "BrowserCLI"):
|
||||
self._c = client
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class StorageNS(Namespace):
|
||||
"""Read and write localStorage / sessionStorage."""
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: str | None = None,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> str | dict | None:
|
||||
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
|
||||
return self._c._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> None:
|
||||
"""Set a localStorage/sessionStorage entry."""
|
||||
self._c._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
|
||||
|
||||
class CookiesNS(Namespace):
|
||||
"""List, get, and set cookies."""
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
domain: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List cookies, optionally filtered by url, domain, or name."""
|
||||
return self._c._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
|
||||
def get(self, url: str, name: str) -> dict | None:
|
||||
"""Get a single cookie by url and name."""
|
||||
return self._c._cmd("cookies.get", {"url": url, "name": name})
|
||||
|
||||
def set(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
value: str,
|
||||
*,
|
||||
domain: str | None = None,
|
||||
path: str | None = None,
|
||||
secure: bool | None = None,
|
||||
http_only: bool | None = None,
|
||||
expiration_date: float | None = None,
|
||||
same_site: str | None = None,
|
||||
) -> dict:
|
||||
"""Set a cookie. Returns the created cookie dict."""
|
||||
return self._c._cmd("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure, "httpOnly": http_only,
|
||||
"expirationDate": expiration_date, "sameSite": same_site,
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
"""DOM, content-extraction, and page-info namespaces: ``b.dom.*``, ``b.extract.*``, ``b.page.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class DomNS(Namespace):
|
||||
"""Query and drive page elements in the active (or specified) tab."""
|
||||
|
||||
def query(self, selector: str) -> list[dict]:
|
||||
return self._c._cmd("dom.query", {"selector": selector}) or []
|
||||
|
||||
def click(self, selector: str) -> None:
|
||||
self._c._cmd("dom.click", {"selector": selector})
|
||||
|
||||
def type(self, selector: str, text: str) -> None:
|
||||
self._c._cmd("dom.type", {"selector": selector, "text": text})
|
||||
|
||||
def attr(self, selector: str, attr: str) -> list[str]:
|
||||
return self._c._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
|
||||
|
||||
def text(self, selector: str) -> list[str]:
|
||||
return self._c._cmd("dom.text", {"selector": selector}) or []
|
||||
|
||||
def exists(self, selector: str) -> bool:
|
||||
return self._c._cmd("dom.exists", {"selector": selector}) or False
|
||||
|
||||
def scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
|
||||
"""Scroll to a CSS selector or to pixel coordinates."""
|
||||
self._c._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
|
||||
def select(self, selector: str, value: str) -> None:
|
||||
"""Set the value of a <select> element."""
|
||||
self._c._cmd("dom.select", {"selector": selector, "value": value})
|
||||
|
||||
def eval(self, code: str, tab_id: int | None = None):
|
||||
"""Evaluate JavaScript in the page's main world and return the result."""
|
||||
return self._c._cmd("dom.eval", {"code": code, "tabId": tab_id})
|
||||
|
||||
def key(self, key: str, selector: str | None = None) -> None:
|
||||
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
|
||||
self._c._cmd("dom.key", {"key": key, "selector": selector})
|
||||
|
||||
def hover(self, selector: str) -> None:
|
||||
"""Dispatch mouseover/mouseenter on an element."""
|
||||
self._c._cmd("dom.hover", {"selector": selector})
|
||||
|
||||
def check(self, selector: str) -> None:
|
||||
"""Check a checkbox."""
|
||||
self._c._cmd("dom.check", {"selector": selector})
|
||||
|
||||
def uncheck(self, selector: str) -> None:
|
||||
"""Uncheck a checkbox."""
|
||||
self._c._cmd("dom.uncheck", {"selector": selector})
|
||||
|
||||
def clear(self, selector: str) -> None:
|
||||
"""Clear the value of an input element."""
|
||||
self._c._cmd("dom.clear", {"selector": selector})
|
||||
|
||||
def focus(self, selector: str) -> None:
|
||||
"""Focus an element."""
|
||||
self._c._cmd("dom.focus", {"selector": selector})
|
||||
|
||||
def submit(self, selector: str) -> None:
|
||||
"""Submit the form containing the matched element."""
|
||||
self._c._cmd("dom.submit", {"selector": selector})
|
||||
|
||||
def poll(
|
||||
self,
|
||||
selector: str,
|
||||
pattern: str,
|
||||
*,
|
||||
attr: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
interval: float = 0.5,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Poll selector's text/value until it matches regex pattern.
|
||||
|
||||
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
|
||||
"""
|
||||
return self._c._cmd("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
def wait_for(
|
||||
self,
|
||||
selector: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
visible: bool = False,
|
||||
hidden: bool = False,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Wait until a CSS selector appears (or disappears) in the DOM.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to watch.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
visible: Wait until the element has non-zero dimensions.
|
||||
hidden: Wait until the element is absent or has ``offsetParent == null``.
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
"""
|
||||
return self._c._cmd("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
class ExtractNS(Namespace):
|
||||
"""Extract structured content from the active tab."""
|
||||
|
||||
def links(self) -> list[dict]:
|
||||
return self._c._cmd("extract.links", {}) or []
|
||||
|
||||
def images(self) -> list[dict]:
|
||||
return self._c._cmd("extract.images", {}) or []
|
||||
|
||||
def text(self) -> str:
|
||||
return self._c._cmd("extract.text", {}) or ""
|
||||
|
||||
def json(self, selector: str):
|
||||
return self._c._cmd("extract.json", {"selector": selector})
|
||||
|
||||
def html(self) -> str:
|
||||
"""Return the full HTML source of the active tab."""
|
||||
return self._c._cmd("extract.html", {}) or ""
|
||||
|
||||
def markdown(self, selector: str | None = None) -> str:
|
||||
"""Extract the page's main content as clean Markdown.
|
||||
|
||||
The extractor may return either Markdown or raw HTML; both are
|
||||
normalized to Markdown here so SDK and CLI callers get identical output.
|
||||
"""
|
||||
from browser_cli.markdown import render_markdown
|
||||
|
||||
return render_markdown(self._c._cmd("extract.markdown", {"selector": selector}))
|
||||
|
||||
class PageNS(Namespace):
|
||||
"""Inspect the active page."""
|
||||
|
||||
def info(self) -> dict:
|
||||
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
|
||||
return self._c._cmd("page.info", {}) or {}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Extension-control namespace: ``b.extension.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class ExtensionNS(Namespace):
|
||||
"""Control the browser-cli extension itself."""
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload the browser-cli extension service worker.
|
||||
|
||||
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
|
||||
immediately. The extension restarts ~200 ms later and reconnects via the
|
||||
keepalive alarm within ~25 seconds.
|
||||
"""
|
||||
self._c._cmd("extension.reload", {})
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Object-factory mixin for :class:`~browser_cli.BrowserCLI`.
|
||||
|
||||
Builds the typed :class:`~browser_cli.models.Tab` / :class:`~browser_cli.models.Group`
|
||||
dataclasses from raw command responses and binds each one to the client that
|
||||
should run its actions. In multi-browser mode an object is bound to a sibling
|
||||
client targeting the browser it came from, so ``tab.close()`` routes correctly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
|
||||
class FactoryMixin:
|
||||
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
|
||||
|
||||
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
|
||||
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
|
||||
"""
|
||||
|
||||
def _make_tab(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
window_id=data.get("windowId", 0),
|
||||
active=data.get("active", False),
|
||||
muted=data.get("muted", False),
|
||||
title=data.get("title") or "",
|
||||
url=data.get("url") or "",
|
||||
group_id=data.get("groupId") or None,
|
||||
browser=browser_name,
|
||||
)
|
||||
tab._browser = self if browser_profile is None else type(self)(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return tab
|
||||
|
||||
def _require_tab(self, data, error: str) -> Tab:
|
||||
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError(error)
|
||||
return self._make_tab(data)
|
||||
|
||||
def _make_group(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
title=data.get("title") or "",
|
||||
color=data.get("color") or "",
|
||||
collapsed=data.get("collapsed", False),
|
||||
tab_count=data.get("tabCount", 0),
|
||||
browser=browser_name,
|
||||
)
|
||||
group._browser = self if browser_profile is None else type(self)(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return group
|
||||
|
||||
def _make_tab_for(self, data: dict, target) -> Tab:
|
||||
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self._make_tab(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
|
||||
def _make_group_for(self, data: dict, target) -> Group:
|
||||
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self._make_group(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tag_browser(item: dict, target) -> dict:
|
||||
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
|
||||
return item if target is None else {**item, "browser": target.display_name}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tab groups namespace: ``b.groups.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCounts
|
||||
|
||||
class GroupsNS(Namespace):
|
||||
"""List, create, query, and modify tab groups."""
|
||||
|
||||
def list(self) -> list[Group]:
|
||||
"""Return all tab groups.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Group
|
||||
includes ``group.browser`` naming its source browser.
|
||||
"""
|
||||
return self._c._multi_list("group.list", {}, self._c._make_group_for)
|
||||
|
||||
def count(self) -> "int | BrowserCounts":
|
||||
"""Return the number of tab groups.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
return self._c._multi_count("group.count", {})
|
||||
|
||||
def query(self, search: str) -> list[Group]:
|
||||
"""Search groups by name."""
|
||||
return [self._c._make_group(g) for g in (self._c._cmd("group.query", {"search": search}) or [])]
|
||||
|
||||
def create(self, name: str) -> Group:
|
||||
"""Create a new tab group with *name*. Returns the created Group."""
|
||||
data = self._c._cmd("group.open", {"name": name})
|
||||
if isinstance(data, dict):
|
||||
return self._c._make_group(data)
|
||||
return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
|
||||
|
||||
def tabs(self, group_id: int) -> list[Tab]:
|
||||
"""Return all tabs inside a group."""
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("group.tabs", {"groupId": group_id}) or [])]
|
||||
|
||||
def add_tab(self, group: str | int, url: str | None = None) -> int | None:
|
||||
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
|
||||
result = self._c._cmd("group.add_tab", {"group": str(group), "url": url})
|
||||
return self._c._field(result, "tabId", fallback=result)
|
||||
|
||||
def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
|
||||
"""Move a tab group forward or backward. Returns the raw move result."""
|
||||
return self._c._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
|
||||
|
||||
def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
|
||||
"""Ungroup (and close) a tab group by ID."""
|
||||
self._c._cmd("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Navigation namespace: ``b.nav.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class NavigationNS(Namespace):
|
||||
"""Open URLs, navigate history, and focus tabs."""
|
||||
|
||||
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
||||
"""Open *url* in a new tab."""
|
||||
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
|
||||
def open_wait(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Tab:
|
||||
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": background, "window": window, "group": group,
|
||||
}),
|
||||
"navigate.open_wait returned unexpected data",
|
||||
)
|
||||
|
||||
def reload(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.reload", {"tabId": tab_id})
|
||||
|
||||
def hard_reload(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.hard_reload", {"tabId": tab_id})
|
||||
|
||||
def back(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.back", {"tabId": tab_id})
|
||||
|
||||
def forward(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.forward", {"tabId": tab_id})
|
||||
|
||||
def focus(self, pattern: str) -> dict | None:
|
||||
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
|
||||
return self._c._cmd("navigate.focus", {"pattern": pattern})
|
||||
|
||||
def to(self, tab_id: int, url: str) -> None:
|
||||
"""Navigate a specific tab to *url* in place."""
|
||||
self._c._cmd("navigate.to", {"tabId": tab_id, "url": url})
|
||||
|
||||
def search(
|
||||
self, engine: str, query: str, *,
|
||||
background: bool = False, window: str | None = None, group: str | None = None,
|
||||
) -> None:
|
||||
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.commands.search import ENGINES
|
||||
template = ENGINES.get(engine)
|
||||
if template is None:
|
||||
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
||||
url = template.format(query=quote_plus(query))
|
||||
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Performance and background-jobs namespace: ``b.perf.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class PerfNS(Namespace):
|
||||
"""Inspect the performance profile and manage background jobs."""
|
||||
|
||||
def status(self) -> dict:
|
||||
return self._c._cmd("perf.status", {}) or {}
|
||||
|
||||
def set_profile(self, profile: str) -> dict:
|
||||
return self._c._cmd("perf.set_profile", {"profile": profile}) or {}
|
||||
|
||||
def job_status(self, job_id: str) -> dict:
|
||||
return self._c._cmd("jobs.status", {"jobId": job_id}) or {}
|
||||
|
||||
def job_cancel(self, job_id: str) -> dict:
|
||||
return self._c._cmd("jobs.cancel", {"jobId": job_id}) or {}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Multi-browser routing mixin for :class:`~browser_cli.BrowserCLI`.
|
||||
|
||||
When no specific browser is selected and more than one browser (local or
|
||||
remote) is active, list/count commands fan out to every target and aggregate
|
||||
the results. This mixin holds that fan-out machinery plus a few small response
|
||||
helpers; single-browser mode falls straight through to ``_cmd``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import browser_cli as _pkg
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
|
||||
# send_command / active_browser_targets / remote_browser_targets are resolved
|
||||
# through the ``browser_cli`` package namespace (``_pkg``) at call time, not bound
|
||||
# here at import, so tests patching ``browser_cli.send_command`` still take effect.
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
class RoutingMixin:
|
||||
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
||||
|
||||
Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
|
||||
and a ``tabs`` namespace.
|
||||
"""
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
if self._remote:
|
||||
targets = _pkg.remote_browser_targets(self._remote, key=self._key)
|
||||
else:
|
||||
targets = _pkg.active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = _pkg.send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
|
||||
else:
|
||||
data = _pkg.send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
if targets:
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _field(result, key, default=None, *, fallback=_UNSET):
|
||||
"""Pull *key* out of a dict response, with a non-dict fallback.
|
||||
|
||||
Returns ``result[key]`` (or *default*) when *result* is a dict. When it
|
||||
is not a dict, returns *fallback* if given, else *default*.
|
||||
"""
|
||||
if isinstance(result, dict):
|
||||
return result.get(key, default)
|
||||
return default if fallback is _UNSET else fallback
|
||||
|
||||
def _toggle_tab(self, command: str, tab_id: int | None) -> int:
|
||||
"""Run a tab toggle command (mute/pin/...) and return the target tab ID."""
|
||||
result = self._cmd(command, {"tabId": tab_id})
|
||||
return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
|
||||
|
||||
def _multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
|
||||
"""Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
|
||||
multi_results = self._collect_multi_browser(command, args or {})
|
||||
if not multi_results:
|
||||
return self._cmd(command, args or {})
|
||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
|
||||
def _multi_list(self, command: str, args: dict | None, mapper):
|
||||
"""List command, flattening per-browser results in multi-browser mode.
|
||||
|
||||
*mapper* is ``(item, target) -> mapped`` where ``target`` is the source
|
||||
:class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser(command, args or {})
|
||||
if multi_results:
|
||||
return [
|
||||
mapper(item, target)
|
||||
for target, items in multi_results
|
||||
for item in (items or [])
|
||||
]
|
||||
return [mapper(item, None) for item in (self._cmd(command, args or {}) or [])]
|
||||
|
||||
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
|
||||
tabs = self.tabs.list()
|
||||
|
||||
try:
|
||||
transformed = filter_fn(tabs)
|
||||
except (AttributeError, TypeError):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
if isinstance(transformed, list):
|
||||
return transformed
|
||||
if isinstance(transformed, tuple):
|
||||
return list(transformed)
|
||||
if isinstance(transformed, set):
|
||||
return list(transformed)
|
||||
if transformed is tabs:
|
||||
return tabs
|
||||
if isinstance(transformed, bool):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
try:
|
||||
return list(transformed)
|
||||
except TypeError:
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Session namespace: ``b.session.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class SessionNS(Namespace):
|
||||
"""Save, restore, list, and diff browser sessions."""
|
||||
|
||||
def save(self, name: str) -> dict:
|
||||
"""Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
|
||||
return self._c._cmd("session.save", {"name": name}) or {}
|
||||
|
||||
@staticmethod
|
||||
def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"gentleMode": gentle_mode,
|
||||
"discardBackgroundTabs": discard_background_tabs,
|
||||
"lazy": lazy,
|
||||
"eagerTabs": eager_tabs,
|
||||
}
|
||||
|
||||
def load(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> dict:
|
||||
"""Restore session *name*. Returns the load result (incl. tabs opened)."""
|
||||
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
|
||||
return self._c._cmd("session.load", args) or {}
|
||||
|
||||
def load_background(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> dict:
|
||||
"""Restore session *name* as a background job. Returns the job descriptor."""
|
||||
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
|
||||
return self._c._cmd("session.load", {**args, "__background": True}) or {}
|
||||
|
||||
def diff(self, name_a: str, name_b: str) -> dict:
|
||||
return self._c._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
"""Return saved sessions.
|
||||
|
||||
In implicit multi-browser mode each session dict includes a ``browser`` key.
|
||||
"""
|
||||
return self._c._multi_list("session.list", {}, self._c._tag_browser)
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
self._c._cmd("session.remove", {"name": name})
|
||||
|
||||
def auto_save(self, enabled: bool) -> None:
|
||||
self._c._cmd("session.auto_save", {"enabled": enabled})
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tabs namespace: ``b.tabs.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCounts
|
||||
|
||||
class TabsNS(Namespace):
|
||||
"""List, open, close, move, and inspect browser tabs."""
|
||||
|
||||
def list(self) -> list[Tab]:
|
||||
"""Return all open tabs across all windows.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Tab
|
||||
includes ``tab.browser`` naming its source browser.
|
||||
"""
|
||||
return self._c._multi_list("tabs.list", {}, self._c._make_tab_for)
|
||||
|
||||
def open(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
wait: bool = False,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Tab:
|
||||
"""Open *url* in a new tab and return a bound :class:`Tab`.
|
||||
|
||||
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||
"""
|
||||
if wait:
|
||||
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
|
||||
"navigate.open returned unexpected data",
|
||||
)
|
||||
|
||||
def get(self, tab_id: int) -> Tab:
|
||||
"""Return a specific tab by ID."""
|
||||
return self.status(tab_id)
|
||||
|
||||
def active(self) -> Tab:
|
||||
"""Return the active tab."""
|
||||
return self.status()
|
||||
|
||||
def query(self, search: str) -> list[Tab]:
|
||||
"""Search tabs by URL or title."""
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.query", {"search": search}) or [])]
|
||||
|
||||
def first(self, search: str) -> Tab | None:
|
||||
"""Return the first tab matching *search*, or ``None``."""
|
||||
matches = self.query(search)
|
||||
return matches[0] if matches else None
|
||||
|
||||
def close(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
tab_ids: Iterable[int | Tab] | None = None,
|
||||
inactive: bool = False,
|
||||
duplicates: bool = False,
|
||||
gentle_mode: str = "auto",
|
||||
) -> int:
|
||||
"""Close tab(s). Returns the number of tabs closed.
|
||||
|
||||
Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
|
||||
IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
|
||||
controls throttling of large close operations.
|
||||
"""
|
||||
ids = None
|
||||
if tab_ids is not None:
|
||||
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
||||
result = self._c._cmd("tabs.close", {
|
||||
"tabId": tab_id,
|
||||
"tabIds": ids,
|
||||
"inactive": inactive,
|
||||
"duplicates": duplicates,
|
||||
"gentleMode": gentle_mode,
|
||||
})
|
||||
return self._c._field(result, "closed", 1)
|
||||
|
||||
def close_inactive(self) -> int:
|
||||
"""Close all inactive tabs. Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.close", {"inactive": True}), "closed", 0)
|
||||
|
||||
def close_duplicates(self) -> int:
|
||||
"""Close duplicate tabs. Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.close", {"duplicates": True}), "closed", 0)
|
||||
|
||||
def move(
|
||||
self, tab_id: int, *,
|
||||
forward: bool = False, backward: bool = False,
|
||||
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
|
||||
) -> None:
|
||||
self._c._cmd("tabs.move", {
|
||||
"tabId": tab_id, "forward": forward, "backward": backward,
|
||||
"groupId": group_id, "windowId": window_id, "index": index,
|
||||
})
|
||||
|
||||
def activate(self, tab_id: int) -> None:
|
||||
"""Switch browser focus to a tab by ID."""
|
||||
self._c._cmd("tabs.active", {"tabId": tab_id})
|
||||
|
||||
def status(self, tab_id: int | None = None) -> Tab:
|
||||
"""Return status for the active tab or a specific tab."""
|
||||
return self._c._require_tab(self._c._cmd("tabs.status", {"tabId": tab_id}), "No tab status returned")
|
||||
|
||||
def mute(self, tab_id: int | None = None) -> int:
|
||||
"""Mute the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.mute", tab_id)
|
||||
|
||||
def unmute(self, tab_id: int | None = None) -> int:
|
||||
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.unmute", tab_id)
|
||||
|
||||
def pin(self, tab_id: int | None = None) -> int:
|
||||
"""Pin the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.pin", tab_id)
|
||||
|
||||
def unpin(self, tab_id: int | None = None) -> int:
|
||||
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.unpin", tab_id)
|
||||
|
||||
def watch_url(self, pattern: str, *, tab_id: int | None = None, timeout: float = 30.0) -> Tab:
|
||||
"""Block until the tab URL matches regex pattern. Returns the Tab."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
|
||||
"tabs.watch_url returned unexpected data",
|
||||
)
|
||||
|
||||
def wait_for_load(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
ready_state: str = "complete",
|
||||
) -> Tab:
|
||||
"""Block until the tab finishes loading. Returns the Tab when ready.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
ready_state: ``"complete"`` (default) or ``"interactive"``.
|
||||
"""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.wait", {
|
||||
"tabId": tab_id,
|
||||
"timeout": int(timeout * 1000),
|
||||
"readyState": ready_state,
|
||||
}),
|
||||
"navigate.wait returned unexpected data",
|
||||
)
|
||||
|
||||
def screenshot(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
format: str = "png",
|
||||
quality: int | None = None,
|
||||
) -> str:
|
||||
"""Capture the visible area of a tab. Returns a base64 data URL.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to capture. Defaults to the active tab.
|
||||
format: ``"png"`` (default) or ``"jpeg"``.
|
||||
quality: JPEG quality 0-100 (ignored for PNG).
|
||||
"""
|
||||
result = self._c._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
|
||||
return self._c._field(result, "dataUrl", "", fallback=str(result))
|
||||
|
||||
def active_in_window(self, window_id: int) -> Tab:
|
||||
"""Return active tab for a specific browser window."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("tabs.active_in_window", {"windowId": window_id}),
|
||||
f"No active tab found for window {window_id}",
|
||||
)
|
||||
|
||||
def filter(
|
||||
self,
|
||||
pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
|
||||
) -> list[Tab]:
|
||||
"""Return tabs filtered by URL pattern or a Python callable."""
|
||||
if isinstance(pattern_or_filter, str):
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
|
||||
return self._c._apply_tab_filter(pattern_or_filter)
|
||||
|
||||
def count(self, pattern: str | None = None) -> "int | BrowserCounts":
|
||||
"""Count open tabs, optionally filtered by URL pattern.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
return self._c._multi_count("tabs.count", {"pattern": pattern})
|
||||
|
||||
def html(self, tab_id: int | None = None) -> str:
|
||||
"""Return the full HTML source of the active (or specified) tab."""
|
||||
return self._c._cmd("tabs.html", {"tabId": tab_id}) or ""
|
||||
|
||||
def dedupe(self, *, gentle_mode: str = "auto") -> int:
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
|
||||
|
||||
def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
|
||||
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
|
||||
self._c._cmd("tabs.sort", {"by": by, "gentleMode": gentle_mode})
|
||||
|
||||
def merge_windows(self, *, gentle_mode: str = "auto") -> int:
|
||||
"""Move all tabs into the focused window. Returns count moved."""
|
||||
return self._c._field(self._c._cmd("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Windows namespace: ``b.windows.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class WindowsNS(Namespace):
|
||||
"""List, open, close, and rename browser windows."""
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
"""Return browser windows.
|
||||
|
||||
In implicit multi-browser mode each window dict includes a ``browser`` key.
|
||||
"""
|
||||
return self._c._multi_list("windows.list", {}, self._c._tag_browser)
|
||||
|
||||
def open(self, url: str | None = None) -> dict:
|
||||
"""Open a new browser window, optionally on a URL."""
|
||||
return self._c._cmd("windows.open", {"url": url}) or {}
|
||||
|
||||
def close(self, window_id: int) -> None:
|
||||
self._c._cmd("windows.close", {"windowId": window_id})
|
||||
|
||||
def rename(self, window_id: int, name: str) -> None:
|
||||
self._c._cmd("windows.rename", {"windowId": window_id, "name": name})
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Response payload encoding for the TCP serve <-> client leg.
|
||||
|
||||
The wire frame stays ``4-byte LE length + payload``. The payload is made
|
||||
self-describing so old peers keep working unchanged:
|
||||
|
||||
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
|
||||
format). Old clients and old servers only ever produce/consume this.
|
||||
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
|
||||
The tag's high nibble selects serialization, the low nibble compression::
|
||||
|
||||
tag = (serialization << 4) | compression
|
||||
|
||||
This is only ever emitted toward a peer that advertised support for it, so it
|
||||
is fully backward compatible: clients announce what they can decode via the
|
||||
``accept_encoding`` field in their request, and the server encodes the
|
||||
response accordingly. Requests themselves stay plain JSON (they are tiny).
|
||||
|
||||
Compression is the big win — response payloads (``extract.html``,
|
||||
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
|
||||
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
|
||||
image as raw bytes instead of a base64 data URL (~33% smaller before
|
||||
compression); the client transparently rebuilds the data URL so the SDK/CLI
|
||||
API is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import zlib
|
||||
|
||||
try: # optional: better ratio + speed than zlib/gzip
|
||||
import zstandard as _zstd
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_zstd = None
|
||||
|
||||
try: # optional: alternate serialization + raw binary for screenshots
|
||||
import msgpack as _msgpack
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_msgpack = None
|
||||
|
||||
# ── codec ids ────────────────────────────────────────────────────────────────
|
||||
SER_JSON = 0
|
||||
SER_MSGPACK = 1
|
||||
|
||||
COMP_NONE = 0
|
||||
COMP_ZLIB = 1
|
||||
COMP_GZIP = 2
|
||||
COMP_ZSTD = 3
|
||||
|
||||
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
||||
_SER_ID = {v: k for k, v in _SER_NAME.items()}
|
||||
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
||||
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
|
||||
|
||||
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
|
||||
DEFAULT_THRESHOLD = 512
|
||||
|
||||
# JSON top-level values always start with one of these bytes; a tag byte never does.
|
||||
_JSON_FIRST_BYTES = frozenset(b"{[")
|
||||
|
||||
def msgpack_available() -> bool:
|
||||
return _msgpack is not None
|
||||
|
||||
def zstd_available() -> bool:
|
||||
return _zstd is not None
|
||||
|
||||
def supported_serialization() -> list[str]:
|
||||
"""Serializations this build can produce/consume, best first."""
|
||||
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
||||
|
||||
def supported_compression() -> list[str]:
|
||||
"""Compression codecs this build can produce/consume, best first."""
|
||||
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
||||
|
||||
def client_accept_encoding() -> dict:
|
||||
"""What the local client advertises it can decode (sent with each request)."""
|
||||
return {"ser": supported_serialization(), "comp": supported_compression()}
|
||||
|
||||
# ── compression primitives ────────────────────────────────────────────────────
|
||||
|
||||
def _compress(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.compress(data, 6)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.compress(data, compresslevel=6)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd compression requested but zstandard is not installed")
|
||||
return _zstd.ZstdCompressor(level=10).compress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
def _decompress(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.decompress(data)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.decompress(data)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd payload received but zstandard is not installed")
|
||||
return _zstd.ZstdDecompressor().decompress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
# ── codec negotiation ──────────────────────────────────────────────────────────
|
||||
|
||||
def _choose(accept: dict | None) -> tuple[int, int]:
|
||||
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
||||
accept = accept if isinstance(accept, dict) else {}
|
||||
accept_ser = accept.get("ser") or ["json"]
|
||||
accept_comp = accept.get("comp") or []
|
||||
|
||||
ser = SER_JSON
|
||||
if _msgpack is not None and "msgpack" in accept_ser:
|
||||
ser = SER_MSGPACK
|
||||
|
||||
comp = COMP_NONE
|
||||
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
||||
if name in accept_comp:
|
||||
comp = _COMP_ID[name]
|
||||
break
|
||||
return ser, comp
|
||||
|
||||
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
|
||||
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
||||
_B64_MARKER = "__b64__"
|
||||
|
||||
def _hoist_screenshot(obj, command: str | None):
|
||||
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
||||
|
||||
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
|
||||
"""
|
||||
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
||||
return obj
|
||||
data = obj.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return obj
|
||||
url = data.get("dataUrl")
|
||||
if not isinstance(url, str):
|
||||
return obj
|
||||
m = _DATA_URL_RE.match(url)
|
||||
if not m:
|
||||
return obj
|
||||
try:
|
||||
raw = base64.b64decode(m.group(2))
|
||||
except Exception:
|
||||
return obj
|
||||
new_data = dict(data)
|
||||
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
|
||||
return {**obj, "data": new_data}
|
||||
|
||||
def _unhoist_binary(obj):
|
||||
"""Rebuild any hoisted data URL so callers see the original string again."""
|
||||
if isinstance(obj, dict):
|
||||
raw = obj.get("raw")
|
||||
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
||||
mime = obj.get("mime") or "application/octet-stream"
|
||||
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
||||
return {k: _unhoist_binary(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_unhoist_binary(v) for v in obj]
|
||||
return obj
|
||||
|
||||
# ── encode / decode ─────────────────────────────────────────────────────────────
|
||||
|
||||
def encode_response(obj, accept: dict | None = None, command: str | None = None,
|
||||
threshold: int = DEFAULT_THRESHOLD) -> bytes:
|
||||
"""Encode a response object for the chosen/accepted codec.
|
||||
|
||||
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
||||
what an old server would have sent.
|
||||
"""
|
||||
ser, comp = _choose(accept)
|
||||
|
||||
if ser == SER_MSGPACK:
|
||||
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
||||
else:
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
|
||||
if comp != COMP_NONE and len(body) >= threshold:
|
||||
body = _compress(comp, body)
|
||||
else:
|
||||
comp = COMP_NONE
|
||||
|
||||
if ser == SER_JSON and comp == COMP_NONE:
|
||||
return body # plain JSON — historical wire format, no tag byte
|
||||
|
||||
return bytes([(ser << 4) | comp]) + body
|
||||
|
||||
def decode_response(raw: bytes | None):
|
||||
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not raw:
|
||||
raise ValueError("empty response payload")
|
||||
if raw[0] in _JSON_FIRST_BYTES:
|
||||
return json.loads(raw)
|
||||
|
||||
tag = raw[0]
|
||||
ser, comp = tag >> 4, tag & 0x0F
|
||||
body = _decompress(comp, raw[1:])
|
||||
if ser == SER_MSGPACK:
|
||||
if _msgpack is None:
|
||||
raise ValueError("msgpack payload received but msgpack is not installed")
|
||||
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
||||
if ser == SER_JSON:
|
||||
return json.loads(body)
|
||||
raise ValueError(f"unknown serialization id {ser}")
|
||||
@@ -3,16 +3,16 @@ from importlib.metadata import version as _pkg_version
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
|
||||
|
||||
def parse_version(v: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(x) for x in v.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
|
||||
|
||||
def get_installed_version() -> str:
|
||||
try:
|
||||
return _pkg_version("browser-cli")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
USER_AGENT = f"browser-cli/{get_installed_version()}"
|
||||
|
||||
Reference in New Issue
Block a user