Files
codex-app-server/chv-guest/chv-browser.py
T

1250 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""chv-browser:浏览器子代理(客机侧、单元外独立进程)。
阶段 3(feat-chv-opt-landing):浏览器服务化。
* Playwright Server 独立进程:常驻运行,codex 与各会话经 CDP
连入(--remote-debugging-port,默认 9222;op `cdp` 返回
webSocketDebuggerUrl)。codex 重启不丢浏览器与登录态——
浏览器进程与 codex 进程分属不同 systemd 单元,无共享进程组。
* 持久 profile:launch_persistent_context,user_data_dir 固定
在独立数据盘 /home/chv/workspace/.chv-browser-profile(数据
盘由 chv-data-mount 挂载,/dev/vdc,LABEL=CHVDATA);数据盘
未挂载时回落到 /var/lib/chv-browser/profile(本机盘,不跨
恢复保留——status 会明说当前 profile 在哪,绝不假装在数据盘)。
服务 crash/重启后重新打开同一 profile,cookie/登录态仍在。
* 多会话:默认会话("default")走单一持久上下文(profile 在
数据盘,登录态跨浏览器重启保留,阶段 3 口径不变);具名
会话各建一个独立 BrowserContext(同浏览器、不同存储分区,
cookie/localStorage 互不串),每会话的 storage-state 落
数据盘 .chv-browser-sessions/<会话>/storage-state.json,
open 后与 close 时各存一次;重开同一会话 id 时从文件恢复,
不同会话 id 永远看不到对方的态。登录态跨会话保留的含义
是“同一会话 id 重开能读回自己的态”,不是“新会话能读别
人的态”(那正是隔离要防的)。localStorage 的隔离与
cookie 同源:同一浏览器的不同 context 自带独立存储分区。
* 阶段 5(feat-chv-opt-landing):具名会话独立 context +
storage-state 落盘恢复(见上);会话 id 只允许
[A-Za-z0-9_-](首字符须为字母数字,最长 64),防路径穿越
进 storage-state 目录;close/kill 只动该会话自己的
context 与任务,不动其它会话。
* 双浏览器:默认 chromium-headless-shell(随 base 常驻);完整
Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完
卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base;
未安装时 mount_full 明确报错并回落 shell,不硬凑通过。
完整版装在数据盘 /home/chv/workspace/.chv-browsers(根盘只剩
约 600MB,装根盘会 ENOSPC;full 引擎启动时直接传
executable_path,不走 channel/env)。
* 截图:JPEG quality 70,viewport 宽封顶 1280(上下文创建时
定死 1280x800);diff_only 时与上一帧比对,字节相同即不落盘、
返回 changed=false(整帧相同跳过)。
* 阶段 4(feat-chv-opt-landing):截图三级管线。
一级 a11y+DOM 主通道:snapshot 在 aria_snapshot 字符串基础
上纯字符串解析出角色计数(button/link/textbox/heading 等)
与 interactive 总数、a11y 字符数与 a11y_hash——不跑 JS、不取
原始 DOM、不新增 evaluate/content 通路,禁 op 保持拒绝。
二级差分截图(op `diff_shot`):先截 PNG 全帧,用 Pillow
解码与上一帧逐像素比对——像素判变阈值 DIFF_PIXEL_THRESHOLD
=10(单通道差值 >10 才算变,滤掉抗锯齿 1 位抖动);无变化
返回 changed:false size 0;有变化算出变化区 bbox(外扩
margin 8px、钳制到视口内、宽高下限 32px),再用
page.screenshot(type=jpeg, quality=70, clip=bbox) 只截变
化区。Pillow 缺失时 diff_shot 如实报错并指引回落全帧
screenshot,绝不静默假装裁剪。full_size 是本帧 PNG 经
Pillow 以 JPEG q70 编码得到的全帧字节数(与浏览器直出
JPEG q70 同参数、字节数可能有数百字节差异,仅作对照)。
三级全屏校验(op `verify_shot`):全帧 JPEG q70,专用于
少数校验场景。事件驱动口径:日常只在动作后调 diff_shot
(变化才出图),verify_shot 只在检查点调用。计数器
shots_full/shots_diff/shots_verify/shots_skipped 由
status 返回,verify_share = shots_verify /
(shots_full+shots_diff+shots_verify),分母为 0 时记 0.0。
计数口径:shots_full = screenshot 落盘数;shots_diff =
diff_shot 返回 changed:true 的数;shots_verify =
verify_shot 成功数;shots_skipped = screenshot diff_only
跳过数 + diff_shot changed:false 数。
对齐 Muse P2 的不变口径:子代理只向单元返回页面无障碍快照,
不见原始 DOM,不能在页面上下文跑 JS,不暴露 evaluate/content,
不开 DevTools。快照 API:当前 Playwright(1.64.1)已移除
page.accessibility,改用 page.aria_snapshot()(返回 YAML 式
字符串);旧版 accessibility.snapshot()(返回 dict)只作回落。
操作(Unix socket /run/chv/browser.sock,JSON 行):
open {url, session?} -> 导航并返回 {title, url, a11y, truncated}
snapshot {session?} -> 当前页的 {title, url, a11y, truncated,
a11y_roles, a11y_interactive,
a11y_chars, a11y_hash}
screenshot {session?, diff_only?} -> JPEG 落盘 /run/chv/shots/<uuid>.jpg,
返回 {path, size, format, quality,
width, changed, sha256}
diff_shot {session?} -> PNG 全帧逐像素比对;无变化
{changed:false, size:0};有变化只截变
化区 JPEG,返回 {changed:true, bbox,
size, full_size, path, sha256}
verify_shot {session?} -> 全帧 JPEG q70(只用于检查点),
返回 {path, size, format, quality,
width, sha256}
close {session?} -> 关该会话的页(浏览器进程继续跑)
sessions {} -> 各会话 {session, url, title}
status {} -> {engine, profile, profile_on_data_disk,
cdp_port, sessions, mounted_full,
full_present}
cdp {} -> {cdp_http, websocket_debugger_url, engine}
engine {engine: shell|full} -> 切换引擎(full 缺失/起不来时
回落 shell 并明说 fallback)
mount_full {} -> 挂载完整 Chromium(切 full 引擎)
unmount_full {} -> 卸载完整 Chromium(切回 shell 引擎)
ping {} -> {"ok": true}
URL 只允许 http/https;localhost 与私有地址字面量直接拒绝(主机名
判定,不做 DNS 解析——DNS 重绑定防护是 P3 Sentinel 的事,这里不
冒充已做)。无障碍树截断到 200 节点 / 64KB,超了标 truncated=true,
绝不把整棵树无上限灌进 socket。
playwright 是懒加载:import 本模块不需要 playwright 已装(单测
在无 playwright 环境跑);真正 open 时才加载,缺失即报错。
只用标准库 + playwright(运行时)。运行身份:系统用户 chv-browser,
socket 0600(broker 以 root 连,不受模式限制)。
"""
import glob
import hashlib
import json
import os
import re
import socket
import sys
import time
import urllib.request
import uuid
BROWSER_SOCK = "/run/chv/browser.sock"
SHOTS_DIR = "/run/chv/shots"
PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
# 持久 profile:数据盘上的工作区内(阶段 1 已做 /dev/vdc 独立盘)。
WORKSPACE_MOUNT = "/home/chv/workspace"
PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile"
PROFILE_FALLBACK_DIR = "/var/lib/chv-browser/profile"
# 具名会话的 storage-state 目录:profile 在数据盘上时跟 profile
# 走数据盘(与 profile 同级);回落本机盘时跟回落 profile 走
# /var/lib/chv-browser/sessions(chv-browser 家目录,服务可写)。
SESSIONS_FALLBACK_DIR = "/var/lib/chv-browser/sessions"
# 会话 id 白名单(阶段 5):只允许字母数字/_/-,首字符须为字母
# 数字,最长 64——storage-state 按会话 id 落子目录,非法 id
# 绝不能拼出 ../ 穿越。
SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
# 完整 Chromium 不 bake 进 base、不装根盘:根盘只剩 ~600MB,
# playwright install chromium 会 ENOSPC(2026-11-08 构建 #8 驱动
# 实证:109 秒后写满失败)。按需装到数据盘(9.3G 空闲),用完可删。
FULL_BROWSERS_DATA_DIR = "/home/chv/workspace/.chv-browsers"
FULL_BROWSERS_FALLBACK_DIR = "/var/lib/chv-browser/browsers"
CDP_PORT_DEFAULT = 9222
# 截图规格(方案阶段 4 口径,阶段 3 先落地编码与尺寸两项):
# JPEG quality 70,宽封顶 1280(上下文 viewport 定死 1280x800)。
VIEWPORT_WIDTH = 1280
VIEWPORT_HEIGHT = 800
JPEG_QUALITY = 70
MAX_REQUEST_BYTES = 65536
MAX_A11Y_NODES = 200
MAX_A11Y_CHARS = 65536
NAV_TIMEOUT_MS = 30000
# 阶段 4 差分截图口径(写死在代码里,驱动与报告按此口径实测):
# 像素判变阈值:单通道差值 >10 才算变(滤抗锯齿 1 位抖动);
# bbox 外扩 margin 8px;变化区宽高下限 32px(不足则绕中心扩到
# 32px 再钳制到视口内)。
DIFF_PIXEL_THRESHOLD = 10
DIFF_MARGIN_PX = 8
DIFF_MIN_SIZE_PX = 32
# a11y 角色里算 interactive 的集合(aria_snapshot 字符串解析用;
# searchbox 是 textbox 的搜索变体,同样算可交互)。
INTERACTIVE_ROLES = frozenset([
"button", "link", "textbox", "searchbox", "checkbox", "radio",
"combobox", "listbox", "menuitem", "tab", "switch", "slider",
"spinbutton",
])
# 拒绝的 URL 主机:localhost 与私有地址字面量(主机名判定)。
_PRIVATE_HOST_RE = re.compile(
r"^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|"
r"172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|"
r"\[::1\]|\[fe8[0-9a-f]*:.*\])$",
re.IGNORECASE)
DEFAULT_SESSION = "default"
ENGINES = ("shell", "full")
def validate_url(url):
"""URL 校验:只允许 http/https,且主机不是 localhost/私有地址
字面量。返回 (ok, error)。DNS 重绑定不在本层防(见模块 docstring)。"""
if not isinstance(url, str) or not url:
return False, "url 不能为空"
m = re.match(r"^(https?)://([^/]+)", url, re.IGNORECASE)
if not m:
return False, "只允许 http/https URL:%s" % url[:80]
host = m.group(2).split("@")[-1].split(":")[0].lower()
if _PRIVATE_HOST_RE.match(host):
return False, "拒绝 localhost/私有地址:%s" % host
return True, ""
def resolve_profile_dir(env=None, ismount=None):
"""决定 profile 目录并返回 (path, on_data_disk)。
env 显式指定(CHV_BROWSER_PROFILE)时优先;否则数据盘已挂载
才用数据盘路径,否则回落本机盘并明说不在数据盘上。"""
if env is None:
env = os.environ
explicit = (env.get("CHV_BROWSER_PROFILE") or "").strip()
if explicit:
return explicit, explicit.startswith(WORKSPACE_MOUNT + "/")
check = ismount or os.path.ismount
if check(WORKSPACE_MOUNT):
return PROFILE_DATA_DIR, True
return PROFILE_FALLBACK_DIR, False
def full_chromium_executable(browsers_path=None):
"""完整 Chromium 可执行文件路径(数据盘安装目录下),未装返回 None。
headless-shell 目录不算。布局有两种:经典 chrome-linux/chrome,
与 CFT(Playwright 1.64.1 下的 chrome-linux64.zip)chrome-linux64/
chrome——2026-11-08 构建 #8 驱动实证:装好的是后者,旧检查只认
前者而漏判,mount_full 误报未安装。"""
base = browsers_path or os.environ.get(
"PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH)
for cand in glob.glob(os.path.join(base, "chromium-*")):
if os.path.basename(cand).startswith(
"chromium_headless_shell"):
continue
for sub in ("chrome-linux", "chrome-linux64"):
exe = os.path.join(cand, sub, "chrome")
if os.path.isfile(exe):
return exe
return None
def full_chromium_present(browsers_path=None):
"""完整 Chromium 是否已装:ms-playwright 下有 chromium-*
目录且含 chrome-linux/chrome 或 chrome-linux64/chrome
(headless-shell 目录不算)。"""
return full_chromium_executable(browsers_path) is not None
def resolve_full_browsers_dir(profile_on_data_disk, env=None):
"""完整 Chromium 的安装目录:profile 在数据盘上时跟 profile 走
数据盘(/home/chv/workspace/.chv-browsers),否则回落本机盘。
env 显式指定(CHV_BROWSER_FULL_BROWSERS_DIR)时优先。"""
if env is None:
env = os.environ
explicit = (env.get("CHV_BROWSER_FULL_BROWSERS_DIR") or "").strip()
if explicit:
return explicit
if profile_on_data_disk:
return FULL_BROWSERS_DATA_DIR
return FULL_BROWSERS_FALLBACK_DIR
def resolve_sessions_dir(profile_dir, profile_on_data_disk, env=None):
"""具名会话 storage-state 的根目录(阶段 5)。
env 显式指定(CHV_BROWSER_SESSIONS_DIR)时优先;否则
profile 在数据盘上时取 profile 的同级目录
/home/chv/workspace/.chv-browser-sessions(数据盘,随
快照恢复保留),回落时取 /var/lib/chv-browser/sessions。"""
if env is None:
env = os.environ
explicit = (env.get("CHV_BROWSER_SESSIONS_DIR") or "").strip()
if explicit:
return explicit
if profile_on_data_disk:
return os.path.join(os.path.dirname(profile_dir),
".chv-browser-sessions")
return SESSIONS_FALLBACK_DIR
def validate_session_id(session):
"""会话 id 校验(阶段 5):空/None 视为 default;非法返回
(False, error),合法返回 (True, "")。storage-state 按 id
落子目录,校验是防穿越的唯一关口。"""
if session is None or session == "":
return True, ""
if not isinstance(session, str) or not SESSION_ID_RE.match(session):
return False, ("非法会话 id:%r(只允许字母数字/_/-,"
"首字符须为字母数字,最长 64)" % (session,))
return True, ""
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
max_chars=MAX_A11Y_CHARS):
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
纯函数,可单测。节点计数按 dict 节点数(含 children 展开)。"""
state = {"count": 0, "truncated": False}
def walk(n):
if not isinstance(n, dict):
return n
state["count"] += 1
if state["count"] > max_nodes:
state["truncated"] = True
return None
out = {}
for key, value in n.items():
if key == "children" and isinstance(value, list):
children = []
for child in value:
walked = walk(child)
if walked is None:
state["truncated"] = True
break
children.append(walked)
out[key] = children
else:
out[key] = value
return out
tree = walk(node)
text = json.dumps(tree, ensure_ascii=False)
if len(text) > max_chars:
state["truncated"] = True
# 字符超限时只留顶层角色/名字/URL 信息,不灌整棵树。
tree = {"role": (node or {}).get("role"),
"name": (node or {}).get("name"),
"truncated_chars": True}
return tree, state["truncated"]
def parse_a11y(a11y):
"""解析无障碍快照出角色计数(纯函数,可单测)。
输入是 aria_snapshot 字符串(主路)或旧版 dict 树(回落)。
字符串按行解析:行形如 `- link "More information..." [ref=e3]`,
嵌套行带前导空格;角色取 `- ` 后的第一个 token。dict 树则
递归数每个节点的 role 字段。返回 (roles, interactive, chars,
a11y_hash):roles 是 {角色: 个数},interactive 是
INTERACTIVE_ROLES 内的角色总数,chars 是快照字符数,
a11y_hash 是快照规范文本的 sha256(字符串用原文,dict 用
sort_keys 的 JSON)。不跑 JS、不取原始 DOM。"""
roles = {}
if isinstance(a11y, str):
text = a11y
for line in text.splitlines():
m = re.match(r"^\s*-\s+([A-Za-z][\w-]*)", line)
if m:
role = m.group(1).lower()
roles[role] = roles.get(role, 0) + 1
elif isinstance(a11y, dict):
text = json.dumps(a11y, ensure_ascii=False, sort_keys=True)
def walk(n):
if not isinstance(n, dict):
return
role = n.get("role")
if isinstance(role, str) and role:
key = role.lower()
roles[key] = roles.get(key, 0) + 1
for value in n.values():
if isinstance(value, list):
for child in value:
walk(child)
elif isinstance(value, dict):
walk(value)
walk(a11y)
else:
text = ""
interactive = sum(count for role, count in roles.items()
if role in INTERACTIVE_ROLES)
digest = hashlib.sha256(text.encode()).hexdigest()
return roles, interactive, len(text), digest
def expand_bbox(x0, y0, x1, y1, width, height,
margin=DIFF_MARGIN_PX, min_size=DIFF_MIN_SIZE_PX):
"""变化区 bbox 外扩 + 钳制 + 最小尺寸(纯函数,可单测)。
输入是像素差分的包围盒(左闭右开 [x0,y0,x1,y1))。先外扩
margin,再钳制到 [0,0,width,height] 内;宽/高不足 min_size
时绕中心扩到 min_size 再钳制(图本身比 min_size 还小时取
整幅宽/高)。返回 Playwright clip 形 {"x","y","width",
"height"}(均为 int)。"""
x0 = max(0, x0 - margin)
y0 = max(0, y0 - margin)
x1 = min(width, x1 + margin)
y1 = min(height, y1 + margin)
# 宽/高不足 min_size 时绕中心扩;一侧贴边扩不动时向另一侧
# 补足(只有图本身比 min_size 还窄/矮时才取整幅宽/高)。
for axis in ("x", "y"):
lo, hi, size = (x0, x1, width) if axis == "x" \
else (y0, y1, height)
if hi - lo < min_size:
need = min_size - (hi - lo)
lo = max(0, lo - need // 2)
hi = min(size, hi + (need - need // 2))
if hi - lo < min_size:
if lo == 0:
hi = min(size, lo + min_size)
elif hi == size:
lo = max(0, hi - min_size)
if hi - lo < min_size:
lo, hi = 0, size
if axis == "x":
x0, x1 = lo, hi
else:
y0, y1 = lo, hi
return {"x": int(x0), "y": int(y0),
"width": int(x1 - x0), "height": int(y1 - y0)}
def _load_pillow():
"""懒加载 Pillow(diff_shot 专用);缺失时报明错并指引回落
全帧 screenshot,绝不静默假装裁剪。返回 (Image, ImageChops)。"""
try:
from PIL import Image, ImageChops
except ImportError as e:
raise RuntimeError(
"diff_shot 需要 Pillow(%s):镜像里 pip 未装 pillow,"
"无法逐像素比对与裁剪;回落:用 screenshot 全帧 JPEG "
"q70(op screenshot,不带 diff_only)" % e)
return Image, ImageChops
def compute_diff_bbox(prev_png, cur_png,
margin=DIFF_MARGIN_PX,
min_size=DIFF_MIN_SIZE_PX,
pillow=None):
"""两帧 PNG 的逐像素比对(纯函数,可单测,需 Pillow)。
返回 (changed, bbox):无变化时 (False, None);有变化时
(True, clip dict)。像素判变:逐通道差值 > DIFF_PIXEL_THRESHOLD
才算变(见模块 docstring)。两帧尺寸不同时视为整帧变化,
bbox 即当前帧全幅。pillow 供单测注入 (Image, ImageChops),
正常运行时用 _load_pillow()。"""
Image, ImageChops = pillow or _load_pillow()
import io
prev = Image.open(io.BytesIO(prev_png)).convert("RGB")
cur = Image.open(io.BytesIO(cur_png)).convert("RGB")
width, height = cur.size
if prev.size != cur.size:
return True, {"x": 0, "y": 0, "width": width,
"height": height}
diff = ImageChops.difference(prev, cur)
# 逐通道阈值掩码:任一通道差值 >10 即变(convert("L") 会按
# 亮度加权、漏掉单通道小变化,故三通道分别取阈值再取并)。
masks = []
for channel in diff.split():
masks.append(channel.point(
lambda p: 255 if p > DIFF_PIXEL_THRESHOLD else 0))
mask = masks[0]
for extra in masks[1:]:
mask = ImageChops.lighter(mask, extra)
box = mask.getbbox()
if box is None:
return False, None
return True, expand_bbox(box[0], box[1], box[2], box[3],
width, height, margin, min_size)
def _load_playwright():
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
返回 sync_playwright() 的上下文管理器实例(须调用,
不能只返回 sync_playwright 函数本身——函数没有 __enter__,
2026-11-08 构建 #8 驱动 live 抓到:真实路径从未跑通过)。"""
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
PLAYWRIGHT_BROWSERS_PATH)
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
raise RuntimeError(
"chv-browser: playwright 未装(%s),浏览器子代理不可用"
% e)
return sync_playwright()
def _fetch_cdp_version(port, timeout=5.0):
"""读本地 CDP 的 /json/version,返回 webSocketDebuggerUrl;
读不到返回 None(不抛错,调用方据此判定 CDP 未就绪)。"""
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/json/version" % port,
timeout=timeout) as resp:
data = json.loads(resp.read().decode())
url = data.get("webSocketDebuggerUrl")
return url if isinstance(url, str) and url else None
except Exception:
return None
class BrowserAgent:
"""多会话浏览器子代理:一个持久上下文 + 每会话一页。
playwright_factory 供单测注入假实现;正常运行时用
_load_playwright()。"""
def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR,
profile_dir=None, cdp_port=None, full_browsers_dir=None,
sessions_dir=None):
self._factory = playwright_factory or _load_playwright
self._shots_dir = shots_dir
self._profile_explicit = bool(profile_dir)
if profile_dir:
self._profile_dir = profile_dir
self._profile_on_data_disk = profile_dir.startswith(
WORKSPACE_MOUNT + "/")
else:
self._profile_dir, self._profile_on_data_disk = \
resolve_profile_dir()
self._sessions_dir = (
sessions_dir or resolve_sessions_dir(
self._profile_dir, self._profile_on_data_disk))
self._sessions_explicit = bool(sessions_dir)
self._full_browsers_explicit = bool(full_browsers_dir)
self._full_browsers_dir = (
full_browsers_dir or resolve_full_browsers_dir(
self._profile_on_data_disk))
try:
self._cdp_port = int(cdp_port or os.environ.get(
"CHV_BROWSER_CDP_PORT") or CDP_PORT_DEFAULT)
except ValueError:
self._cdp_port = CDP_PORT_DEFAULT
self._pw = None
self._entered = None
self._context = None
self._pages = {}
# 阶段 5:具名会话的独立 context(session → context)与
# 每页所属 context 种类("shared" 默认持久上下文 /
# "isolated" 独立 context);_storage_restored 记本次
# context 创建时是否从 storage-state 文件恢复过。
self._contexts = {}
self._page_context = {}
self._storage_restored = {}
self._engine = "shell"
self._mounted_full = False
self._last_shot = {}
# 阶段 4:上一帧 PNG(diff_shot 的比对基线)与截图计数器。
self._last_png = {}
self._counters = {"shots_full": 0, "shots_diff": 0,
"shots_verify": 0, "shots_skipped": 0}
# -- 浏览器生命周期 ----------------------------------------------
def _close_context(self):
# 阶段 5:先把各具名会话的 storage-state 落盘,再关它们
# 的独立 context——引擎切换/服务退出时态不丢,下次重开
# 同一会话 id 能恢复;关不掉的只跳过,不挡持久上下文关闭。
for session in list(self._contexts):
self._save_storage_state(session)
ctx = self._contexts.pop(session)
try:
ctx.close()
except Exception:
pass
self._page_context = {}
self._storage_restored = {}
for page in list(self._pages.values()):
try:
page.close()
except Exception:
pass
self._pages = {}
if self._context is not None:
try:
self._context.close()
except Exception:
pass
self._context = None
def _norm_session(self, session):
"""会话 id 归一化 + 校验:返回 (session, error)。
error 非空时调用方直接返回 {"ok": False, "error": error}。"""
session = session or DEFAULT_SESSION
ok, err = validate_session_id(session)
if not ok:
return None, err
return session, ""
def _session_storage_path(self, session):
"""具名会话的 storage-state 文件路径(阶段 5)。"""
return os.path.join(self._sessions_dir, session,
"storage-state.json")
def _save_storage_state(self, session):
"""把具名会话的 context 状态落盘(阶段 5)。
成功返回 True;无独立 context 或写失败返回 False(调用
方只记录,不因此谎报 open/close 失败——存储失败由
storage_saved 字段如实报告)。"""
ctx = self._contexts.get(session)
if ctx is None:
return False
try:
path = self._session_storage_path(session)
os.makedirs(os.path.dirname(path), exist_ok=True)
ctx.storage_state(path=path)
except (OSError, ValueError, TypeError, AttributeError):
return False
except Exception:
return False
return True
def _ensure_isolated_context(self, session):
"""具名会话的独立 context(阶段 5):已建则复用;未建则
从持久上下文的 browser 上 new_context(同浏览器、独立
存储分区),storage-state 文件在位时恢复。返回
(context, restored, error)。"""
ctx = self._contexts.get(session)
if ctx is not None:
return ctx, self._storage_restored.get(session, False), ""
if self._context is None:
return None, False, "浏览器尚未启动"
browser = getattr(self._context, "browser", None)
if callable(browser):
browser = browser()
if browser is None or not hasattr(browser, "new_context"):
return None, False, ("当前浏览器不支持独立 context"
"(无 browser.new_context)")
kwargs = {"viewport": {"width": VIEWPORT_WIDTH,
"height": VIEWPORT_HEIGHT}}
state_path = self._session_storage_path(session)
restored = os.path.isfile(state_path)
if restored:
kwargs["storage_state"] = state_path
try:
ctx = browser.new_context(**kwargs)
except Exception as e:
return None, False, "创建独立 context 失败:%s" % e
self._contexts[session] = ctx
self._storage_restored[session] = restored
return ctx, restored, ""
def _context_for(self, session):
"""会话对应的 context:default 走共享持久上下文,具名走
独立 context。返回 (context, kind, error)。"""
if session == DEFAULT_SESSION:
return self._context, "shared", ""
ctx, restored, err = self._ensure_isolated_context(session)
if err:
return None, "isolated", err
return ctx, "isolated", ""
def _ensure_browser(self, engine=None):
"""确保浏览器上下文已起(指定引擎时切换)。返回 (ok, error)。
切换时先关旧上下文再起新上下文——两个引擎共用同一 profile
目录,绝不同时打开(Chromium profile 锁会互斥)。"""
engine = engine or self._engine
if engine not in ENGINES:
return False, "未知引擎:%r(只允许 shell/full)" % (engine,)
if self._context is not None and engine == self._engine:
return True, ""
self._close_context()
if not self._profile_explicit:
# 迟挂载自愈:首次启动(或旧上下文已关)时按当前挂载状态
# 重解析 profile——进程启动时数据盘若还没挂上,__init__
# 记的是回落路径;等挂盘完成后再 open 时应拿到数据盘
# profile,而不是把回落态锁死到服务重启。已跑起来的
# 上下文不受影响(上面已提前返回)。
self._profile_dir, self._profile_on_data_disk = \
resolve_profile_dir()
if not self._sessions_explicit:
# 阶段 5:sessions 目录跟 profile 走——迟挂载自愈
# 到数据盘时,storage-state 也落数据盘而非回落盘。
self._sessions_dir = resolve_sessions_dir(
self._profile_dir, self._profile_on_data_disk)
if not self._full_browsers_explicit:
self._full_browsers_dir = resolve_full_browsers_dir(
self._profile_on_data_disk)
try:
# profile 目录自己建好并收紧到 0700:不依赖
# chv-data-mount 的时序(它在 cloud-final 才跑,
# 可能晚于浏览器首次启动),登录态目录绝不留 0755。
os.makedirs(self._profile_dir, exist_ok=True)
os.chmod(self._profile_dir, 0o700)
except OSError:
pass # 只读/无权限时由 Playwright 启动报错,status 明说
try:
if self._pw is None:
self._pw = self._factory()
self._entered = self._pw.__enter__()
kwargs = {
"headless": True,
"viewport": {"width": VIEWPORT_WIDTH,
"height": VIEWPORT_HEIGHT},
"args": ["--remote-debugging-port=%d"
% self._cdp_port,
"--remote-debugging-address=0.0.0.0"],
}
if engine == "full":
# 完整浏览器在数据盘安装目录里:直接给可执行文件路径,
# 不用 channel(channel 与 executable_path 互斥,且
# channel 要走 PLAYWRIGHT_BROWSERS_PATH——驱动进程
# 启动时才读 env,运行中改 env 对已起的驱动无效)。
exe = full_chromium_executable(
self._full_browsers_dir)
if exe is None:
self._context = None
return False, ("完整 Chromium 未安装(%s 下未见 "
"chromium-*/chrome-linux/chrome)"
% self._full_browsers_dir)
kwargs["executable_path"] = exe
launch = getattr(self._entered.chromium,
"launch_persistent_context", None)
if launch is None:
# 旧版 playwright/假实现只有 launch:回落到
# 临时上下文(profile 不持久,调用方在 status
# 里能看到 persistent=false)。
self._context = self._entered.chromium.launch(
headless=True)
else:
self._context = launch(self._profile_dir, **kwargs)
except Exception as e:
self._context = None
return False, "启动浏览器(引擎 %s)失败:%s" % (engine, e)
self._engine = engine
return True, ""
def _page(self, session):
return self._pages.get(session or DEFAULT_SESSION)
def _cdp_ws(self):
"""等 CDP 端口就绪并返回 webSocketDebuggerUrl(最多等 5 秒)。"""
for _ in range(10):
url = _fetch_cdp_version(self._cdp_port)
if url:
return url
time.sleep(0.5)
return None
# -- 会话操作 ------------------------------------------------------
def open(self, url, session=None):
ok, err = validate_url(url)
if not ok:
return {"ok": False, "error": err}
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
ok, err = self._ensure_browser()
if not ok:
return {"ok": False, "error": err}
ctx, kind, err = self._context_for(session)
if err:
return {"ok": False, "error": err}
try:
old = self._pages.get(session)
if old is not None:
try:
old.close()
except Exception:
pass
page = ctx.new_page()
self._pages[session] = page
self._page_context[session] = kind
# 注意:open 不清 diff 基线——diff_shot 永远与“上一帧”
# 逐像素比对,不管上一帧是哪一页;导航后调 diff_shot
# 得到的正是新旧两页渲染的真实差异区(跨页局部变化
# 如搜索词 Cat->Dog 就靠这个)。只有 close(会话结束)
# 才清基线,下次 open 后的首次 diff_shot 为全幅首帧。
page.goto(url, timeout=NAV_TIMEOUT_MS)
page.wait_for_load_state("domcontentloaded",
timeout=NAV_TIMEOUT_MS)
except Exception as e:
return {"ok": False,
"error": "打开 %s 失败:%s" % (url[:80], e)}
# 阶段 5:具名会话导航成功后立即把 storage-state 落盘——
# 导航里刚置的 cookie 当场进文件,不等 close;浏览器
# crash 后重开同一会话 id 仍能恢复(Chromium 刷盘竞态
# 绕过去了:storage_state() 读的是 context 的实时态)。
saved = False
if kind == "isolated":
saved = self._save_storage_state(session)
resp = self.snapshot(session)
if resp.get("ok"):
resp["context"] = kind
if kind == "isolated":
resp["storage_saved"] = saved
resp["storage_restored"] = \
self._storage_restored.get(session, False)
return resp
def snapshot(self, session=None):
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
title = page.title()
url = page.url
# 当前 Playwright(1.64.1):page.accessibility 已移除,
# 用 page.aria_snapshot()(字符串);旧版 dict 树只作回落。
if hasattr(page, "aria_snapshot"):
snap = page.aria_snapshot()
if isinstance(snap, dict):
tree, truncated = truncate_a11y(snap)
else:
text = snap or ""
truncated = len(text) > MAX_A11Y_CHARS
tree = text[:MAX_A11Y_CHARS] if truncated else text
elif hasattr(page, "accessibility"):
tree = page.accessibility.snapshot()
tree, truncated = truncate_a11y(tree or {})
else:
return {"ok": False,
"error": "浏览器不支持无障碍快照(无 "
"aria_snapshot/accessibility)"}
except Exception as e:
return {"ok": False, "error": "取快照失败:%s" % e}
# 阶段 4 一级通道:纯字符串解析 enrich(不跑 JS、不取 DOM)。
roles, interactive, chars, digest = parse_a11y(tree)
return {"ok": True, "title": title, "url": url,
"a11y": tree, "truncated": truncated,
"a11y_roles": roles, "a11y_interactive": interactive,
"a11y_chars": chars, "a11y_hash": digest,
"session": session,
"context": self._page_context.get(session, "shared")}
def screenshot(self, session=None, diff_only=False):
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
data = page.screenshot(type="jpeg",
quality=JPEG_QUALITY)
except Exception as e:
return {"ok": False, "error": "截图失败:%s" % e}
if not isinstance(data, (bytes, bytearray)):
return {"ok": False, "error": "截图未返回字节数据"}
data = bytes(data)
digest = hashlib.sha256(data).hexdigest()
if diff_only and self._last_shot.get(session) == digest:
# 整帧与上一帧字节相同:跳过落盘(阶段 4 的变化区
# 裁剪走 diff_shot op,这里只做整帧相同跳过)。
self._counters["shots_skipped"] += 1
return {"ok": True, "changed": False, "size": 0,
"path": None, "format": "jpeg",
"quality": JPEG_QUALITY,
"width": VIEWPORT_WIDTH, "session": session}
try:
os.makedirs(self._shots_dir, exist_ok=True)
path = os.path.join(self._shots_dir, "%s.jpg"
% uuid.uuid4().hex)
with open(path, "wb") as fh:
fh.write(data)
except OSError as e:
return {"ok": False, "error": "截图落盘失败:%s" % e}
self._last_shot[session] = digest
self._counters["shots_full"] += 1
return {"ok": True, "path": path, "size": len(data),
"format": "jpeg", "quality": JPEG_QUALITY,
"width": VIEWPORT_WIDTH, "changed": True,
"sha256": digest, "session": session}
def diff_shot(self, session=None):
"""二级差分截图(阶段 4):PNG 全帧逐像素比对,只截变化区。
无变化:{changed:false, size:0}(计 shots_skipped)。
有变化:clip=bbox 的 JPEG q70 落盘,返回 {changed:true,
bbox, size, full_size, path, sha256}(计 shots_diff)。
首帧(本会话无上一帧)视为整帧变化,bbox 为全视口。
Pillow 缺失时 ok:false 并指引回落 screenshot 全帧,
不计入计数器、不落盘、不假装裁剪。"""
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
png = page.screenshot(type="png")
except Exception as e:
return {"ok": False, "error": "截图失败:%s" % e}
if not isinstance(png, (bytes, bytearray)):
return {"ok": False, "error": "截图未返回字节数据"}
png = bytes(png)
try:
Image, _ImageChops = _load_pillow()
except RuntimeError as e:
return {"ok": False, "error": str(e),
"fallback": "screenshot"}
prev = self._last_png.get(session)
if prev is None:
changed, bbox = True, {"x": 0, "y": 0,
"width": VIEWPORT_WIDTH,
"height": VIEWPORT_HEIGHT}
else:
try:
changed, bbox = compute_diff_bbox(prev, png)
except Exception as e:
return {"ok": False,
"error": "差分比对失败:%s" % e}
# full_size:本帧 PNG 经 Pillow 以 JPEG q70 编码的全帧
# 字节数(与浏览器直出同参数,仅作对照,不直接等同)。
import io
try:
buf = io.BytesIO()
Image.open(io.BytesIO(png)).convert("RGB").save(
buf, "JPEG", quality=JPEG_QUALITY)
full_size = len(buf.getvalue())
except Exception:
full_size = len(png)
self._last_png[session] = png
if not changed:
self._counters["shots_skipped"] += 1
return {"ok": True, "changed": False, "size": 0,
"path": None, "bbox": None,
"full_size": full_size, "format": "jpeg",
"quality": JPEG_QUALITY, "session": session}
try:
clip = page.screenshot(type="jpeg",
quality=JPEG_QUALITY, clip=bbox)
except Exception as e:
return {"ok": False, "error": "变化区截图失败:%s" % e}
if not isinstance(clip, (bytes, bytearray)):
return {"ok": False, "error": "变化区截图未返回字节数据"}
clip = bytes(clip)
try:
os.makedirs(self._shots_dir, exist_ok=True)
path = os.path.join(self._shots_dir, "%s.jpg"
% uuid.uuid4().hex)
with open(path, "wb") as fh:
fh.write(clip)
except OSError as e:
return {"ok": False, "error": "截图落盘失败:%s" % e}
self._counters["shots_diff"] += 1
return {"ok": True, "changed": True, "bbox": bbox,
"path": path, "size": len(clip),
"full_size": full_size, "format": "jpeg",
"quality": JPEG_QUALITY,
"sha256": hashlib.sha256(clip).hexdigest(),
"session": session}
def verify_shot(self, session=None):
"""三级全屏校验(阶段 4):全帧 JPEG q70,只用于检查点。
计 shots_verify。日常动作后用 diff_shot,检查点才调它。"""
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
data = page.screenshot(type="jpeg",
quality=JPEG_QUALITY)
except Exception as e:
return {"ok": False, "error": "截图失败:%s" % e}
if not isinstance(data, (bytes, bytearray)):
return {"ok": False, "error": "截图未返回字节数据"}
data = bytes(data)
try:
os.makedirs(self._shots_dir, exist_ok=True)
path = os.path.join(self._shots_dir, "%s.jpg"
% uuid.uuid4().hex)
with open(path, "wb") as fh:
fh.write(data)
except OSError as e:
return {"ok": False, "error": "截图落盘失败:%s" % e}
self._last_shot[session] = hashlib.sha256(data).hexdigest()
self._counters["shots_verify"] += 1
return {"ok": True, "path": path, "size": len(data),
"format": "jpeg", "quality": JPEG_QUALITY,
"width": VIEWPORT_WIDTH, "changed": True,
"sha256": self._last_shot[session],
"session": session}
def close(self, session=None):
session, err = self._norm_session(session)
if err:
return {"ok": False, "error": err}
page = self._pages.pop(session, None)
if page is not None:
try:
page.close()
except Exception:
pass
kind = self._page_context.pop(session, "shared")
saved = False
if kind == "isolated":
# 阶段 5:关会话先落盘 storage-state,再关独立
# context——态进文件,下次重开同一会话 id 恢复;
# 只动本会话的 context,其它会话的页与 context
# 原样不动。
saved = self._save_storage_state(session)
ctx = self._contexts.pop(session, None)
if ctx is not None:
try:
ctx.close()
except Exception:
pass
self._storage_restored.pop(session, None)
self._last_shot.pop(session, None)
self._last_png.pop(session, None)
resp = {"ok": True, "closed": True, "session": session,
"context": kind}
if kind == "isolated":
resp["storage_saved"] = saved
return resp
def sessions(self):
out = []
for sid, page in self._pages.items():
try:
title = page.title()
except Exception:
title = ""
try:
url = page.url
except Exception:
url = ""
out.append({"session": sid, "url": url, "title": title,
"context": self._page_context.get(
sid, "shared")})
return {"ok": True, "sessions": out}
# -- 服务化:状态 / CDP / 引擎 --------------------------------------
def status(self):
total = (self._counters["shots_full"]
+ self._counters["shots_diff"]
+ self._counters["shots_verify"])
verify_share = (self._counters["shots_verify"] / total
if total else 0.0)
return {"ok": True, "engine": self._engine,
"profile": self._profile_dir,
"profile_on_data_disk": self._profile_on_data_disk,
"persistent": True,
"cdp_port": self._cdp_port,
"sessions": [s["session"] for s in
self.sessions()["sessions"]],
"isolated_sessions": sorted(self._contexts),
"sessions_dir": self._sessions_dir,
"mounted_full": self._mounted_full,
"full_present": full_chromium_present(
self._full_browsers_dir),
"full_browsers_dir": self._full_browsers_dir,
"counters": dict(self._counters),
"verify_share": verify_share}
def cdp(self):
ok, err = self._ensure_browser()
if not ok:
return {"ok": False, "error": err}
return {"ok": True,
"cdp_http": "http://127.0.0.1:%d" % self._cdp_port,
"websocket_debugger_url": self._cdp_ws(),
"engine": self._engine,
"profile": self._profile_dir}
def switch_engine(self, engine):
"""切换引擎。full 未装或起不来时回落 shell 并明说。"""
if engine not in ENGINES:
return {"ok": False,
"error": "未知引擎:%r(只允许 shell/full)"
% (engine,)}
if engine == "full" and not full_chromium_present(
self._full_browsers_dir):
return {"ok": False,
"error": "完整 Chromium 未安装(%s 下未见 "
"chromium-*/chrome-linux/chrome),回落 "
"headless-shell;先跑 PLAYWRIGHT_BROWSERS_"
"PATH=%s python3 -m playwright install "
"chromium 安装(装数据盘,根盘空间不够)"
% (self._full_browsers_dir,
self._full_browsers_dir),
"engine": self._engine, "fallback": True,
"mounted_full": False}
ok, err = self._ensure_browser(engine)
if not ok:
# 起不来:回落 shell(shell 本身起不来才如实报错)。
back_ok, back_err = self._ensure_browser("shell")
resp = {"ok": False, "error": err,
"engine": self._engine, "fallback": True,
"mounted_full": False}
if not back_ok:
resp["error"] = "%s;回落 shell 也失败:%s" % (
err, back_err)
return resp
self._mounted_full = (engine == "full")
return {"ok": True, "engine": engine,
"mounted_full": self._mounted_full,
"profile": self._profile_dir}
def mount_full(self):
if not full_chromium_present(self._full_browsers_dir):
return {"ok": False,
"error": "完整 Chromium 未安装,无法挂载(回落 "
"headless-shell 继续工作);先跑 "
"PLAYWRIGHT_BROWSERS_PATH=%s python3 -m "
"playwright install chromium 安装(装数据"
"盘,根盘空间不够)"
% self._full_browsers_dir,
"engine": self._engine, "fallback": True,
"mounted_full": False}
return self.switch_engine("full")
def unmount_full(self):
resp = self.switch_engine("shell")
if resp.get("ok"):
resp["mounted_full"] = False
return resp
def shutdown(self):
self._close_context()
try:
if self._pw is not None:
self._pw.__exit__(None, None, None)
except Exception:
pass
self._pw = None
self._entered = None
# 本层不暴露的操作:evaluate / content(原始 DOM)/ DevTools。
# 收到即拒绝,不转发、不执行。
FORBIDDEN_OPS = ("evaluate", "content", "devtools", "js")
def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op in FORBIDDEN_OPS:
return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM)" % op}
if op == "ping":
return {"ok": True, "browser": True}
if op == "open":
return agent.open(req.get("url"), req.get("session"))
if op == "snapshot":
return agent.snapshot(req.get("session"))
if op == "screenshot":
return agent.screenshot(req.get("session"),
bool(req.get("diff_only")))
if op == "diff_shot":
return agent.diff_shot(req.get("session"))
if op == "verify_shot":
return agent.verify_shot(req.get("session"))
if op == "close":
return agent.close(req.get("session"))
if op == "sessions":
return agent.sessions()
if op == "status":
return agent.status()
if op == "cdp":
return agent.cdp()
if op == "engine":
return agent.switch_engine(req.get("engine"))
if op == "mount_full":
return agent.mount_full()
if op == "unmount_full":
return agent.unmount_full()
return {"ok": False,
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
"diff_shot/verify_shot/"
"close/sessions/status/cdp/engine/mount_full/"
"unmount_full/ping)" % (op,)}
def _remove_stale_socket(sock_path):
"""删掉上次残留的 socket。删不掉时不静默吞掉:目录权限不对
(/run/chv 须是 0775 root:chv-browser)就会无限 crash-loop,
报错必须说清修法(2026-11-08 构建 #8 live 实证:cell 曾把组
改回 root,服务 62 次重启才被发现)。"""
if not os.path.exists(sock_path):
return
try:
os.remove(sock_path)
except PermissionError:
raise PermissionError(
"删不掉旧 socket %s:/run/chv 须是 0775 root:chv-browser "
"(tmpfiles.d/chv.conf),且跑 cell 不可把它 chown 回 "
"root:root" % sock_path)
def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR,
profile_dir=None, cdp_port=None):
"""监听 Unix socket,逐连接处理。socket 0600(只给属主 +
broker 的 root 连)。建不起来即抛错,不要静默带病跑。"""
_remove_stale_socket(sock_path)
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(16)
os.chmod(sock_path, 0o600)
print("chv-browser: 监听 %s" % sock_path, file=sys.stderr, flush=True)
agent = BrowserAgent(shots_dir=shots_dir, profile_dir=profile_dir,
cdp_port=cdp_port)
try:
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
conn.settimeout(120)
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
try:
req = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
resp = {"ok": False, "error": "请求不是合法 JSON"}
else:
try:
resp = handle_request(agent, req)
except Exception as e:
resp = {"ok": False,
"error": "浏览器子代理内部错误:%s" % e}
conn.sendall((json.dumps(resp, ensure_ascii=False)
+ "\n").encode())
except OSError as e:
print("chv-browser: 连接处理异常:%s" % e, file=sys.stderr,
flush=True)
finally:
try:
conn.close()
except OSError:
pass
finally:
agent.shutdown()
def main(argv):
sock_path = BROWSER_SOCK
shots_dir = SHOTS_DIR
profile_dir = None
cdp_port = None
i = 0
while i < len(argv):
if argv[i] == "--socket" and i + 1 < len(argv):
sock_path = argv[i + 1]
i += 2
elif argv[i] == "--shots-dir" and i + 1 < len(argv):
shots_dir = argv[i + 1]
i += 2
elif argv[i] == "--profile-dir" and i + 1 < len(argv):
profile_dir = argv[i + 1]
i += 2
elif argv[i] == "--cdp-port" and i + 1 < len(argv):
cdp_port = argv[i + 1]
i += 2
else:
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, shots_dir, profile_dir, cdp_port)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))