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)`) reStyle = regexp.MustCompile(`(?is)`) reBlockEnd = regexp.MustCompile(`(?i)|]*>`) 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) }