☰
基于Python爬虫与MySQL的网络小说数据分析系统
2026/10/13 19:25:42 网站建设 项目流程

简介:一份基于Python爬虫的网络小说数据分析系统毕业设计完整方案,涵盖前台展示与管理后台两大部分,前台可查看作者作品、分类占比、小说名称与分类统计,后台支持用户管理、公告发布等操作,适合计算机相关专业学生用于课程设计或毕业设计参考,也适合想学习爬虫、数据分析与可视化技术链路的开发者。压缩包共415个文件,总大小约16.91MB,核心文件类型包括Python源码、Vue页面、JavaScript脚本、MySQL数据库脚本以及开发文档与论文等,并附带一键安装、初始化数据库和运行等批处理脚本,部署门槛较低。目前已有122人浏览学习,可快速上手。内容提供完整源码、数据库备份、说明文档与论文,系统已测试可运行。通过该项目可以掌握爬虫抓取、数据处理与可视化展示的完整技术链路,是一套实用且完整的毕业设计参考资料。

1. 这个毕设题在做什么:爬虫、MySQL 与数据分析的一次完整闭环

基于Python爬虫的网络小说数据分析系统,真正要交付的不是一堆抓下来的页面,而是一条「采集—存储—分析—展示」都能讲清楚的数据链路。用 Python 爬虫把小说网站的公开列表、简介和点击量抓下来,清洗后写进 MySQL,再通过自带的前后端页面把热门分类、更新趋势、作者产出呈现出来。这个方案能解决「论文里的数据没有来源、没有说服力」的问题,适合毕业设计、课程设计,也适合想把 web 开发、数据库和采集串起来练一遍的入门者。反直觉的地方在于:最容易卡进度的往往不是爬虫,而是数据口径和前后端联调这类黑匣子。

2. 网络小说数据采集:用 requests + lxml 从目录页取数入库

2.1 为什么用 requests + lxml 而不是 Scrapy

Scrapy 功能很强,但一个毕设题通常只面向一两个小说站,要抓的量也就是几千本书,用 requests 串行请求加随机延时,跑一轮只要几分钟到十几分钟。Scrapy 的爬虫类、管道、中间件这些概念,答辩时如果没真正用到,反而会增加解释成本。常见做法是 requests 负责网络下载,lxml 负责 xpath 解析,pymysql 负责写库,三个库就能跑通最小闭环。

pip install requests lxml pymysql flask

这四个依赖分别承担下载、解析、入库和接口层。后面所有代码都基于这套组合,不需要再引入额外框架。如果以后要抓的站点增加到几十个,再考虑把解析逻辑拆成插件式也不迟。

2.2 跑通一个最小可用的目录页解析脚本

小说站最典型的结构是:目录页放一批书卡片,每张卡片里有书名、作者、简介、点击量;点进详情页才有完整章节目录。先抓目录页,把每本书的基础信息和详情页链接拿到,再决定要不要深入章节页。下面这段代码只做「目录页 → 结构化数据」这一件事。

import time import random import requests from lxml import etree HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36" } def fetch(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding return resp.text def parse_list(html): tree = etree.HTML(html) cards = tree.xpath('//div[contains(@class, "book-card")]') books = [] for card in cards: title = card.xpath('.//h3/a/text()') author = card.xpath('.//p[@class="author"]/text()') intro = card.xpath('.//p[@class="intro"]/text()') click = card.xpath('.//span[@class="click-count"]/text()') if not title or not author: continue books.append({ "title": title[0].strip(), "author": author[0].strip(), "intro": intro[0].strip() if intro else "", "click_text": click[0].strip() if click else "0", "url": card.xpath('.//h3/a/@href')[0] }) return books def main(): base_url = "https://example.com/list?page={}" for page in range(1, 3): html = fetch(base_url.format(page)) books = parse_list(html) print(f"page {page}: got {len(books)} books") time.sleep(random.uniform(1, 3)) if __name__ == "__main__": main()

