Agent 开发之项目 AI 能力自我进化:通过浏览器自动化与数据采集实现持续学习

Agent 开发之项目 AI 能力自我进化:通过浏览器自动化与数据采集实现持续学习

文章目录

如何保持网站信息新鲜度?如何让AI系统持续获取新知识并自主优化?

一个真实的尴尬场景:AI 用旧数据回答

用户问 AI,AI 答得很自信 ------ 但用户一查官网,全是旧数据

用户提问 AI 答(旧数据) 实际情况
故宫门票多少钱? × 旺季 60 元 / 淡季 40 元 √ 2025 已调价:旺季 80 元
北京有什么新开的美食街? × 不知道(半年前的快照) √ 最近半年新开数条
颐和园现在开放夜游吗? × 不知道 / 说没有 √ 上月起新增季节性夜游
北京到上海高铁票价? × 2023 年旧票价 √ 2025 已调过两次价

"错误的自信 比 诚实的无知 更危险 ------ 用户对 AI 有信任预期
RAG忠实检索了知识库------问题是知识库里的数据本身就过时了

信息保鲜期分层:不同类型数据的 "过期速度"

不是所有信息保鲜期都一样 ------ 一刀切既做不到,也没必要

信息类型 更新频率 保鲜期 WanderChina 示例
① 实时数据 分钟 / 小时 几小时 汇率、天气、航班状态
② 高频变化 天 / 周 1‑2 周 门票价格、排队时间、临时活动
③ 中频变化 月 / 季 1‑3 个月 开放时间、交通路线、餐厅菜单
④ 低频变化 6‑12 个月 景点简介、地理位置、历史文化
⑤ 几乎不变 极少 数年 世界遗产名录、城市经纬度

已解决 :① 实时数据 → MCP 实时查询,详情查看:Agent 开发之项目能力扩展:通过 MCP 与 CLI 接入外部服务实现功能增强

本文重点:②③④ 高 / 中 / 低频 → 知识层自我进化

什么是 AI 能力自我进化?

× 自我进化 ≠ 模型重新训练

√ 自我进化 = 知识层持续更新

Anthropic 2025:AI 系统的可靠性瓶颈不在模型能力,而在知识时效性

层级 更新频率 成本 / 方式
模型层(LLM 权重) 数月 / 数年 极高(百万美元级)・供应商发新版
知识层(RAG 知识库) 小时 / 天 / 周 低(API + 存储)・采集 + 向量化 + 增量入库
应用层(Prompt / 工作流) 随时 极低・调 Prompt 模板 / 优化检索

模型训练 = 读四年大学

毕业时知识就固定了

再想升级 → 百万美元 / 数千 GPU

知识更新 = 订每天的新闻推送

人还是那个人,但每天获取新信息

本文聚焦:投入产出比最高的一层

知识层自我进化:五步链路

五步一环扣一环 ------ 断了任意一环,整条链路就废了

⚠️ 两个最容易被忽略的坑:脏数据入库比没数据更糟・insert 而不是 upsert 会让新旧数据并存污染 RAG

数据采集方案对比・保鲜期匹配选型

方案 核心思路 代表项目 优点 / 缺点
传统爬虫 写代码解析 HTML Scrapy / Cheerio √ 快、便宜 × 改版即崩
LLM 驱动采集 LLM 读页 + 语义提取 Crawl4AI / Firecrawl √ 抗改版、懂语义 × Token 成本
浏览器自动化 AI 像人一样操作浏览器 Browser Use / Playwright MCP √ 能力最强 × 慢、耗资源
API 接入 直接调对方公开 API 各平台 Open API √ 最稳最快 × 不是都有 API
保鲜期 → 方案匹配
需求 保鲜期 选择方案(理由)
汇率 / 天气 小时 MCP 实时查询(Agent 开发之项目能力扩展:通过 MCP 与 CLI 接入外部服务实现功能增强已完成)
门票价格 / 开放时间 周 / 月 LLM 驱动采集(Crawl4AI・抗改版)
需要登录的后台数据 不定期 浏览器自动化(Browser Use・交互)
有公开 API 的数据 按需 API 接入(最稳,但看对方给不给)

