简介:这是一套面向高校计算机相关专业毕业设计的完整项目资料,主题为基于Python爬虫的网络小说数据分析系统,适合需要完成数据分析类毕设或学习前后端开发的学生参考。系统前台展示作者作品、分类占比、小说名称与分类统计,后台提供用户管理、网络小说维护与系统公告等模块,技术栈覆盖Python爬虫、Pandas/NumPy数据分析、Matplotlib/Seaborn可视化及MySQL数据库。压缩包共415个文件,约16.91MB,以py源码、vue前端组件、js脚本、svg与png图表资源为主,另含sql建库脚本、bat一键安装运行脚本及docx说明文档,目录结构清晰便于按模块查阅。已有121人学习下载。购买后可获得完整源码、数据库文件、开发文档与LW,支持快速部署运行,并附排错思路与调试支持,兼具学习参考与二次开发价值。
1. 从零搭一套网络小说数据分析系统:爬虫、MySQL 与前后端到底怎么串起来
很多人做毕业设计时,第一反应是“爬虫写完就完事了”,结果答辩时被问“数据存哪、怎么查、前端怎么展示”直接卡壳。这个标题真正要解决的,是把 requests 爬虫、MySQL 存储、Python 数据分析、前后端交互四件事串成一条能跑通的链路,而不是四个孤立脚本。适合两类人:一是正在做同类毕设、需要一套可复现骨架的在校生;二是刚入门 Python、想用一个完整项目把爬虫和数据分析连起来练手的开发者。我见过太多项目死在“爬完数据只打印在控制台”这一步,所以这篇按真实落地顺序讲:先定数据模型,再写爬虫入库,再做分析接口,最后接前端页面。核心词 Python 爬虫、数据分析、MySQL、前后端分离会贯穿每一章,跟着走能直接复现一套最小可用系统。
2. 数据模型先立住:小说、章节、评论三张表怎么设计
2.1 为什么表结构决定了后面所有代码的写法
爬虫抓什么、分析算什么、前端展示什么,全部由表结构倒推。网络小说场景的数据关系很清晰:一本小说有多个章节,一个章节可能有多条评论,小说本身还有分类、作者、状态、字数这些属性。如果一开始把小说名、章节名、评论全塞进一张宽表,后面做“某分类下评论情感分布”这种分析时,SQL 会写得极其痛苦,而且重复数据会把库撑爆。
我一般会拆成三张核心表加一张分类维表。小说表存元信息,章节表存正文和字数,评论表存用户评论内容与时间,分类表做字典。这样爬虫每抓一层写一张表,分析时按需 JOIN,前端也能分页查。常见做法是用 SQLAlchemy 定义模型,好处是建表、插入、查询一套 ORM 搞定,不用手写大量 SQL 字符串,也方便后面换数据库。
2.2 用 SQLAlchemy 定义四张表的完整代码
# models.py from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index from sqlalchemy.orm import declarative_base, relationship from datetime import datetime Base = declarative_base() class Category(Base): __tablename__ = "category" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(50), unique=True, nullable=False, comment="分类名,如玄幻、都市") class Novel(Base): __tablename__ = "novel" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(200), nullable=False, comment="小说名") author = Column(String(100), comment="作者") category_id = Column(Integer, ForeignKey("category.id"), comment="分类外键") status = Column(String(20), default="连载中", comment="连载状态") word_count = Column(Integer, default=0, comment="总字数") intro = Column(Text, comment="简介") created_at = Column(DateTime, default=datetime.now) chapters = relationship("Chapter", back_populates="novel") class Chapter(Base): __tablename__ = "chapter" id = Column(Integer, primary_key=True, autoincrement=True) novel_id = Column(Integer, ForeignKey("novel.id"), nullable=False) title = Column(String(200), nullable=False, comment="章节标题") content = Column(Text, comment="章节正文") word_count = Column(Integer, default=0, comment="本章字数") chapter_no = Column(Integer, comment="章节序号") novel = relationship("Novel", back_populates="chapters") __table_args__ = (Index("idx_novel_chapter", "novel_id", "chapter_no"),) class Comment(Base): __tablename__ = "comment" id = Column(Integer, primary_key=True, autoincrement=True) novel_id = Column(Integer, ForeignKey("novel.id"), nullable=False) user_name = Column(String(100), comment="评论用户名") content = Column(Text, comment="评论内容") comment_time = Column(DateTime, comment="评论时间") __table_args__ = (Index("idx_novel_time", "novel_id", "comment_time"),)这段代码里几个参数值得说清楚。String(200)对小说标题足够,但章节正文必须用Text,因为单章可能上万字,String在 MySQL 里默认长度会截断。Index("idx_novel_chapter", "novel_id", "chapter_no")是复合索引,因为查章节列表永远是“某本小说的第几章到第几章”,这个索引能让分页查询走覆盖索引,几百万行也不慢。comment_time单独建索引配合novel_id,是为了做“某本小说评论随时间变化”的分析。category_id用外键而不是直接存分类名,是为了改分类名时不用全表更新。
2.3 建表与初始化分类字典
# init_db.py from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Base, Category engine = create_engine( "mysql+pymysql://root:yourpassword@127.0.0.1:3306/novel_db?charset=utf8mb4", echo=False, pool_size=5, max_overflow=10 ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() for name in ["玄幻", "都市", "仙侠", "历史", "科幻"]: if not session.query(Category).filter_by(name=name).first(): session.add(Category(name=name)) session.commit()连接串里charset=utf8mb4必须写,网络小说里生僻字和 emoji 评论很常见,用默认的 utf8 会在插入时报Incorrect string value。pool_size=5, max_overflow=10是连接池配置,爬虫并发写的时候如果池太小会频繁等待,太大会把 MySQL 连接数打满,5 到 10 这个区间对单机 MySQL 比较稳。初始化分类字典单独做,是因为爬虫抓到的分类名可能不规范,先有字典再匹配,能避免脏分类。
3. 爬虫落地:requests 抓取、解析与批量入库
3.1 请求层怎么写得不容易被封
网络小说站点通常有列表页和详情页两级。列表页给小说链接,详情页给章节列表,章节页给正文。我一般用 requests 加一个带重试的 Session,把 UA、Referer 设好,请求间隔随机 1 到 3 秒。不要用多线程猛冲,小说站反爬主要看频率,单线程加随机延时反而更稳。如果站点有分页参数,先手动翻几页确认 URL 规律,再写循环。
解析层用 lxml 的 XPath 比 BeautifulSoup 快,但容错差,页面结构一变就全空。我的习惯是 XPath 定位加 try 兜底,取不到就记日志跳过,不让一条脏数据中断整个任务。正文里的<br>要替换成换行,否则入库后前端展示是一整坨。
3.2 爬虫主流程与入库代码
# spider.py import requests, time, random, re from lxml import etree from sqlalchemy.orm import sessionmaker from models import Novel, Chapter, Category from init_db import engine Session = sessionmaker(bind=engine) HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example-novel-site.com/" } def fetch(url, retry=3): for i in range(retry): try: r = requests.get(url, headers=HEADERS, timeout=10) r.encoding = r.apparent_encoding if r.status_code == 200: return r.text except requests.RequestException as e: print(f"第{i+1}次失败: {e}") time.sleep(random.uniform(1, 3)) return None def parse_novel_list(html): tree = etree.HTML(html) items = tree.xpath('//div[@class="book-item"]') result = [] for it in items: title = it.xpath('.//h3/a/text()') link = it.xpath('.//h3/a/@href') author = it.xpath('.//span[@class="author"]/text()') if title and link: result.append({ "title": title[0].strip(), "url": link[0], "author": author[0].strip() if author else "佚名" }) return result def save_novel(session, item, category_name="玄幻"): cat = session.query(Category).filter_by(name=category_name).first() if not cat: cat = Category(name=category_name) session.add(cat) session.flush() novel = session.query(Novel).filter_by(title=item["title"]).first() if novel: return novel novel = Novel(title=item["title"], author=item["author"], category_id=cat.id) session.add(novel) session.flush() return novel def crawl_chapters(session, novel, chapter_list_url): html = fetch(chapter_list_url) if not html: return tree = etree.HTML(html) links = tree.xpath('//ul[@id="chapter-list"]/li/a/@href') for idx, link in enumerate(links, start=1): page = fetch(link) if not page: continue ct = etree.HTML(page) title = ct.xpath('//h1/text()') paras = ct.xpath('//div[@id="content"]//text()') content = "\n".join(p.strip() for p in paras if p.strip()) content = re.sub(r"<br\s*/?>", "\n", content) ch = Chapter( novel_id=novel.id, title=title[0].strip() if title else f"第{idx}章", content=content, word_count=len(content), chapter_no=idx ) session.add(ch) if idx % 50 == 0: session.commit() print(f"{novel.title} 已入库 {idx} 章") time.sleep(random.uniform(1, 3)) session.commit()fetch里r.apparent_encoding是让 requests 自己猜编码,小说站常用 GBK,写死 utf-8 会乱码。重试三次加随机延时是防封的基本盘。save_novel里先查后插,避免重复抓同一本书时产生重复行,session.flush()是为了拿到自增 id 给章节用。crawl_chapters每 50 章 commit 一次,这是血泪经验:如果全程不提交,中途断网或报错,前面抓的全丢;提交太频繁又拖慢速度,50 是一个折中。word_count=len(content)直接算字符数,中文场景够用,不用去分词。
3.3 评论抓取与时间字段处理
评论通常在章节页底部或独立接口。如果是接口返回 JSON,直接r.json()取字段;如果是 HTML,XPath 定位评论节点。评论时间格式五花八门,有“3 分钟前”“昨天”“2024-05-01”几种,入库前要统一转成 datetime。我一般写一个parse_time函数,用正则匹配相对时间,匹配不到就按绝对时间解析,解析失败就存当前时间并打日志。评论表数据量大,建议单独一个爬虫任务,按小说 id 分批跑,不要和章节正文混在一个循环里,否则一章正文几万字会把评论请求挤在后面。
4. 数据分析层:从 SQL 聚合到可视化接口
4.1 分析指标先定,再写查询
数据分析不是“把数据拿出来看看”,而是先定指标。网络小说场景常用指标有:各分类小说数量占比、字数分布、连载状态比例、评论量 Top10 小说、评论时间趋势、章节更新频率。每个指标对应一条 SQL 聚合,用 SQLAlchemy 的func.count、func.avg、func.date就能写,不用把全表拉到 Python 里算,那样几百万行会直接把内存吃满。
我一般把分析函数集中放在analysis.py,每个函数返回 list of dict,前端拿到直接渲染 ECharts。这样前后端职责清晰:后端只出数据,前端只画图。如果后面要加指标,只改后端加一个函数,前端加一个图表配置,不动数据库。
4.2 三个核心分析查询的代码实现
# analysis.py from sqlalchemy import func, desc from sqlalchemy.orm import sessionmaker from models import Novel, Chapter, Comment, Category from init_db import engine Session = sessionmaker(bind=engine) def category_distribution(): session = Session() rows = session.query( Category.name, func.count(Novel.id).label("cnt") ).join(Novel, Novel.category_id == Category.id)\ .group_by(Category.name).all() session.close() return [{"name": r[0], "value": r[1]} for r in rows] def word_count_range(): session = Session() rows = session.query( func.case( (Novel.word_count < 100000, "10万以下"), (Novel.word_count < 500000, "10-50万"), (Novel.word_count < 1000000, "50-100万"), else_="100万以上" ).label("range"), func.count(Novel.id) ).group_by("range").all() session.close() return [{"name": r[0], "value": r[1]} for r in rows] def comment_trend(novel_id): session = Session() rows = session.query( func.date(Comment.comment_time).label("d"), func.count(Comment.id) ).filter(Comment.novel_id == novel_id)\ .group_by("d").order_by("d").all() session.close() return [{"date": str(r[0]), "count": r[1]} for r in rows]category_distribution用 JOIN 加 GROUP BY,走的是外键索引,几十万本小说也是毫秒级。word_count_range里的func.case是 SQL 的 CASE WHEN,把连续字数切成区间,比在 Python 里循环判断快一个数量级。comment_trend按天聚合,func.date会丢掉时分秒,正好适合趋势图。注意group_by("range")这里用的是别名,MySQL 支持,但换到某些数据库要写完整表达式,做毕设用 MySQL 没问题。
4.3 用 Flask 暴露分析接口
# app.py from flask import Flask, jsonify, request from flask_cors import CORS import analysis app = Flask(__name__) CORS(app) @app.route("/api/category") def api_category(): return jsonify(analysis.category_distribution()) @app.route("/api/word_range") def api_word_range(): return jsonify(analysis.word_count_range()) @app.route("/api/comment_trend") def api_comment_trend(): novel_id = request.args.get("novel_id", type=int) if not novel_id: return jsonify({"error": "novel_id required"}), 400 return jsonify(analysis.comment_trend(novel_id)) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)CORS(app)是前后端分离必须的,否则前端在 8080 端口请求 5000 端口会被浏览器拦。request.args.get("novel_id", type=int)带类型转换,避免前端传字符串导致 SQL 报错。debug=True只在开发时开,部署要关掉,否则有安全风险。接口返回统一 JSON,前端用 axios 或 fetch 拿数据,和 ECharts 的series.data直接对接。
5. 前后端联调与部署:Vue 页面怎么接 Flask 数据
5.1 前端页面结构与请求封装
前端用 Vue 加 ECharts 是最省事的组合。页面分三块:分类占比饼图、字数分布柱状图、评论趋势折线图。请求统一封装一个request.js,baseURL 指向 Flask 地址,这样换环境只改一处。ECharts 的setOption在onMounted里调用,数据从接口拿回来后chart.setOption({series: [{data: res}]})。
联调时最常见的翻车是跨域和端口写错。前端 dev server 跑 8080,Flask 跑 5000,如果 baseURL 写成localhost:5000但后端没开 CORS,浏览器控制台会报Access-Control-Allow-Origin。另一个坑是 ECharts 容器没有高度,图表不显示,必须给 div 设height: 400px。
5.2 前端请求与图表渲染代码
// src/api/request.js import axios from 'axios' const request = axios.create({ baseURL: 'http://127.0.0.1:5000/api', timeout: 10000 }) request.interceptors.response.use( res => res.data, err => { console.error('接口错误', err) return Promise.reject(err) } ) export default request// src/views/Dashboard.vue 片段 import request from '@/api/request' import * as echarts from 'echarts' import { onMounted, ref } from 'vue' const pieRef = ref(null) onMounted(async () => { const chart = echarts.init(pieRef.value) const data = await request.get('/category') chart.setOption({ title: { text: '小说分类分布' }, tooltip: { trigger: 'item' }, series: [{ type: 'pie', radius: '60%', data: data }] }) })baseURL用127.0.0.1而不是localhost,是因为某些 Windows 环境 localhost 解析到 IPv6,Flask 默认只监听 IPv4,会连不上。拦截器统一处理错误,避免每个页面写一遍 try catch。echarts.init必须在 DOM 挂载后调用,放onMounted里,放setup顶层会拿到 null。
5.3 部署时的两个关键配置
如果部署到 Linux 服务器,Flask 不要用app.run直接跑生产,用 gunicorn 加 nginx 反代。gunicorn 命令gunicorn -w 4 -b 127.0.0.1:5000 app:app,-w 4是 4 个 worker,按 CPU 核数调。前端npm run build出静态文件,nginx 指向 dist 目录,同时把/api反代到 5000 端口,这样前后端同域,连 CORS 都不用配。MySQL 记得设max_connections,默认 151,爬虫加接口并发高时容易打满,调到 500 比较稳。
6. 避坑与排查:这套系统最容易翻车的五个地方
6.1 爬虫入库报编码错误
现象:插入章节正文时报Incorrect string value: '\xF0\x9F...'。原因:MySQL 表或连接字符集不是 utf8mb4,存不了 emoji 和四字节生僻字。解决:建库时CREATE DATABASE novel_db DEFAULT CHARSET utf8mb4,连接串加?charset=utf8mb4,已有表用ALTER TABLE chapter CONVERT TO CHARACTER SET utf8mb4。
6.2 分析接口返回空数据
现象:前端图表空白,接口返回[]。原因:爬虫入库时category_id没匹配上,或者comment_time全是 NULL 导致func.date聚合为空。解决:先SELECT COUNT(*) FROM novel WHERE category_id IS NULL确认,再补一个默认分类;评论时间解析失败的要回填,不能留 NULL。
6.3 章节分页查询越来越慢
现象:小说章节过万后,翻到后面几页要好几秒。原因:只建了主键索引,WHERE novel_id=? ORDER BY chapter_no LIMIT ?走的是全表扫描加排序。解决:加复合索引(novel_id, chapter_no),用EXPLAIN确认 type 是 ref 而不是 ALL。
6.4 前端跨域请求被拦
现象:浏览器控制台报 CORS 错误,Network 里请求状态是 failed。原因:Flask 没开 CORS 或 nginx 没配反代头。解决:开发环境flask_cors.CORS(app),生产环境 nginx 加add_header Access-Control-Allow-Origin *,或者干脆前后端同域部署。
6.5 爬虫跑一半被中断数据丢失
现象:抓了几千章后程序崩溃,数据库里只有几百章。原因:commit 频率太低,或者没做异常捕获,一条脏数据让整个循环退出。解决:每 50 章 commit 一次,fetch加 try 返回 None,主循环判断 None 就 continue,关键字段解析加默认值。
7. 让分析结果更可信:数据清洗与指标校验的一个实用技巧
做到这里系统能跑了,但答辩时老师最容易问的是“你的数据准不准”。我踩过的坑是:爬虫抓来的字数包含空格和换行,直接len(content)会把空白算进去,导致字数分布整体偏大。后来我改成先re.sub(r'\s+', '', content)再去长度,分类占比和字数分布立刻合理了很多。这个清洗步骤建议放在入库前,而不是分析时,因为分析层每次查询都清洗会拖慢接口。
另一个校验习惯是:任何聚合指标出来后,用一条明细 SQL 反查。比如饼图显示玄幻有 120 本,就SELECT COUNT(*) FROM novel n JOIN category c ON n.category_id=c.id WHERE c.name='玄幻'对一下,数字对不上说明 JOIN 或分组写错了。这个动作花不了两分钟,但能避免答辩现场被问倒。
如果要把这套东西做得更完整,下一步可以加评论情感分析,用 SnowNLP 或简单词典对评论打正负标签,再按小说聚合出“口碑分”。但别一上来就上模型,先把爬虫、存储、聚合、展示这条链路跑稳,再叠分析深度。我自己做这类项目的习惯是:每加一个功能,先保证旧功能还能跑,用 git 分支持续提交,出问题能回滚。希望帮到你。
本文还有配套的精品资源,点击获取