☰
requests+正则:从零抓取猫眼TOP100电影榜单
2026/10/13 18:50:12 网站建设 项目流程

简介:面向Python爬虫初学者和入门开发者,这份资料以猫眼电影TOP100榜单为实战对象,完整演示如何用requests库抓取HTML页面,再借助正则表达式从网页源码中提取电影名称、评分等字段,并兼顾分页、延时与爬虫道德规范。资源共6个文件,包含1个Python脚本、1份Markdown说明、1份结果文本和3张PNG截图,整体仅2.41MB,脚本与文档互相配合,截图展示页面URL变化、榜单信息和源代码位置,便于对照学习。已有1259人学习下载,适合希望独立完成一次小规模网页抓取项目的学习者。通过阅读脚本和文档,可掌握requests发送GET请求、正则匹配标签、列表解析结果的基本套路,并了解验证码、IP限制等反爬机制的应对方向。这为后续学习BeautifulSoup、Scrapy等更完整的解析库与框架工具打下基础,也提供了爬虫开发中的合规操作参考。

1. 用 requests 加正则抓猫眼 TOP100,是值得每个 Python 初学者手写一遍的练手项目

如果你正在学 Python,想找一个既有真实数据、又有解析难度、还能拿得出手的小项目,抓猫眼 TOP100 电影榜单是绕不开的那个。这个任务只用两个基础库:requests负责把榜单页面整体拿回来,正则表达式负责从 HTML 里把电影名、主演、上映时间、评分逐项抠出来。它能练到的点包括请求头的构造、超时与异常处理、非贪婪匹配、字段清洗,以及最容易被忽视的反爬节奏控制。

很多人一上来就学 Scrapy 或者 BeautifulSoup,反而把这个最经典的组合跳过了。实际工作中,临时抓一个静态页面、做一次数据核对,requests 加正则往往是最快、最少依赖的解法,而且不会出现框架安装失败这类环境问题。这篇文章用可复现的步骤,把一个单页抓取扩展到 TOP100 全量数据,并把中间常见的坑讲清楚。

2. 动手前的技术选型:requests 拿页面、正则切字段,先把账算清楚

2.1 为什么不用 Scrapy 和 BeautifulSoup:任务体量决定工具选型

先回答一个最现实的问题:抓 100 条数据,明明有 BeautifulSoup 这种专门解析 HTML 的库,也有 Scrapy 这种成熟框架,为什么还要用正则?

核心判断标准是任务体量。TOP100 榜单是单站点、单列表页、固定字段的静态数据场景。100 条记录、5 个字段,结构非常稳定,页面规模也不算大。这种任务如果用 Scrapy,需要建项目、写 pipeline、配置下载中间件,一套骨架下来文件数量就有十几个。对于只想拿到一份干净 CSV 的人,这套成本是严重超标的。BeautifulSoup 倒是不重,但它本身也是个依赖库,还需要配合lxml或html.parser解析器才能工作。在很多临时脚本场景里,目标环境可能没有预装这些库,而正则表达式和requests是 Python 环境里最接近“开箱即用”的选项。

有人会反驳说 BeautifulSoup 写起来更直观、容错更好。这一点我承认,但它解决的是“HTML 结构混乱、需要容错解析”的问题。猫眼榜单页的字段层级非常规律,每一个<dd>块里都是一部电影的完整信息,标签顺序固定,文字内容干净。这种情况下正则的匹配效率反而更高,因为它不需要构建一棵完整 DOM 树再遍历节点;它是直接对文本流做线性扫描,拿到一个模式就切一刀。再加上requests也解决了 HTTP 层的绝大多数问题,比如连接池重用、Session 状态保持、超时控制,两个库配合起来就是一套很轻的抓取工具。

还有一层容易被忽略的理由:学正则。很多人写了好几年 Python,一到实际处理日志、清洗文本、提取配置项时就卡壳,原因就是平时遇到结构化数据全用现成解析器,没练过正则。抓猫眼榜单是一个绝佳的正则练习场景——结构不复杂,但足够覆盖分组捕获、非贪婪匹配、单行模式这些核心知识点。你会因为要抓真实数据而把re模块用熟,这件事比爬下来 100 条电影信息本身更有长期价值。

