sync_liquidity.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品流通性数据同步脚本
  5. 功能:
  6. 1. 请求 buff 接口获取饰品流通性数据
  7. 2. 将数据存入 buff_goods_liquidity_middle 表
  8. 3. 确认存储数量正确后,清空 buff_goods_liquidity 表
  9. 4. 将 buff_goods_liquidity_middle 插入到 buff_goods_liquidity 表中
  10. 使用方法:
  11. python sync_liquidity.py # 执行一次同步(周期调度请使用 run_buff_sync_loop.sh)
  12. 作者: 游铛铛技术数字员工
  13. 创建时间: 2026-01-28
  14. """
  15. import os
  16. import sys
  17. import time
  18. import logging
  19. import json
  20. import requests
  21. import pymysql
  22. from datetime import datetime
  23. from typing import List, Dict, Any
  24. from sync_db_errors import is_database_connection_error
  25. # ============================================================
  26. # 配置信息
  27. # ============================================================
  28. # BUFF API配置
  29. BUFF_API_URL = "https://buff.163.com/api/market/enterprise/items/liquidity_rank"
  30. BUFF_API_TOKEN = "c4a1ebaf64d64d1a9a320bc2fcfa1f7e"
  31. # 数据库配置(从环境变量读取)
  32. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  33. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  34. DB_USER = os.getenv("DB_USER", "root")
  35. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  36. DB_NAME = os.getenv("DB_NAME", "buff")
  37. # 与 sync_goods / sync_float_price 一致,大批量写入避免客户端读写过短触发 2013
  38. DB_READ_TIMEOUT = int(os.getenv("DB_READ_TIMEOUT", "1800"))
  39. DB_WRITE_TIMEOUT = int(os.getenv("DB_WRITE_TIMEOUT", "1800"))
  40. DB_BULK_INSERT_RETRIES = int(os.getenv("DB_BULK_INSERT_RETRIES", "3"))
  41. # 请求配置
  42. REQUEST_TIMEOUT = 60
  43. BATCH_SIZE = int(os.getenv("LIQUIDITY_INSERT_BATCH_SIZE", "1000"))
  44. MAX_RETRIES = 3
  45. RETRY_DELAY = 5
  46. # ============================================================
  47. # 配置日志
  48. # ============================================================
  49. def setup_logging():
  50. log_dir = os.path.dirname(os.path.abspath(__file__))
  51. log_file = os.path.join(log_dir, "logs", f"sync_liquidity_{datetime.now().strftime('%Y%m%d')}.log")
  52. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  53. logging.basicConfig(
  54. level=logging.INFO,
  55. format='%(asctime)s - %(levelname)s - %(message)s',
  56. handlers=[
  57. logging.StreamHandler(sys.stdout),
  58. logging.FileHandler(log_file, encoding='utf-8')
  59. ]
  60. )
  61. return logging.getLogger(__name__)
  62. logger = setup_logging()
  63. # ============================================================
  64. # 数据库操作类
  65. # ============================================================
  66. class LiquidityDatabase:
  67. """流通性数据库操作类"""
  68. def __init__(self):
  69. self.connection = None
  70. self._connect()
  71. def _connect(self):
  72. """建立数据库连接"""
  73. try:
  74. self.connection = pymysql.connect(
  75. host=DB_HOST,
  76. port=DB_PORT,
  77. user=DB_USER,
  78. password=DB_PASSWORD,
  79. database=DB_NAME,
  80. charset='utf8mb4',
  81. cursorclass=pymysql.cursors.DictCursor,
  82. autocommit=False,
  83. connect_timeout=10,
  84. read_timeout=DB_READ_TIMEOUT,
  85. write_timeout=DB_WRITE_TIMEOUT,
  86. )
  87. logger.info(
  88. f"数据库连接成功: {DB_HOST}:{DB_PORT}/{DB_NAME} "
  89. f"(read_timeout={DB_READ_TIMEOUT}s, write_timeout={DB_WRITE_TIMEOUT}s)"
  90. )
  91. except Exception as e:
  92. logger.error(f"数据库连接失败: {e}")
  93. raise
  94. def _reconnect(self):
  95. try:
  96. if self.connection:
  97. self.connection.close()
  98. except Exception:
  99. pass
  100. self.connection = None
  101. self._connect()
  102. @staticmethod
  103. def _is_transient_db_error(exc: BaseException) -> bool:
  104. if not isinstance(exc, pymysql.err.OperationalError):
  105. return False
  106. code = exc.args[0] if exc.args else 0
  107. return code in (2006, 2013, 2014, 1053, 2003)
  108. def close(self):
  109. if self.connection and self.connection.open:
  110. self.connection.close()
  111. logger.info("数据库连接已关闭")
  112. def get_middle_count(self) -> int:
  113. """获取中间表数据数量"""
  114. with self.connection.cursor() as cursor:
  115. cursor.execute("SELECT COUNT(*) as count FROM buff_goods_liquidity_middle")
  116. result = cursor.fetchone()
  117. return result['count']
  118. def clear_target_table(self):
  119. """清空目标表"""
  120. with self.connection.cursor() as cursor:
  121. cursor.execute("DELETE FROM buff_goods_liquidity")
  122. self.connection.commit()
  123. logger.info("已清空 buff_goods_liquidity 表")
  124. def clear_middle_table(self):
  125. """清空中间表"""
  126. with self.connection.cursor() as cursor:
  127. cursor.execute("DELETE FROM buff_goods_liquidity_middle")
  128. self.connection.commit()
  129. logger.info("已清空 buff_goods_liquidity_middle 表")
  130. def insert_to_middle(self, data_list: List[Dict[str, Any]]) -> int:
  131. """批量插入数据到中间表;断连类错误重连并重试当前批,每批提交。"""
  132. if not data_list:
  133. return 0
  134. sql = """
  135. INSERT INTO buff_goods_liquidity_middle
  136. (goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time)
  137. VALUES (%s, %s, %s, %s, %s, %s, %s)
  138. """
  139. total_inserted = 0
  140. for i in range(0, len(data_list), BATCH_SIZE):
  141. batch = data_list[i:i + BATCH_SIZE]
  142. values = [
  143. (
  144. item['goods_id'],
  145. item['name'],
  146. item.get('tag_id'),
  147. item.get('tag_name'),
  148. item.get('liquidity_rank'),
  149. datetime.fromtimestamp(item['update_time']),
  150. datetime.now(),
  151. )
  152. for item in batch
  153. ]
  154. attempt = 0
  155. while True:
  156. try:
  157. with self.connection.cursor() as cursor:
  158. cursor.executemany(sql, values)
  159. self.connection.commit()
  160. break
  161. except pymysql.err.OperationalError as e:
  162. attempt += 1
  163. if not self._is_transient_db_error(e) or attempt > DB_BULK_INSERT_RETRIES:
  164. raise
  165. logger.warning(
  166. "流通性中间表单批插入失败 (%s/%s),将重连后重试: %s",
  167. attempt,
  168. DB_BULK_INSERT_RETRIES,
  169. e,
  170. )
  171. time.sleep(RETRY_DELAY * attempt)
  172. self._reconnect()
  173. total_inserted += len(batch)
  174. logger.info(f"中间表插入进度: {total_inserted}/{len(data_list)}")
  175. return total_inserted
  176. def insert_from_middle_to_target(self) -> int:
  177. """将中间表数据插入到目标表"""
  178. with self.connection.cursor() as cursor:
  179. sql = """
  180. INSERT INTO buff_goods_liquidity
  181. (goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time, update_time)
  182. SELECT goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time, NOW()
  183. FROM buff_goods_liquidity_middle
  184. """
  185. cursor.execute(sql)
  186. affected_rows = cursor.connection.affected_rows()
  187. self.connection.commit()
  188. return affected_rows
  189. def get_target_count(self) -> int:
  190. """获取目标表数据数量"""
  191. with self.connection.cursor() as cursor:
  192. cursor.execute("SELECT COUNT(*) as count FROM buff_goods_liquidity")
  193. result = cursor.fetchone()
  194. return result['count']
  195. # ============================================================
  196. # BUFF API 操作类
  197. # ============================================================
  198. class BuffLiquidityAPI:
  199. """BUFF流通性API操作类"""
  200. def __init__(self):
  201. self.session = requests.Session()
  202. self.session.headers.update({
  203. "Authorization": f"Bearer {BUFF_API_TOKEN}",
  204. "Accept": "application/json",
  205. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
  206. })
  207. def fetch_liquidity_data(self, game: str = "cs2", include_sticker: int = 1) -> Dict[str, Any]:
  208. """获取流通性数据"""
  209. params = {"game": game, "include_sticker": include_sticker}
  210. for retry in range(MAX_RETRIES):
  211. try:
  212. logger.info(f"请求BUFF API (第{retry + 1}次尝试)...")
  213. response = self.session.get(BUFF_API_URL, params=params, timeout=REQUEST_TIMEOUT)
  214. response.raise_for_status()
  215. data = response.json()
  216. # 🔧 调试:打印API返回的完整数据结构
  217. logger.info("=" * 60)
  218. logger.info("🔍 API返回数据结构:")
  219. logger.info(f" 顶层keys: {list(data.keys())}")
  220. logger.info(f" 总记录数: {len(data)}")
  221. # 打印Info字段详情
  222. if "Info" in data:
  223. logger.info(f" Info字段类型: {type(data['Info'])}")
  224. if isinstance(data['Info'], list):
  225. logger.info(f" Info列表长度: {len(data['Info'])}")
  226. if len(data['Info']) > 0:
  227. logger.info(f" 第一条记录keys: {list(data['Info'][0].keys())}")
  228. logger.info(f" 第一条记录示例: {json.dumps(data['Info'][0], ensure_ascii=False, indent=2)}")
  229. else:
  230. logger.info(f" Info字段内容: {data['Info']}")
  231. elif "info" in data:
  232. logger.info(f" info字段类型: {type(data['info'])}")
  233. if isinstance(data['info'], list):
  234. logger.info(f" info列表长度: {len(data['info'])}")
  235. if len(data['info']) > 0:
  236. logger.info(f" 第一条记录keys: {list(data['info'][0].keys())}")
  237. logger.info(f" 第一条记录示例: {json.dumps(data['info'][0], ensure_ascii=False, indent=2)}")
  238. else:
  239. logger.info(f" info字段内容: {data['info']}")
  240. # 打印其他关键字段
  241. for key in ["Stat_time", "stat_time", "Req_remaining", "req_remaining", "code"]:
  242. if key in data:
  243. logger.info(f" {key}: {data[key]}")
  244. logger.info("=" * 60)
  245. # 兼容处理:检查是否有Info字段(成功标志)
  246. info_key = "Info" if "Info" in data else "info"
  247. if info_key in data:
  248. # 获取信息列表
  249. info_count = len(data.get(info_key, []))
  250. logger.info(f"API请求成功,获取到 {info_count} 条记录")
  251. # 获取剩余请求次数(兼容大小写)
  252. remaining = data.get("Req_remaining") or data.get("req_remaining", 0)
  253. logger.info(f"API剩余调用次数: {remaining}")
  254. return data
  255. else:
  256. # 没有Info字段,检查是否有code
  257. code = data.get("code")
  258. if code == 200 or code == "200":
  259. logger.info(f"API请求成功")
  260. return data
  261. else:
  262. logger.error(f"API返回错误: 无Info字段, 完整响应: {data}")
  263. raise Exception(f"API错误: {data.get('msg', '未知错误')}")
  264. except requests.exceptions.RequestException as e:
  265. logger.error(f"API请求失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  266. logger.error(f"响应状态码: {response.status_code}")
  267. logger.error(f"响应内容: {response.text[:500]}")
  268. if retry < MAX_RETRIES - 1:
  269. time.sleep(RETRY_DELAY)
  270. else:
  271. raise
  272. def parse_liquidity_data(self, api_response: Dict[str, Any]) -> List[Dict[str, Any]]:
  273. """解析流通性数据"""
  274. # 🔧 调试:打印解析前数据
  275. logger.info("=" * 60)
  276. logger.info("🔍 开始解析数据...")
  277. logger.info(f" Stat_time: {api_response.get('Stat_time', '不存在')}")
  278. # 获取Info数据(兼容大小写)
  279. info_data = api_response.get("Info") or api_response.get("info", [])
  280. logger.info(f" Info列表长度: {len(info_data) if info_data else 0}")
  281. items = []
  282. stat_time = api_response.get("Stat_time", 0)
  283. # 🔧 调试:检查第一条数据
  284. if info_data and len(info_data) > 0:
  285. first_item = info_data[0]
  286. logger.info(f" 第一条goods_id: {first_item.get('goods_id')}")
  287. logger.info(f" 第一条market_hash_name: {first_item.get('market_hash_name')}")
  288. logger.info(f" 第一条sales字段: {first_item.get('sales', '不存在')}")
  289. if 'sales' in first_item:
  290. logger.info(f" sales类型: {type(first_item['sales'])}")
  291. if isinstance(first_item['sales'], list) and len(first_item['sales']) > 0:
  292. logger.info(f" 第一条sales[0]内容: {json.dumps(first_item['sales'][0], ensure_ascii=False)}")
  293. for item in info_data:
  294. goods_id = item.get("goods_id")
  295. market_hash_name = item.get("market_hash_name")
  296. name = item.get("name") # 中文名称
  297. update_time = item.get("update_time", stat_time)
  298. sales = item.get("sales", [])
  299. logger.info(f" 处理 goods_id={goods_id}, sales数量={len(sales) if sales else 0}")
  300. for sale in sales:
  301. items.append({
  302. "goods_id": goods_id,
  303. "market_hash_name": market_hash_name,
  304. "name": name, # 存储中文名称
  305. "update_time": update_time,
  306. "tag_id": sale.get("tag_id"),
  307. "tag_name": sale.get("tag_name"),
  308. "liquidity_rank": sale.get("liquidity_rank")
  309. })
  310. logger.info(f" 解析完成,共 {len(items)} 条流通性记录")
  311. logger.info("=" * 60)
  312. return items
  313. # ============================================================
  314. # 主逻辑
  315. # ============================================================
  316. class LiquiditySyncTask:
  317. """流通性数据同步任务"""
  318. def __init__(self):
  319. self.db = LiquidityDatabase()
  320. self.api = BuffLiquidityAPI()
  321. def run(self) -> bool:
  322. """执行同步任务"""
  323. start_time = datetime.now()
  324. logger.info("=" * 60)
  325. logger.info(f"开始执行流通性数据同步任务: {start_time}")
  326. logger.info("=" * 60)
  327. try:
  328. # Step 1: 清空中间表
  329. logger.info("[Step 1/5] 清空中间表...")
  330. self.db.clear_middle_table()
  331. # Step 2: 调用BUFF API获取数据
  332. logger.info("[Step 2/5] 调用BUFF API获取流通性数据...")
  333. api_response = self.api.fetch_liquidity_data(game="cs2", include_sticker=1)
  334. # 兼容大小写字段名
  335. remaining = api_response.get("Req_remaining") or api_response.get("req_remaining", 0)
  336. logger.info(f"API剩余调用次数: {remaining}")
  337. # Step 3: 解析数据
  338. logger.info("[Step 3/5] 解析数据...")
  339. data_list = self.api.parse_liquidity_data(api_response)
  340. if not data_list:
  341. logger.warning("未获取到任何流通性数据,任务取消")
  342. return False
  343. # Step 4: 插入中间表
  344. logger.info("[Step 4/5] 插入中间表...")
  345. inserted_count = self.db.insert_to_middle(data_list)
  346. middle_count = self.db.get_middle_count()
  347. logger.info(f"中间表插入完成: 预期 {inserted_count}, 实际 {middle_count}")
  348. # 验证数据量(允许1%误差)
  349. if middle_count < inserted_count * 0.99:
  350. logger.error(f"数据量验证失败: 预期 {inserted_count}, 实际 {middle_count}")
  351. return False
  352. logger.info("数据量验证通过")
  353. # Step 5: 清空目标表并从中间表插入
  354. logger.info("[Step 5/5] 更新目标表...")
  355. self.db.clear_target_table()
  356. target_inserted = self.db.insert_from_middle_to_target()
  357. target_count = self.db.get_target_count()
  358. logger.info(f"目标表更新完成: 插入 {target_inserted}, 当前 {target_count}")
  359. # 验证目标表数据量
  360. if target_count != middle_count:
  361. logger.error(f"目标表数据量不匹配: 中间表 {middle_count}, 目标表 {target_count}")
  362. return False
  363. # 清理中间表数据
  364. self.db.clear_middle_table()
  365. # 任务完成
  366. end_time = datetime.now()
  367. duration = (end_time - start_time).total_seconds()
  368. logger.info("=" * 60)
  369. logger.info(f"流通性数据同步任务完成! 耗时: {duration:.2f}秒")
  370. logger.info(f"同步数据量: {target_count} 条")
  371. logger.info("=" * 60)
  372. return True
  373. except Exception as e:
  374. if is_database_connection_error(e):
  375. logger.exception("数据库连接异常,本轮跳过,下一轮将重试: %s", e)
  376. return True
  377. logger.exception(f"同步任务失败: {e}")
  378. return False
  379. finally:
  380. self.db.close()
  381. def run_once() -> bool:
  382. """执行一次同步任务"""
  383. try:
  384. task = LiquiditySyncTask()
  385. return task.run()
  386. except Exception as e:
  387. if is_database_connection_error(e):
  388. logger.exception("数据库连接异常,本轮跳过,下一轮将重试: %s", e)
  389. return True
  390. raise
  391. if __name__ == "__main__":
  392. sys.exit(0 if run_once() else 1)