豆瓣电影 Top250 爬虫与数据可视化大屏:开发演进日志
本文档记录了“豆瓣电影 Top250 数据大屏项目”从最初的简单爬虫脚本,经过数次迭代与重构,最终演变为全自动 Web 交互式数据大屏的完整开发过程。

引入
大一的我因为还没有学习爬虫和数据分析,在听说有这个项目后,于是我决定先自学一下,我用一天肝了两个实战项目
1.小红书评论爬取
我的实战项目参考:zheco68-collab/-: 小红书爬虫实战
2.携程景点评论爬取
我的实战项目参考:zheco68-collab/-_DrissionPage: 基于DrissionPage的携程景点评论实战爬取
经过两个实战项目的沉淀,我大致懂了爬虫的逻辑, 1.通过模拟浏览器或者真实浏览器对真人操作经行模拟 2.分析接口字段和json 3.用数据结构对接口数据进行存储 4.保存到csv或者要使用的文件中,值得一提的是下面我会将爬出的数据保存到html中
阶段一:基础数据抓取与持久化 (CSV)
目标:获取豆瓣 Top250 的基础数据,并保存为本地可读文件。 过程:
初始代码分析:项目最初基于
requests和BeautifulSoup,只能将数据打印在控制台。""" 获取时光影评电影评分排行榜 """ import requests from bs4 import BeautifulSoup from datetime import datetime, timedelta # 获得指定开始排行的电影url def get_url(root_url,start): return root_url+"?start="+str(start)+"&filter=" def get_review(page_url): movies_list=[] headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } response=requests.get(page_url, headers=headers) soup=BeautifulSoup(response.text,"lxml") soup=soup.find('ol','grid_view') for tag_li in soup.find_all('li'): dict={} dict['rank']=tag_li.find('em').string dict['name']=tag_li.find_all('span','title')[0].string dict['score']=tag_li.find('span','rating_num').string if(tag_li.find('span','inq')): dict['desc']=tag_li.find('span','inq').string movies_list.append(dict) return movies_list if __name__ == "__main__": root_url="https://movie.douban.com/top250" start=0 while(start<250): movies_list=get_review(get_url(root_url,start)) for movie_dict in movies_list: print('电影排名:'+movie_dict['rank']) print('电影名称:'+movie_dict.get('name')) print('电影评分:'+movie_dict.get('score')) print('电影评词:'+movie_dict.get('desc','无评词')) print('------------------------------------------------------') start+=25引入 CSV 模块:为了进行后续分析,增加了
csv模块,将抓取的数据写入本地文件。解决乱码问题:在写入文件时,特意指定了
encoding='utf-8-sig',确保在使用 Microsoft Excel 打开 CSV 文件时不会出现中文乱码。
阶段二:引入 Pandas 与基础静态图表 (Matplotlib)
目标:对抓取到的数据进行简单的统计分析,并生成直观的图表。 过程:
扩展数据维度:为了让图表不至于单调,在爬虫逻辑中增加了对“上映年份 (Year)”的提取。
生成静态图表:引入
pandas进行数据清洗和聚合,使用matplotlib绘制了“评分分布柱状图”和“年代排行条形图”。踩坑与修复 (KeyError):在此阶段遇到了著名的
KeyError: 'year'报错。原因是旧版 CSV 缺失该列,且中英文表头读取不一致。通过统一内部使用英文表头(写入和读取对齐)并加入容错逻辑,彻底解决了历史遗留数据导致的崩溃问题。中文字体适配:加入了针对 Windows/macOS/Linux 的跨平台中文字体兼容代码,防止 Matplotlib 图表中的中文变成“方块”。
""" 获取豆瓣电影 Top250,保存为 CSV,并进行数据分析图表生成 """ import requests from bs4 import BeautifulSoup import csv import pandas as pd import matplotlib.pyplot as plt import platform import os # --- 1. 爬虫与数据采集部分 --- def get_url(root_url, start): return f"{root_url}?start={start}&filter=" def get_review(page_url): movies_list = [] headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } response = requests.get(page_url, headers=headers) soup = BeautifulSoup(response.text, "lxml") soup = soup.find('ol', 'grid_view') if not soup: return movies_list for tag_li in soup.find_all('li'): movie = {} movie['rank'] = tag_li.find('em').string movie['name'] = tag_li.find_all('span', 'title')[0].string movie['score'] = tag_li.find('span', 'rating_num').string # 尝试抓取年份 bd_p = tag_li.find('div', 'bd').find('p') if bd_p: texts = bd_p.text.strip().split('\n') if len(texts) >= 2: # 豆瓣的年份信息通常在第二行,用 '/' 分隔 movie['year'] = texts[1].split('/')[0].strip() else: movie['year'] = '未知' else: movie['year'] = '未知' if tag_li.find('span', 'inq'): movie['desc'] = tag_li.find('span', 'inq').string else: movie['desc'] = '无评词' movies_list.append(movie) return movies_list def scrape_and_save(csv_filename): root_url = "https://movie.douban.com/top250" start = 0 print("开始爬取数据...") with open(csv_filename, mode='w', encoding='utf-8-sig', newline='') as f: fieldnames = ['rank', 'name', 'year', 'score', 'desc'] writer = csv.DictWriter(f, fieldnames=fieldnames) # 修复:写入英文表头,使得 Pandas 能够通过 'year' 和 'score' 正确读取列 writer.writeheader() while start < 250: movies_list = get_review(get_url(root_url, start)) for movie_dict in movies_list: writer.writerow(movie_dict) start += 25 print(f"已获取 {start} 条数据...") print(f"爬取完成!数据已保存至 {csv_filename}\n") # --- 2. 数据分析与图表生成部分 --- def set_chinese_font(): """设置 matplotlib 的中文字体,防止图表中的中文显示为方块""" system = platform.system() if system == 'Windows': plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] # Windows系统 elif system == 'Darwin': plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'PingFang SC'] # macOS系统 else: plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei'] # Linux系统 plt.rcParams['axes.unicode_minus'] = False # 正常显示负号 def analyze_and_plot(csv_filename): print("开始进行数据分析并生成多维图表...") # 1. 读取 CSV 数据 try: df = pd.read_csv(csv_filename) except FileNotFoundError: print("未找到 CSV 文件,请先执行爬虫获取数据!") return # 修复:增加兼容性处理,如果读取到包含“年份”的中文字段,自动重命名为英文字段 if '年份' in df.columns: df.rename(columns={'排名': 'rank', '电影名称': 'name', '年份': 'year', '评分': 'score', '评词': 'desc'}, inplace=True) # 检查数据中是否包含 'year' 列,防止读取了旧版 CSV 导致崩溃 if 'year' not in df.columns: print("\n 错误:在 CSV 文件中没有找到 'year' 列!") return # 清洗数据:提取年份中的纯数字(去掉可能的地区后缀),并将评分转为浮点数 df['year'] = df['year'].astype(str).str.extract(r'(\d{4})') df['year'] = pd.to_numeric(df['year'], errors='coerce') # 转为数字以方便区间划分 df['score'] = pd.to_numeric(df['score']) # 过滤掉无法转换为数字的无效年份数据 df = df.dropna(subset=['year']) set_chinese_font() # 应用内置的绘图风格作为基底 plt.style.use('ggplot') # 创建一个 2行2列 的画板,设置画板背景颜色和更宽敞的尺寸 fig, axes = plt.subplots(2, 2, figsize=(20, 14), facecolor='#F4F6F9') # 添加全局大标题 fig.suptitle('豆瓣电影 Top250 多维度数据分析报告', fontsize=28, fontweight='bold', color='#2C3E50', y=0.98) # --------------------------------------------------------- # 图表 1: 豆瓣Top250电影评分分布 (柱状图 - 左上) # --------------------------------------------------------- ax1 = axes[0, 0] score_counts = df['score'].value_counts().sort_index() colors_left = plt.cm.Blues([0.4 + 0.6 * i / len(score_counts) for i in range(len(score_counts))]) bars1 = ax1.bar(score_counts.index.astype(str), score_counts.values, color=colors_left, width=0.6, zorder=3, edgecolor='none') ax1.set_title('豆瓣 Top250:电影评分分布', fontsize=18, color='#34495E', pad=15, fontweight='bold') ax1.set_xlabel('电影评分', fontsize=14, color='#7F8C8D', fontweight='bold') ax1.set_ylabel('电影数量(部)', fontsize=14, color='#7F8C8D', fontweight='bold') ax1.tick_params(axis='x', rotation=45, colors='#34495E', labelsize=12) ax1.tick_params(axis='y', colors='#34495E', labelsize=12) ax1.set_facecolor('#FFFFFF') ax1.grid(axis='y', linestyle='--', alpha=0.5, color='#BDC3C7', zorder=0) ax1.grid(axis='x', visible=False) for spine in ax1.spines.values(): spine.set_visible(False) for bar in bars1: height = bar.get_height() ax1.annotate(f'{height}', xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 4), textcoords="offset points", ha='center', va='bottom', fontsize=12, color='#2C3E50', fontweight='bold') # --------------------------------------------------------- # 图表 2: 豆瓣Top250电影上榜最多的年份 Top15 (水平条形图 - 右上) # --------------------------------------------------------- ax2 = axes[0, 1] year_counts = df['year'].value_counts().head(15).sort_values() colors_right = plt.cm.Oranges([0.8 - 0.4 * i / len(year_counts) for i in range(len(year_counts))]) bars2 = ax2.barh(year_counts.index.astype(int).astype(str), year_counts.values, color=colors_right, height=0.6, zorder=3, edgecolor='none') ax2.set_title('豆瓣 Top250:上榜数量最多的年份 Top15', fontsize=18, color='#34495E', pad=15, fontweight='bold') ax2.set_xlabel('电影数量(部)', fontsize=14, color='#7F8C8D', fontweight='bold') ax2.set_ylabel('上映年份', fontsize=14, color='#7F8C8D', fontweight='bold') ax2.tick_params(axis='both', colors='#34495E', labelsize=12) ax2.set_facecolor('#FFFFFF') ax2.grid(axis='x', linestyle='--', alpha=0.5, color='#BDC3C7', zorder=0) ax2.grid(axis='y', visible=False) for spine in ax2.spines.values(): spine.set_visible(False) for bar in bars2: width = bar.get_width() ax2.text(width + 0.3, bar.get_y() + bar.get_height()/2, f'{int(width)}', va='center', fontsize=12, color='#2C3E50', fontweight='bold') # --------------------------------------------------------- # 图表 3: 电影所属年代分布占比 (甜甜圈环形图 - 左下) # --------------------------------------------------------- ax3 = axes[1, 0] # 划分年代区间 bins = [1900, 1980, 1990, 2000, 2010, 2030] labels = ['80年代前', '80年代', '90年代', '00年代', '10年代及以后'] df['decade'] = pd.cut(df['year'], bins=bins, labels=labels, right=False) decade_counts = df['decade'].value_counts() # 配色方案,使用柔和的色调 pie_colors = ['#FF9999', '#66B2FF', '#99FF99', '#FFCC99', '#c2c2f0'] # 绘制饼图 wedges, texts, autotexts = ax3.pie( decade_counts.values, labels=decade_counts.index, autopct='%1.1f%%', startangle=140, colors=pie_colors, pctdistance=0.85, # 调整百分比数字的位置 textprops=dict(color="w", weight="bold", fontsize=12), wedgeprops=dict(width=0.4, edgecolor='w') # 设置 width 实现环形图效果 ) # 调整外部标签的样式 for text in texts: text.set_color('#34495E') text.set_fontsize(13) text.set_weight('bold') ax3.set_title('豆瓣 Top250:经典电影年代分布', fontsize=18, color='#34495E', pad=15, fontweight='bold') ax3.set_facecolor('#FFFFFF') # --------------------------------------------------------- # 图表 4: 评分随时间变化的平滑趋势 (折线分布图 - 右下) # --------------------------------------------------------- ax4 = axes[1, 1] # 按年份分组,计算平均评分和每年上榜数量 year_group = df.groupby('year').agg({'score': ['mean', 'count']}).reset_index() year_group.columns = ['year', 'avg_score', 'count'] # 过滤掉数量太少的年份(比如只有1部电影的年份),避免平均分被个例带偏,使趋势更具代表性 valid_years = year_group[year_group['count'] >= 2].sort_values('year') # 绘制平滑折线图(带有数据标记点) ax4.plot(valid_years['year'], valid_years['avg_score'], color='#8E44AD', marker='o', markersize=6, linestyle='-', linewidth=2, zorder=3) # 填充折线图下方面积,增加视觉层次感 ax4.fill_between(valid_years['year'], valid_years['avg_score'], 8.0, color='#8E44AD', alpha=0.1, zorder=2) ax4.set_title('豆瓣 Top250:高上榜率年份的平均评分趋势', fontsize=18, color='#34495E', pad=15, fontweight='bold') ax4.set_xlabel('上映年份 (仅统计上榜2部及以上的年份)', fontsize=14, color='#7F8C8D', fontweight='bold') ax4.set_ylabel('平均评分', fontsize=14, color='#7F8C8D', fontweight='bold') # 设置 y 轴范围,让波动看起来更合理 ax4.set_ylim(8.0, 10.0) ax4.tick_params(axis='both', colors='#34495E', labelsize=12) ax4.set_facecolor('#FFFFFF') ax4.grid(axis='both', linestyle='--', alpha=0.5, color='#BDC3C7', zorder=0) for spine in ax4.spines.values(): spine.set_visible(False) # --------------------------------------------------------- # 调整布局与保存 # --------------------------------------------------------- plt.tight_layout(rect=[0.02, 0.02, 0.98, 0.95]) # 调整外边距 output_img = 'douban_analysis_charts_multidimensional.png' plt.savefig(output_img, dpi=300, bbox_inches='tight', facecolor=fig.get_facecolor()) print(f"多维图表已生成并保存为精美图片:{output_img}") plt.show() if __name__ == "__main__": csv_file = "douban_top250.csv" # 执行步骤1:爬取并保存 CSV scrape_and_save(csv_file) # 执行步骤2:读取 CSV 并生成数据图表 analyze_and_plot(csv_file)
阶段三:视觉重构与 Web 大屏化 (ECharts)
目标:解决 Matplotlib 图表“不够精美”、缺乏交互的问题。 过程:
技术栈跃迁:决定抛弃传统的静态图片生成方式,转向前端数据可视化领域的标准库 Apache ECharts。
构建 HTML 模板:使用 CSS Grid 布局设计了一个暗夜/明亮风格的现代 Dashboard 框架,包含圆角卡片、柔和阴影和渐变色设计。
Python 与 JS 桥接:利用 Python 的
json模块,将 Pandas 计算好的聚合数据(如各年代电影数量、评分平均值)转换为 JSON 格式,直接注入到 HTML 模板的 JavaScript 变量中。自动化体验:加入
webbrowser模块,使得 Python 脚本在处理完数据、生成 HTML 后,能够自动唤起用户的默认浏览器展示大屏,实现了“一键生成并预览”的流畅体验。""" 获取豆瓣电影 Top250,保存为 CSV,并进行数据分析图表生成 """ import requests from bs4 import BeautifulSoup import csv import pandas as pd import json import webbrowser import os # --- 1. 爬虫与数据采集部分 --- def get_url(root_url, start): return f"{root_url}?start={start}&filter=" def get_review(page_url): movies_list = [] headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } response = requests.get(page_url, headers=headers) soup = BeautifulSoup(response.text, "lxml") soup = soup.find('ol', 'grid_view') if not soup: return movies_list for tag_li in soup.find_all('li'): movie = {} movie['rank'] = tag_li.find('em').string movie['name'] = tag_li.find_all('span', 'title')[0].string movie['score'] = tag_li.find('span', 'rating_num').string # 提取评价人数 (新增逻辑) star_div = tag_li.find('div', 'star') if star_div: spans = star_div.find_all('span') movie['votes'] = '0' for span in spans: if '人评价' in span.text: # 剥离中文,只保留纯数字字符串 movie['votes'] = span.text.replace('人评价', '').strip() break else: movie['votes'] = '0' # 尝试抓取年份 bd_p = tag_li.find('div', 'bd').find('p') if bd_p: texts = bd_p.text.strip().split('\n') if len(texts) >= 2: # 豆瓣的年份信息通常在第二行,用 '/' 分隔 movie['year'] = texts[1].split('/')[0].strip() else: movie['year'] = '未知' else: movie['year'] = '未知' if tag_li.find('span', 'inq'): movie['desc'] = tag_li.find('span', 'inq').string else: movie['desc'] = '无评词' movies_list.append(movie) return movies_list def scrape_and_save(csv_filename): root_url = "https://movie.douban.com/top250" start = 0 print("开始爬取数据...") with open(csv_filename, mode='w', encoding='utf-8-sig', newline='') as f: # 在表头字段列表中加入 'votes' fieldnames = ['rank', 'name', 'year', 'score', 'votes', 'desc'] writer = csv.DictWriter(f, fieldnames=fieldnames) # 修复:写入英文表头,使得 Pandas 能够通过 'year' 和 'score' 正确读取列 writer.writeheader() while start < 250: movies_list = get_review(get_url(root_url, start)) for movie_dict in movies_list: writer.writerow(movie_dict) start += 25 print(f"已获取 {start} 条数据...") print(f"爬取完成!数据已保存至 {csv_filename}\n") # --- 2. 数据分析与动态大屏生成部分 --- def generate_html_dashboard(csv_filename): print("开始进行数据分析并生成互动式 HTML 大屏...") # 1. 读取并清洗数据 try: df = pd.read_csv(csv_filename) except FileNotFoundError: print("未找到 CSV 文件,请先执行爬虫获取数据!") return # 兼容中英文表头 (加入 '评价人数': 'votes') if '年份' in df.columns: df.rename(columns={'排名': 'rank', '电影名称': 'name', '年份': 'year', '评分': 'score', '评价人数': 'votes', '评词': 'desc'}, inplace=True) if 'year' not in df.columns: print("\n 错误:在 CSV 文件中没有找到 'year' 列!") return # 数据清洗 df['year'] = df['year'].astype(str).str.extract(r'(\d{4})') df['year'] = pd.to_numeric(df['year'], errors='coerce') df['score'] = pd.to_numeric(df['score']) df['votes'] = pd.to_numeric(df['votes'], errors='coerce').fillna(0) # 将评价人数转换为数字格式 df = df.dropna(subset=['year']) # 2. 核心数据聚合计算 # [图表1数据] 评分分布 score_counts = df['score'].value_counts().sort_index() score_dist_data = { 'scores': score_counts.index.astype(str).tolist(), 'counts': score_counts.values.tolist() } # [图表2数据] 上榜数量最多的年份 Top15 year_counts = df['year'].value_counts().head(15).sort_values() year_top_data = { 'years': year_counts.index.astype(int).astype(str).tolist(), 'counts': year_counts.values.tolist() } # [图表3数据] 经典电影年代分布 bins = [1900, 1980, 1990, 2000, 2010, 2030] labels = ['80年代前', '80年代', '90年代', '00年代', '10年代及以后'] df['decade'] = pd.cut(df['year'], bins=bins, labels=labels, right=False) decade_counts = df['decade'].value_counts() decade_data = [{"value": int(v), "name": str(k)} for k, v in decade_counts.items() if pd.notna(k)] # [图表4数据] 高产年份平均评分趋势 year_group = df.groupby('year').agg({'score': ['mean', 'count']}).reset_index() year_group.columns = ['year', 'avg_score', 'count'] valid_years = year_group[year_group['count'] >= 2].sort_values('year') trend_data = { 'years': valid_years['year'].astype(int).astype(str).tolist(), 'avgScores': valid_years['avg_score'].round(2).tolist() } # [图表5数据 - 新增] 评价人数最多的 Top 50 电影 top_votes_df = df.sort_values(by='votes', ascending=False).head(50) votes_data = { 'names': top_votes_df['name'].tolist(), 'votes': top_votes_df['votes'].tolist() } # 3. 构建 HTML 模板 (包含 ECharts 和 CSS) html_top = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>豆瓣电影 Top250 多维数据大屏</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <style> :root { --bg-color: #f4f7f6; --card-bg: #ffffff; --text-main: #2c3e50; --text-muted: #7f8c8d; --shadow: 0 8px 20px rgba(0, 0, 0, 0.05); --border-radius: 16px; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); color: var(--text-main); padding: 24px; min-height: 100vh; } .dashboard-header { text-align: center; margin-bottom: 30px; padding: 20px 0; } .dashboard-header h1 { font-size: 32px; font-weight: 800; color: #1a252f; letter-spacing: 1px; margin-bottom: 8px; } .dashboard-header p { font-size: 16px; color: var(--text-muted); } .dashboard-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; max-width: 1400px; margin: 0 auto; } .chart-card { background-color: var(--card-bg); border-radius: var(--border-radius); box-shadow: var(--shadow); padding: 20px; transition: transform 0.3s ease, box-shadow 0.3s ease; } .chart-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08); } .chart-container { width: 100%; height: 380px; } /* 新增:跨两列的宽图表容器 */ .full-width { grid-column: 1 / -1; } .tall-container { height: 450px; } @media (max-width: 992px) { .dashboard-grid { grid-template-columns: 1fr; } } </style> </head> <body> <div class="dashboard-header"> <h1>豆瓣电影 Top250 自动化数据大屏</h1> <p>Generated by Python Pandas & ECharts</p> </div> <div class="dashboard-grid"> <div class="chart-card"><div id="chart-score-dist" class="chart-container"></div></div> <div class="chart-card"><div id="chart-year-top" class="chart-container"></div></div> <div class="chart-card"><div id="chart-decade-pie" class="chart-container"></div></div> <div class="chart-card"><div id="chart-score-trend" class="chart-container"></div></div> <!-- 新增:评价人数统计图 --> <div class="chart-card full-width"><div id="chart-votes-bar" class="chart-container tall-container"></div></div> </div> <script> """ # 使用 json.dumps 将 Python 字典安全地转换为 JavaScript 对象 js_data = f""" // 这里是 Python 自动计算并注入的真实数据 const scoreDistData = {json.dumps(score_dist_data)}; const yearTopData = {json.dumps(year_top_data)}; const decadeData = {json.dumps(decade_data)}; const trendData = {json.dumps(trend_data)}; const votesData = {json.dumps(votes_data)}; """ html_bottom = """ // 初始化图表 1 (柱状图) const chartScoreDist = echarts.init(document.getElementById('chart-score-dist')); chartScoreDist.setOption({ title: { text: '电影评分分布情况', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: scoreDistData.scores, axisLabel: { color: '#7F8C8D' }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', name: '电影数量 (部)', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '数量', type: 'bar', data: scoreDistData.counts, itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#36D1DC' }, { offset: 1, color: '#5B86E5' }]) }, label: { show: true, position: 'top', color: '#34495E', fontWeight: 'bold' } }] }); // 初始化图表 2 (条形图) const chartYearTop = echarts.init(document.getElementById('chart-year-top')); chartYearTop.setOption({ title: { text: '上榜数量最多的年份 Top15', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true }, xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, yAxis: { type: 'category', data: yearTopData.years, axisLabel: { color: '#7F8C8D', fontWeight: 'bold' }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, series: [{ name: '上榜数量', type: 'bar', data: yearTopData.counts, itemStyle: { borderRadius: [0, 6, 6, 0], color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [{ offset: 0, color: '#FF8008' }, { offset: 1, color: '#FFC837' }]) }, label: { show: true, position: 'right', color: '#34495E', fontWeight: 'bold' } }] }); // 初始化图表 3 (环形图) const chartDecadePie = echarts.init(document.getElementById('chart-decade-pie')); chartDecadePie.setOption({ title: { text: '经典电影年代分布', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c}部 ({d}%)' }, legend: { top: 'bottom', textStyle: { color: '#7F8C8D' } }, color: ['#FF9A9E', '#FECFEF', '#A1C4FD', '#C2E9FB', '#D4FC79'], series: [{ name: '年代占比', type: 'pie', radius: ['45%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 }, label: { show: true, formatter: '{b}\\n{d}%', fontWeight: 'bold', color: '#34495E' }, labelLine: { show: true, smooth: 0.2, length: 10, length2: 20 }, data: decadeData }] }); // 初始化图表 4 (面积折线图) const chartScoreTrend = echarts.init(document.getElementById('chart-score-trend')); chartScoreTrend.setOption({ title: { text: '高产年份平均评分趋势', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', boundaryGap: false, data: trendData.years, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', min: 8.5, max: 9.3, splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '平均评分', type: 'line', data: trendData.avgScores, smooth: true, symbolSize: 8, itemStyle: { color: '#8E2DE2' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(142, 45, 226, 0.4)' }, { offset: 1, color: 'rgba(74, 0, 224, 0.05)' }]) } }] }); // 初始化图表 5 (评价人数统计柱状图 - 新增) const chartVotesBar = echarts.init(document.getElementById('chart-votes-bar')); chartVotesBar.setOption({ title: { text: '评价人数统计柱状图 (Top 50)', left: 'center', textStyle: { color: '#34495E', fontSize: 20 } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '4%', right: '4%', bottom: '25%', containLabel: true }, // 增加 bottom 空间放置倾斜字体 xAxis: { type: 'category', name: '电影名称', nameLocation: 'middle', nameGap: 60, data: votesData.names, axisLabel: { color: '#2c3e50', rotate: 45, // 文字倾斜 45 度 interval: 0, // 强制显示所有电影名称 fontSize: 10 }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', name: '评价人数 / 人', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '评价人数', type: 'bar', data: votesData.votes, itemStyle: { color: '#2980b9' } // 采用跟截图接近的经典蓝色 }] }); window.addEventListener('resize', () => { chartScoreDist.resize(); chartYearTop.resize(); chartDecadePie.resize(); chartScoreTrend.resize(); chartVotesBar.resize(); }); </script> </body> </html> """ # 4. 拼装完毕,写入 HTML 文件 final_html = html_top + js_data + html_bottom output_html = "douban_dashboard_auto.html" with open(output_html, "w", encoding="utf-8") as f: f.write(final_html) print(f" 动态大屏已成功生成!文件位置:{os.path.abspath(output_html)}") # 5. 自动唤起默认浏览器打开该页面 try: webbrowser.open('file://' + os.path.abspath(output_html)) except Exception as e: print(f"尝试打开浏览器失败,请手动双击打开 {output_html}。错误信息:{e}") if __name__ == "__main__": csv_file = "douban_top250.csv" # 执行步骤1:爬取并保存 CSV scrape_and_save(csv_file) # 执行步骤2:读取 CSV 并生成交互式网页 generate_html_dashboard(csv_file)
阶段四:精细化数据挖掘与定制化图表
目标:响应需求,增加“评价人数”维度,并复刻特定的图表样式。 过程:
正则精准提取:最初直接读取包含“人评价”的文本,但由于网页结构差异容易提取失败。重构了爬虫逻辑,引入
re(正则表达式) 模块,强行从 HTML 节点中剥离出纯数字的评价人数 (votes)。宽幅图表开发:根据参考图片(横向宽幅、X轴文字倾斜 45 度的柱状图),在 CSS Grid 中增加了跨越两列的
.full-width容器。ECharts 深度定制:配置了
rotate: 45、interval: 0以及蓝色的itemStyle,完美复刻了“评价人数 Top 50 统计柱状图”。import os import requests # 网络请求模块 from lxml import etree # 数据解析模块 import pandas as pd # 数据分析模块快 import json import webbrowser import re # 全局变量定义 excelFileName = "豆瓣电影top250数据.xlsx" csvFileName = "豆瓣电影top250数据.csv" headers = { # 请求头信息 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.46' } # 功能函数,获取列表的第一个元素 def get_first_text(list): try: return list[0].strip() # 返回第一个字符串,除去两端的空格 except: return "" # 返回空字符串 def check() -> bool: """ 判断当前的目录下是否存在目标文件,存在则无需重复爬取 """ if os.path.exists(csvFileName) and os.path.exists(excelFileName): print("已经存在文件:", excelFileName, "无需重复爬取,将直接使用现有数据生成图表。") return True else: print("未找到数据文件,开始网络爬取...") return False def getMovieData(): """ 使用爬虫爬取豆瓣电影数据 """ # 增加了一列 "年份",以支持大屏图表的年代分析 df = pd.DataFrame(columns=["序号", "标题", "链接", "导演", "年份", "评分", "评价人数", "简介"]) # 使用列表生成式表示10个页面的地址 urls = ['https://movie.douban.com/top250?start={}&filter='.format(str(i * 25)) for i in range(10)] count = 1 # 用来计数 for url in urls: res = requests.get(url=url, headers=headers) # 发起请求 html = etree.HTML(res.text) # 将返回的文本加工为可以解析的html lis = html.xpath('//*[@id="content"]/div/div[1]/ol/li') # 获取每个电影的li元素 # 解析数据 for li in lis: title = get_first_text(li.xpath('./div/div[2]/div[1]/a/span[1]/text()')) # 电影标题 src = get_first_text(li.xpath('./div/div[2]/div[1]/a/@href')) # 电影链接 # 从 p 标签提取导演和年份 info_texts = li.xpath('./div/div[2]/div[2]/p[1]/text()') dictor = get_first_text([info_texts[0]]) if len(info_texts) > 0 else "" # 导演等信息 year_info = get_first_text([info_texts[1]]) if len(info_texts) > 1 else "" # 包含年份信息的字符串 # 清洗年份数据,去掉可能包含的国家/地区信息 year = year_info.split('/')[0].strip() if year_info else "未知" # 进一步正则提取纯数字年份 year_match = re.search(r'(\d{4})', year) year = year_match.group(1) if year_match else "未知" score = get_first_text(li.xpath('./div/div[2]/div[2]/div/span[2]/text()')) # 评分 comment = get_first_text(li.xpath('./div/div[2]/div[2]/div/span[4]/text()')) # 评价人数 summary = get_first_text(li.xpath('./div/div[2]/div[2]/p[2]/span/text()')) # 电影简介 print(count, title, src, dictor, year, score, comment, summary) # 输出 df.loc[len(df.index)] = [count, title, src, dictor, year, score, comment, summary] count += 1 df.to_excel(excelFileName, sheet_name="豆瓣电影top250数据", na_rep="") df.to_csv(csvFileName, index=False, encoding='utf-8-sig') # 加上 index=False 和中文编码 print("文件已经生成!") def generate_html_dashboard(): """ [新增] 基于爬取的数据生成交互式 HTML 大屏 """ print("开始进行数据分析并生成互动式 HTML 大屏...") try: df = pd.read_csv(csvFileName) except FileNotFoundError: print("未找到 CSV 文件,无法生成大屏!") return # 统一列名以适配大屏渲染逻辑 df.rename(columns={'标题': 'name', '年份': 'year', '评分': 'score', '评价人数': 'votes'}, inplace=True) # 数据深度清洗 df['year'] = pd.to_numeric(df['year'], errors='coerce') df['score'] = pd.to_numeric(df['score']) df['votes'] = df['votes'].astype(str).str.extract(r'(\d+)').astype(float).fillna(0) # 剥离 "xxx人评价" 提取纯数字 df = df.dropna(subset=['year']) # 核心数据聚合计算 # [图表1数据] 评分分布 score_counts = df['score'].value_counts().sort_index() score_dist_data = { 'scores': score_counts.index.astype(str).tolist(), 'counts': score_counts.values.tolist() } # [图表2数据] 上榜数量最多的年份 Top15 year_counts = df['year'].value_counts().head(15).sort_values() year_top_data = { 'years': year_counts.index.astype(int).astype(str).tolist(), 'counts': year_counts.values.tolist() } # [图表3数据] 经典电影年代分布 bins = [1900, 1980, 1990, 2000, 2010, 2030] labels = ['80年代前', '80年代', '90年代', '00年代', '10年代及以后'] df['decade'] = pd.cut(df['year'], bins=bins, labels=labels, right=False) decade_counts = df['decade'].value_counts() decade_data = [{"value": int(v), "name": str(k)} for k, v in decade_counts.items() if pd.notna(k)] # [图表4数据] 高产年份平均评分趋势 year_group = df.groupby('year').agg({'score': ['mean', 'count']}).reset_index() year_group.columns = ['year', 'avg_score', 'count'] valid_years = year_group[year_group['count'] >= 2].sort_values('year') trend_data = { 'years': valid_years['year'].astype(int).astype(str).tolist(), 'avgScores': valid_years['avg_score'].round(2).tolist() } # [图表5数据] 评价人数最多的 Top 50 电影 top_votes_df = df.sort_values(by='votes', ascending=False).head(50) votes_data = { 'names': top_votes_df['name'].tolist(), 'votes': top_votes_df['votes'].tolist() } # 构建 HTML 模板 (包含 ECharts 和 CSS) html_top = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>豆瓣电影 Top250 多维数据大屏</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <style> :root { --bg-color: #f4f7f6; --card-bg: #ffffff; --text-main: #2c3e50; --text-muted: #7f8c8d; --shadow: 0 8px 20px rgba(0, 0, 0, 0.05); --border-radius: 16px; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); color: var(--text-main); padding: 24px; min-height: 100vh; } .dashboard-header { text-align: center; margin-bottom: 30px; padding: 20px 0; } .dashboard-header h1 { font-size: 32px; font-weight: 800; color: #1a252f; letter-spacing: 1px; margin-bottom: 8px; } .dashboard-header p { font-size: 16px; color: var(--text-muted); } .dashboard-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; max-width: 1400px; margin: 0 auto; } .chart-card { background-color: var(--card-bg); border-radius: var(--border-radius); box-shadow: var(--shadow); padding: 20px; transition: transform 0.3s ease, box-shadow 0.3s ease; } .chart-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08); } .chart-container { width: 100%; height: 380px; } .full-width { grid-column: 1 / -1; } .tall-container { height: 450px; } @media (max-width: 992px) { .dashboard-grid { grid-template-columns: 1fr; } } </style> </head> <body> <div class="dashboard-header"> <h1>豆瓣电影 Top250 自动化数据大屏</h1> <p>Generated by Python Pandas & ECharts</p> </div> <div class="dashboard-grid"> <div class="chart-card"><div id="chart-score-dist" class="chart-container"></div></div> <div class="chart-card"><div id="chart-year-top" class="chart-container"></div></div> <div class="chart-card"><div id="chart-decade-pie" class="chart-container"></div></div> <div class="chart-card"><div id="chart-score-trend" class="chart-container"></div></div> <div class="chart-card full-width"><div id="chart-votes-bar" class="chart-container tall-container"></div></div> </div> <script> """ js_data = f""" const scoreDistData = {json.dumps(score_dist_data)}; const yearTopData = {json.dumps(year_top_data)}; const decadeData = {json.dumps(decade_data)}; const trendData = {json.dumps(trend_data)}; const votesData = {json.dumps(votes_data)}; """ html_bottom = """ const chartScoreDist = echarts.init(document.getElementById('chart-score-dist')); chartScoreDist.setOption({ title: { text: '电影评分分布情况', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: scoreDistData.scores, axisLabel: { color: '#7F8C8D' }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', name: '电影数量 (部)', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '数量', type: 'bar', data: scoreDistData.counts, itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#36D1DC' }, { offset: 1, color: '#5B86E5' }]) }, label: { show: true, position: 'top', color: '#34495E', fontWeight: 'bold' } }] }); const chartYearTop = echarts.init(document.getElementById('chart-year-top')); chartYearTop.setOption({ title: { text: '上榜数量最多的年份 Top15', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true }, xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, yAxis: { type: 'category', data: yearTopData.years, axisLabel: { color: '#7F8C8D', fontWeight: 'bold' }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, series: [{ name: '上榜数量', type: 'bar', data: yearTopData.counts, itemStyle: { borderRadius: [0, 6, 6, 0], color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [{ offset: 0, color: '#FF8008' }, { offset: 1, color: '#FFC837' }]) }, label: { show: true, position: 'right', color: '#34495E', fontWeight: 'bold' } }] }); const chartDecadePie = echarts.init(document.getElementById('chart-decade-pie')); chartDecadePie.setOption({ title: { text: '经典电影年代分布', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c}部 ({d}%)' }, legend: { top: 'bottom', textStyle: { color: '#7F8C8D' } }, color: ['#FF9A9E', '#FECFEF', '#A1C4FD', '#C2E9FB', '#D4FC79'], series: [{ name: '年代占比', type: 'pie', radius: ['45%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 }, label: { show: true, formatter: '{b}\\n{d}%', fontWeight: 'bold', color: '#34495E' }, labelLine: { show: true, smooth: 0.2, length: 10, length2: 20 }, data: decadeData }] }); const chartScoreTrend = echarts.init(document.getElementById('chart-score-trend')); chartScoreTrend.setOption({ title: { text: '高产年份平均评分趋势', left: 'center', textStyle: { color: '#34495E' } }, tooltip: { trigger: 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', boundaryGap: false, data: trendData.years, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', min: 8.5, max: 9.3, splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '平均评分', type: 'line', data: trendData.avgScores, smooth: true, symbolSize: 8, itemStyle: { color: '#8E2DE2' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(142, 45, 226, 0.4)' }, { offset: 1, color: 'rgba(74, 0, 224, 0.05)' }]) } }] }); const chartVotesBar = echarts.init(document.getElementById('chart-votes-bar')); chartVotesBar.setOption({ title: { text: '评价人数统计柱状图 (Top 50)', left: 'center', textStyle: { color: '#34495E', fontSize: 20 } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '4%', right: '4%', bottom: '25%', containLabel: true }, xAxis: { type: 'category', name: '电影名称', nameLocation: 'middle', nameGap: 60, data: votesData.names, axisLabel: { color: '#2c3e50', rotate: 45, interval: 0, fontSize: 10 }, axisLine: { lineStyle: { color: '#BDC3C7' } } }, yAxis: { type: 'value', name: '评价人数 / 人', splitLine: { lineStyle: { type: 'dashed', color: '#E0E6ED' } } }, series: [{ name: '评价人数', type: 'bar', data: votesData.votes, itemStyle: { color: '#2980b9' } }] }); window.addEventListener('resize', () => { chartScoreDist.resize(); chartYearTop.resize(); chartDecadePie.resize(); chartScoreTrend.resize(); chartVotesBar.resize(); }); </script> </body> </html> """ final_html = html_top + js_data + html_bottom output_html = "douban_dashboard_auto.html" with open(output_html, "w", encoding="utf-8") as f: f.write(final_html) print(f" 动态大屏已成功生成!文件位置:{os.path.abspath(output_html)}") try: webbrowser.open('file://' + os.path.abspath(output_html)) except Exception as e: print(f"尝试打开浏览器失败,请手动双击打开 {output_html}。错误信息:{e}") def main(): if not check(): getMovieData() generate_html_dashboard() # 运行新增的 HTML 动态大屏模块 if __name__ == '__main__': main()
总结
通过这一系列的迭代,本项目从一个简单的终端爬虫,进化为了一个具备数据采集、数据清洗、数据聚合计算、前后端代码自动融合、最终呈现出商业级交互式数据大屏的完整自动化数据工程项目。