Parcourir la source

refactor(mysql_pool): 移除废弃的MySQL连接池模块

- 完全删除了旧的mysql连接池实现代码
- 优化MySQL连接池配置,添加字符集及连接超时参数
- 调整批量插入操作示例,改为使用insert_many接口
- 更新requirements.txt依赖版本,升级PyMySQL及相关库
- 新增中检检通接口逆向分析文档,详细说明请求签名与响应解密逻辑
- 重构爬虫主逻辑,实现AES-CBC模式响应数据解密
- 实现请求签名生成,使用MD5和动态时间戳
- 优化爬虫HTTP请求头,添加sign和datetime字段,确保接口访问有效
- 修正响应数据解析逻辑,支持解密后的JSON格式处理
- 增加定时调度任务入口及循环处理逻辑
- 完善日志记录,支持调试及错误追踪
charley il y a 1 semaine
Parent
commit
e32af01f3e

+ 0 - 191
zhongjian_spider/mysq_pool.py

@@ -1,191 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author  : Charley
-# Python  : 3.8.10
-# Date: 2024-08-05 19:42
-import pymysql
-import YamlLoader
-from loguru import logger
-from retrying import retry
-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=4, maxcached=5, maxconnections=10, log=None):
-        """
-        初始化连接池
-        :param mincached: 初始化时,链接池中至少创建的链接,0表示不创建
-        :param maxcached: 池中空闲连接的最大数目(0 或 None 表示池大小不受限制)
-        :param maxconnections: 允许的最大连接数(0 或 None 表示任意数量的连接)
-        """
-        # 使用 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
-        )
-
-    @retry(stop_max_attempt_number=100, wait_fixed=600000)
-    def _get_connection(self):
-        """
-        获取连接
-        :return: 连接
-        """
-        try:
-            return self.pool.connection()
-        except Exception as e:
-            self.log.error(f"Failed to get connection from pool: {e}, wait 10 mins retry")
-            raise e
-
-    @staticmethod
-    def _close_connection(conn):
-        """
-        关闭连接
-        :param conn: 连接
-        """
-        if conn:
-            conn.close()
-
-    @retry(stop_max_attempt_number=5, wait_fixed=1000)
-    def _execute(self, query, args=None, commit=False):
-        """
-        执行SQL
-        :param query: SQL语句
-        :param args: SQL参数
-        :param commit: 是否提交事务
-        :return: 查询结果
-        """
-        conn = None
-        cursor = None
-        try:
-            conn = self._get_connection()
-            cursor = conn.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 conn and not commit:
-                conn.rollback()
-            self.log.error(f"Error executing query: {e}")
-            raise e
-        finally:
-            if cursor:
-                cursor.close()
-            self._close_connection(conn)
-
-    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 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
-        return self._execute(query, args, commit=True)
-
-    def insert_all(self, query, args_list):
-        """
-        执行批量插入语句,如果失败则逐条插入
-        :param query: 插入语句
-        :param args_list: 插入参数列表
-        """
-        conn = None
-        cursor = None
-        try:
-            conn = self._get_connection()
-            cursor = conn.cursor()
-            cursor.executemany(query, args_list)
-            conn.commit()
-            self.log.debug(f"sql insert_all , SQL: {query}, Rows: {cursor.rowcount}")
-            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_all 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
-        except Exception as e:
-            conn.rollback()
-            self.log.error(f"Batch insertion failed after 5 attempts. Trying single inserts. Error: {e}")
-            # 如果批量插入失败,则逐条插入
-            rowcount = 0
-            for args in args_list:
-                self.insert_one(query, args)
-                rowcount += 1
-            self.log.debug(f"Batch insertion failed. Inserted {rowcount} rows individually.")
-        finally:
-            cursor.close()
-            self._close_connection(conn)
-
-    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._get_connection()
-            cursor = conn.cursor()
-            cursor.executemany(query, args_list)
-            conn.commit()
-            self.log.debug(f"sql update_all , SQL: {query}, Rows: {cursor.rowcount}")
-            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:
-            cursor.close()
-            self._close_connection(conn)

