这两批改动此前一直压在工作区未提交,本次一并归档。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,
生产库需手动导入。
171 lines
7.1 KiB
Go
171 lines
7.1 KiB
Go
// Package recruitment 的链接筛选词表与评分规则。
|
||
//
|
||
// 背景:政府站列表页包含大量导航/栏目/互动链接,需要从中挑出「招聘公告详情页」。
|
||
// 早期实现要求「URL 形态命中」与「文本语义命中」同时成立,导致「补充工作人员的通知」
|
||
// 「公开选调公务员简章」等标题变体被系统性漏抓(且静默无报错)。
|
||
//
|
||
// 现改为评分制:URL 形态加分、标准词加分、排除词大幅减分,达到阈值即入选。
|
||
// 词表与阈值均可通过 crawl_source.config 覆盖(见 source_config.go)。
|
||
package recruitment
|
||
|
||
import "strings"
|
||
|
||
// defaultIncludeWords 招聘类公告常见语义词(标准词)。
|
||
// 命中即认为「像一条招聘公告」,覆盖招聘/考试/人才引进/选调遴选等各口径。
|
||
var defaultIncludeWords = []string{
|
||
"招聘", "招考", "招录", "招募", "公招",
|
||
"选调", "遴选", "选聘", "聘用", "拟聘",
|
||
"引进", "人才", "急需紧缺",
|
||
"公告", "简章", "启事", "通知", "公示",
|
||
"报名", "笔试", "面试", "体检", "考察", "资格复审", "递补",
|
||
"考试", "考录", "录用", "录取", "拟录用",
|
||
"事业单位", "公务员", "国企", "央企", "编制",
|
||
}
|
||
|
||
// defaultExcludeWords 明确的非公告页面词汇,命中则大幅减分(优先排除)。
|
||
// 这些是政府站常见的固定栏目与工具页,其 URL 形态常与详情页相似,仅靠 URL 无法区分。
|
||
var defaultExcludeWords = []string{
|
||
"政策解读", "常见问题", "办事指南", "下载中心", "联系我们", "网站地图",
|
||
"市长信箱", "在线咨询", "意见建议", "问卷调查", "友情链接", "版权声明",
|
||
"无障碍", "长者版", "繁體", "英文版", "手机版", "返回顶部",
|
||
"信息公开指南", "依申请公开", "年度报告", "财政预决算", "机构职能",
|
||
"领导之窗", "内设机构", "直属单位", "RSS",
|
||
// 以下为实测中被误抓的信息公开类导航页(贵阳市人社局站点)。
|
||
"政府信息公开制度", "政府信息公开年报", "政府非公开信息目录",
|
||
"建议提案公开", "重点领域公开", "信息公开目录", "信息公开年报",
|
||
"公开指南", "公开制度", "预决算公开", "三公经费", "权责清单",
|
||
// 友情链接 / 外部站点入口(政府站页脚常见,URL 常以 .html 结尾易误判)。
|
||
"友情链接", "贵阳人才网", "信用中国", "人才服务中心",
|
||
"官方网站", "政务服务网", "返回", "首页",
|
||
// 站内智能客服 / AI 助手入口(URL 形如 /ai/{id}/index.html)。
|
||
"人社小智", "智能客服", "在线客服", "AI助手", "智能问答",
|
||
}
|
||
|
||
// defaultExcludeHosts 排除的域名关键词——属于「外部链接」而非本站公告。
|
||
// 政府站页脚常挂友链(人才网、信用中国等),其 URL 形态与正文页相似,
|
||
// 仅靠路径无法区分,故按域名做二级过滤。
|
||
var defaultExcludeHosts = []string{
|
||
"gyrc.cn", // 贵阳人才网
|
||
"cx.guiyang.gov.cn", // 信用中国(贵州贵阳)
|
||
"zwfw.guizhou.gov.cn", // 贵州政务服务网
|
||
}
|
||
|
||
// contentSuffixes 内容页 URL 后缀——真实公告正文的特征。
|
||
//
|
||
// 核心区分逻辑(经实测校准):
|
||
// - 栏目目录页以「/」结尾,如 `/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/`;
|
||
// - 公告正文以文件后缀结尾,如 `.../rszk/202609/t20260908_90834079.html`。
|
||
//
|
||
// 注意:两者共享前半段栏目路径,故**绝不能**按路径段判断,
|
||
// 只能按「结尾形态」区分——这是本模块最易踩的坑。
|
||
var contentSuffixes = []string{".html", ".shtml", ".htm", ".jsp", ".asp", ".php"}
|
||
|
||
// contentPathHints 内容页路径关键词(部分 CMS 用无后缀的动态路径,如 /art/123456)。
|
||
var contentPathHints = []string{"/art/", "/info/", "/content", "/detail"}
|
||
|
||
// columnPathHints 栏目目录页的「结尾路径段」——仅当 URL 以此结尾(或以 / 续接)时判为栏目页。
|
||
// 用于压制「人事信息」「重点领域公开」等导航链接,但不误伤嵌在同名栏目下的正文。
|
||
var columnPathHints = []string{
|
||
"/zfxxgk", "/fdzdgklm", "/zdlygk", "/zffgk", "/xxgkml", "/ysqgk",
|
||
"/rszk", "/rsxx", "/zwgk", "/tzgg", "/gsgg", "/ksxx", "/zfcg",
|
||
"/zfxxgkzn", "/zfxxgkzd", "/zfxxgknb", "/zffgkxxml", "/jytagk",
|
||
"/index", "/list", "/more",
|
||
}
|
||
|
||
// scoreAnchor 对单个锚点按评分制打分,分数 >= minScore 视为公告正文页。
|
||
//
|
||
// 评分规则(阈值可调,见 source_config.go 的 MinScore):
|
||
// - URL 以内容页后缀结尾(.html 等) +3 ← 最强正向特征
|
||
// - URL 命中动态内容路径(/art/、/info/ 等) +2
|
||
// - URL 含年月日期路径(/2026/09/13/) +1
|
||
// - 文本命中标准词 +2
|
||
// - 文本命中排除词 -6(强排除)
|
||
// - URL 为纯目录(以「/」结尾,无文件后缀) -5 ← 栏目页特征
|
||
// - 文本过短(<6 字) -2
|
||
//
|
||
// 设计取舍:相比原「URL 与文本必须同时命中」的硬条件,评分制允许
|
||
// 「标题不含标准词但 URL 是正文页」的情况入选(修复漏抓),
|
||
// 同时以「结尾形态」压制栏目页噪声(避免误抓导航链接)。
|
||
func scoreAnchor(a anchor, incWords, excWords []string, minScore int) bool {
|
||
text := strings.TrimSpace(a.Text)
|
||
lower := strings.ToLower(a.URL)
|
||
score := 0
|
||
|
||
// 0. 域名硬排除:友链/外部站点入口直接判定为非公告(不问评分)。
|
||
for _, h := range defaultExcludeHosts {
|
||
if strings.Contains(lower, h) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 1. 最强正向特征:以内容页后缀结尾。
|
||
hasContentSuffix := false
|
||
for _, suf := range contentSuffixes {
|
||
if strings.HasSuffix(lower, suf) {
|
||
score += 3
|
||
hasContentSuffix = true
|
||
break
|
||
}
|
||
}
|
||
// 2. 动态内容路径(无后缀的 CMS 详情页)。
|
||
if !hasContentSuffix {
|
||
for _, p := range contentPathHints {
|
||
if strings.Contains(lower, p) {
|
||
score += 2
|
||
break
|
||
}
|
||
}
|
||
}
|
||
// 3. URL 含年月日期路径(内容页的常见特征)。
|
||
if yearPathRe.MatchString(a.URL) || dateRe.MatchString(a.URL) {
|
||
score++
|
||
}
|
||
|
||
// 4. 文本标准词。
|
||
if containsAny(text, incWords) {
|
||
score += 2
|
||
}
|
||
|
||
// 5. 负向:排除词(导航/工具页)。
|
||
if containsAny(text, excWords) {
|
||
score -= 6
|
||
}
|
||
|
||
// 6. 负向:纯目录页。判定依据是「以 / 结尾」——此形态必为栏目而非正文。
|
||
// 同时要求 URL 的末段命中栏目路径词,进一步确保是导航目录。
|
||
if strings.HasSuffix(lower, "/") {
|
||
score -= 5
|
||
} else {
|
||
for _, p := range columnPathHints {
|
||
if strings.HasSuffix(lower, p) {
|
||
score -= 5
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7. 负向:过短文本(「更多/详细/MORE」等分页锚点)。
|
||
if len([]rune(text)) < 6 {
|
||
score -= 2
|
||
}
|
||
|
||
return score >= minScore
|
||
}
|
||
|
||
// containsAny 判断文本是否命中任一词(不区分大小写,兼顾英文词如 RSS)。
|
||
func containsAny(text string, words []string) bool {
|
||
if text == "" || len(words) == 0 {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(text)
|
||
for _, w := range words {
|
||
if w == "" {
|
||
continue
|
||
}
|
||
if strings.Contains(lower, strings.ToLower(w)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|