每天15分钟玩转Scrapy - Spider 与选择器

开场

上一篇我们把爬虫跑起来了,但 parse 里那几行选择器是从官方教程抄的。

抄完心里发虚。为什么写 span.text::text 而不是 .text::attr@href 到底什么时候用哪个?

我当初刚上手那会儿,选择器基本靠试。改一个字符跑一次,看输出对不对,不对再改回来。一个下午就耗在这上面。

我特别理解那种对着输出反复试的烦躁,所以这一篇我打算换个讲法。

坦率的讲,这不算笨,是没人把这两件事讲清楚过。

这一篇就把它们说透。前半程讲 Spider 到底怎么被框架驱动,后半程把选择器从抄变成写,最后拿一个真实的抓取任务,把两段式爬虫组装起来。

Spider 是怎么跑起来的

Spider 是你跟框架签的一份合同,里面只写三件事,从哪里开始、拿到页面交给谁、产出什么样的数据。

至于调度、下载、并发、去重,全是框架自己的活。

框架驱动一只 Spider 的顺序是固定的。它先调用 start() 拿到一批初始请求。每个请求下载完,框架就把响应交给注册在它上面的回调函数。回调跑完,产出的数据进管道,产出的新请求回到调度器排队。请求耗尽,爬虫结束。

有个细节我想单独拎出来说。回调函数可以继续产出请求,而这些请求又能注册新的回调。

就是这条链,让爬虫能一层层往下走,从列表页走到详情页,再从详情页走到作者主页。第一篇里那个 parse 递归调用自己,是最简单的用法。

四个要素构成一只 Spider。

  1. name:蜘蛛名,项目内唯一,scrapy crawl 后面跟的就是它
  2. allowed_domains:白名单,不在这个域内的链接会被 OffsiteMiddleware 静默丢掉
  3. 起始请求:用 start_urlsasync def start() 提供
  4. 回调函数:默认是 parse,可以在 Request 里用 callback 指定别的名字

回调函数可以返回四类东西,框架都接得住。字典或者 Item 算数据,Request 算新任务,None 表示什么都不产出,这四者组成的迭代对象同样可以。所以在一个函数里 yield 字典和 yield 请求混着写,完全正常。

还有两个成员容易被忽略。errback 用来接管失败的请求,它拿到的是 Failure 对象而不是响应,抓的目标不稳定的时候值得配上。self.logger 是框架给每只 Spider 配好的日志器,打出来的日志会带蜘蛛名,同时跑好几个爬虫的时候特别好用。

 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
import scrapy


class QuotesSpider(scrapy.Spider):
    """Spider 的四要素与回调链。"""

    name = "quotes"                              # 要素一:唯一的名字
    allowed_domains = ["quotes.toscrape.com"]    # 要素二:域白名单
    start_urls = ["https://quotes.toscrape.com/"]  # 要素三:起始请求

    def parse(self, response):                   # 要素四:默认回调
        self.logger.info("正在处理 %s", response.url)

        # 产出数据
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
            }

        # 产出新任务:作者主页另找函数处理,别挤在 parse 里
        for link in response.css("div.quote span a::attr(href)").getall():
            yield response.follow(link, callback=self.parse_author)

    def parse_author(self, response):
        yield {
            "name": response.css("h3.author-title::text").get(default="").strip(),
            "birthdate": response.css(".author-born-date::text").get(default="").strip(),
        }

这种把列表页和详情页拆成两个回调的做法,是所有爬虫的通用骨架。好处很直接,每个函数只关心一种页面结构,站点改版的时候也只改一处。

Spider 的生命周期与回调链

CSS 选择器

response.css() 返回的不是字符串,是一个 SelectorList,里面装着若干个 Selector

这个设计决定了它的两层用法,先选中节点,再从节点里取数据。