+ 31 - 9
zhongjian_spider/mysql_pool.py

@@ -44,7 +44,10 @@ class MySQLConnectionPool:
             user=sql_user,
             password=sql_password,
             database=sql_db,
-            ping=2,  # 每次执行前检查连接有效性,防止使用已断开的连接
+            charset="utf8mb4",
+            use_unicode=True,
+            init_command="SET NAMES utf8mb4",
+            ping=1,  # 0:完全关闭(更快), 1:仅在取连接时检查, 2:每次执行前检查连接有效性,防止使用已断开的连接
             connect_timeout=5,  # 连接超时时间(秒)
             # read_timeout=30,  # 读取超时时间(秒)
             write_timeout=30  # 写入超时时间(秒)
@@ -618,11 +621,30 @@ class MySQLConnectionPool:
 
 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)
+    # 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)
+
+    sql_pool.insert_many(
+        table="jhs_product_record",
+        data_list=[
+            {
+                "product_id": 99999991,
+                "seller_username": "浣熊小助理(裸卡版)",
+                "auction_product_name": "2000 日文 无编号 #175 U 波克比 有瑕疵",
+            },
+            {
+                "product_id": 99999992,
+                "seller_username": "测试商家二号",
+                "auction_product_name": "中文批量插入测试",
+            },
+        ],
+        ignore=False
+    )
+
+

+ 7 - 7
zhongjian_spider/requirements.txt

@@ -1,10 +1,10 @@
 -i https://mirrors.aliyun.com/pypi/simple/
-DBUtils==3.1.0
+DBUtils==3.1.2
 loguru==0.7.3
-PyMySQL==1.1.1
-PyYAML==6.0.2
-requests==2.32.3
-retrying==1.3.4
+pycryptodome==3.23.0
+PyMySQL==1.1.2
+PyYAML==6.0.3
+requests==2.33.1
 schedule==1.2.2
-tenacity==9.0.0
-user_agent==0.1.10
+tenacity==9.1.4
+user_agent==0.1.14

+ 84 - 33
zhongjian_spider/zj_new_daily_spider.py

@@ -3,19 +3,28 @@
 # Python : 3.10.8
 # Date   : 2025/6/9 15:56
 import time
+import json
 import inspect
 import requests
 import schedule
+import hashlib
+import base64
 import user_agent
 from loguru import logger
-from tenacity import retry, stop_after_attempt, wait_fixed
+from datetime import datetime
+from Crypto.Cipher import AES
+from Crypto.Util.Padding import unpad
 from mysql_pool import MySQLConnectionPool
+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")
 
