browser-use 把页面转 markdown 喂模型:取舍与失手点
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
抽取出来的东西不对,八成不是模型没读懂,而是内容在进模型之前就已经被删掉了。 browser-use 的 extract 动作不是把页面 HTML 原样塞给模型,它中间串了四道加工:DOM 树序列化成 HTML、HTML 转 markdown、markdown 二次过滤、按结构切块。每一道都在主动丢东西——丢得对,上下文省一大截;丢得不对,模型面前那份 markdown 里压根没有你要的字段,它只能诚实地告诉你“页面上没有”。
所以排查顺序应该反过来:先确认内容还在不在,再去调提示词。这篇就沿着仓库里的真实调用链走一遍,把每一环删了什么、为什么删、什么时候会误删说清楚。
关于上下文预算怎么分配、长文档按什么粒度切块这类通用方法,上下文工程 和 RAG 分块策略 已经讲过;抽取结果作为中间产物怎么落盘、怎么追溯,可以看 Agent 中间产物罗盘。本篇不重复这些,只盯 browser-use 这一条具体实现上的取舍。
一、这条链路的全貌:谁在干什么活
入口是 browser_use/dom/markdown_extractor.py 里的 extract_clean_markdown。它有两条取树的路径:传 browser_session 时,走 DOMWatchdog,优先复用已缓存的 enhanced_dom_tree,没有才调 _build_dom_tree_without_highlights 现建;传 dom_service 加 target_id 时,走 dom_service.get_dom_tree(target_id=target_id, all_frames=None)。两条路会在返回的统计里留下不同的 method 值(enhanced_dom_tree 或 dom_service),排日志的时候这个字段能直接告诉你内容是从哪条路来的。
拿到树以后,HTMLSerializer 把它序列化回 HTML,convert_html_to_markdown 调 markdownify 转成 markdown,_preprocess_markdown_content 再洗一遍,最后 chunk_markdown_by_structure 切块。函数返回 (content, stats),stats 里带着 original_html_chars、initial_markdown_chars、filtered_chars_removed、final_filtered_chars 四个数字。其中 HTML 字符数、初始 markdown 字符数、过滤后字符数会被拼成一句话放进提示词的 content_stats 里,跟着内容一起递给模型,模型自己也知道“这页被过滤过”;被过滤掉的字符数只在这次没有截断时才追加到那句话末尾,截断时它的位置会让给块序号和续读偏移。看统计时留个心:同一个字段在两种情况下不一定都出现。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
HTMLSerializer | DOM 树序列化回 HTML,顺手裁掉噪声标签与属性 | browser_use/dom/serializer/html_serializer.py | 链接、图片、data-* 里的内容不见了 |
convert_html_to_markdown | 调 markdownify,固定一组转换参数 | browser_use/dom/markdown_extractor.py | 标题层级、列表符号、转义行为不如预期 |
_preprocess_markdown_content | 删 JSON 血块、压空行、扔空白行 | browser_use/dom/markdown_extractor.py | 正文里某段长内容整段消失 |
chunk_markdown_by_structure | 按块类型切分,带重叠与表头续传 | browser_use/dom/markdown_extractor.py | 长页面只抽到前一截,要续读 |
MarkdownChunk | 分块的数据结构(偏移量、是否还有下一块) | browser_use/dom/views.py | 要自己算续读位置 |
extract 动作 | 编排全流程、拼提示词、决定走结构化还是自由文本 | browser_use/tools/service.py | 调参、看统计、处理截断 |
schema_dict_to_pydantic_model | JSON Schema 转运行时 Pydantic 模型 | browser_use/tools/extraction/schema_utils.py | 结构化输出报错或被降级 |
ExtractionResult | 抽取元数据(含 is_partial 等) | browser_use/tools/extraction/views.py | 判断这次结果是否只是半截 |
ExtractAction | 动作参数模型与字段说明 | browser_use/tools/views.py | 想知道模型能自己调哪些开关 |
二、裁剪环:谁在删你的内容
HTMLSerializer 是删得最狠的一环,而且删的规则都写死在代码里。它跳过 style、script、head、meta、link、title 这几类元素——注意 title 也在里面,页面标题不会出现在 markdown 里。属性层面,_serialize_attributes 会跳过所有 data- 前缀的属性,理由写在注释里:现代 SPA 常把状态塞在 data-* 里,那是 JSON 负载不是内容。href 则由构造参数 extract_links 决定,默认 False 时直接不输出。
还有两条更“启发式”的规则值得记住。一是 code 标签:如果 style 里含 display:none,或者 id 里出现 bpr-guid、data、state 这几个片段,整个元素被丢掉。二是 img:src 以 data:image/ 开头的内联图片一律不要。这两条都是拿准确率换上下文,命中噪声时很有效,撞上正常内容时就是静默丢失。
反过来,这一环也在主动多留东西:DOCUMENT_FRAGMENT_NODE(shadow root)会被包成带 shadowroot 属性的 template 输出,iframe 与 frame 的 content_document 子节点也会被序列化进去。也就是说 shadow DOM 和 iframe 里的文字是能进 markdown 的,这跟只取 outer HTML 的做法不一样。
表格还有一段专门处理。_serialize_table_children 会检查 table 是否已有 thead:没有、而第一个 tr 里含 th 单元格,就把这一行包进 <thead>,其余行包进 <tbody>。目的很直接——markdownify 需要这个结构才能产出带分隔行的 markdown 表格。如果站点的表头用的是 td 而不是 th,这个补救不会触发,你拿到的就是一张没有表头的表。
到 markdownify 这一步,参数是固定的:
content = md(
page_html,
heading_style='ATX', # Use # style headings
strip=['script', 'style'], # Remove these tags
bullets='-', # Use - for unordered lists
code_language='', # Don't add language to code blocks
escape_asterisks=False, # Don't escape asterisks (cleaner output)
escape_underscores=False, # Don't escape underscores (cleaner output)
escape_misc=False, # Don't escape other characters (cleaner output)
autolinks=False, # Don't convert URLs to <> format
default_title=False, # Don't add default title attributes
keep_inline_images_in=_keep_inline_images_in, # Include image src URLs when extract_images=True
)
三个 escape_* 全关掉,是拿“markdown 语法严格性”换“字符更干净、token 更少”。keep_inline_images_in 的取值来自 extract_images:为真时是 ['td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],为假时是空列表。代码注释解释了为什么只列这几个标签——它们是 markdownify 会设置内联上下文的元素,在内联上下文里 img 会被压成 alt 文本。所以“要不要图片地址”这个开关,实际影响最明显的位置是表格单元格和标题里的图。
最后是 _preprocess_markdown_content。它连着三条正则删 JSON 血块,把 4 个以上连续换行压到 3 个,再逐行扫一遍扔掉纯空白行。逐行那段有个细节值得抄走:
if len(stripped) > 100 and stripped[0] in '{[':
try:
json.loads(stripped)
continue
except ValueError:
pass
只看首字符不够,因为 markdown 链接和图片也以 [ 开头,所以必须真去 json.loads 试一次,解析成功才当成 SPA 状态块删掉。这是个成本换正确性的选择:多花一次解析,避免把长链接行误删。
三、分块环:为什么表头能跟着走
chunk_markdown_by_structure 的默认签名是 max_chunk_chars=100_000、overlap_lines=5、start_from_char=0。它分三段跑:先把内容解析成原子块,再贪心装块,最后补重叠前缀。
原子块类型是枚举出来的:HEADER、CODE_FENCE、TABLE、LIST_ITEM、PARAGRAPH、BLANK。代码围栏从三个反引号开始一直吞到闭合围栏或文件结束,整段不可切;列表项会把后续的列表行(同级或更深缩进都算)和带缩进的续行吞进同一块,所以一整份列表通常是一个不可切的整体。表格的处理最有意思——表头行加分隔行合成一个块,之后每一行数据各自成块。这样切分点就只落在行与行之间,永远不会把一行表格劈成两半。
装块时有一条“优先在标题处切”的规则:
best_split = len(current_chunk)
for j in range(len(current_chunk) - 1, 0, -1):
if current_chunk[j].block_type == _BlockType.HEADER:
prefix_size = sum(b.char_end - b.char_start for b in current_chunk[:j])
if prefix_size >= max_chunk_chars * 0.5:
best_split = j
break
倒着找最后一个标题块,只有当它前面的内容已经达到限额一半以上时才在那儿切——否则宁可硬切,也不产出一个太小的块。单个块自己就超限时直接放过,注释里明确写着这是软限制。
重叠前缀那段解决的是跨块表格。如果新块的第一个块是 TABLE 类型,而之前记录过表头,就把表头行拼在前面,再把上一块末尾几行去重后接上;跨 3 块以上的长表格,表头变量只在遇到新的表头加分隔行时才覆盖,所以第三、第四块也还带着表头。这是“保留结构”这一侧最实的一处投入:模型不会读到一堆无名的竖线分隔行。
extract 动作那边把限额写成常量 MAX_CHAR_LIMIT = 100000,并且只取 chunks[0]。这意味着一次调用就是一块,块的 overlap_prefix 会被拼到内容前面,has_more 为真时统计里会多出 truncated_at_char、next_start_char、chunk_index、total_chunks。续读靠模型下一次传 start_from_char。分页抓取时还有 already_collected 参数,把已收集的名称或 URL 传回去,提示词里会生成一段 already_collected 让模型跳过重复项——代码里对这个列表做了截断,只取前 100 条。
四、约束环:结构化输出的静默降级
给了 output_schema(或由 Agent 注入 extraction_schema),extract 会先试着把它转成运行时 Pydantic 模型。转换器在 schema_utils.py,第一件事就是查关键字黑名单:
_UNSUPPORTED_KEYWORDS = frozenset(
{
'$ref',
'allOf',
'anyOf',
'oneOf',
'not',
'$defs',
'definitions',
'if',
'then',
'else',
'dependentSchemas',
'dependentRequired',
}
)
命中任何一个就抛 ValueError。而调用方捕获 ValueError 与 TypeError 之后,只打一条 warning,把 output_schema 置空,然后走自由文本路径。链路不会中断,你也不会收到异常——拿到的是一段散文而不是 JSON。这就是这一环最容易咬人的地方:由嵌套 Pydantic 模型导出的 schema 天然带 $defs 和 $ref,直接递上去就会被拒。要结构化,得给一份自包含、把嵌套对象直接内联写开的 schema,顶层还必须是 type: "object" 且至少有一个属性,否则同样是 ValueError。
类型映射也有几处特意放松。enum 一律解析成 str,注释说明了原因——用 Literal 更严格,但模型输出不稳定。可选字段的默认值分了好几档:显式给了 default 用它;标了 nullable 用 None;原始类型落到零值(字符串空串、数字 0、布尔 False);数组落到空列表;嵌套对象和枚举则把类型并上 None、默认 None。所以一个非必填的字符串字段,页面上没有时你拿到的是空串,跟“抽到了但内容为空”完全无法区分。要区分,就得自己把字段标成 nullable。
生成的模型基类配了 extra='forbid',多余字段会被挡掉。抽取成功后元数据装进 ExtractionResult,字段是 data、schema_used、is_partial、source_url、content_stats。其中 is_partial 直接取自这次是否截断——下游想判断“这份结果是不是只看了半页”,认这个字段就够了,不用去猜。
五、边界与代价:它明确不管的事
第一,它不判断内容重要性。整条链路里没有任何“这段是正文、那段是导航”的语义判断,全是标签级和字符级规则。侧边栏、页脚、推荐位只要不落在跳过名单里,就会照样占字符、照样进块。省上下文靠的是删噪声形态,不是删低价值内容。
第二,视觉与坐标全部丢失。转成 markdown 之后没有位置、没有可见性、没有层叠关系,动作里那句说明写得很直白——这个动作拿不到可交互元素。要点按东西,得走别的路径;extract 的产物只用于读。
第三,缓存树意味着时效性由上游负责。走 browser session 那条路时会优先复用已缓存的 enhanced_dom_tree。页面刚发生异步变化时,抽到的可能是变化前的树。异步内容抽不到,先怀疑时序,别急着改提示词。
第四,也是最该认真对待的一条:这类工具驱动的是真实浏览器,可能带着你的登录态在真实账号上操作真实站点。抽取环节裁掉 data-* 和 JSON 状态块,减少的是噪声,不等于减少了敏感面——登录后页面上的姓名、地址、订单号、余额会原样进 markdown,再原样进模型请求,超过长度阈值时还会被写进文件系统落盘。访问第三方站点前先看清对方的使用条款;目标站的验证码与反自动化机制是它的正常防护,遇到就停下换人工,本文不提供任何绕过手段;高频自动化访问带来的账号被判异常风险,只能靠降频、限定范围和用独立测试账号来控制,而不是靠技术手段掩盖。涉及模型服务商时,各家的接口规则与内容策略不同且会调整,以官方最新说明为准。
六、上手与避坑清单
结构化输出没生效,先查 schema 关键字。 会踩是因为失败路径只有一条 warning,表现是“模型不听话”而不是“报错”。避法:把要用的 JSON Schema 先拿去过一遍 schema_dict_to_pydantic_model,能转过再上线;由 Pydantic 导出的 schema 记得先把 $defs / $ref 展开内联。
要 URL 就显式开 extract_links。 会踩是因为默认值为 False,href 在序列化阶段就被扔了,markdown 里只剩锚文本,看上去像“这个页面没链接”。避法:查链接类字段前先确认这个开关,动作的字段说明里也写了它是为省 token 才默认关。
表格里的图片地址缺失,是 extract_images 的事。 会踩是因为空的 keep_inline_images_in 会让内联上下文里的 img 退化成 alt 文本。避法:需要图片地址时显式打开;顺便知道有一组图片关键词会自动开启它,所以你的查询措辞也可能悄悄改变输出形态。
内容“整段消失”先看统计三段数。 会踩是因为 JSON 清洗和空行压缩都是无声的。避法:读那句 HTML 字符数、初始 markdown 字符数、过滤后字符数的对照——过滤掉的比例异常高,问题就在清洗那一环,不在模型。
长页面别指望一次抽完。 会踩是因为动作只取第一块,而截断信息藏在统计里。避法:别去猜,认两个来自块的 has_more 的落地字段——统计里的 next_start_char(续读时原值传回 start_from_char)和结构化元数据里的 is_partial;块之间有重叠行,跨页去重再叠上 already_collected。
别拿整页 markdown 当唯一手段。 会踩是因为 extract 每次都要过一遍模型。同一个仓库的 browser_use/tools/service.py 里还注册了按文本搜索和按 CSS 选择器查元素两个动作,动作说明里标着零 LLM 成本。只想确认某段文字在不在、想数一数有多少条,用它们比抽一遍整页划算。关于把模型调用挪出确定性可完成的环节,Agent 上下文预算 里有更系统的算法。
收束一句:这条链路的设计取向很清楚——用一堆写死的标签级规则把页面压到模型读得动的体积,用块类型和表头续传把结构损失控制在能接受的范围,代价是所有“删错了”都不报错。所以自检顺序固定为:统计三段数是否正常 → 目标字段在 markdown 里还在不在 → 是不是被截断了 → schema 有没有被静默降级 → 最后才是提示词。想继续往下读,从 browser_use/dom/serializer/html_serializer.py 开始比从提示词开始有用得多,它决定了后面所有环节能看到什么。项目采用 MIT 许可证,仓库地址是 https://github.com/browser-use/browser-use ,上面这些规则你都能当场翻开核对。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 怎么判断按钮能不能点 和 browser-use 的浏览器会话层。