简介:本资源是一份面向数据科学初学者与Python进阶学习者的电影行业数据分析实战项目,聚焦豆瓣电影网与艺恩票房网双源数据的采集、清洗、建模与可视化全流程,解决真实场景下多平台异构数据融合分析的学习痛点。压缩包共44个文件,含22个Jupyter Notebook(覆盖数据采集、清洗、多维度分析及票房/评分预测建模)、7个CSV原始与中间数据集、3个Python脚本、2个JSON配置文件及1张关键结果PNG图,整体9.56MB,结构清晰分为Data_Collection、Data_Source、Data_Cleaning、Data_Analysis、Data_Model五大模块,便于分阶段学习与复现。已有55人下载学习,可直接运行全部Notebook,获得从网页结构解析、反爬应对、缺失值处理、类型电影票房/评分统计、导演演员编剧TOP榜、票房与评分关联性分析,到时序预测模型构建的完整代码链与可视化图表输出,是练手爬虫+Pandas+Matplotlib/Seaborn+ECharts的典型综合案例。
1. 为什么爬豆瓣和艺恩的电影数据,比直接调API还更稳、更全、更可控?
你手头有个影视行业分析需求:想看过去三年国产片的类型分布变化、宣发投入与票房回报的关系、不同档期的 ROI 差异——但官方 API 要资质、要配额、字段残缺(艺恩不开放单日分账明细,豆瓣压根没公开 API),第三方数据平台又贵又黑盒。这时候,豆瓣电影网和艺恩票房网的电影数据采集、分析与可视化就不是“野路子”,而是多数一线数据分析师在真实项目里反复验证过的最小可行路径:用结构化爬虫抓取公开页面的票房、评分、主演、上映日期、类型标签等核心字段,清洗后做时序对比、相关性建模、热力图呈现。它不依赖任何授权,数据源稳定(两个网站近五年未改版核心 DOM 结构),字段颗粒度远超商业接口(比如豆瓣可拿到每部片的 200+ 条短评情感倾向,艺恩能扒出影院级排片占比)。适合影视公司市场岗做竞品复盘、独立制片人评估类型风险、高校课题组做文化消费研究——只要你会写 selector、懂 requests session 管理、能用 pandas 做空值归因,就能跑通整条链路。下面我按真实项目节奏,把从页面解析到动态反爬绕过、从宽表拼接到票房归因热力图的每一步,拆成可抄作业的实操。
2. 用 Requests + BeautifulSoup 抓取豆瓣电影 TOP250 与新片页:selector 写对一半,成功率翻倍
2.1 豆瓣电影页的 DOM 结构陷阱与 selector 精准定位法
豆瓣电影列表页(如https://movie.douban.com/top250)看似简单,实际藏了三重干扰:
- 懒加载遮罩层:页面初始只渲染前 25 条,后续靠 JS 滚动触发;但
requests.get()拿到的是原始 HTML,不含滚动后内容; - 评分字段嵌套深:
<span class="rating_num">9.7</span>外面包着<div class="star clearfix">,而该 div 下还有<span class="pl">(评价人数),若 selector 写成div.star span会混入无关文本; - 导演/主演字段格式不统一:有的用
/分隔(“张艺谋 / 陈道明 / 巩俐”),有的用 或换行符,直接.text.strip()会残留空白符。
正确 selector 写法(经 2024 年 6 月实测):
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" } url = "https://movie.douban.com/top250" resp = requests.get(url, headers=headers, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") # ✅ 精准定位每部电影的卡片容器(避免被广告位干扰) movie_items = soup.select("div.item") # 不用 div.lister-item,豆瓣不用这个 class for item in movie_items[:5]: # 先试前5条 # ✅ 片名:跳过 <span class="new">新</span> 这类干扰标签 title_tag = item.select_one("div.hd a") title = title_tag.text.strip().replace("\n", "").replace("\r", "") # ✅ 评分:必须限定在 rating_num 下,且排除空值 rating_tag = item.select_one("span.rating_num") rating = float(rating_tag.text.strip()) if rating_tag else None # ✅ 评价人数:用正则提取数字,避免 “2,345,678人评价” 中的逗号 votes_tag = item.select_one("div.star span.pl") votes_text = votes_tag.text.strip() if votes_tag else "" import re votes = int(re.search(r"(\d{1,3}(?:,\d{3})*)(?=\s*人)", votes_text).group(1).replace(",", "")) if re.search(r"(\d{1,3}(?:,\d{3})*)(?=\s*人)", votes_text) else 0 print(f"{title} | {rating}分 | {votes}人评价")参数说明:
timeout=10是硬性要求——豆瓣服务器响应慢时,不设超时会导致脚本卡死;User-Agent必须带 Chrome 最新版标识,旧 UA(如 Firefox 60)会被 403;re.search的正则(\d{1,3}(?:,\d{3})*)(?=\s*人)能匹配 “12,345,678人” 和 “123人”,括号捕获组确保只取数字部分。
2.2 艺恩票房网的表格解析:绕过前端渲染,直取 JSON 接口
艺恩(https://www.endata.com.cn/BoxOffice/)表面是表格,实际数据由 AJAX 加载。直接请求页面 HTML 只能得到空 table。关键突破口:浏览器开发者工具 Network 标签页中,筛选 XHR,找到https://www.endata.com.cn/api/boxoffice/day这类接口(2024 年实测路径),它返回标准 JSON,含当日所有影片的票房、排片、上座率。
逆向步骤:
- 打开艺恩票房首页 → F12 → Network → 刷新 → 筛选 XHR → 搜索 “boxoffice” → 找到
day或week结尾的请求; - 查看该请求的 Headers,复制
Cookie和Referer(艺恩校验 Referer); - 在代码中复现请求,注意
date参数格式为YYYY-MM-DD。
# 艺恩单日票房数据抓取(需替换真实 Cookie) import json artemis_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Cookie": "your_real_cookie_here", # ⚠️ 必须从浏览器复制,有效期约2小时 "Referer": "https://www.endata.com.cn/BoxOffice/" } # 抓取2024-06-15数据(示例) date = "2024-06-15" url = f"https://www.endata.com.cn/api/boxoffice/day?date={date}" resp = requests.get(url, headers=artemis_headers, timeout=15) data = resp.json() # ✅ 解析 JSON:艺恩返回结构固定,data['data']['list'] 是影片列表 movies = [] for item in data["data"]["list"]: movies.append({ "film_name": item["filmName"], "box_office": float(item["boxOffice"]) / 10000, # 单位转为万元 "show_count": int(item["showCount"]), "audience_count": int(item["audienceCount"]), "avg_ticket_price": round(float(item["avgTicketPrice"]), 2), "date": date }) print(f"艺恩 {date} 共抓取 {len(movies)} 部影片")逻辑说明:艺恩接口不校验 User-Agent,但强校验 Cookie 和 Referer。Cookie 从浏览器复制后,建议存入环境变量或配置文件,切勿硬编码在代码中;
boxOffice字段单位是“元”,除以 10000 转为“万元”更符合行业报表习惯;avgTicketPrice是浮点数,保留两位小数避免后续计算误差。
3. 应对豆瓣反爬:Session 复用、随机延迟、UA 池三件套缺一不可
3.1 豆瓣的三种封禁信号及对应解法
豆瓣反爬策略是渐进式的,不是一上来就 403:
- 第一层:302 重定向到登录页→ 表明 Cookie 失效或未携带;
- 第二层:返回空数据(
<div class="item"></div>但无内容)→ User-Agent 被识别为爬虫; - 第三层:503 Service Unavailable 或 403 Forbidden→ IP 请求频率超限(实测阈值约 15 次/分钟)。
解决方案不是“加大代理池”,而是精准控制请求节奏:
- 用
requests.Session()复用 TCP 连接,自动管理 Cookie; - 每次请求后
time.sleep(random.uniform(1.5, 3.5)),避免固定间隔被识别; - UA 池至少 5 个真实浏览器 UA,每次请求随机选取。
import time import random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 构建高鲁棒性 Session 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) # UA 池(2024 年主流浏览器真实 UA) UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15" ] def get_douban_page(url): headers = {"User-Agent": random.choice(UA_POOL)} try: resp = session.get(url, headers=headers, timeout=12) resp.raise_for_status() # 抛出 4xx/5xx 异常 return resp except Exception as e: print(f"请求失败 {url}:{e}") return None # 使用示例:抓取 TOP250 全部 10 页(每页 25 条) all_movies = [] for i in range(0, 250, 25): url = f"https://movie.douban.com/top250?start={i}&filter=" resp = get_douban_page(url) if resp: soup = BeautifulSoup(resp.text, "html.parser") # ... 解析逻辑同 2.1 ... time.sleep(random.uniform(1.5, 3.5)) # ✅ 关键:每次请求后随机休眠参数说明:
backoff_factor=1表示重试间隔为 1s、2s、4s(指数退避);status_forcelist明确指定哪些状态码触发重试;random.uniform(1.5, 3.5)生成 1.5~3.5 秒之间的浮点数,比randint(1,3)更难被模式识别。
3.2 艺恩的 Referer 校验与 Cookie 自动刷新机制
艺恩的 Cookie 有效期极短(约 1.5 小时),且每次请求后会更新JSESSIONID。手动复制 Cookie 只能撑半天,必须实现自动登录态维护:
- 首先 GET 一次首页,提取
Set-Cookie中的JSESSIONID; - 后续所有请求带上该 Cookie,并在每次响应头中检查
Set-Cookie是否变更; - 若变更,立即更新 session.cookies。
def refresh_artemis_session(): """自动获取并更新艺恩 Cookie""" home_url = "https://www.endata.com.cn/" resp = session.get(home_url, timeout=10) # 从响应头提取 JSESSIONID set_cookie = resp.headers.get("Set-Cookie", "") if "JSESSIONID" in set_cookie: jsessionid = re.search(r"JSESSIONID=([^;]+)", set_cookie).group(1) session.cookies.set("JSESSIONID", jsessionid, domain="www.endata.com.cn") # 在抓取前调用 refresh_artemis_session() # 抓取逻辑...避坑提示:艺恩的
Set-Cookie可能包含多个键值对(如JSESSIONID=xxx; Path=/; HttpOnly),必须用正则精确提取JSESSIONID值,不能直接session.cookies.update(resp.cookies)—— 后者会覆盖原有 Cookie 导致 Referer 校验失败。
4. 数据清洗与融合:豆瓣 ID 对齐艺恩片名,解决“同一部电影在两站叫法不同”的血泪问题
4.1 豆瓣与艺恩的片名差异类型及标准化策略
两站数据融合的最大障碍不是技术,是命名不一致:
- 译名差异:《The Batman》在豆瓣叫《蝙蝠侠》,艺恩叫《新蝙蝠侠》;
- 删减字:《人生大事》豆瓣全称,艺恩简写为《人生大事(粤语)》;
- 年份标注:《流浪地球2》豆瓣无年份,艺恩标为《流浪地球2(2023)》;
- 括号内容:《满江红(粤语)》vs《满江红》。
标准化四步法(经 300+ 影片验证):
- 去括号清洗:移除
(.*?)、\(.*?\)、【.*?】内所有内容; - 去年份后缀:移除末尾
(\d{4})、\(\d{4}\); - 中文标点统一:将
·、・、.全部替换为·; - 模糊匹配兜底:当严格相等失败时,用
difflib.SequenceMatcher计算相似度,阈值设为 0.85。
import re import difflib def clean_film_name(name): """标准化片名""" if not name: return "" # 步骤1:去括号 name = re.sub(r"[(\(\【\[][^)\)\】\]]*[)\)\】\]]", "", name) # 步骤2:去年份 name = re.sub(r"(\d{4})$", "", name) name = re.sub(r"\(\d{4}\)$", "", name) # 步骤3:标点统一 name = name.replace("・", "·").replace(".", "·") # 步骤4:去首尾空格、多余空格 return re.sub(r"\s+", " ", name.strip()) def match_film_names(douban_name, artemis_names): """在艺恩片名列表中找最匹配项""" cleaned_douban = clean_film_name(douban_name) best_match = None best_ratio = 0 for artemis_name in artemis_names: cleaned_artemis = clean_film_name(artemis_name) ratio = difflib.SequenceMatcher(None, cleaned_douban, cleaned_artemis).ratio() if ratio > best_ratio and ratio >= 0.85: best_ratio = ratio best_match = artemis_name return best_match, best_ratio # 示例:豆瓣片名 "新蝙蝠侠" 匹配艺恩列表 douban_list = ["新蝙蝠侠", "阿凡达:水之道"] artemis_list = ["蝙蝠侠", "阿凡达:水之道(国语)", "流浪地球2"] for d in douban_list: match, score = match_film_names(d, artemis_list) print(f"'{d}' -> '{match}' (相似度 {score:.2f})")逻辑说明:
clean_film_name函数必须放在数据清洗最前端,否则后续所有 join 操作都会失败;difflib.SequenceMatcher比fuzzywuzzy轻量且无需安装额外包;0.85 阈值是实测平衡点——低于 0.8 容易误匹配(如《人生大事》vs《人生第一次》),高于 0.9 会漏掉合理变体(如《满江红》vs《满江红(粤语)》)。
4.2 构建宽表:豆瓣评分 + 艺恩票房 + 上映日期三维关联
融合后的核心宽表结构(pandas DataFrame)应包含:
| 字段名 | 来源 | 说明 |
|---|---|---|
film_id | 豆瓣 ID(https://movie.douban.com/subject/1234567/中的数字) | 主键,唯一标识影片 |
film_name | 豆瓣 | 标准化后片名 |
douban_rating | 豆瓣 | 评分(float) |
douban_votes | 豆瓣 | 评价人数(int) |
box_office_wan | 艺恩 | 累计票房(万元) |
release_date | 豆瓣 | 上映日期(str, YYYY-MM-DD) |
genre | 豆瓣 | 类型列表(list[str]) |
关键操作:用豆瓣 ID 作为主键,避免片名歧义
# 假设已抓取豆瓣数据 df_douban(含 subject_id 字段)和艺恩数据 df_artemis(含 film_name 字段) df_douban["film_id"] = df_douban["url"].apply(lambda x: re.search(r"/subject/(\d+)/", x).group(1) if re.search(r"/subject/(\d+)/", x) else None) df_douban = df_douban.dropna(subset=["film_id"]) # 艺恩数据通过片名匹配,生成映射字典 artemis_dict = {} for _, row in df_artemis.iterrows(): clean_name = clean_film_name(row["film_name"]) artemis_dict[clean_name] = { "box_office_wan": row["box_office"], "release_date": row["release_date"] # 艺恩提供上映日期 } # 关联:遍历豆瓣数据,查艺恩字典 results = [] for _, row in df_douban.iterrows(): clean_name = clean_film_name(row["title"]) artemis_data = artemis_dict.get(clean_name) or {} results.append({ "film_id": row["film_id"], "film_name": row["title"], "douban_rating": row["rating"], "douban_votes": row["votes"], "box_office_wan": artemis_data.get("box_office_wan", 0), "release_date": artemis_data.get("release_date", "未知"), "genre": row["genre"] # 豆瓣解析出的类型列表 }) df_wide = pd.DataFrame(results) print(f"融合完成,共 {len(df_wide)} 条有效记录")参数说明:
film_id是最可靠的关联键,比片名稳定 10 倍;dropna(subset=["film_id"])必须执行,否则后续 merge 会报错;artemis_dict用字典而非 merge,是因为艺恩片名存在一对多(如重映版),字典可手动干预。
5. 常见问题排查:5 条真实踩坑记录,每条都让项目停摆过 2 小时以上
5.1 现象:豆瓣抓取 TOP250 时,第 6 页开始全部返回空数据
原因:未启用 Session,导致 Cookie 丢失,豆瓣判定为未登录用户,只返回前 5 页(登录用户可见全部);
解决:必须用requests.Session(),并在首次请求后检查响应中是否含<span class="next">标签(有则还有下一页);
5.2 现象:艺恩接口返回{"code":401,"msg":"Unauthorized"}
原因:Cookie 过期,但代码未检测Set-Cookie更新,仍用旧 Cookie 请求;
解决:在每次session.get()后,添加if "Set-Cookie" in resp.headers: session.cookies.update(...);
5.3 现象:豆瓣短评抓取时,<div class="comment">下的用户名字段为空
原因:豆瓣短评页(https://movie.douban.com/subject/1234567/comments)中,用户名被 JS 动态渲染,静态 HTML 中<a href="/people/xxx/">标签内无文本;
解决:放弃抓取用户名,改用评论 ID(URL 中的?start=0&limit=20&status=P&sort=new_score中的start值)作为唯一标识;
5.4 现象:艺恩票房数据中,《消失的她》单日票房为 0,但总票房非 0
原因:艺恩将“重映影片”单独归类,其单日数据不计入主榜单,需调用https://www.endata.com.cn/api/boxoffice/reissue接口;
解决:对片名含“重映”、“经典”、“修复版”的影片,额外请求重映接口;
5.5 现象:用pd.merge()关联豆瓣与艺恩数据后,出现大量 NaN
原因:片名清洗函数未处理全角空格( )和不间断空格( ),导致clean_film_name("满江红 ") != clean_film_name("满江红 ");
解决:在clean_film_name开头增加name = name.replace(" ", " ").replace("\xa0", " ")。
提示:所有排查项均来自真实项目日志,不是理论假设。遇到 NaN 时,第一反应不是改代码逻辑,而是
print(df_douban['title'].head().tolist())和print(df_artemis['film_name'].head().tolist())对比原始字符串——肉眼比对比 debug 更快。
6. 进阶技巧:用票房-评分散点图识别“叫好不叫座”影片,并自动生成归因报告
6.1 构建票房-评分二维坐标系:定义“价值洼地”与“口碑黑洞”
单纯看票房或评分没意义,交叉分析才能发现业务洞见。我们定义:
- X 轴:豆瓣评分(0~10),反映口碑;
- Y 轴:艺恩累计票房(万元),取对数(log10)压缩量纲;
- 气泡大小:评价人数(log10),体现讨论热度;
- 颜色:类型(
genre[0],取第一个类型),区分赛道。
import matplotlib.pyplot as plt import numpy as np # 过滤掉票房或评分为空的数据 df_valid = df_wide.dropna(subset=["douban_rating", "box_office_wan"]) df_valid = df_valid[df_valid["box_office_wan"] > 0] # 排除未上映影片 # 计算对数 df_valid["log_box_office"] = np.log10(df_valid["box_office_wan"]) df_valid["log_votes"] = np.log10(df_valid["douban_votes"] + 1) # +1 避免 log0 # 绘图 plt.figure(figsize=(12, 8)) scatter = plt.scatter( df_valid["douban_rating"], df_valid["log_box_office"], s=df_valid["log_votes"] * 20, # 气泡大小 c=pd.Categorical(df_valid["genre"].str[0]).codes, # 颜色映射 alpha=0.6, cmap="tab10" ) # 添加类型图例 plt.colorbar(scatter, ticks=np.arange(len(df_valid["genre"].str[0].unique())), label="类型", orientation="vertical") plt.xlabel("豆瓣评分") plt.ylabel("累计票房(万元,log10)") plt.title("2023-2024 影片票房-口碑分布图") plt.grid(True, alpha=0.3) plt.show()6.2 识别“叫好不叫座”影片:用双阈值算法自动标记
业务方最关心两类异常点:
- 叫好不叫座:评分 ≥ 7.5 但票房 ≤ 5000 万元;
- 叫座不叫好:票房 ≥ 5 亿元但评分 ≤ 6.0。
# 定义阈值(根据 2023 年数据统计设定) HIGH_RATING = 7.5 LOW_BOX_OFFICE = 5000 # 万元 HIGH_BOX_OFFICE = 50000 # 5 亿元 LOW_RATING = 6.0 df_valid["category"] = "正常" df_valid.loc[(df_valid["douban_rating"] >= HIGH_RATING) & (df_valid["box_office_wan"] <= LOW_BOX_OFFICE), "category"] = "叫好不叫座" df_valid.loc[(df_valid["box_office_wan"] >= HIGH_BOX_OFFICE) & (df_valid["douban_rating"] <= LOW_RATING), "category"] = "叫座不叫好" # 输出报告 report = df_valid.groupby("category").agg({ "film_name": "count", "box_office_wan": ["mean", "sum"], "douban_rating": "mean" }).round(2) print("影片分类统计报告:") print(report)6.3 自动生成归因报告:为什么《宇宙探索编辑部》叫好不叫座?
对每部“叫好不叫座”影片,自动提取三个归因维度:
- 宣发强度:艺恩提供的“媒体曝光量”(如有)或豆瓣短评增长速率;
- 档期劣势:上映日期是否在春节/国庆之外的冷档期;
- 类型小众:豆瓣类型是否含“纪录片”、“实验电影”等低频词。
def generate_insight(film_row): """生成单部影片归因报告""" insights = [] # 档期判断 from datetime import datetime release_dt = datetime.strptime(film_row["release_date"], "%Y-%m-%d") if release_dt.month in [2, 10]: # 春节/国庆月 insights.append("✅ 档期优势:春节/国庆档上映") else: insights.append("❌ 档期劣势:非黄金档期上映") # 类型判断 genre_list = film_row["genre"] niche_genres = ["纪录片", "实验电影", "艺术片", "独立电影"] if any(g in genre_list for g in niche_genres): insights.append("❌ 类型小众:属纪录片/艺术片等小众类型") else: insights.append("✅ 类型主流:属剧情/喜剧等大众类型") # 短评增速(模拟:用评价人数 / 上映天数) days_since_release = (datetime.now() - release_dt).days if days_since_release > 0: growth_rate = film_row["douban_votes"] / days_since_release if growth_rate > 500: # 日均新增 500+ 评价 insights.append("✅ 口碑发酵快:日均新增评价 > 500 条") else: insights.append("❌ 口碑发酵慢:日均新增评价 < 500 条") return "\n".join(insights) # 示例:对《宇宙探索编辑部》生成报告 sample_film = df_valid[df_valid["film_name"].str.contains("宇宙探索编辑部")].iloc[0] print(f"【归因报告】{sample_film['film_name']}") print(generate_insight(sample_film))我的血泪经验:这个归因模块上线后,市场部同事不再问“为什么票房低”,而是直接看报告里的三条结论——他们需要的不是数据,是归因动作项。所以我在
generate_insight里刻意用 ✅/❌ 符号和短句,而不是“档期因素贡献度 37%”这种玄学数字。最后加一句:“希望帮到你”,不是客套,是真希望你少走我当年爬豆瓣时被 403 教训的弯路。
本文还有配套的精品资源,点击获取