JAVA新闻聚合平台实战:Spring Boot+Jsoup实现抓取、去重与展示
发布时间:2026/9/28 15:58:52来源:尧图网络
简介面向毕业设计与课程作业的在线新闻聚合平台项目采用Java与SpringBoot构建后端结合Vue前端与网络爬虫技术实现新闻自动抓取、解析与集中展示适合需要完整可运行参考方案的开发者。资源包共466个文件压缩包大小13.77MB文件以Java源码、Vue组件、JavaScript脚本为主同时包含SVG图标、Python爬虫脚本、SQL数据库脚本和安装/运行脚本类型覆盖前端页面、后端逻辑、数据抓取与数据库初始化目录按功能模块拆分便于按需查阅。目前已有85人学习浏览可作为毕业设计或课程作业的参考样例。项目完整覆盖数据抓取策略、SpringBoot接口开发、响应式页面设计、数据库存储与安全防护等关键环节前端可展示新闻标题、摘要、图片与链接后端提供可调用的接口逻辑并附有运行脚本和数据库脚本能够帮助读者快速启动环境、理清前后端联调与爬虫解析流程进而基于现有代码完成二次扩展。1. 在线新闻聚合平台到底在做什么一个抓取、清洗到展示的最小闭环别把在线新闻聚合平台想复杂了。它本质上是三件事用 JAVA 写爬虫去数据抓取把抓回来的网页内容洗成干净的结构化数据再通过网页开发把它展示给用户。我见过很多课程设计和毕业设计都卡在一个误区里——上来就研究 Scrapy 或分布式爬虫结果光反爬就耗掉一半时间。实际上一个能验收、能演示、能跑完整个流程的新闻聚合平台工作量分布大约是抓取三成、清洗和去重三成、网页展示四成。反直觉的结论在这里新闻聚合平台最核心的能力不是抓得多而是存得干净、出得来。同一个新闻被多个站点转载抓回来如果不做去重页面上全是重复标题演示时直接翻车。这篇文章按整体设计 → 数据抓取实现 → 避坑 → 网页端聚合展示 → 验证调优的顺序给出一套能用 Spring Boot Jsoup 落地的完整方案。适合正在做 JAVA 课程设计、毕业设计或者想自己搭一个轻量级聚合站点练手的开发者。2. 用JAVA搭聚合平台的整体设计从网页抓取到新闻落库的表结构与技术选型2.1 为什么选Spring Boot Jsoup一套能跑完课程设计也能上生产的组合JAVA Web 做新闻聚合平台常见路线有三条纯 Servlet/JSP、SSMSpring Spring MVC MyBatis、Spring Boot。纯 Servlet/JSP 教学意义强但写起来重复代码太多一个列表页要配 web.xml、过滤器、监听器光配置就劝退大半人。SSM 是前几年毕设的主流但 XML 配置繁琐光 Spring 和 MyBatis 的整合就能卡一整天。Spring Boot 把自动配置和内置 Tomcat 都解决了一个main方法启动整个项目演示的时候不用额外装环境这题面试八股文里也常考做起来性价比最高。数据抓取选型上HttpClient 负责下载页面Jsoup 负责解析 HTML 提取标题和正文这是 JAVA 生态里最成熟的组合。Jsoup 的 selector 语法写起来像 jQuery比如doc.select(h3 a)就能把新闻列表页里所有标题链接捞出来。复杂度更低的方案有HttpURLConnection但重定向、超时、连接池都要自己管不推荐。更重的方案是 WebMagic 或 Selenium前者适合大规模爬虫后者能渲染 JavaScript 动态页面但 Selenium 要额外装浏览器驱动环境依赖太重。做课程设计和本地部署Spring Boot Jsoup 是最不容易卡壳的路线。提示如果目标站点是 JavaScript 动态渲染的Jsoup 拿不到内容。先用浏览器开发者工具看查看网页源代码里有没有数据有就用 Jsoup没有再考虑 HtmlUnit 或 Selenium。数据库我建议直接用 MySQL 8.x本地部署也可以用 H2 的 MySQL 兼容模式。表设计只需要三张表不要过度设计。很多同学一上来就设计用户表、评论表、收藏表结果抓取和展示的主线还没跑通光关联查询就把自己绕晕了。这个项目的核心是新闻数据用户体系、权限控制都是加分项不是必备项先把主线立住。2.2 核心表结构设计新闻表、分类表、抓取源表的三个字段约束三张表的分工很明确t_new存新闻本身t_category存分类t_source存抓取源配置。设计时记住一句话抓取来的数据一定是不干净的表结构要能容忍脏数据但不能容忍重复数据。CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE COMMENT 分类名称如 国内、国际、科技, sort INT DEFAULT 0 COMMENT 排序权重越小越靠前 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_source ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 站点名称, url VARCHAR(500) NOT NULL COMMENT 列表页URL, selector VARCHAR(200) NOT NULL COMMENT 列表项CSS选择器, charset VARCHAR(20) DEFAULT UTF-8 COMMENT 页面编码, category_id INT COMMENT 抓下来的新闻归入哪个分类, enabled TINYINT DEFAULT 1 COMMENT 是否启用, FOREIGN KEY (category_id) REFERENCES t_category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_new ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(500) NOT NULL COMMENT 新闻标题, url VARCHAR(500) NOT NULL COMMENT 原文链接, source_name VARCHAR(100) COMMENT 来源站点名, category_id INT COMMENT 分类, summary VARCHAR(1000) COMMENT 摘要取正文前200字, content MEDIUMTEXT COMMENT 正文HTML或纯文本, publish_time DATETIME COMMENT 发布时间可能为空, fetch_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 抓取时间, view_count INT DEFAULT 0 COMMENT 浏览数, UNIQUE KEY uk_url (url(191)) COMMENT URL去重注意索引长度 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT新闻表;核心约束有三个。第一个是uk_url对 URL 建唯一索引长度控制在 191 是因为 utf8mb4 字符集下 InnoDB 索引有长度上限超长 URL 会被截断截断反而能去掉一些无意义的追踪参数。第二个是分类表的name唯一保证科技不会出现两次。第三个是发布时间字段允许为空因为不是所有站点都规范地标注时间允许空值比强行填一个错误时间更安全。这里有一个取舍要说明URL 去重能挡住同一站点内的重复抓取但挡不住跨站转载。比如新浪发了条新闻网易也发了URL 完全不同。跨站去重要靠标题或正文指纹这个放到第 3 章代码里做。表结构上建议给title加一个普通索引方便按标题查重。2.3 抓取任务的最小调度定时任务与手动触发并存抓取策略不必上 Quartz 或 XXL-JOB 这种重组件Spring Boot 自带的Scheduled足够。但要注意一个问题定时任务默认是单线程的如果某个站点超时后面的站点会全部排队卡住。我一般会在配置类里设置一个线程池让不同站点的抓取并行。Configuration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(4)); } }newScheduledThreadPool(4)表示最多 4 个定时任务并行。线程数不是越大越好抓取属于 IO 密集型任务线程开太多反而容易触发目标站点的访问限制。本地演示时 2 到 4 个线程足够了。调度频率我用fixedDelay而不是fixedRate。fixedRate是固定速率比如每 30 分钟执行一次不管上次有没有跑完fixedDelay是固定延迟上一次执行完再等 30 分钟。抓取任务耗时不稳定有的站点 3 秒返回有的要 30 秒用fixedDelay能避免任务堆叠。实际参数设置为Scheduled(fixedDelay 1800000)即 30 分钟一次。演示的时候这种频率太慢可以在配置里把值调小或者加一个手动触发的接口比如访问/api/fetch就立刻执行一轮抓取这样演示不用干等。手动触发接口在 Controller 里注一个NewsFetchService调fetchAll()方法即可。这样既满足定时更新场景又满足演示时点一下立刻抓取的需求。3. 数据抓取模块的实现从网页源码到结构化新闻的完整代码3.1 抓取源配置把站点差异变成一行数据库记录数据抓取模块最怕写死。常见的错误是 foreach 遍历一个 List然后 nextInt把 rnd 凑成 (cnt - 1) 范围内但边界条件舍掉最后一个这部分确实需要把代码结构设计成配置驱动。每个站点的列表页结构不一样但差异可以抽象成四个字段列表页 URL、列表项选择器、页面编码、分类。把这四个字段存在t_source表里抓取引擎只需要做一件事查启用状态的站点逐个抓。public ListSourceConfig listEnabledSources() { return jdbcTemplate.query( SELECT id, name, url, selector, charset, category_id FROM t_source WHERE enabled 1, (rs, rowNum) - new SourceConfig( rs.getLong(id), rs.getString(name), rs.getString(url), rs.getString(selector), rs.getString(charset), rs.getLong(category_id) ) ); }这段代码的查询条件只有enabled 1没有 order by 是因为多站点抓取本身是并行的顺序不影响结果。SourceConfig就是对应表的 POJO字段一一映射。新增一个抓取源时不用改 JAVA 代码只要往t_source插一条记录写入目标站点的列表页 URL用浏览器开发者工具选一个能唯一定位到新闻列表的选择器。比如某个科技站点的列表是ul classnews-listlia href...标题/a/li/ul选择器就填ul.news-list li a。这样一个系统能对接几十个站点每加一个站点只花两分钟。3.2 用Jsoup解析HTML提取标题、链接、发布时间与正文这是整个项目最核心的一段代码所有站点抓取逻辑都在这里。抓新闻列表页的标准流程下载 HTML 字符串 → Jsoup 解析成 Document → 按选择器取出候选列表项 → 对每个候选提取标题、URL、时间 → 解析详情页正文。public ListNewsItem fetchFromSource(SourceConfig source) { ListNewsItem result new ArrayList(); try { // 设置连接参数10秒连不上就放弃 Document listDoc Jsoup.connect(source.getUrl()) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64)) .timeout(10000) .ignoreContentType(true) .get(); Elements items listDoc.select(source.getSelector()); // 只取前20条防止一次抓太多 for (Element item : items.subList(0, Math.min(20, items.size()))) { String title item.text().trim(); String url item.absUrl(href); // 自动补全相对路径 if (title.isEmpty() || url.isEmpty()) { continue; } NewsItem news new NewsItem(); news.setTitle(title); news.setUrl(url); news.setSourceName(source.getName()); news.setCategoryId(source.getCategoryId()); news.setPublishTime(parseTime(item.select(span.time).text())); result.add(news); } } catch (Exception e) { log.error(抓取失败: {} - {}, source.getName(), e.getMessage()); } return result; }userAgent必须设置。很多站点对无 UA 的请求直接返回 403 或跳转到验证页设置成一个常见浏览器的 UA 能减少很多拦截。timeout(10000)是连接和读取的总超时超过 10 秒直接抛异常避免某个站点了卡死整个线程。ignoreContentType(true)是防止某些站点返回的内容类型不是text/html时 Jsoup 拒绝解析。item.absUrl(href)会把 news/123.html 这种相对路径自动补全成完整的绝对 URL这个细节最容易忘忘了缓存下来的链接全部打不开。parseTime负责解析发布时间各站点的格式五花八门。常见的有 2024-01-15 09:30、 2024-01-15T09:30:0008:00、 5分钟前 这种相对时间。相对时间直接解析成空值不要硬算因为抓取任务 30 分钟跑一次算出来的5分钟前误差可以接受但意义不大反而容易因为时区问题出错。详情页正文的抓取是另一个方法。列表页只拿到标题和链接正文要再发一次请求public String fetchContent(String articleUrl) { try { Document doc Jsoup.connect(articleUrl) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64)) .timeout(10000) .get(); // 常见正文区块选择器如 div.article-content, div#content, article Element contentDiv doc.selectFirst(div.article-content, div#content, article); if (contentDiv null) { return ; } return contentDiv.text(); } catch (Exception e) { return ; } }selectFirst后面跟的多个选择器用逗号分隔谁先匹配到就用谁。正文选择器是高频维护点因为不同站点的正文容器 class 命名完全不一样换一个站点就要调整一次。实践中我还会过滤掉正文里的广告和推荐位关键词比如包含 相关阅读、广告、下载App 的段落直接跳过这个用一段简单正则就行。正文抓取比较耗时建议用Executors.newFixedThreadPool(4)做并行每个详情页一个任务。提示抓正文之前先判断标题和 URL 是否已存在存在的就不抓详情页了能省大量请求。这个判断就是去重逻辑的一部分见 3.3。3.3 落库与去重两个SQL条件挡住90%的重复新闻抓取完成后的落库阶段去重要在两条路径上同时做。第一层是数据库的唯一索引uk_url第二层是代码里的标题相似判断。只靠一层不够URL 是一致的场景标题去重负责跨站转载场景。public int saveNews(NewsItem news, String content) { String titleFingerprint md5(news.getTitle().replaceAll([\\s。、,.!?], )); // 标题指纹已存在则跳过 Integer count jdbcTemplate.queryForObject( SELECT COUNT(*) FROM t_new WHERE title_fingerprint ?, Integer.class, titleFingerprint); if (count ! null count 0) { return 0; } return jdbcTemplate.update( INSERT INTO t_new (title, url, source_name, category_id, summary, content, publish_time) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE view_count view_count, news.getTitle(), news.getUrl(), news.getSourceName(), news.getCategoryId(), buildSummary(content), content, news.getPublishTime() ); }title_fingerprint字段是标题去掉所有标点和空格后做 MD5。为什么不是直接比较标题全文因为有的站点会在标题末尾加 - 新浪新闻 这种后缀去掉空格和标点后再算哈希标准化的效果更好。这段逻辑里ON DUPLICATE KEY UPDATE处理的是 URL 撞唯一索引的情况重复插入时只更新view_count不会真正覆盖已有数据也不会抛异常中断任务。这段代码从代码风格看直接采用了 JdbcTemplate 显式 SQL 的方式好处是简单直接open 到改造空间。再配合summary字段、content字段取正文前 200 个字符作为摘要列表页显示摘要详情页显示全文。落库时如果某条正文抓取失败content为空字符串摘要也自动为空列表页对这种记录要特殊处理显示原文链接而不是空白摘要否则演示时一排空卡片很难看。ALTER TABLE t_new ADD COLUMN title_fingerprint CHAR(32) DEFAULT COMMENT 标题去重指纹; CREATE INDEX idx_fingerprint ON t_new (title_fingerprint);指纹字段要加索引否则表数据量过万后每条插入都要全表扫查询会越来越慢。4. 数据抓取的5个坑编码、超时、链接与规则变更的血泪排查4.1 页面全是乱码GBK站点被当成了UTF-8现象抓回来的新闻标题在数据库里是一串符号或者浏览器页面显示正常但 Jsoup 解析出来全乱。原因HtmlUnit 这类组件默认按响应头里的 charset 解读页面但很多老站点的响应头不写 charset或者写的是Content-Type: text/html不带编码参数。这时 Jsoup 会默认用 UTF-8 解码遇到 GBK 编码的页面就全乱。解决抓取时先看一个已知字符响应头不可信时就手动指定charset这个配置存到t_source.charset字段里。Document doc Jsoup.parse(new String(htmlBytes, source.getCharset()));代码逻辑是先用Jsoup.connect(...).execute()拿到Response再response.bodyAsBytes()得到原始字节按配置的编码手动转成字符串。这样不会在响应头缺失时翻车。判断一个页面是不是 GBK 也简单浏览器里右键查看网页源代码如果 Meta 标签里写的是meta charsetgb2312或gbk就在配置里填 GBK。另外抓取到 HTML 字符串之后立即判断doc.title()里有没有乱码字偶尔会有站点在一个页面里混用两种编码写的时候多加一个contains()判断就能快速反馈问题。4.2 列表抓到了但正文为空目标正文由JavaScript渲染现象列表页解析正常标题、链接都有点进详情页发现正文长度为 0数据库里content全是空串。原因很多移动端新闻站点的正文是通过 JavaScript 异步加载的初始 HTML 里只有一个空壳 div 和一堆 script 标签。Jsoup 不执行 JavaScript自然拿不到内容。解决先用浏览器开发者工具打开目标详情页找到网络请求里返回 JSON 或 JSONP 的那个接口比如https://site.com/api/news/123456?typecontent。如果能找到直接改成抓取这个接口解析 JSON 里的content字段。实在找不到接口只能切换成 HtmlUnit 这类带浏览器内核的工具代价是性能差、依赖重半个站点一个深度抓取要好几秒。解决方案的优先级是找接口 静态解析 上 HtmlUnit。大部分情况下移动版页面m.site.com反而比 PC 版更容易拿到纯 HTML 正文方案可以优先尝试移动版页面的结构。4.3 一个站点超时卡死整个抓取任务现象执行抓取任务时控制台日志停在其中某个站点上超过一分钟不动其他站点也不执行了。原因某个目标站点的服务器响应极慢但 TCP 连接已经建立Jsoup 的read阶段一直等数据默认超时没起作用。项目里如果没有用多线程调度一个连接卡住整个任务就停了。解决两层防护。第一层是给Jsoup.connect设置timeout10 秒到 15 秒之间太短容易误伤正常站点。第二层是给整个抓取方法包一层Future配合future.get(20, TimeUnit.SECONDS)即使 Jsoup 内部出现极端情况也能强制放弃ExecutorService pool Executors.newFixedThreadPool(2); FutureListNewsItem future pool.submit(() - fetchFromSource(source)); try { ListNewsItem items future.get(20, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); log.error(抓取超时已强制取消: {}, source.getName()); }future.cancel(true)会中断正在执行的线程配合 Jsoup 内部对中断的响应能快速释放连接。这个写法在课程设计答辩时可以说清楚原理面试官很吃这一套。线程池单独建不要让定时任务的线程池去跑抓取职责分开避免互相影响。4.4 同一篇新闻反复入库URL追踪参数导致唯一索引失效现象数据库里出现大量标题几乎一样、只有 URL 尾部不同如?fromfeed、?fromfeedpage2的重复记录。原因站点给新闻链接挂了追踪参数同一个新闻的 URL 变了多次uk_url唯一索引只能防完全相同的 URL。解决落库前对 URL 做规范化。public String normalizeUrl(String rawUrl) { int idx rawUrl.indexOf(?); if (idx 0) { return rawUrl.substring(0, idx); } return rawUrl; }这是最简单的版本直接砍掉所有查询参数。有一点要注意有些站点的文章页确实依赖查询参数区分不同内容砍掉参数后不同文章也可能撞同一 URL。对新闻聚合场景来说参数砍掉后如果撞了说明这两个新闻本来就在同一个页面上展示去重不会有损失。如果想去得更细腻只砍掉已知的追踪参数名from、source、page保留下单篇详情页必需的参数需要维护一个黑名单列表。4.5 新闻时间全是空或全是当前时间现象列表页明明有 2024-05-20 09:30 这样的时间文本入库后publish_time全是 NULL或者全部变成抓取时间fetch_time。原因时间选择器写到了列表项的父级容器上把整个区块的文本都拿下来了比如item.text()返回 2024-05-20 09:30 评论 20 分享 5正则匹配日期时没匹配上或者目标页面用的是相对时间如 2小时前直接解析失败。解决解析时间时先做正则提取再考虑格式化。时间字段不用太较真解析失败就存 NULL排序时用COALESCE(publish_time, fetch_time)兜底。public LocalDateTime parseTime(String rawText) { Pattern p Pattern.compile((\\d{4})[-/年](\\d{1,2})[-/月](\\d{1,2})日?); Matcher m p.matcher(rawText); if (m.find()) { return LocalDateTime.of( Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)), 0, 0); } return null; }正则只匹配日期部分时间部分缺失就补 00:00。代码里不要去做5分钟前换算新闻聚合场景对时间的精度要求没那么高按日期展示完全够用。宁可时间为空也不能存一个假的当前时间否则用户会看到一堆刚刚的新闻一眼假。5. 网页端展示与聚合功能JAVA后端接口加前端页面的落地5.1 后端接口设计列表、详情、分类筛选三个接口就够了网页开发部分的核心是三个接口新闻列表、新闻详情、分类筛选。分页用page和size两个参数不需要做复杂的分页封装自己写一个简单的PageResult类更符合课程设计的表意。RestController RequestMapping(/api/news) public class NewsController { private final JdbcTemplate jdbcTemplate; public NewsController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } GetMapping(/list) public MapString, Object list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) Long categoryId) { int offset (page - 1) * size; String sql SELECT id, title, summary, source_name, publish_time FROM t_new ; String countSql SELECT COUNT(*) FROM t_new ; Object[] args; if (categoryId ! null) { sql WHERE category_id ? ; countSql WHERE category_id ? ; args new Object[]{categoryId, offset, size}; } else { sql WHERE 11 ; args new Object[]{offset, size}; } sql ORDER BY COALESCE(publish_time, fetch_time) DESC LIMIT ? OFFSET ?; MapString, Object result new HashMap(); result.put(total, jdbcTemplate.queryForObject(countSql, Integer.class, categoryId ! null ? new Object[]{categoryId} : new Object[]{})); result.put(list, jdbcTemplate.query(sql, (rs, rowNum) - { MapString, Object item new HashMap(); item.put(id, rs.getLong(id)); item.put(title, rs.getString(title)); item.put(summary, rs.getString(summary)); item.put(source, rs.getString(source_name)); item.put(publishTime, rs.getObject(publish_time)); return item; }, args)); return result; } }分页写的LIMIT ? OFFSET ?MySQL 8 支持这种参数占位方式。排序用COALESCE(publish_time, fetch_time)发布时间为空时自动回落成抓取时间保证列表始终按时间倒序。注意categoryId为空时要拼一个恒成立的条件WHERE 11这样后续追加 SQL 子句时不用纠结第一个条件前面要不要加AND实现起来最省事。详情页接口是GET /api/news/{id}返回title、content、source_name、publish_time这四个字段。列表页和详情页分离聚合平台的常见做法就是列表轻、详情重列表展示摘要详情展示正文和原文链接。5.2 页面实现用Thymeleaf渲染新闻列表和详情页前端方案我用 Thymeleaf 服务端渲染不分离前后端。理由很简单课程设计和本地部署场景下服务端渲染能直接在 Controller 里返回视图名不需要配跨域、不需要起两个服务、也不需要 Nginx 转发。模板文件放在src/main/resources/templates下一个list.html加一个detail.html。!-- list.html -- !DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title新闻聚合/title style .news-item { padding: 12px; border-bottom: 1px solid #eee; } .news-item h3 { margin: 0 0 6px 0; } .news-item .meta { color: #999; font-size: 13px; } /style /head body div th:eachitem : ${page.list} h3a th:href{/news/ ${item.id}} th:text${item.title}标题/a/h3 p th:text${item.summary}摘要/p div classmeta span th:text${item.source}来源/span span th:text${item.publishTime}时间/span /div /div div a th:if${page.page 1} th:href{/news?page ${page.page - 1}}上一页/a span th:text${page.page}/span a th:if${page.page page.totalPages} th:href{/news?page ${page.page 1}}下一页/a /div /body /htmlth:each相当于 Java 里的for (item : page.list)th:href{/news/ ${item.id}}是 Thymeleaf 的 URL 表达式拼接语法。分页控件里th:if控制上一页和下一页的显隐第一页不显示上一页最后一页不显示下一页这个小细节能让页面看起来完整许多答辩时也值得提一句。Controller 端对应GetMapping(/news) public String newsPage(Model model, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { model.addAttribute(page, newsService.findPage(page, size)); return list; }model.addAttribute的数据在模板里通过${page}拿到。这套写法 2010 年的 SSM 项目就这样写至今没被淘汰原因就是它简单可靠服务端渲染天然适合新闻这种重内容的展示场景。详情页模板同理把content字段的纯文本按段落切分后展示即可。5.3 聚合排序按时间与来源权重的混合排序聚合这个词要真正落地不能只是把新闻堆在一个列表里。最基本的聚合排序策略是时间优先最新的新闻排最前面。但全站按时间排有一个问题小站更新频率高刷屏式地霸占前几条大站虽然有重要新闻更新慢沉到列表里。所以我会引入一个来源权重字段调整排序。SELECT id, title, summary, source_name, publish_time, (CASE WHEN source_name IN (人民网, 新华网) THEN 3 WHEN source_name IN (新浪, 网易) THEN 2 ELSE 1 END) AS source_weight FROM t_new ORDER BY source_weight DESC, COALESCE(publish_time, fetch_time) DESC LIMIT 10 OFFSET 0;这个CASE WHEN把来源分成三档权威和大型站点权重高小站点权重低。排序时先按权重降序再按时间降序这样既保证重要来源的曝光又避免完全被刷屏。source_weight放在 SELECT 里但不落库因为它可以从source_name推导出来落库反而多一份要维护的数据。关键词匹配也是聚合平台的常见功能。用WHERE title LIKE %关键词%就能实现最基本的搜索数据量小的时候完全够用。如果要做中文分词引入ikanalyzer或jieba分词库把标题和正文分词后建索引表但课程设计阶段不建议做分词器对中文的支持参差不齐调试起来容易陷入分词效果的玄学问题把简单功能做成大坑不划算。6. 把新闻聚合平台调到能用的状态验证指标与三个进阶技巧平台跑起来只是第一步能用和能演示之间差着验证和调优。我每次做完这类项目习惯用四个指标自检抓取成功率成功抓取的源数 / 总源数、去重率被过滤的重复新闻数 / 抓取总数、正文完整率content 非空的记录占比、页面响应时间首屏 2 秒内。这四个数对应到一个可见的检查页面上列为一行四个数字演示时一眼就能看出系统状态。如果抓取成功率连续两轮低于 80%要么是站点改版了要么是网络环境变了先查日志别盲目调代码。三个进阶技巧值得投入。第一个是正文指纹去重比标题指纹更稳。取正文前 100 个字算 MD5 作为content_fingerprint两个站转载同一篇文章时正文文本几乎一致这个指纹能准确识别。标题可能不同正文前 100 个字不会差太多。代价是必须等正文抓取完才能判断是否入库详情页请求省不掉了。第二个是抓取频率动态退避。给每个抓取源维护一个连续失败次数失败 3 次后暂停该源 2 小时成功一次就清零。实现方式是在t_source表加fail_count和last_error两个字段抓取逻辑里判断fail_count 3就跳过该源。这个机制能避免一个坏掉的站点反复拖慢整个任务。第三个是对正文做最小长度校验。正文抓下来后字数少于 50 字大概率是抓到了导航栏或评论区块此时丢弃并记录日志。我踩过一次某个站点的正文选择器写错抓到的是页脚版权文本每篇新闻正文最后都是Copyright 2024这种脏数据在列表页看不出来点进去才穿帮。这套平台的全部代码量含前端模板、抓取逻辑和页面展示大约一千多行 JAVA 代码加两个模板文件。成本集中在选择器配置和站点适配换一个站点平均花 5 分钟。如果你能接受维护抓取源这个长期成本这个方向值得做下去如果只是想验收交差就把抓取源控制在 3 到 5 个稳定的站点同样能讲清楚整个聚合链路。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网