
1. 项目缘起与整体设计思路做招聘数据分析这件事我前前后后折腾过好几轮。最早是手动复制粘贴岗位信息到表格里几十条数据就搞得眼睛发花后来用浏览器插件半自动采集遇到翻页和动态加载就歇菜。真正让我下定决心写一个稳定的采集工具是因为需要持续跟踪某个技术方向的岗位数量变化和薪资区间走势手动操作根本扛不住。这个项目就是在这种背景下落地的用 Java 写一个网络爬虫定向采集招聘平台上的岗位信息包括岗位名称、薪资范围、公司名称、工作城市、经验要求、学历要求、技能标签等字段最终落地成结构化数据供后续分析使用。先说说这个项目适合谁看。如果你有 Java 基础了解 HTTP 协议的基本概念想找一个完整的、能跑通的采集项目练手那这篇内容会很对口。如果你完全没接触过网络编程也不用慌我会把每个环节拆开讲清楚包括为什么这么设计、参数怎么定、遇到问题怎么排查。整个方案的核心思路是用轻量级 HTTP 客户端发请求用 HTML 解析库提取数据用连接池管理资源用分页策略控制采集节奏。不依赖重型框架一个 Maven 项目就能跑起来。为什么选择 Java 而不是 Python很多人第一反应是 Python 做爬虫更方便这话没错但 Java 在工程化方面有天然优势。当你需要把采集任务集成到已有的 Java 后端系统里或者需要长时间稳定运行、方便打包部署时Java 的强类型、成熟的并发工具包和丰富的 HTTP 客户端库就体现出价值了。而且 Java 的 Jsoup 库在解析 HTML 方面相当好用语法接近 jQuery上手成本很低。整体架构我分成了四层请求层负责发送 HTTP 请求和维持会话状态解析层负责从返回的 HTML 中提取目标字段调度层负责分页逻辑、频率控制和重试机制存储层负责把数据写到文件或数据库。这四层之间通过简单的接口解耦方便后续替换实现。比如请求层今天用 Java 11 自带的 HttpClient明天想换成 OkHttp只要接口不变上层代码不用动。注意任何采集行为都要遵守目标网站的服务条款和 robots 协议控制请求频率不对目标服务器造成额外负担。这是做数据采集的基本职业素养也是保证项目能长期稳定运行的前提。2. 核心细节解析与实操要点2.1 请求层的技术选型与参数配置请求层是整个爬虫的地基。我选的是 Java 11 引入的java.net.http.HttpClient原因有三第一它是 JDK 自带的不需要引入额外依赖项目干净第二它原生支持 HTTP/2 和异步请求虽然这个项目用同步模式就够了但留着扩展空间第三它的连接池管理是内置的通过Executor可以控制并发线程数。关键配置参数有这么几个。connectTimeout我设的是 10 秒意思是建立 TCP 连接的超时时间。这个值不能太短否则网络稍微抖动就失败也不能太长否则一个坏连接会拖住整个线程。HttpRequest.timeout设的是 15 秒这是从请求发出到收到完整响应的总超时。这两个超时的区别很多人搞混连接超时只管握手阶段请求超时管全程。请求头Header的配置是重点。最核心的是User-Agent它告诉服务器你是什么客户端。我用的是一个常见浏览器的 UA 字符串目的是让服务器返回正常的网页内容而不是精简版或拒绝响应。其次是Accept和Accept-Language这两个影响服务器返回的内容格式和语言版本。还有一个容易被忽略的是Referer有些网站会检查请求来源带上正确的 Referer 能提高请求成功率。HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(15)) .header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...) .header(Accept, text/html,application/xhtmlxml,...) .header(Accept-Language, zh-CN,zh;q0.9) .GET() .build();这里有个实操心得不要每次请求都新建 HttpClient 实例。HttpClient 是设计成可复用的内部维护连接池。如果你在循环里反复 new不仅浪费资源还可能导致端口耗尽。正确的做法是在类级别创建一个静态的或者单例的 HttpClient所有请求共用它。2.2 解析层的字段定位策略拿到 HTML 之后下一步是把需要的数据抠出来。我用的解析库是 Jsoup它的select方法支持 CSS 选择器写起来很直观。但真正的难点不在于会不会用 Jsoup而在于怎么找到稳定可靠的定位规则。招聘网站的页面结构会变今天用.job-card .title能选到岗位名称明天改版可能就变成.job-list-item .job-name。所以定位策略要遵循一个原则优先选择带有语义化属性或稳定类名的元素避免依赖纯样式类。比如>// 岗位名称尝试多个选择器 Element titleEl doc.selectFirst(.job-primary .job-title); if (titleEl null) { titleEl doc.selectFirst([data-jobname]); } if (titleEl null) { titleEl doc.selectFirst(.job-name); } String jobTitle titleEl ! null ? titleEl.text().trim() : ;薪资字段的处理要特别小心。招聘网站上的薪资格式五花八门有15-25K、20-40K·14薪、面议、8千-1.2万等等。我的策略是先原样保存再单独写一个解析方法做结构化转换。原样保存是为了不丢信息结构化转换是为了后续分析。转换的时候用正则表达式提取数字和单位统一换算成月薪范围单位元。原始格式解析结果最低月薪解析结果最高月薪备注15-25K1500025000标准格式20-40K·14薪2333346666按14薪折算月薪8千-1.2万800012000中文单位换算面议-1-1特殊标记不参与统计提示薪资解析不要追求100%准确能达到90%以上的解析率就够用了。剩下的异常值在分析阶段单独处理比在采集阶段死磕要高效得多。2.3 调度层的分页与频率控制分页逻辑看起来简单其实藏着不少坑。最常见的分页方式是 URL 参数翻页比如page1、page2。但有些网站用的是游标分页cursor-based返回数据里带一个next_cursor下次请求要带上这个游标。还有的网站是滚动加载需要模拟滚动事件或者直接请求数据接口。我优先选择 URL 参数翻页因为最容易实现和控制。具体做法是维护一个pageNum变量每采集完一页就加一直到返回的数据为空或者达到预设的最大页数。最大页数这个限制很重要既是防止无限循环也是控制采集规模。频率控制是另一个关键点。我的策略是每请求一次随机休眠 1 到 3 秒。为什么是随机而不是固定因为固定间隔的请求模式太规律容易被识别为自动化程序。随机间隔更接近人类浏览行为。休眠时间也不能太短太短了给服务器压力大也不能太长太长了采集效率低。1 到 3 秒这个区间是我实测下来比较平衡的值。private void sleepRandom() { try { long millis 1000 ThreadLocalRandom.current().nextLong(2000); Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }重试机制也是调度层的一部分。网络请求失败是常态可能是超时、可能是服务器临时错误、可能是被限流。我的做法是每个请求最多重试 3 次每次重试的间隔翻倍第一次等 2 秒第二次等 4 秒第三次等 8 秒。这种指数退避策略能有效应对临时性故障同时避免在服务器已经压力很大时继续猛冲。2.4 存储层的格式选择与写入策略存储层我提供了两种方案CSV 文件和 SQLite 数据库。CSV 适合快速查看和小规模数据用 Excel 就能打开SQLite 适合需要复杂查询和长期积累的场景单文件数据库不需要额外安装服务。CSV 写入的坑主要在字段转义上。岗位描述里经常包含逗号、换行符、双引号如果不做转义直接拼接整个 CSV 结构就乱了。正确的做法是用双引号把每个字段包起来字段内部的双引号替换成两个双引号。这个规则是 CSV 标准定义的Excel 和主流数据处理工具都认。private String escapeCsv(String value) { if (value null) return ; String escaped value.replace(\, \\); return \ escaped \; }SQLite 方案我用的是 JDBC 驱动建表语句里给关键字段加了索引方便后续按城市、薪资范围查询。批量插入比单条插入快很多我攒够 50 条或者采集完最后一页时统一提交事务。这里有个细节事务提交后要记得关闭 Statement 和 Connection否则连接泄漏跑不了多久就会报错。3. 实操过程与核心环节实现3.1 项目搭建与依赖引入新建一个 Maven 项目pom.xml里只需要两个核心依赖Jsoup 用于 HTML 解析SQLite JDBC 用于数据存储。HTTP 客户端用 JDK 自带的不需要额外依赖。日志我用的是 SLF4J Logback方便排查问题。dependencies dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdorg.xerial/groupId artifactIdsqlite-jdbc/artifactId version3.45.1.0/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.12/version /dependency /dependencies项目结构我分成五个包config放配置常量fetcher放请求相关代码parser放解析逻辑model放数据实体类storage放存储实现。这种分法不是必须的但能让代码职责清晰后续改哪块找哪块。3.2 数据模型设计与字段定义数据实体类JobInfo包含以下字段jobTitle岗位名称、salaryRaw原始薪资文本、salaryMin解析后最低月薪、salaryMax解析后最高月薪、companyName公司名称、city工作城市、experience经验要求、education学历要求、skills技能标签逗号分隔、publishDate发布日期、sourceUrl来源链接、crawlTime采集时间。这里重点说下skills字段。招聘页面上的技能标签通常是一组span或li元素我用 Jsoup 的select一次性选出来然后用逗号拼接成一个字符串。这样做的好处是存储简单后续分析时用split(,)就能还原成列表。如果技能数量很多也可以考虑单独建一张关联表但那是另一个话题了。crawlTime字段记录采集时刻的时间戳这个字段在后续做增量采集时非常有用。比如你每天跑一次采集可以通过对比crawlTime来识别哪些是新出现的岗位。3.3 完整采集流程的代码实现主流程我写成一个CrawlService类核心方法是crawl(String keyword, String city, int maxPages)。方法内部先构造第一页的 URL然后进入循环发请求、解析、存储、翻页、休眠直到达到最大页数或没有更多数据。public void crawl(String keyword, String city, int maxPages) { int pageNum 1; int totalCount 0; while (pageNum maxPages) { String url buildUrl(keyword, city, pageNum); log.info(正在采集第 {} 页: {}, pageNum, url); String html fetcher.fetch(url); if (html null || html.isEmpty()) { log.warn(第 {} 页返回空内容停止采集, pageNum); break; } ListJobInfo jobs parser.parse(html); if (jobs.isEmpty()) { log.info(第 {} 页没有解析到岗位数据停止采集, pageNum); break; } storage.saveBatch(jobs); totalCount jobs.size(); log.info(第 {} 页采集完成本页 {} 条累计 {} 条, pageNum, jobs.size(), totalCount); pageNum; sleepRandom(); } log.info(采集结束共获取 {} 条岗位信息, totalCount); }URL 构造方法buildUrl需要根据目标网站的规则来写。以常见的招聘网站为例搜索页 URL 通常包含关键词参数、城市参数和页码参数。关键词和城市需要做 URL 编码用URLEncoder.encode(value, StandardCharsets.UTF_8)处理否则中文和特殊字符会导致请求失败。解析方法parse是核心中的核心。我把它拆成两个步骤先定位到岗位列表容器再遍历每个岗位卡片提取字段。这样做的好处是如果列表容器选择器变了只需要改一处如果某个字段的选择器变了也只需要改对应的提取逻辑。public ListJobInfo parse(String html) { ListJobInfo result new ArrayList(); Document doc Jsoup.parse(html); Elements jobCards doc.select(.job-list .job-card-wrapper); if (jobCards.isEmpty()) { jobCards doc.select([class*job-card]); } for (Element card : jobCards) { JobInfo job new JobInfo(); job.setJobTitle(extractText(card, .job-name, [data-jobname])); job.setSalaryRaw(extractText(card, .salary, .red)); job.setCompanyName(extractText(card, .company-name, .company-text)); job.setCity(extractText(card, .job-area, .city)); job.setExperience(extractText(card, .job-experience, .exp)); job.setEducation(extractText(card, .job-degree, .edu)); job.setSkills(extractSkills(card)); job.setSourceUrl(extractAttr(card, a, href)); job.setCrawlTime(new Date()); parseSalary(job); result.add(job); } return result; }extractText是一个辅助方法接收卡片元素和多个备选选择器返回第一个非空的文本内容。这个方法看起来简单但它是整个解析层稳定性的关键。我实测下来准备两到三个备选选择器能把解析成功率从 70% 提升到 95% 以上。3.4 薪资解析的完整实现薪资解析我单独写了一个方法parseSalary逻辑是先用正则匹配出数字和单位再统一换算。正则我写了两套一套匹配数字-数字K格式一套匹配数字千-数字万格式。匹配到之后根据单位做乘法换算。private void parseSalary(JobInfo job) { String raw job.getSalaryRaw(); if (raw null || raw.contains(面议)) { job.setSalaryMin(-1); job.setSalaryMax(-1); return; } // 匹配 15-25K 或 15-25k Pattern p1 Pattern.compile((\\d)-(\\d)[Kk]); Matcher m1 p1.matcher(raw); if (m1.find()) { job.setSalaryMin(Integer.parseInt(m1.group(1)) * 1000); job.setSalaryMax(Integer.parseInt(m1.group(2)) * 1000); return; } // 匹配 8千-1.2万 Pattern p2 Pattern.compile(([\\d.])千-([\\d.])万); Matcher m2 p2.matcher(raw); if (m2.find()) { job.setSalaryMin((int)(Double.parseDouble(m2.group(1)) * 1000)); job.setSalaryMax((int)(Double.parseDouble(m2.group(2)) * 10000)); return; } // 其他格式标记为未知 job.setSalaryMin(-1); job.setSalaryMax(-1); }这里有个细节·14薪这种后缀我在解析时先忽略因为折算月薪的方式有争议。有人按总包除以12算有人按总包除以14算。我的做法是保留原始文本分析阶段再决定怎么处理。这样采集层保持简单分析层保持灵活。3.5 数据存储的两种落地方式CSV 存储我用的是BufferedWriter每采集完一页就追加写入。文件头在第一页写入前先写一行字段名。写入的时候注意用StandardCharsets.UTF_8编码否则中文会乱码。public void saveToCsv(ListJobInfo jobs, String filePath) { boolean isNew !Files.exists(Paths.get(filePath)); try (BufferedWriter writer Files.newBufferedWriter( Paths.get(filePath), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { if (isNew) { writer.write(岗位名称,原始薪资,最低月薪,最高月薪,公司,城市,经验,学历,技能,链接,采集时间); writer.newLine(); } for (JobInfo job : jobs) { writer.write(String.join(,, escapeCsv(job.getJobTitle()), escapeCsv(job.getSalaryRaw()), String.valueOf(job.getSalaryMin()), String.valueOf(job.getSalaryMax()), escapeCsv(job.getCompanyName()), escapeCsv(job.getCity()), escapeCsv(job.getExperience()), escapeCsv(job.getEducation()), escapeCsv(job.getSkills()), escapeCsv(job.getSourceUrl()), String.valueOf(job.getCrawlTime().getTime()) )); writer.newLine(); } } catch (IOException e) { log.error(写入CSV失败, e); } }SQLite 存储我用的是 JDBC 的批量插入。建表语句里给city、salary_min、salary_max加了索引方便后续按城市和薪资范围查询。批量插入的时候用PreparedStatement的addBatch和executeBatch比逐条插入快一个数量级。public void saveToDb(ListJobInfo jobs) { String sql INSERT INTO jobs (job_title, salary_raw, salary_min, salary_max, company_name, city, experience, education, skills, source_url, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); try (Connection conn DriverManager.getConnection(jdbc:sqlite:jobs.db); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (JobInfo job : jobs) { ps.setString(1, job.getJobTitle()); ps.setString(2, job.getSalaryRaw()); ps.setInt(3, job.getSalaryMin()); ps.setInt(4, job.getSalaryMax()); ps.setString(5, job.getCompanyName()); ps.setString(6, job.getCity()); ps.setString(7, job.getExperience()); ps.setString(8, job.getEducation()); ps.setString(9, job.getSkills()); ps.setString(10, job.getSourceUrl()); ps.setLong(11, job.getCrawlTime().getTime()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { log.error(写入数据库失败, e); } }4. 常见问题与排查技巧实录4.1 请求被拒绝或返回空内容这是最常见的问题表现是请求返回 403 状态码或者返回的 HTML 里没有岗位数据。原因通常有三个请求头不完整、请求频率太高、IP 被临时限制。排查思路是这样的先用浏览器开发者工具抓一个正常请求把它的请求头完整复制下来对比你的代码里少了哪些。最常见的是少了User-Agent或者Referer。如果请求头没问题那就是频率问题把休眠时间调大比如从 1-3 秒调到 3-5 秒。如果还是不行可能需要等一段时间再试或者换一个网络环境。提示不要试图用多线程猛冲来绕过限制那样只会让情况更糟。控制频率、模拟正常用户行为才是长期稳定的做法。4.2 解析到的字段为空或错位解析为空的原因通常是选择器失效了。排查方法是把返回的 HTML 保存到本地文件用浏览器打开手动检查目标元素的选择器是否还能选到。如果选不到说明网站改版了需要更新选择器。字段错位是另一个坑。比如岗位名称选到了公司名称或者城市选到了经验要求。这种情况通常是选择器写得太宽泛选到了错误的元素。解决办法是把选择器写得更具体加上父级容器的限定。比如.job-card .job-name就比.job-name更精确。问题现象可能原因排查方法解决方案返回403请求头缺失对比浏览器请求头补全 User-Agent、Referer返回空列表选择器失效保存HTML本地检查更新选择器增加备选字段错位选择器太宽泛检查元素层级加父级限定提高精确度中文乱码编码不一致检查响应头编码统一用 UTF-8 处理连接超时网络不稳定检查网络连通性增加超时时间加重试4.3 采集速度慢的优化思路采集速度慢通常不是代码问题而是频率控制的结果。如果你确实需要提高速度有几个方向可以尝试。第一用异步请求Java 的 HttpClient 支持sendAsync可以同时发多个请求但要注意控制并发数建议不超过 3 个。第二只采集需要的字段减少解析工作量。第三用本地缓存对于已经采集过的页面如果短时间内重复请求可以直接读缓存。但我要提醒一句速度不是采集项目的首要目标稳定性才是。一个能稳定跑几个小时的慢速爬虫比一个跑十分钟就被封的快速爬虫有价值得多。4.4 数据去重与增量采集如果你需要定期采集去重就是必须解决的问题。最简单的去重方式是用sourceUrl作为唯一标识采集前先查一下数据库里有没有这个 URL。如果有就跳过如果没有就插入。SQLite 里可以给source_url加唯一索引插入时用INSERT OR IGNORE让数据库自动去重。增量采集的思路是记录上次采集的时间点下次只采集这个时间点之后发布的岗位。但招聘网站的发布时间格式不统一有的显示今天有的显示3天前解析起来比较麻烦。我的做法是按页数控制每次只采集前 N 页因为新发布的岗位通常在前几页。这样虽然不能做到精确增量但实现简单效果也够用。4.5 长时间运行的稳定性保障长时间运行最容易出的问题是内存泄漏和连接泄漏。内存泄漏通常是因为把太多数据攒在内存里没释放解决办法是采集一批就写一批不要全部攒到最后。连接泄漏通常是因为Connection、Statement、ResultSet没有正确关闭解决办法是用 try-with-resources 语法让 Java 自动关闭。还有一个容易被忽略的问题是日志文件过大。如果采集量很大日志会迅速膨胀。我的做法是配置 Logback 的滚动策略每天一个文件最多保留 7 天。这样既保留了排查问题所需的信息又不会把磁盘占满。appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/crawler.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/crawler.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory7/maxHistory /rollingPolicy /appender5. 采集之后的扩展方向数据采集只是第一步真正有价值的是采集之后的分析。我通常会把 CSV 或 SQLite 里的数据导入到数据分析工具里做几个维度的统计按城市统计岗位数量看哪些城市需求旺盛按薪资区间统计岗位分布看市场行情按技能标签做词频分析看哪些技术栈最受欢迎。如果你想把这件事做得更工程化可以考虑把采集任务做成定时任务每天凌晨自动跑一次数据写入数据库后自动生成报表。再进一步可以做一个简单的 Web 界面用图表展示岗位趋势变化。这些扩展都不难核心的采集层稳定了上层怎么玩都行。我个人在实际操作中的体会是采集项目的难点从来不是写代码而是应对目标网站的变化和维持长期稳定。选择器会变、页面结构会变、反采集策略会升级你的代码也要跟着迭代。所以写的时候尽量把易变的部分抽出来做成配置比如选择器、请求头、休眠时间这样改的时候不用动核心逻辑。另外每次采集完把原始 HTML 也存一份万一解析出了问题还能用原始数据重新解析不用重新请求。这个习惯帮我省了很多事。