每天15分钟玩转Scrapy - 登录与Cookie

开场

前面三篇把数据怎么取、怎么存讲完了。但抓的都是那种打开就能看的页面。

真实一点的站点很快会给你上第一道门槛,登录。

想看的内容挂在账号后面,不登录只能看到一个空壳。你直接在地址栏里输那个会员页的地址,服务端给你的是一张登录表单,而不是数据。

这道门槛的解法不止一种。登录页要交表单,浏览器弹框那种要交请求头凭据,还有些站点干脆让你自己带着凭据去敲。

它们的底层是同一件事。你发出去的请求,得跟浏览器发出去的那个长得足够像。

说实话,这一块的坑比前面几篇都多,而且大部分坑不报错。

这一篇拆三块。Cookie 会话到底怎么运作、模拟登录的三步怎么走、HTTP 认证的两个入口怎么选。

演示站还是那个专门给爬虫练手的 quotes.toscrape.com,它自带一个登录页,正好够用。

我当初学这块的时候,最烦的就是文档里写了、照着跑却不对。所以这一篇的每一段输出都是真跑出来的,不是从文档里抄的。

HTTP 本身是无状态的。你发两个请求,服务端不知道这俩是同一个人发的。

补丁就是 Cookie。服务端在响应头里塞一个 Set-Cookie,浏览器记下来,下次请求自动带上去。服务端一看这个 Cookie,就知道「哦,还是刚才那位」。

Scrapy 把这件事做成了 CookiesMiddleware,默认开着。它做的事跟浏览器一样,收 Set-Cookie、存起来、下一个请求带上。你在绝大多数情况下不需要手动管。

需要手动管的是分桶

默认所有请求共用一个 jar,也就是一个会话。这没问题,直到你要同时用两个身份。比如一边用登录好的账号抓会员页,一边用匿名状态看公开页。共用一个 jar 的话,两个身份会互相污染。

meta={"cookiejar": "名字"} 就是干这个的。名字相同的用一个桶,名字不同的各用各的。

我实测了一遍,用一个蜘蛛同时跑两条线。

Cookie 分桶:一个蜘蛛两个身份

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import scrapy
from scrapy import FormRequest

class QuotesJarSpider(scrapy.Spider):
    """两个身份同时在线:桶 A 登录,桶 B 匿名,互不串味儿。"""

    name = "quotesjar"
    allowed_domains = ["quotes.toscrape.com"]

    async def start(self):
        # 桶 A 的一条线:先 GET 登录页,再把令牌提交回去
        yield scrapy.Request(
            "https://quotes.toscrape.com/login",
            meta={"cookiejar": "jar_a"},
            callback=self.submit_login,
        )
        # 桶 B 的一条线:同一个站点,但换一个桶,不带登录态
        yield scrapy.Request(
            "https://quotes.toscrape.com/page/2/",
            meta={"cookiejar": "jar_b"},
            callback=self.check_jar_b,
        )

    def submit_login(self, response):
        token = response.css('input[name="csrf_token"]::attr(value)').get()
        yield FormRequest(
            url="https://quotes.toscrape.com/login",
            formdata={"csrf_token": token, "username": "feiwuxiong", "password": "x"},
            meta={"cookiejar": "jar_a"},
            callback=self.check_jar_a,
        )

    def check_jar_a(self, response):
        self.logger.info("桶 A 登录态 = %s", "Logout" in response.text)

    def check_jar_b(self, response):
        self.logger.info("桶 B 登录态 = %s", "Logout" in response.text)

跑出来的结果很干净。

1
2
2026-09-12 23:41:11 [quotesjar] INFO: 桶 B 登录态 = False
2026-09-12 23:41:13 [quotesjar] INFO: 桶 A 登录态 = True

你看,同一个站点,同一个时刻。一个桶登录成功了,另一个桶还是游客身份。这就是分桶的意义。

有个顺序问题必须记住。桶要从这条线的第一个请求就挂上。

我第一版写的时候偷了个懒,GET 登录页那个请求没写 meta,想着反正令牌是页面里的字符串,跟会话没关系。结果提交的时候带着令牌、却挂在新的桶里,服务端一看这个令牌不是你这个会话发的,登录直接失败。查了半天才反应过来。

