项目文件夹

文件
Magnus Müller 759f44ff13 remote: paginate list_cloud_profiles; expose profile-use v1.0.4 sync flags (#93)
Two fixes that fell out of testing PR #84 against a real account.

1) list_cloud_profiles was hitting /profiles?pageSize=200 and getting 422
   back — the API caps pageSize at 100. Anyone with more than 10 profiles
   already saw a silent truncation before this (the request returned 10
   items by default) and anyone bumping past 100 would hit the hard error.
   Now paginates with pageSize=100 until totalItems is reached. My account
   is at 18 and climbs every sync_local_profile() call, so this was going
   to bite shortly.

2) sync_local_profile only exposed profile_name + browser. profile-use
   v1.0.4 shipped three more flags that solve real pain:
     --cloud-profile-id <uuid>  → update an existing cloud profile
     --domain <d>               → only these domains (repeatable)
     --exclude-domain <d>       → drop these domains (repeatable)
   Wired up as cloud_profile_id / include_domains / exclude_domains kwargs.
   When cloud_profile_id is passed, profile-use prints "♻️ Using existing
   cloud profile" instead of "Profile created: <uuid>" — special-cased the
   regex path to just return the caller-supplied UUID.

Also drops the "Upstream limitations" section from profile-sync.md (both
limitations are fixed as of v1.0.4) and adds two worked examples:
  - refresh the same cloud profile (cloud_profile_id=)
  - push only Stripe cookies into it (include_domains=["stripe.com"])

Verified end-to-end:
  sync_local_profile("browser-use.com", include_domains=["stripe.com"])
  → "Domain filter: 43 → 1 cookies (include=[stripe.com])"
  → cloud profile cookieDomains == ["m.stripe.com"]
  → second call with cloud_profile_id=uuid printed "Using existing cloud
    profile" and returned the same UUID (idempotent).
2026-04-18 22:38:43 -07:00

281 行
10 KiB
Python

