sync_goods_attr@20260309.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品属性信息获取脚本(使用连接池版本)
  5. 特点:
  6. 1. 使用DBUtils连接池管理数据库连接
  7. 2. 自动处理连接失效和重连
  8. 3. 更稳定的长连接管理
  9. 4. 避免频繁创建和关闭连接的开销
  10. 5. 直接请求接口,无代理
  11. 快速补齐缺失属性:可设置环境变量调大每轮处理量,例如:
  12. BATCH_SIZE=200 python sync_goods_attr.py
  13. (当前为串行模式:每次 1 个请求,间隔 5-6 秒随机;连续请求 1 分钟后休息 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. import concurrent.futures
  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. # 数据库配置
  64. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  65. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  66. DB_USER = os.getenv("DB_USER", "root")
  67. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  68. DB_NAME = os.getenv("DB_NAME", "buff")
  69. # 连接池配置
  70. DB_POOL_CONFIG = {
  71. 'creator': pymysql,
  72. 'host': DB_HOST,
  73. 'port': DB_PORT,
  74. 'user': DB_USER,
  75. 'password': DB_PASSWORD,
  76. 'database': DB_NAME,
  77. 'charset': 'utf8mb4',
  78. 'cursorclass': pymysql.cursors.DictCursor,
  79. 'autocommit': True, # 自动提交事务
  80. 'maxconnections': 10, # 最大连接数
  81. 'mincached': 3, # 初始空闲连接数
  82. 'maxcached': 5, # 最大空闲连接数
  83. 'maxusage': 1000, # 单个连接最大使用次数
  84. 'ping': 7, # 使用ping检查连接:0=从不, 1=创建时, 2=获取时, 4=归还时, 7=总是
  85. 'blocking': True, # 连接池满时是否等待
  86. 'setsession': [], # 可选的会话命令列表
  87. }
  88. # 请求配置(可通过环境变量 BATCH_SIZE 调大每轮处理量)
  89. REQUEST_TIMEOUT = 30
  90. REQUEST_INTERVAL_MIN = 5 # 请求间隔下限(秒)
  91. REQUEST_INTERVAL_MAX = 6 # 请求间隔上限(秒),每次在 [MIN, MAX] 间随机
  92. WORK_DURATION_SEC = 60 # 连续请求时长(秒),满后进入休息
  93. REST_DURATION_SEC = 60 # 休息时长(秒),降低被 BUFF 检测限流风险
  94. CONCURRENT_WORKERS = int(os.getenv("CONCURRENT_WORKERS", "1")) # 并发请求数(1=串行,间隔5-6秒随机)
  95. MAX_RETRIES = 3
  96. RETRY_DELAY = 2
  97. BATCH_SIZE = int(os.getenv("BATCH_SIZE", "30")) # 每轮拉取的饰品数量,每批提交一次,调小便于数据及时入库
  98. FAILURE_LONG_REST_THRESHOLD = int(os.getenv("FAILURE_LONG_REST_THRESHOLD", "5")) # 连续请求失败达到阈值后触发长休息
  99. FAILURE_LONG_REST_MIN_SEC = int(os.getenv("FAILURE_LONG_REST_MIN_SEC", "300")) # 失败触发长休息最短秒数(默认 5 分钟)
  100. FAILURE_LONG_REST_MAX_SEC = int(os.getenv("FAILURE_LONG_REST_MAX_SEC", "600")) # 失败触发长休息最长秒数(默认 10 分钟)
  101. SUCCESS_CONTINUOUS_SAVE_THRESHOLD = int(os.getenv("SUCCESS_CONTINUOUS_SAVE_THRESHOLD", "100")) # 连续成功入库数量阈值
  102. SUCCESS_REST_SEC = int(os.getenv("SUCCESS_REST_SEC", "180")) # 连续成功入库达到阈值后的固定休息秒数(默认 3 分钟)
  103. CONTINUOUS_RUN_LIMIT_SEC = int(os.getenv("CONTINUOUS_RUN_LIMIT_SEC", "7200")) # 连续运行时长阈值(秒),默认 2 小时
  104. CONTINUOUS_RUN_REST_SEC = int(os.getenv("CONTINUOUS_RUN_REST_SEC", "600")) # 连续运行达到阈值后的休息秒数(默认 10 分钟)
  105. FORBIDDEN_STOP_THRESHOLD = int(os.getenv("FORBIDDEN_STOP_THRESHOLD", "3")) # 连续检测到 403 后直接停止脚本的阈值
  106. # ============================================================
  107. # 配置日志
  108. # ============================================================
  109. def setup_logging():
  110. """配置日志系统"""
  111. log_dir = os.path.dirname(os.path.abspath(__file__))
  112. log_file = os.path.join(log_dir, "logs", f"sync_goods_attr_{datetime.now().strftime('%Y%m%d')}.log")
  113. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  114. logging.basicConfig(
  115. level=logging.INFO,
  116. format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s',
  117. handlers=[
  118. logging.StreamHandler(sys.stdout),
  119. logging.FileHandler(log_file, encoding='utf-8')
  120. ]
  121. )
  122. return logging.getLogger(__name__)
  123. logger = setup_logging()
  124. # 请求频率限制:每次请求间隔在 [REQUEST_INTERVAL_MIN, REQUEST_INTERVAL_MAX] 秒随机(多线程安全)
  125. _request_lock = threading.Lock()
  126. _last_request_time = [0.0] # 使用列表以便在闭包中修改
  127. _work_window_start = [0.0] # 当前「连续请求」窗口开始时间,满 WORK_DURATION_SEC 后休息 REST_DURATION_SEC
  128. _consecutive_request_failures = [0] # 连续请求失败计数(网络错误 / API 返回错误)
  129. _consecutive_403_errors = [0] # 连续 403 错误计数
  130. def _record_request_failure():
  131. """
  132. 记录一次请求失败(网络错误、状态码/业务code异常等),
  133. 当连续失败次数达到阈值时随机长休息 5-10 分钟以降低被封 IP 风险。
  134. """
  135. with _request_lock:
  136. _consecutive_request_failures[0] += 1
  137. current_failures = _consecutive_request_failures[0]
  138. if current_failures >= FAILURE_LONG_REST_THRESHOLD:
  139. rest_sec = random.randint(FAILURE_LONG_REST_MIN_SEC, FAILURE_LONG_REST_MAX_SEC)
  140. logger.warning(
  141. "请求连续失败 %s 次,触发防封长休息,脚本将休息 %s 秒后再继续请求...",
  142. current_failures,
  143. rest_sec,
  144. )
  145. time.sleep(rest_sec)
  146. with _request_lock:
  147. _consecutive_request_failures[0] = 0
  148. def _reset_request_failures():
  149. """请求成功(包括正常返回但未找到该饰品)时重置连续失败计数。"""
  150. with _request_lock:
  151. if _consecutive_request_failures[0] != 0:
  152. _consecutive_request_failures[0] = 0
  153. def _record_403_and_maybe_stop():
  154. """
  155. 记录一次 403 错误。
  156. 当连续 403 次数达到 FORBIDDEN_STOP_THRESHOLD 时,认为当前 IP / 行为已被 BUFF 强风控,
  157. 直接终止脚本运行,避免继续打压力。
  158. """
  159. with _request_lock:
  160. _consecutive_403_errors[0] += 1
  161. current_403 = _consecutive_403_errors[0]
  162. logger.error("检测到 403 Forbidden 错误(连续第 %s 次)", current_403)
  163. if current_403 >= FORBIDDEN_STOP_THRESHOLD:
  164. logger.critical(
  165. "连续检测到 %s 次 403 Forbidden,可能已被 BUFF 风控,脚本将自动退出,请人工检查后再重启。",
  166. current_403,
  167. )
  168. # 直接退出整个脚本进程
  169. sys.exit(1)
  170. def _reset_403_errors():
  171. """请求成功(含正常业务失败)时重置 403 计数。"""
  172. with _request_lock:
  173. if _consecutive_403_errors[0] != 0:
  174. _consecutive_403_errors[0] = 0
  175. # ============================================================
  176. # 数据库连接池操作类
  177. # ============================================================
  178. class DatabaseConnectionPool:
  179. """数据库连接池管理类"""
  180. _instance = None
  181. _pool = None
  182. def __new__(cls):
  183. if cls._instance is None:
  184. cls._instance = super().__new__(cls)
  185. cls._instance._init_pool()
  186. return cls._instance
  187. def _init_pool(self):
  188. """初始化连接池"""
  189. try:
  190. self._pool = PooledDB(**DB_POOL_CONFIG)
  191. logger.info(f"数据库连接池初始化成功: {DB_HOST}:{DB_PORT}/{DB_NAME}")
  192. logger.info(f"连接池配置: maxconnections={DB_POOL_CONFIG['maxconnections']}, mincached={DB_POOL_CONFIG['mincached']}")
  193. except Exception as e:
  194. logger.error(f"数据库连接池初始化失败: {e}")
  195. raise
  196. def get_connection(self):
  197. """从连接池获取连接"""
  198. try:
  199. conn = self._pool.connection()
  200. return conn
  201. except Exception as e:
  202. logger.error(f"从连接池获取连接失败: {e}")
  203. # 尝试重新初始化连接池
  204. time.sleep(2)
  205. self._init_pool()
  206. return self._pool.connection()
  207. def close_pool(self):
  208. """关闭连接池"""
  209. if self._pool:
  210. self._pool.close()
  211. logger.info("数据库连接池已关闭")
  212. class GoodsAttrDatabase:
  213. """饰品属性数据库操作类(使用连接池)"""
  214. def __init__(self):
  215. self.pool = DatabaseConnectionPool()
  216. def execute_query(self, sql: str, params=None, fetch_all=True):
  217. """执行查询"""
  218. conn = None
  219. cursor = None
  220. try:
  221. conn = self.pool.get_connection()
  222. cursor = conn.cursor()
  223. if params:
  224. cursor.execute(sql, params)
  225. else:
  226. cursor.execute(sql)
  227. if fetch_all:
  228. return cursor.fetchall()
  229. else:
  230. return cursor.fetchone()
  231. except pymysql.Error as e:
  232. logger.error(f"数据库查询失败: {e}, SQL: {sql}")
  233. raise
  234. finally:
  235. if cursor:
  236. cursor.close()
  237. # 注意:这里不需要关闭conn,连接池会自动管理
  238. def execute_update(self, sql: str, params=None, many=False):
  239. """执行更新操作"""
  240. conn = None
  241. cursor = None
  242. try:
  243. conn = self.pool.get_connection()
  244. cursor = conn.cursor()
  245. if many and params:
  246. cursor.executemany(sql, params)
  247. elif params:
  248. cursor.execute(sql, params)
  249. else:
  250. cursor.execute(sql)
  251. affected_rows = cursor.rowcount
  252. return affected_rows
  253. except pymysql.Error as e:
  254. logger.error(f"数据库更新失败: {e}, SQL: {sql}")
  255. raise
  256. finally:
  257. if cursor:
  258. cursor.close()
  259. def execute_transaction(self, operations: List[Tuple[str, tuple]]):
  260. """执行事务(一组操作要么全部成功,要么全部回滚)"""
  261. conn = None
  262. cursor = None
  263. try:
  264. conn = self.pool.get_connection()
  265. cursor = conn.cursor()
  266. # 开始事务
  267. conn.begin()
  268. for sql, params in operations:
  269. if params:
  270. cursor.execute(sql, params)
  271. else:
  272. cursor.execute(sql)
  273. # 提交事务
  274. conn.commit()
  275. return True
  276. except Exception as e:
  277. logger.error(f"事务执行失败: {e}")
  278. if conn:
  279. try:
  280. conn.rollback()
  281. except:
  282. pass
  283. return False
  284. finally:
  285. if cursor:
  286. cursor.close()
  287. def get_goods_without_attr(self, limit: int = BATCH_SIZE, offset: int = 0) -> List[Dict[str, Any]]:
  288. """
  289. 获取没有属性信息的饰品列表(支持分页,便于每轮拉取不同批次)
  290. 从 buff_goods_info 表中找出在 buff_goods_attr 表中不存在的饰品。
  291. offset 用于轮次推进:每轮取下一批,全部取完后从 0 再开始。
  292. """
  293. try:
  294. sql = """
  295. SELECT
  296. g.goods_id,
  297. g.name,
  298. g.buff_url,
  299. g.stat_time
  300. FROM buff_goods_info g
  301. LEFT JOIN buff_goods_attr a ON g.goods_id = a.goods_id
  302. WHERE a.goods_id IS NULL
  303. ORDER BY g.goods_id ASC
  304. LIMIT %s OFFSET %s
  305. """
  306. results = self.execute_query(sql, (limit, offset))
  307. goods_list = []
  308. for row in results:
  309. goods_list.append({
  310. 'goods_id': row['goods_id'],
  311. 'name': row['name'],
  312. 'buff_url': row['buff_url'],
  313. 'stat_time': row['stat_time']
  314. })
  315. logger.info(f"找到 {len(goods_list)} 个没有属性信息的饰品")
  316. return goods_list
  317. except Exception as e:
  318. logger.error(f"查询没有属性信息的饰品失败: {e}")
  319. return []
  320. def _attr_row_for_insert(self, attr_data: Dict[str, Any]) -> tuple:
  321. """按表列顺序生成一条 INSERT 参数元组,保证每项均为标量(30 个占位符对应 30 列)"""
  322. row = []
  323. for k in INSERT_ATTR_COLUMNS:
  324. v = attr_data.get(k) if k != 'stat_time' else attr_data.get('stat_time', datetime.now())
  325. row.append(_scalar_for_db(v))
  326. return tuple(row)
  327. def batch_insert_goods_attrs(self, attr_data_list: List[Dict[str, Any]]) -> int:
  328. """批量插入饰品属性信息:先全部 DELETE,再 executemany INSERT,避免占位符与参数数量错位"""
  329. if not attr_data_list:
  330. return 0
  331. delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
  332. rows = [self._attr_row_for_insert(attr_data) for attr_data in attr_data_list]
  333. # 每行必须是 30 个标量,与 INSERT_ATTR_SQL 中 30 个 %s 一致
  334. assert len(INSERT_ATTR_COLUMNS) == 30, "INSERT_ATTR_COLUMNS 须为 30 项"
  335. try:
  336. conn = None
  337. cursor = None
  338. try:
  339. conn = self.pool.get_connection()
  340. cursor = conn.cursor()
  341. conn.begin()
  342. # 先删除
  343. for attr_data in attr_data_list:
  344. gid = _scalar_for_db(attr_data['goods_id'])
  345. cursor.execute(delete_sql, (gid,))
  346. # 再批量插入
  347. cursor.executemany(INSERT_ATTR_SQL, rows)
  348. conn.commit()
  349. logger.info(f"成功批量插入 {len(attr_data_list)} 个饰品的属性信息")
  350. return len(attr_data_list)
  351. except Exception as e:
  352. logger.exception("事务执行失败")
  353. if conn:
  354. try:
  355. conn.rollback()
  356. except Exception:
  357. pass
  358. return 0
  359. finally:
  360. if cursor:
  361. cursor.close()
  362. except Exception as e:
  363. logger.error(f"批量插入饰品属性信息异常: {e}")
  364. return 0
  365. def insert_goods_attr(self, attr_data: Dict[str, Any]) -> bool:
  366. """插入单个饰品属性信息"""
  367. try:
  368. delete_sql = "DELETE FROM buff_goods_attr WHERE goods_id = %s"
  369. row = self._attr_row_for_insert(attr_data)
  370. operations = [
  371. (delete_sql, (_scalar_for_db(attr_data['goods_id']),)),
  372. (INSERT_ATTR_SQL, row),
  373. ]
  374. success = self.execute_transaction(operations)
  375. if success:
  376. logger.debug(f"成功插入饰品 {attr_data['goods_id']} 的属性信息")
  377. else:
  378. logger.error(f"插入饰品 {attr_data['goods_id']} 属性信息失败")
  379. return success
  380. except Exception as e:
  381. logger.error(f"插入饰品 {attr_data.get('goods_id', '未知')} 属性信息异常: {e}")
  382. return False
  383. def check_connection(self):
  384. """检查连接是否正常"""
  385. try:
  386. self.execute_query("SELECT 1")
  387. return True
  388. except Exception as e:
  389. logger.error(f"数据库连接检查失败: {e}")
  390. return False
  391. # ============================================================
  392. # BUFF API 操作类
  393. # ============================================================
  394. class BuffGoodsAPI:
  395. """BUFF饰品API操作类(直连请求)"""
  396. def __init__(self):
  397. self.session = requests.Session()
  398. self.session.headers.update({
  399. "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",
  400. "Accept": "application/json, text/plain, */*",
  401. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  402. "Accept-Encoding": "gzip, deflate, br",
  403. "Connection": "keep-alive",
  404. "Referer": "https://buff.163.com/",
  405. "Sec-Fetch-Dest": "empty",
  406. "Sec-Fetch-Mode": "cors",
  407. "Sec-Fetch-Site": "same-origin"
  408. })
  409. def fetch_goods_info(self, goods_id: str) -> Optional[Dict[str, Any]]:
  410. """获取单个饰品的详细信息"""
  411. url = f"{BUFF_API_BASE_URL}?game={GAME}&goods_id={goods_id}"
  412. for retry in range(MAX_RETRIES):
  413. try:
  414. logger.debug(f"请求饰品 {goods_id} (第{retry + 1}次尝试)...")
  415. # 每次间隔 5-6 秒随机,多线程下串行控制;满 1 分钟请求后休息 1 分钟
  416. with _request_lock:
  417. now = time.time()
  418. if _work_window_start[0] <= 0:
  419. _work_window_start[0] = now
  420. work_elapsed = now - _work_window_start[0]
  421. if work_elapsed >= WORK_DURATION_SEC:
  422. logger.info(f"已连续请求满 {WORK_DURATION_SEC} 秒,休息 {REST_DURATION_SEC} 秒后再继续...")
  423. time.sleep(REST_DURATION_SEC)
  424. _work_window_start[0] = time.time()
  425. elapsed = time.time() - _last_request_time[0]
  426. interval = random.uniform(REQUEST_INTERVAL_MIN, REQUEST_INTERVAL_MAX)
  427. if elapsed < interval:
  428. time.sleep(interval - elapsed)
  429. _last_request_time[0] = time.time()
  430. # 使用 requests.get 避免多线程共享 Session,保证并发安全,直连请求
  431. response = requests.get(
  432. url,
  433. headers=self.session.headers,
  434. timeout=REQUEST_TIMEOUT,
  435. )
  436. response.raise_for_status()
  437. data = response.json()
  438. # 检查API响应状态
  439. if data.get("code") != "OK":
  440. logger.warning(f"饰品 {goods_id} API返回错误: {data.get('msg', '未知错误')}")
  441. _record_request_failure()
  442. return None
  443. # 提取饰品信息
  444. goods_info = self._extract_goods_info(data, goods_id)
  445. if goods_info:
  446. _reset_request_failures()
  447. _reset_403_errors()
  448. logger.info(f"成功获取饰品 {goods_id} 的属性信息")
  449. return goods_info
  450. else:
  451. # 请求成功但未找到该饰品,视为正常请求,重置失败计数
  452. _reset_request_failures()
  453. _reset_403_errors()
  454. logger.warning(f"饰品 {goods_id} 未找到属性信息")
  455. return None
  456. except requests.exceptions.RequestException as e:
  457. logger.error(f"请求饰品 {goods_id} 失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  458. # 如果是 HTTP 403,记录连续 403 并在达到阈值时直接终止脚本
  459. try:
  460. response = getattr(e, "response", None)
  461. if response is not None and response.status_code == 403:
  462. _record_403_and_maybe_stop()
  463. else:
  464. _record_request_failure()
  465. except Exception:
  466. _record_request_failure()
  467. if retry < MAX_RETRIES - 1:
  468. time.sleep(RETRY_DELAY * (retry + 1))
  469. else:
  470. logger.error(f"饰品 {goods_id} 请求失败,已重试{MAX_RETRIES}次")
  471. return None
  472. except json.JSONDecodeError as e:
  473. logger.error(f"饰品 {goods_id} 响应JSON解析失败: {e}")
  474. _record_request_failure()
  475. return None
  476. except Exception as e:
  477. logger.error(f"处理饰品 {goods_id} 时发生未知错误: {e}")
  478. _record_request_failure()
  479. return None
  480. return None
  481. def _extract_goods_info(self, api_data: Dict[str, Any], goods_id: str) -> Optional[Dict[str, Any]]:
  482. """从API响应中提取饰品属性信息"""
  483. try:
  484. data = api_data.get("data", {})
  485. goods_infos = data.get("goods_infos", {})
  486. # 获取指定goods_id的饰品信息
  487. goods_data = goods_infos.get(str(goods_id))
  488. if not goods_data:
  489. logger.warning(f"饰品 {goods_id} 未在响应中找到")
  490. return None
  491. # 提取tags信息
  492. tags = goods_data.get("tags", {})
  493. # 构建属性字典
  494. attr_data = {
  495. 'goods_id': goods_id,
  496. 'name': goods_data.get("name", ""),
  497. 'icon_url': goods_data.get("icon_url"),
  498. 'stat_time': datetime.now(),
  499. 'create_time': datetime.now()
  500. }
  501. # 映射tags到对应字段
  502. tag_mapping = {
  503. 'type': ('type', 'type_name'),
  504. 'rarity': ('rarity', 'rarity_name'),
  505. 'category': ('category', 'category_name'),
  506. 'category_group': ('category_group', 'category_group_name'),
  507. 'quality': ('quality', 'quality_name'),
  508. 'exterior': ('exterior', 'exterior_name'),
  509. 'weapon': ('weapon', 'weapon_name'),
  510. 'series': ('series', 'series_name'),
  511. 'custom': ('custom', 'custom_name'),
  512. 'itemset': ('itemset', 'itemset_name')
  513. }
  514. # 处理每个tag
  515. for tag_key, (field_key, field_name_key) in tag_mapping.items():
  516. if tag_key in tags:
  517. tag_data = tags[tag_key]
  518. attr_data[field_key] = tag_data.get("internal_name")
  519. attr_data[field_name_key] = tag_data.get("localized_name")
  520. logger.debug(f"饰品 {goods_id} 属性提取成功")
  521. return attr_data
  522. except Exception as e:
  523. logger.error(f"提取饰品 {goods_id} 属性时出错: {e}")
  524. return None
  525. # ============================================================
  526. # 主逻辑
  527. # ============================================================
  528. class GoodsAttrSyncTask:
  529. """饰品属性同步任务"""
  530. def __init__(self):
  531. self.db = GoodsAttrDatabase()
  532. self.api = BuffGoodsAPI()
  533. # 每轮取「下一批」无属性饰品,全部取完后再从 0 开始,避免每轮都从同一批(如 40295)开始
  534. self._sync_offset = 0
  535. # 连续成功写入的属性总数,用于达到一定数量后让脚本主动休息一段时间
  536. self._continuous_saved_success = 0
  537. # 连续运行起始时间,用于控制“连续跑 2 小时休息 10 分钟”
  538. self._continuous_run_start = time.time()
  539. def fetch_and_save_goods_attrs(self, goods_ids: List[str]) -> Dict[str, int]:
  540. """获取并保存饰品属性信息(每次最多 CONCURRENT_WORKERS 个并发请求)"""
  541. results = {'success': 0, 'failed': 0, 'total': len(goods_ids)}
  542. batch_attr_data: List[Dict[str, Any]] = []
  543. def fetch_one(goods_id: str) -> Optional[Dict[str, Any]]:
  544. try:
  545. return self.api.fetch_goods_info(goods_id)
  546. except Exception as e:
  547. logger.error(f"获取饰品 {goods_id} 属性异常: {e}")
  548. return None
  549. with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_WORKERS) as executor:
  550. future_to_id = {executor.submit(fetch_one, gid): gid for gid in goods_ids}
  551. for future in concurrent.futures.as_completed(future_to_id):
  552. goods_id = future_to_id[future]
  553. try:
  554. attr_data = future.result()
  555. if attr_data:
  556. batch_attr_data.append(attr_data)
  557. logger.info(f"获取饰品 {goods_id} 属性成功")
  558. else:
  559. results['failed'] += 1
  560. logger.warning(f"获取饰品 {goods_id} 属性失败")
  561. except Exception as e:
  562. results['failed'] += 1
  563. logger.error(f"获取饰品 {goods_id} 异常: {e}")
  564. if batch_attr_data:
  565. logger.info(f"本批次共获取 {len(batch_attr_data)} 条属性,即将写入数据库 {DB_HOST}/{DB_NAME} 表 buff_goods_attr ...")
  566. saved_count = self.db.batch_insert_goods_attrs(batch_attr_data)
  567. results['success'] += saved_count
  568. if saved_count < len(batch_attr_data):
  569. results['failed'] += len(batch_attr_data) - saved_count
  570. return results
  571. def run_single_batch(self) -> bool:
  572. """执行单次批次同步任务"""
  573. start_time = datetime.now()
  574. logger.info("=" * 60)
  575. logger.info(f"开始执行饰品属性同步任务: {start_time}")
  576. logger.info("=" * 60)
  577. try:
  578. # 检查数据库连接
  579. if not self.db.check_connection():
  580. logger.error("数据库连接不可用,任务中止")
  581. return False
  582. # Step 1: 获取需要同步的饰品列表(本轮使用当前 offset,下一轮会用下一批)
  583. logger.info("[Step 1/3] 查询需要同步的饰品...")
  584. goods_list = self.db.get_goods_without_attr(BATCH_SIZE, self._sync_offset)
  585. if not goods_list:
  586. logger.info("没有需要同步的饰品(本轮回零 offset,下一轮从头开始)")
  587. self._sync_offset = 0
  588. return True
  589. logger.info(f"找到 {len(goods_list)} 个需要同步的饰品(offset=%s)", self._sync_offset)
  590. # 提取goods_id列表
  591. goods_ids = [goods['goods_id'] for goods in goods_list]
  592. # Step 2: 获取并保存饰品属性信息
  593. logger.info("[Step 2/3] 获取并保存饰品属性信息...")
  594. results = self.fetch_and_save_goods_attrs(goods_ids)
  595. # Step 3: 任务完成统计
  596. end_time = datetime.now()
  597. duration = (end_time - start_time).total_seconds()
  598. logger.info("=" * 60)
  599. logger.info(f"饰品属性同步任务完成!")
  600. logger.info(f"处理饰品总数: {results['total']}")
  601. logger.info(f"成功获取并保存: {results['success']}")
  602. logger.info(f"失败: {results['failed']}")
  603. logger.info(f"耗时: {duration:.2f}秒")
  604. logger.info("=" * 60)
  605. # 连续成功入库计数:本批有成功则累加,否则清零
  606. if results['success'] > 0:
  607. self._continuous_saved_success += results['success']
  608. else:
  609. self._continuous_saved_success = 0
  610. # 当连续成功入库数量达到阈值时,脚本固定休息一段时间(默认 3 分钟),然后重置计数
  611. if self._continuous_saved_success >= SUCCESS_CONTINUOUS_SAVE_THRESHOLD:
  612. logger.info(
  613. "连续成功入库 %s 条属性,脚本将休息 %s 秒以降低对 BUFF 的压力...",
  614. self._continuous_saved_success,
  615. SUCCESS_REST_SEC,
  616. )
  617. time.sleep(SUCCESS_REST_SEC)
  618. self._continuous_saved_success = 0
  619. # 本批已处理,下一轮取下一批;若本批不足 BATCH_SIZE 说明已到末尾,下轮会得到 0 条并回零
  620. self._sync_offset += len(goods_list)
  621. return results['success'] > 0
  622. except Exception as e:
  623. logger.exception(f"同步任务失败: {e}")
  624. return False
  625. def run_continuous(self):
  626. """连续运行模式,每次执行后随机暂停"""
  627. execution_count = 0
  628. while True:
  629. # 检查是否已经连续运行超过设定的时长,如果是则休息一段时间再继续
  630. elapsed_run = time.time() - self._continuous_run_start
  631. if elapsed_run >= CONTINUOUS_RUN_LIMIT_SEC:
  632. logger.info(
  633. "脚本已连续运行约 %.1f 分钟,达到阈值,将休息 %s 秒以降低风险...",
  634. elapsed_run / 60.0,
  635. CONTINUOUS_RUN_REST_SEC,
  636. )
  637. time.sleep(CONTINUOUS_RUN_REST_SEC)
  638. # 休息完重新计时
  639. self._continuous_run_start = time.time()
  640. execution_count += 1
  641. logger.info(f"\n{'='*60}")
  642. logger.info(f"开始第 {execution_count} 次执行")
  643. logger.info(f"{'='*60}")
  644. # 执行同步任务
  645. success = self.run_single_batch()
  646. if success:
  647. logger.info(f"第 {execution_count} 次执行成功")
  648. else:
  649. logger.warning(f"第 {execution_count} 次执行失败")
  650. # 直接进入下一轮,不再等待
  651. def cleanup(self):
  652. """清理资源"""
  653. try:
  654. # 关闭连接池
  655. DatabaseConnectionPool().close_pool()
  656. logger.info("资源清理完成")
  657. except:
  658. pass
  659. # ============================================================
  660. # 主函数
  661. # ============================================================
  662. def main():
  663. """主函数"""
  664. task = None
  665. try:
  666. logger.info("启动饰品属性同步脚本(连接池版本)...")
  667. logger.info(f"数据库写入目标: {DB_HOST}:{DB_PORT}/{DB_NAME} 表 buff_goods_attr(请确认您查询的是同一库)")
  668. logger.info(f"配置信息: 每次处理 {BATCH_SIZE} 个饰品, 串行请求间隔 {REQUEST_INTERVAL_MIN}-{REQUEST_INTERVAL_MAX} 秒, 请求满 {WORK_DURATION_SEC} 秒休息 {REST_DURATION_SEC} 秒, 直连请求")
  669. # 创建任务实例
  670. task = GoodsAttrSyncTask()
  671. # 运行连续模式
  672. task.run_continuous()
  673. except KeyboardInterrupt:
  674. logger.info("\n收到中断信号,正在退出...")
  675. if task:
  676. task.cleanup()
  677. sys.exit(0)
  678. except Exception as e:
  679. logger.exception(f"程序运行异常: {e}")
  680. if task:
  681. task.cleanup()
  682. sys.exit(1)
  683. if __name__ == "__main__":
  684. # 安装依赖提示
  685. try:
  686. import dbutils
  687. except ImportError:
  688. print("请先安装依赖: pip install DBUtils==3.0.2")
  689. sys.exit(1)
  690. main()