数据截至 (上游 commit ae57a2357745)
第 4 章 · 自主浏览器:不用眼睛怎么上网
本章讲:
BrowserAgent怎么用一台真实 Chrome 完成「搜索 → 逐页阅读 → 记笔记 → 填表 → 汇总」,全程只靠文本,不用视觉模型。
4.1 它要解决的小问题
让 AI 上网找答案,难在三处:
| 难点 | 具体是什么 |
|---|---|
| 页面太大 | 一个网页的 HTML 动辄几十万字符,塞不进上下文 |
| 会被拦 | 自动化浏览器有一堆指纹特征,很多站直接拒绝 |
| 何时收工 | 模型容易在链接里无限游走,或看了一页就妄下结论 |
AgenticSeek 三样都是纯文本方案——没有截图、没有视觉模型、没有可访问性树。
4.2 顶层流程
怎么读这张图:主体是一个循环,三个出口(找到答案 / 链接耗尽 / 用户叫停)。
用户请求
|
v
① 让 LLM 把请求改写成搜索词 search_prompt()
| (模型觉得这问题不该搜网 → 直接 REQUEST_EXIT)
v
② SearxNG 搜索,取前 16 条 searxSearch.execute()
|
v
┌─────────── 导航循环(未访问链接非空且未停)──────────┐
│ │
│ ③ 问 LLM:读完这页,记什么笔记?下一步去哪? │
│ | │
│ ├─ 答案里有 [字段](值) → 填表并提交 │
│ ├─ 答案里有 URL → go_to() 并抓页面文本 │
│ ├─ 答案里有 GO_BACK → 回搜索结果重选 │
│ └─ 答案里有 REQUEST_EXIT → 跳出循环 │
│ │
└──────────────────────────────────────────────────────┘
|
v
④ 把所有笔记喂给 LLM,让它写成结论 conclude_prompt()
源码在 sources/agents/browser_agent.py:347-445(BrowserAgent.process)。
4.3 第一步:把请求变成搜索词
不直接拿用户原话去搜,先让 LLM 改写(search_prompt,sources/agents/browser_agent.py:274-292)。prompt 里塞了当天日期和三组示例:
| 用户说 | 期望模型写出 |
|---|---|
| “go to twitter, login with username toto…” | search: Twitter login page |
| “I need info on the best laptops for AI this year.” | search: best laptops 2025 to run Machine Learning model, reviews |
| “Search for recent news about space missions.” | search: Recent space missions news, <今天日期> |
注意第一条:用户想登录 Twitter,模型该搜的是「Twitter 登录页」而不是用户名密码。这是在教模型区分「目标」和「查询」。
日期是运行时注入的(get_today_date(),sources/agents/browser_agent.py:46-49),解决本地模型知识截止日期过旧、总搜出陈年结果的问题。
如果模型判断这问题根本不需要上网,就回 REQUEST_EXIT,process 直接返回(sources/agents/browser_agent.py:363-365)。
4.4 搜索:自建 SearxNG
搜索走的是 docker-compose 里自托管的 SearxNG(元搜索引擎,把多个搜索引擎的结果聚合起来),POST 表单请求后用 BeautifulSoup 扒 HTML(sources/tools/searxSearch.py:62-107):
for article in soup.find_all('article', class_='result'):
url_header = article.find('a', class_='url_header')
...
results.append(f"Title:{title}\nSnippet:{description}\nLink:{url}")
返回的是一整块纯文本,再由 jsonify_search_results() 按 \n\n 切块、按 Title:/Snippet:/Link: 前缀解析回结构(sources/agents/browser_agent.py:198-215),最后 [:16] 截断。
为什么绕这么一圈:工具的返回值统一是字符串(Tools.execute 的契约),所以要么在工具里 JSON 化,要么在调用方解析。项目选了后者。
注意
requests.post(..., verify=False)(sources/tools/searxSearch.py:91)——对本地 SearxNG 关掉了证书校验。
4.5 页面 → Markdown:最关键的一步
Browser.get_text() 是整章的核心(sources/browser.py:390-418)。四道工序:
driver.page_source (几十万字符 HTML)
|
① BeautifulSoup 剥掉 script / style / noscript / meta / link
v
② markdownify 转 Markdown(ATX 标题、strip 掉 a 标签、autolinks=False)
v
③ 逐行过 is_sentence() 判定,不像人话的行整行丢弃
v
④ 图片  换成 [IMAGE: alt],包上 [Start of page]/[End of page],硬截 32768 字符
第 ③ 步是最有意思的启发式(sources/browser.py:378-388):
def is_sentence(self, text: str) -> bool:
text = text.strip()
if any(c.isdigit() for c in text):
return True # 含数字直 接放行
words = re.findall(r'\w+', text, re.UNICODE)
word_count = len(words)
has_punctuation = any(text.endswith(p) for p in ['.', ',', ',', '!', '?', '。', '!', '?', '।', '۔'])
is_long_enough = word_count > 4
return (word_count >= 5 and (has_punctuation or is_long_enough))
三条规则各有针对性:
| 规则 | 想留下 / 想丢掉 |
|---|---|
| 含数字就放行 | 价格、日期、版本号、错误码——这些常常是答案本身 |
| 词数 ≥ 5 | 丢掉 “Home” / “Login” / “Cookie” 这类导航碎词 |
| 标点表覆盖中日韩、天城文、阿拉伯语 | 非拉丁语系页面也能过滤 |
妙在哪:不需要任何模型、不需要 DOM 语义分析,几行纯规则就把导航栏和页脚噪声削掉大半。代价是会误伤——短的表格单元、只有几个词的关键结论也会被丢。
链接的清洗
get_navigable() 收集所有 <a href> 并过两道筛(sources/browser.py:455-474):
is_link_valid():URL 超过 72 字符直接丢、必须有 scheme 和 netloc、路径以纯数字结尾的丢、图片和元数据扩展名的丢(sources/browser.py:437-453)。clean_url():砍掉#锚点,query 参数只保留_skw=/q=/s=这三种可能承载语义的(sources/browser.py:420-435)。
「72 字符」是个硬编码的经验阈值——长 URL 往往是跟踪链接或深层动态页。副作用是合法的长文章链接也会被误杀。
4.6 导航决策:一页一次的「笔记 + 动作」
每到一个新页面,make_navigation_prompt() 拼出一份很长的 prompt(sources/agents/browser_agent.py:92-178),结构是:
【当前上下文】
页面 URL + 过滤后的 Markdown 正文
Allowed Navigation Links:未访问过的链接,带 [序号]
Inputs forms:页面上可填的表单字段
【指令】
1. 这页有用吗?有用就写 Note: <笔记>;没用就写 Error: <原因>
2. 去哪:说 "I will navigate to <完整URL>" 或说 GO_BACK
3. 要不要填表:用 [字段名](值) 的形式
4. 够了吗?够了说 REQUEST_EXIT
【示例】5 段示例:有用页 1 段、无用页 2 段、判定收工 1 段、填表 1 段
【收尾】重复一遍用户原始问题 + 已有的全部笔记
三个提示工程细节
① 笔记格式被强约束。 prompt 明写:
“Written as: ‘On <website URL>, <key fact 1>. <Key fact 2>.’ Avoid phrases like ‘the page provides’ or ‘I found that.’”
因为这些笔记最后要被拼起来交给 LLM 写结论,元叙述(“这个页面提供了……”)是纯噪声。
② 用户原问题在开头和结尾各出现一次。 结尾那句 “Remember, user asked: {user_prompt}”(sources/agents/browser_agent.py:172-173)是对付「中间内容太长,模型忘了初衷」的常规手段。
③ 明确禁止求助。 “Never stop or ask the user for help.” —— 自主浏览一旦停下来问人,整个流程就断了。
顺手记一个瑕疵:示例编号撞号
那 5 段示例的抬头依次写成 Example 1 / 2 / 3 / 3 / 4——第三个编号用了两次(sources/agents/browser_agent.py:146-170)。功能上无害(模型看的是内容不是序号),但和第 3 章 §3.8 里 planner 官方示例计划 id 撞号是同一类手抖,只是那一处会真的导致结果互相覆盖。
答案怎么被解析
parse_answer() 用一个状态机走行(sources/agents/browser_agent.py:220-235):
for line in lines:
if line == '' or 'action:' in line.lower():
saving = False # 空行或 "Action:" → 笔记段结束
if "note" in line.lower():
saving = True # 见到 note → 开始收
if saving:
buffer.append(line.replace("notes:", ''))
else:
links.extend(self.extract_links(line)) # 非笔记行才提取链接
笔记区里的链接不会被当成导航目标——这条区分很重要,否则模型在笔记里引用一个 URL,就会被误解成「我要去这里」。
4.7 表单填写
模型用 [字段名](值) 表达填表意图,正则 \[\w+\]\([^)]+\) 提取(extract_form,sources/agents/browser_agent.py:60-64)。
浏览器侧的完整链路(Browser.fill_form,sources/browser.py:752-769):
fill_form_inputs() 按名字找 xpath,按控件类型分别处理
| select → Select.select_by_visible_text,失败退回 by_value
| textarea → clear + send_keys
| file → 仅当是存在的绝对路径才 send_keys
| checkbox/radio → 当前状态 != 目标状态才 click
| 其他 → clear + send_keys
v
tick_all_checkboxes() 把页面上所有复选框都勾上(同意条款之类)
v
find_and_click_submission() 按 13 个候选词依次找按钮:
| login / submit / register / continue / apply /
| ok / confirm / proceed / accept / done / finish / start / calculate
v
wait_for_submission_outcome() 等 URL 变化或页面出现 "success"
字段定位靠 find_input_xpath_by_name,是子串匹配(sources/browser.py:676-680):
for field in inputs:
if name in field["text"]:
return field["xpath"]
宽松匹配是为了容忍模型把 user[username] 写成 username,代价是可能撞上别的字段。
表单里的输入元素是靠注入 JS 收集的(sources/web_scripts/find_inputs.js),它会递归进 shadow DOM,并为每个元素算出 XPath——这一点比纯 Selenium 选择器强。
填完之后不是直接继续,而是再问一次 LLM「表单提交成功了吗」(handle_form_filling → handle_update_prompt,sources/agents/browser_agent.py:294-327):
“Does the page answer the user's query now? Are you still on a login page or did you get redirected?”
把「提交是否生效」的判断交给模型看新页面内容,而不是靠 HTTP 状态码。
4.8 反检测:三层叠加
| 层 | 做什么 | 在哪 |
|---|---|---|
| ChromeDriver | undetected_chromedriver 替换标准 driver | create_undetected_chromedriver,sources/browser.py:238-258 |
| 启动参数 | --disable-blink-features=AutomationControlled、随机 UA、固定 1920x1080、每次新建随机 profile 目录 | create_chrome_options,sources/browser.py:164-236 |
| 页面内 JS | 删 cdc_* 变量、RTCPeerConnection 置 undefined、伪造 Notification 权限 | patch_browser_fingerprint(sources/browser.py:342-344)注入 sources/web_scripts/spoofing.js |
另外还有两处「装人」的行为:
human_scroll():随机滚动 13 次,每次 1501200 像素,40% 概率往回滚一点(sources/browser.py:333-340);go_to()在导航前后各 sleep 一个随机时长,最后调它(sources/browser.py:346-376)。- 导航后会
WebDriverWait等页面里不再出现 “checking your browser” 或 “captcha”,最多 10 秒(sources/browser.py:353-362)。
human_move()那个方法(sources/browser.py:325-331)是坏的——签名少了self,函数体里用了未定义的driver。代码里没有调用点,调了就会炸。
反过来:还要防网页搞你
每次 go_to() 成功后会顺手注入一段安全脚本(apply_web_safety,sources/browser.py:363、815-821),把硬件访问、自动播放、全屏、指针锁定、window.fetch、window.prompt 这几样能力就地掐掉。具体禁了哪些、每一条在防什么,见 06-sandbox-and-safety.md §6.4「第 ④ 层」——那里也说清了它和上表 spoofing.js 的分工(一个防网站认出 agent,一个防网站坑本机)。
这里只提对浏览环节的直接后果:禁掉 fetch 会让重度依赖它的 SPA 在注入后瘫掉,页面正文抓不到东西,导航循环只能把这一页判成 Error 换下一 个链接。
4.9 防打转的几个补丁
本地模型在导航里最容易犯的病是「原地打转」。代码里有四道补丁:
| 症状 | 补丁 | 位置 |
|---|---|---|
| 模型逐字重复上一轮答案 | 换成 stuck_prompt,把上次的话贴回去说「换一个」 | sources/agents/browser_agent.py:335-345、380-382 |
| 选了已访问过的链接 | select_link 跳过 search_history 里的 | 237-249 |
| 选中当前页 | 回退到搜索结果重选 | 399-403 |
| 链接打不开 | 提示「你之前选的站点不可用,考虑其他选项」 | 411-420 |
还有一个容易看漏的关键动作——每轮循环开头 self.memory.clear()(sources/agents/browser_agent.py:374):
while not complete and len(unvisited) > 0 and not self.stop:
self.memory.clear() # 只留 system prompt
...
也就是说浏览器 agent 每一页都是无记忆的:模型看到的只有系统提示 + 当前页 prompt。历史不靠上下文承载,全靠笔记数组 self.notes 承载,笔记在每次 make_navigation_prompt 时重新拼进去。
这是本项目里最值得学的一招:把长程记忆从「不断增长的对话历史」换成「一个由模型自己维护的结构化笔记列表」,上下文占用因此几乎恒定。
4.10 收尾:把笔记写成答案
循环退出后走 conclude_prompt()(sources/agents/browser_agent.py:259-272):笔记编号后拼成列表,要求模型
“Expand on the finding or step that lead to success… Do not give advices or try to answer the human. Just structure the AI finding in a structured and clear way. You should answer in the same language as the user.”
两条约束:不要自由发挥(只准整理笔记,不准补充模型自己的知识)、用用户的语言回答(路由阶段把请求翻成了英文,笔记也是英文,这里要翻回去)。
4.11 边界
- 完全没有视觉。 页面 canvas、图片里的信息、纯 JS 渲染出来但没进 DOM 文本的内容,一概看不见。
Browser.screenshot()拍的图只发给前端给人看(api.py:155-165),不进模型。 click_element里有个未导入的异常名。except ElementClickInterceptedException出现在sources/browser.py:491和649,但文件顶部只从 selenium 导入了TimeoutException和WebDriverException。真触发这个分支会变成NameError。- CAPTCHA 靠外部扩展。
stealth_mode打开时会引导用户手动装 nopecha 扩展(load_anticatpcha_manually,sources/browser.py:317-323),本体不解验证码。 - 一个 Browser 实例全局共享。 CLI 和 API 都只建一台 Chrome(
api.py:106-109),顶层 BrowserAgent 和 planner 里的 BrowserAgent 共用它,所以浏览操作天然串行。
4.12 本章代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 导航主循环 | sources/agents/browser_agent.py | BrowserAgent.process |
| 搜索词生成 | sources/agents/browser_agent.py | search_prompt |
| 导航 prompt | sources/agents/browser_agent.py | make_navigation_prompt、make_newsearch_prompt |
| 笔记/链接解析 | sources/agents/browser_agent.py | parse_answer、extract_links、extract_form |
| 填表流程 | sources/agents/browser_agent.py | handle_form_filling、handle_update_prompt |
| 结论生成 | sources/agents/browser_agent.py | conclude_prompt |
| 页面转 Markdown | sources/browser.py | Browser.get_text、is_sentence |
| 链接清洗 | sources/browser.py | get_navigable、is_link_valid、clean_url |
| 表单执行 | sources/browser.py | fill_form、fill_form_inputs、find_and_click_submission |
| 驱动创建与隐身 | sources/browser.py | create_driver、create_chrome_options、patch_browser_fingerprint |
| 网页安全注入 | sources/browser.py + sources/web_scripts/inject_safety_script.js | apply_web_safety |
| 输入元素扫描 | sources/web_scripts/find_inputs.js | findInputs、getXPath |
| 指纹伪装 | sources/web_scripts/spoofing.js | — |
| 搜索工具 | sources/tools/searxSearch.py | searxSearch.execute |
| 解析测试 | tests/test_browser_agent_parsing.py | — |