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("crawler 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 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
func genericStaticCrawl(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
}
g.Log().Warningf(ctx, "[recruit-debug] src=%d listURL=%s htmlLen=%d", src.Id, listURL, len(html))
raw := extractAnchors(html, listURL)
anchors := filterArticleAnchors(raw)
g.Log().Warningf(ctx, "[recruit-debug] src=%d rawAnchors=%d filtered=%d", src.Id, len(raw), len(anchors))
var out []dto.RecruitmentInput
limit := 60
if force {
limit = 300 // 全量回溯放宽上限
}
for _, a := range anchors {
if len(out) >= limit {
break
}
dHtml, e := fetchHTML(ctx, a.URL)
if e != nil {
continue
}
title, content, pub, dl, ex := extractDetail(dHtml)
if title == "" {
title = a.Text
}
pubDate := normalizeDate(pub)
// 增量模式:跳过 30 天前的公告,避免无效回扫。
if !force && pubDate != "" {
if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
if time.Since(t) > 30*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,
})
}
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)")
}
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().Warningf(ctx, "[recruit-debug] curl ok len=%d url=%s", len(body), u)
return body, nil
} else if e != nil {
g.Log().Warningf(ctx, "[recruit-debug] curl failed: %v url=%s", e, u)
} else {
g.Log().Warningf(ctx, "[recruit-debug] curl suspicious len=%d 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, "fetch %s", u)
}
defer resp.Close()
if resp.StatusCode != 200 {
return "", gerror.Newf("HTTP %d for %s", resp.StatusCode, u)
}
body := resp.ReadAllString()
if !looksLikeHtml(body) || len(body) < 3000 {
g.Log().Warningf(ctx, "[recruit-debug] go-fallback suspicious len=%d url=%s", len(body), u)
return "", gerror.Newf("suspicious page (len=%d) for %s", len(body), u)
}
g.Log().Warningf(ctx, "[recruit-debug] go-fallback ok 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)