charley cc87f0af81 chore(scheduler): 注释调度任务中的立即运行调用 1 週間 前
..
README.md e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
YamlLoader.py e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
application.yml e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
mysql_pool.py e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
rea_core.py e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
rea_history.py e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前
rea_spider.py cc87f0af81 chore(scheduler): 注释调度任务中的立即运行调用 1 週間 前
requirements.txt e187d77fb8 feat(mysql): 添加 MySQL 连接池及基础数据库操作封装 1 週間 前

README.md

REA (collectrea.com) 爬虫文档

1. 目标信息

  • URLhttps://collectrea.com/search
  • 技术栈
    • 站点:Laravel + Livewire + Alpine.js,页面服务端渲染(SSR),无独立数据 API
    • CDN / 防护:Cloudflare(拍品图在 rea-image-archive.nyc3.cdn.digitaloceanspaces.com
    • HTTP:curl_cffi(带 impersonate 浏览器指纹,过 Cloudflare 的 TLS 指纹校验)
    • 解析:parsel.Selector(CSS + XPath)
    • 调度:schedule;日志:loguru;重试:tenacity;连接池:charley-utilsMySQLConnectionPool
  • 数据目标:拍卖会(场次)下所有拍品(lot)的列表与详情,含标题、成交价等 5 个明细字段与多图。

抓取层级(三级)

/search 场次列表  →  /archives/{year}/{month}/ 场次内 lot 列表  →  lot 详情页

字段一览(表 rea_record,见 create_table.sql)

字段 阶段 说明
auction_key 场次唯一标识,如 2025/spring(由 URL 派生,增量差集依据
auction_name 场次展示名,如 Spring 2025
lot_id 站内 lot 唯一 id(列表卡片的数字 wire:key
lot_number lot 编号(详情 URL 中的段)
slug 详情 URL 末段 slug
detail_url 详情页绝对 URL(唯一索引,幂等去重)
title 拍品标题(详情页 h1
sold_for 成交价,纯数字串如 336000;未成交为空
year 拍品年份,如 1916
auction 详情页场次名,如 2025 Spring
lot_no 详情页 Lot 编号(应与 lot_number 一致,可交叉校验)
category 分类,如 Prewar Baseball Cards (1900-1941)
imgs 详情多图 URL 逗号拼接
state 详情抓取状态:0/null 待抓,1 已抓,2 失败

阶段一(crawl_one_auction)只产出前 6 个字段;title~imgsstate 由阶段二(update_details_for_pending)写入。这是主公在 img_1.png 里圈定的「标题 + 5 行明细 + 多图」。


2. 请求分析

关键接口(全部为 SSR 页面,无 JSON API)

接口 方法 用途
/search GET 场次列表页。三个 tab 面板:RECENT / AUCTION ARCHIVE / PAST CATALOG
/archives/{year}/{month}/?page=N&pageSize=M GET 某场次的 lot 列表,用查询参数翻页
/archives/{year}/{Season}/{lotNumber}/{slug} GET lot 详情页,取标题 / 5 字段 / 多图

场次列表:三个 tab 面板

/search 页是 Alpine.js 的 tab 组件。注意 curl_cffi 拿到的是 Alpine 未执行的原始 HTML, 面板不能靠 Alpine 解析后的 aria-labelledby="tab-1-1" 区分,只能按 role="tabpanel"文档顺序

面板顺序 tab 内容 数量 本爬虫
第 1 个含链接面板 RECENT AUCTIONS(最近) Swiper 封面卡 16 增量只抓这个
第 2 个含链接面板 AUCTION ARCHIVE(历史) 按年份分组文字链 68 全量抓这个
第 3 个面板 PAST CATALOG <select> 往期图录(/catalog/... 9 不在范围内

原始 HTML 最外层还有一个「包裹用」的 <section role="tabpanel"> 嵌套了全部面板, 解析时用 XPath //*[@role="tabpanel" and not(.//*[@role="tabpanel"])] 只取叶子面板,避免误取到外壳。 RECENT + ARCHIVE 合计 84 场即全量。

场次内 lot 列表:Livewire 翻页 + 绕过 1000 条上限

场次页 /archives/{year}/{month}/ 是一个 Livewire 搜索组件,预置过滤了该场次 (Auction=['2025-Spring']),默认 pageSize=12sortBy=Price:desc

翻页无需逆向 Livewire 协议:Livewire WithPagination 默认把页码同步到 URL 查询串, 实测 ?page=N&pageSize=M?sortBy=Price:asc|Price:desc?Category[]=<分类名> 都直接对 SSR 页面生效(pageSize 最高 1000)。超出末页服务端返回空列表 → 作为翻页停止条件。

⚠️ 1000 条硬上限(关键坑):搜索结果有 1000 条总量窗口——任何排序下深翻页最多只能取到 前 1000 条(实测 pageSize=1000 第 2 页即返回 0)。而单场次动辄数千件(如 2025 Spring 约 3653 件), 直接整场翻页会静默丢失 1000 之后的全部数据

应对(fetch_auction_lots 已实现):改为逐个 Category 分类过滤抓取——

  1. 先解析左侧筛选栏的分类列表及各分类数量(parse_category_facet,来自 input[name="Category[]"] + 相邻 <label>(N))。
  2. 逐个分类 ?Category[]=<分类名> 过滤翻页(各分类天然只属该场次,且绝大多数 <1000,可整取)。
  3. 某分类仍 >1000 时,分别用 sortBy=Price:asc(取前 1000)+ sortBy=Price:desc(取前 1000)并按 detail_url 去重,可覆盖到 2000。
  4. 若某分类 >2000,升+降序仍无法全覆盖 → 打 warning 提示漏采条数(当前站点单分类最大约 1108,暂无此情况)。

实测 2025 Spring:17 个分类合计 3653 件全部取到(其中 Postwar 分类 1108 件 = asc 1000 + desc 108,去重后正好 1108)。

列表卡片信息极简:只有数字 wire:key(=lot_id)、详情链接(含 lot_numberslug)。 筛选器控件的 wire:keyitem-... / Category非数字,天然与 lot 卡片区分; 数字 wire:key 与详情 <a> 按文档顺序 1:1 对应,逐条配对回填 lot_id

lot 详情页

  • 标题:页面有两个 h1,其一为 "xxx - Item detail" 面包屑,取另一条真实标题。
  • 5 字段:Sold For / Year / Auction / Lot # / Category<dl>dt/dd 对里。
  • 多图:rea-image-archive CDN 上的 -N.jpg,去重并保序拼接。

3. 实现思路

文件分工

文件 角色
rea_core.py 公用核心:HTTP/指纹/代理/重试、场次列表解析、lot 分页抓取、详情解析、两阶段入库
rea_history.py 一次性全量:抓「最近 + 历史归档」全部 84 场,初始化数据库时跑一次
rea_spider.py 日调度增量:只看「最近」板块,查库 auction_key 差集,仅抓新增场次
create_table.sql 建表脚本(表 rea_record
application.yml 数据库配置(MySQLConnectionPool 读运行目录下这份)

两阶段设计(与 wheatland 一致)

列表抓取与详情抓取完全分离:列表先入库(state 默认 0),详情阶段再扫库 state != 1 的记录逐条补齐。 好处是列表抓取快、可断点续补,详情失败(state=2)不影响列表、下轮自动重试。

阶段一 · 列表(crawl_one_auctionfetch_auction_lots

  1. GET /searchparse_auction_list 取场次(only_recent 决定取「最近」还是全部)。
  2. 对每个场次逐个 Category 分类翻页 GET .../?Category[]=<分类>&page=N&pageSize=200 (超 1000 的分类再加 &sortBy=Price:asc|desc),parse_lot_cards 解析卡片, 以 detail_url 全局去重,空页即停。详见上文「绕过 1000 条上限」。
  3. sql_pool 不为 Noneinsert_many(table="rea_record", ignore=True) 入库。

阶段二 · 详情(update_details_for_pendingget_detailsfetch_lot_detail

  1. select id, detail_url from rea_record where state != 1 or state is null 取待补记录。
  2. 逐条 GET 详情页 → parse_lot_detail 解析标题 + 5 字段 + 多图。
  3. 写回 title~imgsstate=1;整条失败则 state=2

增量逻辑

rea_spider.pyget_existing_auction_keysselect distinct auction_key from rea_record, 与「最近」面板解析出的场次做差集,仅抓新增场次。REA 场次一旦结束即定型,新场次会出现在「最近」板块顶部, 老场次早已在库,故增量只需盯「最近」。sql_pool=None 时视作库内无任何场次 = 全量重抓最近板块。


4. 使用方式

4.1 环境依赖(实测版本)

  • Python 3.12.10
  • curl_cffi 0.15.1b1、parsel 1.11.0、loguru 0.7.3、tenacity 9.1.4、schedule 1.2.2
  • charley-utils(全局 editable 安装,提供 mysql_pool / YamlLoader

    pip install curl_cffi parsel loguru tenacity schedule
    

4.2 建库建表

先按 create_table.sql 建表,并确认 application.yml 的 mysql 配置正确:

mysql -u<user> -p <db> < create_table.sql

4.3 运行

# 全量历史(初始化用,跑一次;84 场 × 数百~千余 lot,较慢)
python rea_history.py

# 日调度增量(生产用,常驻)
python rea_spider.py

rea_spider.py 默认:先即时跑一次,之后每天 05:00 跑一次增量。只想手动跑一次时, 把 schedule_task() 注释掉、保留 rea_main(log=logger) 即可。

4.4 调试开关

  • rea_history.py 顶部 DEBUG_AUCTION_LIMIT:设 1 只抓前 1 个场次,设 None 全抓。
  • 想不入库只看样本:把入口里的 sql_pool 临时改为 Nonecrawl_one_auction 会跳过入库、print 样本(阶段二自动跳过)。

5. 注意事项

  • 反爬:站点走 Cloudflare,requests 直连易被拦;统一用 curl_cffi + 随机 impersonate 浏览器指纹直连即可。若后续被限流,在 rea_core.get_proxys 填入代理(函数已带 tenacity 重试)。
  • 原始 HTML vs 渲染后 DOMcurl_cffi 看到的是 Alpine 未执行的原始 HTML,所有选择器均按原始 HTML 校准;调试时若用浏览器 outerHTML(Alpine 已执行)会有出入,切记区分。
  • 翻页停止fetch_auction_lots 靠「空页」停止,并以 detail_url 去重兜底分页异常。若站点未来改分页机制,重点核对 ?page=/?pageSize= 是否仍生效。
  • 未成交拍品:详情页可能无 Sold For 明细,sold_for 会为空,属正常。
  • 幂等:阶段一 insert_many(ignore=True) + detail_url 唯一索引,重复跑不会产生重复记录;增量重跑安全。

6. 举一反三

  • Livewire 站点通用套路:凡是 Laravel Livewire + WithPagination 的列表页,多数支持 ?page=N 甚至 ?pageSize=M 直接翻页(页码同步到 URL),不必逆向 /livewire/update 的 snapshot/checksum 协议——先试查询参数,成本最低。
  • Alpine tab 面板区分:原始 HTML 里 tab 内容常全部渲染、靠 x-show 切换显隐;用 role="tabpanel" 文档顺序 + 叶子节点过滤来区分,比依赖 Alpine 运行后的属性稳。
  • 列表信息不全时:像本站列表卡片只有图和链接,标题/价格都在详情页——两阶段(列表入库 + 详情补抓)是标配,能把「快速铺全量」和「慢速补细节」解耦。
  • 图片分级:本站详情多图为 -1.jpg-2.jpg…同分辨率;部分同类站有 _lg/_med/_sml 分级,按需保留。