原则:能用API就不用爬虫,能用LLM驱动就不用传统爬虫

AI时代下爬虫技术介绍

传统爬虫的三板斧

python 复制代码
# 传统方式:写死选择器
name = soup.select_one('.attraction-title').text
price = soup.select_one('.ticket-price span').text
hours = soup.select_one('.opening-hours').text
问题 传统爬虫的困境 AI 时代的解法
网站改版 .attraction‑title 改名 → 脚本报废 LLM 理解语义,不依赖 class 名
动态渲染 JS 加载数据,静态 HTML 为空壳 浏览器自动化渲染完整页面
反爬对抗 CAPTCHA / IP 封禁 / 指纹检测 AI 浏览器更像真人,难识别

核心竞争力是 "写对选择器"------ 但选择器是最脆弱的东西

传统爬虫的真实维护成本

看得见的是水面上的选择器 ------ 水面下的反爬 / 动态渲染 / 长期维护才是冰山主体

维度 传统爬虫 LLM 驱动采集
初始开发成本 低(写选择器) 中(配置 LLM + Prompt)
维护成本 高(每月 2‑3 天 / 站) 极低(语义理解不怕改版)
运行成本 低(HTTP 请求) 中(Token 消耗)
扩展成本 高(每站手写) 低(同 Prompt 多站复用)
长期总成本 高(维护累积) 低(Token 持续降价)

燃油车买着便宜养着贵,电动车买着贵但充电便宜 ------ 选长期总成本低的

AI爬虫三层架构

Token 成本估算
  • 单页 Markdown ≈ 3000‑5000 Token
  • 单次提取总消耗 ≈ 5000‑8000 Token
  • 通义千问‑Max:输入 ¥0.02 / 千 Token・输出 ¥0.06 / 千 Token
  • 单次成本 ≈ ¥0.1‑0.2|20 个景点 ≈ ¥2‑4
  • Token 价格持续下降:2023→2025 降幅超 10 倍

合规采集:三条红线 + 真实案例

红线 说明 WanderChina 实践
尊重 robots.txt 检查允许 / 禁止路径 采集前先读 robots.txt
不采集个人信息 PII 受法律保护 只采集景点公开信息
控制请求频率 高频 = DDoS 风险 间隔 2‑5 秒 + Rate Limit
真实判例
WanderChina 为什么安全?

① 只采集公开信息 ② 不涉及个人信息 ③ 小规模低频率(20 景点 / 间隔 3s) ④ 用于产品功能,不转卖

采集框架全景 + 选型决策树

框架 核心定位 Stars 适用场景
Crawl4AI LLM 优化网页提取 40K+ 静态页面提取
Browser Use AI Agent 浏览器自动化 89K+ 登录 / 点击 / 交互
Firecrawl AI 爬取云 API 30K+ 商业项目快速集成
Scrapy 传统爬虫框架 53K+ 结构化网站高频采集
Playwright MS 浏览器自动化 70K+ E2E 测试 + 基础自动化
Playwright MCP Playwright MCP 封装 15K+ AI Agent 浏览器操作
技术派别分类
  • 传统派:Scrapy、Playwright
  • LLM 提取派:Crawl4AI、Firecrawl
  • AI 浏览器派:Browser Use、PW MCP
选型决策树
tex 复制代码
能通过URL访问?
├─能
│  ├─静态页面
│  │  ├─有API → 直接用API
│  │  └─无API → Crawl4AI
│  └─动态页面 → Playwright + Crawl4AI
└─不能访问(需登录/交互) → Browser Use

如何让AI操作浏览器获取数据?Browser Use、Playwright框架实战

为什么需要浏览器自动化?------ 需要交互的场景

LLM 驱动采集能读 HTML------ 但如果数据 "藏在交互背后" 呢?

传统爬虫(requests + BS4)

只能发 HTTP 请求拿 HTML

没有 "手"------ 无法点击、输入、滚动

浏览器自动化(Browser Use)

AI 像人一样操作浏览器

能点击、填写、滚动、切标签页

