ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

爬虫到可视化全链路:数据清洗、MySQL存储与Echarts大屏实战

爬虫到可视化全链路:数据清洗、MySQL存储与Echarts大屏实战 从爬虫到可视化的全链路这篇把第二阶段的硬骨头啃完先说清楚这篇解决什么问题。上一篇写完了豆瓣读书Top250的爬虫部分重点讲了requests解析、xpath选节点、校验码隐藏坑这些事。这一篇我直接把后半段拆开讲透爬下来的脏数据怎么清洗成结构化字段、怎么设计MySQL表把250本书存进去、Java后端怎么写统计接口最后怎么用Echarts把这些数据变成能直接演示的可视化大屏。整个链路下来你得到的不只是一套代码而是一个能当课程设计、毕业设计、甚至面试演示项目的完整作品。适合谁来参考正在做数据库课程设计的学生、想练手PythonJava全栈衔接的开发者、或者是想给简历塞一个有完整故事线的项目的求职者。不需要你有多深的基础但至少要懂一点Python语法、会装MySQL、知道Spring Boot是什么后面跟着操作就能跑通。这篇的代码我会给到关键实现涉及完整工程结构的部分也会把思路讲明白不会藏私。1. 整体链路设计与技术选型为什么是这套组合1.1 项目整体流程拆解很多人做爬虫项目爬到数据就算完事了。但这套项目不一样它的核心是“数据链路”。从爬虫拿到HTML原始页面到前端浏览器里渲染出漂亮的图表中间要经过四个环节Python爬虫采集这一步产出原始的HTML文本和初步解析出的书籍字段。数据清洗与规整把“、作者xxx”这种混杂字符串拆成干净字段把“336088人评价”转成数字。数据库存储把清洗后的250条记录写入MySQL建立索引方便查询。Java后端接口开发用Spring Boot提供REST API前端拿JSON数据渲染图表。为什么要用两门语言来做Python在爬虫和数据清洗上确实效率高pandas处理表格数据一套又一套。但到了后端接口层面Java的Spring Boot生态更成熟分页、事务、连接池这些开箱即用而且很多学校的课程设计都要求Java。这套项目等于一次把两个热门方向都覆盖了练手的性价比非常高。1.2 每个环节的技术选型理由爬虫部分用的还是requestsBeautifulSoup这个组合对静态页面足够用。豆瓣Top250信息都在HTML源码里不存在异步加载所以不引入scrapy反而更轻量。有人问要不要用selenium真没必要——页面不动态渲染反而白白增加资源消耗。数据清洗选pandas而不是纯Python手写循环是因为清洗规则涉及到字符串分割、类型转换、空值填充pandas的向量化操作写起来快、可读性高而且后面如果要扩展到5000本书也不怕性能扛不住。数据库选MySQL而不是SQLite单纯因为课程设计和面试场景下MySQL是标配。表结构设计上我预留了主键id、书籍唯一标识方便后面扩展或去重。Java后端用Spring Boot 2.xMyBatis-Plus前者是当前主流框架后者帮我们省了大量写SQL的时间。可视化部分直接用Echarts CDN方式引入不搞前端工程化那套一个HTML页面搞定方便演示。1.3 这套方案的全局视野单独每一个环节都不算难难的是把它们串起来。我收到过不少读者的私信卡在“爬虫数据存不进MySQL”“Java读不出中文数据”“Echarts图表不显示”这些点上。其实大部分问题都出在数据格式和编码约定上Python写入端用什么字符集、Java读取端就要对应什么字符集数据库字段设计成什么类型、JSON输出就要能对应上。所以我在写这一篇的时候特意把“衔接”作为重点编码统一UTF-8、金额和评分统一成浮点、日期统一成字符串这样每个环节之间的接口清晰不会数据打架。2. 数据清洗实战把脏文本变成干净字段2.1 豆瓣Top250原始数据到底长什么样先说结论爬虫拿到的原始字段是非常杂乱的。以豆瓣读书Top250为例每一本书的信息在HTML里的结构大概是这样的书名a title活着活着/a作者信息[中] 余华 著 / 作家出版社 / 2012-08 / 20.00元评分9.4评分人数367814人评价简介人如何去承受巨大的苦难就像中国的一句成语千钧一发。注意这这里有一个绝对的坑作者信息那一段“出版社、出版年份、价格”全挤在一起中间用/分隔。如果你直接把这个字符串存进数据库后面做“各年份出版书籍分布”“出版社Top10排行”就完全没法做因为数据库里是一个无法拆分的整体。2.2 清洗规则设计一段代码讲明白清洗的核心逻辑就六个字拆、转、规、去、填、验。拆是拆分混合字段转是把字符串转成数值规是统一文本格式去是去重和去空白填是处理空值验是清洗后人工抽查。拆字段用pandas的str.split配合expand参数import pandas as pd import re # 假设df是从爬虫列表构造出的DataFrame # df.columns [title, author_info, rating, rating_num, quote, introduction] df[author_info] df[author_info].str.strip() # 拆分author_info按 / 分割 author_split df[author_info].str.split( / , expandTrue) # 多数情况会拆成3~6列取决于有没有译者、丛书名等 print(author_split.shape) # (250, 至少3列) # 统一处理第1列 第2列拼成作者信息 df[author] author_split[0].fillna() df[translator] author_split[1].fillna() if author_split.shape[1] 1 else 这里就要做分支判断。有的书像《百年孤独》的作者字段是[哥伦比亚] 加西亚·马尔克斯 著 / 范晔 译 / 南海出版公司 / 2011-06 / 39.50元那就存在译者。有的书没有译者第二列直接就是出版社。所以不能死板地固定列数得用正则判断def split_author_info(s): parts s.split( / ) if len(parts) 3: author, press, year_price parts[0], parts[1], parts[2] translator elif len(parts) 4: author parts[0] translator parts[1] if 译 in parts[1] else press parts[-2] year_price parts[-1] else: author, translator, press, year_price parts[0], , , return author, translator, press, year_price这个函数把作者、译者、出版社提取出来year_price仍然是2011-06 / 39.50元这种格式再用一个正则把年份和价格分别抓出来def split_year_price(s): # 例2003-06 / 32.00元 m re.search(r(\d{4})-(\d{2}), s) year m.group(1) if m else price re.search(r([\d.])元, s) price_val price.group(1) if price else return year, price_val评分人数也要处理爬下来的值是367814人评价直接转int会报错df[rating_num] df[rating_num].str.replace(人评价, ).astype(int)这一整套清洗流程跑完数据就从原来的5个原始字段变成了9个干净字段title、author、translator、press、year、price、rating、rating_num、introduction。注意清洗过程中一定要保留原始数据一份清洗后的另存一个文件。我在实践中见过不止一次因为清洗规则出差错导致整表数据崩溃的情况保留原始备份相当于给自己留了后悔药。2.3 清洗结果的验证与导出清洗完之后不能直接入库先做两层验证。第一层看数值范围rating应该在0~10之间rating_num大于0year在1900~2025之间。第二层抽样打印前10条记录肉眼检查作者和出版社是否错位。验证通过之后导出成一份整洁的CSV或JSON方便后面写入数据库df_clean df[[title, author, translator, press, year, price, rating, rating_num, introduction, quote]] df_clean.to_csv(douban_top250_clean.csv, indexFalse, encodingutf-8-sig)这里有个细节导出用utf-8-sig而不是utf-8是为了防止Excel打开CSV出现中文乱码。以后你不管做什么数据项目往Windows生态传文件utf-8-sig都比utf-8安全。3. 数据库设计250本书也得按规范建表3.1 表结构设计思路有人觉得就250条数据随便建个表都行。这话不对。课设答辩老师会问你的第一句话就是为什么这么设计表如果你回答“数据少无所谓”那印象分直接减半。我当时设计表的时候遵循了三个原则每一本书对应一行记录用主键id标识未来爬虫更新数据时通过书名作者做去重。字段类型要合理price使用DECIMAL(10,2)而不是VARCHARrating使用FLOATrating_num使用INT这样才能在Java后端做排序、聚合操作。预留冗余字段introduction和quote是文本型设置成TEXT而不是VARCHAR(255)因为豆瓣的简介经常超过255个字符。具体的建表SQL如下CREATE DATABASE IF NOT EXISTS douban_books DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE douban_books; CREATE TABLE IF NOT EXISTS book ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL COMMENT 书名, author VARCHAR(255) DEFAULT COMMENT 作者, translator VARCHAR(255) DEFAULT COMMENT 译者, press VARCHAR(255) DEFAULT COMMENT 出版社, year VARCHAR(10) DEFAULT COMMENT 出版年份, price DECIMAL(10,2) DEFAULT 0.00 COMMENT 定价, rating FLOAT DEFAULT 0 COMMENT 豆瓣评分, rating_num INT DEFAULT 0 COMMENT 评分人数, introduction TEXT COMMENT 内容简介, quote TEXT COMMENT 经典语录/短评, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, UNIQUE KEY uk_title_author (title, author) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT豆瓣读书Top250书籍表;看名字就知道我把数据库名定为douban_books表名为book。字符集选utf8mb4而不是utf8是因为豆瓣的一些书名和简介里可能带有特殊字符utf8mb4是MySQL中支持完整Unicode的字符集能兼容生僻字和emoji问题少。UNIQUE KEY uk_title_author (title, author)是防重用的同一个书名和作者不会重复入库。这个唯一索引还有一个隐藏好处后面Java后端如果需要支持“收藏”或“标记阅读”功能可以直接引用这本书的id不用担心突然重复两行。3.2 Java后端和MySQL的版本兼容注意事项MySQL建议用5.7或8.0Java连接驱动用对应的mysql-connector-java版本。如果你用的是Spring Boot 2.5默认会引入8.0.x的驱动这一点基本不用管。但如果你是手动配置数据源记得在JDBC连接串上加上jdbc:mysql://localhost:3306/douban_books?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai是必须要加的不然MySQL 8.x会强制要求设置时区allowPublicKeyRetrievaltrue也是8.x的老问题不加会报Public Key Retrieval not allowed的异常。这两个参数是新手最容易踩的坑。3.3 Python批量写库避免一条一条insert的低效做法清洗后的数据怎么进MySQL最蠢的办法是写个for循环每本书一条INSERT。250条跑起来没感觉但如果是25000本这个方式会慢到让你怀疑人生而且事务日志会很大。推荐的做法是用pandas的to_sql或者用pymysql的executemany执行批量插入。我用的是pymysql方式因为它不依赖SQLAlchemy依赖更少import pymysql conn pymysql.connect( hostlocalhost, userroot, password你的密码, databasedouban_books, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) sql INSERT INTO book (title, author, translator, press, year, price, rating, rating_num, introduction, quote) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE pressVALUES(press), ratingVALUES(rating), rating_numVALUES(rating_num), introductionVALUES(introduction) data list(df_clean.itertuples(indexFalse, nameNone)) cursor conn.cursor() cursor.executemany(sql, data) conn.commit() cursor.close() conn.close()ON DUPLICATE KEY UPDATE是配合刚才那张表的唯一索引用的意思是如果书名作者重复了就更新评分、简介这些可能变化的字段。这样爬虫后续多次执行也不会产生重复数据表里始终是最新的250条。实操心得插入之前先查一下df_clean的列顺序是否和SQL里的占位符一一对应。这个错误几乎每个人都犯过顺序一错数据全错位排查起来非常浪费时间。我的习惯是写库前先打印df_clean.columns和SQL语句里的字段列表放在一起对照一遍。4. Java后端开发提供数据接口的核心实现4.1 Spring Boot工程搭建与初始配置数据库有了数据接下来就是用Java把数据暴露成HTTP接口。打开IDEA创建一个Spring Boot项目Java版本选8或11都行依赖勾选Spring Web、MyBatis-Plus、MySQL Driver。工程结构方面我习惯这样分包com.example.douban ├── controller │ └── BookController.java ├── entity │ └── Book.java ├── mapper │ └── BookMapper.java ├── service │ ├── BookService.java │ └── impl │ └── BookServiceImpl.java └── config └── CorsConfig.javaapplication.yml里关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/douban_books?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case: true这个配置很关键它能把数据库字段rating_num自动映射成Java实体类里的ratingNum。Java那边字段命名用驼峰数据库用下划线靠这个配置就不用一条条写ResultMap了。4.2 实体类设计与通用CRUD实体类对应数据库的book表Data TableName(book) public class Book { TableId(type IdType.AUTO) private Integer id; private String title; private String author; private String translator; private String press; private String year; private BigDecimal price; private Float rating; private Integer ratingNum; private String introduction; private String quote; }用MyBatis-Plus的话Mapper接口非常简单Mapper public interface BookMapper extends BaseMapperBook { }单表的增删改查、分页查询都是现成的。这里真正有难度的是写统计类SQL。比如“各评分区间书籍数量分布”MyBatis-Plus的LambdaQueryWrapper写起来有点绕我选择直接用Select注解写原生SQLpublic interface BookMapper extends BaseMapperBook { Select(SELECT FLOOR(rating) AS rating_group, COUNT(*) AS cnt FROM book GROUP BY rating_group ORDER BY rating_group) ListMapString, Object selectRatingDistribution(); Select(SELECT year, COUNT(*) AS cnt FROM book GROUP BY year ORDER BY cnt DESC) ListMapString, Object selectYearDistribution(); Select(SELECT press, COUNT(*) AS cnt FROM book GROUP BY press ORDER BY cnt DESC LIMIT 10) ListMapString, Object selectPressTop10(); Select(SELECT title, rating, rating_num FROM book ORDER BY rating_num DESC LIMIT 10) ListMapString, Object selectHotBooks(); }FLOOR(rating)会把9.4变成9、8.7变成8这样就能统计出评分为8的有几本、9的有几本。这个思路很实用有些同行是用CASE WHEN分区间我嫌麻烦直接FLOOR天然形成整数分组。4.3 Service层和Controller层让接口更规范Service层不要直接在Controller里写业务逻辑。好的习惯是Controller只管接参数、调Service、返回结果这样代码易读也方便写单元测试。Service public class BookServiceImpl implements BookService { Autowired private BookMapper bookMapper; Override public ListBook getBooksByPage(int page, int size) { PageBook pageInfo new Page(page, size); PageBook result bookMapper.selectPage(pageInfo, null); return result.getRecords(); } Override public ListMapString, Object getRatingDistribution() { return bookMapper.selectRatingDistribution(); } Override public ListMapString, Object getYearDistribution() { return bookMapper.selectYearDistribution(); } Override public ListMapString, Object getPressTop10() { return bookMapper.selectPressTop10(); } Override public ListMapString, Object getHotBooks() { return bookMapper.selectHotBooks(); } }Controller层返回的是一手数据给前端的是标准JSON格式。我这里加了一个通用返回体ApiResult把每一段状态码和消息都包进去前端拿到之后可以做统一处理RestController RequestMapping(/api/books) public class BookController { Autowired private BookService bookService; GetMapping(/page) public ApiResult getBooksByPage(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { return ApiResult.success(bookService.getBooksByPage(page, size)); } GetMapping(/rating-distribution) public ApiResult getRatingDistribution() { return ApiResult.success(bookService.getRatingDistribution()); } GetMapping(/year-distribution) public ApiResult getYearDistribution() { return ApiResult.success(bookService.getYearDistribution()); } GetMapping(/press-top10) public ApiResult getPressTop10() { return ApiResult.success(bookService.getPressTop10()); } GetMapping(/hot-books) public ApiResult getHotBooks() { return ApiResult.success(bookService.getHotBooks()); } }看到一个约束没有不要把所有东西都返回给前端比如introduction和quote这种大字段在列表接口里头可以保留但在统计接口中用Map直接返回聚合结果不会把整本书的记录全端出来。4.4 跨域问题与返回格式的一个坑坑出现在你单独打开前端HTML文件调试的时候。由于前端是file://协议打开的或者部署在8081端口而后端跑在8080端口浏览器会触发跨域。解决方式最简单的就是后端加一个全局CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }另一个坑如果你的接口返回的Map里有BigDecimal或FloatJava的JSON序列化会把9.0序列化成9Echarts显示饼图和柱状图的label就少了一个小数点。如果你特别介意在实体类的字段上加上JsonSerialize(using ToStringSerializer.class)或者直接在前端处理时判断一下是整数就补一个.toFixed(1)。5. Echarts数据可视化后端一响图表登场5.1 前端页面总体思路可视化部分我只用一个HTML页面引入Echarts的CDN然后通过fetch调用Java后端的接口。整体布局是一个顶部标题栏下面放四个图表评分分布柱状图、出版年份分布折线图、出版社Top10条形图、热门书籍评分人数饼图。这样做的好处是演示起来有视觉冲击力答辩或者汇报时不用临时切好几个页面一个页面由数据驱动刷新即可。页面的HTML骨架大家都会写我不贴全部代码重点讲关键部分的实现。5.2 数据对接与图表配置以评分分布为例评分分布柱状图拿到的是后端返回的JSON数组[ {rating_group: 8, cnt: 29}, {rating_group: 9, cnt: 221} ]前端只需要把这个数组拆成x轴和y轴对应的数组async function loadRatingDistribution() { const res await fetch(http://localhost:8080/api/books/rating-distribution); const json await res.json(); const data json.data; const xAxisData data.map(item item.rating_group 分); const seriesData data.map(item item.cnt); const chart echarts.init(document.getElementById(ratingChart)); chart.setOption({ tooltip: {}, xAxis: { type: category, data: xAxisData }, yAxis: { type: value, name: 书籍数量 }, series: [{ type: bar, data: seriesData, itemStyle: { color: #b37feb }, label: { show: true, position: top } }] }); } loadRatingDistribution();有一个细节Echarts的图表需要在一个有明确宽高的容器里初始化。如果没有给div设置height图表永远渲染不出来。我在这上面卡了半小时所以提醒大家div idratingChart stylewidth: 100%; height: 400px;/div5.3 剩下的三张图配置思路年份分布折线图const res await fetch(http://localhost:8080/api/books/year-distribution); const json await res.json(); const data json.data.sort((a, b) a.year - b.year); option { xAxis: { type: category, data: data.map(item item.year) }, yAxis: { type: value }, series: [{ type: line, data: data.map(item item.cnt), smooth: true }] };出版社Top10条形图把x轴和y轴颠倒过来书籍名字太长也不怕data.sort((a, b) b.cnt - a.cnt); option { xAxis: { type: value, name: 出版数量 }, yAxis: { type: category, data: data.slice(0, 10).map(item item.press) }, series: [{ type: bar, data: data.slice(0, 10).map(item item.cnt), itemStyle: { color: #5ab1ef }, label: { show: true, position: right } }] };热门书籍评分人数用饼图更直观option { tooltip: { trigger: item, formatter: {b}: {c}人 ({d}%) }, series: [{ type: pie, radius: [30%, 70%], // 环形饼图 data: data.map(item ({ name: item.title, value: item.rating_num })) }] };radius: [30%, 70%]就是做环形的写法内半径30%、外半径70%比实心饼图颜值高很多。评分人数差距大会出现小扇形几乎看不见的情况可以设置minAngle: 5让每个扇区至少有几度的显示范围。5.4 前后端联调时的几个现实问题联调阶段最容易遇到的问题有三类。一类是接口返回JSON里中文乱码。Spring Boot默认已经是UTF-8编码但如果你在返回String时直接往JSON字符串拼接中文可能乱码。解决办法是不要手动拼接JSON统一返回对象让Jackson序列化。一类是接口路径404。检查Controller类上有没有RequestMapping(/api/books)方法上有没有GetMapping(/xxx)以及前端fetch拼的URL是不是完全一致。一个斜杠对不上就404。一类是后端启动时报MySQL连接失败。先本地命令行测试mysql -u root -p能不能连上然后再看配置的密码是否一致。数据库没启动就去服务里把MySQL服务开起来Windows用户经常忘这一步。6. 完整跑通流程与问题排查速查6.1 从零开始需要跑的七个步骤到这里整套链路已经完整了。我按照实际操作顺序整理一遍方便你照着走启动MySQL服务执行建库建表SQL。在Python环境里安装pandas、pymysql、requests、beautifulsoup4。运行爬虫脚本爬取250条数据并保存到CSV。运行清洗脚本输出douban_top250_clean.csv。运行写库脚本把清洗后的CSV插入book表可以用SELECT COUNT(*) FROM book;验证是250条。启动Spring Boot工程浏览器访问http://localhost:8080/api/books/page查看JSON是否正常返回。打开可视化HTML页面确认四张图表都能正常渲染。如果每一步都通了整个项目也就落地了。以后想更新数据只要重新跑一次爬虫清洗写库脚本再刷新页面就行。6.2 常见报错与排查方案速查表报错关键字原因分析解决方案Unknown database douban_books数据库没有创建执行建库语句CREATE DATABASE douban_booksPublic Key Retrieval is not allowedMySQL 8.x安全机制连接串加allowPublicKeyRetrievaltruejava.sql.SQLException: The server time zone value时区未设置连接串加serverTimezoneAsia/Shanghaipymysql.err.DataError: Data too long for column某个字段长度超了把对应字段改成TEXT类型UnicodeDecodeError编码不一致爬虫拿到响应后先resp.encodingutf-8图表不显示容器高度为0或路径不对给div设置heightF12看Console报错接口报500后端SQL错误或空指针看IDEA控制台异常堆栈逐行定位数据全是null实体类字段和数据库列名不匹配开启map-underscore-to-camel-case或加TableField6.3 关于这套项目的一些真实经验整套项目做完之后我最大的体会是数据链路的“约定”比代码本身更重要。爬虫端如果不去清洗Java后端写的所有统计SQL都无从谈起数据库字段如果命名不规范MyBatis-Plus映射就会出问题后端返回的字段如果不是稳定的JSON结构前端的Echarts图表就没法绑数据。这不只是一个课程设计而是一条完整的生产流水线虽然体量小但五脏俱全。扩展空间也很大爬虫可以增加代理池和并发抓取数据库可以做分页和缓存Java后端可以加权限验证前端可以做成大屏。甚至还可以用Spark对豆瓣Top250的历史版本做趋势分析使用向量数据库做语义搜索这些话题我以后也会逐个展开。最后说一个实操中的小技巧把爬虫、清洗、写库三个Python脚本合并成一个run.py用三行代码依次调用。这样每次更新数据只需要执行一次演示的时候也会更流畅。Java后端启动之后不用再动直接刷新页面就能看到最新数据这一整套“一条命令更新数据”的感觉确实很有成就感。
返回列表