posts: reframe marketplace scraper as client work (used-electronics reseller), en+zh + OG image
Deploy / build (push) Successful in 18s
Deploy / build (push) Successful in 18s
This commit is contained in:
@@ -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 而爬取的时候——我保留了能泛化的部分,砍掉了那些只对某个马来西亚电商网站、某个时间点有效的部分。*
|
||||
*这是一次真实的调试过程,不是事后补写的教程。这个爬虫工具集、以及这里的每一次失败,都发生在为一个做二手电子产品倒卖的客户构建它的过程中——我保留了能泛化的部分,砍掉了那些只对某个马来西亚电商网站、某个时间点有效的部分。*
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user