同一件商品在两家零售商的售价几乎从不相同。某个市场上标价的笔记本电脑,在品牌官网可能便宜 10%,而这个差距每天都在波动。逐个网站手动核查,超过几件商品就无法维持,这正是脚本应该负责的那种重复性查找工作。
本教程将教你如何用 Python 构建价格比较工具:从两个或更多公开电商页面抓取同一件商品,将数据规范化为统一格式,跨来源匹配 listing,存储结果,并找出最低价格。我们通过 Crawling API 抓取每个页面,让渲染和 IP 轮换由其处理,整个演练严格限定在公开目录数据范围内:商品名称、价格、货币、零售商和商品 URL。
你将构建的内容
一个小型 Python 项目,将几个商品 URL 转化为整洁的比较表格。每个抓取到的 listing 都成为一条具有固定字段集的记录,使得描述方式不同的两家零售商依然能够对齐。到最后你将拥有:
- Name。零售商列出的商品标题。
- Price。从页面杂乱文字中解析出的数值。
- Currency。货币代码,确保你比较的是同种货币。
- Retailer。listing 来自哪个来源。
- URL。确切的页面地址,供人工核实。
- 存储输出。所有记录写入 JSON 和 CSV,加上最低价格摘要。
为什么普通请求在零售页面上会失败
在简单网站上,requests 库可以走很远,但现代电商页面以两种方式进行反击。首先,许多页面在客户端渲染价格:初始 HTML 几乎是空壳,JavaScript 在加载后才填入目录内容。普通的 requests.get 只看到空壳,因此你想要的价格根本不在你下载的文字中。
其次,零售商会监控自动化流量。来自一个数据中心 IP 的几次请求,以可识别的模式发出,就会被速率限制、CAPTCHA 拦截或封锁。要比较多个商店的价格,你需要每个页面都被渲染,且每个请求都来自可信的轮换地址。这正是 Crawling API 在单次调用中提供的组合,这也是我们通过它路由每次抓取而不是直接访问网站的原因。如果 JavaScript 渲染在零售页面上对你来说还比较新,我们关于用 Python 抓取 JavaScript 页面的指南提供了背景知识。
前提条件
编写代码之前需要三样东西。
Python 3.8 或更高版本。使用 python --version 检查。任何近期的 3.x 版本都可以;如果需要,从官方 Python 网站下载当前版本。
Crawlbase 账号和 token。注册一个免费账号,从控制台复制你的 token。你会获得普通 token(用于静态 HTML)和 JavaScript token(用于客户端渲染页面);把两个都记下来。
基础 Python 知识。你应该能从终端运行脚本,用 pip 安装包,并读懂字典。熟悉 HTML 和 CSS 选择器有帮助,因为你需要将它们适配到目标页面。
搭建项目
创建文件夹并安装依赖。我们使用官方 crawlbase 客户端进行抓取,使用 beautifulsoup4 解析 HTML,使用 pandas 存储和展示比较结果。json 和 csv 模块是内置的,不需要安装。
mkdir price-comparison && cd price-comparison pip install crawlbase beautifulsoup4 pandas
如果你特别想深入了解解析部分,我们的如何在 Python 中使用 BeautifulSoup演练比下面的代码片段更详细。
第一步:抓取第一个来源
从一家零售商的单个页面开始。Crawling API 客户端接收你的 token,在轮换住宅 IP 后面抓取 URL,并返回完整的 HTML。对于 JavaScript 渲染的页面,传入 ajax_wait 和 page_wait,让 API 等待异步内容,并在捕获前稍作停留。对于这些页面使用你的 JavaScript token,对普通静态页面使用普通 token。
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def fetch_page(url): response = api.get(url, {"ajax_wait": "true", "page_wait": 3000}) if response["status_code"] == 200: return response["body"].decode("utf-8") raise RuntimeError(f"Crawl failed: {response['status_code']}") html = fetch_page("https://store-a.example.com/product/smartphone-xyz") print(html[:500])
运行此代码会打印渲染后 HTML 的前 500 个字符。如果你看到的是真实页面标记而不是空壳,说明抓取成功,API 为你处理了渲染和 IP 两个问题。status_code 检查防止软失败流入下游,就像好的 HTML 一样。
你刚刚运行的那个 fetch_page 调用为你完成了两项难题:它渲染了 JavaScript,使价格真实存在于 HTML 中;并通过轮换住宅 IP 发送请求,使零售商不会封锁你。一旦你从第二和第三家商店按计划抓取,你就不需要自建无头浏览器群和代理池了。对那些返回空白的页面使用它。
第二步:抓取第二个来源
比较需要至少两家零售商。抓取逻辑不变:同样的 fetch_page 适用于任何 URL,因为 API 处理每个网站抛出的任何问题。变化的是解析部分,因为每家零售商的标题和价格标记方式不同。为每个来源编写一个小型解析器,读取页面并返回规范化的记录。
import re from bs4 import BeautifulSoup def parse_price(text): # Pull a number out of strings like "$279.99" or "1,299.00 USD" match = re.search(r"[\d,]+\.?\d*", text.replace(",", "")) return float(match.group()) if match else None def parse_store_a(html, url): soup = BeautifulSoup(html, "html.parser") return { "name": soup.select_one("h1.product-title").get_text(strip=True), "price": parse_price(soup.select_one(".price").get_text()), "currency": "USD", "retailer": "Store A", "url": url, } def parse_store_b(html, url): soup = BeautifulSoup(html, "html.parser") return { "name": soup.select_one("#productName").get_text(strip=True), "price": parse_price(soup.select_one("span.amount").get_text()), "currency": "USD", "retailer": "Store B", "url": url, }
两个解析器都返回相同的五个键,这正是关键所在:name、price、currency、retailer 和 url。选择器(h1.product-title、.price、#productName、span.amount)是占位符。在浏览器开发者工具中打开每个目标,找到实际存放标题和价格的元素,替换进去。parse_price 辅助函数做了将 "$279.99" 转化为数字 279.99 的脏活,使价格可以比较。
零售商经常重新设计,上个月还有效的选择器可能会悄无声息地开始返回 None。将每个来源的选择器视为需要维护的内容。当价格返回为空时,重新检查实时页面并更新那一个解析器,而不是整个管道的其他部分。
第三步:跨来源规范化和匹配
现在将抓取和解析串联起来。将每个 URL 映射到理解它的解析器,构建规范化记录列表,然后将描述同一商品的记录分组,以便比较同类。最可靠的匹配方式是你为每个 URL 分配的显式商品键,而不是从略有不同的标题猜测。
# Each entry: (product key, retailer URL, parser) sources = [ ("Smartphone XYZ", "https://store-a.example.com/product/smartphone-xyz", parse_store_a), ("Smartphone XYZ", "https://store-b.example.com/p/smartphone-xyz", parse_store_b), ] def collect(sources): products = {} for key, url, parser in sources: try: record = parser(fetch_page(url), url) except Exception as err: print(f"Skipping {url}: {err}") continue products.setdefault(key, []).append(record) return products
结果是一个以商品为键的字典,每个键下有一组按零售商分类的记录列表。这种结构反映了原始教程的数据模型,即一个商品名下有多个商店和价格,但这里每条记录都是实时抓取并规范化的,而不是从静态文件读取的。将每次抓取封装在 try/except 中,意味着一个失效的 URL 记录并继续,而不是中途崩溃。
第四步:比较并存储结果
有了匹配的记录,找最低价格只需简短的循环,存储输出也只需几行。比较逻辑与经典版本保持一致:遍历每件商品的记录,保留最便宜的。
def find_lowest_price(records): lowest_price = float("inf") best = None for record in records: if record["price"] is not None and record["price"] < lowest_price: lowest_price = record["price"] best = record return best
然后组装完整脚本。它采集所有来源,打印每件商品的最优价格,并用 pandas 将平面记录列表写入 JSON 和 CSV,让你可以在电子表格中打开比较结果或传入其他工具。
import json import pandas as pd def main(): products = collect(sources) rows = [] for name, records in products.items(): rows.extend(records) best = find_lowest_price(records) if best: print(f"Product: {name}") print(f" - Best Price: {best['currency']} {best['price']} at {best['retailer']}") # Store the full comparison as JSON and CSV with open("comparison.json", "w") as f: json.dump(rows, f, indent=2) pd.DataFrame(rows).to_csv("comparison.csv", index=False) print(f"Saved {len(rows)} records.") if __name__ == "__main__": main()
使用 python compare.py 运行它。你会在终端看到每件商品的最优价格行,磁盘上出现两个文件:comparison.json 供程序读取,comparison.csv 供电子表格使用。如果你更想随时间追踪价格历史,可以用内置的 sqlite3 模块将 CSV 写入替换为 SQLite 插入;记录格式保持不变,上游内容无需任何修改。
输出结果示例
JSON 文件为每个抓取到的 listing 保存一个对象,全部共享五个规范化字段。对于两家出售同一款手机的零售商,它看起来如下:
[ { "name": "Smartphone XYZ", "price": 299.99, "currency": "USD", "retailer": "Store A", "url": "https://store-a.example.com/product/smartphone-xyz" }, { "name": "Smartphone XYZ", "price": 279.99, "currency": "USD", "retailer": "Store B", "url": "https://store-b.example.com/p/smartphone-xyz" } ]
而由 find_lowest_price 驱动的终端摘要显示为:
Product: Smartphone XYZ - Best Price: USD 279.99 at Store B Saved 2 records.
CSV 以平面形式保存相同的记录,一行标题后每个 listing 一行,可以直接在任何电子表格中打开进行排序和绘图。
扩展到更多商品和来源
这个设计通过向 sources 列表添加行来扩展,而不是重写逻辑。要跨四家零售商追踪十件商品,为每个新零售商添加一个解析器,并为每个(商品,零售商)组合添加一个元组。按计划运行脚本(每天一次或两次的 cron 任务对于价格追踪已经足够),将每次运行追加到带日期的文件或 SQLite 表中,以建立价格历史。
一些习惯可以保持大规模时的健康运行。控制请求节奏,不要在紧密循环中抓取几十个页面。单独处理每次抓取(collect 中的 try/except 已经做到了),这样一个失效的页面永远不会终止整次运行。在迭代选择器时将抓取的 HTML 缓存到磁盘,避免在每次代码修改时重新访问实时网站。如果页面后来从静态变为 JavaScript 渲染,Crawling API 会为你吸收这种变化,因为渲染只是同一调用上的一个选项。对于匹配已知布局的页面,Crawling API 可以自动解析商品字段并直接返回结构化 JSON,当受支持的模板适用时,你可以省去编写每个来源解析器的麻烦。
负责任地构建价格追踪器
价格比较工具只访问购物者会访问的那些公开目录页面,但这并不意味着什么都可以。在将循环指向某个网站之前,先阅读每家零售商的服务条款和 robots.txt,并保持请求量适中:每件商品每天几次已经足以追踪价格,比持续轰炸的侵入性要小得多。只处理公开商品数据(名称、价格、货币、URL),远离任何登录后的内容、任何个人信息,以及你会重新分发的受版权保护的媒体。
如果零售商提供官方商品 API 或联盟数据源,优先使用它。这些渠道专为此类用途而设计,通常返回比抓取更干净的结构化数据,并让你与网站保持良好关系。将抓取视为没有此类选项的来源的备选方案,你就会得到一个既可靠又体贴其所依赖网站的工具。有关更广泛的反封锁策略,我们的如何不被封锁地抓取网站指南提供了详细内容。
核心要点
- 先规范化,再比较。将每家零售商的页面映射到相同的五个字段(名称、价格、货币、零售商、URL),使不同的标记依然能够对齐。
- 每个来源一个解析器,一套共享管道。抓取在所有网站之间完全相同;只有每个来源的选择器会变化,其余代码保持稳定。
-
Crawling API 处理渲染和 IP。一次
fetch_page调用即可在轮换住宅 IP 后面返回渲染好的 HTML,省去自建无头浏览器群和代理池的麻烦。 - 以 JSON 和 CSV 格式扁平存储。扁平记录列表干净地序列化为两种格式,当你需要价格历史时也可以切换到 SQLite,上游内容无需改变。
- 通过数据而非代码来扩展。通过扩展来源列表并按计划运行来添加商品和零售商,而不是重写比较逻辑。
常见问题
什么是价格比较工具?
价格比较工具从几家零售商采集同一件商品的价格,并告诉你哪家最便宜。用代码来说,它抓取每家零售商的公开商品页面,将数据规范化为同一格式,匹配描述同一商品的 listing,并找出最低价格。本教程中的 Python 项目正是跨两个或更多来源完成这些工作。
为什么使用 Crawling API 而不是普通的 requests?
普通 requests 只下载初始 HTML,因此会遗漏用 JavaScript 客户端加载的价格,而且从多家商店重复抓取时,单一 IP 会被封锁。Crawling API 在一次调用中渲染页面并通过轮换住宅 IP 路由每个请求,使价格真实存在于你解析的 HTML 中,并在所有来源上保持不被封锁。
如何跨不同零售商匹配同一件商品?
最可靠的方式是你为每个 URL 分配的显式商品键,正如本教程中的 sources 列表所做的那样,而不是尝试自动匹配略有不同的商品标题。对于更大的目录,当零售商公开型号编号、GTIN 或 SKU 等共享标识符时,可以用它们进行匹配,这比模糊标题匹配更可靠。
应该将数据存储为 JSON 还是 CSV?
两种都用,因为它们满足不同需求。JSON 保留完整的嵌套结构,易于在另一个脚本中读取;CSV 是扁平的,可以直接在电子表格中打开进行排序和绘图。本教程中的脚本从相同的记录列表写出两种格式。对于随时间追踪价格,迁移到 SQLite,这样你就可以跨运行高效查询,而不用处理许多文件。
我可以自动随时间追踪价格吗?
可以。按计划运行脚本(例如每天一次或两次的 cron 任务),将每次运行的记录追加到带日期的文件或 SQLite 表中。适度、定期的抓取已经足以发现价格下降并建立历史记录,而不会轰炸任何零售商。保持轻松的节奏并遵守每个网站的条款。
这个工具需要像 Selenium 这样的无头浏览器吗?
通过 Crawling API 抓取时不需要。Selenium 或 Playwright 等无头浏览器可以渲染 JavaScript 页面,但每个实例都是消耗内存和 CPU 的完整浏览器,在规模扩展时你还需要自行管理代理。Crawling API 在服务端渲染页面并为你轮换 IP,因此一个带有 BeautifulSoup 的普通 Python 脚本就足够了。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
