Просмотр исходного кода

feat(mysql): 新增SNKRDUNK日本站爬虫数据库支持

- 新增MySQL连接池封装,支持连接重试和批量操作
- 增加日本站二手商品及买卖历史建表DDL脚本
- 添加数据库配置文件application.yml示例
- 编写详细逆向分析文档README,说明抓取目标及字段对应
- 实现多种插入和更新方法,支持字典与原始SQL操作
- 日志集成loguru,便于SQL执行监控和异常处理
charley 1 неделя назад
Родитель
Сommit
85013e71e5

+ 95 - 0
snkrdunk_jp_spider/README.md

@@ -0,0 +1,95 @@
+# SNKRDUNK 日本站 逆向分析文档
+
+> 宝可梦单卡「已售出品 + 售买履历」采集。列表页(表1) + 详情页(表1) + 交易记录(表2)。
+
+## 1. 目标信息
+
+- **站点**:https://snkrdunk.com (日文站,スニダン)
+- **列表页**:`/search?keywords=Pokemon+Card+Game...&brandIds=pokemon&searchCategoryIds=6/33&sort=launch&itemConditions=...&page=1`
+- **详情页**:`/apparels/{apparel_id}/used/{used_item_id}`,如 `/apparels/835482/used/47574548`
+- **技术栈**:前端 Next.js App Router(SSR + RSC flight data);后端提供 v1/v2/v3 REST JSON 接口
+- **反爬 / 加密措施**:无签名、无加密参数;核心业务接口**无需登录、无需 cookie**(`credentials: omit` 可直接访问)。仅账号相关接口(`/v1/accounts/me`、EN 站 `trading-histories`)需登录态
+
+## 2. 数据目标(字段)
+
+### 表1 `snkrdunk_jp_used_item`(二手出品 / 商品信息)
+列表页来源:`apparel_id / used_item_id / name_ja / condition_grade(品相) / price / is_sold / primary_image_url / detail_url`
+详情页补全:`product_id / product_number(品番) / name_en / brand / category / quantity_text(枚数) / condition_desc / sale_status / image_urls(多图逗号分割) / released_at`
+
+### 表2 `snkrdunk_jp_trading_history`(买卖历史 / 交易记录)
+`product_id / apparel_id / product_number / sold_price / condition_grade(品相) / quantity_text(枚数) / sold_at / trade_index_in_second`
+
+联合唯一索引 `(product_id, sold_at, sold_price, condition_grade, quantity_text, trade_index_in_second)` 配合 `INSERT IGNORE` 累积去重(详见第6节踩坑)。
+
+> 建表脚本见 `ddl.sql`。字段一律按原意翻译、避开 MySQL 保留字(`condition`→`condition_grade`、`status`→`sale_status`);
+> 时间字段统一 `gmt_create_time / gmt_modified_time`,成交/发售时间转为北京时间(UTC+8)与现有管线一致。
+
+## 3. 请求分析(关键接口)
+
+| 用途 | 接口 | 说明 |
+|---|---|---|
+| 列表页(表1) | `GET /search?...&page=N` | **无独立 JSON 接口**,数据内嵌在 SSR HTML 的 `self.__next_f` flight 里 |
+| 详情(表1) | `GET /v1/apparels/{apparel_id}/used/{used_item_id}` | 返回完整 `apparelUsedItem`,无需 cookie |
+| 交易记录(表2) | `GET /v3/products/{product_id}/trading-history?range=all` | 返回 `trades[]`,无需 cookie,一次性全量 |
+
+**三个 ID 的区别(易踩坑)**:
+- `apparel_id`(URL 中 `/apparels/{}`):款式ID
+- `used_item_id`(URL 中 `/used/{}`):某个具体二手出品ID
+- `product_id` = 详情里的 `apparel.productId`(商品目录 catalog ID):**交易记录接口用的是它,不是 apparel_id**。需先抓详情拿到 product_id,再抓交易记录。
+
+**关键字段来源**:
+- 列表卡片:`displayCardPattern`(含 `SoldOut` 即已售)、`title`、`link`(含 apparel_id+used_item_id)、`imageUrl`、`salePrice`、`condition`、`favoriteCount`
+- 交易记录 `trades[]`:`price`→成交价、`soldAt`→成交时间(UTC)、`title`→品相(A/B/C..)、`label`→枚数(1枚..)
+
+## 4. 逆向思路
+
+1. **列表页找数据源**:`/search` 是 Next.js SSR,商品卡片在客户端由 flight 数据 hydration,页面 raw HTML 里没有 `<a href>` 卡片 DOM,`_rsc` 接口又依赖动态 token。最终定位:数据以 `self.__next_f.push([1,"..."])` 分片写入 HTML。
+2. **还原 flight 数据**:正则抽出所有分片字符串 → 逐段 `json.loads('"'+p+'"')` 反转义 → 拼接成完整文本 → 「花括号配平」抠出每个 `{"displayCardPattern"...}` 卡片对象 → `json.loads`。(见 `_extract_flight_cards`)
+3. **SOLD 判定**:卡片 `displayCardPattern` 含 `SoldOut` = 已售(对应网页左上角 SOLD 角标)。
+4. **详情/交易记录**:直接命中 v1/v3 JSON 接口,无需解析 HTML。
+5. **无 cookie 验证**:`credentials:'omit'` 实测三类接口均 200,确认可纯 requests 抓取。
+
+## 5. 算法要点
+
+无加密 / 无签名。唯一「非直观」点是列表页的 **Next.js flight data 解析**:
+
+```python
+# 1) 抽取并拼接所有 flight 分片
+parts = re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)', html)
+flight = "".join(json.loads('"' + p + '"') for p in parts)   # 反转义后拼接
+# 2) 花括号配平(尊重字符串内的转义引号)逐个抠出 {"displayCardPattern"...} 对象 → json.loads
+```
+
+## 6. 踩坑记录
+
+- **交易记录无图**(需求3):`trading-history` 接口的 `trades[]` 是平台**聚合/匿名化**数据,每条只有价格/时间/品相/枚数,**故意不含图片和出品ID**——不是遗漏。若要给每条配图,复用现有管线 `match_data` 思路:以 `(product_id + 品相 + 价格)` 关联表1、取 `primary_image_url` 回填(届时给表2 加一个 `image_url` 字段)。
+- **交易记录接口硬限 20 条**:不管 `range=all/oneWeek/oneMonth/...`、`page/perPage/limit/offset/size` 各种参数都固定返回最近 20 条。所以**不能用「先删后插」**(会永远丢老历史),必须**跨天累积**。
+- **同秒真实重复成交**:同一 `soldAt` 秒可能出现多条完全相同的真实成交(如 6 笔 `¥1000 / B / 1枚 / 2026-07-17T03:50:03Z`)。爬虫端解析时给同签名记录编 `trade_index_in_second = 0/1/2/...` 位序,配合联合唯一索引 + `INSERT IGNORE` 累积去重,既能保留真实重复、又能跨天自动跳过已存在的。前提:接口对 `trades` 的排序在同秒内相对稳定(实测成立)。
+- **同款卡多出品共享同一份买卖历史**:买卖历史按 `product_id` 聚合,而一张卡可能有几十个不同的 `used_item_id` 出品。批量模式下用 `seen_product_ids` 集合,每次 run 每个 `product_id` 只调一次交易记录接口。
+- **JP 站无 `/v1/trading-cards/used`**:EN 站有、JP 站 404,故 JP 列表只能解析搜索页 flight。
+- **EN 分页交易接口需 cookie**:`/en/v1/products/SW---{id}/trading-histories` 返回 401;JP 的 `/v3/.../trading-history` 无需 cookie,优先用后者。
+- **图片带尺寸参数**:`...jpeg?size=m`,入库前 `split("?")[0]` 去掉,保留原图。
+- **product_id ≠ apparel_id**:交易记录必须用 `apparel.productId`。
+- **mysql_pool 读运行目录 `application.yml`**:务必在项目目录下运行脚本。
+
+## 7. 使用方式
+
+```bash
+# 依赖:requests / parsel / user_agent / schedule / loguru / tenacity / charley-utils(mysql_pool)
+# 1) 先执行 ddl.sql 建表(snkrdunk_jp_used_item / snkrdunk_jp_trading_history)
+# 2) 确保项目目录下有 application.yml(mysql 连接配置)
+# 3) 运行 snk_jp_spider.py,按底部开关选择任务:
+```
+
+| 场景 | 调用 |
+|---|---|
+| 任务2 单条详情+交易记录 | `detail_main(logger, apparel_id=835482, used_item_id=47574548)` |
+| 任务1 列表页全部已售 → 表1 | `list_main(log=logger)` |
+| 批量补全表1 列表记录的详情+交易记录 | `detail_main(logger)`(先跑完 `list_main`) |
+| 每日定时(列表+详情+交易记录) | `schedule_task()` |
+
+## 8. 举一反三
+
+- 换商品/品类:改 `LIST_URL_TEMPLATE` 的 `keywords / brandIds / searchCategoryIds / itemConditions` 即可,解析逻辑通用。
+- 该 flight 解析法适用于所有 Next.js App Router SSR 站点:`self.__next_f` 分片 + 花括号配平抠对象。
+- EN 站(`/en/v1/...`)已有独立 JSON 接口,若需英文数据可直接调用,无需解析 flight。

