豆瓣电影Top250数据全流程:爬虫、清洗到可视化大屏

豆瓣电影Top250数据全流程:爬虫、清洗到可视化大屏 简介基于Python与Django框架的豆瓣电影数据分析可视化系统设计文档采用B/S架构与MySQL数据库围绕个人中心、电影管理、用户管理和系统管理等模块展开面向对Python/Django开发感兴趣的初学者及互联网数据分析与系统设计从业者。文档以单份docx格式呈现压缩包整体约2.78MB共1个文件内容从摘要、关键词和目录切入开篇即明确系统具备管理员和用户两种角色并系统涵盖第一章概述中的研究背景、研究目的及意义、国内外发展现状、研究内容与论文结构以及第二章开发工具及技术介绍中的Python语言、MySQL数据库、Django框架等随后逐步展开系统开发过程与技术细节。读者可借此掌握豆瓣电影数据的在线化、可视化分析流程理解从需求分析、功能设计到数据库与系统实现的完整链路从而为课程设计、毕业设计或实际项目提供可落地的参照方案。目前已有273人浏览学习对快速了解同类系统的关键技术选型、模块划分与设计思路具有直接参考价值。1. 系统架构与数据获取策略豆瓣电影数据是中文语境下最常被拿来练手的数据集之一。很多人拿到一张Top250表格后直接用Excel画图最后发现能解读的信息很有限。原因在于原始数据里藏着“类型复合”“年份缺失”“评论数单位不统一”等坑。一个更实用的做法是用Python把采集、清洗、分析、展示串成一条流水线先把豆瓣电影Top250详情页的数据抓下来入库后用pandas做特征分析再通过Flask提供JSON接口前端用ECharts渲染成可视化大屏。这套系统适合需要从零构建数据产品的开发者和学生也适合作为数据平台落地的前置原型。2. 豆瓣电影数据采集入库与反爬细节构建系统的第一步是获取数据。常见做法是直接对豆瓣电影的HTML解析而不是依赖未公开的内部接口。因为站点改版时内部接口可能失效而Top250页面结构相对稳定。下面先实现列表页抓取。2.1 用 Requests BeautifulSoup 构造最小爬虫import time import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36, Referer: https://movie.douban.com/, } def fetch_top250_links(): links [] for start in range(0, 250, 25): url fhttps://movie.douban.com/top250?start{start}filter try: resp requests.get(url, headersHEADERS, timeout8) soup BeautifulSoup(resp.text, html.parser) for a in soup.select(div.hd a): links.append(a[href]) except requests.RequestException as e: print(fpage {start} failed: {e}) time.sleep(0.8) return links这段代码每次抓25条共10页。start是分页游标filter是空参数用来保持URL稳定。选择div.hd a是因为Top250列表页中每个条目标题和链接都包在这个容器里。time.sleep(0.8)是为了把请求频率压到每秒1次左右也是后面反爬策略里最有效的一条。如果你发现返回结果被截成验证码页面先检查User-Agent是否过期再检查请求间隔。2.2 反爬字段与请求频率参数表常用请求头和参数可以整理成下面这张表方便在项目里直接对照检查。字段示例值作用备注User-AgentMozilla/5.0 ...声明浏览器环境不要长期使用固定字符串Refererhttps://movie.douban.com/告诉服务器来源页面列表页和详情页来源要一致Accepttext/html,application/xhtmlxml声明可接收内容类型防止返回JSON格式Cookiebidxxxx; ll108288保持会话状态未登录也能用但高频访问会失效对于详情页字段设计我一般会从列表链接再次进入详情页提取title、year、rating、comment_count、genre、director、runtime等字段。这里需要说明为什么要抓详情页而不是直接解析列表页列表页只有评分和一句话短评缺少类型、导演、时长等分析用的关键字段。为了避免给服务器造成压力详情页请求间隔建议控制在1秒以上并且只取Top250里需要的条目不做全站遍历。2.3 数据入库与增量去重数据落库可以使用SQLite、MySQL或MongoDB。对这套系统来说SQLite足够轻量而且Python内置库可以直接连接不会给部署增加额外依赖。表结构设计成下面这样CREATE TABLE IF NOT EXISTS movie ( id INTEGER PRIMARY KEY AUTOINCREMENT, douban_id TEXT UNIQUE, title TEXT, year INTEGER, runtime INTEGER, rating REAL, comment_count INTEGER, genre TEXT, director TEXT, updated_at TEXT );因为数据可能分多次抓取入库时要用douban_id做去重。这里用SQLite的REPLACE INTO只要唯一键冲突就整行覆盖适合固定字段的更新场景。如果你希望保留历史版本可以改成INSERT ON CONFLICT DO UPDATE。import sqlite3 def save_movie_records(records, db_pathdouban.db): conn sqlite3.connect(db_path) sql REPLACE INTO movie (douban_id, title, year, runtime, rating, comment_count, genre, director, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime(now)) conn.executemany(sql, records) conn.commit() conn.close()records是一个元组列表顺序必须与SQL字段顺序一致。这里REPLACE INTO不仅会更新已有行也会删除旧行再插入新行所以如果后续增加了字段需要先用ALTER TABLE补齐列避免丢失数据。抓取完成后可以用SELECT COUNT(*) FROM movie验证数据规模正常是250条。3. 数据清洗与特征分析从原始表到指标表原始数据入库后不能直接给可视化用。最常见的坑有年份字段被解析成字符串、导演名里带着空格、类型是“剧情 / 爱情 / 历史”这样的复合值。所以要先做清洗再形成可供图表使用的聚合指标。3.1 缺失值、重复值与异常值处理import pandas as pd import sqlite3 conn sqlite3.connect(douban.db) df pd.read_sql(SELECT * FROM movie, conn, parse_dates[updated_at]) df df.drop_duplicates(subset[douban_id]) df[year] pd.to_numeric(df[year], errorscoerce) df[rating] pd.to_numeric(df[rating], errorscoerce) df[comment_count] pd.to_numeric(df[comment_count], errorscoerce) df df.dropna(subset[rating, year]) df df[(df[rating] 1) (df[rating] 10)] df[genre] df[genre].fillna(未知)drop_duplicates去掉重复抓取的记录pd.to_numeric把字段统一转数字。豆瓣详情页偶有评论数为空errorscoerce会把无法转换的内容变成NaN之后用dropna丢弃缺少评分或年份的样本。这里不要全表dropna因为genre缺失可以填“未知”导演缺失也不影响年份趋势图。评分过滤到1到10是为了排除脏数据。清洗前后对照可以帮助理解规则字段原始值示例清洗后说明title肖申克的救赎肖申克的救赎不需要处理year19941994转为整数rating9.79.7转为floatcomment_count2259925人评价2259925去掉单位genre剧情 / 爱情剧情,爱情后续拆分这里要说明原始值可能来自不同解析正则所以把清洗规则尽量放在入库前而不是可视化阶段。3.2 评分分布、评论数和年份趋势的统计口径score_bins pd.cut(df[rating], bins[0, 6, 7, 8, 9, 10], labels[0-6, 6-7, 7-8, 8-9, 9-10]) score_dist df.groupby(score_bins, observedFalse).size().reset_index(namecount)为什么分箱而不是直接画直方图因为评分离散度不高Top250评分集中在7.5到9.5之间直接绘图横坐标会拉得很长。分箱之后可以直观看到高分档的样本数量同时也能过滤掉个别异常低分。observedFalse是pandas 2.x的推荐写法避免groupby时把未出现的分箱丢弃。年份趋势通常用“平均分”而非“总分”因为不同年份影片数量不等。计算时还要去掉当年数据量少于3的年份否则某一年只有一部片子平均值波动很大。year_trend ( df.groupby(year)[rating] .agg([mean, count, std]) .dropna() .query(count 3) .reset_index() )这里用agg同时算出均值、数量和标准差。后面可视化的折线图就用mean柱状图用count。如果只保留mean很多年份只因为一两部片子就显得非常突出这也是做趋势分析时最容易误读的地方。3.3 类型拆分和导演维度的聚合豆瓣类型字段是“剧情 / 犯罪 / 悬疑”这种带分隔符的复合值直接groupby会把组合类型当成独立类别导致数据稀疏。常见做法是先用str.split拆分再把每一行展开成多条记录。genre_df df.dropna(subset[genre]).copy() genre_df[genre_list] genre_df[genre].str.split( / ) genre_exploded genre_df.explode(genre_list) genre_exploded[genre_list] genre_exploded[genre_list].str.strip() genre_stats ( genre_exploded.groupby(genre_list) .agg(movie_count(title, count), avg_rating(rating, mean)) .sort_values(movie_count, ascendingFalse) .reset_index() )explode是pandas里拆分列表的标准方法。展开之后一部片子在剧情类型下保留一条在犯罪类型下也保留一条再做聚合就能统计每个单一类型的电影数和平均分。注意拆分后要strip去掉两侧空格否则“剧情”和“剧情 ”会被当成两个类别。导演维度处理方式类似。如果解析详情页时把主演名单混进来就会出现“王家卫 / 梁朝伟”这种脏数据常见做法是只保留第一个值或者用正则过滤掉非导演部分。如果后续要做导演口碑榜我一般会额外抓导演的更多作品而不是只依赖Top250因为Top250里导演出现次数过低。类型统计可以验证上面的逻辑类型电影数平均评分剧情1808.72爱情608.21科幻358.50这个表格是示意不是真实数据但可以用来验证分析逻辑是否成立。4. 可视化系统设计与数据接口实现数据聚合完成之后需要把结果暴露给前端。这里不使用Jupyter Notebook直接展示而是做一个可部署的Web系统。系统的核心不是前端HTML而是后端如何稳定地把DataFrame转成JSON以及前端如何在刷新时保持图表不闪白。4.1 可视化选型ECharts 与 Plotly 的取舍常用可视化库有ECharts、Plotly、Pyecharts、Matplotlib。如果用Python直接输出静态图Matplotlib最方便但交互能力弱Plotly可以生成独立HTML适合报告场景ECharts则适合嵌入Web页面做可视化大屏。下面这个表可以作选型参考库输出方式交互适用场景MatplotlibPNG无报告插图PlotlyHTML/JS有但定制成本高数据分析交付EChartsJS强社区图表多Web可视化大屏PyechartsHTML一般Python快速出图本项目选择Flask提供数据接口前端用ECharts渲染这样的好处是接口复用如果还想再做一个排行榜页面不需要重复写后端逻辑。4.2 Flask 接口设计把 DataFrame 变成 JSON后端接口只做两件事读取聚合结果、返回JSON。为了避免每次请求都重新计算我会在应用启动时把上一步得到的year_trend和genre_stats加载为全局变量接口只做查询不做聚合。from flask import Flask, jsonify import pandas as pd app Flask(__name__) # 全局缓存避免重复读库 year_trend pd.read_csv(year_trend.csv) genre_stats pd.read_csv(genre_stats.csv) app.route(/api/year_rating) def year_rating(): data year_trend[[year, mean, count]].rename( columns{mean: average, count: movie_count} ) return jsonify(data.to_dict(orientrecords)) if __name__ __main__: app.run(host0.0.0.0, port5000)to_dict(orientrecords)把DataFrame转成[{year:1994, average:9.7}, ...]形式这是前端fetch最容易消费的结构。如果你返回了NaNFlask会把它序列化成NaN导致前端解析失败。所以接口返回前要调用data data.dropna()或把NaN替换成None。实际项目里我会统一做data data.where(pd.notnull(data), None)转换。4.3 前端图表渲染与30秒轮询刷新ECharts生态成熟折线图、饼图、地图都有现成配置。核心是让前端自动从接口取数并绘图。const chart echarts.init(document.getElementById(main)); fetch(/api/year_rating) .then((res) res.json()) .then((data) { chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: data.map((d) d.year) }, yAxis: { type: value }, series: [{ name: 平均评分, type: line, data: data.map((d) d.average), smooth: true, }], }); });这段代码把年份作为x轴平均评分作为y轴使用平滑折线展示趋势。setOption是ECharts的合并配置方法数据更新时不必重新初始化实例因此页面不会有闪烁问题。如果你要做可视化大屏可以加一个定时器每30秒请求一次最新数据让前端自动刷新。setInterval(() { fetch(/api/year_rating) .then((res) res.json()) .then((data) { chart.setOption({ series: [{ data: data.map((d) d.average) }] }); }); }, 30000);这个方案的优点是后端不需要推流前端用轮询就能满足“大屏实时刷新”的需求。缺点是如果接口响应超过30秒请求会重叠堆积。所以后面会专门做缓存和部署优化。5. 系统性能优化与部署验证技巧可视化系统真正上线后瓶颈通常不在数据量而在重复计算和数据库连接。豆瓣Top250只有250条但每次请求都重新读库和聚合仍然会造成不必要的IO。优化方向是让数据只在启动时计算一次运行期只读内存。5.1 用内存缓存代替重复查询可以把聚合结果导出成CSV或者直接存到Redis。数据量小的时候CSV是更简单的选择。在Flask启动前将上一章的year_trend和genre_stats从CSV读入全局字典接口只用查询字典不访问数据库。import json from flask import Flask, jsonify app Flask(__name__) with open(year_trend.json, r, encodingutf-8) as f: year_cache json.load(f) app.route(/api/year_rating) def year_rating(): return jsonify(year_cache)这样每次请求都只返回一个Python对象省去DataFrame创建和SQL查询的时间。如果后续要支持多用户并发可以考虑把缓存放到Redis给缓存设置一个过期时间例如EXPIRE movie_cache 86400。5.2 Gunicorn 和 Nginx 的部署配置生产环境不建议使用flask run因为开发服务器是单线程的。常见做法是用Gunicorn启动Flask再在前面加Nginx做反向代理和静态文件服务。gunicorn -w 2 -b 127.0.0.1:5000 app:app-w 2表示开两个Worker进程-b指定监听地址。如果机器只有1核开2个Worker就够开多了反而增加上下文切换。如果担心单个接口出错导致整个进程挂掉可以用--timeout 30限制单个请求的执行时间。Nginx侧只需要把/api/转发到5000端口其他静态资源交给Nginx自己处理。location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }proxy_set_header X-Real-IP用来保留客户端的真实IP方便在Flask日志里排查访问来源。对于静态图表库建议用CDN加载ECharts避免服务器带宽成为瓶颈。5.3 用 curl 验证接口时延和错误状态上线前花一分钟做接口自检能发现不少隐藏问题。用curl带-w参数可以同时拿到HTTP状态码和总耗时。curl -s -o /dev/null -w http_code:%{http_code} time:%{time_total}s\n \ http://127.0.0.1:5000/api/year_rating正常情况下应该看到http_code:200和时间小于0.5秒。如果time_total超过1秒就要检查是不是接口里还在做实时聚合或者数据库连接没有复用。这里把输出写到/dev/null因为只需要耗时统计不需要在终端打印全部JSON。你也可以用curl -I只看请求头但-w提供的信息更全面。同时注意关闭Flask的debug模式否则访问/api/时如果出现异常会直接向客户端返回堆栈信息。设置环境变量FLASK_ENVproduction并确保app.run(debugFalse)是部署前最基本的检查项。如果你使用了定时爬虫建议把数据采集步骤放到独立进程而不是放在Web请求里这样Web接口始终快数据更新失败也不会影响可视化界面。本文还有配套的精品资源点击获取