news 2026/10/15 11:03:28

Python构建网页爬虫原理分析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python构建网页爬虫原理分析

前言


「会用 requests 抓一个页面」和「理解爬虫原理」是两回事。前者是调库,后者是能回答:一次请求到拿到正文之间经历了什么、下一页地址是从哪里发现的、怎么保证同一页面不被抓两遍、循环靠什么终止。这四个问题答不上来,遇到稍微复杂一点的场景就只能靠试。


一个典型误解是「原理就是库的源码」。其实原理在协议层:HTTP 是请求—响应模型,爬虫无非是把「请求、解析、发现新链接、入队、去重」这五步循环起来。第三方库只是把这五步包装得更顺手,换掉库,原理一点不变。


本文用标准库从最底层讲起:先用http.client手工发一次请求,看清响应由什么组成;再用html.parser讲链接是怎么被发现的;最后把队列、去重、限速拼成一个广度优先(BFS)模型。示例适用于 Python 3.8 及以上。


一、请求生命周期:手工发一次 HTTP


高层库藏起来的东西,用http.client就能看清。它是一次连接、一次请求、一次响应的直接映射,不会自动跟随重定向,也不会自动处理 Cookie。


# 适用于 Python 3.8+
import http.client

conn = http.client.HTTPSConnection("www.example.com", timeout=10)
conn.request("GET", "/", headers={
"User-Agent": "PrincipleBot/0.1 (contact: me@example.com)",
})
resp = conn.getresponse()
print("状态:", resp.status, resp.reason)
print("类型:", resp.getheader("Content-Type"))
print("编码:", resp.getheader("Content-Encoding"))
body = resp.read() # bytes
conn.close()
print("响应体字节数:", len(body))

一次请求的产物是「状态码 + 响应头 + 响应体」三样东西。状态码决定要不要继续处理,响应头决定响应体该怎么还原,响应体才是你要解析的内容。很多人只盯第三样,前两样直接丢掉,问题就出在这里。


高层接口urllib.request.urlopen做的就是把这些细节包好:自动跟随重定向、按需选择协议、返回一个可当上下文管理器用的响应对象。但它的行为边界要记清楚——不会自动解压,4xx / 5xx 会抛HTTPError而不是返回响应。


二、响应体的还原:编码与压缩


响应体是 bytes。要变成能解析的 str,必须经过两步还原,顺序不能反:先解压,再解码。



  • 压缩:若响应头里有Content-Encoding: gzip,响应体是 gzip 流,要先用gzip模块解压。

  • 解码:从响应头的Content-Type里取 charset,用它对 bytes 解码;取不到再退回 UTF-8。


顺序错了会得到一堆看不懂的二进制或直接抛错。高层库urlopen不替你解压,这一步必须自己确认。


三、链接发现:解析器怎么找到「下一页」


爬虫能自动抓下去,靠的是从当前页面里发现新的 URL。标准库的html.parser.HTMLParser是事件驱动的:它一边扫描一边回调,你只需要在handle_starttag里对a标签取href。


# 适用于 Python 3.8+
from html.parser import HTMLParser
from urllib.parse import urljoin


class AbsoluteLinkResolver(HTMLParser):
def __init__(self, base):
super().__init__(convert_charrefs=True)
self.base = base
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(urljoin(self.base, value))


parser = AbsoluteLinkResolver("https://www.example.com/dir/")
parser.feed('<a href="x.html">X</a><a href="/y">Y</a>')
parser.close()
print(parser.links)

这里有两个原理级的动作:



  1. handle_starttag(tag, attrs)的attrs是(属性名, 属性值)列表,属性值可能是None(布尔属性),要判空。

  2. 相对地址必须用urljoin(base, href)补成绝对地址——因为入队和去重都要求地址是规范形式。


四、调度:队列、去重、限速、终止


发现链接之后,原理上就只剩「循环」了。一个正确的循环需要四个部件:




部件作用缺失后果



待抓队列保存「还没抓的地址」抓完一页就停

