Python爬虫+MySQL+Flask+Vue:网络小说数据分析系统全链路实战
发布时间:2026/9/26 7:35:03来源:尧图网络
简介这是一套面向高校计算机相关专业毕业设计的完整项目资料主题为基于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_keyTrue, autoincrementTrue) name Column(String(50), uniqueTrue, nullableFalse, comment分类名如玄幻、都市) class Novel(Base): __tablename__ novel id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200), nullableFalse, comment小说名) author Column(String(100), comment作者) category_id Column(Integer, ForeignKey(category.id), comment分类外键) status Column(String(20), default连载中, comment连载状态) word_count Column(Integer, default0, comment总字数) intro Column(Text, comment简介) created_at Column(DateTime, defaultdatetime.now) chapters relationship(Chapter, back_populatesnovel) class Chapter(Base): __tablename__ chapter id Column(Integer, primary_keyTrue, autoincrementTrue) novel_id Column(Integer, ForeignKey(novel.id), nullableFalse) title Column(String(200), nullableFalse, comment章节标题) content Column(Text, comment章节正文) word_count Column(Integer, default0, comment本章字数) chapter_no Column(Integer, comment章节序号) novel relationship(Novel, back_populateschapters) __table_args__ (Index(idx_novel_chapter, novel_id, chapter_no),) class Comment(Base): __tablename__ comment id Column(Integer, primary_keyTrue, autoincrementTrue) novel_id Column(Integer, ForeignKey(novel.id), nullableFalse) 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( mysqlpymysql://root:yourpassword127.0.0.1:3306/novel_db?charsetutf8mb4, echoFalse, pool_size5, max_overflow10 ) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() for name in [玄幻, 都市, 仙侠, 历史, 科幻]: if not session.query(Category).filter_by(namename).first(): session.add(Category(namename)) session.commit()连接串里charsetutf8mb4必须写网络小说里生僻字和 emoji 评论很常见用默认的 utf8 会在插入时报Incorrect string value。pool_size5, max_overflow10是连接池配置爬虫并发写的时候如果池太小会频繁等待太大会把 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(bindengine) HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example-novel-site.com/ } def fetch(url, retry3): for i in range(retry): try: r requests.get(url, headersHEADERS, timeout10) r.encoding r.apparent_encoding if r.status_code 200: return r.text except requests.RequestException as e: print(f第{i1}次失败: {e}) time.sleep(random.uniform(1, 3)) return None def parse_novel_list(html): tree etree.HTML(html) items tree.xpath(//div[classbook-item]) result [] for it in items: title it.xpath(.//h3/a/text()) link it.xpath(.//h3/a/href) author it.xpath(.//span[classauthor]/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(namecategory_name).first() if not cat: cat Category(namecategory_name) session.add(cat) session.flush() novel session.query(Novel).filter_by(titleitem[title]).first() if novel: return novel novel Novel(titleitem[title], authoritem[author], category_idcat.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[idchapter-list]/li/a/href) for idx, link in enumerate(links, start1): page fetch(link) if not page: continue ct etree.HTML(page) title ct.xpath(//h1/text()) paras ct.xpath(//div[idcontent]//text()) content \n.join(p.strip() for p in paras if p.strip()) content re.sub(rbr\s*/?, \n, content) ch Chapter( novel_idnovel.id, titletitle[0].strip() if title else f第{idx}章, contentcontent, word_countlen(content), chapter_noidx ) 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_countlen(content)直接算字符数中文场景够用不用去分词。3.3 评论抓取与时间字段处理评论通常在章节页底部或独立接口。如果是接口返回 JSON直接r.json()取字段如果是 HTMLXPath 定位评论节点。评论时间格式五花八门有“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(bindengine) 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, typeint) if not novel_id: return jsonify({error: novel_id required}), 400 return jsonify(analysis.comment_trend(novel_id)) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)CORS(app)是前后端分离必须的否则前端在 8080 端口请求 5000 端口会被浏览器拦。request.args.get(novel_id, typeint)带类型转换避免前端传字符串导致 SQL 报错。debugTrue只在开发时开部署要关掉否则有安全风险。接口返回统一 JSON前端用 axios 或 fetch 拿数据和 ECharts 的series.data直接对接。5. 前后端联调与部署Vue 页面怎么接 Flask 数据5.1 前端页面结构与请求封装前端用 Vue 加 ECharts 是最省事的组合。页面分三块分类占比饼图、字数分布柱状图、评论趋势折线图。请求统一封装一个request.jsbaseURL 指向 Flask 地址这样换环境只改一处。ECharts 的setOption在onMounted里调用数据从接口拿回来后chart.setOption({series: [{data: res}]})。联调时最常见的翻车是跨域和端口写错。前端 dev server 跑 8080Flask 跑 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 解析到 IPv6Flask 默认只监听 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连接串加?charsetutf8mb4已有表用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_idc.id WHERE c.name玄幻对一下数字对不上说明 JOIN 或分组写错了。这个动作花不了两分钟但能避免答辩现场被问倒。如果要把这套东西做得更完整下一步可以加评论情感分析用 SnowNLP 或简单词典对评论打正负标签再按小说聚合出“口碑分”。但别一上来就上模型先把爬虫、存储、聚合、展示这条链路跑稳再叠分析深度。我自己做这类项目的习惯是每加一个功能先保证旧功能还能跑用 git 分支持续提交出问题能回滚。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网