Java爬虫实战:招聘数据聚合与薪资分析系统设计

发布时间:2026/10/19 9:35:04
Java爬虫实战:招聘数据聚合与薪资分析系统设计
1. 项目缘起与整体设计思路做招聘数据聚合这件事我在不同阶段尝试过至少三种方案。最早是手动复制粘贴后来用浏览器插件半自动采集再后来才落到Java爬虫这条路上。最终选择Java作为主力语言原因很直接生态成熟、并发模型清晰、反爬对抗时可控粒度细而且团队里后端同学多维护成本低。这个项目的目标很明确——把某头部招聘平台后文统一用“目标平台”代称上指定城市、指定关键词的岗位信息批量抓下来清洗后落到本地数据库供后续的薪资分析、技能词频统计、岗位趋势观察使用。需要先说明的是这类项目本质上属于“公开信息聚合”范畴采集的是求职者本来就能在页面上看到的岗位标题、薪资区间、公司简称、经验学历要求、技能标签等字段。我在设计之初就给自己划了几条线不碰登录态数据、不采集任何个人联系方式、控制请求频率、遵守目标平台公开的robots约定。这不是唱高调而是因为一旦越界轻则IP被封、账号受限重则项目直接作废前面写的代码全部白费。整体架构我拆成了四层这个分层是踩过坑之后定下来的不是拍脑袋调度层负责生成待抓取的URL队列控制并发数和请求间隔是整个爬虫的“节拍器”。下载层封装HTTP请求处理请求头、Cookie、重试、超时是唯一直接和目标平台对话的模块。解析层把HTML或接口返回的JSON转成结构化对象是逻辑最重、最容易随页面改版而失效的一层。存储层负责去重、字段清洗、入库保证同一岗位不会因为翻页重复而写多条。为什么这么分因为目标平台的页面结构和接口参数会变。如果下载和解析揉在一起改版时你要动的地方就特别多。分层之后页面改版通常只需要改解析层接口换参数只需要改调度层互不干扰。我实测下来这种分层让后期维护的工作量至少减少了一半。技术选型上HTTP客户端我用的是OkHttp解析用Jsoup配合少量正则JSON处理用Jackson存储用MySQL加一层本地文件缓存做去重。没有上Selenium这类浏览器自动化方案原因是它太重、太慢一个岗位页面渲染要好几秒抓几千条数据时间成本太高。目标平台的岗位列表页在多数情况下是服务端渲染的直接请求HTML就能拿到数据没必要动用浏览器。只有在极少数动态加载的场景下我才会考虑用接口逆向的方式替代而不是直接上浏览器。提示项目开始前先花半小时手动翻十几页岗位列表用浏览器开发者工具看请求判断数据是服务端直出还是异步接口返回。这一步决定了你后面是解析HTML还是解析JSON方向错了后面全是白工。2. 核心细节解析与实操要点2.1 请求头与身份伪装的关键参数目标平台对请求来源的识别主要看几个东西User-Agent、Referer、Cookie里的部分字段以及请求频率。我一开始图省事只带了一个默认UA结果前二十个请求正常第二十一个开始返回验证页。后来把请求头补全情况立刻好转。我常用的请求头组合是这样的Request.Builder builder new Request.Builder() .url(url) .header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36) .header(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8) .header(Accept-Language, zh-CN,zh;q0.9) .header(Referer, https://目标平台域名/) .header(Connection, keep-alive);这里有几个细节值得展开。User-Agent不要用网上随便抄的“爬虫专用UA”那种反而更容易被识别。用主流浏览器的真实UA字符串并且整个项目里保持一致不要一会儿Chrome一会儿Firefox行为特征太乱。Referer要指向目标平台的首页或列表页模拟“从站内点进来”的正常路径。Accept-Language带上zh-CN因为目标平台主要服务中文用户缺失这个头会显得可疑。Cookie的处理要谨慎。我个人的做法是不主动伪造登录Cookie只用匿名访问时服务端下发的会话标识。首次请求时让OkHttp自动接收Set-Cookie后续请求带上即可。如果你强行塞一个来源不明的登录Cookie一旦被风控标记整个Cookie对应的会话都会失效得不偿失。2.2 请求频率控制与并发策略这是整个项目里最容易被忽视、但影响最大的环节。我见过太多人为了“快”开几十个线程猛冲结果五分钟内IP就被限流后面几小时都抓不了。目标平台对单IP的请求频率是有隐性阈值的具体数值会变但经验值大概在每分钟几十次这个量级。我的策略是单机并发控制在3到5个线程每个请求之间加随机延迟延迟范围设在1.5秒到4秒之间。为什么是随机而不是固定因为固定间隔的请求序列在服务端看来是高度规律的机器行为随机化之后更像人在翻页。代码里我用一个简单的随机数生成器实现private static final Random RANDOM new Random(); private void randomSleep() { try { long sleepMs 1500 RANDOM.nextInt(2500); Thread.sleep(sleepMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }并发数不是越多越好。我实测过3线程配随机延迟一小时能稳定抓取800到1200条岗位详情这个速度对个人分析项目完全够用。如果你把并发提到10短期吞吐上去了但触发验证的概率大幅上升一旦被拦重试成本远高于省下来的时间。注意不要用固定线程池无脑submit任务。任务队列要有界并且每个任务执行前都调用一次随机延迟。我踩过的坑是任务提交太快延迟加在了错误的位置等于没加。2.3 页面解析的字段定位技巧解析层是整个项目里最“脆”的部分因为目标平台的页面结构随时可能调整。我的应对思路是优先用稳定的语义化选择器避免依赖那些一看就是自动生成的class名。岗位列表页里每个岗位卡片通常包含这些字段岗位名称、薪资区间、公司名称、公司规模、融资阶段、技能标签、经验要求、学历要求。定位时我一般这样写Document doc Jsoup.parse(html); Elements jobCards doc.select(li.job-card-wrapper); for (Element card : jobCards) { String title card.select(span.job-name).text(); String salary card.select(span.salary).text(); String company card.select(h3.company-name).text(); // 其余字段类似 }这里的关键经验是选择器要写得“宽”一点不要精确到第几层第几个子元素。比如用span.job-name而不是div div span:nth-child(2)。前者在页面微调时大概率还能用后者一改就废。另外每个字段解析时都要做空值判断因为有些岗位不显示薪资或技能标签直接.text()会抛异常。如果目标平台改成了接口返回JSON解析逻辑就要换成Jackson。接口返回的字段名通常比HTML的class名更稳定因为改字段名意味着前端也要跟着改成本高。所以一旦确认是接口数据优先走接口路线长期维护更省心。2.4 数据去重与存储设计去重这件事我一开始想得太简单以为用岗位链接做唯一键就够了。实际跑起来发现同一个岗位在不同关键词下会重复出现链接也可能带不同的追踪参数。后来我改成用“岗位名称公司名称薪资区间”拼成一个业务指纹再做MD5作为去重依据。这个组合的碰撞概率很低实测几千条数据里没有误判。存储层我用MySQL建了一张主表字段包括指纹、岗位名称、公司名称、薪资下限、薪资上限、薪资月数、经验要求、学历要求、技能标签、城市、抓取时间。薪资字段单独拆出下限上限和月数是为了后面做统计分析时方便计算。技能标签用逗号分隔存成字符串虽然不符合范式但查询时用LIKE足够没必要上关联表。入库时用INSERT ... ON DUPLICATE KEY UPDATE把指纹设成唯一索引。这样重复数据不会报错只会更新抓取时间。批量入库时每500条提交一次比逐条插入快一个数量级。3. 实操过程与核心环节实现3.1 从零搭建项目骨架项目我用Maven管理依赖核心依赖就四个OkHttp、Jsoup、Jackson、MySQL Connector。pom里版本我习惯锁定避免不同环境拉到不同版本导致行为不一致。dependencies dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.16.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.3.0/version /dependency /dependencies包结构我分成fetcher、parser、model、store、scheduler五个包。model里放岗位实体类字段和数据库表一一对应。scheduler负责生成URL队列我用的策略是先抓列表页拿到岗位详情链接再把详情链接放进队列逐个抓取。列表页翻页通过URL里的页码参数控制抓到没有岗位卡片为止。3.2 列表页抓取与详情链接提取列表页的URL结构通常是/web/geek/job?query关键词city城市编码page页码。城市编码需要提前查好比如某城市对应一个固定数字。我一般把关键词和城市编码做成配置方便换目标。抓列表页时我先把整页HTML拿到然后用Jsoup提取所有岗位卡片的详情链接。这里要注意详情链接可能是相对路径需要拼上域名前缀。提取完一页后判断是否还有下一页如果当前页的岗位卡片数量小于预期值比如正常一页30条这页只有12条说明已经到末尾停止翻页。private ListString extractDetailUrls(String listHtml) { ListString urls new ArrayList(); Document doc Jsoup.parse(listHtml); Elements links doc.select(a.job-card-left); for (Element link : links) { String href link.attr(href); if (href.startsWith(/)) { href BASE_URL href; } urls.add(href); } return urls; }翻页时页码从1开始递增每翻一页调用一次随机延迟。我设了一个上限比如最多翻50页防止因为判断逻辑出错导致无限翻页。3.3 详情页字段解析与清洗详情页的信息比列表页丰富除了列表页已有的字段还能拿到岗位描述全文、公司详细地址、福利标签等。岗位描述全文是后面做技能词频分析的核心素材所以一定要完整抓下来。解析详情页时我遇到的最大问题是岗位描述里混有大量HTML标签和特殊字符。直接用.text()能去掉标签但会丢失换行结构。我的做法是先取.html()再用正则把br替换成换行符把p替换成换行加空行最后用Jsoup的clean方法去掉剩余标签。这样既保留了段落结构又去掉了干扰标签。薪资字段的清洗要单独处理。目标平台的薪资格式有好几种“15-25K”、“15-25K·14薪”、“200-300元/天”。我写了一个解析方法用正则提取数字和单位统一转换成“月薪下限、月薪上限、月数”三个字段。日薪的岗位单独标记不参与月薪统计。private Salary parseSalary(String raw) { // 匹配 15-25K·14薪 这类格式 Pattern p Pattern.compile((\\d)-(\\d)([Kk万])(?:·(\\d)薪)?); Matcher m p.matcher(raw); if (m.find()) { int low Integer.parseInt(m.group(1)); int high Integer.parseInt(m.group(2)); String unit m.group(3); int months m.group(4) ! null ? Integer.parseInt(m.group(4)) : 12; if (万.equals(unit)) { low * 10; high * 10; } return new Salary(low, high, months); } return null; }3.4 入库与断点续抓入库我用的是批量提交每攒够500条或者队列空了就flush一次。批量插入的SQL用ON DUPLICATE KEY UPDATE唯一索引建在指纹字段上。断点续抓这个功能我强烈建议加上。爬虫跑一半因为网络波动或程序异常中断是常事如果没有断点记录重启后要么从头再来要么手动去重。我的做法是每抓完一个列表页就把当前的关键词、城市、页码写到一个进度文件里。重启时先读进度文件从上次的页码继续。详情页的抓取进度则通过数据库里的指纹判断已经存在的岗位直接跳过不再请求详情页。private void saveProgress(String keyword, String city, int page) { String line keyword , city , page; Files.write(Paths.get(progress.txt), line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); }这个进度文件很简单但省了我很多事。有一次程序跑到第37页崩了我改完bug重启直接从第38页继续前面37页的数据一条没丢。4. 常见问题与排查技巧实录4.1 请求被拦截的典型表现与应对被拦截的表现有好几种每种对应的处理方式不一样。我整理了一张速查表现象可能原因应对方式返回状态码403请求头缺失或被识别为爬虫补全User-Agent、Referer检查Cookie返回验证页面HTML请求频率过高降低并发加大随机延迟暂停一段时间返回空列表页面结构改版或参数失效手动访问确认页面是否正常检查选择器连接超时网络问题或IP被限检查网络更换出口降低频率返回数据乱码编码未正确处理确认响应头编码强制UTF-8解析我遇到最多的是第二种验证页面。第一次遇到时我慌了以为IP废了。后来发现只要暂停半小时降低频率再跑就恢复正常了。所以遇到验证页不要硬刚停下来等一等比反复重试有效得多。4.2 页面改版导致解析失败的排查流程页面改版是爬虫的“慢性病”迟早会遇到。我的排查流程是固定的第一步手动用浏览器打开一个详情页确认页面本身能正常显示第二步用curl或Postman发一个同样的请求看返回的HTML和浏览器看到的是否一致第三步如果不一致说明有动态渲染或反爬需要调整策略第四步如果一致把返回的HTML存到本地用Jsoup的选择器逐个测试找出失效的那个。我习惯在解析层加一层“字段命中率”日志。每次解析完一批数据统计每个字段的非空率。如果某个字段的非空率突然从95%掉到10%基本可以确定是选择器失效了。这个日志帮我提前发现了好几次改版不用等到数据入库才发现字段全空。4.3 数据质量问题的处理经验抓下来的数据不是每条都干净。我遇到过这几种脏数据薪资区间上下限颠倒、公司名称带多余空格、技能标签里混入HTML实体字符。这些问题如果不在入库前处理后面做分析时全是坑。我的处理原则是能在解析层清洗的绝不留给存储层。薪资上下限颠倒的解析时判断一下如果下限大于上限就交换。公司名称用trim()去掉首尾空格中间连续空格替换成单个。技能标签里的nbsp;这类HTML实体用StringEscapeUtils.unescapeHtml4()统一转义。还有一类问题是重复数据。前面说了用指纹去重但指纹本身也可能因为字段清洗不彻底而失效。比如同一个岗位一次抓到公司名是“某科技”另一次是“某科技 ”多了个空格指纹就不一样了。所以清洗必须在生成指纹之前完成顺序不能反。4.4 长期运行的稳定性建议如果这个爬虫要长期跑比如每天定时抓一次增量有几个点必须注意。第一日志要分级INFO级别记录每页抓取结果ERROR级别记录异常方便出问题时快速定位。第二异常要捕获但不吞掉每个请求的异常都记录URL和堆栈但不要因为一个请求失败就中断整个任务。第三定时任务用系统级的调度工具触发不要用while(true)加sleep后者一旦程序卡死就彻底不动了。我自己的增量策略是每天凌晨跑一次只抓列表页前5页的新岗位详情页只抓数据库里不存在的。这样每天新增数据量不大对目标平台的压力也小长期跑下来很稳。提示增量抓取时列表页的排序方式要固定比如按“最新发布”排序。如果排序方式变了前5页可能全是旧岗位增量就失效了。5. 从数据到洞察的延伸玩法数据抓下来只是第一步真正有价值的是后面的分析。我用这批数据做过几件事效果不错这里分享一下思路。第一件是薪资分布分析。把月薪下限和上限取平均按城市和岗位关键词分组算出中位数和四分位数。这个分析能直观看出不同城市的薪资水平差异以及同一岗位在不同经验要求下的薪资跨度。我用它验证过一个直觉同样三年经验的开发岗某新一线城市的薪资中位数大概是一线城市的七成左右。第二件是技能词频统计。把岗位描述全文分词后统计高频技能词。这个分析能看出市场上对哪些技术栈的需求在上升。我做过一次前后两个季度的对比发现某个框架的提及率涨了将近一倍后来果然成了招聘热点。这种趋势观察对个人学习方向的选择很有参考价值。第三件是公司招聘活跃度排行。按公司名称分组统计每个公司发布的岗位数量和更新频率。招聘活跃的公司往往业务在扩张对求职者来说是值得关注的目标。这些分析都不需要复杂的工具用SQL加一点Java代码就能跑出来。数据量在几万条以内单机MySQL完全扛得住。关键是字段要清洗干净尤其是薪资和技能标签脏数据会让分析结果完全失真。我个人在实际操作中的体会是爬虫项目的难点从来不在“爬”这个动作本身而在于对目标平台行为的理解、对数据质量的把控、以及对长期维护成本的预估。代码写出来只是开始能稳定跑三个月、半年才是真正有价值的项目。