sync_goods_attr.py 41 KB

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