缺失的页面和被拦截的抓取需要不同的处理。在我们的实时运行中,一个请求 example.com 上并不存在页面的调用返回了 HTTP 200,附带 cb_status=200 和 original_status=404。这次抓取成功且计费。一次对 Glassdoor 的 Normal token 请求返回了 HTTP 520,附带 cb_status=705 和 original_status=403。那次抓取失败且免费。只读取外层 HTTP 状态的监控无法区分这两者。
本文把这些信号变成一个网页抓取监控仪表盘。一个小型客户端记录每一次完成的尝试,Prometheus 收集计数器和直方图,Grafana 把每个域名的健康状况与来自 Account API 的账户级用量并排展示。三条告警规则覆盖持续的获取失败、需要路由决策的 Normal token 响应,以及预计将耗尽的预算。
配套仓库 包含 完整技术栈, 九个演练文件夹,单元测试和实时验证脚本。下文的数字来自 2026年11月 的实时运行,这次运行有意混合了容易的目标、一个用了错误 token 的受保护站点,以及一个返回 HTTP 500 的 URL。它们展示的是监控如何处理失败。它们不是 Crawlbase 的成功率基准。
- 每个 Crawling API 响应都携带四个信号:外层 HTTP 状态、
cb_status,original_status,以及域名复杂度等级。把四个全部记录下来,以主机名为键,而不是以 URL 为键。 - 计费和成功是两个不同的集合。成功抓取的 404 要计费。失败的 520 和客户端超时都免费。在指标和仪表盘上把这两个定义分开。
- 本地计数器回答“现在正在发生什么”。Account API 回答“这个账户本月用了多少”,每五分钟一次。要对比它们,但不要期望它们一致。
把抓取健康状况与目标站点的响应分开
Crawling API 返回的元数据回答三个不同的问题:Crawlbase 是否处理了请求,获取过程中发生了什么,以及目标站点回答了什么?
| 信号 | 描述的内容 | 仪表盘如何使用它 |
|---|---|---|
| 外层 HTTP 状态 | Crawlbase 返回给客户端的响应 | 区分已收到的响应和传输失败;失败的抓取以 520 到达 |
cb_status |
Crawlbase 对这次抓取的判定 | 定义获取成功,并对失败结果分组 |
original_status |
目标站点自身的响应状态 | 区分缺失页面、拒绝访问和上游错误 |
domain_complexity |
域名的定价等级 | 按等级对延迟分组 |
在记录下来的 HTML 响应中,等级以 x-domain-complexity 标头的形式到达。使用 format=json 时,它作为 domain_complexity 出现在信封中。信号解析器 同时接受两种写法,归一化标头名称,并在请求要求时读取 JSON 信封。它还接受旧的 pc_status 别名,而应用程序在各处统一暴露 cb_status。
cb_status 说明 Crawlbase 为何放弃,original_status 说明站点回答了什么。原始请求演练 会为几个 URL 打印这些信号。下面两条观察来自这次运行:
https://example.com/does-not-exist http_status 200 cb_status 200 original_status 404 domain_complexity standard seconds 1.4 https://www.glassdoor.com/Reviews/index.htm http_status 520 cb_status 705 original_status 403 domain_complexity standard seconds 20.3
上图展示的是同一次运行中另一个 Glassdoor 请求,它以 cb_status=576 失败;清单中的那个返回的是 705。两个代码属于同一个失败家族。缺失的页面在 1.4 秒内完成。Glassdoor 在 20.3 秒后返回了失败响应,对同一 URL 的第二次请求则用了 68 秒才失败。因此,404 可以是一次成功的获取,尽管其中没有任何可提取的内容。必填字段校验属于获取之后的环节,不在本仪表盘的成功定义之内。
这次运行还返回了 complex_ii 作为等级,因此解析器接受任何短小的小写 slug,而不是固定的三个名称。这与 定价页 上更宽的等级范围一致。观察到的 cb_status 值除了文档记载的结果之外,还包括 0、515、521、576、611 和 702。状态参考 解释了常见代码及其所属家族。让不熟悉的代码在仪表盘上保持可见;一个代码数字本身并不能确立根本原因。
HTTP 520 标识一次失败的抓取,但它不是唯一的错误响应。身份验证、速率限制和请求校验会产生其他 HTTP 状态,而传输超时根本不会产生 HTTP 响应。
记录每一次尝试,而不为每个 URL 创建一条序列
带埋点的客户端 在每次 get() 调用时发出一个 Crawling API 请求。它在 HTTP 520 时不会抛出异常;失败的抓取是一种正常的、带标签的结果。传输错误会先计数再重新抛出,所以重试策略留给调用方。客户端从不自行切换 token,也不自行重试 URL。这是仓库中响应路径的样子:
def get(self, url: str, **params: Any) -> CrawlResult: domain = self._limit_domain(domain_label(url)) token_type = "js" if str(params.get("javascript", "")).lower() == "true" else self.token_type success_codes = self.custom_success_codes | parse_success_codes(params.get("custom_success_codes")) self._ensure_series(domain, token_type) started = self._clock() try: response = self._http.get( f"{self.base_url}/", params={"token": self._token, "url": url, **params} ) except httpx.HTTPError as exc: self._record_failure(domain, token_type, exc) raise elapsed = self._clock() - started json_body = None if params.get("format") == "json": try: json_body = response.json() except ValueError: json_body = None signals = parse_signals(response.status_code, response.headers, json_body) billable = is_billable(signals.cb_status, signals.original_status, success_codes) # Counted on completion, next to the outcome, so success <= attempts in any window. self.metrics.attempts.labels(domain=domain, token_type=token_type).inc() self.metrics.requests.labels( domain=domain, cb_status=status_label(signals.cb_status), original_status=status_label(signals.original_status), http_status=status_label(signals.http_status), token_type=token_type, billable=str(billable).lower(), ).inc() self.metrics.latency.labels(domain=domain, complexity=signals.complexity).observe(elapsed) self.metrics.response_bytes.labels(domain=domain).observe(len(response.content)) return CrawlResult(url, domain, token_type, signals, billable, elapsed, response.content)
指标定义 把完成的尝试与各个结果分开。crawlbase_attempts_total 在每次收到响应和每次传输错误时递增。crawlbase_requests_total 携带状态标签;传输错误的三个状态全部记录为 none,并带上 billable="false",因此超时仍留在成功率的分母中:
def _record_failure(self, domain: str, token_type: str, exc: httpx.HTTPError) -> None: reason = "timeout" if isinstance(exc, httpx.TimeoutException) else "transport" self.metrics.attempts.labels(domain=domain, token_type=token_type).inc() self.metrics.client_errors.labels(domain=domain, token_type=token_type, reason=reason).inc() # Count it as a request too, so success-rate denominators include it. self.metrics.requests.labels( domain=domain, cb_status="none", original_status="none", http_status="none", token_type=token_type, billable="false", ).inc()
标签决定 Prometheus 必须存储多少条序列。域名辅助函数 将主机名转为小写并做 IDNA 编码,去掉开头的 www.,并丢弃路径和查询串。子域名保持独立。演示在两个 token 客户端之间共享一个 DomainLimiter;它最多保留 200 个域名,之后的一切都归入 other.
状态标签接受 0 到 999 的整数,缺失值用 none,超出范围的任何值用 other。复杂度标签接受最多 24 个字符的小写 slug。这些上限约束了标签的增长,尽管状态、域名和等级的组合仍会相乘。完整 URL 和请求 ID 属于诊断日志,而不是指标标签。演示还安装了一个 日志过滤器,它会在每一行日志中遮蔽 token= 查询参数、配置的 token 值,以及 /crawler/<token> 路径。
在流量开始前建立计数器基线
一个 Prometheus 第一次看到时就是 1 的计数器,没有被观察到的从零出发的变化。rate() 无法从之后的样本中恢复那第一个事件。因此演示预先注册已知域名,并在第一个请求之前等待 20 秒,让 15 秒一次的采集看到零值:
def preregister(self, urls: Iterable[str], token_type: str | None = None) -> None: """Create zero-valued series before traffic starts. Prometheus only sees a counter once it exists. A series born at 1 between two scrapes shows no increase, so the first failure would be invisible. """ for url in urls: self._ensure_series(self._limit_domain(domain_label(url)), token_type or self.token_type) def _ensure_series(self, domain: str, token_type: str) -> None: if (domain, token_type) in self._known: return self._known.add((domain, token_type)) self.metrics.attempts.labels(domain=domain, token_type=token_type) self.metrics.requests.labels( domain=domain, cb_status="200", original_status="200", http_status="200", token_type=token_type, billable="true", )
preregister() 为每个域名和 token 类型创建尝试序列和默认的 200/200 成功序列。它不会创建所有可能的结果。首次出现的状态组合仍可能在基于速率的拆分中丢失它的第一个事件。单独的尝试基线让成功率分母可靠,而无需提前准备每一个失败标签。
把计费响应与成功提取分开分类
计费分类器 遵循 Crawling API 文档中的规则:
CHARGED_CB_STATUSES = frozenset({200, 207}) CHARGED_ORIGINAL_STATUSES = frozenset({200, 201, 301, 302, 303, 307, 308, 404, 410}) def is_billable( cb_status: int | None, original_status: int | None, custom_success_codes: Iterable[int] = (), ) -> bool: if cb_status not in CHARGED_CB_STATUSES or original_status is None: return False return original_status in CHARGED_ORIGINAL_STATUSES or original_status in set(custom_success_codes)
一个响应需要同时具备符合条件的 cb_status 和符合条件的目标状态才会被收费。成功抓取一个 404 或 410 页面要计费。如果你传入 custom_success_codes=403,Crawlbase 会把 403 回答视为成功抓取,返回 cb_status=200,并为此收费。本地分类器镜像了这条规则:它只在 200 或 207 的 cb_status 下才接受自定义代码,因此它从不把 Crawlbase 自己标记为失败的响应升级为成功。
计费演练 包含一张决策表和 41 个离线测试。指标端点演练 通过 start_http_server(8000) 暴露计数器。两条记录下来的行展示了实际使用的标签:
crawlbase_requests_total{billable="true",cb_status="200",domain="example.com",http_status="200",original_status="404",token_type="normal"} 9.0 crawlbase_requests_total{billable="false",cb_status="576",domain="glassdoor.com",http_status="520",original_status="403",token_type="normal"} 3.0
第一条序列记录了对一个缺失页面的九次计费获取。第二条记录了三次免费的 Glassdoor 失败。两者都没有说明提取出的记录是否有效。
仪表盘把获取成功定义为 cb_status=200。计费类别还包括符合条件的 207 响应。207 是空响应,所以它可以计费却落在成功策略之外。并排阅读成功率和计费份额时,要把这些定义分开。
把本地活动与账户级用量对比
本地计数器描述的是一个带埋点的进程。Account API 返回一个产品的月度账户级统计,包括成功数、失败数、应付金额和按域名的汇总。它允许每五分钟一次请求,这使它成为与每 15 秒采集一次的 /metrics 端点完全不同的一类数据源。
账户轮询器 把最近一次快照和最近一次调用的时间保存在磁盘上。它在发送请求之前就写入时间戳,所以失败的调用或被中断的进程同样会消耗这 300 秒的间隔:
def poll(self, metrics: AccountMetrics | None = None) -> AccountSnapshot | None: """Return a fresh snapshot if allowed, otherwise the cached one.""" if self.seconds_until_allowed() > 0: if metrics: metrics.cache_hits.inc() return self._cached self._last_call = self._clock() self._save_state() try: response = self._http.get( f"{self.base_url}/account", params={"token": self._token, "product": self.product} ) response.raise_for_status() snapshot = parse_account(response.json()) except (httpx.HTTPError, ValueError) as exc: result = "timeout" if isinstance(exc, httpx.TimeoutException) else "error" status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None log.warning("Account API call failed (%s): %s status=%s", result, type(exc).__name__, status) if metrics: metrics.api_calls.labels(result=result).inc() return self._cached self._cached = snapshot self._save_state() if metrics: metrics.api_calls.labels(result="ok").inc() return snapshot
启动时,轮询器重新加载保存的调用时间和快照。对账器演练 展示了第二次调用立即从缓存得到回答,而在记录的测试中,一个新进程也复用了持久化的响应。
每个 token 只运行一个对账器。状态文件支持顺序重启,不支持并发轮询器之间的协调。每个实例保留自己的内存内时间戳,不加任何进程间锁。测试期间,两个使用独立状态文件的轮询器触发了一次过早调用的拒绝,而仅靠一个共享文件并不能让并发轮询器变得安全。
文档中的字段表描述了总量和按域名的拆分。记录下来的载荷把它们嵌套在 currentMonth 和 metrics:
{ "currentMonth": { "totalSuccess": "<str>", "totalFailed": "<str>", "totalDue": "<float>", "domainStats": [ { "domain": "<str>", "metrics": { "totalRequests": "<int>", "success": "<int>", "failure": "<int>", "successRate": "<str>" } } ] } }
解析器既接受这种观察到的结构,也接受扁平结构。它会转换字符串形式的总量和诸如 "100.0%" 的百分比字符串,并在存在域名计数时根据成功数和总请求数计算比率。实时域名是像 glassdoor 这样的裸标签,而不是 glassdoor.com。数据比流量滞后了几分钟,product=crawler 返回了同样的月度结构。
Prometheus 可以每 60 秒采集一次对账器的 gauge 指标,而不会在每次采集时触发一次 Account API 调用。落在保护间隔内的调用返回缓存数据,长期运行的对账器只是等到下一次允许调用为止。
记录规则派生出一个 site 标签,这样主机名就能与账户侧的裸标签对比。它是一个普通的主机名正则表达式,并不感知公共后缀。它适用于演示中的 .com 和 .org 目标;在把它指向 example.co.uk.
理解预算回退机制
remainingCredits 在记录下来的 Crawling API 响应中并不存在。文档指出它只对基于订阅的产品返回,因此对账器需要一个回退。设置 CRAWLBASE_MONTHLY_BUDGET 后,快照会以 max(0, budget - totalSuccess) 导出,带上 source="budget"。当 API 确实提供剩余积分时,gauge 指标改用 source="account_api":
metrics.total_success.set(snapshot.total_success) metrics.total_failed.set(snapshot.total_failed) metrics.total_due.set(snapshot.total_due) if snapshot.remaining_credits is not None: metrics.remaining_credits.labels(source="account_api").set(snapshot.remaining_credits) elif monthly_budget: metrics.remaining_credits.labels(source="budget").set( max(0.0, monthly_budget - snapshot.total_success) )
这个回退是基于请求计数的估算。耗尽天数规则把剩余 gauge 指标除以最近一小时的计费请求速率乘以 86,400,这假定每个计费请求消耗一个积分。JavaScript 渲染和更高的复杂度等级会消耗更多积分,其他产品也可能从同一余额中扣减。这个预测足以演示预算告警。在把它当作财务上限之前,请改用按积分加权的消耗量。
连接导出器、Prometheus 和 Grafana
获取和对账作为独立的进程运行。每个进程都暴露一个指标端点,Prometheus 采集两者并评估记录规则,Grafana 查询这些规则来驱动面板和告警。
Compose 演练 引入了四个服务:爬取器在 8000 端口,对账器在 8001,Prometheus 在 9090,Grafana 在 3000。最终技术栈固定使用 Prometheus 3.1.0 和 Grafana 11.4.0,其 Python 依赖 固定了 HTTPX 0.28.1、prometheus-client 0.21.1 和 python-dotenv 1.0.1。采集配置很短:
global: scrape_interval: 15s evaluation_interval: 15s rule_files: - /etc/prometheus/rules/*.yml scrape_configs: - job_name: crawlbase-scraper static_configs: - targets: ["scraper:8000"] # The gauges only change every 5 minutes, a slower scrape is plenty. - job_name: crawlbase-account scrape_interval: 60s static_configs: - targets: ["reconciler:8001"]
规则全局每 15 秒评估一次,Crawlbase 记录规则组设为 30 秒。Grafana 每 30 秒刷新仪表盘,每分钟评估一次告警组。这些调度都不是 Account API 的五分钟保护间隔;它们是各自独立的时钟。
命名卷 cbwatch-account-state 由各演练栈和最终技术栈共享。普通的 docker compose down 会保留它,所以你可以在各栈之间切换而不丢弃保护间隔。删除该卷也会一并删除保存的调用时间。
仪表盘演练 引入了记录规则,让面板和告警共用一个定义。核心的按域名比率是:
# Success = the crawl worked (cb_status 200). The denominator is # crawlbase_attempts_total, which also counts timeouts and is created at 0. - record: crawlbase:domain_success_ratio:rate5m expr: | clamp_max( sum by (domain) (rate(crawlbase_requests_total{cb_status="200"}[5m])) / sum by (domain) (rate(crawlbase_attempts_total[5m])), 1 )
分子统计 cb_status=200。分母是每一次完成的尝试,包含超时,并且每个域名合并两种 token 类型。clamp_max 在两个分别采样的计数器产生瞬时超出时把比率上限设为 1。一小时汇总在更长的窗口上使用同一定义。
Prometheus 规则测试 把合成的计数器序列送入这些规则。它们的 Walmart 序列得到 90% 的成功率,第二条序列确认超时会拉低比率。这些是测试夹具,不是实时观察值。
按各自的定义解读每个仪表盘面板
Grafana 从预置的 JSON 加载仪表盘。仪表盘生成器 用 Python 构建该 JSON,一个单元测试检查提交的文件与生成器输出一致。成功率面板指向共享规则:
timeseries("Success rate per domain", [target("crawlbase:domain_success_ratio:rate5m", "{{domain}}")], 0, 6, unit="percentunit", minv=0, maxv=1, desc="cb_status 200 share per domain, 5 minute window. Alert fires under 90% for 10m."),
这张示意图按运维问题排列记录下来的观察值。读每个值时都要结合它的查询窗口;一小时速率、五分钟速率和原始计数器总量回答的是不同问题,不应合并成单一的批次成功比例。
| 面板或诊断项 | 记录的观察值 | 解读 |
|---|---|---|
| 成功率,一小时 | 65.1% | 一个有意包含失败的演示中的获取成功率 |
| 计费份额,一小时 | 65.1% | 窗口化的计费份额,与原始计数器总量分开 |
| 剩余预算与预测 | 1,000 预算中剩余 890;0.7 天 | 每请求一个积分的估算 |
| 域名成功率 | example.com 100%;glassdoor.com 15%;httpbin.org 0% | 按域名的获取健康状况;httpbin 目标故意返回 500 |
| 延迟 p95,standard 等级 | 87.8 秒 | 仅基于已收到响应的直方图估算 |
| 失败最多的域名 | glassdoor.com 和 httpbin.org | 20 个非 200 结果,包含超时 |
| Normal token 的 Glassdoor 被拦截份额 | 41% | 单独的被拦截比率诊断项;仪表盘面板绘制的是每分钟请求数 |
| 计费与免费计数器 | 46 计费;20 免费 | 与状态拆分相同的 66 次尝试 |
| 本地与账户成功率 | 65.1% 对 70.1% | 本地一小时比率对比账户的月初至今比率 |
| Glassdoor 本地与账户 | 15% 对 36.4% | 同样的窗口和范围差异,在站点层面 |
状态拆分中有 46 个响应为 200、六次客户端超时、四次 576、四次 0、两次 704、两次 705,以及 521 和 611 各一次。46 个 200 响应全部计费,其余 20 个免费,所以计费标签和状态拆分描述的是同样的 66 次尝试。20 个免费结果全部出在 glassdoor.com 和 httpbin.org 上。每个非 200 的 cb_status 都以 HTTP 520 到达;超时没有响应,被记录为 none。窗口化速率和原始计数器仍然不同:一小时成功率不是原始的 46 比 66。
87.8 秒的 p95 需要一个精确的分母。客户端只在响应到达后才观察延迟,HTTP 520 也包括在内。客户端超时从不进入直方图。这个值是一次 分桶插值,而不是对各个耗时的精确百分位,并且最高桶的边界就是 90 秒超时。普通页面观察到的耗时在两秒左右。HTTPX 的 90 秒超时适用于每一次网络操作,包括等待下一个数据块,因此它并不是严格的挂钟截止时间,正如 HTTPX 超时文档 所解释的。
本地与账户数字之间的差距有几个预期之内的原因。本地指标覆盖本进程的尝试并包含客户端侧错误。Account API 覆盖整个月内所有使用所选产品的调用方,并且可能滞后于最近的流量。这些范围无法给你一对一的发票对账,也无法给出一致的超时核算。
数据年龄面板的含义比它的标签所暗示的要少。对账器每次应用快照时都会更新时间戳,包括轮询失败或重启后的缓存数据。请把它和 API 错误计数器放在一起看;单凭它本身并不能证明上游数据是新鲜获取的。
让抓取成功率告警可执行
告警演练 预置了三条由 Grafana 管理的规则。它们把持续的获取损失、需要调查的 token 路由问题和预算预警区分开来:
| 告警 | 条件 | 预期处理 |
|---|---|---|
| 域名成功率低于 90% | 五分钟成功比率低于 0.9 持续 10 分钟,且域名的尝试速率高于 0.008/s | 呼叫值班 |
| Normal token 被拦截 | 选定的 Normal token 结果占尝试数的比例超过 20% 持续五分钟 | 开工单 |
| 积分预计耗尽 | 估算的耗尽天数少于三天持续 15 分钟 | 呼叫值班 |
域名告警上的速率门限大约是每 125 秒一个请求,即每个五分钟窗口 2.4 个请求。它过滤掉几乎空闲的域名,但并不是大样本保证。请按你的作业实际需要的流量和可靠性来设置流量下限和阈值。
- uid: cb-domain-success-low title: Crawlbase domain success rate below 90% condition: C for: 10m noDataState: OK execErrState: Error labels: severity: page annotations: summary: "Success rate for {{ $labels.domain }} is {{ humanizePercentage $values.B.Value }}" description: >- cb_status 200 share has been under 90% for 10 minutes. Open the cb_status breakdown panel: 5xx/6xx codes are the crawl layer, 704/705/706 mean the target site said no (check original_status). runbook_url: https://crawlbase.com/docs/status-codes/ data: - refId: A relativeTimeRange: { from: 600, to: 0 } datasourceUid: prometheus model: refId: A instant: true # Ignore domains with almost no traffic (under ~1 request per 2 minutes). expr: crawlbase:domain_success_ratio:rate5m and on (domain) crawlbase:domain_requests:rate5m > 0.008 - refId: B datasourceUid: __expr__ model: { refId: B, type: reduce, expression: A, reducer: last } - refId: C datasourceUid: __expr__ model: refId: C type: threshold expression: B conditions: - evaluator: { type: lt, params: [0.9] }
被拦截规则匹配 Normal token 上的 503|207|525|576|705。文档建议对 207 和 503 使用 JavaScript token;项目为较旧的响应保留 525,并加入观察到的 576 和 705 结果。这些追加是应用层策略,不是产品规则。持续的 403 仍然需要查看目标的响应和你的请求速率,而不是假定切换 token 就能解决每一种情况。
在实时运行中,Normal token 告警最先触发,针对 glassdoor.com。域名成功率告警在三分钟后触发,预算告警在其速率估算稳定后进入待触发状态。
仓库预置的是规则和严重级别标签,而不是 Slack、邮件、PagerDuty 或工单系统集成。请配置 Grafana 联络点和通知策略,把 severity=page 和 severity=ticket 路由到它们该去的地方。一个标签本身不会呼叫任何人。
孤立的 520、一次缓慢的响应或已知的 404 属于仪表盘,而不是值班呼叫。失败率、完成时间或必填字段覆盖率的持续变化才值得叫人处理。预置的规则把缺失数据视为正常,所以要单独监控导出器可用性和停止的作业。
Grafana 注解在诸如 $ 和 $labels.domain 这样的引用中使用单个 $values.B.Value。记录下来的 Grafana 11 行为会把双写的美元符号原样输出,因此仓库在测试阈值的同时也测试模板的写法。
把同一套指标扩展到 Crawler 队列和 token 路由
对于 Crawler,可选的统计轮询器读取 /crawler/<TOKEN>/stats,并导出账户总量以及每个 crawler 的等待数、并发数、延迟和暂停状态 gauge 指标。设置 CRAWLER_STATS_ENABLED=true 后,对账器按名义上 60 秒一次的调度尝试这些轮询,同步 API 调用可能会使其延后。
Crawler 与多 token 演练 还为每个 token 保留一个客户端,并通过配置的 JS_DOMAINS 集合把 Glassdoor 路由到 JavaScript token。在记录的运行中,JavaScript token 请求返回了干净的 200。操作人员在调查告警之后更新该集合;告警从不自行改写路由:
# Filled from the alert: Normal token gets cb_status 503/576/705 on these. JS_DOMAINS = {"glassdoor.com"} URLS = [ "https://example.com/", "https://www.glassdoor.com/Reviews/index.htm", ] def main() -> None: load_dotenv(find_dotenv(usecwd=True)) clients = { "normal": InstrumentedCrawlingAPI(os.environ["CRAWLBASE_NORMAL_TOKEN"], "normal"), "js": InstrumentedCrawlingAPI(os.environ["CRAWLBASE_JS_TOKEN"], "js"), } for url in URLS: token_type = "js" if domain_label(url) in JS_DOMAINS else "normal" r = clients[token_type].get(url) print(f"{token_type:<6} http={r.status_code} cb_status={r.headers.get('cb_status')} {url}")
两个客户端写入同一组指标族,只是 token_type 值不同,所以你可以在保留单一按域名视图的同时检查特定 token 的失败。Crawler 名称也应以同样的方式标识稳定的队列,而不是单个作业或 URL。
运行技术栈并验证集成
在仓库根目录下,用以下命令启动最终技术栈:
cd final cp .env.example .env # add CRAWLBASE_NORMAL_TOKEN and CRAWLBASE_JS_TOKEN docker compose up -d --build # Grafana: http://localhost:3000/d/crawlbase-observability (admin / admin)
最终环境变量模板 包含预算和可选的 Crawler 设置。根模板 为各演练文件夹提供 token,生成的 .env 文件被 git 忽略。最终技术栈默认发送 40 个请求,然后持续提供指标。成功的获取会消耗积分。在把技术栈暴露到你的机器之外之前,先设置 Grafana 密码。
README 描述了每个阶段和检查项。在发布的目录布局中,final/ 和 steps/ 直接位于根目录下;README 中仍带有 code/ 前缀的命令指的是更早的父级布局。
项目附带 319 个使用 pytest 和 respx 的离线测试,另有六个通过标记单独选择的实时测试。离线验证通过了全部 319 个测试以及演练中的 41 个计费测试。模拟 HTTP 的测试覆盖解析器、状态分类、指标更新、缓存行为和重启处理。
配置检查器 运行 Prometheus 配置和规则检查、合成规则测试以及仪表盘校验。冒烟测试 检查一个运行中技术栈的目标、导出的序列、记录规则、仪表盘、数据源和告警规则评估。实时探测, 样本捕获 和 面板查询脚本 揭示了本文数字背后的响应形态和查询。
结论
有用的抓取可观测性始于把三件事分开:抓取结果、目标站点的回答,以及你自己的验收策略。Crawlbase 提供获取信号,封装层记录它们,Prometheus 把它们变成窗口化指标,Grafana 让持续的变化可见。
这次实时运行说明了这些区分为何重要。有效的 404 获取要计费,一个受保护的目标产生了多个失败代码,客户端超时需要自己的核算,账户级统计与本地一小时视图不同。项目让所有这些都可以检视,而无需给每个页面打标签,也无需在爬取器内部嵌入一个监控平台。
用一小组有代表性的目标使用这份 完整实现,确认你的成功定义和积分假设,并把告警接入真实的值班响应。然后再去度量你的应用真正依赖的那些目标。
每个响应都携带 cb_status, original_status 和域名等级,让你的监控能区分被拦截的抓取和缺失的页面。失败的请求从不计费,Account API 为你提供本月按域名的总量。最多 5,000 次免费请求起步,无需信用卡。
常见问题(FAQ)
什么应该算作一次成功的抓取请求?
本项目把 cb_status=200 算作获取成功,并把每一次完成的尝试放入分母。目标返回 404 符合这个定义;空的 207 不符合。对于商品或文章管道,请为通过你的必填字段和内容检查的记录添加单独的指标。成功获取和可用的业务数据是两个不同的问题。
为什么 Account API 的成功率与 Grafana 的本地成功率不同?
本地汇总是本进程尝试的滚动一小时窗口。Account API 报告所选产品的月初至今活动,可能包含其他调用方,并且在记录的运行中比流量滞后了几分钟。在把差距解读为流量缺失或计费错误之前,先对齐范围。
仪表盘显示的是精确的剩余积分数吗?
当 Account API 返回 remainingCredits 时,对账器会导出这个余额,而它只对基于订阅的产品返回。否则,对账器从配置的预算中减去本月的成功请求数。这个回退和耗尽天数规则都假定每个请求一个积分。在把这个估算当作真实余额之前,先考虑渲染、域名倍率和共享的产品用量。
被拦截 token 的告警会自动用 JavaScript token 重试吗?
不会。客户端记录结果并把重试留给调用方。多 token 示例通过配置的集合路由域名,告警则告诉操作人员该查看哪里。更新该路由以及接通通知目的地都是明确的应用和运维决策。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和验证码,让您的团队专注于构建数据管道,而不是维护爬取基础设施。免费提供最多 5,000 次请求,无需绑定信用卡。

