一、背景与需求
在日常开发与自动化测试中,经常需要获取浏览器当前页面的完整 DOM 结构、选中文本、交互元素等上下文信息,或向页面注入 JavaScript 执行特定操作。现有方案(书签脚本、手动 DevTools、独立爬虫)存在以下痛点:
- 书签脚本:无法异步等待结果,难以处理 Promise 和序列化复杂返回值。
- 手动 DevTools:不可编程,无法集成到自动化流程。
- 独立爬虫:无法获取动态渲染后的真实 DOM,也无法与用户当前登录态交互。
本文描述了一个 Chrome Extension + 本地 Python 服务 的完整技术方案,支持:
- 页面内嵌聊天 UI,用户提问时自动发送完整页面上下文到本地服务。
- 本地程序通过 HTTP 接口主动触发扩展采集当前活动标签页。
- 本地程序通过 HTTP 接口在活动标签页执行任意 JavaScript 代码。
二、系统架构
2.1 整体设计
系统由两个子系统组成:
| 子系统 | 技术栈 | 职责 |
|---|---|---|
| Chrome Extension | Manifest V3, Chrome APIs | 注入 UI、采集上下文、执行 JavaScript |
| 本地服务 | Python 3.11+, aiohttp | HTTP 路由、WebSocket 通道、命令调度 |
通信链路包含三种模式:
- 聊天模式:Content Script → Service Worker(runtime message)→ HTTP POST /chat → 响应返回。
- 采集模式:本地客户端 → HTTP POST /capture → Service Worker(WebSocket message)→ Content Script(runtime message)→ 上下文返回。
- 执行模式:本地客户端 → HTTP POST /execute → Service Worker(WebSocket message)→ CDP Runtime.evaluate → 结果返回。
2.2 架构图
#mermaid-svg-FZjo8XoIQfiFAVkp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FZjo8XoIQfiFAVkp .error-icon{fill:#552222;}#mermaid-svg-FZjo8XoIQfiFAVkp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FZjo8XoIQfiFAVkp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FZjo8XoIQfiFAVkp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FZjo8XoIQfiFAVkp .marker.cross{stroke:#333333;}#mermaid-svg-FZjo8XoIQfiFAVkp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FZjo8XoIQfiFAVkp p{margin:0;}#mermaid-svg-FZjo8XoIQfiFAVkp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster-label text{fill:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster-label span{color:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster-label span p{background-color:transparent;}#mermaid-svg-FZjo8XoIQfiFAVkp .label text,#mermaid-svg-FZjo8XoIQfiFAVkp span{fill:#333;color:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp .node rect,#mermaid-svg-FZjo8XoIQfiFAVkp .node circle,#mermaid-svg-FZjo8XoIQfiFAVkp .node ellipse,#mermaid-svg-FZjo8XoIQfiFAVkp .node polygon,#mermaid-svg-FZjo8XoIQfiFAVkp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FZjo8XoIQfiFAVkp .rough-node .label text,#mermaid-svg-FZjo8XoIQfiFAVkp .node .label text,#mermaid-svg-FZjo8XoIQfiFAVkp .image-shape .label,#mermaid-svg-FZjo8XoIQfiFAVkp .icon-shape .label{text-anchor:middle;}#mermaid-svg-FZjo8XoIQfiFAVkp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FZjo8XoIQfiFAVkp .rough-node .label,#mermaid-svg-FZjo8XoIQfiFAVkp .node .label,#mermaid-svg-FZjo8XoIQfiFAVkp .image-shape .label,#mermaid-svg-FZjo8XoIQfiFAVkp .icon-shape .label{text-align:center;}#mermaid-svg-FZjo8XoIQfiFAVkp .node.clickable{cursor:pointer;}#mermaid-svg-FZjo8XoIQfiFAVkp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FZjo8XoIQfiFAVkp .arrowheadPath{fill:#333333;}#mermaid-svg-FZjo8XoIQfiFAVkp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FZjo8XoIQfiFAVkp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FZjo8XoIQfiFAVkp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FZjo8XoIQfiFAVkp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FZjo8XoIQfiFAVkp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FZjo8XoIQfiFAVkp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster text{fill:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp .cluster span{color:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FZjo8XoIQfiFAVkp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FZjo8XoIQfiFAVkp rect.text{fill:none;stroke-width:0;}#mermaid-svg-FZjo8XoIQfiFAVkp .icon-shape,#mermaid-svg-FZjo8XoIQfiFAVkp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FZjo8XoIQfiFAVkp .icon-shape p,#mermaid-svg-FZjo8XoIQfiFAVkp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FZjo8XoIQfiFAVkp .icon-shape .label rect,#mermaid-svg-FZjo8XoIQfiFAVkp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FZjo8XoIQfiFAVkp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FZjo8XoIQfiFAVkp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FZjo8XoIQfiFAVkp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 页面提问
runtime message
POST /chat
reply
reply
POST /capture
capture_page / WebSocket
CAPTURE_PAGE_CONTEXT
context
capture_result
POST /execute
execute_script / WebSocket
CDP Runtime.evaluate
result
execute_result
本地用户/程序
Content Script
Shadow DOM Chat
Extension Service Worker
aiohttp Server
127.0.0.1:8090
当前活动页面
2.3 为什么选择 WebSocket 作为命令通道
最初考虑过两种方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 定时轮询(扩展每隔 N 秒请求一次) | 实现简单 | 延迟不可控、资源浪费、无法实时响应 |
| WebSocket 长连接 | 双向实时、低延迟、按需通信 | Service Worker 保活需要额外处理 |
最终选择 WebSocket 。Chrome Service Worker 在 30 秒无活动后会进入休眠,通过每 20 秒发送 ping 保持连接活跃。
三、Chrome Extension 设计
3.1 Manifest V3 配置
使用 Manifest V3,声明 debugger 和 tabs 两个权限:
json
{
"manifest_version": 3,
"minimum_chrome_version": "116",
"permissions": ["debugger", "tabs"],
"host_permissions": [
"http://127.0.0.1:8090/*",
"http://localhost:8090/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
权限设计要点:
tabs:用于查询当前聚焦窗口的活动标签页,判断是否为 HTTP/HTTPS 页面。debugger:/execute功能的核心,通过 Chrome DevTools Protocol 在页面主世界执行代码。host_permissions:限制扩展后台只能访问本机8090端口,不向任意 URL 开放 fetch。
3.2 Content Script:Shadow DOM 聊天 UI
Content Script 在页面加载完成后注入,使用 closed Shadow DOM 隔离样式:
document.createElement("div") → attachShadow({ mode: "closed" })
这种模式下,页面自身的 JavaScript 无法访问组件内部的 DOM 节点和样式,避免了与页面样式冲突。
组件内部包含:
- 启动按钮:固定在右下角,点击展开聊天面板。
- 聊天面板:网格布局,包含标题栏、消息列表和输入区。
- 消息模型 :
user和assistant/bubble两种样式,支持错误状态。
3.3 页面上下文采集
采集函数在用户点击发送或 Service Worker 请求时执行,返回以下结构:
javascript
{
url: location.href,
title: document.title,
language: document.documentElement.lang || navigator.language,
description: getMeta("description") || getMeta("og:description"),
selectedText: window.getSelection().toString().slice(0, 10000),
headings: Array.from(document.querySelectorAll("h1, h2, h3")).slice(0, 60),
interactiveElements: collectInteractiveElements(),
visibleText: document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 30000),
html: document.doctype
? `${new XMLSerializer().serializeToString(document.doctype)}\n${document.documentElement.outerHTML}`
: document.documentElement.outerHTML
}
设计考量:
interactiveElements采集<a>、<button>、<input>(排除password)、<select>、<textarea>,限制前 80 个元素。visibleText规范化空白并限制 30,000 字符,避免超大页面导致请求体过大。html不截断,包含 DOCTYPE 声明,便于后端解析。- 密码输入框在
interactiveElements中排除,但完整 HTML 仍可能包含隐藏敏感信息。
3.4 Service Worker:WebSocket 与命令调度
Service Worker 作为扩展的中枢,承担以下职责:
-
WebSocket 连接管理:
- 扩展加载后立即连接
ws://127.0.0.1:8090/ws。 - 每 20 秒发送
{"type": "ping"}保活。 - 断线后按指数退避重连(1s → 2s → 4s → 8s → 10s 上限)。
- 扩展加载后立即连接
-
HTTP 请求转发:
- 监听 Content Script 的
PAGE_CONTEXT_CHATruntime message,转发到/chat。 - 支持 30 秒超时和
AbortController取消。
- 监听 Content Script 的
-
WebSocket 命令调度:
- 收到
capture_page命令 → 查询活动标签页 → 向 Content Script 发送CAPTURE_PAGE_CONTEXT→ 回传结果。 - 收到
execute_script命令 → 查询活动标签页 → 附加 CDP 调试器 → 执行Runtime.evaluate→ 回传结果。
- 收到
3.5 JavaScript 执行:CDP Runtime.evaluate
核心实现代码:
javascript
async function executeInActivePage(requestId, code) {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (!tab?.id || !/^https?:\/\//.test(tab.url || "")) {
throw new Error("当前活动页不是可执行的 HTTP/HTTPS 页面");
}
const debuggee = { tabId: tab.id };
await chrome.debugger.attach(debuggee, "1.3");
const response = await chrome.debugger.sendCommand(debuggee, "Runtime.evaluate", {
expression: code,
awaitPromise: true, // 支持异步代码
returnByValue: true, // 尽可能序列化为 JSON
userGesture: true, // 模拟用户操作
timeout: 10000 // 浏览器执行超时
});
// ... 处理结果和异常
await chrome.debugger.detach(debuggee);
}
技术要点:
awaitPromise: true:当代码返回 Promise 时,CDP 会等待 Promise resolve 后再返回结果。returnByValue: true:CDP 会尽量将返回值序列化为 JSON。函数、DOM 节点、循环引用对象无法序列化,此时只能通过type、subtype和description描述。- 执行结束后必须 detach 调试器,否则 Chrome DevTools 无法再次附加到该标签页。
四、本地服务设计
4.1 技术选型
| 场景 | 备选方案 | 选择 | 理由 |
|---|---|---|---|
| HTTP 框架 | Flask, FastAPI, aiohttp | aiohttp | 原生支持 WebSocket,单个进程处理所有路由 |
| WebSocket | 单独 ws 库 + HTTP 服务 | aiohttp 内置 | 统一端口、共享事件循环 |
4.2 路由表
| 方法 | 路径 | 处理函数 | 功能 |
|---|---|---|---|
| GET | /health | health | 健康检查 |
| POST | /chat | chat | 聊天消息处理 |
| POST | /capture | capture | 触发页面采集 |
| POST | /execute | execute_script | 执行 JavaScript |
| GET | /ws | extension_socket | 扩展 WebSocket 通道 |
4.3 CaptureBroker:请求关联器
CaptureBroker 是服务的核心组件,负责将 HTTP 请求与 WebSocket 回包关联:
python
class CaptureBroker:
def __init__(self):
self.connections = [] # 存活 WebSocket 连接
self.pending = {} # {requestId: (Future, socket)}
def register(self, socket): pass
def unregister(self, socket): pass
async def _request(self, command_type, command, timeout): pass
def resolve(self, message): pass
请求关联流程:
- HTTP handler 调用
broker._request(...)。 - 生成
uuid4().hex作为requestId。 - 创建
asyncio.Future,存入pending字典。 - 通过 WebSocket 发送命令(包含
requestId)。 - 等待
Future完成(最多timeout秒)。 - WebSocket 收到
capture_result/execute_result时,resolve()设置对应Future的结果。 - HTTP handler 从
Future获取结果并返回。
优势:
- 支持并发请求:不同
requestId对应不同Future,互不阻塞。 - 超时安全:
asyncio.wait_for在超时后自动取消等待。 - 断连清理:扩展断开 WebSocket 时,
unregister()会 set_exception 所有关联的Future。
4.4 中间件:来源校验
python
@web.middleware
async def origin_guard(request, handler):
origin = request.headers.get("Origin")
if origin and not origin.startswith("chrome-extension://"):
return json_error(403, "origin_forbidden", ...)
# ...
安全逻辑:
- 带
Origin的请求只允许chrome-extension://来源,普通网页的 fetch 无法调用接口。 - 本地命令行调用(无
Origin)不受限制------因为服务只监听127.0.0.1,已经隔离了外部网络。
4.5 占位聊天实现
当前 build_placeholder_reply() 只返回一条确认消息,包含收到的页面标题、文本长度和 HTML 长度:
python
def build_placeholder_reply(message, context):
return (
f"本地服务已收到你的问题:{message}\n\n"
f"当前页面:{title}\n"
f"已接收:{context_source},完整 HTML {len(html)} 字符\n\n"
"这是占位响应。请在 server.py 的 build_placeholder_reply() 中接入实际模型或业务逻辑。"
)
接入 LLM 服务时,只需替换此函数,接口格式和请求/响应结构无需改动。
五、协议设计
5.1 HTTP 接口
所有接口统一使用 JSON 请求体和响应体(UTF-8 编码)。
成功响应顶层结构:
json
{
"requestId": "uuid",
"reply": "..." // 仅 /chat
"context": {...} // 仅 /capture
"result": {...} // 仅 /execute
}
错误响应顶层结构:
json
{
"error": {
"code": "error_code",
"message": "错误说明"
}
}
5.2 WebSocket 消息协议
| 方向 | type | 说明 |
|---|---|---|
| 服务端 → 扩展 | ready | 连接建立成功 |
| 扩展 → 服务端 | hello | 扩展名称和版本 |
| 扩展 → 服务端 | ping | 保活心跳 |
| 服务端 → 扩展 | pong | 心跳响应 |
| 服务端 → 扩展 | capture_page | 请求采集上下文 |
| 扩展 → 服务端 | capture_result | 返回上下文或错误 |
| 服务端 → 扩展 | execute_script | 请求执行 JavaScript |
| 扩展 → 服务端 | execute_result | 返回执行结果 |
capture_page 和 execute_script 消息包含 requestId 字段,回包时必须携带相同的 requestId 用于关联。
5.3 数据大小限制
| 限制项 | 上限 | 位置 |
|---|---|---|
| HTTP 请求体 | 50 MB | aiohttp client_max_size |
| WebSocket 消息 | 50 MB | max_msg_size |
| 代码段(UTF-8) | 256 KB | 服务端 len(code.encode("utf-8")) 校验 |
| 可见文本 | 30,000 字符 | Content Script 采集时截断 |
| 选中文本 | 10,000 字符 | Content Script 采集时截断 |
六、关键实现细节
6.1 WebSocket 保活
Chrome Service Worker 在约 30 秒无活动后进入休眠。发送 ping 会触发事件监听,保持 Service Worker 存活:
javascript
// background.js
keepaliveTimer = setInterval(() => sendSocketMessage({ type: "ping" }), 20000);
服务端同时设置了 25 秒的 heartbeat 参数,无消息时会自动关闭连接。
6.2 重连退避
javascript
let reconnectDelay = 1000; // 第一次重连延迟 1 秒
function scheduleReconnect() {
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectSocket();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000); // 上限 10 秒
}
6.3 调试器附加与分离
每次执行 JavaScript 时,扩展会:
- 查询当前活动标签页。
- 调用
chrome.debugger.attach(debuggee, "1.3")。 - 执行
Runtime.evaluate。 - 在
finally块中调用chrome.debugger.detach(debuggee)。
如果 Chrome DevTools 已经附加到同一标签页,attach 会失败,扩展回传 execution_failed 错误。
6.4 DOCTYPE 序列化
document.documentElement.outerHTML 不包含 <!DOCTYPE ...>,需要单独获取:
javascript
const doctype = document.doctype
? `${new XMLSerializer().serializeToString(document.doctype)}\n`
: "";
七、安全设计
7.1 网络层
- 服务固定监听
127.0.0.1(回环地址),不对外部网络暴露。 origin_guard中间件阻止带普通网页Origin的跨域请求。
7.2 权限层
/execute默认关闭,启动时需显式传入--allow-js-execution。- Chrome Extension 要求用户手动接受
debugger权限,Chrome 会在执行时显示调试器附加提示。 - 执行目标限定为 HTTP/HTTPS 页面,
chrome://等受保护页面无法执行。
7.3 数据层
- 页面上下文不持久化存储(但占位实现在服务端控制台
print了上下文信息)。 - 完整 HTML 不截断,发送前应评估页面是否包含密码、令牌或敏感数据。
7.4 当前限制
- 无访问令牌鉴权:同一台机器的任何进程都可以调用暴露在
127.0.0.1:8090的接口。 - 无审计日志:未记录谁调用了哪些接口。
- 无代码执行白名单:
/execute接受任意 JavaScript 代码。
八、测试策略
8.1 单元测试(aiohttp TestClient)
使用 aiohttp 的 TestClient 和 TestServer 模拟 HTTP 请求和 WebSocket 连接,验证服务端的协议正确性:
python
async def test_capture_round_trip(self):
socket = await client.ws_connect("/ws", origin="chrome-extension://test")
# 模拟扩展回包
await socket.send_json({...})
response = await client.post("/capture")
# 断言响应结构和字段
测试覆盖:
/capture完整往返。- 普通网页 Origin 拒绝(403)。
- 扩展未连接时返回 503。
/execute默认关闭(403)。- 启用后
/execute完整往返。
8.2 集成测试
独立的 Python 脚本,在真实扩展和服务运行时调用接口:
test_capture_api.py:校验requestId、context和 HTML 完整性。test_execute_api.py:在活动页执行setTimeout+alert,校验result.type和result.value。
8.3 测试与生产的关系
| 组件 | 测试 | 生产 |
|---|---|---|
| HTTP 路由 | TestClient | aiohttp.web.run_app |
| WebSocket | TestClient ws_connect | Chrome Extension 真实连接 |
| 页面上下文 | 模拟回包 | Content Script 实时采集 |
| CDP 执行 | 模拟回包 | Chrome debugger API |
九、开发与部署
9.1 本地开发
powershell
# 安装依赖
python -m pip install -r .\server\requirements.txt
# 启动普通模式
python .\server\server.py
# 启动执行模式
python .\server\server.py --allow-js-execution
Chrome 加载开发模式扩展:chrome://extensions/ → 启用开发者模式 → 加载已解压的扩展程序。
9.2 版本兼容
| 依赖 | 最低版本 | 说明 |
|---|---|---|
| Chrome | 116 | WebSocket Service Worker |
| Python | 3.11 | AppKey 泛型 |
| aiohttp | 3.10 | WebSocketResponse.heartbeat |
9.3 已知缺陷
- 同一个标签页不能同时被 DevTools 和扩展调试器附加 :如果用户已经打开 Chrome DevTools,
/execute会失败。 - Service Worker 生命周期:Chrome 可能因内存压力终止 Service Worker,WebSocket 断开后依赖重连逻辑恢复。
- CDP 序列化限制 :
returnByValue: true无法序列化Function、Symbol、DOM 节点和循环引用对象。 - 多窗口行为 :
tabs.query({ active: true, lastFocusedWindow: true })取最后获得焦点的窗口,如果多个 Chrome 窗口存在,行为取决于用户最近点击的窗口。
十、总结
本文设计了一个 Chrome Extension + aiohttp 服务 的技术方案,实现了页面上下文采集和远程 JavaScript 执行两套核心能力。
设计亮点:
- 消息关联模式 :通过
requestId+asyncio.Future+ WebSocket 的异步关联方案,在单个线程内高效处理并发请求。 - 安全分层:回环地址 + Origin 校验 + 启动开关 + Chrome 权限体系,形成多层防护。
- 渐进式扩展:聊天、采集、执行三个功能按权限和风险递减设计,用户可以根据需要选择启用范围。
适用场景:
- 自动化测试中的页面状态捕获。
- 本地 LLM 服务获取浏览器上下文。
- 开发工具中需要对当前浏览页面进行编程控制。
不适用场景:
- 生产环境的多用户服务。
- 需要长期稳定运行的浏览器自动化。
- 对 Chrome Web Store 发布有要求的场景(因使用了
debugger权限)。
https://github.com/wqs-base/Page-Context-Chat
完整代码仓库结构:
text
chrome-dom-chat/
├── README.md
├── TECHNICAL_DESIGN.md
├── .gitignore
├── extension/
│ ├── manifest.json
│ ├── background.js
│ └── content.js
└── server/
├── requirements.txt
├── server.py
├── test_server.py
├── test_capture_api.py
└── test_execute_api.py