浏览器自动化 = 给 AI 配一双 "手"------ 人能在浏览器里做的事,它都能做

Browser Use 架构:四层 + Agent Loop 四步

四层架构
Agent Loop 四步循环
  1. 观察 Observe:截取 Accessibility Snapshot
  2. 思考 Think:LLM 判断当前状态与目标差距
  3. 行动 Act:点击 / 输入 / 滚动 / 导航
  4. 验证 Verify:再次快照,确认操作成功
两种模式
  • Snapshot 模式:结构化 DOM,快、省 Token(首选)
  • Vision 模式:截图给 LLM 看,适合验证码 / 复杂布局

Browser Use = AI 大脑 + Playwright 的手和眼 ------ 你只说 "做什么",不必说 "怎么做"

Playwright MCP:和之前文章知识衔接

之前 MCPAgent 开发之项目能力扩展:通过 MCP 与 CLI 接入外部服务实现功能增强:Qoder → MCP 协议 → 旅行信息 Server → 汇率 / 天气

本文 Playwright MCP:Qoder → MCP 协议 → Playwright Server → 控制浏览器

MCP Tool 功能 类比
navigate_page 打开指定 URL 地址栏输入网址
click 点击页面元素 鼠标点击
fill 在输入框填写文字 键盘输入
take_snapshot 获取页面可访问性快照 "看一眼" 当前页面
take_screenshot 截取页面截图 PrintScreen 截图
press_key 按键(Enter/Tab 等) 按键盘上的键
list_pages 查看已打开页面列表 看浏览器标签栏
evaluate_script 在页面执行 JavaScript DevTools Console

配置片段

复制代码
{
  "mcpServers": {
    "playwright": {
      "args": ["@playwright/mcp"]
    }
  }
}

Qoder 已内置 browser‑use MCP,开箱即用,无需额外配置

具体实现

提示词
tex 复制代码
打开浏览器访问 https://m.ctrip.com/html5/,在搜索框输入'北京景点'并回车,浏览搜索结果页面,提取前5个景点的名称、门票价格和开放时间,以 JSON 格式输出。你把你的思考链路和调用的工具也展示出来
思考链路与工具调用总结
步骤 思考 工具
1 需要真实浏览器操作网页,选择 Browser 子代理 Agent(subagent_type="Browser")
2 导航到 https://m.ctrip.com/html5/ 并截图确认页面 浏览器 navigate + take_screenshot
3 定位搜索框元素,点击激活输入 浏览器 click
4 输入 "北京景点" 并提交搜索 浏览器 fill + press_key (Enter)
5 等待搜索结果加载,截图记录 浏览器 take_screenshot
6 逐个点击景点进入详情页,提取门票价格、开放时间等结构化信息 浏览器 click + take_snapshot(获取 DOM 文本)
7 汇总 5 个景点数据,格式化为 JSON 输出 数据整理
需要登录才能进行的提示词
tex 复制代码
打开携程登录页,用账号 account 和密码 password 登录,然后进入「我的收藏」页面,提取我收藏的所有景点名称。

可以看到AI会模拟用户登录场景,会依次点击收藏页面顶部底部,依次获取心愿单、商品、内容

AI直接自己判断哪个是用户名输入框、哪个是密码输入框,通过页面的语义信息就能够在哪个位置填哪些信息

如果携程登录页改版,传统脚本会报错,但是AI不会

稳定性最佳实践 + 认知升级

现象 解法
页面加载慢 AI 未等数据加载完就操作 操作后 wait_for 确认元素出现
弹窗干扰 登录后弹出欢迎 Dialog AI 先关闭弹窗再继续
Token 成本 每次 snapshot 消耗大量 Token Snapshot 模式(Token 仅为截图 1/10)

原则 1 任务要具体

"搜索北京景点,提取前 5 个结果"

原则 2 给容错空间

"如果搜索框找不到,尝试点导航栏链接"

原则 3 结果结构化

明确要求 JSON/CSV,方便后续入库