取数据有四个方法,分工明确。

  1. .get():取第一个匹配项,没有就返回 None
  2. .getall():取全部匹配项,返回列表,没有就是空列表
  3. .get(default=""):取不到时返回你指定的默认值,比 None 好处理
  4. .attrib:直接把选中节点的属性字典取出来

至于选中什么,由选择器写法决定,三种写法对应三种结果。

  1. title 选中整个标签,.get() 拿到 <title>Quotes to Scrape</title>
  2. title::text 只取标签里的文本,.get() 拿到 Quotes to Scrape
  3. a::attr(href) 只取指定属性,.get() 拿到 /page/2/

在 shell 里跑一遍这几条,下面每一条输出都是实测结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> response.css("title").get()
'<title>Quotes to Scrape</title>'

>>> response.css("title::text").get()
'Quotes to Scrape'

>>> response.css("div.quote span.text::text").get()[:40]
'“The world as we have created it is a pro'

>>> response.css("div.tags a.tag::text").getall()[:4]
['change', 'deep-thoughts', 'thinking', 'world']

>>> response.css("li.next a").attrib
{'href': '/page/2/'}

SelectorList 还能继续往下选,这就是嵌套选择器。它的意义在于先把搜索范围收窄到一条记录里,再在记录内部找字段。页面上就算有几十个同类元素,也互不干扰。

1
2
3
4
5
6
>>> first = response.css("div.quote")[0]
>>> first.css("span.text::text").get()[:30]
'“The world as we have created '

>>> first.css("small.author::text").get()
'Albert Einstein'

嵌套时有个相对性的问题。first.css(...) 表示在这个节点内部找,写法上没问题。换成 XPath 就要小心了,这一点下一节细说。想先看一眼实际效果,就在 shell 里直接试,比对着文档猜快得多。

选择器还有个福利,CSS3 的属性匹配语法可以直接用,比如 a[href*="image"] 表示 href 里包含 image。不过真到抓取的时候,更常见的做法是先复制浏览器开发者工具给出的选择器,再在 shell 里逐步简化。一次写出完美表达式这件事,我也没做到过。

XPath 与正则

CSS 能覆盖八成场景,剩下两成要靠 XPath。

XPath 的独门本事是按内容和按位置来选,这是 CSS 做不到的。

有三个需求 CSS 无解,选中所有包含某段文字的链接、按文本内容定位元素、从当前节点往上找父节点。对应到 XPath 就是下面这些写法。

