service.xpcool.com/docs/recruitment-crawler-design.md
夏犀麟 e79690518e
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 54s
fix(notice): 修复通知历史状态回填与配置占位符问题
- 修正 status 列默认值为 0,避免历史失败记录被误标为成功
- 历史数据回填改为幂等更新,可重复执行
- 过滤未解析的 ${...} 配置占位符,优化 Bark 推送错误提示
- LogDetail 记录不存在时统一返回参数错误,避免暴露 SQL 细节
2026-09-14 23:53:56 +08:00

17 KiB
Raw Blame History

招聘考试抓取推送模块 · 完善设计方案

模块路径:api/recruitment/internal/controller/recruitment/internal/service/recruitment/ 独立数据库:recruitment(与主库 service 隔离) 文档定位:本次「抓取健壮性」专项设计,供评审后实施。 实施状态阶段一已完成2026-09-14,实施中的关键调整见文末「七、实施结果」。


一、现状总览

1.1 架构分层

┌─────────────────────────────────────────────────────────────┐
│ 调度层  auto_job 表(主库 service.auto_job由 job 模块驱动) │
│   ├─ recruit-crawl-hourly  每小时  触发 crawl --all          │
│   └─ recruit-push-daily    每日08:00 触发 push --daily       │
└───────────────────────────┬─────────────────────────────────┘
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ 服务层  internal/service/recruitment/                        │
│  crawler.go      抓取主流程(静态两级 / SPA 占位 / curl 回退)│
│  scheduler.go    RegisterTasks 注册 cron 到 auto_job          │
│  bark.go         Bark 推送(单条 + 早报汇总)                 │
│  recruitment.go  查询/统计/订阅 CRUD                         │
└───────────────────────────┬─────────────────────────────────┘
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ 数据层  recruitment 库                                        │
│  crawl_source  数据源配置source_type 1静态 2SPA 3登录 4附件│
│  recruitment_info  公告主表UNIQUE fingerprint 去重)        │
│  crawl_log / push_log  运行日志                               │
│  push_subscription / organization                             │
└─────────────────────────────────────────────────────────────┘

1.2 已具备的能力

能力 实现位置 说明
静态列表抓取 genericStaticCrawl 列表页 → 详情页两级抓取
反 WAF fetchHTML curl 子进程优先,失败回退 Go gclient
编码兼容 fetchHTML gbk/gb2312 → utf-8 转码
去重 fingerprint 源ID + 标题 + 日期 + URL 哈希,唯一索引
跨源聚合 group_key 标题+日期归一化,识别同公告多源转载
增量控制 crawlInfo time.Since(publishDate) > 30天 跳过
调度 auto_job 支持启停 / 改 cron / 运行日志
推送 bark.go 订阅按地区+分类过滤,支持早报汇总
观测 crawl_log / fail_count 记录抓取量、错误、连续失败次数