认知升级
  • 之前:浏览器自动化 = Selenium 硬编码选择器
  • 现在:AI 通过 snapshot 语义理解 → 自主决策操作
  • 关键:同一套 MCP 协议,不同 Server(旅行信息 MCP vs Playwright MCP)

景点信息数据爬取,如何让LLM控制浏览器完成信息查询与数据获取?

任务定义:从携程采集数据 → 存入知识库

数据库

结构化数据存储

门票、时间、地址 → 可查询

Chroma 向量库

AI 可检索的知识文档

向量化 → AI 能回答问题

验证标准:不是 "采了多少条"------ 是 "系统搜的准不准,AI 回答得准不准"

具体实现

提示词
tex 复制代码
/opsx:explore

我要实现景点数据自动采集功能:
- 从携程采集北京 Top 20 景点数据(名称、门票价格、开放时间、地址、简介、评分)
- 使用 Playwright MCP(Browser Use)访问携程景点页面,LLM 结构化提取
- 采集结果清洗后双写数据库 + Chroma 向量库
- 请分析项目现状,生成 Spec
执行步骤
tex 复制代码
☑ 1.1 SpotEntityTest RED: 新增实用字段测试
☑ 1.2 SpotEntity GREEN: 新增3字段+getter/setter
☑ 1.3 运行 SpotEntityTest 验证绿灯

☑ 2.1 SpotResponse 新增3字段+构造器+getter
☑ 2.2 修复所有 SpotResponse 构造器调用点
☑ 2.3 运行全部测试确认编译+绿灯

☑ 3.1‑3.2 KnowledgeBuilderServiceTest RED
☑ 3.3 KnowledgeBuilderService GREEN
☑ 3.4 运行测试验证绿灯

☑ 4.1‑4.2 SpotQueryToolTest RED
☑ 4.3 SpotQueryTool GREEN
☑ 4.4 运行测试验证绿灯

☑ 5.1‑5.4 携程数据采集(Browser MCP)

☑ 6.1‑6.4 种子数据更新+Chroma重建

☑ 7.1‑7.3 收尾验证

ps:不写采集service,不在springboot里面引入playwright

理由:

1.当前这种数据更新不是每天都需要跑的运行式功能,只是偶尔一次性采集,如果后端引入playwright太重了

2.Browser Use MCP是Qoder内置工具,只有AI Agent能调用,后端java线程调用不动

3.职责分离,Agent去拿采,采集什么,后端负责拿到数据库怎么存,各干各的干净利落

示例代码
java 复制代码
// 根据名称和slug查询景点详情
@Tool(description = "Get detailed information about a specific tourist spot including description, tags, rating, and gallery.")
public String getSpotDetails(
        @ToolParam(description = "Spot slug or name identifier, e.g. 'lingyin-temple', 'west-lake'") String nameOrSlug) {
    Optional<SpotEntity> optionalSpot = spotRepository.findBySlugAndDeletedFalse(nameOrSlug);
    if (optionalSpot.isEmpty()) {
        return "Spot not found: " + nameOrSlug;
    }

    SpotEntity spot = optionalSpot.get();
    StringBuilder sb = new StringBuilder();
    sb.append("Name: ").append(spot.getName());
    if (spot.getNameZh() != null) {
        sb.append(" (").append(spot.getNameZh()).append(")");
    }
    sb.append("\nCity: ").append(spot.getCityName() != null ? spot.getCityName() : "Unknown");
    sb.append("\nRating: ").append(spot.getRating() != null ? spot.getRating().toPlainString() : "N/A");
    sb.append("\nTags: ").append(String.join(", ", spot.getTags()));
    if (spot.getTicketPrice() != null) {
        sb.append("\nTicket Price: ").append(spot.getTicketPrice());
    }
    if (spot.getOpeningHours() != null) {
        sb.append("\nOpening Hours: ").append(spot.getOpeningHours());
    }
    if (spot.getAddress() != null) {
        sb.append("\nAddress: ").append(spot.getAddress());
    }
    if (spot.getDescription() != null) {
        sb.append("\nDescription: ").append(spot.getDescription());
    }
    if (spot.getDescriptionZh() != null) {
        sb.append("\nDescription (Chinese): ").append(spot.getDescriptionZh());
    }
    if (spot.getGallery() != null && !spot.getGallery().isEmpty()) {
        sb.append("\nGallery: ").append(String.join(", ", spot.getGallery()));
    }
    return sb.toString();
}

