LangChain + Playwright 网页自动化测试:语义定位实战
2026/9/21 5:13:12 网站建设 项目流程

1. 为什么我最终选了 LangChain + Playwright 这套组合

网页自动化测试这件事,我做了快六年。早期用 Selenium 写脚本,后来换到 Playwright,再后来发现光有 Playwright 还不够——因为测试用例的维护成本实在太高了。一个电商后台,光是登录、下单、退款这三条链路,就能写出两百多个断言,每次前端改个按钮的 class 名,我就得跟着改一堆选择器。这种活干久了,人会麻木。

真正让我下决心引入 LangChain 的,是一次很典型的场景:产品经理临时要求验证"用户下单后,如果优惠券过期,页面提示文案是否正确"。这种需求用传统脚本写,得先定位优惠券元素、判断状态、再定位提示文案,前后至少半小时。但如果让大模型来理解页面结构,我只需要告诉它"检查过期优惠券的提示",它就能自己找到对应的 DOM 节点。这就是 LangChain 在这套组合里的核心价值——它不替代 Playwright 的执行能力,而是补上了"语义理解"这一环。

这套方案适合谁?如果你已经会写基本的 Playwright 脚本,但被频繁变动的选择器和断言逻辑折磨,那这套组合能帮你省下大量维护时间。如果你是完全的新手,也没关系,我会把每一步拆开讲,包括环境配置、代码结构、以及我踩过的那些坑。

提示:LangChain 和 LangGraph 不是替代关系。LangGraph 更适合有状态、多轮循环的 Agent 场景,而网页自动化测试这种"一次性任务链"用 LangChain 的 LCEL 表达式就够了,没必要上 LangGraph 增加复杂度。

2. 环境搭建与核心依赖选型

2.1 Python 版本与虚拟环境

我实测下来,Python 3.10 到 3.12 都能跑通这套组合,但 3.9 会在 LangChain 的某些依赖上出问题。建议直接用 3.11,兼容性最稳。虚拟环境用 venv 就行,没必要上 conda,除非你本地已经有 conda 环境不想折腾。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate

创建完环境后,先升级 pip,这一步很多人会跳过,但老版本 pip 在装 Playwright 的浏览器依赖时容易卡住。

pip install --upgrade pip

2.2 核心依赖安装

这套组合需要三个核心包:playwright、langchain、langchain-openai。注意 langchain-openai 是独立包,不要只装 langchain 就以为万事大吉。

pip install playwright langchain langchain-openai python-dotenv playwright install chromium

这里有个细节:playwright install chromium只装 Chromium 内核,如果你需要测试 Firefox 或 WebKit,得单独执行playwright install firefoxplaywright install webkit。我一般只装 Chromium,因为 90% 的网页测试场景用它就够了,而且能省下几百 MB 磁盘空间。

注意:如果你在公司内网环境,Playwright 下载浏览器二进制文件可能会超时。这时候可以设置环境变量PLAYWRIGHT_DOWNLOAD_HOST指向内部镜像源,或者手动下载后放到缓存目录。具体路径在 Windows 下是%USERPROFILE%\AppData\Local\ms-playwright,macOS 和 Linux 在~/.cache/ms-playwright

2.3 大模型接入配置

LangChain 本身不绑定任何模型,你需要自己接一个。我用的是 OpenAI 的接口,但国内也有不少兼容 OpenAI 协议的服务,配置方式基本一致。在项目根目录建一个.env文件:

OPENAI_API_KEY=你的密钥 OPENAI_BASE_URL=https://api.openai.com/v1

然后在代码里用python-dotenv加载。这里我要强调一点:不要把密钥硬编码在代码里,哪怕只是本地测试。我见过太多人把带密钥的代码直接传到公开仓库,结果被人扫到盗刷。用.env文件加上.gitignore是最基本的操作。

from dotenv import load_dotenv load_dotenv()

2.4 目录结构设计

我习惯把项目分成四个目录:pages放页面对象、chains放 LangChain 链、tests放测试用例、utils放工具函数。这种结构在项目变大后优势很明显——当你要改某个页面的定位逻辑时,只需要动pages目录下的文件,不会影响到测试用例本身。

project/ ├── pages/ │ └── login_page.py ├── chains/ │ └── element_finder.py ├── tests/ │ └── test_login.py ├── utils/ │ └── browser.py └── .env

3. 核心原理:LangChain 到底在自动化测试里做什么

3.1 传统 Playwright 脚本的痛点

先看一段典型的 Playwright 代码:

await page.click("button.submit-btn") await page.fill("#username", "testuser") await page.fill("#password", "testpass")

这段代码能跑,但问题在于button.submit-btn#username这些选择器是硬编码的。一旦前端重构,把submit-btn改成submit-button,脚本立刻报错。更麻烦的是,有些页面的按钮文案会变——今天叫"登录",明天叫"立即登录",后天叫"登 录"(中间多个空格),你根本没法用固定的选择器覆盖所有情况。