1.3 数据源现状(002_seed_sources.sql

类型 地区 状态
贵阳市人社局-人事招考 1 静态 贵阳 启用
贵阳市政府-人事招考 1 静态 贵阳 启用
贵州人事考试信息网 2 SPA 省直 禁用(待 POC
贵州国资央企招聘平台 2 SPA 省直 禁用(待 POC
贵州茅台集团 4 附件型 省直 禁用(待 POC

关键结论:当前「招聘聚合」实际只覆盖 2 个贵阳本地静态源,省直/央企/国企类公告完全未覆盖。


二、问题清单

按严重度分级,本次专项聚焦 P0 / P1

P0-1 列表页链接过滤过严,存在系统性漏抓

位置crawler.gofilterArticleAnchors

// 现状伪代码
if urlHint && textHint {   // 两个条件必须同时满足才认为是公告
    keep(a)
}

问题textHint 要求锚文本包含「招聘/考录/招考/公告」等词。政府站的公告标题变体极多,例如:

  • 「XX局关于2026年补充工作人员的通知」→ 无「招聘」,漏
  • 「XX市2026年公开选调公务员简章」→ 无标准词,漏
  • 「XX单位引进高层次人才启事」→ 漏
  • 列表页若用「more」分页锚点锚文本为空 → 漏

影响:漏抓是静默的——crawl_log 只显示 fetched 变少,不会报错。这是当前最影响数据完整性的缺陷。

方案:改为**「URL 形态命中」为主、「文本命中」放宽**的组合评分:

score = 0
if url 匹配详情页正则(/art/、/\d{6,}\.html、/info/、content?id= then score += 2
if 文本命中标准词(招聘|考录|招考|选调|遴选|引进|人才|公告|简章|启事|通知) then score += 2
if 文本命中排除词(政策解读|常见问题|办事指南|下载中心|联系我们) then score -= 5
if 锚文本为空 或 长度<6 then score -= 2
if score >= 2 then keep(a)

同时把「词表」外置到 crawl_source.config,不同站点可定制,无需改代码。

P0-2 发布日期误取(整页第一个日期)

位置crawler.goextractDetailfirstDate(html)

问题政府详情页头部常有「今天是2026年9月13日 星期五」,firstDate 取全页第一个日期即取到它。导致 publish_date 全部错成抓取当天,进而:

  • 增量窗口判断失效永远「30天内」全量回抓
  • 看板趋势图失真(全部堆在当天)
  • 推送早报的「今日新增」虚高

方案:按优先级分段抽取,取第一个可信命中

  1. 优先 <meta name="PubDate" / "publishdate" / "og:published_time">
  2. 其次带语义容器的正则:<div class="(time|date|pubdate|info)">...2026-09-13...
  3. 再次匹配「发布时间:/发布日期:/日期:」前缀后的日期
  4. 全部未命中 → 置空,并在 crawl_log.error 记「日期未识别」,而非盲目取第一个

P1-3 增量窗口硬编码 30 天

位置crawler.gocrawlInfo30*24*time.Hour

方案:读取 crawl_source.config.incrDays,默认 30允许按源配置。Force 手动触发时忽略该限制(现有 force 语义保留)。

P1-4 crawl_source.config 字段完全未被使用

现状:表里有 config VARCHAR(1000) COMMENT '适配器扩展配置(JSON)',代码中零引用

方案:定义并启用 SourceConfig 结构,让该字段真正生效:

{
  "incrDays": 30,               // 增量窗口天数
  "listSelector": "ul.list li a", // 列表链接选择器(覆盖默认猜测)
  "detailSelector": ".content",   // 详情正文容器
  "includeWords": ["招聘","选调"],  // 覆盖默认标准词
  "excludeWords": ["政策解读"],     // 覆盖默认排除词
  "datePatterns": ["发布时间:(\\d{4}-\\d{2}-\\d{2})"],
  "maxPages": 3,                // 列表分页最大翻页数
  "delayMs": 800                // 详情页抓取间隔,避免过快
}

P1-5 Sources() 无 LIMIT 全表扫描

位置recruitment.goSources()

_ = dao.CrawlLog.Ctx(ctx).OrderDesc("id").Scan(&logs)  // 无分页

问题crawl_log 只增不减,每次打开「数据源状态」页都全量拉取到内存,随运行时间线性劣化。

方案:改为取每个源最近 1 条。两种实现任选:

  • 子查询:WHERE id IN (SELECT MAX(id) FROM crawl_log GROUP BY source_id)
  • 或新增 crawl_source.last_log_* 冗余列(写入时同步),查询零 JOIN

推荐后者,顺带解决 P1-6。

P1-6 失败无告警、无自动禁用

现状fail_count 累加了,但没有任何消费者。源静默失效(改版/封禁)不会被发现。

方案

  • 连续失败达阈值(默认 5→ 自动置 enabled=0,并推送一条「数据源已自动禁用」告警;
  • 抓取成功 → fail_count 归零(当前是否归零需确认,应立即归零);
  • 可选:失败达 3 次时先推送一次预警(未禁用)。

P2-7 调试日志残留

位置crawler.go 中 6 处 g.Log().Warningf(ctx, "[recruit-debug] ...")

方案:删除,或降为 Debugf。生产日志不应被调试信息污染。

P2-8 Bark 模块英文日志(翻译遗漏)

位置bark.go 第 92 / 98 / 128 / 174 行附近(load subscriptions failed 等)

方案:按项目「中文优先」约定统一中文化。

P2-9 push_time 字段是死配置

位置push_subscription.push_time vs scheduler.gorecruit-push-daily 固定 0 0 8 * * *

问题:订阅里设 09:30 不生效,永远 08:00 推送。

方案(本次不做,列入后续):recruit-push-daily 改为每小时跑一次,只处理 push_time 落在当前小时的订阅;或用动态 cron 按订阅分时注册。


三、SPA / 附件型源接入(后续阶段)

本次不做,但设计上预留。三个禁用源的技术路径预判:

预判路径 难度
贵州人事考试信息网 hash 路由背后通常是 POST /api/xxx/list 返回 JSON需抓包定位接口
贵州国资央企招聘平台iguopin 国聘系平台接口形态统一,通常有公开列表 API
茅台集团官网 附件型列表页→详情页→PDF 附件→解析 PDF 文本

建议:新增 source_type=5 接口型config 存接口地址与字段映射,用 JSONPath 提取。这样 SPA 源无需模拟浏览器,直接调底层接口,稳定且快。


四、表结构变更(本次仅 P1 相关)

沿用「不破坏现有数据」原则,全部为新增列,无需数据迁移。

4.1 crawl_source 新增列

ALTER TABLE `crawl_source`
  ADD COLUMN `last_log_at`      DATETIME     NULL DEFAULT NULL COMMENT '最近一次抓取时间(冗余,避免全表扫描 crawl_log)' AFTER `fail_count`,
  ADD COLUMN `last_log_fetched` INT          NOT NULL DEFAULT 0 COMMENT '最近一次抓取条数(冗余)' AFTER `last_log_at`,
  ADD COLUMN `last_log_new`     INT          NOT NULL DEFAULT 0 COMMENT '最近一次新增条数(冗余)' AFTER `last_log_fetched`,
  ADD COLUMN `last_log_error`   VARCHAR(500) NOT NULL DEFAULT '' COMMENT '最近一次错误信息(冗余)' AFTER `last_log_new`;

4.2 迁移脚本

新增 manifest/sql/recruitment/004_crawl_source_status.sql,幂等(ADD COLUMN IF NOT EXISTS 或部署前判存在)。


五、实施计划

阶段一抓取健壮性本次P0 + P1

# 任务 涉及文件
1 链接过滤改为评分制 + 词表可配 crawler.go
2 发布日期分段抽取 + 未识别记日志 crawler.go
3 增量窗口读 config.incrDays crawler.go
4 启用 SourceConfig 结构(含选择器覆盖、限速) crawler.go(新增 source_config.go
5 Sources() 查最近日志改为冗余列 recruitment.go + 表变更
6 失败达阈值自动禁用 + 告警推送 crawler.go + bark.go
7 删除调试日志、bark.go 中文化 crawler.gobark.go
8 迁移脚本 004_crawl_source_status.sql manifest/sql/recruitment/

验收标准

  • 2 个启用源抓取条数不低于手工核对数量(目标:不漏抓);
  • publish_date 与页面实际发布日期一致(抽样 10 条);
  • crawl_log 可按源查看最近一次结果;
  • 手动触发 force=true 可绕过增量窗口全量回溯。

阶段二:推送体系(后续)

  • push_time 真正生效(订阅分时推送);
  • 推送结果统计(订阅级成功率);
  • 失败重试Bark 失败重试 2 次,指数退避)。

阶段三:源扩展(后续)

  • source_type=5 接口型 + JSONPath 映射;
  • 接入贵州人事考试信息网、国资央企平台;
  • 茅台附件型PDF 下载 + 文本抽取。

阶段四:运维增强(后续)

  • crawl_log / push_log 定期归档(保留 90 天);
  • 抓取质量日报(新增数、失败源、异常波动告警)。

六、风险与注意事项

  1. 抓取频率与合规:政府站对高频访问敏感,delayMs 默认 800ms单源单次抓取控制在分钟级建议遵守 robots.txt 与站点条款。
  2. 词表误伤:评分制若阈值过低会引入噪声(如「招聘会预告」),需先用真实列表页离线验证,再上线。
  3. 日期置空的影响P0-2 改为「未识别则置空」后,依赖 publish_date 的统计会短期波动,属预期(此前是错误数据)。
  4. 自动禁用需谨慎:阈值过低会因偶发网络抖动误禁。建议结合「连续」失败(中间成功即归零),并推送告警以便人工复核。
  5. 表变更走迁移脚本:禁止手工改库;生成代码(若后续跑 gf gen dao)需注意本模块 entity 为手写,勿被覆盖。

七、实施结果2026-09-14

阶段一全部完成。实施过程中基于真实站点数据校准,方案有两处重要调整, 这两点也是本模块最容易踩的坑,特此记录。

7.1 变更清单

文件 类型 说明
internal/service/recruitment/keywords.go 新增 词表与评分规则(含排除域名)
internal/service/recruitment/source_config.go 新增 crawl_source.config 解析与默认值兜底
internal/service/recruitment/keywords_test.go 新增 16 个评分用例 + 标题清洗 + 图片锚点回归
internal/service/recruitment/crawler.go 修改 评分制过滤、日期分段抽取、限速、自动禁用、图片锚点处理、调试日志清理
internal/service/recruitment/bark.go 修改 告警推送、英文日志中文化、占位符过滤
internal/service/recruitment/recruitment.go 修改 Sources() 改读冗余列(零 JOIN
internal/model/entity/recruitment.go 修改 CrawlSource 增 4 个冗余列
internal/model/do/recruitment.go 修改 同上
manifest/sql/recruitment/004_crawl_source_status.sql 新增 幂等迁移(存储过程判列存在)+ 启用源 config 初始化

7.2 关键调整一评分以「URL 结尾形态」为准,而非路径关键词

设计原方案是按 URL 路径段(/zfxxgk/rszk 等)加权。实测发现此方案根本性错误

栏目页: /zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/                        ← 以 / 结尾
正文页: /zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202609/t20260908_xxx.html  ← 以 .html 结尾

两者共享同一段栏目路径,仅靠路径段无法区分;按路径段减分反而会把真实公告一并误杀 (实测第一版即因此把 19 条真实公告全部过滤,只剩 1 条)。

最终方案:只按「结尾形态」判定——

  • .html/.shtml/.htm 等结尾 → +3(正文)
  • / 结尾 → -5(栏目目录,必为列表页而非正文)

该判定经 16 个真实锚点用例验证,公告正文与栏目页/导航页全部分类正确。

7.3 关键调整二:图片型锚点会以「文件名」污染标题

实测发现 依申请公开 未被过滤,根因是:

<a href=".../ysqgk/index.html"><img src="ysqgk3.png" title="ysqgk3.png" /></a>

stripTags<img> 替换为空白后,title 属性回退逻辑取到了 title="ysqgk3.png" 于是锚点文本变成 ysqgk3.png——绕过了所有中文排除词,再叠加 .html 的 +3 分通过筛选。

处理:新增 isImageFileName 判定,图片型锚点优先取 alt,取不到则整体丢弃。

7.4 实测数据对比源1贵阳市人社局·人事招考

指标 修复前 修复后
增量模式抓取 8 条(含 6 个导航页噪声) 2 条(全为真实公告)
全量回溯force 8 条 21 条真实公告
发布日期识别率 大量误取(取页面头部日期) 100%0 条为空)
标题污染 \n\t\t\t 与图片文件名 已清洗

增量模式抓取条数少是正确的30 天窗口内源站确实只发布了 2 条。

7.5 验证方式

  • 单元测试go test ./internal/service/recruitment/ 全绿4 个测试函数 / 16 个评分用例);
  • 端到端:启动服务 → 管理端登录 → 触发抓取 → 核对入库数据与冗余列;
  • 失败链路:构造坏源(http://127.0.0.1:1),第 5 次失败时 enabled 自动置 0 日志输出「数据源 N 连续失败 5 次,已自动禁用」,告警推送按预期优雅降级(本地未配 Bark 仅记警告,不影响抓取)。

7.6 遗留事项

  • 阶段二(推送分时)、阶段三(源扩展)、阶段四(运维增强)未做,见「五、实施计划」。
  • push_time 字段仍未生效(recruit-push-daily 固定 08:00属阶段二范围。
  • crawl_source.configMaxPages(翻页)与 DatePatterns(自定义日期正则)已实现解析但暂无源使用, 待具体站点需要时配置即可。