这里有两个关键点。一是 xpath 中用text()取的是元素直接子文本,返回结果是列表,所以用title[0]取值之前必须先判空,否则某本书缺简介时整个脚本会抛 IndexError。二是resp.apparent_encoding会根据页面字节内容推断编码,很多小说站还在用 GBK,这行能省掉手动指定编码的麻烦。

random.uniform(1, 3)是串行请求之间 1 到 3 秒的随机延时,目的是把请求节奏打散,避免固定间隔被服务端识别。页面 URL 里的example.com是占位,实际抓取时换成目标站点目录页的地址即可。

2.3 清洗「12.5万」与入库事务边界

目录页拿到的点击量是文本,可能是3456,也可能是12.5万。如果直接写进 MySQL,排序时会按字符串字典序排,结果完全不是那么回事。清洗必须在入库前完成,常见做法是用正则把文本转成整数。

import re import pymysql def clean_click(text: str) -> int: text = text.strip() if not text: return 0 m = re.search(r"([\d.]+)\s*(万|亿)?", text) if not m: return 0 num = float(m.group(1)) if m.group(2) == "万": num *= 10000 elif m.group(2) == "亿": num *= 100000000 return int(num) def insert_books(conn, books): sql = ( "INSERT INTO books " "(source_url, title, author, category, intro, click_count, word_count, " " created_at, updated_at) " "VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())" ) data = [ (b["url"], b["title"], b["author"], b.get("category", ""), b["intro"], b["click_count"], b.get("word_count", 0)) for b in books ] conn.ping(reconnect=True) try: with conn.cursor() as cur: cur.executemany(sql, data) conn.commit() except Exception: conn.rollback() raise

executemany把多条记录一次提交,效率比逐条 execute 高一个量级。这段代码把整个批量插入包在事务里,commit之后再算成功,中途任何一个坏数据都触发rollback,不会出现插了一半的脏库。conn.ping(reconnect=True)是防止 MySQL 服务器超过 wait_timeout 后连接被断开,重跑长任务时最稳。清洗函数同时考虑了「万」和「亿」两种单位,翻页抓下来的数据直接进库,后面所有 SQL 都不用再做字符串转换。

3. MySQL 数据模型与查询口径:三张表支撑排行榜分析

数据抓下来之后,表结构决定后面所有接口的写法。第二个最容易翻车的地方就是「字段类型和口径没设计好」,等前端画图时再回头改库,成本很高。先把三张表建清楚。

3.1 三张核心表的结构与唯一索引

这张设计对单个毕设量级足够:books放书籍基础信息和统计指标,chapters放章节级数据,analysis_record放历史统计结果,便于画时间趋势图。

