
简介这是一份Java爬虫项目实战源码包面向有一定Java基础、希望系统掌握网络数据抓取技能的开发者覆盖页面请求、内容解析、数据存储与反爬应对等实际开发场景。压缩包共包含1959个文件整体大小约243.14MB内容以Java源码、class字节码、jar依赖库为主体辅以png图片、js/css前端资源、xml/jsp配置及sql脚本等方便对照学习完整工程的组织方式。目前已有135人学习下载。项目代码涵盖HTTP请求发送、Jsoup解析HTML、正则表达式提取、多线程并发抓取、MySQL与MongoDB存储并针对延迟加载、验证码、IP限制等反爬策略给出处理思路同时包含日志记录与单元测试示例可作为课程设计、毕业设计或实际爬虫项目的参考蓝本。通过研读源码可快速理解Java爬虫从发起请求到解析落库的全流程并积累应对常见反爬机制的实战经验。1. 拿到“Java爬虫项目实战源码.zip”解压前先想清楚三件事看到“Java爬虫项目实战源码.zip”多数人的第一反应是解压、换数据库、点运行然后拿结果去交差。做过采集任务的开发者都清楚源码可靠与否不在于能不能抓到一条而在抓完不崩、崩了能续、请求被拒知道怎么退。这类实战项目真正帮你省的不是那几行HTTP请求而是任务拆分、并发控制、重试与去重存储的一整套取舍。它适合三类人想进阶的Java后端工程师、有长期采集需求的数据业务方以及不想把抓取链路当黑匣子的相关专业学生。临时抓一次用在线工具更快连续跑几天的任务后面提到的参数可能就帮你省一次返工。这篇按一条主线讲先立住最小可运行的抓取主流程再调并发与限速再解决断点续抓和去重最后给一套排坑自查方法。你看完拿到的不是“别人跑通的代码”而是能回答“网站改版了怎么办”的爬虫方案。2. 先立住抓取主流程JDK HttpClient与HTML解析器选型2.1 拆成六个环节后源码哪里值钱一目了然一个爬虫再简化也绕不开六个动作从种子地址出发把待抓URL放进队列用HTTP客户端去请求拿到响应头和响应体对响应体做解析区分HTML、JSON或纯文本按站点结构提取目标字段用去重逻辑避免同一条数据被消费两次最后把结果序列化到本地文件、数据库或消息通道。把六个动作想清楚再看任意一套Java爬虫实战源码你的关注点才会落在关键处。很多源码把网络请求和URL管理包装得比较复杂看着像个完整框架可真正决定项目寿命的反而在后四步解析代码是否集中、去重是否幂等、存储是否可恢复、失败任务有没有兜底路径。目标网站一旦改版先崩的往往不是请求层而是解析和去重这两块。我拿到一套要落地的源码时习惯先用最笨的方式跑通一条链路不关心里面封装的并发有多花哨只看它能不能在单线程下完成“一个URL进去、一批结构化数据出来”的闭环。这个闭环能跑通后续才有优化的基础连单线程都断断续续那后面堆多少并发都是在扩大事故规模。2.2 最小可运行的抓取代码JDK自带HttpClient就够了不用先引入复杂依赖JDK自带的java.net.http包就能完成基础抓取。我建议你拿以下代码作为最小骨架先验证网络通不通、目标页返回什么状态、响应体有多大再考虑解析的事情。import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class SimpleFetcher { // 写一个你能维护的UA即可不必刻意伪装成浏览器 private static final String UA DemoCrawler/1.0 (practice project); public String fetch(String url) throws Exception { HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(20)) .header(User-Agent, UA) .header(Accept-Language, zh-CN,zh;q0.8) .GET() .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() 400) { throw new RuntimeException(HTTP response.statusCode() for url); } return response.body(); } }这段代码做了三件事创建带连接超时的HttpClient构造带超时和基础请求头的GET请求再把响应体以字符串形式返回。connectTimeout控制建立TCP连接的最长等待时间timeout控制整个请求从发起到拿到响应的总时间。抓取任务最怕一个慢接口拖死整个线程池超时宁可设置得保守一点也不要让某个请求无限等下去。followRedirects(NORMAL)的细节值得多说一句它会在遇到301/302时自动跟随重定向但跨协议跳转会有限制。很多实战源码把这部分漏掉结果同一批URL在本地能抓换到线上环境就少一截数据原因不是被拒绝访问而是重定向后的页面没跟上。状态码检查也必须有404和410这类页面不该进解析流程直接丢弃并记录即可。抓中文站点时用BodyHandlers.ofString()存在一个隐患它把字节转换成字符串时默认按HTTP头里的charset处理但有些站点的charset声明不准确。更稳的写法是先拿byte[]再自己判断编码HttpResponsebyte[] response client.send(request, HttpResponse.BodyHandlers.ofByteArray()); String charset detectCharset( response.headers().firstValue(Content-Type).orElse(), response.body() ); String html new String(response.body(), charset);detectCharset是一个自定义方法逻辑通常是先从Content-Type的charset参数取值取不到再看HTML里的meta标签再取不到才用默认UTF-8。这段代码的价值在编码错乱时尤其明显稍后避坑章节会专门讲。你可以在自己的源码里补一个这样的工具方法它能替你在后面省下大量排查乱码的时间。2.3 提取目标字段正则能用但别硬扛整个页面拿到HTML后第一步常常是提取链接。用正则提取链接不是不行但要注意HTML属性写法的多样性。下面这段代码兼容了双引号、单引号和相对地址ListString links new ArrayList(); Pattern pattern Pattern.compile(href[\]([^\])[\], Pattern.CASE_INSENSITIVE); Matcher matcher pattern.matcher(html); while (matcher.find()) { String href matcher.group(1); if (href.startsWith(//)) { href https: href; } else if (href.startsWith(/)) { href baseUri href; } if (href.startsWith(http)) { links.add(href); } }这段代码做了三件事正则匹配href属性并兼容单双引号补全协议相对地址和站点相对地址最后只留下http/https链接。baseUri是当前页面的完整地址用于把根路径拼回去。这个方案轻量、无依赖、起点很低但对于复杂的现代页面正则的脆弱性很快会显现。如果目标是提取标题、发布时间、作者这类结构化字段我会直接把正则换成支持CSS选择器的解析组件。以下是示意代码你可以把它理解成项目里某个解析包的通用接口形式HtmlDocument doc HtmlDocument.parse(html); String title doc.selectFirst(h1.news-title).text(); String pubTime doc.selectFirst(time.publish-time).attr(datetime); String author doc.selectFirst(.author-name).text();这里我没有写某个具体解析库的导入因为不同源码依赖差异很大你只需要记住两个关键点解析器要能容错面对不规范的HTML不直接抛异常选择器要支持常见的CSS路径这样用h1.news-title这种写法就能定位元素。项目里如果有自己的parse方法请优先复用别把整条提取逻辑重写一遍。选择解析组件时我会看三个维度依赖数量是否精简、是不是还在更新、对非标准HTML的容错能力。有的解析器体积小但严格页面一有错就吐异常有的解析器宽容但重会拖慢启动速度。面向实战选中间档位即可。解析这块最忌讳的是一处页面写一种手工字符串截取那会导致整个项目变成一堆没法维护的特殊逻辑。3. 可控并发与限速线程池参数、令牌桶和重试队列这样设置3.1 为什么不要用手写线程线程池参数背后是整套流量控制新手最容易犯的错误是为了提速每来一个URL就new一个Thread。单看几秒内的效果确实快但线程数量上去后内存、文件句柄、连接数都会失控而且你没有办法优雅停止也没有办法知道当前到底有多少任务在等响应。线程池的价值不是快而是把并发数量、队列长度、拒绝策略都变成可观察、可调整的参数。下面的线程池配置是采集任务里比较常见的一份ThreadPoolExecutor pool new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 30, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(500), // 有界队列最多排队500个任务 task - { Thread t new Thread(task, crawler-worker- counter.incrementAndGet()); t.setDaemon(false); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );这段代码的关键在四组参数核心线程数4、最大线程数8、有界队列500、拒绝策略CallerRunsPolicy。抓取属于I/O密集型操作大量时间花在等待网络响应上因此核心线程数不必按CPU核数去对齐小项目取4到8就够。真正的瓶颈往往不是线程而是目标站点的承受能力。队列用ArrayBlockingQueue而不是无界队列是为了防止任务无限堆积。如果对方响应变慢500个排队任务会把内存占满你再多的线程也救不回来。拒绝策略选择CallerRunsPolicy而不是AbortPolicy含义是当线程池和队列都满时新任务不在池里执行而是在提交任务的那个线程里直接跑。这相当于一种背压机制提交方自己也变慢整个系统自然放慢节奏而不是直接抛异常丢掉任务。如果你拿到的源码默认用了AbortPolicy建议改成CallerRunsPolicy试试。丢任务对采集任务来说比慢更可怕慢还能在后续日志里看出来丢了一旦没记录你根本不知道某条数据曾经应该被抓。3.2 令牌桶限速把“平均速度”变成“可执行的约束”线程池控制的是并发数但并发数不等于请求频率。4个线程如果每个都飞快循环照样能在短时间内发出大量请求所以还需要一层的令牌桶限速。思路很简单往桶里放令牌每秒放若干次每发一个请求取走一个令牌桶空了就等。public class RateLimiter { private final double capacity; private final double refillPerSecond; private double tokens; private long lastRefillNanos; public RateLimiter(double requestsPerSecond) { this.capacity requestsPerSecond; this.refillPerSecond requestsPerSecond; this.tokens requestsPerSecond; this.lastRefillNanos System.nanoTime(); } public synchronized boolean tryAcquire() { long now System.nanoTime(); tokens Math.min(capacity, tokens (now - lastRefillNanos) / 1e9 * refillPerSecond); lastRefillNanos now; if (tokens 1) { tokens - 1; return true; } return false; } }这段代码的核心是tokens的计算方式根据当前时间与上次补充时间的差值按refillPerSecond增加令牌同时用Math.min把令牌数限制在capacity以内避免长时间空闲后累积出一次爆发请求。synchronized保证多线程同时调用tryAcquire时不会重复扣减令牌。使用起来也很简单。每次准备提交抓取任务前调用tryAcquire拿到令牌就提交拿不到就稍等if (rateLimiter.tryAcquire()) { pool.submit(new CrawlTask(url)); } else { Thread.sleep(200); }这里把单位时间的请求数设置成多少取决于对方站点的情况。没有标准的“安全值”只能从小往大试探先每秒2到3次跑一段时间观察响应时间和失败率再慢慢上调。如果源码里没有限速模块这块需要自己补上如果有限速模块但设置得很大请一定根据自己的场景调小而不是沿用原值。3.3 失败重试要有边界指数退避怎么算长时间跑的任务里网络超时、连接重置、临时返回5xx都很常见。处理方式不是失败一次就重来也不是无限重试。常见的做法是限制最大重试次数并让两次重试之间的间隔按指数增长。ScheduledExecutorService retryScheduler Executors.newSingleThreadScheduledExecutor(); private void submitWithRetry(String url, int attempt, int maxRetries) { pool.submit(() - { try { handleUrl(url); } catch (Exception e) { if (attempt maxRetries) { long delay Math.min(30_000L, 1_000L attempt); retryScheduler.schedule( () - submitWithRetry(url, attempt 1, maxRetries), delay, TimeUnit.MILLISECONDS ); } else { logFailure(url, e); } } }); }这段代码做了三件事先尝试执行一次抓取失败后判断重试次数是否超限未超限就按1秒、2秒、4秒的节奏重新提交超过最大次数则记入失败日志。Math.min把延迟封顶在30秒避免重试间隔无限拉长。这里用ScheduledExecutorService做延迟调度不会因为sleep阻塞工作线程。重试次数的经验值是3到5次具体看网络环境。需要特别注意的是只有瞬时故障才值得重试。如果状态码是404、410或者请求参数错误重试多少次都不会成功反而会把失败率刷得很难看。代码里要按异常类型或者状态码区分开把“不该重试”的情况直接放进失败列表。4. 断点续抓与去重长跑任务怎么做到重启不慌4.1 URL去重从内存Set到布隆过滤器怎么选采集任务跑得越久URL去重越重要。最简单的做法是用一个Set保存已经抓过的URL每次从队列取任务前先判断是否已存在。小规模场景完全够用但百万级以上URL时内存会变得非常可观所以需要根据规模选方案。方案内存占用适用规模主要风险HashSet中十万到百万级数据量大时内存膨胀ConcurrentHashMap.newKeySet中多线程场景线程安全有保障容量大仍需注意布隆过滤器低千万级以上有误判可能漏抓少量URL数据库唯一索引取决于表设计超大持续任务每次判重要查库需控制频率我的建议是源码如果只用了HashSet就先别看它炫不炫直接确认两点有没有容量上限、满了之后怎么处理。有些源码写得很漂亮但去重集合无限增长跑一天就内存吃紧这在实战里属于必须第一时间发现的隐患。布隆过滤器适合内存敏感且能容忍极小概率漏抓的场景。要注意的是布隆过滤器只能告诉你“一定没有”或“可能存在”它不会告诉你“一定存在”。如果你不希望任何一条数据被跳过就不要在关键链路上只用布隆过滤器可以把已完成集合定期落盘做二次确认。4.2 任务状态快照进程重启后从哪里继续很多采集任务跑着跑着进程就没了原因可能是虚拟机重启、内存溢出、手动停止。没有状态快照重启后只能全量重来有状态快照就能从上次断点继续。快照要保存两个东西已抓完的URL集合和还没抓的待处理队列。private void saveSnapshot(Path dir, SetString completed, QueueString pending) throws IOException { Files.createDirectories(dir); Path tmp dir.resolve(snapshot.tmp); Path target dir.resolve(snapshot.txt); StringBuilder sb new StringBuilder(); sb.append(#completed\n); completed.forEach(url - sb.append(url).append(\n)); sb.append(#pending\n); pending.forEach(url - sb.append(url).append(\n)); Files.writeString(tmp, sb.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); }这段代码最重要的细节不是写文件而是先写临时文件再改名。直接往目标文件里写如果写到一半进程崩溃文件就是半截的下次启动读进来会污染整个队列。先写tmp再move要么旧快照完整保留要么新快照完整替换不会出现中间状态。启动时把快照读回来也很简单按行解析遇到#completed标记之前的行回填到已完成集合之后的行放回待处理队列。快照不需要每抓一条都写那样磁盘开销太大常见的做法是每完成N条任务或每隔固定分钟写一次。多线程环境下写快照时要先暂停派发或者在同一把锁里做否则会出现写了快照之后又有任务完成但没更新进去的情况。4.3 落库不纠结先想清楚数据要用来干什么抓下来的数据最终要存到哪里取决于数据量、字段结构和后续使用方式。小规模演示用CSV最直接能打开、能追加、能对比数据量上去后放在本地文件里做检索会非常吃力这时嵌入数据库或统一落到数据库会更合适。BufferedWriter writer Files.newBufferedWriter(outPath, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); for (Item item : batch) { writer.write(item.toLine()); writer.newLine(); } writer.flush();这段代码展示的是批量写入的姿势一批数据攒在内存里统一写一次文件再flush一次。每条数据单独打开文件、单独写、单独关闭看起来直白性能却很差跑几万条之后差距非常明显。flush的频率代表的是数据安全性和性能的平衡flush越频繁越安全但磁盘压力也越大折中方案是每攒几百条或几十条flush一次。如果你要做的后续操作是排重、按时间范围查询、更新字段直接上嵌入式数据库会省很多事。源码里如果已经封装好某种存储接口建议保留接口把具体实现从文件方式替换成你需要的存储。这样换存储不影响上层解析和抓取逻辑也算是对源码做最小侵入式改造。5. 避坑自查清单乱码、选择器失效与请求被拒的排查路线5.1 四个高频翻车现象与修复现象控制台打印HTML时中文全是乱码。原因响应头里声明的charset和页面实际编码不一致或者响应体里没有声明编码。解决不要用BodyHandlers.ofString直接转字符串改成ofByteArray后自行探测编码探测顺序是Content-Type里的charset、HTML里的meta标签、默认UTF-8。这一步解决后解析器也要显式传入同一编码否则解析器内部转换可能再次错乱。现象正则表达式在测试页面能匹配换一批URL就失配。原因网页属性写法不固定有的用单引号有的属性顺序不同有的链接是转义后的实体。正则看起来简单但每遇到一种新写法就要改一次规则最后变成一堆if嵌套。解决把链接提取和字段提取统一改成CSS选择器方案让解析器处理属性顺序和标签容错站点结构变化时只改选择器字符串不动整段提取逻辑。现象请求返回200但目标字段全部为空。原因页面里根本没有你要的内容内容是页面加载后通过异步请求拿到的也可能是请求缺少必要的鉴权头或登录态服务端返回了一个空壳页面。解决先用浏览器开发者工具看网络请求找到真正返回数据的接口地址把这个接口作为抓取源如果字段依赖登录态先把登录流程单独跑通拿到会话凭证后再发起数据请求。现象跑了一段时间后被拒绝访问甚至出现验证码页。原因单位时间请求频率太高UA和请求头组合太单一失败后没有退避机制导致把对方网关压力顶满。解决降低令牌桶速率检查UA和Accept-Language等基础请求头重试策略改成指数退避并把同一域名下的并发限制调小。记住一个原则抓取公开数据的底线是别影响对方网站正常服务速率宁可保守也不要激进。5.2 排查顺序先复现、再打日志、最后加规则踩坑多了之后我养成了一个习惯一次只改一个变量。改了解析逻辑就不要同时动线程池改了重试策略就不要顺手换存储格式。多个变量一起改会让日志变得无法解释你分不清结果是哪一个修改带来的。遇到采集结果异常先把范围缩小到单个URL设置成只跑一条数据、打印完整链路发出去请求URL是什么收到什么状态码响应体有多少字节HTML片段是什么。这一段链路里哪一步不对问题就在哪一步不要一上来就怀疑整个框架。网络问题导致的异常和解析问题导致的异常要分开看。如果报错在解析阶段之前比如连接超时、连接重置、下载字节为0那就别去动解析代码如果报错在选择器执行之后返回空集合那跟网络几乎无关该检查的是页面结构和选择器是否匹配。为了便于复现可以把每次失败的响应体保存到本地文件留作样本而不是一遍遍重新请求线上地址。6. 进阶用法把源码改成按需增量采集与监控警报拿到手并能稳定运行的源码下一步不是继续堆功能而是把它改造成可持续运维的小系统。我建议优先做两件事增量采集和可观察性。增量采集的含义是每次启动不再全量重新抓而是先读取上一次已经抓过的数据主键集合只处理新增项和变化项。实现思路是给每条数据一个唯一ID比如新闻链接或文章详情页URL把它作为判断是否已存在的依据启动时加载已有ID到Set遇到新ID才进详情页抓取遇到已有ID直接跳过。这样日更站点每天只需要抓少量新页面对服务器和自己都友好。第二件事是暴露监控指标。不需要造复杂系统在项目里添一个简单的统计类维护已成功数、失败数、当前排队数、线程池活跃数这几个计数器再提供一个HTTP接口或定时输出到日志文件。线程池本身提供了getActiveCount和getQueue().size()直接读出来就能知道当前负载。设置报警阈值连续失败超过10条或者队列积压超过预定值就通过邮件或Webhook通知自己人不用盯屏幕。验证方法也有一套固定顺序先用50条样本试跑逐字段人工对比准确性再放大到1000条观察内存、失败率和响应时间最后才连续跑24小时检查凌晨低峰期的稳定性。任何一次改动后都重复这个小样本验证流程不要跳步直接全量跑。我自己的习惯是给爬虫项目留三个调试开关只跑一个URL、失败响应存本地、控制台打印提取结果。这个习惯吃过太多次亏才养出来改版后最怕的不是抓不到而是抓回一堆空字符串还以为是成功的。以后你再打开这份源码先把这三个开关找到找不到就自己补上再谈并发优化。希望帮到你。本文还有配套的精品资源点击获取