import json
import os
import socket
import time
import urllib.request
from pathlib import Path
def _load_env():
p = Path(__file__).parent / ".env"
if not p.exists():
return
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
_load_env()
NAME = os.environ.get("BU_NAME", "default")
BU_API = "https://api.browser-use.com/api/v3"
def _paths(name):
n = name or NAME
return f"/tmp/bu-{n}.sock", f"/tmp/bu-{n}.pid"
def _log_tail(name):
p = f"/tmp/bu-{name or NAME}.log"
try:
return Path(p).read_text().strip().splitlines()[-1]
except (FileNotFoundError, IndexError):
return None
def daemon_alive(name=None):
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(1)
s.connect(_paths(name)[0])
s.close()
return True
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
return False
def ensure_daemon(wait=60.0, name=None, env=None):
"""Idempotent. `env` is merged into the child process env."""
if daemon_alive(name):
return
import subprocess
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
p = subprocess.Popen(
["uv", "run", "daemon.py"],
cwd=os.path.dirname(os.path.abspath(__file__)),
env=e,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.time() + wait
while time.time() < deadline:
if daemon_alive(name):
return
if p.poll() is not None:
break
time.sleep(0.2)
msg = _log_tail(name)
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check /tmp/bu-{name or NAME}.log")
def restart_daemon(name=None):
"""Best-effort daemon restart for setup/debug flows."""
import signal
sock, pid_path = _paths(name)
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
s.connect(sock)
s.sendall(b'{"meta":"shutdown"}\n')
s.recv(1024)
s.close()
except Exception:
pass
try:
pid = int(open(pid_path).read())
except (FileNotFoundError, ValueError):
pid = None
if pid:
for _ in range(75):
try:
os.kill(pid, 0)
time.sleep(0.2)
except ProcessLookupError:
break
else:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
for f in (sock, pid_path):
try:
os.unlink(f)
except FileNotFoundError:
pass
def _browser_use(path, method, body=None):
key = os.environ.get("BROWSER_USE_API_KEY")
if not key:
raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
req = urllib.request.Request(
f"{BU_API}{path}",
method=method,
data=(json.dumps(body).encode() if body is not None else None),
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
)
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
def _cdp_ws_from_url(cdp_url):
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
def _has_local_gui():
"""True when this machine plausibly has a browser we can open. False on headless servers."""
import platform
system = platform.system()
if system in ("Darwin", "Windows"):
return True
if system == "Linux":
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
return False
def _show_live_url(url):
"""Print liveUrl and auto-open it locally if there's a GUI."""
import sys, webbrowser
if not url: return
print(url)
if not _has_local_gui():
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
return
try:
webbrowser.open(url, new=2)
print("(opened liveUrl in your default browser)", file=sys.stderr)
except Exception as e:
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
def list_cloud_profiles():
"""List cloud profiles under the current API key.
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
is the array of domain strings the cloud profile has cookies for — use
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
Paginates through all pages — the API caps `pageSize` at 100."""
out, page = [], 1
while True:
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
items = listing.get("items") if isinstance(listing, dict) else listing
if not items:
break
for p in items:
detail = _browser_use(f"/profiles/{p['id']}", "GET")
out.append({
"id": detail["id"],
"name": detail.get("name"),
"userId": detail.get("userId"),
"cookieDomains": detail.get("cookieDomains") or [],
"lastUsedAt": detail.get("lastUsedAt"),
})
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
break
page += 1
return out
def _resolve_profile_name(profile_name):
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
if not matches:
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
if len(matches) > 1:
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
return matches[0]["id"]
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
"""Provision a Browser Use cloud browser and start a daemon attached to it.
kwargs forwarded to `POST /browsers` (camelCase):
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
timeout — minutes, 1..240.
customProxy — {host, port, username, password, ignoreCertErrors}.
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
Returns the full browser dict including `liveUrl`. Prints the liveUrl and
auto-opens it locally when a GUI is detected, so the user can watch along."""
if daemon_alive(name):
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
if profileName:
if "profileId" in create_kwargs:
raise RuntimeError("pass profileName OR profileId, not both")
create_kwargs["profileId"] = _resolve_profile_name(profileName)
browser = _browser_use("/browsers", "POST", create_kwargs)
ensure_daemon(
name=name,
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
)
_show_live_url(browser.get("liveUrl"))
return browser
def list_local_profiles():
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
import json, shutil, subprocess
if not shutil.which("profile-use"):
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
include_domains=None, exclude_domains=None):
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
target local Chrome profile to be closed (profile-use needs an exclusive lock on
the Cookies DB).
Args:
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
browser: disambiguate when multiple browsers have profiles of the
same name (e.g. "Google Chrome"). Default: any match.
cloud_profile_id: push cookies into this existing cloud profile instead of
creating a new one. Idempotent — call again to refresh
the same profile. Default: create new.
include_domains: only sync cookies for these domains (and subdomains).
Leading dot is optional. Example: ["google.com", "stripe.com"].
exclude_domains: drop cookies for these domains (and subdomains). Applied
before `include_domains` so exclude wins on overlap."""
import os, re, shutil, subprocess, sys
if not shutil.which("profile-use"):
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
if not os.environ.get("BROWSER_USE_API_KEY"):
raise RuntimeError("BROWSER_USE_API_KEY missing")
cmd = ["profile-use", "sync", "--profile", profile_name]
if browser:
cmd += ["--browser", browser]
if cloud_profile_id:
cmd += ["--cloud-profile-id", cloud_profile_id]
for d in include_domains or []:
cmd += ["--domain", d]
for d in exclude_domains or []:
cmd += ["--exclude-domain", d]
r = subprocess.run(cmd, text=True, capture_output=True)
sys.stdout.write(r.stdout)
sys.stderr.write(r.stderr)
if r.returncode != 0:
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
# instead of "Profile created: <uuid>", so we already know the UUID.
if cloud_profile_id:
return cloud_profile_id
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
if not m:
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
return m.group(1)