ARTICLE DETAIL

资讯详情

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

豆瓣电影爬虫与Spark数据分析可视化:毕业设计全流程实践

豆瓣电影爬虫与Spark数据分析可视化:毕业设计全流程实践 简介这是一份基于豆瓣电影数据的毕业设计源码案例面向大数据、爬虫与可视化方向的高校学生和开发者完整覆盖从数据采集、Spark 处理分析到结果展示的流程。压缩包共241个文件大小约5.6MB主要包含Java源码、XML配置、CSS样式、HTML页面以及SQL脚本其中Java和XML构成项目主体CSS/HTML用于前端展示SQL文件对应Spark分析脚本适合用于课程设计、毕业设计或大数据入门实践。目前已有202人学习下载资源内还包含分词工具类、统计类等class文件及部分运行结果数据可帮助理解数据清洗、评分统计与可视化实现的细节。1. 基于豆瓣电影爬虫与Spark数据分析可视化毕业设计的完整落地方案豆瓣电影的数据集几乎是为大数据课程设计量身定制的结构化程度高、评分数值分布有规律、条目维度丰富且爬取难度适中。把爬虫、Spark、可视化三件事串成一个完整项目核心价值不是“爬了多少条”而是证明你理解了一条数据从生产到决策的完整链路采集层怎么设计、存储层怎么选型、计算层怎么用分布式思想处理真实数据、展示层怎么让结果有说服力。这篇文基于“基于豆瓣电影爬虫及Spark数据分析可视化设计”这个标题把毕业设计从环境搭建到答辩演示的完整路径讲清楚。你会看到用 requests BeautifulSoup 的轻量爬虫如何应对豆瓣的反爬策略用 PySpark 的 DataFrame API 做用户评分与电影类型之间的关联分析再用 Flask ECharts 把分析结果做成可交互的可视化页面。整体方案不依赖 Hadoop 集群单机 Spark 即可跑完适合大多数本科毕业设计的硬件条件。2. 爬虫模块设计豆瓣电影 Top250 与详情页的数据抓取方案2.1 采集范围与字段定义先确定分析需要哪些列豆瓣电影可供爬取的数据分为列表页和详情页两层。列表页比如 Top250、分类浏览提供电影名、评分、评价人数、封面图和一句话短评详情页则包含导演、编剧、主演、类型、制片国家/地区、语言、上映日期、片长、又名、IMDb 链接以及更详细的剧情简介和用户短评。做毕业设计不建议爬全站数据量过大反而处理不了首选 Top250 或某个分类下的前几百部。字段设计需要兼顾两个约束一是 Spark 分析时的“可算性”二是可视化页面的“可展示性”。我建议至少采集以下字段字段名来源页面类型分析用途movie_id详情页URLString去重与关联title列表页String展示rating列表页Float数值分布分析rating_count列表页Int热度分析director详情页String导演维度的聚合actors详情页Array演员合作网络可选genres详情页Array类型占比与交叉分析release_date详情页Date时间趋势分析runtime详情页Int时长与评分的关系language详情页String制片地区/语言偏好summary详情页Text可选 NLP 词频分析提示豆瓣的列表页 HTML 结构相对稳定但详情页存在部分字段缺失如纪录片没有片长、老电影没有 IMDb 链接爬虫必须做空值容错否则一条解析异常会导致整个线程退出。2.2 单页解析requests BeautifulSoup 的稳定写法列表页的解析是整个项目的基石。以 Top250 页面为例每页 25 条共 10 页URL 的 start 参数翻页0、25、50...。豆瓣的分页逻辑是固定规律用 requests.get 拿到 HTML 后BeautifulSoup 按 CSS 选择器抽数据。import requests import time import pandas as pd from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_list_page(start0): url fhttps://movie.douban.com/top250?start{start}filter resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) movies [] for item in soup.select(div.item): title_tag item.select_one(span.title) title title_tag.text if title_tag else N/A rating_tag item.select_one(span.rating_num) rating float(rating_tag.text) if rating_tag else 0.0 count_tag item.select_one(div.star span:last-child) count_str count_tag.text if count_tag else 0人评价 rating_count int(.join(filter(str.isdigit, count_str)) or 0) quote_tag item.select_one(p.quote span) quote quote_tag.text if quote_tag else link_tag item.select_one(a) detail_url link_tag[href] if link_tag else movie_id detail_url.split(/)[-2] if detail_url else movies.append({ movie_id: movie_id, title: title, rating: rating, rating_count: rating_count, quote: quote, detail_url: detail_url, }) return movies这段代码里需要注意三个细节。select_one(div.star span:last-child)拿到的是“xx人评价”整段文字用filter(str.isdigit)提取纯数字再转 int避免正则表达式的额外开销。movie_id从详情页 URL 中截取格式是https://movie.douban.com/subject/1292052/倒数第二个/之间的数字串就是唯一 ID。标题字段有个易漏点豆瓣同时存在span.title主标题和span.other副标题通常是外文名或别名只取主标题会让后续分析缺少部分信息但字段冗余了又会干扰去重逻辑建议列表页只保留主标题详情页再补副标题。2.3 并发控制与反爬应对不要让 IP 被封豆瓣没有公开的 API防爬策略主要体现在请求频率限制和 User-Agent 校验上。刚启动爬虫时频繁请求会收到 418 或 403触发后需要在页面上输入验证码才能恢复。设计爬虫时要把“限速”当作核心功能而不是副作用。import threading import random from queue import Queue import requests request_queue Queue() result_list [] lock threading.Lock() def worker(worker_id, base_headers): while not request_queue.empty(): try: start_offset request_queue.get(timeout3) # 随机延迟 1.5 ~ 3.5 秒模拟人工浏览节奏 time.sleep(random.uniform(1.5, 3.5)) headers base_headers.copy() headers[User-Agent] random.choice(USER_AGENT_POOL) resp requests.get( fhttps://movie.douban.com/top250?start{start_offset}filter, headersheaders, timeout10 ) if resp.status_code 418: print(f[worker-{worker_id}] 疑似触发反爬请求被拒绝) request_queue.put(start_offset) # 重新入队 time.sleep(30) # 冷却后重试 continue movies fetch_list_page_with_soup(resp.text) with lock: result_list.extend(movies) print(f[worker-{worker_id}] start{start_offset} 完成累计 {len(result_list)} 条) except Exception as exc: print(f[worker-{worker_id}] 请求异常: {exc}) finally: request_queue.task_done() # 初始化任务队列0, 25, 50, ..., 225 共 10 页 for offset in range(0, 250, 25): request_queue.put(offset)并发线程数建议控制在 2 到 4 个不要超过 4。线程池的意义不是加速而是带宽不够时能续上——单线程请求时如果一次超时就得重来整个队列多线程可以让单页失败不影响其他页。User-Agent 池里放 5 个桌面浏览器的 UA每次请求随机选一个避免单一 UA 被豆瓣高频标记。418 状态码是反爬触发的明确信号这时不要继续请求等冷却时间结束再重试。注意requests 默认的 Session 不保存 cookies豆瓣某些页面会下发bid或gr_user_idcookie第一次请求拿到后再带回去能降低被拦截的概率。建议用同一 Session 实例做所有请求保持 cookie 一致。2.4 数据落盘CSV 还是 JSON Lines爬完的数据有两种落盘方式选型取决于 Spark 后续从哪里读。CSV 适合人眼检查和导入 Excel但类型信息丢失数字会被当成字符串JSON Lines每行一个 JSON 对象更接近 K-V 结构Spark 的spark.read.json()能自动推断 schema不建议把数据存成单行超长 JSON。默认导出 JSON Lines同时额外输出一份 CSV 用于答辩时展示“原始数据长什么样”。import json with open(douban_movies.jsonl, w, encodingutf-8) as f: for movie in result_list: f.write(json.dumps(movie, ensure_asciiFalse) \n) # 导出 CSV 便于查看 df pd.DataFrame(result_list) df.to_csv(douban_movies.csv, indexFalse, encodingutf-8-sig) print(f采集完成共 {len(result_list)} 条已保存为 JSONL 和 CSV)3. 数据清洗与存储从 JSONL 到 Spark DataFrame 的准备3.1 PySpark 读取 JSONL 的 schema 坑Spark 读取 JSON 文件时会自动推断类型但推断逻辑基于首条数据。如果首条记录的字段缺失或类型不对后续数据解析就会报错。比如之前爬虫逻辑里rating_count可能是 0空字符串转出来JSON Lines 里记录的就是0Spark 推断成 int 没问题。但release_date如果某条是空字符串推断就会变成 string后续转换日期还得处理。为了避免这种情况建议显式定义 schema不用自动推断from pyspark.sql.types import StructType, StructField, StringType, IntegerType, FloatType, ArrayType movie_schema StructType([ StructField(movie_id, StringType(), True), StructField(title, StringType(), True), StructField(rating, FloatType(), True), StructField(rating_count, IntegerType(), True), StructField(quote, StringType(), True), StructField(director, StringType(), True), StructField(genres, ArrayType(StringType()), True), StructField(release_date, StringType(), True), # 先读成字符串 StructField(runtime, IntegerType(), True), StructField(language, StringType(), True), StructField(summary, StringType(), True), ]) df spark.read.json(douban_movies.jsonl, schemamovie_schema) df.printSchema() df.show(5, truncateFalse)显式 schema 的另一个好处是能尽早发现数据质量问题如果某个字段在 JSON 里不存在Spark 会填null而不是报错如果字段名拼写错误Spark 不会将其映射到 schema 中而是直接丢弃并生成_corrupt_record列。清洗前先执行一次质量统计from pyspark.sql.functions import col, isnan, when, count df.select( [count(when(col(c).isNull(), c)).alias(c) for c in df.columns] ).show()3.2 清洗逻辑空值、去重、类型转换三板斧豆瓣数据有几类不干净的地方director为空有些条目被删、rating_count为 0表示没有评分数、runtime为 0片长未知。针对毕业设计的分析场景建议执行以下清洗策略from pyspark.sql.functions import col, trim, split, to_date, regexp_replace # 去掉完全重复的记录按 movie_id 去重保留第一条 df_cleaned df.dropDuplicates([movie_id]) # rating_count 为 0 的视为无效热度数据保留但做标记 df_cleaned df_cleaned.withColumn( hot_flag, when(col(rating_count) 0, 1).otherwise(0) ) # 清洗 director 字段去掉多余空格和换行符 df_cleaned df_cleaned.withColumn( director, trim(regexp_replace(col(director), r[\r\n], )) ) # 把 release_date 字符串转成真实日期类型转换失败置为 null df_cleaned df_cleaned.withColumn( release_date_parsed, to_date(col(release_date), yyyy-MM-dd) ) # 过滤掉完全没有评分的记录对分析没有价值 df_cleaned df_cleaned.filter(col(rating) 0)清洗时有个容易忽略的点genres数组字段里豆瓣使用的是“剧情”“喜剧”“动作”等中文标签如果存储时误存成了字符串剧情,喜剧需要先用split切分。建议在爬虫阶段就直接把 genres 处理成数组Spark 端只做验证不要依赖 Spark 做昂贵的中文括号处理和切分。3.3 存储选型Parquet 是 Spark 分析的最优解清洗完的数据直接给 Spark 做分析当然可以但如果要把中间结果保存下来CSV 会丢失 schema 信息JSON 体积大且读取慢推荐使用 Parquet 列式存储。Parquet 在 Spark 的 DataFrame 读写中有两个优势列式压缩后磁盘占用减少约 60%-70%Spark 的 Catalyst 优化器可以只读取需要的列显著加速聚合操作。df_cleaned.write.mode(overwrite).parquet(hdfs:///user/douban/cleaned_movies.parquet) # 单机模式可以用本地路径 # df_cleaned.write.mode(overwrite).parquet(file:///home/user/movies/cleaned.parquet)Parquet 文件是目录结构_SUCCESS文件表示写入完成.parquet后缀的文件是实际数据块。Spark 读取时只需指定目录位置不用关心内部有几个文件。4. Spark 数据分析评分、类型、导演的关联洞察4.1 评分分布与 TopN 电影基础聚合拿到清洗后的 DataFrame第一个能出成果的分析是“评分分布直方图”。用bucketizer把评分按 0.5 分一档分桶然后统计每个桶的电影数量和平均评价人数。from pyspark.ml.feature import Bucketizer from pyspark.sql.functions import col, count, avg, desc # 评分区间拆桶0.5 分一档 bucketizer Bucketizer( splits[0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], inputColrating, outputColscore_bucket ) df_bucketed bucketizer.transform(df_cleaned) # 每个桶内统计数量和平均评价人数 df_bucket_stats df_bucketed.groupBy(score_bucket).agg( count(*).alias(movie_count), avg(rating_count).alias(avg_rating_count) ).orderBy(score_bucket) df_bucket_stats.show(10)Bucketizer的splits参数指定分桶边界必须是单调递增数组且小于最小值的放第 0 桶大于等于最大值的放最后一桶。如果你只写[0.0, 5.0]所有评分都会归到同一桶分桶就失去意义了。输出结果里score_bucket是 0 到 9 的索引可视化时再映射回具体的评分范围。4.2 多字段展开explode 处理 genres 数组genres是数组字段要统计“哪种类型电影数量最多”必须先explode把数组拆成行。注意explode之后的行数会成倍增加如果原始数据有 500 部电影平均每部 2.5 个类型展开后约 1250 行后续聚合前要确认中间结果的规模可接受。from pyspark.sql.functions import explode, col df_exploded df_cleaned.select( col(movie_id), col(title), col(rating), col(rating_count), explode(col(genres)).alias(genre) ) df_genre_stats df_exploded.groupBy(genre).agg( count(*).alias(movie_count), avg(rating).alias(avg_rating), avg(rating_count).alias(avg_rating_count) ).orderBy(desc(movie_count)) df_genre_stats.show(20)这里可以做一个比较有意思的交叉分析不同类型的平均评分和平均评价人数。通常文艺片剧情、爱情平均评分高但评价人数少商业片动作、冒险评分略低但热度高。毕业设计答辩时这个对比能直观体现“数据叙事”的能力。4.3 导演产量与评分关系Top 导演竞争力分析分析导演维度时需要处理“一人多片”的情况。Spark 的groupBy(director)能把同一个导演的 3 部电影聚合到一行再用avg(rating)求平均分count算产量。from pyspark.sql.functions import count, avg, desc df_director_stats df_cleaned.filter(col(director) ! ).groupBy(director).agg( count(*).alias(movie_count), avg(rating).alias(avg_rating), avg(rating_count).alias(avg_hot) ).filter(col(movie_count) 2) # 过滤只拍过 1 部的导演 .orderBy(desc(movie_count), desc(avg_rating)) df_director_stats.show(15)过滤条件是关键的拍过 1 部电影且评分 9.0 的导演数据量不足以代表任何竞争力至少 2 部以上才谈得上稳定。聚合之后avg_rating保留 4 位小数展示时如果需要精度控制用round()函数处理。4.4 年份与地区趋势分析时间序列的角度以下为热点维度分析。豆瓣电影的上映日期跨度大从 1930 年代到 2023 年数据量分布不均匀。分析时先year()提取年份再统计每十年的电影产量变化。from pyspark.sql.functions import year, floor, col, count df_with_year df_cleaned.withColumn(release_year, year(col(release_date_parsed))) df_with_decade df_with_year.withColumn( decade, floor(col(release_year) / 10) * 10 ) df_decade_stats df_with_decade.filter(col(release_year).isNotNull()).groupBy(decade).agg( count(*).alias(movie_count), avg(rating).alias(avg_rating) ).orderBy(decade) df_decade_stats.show(20)这个统计能产出一张“不同年代电影平均评分”的折线图通常 1990 年代之前的条目较少但评分高2010 年后条目多但评分下降这个现象可以解释为年代越久远的电影经过时间淘汰后留下的都是经典而近期的电影包含大量未经筛选的普通作品导致评分均值被拉低。这个结论在答辩时可以作为数据洞察亮点。5. 可视化与交互系统Flask ECharts 的产出物5.1 架构决策为什么不用 Jupyter Notebook很多人做毕业设计会在 Jupyter 里画 matplotlib 图表直接交差但这种方式有两个问题一是图片无法动态交互答辩时没办法让评委输入参数二是没有系统感分析过程散落在多个 cell 里不像一个完整项目。常见做法是把 Spark 分析结果导出成聚合后的数据文件然后搭建一个简化的 Web 系统前端用 ECharts 渲染交互图表。后端用 Flask 的好处是轻量、路由简单、和 Python 技术栈天然衔接。整个流程是Spark 聚类 → 结果存入 MySQL 或 JSON 文件 → Flask 提供 API → ECharts 异步拉取数据绘图。5.2 数据导出与 API 设计把 Spark 聚合结果写到 MySQL 或 JSON 文件方式取决于可视化查询的频率。如果只做 4 个固定图表直接导出 JSON 效率最高不需要额外维护数据库服务。这里给出导出 JSON 的方式import json # 类型统计结果导出 genre_data df_genre_stats.toPandas().to_dict(orientrecords) with open(web/static/api/genre_stats.json, w, encodingutf-8) as f: json.dump(genre_data, f, ensure_asciiFalse, indent2) # 导演统计结果导出只保留 Top 20 director_data df_director_stats.limit(20).toPandas().to_dict(orientrecords) with open(web/static/api/director_stats.json, w, encodingutf-8) as f: json.dump(director_data, f, ensure_asciiFalse, indent2)Flask 端只需要写一个路由来返回这些 JSON 文件from flask import Flask, jsonify, send_from_directory import os app Flask(__name__) API_DIR os.path.join(app.root_path, static, api) app.route(/api/genres) def api_genres(): return send_from_directory(API_DIR, genre_stats.json, mimetypeapplication/json) app.route(/api/directors) def api_directors(): return send_from_directory(API_DIR, director_stats.json, mimetypeapplication/json) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)5.3 ECharts 前端图表配置评分分布与类型占比前端页面放在templates/index.html用原生 HTML ECharts CDN 加载。核心图表是评分分布柱状图和类型占比环形图。!-- templates/index.html 片段 -- div idratingChart stylewidth: 100%; height: 400px;/div div idgenreChart stylewidth: 100%; height: 400px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script fetch(/api/rating_buckets) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(ratingChart)); chart.setOption({ title: { text: 豆瓣电影评分分布 }, tooltip: { trigger: axis }, xAxis: { type: category, data: data.map(item ${item.range}), // 例如 8.5~9.0 name: 评分区间 }, yAxis: { type: value, name: 电影数量 }, series: [{ type: bar, data: data.map(item item.movie_count), itemStyle: { color: #e67e22 } }] }); }); /script这里的rating_buckets数据需要在 Spark 端把score_bucket0~9 索引映射成可读的区间字符串比如“8.0~8.5”否则前端展示的数字用户看不懂。提示ECharts 图表要有至少一个交互维度才能在答辩时加分最常见的是 tooltip 悬停显示明细、legend 切换显示分类、dataZoom 缩放。类型占比图用环形 legend 切换评分分布图用柱状图 dataZoom 拖动这两样能显著提升演示的“完成度感”。5.4 可视化大屏布局一页集成 4 张图毕业设计答辩时可视化页面建议控制在一屏内展示核心结论。常见布局是顶部标题栏 评分分布柱状图左 类型占比环形图右 导演 TOP10 横向柱状图左下 年代评分趋势折线图右下。用 CSS Grid 或 Flex 布局把整个页面控制在 1366 分辨率内无需滚动画面完整度会显得更高。6. 性能调优与答辩要点的 3 个实践细节6.1 Spark 内存优化单机跑大数据集的参数配置毕业设计通常用本地 Sparklocal[*]但默认内存设置经常导致 shuffle 阶段 OOM。以下是常用的 SparkSession 配置模板能覆盖大多数情况。from pyspark.sql import SparkSession spark SparkSession.builder \ .appName(DoubanMovieAnalysis) \ .master(local[*]) \ .config(spark.sql.shuffle.partitions, 10) \ .config(spark.executor.memory, 4g) \ .config(spark.driver.memory, 4g) \ .config(spark.memory.offHeap.enabled, true) \ .config(spark.memory.offHeap.size, 2g) \ .getOrCreate()spark.sql.shuffle.partitions是重点参数。默认值 200 意味着每次 shuffle 会产生 200 个小文件在单机小数据集下反而浪费调成 10 能显著降低调度开销。executor.memory和driver.memory的值不要超过物理内存的 70%否则系统会因内存交换变慢。offHeap配置可以绕过 JVM GC 的限制但只有在频繁出现 Full GC 时才需要开启。6.2 数据量小但 Spark 慢回调分区与广播变量如果最终数据集只有几百部电影Spark 的性能优势完全体现不出来。这时需要的不是 Spark 调优而是分析结构的调整。用广播变量把 DataFrame 映射成小的 RDD 做自定义计算或者直接在 DataFrame 上用repartition(1)聚合后再输出避免生成多个小文件。# 将分区数控制在合理范围 df_cleaned df_cleaned.repartition(4) # 缓存中间结果避免重复计算 df_cached df_cleaned.cache() df_cached.count() # 触发缓存6.3 答辩演示时的“陷阱”与应对爬虫断点续爬和数据分析结论闭环答辩时最怕被问到的问题是“爬虫挂了怎么办”和“分析结果说明了什么”。针对第一个问题建议在爬虫代码中加入断点续爬逻辑启动时先读取本地已有的movie_id列表跳过已经抓取过的 URL。针对第二个问题建议准备 1 到 2 个明确的结论比如“评分在 8.5 分以上的电影中剧情片占比超过 60%且平均评价人数约为普通类型片的 3 倍”这种结论可以配合可视化图表直接回答评委的追问。# 断点续爬读取已有数据里的 movie_id 集合 import os if os.path.exists(douban_movies.jsonl): existing_ids set() with open(douban_movies.jsonl, r, encodingutf-8) as f: for line in f: try: existing_ids.add(json.loads(line)[movie_id]) except json.JSONDecodeError: continue print(f已加载 {len(existing_ids)} 条现有数据)还有一个容易被忽略的细节整个项目提交时代码和 PPT 里不要只呈现“爬到了多少条数据”要展示从清洗到分析再到可视化的大屏跳转路径。可读性好、工程规范完整比数据量大更有说服力。每一层的数据流关系爬虫 → JSONL → Spark → Parquet → JSON File → ECharts可以用一个简单的数据流图在答辩时展示这比代码截图更直观。本文还有配套的精品资源点击获取
返回列表