重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
1 parent
9a152a1952
commit
777f7fe5d0
240 files changed
+52518
-4409
No files matched your search
@@ -0,0 +1,163 @@
|
||||
"""Stop active Browser Use cloud browsers older than `--older-than` minutes.
|
||||
|
||||
Designed to be the live regression artefact for the cloud.md skill in this
|
||||
folder — running it exercises GET /browsers + PATCH /browsers/{id}/stop on
|
||||
the public API and surfaces every wire-shape gotcha the skill documents.
|
||||
|
||||
Usage:
|
||||
BROWSER_USE_API_KEY=... python cleanup-zombies.py
|
||||
# stop browsers running longer than 30 minutes (default)
|
||||
|
||||
BROWSER_USE_API_KEY=... python cleanup-zombies.py --older-than 5 --dry-run
|
||||
# preview only; no PATCH /stop sent
|
||||
|
||||
BROWSER_USE_API_KEY=... python cleanup-zombies.py --json
|
||||
# machine-readable output (one record per browser inspected)
|
||||
|
||||
Exit codes:
|
||||
0 any zombies stopped (or none needed)
|
||||
1 API error (auth, network, etc.)
|
||||
2 bad CLI args
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = "https://api.browser-use.com/api/v3"
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if not key:
|
||||
sys.exit("BROWSER_USE_API_KEY is not set")
|
||||
return {
|
||||
"X-Browser-Use-API-Key": key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _call(method: str, path: str, body: dict | None = None, timeout: float = 30.0) -> dict:
|
||||
req = urllib.request.Request(
|
||||
f"{API}{path}",
|
||||
method=method,
|
||||
data=(json.dumps(body).encode() if body is not None else None),
|
||||
headers=_headers(),
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read() or b"{}")
|
||||
|
||||
|
||||
def list_active_browsers() -> list[dict]:
|
||||
"""Return only sessions that are still alive (no `finishedAt`)."""
|
||||
out, page = [], 1
|
||||
while True:
|
||||
listing = _call("GET", f"/browsers?pageSize=100&pageNumber={page}")
|
||||
items = listing.get("items") or []
|
||||
if not items:
|
||||
break
|
||||
out.extend(b for b in items if not b.get("finishedAt"))
|
||||
if len(out) + sum(1 for b in items if b.get("finishedAt")) >= listing.get("totalItems", len(items)):
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
|
||||
def _parse_started(b: dict) -> datetime.datetime:
|
||||
"""`startedAt` is ISO 8601 UTC with a trailing `Z`. Python <3.11 needs the swap."""
|
||||
return datetime.datetime.fromisoformat(b["startedAt"].replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _to_float(v: str | None) -> float:
|
||||
"""Cost / proxy fields come back as strings; tolerate `None` and empty."""
|
||||
return float(v) if v else 0.0
|
||||
|
||||
|
||||
def stop_browser(browser_id: str) -> dict:
|
||||
return _call("PATCH", f"/browsers/{browser_id}", {"action": "stop"})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Stop Browser Use cloud browsers older than N minutes.",
|
||||
)
|
||||
parser.add_argument("--older-than", type=int, default=30, metavar="MIN",
|
||||
help="age threshold in minutes (default: 30)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="list zombies but do not call PATCH /stop")
|
||||
parser.add_argument("--json", action="store_true",
|
||||
help="emit one JSON object per inspected browser")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.older_than < 0:
|
||||
parser.error("--older-than must be non-negative")
|
||||
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=args.older_than)
|
||||
|
||||
try:
|
||||
active = list_active_browsers()
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.stderr.write(f"GET /browsers failed: HTTP {e.code} -- {e.read().decode('utf-8', 'replace')[:200]}\n")
|
||||
return 1
|
||||
except urllib.error.URLError as e:
|
||||
sys.stderr.write(f"GET /browsers network error: {e}\n")
|
||||
return 1
|
||||
|
||||
stopped = 0
|
||||
for b in active:
|
||||
started = _parse_started(b)
|
||||
age_min = (datetime.datetime.now(datetime.timezone.utc) - started).total_seconds() / 60
|
||||
is_zombie = started < cutoff
|
||||
record = {
|
||||
"id": b["id"],
|
||||
"started_at": b["startedAt"],
|
||||
"age_minutes": round(age_min, 1),
|
||||
"browser_cost": _to_float(b.get("browserCost")),
|
||||
"proxy_cost": _to_float(b.get("proxyCost")),
|
||||
"proxy_used_mb": _to_float(b.get("proxyUsedMb")),
|
||||
"is_zombie": is_zombie,
|
||||
"action": "skipped",
|
||||
}
|
||||
if is_zombie:
|
||||
if args.dry_run:
|
||||
record["action"] = "would_stop"
|
||||
else:
|
||||
try:
|
||||
final = stop_browser(b["id"])
|
||||
record["action"] = "stopped"
|
||||
record["final_browser_cost"] = _to_float(final.get("browserCost"))
|
||||
record["final_proxy_cost"] = _to_float(final.get("proxyCost"))
|
||||
stopped += 1
|
||||
except urllib.error.HTTPError as e:
|
||||
record["action"] = f"stop_failed: HTTP {e.code}"
|
||||
except urllib.error.URLError as e:
|
||||
record["action"] = f"stop_failed: {e.reason}"
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(record))
|
||||
else:
|
||||
tag = {
|
||||
"skipped": "OK",
|
||||
"would_stop": "DRY",
|
||||
"stopped": "STOP",
|
||||
}.get(record["action"], record["action"])
|
||||
print(
|
||||
f"[{tag}] {record['id']} age={record['age_minutes']:5.1f}min "
|
||||
f"cost=${record['browser_cost']+record['proxy_cost']:.4f}"
|
||||
)
|
||||
|
||||
if not args.json:
|
||||
verb = "would stop" if args.dry_run else "stopped"
|
||||
print(f"summary: {len(active)} active session(s), {verb} {stopped if not args.dry_run else sum(1 for b in active if _parse_started(b) < cutoff)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user