+RESPONSE_KEY = b"3bd48ea5e910b195843941351be7cbae"  # 16字节AES密钥(UTF8编码)
+REQUEST_KEY = "1ba48ea2e910b666843941351be7cbad"
+
 
 def after_log(retry_state):
     """
@@ -55,21 +64,43 @@ def get_proxys(log):
         raise e
 
 
+def make_sign():
+    """生成sign: MD5(REQUEST_KEY + 当前时间戳秒)"""
+    now = datetime.now()
+    dt_str = now.strftime("%Y-%m-%d %H:%M:%S")
+    # JS: new Date(o).getTime() / 1e3 — 取秒级时间戳
+    timestamp = int(datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S").timestamp())
+    raw = REQUEST_KEY + str(timestamp)
+    return hashlib.md5(raw.encode()).hexdigest(), dt_str
+
+
+def decrypt_response(data_b64, iv_hex):
+    """AES-CBC解密响应数据"""
+    iv = iv_hex.encode("utf-8")  # JS: CryptoJS.enc.Utf8.parse(iv) — 直接UTF8编码
+    cipher = AES.new(RESPONSE_KEY, AES.MODE_CBC, iv)
+    ciphertext = base64.b64decode(data_b64)
+    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
+    return json.loads(plaintext.decode("utf-8"))
+
+
 @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
 def get_request_one_page(log, rating_no) -> dict:
+    """
+    获取单页数据
+    :param log: logger
+    :param rating_no: 证书编号
+    :return: dict
+    """
+    sign, dt_str = make_sign()
+
     headers = {
         "accept": "*/*",
         "accept-language": "en,zh-CN;q=0.9,zh;q=0.8",
         "content-type": "application/json;charset=UTF-8",
+        "datetime": dt_str,
         "origin": "https://www.zhongjianjiantong.com",
-        "priority": "u=1, i",
         "referer": "https://www.zhongjianjiantong.com/web/index.html",
-        "sec-ch-ua": "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
-        "sec-ch-ua-mobile": "?1",
-        "sec-ch-ua-platform": "\"Android\"",
-        "sec-fetch-dest": "empty",
-        "sec-fetch-mode": "cors",
-        "sec-fetch-site": "same-origin",
+        "sign": sign,
         "user-agent": user_agent.generate_user_agent()
     }
     url = "https://www.zhongjianjiantong.com/Api/OrderRatingGoods/detail"
@@ -81,36 +112,46 @@ def get_request_one_page(log, rating_no) -> dict:
         response = session.post(url, headers=headers, json=data, proxies=get_proxys(log), timeout=5)
     # print(response.text)
     response.raise_for_status()
-    return response.json()
+    result = response.json()
+    if result["code"] == 200 and result.get("iv"):
+        decrypted = decrypt_response(result["data"], result["iv"])
+        return decrypted
+    else:
+        return result
 
 
 def parse_data(resp_json, sql_pool):
-    card_id = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('id')
-    order_no = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('order_no')
-    tag_no = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('tag_no')  # 标签号/查询的号码
-
-    images = resp_json.get('data', {}).get('obj_order_rating_goods', []).get('images')
-    card_create_time = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('create_time')
-    card_update_time = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('update_time')
-    score = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('score')  # 中检评分
-    corners = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('corners')  # 四角
-    eoges = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('eoges')  # 边缘
-    surface = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('surface')  # 表面
-    centering = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('centering')  # 居中
-    colour = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('colour')  # 颜色
-    repair = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('repair')  # 修复
-
-    rating_no = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('rating_no')  # 证书编号
-    obj_brand_title = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_brand', {}).get(
+    """
+    解析数据
+    :param resp_json: 响应数据
+    :param sql_pool: 数据库连接池
+    """
+    card_id = resp_json.get('obj_order_rating_goods', {}).get('id')
+    order_no = resp_json.get('obj_order_rating_goods', {}).get('order_no')
+    tag_no = resp_json.get('obj_order_rating_goods', {}).get('tag_no')  # 标签号/查询的号码
+
+    images = resp_json.get('obj_order_rating_goods', []).get('images')
+    card_create_time = resp_json.get('obj_order_rating_goods', {}).get('create_time')
+    card_update_time = resp_json.get('obj_order_rating_goods', {}).get('update_time')
+    score = resp_json.get('obj_order_rating_goods', {}).get('score')  # 中检评分
+    corners = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('corners')  # 四角
+    eoges = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('eoges')  # 边缘
+    surface = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('surface')  # 表面
+    centering = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('centering')  # 居中
+    colour = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('colour')  # 颜色
+    repair = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('repair')  # 修复
+
+    rating_no = resp_json.get('obj_order_rating_goods', {}).get('rating_no')  # 证书编号
+    obj_brand_title = resp_json.get('obj_order_rating_goods', {}).get('obj_brand', {}).get(
         'title')  # 商品品牌
-    obj_detail_spxl = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
+    obj_detail_spxl = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
         'spxl')  # 商品系列
-    obj_detail_spmc = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
+    obj_detail_spmc = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
         'spmc')  # 商品名称
-    obj_detail_fxnf = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
+    obj_detail_fxnf = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
         'fxnf')  # 发行年份
-    obj_detail_yy = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get('yy')  # 语言
-    obj_detail_spbh = resp_json.get('data', {}).get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
+    obj_detail_yy = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get('yy')  # 语言
+    obj_detail_spbh = resp_json.get('obj_order_rating_goods', {}).get('obj_detail', {}).get(
         'spbh')  # 商品编号
 
     info = (
@@ -118,6 +159,7 @@ def parse_data(resp_json, sql_pool):
         centering,
         colour, repair, rating_no, obj_brand_title, obj_detail_spxl, obj_detail_spmc, obj_detail_fxnf, obj_detail_yy,
         obj_detail_spbh)
+    # print(info)
     sql = """
     INSERT INTO zhongjian_record (card_id, order_no, tag_no, images, card_create_time, card_update_time, score, corners, eoges, surface, centering, colour, repair, rating_no, obj_brand_title, obj_detail_spxl, obj_detail_spmc, obj_detail_fxnf, obj_detail_yy, obj_detail_spbh)
     VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
