这两批改动此前一直压在工作区未提交,本次一并归档。go build ./... 通过。
一、招聘爬虫健壮性
- 新增 internal/service/recruitment/source_config.go:
crawl_source.config(VARCHAR(1000) JSON)的源级抓取参数解析 —— 增量窗口天数、
单次详情页上限、最大翻页数、详情间隔限速、自定义标准词/排除词。
全部字段可选,JSON 解析失败即回退默认值,保证历史数据零迁移可用,
且一个源的脏配置不会拖垮整个调度。
- 新增 internal/service/recruitment/keywords.go(含 keywords_test.go 与 testdata/):
链接筛选由「URL 形态命中 AND 文本语义命中」改为评分制 —— URL 形态加分、
标准词加分、排除词大幅减分,达阈值即入选。原与逻辑会系统性漏抓
「补充工作人员的通知」「公开选调公务员简章」等标题变体,且静默无报错。
- 新增 manifest/sql/recruitment/004_crawl_source_status.sql:
crawl_source 增加「最近一次运行状态」冗余列,抓取结束时写入,
使 Sources() 查询零 JOIN 零扫描 —— 规避原实现全表扫描只增不减的 crawl_log、
随运行时间线性劣化的问题。幂等,可重复执行。
- internal/service/recruitment/crawler.go:在既有「静态两级 / SPA / curl 回退」
流程上做锚点抽取与过滤、编码回退的健壮性增强。
- internal/service/recruitment/bark.go、recruitment.go:配合上述调整。
二、通知记录增强
- api/notice、internal/controller/notice、internal/service/notice、
internal/model/{dto,entity,do}/notice.go:通知日志查询与操作扩展 ——
批次号、状态、重试次数、来源、耗时、详情、删除、清空。
- 新增 internal/model/dto/notice_meta.go(字典选项元数据,供前端筛选器取选项)。
- 新增 manifest/sql/017_notice_log_enhance.sql:notice_log 扩展列 +
删除/清空/详情/字典选项接口权限,幂等(ALTER 走 information_schema 判断,
菜单 ON DUPLICATE KEY UPDATE,角色绑定 INSERT IGNORE)。
三、仓库卫生
- .gitignore 补 rc_*.log 与 rc_verify*,挡掉本地离线验证产物
(含 30MB 的 rc_verify.exe),避免误入库。
设计依据见 docs/recruitment-crawler-design.md。
注:017 与 recruitment/004 两个 SQL 为增量迁移,DB 结构变更不自动 DDL,
生产库需手动导入。
824 lines
27 KiB
Go
824 lines
27 KiB
Go
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 文档(含 <a 锚点与 <html 文档标记)。
|
||
func looksLikeHtml(s string) bool {
|
||
lower := strings.ToLower(s)
|
||
return strings.Contains(lower, "<html") && strings.Contains(lower, "<a")
|
||
}
|
||
|
||
type anchor struct {
|
||
URL string
|
||
Text string
|
||
}
|
||
|
||
var (
|
||
// anchorBlockRe 匹配完整 <a>...</a> 块;hrefRe/titleAttrRe 从块内分别抽取链接与 title 属性。
|
||
anchorBlockRe = regexp.MustCompile(`(?is)<a\b[^>]*>([\s\S]*?)</a>`)
|
||
hrefRe = regexp.MustCompile(`(?i)\bhref\s*=\s*["']([^"']+)["']`)
|
||
titleAttrRe = regexp.MustCompile(`(?i)\btitle\s*=\s*["']([^"']+)["']`)
|
||
tagRe = regexp.MustCompile(`<[^>]+>`)
|
||
scriptRe = regexp.MustCompile(`(?is)<script[\s\S]*?</script>`)
|
||
styleRe = regexp.MustCompile(`(?is)<style[\s\S]*?</style>`)
|
||
titleRe = regexp.MustCompile(`(?is)<title[^>]*>([\s\S]*?)</title>`)
|
||
h1Re = regexp.MustCompile(`(?is)<h1[^>]*>([\s\S]*?)</h1>`)
|
||
dateRe = regexp.MustCompile(`(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})`)
|
||
inlineJSONRe = regexp.MustCompile(`(?is)(?:__NEXT_DATA__|__INITIAL_STATE__|window\.__[A-Z_]+)\s*=\s*(\{[\s\S]*?\})\s*;?`)
|
||
spaceRe = regexp.MustCompile(`\s+`)
|
||
// yearPathRe 匹配 URL 路径中的年份(如 /2026/ 或 /202609/),用于判断内容页形态。
|
||
yearPathRe = regexp.MustCompile(`/(20\d{2})[/_]?(\d{2})?/`)
|
||
// imgAltRe 抽取 <img> 的 alt 或 title 属性,用于图片型锚点的文本回退。
|
||
imgAltRe = regexp.MustCompile(`(?i)\b(?:alt|title)\s*=\s*["']([^"']+)["']`)
|
||
// imageFileRe 判断字符串是否为图片文件名(如 ysqgk3.png、banner.jpg)。
|
||
imageFileRe = regexp.MustCompile(`(?i)^[\w\-.\s]+\.(?:png|jpe?g|gif|bmp|webp|svg|ico)$`)
|
||
|
||
// metaDateRes 发布日期标准元数据,按可靠性排序。
|
||
// 政府站多用 PubDate / publishdate,新闻系统多用 og:published_time / article:published_time。
|
||
metaDateRes = []*regexp.Regexp{
|
||
regexp.MustCompile(`(?i)<meta[^>]+name=["']?(?:PubDate|publishdate|publish_date|Pubdate)["']?[^>]+content=["']([^"']+)["']`),
|
||
regexp.MustCompile(`(?i)<meta[^>]+content=["']([^"']+)["'][^>]+name=["']?(?:PubDate|publishdate|publish_date)["']?`),
|
||
regexp.MustCompile(`(?i)<meta[^>]+property=["']?(?:og:published_time|article:published_time|og:release_date)["']?[^>]+content=["']([^"']+)["']`),
|
||
regexp.MustCompile(`(?i)<meta[^>]+content=["']([^"']+)["'][^>]+property=["']?(?:og:published_time|article:published_time)["']?`),
|
||
}
|
||
// dateContainerRe 语义容器内的日期(如 class="time|date|pubdate|info|source" 的块)。
|
||
dateContainerRe = regexp.MustCompile(
|
||
`(?is)<(?:div|span|p|td)[^>]*class=["'][^"']*\b(?:time|date|pubdate|publishtime|info|source)\b[^"']*["'][^>]*>([\s\S]{0,120}?)<\/(?:div|span|p|td)>`)
|
||
)
|
||
|
||
// extractAnchors 从 HTML 抽取绝对化后的锚点(URL + 文本)。
|
||
// 政府站常把真实标题放在 title 属性、inner text 仅"详细/更多",故 inner 过短时回退 title。
|
||
func extractAnchors(html, base string) []anchor {
|
||
out := make([]anchor, 0)
|
||
for _, block := range anchorBlockRe.FindAllStringSubmatch(html, -1) {
|
||
full := block[0]
|
||
hm := hrefRe.FindStringSubmatch(full)
|
||
if len(hm) < 2 {
|
||
continue
|
||
}
|
||
href := strings.TrimSpace(hm[1])
|
||
if href == "" || strings.HasPrefix(href, "javascript:") ||
|
||
strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") {
|
||
continue
|
||
}
|
||
// 注意:stripTags 会把 <img> 替换为空白,图片锚点(如仅含
|
||
// <img src="ysqgk3.png" title="ysqgk3.png">)经去标签后 inner 为空,
|
||
// 随后的 title 属性回退会取到图片文件名,从而绕过语义词表。
|
||
// 故先尝试用 img 的 alt 作为文本,再回退 <a> 的 title,最后统一做
|
||
// 「图片文件名」筛除,避免这类噪声进入评分。
|
||
inner := strings.TrimSpace(collapseSpace(stripTags(block[1])))
|
||
text := inner
|
||
if text == "" || isImageFileName(text) {
|
||
text = firstAttr(block[1], imgAltRe)
|
||
}
|
||
// 文本过短时回退到 <a> 的 title 属性(政府站常把真实标题放这里)。
|
||
if len([]rune(text)) < 4 {
|
||
if tm := titleAttrRe.FindStringSubmatch(full); len(tm) >= 2 {
|
||
t := strings.TrimSpace(collapseSpace(tm[1]))
|
||
if t != "" {
|
||
text = t
|
||
}
|
||
}
|
||
}
|
||
// 最终筛除:图片文件名不是有效标题(其 alt/title 也常是文件名)。
|
||
if text == "" || isImageFileName(text) {
|
||
continue
|
||
}
|
||
abs := resolveURL(base, href)
|
||
if abs == "" {
|
||
continue
|
||
}
|
||
out = append(out, anchor{URL: abs, Text: text})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// filterArticleAnchors 过滤导航/无用链接,保留疑似招聘公告条目并去重。
|
||
//
|
||
// 采用评分制(见 keywords.go 的 scoreAnchor):不再要求 URL 与文本「同时」命中,
|
||
// 从而修复「补充工作人员的通知」「公开选调简章」等标题变体被漏抓的问题。
|
||
// 词表与阈值来自数据源配置(crawl_source.config),未配置则用内置默认。
|
||
func filterArticleAnchors(as []anchor, cfg SourceConfig) []anchor {
|
||
incWords := cfg.includeWordsOrDefault()
|
||
excWords := cfg.excludeWordsOrDefault()
|
||
minScore := cfg.minScore()
|
||
|
||
out := make([]anchor, 0, len(as))
|
||
for _, a := range as {
|
||
text := strings.TrimSpace(a.Text)
|
||
if len([]rune(text)) < 4 {
|
||
continue
|
||
}
|
||
if scoreAnchor(a, incWords, excWords, minScore) {
|
||
out = append(out, a)
|
||
}
|
||
}
|
||
seen := map[string]bool{}
|
||
uniq := out[:0]
|
||
for _, a := range out {
|
||
if seen[a.URL] {
|
||
continue
|
||
}
|
||
seen[a.URL] = true
|
||
uniq = append(uniq, a)
|
||
}
|
||
return uniq
|
||
}
|
||
|
||
// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期。
|
||
//
|
||
// 发布日期采用「分段抽取」策略(修复原 firstDate 取整页首个日期、误把
|
||
// 页面头部"今天是2026年9月13日"当作发布日的问题):
|
||
// 1. 优先 <meta> 的 PubDate / publishdate / og:published_time 等标准字段;
|
||
// 2. 其次带语义容器的日期(如 class="time|date|pubdate|info" 的块);
|
||
// 3. 再次「发布时间:/发布日期:/日期:」等中文前缀后的日期;
|
||
// 4. 自定义正则(config.datePatterns,按源覆盖);
|
||
// 5. 全部未命中则置空(不再盲目取整页第一个日期)。
|
||
func extractDetail(html string, cfg SourceConfig) (title, content, publishDate, deadline, examDate string) {
|
||
if m := titleRe.FindStringSubmatch(html); len(m) > 1 {
|
||
title = cleanText(m[1])
|
||
}
|
||
if strings.TrimSpace(title) == "" {
|
||
if m := h1Re.FindStringSubmatch(html); len(m) > 1 {
|
||
title = cleanText(m[1])
|
||
}
|
||
}
|
||
title = strings.TrimSpace(title)
|
||
|
||
body := scriptRe.ReplaceAllString(html, " ")
|
||
body = styleRe.ReplaceAllString(body, " ")
|
||
text := stripTags(body)
|
||
text = collapseSpace(text)
|
||
if len([]rune(text)) > 4000 {
|
||
text = string([]rune(text)[:4000])
|
||
}
|
||
content = text
|
||
|
||
publishDate = extractPublishDate(html, text, cfg)
|
||
// 截止/笔试日期在正文中按关键词就近查找:
|
||
// 政府公告常写「报名时间:2026年9月1日9:00至9月5日17:00」,
|
||
// 故限制关键词后 40 字内取证,避免取到正文别处的无关日期。
|
||
deadline = normalizeDate(findDateAfterLimit(text, "报名", 40))
|
||
examDate = normalizeDate(findDateAfterLimit(text, "笔试", 40))
|
||
return
|
||
}
|
||
|
||
// extractPublishDate 按优先级分段抽取发布日期,全部未命中返回空串。
|
||
func extractPublishDate(html, text string, cfg SourceConfig) string {
|
||
// 1. meta 标准字段(最可靠)。
|
||
for _, re := range metaDateRes {
|
||
if m := re.FindStringSubmatch(html); len(m) > 1 {
|
||
if d := normalizeDate(m[1]); d != "" {
|
||
return d
|
||
}
|
||
}
|
||
}
|
||
// 2. 语义容器内的日期(如 <div class="time">2026-09-13</div>)。
|
||
if m := dateContainerRe.FindStringSubmatch(html); len(m) > 1 {
|
||
if d := normalizeDate(m[1]); d != "" {
|
||
return d
|
||
}
|
||
}
|
||
// 3. 中文前缀后的日期(发布时间:/发布日期:/日期:/时间:)。
|
||
for _, kw := range []string{"发布时间", "发布日期", "发布于", "日期", "时间"} {
|
||
if d := normalizeDate(findDateAfter(text, kw)); d != "" {
|
||
// 仅取关键词后 40 字内出现的日期,避免跨度太大误取。
|
||
if v := findDateAfterLimit(text, kw, 40); v != "" {
|
||
return normalizeDate(v)
|
||
}
|
||
}
|
||
}
|
||
// 4. 源自定义正则。
|
||
for _, p := range cfg.DatePatterns {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
re, err := regexp.Compile(p)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if m := re.FindStringSubmatch(html); len(m) > 1 {
|
||
if d := normalizeDate(m[1]); d != "" {
|
||
return d
|
||
}
|
||
}
|
||
}
|
||
// 5. 未识别:置空并在调用方统一告警(避免误取页面头部日期)。
|
||
return ""
|
||
}
|
||
|
||
// findDateAfterLimit 在 keyword 之后 limit 个字符内查找日期,超出范围视为未命中。
|
||
func findDateAfterLimit(text, keyword string, limit int) string {
|
||
idx := strings.Index(text, keyword)
|
||
if idx < 0 {
|
||
return ""
|
||
}
|
||
rest := text[idx:]
|
||
runes := []rune(rest)
|
||
if len(runes) > limit {
|
||
runes = runes[:limit]
|
||
}
|
||
m := dateRe.FindStringSubmatch(string(runes))
|
||
if len(m) == 0 {
|
||
return ""
|
||
}
|
||
return m[0]
|
||
}
|
||
|
||
// findDateAfter 在 keyword 之后查找下一个日期(用于报名/笔试时间)。
|
||
func findDateAfter(text, keyword string) string {
|
||
idx := strings.Index(text, keyword)
|
||
if idx < 0 {
|
||
return ""
|
||
}
|
||
rest := text[idx:]
|
||
m := dateRe.FindStringSubmatch(rest)
|
||
if len(m) == 0 {
|
||
return ""
|
||
}
|
||
return normalizeDate(m[0])
|
||
}
|
||
|
||
// normalizeDate 将多种中文/西式日期归一化为 YYYY-MM-DD。
|
||
func normalizeDate(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
m := dateRe.FindStringSubmatch(s)
|
||
if len(m) == 0 {
|
||
return ""
|
||
}
|
||
y, mo, d := m[1], atoiSafe(m[2]), atoiSafe(m[3])
|
||
return fmt.Sprintf("%s-%02d-%02d", y, mo, d)
|
||
}
|
||
|
||
// stripTags 去除 HTML 标签。
|
||
func stripTags(s string) string {
|
||
return tagRe.ReplaceAllString(s, " ")
|
||
}
|
||
|
||
// collapseSpace 折叠空白字符。
|
||
func collapseSpace(s string) string {
|
||
return spaceRe.ReplaceAllString(s, " ")
|
||
}
|
||
|
||
// cleanText 清理文本中的标签残留与首尾空白,用于标题等展示字段。
|
||
// 政府站的锚点内文本常含换行与制表符(如 "\n\t\t\t人事招考"),需归一化。
|
||
func cleanText(s string) string {
|
||
return strings.TrimSpace(collapseSpace(stripTags(s)))
|
||
}
|
||
|
||
// isImageFileName 判断文本是否为图片文件名(图片型锚点的特征)。
|
||
func isImageFileName(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || strings.ContainsAny(s, "\n") {
|
||
return false
|
||
}
|
||
return imageFileRe.MatchString(s)
|
||
}
|
||
|
||
// firstAttr 用正则从 HTML 片段中抽取首个属性值(如 img 的 alt/title)。
|
||
func firstAttr(html string, re *regexp.Regexp) string {
|
||
m := re.FindStringSubmatch(html)
|
||
if len(m) < 2 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(collapseSpace(m[1]))
|
||
}
|
||
|
||
// resolveURL 将相对链接解析为绝对 URL。
|
||
func resolveURL(base, href string) string {
|
||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||
return href
|
||
}
|
||
u, err := url.Parse(base)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
ref, err := url.Parse(href)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
abs := u.ResolveReference(ref).String()
|
||
// 同一政府页面常同时含 http/https 两种文章链接,统一升级为 https 便于去重。
|
||
if strings.HasPrefix(base, "https://") && strings.HasPrefix(abs, "http://") {
|
||
abs = "https://" + strings.TrimPrefix(abs, "http://")
|
||
}
|
||
return abs
|
||
}
|
||
|
||
// joinURL 拼接基础域名与路径。
|
||
func joinURL(base, p string) string {
|
||
if p == "" {
|
||
return base
|
||
}
|
||
if strings.HasPrefix(p, "http") {
|
||
return p
|
||
}
|
||
u, err := url.Parse(base)
|
||
if err != nil {
|
||
return base + p
|
||
}
|
||
ref, err := url.Parse(p)
|
||
if err != nil {
|
||
return base + p
|
||
}
|
||
return u.ResolveReference(ref).String()
|
||
}
|
||
|
||
func atoiSafe(s string) int {
|
||
n := 0
|
||
for _, c := range s {
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ---------- 去重与写入 ----------
|
||
|
||
func fingerprintOf(in dto.RecruitmentInput) string {
|
||
h := md5.Sum([]byte(fmt.Sprintf("%d|%s|%s|%s", in.SourceId, in.Title, in.PublishDate, in.Url)))
|
||
return fmt.Sprintf("%x", h)
|
||
}
|
||
|
||
func groupKeyOf(in dto.RecruitmentInput) string {
|
||
h := md5.Sum([]byte(fmt.Sprintf("%s|%s", in.Title, in.PublishDate)))
|
||
return fmt.Sprintf("%x", h)
|
||
}
|
||
|
||
// upsertInfo 按指纹去重写入公告;已存在则更新可变字段(内容/状态/日期)。
|
||
func upsertInfo(ctx context.Context, in dto.RecruitmentInput) (bool, error) {
|
||
if in.Fingerprint == "" {
|
||
in.Fingerprint = fingerprintOf(in)
|
||
}
|
||
if in.GroupKey == "" {
|
||
in.GroupKey = groupKeyOf(in)
|
||
}
|
||
var exist entity.RecruitmentInfo
|
||
if err := dao.RecruitmentInfo.Ctx(ctx).Where("fingerprint", in.Fingerprint).Scan(&exist); err != nil {
|
||
// gf 的 Scan 在查无记录时返回 "no rows" 错误,视为未存在,继续插入。
|
||
if !strings.Contains(err.Error(), "no rows") {
|
||
return false, gerror.Wrap(err, "查询已存在公告失败")
|
||
}
|
||
}
|
||
if exist.Id > 0 {
|
||
_, err := dao.RecruitmentInfo.Ctx(ctx).Where("id", exist.Id).Data(do.RecruitmentInfo{
|
||
Content: in.Content, Status: in.Status, Deadline: in.Deadline,
|
||
ExamDate: in.ExamDate, UpdatedAt: gtime.Now(),
|
||
}).Update()
|
||
if err != nil {
|
||
return false, gerror.Wrap(err, "更新公告失败")
|
||
}
|
||
return false, nil
|
||
}
|
||
orgId, err := upsertOrg(ctx, in.OrgName, in.Category, in.Region)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
in.OrgId = orgId
|
||
if _, err = dao.RecruitmentInfo.Ctx(ctx).Data(toRecruitmentDO(in)).InsertAndGetId(); err != nil {
|
||
return false, gerror.Wrap(err, "新增公告失败")
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// upsertOrg 发布主体按名称去重,返回主键。
|
||
func upsertOrg(ctx context.Context, name string, category int, region string) (uint64, error) {
|
||
if name == "" {
|
||
return 0, nil
|
||
}
|
||
var org entity.Organization
|
||
if err := dao.Organization.Ctx(ctx).Where("name", name).Scan(&org); err != nil {
|
||
if !strings.Contains(err.Error(), "no rows") {
|
||
return 0, gerror.Wrap(err, "查询发布机构失败")
|
||
}
|
||
}
|
||
if org.Id > 0 {
|
||
return org.Id, nil
|
||
}
|
||
id, err := dao.Organization.Ctx(ctx).Data(do.Organization{
|
||
Name: name, Type: mapCategoryToOrgType(category), Region: region,
|
||
CreatedAt: gtime.Now(), UpdatedAt: gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
return 0, gerror.Wrap(err, "新增发布机构失败")
|
||
}
|
||
return uint64(id), nil
|
||
}
|
||
|
||
func mapCategoryToOrgType(c int) int {
|
||
switch c {
|
||
case 2:
|
||
return 2 // 事业单位
|
||
case 3:
|
||
return 3 // 国企
|
||
case 4:
|
||
return 4 // 央企
|
||
case 5:
|
||
return 5 // 私企
|
||
default:
|
||
return 6
|
||
}
|
||
}
|
||
|
||
func toRecruitmentDO(in dto.RecruitmentInput) do.RecruitmentInfo {
|
||
now := gtime.Now()
|
||
return do.RecruitmentInfo{
|
||
Title: in.Title, SourceId: in.SourceId, OrgId: in.OrgId, OrgName: in.OrgName,
|
||
Category: in.Category, Region: in.Region, PublishDate: nullIfEmpty(in.PublishDate),
|
||
Deadline: nullIfEmpty(in.Deadline), ExamDate: nullIfEmpty(in.ExamDate), Url: in.Url, Content: in.Content,
|
||
Attachments: in.Attachments, Status: in.Status, Fingerprint: in.Fingerprint,
|
||
GroupKey: in.GroupKey, CreatedAt: now, UpdatedAt: now,
|
||
}
|
||
}
|
||
|
||
// nullIfEmpty 将空字符串转为 nil,便于插入 NULL(DATE 等可空列不接受空串)。
|
||
func nullIfEmpty(s string) any {
|
||
if strings.TrimSpace(s) == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
|
||
// autoDisableThreshold 连续失败达该次数后自动禁用数据源,避免无效空跑。
|
||
// 采用「连续」语义:中间任意一次成功即归零,避免偶发网络抖动误禁。
|
||
const autoDisableThreshold = 5
|
||
|
||
// autoDisableWarnThreshold 连续失败达该次数时先推送一次预警(尚未禁用)。
|
||
const autoDisableWarnThreshold = 3
|
||
|
||
// recordCrawlLog 写抓取日志并更新数据源运行状态。
|
||
// - 成功:清失败计数、更新 LastSuccessAt 与最近运行摘要冗余列;
|
||
// - 失败:累加失败计数,达阈值自动禁用并推送告警。
|
||
func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
|
||
errMsg := ""
|
||
if res.Err != nil {
|
||
// 错误可能含整页内容,截断避免超过 error 列长度导致日志也写不进。
|
||
errMsg = res.Err.Error()
|
||
const maxErr = 900
|
||
if len(errMsg) > maxErr {
|
||
errMsg = errMsg[:maxErr] + "...(已截断)"
|
||
}
|
||
}
|
||
if _, err := dao.CrawlLog.Ctx(ctx).Data(do.CrawlLog{
|
||
SourceId: res.SourceId, RunAt: gtime.Now(), Fetched: res.Fetched,
|
||
NewCount: res.NewCount, UpdatedCount: res.UpdatedCount, Error: errMsg,
|
||
}).Insert(); err != nil {
|
||
g.Log().Errorf(ctx, "写入抓取日志失败: %v", err)
|
||
}
|
||
|
||
// 读取当前源状态,用于累计失败次数与判断是否需自动禁用。
|
||
var src entity.CrawlSource
|
||
if err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src); err != nil {
|
||
if !strings.Contains(err.Error(), "no rows") {
|
||
g.Log().Errorf(ctx, "查询数据源状态失败: %v", err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 无论成败都同步「最近一次运行摘要」冗余列,供 Sources() 零 JOIN 读取。
|
||
summary := do.CrawlSource{
|
||
LastLogAt: gtime.Now(),
|
||
LastLogFetched: res.Fetched,
|
||
LastLogNew: res.NewCount,
|
||
LastLogError: truncateStr(errMsg, 500),
|
||
}
|
||
|
||
if res.Err != nil {
|
||
failCount := src.FailCount + 1
|
||
summary.FailCount = failCount
|
||
// 达阈值自动禁用,防止持续无效空跑并污染日志。
|
||
if failCount >= autoDisableThreshold && src.Enabled == 1 {
|
||
summary.Enabled = 0
|
||
g.Log().Warningf(ctx, "数据源 %d(%s) 连续失败 %d 次,已自动禁用", src.Id, src.Name, failCount)
|
||
notifySourceDisabled(ctx, src, failCount, errMsg)
|
||
} else if failCount == autoDisableWarnThreshold {
|
||
// 首次达预警线时推送一次提醒,便于人工介入。
|
||
notifySourceFailing(ctx, src, failCount, errMsg)
|
||
}
|
||
} else {
|
||
summary.LastSuccessAt = gtime.Now().String()
|
||
summary.FailCount = 0
|
||
}
|
||
if _, err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Data(summary).Update(); err != nil {
|
||
g.Log().Errorf(ctx, "更新数据源状态失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// truncateStr 按最大字符数截断字符串,超长时添加省略标记。
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
if n <= 3 {
|
||
return s[:n]
|
||
}
|
||
return s[:n-3] + "..."
|
||
}
|
||
|
||
// ---------- 订阅辅助 ----------
|
||
|
||
// loadSubscription 加载推送订阅:id>0 按 id;否则取首个启用订阅。
|
||
func loadSubscription(ctx context.Context, id uint64) (*entity.PushSubscription, error) {
|
||
var sub entity.PushSubscription
|
||
m := dao.PushSubscription.Ctx(ctx)
|
||
if id > 0 {
|
||
m = m.Where("id", id)
|
||
} else {
|
||
m = m.Where("enabled", 1)
|
||
}
|
||
if err := m.OrderDesc("id").Scan(&sub); err != nil {
|
||
return nil, gerror.Wrap(err, "查询推送订阅失败")
|
||
}
|
||
if sub.Id == 0 {
|
||
return nil, nil
|
||
}
|
||
return &sub, nil
|
||
}
|
||
|
||
func doPushSubscription(in dto.SubscriptionInput) do.PushSubscription {
|
||
regions, _ := json.Marshal(in.Regions)
|
||
cats, _ := json.Marshal(in.Categories)
|
||
return do.PushSubscription{
|
||
Name: in.Name, DeviceKey: in.DeviceKey, Regions: string(regions),
|
||
Categories: string(cats), OnlyNew: in.OnlyNew, PushTime: in.PushTime,
|
||
Enabled: in.Enabled, UpdatedAt: gtime.Now(),
|
||
}
|
||
}
|
||
|
||
func parseJSONStrings(s string) []string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
_ = json.Unmarshal([]byte(s), &out)
|
||
return out
|
||
}
|
||
|
||
func parseJSONInts(s string) []int {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
var out []int
|
||
_ = json.Unmarshal([]byte(s), &out)
|
||
return out
|
||
}
|