Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 54s
- 修正 status 列默认值为 0,避免历史失败记录被误标为成功
- 历史数据回填改为幂等更新,可重复执行
- 过滤未解析的 ${...} 配置占位符,优化 Bark 推送错误提示
- LogDetail 记录不存在时统一返回参数错误,避免暴露 SQL 细节
351 lines
17 KiB
Go
351 lines
17 KiB
Go
# 招聘考试抓取推送模块 · 完善设计方案
|
||
|
||
> 模块路径:`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`(自定义日期正则)已实现解析但暂无源使用,
|
||
待具体站点需要时配置即可。
|