用「热浏览器会话」爬取有反爬墙的电商网站

· 2 min read · engineering scrapingcdpheadless-chromeanti-botshopeepython

一位客户请我做一个听起来很简单的东西:每天把一个大电商平台上的二手手机列出来,按价格排序,好让他的进货决策靠数据而不是靠感觉。这位客户靠倒卖二手电子产品吃饭——一台手机差几百马币就是他的利润。

结果完全不是一段干净的 requests + BeautifulSoup 脚本。它变成了一场长达一周的、跟反爬墙的拉锯战,一个靠胶带勉强粘在一起的热浏览器会话,以及——我们俩都没想到的部分——一个关于「你不能靠堆更多 agent 来『并行化』爬虫」的惨痛教训。

这就是那个故事,按照我经历的顺序来讲:问题、我试过却失败的每一件事、最终奏效的办法,以及下次我会怎么做。

问题,用别人真正会搜索的方式表述

“如何从一个拦截无头浏览器的网站爬取商品列表?”

或者更诚实地说:当一个电商网站已经把「防爬」当成它唯一的本职工作,你要怎么爬它? Shopee 不会礼貌地把 HTML 喂给 curl。它会检测自动化、抛出一个 /verify 验证码,然后静默地给你一个「看起来正常但里面什么都没有」的页面。

客户的需求清单很短:

从这里开始,全部都是调试故事。

我试过的,以及为什么失败

尝试 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 的上下文抹掉。而且关键在于:

  1. 热会话是共享资源。 只有一个没被标记的 Canary profile。五个 agent 交错访问同一个标签页,在 Shopee 眼里,跟一个浏览器疯狂快速导航没有区别。而这恰恰是触发反爬墙的行为。
  2. 反爬的「节奏」是累积的。 我的可用方案依赖导航之间约 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

下次我会怎么做

  1. 我会一开始就写「失败即止」的健康检查,在任何爬取代码之前。三十秒的活,换来未来每次失败省四十秒。
  2. 我会从第一天就抵抗并行化的冲动。 「爬虫太慢」的答案是把更多工作打包进一次串行遍历(批量搜索、对多个 URL 跑 sweep.py),而不是「再加 worker」。共享的浏览器状态让并发变得更糟,而不是更好。
  3. 我会立刻接受双轴的限制,老实报告区间,而不是在一个根本没法可靠化的选择器上烧一个下午。
  4. 我会别再猜 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)。

如果你需要从某个「会反抗」的网站拿商品或价格数据,直接点进来,告诉我你想提取什么:

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.