Forráskód Böngészése

feat(mysql): 添加 MySQL 连接池及基础数据库操作封装

- 新增 rea_spider/mysql_pool.py,封装 MySQL 连接池管理
- 支持基本的单条及批量查询、插入、更新操作
- 插入操作支持字典及原始 SQL 两种方式,支持忽略重复条目
- 批量操作支持分批提交,遇重复自动降级逐条处理
- 实现连接断开自动重试机制,提升稳定性
- 提供连接池健康检查及安全标识符校验功能
- 配置 rea_spider/application.yml 添加默认 MySQL 连接信息

feat(rea): 实现 collectrea.com 拍卖数据爬虫核心模块

- 新增 rea_spider/rea_core.py,处理 HTTP 请求和页面 HTML 解析
- 支持抓取拍卖场次列表,包括最近和历史两种模式
- 实现场次内拍品(lot)列表和分页抓取功能
- 解析拍品详情页,提取标题、价格、拍卖信息和多图地址
- 采用 curl_cffi 模拟多浏览器指纹访问,规避 Cloudflare TLS 校验
- 配置代理支持,带重试机制保证网络请求稳定
- 设计可复用的解析函数与重试逻辑,确保数据抓取准确完整
charley 1 hete
szülő
commit
e187d77fb8

+ 196 - 0
rea_spider/README.md

