做这个豆瓣读书 Top250 全栈数据项目,起因其实特别朴素,就是想给简历里添一个能从头讲到尾的实战项目。python爬虫负责采数据,pandas做数据清洗,MySQL落库,Java后端提供接口,前端再用ECharts把图书数据渲染成图表,一条完整链路走下来,比单独刷一百道面试题都管用。系列第一篇我们聊了环境准备和单页爬取的思路,这一篇直接把数据清洗、入库、Java后端、可视化全部打通。
很多同学学完Python爬虫不知道下一步怎么接,或者Java后端学完只会写增删改查,ECharts也只停留在复制官方示例的水平。这篇项目的价值,就是帮你把几门独立技术串成一条从数据采集到可视化展示的生产链路。如果你是准备Python或Java开发岗,又或者正在做数据库课程设计、毕业设计,照着这条线完整做一遍,至少能解决“学完不知道能做什么”的问题。
1. 项目整体设计与技术选型思路
1.1 为什么偏偏选豆瓣读书 Top250 这个数据源
豆瓣读书Top250是个特别适合练手的数据源。第一,数据量不多不少,250本书刚好够做统计图表,又不至于让爬虫和数据库处理显得臃肿。第二,页面结构非常稳定,从早些年到现在基本还是同一套HTML结构,用BeautifulSoup解析不会频繁改版。第三,字段足够丰富,书名、作者、出版社、出版年份、评分、评价人数、简介全都有,想画柱状图、饼图、折线图都不缺维度。
有人问我为什么不用其他电商图书站,数据量虽然更大,但页面结构和反爬策略对新手不友好。豆瓣读书Top250唯一的反爬就是比较常规的User-Agent校验和访问频率限制,只要正常设置请求头、控制抓取速度,跑一轮下来基本不会触发封禁。对一个教学型项目来说,选它能让你把注意力放在数据链路的搭建上,而不是跟验证码和加密参数死磕。
1.2 技术栈选型的逻辑
有人觉得“Python都能干完的事,为什么还要拉上Java后端”,这个问题我在带项目时被问得最多,这里先讲清楚选型逻辑。
Python在这条链路里负责的是采集加清洗。这两件事Python生态优势明显,requests发请求、BeautifulSoup解析、pandas清洗,每一环都有成熟轮子,写起来不超过一百行。数据量只有250条,完全不需要上Spark、Flink这种东西,杀鸡不用牛刀。
数据库选MySQL,理由就一条:最通用。不管是数据库课程设计还是公司实习,MySQL几乎是默认选项。建库、建表、增删改查、索引、编码这些技能,练完无论去什么项目都能复用。
Java后端是很多人质疑的地方。我的观点是,如果只是把数据展示出来,Python写个Flask就够了;可如果你要把这套流程当作求职项目,尤其目标是后端开发岗,那Java Spring Boot才是最有展示度的答案。它能把一条简单的数据查询变成标准的三层架构:Controller接收请求、Service处理业务、Mapper访问数据库。这正好对应面试里常问的那些热点问题的真实落点,后面第四章我会展开讲。
ECharts选它没什么悬念。图表类型全,柱状图、饼图、折线图、地图都有;配置项丰富,鼠标悬浮、缩放、图例切换都是现成的;对中文环境非常友好,文档和社区资源多。更重要的是,ECharts基于Canvas渲染,性能在小数据量下完全没压力,250条图书数据怎么画都流畅。
1.3 数据链路全景:从网页到图表
整个项目的数据流是这样的:Python爬虫从豆瓣读书Top250页面采集原始HTML,解析成结构化字典列表,转成pandas DataFrame做数据清洗,写入MySQL数据库,Java Spring Boot后端从库里查询并封装成JSON接口,前端页面用fetch请求接口,最后ECharts渲染图表。
这条链路每一环都有自己容易踩的坑。爬虫的坑在解析失败和编码,数据清洗的坑在字段拆分和类型转换,数据库的坑在编码和去重,后端的坑在跨域和数据结构,可视化的坑在数据格式不匹配。后面每一章我会按环节逐个把这些坑填上,你看完照做基本能一次跑通。
2. 爬虫核心:请求构造、页面解析与翻页逻辑
2.1 页面结构和URL规律先搞清楚
先说URL的规律。豆瓣读书Top250的列表页地址是https://book.douban.com/top250,第一页不带参数。翻到第二页可以看到地址变成?start=25,第三页是?start=50,以此类推。每页固定25本书,所以总页数是10页,start的取值就是0、25、50一直到225。
这个规律为什么重要?因为可以省去很多麻烦。你不需要去解析翻页按钮的链接,直接用循环生成10个URL就能把250本书全部抓完。理解了分页参数,哪怕以后面对的是别的分页网站,第一反应也是先看URL参数怎么变,而不是硬解析页面上那个“下一页”按钮。
2.2 请求构造:UA、超时与编码控制
爬虫的第一步是发请求。直接裸请求豆瓣大概率返回418或403,你需要在headers里带上一个正常的浏览器标识。最基础也最有效的就是User-Agent,把Chrome里的UA字符串复制过来就行。如果需要更稳,可以把Cookie也带上,不过豆瓣读书Top250这个页面实测只带UA也是能跑的。
我习惯把请求封装成一个小函数,方便统一处理:
import requests import time from bs4 import BeautifulSoup HEADERS = { "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" } def get_page(start): url = f"https://book.douban.com/top250?start={start}" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text这里有几个细节值得说。timeout=10是必须的,没有超时设置时如果网络卡住,脚本会一直挂在那里。resp.encoding指定成utf-8可以避免部分请求下中文乱码的问题。resp.raise_for_status()能帮你在请求失败时立刻报错,而不是拿到一个错误页面后继续解析,最后报一堆莫名其妙的解析失败。
2.3 页面解析:字段定位与提取
用BeautifulSoup解析时,建议用select加CSS选择器的写法,比find_all逐层查找直观得多。豆瓣Top250每个item的结构大体是这样的:一个li标签里包含封面图、书名、评分、评价人数、作者出版信息和简介。
我提取的字段有六个:书名、作者、出版社、出版年份、评分、评价人数、简介。页面里作者和出版社、年份是拼在同一个p标签里的,形如“[美] 卡勒德·胡赛尼 / 李继宏 / 上海人民出版社 / 2006-5 / 29.00元”,这一长串需要留到数据清洗环节去拆分,爬虫阶段先把整段文本取下来就成。
def parse_page(html): soup = BeautifulSoup(html, "html.parser") items = soup.select("tr.item") books = [] for item in items: title = item.select_one("a.title").text.strip() rating = item.select_one("span.rating_nums").text.strip() people = item.select_one("span.pl").text.strip() info = item.select("p")[0].text.strip() intro = item.select_one("span.inq") books.append({ "title": title, "rating": rating, "people": people, "info": info, "intro": intro.text.strip() if intro else "" }) return books这里特别注意三个地方。第一,评价人数那个span的文本长这样:“(8675人评价)”,带括号带单位,必须等清洗阶段统一处理。第二,简介那一列不是每本书都有,部分书籍没有那个span.inq,所以要用if intro先判断再取text,不然直接NoneType报错。第三,select("p")[0]这种取法依赖p标签顺序,在豆瓣这个页面实测稳定,但换别的网站要重新确认。
2.4 翻页循环与并发设计:到底要不要上并发
250条数据、10个页面,这个量级下我的建议就是老老实实单线程同步抓取,每页之间sleep 0.3秒左右,十几秒钟就跑完了,完全不存在性能瓶颈。
那“爬虫并发设计到底哪个好”这个问题怎么回答?我的经验是:并发的本质是时间换速度,但代价是更大的代码复杂度和更高的反爬触发风险。如果目标页面只有几十上百个请求,单线程加延时就是最优解;如果目标是几千上万页的站点,再考虑线程池并发,比如requests配合ThreadPoolExecutor,或者直接上Scrapy的并发配置。
翻页循环可以这样写:
all_books = [] for start in range(0, 250, 25): html = get_page(start) all_books.extend(parse_page(html)) time.sleep(0.5) print(f"共采集 {len(all_books)} 本书")循环用range(0, 250, 25)直接覆盖了所有分页起点,每次解析完往all_books里extend,最后检查长度是不是250,就知道有没有漏页。这种验证方式虽然基础,但在教学项目里比什么都管用。
3. 数据清洗与落库:从DataFrame到MySQL
3.1 先看数据结构再动手
爬完的数据是一堆字典组成的列表。我习惯先快速转成DataFrame看一眼类型和缺失情况,不要急着写清洗代码。
import pandas as pd df = pd.DataFrame(all_books) print(df.shape) print(df.dtypes) print(df.isnull().sum())250本书,7个字段,dtypes里全是object,isnull()能看到简介里有缺失值。这一步的价值在于让你对整个数据底座心里有数:哪些字段要转数字、哪个字段有缺失、哪个字段文本里带杂质,全都在这一眼里。不要跳过这个检查直接开洗,不然洗到一半发现某个字段结构跟预期不一样,回头改代码更痛苦。
3.2 字段拆分和类型转换:清洗的重头戏
清洗的核心在info字段。这个字段原始值长这样:“[美] 卡勒德·胡赛尼 / 李继宏 / 上海人民出版社 / 2006-5 / 29.00元”。拆分的规则是用“/”分割,按顺序对应作者、译者、出版社、出版年份、价格。但这里有个坑,并不是每本书都有译者,比如部分中文原创书就是“王小波 / 陕西师范大学出版社 / 2009-7 / 24.00元”这种三段式。
所以不能简单粗暴地按固定索引切片。我的处理办法是:先把字符串按“/”拆成数组,再根据数组长度和内容特征去判断,倒数第二个元素通常是年份,倒数第三个元素通常是出版社。代码写起来不难,关键是理解“位置规则”和“内容规则”配合使用的思路。
def parse_info(info): parts = [p.strip() for p in info.split("/")] year = None for p in parts: if "-" in p and len(p) >= 4 and p[:4].isdigit(): year = p break publisher = parts[-2] if len(parts) >= 3 else None return publisher, year年份检测优先用“包含短横线且开头是四位数字”这种内容规则,比固定位置更稳。出版社取倒二是因为不管前面有没有译者,出版社都在年份前面、价格和年份之间。
评分这列直接astype(float),评价人数这列要先去掉括号和“人评价”三个字再转int。这些看起来像重复劳动,但漏掉任何一个,后面Java接口返回的数据类型就会乱套。
df["rating"] = df["rating"].astype(float) df["people"] = df["people"].str.extract(r"(\d+)").astype(int)提取数字用正则最省事,r"(\d+)"直接抓出字符串里的第一段连续数字,“(8675人评价)”抓出来就是“8675”。
3.3 去重和其他文本清理
豆瓣Top250理论上不会有重复,但万一爬虫跑了两遍或者页面加载异常,保不齐就混进重复数据。我的习惯是清洗阶段就把去重逻辑落实,别等到数据库表建完再用SQL去删。
df = df.drop_duplicates(subset=["title", "author"])如果后续把清洗脚本重跑,每次都往库里插一遍,肯定会出现重复,这个问题我在常见问题环节还会再提一次。
文本清理上要处理全角空格、连续换行、首尾空白。简介里偶尔会有奇怪的换行符,用一个简单的replace批量处理掉。不要在一开始就追求清洗代码写得“完美”,数据清洗永远是针对具体数据形态的,先把这250条数据的里里外外看一遍,再写对应规则,效率最高。
3.4 建库建表和to_sql入库
入库前先在MySQL里把库和表建好。库名可以用douban_book,表名用book。字符集必须用utf8mb4,不然中文和特殊符号都可能变乱码。
CREATE DATABASE IF NOT EXISTS douban_book DEFAULT CHARACTER SET utf8mb4; USE douban_book; CREATE TABLE IF NOT EXISTS book ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), publish_year VARCHAR(20), rating DECIMAL(3, 1), rating_num INT, intro VARCHAR(500) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;publish_year这里我故意用了VARCHAR,因为有些书籍的出版年份是“2006-5”这种带月份的格式,存成日期类型反而麻烦。评分用DECIMAL(3,1)可以存9.9这种一位小数,评价人数用INT。
Python写库用pandas.to_sql最省事:
from sqlalchemy import create_engine engine = create_engine( "mysql+pymysql://root:password@localhost:3306/douban_book?charset=utf8mb4" ) df[["title", "author", "publisher", "publish_year", "rating", "people", "intro"]].to_sql( "book", engine, if_exists="append", index=False )pymysql后面那段charset=utf8mb4是必须的,少写它,到Java那边查出来的中文大概率就是问号。if_exists="append"表示追加数据,重复执行清洗入库脚本就会产生重复数据,我在项目里一般会在脚本开头先执行一个DELETE FROM book,保证每次跑都是干净的全量数据。
到这里,数据链路的前半段就走通了,从网页变成了数据库里的250条记录。接下来进入Java后端环节。
4. Java后端开发:从数据库到JSON接口
4.1 为什么这个项目要用Java后端
在第1.2节我已经说了一半,这里再展开一下。很多教程的习惯做法是Python一条龙:爬虫爬到数据,Flask直接返回JSON,前端ECharts渲染。这样做当然快,但你要想清楚一个问题:这个项目如果是为了学习、面试或者数据库课程设计,后端用Java能让你的技术面看起来宽很多。
Java后端对应的是企业里最标准的服务端开发模式。Controller、Service、Mapper三层一拆,前端要什么数据就调什么接口,加一个接口、改一个查询都是常规开发操作。面试官问起来,你可以从Spring容器讲到SQL映射,再讲到MVC请求流程,整个知识体系都能串起来。这就是为什么我坚持在项目里把Java后端作为一个独立环节,而不是用Python一把梭。
4.2 Spring Boot工程搭建与配置
工程创建我推荐用Spring Initializr,直接选上Web、MyBatis Framework、MySQL Driver三个依赖。版本就选当前最新稳定版,不要追RC版。
数据源配置写在application.yml里,注意时区和编码参数:
spring: datasource: url: jdbc:mysql://localhost:3306/douban_book?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: password driver-class-name: com.mysql.cj.jdbc.Driverurl里那串参数一个都不能少。characterEncoding=utf8保证读写中文不出乱码,serverTimezone=Asia/Shanghai是MySQL驱动8.x版本的强制要求,不加直接报错。
实体类Book对应数据库表的字段,加几个注解映射关系。字段类型上要注意,数据库里DECIMAL对应Java的BigDecimal,VARCHAR对应String,INT对应Integer,别把rating写成double,后面JSON精度会有问题。
4.3 Mapper层与常用接口SQL
按字段可用性,我设计了下面四类接口:
| 接口 | 说明 | SQL要点 |
|---|---|---|
| /api/books | 返回全部书籍 | SELECT * FROM book |
| /api/books/top10 | 评分最高的10本 | ORDER BY rating DESC LIMIT 10 |
| /api/stats/publisher | 出版社出书数量Top20 | GROUP BY publisher ORDER BY cnt DESC LIMIT 20 |
| /api/stats/year | 按年份统计图书数量 | SUBSTRING(publish_year, 1, 4) 分组 |
这些SQL都不复杂,考察的重点其实是分组统计和排序。比如年份统计用SUBSTRING取年份前四位做分组,就能把“2006-5”这种带月份的字段规整到2006这个年份下去。这在面试里也是个常见说辞:清洗阶段没把年份拆干净,但后端查询时做了一次补救。
4.4 Controller、跨域和统一返回结构
Controller层写起来比较机械,一个类对应一组接口,用@RestController注解:
@RestController @RequestMapping("/api") public class BookController { @Autowired private BookService bookService; @GetMapping("/books") public Result getBooks() { return Result.success(bookService.listAll()); } }我在项目里会加一个统一的Result包装类,返回结构固定成{code: 0, msg: "success", data: ...}。这个习惯非常重要,因为前端ECharts拿数据时,只需要统一从data字段里取,不需要关心每个接口的返回长什么样。很多人在联调时出现图表空白,就是返回结构不统一,前端解析逻辑写得乱七八糟。
跨域问题是必须处理的。前端页面如果直接用浏览器打开HTML文件,或者前端项目跑在8081端口的Node服务里,而Spring Boot跑在8080,就属于跨域请求。最简单的方式是加一个全局配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE"); } }新手最容易在这里踩坑:接口用Postman测得好好的,前端一调就报CORS错误,弹出来的字还是英文的Access to XMLHttpRequest has been blocked。看到这个报错别慌,十有八九就是后端没配跨域。
接口写完以后,可以先用Postman或者浏览器直接访问http://localhost:8080/api/books,确认返回的是合法的JSON,再进入前端可视化阶段。
5. ECharts数据可视化:从JSON到图表
5.1 前端页面结构和ECharts引入
可视化部分我用的最简单的方式:一个HTML文件,引入ECharts的CDN,几个div容器分别放柱状图、饼图、折线图和排名表格。不引入Vue或React,因为项目核心在数据链路,前端框架不要喧宾夺主。
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>豆瓣读书Top250数据可视化</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="bar" style="width: 600px; height: 400px;"></div> <div id="pie" style="width: 600px; height: 400px;"></div> <div id="line" style="width: 600px; height: 400px;"></div> </body> </html>5.2 柱状图:评分最高的10本书
柱状图的场景很直接,展示Top10书籍的评分排名。第一步fetch后端接口,第二步把返回的data数组加工成ECharts需要的xAxis数据和series数据。
const chartBar = echarts.init(document.getElementById("bar")); async function loadTop10() { const res = await fetch("http://localhost:8080/api/books/top10"); const data = await res.json(); const names = data.data.map(item => item.title); const ratings = data.data.map(item => item.rating); chartBar.setOption({ title: { text: "豆瓣读书Top10评分" }, tooltip: {}, xAxis: { data: names, axisLabel: { rotate: 30 } }, yAxis: {}, series: [{ type: "bar", data: ratings }] }); }这里有两个要点。第一,书名太长时x轴标签会挤成一团,所以要设置axisLabel的rotate旋转30度,或者用formatter截断文字。第二,series里没有设置name时,tooltip的悬浮提示会显示得很简陋,建议给series加上name: "评分"。
5.3 饼图:出版社出书数量Top10
饼图对应的接口是/api/stats/publisher。ECharts饼图的data结构是[{name: "出版社", value: 数量}],需要把后端返回的字段名映射一下。
const res = await fetch("http://localhost:8080/api/stats/publisher"); const json = await res.json(); const pieData = json.data.slice(0, 10).map(item => ({ name: item.publisher, value: item.cnt })); chartPie.setOption({ title: { text: "出版社出书数量Top10" }, tooltip: { trigger: "item" }, legend: { type: "scroll" }, series: [{ type: "pie", radius: ["30%", "70%"], data: pieData }] });这里我特意用了半径数组["30%", "70%"],也就是做成了环形图,比普通饼图更耐看。legend用scroll类型,出版社名字长的时候可以滚动展示,这是做ECharts饼图时最常查的一个点。
如果你想把饼图改成3D效果,ECharts官方目前没有原生3D饼图,需要额外引入echarts-gl,但250条数据做一个3D效果的饼图意义不大,我个人建议老老实实用环形图,信息传达更准确,也不用多维护一个依赖库。
5.4 折线图:出版年份分布
折线图接口来自/api/stats/year。后端返回的是按年份聚合后的计数,前端直接映射成x轴和y轴即可。
const res = await fetch("http://localhost:8080/api/stats/year"); const json = await res.json(); const years = json.data.map(item => item.year); const counts = json.data.map(item => item.cnt); chartLine.setOption({ title: { text: "图书出版年份分布" }, tooltip: { trigger: "axis" }, xAxis: { type: "category", data: years }, yAxis: { type: "value" }, series: [{ type: "line", data: counts, smooth: true }] });年份分布折线图是最能反映Top250这份书单历史跨度的一张图,能看到哪些年代的好书被收录得最多。平滑曲线用smooth: true,视觉上更清爽。
前端有几个细节需要反复强调:所有图表渲染前要拿到数据再setOption,所以fetch必须在setOption之前完成,async/await的顺序别写反;如果接口数据量大,可以只在页面加载完成后请求一次,不要做成轮询;每个图表div要有固定宽高,不然ECharts初始化时拿到的容器尺寸是0,图表直接白屏。
5.5 表格展示与整体页面
除了图表,我通常还会在页面右侧放一个完整书单表格,直接用原生HTML表格渲染后端返回的250条数据。不引入UI框架,因为表格的功能就是简单展示书名、作者、评分、评价人数,让用户能翻一翻完整榜单。图表给宏观趋势,表格给微观明细,两者搭配起来,整个可视化页面才完整。
这个环节做完以后,你从浏览器里能看到自己爬下来、洗好、入库、再经Java接口吐出来的数据,变成一张张能交互的图表,那种成就感比单纯跑通一段爬虫代码强得多。
6. 联调问题、优化方向与经验复盘
6.1 全链路最容易断的环节
项目做完以后,我复盘了一下最容易掉链子的几个环节。
第一是字符集。Python侧的requests解析、MySQL的表结构utf8mb4、pymysql连接串的charset参数、Spring Boot的characterEncoding、前端HTML的charset,这五个地方只要有一个没设置对,中文可能就在某一环变成乱码。排查乱码问题要从数据源头开始,一个环节一个环节验证,别一头扎进前端找问题。
第二是字段类型。评价人数在Python里转成了int,数据库里是INT,Java里是Integer,前端拿到就是数字,整个过程要一致。中间任何一环用了字符串,ECharts的数值轴都会出问题。
第三是跨域。很多人把可视化页面放着不动,然后疯狂刷新页面,其实问题不出在前端,而是后端没有配置CORS。遇到Cross-Origin Request Blocked先查后端。
6.2 常见问题速查
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 爬虫返回403 | 缺少User-Agent | headers加上浏览器UA |
| 爬虫返回乱码 | 响应编码识别错误 | resp.encoding = "utf-8" |
| to_sql报ModuleNotFoundError | 没装pymysql或sqlalchemy | pip install pymysql sqlalchemy |
| 数据库中文全是问号 | 连接串或表字符集不对 | 统一用utf8mb4 |
| Java接口访问404 | 启动类扫描不到Controller | 检查包结构和@SpringBootApplication位置 |
| 前端请求报CORS | 后端未配置跨域 | 加CorsConfig或@CrossOrigin |
| 图表初始化后一直空白 | div没有宽高或数据没回来就init | 设置容器宽高,fetch后再init |
| ECharts数据对应不上 | 后端返回结构和前端解析逻辑不匹配 | 统一Result包装,固定取data字段 |
这个表格基本覆盖了这个项目从爬虫到可视化九成以上的常见问题。我把它们贴出来,就是为了让大家遇到问题时先对号入座,不要瞎排查。
6.3 可以继续扩展的方向
这套项目做完后,往上扩展的空间很大。比如把爬虫改成定时任务,每周自动抓取一次,用Spring的@Scheduled或者Linux的crontab都行;再比如给后端接口加Redis缓存,第一次查询时从MySQL拿数据写缓存,后续都走缓存,这个点在面试里能聊很久;还可以给前端加筛选条件,按出版社、年份、评分区间做联动过滤,让250条数据真正“可交互”起来。
如果你愿意把爬虫部分做得更深入,还可以研究分布式爬虫。但我要泼一盆冷水:豆瓣读书Top250这个体量,分布式完全是杀鸡用牛刀。分布式爬虫适合的是千万级URL的站点,有没有必要还是要看数据规模,这也是我在爬虫部分反复强调的判断标准:根据任务量选择工具,而不是为了秀技术选工具。
6.4 我的实操体会
这个项目最大的价值不是某一个技术点,而是让你把一个真实的数据项目完整地跑通一遍。从一百多行Python代码爬到数据,到清洗成规整的表格,到MySQL里能查到,到Java接口返回JSON,再到浏览器里渲染出图表,每一环都不是高科技,但环环相扣的感觉是看教程体会不到的。
我建议所有做这个项目的人,都强迫自己把每个环节都亲手写一遍,不要复制粘贴。尤其是Java后端那一块,很多人网上找段代码粘贴上去能跑,但面试官一问Controller、Service、Mapper的关系就卡壳,项目就白做了。
最后再分享一个小技巧:整个项目跑通以后,把每一步的验证方式都写成文字记录下来。比如爬虫完成后打印行数、数据清洗后打印字段类型、入库后执行一条SELECT确认记录数、接口返回后用浏览器看JSON、前端渲染后看图表。这套验证习惯放到任何数据项目里都通用,会帮你省掉大量联调排错的时间。