大多数监控市场、构建搜索索引或填充数据集的团队,往往从同一件事开始:从一组公开网页中抓取数据,并将其转化为干净的记录。真正的难点很少在于单个页面,而在于如何跨越数百个页面完成这一工作,同时不让请求遭到限速、封锁,或静默地返回残缺的 HTML。
本指南将向你展示如何用 Python 构建一个小型、可运行的网络爬虫。它通过 Crawling API 获取起始页面,提取其中的链接,追踪目标范围内的链接,解析每个页面上你想要的字段,去除重复项,并导出为干净的 JSON 和 CSV 格式。整个演示使用一个中性示例网站,你可以直接运行,然后将其指向自己的公开数据源。
一段话说清爬取与抓取的区别
这两个词经常被混用,但它们指的是不同的工作。爬取(Crawling)是发现:从一个或多个 URL 出发,跟随链接,向外扩展以找到值得访问的页面。抓取(Scraping)是提取:获取单个页面的 HTML,并从中提取你关心的特定字段,例如标题、价格或日期。真实的数据流水线两者都做。爬虫决定访问哪些页面,抓取器决定从每个页面中保留什么。本指南中的脚本就是一个爬虫,并在它访问的每个页面上都挂载了一个抓取器。
你将构建什么
一个 Python 脚本,接受起始 URL,通过跟踪范围内的链接来发现文章链接,通过 Crawling API 获取每个页面,并从每个页面提取一条结构化记录。示例使用 https://example.com 作为公开列表或博客索引的替代。每条记录包含以下字段:
- 标题 页面的主标题。
- URL 抓取该记录的规范链接。
- 摘要 前导段落或 meta 描述。
- 日期 页面公开展示的发布或更新日期。
- 链接数 该页面发现的范围内链接数量。
为什么普通请求经常失败
最简单的方案是在裸 HTTP 客户端外面套一个循环:获取 URL,解析它,将链接加入队列,重复执行。它在玩具网站上可以运行,在真实网站上则因为两个原因而崩溃。
首先是渲染问题。许多现代页面发送的是薄 HTML 壳,通过 JavaScript 和 Ajax 在浏览器中加载真实内容。用普通客户端请求这个壳,你想要的链接和字段还不在 body 里,爬虫什么也发现不了、什么也解析不到。其次是封锁问题。网站会监控自动化流量:来自数据中心 IP 段的、缺少浏览器请求头的、或触发频率快于任何人类的请求,都会在到达内容之前遭到限速、IP 封锁,或被要求完成 CAPTCHA。
因此,一个能稳定大规模运行的爬虫,每次请求都需要两样东西:能渲染页面的浏览器,以及网站认为是真实访客的 IP。你可以自己用无头浏览器加上轮换住宅代理池来实现,但维持这套基础设施的健康运行才是大部分工作所在。Crawling API 将两者合并为一次调用:你发送 URL,它在可信 IP 后面渲染页面,并返回已渲染的 HTML 供你解析。
前置条件
开始之前需要准备好几样东西,都不需要太长时间。
基础 Python 知识。 你应该能够编写和运行脚本,并用 pip 安装包。如果解析部分对你来说比较陌生,BeautifulSoup 指南与本教程配合使用效果很好。
Python 3.8 或更高版本。 用 python --version 确认。如果没有,请从 python.org 安装,或通过 Anaconda 等发行版安装,并确保 Python 在 PATH 中。
Crawlbase 账号和 token。 注册后打开控制台,从账号页面复制你的 token。Crawlbase 提供 1,000 次免费请求作为起步,足以完成本指南的全程练习。token 有两种类型:普通 token 获取静态 HTML,JavaScript token 则先在真实浏览器中渲染页面。静态页面使用普通 token,内容在客户端加载的页面使用 JavaScript token。请像对待密码一样保管 token,不要放入版本控制系统。
搭建项目
创建虚拟环境以隔离依赖,然后安装爬虫所需的两个库。
python --version python -m venv crawler_env source crawler_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,用 crawler_env\Scripts\activate 代替 source 那行来激活环境。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以通过 CSS 选择器提取字段和链接。json 和 csv 随标准库附带,因此导出步骤无需额外安装。
第 1 步:通过 Crawlbase 获取页面
从可靠地获取单个页面开始。导入 CrawlingAPI 类,用你的 token 初始化,并请求起始 URL。在解析前检查 Crawlbase 的 pc_status,可以让错误显而易见,并为你提供一个干净的重试位置。
import time from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def fetch_html(page_url, max_retries=2): for attempt in range(max_retries + 1): response = api.get(page_url) if response["headers"]["pc_status"] == "200": return response["body"].decode("utf-8") if attempt < max_retries: print(f"Retrying ({attempt + 1}/{max_retries})...") time.sleep(1) print(f"Failed: {page_url} ({response['headers']['pc_status']})") return None if __name__ == "__main__": html = fetch_html("https://example.com") print(html[:500] if html else "No HTML returned")
fetch_html 辅助函数是整个爬虫的骨干。它通过 Crawlbase 发送 URL,在获取失败时短暂暂停后最多重试两次,成功时返回解码后的 HTML,最终放弃时返回 None。运行 python crawler.py,你应该能看到真实的标记语言打印出来,这在你编写任何选择器之前就确认了请求路径的有效性。如果你的目标在客户端加载内容,请使用 JavaScript token 初始化,并将 {"ajax_wait": "true", "page_wait": 5000} 作为第二个参数传给 api.get,以便 API 在抓取页面前等待动态内容加载完成。
上面的 fetch_html 辅助函数依赖一件事:每个请求都会以已渲染的形式、从网站信任的 IP 地址返回。Crawling API 正是做到了这一点。有需要时它在真实浏览器中运行页面,在服务端轮换住宅 IP,并将已渲染的 HTML 交付给你,让你无需自己搭建无头浏览器集群和代理池。先从免费套餐开始,将其指向一个公开页面。
第 2 步:提取页面上的链接
发现的本质就是在循环中提取链接。将 HTML 加载到 BeautifulSoup,提取每个锚点的 href,并将相对路径针对其所在页面进行解析,使你始终处理绝对 URL。
from urllib.parse import urljoin, urldefrag from bs4 import BeautifulSoup def extract_links(html, base_url): soup = BeautifulSoup(html, "html.parser") links = set() for a in soup.select("a[href]"): href = a["href"].strip() if not href or href.startswith(("mailto:", "tel:", "javascript:")): continue absolute = urljoin(base_url, href) absolute, _ = urldefrag(absolute) links.add(absolute) return links
三个小决策使这个函数更健壮。该函数跳过 mailto:、tel: 和 javascript: 等非真实页面的锚点。它使用 urljoin,将 /articles/web-data 这样的相对 href 转化为基于其所在页面的完整 URL。它还调用 urldefrag 去除 #section 片段,因为 /page 和 /page#top 是同一个文档,不应重复访问。返回 set 在进入队列之前就对单个页面上发现的链接进行了去重。
第 3 步:将爬取范围限定在目标内
若不加限制,爬虫会跟随链接离开你的目标网站,永无止境。解决办法是范围规则:只追踪与起始 URL 共享同一主机,且可选地位于你关心的路径前缀下的链接。这相当于爬虫版的"只在产品区活动,不要跑到帮助中心去"。
from urllib.parse import urlparse def in_scope(url, root): root_parts = urlparse(root) url_parts = urlparse(url) if url_parts.scheme not in ("http", "https"): return False if url_parts.netloc != root_parts.netloc: return False return url_parts.path.startswith(root_parts.path)
in_scope 将每个候选 URL 与你的根 URL 进行比较。它拒绝任何非 HTTP 或 HTTPS 的链接、任何不同主机(netloc)上的链接,以及任何路径不以根路径开头的链接。将根设置为 https://example.com/ 可以爬取整个主机,设置为 https://example.com/blog/ 则只在一个区段内爬取。在此收紧范围,是控制你抓取多少内容的最重要杠杆。
第 4 步:解析每个页面上的字段
发现告诉你访问哪些页面,解析决定保留什么。从每个页面提取一条小而定义明确的记录,并对每个查找进行防御处理,让缺失的字段返回 None 而不是使运行崩溃。
def text_of(soup, selector): el = soup.select_one(selector) return el.get_text(strip=True) if el else None def attr_of(soup, selector, attr): el = soup.select_one(selector) return el.get(attr) if el else None def parse_page(html, url): soup = BeautifulSoup(html, "html.parser") summary = ( attr_of(soup, 'meta[name="description"]', "content") or text_of(soup, "article p") ) return { "url": url, "title": text_of(soup, "h1") or text_of(soup, "title"), "summary": summary, "date": attr_of(soup, "time[datetime]", "datetime"), }
两个辅助函数 text_of 和 attr_of 查询单个元素,返回其文本或某个属性,元素缺失时回退到 None。parse_page 使用一组回退链:摘要优先使用 meta[name="description"] 标签,没有时退回到第一个 article 段落;标题使用 h1,没有 h1 时使用 <title> 标签。这些选择器特意保持通用,使脚本可以直接在示例网站上运行。对于真实目标,在浏览器开发者工具中打开页面,将它们替换为匹配该网站实际标记结构的选择器。
第 5 步:组装爬取循环
现在将各部分整合为一个广度优先爬虫。队列保存待访问的 URL,visited 集合防止同一页面被重复获取,max_pages 上限防止运行无休止地持续。对于它访问的每个页面,爬虫解析一条记录,统计范围内的链接数,并将新链接加入队列。
import csv import json import time from collections import deque from urllib.parse import urljoin, urldefrag, urlparse from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def fetch_html(page_url, max_retries=2): for attempt in range(max_retries + 1): response = api.get(page_url) if response["headers"]["pc_status"] == "200": return response["body"].decode("utf-8") if attempt < max_retries: time.sleep(1) return None def extract_links(html, base_url): soup = BeautifulSoup(html, "html.parser") links = set() for a in soup.select("a[href]"): href = a["href"].strip() if not href or href.startswith(("mailto:", "tel:", "javascript:")): continue absolute, _ = urldefrag(urljoin(base_url, href)) links.add(absolute) return links def in_scope(url, root): r, u = urlparse(root), urlparse(url) return ( u.scheme in ("http", "https") and u.netloc == r.netloc and u.path.startswith(r.path) ) def text_of(soup, selector): el = soup.select_one(selector) return el.get_text(strip=True) if el else None def attr_of(soup, selector, attr): el = soup.select_one(selector) return el.get(attr) if el else None def parse_page(html, url, link_count): soup = BeautifulSoup(html, "html.parser") summary = ( attr_of(soup, 'meta[name="description"]', "content") or text_of(soup, "article p") ) return { "url": url, "title": text_of(soup, "h1") or text_of(soup, "title"), "summary": summary, "date": attr_of(soup, "time[datetime]", "datetime"), "links": link_count, } def crawl(start_url, max_pages=25): queue = deque([start_url]) visited = set() records = [] while queue and len(visited) < max_pages: url = queue.popleft() if url in visited: continue visited.add(url) html = fetch_html(url) if not html: continue found = {l for l in extract_links(html, url) if in_scope(l, start_url)} records.append(parse_page(html, url, len(found))) for link in found: if link not in visited: queue.append(link) print(f"[{len(visited)}/{max_pages}] {url}") time.sleep(2) return records
这是教科书式的广度优先爬取。visited 集合是爬取层面的去重守卫:URL 在获取之前就被添加进去,因此即使三个页面都链接到同一篇文章,也只会请求一次。max_pages 限制总工作量,范围过滤器防止队列被站外链接填满,两秒的 sleep 使运行速率保持合理,不至于轰炸服务器。print 行在运行时给你实时进度追踪。
第 6 步:去重并导出为 JSON 和 CSV
visited 集合已经防止了同一 URL 被获取两次,但重定向和尾部斜杠变体仍然可能产生描述同一页面的两条记录。导出前以 URL 为键做最后一次去重,可以合并这些情况。
def dedupe(records): seen = {} for record in records: seen[record["url"].rstrip("/")] = record return list(seen.values()) def save_outputs(records): with open("crawl_results.json", "w") as f: json.dump(records, f, indent=2) if not records: return with open("crawl_results.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=records[0].keys()) writer.writeheader() writer.writerows(records) def main(): records = crawl("https://example.com", max_pages=25) records = dedupe(records) save_outputs(records) print(f"Saved {len(records)} pages") if __name__ == "__main__": main()
dedupe 以去掉尾部斜杠的 URL 作为每条记录的键,使 /article 和 /article/ 解析为同一条记录,且以后者为准。save_outputs 写入一个 JSON 文件和一个 CSV 文件,以第一条记录的键作为表头,将数据以你的下游工具所需的格式提供。将这两个函数添加在第 5 步的爬取循环下方,脚本就可以端到端运行了。
输出结果示例
运行完整脚本 python crawler.py,每个页面你会得到一条结构化记录,可以直接用于分析、写入数据库,或放入电子表格。
[ { "url": "https://example.com/articles/web-data", "title": "A Practical Guide to Web Data", "summary": "How teams turn public pages into clean, structured records.", "date": "2024-09-18", "links": 12 }, { "url": "https://example.com/articles/crawling-basics", "title": "Crawling Basics", "summary": "Discovery, scope, and dedupe explained from first principles.", "date": "2024-08-02", "links": 9 } ]
对应的 CSV 包含相同的列,每行一个页面,可以直接导入 pandas 或任何电子表格进行排序、过滤,或与其他数据集关联。如果你想进一步处理存储步骤,可以参考将抓取数据存储到云端和将其加载到 SQL。
扩大爬取规模
上面的脚本特意设计为单线程,便于阅读且易于保持礼貌。几个改动可以让它从演示版变成一个可以长期运行的任务。
-
谨慎提高上限。
max_pages是你的安全阀。分步提高它,并在投入大规模运行之前观察爬取发现了多少个范围内的链接。 -
持久化前沿队列。 对于长时间爬取,将队列和
visited集合写入磁盘,使中断的任务能够从断点恢复,而不是重新开始并重新获取所有内容。 - 大量数据时使用异步方式。 当你需要数千个页面时,异步 Crawler 可以将请求排入队列并将结果推送到 webhook,你无需在页面渲染期间保持连接开启。
对于链接本身也在客户端加载的 JavaScript 密集型目标,切换到 JavaScript token 和等待选项后,同样的循环即可运行。详情请参阅爬取 JavaScript 网站。
保持不被封锁
即使渲染和可信 IP 都处理好了,以下几个习惯可以让较长的爬取任务保持健康。
- 控制请求速率。 循环中的两秒 sleep 是下限,不是上限。对于较大的任务,可以适当延长,同时避免以服务器最快响应速度爬取同一路径。
- 依赖 IP 轮换。 住宅 IP 池将请求分散到多个真实用户地址,使单个地址不会触发速率限制。Crawling API 为你处理了这一点;如果你自行搭建,这是最需要做好的环节。
-
关注状态码。 如果运行开始返回非 200 的
pc_status值,说明当前速率或 IP 套餐已不够用。将此视为退出的信号,而非可忽略的噪音。
完整的操作手册,请参阅如何在不被封锁的情况下抓取网站。
负责任地抓取
只抓取公开数据,并遵守你访问网站的规则。在开始之前阅读每个目标网站的服务条款和 robots.txt,将请求速率保持在合理范围内,不要给任何人的服务器造成压力,并远离需要登录或付费才能访问的内容。当你收集的页面包含个人数据时,GDPR 和 CCPA 等隐私法律适用于你存储和使用这些数据的方式,因此将字段范围限定在你真正需要的内容,避免收集与可识别个人相关联的详细信息。本指南中的代码使技术层面的工作成为可能;让项目站在这些规则正确一侧的责任在于你。
核心要点
- 爬取和抓取是两项不同的工作。 爬虫通过跟踪链接来发现访问哪些页面;抓取器从每个页面提取你要保留的字段。
- 渲染并通过可信 IP 路由。 普通客户端会错过客户端渲染的内容并遭到封锁;Crawling API 在一次调用中从可信 IP 返回已渲染的 HTML。
-
范围和去重使爬取保持可控。
in_scope检查防止任务偏离目标网站,visited集合加上以 URL 为键的去重过程,消除重复工作和重复记录。 -
防御性地解析。 对每个选择器进行防护,使缺失字段返回
None,一个异常页面不会终止整个运行。 - 一次导出,随处使用。 同时写入 JSON 和 CSV,使同一数据集无需重新处理即可流入 pandas、数据库或电子表格。
常见问题
网络爬取和网络抓取有什么区别?
爬取是发现步骤:从一个或多个 URL 出发,跟踪链接找到值得访问的页面。抓取是提取步骤:获取单个页面的 HTML,提取标题或日期等特定字段。大多数真实的数据流水线同时执行两者,这正是本指南中脚本所做的事:爬取以发现页面,并从每个页面抓取一条记录。
为什么我的爬虫返回空的或不完整的 HTML?
通常是因为页面通过 JavaScript 在浏览器中渲染内容,所以初始 HTML 只是一个薄壳,你的链接和字段还不在其中。通过使用 JavaScript token 以及 ajax_wait 和 page_wait 选项,用 Crawling API 获取页面,这些选项会先渲染页面,再返回已渲染的标记供你解析。
如何防止爬虫离开我的目标网站?
使用范围规则。in_scope 函数将每个候选链接与你起始 URL 的主机和路径进行比较,拒绝任何不匹配的链接。将根路径设置得更窄,例如 https://example.com/blog/,可以将爬取限制在一个区段内而不是整个域。
爬虫如何避免重复访问同一个页面?
通过两层机制。visited 集合在每个 URL 被获取之前就记录下来,因此即使一个页面被多处链接,也只会请求一次。爬取结束后,一个以 URL 为键(规范化尾部斜杠)的去重过程,在记录进入 JSON 和 CSV 之前,合并任何仍然描述同一页面的记录。
我应该导出为 JSON 还是 CSV?
两者都导出,让下游工具来决定。JSON 保留了代码和 API 偏好的嵌套、有类型的结构,而 CSV 可以直接导入电子表格和 pandas。save_outputs 函数从同一批记录同时写入两种格式,使你不会被锁定在单一格式上。关于两者权衡的更多讨论,可以参考 JSON 与 CSV 的对比分析。
免费套餐可以爬取多少个页面?
Crawlbase 提供 1,000 次免费请求作为起步,且仅对成功的请求计费。爬虫获取的每个页面算作一次请求,因此脚本中的 max_pages 上限直接对应你的使用量。对于规模较大或定期执行的任务,异步 Crawler 可以在不保持连接开启的情况下扩展同样的方法。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