CREATE TABLE books ( id INT AUTO_INCREMENT PRIMARY KEY, source_url VARCHAR(255) NOT NULL, title VARCHAR(128) NOT NULL, author VARCHAR(64) NOT NULL, category VARCHAR(32) NOT NULL, intro TEXT, click_count INT NOT NULL DEFAULT 0, word_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_source_url (source_url) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE chapters ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, chapter_title VARCHAR(255) NOT NULL, content_length INT NOT NULL DEFAULT 0, crawl_time DATETIME NOT NULL, KEY idx_book_id (book_id), CONSTRAINT fk_chapters_book FOREIGN KEY (book_id) REFERENCES books(id) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE analysis_record ( id INT AUTO_INCREMENT PRIMARY KEY, metric_type VARCHAR(64) NOT NULL, metric_value DECIMAL(12,2) NOT NULL, stat_date DATE NOT NULL, KEY idx_metric_date (metric_type, stat_date) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

三张表里最值得说的是source_url的唯一索引。爬虫脚本会反复执行,每次全量抓取时,URL 是内容的天然指纹,唯一索引能挡住同一本书被插入两次。click_count和word_count用 INT 而不是 VARCHAR,是排行榜能正常ORDER BY的前提。analysis_record的metric_value用 DECIMAL,因为增长率和占比这类指标有小数,FLOAT 在精度上容易出问题。

建表语句末尾统一指定utf8mb4和utf8mb4_unicode_ci,这是中文内容站的基本配置。utf8mb4 比 utf8 多支持四字节字符,小说里偶尔会出现特殊符号或 emoji,用 utf8 建表在写入时可能直接报错。

3.2 用 SQL 算分类热度榜与更新趋势

数据入库后,最直接的分析需求是「哪个分类的书最火」「最近一周收录了多少新书」。这两个查询都简单,但口径要先定清楚。

SELECT category, COUNT(*) AS book_count, ROUND(AVG(click_count)) AS avg_click FROM books GROUP BY category ORDER BY avg_click DESC, book_count DESC LIMIT 10;

这个语句按分类聚合,算每类书的数量和平均点击量。ORDER BY avg_click DESC, book_count DESC的意思是先按平均点击排序,点击接近时再看书多的分类排前面。这里藏着一个常见的口径坑:如果建表时把分类存成逗号分隔的字符串,比如「玄幻,仙侠」,GROUP BY 会按整串分组,同一个分类被拆得七零八落,排行榜就废了。所以建表时 category 一定存单一值,一本书属于多分类的场景应该用关联表,而不是逗号拼接。

再看更新趋势,created_at是爬虫写入时间,可以直接当入库时间用。

SELECT DATE(created_at) AS d, COUNT(*) AS cnt FROM books WHERE created_at >= NOW() - INTERVAL 7 DAY GROUP BY DATE(created_at) ORDER BY d;

窗口时间用NOW() - INTERVAL 7 DAY写在 SQL 里,前端不用算日期。MySQL 排序在数字字段上完全可靠,但如果 click_count 是 VARCHAR,这个查询的排序结果会按字典序出来,比如 9999 排在 100000 后面,这就是为什么第 2 章的清洗环节不能省。

3.3 接口返回结构与分页约定:前后端分离的第一步

前后端分离项目实战里,最怕各写各的。后端返回什么结构,前端就按什么结构取,两边没有约定,联调时全是破事。接口统一用「code + message + data」的外壳,code 为 0 表示成功,非 0 时 message 放失败原因。

{ "code": 0, "message": "ok", "data": { "total": 1200, "page": 1, "page_size": 20, "items": [ {"title": "书甲", "author": "某作者", "click_count": 12000} ] } }

total和分页参数方便前端做分页器。列表数据永远放在data.items里,字段名用下划线风格,避免驼峰和蛇形混用。日期统一返回YYYY-MM-DD字符串,前端 new Date 解析时不会踩时区坑。这套约定在后面的 Flask 接口里直接落地,前端页面只认这个结构。

4. 前后端分离落地:Flask 接口、ECharts 图表与中文乱码排查

4.1 Flask 接口最小实现与数据库连接管理

接口层选 Flask 而不是 Django,理由和选 requests 不选 Scrapy 类似:单个分析系统只有几个页面,Flask 几十行就能起服务,Django 的 ORM、Admin、中间件在这个规模下都用不满。数据库操作用 PyMySQL 原生 SQL,表结构就那么几张,SQL 反而比 ORM 直观。

from flask import Flask, jsonify import pymysql app = Flask(__name__) app.config["JSON_AS_ASCII"] = False DB = { "host": "127.0.0.1", "user": "root", "password": "your_password", "database": "novel_analysis", "charset": "utf8mb4", "cursorclass": pymysql.cursors.DictCursor, } def query_all(sql, args=None): conn = pymysql.connect(**DB) try: with conn.cursor() as cur: cur.execute(sql, args or ()) return cur.fetchall() finally: conn.close() @app.route("/api/category-stats") def category_stats(): sql = """ SELECT category, COUNT(*) AS book_count, ROUND(AVG(click_count)) AS avg_click FROM books GROUP BY category ORDER BY avg_click DESC, book_count DESC """ rows = query_all(sql) return jsonify(code=0, message="ok", data=rows) if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=True)

query_all把「连接—查询—关闭」收在一个函数里,每个请求开一次连接,对毕业设计量级足够,不用引入连接池。用finally保证异常时连接也一定关闭,这是血泪经验:Windows 下连接不关,跑几个小时 MySQL 会报 Too many connections。DictCursor让查询结果直接是字典列表,前端拿到就是干净的 JSON。

注意JSON_AS_ASCII = False,这个配置让 Flask 返回中文时不做 \u 转义,接口里直接能看到「玄幻」而不是\u7384\u5e7b。新版 Flask 里这个配置挪到了app.json.ensure_ascii,如果浏览器里看到中文正常、终端里看到转义串,就用新写法。

4.2 ECharts 图表与空数据显示兜底

前端部分用 ECharts 画柱状图和趋势线最省事,一张 CDN 引用的 JS 文件就够。页面逻辑很简单:fetch 接口拿数据,映射成 x 轴分类和 y 轴数值,再 setOption 绘出图表。

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <script src="/static/js/echarts.min.js"></script> </head> <body> <div id="chart" style="width:900px;height:480px;margin:40px auto;"></div> <script> fetch("/api/category-stats") .then(res => res.json()) .then(json => { if (json.code !== 0) throw new Error(json.message); const data = json.data || []; const cats = data.map(d => d.category); const values = data.map(d => Number(d.avg_click)); if (values.length === 0) { document.getElementById("chart").innerHTML = "暂无数据,请先运行爬虫入库"; return; } const chart = echarts.init(document.getElementById("chart")); chart.setOption({ tooltip: {}, xAxis: { type: "category", data: cats }, yAxis: { type: "value" }, series: [{ type: "bar", data: values, name: "平均点击量" }] }); }) .catch(err => { document.getElementById("chart").innerHTML = "加载失败:" + err.message; }); </script> </body> </html>

ECharts 的坑集中在数据格式:分类轴 data 必须是数组,系列值也必须是数组。Number(d.avg_click)这步很关键,数据库返回的 DECIMAL 或字符串如果不转数字,ECharts 有时能画、有时直接空白,属于典型的「接口有数据但页面画不出来」。空数据显示兜底文案,演示时先跑爬虫再刷新页面,不会面对一块空白尴尬。

4.3 中文不乱码:三个位置必须统一

中文乱码是前后端分离项目里出现频率最高的联调问题。乱码不是玄学,是编码链路断了一处。按「建表—连接—返回」三个位置逐一检查,通常五分钟内定位。

位置推荐配置乱码时的典型现象
MySQL 建表DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci特殊字符写入失败或被替换
PyMySQL 连接charset="utf8mb4"中文能看但特殊字符报错
Flask 返回JSON_AS_ASCII=False接口返回 \uXXXX,排查困难
前端页面meta charset="UTF-8"页面直接显示乱码方块

排查顺序也有讲究:先看 MySQL 里存的数据是不是正常中文,再用命令行工具查接口返回,最后看页面。数据源头就是乱的,前端怎么改都没用。通常是 PyMySQL 连接时漏了charset="utf8mb4",或者建表时用了默认的 latin1,这两处最常踩。

5. 毕业设计避坑指南:反爬、重复入库与联调常见的 5 个坑

5.1 反爬拦截:浏览器正常而 requests 拿到 403

现象:脚本第一次跑能抓几十页,多跑几次后 requests 返回 403,页面内容变成验证提示,浏览器却一切正常。

原因:请求头不完整只是一小部分,更多是请求频率太高,服务端按 IP 或会话维度做限流。固定间隔、并发请求都会加速触发拦截。

解决:requests 里带上完整请求头,至少包含 User-Agent、Referer、Accept;每次请求之间用 random 延时打散节奏;循环里加失败退避,连续三次失败就停止 60 秒再继续。抓小说站这类内容站,频率控制在每秒一个请求以内,通常能稳定跑完全量。

5.2 重复入库:重跑脚本后统计数量直接翻倍

现象:第一次跑完 books 表 800 行,第二次跑完变 1600 行,排行榜接口的 COUNT 也跟着翻倍。

原因:表里只有自增 id,没有业务唯一键。爬虫又是全量抓取,脚本每执行一次就插入一份新数据。

解决:给 source_url 建唯一索引,插入语句用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE。执行完后 COUNT(*) 验证数量没有翻倍。用 ON DUPLICATE KEY UPDATE 时顺手更新 updated_at,重复跑也能保留最后的采集时间。

5.3 图表空白:接口有数据但页面画不出来

现象:浏览器直接访问 /api/category-stats 能看到 JSON,页面里 ECharts 区域却是空白,控制台报错。

原因:JS 报错通常是字段名不一致,比如接口返回 avg_click,前端写成 click_avg;或者值不是数字,字符串参加了数值运算;再或者 data 里混了 null。

解决:先打开浏览器开发者工具看 Network 面板里接口响应,再 Console 打印 json.data 第一条记录确认字段名。x 轴数据里的空值先过滤再做 map 映射。这类问题不是库设计的问题,是前后端字段口径没对齐。

5.4 MySQL 8.0 认证插件报错:caching_sha2_password 连不上

现象:程序连接数据库时抛 Authentication plugin "caching_sha2_password" cannot be loaded,代码一行没改,换台电脑跑就报。

原因:MySQL 8.0 默认认证插件是 caching_sha2_password,某些较旧版本的 PyMySQL 不支持这个插件。

解决:升级 PyMySQL 到 1.0 以上,新装环境直接用新版本库最省事。如果确实不能升级,可以在 MySQL 侧把该用户的插件改成 mysql_native_password。注意数据库密码不要硬编码在前端 JS 或公开仓库里,给后端单独建一个最小权限账号。

5.5 CSV 导出乱码:Excel 打开全是问号

现象:把 books 表导出成 CSV,用记事本打开正常,用 Excel 打开中文全变成问号。

原因:导出文件是 UTF-8 无 BOM 编码,Excel 在 Windows 下默认用本地代码页去猜编码,中文被读错。

解决:用 pandas 导出时指定编码encoding="utf-8-sig",文件会带 BOM 头,Excel 打开不乱码。用数据库工具导出时选 UTF-8 或 GBK 都行,关键是导出编码和打开工具的默认编码一致,别让它们打架。

6. 从「能跑」到「能答辩」:衍生指标、定时更新与正确性验证

6.1 增加三个真正体现「分析」的指标

只有一张图书列表的展示,答辩时容易被问「分析在哪」。加三个算得上分析的指标:更新频率、点击增长率、作者活跃度。更新频率用 chapters 表按周统计章节量,增长率对比前后两天的平均点击,作者活跃度按 author 聚合出书数量和平均点击。增长率这类窗口计算,MySQL 8 支持 LAG 窗口函数直接算,5.7 就在 Python 里取两天数据自己算,更省事。

6.2 验证正确性并与定时任务联动

数据正确性要靠验证,否则演示时翻车很难救场。每类随机抽 5 本书,浏览器打开原始页面和数据库记录逐字段核对;再对比 books 总数和目录页总数,数量对不上说明解析漏了卡片。最后把爬虫脚本挂到定时任务上,Linux 用 crontab 每周一凌晨跑一次,Windows 用任务计划程序,增量逻辑靠 source_url 唯一索引挡住重复写入。

我当年做类似题目时,第一个版本在凌晨一次性抓完全部列表页,第二天 IP 就被临时限制。改成串行请求加随机延时、失败退避之后才稳定下来。这套系统真正锻炼人的地方,是把黑匣子打开:数据从哪来、怎么存、怎么算、怎么展示,每段都能讲出理由,而不是只把别人的成品跑起来。宁可把脚本拆小,也不要让一个任务一次干太多事,这是踩坑换来的习惯。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询