service.xpcool.com/internal/service/recruitment/keywords_test.go
夏犀麟 ad4567fc01 feat: 招聘爬虫健壮性 + 通知记录增强(归档工作区留存改动)
这两批改动此前一直压在工作区未提交,本次一并归档。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,
生产库需手动导入。
2026-09-14 01:22:39 +08:00

206 lines
5.8 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.

// Package recruitment 的链接评分规则单元测试。
//
// 用贵阳市人社局「人事招考」列表页的真实锚点样本做回归,防止评分规则
// 调整后再次出现「导航页被误抓」或「真实公告被漏抓」两类问题。
package recruitment
import "testing"
// TestScoreAnchor 覆盖真实列表页中的典型锚点形态。
func TestScoreAnchor(t *testing.T) {
incWords := defaultIncludeWords
excWords := defaultExcludeWords
minScore := defaultMinScore
cases := []struct {
name string
a anchor
want bool // true=应保留公告正文false=应过滤(导航/栏目页)
}{
// ---------- 应保留:真实公告正文 ----------
{
name: "公告正文-带文件后缀",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202609/t20260908_90834079.html",
Text: "2026年贵阳贵安面向退役军人选拔培养中小学教师公告",
},
want: true,
},
{
name: "公告正文-标题不含标准词但URL是正文",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202603/t20260303_89596144.html",
Text: "贵阳贵安2026年公开招聘事业单位工作人员岗位取消情况公告",
},
want: true,
},
{
name: "公告正文-仅URL可判定",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202512/t20251225_89089652.html",
Text: "关于做好2026年度考试录用公务员工作的通知",
},
want: true,
},
// ---------- 应过滤:栏目目录页(以 / 结尾) ----------
{
name: "栏目页-人事招考",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/",
Text: "人事招考",
},
want: false,
},
{
name: "栏目页-重点领域公开",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/fdzdgklmzdlygk/",
Text: "重点领域公开",
},
want: false,
},
{
name: "栏目页-政府非公开信息目录",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zffgkxxml/",
Text: "政府非公开信息目录",
},
want: false,
},
{
name: "栏目页-建议提案公开",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/jytagk/",
Text: "建议提案公开",
},
want: false,
},
{
name: "栏目页-依申请公开",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html",
Text: "依申请公开",
},
want: false,
},
{
name: "导航页-政府信息公开制度",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/zfxxgkzd/",
Text: "政府信息 公开制度",
},
want: false,
},
{
name: "导航页-政府信息公开年报",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/zfxxgknb/",
Text: "政府信息 公开年报",
},
want: false,
},
{
name: "导航页-返回首页",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/",
Text: "返回首页",
},
want: false,
},
{
name: "导航页-机构概况",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkjggk/",
Text: "机构概况",
},
want: false,
},
// ---------- 应过滤:友链/外部站点URL 以 .html 结尾易误判) ----------
{
name: "友链-贵阳人才网",
a: anchor{
URL: "https://www.gyrc.cn/jobPosition/result.html",
Text: "贵阳人才网 - 贵阳市人才服务中心官方网站",
},
want: false,
},
{
name: "友链-信用中国",
a: anchor{
URL: "http://cx.guiyang.gov.cn/index.html",
Text: "信用中国(贵州贵阳)",
},
want: false,
},
{
name: "工具页-依申请公开",
a: anchor{
URL: "http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html",
Text: "贵阳市人力资源和社会保障局- 依申请公开",
},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := scoreAnchor(c.a, incWords, excWords, minScore)
if got != c.want {
t.Errorf("scoreAnchor() = %v, want %v\n url=%s\n text=%s",
got, c.want, c.a.URL, c.a.Text)
}
})
}
}
// TestCleanText 验证标题清洗:去除标签与折叠空白。
func TestCleanText(t *testing.T) {
cases := []struct{ in, want string }{
{"\n\t\t\t人事招考", "人事招考"},
{"贵阳贵安 <b>2026</b> 年招聘公告", "贵阳贵安 2026 年招聘公告"},
{" 前后空白 ", "前后空白"},
}
for _, c := range cases {
if got := cleanText(c.in); got != c.want {
t.Errorf("cleanText(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestIsImageFileName 验证图片文件名识别(用于剔除图片型锚点噪声)。
func TestIsImageFileName(t *testing.T) {
yes := []string{"ysqgk3.png", "banner.jpg", "logo.gif", "icon.SVG", "pic.webp"}
no := []string{"", "2026年招聘公告", "index.html", "关于招聘的通知"}
for _, s := range yes {
if !isImageFileName(s) {
t.Errorf("isImageFileName(%q) = false, want true", s)
}
}
for _, s := range no {
if isImageFileName(s) {
t.Errorf("isImageFileName(%q) = true, want false", s)
}
}
}
// TestExtractAnchorsImageFallback 验证图片型锚点不会以「文件名」形式进入候选。
// 复现真实页面:<a href=".../index.html"><img src="ysqgk3.png" title="ysqgk3.png"></a>
func TestExtractAnchorsImageFallback(t *testing.T) {
html := `<html><body>
<a href="http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html" id='gknb'>
<img src="../../images/ysqgk3.png" alt="ysqgk3.png" title="ysqgk3.png" /></a>
</body></html>`
got := extractAnchors(html, "http://rsj.guiyang.gov.cn/")
for _, a := range got {
if isImageFileName(a.Text) {
t.Errorf("图片型锚点应以文件名泄漏进候选: text=%q url=%s", a.Text, a.URL)
}
}
// 该锚点应被完全丢弃(无可读文本)。
if len(got) != 0 {
t.Errorf("期望丢弃图片型锚点,实际保留 %d 个: %+v", len(got), got)
}
}