已抓集合记录「抓过的地址」页面互相链接时无限循环

限速控制请求间隔拖垮对端,触发封禁

终止条件抓够数量或队列空了永远不结束



队列的取法决定了遍历策略:从头取是广度优先(BFS),先抓完整层再深入;从尾取就是深度优先(DFS)。入门和大多数站点抓取用 BFS,因为它能更快覆盖同一层的页面。


去重集合里存的应该是规范化后的绝对地址:去掉 fragment、补全相对路径、必要时统一大小写。否则page和page#top、/a和https://host/a会被当成两个地址重复抓。


实战:一个广度优先爬虫模型


下面把四步串成一个完整模型,控制在同站范围内、带 robots 检查,最多抓若干个页面。


# 适用于 Python 3.8+
import time
import urllib.error
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urldefrag

USER_AGENT = "PrincipleBot/0.1 (contact: me@example.com)"
HEADERS = {"User-Agent": USER_AGENT}


class AbsoluteLinkResolver(HTMLParser):
def __init__(self, base):
super().__init__(convert_charrefs=True)
self.base = base
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(urljoin(self.base, value))


def normalize(url, base):
absolute = urljoin(base, url)
without_frag, _ = urldefrag(absolute)
return without_frag


def robots_allows(url, user_agent):
parts = urlsplit(url)
rp = urllib.robotparser.RobotFileParser()
rp.set_url(parts.scheme + "://" + parts.netloc + "/robots.txt")
try:
rp.read()
except urllib.error.URLError:
return False
return rp.can_fetch(user_agent, url)


def fetch_text(url, timeout=10):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
return raw.decode(charset, errors="replace")


def main():
start = "https://www.example.com/"
host = urlsplit(start).hostname
queue = [start] # 待抓队列(BFS)
seen = {start} # 已入队 / 已抓(去重)
crawled = 0
limit = 5

while queue and crawled < limit:
url = queue.pop(0) # 从头取 = 广度优先
if not robots_allows(url, USER_AGENT):
print("robots 禁止:", url)
continue
try:
html = fetch_text(url)
except urllib.error.HTTPError as e:
print("HTTP", e.code, url)
continue
except urllib.error.URLError as e:
print("网络错误", e.reason, url)
continue

parser = AbsoluteLinkResolver(url)
parser.feed(html)
parser.close()

for link in parser.links:
candidate = normalize(link, url)
if urlsplit(candidate).scheme not in ("http", "https"):
continue
if urlsplit(candidate).hostname != host: # 只抓同站
continue
if candidate not in seen:
seen.add(candidate)
queue.append(candidate)

crawled += 1
print("已抓:", url, "队列剩余:", len(queue))
time.sleep(1) # 限速

print("结束,共抓取", crawled, "个页面")


if __name__ == "__main__":
main()

这段代码把前面四个部件都体现了:queue是待抓队列、seen同时完成入队去重、time.sleep(1)是限速、crawled < limit配合queue为空是终止条件。


常见坑点



  1. 只读响应体,丢掉状态码和响应头


❌ 拿到内容直接解析,不看Content-Type和状态码。 ✅ 先看状态码决定是否处理,再用响应头里的 charset 解码。



  1. 解压和解码顺序反了


❌ 先.decode()再想 gzip —— 压缩流上解码必然失败。 ✅ 先按Content-Encoding解压,再按 charset 解码。



  1. 去重集合里存原始地址


❌ 把page、page#top、https://host/page当三条记录。 ✅ 先urljoin补全、urldefrag去 fragment,再入seen。



  1. 忘了判断同站


❌ 把所有href都入队,很快抓到站外去。 ✅ 比较urlsplit(url).hostname,只保留同站链接。



  1. 队列取法写错导致策略不符预期


❌ 写queue.pop()却以为是广度优先——那是深度优先。 ✅queue.pop(0)是 BFS;明确自己用哪种并写进注释。



  1. 没有终止条件


