这份需求我遇到过不止一次,而且每次说出口的版本都不一样:有人要“评分前50”,有人要“口碑最高的100部”,还有人要“按评价人数排序的热门榜”。看着只是数字差别,落到代码里的边界、分页逻辑和字段清洗方式却完全不同。这篇文章专门记录我完成“某电影评分网站评分前50电影信息抓取”这一项目的完整过程,从需求拆解到请求设计、解析清洗、存储校验,再到反爬应对,一条链路全走完。
项目本身不大,但很有代表性。它属于典型的轻量级榜单抓取:数据量只有几十条,不需要上分布式,不需要消息队列,甚至不需要刻意优化性能。可恰恰是这种规模,最容易暴露爬虫新手的一个通病——拿到URL就直接请求,拿到HTML就正则抠,最后五分钟改了两小时代码。如果你正准备做类似榜单类数据采集,或者想系统梳理一遍爬虫项目的完整流程,这篇文章应该能让你少走不少弯路。
1. 为什么把目标定在前50:需求边界和验收标准
1.1 榜单的自然分页规律
接到这类需求,第一反应不是写代码,而是先把“前50”这三个字拆清楚。
当时我在浏览器里打开了目标网站的电影排行榜,观察了几个关键点。榜单默认按照评分排序,评分相同的条目再按评价人数排序;每页显示25条,也就是说前50名恰好对应两页。这个发现直接把问题简化了一大半——不需要翻几十页,不需要维护复杂的翻页状态,只需要循环两次页面请求即可。
但这里面有一个极易踩的坑:“前50名”不等于“前两页的前25条”。有些榜单会在第一页插入置顶内容、编辑推荐位或者运营广告位,如果直接用每页25条累积到50条,取回来的数据可能已经不是真实的“前50”了。为了验证这一点,我在抓取完两页之后做了一次排名连续性检查:把50条数据的排名字段按1到50拼接起来,看有没有断号或者重复。没有断号,才说明分页边界正确。
类似这种榜单类需求,验收标准往往比实现方式更重要。数据拿不拿得到是基础,拿到的数据是否和页面呈现完全一致,才是项目真正的价值所在。
1.2 字段定义先行
“电影信息”是个模糊的说法。在写第一行代码之前,我把需要的字段列成了清单,并且给每个字段标了是否必填:
| 字段 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
| 排名 | int | 是 | 榜单位次,作为排序和校验依据 |
| 片名 | string | 是 | 主展示字段 |
| 详情页链接 | string | 是 | 唯一标识,用于去重和后续深挖 |
| 评分 | float | 是 | 保留一位小数 |
| 评价人数 | int | 是 | 辅助排序和衡量热度 |
| 年份 | int | 否 | 可用于筛选年代区间 |
这个清单看起来简单,但每个字段背后都有取舍。最容易被忽略的是“详情页链接”。很多人在抓取榜单时只存片名和评分,下次再想获取导演、演员、剧情时,就只能重新爬一遍全站。把链接存下来,意味着你保留了后续扩展的入口——相当于给每条数据留了一把备用钥匙。我接手的项目里,十个有九个最后都会从“榜单前50”扩展到“榜单前50的完整详情”,链接字段是那时候唯一的救命稻草。
1.3 达标不等于能交差
项目验收时我会自查三个问题:数据条数是否正好50条?排名是否连续无重复?评分字段能否正常参与排序和筛选?这三条全部满足,项目才算真正闭环。后续存储部分还会补充更细的校验手段,但到这一步,需求边界已经足够清晰,可以进入正式的页面结构摸底。
2. 动手前必须做的一次静态页面摸底
2.1 URL参数中隐藏的分页线索
很多人打开榜单页面之后会直接复制完整URL塞进代码,这是一个效率很低的做法。正确做法是手动点击下一页,观察URL变化规律,把动态参数和静态路径区分开。
以目标网站的榜单页为例,URL大概长这样:
https://example-movie-site.top/movie/top?start=0&count=25第二页无非是把start=0改成start=25。这个参数的含义很直白:从第几条记录开始偏移。分页循环就变成了:
for offset in range(0, 50, 25): url = f"https://example-movie-site.top/movie/top?start={offset}&count=25"代码虽然只有几行,但比写死两个URL要健壮得多。如果哪一天需求从前50扩大到前250,只需要改range的上限和步长,其他逻辑完全不用动。这也是我坚持在需求阶段先摸清URL参数的原因——看似省事的时间,最后都会加倍花在改代码上。
2.2 信息在HTML中的实际位置
我只在浏览器开发者工具里做了一件事:找到第一条电影标题对应的HTML标签,查看它的父级结构。这一步决定了后面所有解析逻辑怎么写。
榜单项目通常都包裹在同一个容器类名下,比如div.item或者li.movie-item。如果目标网站结构比较规整,一个容器内就能找到片名、链接、评分、评价人数等全部信息。这时候用CSS选择器逐个提取字段,和直接在HTML里正则搜索相比,代码可读性和鲁棒性都明显更好。
当时我定位到的结构大致是这样:
<div class="movie-item"> <span class="rank">1</span> <a class="title" href="/movie/12345">某部电影</a> <span class="star">9.2</span> <span class="people">123456人评价</span> </div>这种结构在榜单类网站里非常典型。选择器只需要写成select_one('div.movie-item')再往里查,逐条解析即可。但页面结构偶尔会不按剧本走,所以还需要一套兜底方案。
2.3 页面变化必须关注的三个位置
实际摸底时发现,榜单页有几个区域并不是每刷一次都一样。第一是顶部可能插入评分人数特别多的“热门推荐”;第二是部分影片有“想看”按钮和标签,会产生额外的包裹层;第三是榜单底部会根据不同的排序方式追加一段导流内容。
这三个位置都会影响一个关键环节:解析时去重。如果第一页和第二页之间出现了重复条目,直接拼接50条数据就会出现实际只有49部电影的情况。因此我在解析阶段就加了一步去重逻辑,后面存储部分会具体讲。
页面摸底做完之后,我开始设计请求层。这一步很多入门教程讲得比较浅,似乎requests.get()一下就结束了。但真实项目里,请求层承担着整个采集脚本的生命线作用。
3. 请求层设计:requests并不是简单地get一次
3.1 稳定请求的三件套
第一次请求榜单页时,我直接用了裸的requests.get,结果第一轮就被拦截了。原因并不复杂——服务器通过请求头里的User-Agent识别出这不是浏览器,直接拒绝了访问。
常规的解决办法是伪装请求头。我一般固定配置三样东西:User-Agent、Accept和Accept-Language。后两项看起来不起眼,但对服务器判断请求是否自然有很大影响。一个正常的浏览器每次请求都会带上完整的Accept头,而写死的脚本默认只发*/*,在对方看来就是异常信号。
import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/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", }除了请求头,超时设置必须加上。很多人刚写爬虫时不习惯设超时,结果一个请求卡住,整个脚本像死机一样停在原地。我习惯用元组形式分别指定连接超时和读取超时:
resp = requests.get(url, headers=HEADERS, timeout=(3.05, 10))连接阶段3秒连不上就放弃,读取阶段10秒拿不到数据就放弃。配合异常捕获,至少不会出现“脚本运行到一半卡死到天荒地老”的尴尬场面。
3.2 使用Session而不是裸请求
榜单前50虽然只有两页,但后续扩展详情页时会有几十个请求。这时候每请求一次都新建TCP连接,效率太低。更好的做法是用requests.Session()维持连接,复用底层TCP连接,减少握手开销。
session = requests.Session() session.headers.update(HEADERS) def fetch_page(session, url): for attempt in range(3): try: resp = session.get(url, timeout=(3.05, 10)) if resp.status_code == 200: return resp elif resp.status_code in (429, 503): time.sleep(2 ** attempt) # 退避等待 except requests.RequestException: time.sleep(1) return None这里加入了简单的重试逻辑。遇到429(请求太频繁)和503(服务暂时不可用)时,按指数退避原则等待,而不是立刻重试。实际运行中,这种方法能让脚本在榜单页这种低频请求场景下几乎不出现失败。
3.3 随机间隔到底该是多少
在请求间隙加了sleep之后,我开始考虑一个值得新手思考的问题:间隔多久才合适?1秒、2秒还是5秒?
我参考的实践标准是:只要不影响对方网站正常服务,就尽可能温和。榜单两页的请求量极小,每页之间延迟1.5到2.5秒即可。这里更推荐用随机值,避免固定间隔被风控系统识别为机器行为。
import random time.sleep(random.uniform(1.5, 2.5))如果后续需要抓取更多条目,建议把间隔拉长到3到5秒,并且观察服务器响应速度的变化。一个令人舒服的爬虫请求频率,应该让你的脚本长时间运行也不会触发任何拦截。感觉“一次拿完”比“稳定拿完”更酷,这其实是新手最危险的心态。
4. 解析和清洗的完整流程:从HTML到结构化数据
4.1 用选择器提取每个字段
请求拿到HTML之后,就进入了整个项目里最需要耐心的阶段:解析。我选择了BeautifulSoup加lxml解析器,这是榜单类静态页面的经典组合,简单、稳定、依赖少。
pip install requests beautifulsoup4 lxml解析逻辑和之前页面摸底时看到的结构一一对应:
from bs4 import BeautifulSoup def parse_movies(html): soup = BeautifulSoup(html, "lxml") results = [] for item in soup.select("div.movie-item"): rank_tag = item.select_one("span.rank") title_tag = item.select_one("a.title") star_tag = item.select_one("span.star") people_tag = item.select_one("span.people") movie = { "rank": clean_rank(rank_tag.text) if rank_tag else None, "title": clean_title(title_tag.text) if title_tag else None, "link": title_tag.get("href") if title_tag else None, "rating": clean_rating(star_tag.text) if star_tag else None, "rating_count": clean_count(people_tag.text) if people_tag else None, } if movie["title"]: results.append(movie) return results这段代码有几个值得留意的地方。第一个是title_tag.get("href"),从中取出详情页的相对路径,后面可以拼接成完整链接。第二个是每个字段都配了一个清洗函数,而不是直接把文本塞进结果里。原因在于页面提取的原始文本大多带着换行、空格和中文说明,“9.2分”和“123456人评价”这种字符串,不处理根本无法直接存入数据库。
4.2 脏数据的几种常见形态与处理
在真实页面上,字段内容远不如示例代码里那么干净。最常见的几种脏数据形态包括:
- 片名前后混有换行符或空格。
- 评分为“-”表示暂无评分。
- 评价人数带有“人评价”字样。
- 年份可能是“2023(中国大陆)”这种混合字符串。
- 链接相对路径缺失前缀域名。
针对这些情况,我写了几个独立的清洗函数:
def clean_title(raw): return raw.replace("\n", "").strip() def clean_rating(raw): num = raw.replace("分", "").strip() if num in ("-", "暂无"): return None try: return float(num) except ValueError: return None def clean_count(raw): num = raw.replace("人评价", "").replace(",", "").strip() if not num: return 0 return int(float(num))把清洗独立成函数还有一个好处:后续如果网站改版,只需要修改对应的清洗函数,解析主逻辑不受影响。我在实际项目里遇到过好几次网站微调的情况,每次都是靠这层隔离把改动控制在最小范围。
4.3 构造电影信息对象并汇总
两个页面的数据都解析完后,需要合并列表。合并时最容易出的问题是重复,页面交界处偶尔会重复出现同一部电影。解决办法是在汇总时以详情页链接作为去重键:
def merge_results(pages_data): seen = set() unique_movies = [] for movie in pages_data: key = movie.get("link") if key and key not in seen: seen.add(key) unique_movies.append(movie) return unique_movies[:50]这里用seen集合记录已经出现过的链接,保证合并后的列表不重复。最后再按排名排序,确认前50真实存在。
解析阶段完成之后,拿着50条字典组成的列表,接下来需要考虑的是:这些数据应该以什么形态保存。
5. 数据存储:CSV、JSON还是SQLite
5.1 各存储格式的取舍
很多人拿到数据后直接print到控制台,看一眼就完了。但项目只要涉及复用或后续扩展,存储就是绕不开的环节。榜单前50这种数据量,常用的三种落地方案我都评估过:
- CSV:适合给非技术人员用。用Excel直接打开,表格展示直观,传递方便。
- JSON:适合程序间交互。字段名和类型保持原样,二次处理时无需转换。
- SQLite:适合需要去重、增量更新和查询统计的场景。单文件数据库,零配置。
我的选择是CSV加SQLite双写。CSV负责交付和人工核对,SQLite负责程序化处理和后续增量抓取。这个组合成本很低,但兼顾了数据交付和工程管理的双重需求。
5.2 去重与增量更新的实现
SQLite的建表语句里,我把详情页链接设成了UNIQUE约束:
import sqlite3 conn = sqlite3.connect("movie_top50.db") conn.execute(""" CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, rank INTEGER, title TEXT NOT NULL, link TEXT UNIQUE, rating REAL, rating_count INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """)插入时用INSERT OR IGNORE,遇到已存在的链接自动跳过:
conn.executemany( """ INSERT OR IGNORE INTO movies (rank, title, link, rating, rating_count) VALUES (:rank, :title, :link, :rating, :rating_count) """, movies ) conn.commit()这样一来,脚本重复运行不会产生脏数据。第二次执行时,已经插入过的电影会被自动忽略,只有新变化的数据——比如评分变动——才需要额外处理。
5.3 落地后的完整性校验
存储落完后,我做了一次完整性校验。统计表里数据条数是否为50,排名是否连续,评分是否在合理范围内:
SELECT COUNT(*) FROM movies; SELECT MIN(rank), MAX(rank) FROM movies; SELECT rank, title, rating, rating_count FROM movies ORDER BY rank LIMIT 5;头几条数据打印出来后,和网站页面人工比对,确认排序完全一致。这一步是交付前最踏实的动作,比任何自动化测试都更能让人放心。
存储这块看似平淡,但它在整个项目里承担了“兜底”的角色。没有可靠的存储和去重,后面一旦发生反爬拦截,清理数据的工作量会让人崩溃。
6. 反爬机制的碰撞与应对:一次真实的追赶过程
6.1 第一轮被拦截的迹象
榜单两页的请求量很低,理论上不该触发风控。但我在一次测试时连续跑了十几次脚本,某次突然发现返回的HTML里没有任何电影条目,只有一句提示和一段空白。状态码依然是200,但页面内容完全变了。
这是爬虫项目中最容易忽略的拦截迹象:请求成功了,内容却不对。很多人写代码只检查状态码,看到200就觉得万事大吉,实际上很多服务器会返回200配上风控页面,专门用来迷惑脚本。我在请求函数里加了一行检查:
if "movie-item" not in resp.text: raise ValueError("页面内容异常,可能被风控拦截")用核心结构是否落地来判断页面是否正常,比只看状态码可靠得多。
6.2 常规应对:改请求头、限速与重试
发现被拦截之后,我先恢复了请求频率,把每页之间的间隔从2秒提高到了4到5秒。同时检查请求头是否遗漏了某些浏览器常规字段。很多情况下,风控触发的原因并不是单个请求头,而是访问频率过高,服务器根据IP维度的统计做出了临时限制。
这里要特别强调:不建议为了对抗风控而疯狂更换代理IP。对于公开榜单这种请求量极小的任务,限速才是根因解法。把频率降到合理范围之后,脚本再跑几次就没有再触发拦截了。
如果遇到验证码页面,我的建议是停止脚本。因为绕验证码、打码平台这些操作已经明显越过了数据采集的合理边界。公开数据可以拿,但不应该以破解安全机制为代价。合理做法是暂停采集,间隔一段时间等风控解除,再以更慢的速度继续。
6.3 需要自动化浏览器的情形如何判断
在这个项目里,我自始至终用的都是requests加BeautifulSoup,甚至没有考虑Selenium。但榜单类网站如果改成动态渲染,HTML源代码里没有电影条目,requests就无能为力了。
判断依据很简单:在浏览器里右键“查看网页源代码”,搜索“movie-item”这个类名是否出现。如果出现,说明数据是服务端直出的,requests完全够用;如果源代码里搜不到,但页面能正常显示,说明是JavaScript异步加载,才需要考虑动态渲染方案。
纯静态页面用Selenium属于杀鸡用牛刀,不仅启动慢、占用内存大,还更容易被识别为自动化操作。把工具选择降级到你需要的那个程度,往往是对目标网站和自身脚本的双重保护。
6.4 合规与频率自律
最后说一句经常被忽略的话。抓取公开榜单数据本身没有问题,但前提是尊重目标网站的访问规则,不频繁请求、不绕过限制、不触碰需要登录才能访问的内容。我的经验是:把采集频率控制在自己的合理范围,脚本往往能稳定运行很久。追求极限速度,通常意味着需要和风控系统反复博弈,既不稳定也不体面。
7. 复盘:这50条数据带来的通用爬虫经验
7.1 数据校验三板斧
项目收尾时,我总结出一套适合榜单类数据的三板斧校验法。
第一板斧是数量校验。统计最终入库条数,确认和目标值一致。第二板斧是排名连续性校验。生成一个1到50的列表,和数据表里的排名字段做差集,看看是否有缺失名次。第三板斧是和线上页面人工比对一次。随机抽榜单前10名,确认片名、评分、评价人数一致。
这三板斧做完,数据质量基本就能兜底了。尤其是第二个校验,帮我发现过一次因为页面插入置顶内容导致的排名错位问题,当时数据总量恰好是50,但排名编号从2开始,等于丢了真正的第1名。
7.2 从“拿一次”到“可更新”
最初的需求只是拿一次前50数据,但我顺手把脚本做成了可重入的结构:请求函数、清洗函数、存储函数完全分离,每次运行都走一遍“请求—解析—去重—入库”流程。
这么做有一个明显好处。榜单排名每天都会微调,评分也会波动,运行一段时间的脚本能自动积累一份历史数据,后续如果想分析“评分如何随时间变化”或“哪部电影排名上升最快”,历史数据就直接成了一次完整的记录。项目可利用价值瞬间提高了一个维度。
7.3 通用扩展思路
最后聊一个扩展方向。现在脚本只抓榜单前50,但解析管道的结构是通用的。如果把入口URL改成其他榜单页面,只要字段清洗函数微调,就能快速适配新的数据源。我在做这一类项目时,会刻意让代码的每个职能切分干净:请求层只负责拿HTML,解析层只负责转结构化数据,存储层只负责写库。后续哪怕换了一个完全不同结构的页面,也只需要替换解析层这一块。
这种“拿一次”变成“可复用”的思路,比单纯跑通一个脚本更有价值。至少在类似的需求第三次找上门时,你要做的只是一次简单的参数调整罢了。