让 AI"看"网页这件事,我最初是被一个真实需求逼出来的。某天我在做一个数据可视化大屏项目,想请 Cursor 帮忙分析页面配色和布局,结果它读到的 HTML 里只有一个空壳 root 节点,所有内容都是打包后的 JS 动态渲染的。Cursor 只能"猜"页面长什么样,根本谈不上分析。那一刻我意识到,AI 编码助手缺的不是推理能力,而是眼睛。后来我通过网页渲染 API 把动态页面转成渲染后的文本和截图,再用 MCP 协议把这项能力暴露给 Claude / Cursor,才算真正让 AI "看"到了网页。这篇文章完整记录我从选型、实现到接入的全过程,也把踩过的坑一并交代清楚,适合所有想给 AI 补上视觉能力的开发者参考。
1. 让 AI"睁眼"的痛点:静态源码与真实页面的鸿沟
1.1 AI 默认只能"读源码",但很多页面根本没有源码可用
大多数 AI 编码工具抓取网页的方式,本质上和写爬虫差不多:发一个 HTTP 请求,拿回 HTML,然后从 HTML 里提取信息。这个思路对付十年前的服务端渲染页面没问题,因为那时候页面内容都嵌在 HTML 里。但放到今天,情况完全变了。
Vue、React 这类前端框架普及之后,页面变成了"骨架 + 脚本"的模式。服务器返回的 HTML 往往只有一个<div id="app"></div>,真正的内容要等浏览器执行完 JavaScript 之后才被渲染进 DOM。AI 如果只拿到源码,看到的就是一个空壳,再加上一堆混淆过的 bundle 文件,别说分析视觉设计了,连提取一篇文章的正文都做不到。
我试过最典型的例子是某文档站的 API 文档页。用 requests 直接请求,返回的标题和 description 正常,但正文区域是空的,整个文档是前端路由渲染的。这种场景下,如果工具链缺了渲染能力,AI 再强也巧妇难为无米之炊。
1.2 必须"渲染后"才能解决的典型任务
我梳理了一下,以下三类任务几乎必须依赖渲染后的页面状态,单靠源码无法完成。
第一类是动态内容抽取。包括文档站、数据报表、图表大屏,包括需要点击或滚动才加载的懒加载列表。虽然有些接口可以直接返回数据,但如果对方没有提供 API,或者接口参数不明朗,最稳妥的方式就是无头浏览器渲染后提取 DOM 文本。
第二类是视觉设计分析。分析竞品的配色、字体、间距、卡片布局,判断某个页面的设计风格,这些信息只存在于渲染后的像素里。CSS 虽然写在样式文件里,但经过变量计算、媒体查询、动画状态之后,最终效果只有浏览器渲染才知道。
第三类是前端改动验证。改完代码想看两个版本的页面差异,或者确认某个样式的实际效果。AI 如果只能看代码 diff,很难判断视觉层面是否符合预期,这时候一张渲染后的截图比任何描述都直观。
1.3 MCP 在中间扮演的角色
有了渲染能力还不够,更关键的问题是:Claude / Cursor 怎么调用它?总不能让 AI 每次都用命令行跑脚本吧,那样不灵活,而且 AI 无法解析输出。
这里就需要 MCP(Model Context Protocol)登场。它的定位很清晰:给 AI 应用提供一套标准化的工具调用接口。整个体系里有三个角色:
- MCP 客户端:也就是 Claude、Cursor 这类 AI 应用;
- MCP 服务器:负责暴露具体的工具,比如渲染网页、截图、读取文件;
- 工具描述:用 JSON Schema 描述每个工具的功能、参数和返回值,让 AI 知道什么时候该调哪个、参数填什么。
我的架构是这样拆的:最底层是无头浏览器渲染服务,负责真正把 URL 转成文本和截图;中间层是 MCP 服务器,把渲染服务封装成render_webpage和screenshot_webpage两个工具;最上层是 Claude / Cursor。AI 通过工具名和描述理解能力边界,按需调用,拿到结构化结果后再基于它做分析和回答。整个调用链路清晰,调试也方便。
2. 技术选型拆解:渲染服务怎么搭,MCP 怎么接
2.1 渲染层:无头浏览器方案对比
扒开需求看本质,这套方案的核心就一句话:需要一个能执行 JavaScript 的浏览器内核。我对比过三个主流方案。
Puppeteer 是 Node.js 生态的老牌方案,API 成熟,资料最多,但默认绑定 Chromium,多浏览器支持需要额外配置。Selenium 更偏向自动化测试,分布式能力强,但 API 略显老气,在现代异步场景下写起来不够舒服。Playwright 是后起之秀,API 设计现代,原生支持异步,内置等待机制,而且对 Chromium、WebKit、Firefox 一视同仁。
如果让我给一个选型结论:新项目优先选 Playwright。理由很实际——它内置了wait_until="networkidle"这类智能等待条件,能大幅减少"页面还没加载完就执行下一步"的问题;.screenshot()、.content()、.inner_text()这些 API 语义清晰,不需要自己造轮子;而且浏览器实例复用的管理方式也灵活。
我最终选了 Python 生态,原因是写 MCP 服务器时 Python 的 SDK 支持最顺手,而且 FastAPI 起个 HTTP 服务非常快。如果你偏好 Node.js,Playwright 的 Node 版本表现同样优秀,架构上没有什么差别。
2.2 部署形态:本地服务与云渲染 API 的取舍
渲染服务跑在哪里,决定了整套方案的隐私性、成本和维护复杂度。我对比过两条路线,各有利弊。
本地渲染服务的优势是隐私安全。页面 URL、Cookie、登录态这些敏感信息完全不出本机,适合处理内网页面和需要登录的后台系统。成本上只要付自己机器的电费,免费。但缺点也很明显:无头浏览器体积大,首次安装 Chromium 要下载接近两百兆,而且服务器内存和并发需要自己管。
云渲染 API 的好处是开箱即用,不用维护浏览器环境,并发能力通常更强,按量计费也适合高频调用。但问题在于:你的 URL 会经过第三方服务,敏感页面不适合走这条路;而且真实的现代网站反爬策略多,云服务商的 IP 段容易被限制。
我的选择是本地起一个 FastAPI 渲染服务。理由有三:一是这套方案主要用于开发场景,频率不高,本地足够;二是页面里往往带着个人登录态,我不放心交给外部服务;三是本地服务调试毫无障碍,出问题能直接看日志。如果你要做大规模生产级应用,再考虑云渲染也不迟。
2.3 传输方式:stdio 与 HTTP 的选择
MCP 服务器和客户端之间的通信方式,主要有三种。本地场景最常用的是 stdio:客户端启动一个子进程,通过标准输入输出传输 JSON 消息。它的好处是零网络配置,不暴露端口,对 Claude Desktop 和 Cursor 这种桌面应用来说最省事。
SSE 和 Streamable HTTP 适合远程部署,服务器跑在独立进程里,客户端通过 HTTP 连接。这种方式的好处是渲染能力可以做成多人共享的服务,坏处是多了一层网络配置和鉴权,本地调试时要多处理跨域、端口、认证这些问题。
我的建议是:本地开发全部用 stdio,远程共享再考虑 HTTP。文章后面的配置示例也基于 stdio 模式,因为这是体验最丝滑的路线。MCP 服务器代码里最后一行mcp.run(transport="stdio")就够了,不需要额外定义端口。
3. 动手实现渲染服务:基于无头浏览器的截图与 DOM 提取
3.1 环境准备
先搭基础环境。我用的是 Python 3.11,核心依赖是 Playwright、FastAPI、Uvicorn。装好之后千万别忘了执行playwright install chromium,这一步会下载浏览器内核,缺少它,后面所有代码都会报"浏览器启动失败"。
在 Linux 服务器上还要额外安装一些系统依赖库,否则 Chromium 启动时会提示缺libnss3、libatk之类的动态库。这类问题最典型的症状是容器里明明 pip install 都成功了,但一启动浏览器就崩溃,报错信息全是底层库缺失。解决办法是以最小化安装方式跑一遍playwright install-deps,基本能解决九成问题。
3.2 核心代码:一个能渲染 URL 的 HTTP 服务
渲染服务的核心逻辑不复杂:接收 URL,用无头浏览器打开,等待网络请求趋于稳定,提取标题、正文文本、DOM 源码,并截一张图,然后以 JSON 格式返回。我贴一份简化后可用的代码。
import asyncio import base64 from fastapi import FastAPI from playwright.async_api import async_playwright app = FastAPI() _browser = None _pw = None _semaphore = asyncio.Semaphore(4) async def get_browser(): global _pw, _browser if _browser is None: _pw = await async_playwright().start() _browser = await _pw.chromium.launch() return _browser @app.post("/render") async def render(url: str, wait_ms: int = 3000, width: int = 1280, height: int = 800, full_page: bool = False): browser = await get_browser() page = await browser.new_page( viewport={"width": width, "height": height} ) try: await page.goto(url, wait_until="networkidle", timeout=20000) await page.wait_for_timeout(wait_ms) title = await page.title() content = await page.content() text = await page.evaluate("document.body.innerText") shot = await page.screenshot(type="jpeg", quality=80, full_page=full_page) return { "title": title, "text": text[:8000], "dom": content[:15000], "screenshot": base64.b64encode(shot).decode(), } except Exception as exc: return {"error": str(exc)} finally: await page.close()几个关键点值得展开说明。wait_until="networkidle"的意思是等网络连接数降为零才算加载完成,它在处理大多数 SPA 页面时比load事件可靠得多,因为load只代表 HTML 和相关资源加载完,不代表 JS 跑完了。不过networkidle在实时刷新页面(比如有 WebSocket 推送或轮询接口的页面)上可能永远不触发,所以我加了wait_for_timeout作为兜底,强制多等几秒,两者配合能覆盖绝大多数情况。
screenshot我特意用了 JPEG 而不是 PNG。原因马上会说,JPEG 在截图上的体积比 PNG 小一个数量级,对后续 MCP 消息传输更友好。full_page参数控制是否截完整页,默认关闭,因为超长页面截图不仅文件大,AI 看的时候也很难聚焦。
text和dom我都做了截断。这段代码里text只保留前 8000 字符,dom保留前 15000 字符,后面接入 MCP 时还要再截一遍。原始页面文本可能几十万字,全塞给 AI 只会浪费上下文窗口。
3.3 容易被忽略的优化细节
第一个优化是浏览器实例复用。如果每个请求都launch()一次浏览器,耗时大概 3 到 5 秒,而且内存反复占用释放,高并发下基本不可用。我在全局缓存了浏览器实例,请求只负责new_page和close,这样单次渲染的耗时能压到 1 秒左右。
第二个优化是并发控制。无头浏览器不是无限制的,一个 Chromium 实例同时开太多页面容易崩。我用了信号量限制并发为 4,场景上完全够用,同时避免把自己机器拖垮。
第三个细节是超时和错误兜底。每个页面都有超时限制,因为有些站点会一直处于 pending 状态。except里把异常信息原样返回,这样 MCP 层能把失败原因清晰传给 AI,AI 才知道是页面打不开还是渲染超时,而不是看到一堆堆栈。
启动服务就一条命令:uvicorn render_server:app --host 127.0.0.1 --port 8000。启动之后可以用curl简单验证一下返回结构,确认截图 base64 是否能被解码,再做 MCP 接入。
4. MCP 服务器编写:把渲染能力封装成三个核心工具
4.1 用 FastMCP 快速搭建服务器
渲染服务本身不服务于 AI,它只是一套 HTTP 接口。要让它被 Claude / Cursor 使用,还需要一层 MCP 封装。我用的是 MCP 的 Python SDK,FastMCP这个类把工具注册、参数解析、协议通信都封装好了,只需要定义普通函数,加一个装饰器,工具就自动能被客户端发现。
import httpx from mcp.server.fastmcp import FastMCP mcp = FastMCP("web-renderer") RENDER_API = "http://127.0.0.1:8000/render" @mcp.tool() async def render_webpage(url: str, wait_ms: int = 3000) -> str: """渲染目标网页并返回页面标题与正文文本,适合内容抽取和页面信息分析。""" async with httpx.AsyncClient() as client: resp = await client.post(RENDER_API, json={"url": url, "wait_ms": wait_ms}) data = resp.json() if "error" in data: return f"渲染失败: {data['error']}" return ( f"页面标题: {data['title']}\n\n" f"正文内容:\n{data['text'][:4000]}" ) @mcp.tool() async def screenshot_webpage(url: str, wait_ms: int = 3000, full_page: bool = False) -> str: """渲染目标网页并返回 JPEG 截图的 base64 数据,适合视觉设计和布局分析。""" async with httpx.AsyncClient() as client: resp = await client.post(RENDER_API, json={"url": url, "wait_ms": wait_ms, "full_page": full_page}) data = resp.json() if "error" in data: return f"截图失败: {data['error']}" return f"data:image/jpeg;base64,{data['screenshot']}" if __name__ == "__main__": mcp.run(transport="stdio")这段代码比我实际用的精简了一些,但结构完全一致。注意两个工具的分工:render_webpage返回纯文本,token 消耗低,适合内容抽取、文档摘要、页面信息提取;screenshot_webpage返回图片数据,token 消耗高,但信息密度高,适合视觉分析、布局评估、元素定位。
4.2 工具描述是给 AI 的"说明书"
写 MCP 工具时最容易忽略的一点是:工具描述本身也是输入给 AI 的提示词。AI 看到工具列表之后,完全靠函数名、参数名和 docstring 来判断何时使用。描述太模糊,AI 可能根本不知道要调它;描述太啰嗦,又挤占上下文窗口。
我调试过一段时间之后总结的经验是:描述里一定要写清楚"输入是什么、输出是什么、适合什么场景"。比如screenshot_webpage的描述里明确写了"适合视觉设计和布局分析",AI 在做网页风格评估时就会优先选它;而render_webpage写了"适合内容抽取",遇到新闻正文提取任务时模型就会自觉调用。这种意图对齐很重要,否则模型经常选错工具。
参数命名也有讲究。我用wait_ms而不用模糊的delay,用full_page而不是full。参数语义越明确,模型填错值的概率越低。我在工具里给参数都设置了默认值,降低调用复杂度,也避免模型漏填必填参数导致报错。
4.3 本地调试用 MCP Inspector
MCP 服务器写完,没必要直接拿到 Cursor 里验证,先用官方的 MCP Inspector 做一轮调试更高效。启动命令大概是:
npx @modelcontextprotocol/inspector python mcp_server.pyInspector 会拉起服务器,并提供交互面板。你可以在里面手填 URL 调用render_webpage,查看返回内容是否完整,也能直接看到 JSON Schema 是否正确生成。我每次改动工具之后都会先跑一遍 Inspector,确认消息能正常往返,再交给 AI 客户端使用。这一步能省去很多来回折腾的时间。
5. Claude 与 Cursor 的接入配置:从配置到首次实测
5.1 Cursor 里的接入步骤
Cursor 的 MCP 配置入口在 Settings 里的 Integrations 或 Agents 相关面板,不同版本位置略有差异,但逻辑一致:添加一个 MCP 服务器,指定类型、命令和参数。我用的是 stdio 类型,命令是 Python,参数是服务器脚本的路径。
添加完后 Cursor 会自动拉起进程并探测工具列表。如果一切正常,你会在 MCP 服务器列表里看到连接状态为已连接,工具列表出现render_webpage和screenshot_webpage两个条目。这时候在对话中让 Cursor "打开某个网页看看" 或 "渲染某个 URL",它就会调用工具并基于结果回答。
注意一点:Cursor 不同版本对 MCP 工具的调用策略有差异,有些版本需要手动确认"允许使用这些工具",或者弹窗询问是否执行。首次调用时留意界面提示,别以为工具加载失败。
5.2 Claude Desktop 与 Claude Code 的配置
Claude Desktop 走的是配置文件方式。编辑 MCP 配置文件,填上同样的 stdio 类型信息,保存后重启客户端。配置内容大致是:
{ "mcpServers": { "web-renderer": { "command": "python", "args": ["/绝对路径/mcp_server.py"] } } }Claude 的命令行版本配置方式更简洁,直接用命令注册即可。大致是claude mcp add web-renderer -- python /绝对路径/mcp_server.py,添加后重启会话就能生效。命令行版本的好处是切换项目时能按项目隔离 MCP 配置,适合团队协作时共享同一套渲染服务。
配置完别急着问复杂问题,先做一次最小验证:让 AI 渲染https://example.com,然后描述页面内容。如果这一步正常,说明整条链路已经打通。
5.3 首次实测:让 AI 描述一个页面
我拿自己的博客页面做了第一次实测,提示词是:"请使用截图工具查看我的博客首页,然后告诉我它的整体布局、导航结构和主要配色。"结果 AI 调用screenshot_webpage后,返回了一段非常准确的描述,甚至指出了我首页的 hero 区域和文章列表的卡片布局,设计风格判断也基本到位。
这个结果之所以让我印象深刻,是因为同一个页面,此前用纯源码分析时,模型只能罗列 CSS 类名,完全无法描述"视觉感受"。一个工具链的差距,直接决定了 AI 能发挥的能力上限。实测里还发现,AI 会主动结合文本摘要和截图两种信息,文本提供细节,截图提供整体感,两者互补得很好。
6. "看网页"的高频实战场景与提示词配合技巧
6.1 场景一:SPA 动态页面内容抽取
这是我用得最频繁的场景。某内部知识库是 Vue 写的,整个文档列表都靠前端渲染,直接抓源码只有一个空壳。以前这类任务得写完整 Playwright 脚本,现在只需要在 Cursor 里说一句:"请用渲染工具读取这个知识库首页的文档列表,提取前 10 个文档标题和链接。"
提示词里一定要带上"渲染"或"截图"关键词,否则模型可能尝试用普通抓取方式。你也可以在提示词里直接点名工具:"调用 render_webpage 工具"。AI 调工具之后,返回的文本经过截断仍然保留了关键信息,我按列表结构直接把内容落进文档,整个过程不到一分钟。
6.2 场景二:前端视觉自检与回归对比
改前端样式最怕的就是"改完代码不知道实际效果"。现在我的常规操作是:本地启动开发服务器,然后让 AI 分别渲染改动前后的两个页面地址,再对比差异。提示词写成"请分别截图渲染 http://localhost:3000/page 和 http://localhost:3000/page?old=1,对比导航栏高度和按钮圆角这两处改动是否生效。"
这个场景最依赖截图质量。因为涉及视觉对比,screenshot_webpage返回的 JPEG 一旦压缩过度,细节就看不清。我把 quality 控制在 80,尺寸 1280 宽度,AI 能看清文字和边界。如果还嫌不清晰,直接把渲染服务里的 viewport 调大到 1440 或 1920,截图分辨率更高,模型识别精度也更高。
6.3 场景三:批量页面巡检
我还会用这套能力做轻量级的页面巡检。比如整理一批 URL 列表,逐个调用render_webpage,让 AI 判断每个页面是否有正文、是否有 404 文案、关键模块是否为空。相比写独立爬虫,这种方式部署成本几乎为零,逻辑就写在提示词里,临时任务随手就能干。
批量任务需要注意节奏。一次丢给 AI 二十个 URL,连续调用工具,会占用大量上下文,而且每页渲染都要等。我一般拆成每批 5 个 URL,加一句"请逐个渲染,并在每页结果前加上页面序号",这样输出有结构、批次之间还能切换。AI 判断不了的异常页面,再单独用截图工具二次确认。
7. 接入过程中的踩坑记录:消息体积、等待策略与登录墙
7.1 截图体积过大导致 MCP 消息被拒
我第一次把截图接进 Claude 时遇到一个奇特现象:工具明明返回了数据,对话却忽然中断,AI 像是没收到结果一样。查了很久才发现,问题出在截图体积上。全尺寸 PNG 截图轻易达到 3 到 5 MB,base64 之后更是暴涨三分之一,而 MCP 在客户端传输时有消息大小上限,超限消息直接被丢弃,AI 端看起来就像工具调用失败。
解决方式我做了三层:第一层,截图格式从 PNG 换成 JPEG,质量压到 80,单张截图立刻降到 200 到 400 KB;第二层,默认关闭full_page,只截首屏;第三层,在 MCP 返回前判断 base64 长度,超过阈值就提示客户端"截图过大,建议截首屏"。第二层通常就能解决问题,第三层是兜底保险。
7.2 页面渲染未完成就先截图
另一个高频坑是白屏截图。有些页面加载流程很长,networkidle判断的是网络请求,但页面里的动画、延迟渲染的组件、懒加载的图片都没法被它感知。我遇到过截图里正文区域空白,只有顶部导航栏的情况。
解决办法是在渲染服务里加一个可选的"等待选择器"参数:调用方指定一个 CSS 选择器,页面旋转完就等这个元素出现在 DOM 里,再执行截图。这比固定等待时间可靠得多。虽然 MCP 工具那一层我没暴露这个参数,但内部逻辑做了兜底:wait_until="networkidle"之后至少再等 3 秒。对付大多数页面已经够用。
7.3 登录墙与本地身份验证页面
处理需要登录才能看的内容时,会遇到两个问题:一是渲染服务没有登录态,打开页面直接跳转登录页;二是把账号密码塞给 AI 既不安全也不现实。我的处理方式是复用浏览器的本地存储状态。
Playwright 支持把登录后的 Cookie 和 localStorage 保存成storage_state文件,渲染请求启动时加载这份状态。具体操作是先用普通浏览器手动登录一次,然后用脚本导出状态文件,渲染服务在new_page之前注入。这样 AI 拿到的永远是登录后的渲染结果,它自己完全不接触敏感凭据。需要注意状态文件有时效性,Cookie 过期要重新导出。
7.4 模型不主动调用工具,总说"无法访问网络"
接入之后最让人恼火的不是工具报错,而是模型压根不调工具。你让它"查看网页",它直接回答"我无法访问外部网络"。这种情况多半是工具描述不够清晰,或者模型在权衡时觉得直接回答更经济。
我的应对是两个技巧并重。第一,在提示词里显式指定工具:"请先调用 render_webpage 工具渲染该 URL,再基于返回结果回答问题"。第二,在系统提示或对话上下文里声明一遍"你可以使用网页渲染与截图工具",让模型建立预期。两手配合之后,模型几乎不会走偏。尤其注意 Cursor 里有时会出现工具可用但模型未识别的情况,重启 Cursor 让 MCP 列表刷新通常能解决。
7.5 并发与资源控制
无头浏览器是很吃内存的,多开几个并发页面,开发机内存直接报警。我在渲染服务里用信号量做了并发限制,但从 MCP 层也要有控制意识:告诉 AI "一次最多渲染一个页面",或把它放进提示词,避免模型开多个并行调用。实际体验下来,串行调用比并行稳定得多,反正单页渲染也就一两秒,没必要冒险。
回看这套方案,真正的价值在于把 AI 的能力边界向前推了一步。以前 AI 处理网页相关内容永远是"纸上谈兵",拿到源码靠猜;现在它有了自己的眼睛,能看着真实渲染结果说话。如果你也经常让 AI 处理前端页面、文档抽取或设计分析,这套"本地渲染服务 + MCP 工具封装"的架构值得直接抄走。后续我还打算在这个服务器上继续加工具,比如 PDF 输出、无障碍扫描、RSS 抓取,思路完全一致,算是把个人 AI 工具箱越做越顺手了。