
做这个豆瓣读书 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第一页不带参数。翻到第二页可以看到地址变成?start25第三页是?start50以此类推。每页固定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 fhttps://book.douban.com/top250?start{start} resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text这里有几个细节值得说。timeout10是必须的没有超时设置时如果网络卡住脚本会一直挂在那里。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到MySQL3.1 先看数据结构再动手爬完的数据是一堆字典组成的列表。我习惯先快速转成DataFrame看一眼类型和缺失情况不要急着写清洗代码。import pandas as pd df pd.DataFrame(all_books) print(df.shape) print(df.dtypes) print(df.isnull().sum())250本书7个字段dtypes里全是objectisnull()能看到简介里有缺失值。这一步的价值在于让你对整个数据底座心里有数哪些字段要转数字、哪个字段有缺失、哪个字段文本里带杂质全都在这一眼里。不要跳过这个检查直接开洗不然洗到一半发现某个字段结构跟预期不一样回头改代码更痛苦。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;publish_year这里我故意用了VARCHAR因为有些书籍的出版年份是“2006-5”这种带月份的格式存成日期类型反而麻烦。评分用DECIMAL(3,1)可以存9.9这种一位小数评价人数用INT。Python写库用pandas.to_sql最省事from sqlalchemy import create_engine engine create_engine( mysqlpymysql://root:passwordlocalhost:3306/douban_book?charsetutf8mb4 ) df[[title, author, publisher, publish_year, rating, people, intro]].to_sql( book, engine, if_existsappend, indexFalse )pymysql后面那段charsetutf8mb4是必须的少写它到Java那边查出来的中文大概率就是问号。if_existsappend表示追加数据重复执行清洗入库脚本就会产生重复数据我在项目里一般会在脚本开头先执行一个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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: password driver-class-name: com.mysql.cj.jdbc.Driverurl里那串参数一个都不能少。characterEncodingutf8保证读写中文不出乱码serverTimezoneAsia/Shanghai是MySQL驱动8.x版本的强制要求不加直接报错。实体类Book对应数据库表的字段加几个注解映射关系。字段类型上要注意数据库里DECIMAL对应Java的BigDecimalVARCHAR对应StringINT对应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出版社出书数量Top20GROUP 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 langzh head meta charsetUTF-8 title豆瓣读书Top250数据可视化/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script /head body div idbar stylewidth: 600px; height: 400px;/div div idpie stylewidth: 600px; height: 400px;/div div idline stylewidth: 600px; height: 400px;/div /body /html5.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数据库里是INTJava里是Integer前端拿到就是数字整个过程要一致。中间任何一环用了字符串ECharts的数值轴都会出问题。第三是跨域。很多人把可视化页面放着不动然后疯狂刷新页面其实问题不出在前端而是后端没有配置CORS。遇到Cross-Origin Request Blocked先查后端。6.2 常见问题速查现象可能原因解决办法爬虫返回403缺少User-Agentheaders加上浏览器UA爬虫返回乱码响应编码识别错误resp.encoding utf-8to_sql报ModuleNotFoundError没装pymysql或sqlalchemypip install pymysql sqlalchemy数据库中文全是问号连接串或表字符集不对统一用utf8mb4Java接口访问404启动类扫描不到Controller检查包结构和SpringBootApplication位置前端请求报CORS后端未配置跨域加CorsConfig或CrossOrigin图表初始化后一直空白div没有宽高或数据没回来就init设置容器宽高fetch后再initECharts数据对应不上后端返回结构和前端解析逻辑不匹配统一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、前端渲染后看图表。这套验证习惯放到任何数据项目里都通用会帮你省掉大量联调排错的时间。