前言
很多初学者一上来就学 Scrapy,结果被框架的目录结构、中间件、管道劝退。其实抓一个公开页面并把字段抠出来这件事,只需要两个东西:一个负责发 HTTP 请求的库,一个负责解析 HTML 的库。前者用requests,后者用lxml.etree,加起来不到五十行就能跑起来。
本文只讲这套最小组合:怎么装、怎么写、每一行在干什么,以及怎样从「抓一个标题」扩展到「抓一列记录」。刻意不引入任何框架、不引入异步,目的是让你先看见完整数据流,再决定要不要上更重的工具。
三点前置说明:其一,爬虫必须合规——遵守 robots.txt、带可识别的 User-Agent、请求间限速、只取公开页面、不绕登录墙与验证码、不并发压站。其二,本文示例的请求目标是 RFC 2606 保留的example.com(专门留给文档演示用,不会打扰任何真实站点),解析用自己构造的 HTML 字符串。其三,本机没有 Python 解释器,也不允许安装第三方库,代码均为逐行推演,未经运行。
还有一个版本前提:本文用 Python 3 的写法。Python 2.7 已于 2020 年 1 月 1 日停止维护,它那套东西(print语句、urllib2、xrange)都已过时——xrange在 Python 3 里被移除,range本身就已经是惰性的。
一、两个库各管一段
先摆清楚分工,后面看代码就不会混:
| 库 | 层级 | 职责 | 输入 | 输出 |
|---|
requests | 网络层 | 发请求、拿响应、处理编码 | URL | Response对象 |
lxml.etree | 解析层 | 把 HTML 文本变成树、查询节点 | str/bytes | Element / 列表 |
安装(在能联网、有 pip 的环境里):
pip install requests lxmlrequests是纯 Python 包,装起来通常很顺。lxml依赖 C 库 libxml2,部分平台需要预编译轮子;如果pip install lxml报编译错误,请查阅其官方文档提供的安装说明(以该库官方文档为准)。
二、最小可用脚本
下面是本文的主角,一个完整的最小抓取脚本:
# 适用于 Python 3.8+
import time
import requests
from lxml import etree
# 可识别的 User-Agent,注明身份,便于站点管理员联系
HEADERS = {"User-Agent": "TinyBot/1.0 (+contact: you@example.com)"}
def get_html(url, timeout=10):
"""发一次 GET 请求,返回解析好编码的文本。"""
resp = requests.get(url, headers=HEADERS, timeout=timeout)
resp.raise_for_status() # 非 2xx 一律抛异常
resp.encoding = resp.apparent_encoding # 修正可能的编码猜测
return resp.text
# 本地演示 HTML:真实项目里这行换成 get_html(url) 的返回值
HTML = """
<html><body>
<h1 class="page-title">演示页面</h1>
<ul>
<li><a href="/item/1">条目一</a></li>
<li><a href="/item/2">条目二</a></li>
</ul>
</body></html>
"""
root = etree.HTML(HTML) # 解析成树
title = root.xpath('//h1/text()')[0]
items = root.xpath('//ul/li/a/text()')
print(title) # 演示页面
print(items) # ['条目一', '条目二']
time.sleep(1) # 礼貌:请求之间留间隔五十行不到,这就是爬虫的完整骨架:请求、解析、取值、限速。
三、逐段拆解
3.1requests.get的四个关注点
headers:带一个真实可识别的 User-Agent。默认的python-requests/x.y很容易被直接拒绝,而且也不礼貌——站点管理员不知道该联系谁。注释里写上联系方式是好习惯。timeout:必须给。不写超时的话,对方迟迟不响应,你的脚本会无限期挂住。raise_for_status():把 4xx / 5xx 变成异常。否则你会拿着一个 404 页面当正常内容去解析,出来一堆空值还查不出原因。resp.encoding:requests优先按 HTTP 头里的 charset 解码;头缺失时会退化成 ISO-8859-1,中文网页就会乱码。把它改成resp.apparent_encoding(基于内容的猜测)能救回大部分情况。
apparent_encoding只对文本响应有意义,且只是猜测。真正权威的顺序是:HTTP 头 > HTML 里的 meta 声明 > 内容探测。
3.2etree.HTML干了什么
etree.HTML(text)把字符串按 HTML 规则解析:
- 它会自动补全未闭合的标签(
<li>不写</li>也没事),这是 HTML 解析器与 XML 解析器的关键区别。 - 返回一个根 Element,可以直接对它调用
.xpath()。 - 如果传入的是
bytes,它会看 HTML 里的 meta 编码声明;传str时就按已解码的文本处理。
3.3.xpath()的返回值
.xpath()总是返回列表,哪怕命中的只有一个节点。所以root.xpath('//h1/text()')[0]才是单个字符串。空结果返回[],下标访问会抛IndexError——这是这套组合最常见的运行时错误。
不同表达式返回不同元素类型,务必分清:
| 表达式 | 返回 |
|---|
//li | Element 列表 |
//li/@class | 字符串列表(属性值) |
//li/text() | 字符串列表(直接文本) |
//li//text() | 字符串列表(含后代文本) |
string(//li) | 单个str(不是列表) |
四、从「抓一个」到「抓一列」
真实需求几乎都是抓一列记录。做法是先把行收敛出来,再在每行内取字段:
# 适用于 Python 3.8+
rows = root.xpath('//li') # 每一行
records = []
for row in rows:
link = row.xpath('./a') # 相对当前行
if not link:
continue
a = link[0]
records.append({
"text": (a.text or "").strip(), # text 可能是 None
"href": a.get("href", ""), # 属性取值,带默认值
})
print(records)两个细节值得强调:a.text在元素没有直接文本时是None,所以用a.text or ""兜底;a.get("href", "")是 Element 的属性读取方法,属性不存在时返回第二个参数,比a.attrib["href"]安全(后者会抛KeyError)。
如果要抓很多页,把限速写进循环,并遵守 robots.txt 里声明的抓取间隔:
# 适用于 Python 3.8+
import time
def crawl(urls, delay=1.0):
results = []
for u in urls:
try:
html = get_html(u)
except requests.RequestException as exc:
print("跳过:", u, exc) # 单页失败不拖垮整批
continue
root = etree.HTML(html)
results.extend(root.xpath('//h1/text()'))
time.sleep(delay) # 限速:不要并发压站
return resultsrequests.RequestException是requests异常体系的基类,捕获它可以把连接错误、超时、HTTP 错误统一处理掉,并且不会顺手捕获KeyboardInterrupt这类不该吞的异常。
常见坑点
- 不带 User-Agent
❌ 直接用默认 UA 发请求,很多站点直接返回 403。
✅ 设置一个可识别身份的User-Agent,并在注释里留下联系方式。
- 不设
timeout
❌requests.get(url)—— 对方不响应时脚本永久挂起。
✅requests.get(url, timeout=10)。
- 忽略状态码
❌ 拿着 404 页面解析,结果全空。
✅ 先resp.raise_for_status()。
- 中文乱码不当回事
❌ 直接resp.text,服务端没声明 charset 时按 ISO-8859-1 解码,中文全乱。
✅ 显式修正resp.encoding,优先信任响应头。
xpath结果直接下标
❌root.xpath('//h1/text()')[0],页面没有 h1 时抛IndexError。
✅ 先取列表判断是否为空,或用next(iter(...), default)。
- 用
etree.fromstring解析 HTML
❌ 遇到<br>、未闭合标签抛XMLSyntaxError。
✅ HTML 一律用etree.HTML。
- 不留请求间隔
❌for u in urls: requests.get(u)一秒几十次。
✅ 每轮time.sleep(delay),尊重 robots.txt。
- 裸
except吞掉一切
❌except:会把KeyboardInterrupt、SystemExit一起吞掉,Ctrl+C 都停不下来。
✅ 只捕获requests.RequestException这类具体异常。
总结
| 环节 | 最小写法 |
|---|
| 请求 | requests.get(url, headers=..., timeout=...) |
| 校验 | resp.raise_for_status() |
| 编码 | resp.encoding = resp.apparent_encoding |
| 解析 | etree.HTML(resp.text) |
| 取值 | root.xpath(...),注意总是返回列表 |
| 限速 | 循环里time.sleep(delay) |
requests加lxml.etree这套最小组合,把「发请求」和「解结构」两件事拆得干干净净,二十来行就能跑。先把它写熟,理解每一步为什么这么写,再考虑要不要上框架——那时你判断框架里每个组件的作用,会清楚得多。