ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

豆瓣电影爬虫可视化系统:Python+Flask+ECharts全栈实现与反爬解析

豆瓣电影爬虫可视化系统:Python+Flask+ECharts全栈实现与反爬解析 简介基于Python与Flask开发的豆瓣电影爬虫采集与分析可视化系统毕业设计源码面向计算机相关专业学生适合用于毕业设计、期末大作业、课程设计等场景。项目难度适中评审分达到98分本地编译可运行内容经助教老师审定可帮助学习者快速理解并复现一个真实完整的爬虫可视化项目涵盖数据采集、清洗、存储、分析及展示的核心链路。压缩包共110个文件以Python源码py、Flask模板html、CSS/JS前端资源及数据库文件db/xls为主整体约6.25MB目录结构清晰便于按模块定位和复用代码。目前已有241人学习下载参考价值得到初步验证。下载后可直接运行体验既能对照源码掌握requests爬虫解析、Flask路由设计、数据可视化图表生成等具体实现也能在此基础上扩展新功能高效支撑毕业设计或课程作业的完成。1. 豆瓣电影爬虫可视化系统拿高分毕设源码前先把这几件事想清楚做毕设最怕的不是不会写代码而是把别人的项目跑起来后答辩时被问一句“这个反爬怎么处理的”就卡壳。这套基于 Python Flask 的豆瓣电影爬虫采集与分析可视化系统源码本身是能直接跑的但我拆过之后建议你把它当成一套“动手实验教材”来看而不是直接交上去的成品。它覆盖了从 requests 采集、数据清洗入库到 Flask 后端接口和 ECharts 可视化大屏的完整链路难度适中正好卡在课程设计和毕设之间。适合两类人一是想快速跑通一个全栈爬虫项目、了解完整工程结构的学生二是想拿现成源码做二次开发、替换数据源或增加爬虫维度的开发者。如果你连 requests 和 Flask 都没写过那这份资源反而是个很不错的入门跳板。核心思路就一句话把豆瓣电影榜单数据抓下来、存进数据库再通过 Web 页面把排行、评分、上映年份这些维度可视化展示出来。2. 爬虫采集层拆解requests 请求与页面解析的取舍2.1 为什么选 requests 正则解析而不是 Scrapy拿到源码后先别急着跑把 collector 模块打开读一遍。这个系统用的是 requests 做 HTTP 请求配合正则或 BeautifulSoup 做页面解析没用 Scrapy 框架。这个选择对毕设来说反而是合理的Scrapy 的工程规范更多但调试链路长你为了解释一个 selector 写错要翻半天日志requests 的请求流程是直线式的代码贴出来导师一眼就能看懂。我一般会建议你保留 requests 方案但把请求头补全模拟真实浏览器环境。代码里常见的写法是这样import requests from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/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, Referer: https://movie.douban.com/ } def fetch_page(url): try: response requests.get(url, headersheaders, timeout10) response.raise_for_status() response.encoding utf-8 return response.text except requests.RequestException as e: print(f[ERROR] 请求失败: {e}) return None这段代码的逻辑分三层headers 伪装浏览器身份timeout 防止某个页面卡死整个爬虫raise_for_status 把 404/500 这类状态码直接抛出来避免拿半截 HTML 去解析。参数里需要你重点关注的是 User-Agent豆瓣对非浏览器的 UA 识别很敏感很多初次爬取失败都是 UA 没设对。2.2 解析策略优先选 BeautifulSoup正则只做兜底很多毕设项目喜欢到处用正则看着很厉害但维护起来是真头疼。这个项目的解析部分值得学的是它区分了“结构化定位”和“兜底提取”两种场景。榜单页面的 li 标签结构稳定用 BeautifulSoup字段缺失时才用正则补漏。from bs4 import BeautifulSoup import re def parse_movie_list(html): soup BeautifulSoup(html, lxml) movies [] for item in soup.select(.grid_view .item): title_tag item.select_one(.title) rating_tag item.select_one(.rating_num) quote_tag item.select_one(.quote .inq) title title_tag.text.strip() if title_tag else 未知 rating float(rating_tag.text.strip()) if rating_tag else 0.0 # 部分条目没有一句话评价用正则从详情链接里兜底提取 detail_url item.select_one(a)[href] if item.select_one(a) else quote quote_tag.text.strip() if quote_tag else movies.append({ title: title, rating: rating, quote: quote, detail_url: detail_url }) return movies这里的逻辑核心是每个字段都做了空值兜底title 没有就给“未知”rating 没有就给 0.0。这个习惯在你后面做数据可视化时特别重要——前端图表拿到 NaN 直接会空白而 0.0 至少能正常渲染。lxml 解析速度比 html.parser 快不少数据量大以后差距明显。2.3 翻页控制与请求间隔访问频率的边界在哪豆瓣榜单列表有分页每页 25 条翻页逻辑不能写死成 range(10)因为不同榜单的页数不一定一样。正确做法是先解析总条数再算出总页数动态决定请求次数。def crawl_top250(base_url): all_movies [] # 每页25条先请求第一页拿总数 html fetch_page(base_url) soup BeautifulSoup(html, lxml) total_text soup.select_one(.total).text if soup.select_one(.total) else total re.search(r(\d), total_text) total_count int(total.group(1)) if total else 250 total_pages (total_count 24) // 25 for page in range(total_pages): page_url f{base_url}?start{page * 25}filter page_html fetch_page(page_url) if page_html: page_movies parse_movie_list(page_html) all_movies.extend(page_movies) print(f[INFO] 第{page 1}页完成累计{len(all_movies)}条) time.sleep(random.uniform(2, 5)) # 随机间隔避免请求频率固定被抓特征 return all_moviesrandom.uniform(2, 5) 这段是防反爬的关键细节。固定 sleep(3) 的请求间隔在服务端日志里是等距出现的容易被识别成脚本随机间隔虽然不能完全规避但至少没有明显的机器特征。这里还有一个隐藏逻辑——每页请求前重新调用 fetch_page而不是复用第一次的响应对象因为 URL 变了必须发新请求。2.4 榜单详情页的数据补充采集到底采到哪一层很多初学者做完列表页就收工了但豆瓣电影的真正价值在详情页。这套系统如果只采到评分和标题做可视化时维度会很薄。详情页至少可以补出导演、主演、上映日期、片长、类型这五个字段。建议你按这个顺序扩展采集逻辑先确认详情页 URL 能稳定访问再解析导演和主演字段最后才考虑片长和类型。def parse_movie_detail(detail_url): html fetch_page(detail_url) if not html: return {} soup BeautifulSoup(html, lxml) detail {} # 导演/主演 directors soup.select(a[relv:directedBy]) detail[director] directors[0].text if directors else actors soup.select(a[relv:starring]) detail[actors] / .join([a.text for a in actors[:3]]) # 上映日期和片长 date_tag soup.select_one(span[propertyv:initialReleaseDate]) detail[release_date] date_tag[content] if date_tag and date_tag.has_attr(content) else runtime_tag soup.select_one(span[propertyv:runtime]) detail[runtime] runtime_tag.text.replace(分钟, ).strip() if runtime_tag else return detail这里的 v:directedBy 和 v:starring 是豆瓣页面的微数据属性比用 class 定位稳定性高因为豆瓣改版大概率动样式不动微数据结构。这个经验可以迁移到其他站点——优先找微数据或 JSON-LD 这类语义化标记而不是死磕 class 名。2.5 反爬的初级应对Cookie 保持与异常重试豆瓣最常触发的是 418 状态码遇到这个说明请求被识别了。解决方案有两种一是过一段时间再试二是加强伪装。国产电影榜单和国外电影榜单用的反爬策略还不一样这套源码里如果有 cookie 处理逻辑建议保留。推荐你加一段异常重试封装from tenacity import retry, stop_after_attempt, wait_random retry(stopstop_after_attempt(3), waitwait_random(min1, max3)) def fetch_with_retry(url, max_retries3): html fetch_page(url) if html is None: raise ValueError(f请求异常: {url}) return htmltenacity 的重试策略里stop_after_attempt 控制最多试几次wait_random 控制重试间隔。这个比手写 while 循环要干净答辩时提一句“用重试机制处理偶发反爬拦截”会让导师觉得你对异常处理有意识。3. 数据持久化层SQLite 起步还是 MySQL 稳妥3.1 为什么开发环境用 SQLite生产部署再切 MySQL这套毕设的存储层一般选 SQLite 或 MySQL源码里大概率用的是 SQLAlchemy ORM所以数据库切换成本很低。SQLite 的优点是零配置、单文件、复制即备份适合开发调试MySQL 的优点是并发写入强适合真正部署上线。我的建议是开发阶段直接用 SQLite答辩演示完再换 MySQL既省事又能展示你懂数据库迁移。3.2 数据表设计评分、年份、类型三个维度的建模数据库建模决定了可视化能画什么图。以豆瓣电影 Top250 为例最少需要两张表movie 主表和 movie_type 类型关联表。movie 表存标题、评分、导演、主演、年份、片长这些字段movie_type 表存电影类型因为一部电影多个类型多对多关系比逗号拼接更规范。CREATE TABLE IF NOT EXISTS movie ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(255) NOT NULL, rating FLOAT DEFAULT 0.0, director VARCHAR(255), actors VARCHAR(500), release_year INTEGER, release_date VARCHAR(20), runtime INTEGER, quote TEXT, detail_url VARCHAR(500), UNIQUE(title, release_date) ); CREATE TABLE IF NOT EXISTS movie_type ( id INTEGER PRIMARY KEY AUTOINCREMENT, movie_id INTEGER NOT NULL, type_name VARCHAR(50) NOT NULL, FOREIGN KEY (movie_id) REFERENCES movie(id) );这里 UNIQUE 约束很重要它保证了重复运行爬虫不会插入两遍同一部电影。第二次跑数据时ORM 会报 IntegrityError你可以用 session.merge() 代替 session.add()或者先查重再插入。release_year 字段是为了年份趋势图单独抽出来的直接 type 转换的会有格式兼容问题所以单独存整数最保险。3.3 SQLAlchemy ORM 的写入性能优化逐条 commit 的写法在毕设里很常见但数据量过千条后速度会明显变慢。SQLAlchemy 提供了 bulk_save_objects 接口可以一次批量提交。常见的批量写入写法是这样from sqlalchemy.orm import sessionmaker from sqlalchemy.exc import IntegrityError from models import Movie, engine Session sessionmaker(bindengine) session Session() def bulk_import_movies(movie_list): objects [] for item in movie_list: movie Movie( titleitem[title], ratingitem[rating], directoritem.get(director, ), actorsitem.get(actors, ), release_yearitem.get(release_year, 0), runtimeitem.get(runtime, 0), quoteitem.get(quote, ), detail_urlitem.get(detail_url, ) ) objects.append(movie) try: session.bulk_save_objects(objects) session.commit() print(f[INFO] 批量写入 {len(objects)} 条数据) except IntegrityError: session.rollback() print([WARN] 部分数据重复已回滚)bulk_save_objects 跳过了 ORM 的完整生命周期检查速度比逐条 add 快好几倍。但要注意它不触发数据库默认值填充之外的事件钩子所以字段默认值必须在 Python 侧设好。IntegrityError 的 rollback 也是必须的否则 session 会进入脏状态后续所有查询都会报错。4. Flask 后端与数据可视化从数据库到图表大屏的完整链路4.1 Flask 应用结构与接口分层这套系统的 Web 层是 Flask 驱动的建议你不要把所有路由写在一个文件里按蓝图拆分会清晰很多。典型结构是 app.py 做应用入口blueprints/ 目录下挂 movie_api 和 page_view 两个蓝图前者提供 JSON 数据接口后者渲染页面模板。from flask import Flask, jsonify, render_template from models import Movie, MovieType, session app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/movies/top_ratings) def top_ratings(): movies session.query(Movie).order_by(Movie.rating.desc()).limit(20).all() result [{title: m.title, rating: m.rating} for m in movies] return jsonify(result) app.route(/api/movies/year_distribution) def year_distribution(): movies session.query(Movie.release_year, func.count(Movie.id)).group_by(Movie.release_year).all() result [{year: y, count: c} for y, c in movies] return jsonify(result)这个接口设计的关键是“把 SQL 聚合的结果直接转成 JSON 数组”前端拿到后不用二次处理。要注意 session 的作用域Flask 多线程环境下每个请求需要独立的 session否则会出现“session is already closed”的报错。常见做法是用 Flask-SQLAlchemy 的 scoped_session 而不是裸的 sessionmaker。4.2 ECharts 可视化评分分布、年份趋势与类型占比可视化部分是这套系统的门面答辩时导师第一眼看的也是图表效果。ECharts 是前端可视化的事实标准配置项在网上很好查但递到 Flask 模板里时要注意数据格式与前端预期格式的匹配。评分分布图用直方图最能说明问题年份趋势用折线图类型占比用饼图。// 评分分布直方图 const ratingChart echarts.init(document.getElementById(ratingChart)); fetch(/api/movies/rating_distribution) .then(res res.json()) .then(data { ratingChart.setOption({ title: { text: 电影评分分布, left: center }, tooltip: { trigger: axis }, xAxis: { type: category, data: data.map(d d.range) }, yAxis: { type: value, name: 部数 }, series: [{ type: bar, data: data.map(d d.count), itemStyle: { color: #4A90E2 } }] }); });这段代码的要点是 xAxis 的 data 和 series 的 data 都来自同一个接口返回的不同字段前端不做任何转换。很多毕设翻车就翻在这里——后端返回的是 [{rating: 8.5, count: 12}] 这种结构前端却 index 取了 range 字段结果全是空值。建议后端在建 JSON 时字段名直接对齐图表配置项。4.3 模板渲染与静态资源的组织方式源码的 CSS 清单里有 bootstrap.min.css、animate.css、icofont.min.css、aos.css 这些文件说明前端模板用了 Bootstrap 布局 AOS 滚动动画 iconfont 图标。静态资源的组织方式建议按 Flask 默认约定放在 static/ 目录下模板里通过 url_for(static, filenamecss/bootstrap.min.css) 引用而不是写死相对路径。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title豆瓣电影数据分析/title link relstylesheet href{{ url_for(static, filenamecss/bootstrap.min.css) }} link relstylesheet href{{ url_for(static, filenamecss/aos.css) }} script src{{ url_for(static, filenamejs/echarts.min.js) }}/script /head body div classcontainer-fluid h1 classtext-center mt-4豆瓣电影Top250数据分析/h1 div idratingChart stylewidth: 100%; height: 400px;/div /div script src{{ url_for(static, filenamejs/main.js) }}/script /body /html4.4 数据看板的布局套路网格卡片式设计可视化大屏的常见布局是顶部标题栏 中部图表卡片 底部滚动信息。Bootstrap 的栅格系统在这里派上用场col-md-6 放两张主图col-md-4 放三张次要图。AOS 动画库的引入方式也很关键aos.js 初始化要在 DOM 加载完成后执行AOS.init({ duration: 800, easing: ease-out-back, once: true });参数说明duration 控制动画时长单位毫秒easing 控制动画曲线once 表示只在首次滚动到该区域时播放一次。这个库的坑在于它依赖元素自身有>if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)不要把 app.run 裸露在模块顶层应该放在 ifname main 里。否则你导入 app 时就会启动 Flask 服务这在写单元测试时会导致端口冲突。5. 避坑与常见问题排查跑这套源码必须知道的六个大坑5.1 反复弹 418 状态码请求被识别为脚本现象爬虫连续跑几分钟后返回的 HTML 从正常的电影列表变成“请求异常”提示页抓到的内容是空白或验证码页。原因豆瓣的反爬策略会在发现高频访问后临时封禁 IP最典型的标志是 HTTP 418。请求量一上来再好的 UA 也没用。解决第一请求频率降到 3-6 秒一个请求加 random.uniform 抖动第二加 cookie 保持用 requests.Session() 保持会话首次访问拿到 cookie 后再带 cookie 请求列表页第三如果还不行试用简单的代理池轮换 IP。但不要过度依赖 IP 轮换毕设场景下控制频率才是根本解法。5.2 数据入库后中文乱码页面显示成问号现象爬虫在终端打印中文正常但存入 SQLite 后通过 Flask 查询返回的是乱码或问号。原因数据库连接字符串里没有指定 utf8mb4 编码SQLAlchemy 连接 SQLite 默认不强制编码。解决SQLite 默认编码是 UTF-8但 MySQL 必须显式声明。连接串改成 mysqlpymysql://root:passwordlocalhost/douban?charsetutf8mb4。另外建表时也要加上 DEFAULT CHARSETutf8mb4。5.3 ECharts 图表加载不出来控制台报 getContext is null现象页面能打开但图表区域空白浏览器控制台报“Cannot read properties of null”。原因div 容器尚未渲染完成JS 就去执行 echarts.init拿到的是 null 节点。解决script 放在 body 底部或包在 window.onload 里。最常见做法是放在模板底部并用自己的初始化函数。5.4 Flask 返回的 JSON 接口数据是空数组现象接口能访问但返回 []页面图表不出数据。原因数据库连接正常但查询条件拼错了或者爬虫没采到数据表和数据库是空的。另一种可能是模型表的类名和实际表名没对齐。解决先确认数据是否入库。用 SQLite 命令行或 DB Browser 打开库文件执行 SELECT COUNT(*) FROM movie; 看条数。如果条数为 0问题在采集层而不在展示层。5.5 爬虫运行一段时间后内存飙升现象循环爬取过程中内存使用持续上涨最后系统卡顿。原因每次请求的 response 对象没释放列表数据只追加不清空或者在列表页爬完一页后把整页 HTML 保存在变量里没有及时置空。解决每页处理完把 html 变量设为 None每 50 条数据批量写库一次并清空内存中的列表。5.6 部署到服务器后页面样式丢失现象本地开发一切正常部署到 Linux 服务器后 CSS、JS 全部加载失败。原因Nginx 配置没把 /static/ 路径代理到 Flask 的 static 目录。Flask 默认 static 目录相对 app.py 所在目录部署时路径变了就找不到了。解决推荐用 Gunicorn Nginx 方式部署Nginx 里配置 location /static/ 直接指向静态文件目录动态请求才转发给 Gunicorn。6. 从脚本到系统数据采集任务的模块化改造技巧很多毕设交付时只有一个爬虫脚本跑一次就完这在评审时会被扣分。更值得花时间的改造方向是把爬虫任务模块化做成可配置、可重跑、可调度的数据管道。我最推荐的一个做法是给爬虫加一个配置文件把目标 URL、请求头、采集字段、页面解析规则全部抽出来代码和数据描述分离import configparser config configparser.ConfigParser() config.read(config.ini) base_url config.get(crawler, base_url) request_interval config.getfloat(crawler, request_interval) headless config.getboolean(crawler, headless)config.ini 文件里写着[crawler] base_url https://movie.douban.com/top250 request_interval 3.5 page_size 25 headless False这个技巧的实战价值在于答辩时导师如果问“爬取频率怎么调”你直接改配置文件就行不用改代码。另一个比较实用的改造方向是加增量更新把已采集的 detail_url 存到一张 visited 表每次跑任务先查这张表只采没见过的电影。这样重复跑任务不会产生重复数据展示出来的工程意识比一次性脚本强很多。CREATE TABLE IF NOT EXISTS visited_urls ( detail_url VARCHAR(500) PRIMARY KEY, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );爬虫采集和可视化系统做完后建议再验证一手完整链路清空数据库 → 运行爬虫 → 启动 Flask → 打开浏览器访问首页 → 逐一检查图表是否有数据。这个流程我每次改完代码都会强制走一遍从那以后再也没有在答辩现场出现空白图表的翻车情况。这套源码的价值不只是跑通那么简单把每一层模块拆开看一遍你会比直接抄代码的同学多理解很多东西。希望帮到你。本文还有配套的精品资源点击获取
返回列表