3.2 LangChain 的语义补位

LangChain 在这里的作用,是把"找元素"这件事从"精确匹配"变成"语义匹配"。具体做法是:先把页面的 DOM 结构简化成一段文本(去掉样式、脚本等无关内容),然后让大模型根据你的自然语言描述,返回最可能匹配的元素选择器。

举个例子,你告诉模型"找到登录按钮",它会在简化后的 DOM 里搜索包含"登录"、"登陆"、"sign in"等语义相关的元素,然后返回对应的 CSS 选择器或 XPath。这样即使按钮文案变了,只要语义没变,脚本依然能跑。

3.3 为什么不用纯大模型操作浏览器

有人会问:既然大模型这么聪明,为什么不直接让它控制浏览器?答案是成本和稳定性。大模型每次调用都有延迟和费用,如果每个点击、每个输入都走一遍模型,一个测试用例跑下来可能要几十秒甚至几分钟。而且大模型的输出有不确定性,同样的输入可能返回不同的选择器,这在测试场景里是致命的。

所以我的方案是:用 LangChain 做"元素定位"这一件事,其余操作全部交给 Playwright 原生 API。这样既享受了语义理解的好处,又保持了执行层的稳定和高效。

3.4 页面 DOM 简化的具体做法

直接把整个 HTML 丢给大模型是不现实的,一个现代网页的 DOM 动辄几千个节点,token 消耗巨大且容易超出上下文限制。我的做法是用 Playwright 的evaluate方法,在浏览器里先做一轮筛选:

simplified_dom = await page.evaluate("""() => { const interactiveTags = ['button', 'a', 'input', 'select', 'textarea']; const elements = document.querySelectorAll(interactiveTags.join(',')); return Array.from(elements).map(el => ({ tag: el.tagName.toLowerCase(), text: el.innerText?.trim().slice(0, 50) || '', id: el.id || '', className: el.className || '', type: el.type || '', placeholder: el.placeholder || '' })); }""")

这段代码只提取可交互元素,并且把每个元素的文本截断到 50 个字符,避免 token 浪费。实测下来,一个普通页面的简化 DOM 大概在 500 到 1500 token 之间,完全在可控范围内。

4. 完整代码实现:从零到跑通

4.1 浏览器管理工具类

先写一个浏览器管理的工具类,负责启动、关闭和页面创建。这里我用的是异步 API,因为 Playwright 的同步 API 在某些场景下会和 LangChain 的异步调用冲突。

from playwright.async_api import async_playwright class BrowserManager: def __init__(self, headless=True): self.headless = headless self.playwright = None self.browser = None async def start(self): self.playwright = await async_playwright().start() self.browser = await self.playwright.chromium.launch( headless=self.headless, args=['--disable-blink-features=AutomationControlled'] ) async def new_page(self): context = await self.browser.new_context( viewport={'width': 1280, 'height': 720}, user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ) return await context.new_page() async def close(self): if self.browser: await self.browser.close() if self.playwright: await self.playwright.stop()

--disable-blink-features=AutomationControlled这个参数很关键,它能去掉 navigator.webdriver 标志,避免被一些网站的反自动化机制识别。当然,这不是万能的,有些站点还有更复杂的检测手段,但对于大多数内部测试场景已经够用。

4.2 LangChain 元素定位链

这是整套方案的核心。我用 LCEL 表达式构建了一条链:输入是简化 DOM 和自然语言描述,输出是选择器。