@@ -126,6 +168,12 @@ def parse_data(resp_json, sql_pool):
 
 
 def loop_rating_no(log, sql_pool, sql_ra_no_list):
+    """
+    循环处理每个证书编号
+    :param log: logger
+    :param sql_pool: 数据库连接池
+    :param sql_ra_no_list: 证书编号列表
+    """
     # sql_ra_no_list = sql_pool.select_all('select tag_no from zhongjian_task where state = 0 limit 10000')
     # sql_ra_no_list = [i[0] for i in sql_ra_no_list]
     for rating_no_ in sql_ra_no_list:
@@ -166,7 +214,10 @@ def zhongjian_main(log):
 
     try:
         # while True:
-        sql_ra_no_list = sql_pool.select_all('select tag_no from zhongjian_task where state = 0 limit 10000')
+        # sql_ra_no_list = sql_pool.select_all('select tag_no from zhongjian_task where state = 0 limit 10000')
+        sql_ra_no_list = sql_pool.select_all(
+            "select tag_no from zhongjian_task where tag_no like '529%' and state = 0 limit 50000")
+        # sql_ra_no_list = sql_pool.select_all("select tag_no from zhongjian_task where tag_no > '519354131' and state != 1 limit 10000")
         sql_ra_no_list = [i[0] for i in sql_ra_no_list]
         if not sql_ra_no_list:
             log.info(f'没有需要处理的数据,等待下一轮处理........................................................')
@@ -185,7 +236,7 @@ def zhongjian_main(log):
 
 def schedule_task():
     """
-    爬虫模块的启动文件
+    爬虫模块 定时任务 的启动文件
     """
     # 立即运行一次任务
     zhongjian_main(log=logger)

+ 237 - 0
zhongjian_spider/中检检通逆向分析文档.md

