文章目录
- [爬虫项目: 获取高分电影的数据总结](#爬虫项目: 获取高分电影的数据总结)
-
- 一、项目概述
- 二、核心模块拆解
-
- [1. 请求头构造(反爬基础)](#1. 请求头构造(反爬基础))
- [2. 榜单数据获取(分页处理)](#2. 榜单数据获取(分页处理))
- [3. 详情页解析(XPath)](#3. 详情页解析(XPath))
- [三、问题发现与改进:从位置索引到 class 语义定位](#三、问题发现与改进:从位置索引到 class 语义定位)
-
- [3.1 初始版本的写法](#3.1 初始版本的写法)
- [3.2 运行后发现的问题](#3.2 运行后发现的问题)
- [3.3 原因分析](#3.3 原因分析)
- [3.4 改进方案:用 `@class` 语义定位](#3.4 改进方案:用
@class语义定位) - [3.5 引申:XPath 定位方式的稳定性优先级](#3.5 引申:XPath 定位方式的稳定性优先级)
- [3.6 相同思路可以继续优化的地方](#3.6 相同思路可以继续优化的地方)
- 四、数据保存(CSV)
- 五、可继续优化点
-
- [1. 字符串拼接表单参数不优雅](#1. 字符串拼接表单参数不优雅)
- [2. 没有异常处理](#2. 没有异常处理)
- [3. 缺少请求间隔,容易被封](#3. 缺少请求间隔,容易被封)
- [4. 字段抽取过散,可以抽成配置](#4. 字段抽取过散,可以抽成配置)
- 六、本项目涉及的知识点串联
- 七、总结
爬虫项目: 获取高分电影的数据总结
一、项目概述
本项目实现了一个电影数据爬虫,从 TMDB(The Movie Database)网站抓取高分电影榜单数据,解析电影详情页,最终将数据保存为 CSV 文件。
整体流程:
获取榜单页 → 解析电影详情页 URL → 请求详情页 → XPath 解析字段 → 汇总写入 CSV
二、核心模块拆解
1. 请求头构造(反爬基础)
python
headers = {
"Connection": "close",
"User-Agent": "Mozilla/5.0 ... Chrome/152.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN, zh; q = 0.9"
}
几个要点:
- User-Agent:伪装成真实浏览器,避免被服务器识别为爬虫直接拒绝
- Accept-Language:告诉服务器返回中文内容(影响 TMDB 页面文案)
- Connection: close:每次请求完关闭连接,避免连接池被占用过久
在详情页请求里还额外加了 Referer,模拟"从榜单页点进详情页"的真实跳转来源。
反爬的第一层,就是让请求"看起来像正常用户发出的"。
2. 榜单数据获取(分页处理)
TMDB 的高分榜有两个入口:
| 页码 | URL | 请求方式 |
|---|---|---|
| 第 1 页 | /movie/top-rated |
GET |
| 第 2~5 页 | /discover/movie/items |
POST(带表单参数) |
python
for page_num in range(1, 6):
if page_num == 1:
response = requests.get(TMDB_TOP_URL_1, timeout=20, headers=headers)
else:
response = requests.post(TMDB_TOP_URL_2, data=f"...&page={page_num}&...", timeout=20, headers=headers)
为什么第 2 页开始要用 POST?
TMDB 的 discover 接口筛选条件非常多(日期、语言、评分区间、时长范围......),用 POST 表单提交更合适,URL 也更干净。参数里的关键项:
sort_by=vote_average.desc:按平均分降序vote_count.gte=300:过滤掉评分人数太少的电影(防止"1 人打 10 分"登顶)with_runtime.gte/lte:时长范围
分页爬取的核心是观察请求规律 ,找到翻页时唯一变化的参数(这里是
page)。
3. 详情页解析(XPath)
python
movie_doc = html.fromstring(movie_response.text)
movie_name = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/h2/a/text()')
movie_years = movie_doc.xpath('//*[@id="original_header"]/.../h2/span/text()')
...
关键点:
html.fromstring():把 HTML 字符串转成结构化文档对象,之后可以用 XPath 查询xpath()返回的是列表 ,即使只有一个结果也是[xxx]- 取值时统一做空列表兜底:
python
'电影名': movie_name[0].strip() if len(movie_name) > 0 else ""
XPath 定位一定要先在浏览器 F12 里验证,直接抄路径很容易因为页面动态渲染而落空。
三、问题发现与改进:从位置索引到 class 语义定位
3.1 初始版本的写法
详情页头部结构里,上映日期、标签、时长这三个字段,最初是用纯位置索引定位的:
python
# 上映日期
movie_data = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[2]/text()')
# 标签
movie_tags = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[3]/a/text()')
# 时长
movie_cost_times = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[4]/text()')
当时抓取第一页几部电影时看起来是正常的,因为这几部电影的详情页结构一致,span[2] / span[3] / span[4] 恰好一一对应上映日期、标签和时长。
3.2 运行后发现的问题
爬完 5 页、上百部电影之后,检查 CSV 结果发现:
- 部分电影的上映日期是空白
- 部分电影的标签列内容错位,或者干脆为空
- 部分电影的时长是空白
但同一部电影的电影名和年份都是正常的。
3.3 原因分析
回到 TMDB 详情页的结构上找原因。头部那一行 div 里,span 的顺序并不是固定死的,它大致按这样的顺序排布:
[0] 分级认证(如 PG-13 / R / TV-MA)
[1] 上映日期(release)
[2] 类型标签(genres)
[3] 时长(runtime)
[4] 导演、编剧等其他信息
关键在于:不是每部电影都有分级认证。
- 有分级的电影 :
span[2]= 上映日期 ✅,span[3]= 标签 ✅,span[4]= 时长 ✅ - 没有分级的电影 :整个序列前移一位,
span[2]变成了标签,span[3]变成了时长,span[4]可能是别的东西 ❌
位置索引一旦错位,取到的要么是错的字段,要么是空字符串。这就是"部分电影信息空白/错位"的根因。
换句话说:位置索引把"结构假设"写死了。它假设"上映日期永远是第 2 个 span",但这个假设在没有分级的电影上不成立。
3.4 改进方案:用 @class 语义定位
把位置索引 换成 @class 属性定位:
python
# 上映日期
movie_data = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[@class = "release"]/text()')
# 标签
movie_tags = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[@class = "genres"]/a/text()')
# 时长
movie_cost_times = movie_doc.xpath('//*[@id="original_header"]/div[2]/section/div[1]/div/span[@class = "runtime"]/text()')
对比一下:
| 字段 | 改进前(位置索引) | 改进后(class 定位) |
|---|---|---|
| 上映日期 | div/span[2]/text() |
div/span[@class="release"]/text() |
| 标签 | div/span[3]/a/text() |
div/span[@class="genres"]/a/text() |
| 时长 | div/span[4]/text() |
div/span[@class="runtime"]/text() |
为什么 class 定位更稳?
span[2]表示"父节点下第 2 个 span",依赖兄弟节点的数量和顺序span[@class="release"]表示"父节点下 class 为 release 的那个 span",依赖语义,不依赖位置
只要 TMDB 不改变字段的 class 名,无论前面有没有分级,无论顺序怎么变,都能精确命中。再次运行后,空白字段的问题消失。
3.5 引申:XPath 定位方式的稳定性优先级
按稳定性从高到低排序:
@id+@class语义定位 ← 最稳,推荐@id+ 层级结构定位 ://*[@id="original_header"]/.../h2/a/text()- 文本内容定位 :
//span[text()="Runtime"]/following-sibling::span/text() - 纯位置索引 :
div[2]/span[3]← 最脆,容易翻车
一句话原则:能靠"属性"和"语义"定位的,就别靠"位置"定位。位置索引描述的是"它在第几个",语义定位描述的是"我要什么"------后者才是页面结构和字段语义之间的稳定契约。
3.6 相同思路可以继续优化的地方
榜单列表项的选择器也是靠一串长长的 class:
python
movies_list = document.xpath('//*[@class="w-full overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-colors hover:border-gray-300"]')
这类 Tailwind 生成的 class 组合,TMDB 改版样式时容易整体失效。可以用更稳定的结构特征:
python
# 抓所有指向 /movie/ 的链接,再回溯父级卡片
movies_list = document.xpath('//a[contains(@href, "/movie/")]')
四、数据保存(CSV)
python
def save_all_movies(all_movies):
with open(MOVIE_LIST_FILE, 'w', encoding='UTF-8', newline='') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=["电影名","年份","上映日期","时长","标签","分数","语言"])
writer.writeheader()
writer.writerows(all_movies)
几个细节:
encoding='UTF-8':中文必须指定,否则 Windows 下默认 GBK 会乱码newline='':防止 Windows 下每行之间多出空行DictWriter:按字典键写入,fieldnames 顺序决定列顺序
五、可继续优化点
1. 字符串拼接表单参数不优雅
python
data = f"air_date.gte=&...&page={page_num}&..."
改进 :用字典让 requests 自动编码:
python
data = {
"sort_by": "vote_average.desc",
"vote_count.gte": 300,
"page": page_num,
}
response = requests.post(url, data=data, ...)
2. 没有异常处理
单个详情页请求失败(超时、404)会直接中断整个爬虫。建议:
python
try:
movie_response = requests.get(movie_info_url, headers=headers, timeout=15)
movie_response.raise_for_status()
except requests.RequestException as e:
print(f"请求失败: {movie_info_url}, 原因: {e}")
return None
调用处过滤掉 None 再保存。
3. 缺少请求间隔,容易被封
连续 5 页 × 每页 ~20 部电影 = 上百次请求。建议加:
python
import time, random
time.sleep(random.uniform(0.5, 1.5))
4. 字段抽取过散,可以抽成配置
现在每加一个字段都要改三处(定义、xpath、dict)。建议用列表配置:
python
FIELDS = [
("电影名", '//*[@id="original_header"]/.../h2/a/text()', "first"),
("年份", '//*[@id="original_header"]/.../h2/span/text()', "first"),
...
]
六、本项目涉及的知识点串联
| 环节 | 用到的知识 |
|---|---|
| 发请求 | requests.get / post、headers 伪装、timeout |
| 拿 HTML | lxml.html.fromstring |
| 定位元素 | XPath 语法(//、@id、@class、text()、contains()) |
| 数据清洗 | strip()、空列表兜底、','.join() |
| 写文件 | csv.DictWriter、encoding='UTF-8'、newline='' |
| 流程控制 | for 循环分页、字典组装 |
七、总结
这个案例虽小,但把爬虫的完整链路(请求 → 解析 → 清洗 → 存储)走了一遍。
本次改进最大的收获是:XPath 定位应当尽量依赖"语义属性(class/id)",而不是"位置索引"。
- 初始版本用
span[2] / span[3] / span[4]定位,在结构一致的页面上勉强能用 - 但 TMDB 详情页并非所有电影都有分级认证,导致
span序列错位 - 一错位,位置索引取到的就是错误字段或空字符串,于是出现"部分电影信息空白"
- 换成
span[@class="release"]这种语义定位后,无论有没有分级、顺序怎么变,都能稳定命中
位置索引是脆弱的,语义定位才是稳定的------这是本次踩坑最核心的反思。
后续可以从三个方向继续深化:
- 健壮性:异常捕获、重试机制、限速
- 扩展性:字段配置化、XPath 选择器从外部配置读取
- 持久化:从 CSV 升级到 SQLite / MySQL,便于后续查询和增量更新
一句话总结:
爬虫的本质,是"用程序模拟人类浏览网页的行为,再把结构化的信息提取出来" 。
XPath 的精髓,是"用语义属性描述你要什么",而不是"用位置索引告诉程序它排第几"。