有个细节值得单独说一句。Cookie 的值类型在 2.18 起放宽了,Request.cookies 不再要求值必须是字符串。

1
2
3
req = scrapy.Request("https://example.com/", cookies={
    "text": "abc", "flag": True, "score": 1.5, "count": 3,
})

实测这三种非字符串值都能正常发出去。

1
2
3
Request 上存的 cookies -> {'text': 'abc', 'flag': True, 'score': 1.5, 'count': 3}
值类型 -> {'text': 'str', 'flag': 'bool', 'score': 'float', 'count': 'int'}
实际发出的 Cookie 头 -> text=abc; flag=True; score=1.5; count=3

最后说这事儿最反直觉的一点。想读响应里的 Cookie,response.cookies 这个属性在 2.19 上是没有的。

1
AttributeError: 'HtmlResponse' object has no attribute 'cookies'

老教程里常见这个写法,照抄过来就是一行报错。要拿服务端发下来的 Cookie,走 response.headers.getlist("Set-Cookie") 自己解析。

模拟登录:三步走

登录的本质是把服务端给你的一次性凭据,换成一个能重复用的会话。整个过程拆开就三步。

  1. 打开登录页,把隐藏的令牌抠出来
  2. 带着令牌和账号密码,提交表单
  3. 拿到会话,后面的请求自动带着它

1. 先看登录页长什么样

动手之前先摸清表单结构。这一步千万别省,抄错一个字段名能查一下午。演示站的登录页在 https://quotes.toscrape.com/login,表单部分是这样的。

登录表单的三个字段与隐藏令牌

1
2
3
4
<form action="/login" method="post" accept-charset="utf-8" >
    <input type="hidden" name="csrf_token" value="bkfswnpSqLiNVDgZhlaWmtCuIxQOAFReHKvGcjXdTrMYyJUzEoBP"/>
    <label for="username">Username</label>
    <input type="text" class="form-control" id="username" name="username" />

三个字段。csrf_token 是隐藏的,值是服务端每次生成的随机串。另外两个是账号密码。

这个隐藏令牌是登录爬虫最关键的一环。它存在的意义是防止别人拿一个脚本对着登录接口猛刷。你把用户名密码直接 POST 过去,服务端只会当你没交令牌,直接拒绝。

2. 用 FormRequest 提交

取出令牌,跟账号密码一起交回去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import scrapy
from scrapy import FormRequest

class QuotesLoginSpider(scrapy.Spider):
    """登录后带会话翻页,每页都确认登录态还在。"""

    name = "quoteslogin"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com/login"]

    def parse(self, response):
        # 第一步:从登录页里把隐藏令牌抠出来
        token = response.css('input[name="csrf_token"]::attr(value)').get()
        self.logger.info("第一步 拿到 csrf_token=%s", token)

        # 第二步:连着令牌一起提交,回调就是登录后的落点
        yield FormRequest(
            url="https://quotes.toscrape.com/login",
            formdata={
                "csrf_token": token,
                "username": "feiwuxiong",
                "password": "whatever",
            },
            callback=self.after_login,
        )

    def after_login(self, response):
        # 第三步:用文本特征判断,不要看状态码
        if "Logout" not in response.text:
            self.logger.error("登录失败,还停在登录页:%s", response.url)
            return
        self.logger.info("第三步 登录成功,落点 %s", response.url)
        yield from response.follow_all(css="li.next a", callback=self.parse_page)

    def parse_page(self, response):
        """翻页页面上再确认一次登录态,证明 Cookie 一直跟着。"""
        self.logger.info("翻到 %s,仍然登录中=%s", response.url, "Logout" in response.text)
        for q in response.css("div.quote"):
            yield {
                "text": q.css("span.text::text").get(),
                "author": q.css("small.author::text").get(),
            }

FormRequestformdata 会给每个值做 URL 编码,然后自动带上 Content-Type: application/x-www-form-urlencoded。你不用自己拼字符串。

顺手说一个版本变化。FormRequest.from_response() 这个方法在 2.17 起已经弃用了。它会去猜表单的 action、method 和所有隐藏字段,页面稍微复杂一点就猜错,而且错得很难查。用显式的 FormRequestresponse.css 自己取字段,多写两行,换来的是可控。

