Python 爬虫的原理

前言

很多人学爬虫是从背 API 开始的:记住 urlopen 怎么写、记住选择器怎么写。结果一旦换个网站就卡住,因为脑子里没有「整个东西是怎么运转的」这张图。本文反过来,先讲原理再落到代码:爬虫本质上就是一个带礼貌约束的图遍历程序,理解了这一点,任何框架都只是把这张图实现得更高效而已。

需要先纠正一个常见说法:有人把爬虫描述成「模拟浏览器点击」或者「破解网页的加密」。这两个说法都不准确。爬虫做的是发 HTTP 请求、拿 HTTP 响应、从文本里抽数据,它不点击、不渲染、也不解密(除非页面内容真的经过加密,那属于另一回事,本文不涉及)。至于「破解」------绕过登录墙、付费墙、验证码这类做法不在本文范围,本文只讲合规地把公开数据取下来。

还有一个前提要说清楚:本文所有代码基于 Python 3。Python 2.7 已于 2020 年 1 月 1 日停止维护,网上大量老文章里的 print 语句、dict.iteritems()、xrange 在 Python 3 里已经不存在,遇到要改成 print(...)、dict.items()、range。整篇文章的例子只用标准库,不需要装任何第三方包。

一、原理起点:HTTP 是「一问一答」

HTTP(HyperText Transfer Protocol,超文本传输协议)是无状态协议。客户端发一个请求,服务端回一个响应,这次交互就结束了;服务端不记得你上一次是谁(除非靠 Cookie 之类的机制显式记住)。所以「爬虫」在协议层面没有任何特殊之处------它和浏览器发的是同一类请求,区别只在请求头、频率和后续处理。

一次交互里,最需要理解的三样东西是:

  • 请求方法:GET 表示「取资源」,POST 表示「提交数据」。绝大多数采集用 GET。
  • 请求头 :User-Agent 表明客户端身份,Accept-Language、Referer、Cookie 影响服务端返回什么。
  • 响应 :状态码(200 成功、301/302 跳转、403 拒绝、404 不存在、429 请求过多)加上响应体。响应体是字节串,要按正确的字符集解码成文本。

理解了这一点,就知道「反爬」的绝大多数手段其实是在请求头、频率、Cookie 这三个维度上做文章,而不是什么黑魔法。

二、原理核心:爬虫是一次图遍历

把每个 URL 看成一个节点,把页面里的链接看成有向边,那么「顺着链接把所有页面抓一遍」就是一次图的遍历。任何爬虫的调度循环都可以写成这几步:

  1. 把起点 URL 放进待抓队列;
  2. 从队列取出一个 URL;
  3. 检查它是否已经抓过(去重 )、是否允许抓(robots 与频率);
  4. 发请求、拿响应、解析出新的链接;
  5. 把「同域且没抓过」的链接放回队列;
  6. 重复,直到队列空或达到页数上限。

用广度优先(FIFO 队列)还是深度优先(栈),决定了抓取顺序。广度优先更常见,因为它能保证「离入口近的页面先抓到」,也更方便控制总量。去重集合是这个循环里最容易被忽略又最关键的部件:没有它,一个环形链接就能让你无限抓下去。

三、页面怎么变成数据

HTTP 返回的响应体默认是「一大坨文本」。要从中拿数据,先要把它结构化。浏览器内部会把这个文本解析成一棵 DOM(Document Object Model,文档对象模型)树,然后你才能在开发者工具里点选元素。Python 端常见有两类做法:

  • 事件式解析 :html.parser.HTMLParser 这类。你继承它,重写 handle_starttag / handle_data / handle_endtag,它边扫边回调。省内存、无依赖,但写法啰嗦,且官方文档明确说它不检查标签是否配对。
  • 树式解析:先把文本建成 DOM 再查询。标准库没有顺手的 DOM 加选择器方案,实际工作中多用第三方库;具体 API 请以各库官方文档为准。

还有一类数据根本不在 HTML 里,而是页面通过接口异步加载的 JSON。不要为了拿到它去逆向破解签名、绕过风控------正确做法是看这个站点有没有开放的数据接口或 API,有就按它的规则申请与调用。

原理要素 它解决的问题 对应实现

|---------|-----------|--------------------------|
| 请求 / 响应 | 怎么把网页拿到本地 | urllib.request.urlopen |

|-----------|--------------|--------------------------------------|
| URL 解析与拼接 | 相对链接变绝对、参数编码 | urllib.parse.urljoin / urlencode |