+ 98 - 0
snkrdunk_jp_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
snkrdunk_jp_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}

+ 55 - 0
snkrdunk_jp_spider/ddl.sql

@@ -0,0 +1,55 @@
+-- ============================================================================
+-- SNKRDUNK 日本站 爬虫建表脚本
+-- 变更日期 2026/07/22
+-- 表1 snkrdunk_jp_used_item      : 二手出品 / 商品信息(列表页 + 详情页)
+-- 表2 snkrdunk_jp_trading_history : 买卖历史(交易记录)
+-- 说明:字段名一律按原意翻译,避开 MySQL 保留字(如 condition/status 已改名)
+-- ============================================================================
+
+-- ------------------------------ 表1:商品信息 ------------------------------
+CREATE TABLE IF NOT EXISTS `snkrdunk_jp_used_item` (
+  `id`                        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+  `apparel_id`                BIGINT       NOT NULL                COMMENT '商品(款式)ID,URL 中 /apparels/{apparel_id}',
+  `used_item_id`              BIGINT       NOT NULL                COMMENT '二手出品ID,URL 中 /used/{used_item_id}',
+  `product_id`                BIGINT       DEFAULT NULL            COMMENT '商品目录ID(catalog),交易记录接口用的就是它',
+  `product_number`            VARCHAR(128) DEFAULT NULL            COMMENT '品番,如 pkmn-tcg-M-P-133',
+  `name_ja`                   VARCHAR(512) DEFAULT NULL            COMMENT '商品名(日文)',
+  `name_en`                   VARCHAR(512) DEFAULT NULL            COMMENT '商品名(英文)',
+  `brand_id`                  VARCHAR(64)  DEFAULT NULL            COMMENT '品牌ID,如 pokemon',
+  `brand_name`                VARCHAR(255) DEFAULT NULL            COMMENT '品牌名(日文)',
+  `category_name`             VARCHAR(255) DEFAULT NULL            COMMENT '类目(日文),如 トレカ (シングルカード)',
+  `quantity_text`             VARCHAR(32)  DEFAULT NULL            COMMENT '枚数,如 1枚',
+  `condition_grade`           VARCHAR(32)  DEFAULT NULL            COMMENT '品相/评级,A/B/C/D/PSA10/BGS9.5/ARS10 等',
+  `condition_desc`            VARCHAR(255) DEFAULT NULL            COMMENT '品相完整描述,如 B(小さなキズ/ソリがある品)',
+  `price`                     INT          DEFAULT NULL            COMMENT '售价(日元)',
+  `sale_status`               INT          DEFAULT NULL            COMMENT '出品状态码,0=出品中/入札中,4=取引完了(已售)',
+  `sale_status_text`          VARCHAR(64)  DEFAULT NULL            COMMENT '出品状态文本,如 取引完了',
+  `is_sold`                   TINYINT(1)   DEFAULT NULL            COMMENT '是否已售,1=SOLD,0=在售',
+  `primary_image_url`         VARCHAR(512) DEFAULT NULL            COMMENT '主图URL',
+  `image_urls`                TEXT         DEFAULT NULL            COMMENT '全部图片URL,多个用英文逗号(,)分割',
+  `detail_url`                VARCHAR(512) DEFAULT NULL            COMMENT '详情页URL',
+  `released_at`               DATETIME     DEFAULT NULL            COMMENT '发售时间(已转为北京时间 UTC+8)',
+  `data_source`               VARCHAR(16)  DEFAULT NULL            COMMENT '数据来源:list=列表页,detail=详情页',
+  `gmt_create_time`           DATETIME     DEFAULT CURRENT_TIMESTAMP                          COMMENT '创建时间',
+  `gmt_modified_time`         DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  UNIQUE KEY `uk_apparel_used` (`apparel_id`, `used_item_id`),
+  KEY `idx_product` (`product_id`),
+  KEY `idx_is_sold` (`is_sold`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SNKRDUNK日本站 商品信息';
+
+-- ------------------------------ 表2:买卖历史(交易记录) ------------------------------
+CREATE TABLE IF NOT EXISTS `snkrdunk_jp_trading_history` (
+  `id`                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+  `product_id`        BIGINT       NOT NULL       COMMENT '商品目录ID(catalog)',
+  `apparel_id`        BIGINT       DEFAULT NULL   COMMENT '关联的商品(款式)ID',
+  `product_number`    VARCHAR(128) DEFAULT NULL   COMMENT '品番,如 pkmn-tcg-M-P-133',
+  `sold_price`        INT          NOT NULL       COMMENT '成交价(日元)',
+  `condition_grade`   VARCHAR(32)  DEFAULT NULL   COMMENT '成交时品相/评级,A/B/C/D/PSA10 等',
+  `quantity_text`     VARCHAR(32)  DEFAULT NULL   COMMENT '枚数,如 1枚',
+  `sold_at`               DATETIME              DEFAULT NULL COMMENT '成交时间(已转为北京时间 UTC+8,与现有管线 trading_at 一致)',
+  `trade_index_in_second` TINYINT UNSIGNED      NOT NULL DEFAULT 0 COMMENT '同 (product_id, sold_at, 价格, 品相, 枚数) 签名内的位序 0/1/2..,用于区分同秒的多笔真实重复成交',
+  `gmt_create_time`       DATETIME              DEFAULT CURRENT_TIMESTAMP                          COMMENT '创建时间',
+  `gmt_modified_time`     DATETIME              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  UNIQUE KEY `uk_trade` (`product_id`, `sold_at`, `sold_price`, `condition_grade`, `quantity_text`, `trade_index_in_second`),
+  KEY `idx_sold_at` (`sold_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SNKRDUNK日本站 买卖历史(交易记录)';

+ 671 - 0
snkrdunk_jp_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)

+ 9 - 0
snkrdunk_jp_spider/requirements.txt

@@ -0,0 +1,9 @@
+-i https://mirrors.aliyun.com/pypi/simple/
+DBUtils==3.1.2
+loguru==0.7.3
+PyMySQL==1.1.2
+PyYAML==6.0.3
+requests==2.33.1
+schedule==1.2.2
+tenacity==9.1.4
+user_agent==0.1.14

+ 588 - 0
snkrdunk_jp_spider/snk_jp_spider.py

@@ -0,0 +1,588 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/07/22
+"""SNKRDUNK 日本站 爬虫(任务1 列表页 + 任务2 详情页/交易记录)。
+
+任务1(列表页):抓取搜索列表页中「带 SOLD 字样」的二手出品,存入表1 snkrdunk_jp_used_item。
+任务2(详情页):抓取单个详情页完整字段存入表1,并抓其交易记录(买卖历史)存入表2 snkrdunk_jp_trading_history。
+
+接口说明(均为同源 GET、无需登录 / 无加密):
+  - 列表页: GET /search?...(Next.js SSR 页面,商品数据内嵌在 HTML 的 self.__next_f flight 里,非独立 JSON 接口)
+  - 详情:   GET /v1/apparels/{apparel_id}/used/{used_item_id}
+  - 交易记录:GET /v3/products/{product_id}/trading-history?range=all
+             返回 trades[],每条 {price, soldAt, title(品相), label(枚数)}。
+
+关于「给交易记录配图」(需求3):
+  交易记录接口是平台聚合/匿名化数据,每条只有「价格/时间/品相/枚数」,本身不含图片和出品ID——
+  这是设计使然。若日后需要配图,可复用现有管线 match_data 的思路:用 (product_id + 品相 + 价格)
+  关联表1,取表1 的 primary_image_url 回填到表2(届时给表2 加一个 image_url 字段即可)。
+"""
+import re
+import sys
+import json
+import time
+import random
+import requests
+import schedule
+import user_agent
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+from datetime import datetime, timezone, timedelta
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+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")
+
+# 北京时区:UTC+8(与现有管线 trading_at 保持一致)
+BEIJING_TZ = timezone(timedelta(hours=8))
+
+# 表名
+TABLE_USED_ITEM = "snkrdunk_jp_used_item"            # 表1:二手出品/商品信息
+TABLE_TRADING_HISTORY = "snkrdunk_jp_trading_history"  # 表2:交易记录
+
+# 列表页 URL 模板:{page} 处替换页码,其余筛选条件与给定的搜索 URL 完全一致
+LIST_URL_TEMPLATE = (
+    "https://snkrdunk.com/search?"
+    "keywords=Pokemon+Card+Game+%E3%83%88%E3%83%AC%E3%82%AB+%28%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%82%AB%E3%83%BC%E3%83%89%29"
+    "&searchCategoryIds=6%2F33"
+    "&brandIds=pokemon"
+    "&sort=launch"
+    "&itemConditions=like_new,minor_scratches,moderate_scratches,significant_damage,"
+    "psa_10,psa_9,psa_8_below,bgs_10_black,bgs_10_gold,bgs_9_5,bgs_9_below,"
+    "ars_10_plus,ars_10,ars_9,ars_8_below"
+    "&page={page}"
+)
+
+# 翻页安全上限,防止解析异常导致死循环
+MAX_PAGE = 500
+
+headers = {
+    "accept-language": "ja",
+    "user-agent": user_agent.generate_user_agent(),
+}
+
+
+# ============================== 公共工具 ==============================
+def after_log(retry_state):
+    """tenacity retry 回调,统一打印重试日志。
+
+    :param 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(1), after=after_log)
+def get_proxys(log):
+    """获取代理配置(快代理隧道)。
+
+    :param log: 日志对象。
+    :return: 代理字典 {"http": ..., "https": ...}。
+    """
+    tunnel = "x371.kdltps.com:15818"
+    kdl_username = "t13753103189895"
+    kdl_password = "o0yefv6z"
+    try:
+        proxies = {
+            "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel},
+            "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel}
+        }
+        return proxies
+    except Exception as e:
+        log.error(f"Error getting proxy: {e}")
+        raise e
+
+
+def utc_to_beijing(utc_str):
+    """将带 Z 的 UTC 时间字符串转换成北京时间字符串。
+
+    接口返回的 soldAt / releasedAt 形如 "2026-07-21T23:16:09Z",结尾 Z 表示 UTC,
+    北京时间为 UTC+8。
+
+    :param utc_str: UTC 时间字符串,格式 "%Y-%m-%dT%H:%M:%SZ"。
+    :return: 北京时间字符串,格式 "%Y-%m-%d %H:%M:%S";入参为空或解析失败返回 None。
+    """
+    if not utc_str:
+        return None
+    try:
+        dt_utc = datetime.strptime(utc_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
+        return dt_utc.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
+    except Exception:
+        return None
+
+
+def upsert_used_items(log, sql_pool, data_list):
+    """批量 upsert 到表1(按 apparel_id + used_item_id 唯一键去重更新)。
+
+    使用 INSERT ... ON DUPLICATE KEY UPDATE,且更新时用 COALESCE(VALUES(col), col),
+    保证列表页带来的空字段不会覆盖掉详情页已写入的更完整数据(反之亦然)。
+    同一次调用的所有 dict 必须拥有相同的 key。
+
+    :param log: 日志对象。
+    :param sql_pool: 数据库连接池 MySQLConnectionPool。
+    :param data_list: list[dict],表1 入库字典列表。
+    :return: int,影响行数;无数据返回 0。
+    """
+    if not data_list:
+        return 0
+
+    cols = list(data_list[0].keys())
+    key_cols = {"apparel_id", "used_item_id"}  # 唯一键列不参与 UPDATE
+    col_sql = ", ".join(f"`{c}`" for c in cols)
+    placeholder = "(" + ", ".join(["%s"] * len(cols)) + ")"  # 单行占位,executemany 自动合并多行
+    update_sql = ", ".join(
+        f"`{c}`=COALESCE(VALUES(`{c}`), `{c}`)" for c in cols if c not in key_cols
+    )
+    sql = (
+        f"INSERT INTO `{TABLE_USED_ITEM}` ({col_sql}) VALUES {placeholder} "
+        f"ON DUPLICATE KEY UPDATE {update_sql}"
+    )
+    # 用封装的 insert_many(query + args_list 模式,内部 executemany,
+    # pymysql 会识别并保留 ON DUPLICATE KEY UPDATE 尾巴、自动合并多行)
+    args_list = [tuple(d.get(c) for c in cols) for d in data_list]
+    total = sql_pool.insert_many(query=sql, args_list=args_list)
+    log.info(f"upsert 表1 完成,影响 {total} 行")
+    return total
+
+
+# ============================== 任务1:列表页 ==============================
+def _extract_flight_cards(html):
+    """从列表页 HTML 的 flight 数据中抽取所有商品卡片对象。
+
+    Next.js 把服务端渲染的数据分片写在若干 self.__next_f.push([1,"..."]) 里,
+    这里先把这些分片的字符串拼接还原成完整 flight 文本,再用「花括号配平」的方式
+    逐个提取以 {"displayCardPattern" 开头的卡片 JSON 对象。
+
+    :param html: 列表页 HTML 源码。
+    :return: list[dict],每个元素为一张卡片对象;解析失败返回空列表。
+    """
+    # 1) 抽取并拼接所有 flight 分片字符串
+    parts = re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)', html)
+    flight = ""
+    for p in parts:
+        try:
+            # 分片本身是 JS 字符串字面量,用 JSON 解码还原转义
+            flight += json.loads('"' + p + '"')
+        except Exception:
+            continue
+
+    # 2) 花括号配平,逐个抠出卡片对象
+    cards = []
+    marker = '{"displayCardPattern"'
+    start = flight.find(marker)
+    while start != -1:
+        depth = 0
+        in_str = False
+        escape = False
+        end = -1
+        for i in range(start, len(flight)):
+            ch = flight[i]
+            if escape:
+                escape = False
+                continue
+            if ch == '\\':
+                escape = True
+                continue
+            if ch == '"':
+                in_str = not in_str
+                continue
+            if in_str:
+                continue
+            if ch == '{':
+                depth += 1
+            elif ch == '}':
+                depth -= 1
+                if depth == 0:
+                    end = i
+                    break
+        if end == -1:
+            break
+        obj_str = flight[start:end + 1]
+        try:
+            cards.append(json.loads(obj_str))
+        except Exception:
+            pass
+        start = flight.find(marker, end + 1)
+
+    return cards
+
+
+def _parse_link_ids(link):
+    """从卡片 link 中解析 apparel_id 与 used_item_id。
+
+    :param link: 详情页链接,如 https://snkrdunk.com/apparels/835482/used/47705323。
+    :return: tuple(apparel_id:int|None, used_item_id:int|None);解析失败返回 (None, None)。
+    """
+    m = re.search(r'/apparels/(\d+)/used/(\d+)', link or "")
+    if not m:
+        return None, None
+    return int(m.group(1)), int(m.group(2))
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
+def get_single_page(log, page):
+    """获取并解析单页列表数据,仅返回「已售」卡片。
+
+    :param log: 日志对象。
+    :param page: 页码。
+    :return: tuple(sold_list:list[dict], total_cards:int)。
+             sold_list 为已售卡片解析后的入库字典列表;total_cards 为该页卡片总数
+             (用于判断是否已翻到底)。
+    """
+    log.info(f"获取列表第 {page} 页数据....................................................")
+    url = LIST_URL_TEMPLATE.format(page=page)
+    response = requests.get(url, headers={**headers, "accept": "text/html"},
+                            proxies=get_proxys(log), timeout=22)
+    response.raise_for_status()
+
+    cards = _extract_flight_cards(response.text)
+
+    sold_list = []
+    for card in cards:
+        pattern = card.get("displayCardPattern", "")
+        # 只保留带 SOLD 字样(displayCardPattern 含 SoldOut)的已售卡片
+        if "SoldOut" not in pattern:
+            continue
+
+        link = card.get("link", "")
+        apparel_id, used_item_id = _parse_link_ids(link)
+        if not apparel_id or not used_item_id:
+            log.warning(f"卡片 link 无法解析 id,跳过:{link}")
+            continue
+
+        # 去掉图片尺寸参数,保留原图地址,如 ...jpeg?size=m -> ...jpeg
+        image_url = (card.get("imageUrl") or "").split("?")[0] or None
+
+        sold_list.append({
+            "apparel_id": apparel_id,
+            "used_item_id": used_item_id,
+            "name_ja": card.get("title"),
+            "condition_grade": card.get("condition"),
+            "price": card.get("salePrice"),
+            "is_sold": 1,
+            "primary_image_url": image_url,
+            "detail_url": link,
+            "data_source": "list",
+        })
+
+    log.info(f"第 {page} 页共 {len(cards)} 张卡片,其中已售 {len(sold_list)} 张")
+    return sold_list, len(cards)
+
+
+def get_list_data(log, sql_pool):
+    """翻页抓取整个列表,将所有已售卡片写入表1(自动翻到底)。
+
+    :param log: 日志对象。
+    :param sql_pool: 数据库连接池 MySQLConnectionPool。
+    """
+    page = 1
+    total_sold = 0
+    while page <= MAX_PAGE:
+        try:
+            sold_list, total_cards = get_single_page(log, page)
+        except Exception as e:
+            log.error(f"获取列表第 {page} 页出错:{e}")
+            break
+
+        # 该页无任何卡片,说明已翻到底
+        if total_cards == 0:
+            log.info(f"列表第 {page} 页无数据,翻页结束")
+            break
+
+        try:
+            upsert_used_items(log, sql_pool, sold_list)
+            total_sold += len(sold_list)
+        except Exception as e:
+            log.error(f"第 {page} 页数据入库出错:{e}")
+
+        page += 1
+        time.sleep(random.uniform(0.3, 1.2))
+
+    log.info(f"列表抓取结束,累计入库已售卡片 {total_sold} 张")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def list_main(log):
+    """列表页爬虫主函数(任务1 入口)。
+
+    :param log: 日志对象。
+    """
+    log.info(f'开始运行 {sys._getframe().f_code.co_name} 爬虫任务....................................................')
+
+    sql_pool = MySQLConnectionPool(log=log)
+    if not sql_pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+
+    try:
+        get_list_data(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} 运行结束,等待下一轮的采集任务............')
+
+
+# ============================== 任务2:详情页 + 交易记录 ==============================
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
+def get_detail(log, apparel_id, used_item_id):
+    """抓取单个二手出品的详情 JSON。
+
+    :param log: 日志对象。
+    :param apparel_id: 商品(款式)ID,URL 中 /apparels/{apparel_id}。
+    :param used_item_id: 二手出品ID,URL 中 /used/{used_item_id}。
+    :return: dict,详情接口返回的完整 JSON;请求失败抛出异常。
+    """
+    log.info(f"获取详情数据 -> apparel={apparel_id}, used={used_item_id} .............................")
+    url = f"https://snkrdunk.com/v1/apparels/{apparel_id}/used/{used_item_id}"
+    response = requests.get(url, headers={**headers, "accept": "application/json"},
+                            proxies=get_proxys(log), timeout=22)
+    response.raise_for_status()
+    return response.json()
+
+
+def parse_detail(log, detail_json, apparel_id, used_item_id):
+    """把详情 JSON 解析成表1 入库字典。
+
+    :param log: 日志对象。
+    :param detail_json: get_detail 返回的完整 JSON。
+    :param apparel_id: 商品(款式)ID。
+    :param used_item_id: 二手出品ID。
+    :return: tuple(data_dict:dict, product_id:int|None)。
+             data_dict 为表1 入库字典;product_id 供后续抓交易记录使用。
+    """
+    item = detail_json.get("apparelUsedItem", {}) or {}
+    apparel = item.get("apparel", {}) or {}
+
+    # 品牌(取第一个)
+    brands = apparel.get("brands") or []
+    brand_id = brands[0].get("id") if brands else None
+    brand_name = brands[0].get("localizedName") if brands else None
+
+    # 类目(多个时用 / 连接,取日文名)
+    categories = apparel.get("categories") or []
+    category_name = " / ".join(c.get("localizedName", "") for c in categories) if categories else None
+
+    # 主图去掉尺寸参数
+    primary_image_url = ((item.get("primaryPhoto") or {}).get("imageUrl") or "").split("?")[0] or None
+
+    # 全部图片 URL,多个用英文逗号拼接
+    image_urls = item.get("imageUrls") or []
+    image_urls_str = ",".join(image_urls) if image_urls else None
+
+    # 是否已售:优先用 isDisplaySold,其次用状态码 4=取引完了
+    is_sold = 1 if item.get("isDisplaySold") or item.get("status") == 4 else 0
+
+    product_id = apparel.get("productId") or None
+
+    data_dict = {
+        "apparel_id": apparel_id,
+        "used_item_id": used_item_id,
+        "product_id": product_id,
+        "product_number": apparel.get("productNumber") or None,
+        "name_ja": apparel.get("localizedName") or None,
+        "name_en": apparel.get("name") or None,
+        "brand_id": brand_id,
+        "brand_name": brand_name,
+        "category_name": category_name,
+        "quantity_text": (item.get("size") or {}).get("localizedName") or None,
+        "condition_grade": item.get("displayShortConditionTitle") or None,
+        "condition_desc": item.get("displayWearCount") or None,
+        "price": item.get("price"),
+        "sale_status": item.get("status"),
+        "sale_status_text": item.get("statusText") or None,
+        "is_sold": is_sold,
+        "primary_image_url": primary_image_url,
+        "image_urls": image_urls_str,
+        "detail_url": f"https://snkrdunk.com/apparels/{apparel_id}/used/{used_item_id}",
+        "released_at": utc_to_beijing(apparel.get("releasedAt")),
+        "data_source": "detail",
+    }
+    return data_dict, product_id
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
+def get_trading_history(log, product_id):
+    """抓取某商品的完整交易记录(买卖历史)。
+
+    :param log: 日志对象。
+    :param product_id: 商品目录ID(catalog),来自详情 apparel.productId。
+    :return: list[dict],交易记录原始 trades 列表;请求失败抛出异常。
+    """
+    log.info(f"获取交易记录 -> product_id={product_id} .............................")
+    url = f"https://snkrdunk.com/v3/products/{product_id}/trading-history"
+    params = {"range": "all"}
+    response = requests.get(url, headers={**headers, "accept": "application/json"},
+                            params=params, proxies=get_proxys(log), timeout=22)
+    response.raise_for_status()
+    return response.json().get("trades", []) or []
+
+
+def save_trading_history(log, sql_pool, product_id, apparel_id, product_number, trades):
+    """把交易记录写入表2,采用「联合唯一索引 + INSERT IGNORE」累积去重。
+
+    背景:
+      - 接口硬性截断到最近 20 条,不能删旧再插(会丢历史),必须**跨天累积**。
+      - 表2 有联合唯一键 (product_id, sold_at, sold_price, condition_grade, quantity_text,
+        trade_index_in_second),配合 INSERT IGNORE 保证跨天抓到的重复记录自动跳过、
+        新记录正常入库。
+
+    trade_index_in_second 计算:
+      同一个 (sold_at + 价格 + 品相 + 枚数) 签名可能对应多笔真实成交(如 6 笔同秒
+      ¥1000 的 B 品 1枚),按接口返回顺序编号 0/1/2/... 作为位序,才能把它们全部保留。
+      前提是接口对 trades 的排序稳定(同 soldAt 内相对顺序不变),实测方案。
+
+    :param log: 日志对象。
+    :param sql_pool: 数据库连接池 MySQLConnectionPool。
+    :param product_id: 商品目录ID。
+    :param apparel_id: 关联的商品(款式)ID,冗余存储便于关联表1。
+    :param product_number: 品番,冗余存储。
+    :param trades: get_trading_history 返回的 trades 列表(按接口原始顺序传入)。
+    :return: int,本次实际新增行数(已存在的靠 INSERT IGNORE 跳过、不计入)。
+    """
+    if not trades:
+        log.warning(f"product_id={product_id} 无交易记录")
+        return 0
+
+    sig_counter = {}  # 记录每个签名出现过几次,用作 trade_index_in_second
+    data_list = []
+    for tr in trades:
+        sold_at = utc_to_beijing(tr.get("soldAt"))
+        sold_price = tr.get("price")
+        condition_grade = tr.get("title")   # 接口 title 即品相 A/B/C...
+        quantity_text = tr.get("label")     # 接口 label 即枚数 1枚/2枚...
+        sig = (sold_at, sold_price, condition_grade, quantity_text)
+        idx = sig_counter.get(sig, 0)
+        sig_counter[sig] = idx + 1
+        data_list.append({
+            "product_id": product_id,
+            "apparel_id": apparel_id,
+            "product_number": product_number,
+            "sold_price": sold_price,
+            "condition_grade": condition_grade,
+            "quantity_text": quantity_text,
+            "sold_at": sold_at,
+            "trade_index_in_second": idx,
+        })
+
+    new_rows = sql_pool.insert_many(table=TABLE_TRADING_HISTORY, data_list=data_list, ignore=True)
+    log.info(f"交易记录入库 -> product_id={product_id},接口 {len(trades)} 条,本次新增 {new_rows} 条(重复的已跳过)")
+    return new_rows
+
+
+def crawl_detail_and_history(log, sql_pool, apparel_id, used_item_id, seen_product_ids=None):
+    """抓取单个详情页:详情入表1 + 交易记录入表2(任务2 单条流程)。
+
+    详情(表1) 每次都要抓(每个出品各自独立);但交易记录(表2) 是按 product_id 聚合的,
+    同一 product_id 每次 run 只需抓一次——传入 seen_product_ids 集合可实现批量模式下
+    的自动去重(避免同款卡的多个出品重复调交易记录接口)。
+
+    :param log: 日志对象。
+    :param sql_pool: 数据库连接池 MySQLConnectionPool。
+    :param apparel_id: 商品(款式)ID。
+    :param used_item_id: 二手出品ID。
+    :param seen_product_ids: 可选,本次 run 已抓过交易记录的 product_id 集合;命中则跳过。
+                             为 None 时不做去重(单条模式用)。
+    """
+    # 1) 详情 -> 表1
+    detail_json = get_detail(log, apparel_id, used_item_id)
+    data_dict, product_id = parse_detail(log, detail_json, apparel_id, used_item_id)
+    upsert_used_items(log, sql_pool, [data_dict])
+
+    # 2) 交易记录 -> 表2
+    if not product_id:
+        log.warning(f"apparel={apparel_id} 未取到 product_id,跳过交易记录")
+        return
+
+    # 同一 product_id 每次 run 只抓一次交易记录(同款卡的多个出品共用同一份买卖历史)
+    if seen_product_ids is not None:
+        if product_id in seen_product_ids:
+            log.info(f"product_id={product_id} 本次已抓过交易记录,跳过")
+            return
+        seen_product_ids.add(product_id)
+
+    try:
+        trades = get_trading_history(log, product_id)
+        save_trading_history(log, sql_pool, product_id, apparel_id, data_dict.get("product_number"), trades)
+    except Exception as e:
+        log.error(f"抓取交易记录出错 product_id={product_id}: {e}")
+
+
+def detail_main(log, apparel_id=None, used_item_id=None):
+    """详情+交易记录爬虫主函数(任务2 入口)。
+
+    传入 apparel_id / used_item_id 时只抓该条;不传时从表1 里筛出列表页抓来的、
+    尚未补全详情的已售项(data_source='list')逐个补全详情与交易记录。
+
+    :param log: 日志对象。
+    :param apparel_id: 可选,指定要抓的商品(款式)ID。
+    :param used_item_id: 可选,指定要抓的二手出品ID。
+    """
+    log.info(f'开始运行 {sys._getframe().f_code.co_name} 爬虫任务....................................................')
+
+    sql_pool = MySQLConnectionPool(log=log)
+    if not sql_pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+
+    # 模式一:指定单条
+    if apparel_id and used_item_id:
+        try:
+            crawl_detail_and_history(log, sql_pool, apparel_id, used_item_id)
+        except Exception as e:
+            log.error(f'抓取详情 apparel={apparel_id} used={used_item_id} 异常: {e}')
+        return
+
+    # 模式二:批量补全表1 中列表页抓来的记录
+    rows = sql_pool.select_all(
+        f"SELECT apparel_id, used_item_id FROM `{TABLE_USED_ITEM}` "
+        f"WHERE data_source = 'list' ORDER BY id"
+    )
+    log.info(f"待补全详情的记录数:{len(rows)}")
+    seen_product_ids = set()  # 本次 run 已抓过交易记录的 product_id,避免重复调接口
+    for _apparel_id, _used_item_id in rows:
+        try:
+            crawl_detail_and_history(log, sql_pool, _apparel_id, _used_item_id, seen_product_ids)
+        except Exception as e:
+            log.error(f'抓取详情 apparel={_apparel_id} used={_used_item_id} 异常: {e}')
+        time.sleep(random.uniform(0.3, 1.0))
+    log.info(f"批量模式结束,共抓取 {len(seen_product_ids)} 个不同 product_id 的交易记录")
+
+    log.info(f'爬虫程序 {sys._getframe().f_code.co_name} 运行结束............')
+
+
+def schedule_task():
+    """定时任务启动入口(每日 00:01 先跑列表,再补全详情+交易记录)。"""
+    def daily_job():
+        list_main(log=logger)      # 任务1:列表 -> 表1
+        detail_main(logger)        # 任务2:批量补全详情 -> 表1 + 交易记录 -> 表2
+
+    # daily_job()
+
+    schedule.every().day.at("00:01").do(daily_job)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == '__main__':
+    # 任务2 单条示例:主公给的详情页 https://snkrdunk.com/apparels/835482/used/47574548
+    # detail_main(logger, apparel_id=835482, used_item_id=47574548)
+
+    # 任务1:抓列表页所有已售 -> 表1
+    # list_main(log=logger)
+
+    # 任务2:批量补全表1 中列表页记录的详情与交易记录(先跑完 list_main 再放开)
+    # detail_main(logger)
+
+    # 定时任务(列表 + 详情/交易记录 每日跑一次)
+    schedule_task()