3. 登录成功的判断标准

跑起来看结果。

1
2
3
4
2026-09-12 23:35:59 [quoteslogin] INFO: 第一步 拿到 csrf_token=mibxVYyCslWUJSGQBEdjZLMwATkXIHeagnptqhozfrcPNROuDvFK
2026-09-12 23:36:01 [quoteslogin] INFO: 第三步 登录成功,落点 https://quotes.toscrape.com/
2026-09-12 23:36:01 [quoteslogin] INFO: 首页拿到 10 条名言
2026-09-12 23:36:12 [quoteslogin] INFO: 翻到 https://quotes.toscrape.com/page/2/,仍然登录中=True

最后那行是本篇的重点。翻到第二页,会话还在。说明 Cookie 已经被自动带上了,后面的请求不用你操心。

登录成功后会话被自动带上

统计里有几个数字值得看。

1
2
3
4
5
6
7
{'downloader/request_count': 5,
 'downloader/request_method_count/POST': 1,
 'downloader/response_status_count/200': 3,
 'downloader/response_status_count/302': 1,
 'elapsed_time_seconds': 34.78,
 'item_scraped_count': 12,
 'request_depth_max': 2}

5 个请求。登录页一个 GET,登录一个 POST,第三页的名言数据分布在这之后的请求里。那个 302 是登录成功后服务端给的跳转,我这次实测里它返回的是重定向而不是直接给首页。

这就是为什么判断登录成功不能看状态码。你可能收到 200 也可能收到 302,甚至可能收到 302 之后再跳回登录页。真正可靠的判断是找登录后才出现的文本特征,我用的就是右上角那个 Logout 链接。换一个站点,特征换成用户名、头像、或者「退出」两个字都行,只要它登录前不存在。

4. 顺手把请求导出成 curl

调试登录这类问题时,最想干的事是把请求原样搬到命令行里试。2.18 起 Request 自带这个能力。

1
2
3
4
5
6
7
req = FormRequest(
    url="https://quotes.toscrape.com/login",
    formdata={"csrf_token": token, "username": "feiwuxiong", "password": "x"},
    headers={"Referer": "https://quotes.toscrape.com/login"},
    cookies={"probe": "1"},
)
print(req.to_curl())

实测输出是一条可以直接粘进终端的命令。

1
curl -X POST https://quotes.toscrape.com/login --data-raw 'csrf_token=uJpsNjYSVORkDhMzdcCPZIqALmioaKxEgebUWFQrnlywXvBfTGtH&username=feiwuxiong&password=x' -H 'Referer: https://quotes.toscrape.com/login' -H 'Content-Type: application/x-www-form-urlencoded' --cookie 'probe=1'

方法、URL、表单数据、请求头、Cookie 全都还原了。抓虫的时候把它打出来跑一遍,能省掉很多猜。

HTTP 认证:两个入口

有一类站点不用登录页,而是浏览器弹一个框让你输账号密码。那是 HTTP Basic 认证,凭据直接放在请求头里。

2.17 起 Scrapy 给了两个入口。一个是 settings,全局生效。

1
2
3
HTTPAUTH_USER = "user"
HTTPAUTH_PASS = "passwd"
HTTPAUTH_DOMAIN = "example.com"

另一个是每个请求自己带,写在 meta 里。

1
yield scrapy.Request(url, meta={"http_user": "user", "http_pass": "passwd"})

我用 httpbingo.org 的 basic-auth 端点实测了 meta 这个入口,三个请求打同一个地址。

1
2
3
meta 正确凭据 -> 状态码 200 | { "authenticated": true,  "user": "user", "authorized": true }
meta 错误凭据 -> 状态码 401 | { "authenticated": false, "user": "user", "authorized": false }
不带任何凭据 -> 状态码 401 | { "authenticated": false, "user": "",     "authorized": false }

正确凭据 200,错误凭据和不带凭据都是 401。

有两个细节我实测撞到了,都值得说。

第一,HTTPAUTH_DOMAIN 是必填的。 只配 USER 和 PASS,爬虫直接起不来。

