Java 抓取动态网页:Chromium 渲染 + LangChain4j 大模型结构化提取

大模型让网页数据采集这件事有了新的用法。
过去,企业做爬虫通常是为了把网页上的标题、正文、公告等字段保存到数据库;现在,采集到的数据还可以继续交给大模型做摘要、分类、实体提取和翻译,或者经过清洗、切分和向量化之后进入企业知识库,成为 RAG 系统的数据来源。
在 Java 里,这条链路的后半段已经有了顺手的工具:LangChain4j 把提示词组装、结构化输出、工具调用这些与大模型打交道的胶水代码,都封装成了类型安全的 API。缺的是前半段 – 把一个满是 JS 的现代网页,变成模型读得动的干净文本。
现有解决方案
收集网页数据最常见的做法,是向 Web 服务器发送 HTTP 请求、拿回 HTML 响应,再从中解析出需要的字段。LangChain4j 自带的 UrlDocumentLoader 走的就是这条路。
对静态页面,这足够了。但现代网页大量使用 JavaScript:服务器返回的往往只是一个引导页面,标题、正文、列表都是脚本执行之后才渲染出来的。
利用 Web 浏览器的功能
一种更可靠的做法是使用真正的浏览器加载页面,让 JavaScript 正常执行,并在动态内容渲染完成后提取结果。对于需要登录、点击或滚动才能访问目标内容的网站,浏览器方案同样能够处理。
Fuzio 是一款 Java 浏览器库,可将完整的 Chromium 集成到 Java 应用中。浏览器实例由应用直接创建和管理,无需安装或连接外部驱动,网页加载、交互和内容提取都可以在同一个 Java 程序中完成。对于使用 Java 技术开发和销售软件解决方案的公司,或者需要为内部 Java 应用配备高级、可靠浏览器组件的团队,它都非常实用。
准备工作
文中代码基于 Fuzio 2026.5.0 和 LangChain4j 1.18.1,JDK 17 及以上。构建脚本需要三个依赖:
plugins {
java
application
id("tech.fuzio.gradle") version "1.0.0"
}
fuzio {
version = "2026.5.0"
}
dependencies {
// 只下载当前操作系统对应的 Chromium 二进制。
implementation(fuzio.currentPlatform)
implementation("dev.langchain4j:langchain4j:1.18.1")
implementation("dev.langchain4j:langchain4j-open-ai:1.18.1")
}
第一步:启动 Fuzio
Fuzio 的核心对象层次包括 Engine、Browser、Frame。Engine 会启动一整套 Chromium 进程;本文顺序抓取多个页面,因此只创建一个 Engine 和一个 Browser 并复用。
engine = Engine.newInstance(
EngineOptions.newBuilder(HARDWARE_ACCELERATED)
.enableIncognito()
.timeZone(ZoneId.of("Asia/Shanghai"))
.build());
engine.network().acceptLanguage("zh-CN,zh;q=0.9,en;q=0.8");
browser = engine.newBrowser();
第二步:加载页面,等内容真正就绪
browser.navigation().loadUrlAndWait(url, Duration.ofSeconds(45));
现代网页在加载事件触发后,内容可能仍在异步渲染。一个简单实用的办法是轮询 document.body.innerText 的长度;如果连续两次没有变化,就认为页面文本已基本稳定,可以开始提取。
void waitUntilTextStable() {
Frame frame = mainFrame(); // 主 Frame 的来历见下一步
double previous = -1;
for (int i = 0; i < 20; i++) {
double length = frame.executeJavaScript("document.body.innerText.length");
if (length == previous) {
return;
}
previous = length;
sleep(500);
}
}
第三步:把页面变成 Document
取网址、标题和正文,包装成 LangChain4j 的 Document:
Frame frame = browser.mainFrame().orElseThrow();
String url = frame.executeJavaScript("document.location.href");
String title = frame.executeJavaScript("document.title");
String body = frame.executeJavaScript("document.body.innerText");
Metadata metadata = Metadata.from(Document.URL, url);
metadata.put("title", title);
metadata.put("fetched_at", Instant.now().toString());
Document page = Document.from(body, metadata);
这段代码里有两个选择值得说清楚。
为什么是 innerText。 它反映渲染后的可见文本:跳过 display:none 的元素、保留自然换行。
为什么是 Document。 它是 LangChain4j 处理文本的标准数据类型,可以直接交给 DocumentSplitter 切分,也可以通过 EmbeddingStoreIngestor 写入向量库。将渲染结果封装成 Document 后,Fuzio 抓取的内容就能自然接入 LangChain4j 的后续处理流程。
图片怎么办
innerText 有个天然限制:只返回文本,页面里的 <img> 不会变成描述。图片的 alt 信息可以用一小段脚本原位保留成文本标记:
frame.executeJavaScript("""
document.querySelectorAll('img').forEach(img => {
const alt = (img.getAttribute('alt') || '').trim();
const marker = document.createElement('span');
marker.textContent = alt ? '\\n[图片:' + alt + ']\\n' : '\\n[图片]\\n';
img.replaceWith(marker);
});
""");
这样至少可以让模型知道: 这里原本有一张图片。以及图片作者提供的 alt 描述。
如果图表、信息图本身承载关键信息,光有 alt 就不够了,得走视觉路线。Fuzio 这边先把 Browser 调到页面完整宽高,再取整页位图:
double pageWidth = frame.executeJavaScript(
"Math.max(document.body.scrollWidth, document.documentElement.scrollWidth)");
double pageHeight = frame.executeJavaScript(
"Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)");
browser.resize((int) pageWidth, (int) pageHeight);
// 给重新布局留一点渲染时间,再取位图。
Thread.sleep(2000);
var bitmap = browser.bitmap();
var image = BitmapImage.toToolkit(bitmap);
var output = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", output);
String base64Png = Base64.getEncoder().encodeToString(output.toByteArray());
BitmapImage.toToolkit() 把 Fuzio 的 Bitmap 转成标准的 BufferedImage,之后就能交给 ImageIO 编码。一个实际使用中的提醒:页面特别长时位图会占大量内存,可以改成按固定视口高度分段截取、再拼接。
LangChain4j 这边,图像和文字一样,都是 UserMessage 的一种内容,交给一个普通的 ChatModel 即可(构造方式见第四步)。gpt-4o-mini 本身就接受图像输入,不用换模型:
UserMessage message = UserMessage.from(
TextContent.from("这张网页截图里的图表说明了什么?用中文概括。"),
ImageContent.from(base64Png, "image/png"));
String answer = model.chat(message).aiMessage().text();
对纯文本页面,innerText 是更便宜的输入;当布局、表格和图片本身承载语义时,才值得上截图 + 多模态。
第四步:交给大模型
正文到手,剩下的交给模型。网页信息提取通常需要模型返回 JSON 等结构化数据。仅在提示词中约定输出格式并不稳定,模型仍可能遗漏字段或返回不合法的 JSON。对于支持结构化输出的模型,LangChain4j 可以根据 Java 类型生成 JSON Schema,在模型侧约束输出格式,并自动将结果转换成对应的 Java 对象。
先用一个 record 声明想要什么。字段说明写在 @Description 上,这个 record 就是抽取契约本身:
public record ArticleSummary(
@Description("网页的中文标题") String titleZh,
@Description("中文标题对应的英文翻译") String titleEn,
@Description("150 字以内的中文摘要") String summaryZh,
@Description("与中文摘要对应的英文摘要") String summaryEn,
@Description("2 到 4 个主题分类") List<String> topics) {
}
接着声明一个用于网页分析的接口。@SystemMessage 定义模型的角色和回答规则,@UserMessage 则是提示词模板,其中的 {{url}} 和 {{content}} 会由 @V 标注的参数填充。
AiServices 会在运行时创建动态代理,根据注解组装消息并调用模型,再将返回结果转换成 ArticleSummary:
public interface ArticleAnalyzer {
@SystemMessage("""
你是一个严谨的中文网页信息抽取助手。
只依据给定的网页正文作答,无法确定的字段留空字符串或空数组,不要编造。
""")
@UserMessage("""
请从以下网页内容中提取信息。
网页地址:{{url}}
网页内容:
{{content}}
""")
ArticleSummary analyze(@V("url") String url, @V("content") String content);
}
构造模型时打开 JSON Schema 能力,输出格式就从"提示词里的君子协定"变成"模型侧的硬约束":
ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
// 从返回类型反射生成 JSON Schema 随请求发出,由模型侧保证输出合法;
// strict 进一步要求所有字段必填、不允许多余字段。
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)
.strictJsonSchema(true)
// 抽取要的是稳定复现,不是创造性。
.temperature(0.0)
// 长正文的抽取比普通对话慢不少,默认超时容易踩线。
.timeout(Duration.ofSeconds(120))
.build();
要加抽取字段时只改 record,提示词一个字不用动。串起来只剩两行:
ArticleAnalyzer analyzer = AiServices.create(ArticleAnalyzer.class, model);
ArticleSummary summary = analyzer.analyze(page.metadata().getString(Document.URL), page.text());
严格 JSON Schema 是 OpenAI 系接口的能力。换成只支持
json_object的兼容端点时(例如 DeepSeek,它收到json_schema会直接返回 400),把上面两行能力声明换成responseFormat("json_object"),并把字段说明写回提示词,其余代码不变。
到这里,一个中文页面就变成了带标题、日期、中英摘要、要点、实体和主题的 Java 对象。
两个会遇到的问题
以上完成了基本的抓取流程。在实际使用中,还需要处理两个常见问题。
一、列表页:先滚动展开
文章详情页通常加载一次就够;信息流、搜索结果和瀑布流页面则会随着滚动继续加载。程序化滚动就能触发懒加载:
static final String PAGE_HEIGHT =
"Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)";
void scrollToBottom(int maxRounds) {
Frame frame = mainFrame();
double lastHeight = -1;
for (int i = 0; i < maxRounds; i++) {
double height = frame.executeJavaScript(PAGE_HEIGHT);
if (height == lastHeight) {
return;
}
lastHeight = height;
frame.executeJavaScript("window.scrollTo(0, " + PAGE_HEIGHT + ")");
sleep(800); // 给懒加载的网络请求留时间
}
}
二、重试与礼貌限速
真实网络里失败是常态。很多站点在请求过密时会直接掐断连接,Fuzio 会抛出 NavigationException:
private void loadWithRetry(String url) {
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
// 礼貌延迟,且随重试次数递增,给被限流的服务器留出喘息时间。
sleep(500L * attempt);
browser.navigation().loadUrlAndWait(url, LOAD_TIMEOUT);
return;
} catch (NavigationException e) {
boolean retryable = e.netError() == NetError.ABORTED;
if (!retryable || attempt == MAX_ATTEMPTS) {
throw e;
}
}
}
}
这个示例只面向公开可访问的页面。抓取前请遵守目标站点的
robots.txt与服务条款,控制好对同一域名的请求频率,并遵守数据来源地关于数据采集与个人信息保护的相关法规。
拓展:把浏览器交给模型
到目前为止,页面的访问和操作都由 Java 代码控制,模型只负责理解抓取到的内容。借助 LangChain4j 的工具调用(@Tool),还可以把打开页面、点击和滚动等浏览器操作交给模型,由它根据任务自行规划并执行下一步。
public final class WebTools {
private final FuzioDocumentLoader loader; // 四个工具共享同一个浏览器
@Tool("打开指定网址,等待页面(含 JavaScript 动态内容)渲染完成,"
+ "返回页面标题和正文开头。读取任何网页前都要先调用它。")
public String openPage(
@P("完整网址,必须以 http:// 或 https:// 开头") String url) { /* … */ }
@Tool("读取当前页面已渲染的完整正文。当 openPage 返回的开头部分不足以回答问题时调用。")
public String readPage() { /* … */ }
@Tool("在当前页面上找到文字匹配的链接或按钮并点击。用于进入搜索结果、翻页或展开折叠区块。")
public String clickText(
@P("链接或按钮上显示的文字,支持部分匹配,尽量用最短的关键词") String text) { /* … */ }
@Tool("向下滚动当前页面以触发懒加载,返回新加载出来的内容。列表页内容看起来不完整时调用。")
public String scrollForMore() { /* … */ }
}
@Tool 和 @P 中的描述会作为工具定义发送给模型,帮助模型理解每个工具的用途和参数,并据此决定何时调用以及传入什么内容。
这些工具封装了打开页面、读取正文、点击元素和滚动加载等浏览器操作,Fuzio 都提供了对应的导航、DOM 和输入 API。四个工具共享同一个 FuzioDocumentLoader,因此也共享同一个浏览器实例,页面状态、Cookie、登录信息和滚动位置都能在多次调用之间保留。
LangChain4j 负责将 @Tool 方法提供给模型,并根据模型的选择执行相应方法,再把结果返回给模型,直到它完成任务并给出最终答案。
结论
把"抓取动态网站"这件麻烦事拆开看,其实是三个不同性质的问题,各自交给擅长的角色反而简单:
- 让 Fuzio 负责加载网页。 通过集成完整的 Chromium,Fuzio 能够执行页面中的 JavaScript,并获取动态渲染后的内容。
- 让 LangChain4j 负责与模型衔接。 从 Document、结构化输出到 @Tool 工具调用,都可以通过统一的类型化 API 完成,减少手写提示词组装、结果解析和调用流程等胶水代码。
- 让大模型负责理解内容。 由模型完成摘要、分类和信息提取,使处理逻辑不再依赖特定的页面结构。
这套骨架的用途也不止"抓文章"。渲染结果已经是标准的 Document,接上 DocumentSplitter 切分、EmbeddingStore 入库,就是 RAG 的数据源。
更多推荐
所有评论(0)