Appearance
招聘平台怎么识别 AI 自动投简历?IP 属地聚合检测(Go 实现)
上个月有几个 HR 朋友吐槽:一个普通运营岗,一天收了 400 份简历,第一轮筛出来一半是同一批模板,简历里的项目经历连公司名都懒得改。这类「僵尸投递」现在基本靠 AI 脚本批量完成——Loopcv 这类工具能自动循环投 1000+ 职位,GitHub 上还有一堆 GPT 求职脚本。投的人省事了,平台的垃圾数据也来了:职位匹配度失真、HR 信任崩坏、简历库被注水。这文章讲平台侧怎么用 IP 归属地把这类自动投递识别出来。
一、自动投递脚本长什么样
脚本投递和真人投递,行为特征差异很明显:
- 频率规律:真人看职位、思考、写自我介绍,间隔几十秒到几分钟不等;脚本固定 2 秒一发,跟节拍器似的。
- 单 IP 聚集:一台服务器或一个代理出口,一天内投递几十上百次,覆盖几十个账号。
- 属地错乱:一个账号今天从北京投、明天从广州投、后天从成都投,且每次间隔极其均匀——真人出差都没这么勤快。
- 忽略展示逻辑:浏览职位页的时间、停留时长、滚动深度这些「前端行为」全部缺失或完全一样。
其中「单 IP 高频投递」和「账号属地漂移」两项,靠 IP 归属地聚合就能抓个八九不离十。
二、方案:投递事件打属地标签 + 三规则聚合
投递接口收到事件后,做三件事:取到真实客户端 IP(Nginx 反代后注意用 XFF 里最右边的可信 IP,别被伪造头骗了)、用归属地接口把 IP 打成「省-市-运营商」标签、进内存/Redis 聚合计数。然后三个规则并行判断:
- 单 IP 窗口投递数超阈值(比如 1 分钟 5 次)→ 高频标记,弹验证码;
- 单账号累计出现不同城市数超过 3 个且间隔短 → 属地漂移,转人工;
- 单 IP 关联账号数超过 3 个 → 疑似脚本池,整组降权。
判断结果只做「验证码 / 转人工 / 降权」,不直接拒投——批量真实投递(校招 HR、猎头顾问)确实存在,后文讲怎么兜底。
三、Go 实现
为什么用 Go:投递接口是典型的高并发写路径,Go 的协程 + 内存计数几乎零成本,先跑起来再说。下面是一个可运行的完整示例:HTTP 服务接收投递事件 → 打属地标签 → 三规则判定 → 返回风险等级。归属地用 IP9 免费接口 https://ip9.com.cn/get?ip=<ip>(60 次/分钟免费额度,代码里用 24 小时缓存把实际请求量压到极低)。
go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const geoAPI = "https://ip9.com.cn/get?ip=%s"
var client = &http.Client{Timeout: 5 * time.Second}
type GeoInfo struct {
Prov string `json:"prov"`
City string `json:"city"`
Isp string `json:"isp"`
}
type geoResp struct {
Ret int `json:"ret"`
Data GeoInfo `json:"data"`
}
// --- 归属地查询:带 24h 缓存的本地 LRU(简化版) ---
var geoCache sync.Map // ip -> cacheItem
type cacheItem struct {
info GeoInfo
exp time.Time
}
func lookupGeo(ip string) (GeoInfo, error) {
if v, ok := geoCache.Load(ip); ok {
item := v.(cacheItem)
if time.Now().Before(item.exp) {
return item.info, nil
}
}
req, _ := http.NewRequest("GET", fmt.Sprintf(geoAPI, ip), nil)
req.Header.Set("User-Agent", "job-app-anti-bot/1.0")
resp, err := client.Do(req)
if err != nil {
return GeoInfo{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var r geoResp
if err := json.Unmarshal(body, &r); err != nil || r.Ret != 200 {
return GeoInfo{}, fmt.Errorf("geo api ret=%d", r.Ret)
}
geoCache.Store(ip, cacheItem{info: r.Data, exp: time.Now().Add(24 * time.Hour)})
return r.Data, nil
}
// --- 聚合计数:1 分钟窗口,生产环境建议换 Redis ---
type Aggregator struct {
mu sync.Mutex
perIP map[string]int // ip -> 窗口内投递次数
ipAcct map[string]map[string]bool // ip -> 关联账号集合
acctCity map[string]map[string]bool // 账号 -> 出现过的城市集合
}
func NewAggregator() *Aggregator {
a := &Aggregator{
perIP: map[string]int{},
ipAcct: map[string]map[string]bool{},
acctCity: map[string]map[string]bool{},
}
go func() { // 每分钟重置窗口(简化滑动窗口)
for range time.Tick(1 * time.Minute) {
a.mu.Lock()
a.perIP = map[string]int{}
a.mu.Unlock()
}
}()
return a
}
func (a *Aggregator) record(acct, ip, city string) {
a.mu.Lock()
defer a.mu.Unlock()
a.perIP[ip]++
if a.ipAcct[ip] == nil {
a.ipAcct[ip] = map[string]bool{}
}
a.ipAcct[ip][acct] = true
if a.acctCity[acct] == nil {
a.acctCity[acct] = map[string]bool{}
}
a.acctCity[acct][city] = true
}
func (a *Aggregator) risk(acct, ip string) []string {
a.mu.Lock()
defer a.mu.Unlock()
var reasons []string
if a.perIP[ip] >= 5 {
reasons = append(reasons, fmt.Sprintf("单IP窗口投递%d次超阈值", a.perIP[ip]))
}
if len(a.ipAcct[ip]) >= 3 {
reasons = append(reasons, fmt.Sprintf("单IP关联%d个账号", len(a.ipAcct[ip])))
}
if len(a.acctCity[acct]) >= 3 {
reasons = append(reasons, fmt.Sprintf("账号累计出现%d个城市", len(a.acctCity[acct])))
}
return reasons
}
// --- 投递事件处理 ---
type ApplyEvent struct {
Account string `json:"account"`
IP string `json:"ip"`
}
func main() {
agg := NewAggregator()
http.HandleFunc("/apply", func(w http.ResponseWriter, r *http.Request) {
var ev ApplyEvent
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil || ev.IP == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
geo, err := lookupGeo(ev.IP)
if err != nil {
// 归属地接口故障时降级:只按频率规则判,不拦截
geo = GeoInfo{Prov: "unknown", City: "unknown"}
}
city := geo.Prov + "-" + geo.City
agg.record(ev.Account, ev.IP, city)
reasons := agg.risk(ev.Account, ev.IP)
level := "low"
if len(reasons) > 0 {
level = "medium"
}
if len(reasons) >= 2 {
level = "high"
}
json.NewEncoder(w).Encode(map[string]any{
"risk": level,
"reasons": reasons,
"region": city,
})
})
http.ListenAndServe(":8080", nil)
}运行:go run main.go,然后 curl -X POST localhost:8080/apply -d '{"account":"u01","ip":"114.114.114.114"}'。同一 IP 连打 5 次,响应里的 risk 就会从 low 变 medium。生产化要改三处:计数换 Redis 做多实例共享、窗口用真正的滑动窗口、/apply 前的取 IP 逻辑接网关层(XFF 取最右非信任代理的 IP)。
四、落地注意事项
- 别把真人群伤进去。批量真实投递是存在的:校招 HR 在一个网段里批量操作、猎头用企业出口集中投递。所以阈值要按平台真实流量统计分位数来定,命中后先弹验证码而不是直接拒。
- IP 证明不了「人」。脚本作者换个代理池,单 IP 规则立刻失效——这时候靠属地漂移 + 行为特征(投递间隔方差、简历模板相似度)交叉验证。IP 归属地负责把可疑候选圈出来,精判交给行为模型。
- 代理和机房流量直接降权。IP9 免费版能拿到运营商(
isp)字段,VIP 版还能区分区县和ip_type(ISP 住宅 / BUS 企业 / IDC 机房)。大量脚本跑在机房 IP 上,机房来源的投递天然低权重。 - 合规:收集 IP 用于反作弊要写进隐私政策;判定依据存档,方便申诉时解释。
总结
自动投简历的本质是「低个体成本 + 高频重复」,IP 归属地聚合正好打在这两个特征上:单 IP 高频、单 IP 多账号、账号属地漂移,三个规则用 Go 一个文件就能跑起来。归属地数据用 IP9 免费接口 https://ip9.com.cn/get?ip=xxx 即可(无需注册、60 次/分钟,配合缓存几乎不产生压力)。先圈可疑、再人工精判,比一刀切拦截稳得多。官网:https://www.ip9.com.cn 。