Files
xk-ai-agent/internal/crawler/crawler.go
2026-08-14 21:50:48 +08:00

192 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package crawler
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
// ========================================================================
// crawler —— 中药药材抓取框架
// ========================================================================
// 职责:
// - 定义抓取源接口Source与药材结构Medicine
// - 提供源注册表:新增抓取源只需实现 Source 并在 init 注册
// - 提供通用 HTTP 抓取 / 编码转换 / HTML 清洗工具
//
// 设计要点:
// - 抓取是"礼貌抓取":全局限速(默认 600ms/请求)、明确 UA、超时兜底
// 避免给源站造成压力
// - 编码自适应:先验 UTF-8不合法则按 GB18030 解GB2312/GBK 的超集),
// 国内中医药老站基本都是 GB 系编码
// ========================================================================
// Medicine 一味药材的解析结果
type Medicine struct {
Name string // 药名(如"白果"
Pinyin string // 拼音(如"baiguo",可空)
Aliases []string // 别名列表(如 银杏核、公孙树子)
Sections []Section // 有序的标记段落(性味归经/功效与作用/...
SourceURL string // 详情页地址(溯源)
}
// Section 详情页中的一个【标记】段落
type Section struct {
Label string // 标记名(不含【】,如"性味归经"
Text string // 段落正文
}
// GetSection 按标记名取段落正文(没有返回空串)
func (m *Medicine) GetSection(label string) string {
for _, s := range m.Sections {
if s.Label == label {
return s.Text
}
}
return ""
}
// Source 抓取源接口
//
// 实现者要求:
// - FetchIndex 返回全量详情页 URL有序、去重调用方按游标切片实现断点续抓
// - FetchDetail 抓取并解析单个详情页
type Source interface {
// Name 源标识(存 DB 的 source 字段,如 zhongyoo
Name() string
// Label 源中文名(面板展示,如 中药查询网)
Label() string
// FetchIndex 抓取索引页,返回全部详情页 URL
FetchIndex(ctx context.Context) ([]string, error)
// FetchDetail 抓取并解析单个药材详情页
FetchDetail(ctx context.Context, url string) (*Medicine, error)
}
// ---------------------------- 源注册表 ----------------------------
// sources 已注册的抓取源init 时注册,运行期只读,无需加锁)
var sources = map[string]Source{}
// register 注册一个抓取源(各源文件 init 里调用)
func register(s Source) { sources[s.Name()] = s }
// GetSource 按标识取抓取源
func GetSource(name string) (Source, bool) {
s, ok := sources[name]
return s, ok
}
// ListSources 列出所有可用源(面板下拉框用)
//
// 按 name 排序map 遍历顺序随机,不排序会导致前端下拉每次刷新顺序乱跳
func ListSources() []map[string]string {
out := make([]map[string]string, 0, len(sources))
for _, s := range sources {
out = append(out, map[string]string{"name": s.Name(), "label": s.Label()})
}
sort.Slice(out, func(i, j int) bool { return out[i]["name"] < out[j]["name"] })
return out
}
// ---------------------------- 通用抓取工具 ----------------------------
// crawlUA 统一 User-Agent表明普通浏览器身份部分站点拒绝空 UA
const crawlUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
// httpClient 抓取共用客户端15s 超时(详情页都很小,够用)
var httpClient = &http.Client{Timeout: 15 * time.Second}
// fetchBytes 抓取一个 URL 的原始字节(带一次重试,容忍瞬时网络抖动)
func fetchBytes(ctx context.Context, url string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
// 重试前稍等,避开瞬时抖动
select {
case <-time.After(1 * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", crawlUA)
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 单页最大 2MB 防异常
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
return body, nil
}
return nil, fmt.Errorf("抓取失败 %s: %w", url, lastErr)
}
// decodeGBAware 编码自适应解码:合法 UTF-8 原样返回,否则按 GB18030 解码
func decodeGBAware(data []byte) string {
if utf8.Valid(data) {
return string(data)
}
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes(data)
if err != nil {
// 解码失败兜底:按原字节返回(后续正则匹配不到会自然报"解析失败"
return string(data)
}
return string(decoded)
}
// 预编译的 HTML 清洗正则(包级复用,避免每次抓取重复编译)
var (
reScript = regexp.MustCompile(`(?is)<script.*?</script>`)
reStyle = regexp.MustCompile(`(?is)<style.*?</style>`)
reBlockEnd = regexp.MustCompile(`(?i)</(p|div|li|h[1-6]|tr)>|<br[^>]*>`)
reTag = regexp.MustCompile(`<[^>]+>`)
reBlank = regexp.MustCompile(`\n{3,}`)
)
// htmlToText 把 HTML 清洗成保留段落结构的纯文本
//
// 步骤:去 script/style → 块级闭合标签转换行 → 去所有标签 → 反转义实体 → 压缩空行
func htmlToText(html string) string {
s := reScript.ReplaceAllString(html, "")
s = reStyle.ReplaceAllString(s, "")
s = reBlockEnd.ReplaceAllString(s, "\n")
s = reTag.ReplaceAllString(s, "")
s = htmlUnescape(s)
// 逐行 trim去掉行内残留空白
lines := strings.Split(s, "\n")
for i := range lines {
lines[i] = strings.TrimSpace(lines[i])
}
s = strings.Join(lines, "\n")
return strings.TrimSpace(reBlank.ReplaceAllString(s, "\n\n"))
}
// htmlUnescape 反转义常见 HTML 实体(够用即可,不引 html 包避免全量实体表开销)
func htmlUnescape(s string) string {
r := strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">",
"&quot;", `"`, "&#39;", "'", "&ldquo;", "“", "&rdquo;", "”",
)
return r.Replace(s)
}