1. 从GET到POST:为什么你的爬虫需要升级
很多人写爬虫的第一课都是requests.get(),拿一个网页地址,发出去,等返回,解析HTML,收工。这套流程对付静态展示型页面确实够用,但只要你稍微往真实场景走一步,就会发现事情没那么简单——登录、搜索、翻页、提交表单,这些操作背后几乎全是POST请求在支撑。
我刚开始写爬虫那会儿,遇到一个很典型的场景:需要抓取某个站点的搜索结果。用GET方式把查询参数拼在URL里,请求过去,返回的永远是空列表。当时以为是参数拼错了,反复检查了十几遍,最后才发现人家搜索接口走的是POST,参数放在请求体里,而且还有一层CSRF令牌校验。这就是典型的“GET思维”碰壁。
这篇文章要聊的,就是爬虫从入门到进阶必须跨过的那道坎:POST请求的构造、Cookies的读取与维持、以及Session会话管理。这三个东西是绑在一起的——POST请求往往需要携带Cookies做身份识别,而Session则是自动帮你管理Cookies的利器。搞懂它们之间的关系,你才能写出真正能应对登录态、能维持会话、能处理复杂交互的爬虫。
适合谁看?如果你已经会用requests发GET请求、能解析HTML、大致了解HTTP协议的基本概念,但一遇到“登录后才能看的内容”就卡壳,那这篇内容就是为你准备的。我会从底层原理讲到实操代码,把每个参数为什么这么设、每个坑为什么会出现,都掰开揉碎说清楚。
2. POST请求的本质:数据到底放在哪里
2.1 GET与POST的核心差异
先把这个基础问题讲透。GET和POST最直观的区别是:GET把参数拼在URL后面,POST把参数放在请求体里。但这只是表象,真正需要理解的是它们的设计意图不同。
GET的语义是“获取资源”,它是幂等的——同样的URL请求多少次,结果应该是一样的。所以浏览器会缓存GET请求,URL长度也有限制(不同浏览器和服务器不一样,一般2KB到8KB)。POST的语义是“提交数据”,它不幂等——同样的请求发两次,可能创建两条记录。所以POST不会被缓存,数据放在请求体里,长度理论上没有限制。
这个差异直接决定了爬虫的写法。你用GET抓一个列表页,翻页就是改URL里的page参数;你用POST提交搜索,参数得放在data字典里传给requests。
import requests # GET方式:参数在URL里 resp_get = requests.get( "https://example.com/search", params={"keyword": "python", "page": 1} ) print(resp_get.url) # https://example.com/search?keyword=python&page=1 # POST方式:参数在请求体里 resp_post = requests.post( "https://example.com/search", data={"keyword": "python", "page": 1} ) print(resp_post.request.body) # keyword=python&page=1注意上面代码里resp_post.request.body这个用法——requests库允许你通过response.request反查实际发出的请求,这在调试POST请求时非常有用。我经常用它来确认参数到底有没有正确编码。
2.2 POST请求体的三种编码格式
这是很多人踩坑的地方。POST请求体不是只有一种格式,常见的有三种,服务器端解析方式完全不同,用错了就是“参数传了但服务器收不到”。
第一种:application/x-www-form-urlencoded
这是最传统的表单提交格式,也是requests.post(data={...})的默认行为。数据被编码成key1=value1&key2=value2的形式,和GET的URL参数长得一样,只是放在了请求体里。
resp = requests.post( "https://example.com/login", data={"username": "test", "password": "123456"} ) # 实际请求体:username=test&password=123456第二种:application/json
现在很多前后端分离的站点用JSON传参。这时候你不能用data=,得用json=,或者手动设置headers并序列化。
import json # 方式一:用json参数(推荐) resp = requests.post( "https://example.com/api/login", json={"username": "test", "password": "123456"} ) # 方式二:手动设置 resp = requests.post( "https://example.com/api/login", data=json.dumps({"username": "test", "password": "123456"}), headers={"Content-Type": "application/json"} )这两种方式效果一样,但json=参数会自动帮你设置Content-Type: application/json,少写一行代码,也少一个出错点。
第三种:multipart/form-data
文件上传用的格式。requests里用files=参数,它会自动处理边界符和编码。
resp = requests.post( "https://example.com/upload", files={"file": open("test.jpg", "rb")}, data={"description": "测试图片"} )怎么判断该用哪种?最可靠的方法是打开浏览器的开发者工具,看Network面板里实际请求的Content-Type头。不要猜,不要凭经验,直接看。我见过太多人对着一个JSON接口用data=传字典,然后纳闷为什么返回400。
提示:如果你用
data=传了一个字典,但服务器期望的是JSON,有些框架会尝试兼容解析,有些则直接拒绝。最稳妥的做法永远是先看请求头。
2.3 构造POST请求的完整参数清单
一个完整的POST请求,除了URL和请求体,还涉及这些参数:
| 参数 | 作用 | 常见取值 |
|---|---|---|
data | 表单格式请求体 | 字典或字符串 |
json | JSON格式请求体 | 字典或列表 |
headers | 请求头 | 字典,常设User-Agent、Referer、Content-Type |
cookies | 请求携带的Cookies | 字典或CookieJar对象 |
timeout | 超时时间 | 秒数,建议设5-15 |
allow_redirects | 是否自动跟随重定向 | True/False |
proxies | 代理设置 | 字典 |
headers里最常设的是User-Agent和Referer。User-Agent伪装浏览器身份,Referer告诉服务器你从哪个页面来的——很多站点会校验Referer来防爬。timeout是必须设的,不设的话遇到慢响应你的程序会一直挂着。
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/search", "Content-Type": "application/x-www-form-urlencoded" } resp = requests.post( "https://example.com/api/search", data={"keyword": "python"}, headers=headers, timeout=10 )3. Cookies:爬虫的身份通行证
3.1 Cookie的工作原理
HTTP是无状态协议——服务器不记得你上次来过。但很多功能需要“记住”用户,比如登录状态、购物车、浏览偏好。Cookie就是解决这个问题的机制。
流程是这样的:你第一次请求服务器,服务器在响应头里塞一个Set-Cookie,浏览器把它存下来。之后每次请求同一个域名,浏览器自动把这个Cookie放在请求头里发给服务器。服务器一看Cookie,就知道你是谁。
用requests模拟这个过程:
# 第一次请求,服务器下发Cookie resp1 = requests.get("https://example.com/login") print(resp1.cookies) # <RequestsCookieJar[Cookie(session_id=abc123)]> # 手动把Cookie带到第二次请求 resp2 = requests.get( "https://example.com/profile", cookies=resp1.cookies )但手动传递很麻烦,尤其是Cookie有多个、还会过期更新的时候。这时候就需要Session出场了。
3.2 从响应中提取和设置Cookies
有时候你需要从响应里把特定的Cookie值抠出来,用于后续请求的某个参数(比如CSRF令牌)。requests提供了几种方式:
resp = requests.get("https://example.com/page") # 方式一:通过cookies属性(返回CookieJar对象) session_id = resp.cookies.get("session_id") # 方式二:通过字典方式访问 session_id = resp.cookies["session_id"] # 方式三:获取所有Cookie的字典形式 cookie_dict = resp.cookies.get_dict() print(cookie_dict) # {'session_id': 'abc123', 'token': 'xyz789'}反过来,你也可以手动构造Cookie发给服务器:
cookies = { "session_id": "abc123", "token": "xyz789" } resp = requests.get("https://example.com/profile", cookies=cookies)这里有个细节:requests的cookies参数接受字典或CookieJar对象。如果你从浏览器开发者工具里复制Cookie字符串(形如session_id=abc123; token=xyz789),需要自己分割成字典。我通常写个小函数处理:
def parse_cookie_string(cookie_str): return dict( item.strip().split("=", 1) for item in cookie_str.split(";") if "=" in item ) cookies = parse_cookie_string("session_id=abc123; token=xyz789")3.3 Cookie的时效性与作用域
Cookie不是永久的,它有两个关键属性:Expires/Max-Age和Domain/Path。
Expires或Max-Age决定Cookie什么时候过期。会话Cookie(没有这两个属性)在浏览器关闭时失效,持久Cookie则存到指定时间。爬虫场景下,如果你手动设置Cookie,过期时间由服务器校验,你设不设无所谓;但如果你从响应里读Cookie,要注意它可能很快失效。
Domain决定Cookie发给哪个域名,Path决定发给哪个路径。比如Domain=.example.com的Cookie会发给www.example.com和api.example.com,但Domain=www.example.com的只发给www子域。跨子域请求时这个区别很关键。
注意:用
requests手动传cookies参数时,它不会自动帮你判断Domain和Path,会一股脑全发出去。如果你需要精确控制,用Session或者CookieJar。
4. Session:让Cookies管理自动化
4.1 Session的核心价值
前面说了,手动管理Cookie很烦。Session就是来解决这个问题的——它自动在多次请求之间保持Cookie,就像浏览器一样。
import requests s = requests.Session() # 第一次请求:登录,服务器下发Cookie s.post( "https://example.com/login", data={"username": "test", "password": "123456"} ) # 第二次请求:Session自动带上之前的Cookie resp = s.get("https://example.com/profile") print(resp.text) # 已登录状态的页面内容就这么简单。Session对象内部维护了一个CookieJar,每次请求后自动更新,下次请求自动携带。你不需要手动碰Cookie。
4.2 Session的底层机制
Session不只是帮你管Cookie,它还做了这些事:
- 连接池复用:底层用
urllib3的连接池,对同一主机的多次请求复用TCP连接,比每次新建连接快很多。 - 默认headers合并:你可以给Session设置默认headers,后续请求自动带上。
- 参数继承:Session级别的
auth、proxies、verify等参数会被子请求继承。
s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://example.com/" }) # 后续所有请求都自动带上这些headers s.get("https://example.com/page1") s.get("https://example.com/page2")这个特性在写爬虫时特别实用——你把公共的headers设一次,后面每个请求都省事。
4.3 Session与Cookies的配合实战
来看一个完整的登录+抓取流程。假设目标站点是这样的:
- 登录页有个隐藏的CSRF令牌,需要先GET拿到
- POST登录接口,带上用户名、密码、CSRF令牌
- 登录成功后跳转到个人中心,需要保持登录态
import requests from bs4 import BeautifulSoup s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }) # 第一步:GET登录页,提取CSRF令牌 login_page = s.get("https://example.com/login") soup = BeautifulSoup(login_page.text, "html.parser") csrf_token = soup.find("input", {"name": "csrf_token"})["value"] # 第二步:POST登录 login_resp = s.post( "https://example.com/login", data={ "username": "test", "password": "123456", "csrf_token": csrf_token }, allow_redirects=True ) # 第三步:访问需要登录的页面 profile = s.get("https://example.com/profile") print(profile.status_code) print("登录成功" if "个人中心" in profile.text else "登录失败")这个流程里,Session自动处理了登录页下发的Cookie(包括session id),后续请求自动携带,不需要手动干预。CSRF令牌则是从HTML里解析出来的,这是很多站点防CSRF的标准做法。
5. 实战:一个完整的登录态爬虫
5.1 目标分析与请求链路梳理
假设我们要抓取一个模拟站点的订单列表。这个站点需要登录才能看订单,登录接口是POST,订单列表是GET但需要登录态。
先梳理请求链路:
GET /login→ 拿到登录页HTML和初始CookiePOST /api/login→ 提交账号密码,服务器返回登录成功并设置新的CookieGET /api/orders?page=1→ 带登录Cookie请求订单列表- 翻页:
GET /api/orders?page=2,以此类推
关键点在于第2步和第3步之间Cookie的传递。用Session的话,第2步设置的Cookie会自动带到第3步。
5.2 代码实现与关键注释
import requests import time import json class OrderSpider: def __init__(self, base_url): self.base_url = base_url self.session = requests.Session() self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }) def login(self, username, password): """登录并保持会话""" # 先访问登录页,获取初始Cookie(有些站点需要) self.session.get(f"{self.base_url}/login", timeout=10) # 提交登录 resp = self.session.post( f"{self.base_url}/api/login", json={"username": username, "password": password}, timeout=10 ) result = resp.json() if result.get("code") != 0: raise Exception(f"登录失败: {result.get('msg')}") print(f"登录成功,当前Cookie: {self.session.cookies.get_dict()}") return True def get_orders(self, page=1, page_size=20): """获取订单列表""" resp = self.session.get( f"{self.base_url}/api/orders", params={"page": page, "page_size": page_size}, timeout=10 ) # 检查是否登录态失效 if resp.status_code == 401: raise Exception("登录态已失效,需要重新登录") return resp.json() def run(self, username, password, max_pages=5): """主流程""" self.login(username, password) all_orders = [] for page in range(1, max_pages + 1): data = self.get_orders(page=page) orders = data.get("data", {}).get("list", []) if not orders: break all_orders.extend(orders) print(f"第{page}页获取{len(orders)}条订单") time.sleep(1) # 礼貌性延迟 return all_orders if __name__ == "__main__": spider = OrderSpider("https://example.com") orders = spider.run("test_user", "test_password") print(f"共获取{len(orders)}条订单")这段代码里有几个值得说的点:
第一,登录接口用json=而不是data=。因为现代API大多用JSON。如果你不确定,看请求头。
第二,登录后打印了Cookie。这是调试习惯——确认服务器确实下发了Cookie,而且Session确实存下来了。
第三,get_orders里检查了401状态码。登录态可能因为超时或服务器重启而失效,显式检查比等到解析数据时报错要好。
第四,加了time.sleep(1)。这是基本的礼貌,也是降低被封风险的手段。别小看这一秒,很多站点就是靠请求频率来识别爬虫的。
5.3 登录态失效的检测与重试
登录态失效是爬虫绕不开的问题。Cookie有有效期,服务器也可能主动踢人。处理方式有两种:
方式一:每次请求前检查,失效则重新登录。
def request_with_retry(self, method, url, max_retries=2, **kwargs): for attempt in range(max_retries + 1): resp = self.session.request(method, url, **kwargs) if resp.status_code == 401 and attempt < max_retries: print("登录态失效,重新登录...") self.login(self.username, self.password) continue return resp return resp方式二:定时刷新。如果知道Cookie大概多久过期,可以设个定时器定期重新登录。
我一般用方式一,因为更可靠——不管什么原因失效,都能兜住。
6. 常见问题与排查技巧实录
6.1 POST请求返回400/403的排查思路
这是最高频的问题。按这个顺序排查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| Content-Type | 看请求头 | JSON接口用了form格式 |
| 参数名 | 对比浏览器请求 | 拼写错误、大小写不一致 |
| 参数位置 | 看是data还是json | 该放body的放URL了 |
| CSRF令牌 | 检查是否有隐藏字段 | 漏传或令牌过期 |
| Referer | 检查请求头 | 服务器校验来源 |
| User-Agent | 检查请求头 | 被识别为爬虫 |
我遇到最多的是Content-Type和CSRF令牌。前者用json=还是data=就能解决,后者需要先GET页面解析出令牌。
6.2 Cookie不生效的几种原因
有时候你明明传了Cookie,服务器还是说没登录。可能的原因:
- Domain不匹配:Cookie的Domain是
.example.com,你请求的是api.example.com,理论上应该匹配,但如果你手动构造Cookie字典,requests不会做Domain校验,会直接发。问题可能出在服务器端校验了Cookie的Domain属性。 - Cookie过期:服务器下发的Cookie有
Max-Age,过期了自然失效。 - Cookie被服务器更新:有些站点每次请求都刷新Session ID,你如果手动固定了旧Cookie,就会失效。用Session可以自动跟进更新。
- 路径不匹配:Cookie的Path是
/api,你请求/web,浏览器不会发这个Cookie。requests手动传Cookie时同样不校验Path,但服务器可能校验。
提示:遇到Cookie问题,先用Session跑一遍。Session的行为最接近浏览器,如果Session能通,说明是手动管理Cookie的方式有问题。
6.3 Session复用与并发注意事项
Session对象不是线程安全的。如果你在多线程里共用一个Session,可能会出现Cookie错乱、连接池竞争等问题。正确做法是每个线程用自己的Session:
import threading def worker(url): s = requests.Session() # 每个线程独立Session s.headers.update({"User-Agent": "..."}) resp = s.get(url) # ... threads = [threading.Thread(target=worker, args=(url,)) for url in urls] for t in threads: t.start() for t in threads: t.join()如果一定要共享,至少给Session加锁,但性能会下降。我的建议是:并发场景下,每个线程/协程独立Session,不要共享。
另外,Session用完后最好调用close()释放连接池资源:
s = requests.Session() try: # 使用Session pass finally: s.close()或者用with语句(requests.Session支持上下文管理器):
with requests.Session() as s: s.get("https://example.com")6.4 调试POST请求的实用技巧
最后分享几个我常用的调试手段:
技巧一:打印实际请求。
resp = requests.post(url, data=data) print(resp.request.url) print(resp.request.headers) print(resp.request.body)resp.request是实际发出的请求对象,能看到最终编码后的URL、headers和body。这比猜参数有没有传对靠谱得多。
技巧二:用requests_toolbelt抓包。
from requests_toolbelt.utils import dump resp = requests.post(url, data=data) print(dump.dump_all(resp).decode("utf-8"))这个库能把请求和响应的完整内容(包括headers和body)格式化输出,调试时非常方便。
技巧三:对比浏览器请求。
最笨但最有效的方法:打开开发者工具,把浏览器发出的请求复制为cURL,然后用工具转成Python代码,逐项对比你的代码和浏览器请求的差异。差异在哪,问题就在哪。
7. 关于Cookie持久化与跨请求共享的补充
前面讲的都是单次运行内的Session管理。但实际项目中,你可能需要把登录态保存下来,下次运行直接复用,避免重复登录。这就涉及Cookie的持久化。
requests的Session底层用http.cookiejar.CookieJar,它支持save()和load()方法,可以序列化到文件:
import requests import http.cookiejar # 保存Cookie到文件 s = requests.Session() s.post("https://example.com/login", data={...}) s.cookies.save("cookies.txt", ignore_discard=True, ignore_expires=True) # 从文件加载Cookie s2 = requests.Session() s2.cookies.load("cookies.txt", ignore_discard=True, ignore_expires=True) resp = s2.get("https://example.com/profile")ignore_discard=True表示连会话Cookie也保存,ignore_expires=True表示连过期Cookie也保存。这两个参数在调试时有用,但生产环境建议设False,避免用到已失效的Cookie。
不过要注意,CookieJar.save()保存的是Mozilla格式的文本文件,不是所有场景都适用。如果你需要更灵活的持久化(比如存到数据库、Redis),可以自己序列化cookies.get_dict():
import json # 保存 cookie_dict = s.cookies.get_dict() with open("cookies.json", "w") as f: json.dump(cookie_dict, f) # 加载 with open("cookies.json") as f: cookie_dict = json.load(f) s = requests.Session() s.cookies.update(cookie_dict)这种方式更通用,也方便你检查Cookie内容。但缺点是丢失了Domain、Path、Expires等元信息,如果站点对这些有校验,可能会出问题。
我个人在实际操作中的体会是:短期调试用JSON序列化,长期运行用CookieJar的save/load,或者干脆每次重新登录。重新登录虽然多一步,但最可靠,不会因为Cookie过期或格式问题浪费时间排查。
最后再分享一个小技巧:如果你在抓一个需要登录的站点,但不想每次都输账号密码,可以把登录逻辑封装成一个装饰器,在Session失效时自动重新登录。这样你的主流程代码可以完全不用关心登录态,写起来清爽很多。这个模式我在多个项目里用过,实测下来很稳。