教程停在 200,生产从下一步开始
用 Python 拿到亚马逊第一份数据只要十几行。让脚本连续三十天每天产出干净数据,要补的是另外八个环节。多数教程停在请求返回 200、字段打印出来那一刻,那一课结束时的表格很工整,故事才刚开始。
让脚本失效的不是反爬,是 #productTitle 某天不再存在而代码照常返回成功、第二批数据混进重复行、以及 429 之后那段 time.sleep(3) 把失败藏了起来。本文用一套可运行 Python,把「第一次成功」到「每天可用」之间的缺口逐个补上。
关于停掉自建爬虫后还剩多少维护量,这篇笔记讲了另一面。下面八节按边界划分:前三节解决「拿到的是不是我要的那个东西」,中间三节解决「能不能稳定持续拿到」,最后两节解决「这笔投入值不值、出问题谁先知道」。笔记本脚本错了改一行三十秒,pandas 里混了三周重复行,代价是追回所有下游报表,且你未必知道哪些报表用过这批数据。
为什么这样写:先点明失效都藏在非报错处,后面八节才有存在的理由。
请求层:超时、凭据、退避都收进一个类
第一段代码不写业务逻辑,先把三件容易散落各处的事收拢:超时从哪里来、密钥放哪里、重试由谁负责。散落的结果是某个深夜任务用了默认超时,挂住整个调度。
# pip install httpx
import os, time, random
import httpx
BASE_URL = os.environ["AMZ_API_BASE"] # 换成供应商域名
API_KEY = os.environ["AMZ_API_KEY"] # 密钥只从环境变量读
ENDPOINTS = { # 端点集中维护,改版只改这一处
"product": "/v1/amazon/product",
"search": "/v1/amazon/search",
"review": "/v1/amazon/reviews",
}
TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=5.0, pool=10.0)
class AmazonDataClient:
def __init__(self, base_url: str = BASE_URL, key: str = API_KEY) -> None:
self._http = httpx.Client(
base_url=base_url,
timeout=TIMEOUT,
headers={
"Authorization": f"Bearer {key}",
"User-Agent": "amz-pipeline/1.0 (+ops@example.com)",
},
)
def get_json(self, endpoint: str, params: dict, attempts: int = 3) -> dict:
path = ENDPOINTS[endpoint]
last = None
for attempt in range(1, attempts + 1):
try:
resp = self._http.get(path, params=params)
except httpx.TransportError as exc: # 连接层问题,可重试
last = exc
else:
if resp.status_code < 400:
return resp.json()
if resp.status_code not in (429, 500, 502, 503, 504):
raise ValueError(f"{endpoint} rejected: {resp.status_code} {resp.text[:200]}")
last = ValueError(f"HTTP {resp.status_code}")
time.sleep(min(2 ** attempt, 30) + random.uniform(0, 1)) # 退避 + 抖动
raise RuntimeError(f"{endpoint} failed after {attempts} attempts") from last
超时被拆成连接、读取、写入、连接池四段,读取给 30 秒是因为渲染型端点要等异步加载;退避之后加抖动,避免几十个并发任务同一秒集体重试;User-Agent 留联系邮箱,供应商封禁或限流时能找到人。散落的典型代价还有密钥硬编码进仓库,离职人员带走过期 token 没人发现;退避写在业务函数里,换个端点就忘了加。收进一个类之后,这些风险点变成一处可读、可测、可审计的代码,半夜告警也能定位到同一文件。密钥从环境变量读还有一层好处:轮换不用发版,改云平台的 secret 即可,凭据泄露面收在平台侧。
为什么这样写:把可变配置收进一个类,改版只改一处,排障有单点。
字段契约:先约定词叫什么
拿到字典不要直接入库。中间放一层模型,承担两个职责:把供应商字段名映射成内部名,以及在版本变化时让旧数据自动失效。Pangolinfo 亚马逊数据采集 API 的端点字典在这里可以直接复用,不用再接一层渲染开关。
from dataclasses import dataclass
from datetime import date
from typing import Optional
CONTRACT_VERSION = "2026-09-01" # 字段增减必须改这里
@dataclass(frozen=True)
class ProductRow:
contract_version: str
asin: str
marketplace: str
captured_at: date
title: Optional[str]
price: Optional[float]
currency: Optional[str]
rating: Optional[float]
review_count: Optional[int]
is_sponsored_slot: bool
P0_FIELDS = ("title", "price", "currency") # 缺任何一个,这条记录不可用
def to_row(raw: dict, marketplace: str, captured_at: date) -> ProductRow:
return ProductRow(
contract_version=CONTRACT_VERSION,
asin=raw["asin"],
marketplace=marketplace,
captured_at=captured_at,
title=(raw.get("title") or "").strip() or None,
price=parse_price(raw.get("price")),
currency=raw.get("currency"),
rating=float(raw["rating"]) if raw.get("rating") else None,
review_count=int(raw["review_count"]) if raw.get("review_count") else None,
is_sponsored_slot=bool(raw.get("sponsored", False)),
)
P0_FIELDS 这行是整套代码的收益来源:它把「数据好不好」从主观判断变成可写进 CI 的断言。contract_version 进主键后,升级字段定义会自动触发历史数据重跑,不必担心新旧两种口径混在同一张表。供应商字段改名是常态,今天返回 title,下个版本可能叫 productTitle。to_row 把映射写死在函数里,外部只认内部名,上游抖动不穿透到下游表结构。版本号进主键也意味着同一 ASIN 在新旧契约下是两行,比对差异只是一条 SQL。端点字典直接可用,换供应商时只改 BASE_URL 一处,接入层不堆渲染开关。
为什么这样写:字段名对齐和版本失效属于定义权,任何 API 都不能替业务侧决定。
质量门:覆盖率与填充率分开算
字段存在不等于字段有值。覆盖率衡量结构里有没有这个路径,填充率衡量实际交付时取没取到。两者差距靠肉眼难发现,因为它混在正常数据里。
def quality_gate(rows: list[ProductRow], min_fill: float = 0.95) -> dict:
n = len(rows) or 1
report = {}
for field in P0_FIELDS:
filled = sum(1 for r in rows if getattr(r, field) is not None)
report[field] = round(filled / n, 4)
report["usable"] = all(report[f] >= min_fill for f in P0_FIELDS)
return report
# 用法:gate = quality_gate(rows)
# 返回 {'title': 1.0, 'price': 0.61, 'currency': 1.0, 'usable': False}
# 看到 price 掉到 0.61 就该停下手上的活去查,而不是继续往库里写
这段检查要跑在入库之前,不是跑在报表里。判断顺序反过来,脏数据进库后清理成本高出数倍。供应商计费系统把返 200 记作成功,业务侧把字段为空记作缺失,两套账对不上,唯一的办法是自己的管道给出第三种定义。《数据管道里剩下的自建部分》那篇按层拆过这个定义。质量门返回的不仅是可用与否,还有每个字段的逐行填充率。把这组数字按天画曲线,能在字段整体消失前看到缓慢下滑。min_fill 取 0.95 允许 5% 稀疏,超过即拦截,这个值按业务定,价格类字段通常比评论数更严格。P0 断言进 CI 之后,字段缺失能在合入阶段就红灯,而不是流到报表里。
为什么这样写:质量门放在写库前拦截,成本比事后清理低一个数量级。
翻页与去重:先定主键
先把键定下来:(asin, marketplace, captured_at, contract_version)。这四个字段唯一决定一条记录,重跑同一天只覆盖同一行,不追加。定键之前谈翻页是浪费时间。
def collect_search(client, keyword: str, marketplace: str, max_pages: int = 20):
seen, rows = set(), []
for page in range(1, max_pages + 1):
payload = client.get_json("search", {"q": keyword, "page": page, "marketplace": marketplace})
items = payload.get("results", [])
if not items:
break
new = 0
for raw in items:
item = to_row(raw, marketplace, date.today())
key = (item.asin, item.marketplace, item.captured_at, item.contract_version)
if key in seen:
continue
seen.add(key)
rows.append(item)
new += 1
if new == 0: # 整页都是重复,说明到底了
break
return rows
翻页有两个现实约束。深度限制:单一搜索词通常翻不到二十页后,要更深只能按类目、价格区间或品牌拆词,把任务切成并行子任务。跨页重复:同一商品在相邻两页重复出现是常态,尤其广告位之外的自然结果。去重率某天从 3% 涨到 40%,多半不是亚马逊改排序,而是去重键写错或分页参数失效。拆词并行带来任务数暴涨,二十品牌乘十价格区间就是两百子任务,并发与去重配合变关键:去重键错了,并行只产更多重复行反而推高成本。去重率因此也是并发健康度的侧面指标,异常上升先查键与分页参数。
为什么这样写:主键先于翻页,幂等重跑才有保障,重复检测才成立。
失败四类:别用一个 except 全重试
except Exception: retry 是最贵的一行代码。它把参数错误、鉴权失败、配额耗尽、网络抖动混成一件事,参数错误也重试三次,再带着 429 让整批排队。
四类处置:瞬时故障(连接超时、502/503/504)退避重试;限流(429 带 Retry-After)按头里的秒数等;契约错误(400、缺参、密钥失效)直接失败告警不重试;内容缺失(200 但 P0 为空)入库前拦截进死信队列。
第三类出现就停整批任务,重试只耗额度;第四类靠质量门拦,不能指望状态码。attempts 计数必须写进日志——失败那次在多数供应商那里也计费,直接出现在月底账单。把四类写进结构化日志,月底能按类聚合成表:瞬时故障占多少、限流占多少、契约错误占多少。契约错误占比异常上升多半是参数构造有 bug 而非上游问题。这张表是和供应商对账与优化调用方式的依据。内容缺失走死信队列后,这批记录可单独复核,不阻塞整批,也不污染可用集合。
为什么这样写:失败分类决定重试与否,混写等于拿配额买单却没产出。
并发:信号量而非 random.sleep
time.sleep(random.uniform(1, 3)) 把任务变慢,没把速率控住。随机延时下实际 QPS 随并发数波动,撞上限流只是时间问题。
import asyncio, httpx
async def fetch_all(client: httpx.AsyncClient, jobs: list[dict], concurrency: int = 8):
sem = asyncio.Semaphore(concurrency)
results = []
async def one(job):
async with sem:
for attempt in range(1, 4):
try:
r = await client.get(ENDPOINTS[job["endpoint"]], params=job["params"])
except httpx.TransportError:
await asyncio.sleep(2 ** attempt)
continue
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 5))
await asyncio.sleep(wait)
continue
if r.status_code < 400:
results.append((job, r.json()))
return
if r.status_code not in (500, 502, 503, 504):
raise RuntimeError(f"job {job} rejected: {r.status_code}")
raise RuntimeError(f"job {job} exhausted retries")
await asyncio.gather(*[one(j) for j in jobs])
return results
并发数从 4 起步,观察 429 比例与端到端时延两条曲线再往上调。异步在这里只把等待时间用来做别的事,不是让请求变多;上游速率额度是每秒 10 次,八个和十六个并发的总量天花板一样。信号量值不是越大越好。超过上游速率额度后排队时间拉长、端到端时延上升,单位时间产出却不变,账单因重试与超时消耗悄悄增加。观察曲线到拐点停手,是 FinOps 里最朴素的动作,加并发前先看 429 比例。并发从 4 起步这条经验值,给的是让曲线先跑平再加压,避免一上来就触发限流。
为什么这样写:信号量给的是确定的并发上限,随机 sleep 给的是不确定的抖动。
落地:快照表不是覆盖写
存当前价格那一刻也丢掉最有价值的信息:价格什么时候变。把每次运行结果当快照追加,主键带 captured_at,历史自然留在表里。
# pip install duckdb
import duckdb
con = duckdb.connect("amazon.duckdb")
con.execute("""
CREATE TABLE IF NOT EXISTS product_snapshot (
asin VARCHAR, marketplace VARCHAR, captured_at DATE,
contract_version VARCHAR, title VARCHAR, price DOUBLE,
currency VARCHAR, rating DOUBLE, review_count INTEGER,
is_sponsored_slot BOOLEAN,
PRIMARY KEY (asin, marketplace, captured_at, contract_version)
)
""")
con.executemany(
"INSERT OR REPLACE INTO product_snapshot VALUES (?,?,?,?,?,?,?,?,?,?)",
[(r.asin, r.marketplace, r.captured_at, r.contract_version, r.title,
r.price, r.currency, r.rating, r.review_count, r.is_sponsored_slot) for r in rows],
)
INSERT OR REPLACE 四个字符解决「任务失败是否会污染数据」:同一天重跑覆盖同一批主键,不留半旧半新的中间态。变更检测因此变简单——窗口函数比对相邻两次快照,就输出「今天哪些 ASIN 调过价」。快照表选 duckdb 只是示例,同等结构落到 PostgreSQL 或云数仓也成立,主键定义不变。关键在 captured_at 进主键让历史成为表的一等公民,而非另一张归档表。变更检测、趋势计算都建在这层之上,不另写同步任务。快照表的 captured_at 进主键,也让按月趋势查询无需 join 历史归档,查询形态简单。
为什么这样写:快照而非覆盖,失败后敢重跑,历史自带不另建表。
成本埋点:每千条可用记录成本
账单告诉你上月花多少,不告诉你哪段代码花的。把计数埋进客户端,每批次结束输出一条指标,月底对账不必翻后台。
class MeteredClient(AmazonDataClient):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.calls = self.usable_rows = self.credits = 0
def get_json(self, endpoint, params, attempts=3):
self.calls += 1
data = super().get_json(endpoint, params, attempts)
self.credits += data.get("_meta", {}).get("credits", 1)
return data
def cost_per_1k_usable(self, usable_rows: int) -> float:
if not usable_rows:
return float("inf")
return self.credits * 0.0015375 * 1000 / usable_rows # 每积分单价见定价页
分子是累计积分,分母是通过质量门的记录数,不是拿到手的行数。两者差距正是前九节在处理的:重复项、空字段、被重试消耗的请求。定价与积分倍率给出分子的一部分,分母只在你自己的日志里。接口报价和真实账单之间的落差,这篇笔记有具体演算。埋点输出建议写两条:每条调用累计积分,每批次结束算一次每千条成本。前者进明细,后者进监控。两者结合能回答「这周单价为何比上周高」——是重试变多还是填充率掉了,定位到具体环节。计量客户端还顺带输出调用次数,能从同一份日志里拆出每端点的消耗占比。
为什么这样写:成本分母取可用记录,重复与空值才会直接反映到单价上。
四个指标两条告警
指标不用多,四个足够,且全部能从已有代码取到:P0 字段填充率(质量门)、平均尝试次数(attempts)、单任务跨页重复率(去重计数)、每千条可用记录成本(埋点)。
两条告警:任一 P0 字段填充率跌破阈值;每千条可用记录成本环比涨 30% 以上。前者是数据质量事故,后者是预算事故,两者都先于任何投诉出现在日志里。
上线前自查五行:超时重试是否集中一类;P0 是否有一处硬断言;主键是否含采集日期与契约版本;失败是否分四类而非一个 except;每次调用是否计了数与额。少一行,第三周起的报表就会悄悄偏。指标接入监控后,告警阈值要和业务方对齐而非工程自拍。P0 填充率与可用记录成本阈值最好由数据消费方确认,否则告警要么天天响要么从不响。上线前五行自查一次性,这四指标两条告警是长期的。
为什么这样写:四个指标两条告警覆盖质量与预算两条事故线,无需更多。
补充:四道反爬的门,以及一次请求包含什么
前几节讲的是拿到数据之后怎么管。这里补一个更容易被跳过的前置问题:请求本身能不能被当成正常流量。四道门按顺序是 TLS 与 HTTP/2 指纹、浏览器指纹一致性、IP 类型与信誉、请求节奏与会话的混淆。
**第一道门最容易踩。**判定发生在 TLS 握手阶段,不是 HTTP 层。requests 基于 urllib3 与 OpenSSL,ClientHello 里密码套件与扩展的顺序组合,和任何版本的 Chrome 都对不上,风控按已知客户端指纹库一比对就命中。这也解释了为什么换 UA、换代理、降频率都无效,得换客户端:
from curl_cffi import requests
r = requests.get("https://www.amazon.com/dp/B08N5WRWNW",
impersonate="chrome", # 带出配套的 UA 与 header 顺序
headers={"Accept-Language": "en-US,en;q=0.9"},
timeout=25)
impersonate 会带出配套的 UA、Sec-Fetch-* 与 header 顺序,不要手动覆盖 UA;Accept-Language 跟着站点走,amazon.de 用 de-DE。验收也别用「能返回数据」当标准,去公开指纹端点打一次,确认 ja4 与目标浏览器版本对得上。
**第二道门只在用无头浏览器时存在。**破绽是 navigator.webdriver、Canvas 与 WebGL、字体列表、屏幕与像素比、时区与语言这几项互相矛盾。原则是五件套同源,不是逐项追求最新。UA 说 Windows 而时区是 UTC,比版本旧更可疑。
**第三道门是 IP。**数据中心 IP 在 ASN 层面就被归类为服务器流量,强保护页面上成功率常在一到四成;住宅 IP 来自 ISP,移动 IP 走运营商 CGNAT,成功率明显更高。商品页、搜索页、评论页建议住宅或移动出口;无风控的自建接口与防护很轻的独立站,数据中心 IP 就够。
**第四道门是节奏。**固定间隔和均匀随机一样好认,人的访问间隔是长尾分布,还会成簇出现。每 IP 每分钟请求数、单会话连续请求数、任务启动时间分布,这三个参数比间隔本身更重要。
四道门加上前面八节,就是自建路线的全部工作量。Pangolinfo Amazon Scraper API把四道门收在服务端:住宅与移动 IP 的出口和轮换、TLS 与 HTTP/2 指纹、浏览器指纹一致性、需要时的 JS 渲染、验证页的处理与重试、地域与邮区对齐,都包含在一次请求的价格里,不拆成「住宅 IP 加价」「渲染倍率」这类加价项。你发一个请求,收一份结构化 JSON。