- snapshot 纯字符串解析 a11y 角色计数/interactive/字符数/hash (不跑 JS、不取原始 DOM,禁 op 保持拒绝);status 带计数器 与 verify_share - diff_shot:PNG 全帧 Pillow 逐像素比对(单通道阈值 10), 无变化 changed:false size 0;有变化 bbox 外扩 8px、钳制视口、 宽高下限 32px,再 clip 截 JPEG q70;Pillow 缺失如实报错并 指引回落 screenshot 全帧;open 不清基线(跨页局部差分靠它) - verify_shot:全帧 JPEG q70,只用于检查点 - build.sh 与 playwright 同批 pip 装 pillow(只加 pillow); buildinfo BASE_CONTENTS 登记 pillow(pip 名覆盖) - 单测 cloud/tests/test_chv_opt_stage4.py:31 项(bbox 纯函数、 a11y 解析、计数器、Pillow 缺失回落、禁 op、lockfile 往返)
1045 lines
46 KiB
Python
1045 lines
46 KiB
Python
#!/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/登录态仍在。
|
||
* 多会话:同一浏览器上下文内每会话一页(session id → page),
|
||
会话 A/B 各自导航、互不干扰;登录态(cookie)同上下文共享,
|
||
是既定取舍(方案:同 VM 内 Bot 共享登录态,隔离边界是 VM 本身)。
|
||
* 双浏览器:默认 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"
|
||
# 完整 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 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):
|
||
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._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 = {}
|
||
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):
|
||
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 _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._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 = session or DEFAULT_SESSION
|
||
ok, err = self._ensure_browser()
|
||
if not ok:
|
||
return {"ok": False, "error": err}
|
||
try:
|
||
old = self._pages.get(session)
|
||
if old is not None:
|
||
try:
|
||
old.close()
|
||
except Exception:
|
||
pass
|
||
page = self._context.new_page()
|
||
self._pages[session] = page
|
||
# 注意: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)}
|
||
return self.snapshot(session)
|
||
|
||
def snapshot(self, session=None):
|
||
session = session or DEFAULT_SESSION
|
||
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}
|
||
|
||
def screenshot(self, session=None, diff_only=False):
|
||
session = session or DEFAULT_SESSION
|
||
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 = session or DEFAULT_SESSION
|
||
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 = session or DEFAULT_SESSION
|
||
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 = session or DEFAULT_SESSION
|
||
page = self._pages.pop(session, None)
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
self._last_shot.pop(session, None)
|
||
self._last_png.pop(session, None)
|
||
return {"ok": True, "closed": True, "session": session}
|
||
|
||
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})
|
||
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"]],
|
||
"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:]))
|