网络爬取帮你获得数据,但存储和分析同样重要。一个松散 JSON 或 CSV 文件的文件夹,对于一次性拉取来说还好,但一旦你想要追踪价格随时间的变化、比较不同来源的记录,或者回答一个真实问题,这堆扁平文件就成了瓶颈。SQL 数据库为爬取数据提供了一个结构化的归宿,你可以在不每次都编写一次性解析代码的情况下查询、过滤和聚合。

本指南用 Python 和 SQL 从头到尾构建完整的路径。你通过 Crawling API 从一个中立示例页面爬取少量产品风格的记录,设计 SQL 架构,插入解析后的行,并运行分析查询来提取洞察。整个过程限定在公开的、说明性的数据和占位符 URL 上,所以你可以安全地跟着做,之后再换成你自己的目标。

你将构建什么

一个可运行的 Python 流程:通过 Crawling API 获取渲染后的列表页,用 BeautifulSoup 解析每个产品卡片,将记录写入 SQLite 表,然后查询该表以获得价格区间、平均值和类别计数。每条爬取的记录包含以下字段:

  • 名称 列表卡片上显示的产品标题。
  • 价格 标价,存储为数字,以便比较和聚合。
  • 类别 商品所属的分区或产品类型。
  • URL 指向产品自有页面的链接。
  • 爬取时间 数据库自动填写的时间戳,让你可以追踪每行数据的收集时间。

为什么将爬取数据存储在 SQL 中

数据落地的位置决定了它之后有多大用处。文件可以用,直到你需要提出一个跨行的问题:哪些商品价格低于某个阈值,每个类别的平均价格是多少,本周的拉取与上周相比如何。SQL 正是为此而生的,关系型数据库能给你几个文件夹做不到的事情。

  • 结构化存储。表和列强制执行一致的格式,确保每条记录都有相同类型的相同字段。
  • 高效查询。单个 SELECT 语句无需任何手动循环就能过滤、排序和聚合数千行。
  • 数据完整性。主键、类型和约束保持数据一致,并在入库时捕获格式错误的行。
  • 可扩展性。无论你存储数千还是数百万条记录,SQL 引擎都能处理增长,并在正确的索引下保持速度。
  • 可重复分析。查询是可重用的。一旦你写了一个有用的查询,就可以在明天的数据上再次运行,无需重建任何东西。

如果你在更广泛地权衡存储格式,JSON 与 CSV 比较涵盖了什么时候平面文件仍然是正确选择,数据建模指南对架构设计的深入程度超出了我们这里的篇幅。

为什么普通请求在现代列表页上会失败

在存储工作有意义之前,你需要有干净的记录可以存储,而这正是朴素的 HTTP 请求容易出问题的地方。许多现代列表页在浏览器中通过 JavaScript 渲染其内容:第一个 HTML 响应是一个薄薄的壳,产品卡片只有在页面脚本运行之后才会出现。从那个初始响应拉取数据,你捕获到的是列表的片段,或者什么都没有。

第二个问题是访问。发布商业上有价值的列表的网站会监控自动化流量,并封锁来自数据中心 IP 或任何看起来不像真实浏览器的请求。因此,可靠的爬虫需要在一次请求中做两件事:渲染页面的浏览器,以及网站认为是真实访客的 IP。你可以用无头浏览器和轮换住宅代理池自己构建,但保持那个技术栈健康运行是大部分工作所在。Crawling API 将两者合并为一次调用,并返回可以解析的完整 HTML。对于解析方面,Python 网络爬取指南结构化爬取数据指南是有用的参考。

前提条件

在你写任何代码之前,应该先准备好几件事。都不需要太长时间。

基本的 Python 和 SQL 知识。你应该能够运行 Python 脚本并读懂一条 SELECT 语句。你不需要成为数据库管理员;本指南使用 SQLite,它随 Python 附带,所以没有服务器需要安装。

Python 3.8 或更高版本。python --version 确认你的版本。如果没有,请从 python.org 安装,并确保 Python 在你的 PATH 中。

Crawlbase 账号和令牌。注册后,打开控制台并复制你的令牌。Crawlbase 提供 1,000 个免费请求供开始使用,足以完成本指南的演练。像对待密码一样对待令牌,不要将其提交到版本控制中。如果你的目标在客户端渲染内容,使用 JavaScript(JS)令牌,这样页面在你收到之前就会被渲染。

搭建项目

创建一个虚拟环境以隔离依赖,然后安装爬虫需要的两个库。

bash
python --version

