前言很多初学者一上来就学 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网络层发请求、拿响应、处理编码URLResponse对象lxml.etree解析层把 HTML 文本变成树、查询节点str/bytesElement / 列表安装在能联网、有 pip 的环境里pip install requests lxmlrequests是纯 Python 包装起来通常很顺。lxml依赖 C 库 libxml2部分平台需要预编译轮子如果pip install lxml报编译错误请查阅其官方文档提供的安装说明以该库官方文档为准。二、最小可用脚本下面是本文的主角一个完整的最小抓取脚本# 适用于 Python 3.8import timeimport requestsfrom lxml import etree# 可识别的 User-Agent注明身份便于站点管理员联系HEADERS {User-Agent: TinyBot/1.0 (contact: youexample.com)}def get_html(url, timeout10):发一次 GET 请求返回解析好编码的文本。resp requests.get(url, headersHEADERS, timeouttimeout)resp.raise_for_status() # 非 2xx 一律抛异常resp.encoding resp.apparent_encoding # 修正可能的编码猜测return resp.text# 本地演示 HTML真实项目里这行换成 get_html(url) 的返回值HTML htmlbodyh1 classpage-title演示页面/h1ullia href/item/1条目一/a/lilia href/item/2条目二/a/li/ul/body/htmlroot 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.encodingrequests优先按 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——这是这套组合最常见的运行时错误。不同表达式返回不同元素类型务必分清表达式返回//liElement 列表//li/class字符串列表属性值//li/text()字符串列表直接文本//li//text()字符串列表含后代文本string(//li)单个str不是列表四、从「抓一个」到「抓一列」真实需求几乎都是抓一列记录。做法是先把行收敛出来再在每行内取字段# 适用于 Python 3.8rows root.xpath(//li) # 每一行records []for row in rows:link row.xpath(./a) # 相对当前行if not link:continuea link[0]records.append({text: (a.text or ).strip(), # text 可能是 Nonehref: a.get(href, ), # 属性取值带默认值})print(records)两个细节值得强调a.text在元素没有直接文本时是None所以用a.text or 兜底a.get(href, )是 Element 的属性读取方法属性不存在时返回第二个参数比a.attrib[href]安全后者会抛KeyError。如果要抓很多页把限速写进循环并遵守 robots.txt 里声明的抓取间隔# 适用于 Python 3.8import timedef crawl(urls, delay1.0):results []for u in urls:try:html get_html(u)except requests.RequestException as exc:print(跳过, u, exc) # 单页失败不拖垮整批continueroot 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, timeout10)。忽略状态码❌ 拿着 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一起吞掉CtrlC 都停不下来。✅ 只捕获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这套最小组合把「发请求」和「解结构」两件事拆得干干净净二十来行就能跑。先把它写熟理解每一步为什么这么写再考虑要不要上框架——那时你判断框架里每个组件的作用会清楚得多。