第 2 课

抓 JS 页面、找数据接口,写一个能暂停续爬的 Spider

用 scrapling install 准备浏览器,对比 curl 与 extract fetch 抓 JS 页面,用 StealthyFetcher 的 wait_selector 等延迟内容,用 StealthySession 的 record_requests 和 network.search 找到无限滚动背后的接口,再写一个遵守 robots.txt 的 Spider 抓完 10 页 100 条名言、导出 JSON/CSV,并用 crawldir 加 Ctrl+C 暂停续爬、用 on_scraped_item 边抓边存。

图文25 分钟Scrapling 官方文档 ↗

课程目录第 2 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方页面:浏览器抓取 StealthyFetcher、extract 命令、爬虫入门、爬虫进阶、迁移到 0.5。命令与输出在 Linux x86_64 云主机上实测(Scrapling 0.5,patchright 1.64.4),输出为节选。

上一课的 Fetcher 只下载 HTML,不执行 JavaScript。这一课换成浏览器抓取器 StealthyFetcher,抓靠 JS 渲染、延迟加载的页面;再打开网络记录,直接找到页面背后的数据接口;最后写一个 Spider 爬虫,抓完 10 页 100 条名言,导出 JSON 和 CSV,并试一下中途暂停、下次接着爬。

第 1 步:准备浏览器,确认 curl 拿不到 JS 页面

接着用上一课的虚拟环境。浏览器抓取器需要先下载浏览器:

source .venv/bin/activate
scrapling install

上一课说过,测试机这一步在安装系统库时因软件源报错退出,但浏览器已下载完成,后面的命令都正常。

练习站的 /js/ 页面,名言是页面加载后由 JavaScript 写进去的。先用 curl 数一下 HTML 里有几条:

curl -s https://quotes.toscrape.com/js/ | grep -c 'class="quote"'

对照结果:

0

第 2 步:用 extract fetch 抓 JS 页面

extract fetch 用 Scrapling 的隐身浏览器打开页面,等 JavaScript 执行完再保存。--network-idle 表示等网络空闲(500 毫秒内没有新请求)再取内容:

scrapling extract fetch "https://quotes.toscrape.com/js/" js.md \
  --network-idle
grep -c '^“' js.md

对照结果(数以弯引号 “ 开头的行,即名言条数;测试机约 3 秒):

10

js.md 里的名言格式和上一课一样,例如 “The world as we have created it ...”by Albert Einstein(作者前没有空格,是转换时的原样输出)。

0.5 版改了命令名:旧教程里的 extract stealthy-fetch 现在叫 extract fetch,旧的 DynamicFetcher 已经删除,统一用 StealthyFetcher。照着 0.4 的文章抄命令会报错,以官方迁移说明为准。

第 3 步:在 Python 里等元素出现再抓

/js-delayed/ 页面会故意延迟几秒才写入名言,只等页面加载完还不够,要等目标元素出现。新建 js_compare.py:

from scrapling.fetchers import Fetcher, StealthyFetcher

url = 'https://quotes.toscrape.com/js-delayed/'
plain = Fetcher.get(url)
print('Fetcher:', len(plain.css('.quote')), 'quotes')

page = StealthyFetcher.fetch(url, wait_selector='.quote',
                             wait_selector_state='visible')
print('StealthyFetcher:', len(page.css('.quote')), 'quotes')
print(page.css('.quote .author::text').getall()[:3])
python js_compare.py

对照结果(已去掉 INFO 日志行,测试机约 12 秒):

Fetcher: 0 quotes
StealthyFetcher: 10 quotes
['Albert Einstein', 'J.K. Rowling', 'Albert Einstein']

wait_selector 填 CSS 选择器,wait_selector_state='visible' 表示等到元素可见。StealthyFetcher.fetch() 返回的页面对象和 Fetcher.get() 一样,上一课的 css()、xpath()、find_similar() 都能直接用。

第 4 步:记录网络请求,找到页面背后的接口

很多页面的数据来自后台接口。与其解析 HTML,不如直接拿接口返回的 JSON。练习站的 /scroll 页面是无限滚动,每滚到底部就请求一次 /api/quotes?page=N。

0.5 版的做法是:打开一个 StealthySession 并设 record_requests=True,用 page_action 在页面上滚动,最后用 session.network.search() 按网址正则查找记录下来的请求。新建 scroll_api.py:

from patchright.sync_api import Page
from scrapling.fetchers import StealthySession

def scroll(page: Page):
    for _ in range(3):
        page.mouse.wheel(0, 5000)
        page.wait_for_timeout(1200)

with StealthySession(record_requests=True) as session:
    page = session.fetch('https://quotes.toscrape.com/scroll',
                         network_idle=True, page_action=scroll)
    print('页面上的名言:', len(page.css('.quote')))

calls = session.network.search(url_pattern=r'/api/quotes', limit=None)
for r in calls:
    data = r.json()
    print(r.status, r.url, 'page', data['page'],
          'quotes', len(data['quotes']), 'has_next', data['has_next'])
python scroll_api.py

对照结果(已去掉 INFO 日志行,网址前缀有删节):

页面上的名言: 40
200 .../api/quotes?page=1 page 1 quotes 10 has_next True
200 .../api/quotes?page=2 page 2 quotes 10 has_next True
200 .../api/quotes?page=3 page 3 quotes 10 has_next True
200 .../api/quotes?page=4 page 4 quotes 10 has_next True

滚动 3 次,页面一共请求了 4 页接口、显示 40 条。记下的响应在会话关闭后也能读,r.json() 直接得到接口数据。知道接口地址之后,后面就可以用上一课的 Fetcher.get() 直接请求 /api/quotes?page=N,不必再开浏览器。

