网络爬虫与 CDP 实战(二):浏览器能访问,Python 却被拒绝?拆开登录态与 CSRF
配套实验网站:实验 05:Session 账户 · 实验 06:CSRF 购物车 · 实验 07:Token 账户
上一篇,我们已经找到了真正提供商品数据的接口,也知道如何处理分页和 JSON 请求体。可把 URL 放进 Python 后,接口返回 401;换成 POST,又可能遇到 403。与此同时,浏览器里的页面仍然一切正常。
面对这种现象,继续修改商品选择器解决不了问题。数据源可能已经找对了,差别在于:浏览器发出去的请求,带了 Python 没有携带的状态。
这一篇用三个连续实验拆开这些状态:Cookie 与 Session 让服务端认出用户,CSRF 保护检查某些请求的提交方式,Token 则提供另一种显式凭证链路。随后再延伸到会话复用、存储隔离和接口诊断,看看怎样把一次登录变成可维护的采集流程。
跟着文章动手:在线实验站
本篇的登录、购物车和 Token 实验,都可以在我搭建的网页数据实验场上直接操作。打开首页的 Phase 2,进入实验 05—07;先在浏览器里完成一次业务操作,再用本文代码复现请求。演示账号是demo / demo123,页面与接口已经在线,不需要自己部署登录服务。
网站使用固定的 24 件虚拟商品,页面负责提供业务操作,操作步骤和分析方法在本文中。下面的请求代码默认使用这个公开站点;如有自己的本地实例,可以设置CDP_BASE_URL切换地址。
1. 先区分三种“被拒绝”
不要把所有失败都归结成“反爬”。先回答:请求有没有到达服务器,服务器有没有给出 HTTP 响应,响应内容又属于哪一类?
| 现象 | 已经能知道什么 | 还需要查什么 |
|---|---|---|
| 网络连接或读取超时 | 这次没有取得完整响应 | 网络路径、服务状态、超时阶段 |
| HTTP 401 | 服务端要求身份信息,或未接受现有凭证 | Cookie、Token、凭证是否过期 |
| HTTP 403 | 服务端拒绝执行这次请求 | 响应说明、权限、CSRF 等具体规则 |
| HTTP 200,但内容是登录页 | 请求返回了页面,业务结果仍未取得 | 重定向、响应类型、页面内容 |
401 和 403 的通用语义提供排查入口,却不替你解释某个站点的内部规则。特别是“200 加登录页”,raise_for_status()不会把它当 HTTP 错误;还要检查 Content-Type 与预期数据字段。[1]
打开 Network,找一条浏览器成功请求作为基准。比较方法、路径、参数、Cookie 和 Authorization,先找到差异,再做对照。Cookie 值没有必要在日志中完整打印;是否存在、哪个域和路径下生效、使用后是否被服务端认可,通常已经足够诊断。
2. 实验五:HTTP 不记得你,Session 替服务端保存状态
动手入口:打开Session 账户实验页。先在未登录状态查看账户,再用demo / demo123登录,最后登出并重新请求。把这三个结果与 Cookie 的变化一起记录下来。
设想接口/profile/在登录前返回 401,登录后返回用户名和访问次数。为什么 URL 一样,结果却变了?
HTTP 本身没有“上一条请求已登录”的记忆。服务端可以在登录成功后创建一条 Session(会话)记录,把用户 ID 等信息留在服务端,再返回一个sessionid。浏览器通过响应头Set-Cookie保存它;后续符合 Cookie 匹配规则的请求,会带上Cookie: sessionid=...。服务端凭这个值查回会话状态。[2]
POST 登录 ──> 校验账号 ──> 建立服务端 Session │ Set-Cookie: sessionid=... ↓ 浏览器保存 Cookie ──> GET profile,带 Cookie ──> 服务端查 Session这里有两个不同位置:Cookie 存在客户端,Session 数据存在哪里取决于服务端实现。示例采用服务端会话记录;别因此推断所有框架都使用同一种存储。有些系统会使用带签名的客户端会话 Cookie,后续的撤销行为也会不同。
用一个客户端走完整生命周期
下列 URL 是配套实验站的接口约定。演示账户为demo / demo123;公开站点的 Session、Token 与门户 ticket 均在签发一小时后失效,登出可以提前撤销。代码展示如何保持会话:
importosimporthttpx BASE=os.environ.get("CDP_BASE_URL","https://web-data-lab.pages.dev").rstrip("/")withhttpx.Client(base_url=BASE,timeout=10.0,trust_env=False)asclient:before=client.get("/labs/session/profile/")ifbefore.status_code!=401:raiseValueError("示例的未登录起点不符合预期")login=client.post("/labs/session/login/",json={"username":"demo","password":"demo123",})login.raise_for_status()print("客户端是否持有 sessionid:",bool(client.cookies.get("sessionid")))for_inrange(2):profile=client.get("/labs/session/profile/")profile.raise_for_status()print(profile.json())client.post("/labs/session/logout/").raise_for_status()after=client.get("/labs/session/profile/")ifafter.status_code!=401:raiseValueError("登出后受限接口仍可访问,需要核对会话规则")httpx.Client维护跨请求 Cookie,并复用连接。若每次都调用彼此独立的顶层请求,就不能指望上一次登录得到的 Cookie 自动进入下一次调用。[3]
这个实验至少要看到四段证据:登录前拒绝,登录响应下发凭证,后续请求带回凭证,登出后服务端不再接受旧会话。只看客户端“有一个 Cookie”并不能证明登录成功。Cookie 可能已经过期、只在其他路径生效,或者服务器已经撤销对应记录。
3. Cookie 的寿命与作用范围,常比值本身更重要
Cookie 是否发送,涉及域、路径、Secure、SameSite 等属性。HttpOnly限制页面 JavaScript 读取 Cookie,并不阻止浏览器把它带到符合条件的请求里。Secure与加密连接相关;Max-Age、Expires等控制保存时间。[2]
“关闭浏览器后会不会掉登录”也没有统一答案。会话 Cookie、持久 Cookie、浏览器的会话恢复功能,以及服务端 Session 的寿命都会影响结果。真正能确认的是:重新打开页面后的那次请求带了什么,服务端是否接受。
一个容易误判的本地开发问题:端口不同,不代表 Cookie 隔离
假设你在127.0.0.1:8100与127.0.0.1:8200上运行两个项目。Local Storage 按 origin(协议、主机、端口)区分;Cookie 的匹配却不以端口作为隔离维度。因此,同一主机下相同名称、域和路径的 Cookie 可能互相覆盖。仅换端口,不能保证两个项目的 Session Cookie 隔离。[4][9]
这是从教程延伸到实际调试时非常有用的区别。如果两个站点来回切换就掉登录,不妨检查 Cookie 名称与作用范围,而不是先改账号校验代码。使用不同 Cookie 名称、不同主机名或独立浏览器上下文,通常更容易把实验状态分开。
同时也别把 Cookie 罐理解成简单字典:同名 Cookie 可以处在不同域和路径下。跨站复用时,要保留匹配信息,而不是只拼一个裸字符串。
4. 实验六:Cookie 自动带回,为什么 POST 还要 CSRF 令牌
动手入口:打开CSRF 购物车实验页。选择商品和数量,分别尝试页面提供的两种提交操作,观察请求头、Cookie 和返回状态。下面的 Python 示例直接请求这个页面取得令牌,再提交购物车。
现在商品页允许“加入购物车”。你已经有 Session Cookie,但 POST 仍被拒绝。服务端可能在检查 CSRF。
CSRF 是 Cross-Site Request Forgery,跨站请求伪造。问题来自浏览器自动携带凭证的能力:某些跨站请求可能带着用户已有的 Cookie,被目标服务误认为是用户主动发起的操作。具体发送行为还受 SameSite 等规则影响,但它不能简单等同于“浏览器能跨站发请求就一定能读到响应”。[2][5]
CSRF 令牌提供额外检查。合法页面取得令牌,再在表单字段或请求头里主动提交。攻击页面通常不能像同源页面那样读取所需状态;服务端还可能检查 Origin 或 Referer。浏览器自动携带的 Cookie,与页面主动提供的令牌,是两个不同证据。[5]
把令牌的两段路接起来
公开实验站保留了 Django 风格的 CSRF 令牌流程:GET 页面后客户端得到csrftoken。POST 时把它放进X-CSRFToken,同时保持同一个客户端的 Cookie。HTTPS 请求还会检查来源,Python 示例需要发送同源 Referer:
importosimporthttpxwithhttpx.Client(base_url=os.environ.get("CDP_BASE_URL","https://web-data-lab.pages.dev"),timeout=10.0,trust_env=False)asclient:page=client.get("/labs/csrf/")page.raise_for_status()token=client.cookies.get("csrftoken")ifnottoken:raiseValueError("页面没有下发示例所需的 CSRF Cookie")payload={"product_id":1,"quantity":2}success=client.post("/labs/csrf/cart/add/",json=payload,headers={"X-CSRFToken":token,"Referer":str(client.base_url).rstrip("/")+"/labs/csrf/"})success.raise_for_status()missing=client.post("/labs/csrf/cart/add/",json=payload)print("带令牌:",success.status_code,"缺令牌:",missing.status_code)公开站点的 CSRF 校验由 Worker 实现,并支持 32 字符 Cookie secret 与 64 字符 masked 表单令牌。下面讨论 Django 的配置差异作为扩展背景;不要把所有框架都当成这份站点的实现。
这不是所有 Django 配置通用的取令牌方式。若服务端把 CSRF secret 放在 Session 中,或限制 JavaScript 读取 CSRF Cookie,就需要从页面中的令牌字段取得值。Django 官方文档分别解释了这些配置,并接受表单与 AJAX 头的合法提交路径。[6]
表单字段csrfmiddlewaretoken与 Cookie 中的csrftoken也不一定是逐字符相同的字符串。Django 的表单令牌可以使用随机 mask,服务端验证底层 secret;登录还会轮换 CSRF secret。因此,把旧页面上的令牌保存下来,登录后继续用,可能失败。[5]
对照要一次只改一个条件
先用 Cookie 与令牌齐全的请求建立基线,再只删除请求头,再只篡改令牌,最后换一个没有 Cookie 的客户端。这样才知道失败由哪个条件引起。如果同时改 URL、body 和三个请求头,返回 403 后并没有得到更多理解。
同样,CSRF 令牌不自动代表用户身份。一个未登录页面也可以得到 CSRF 令牌;它通过校验,并不表示具备查询私有库存的权限。身份、权限与请求来源校验,在服务端是不同判断。
5. 实验七:Token 在哪里保存,与谁负责发送,是两个问题
动手入口:打开Token 账户实验页。登录后获取商品,再登出并重复请求。在 Network 中核对 Authorization 请求头,观察它与上一节 Cookie 提交方式的差别。
另一个商品页登录后没有 Session Cookie。响应只返回一个 Token,页面把它放进localStorage,再主动设置Authorization: Bearer <token>。这条链路就与 Session Cookie 不同。
登录响应给 Token ──> 页面脚本存入 localStorage │ 发请求时读取 ↓ Authorization: Bearer <token>Local Storage 是浏览器按 origin 保存的键值存储,同源脚本可以访问它。它不会自动变成 Authorization 请求头。清掉 Local Storage,只是删除这个浏览器的保存副本;如果另一段代码仍持有有效 Token,服务端可能继续接受它。[4]
importosimporthttpxwithhttpx.Client(base_url=os.environ.get("CDP_BASE_URL","https://web-data-lab.pages.dev"),timeout=10.0,trust_env=False)asclient:login=client.post("/labs/token/login/",json={"username":"demo","password":"demo123",})login.raise_for_status()token=login.json()["token"]headers={"Authorization":f"Bearer{token}"}products=client.get("/labs/token/products/",headers=headers)products.raise_for_status()print(products.json()["count"])client.post("/labs/token/logout/",headers=headers).raise_for_status()revoked=client.get("/labs/token/products/",headers=headers)print("撤销后:",revoked.status_code)案例中的 Token 是服务端记录的随机令牌,登出删除记录后失效;公开演示站还设置一小时有效期。别把这个结果推广到所有 Token。例如自包含的令牌可以由服务端验签、检查过期时间,不一定每次查询令牌记录;要做到即时撤销,通常还需要额外机制。令牌存储位置,不能替你推断它的服务端状态模型。
另一个常见混淆是:显式 Token 并不会让所有安全问题消失。存储可被页面脚本读取,脚本注入风险仍需考虑;若一个系统同时接受 Cookie 身份,也要分析它实际使用哪条认证路径。对采集程序而言,最有用的结论是准确记录凭证来源、发送位置、有效期与撤销方式。
6. CORS 为什么常常被误当作身份规则
假设页面 Console 报跨域错误,而 Python 能调用同一 API。这并不说明 Python 获得了某种额外业务权限。CORS 是浏览器对跨源资源访问的机制;服务端的身份认证和权限检查仍然可以独立存在。[7]
反过来,Python 返回 401 也不应先去增加 CORS 请求头。先核对 Cookie 或 Token。Origin、Referer是否属于服务端的必要校验,要用成功请求与实际规则确认。手工照抄浏览器的所有头,容易暂时掩盖缺失的凭证流程。
可以把请求拆成三层来记:HTTP 结构是否正确;身份凭证是否被认可;业务或请求校验是否通过。这样面对不同错误,才不会把工具层、浏览器策略与服务端规则揉在一起。
7. 延伸案例:登录复用怎样变成一条可维护的流水线
设想团队要定期同步一个有登录权限的供应商目录。与其每天人工复制 Cookie,不如让流程明确区分四个状态:未登录、已认证、凭证失效、重新认证中。这是教学架构案例,实际登录步骤由系统提供的合法认证流程决定。
认证模块负责取得状态,采集模块负责业务请求,结果模块负责数量与字段验证。业务请求若得到 401,先确认是不是凭证失效;允许重新认证时只做有限次数恢复。如果错误是业务权限不足,不应该无休止登录重试。
若登录过程需要浏览器,可以考虑用 Playwright 的浏览器上下文保存和复用认证状态。其storage_state支持保存相关浏览器状态,但不会自动囊括所有内存变量或 sessionStorage;后者需要单独处理。保存状态只是复用某一时刻的凭证,不保证服务端永远接受它。[8]
复用时还要注意 Cookie 的域路径、Token 的请求头位置,以及多账号是否被意外放在同一个客户端里。客户端只负责带回状态;哪个账号可以访问哪些数据,最终还是服务端决定。认证状态文件本身包含凭证,应按账号配置而不是普通教程附件管理。[8]
一张足够实用的诊断表
| 浏览器成功、脚本失败时的证据 | 优先怀疑什么 | 下一次对照怎么做 |
|---|---|---|
| 脚本的 Cookie 罐为空 | 登录状态未传递 | 用同一个 Client 完成登录与查询 |
| Cookie 存在,但未出现在业务请求 | 域、路径、Secure 等匹配条件 | 检查实际发出的请求,而不是只看存储 |
POST 缺X-CSRFToken,浏览器有 | 缺少主动令牌提交 | 补齐取令牌流程,保留 Cookie |
| 登录后旧 CSRF 值失效 | secret 已轮换 | 登录之后重新读取有效令牌 |
| 浏览器带 Authorization,脚本没有 | Token 只被保存,未被发送 | 按接口要求显式构造请求头 |
| 响应变成 HTML 登录页 | 状态失效或重定向 | 核对最终 URL 与 Content-Type |
这三个实验最后要留下的不是三个凭证字符串,而是完整状态链:谁签发、在哪里存、谁发送、谁验证、什么时候失效。
下一篇继续推进一个新问题:身份已经正确,Network 响应却没有页面里的目标字段。我们会正式写 CDP 代码,进入浏览器事件、JavaScript 内存、动态 DOM 与请求拦截。
References
[1] HTTP response status codes. MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: HTTP 状态码语义。
[2] Using HTTP cookies. MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: Cookie 往返、属性与作用范围。
[3] Clients. HTTPX. Published/updated: not listed. Accessed: 2026-10-01. Used for: 跨请求 Cookie 与客户端状态。
[4] Window: localStorage property. MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: origin 与浏览器存储。
[5] Cross Site Request Forgery protection. Django documentation. Published/updated: not listed. Accessed: 2026-10-01. Used for: CSRF 校验、mask 与登录轮换。
[6] How to use Django’s CSRF protection. Django documentation. Published/updated: not listed. Accessed: 2026-10-01. Used for: 获取令牌与 AJAX 提交方式。
[7] Cross-Origin Resource Sharing (CORS). MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: 浏览器跨源访问机制。
[8] Authentication. Playwright Python. Published/updated: not listed. Accessed: 2026-10-01. Used for: 认证状态复用与存储边界。
[9] RFC 6265: HTTP State Management Mechanism. IETF / RFC Editor. Published: 2011-04. Accessed: 2026-10-01. Used for: Cookie 不按端口隔离的边界。