传统爬虫的痛点不是写代码——是维护。
你要爬一个技术博客,先 F12 看 DOM,找到标题的 class 是 .article-title,作者是 .author-name span,正文是 #content p。写好了跑两周,网站改版了——class 全变了,你的爬虫挂了。
AI 爬虫的思路刚好反过来:不写选择器,让 AI 自己看 DOM 决定什么是标题、什么是正文。
核心思路
用户给一个 URL
→ chromedp 渲染页面(执行 JS,加载动态内容)
→ 提取 DOM + 可见文本
→ 丢给 LLM:「请从这个页面提取:标题、作者、发布日期、正文」
→ LLM 返回结构化 JSON
→ 存入数据库
关键区别:不是用 LLM 替换 CSS 选择器,是让 LLM 理解页面内容。
第一步:chromedp 渲染并提取内容
Go 的 chromedp 库提供了无头 Chrome 控制能力:
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/chromedp/chromedp"
)
type WebPage struct {
URL string
Title string
HTML string
Text string
}
// 渲染页面,提取标题+可见文本
func fetchPage(url string, timeoutSec int) (*WebPage, error) {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
var title, textContent string
// 获取页面标题
err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.WaitReady("body"),
chromedp.Sleep(2*time.Second), // 等 JS 渲染
chromedp.Title(&title),
chromedp.Evaluate(`document.body.innerText`, &textContent),
)
if err != nil {
return nil, fmt.Errorf("获取页面失败: %w", err)
}
// 去掉过长的空白
textContent = compressWhitespace(textContent)
return &WebPage{
URL: url,
Title: title,
Text: textContent,
}, nil
}
func compressWhitespace(s string) string {
lines := strings.Split(s, "\\n")
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
result = append(result, trimmed)
}
}
return strings.Join(result, "\\n")
}
document.body.innerText 拿到的是用户肉眼能看到的所有文字——不包含 <script> 和 <style> 里的噪音。
第二步:LLM 结构化提取
import "encoding/json"
type Article struct {
Title string `json:"title"`
Author string `json:"author"`
PublishDate string `json:"publish_date"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
BodyLength int `json:"body_length"`
CodeBlocks int `json:"code_blocks"`
}
func extractArticle(page *WebPage) (*Article, error) {
// 只取前 8000 字符发给 LLM(太长浪费 Token)
snippet := page.Text
if len([]rune(snippet)) > 8000 {
snippet = string([]rune(snippet)[:8000])
}
prompt := fmt.Sprintf(`从以下网页文本中提取文章信息。
网页标题: %s
URL: %s
正文前 8000 字符:
%s
请返回 JSON 格式(严格遵守,不要 Markdown 包裹):
{
"title": "文章标题",
"author": "作者名(找不到填 null)",
"publish_date": "发布日期 YYYY-MM-DD(找不到填 null)",
"summary": "100字以内摘要",
"tags": ["标签1", "标签2"],
"body_length": 正文总字符数(估算),
"code_blocks": 代码块数量
}
规则:
– 作者名只取具体人名/昵称,不要取网站名
– 日期优先从文章元数据提取(通常在开头),其次从 URL
– 标签从文章分类/关键词提取,不超过 5 个
– 如果某项找不到,填 null(字符串)或 0(数字)`, page.Title, page.URL, snippet)
result, err := callLLM("你是数据提取专家。只返回 JSON,不返回其他内容。", prompt)
if err != nil {
return nil, err
}
// 清理 LLM 输出(有时会带 ```json 包裹)
result = cleanJSON(result)
var article Article
if err := json.Unmarshal([]byte(result), &article); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w\\n原始输出: %s", err, result)
}
return &article, nil
}
func cleanJSON(s string) string {
// 去掉可能的 Markdown 代码块包裹
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}
实测:爬一篇技术博客
我找了一篇中等复杂度的技术文章来测:
输入 URL: 一篇关于「Go 泛型实战」的博客文章
LLM 提取结果:
{
"title": "Go 泛型实战:从 interface{} 到类型安全",
"author": "张三",
"publish_date": "2026-03-15",
"summary": "本文通过一个缓存库的重构案例,展示了如何用 Go 1.21 泛型替代 interface{} 提升类型安全性,包含泛型约束、类型推断和性能对比。",
"tags": ["Go", "泛型", "重构", "类型安全"],
"body_length": 5200,
"code_blocks": 8
}
标题、作者、日期、标签——全对。摘要 6 秒生成,比我手动写的好。
第三步:爬完存数据库
import "database/sql"
import _ "github.com/mattn/go-sqlite3"
type Store struct {
db *sql.DB
}
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
db.Exec(`CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE,
title TEXT,
author TEXT,
publish_date TEXT,
summary TEXT,
tags TEXT,
body_length INTEGER,
code_blocks INTEGER,
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
return &Store{db: db}, nil
}
func (s *Store) SaveArticle(a *Article) error {
tags, _ := json.Marshal(a.Tags)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO articles
(url, title, author, publish_date, summary, tags, body_length, code_blocks)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
a.URL, a.Title, a.Author, a.PublishDate,
a.Summary, string(tags), a.BodyLength, a.CodeBlocks,
)
return err
}
完整流程
func main() {
store, _ := NewStore("articles.db")
urls := []string{
"https://example.com/go-generics-in-action",
"https://example.com/rust-vs-go-2026",
}
for _, url := range urls {
fmt.Printf("爬取: %s\\n", url)
// 1. 渲染页面
page, err := fetchPage(url, 30)
if err != nil {
fmt.Printf(" 跳过 (获取失败): %v\\n", err)
continue
}
// 2. AI 提取
article, err := extractArticle(page)
if err != nil {
fmt.Printf(" 跳过 (提取失败): %v\\n", err)
continue
}
// 3. 存数据库
if err := store.SaveArticle(article); err != nil {
fmt.Printf(" 跳过 (保存失败): %v\\n", err)
continue
}
fmt.Printf(" ✅ %s (%s, %d 字)\\n",
article.Title, article.Author, article.BodyLength)
}
}
AI 爬虫 vs 传统爬虫
| 开发 | 写 XPath / CSS 选择器 | 写 Prompt |
| 适应性 | 网站改版就挂 | 自动适应不同结构 |
| 准确率 | 99%(规则明确时) | 90-95%(内容相关时) |
| 成本/页 | ~0 | ¥0.002-0.01(LLM 调用) |
| 速度 | <1 秒 | 3-8 秒 |
| 维护 | 高(结构变了要改代码) | 低(靠 AI 自己理解) |
使用建议:
- 固定结构的页面(列表页、API 响应)→ 传统爬虫,便宜又快
- 不同网站的详情页、文章内容 → AI 爬虫,一劳永逸
- 混合策略:传统爬虫爬列表拿到 URL → AI 爬虫抽取详情
进阶玩法
这些都是 daily-report-agent 的变体——数据源从 Git API 换成网页,其他逻辑不变。
下一篇换方向:AI 写测试。让它自己读源码、写测试、跑测试、看报错、修代码。闭环自动化。



