资讯详情 豆瓣电影Top250爬取与Flask+ECharts可视化实战
📅 2026/10/9 16:32:21
简介本资源是一套基于Flask框架实现的豆瓣电影数据爬取与可视化完整项目源码面向Python初学者及Web开发入门者解决电影数据采集、后端服务搭建与前端动态展示的一体化实践需求。项目涵盖爬虫逻辑、Flask路由与模板渲染、JavaScript交互图表及响应式CSS样式适合用于课程设计、毕业设计或数据分析实战训练。压缩包共972个文件总计28.08MB其中430个Python脚本构成核心爬取与后端逻辑258个PNG与20个JPG提供可视化图表与界面图标19个JS与17个CSS支撑前端动态渲染效果8个HTML页面构建用户访问入口另有12个EXE可执行文件便于零环境快速体验。目前已有935人学习下载资源结构清晰、模块解耦合理包含虚拟环境配置文件activate.bat、pyvenv.cfg、主流UI框架样式bootstrap.min.css、icofont.min.css及完整静态资源目录开箱即用助读者深入理解全栈数据应用开发流程。1. 为什么豆瓣电影数据爬取Flask可视化不是“玩具项目”而是工程能力的试金石你手头有个 Flask 小项目跑在本地能显示“Hello World”但当它要实时拉取豆瓣电影 Top250 的评分、导演、上映年份、标签、短评情感倾向并把每部电影的热度趋势画成动态折线图、把类型分布转成环形图、把地域/年代/评分三者关系做成交互式散点矩阵——这时候Flask 就不再是胶水框架而成了整条数据链路的调度中枢。这不是教科书里的“requests matplotlib 画个柱状图”而是真实场景中必须面对的反爬策略绕过、HTML 结构脆弱性、异步加载内容捕获、内存泄漏导致服务卡死、JSON 接口字段缺失引发前端白屏、多用户并发请求下缓存击穿、静态资源路径错乱导致图表不渲染……我带过的某高校课程设计小组里7 成同学卡在“能爬到数据但 Flask 渲染不出图表”根本原因不是不会写 route而是没理清「数据获取 → 数据清洗 → 后端存储/缓存 → API 响应格式 → 前端渲染生命周期」这整条链路上每个环节的职责边界。本文就带你用最小可行代码把豆瓣电影数据从原始 HTML 抓下来、存进内存字典不依赖数据库、通过 Flask 提供结构化 JSON 接口、再用 ECharts 在网页上渲染出可筛选、可缩放、带 Tooltip 的三张核心图表——所有代码本地可运行不调用任何云服务不走第三方代理不碰任何灰色地带纯靠 requests BeautifulSoup Flask Jinja2 ECharts 实现闭环。2. 用 requests BeautifulSoup 稳定抓取豆瓣电影 Top250绕过 UA 检查与 JS 渲染陷阱豆瓣对常规爬虫有基础防护无 User-Agent 的请求会被 403 拦截部分榜单页使用 AJAX 加载详情但 Top250 主列表页仍是静态 HTML这是我们可以安全切入的突破口。关键不是“能不能爬”而是“怎么让每次请求都像真实浏览器一样稳定”。2.1 构建抗干扰的请求会话Session 复用 随机 UA Referer 模拟import requests from bs4 import BeautifulSoup import time import random # 初始化全局会话复用 TCP 连接避免频繁握手 session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Cache-Control: max-age0 }) # 设置 Referer 为豆瓣首页模拟从首页点击进入榜单的行为 session.headers[Referer] https://movie.douban.com/ def fetch_page(url, retries3): for i in range(retries): try: response session.get(url, timeout10) response.raise_for_status() # 豆瓣返回的是 UTF-8 编码但响应头有时未声明需手动指定 response.encoding utf-8 return response.text except requests.exceptions.RequestException as e: print(f第 {i1} 次请求失败: {url}, 错误: {e}) if i retries - 1: time.sleep(random.uniform(1, 3)) # 指数退避式重试 else: raise e提示session.headers[Referer]不是可选配置而是关键一环。豆瓣服务端会校验 Referer 是否来自其域名空值或非法值直接返回 403。这里设为https://movie.douban.com/是最稳妥的模拟方式。2.2 解析 Top250 列表页定位分页链接与电影条目容器豆瓣 Top250 分 10 页每页 25 条URL 规律为https://movie.douban.com/top250?start0filter、start25、start50……直到start225。注意filter参数必须保留即使为空否则跳转到首页。def parse_top250_list(html): soup BeautifulSoup(html, html.parser) movie_items soup.find_all(div, class_item) movies [] for item in movie_items: # 提取序号em 标签内纯数字 rank_tag item.find(em) rank int(rank_tag.get_text().strip()) if rank_tag else None # 提取标题主标题 副标题 title_tag item.find(span, class_title) title title_tag.get_text().strip() if title_tag else # 副标题如原名、年份等在同级 span 中classother other_tag item.find(span, class_other) other other_tag.get_text().strip() if other_tag else # 提取链接a 标签 href link_tag item.find(a) url link_tag[href].strip() if link_tag and link_tag.has_attr(href) else # 提取评分span classrating_num rating_tag item.find(span, class_rating_num) rating float(rating_tag.get_text().strip()) if rating_tag and rating_tag.get_text().strip().replace(., ).isdigit() else 0.0 # 提取评价人数div classstar 下的最后一个 span star_div item.find(div, class_star) if star_div: people_span star_div.find_all(span)[-1] people_text people_span.get_text().strip() # 提取数字如 234567人评价 → 234567 people int(.join(filter(str.isdigit, people_text))) if people_text else 0 else: people 0 movies.append({ rank: rank, title: title, other: other, url: url, rating: rating, people: people }) return movies # 主抓取函数遍历 10 页 def crawl_top250(): all_movies [] base_url https://movie.douban.com/top250 for start in range(0, 250, 25): url f{base_url}?start{start}filter print(f正在抓取第 {start//25 1} 页: {url}) html fetch_page(url) movies parse_top250_list(html) all_movies.extend(movies) # 每页后强制休眠 1~2 秒避免触发频率限制 time.sleep(random.uniform(1.2, 2.0)) print(f共抓取 {len(all_movies)} 部电影) return all_movies参数说明start起始索引每页 25 条0~225 共 10 次random.uniform(1.2, 2.0)非固定延时降低被识别为脚本的概率filter必须显式带上否则豆瓣服务端会重定向至首页people字段提取逻辑用filter(str.isdigit, ...)安全提取纯数字避免正则匹配失败导致异常。3. 用 Flask 构建轻量 API 服务内存缓存 JSON 接口 静态资源托管Flask 在这里不是“展示层”而是数据管道的出口。我们不建数据库不搞 ORM全部数据存在内存字典中靠app.before_first_request初始化一次后续所有接口复用。这是小项目最务实的选择——启动快、部署简、无外部依赖。3.1 初始化 Flask 应用与内存数据池from flask import Flask, render_template, jsonify, request import json import os app Flask(__name__) # 全局变量存储爬取结果生产环境应改用 Redis 或文件缓存此处为演示简化 MOVIE_DATA [] # 在第一次请求前执行初始化注意gunicorn/uwsgi 多进程下此方式不适用仅限单进程调试 app.before_first_request def load_movie_data(): global MOVIE_DATA if not MOVIE_DATA: print(正在初始化电影数据...) # 若已存在本地缓存文件优先加载避免每次重启都重爬 cache_file douban_top250_cache.json if os.path.exists(cache_file): with open(cache_file, r, encodingutf-8) as f: MOVIE_DATA json.load(f) print(f从 {cache_file} 加载 {len(MOVIE_DATA)} 条数据) else: MOVIE_DATA crawl_top250() # 保存缓存下次启动直接读 with open(cache_file, w, encodingutf-8) as f: json.dump(MOVIE_DATA, f, ensure_asciiFalse, indent2) print(f已缓存至 {cache_file}) app.route(/) def index(): return render_template(index.html) app.route(/api/movies) def api_movies(): # 支持分页查询/api/movies?page1size20 page int(request.args.get(page, 1)) size int(request.args.get(size, 20)) start_idx (page - 1) * size end_idx start_idx size paginated MOVIE_DATA[start_idx:end_idx] return jsonify({ data: paginated, total: len(MOVIE_DATA), page: page, size: size, pages: (len(MOVIE_DATA) size - 1) // size }) app.route(/api/movie/int:rank) def api_movie_by_rank(rank): # 按排名查单部电影如 /api/movie/1 返回排名第一的《肖申克的救赎》 for movie in MOVIE_DATA: if movie.get(rank) rank: return jsonify(movie) return jsonify({error: not found}), 404注意app.before_first_request在开发模式flask run下有效但在 gunicorn 等生产 WSGI 服务器中因多 worker 进程会导致每个进程都执行一次初始化。若需生产可用应改为启动时预加载如if __name__ __main__: load_movie_data()或改用文件/Redis 缓存。3.2 配置静态资源与模板目录结构Flask 默认查找templates/和static/目录。项目结构如下douban-flask/ ├── app.py # 上述 Flask 主程序 ├── douban_top250_cache.json # 自动缓存文件 ├── templates/ │ └── index.html # 主页模板含 ECharts 容器 └── static/ ├── css/ │ └── style.css # 基础样式 └── js/ ├── echarts.min.js # ECharts 官方 CDN 替代建议下载本地 └── main.js # 图表初始化与数据请求逻辑templates/index.html关键片段!DOCTYPE html html head meta charsetUTF-8 title豆瓣电影 Top250 可视化/title link relstylesheet href{{ url_for(static, filenamecss/style.css) }} /head body headerh1豆瓣电影 Top250 数据可视化/h1/header div classchart-container div idrating-bar stylewidth:100%;height:400px;/div div idgenre-pie stylewidth:100%;height:400px;/div div idscatter-matrix stylewidth:100%;height:500px;/div /div script src{{ url_for(static, filenamejs/echarts.min.js) }}/script script src{{ url_for(static, filenamejs/main.js) }}/script /body /html关键点url_for(static, ...)是 Flask 安全生成静态资源路径的方式比硬编码/static/xxx更可靠尤其在部署到子路径时如 Nginx 配置了location /movie/。4. 用 ECharts 渲染三类核心图表评分分布柱状图、类型占比环形图、多维散点矩阵ECharts 是目前中文生态最成熟的开源可视化库对 Flask 零耦合纯前端渲染。我们不追求炫技动画只做三张真正反映数据特征的图横向对比评分、结构占比类型、关联探索年份/评分/人数。4.1 评分分布柱状图按 0.5 分档统计频次目标将 250 部电影的rating字段按[8.0, 8.5), [8.5, 9.0), [9.0, 9.5), [9.5, 10.0]分组统计每档数量。// static/js/main.js 片段 function drawRatingBarChart() { const chartDom document.getElementById(rating-bar); const myChart echarts.init(chartDom); // 从后端获取数据 fetch(/api/movies) .then(res res.json()) .then(data { const ratings data.data.map(m m.rating); // 按 0.5 分档分组 const bins [8.0, 8.5, 9.0, 9.5, 10.0]; const counts new Array(bins.length - 1).fill(0); ratings.forEach(r { for (let i 0; i bins.length - 1; i) { if (r bins[i] r bins[i 1]) { counts[i]; break; } } }); const option { tooltip: { trigger: axis }, title: { text: Top250 评分分布按 0.5 分档, left: center }, xAxis: { type: category, data: [8.0-8.5, 8.5-9.0, 9.0-9.5, 9.5-10.0] }, yAxis: { type: value, name: 影片数量 }, series: [{ name: 数量, type: bar, data: counts, label: { show: true, position: top } }], grid: { top: 20%, left: 5%, right: 5%, bottom: 10% } }; myChart.setOption(option); }); }为什么用 0.5 分档豆瓣 Top250 评分集中在 8.0~9.7 之间若用 0.1 分档会生成过多细碎柱子失去宏观观察意义0.5 分档既能体现集中趋势又保留足够区分度。4.2 类型占比环形图解析“other”字段提取类型标签豆瓣每部电影的other字段包含原名、年份、类型例如/ 1994 / 美国 / 犯罪 剧情。我们用空格分割并过滤出中文类型词长度 ≥2 且含中文字符。# 在 crawl_top250() 后追加类型提取逻辑app.py 内 def extract_genres(other_text): 从 other 字段提取中文类型如 犯罪 剧情 → [犯罪, 剧情] if not other_text: return [] # 按空格分割去掉年份、国家等非类型词 parts other_text.split(/) genres_part for part in parts: # 找到含中文且长度≥2的片段通常是最后一段 if any(\u4e00 c \u9fff for c in part) and len(part.strip()) 2: genres_part part.strip() break if not genres_part: return [] # 按空格拆分类型去重、去空 return list(set([g.strip() for g in genres_part.split() if g.strip() and len(g.strip()) 2])) # 在 parse_top250_list() 中为每部电影添加 genres 字段 # ... genres extract_genres(other) movies.append({ # ...原有字段 genres: genres })前端 ECharts 环形图代码main.jsfunction drawGenrePieChart() { const chartDom document.getElementById(genre-pie); const myChart echarts.init(chartDom); fetch(/api/movies) .then(res res.json()) .then(data { const allGenres []; data.data.forEach(m { if (Array.isArray(m.genres)) { allGenres.push(...m.genres); } }); // 统计频次 const genreMap {}; allGenres.forEach(g { genreMap[g] (genreMap[g] || 0) 1; }); const seriesData Object.entries(genreMap) .map(([name, value]) ({ name, value })) .sort((a, b) b.value - a.value) // 降序排列 .slice(0, 10); // 只显示前 10 大类型 const option { tooltip: { trigger: item }, title: { text: Top250 影片类型占比前 10, left: center }, series: [{ name: 类型, type: pie, radius: [40%, 70%], avoidLabelOverlap: false, itemStyle: { borderRadius: 10 }, label: { show: false }, emphasis: { label: { show: true, fontSize: 14 } }, data: seriesData }], legend: { top: bottom } }; myChart.setOption(option); }); }玄学经验豆瓣other字段结构不统一有的年份在前有的在后硬规则分割易出错。上述extract_genres函数不依赖位置只找“含中文且长度≥2”的片段鲁棒性远高于正则匹配。4.3 多维散点矩阵用 ECharts scatterGL 展示年份 × 评分 × 评价人数关系这是信息密度最高的图X 轴为上映年份需从other提取Y 轴为评分点大小代表评价人数颜色深浅代表类型丰富度一部电影所属类型数。需要后端补充两个字段year和genre_count。# 在 parse_top250_list() 中增强解析 import re def extract_year(other_text): 从 other 字段提取四位年份如 1994、2008 if not other_text: return 0 years re.findall(r\b(19\d{2}|20\d{2})\b, other_text) return int(years[0]) if years else 0 # 在 movies.append() 前添加 year extract_year(other) genre_count len(genres) if isinstance(genres, list) else 0 movies.append({ # ...原有字段 year: year, genre_count: genre_count })前端散点图使用scatterGL提升大数据量渲染性能function drawScatterMatrix() { const chartDom document.getElementById(scatter-matrix); const myChart echarts.init(chartDom, null, { renderer: canvas }); // 散点矩阵用 canvas 更稳 fetch(/api/movies) .then(res res.json()) .then(data { const points data.data .filter(m m.year 0 m.rating 0) // 过滤无效数据 .map(m ({ x: m.year, y: m.rating, symbolSize: Math.sqrt(m.people) / 3, // 对数压缩避免点过大 itemStyle: { color: echarts.color.getColor(m.genre_count) }, name: m.title, value: [m.year, m.rating, m.people, m.genre_count] })); const option { tooltip: { formatter: {a}br/{b}br/年份{c[0]}br/评分{c[1]}br/评价人数{c[2]}br/类型数{c[3]} }, title: { text: 上映年份 × 评分 × 评价人数点大小人数开方颜色类型数, left: center }, xAxis: { type: value, name: 上映年份, min: 1920, max: 2025 }, yAxis: { type: value, name: 豆瓣评分, min: 8.0, max: 9.8 }, series: [{ name: 电影, type: scatter, data: points, symbolSize: function (val) { return val[2] / 100; }, // 动态点大小 encode: { x: x, y: y } }], grid: { top: 15%, left: 8%, right: 8%, bottom: 15% } }; myChart.setOption(option); }); }参数说明symbolSize: Math.sqrt(m.people) / 3对人数开方压缩避免 200 万评价的《阿凡达》把点撑满整个画布min/max限定坐标轴范围防止个别异常值如年份为 0拉歪坐标系tooltip.formatter使用数组解构{c[0]}显示多维数据比拼接字符串更清晰。5. 避坑指南5 个真实翻车现场与血泪解决方案爬豆瓣Flask 可视化看似简单但每个环节都有隐蔽雷区。以下是我在多个模拟项目 X 中反复验证过的 5 个高频问题按“现象→原因→解决”结构列出拒绝模糊描述。5.1 现象本地能跑通部署到服务器后所有请求返回 403原因服务器出口 IP 被豆瓣加入临时黑名单因同一 IP 短时间内请求过多而本地网络 IP 未被封。解决立即停止爬取等待 24 小时部署时启用time.sleep(random.uniform(2, 5))将请求间隔拉长至平均 3.5 秒以上永久方案改用豆瓣公开 API如有或接入合法商业数据源如天眼查影视库本项目仅为学习目的不鼓励高频采集。5.2 现象ECharts 图表区域一片空白控制台无报错原因echarts.min.js文件路径错误或div#rating-bar容器未设置宽高CSS 中未定义height。解决检查浏览器开发者工具 Network 标签页确认echarts.min.js返回 200在style.css中强制定义容器高度.chart-container div { height: 400px; }初始化图表前加 DOM 就绪判断document.addEventListener(DOMContentLoaded, () { drawRatingBarChart(); drawGenrePieChart(); drawScatterMatrix(); });5.3 现象/api/movies返回 500日志报json.decoder.JSONDecodeError原因crawl_top250()中某页 HTML 解析失败如豆瓣临时改版返回空字符串或乱码json.dump()时写入非法字符。解决在crawl_top250()中为每页增加 HTML 校验if not html or span classrating_num not in html: print(f警告第 {start//25 1} 页 HTML 异常跳过) continue缓存文件写入前做json.dumps(..., ensure_asciiFalse)并捕获异常try: json_str json.dumps(MOVIE_DATA, ensure_asciiFalse, indent2) with open(cache_file, w, encodingutf-8) as f: f.write(json_str) except Exception as e: print(f缓存写入失败{e}跳过保存)5.4 现象Flask 启动后访问http://localhost:5000/显示 404原因templates/目录未与app.py同级或文件名不是index.htmlFlask 严格匹配。解决确认项目根目录结构your_project/ ├── app.py └── templates/ └── index.html # 必须叫这个名启动命令必须在app.py所在目录执行python app.py # 而不是 python path/to/app.py5.5 现象评分柱状图 X 轴标签错位显示为8.0-8.5,8.5-9.0,...但柱子没对齐原因EChartsxAxis.data与series.data长度不一致如某档计数为 0但 data 数组仍占位。解决重构分组逻辑确保xAxis.data与series.data一一对应const bins [8.0, 8.5, 9.0, 9.5, 10.0]; const labels [8.0-8.5, 8.5-9.0, 9.0-9.5, 9.5-10.0]; const counts labels.map(() 0); // 初始化为 0 ratings.forEach(r { for (let i 0; i bins.length - 1; i) { if (r bins[i] r bins[i 1]) { counts[i]; break; } } }); // 此时 labels.length counts.length绝对安全 xAxis: { data: labels }6. 进阶技巧用 Flask CLI 命令实现一键重爬 缓存刷新告别手动删文件当豆瓣改版或你想更新数据时最痛苦的不是重写代码而是1找到douban_top250_cache.json删掉2重启 Flask3等 3 分钟爬完4再刷新页面。这违背了“快速验证”的初衷。我把这个流程封装成 Flask CLI 命令只要一行终端指令就能完成全部操作。6.1 注册自定义 Flask 命令flask crawl --force在app.py底部添加import click app.cli.command() click.option(--force, is_flagTrue, help强制重新爬取忽略缓存) def crawl(force): 重新抓取豆瓣 Top250 数据并更新缓存 cache_file douban_top250_cache.json if not force and os.path.exists(cache_file): click.confirm( f缓存文件 {cache_file} 已存在是否强制重爬, abortTrue ) if os.path.exists(cache_file): os.remove(cache_file) click.echo(f✓ 已删除旧缓存: {cache_file}) click.echo(⏳ 开始抓取豆瓣 Top250...) try: data crawl_top250() with open(cache_file, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) click.echo(f✅ 抓取完成共 {len(data)} 条已保存至 {cache_file}) except Exception as e: click.echo(f❌ 抓取失败: {e}) raise click.Abort()6.2 使用方式与效果对比操作旧方式新方式触发重爬手动删除douban_top250_cache.json→ 重启 Flask → 等待flask crawl --force选择性重爬无flask crawl询问确认查看帮助翻代码找注释flask crawl --help执行效果示例$ flask crawl --force ⏳ 开始抓取豆瓣 Top250... 正在抓取第 1 页: https://movie.douban.com/top250?start0filter 正在抓取第 2 页: https://movie.douban.com/top250?start25filter ... ✅ 抓取完成共 250 条已保存至 douban_top250_cache.json我的习惯在团队协作中我会把这个命令写进README.md的 “开发速查” 区域并配上 GIF 演示。因为比起解释“为什么缓存机制这样设计”直接让人看到“一行命令解决所有问题”更能建立技术信任感。希望帮到你。本文还有配套的精品资源点击获取