简介面向Python爬虫入门者的实战资源包以猫眼电影TOP100榜单为案例演示如何用requests库请求网页、用正则表达式解析电影名与评分并说明robots.txt、请求延时、反爬应对等爬虫规范与实用技巧。整包为zip格式共6个文件体积2.41MBmaoyan.py为完整抓取脚本result.txt保存抓取后的结果README.md提供运行说明3张png截图分别对应页面URL变化、榜单信息与源代码便于逐步对照学习和定位问题。已有1259人学习下载适合想通过一个真实小项目打通静态网页抓取、正则匹配和结果落盘的Python爱好者也可作为爬虫课设或技能备赛的参考素材。资源从HTTP请求、HTML解析到数据存储形成完整链路脚本可即改即用配合截图与文档能帮助读者快速复现猫眼TOP100抓取并掌握迁移到其他同类型网站的通用思路。1. 猫眼TOP100这个列表是练手正则和requests的最佳样本之一很多人在学Python爬虫时会卡在一个问题上要挑战的网站太难验证码、JS加密、字体反爬一套组合拳下来信心直接被打没。而猫眼TOP100这个榜单恰好处在一个“有点难度但又不至于劝退”的位置——它有完整的HTML结构、稳定的分页规律、足够多的字段供你练习正则提取同时又有UA校验、间接的字体反爬和频繁请求后的拦截这些小坑等着你。抓下这100部电影的信息你等于把「发送请求 → 解析HTML → 提取字段 → 翻页循环 → 数据落地」这条爬虫主链路完整走了一遍后面再去看任何静态站点思路都是一样的。这篇文章直接按“从零跑通”来讲先拿到一页HTML再用正则把电影名、主演、上映时间、评分这些字段抠出来接着处理翻页最后保存成结构化文件。每一步都会给出完整代码、参数说明和失败时的排查方向。适合已经会Python基础语法、但没系统写过爬虫的人如果你已经能抓普通网页也可以重点看第5章的坑位盘点里面有几个不亲自动手很难发现的细节。2. 先把环境备好发请求拿到TOP100第一页的HTML爬虫的第一步不是写解析逻辑而是把HTML稳稳地拿到手里。这一步做不好后面所有正则都是空中楼阁。本节先把requests的请求参数讲透再处理一个几乎所有初学者都会翻车的编码问题。2.1 用requests构造请求UA伪装和超时设置是底线抓猫眼不需要登录、不需要携带Cookie最基本的GET请求就能拿到数据。但直接裸奔用requests.get()访问大概率会收到一个异常页面或者直接被拒。原因是服务器会检查请求头里的User-Agent当它发现你不是来自浏览器时可以选择不返回正常内容。先看最小可用版本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, } url https://maoyan.com/board/4?offset0 resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 html resp.text print(len(html))这段代码里三个参数值得说明。headers里的User-Agent是必须伪装的它告诉服务器“我是一个浏览器”不带它或带默认的python-requests标识被识别为自动化脚本的概率极高。timeout10是连接和读取的总超时时间不加的话当目标服务器迟迟不响应时脚本会一直卡住尤其是在循环翻页的场景下一次卡死整个任务就中断了。resp.encoding utf-8是显式指定解码方式这一步待会儿单独讲。代码里还留了print(len(html))这不是调试用的废代码而是你验证请求是否成功的第一个信号。正常拿到首页时html长度通常在70KB到90KB之间。如果打印出来只有几千字节或者里面包含“verify”之类的字样说明请求被拦截了这时候先别急着写正则回头检查请求头。2.2 编码声明不一致为什么你抓到的中文字符全是乱码很多人在这一步已经翻车了。requests库在拿到响应后会根据响应头里的Content-Type字段猜测编码但猫眼的响应头里并没有明确给出charsetrequests的默认行为是去读HTML源码里的meta标签。问题在于猫眼的HTML头部有一段JS逻辑meta标签的charset信息藏在后面requests的编码探测器可能没读到于是回退成了ISO-8859-1来解码。ISO-8859-1解码UTF-8字节流的结果就是你在终端里看到的“çµå½±”这类乱码。解决方式有两种第一种就是上一节代码里的resp.encoding utf-8直接告诉requests不要猜用UTF-8解码。第二种更稳妥因为你无法百分百确定页面到底用的什么编码import requests import chardet resp requests.get(url, headersheaders, timeout10) detected chardet.detect(resp.content)[encoding] resp.encoding detected html resp.text用chardet.detect去检测原始字节流的编码再赋值给resp.encoding。这一步的原理是resp.content是未经解码的原始字节chardet会根据字节分布特征推断编码之后resp.text才会按这个编码正确解码。我一般会在写爬虫脚本时保留这个习惯因为不同的站点编码策略完全不同写死utf-8不一定总是对。判断你是否成功的关键标志打印html[:500]如果开头是正常的HTML标签且包含中文说明请求和解码都过了。如果看到的是乱码或JS跳转代码先不要碰正则问题一定出在这一节。3. 用正则表达式从HTML里抠出电影字段pattern这样写才对HTML已经握在手里了接下来就是正则的活。猫眼TOP100的页面结构有个特点每部电影的信息集中在dd标签内从电影名到评分都在这个块里非常适合用正则一次提取。本节从单条电影信息提取开始再到多条批量提取最后处理缺失字段。3.1 单条电影信息提取六个字段的正则逐一拆解先看单条数据长什么样。简化后的HTML结构类似这样dd i classboard-index board-index-11/i a href/films/1234 title霸王别姬 classimage-link>import re pattern re.compile( rdd.*?board-index.*?(\d)/i.*? rp classnamea.*?title([^]).*?/a/p.*? rp classstar主演(.*?)/p.*? rp classreleasetime上映时间(.*?)/p.*? rp classscorei classinteger(.*?)/ii classfraction(.*?)/i/p, re.S ) item pattern.search(html) print(item.groups())逐个字段解释。board-index.*?(\d)/i匹配排名\d提取数字部分。p classnamea.*?title([^])这里用[^]而不是.*?是为了防止电影名里出现引号导致匹配越界。主演(.*?)/p用非贪婪模式匹配主演列表猫眼在这个位置给出的是逗号分隔的演员名字。最后两个.integer和.fraction拼起来就是完整评分比如9和6拼成9.6。这里必须要提re.S这个标志。它让.能匹配换行符。猫眼返回的HTML不是压缩成一行的dd内部有大量换行和缩进如果不加re.S. *?到换行处就停了整个匹配直接失败。这是正则解析HTML时最容易被忽略的点。3.2 批量提取finditer遍历每一部电影单条匹配验证通过后把它改成遍历整个页面。使用finditer而不是findall因为我们需要在循环里对每个匹配结果做后处理比如计算完整评分、清洗主演字段import re 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, } url https://maoyan.com/board/4?offset0 resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 html resp.text pattern re.compile( rdd.*?board-index.*?(\d)/i.*? rp classnamea.*?title([^]).*?/a/p.*? rp classstar主演(.*?)/p.*? rp classreleasetime上映时间(.*?)/p.*? rp classscorei classinteger(.*?)/ii classfraction(.*?)/i/p, re.S ) movies [] for match in pattern.finditer(html): rank match.group(1) name match.group(2) stars match.group(3).strip() release match.group(4).strip() score match.group(5) match.group(6) movies.append({ rank: int(rank), name: name, stars: stars, release: release, score: float(score), }) print(f本页共提取到 {len(movies)} 条电影信息) print(movies[0])finditer逐个产出匹配对象每个match都可以用group(1)到group(6)访问对应分组。这里有一个值得注意的细节release字段最终你会得到类似“上映时间1993-01-01”这样的字符串strip()只能去掉首尾空白去不掉“上映时间”这四个字清洗逻辑待会儿单独处理。float(score)的转换也有坑因为在某些版本里分数可能只包含整数部分后面细说。打印结果时如果len(movies)是0说明前面的正则一个都没匹配上。此时建议先打印一小段HTML看看实际结构确认class名是否和预期一致。猫眼改版时最常动的就是这里。3.3 字段清洗把“主演xxx”变成干净数据提取出来的字段是给人看的不是给JSON文件用的。直接存“主演张国荣,张丰毅,巩俐”会让后续的数据分析多一步处理。常见做法是写几个清洗函数把冗余前缀去掉def clean_stars(raw): # raw形如 主演张国荣,张丰毅,巩俐 return raw.replace(主演, ).strip() def clean_release(raw): # raw形如 上映时间1993-01-01(中国内地) raw raw.replace(上映时间, ).strip() if ( in raw: raw raw.split(()[0] return raw def process_movie(match): return { rank: int(match.group(1)), name: match.group(2).strip(), stars: clean_stars(match.group(3)), release: clean_release(match.group(4)), score: float(match.group(5) match.group(6)), } movies [process_movie(m) for m in pattern.finditer(html)]这里clean_release里多做了一个操作按(截断。因为猫眼的上映时间字段有时会带地区备注比如“1993-01-01(中国内地)”如果你只要日期就必须去掉括号及后面的内容。这一步做不做取决于你的用途但既然是抓TOP100电影信息通常关心的就是上映日期本身留着地区反而会让日期字段变成字符串没法直接比较大小。4. 翻页抓取TOP100全量数据URL规律与数据落地TOP100意味着100条数据猫眼每页显示25条所以需要翻4页。这一节讲清楚URL里的offset规律、循环请求的节奏控制以及最后把数据保存成JSON文件。4.1 观察URL规律offset就是你的分页控制变量猫眼TOP100这个榜单的URL格式非常好认第一页是/board/4?offset0第二页是/board/4?offset10第三页是/board/4?offset20。这里的关键是offset这个查询参数它表示从第几条开始取。所以翻页逻辑本质上是一个for循环步长25范围从0到75import time import requests import re import json 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, } pattern re.compile( rdd.*?board-index.*?(\d)/i.*? rp classnamea.*?title([^]).*?/a/p.*? rp classstar主演(.*?)/p.*? rp classreleasetime上映时间(.*?)/p.*? rp classscorei classinteger(.*?)/ii classfraction(.*?)/i/p, re.S ) def parse_page(html): movies [] for m in pattern.finditer(html): movies.append({ rank: int(m.group(1)), name: m.group(2).strip(), stars: m.group(3).replace(主演, ).strip(), release: m.group(4).replace(上映时间, ).strip(), score: float(m.group(5) m.group(6)), }) return movies all_movies [] for offset in range(0, 100, 25): url fhttps://maoyan.com/board/4?offset{offset} resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 html resp.text movies parse_page(html) print(foffset{offset}, 解析到 {len(movies)} 条) all_movies.extend(movies) time.sleep(1) print(f总计: {len(all_movies)} 条)这段代码就是完整的主流程。range(0, 100, 25)精确控制了4次请求offset分别是0、25、50、75。all_movies.extend(movies)把每一页的数据累积进总列表注意这里不能用append否则会得到一个包含4个列表的嵌套结构。time.sleep(1)放在请求之后作用是控制请求频率。如果你不加这个4页请求会在一两秒内全部发出看起来没多大问题但实际项目里如果目标从4页变成400页不加延时很快就会被封。服务器不是不让你抓是不让你把它的带宽占满。4.2 保存为JSON文件把内存数据固化到磁盘拿到数据后如果只打印在终端里脚本一关就什么都没了。落地这一步通常用JSON格式因为它跨语言通用而且猫眼的数据结构很适合键值对表示import json with open(maoyan_top100.json, w, encodingutf-8) as f: json.dump(all_movies, f, ensure_asciiFalse, indent2)ensure_asciiFalse这个参数必须写。如果不写JSON里的中文会被转义成\uXXXX形式的ASCII字符序列文件打开后全是乱码虽然数据没丢但可读性极差。indent2让JSON文件以缩进格式呈现方便人工核对缺点是文件体积略大。如果后续要交给pandas做分析也可以改成indentNone并把数据用逗号拼接成CSV格式写入。验证是否保存成功最简单的办法是重新读取文件并检查条数with open(maoyan_top100.json, r, encodingutf-8) as f: loaded json.load(f) print(len(loaded)) print(loaded[0])输出应该显示100条且第一条是排名第1的电影。这里的坑是如果你在第4.1节的循环里没有time.sleep(1)翻页过程中被服务器拦截某一页返回的不是电影列表而是验证页面解析出来就是0条最终总数会变成75甚至更少。所以验证总数是必须走的步骤不是可有可无。5. 抓取过程中的避坑指南现象、原因与解决这一章是实操里最容易让脚本翻车的地方。我按自己踩过的坑顺序来写每条都给出具体的判断方法和修复路径。5.1 返回的HTML里找不到dd请求被重定向到了验证页现象len(html)只有几千字节正则一个都匹配不到打印HTML片段发现里面有verify或captcha字样。原因猫眼对无UA或请求频率异常的客户端返回验证页有时是302跳转有时是直接替换响应内容。你拿到的根本不是电影列表页。解决先确认headers里的User-Agent是完整的浏览器标识不要自己拼一个简短的UA。再把请求间隔调大到2秒以上。如果还是被拦考虑用requests.Session()来复用TCP连接保持会话状态session requests.Session() session.headers.update(headers) for offset in range(0, 100, 25): url fhttps://maoyan.com/board/4?offset{offset} resp session.get(url, timeout10) ...Session的好处在于它会自动管理Cookie和请求头在连续翻页时比每次单独requests.get更接近浏览器的行为模式。5.2 排名对不上第一页和第二页之间的rank赋值方式不对现象第一页抓下来的rank是1到25第二页抓下来也是从1开始的编号而不是26到50。原因正则匹配的是页面里的board-index标签而这个标签在每一页都是从当前页第一名的编号开始显示它本身就是页内编号不是全局编号。解决两个方案任选。第一种是在循环里用global_rank变量手动累加无视页面上的board-index值。第二种更简单既然每页是25条且偏移量是已知的直接用offset 页内索引来构造rankfor page_idx, offset in enumerate(range(0, 100, 25)): ... for idx, m in enumerate(pattern.finditer(html)): rank offset idx 1第二种方式不依赖HTML里的数字逻辑上更稳。如果你要抓的不是猫眼而是其他榜单这个思路同样适用优先信任自己的计数逻辑而不是页面上的编号。5.3 评分提取失败某些页面的分数结构不是integer加fraction现象前两页正常第三页开始float(m.group(5) m.group(6))报错或者解析出的score是nan。原因猫眼的评分区域偶尔会出现没有.fraction的情况或者class名发生了局部调整。也有极小概率是因为该电影暂无评分页面直接显示“暂无评分”字样。解决先打印那一条的原始HTML片段确认结构。如果是缺fraction正则就要改成兼容写法rp classscorei classinteger(.*?)/i(?:i classfraction(.*?)/i)?这里用(?:...)?把fraction部分整体变成可选匹配。同时Python代码里做兜底score_part1 m.group(6) score_part2 m.group(7) if m.group(7) else 0 score float(score_part1 score_part2)这样即使fraction缺失也不会抛异常而是得到一个整数的分数值。5.4 抓完100条后发现主演是空的现象数据能正常落盘但部分条目的stars字段为空字符串。原因正则里的主演(.*?)/p在遇到某些电影的演职人员名单为空时会匹配失败因为页面上可能根本没有主演这三个字。这个情况在外国电影或资料不全的老电影身上更容易出现。解决把主演提取改成容错模式在正则里让它匹配零个或多个字符同时Python端补默认值stars m.group(3).strip() if m.group(3) else 未知不要小看这个“未知”的默认值。后续你用这批数据做统计时空字符串会导致分组聚合出错而“未知”至少是一个可以区分出来的合法类别。5.5 打开JSON文件发现中文全部变了样现象JSON文件里全是\u7535\u5f71这类转义序列。原因json.dump时没有设置ensure_asciiFalsePython默认把非ASCII字符转成ASCII转义序列。解决ensure_asciiFalse只要记住这一个参数就行。另外注意open文件时也要写encodingutf-8否则在Windows默认编码下写入Linux、macOS上打开会有兼容问题。6. 用CSV和pandas验证结果把抓取成果变成能分析的数据表JSON格式适合程序读取但如果你想直接在Excel里检视数据、或者后续用pandas做统计分析CSV是更顺手的格式。这一节用一个pandas链式操作来收尾把抓下来的100条电影信息转成DataFrame并顺手验证排名的完整性。先从生成的JSON文件加载数据并转为CSVimport pandas as pd df pd.read_json(maoyan_top100.json) df.to_csv(maoyan_top100.csv, indexFalse, encodingutf-8-sig) print(df.shape) print(df.head(10))编码这里用的是utf-8-sig它会自动在文件开头写入BOM标记。加上这个标记后用Excel直接双击打开CSV中文列名不会乱码不加的话Excel默认按ANSI解码中文大概率会显示成“铪镕”之类的乱码。这是一个实用性非常高的细节很多教程不会提。接着验证数据质量重点检查两件事排名是否连续、是否重复assert df[rank].is_monotonic_increasing, 排名不连续 assert df[rank].nunique() 100, 存在重复排名 print(校验通过100条数据排名连续无重复)is_monotonic_increasing用来检查排名是不是从1到100严格递增nunique() 100确保没有重复项。这两个断言过掉说明你的翻页逻辑没有漏页或重复抓取整个流程是可信的。最后可以做一个最简单的统计分析比如按评分排序看TOP10或者统计每部电影上映年份的分布。这个分析动作本身不复杂但它验证的是你抓到的数据确实可用于后续决策而不是一堆无意义的字符串df[year] df[release].str[:4] year_dist df.groupby(year).size().sort_values(ascendingFalse) print(year_dist.head(10)).str[:4]截取上映日期的前4个字符作为年份这要求你在前面章节的clean_release里已经把年份之外的备注清理掉了。如果当时没清理这里取到的可能就不是干净的年份值。这正好说明了一个习惯爬虫的每一层处理都不是多余的早点把数据洗干净后面的分析和验证环节会省很多力气。我自己的习惯是每次抓完数据不管当时的任务是只要一个列表还是要做完整分析都会顺手跑一遍这个pandas验证流程。它花不了30秒但能立刻暴露出抓取环节的隐性错误比如漏页、重复、字段错位。等你真正需要这批数据去支撑一个结论时才发现问题那时候重爬的成本要高得多。希望这套从requests到正则再到数据验证的流程能帮你把TOP100这个练习项目做成一个真正拿得出手的完整案例。本文还有配套的精品资源点击获取