posts: reframe marketplace scraper as client work (used-electronics reseller), en+zh + OG image
Deploy / build (push) Successful in 18s

This commit is contained in:
2026-09-14 00:09:45 +08:00
parent 4cf52e04c1
commit 12529e0cd0
4 changed files with 27 additions and 27 deletions
@@ -1,6 +1,6 @@
---
title: "用「热浏览器会话」爬取有反爬墙的电商网站"
description: "我如何用热 headless-Chrome 会话通过 CDP 爬取 Shopee 的反爬商品列表——失败即止的健康检查、分段等待渲染,以及我为什么拒绝并行化处理。"
description: "为一位做二手手机生意的客户:爬取有反爬墙的电商商品列表——热 CDP 会话、失败即止的健康检查、分段等待渲染。"
pubDate: 2026-09-13
category: engineering
tags: ["scraping", "cdp", "headless-chrome", "anti-bot", "shopee", "python"]
@@ -8,9 +8,9 @@ ogImage: "/og/scraping-bot-walled-marketplace-warm-browser-session.png"
draft: false
---
我一开始想做一件听起来很简单的事:把一个电商网站上所有的二手 M.2 NVMe SSD 都列出来,按价格排序,好让我买到最便宜的那一个。
一位客户请我做一个听起来很简单的东西:每天把一个大电商平台上的二手手机列出来,按价格排序,好让他的进货决策靠数据而不是靠感觉。这位客户靠倒卖二手电子产品吃饭——一台手机差几百马币就是他的利润。
结果完全不是一段干净的 `requests` + BeautifulSoup 脚本。它变成了一场长达一周的、跟反爬墙的拉锯战,一个靠胶带勉强粘在一起的热浏览器会话,以及——最让我意外的部分——一个关于「你不能靠堆更多 agent 来『并行化』爬虫」的惨痛教训。
结果完全不是一段干净的 `requests` + BeautifulSoup 脚本。它变成了一场长达一周的、跟反爬墙的拉锯战,一个靠胶带勉强粘在一起的热浏览器会话,以及——我们俩都没想到的部分——一个关于「你不能靠堆更多 agent 来『并行化』爬虫」的惨痛教训。
这就是那个故事,按照我经历的顺序来讲:问题、我试过却失败的每一件事、最终奏效的办法,以及下次我会怎么做。
@@ -20,12 +20,12 @@ draft: false
或者更诚实地说:**当一个电商网站已经把「防爬」当成它唯一的本职工作,你要怎么爬它?** Shopee 不会礼貌地把 HTML 喂给 `curl`。它会检测自动化、抛出一个 `/verify` 验证码,然后静默地给你一个「看起来正常但里面什么都没有」的页面。
我的需求清单很短:
客户的需求清单很短:
- 搜索「二手 SSD」,只筛选「二手」,只筛选 M.2 NVMe(排除 SATA/mSATA),只筛选 ≥128 GB。
- 提取每个商品的所有容量规格和价格。
- 搜索二手手机,只筛选「二手」成色,存储 128 GB 及以上。
- 提取每个商品的所有存储规格和价格。
- 按价格升序排序。
- 不搞一百个 headless 实例的 `chromedriver` 农场,因为我没有一百个住宅 IP。
- 不搞一百个 headless 实例的 `chromedriver` 农场——倒卖的利润养不起一百个住宅 IP,我的也一样。
从这里开始,全部都是调试故事。
@@ -54,13 +54,13 @@ draft: false
一旦列表能渲染出来了,我假设购买按钮的文案里带着价格。大多数电商网站确实如此。但这个渲染出来的按钮写的是 `Add To Cart` / `Buy Now`。我用来匹配「Buy With Voucher … RM…」的正则什么都匹配不到,我在原地打转了好一会儿,才真正去读了 DOM。
**失败:** Shopee 商品页的价格不在按钮里。它被渲染成**拆分的 span**——`RM` 在一个元素里、`84` 在另一个里、`00` 又在另一个里——所以朴素的叶子文本匹配只会抓到碎片,或者当商品有多个规格时,抓到一个**区间**(`RM125.00 - RM155.00`)。
**失败:** Shopee 商品页的价格不在按钮里。它被渲染成**拆分的 span**——`RM` 在一个元素里、`850` 在另一个里、`00` 又在另一个里——所以朴素的叶子文本匹配只会抓到碎片,或者当商品有多个规格时,抓到一个**区间**(`RM850.00 - RM1150.00`)。
### 尝试 4:点击规格选项来拿精确价格
对于单轴规格选择器(比如一个容量下拉:128GB / 256GB / 512GB),点击一个选项会把价格收敛成单个值,我的提取方法能正常工作。
对于单轴规格选择器(比如一个存储下拉:128GB / 256GB / 512GB),点击一个选项会把价格收敛成单个值,我的提取方法能正常工作。
**失败:** 对于**双轴**选择器(品牌 × 容量),点击第一个轴(某个品牌)会锁定一个价格,但加上第二个轴(某个容量)又把它**解散**回商品全区间。这个面板渲染的是「所有可购买组合」的区间,而不是我选中的那个具体组合;而且「已选中」状态是用一个「同时匹配其他所有选项框」的 class 来标记的。我没法可靠地知道我到底锁定了哪个组合。
**失败:** 对于**双轴**选择器(品牌 × 存储),点击第一个轴(某个品牌)会锁定一个价格,但加上第二个轴(某个存储规格)又把它**解散**回商品全区间。这个面板渲染的是「所有可购买组合」的区间,而不是我选中的那个具体组合;而且「已选中」状态是用一个「同时匹配其他所有选项框」的 class 来标记的。我没法可靠地知道我到底锁定了哪个组合。
我在这个上面花了实打实的时间,最后才接受这个诚实的答案:**对于真正的双轴商品,正确的数据是价格区间,而不是一个瞎猜的「每个组合」数字。** 发布一个「有时候能用」的脆弱覆盖方案,比老实报告一个区间更糟糕。
@@ -101,7 +101,7 @@ def session_healthy():
### 3. 正确的价格选择器:最深的纯 RM 节点
购买按钮是个谎言。价格是**那个文本内容纯粹是一个 RM 值的、最深的元素**——在选中规格之前是一个区间(`RM125.00 - RM155.00`),之后是单个值(`RM84.00`)。遍历 DOM,取文本能匹配上的最深元素:
购买按钮是个谎言。价格是**那个文本内容纯粹是一个 RM 值的、最深的元素**——在选中规格之前是一个区间(`RM850.00 - RM1150.00`),之后是单个值(`RM850.00`)。遍历 DOM,取文本能匹配上的最深元素:
```python
import re
@@ -123,7 +123,7 @@ def find_price(root):
## 结果——一个可量化的成果
从单个热会话、一次串行遍历中,我提取了 **20+ 个二手 NVMe 商品及其各规格价格**,筛选到 M.2 NVMe ≥128GB,按价格升序排序。最便宜的真实目标——一块 256GB M.2 NVMe——以 **RM84** 的价格浮出水面,另外还有几块 256GB 的选项在 RM125–175 区间。现在一条命令(`sweep.py <url1> <url2> ...`)就能重新检查整份候选清单,并输出 JSONLines。
从单个热会话、一次串行遍历中,我提取了 **20+ 个二手手机商品及其各规格价格**,筛选到 128 GB 及以上,按价格升序排序。最便宜的真实目标——一台 128GB 机型——以 **RM299** 的价格浮出水面,另外还有几台 128/256GB 的选项在 RM450–880 区间。现在一条命令(`sweep.py <url1> <url2> ...`)就能重新检查整份候选清单并输出 JSONLines;客户每天进货前跑一次。
但我最庆幸的数字是这个:**零验证码。** 「热会话 + 人性化节奏 + 失败即止」这套组合,在整次遍历中一次都没触发过 `/verify`。
@@ -150,7 +150,7 @@ def find_price(root):
---
*这是一次真实的调试过程,不是事后补写的教程。这个爬虫工具集、以及这里的每一次失败,都发生在我真的为了买一块便宜 SSD 而爬取的时候——我保留了能泛化的部分,砍掉了那些只对某个马来西亚电商网站、某个时间点有效的部分。*
*这是一次真实的调试过程,不是事后补写的教程。这个爬虫工具集、以及这里的每一次失败,都发生在为一个做二手电子产品倒卖的客户构建它的过程中——我保留了能泛化的部分,砍掉了那些只对某个马来西亚电商网站、某个时间点有效的部分。*
---