☰
Python爬虫从基础到实战:请求、解析与采集规范全链路
2026/10/15 8:10:47 网站建设 项目流程

简介:这份资源是一份面向具备一定编程基础的学习者与开发者的Python爬虫技术文档,系统梳理从基础概念到实战应用的知识脉络,帮助读者掌握高效网络数据采集的方法与合规边界。内容围绕发送请求、解析网页、数据存储三大核心环节展开,重点讲解requests、BeautifulSoup、lxml与Scrapy等常用库的特点及适用场景,并延伸至动态加载处理、反爬虫机制规避与爬虫效率优化等进阶技巧,同时强调法律法规、网站协议与用户隐私保护。资源包共1个docx文件,约16KB,以图文与代码示例结合的方式呈现,便于按章节检索学习。目前已有628人学习,适合需要批量获取公开数据的研究人员、希望构建数据挖掘或信息监测系统的开发者,以及想通过小项目深入理解爬虫原理的编程学习者参考。

1. 数据采集这件事,门槛从来不在“爬”字上

很多人第一次写 Python 爬虫,是冲着“把网页内容抓下来”去的,结果真正卡住的地方往往不是请求发不出去,而是发出去之后拿回来的东西对不上、跑两天就被限、字段三天两头变。标题里说的“从基础到实战”,落到工程上其实是三件事:把请求发稳、把结构解析准、把采集节奏控制住。这套方法适合做竞品价格监控、行业公开数据归集、内容聚合类产品的数据源建设,也适合个人做量化素材积累。它不神秘,但确实有一套自己的规矩——不懂规矩的爬虫,跑得越快死得越早。下面按“能复现”的标准,把这条链路拆开讲。

2. 请求层怎么搭:从 urllib 到 requests 的选型与最小可跑骨架

2.1 为什么实战里几乎不用 urllib 裸写

Python 标准库自带 urllib,理论上不发任何第三方依赖就能抓页面。但真到项目里,几乎没人用它裸写业务代码,原因很实在:连接池要自己管、重试要自己写、编码处理容易翻车、Session 维持 Cookie 得手动拼。requests 把这些都封好了,代码量能压到三分之一,可读性还高。选型上我的习惯是:一次性脚本用 requests 足够;要跑高并发、要精细控制连接复用,再上 httpx 或 aiohttp。下面这段是能直接跑的最小骨架,包含超时、重试、UA 伪装三个必备件。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 统一会话,复用连接,避免每次请求重新握手 session = requests.Session() # 重试策略:只对幂等的状态码和连接错误重试 retry = Retry( total=3, # 最多重试 3 次 backoff_factor=0.5, # 退避系数,第 n 次等待 0.5 * 2^(n-1) 秒 status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "HEAD"] ) adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20) session.mount("https://", adapter) session.mount("http://", adapter) HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch(url, timeout=(5, 15)): # timeout 传元组:(连接超时, 读取超时),比单一数值更可控 resp = session.get(url, headers=HEADERS, timeout=timeout) resp.raise_for_status() # 4xx/5xx 直接抛异常,别让脏数据流到下游 resp.encoding = resp.apparent_encoding # 中文站点编码经常标错,用探测值兜底 return resp.text

逻辑上,这段代码解决的是“请求不稳定”这个最容易被低估的问题。参数说明几个关键点:total=3是重试总次数,不是并发数;backoff_factor控制退避节奏,设太小等于没退避,设太大任务会拖慢,0.5 到 1 之间比较稳;pool_maxsize要和你的并发量匹配,太小会频繁新建连接,太大占内存。timeout用元组是血泪经验——只设一个值,遇到慢响应会一直挂着,任务卡死你还找不到原因。

2.2 会话、Cookie 与请求头的正确姿势

