browser-use 把页面转 markdown 喂模型:取舍与失手点

2026-07-30

本文基于 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_servicetarget_id 时,走 dom_service.get_dom_tree(target_id=target_id, all_frames=None)。两条路会在返回的统计里留下不同的 method 值(enhanced_dom_treedom_service),排日志的时候这个字段能直接告诉你内容是从哪条路来的。

拿到树以后,HTMLSerializer 把它序列化回 HTML,convert_html_to_markdown 调 markdownify 转成 markdown,_preprocess_markdown_content 再洗一遍,最后 chunk_markdown_by_structure 切块。函数返回 (content, stats)stats 里带着 original_html_charsinitial_markdown_charsfiltered_chars_removedfinal_filtered_chars 四个数字。其中 HTML 字符数、初始 markdown 字符数、过滤后字符数会被拼成一句话放进提示词的 content_stats 里,跟着内容一起递给模型,模型自己也知道“这页被过滤过”;被过滤掉的字符数只在这次没有截断时才追加到那句话末尾,截断时它的位置会让给块序号和续读偏移。看统计时留个心:同一个字段在两种情况下不一定都出现。

组成部分它负责什么仓库位置你什么时候会碰到它
HTMLSerializerDOM 树序列化回 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_modelJSON 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 是删得最狠的一环,而且删的规则都写死在代码里。它跳过 stylescriptheadmetalinktitle 这几类元素——注意 title 也在里面,页面标题不会出现在 markdown 里。属性层面,_serialize_attributes 会跳过所有 data- 前缀的属性,理由写在注释里:现代 SPA 常把状态塞在 data-* 里,那是 JSON 负载不是内容。href 则由构造参数 extract_links 决定,默认 False 时直接不输出。

还有两条更“启发式”的规则值得记住。一是 code 标签:如果 style 里含 display:none,或者 id 里出现 bpr-guiddatastate 这几个片段,整个元素被丢掉。二是 imgsrcdata:image/ 开头的内联图片一律不要。这两条都是拿准确率换上下文,命中噪声时很有效,撞上正常内容时就是静默丢失。

反过来,这一环也在主动多留东西:DOCUMENT_FRAGMENT_NODE(shadow root)会被包成带 shadowroot 属性的 template 输出,iframeframecontent_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_000overlap_lines=5start_from_char=0。它分三段跑:先把内容解析成原子块,再贪心装块,最后补重叠前缀。

原子块类型是枚举出来的:HEADERCODE_FENCETABLELIST_ITEMPARAGRAPHBLANK。代码围栏从三个反引号开始一直吞到闭合围栏或文件结束,整段不可切;列表项会把后续的列表行(同级或更深缩进都算)和带缩进的续行吞进同一块,所以一整份列表通常是一个不可切的整体。表格的处理最有意思——表头行加分隔行合成一个块,之后每一行数据各自成块。这样切分点就只落在行与行之间,永远不会把一行表格劈成两半。

装块时有一条“优先在标题处切”的规则:

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_charnext_start_charchunk_indextotal_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。而调用方捕获 ValueErrorTypeError 之后,只打一条 warning,把 output_schema 置空,然后走自由文本路径。链路不会中断,你也不会收到异常——拿到的是一段散文而不是 JSON。这就是这一环最容易咬人的地方:由嵌套 Pydantic 模型导出的 schema 天然带 $defs$ref,直接递上去就会被拒。要结构化,得给一份自包含、把嵌套对象直接内联写开的 schema,顶层还必须是 type: "object" 且至少有一个属性,否则同样是 ValueError

类型映射也有几处特意放松。enum 一律解析成 str,注释说明了原因——用 Literal 更严格,但模型输出不稳定。可选字段的默认值分了好几档:显式给了 default 用它;标了 nullableNone;原始类型落到零值(字符串空串、数字 0、布尔 False);数组落到空列表;嵌套对象和枚举则把类型并上 None、默认 None。所以一个非必填的字符串字段,页面上没有时你拿到的是空串,跟“抽到了但内容为空”完全无法区分。要区分,就得自己把字段标成 nullable。

生成的模型基类配了 extra='forbid',多余字段会被挡掉。抽取成功后元数据装进 ExtractionResult,字段是 dataschema_usedis_partialsource_urlcontent_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 会踩是因为默认值为 Falsehref 在序列化阶段就被扔了,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 的浏览器会话层

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。