Skip to content

招聘平台怎么识别 AI 自动投简历?IP 属地聚合检测(Go 实现) ​

上个月有几个 HR 朋友吐槽:一个普通运营岗,一天收了 400 份简历,第一轮筛出来一半是同一批模板,简历里的项目经历连公司名都懒得改。这类「僵尸投递」现在基本靠 AI 脚本批量完成——Loopcv 这类工具能自动循环投 1000+ 职位,GitHub 上还有一堆 GPT 求职脚本。投的人省事了,平台的垃圾数据也来了:职位匹配度失真、HR 信任崩坏、简历库被注水。这文章讲平台侧怎么用 IP 归属地把这类自动投递识别出来。

一、自动投递脚本长什么样 ​

脚本投递和真人投递,行为特征差异很明显:

  • 频率规律:真人看职位、思考、写自我介绍,间隔几十秒到几分钟不等;脚本固定 2 秒一发,跟节拍器似的。
  • 单 IP 聚集:一台服务器或一个代理出口,一天内投递几十上百次,覆盖几十个账号。
  • 属地错乱:一个账号今天从北京投、明天从广州投、后天从成都投,且每次间隔极其均匀——真人出差都没这么勤快。
  • 忽略展示逻辑:浏览职位页的时间、停留时长、滚动深度这些「前端行为」全部缺失或完全一样。

其中「单 IP 高频投递」和「账号属地漂移」两项,靠 IP 归属地聚合就能抓个八九不离十。

二、方案:投递事件打属地标签 + 三规则聚合 ​

投递接口收到事件后,做三件事:取到真实客户端 IP(Nginx 反代后注意用 XFF 里最右边的可信 IP,别被伪造头骗了)、用归属地接口把 IP 打成「省-市-运营商」标签、进内存/Redis 聚合计数。然后三个规则并行判断:

  1. 单 IP 窗口投递数超阈值(比如 1 分钟 5 次)→ 高频标记,弹验证码;
  2. 单账号累计出现不同城市数超过 3 个且间隔短 → 属地漂移,转人工;
  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 。