@@ -0,0 +1,196 @@
+# REA (collectrea.com) 爬虫文档
+
+## 1. 目标信息
+
+- **URL**:https://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-utils` 的 `MySQLConnectionPool`
+- **数据目标**:拍卖会(场次)下所有拍品(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`~`imgs`、`state` 由阶段二(`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=12`、`sortBy=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_number` 与 `slug`)。
+筛选器控件的 `wire:key` 是 `item-...` / `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_auction` → `fetch_auction_lots`)**
+
+1. `GET /search` → `parse_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` 不为 `None` 时 `insert_many(table="rea_record", ignore=True)` 入库。
+
+**阶段二 · 详情(`update_details_for_pending` → `get_details` → `fetch_lot_detail`)**
+
+4. `select id, detail_url from rea_record where state != 1 or state is null` 取待补记录。
+5. 逐条 `GET` 详情页 → `parse_lot_detail` 解析标题 + 5 字段 + 多图。
+6. 写回 `title`~`imgs`、`state=1`;整条失败则 `state=2`。
+
+### 增量逻辑
+
+`rea_spider.py` 的 `get_existing_auction_keys` 会 `select 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`)
+
+```bash
+pip install curl_cffi parsel loguru tenacity schedule
+```
+
+### 4.2 建库建表
+
+先按 `create_table.sql` 建表,并确认 `application.yml` 的 mysql 配置正确:
+
+```bash
+mysql -u<user> -p <db> < create_table.sql
+```
+
+### 4.3 运行
+
+```bash
+# 全量历史(初始化用,跑一次;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` 临时改为 `None`,`crawl_one_auction` 会跳过入库、print 样本(阶段二自动跳过)。
+
+---
+
+## 5. 注意事项
+
+- **反爬**:站点走 Cloudflare,`requests` 直连易被拦;统一用 `curl_cffi` + 随机 `impersonate` 浏览器指纹直连即可。若后续被限流,在 `rea_core.get_proxys` 填入代理(函数已带 `tenacity` 重试)。
+- **原始 HTML vs 渲染后 DOM**:`curl_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` 分级,按需保留。

+ 98 - 0
rea_spider/YamlLoader.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/12/22 10:44
+import os, re
+import yaml
+
+regex = re.compile(r'^\$\{(?P<ENV>[A-Z_\-]+:)?(?P<VAL>[\w.]+)}$')
+
+
+class YamlConfig:
+    def __init__(self, config):
+        self.config = config
+
+    def get(self, key: str):
+        return YamlConfig(self.config.get(key))
+
+    def getValueAsString(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return os.getenv(env, group['VAL'])
+            return None
+        except:
+            return self.config[key]
+
+    def getValueAsInt(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return int(os.getenv(env, group['VAL']))
+            return 0
+        except:
+            return int(self.config[key])
+
+    def getValueAsBool(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return bool(os.getenv(env, group['VAL']))
+            return False
+        except:
+            return bool(self.config[key])
+
+
+def _resolve_path(path: str) -> str:
+    """
+    解析 yaml 文件路径,按优先级查找:
+      1) 绝对路径或 cwd 下存在 → 直接用(保留旧行为,向后兼容)
+      2) 调用方主脚本所在目录 → 兜底,方便打包后从任意 cwd 启动
+    :param path: (str) 用户传入的路径,默认 'application.yml'
+    :return: (str) 实际可读取的完整路径;找不到则返回原 path 让 open() 抛错
+    """
+    # 1) 旧行为:cwd 或绝对路径
+    if os.path.exists(path):
+        return path
+
+    # 2) 主脚本目录(__main__.__file__)
+    try:
+        import __main__
+        main_file = getattr(__main__, '__file__', None)
+        if main_file:
+            candidate = os.path.join(os.path.dirname(os.path.abspath(main_file)), path)
+            if os.path.exists(candidate):
+                return candidate
+    except Exception:
+        pass
+
+    return path
+
+
+def readYaml(path: str = 'application.yml', profile: str = None) -> YamlConfig:
+    """
+    读取 yaml 配置。
+    :param path: (str) yaml 文件路径,默认 'application.yml'。
+                       优先 cwd / 绝对路径(保留旧行为),找不到再 fallback 到主脚本所在目录。
+    :param profile: (str) 可选环境后缀,如 'dev' 会额外加载 'application-dev.yml' 并 update
+    :return: (YamlConfig) 配置访问对象
+    :raises FileNotFoundError: cwd 和主脚本目录都找不到时抛出
+    """
+    real_path = _resolve_path(path)
+    with open(real_path, encoding='utf-8') as fd:
+        conf = yaml.load(fd, Loader=yaml.FullLoader)
+
+    if profile is not None:
+        result = real_path.rsplit('.', 1)
+        profiledYaml = f'{result[0]}-{profile}.{result[1]}'
+        if os.path.exists(profiledYaml):
+            with open(profiledYaml, encoding='utf-8') as fd:
+                conf.update(yaml.load(fd, Loader=yaml.FullLoader))
+
+    return YamlConfig(conf)

+ 6 - 0
rea_spider/application.yml

@@ -0,0 +1,6 @@
+mysql:
+  host: ${MYSQL_HOST:100.64.0.21}
+  port: ${MYSQL_PROT:3306}
+  username: ${MYSQL_USERNAME:crawler}
+  password: ${MYSQL_PASSWORD:Pass2022}
+  db: ${MYSQL_DATABASE:crawler}

+ 671 - 0
rea_spider/mysql_pool.py

@@ -0,0 +1,671 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/3/25 14:14
+import re
+import pymysql
+import YamlLoader
+from loguru import logger
+from dbutils.pooled_db import PooledDB
+
+# 获取yaml配置
+yaml = YamlLoader.readYaml()
+mysqlYaml = yaml.get("mysql")
+sql_host = mysqlYaml.getValueAsString("host")
+sql_port = mysqlYaml.getValueAsInt("port")
+sql_user = mysqlYaml.getValueAsString("username")
+sql_password = mysqlYaml.getValueAsString("password")
+sql_db = mysqlYaml.getValueAsString("db")
+
+
+class MySQLConnectionPool:
+    """
+    MySQL连接池
+    """
+
+    def __init__(self, mincached=1, maxcached=2, maxconnections=3, log=None):
+        """
+        初始化连接池
+        :param mincached: 初始化时,链接池中至少创建的链接,0表示不创建
+        :param maxcached: 池中空闲连接的最大数目(0 或 None 表示池大小不受限制)
+        :param maxconnections: 允许的最大连接数(0 或 None 表示任意数量的连接)
+        :param log: 自定义日志记录器
+        """
+        # 使用 loguru 的 logger,如果传入了其他 logger,则使用传入的 logger
+        self.log = log or logger
+        self.pool = PooledDB(
+            creator=pymysql,
+            mincached=mincached,
+            maxcached=maxcached,
+            maxconnections=maxconnections,
+            blocking=True,  # 连接池中如果没有可用连接后,是否阻塞等待。True,等待;False,不等待然后报错
+            host=sql_host,
+            port=sql_port,
+            user=sql_user,
+            password=sql_password,
+            database=sql_db,
+            ping=2,  # 每次执行前检查连接有效性,防止使用已断开的连接
+            connect_timeout=5,  # 连接超时时间(秒)
+            # read_timeout=30,  # 读取超时时间(秒)
+            write_timeout=30  # 写入超时时间(秒)
+        )
+
+    # def _execute(self, query, args=None, commit=False):
+    #     """
+    #     执行SQL
+    #     :param query: SQL语句
+    #     :param args: SQL参数
+    #     :param commit: 是否提交事务
+    #     :return: 查询结果
+    #     """
+    #     try:
+    #         with self.pool.connection() as conn:
+    #             with conn.cursor() as cursor:
+    #                 cursor.execute(query, args)
+    #                 if commit:
+    #                     conn.commit()
+    #                 self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+    #                 return cursor
+    #     except Exception as e:
+    #         if commit and conn:
+    #             conn.rollback()
+    #         self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+    #         raise e
+
+    def _execute(self, query, args=None, commit=False):
+        """
+        执行SQL(带断连重试)
+        :param query: SQL语句
+        :param args: SQL参数
+        :param commit: 是否提交事务
+        :return: 查询结果
+        """
+        conn = None
+        for attempt in range(2):  # 最多重试1次
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.execute(query, args)
+                        if commit:
+                            conn.commit()
+                        self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+                        return cursor
+            except pymysql.err.InterfaceError as e:
+                # 连接已断开,重试一次
+                if attempt == 0:
+                    self.log.warning(f"数据库连接断开,正在重试... Error: {e}")
+                    continue
+                self.log.error(f"重试后仍失败: {e}, Query: {query}")
+                raise e
+            except pymysql.err.IntegrityError:
+                # 完整性错误(如重复条目)交由上层处理,避免在此打印完整堆栈污染日志
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise
+            except Exception as e:
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+                raise e
+
+    def select_one(self, query, args=None):
+        """
+        执行查询,返回单个结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchone()
+
+    def select_all(self, query, args=None):
+        """
+        执行查询,返回所有结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchall()
+
+    def insert_one(self, query, args):
+        """
+        执行单条插入语句
+        :param query: 插入语句
+        :param args: 插入参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        cursor = self._execute(query, args, commit=True)
+        return cursor.lastrowid  # 返回插入的ID
+
+    def insert_all(self, query, args_list):
+        """
+        执行批量插入语句,如果失败则逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql insert_all, SQL: {query[:100]}..., Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_all 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                conn.rollback()
+                self.log.warning(f"批量插入遇到重复,开始逐条插入。错误: {e}")
+                rowcount = 0
+                for args in args_list:
+                    try:
+                        self.insert_one(query, args)
+                        rowcount += 1
+                    except pymysql.err.IntegrityError as e2:
+                        if "Duplicate entry" in str(e2):
+                            self.log.debug(f"跳过重复条目: {e2}")
+                        else:
+                            self.log.error(f"插入失败: {e2}")
+                    except Exception as e2:
+                        self.log.error(f"插入失败: {e2}")
+                self.log.info(f"逐条插入完成: {rowcount}/{len(args_list)}条")
+            else:
+                conn.rollback()
+                self.log.exception(f"数据库完整性错误: {e}")
+                raise e
+        except Exception as e:
+            conn.rollback()
+            self.log.exception(f"批量插入失败: {e}")
+            raise e
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def insert_one_or_dict(self, table=None, data=None, query=None, args=None, commit=True, ignore=False):
+        """
+        单条插入(支持字典或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data: 字典数据 {列名: 值}
+        :param query: 直接SQL语句(与data二选一)
+        :param args: SQL参数(query使用时必需)
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 最后插入ID
+        """
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data.keys()])
+            values = ', '.join(['%s'] * len(data))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args = tuple(data.values())
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        try:
+            cursor = self._execute(query, args, commit)
+            self.log.info(f"sql insert_one_or_dict, Table: {table}, Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one_or_dict 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+            return cursor.lastrowid
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                # 重复条目用 warning 简短输出,不打印堆栈
+                self.log.warning(f"插入跳过-重复条目 Table: {table}, {e.args[1] if len(e.args) > 1 else e}")
+                return -1  # 返回 -1 表示重复条目被跳过
+            else:
+                self.log.error(f"数据库完整性错误 Table: {table}, Error: {e}")
+                raise
+        except Exception as e:
+            self.log.error(f"insert_one_or_dict 失败 Table: {table}, Error: {e}")
+            raise
+
+    def insert_many(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                    ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                # 处理唯一索引冲突
+                if "Duplicate entry" in str(e):
+                    if ignore:
+                        # 如果使用了 INSERT IGNORE,理论上不会进这里,但以防万一
+                        self.log.warning(f"批量插入遇到重复条目(ignore模式): {e}")
+                    else:
+                        # 没有使用 IGNORE,降级为逐条插入
+                        self.log.warning(f"批量插入遇到重复条目,开始逐条插入。错误: {e}")
+                        if commit:
+                            conn.rollback()
+                        
+                        rowcount = 0
+                        for j, args in enumerate(batch):
+                            try:
+                                if data_list:
+                                    # 字典模式
+                                    self.insert_one_or_dict(
+                                        table=table,
+                                        data=dict(zip(data_list[0].keys(), args)),
+                                        commit=commit,
+                                        ignore=False  # 单条插入时手动捕获重复
+                                    )
+                                else:
+                                    # 原始SQL模式
+                                    self.insert_one(query, args)
+                                rowcount += 1
+                            except pymysql.err.IntegrityError as e2:
+                                if "Duplicate entry" in str(e2):
+                                    self.log.debug(f"跳过重复条目[{i+j+1}]: {e2}")
+                                else:
+                                    self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                            except Exception as e2:
+                                self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                        total += rowcount
+                        self.log.info(f"批次逐条插入完成: 成功{rowcount}/{len(batch)}条")
+                else:
+                    # 其他完整性错误
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                # 其他数据库错误
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        if table:
+            self.log.info(f"sql insert_many, Table: {table}, Total Rows: {total}")
+        else:
+            self.log.info(f"sql insert_many, Query: {query}, Total Rows: {total}")
+        return total
+
+    def insert_many_two(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                        ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL) - 备用方法
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用INSERT IGNORE
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+    
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                if "Duplicate entry" in str(e) and not ignore:
+                    self.log.warning(f"批量插入遇到重复,降级为逐条插入: {e}")
+                    if commit:
+                        conn.rollback()
+                    rowcount = 0
+                    for args in batch:
+                        try:
+                            self.insert_one(query, args)
+                            rowcount += 1
+                        except pymysql.err.IntegrityError as e2:
+                            if "Duplicate entry" in str(e2):
+                                self.log.debug(f"跳过重复条目: {e2}")
+                            else:
+                                self.log.error(f"插入失败: {e2}")
+                        except Exception as e2:
+                            self.log.error(f"插入失败: {e2}")
+                    total += rowcount
+                else:
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        self.log.info(f"sql insert_many_two, Table: {table}, Total Rows: {total}")
+        return total
+
+    def insert_too_many(self, query, args_list, batch_size=1000):
+        """
+        执行批量插入语句,分片提交, 单次插入大于十万+时可用, 如果失败则降级为逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        :param batch_size: 每次插入的条数
+        """
+        self.log.info(f"sql insert_too_many, Query: {query}, Total Rows: {len(args_list)}")
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        conn.commit()
+                        self.log.debug(f"insert_too_many -> Total Rows: {len(batch)}")
+            except Exception as e:
+                self.log.error(f"insert_too_many error. Trying single insert. Error: {e}")
+                # 当前批次降级为单条插入
+                for args in batch:
+                    self.insert_one(query, args)
+
+    def update_one(self, query, args):
+        """
+        执行单条更新语句
+        :param query: 更新语句
+        :param args: 更新参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        return self._execute(query, args, commit=True)
+
+    def update_all(self, query, args_list):
+        """
+        执行批量更新语句,如果失败则逐条更新
+        :param query: 更新语句
+        :param args_list: 更新参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql update_all, SQL: {query}, Rows: {len(args_list)}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_all 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except Exception as e:
+            conn.rollback()
+            self.log.error(f"Error executing query: {e}")
+            # 如果批量更新失败,则逐条更新
+            rowcount = 0
+            for args in args_list:
+                self.update_one(query, args)
+                rowcount += 1
+            self.log.debug(f'Batch update failed. Updated {rowcount} rows individually.')
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def update_one_or_dict(self, table=None, data=None, condition=None, query=None, args=None, commit=True):
+        """
+        单条更新(支持字典或原始SQL)
+        :param table: 表名(字典模式必需)
+        :param data: 字典数据 {列名: 值}(与 query 二选一)
+        :param condition: 更新条件,支持以下格式:
+            - 字典: {"id": 1} → "WHERE id = %s"
+            - 字符串: "id = 1" → "WHERE id = 1"(需自行确保安全)
+            - 元组: ("id = %s", [1]) → "WHERE id = %s"(参数化查询)
+        :param query: 直接SQL语句(与 data 二选一)
+        :param args: SQL参数(query 模式下必需)
+        :param commit: 是否自动提交
+        :return: 影响行数
+        :raises: ValueError 参数校验失败时抛出
+        """
+        # 参数校验
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+            if table is None:
+                raise ValueError("Table name is required for dictionary update")
+            if condition is None:
+                raise ValueError("Condition is required for dictionary update")
+
+            # 构建 SET 子句
+            set_clause = ", ".join([f"{self._safe_identifier(k)} = %s" for k in data.keys()])
+            set_values = list(data.values())
+
+            # 解析条件
+            condition_clause, condition_args = self._parse_condition(condition)
+            query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            args = set_values + condition_args
+
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        # 执行更新
+        cursor = self._execute(query, args, commit)
+        # self.log.debug(
+        #     f"Updated table={table}, rows={cursor.rowcount}, query={query[:100]}...",
+        #     extra={"table": table, "rows": cursor.rowcount}
+        # )
+        return cursor.rowcount
+
+    def _parse_condition(self, condition):
+        """
+        解析条件为 (clause, args) 格式
+        :param condition: 字典/字符串/元组
+        :return: (str, list) SQL 子句和参数列表
+        """
+        if isinstance(condition, dict):
+            clause = " AND ".join([f"{self._safe_identifier(k)} = %s" for k in condition.keys()])
+            args = list(condition.values())
+        elif isinstance(condition, str):
+            clause = condition  # 注意:需调用方确保安全
+            args = []
+        elif isinstance(condition, (tuple, list)) and len(condition) == 2:
+            clause, args = condition[0], condition[1]
+            if not isinstance(args, (list, tuple)):
+                args = [args]
+        else:
+            raise ValueError("Condition must be dict/str/(clause, args)")
+        return clause, args
+
+    def update_many(self, table=None, data_list=None, condition_list=None, query=None, args_list=None, batch_size=500,
+                    commit=True):
+        """
+        批量更新(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param condition_list: 条件列表(必须为字典,与data_list等长)
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            if condition_list is None or len(data_list) != len(condition_list):
+                raise ValueError("Condition_list must be provided and match the length of data_list")
+            if not all(isinstance(cond, dict) for cond in condition_list):
+                raise ValueError("All elements in condition_list must be dictionaries")
+
+            # 获取第一个数据项和条件项的键
+            first_data_keys = set(data_list[0].keys())
+            first_cond_keys = set(condition_list[0].keys())
+
+            # 构造基础SQL
+            set_clause = ', '.join([self._safe_identifier(k) + ' = %s' for k in data_list[0].keys()])
+            condition_clause = ' AND '.join([self._safe_identifier(k) + ' = %s' for k in condition_list[0].keys()])
+            base_query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            total = 0
+
+            # 分批次处理
+            for i in range(0, len(data_list), batch_size):
+                batch_data = data_list[i:i + batch_size]
+                batch_conds = condition_list[i:i + batch_size]
+                batch_args = []
+
+                # 检查当前批次的结构是否一致
+                can_batch = True
+                for data, cond in zip(batch_data, batch_conds):
+                    data_keys = set(data.keys())
+                    cond_keys = set(cond.keys())
+                    if data_keys != first_data_keys or cond_keys != first_cond_keys:
+                        can_batch = False
+                        break
+                    batch_args.append(tuple(data.values()) + tuple(cond.values()))
+
+                if not can_batch:
+                    # 结构不一致,转为单条更新
+                    for data, cond in zip(batch_data, batch_conds):
+                        self.update_one_or_dict(table=table, data=data, condition=cond, commit=commit)
+                        total += 1
+                    continue
+
+                # 执行批量更新
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(base_query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args, data, cond in zip(batch_args, batch_data, batch_conds):
+                        try:
+                            self._execute(base_query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Data: {data}, Condition: {cond}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        elif query is not None:
+            # 处理原始SQL和参数列表
+            if args_list is None:
+                raise ValueError("args_list must be provided when using query")
+
+            total = 0
+            for i in range(0, len(args_list), batch_size):
+                batch_args = args_list[i:i + batch_size]
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args in batch_args:
+                        try:
+                            self._execute(query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Args: {args}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        else:
+            raise ValueError("Either data_list or query must be provided")
+
+    def check_pool_health(self):
+        """
+        检查连接池中有效连接数
+
+        # 使用示例
+        # 配置 MySQL 连接池
+        sql_pool = MySQLConnectionPool(log=log)
+        if not sql_pool.check_pool_health():
+            log.error("数据库连接池异常")
+            raise RuntimeError("数据库连接池异常")
+        """
+        try:
+            with self.pool.connection() as conn:
+                conn.ping(reconnect=True)
+                return True
+        except Exception as e:
+            self.log.error(f"Connection pool health check failed: {e}")
+            return False
+
+    def close(self):
+        """
+        关闭连接池,释放所有连接
+        """
+        try:
+            if hasattr(self, 'pool') and self.pool:
+                self.pool.close()
+                self.log.info("数据库连接池已关闭")
+        except Exception as e:
+            self.log.error(f"关闭连接池失败: {e}")
+
+    @staticmethod
+    def _safe_identifier(name):
+        """SQL标识符安全校验"""
+        if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
+            raise ValueError(f"Invalid SQL identifier: {name}")
+        return name
+
+
+if __name__ == '__main__':
+    sql_pool = MySQLConnectionPool()
+    data_dic = {'card_type_id': 111, 'card_type_name': '补充包 继承的意志【OPC-13】', 'card_type_position': 964,
+                'card_id': 5284, 'card_name': '蒙奇·D·路飞', 'card_number': 'OP13-001', 'card_rarity': 'L',
+                'card_img': 'https://source.windoent.com/OnePiecePc/Picture/1757929283612OP13-001.png',
+                'card_life': '4', 'card_attribute': '打', 'card_power': '5000', 'card_attack': '-',
+                'card_color': '红/绿', 'subscript': 4, 'card_features': '超新星/草帽一伙',
+                'card_text_desc': '【咚!!×1】【对方的攻击时】我方处于活跃状态的咚!!不多于5张的场合,可以将我方任意张数的咚!!转为休息状态。每有1张转为休息状态的咚!!,本次战斗中,此领袖或我方最多1张拥有《草帽一伙》特征的角色力量+2000。',
+                'card_offer_type': '补充包 继承的意志【OPC-13】', 'crawler_language': '简中'}
+    sql_pool.insert_one_or_dict(table="one_piece_record", data=data_dic)

+ 536 - 0
rea_spider/rea_core.py

@@ -0,0 +1,536 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/07/10
+"""
+REA (collectrea.com) 公用模块:HTTP 配置、场次列表解析、场次内 lot 分页抓取、详情页解析。
+被 rea_history.py / rea_spider.py 复用。
+
+目标网站: https://collectrea.com/search
+逻辑要点:
+    1. 站点为 Laravel + Livewire + Alpine.js,页面服务端渲染(SSR),无独立数据 API。
+       curl_cffi 拿到的是「Alpine 未执行」的原始 HTML,解析选择器均按原始 HTML 校准。
+    2. /search 页三个 tab 面板按文档顺序排列(role="tabpanel"):
+         面板1 = RECENT AUCTIONS(最近 16 场,Swiper 封面卡)
+         面板2 = AUCTION ARCHIVE(历史归档 68 场,按年份分组文字链)
+         面板3 = PAST CATALOG(往期图录,<select>,不属于本爬虫范围)
+       两个 archives 面板的链接形如 /archives/{year}/{month}/,即一个「场次」。
+    3. 场次页 /archives/{year}/{month}/ 是 Livewire 搜索组件,预置过滤该场次。
+       翻页直接用查询参数:?page=N&pageSize=M(Livewire WithPagination 同步到 URL),
+       超出末页返回空列表 → 作为停止条件。列表卡片只有 lot_id/lot_number/slug/封面图。
+    4. lot 详情页 /archives/{year}/{Season}/{lotNumber}/{slug}:
+         标题在 <h1>(排除 "... - Item detail" 面包屑那条);
+         Sold For / Year / Auction / Lot # / Category 在 <dl> 的 dt/dd;
+         多图为 rea-image-archive CDN 上的 -N.jpg。
+"""
+import re
+import random
+from loguru import logger
+from parsel import Selector
+from curl_cffi import requests
+from curl_cffi.requests import BrowserType
+from urllib.parse import urljoin, quote
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+# —— 站点常量 ——
+BASE_URL = "https://collectrea.com"  # 站点根,用于拼接相对链接
+SEARCH_URL = "https://collectrea.com/search"  # 场次列表页(含三个 tab)
+PAGE_SIZE = 200  # 场次内 lot 每页条数(Livewire pageSize 参数,实测最高支持 1000);逐分类抓取时用较大值减少请求数
+IMG_CDN_PREFIX = "https://rea-image-archive.nyc3.cdn.digitaloceanspaces.com"  # 拍品图 CDN 前缀
+
+# 直接用库内置的所有浏览器指纹(应对 Cloudflare 的 TLS 指纹校验)
+client_identifier_list = [b.value for b in BrowserType]
+
+# 不写死 user-agent:curl_cffi 会按 impersonate 指纹自动带「与 JA3 匹配的 UA」,
+# 若在此写死 UA 会覆盖它,造成 TLS 指纹与 UA 头矛盾,反而更易被 Cloudflare 识别。
+# 这里只保留通用、不与指纹冲突的头。
+headers = {
+    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
+    "accept-language": "en-US,en;q=0.9",
+}
+
+
+def after_log(retry_state):
+    """tenacity retry 回调,统一打印重试日志。
+
+    Args:
+        retry_state: tenacity.RetryCallState,retry 框架自动传入。
+    """
+    if retry_state.args and len(retry_state.args) > 0:
+        log = retry_state.args[0]
+    else:
+        log = logger
+
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def get_proxys(log):
+    """获取代理字典,全部请求方法的 proxies 参数都走这里。
+
+    默认直连(返回 None)。站点走 Cloudflare,实测 curl_cffi + 浏览器指纹直连可用;
+    若后续被限流/封禁,在此按 {"http": ..., "https": ...} 填入代理即可,函数已带重试。
+
+    Args:
+        log: logger 对象。
+
+    Returns:
+        dict | None: requests 风格代理字典;直连时返回 None。
+
+    Raises:
+        Exception: 透传内部异常以便 tenacity 触发重试。
+    """
+    # 住宅ip池 北美
+    http_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
+    https_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
+
+    # url = "https://ifconfig.me"
+    try:
+        proxySettings = {
+            "http": http_proxy,
+            "https": https_proxy,
+        }
+        return proxySettings
+    except Exception as e:
+        log.error(f"Error getting proxy: {e}")
+        raise e
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def _get_selector(log, session, impersonate, url):
+    """GET 一个页面并返回 parsel Selector(带重试)。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        url (str): 目标绝对 URL。
+
+    Returns:
+        Selector: 响应 HTML 的 parsel 解析对象。
+
+    Raises:
+        Exception: HTTP 状态非 2xx 时透传异常以触发重试。
+    """
+    resp = session.get(url, headers=headers, impersonate=impersonate,
+                       # timeout=20)
+                       proxies=get_proxys(log), timeout=20)
+    resp.raise_for_status()
+    return Selector(resp.text)
+
+
+def _auction_from_href(href):
+    """把场次相对链接解析成 {auction_key, auction_name, url}。
+
+    Args:
+        href (str): 形如 /archives/2025/spring/ 的相对链接。
+
+    Returns:
+        dict | None: {"auction_key": "2025/spring", "auction_name": "Spring 2025",
+            "url": "https://collectrea.com/archives/2025/spring/"};非法链接返回 None。
+    """
+    parts = [p for p in href.strip("/").split("/") if p]  # ['archives','2025','spring']
+    if len(parts) < 3 or parts[0] != "archives":
+        return None
+    year, month = parts[1], parts[2]
+    return {
+        "auction_key": f"{year}/{month}",  # 唯一标识(等价 wheatland 的 auction_id)
+        "auction_name": f"{month.title()} {year}",  # 展示名,如 "Spring 2025"
+        "url": urljoin(BASE_URL, href if href.endswith("/") else href + "/"),
+    }
+
+
+def parse_auction_list(selector, only_recent=False):
+    """解析 /search 页的场次列表。
+
+    页面三个 role="tabpanel" 面板按文档顺序排列,含 archives 链接的两个面板依次为
+    「RECENT(最近)」「AUCTION ARCHIVE(历史)」。only_recent=True 时只取最近面板。
+
+    Args:
+        selector (Selector): /search 页 GET 响应的 parsel 解析对象。
+        only_recent (bool, optional): 是否只取「最近」面板。Defaults to False(取全部)。
+
+    Returns:
+        list[dict]: 每个元素为 _auction_from_href 返回的场次 dict,已按 auction_key 去重。
+
+    Raises:
+        ValueError: 页面找不到任何含 archives 链接的 tab 面板时抛出(说明响应异常)。
+    """
+    # 只取「叶子」tabpanel:原始 HTML 最外层有个包裹用的 <section role="tabpanel">,
+    # 它嵌套了全部内层面板;用 not(.//*[@role="tabpanel"]) 排除外壳,得到真实的 3 个面板。
+    panels = selector.xpath('//*[@role="tabpanel" and not(.//*[@role="tabpanel"])]')
+    # 收集「含 archives 链接」的面板,保持文档顺序:[0]=最近,[1]=历史
+    link_panels = []
+    for p in panels:
+        hrefs = [h for h in p.css('a::attr(href)').getall() if "/archives/" in h]
+        if hrefs:
+            link_panels.append(hrefs)
+
+    if not link_panels:
+        raise ValueError("找不到任何含 archives 链接的 tab 面板,页面结构可能已变更")
+
+    target_panels = link_panels[:1] if only_recent else link_panels
+
+    result, seen = [], set()
+    for hrefs in target_panels:
+        for href in hrefs:
+            auc = _auction_from_href(href)
+            if auc and auc["auction_key"] not in seen:
+                seen.add(auc["auction_key"])
+                result.append(auc)
+    return result
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def get_auction_list(log, session, impersonate, only_recent=False):
+    """GET /search 首页,解析出场次列表。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        only_recent (bool, optional): True 仅取「最近」面板(增量用);False 取全部(全量用)。
+            Defaults to False。
+
+    Returns:
+        list[dict]: [{"auction_key": ..., "auction_name": ..., "url": ...}, ...]。
+    """
+    scope = "最近" if only_recent else "全部"
+    log.info(f"获取{scope}场次列表")
+    sel = _get_selector(log, session, impersonate, SEARCH_URL)
+    auctions = parse_auction_list(sel, only_recent=only_recent)
+    log.info(f"共解析到 {len(auctions)} 个场次:{[a['auction_key'] for a in auctions[:5]]}...")
+    return auctions
+
+
+def parse_lot_cards(selector, auction):
+    """解析场次页单页的 lot 结果卡片。
+
+    列表卡片信息极简:只有内部 lot_id(数字 wire:key)、详情链接(含 lot_number 与 slug)、
+    封面图。标题/价格/明细均在详情页,由第二阶段补抓。
+
+    Args:
+        selector (Selector): 场次页某一页 GET 响应的 parsel 解析对象。
+        auction (dict): 当前场次 dict(含 auction_key / auction_name),回填到每条 lot。
+
+    Returns:
+        list[dict]: 每条 lot 一个 dict,字段见 crawl_one_auction 说明;无卡片返回空列表。
+    """
+    # 只有 lot 卡片是「纯数字」wire:key;筛选器控件是 wire:key="item-..." / "Category" 等非数字,天然区分
+    lot_ids = selector.re(r'wire:key="(\d+)"')
+
+    # 详情链接:/archives/{year}/{Season}/{lotNumber}/{slug}
+    cards = []
+    for a in selector.css('a[href*="/archives/"]'):
+        href = a.attrib.get("href", "")
+        parts = [p for p in href.strip("/").split("/") if p]
+        # ['archives', year, Season, lotNumber, slug...]
+        if len(parts) < 5 or parts[0] != "archives" or not parts[3].isdigit():
+            continue
+        cards.append({
+            "lot_number": parts[3],  # lot 编号
+            "slug": "/".join(parts[4:]),  # slug(可能含斜杠,保险起见 join)
+            "detail_url": urljoin(BASE_URL, href),  # 详情绝对 URL
+        })
+
+    # 数字 wire:key 与详情卡片按文档顺序 1:1 对应,逐条配对回填 lot_id
+    rows = []
+    for i, card in enumerate(cards):
+        rows.append({
+            "auction_key": auction["auction_key"],  # 场次唯一标识
+            "auction_name": auction["auction_name"],  # 场次展示名
+            "lot_id": lot_ids[i] if i < len(lot_ids) else "",  # 站内 lot 唯一 id(wire:key)
+            **card,
+        })
+    return rows
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def _fetch_lot_page(log, session, impersonate, auction_url, page, extra_query=""):
+    """抓取场次页的某一页 lot 卡片。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        auction_url (str): 场次页绝对 URL,如 https://collectrea.com/archives/2025/spring/。
+        page (int): 页码,从 1 开始。
+        extra_query (str, optional): 追加的查询串(不带前导 &),
+            如 "Category[]=Football...&sortBy=Price:asc"。Defaults to ""。
+
+    Returns:
+        Selector: 该页响应的 parsel 解析对象。
+    """
+    url = f"{auction_url}?page={page}&pageSize={PAGE_SIZE}"
+    if extra_query:
+        url += f"&{extra_query}"
+    return _get_selector(log, session, impersonate, url)
+
+
+def parse_category_facet(selector):
+    """从场次页左侧筛选栏解析 Category 分类列表及各分类的数量。
+
+    用于绕过搜索结果 1000 条硬上限:逐个分类过滤抓取。
+
+    Args:
+        selector (Selector): 场次页任意一页的 parsel 解析对象(筛选栏恒渲染)。
+
+    Returns:
+        list[dict]: 每个元素为 {"value": 分类原值, "count": 该分类数量(int或None)};
+            无分类栏时返回空列表。
+    """
+    cats = []
+    for inp in selector.css('input[name="Category[]"]'):
+        value = (inp.attrib.get("value") or "").strip()  # parsel 已把 &amp; 还原为 &
+        if not value:
+            continue
+        # 紧邻的 <label> 文本形如 "Football Cards and Memorabilia (152)"
+        label = inp.xpath('./following-sibling::label[1]')
+        text = label.xpath('normalize-space(.)').get() if label else ""
+        m = re.search(r'\((\d+)\)\s*$', text or "")
+        cats.append({"value": value, "count": int(m.group(1)) if m else None})
+    return cats
+
+
+def _collect_pages(log, session, impersonate, auction, extra_query, seen, all_lots):
+    """按给定过滤条件从 page=1 翻到空页,去重后追加到 all_lots。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        auction (dict): 场次 dict。
+        extra_query (str): 追加查询串(不带前导 &),空串表示整场不过滤。
+        seen (set): 跨分类共享的 detail_url 去重集合(原地更新)。
+        all_lots (list): 累积结果列表(原地追加)。
+
+    Returns:
+        int: 本次新增(去重后)的 lot 条数。
+    """
+    page = 1
+    added = 0
+    while True:
+        sel = _fetch_lot_page(log, session, impersonate, auction["url"], page, extra_query)
+        lots = parse_lot_cards(sel, auction)
+        if not lots:
+            break  # 空页 = 已翻过末页(含 1000 上限触顶)
+        new = [x for x in lots if x["detail_url"] not in seen]
+        for x in new:
+            seen.add(x["detail_url"])
+        all_lots.extend(new)
+        added += len(new)
+        # 本页全是重复(分页异常/越界回卷兜底),停止
+        if not new:
+            break
+        page += 1
+    return added
+
+
+def fetch_auction_lots(log, session, impersonate, auction):
+    """抓取一个场次下的全部 lot 列表(绕过 1000 条搜索上限)。
+
+    站点搜索结果有 1000 条硬上限(任何排序下深翻页最多取到前 1000)。为取全,
+    改为逐个 Category 分类过滤抓取(各分类天然只属该场次);某分类 >1000 时再分别
+    用价格升序 + 降序各取 ≤1000 并去重,可覆盖到 2000。全程以 detail_url 去重。
+
+    若某分类 >2000,升+降序仍无法全覆盖,会打 warning 提示漏采条数(当前站点单分类
+    最大约 1100,暂无此情况)。若页面无分类栏,则退回整场翻页(受 1000 上限)。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        auction (dict): 场次 dict(含 auction_key / auction_name / url)。
+
+    Returns:
+        list[dict]: 该场次全部 lot 列表(阶段一字段,不含详情)。
+    """
+    log.info(f"开始抓取场次 {auction['auction_key']} 的 lot 列表")
+    all_lots, seen = [], set()
+
+    # 先取首页拿分类 facet(同时这一页也含 lot,但按分类重抓更全,故此处只用来取 facet)
+    first_sel = _fetch_lot_page(log, session, impersonate, auction["url"], 1)
+    cats = parse_category_facet(first_sel)
+
+    if not cats:
+        # 兜底:无分类栏,整场直接翻页(最多 1000)
+        log.warning(f"{auction['auction_key']} 未解析到 Category 分类栏,退回整场翻页(受 1000 上限)")
+        _collect_pages(log, session, impersonate, auction, "", seen, all_lots)
+        log.info(f"场次 {auction['auction_key']} 共抓 {len(all_lots)} 条 lot")
+        return all_lots
+
+    log.info(f"{auction['auction_key']} 共 {len(cats)} 个分类,逐类抓取以绕过 1000 上限")
+    for c in cats:
+        value, count = c["value"], c["count"]
+        if count == 0:
+            continue
+        cat_q = f"Category[]={quote(value, safe='')}"
+        if count is not None and count > 1000:
+            if count > 2000:
+                log.warning(f"  分类[{value}] {count} 条 >2000,升+降序仍会漏约 {count - 2000} 条")
+            for sort in ("Price:asc", "Price:desc"):
+                n = _collect_pages(log, session, impersonate, auction,
+                                   f"{cat_q}&sortBy={quote(sort, safe='')}", seen, all_lots)
+                log.info(f"  分类[{value}] {sort} 新增 {n}(累计 {len(all_lots)})")
+        else:
+            n = _collect_pages(log, session, impersonate, auction, cat_q, seen, all_lots)
+            log.info(f"  分类[{value}](facet={count}) 新增 {n}(累计 {len(all_lots)})")
+
+    log.info(f"场次 {auction['auction_key']} 共抓 {len(all_lots)} 条 lot")
+    return all_lots
+
+
+def _clean_price(text):
+    """把 "Sold For" 文本清洗成纯数字字符串。
+
+    Args:
+        text (str): 原始价格文本,如 "$336,000"。
+
+    Returns:
+        str: 去掉 $ 和千分位逗号后的数字串,如 "336000";空值返回空串。
+    """
+    if not text:
+        return ""
+    return text.replace("$", "").replace(",", "").strip()
+
+
+def parse_lot_detail(selector):
+    """解析 lot 详情页,抽取标题、5 个明细字段、多图。
+
+    Args:
+        selector (Selector): 详情页 GET 响应的 parsel 解析对象。
+
+    Returns:
+        dict: {
+            "title": 标题, "sold_for": 成交价(纯数字串), "year": 年份,
+            "auction": 场次名(如 "2025 Spring"), "lot_no": Lot 编号,
+            "category": 分类, "imgs": 多图逗号拼接串
+        }。
+    """
+    # 标题:页面有两个 h1,其一为 "xxx - Item detail" 面包屑,取另一条真实标题
+    h1s = [t.strip() for t in selector.css('h1::text').getall() if t.strip()]
+    real = [h for h in h1s if "Item detail" not in h]
+    title = real[-1] if real else (h1s[-1] if h1s else "")
+
+    # dt/dd 明细字段
+    fields = {}
+    for dt in selector.css('dt'):
+        label = (dt.xpath('normalize-space(.)').get() or "").rstrip(":").strip()
+        dd = dt.xpath('following-sibling::dd[1]')
+        value = dd.xpath('normalize-space(.)').get() if dd else ""
+        if label:
+            fields[label] = value or ""
+
+    # 多图:CDN 上的拍品图,去重并保持出现顺序
+    imgs, seen = [], set()
+    for u in selector.re(rf'{IMG_CDN_PREFIX}/[^"\'\s]+'):
+        if u not in seen:
+            seen.add(u)
+            imgs.append(u)
+
+    return {
+        "title": title,
+        "sold_for": _clean_price(fields.get("Sold For", "")),
+        "year": fields.get("Year", ""),
+        "auction": fields.get("Auction", ""),
+        "lot_no": fields.get("Lot #", ""),
+        "category": fields.get("Category", ""),
+        "imgs": ",".join(imgs),
+    }
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def fetch_lot_detail(log, session, impersonate, detail_url):
+    """GET lot 详情页并解析。
+
+    Args:
+        log: logger 对象。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        detail_url (str): 详情页绝对 URL。
+
+    Returns:
+        dict: parse_lot_detail 的返回结果。
+    """
+    log.debug(f"获取详情 {detail_url}")
+    sel = _get_selector(log, session, impersonate, detail_url)
+    return parse_lot_detail(sel)
+
+
+def crawl_one_auction(log, sql_pool, session, impersonate, auction):
+    """抓取单个场次的全部 lot 列表(阶段一:只抓列表,不进详情页)。
+
+    与 wheatland 一致的两阶段设计:本函数只负责列表入库(state 默认 0),
+    详情字段由后续 update_details_for_pending 扫库 state != 1 的记录单独补抓。
+
+    入库字段(表 rea_record):
+        auction_key, auction_name, lot_id, lot_number, slug, detail_url,
+        (以下由阶段二补写)title, sold_for, year, auction, lot_no, category, imgs, state。
+
+    Args:
+        log: logger 对象。
+        sql_pool: MySQL 连接池;传 None 时只返回数据,不入库。
+        session (requests.Session): curl_cffi 会话对象。
+        impersonate (str): 浏览器指纹标识。
+        auction (dict): 场次 dict(含 auction_key / auction_name / url)。
+
+    Returns:
+        list[dict]: 该场次全部 lot 列表数据(阶段一字段,不含详情)。
+    """
+    lots = fetch_auction_lots(log, session, impersonate, auction)
+
+    # 入库(state 默认 0 待补详情;detail_url 建唯一索引,ignore 去重)
+    if sql_pool is not None and lots:
+        sql_pool.insert_many(table="rea_record", data_list=lots, ignore=True)
+
+    log.info(f"场次 {auction['auction_key']}({auction['auction_name']}) 共抓 {len(lots)} 条 lot")
+    return lots
+
+
+def get_details(log, detail_url, sql_pool, sql_id):
+    """对单条已入库记录补抓详情(阶段二),写回 rea_record。
+
+    Args:
+        log: logger 对象。
+        detail_url (str): 详情页 URL。
+        sql_pool: MySQL 连接池。
+        sql_id: 数据库记录 id。
+    """
+    log.info(f">>> 补抓详情 {detail_url}")
+    impersonate = random.choice(client_identifier_list)
+    with requests.Session() as session:
+        detail = fetch_lot_detail(log, session, impersonate, detail_url)
+
+    data = {**detail, "state": 1}  # 详情 7 字段 + 标记已抓
+    sql_pool.update_one_or_dict(
+        table="rea_record",
+        data=data,
+        condition={"id": sql_id},
+    )
+
+
+def update_details_for_pending(log, sql_pool):
+    """扫库里 state != 1 的记录,逐条补抓详情。
+
+    Args:
+        log: logger 对象。
+        sql_pool: MySQL 连接池。
+    """
+    log.debug("Updating detail pages ...")
+    rows = sql_pool.select_all(
+        "select id, detail_url from rea_record where state != 1"
+    )
+    for row in rows:
+        sql_id, detail_url = row[0], row[1]
+        try:
+            get_details(log, detail_url, sql_pool, sql_id)
+        except Exception as e:
+            log.error(f"Error getting details for {detail_url}: {e}")
+            sql_pool.update_one_or_dict(
+                table="rea_record",
+                data={"state": 2},
+                condition={"id": sql_id},
+            )

+ 95 - 0
rea_spider/rea_history.py

@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/07/10
+"""
+REA (collectrea.com) 全量历史爬虫(一次性脚本)
+逻辑(两阶段,与 wheatland 一致):
+  阶段一 列表:GET /search 解析「全部」场次(RECENT + AUCTION ARCHIVE 两个 tab,去重 84 场)
+              → 逐场次翻页抓 lot 列表入库(state 默认 0)
+  阶段二 详情:扫库 state != 1 的记录 → 逐条进详情页抓「标题 + 5 字段 + 多图」写回
+适用场景:初始化数据库时跑一次。后续日常增量由 rea_spider.py 负责。
+"""
+import sys
+import random
+from curl_cffi import requests
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+from rea_core import (
+    client_identifier_list,
+    crawl_one_auction,
+    get_auction_list,
+    update_details_for_pending,
+)
+
+logger.remove()
+logger.add("./logs/his_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# —— 调试开关 ——
+# 只抓前 N 个场次,None 表示全抓(生产);测试时设小一点,比如 1
+DEBUG_AUCTION_LIMIT = None
+
+
+def run_history(log, sql_pool):
+    """全量抓取所有场次(RECENT + AUCTION ARCHIVE)。
+
+    Args:
+        log: logger 对象。
+        sql_pool: MySQL 连接池;传 None 时不入库,仅翻页抓取并 print 样本。
+    """
+    impersonate = random.choice(client_identifier_list)
+    with requests.Session() as session:
+        try:
+            auctions = get_auction_list(log, session, impersonate, only_recent=False)
+            # print(auctions)
+        except Exception as e:
+            log.error(f"获取场次列表失败: {e}")
+            return
+
+        # 调试模式裁剪场次数量
+        if DEBUG_AUCTION_LIMIT is not None:
+            auctions = auctions[:DEBUG_AUCTION_LIMIT]
+            log.warning(f"[DEBUG] 调试模式,仅抓前 {len(auctions)} 个场次")
+
+        for idx, auc in enumerate(auctions, 1):
+            log.info(f"========== [{idx}/{len(auctions)}] 开始抓场次 {auc['auction_key']} ({auc['auction_name']}) ==========")
+            try:
+                lots = crawl_one_auction(log, sql_pool, session, impersonate, auc)
+                if sql_pool is None:
+                    for row in lots[:2]:
+                        print(row)
+            except Exception as e:
+                log.error(f"场次 {auc['auction_key']} 抓取异常: {e}")
+                continue
+
+
+def rea_history_main(log):
+    """全量历史抓取入口。
+
+    Args:
+        log: logger 对象。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 全量爬虫任务 ...")
+
+    sql_pool = MySQLConnectionPool(log=log)
+    if not sql_pool:
+        log.error("MySQL数据库连接失败")
+        raise Exception("MySQL数据库连接失败")
+
+    try:
+        # 阶段一:抓列表入库
+        # run_history(log, sql_pool)
+        # 阶段二:扫库 state != 1 的记录补抓详情
+        if sql_pool is not None:
+            update_details_for_pending(log, sql_pool)
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"爬虫程序 {sys._getframe().f_code.co_name} 运行结束")
+
+
+if __name__ == "__main__":
+    rea_history_main(log=logger)

+ 168 - 0
rea_spider/rea_spider.py

@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/07/10
+"""
+REA (collectrea.com) 增量爬虫(日调度)
+逻辑(两阶段,与 wheatland 一致):
+  1. GET /search 只解析「最近」板块(RECENT AUCTIONS tab,约 16 场)
+  2. 查库 select distinct auction_key from rea_record,得到已爬过的场次
+  3. 差集 = 新增场次
+  4. 没有新增 → 本轮无数据可抓,结束
+  5. 阶段一 列表:对每个新增场次翻页抓 lot 列表入库(state 默认 0)
+  6. 阶段二 详情:扫库 state != 1 的记录 → 逐条进详情页抓「标题 + 5 字段 + 多图」写回
+
+目标网站: https://collectrea.com/search
+说明:REA 的场次一旦结束即定型,新场次会出现在「最近」板块顶部;因此增量只需盯「最近」,
+      用 auction_key 差集识别新增场次即可(老场次早已在库)。
+"""
+import sys
+import time
+import random
+
+import schedule
+from curl_cffi import requests
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+from rea_core import (
+    client_identifier_list,
+    crawl_one_auction,
+    get_auction_list,
+    update_details_for_pending,
+    after_log,
+)
+
+logger.remove()
+logger.add("./logs/{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+
+def get_existing_auction_keys(log, sql_pool):
+    """查库返回已爬过的 auction_key 集合。
+
+    Args:
+        log: logger 对象。
+        sql_pool: MySQL 连接池;为 None 时返回空集合(视作库内无任何场次)。
+
+    Returns:
+        set[str]: 已存在的 auction_key 字符串集合。
+    """
+    if sql_pool is None:
+        log.warning("sql_pool 为 None,视为库内无任何场次(将全量重抓最近板块)")
+        return set()
+
+    rows = sql_pool.select_all(
+        "select distinct auction_key from rea_record"
+    )
+    keys = {str(r[0]) for r in rows} if rows else set()
+    log.info(f"库中已存在 {len(keys)} 个 auction_key")
+    return keys
+
+
+def diff_new_auctions(log, recent_auctions, existing_keys):
+    """从「最近」场次中筛出库里没有的新增场次。
+
+    Args:
+        log: logger 对象。
+        recent_auctions (list[dict]): get_auction_list(only_recent=True) 的返回。
+        existing_keys (set[str]): 已存在的 auction_key 集合。
+
+    Returns:
+        list[dict]: 待抓取的新增场次列表。
+    """
+    new_list = [a for a in recent_auctions if a["auction_key"] not in existing_keys]
+    log.info(f"新增待抓取场次数: {len(new_list)} -> {[a['auction_key'] for a in new_list]}")
+    return new_list
+
+
+def run_incremental(log, sql_pool):
+    """增量抓取主流程。
+
+    Args:
+        log: logger 对象。
+        sql_pool: MySQL 连接池;为 None 时不入库,仅在内存收集并 print 样本。
+    """
+    impersonate = random.choice(client_identifier_list)
+    with requests.Session() as session:
+        try:
+            recent = get_auction_list(log, session, impersonate, only_recent=True)
+        except Exception as e:
+            log.error(f"获取最近场次列表失败: {e}")
+            return
+
+        existing_keys = get_existing_auction_keys(log, sql_pool)
+        new_auctions = diff_new_auctions(log, recent, existing_keys)
+
+        if not new_auctions:
+            log.info("本轮无新增场次,跳过 list 抓取")
+            return
+
+        collected = []
+        for idx, auc in enumerate(new_auctions, 1):
+            log.info(f"========== [{idx}/{len(new_auctions)}] 开始抓场次 {auc['auction_key']} ({auc['auction_name']}) ==========")
+            try:
+                lots = crawl_one_auction(log, sql_pool, session, impersonate, auc)
+                if sql_pool is None:
+                    collected.extend(lots)
+            except Exception as e:
+                log.error(f"场次 {auc['auction_key']} 抓取异常: {e}")
+                continue
+
+        if sql_pool is None:
+            log.info(f"增量抓取结束,共 {len(collected)} 条 lot(未入库)")
+            for row in collected[:3]:
+                print(row)
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def rea_main(log):
+    """日调度主函数:增量 list + 补详情。
+
+    Args:
+        log: logger 对象。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 增量爬虫任务 ...")
+
+    sql_pool = MySQLConnectionPool(log=log)
+    if not sql_pool:
+        log.error("MySQL数据库连接失败")
+        raise Exception("MySQL数据库连接失败")
+
+    try:
+        # 阶段一:抓新增场次的列表入库
+        try:
+            run_incremental(log, sql_pool)
+        except Exception as e:
+            log.error(f"增量抓取失败: {e}")
+
+        # 阶段二:扫库 state != 1 的记录补抓详情
+        if sql_pool is not None:
+            try:
+                update_details_for_pending(log, sql_pool)
+            except Exception as e:
+                log.error(f"详情补抓失败: {e}")
+
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"爬虫程序 {sys._getframe().f_code.co_name} 运行结束,等待下一轮采集 ...")
+
+
+def schedule_task():
+    """启动调度器:先即时跑一次,之后每天 05:00 跑一次增量。"""
+    rea_main(log=logger)
+
+    schedule.every().day.at("05:00").do(rea_main, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    # 测试时直接跑一次
+    # rea_main(log=logger)
+    # 上生产再切回 schedule
+    schedule_task()

+ 9 - 0
rea_spider/requirements.txt

@@ -0,0 +1,9 @@
+-i https://mirrors.aliyun.com/pypi/simple/
+curl_cffi==0.15.1b1
+DBUtils==3.1.2
+loguru==0.7.3
+parsel==1.11.0
+PyMySQL==1.1.2
+PyYAML==6.0.3
+schedule==1.2.2
+tenacity==9.1.4

+ 9 - 0
scp_spider/requirements.txt

@@ -0,0 +1,9 @@
+-i https://mirrors.aliyun.com/pypi/simple/
+curl_cffi==0.15.1b1
+DBUtils==3.1.2
+loguru==0.7.3
+parsel==1.11.0
+PyMySQL==1.1.2
+PyYAML==6.0.3
+schedule==1.2.2
+tenacity==9.1.4