Python爬虫+Flask+ECharts:小说数据分析可视化平台搭建实践

发布时间:2026/10/2 14:35:45
Python爬虫+Flask+ECharts:小说数据分析可视化平台搭建实践
年初做“起点小说数据分析与可视化平台”的时候我的出发点很朴素当时自己书单越攒越多但每本新品从简介、成绩到口碑都得靠手动翻榜单来回切换效率太低。干脆用Python抓一遍起点中文网的公开榜单和书籍详情存进数据库再做一套基于Flask的Web页面把数据分析结果可视化。这个平台做完以后我自己的找书效率提升得很明显很多朋友看了也都想照着摆弄一套所以我干脆把整个搭建过程、关键代码、踩坑经验完整整理出来希望能帮到正在摸索爬虫、数据分析、可视化这条线的朋友。这套东西适合的人群比较明确刚入门Python但想把爬虫、SQL、Flask、ECharts串成一个完整项目的学习者已经在写Python但没试过全链路集成的人以及纯想做一个小而实用的数据分析工具不想要太重框架的开发爱好者。1. 平台定位与整体技术架构先想清楚要做什么再动手1.1 需求拆解这是要做一个“能查、能看、能持续更新”的小平台很多新手拿到类似项目第一反应是“能爬到数据就行”。但真把这个爬虫变成可维护的数据分析平台需求其实分几层数据采集层能从起点排行榜、详情页拿到书名、作者、分类、字数、评分、推荐票、读者数、连载状态等字段。数据存储层数据要落库不能每次重新爬要能回溯历史趋势。数据分析层能按分类、时间、作者、榜单变化做统计算出增长、热度、口碑等指标。展示层用户打开浏览器能通过不同图表看榜单结构、分类分布、热门作品甚至按条件筛选。调度层脚本能周期性增量更新不用每天手动跑。我最初把这几层拆开细化以后才发现核心痛点是“怎么让数据每天更新且不重复”而不是“怎么爬”。所以整个项目设计成了“爬虫写数据 - 分析脚本算指标 - Flask提供接口 - 前端渲染图表”的流水线每一层各干各的事后续想替换任何一个环节都不影响整体。技术选型方面我做了几轮对比。Flask对比Django最大的吸引力是轻和自由。Django自带Admin、ORM、表单、模板一大堆东西但很多在这个项目里是用不上的反而增加了理解成本。Flask只做路由和视图数据库层用SQLAlchemy模板层面直接在HTML里用Jinja2和JavaScript逻辑简单直接。可视化这块我最终选了ECharts而不是直接套BI系统原因不只是免费而是它和Flask接口的配合非常顺手后端吐出JSON前端拿到数据就能出柱状图、折线图、饼图、散点图交互也很灵活。对比过帆软、Metabase、Superset它们更适合拖着数据源做固定报表但自由度不如自己画图而且要想做一些榜单自定义分析还是得写SQL等于多学一套东西没必要。1.2 技术选型为什么是FlaskSQLAlchemyECharts这套组合项目当时具体选型是这样定的模块选型理由Web框架Flask 2.x轻量、路由自由、和爬虫代码共用一套虚拟环境数据库SQLite SQLAlchemy单机版先run起来换MySQL只改连接串爬虫requests BeautifulSoup页面结构不复杂够用且直观前端Bootstrap 5 ECharts 5快速搭界面图表渲染专业调度APScheduler / crontab定时任务做增量爬取部署gunicorn nginx生产环境稳定能挂后台进程为什么不一上来就用Scrapy因为起点榜单结构不算深requests起步足够。但我在项目设计中预留了接口如果哪天页面复杂到需要中间件、分布式调度再换Scrapy也不晚。实际后来也确实遇到了一些需要应对的反爬限制那是后话。1.3 目录结构与数据库表设计提前规划比反复重构更省心项目目录我是这样组织的novel_analysis_platform/ ├── app.py # Flask主入口 ├── config.py # 配置项数据库、爬虫参数 ├── requirements.txt ├── crawler/ │ ├── qidian_crawler.py # 爬虫脚本 │ └── utils.py # 请求头、重试、时间处理工具 ├── analysis/ │ ├── metrics.py # 指标计算 │ └── data_clean.py # 清洗逻辑 ├── static/ │ ├── css/ │ └── js/ # echarts配置、ajax请求 ├── templates/ │ ├── index.html │ ├── list.html │ └── detail.html └── data/ └── novels.db数据库表设计是这套平台最关键的地基。我建的核心表长这样CREATE TABLE novels ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id VARCHAR(32) UNIQUE, -- 起点书籍ID book_name VARCHAR(128), author VARCHAR(64), category VARCHAR(32), intro TEXT, word_count INTEGER, -- 总字数 rating REAL, -- 评分 rating_count INTEGER, -- 评分人数 recommend_count INTEGER, -- 推荐票 fans_count INTEGER, -- 粉丝数 status VARCHAR(16), -- 连载状态 update_time VARCHAR(32), -- 最后更新时间 crawl_date DATE, -- 抓取日期 UNIQUE(book_id, crawl_date) );这里有个关键设计UNIQUE(book_id, crawl_date)。它保证了当天同一本书只保留一条记录同时又保留了历史天数的数据做趋势分析和环比变化特别有用。否则每次都只覆盖最新数据以后想做“这本书一周内推荐票涨了多少”就做不出来了。2. 核心爬虫抓取起点小说数据的工程化细节2.1 分析目标页面先看URL规律再动手写代码爬虫最难的不是写代码是把目标页面结构摸清楚。起点的榜单和分类页URL其实很有规律我拿“排行榜”和“分类作品库”两个入口做数据源结构大概是这样分类作品库https://www.qidian.com/all/page{i}/参数里有分类、状态、字数区间可以组合出几十个列表页。月票榜、畅销榜、推荐榜路径一般是/rank/下面带参数每个榜单抓一遍就能交叉补充热度指标。我当时先手动打开几个页面用浏览器开发者工具查看HTML结构发现小说列表里每一本书是一个li里面有书名、作者、分类、简介、推荐票这些信息都包在不同class的标签里。只要把选择器定准数据就能稳定解析。2.2 requestsBeautifulSoup的采集流程请求、解析、入库第一次写爬虫最容易犯的错是不做异常处理。我在代码里加了三层保护请求重试、解析容错、入库去重。这里放一段精简版核心代码能跑通整个采集链路import time import requests from bs4 import BeautifulSoup from datetime import date from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Novel, Base HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } engine create_engine(sqlite:///data/novels.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine) def fetch_page(url, retries3): for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.text except Exception as exc: print(f请求失败: {url}, 第{attempt1}次重试, 错误信息: {exc}) time.sleep(3 * (attempt 1)) return None def parse_list(html): soup BeautifulSoup(html, html.parser) books [] for li in soup.select(li.book-li): try: name li.select_one(h4 a).get_text(stripTrue) author li.select_one(p.author a).get_text(stripTrue) cat li.select_one(p.author).get_text(stripTrue).split(/)[0] intro li.select_one(p.intro).get_text(stripTrue) book_url li.select_one(h4 a)[href] book_id book_url.split(/)[-2] books.append({ book_id: book_id, book_name: name, author: author, category: cat, intro: intro, crawl_date: date.today().isoformat() }) except AttributeError: # 某本书信息不完整就跳过不要因为一条脏数据中断整个任务 continue return books def save_to_db(books): session Session() for book in books: # 使用merge做upsert同一天同一book_id就跳过 session.merge(Novel(**book)) session.commit() session.close() def crawl_list_pages(total_pages20): for page in range(1, total_pages 1): url fhttps://www.qidian.com/all/page{page}/ html fetch_page(url) if not html: continue books parse_list(html) save_to_db(books) print(f第{page}页完成抓取{len(books)}本) time.sleep(2) # 控制访问频率避免对目标站点造成压力这段代码的思路很直白请求页面、解析字段、用session.merge做幂等入库。merge的作用是如果发现同一book_id crawl_date的记录已存在就用新数据覆盖不存在则新增天然支持每天重跑。2.3 反爬与封禁限速、UA轮换、异常重试的实践经验所有人都关心反爬但真正在起点的抓取过程中我发现大量问题不是“封IP”而是“请求频率过高触发临时访问限制”。刚开始我不懂写了个for循环一秒请求好几页跑没几轮就收到异常响应页面也打不开了。后来我总结出来一套“温和但稳定”的策略限速每页请求之间至少间隔1到2秒不要贪快。User-Agent轮换提前准备一个UA池每次请求随机取一个降低被简单规则误伤的概率。重试机制遇到网络波动或状态码异常用指数退避的方式重试3次。及时停止一旦连续多次失败就暂停爬虫而不是死循环硬冲。不要抱着“一定要把所有榜单全部抓完”的心态数据更新是循环往复的任务今天漏掉的页面明天可以补但IP一旦被封反而耽误更久。这也是我后来建议上代理池的原因但单机个人项目用不太到做好基本礼貌就够了。2.4 增量更新与断点续爬让每天的数据都有累积价值增量抓取逻辑我实现了两层。第一层就是前面说的UNIQUE(book_id, crawl_date)这就是天然的去重机制。第二层是做“断点续爬”爬虫每完成一页把这个页数记录到本地progress.txt如果中途挂了下次启动先读进度直接从上次完成的页数往后继续。def load_progress(): try: with open(progress.txt, r) as f: return int(f.read().strip()) except FileNotFoundError: return 1 def save_progress(page): with open(progress.txt, w) as f: f.write(str(page))这个设计在真实跑任务时很重要。爬虫不可能永远不出错断电、网络波动、目标页面改版、服务器重启任何一个问题都会中断进程。有了进度文件任务恢复成本特别低。3. 数据清洗与分析把抓来的数据变成可用的信息3.1 清洗的常见坑千分位、状态字段、缺失值原始爬下来的数据不能直接分析我踩过几个很明显的坑。第一个是数字格式。起点页面显示的字数和推荐票都是“123.4万”“1.2亿”这类格式直接存成字符串没法排序算均值。我写了一个转换函数def parse_chinese_number(s): s s.strip() if 亿 in s: return int(float(s.replace(亿, )) * 100000000) if 万 in s: return int(float(s.replace(万, )) * 10000) return int(s)第二个坑是状态字段不统一。连载中的书有的页面写“连载中”有的写“连载”还有的写“未完结”。我在清洗阶段统一映射成“连载中/已完结”两个值分析“新书涨幅榜”和“完结热度”时就不容易出错了。第三个坑是简介和分类字段带空白字符、HTML标签残留。BeautifulSoup处理完如果发现intro里还夹着\r\n直接用正则清理掉。3.2 指标体系从榜单和书库数据里能算出什么有用的东西清洗完之后数据已经存在novels表里。但这个平台要做的不是把表格原样展示在网页上而是找出有价值的信息。我的数据分析模块算了这几种指标分类热度分布统计每个分类下的书籍数量、平均字数、平均评分看哪个分类作品最多、哪个分类评分最高。榜单TOP10按推荐票、评分、粉丝数排序结合分类维度看头部作品。涨幅榜用同一本书在不同抓取日的recommend_count做差算出最近24小时的推荐票涨幅找出“正在起量”的书。口碑分析评分超过某阈值且评分人数超过某阈值的书定义为“口碑稳定作品”。更新活跃度用update_time字段分析最近7天有更新的作品占比判断整个品类的活跃状态。这些指标的量级不大但在analysis/metrics.py里用pandas处理非常顺手。举个例子import pandas as pd import sqlite3 conn sqlite3.connect(data/novels.db) df pd.read_sql_query(SELECT * FROM novels WHERE crawl_date date(now), conn) category_stats df.groupby(category).agg( book_count(book_id, count), avg_word(word_count, mean), avg_rating(rating, mean) ).sort_values(book_count, ascendingFalse)groupby一次就能把分类分布算出来后面接Flask接口时只需要to_dict(orientrecords)就能转JSON。3.3 趋势与对比为什么必须保留历史快照数据我前面特意设计了历史快照这一步就体现出价值。只看某一天的排行榜最多知道“现在谁最火”但看不出“谁在涨”。我实际做分析时最喜欢看的是“推荐票7日涨幅榜”它把同一个book_id在7天前的数据与今天的数据做对比today pd.read_sql_query(SELECT * FROM novels WHERE crawl_date date(now), conn) seven_days_ago pd.read_sql_query(SELECT * FROM novels WHERE crawl_date date(now, -7 days), conn) merged today.merge(seven_days_ago, onbook_id, suffixes(_today, _old)) merged[recommend_growth] merged[recommend_count_today] - merged[recommend_count_old]这张“涨幅榜”比绝对值排行榜有意思得多。有些书可能在总榜排名不进前五十但涨幅惊人说明正处于推荐位曝光期是真正的潜力作品。这种分析必须有历史数据支撑否则做不出来。4. Flask与ECharts可视化平台的前后端串联实战4.1 Flask路由设计页面路由和API接口分开放平台的前后端交互我采用“页面路由 AJAX数据接口”的模式。页面路由负责渲染HTML模板把框架和布局给出来数据接口只负责返回JSON前端再通过JavaScript请求和填充图表。from flask import Flask, render_template, jsonify import pandas as pd import sqlite3 app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/category_stats) def category_stats(): conn sqlite3.connect(data/novels.db) df pd.read_sql_query( SELECT category, COUNT(*) as cnt, AVG(rating) as avg_rating FROM novels WHERE crawl_date date(now) GROUP BY category, conn ) conn.close() return jsonify(df.to_dict(orientrecords))这样做的最大好处是“接口和展示解耦”。以后如果想换前端框架或者想让朋友直接在手机上查看都是现成接口不需要重写逻辑。4.2 ECharts图表配置从后端JSON到前端柱状图、折线图前端我用的模板框架是Bootstrap图表全靠ECharts。以一个“分类分布柱状图”为例HTML里放一个容器JavaScript里从接口拿数据设置xAxis、yAxis、series即可div idcategoryChart styleheight:400px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script fetch(/api/category_stats) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(categoryChart)); chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: data.map(d d.category) }, yAxis: { type: value }, series: [{ type: bar, data: data.map(d d.cnt), name: 书籍数量 }] }); }); /script图表里最重要的不是配置多花哨而是数据对得上。所以我每次写完一个图表第一件事是先在浏览器里按F12打开控制台打印接口返回数据确认字段名一致再谈样式。4.3 页面设计榜单表格、关键词搜索、图表联动除了图表平台还做了两个实际场景一是“书库列表页”。默认按推荐票倒序支持按分类、作者名、书名关键词筛选。为了让页面不卡顿我在后端做了分页参数前端用普通表单提交就行。二是“详情趋势页”。点列表里的书名跳到/detail/book_id这个页面向API请求该书最近30天的推荐票变化用折线图展示。这个页面是我个人最常用的一本书有没有持续起量一眼就能看出来。趋势接口是这样写的app.route(/api/book_trend/book_id) def book_trend(book_id): conn sqlite3.connect(data/novels.db) df pd.read_sql_query( SELECT crawl_date, recommend_count, fans_count FROM novels WHERE book_id ? ORDER BY crawl_date, conn, params(book_id,) ) conn.close() return jsonify({ dates: df[crawl_date].tolist(), recommend: df[recommend_count].tolist(), fans: df[fans_count].tolist() })前端拿到两组数组以后直接塞给折线图维护起来很清爽。4.4 把平台跑起来虚拟环境、依赖、初始化数据库很多人在这一步卡住其实是环境问题。我建议一上来就用虚拟环境隔离依赖不要全局装。装依赖的文件requirements.txt是这样flask2.3.3 requests2.31.0 beautifulsoup44.12.2 sqlalchemy2.0.16 pandas2.0.3 apscheduler3.10.1 gunicorn21.2.0创建环境并安装python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install -r requirements.txt然后初始化数据库表执行爬虫脚本最后启动Flaskpython crawler/qidian_crawler.py python app.py浏览器访问http://127.0.0.1:5000就能看到页面。这里要提醒一个Python版本问题建议用Python 3.9以上sqlalchemy和pandas新版本对老版本兼容有要求我用Python 3.11跑得很稳。5. 部署运行与踩坑复盘看得见的坑才是真经验5.1 从Windows本地到Linux服务器卡了我两天的坑本地Windows跑得很顺利真正部署到云服务器以后我遇到了第一个困惑页面可以打开但样式和图表全部失效。排查链路是这样的打开浏览器开发者工具看到控制台报404错误。发现HTML里引用的静态资源路径用的是相对路径/static/但nginx没配静态资源转发规则。我把Flask单独用gunicorn -w 4 -b 127.0.0.1:5000 app:app起起来绕过nginx先测试。本地访问服务IP:5000页面正常确定问题在nginx一层。在nginx配置里增加location /static/ { root /root/novel_analysis_platform; }同时加proxy_pass http://127.0.0.1:5000;问题解决。这个教训让我明白了碰到问题一定先分层排查不要一上来就怀疑代码。5.2 爬虫定时调度APScheduler和crontab怎么选平台要是只能手动每天跑一次那就没意义了。我的方案是写了一个只有爬虫任务的脚本用APScheduler内置在同一个进程里跑定时任务from apscheduler.schedulers.blocking import BlockingScheduler scheduler BlockingScheduler() scheduler.add_job(crawl_all, cron, hour6, minute0, iddaily_crawl) scheduler.start()为什么不用系统cron因为我最初是在Windows上跑通整条流程的Windows没有系统级crontabAPScheduler跨平台而且可以直接和数据库、日志等逻辑共享变量。等到迁移到Linux以后再考虑把调度拆出来交给crontab也不迟。定时任务还有个细节必须注意因为爬虫定时任务和Flask应用在不同的进程操作的是同一个SQLite数据库理论上可能有并发写问题。我在SQLite连接里加了check_same_threadFalse同时在爬虫里用事务提交实测下来没遇到锁冲突。5.3 高频问题与排查思路一张表讲清楚我把整个开发过程里朋友问得最多的问题和自己的排查过程列成了一张表方便后面的人直接对照现象原因排查链路Flask页面打不开端口占用或服务没启动先curl 127.0.0.1:5000再lsof -i:5000图表空白接口没返回数据看开发者工具Network直接访问API看JSON爬虫没有入库没有创建表先跑Base.metadata.create_all初始化爬虫被限制频率太高看状态码和异常文本按页数暂停24小时数据重复严重缺少日期唯一约束检查表结构里UNIQUE索引是否建好页面数据是昨天的定时任务没触发先手动跑一次确认能成功再查调度这个表不是“标准答案”而是我平时排查的一级思路。遇到问题先缩小范围再针对具体模块打日志。5.4 为什么说“先跑通最小闭环”比追求完美更重要开发这个平台的过程中我最大的体会就是“别想一口气吃成一个胖子”。最早我只做了一页爬虫、一个表格、一张柱状图先打通“爬-存-读-显”的闭环。从最小闭环到加上趋势分析、定时任务、服务器部署是一个逐步迭代的过程。如果一开始就规划到分布式爬虫、Redis缓存、消息队列开发周期至少翻几倍而且大多数功能当前根本用不到。尤其是Redis这类中间件我自己只在后面扩展缓存接口时才考虑加。如果你也想做类似平台记住一个原则先能用再优化最后才追求架构。6. 可继续扩展的方向与个人建议平台到现在已经稳定运行了一段时间功能上其实还有不少延伸空间。如果你想在这个项目基础上继续做我有几个明确的方向一是把SQLite换成MySQL。当历史数据累积到几万条以后SQLite在并发和查询复杂度上会吃力。换MySQL只是改连接串和建表语句SQLAlchemy帮你扛住了大部分切换成本。二是引入Redis做缓存。分析结果如果每次从SQLite里重新计算速度终究有限。把热门图表接口的结果缓存到Redis点击刷新几乎秒开。配合Redis可视化客户端里的键值浏览排查缓存命中也方便许多。三是上分布式爬虫。如果不想再只盯一个网站想扩展到几十个书源那用Scrapy加Scrapy-Redis做分布式调度是更合理的选择。但前提是数据量真的上去了否则单机反而是最省事的。四是做NLP推荐。书库里的简介和标签是天然文本数据可以统计标签共现、计算简介关键词权重用TF-IDF或简单词向量做“相似书推荐”。我自己正计划把这一步接进详情页读者点开一本书时平台顺便推荐三本关键词最接近的书。给还在踩坑路上的朋友一个我自己的经验总结做这类“爬虫数据分析可视化”项目最大的价值不在于某个技术有多牛而在于你亲手把数据从页面变成表格再从表格变成一张能讲出故事的图表。中间每一步都会遇到杂七杂八的问题但每解决一个你对整个数据链路的理解就深一层。