// 转换景点为RAG文档
private Document toSpotDocument(SpotEntity spot) {
    String tags = spot.getTags() != null ? String.join(", ", spot.getTags()) : "";
    StringBuilder sb = new StringBuilder();
    sb.append(String.format("Spot: %s (%s)\nCity: %s\nTags: %s\nRating: %s",
                            spot.getName(),
                            spot.getNameZh() != null ? spot.getNameZh() : "",
                            spot.getCityName() != null ? spot.getCityName() : "",
                            tags,
                            spot.getRating()));
    if (spot.getTicketPrice() != null) {
        sb.append("\nTicket Price: ").append(spot.getTicketPrice());
    }
    if (spot.getOpeningHours() != null) {
        sb.append("\nOpening Hours: ").append(spot.getOpeningHours());
    }
    if (spot.getAddress() != null) {
        sb.append("\nAddress: ").append(spot.getAddress());
    }
    if (spot.getDescription() != null) {
        sb.append("\nDescription: ").append(spot.getDescription());
    }

    Map<String, Object> metadata = new HashMap<>();
    metadata.put("entity_type", "spot");
    metadata.put("slug", spot.getSlug());
    metadata.put("name", spot.getName());
    if (spot.getNameZh() != null) {
        metadata.put("name_zh", spot.getNameZh());
    }
    if (spot.getCityName() != null) {
        metadata.put("city_name", spot.getCityName());
    }
    if (!tags.isEmpty()) {
        metadata.put("tags", String.join(",", spot.getTags()));
    }

    return new Document(sb.toString(), metadata);
}

如何定时让AI爬取数据及更新机制?

手动触发 vs 全自动采集

维度 手动触发(L4) 全自动(本文)
触发方式 人在 Qoder 输入指令 定时任务自动触发(Cron Job)
采集范围 人指定的 URL 列表 AI 根据数据新鲜度自动决定
异常处理 人检查采集报告 AI 自动分析失败原因并重试
入库方式 人确认后入库 采集后自动入库 + 通知人 Review

手动 = 自己去超市买菜

每次都得你惦记冰箱缺什么

亲自跑一趟超市

一周不去,冰箱就空了

全自动 = 每周蔬菜配送

不需要你想着去买

菜自动送到家门口

你只需检查一下质量

手动触发 = "你告诉 AI 什么时候补货";全自动 = "AI 自己发现冰箱空了,自己去补货"

具体实现

提示词
tex 复制代码
/opsx:explore
基于当前系统已有能力,定时使用 Browser MCP
访问携程页面,LLM
结构化提取,采集结果清洗后双写数据库 + Chroma
向量库,以下是一些具体规则:
- 每天凌晨 2 点自动触发
- 检查数据库中哪些景点数据超过 7 天未更新,只采集过期的部分
- 按优先级排序:严重过期(> 30 天)优先,普通过期(7‑30 天)其次
- 采集完成后生成报告并通知
- 请分析项目现状,生成 Spec

相关问题答案
问题 1:数据新鲜度怎么追踪?
BaseEntity.updatedAt 在任何字段变更时都会更新(包括 viewCount+1),不能用来判断 "采集数据是否过期"。
方案:新增 dataRefreshedAt 字段到 SpotEntity
    → 每次采集更新时显式设置 dataRefreshedAt = now()
    → 查询时 WHERE dataRefreshedAt < now() - 7 days
    
问题 2:Chroma 更新策略?
之前 Chroma 只能全量重建,通过 rebuildAll() 方法每次将文档全部删除重新写入
方案:新增refresh()方法,按slug删除旧文档写新文档,景点的文档id永远不会变化不管更新多少次

问题 3:采集报告和通知的形式?
方案:后端存储采集报告(SpotEnrichmentReport 表)
    → GET /api/spots/enrichment/report 可查询
    → 前端后续可消费
    
