sync_goods_attr_by_ids.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品属性获取脚本(按指定饰品ID)
  5. 对指定的饰品ID进行数据爬取并写入 buff_goods_attr。
  6. 支持多个饰品ID,命令行用逗号分隔。
  7. 用法:
  8. python sync_goods_attr_by_ids.py 1115519,1115528,1234567
  9. python sync_goods_attr_by_ids.py 1115519
  10. 依赖、数据库与请求配置与 sync_goods_attr.py 一致(DBUtils、每 3 秒一次请求等)。
  11. """
  12. import os
  13. import sys
  14. import time
  15. import logging
  16. import threading
  17. import json
  18. import requests
  19. from decimal import Decimal
  20. import pymysql
  21. from dbutils.pooled_db import PooledDB
  22. import concurrent.futures
  23. from datetime import datetime
  24. from typing import List, Dict, Any, Optional, Tuple
  25. # ============================================================
  26. # 配置信息
  27. # ============================================================
  28. BUFF_API_BASE_URL = "https://buff.163.com/api/market/goods/sell_order"
  29. GAME = "csgo"
  30. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  31. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  32. DB_USER = os.getenv("DB_USER", "root")
  33. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  34. DB_NAME = os.getenv("DB_NAME", "buff")
  35. DB_POOL_CONFIG = {
  36. 'creator': pymysql,
  37. 'host': DB_HOST,
  38. 'port': DB_PORT,
  39. 'user': DB_USER,
  40. 'password': DB_PASSWORD,
  41. 'database': DB_NAME,
  42. 'charset': 'utf8mb4',
  43. 'cursorclass': pymysql.cursors.DictCursor,
  44. 'autocommit': True,
  45. 'maxconnections': 10,
  46. 'mincached': 3,
  47. 'maxcached': 5,
  48. 'maxusage': 1000,
  49. 'ping': 7,
  50. 'blocking': True,
  51. 'setsession': [],
  52. }
  53. REQUEST_TIMEOUT = 30
  54. REQUEST_INTERVAL = 3
  55. CONCURRENT_WORKERS = int(os.getenv("CONCURRENT_WORKERS", "1"))
  56. MAX_RETRIES = 3
  57. RETRY_DELAY = 2
  58. # ============================================================
  59. # 配置日志
  60. # ============================================================
  61. def setup_logging():
  62. """配置日志系统"""
  63. log_dir = os.path.dirname(os.path.abspath(__file__))
  64. log_file = os.path.join(log_dir, "logs", f"sync_goods_attr_by_ids_{datetime.now().strftime('%Y%m%d')}.log")
  65. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  66. logging.basicConfig(
  67. level=logging.INFO,
  68. format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s',
  69. handlers=[
  70. logging.StreamHandler(sys.stdout),
  71. logging.FileHandler(log_file, encoding='utf-8')
  72. ]
  73. )
  74. return logging.getLogger(__name__)
  75. logger = setup_logging()
  76. def _to_db_scalar(v):
  77. """将可能为 list/dict 的值转为标量,避免 cursor.execute 时 'not all arguments converted'"""
  78. if v is None:
  79. return None
  80. if isinstance(v, (list, dict)):
  81. return json.dumps(v, ensure_ascii=False) if v else None
  82. if isinstance(v, (datetime, Decimal)):
  83. return v
  84. if isinstance(v, (str, int, float, bool)):
  85. return v
  86. return str(v)
  87. _request_lock = threading.Lock()
  88. _last_request_time = [0.0]
  89. # ============================================================
  90. # 数据库连接池操作类
  91. # ============================================================
  92. class DatabaseConnectionPool:
  93. _instance = None
  94. _pool = None
  95. def __new__(cls):
  96. if cls._instance is None:
  97. cls._instance = super().__new__(cls)
  98. cls._instance._init_pool()
  99. return cls._instance
  100. def _init_pool(self):
  101. try:
  102. self._pool = PooledDB(**DB_POOL_CONFIG)
  103. logger.info(f"数据库连接池初始化成功: {DB_HOST}:{DB_PORT}/{DB_NAME}")
  104. except Exception as e:
  105. logger.error(f"数据库连接池初始化失败: {e}")
  106. raise
  107. def get_connection(self):
  108. try:
  109. return self._pool.connection()
  110. except Exception as e:
  111. logger.error(f"从连接池获取连接失败: {e}")
  112. time.sleep(2)
  113. self._init_pool()
  114. return self._pool.connection()
  115. def close_pool(self):
  116. if self._pool:
  117. self._pool.close()
  118. logger.info("数据库连接池已关闭")
  119. class GoodsAttrDatabase:
  120. def __init__(self):
  121. self.pool = DatabaseConnectionPool()
  122. def execute_query(self, sql: str, params=None, fetch_all=True):
  123. conn = None
  124. cursor = None
  125. try:
  126. conn = self.pool.get_connection()
  127. cursor = conn.cursor()
  128. if params:
  129. cursor.execute(sql, params)
  130. else:
  131. cursor.execute(sql)
  132. return cursor.fetchall() if fetch_all else cursor.fetchone()
  133. except pymysql.Error as e:
  134. logger.error(f"数据库查询失败: {e}, SQL: {sql}")
  135. raise
  136. finally:
  137. if cursor:
  138. cursor.close()
  139. def execute_update(self, sql: str, params=None, many=False):
  140. conn = None
  141. cursor = None
  142. try:
  143. conn = self.pool.get_connection()
  144. cursor = conn.cursor()
  145. if many and params:
  146. cursor.executemany(sql, params)
  147. elif params:
  148. cursor.execute(sql, params)
  149. else:
  150. cursor.execute(sql)
  151. return cursor.rowcount
  152. except pymysql.Error as e:
  153. logger.error(f"数据库更新失败: {e}, SQL: {sql}")
  154. raise
  155. finally:
  156. if cursor:
  157. cursor.close()
  158. def execute_transaction(self, operations: List[Tuple[str, tuple]]):
  159. conn = None
  160. cursor = None
  161. try:
  162. conn = self.pool.get_connection()
  163. cursor = conn.cursor()
  164. conn.begin()
  165. for sql, params in operations:
  166. if params:
  167. cursor.execute(sql, params)
  168. else:
  169. cursor.execute(sql)
  170. conn.commit()
  171. return True
  172. except Exception as e:
  173. logger.error(f"事务执行失败: {e}")
  174. if conn:
  175. try:
  176. conn.rollback()
  177. except Exception:
  178. pass
  179. return False
  180. finally:
  181. if cursor:
  182. cursor.close()
  183. def batch_insert_goods_attrs(self, attr_data_list: List[Dict[str, Any]]) -> int:
  184. if not attr_data_list:
  185. return 0
  186. operations = []
  187. for attr_data in attr_data_list:
  188. delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
  189. insert_sql = """
  190. INSERT INTO buff_goods_attr
  191. (goods_id, name, icon_url, type, type_name, category, category_name,
  192. category_group, category_group_name, rarity, rarity_name,
  193. quality, quality_name, exterior, exterior_name, weapon, weapon_name,
  194. series, series_name, custom, custom_name, itemset, itemset_name,
  195. extra_tag_ids, extra_tag_ids_name, custom_patch, custom_patch_name,
  196. custom_charm, custom_charm_name, stat_time, create_time, update_time)
  197. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  198. %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
  199. """
  200. values = (
  201. _to_db_scalar(attr_data.get('goods_id')),
  202. _to_db_scalar(attr_data.get('name')) or '',
  203. _to_db_scalar(attr_data.get('icon_url')),
  204. _to_db_scalar(attr_data.get('type')),
  205. _to_db_scalar(attr_data.get('type_name')),
  206. _to_db_scalar(attr_data.get('category')),
  207. _to_db_scalar(attr_data.get('category_name')),
  208. _to_db_scalar(attr_data.get('category_group')),
  209. _to_db_scalar(attr_data.get('category_group_name')),
  210. _to_db_scalar(attr_data.get('rarity')),
  211. _to_db_scalar(attr_data.get('rarity_name')),
  212. _to_db_scalar(attr_data.get('quality')),
  213. _to_db_scalar(attr_data.get('quality_name')),
  214. _to_db_scalar(attr_data.get('exterior')),
  215. _to_db_scalar(attr_data.get('exterior_name')),
  216. _to_db_scalar(attr_data.get('weapon')),
  217. _to_db_scalar(attr_data.get('weapon_name')),
  218. _to_db_scalar(attr_data.get('series')),
  219. _to_db_scalar(attr_data.get('series_name')),
  220. _to_db_scalar(attr_data.get('custom')),
  221. _to_db_scalar(attr_data.get('custom_name')),
  222. _to_db_scalar(attr_data.get('itemset')),
  223. _to_db_scalar(attr_data.get('itemset_name')),
  224. _to_db_scalar(attr_data.get('extra_tag_ids')),
  225. _to_db_scalar(attr_data.get('extra_tag_ids_name')),
  226. _to_db_scalar(attr_data.get('custom_patch')),
  227. _to_db_scalar(attr_data.get('custom_patch_name')),
  228. _to_db_scalar(attr_data.get('custom_charm')),
  229. _to_db_scalar(attr_data.get('custom_charm_name')),
  230. _to_db_scalar(attr_data.get('stat_time')) or datetime.now(),
  231. )
  232. goods_id_val = _to_db_scalar(attr_data.get('goods_id')) or attr_data['goods_id']
  233. operations.append((delete_sql, (goods_id_val,)))
  234. operations.append((insert_sql, values))
  235. try:
  236. if self.execute_transaction(operations):
  237. logger.info(f"成功批量插入 {len(attr_data_list)} 个饰品的属性信息")
  238. return len(attr_data_list)
  239. return 0
  240. except Exception as e:
  241. logger.error(f"批量插入饰品属性信息异常: {e}")
  242. return 0
  243. def check_connection(self):
  244. try:
  245. self.execute_query("SELECT 1")
  246. return True
  247. except Exception as e:
  248. logger.error(f"数据库连接检查失败: {e}")
  249. return False
  250. # ============================================================
  251. # BUFF API 操作类
  252. # ============================================================
  253. class BuffGoodsAPI:
  254. def __init__(self):
  255. self.session = requests.Session()
  256. self.session.headers.update({
  257. "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",
  258. "Accept": "application/json, text/plain, */*",
  259. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  260. "Accept-Encoding": "gzip, deflate, br",
  261. "Connection": "keep-alive",
  262. "Referer": "https://buff.163.com/",
  263. "Sec-Fetch-Dest": "empty",
  264. "Sec-Fetch-Mode": "cors",
  265. "Sec-Fetch-Site": "same-origin"
  266. })
  267. def fetch_goods_info(self, goods_id: str) -> Optional[Dict[str, Any]]:
  268. url = f"{BUFF_API_BASE_URL}?game={GAME}&goods_id={goods_id}"
  269. for retry in range(MAX_RETRIES):
  270. try:
  271. with _request_lock:
  272. elapsed = time.time() - _last_request_time[0]
  273. if elapsed < REQUEST_INTERVAL:
  274. time.sleep(REQUEST_INTERVAL - elapsed)
  275. _last_request_time[0] = time.time()
  276. response = requests.get(
  277. url,
  278. headers=self.session.headers,
  279. timeout=REQUEST_TIMEOUT,
  280. )
  281. response.raise_for_status()
  282. data = response.json()
  283. if data.get("code") != "OK":
  284. logger.warning(f"饰品 {goods_id} API返回错误: {data.get('msg', '未知错误')}")
  285. return None
  286. goods_info = self._extract_goods_info(data, goods_id)
  287. if goods_info:
  288. return goods_info
  289. return None
  290. except requests.exceptions.RequestException as e:
  291. logger.error(f"请求饰品 {goods_id} 失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  292. if retry < MAX_RETRIES - 1:
  293. time.sleep(RETRY_DELAY * (retry + 1))
  294. else:
  295. return None
  296. except json.JSONDecodeError:
  297. return None
  298. except Exception as e:
  299. logger.error(f"处理饰品 {goods_id} 时发生错误: {e}")
  300. return None
  301. return None
  302. def _extract_goods_info(self, api_data: Dict[str, Any], goods_id: str) -> Optional[Dict[str, Any]]:
  303. try:
  304. data = api_data.get("data", {})
  305. goods_infos = data.get("goods_infos", {})
  306. goods_data = goods_infos.get(str(goods_id))
  307. if not goods_data:
  308. return None
  309. tags = goods_data.get("tags", {})
  310. attr_data = {
  311. 'goods_id': goods_id,
  312. 'name': goods_data.get("name", ""),
  313. 'icon_url': goods_data.get("icon_url"),
  314. 'stat_time': datetime.now(),
  315. 'create_time': datetime.now()
  316. }
  317. tag_mapping = {
  318. 'type': ('type', 'type_name'),
  319. 'rarity': ('rarity', 'rarity_name'),
  320. 'category': ('category', 'category_name'),
  321. 'category_group': ('category_group', 'category_group_name'),
  322. 'quality': ('quality', 'quality_name'),
  323. 'exterior': ('exterior', 'exterior_name'),
  324. 'weapon': ('weapon', 'weapon_name'),
  325. 'series': ('series', 'series_name'),
  326. 'custom': ('custom', 'custom_name'),
  327. 'itemset': ('itemset', 'itemset_name')
  328. }
  329. for tag_key, (field_key, field_name_key) in tag_mapping.items():
  330. if tag_key in tags:
  331. tag_data = tags[tag_key]
  332. attr_data[field_key] = tag_data.get("internal_name")
  333. attr_data[field_name_key] = tag_data.get("localized_name")
  334. return attr_data
  335. except Exception as e:
  336. logger.error(f"提取饰品 {goods_id} 属性时出错: {e}")
  337. return None
  338. # ============================================================
  339. # 主逻辑
  340. # ============================================================
  341. class GoodsAttrSyncTask:
  342. def __init__(self):
  343. self.db = GoodsAttrDatabase()
  344. self.api = BuffGoodsAPI()
  345. def fetch_and_save_goods_attrs(self, goods_ids: List[str]) -> Dict[str, int]:
  346. results = {'success': 0, 'failed': 0, 'total': len(goods_ids)}
  347. batch_attr_data: List[Dict[str, Any]] = []
  348. def fetch_one(goods_id: str) -> Optional[Dict[str, Any]]:
  349. try:
  350. return self.api.fetch_goods_info(goods_id)
  351. except Exception as e:
  352. logger.error(f"获取饰品 {goods_id} 属性异常: {e}")
  353. return None
  354. with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_WORKERS) as executor:
  355. future_to_id = {executor.submit(fetch_one, gid): gid for gid in goods_ids}
  356. for future in concurrent.futures.as_completed(future_to_id):
  357. goods_id = future_to_id[future]
  358. try:
  359. attr_data = future.result()
  360. if attr_data:
  361. batch_attr_data.append(attr_data)
  362. logger.info(f"获取饰品 {goods_id} 属性成功")
  363. else:
  364. results['failed'] += 1
  365. logger.warning(f"获取饰品 {goods_id} 属性失败")
  366. except Exception as e:
  367. results['failed'] += 1
  368. logger.error(f"获取饰品 {goods_id} 异常: {e}")
  369. if batch_attr_data:
  370. saved_count = self.db.batch_insert_goods_attrs(batch_attr_data)
  371. results['success'] += saved_count
  372. if saved_count < len(batch_attr_data):
  373. results['failed'] += len(batch_attr_data) - saved_count
  374. return results
  375. def cleanup(self):
  376. try:
  377. DatabaseConnectionPool().close_pool()
  378. logger.info("资源清理完成")
  379. except Exception:
  380. pass
  381. def parse_goods_ids(ids_str: str) -> List[str]:
  382. """从逗号分隔的字符串解析出饰品ID列表,去重并过滤空串"""
  383. if not ids_str or not ids_str.strip():
  384. return []
  385. parts = [p.strip() for p in ids_str.split(",") if p.strip()]
  386. seen = set()
  387. result = []
  388. for p in parts:
  389. if p not in seen:
  390. seen.add(p)
  391. result.append(p)
  392. return result
  393. # ============================================================
  394. # 主函数
  395. # ============================================================
  396. def main():
  397. task = None
  398. try:
  399. # 从命令行获取逗号分隔的饰品ID:第一个参数
  400. if len(sys.argv) < 2:
  401. print("用法: python sync_goods_attr_by_ids.py <饰品ID1>,<饰品ID2>,...")
  402. print("示例: python sync_goods_attr_by_ids.py 1115519,1115528,1234567")
  403. sys.exit(1)
  404. ids_str = sys.argv[1]
  405. goods_ids = parse_goods_ids(ids_str)
  406. if not goods_ids:
  407. logger.error("未解析到有效饰品ID,请使用逗号分隔的ID列表")
  408. sys.exit(1)
  409. logger.info("启动按指定饰品ID属性同步脚本...")
  410. logger.info(f"待处理饰品ID: {goods_ids}")
  411. logger.info(f"共 {len(goods_ids)} 个,请求间隔 {REQUEST_INTERVAL} 秒")
  412. task = GoodsAttrSyncTask()
  413. if not task.db.check_connection():
  414. logger.error("数据库连接不可用,任务中止")
  415. sys.exit(1)
  416. start_time = datetime.now()
  417. results = task.fetch_and_save_goods_attrs(goods_ids)
  418. duration = (datetime.now() - start_time).total_seconds()
  419. logger.info("=" * 60)
  420. logger.info("任务完成")
  421. logger.info(f"总数: {results['total']}, 成功: {results['success']}, 失败: {results['failed']}, 耗时: {duration:.2f}秒")
  422. logger.info("=" * 60)
  423. sys.exit(0 if results['failed'] == 0 else 1)
  424. except KeyboardInterrupt:
  425. logger.info("\n收到中断信号,正在退出...")
  426. if task:
  427. task.cleanup()
  428. sys.exit(0)
  429. except Exception as e:
  430. logger.exception(f"程序运行异常: {e}")
  431. if task:
  432. task.cleanup()
  433. sys.exit(1)
  434. if __name__ == "__main__":
  435. try:
  436. import dbutils
  437. except ImportError:
  438. print("请先安装依赖: pip install DBUtils==3.0.2")
  439. sys.exit(1)
  440. main()