browscreen 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browscreen/__init__.py +5 -0
- browscreen/adapters/__init__.py +1 -0
- browscreen/adapters/base.py +39 -0
- browscreen/adapters/chrome_cdp.py +132 -0
- browscreen/app.py +93 -0
- browscreen/assets/cursor.png +0 -0
- browscreen/capture.py +116 -0
- browscreen/files.py +54 -0
- browscreen/imaging.py +46 -0
- browscreen/main.py +59 -0
- browscreen/models.py +71 -0
- browscreen/preview.html +139 -0
- browscreen/webhooks.py +36 -0
- browscreen-0.2.1.dist-info/METADATA +116 -0
- browscreen-0.2.1.dist-info/RECORD +18 -0
- browscreen-0.2.1.dist-info/WHEEL +4 -0
- browscreen-0.2.1.dist-info/entry_points.txt +2 -0
- browscreen-0.2.1.dist-info/licenses/LICENSE +21 -0
browscreen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""浏览器与协议适配器。"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""公共浏览器契约,不包含具体协议字段。"""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, FiniteFloat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BrowserAdapterError(RuntimeError):
|
|
9
|
+
"""连接、页面或截图操作失败的统一错误。"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrowserScreenshot(BaseModel):
|
|
13
|
+
"""与 CSS 视口对应的原始 PNG。
|
|
14
|
+
|
|
15
|
+
图片有效性由公共合成流程解码验证。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
png_bytes: bytes = Field(min_length=1)
|
|
20
|
+
css_viewport_width: FiniteFloat = Field(gt=0)
|
|
21
|
+
css_viewport_height: FiniteFloat = Field(gt=0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrowserAdapter(Protocol):
|
|
25
|
+
"""外部浏览器的连接、视口采集和自身资源释放契约。"""
|
|
26
|
+
|
|
27
|
+
endpoint_file_name: str
|
|
28
|
+
|
|
29
|
+
async def connect(self, *, endpoint: str, timeout_s: float) -> None:
|
|
30
|
+
"""在总预算内连接并选择已有可截图页面。"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def capture_viewport(self, *, timeout_s: float) -> BrowserScreenshot:
|
|
34
|
+
"""在总预算内返回未合成指针的当前视口。"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def disconnect(self) -> None:
|
|
38
|
+
"""释放自身资源;不关闭外部浏览器或页面。"""
|
|
39
|
+
...
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""通过原生 CDP 连接已有 Chrome 页面。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
10
|
+
from websockets.exceptions import WebSocketException
|
|
11
|
+
|
|
12
|
+
from browscreen.adapters.base import BrowserAdapterError, BrowserScreenshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_endpoint(*, endpoint: str) -> str:
|
|
16
|
+
"""校验 HTTP 调试根入口或 Chrome WebSocket 地址。
|
|
17
|
+
|
|
18
|
+
:returns: 地址类型,http、browser 或 page。
|
|
19
|
+
:raises BrowserAdapterError: 地址不符合首版协议。
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
parsed = urlsplit(url=endpoint)
|
|
23
|
+
if not parsed.hostname or any(character.isspace() for character in endpoint):
|
|
24
|
+
raise ValueError("地址缺少主机或包含空白")
|
|
25
|
+
if parsed.port is not None and not 1 <= parsed.port <= 65535:
|
|
26
|
+
raise ValueError("端口无效")
|
|
27
|
+
if parsed.scheme in {"http", "https"} and parsed.path in {"", "/"} and not parsed.query and not parsed.fragment:
|
|
28
|
+
return "http"
|
|
29
|
+
if parsed.scheme in {"ws", "wss"} and not parsed.fragment:
|
|
30
|
+
for kind in ("browser", "page"):
|
|
31
|
+
prefix = f"/devtools/{kind}/"
|
|
32
|
+
if parsed.path.startswith(prefix) and parsed.path[len(prefix):]:
|
|
33
|
+
return kind
|
|
34
|
+
raise ValueError("需要 HTTP(S) 调试根地址或浏览器/页面 WebSocket 地址")
|
|
35
|
+
except ValueError as error:
|
|
36
|
+
raise BrowserAdapterError(f"无效 Chrome 端点:{error}") from error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChromeCdpAdapter:
|
|
40
|
+
"""封装 Chrome 端点发现、会话与顺序命令响应。
|
|
41
|
+
|
|
42
|
+
:param client: 应用生命周期拥有的共享 HTTP 客户端。
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
endpoint_file_name = ".cdp"
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, client: httpx.AsyncClient) -> None:
|
|
48
|
+
self.client = client
|
|
49
|
+
self.websocket: ClientConnection | None = None
|
|
50
|
+
self.session_id: str | None = None
|
|
51
|
+
self.command_id = 0
|
|
52
|
+
|
|
53
|
+
async def connect(self, *, endpoint: str, timeout_s: float) -> None:
|
|
54
|
+
"""在一个总预算内发现端点、连接并附着首个已有页面。"""
|
|
55
|
+
try:
|
|
56
|
+
async with asyncio.timeout(delay=timeout_s):
|
|
57
|
+
kind = validate_endpoint(endpoint=endpoint)
|
|
58
|
+
if kind == "http":
|
|
59
|
+
response = await self.client.get(url=f"{endpoint.rstrip('/')}/json/version", timeout=timeout_s)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
endpoint = response.json()["webSocketDebuggerUrl"]
|
|
62
|
+
kind = validate_endpoint(endpoint=endpoint)
|
|
63
|
+
if kind == "http":
|
|
64
|
+
raise BrowserAdapterError("发现结果不是 WebSocket 地址")
|
|
65
|
+
self.websocket = await connect(uri=endpoint, open_timeout=timeout_s, close_timeout=1, max_size=None, proxy=None)
|
|
66
|
+
if kind == "browser":
|
|
67
|
+
targets = await self._command(method="Target.getTargets")
|
|
68
|
+
target = next((item for item in targets["targetInfos"] if item.get("type") == "page"), None)
|
|
69
|
+
if target is None:
|
|
70
|
+
raise BrowserAdapterError("浏览器没有已有页面")
|
|
71
|
+
attached = await self._command(method="Target.attachToTarget", params={"targetId": target["targetId"], "flatten": True})
|
|
72
|
+
self.session_id = attached["sessionId"]
|
|
73
|
+
except (httpx.HTTPError, httpx.InvalidURL, WebSocketException, OSError, TimeoutError, ValueError, KeyError, TypeError) as error:
|
|
74
|
+
raise BrowserAdapterError(f"Chrome 连接失败:{error}") from error
|
|
75
|
+
|
|
76
|
+
async def _command(self, *, method: str, params: dict | None = None) -> dict:
|
|
77
|
+
"""顺序发送命令并过滤事件、其他编号与其他会话响应。"""
|
|
78
|
+
if self.websocket is None:
|
|
79
|
+
raise BrowserAdapterError("Chrome 尚未连接")
|
|
80
|
+
self.command_id += 1
|
|
81
|
+
command = {"id": self.command_id, "method": method, "params": params or {}}
|
|
82
|
+
if self.session_id is not None:
|
|
83
|
+
command["sessionId"] = self.session_id
|
|
84
|
+
await self.websocket.send(message=json.dumps(obj=command))
|
|
85
|
+
while True:
|
|
86
|
+
message = json.loads(s=await self.websocket.recv())
|
|
87
|
+
if not isinstance(message, dict):
|
|
88
|
+
raise BrowserAdapterError("CDP 响应不是对象")
|
|
89
|
+
if message.get("method") == "Target.detachedFromTarget" and message.get("params", {}).get("sessionId") == self.session_id:
|
|
90
|
+
raise BrowserAdapterError("Chrome 页面调试会话已断开")
|
|
91
|
+
if message.get("method") == "Inspector.detached" and message.get("sessionId") == self.session_id:
|
|
92
|
+
raise BrowserAdapterError("Chrome 页面已断开")
|
|
93
|
+
if message.get("id") != self.command_id:
|
|
94
|
+
continue
|
|
95
|
+
if message.get("sessionId") != self.session_id and not ("error" in message and "sessionId" not in message):
|
|
96
|
+
continue
|
|
97
|
+
if "error" in message:
|
|
98
|
+
raise BrowserAdapterError(f"CDP {method} 失败:{message['error']}")
|
|
99
|
+
result = message.get("result", {})
|
|
100
|
+
if not isinstance(result, dict):
|
|
101
|
+
raise BrowserAdapterError("CDP 结果不是对象")
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
async def capture_viewport(self, *, timeout_s: float) -> BrowserScreenshot:
|
|
105
|
+
"""在一个总预算内测量 CSS 视口并抓取当前 PNG。"""
|
|
106
|
+
try:
|
|
107
|
+
async with asyncio.timeout(delay=timeout_s):
|
|
108
|
+
metrics = await self._command(method="Runtime.evaluate", params={
|
|
109
|
+
"expression": "({width: window.innerWidth, height: window.innerHeight})",
|
|
110
|
+
"returnByValue": True,
|
|
111
|
+
})
|
|
112
|
+
viewport = metrics["result"]["value"]
|
|
113
|
+
screenshot = await self._command(method="Page.captureScreenshot", params={
|
|
114
|
+
"format": "png", "fromSurface": True, "captureBeyondViewport": False,
|
|
115
|
+
})
|
|
116
|
+
return BrowserScreenshot(
|
|
117
|
+
png_bytes=base64.b64decode(s=screenshot["data"], validate=True),
|
|
118
|
+
css_viewport_width=viewport["width"],
|
|
119
|
+
css_viewport_height=viewport["height"],
|
|
120
|
+
)
|
|
121
|
+
except (WebSocketException, OSError, TimeoutError, ValueError, KeyError, TypeError) as error:
|
|
122
|
+
raise BrowserAdapterError(f"Chrome 截图失败:{error}") from error
|
|
123
|
+
|
|
124
|
+
async def disconnect(self) -> None:
|
|
125
|
+
"""关闭自身 WebSocket,保留浏览器和已有页面。"""
|
|
126
|
+
websocket, self.websocket = self.websocket, None
|
|
127
|
+
self.session_id = None
|
|
128
|
+
if websocket is not None:
|
|
129
|
+
try:
|
|
130
|
+
await websocket.close()
|
|
131
|
+
except (WebSocketException, OSError) as error:
|
|
132
|
+
raise BrowserAdapterError(f"Chrome 连接释放失败:{error}") from error
|
browscreen/app.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""FastAPI 生命周期、只读图片预览和 webhook 注册。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi import FastAPI, Response
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
11
|
+
|
|
12
|
+
from browscreen import __version__
|
|
13
|
+
from browscreen.adapters.base import BrowserAdapter
|
|
14
|
+
from browscreen.adapters.chrome_cdp import ChromeCdpAdapter
|
|
15
|
+
from browscreen.capture import CaptureService
|
|
16
|
+
from browscreen.files import clear_mouse
|
|
17
|
+
from browscreen.models import ErrorResponse, Settings, WebhookRegistration
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
ADAPTER_FACTORIES = {"chrome-cdp": ChromeCdpAdapter}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app(*, settings: Settings, adapter: BrowserAdapter | None = None, client: httpx.AsyncClient | None = None) -> FastAPI:
|
|
24
|
+
"""创建实例;注入的适配器和客户端也由该实例负责释放。
|
|
25
|
+
|
|
26
|
+
:param settings: 经校验的启动配置。
|
|
27
|
+
:param adapter: 测试或集成可注入符合公共契约的适配器。
|
|
28
|
+
:param client: 测试可注入 HTTP 客户端;默认直接连接端点和接收器。
|
|
29
|
+
:returns: 含一个后台采集任务的应用。
|
|
30
|
+
"""
|
|
31
|
+
@asynccontextmanager
|
|
32
|
+
async def lifespan(app: FastAPI):
|
|
33
|
+
http_client = client if client is not None else httpx.AsyncClient(timeout=3, follow_redirects=False, trust_env=False)
|
|
34
|
+
browser = adapter if adapter is not None else ADAPTER_FACTORIES[settings.adapter](client=http_client)
|
|
35
|
+
service = CaptureService(settings=settings, adapter=browser, client=http_client)
|
|
36
|
+
app.state.capture = service
|
|
37
|
+
|
|
38
|
+
async def capture() -> None:
|
|
39
|
+
"""立即报告未预期的任务异常,并停止提供旧图。"""
|
|
40
|
+
try:
|
|
41
|
+
await service.run()
|
|
42
|
+
except Exception:
|
|
43
|
+
service.current_frame = None
|
|
44
|
+
service.state = "failed"
|
|
45
|
+
logger.exception("采集任务异常停止")
|
|
46
|
+
await service.disconnect()
|
|
47
|
+
|
|
48
|
+
task = asyncio.create_task(coro=capture(), name="browscreen-capture")
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
task.cancel()
|
|
53
|
+
try:
|
|
54
|
+
await task
|
|
55
|
+
except asyncio.CancelledError:
|
|
56
|
+
pass
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.exception("采集任务退出时报告错误")
|
|
59
|
+
await service.disconnect()
|
|
60
|
+
try:
|
|
61
|
+
await http_client.aclose()
|
|
62
|
+
except Exception:
|
|
63
|
+
logger.exception("HTTP 客户端关闭失败")
|
|
64
|
+
finally:
|
|
65
|
+
await asyncio.to_thread(clear_mouse, path=settings.work_dir / ".mouse")
|
|
66
|
+
|
|
67
|
+
app = FastAPI(title="browscreen", version=__version__, lifespan=lifespan)
|
|
68
|
+
preview = files(anchor="browscreen").joinpath("preview.html").read_text(encoding="utf-8").replace("__INTERVAL_MS__", str(settings.interval_ms))
|
|
69
|
+
|
|
70
|
+
@app.get("/", response_class=HTMLResponse)
|
|
71
|
+
async def index() -> HTMLResponse:
|
|
72
|
+
"""返回按配置间隔读取同一最新帧的只读页面。"""
|
|
73
|
+
return HTMLResponse(content=preview, headers={"Cache-Control": "no-store"})
|
|
74
|
+
|
|
75
|
+
@app.get("/api/screenshot", response_class=Response, responses={200: {"content": {"image/png": {}}}, 503: {"model": ErrorResponse}})
|
|
76
|
+
async def screenshot() -> Response:
|
|
77
|
+
"""读取一次完整帧引用,返回 PNG 或明确的不可用状态。"""
|
|
78
|
+
service = app.state.capture
|
|
79
|
+
frame = service.current_frame
|
|
80
|
+
if frame is None:
|
|
81
|
+
return JSONResponse(content=service.unavailable_error().model_dump(), status_code=503, headers={"Cache-Control": "no-store"})
|
|
82
|
+
return Response(content=frame.png_bytes, media_type="image/png", headers={"Cache-Control": "no-store", **frame.headers})
|
|
83
|
+
|
|
84
|
+
@app.post("/api/webhooks", status_code=201)
|
|
85
|
+
async def register(registration: WebhookRegistration, response: Response) -> dict[str, str | bool]:
|
|
86
|
+
"""规范化去重,只参与注册后的新帧。"""
|
|
87
|
+
url = str(registration.url)
|
|
88
|
+
urls = app.state.capture.webhooks
|
|
89
|
+
response.status_code = 200 if url in urls else 201
|
|
90
|
+
urls.add(url)
|
|
91
|
+
return {"url": url, "registered": True}
|
|
92
|
+
|
|
93
|
+
return app
|
|
Binary file
|
browscreen/capture.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""公共连接等待、单循环采集和最新帧发布。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from asyncio import sleep
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from time import monotonic
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from browscreen.adapters.base import BrowserAdapter, BrowserAdapterError
|
|
12
|
+
from browscreen.files import read_endpoint, read_mouse
|
|
13
|
+
from browscreen.imaging import compose_screenshot
|
|
14
|
+
from browscreen.models import CurrentFrame, ErrorResponse, Settings
|
|
15
|
+
from browscreen.webhooks import push_frame
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
OPERATION_TIMEOUT_S = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CaptureService:
|
|
22
|
+
"""驱动任意符合契约的浏览器适配器。
|
|
23
|
+
|
|
24
|
+
:param settings: 实例配置。
|
|
25
|
+
:param adapter: 已由应用选定的适配器。
|
|
26
|
+
:param client: 应用拥有的共享 HTTP 客户端。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, settings: Settings, adapter: BrowserAdapter, client: httpx.AsyncClient) -> None:
|
|
30
|
+
self.settings = settings
|
|
31
|
+
self.adapter = adapter
|
|
32
|
+
self.client = client
|
|
33
|
+
self.state = "waiting"
|
|
34
|
+
self.current_frame: CurrentFrame | None = None
|
|
35
|
+
self.frame_id = 0
|
|
36
|
+
self.webhooks: set[str] = set()
|
|
37
|
+
self.failed_webhooks: set[str] = set()
|
|
38
|
+
|
|
39
|
+
def unavailable_error(self) -> ErrorResponse:
|
|
40
|
+
"""生成当前无图片状态的公共响应。"""
|
|
41
|
+
if self.state == "failed":
|
|
42
|
+
return ErrorResponse(code="capture_failed", message="采集任务异常停止,请检查服务日志后重启")
|
|
43
|
+
if self.state == "timed_out":
|
|
44
|
+
return ErrorResponse(code="browser_wait_timeout", message="等待浏览器连接超时,请修正端点后重启服务")
|
|
45
|
+
if self.state == "connected":
|
|
46
|
+
return ErrorResponse(code="screenshot_not_ready", message="浏览器已连接,正在生成首帧")
|
|
47
|
+
return ErrorResponse(code="waiting_for_browser", message="正在等待浏览器端点可用")
|
|
48
|
+
|
|
49
|
+
async def disconnect(self, *, timeout_s: float = 1) -> None:
|
|
50
|
+
"""限时释放自身连接,清理失败记录日志。"""
|
|
51
|
+
try:
|
|
52
|
+
async with asyncio.timeout(delay=timeout_s):
|
|
53
|
+
await self.adapter.disconnect()
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.exception("浏览器适配器连接清理失败")
|
|
56
|
+
|
|
57
|
+
async def _wait_for_browser(self, *, deadline: float) -> bool:
|
|
58
|
+
"""沿用本轮恢复截止时间,每次重试重新读文件。"""
|
|
59
|
+
self.state = "waiting"
|
|
60
|
+
path = self.settings.work_dir / self.adapter.endpoint_file_name
|
|
61
|
+
logger.debug(msg=f"等待浏览器端点:{path},本轮剩余 {max(0, deadline - monotonic()):.3f} 秒")
|
|
62
|
+
while (remaining := deadline - monotonic()) > 0:
|
|
63
|
+
try:
|
|
64
|
+
async with asyncio.timeout(delay=remaining):
|
|
65
|
+
endpoint = await asyncio.to_thread(read_endpoint, path=path)
|
|
66
|
+
remaining = deadline - monotonic()
|
|
67
|
+
if endpoint and remaining > 0:
|
|
68
|
+
budget = min(OPERATION_TIMEOUT_S, remaining)
|
|
69
|
+
async with asyncio.timeout(delay=budget):
|
|
70
|
+
await self.adapter.connect(endpoint=endpoint, timeout_s=budget)
|
|
71
|
+
self.state = "connected"
|
|
72
|
+
logger.debug("浏览器已连接")
|
|
73
|
+
return True
|
|
74
|
+
except (BrowserAdapterError, TimeoutError) as error:
|
|
75
|
+
logger.debug(msg=f"浏览器暂不可用:{error!r}")
|
|
76
|
+
await self.disconnect(timeout_s=max(0, min(1, deadline - monotonic())))
|
|
77
|
+
await sleep(delay=min(self.settings.interval_ms / 1000, max(0, deadline - monotonic())))
|
|
78
|
+
self.state = "timed_out"
|
|
79
|
+
logger.warning("等待浏览器连接超时,停止采集与端点轮询")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def run(self) -> None:
|
|
83
|
+
"""顺序采集、发布和发送;失效时清空缓存并重新等待。"""
|
|
84
|
+
deadline = monotonic() + self.settings.connect_wait_timeout_s
|
|
85
|
+
logger.info(msg=f"开始等待浏览器画面,等待上限 {self.settings.connect_wait_timeout_s:g} 秒")
|
|
86
|
+
while await self._wait_for_browser(deadline=deadline):
|
|
87
|
+
try:
|
|
88
|
+
while True:
|
|
89
|
+
started = monotonic()
|
|
90
|
+
timestamp = datetime.now(tz=UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
91
|
+
# 首个有效帧产出前,连接、文件读取和图片合成共用恢复预算。
|
|
92
|
+
remaining = None if deadline is None else max(0, deadline - monotonic())
|
|
93
|
+
async with asyncio.timeout(delay=remaining):
|
|
94
|
+
mouse = await asyncio.to_thread(read_mouse, path=self.settings.work_dir / ".mouse")
|
|
95
|
+
budget = OPERATION_TIMEOUT_S if deadline is None else max(0, min(OPERATION_TIMEOUT_S, deadline - monotonic()))
|
|
96
|
+
async with asyncio.timeout(delay=budget):
|
|
97
|
+
screenshot = await self.adapter.capture_viewport(timeout_s=budget)
|
|
98
|
+
png = await asyncio.to_thread(compose_screenshot, screenshot=screenshot, mouse=mouse)
|
|
99
|
+
if deadline is not None:
|
|
100
|
+
logger.info("浏览器画面已就绪" if self.frame_id == 0 else "浏览器画面已恢复")
|
|
101
|
+
deadline = None
|
|
102
|
+
self.frame_id += 1
|
|
103
|
+
frame = CurrentFrame(frame_id=self.frame_id, capture_started_at=timestamp, png_bytes=png)
|
|
104
|
+
urls = tuple(self.webhooks)
|
|
105
|
+
self.current_frame = frame
|
|
106
|
+
await push_frame(client=self.client, urls=urls, frame=frame, failed_urls=self.failed_webhooks)
|
|
107
|
+
await sleep(delay=max(0, self.settings.interval_ms / 1000 - (monotonic() - started)))
|
|
108
|
+
except (BrowserAdapterError, TimeoutError) as error:
|
|
109
|
+
self.current_frame = None
|
|
110
|
+
self.state = "waiting"
|
|
111
|
+
if deadline is None:
|
|
112
|
+
logger.warning(msg=f"浏览器采集失效,开始恢复:{error!r}")
|
|
113
|
+
deadline = monotonic() + self.settings.connect_wait_timeout_s
|
|
114
|
+
logger.debug(msg=f"浏览器采集失败:{error!r}")
|
|
115
|
+
await self.disconnect(timeout_s=max(0, min(1, deadline - monotonic())))
|
|
116
|
+
await sleep(delay=min(self.settings.interval_ms / 1000, max(0, deadline - monotonic())))
|
browscreen/files.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""工作目录文件的读取与退出清理。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from browscreen.models import MousePosition
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_endpoint(*, path: Path) -> str | None:
|
|
14
|
+
"""读取固定端点文件中的单行文本。
|
|
15
|
+
|
|
16
|
+
:returns: 去除 BOM 与空白的文本;不可读、空值或多行时返回 None。
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
content = path.read_text(encoding="utf-8-sig").strip()
|
|
20
|
+
except (OSError, UnicodeError):
|
|
21
|
+
return None
|
|
22
|
+
if not content or len(content.splitlines()) != 1:
|
|
23
|
+
return None
|
|
24
|
+
return content
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_mouse(*, path: Path) -> MousePosition | None:
|
|
28
|
+
"""读取本轮坐标,不保留上一轮的有效值。
|
|
29
|
+
|
|
30
|
+
:returns: 有限数值坐标;不可读或格式无效时返回 None。
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
parts = path.read_text(encoding="utf-8-sig").strip().split(",")
|
|
34
|
+
if len(parts) != 2:
|
|
35
|
+
return None
|
|
36
|
+
return MousePosition(x=parts[0].strip(), y=parts[1].strip())
|
|
37
|
+
except (OSError, UnicodeError, ValidationError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_mouse(*, path: Path) -> None:
|
|
42
|
+
"""退出时截断已有坐标文件,缺失时不创建。
|
|
43
|
+
|
|
44
|
+
清理失败记录路径与原因,避免影响其他退出清理。
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
with path.open(mode="r+b") as stream:
|
|
48
|
+
stream.truncate(0)
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
return
|
|
51
|
+
except OSError:
|
|
52
|
+
logger.exception(msg=f"无法清空鼠标坐标文件:{path}")
|
|
53
|
+
else:
|
|
54
|
+
logger.debug(msg=f"已清空鼠标坐标文件:{path}")
|
browscreen/imaging.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""视口 PNG 校验与透明鼠标指针合成。"""
|
|
2
|
+
|
|
3
|
+
from functools import cache
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
|
|
7
|
+
from PIL import Image, UnidentifiedImageError
|
|
8
|
+
|
|
9
|
+
from browscreen.adapters.base import BrowserAdapterError, BrowserScreenshot
|
|
10
|
+
from browscreen.models import MousePosition
|
|
11
|
+
|
|
12
|
+
# PNG 像素单位;箭头尖端位于资源图的 (1, 1)。
|
|
13
|
+
CURSOR_HOTSPOT = (1, 1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@cache
|
|
17
|
+
def _cursor_image() -> Image.Image:
|
|
18
|
+
with files(anchor="browscreen").joinpath("assets/cursor.png").open(mode="rb") as stream:
|
|
19
|
+
with Image.open(fp=stream) as image:
|
|
20
|
+
return image.convert(mode="RGBA")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compose_screenshot(*, screenshot: BrowserScreenshot, mouse: MousePosition | None) -> bytes:
|
|
24
|
+
"""验证原始 PNG,按实际 PNG/CSS 比例叠加本轮指针。
|
|
25
|
+
|
|
26
|
+
:returns: 当前视口 PNG;无有效视口内坐标时保留原始字节。
|
|
27
|
+
:raises BrowserAdapterError: 原始截图不是可解码的 PNG。
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
with Image.open(fp=BytesIO(initial_bytes=screenshot.png_bytes)) as source:
|
|
31
|
+
if source.format != "PNG":
|
|
32
|
+
raise BrowserAdapterError("适配器截图必须为 PNG")
|
|
33
|
+
source.load()
|
|
34
|
+
if mouse is None or not (0 <= mouse.x < screenshot.css_viewport_width and 0 <= mouse.y < screenshot.css_viewport_height):
|
|
35
|
+
return screenshot.png_bytes
|
|
36
|
+
image = source.convert(mode="RGBA")
|
|
37
|
+
point = (
|
|
38
|
+
round(mouse.x * image.width / screenshot.css_viewport_width) - CURSOR_HOTSPOT[0],
|
|
39
|
+
round(mouse.y * image.height / screenshot.css_viewport_height) - CURSOR_HOTSPOT[1],
|
|
40
|
+
)
|
|
41
|
+
image.alpha_composite(im=_cursor_image(), dest=point)
|
|
42
|
+
output = BytesIO()
|
|
43
|
+
image.save(fp=output, format="PNG")
|
|
44
|
+
return output.getvalue()
|
|
45
|
+
except (UnidentifiedImageError, OSError, ValueError) as error:
|
|
46
|
+
raise BrowserAdapterError(f"PNG 解码或合成失败:{error}") from error
|
browscreen/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""命令行参数与单进程服务入口。"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from browscreen import __version__
|
|
10
|
+
from browscreen.app import create_app
|
|
11
|
+
from browscreen.models import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_settings(*, argv: list[str] | None = None) -> Settings:
|
|
15
|
+
"""解析并校验命令行配置。
|
|
16
|
+
|
|
17
|
+
:param argv: 参数列表;None 表示读取进程参数。
|
|
18
|
+
:returns: 已校验的实例配置。
|
|
19
|
+
"""
|
|
20
|
+
parser = argparse.ArgumentParser(description="browscreen 浏览器画面与鼠标指针预览")
|
|
21
|
+
parser.add_argument("command", nargs="?", choices=["version"], help="查询版本;省略时启动服务")
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"browscreen {__version__}", help="显示版本并退出")
|
|
23
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="开启 DEBUG 日志和 HTTP 访问日志")
|
|
24
|
+
parser.add_argument("--work-dir", help="启动服务必填:已有的文件交换工作目录")
|
|
25
|
+
parser.add_argument("--adapter", default="chrome-cdp", help="浏览器适配器(chrome-cdp)")
|
|
26
|
+
parser.add_argument("--interval-ms", type=int, default=300, help="截图与端点重试间隔(毫秒)")
|
|
27
|
+
parser.add_argument("--connect-wait-timeout-s", type=float, default=60, help="每轮等待首个有效帧的上限(秒)")
|
|
28
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP 监听地址(默认 127.0.0.1)")
|
|
29
|
+
parser.add_argument("--port", type=int, default=8000, help="HTTP 端口(默认 8000)")
|
|
30
|
+
arguments = parser.parse_args(args=argv)
|
|
31
|
+
if arguments.command == "version":
|
|
32
|
+
print(f"browscreen {__version__}")
|
|
33
|
+
parser.exit()
|
|
34
|
+
if arguments.work_dir is None:
|
|
35
|
+
parser.error(message="启动服务必须提供 --work-dir")
|
|
36
|
+
del arguments.command
|
|
37
|
+
try:
|
|
38
|
+
return Settings(**vars(arguments))
|
|
39
|
+
except ValidationError as error:
|
|
40
|
+
parser.error(message=str(error))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_logging(*, verbose: bool) -> None:
|
|
44
|
+
"""配置服务日志,默认省略第三方库的逐请求日志。
|
|
45
|
+
|
|
46
|
+
:param verbose: 是否开启 DEBUG 诊断日志。
|
|
47
|
+
"""
|
|
48
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
49
|
+
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
50
|
+
for name in ("httpx", "httpcore", "websockets", "PIL"):
|
|
51
|
+
logging.getLogger(name=name).setLevel(level=level if verbose else logging.WARNING)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None:
|
|
55
|
+
"""处理命令,或启动一个 worker 和一个采集循环。"""
|
|
56
|
+
settings = parse_settings()
|
|
57
|
+
configure_logging(verbose=settings.verbose)
|
|
58
|
+
uvicorn.run(app=create_app(settings=settings), host=settings.host, port=settings.port, workers=1,
|
|
59
|
+
log_level="debug" if settings.verbose else "info", access_log=settings.verbose)
|
browscreen/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""启动配置、文件数据与 HTTP 数据契约。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, FiniteFloat, HttpUrl, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseModel):
|
|
11
|
+
"""校验实例的工作目录与启动参数。"""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(frozen=True)
|
|
14
|
+
|
|
15
|
+
work_dir: Path
|
|
16
|
+
adapter: Literal["chrome-cdp"] = "chrome-cdp"
|
|
17
|
+
interval_ms: int = Field(default=300, gt=0)
|
|
18
|
+
connect_wait_timeout_s: float = Field(default=60, gt=0, allow_inf_nan=False)
|
|
19
|
+
host: str = Field(default="127.0.0.1", min_length=1)
|
|
20
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
21
|
+
verbose: bool = False
|
|
22
|
+
|
|
23
|
+
@field_validator("work_dir", mode="before")
|
|
24
|
+
@classmethod
|
|
25
|
+
def validate_work_dir(cls, value: str | Path) -> Path:
|
|
26
|
+
"""将已有工作目录转换为绝对路径。
|
|
27
|
+
|
|
28
|
+
:raises ValueError: 路径不存在或不是目录。
|
|
29
|
+
"""
|
|
30
|
+
path = Path(value).expanduser().resolve()
|
|
31
|
+
if not path.is_dir():
|
|
32
|
+
raise ValueError(f"工作目录不存在或不是目录:{path}")
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MousePosition(BaseModel):
|
|
37
|
+
"""以当前 CSS 视口左上角为原点的有限坐标。"""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True)
|
|
40
|
+
x: FiniteFloat
|
|
41
|
+
y: FiniteFloat
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebhookRegistration(BaseModel):
|
|
45
|
+
"""注册接收后续 PNG 新帧的 HTTP(S) 地址。"""
|
|
46
|
+
|
|
47
|
+
url: HttpUrl
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ErrorResponse(BaseModel):
|
|
51
|
+
"""截图不可用时的公共错误响应。"""
|
|
52
|
+
|
|
53
|
+
code: Literal["waiting_for_browser", "screenshot_not_ready", "browser_wait_timeout", "capture_failed"]
|
|
54
|
+
message: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class CurrentFrame:
|
|
59
|
+
"""一次性发布的完整合成帧,避免图片与元数据交叉。"""
|
|
60
|
+
|
|
61
|
+
frame_id: int
|
|
62
|
+
capture_started_at: str
|
|
63
|
+
png_bytes: bytes
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def headers(self) -> dict[str, str]:
|
|
67
|
+
"""返回图片接口和 webhook 共用的帧元数据。"""
|
|
68
|
+
return {
|
|
69
|
+
"X-Frame-Id": str(self.frame_id),
|
|
70
|
+
"X-Capture-Started-At": self.capture_started_at,
|
|
71
|
+
}
|
browscreen/preview.html
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>browscreen · 浏览器画面预览</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin: 0; background: #f3f4f6; color: #202632; font-family: system-ui, sans-serif; }
|
|
10
|
+
header { max-width: 1320px; margin: auto; padding: 24px 20px 16px; }
|
|
11
|
+
h1 { margin: 0 0 6px; font-size: 24px; letter-spacing: -.5px; }
|
|
12
|
+
p { margin: 4px 0; font-size: 14px; color: #5c6575; }
|
|
13
|
+
#status { color: #303b50; }
|
|
14
|
+
main { max-width: 1320px; margin: auto; padding: 0 20px 24px; }
|
|
15
|
+
img { display: block; width: 100%; height: auto; background: white; border-radius: 6px; box-shadow: 0 2px 16px #16233a14; }
|
|
16
|
+
[hidden] { display: none !important; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<header>
|
|
21
|
+
<h1>browscreen</h1>
|
|
22
|
+
<p>浏览器画面预览 · 只读</p>
|
|
23
|
+
<p id="status" role="status" aria-live="polite">正在等待浏览器端点可用</p>
|
|
24
|
+
<p id="metadata"></p>
|
|
25
|
+
</header>
|
|
26
|
+
<main><img id="preview" alt="浏览器当前视口画面" hidden></main>
|
|
27
|
+
<script>
|
|
28
|
+
const intervalMs = __INTERVAL_MS__;
|
|
29
|
+
const preview = document.getElementById('preview');
|
|
30
|
+
const status = document.getElementById('status');
|
|
31
|
+
const metadata = document.getElementById('metadata');
|
|
32
|
+
const messages = {
|
|
33
|
+
waiting_for_browser: '正在等待浏览器端点可用',
|
|
34
|
+
screenshot_not_ready: '浏览器已连接,正在生成首帧',
|
|
35
|
+
browser_wait_timeout: '等待浏览器连接超时,请修正端点后重启服务',
|
|
36
|
+
capture_failed: '采集任务异常停止,请检查服务日志后重启'
|
|
37
|
+
};
|
|
38
|
+
const requestTimeoutMs = 5000;
|
|
39
|
+
let currentUrl = null;
|
|
40
|
+
let currentFrameKey = null;
|
|
41
|
+
let activeRequest = null;
|
|
42
|
+
let timer = null;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
|
|
45
|
+
function clearPreview() {
|
|
46
|
+
preview.hidden = true;
|
|
47
|
+
preview.removeAttribute('src');
|
|
48
|
+
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
|
49
|
+
currentUrl = null;
|
|
50
|
+
currentFrameKey = null;
|
|
51
|
+
metadata.textContent = '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadImage(url, signal) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const image = new Image();
|
|
57
|
+
function finish(error) {
|
|
58
|
+
image.onload = null;
|
|
59
|
+
image.onerror = null;
|
|
60
|
+
signal.removeEventListener('abort', abort);
|
|
61
|
+
if (error) {
|
|
62
|
+
image.removeAttribute('src');
|
|
63
|
+
reject(error);
|
|
64
|
+
} else {
|
|
65
|
+
resolve();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function abort() { finish(signal.reason); }
|
|
69
|
+
image.onload = () => finish();
|
|
70
|
+
image.onerror = () => finish(new Error('图片加载失败,将自动重试'));
|
|
71
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
72
|
+
if (signal.aborted) abort();
|
|
73
|
+
else image.src = url;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function poll() {
|
|
78
|
+
if (stopped || activeRequest) return;
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
activeRequest = controller;
|
|
81
|
+
const timeout = setTimeout(() => {
|
|
82
|
+
controller.abort(new Error('预览请求超时,将自动重试'));
|
|
83
|
+
}, requestTimeoutMs);
|
|
84
|
+
let nextUrl = null;
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch('/api/screenshot', { cache: 'no-store', signal: controller.signal });
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = await response.json();
|
|
89
|
+
throw new Error(messages[error.code] || '预览暂时不可用,将自动重试');
|
|
90
|
+
}
|
|
91
|
+
const frameId = response.headers.get('X-Frame-Id');
|
|
92
|
+
const startedAt = response.headers.get('X-Capture-Started-At');
|
|
93
|
+
const frameKey = frameId && startedAt ? `${frameId}/${startedAt}` : null;
|
|
94
|
+
const blob = await response.blob();
|
|
95
|
+
controller.signal.throwIfAborted();
|
|
96
|
+
// 帧号与时间共同判断,服务重启后仍接受较小或重复的帧号。
|
|
97
|
+
if (frameKey && frameKey === currentFrameKey && currentUrl) return;
|
|
98
|
+
nextUrl = URL.createObjectURL(blob);
|
|
99
|
+
await loadImage(nextUrl, controller.signal);
|
|
100
|
+
controller.signal.throwIfAborted();
|
|
101
|
+
preview.src = nextUrl;
|
|
102
|
+
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
|
103
|
+
currentUrl = nextUrl;
|
|
104
|
+
currentFrameKey = frameKey;
|
|
105
|
+
nextUrl = null;
|
|
106
|
+
preview.hidden = false;
|
|
107
|
+
status.textContent = '画面持续更新';
|
|
108
|
+
metadata.textContent = `第 ${frameId} 帧 · 采集开始 ${startedAt}`;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// 离开页面的取消不覆盖恢复后的状态;网络超时仍显示明确提示。
|
|
111
|
+
const failure = controller.signal.aborted ? controller.signal.reason : error;
|
|
112
|
+
if (!stopped && failure.name !== 'AbortError') {
|
|
113
|
+
clearPreview();
|
|
114
|
+
status.textContent = failure.message;
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
if (nextUrl) URL.revokeObjectURL(nextUrl);
|
|
119
|
+
activeRequest = null;
|
|
120
|
+
if (!stopped) timer = setTimeout(poll, intervalMs);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
window.addEventListener('pagehide', () => {
|
|
124
|
+
stopped = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
activeRequest?.abort();
|
|
127
|
+
clearPreview();
|
|
128
|
+
});
|
|
129
|
+
window.addEventListener('pageshow', event => {
|
|
130
|
+
if (!event.persisted || !stopped) return;
|
|
131
|
+
stopped = false;
|
|
132
|
+
status.textContent = '正在恢复预览';
|
|
133
|
+
// 在途请求先完成取消清理,再由其 finally 调度,避免并行轮询。
|
|
134
|
+
if (!activeRequest) poll();
|
|
135
|
+
});
|
|
136
|
+
poll();
|
|
137
|
+
</script>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
browscreen/webhooks.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""逐帧并发发送,无图片队列和历史回放。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from browscreen.models import CurrentFrame
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
WEBHOOK_TIMEOUT_S = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def push_frame(*, client: httpx.AsyncClient, urls: tuple[str, ...], frame: CurrentFrame, failed_urls: set[str]) -> None:
|
|
15
|
+
"""向本帧地址快照各尝试发送一次,等待全部尝试结束。
|
|
16
|
+
|
|
17
|
+
:param frame: 与图片接口共用的完整 PNG 帧。
|
|
18
|
+
:param failed_urls: 跨帧保留的失败地址,仅在首次失败和恢复时输出常规日志。
|
|
19
|
+
"""
|
|
20
|
+
async def send(*, url: str) -> None:
|
|
21
|
+
try:
|
|
22
|
+
async with asyncio.timeout(delay=WEBHOOK_TIMEOUT_S):
|
|
23
|
+
response = await client.post(url=url, content=frame.png_bytes, headers={
|
|
24
|
+
"Content-Type": "image/png", **frame.headers,
|
|
25
|
+
}, timeout=WEBHOOK_TIMEOUT_S, follow_redirects=False)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
except (httpx.HTTPError, TimeoutError) as error:
|
|
28
|
+
log = logger.debug if url in failed_urls else logger.warning
|
|
29
|
+
log(msg=f"webhook 发送失败,帧 {frame.frame_id},地址 {url}:{error!r}")
|
|
30
|
+
failed_urls.add(url)
|
|
31
|
+
else:
|
|
32
|
+
if url in failed_urls:
|
|
33
|
+
failed_urls.remove(url)
|
|
34
|
+
logger.info(msg=f"webhook 发送已恢复,地址 {url}")
|
|
35
|
+
|
|
36
|
+
await asyncio.gather(*(send(url=url) for url in urls))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: browscreen
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: 浏览器视口截图与鼠标指针预览服务
|
|
5
|
+
Project-URL: Homepage, https://github.com/Pegasus-Yang/Browscreen
|
|
6
|
+
Project-URL: Documentation, https://github.com/Pegasus-Yang/Browscreen/blob/main/doc/README.md
|
|
7
|
+
Project-URL: Repository, https://github.com/Pegasus-Yang/Browscreen
|
|
8
|
+
Project-URL: Issues, https://github.com/Pegasus-Yang/Browscreen/issues
|
|
9
|
+
Author-email: Pegasus-Yang <panesas2@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Requires-Dist: fastapi<1,>=0.118
|
|
14
|
+
Requires-Dist: httpx<1,>=0.28
|
|
15
|
+
Requires-Dist: pillow<13,>=12
|
|
16
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
17
|
+
Requires-Dist: uvicorn<1,>=0.37
|
|
18
|
+
Requires-Dist: websockets<18,>=15
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# browscreen
|
|
22
|
+
|
|
23
|
+
[](pyproject.toml)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
|
|
26
|
+
简体中文 | [English](README.en.md)
|
|
27
|
+
|
|
28
|
+
浏览器画面与鼠标指针预览服务。browscreen(browser+screen)通过 Chrome DevTools Protocol(CDP)连接已有 Chrome,将当前视口截图和外部提供的指针坐标合成 PNG,用于网页预览、图片接口和 webhook 推送。
|
|
29
|
+
|
|
30
|
+
## 功能
|
|
31
|
+
|
|
32
|
+
- 默认每 300 毫秒采集当前视口,网页、图片接口和 webhook 共用最新帧。
|
|
33
|
+
- 读取工作目录的 `.cdp` 连接浏览器,读取 `.mouse` 在 CSS 视口坐标上合成指针。
|
|
34
|
+
- 浏览器失效后清空旧图,在限定时间内重读端点并恢复采集。
|
|
35
|
+
- 正常退出时清空已有 `.mouse`,保留 `.cdp` 和外部浏览器。
|
|
36
|
+
|
|
37
|
+
服务采用 Python 3.14、FastAPI 和 Pydantic v2,一个进程运行一个采集循环。浏览器启动、页面导航和环境隔离由调用方管理;预览页只读,不转发鼠标或键盘操作。
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
准备 `uv` 和 Python 3.14,从 GitHub 获取源码后安装:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/Pegasus-Yang/Browscreen.git
|
|
45
|
+
cd Browscreen
|
|
46
|
+
uv sync --locked --no-dev \
|
|
47
|
+
-i http://mirrors.aliyun.com/pypi/simple/ \
|
|
48
|
+
--trusted-host mirrors.aliyun.com
|
|
49
|
+
.venv/bin/browscreen version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
需要安装 Python 时先执行 `uv python install 3.14`。当前安装方式为源码或自行构建的 wheel;详见[安装与运行](doc/deployment/安装与运行.md)。
|
|
53
|
+
|
|
54
|
+
## 快速开始
|
|
55
|
+
|
|
56
|
+
准备一个已有工作目录,以及启用了远程调试、包含打开页面的 Chrome。将实际调试地址写入 `.cdp`:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
work_dir="/absolute/path/to/workspace"
|
|
60
|
+
printf '%s\n' 'http://127.0.0.1:9222' > "$work_dir/.cdp"
|
|
61
|
+
uv run --no-sync browscreen --work-dir "$work_dir"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
打开 `http://127.0.0.1:8000/` 查看画面。另一个终端向同一目录的 `.mouse` 写入 `320,180`,即可在下一个新帧显示指针。工作目录和 Chrome 由外部系统准备,示例路径和端口需按实际环境替换。
|
|
65
|
+
|
|
66
|
+
`.cdp` 尚未可用时 HTTP 保持可访问,默认等待 60 秒,等待预算持续到首个有效 PNG 生成。超时后修正端点并重启服务。
|
|
67
|
+
|
|
68
|
+
## 命令行
|
|
69
|
+
|
|
70
|
+
安装后提供一个 `browscreen` 命令。在源码安装环境中,可通过 `.venv/bin/browscreen` 或 `uv run --no-sync browscreen` 调用。
|
|
71
|
+
|
|
72
|
+
| 命令 | 用途 |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `browscreen --help` | 查看全部命令与参数 |
|
|
75
|
+
| `browscreen version` / `browscreen --version` | 查询安装版本并退出,无需工作目录 |
|
|
76
|
+
| `browscreen --work-dir <目录>` | 启动服务,默认 INFO 日志 |
|
|
77
|
+
| `browscreen -v --work-dir <目录>` | 启动服务并开启 DEBUG 和 HTTP 访问日志 |
|
|
78
|
+
|
|
79
|
+
默认省略网页轮询、HTTPX 请求和重复重试细节;状态变化、超时及异常仍会记录。同一 webhook 连续失败仅首次告警,恢复后记录一次;每帧仍按原规则发送。完整参数与日志说明见[命令行参考](doc/reference/命令行.md)。
|
|
80
|
+
|
|
81
|
+
## HTTP 接口
|
|
82
|
+
|
|
83
|
+
| 接口 | 用途 |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `GET /` | 只读网页预览 |
|
|
86
|
+
| `GET /api/screenshot` | 最新合成 PNG,附带帧编号、UTC 采集开始时间和 `no-store` |
|
|
87
|
+
| `POST /api/webhooks` | 注册 HTTP(S) 接收地址,向后续新帧发送 PNG |
|
|
88
|
+
|
|
89
|
+
截图与发送顺序执行,每帧向所有接收地址并发尝试一次。慢 webhook 会降低采集频率,每次发送最多等待 3 秒;失败不自动重试、不跟随重定向。注册保留至进程退出。协议与错误码见[使用说明](doc/user-guide/使用说明.md)。
|
|
90
|
+
|
|
91
|
+
## 文档
|
|
92
|
+
|
|
93
|
+
- [安装与运行](doc/deployment/安装与运行.md):环境、安装、启动、退出和构建。
|
|
94
|
+
- [命令行参考](doc/reference/命令行.md):命令、参数和日志级别。
|
|
95
|
+
- [使用说明](doc/user-guide/使用说明.md):文件协议、预览、图片和 webhook。
|
|
96
|
+
- [文档导航](doc/README.md):完整阅读路线和技术设计。
|
|
97
|
+
- [版本变动历史](changelog.md):各版本的新增、变更和修复。
|
|
98
|
+
|
|
99
|
+
## 开发与贡献
|
|
100
|
+
|
|
101
|
+
开发环境需要 Node.js 22 或更高版本,以运行预览脚本回归,无需 npm 依赖:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
uv sync --locked --group dev \
|
|
105
|
+
-i http://mirrors.aliyun.com/pypi/simple/ \
|
|
106
|
+
--trusted-host mirrors.aliyun.com
|
|
107
|
+
.venv/bin/python -m pytest -q -W error
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
默认测试使用模拟浏览器端点;真实 Chrome 验证需要另行执行。缺少 Node.js 时预览测试会跳过,不代表前端验证通过。
|
|
111
|
+
|
|
112
|
+
问题和建议请提交到 [GitHub Issues](https://github.com/Pegasus-Yang/Browscreen/issues)。提交改动前请阅读[贡献指南](CONTRIBUTING.md)。项目由 [Pegasus-Yang](https://github.com/Pegasus-Yang) 维护。
|
|
113
|
+
|
|
114
|
+
## 许可证
|
|
115
|
+
|
|
116
|
+
项目采用 [MIT 许可证](LICENSE)。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
browscreen/__init__.py,sha256=c9ZjBrjgrPlR-vYDtrZbUbfZdXFSXqwYS51BdQW1EDU,147
|
|
2
|
+
browscreen/app.py,sha256=DNLJCpqucxxzRnWhFhQ_-goULs2xmXN6QA-_n_HcIFE,4210
|
|
3
|
+
browscreen/capture.py,sha256=fftE8taWAp-kiUl0uIPgU6d3I8ti9ZqjFYU-cZm4CRc,6353
|
|
4
|
+
browscreen/files.py,sha256=D2vPKrEQeEMpLTfvRiMLSoiOf49bFtFMv2uPUlpITnA,1608
|
|
5
|
+
browscreen/imaging.py,sha256=o4WzZ9sbk7-29sdoY3welKKqR98qj8jfEzesen_sJ-I,1955
|
|
6
|
+
browscreen/main.py,sha256=g9C8dosRxktol64vgmGxzUyXsd3obgeZQOcHs8XvPk0,2832
|
|
7
|
+
browscreen/models.py,sha256=SCC8mH-UEjcFVR2c4rpsdZB0eQMbGw4SSNZ3Srz8WDs,2113
|
|
8
|
+
browscreen/preview.html,sha256=0t5j94mT1xqNOol8dVWYB7WSZzhOTJurp_Wr9JQOZUw,5481
|
|
9
|
+
browscreen/webhooks.py,sha256=aPre8KK71q9ZCKWF4uiFWoxQYbnTfIyyC1p5ZqP9lfM,1464
|
|
10
|
+
browscreen/adapters/__init__.py,sha256=er4JIFNF9x0jR4Eusol-Ug5L2-3uccvWWUHbsvGquCc,37
|
|
11
|
+
browscreen/adapters/base.py,sha256=uJYxpEZgGw7VJuUtflbZO8Lgo8rlxUiHu9-Ov3pVAwA,1182
|
|
12
|
+
browscreen/adapters/chrome_cdp.py,sha256=NV2Bsq6LcNkzKI_JzwVmZDjUA01JZUNLNlL5W6-BQ5Y,6870
|
|
13
|
+
browscreen/assets/cursor.png,sha256=vKJdVu3DgZvw8O9b3WIhprIQSb-ws6BTFkK3DbMGYnw,237
|
|
14
|
+
browscreen-0.2.1.dist-info/METADATA,sha256=Kxh8L3amfRWN2usqShD-qiyT9XtS0QY6YwOGIGERnZY,5537
|
|
15
|
+
browscreen-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
browscreen-0.2.1.dist-info/entry_points.txt,sha256=wthOnavN86RDgc50wLBoJqfMZ9iqHLwFEp_4KntcVoY,52
|
|
17
|
+
browscreen-0.2.1.dist-info/licenses/LICENSE,sha256=wCZOJAWha2auh_hNgp_oUGgM46r6IShZFiWh-lguKKY,1069
|
|
18
|
+
browscreen-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pegasus-Yang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|