|------------|----------|-----------------------------|
| 队列 + 已访问集合 | 抓哪些、不重复抓 | collections.deque + set |

|----|----------|-----------------------|
| 解析 | 文本变结构化数据 | html.parser 或第三方解析库 |

|----|------------------|--------------|
| 限速 | 不给对方添麻烦,也降低被限流概率 | time.sleep |

|-----------|-------------|----------------------|
| robots 检查 | 抓取前先问「允不允许」 | urllib.robotparser |

|------|--------------|------------------------------------------|
| 会话保持 | 跨请求带着 Cookie | http.cookiejar + HTTPCookieProcessor |

四、为什么并发对爬虫「有用但要克制」

爬虫绝大部分时间花在等网络 上,属于典型的 I/O 密集任务。CPython 有全局解释器锁(GIL,Global Interpreter Lock),同一进程内同一时刻只有一个线程在执行 Python 字节码;但线程在等待网络 I/O 时会释放 GIL,所以多线程对 I/O 密集的爬虫确实能提高吞吐。反过来,CPU 密集的任务用多线程没有意义,需要用多进程或 C 扩展。

不过「能并发」不等于「该高并发」。对一个小站点开几十上百个并发,等于在替它做压力测试,既不礼貌也可能直接把自己送进黑名单。合理的做法是先设一个很小的并发上限,再配合每域名间隔。Python 3.13 起有实验性的自由线程(no-GIL)构建,但它不是默认构建、需要单独安装,所以「Python 已经没有 GIL 了」这句话目前并不成立。

实战:一个会翻页、去重、限速的最小爬虫

下面用标准库实现前面讲的遍历循环。目标站 quotes.toscrape.com 是专门给练习用的站点。

python 复制代码
# 适用于 Python 3.8+

import time

import urllib.parse

import urllib.request

import urllib.robotparser

from collections import deque

from html.parser import HTMLParser



UA = "principle-walker/0.1 (contact: theory@example.net)"

DELAY = 1.0  # 每两次请求之间的间隔(秒)





class AnchorScanner(HTMLParser):

    """只收集页面里的 a 标签 href。"""



    def __init__(self):

        super().__init__()

        self.links = []



    def handle_starttag(self, tag, attrs):

        if tag == "a":

            href = dict(attrs).get("href")

            if href:

                self.links.append(href)





class SiteCrawler:

    def __init__(self, start_url, max_pages=5):

        self.start_url = start_url

        self.max_pages = max_pages

        self.host = urllib.parse.urlsplit(start_url).netloc

        self.seen = set()

        self.robots = self._load_robots()



    def _load_robots(self):

        parts = urllib.parse.urlsplit(self.start_url)

        robots_url = urllib.parse.urlunsplit(

            (parts.scheme, parts.netloc, "/robots.txt", "", "")

        )

        parser = urllib.robotparser.RobotFileParser()

        parser.set_url(robots_url)

        try:

            parser.read()

        except Exception:

            return None  # 拿不到 robots 时按「不抓」处理

        return parser



    def allowed(self, url):

        if self.robots is None:

            return False

        return self.robots.can_fetch(UA, url)



    def fetch(self, url):

        req = urllib.request.Request(url, headers={"User-Agent": UA})

        with urllib.request.urlopen(req, timeout=15) as resp:

            charset = resp.headers.get_content_charset() or "utf-8"

            return resp.read().decode(charset, errors="replace")



    def run(self):

        queue = deque([self.start_url])

        while queue and len(self.seen) < self.max_pages:

            url = queue.popleft()

            if url in self.seen or not self.allowed(url):

                continue

            self.seen.add(url)

            try:

                html = self.fetch(url)

            except Exception as exc:

                print("抓取失败:", url, exc)

                continue



            print("已抓:", url)

            parser = AnchorScanner()

            parser.feed(html)

            parser.close()



            for href in parser.links:

                absolute = urllib.parse.urljoin(url, href)

                parts = urllib.parse.urlsplit(absolute)

                if parts.scheme in ("http", "https") and parts.netloc == self.host:

                    clean = urllib.parse.urldefrag(absolute)[0]

                    if clean not in self.seen:

                        queue.append(clean)



            time.sleep(DELAY)  # 礼貌限速





if __name__ == "__main__":

    SiteCrawler("https://quotes.toscrape.com/", max_pages=5).run()