@@ -0,0 +1,237 @@
+# 中检检通(zhongjianjiantong.com)接口逆向分析
+
+## 一、目标
+
+对 `https://www.zhongjianjiantong.com` 网站的 API 接口进行逆向分析,解决以下两个问题:
+
+1. **请求签名**:`sign` 请求头的生成方式
+2. **响应解密**:接口返回的加密数据(`data` 字段)的解密方式
+
+---
+
+## 二、请求分析
+
+### 2.1 目标接口
+
+```
+POST https://www.zhongjianjiantong.com/Api/OrderRatingGoods/webDetail
+Content-Type: application/json;charset=UTF-8
+```
+
+请求体示例:
+
+```json
+{"rating_no":"519220343"}
+```
+
+### 2.2 关键请求头
+
+| 请求头 | 示例值 | 说明 |
+|--------|--------|------|
+| `sign` | `0e576e116bec064c34fb28f15906d6b3` | 32位hex,动态生成的签名 |
+| `datetime` | `2026-02-02 11:57:50` | 当前时间,与sign计算关联 |
+
+### 2.3 响应结构
+
+```json
+{
+  "code": 200,
+  "msg": "",
+  "data": "leUdchIlW5WHRTVdKEVqc0En...(Base64密文)",
+  "iv": "894CA3EE5756EDF4"
+}
+```
+
+- `data`:Base64 编码的 AES 密文
+- `iv`:16 字符的初始化向量(AES-CBC 模式使用)
+
+---
+
+## 三、逆向过程
+
+### 3.1 入口定位
+
+**目标文件**:`https://www.zhongjianjiantong.com/web/static/js/index.e181df9a.js`
+
+该文件为 webpack 打包的业务代码(单行压缩格式),通过以下方式定位关键逻辑:
+
+1. 搜索 `webDetail` → 找到 API 接口定义位置
+2. 搜索 `interceptors.response` → 找到 axios 响应拦截器
+3. 搜索 `JSON.parse` → 找到解密函数 `decryptedData`
+4. 搜索 `RESPONSE_KE` → 找到密钥常量定义
+
+### 3.2 常量定义模块(模块 `8fc7`)
+
+在 JS 源码中找到以下常量定义:
+
+```javascript
+// 模块 "8fc7" — 常量定义
+var v = "3bd48ea5e910b195843941351be7cbae";  // RESPONSE_KE — 响应解密密钥
+e.RESPONSE_KE = v;
+
+var g = "1ba48ea2e910b666843941351be7cbad";  // REQUESR_KEY — 请求签名密钥(原文拼写错误)
+e.REQUESR_KEY = g;
+
+var p = "sign";       // e.SIGN — sign请求头名
+var h = "datetime";   // e.DATETIME — datetime请求头名
+```
+
+### 3.3 请求签名逻辑(axios 请求拦截器)
+
+```javascript
+d.interceptors.request.use(function(t) {
+    // 生成当前时间字符串
+    var o = moment(new Date().getTime()).format("YYYY-MM-DD HH:mm:ss");
+
+    // sign = MD5(REQUESR_KEY + 秒级时间戳)
+    t.headers[r.SIGN] = md5(r.REQUESR_KEY + new Date(o).getTime() / 1e3);
+
+    // datetime = 当前时间字符串
+    t.headers[r.DATETIME] = o;
+
+    return t;
+});
+```
+
+**签名算法**:
+
+```
+sign = MD5("1ba48ea2e910b666843941351be7cbad" + 秒级时间戳)
+```
+
+其中秒级时间戳 = `new Date("YYYY-MM-DD HH:mm:ss").getTime() / 1000`
+
+### 3.4 响应解密逻辑
+
+在 axios 响应拦截器中,接收到响应后调用 `decryptedData` 解密:
+
+```javascript
+// 解密函数
+var r = function(t) {
+    var e = t.encryptedData;   // Base64密文(即 response.data)
+    var n = t.iv_data;         // IV(即 response.iv)
+
+    // 密钥:UTF-8编码 RESPONSE_KE
+    var r = CryptoJS.enc.Utf8.parse("3bd48ea5e910b195843941351be7cbae");
+
+    // IV:UTF-8编码
+    var o = CryptoJS.enc.Utf8.parse(n);
+
+    // AES-CBC 解密
+    var s = CryptoJS.AES.decrypt(
+        { ciphertext: CryptoJS.enc.Base64.parse(e) },
+        r,
+        {
+            iv: o,
+            mode: CryptoJS.mode.CBC,
+            padding: CryptoJS.pad.Pkcs7
+        }
+    );
+
+    var c = s.toString(CryptoJS.enc.Utf8);
+    return c ? JSON.parse(c) : c;
+};
+```
+
+---
+
+## 四、加密算法总结
+
+### 4.1 请求签名(sign)
+
+| 项目 | 值 |
+|------|-----|
+| 算法 | MD5 |
+| 输入 | `REQUEST_KEY` + 秒级时间戳(整数) |
+| REQUEST_KEY | `1ba48ea2e910b666843941351be7cbad` |
+| 输出 | 32位小写hex字符串 |
+
+### 4.2 响应解密
+
+| 项目 | 值 |
+|------|-----|
+| 算法 | AES |
+| 模式 | CBC |
+| 填充 | PKCS7 |
+| 密钥 | `3bd48ea5e910b195843941351be7cbae`(UTF-8编码,16字节) |
+| IV | 响应JSON中的 `iv` 字段(UTF-8编码,16字节) |
+| 密文 | 响应JSON中的 `data` 字段(Base64编码) |
+| 明文 | JSON字符串 |
+
+### 4.3 加密流程图
+
+```
+请求方向:
+┌──────────┐    sign = MD5(KEY + timestamp)    ┌──────────┐
+│  客户端   │ ──────────────────────────────────→ │  服务端   │
+│          │   headers: { sign, datetime }      │          │
+└──────────┘                                    └──────────┘
+
+响应方向:
+┌──────────┐    { data: Base64(AES_CBC(json)), iv: "..." }    ┌──────────┐
+│  客户端   │ ←────────────────────────────────────────────── │  服务端   │
+│          │    AES.decrypt(data, RESPONSE_KEY, iv)           │          │
+└──────────┘                                                  └──────────┘
+```
+
+---
+
+## 五、Python 实现
+
+### 5.1 依赖
+
+```bash
+pip install requests pycryptodome
+```
+
+### 5.2 核心代码
+
+```python
+import hashlib
+import json
+import base64
+from datetime import datetime
+from Crypto.Cipher import AES
+from Crypto.Util.Padding import unpad
+
+RESPONSE_KEY = b"3bd48ea5e910b195843941351be7cbae"
+REQUEST_KEY = "1ba48ea2e910b666843941351be7cbad"
+
+
+def make_sign():
+    """生成sign和datetime请求头"""
+    now = datetime.now()
+    dt_str = now.strftime("%Y-%m-%d %H:%M:%S")
+    timestamp = int(datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S").timestamp())
+    raw = REQUEST_KEY + str(timestamp)
+    sign = hashlib.md5(raw.encode()).hexdigest()
+    return sign, dt_str
+
+
+def decrypt_response(data_b64, iv_hex):
+    """AES-CBC解密响应数据"""
+    iv = iv_hex.encode("utf-8")
+    cipher = AES.new(RESPONSE_KEY, AES.MODE_CBC, iv)
+    ciphertext = base64.b64decode(data_b64)
+    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
+    return json.loads(plaintext.decode("utf-8"))
+```
+
+### 5.3 JS 与 Python 对应关系
+
+| JS(CryptoJS) | Python(pycryptodome) |
+|----------------|----------------------|
+| `CryptoJS.enc.Utf8.parse(key)` | `key.encode("utf-8")` 或直接 `b"..."` |
+| `CryptoJS.enc.Base64.parse(data)` | `base64.b64decode(data)` |
+| `CryptoJS.AES.decrypt(...)` | `AES.new(key, AES.MODE_CBC, iv).decrypt(...)` |
+| `CryptoJS.pad.Pkcs7` | `unpad(data, AES.block_size)` |
+| `CryptoJS.MD5(str).toString()` | `hashlib.md5(str.encode()).hexdigest()` |
+
+---
+
+## 六、注意事项
+
+1. **sign 时效性**:`sign` 与 `datetime` 必须配对,且时间不能与服务器时间偏差过大,否则请求会被拒绝
+2. **IV 编码方式**:IV 是以 UTF-8 字符串形式使用的(不是 hex 解码),例如 `"894CA3EE5756EDF4"` 就是 16 个 ASCII 字符 = 16 字节
+3. **密钥长度**:`RESPONSE_KEY` 为 32 个 hex 字符,但作为 UTF-8 字符串使用时是 32 字节,实际上 CryptoJS 默认会将超过 16 字节的密钥用于 AES-256;此处密钥恰好为 32 ASCII 字符 = 32 字节,对应 AES-256
+4. **部分字段编码**:某些商品名称字段(如 `goods_uni_str`)包含非 UTF-8 编码的字符,在 Python 中可能显示为乱码,这是原始数据问题而非解密错误