Files
browser-cli/browser_cli/url_match.py
T
daniel156161 7cb034dfc2
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 47s
Testing / test (push) Successful in 50s
feat(tabs): reuse an open tab instead of duplicating a page
- Add browser_cli/url_match.py with a canonical URL key for tab matching
- Canonicalize scheme, host, default ports, www., trailing slash, query order
- Support reuse modes none, exact, similar and origin via normalize_mode()
- Pick the closest match when several tabs qualify, preferring identical URLs
- Skip the reload when the reused tab already shows the requested page
- Expose nav.reusable_tab() so callers can look up a tab without side effects
- Accept reuse/reuse_domain/reuse_title in tabs.open(), not just nav.open()
- Add --reuse-exact to nav open and nav open-wait; --reuse is now fuzzy
- Default MCP tabs_open to reuse="similar" and return a reused flag
- Tell MCP agents in the server instructions to reuse before opening
- Document the reuse modes in README for MCP, CLI and Python API
- Cover url_match, SDK reuse paths and MCP reuse behaviour with new tests
- Bump package and extension version to 0.16.9
- Refresh uv.lock for the updated dependency set
2026-10-19 10:47:56 +02:00

139 lines
4.7 KiB
Python

"""Match a requested URL against already-open tabs.
Agents and scripts usually want the tab that already shows a page instead of a
duplicate of it. These helpers answer "is this tab already (nearly) the URL I
am about to open?" without touching the browser.
Modes:
``none`` never reuse a tab
``exact`` only a byte-identical URL
``similar`` same site and page, ignoring fragment, trailing slash, query
order, ``www.`` and default ports; a request for the site root
also matches any tab on that site
``origin`` any tab on the same site, whatever its path
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import parse_qsl, urlsplit
ReuseMode = Literal["none", "exact", "similar", "origin"]
REUSE_MODES: tuple[str, ...] = ("none", "exact", "similar", "origin")
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
_ROOT_PATHS = {"", "/"}
@dataclass(frozen=True)
class UrlKey:
"""Canonical, comparable form of a URL."""
scheme: str
host: str
port: int | None
path: str
query: tuple[tuple[str, str], ...]
@property
def origin(self) -> tuple[str, str, int | None]:
return (self.scheme, self.host, self.port)
@property
def is_root(self) -> bool:
return self.path == "" and not self.query
def url_key(url: str | None) -> UrlKey | None:
"""Return the canonical key of *url*, or ``None`` when it is not comparable."""
if not url:
return None
parts = urlsplit(url.strip())
if not parts.scheme or not parts.hostname:
return None
scheme = parts.scheme.lower()
host = parts.hostname.lower()
if host.startswith("www.") and len(host) > 4:
host = host[4:]
port = parts.port
if port is not None and _DEFAULT_PORTS.get(scheme) == port:
port = None
path = parts.path.rstrip("/")
query = tuple(sorted(parse_qsl(parts.query, keep_blank_values=True)))
return UrlKey(scheme=scheme, host=host, port=port, path=path, query=query)
def normalize_mode(reuse: bool | str | None, reuse_domain: bool = False) -> ReuseMode:
"""Fold the boolean flags and explicit mode names into one :data:`ReuseMode`."""
if reuse_domain:
return "origin"
if reuse is True:
return "similar"
if reuse is False or reuse is None:
return "none"
mode = str(reuse).lower()
if mode not in REUSE_MODES:
raise ValueError(f"Unknown reuse mode '{reuse}'. Use one of: {', '.join(REUSE_MODES)}")
return mode # type: ignore[return-value]
def urls_match(url: str, candidate: str | None, mode: ReuseMode) -> bool:
"""Report whether *candidate* may be reused for *url* under *mode*."""
if mode == "none" or not candidate:
return False
if mode == "exact":
return candidate == url
wanted, other = url_key(url), url_key(candidate)
if wanted is None or other is None:
return False
if wanted.origin != other.origin:
return False
if mode == "origin":
return True
return wanted.path == other.path or wanted.is_root
def needs_navigation(current: str | None, url: str) -> bool:
"""Report whether a reused tab still has to be navigated to *url*."""
if current == url:
return False
wanted, other = url_key(url), url_key(current)
return wanted is None or other is None or wanted != other
def tab_url(tab: Any) -> str | None:
"""Read a tab URL from an SDK ``Tab`` or a serialized tab dict."""
if isinstance(tab, dict):
value = tab.get("url")
else:
value = getattr(tab, "url", None)
return value if isinstance(value, str) else None
def tab_id(tab: Any) -> int | None:
"""Read a tab ID from an SDK ``Tab`` or a serialized tab dict."""
value = tab.get("id") if isinstance(tab, dict) else getattr(tab, "id", None)
return value if isinstance(value, int) else None
def find_reusable_tab(tabs: Any, url: str, mode: ReuseMode):
"""Return the open tab that best fits *url*, or ``None``.
Closer matches win: an identical URL beats a canonical match, which beats a
looser site-level match; among equally loose matches the tab closest to the
requested page wins.
"""
if mode == "none":
return None
wanted = url_key(url)
best, best_rank = None, None
for tab in tabs or ():
candidate = tab_url(tab)
if not urls_match(url, candidate, mode):
continue
rank = _rank(wanted, candidate)
if best_rank is None or rank < best_rank:
best, best_rank = tab, rank
return best
def _rank(wanted: UrlKey | None, candidate: str | None) -> tuple[int, int]:
"""Lower is a closer match: exact string, canonical, then shallowest path."""
other = url_key(candidate)
if wanted is not None and other is not None and wanted == other:
return (0, 0)
depth = other.path.count("/") if other is not None else 99
return (1, depth)