192 lines
6.3 KiB
Go
192 lines
6.3 KiB
Go
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(
|
||
" ", " ", "&", "&", "<", "<", ">", ">",
|
||
""", `"`, "'", "'", "“", "“", "”", "”",
|
||
)
|
||
return r.Replace(s)
|
||
}
|