| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- BUFF饰品流通性数据同步脚本
- 功能:
- 1. 每天请求一次buff接口获取饰品流通性数据
- 2. 将数据存入 buff_goods_liquidity_middle 表
- 3. 确认存储数量正确后,清空 buff_goods_liquidity 表
- 4. 将 buff_goods_liquidity_middle 插入到 buff_goods_liquidity 表中
- 使用方法:
- python sync_liquidity.py # 手动执行一次
- python sync_liquidity.py --daemon # 守护进程模式(定时执行)
- python sync_liquidity.py --install-cron # 安装到系统定时任务
- 作者: 游铛铛技术数字员工
- 创建时间: 2026-01-28
- """
- import os
- import sys
- import time
- import argparse
- import logging
- import json
- import requests
- import pymysql
- from datetime import datetime
- from typing import List, Dict, Any
- import schedule
- # ============================================================
- # 配置信息
- # ============================================================
- # BUFF API配置
- BUFF_API_URL = "https://buff.163.com/api/market/enterprise/items/liquidity_rank"
- BUFF_API_TOKEN = "2ce4f8fd95194fe090d53b785056de0b"
- # 数据库配置(从环境变量读取)
- DB_HOST = os.getenv("DB_HOST", "125.122.32.114")
- DB_PORT = int(os.getenv("DB_PORT", "3306"))
- DB_USER = os.getenv("DB_USER", "root")
- DB_PASSWORD = os.getenv("DB_PASSWORD", "Buff123456")
- DB_NAME = os.getenv("DB_NAME", "buff")
- # 请求配置
- REQUEST_TIMEOUT = 60
- BATCH_SIZE = 1000
- MAX_RETRIES = 3
- RETRY_DELAY = 5
- # ============================================================
- # 配置日志
- # ============================================================
- def setup_logging():
- log_dir = os.path.dirname(os.path.abspath(__file__))
- log_file = os.path.join(log_dir, "logs", f"sync_liquidity_{datetime.now().strftime('%Y%m%d')}.log")
- os.makedirs(os.path.dirname(log_file), exist_ok=True)
-
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(levelname)s - %(message)s',
- handlers=[
- logging.StreamHandler(sys.stdout),
- logging.FileHandler(log_file, encoding='utf-8')
- ]
- )
- return logging.getLogger(__name__)
- logger = setup_logging()
- # ============================================================
- # 数据库操作类
- # ============================================================
- class LiquidityDatabase:
- """流通性数据库操作类"""
- def __init__(self):
- self.connection = None
- self._connect()
-
- def _connect(self):
- """建立数据库连接"""
- try:
- self.connection = pymysql.connect(
- host=DB_HOST,
- port=DB_PORT,
- user=DB_USER,
- password=DB_PASSWORD,
- database=DB_NAME,
- charset='utf8mb4',
- cursorclass=pymysql.cursors.DictCursor,
- autocommit=False
- )
- logger.info(f"数据库连接成功: {DB_HOST}:{DB_PORT}/{DB_NAME}")
- except Exception as e:
- logger.error(f"数据库连接失败: {e}")
- raise
-
- def close(self):
- if self.connection and self.connection.open:
- self.connection.close()
- logger.info("数据库连接已关闭")
-
- def get_middle_count(self) -> int:
- """获取中间表数据数量"""
- with self.connection.cursor() as cursor:
- cursor.execute("SELECT COUNT(*) as count FROM buff_goods_liquidity_middle")
- result = cursor.fetchone()
- return result['count']
-
- def clear_target_table(self):
- """清空目标表"""
- with self.connection.cursor() as cursor:
- cursor.execute("DELETE FROM buff_goods_liquidity")
- self.connection.commit()
- logger.info("已清空 buff_goods_liquidity 表")
-
- def clear_middle_table(self):
- """清空中间表"""
- with self.connection.cursor() as cursor:
- cursor.execute("DELETE FROM buff_goods_liquidity_middle")
- self.connection.commit()
- logger.info("已清空 buff_goods_liquidity_middle 表")
-
- def insert_to_middle(self, data_list: List[Dict[str, Any]]) -> int:
- """批量插入数据到中间表"""
- if not data_list:
- return 0
-
- sql = """
- INSERT INTO buff_goods_liquidity_middle
- (goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time)
- VALUES (%s, %s, %s, %s, %s, %s, %s)
- """
-
- total_inserted = 0
-
- with self.connection.cursor() as cursor:
- for i in range(0, len(data_list), BATCH_SIZE):
- batch = data_list[i:i + BATCH_SIZE]
- values = [
- (
- item['goods_id'],
- item['name'], # 使用中文名称
- item.get('tag_id'),
- item.get('tag_name'),
- item.get('liquidity_rank'),
- datetime.fromtimestamp(item['update_time']),
- datetime.now()
- )
- for item in batch
- ]
- cursor.executemany(sql, values)
- total_inserted += len(batch)
- logger.info(f"中间表插入进度: {total_inserted}/{len(data_list)}")
-
- self.connection.commit()
- return total_inserted
-
- def insert_from_middle_to_target(self) -> int:
- """将中间表数据插入到目标表"""
- with self.connection.cursor() as cursor:
- sql = """
- INSERT INTO buff_goods_liquidity
- (goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time, update_time)
- SELECT goods_id, name, tag_id, tag_name, liquidity_rank, stat_time, create_time, NOW()
- FROM buff_goods_liquidity_middle
- """
- cursor.execute(sql)
- affected_rows = cursor.connection.affected_rows()
-
- self.connection.commit()
- return affected_rows
-
- def get_target_count(self) -> int:
- """获取目标表数据数量"""
- with self.connection.cursor() as cursor:
- cursor.execute("SELECT COUNT(*) as count FROM buff_goods_liquidity")
- result = cursor.fetchone()
- return result['count']
- # ============================================================
- # BUFF API 操作类
- # ============================================================
- class BuffLiquidityAPI:
- """BUFF流通性API操作类"""
-
- def __init__(self):
- self.session = requests.Session()
- self.session.headers.update({
- "Authorization": f"Bearer {BUFF_API_TOKEN}",
- "Accept": "application/json",
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
- })
-
- def fetch_liquidity_data(self, game: str = "cs2", include_sticker: int = 1) -> Dict[str, Any]:
- """获取流通性数据"""
- params = {"game": game, "include_sticker": include_sticker}
-
- for retry in range(MAX_RETRIES):
- try:
- logger.info(f"请求BUFF API (第{retry + 1}次尝试)...")
- response = self.session.get(BUFF_API_URL, params=params, timeout=REQUEST_TIMEOUT)
- response.raise_for_status()
-
- data = response.json()
- # 🔧 调试:打印API返回的完整数据结构
- logger.info("=" * 60)
- logger.info("🔍 API返回数据结构:")
- logger.info(f" 顶层keys: {list(data.keys())}")
- logger.info(f" 总记录数: {len(data)}")
- # 打印Info字段详情
- if "Info" in data:
- logger.info(f" Info字段类型: {type(data['Info'])}")
- if isinstance(data['Info'], list):
- logger.info(f" Info列表长度: {len(data['Info'])}")
- if len(data['Info']) > 0:
- logger.info(f" 第一条记录keys: {list(data['Info'][0].keys())}")
- logger.info(f" 第一条记录示例: {json.dumps(data['Info'][0], ensure_ascii=False, indent=2)}")
- else:
- logger.info(f" Info字段内容: {data['Info']}")
- elif "info" in data:
- logger.info(f" info字段类型: {type(data['info'])}")
- if isinstance(data['info'], list):
- logger.info(f" info列表长度: {len(data['info'])}")
- if len(data['info']) > 0:
- logger.info(f" 第一条记录keys: {list(data['info'][0].keys())}")
- logger.info(f" 第一条记录示例: {json.dumps(data['info'][0], ensure_ascii=False, indent=2)}")
- else:
- logger.info(f" info字段内容: {data['info']}")
- # 打印其他关键字段
- for key in ["Stat_time", "stat_time", "Req_remaining", "req_remaining", "code"]:
- if key in data:
- logger.info(f" {key}: {data[key]}")
- logger.info("=" * 60)
- # 兼容处理:检查是否有Info字段(成功标志)
- info_key = "Info" if "Info" in data else "info"
- if info_key in data:
- # 获取信息列表
- info_count = len(data.get(info_key, []))
- logger.info(f"API请求成功,获取到 {info_count} 条记录")
-
- # 获取剩余请求次数(兼容大小写)
- remaining = data.get("Req_remaining") or data.get("req_remaining", 0)
- logger.info(f"API剩余调用次数: {remaining}")
- return data
- else:
- # 没有Info字段,检查是否有code
- code = data.get("code")
- if code == 200 or code == "200":
- logger.info(f"API请求成功")
- return data
- else:
- logger.error(f"API返回错误: 无Info字段, 完整响应: {data}")
- raise Exception(f"API错误: {data.get('msg', '未知错误')}")
-
- except requests.exceptions.RequestException as e:
- logger.error(f"API请求失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
- logger.error(f"响应状态码: {response.status_code}")
- logger.error(f"响应内容: {response.text[:500]}")
- if retry < MAX_RETRIES - 1:
- time.sleep(RETRY_DELAY)
- else:
- raise
-
- def parse_liquidity_data(self, api_response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """解析流通性数据"""
- # 🔧 调试:打印解析前数据
- logger.info("=" * 60)
- logger.info("🔍 开始解析数据...")
- logger.info(f" Stat_time: {api_response.get('Stat_time', '不存在')}")
- # 获取Info数据(兼容大小写)
- info_data = api_response.get("Info") or api_response.get("info", [])
- logger.info(f" Info列表长度: {len(info_data) if info_data else 0}")
- items = []
- stat_time = api_response.get("Stat_time", 0)
- # 🔧 调试:检查第一条数据
- if info_data and len(info_data) > 0:
- first_item = info_data[0]
- logger.info(f" 第一条goods_id: {first_item.get('goods_id')}")
- logger.info(f" 第一条market_hash_name: {first_item.get('market_hash_name')}")
- logger.info(f" 第一条sales字段: {first_item.get('sales', '不存在')}")
- if 'sales' in first_item:
- logger.info(f" sales类型: {type(first_item['sales'])}")
- if isinstance(first_item['sales'], list) and len(first_item['sales']) > 0:
- logger.info(f" 第一条sales[0]内容: {json.dumps(first_item['sales'][0], ensure_ascii=False)}")
- for item in info_data:
- goods_id = item.get("goods_id")
- market_hash_name = item.get("market_hash_name")
- name = item.get("name") # 中文名称
- update_time = item.get("update_time", stat_time)
- sales = item.get("sales", [])
- logger.info(f" 处理 goods_id={goods_id}, sales数量={len(sales) if sales else 0}")
- for sale in sales:
- items.append({
- "goods_id": goods_id,
- "market_hash_name": market_hash_name,
- "name": name, # 存储中文名称
- "update_time": update_time,
- "tag_id": sale.get("tag_id"),
- "tag_name": sale.get("tag_name"),
- "liquidity_rank": sale.get("liquidity_rank")
- })
- logger.info(f" 解析完成,共 {len(items)} 条流通性记录")
- logger.info("=" * 60)
- return items
- # ============================================================
- # 主逻辑
- # ============================================================
- class LiquiditySyncTask:
- """流通性数据同步任务"""
-
- def __init__(self):
- self.db = LiquidityDatabase()
- self.api = BuffLiquidityAPI()
-
- def run(self) -> bool:
- """执行同步任务"""
- start_time = datetime.now()
- logger.info("=" * 60)
- logger.info(f"开始执行流通性数据同步任务: {start_time}")
- logger.info("=" * 60)
-
- try:
- # Step 1: 清空中间表
- logger.info("[Step 1/5] 清空中间表...")
- self.db.clear_middle_table()
-
- # Step 2: 调用BUFF API获取数据
- logger.info("[Step 2/5] 调用BUFF API获取流通性数据...")
- api_response = self.api.fetch_liquidity_data(game="cs2", include_sticker=1)
- # 兼容大小写字段名
- remaining = api_response.get("Req_remaining") or api_response.get("req_remaining", 0)
- logger.info(f"API剩余调用次数: {remaining}")
-
- # Step 3: 解析数据
- logger.info("[Step 3/5] 解析数据...")
- data_list = self.api.parse_liquidity_data(api_response)
-
- if not data_list:
- logger.warning("未获取到任何流通性数据,任务取消")
- return False
-
- # Step 4: 插入中间表
- logger.info("[Step 4/5] 插入中间表...")
- inserted_count = self.db.insert_to_middle(data_list)
- middle_count = self.db.get_middle_count()
- logger.info(f"中间表插入完成: 预期 {inserted_count}, 实际 {middle_count}")
-
- # 验证数据量(允许1%误差)
- if middle_count < inserted_count * 0.99:
- logger.error(f"数据量验证失败: 预期 {inserted_count}, 实际 {middle_count}")
- return False
-
- logger.info("数据量验证通过")
-
- # Step 5: 清空目标表并从中间表插入
- logger.info("[Step 5/5] 更新目标表...")
- self.db.clear_target_table()
- target_inserted = self.db.insert_from_middle_to_target()
- target_count = self.db.get_target_count()
-
- logger.info(f"目标表更新完成: 插入 {target_inserted}, 当前 {target_count}")
-
- # 验证目标表数据量
- if target_count != middle_count:
- logger.error(f"目标表数据量不匹配: 中间表 {middle_count}, 目标表 {target_count}")
- return False
-
- # 清理中间表数据
- self.db.clear_middle_table()
-
- # 任务完成
- end_time = datetime.now()
- duration = (end_time - start_time).total_seconds()
- logger.info("=" * 60)
- logger.info(f"流通性数据同步任务完成! 耗时: {duration:.2f}秒")
- logger.info(f"同步数据量: {target_count} 条")
- logger.info("=" * 60)
-
- return True
-
- except Exception as e:
- logger.exception(f"同步任务失败: {e}")
- return False
- finally:
- self.db.close()
- def run_once() -> bool:
- """执行一次同步任务"""
- task = LiquiditySyncTask()
- return task.run()
- def run_daemon():
- """守护进程模式,持续运行并定时执行任务"""
- logger.info("启动守护进程模式... 定时任务: 每天 02:00 执行")
- schedule.every().day.at("02:00").do(run_once)
- run_once() # 立即执行一次
-
- while True:
- schedule.run_pending()
- time.sleep(60)
- def main():
- parser = argparse.ArgumentParser(description="BUFF饰品流通性数据同步脚本")
- parser.add_argument("--daemon", action="store_true", help="守护进程模式")
- parser.add_argument("--install-cron", action="store_true", help="安装到系统定时任务(每天02:00执行)")
-
- args = parser.parse_args()
-
- if args.install_cron:
- script_path = os.path.abspath(__file__)
- cron_entry = f"0 2 * * * /usr/bin/python3 {script_path}\n"
-
- try:
- import subprocess
- result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
- current_crontab = result.stdout
-
- if script_path in current_crontab:
- logger.info("定时任务已存在,无需重复安装")
- else:
- subprocess.run(["crontab", "-"], input=current_crontab + cron_entry, text=True)
- logger.info(f"定时任务安装成功: {cron_entry.strip()}")
- except Exception as e:
- logger.error(f"安装定时任务失败: {e}")
- sys.exit(1)
- elif args.daemon:
- run_daemon()
- else:
- success = run_once()
- sys.exit(0 if success else 1)
- if __name__ == "__main__":
- main()
|