❌while queue:且页面无限互链 —— 可能跑很久。 ✅ 加上数量上限或深度上限,与队列空同时作为终止条件。



  1. robots 检查放在入队之后却忘了限速


❌ 只查 robots,不设间隔,一秒发几十个请求。 ✅ robots 只解决「允不允许」,频率要靠time.sleep或Retry-After。



  1. 用 Python 2 的思路理解异常


❌except urllib2.URLError, e:—— 这种语法在 Python 3 里非法。 ✅ 用except urllib.error.URLError as e:;Python 2 已于 2020 年停止维护。


总结




环节原理要点常见失误



请求状态码 + 响应头 + 响应体只看响应体

还原先解压、再解码顺序反了或硬编码 UTF-8

发现事件回调取href,urljoin补全相对地址直接入队

调度队列 + 去重 + 限速 + 终止缺任一件都会出事



爬虫原理说穿了就是一句话:把「请求—还原—发现—入队—去重」循环起来,并给它加上礼貌和终止条件。把这条线在自己脑子里跑通,换任何库都不会迷路。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 11:03:20

TCP流量控制与拥塞控制的本质区别与协同机制

1. 为什么“流量控制”和“拥塞控制”总被混为一谈&#xff1f;——从一个真实丢包现场说起上周帮某高校实验室调试一套实时视频流分析系统&#xff0c;后端用的是自研的TCP长连接传输模块。现象很典型&#xff1a;局域网内一切正常&#xff0c;一旦接入校园骨干网&#xff0c;…

作者头像 李华
网站建设 2026/10/15 11:03:19

期刊要求披露AI使用吗?投稿前AIGC率多少才稳妥?

期刊AI率超标被退稿&#xff0c;往往不是因为作者用了AI&#xff0c;而是投稿前根本没查过。 自己写的论文同样可能有AI率&#xff0c;20%-30%的情况并不少&#xff0c;尤其是综述和总结部分&#xff0c;表达越规整越容易被误判为AIGC生成。 每个期刊的AIGC率要求是多少&…

作者头像 李华
网站建设 2026/10/15 11:03:17

从感知机到神经网络:激活函数如何突破线性不可分困境

某天下午我在调一个实验代码&#xff0c;用单层感知机去学 XOR&#xff08;异或&#xff09;逻辑&#xff0c;折腾了一整个晚上&#xff0c;训练准确率始终卡在 75% 左右&#xff0c;四组样本里总有那么一组会学错。那一刻我突然意识到&#xff1a;教科书里说的“线性不可分”根…

作者头像 李华
网站建设 2026/10/15 11:03:04

ArcGIS协同克里金法实战:从互协方差建模到交叉验证避坑指南

简介&#xff1a;这份ArcGIS教程文档面向地理信息、环境科学与资源管理领域的学习者与从业者&#xff0c;系统讲解协同克里金法这一空间插值技术。内容围绕如何利用目标变量Z1的自相关性及其与协变量Z2、Z3之间的互相关性提升预测精度展开&#xff0c;涵盖普通协同克里金法的模…

作者头像 李华
网站建设 2026/10/15 11:03:02

TCP流量控制与拥塞控制协同机制深度解析

1. 为什么TCP的流量控制和拥塞控制不是“背下来就行”的知识点&#xff1f;你翻过《计算机网络》教材&#xff0c;肯定见过滑动窗口、慢启动、拥塞避免这些词——但合上书&#xff0c;真让你在Wireshark里抓包分析一段视频会议卡顿的根源&#xff0c;或者调试一个高延迟API接口…

作者头像 李华
网站建设 2026/10/15 11:02:36

STM32 I2C接口驱动SSD1306 OLED显示屏:原理到实践

简介&#xff1a;面向STM32嵌入式开发者的IIC接口OLED显示程序工程&#xff0c;基于标准外设库完成OLED屏的驱动与显示&#xff0c;适合正在学习I2C通信协议、OLED时序控制或需要快速搭建显示模块的入门及进阶开发者。工程包内含130个文件&#xff0c;主要为h头文件与c源文件&a…

作者头像 李华