首页 / 资讯中心 / 文章详情

电影评分网站Top50爬虫实战:从需求拆解到反爬应对

电影评分网站Top50爬虫实战:从需求拆解到反爬应对 ★ FEATURED ARTICLE
这份需求我遇到过不止一次而且每次说出口的版本都不一样有人要“评分前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?start0count25第二页无非是把start0改成start25。这个参数的含义很直白从第几条记录开始偏移。分页循环就变成了for offset in range(0, 50, 25): url fhttps://example-movie-site.top/movie/top?start{offset}count25代码虽然只有几行但比写死两个URL要健壮得多。如果哪一天需求从前50扩大到前250只需要改range的上限和步长其他逻辑完全不用动。这也是我坚持在需求阶段先摸清URL参数的原因——看似省事的时间最后都会加倍花在改代码上。2.2 信息在HTML中的实际位置我只在浏览器开发者工具里做了一件事找到第一条电影标题对应的HTML标签查看它的父级结构。这一步决定了后面所有解析逻辑怎么写。榜单项目通常都包裹在同一个容器类名下比如div.item或者li.movie-item。如果目标网站结构比较规整一个容器内就能找到片名、链接、评分、评价人数等全部信息。这时候用CSS选择器逐个提取字段和直接在HTML里正则搜索相比代码可读性和鲁棒性都明显更好。当时我定位到的结构大致是这样div classmovie-item span classrank1/span a classtitle href/movie/12345某部电影/a span classstar9.2/span span classpeople123456人评价/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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }除了请求头超时设置必须加上。很多人刚写爬虫时不习惯设超时结果一个请求卡住整个脚本像死机一样停在原地。我习惯用元组形式分别指定连接超时和读取超时resp requests.get(url, headersHEADERS, 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还是SQLite5.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解析层只负责转结构化数据存储层只负责写库。后续哪怕换了一个完全不同结构的页面也只需要替换解析层这一块。这种“拿一次”变成“可复用”的思路比单纯跑通一个脚本更有价值。至少在类似的需求第三次找上门时你要做的只是一次简单的参数调整罢了。
阅读完成 · 觉得有帮助?
咨询建站