问题 4:携程页面结构变化怎么办?
方案:上次采集是人工逐页操作,可以灵活适配。自动化后页面结构变化会导致采集失败,需要:
• 容错机制:单个景点采集失败不中断整体流程
• 重试机制:失败的景点标记为"采集失败",下次优先重试
• 采集结果校验:LLM 提取后做基本合理性检查(如评分 0‑5、门票不为空等)

问题 5:Schedule MCP 的自延续模式
Schedule MCP 只支持 one‑shot,意味着:
Day 1 23:00: Qoder 创建 Day 2 02:00 的任务
Day 2 02:00: 任务触发 → Qoder 执行采集 → 完成后创建 Day 3 02:00 的任务
Day 3 02:00: 任务触发 → Qoder 执行采集 → 完成后创建 Day 4 02:00 的任务
...(无限延续)
风险:如果某天 Qoder 没运行(电脑关机),链条就断了。需要考虑:
方案:Qoder 必须运行才能采集。每天完成后自动创建第二天任务。
对比:有 Qoder vs 没 Qoder vs MCP Client
维度 Qoder Agent 方案 后端 Playwright Java 后端 MCP Client + Browser MCP
定时 Schedule MCP 自延续 @Scheduled @Scheduled 天然循环
浏览器 Browser MCP Playwright Java Browser MCP(复用现有)
LLM Qoder 内置 自己调 DashScope DashScope(已有)
新依赖 +Playwright Java +MCP Client starter(轻量)
运行要求 Qoder 客户端 装 Chromium 引擎 Browser MCP 进程跑着

具体代码

配置文件
yaml 复制代码
spring:
  ai:
    mcp:
      server:
        name: wanderchina-travel-services
        version: 1.0.0
        type: SYNC
      client:
        enabled: ${MCP_CLIENT_ENABLED:false}
        stdio:
          connections:
            browser-use:
              command: npx
              args:
                - -y
                - "@playwright/mcp"
                - --headless
app:
  enrichment:
    cron: "0 0 2 * * *"
    stale-days: 7
    critical-days: 30

作用:Spring‑AI MCP Client 通过 stdio 模式,Java 自动 fork 拉起 @playwright/mcp 外部子进程(Browser‑MCP),Java 不引入 Playwright jar 包。

景点采集业务层
java 复制代码
package com.mooc.app.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mooc.app.dto.EnrichRequest;
import com.mooc.app.entity.SpotEntity;
import com.mooc.app.entity.SpotEnrichmentReport;
import com.mooc.app.repository.SpotEnrichmentReportRepository;
import com.mooc.app.repository.SpotRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;

@Service
public class SpotDataCollectorService {

    private static final Logger log = LoggerFactory.getLogger(SpotDataCollectorService.class);
    private static final ObjectMapper objectMapper = new ObjectMapper();

    private final AtomicBoolean running = new AtomicBoolean(false);

    private final SpotRepository spotRepository;
    private final SpotEnrichmentService spotEnrichmentService;
    private final SpotEnrichmentReportRepository reportRepository;
    private final ChatModel chatModel;
    private final int staleDays;
    private final int criticalDays;

    public SpotDataCollectorService(SpotRepository spotRepository,
                                     SpotEnrichmentService spotEnrichmentService,
                                     SpotEnrichmentReportRepository reportRepository,
                                     ChatModel chatModel,
                                     @Value("${app.enrichment.stale-days:7}") int staleDays,
                                     @Value("${app.enrichment.critical-days:30}") int criticalDays) {
        this.spotRepository = spotRepository;
        this.spotEnrichmentService = spotEnrichmentService;
        this.reportRepository = reportRepository;
        this.chatModel = chatModel;
        this.staleDays = staleDays;
        this.criticalDays = criticalDays;
    }

    public boolean isRunning() {
        return running.get();
    }

    @Scheduled(cron = "${app.enrichment.cron:0 0 2 * * *}")
    @Async
    public void scheduledCollect() {
        collectStaleSpots();
    }

    @Async
    public void collectStaleSpotsAsync() {
        collectStaleSpots();
    }

