package recruitment
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"net/url"
"os/exec"
"regexp"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// crawlerRun 按数据源类型分发抓取,返回运行结果(供写日志/统计)。
func crawlerRun(ctx context.Context, src entity.CrawlSource, force bool) dto.CrawlRunResult {
res := dto.CrawlRunResult{SourceId: src.Id}
defer func() {
// 防止单源解析panic拖垮整体调度。
if r := recover(); r != nil {
res.Err = gerror.Newf("抓取发生 panic: %v", r)
}
}()
var (
infos []dto.RecruitmentInput
err error
)
switch src.SourceType {
case 1: // 静态列表页
infos, err = genericStaticCrawl(ctx, src, force)
case 2: // SPA / JS 渲染(需底层 JSON 接口,首批 POC)
infos, err = spaCrawl(ctx, src, force)
case 3: // 需登录/验证码,首批已排除
err = gerror.New("该源需登录/验证码,首批已排除")
case 4: // 附件型,先按静态抓取,附件解析为后续增强
infos, err = genericStaticCrawl(ctx, src, force)
default:
err = gerror.Newf("未知源类型 %d", src.SourceType)
}
res.Err = err
res.Fetched = len(infos)
if err != nil {
return res
}
newCount, updated := 0, 0
for i := range infos {
isNew, e := upsertInfo(ctx, infos[i])
if e != nil {
res.Err = e
continue
}
if isNew {
newCount++
} else {
updated++
}
}
res.NewCount = newCount
res.UpdatedCount = updated
return res
}
// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
//
// 抓取行为(增量窗口、详情上限、限速、词表、阈值)全部来自 crawl_source.config,
// 未配置则走内置默认值,保证历史数据无需迁移即可继续工作。
func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
cfg := parseSourceConfig(src.Config)
listURL := joinURL(src.BaseUrl, src.ListPath)
if listURL == "" {
listURL = src.BaseUrl
}
html, err := fetchHTML(ctx, listURL)
if err != nil {
return nil, err
}
raw := extractAnchors(html, listURL)
anchors := filterArticleAnchors(raw, cfg)
g.Log().Debugf(ctx, "抓取列表页 src=%d url=%s 锚点=%d 选中=%d", src.Id, listURL, len(raw), len(anchors))
limit := cfg.maxItems(force)
incrDays := cfg.incrDays()
var out []dto.RecruitmentInput
dateMissing := 0 // 未能识别发布日期的条数,用于在日志中告警
for _, a := range anchors {
if len(out) >= limit {
break
}
if cfg.DelayMs > 0 {
// 限速:政府站对高频访问敏感,按源配置间隔。
time.Sleep(time.Duration(cfg.DelayMs) * time.Millisecond)
}
dHtml, e := fetchHTML(ctx, a.URL)
if e != nil {
continue
}
title, content, pub, dl, ex := extractDetail(dHtml, cfg)
if title == "" {
title = a.Text
}
pubDate := normalizeDate(pub)
if pubDate == "" {
dateMissing++
}
// 增量模式:跳过窗口期之外的历史公告,避免无效回扫。force 时忽略窗口。
if !force && pubDate != "" {
if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
if time.Since(t) > time.Duration(incrDays)*24*time.Hour {
continue
}
}
}
out = append(out, dto.RecruitmentInput{
Title: title,
SourceId: src.Id,
OrgName: src.Name,
Category: src.Category,
Region: src.Region,
PublishDate: pubDate,
Deadline: normalizeDate(dl),
ExamDate: normalizeDate(ex),
Url: a.URL,
Content: content,
Status: 0,
})
}
if dateMissing > 0 {
// 发布日期识别失败会影响增量判断与趋势统计,需显式暴露而非静默。
g.Log().Warningf(ctx, "源 %d 有 %d 条未识别发布日期,已置空(建议检查 datePatterns 配置)", src.Id, dateMissing)
}
return out, nil
}
// spaCrawl SPA 源抓取(最佳实践:逆向底层 JSON 接口)。当前为占位实现,
// 若服务端渲染无锚点则返回明确错误,便于在阶段0 POC 中补齐接口。
func spaCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
listURL := joinURL(src.BaseUrl, src.ListPath)
if listURL == "" {
listURL = src.BaseUrl
}
html, err := fetchHTML(ctx, listURL)
if err != nil {
return nil, err
}
// 部分 SPA 会在 HTML 内联 __NEXT_DATA__ / 初始 state,可从中抽取。
if data := extractFromInlineJSON(html); len(data) > 0 {
return data, nil
}
// 纯客户端渲染(hash 路由)无服务端内容,需 POC 接入接口。
if len(extractAnchors(html, listURL)) == 0 {
return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口(阶段0 POC)")
}
// 有服务端渲染锚点时,退回通用静态抓取(内部会读取 config 适配)。
return genericStaticCrawl(ctx, src, force)
}
// extractFromInlineJSON 从 SPA 内联 JSON(__NEXT_DATA__ / window.__INITIAL_STATE__)抽取标题与链接。
// 适配 Vue/Next 等 SSR/CSR 混合页面,作为 SPA 源的兜底解析。
func extractFromInlineJSON(html string) []dto.RecruitmentInput {
var out []dto.RecruitmentInput
// 匹配常见内联 JSON 块,逐块扫描其中的标题/链接字段。
blocks := inlineJSONRe.FindAllStringSubmatch(html, -1)
for _, b := range blocks {
raw := b[1]
var generic map[string]any
if err := json.Unmarshal([]byte(raw), &generic); err != nil {
continue
}
walkJSON(generic, &out)
}
return out
}
// walkJSON 递归遍历内联 JSON,提取含标题与链接的条目(最佳实践,避免硬规则)。
func walkJSON(node any, out *[]dto.RecruitmentInput) {
switch v := node.(type) {
case map[string]any:
title, _ := v["title"].(string)
link, _ := v["url"].(string)
if link == "" {
link, _ = v["href"].(string)
}
if title != "" && link != "" {
*out = append(*out, dto.RecruitmentInput{
Title: title, Url: link, Status: 0,
})
}
for _, val := range v {
walkJSON(val, out)
}
case []any:
for _, item := range v {
walkJSON(item, out)
}
}
}
// fetchHTML 抓取页面。优先用系统 curl(已实测:政府站在容器内对 curl 返回完整页面,
// 而 Go 客户端可能被 TLS 指纹级 WAF 拦截返回无内容挑战页),失败再回退 Go 客户端。
func fetchHTML(ctx context.Context, u string) (string, error) {
if body, e := fetchWithCurl(u); e == nil && looksLikeHtml(body) && len(body) >= 3000 {
g.Log().Debugf(ctx, "curl 抓取成功 len=%d url=%s", len(body), u)
return body, nil
} else if e != nil {
// curl 不可用(如容器未装 curl)或执行失败,属预期回退场景,用 Debug 记录。
g.Log().Debugf(ctx, "curl 抓取未命中,回退 Go 客户端: %v url=%s", e, u)
} else {
g.Log().Debugf(ctx, "curl 返回内容可疑(len=%d),回退 Go 客户端 url=%s", len(body), u)
}
client := g.Client()
client.SetTimeout(15 * time.Second)
client.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
client.SetHeader("Accept", "text/html,application/xhtml+xml,*/*")
client.SetHeader("Accept-Language", "zh-CN,zh;q=0.9")
resp, err := client.Get(ctx, u)
if err != nil {
return "", gerror.Wrapf(err, "抓取页面失败 %s", u)
}
defer resp.Close()
if resp.StatusCode != 200 {
return "", gerror.Newf("请求 %s 返回 HTTP %d", u, resp.StatusCode)
}
body := resp.ReadAllString()
if !looksLikeHtml(body) || len(body) < 3000 {
// Go 客户端被 TLS 指纹级 WAF 拦截时会返回无内容的挑战页,这里显式报错便于排查。
return "", gerror.Newf("页面内容可疑(长度=%d,疑似被 WAF 拦截)%s", len(body), u)
}
g.Log().Debugf(ctx, "Go 客户端抓取成功 len=%d url=%s", len(body), u)
return body, nil
}
// fetchWithCurl 调用系统 curl 抓取(自动跟随重定向,带浏览器 UA)。
func fetchWithCurl(u string) (string, error) {
args := []string{
"-s", "-L", "--max-time", "25",
"-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"-H", "Accept: text/html,application/xhtml+xml,*/*",
"-H", "Accept-Language: zh-CN,zh;q=0.9",
u,
}
out, err := exec.Command("curl", args...).Output()
if err != nil {
return "", gerror.Wrapf(err, "curl 抓取失败 %s", u)
}
return string(out), nil
}
// looksLikeHtml 粗略判断响应是否为正常 HTML 文档(含 ... 块;hrefRe/titleAttrRe 从块内分别抽取链接与 title 属性。
anchorBlockRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`)
hrefRe = regexp.MustCompile(`(?i)\bhref\s*=\s*["']([^"']+)["']`)
titleAttrRe = regexp.MustCompile(`(?i)\btitle\s*=\s*["']([^"']+)["']`)
tagRe = regexp.MustCompile(`<[^>]+>`)
scriptRe = regexp.MustCompile(`(?is)