1
2
3
ValueError: HTTPAUTH_DOMAIN must be set when HTTPAUTH_USER or HTTPAUTH_PASS is configured. 
Set it to a domain (e.g. 'example.com') to restrict credentials to that domain, 
or set it to None to send credentials with all requests.

报错写得很明白,它是逼你想清楚凭据要发给谁。写域名就只发给那个域名,写 None 就是发给所有请求。这个设计是对的,毕竟把账号密码发给一个不认识的站点很危险。

第二,settings 配的凭据是全局兜底,会「救活」你没打算认证的请求。 我拿同一个蜘蛛换用 settings 入口跑了一遍。

1
2
3
meta 正确凭据 -> 状态码 200
meta 错误凭据 -> 状态码 401
不带任何凭据 -> 状态码 200

第三个变 200 了。因为全局凭据对每个请求都生效,包括那个我故意不带凭据的。meta 的优先级更高,能覆盖 settings,但反向不成立。

所以选哪个入口的标准很清楚。整站都要认证,用 settings 省事。只有一部分接口要认证,一律用 meta。

容易栽的跟头

坑 1:取令牌和提交令牌不在同一个桶里。 我第一版就是这么写的,meta={"cookiejar": "jar_a"} 只加在提交那一步。结果是服务端认为令牌不是这个会话发的,登录失败,而失败的表现为「还停在登录页」,看起来像账号密码错了。规则很简单,一条会话线的第一个请求就要挂上桶

坑 2:用 response.cookies 读响应 Cookie。 2.19 上这个属性不存在,直接 AttributeError。要读服务端下发的 Cookie,用 response.headers.getlist("Set-Cookie") 自己解析,或者干脆交给 CookiesMiddleware 自动管,别去碰。

坑 3:只配 HTTPAUTH_USERHTTPAUTH_PASSHTTPAUTH_DOMAIN 的话爬虫起不来,抛 ValueError。这个报错信息足够清楚,照着它补上就行。写域名表示只发给该域,写 None 表示发给所有请求。

坑 4:用 settings 配全局凭据,然后奇怪为什么没认证的接口也通了。 实测里那个不带任何凭据的请求返回了 200,就是被全局凭据救的。settings 是全局兜底,meta 是逐请求覆盖,优先级 meta 更高。只有一部分接口要认证的场景,一律用 meta。

坑 5:靠状态码判断登录是否成功。 登录接口返回什么状态码取决于站点的实现。我这次实测拿到的是 302,换个站点可能是 200,也可能是 302 之后再跳回登录页。判断依据要看登录后才出现的页面特征,比如 Logout 链接、用户名、退出按钮。

坑 6:用 FormRequest.from_response() 自动认表单。 这个方法 2.17 起弃用了。它会去猜表单的 action、method 和所有隐藏字段,页面稍微复杂一点就猜错,而且错得很难查。用显式的 FormRequestresponse.css 自己取字段,多写两行,换来的是可控。

坑 7:把账号密码直接写进代码。 这个坑不在框架里,在你自己的仓库里。演示站上写 password: "whatever" 无所谓,真实站点上这一行进版本库就等于泄露。凭据从环境变量或者配置中心读,日志里也别打出来。

小结

这一篇把登录这道门槛拆开了。

Cookie 那块,核心概念只有一个,请求要跟浏览器长得像。CookiesMiddleware 自动处理收发的细节,你需要手动管的只有分桶,而分桶的规矩是第一条请求就得挂上。

登录那块,三步的骨架是取令牌、提交、复用会话。FormRequest 负责第二步,to_curl() 负责在你搞不定的时候把请求搬到命令行里。

认证那块,记住一句就够,全局的管整站,逐请求的管局部,两个都配了的时候 meta 赢。

说真的,登录这块我踩过的坑比这一篇写出来的多。最难受的就是那种不报错的失败,写了一堆代码,跑起来安安静静,什么也没抓到。

你想想看,日志里一行错都没有,你怎么知道是哪个环节出的问题。只能靠一个个状态去比对。

遇到这种,先怀疑三件事。桶挂上了没有、令牌取对了没有、判断登录成功的特征选对了没有。这三个都确认过,基本上就通了。

这篇里要是有哪里讲得不对,欢迎拍砖。