在这里插入图片描述

大模型让网页数据采集这件事有了新的用法。

过去,企业做爬虫通常是为了把网页上的标题、正文、公告等字段保存到数据库;现在,采集到的数据还可以继续交给大模型做摘要、分类、实体提取和翻译,或者经过清洗、切分和向量化之后进入企业知识库,成为 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 的核心对象层次包括 EngineBrowserFrameEngine 会启动一整套 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 的数据源。

更多推荐