Python正则表达式实战:豆瓣TOP250数据提取与爬虫清洗全攻略 做爬虫的都知道拿到 HTML 之后最烦人的不是请求被拦而是面对一坨堆在一起、看着像纯文本的数据无从下手。标签嵌套、属性值里混着乱七八糟的空白、一行里同时塞了电影名、评分和评价人数——这种“非结构化”的东西用 XPath 或者 CSS 选择器有时候反而会把自己绕晕因为它们太依赖 DOM 结构的“稳定”。一旦页面结构稍微动一下选择器整套就废了。而正则表达式不一样它处理的是“文本”本身只要数据在页面源码里以某种规律存在就能用一段模式把它抠出来。这也是我为什么一直觉得Re 库是 Python 爬虫技能树里必须点满的基础分支。这个项目不会带你做什么高深的大数据平台也不涉及分布式爬虫核心就是把一件事做到位用 Python 的 re 模块把一个经典到不能再经典的豆瓣 TOP250 页面拆得干干净净拿到电影名称、导演信息、评分、评价人数、经典台词这五类字段。整个过程会踩到很多新手必踩的坑匹配串写错导致结果为空、贪婪匹配把一整段都吞掉、中文编码显示乱码、请求头没伪装被豆瓣拒绝——我都会在下面把排查思路和最终可复现的代码都写出来。适合刚学完 Python 基础语法、准备往爬虫方向深入的同学也适合已经会用 Requests 但解析 HTML 总靠复制粘贴正则、却经常一改就崩的人。1. 项目整体拆解与方案选型1.1 为什么是正则而不是 XPath / BeautifulSoup先聊一个替新手问得最多的问题既然 BeautifulSoup 解析 HTML 那么简单为什么不直接用这个问题的答案其实取决于“数据到底在什么地方”。BeautifulSoup 和 XPath 的核心逻辑是“根据标签找内容”比如find_all(div, class_hd)这要求页面元素有清晰的层级关系。但真实的爬虫场景里数据不见得都在 HTML 标签里规规矩矩躺着。比如有些站点会把一段 JSON 直接塞进script标签、有些数据是写在 JS 变量里、有些 HTML 本身就不符合标准结构导致解析器报警。这个时候正则反而是最通用的解法因为它不关心“这行文本是不是在某个 div 里”只关心“这一段文本是不是符合我定义的规律”。再补一个更实际的场景你从接口里拿到的不一定是完整 HTML可能是被转义过的字符串、日志文件、纯文本这些通通没有标签可找。而你只要掌握正则无论数据长什么样只要能抽取出“规律”就能抽出“数据”。所以说正则不是替代 BeautifulSoup而是相辅相成——先用正则做粗提取和清洗再用 HTML 解析器做语义层处理这才是生产环境里的常见姿势。这个项目选豆瓣 TOP250还有一个很实际的原因它的列表页是纯静态返回数据全部在 HTML 里明文存在没有异步加载也不需要处理 JS 渲染特别适合把精力全部集中在正则解析这一环。加上 TOP250 数据量少、字段固定、页面结构高度统一作为练习正则的样本再合适不过。1.2 目标字段和数据特点分析在我们的项目里最终要提取的字段有五个电影名称包括中英文名和年份导演和主演信息评分评价人数经典台词这些字段在页面 HTML 里的存在形态完全不同。电影名称是在a标签里带href属性的文本评分是类名为rating_num的span里的文本评价人数是在span里带逗号分隔的字符串比如“1234567人评价”经典台词则有的电影有、有的电影没有没有的话页面会显示“没有台词”。这种“有的字段稳定、有的字段可能缺失”的情况正是真实数据解析最常遇到的。如果只用固定索引或者只靠标签定位一旦某个字段缺席后面的数据就全部错位。正则的写法则要灵活得多可以把“台词可能不存在”这个情况直接写进匹配规则里。1.3 运行环境准备在动手之前先确认一下环境。我用的是 Python 3.10以下是马上要用到的库requests负责发 HTTP 请求拿 HTMLrePython 自带的标准库不需要额外安装csv标准库用来把结果落盘time标准库控制请求间隔如果你本机还没有安装requests命令行执行一下就好pip install requests如果你是刚配好 Python 环境的新手建议用虚拟环境别把包直接装进全局。创建虚拟环境的方式python -m venv venvWindows 下激活用venv\Scripts\activatemacOS / Linux 用source venv/bin/activate然后再执行 pip 安装。2. Re 库核心语法爬虫场景下的实践用法2.1 字符匹配的底层逻辑模式串如何对应文本正则能匹配的本质是“用一套模式来描述一类文本”。你不用告诉程序“把电影名给我”你需要告诉它“从span classtitle后面开始找到第一个紧跟的中文加英文组合然后停下来”。这里最核心的认知转变是正则不是“查询语言”而是“模式描述语言”。它做的是从文本流的当前位置开始尝试用模式串去匹配匹配到就返回匹配不到就换下一个位置继续试。在爬虫场景里最常见的几类模式元素字面字符abc就只能匹配 “abc” 这三个字符连续出现的情况。字符类[0-9]表示任意一个数字[a-zA-Z]表示任意一个英文字母。预定义字符类\d等价于[0-9]\w等价于[a-zA-Z0-9_]\s匹配空白字符包括空格、换行、Tab。量词*表示前面的字符出现 0 次或多次表示出现 1 次或多次?表示出现 0 次或 1 次。锚点^表示文本开头$表示文本结尾。但在爬虫提取场景里我们很少整段匹配所以锚点用得不多。举一个和项目直接相关的例子评价人数在源码里长这样span propertyv:votes1634661/span注意这里直接的文本是“1634661”这个数字串没有“人评价”三个字。但我希望提取的时候保留“人评价”这个单位就可以这么写re.search(rspan propertyv:votes(\d)/span, html).group(1)\d匹配一个或多个数字()是分组后面会细讲。这样做的好处是万一标签里混入了空格你不会把空格也提取出来。在编写匹配模式时建议把所有模式串都写成raw 字符串也就是r...这种形式。原因很简单在普通字符串里\d是“反斜杠 字母 d”在 raw 字符串里\d才是“反斜杠 字母 d”的字面意思如果不用 raw你需要写成\\d才能表达一个反斜杠。爬虫正则里面全是\d、\s、\.这种东西不用 raw 会把代码写得很痛苦还容易漏写反斜杠导致结果变成匹配字母 “d”。2.2 贪婪匹配与惰性匹配爬虫里最容易踩的坑写爬虫正则的新手十个里有九个栽在贪婪匹配上。什么叫贪婪就是正则里的*和默认会“尽可能多地”吞掉字符。举个例子HTML 片段里有两个电影链接a hrefhttps://movie.douban.com/subject/1292052/肖申克的救赎/a a hrefhttps://movie.douban.com/subject/1291546/霸王别姬/a我原本想提取第一个链接的 URL于是写了re.findall(ra href(.*), html)结果会一次性匹配到https://movie.douban.com/subject/1292052//a a hrefhttps://movie.douban.com/subject/1291546/原因是第一个.*中的*是贪婪的它会一直向后吞直到文本结束前最后一个符合条件的出现时才停下来。也就是说它找到了最后一个而不是第一个。解决办法就是让量词变“懒”在量词后面加一个?改成.*?。这时候正则引擎会匹配到第一个就停下正好是我们想要的结果re.findall(ra href(.*?), html)这个?加不加结果天差地别。我最后给你的忠告是所有写爬虫正则的人都应该默认用.*?而不是.*除非你有明确的理由要贪婪匹配。本项目里我会有意识地全部使用非贪婪写法这属于“写得慢一点但跑得稳一点”的经验。2.3 分组捕获与命名组从“抓到”变成“结构化”括号()在正则里有两个作用一是把几个字符绑定成一个整体比如(ab)表示 ab 可以连在一起出现多次二是捕获匹配到的内容方便后面单独取值。在re.search或re.match返回的Match对象上调用.group(1)就是取第一个括号里的内容.group(2)取第二个.group(0)是整个匹配到的完整文本。比如豆瓣页面的年份信息经常和电影名在一个标签里像这样span classtitle肖申克的救赎/span span classother / 刺激1995 / 月黑高飞/span但如果只看年份可以这样单独写一条规则re.search(r(\d{4}), html).group(1)直接匹配四位数字就是年份。但这里有个隐藏问题如果页面里还有评价人数这种长数字\d{4}也可以匹配长数字中的前四位导致取错值。所以严谨的做法是限定边界比如\d{4}前后不允许出现其他数字re.search(r(?!\d)(\d{4})(?!\d), html).group(1)(?!\d)是负向后顾断言意思是“前面的位置不能是数字”(?!\d)是负向前瞻断言意思是“后面的位置不能是数字”。这样匹配到的才是真正的独立四位数年份而不是长数字串的一部分。这种边界处理在模糊数据解析里非常重要属于进阶技巧但在 TOP250 页面里视觉效果很好。Python 的 re 模块还支持命名组也就是给括号起名用?Pname语法m re.search(rspan propertyv:average(?Pscore[\d.])/span, html) m.group(score)命名组的好处是项目大了之后提取多个字段时不需要去记“第几个括号是对应哪个字段”直接按名字取值可读性强很多。我之前在实际项目里处理十几个字段时就完全依赖命名组不然代码维护起来实在痛苦。2.4 四大核心函数findall 与 search 的分工逻辑re模块里有四个高频函数各有分工re.findall(pattern, string)返回所有匹配到的“结果列表”。如果有多个分组返回元组列表。适合遍历列表页里的所有 25 部电影。re.search(pattern, string)在整个字符串中搜索返回第一个匹配到的Match对象。适合“只找一个目标”的场景。re.match(pattern, string)与 search 的区别是它强制从字符串的开头开始匹配。由于 HTML 开头是!DOCTYPE htmlmatch 大多数情况下都匹配不到东西爬虫里用得少但不代表没用有时候判断文本是否“以某种模式开头”就很好用。re.sub(pattern, repl, string)把匹配到的文本替换成指定内容适合做数据清洗。比如把 HTML 标签全部去掉把多余的空白压缩成单个空格。列表页一次展示 25 条数据所以我们的主流程应该是findall一把梭把 25 部电影的基础信息先取出来再做字段加工。而search适合在单条详情页场景里取返回值比如打开一个电影详情页提取独立信息。3. 豆瓣 TOP250 实战完整抓取与解析流程3.1 构建安全请求UA 与常见反爬规避豆瓣对爬虫的拦截在业内是出了名的。你要是直接用一个默认的requests请求头去访问大概率会收到 418 或者直接被重定向到登录页。这个“418 Im a teapot”状态码是豆瓣反爬的特色意思是“我不给你数据”。反爬拦截通常看的几个关键信息User-Agent标识你是什么客户端。如果直接暴露 Python显然会被识别为爬虫。Referer标识你是从哪个页面跳过来的。部分站点会校验这个头。请求频率如果你每秒发 10 个请求正常用户根本做不到。我们的方案是在请求头里伪装成一个 Mozilla 浏览器的 User-Agent并加上Referer指向豆瓣首页把请求间隔控制在 2 秒以上。构建请求的方式如下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, Referer: https://movie.douban.com/ } url https://movie.douban.com/top250?start0filter r requests.get(url, headersheaders, timeout10) r.encoding utf-8 html r.text注意设置了r.encoding utf-8这是为了避免豆瓣返回的 HTML 被按照默认编码解析导致后续中文全是乱码。豆瓣的页面是 UTF-8 编码这一步设置好后后面的正则匹配才能看到正确的中文。3.2 编写提取规则从 HTML 到结构化字段先看豆瓣 TOP250 一个电影项在页面源码里的结构。以第一名的“肖申克的救赎”为例核心片段长这样div classitem div classpic em class1/em a hrefhttps://movie.douban.com/subject/1292052/ img width100 alt肖申克的救赎 src... class /a /div div classinfo div classhd a hrefhttps://movie.douban.com/subject/1292052/ class span classtitle肖申克的救赎/span span classtitlenbsp;/nbsp;The Shawshank Redemption/span span classothernbsp;/nbsp;月黑高飞(港) / 刺激1995(台)/span /a span classplayable[可播放]/span /div div classbd p class 导演: 弗兰克·德拉邦特 Frank Darabontnbsp;nbsp;nbsp;主演: 蒂姆·罗宾斯 Tim Robbins /...br 1994nbsp;/nbsp;美国nbsp;/nbsp;犯罪 剧情 /p div classstar span classrating5-t/span span classrating_num propertyv:average9.7/span span propertyv:votes2375964/span /div p classquote span classinq希望让人自由。/span /p /div /div /div注意我做了简化但真实页面基本就是这套结构。字段提取思路如下电影名称span classtitle出现了两次第一次是中文名第二次是英文名。如果用findall会把两个都拿到。我们可以只抓第一个也可以在编译时用一个?Ptitle组同时抓取然后单独处理。更好的策略是用findall匹配整个item块再对每个块做字段级解析。这样可以避免跨电影错位匹配。伪代码思路是items re.findall(rdiv classitem(.*?)/div\s*/div, html, re.S)这一步先把每个电影的“容器”抠出来再在容器内部做细粒度匹配。因为一个div classitem里面嵌套了很多/divfindall搭配.*?和非贪婪特性能恰好停在第一个闭合位置答案是——不能。这个item块内部嵌套了多层div第一条.*?会在遇到第一个/div时就停下来导致内容不完整。正确的做法是直接识别到.info块的边界或者使用一个更简单粗暴的方案让findall直接基于item内部的唯一标识起点和终点来截断比如从div classitem一直取到div classitem下一次出现之前。不过在实际操作中我建议用另一种更稳的思路不按块切分而是直接用多个findall分别提取所有片名、所有评分、所有评价人数——因为 TOP250 页面的列表结构高度一致这 25 条数据天然就在同一层级按列提取是可行的。但有一点必须注意由于“片名”字段在同一部电影里可能有两个如果用全局findall直接抓你会拿到 50 个结果而不是 25 个。所以处理时要按结构切分。导演与主演信息这个字段在p class里面开头是“导演: ”然后是“主演: ”中间用nbsp;nbsp;nbsp;分隔。我的提取方式是director_actors re.search(r导演:\s*(.*?)(?:主演|nbsp;|br), block, re.S)这里的关键是正则的“非贪婪 终止条件”。导演信息后面遇到“主演”就停这样不会把主演也吞进来。同样的逻辑也可以用于提取年份和地区。评分直接抓span classrating_num propertyv:average([\d.])/span。评价人数直接抓span propertyv:votes(\d)/span。经典台词注意这个字段是可选的。如果一部电影没有台词页面上会是一句空话。我们要允许它缺失不让整个流程断裂。所以提取时不能用searchsearch 找不到会抛AttributeError而是要用re.search加if判断或者用findall取第一个元素没有就赋值空字符串。更多细节我会在第 4 节补上下面先呈现完整的工程化代码。3.3 翻页逻辑与数据存储豆瓣 TOP250 是一共 10 页每页 25 条。翻页参数在 URL 的start字段上每翻一页start加 25https://movie.douban.com/top250?start0filter https://movie.douban.com/top250?start25filter https://movie.douban.com/top250?start50filter所以循环可以直接写for page in range(10): start page * 25 url fhttps://movie.douban.com/top250?start{start}filter # 抓取与解析存储方面这个项目我选择最简单的csv标准库写入一个douban_top250.csv文件UTF-8 编码加utf-8-sig这样用 Excel 打开也不会出现乱码。CSV 的基本操作不复杂import csv with open(douban_top250.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([排名, 电影名, 英文名, 导演/主演, 年份, 国家, 类型, 评分, 评价人数, 经典台词])3.4 完整代码与运行结果下面就是我跑通的完整代码你可以直接复制配合requests库在本地跑起来import requests import re import csv import time 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, Referer: https://movie.douban.com/, } def fetch_page(start): url fhttps://movie.douban.com/top250?start{start}filter r requests.get(url, headersheaders, timeout10) r.encoding utf-8 return r.text def parse_page(html): # 先把每个电影 item 块切出来 blocks re.findall(rdiv classitem(.*?)/div\n, html, re.S) # 上面的写法不稳定更稳的是按 div classitem 和 div classitem 切分 blocks [] parts html.split(div classitem) for part in parts[1:]: part part.split(div classitem)[0] if div classitem in part else part blocks.append(part) ...等一下上面的写法为了凑切分逻辑在真实项目里并不需要搞这么复杂。让我把简洁可行的版本写出来不遮遮掩掩。实际操作中我推荐这样按块切分def split_blocks(html): pattern re.compile(rdiv classitem(.*?)(?div classitem|$), re.S) return [m.group(1) for m in pattern.finditer(html)] def parse_block(block, rank): # 中文名 title_cn re.search(rspan classtitle([^])/span, block) # 英文名 title_en re.search(rspan classtitlenbsp;/nbsp;([^])/span, block) # 导演/主演 director_actors re.search(r导演:\s*(.*?)(?:主演|nbsp;|br), block, re.S) # 年份四位数且前后不能是数字 year re.search(r(?!\d)(\d{4})(?!\d), block) # 评分 rating re.search(rspan classrating_num propertyv:average([\d.])/span, block) # 评价人数 votes re.search(rspan propertyv:votes(\d)/span, block) # 经典台词 quote re.search(rspan classinq([^])/span, block) return { rank: rank, title_cn: title_cn.group(1) if title_cn else , title_en: title_en.group(1) if title_en else , director_actors: re.sub(r\s, , director_actors.group(1)).strip() if director_actors else , year: year.group(1) if year else , rating: rating.group(1) if rating else , votes: votes.group(1) if votes else , quote: quote.group(1) if quote else , } def main(): all_data [] rank 1 for page in range(10): html fetch_page(page * 25) blocks split_blocks(html) for block in blocks: all_data.append(parse_block(block, rank)) rank 1 print(f第 {page1} 页完成已拿到 {rank-1} 条数据) time.sleep(3) # 绅士爬虫不要太急 with open(douban_top250.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[rank, title_cn, title_en, director_actors, year, rating, votes, quote]) writer.writeheader() writer.writerows(all_data) print(全部完成共, len(all_data), 条) if __name__ __main__: main()注意split_blocks用到了(?div classitem|$)这种正向前瞻。含义是“当我匹配到div classitem后面的任意内容时我会一直吃到下一个div classitem出现之前为止如果不再出现下一个就吃到文本结尾”。这样每块的内容是完整的、互不重叠的配合re.SDOTALL标记让.能匹配换行符是非常稳定的块切分写法。4. 非结构化与模糊数据的进阶处理4.1 数据清洗re.sub 的典型用法爬虫解析出来的数据很少是“干净”的。比如导演字段原文是导演: 弗兰克·德拉邦特 Frank Darabontnbsp;nbsp;nbsp;主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman / ...我们要做两件事把nbsp;替换成空格再把多个连续空白压缩成单个空格。cleaned re.sub(rnbsp;, , raw_text) cleaned re.sub(r\s, , cleaned)第一行替换掉 HTML 实体第二行把多个空格、换行、制表符统一压成单一空格。这个组合拳在爬虫数据清洗里基本是标配。如果说正则匹配是从“混沌”中找“有序”那么re.sub就是消除“多余的有序”让数据回到规整状态。再举个例子年份字段(?!\d)(\d{4})(?!\d)的边界判断本质上也是数据清洗的一部分。如果不去做边界限制直写\d{4}那么当你解析“2375964”这个人数时会匹配出 “2375” 或 “7596” 之类的假年份。这个坑我已经见过无数人踩过了。4.2 处理字段缺失让正则从“报错”变为“容错”爬虫解析里最麻烦的永远是字段可选的情况。TOP250 页面的经典台词字段就是这样——杨德昌的《一一》没有台词页面就显示“没有台词”。如果我用.group()直接取值且没有匹配到Python 会抛出AttributeError: NoneType object has no attribute group整个程序直接中断。这是初学者最常崩溃的地方。我的处理思路是每次取值前做 None 判断或者用一个小工具函数包装def safe_search(pattern, text): m re.search(pattern, text) return m.group(1) if m else 这样写的好处是所有缺失字段统一变成空字符串不会炸流程。后面做数据清洗或分析时再决定是丢弃还是填充。这个容错思维在爬虫里极其重要因为网络页面你永远不知道什么时候会改版、什么时候会少字段。4.3 正则性能优化与“够用就好”原则正则的性能问题在 TOP250 这种小数据量场景完全不用操心。但如果你在爬一个上万条数据的站点就需要关注几点复用编译后的正则对象如果提取规则不变就用re.compile预编译再用pattern.findall(...)反复调用而不是每次循环都重新解析正则表达式。避免灾难性回溯正则里如果写了嵌套量词比如(a)在特定文本下可能造成指数级回溯程序卡死。日常爬虫规则里尽量保持模式简单不要为了炫技写复杂嵌套。先用findall缩小范围再做细粒度处理不要拿着一个巨复杂的正则去匹配整页 HTML先把大区块切出来再在小片区里做具体字段提取。这既符合“分治”思维也方便调试。我见过有人一个正则写了七八十字符试图一口气匹配出所有字段结果一跑就错还很难排查。我的建议是“一个字段一个规则”正则符号本身就不适合过度复用。5. 常见问题排查与避坑技巧5.1 页面结构变化导致匹配为空豆瓣 TOP250 的页面结构已经好几年没大变了但不代表一成不变。如果你的正则突然匹配不到数据第一步不是改代码而是先把页面源码存下来看一眼。调试时可以先把 HTML 存成文件with open(page_test.html, w, encodingutf-8) as f: f.write(html)然后用文本编辑器打开搜索你要提取的字段在源码里到底长什么样。很多时候不是正则写错而是你记忆中的页面结构和实际源码已经不一样了。在大型爬虫项目里我会专门在代码里写一个“结构变更预警”——提取结果为空或数量异常时打印告警。这个习惯看起来简单但能省下大把排查时间。5.2 中文乱码与编码问题爬虫拿到中文乱码90% 的情况是编码设置不对。这里其实有一个底层顺序服务器返回的 HTML 头部会有Content-Type: text/html; charsetutf-8requests会自动根据这个头解码但豆瓣的响应头里并没有明确写清楚 charset导致requests默认按 ISO-8859-1 解码于是中文全乱。解决办法就是强制设置成 UTF-8r.encoding utf-8这个设置要在读取.text之前执行否则你会先把乱码文本缓存下来后面设置也没用。5.3 反爬拦截与请求频率控制如果你请求频率过高很快会被豆瓣临时封禁 IP表现就是所有请求都返回 418。解决的思路有几个控制请求频率睡眠时间设置在 2~5 秒之间。每次请求都用一个不同的 User-Agent可以准备一个小型池。如果并发压力大可以退而求其次在非高峰时段运行。说实话豆瓣 TOP250 这种小规模数据完全没必要上代理池。它只是一次性抓取 250 条数据总共 10 个请求只要你不写死循环、不开多线程基本不会被封。5.4 正则调试技巧从“跑不通”到“跑得稳”正则调试有一个非常实用的思路从结果反推规则。别一上来就写完整规则先把要匹配的文本复制到一个临时文件然后分成几段验证第一步确认单个字符能匹配。比如span classtitle([^])/span能不能匹配到片名。 第二步确认边界条件。比如在([^])里[^]表示“除了以外的任意字符”这是个很常用的技巧能保证不跨标签匹配。 第三步验证 None 情况。手动把一部没有台词的块放进去确认代码不会炸。另外一个小技巧是使用 Python 交互式环境或 Jupyter Notebook 快速测试正则。我个人的工作流是先在 Notebook 里写一小段正则匹配成功后再贴回爬虫脚本这样迭代速度快很多。6. 项目扩展方向从列表页到详情页6.1 详情页字段提取TOP250 列表页只有十个字段但豆瓣每个电影详情页里还有更多信息片长、制片国家/地区、语言、上映日期、又名、剧情简介IMDb 链接等。从列表页拿到 subject ID就是在 URL/subject/1292052/里的那串数字构造详情页 URL 后可以用同样的正则方法提取更多字段。比如片长信息经常长这样span propertyv:runtime142/span分钟提取时可以用re.search(rspan propertyv:runtime(\d)/span, detail_html).group(1)6.2 其他站点的移植思路正则最大的优点是“跨结构复用”。你只需要重新分析目标站点的 HTML 结构把标签规则修改一下整套解析逻辑就能迁移到其他站点。但记住一个原则在站点结构稳定的前提下正则效率很高在结构频繁变化的站点上优先考虑稳定性可以结合 BeautifulSoup。两种技术不是互斥的而是可以按场景组合使用。我个人在实际项目中经常是“Requests 拿 HTML 正则做粗提取 BeautifulSoup 做特殊节点提取 re.sub 做清洗”四件套一起上分工明确哪一层出问题都容易定位。希望这篇博文能帮你把 re 库这块的短板补上往后遇到再乱的 HTML 也不慌了。