// 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 }