# SCP Auctions 拍卖数据爬虫
抓取 **SCP Auctions**([catalogs.scpauctions.com](https://catalogs.scpauctions.com/auctions/past))历史拍卖会的已售/流拍拍品数据,含拍卖会信息、拍品列表与拍品详情。提供 **历史全量** 与 **每周新增** 两个任务。
- URL:https://catalogs.scpauctions.com/auctions/past
- 平台:**Bidsquare** 托管(Apache + 服务端渲染 SSR,无 Cloudflare)
- 语言 / 环境:Python 3.12.10
- 请求 / 解析:`curl_cffi`(浏览器指纹)+ `parsel`
---
## 1. 目标数据
| 层级 | 来源页 | 抓取字段 |
|---|---|---|
| 拍卖会 | `/auctions/past` | 标题、类型、状态、开始/结束时间、主页链接、图录链接、封面图、**event_id(去重)** |
| 拍品列表 | 图录页 `catalog` | Lot 号、标题、详情链接、缩略图、出价数、成交状态、成交价、**item_id(去重)** |
| 拍品详情 | `/online-auctions/...` | 标题、成交价、成交状态、出价数、Collection、Sport、Type、Athlete、Team、多图 |
---
## 2. 站点分析结论(为什么这么抓)
> 用户要求「优先从接口抓取」。经浏览器抓包确认:**本站为 Bidsquare 纯 SSR 页面,数据全部内嵌在 HTML 中,无独立数据 API**(XHR 仅为 GA/埋点)。因此采用 `curl_cffi` 直接 GET 页面 + `parsel` 解析,无 Cloudflare 挑战,响应 200 纯 HTML。
- **去重键**
- 拍卖会:`event_id`(拍卖会卡片 `data-event_id`,如 `21706`,也出现在 URL 尾部)
- 拍品:`item_id`(lot 卡片 `data-item_id`,如 `9317607`)
- **分页**
- 拍卖会列表:`/auctions/past?page=N`,每页 **24** 场,翻到空页为止
- 图录(lot 列表):`{catalog_url}?page=N&limit=120`,`limit` 上限 **120**,翻到空页为止
- **详情页属性区字段数不固定**:`.item-attributes li`(`值`)可能是 2~5 个,Athlete/Team 常缺失 → 先建 `label→value` 字典,再按需取值,缺失置 `None`。
- **多图归一化**:缩略图 `s1.img.bidsquare.com/item/s/...` 与大图 `/item/l/...` 并存,统一替换成 `/item/l/`(large)后去重,逗号拼接入库。
- **成交状态**:列表卡片底部 `9 BidsSold for$90,000` / `24 BidsUnsold`;详情页 `.bidding-price` 有金额为 `sold`,为空为 `unsold`。
---
## 3. 文件结构
```
2026-07-15(scp_spider)/
├── application.yml # MySQL 配置(mysql_pool 从运行目录读取)
├── create_table.sql # 建表:scp_auction(场次)+ scp_lot(拍品)
├── scp_core.py # 公用模块:HTTP/代理、列表解析、分页抓取、详情解析、入库
├── scp_history.py # 任务一:历史全量(一次性)
├── scp_spider.py # 任务二:每周新增(周调度常驻)
└── logs/ # 运行日志(loguru 按天切分,保留 7 天)
```
### 数据表
- **`scp_auction`**:一条 = 一场拍卖会,`event_id` 唯一索引。
- **`scp_lot`**:一条 = 一个拍品,`item_id` 唯一索引;字段分两阶段写入。
两阶段设计:
- **阶段一(列表)**:抓拍卖会 + lot 列表入库,`scp_lot.state=0`(含成交价/状态/出价,列表页即有)。
- **阶段二(详情)**:扫库 `state != 1` 的 lot,逐条进详情页补 Collection/Sport/Type/Athlete/Team/多图,成功置 `state=1`,失败置 `2`。
---
## 4. 使用方式
### 4.1 前置
1. 安装依赖:`curl_cffi`、`parsel`、`loguru`、`tenacity`、`schedule`,以及全局公共库 `charley-utils`(提供 `mysql_pool`)。
2. 建表:执行 `create_table.sql`(DDL 写操作,需人工确认执行)。
3. 配置 `application.yml` 中的 MySQL 连接。
4. **代理**:本地开了 VPN,从大陆直连站点不通,请求必须走代理。见下方「代理配置」。
### 4.2 运行
```bash
# 任务一:历史全量(初始化数据库时跑一次)
python scp_history.py
# 任务二:每周新增(常驻,先立即跑一次,之后每周一 05:00)
python scp_spider.py
```
- 每周新增逻辑:GET `/auctions/past` 首页 → 与库中 `event_id` 求差集 → **仅新增拍卖会才入库查询**;随后补跑阶段二,兜底上一轮失败/中断的详情。
---
## 5. 代理配置
代理集中在 `scp_core.py` 的 `get_proxys()`(带重试)。默认使用住宅代理(北美出口,与 SCP 面向的美区一致):
```python
http_proxy = "http://账号:密码@proxy.123proxy.cn:36927"
# 若改用本地 VPN 客户端 HTTP 端口(如 Clash),改成:
# http_proxy = https_proxy = "http://127.0.0.1:7890"
```
切换本地 VPN 端口时,把 `get_proxys()` 内两行替换为对应地址即可,其余代码无需改动。
---
## 6. 反爬与稳健性
- **无 Cloudflare / 无 JS 挑战**:`curl_cffi` 随机浏览器指纹(`impersonate`)+ 通用请求头即可,未写死 UA(避免与 TLS 指纹矛盾)。
- **重试**:页面 GET、代理获取均由 `tenacity` 包裹重试;周调度主函数失败按小时重试。
- **幂等**:`insert_many(ignore=True)` 依赖唯一索引去重,重复跑不会产生脏数据。
- **限频**:站点为静态页,当前未加显式 sleep;如遇风控可在翻页/详情循环处补 `time.sleep`。
---
## 7. 踩坑记录
- **优先接口落空**:抓包确认本站无数据接口,全部 SSR,最终走 HTML 解析。
- **详情属性字段数量不一致**(2~5 个、可能缺 Athlete/Team):改为按 `label` 建字典按需取,避免按固定顺序取值错位。
- **多图有 small/large 两套**:统一归一化到 large 再去重,避免同图重复入库。
- **时间带时区名**(`EDT`/`EST`):解析时剥离时区名,保留东部墙钟时间入 `datetime` 字段,原文另存 `*_raw` 字段。
- **控制台中文/en-dash 乱码**:Windows GBK 终端显示问题,实际数据 UTF-8 正确,入 `utf8mb4` 库无碍。
---
## 8. 举一反三
- 本套「拍卖会 → 图录列表 → 拍品详情」三级 + 两阶段(列表/详情)+ `event_id`/`item_id` 去重 + 首页差集增量,是 Bidsquare 系托管拍卖站的通用范式,换其他 Bidsquare 站点只需调整域名与少量选择器。
- 若某站改为前端渲染(数据走 XHR/JSON),优先复用「浏览器抓包定位接口 → 直接请求接口」路径,比解析 HTML 更稳。