资讯动态

Python豆瓣电影数据可视化分析项目设计与实现复盘

发布时间:2026/10/14 2:33:29 来源:尧图企业网站定制
1. 项目概述与整体设计去年我把“基于Python豆瓣电影数据可视化分析”这个题目完整做了一遍从采集脚本写到可视化页面前后改了四版。这个项目在课程设计和数据分析作品集里出镜率很高但很多人写着写着就变成了“爬下来一堆数据画了几个图”完全没有形成分析闭环。这篇文章把设计和实现过程完整复盘一遍包括字段怎么选、请求频率怎么控制、数据清洗有哪些坑、图表怎么跟业务问题对应以及我在实操中踩过的几个典型问题。无论你是刚开始学Python还是已经能用pandas做简单处理希望这份复盘能让你少走一些弯路。1.1 项目到底是做什么的第一版很多人的做法是控制台输出几个统计数字再加两张静态图。这个项目如果按“设计与实现”的标准来要求至少应该是一个分层的、能复跑的完整流程采集层从公开网页提取电影基本信息存储层把半结构化文本转成干净的表格分析层围绕几个明确问题做聚合计算展示层用可视化页面把结论呈现出来。可以做成一站式脚本也可以做成 Flask 启动的交互页面。我最终做的是后者。因为可视化分析的最终目的是让人“一眼看懂”数据背后的结构而不是把数字甩在用户脸上。这个定位决定了整个项目的技术选型。1.2 为什么选豆瓣电影作为数据对象如果只考虑练手爬虫目标很多但豆瓣电影有一个很突出的优点一条记录里同时包含评分、评论人数、年份、类型、导演、演员、短评摘要几乎覆盖了数据分析练习需要的所有字段类型。评分和评论数都是数值型适合做分布与相关性分析年份是时间型适合做趋势类型和地区是多标签文本适合做拆分与占比分析。一条数据能同时支撑这么多分析角度这在很多数据源里并不常见。另一个因素是页面结构相对规整列表页和详情页都有清晰的语义标记解析难度适中正好适合初中级 Python 开发者练手。缺点是公开访问有一定限制不能高频抓取所以整个项目把“控制访问频率、做好重试、做增量采集”也当成了核心模块来设计。别小看这一点实际工作中很多采集任务失败都是因为请求策略设计不当。1.3 技术方案选型最终技术栈很常规Python 3 requests BeautifulSoup4 负责采集pandas 负责清洗和聚合MySQL 存储Flask 提供接口ECharts 渲染前端图表。为什么不用 Scrapy对于几千条到几万条的数据量单线程加限速已经完全够用Scrapy 的并发和中间件机制在这里属于过度设计。为什么不用 Matplotlib 直接出图静态图适合写报告但不太适合做多指标联动探索ECharts 的交互缩放和 tooltip 能让人更直观地看到异常点和集中趋势。技术方案的选择逻辑不是“哪个新技术用哪个”而是“数据规模、分析目标、维护成本三者之间取平衡”。这一点在后面每一层的实现中都会体现。2. 数据采集层的设计与实现2.1 字段清单我先列一下最终保留的字段以及每个字段在分析中的用途。字段说明分析用途title电影名称去重、展示rating评分评分分布、趋势votes评论人数热度指标、与评分做相关分析year上映年份年份趋势regions上映地区逗号分隔地区差异分析types电影类型逗号分隔类型偏好分析directors导演后续可做导演作品数量统计actors演员展示、后续可做演员网络分析quote一句话短评展示、后续可做文本分析detail_url详情页链接去重、后续深爬为什么要单独保存 detail_url因为列表页字段并不完整导演、演员、剧情等需要进详情页补全。如果一开始不把 URL 保存下来后面想补数据就只能重新爬一遍代价很大。这个字段在设计阶段容易被忽略但它在增量采集里非常关键。2.2 合规采集与请求策略采集公开数据前先明确边界只抓公开页面不碰登录后内容不在短时间内对服务器造成压力数据仅用于学习研究。这类站点对爬虫并不友好但只要你把请求频率控制在合理范围一般不会有大问题。请求头方面我会准备一个常见 User-Agent 列表每次随机取一个而不是频繁生成看起来“一眼假”的客户端标识。请求间隔设置为 1 到 3 秒随机避免固定间隔形成规律。代码里加一个带重试的请求函数import random import time import requests UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..., ] def fetch_page(url, timeout10, retries3): for attempt in range(retries): headers {User-Agent: random.choice(UA_POOL)} try: resp requests.get(url, headersheaders, timeouttimeout) if resp.status_code 200: return resp.text time.sleep(2 attempt * 2) except requests.RequestException: time.sleep(2 attempt * 2) return None这段代码没有做任何绕过手段核心思想是“慢、稳、可重试”。实际运行下来单线程抓取几千条数据可能需要一两个小时但中断率很低。如果你用并发线程可能半小时跑完但被封概率会高很多。对学习项目来说稳定性远比速度重要。2.3 页面解析与字段抽取我实际采用的是列表页为主、详情页补充的方式。先通过排行榜列表的分页参数采集首页从每个 item 里抽基础字段如果字段不足再访问 detail_url 补齐。列表页的解析示例from bs4 import BeautifulSoup import re def parse_list_item(item): title_tag item.select_one(.title) rating_tag item.select_one(.rating_num) info_text item.select_one(.bd p) quote_tag item.select_one(.quote) title title_tag.text.strip() if title_tag else None rating float(rating_tag.text.strip()) if rating_tag else None quote quote_tag.text.strip() if quote_tag else info info_text.text.strip() if info_text else year_match re.search(r(\d{4}), info) year int(year_match.group(1)) if year_match else None parts [p.strip() for p in info.split(\n) if p.strip()] regions parts[0] if parts else types parts[1] if len(parts) 1 else return { title: title, rating: rating, year: year, regions: regions, types: types, quote: quote, }这里有个细节info 文本的换行和空格很不规律直接 split 会得到很多空字符串所以解析前先做一次过滤。年份、地区、类型经常挤在同一行文本里用正则抓年份比用位置切片更稳定。列表页的解析最简单但也最容易因为页面结构微调而失效所以采集脚本里建议把采集和解析拆成两个文件页面结构变了只改解析模块。注意不要根据浏览器开发者工具里渲染后的 HTML 写选择器要看请求返回的原始 HTML。3. 数据清洗与存储设计3.1 清洗是可视化前最花时间的环节很多人把重点放在爬虫上数据落库后直接画图结果图上一堆“暂无”“nan”。不要这样。我统计过这个项目的数据采集回来的原始记录里至少有 5% 到 10% 存在字段错位、缺失或重复这个比例在页面结构略微变化后会更高。清洗步骤按顺序处理去掉完全重复的记录。判断重复不能只看标题因为不同年份可能拍过同名电影所以唯一性标准是 title year。把 rating 转成 float把 votes 转成 int。处理时用 try/except失败的置为 None。从 info 文本里抽 year如果抽不到再从 detail_url 或详情页里补。regions 和 types 里的中文逗号统一成英文逗号去掉多余空格。对于导演和演员字段去掉空值记录防止前端拼接时报错。代码里最容易被忽略的是类型和地区字段的多标签问题。比如一部电影的类型是“剧情 / 爱情”地区可能有多个如果直接当成一个整体后面做类型分析就非常困难。我在清洗阶段先统一分隔符再在分析阶段做拆分。3.2 存储选型CSV还是MySQL如果是单机学习项目只有几千条数据CSV 完全够用。它的优点是打开方便、不依赖数据库环境适合快速验证。但它的问题也很明显并发读取性能差、不支持类型约束、容易重复写入。项目做到可视化接口这一步我建议至少用 SQLite 或 MySQL。SQLite 部署最轻适合纯本地MySQL 更接近真实工作环境也方便练习 SQL 查询。我用的是 MySQL。原因很实际后期要加“按类型过滤”“按年份范围查询”这类接口直接写 SQL 比每次把 CSV 重新读进来再过滤要清晰。如果你没有数据库环境一开始用 CSV 也没关系清洗完导出 CSV再把 CSV 导入 MySQL 即可。3.3 表结构设计建表语句如下CREATE TABLE movies ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, rating DECIMAL(3, 1), votes INT, year SMALLINT, regions VARCHAR(255), types VARCHAR(255), directors VARCHAR(255), actors VARCHAR(255), quote VARCHAR(255), detail_url VARCHAR(255), crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_title_year (title, year) ) DEFAULT CHARSETutf8mb4;两个关键点一是唯一索引建在 title year 上防止同一部电影被重复插入二是字符集必须用 utf8mb4否则中文名称和特殊符号可能乱码。rating 用 DECIMAL(3,1)既能保留一位小数又比 FLOAT 更适合展示评分避免 7.899999 这种问题。有人会问 types 和 regions 为什么不建关联表。模型上确实应该建但第一版为了控制复杂度先用逗号分隔存一个字段分析时在 pandas 里做拆分。当数据量增长到几万条再做电影类型关联表也不迟。这个取舍属于典型的“先跑通再优化”。4. 可视化分析指标与图表选型4.1 先定义问题再选图表这个项目最容易翻车的地方是把所有图表堆在一个页面上。每个图表都应该对应一个具体问题。我给自己定了五个问题电影评分的整体分布是什么形态平均评分随年份的变化趋势如何哪些电影类型更受口碑认可评论人数和评分之间有没有关系不同地区来源的电影在数量和评分上有什么差异后面所有图表都围绕这五个问题展开。这样做的价值在于你很清楚每一张图想表达什么。如果画完图说不清结论那这张图就是无效的。4.2 五张核心图表的选型与数据准备评分分布用直方图。把 0 到 10 分切成 5 个区间或更细的 10 个区间看频数形态。我原本以为会是正态分布实际数据偏高分端更密集这本身就说明“榜单电影”和“全部电影”是两个分布。数据准备用 pandas.cutbins [0, 2, 4, 6, 8, 10] labels [0-2, 2-4, 4-6, 6-8, 8-10] df[rating_bin] pd.cut(df[rating], binsbins, labelslabels, rightFalse) dist df[rating_bin].value_counts().sort_index()年份趋势用折线图但同时统计每年平均评分和电影数量。单看平均分会误导因为某一年只有一部高评分电影时会拉高均值。我会用 5 年滑动平均yearly df.groupby(year).agg( avg_rating(rating, mean), movie_count(rating, count) ).reset_index() yearly[ma_rating] yearly[avg_rating].rolling(5, min_periods1).mean()类型分析用横向条形图。因为类型名称较长横向条形图能容纳完整标签也更方便比较。一部电影可能属于多个类型先把 types 按逗号拆分再展开type_df df.assign(typedf[types].str.split(,)).explode(type) type_df[type] type_df[type].str.strip() type_rating type_df.groupby(type)[rating].agg([mean, count]).reset_index()评论人数和评分关系用散点图。评论人数跨度大x 轴用对数刻度能更清楚看到不同热度区间的评分分布。地区差异用柱状图展示数量 Top 10再叠加平均评分一张图同时表达体量和口碑两个维度。地区字段同样存在多标签先拆分再做聚合。4.3 可视化页面的实现方式我选了 Flask ECharts 的组合而不是用 Pyecharts 生成静态 HTML。原因有两个一是前后端分离后每个图表的数据由接口提供方便后续换数据源二是 Flask 接口本身就是一个小型数据服务做作品集时更能体现工程能力。页面结构很简单一个 index.html 放多个图表容器通过 fetch 请求 /api 下的数据接口拿到 JSON 后分别渲染。Flask 端的聚合接口示例from flask import Flask, jsonify import pandas as pd app Flask(__name__) app.route(/api/rating_dist) def rating_dist(): dist df[rating_bin].value_counts().sort_index() return jsonify({ labels: dist.index.tolist(), counts: dist.values.tolist() })这里最容易踩的坑是 pandas 返回的 numpy 类型不能直接 JSON 序列化所以接口里统一做一次 tolist()。如果返回的是 DataFrame也要先转成字典。前端渲染我一般只在 window resize 时调用 chart.resize()其他状态管理交给 ECharts 内部处理不额外引入前端框架。5. 核心功能实现与关键代码5.1 数据聚合计算怎么做聚合计算的关键不是会写 groupby而是清楚每一层聚合要解决什么问题。比如趋势图需要“每年平均分”类型图需要“每个类型的平均分”这些都要单独写聚合函数。为了不让接口文件越来越乱我把聚合逻辑整理为 functions.py每个函数返回字典结构def rating_distribution(df): df df.copy() df[rating_bin] pd.cut(df[rating], bins[0,2,4,6,8,10], rightFalse) dist df[rating_bin].value_counts().sort_index() return {labels: dist.index.astype(str).tolist(), counts: dist.values.tolist()} def yearly_trend(df, window5): yearly df.groupby(year)[rating].agg([mean, count]) yearly[ma] yearly[mean].rolling(window, min_periods1).mean() return {years: yearly.index.tolist(), avg: yearly[mean].round(2).tolist(), ma: yearly[ma].round(2).tolist()}建议所有聚合函数都接收 DataFrame 并返回基础类型这样后期不管是接 Flask 还是做命令行输出都不用改逻辑。functions.py 单独文件的好处是可以用 pytest 写几个简单测试比如传入只有两行数据的 DataFrame 验证结果长度这在数据项目里非常有用。5.2 Flask接口和本地运行接口层我做得比较克制只暴露了前端真正需要的五个端点。以 /api/yearly_trend 为例app.route(/api/yearly_trend) def api_yearly_trend(): return jsonify(yearly_trend(DF))启动时读取数据全局变量存一份 DataFrame。项目数据量不超过 5 万条时内存占用完全可以忽略。如果数据量再大再考虑用缓存或数据库查询没必要一上来就引入 Redis。运行入口export FLASK_APPapp.py flask run --host0.0.0.0 --port8000本地访问 / 就能看到可视化页面。部署到云服务器时建议用 gunicorn 跑 Flask并配置 Nginx 做静态文件转发其中一个原因是 ECharts 的 CDN 资源比较大放到本地静态目录能明显提升加载速度。5.3 前端ECharts渲染细节前端页面最需要注意的是数据对齐。接口返回的 labels 和 counts 是一一对应的前端不要自己再去排序否则会出现标签和数值错位。ECharts 的 setOption 支持多次合并如果接口数据刷新直接调用 setOption 即可。评分趋势图的核心配置fetch(/api/yearly_trend) .then(resp resp.json()) .then(data { const chart echarts.init(document.getElementById(trendChart)); chart.setOption({ tooltip: { trigger: axis }, legend: { data: [平均分, 5年均线] }, xAxis: { type: category, data: data.years }, yAxis: { type: value, min: 0, max: 10 }, series: [ { name: 平均分, type: line, data: data.avg }, { name: 5年均线, type: line, data: data.ma, smooth: true } ] }); });这里有一个前端常见的反直觉问题如果接口返回的年份是字符串ECharts 的 category 轴会按数组顺序显示而不是按时间排序。所以后端在聚合时必须先 sort_index() 或 sort_values()保证返回的 years 本身就是升序的。否则前端看到的时间轴是乱的排查时又得从前端角度怀疑半天其实是后端没排序。6. 常见问题与排查技巧实录6.1 问题速查表现象常见原因处理办法请求返回 403/418请求频率过快或请求头不完整降低频率、换标准UA、增加重试退避解析结果全部为空页面结构变化或选择器写错打印 HTML 片段对比 class 名CSV 中文乱码文件编码问题导出时使用 utf-8-sigMySQL 中文乱码表字符集不是 utf8mb4建表指定 utf8mb4连接串也指定ECharts 时间轴乱序后端未排序后端 sort 后再返回JSON 序列化失败numpy 类型无法直接序列化统一转换为 Python 内置类型这张表覆盖了我实际运行中最常遇到的六类问题。其中前三类在采集阶段出现频率最高后三类在展示阶段出现频率最高。6.2 采集阶段的典型问题有一次我跑批量任务前 50 条正常第 60 条开始连续返回 403。排查后发现不是 IP 出了问题而是某一页的 URL 参数被我写错触发了不同类型的响应。后来我在请求函数里加了状态码判断只有 200 才继续解析其他状态码记录到日志并 sleep 更长时间。还有一次解析结果为空但浏览器打开网页正常。原因是页面里某些样式名是动态加载的而请求返回的是静态 HTML。解决方案是把网页源码保存到本地再看一遍结构再调整选择器。永远不要根据浏览器渲染后的 HTML 写爬虫选择器要看请求返回的原始 HTML。6.3 数据处理阶段的典型问题votes 字段在页面展示时可能是“12.3万”这类格式直接 int() 会报错。我的处理方式是先判断是否包含“万”是则转成 float 再乘以 10000。这个问题容易被忽略因为评分转 float 很自然但评论数一旦带上中文单位就会让整列类型变成字符串导致后续排序全乱。MySQL 插入时我遇到过拿 title 做主键判断重复结果同一部电影因为年份不同被误判为重复导致丢掉另一条记录。后来改成 title year 联合唯一键这个问题就消失了。所以去重规则一定要贴近业务语义而不是机械地按某一列判断。7. 项目经验总结与后续扩展7.1 做完后最值得沉淀的几点最大体会是“可视化分析项目的数据流设计比单个算法重要”。从采集、清洗到聚合、展示每一层都像一个漏斗前面漏一点后面就放大。我在清洗阶段花的时间是采集的两倍以上这是一开始没有预料到的。其次是图表要跟着问题走。如果只是把数据都堆上去最后页面虽然好看但说不清任何结论。把五个问题写清楚每一张图才都有存在的理由。数据项目最怕的不是代码写得糙而是不知道自己在回答什么问题。第三点是采集策略要给自己留余地。不要试图一次性抓完所有数据而是把 URL、去重键、采集时间都存下来这样后续增量更新时整个脚本可以复用。我在第一版里没有保存 detail_url后面想补详情数据只能重新跑了一遍这是时间成本最高的一次错误。7.2 后续还能怎么扩展这个项目往后扩展的方向很多。比如在详情页继续采集剧情简介和更完整的短评对短评做情感分析计算不同情感倾向与评分的关系也可以基于导演、演员字段构建合作网络分析创作者之间的关联还可以把抓取任务做成定时任务每天更新榜单形成时间序列数据用同一个 Flask 服务展示“评分排名变化”类图表。如果要把它变成一个可展示的作品集项目我建议把代码分成采集、清洗、分析、展示四个目录写一个 README 说明数据来源、运行方式和分析结论。我个人在反复调整这个项目的过程中体会到“把数据流理顺”带来的收益远大于“试图爬全所有电影”。数据量不在多能把一个小数据集从采集到分析完整跑通才是这个项目最有价值的部分。后面你想放大数据量只需要替换采集源或增加爬取范围整体架构基本不用动。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