from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个网页元素定位专家。根据用户描述和页面元素列表, 返回最匹配的元素选择器。优先使用 id,其次使用稳定的 class, 最后才用文本内容。返回 JSON 格式:{{"selector": "...", "reason": "..."}}"""), ("human", "页面元素:\n{elements}\n\n用户描述:{description}") ]) parser = JsonOutputParser() element_chain = prompt | llm | parser

temperature=0是为了让输出尽可能确定,减少随机性。模型我选的是 gpt-4o-mini,因为元素定位这个任务不需要太强的推理能力,mini 版本足够且便宜。如果你对成本不敏感,换成 gpt-4o 效果会更好,尤其是在处理复杂表单时。

4.3 页面对象封装

把定位逻辑封装成页面对象,这样测试用例里就不用关心具体选择器是怎么来的。

class SmartPage: def __init__(self, page, chain): self.page = page self.chain = chain async def get_elements(self): return await self.page.evaluate("""() => { const tags = ['button', 'a', 'input', 'select', 'textarea']; return Array.from(document.querySelectorAll(tags.join(','))) .map(el => ({ tag: el.tagName.toLowerCase(), text: (el.innerText || '').trim().slice(0, 50), id: el.id || '', className: typeof el.className === 'string' ? el.className : '', type: el.type || '', placeholder: el.placeholder || '' })); }""") async def smart_click(self, description): elements = await self.get_elements() result = await self.chain.ainvoke({ "elements": str(elements), "description": description }) selector = result["selector"] await self.page.click(selector) return selector async def smart_fill(self, description, value): elements = await self.get_elements() result = await self.chain.ainvoke({ "elements": str(elements), "description": description }) selector = result["selector"] await self.page.fill(selector, value) return selector

4.4 完整测试用例

把上面的组件串起来,写一个完整的登录测试:

import asyncio from utils.browser import BrowserManager from chains.element_finder import element_chain from pages.smart_page import SmartPage async def test_login(): manager = BrowserManager(headless=False) await manager.start() page = await manager.new_page() smart = SmartPage(page, element_chain) await page.goto("https://example.com/login") await page.wait_for_load_state("networkidle") await smart.smart_fill("用户名输入框", "testuser") await smart.smart_fill("密码输入框", "testpass") await smart.smart_click("登录按钮") await page.wait_for_timeout(2000) assert "dashboard" in page.url print("登录测试通过") await manager.close() if __name__ == "__main__": asyncio.run(test_login())

跑通这段代码,你就有了一个能自动理解页面语义的测试脚本。实测下来,从零开始到跑通第一个用例,熟练的话 5 分钟确实够,新手大概 15 到 20 分钟。

5. 实操中踩过的坑与排查技巧

5.1 模型返回的选择器不唯一

这是最常见的问题。比如页面上有两个"提交"按钮,模型可能返回了一个通用的button选择器,导致点错。我的解决办法是在 prompt 里明确要求返回唯一选择器,并且在代码里加一层校验:

count = await page.locator(selector).count() if count > 1: # 回退到更精确的定位 selector = f"{selector}:has-text('{description}')"

5.2 动态加载页面元素抓取不全

有些页面是懒加载的,初始 DOM 里没有目标元素。这时候需要先滚动页面触发加载:

await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") await page.wait_for_timeout(1000)

5.3 iframe 内的元素定位

如果目标元素在 iframe 里,page.evaluate是抓不到的。需要先切换到对应的 frame:

frame = page.frame_locator("iframe#content") elements = await frame.locator("button").all()

5.4 常见问题速查表

问题现象可能原因解决方法
模型返回空选择器DOM 简化后元素太少扩大提取范围,加入 div 等容器标签
点击后无反应元素被遮挡page.click(selector, force=True)
超时错误页面加载慢增加wait_for_load_state等待时间
token 超限DOM 太大只提取可见元素,过滤display:none
选择器失效前端改版重新跑一次定位链,让模型重新生成

提示:不要完全信任模型的输出。我一般会在关键操作前加一个截图,方便出问题时回溯。await page.screenshot(path="debug.png")这行代码救过我很多次。

6. 性能优化与成本控制

6.1 缓存选择器

同一个页面反复定位同一个元素是浪费。我加了一个简单的内存缓存:

self.selector_cache = {} async def smart_click(self, description): if description in self.selector_cache: selector = self.selector_cache[description] if await self.page.locator(selector).count() == 1: await self.page.click(selector) return selector # 缓存失效,重新定位 ...

这样在同一个测试会话里,第二次点击同一个按钮就不会再调用模型了。

6.2 批量定位

如果一个页面要操作多个元素,可以一次性把所有描述传给模型,让它返回一个映射表,而不是逐个调用。这样能把 N 次模型调用压缩成 1 次,成本直接降一个数量级。

6.3 模型选型建议

模型定位准确率单次成本适用场景
gpt-4o-mini85%极低常规页面,元素特征明显
gpt-4o95%中等复杂表单,动态内容
本地小模型60-70%免费内网环境,对准确率要求不高

我个人的建议是:日常回归测试用 mini,关键链路用 4o,本地模型只在完全离线场景下考虑。

7. 后续扩展方向

这套框架跑通后,可以往几个方向扩展。一是接入 pytest,把每个测试用例写成独立的 test 函数,用 pytest-asyncio 管理异步执行。二是加入视觉比对,用 Playwright 的截图功能配合图像差异算法,检测页面渲染是否异常。三是把定位链换成 LangGraph,支持多轮交互——比如模型第一次没找到元素时,自动触发"滚动页面再找一次"的循环逻辑。

我在实际项目里还加了一个"自愈"机制:当某个选择器连续三次定位失败时,自动触发模型重新生成,并把新选择器写回缓存。这个机制让脚本的维护频率从每周一次降到了每月一次,省下来的时间相当可观。

最后分享一个小技巧:如果你在调试阶段,把headless设为False,能看到浏览器实际操作过程,定位问题时非常直观。等脚本稳定后再切回True,跑回归测试时速度会快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询