2.2 requests 请求页面的基础配置:请求头、超时与编码处理

requests的使用门槛很低,不代表随便get一下就能稳定拿到页面。实际抓取时最容易出问题的三件事:请求头缺失、超时未设置、编码判断错误。

请求头里最重要的是User-Agent。很多站点会拦截不带浏览器标识的请求,因为 Python 默认的User-Agent是python-requests/x.x.x,一眼就能看出是程序在访问。我一般会带上完整的浏览器标识,包括Accept和Accept-Language,让请求看起来更像真实浏览器。这里有个细节:Accept-Language如果缺失,部分站点可能会返回英文页面或者默认编码页面,导致后面的正则匹配直接落空。

超时参数必须在每次请求里显式设置。不设timeout的请求,在网络异常时可能挂几十秒甚至更久,整个爬虫就像死掉一样。requests.get的timeout参数可以传一个浮点数表示连接和读取的总超时,也可以传元组(connect_timeout, read_timeout)分别控制。我更推荐后者,因为连接超时和读取超时是两类不同的问题——连不上是网络或域名问题,读不到是服务端处理慢,混在一起不好排查。

编码处理是最典型的“黑匣子”问题。requests会根据响应头里的charset推测编码,但如果站点返回头里没写或者写得不标准,response.text就会用默认的ISO-8859-1去解码,结果就是满屏乱码。标准做法是主动指定response.encoding = "utf-8",然后在拿到text后先打印前 300 个字符检查一下。如果再遇到乱码,就要考虑用response.content拿原始字节,再自己decode,这就进入后面避坑章节要展开的排查过程了。

2.3 正则匹配的核心逻辑:把 HTML 当文本切片,用非贪婪匹配圈定字段

正则表达式不是 HTML 解析器,它是文本模式匹配器。这一点必须想明白——你面对的不是 XML 树,而是一长串字符串,你要做的是在字符串里找到那些“看起来像电影信息”的片段,然后把需要的部分圈出来。

抓取场景最常用的技术是分组捕获。Python 里用圆括号定义一个捕获组,match.group(1)就能拿到第一组的内容。命名分组更好用,写法是(?P<name>...),拿到结果时可以直接match.group("name"),不用数序号。字段一多,命名分组的可读性优势就非常明显。

非贪婪匹配是另一个必须熟练掌握的点。默认情况下.*是贪婪的,会尽可能多地匹配字符。比如你想匹配<p class="star">内容</p>,写成<p class="star">.*</p>时,如果页面里有十个这样的标签,贪婪模式会从第一个<p class="star">一直匹配到最后一个</p>,中间全部吞掉。解决办法是写成.*?,非贪婪模式会在满足条件的最短位置停下来。配合re.S标志使用,让.能匹配换行符,HTML 跨行时的匹配才会正常。

实操里还有一个更稳的策略:先切片、再提取。先用一个大正则把每一部电影的<dd>...</dd>块切出来,然后再在这个小片段里用多个小正则分别提取字段。这样做的好处是就算某个字段缺失或者顺序有变化,也不会波及其他电影的匹配,问题会被限制在小范围内,定位和修复都更快。

3. 单页抓取跑通:观察榜单结构、写第一版可用代码

3.1 观察目标页面:字段、层级与翻页规则

拿到页面之后第一件事不是写代码,而是把页面内容看明白。把榜单页 URL 复制到浏览器里打开,右键查看网页源代码,然后用 Ctrl + F 搜索“board-index”或者“star”,找到一部电影的 HTML 块。

观察要点有三个。第一,看单部电影被什么标签包裹。通常每一部电影对应一个<dd>元素,里面包含排名、名称、主演、上映时间和评分。第二,看每个字段所在的位置。名称在<p class="name">里的<a>标签的title属性上,主演在<p class="star">里,上映时间在<p class="releasetime">里,评分由<i class="integer">和<i class="fraction">两个标签拼接而成。第三,看翻页的 URL 规律。TOP100 分 10 页,每页 10 部,URL 里通过offset参数控制页码偏移,第二页offset=10,第三页offset=20。

