| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- BUFF饰品属性获取脚本(按指定饰品ID)
- 对指定的饰品ID进行数据爬取并写入 buff_goods_attr。
- 支持多个饰品ID,命令行用逗号分隔。
- 用法:
- python sync_goods_attr_by_ids.py 1115519,1115528,1234567
- python sync_goods_attr_by_ids.py 1115519
- 依赖、数据库与请求配置与 sync_goods_attr.py 一致(DBUtils、每 3 秒一次请求等)。
- """
- import os
- import sys
- import time
- import logging
- import threading
- import json
- import requests
- from decimal import Decimal
- import pymysql
- from dbutils.pooled_db import PooledDB
- import concurrent.futures
- from datetime import datetime
- from typing import List, Dict, Any, Optional, Tuple
- # ============================================================
- # 配置信息
- # ============================================================
- BUFF_API_BASE_URL = "https://buff.163.com/api/market/goods/sell_order"
- GAME = "csgo"
- DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
- DB_PORT = int(os.getenv("DB_PORT", "3306"))
- DB_USER = os.getenv("DB_USER", "root")
- DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
- DB_NAME = os.getenv("DB_NAME", "buff")
- DB_POOL_CONFIG = {
- 'creator': pymysql,
- 'host': DB_HOST,
- 'port': DB_PORT,
- 'user': DB_USER,
- 'password': DB_PASSWORD,
- 'database': DB_NAME,
- 'charset': 'utf8mb4',
- 'cursorclass': pymysql.cursors.DictCursor,
- 'autocommit': True,
- 'maxconnections': 10,
- 'mincached': 3,
- 'maxcached': 5,
- 'maxusage': 1000,
- 'ping': 7,
- 'blocking': True,
- 'setsession': [],
- }
- REQUEST_TIMEOUT = 30
- REQUEST_INTERVAL = 3
- CONCURRENT_WORKERS = int(os.getenv("CONCURRENT_WORKERS", "1"))
- MAX_RETRIES = 3
- RETRY_DELAY = 2
- # ============================================================
- # 配置日志
- # ============================================================
- def setup_logging():
- """配置日志系统"""
- log_dir = os.path.dirname(os.path.abspath(__file__))
- log_file = os.path.join(log_dir, "logs", f"sync_goods_attr_by_ids_{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 - [%(threadName)s] - %(message)s',
- handlers=[
- logging.StreamHandler(sys.stdout),
- logging.FileHandler(log_file, encoding='utf-8')
- ]
- )
- return logging.getLogger(__name__)
- logger = setup_logging()
- def _to_db_scalar(v):
- """将可能为 list/dict 的值转为标量,避免 cursor.execute 时 'not all arguments converted'"""
- if v is None:
- return None
- if isinstance(v, (list, dict)):
- return json.dumps(v, ensure_ascii=False) if v else None
- if isinstance(v, (datetime, Decimal)):
- return v
- if isinstance(v, (str, int, float, bool)):
- return v
- return str(v)
- _request_lock = threading.Lock()
- _last_request_time = [0.0]
- # ============================================================
- # 数据库连接池操作类
- # ============================================================
- class DatabaseConnectionPool:
- _instance = None
- _pool = None
- def __new__(cls):
- if cls._instance is None:
- cls._instance = super().__new__(cls)
- cls._instance._init_pool()
- return cls._instance
- def _init_pool(self):
- try:
- self._pool = PooledDB(**DB_POOL_CONFIG)
- logger.info(f"数据库连接池初始化成功: {DB_HOST}:{DB_PORT}/{DB_NAME}")
- except Exception as e:
- logger.error(f"数据库连接池初始化失败: {e}")
- raise
- def get_connection(self):
- try:
- return self._pool.connection()
- except Exception as e:
- logger.error(f"从连接池获取连接失败: {e}")
- time.sleep(2)
- self._init_pool()
- return self._pool.connection()
- def close_pool(self):
- if self._pool:
- self._pool.close()
- logger.info("数据库连接池已关闭")
- class GoodsAttrDatabase:
- def __init__(self):
- self.pool = DatabaseConnectionPool()
- def execute_query(self, sql: str, params=None, fetch_all=True):
- conn = None
- cursor = None
- try:
- conn = self.pool.get_connection()
- cursor = conn.cursor()
- if params:
- cursor.execute(sql, params)
- else:
- cursor.execute(sql)
- return cursor.fetchall() if fetch_all else cursor.fetchone()
- except pymysql.Error as e:
- logger.error(f"数据库查询失败: {e}, SQL: {sql}")
- raise
- finally:
- if cursor:
- cursor.close()
- def execute_update(self, sql: str, params=None, many=False):
- conn = None
- cursor = None
- try:
- conn = self.pool.get_connection()
- cursor = conn.cursor()
- if many and params:
- cursor.executemany(sql, params)
- elif params:
- cursor.execute(sql, params)
- else:
- cursor.execute(sql)
- return cursor.rowcount
- except pymysql.Error as e:
- logger.error(f"数据库更新失败: {e}, SQL: {sql}")
- raise
- finally:
- if cursor:
- cursor.close()
- def execute_transaction(self, operations: List[Tuple[str, tuple]]):
- conn = None
- cursor = None
- try:
- conn = self.pool.get_connection()
- cursor = conn.cursor()
- conn.begin()
- for sql, params in operations:
- if params:
- cursor.execute(sql, params)
- else:
- cursor.execute(sql)
- conn.commit()
- return True
- except Exception as e:
- logger.error(f"事务执行失败: {e}")
- if conn:
- try:
- conn.rollback()
- except Exception:
- pass
- return False
- finally:
- if cursor:
- cursor.close()
- def batch_insert_goods_attrs(self, attr_data_list: List[Dict[str, Any]]) -> int:
- if not attr_data_list:
- return 0
- operations = []
- for attr_data in attr_data_list:
- delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
- insert_sql = """
- INSERT INTO buff_goods_attr
- (goods_id, name, icon_url, type, type_name, category, category_name,
- category_group, category_group_name, rarity, rarity_name,
- quality, quality_name, exterior, exterior_name, weapon, weapon_name,
- series, series_name, custom, custom_name, itemset, itemset_name,
- extra_tag_ids, extra_tag_ids_name, custom_patch, custom_patch_name,
- custom_charm, custom_charm_name, stat_time, create_time, update_time)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
- """
- values = (
- _to_db_scalar(attr_data.get('goods_id')),
- _to_db_scalar(attr_data.get('name')) or '',
- _to_db_scalar(attr_data.get('icon_url')),
- _to_db_scalar(attr_data.get('type')),
- _to_db_scalar(attr_data.get('type_name')),
- _to_db_scalar(attr_data.get('category')),
- _to_db_scalar(attr_data.get('category_name')),
- _to_db_scalar(attr_data.get('category_group')),
- _to_db_scalar(attr_data.get('category_group_name')),
- _to_db_scalar(attr_data.get('rarity')),
- _to_db_scalar(attr_data.get('rarity_name')),
- _to_db_scalar(attr_data.get('quality')),
- _to_db_scalar(attr_data.get('quality_name')),
- _to_db_scalar(attr_data.get('exterior')),
- _to_db_scalar(attr_data.get('exterior_name')),
- _to_db_scalar(attr_data.get('weapon')),
- _to_db_scalar(attr_data.get('weapon_name')),
- _to_db_scalar(attr_data.get('series')),
- _to_db_scalar(attr_data.get('series_name')),
- _to_db_scalar(attr_data.get('custom')),
- _to_db_scalar(attr_data.get('custom_name')),
- _to_db_scalar(attr_data.get('itemset')),
- _to_db_scalar(attr_data.get('itemset_name')),
- _to_db_scalar(attr_data.get('extra_tag_ids')),
- _to_db_scalar(attr_data.get('extra_tag_ids_name')),
- _to_db_scalar(attr_data.get('custom_patch')),
- _to_db_scalar(attr_data.get('custom_patch_name')),
- _to_db_scalar(attr_data.get('custom_charm')),
- _to_db_scalar(attr_data.get('custom_charm_name')),
- _to_db_scalar(attr_data.get('stat_time')) or datetime.now(),
- )
- goods_id_val = _to_db_scalar(attr_data.get('goods_id')) or attr_data['goods_id']
- operations.append((delete_sql, (goods_id_val,)))
- operations.append((insert_sql, values))
- try:
- if self.execute_transaction(operations):
- logger.info(f"成功批量插入 {len(attr_data_list)} 个饰品的属性信息")
- return len(attr_data_list)
- return 0
- except Exception as e:
- logger.error(f"批量插入饰品属性信息异常: {e}")
- return 0
- def check_connection(self):
- try:
- self.execute_query("SELECT 1")
- return True
- except Exception as e:
- logger.error(f"数据库连接检查失败: {e}")
- return False
- # ============================================================
- # BUFF API 操作类
- # ============================================================
- class BuffGoodsAPI:
- def __init__(self):
- self.session = requests.Session()
- self.session.headers.update({
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
- "Accept": "application/json, text/plain, */*",
- "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
- "Accept-Encoding": "gzip, deflate, br",
- "Connection": "keep-alive",
- "Referer": "https://buff.163.com/",
- "Sec-Fetch-Dest": "empty",
- "Sec-Fetch-Mode": "cors",
- "Sec-Fetch-Site": "same-origin"
- })
- def fetch_goods_info(self, goods_id: str) -> Optional[Dict[str, Any]]:
- url = f"{BUFF_API_BASE_URL}?game={GAME}&goods_id={goods_id}"
- for retry in range(MAX_RETRIES):
- try:
- with _request_lock:
- elapsed = time.time() - _last_request_time[0]
- if elapsed < REQUEST_INTERVAL:
- time.sleep(REQUEST_INTERVAL - elapsed)
- _last_request_time[0] = time.time()
- response = requests.get(
- url,
- headers=self.session.headers,
- timeout=REQUEST_TIMEOUT,
- )
- response.raise_for_status()
- data = response.json()
- if data.get("code") != "OK":
- logger.warning(f"饰品 {goods_id} API返回错误: {data.get('msg', '未知错误')}")
- return None
- goods_info = self._extract_goods_info(data, goods_id)
- if goods_info:
- return goods_info
- return None
- except requests.exceptions.RequestException as e:
- logger.error(f"请求饰品 {goods_id} 失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
- if retry < MAX_RETRIES - 1:
- time.sleep(RETRY_DELAY * (retry + 1))
- else:
- return None
- except json.JSONDecodeError:
- return None
- except Exception as e:
- logger.error(f"处理饰品 {goods_id} 时发生错误: {e}")
- return None
- return None
- def _extract_goods_info(self, api_data: Dict[str, Any], goods_id: str) -> Optional[Dict[str, Any]]:
- try:
- data = api_data.get("data", {})
- goods_infos = data.get("goods_infos", {})
- goods_data = goods_infos.get(str(goods_id))
- if not goods_data:
- return None
- tags = goods_data.get("tags", {})
- attr_data = {
- 'goods_id': goods_id,
- 'name': goods_data.get("name", ""),
- 'icon_url': goods_data.get("icon_url"),
- 'stat_time': datetime.now(),
- 'create_time': datetime.now()
- }
- tag_mapping = {
- 'type': ('type', 'type_name'),
- 'rarity': ('rarity', 'rarity_name'),
- 'category': ('category', 'category_name'),
- 'category_group': ('category_group', 'category_group_name'),
- 'quality': ('quality', 'quality_name'),
- 'exterior': ('exterior', 'exterior_name'),
- 'weapon': ('weapon', 'weapon_name'),
- 'series': ('series', 'series_name'),
- 'custom': ('custom', 'custom_name'),
- 'itemset': ('itemset', 'itemset_name')
- }
- for tag_key, (field_key, field_name_key) in tag_mapping.items():
- if tag_key in tags:
- tag_data = tags[tag_key]
- attr_data[field_key] = tag_data.get("internal_name")
- attr_data[field_name_key] = tag_data.get("localized_name")
- return attr_data
- except Exception as e:
- logger.error(f"提取饰品 {goods_id} 属性时出错: {e}")
- return None
- # ============================================================
- # 主逻辑
- # ============================================================
- class GoodsAttrSyncTask:
- def __init__(self):
- self.db = GoodsAttrDatabase()
- self.api = BuffGoodsAPI()
- def fetch_and_save_goods_attrs(self, goods_ids: List[str]) -> Dict[str, int]:
- results = {'success': 0, 'failed': 0, 'total': len(goods_ids)}
- batch_attr_data: List[Dict[str, Any]] = []
- def fetch_one(goods_id: str) -> Optional[Dict[str, Any]]:
- try:
- return self.api.fetch_goods_info(goods_id)
- except Exception as e:
- logger.error(f"获取饰品 {goods_id} 属性异常: {e}")
- return None
- with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_WORKERS) as executor:
- future_to_id = {executor.submit(fetch_one, gid): gid for gid in goods_ids}
- for future in concurrent.futures.as_completed(future_to_id):
- goods_id = future_to_id[future]
- try:
- attr_data = future.result()
- if attr_data:
- batch_attr_data.append(attr_data)
- logger.info(f"获取饰品 {goods_id} 属性成功")
- else:
- results['failed'] += 1
- logger.warning(f"获取饰品 {goods_id} 属性失败")
- except Exception as e:
- results['failed'] += 1
- logger.error(f"获取饰品 {goods_id} 异常: {e}")
- if batch_attr_data:
- saved_count = self.db.batch_insert_goods_attrs(batch_attr_data)
- results['success'] += saved_count
- if saved_count < len(batch_attr_data):
- results['failed'] += len(batch_attr_data) - saved_count
- return results
- def cleanup(self):
- try:
- DatabaseConnectionPool().close_pool()
- logger.info("资源清理完成")
- except Exception:
- pass
- def parse_goods_ids(ids_str: str) -> List[str]:
- """从逗号分隔的字符串解析出饰品ID列表,去重并过滤空串"""
- if not ids_str or not ids_str.strip():
- return []
- parts = [p.strip() for p in ids_str.split(",") if p.strip()]
- seen = set()
- result = []
- for p in parts:
- if p not in seen:
- seen.add(p)
- result.append(p)
- return result
- # ============================================================
- # 主函数
- # ============================================================
- def main():
- task = None
- try:
- # 从命令行获取逗号分隔的饰品ID:第一个参数
- if len(sys.argv) < 2:
- print("用法: python sync_goods_attr_by_ids.py <饰品ID1>,<饰品ID2>,...")
- print("示例: python sync_goods_attr_by_ids.py 1115519,1115528,1234567")
- sys.exit(1)
- ids_str = sys.argv[1]
- goods_ids = parse_goods_ids(ids_str)
- if not goods_ids:
- logger.error("未解析到有效饰品ID,请使用逗号分隔的ID列表")
- sys.exit(1)
- logger.info("启动按指定饰品ID属性同步脚本...")
- logger.info(f"待处理饰品ID: {goods_ids}")
- logger.info(f"共 {len(goods_ids)} 个,请求间隔 {REQUEST_INTERVAL} 秒")
- task = GoodsAttrSyncTask()
- if not task.db.check_connection():
- logger.error("数据库连接不可用,任务中止")
- sys.exit(1)
- start_time = datetime.now()
- results = task.fetch_and_save_goods_attrs(goods_ids)
- duration = (datetime.now() - start_time).total_seconds()
- logger.info("=" * 60)
- logger.info("任务完成")
- logger.info(f"总数: {results['total']}, 成功: {results['success']}, 失败: {results['failed']}, 耗时: {duration:.2f}秒")
- logger.info("=" * 60)
- sys.exit(0 if results['failed'] == 0 else 1)
- except KeyboardInterrupt:
- logger.info("\n收到中断信号,正在退出...")
- if task:
- task.cleanup()
- sys.exit(0)
- except Exception as e:
- logger.exception(f"程序运行异常: {e}")
- if task:
- task.cleanup()
- sys.exit(1)
- if __name__ == "__main__":
- try:
- import dbutils
- except ImportError:
- print("请先安装依赖: pip install DBUtils==3.0.2")
- sys.exit(1)
- main()
|