简介:《Python爬虫技术:从基础到实战》是一份面向具备一定编程经验的学习者与开发者的docx文档,聚焦用Python高效采集网络公开数据,并兼顾合规与隐私保护。内容从爬虫概念、优势和工作原理讲起,逐步展开发送请求、解析网页、数据存储三大核心环节,结合requests、BeautifulSoup、lxml与Scrapy说明各自特点及适用场景,并配有请求头设置、标签定位、XPath提取和基础Spider等代码片段;同时涉及动态加载处理、反爬应对和效率优化等进阶技巧。压缩包仅含1个docx文件,大小约16KB,篇幅精炼,便于按主题查阅与快速上手;已有629人学习。读者可据此理解爬虫开发流程、常用库选型与实战思路,也可为研究机构批量获取公开数据、开发者搭建数据挖掘项目或信息监测系统提供参考,并在实践中注意遵守相关法规与网站协议。
1. 爬虫的真相:瓶颈从来不在"把网页拿下来"
很多人对 Python 爬虫的第一印象是"把网页下载下来",于是把精力全花在换请求头、加延时、找可用出口 IP 上。可真跑过一次长期任务的人会发现,翻车的点几乎从不在网络层:三天前还能命中的选择器,今天返回一堆空列表;同一份代码本地跑得飞快,上了服务器就卡在某个响应上不动;更邪门的是状态码全是 200,抓下来的却是登录页。网络数据采集的难点从来不是"抓",而是"抓完之后结构还能不能对上、任务还能不能收敛"。
把爬虫看成一条流水线会更清楚:请求层负责把字节拿回来,解析层负责把字节变成字段,调度层负责决定"下一个抓谁、抓过了没有"。三段各自的失败模式完全不同,混在一起调就会变成一种玄学——改一个参数,别的地方又坏了。下面按这条流水线往下拆,讲清楚每一段真正在做什么、哪里最容易出问题。
2. HTTP 请求层:requests.get 这一行背后压缩了多少东西
requests.get(url)一行代码给人的错觉是"网络请求很简单"。实际上它把 TCP 握手、TLS 协商、HTTP 报文拼装、响应头解析、编码推断、重定向跟随、连接回收这一整套流程全塞进了一次函数调用里。看不见不代表不存在,出问题时恰恰就是这些看不见的层在捣乱。
2.1 一次 GET 在 socket 上留下的真实痕迹
要理解requests帮你做了什么,最直接的办法是绕开它,用标准库的socket手写一次 HTTP/1.1 请求。写出来就会发现,所谓"请求"就是一串按格式拼好的 ASCII 字节,发出去之后再按分隔符切回来。
import socket # 手工拼一个 HTTP/1.1 请求,走明文 80 端口 sock = socket.create_connection(("demo.example", 80), timeout=5) request = ( "GET /list?page=1 HTTP/1.1\r\n" "Host: demo.example\r\n" "Connection: close\r\n" # 明确告知服务端:本次用完即断 "User-Agent: demo-crawler/1.0\r\n" "\r\n" # 空行是头部与正文的分界符 ) sock.sendall(request.encode("ascii")) chunks = [] while True: buf = sock.recv(4096) if not buf: # recv 返回空字节表示对端关闭 break chunks.append(buf) raw = b"".join(chunks) head, _, body = raw.partition(b"\r\n\r\n") # 按空行切开响应头与响应体 print(head.split(b"\r\n")[0].decode("latin-1")) # HTTP/1.1 200 OK print(len(body)) # 字节数,不是字符数这段代码的信息量比它看起来大。第一,请求行和头部必须用\r\n而不是\n,用错符号服务端会直接返回 400,这也是手写报文最常踩的坑。第二,Host头是 HTTP/1.1 的强制字段,同一台服务器上可能挂着几十个站点,服务端靠它决定返回哪一份内容,缺了它拿到的可能是默认站点。第三,响应头和响应体之间靠一个空行分隔,partition(b"\r\n\r\n")就是在做这个切割。第四,recv拿到的是字节流,一次调用不保证读完整的响应,必须循环到对端关闭或按Content-Length判断结束。
requests在这些之上又叠了几层:连接池管理、重定向自动跟随、Cookie 容器、内容编码解压、字符集推断。其中字符集推断是最容易被忽略的一环,也是乱码问题的总根源。它的策略是先读响应头Content-Type里的charset,读不到就用统计方法猜。猜错的概率在中文站点上并不低,尤其是一些老站点把编码写在<meta>里而非响应头里。
import requests # timeout 用元组:(连接超时, 读超时),两者含义完全不同 resp = requests.get("https://demo.example/list", timeout=(3.05, 10)) # 响应头没给 charset 时,主动接管解码,别让库去猜 raw = resp.content # 原始字节,未经解码 for enc in ("utf-8", "gb18030"): # 按优先级依次尝试 try: text = raw.decode(enc) break except UnicodeDecodeError: continue # 解不开就换下一个 else: text = raw.decode("utf-8", errors="replace")参数含义值得单独说一遍。timeout传元组时,第一个值是建立 TCP 连接的超时,第二个值是两次recv之间的最大间隔——注意是"间隔"不是"总时长"。这意味着如果服务端每 8 秒吐一个字节,而读超时设的是 10 秒,这个请求可以一直挂着不结束。这就是"明明设了超时还是跑了一整夜"的典型成因。另一处盲区是 DNS 解析,它发生在连接建立之前,不受这个超时覆盖,域名解析卡住时同样表现为"无响应"。
2.2 Session、连接复用与状态:为什么"每次新建会话"会拖慢一半
一个 HTTPS 请求的真实成本里,DNS 查询、TCP 三次握手、TLS 握手加起来往往比传输本身还贵。TLS 握手在 TLS 1.2 下需要两个往返,TLS 1.3 优化到一个,但依然不是免费的。如果每抓一个页面都新建一次连接,等于把这份开销重复支付几千次。requests.Session解决的正是这个问题:它内部持有一个HTTPAdapter,适配器里是连接池,同一个 host 的连接用完会放回池子而不是关闭。
import requests with requests.Session() as s: # 会话级默认头,后续所有请求自动带上 s.headers.update({"User-Agent": "Mozilla/5.0 (compatible; demo-crawler/1.0)"}) adapter = requests.adapters.HTTPAdapter( pool_connections=10, # 为多少个不同 host 各保留一个连接池 pool_maxsize=20, # 单个池内最多缓存多少条空闲连接 max_retries=2, # 连接层重试次数,不覆盖 4xx/5xx 业务错误 ) s.mount("https://", adapter) # 让 https 请求走这个适配器 for page in range(1, 6): r = s.get(f"https://demo.example/list?page={page}", timeout=(3.05, 10)) print(page, r.status_code, len(r.content))pool_connections和pool_maxsize这两个参数经常被混为一谈。前者是"池的数量",按 host 分桶;后者是"每个桶的容量"。如果目标站点只有一个域名,把pool_connections调再大也没用,该调的是pool_maxsize。反过来,如果一次任务要打几十个不同子域,池数量不够会导致连接被频繁创建销毁,复用的收益荡然无存。
Session还顺带维护了 Cookie。需要登录态才能访问的页面,用同一个 Session 连续请求,服务端下发的会话标识会自动带上,不用手工拼Cookie头。但这里有个隐蔽的坑:Session本身不是为多线程共享设计的。连接池内部虽然有锁,CookieJar和头部字典的读写却没有。多线程环境下共享一个 Session,容易看到偶发的 Cookie 丢失或者头部被覆盖。常见的做法是线程局部存储,每个线程一个 Session 实例。
import threading import requests _local = threading.local() def get_session() -> requests.Session: # 每个线程持有独立 Session,避免 CookieJar 与 header 的竞态 if not hasattr(_local, "session"): s = requests.Session() s.headers.update({"User-Agent": "demo-crawler/1.0"}) _local.session = s return _local.session3. 解析层:同一份 HTML,为什么两种解析器给出两棵树
拿到 HTML 字符串之后,下一步是把它变成可查询的树。这一步看起来最没有技术含量,实际上决定了整条流水线的可靠程度。BeautifulSoup和lxml的差别远不止"一个慢一个快",它们对畸形 HTML 的容错规则压根不是一套。
3.1 两种建树路径:纯 Python 状态机与 libxml2
html.parser是标准库自带的解析器,纯 Python 实现,本质是一个分词器加一个手写的标签栈状态机,遇到未闭合的标签就按内置规则补上。lxml底层是 C 写的 libxml2,容错策略是另一套,并且会主动补全<html>、<body>这些结构节点。同一段残缺的 HTML,喂给两者可能得到结构不同的树,进而导致选择器在其中一棵上命中、在另一棵上落空。
from bs4 import BeautifulSoup # 故意写一段未闭合、缺结束标签的 HTML broken = "<div class='item'><h3>标题A<p>描述A</div>" \ "<div class='item'><h3>标题B" for parser in ("html.parser", "lxml"): soup = BeautifulSoup(broken, parser) titles = [h.get_text(strip=True) for h in soup.find_all("h3")] # 观察第一个 div 的直接子节点,两种解析器的补全结果不同 first_div_children = [c.name for c in soup.find("div").find_all(recursive=False)] print(parser, titles, first_div_children)这段代码的输出差异,就是"本地调试正常、线上取不到"的一类经典来源:浏览器开发者工具里看到的那份 DOM,是浏览器补全、执行过脚本之后的最终形态,不是服务端返回的原始字节。拿它去反推选择器,等于用答案倒推题目。正确的做法永远是把response.content原样落盘,用落盘的那份做选择器调试验证。
选择器的写法也值得对齐一下习惯。用class精确匹配是很脆的写法,因为class是多值属性,页面加一个active、highlight之类的状态类,@class="item"立刻失效。用contains(@class, "item")稳得多,但也会误伤item-list这种名字。更可靠的锚点是语义稳定的属性——带业务含义的>from lxml import etree html = etree.HTML(raw_html) # 返回补全后的 <html> 根节点 # 先按容器切块,再在块内取字段,避免全局搜索带来的错位 rows = html.xpath('//div[contains(@class,"item")]') for row in rows: title = row.xpath('.//h3/text()') # 相对路径必须用 . 开头 link = row.xpath('.//a/@href') print((title[0] if title else "").strip(), link[0] if link else "")
两个细节决定了这段代码能不能长期跑下去。一是//与.//的区别:前者从文档根开始全局匹配,后者从当前节点往下,逐块提取字段时必须用后者,否则每条记录都会取到页面第一个标题。二是xpath永远返回列表,编码时不能假设它一定非空,title[0]直接下标在页面结构变化时会抛IndexError,这种异常如果没被捕获,整个任务的进度就断在那一条上。
还有一个性能层面的注意点:etree.HTML每次调用都会重新建树。如果同一个页面要提取十几个字段,先建一次树再多次查询,而不是每个字段各建一次。
3.3 动态渲染:page_source 到底给的是哪一版
现代站点大量使用异步加载,首屏 HTML 里没有数据,内容靠脚本回来之后再塞进 DOM。这种情况下静态解析必然拿不到东西,需要真实执行脚本的浏览器环境。用浏览器驱动时最典型的误解是:以为page_source返回的是"页面最终状态"。实际上它是"你读取那一刻的 DOM 序列化结果",读早了就是半成品。
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver = webdriver.Chrome() try: driver.get("https://demo.example/list") # 等到列表项真的出现,而不是等文档加载状态 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, "div.item")) ) driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") # 更可靠的判定:元素数量达到预期,而不是 readyState WebDriverWait(driver, 10).until( lambda d: len(d.find_elements(By.CSS_SELECTOR, "div.item")) >= 20 ) html = driver.page_source finally: driver.quit() # 放 finally,避免异常路径下浏览器进程残留document.readyState == "complete"只说明文档和静态资源加载完成,不说明后续的异步请求回来了,用它做等待条件经常等了个寂寞。真正可靠的信号是业务元素本身——元素出现、数量达到预期、或者某个加载中的占位节点消失。另外,每个浏览器实例都有独立的内存占用,批量任务里如果不复用实例,几百个页面之后内存就会爬到一个危险水位,常见做法是复用实例并定期重启。
4. Scrapy 的调度内核:异步引擎为什么快,又为什么会被一行同步代码拖死
手写循环加线程能跑到几千个页面,再往上就会撞到天花板:线程切换成本、连接管理混乱、去重和重试要自己写。Scrapy存在的意义不是"封装了几个库",而是提供了一套基于事件循环的调度内核,让单进程能同时挂起成千上万个未完成的请求。
4.1 引擎、调度器、下载器、爬虫、管道之间的数据流
整套机制围绕一个中心节点转:引擎。它不是多线程模型,而是单线程事件循环(底层是 Twisted 的 reactor)。整个流程是一条闭环:引擎从调度器取出一个请求,交给下载器;下载器完成之后把响应回传给引擎;引擎把响应交给对应的 Spider 回调;回调里产出的 Item 进管道,产出的新请求回到调度器。全程没有线程阻塞等待,任何一个环节在等 IO 时,控制权都会让出去处理别的请求。
这个设计带来的直接后果是:任何在回调里执行的同步阻塞操作,都会卡住整个事件循环。在parse方法里直接调一次数据库写入、一次requests.get、一次大文件读取,表面上只是这一条慢,实际上所有正在排队的请求、所有已经下载完等着回调的响应,全都得等它。表现出来就是"跑着跑着吞吐量突然掉下来",查日志又看不出错误——这类问题算得上是 Scrapy 里最典型的黑匣子。
常见的解法有两条。轻量的做法是把阻塞调用丢到线程池里执行,返回一个延迟对象;彻底的做法是把存储逻辑挪到管道里,用支持异步的客户端。
import scrapy from twisted.internet import threads class ListSpider(scrapy.Spider): name = "list" start_urls = ["https://demo.example/list"] def parse(self, response): for row in response.xpath('//div[contains(@class,"item")]'): yield { "title": row.xpath('.//h3/text()').get(default="").strip(), "link": row.xpath('.//a/@href').get(), } # 阻塞型操作交给线程池,别让它占住 reactor yield threads.deferToThread(self._save_progress, response.url) def _save_progress(self, url): # 这里可以放心做同步 IO,它跑在独立线程里 with open("progress.log", "a", encoding="utf-8") as f: f.write(url + "\n")deferToThread的参数就是普通函数和它的入参,返回一个 Deferred,Scrapy 会把它接进自己的回调链。要注意线程池大小是有上限的,把大量阻塞任务一股脑丢进去,等于把瓶颈从事件循环转移到了线程池排队,效果只是换了个地方堵。
4.2 并发参数的真实含义
配置里那几个数字经常被误读成"调大就快"。实际上它们各自约束的是不同维度,混着调很容易得出错误结论。
# settings.py 片段 CONCURRENT_REQUESTS = 32 # 全局同时在途的请求上限 CONCURRENT_REQUESTS_PER_DOMAIN = 8 # 单个域名同时在途的请求上限 DOWNLOAD_DELAY = 0.5 # 同域名两次请求之间的最小间隔(秒) RANDOMIZE_DOWNLOAD_DELAY = True # 实际间隔在 0.5 到 1.5 倍之间抖动 DOWNLOAD_TIMEOUT = 15 # 单次下载超时,秒 RETRY_TIMES = 2 # 仅对连接错误与 5xx 生效 AUTOTHROTTLE_ENABLED = True # 依据响应延迟自动下调并发 AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0 # 自动调节的目标并发DOWNLOAD_DELAY约束的是同一域名下两次请求的起始时间间隔,不是单次请求的耗时上限,把它和DOWNLOAD_TIMEOUT搞混是配置阶段最常见的误解。RANDOMIZE_DOWNLOAD_DELAY打开之后,实际间隔会在设定值的 0.5 到 1.5 倍之间浮动,避免所有请求以固定节拍抵达——固定节拍本身就是一种很明显的机器特征。
单域名并发设成 8 还是 2,取决于对方能承受多少,不取决于你的机器。并发拉高之后,服务端开始限流或降级,返回的响应变慢、错误率上升,重试机制又把流量放大一轮,最后总吞吐反而低于低并发。这类雪崩在生产环境里很常见,尤其是任务刚启动的那几分钟。
4.3 去重指纹与增量抓取
Scrapy 默认的请求去重器会把请求的规范化 URL、方法、请求体拼起来算一个 SHA1 指纹,存进内存里的集合。这带来一个很实际的后果:进程一重启,集合就清空,之前抓过的页面会被重新抓一遍。任务跑单次看不出问题,做成定时任务之后数据库里就会堆重复记录。
import hashlib from urllib.parse import urlsplit, urlunsplit, parse_qsl def fingerprint(url: str, method: str = "GET", body: bytes = b"") -> str: """把 URL 规范化之后再哈希,避免同页面不同写法被当成两条""" parts = urlsplit(url) # 关键一步:query 参数排序,消除顺序差异 query = "&".join(f"{k}={v}" for k, v in sorted(parse_qsl(parts.query))) norm = urlunsplit(( parts.scheme.lower(), parts.netloc.lower(), parts.path, query, "", # 丢掉 fragment,它不影响服务端返回 )) return hashlib.sha1(f"{method}|{norm}|".encode() + body).hexdigest()这段逻辑里最容易被跳过的是 query 排序和 fragment 剔除。?a=1&b=2和?b=2&a=1在服务端是同一个页面,不排序就会抓两遍;URL 末尾的#section压根不会发给服务端,不剔除同样会造成重复。要做真正的增量,指纹还得持久化——存到外部键值存储里,或者入库时用业务主键做 upsert,让重复数据在写入端被吸收掉。
5. 排查实录:五类翻车现场与它们的成因链
前面几章讲的是机制,这一章讲机制失效时的样子。下面五条都是实际跑任务时反复遇到的,每条按现象、原因、解决三段来写,方便对着日志定位。
5.1 请求层:超时、乱码与假成功
第一条,超时设了但任务还是挂住不动。现象是进程活着、CPU 几乎为零、日志停在某一条请求上几个小时没有新输出。原因有两层:timeout的第二个值是两次读取之间的间隔,只要服务端每隔几秒吐一点数据,这个超时永远不会触发;另一层是 DNS 解析发生在连接建立之前,不在超时覆盖范围内。解决思路是给整个请求加一层外部时限,用线程或者异步的超时包装把单次请求圈死在固定时长内,超过就放弃并记录该 URL,而不是指望库内部的超时兜底。
第二条,中文内容在本地正常、在容器里乱码。现象是同一份代码、同一个 URL,本地跑出来是正常中文,容器里跑出来是一堆问号或者方块。原因是响应头没给charset时,编码靠统计方法猜测,而容器里的 locale 设置会影响默认编码与猜测结果的走向。解决办法是彻底放弃让库去猜:拿response.content显式按 utf-8 尝试,失败再退到 gb18030,两次都失败再考虑替换非法字节,全程不做隐式推断。
第三条,状态码全 200,内容却是登录页。现象是成功率统计很漂亮,抓下来的正文却全是同一个页面。原因是会话过期或者 Cookie 没带上,服务端把请求重定向到登录页,而登录页本身返回 200,跟随重定向之后你看到的就是它。这类问题不看内容只看状态码永远发现不了。排查手段是把allow_redirects关掉,观察Location头的跳转链条,同时在解析阶段加一条内容断言——比如页面上必须存在某个业务节点,不存在就判定为异常页并告警。
5.2 解析与调度层:选择器失效与重复入库
第四条,选择器昨天命中两百条,今天命中零条,状态码依然正常。原因通常是页面改版,或者站点在做分流实验,不同用户拿到不同模板;也有一种情况是原始 HTML 与浏览器补全后的 DOM 结构不一致,本地用开发者工具验证通过,线上拿到的原始字节里根本没有那个层级。解决的核心是留回归样本:把每天的原始响应按日期落盘几份,选择器改动必须在这些样本上跑通;同时在任务里加数量下限断言,低于阈值直接失败而不是静默产出空结果。
第五条,定时任务跑了几轮之后,库里出现大量重复记录。原因是去重指纹只存在内存里,进程重启即清空,而分页链接的参数顺序又常常不固定,导致同一页面被算出不同指纹。解决分两步走:指纹生成前先做 URL 规范化(参数排序、fragment 剔除、host 小写),再把指纹存到进程外的存储里;即便这样,写入端仍然应该用业务主键做 upsert,把去重当成最后一道闸而不是唯一一道。
还有一类不算 bug 但很折磨人的情况:长跑任务内存缓慢上涨,六小时后被系统杀掉。成因多半是响应对象被闭包或者全局列表意外持有,或者解析结果先攒在内存里最后一次性写入。养成用生成器逐个产出、及时落盘的习惯,能让这个问题在出现之前就被绕开。
6. 设计取舍:并发、礼貌与合规之间的三角平衡
并发模型的选择本质上是在三种代价之间做取舍,没有哪个方案全面占优。多线程加requests写起来最顺手,调试也直观,但线程数一上去,上下文切换和 GIL 下的解析开销就开始咬人;Scrapy的单进程事件循环能把并发拉到很高,代价是回调式写法对同步代码零容忍;asyncio加aiohttp介于两者之间,但要求整条链路都是异步的,中间混进一个同步库就会前功尽弃。
| 并发模型 | 并发单位 | 适合的规模 | 主要代价 |
|---|---|---|---|
| 多线程 + requests | 线程 | 数千级 URL | 线程数难控,解析阶段受 GIL 限制 |
| Scrapy 事件循环 | 在途请求 | 十万级 URL | 回调式写法,任何阻塞调用会拖垮全局 |
| asyncio + aiohttp | 协程 | 万级 URL | 全链路必须异步,同步库无法直接使用 |
比并发参数更值得先想清楚的是"该抓多少"。robots.txt给出的是一份访问声明,它只回答"哪些路径不欢迎",不回答"你能跑多快"。
from urllib.robotparser import RobotFileParser rp = RobotFileParser() rp.set_url("https://demo.example/robots.txt") rp.read() # can_fetch 返回布尔值,表示该 UA 是否被允许访问此路径 print(rp.can_fetch("demo-crawler/1.0", "https://demo.example/list")) # crawl_delay 返回建议间隔,返回 None 表示没写,不表示可以全力跑 delay = rp.crawl_delay("demo-crawler/1.0") print(delay)crawl_delay返回None是常态,绝大多数站点压根不写这条。拿到None就把并发拉满,是一种很典型的误读——它的意思是"没声明",不是"没限制"。实际执行时我会给单域名并发设一个保守上限,再用自适应限速根据响应延迟动态下调:对方变慢,我方就退,等到延迟回落再慢慢加上去。这套反馈闭环比任何一个固定数字都管用。
数据侧还有一个容易被忽略的取舍:只存需要的字段。整页 HTML 落盘在调试阶段很有用,长期跑就变成了存储和隐私的双重负担。字段清单应该在设计阶段就定死,超出清单的内容不进入持久化层。
最后说一条我自己踩出来的教训。早年做一个跨站采集任务,为了赶进度把单域名并发直接设成了 32,跑了两小时一切正常,第三天收到对方站点发来的限流提示,任务全线失败,重跑一遍的成本远超当初省下的那点时间。从那以后我每写一个新爬虫,都先把robots.txt的解析结果和单域名并发上限这两个数字写在配置文件最上面,再去动解析逻辑——顺序反了,后面全是白干。希望帮到你。
本文还有配套的精品资源,点击获取