python -m venv scraper_env
source scraper_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,使用 scraper_env\Scripts\activate 代替 source 行来激活环境。crawlbase 包是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以提取单独的字段。sqlite3json 都随 Python 标准库附带,所以存储步骤不需要再安装任何东西。

第一步:获取渲染后的页面

从获取一个完整的页面开始。导入 CrawlingAPI 类,用你的令牌初始化它,然后请求列表 URL。我们使用占位符 https://example.com/products;将其指向任何你有权收集的公开列表页。在解析之前检查 Crawlbase 的 pc_status 可以让失败显眼而非悄无声息。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

LISTING_URL = "https://example.com/products"

def crawl(page_url):
    response = api.get(page_url)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    html = crawl(LISTING_URL)
    print(html[:500] if html else "No HTML returned")

运行这段代码,你应该能看到打印出来的真实页面标记,这在你写任何选择器之前就确认了请求是有效的。如果你的目标在客户端渲染卡片,改用 JS 令牌,并将 {"ajax_wait": "true", "page_wait": 5000} 作为第二个参数传给 api.get,让动态内容有时间加载。

Crawlbase Crawling API

上面的 crawl 函数隐藏了困难的部分:从一个在浏览器中渲染并封锁数据中心流量的页面获取完整的 HTML。Crawling API 在真实浏览器中运行页面,在服务端轮换住宅 IP,并通过一次调用将渲染后的 HTML 交给你,省去了自己运行无头浏览器集群和代理池的麻烦。先在免费套餐上指向一个公开列表页测试。

第二步:解析要存储的记录

有了完整的 HTML,将其加载到 BeautifulSoup 中,从每个产品卡片提取一条记录。下面的选择器假设一个带有标题、价格、类别标签和链接的卡片布局,这是列表页的常见形式。每次查找都有保护,使缺失字段返回安全默认值,而不是让整个运行崩溃。存储中最重要的细节是价格:将其作为文本爬取,然后去除货币符号和逗号,使其作为可以比较和平均的数字进入数据库。

python
import re
from bs4 import BeautifulSoup

def parse_price(text):
    if not text:
        return None
    cleaned = re.sub(r"[^0-9.]", "", text)
    return float(cleaned) if cleaned else None

def parse_products(html):
    soup = BeautifulSoup(html, "html.parser")
    records = []
    for card in soup.select("div.product-card"):
        name = card.select_one("h2.product-title")
        price = card.select_one("span.price")
        category = card.select_one("span.category")
        link = card.select_one("a.product-link")
        records.append({
            "name": name.get_text(strip=True) if name else None,
            "price": parse_price(price.get_text() if price else None),
            "category": category.get_text(strip=True) if category else "Uncategorized",
            "url": link["href"] if link and link.get("href") else None,
        })
    return records

parse_price 去除所有不是数字或小数点的字符,所以 "$1,299.00" 变成浮点数 1299.0。这个单一步骤使后续的价格查询成为可能:文本按字母排序,无法求平均值,但数字两者都能正确处理。将选择器(product-cardproduct-titlepricecategoryproduct-link)调整为你真实目标的标记;其余流程保持不变。

第三步:设计 SQL 架构

现在设计保存这些记录的表。好的架构反映你的记录形状,为每列选择正确的类型,并添加主键和数据库自动填写的时间戳。注意以下选择:priceREAL(数字,而非文本),因此可以比较和聚合,scraped_at 默认为当前时间,使每一行都记录其收集时间。