几个说明:

  • page_action 接收一个函数,参数是浏览器页面对象(Playwright 接口),可以滚动、点击、填表。
  • search() 默认最多返回 100 条,limit=None 返回全部。
  • 旧版的 capture_xhr 参数在 0.5 版已删除,按上面的写法替代。

第 5 步:写一个爬虫,抓完全部分页

单页抓取之外,Scrapling 0.5 自带类似 Scrapy 的爬虫框架。新建 quotes_spider.py:

import json

from scrapling.spiders import Spider, Response


class QuotesSpider(Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/"]
    allowed_domains = {"quotes.toscrape.com"}
    robots_txt_obey = True
    concurrent_requests = 2
    download_delay = 1.0

    async def parse(self, response: Response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(""),
                "author": quote.css("small.author::text").get(""),
                "tags": quote.css("a.tag::text").getall(),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

    async def on_scraped_item(self, item: dict) -> dict | None:
        # 每抓到一条就追加写盘,暂停后再续爬也不会丢前面的数据
        with open("quotes.jsonl", "a", encoding="utf-8") as f:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
        return item


if __name__ == "__main__":
    result = QuotesSpider(crawldir="crawl_data/quotes").start()
    print("paused:", result.paused, "completed:", result.completed)
    print("items:", result.stats.items_scraped,
          "requests:", result.stats.requests_count,
          f"elapsed: {result.stats.elapsed_seconds:.1f}s")
    if result.completed:
        result.items.to_json("quotes.json", indent=True)
        result.items.to_csv("quotes.csv", fields=["author", "text"])

代码说明:

  • parse() 里 yield 一个字典就是一条数据,yield response.follow() 就是继续抓下一页。
  • allowed_domains 防止爬到别的网站;robots_txt_obey = True 先读 robots.txt 并遵守规则(默认关闭);concurrent_requests 和 download_delay 控制并发和间隔,别把对方网站压垮。
  • on_scraped_item() 是官方提供的钩子,每条数据入库前都会调用。这里用它把每条数据马上追加写进 quotes.jsonl,原因见第 6 步。
  • crawldir 开启断点续爬。

运行:

python quotes_spider.py

对照结果(节选):

INFO: Fetched (404) <GET https://quotes.toscrape.com/robots.txt>
...
    "robots_disallowed_count": 0,
INFO: Saved 100 items to quotes.json
INFO: Saved 100 items to quotes.csv
paused: False completed: True
items: 100 requests: 10 elapsed: 12.2s

练习站没有 robots.txt(404),所以不限制抓取。查看导出结果:

wc -l quotes.jsonl quotes.csv
head -3 quotes.csv

对照结果(第 2 行名言有删节):

  100 quotes.jsonl
  101 quotes.csv
  201 total
author,text
Albert Einstein,“The world as we have created it is a process ...”
J.K. Rowling,"“It is our choices, Harry, that show what we truly ..."

quotes.csv 多出的 1 行是表头。to_csv() 的 fields 参数决定导出哪些列、按什么顺序。

第 6 步:中途暂停,下次接着爬

开了 crawldir 之后,运行中按一次 Ctrl+C,爬虫会等正在进行的请求完成,把待抓队列和已抓网址存成检查点再退出。用同样的 crawldir 再运行一次,就从断点继续。连按两次 Ctrl+C 是强制退出,不等请求完成。

先删掉上一步的结果,重新跑,大约 4 秒后按一次 Ctrl+C:

rm -rf crawl_data quotes.json quotes.csv quotes.jsonl
python quotes_spider.py
# 约 4 秒后按一次 Ctrl+C
wc -l quotes.jsonl

对照结果(节选):

INFO: Checkpoint saved: 1 requests, 5 seen URLs
INFO: Spider paused, checkpoint saved
paused: True completed: False
items: 40 requests: 4 elapsed: 5.1s
40 quotes.jsonl

再运行一次同样的命令:

python quotes_spider.py
wc -l quotes.jsonl

对照结果(节选):

INFO: Checkpoint loaded: 1 requests, 5 seen URLs
INFO: Resuming from checkpoint, skipping start_requests()
DEBUG: Checkpoint file cleaned up
INFO: Saved 60 items to quotes.json
INFO: Saved 60 items to quotes.csv
paused: False completed: True
items: 60 requests: 6 elapsed: 7.5s
100 quotes.jsonl

注意 Saved 60 items:续爬后的 result.items 只包含这一次运行抓到的 60 条,前面 40 条不在里面。如果只靠最后的 to_json() 导出,暂停前的数据就丢了。这就是第 5 步用 on_scraped_item() 边抓边写 quotes.jsonl 的原因:两次运行加起来正好 100 行,测试机核对 100 条名言没有重复。

小结

  • JS 渲染或延迟加载的页面用 StealthyFetcher 或 scrapling extract fetch,配合 network_idle、wait_selector 等内容出现。
  • 0.5 版删除了 DynamicFetcher 和 capture_xhr;抓接口用 StealthySession(record_requests=True) 加 session.network.search()。
  • Spider 爬虫用 parse() 产出数据和下一页请求,allowed_domains、robots_txt_obey、download_delay 让抓取更规矩。
  • crawldir 加 Ctrl+C 可以暂停续爬,但续爬后 result.items 只有本次数据,要用 on_scraped_item() 之类的办法边抓边存。

下一课把 Scrapling 作为 MCP 服务器交给 AI 应用,让智能体自己调用这些抓取能力。