很多人把请求头当成“随便填个 UA 就行”,实际上服务端判断一个请求是不是正常浏览器,看的是整套头部的一致性。UA 写 Chrome,Accept 却是默认的*/*,这种组合一眼假。更稳的做法是固定一套头部,全局复用。Cookie 的处理分两种:不需要登录的站点,别带 Cookie,带了反而暴露;需要登录态的,用 Session 自动维持,登录一次后续请求自动带上,不要手动拼 Cookie 字符串,容易过期还难维护。

# 需要登录态的场景:先登录,后续请求自动携带 Cookie login_payload = {"username": "your_account", "password": "your_password"} session.post("https://example.com/login", data=login_payload, timeout=10) # 之后所有 session.get 都会自动带上登录后的 Cookie data_page = fetch("https://example.com/data?page=1")

这里有个容易忽略的点:登录接口返回 200 不代表登录成功,有些站点失败也返回 200,只是页面里带错误提示。稳妥做法是登录后立刻请求一个只有登录态才能访问的页面,验证状态码和关键字段,确认成功再进入采集循环。这个校验步骤能省掉后面大量“为什么抓回来是登录页”的排查时间。

2.3 并发采集的边界:线程池不是越大越好

单线程采集慢,但一上来就开几百个线程,结果往往是被封得更快。并发要配合目标站点的承受能力和你的采集节奏来定。我一般先用线程池做中等并发,把并发数控制在 5 到 20 之间,配合随机间隔。下面是一个带节流的并发骨架。

import time import random from concurrent.futures import ThreadPoolExecutor, as_completed def worker(url): time.sleep(random.uniform(0.3, 1.2)) # 随机间隔,模拟自然访问节奏 try: return url, fetch(url) except Exception as e: return url, f"ERROR: {e}" urls = [f"https://example.com/list?page={i}" for i in range(1, 51)] with ThreadPoolExecutor(max_workers=8) as pool: futures = {pool.submit(worker, u): u for u in urls} for fut in as_completed(futures): url, result = fut.result() print(url, len(result) if not result.startswith("ERROR") else result)

参数上,max_workers=8是经验起点,不是标准答案;random.uniform(0.3, 1.2)的区间要根据目标响应速度调,响应慢的站点间隔要拉长。注意as_completed是按完成顺序取结果,不是提交顺序,如果你需要保序,得自己用字典映射回来。并发这块最大的坑是“以为并发高就快”,实际上被限流之后重试成本更高,整体反而更慢。

3. 解析层怎么选:HTML 结构提取的三种路线与字段稳定性设计

3.1 正则、BeautifulSoup、lxml 的适用边界

解析工具没有绝对优劣,只有场景匹配。正则适合提取结构极其规整、边界明确的片段,比如从一段文本里抠出特定格式的编号;一旦 HTML 嵌套复杂,正则维护成本会指数上升。BeautifulSoup 容错性强,能解析残缺 HTML,写起来直观,适合结构不规整的页面;缺点是慢,大页面批量解析时性能吃紧。lxml 基于 C 实现,速度快,支持 XPath,适合结构清晰、量大、要长期跑的采集任务。我的习惯是:调试阶段用 BeautifulSoup 快速定位,稳定后如果性能不够再换 lxml。

from bs4 import BeautifulSoup import lxml.html html = fetch("https://example.com/list") # 路线一:BeautifulSoup,容错好,适合结构不规整 soup = BeautifulSoup(html, "html.parser") items = soup.select("div.item-card") for it in items: title = it.select_one("h3.title") price = it.select_one("span.price") print(title.get_text(strip=True) if title else None, price.get_text(strip=True) if price else None) # 路线二:lxml + XPath,速度快,适合大批量 tree = lxml.html.fromstring(html) for node in tree.xpath("//div[@class='item-card']"): title = node.xpath(".//h3[@class='title']/text()") print(title[0].strip() if title else None)

逻辑说明:BeautifulSoup 的select用 CSS 选择器,写起来接近前端思维;lxml 的 XPath 表达力更强,能按属性、层级、文本内容组合定位。参数上,html.parser是纯 Python 解析器,不依赖额外库但慢;如果装了 lxml,BeautifulSoup(html, "lxml")会快很多。选择器尽量用稳定的 class 或结构特征,别依赖会变的动态属性。

3.2 字段提取的容错写法:别让一个空值搞崩整条流水线

采集最怕的不是抓不到,而是抓到的字段时有时无,下游处理直接报错。稳健的写法是每个字段提取都做空值兜底,并且记录缺失情况,方便后续判断是页面改版还是个别数据本身为空。

def parse_item(node): def text_of(selector): el = node.select_one(selector) return el.get_text(strip=True) if el else None return { "title": text_of("h3.title"), "price": text_of("span.price"), "link": node.select_one("a")["href"] if node.select_one("a") else None, "missing": [k for k, v in { "title": text_of("h3.title"), "price": text_of("span.price"), }.items() if v is None] }

这段的关键是missing字段,它把“哪些字段没抓到”显式记录下来。跑一段时间后统计缺失率,如果某个字段缺失率突然从 1% 涨到 80%,基本可以判定页面结构变了,而不是数据本身为空。这个设计比事后翻日志高效得多。

3.3 动态渲染页面的两条路:接口直取与浏览器渲染

现在不少页面内容是 JS 渲染出来的,直接请求 HTML 拿不到数据。两条路:一是找页面背后的数据接口,直接请求接口拿 JSON,这是首选,快且稳;二是用浏览器自动化渲染,成本高但通用。找接口的方法是打开开发者工具的网络面板,筛选 XHR/Fetch 请求,看哪个返回了你想要的数据。

# 路线一:直接请求数据接口,返回 JSON api_url = "https://example.com/api/list" params = {"page": 1, "size": 20} resp = session.get(api_url, params=params, headers=HEADERS, timeout=10) data = resp.json() for row in data.get("data", []): print(row.get("title"), row.get("price"))

接口直取要注意参数和签名。有些接口带时间戳或签名参数,得从页面源码里找生成逻辑;有些接口对 Referer 有校验,请求头里要带上来源页。如果接口实在拿不到,再考虑浏览器渲染,但那属于另一个成本量级,能不走就不走。

4. 采集规范与反爬应对:把“能跑”变成“能长期跑”

4.1 robots 与频率控制:先守规矩再谈效率

采集之前先看目标站点的 robots 协议,确认哪些路径允许抓、哪些禁止。这不是走形式,而是决定你的采集方案能不能长期存在的前提。频率控制上,核心原则是“像人一样访问”:请求间隔随机化、避开高峰时段、单 IP 请求量控制在合理范围。下面是一个带令牌桶思路的限速器,比单纯 sleep 更平滑。

import time class RateLimiter: def __init__(self, rate_per_sec=2): self.interval = 1.0 / rate_per_sec self.last = 0.0 def wait(self): now = time.monotonic() delta = now - self.last if delta < self.interval: time.sleep(self.interval - delta) self.last = time.monotonic() limiter = RateLimiter(rate_per_sec=2) # 每秒最多 2 次请求 for url in urls: limiter.wait() fetch(url)

参数说明:rate_per_sec是每秒允许的请求数,设多少取决于目标站点的承受力和你的合规判断,宁可保守。这个限速器是单线程内的,多线程场景要加锁或者用分布式限速。注意time.monotonic()不受系统时间调整影响,比time.time()更适合做间隔计算。

4.2 被封的常见信号与应对顺序

被封不是一瞬间的事,通常有前兆:响应变慢、返回 403/429、返回验证页、返回空数据。发现这些信号后,应对顺序应该是:先降频,再换请求头,最后才考虑换出口。很多人一被封就急着换 IP,结果新 IP 上去还是同样的高频请求,很快又被封,白白浪费资源。正确的做法是先定位是频率问题还是特征问题。如果是频率,降下来就能恢复;如果是特征(比如 UA 被标记),换一套完整的头部组合。

def fetch_with_guard(url): resp = session.get(url, headers=HEADERS, timeout=(5, 15)) if resp.status_code == 429: time.sleep(30) # 被限流,主动退避 return None if resp.status_code == 403: print("疑似被拦截,检查请求头与频率") return None if "验证" in resp.text[:500]: print("命中验证页,暂停该任务") return None return resp.text

这段的价值在于把“异常”变成“可识别的状态”,而不是让异常直接抛出去中断整个任务。实际跑的时候,我会把这类信号计数,超过阈值就自动暂停任务并告警,避免在已经被封的情况下继续无效请求。

4.3 数据落库与去重:别让重复数据污染结果

采集回来的数据要落库,落库前必须去重。去重键的选择很关键:用 URL 去重最简单,但同一内容不同 URL 的情况很常见;用内容哈希去重更准,但计算成本高。我的习惯是组合键:业务 ID 优先,没有业务 ID 就用 URL 加关键字段的哈希。

import hashlib import sqlite3 conn = sqlite3.connect("data.db") conn.execute(""" CREATE TABLE IF NOT EXISTS items ( uid TEXT PRIMARY KEY, title TEXT, price TEXT, url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) def make_uid(item): raw = f"{item.get('title')}|{item.get('price')}|{item.get('link')}" return hashlib.md5(raw.encode("utf-8")).hexdigest() def save(item): uid = make_uid(item) conn.execute( "INSERT OR IGNORE INTO items (uid, title, price, url) VALUES (?, ?, ?, ?)", (uid, item.get("title"), item.get("price"), item.get("link")) ) conn.commit()

INSERT OR IGNORE配合主键,天然去重,不用先查再插,省一次查询。参数上,哈希用 md5 够用,不需要更强的算法,因为这里只是做去重标识,不涉及安全。注意created_at用数据库默认值,避免应用层时间不一致。

5. 避坑与排查:那些让采集任务半夜挂掉的细节

5.1 编码乱码:不是所有站点都老实标 UTF-8

现象:抓回来的中文全是乱码,或者部分页面正常部分乱码。原因:响应头里的 charset 和实际编码不一致,或者根本没标。解决:不要信resp.encoding的默认值,用resp.apparent_encoding探测,或者根据站点特征手动指定。如果探测也不准,就按字节流拿回来自己 decode,逐个编码试。

5.2 选择器失效:页面改版是常态不是意外

现象:昨天还能抓到的字段,今天全变 None。原因:目标页面调整了 class 名或 DOM 结构。解决:选择器尽量用语义稳定的属性,别用带随机后缀的 class;同时加字段缺失率监控,缺失率突增就告警。更稳的做法是准备主备两套选择器,主选择器失效时自动切备用。

5.3 内存涨爆:大页面和长任务的内存管理

现象:任务跑几个小时后内存占用越来越高,最后被系统杀掉。原因:Session 对象累积、响应内容没释放、列表无限增长。解决:分批处理,处理完一批就释放;大响应不要全量读进内存,用流式读取;长任务定期重启 worker 进程。这个坑很隐蔽,因为短时间测试根本看不出来。

5.4 时间戳与签名参数:接口直取时的隐藏门槛

现象:直接请求接口返回签名错误或参数无效。原因:接口带了时间戳、随机数或签名,这些参数由页面 JS 生成。解决:从页面源码里找生成逻辑,用 Python 复现;如果逻辑太复杂,退回到浏览器渲染路线。注意时间戳通常有有效期,请求要快,别生成完放很久再发。

5.5 并发下的数据错乱:共享状态没加锁

现象:多线程采集时,结果里出现串行、重复或丢失。原因:多个线程共享了同一个列表或字典,没有加锁。解决:每个线程处理自己的数据,最后统一汇总;或者用线程安全的数据结构。这个坑在测试数据量小时不容易发现,量一大就暴露。

6. 进阶技巧:把采集任务做成可观测、可恢复的流水线

到这一步,单次采集已经不是问题了,真正拉开差距的是“长期稳定运行”。我的做法是把采集拆成三个阶段:调度、抓取、入库,每个阶段独立,中间用队列连接。调度负责生成任务和限速,抓取负责请求和解析,入库负责去重和持久化。任何一段挂了,重启后能从断点继续,不用从头再来。

断点续传的实现靠任务状态表。每个 URL 记录状态:待处理、处理中、已完成、失败。启动时先加载待处理和失败的,跳过已完成的。失败任务记录重试次数,超过阈值就标记为死信,人工介入。

import sqlite3 conn = sqlite3.connect("tasks.db") conn.execute(""" CREATE TABLE IF NOT EXISTS tasks ( url TEXT PRIMARY KEY, status TEXT DEFAULT 'pending', retries INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) def next_batch(limit=50): cur = conn.execute( "SELECT url FROM tasks WHERE status IN ('pending','failed') AND retries < 3 LIMIT ?", (limit,) ) return [r[0] for r in cur.fetchall()] def mark(url, status): conn.execute( "UPDATE tasks SET status=?, retries=retries+1, updated_at=CURRENT_TIMESTAMP WHERE url=?", (status, url) ) conn.commit()

这套状态机的价值在于:任务跑到一半机器重启,重新拉起后自动接着跑,不会重复也不会漏。参数上,retries < 3是重试上限,超过就进死信,避免无限重试拖垮任务;limit=50是批大小,根据内存和并发调。

可观测性方面,至少记录四个指标:请求成功率、平均响应时间、字段缺失率、任务积压量。这四个指标能覆盖绝大多数异常场景。成功率跌了看反爬,响应时间涨了看网络或目标负载,缺失率涨了看页面改版,积压量涨了看并发或限速是否过严。把这些指标打到日志或简单的监控面板上,任务出问题你能第一时间知道,而不是等下游来问“数据怎么断了”。

最后说个我自己的习惯:任何采集任务上线前,先小流量跑满 24 小时,观察上面四个指标是否平稳,再逐步放大。急着上量是翻车的最常见原因,没有之一。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询