前言
用正则表达式从网页里提取内容,是很多人接触爬虫的第一步:拿到 HTML 源码,用re.findall把链接、标题、价格抠出来。这条路学习成本低,但也最容易被用歪——既容易写出脆弱的模式,也容易忽视抓取的分寸。
本文要先把两件事说清楚。第一,正则不是解析 HTML 的正确工具。HTML 不是正则语言,标签可能嵌套、属性顺序可变、还有注释和脚本,正则只在「结构简单且稳定」的片段上可靠。第二,抓取要合规:遵守目标站点的robots.txt、如实表明自己的 User-Agent、控制请求频率、不绕过登录或付费限制、不做高并发把对方打挂。反爬机制的存在有它的原因,本文只讲「怎么合规地抓」,不讲任何绕过手段。
下面用一个最小可运行的例子,演示「先问 robots、再取页面、用正则提取链接、限速」的完整闭环。示例基于 Python 3.8+。
一、Python 3 的抓取工具链
Python 2 时代流行urllib2,这个模块在 Python 3 里已经不存在了,它的功能被合并进urllib.request。Python 2.7 已于 2020 年 1 月 1 日停止维护,旧教程里的urllib2.urlopen(url)写法一律不能再用,本文全部采用 Python 3 的路径:
| 用途 | Python 3 模块 | 说明 |
|---|
| 发起请求 | urllib.request | Request/urlopen |
| 解析相对链接 | urllib.parse | urljoin(base, link) |
| 读取抓取规则 | urllib.robotparser | RobotFileParser |
| 正则匹配 | re | 只用于结构稳定的片段 |
先关心合规:urllib.robotparser.RobotFileParser能读站点根目录的robots.txt,用can_fetch(useragent, url)判断某个 URL 允不允许抓。注意它判断的是「给定 UA 下能不能访问」,所以 UA 字符串要和实际请求头里的一致。
二、完整示例:合规地取一个页面的链接
# 适用于 Python 3.8+
import re
import time
import urllib.request
import urllib.robotparser
from urllib.parse import urljoin
BASE = "https://example.com/"
UA = "MyCrawler/1.0 (+https://example.com/about-bot)"
# 只匹配 href 属性里的取值,单双引号都接受
LINK_RE = re.compile(r'''href=["']([^"']+)["']''', re.IGNORECASE)
def allowed(url):
"""查 robots.txt,判断当前 UA 是否可以抓取 url。"""
rp = urllib.robotparser.RobotFileParser()
rp.set_url(urljoin(url, "/robots.txt"))
try:
rp.read()
except OSError:
# 读不到规则时保守处理:不抓
return False
return rp.can_fetch(UA, url)
def fetch(url):
"""发起 GET 请求,按响应声明的编码解码成文本。"""
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace")
def extract_links(html, base):
"""用正则找出所有 href,并把相对地址补成绝对地址。"""
hrefs = LINK_RE.findall(html)
return [urljoin(base, h) for h in hrefs]
if __name__ == "__main__":
url = BASE
if allowed(url):
html = fetch(url)
for link in extract_links(html, url):
print(link)
time.sleep(1) # 每次请求之间歇一秒,控制频率
else:
print("robots.txt 不允许抓取:", url)模式r'''href=["']([^"']+)["']'''用三重单引号包住,是为了让里面的"和'都不需要转义。[^"']+是「一个及以上非引号字符」,正好卡住属性值;re.IGNORECASE让HREF也能匹配。
urljoin(base, href)会处理三种情况:绝对地址原样返回,/path补成站点根下的绝对地址,page.html补成同级绝对地址。少了这一步,正则抠出来的往往是一堆相对路径,后续没法直接用。
三、正则 vs 结构化解析
只做「捞链接、抓数字」这类扁平任务,正则是够用的;一旦结构变复杂,就该换工具:
| 场景 | 正则re | html.parser.HTMLParser |
|---|
| 提取固定属性的值 | 合适,模式短 | 也可以,但要写子类回调 |
| 嵌套结构、多层级 | 容易失手 | 按标签层次处理,稳 |
| 容错性(缺闭合标签) | 差 | HTMLParser 也不完美,但更可控 |
| 学习成本 | 低 | 中 |
结论:正则用来处理局部、扁平的片段,整页结构交给html.parser或专门库。把整页 HTML 塞进一个正则里做「万能匹配」,是最常见的失败来源。
四、看一眼 robots.txt 长什么样
robots.txt是放在站点根目录的一行行规则,告诉爬虫「哪些路径可以抓、抓的间隔多少」:
User-agent: *
Disallow: /admin/
Disallow: /tmp/
Crawl-delay: 5
User-agent: MyCrawler
Disallow:大意是:对大多数爬虫,/admin/和/tmp/不许进;每次请求之间至少隔 5 秒。而针对MyCrawler的单条规则写了空的Disallow:,表示对这一个 UA 放行全部。RobotFileParser读进来后,can_fetch("MyCrawler/1.0", "https://example.com/admin/x")会给出是否放行的判断——它匹配的是 UA 的前缀,所以传给can_fetch的字符串要与请求头里保持一致。需要留意的是,Crawl-delay不是所有解析器都会自动执行,实践中仍应自己在代码里time.sleep。
五、真的需要解析结构时:换用 HTMLParser
官方标准库自带html.parser.HTMLParser,用回调的方式按标签取数据,比正则稳:
# 适用于 Python 3.8+
from html.parser import HTMLParser
class LinkGrabber(HTMLParser):
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
if tag != "a":
return
for name, value in attrs:
if name == "href" and value:
self.links.append(value)
page = '<a href="/a">A</a><a class="x" href="/b">B</a>'
g = LinkGrabber()
g.feed(page)
print(g.links) # ['/a', '/b']HTMLParser会把标签名转成小写、把属性解析成(名字, 值)的列表,正好绕开了「属性顺序可变、单双引号混用」这些让正则头疼的问题。代价是要写子类、处理回调。什么时候切换?当你的正则开始出现「一层套不进去就加一个.*?」的时候,就该换了。
常见坑点
- ❌ 把
.*用在跨行的 HTML 上,结果只匹配到第一行,还以为页面没内容。
✅ 默认.不匹配换行;跨行提取要么加re.DOTALL,要么用[\s\S]*?这类显式包含换行的写法。
- ❌ 用
re.findall(r'href="(.*)"', html)这类贪心模式,一次把从第一个href="到行尾最后引号的内容全吞了。
✅ 用[^"]+代替.*,把「不能是引号」写进字符集,天然截断在属性值末尾。
- ❌ 请求时不写 User-Agent,用库的默认值,被对方按「未知爬虫」拦掉。
✅ 如实填写能标识自己的 UA,通常带上用途或联系方式;不要伪装成浏览器去骗过校验。
- ❌ 无视
robots.txt,直接按链接列表批量抓取。
✅ 先用RobotFileParser.can_fetch(UA, url)判断,只抓允许的部分;读不到规则时按不放行处理。
- ❌ 在循环里不 sleep 地连续请求,短时间把对方压出高负载。
✅ 请求之间加time.sleep,串行访问,控制并发量,给对方留出余量。
- ❌ 直接对
resp.read()返回的 bytes 用str模式匹配,报TypeError: cannot use a string pattern on a bytes-like object。
✅ 先decode成str;编码优先取响应头声明的get_content_charset(),拿不到再退回utf-8。
- ❌ 抠出
href后直接当 URL 用,跳转时才发现是/about这样的相对路径。
✅ 用urllib.parse.urljoin(base, href)归一成绝对地址再处理。
- ❌ 用正则解析嵌套的
div结构,页面一改样式就整段失配。
✅ 结构复杂的部分改用html.parser.HTMLParser或专门的解析库,正则只负责最外层、最稳定的模式。
总结
| 环节 | 做法 | 关键点 |
|---|
| 合规检查 | RobotFileParser.can_fetch | 读不到规则就不抓 |
| 发起请求 | urllib.request+ 自定义 UA | 如实标识,别伪装 |
| 解码 | decode(charset, errors="replace") | 正则要在str上跑 |
| 提取 | re.findall+[^"']+ | 用字符集截断,别用.* |
| 归一 | urljoin | 相对路径转绝对 |
| 限速 | time.sleep | 串行、低频、不压垮对方 |
用正则写爬虫,本质是在做「文本提取」而不是「DOM 解析」,所以要挑结构简单、格式稳定的部分下手,同时对抓取行为负责。合规、低频、明确身份,这三条比任何技巧都重要。