这里提醒一点:页面结构可能会随着站点改版而变化。如果写作本文时你拿到的 HTML 跟上面关键字不完全一样,不用慌,思路是一样的——找到每个字段周围的常量字符串,把它写进正则的匹配上下文里。正则匹配的精髓不是背模板,而是学会看结构、找锚点。

3.2 第一版抓取代码:requests 获取页面并用正则提取字段

看明白结构后,写第一版代码。我习惯把请求和解析拆成两个独立函数,后续调试和复用都方便。

import requests import re 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" } def fetch_page(url): """获取页面文本,失败时抛出异常并打印状态码""" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 非 200 状态码直接抛异常 resp.encoding = "utf-8" # 手动指定编码,避免乱码 return resp.text def parse_page(html): """从 HTML 中提取电影信息,返回字典列表""" movies = [] # 先按 <dd>...</dd> 切片,保证每部电影的数据独立处理 dd_pattern = re.compile(r"<dd>(.*?)</dd>", re.S) for dd_match in dd_pattern.finditer(html): block = dd_match.group(1) # 在每个小块内部分别提取字段 name_m = re.search(r'<p class="name"><a.*?title="(.*?)"', block, re.S) star_m = re.search(r'<p class="star">(.*?)</p>', block, re.S) time_m = re.search(r'<p class="releasetime">(.*?)</p>', block, re.S) score_m = re.search( r'<i class="integer">(.*?)</i><i class="fraction">(.*?)</i>', block, re.S ) # 任何一个字段缺失都跳过,不做整页崩溃式处理 if not (name_m and star_m and time_m and score_m): continue movies.append({ "name": name_m.group(1).strip(), "star": star_m.group(1).replace("主演:", "").strip(), "time": time_m.group(1).replace("上映时间:", "").strip(), "score": score_m.group(1).strip() + score_m.group(2).strip() }) return movies if __name__ == "__main__": url = "https://maoyan.com/board/4" html_content = fetch_page(url) result = parse_page(html_content) for movie in result: print(movie)

这段代码的逻辑说明:dd_pattern.finditer返回所有<dd>块的迭代器,每个块单独处理,这是防止正则跨块匹配的关键。re.S让.能匹配换行,因为 HTML 源码里标签经常跨行。name_m的正则里用了title="(.*?)",因为电影名称主要出现在title属性里。star_m提取出来的是“主演:张三、李四”这整段文字,所以后面用replace("主演:", "")去掉前缀,上映时间同理。

这里对timeout=10的参数做一个说明:单个requests.get的timeout是总超时,包括建立连接和读取数据两个阶段。如果你在弱网环境,建议拆成(5, 15)这种元组形式,连接阶段 5 秒,读取阶段 15 秒,灵活性更好。

整个第一版代码的核心目标是跑通单页。不要急着加翻页、不要急着写文件,先把第一页的 10 条数据能稳定打印出来再说。打印时重点看两条:一是结果长度是否为 10,二是字段内容是否干净、有没有缺项。

3.3 解析结果检查:打印字段、验证正则是否命中

第一版代码跑完,先别急着往下走。我每次写完抓取脚本都会执行一遍“结果三查”。

第一查数量。榜单第一页应该返回 10 条数据,少于 10 条说明有<dd>块没匹配上。这时候把parse_page里那个continue临时改成打印日志,把漏掉的那块 HTML 输出出来,看一眼到底哪里跟预期结构不一样。第二查字段完整性。名称不能带多余空白,主演不能还留着“主演:”前缀,评分必须是“9.5”这种能直接转成浮点数的形式。第三查排名顺序。榜单是按排名排列的,如果你的输出顺序跟页面上显示的顺序不一致,说明匹配到的<dd>块不是按文档顺序迭代的——这种情况很少见,但一旦出现,多半是页面里还有其他<dd>标签被误匹配了。

字段检查时可以用一段循环代码快速验证:

for idx, movie in enumerate(result, start=1): name_ok = isinstance(movie["name"], str) and len(movie["name"]) > 0 score_ok = len(movie["score"]) == 3 # 形如 "9.5" 共 3 字符 print(idx, movie["name"], movie["score"], name_ok, score_ok)

len(movie["score"]) == 3这个判断是试探性验证,因为正常评分是“9.5”三个字符,少数可能是“10”被拆成整数部分“10.”和小数部分“0”,那样长度会变成 4。如果你发现长度不是 3,说明评分字段的数据有特殊情况,要么是页面结构变了,要么是分数真的超过 9.9,需要再单独看一下。

单页跑通的标志是:打印结果与浏览器页面一一对应,10 条数据无遗漏。到这个点,请求和解析两个函数都经过了第一轮真实验证,后面扩展翻页和保存就没有什么玄学成分了。

4. 从单页到 TOP100:翻页、字段清洗与本地存储

4.1 offset 翻页逻辑:10 页循环与请求间隔控制

猫眼 TOP100 榜单的分页是offset模式,每页 10 条,偏移量从 0 开始递增到 90。翻页逻辑本质上就是一个range(0, 100, 10)的循环,用它拼出 10 个 URL。

import time def crawl_top100(): """抓取 TOP100 全量数据,返回列表""" base_url = "https://maoyan.com/board/4?offset={}" all_movies = [] for offset in range(0, 100, 10): url = base_url.format(offset) print(f"正在抓取第 {offset // 10 + 1} 页,offset={offset}") html = fetch_page(url) if html is None: print(f"第 {offset // 10 + 1} 页抓取失败,跳过") continue page_movies = parse_page(html) print(f"第 {offset // 10 + 1} 页解析到 {len(page_movies)} 条数据") all_movies.extend(page_movies) # 每页之间停顿 1 秒,降低对目标站点的请求压力 time.sleep(1) return all_movies

这个循环里的关键不在range,而在time.sleep(1)。很多初学者写翻页时漏掉停顿,10 个请求在几秒内连续打过去,非常容易触发反爬。1 秒的间隔在 10 页数据下总共只增加 9 秒耗时,但稳定性提升非常明显。

offset // 10 + 1是从偏移量反算页码的常见写法。offset=0时页码为 1,offset=90时页码为 10,逻辑直观。continue的用途是单页失败不中断整体抓取,这个设计习惯很重要,一个失败的页面不应该毁掉整个任务。

抓完之后打印len(all_movies),如果结果不是 100,说明至少有一页解析不完整,需要用前面提到的“结果三查”定位。还有一种情况:parse_page里的continue条件把某些异常结构的电影跳过了,导致单页不是 10 条。这时候宁可先看日志,不要盲目去调正则,看到实际缺失的是哪一条数据,再针对性修。

4.2 字段清洗与规范化:去掉前缀、空白与异常字符

TOP100 抓下来之后,原始字段里通常还有几处需要处理:主演字段的“主演:”前缀、上映时间字段的“上映时间:”前缀、标签间的多余空白,以及某些隐藏字符。

清洗操作放在解析函数里做最简单,因为数据刚被提取出来时上下文最清楚。前面代码里的replace("主演:", "")和strip()就是最基本的清洗。到了全量数据阶段,还需要考虑两个额外问题:空格和去重。

def clean_movie_data(movies): """清洗字段:去空白、去前缀、去重复记录""" cleaned = [] seen_names = set() for movie in movies: name = movie["name"].strip() star = movie["star"].strip() release_time = movie["time"].strip() score = movie["score"].strip() # 过滤明显无效的数据 if not name or not score: continue # 去重:同一部电影在同一轮抓取中不应出现两次 if name in seen_names: continue seen_names.add(name) # 统一评分格式为浮点数 try: score_value = float(score) except ValueError: score_value = None if score_value is not None: cleaned.append({ "name": name, "star": star, "release_time": release_time, "score": score_value }) return cleaned

这里把“主演:”前缀的清洗在上一版就做掉了,那这版clean_movie_data主要处理的是全量数据融合时可能出现的问题。用set做去重是最轻量级的方案,因为数据量只有 100 条,不存在内存压力。float(score)的转换是有价值的,字符串形式的“9.5”没办法排序和比较,转成浮点数之后,后续无论做排序还是求 TOP N 都顺畅了。

要注意的一点:movie["score"]在某些异常情况下可能是空字符串或者 None,float("")会抛ValueError,所以这里用 try-except 兜底,把解析失败的分数置为 None。这个字段最终写进 CSV 时会是空单元格,比写一个错误数值更能保留问题痕迹。

4.3 保存本地 CSV:编码、字段对齐与去重

数据清洗完成后,保存成 CSV 是最通用的交付方式。Python 的csv模块很好用,但有两个坑必须提前避开:编码要用utf-8-sig,写文件要带newline=""。

import csv def save_to_csv(movies, output_path="top100.csv"): """保存电影数据到 CSV 文件""" # utf-8-sig 是为了在 Excel 中打开不乱码 with open(output_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["排名", "电影名", "主演", "上映时间", "评分"]) for idx, movie in enumerate(movies, start=1): writer.writerow([ idx, movie["name"], movie["star"], movie["release_time"], movie["score"] ])

utf-8-sig这个编码是 Excel 打开 CSV 不乱码的关键。普通utf-8保存的文件,Excel 默认按 GBK 解析,中文全是乱码,必须手动切编码才能正常。utf-8-sig会在文件开头写入 BOM 标记,Excel 看到后自动识别为 UTF-8。newline=""是 csv 模块的官方要求,不加的话在 Windows 上每行之间会多出一个空行。

保存完以后,用 Pandas 或者直接读文件的方式验证一下行数和列数是否符合预期。我习惯在保存后立刻打印前 5 行和总行数,确认“100 条数据、每行 5 个字段”这个预期结果真的落地了。到这里,整个 TOP100 抓取流程已经完整跑通,剩下的问题就只有偶发情况和异常场景了。

5. 避坑指南:抓取过程中常见的 4 个翻车点

5.1 现象:页面中文全变乱码

现象:resp.text打印出来,电影名全是“é”这类乱码,或者中文字符变成一个个单字节符号。

原因:requests在响应头里没拿到明确的charset,于是用默认编码去解码响应体。榜单页返回的主要是中文字符,如果被按 Latin-1 或者其他西欧编码解码,中文部分必然乱码。

解决:在resp.text之前强制指定编码。最直接的做法是resp.encoding = "utf-8"。如果指定后仍然乱码,改用原始字节自己解码:resp.content.decode("utf-8", errors="ignore")。若这样还乱码,就得检查服务器返回的压缩格式,有没有可能是 gzip 未解压。requests默认会自动解压 gzip,所以大概率不是这个问题,重点还是放在编码指定上。

5.2 现象:一部电影的数据里混入了下一部电影的字段

现象:解析结果中,第一条数据的评分是 9.5,但“主演”字段却包含了第二部电影的演员名字;或者十条数据只剩两条,中间内容全部被吞了。

原因:这是典型的贪婪匹配问题。正则.*会从左边的起点一直匹配到右边最后一个可能的终点,而不是最近的终点。当你用一条大正则去全页面匹配时,第一个捕获组的结尾可能直接跳到了页面底部最后一个标签,把所有中间内容都吸收进去了。

解决:两个手段交替使用。第一,把所有点号匹配改成非贪婪.*?,让匹配在满足条件的最短位置停下。第二,采用先切片再提取的策略,用<dd>(.*?)</dd>先按电影块切分,再在每一块内部用小正则逐字段提取。第二种方法更可靠,因为它把问题范围限制在一个块的内部,不会出现跨块污染。

5.3 现象:评分字段缺失导致整体匹配失败

现象:某一条电影数据解析出来缺少评分,或者整个<dd>块被continue跳过,导致当页只返回 9 条数据。

原因:页面结构不是完全恒定的。评分标签可能在个别电影上没有,也有可能是站内数据源不同导致 HTML 结构有细微差异。正则匹配是精确的,任何一点结构偏差都会导致该次匹配返回 None。

解决:在代码里对每个re.search的结果做存在性判断,缺失字段的记录单独标记,不要静默丢弃。更稳妥的做法是把评分匹配拆开,整数部分和小数部分分别匹配,允许其中一部分缺失,最后再拼接。还有,打印日志是必须的——哪种数据缺失、缺失发生在哪一页,这些信息比盲调正则更有价值。只要看到了真实缺失的那块 HTML 长什么样,修复就是十分钟内的事情。

5.4 现象:请求到第 4 页突然被 403 拒绝

现象:前 3 页抓得好好的,第 4 页开始返回 403 状态码,或者页面返回一个验证提示页。

原因:请求频率太高,或者请求特征太明显。即使每次都带了浏览器User-Agent,1 秒内连续请求依然可能被风控识别。还有一种情况是User-Agent虽然带了,但缺少Referer或Accept导致请求头看起来不像完整浏览器。

解决:三重手段并用。第一,翻页循环里加time.sleep(1)甚至time.sleep(2),把整体请求速率降下来。第二,补全请求头字段,至少包含User-Agent、Accept、Accept-Language、Referer这四个。第三,做失败重试,遇到 403 时暂停 5 秒再重试,并打印当前状态码和 URL,方便观察是被拦截还是临时网络波动导致的异常。如果连续多次 403,就调整策略:拉长间隔、换一个User-Agent,或者分段抓取,不要硬刚。

6. 再进一步:给抓取流程加上异常重试与断点续抓机制

单页跑通、全量能抓、数据落盘,这只是完成了基本功能。实际运行中,网络抖动、页面微改、反爬升级这些不确定因素随时会让任务中断,所以把流程改造成带异常恢复的版本,才算真正可以托付的抓取脚本。

我做的第一件事是给fetch_page加重试机制。

def fetch_page_with_retry(url, retries=3, base_delay=2): """带重试的页面获取,连续失败超过 retries 次返回 None""" for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=(5, 15)) if resp.status_code == 403: print(f"触发反爬,等待 {base_delay * (attempt + 1)} 秒") time.sleep(base_delay * (attempt + 1)) continue resp.raise_for_status() resp.encoding = "utf-8" return resp.text except requests.RequestException as exc: print(f"第 {attempt + 1} 次请求失败:{exc}") time.sleep(base_delay * (attempt + 1)) return None

重试的关键在退避策略:第一次失败等 2 秒,第二次等 4 秒,第三次等 6 秒,不要每次都固定间隔。对临时性的网络抖动和轻量反爬来说,退避等待通常能扛过去。状态码方面,403 要单独处理,它跟超时的性质不一样,前者是风控,后者是网络。风控需要更长等待时间,网络超时则重试即可。

第二件事是保存阶段做断点续抓。如果第 7 页抓取失败被跳过,全量数据只有 90 条,不能手动从第 1 页重新来。常见做法是把已经抓到的数据先落盘,跑完 10 页后再汇总。我在crawl_top100里改成每处理完一页就追加写一行到增量文件,最后所有页都遍历结束后,再合并并做一次去重排序。这样即使程序中途崩溃,已经抓到的页码数据也不会丢。

第三件事是最终校验脚本。抓完 TOP100 后,我会再用一段独立代码读取 CSV,校验三件事:总条数是否为 100,评分列能否全部转成浮点数,电影名是否有重复。这三项检查全部通过,我才会认为这次抓取真正可信了。

这套流程完善之后,抓猫眼 TOP100 对我来说不再是“跑一次看运气”,而是一个碰到问题能自动恢复、失败能定位、重复执行结果稳定的常规任务。早期我写抓取脚本总喜欢把请求、解析、保存全塞在一个函数里,看起来代码短,一旦出错就只能从头跑,后来吃了好几次这种亏,才老老实实拆开写。任何一个抓取任务,把请求和解析拆开、把重试和校验做成标配,踩坑的概率都会大幅下降。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询