需求XPath 写法
按文本内容找链接//a[contains(., "Next")]/@href
按属性值找元素//small[@class="author"]/text()
只取第一个匹配(//div[@class="quote"])[1]
取所有匹配//div[@class="quote"]
往上找父节点//span[@class="text"]/../..

实测跑一遍,输出如下。

1
2
3
4
5
6
7
8
>>> response.xpath('//small[@class="author"]/text()').getall()[:3]
['Albert Einstein', 'J.K. Rowling', 'Albert Einstein']

>>> response.xpath('//a[contains(., "Next")]/@href').get()
'/page/2/'

>>> response.xpath('//div[@class="tags"]/a/@href').getall()[:3]
['/tag/change/page/1/', '/tag/deep-thoughts/page/1/', '/tag/thinking/page/1/']

嵌套 XPath 有个差别必须记住。CSS 嵌套是天然的相对查找,XPath 不是。以 // 开头会跳回文档根节点重新搜,只有在前面加一个点变成 .// 才表示在当前节点内部找。

1
2
3
>>> quotes = response.xpath('//div[@class="quote"]')   # 10 条名言
>>> len(quotes.xpath('.//small[@class="author"]'))     # 在每条名言内部找作者
10

还有个近义写法的坑,//node[1](//node)[1] 结果完全不同。前者是每个父节点下的第一个 node,后者才是全文第一个 node。刚上手时写错这个,会拿到一堆莫名其妙的结果。

需要按位置取的时候,实测对比是这样。

1
2
3
4
>>> len(response.xpath('//div[@class="quote"]'))
10
>>> len(response.xpath('(//div[@class="quote"])[1]'))
1

最后是正则。选中的文本往往还带着引号、多余空格、编号,这时候用 .re().re_first() 在结果上做二次清洗,比再写一层选择器省事得多。

1
2
3
4
5
>>> response.css("span.text::text").re(r"([A-Z][a-z]+) is")
['It', 'One', 'Imperfection', 'It']

>>> response.css("span.text::text").re_first(r"world")
'world'

给你一个实用的判断标准。清洗逻辑能用一句正则说清,就放在 .re() 里。要是需要多步处理,比如去标签、去空格、类型转换,那属于 Item Loader 的活,后面的文章会讲。

CSS 与 XPath 的分工

把链接串成一张网

单个页面好办,难的是怎么把成百上千个页面串起来。

Scrapy 从简单到复杂给了三档工具。

1. follow 和 follow_all

最基础的是 response.follow()。它跟 scrapy.Request 有两处不一样。它直接吃相对路径,不用你先 urljoin。它也吃 Selector 对象,能直接接选择器的结果。

1
2
3
4
# 三种等价写法,任选一种
yield response.follow("/page/2/", callback=self.parse)
yield response.follow(response.css("li.next a"), callback=self.parse)
yield response.follow(response.css("li.next a::attr(href)").get(), callback=self.parse)

要一次性跟进一批链接,就用 response.follow_all()。它返回生成器,配合 yield from 一次吐完。

1
2
anchors = response.css("div.quote span a")
yield from response.follow_all(anchors, callback=self.parse_author)

2. CrawlSpider 和 Rule

页面结构复杂起来,手写 follow 就变得啰嗦。CrawlSpider 把这部分抽成了规则表,你声明什么样的链接该怎么处理,它自己去提取和跟进。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule


class AuthorCrawlSpider(CrawlSpider):
    """规则化爬取:翻页和作者页都交给 Rule。"""

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

    rules = (
        # 作者主页:交给 parse_author,不再往下跟
        Rule(LinkExtractor(allow=r"/author/"), callback="parse_author"),
        # 分页:没有 callback,follow 默认为 True,会一直翻到没有下一页
        Rule(LinkExtractor(allow=r"/page/")),
    )

    def parse_author(self, response):
        yield {
            "name": response.css("h3.author-title::text").get(default="").strip(),
            "birthdate": response.css(".author-born-date::text").get(default="").strip(),
        }

Rule 有个特别容易记混的默认值,follow 的默认值取决于有没有 callback。写了 callback 就默认不跟进,没写才默认跟进。这个设计其实挺合理,但它仍然是最容易配错的地方。我当初第一次配的时候就栽在这儿。

LinkExtractor 有几个常用参数值得过一遍。allowdeny 用正则筛链接地址,restrict_xpaths 把提取范围限定在页面某个区域内,process_value 能对链接做二次加工。2.17 还新增了 deny_tagsdeny_attrs,可以直接排除某类标签,或者排掉不带指定属性的链接,比写正则直观。

这次我把上面这个爬虫实测跑了一遍。结果是 50 条作者数据,发出 254 个请求,而去重过滤器拦下了 1062 个重复请求

这个数字挺说明问题的。规则化爬取会提取出大量重复链接,同一个作者在多个页面被反复引用。如果没有内置去重,请求量要翻好几倍。

3. 三种订阅类 Spider

除了逐页爬取,Scrapy 还内置了三种针对订阅源的 Spider,省掉手工解析的功夫。

  1. XMLFeedSpider:按节点遍历 XML 订阅源,用 itertag 指定节点名,parse_node() 处理每个节点
  2. CSVFeedSpider:一行一行地读,用 headers 声明列名,parse_row() 处理每一行
  3. SitemapSpider:直接读站点地图发现 URL,支持嵌套地图,也能从 robots.txt 里找地图地址

这三者的共同点是响应类型已经由框架处理好了,你只需要写拿到一条记录后提取什么。需要注意的是 2.18 起行为有变化,以前没实现 parse_node()parse_row() 会报一个配置类错误,现在直接抛 AttributeError。报错直白多了,但也更容易让人措手不及。

实战:把演示站抓个遍

前面都是零件,现在把它们组装成一个能出结果的爬虫。

任务是这个,把演示站 100 条名言对应的 50 位作者全部整理成档案,包括出生日期和出生地,最后统计出这批作家生活在什么年代、都来自哪里。

难点在于数据分布在两类页面上。作者名字和链接在名言列表页,出生日期和出生地在作者详情页。列表页只负责发现链接,真正的数据要从详情页取。

这正是两段式回调的用武之地。

 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
import scrapy


class AuthorsSpider(scrapy.Spider):
    """两段式抓取:列表页取作者链接,作者页补生平档案。"""

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

    def parse(self, response):
        # 第一段:名言条目里的作者主页链接。
        # dict.fromkeys 保序去重,同一页重复出现的作者只发一次请求
        links = response.css("div.quote span a::attr(href)").getall()
        for link in dict.fromkeys(links):
            yield response.follow(link, callback=self.parse_author)

        # 第二段:翻页,把 10 页都走一遍
        yield from response.follow_all(css="li.next a", callback=self.parse)

    def parse_author(self, response):
        """作者详情页:字段与页面结构一一对应,缺字段用 default 兜底。"""
        yield {
            "name": response.css("h3.author-title::text").get(default="").strip(),
            "birthdate": response.css(".author-born-date::text").get(default="").strip(),
            "birthplace": response.css(".author-born-location::text").get(default="").strip(),
            "bio": response.css(".author-description::text").get(default="").strip(),
        }

跑起来。

1
scrapy crawl authors -O authors.json

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

1
2
3
4
5
'item_scraped_count': 50,
'downloader/request_count': 111,
'dupefilter/filtered': 37,
'elapsed_time_seconds': 132.32,
'finish_reason': 'finished',

50 条作者档案,111 个请求,10 个列表页加 50 个作者页,再加首次的 robots.txt。37 个重复请求被自动拦下,这是 dict.fromkeys 之外的第二层保险,来自框架的指纹去重。耗时 132 秒是因为默认配置把延迟设成了 1 秒,属于对站点的礼貌。嫌慢可以调 DOWNLOAD_DELAY,但别调成 0。

有了档案就能做统计。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
"""读 authors.json,统计出生年代分布与出生地 Top 榜。"""
import json
from collections import Counter

with open("authors.json", encoding="utf-8") as f:
    data = json.load(f)

years = [int(item["birthdate"][-4:]) for item in data if item["birthdate"]]
decade = Counter(f"{y // 10 * 10}s" for y in years)
places = Counter(item["birthplace"].removeprefix("in ").rsplit(",", 1)[-1].strip()
                 for item in data if item["birthplace"])

print(f"作者档案: {len(data)} 位")
print(f"出生年份跨度: {min(years)} - {max(years)}")
print("\n出生年代分布:")
for d, n in sorted(decade.items()):
    print(f"  {d:<8} {'█' * n} {n}")
print("\n出生地 Top 5:")
for p, n in places.most_common(5):
    print(f"  {p:<16} {n} 位")

结果出来了,全是真实数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
作者档案: 50 位
出生年份跨度: 1775 - 1973

出生年代分布:
  1770s    █ 1
  1800s    ██ 2
  1810s    █ 1
  ...
  1920s    ███████ 7
  1940s    █████████ 9
  1960s    ███ 3
  1970s    █ 1

出生地 Top 5:
  The United States 25 位
  The United Kingdom 9 位
  Germany          3 位
  France           2 位
  Ireland          2 位

榜单读起来挺有意思。演示站的作家一半来自美国,英国排第二,两个英语国家的作家加起来占了近七成。年龄上则集中在 20 世纪。

回头看这个爬虫,它做的事其实很简单。从列表页发现 50 个链接,每个链接发一个请求,把 50 个响应拼起来。复杂的感觉都来自页面结构,而不是框架。

你花在抓什么上的精力,不会被怎么调度稀释掉。说到底,这就是用框架的收益。

两段式抓取与统计结果

容易栽的跟头

坑 1:拿 [0] 取结果,没匹配到就崩了。 SelectorList 是列表,response.css("h1")[0] 在页面改版后没有 h1 时会直接抛 IndexError,整个爬虫中断。.get() 取不到返回 None.get(default="") 还能给个兜底值,抓取代码应该尽量用后者。判断逻辑也别写 if x == None,直接 if x 或者 if x is None 更清楚。

坑 2:嵌套 XPath 用 // 开头。 quotes.xpath('//small[@class="author"]') 看起来像在每条名言里找作者,实际上 // 跳回了文档根,返回的是全页所有作者。正确写法是 .//small[@class="author"]。这个错误很隐蔽,因为页面结构简单时两种写法的结果碰巧一样。

坑 3:Rule 里的正则限制得太宽。 我这次写的 LinkExtractor(allow=r"/page/") 看着是想翻页,实际它同时匹配了 /tag/love/page/1/,把标签页也一起拖进了爬取范围,请求数从预期的 60 涨到 254。正则匹配的是整段 URL 的子串,写规则时最好加上更具体的锚点,比如 allow=r"/page/\d+/?$"。想确认自己写的规则提取了什么,在 scrapy shell 里手动构造一个 LinkExtractor 跑一遍最直接。

坑 4:callback 名字写错,静默丢数据。 callback=self.parse_authorr 拼错一个字母,Scrapy 不会报错,请求照样发、页面照样下载,只是谁也处理不了它。日志里会看到请求数正常,可 item_scraped_count 是 0。遇到这种组合,先回去检查回调名。

坑 5:response.follow 忘了 yield。 response.follow() 只是构造并返回一个 Request 对象,不 yield 出去,框架根本不知道有这个任务。写成 response.follow(link, callback=self.parse) 而不带 yield,爬虫会在第一页就安静结束。

坑 6:从 response.meta 复制内部键。 想把附加数据带到下一个请求,正确做法是自己起一个键名,比如 meta={'item': item}。如果直接把 response.meta 整个拷进新请求,就带上了框架自己用的内部键,深度、下载槽位这些。2.18 起新增的 MetaCopyDetectionMiddleware 会检测到并发出警告。这个警告不是空穴来风,带上错误的深度会让深度的限制和统计全部失真。

坑 7:XMLFeedSpiderCSVFeedSpider 的报错换了样子。 2.18 起,忘了实现 parse_node()parse_row() 不再报原来的配置类异常,而是直接抛 AttributeError。看到这个报错别先去怀疑 XML 格式,回头看一眼方法名是不是写全了。

小结

这一篇把爬虫的两个核心环节拆开了。

Spider 那半边,你要记住的是那条回调链。start() 给初始请求,回调产出数据和新请求,循环到请求耗尽。

选择器那半边,记住的是先选节点再取数据的两层结构,还有 CSS 打头阵、XPath 补盲区、正则做清洗的分工。

真正把这一篇钉牢的是那个实战。50 位作家的档案不是从单个页面里抠出来的,是 111 个请求连起来的结果。

下次再遇到数据分散在不同层级页面这种需求,两段式回调就是你的默认解法。

说真的,这些东西不用背。写的时候卡在哪儿,回来翻一眼就行。

你看,选择器这东西没什么玄学,试多了手感自然就来了。我自己也还在用这套东西,也还在踩新的坑。这篇里要是有哪里讲得不对,欢迎拍砖。