sql
CREATE TABLE IF NOT EXISTS products (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    name      TEXT NOT NULL,
    price     REAL,
    category  TEXT,
    url       TEXT UNIQUE,
    scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Speed up the price filters and category rollups you will run later
CREATE INDEX IF NOT EXISTS idx_price ON products(price);
CREATE INDEX IF NOT EXISTS idx_category ON products(category);

有两个设计选择值得指出。url 上的 UNIQUE 约束阻止同一产品被插入两次,这在爬虫重新运行时很重要,你不希望昨天的行在今天被重复。索引随着表的增长保持价格和类别查询的速度。在 MySQL 或 PostgreSQL 上,架构读起来几乎相同:用 AUTO_INCREMENTSERIAL 代替 AUTOINCREMENT,对于金钱字段 DECIMAL(10,2) 是比 REAL 更严格的选择。数据建模指南涵盖了每种类型何时是正确选择。

第四步:插入已解析的记录

设计好表之后,从 Python 连接,如果需要就创建表,然后插入解析后的记录。始终使用参数化查询:? 占位符让驱动程序处理转义,这既防止了格式错误的文本破坏插入语句,也关闭了注入漏洞。executemany 在一次批处理调用中写入所有记录,当你有数百行时比单个插入的循环快得多。

python
import sqlite3

SCHEMA = """
CREATE TABLE IF NOT EXISTS products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    price REAL,
    category TEXT,
    url TEXT UNIQUE,
    scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""

def store_records(records, db_path="scraped_data.db"):
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    cursor.execute(SCHEMA)

    rows = [
        (r["name"], r["price"], r["category"], r["url"])
        for r in records if r["name"]
    ]
    cursor.executemany(
        "INSERT OR IGNORE INTO products (name, price, category, url) "
        "VALUES (?, ?, ?, ?)",
        rows,
    )

    conn.commit()
    inserted = cursor.rowcount
    conn.close()
    return inserted

INSERT OR IGNORE 与第三步中 url 上的 UNIQUE 约束配合:URL 已存在的行会被静默跳过,而不是抛出错误,所以重新运行爬虫会补充新列表,而不会重复旧列表。批处理后提交一次保持写入速度。对于 MySQL,相同的代码形式适用,用 mysql.connector 代替 sqlite3,用 %s 占位符代替 ?;下游的一切都是相同的。

为什么要使用参数化查询

永远不要通过将爬取的文本直接粘贴到 SQL 字符串中来构建 INSERT。爬取的值是不可信的输入,可能包含引号或字符,这些字符会破坏语句,或者更糟糕的是改变它。? 占位符将该文本作为数据而非代码交给驱动程序,这既更安全也更不容易出错。

第五步:用 SQL 查询分析数据

数据现在处于可查询的形式,这正是使用 SQL 的全部意义所在。以下是你会经常用到的三种分析:过滤和排序、聚合汇总,以及按类别分组获得洞察。每个都是普通的 SELECT,你可以从 SQLite shell、数据库 GUI 或 Python 中运行。

首先,过滤和排序。这会提取价格低于某个阈值的所有产品,最便宜的排在前面:

sql
SELECT name, price, category
FROM products
WHERE price < 500
ORDER BY price ASC;

然后,将整个表聚合为一行汇总。COUNTAVGMINMAX 等函数将多行折叠为你真正想要报告的数字:

sql
SELECT
    COUNT(*) AS total_products,
    ROUND(AVG(price), 2) AS average_price,
    MIN(price) AS cheapest,
    MAX(price) AS most_expensive
FROM products;

最后,按类别分组以查看目录的构成。GROUP BY 为每个类别运行一次聚合,HAVING 在事后过滤这些分组:

sql
SELECT
    category,
    COUNT(*) AS items,
    ROUND(AVG(price), 2) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 1
ORDER BY avg_price DESC;

要从 Python 而不是 shell 运行这些查询,打开相同的连接并读取行。这个片段运行按类别汇总并打印每个分组:

python
def category_summary(db_path="scraped_data.db"):
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    cursor.execute("""
        SELECT category, COUNT(*), ROUND(AVG(price), 2)
        FROM products
        GROUP BY category
        ORDER BY 3 DESC
    """)
    for category, count, avg_price in cursor.fetchall():
        print(f"{category}: {count} items, avg {avg_price}")
    conn.close()

如果你的分析超出了普通 SQL 的舒适范围,将表拉入 DataFrame 然后继续;pandas 分析指南正好从这个点接着往下讲。

将其整合为一个流程

这四个函数链接为一个可运行的脚本。爬取、解析、存储,然后分析:

python
def main():
    html = crawl(LISTING_URL)
    if not html:
        return
    records = parse_products(html)
    inserted = store_records(records)
    print(f"Parsed {len(records)} records, inserted {inserted} new rows")
    category_summary()

if __name__ == "__main__":
    main()

每次运行获取页面,由于 INSERT OR IGNORE 只写入新行,并打印按类别汇总。在 cron 任务上调度它,表就成为一个你可以随时间查询的增长历史,这正是 SQL 相比一堆带日期的 CSV 文件所能体现价值的地方。

输出的样子

一条存储的记录带有结构化字段加上数据库分配的 id 和时间戳。从中查询几行返回的内容类似这样:

json
[
  {
    "id": 1,
    "name": "Wireless Headphones",
    "price": 129.99,
    "category": "Audio",
    "url": "https://example.com/products/wireless-headphones",
    "scraped_at": "2026-06-11 09:42:18"
  },
  {
    "id": 2,
    "name": "Mechanical Keyboard",
    "price": 89.50,
    "category": "Accessories",
    "url": "https://example.com/products/mechanical-keyboard",
    "scraped_at": "2026-06-11 09:42:18"
  }
]

对同一个表运行类别汇总查询,会打印出紧凑的汇总,每个类别一行,包含计数和平均价格,这正是一堆原始文件在没有额外代码的情况下无法给你的洞察。

扩展到单个页面之外

上面的流程处理一个页面。真实的数据集跨越许多页面,一些调整可以将这个演示升级为生产就绪的形式。

  • 分页。大多数列表页会暴露页面参数或下一个链接。遍历页面,为每个页面调用 crawlparse_products,并将合并的记录一次性传给 store_records
  • 批量插入。executemany 和每批一次的 commit 已经最小化了往返次数。对于非常大的任务,每隔几千行提交一次,而不是全部一起,这样失败就不会丢失整个运行。
  • 为你查询的列建立索引。第三步的两个索引覆盖了价格和类别过滤。在你频繁过滤或排序的任何其他列上添加索引。
  • 大量访问时使用异步。对于数千个页面,异步 Crawler 将请求排队并将结果发送到 webhook,这样你就不必在爬取时保持连接打开。与批量插入配合,数据库可以跟上。

如果你在设计更大的收集-解析-存储-分析系统而不是单个脚本,数据管道架构指南涵盖了这些阶段如何在规模上组合在一起。

负责任地爬取

将收集限制在合理范围内。只爬取不在登录后面的公开数据,阅读并遵守网站的服务条款和 robots.txt,并将请求速率保持在合理范围内,以免给目标服务器造成压力。当数据涉及可识别个人时,GDPR 和 CCPA 等隐私规则适用,因此除非你有合法依据和明确需要,否则应避免个人数据。这里的示例使用占位符 URL 和说明性产品字段,正是为了远离这些顾虑;在将爬虫指向真实网站时也适用同样的判断。

回顾

核心要点

  • SQL 优于平面文件用于分析。结构化表让你用单个查询过滤、聚合和分组数千条记录,而不需要一次性解析代码。
  • 在入库时清洗数据。去除货币符号和逗号,使价格作为数字存储;数值列正确排序和求平均值,而文本不行。
  • 有意识地设计架构。选择真实的类型,添加主键和 UNIQUE URL 以阻止重复,并对你过滤的列建立索引。
  • 安全且批量地插入。参数化的 ? 占位符和每批一次提交的 executemany 使插入既安全又快速。
  • 用普通 SELECT 分析。WHEREORDER BY、聚合函数和 GROUP BY 将存储的行转化为价格区间、平均值和按类别洞察。

常见问题

为什么将爬取数据存储在 SQL 中而不是 CSV 或 JSON 文件?

文件对于单次拉取很好,但它们使跨行问题变得痛苦。SQL 数据库强制执行一致的结构,让你用一个查询过滤、排序和聚合,并随索引扩展到数百万行。它还能干净地处理重复运行:UNIQUE 约束阻止重复,所以你可以随时间向同一个表追加,而不是管理带日期的文件。

我应该使用哪个数据库,SQLite、MySQL 还是 PostgreSQL?

SQLite 非常适合小到中型项目和本地工作,因为它随 Python 附带且不需要服务器,这就是本指南使用它的原因。MySQL 适用于 Web 应用程序和较大的共享数据集,PostgreSQL 在复杂查询和分析方面很强。当你在它们之间切换时,这里的 Python 流程只改变连接和占位符语法;架构和查询几乎保持相同。

如何避免两次插入同一条记录?

在标识记录的列上放置 UNIQUE 约束,例如产品 URL,然后用 INSERT OR IGNORE(或 PostgreSQL 上的 ON CONFLICT)插入。数据库会跳过其键已存在的任何行,所以重新运行爬虫只会添加新列表,而不是重复旧列表。

为什么在存储之前将价格转换为数字?

因为以文本形式保存的价格(如 "$1,299.00")无法正确比较或求平均值:它按字母排序,并破坏聚合函数。去除符号和逗号以存储数值 price,意味着 WHERE price < 500AVG(price)ORDER BY price 都能按预期工作。

我需要 JavaScript 令牌吗?

只有当你的目标在客户端渲染其内容时。普通令牌返回静态 HTML,对于服务端渲染的页面来说已经足够。如果列表在页面脚本运行后才填充,使用 JS 令牌并添加 ajax_waitpage_wait 选项,这样动态内容在 HTML 被捕获之前就能加载完毕。

如何将其扩展到多个页面而不使数据库过载?

对爬取进行分页,并将合并的记录传给批量的 executemany 插入,每隔几千行提交一次而不是每次一行。对于大型爬取,异步 Crawler 将请求排队并将结果发送到 webhook,这样你就不必保持连接打开,对你查询的列建立索引使随着表的增长读取保持快速。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量