sync_liquidity.py 18 KB

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