service.xpcool.com/internal/service/recruitment/crawler.go
夏犀麟 ad4567fc01 feat: 招聘爬虫健壮性 + 通知记录增强(归档工作区留存改动)
这两批改动此前一直压在工作区未提交,本次一并归档。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,
生产库需手动导入。
2026-09-14 01:22:39 +08:00

824 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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便于插入 NULLDATE 等可空列不接受空串)。
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
}