资讯动态

Java新闻聚合平台实战:Spring Boot+Jsoup抓取与去重

发布时间:2026/10/9 1:07:20 来源:尧图企业网站定制
简介本资源是一套基于JAVA与SpringBoot的在线新闻聚合平台完整项目源码面向计算机相关专业的毕业设计、课程作业开发者以及想实践网页开发与数据抓取技术的学习者。项目通过爬虫定期抓取各大新闻网站内容聚合标题、摘要、图片与链接并借助数据库完成存储与检索前端采用响应式设计适配多设备。压缩包共466个文件约13.77MB包含99个java后端源码、46个vue前端组件、34个js脚本、9个py爬虫程序以及svg、jpg、png等静态资源与xml、yml、sql等配置和数据库文件另附bat与cmd启动脚本结构完整便于二次开发。目前已有86人学习下载。读者可从中获得一套可运行的全栈项目参考涵盖需求分析、系统设计、编码实现到部署上线的完整流程并学习爬虫策略调优、数据库索引优化及SQL注入、XSS防护等安全实践适合作为毕业设计模板或技术进阶练手素材。1. 从零搭一个在线新闻聚合平台Java 网页开发加数据抓取到底怎么落地很多人第一次听到「在线新闻聚合平台」会以为是个 RSS 阅读器套壳真动手才发现难点根本不在页面而在数据从哪来、怎么稳定地来、来了之后怎么存、怎么去重、怎么在页面上按频道和热度排出来。这个标题拆开看是三件事Java 做后端网页开发、抓取外部新闻源、把抓来的数据聚合成一个可浏览的产品。它适合两类人一类是 Java 后端想找一个能同时练 Web 和爬虫的完整项目另一类是已经会写 CRUD但没做过「外部数据接入 清洗 展示」这条链路的人。我做过几个类似形态的系统血泪经验是抓取部分决定了这个项目能不能跑起来Web 部分决定了它好不好用而真正让人翻车的是中间那层——数据清洗和去重。下面按「先立住原理和选型再给可抄的代码和参数最后讲坑」的顺序推下去新手能照着搭出最小可用版本熟手能直接跳到参数和边界那几节。2. 技术选型与抓取链路为什么是 Spring Boot 加 Jsoup 这套组合2.1 后端框架为什么优先 Spring Boot 而不是原生 Servlet标题里写的是「JAVA 网页开发」落到工程上就是选一个 Web 框架。常见做法是 Spring Boot MyBatis-Plus理由很实际新闻聚合平台的数据模型是典型的「源表 文章表 频道表 标签表」字段多、查询条件杂MyBatis-Plus 能省掉大量单表 CRUD 的样板代码分页、条件构造器开箱即用。如果你用原生 Servlet 或 JSP 硬写光是把「按频道分页查文章」这条 SQL 拼明白就要花掉半天而且后面加缓存、加定时任务时没有统一的容器管理会很痛苦。选型上还有一层考虑是定时抓取。Spring Boot 自带Scheduled配合线程池就能把「每隔 N 分钟抓一批源」这件事做掉不需要额外引入 Quartz 这种重家伙。对于个人项目或中小规模聚合站这套足够。数据库我一般用 MySQL 8字符集utf8mb4因为新闻标题里 emoji 和特殊符号很常见用utf8会直接插入失败这个坑后面还会提。2.2 抓取库怎么选Jsoup、HttpClient 还是 Selenium抓取新闻列表页绝大多数情况是「请求 HTML → 解析 DOM → 抽字段」Jsoup 就是为这个场景生的。它既能发 HTTP 请求又能用 CSS 选择器解析一个库干两件事。对比一下常见方案方案适用场景代价Jsoup静态 HTML、结构规整的列表页不执行 JS遇到前端渲染的页面抓不到HttpClient Jsoup需要精细控制请求头、Cookie、超时代码量翻倍但可控性强Selenium / Playwright页面内容由 JS 动态渲染要装浏览器驱动资源占用高不适合高频抓取我的建议是先用 Jsoup 试能抓到就用它抓到的 HTML 里没有目标内容说明是 JS 渲染再考虑上无头浏览器。不要一上来就 Selenium那会让你的服务器内存和 CPU 都很难受而且抓取速度会慢一个数量级。2.3 最小可跑的抓取代码请求、解析、抽字段下面这段是一个新闻列表页抓取的最小实现用 Jsoup 完成请求和解析抽标题、链接、发布时间三个字段。import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public class NewsFetcher { // 抓取入口传入列表页 URL返回解析后的文章条目 public static void fetchList(String listUrl) throws Exception { // 1. 发起请求设置超时和 UAUA 不设很多站点会直接返回 403 Document doc Jsoup.connect(listUrl) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .timeout(10000) // 连接读取总超时单位毫秒 .ignoreHttpErrors(true) // 不因 4xx/5xx 抛异常自己判断状态码 .get(); // 2. 用 CSS 选择器定位列表项选择器要按目标站点实际结构调整 Elements items doc.select(ul.news-list li); for (Element item : items) { Element a item.selectFirst(a.title); // 标题链接 if (a null) continue; // 结构不匹配就跳过别让一条脏数据炸掉整批 String title a.text().trim(); String url a.absUrl(href); // absUrl 会把相对路径补成绝对路径 String time item.select(span.time).text().trim(); // 3. 简单过滤标题太短或链接为空直接丢弃 if (title.length() 5 || url.isEmpty()) continue; System.out.println(title | url | time); } } }逻辑说明Jsoup.connect负责发请求userAgent和timeout是两个必调参数前者决定对方站点认不认你后者决定网络抖动时你的线程会不会被拖死。select用的是 CSS 选择器语法ul.news-list li表示 class 为 news-list 的 ul 下的直接 li 子元素是直接子代选择器用错成空格会匹配到嵌套层级抽出来的数据会多出一堆噪音。absUrl(href)这个细节很多人忽略列表页里的链接常是/news/123.html这种相对路径不转绝对路径存进库后面点开就是 404。参数怎么改timeout我一般设 8000 到 15000 毫秒太短容易误判超时太长会让抓取任务堆积。userAgent不要用默认的 Jsoup 标识很多站点会针对性拦截。选择器是最需要按站点调整的部分没有通用写法只能打开目标页面的开发者工具右键复制选择器再简化。2.4 抓取频率与并发别把对方站点打挂也别把自己封了抓取频率是新手最容易翻车的地方。我见过有人开 50 个线程同时抓一个站点结果十分钟后 IP 被限整个任务全挂。合理做法是单站点串行或低并发2 到 3 个线程请求之间加随机间隔比如 1 到 3 秒。用 Spring 的Scheduled做定时任务时把抓取周期设成 10 到 30 分钟一次新闻类站点更新频率没那么高抓太勤没意义还增加风险。Scheduled(fixedDelay 15 * 60 * 1000) // 上一轮结束后再等 15 分钟避免任务重叠 public void scheduledFetch() { for (NewsSource source : sourceMapper.selectList(null)) { try { fetchList(source.getListUrl()); Thread.sleep(1000 new Random().nextInt(2000)); // 随机间隔 1~3 秒 } catch (Exception e) { log.error(抓取失败: {}, source.getListUrl(), e); // 单个源失败不影响其他源 } } }这里用fixedDelay而不是fixedRate是关键fixedRate是固定频率触发如果上一轮还没跑完就会重叠执行抓取任务重叠会放大请求量。fixedDelay保证上一轮彻底结束后再计时。每个源单独 try-catch一个源挂了不能拖垮整批这是长期运行的基本素养。3. 数据清洗、去重与入库聚合平台的核心其实在这一层3.1 表结构怎么设计源表、文章表、频道表的关系抓回来的数据不能直接往一张表里塞否则后面做频道筛选、按源统计、去重都会很别扭。我一般拆三张核心表news_source存抓取源名称、列表页 URL、抓取规则、是否启用news_article存文章标题、原文链接、来源 ID、发布时间、正文摘要、入库时间news_channel存频道分类文章和频道多对多用一张关联表。CREATE TABLE news_article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, url VARCHAR(512) NOT NULL, url_md5 CHAR(32) NOT NULL, -- 链接的 MD5用于唯一约束去重 source_id BIGINT NOT NULL, publish_time DATETIME NULL, summary TEXT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_url_md5 (url_md5), -- 数据库层兜底去重 KEY idx_source_time (source_id, publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;url_md5这一列是去重的关键。原文链接可能很长直接给url建唯一索引在 MySQL 里会因为索引长度限制出问题所以存一个 MD5 值对 MD5 建唯一索引。utf8mb4必须显式指定否则标题里的 emoji 或生僻字会插入报错。idx_source_time这个联合索引是给「按来源查最新文章」准备的没有它数据量上到十万级后列表页会明显变慢。3.2 去重的三个层次URL、标题相似度、内容指纹去重不是一道题是三道。第一层是 URL 精确去重靠上面的唯一索引插入时用INSERT IGNORE或捕获唯一键冲突。第二层是标题相似度去重因为同一篇新闻会被多个源转载链接不同但标题几乎一样。第三层是内容指纹针对正文做 SimHash 或 MinHash成本最高一般项目做到前两层就够。标题相似度我一般用简单的归一化加编辑距离去掉标点和空格转小写然后算相似度超过阈值比如 0.85就判定为重复。不要一上来就上复杂的语义模型那个维护成本对聚合平台来说不划算。// 标题归一化去标点、去空格、转小写 public static String normalizeTitle(String title) { if (title null) return ; return title.replaceAll([\\p{Punct}\\s], ).toLowerCase(); } // 用编辑距离算相似度返回 0~1 public static double similarity(String a, String b) { String na normalizeTitle(a), nb normalizeTitle(b); int maxLen Math.max(na.length(), nb.length()); if (maxLen 0) return 1.0; int dist levenshtein(na, nb); return 1.0 - (double) dist / maxLen; }逻辑说明normalizeTitle里的正则[\p{Punct}\s]匹配所有标点和空白字符去掉它们能消除「标题里多个感叹号」这类无意义差异。levenshtein是标准编辑距离实现这里省略了具体代码核心是动态规划。阈值 0.85 是我在几个项目里试出来的经验值设太低会误杀不同新闻设太高会漏掉转载。这个值要按你的数据实际分布调没有万能数字。3.3 入库的幂等处理重复抓取不能产生重复数据定时任务会反复抓同一个列表页同一篇文章会被抓到多次所以入库必须幂等。最稳的做法是数据库唯一索引兜底代码层用INSERT IGNORE或ON DUPLICATE KEY UPDATE。Insert(INSERT IGNORE INTO news_article(title, url, url_md5, source_id, publish_time, summary) VALUES(#{title}, #{url}, #{urlMd5}, #{sourceId}, #{publishTime}, #{summary})) int insertIgnore(NewsArticle article);INSERT IGNORE在遇到唯一键冲突时不会抛异常而是静默跳过返回受影响行数为 0。这样即使同一批数据被抓两次库里也只有一条。注意它也会忽略其他错误比如字段超长被截断所以字段长度要留够余量标题给 255、链接给 512 是比较安全的。如果你需要「重复时更新摘要」就换成ON DUPLICATE KEY UPDATE summary VALUES(summary)。3.4 正文抓取的取舍抓全文还是只存摘要列表页只有标题和链接要不要点进详情页抓正文是个成本决策。抓全文的好处是站内可读、能做全文检索代价是请求量翻几倍而且详情页结构差异大解析规则要逐个源维护。我的做法是先只存标题、链接、发布时间把聚合和展示跑通等平台稳定了再对重点源开正文抓取。不要一开始就追求全文那会让项目卡在解析规则上出不来。如果确实要抓正文常见做法是定位详情页里的正文容器比如div.article-content取text()或html()然后做一次长度校验太短比如小于 100 字说明选择器没匹配上直接丢弃而不是存空。4. 网页展示与接口设计把聚合结果做成能用的页面4.1 列表接口的分页与频道筛选怎么写前端要的是「按频道分页拉文章按发布时间倒序」。后端接口用 MyBatis-Plus 的分页插件配合条件构造器几行就能写出来。public IPageNewsArticle pageByChannel(Long channelId, int page, int size) { PageNewsArticle p new Page(page, size); LambdaQueryWrapperNewsArticle qw new LambdaQueryWrapper(); if (channelId ! null) { // 先查关联表拿到文章 ID 列表再作为 in 条件 ListLong ids articleChannelMapper.selectArticleIdsByChannel(channelId); if (ids.isEmpty()) return p; // 频道下没文章直接返回空页别拼空 in qw.in(NewsArticle::getId, ids); } qw.orderByDesc(NewsArticle::getPublishTime); return articleMapper.selectPage(p, qw); }逻辑说明Page对象承载页码和每页条数MyBatis-Plus 的分页插件会自动改写 SQL 加上LIMIT。频道筛选走关联表先拿文章 ID 再in查询。这里有个必须处理的边界如果频道下没有文章ids为空直接拼in ()会导致 SQL 语法错误所以要先判空返回。orderByDesc按发布时间倒序但要注意publish_time可能为 null有些源不提供时间null 值在 MySQL 倒序里会排最后如果希望它们排前面要额外处理。4.2 页面渲染服务端渲染还是前后端分离标题里是「网页开发」两种路线都算数。服务端渲染用 Thymeleaf好处是 SEO 友好、首屏快、部署简单一个 Spring Boot 打成一个 jar 就能跑。前后端分离用 Vue/React 加 REST 接口好处是交互灵活但要多维护一套前端工程和跨域配置。新闻聚合平台对 SEO 有天然需求希望被搜索引擎收录所以我一般选 Thymeleaf 服务端渲染列表页和详情页直接由后端吐 HTML。Thymeleaf 模板里循环渲染文章列表注意对标题做 HTML 转义防止抓来的标题里带脚本标签造成 XSS。Thymeleaf 的th:text默认会转义用th:utext才不转义所以展示用户或外部数据一律用th:text。4.3 缓存与刷新列表页别每次都查库首页和频道页是访问最频繁的每次都查库在数据量上来后会有压力。常见做法是用 Redis 缓存首页列表设置 5 到 10 分钟过期抓取任务跑完后主动删掉相关缓存键。如果不想引入 Redis用 Spring 的Cacheable配 Caffeine 本地缓存也能顶一阵单机部署够用。Cacheable(value homeList, key page: #page) public ListNewsArticle homeList(int page) { return articleMapper.selectPage(new Page(page, 20), new LambdaQueryWrapperNewsArticle().orderByDesc(NewsArticle::getPublishTime)).getRecords(); }Cacheable的 key 用页码区分不同页缓存不同结果。抓取任务结束后要调用CacheEvict清掉否则用户看到的还是旧数据。缓存过期时间别设太长新闻类内容时效性强10 分钟是个平衡点。5. 避坑与排查抓取和聚合里最容易翻车的几件事5.1 抓到的中文全是乱码现象入库后标题显示成æ–°é—»这种乱码。原因目标页面编码不是 UTF-8可能是 GBK而 Jsoup 默认按响应头或 UTF-8 解析。解决先看响应头Content-Type里的 charset如果页面是 GBK用Jsoup.parse(html, GBK)显式指定编码或者用Jsoup.connect(url).get()后检查doc.charset()。最稳的方式是拿到字节流后自己判断编码再交给 Jsoup 解析。5.2 定时任务跑着跑着线程池满了现象服务运行几小时后接口变慢日志里出现任务拒绝执行。原因抓取任务里有阻塞操作网络请求超时、Thread.sleep默认调度线程池只有一个线程任务堆积。解决给Scheduled配置独立线程池spring.task.scheduling.pool.size设成 3 到 5同时确保每个抓取请求都有超时不能让单个请求无限等待。5.3 唯一索引建了还是插进重复数据现象明明建了uk_url_md5库里还是有重复标题。原因去重键是 URL 的 MD5但同一篇新闻在不同源下 URL 不同URL 去重管不了跨源转载。解决URL 去重只能防同一链接重复抓取跨源重复要靠标题相似度那一层在入库前先查一遍近期文章的归一化标题命中相似度阈值就跳过。两层去重各管各的别指望一个唯一索引解决所有重复。5.4 页面能打开但列表是空的现象接口返回 200但records为空。原因多半是抓取任务根本没成功入库或者入库了但publish_time为 null 导致排序异常或者频道关联表没写数据。解决按链路排查——先看抓取日志有没有异常再直接查news_article表确认有没有数据再看关联表有没有对应记录。我一般会在抓取任务里加一行统计日志打印「本次抓取 N 条新增 M 条」这样一眼就能看出是抓取问题还是入库问题。5.5 目标站点改版后选择器全部失效现象某天开始某个源一条都抓不到日志里没有异常。原因目标站点改了 HTML 结构原来的 CSS 选择器匹配不到元素代码里selectFirst返回 null 被continue跳过了所以不报错但也没数据。解决给每个源单独记录「本次抓取条数」连续多次为 0 就告警。选择器不要写死在代码里存到news_source表的字段里改版时改数据库配置就行不用重新发版。6. 进阶技巧把抓取规则外置让平台能持续维护做到上面这些一个能跑的聚合平台就有了。但真正决定它能不能长期活下去的是抓取规则的可维护性。我踩过最深的坑就是把每个源的选择器硬编码在 Java 类里源一多改一个选择器就要重新编译打包维护成本高到想放弃。后来改成把规则存进数据库用一套通用的解析器读规则执行情况就好很多。具体做法是在news_source表里加几个字段list_selector列表项选择器、title_selector、link_selector、time_selector抓取时从库里读出来动态执行。这样新增一个源只是插一条记录改版只是改一个字段不用动代码。// 通用解析选择器全部来自数据库配置 public ListNewsArticle parseByRule(String html, NewsSource source) { Document doc Jsoup.parse(html); ListNewsArticle result new ArrayList(); for (Element item : doc.select(source.getListSelector())) { Element titleEl item.selectFirst(source.getTitleSelector()); Element linkEl item.selectFirst(source.getLinkSelector()); if (titleEl null || linkEl null) continue; NewsArticle a new NewsArticle(); a.setTitle(titleEl.text().trim()); a.setUrl(linkEl.absUrl(href)); a.setUrlMd5(DigestUtils.md5Hex(a.getUrl())); a.setSourceId(source.getId()); result.add(a); } return result; }这套做法的边界是它只能处理「选择器能表达」的静态结构遇到需要登录、需要翻页参数、需要执行 JS 的源还是得写专用逻辑。所以我的习惯是通用解析器覆盖 80% 的简单源剩下 20% 的特殊源单独写适配器用一个接口统一调度。这样既保证了大部分源的维护成本低又不会被通用方案绑死。验证抓取规则是否有效我一般用一个笨但可靠的办法写个单元测试把目标页面的 HTML 存成测试资源文件跑解析逻辑断言抽出来的条数大于 0、标题非空。这样目标站点改版时只要更新测试资源文件就能快速验证规则改对没有不用每次都连真实站点。这个习惯帮我省了很多「改完上线才发现选择器写错」的后悔药。最后说个我自己的教训这个项目最容易让人上头的是抓取觉得能抓到数据就成功了结果把大量时间花在适配各种源上页面和体验一塌糊涂。其实聚合平台的价值在于「聚合后的呈现」抓取只是手段。先把三五个稳定源跑通、把去重和展示做扎实比接二十个源但数据一团乱要值钱得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