利用 Codex 调试通义智文 RAG 实现的一次逆向经历
发布时间:
最近在做 RAG 系统的文档高亮功能,想参考一下通义智文的实现方案。本来打算自己 F12 慢慢看,但因为涉及登录态失效和大量请求过滤,比较繁琐。于是尝试用 agent-browser 让 AI 帮我跑了一遍流程。过程中踩了几个坑(主要是模拟登录失败和拿不到 Response Body),最后通过劫持本地 Cookie 和注入 JS 解决了,顺便把他们后端返回的坐标数据结构摸清楚了。记录一下这个全自动化的分析过程。。
理解 agent-browser
首先 codex主要使用了 skills.sh 生态中的 agent-browser 技能(这个我是提前安装好的,是参考 skills.sh 这个网站下载的工具)。把它当成“可脚本化的 DevTools”。
| 你平时在 DevTools 里干的事 | agent-browser 对应的命令 | 作用 |
|---|---|---|
| F12 -> Network 面板 | agent-browser network requests | 抓包、看流量、过滤 XHR/Fetch |
| F12 -> Console 面板 | agent-browser eval | 在页面上下文跑 JS(提取数据、fetch、甚至改 DOM) |
| 地址栏输 URL | agent-browser open | 打开网页 |
| 点按钮/输入框/滚动 | agent-browser click / fill / scroll | 模拟交互 |
| Elements/Accessibility 树 | agent-browser snapshot | 拉一份当前页面结构快照(拿到元素 ref) |
它的边界也很清楚:验证码、扫码登录这种,靠“点点点”硬冲只会浪费时间。得换思路。
第一坑:自动下载 Chromium 失败
一开始 agent-browser 自己拉一份 Chromium 跑,但环境里下载二进制经常失败(TLS reset 这事国内太常见了)。这一步如果死磕会把整个流程卡死在“下载浏览器”上。
处理很简单:不用它下载,直接用本机的 Google Chrome。
第二坑:登录就是自动化的天敌
通义这边的阅读页(/efficiency/doc/read?taskId=...)没登录会直接跳登录。让 agent 去填手机号、等验证码、扫二维码……理论上能做,实际很痛苦,而且不稳定。最后用的是“复用本地登录态”:
- 把我本机 Chrome Profile 里跟登录相关的少量文件复制到临时目录(Cookies/Local Storage/Local State/Preferences 这几样)。
- 用这个临时目录启动一个 headless Chrome,并打开 CDP 端口(
--remote-debugging-port=9222)。 - 让 agent-browser 通过
connect 9222附着上去。站点看到的是“一个带着登录态的 Chrome”,就直接放行了。
这就是“会话劫持”,只是复用我自己电脑上、我自己的登录态。
第三坑:Network 里能看到请求,但看不到 Response Body
agent-browser network requests 很好用,但它默认更像“请求索引”:能看到 URL、方法、类型(xhr/fetch),不保证直接把响应体也给你。
要确认“接口里到底有没有 x/y 坐标”,最终还是得在 Console 语境里自己 fetch(url),把文本截一段出来看。这也是后面很多关键证据的来源。
先搞清楚坐标从哪来:不是前端切出来的
在阅读页清空抓包后重载,然后只看 xhr/fetch,再按关键词(list/doc/text/coordinate/json)筛了一轮。
最可疑的不是某个“coordinate API”,而是一串对象存储上的页面 JSON:
GET https://darwin-controller-pro*.oss-cn-hangzhou.aliyuncs.com/<docId>/pages/<pageId>.json?...
直接在页面里 fetch 了其中一个 pages/<pageId>.json,截前 500 个字符,里面就把坐标摊开了(字段名都懒得伪装):
[
{
"alignment": "left",
"blocks": [
{
"pos": [{"x":153,"y":51},{"x":338,"y":51},{"x":338,"y":92},{"x":153,"y":92}],
"text": "华创证券",
"wordInfo": [
{"pos":[{"x":165,"y":51},{"x":202,"y":51},{"x":202,"y":92},{"x":165,"y":92}],"text":"华"},
{"pos":[{"x":212,"y":51},{"x":249,"y":51},{"x":249,"y":92},{"x":212,"y":92}],"text":"创"}
]
}
]
}
]
这基本说明:
- 坐标不是“前端渲染后再切分”算出来的。
- 后端已经把 块级(
blocks[].pos)和 字/词级(wordInfo[].pos)的四点框都算好,前端只是叠一层渲染。
这份 JSON 里有 fontName/fontSize/bold 这种信息,像是文本层解析;扫描件没试,估计大概率还是走渲染+OCR,再统一产出同一套结构。它们最终都落到 “pages/*.json + 坐标” 这套格式上,前端不关心来源。
再看 RAG:引用、chunk_id、高亮,其实都在一个 stop event 里
真正想看的,是“检索切片怎么做”和“高亮怎么对齐”。在阅读页里点发送之后,抓包里终于出现了一个更像主流程的请求:
POST https://www.qianwen.com/zhiwen/api/doc_chat
Accept: text/event-stream
踩坑:它是 fetch + SSE,network requests 里能看到 URL,但不太好直接拿到请求体。想复现 stop event 的结构,就必须知道后端到底吃了哪些字段。最省事的方法反而是“在页面里埋点”:给 window.fetch 包一层,把 url/method/body 记录下来,然后再点一次发送。这样就把 payload 里的关键字段抓出来了,比如 currentDocId/docIdList/taskId/prompt/pluginIdList/sseId/source。
这是 SSE。它不是一次性返回 JSON,而是一段段 data:{...}\n\n 往外吐。前面会先冒出 finish_reason="function_call",而且能看到插件信息:
plugin_content.function_name = "doc-search"
真正有价值的是最后那条 stop event。里面同时返回了两套东西:
references[]:给“引用/溯源”用的文本和 chunk idhighlight[]:给“高亮/定位”用的页码和坐标
把 stop event 的头部截了 500 字符,大概长这样:
{
"code": 200,
"data": {
"finish_reason": "stop",
"references": [
{
"doc_id": 1469...,
"chunk_ids": ["64c0c1c3...","f0fcb618...", "..."],
"text": "..."
}
],
"highlight": [
{
"docId": "1469...",
"posList": [
{
"pageNum": 39,
"posList": [
[{"x":87,"y":48},{"x":338,"y":48},{"x":338,"y":114},{"x":87,"y":114}],
...
]
}
],
"uniqueIdList": ["64c0c1c3...","f0fcb618...", "..."],
"text": "..."
}
]
}
}
当时有个小插曲:doc_id 是超大整数,JS 里直接 JSON.parse 会有精度丢失风险。后面干脆把它当字符串处理,省心。
“chunk_ids 对应的是行/段/块”怎么验证?
计算统计。
同一次 stop event 里,把每条 reference 做了四个统计:
chunk_ids.lengthtext.lengthtext里\n\n分段的段落数量- 同一条高亮里
uniqueIdList.length与矩形框数量
跑出来的一组典型数字是:
chunk_ids_len = 28text_len = 1412- 按空行切段:
paragraphs ≈ 27 uniqueIdList_len = 28(和chunk_ids_len对齐)- 一个页上的矩形框:
rects_total = 46 - 平均每个 chunk 覆盖
46/28 ≈ 1.64个矩形框
这组关系太像“段落块”了:
chunk_ids和“段落数”基本同量级,明显不像 512 字符硬切(那通常只会有几块)。- 一个 chunk 对应 1~2 个矩形框,很符合“一个段落折成两行”的排版现实。
references[i].text === highlight[i].text(我额外校验过),说明引用文本和高亮文本是同一个拼接结果。
结论:通义这套 RAG 的 chunk 粒度更接近“段落块”,不是固定长度切片。
他们的“溯源 + 高亮”大概是怎么拼起来的
把上面的证据串起来,通义这套实现看起来像这样:
- 文档入库时先做解析,拆成很多“段落块/版面块”。每块有:
chunk_id(32 位 hex,看起来像 hash)pageNum + 坐标矩形列表text
- 检索时按块召回,但返回给前端时会把相邻块做合并,变成“引用片段”:
references[].text是拼接后的片段references[].chunk_ids[]记录这个片段由哪些块组成
- 高亮不靠前端再算:
- 后端直接给
highlight[].posList[{pageNum, posList:[rect...]}] - 一条引用可以跨页:
posList本身就是数组,天然支持多页
- 后端直接给
- 高亮是“块级”而不是“句子级”:
- 我没在响应里看到 offset/token 对齐信息
- 看到的是“若干矩形框”,更像把整块框出来
附录:Codex用过的命令
供你参考:
# 0) (可选) 克隆本机 Chrome Profile 到临时目录(只复制必要文件)
# 说明:
# - 下面的 Profile 路径以 macOS + Chrome 的 Default profile 为例,你本地可能是 Profile 1/2。
# - 只建议在“自己的电脑、自己的账号”场景这么做。
Ran mkdir -p /tmp/agent-qwen-profile/Default
Ran cp "$HOME/Library/Application Support/Google/Chrome/Local State" /tmp/agent-qwen-profile/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Preferences" /tmp/agent-qwen-profile/Default/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Secure Preferences" /tmp/agent-qwen-profile/Default/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Cookies"* /tmp/agent-qwen-profile/Default/
Ran cp -R "$HOME/Library/Application Support/Google/Chrome/Default/Local Storage" /tmp/agent-qwen-profile/Default/
# 1) 启动带 CDP 的本机 Chrome(复用临时 profile)
Ran /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\
--remote-debugging-port=9222 \\
--user-data-dir=/tmp/agent-qwen-profile \\
--headless=new --disable-gpu \\
--no-first-run --no-default-browser-check \\
about:blank
# 2) agent-browser 附着到 CDP
Ran agent-browser --session qwen-cdp connect 9222
# 3) 打开阅读页
Ran agent-browser --session qwen-cdp open 'https://www.qianwen.com/efficiency/doc/read?taskId=<TASK_ID>'
Ran agent-browser --session qwen-cdp wait --load networkidle
# 4) 清空并导出请求列表
Ran agent-browser --session qwen-cdp network requests --clear
Ran agent-browser --session qwen-cdp network requests --json > /tmp/qwen_requests.json
# 5) 用脚本过滤出 xhr/fetch + 关键词命中的 URL(示例:用 uv 跑 python)
Ran uv run python - <<'PY'
import json, re
obj=json.load(open("/tmp/qwen_requests.json"))
reqs=(obj.get("data") or {}).get("requests") or []
kw=re.compile(r"(list|doc|text|coordinate|json)", re.I)
for r in reqs:
if (r.get("resourceType") or "").lower() not in ("xhr","fetch"):
continue
if not kw.search(r.get("url","")):
continue
print(r.get("resourceType"), r.get("method"), r.get("url"))
PY
# 6) 在页面里 fetch pages/*.json,看坐标字段(只看前 500 字符)
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
(async () => {
const url = 'https://darwin-controller-pro.oss-cn-hangzhou.aliyuncs.com/<docId>/pages/<pageId>.json?...';
const text = await fetch(url).then(r => r.text());
return text.slice(0, 500);
})()
JS
# 6.5) (可选) 注入 fetch hook,抓 doc_chat 的请求体
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
(() => {
if (window.__FETCH_HOOKED__) return;
window.__FETCH_HOOKED__ = true;
window.__FETCH_CALLS__ = [];
const origFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
try {
const [input, init] = args;
const url = typeof input === "string" ? input : input?.url;
const method = init?.method || input?.method || "GET";
const body = typeof init?.body === "string" ? init.body.slice(0, 2000) : init?.body;
window.__FETCH_CALLS__.push({ t: Date.now(), url, method, body });
} catch {}
return origFetch(...args);
};
})();
JS
# 7) 触发 doc_chat,然后在页面里读取 SSE stop event,提取 references/highlight
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
// 这里省略了完整 JS:核心是 fetch('.../doc_chat') 后 reader.read() 读 SSE,
// 找到 finish_reason === 'stop' 的那条 data 事件,解析 data.references/data.highlight。
JS