Scrapling 平台爬虫模板详解:ShopifySpider 如何免写 HTML 解析抓取整店商品
本文围绕 Scrapling 的"平台型爬虫模板"(Platform Spider Templates)展开:它面向 Shopify 这类"统一数据结构、跨大量独立站点"的平台,只需用 ShopifySpider 指定一个店铺域名,即可通过平台的 JSON API 抓取全部商品与变体数据。读完本文,你将掌握平台模板与通用模板的本质区别、ShopifySpider 的分页抓取流程、全部 item 字段的来源与取值规则、target_website 的域名解析逻辑,以及如何通过覆写 _process_product() 定制输出结构,并能结合仓库源码验证每一个行为细节。
平台模板 vs 通用模板:按"平台"而非按"模式"复用
Scrapling 内置的爬虫模板分为两类,理解两者的定位是理解本文的前提:
- 通用模板(CrawlSpider、SitemapSpider、XMLFeedSpider、CSVFeedSpider)覆盖的是爬取的模式:跟踪匹配某模式的链接、遍历站点 sitemap、迭代 XML/CSV 数据源。它们需要你针对具体站点的 HTML 结构自己写解析逻辑。
- 平台模板覆盖的是平台:网站构建器(site builder)会在它托管的众多独立网站上暴露出相同的机器可读结构,所以爬虫天生知道"数据在哪里"——你只需把爬虫指向某个具体域名。
从源码结构看,这一划分直接体现在模板包的组织方式上:scrapling/spiders/templates/init.py 将通用模板(CrawlSpider、SitemapSpider、XMLFeedSpider、CSVFeedSpider)与平台模板(ShopifySpider)统一导出,而 scrapling/spiders/init.py 再将其提升到 scrapling.spiders 顶层,因此可以直接 from scrapling.spiders import ShopifySpider。
当前仓库中唯一的平台模板就是 ShopifySpider,它是本文的绝对主角。
ShopifySpider:最小可用示例
ShopifySpider 通过 Shopify 店铺的 JSON API 提取任意 Shopify 驱动商店的全部商品,全程不接触网站的 HTML。最小用法如下:
from scrapling.spiders import ShopifySpider
class MyStore(ShopifySpider):
target_website = "example.com"
result = MyStore().start()
print(result.items[0])
将 target_website 设置为商店域名即可,爬虫会自行完成 collections 与 products 两层的分页遍历,result.items 中是逐变体展开的商品条目。Spider 基类的其余能力全部继承可用:并发配置、下载延迟、robots.txt 遵循、检查点(checkpoint)以及生命周期钩子——这套异步调度、并发控制与断点续爬的完整机制,可参见 Spider 架构文档。
目标域名解析:三级回退与自动归一化
target_website 并非唯一指定目标的方式。从 shopify.py 的 __init__ 可以看到,域名的确定遵循明确的三级回退顺序:
- 优先使用
target_website; - 为空时,回退到
start_urls的第一个条目; - 仍为空时,回退到
allowed_domains的第一个条目; - 三者全部为空则抛出
ValueError,错误信息明确要求设置其中任意一项。
无论来源是裸域名(example.com)还是完整 URL(https://example.com/collections/all),最终都会被归一化为纯域名(netloc):没有 :// 的字符串会先补上 https:// 前缀再交给 urlparse 取 netloc。
这套行为在 tests/spiders/test_shopify.py 的 TestDomainResolution 中有逐项验证:从 target_website 取域名、从完整 URL 归一化出域名、从 start_urls 回退(含 www. 子域保留)、从 allowed_domains 回退,以及三源皆空时抛出 ValueError,五个用例与源码实现一一对应。
抓取流程:collections.json → products.json → 变体级条目
ShopifySpider 的抓取逻辑由三个类属性 URL 模板驱动(shopify.py#L33-L37):
name = "shopify"
target_website = ""
collections_url = "https://{website}/collections.json?page={page}&limit=250"
products_url = "https://{website}/collections/{handle}/products.json?page={page}&limit=250"
product_url = "https://{website}/collections/{handle}/products/{product_handle}"
其中 limit=250 是 Shopify 平台的单页上限。完整流程分三步:
第一步:遍历 collections.json 分页
start_requests() 只产出第一个请求:https://<store>/collections.json?page=1&limit=250,并携带 meta={"page": 1}。parse() 处理该响应时做两件事(shopify.py#L54-L70):
- 对当前页中每一个
products_count非零的 collection,派发一个指向/collections/<handle>/products.json?page=1的请求,meta中记录handle与page; - 只要当前页的
collections列表非空,就继续派发下一页(page + 1)的 collections.json 请求。空页即终止,不再产生后续请求。
products_count 在这里只被当作"非零就抓这个 collection"的信号,从不作为期望总数使用——原因见下文"注意事项与限制"。
第二步:遍历每个 collection 的 products.json 分页
parse_collection()(shopify.py#L96-L112)对每个 collection 的响应执行:
- 对
products列表中的每个产品,调用_process_product()逐变体产出条目; - 只要当前页
products非空,就派发该 collection 的下一页产品请求; - 空页表示该 collection 已抓完,记录一条 debug 日志("Extracted all products from collection ")并停止。
测试用例 test_parse_dispatches_collections_and_next_page、test_parse_stops_on_empty_page、test_parse_collection_stops_on_empty_page(test_shopify.py)精确覆盖了这套分页边界:2 个 collection(含一个空 collection 被跳过)时派发 2 个请求、空 collections 页返回 0 个请求、空 products 页返回 0 个请求。
第三步:按变体产出条目并跨 collection 去重
去重状态存放在实例属性 self.collected_ids(一个 set,在 __init__ 中初始化)。每个变体以 variant["id"] 作为键:已见过的变体 id 直接跳过。由于同一个产品变体可能出现在多个 collection 中,这一机制保证每个变体只产出一次条目。测试用例 test_variants_deduplicated_across_collections 验证了同一产品在 lipsticks 与 bestsellers 两个 collection 中只产出一次,test_dedup_state_is_per_instance 则确认去重状态是实例级的——不同 Spider 实例之间互不干扰。
条目字段详解:每个字段的来源与取值规则
ShopifySpider 默认每个变体产出一个 dict 条目,字段结构与底层数据来源如下(逐项对应 _process_product() 的实现):
| 字段 | 来源与规则 |
|---|---|
name |
产品标题;当变体标题不是 Default Title 时,追加 " - <变体标题>" |
price |
变体价格,字符串,保持 Shopify 返回的原样(如 "9.00") |
category |
所属 collection 的 handle,做 title-case 化(summer-sale 变成 Summer Sale) |
brand |
产品的 vendor 字段 |
identifier |
变体 id(variant["id"]) |
sku |
变体 SKU;店铺未设置时为 ""(None 也被归一为空串) |
stock |
变体有货为 None,缺货为 0 |
image_url |
产品第一张图的 src;无图为 "" |
url |
产品在所属 collection 内的商品页 URL(product_url 模板拼出) |
description |
产品 body_html 经 remove_tags(来自 w3lib.html)剥离 HTML 标签后的纯文本 |
old_price |
compare_at_price,但仅当它是真实预售价(非零)时才保留,否则为 "" |
barcode |
变体 barcode,无则为 ""(大多数店铺不在这两个端点暴露该字段) |
测试数据(test_shopify.py#L19-L59 的 PRODUCTS_PAGE)恰好覆盖了所有边界分支,TestItemProcessing 的断言可以直接当作"字段规则 → 期望输出"的验收清单,例如:
- 变体标题为
Red(非默认)→name为"Matte Lipstick - Red",标题为Default Title→name保持产品原标题; sku: None→ 归一为"";available: False→stock为0;compare_at_price: "0.00"→old_price为"","12.00"→ 原样保留;body_html: None→description为"",images: []→image_url为""。
自定义输出结构:覆写 _process_product()
上述字段不是强制契约。如果你想改变条目结构、或从产品数据中提取其他字段,只需在子类中覆写 _process_product()——它的签名是 _process_product(self, product: Dict, collection_handle: str) -> Generator[Dict, None, None],接收单个产品 dict 与所属 collection 的 handle,以生成器形式逐条 yield 结果。覆写后,parse_collection() 的分页、去重之外的调用链保持不变(注意:跨 collection 的变体去重发生在 _process_product() 内部,如果你完全重写该方法,需要自行决定是否保留 self.collected_ids 去重逻辑)。
注意事项与限制
使用 ShopifySpider 前必须了解以下边界(来自 platform-templates.md 与源码行为):
- JSON 端点的可见性限制:
/collections.json与/products.json只暴露已发布到 online-store 渠道的商品,因此一个 collection 报告的products_count可能大于实际能抓到的数量。源码中(shopify.py#L58)if collection["products_count"]:的判断印证了这一点——该计数只被用作"是否抓取"的开关,而不是断点校验或完整性预期。 - 受保护店铺大概率不可用:位于额外防护或密码保护(Shopify 店铺密码页)后面的商店,该模板很可能无法工作;即使勉强可用,也需要你进行大量覆写。
- Spider 基类能力照常生效:
robots_txt_obey、concurrent_requests、下载延迟、crawldir检查点(暂停/恢复)与生命周期钩子等 Spider 系统 提供的一切机制对平台模板同样适用。
什么样的平台有资格成为平台模板
仓库文档给出了明确的准入标准:平台模板只接受"在大量独立网站上暴露统一、机器可读结构"的平台,Shopify 的 collections/products JSON 端点即典型例子。反过来,针对某一个特定网站的爬虫不属于库的收录范围——无论该网站有多受欢迎。从源码结构看,这一约束也反映在实现方式上:ShopifySpider 的全部逻辑都建立在平台级 URL 模板(collections_url/products_url/product_url 类属性)之上,没有任何站点专属的 HTML 选择器或硬编码路径——这正是"平台模板"与"单站爬虫"的结构性差异。
小结
Scrapling 的平台模板把"数据在哪里"的知识封装进了库:ShopifySpider 用约 110 行源码(scrapling/spiders/templates/shopify.py)实现了三级域名回退、双层分页遍历、变体级去重和 12 个规范化字段,并留出 _process_product() 作为唯一的定制点。对任意一家 Shopify 店铺,你只需要定义一个两行的子类并调用 start();而 docs/spiders/generic-templates.md 中的通用模板则继续负责链接跟踪、sitemap 与数据源迭代这些"模式级"的复用。两者叠加,覆盖了从单页抓取到整站乃至整平台规模抓取的大部分场景。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00