    public void collectStaleSpots() {
        if (!running.compareAndSet(false, true)) {
            log.warn("Collection already in progress, skipping");
            return;
        }
        try {
            doCollect();
        } finally {
            running.set(false);
        }
    }

    private void doCollect() {
        Instant startedAt = Instant.now();
        String runId = UUID.randomUUID().toString();
        log.info("Starting spot data collection run: {}", runId);

        Instant cutoff = Instant.now().minus(staleDays, ChronoUnit.DAYS);
        List<SpotEntity> staleSpots = spotRepository.findStaleSpots(cutoff);

        int totalAttempted = 0;
        int totalSuccess = 0;
        int totalFailed = 0;
        List<String> details = new ArrayList<>();

        for (SpotEntity spot : staleSpots) {
            totalAttempted++;
            try {
                enrichSpot(spot);
                totalSuccess++;
                details.add(String.format("{\"slug\":\"%s\",\"status\":\"success\"}", spot.getSlug()));
                log.info("Successfully enriched: {} ({})", spot.getName(), spot.getSlug());
            } catch (Exception e) {
                totalFailed++;
                details.add(String.format("{\"slug\":\"%s\",\"status\":\"failed\",\"error\":\"%s\"}",
                        spot.getSlug(), escapeJson(e.getMessage())));
                log.error("Failed to enrich {}: {}", spot.getSlug(), e.getMessage());
            }
        }

        Instant completedAt = Instant.now();
        Duration elapsed = Duration.between(startedAt, completedAt);

        SpotEnrichmentReport report = new SpotEnrichmentReport();
        report.setRunId(runId);
        report.setStartedAt(startedAt);
        report.setCompletedAt(completedAt);
        report.setTotalAttempted(totalAttempted);
        report.setTotalSuccess(totalSuccess);
        report.setTotalFailed(totalFailed);
        report.setDetails("[" + String.join(",", details) + "]");
        reportRepository.save(report);

        log.info("Collection run {} completed: {} attempted, {} success, {} failed in {}ms",
                runId, totalAttempted, totalSuccess, totalFailed, elapsed.toMillis());
    }

    private void enrichSpot(SpotEntity spot) {
        // Build extraction prompt for LLM
        String extractionPrompt = buildExtractionPrompt(spot);
        var response = chatModel.call(new Prompt(extractionPrompt));

        String llmContent = response.getResult().getOutput().getText();
        EnrichRequest enrichRequest = parseLlmResponse(llmContent);

        spotEnrichmentService.updateSpot(spot.getId(), enrichRequest);
    }

    private String buildExtractionPrompt(SpotEntity spot) {
        return """
                Extract structured information about the spot "%s" from the context below.
                Return ONLY a JSON object with these fields (use null for unknown fields):
                {
                  "name_zh": "Chinese name",
                  "ticket_price": "ticket price info",
                  "opening_hours": "opening hours",
                  "address": "full address",
                  "rating": 4.5,
                  "description": "English description",
                  "description_zh": "Chinese description"
                }
                
                Spot: %s (%s), City: %s
                """.formatted(spot.getName(), spot.getName(), spot.getNameZh(), spot.getCityName());
    }

    EnrichRequest parseLlmResponse(String content) {
        try {
            // Extract JSON from response (might be wrapped in markdown code blocks)
            String json = extractJson(content);
            JsonNode node = objectMapper.readTree(json);

            return new EnrichRequest(
                    textOrNull(node, "name_zh"),
                    textOrNull(node, "ticket_price"),
                    textOrNull(node, "opening_hours"),
                    textOrNull(node, "address"),
                    node.has("rating") && !node.get("rating").isNull()
                            ? new BigDecimal(node.get("rating").asText()) : null,
                    textOrNull(node, "description"),
                    textOrNull(node, "description_zh")
            );
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to parse LLM response as JSON: " + content, e);
        }
    }

