你有没有试过,想看最近哪部电影值得去影院,结果翻遍猫眼、豆瓣、淘票票,最后还是被各种评分和评论搞得一头雾水?或者想分析某个导演的作品票房规律,却发现数据散落在不同页面,手动整理费时费力?
上个月我帮朋友做一个小型影视数据分析,就遇到了类似问题。原本以为猫眼这类平台的数据应该很好抓,真正动手才发现:单次抓取几页数据不难,难的是稳定获取批量数据、处理反爬机制、把零散信息整理成可分析的结构化数据,最后还能直观呈现出来。
这篇文章,我就以猫眼电影数据爬取与可视化分析为例,带你走完从单次请求到完整项目的全过程。但我要先提醒你:这个项目的价值不在于“能抓到数据”,而在于理解一套可复用的数据采集-清洗-分析-展示工作流。真正重要的不是代码本身,而是背后的问题拆解思路和工程化意识。
1. 先别急着写爬虫,搞清楚你要什么和能要什么
很多人一上来就打开编辑器写 requests 代码,这是最容易踩坑的开始。猫眼这样的平台,对数据抓取有明确的技术限制和合规边界,先理解规则比直接冲更重要。
1.1 明确数据边界:你能获取什么,应该获取什么
猫眼电影页面公开显示的信息通常包括:
- 电影基本信息:名称、评分、主演、上映时间、片长
- 票房数据:实时票房、累计票房(部分页面)
- 评论数据:用户评分、短评内容
但这里有个关键区别:公开可见的数据不等于可以无限批量抓取的数据。从技术伦理和平台规则角度,你应该遵循:
- 只获取公开显示的非个人化数据
- 控制请求频率,避免对服务器造成压力
- 不获取需要登录才能访问的敏感信息
- 不将抓取数据用于商业用途
在实际代码中,这意味着你需要设置合理的请求间隔、使用正式的 HTTP 头部信息、避免触发反爬机制。
1.2 技术方案选型:为什么是 Flask + requests + Pandas
这个组合看起来简单,但每个组件都有明确的分工:
requests:负责 HTTP 请求处理
- 比 urllib 更简洁的 API
- 自动处理连接复用和持久化
- 内置会话管理和 Cookie 保持
Pandas:负责数据清洗和结构化处理
- 缺失值处理、数据类型转换
- 表格化数据操作和筛选
- 为后续分析和可视化做准备
Flask:提供轻量级 Web 界面
- 快速展示分析结果
- 可扩展为数据查询接口
- 学习成本低于 Django
关键是,这个组合覆盖了从数据获取到展示的全链路,而且每个环节都可以独立优化。
1.3 环境准备:别在依赖版本上踩坑
Python 环境建议使用 3.8+,主要依赖库版本兼容性:
# 基础请求和解析 requests>=2.25.1 beautifulsoup4>=4.9.3 # 数据处理 pandas>=1.3.0 numpy>=1.20.0 # 可视化 matplotlib>=3.3.0 seaborn>=0.11.0 # Web 框架 flask>=2.0.0安装时最容易出问题的是环境隔离。建议使用虚拟环境:
python -m venv movie_analysis source movie_analysis/bin/activate # Linux/Mac movie_analysis\Scripts\activate # Windows pip install -r requirements.txt2. 爬虫核心:从单页请求到稳定批量采集
写爬虫最怕的就是代码看起来能跑,一上量就各种报错。关键在于理解请求逻辑和异常处理。
2.1 构建稳健的单页请求函数
先看一个基础的请求函数,重点不在功能完整,而在错误处理:
import requests import time from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def create_session_with_retries(): """创建带重试机制的会话""" session = requests.Session() # 重试策略 retry_strategy = Retry( total=3, # 最大重试次数 backoff_factor=1, # 重试等待时间因子 status_forcelist=[429, 500, 502, 503, 504], # 需要重试的状态码 ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter) # 设置合理的请求头 session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Connection': 'keep-alive' }) return session def get_movie_page(url, delay=1): """获取单页数据,包含错误处理""" session = create_session_with_retries() try: time.sleep(delay) # 请求间隔 response = session.get(url, timeout=10) response.raise_for_status() # 检查HTTP状态码 # 检查是否被反爬 if "验证" in response.text or "access denied" in response.text.lower(): print("可能触发了反爬机制,需要调整策略") return None return response.text except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None这个函数有几个关键设计:
- 使用会话对象保持连接复用
- 重试机制处理临时网络问题
- 超时设置避免长时间等待
- 反爬检测提前发现问题
- 请求间隔避免频率过高
2.2 解析逻辑:用 BeautifulSoup 提取结构化数据
猫眼页面结构相对规整,但还是要处理各种边界情况:
from bs4 import BeautifulSoup import re def parse_movie_list(html): """解析电影列表页""" if not html: return [] soup = BeautifulSoup(html, 'html.parser') movies = [] # 根据实际页面结构调整选择器 movie_items = soup.select('.movie-item') # 示例选择器 for item in movie_items: try: movie = {} # 电影名称 name_elem = item.select_one('.movie-name') movie['name'] = name_elem.text.strip() if name_elem else '未知' # 评分处理 score_elem = item.select_one('.score') if score_elem: score_text = score_elem.text.strip() movie['score'] = float(score_text) if score_text.replace('.', '').isdigit() else 0.0 else: movie['score'] = 0.0 # 上映时间解析 date_elem = item.select_one('.release-date') if date_elem: date_text = date_elem.text.strip() # 多种日期格式处理 movie['release_date'] = parse_date(date_text) else: movie['release_date'] = None # 演员信息(处理多演员情况) actors_elems = item.select('.actors') movie['actors'] = [actor.text.strip() for actor in actors_elems] if actors_elems else [] movies.append(movie) except Exception as e: print(f"解析电影信息失败: {e}") continue # 单条记录失败不影响整体 return movies def parse_date(date_text): """处理多种日期格式""" patterns = [ r'\d{4}-\d{2}-\d{2}', r'\d{4}年\d{1,2}月\d{1,2}日', r'\d{4}\.\d{1,2}\.\d{1,2}' ] for pattern in patterns: match = re.search(pattern, date_text) if match: return match.group() return date_text # 无法解析时返回原文本解析时最容易忽略的是数据清洗:
- 文本前后的空白字符
- 数字类型的转换验证
- 空值和缺失值处理
- 日期格式的统一
2.3 批量采集策略:平衡效率与稳定性
单页解析完成后,批量采集要考虑分页逻辑和频率控制:
def crawl_maoyan_movies(base_url, max_pages=10, start_page=0): """批量爬取多页电影数据""" all_movies = [] session = create_session_with_retries() for page in range(start_page, start_page + max_pages): print(f"正在爬取第 {page + 1} 页...") # 构造分页URL(根据实际网站结构调整) url = f"{base_url}?offset={page * 30}" # 示例分页参数 html = get_movie_page(url, delay=1.5) # 增加延迟 if html: movies = parse_movie_list(html) all_movies.extend(movies) print(f"第 {page + 1} 页获取到 {len(movies)} 部电影") else: print(f"第 {page + 1} 页获取失败,停止爬取") break # 每5页增加一次较长延迟 if (page + 1) % 5 == 0: time.sleep(3) return all_movies批量爬取的关键平衡点:
- 请求频率:太快容易被封,太慢效率低
- 错误处理:单页失败不应影响整体流程
- 进度保存:长时间运行需要断点续传
- 资源释放:及时关闭连接,避免内存泄漏
3. 数据清洗:从原始文本到分析就绪的结构化数据
爬取到的原始数据往往包含各种不一致和缺失,直接分析会得到错误结论。
3.1 使用 Pandas 进行数据质量检查
import pandas as pd from datetime import datetime def clean_movie_data(movies_list): """清洗电影数据""" df = pd.DataFrame(movies_list) print("原始数据概况:") print(f"总记录数: {len(df)}") print("\n各字段缺失值情况:") print(df.isnull().sum()) # 数据类型转换 if 'score' in df.columns: df['score'] = pd.to_numeric(df['score'], errors='coerce') # 处理重复数据 duplicate_count = df.duplicated(subset=['name']).sum() if duplicate_count > 0: print(f"发现 {duplicate_count} 条重复记录,进行去重") df = df.drop_duplicates(subset=['name'], keep='first') # 日期字段标准化 if 'release_date' in df.columns: df['release_date'] = pd.to_datetime(df['release_date'], errors='coerce') # 提取年份月份等信息 df['release_year'] = df['release_date'].dt.year df['release_month'] = df['release_date'].dt.month # 演员列表处理 if 'actors' in df.columns: # 统计演员数量 df['actor_count'] = df['actors'].apply(lambda x: len(x) if isinstance(x, list) else 0) # 提取主要演员(第一个) df['main_actor'] = df['actors'].apply( lambda x: x[0] if isinstance(x, list) and len(x) > 0 else '未知' ) return df def analyze_data_quality(df): """分析数据质量""" print("\n=== 数据质量分析 ===") # 数值型字段统计 numeric_cols = df.select_dtypes(include=['number']).columns for col in numeric_cols: print(f"\n{col} 字段统计:") print(f" 有效值数量: {df[col].notna().sum()}") print(f" 平均值: {df[col].mean():.2f}") print(f" 中位数: {df[col].median():.2f}") print(f" 标准差: {df[col].std():.2f}") # 文本型字段分析 text_cols = df.select_dtypes(include=['object']).columns for col in text_cols: unique_count = df[col].nunique() print(f"\n{col} 字段: {unique_count} 个唯一值") # 显示最常见的几个值 top_values = df[col].value_counts().head(3) for value, count in top_values.items(): print(f" {value}: {count} 次")3.2 处理常见数据问题
实际数据清洗中会遇到的各种问题:
def handle_common_issues(df): """处理常见数据问题""" # 1. 评分异常值处理 if 'score' in df.columns: # 假设合理评分范围是 0-10 score_mask = (df['score'] >= 0) & (df['score'] <= 10) outlier_count = (~score_mask).sum() if outlier_count > 0: print(f"发现 {outlier_count} 条异常评分记录") df = df[score_mask] # 直接过滤异常值 # 2. 电影名称规范化 if 'name' in df.columns: df['name'] = df['name'].str.strip() # 去除多余的空格和特殊字符 df['name'] = df['name'].str.replace(r'\s+', ' ', regex=True) # 3. 年代久远电影处理 if 'release_year' in df.columns: current_year = datetime.now().year # 标记近5年的电影 df['is_recent'] = df['release_year'] >= (current_year - 5) # 4. 演员信息展开(为分析做准备) if 'actors' in df.columns: # 创建演员出现频次统计 from collections import Counter all_actors = [] for actors_list in df['actors'].dropna(): if isinstance(actors_list, list): all_actors.extend(actors_list) actor_stats = Counter(all_actors) df['actor_popularity'] = df['main_actor'].map( lambda x: actor_stats.get(x, 0) if pd.notna(x) else 0 ) return df数据清洗的核心原则:
- 保持数据真实性,不随意修改原始值
- 记录所有处理步骤,确保可追溯
- 区分数据修复和数据增强
- 为后续分析优化数据结构
4. 可视化分析:用图表讲好数据故事
清洗后的数据需要合适的可视化来发现规律。重点不是图表数量,而是每个图表要回答一个明确问题。
4.1 评分分布分析
import matplotlib.pyplot as plt import seaborn as sns def plot_rating_distribution(df): """绘制评分分布图""" plt.figure(figsize=(12, 8)) # 设置中文字体(根据系统调整) plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False # 1. 评分分布直方图 plt.subplot(2, 2, 1) plt.hist(df['score'].dropna(), bins=20, alpha=0.7, color='skyblue', edgecolor='black') plt.xlabel('评分') plt.ylabel('电影数量') plt.title('电影评分分布') plt.grid(True, alpha=0.3) # 2. 评分箱线图 plt.subplot(2, 2, 2) df['score'].dropna().plot(kind='box', vert=False) plt.title('评分箱线图') plt.xlabel('评分') # 3. 年度平均评分趋势 plt.subplot(2, 2, 3) yearly_avg = df.groupby('release_year')['score'].mean() yearly_count = df.groupby('release_year').size() # 只显示有足够数据的年份 valid_years = yearly_count[yearly_count >= 5].index yearly_avg_filtered = yearly_avg[valid_years] plt.plot(yearly_avg_filtered.index, yearly_avg_filtered.values, marker='o', linewidth=2, markersize=4) plt.xlabel('年份') plt.ylabel('平均评分') plt.title('年度平均评分趋势') plt.grid(True, alpha=0.3) plt.xticks(rotation=45) # 4. 评分与演员数量关系 plt.subplot(2, 2, 4) if 'actor_count' in df.columns: plt.scatter(df['actor_count'], df['score'], alpha=0.5) plt.xlabel('演员数量') plt.ylabel('评分') plt.title('评分与演员数量关系') plt.grid(True, alpha=0.3) plt.tight_layout() return plt4.2 时间维度分析
def analyze_time_trends(df): """时间趋势分析""" fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # 1. 年度电影产量 yearly_production = df['release_year'].value_counts().sort_index() axes[0, 0].bar(yearly_production.index, yearly_production.values, alpha=0.7) axes[0, 0].set_xlabel('年份') axes[0, 0].set_ylabel('电影数量') axes[0, 0].set_title('年度电影产量') axes[0, 0].tick_params(axis='x', rotation=45) # 2. 月度分布(忽略年份) monthly_dist = df['release_month'].value_counts().sort_index() axes[0, 1].bar(monthly_dist.index, monthly_dist.values, color='orange', alpha=0.7) axes[0, 1].set_xlabel('月份') axes[0, 1].set_ylabel('电影数量') axes[0, 1].set_title('电影上映月份分布') axes[0, 1].set_xticks(range(1, 13)) # 3. 热门演员分析 if 'main_actor' in df.columns: top_actors = df['main_actor'].value_counts().head(10) axes[1, 0].barh(range(len(top_actors)), top_actors.values) axes[1, 0].set_yticks(range(len(top_actors))) axes[1, 0].set_yticklabels(top_actors.index) axes[1, 0].set_xlabel('电影数量') axes[1, 0].set_title('热门演员作品数量') # 4. 评分区间统计 rating_bins = [0, 3, 6, 8, 10] rating_labels = ['3分及以下', '3-6分', '6-8分', '8分以上'] df['rating_category'] = pd.cut(df['score'], bins=rating_bins, labels=rating_labels) rating_dist = df['rating_category'].value_counts() axes[1, 1].pie(rating_dist.values, labels=rating_dist.index, autopct='%1.1f%%') axes[1, 1].set_title('评分区间分布') plt.tight_layout() return fig4.3 高级分析:相关性探索
def advanced_analysis(df): """高级分析:探索变量间关系""" # 选择数值型列进行相关性分析 numeric_df = df.select_dtypes(include=['number']) if len(numeric_df.columns) > 1: plt.figure(figsize=(10, 8)) # 相关性热力图 correlation_matrix = numeric_df.corr() sns.heatmap(correlation_matrix, annot=True, cmap='coolwarm', center=0, square=True, linewidths=0.5) plt.title('变量相关性热力图') plt.tight_layout() # 保存图表 plt.savefig('correlation_heatmap.png', dpi=300, bbox_inches='tight') return correlation_matrix if 'correlation_matrix' in locals() else None可视化分析的关键洞察:
- 评分分布是否呈现特定模式(如正态分布)
- 是否存在明显的年度趋势或季节性规律
- 演员影响力与电影评分的关系
- 数据质量对分析结论的影响
5. Flask 集成:从数据分析到交互展示
数据分析结果需要合适的展示方式,Flask 提供了轻量级的 Web 界面方案。
5.1 基础 Flask 应用结构
from flask import Flask, render_template, request, jsonify import os app = Flask(__name__) @app.route('/') def index(): """主页面:展示分析概览""" # 基础统计信息 stats = { 'total_movies': len(df), 'avg_rating': df['score'].mean(), 'recent_movies': df['is_recent'].sum() if 'is_recent' in df.columns else 0, 'top_actor': df['main_actor'].mode()[0] if 'main_actor' in df.columns else '未知' } return render_template('index.html', stats=stats) @app.route('/api/movies') def get_movies(): """电影数据API接口""" page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) start_idx = (page - 1) * per_page end_idx = start_idx + per_page movies_data = df.iloc[start_idx:end_idx].to_dict('records') return jsonify({ 'movies': movies_data, 'total': len(df), 'page': page, 'per_page': per_page }) @app.route('/analysis/<analysis_type>') def show_analysis(analysis_type): """展示特定分析结果""" analysis_types = { 'rating': '评分分析', 'time': '时间趋势', 'actor': '演员分析' } if analysis_type not in analysis_types: return "分析类型不存在", 404 # 生成对应的分析图表 if analysis_type == 'rating': chart_path = generate_rating_chart() elif analysis_type == 'time': chart_path = generate_time_chart() else: chart_path = generate_actor_chart() return render_template('analysis.html', analysis_type=analysis_types[analysis_type], chart_path=chart_path) def generate_rating_chart(): """生成评分分析图表""" plt = plot_rating_distribution(df) chart_path = 'static/images/rating_analysis.png' plt.savefig(chart_path, dpi=300, bbox_inches='tight') plt.close() return chart_path if __name__ == '__main__': # 确保静态文件目录存在 os.makedirs('static/images', exist_ok=True) app.run(debug=True, host='0.0.0.0', port=5000)5.2 模板设计:简洁有效的信息展示
创建templates/index.html:
<!DOCTYPE html> <html> <head> <title>猫眼电影数据分析</title> <meta charset="utf-8"> <style> .stats-container { display: flex; justify-content: space-around; margin: 20px 0; } .stat-card { background: #f5f5f5; padding: 20px; border-radius: 8px; text-align: center; } .nav-links { text-align: center; margin: 30px 0; } .nav-links a { margin: 0 15px; text-decoration: none; color: #007bff; } </style> </head> <body> <h1 style="text-align: center;">猫眼电影数据分析平台</h1> <div class="stats-container"> <div class="stat-card"> <h3>总电影数量</h3> <p style="font-size: 24px; font-weight: bold;">{{ stats.total_movies }}</p> </div> <div class="stat-card"> <h3>平均评分</h3> <p style="font-size: 24px; font-weight: bold;">{{ "%.1f"|format(stats.avg_rating) }}</p> </div> <div class="stat-card"> <h3>近5年电影</h3> <p style="font-size: 24px; font-weight: bold;">{{ stats.recent_movies }}</p> </div> <div class="stat-card"> <h3>最活跃演员</h3> <p style="font-size: 24px; font-weight: bold;">{{ stats.top_actor }}</p> </div> </div> <div class="nav-links"> <a href="/analysis/rating">评分分析</a> <a href="/analysis/time">时间趋势</a> <a href="/analysis/actor">演员分析</a> <a href="/api/movies">数据接口</a> </div> </body> </html>5.3 数据导出功能
@app.route('/export/<format_type>') def export_data(format_type): """数据导出功能""" if format_type == 'csv': from io import StringIO output = StringIO() df.to_csv(output, index=False, encoding='utf-8') output.seek(0) return app.response_class( output.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=maoyan_movies.csv'} ) elif format_type == 'json': return jsonify(df.to_dict('records')) else: return "不支持的导出格式", 400Flask 集成的核心价值:
- 快速验证分析结果
- 提供数据查询接口
- 支持结果导出和分享
- 为更复杂应用提供基础框架
6. 项目优化与生产级考量
这个基础版本能跑通流程,但要用于真实场景还需要考虑更多工程化问题。
6.1 性能优化策略
# 1. 数据缓存机制 import pickle import hashlib def get_data_cache_key(url): """生成缓存键""" return hashlib.md5(url.encode()).hexdigest() def cached_request(url, cache_dir='cache', expire_hours=24): """带缓存的请求函数""" os.makedirs(cache_dir, exist_ok=True) cache_key = get_data_cache_key(url) cache_file = os.path.join(cache_dir, f"{cache_key}.pkl") # 检查缓存是否有效 if os.path.exists(cache_file): file_age = time.time() - os.path.getmtime(cache_file) if file_age < expire_hours * 3600: with open(cache_file, 'rb') as f: return pickle.load(f) # 执行新请求 result = get_movie_page(url) # 保存缓存 if result: with open(cache_file, 'wb') as f: pickle.dump(result, f) return result # 2. 数据库集成(可选) import sqlite3 def save_to_database(df, db_path='movies.db'): """保存到SQLite数据库""" conn = sqlite3.connect(db_path) # 创建表 df.to_sql('movies', conn, if_exists='replace', index=False) conn.close() def load_from_database(db_path='movies.db'): """从数据库加载数据""" conn = sqlite3.connect(db_path) df = pd.read_sql('SELECT * FROM movies', conn) conn.close() return df6.2 错误处理与日志记录
import logging from logging.handlers import RotatingFileHandler def setup_logging(): """配置日志系统""" logger = logging.getLogger('movie_analyzer') logger.setLevel(logging.INFO) # 文件日志(滚动记录) file_handler = RotatingFileHandler( 'movie_analysis.log', maxBytes=10*1024*1024, # 10MB backupCount=5 ) file_handler.setFormatter(logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s' )) # 控制台日志 console_handler = logging.StreamHandler() console_handler.setFormatter(logging.Formatter( '%(levelname)s - %(message)s' )) logger.addHandler(file_handler) logger.addHandler(console_handler) return logger # 在关键函数中添加日志记录 logger = setup_logging() def robust_crawl_maoyan_movies(base_url, max_pages=10): """增强版的爬取函数""" try: logger.info(f"开始爬取猫眼电影数据,最大页数: {max_pages}") result = crawl_maoyan_movies(base_url, max_pages) logger.info(f"爬取完成,共获取 {len(result)} 条记录") return result except Exception as e: logger.error(f"爬取过程出现异常: {e}") return []6.3 配置化管理
创建config.py:
import os from datetime import timedelta class Config: # 爬虫配置 REQUEST_DELAY = 1.5 # 请求延迟(秒) MAX_RETRIES = 3 TIMEOUT = 10 # Flask配置 SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-please-change' DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' # 数据存储 CACHE_DIR = 'data/cache' DATABASE_PATH = 'data/movies.db' EXPORT_DIR = 'data/exports' # 分析配置 RECENT_YEARS = 5 # 近几年的定义 MIN_MOVIES_PER_YEAR = 5 # 年度分析的最小电影数量 @classmethod def init_directories(cls): """初始化所需目录""" os.makedirs(cls.CACHE_DIR, exist_ok=True) os.makedirs(cls.EXPORT_DIR, exist_ok=True) os.makedirs(os.path.dirname(cls.DATABASE_PATH), exist_ok=True)6.4 生产部署建议
对于实际部署,需要考虑:
环境配置
- 使用环境变量管理敏感信息
- 配置正式数据库(MySQL/PostgreSQL)
- 设置反向代理(Nginx)
安全考虑
- 限制访问IP范围
- 添加身份验证(如果需要)
- 防止SQL注入等常见攻击
监控维护
- 添加健康检查接口
- 设置日志轮转和监控
- 定期备份数据
这个项目真正的价值不在于代码本身,而在于展示了一个完整的数据处理流程:从需求分析、技术选型、数据获取、清洗处理、分析可视化到最终展示。每个环节都有其独特的技术考量和最佳实践。
最重要的是,这套方法论可以复用到其他类似的数据分析项目中。下次当你需要分析电商商品、社交媒体内容或其他公开数据时,只需要调整数据获取和解析逻辑,核心的数据处理和分析框架都可以直接复用。