这里每一行都对应第二节里的一个原理步骤:deque 是待抓队列,set 是去重,allowed 是 robots 检查,urljoin + urldefrag 是把相对链接规范成绝对链接并去掉锚点,time.sleep 是限速。把它读懂,再去看任何框架的文档都不会迷路。

常见坑点

1. 没有去重集合,环形链接导致无限抓

❌ 每解析到一个链接就直接丢进队列,页面 A 链到 B、B 又链回 A,程序永远跑不完。 ✅ 维护一个 seen 集合,入队前先判断是否访问过。

2. 相对链接当绝对链接用

❌ urlopen("/page/2/") ------ 没有 host,直接抛异常。 ✅ 用 urllib.parse.urljoin(base, href) 拼成绝对地址。

3. 拼查询参数时手工字符串连接

❌ url + "?q=" + keyword ------ 关键词里若有 &、空格、中文,URL 会坏掉。 ✅ 用 urllib.parse.urlencode({"q": keyword}),它负责百分号编码。

4. 把状态码异常当成「没数据」

❌ 只用裸 except: 吞掉一切,403 和 404 一视同仁地跳过。 ✅ 分别处理:404 说明页面不存在,403 说明被拒绝(应停止而非重试),429 说明频率过高(应退避)。

5. 用 time.sleep 之外还想靠多线程「加速一切」

❌ 「开 200 个线程就能抓得飞快」------对 CPU 密集无效,对小站还会被打进黑名单。 ✅ 认清爬虫是 I/O 密集;用少量并发 + 每域名间隔,遵守对方的频率约定。

6. 拿 resp.read() 直接当字符串比对

❌ if b"关键词" in resp.read(): 与 if "关键词" in resp.read(): 混用 ------ 前者是字节比较,后者在 Python 3 里会报 TypeError。 ✅ 先 decode 成 str 再做文本判断,解码字符集来自响应头。

7. 抄了 Python 2 的示例

❌ print html、for k, v in d.iteritems()、range = xrange(...)。 ✅ print(html)、d.items()、range(...)。Python 2.7 已于 2020 年 1 月 1 日停止维护。

总结

原理 一句话 落到代码

|----------|-----------------|-----------------------|
| 无状态请求-响应 | 一问一答,靠头信息决定返回什么 | Request + urlopen |

|-----|----------|-------------|
| 图遍历 | 从入口顺链接扩散 | 队列 + seen |

|----|-------|--------------------|
| 解析 | 文本变结构 | HTMLParser 或第三方库 |

|----|----------|--------------|
| 限速 | 宁可慢,不添麻烦 | time.sleep |

|--------|--------|-----------------------------|
| robots | 先问允不允许 | RobotFileParser.can_fetch |

|--------|-----------|-----------|
| I/O 密集 | 等待时释放 GIL | 少量线程/异步即可 |

爬虫的原理没有神秘之处:它就是把「HTTP 请求---链接扩散---文本解析」这三件事拼成一个受礼貌约束的循环。把这张图刻进脑子里,剩下的都是工程细节和合规判断。动手之前,先读一眼目标站的 robots.txt 和使用条款。

相关推荐
Fluxproxy1 小时前
Python爬虫异步提速终极方案|线程池ThreadPoolExecutor实战,告别单线程慢速爬取
开发语言·爬虫·python
蜗牛互联网1 小时前
Java标准HTTP调用多模态模型的票据抽取前置校验
java·人工智能·python·http
用户019027581611 小时前
AlphaFeed K 线的 OHLCV 字段怎么理解?(含单位)
python
淡海水1 小时前
02-07-原理篇-GC暂停时间的本质
开发语言·unity·c#·gc·暂停时间
高级程序源1 小时前
SSM仓储管理系统31582-计算机课程设计、毕业设计
vue.js·python·mysql·小程序·flask·课程设计
工具派1 小时前
Agent 不是玄学:用 Java 状态机思维理解 LangGraph
java·人工智能·python
czq_26867194872 小时前
Python打卡第36天
pytorch·python·深度学习
计算机编程指导师2 小时前
【计算机毕设】基于Hadoop+Spark的美国互联网银行贷款数据可视化分析系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·数据分析·spark·毕业设计·贷款
Starry-sky(jing)2 小时前
onnxruntime 安装后 GPU 没加速?3 条命令确认它跑在 CPU 还是 GPU
python·gpu·cuda·onnxruntime·推理加速