每年三四月份,欧洲三大电影节的入围名单一公布,影迷圈就开始疯狂转发,但大部分转发贴里只有片单本身,没人告诉你这些片单沉淀下来能挖出多少信息。我这两年在做影视数据研究时,把近几届国际A类电影节的入围名单翻了个遍,发现一个尴尬的现实:官方页面上的数据是给读者看的,不是给程序用的,字段乱、结构散,年份和单元全靠肉眼分辨,想按国家、导演、类型做统计基本无从下手。所以我自己写了一套Python爬虫系统,把入围名单抓下来做清洗和智能分析,最后用统计模型做一个简单的获奖预测,所有结果统一导出CSV。这篇文章就把完整思路和实战过程拆开讲,从目标站点分析、反爬策略、字段抽取,到特征工程、预测模型和CSV导出,一步步说清楚。
1. 电影节入围名单为什么值得做成一套采集分析系统
1.1 名单背后的信息维度比想象中多
很多人把入围名单当成一份简单列表,实际上它的信息密度非常高。一部电影能进主竞赛单元,背后至少包含以下维度:电影名称、导演、制片国家或地区、入围单元、年份、时长、类型,以及最重要的——是否最终获奖。这些维度单独看没什么,组合起来却能回答很多有意思的问题。我见过有人统计"近十年哪个国家的入围率上升最快",也见过影评人分析"某类型片在评奖中的胜率趋势",本质上都是先要把名单结构化。
电影节官网每年都会更新入围名单,但问题在于格式不统一。有的年份是一个静态页面,有的年份是分页列表,有的还混着发布会视频转写文字,字段经常缺胳膊少腿。更麻烦的是,往届归档页面和当年页面可能根本不是同一套模板。这种数据散乱的情况,正好是爬虫和分析系统最有发挥空间的地方。
1.2 系统目标与整体流程设计
我给自己定的目标是:构造一个可复用的流程,每年电影节名单公布后,跑一遍脚本就能生成当年的结构化数据和获奖预测参考。系统设计上不搞重型框架,全部用Python标准库加几个常用第三方库搞定,单机就能跑。
整个流程拆成五个模块:
- 采集模块:负责下载入围名单页面,处理请求重试、超时和基本伪装
- 解析模块:从HTML里抽取电影字段,处理缺失和异常
- 清洗与特征模块:统一国家名、构造导演历史获奖次数、国家胜率等衍生特征
- 分析与预测模块:基于历史数据做统计画像,再用规则模型和逻辑回归给出预测
- 导出模块:把最终数据表和预测结果写成CSV文件
这样做的好处是职责清晰,哪个环节出问题只需要改对应的函数。本文后面的章节就按这个流程展开,你可以照着搭一套自己的版本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手写代码前的站点调研与反爬边界
2.1 官网的URL规律与数据载体
写爬虫最忌讳上来就写代码,第一步永远是花半小时人工浏览目标站点,搞清楚三件事:数据在哪个URL里、什么格式、有没有分页。
以电影节官网为例,一般会有历年入围名单归档页面,点开某一年之后能看到当年的完整片单。常见的有两种实现方式:
- 服务端渲染型:片单直接渲染在HTML里,使用requests就能拿全
- 接口返回型:页面是空的,数据通过XHR接口返回JSON,需要先找到接口地址
区分方法很简单:在浏览器里打开页面,右键查看源代码,如果能直接搜到电影标题,就是服务端渲染;搜不到就按F12打开开发者工具,刷新页面后看Network面板里的XHR请求,从响应里找电影标题。
我实际处理时两种都遇到过,同一个网站不同年份可能还会切换实现方式。所以解析逻辑要设计得灵活一点,HTML和JSON两套解析分支都要有。URL规律也要记录,比如年度的入围名单页面路径基本是固定的,只需要替换年份参数,这为后面批量抓取提供了基础。
2.2 反爬应对策略:伪装、限速、重试
电影节官网的反爬强度通常不算高,但也别掉以轻心。最常遇到的反爬是User-Agent检测、请求频率限制和IP临时封禁。我的做法是做好三件事:
第一,伪装请求头。把User-Agent改成真实浏览器的版本,同时加上Accept-Language和Referer,让请求看起来像是浏览器发出的。
第二,限速。两次请求之间至少间隔两到三秒,可以用time.sleep控制。别贪快,电影节官网的数据量本身不大,几百部电影的名单,慢一点也就几分钟的事。
第三,重试和退避。网络请求一定会失败,不要假设一次就成功。我习惯用urllib3的Retry配置,自动处理超时和5xx错误,配合指数退避的时间间隔,避免失败后立即重试把对方服务器打挂。
2.3 合规与数据伦理的底线
爬虫不是不能写,但要有边界。我给自己定了几条必须遵守的原则:
- 只抓公开的、不涉及隐私的信息,电影节入围名单属于公开宣传材料,合规性相对清晰
- 通过正常页面入口抓取,不绕过登录和验证码
- 控制请求频率,不给目标服务器造成压力
- 抓下来的数据只用于个人研究和学习,不用于商业用途或大规模分发
如果你要常跑这类爬虫,建议把robots.txt读一遍,尊重对方的爬虫协议。合规这个事不能抱侥幸心理,做技术的人更要懂得什么能碰、什么不能碰。
3. 核心采集模块实现:requests与BeautifulSoup的完整落地
3.1 Session管理与重试机制
采集模块是整个系统的入口,也是最容易写崩的部分。我强烈建议使用requests.Session而不是每次直接调用requests.get。Session会自动维持Cookie和连接池,对需要保持会话状态的站点很重要,即使目标站点不需要登录,也能减少TCP握手开销,提升抓取速度。
请求重试配置我写成了一段公共代码:
python复制import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def make_session(retries=3):
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
})
retry = Retry(
total=retries,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
这里backoff_factor=0.5表示第一次重试前等待0.5秒,第二次等待1秒,第三次2秒,指数递增。这个方法在抓取稳定性上帮了大忙,刚开始写爬虫的时候我不加重试,经常因为网络抖动导致整个任务中断,加了之后基本不用管了。
抓取函数本身也要注意超时设置,requests如果不设置timeout,会一直等下去,程序就卡死了。我习惯统一设置10秒连接超时和10秒读超时。
3.2 页面解析与字段抽取
解析之前先确定解析器。BeautifulSoup配合lxml解析速度够快,选择器写起来也方便。我的经验是,不要过度依赖CSS选择器逐级定位,因为官网的HTML结构很可能改版,一旦某个中间层class变了,整条选择链就断了。更稳的做法是分层取值,加上容错判断。
下面是我常用的字段抽取模式:
python复制from bs4 import BeautifulSoup
def parse_film_items(page_html):
soup = BeautifulSoup(page_html, "lxml")
films = []
# 找所有电影条目容器,选择器尽量宽松
for item in soup.select(".film-item, .movie-card, [class*=entry]"):
title_node = item.select_one(".title a, h3 a, .film-title")
director_node = item.select_one(".director, .meta-director")
country_node = item.select_one(".country, .meta-country")
section_node = item.select_one(".section, .unit")
films.append({
"title": title_node.get_text(strip=True) if title_node else None,
"director": director_node.get_text(strip=True) if director_node else None,
"country": country_node.get_text(strip=True) if country_node else None,
"section": section_node.get_text(strip=True) if section_node else None,
})
return films
写一个选择器时我会用逗号分隔多个可能路径,比如".title a, h3 a, .film-title",这样其中一条失效时其他路径还能接上。字段缺失时先给None,到清洗阶段再统一处理,不要在解析阶段直接跳过整条记录。
真正遇到过的情况是导演名和制片国家在同一个p标签里,比如"Dir. A. Dupont | France",这时候就需要用字符串拆分和正则进一步处理。这个细节隐藏在数据里,不实际抓一次很难发现。
3.3 来源登记与异常隔离
每一步抓取都要可以追溯。我在每条数据里加了两个字段:source_url和fetched_at,记录数据从哪个页面来的、什么时候抓的。这个习惯是踩了不少坑才养成的,有一次解析规则写错,导致某个年份的所有电影导演字段全是空的,如果没有来源记录,只能重新全网找数据;有了来源记录,直接按URL过滤重新抓那一个页面就行。
异常控制也要做细。我的做法是单页抓取函数内部捕获异常,失败时记录日志但不中断整体流程,等一轮跑完再集中看日志决定是否补抓。如果中途因为一条异常就break,后面的年份全废了。
4. 特征工程:把入围名单变成可学习的数据集
4.1 字段清洗与统一格式
解析模块拿到的原始名单还不能直接用,必须做一轮严格的清洗。最常见的坑有两个。
第一个是制片国家字段格式不统一。"法国"和"France"同时存在,同一个国家有全称和简称,甚至还有合拍片的"法国/德国/意大利"这种组合表达。我的处理思路是建立别名映射表,把常见中文名和英文名统一映射到标准国家代码或标准中文名,合拍片则拆分成多个国家并保留一个主字段。
第二个是空缺值处理。导演名缺失、片名缺失都很常见,我统一保留为None,数据入库时允许为空,但在特征构造阶段会单独处理,不参与需要该字段的计算。
清洗后的数据结构我一般这样定义:
| 字段名 | 类型 | 说明 |
|---|---|---|
| title | str | 电影名,已去除多余空格 |
| director | str | 导演名,已统一格式 |
| country_primary | str | 主制片国家,已标准化 |
| country_all | list | 全部制片国家 |
| section | str | 入围单元,如主竞赛、一种关注 |
| year | int | 入围年份 |
| is_awarded | int | 是否获奖,0/1,用于建模标签 |
这个表结构是我在实际工作中反复调整后得到的,够用又不过度设计。
4.2 衍生特征:把获奖潜力拆成可量化的指标
原始字段只能做描述性统计,要预测获奖必须构造能体现"历史趋势"的衍生特征。我构造了四类:
导演历史入围次数和获奖次数。一个导演过去经常入围,说明其风格和评委口味契合度较高。做法是把历史数据按导演分组,统计截至当前年前累计的获奖数量。
国家历史胜率。制片国家在特定年份的获奖比例,比如某国过去二十年的获奖概率是8%,这个比率作为国家维度的先验。
单元权重。不同入围单元的含金量完全不同,主竞赛单元获奖概率远高于其他单元。我按历史数据计算了每个单元的平均获奖比例,作为权重特征。
类型偏好。电影节评奖对类型片和作者电影有明显偏向,可以从历史获奖影片的类型分布算出权重,再映射到新片。
代码示意:
python复制def build_features(df, history_df):
# 导演历史获奖次数
director_wins = history_df[history_df["is_awarded"] == 1]
df["director_prior_wins"] = df["director"].map(
director_wins.groupby("director").size()
).fillna(0)
# 国家历史胜率
country_stats = history_df.groupby("country_primary")["is_awarded"].agg(["mean", "count"])
df["country_win_rate"] = df["country_primary"].map(country_stats["mean"]).fillna(0.05)
# 单元权重(历史获奖率)
section_stats = history_df.groupby("section")["is_awarded"].mean()
df["section_weight"] = df["section"].map(section_stats).fillna(0.03)
return df
4.3 构造训练集时最容易犯的时间穿越错误
这一点必须单独拿出来讲,因为几乎所有做预测的新手都会踩。构造训练样本时,历史数据和预测目标必须严格按时间切分,拿2015年到2022年入围名单做特征,预测对象只能是2023年及之后的获奖结果。
具体来说,预测2023年某部电影是否获奖,它的特征只能用2015到2022年的统计数据计算,绝对不能用2023年当年的整体获奖率来填充特征。如果直接用全量历史数据构造特征,再训练模型预测当年,特征里已经包含了当年的信息,测试结果会虚高得离谱,实际部署时立刻就崩。
我用的方法是对每个目标年份单独回溯统计,只取小于目标年份的数据计算特征。代码上就是每次训练前做一次按年份的过滤循环,代码会多几行,但结果可信得多。
5. 智能分析模块:从统计画像到获奖预测
5.1 入围名单的统计画像
数据清洗完,先做基础分析,这能帮我们了解数据全貌,也能验证采集过程有没有漏数据。我用pandas做聚合统计,一般看这几个维度:
- 各年份入围数量变化,确认数据连续性
- 主力制片国家的入围次数排名
- 导演入围频次分布,是否出现明显的"常客"
- 各单元入围数量占比和获奖占比
有次分析发现某年的数据集中,主竞赛单元入围数量明显偏少,查日志后确认是分页的第二页没抓到。这类问题靠观感就能发现,所以统计画像不要跳过,它是对数据质量的二次校验。
5.2 获奖预测:先用规则,再上模型
预测模型这块,我的建议是分两步走。第一步先做一个可解释的规则模型,对特征做加权求和,权重来自历史特征的获奖概率差异,本质上是一种手工的逻辑回归。它的优点是透明,可以讲清楚"哪部电影为什么排第一"。
第二步再把同样的特征喂给逻辑回归。之所以首选逻辑回归而不是上来就上XGBoost,是因为样本量太小,电影节一年撑死一百多部入围影片,获奖标签更是极度稀疏,复杂模型很容易过拟合。逻辑回归的权重还可以和规则模型对照,验证特征方向是否合理。
训练和评估要特别注意类别不平衡。我用的评估指标不是准确率,而是ROC曲线下面积和Top-K命中率。Top-K命中率的意思是:模型给所有影片打分排序,看最终获奖影片是否出现在排序前10名里。这个指标更贴近实际使用场景。
python复制from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
feature_cols = ["director_prior_wins", "country_win_rate", "section_weight"]
X = train_df[feature_cols]
y = train_df["is_awarded"]
model = LogisticRegression(max_iter=1000)
scores = cross_val_score(model, X, y, cv=5, scoring="roc_auc")
print("CV ROC_AUC:", scores.mean())
model.fit(X, y)
test_df["pred_score"] = model.predict_proba(test_df[feature_cols])[:, 1]
top10 = test_df.nlargest(10, "pred_score")
实际预测的效果是,Top-10名单里能覆盖三到五个最终获奖影片,这个水平对纯数据预测来说已经不错了。别指望准确预测冠军,评奖的主观性决定了数据模型能给出的只是一个概率排序,而非确定性答案。
5.3 预测结果的解释机制
模型输出不能只是一列概率值,要能解释。我发现把导演历史权重、国家胜率、单元权重拆开输出,比只给一个总体分数更有参考价值。比如某部电影预测分高,可能是因为导演之前拿过三次奖,国家胜率又高,单元权重也占优,每一项拆开都能看懂。
我把解释字段直接写到最终CSV里,方便人工复核时理解预测依据。这个设计一开始没做,后来被人问"你凭什么说这部片子能拿奖"的时候才发现解释性比准确率更重要。数据模型给出的永远是一个辅助参考,最终判断要结合行业知识和当年评审语境。
6. CSV导出与长期运行的经验总结
6.1 UTF-8-SIG编码问题
导出CSV有一个细节必须注意:如果用默认的UTF-8编码写入CSV,用Excel直接打开中文会乱码。原因在于Excel默认以ANSI编码解析CSV文件,无法正确识别无BOM的UTF-8。解决方法很简单,写入时指定编码为utf-8-sig,Python会自动加上BOM头,Excel就能正常识别了。
python复制import pandas as pd
df.to_csv("festival_films_2024.csv", index=False, encoding="utf-8-sig")
不要小看这一个参数,很多人拿到乱码CSV就到处找转码工具,实际上一行代码的事。如果希望同时生成xlsx版本,可以用pandas配合openpyxl引擎导出,但有CSV就够了,方便后续程序处理。
6.2 增量更新与日志管理
采集系统不能只跑一次,电影节每年都有新名单,所以我把项目设计成按年份增量抓取的。每次运行前检查已有数据里有哪些年份,只抓缺失的年份和当年新增的入围名单。数据文件用文件名带年份的方式存放,避免误覆盖。
日志管理我用Python自带的logging模块,输出到控制台和文件各一份。日志级别设为INFO,每抓完一个页面记录一条,出错时记ERROR并附带URL。没有日志的爬虫等于闭着眼睛开车,出了问题完全无从查起。
6.3 我踩过的坑和后续扩展方向
最后分享几个实实在在踩过的坑。
第一个是解析规则写得太死。之前直接选择器精确到了某个嵌套层级,官网一个很小的样式调整就把整个解析器打崩了,后来改成宽松的候选选择器方案才稳下来。第二个是没做请求失败隔离,一个超时页面导致整个循环中断,加上单页异常捕获后这个问题才解决。第三个是时间穿越问题,这个前面专门讲过了,是预测类项目最容易出的逻辑错误。
后续想扩展的方向有两个。一是接入电影节官方社交媒体数据,做舆论热度特征,看看和获奖结果有没有相关性。二是把历史数据可视化,做一个入围名单趋势分析的小页面。这些都是在现有CSV和特征体系上的增量工作,不需要推倒重来。
这套系统从我第一次写完到现在,已经跑了好几届电影节的数据,从一开始的纯手工整理,到如今一条命令搞定抓取、清洗、分析和导出,回头最大感受是:爬虫只是入口,真正花时间的永远是对数据的理解。先把数据结构和特征逻辑想清楚,代码本身反而简单。
