sync_goods_attr.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品属性信息获取脚本(使用连接池版本)
  5. 特点:
  6. 1. 使用DBUtils连接池管理数据库连接
  7. 2. 自动处理连接失效和重连
  8. 3. 更稳定的长连接管理
  9. 4. 避免频繁创建和关闭连接的开销
  10. 5. 通过代理请求接口,403 自动切换代理重试
  11. 快速补齐缺失属性:可设置环境变量调大每轮处理量,例如:
  12. BATCH_SIZE=200 python sync_goods_attr.py
  13. (当前为串行模式:每次 1 个请求,不主动增加固定请求间隔)
  14. 安装依赖:
  15. pip install DBUtils==3.0.2
  16. """
  17. import os
  18. import random
  19. import sys
  20. import time
  21. import logging
  22. import threading
  23. import json
  24. import requests
  25. import pymysql
  26. from dbutils.pooled_db import PooledDB
  27. from datetime import datetime
  28. from typing import List, Dict, Any, Optional, Tuple
  29. def _scalar_for_db(val: Any) -> Any:
  30. """将 API 返回的 list/dict 等转为 DB 单值,避免 'not all arguments converted during string formatting'"""
  31. if val is None or isinstance(val, (str, int, float)):
  32. return val
  33. if isinstance(val, datetime):
  34. return val
  35. if isinstance(val, (list, dict)):
  36. return json.dumps(val, ensure_ascii=False) if val else None
  37. # 元组、集合等可迭代但非标量一律转成字符串,避免被当成多个参数
  38. return str(val) if val is not None else None
  39. # 与 buff_goods_attr 表 INSERT 列顺序一致(不含 create_time/update_time,用 NOW())
  40. INSERT_ATTR_COLUMNS = [
  41. 'goods_id', 'name', 'icon_url', 'type', 'type_name', 'category', 'category_name',
  42. 'category_group', 'category_group_name', 'rarity', 'rarity_name', 'quality', 'quality_name',
  43. 'exterior', 'exterior_name', 'weapon', 'weapon_name', 'series', 'series_name',
  44. 'custom', 'custom_name', 'itemset', 'itemset_name', 'extra_tag_ids', 'extra_tag_ids_name',
  45. 'custom_patch', 'custom_patch_name', 'custom_charm', 'custom_charm_name', 'stat_time',
  46. ]
  47. INSERT_ATTR_SQL = """
  48. INSERT INTO buff_goods_attr
  49. (goods_id, name, icon_url, type, type_name, category, category_name,
  50. category_group, category_group_name, rarity, rarity_name, quality, quality_name,
  51. exterior, exterior_name, weapon, weapon_name, series, series_name,
  52. custom, custom_name, itemset, itemset_name, extra_tag_ids, extra_tag_ids_name,
  53. custom_patch, custom_patch_name, custom_charm, custom_charm_name, stat_time, create_time, update_time)
  54. 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())
  55. """
  56. # ============================================================
  57. # 配置信息
  58. # ============================================================
  59. # BUFF API配置
  60. BUFF_API_BASE_URL = "https://buff.163.com/api/market/goods/sell_order"
  61. GAME = "csgo"
  62. PROXY_POOL_API_URL = os.getenv(
  63. "PROXY_POOL_API_URL",
  64. "https://api.haiwaiip.net/api/pull?token=3d706c31d8d0267d757706b3e4d81454&num=1&format=text&protocol=http&country=&state=&city=&ip_type=datacenter&sep=1&area=",
  65. )
  66. PROXY_POOL_TIMEOUT = int(os.getenv("PROXY_POOL_TIMEOUT", "10"))
  67. # 数据库配置
  68. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  69. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  70. DB_USER = os.getenv("DB_USER", "root")
  71. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  72. DB_NAME = os.getenv("DB_NAME", "buff")
  73. # 连接池配置
  74. DB_POOL_CONFIG = {
  75. 'creator': pymysql,
  76. 'host': DB_HOST,
  77. 'port': DB_PORT,
  78. 'user': DB_USER,
  79. 'password': DB_PASSWORD,
  80. 'database': DB_NAME,
  81. 'charset': 'utf8mb4',
  82. 'cursorclass': pymysql.cursors.DictCursor,
  83. 'autocommit': True, # 自动提交事务
  84. 'maxconnections': 10, # 最大连接数
  85. 'mincached': 3, # 初始空闲连接数
  86. 'maxcached': 5, # 最大空闲连接数
  87. 'maxusage': 1000, # 单个连接最大使用次数
  88. 'ping': 7, # 使用ping检查连接:0=从不, 1=创建时, 2=获取时, 4=归还时, 7=总是
  89. 'blocking': True, # 连接池满时是否等待
  90. 'setsession': [], # 可选的会话命令列表
  91. }
  92. # 请求配置(可通过环境变量 BATCH_SIZE 调大每轮处理量)
  93. REQUEST_TIMEOUT = 30
  94. CONCURRENT_WORKERS = int(os.getenv("CONCURRENT_WORKERS", "1")) # 并发请求数(1=串行)
  95. GOODS_PER_REQUEST = int(os.getenv("GOODS_PER_REQUEST", "10")) # 仅用于批量模式(当前主流程默认串行单请求)
  96. MAX_RETRIES = 3
  97. RETRY_DELAY = 2
  98. BATCH_SIZE = int(os.getenv("BATCH_SIZE", "30")) # 每轮拉取的饰品数量,每批提交一次,调小便于数据及时入库
  99. FAILURE_LONG_REST_THRESHOLD = int(os.getenv("FAILURE_LONG_REST_THRESHOLD", "5")) # 连续请求失败达到阈值后触发长休息
  100. FAILURE_LONG_REST_MIN_SEC = int(os.getenv("FAILURE_LONG_REST_MIN_SEC", "300")) # 失败触发长休息最短秒数(默认 5 分钟)
  101. FAILURE_LONG_REST_MAX_SEC = int(os.getenv("FAILURE_LONG_REST_MAX_SEC", "600")) # 失败触发长休息最长秒数(默认 10 分钟)
  102. FORBIDDEN_STOP_THRESHOLD = int(os.getenv("FORBIDDEN_STOP_THRESHOLD", "3")) # 连续检测到 403 后直接停止脚本的阈值
  103. # ============================================================
  104. # 配置日志
  105. # ============================================================
  106. def setup_logging():
  107. """配置日志系统"""
  108. log_dir = os.path.dirname(os.path.abspath(__file__))
  109. log_file = os.path.join(log_dir, "logs", f"sync_goods_attr_{datetime.now().strftime('%Y%m%d')}.log")
  110. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  111. logging.basicConfig(
  112. level=logging.INFO,
  113. format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s',
  114. handlers=[
  115. logging.StreamHandler(sys.stdout),
  116. logging.FileHandler(log_file, encoding='utf-8')
  117. ]
  118. )
  119. return logging.getLogger(__name__)
  120. logger = setup_logging()
  121. _request_lock = threading.Lock()
  122. _consecutive_request_failures = [0] # 连续请求失败计数(网络错误 / API 返回错误)
  123. _consecutive_403_errors = [0] # 连续 403 错误计数
  124. def _record_request_failure():
  125. """
  126. 记录一次请求失败(网络错误、状态码/业务code异常等),
  127. 当连续失败次数达到阈值时随机长休息 5-10 分钟以降低被封 IP 风险。
  128. """
  129. with _request_lock:
  130. _consecutive_request_failures[0] += 1
  131. current_failures = _consecutive_request_failures[0]
  132. if current_failures >= FAILURE_LONG_REST_THRESHOLD:
  133. rest_sec = random.randint(FAILURE_LONG_REST_MIN_SEC, FAILURE_LONG_REST_MAX_SEC)
  134. logger.warning(
  135. "请求连续失败 %s 次,触发防封长休息,脚本将休息 %s 秒后再继续请求...",
  136. current_failures,
  137. rest_sec,
  138. )
  139. time.sleep(rest_sec)
  140. with _request_lock:
  141. _consecutive_request_failures[0] = 0
  142. def _reset_request_failures():
  143. """请求成功(包括正常返回但未找到该饰品)时重置连续失败计数。"""
  144. with _request_lock:
  145. if _consecutive_request_failures[0] != 0:
  146. _consecutive_request_failures[0] = 0
  147. def _record_403_and_maybe_stop():
  148. """
  149. 记录一次 403 错误。
  150. 连续 403 说明当前出口 IP 可能被风控,需要切换代理。
  151. 当连续次数达到阈值时,执行一次短暂休息,避免过于频繁地请求。
  152. """
  153. with _request_lock:
  154. _consecutive_403_errors[0] += 1
  155. current_403 = _consecutive_403_errors[0]
  156. logger.error("检测到 403 Forbidden 错误(连续第 %s 次)", current_403)
  157. if current_403 >= FORBIDDEN_STOP_THRESHOLD:
  158. rest_sec = random.randint(30, 90)
  159. logger.warning(
  160. "连续检测到 %s 次 403 Forbidden,脚本将休息 %s 秒并继续尝试(期间会切换代理)。",
  161. current_403,
  162. rest_sec,
  163. )
  164. time.sleep(rest_sec)
  165. def _reset_403_errors():
  166. """请求成功(含正常业务失败)时重置 403 计数。"""
  167. with _request_lock:
  168. if _consecutive_403_errors[0] != 0:
  169. _consecutive_403_errors[0] = 0
  170. # ============================================================
  171. # 数据库连接池操作类
  172. # ============================================================
  173. class DatabaseConnectionPool:
  174. """数据库连接池管理类"""
  175. _instance = None
  176. _pool = None
  177. def __new__(cls):
  178. if cls._instance is None:
  179. cls._instance = super().__new__(cls)
  180. cls._instance._init_pool()
  181. return cls._instance
  182. def _init_pool(self):
  183. """初始化连接池"""
  184. try:
  185. self._pool = PooledDB(**DB_POOL_CONFIG)
  186. logger.info(f"数据库连接池初始化成功: {DB_HOST}:{DB_PORT}/{DB_NAME}")
  187. logger.info(f"连接池配置: maxconnections={DB_POOL_CONFIG['maxconnections']}, mincached={DB_POOL_CONFIG['mincached']}")
  188. except Exception as e:
  189. logger.error(f"数据库连接池初始化失败: {e}")
  190. raise
  191. def get_connection(self):
  192. """从连接池获取连接"""
  193. try:
  194. conn = self._pool.connection()
  195. return conn
  196. except Exception as e:
  197. logger.error(f"从连接池获取连接失败: {e}")
  198. # 尝试重新初始化连接池
  199. time.sleep(2)
  200. self._init_pool()
  201. return self._pool.connection()
  202. def close_pool(self):
  203. """关闭连接池"""
  204. if self._pool:
  205. self._pool.close()
  206. logger.info("数据库连接池已关闭")
  207. class GoodsAttrDatabase:
  208. """饰品属性数据库操作类(使用连接池)"""
  209. def __init__(self):
  210. self.pool = DatabaseConnectionPool()
  211. def execute_query(self, sql: str, params=None, fetch_all=True):
  212. """执行查询"""
  213. conn = None
  214. cursor = None
  215. try:
  216. conn = self.pool.get_connection()
  217. cursor = conn.cursor()
  218. if params:
  219. cursor.execute(sql, params)
  220. else:
  221. cursor.execute(sql)
  222. if fetch_all:
  223. return cursor.fetchall()
  224. else:
  225. return cursor.fetchone()
  226. except pymysql.Error as e:
  227. logger.error(f"数据库查询失败: {e}, SQL: {sql}")
  228. raise
  229. finally:
  230. if cursor:
  231. cursor.close()
  232. # 注意:这里不需要关闭conn,连接池会自动管理
  233. def execute_update(self, sql: str, params=None, many=False):
  234. """执行更新操作"""
  235. conn = None
  236. cursor = None
  237. try:
  238. conn = self.pool.get_connection()
  239. cursor = conn.cursor()
  240. if many and params:
  241. cursor.executemany(sql, params)
  242. elif params:
  243. cursor.execute(sql, params)
  244. else:
  245. cursor.execute(sql)
  246. affected_rows = cursor.rowcount
  247. return affected_rows
  248. except pymysql.Error as e:
  249. logger.error(f"数据库更新失败: {e}, SQL: {sql}")
  250. raise
  251. finally:
  252. if cursor:
  253. cursor.close()
  254. def execute_transaction(self, operations: List[Tuple[str, tuple]]):
  255. """执行事务(一组操作要么全部成功,要么全部回滚)"""
  256. conn = None
  257. cursor = None
  258. try:
  259. conn = self.pool.get_connection()
  260. cursor = conn.cursor()
  261. # 开始事务
  262. conn.begin()
  263. for sql, params in operations:
  264. if params:
  265. cursor.execute(sql, params)
  266. else:
  267. cursor.execute(sql)
  268. # 提交事务
  269. conn.commit()
  270. return True
  271. except Exception as e:
  272. logger.error(f"事务执行失败: {e}")
  273. if conn:
  274. try:
  275. conn.rollback()
  276. except:
  277. pass
  278. return False
  279. finally:
  280. if cursor:
  281. cursor.close()
  282. def get_goods_without_attr(self, limit: int = BATCH_SIZE, offset: int = 0) -> List[Dict[str, Any]]:
  283. """
  284. 获取没有属性信息的饰品列表(支持分页,便于每轮拉取不同批次)
  285. 从 buff_goods_info 表中找出在 buff_goods_attr 表中不存在的饰品。
  286. offset 用于轮次推进:每轮取下一批,全部取完后从 0 再开始。
  287. """
  288. try:
  289. sql = """
  290. SELECT
  291. g.goods_id,
  292. g.name,
  293. g.buff_url,
  294. g.stat_time
  295. FROM buff_goods_info g
  296. LEFT JOIN buff_goods_attr a ON g.goods_id = a.goods_id
  297. WHERE a.goods_id IS NULL
  298. ORDER BY g.goods_id ASC
  299. LIMIT %s OFFSET %s
  300. """
  301. results = self.execute_query(sql, (limit, offset))
  302. goods_list = []
  303. for row in results:
  304. goods_list.append({
  305. 'goods_id': row['goods_id'],
  306. 'name': row['name'],
  307. 'buff_url': row['buff_url'],
  308. 'stat_time': row['stat_time']
  309. })
  310. logger.info(f"找到 {len(goods_list)} 个没有属性信息的饰品")
  311. return goods_list
  312. except Exception as e:
  313. logger.error(f"查询没有属性信息的饰品失败: {e}")
  314. return []
  315. def _attr_row_for_insert(self, attr_data: Dict[str, Any]) -> tuple:
  316. """按表列顺序生成一条 INSERT 参数元组,保证每项均为标量(30 个占位符对应 30 列)"""
  317. row = []
  318. for k in INSERT_ATTR_COLUMNS:
  319. v = attr_data.get(k) if k != 'stat_time' else attr_data.get('stat_time', datetime.now())
  320. row.append(_scalar_for_db(v))
  321. return tuple(row)
  322. def batch_insert_goods_attrs(self, attr_data_list: List[Dict[str, Any]]) -> int:
  323. """批量插入饰品属性信息:先全部 DELETE,再 executemany INSERT,避免占位符与参数数量错位"""
  324. if not attr_data_list:
  325. return 0
  326. delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
  327. rows = [self._attr_row_for_insert(attr_data) for attr_data in attr_data_list]
  328. # 每行必须是 30 个标量,与 INSERT_ATTR_SQL 中 30 个 %s 一致
  329. assert len(INSERT_ATTR_COLUMNS) == 30, "INSERT_ATTR_COLUMNS 须为 30 项"
  330. try:
  331. conn = None
  332. cursor = None
  333. try:
  334. conn = self.pool.get_connection()
  335. cursor = conn.cursor()
  336. conn.begin()
  337. # 先删除
  338. for attr_data in attr_data_list:
  339. gid = _scalar_for_db(attr_data['goods_id'])
  340. cursor.execute(delete_sql, (gid,))
  341. # 再批量插入
  342. cursor.executemany(INSERT_ATTR_SQL, rows)
  343. conn.commit()
  344. logger.info(f"成功批量插入 {len(attr_data_list)} 个饰品的属性信息")
  345. return len(attr_data_list)
  346. except Exception as e:
  347. logger.exception("事务执行失败")
  348. if conn:
  349. try:
  350. conn.rollback()
  351. except Exception:
  352. pass
  353. return 0
  354. finally:
  355. if cursor:
  356. cursor.close()
  357. except Exception as e:
  358. logger.error(f"批量插入饰品属性信息异常: {e}")
  359. return 0
  360. def insert_goods_attr(self, attr_data: Dict[str, Any]) -> bool:
  361. """插入单个饰品属性信息"""
  362. try:
  363. delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
  364. row = self._attr_row_for_insert(attr_data)
  365. operations = [
  366. (delete_sql, (_scalar_for_db(attr_data['goods_id']),)),
  367. (INSERT_ATTR_SQL, row),
  368. ]
  369. success = self.execute_transaction(operations)
  370. if success:
  371. logger.debug(f"成功插入饰品 {attr_data['goods_id']} 的属性信息")
  372. else:
  373. logger.error(f"插入饰品 {attr_data['goods_id']} 属性信息失败")
  374. return success
  375. except Exception as e:
  376. logger.error(f"插入饰品 {attr_data.get('goods_id', '未知')} 属性信息异常: {e}")
  377. return False
  378. def check_connection(self):
  379. """检查连接是否正常"""
  380. try:
  381. self.execute_query("SELECT 1")
  382. return True
  383. except Exception as e:
  384. logger.error(f"数据库连接检查失败: {e}")
  385. return False
  386. # ============================================================
  387. # BUFF API 操作类
  388. # ============================================================
  389. class BuffGoodsAPI:
  390. """BUFF饰品API操作类(代理请求,403/429 自动换 IP)"""
  391. def __init__(self):
  392. self.session = requests.Session()
  393. self._proxy_lock = threading.Lock()
  394. self._current_proxy: Optional[str] = None
  395. self._supports_batch_request: Optional[bool] = None
  396. self.session.headers.update({
  397. "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",
  398. "Accept": "application/json, text/plain, */*",
  399. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  400. "Accept-Encoding": "gzip, deflate, br",
  401. "Connection": "keep-alive",
  402. "Referer": "https://buff.163.com/",
  403. "Sec-Fetch-Dest": "empty",
  404. "Sec-Fetch-Mode": "cors",
  405. "Sec-Fetch-Site": "same-origin"
  406. })
  407. self._rotate_proxy("初始化")
  408. def _fetch_new_proxy_from_pool(self) -> Optional[str]:
  409. """从代理池接口获取新代理,格式: ip:port"""
  410. try:
  411. resp = requests.get(PROXY_POOL_API_URL, timeout=PROXY_POOL_TIMEOUT)
  412. resp.raise_for_status()
  413. proxy_text = (resp.text or "").strip()
  414. if not proxy_text:
  415. logger.error("代理池返回为空")
  416. return None
  417. # 兼容多行返回,只取第一行
  418. proxy_line = proxy_text.splitlines()[0].strip()
  419. if ":" not in proxy_line:
  420. logger.error("代理池返回格式不正确: %s", proxy_line)
  421. return None
  422. return proxy_line
  423. except Exception as e:
  424. logger.error("获取代理失败: %s", e)
  425. return None
  426. def _rotate_proxy(self, reason: str) -> bool:
  427. """切换当前代理"""
  428. new_proxy = self._fetch_new_proxy_from_pool()
  429. if not new_proxy:
  430. logger.error("切换代理失败(原因:%s)", reason)
  431. return False
  432. with self._proxy_lock:
  433. old_proxy = self._current_proxy
  434. self._current_proxy = new_proxy
  435. logger.info("代理已切换(%s): %s -> %s", reason, old_proxy or "None", new_proxy)
  436. return True
  437. def _get_current_proxies(self) -> Optional[Dict[str, str]]:
  438. with self._proxy_lock:
  439. proxy = self._current_proxy
  440. if not proxy:
  441. return None
  442. proxy_url = f"http://{proxy}"
  443. return {"http": proxy_url, "https": proxy_url}
  444. def fetch_goods_info(self, goods_id: str) -> Optional[Dict[str, Any]]:
  445. """获取单个饰品的详细信息"""
  446. url = f"{BUFF_API_BASE_URL}?game={GAME}&goods_id={goods_id}"
  447. for retry in range(MAX_RETRIES):
  448. try:
  449. logger.debug(f"请求饰品 {goods_id} (第{retry + 1}次尝试)...")
  450. proxies = self._get_current_proxies()
  451. if not proxies and not self._rotate_proxy("请求前代理为空"):
  452. _record_request_failure()
  453. if retry < MAX_RETRIES - 1:
  454. time.sleep(RETRY_DELAY * (retry + 1))
  455. continue
  456. return None
  457. proxies = self._get_current_proxies()
  458. # 使用 requests.get 避免多线程共享 Session,保证并发安全
  459. response = requests.get(
  460. url,
  461. headers=self.session.headers,
  462. proxies=proxies,
  463. timeout=REQUEST_TIMEOUT,
  464. )
  465. if response.status_code in (403, 429):
  466. status = response.status_code
  467. logger.warning("饰品 %s 请求返回 %s,准备切换代理后重试", goods_id, status)
  468. if status == 403:
  469. _record_403_and_maybe_stop()
  470. else:
  471. _record_request_failure()
  472. rotated = self._rotate_proxy(f"goods_id={goods_id} 触发{status}")
  473. if rotated:
  474. if status == 403:
  475. _reset_403_errors()
  476. if retry < MAX_RETRIES - 1:
  477. time.sleep(RETRY_DELAY * (retry + 1))
  478. continue
  479. logger.error("饰品 %s 因连续 %s 最终失败", goods_id, status)
  480. return None
  481. response.raise_for_status()
  482. data = response.json()
  483. # 检查API响应状态
  484. if data.get("code") != "OK":
  485. logger.warning(f"饰品 {goods_id} API返回错误: {data.get('msg', '未知错误')}")
  486. _record_request_failure()
  487. return None
  488. # 提取饰品信息
  489. goods_info = self._extract_goods_info(data, goods_id)
  490. if goods_info:
  491. _reset_request_failures()
  492. _reset_403_errors()
  493. logger.info(f"成功获取饰品 {goods_id} 的属性信息")
  494. return goods_info
  495. else:
  496. # 请求成功但未找到该饰品,视为正常请求,重置失败计数
  497. _reset_request_failures()
  498. _reset_403_errors()
  499. logger.warning(f"饰品 {goods_id} 未找到属性信息")
  500. return None
  501. except requests.exceptions.RequestException as e:
  502. logger.error(f"请求饰品 {goods_id} 失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  503. # 如果是 HTTP 403/429,切换代理并重试
  504. try:
  505. response = getattr(e, "response", None)
  506. if response is not None and response.status_code in (403, 429):
  507. status = response.status_code
  508. if status == 403:
  509. _record_403_and_maybe_stop()
  510. else:
  511. _record_request_failure()
  512. rotated = self._rotate_proxy(f"goods_id={goods_id} RequestException-{status}")
  513. if rotated and status == 403:
  514. _reset_403_errors()
  515. else:
  516. _record_request_failure()
  517. except Exception:
  518. _record_request_failure()
  519. if retry < MAX_RETRIES - 1:
  520. time.sleep(RETRY_DELAY * (retry + 1))
  521. else:
  522. logger.error(f"饰品 {goods_id} 请求失败,已重试{MAX_RETRIES}次")
  523. return None
  524. except json.JSONDecodeError as e:
  525. logger.error(f"饰品 {goods_id} 响应JSON解析失败: {e}")
  526. _record_request_failure()
  527. return None
  528. except Exception as e:
  529. logger.error(f"处理饰品 {goods_id} 时发生未知错误: {e}")
  530. _record_request_failure()
  531. return None
  532. return None
  533. def _build_attr_data_from_goods_data(self, goods_id: str, goods_data: Dict[str, Any]) -> Dict[str, Any]:
  534. """将单个 goods_data 转为可入库结构。"""
  535. tags = goods_data.get("tags", {})
  536. attr_data = {
  537. 'goods_id': goods_id,
  538. 'name': goods_data.get("name", ""),
  539. 'icon_url': goods_data.get("icon_url"),
  540. 'stat_time': datetime.now(),
  541. 'create_time': datetime.now()
  542. }
  543. tag_mapping = {
  544. 'type': ('type', 'type_name'),
  545. 'rarity': ('rarity', 'rarity_name'),
  546. 'category': ('category', 'category_name'),
  547. 'category_group': ('category_group', 'category_group_name'),
  548. 'quality': ('quality', 'quality_name'),
  549. 'exterior': ('exterior', 'exterior_name'),
  550. 'weapon': ('weapon', 'weapon_name'),
  551. 'series': ('series', 'series_name'),
  552. 'custom': ('custom', 'custom_name'),
  553. 'itemset': ('itemset', 'itemset_name')
  554. }
  555. for tag_key, (field_key, field_name_key) in tag_mapping.items():
  556. if tag_key in tags:
  557. tag_data = tags[tag_key]
  558. attr_data[field_key] = tag_data.get("internal_name")
  559. attr_data[field_name_key] = tag_data.get("localized_name")
  560. return attr_data
  561. def _extract_goods_info_batch(self, api_data: Dict[str, Any], goods_ids: List[str]) -> Dict[str, Dict[str, Any]]:
  562. """从响应中提取一批饰品属性信息。"""
  563. extracted: Dict[str, Dict[str, Any]] = {}
  564. try:
  565. data = api_data.get("data", {})
  566. goods_infos = data.get("goods_infos", {})
  567. for goods_id in goods_ids:
  568. goods_data = goods_infos.get(str(goods_id))
  569. if not goods_data:
  570. continue
  571. extracted[str(goods_id)] = self._build_attr_data_from_goods_data(str(goods_id), goods_data)
  572. except Exception as e:
  573. logger.error(f"批量提取饰品属性失败: {e}")
  574. return extracted
  575. def _fetch_goods_info_batch_fallback(self, goods_ids: List[str]) -> Dict[str, Optional[Dict[str, Any]]]:
  576. """批量接口不可用时,降级为单个请求并发模式,保证任务不中断且尽量提速。"""
  577. logger.warning("当前批量请求不可用,降级为单个请求并发模式(本批次)")
  578. result: Dict[str, Optional[Dict[str, Any]]] = {}
  579. worker_count = max(1, min(CONCURRENT_WORKERS, len(goods_ids)))
  580. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as executor:
  581. future_to_id = {executor.submit(self.fetch_goods_info, str(goods_id)): str(goods_id) for goods_id in goods_ids}
  582. for future in concurrent.futures.as_completed(future_to_id):
  583. gid = future_to_id[future]
  584. try:
  585. result[gid] = future.result()
  586. except Exception as e:
  587. logger.error(f"降级并发模式获取饰品 {gid} 失败: {e}")
  588. result[gid] = None
  589. return result
  590. def fetch_goods_info_batch(self, goods_ids: List[str]) -> Dict[str, Optional[Dict[str, Any]]]:
  591. """批量获取饰品属性信息(优先 1 次请求拉取多个 goods_id)。"""
  592. normalized_ids = [str(gid) for gid in goods_ids if str(gid).strip()]
  593. if not normalized_ids:
  594. return {}
  595. if len(normalized_ids) == 1:
  596. gid = normalized_ids[0]
  597. return {gid: self.fetch_goods_info(gid)}
  598. if self._supports_batch_request is False:
  599. return self._fetch_goods_info_batch_fallback(normalized_ids)
  600. ids_param = ",".join(normalized_ids)
  601. url = f"{BUFF_API_BASE_URL}?game={GAME}&goods_ids={ids_param}"
  602. for retry in range(MAX_RETRIES):
  603. try:
  604. logger.info("批量请求饰品属性(第%s次,数量=%s)", retry + 1, len(normalized_ids))
  605. proxies = self._get_current_proxies()
  606. if not proxies and not self._rotate_proxy("批量请求前代理为空"):
  607. _record_request_failure()
  608. if retry < MAX_RETRIES - 1:
  609. time.sleep(RETRY_DELAY * (retry + 1))
  610. continue
  611. return self._fetch_goods_info_batch_fallback(normalized_ids)
  612. proxies = self._get_current_proxies()
  613. response = requests.get(
  614. url,
  615. headers=self.session.headers,
  616. proxies=proxies,
  617. timeout=REQUEST_TIMEOUT,
  618. )
  619. if response.status_code in (403, 429):
  620. status = response.status_code
  621. logger.warning("批量请求返回 %s,准备切换代理后重试", status)
  622. if status == 403:
  623. _record_403_and_maybe_stop()
  624. else:
  625. _record_request_failure()
  626. rotated = self._rotate_proxy(f"goods_ids 批量请求触发{status}")
  627. if rotated and status == 403:
  628. _reset_403_errors()
  629. if retry < MAX_RETRIES - 1:
  630. time.sleep(RETRY_DELAY * (retry + 1))
  631. continue
  632. return self._fetch_goods_info_batch_fallback(normalized_ids)
  633. response.raise_for_status()
  634. data = response.json()
  635. if data.get("code") != "OK":
  636. api_code = data.get("code")
  637. api_msg = data.get("msg") or "未知错误"
  638. logger.warning("批量请求 API返回错误: code=%s, msg=%s", api_code, api_msg)
  639. _record_request_failure()
  640. if self._supports_batch_request is None:
  641. self._supports_batch_request = False
  642. logger.warning("检测到批量参数 goods_ids 不可用,自动切换为单个请求并发模式")
  643. return self._fetch_goods_info_batch_fallback(normalized_ids)
  644. extracted = self._extract_goods_info_batch(data, normalized_ids)
  645. if extracted:
  646. self._supports_batch_request = True
  647. _reset_request_failures()
  648. _reset_403_errors()
  649. return {gid: extracted.get(gid) for gid in normalized_ids}
  650. # 批量接口有可能不支持 goods_ids 参数:首轮探测不到任何目标数据时自动降级
  651. if self._supports_batch_request is None:
  652. self._supports_batch_request = False
  653. logger.warning("批量参数 goods_ids 可能不受支持,自动降级为单个请求")
  654. _reset_request_failures()
  655. _reset_403_errors()
  656. return self._fetch_goods_info_batch_fallback(normalized_ids)
  657. except requests.exceptions.RequestException as e:
  658. logger.error(f"批量请求失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  659. try:
  660. response = getattr(e, "response", None)
  661. if response is not None and response.status_code in (403, 429):
  662. status = response.status_code
  663. if status == 403:
  664. _record_403_and_maybe_stop()
  665. else:
  666. _record_request_failure()
  667. rotated = self._rotate_proxy(f"goods_ids 批量RequestException-{status}")
  668. if rotated and status == 403:
  669. _reset_403_errors()
  670. else:
  671. _record_request_failure()
  672. except Exception:
  673. _record_request_failure()
  674. if retry < MAX_RETRIES - 1:
  675. time.sleep(RETRY_DELAY * (retry + 1))
  676. continue
  677. return self._fetch_goods_info_batch_fallback(normalized_ids)
  678. except json.JSONDecodeError as e:
  679. logger.error(f"批量请求响应JSON解析失败: {e}")
  680. _record_request_failure()
  681. return self._fetch_goods_info_batch_fallback(normalized_ids)
  682. except Exception as e:
  683. logger.error(f"处理批量请求时发生未知错误: {e}")
  684. _record_request_failure()
  685. return self._fetch_goods_info_batch_fallback(normalized_ids)
  686. return self._fetch_goods_info_batch_fallback(normalized_ids)
  687. def _extract_goods_info(self, api_data: Dict[str, Any], goods_id: str) -> Optional[Dict[str, Any]]:
  688. """从API响应中提取饰品属性信息"""
  689. try:
  690. data = api_data.get("data", {})
  691. goods_infos = data.get("goods_infos", {})
  692. # 获取指定goods_id的饰品信息
  693. goods_data = goods_infos.get(str(goods_id))
  694. if not goods_data:
  695. logger.warning(f"饰品 {goods_id} 未在响应中找到")
  696. return None
  697. attr_data = self._build_attr_data_from_goods_data(str(goods_id), goods_data)
  698. logger.debug(f"饰品 {goods_id} 属性提取成功")
  699. return attr_data
  700. except Exception as e:
  701. logger.error(f"提取饰品 {goods_id} 属性时出错: {e}")
  702. return None
  703. # ============================================================
  704. # 主逻辑
  705. # ============================================================
  706. class GoodsAttrSyncTask:
  707. """饰品属性同步任务"""
  708. def __init__(self):
  709. self.db = GoodsAttrDatabase()
  710. self.api = BuffGoodsAPI()
  711. # 每轮取「下一批」无属性饰品,全部取完后再从 0 开始,避免每轮都从同一批(如 40295)开始
  712. self._sync_offset = 0
  713. def fetch_and_save_goods_attrs(self, goods_ids: List[str]) -> Dict[str, int]:
  714. """获取并保存饰品属性信息(串行单请求,无固定间隔)。"""
  715. results = {'success': 0, 'failed': 0, 'total': len(goods_ids)}
  716. batch_attr_data: List[Dict[str, Any]] = []
  717. for idx, goods_id in enumerate(goods_ids, 1):
  718. gid = str(goods_id)
  719. logger.info("处理第 %s/%s 个饰品:%s", idx, len(goods_ids), gid)
  720. try:
  721. attr_data = self.api.fetch_goods_info(gid)
  722. except Exception as e:
  723. logger.error(f"获取饰品 {gid} 属性异常: {e}")
  724. attr_data = None
  725. if attr_data:
  726. batch_attr_data.append(attr_data)
  727. logger.info(f"获取饰品 {gid} 属性成功")
  728. else:
  729. results['failed'] += 1
  730. logger.warning(f"获取饰品 {gid} 属性失败")
  731. if batch_attr_data:
  732. logger.info(f"本批次共获取 {len(batch_attr_data)} 条属性,即将写入数据库 {DB_HOST}/{DB_NAME} 表 buff_goods_attr ...")
  733. saved_count = self.db.batch_insert_goods_attrs(batch_attr_data)
  734. results['success'] += saved_count
  735. if saved_count < len(batch_attr_data):
  736. results['failed'] += len(batch_attr_data) - saved_count
  737. return results
  738. def run_single_batch(self) -> bool:
  739. """执行单次批次同步任务"""
  740. start_time = datetime.now()
  741. logger.info("=" * 60)
  742. logger.info(f"开始执行饰品属性同步任务: {start_time}")
  743. logger.info("=" * 60)
  744. try:
  745. # 检查数据库连接
  746. if not self.db.check_connection():
  747. logger.error("数据库连接不可用,任务中止")
  748. return False
  749. # Step 1: 获取需要同步的饰品列表(本轮使用当前 offset,下一轮会用下一批)
  750. logger.info("[Step 1/3] 查询需要同步的饰品...")
  751. goods_list = self.db.get_goods_without_attr(BATCH_SIZE, self._sync_offset)
  752. if not goods_list:
  753. logger.info("没有需要同步的饰品(本轮回零 offset,下一轮从头开始)")
  754. self._sync_offset = 0
  755. return True
  756. logger.info(f"找到 {len(goods_list)} 个需要同步的饰品(offset=%s)", self._sync_offset)
  757. # 提取goods_id列表
  758. goods_ids = [goods['goods_id'] for goods in goods_list]
  759. # Step 2: 获取并保存饰品属性信息
  760. logger.info("[Step 2/3] 获取并保存饰品属性信息...")
  761. results = self.fetch_and_save_goods_attrs(goods_ids)
  762. # Step 3: 任务完成统计
  763. end_time = datetime.now()
  764. duration = (end_time - start_time).total_seconds()
  765. logger.info("=" * 60)
  766. logger.info(f"饰品属性同步任务完成!")
  767. logger.info(f"处理饰品总数: {results['total']}")
  768. logger.info(f"成功获取并保存: {results['success']}")
  769. logger.info(f"失败: {results['failed']}")
  770. logger.info(f"耗时: {duration:.2f}秒")
  771. logger.info("=" * 60)
  772. # 本批已处理,下一轮取下一批;若本批不足 BATCH_SIZE 说明已到末尾,下轮会得到 0 条并回零
  773. self._sync_offset += len(goods_list)
  774. return results['success'] > 0
  775. except Exception as e:
  776. logger.exception(f"同步任务失败: {e}")
  777. return False
  778. def run_continuous(self):
  779. """连续运行模式,每次执行后随机暂停"""
  780. execution_count = 0
  781. while True:
  782. execution_count += 1
  783. logger.info(f"\n{'='*60}")
  784. logger.info(f"开始第 {execution_count} 次执行")
  785. logger.info(f"{'='*60}")
  786. # 执行同步任务
  787. success = self.run_single_batch()
  788. if success:
  789. logger.info(f"第 {execution_count} 次执行成功")
  790. else:
  791. logger.warning(f"第 {execution_count} 次执行失败")
  792. # 直接进入下一轮,不再等待
  793. def cleanup(self):
  794. """清理资源"""
  795. try:
  796. # 关闭连接池
  797. DatabaseConnectionPool().close_pool()
  798. logger.info("资源清理完成")
  799. except:
  800. pass
  801. # ============================================================
  802. # 主函数
  803. # ============================================================
  804. def main():
  805. """主函数"""
  806. task = None
  807. try:
  808. logger.info("启动饰品属性同步脚本(连接池版本)...")
  809. logger.info(f"数据库写入目标: {DB_HOST}:{DB_PORT}/{DB_NAME} 表 buff_goods_attr(请确认您查询的是同一库)")
  810. logger.info(
  811. f"配置信息: 每次处理 {BATCH_SIZE} 个饰品, 串行单请求(无固定间隔), 并发数 {CONCURRENT_WORKERS}, 代理请求(403自动换IP), 连续运行不主动休息"
  812. )
  813. # 创建任务实例
  814. task = GoodsAttrSyncTask()
  815. # 运行连续模式
  816. task.run_continuous()
  817. except KeyboardInterrupt:
  818. logger.info("\n收到中断信号,正在退出...")
  819. if task:
  820. task.cleanup()
  821. sys.exit(0)
  822. except Exception as e:
  823. logger.exception(f"程序运行异常: {e}")
  824. if task:
  825. task.cleanup()
  826. sys.exit(1)
  827. if __name__ == "__main__":
  828. # 安装依赖提示
  829. try:
  830. import dbutils
  831. except ImportError:
  832. print("请先安装依赖: pip install DBUtils==3.0.2")
  833. sys.exit(1)
  834. main()