用「热浏览器会话」爬取有反爬墙的电商网站
一位客户请我做一个听起来很简单的东西:每天把一个大电商平台上的二手手机列出来,按价格排序,好让他的进货决策靠数据而不是靠感觉。这位客户靠倒卖二手电子产品吃饭——一台手机差几百马币就是他的利润。
结果完全不是一段干净的 requests + BeautifulSoup 脚本。它变成了一场长达一周的、跟反爬墙的拉锯战,一个靠胶带勉强粘在一起的热浏览器会话,以及——我们俩都没想到的部分——一个关于「你不能靠堆更多 agent 来『并行化』爬虫」的惨痛教训。
这就是那个故事,按照我经历的顺序来讲:问题、我试过却失败的每一件事、最终奏效的办法,以及下次我会怎么做。
问题,用别人真正会搜索的方式表述
“如何从一个拦截无头浏览器的网站爬取商品列表?”
或者更诚实地说:当一个电商网站已经把「防爬」当成它唯一的本职工作,你要怎么爬它? Shopee 不会礼貌地把 HTML 喂给 curl。它会检测自动化、抛出一个 /verify 验证码,然后静默地给你一个「看起来正常但里面什么都没有」的页面。
客户的需求清单很短:
- 搜索二手手机,只筛选「二手」成色,存储 128 GB 及以上。
- 提取每个商品的所有存储规格和价格。
- 按价格升序排序。
- 不搞一百个 headless 实例的
chromedriver农场——倒卖的利润养不起一百个住宅 IP,我的也一样。
从这里开始,全部都是调试故事。
我试过的,以及为什么失败
尝试 1:通过 CDP 用冷的无头 Chrome——「直接把 Chrome 远程调试开起来」
我用 --headless --remote-debugging-port=9222 启动了 Chrome,用 Chrome DevTools Protocol 连上去,然后导航到搜索 URL。大约十秒钟里我觉得自己很聪明。
失败: 页面加载了,但每个商品卡片都是空的。DOM 是个空壳。Shopee 指纹识别了这个无头浏览器(没有真正的 GPU、user-agent 指纹不一致、navigator.webdriver 标志),把我重定向进了一个我永远过不去的 /verify 验证码循环。冷的无头 profile 一上来就撞墙。
教训很无聊但很重要:如果你的浏览器本身在「身份」上撒谎,选择器写得再好也没用。
尝试 2:并行子 agent——「直接开 N 个 worker」
这是我最想让它成功的方案。我有一套多 agent 的架构。本能反应就是:开五个 agent,每个爬一个不同的搜索词,收集结果,合并。
失败: 它们全都连到同一个浏览器会话上。它们不会各自拿到一个浏览器——只会各自拿到共享 Chrome 实例里的一个标签页,或者更糟:全都挤在同一个标签页上。每一次 Page.navigate 都会把上一个 agent 的上下文抹掉。而且关键在于:
- 热会话是共享资源。 只有一个没被标记的 Canary profile。五个 agent 交错访问同一个标签页,在 Shopee 眼里,跟一个浏览器疯狂快速导航没有区别。而这恰恰是触发反爬墙的行为。
- 反爬的「节奏」是累积的。 我的可用方案依赖导航之间约 7 秒的间隔来显得像真人。五个 agent 并发导航,就等于五个导航以零间隔打向同一个会话。
所以「横向扩展」的本能——加更多 worker——对我赖以存活的那件事(看起来像真人)是有害的。这里的并发上限不是 CPU 也不是内存,而是一个热浏览器,一次一个标签页。
尝试 3:靠「Buy With Voucher」按钮来拿价格
一旦列表能渲染出来了,我假设购买按钮的文案里带着价格。大多数电商网站确实如此。但这个渲染出来的按钮写的是 Add To Cart / Buy Now。我用来匹配「Buy With Voucher … RM…」的正则什么都匹配不到,我在原地打转了好一会儿,才真正去读了 DOM。
失败: Shopee 商品页的价格不在按钮里。它被渲染成拆分的 span——RM 在一个元素里、850 在另一个里、00 又在另一个里——所以朴素的叶子文本匹配只会抓到碎片,或者当商品有多个规格时,抓到一个区间(RM850.00 - RM1150.00)。
尝试 4:点击规格选项来拿精确价格
对于单轴规格选择器(比如一个存储下拉:128GB / 256GB / 512GB),点击一个选项会把价格收敛成单个值,我的提取方法能正常工作。
失败: 对于双轴选择器(品牌 × 存储),点击第一个轴(某个品牌)会锁定一个价格,但加上第二个轴(某个存储规格)又把它解散回商品全区间。这个面板渲染的是「所有可购买组合」的区间,而不是我选中的那个具体组合;而且「已选中」状态是用一个「同时匹配其他所有选项框」的 class 来标记的。我没法可靠地知道我到底锁定了哪个组合。
我在这个上面花了实打实的时间,最后才接受这个诚实的答案:对于真正的双轴商品,正确的数据是价格区间,而不是一个瞎猜的「每个组合」数字。 发布一个「有时候能用」的脆弱覆盖方案,比老实报告一个区间更糟糕。
修复——真正奏效的东西
三块,都很小,都是吃了亏才学会的。
1. 热的人性化节奏的浏览器会话(地基)
别跟墙硬刚。用一个已经登录过、正常浏览过的浏览器 profile——一个「热」profile。用真实的 7 秒间隔来导航。它更慢,但它是唯一能稳定活下来的方式。
# 不是真正的代码——是它的「形状」。先导航,然后像真人一样等待。
goto_url(target)
time.sleep(7) # 真人节奏,不可妥协
wait_for_selector(".shopee-search-item-result__item")
2. 失败即止,别傻等超时
在任何爬取之前,先打 CDP 的 HTTP 端点,检查会话状态。如果 Canary 挂掉了、或标签页停在 /verify 上,就在约 1 秒内中止,而不是在「怎么还不渲染」的超时里浪费 40 秒。
import json, urllib.request
def session_healthy():
try:
tabs = json.load(urllib.request.urlopen("http://127.0.0.1:9222/json"))
except Exception:
return False
for t in tabs:
if t.get("type") == "page" and "/verify" in t.get("url", ""):
return False # 撞到验证码墙——重新热会话,别爬了
return True
就这么一个改动,把「静默的 40 秒死胡同」变成了「即时、可执行的失败」。
3. 正确的价格选择器:最深的纯 RM 节点
购买按钮是个谎言。价格是那个文本内容纯粹是一个 RM 值的、最深的元素——在选中规格之前是一个区间(RM850.00 - RM1150.00),之后是单个值(RM850.00)。遍历 DOM,取文本能匹配上的最深元素:
import re
RM = re.compile(r"^RM\s?[\d,.]+(\s?[-–]\s?RM\s?[\d,.]+)?$")
def find_price(root):
best, depth = None, -1
for el in root.querySelectorAll("*"):
txt = (el.textContent or "").strip()
if txt and RM.match(txt) and " " not in txt.split("RM")[0]:
d = depth_of(el)
if d > depth:
best, depth = el, d
return best.textContent.strip() if best else None
这就是全部的诀窍。没有脆弱的 class 名。没有瞎猜。一个「整个文本就是一个价格」的节点,就是价格本身。
结果——一个可量化的成果
从单个热会话、一次串行遍历中,我提取了 20+ 个二手手机商品及其各规格价格,筛选到 128 GB 及以上,按价格升序排序。最便宜的真实目标——一台 128GB 机型——以 RM299 的价格浮出水面,另外还有几台 128/256GB 的选项在 RM450–880 区间。现在一条命令(sweep.py <url1> <url2> ...)就能重新检查整份候选清单并输出 JSONLines;客户每天进货前跑一次。
但我最庆幸的数字是这个:零验证码。 「热会话 + 人性化节奏 + 失败即止」这套组合,在整次遍历中一次都没触发过 /verify。
下次我会怎么做
- 我会一开始就写「失败即止」的健康检查,在任何爬取代码之前。三十秒的活,换来未来每次失败省四十秒。
- 我会从第一天就抵抗并行化的冲动。 「爬虫太慢」的答案是把更多工作打包进一次串行遍历(批量搜索、对多个 URL 跑
sweep.py),而不是「再加 worker」。共享的浏览器状态让并发变得更糟,而不是更好。 - 我会立刻接受双轴的限制,老实报告区间,而不是在一个根本没法可靠化的选择器上烧一个下午。
- 我会别再猜 DOM 结构,先去探测它。 几乎每一个错误假设(URL 路径里
/i.vs-i.、把Buy With Voucher当价格锚点、「过滤器是个真正的<input>复选框」)都来自没有先读真实的 DOM。
可复用的部分
所有这一切被做成了一个小的独立工具集——每个脚本都是一次自包含的 python x.py ... 调用:
| 脚本 | 作用 |
|---|---|
search.py | 粗粒度搜索 → 商品卡片 |
used_search.py | 点击「二手」条件复选框,校验 URL 里的 USED_ITEM,再提取 |
detail.py | 从商品页提取各规格价格(最深 RM 节点法 + 分段等待) |
sweep.py | 一次会话内对 N 个 URL 做批量详情提取,按 item-id 去重,写 JSONLines,打印表格 |
cdp_common.py | 共享的健康检查 + 导航辅助 |
真正可复用的教训不是那些 Shopee 专属的选择器——而是姿态:假设页面在 DOM 上撒谎,失败即止,用批量化而不是并行 worker 来扩展。
这是一次真实的调试过程,不是事后补写的教程。这个爬虫工具集、以及这里的每一次失败,都发生在为一个做二手电子产品倒卖的客户构建它的过程中——我保留了能泛化的部分,砍掉了那些只对某个马来西亚电商网站、某个时间点有效的部分。
想为你的业务做这个吗?
我做定制爬虫和数据提取管道——热会话 CDP 爬虫、反爬墙感知的采集器、批量价格监控扫描——也做全栈 Web 应用(PHP/CodeIgniter、Java/Spring、React/TypeScript)和自托管自动化(n8n、Telegram 机器人、NAS 上的 Docker)。
如果你需要从某个「会反抗」的网站拿商品或价格数据,直接点进来,告诉我你想提取什么:
- 💬 WhatsApp — 直接私信我
- ✉️ Email — 告诉我网站和数据
- 🌐 hoelee.com — 看看我还做了些什么