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)`) styleRe = regexp.MustCompile(`(?is)`) titleRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) h1Re = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) 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 抽取 的 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)]+name=["']?(?:PubDate|publishdate|publish_date|Pubdate)["']?[^>]+content=["']([^"']+)["']`), regexp.MustCompile(`(?i)]+content=["']([^"']+)["'][^>]+name=["']?(?:PubDate|publishdate|publish_date)["']?`), regexp.MustCompile(`(?i)]+property=["']?(?:og:published_time|article:published_time|og:release_date)["']?[^>]+content=["']([^"']+)["']`), regexp.MustCompile(`(?i)]+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 会把 替换为空白,图片锚点(如仅含 // )经去标签后 inner 为空, // 随后的 title 属性回退会取到图片文件名,从而绕过语义词表。 // 故先尝试用 img 的 alt 作为文本,再回退 的 title,最后统一做 // 「图片文件名」筛除,避免这类噪声进入评分。 inner := strings.TrimSpace(collapseSpace(stripTags(block[1]))) text := inner if text == "" || isImageFileName(text) { text = firstAttr(block[1], imgAltRe) } // 文本过短时回退到 的 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. 优先 的 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. 语义容器内的日期(如
2026-09-13
)。 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 }