    private String extractJson(String content) {
        if (content == null) throw new RuntimeException("LLM returned null content");
        // Strip markdown code blocks if present
        String trimmed = content.trim();
        if (trimmed.startsWith("```json")) {
            trimmed = trimmed.substring(7);
        } else if (trimmed.startsWith("```")) {
            trimmed = trimmed.substring(3);
        }
        if (trimmed.endsWith("```")) {
            trimmed = trimmed.substring(0, trimmed.length() - 3);
        }
        return trimmed.trim();
    }

    private String textOrNull(JsonNode node, String field) {
        if (!node.has(field) || node.get(field).isNull()) return null;
        return node.get(field).asText();
    }

    private String escapeJson(String text) {
        if (text == null) return "null";
        return text.replace("\"", "\\\"").replace("\n", "\\n");
    }
}

ps:也可以通过云端SSE的通信方式实现Browser MCP的调用,但是这是依赖大模型打开云端的浏览器爬取数据再存储到我们的向量数据库,比较麻烦。因为我们本地是有浏览器的,可以采用本地采集的方式

总结&相关面试题

本文认知升级线

  1. 发现问题

    • 知识库有保鲜期 / AI 用过时数据回答比没 AI 还糟
  2. 建立认知

    • "写正则"→"LLM 理解语义" / 合规三红线 / 框架全景
  3. 动手实战

    • Browser Use Agent Loop / Playwright MCP 操作浏览器
  4. 核心实战

    • 景点批量采集 → 清洗 → 向量化 → Chroma 增量入库
  5. 全自动

    • 定时任务 + AI 自主规划 / 知识入库闭环
  6. 总结收口

    • 认知回顾 / 能力进化图 / 面试精选

面试精选 10 题

Q1 AI 自我进化 vs 模型重训练

知识层更新・成本极低・订阅新闻 vs 读大学

Q2 AI 爬虫 vs 传统爬虫本质区别

LLM 理解语义 vs 写对选择器・不怕改版

Q3 Browser Use vs Playwright MCP

Agent Loop 深度采集 vs MCP 快速操作

Q4 知识入库闭环 vs 传统 ETL

"提取" 从硬编码→LLM 语义理解

Q5 WanderChina 采集走 SDD

流程 explore→propose→apply / Spec 驱动

Q6 数据采集合规三条红线

robots.txt/ 只采公开 / 控制频率

Q7 定时采集 vs 智能采集

无脑全采 vs 只采过期 + 热度排序

Q8 Snapshot vs Vision 模式

DOM 结构省 Token vs 截图识别・10 倍成本

Q9 MCP 的三种角色

数据接口→FC 给应用→浏览器操作

Q10 设计完整 AI 自我进化系统

数据层 + 调度层 + 验证层・准确性是目的

相关推荐
CIO_Alliance15 分钟前
AI提示系列(2)| Few-shot与ReAct有何不同? 大模型工具调用的底层逻辑详解
前端·人工智能·深度学习·神经网络·react.js·前端框架·ai+ipaas
A555666777878916 分钟前
AI漫剧制作平台怎么选?2026一站式影视制作工具与AI真人剧创作软件测评
人工智能·ai
山西茄子19 分钟前
在NVIDIA Jetson上从`NvBufSurface`获取CUDA访问
人工智能·deepstream
IT古董19 分钟前
AI 资讯日报 | 2026年8月27日:智谱、阿里接连发布并开源高性能大模型,英伟达交出营收翻倍的超预期财报,工信部明确“十五五“AI发展路线图
人工智能·开源
牧羊人.33320 分钟前
动手学深度学习 01:核心组件与完整训练流程
开发语言·人工智能·深度学习
盟接之桥21 分钟前
半导体供应链破局:EDI如何成为中国制造的数字通行证
大数据·运维·服务器·网络·数据库·人工智能·制造
晓晓_za89866822 分钟前
GEO 搜索源码白帽合规改造:适配各大 AI 信源收录规则
java·开发语言·人工智能·性能优化·开源
集芯微电科技有限公司23 分钟前
低压功率MOSFETs选型手册
人工智能·单片机·嵌入式硬件·神经网络·生成对抗网络
Haooog23 分钟前
从 Tool Calling 到 State 并发控制:一个 Agent 如何安全地连续执行任务
java·agent·后端开发