Python正则加requests抓取猫眼TOP100:入门爬虫完整实战与避坑指南
简介面向爬虫初学与进阶者这份实战资源演示了如何运用requests库抓取猫眼电影排行榜前一百名的榜单页面并借助正则表达式从超文本中提取影片名称、评分等关键信息覆盖请求发送、页面结构解析、反爬虫策略应对以及多页抓取的常见处理方式。压缩包共六个文件整体大小约2.41MB内含三张页面与源代码截图、一个爬虫脚本、一份抓取结果文本以及一份说明文档结构清晰便于对照学习。目前已有1259人学习浏览适合希望在实际项目中快速掌握正则与requests组合用法的读者。借助源码与配套截图可对照理解页面链接变化、HTML结构解析思路并通过结果文本验证抓取效果说明文档中的环境配置说明、代码解读以及常见排错提示能大幅降低上手门槛。同时项目还展示了分页处理、请求间隔设置和结果保存方法这些经验可直接迁移到其他榜单或资讯类网站的爬取中为后续数据清洗与分析打下基础整体具有较高的入门实操价值。1. 用正则加 requests 抓 TOP100 榜单入门爬虫最值得复现的一条路径如果你刚入门 Python 开发-Web爬虫多半会卡在同一个选择题上用 Scrapy 还是 BeautifulSoup要不要上 Selenium。其实最经典的练手组合反而是 requests 加正则表达式逻辑透明、依赖极少一个脚本就能把猫眼 TOP100 的电影信息完整抓下来。这个项目我拆过不止一遍每次带新人也是从它起步——它能让你在一小时内体验到完整的爬虫闭环分析页面、构造请求、解析字段、避坑、落地数据而且踩的每个坑都特别典型。适合刚学完 Python 基础、想验证自己能否独立写完一个小型爬虫的开发者也适合想复习正则与 requests 核心用法的熟手。2. 抓取前的关键准备目标页面结构分析与请求头伪装2.1 先看页面长什么样再写代码很多新手上来就写 requests.get(url)然后把返回的 HTML 丢给正则去硬抠结果抠出来一堆空列表。问题往往不在正则而在请求本身。猫眼的 TOP100 榜单页面是一个静态 HTML 页面电影条目直接渲染在 HTML 里不需要额外请求接口这对入门来说非常友好。但它的 HTML 结构并不是标准的div classmovie-item这种好解析的格式字段直接嵌在一大段压缩过的 HTML 里。我一般会先手动打开页面按 F12 看一眼源码里电影的条目长什么样。你会看到类似这样的结构dd i classboard-index board-index-11/i a href/films/1234 title霸王别姬 classimage-link>import requests headers { 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: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive } url https://maoyan.com/board/4 res requests.get(url, headersheaders, timeout5)这里Accept和Accept-Language不属于必需项但加上能减少被拦截的概率。timeout5很重要防止请求卡死导致脚本挂在那里不往下走。用requests.get拿到响应后先检查res.status_code是不是 200再检查res.text里有没有电影名出现。很多情况下请求成功了但拿到的是验证页面所以「状态码 200 不代表能继续解析」这一步养成习惯可以少踩不少坑。2.3 拿到 HTML 后先做编码处理猫眼页面返回的编码是 UTF-8正常情况下res.text就已经自动解码了。但偶尔会出现乱码尤其是当你把响应内容再传给文件或终端时。我在 Windows 终端里遇到过中文全变问号的情况。解决办法很直接在拿到响应之后手动指定编码再去取文本。res.encoding utf-8 html res.text一句话的事但能让后面正则匹配到的中文不乱。顺带一提如果你把数据写到 CSV 文件打开文件时也要指定encodingutf-8或者utf-8-sig否则 Excel 打开 CSV 会乱码。这个属于经典坑了后面避坑章节细说。3. 核心抓取逻辑翻页规律、正则提取与字段解析3.1 先写一个函数拿单页 HTMLTOP100 榜单分 10 页每页 10 条。页面 URL 的规律是https://maoyan.com/board/4?offset1offset1是第一页offset11是第二页offset21是第三页。注意不是offset2而是每隔 10 条往下翻。这个规律你要么从浏览器 URL 里看出来要么从源码里的分页控件看出来。def get_one_page(url): try: res requests.get(url, headersheaders, timeout5) res.encoding utf-8 if res.status_code 200: return res.text else: print(f请求失败状态码{res.status_code}) return None except requests.exceptions.RequestException as e: print(f请求异常{e}) return None逻辑很简单传 URL 进去返回 HTML 字符串。try-except捕获的是连接超时、DNS 解析失败这类网络层面的异常防止单个页面挂了导致整个程序崩溃。timeout5的含义是连接超时 5 秒、读取超时 5 秒超过就抛异常。实际应用里猫眼的响应通常在几百毫秒内5 秒已经很宽松了。3.2 正则提取的单点校验先在单条数据上试正则是这个项目的核心技术点。常见的误区是想着一步到位一个正则把十个字段全抠出来结果嵌套复杂、匹配失败还看不出错在哪。我一般会先拿一小段 HTML 试验证正则能命中再放到循环里跑全页。先提取单个电影条目块用非贪婪匹配import re pattern re.compile(rdd.*?/dd, re.S) items re.findall(pattern, html)dd.*?/dd里的.*?是非贪婪匹配意思是从第一个dd开始匹配遇到第一个/dd就停。加re.S参数让点号能匹配换行符因为压缩后的 HTML 里换行位置不固定不加re.S很可能匹配不到。这个re.S是新手最容易漏的漏了之后 findall 返回空列表然后就开始怀疑人生——其实只是点号匹配不了换行。拿到每个dd块之后再分别提取字段def parse_one_item(item): # 提取排名 rank re.search(ri classboard-index board-index-\d(\d)/i, item).group(1) # 提取电影名 name re.search(rp classnamea.*?title(.*?), item).group(1) # 提取主演 star re.search(rp classstar.*?title主演(.*?), item).group(1) # 提取上映时间 release_time re.search(rp classreleasetime上映时间(.*?)/p, item).group(1) # 提取评分 score_mark re.search(rp classscorei classinteger(.*?)/ii classfraction(.*?)/i, item) score_moon score_mark.group(1) score_mark.group(2) return { rank: rank, name: name, star: star, release_time: release_time, score: score_moon }逐个正则拆开看。排名的正则用board-index-\d去匹配序号然后取括号里的数字电影名的正则从title开始注意.*?在这里起到了截止作用碰到第一个就停防止贪婪匹配吞掉后面的内容主演和上映时间的逻辑类似关键是找准标杆字符串——主演和上映时间是固定前缀可以作为锚点。3.3 翻页循环与数据累加单页能提取成功之后翻页就只是循环拼接 URL 的问题。if __name__ __main__: all_movies [] for i in range(10): offset i * 10 1 url fhttps://maoyan.com/board/4?offset{offset} html get_one_page(url) if html is None: continue items re.findall(pattern, html) for item in items: try: movie parse_one_item(item) all_movies.append(movie) except AttributeError: print(f第 {i 1} 页解析失败一条{item[:50]}) continue print(f第 {i 1} 页抓取完成共 {len(items)} 条) print(f总共抓取 {len(all_movies)} 部电影)offset的计算是i * 10 1i 从 0 开始所以第一页 offset1第二页 offset11直到第十页 offset91。每页解析完打印一行进度方便看出哪页出了问题。这里try-except AttributeError是必要的某个字段正则没匹配到时.group(1)会抛 AttributeError程序中断。比如某部电影没有主演列表或者 HTML 结构有微小差异都会触发。捕获后打印出错的那条数据片段方便定位是正则写错了还是页面结构变了。3.4 数据落地先存 JSON再考虑其他格式抓完的数据最好先落成 JSON因为 JSON 是结构化的后面转 CSV、Excel、数据库都不会丢字段类型。用json.dumps确保中文不被转义成\uXXXX。import json with open(maoyan_top100.json, w, encodingutf-8) as f: json.dump(all_movies, f, ensure_asciiFalse, indent2)ensure_asciiFalse是让中文字符直接写入文件不加的话默认会把中文转成 Unicode 转义序列阅读性极差。indent2让 JSON 文件有缩进方便直接打开检查。这一步做完核心抓取流程就完整了——请求、解析、提取、落地四个环节缺一不可。4. 数据清洗与字段处理正则匹配到了不代表数据能用4.1 评分字段的合并与类型转换正则提取出来的评分是两个独立的字符串整数部分9和小数部分6。如果直接存成 JSON它们是分开的这不方便后续做统计、排序。所以在解析阶段就合并# 评分合并 score_integer score_mark.group(1) # 9 score_fraction score_mark.group(2) # 6 score_float float(score_integer . score_fraction)合并之后转成 float 类型后面想求平均分、画分布图直接对数值操作省得来回转换。这里有个隐患如果某个电影评分缺失正则匹配到的可能是空字符串float(.)会抛 ValueError。稳妥的写法是先判断再转换score_str score_integer . score_fraction try: score_float float(score_str) except ValueError: score_float NoneScore 字段的类型转换决定了你后续拿这份数据干什么。如果只做展示字符串就够如果做分析必须转数值。4.2 主演字段的规范化处理主演字段提取出来之后长这样主演张国荣,张丰毅,巩俐。这个格式有两个问题一是带了主演前缀二是用英文逗号分隔而不同页面偶尔会用中文逗号。清洗目标是把主演转成干净的列表star_clean star.replace(主演, ).replace(, ,).split(,) star_list [s.strip() for s in star_clean if s.strip()]replace(主演, )去掉前缀replace(, ,)把中文逗号统一成英文逗号然后split(,)切成列表最后strip()去掉演员名两端的空格同时过滤空字符串。这一步做完主演字段从字符串变成了列表后续如果要做演员维度的分析比如统计哪些演员出现次数最多直接可以操作了。而且if s.strip()这个过滤能处理掉一些尾部多出来的逗号导致的空元素。4.3 上映时间的统一格式releasetime提取出来是1993-01-01(中国香港)这种格式带括号和地区名。如果只关心年份可以直接截取前四位。如果想把时间用起来建议把日期部分和地区部分拆开import re time_match re.search(r(\d{4}-\d{2}-\d{2})\(?(.*?)\)?$, release_time) if time_match: date_str time_match.group(1) # 1993-01-01 region time_match.group(2) # 中国香港 或空字符串这里的意思是先找完整的日期后面的括号内容可存可不存。注意.*?用了非贪婪但结尾有$锚定所以会匹配到最后一个右括号为止。有些影片的上映时间可能没有地区后缀所以括号部分是可选的。清洗后的数据里放date和region两个字段比塞在一个原始字符串里更便于检索和分析。4.4 去重处理抓取过程中偶尔会有重复项比如页面结构变动导致某页被重新请求或者断点续抓时重复跑了某页。TOP100 理论上不会重复但保险起见我习惯在最终落地前去一次重seen set() unique_movies [] for movie in all_movies: key movie[name] movie[release_time] if key not in seen: seen.add(key) unique_movies.append(movie)用电影名上映时间作为唯一键因为理论上同一部电影不会在同一天上映两次。这个去重逻辑是通用的后面抓其他榜单也能复用。5. 避坑指南猫眼 TOP100 抓取中的五个经典问题5.1 拿到 403 状态码页面完全无法访问现象requests.get返回 403HTML 内容不是电影榜单而是一段错误提示。原因请求头信息太少或 UA 被识别为脚本。只是简单写了headers{User-Agent: python}或者其他默认 UA服务器直接拒绝。解决把 UA 换成浏览器完整的字符串同时加上Accept和Accept-Language。如果仍然 403检查是否有代理环境变量影响请求或者加上Referer头。不过一般来说完整 UA 就能解决入门阶段的大部分 403。5.2 正则提取返回空列表但 HTML 明明有内容现象findall返回[]打印 HTML 又能看到电影信息。原因大概率是忘了加re.S参数。猫眼返回的 HTML 里有换行符没有re.S正则里的点号无法跨行匹配。另外还有一种情况是正则写得太宽松dd和/dd之间存在嵌套标签匹配范围错位。解决pattern re.compile(rdd.*?/dd, re.S)是标准写法。先输出len(items)验证数量是否为 10如果不为 10再逐条打印 items 内容确认正则切分是否准确。5.3 中文乱码写入 JSON 全变成 \uXXXX现象JSON 文件里中文显示为\u738b\u8005\u8363\u8000这种形式或者在终端直接打印乱码。原因json.dump默认ensure_asciiTrue把非 ASCII 字符全转义了。终端乱码则可能是 Windows 控制台默认编码是 GBK而字符串是 UTF-8。解决写入文件时设置ensure_asciiFalse。终端打印时设置sys.stdout.reconfigure(encodingutf-8)或者在代码最开始加# -*- coding: utf-8 -*-并确保 Python 版本在 3.7 以上。5.4 某个字段解析失败导致整个程序崩溃现象程序跑了几页之后抛AttributeError: NoneType object has no attribute group进程中断。原因re.search没有匹配到内容时返回None对None调.group(1)必然报错。页面结构可能有细微差异比如某部电影没有评分板块。解决在提取字段时增加判断或者统一包一层try-except。我的习惯是解析函数里每个字段单独判断匹配结果为None就赋默认值不让它中断循环。上线跑长时间任务时每个错误都中断是灾难性的。5.5 请求太快被临时封禁IP 被限制访问现象前几页正常到第 5 页以后突然连续 403request 状态码异常。原因没有控制请求频率短时间内对服务器发起了大量请求触发了限流策略。爬虫的自我修养第一条就是别给目标服务器制造压力。解决每次请求之间加time.sleep(1)让请求节奏接近人工浏览。更稳妥的做法是随机延时 0.5 到 1.5 秒import time import random time.sleep(random.uniform(0.5, 1.5))10 页数据加了这个之后总共多花不到 10 秒但对服务器的压力完全不同。TOP100 这种小规模抓取没必要上代理池做好限速就够了。6. 抓完不是终点离线复用、字段扩展与脚本健壮性升级数据抓下来之后这个项目可以往几个方向延伸。最常见的用法是把 maoyan_top100.json 当作离线数据源供本地做各种分析——比如统计上映年份分布、评分分布、演员出镜频率。我见过某开发者用这个数据练 pandas 的数据清洗和 matplotlib 的可视化效果挺好因为数据量刚好字段类型又典型比用内置练习数据集更有真实感。要做这类复用脚本里可以把存储和抓取拆开。我的习惯是抓取函数只管返回列表存储单独写成函数def save_to_json(data, filenamemaoyan_top100.json): with open(filename, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(f数据已保存至 {filename})这样将来想同时输出 CSV 或写入 MySQL直接加函数就行不用改抓取逻辑。字段扩展方面目前抓的是排名、片名、主演、上映时间、评分。其实还可以尝试抓取封面图看style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />