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

351 lines
17 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.

# 招聘考试抓取推送模块 · 完善设计方案
> 模块路径`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.go` `filterArticleAnchors`
```go
// 现状伪代码
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.go` `extractDetail` `firstDate(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.go` `crawlInfo` `30*24*time.Hour`
**方案**读取 `crawl_source.config.incrDays`默认 30允许按源配置`Force` 手动触发时忽略该限制现有 `force` 语义保留
### P1-4 `crawl_source.config` 字段完全未被使用
**现状**表里有 `config VARCHAR(1000) COMMENT '适配器扩展配置(JSON)'`代码中**零引用**
**方案**定义并启用 `SourceConfig` 结构让该字段真正生效
```jsonc
{
"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.go` `Sources()`
```go
_ = 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.go` `recruit-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` 新增列
```sql
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.go``bark.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 关键调整二图片型锚点会以文件名污染标题
实测发现 `依申请公开` 未被过滤根因是
```html
<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.config` `MaxPages`翻页 `DatePatterns`自定义日期正则已实现解析但暂无源使用
待具体站点需要时配置即可