sync_goods.py.bak_20260408_183731 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. BUFF饰品价格数据同步脚本(修正清空表顺序)
  5. 功能:
  6. 1. 请求buff接口获取饰品价格数据(最低售价、最高求购价)
  7. 2. 正确处理steam_url生成
  8. 3. 使用高性能批量插入技术处理数据
  9. 4. 维护近60天的价格数据
  10. 5. 批量计算近7天和30天的平均价格
  11. 6. 在插入数据前先清空目标表
  12. 使用方法:
  13. python sync_goods.py # 执行一次同步(周期调度请使用 run_buff_sync_loop.sh)
  14. 作者: 游铛铛技术数字员工
  15. 创建时间: 2026-01-28
  16. """
  17. import os
  18. import sys
  19. import time
  20. import logging
  21. import json
  22. import requests
  23. import pymysql
  24. from sync_db_errors import is_database_connection_error
  25. from datetime import datetime, timedelta
  26. from typing import List, Dict, Any, Optional, Tuple
  27. import urllib.parse
  28. # ============================================================
  29. # 配置信息
  30. # ============================================================
  31. # BUFF API配置
  32. BUFF_API_URL = "https://buff.163.com/api/market/enterprise/items"
  33. BUFF_API_TOKEN = "c4a1ebaf64d64d1a9a320bc2fcfa1f7e"
  34. # 数据库配置(从环境变量读取)
  35. DB_HOST = os.getenv("DB_HOST", "47.99.164.66")
  36. DB_PORT = int(os.getenv("DB_PORT", "3306"))
  37. DB_USER = os.getenv("DB_USER", "root")
  38. DB_PASSWORD = os.getenv("DB_PASSWORD", "hn02le.34lkdLKD")
  39. DB_NAME = os.getenv("DB_NAME", "buff")
  40. # 大批量 INSERT/SELECT 可能远超 30s;过短会触发 2013 Lost connection(客户端读超时)
  41. DB_READ_TIMEOUT = int(os.getenv("DB_READ_TIMEOUT", "1800"))
  42. DB_WRITE_TIMEOUT = int(os.getenv("DB_WRITE_TIMEOUT", "1800"))
  43. DB_BULK_INSERT_RETRIES = int(os.getenv("DB_BULK_INSERT_RETRIES", "3"))
  44. # Steam社区市场基础URL
  45. STEAM_MARKET_BASE_URL = "https://steamcommunity.com/market/listings/730/"
  46. # 请求配置
  47. REQUEST_TIMEOUT = 60
  48. BATCH_SIZE = int(os.getenv("BULK_INSERT_BATCH_SIZE", "5000"))
  49. MAX_RETRIES = 3
  50. RETRY_DELAY = 5
  51. # 数据保留天数
  52. PRICE_RETENTION_DAYS = 60
  53. # 平均价格计算批次大小
  54. AVG_PRICE_BATCH_SIZE = 10000 # 增加批次大小
  55. # ============================================================
  56. # 配置日志
  57. # ============================================================
  58. def setup_logging():
  59. log_dir = os.path.dirname(os.path.abspath(__file__))
  60. log_file = os.path.join(log_dir, "logs", f"sync_price_{datetime.now().strftime('%Y%m%d')}.log")
  61. os.makedirs(os.path.dirname(log_file), exist_ok=True)
  62. logging.basicConfig(
  63. level=logging.INFO,
  64. format='%(asctime)s - %(levelname)s - %(message)s',
  65. handlers=[
  66. logging.StreamHandler(sys.stdout),
  67. logging.FileHandler(log_file, encoding='utf-8')
  68. ]
  69. )
  70. return logging.getLogger(__name__)
  71. logger = setup_logging()
  72. # ============================================================
  73. # 数据库操作类(修正清空表逻辑)
  74. # ============================================================
  75. class PriceDatabase:
  76. """价格数据库操作类(修正清空表逻辑)"""
  77. def __init__(self):
  78. self.connection = None
  79. self._connect()
  80. def _connect(self):
  81. """建立数据库连接,优化连接参数"""
  82. try:
  83. self.connection = pymysql.connect(
  84. host=DB_HOST,
  85. port=DB_PORT,
  86. user=DB_USER,
  87. password=DB_PASSWORD,
  88. database=DB_NAME,
  89. charset='utf8mb4',
  90. cursorclass=pymysql.cursors.DictCursor,
  91. autocommit=False,
  92. connect_timeout=10,
  93. read_timeout=DB_READ_TIMEOUT,
  94. write_timeout=DB_WRITE_TIMEOUT,
  95. )
  96. logger.info(
  97. f"数据库连接成功: {DB_HOST}:{DB_PORT}/{DB_NAME} "
  98. f"(read_timeout={DB_READ_TIMEOUT}s, write_timeout={DB_WRITE_TIMEOUT}s)"
  99. )
  100. except Exception as e:
  101. logger.error(f"数据库连接失败: {e}")
  102. raise
  103. def _reconnect(self):
  104. try:
  105. if self.connection:
  106. self.connection.close()
  107. except Exception:
  108. pass
  109. self.connection = None
  110. self._connect()
  111. @staticmethod
  112. def _is_transient_db_error(exc: BaseException) -> bool:
  113. if not isinstance(exc, pymysql.err.OperationalError):
  114. return False
  115. code = exc.args[0] if exc.args else 0
  116. return code in (2006, 2013, 2014, 1053, 2003)
  117. def close(self):
  118. if self.connection and self.connection.open:
  119. self.connection.close()
  120. logger.info("数据库连接已关闭")
  121. def _safe_rollback(self) -> None:
  122. """连接已断开时 rollback 会抛 InterfaceError,避免二次异常掩盖原始错误。"""
  123. try:
  124. if self.connection and getattr(self.connection, "open", False):
  125. self.connection.rollback()
  126. except Exception as ex:
  127. logger.warning(f"回滚跳过(连接可能已断开): {ex}")
  128. def clear_table(self, table_name: str):
  129. """清空表(使用TRUNCATE优化)"""
  130. try:
  131. with self.connection.cursor() as cursor:
  132. # 使用TRUNCATE比DELETE更快
  133. cursor.execute(f"TRUNCATE TABLE {table_name}")
  134. self.connection.commit()
  135. logger.info(f"已清空 {table_name} 表")
  136. except Exception as e:
  137. # 如果TRUNCATE失败,回退到DELETE
  138. logger.warning(f"TRUNCATE失败,使用DELETE: {e}")
  139. try:
  140. with self.connection.cursor() as cursor:
  141. cursor.execute(f"DELETE FROM {table_name}")
  142. self.connection.commit()
  143. logger.info(f"已清空 {table_name} 表")
  144. except Exception as e2:
  145. logger.error(f"清空表失败: {e2}")
  146. raise
  147. def _bulk_insert_one_batch(
  148. self,
  149. cursor,
  150. sql: str,
  151. batch: List[Dict[str, Any]],
  152. columns: List[str],
  153. ) -> None:
  154. values = []
  155. for item in batch:
  156. row_values = []
  157. for col in columns:
  158. value = item.get(col)
  159. if value is None:
  160. row_values.append(None)
  161. elif isinstance(value, datetime):
  162. row_values.append(value.strftime('%Y-%m-%d %H:%M:%S'))
  163. elif isinstance(value, (int, float)):
  164. row_values.append(value)
  165. else:
  166. row_values.append(str(value))
  167. values.append(tuple(row_values))
  168. cursor.executemany(sql, values)
  169. def _safe_restore_session_defaults(self, cursor) -> None:
  170. if not self.connection:
  171. return
  172. try:
  173. if getattr(self.connection, "open", False):
  174. cursor.execute("SET SESSION autocommit = 1")
  175. cursor.execute("SET SESSION unique_checks = 1")
  176. cursor.execute("SET SESSION foreign_key_checks = 1")
  177. except Exception as e:
  178. logger.warning(f"恢复会话设置失败(连接可能已断开),可忽略: {e}")
  179. def bulk_insert(self, table_name: str, data_list: List[Dict[str, Any]],
  180. columns: List[str]) -> int:
  181. """高性能批量插入;断连类错误自动重连并重试当前批。"""
  182. if not data_list:
  183. return 0
  184. column_list = ', '.join([f'`{col}`' for col in columns])
  185. placeholders = ', '.join(['%s'] * len(columns))
  186. sql = f"INSERT INTO `{table_name}` ({column_list}) VALUES ({placeholders})"
  187. total_inserted = 0
  188. cursor = self.connection.cursor()
  189. cursor.execute("SET SESSION autocommit = 0")
  190. cursor.execute("SET SESSION unique_checks = 0")
  191. cursor.execute("SET SESSION foreign_key_checks = 0")
  192. try:
  193. for i in range(0, len(data_list), BATCH_SIZE):
  194. batch = data_list[i:i + BATCH_SIZE]
  195. attempt = 0
  196. while True:
  197. try:
  198. self._bulk_insert_one_batch(cursor, sql, batch, columns)
  199. break
  200. except pymysql.err.OperationalError as e:
  201. attempt += 1
  202. if not self._is_transient_db_error(e) or attempt > DB_BULK_INSERT_RETRIES:
  203. raise
  204. logger.warning(
  205. "批量插入单批失败 (%s/%s),将重连后重试当前批: %s",
  206. attempt,
  207. DB_BULK_INSERT_RETRIES,
  208. e,
  209. )
  210. time.sleep(RETRY_DELAY * attempt)
  211. self._reconnect()
  212. cursor = self.connection.cursor()
  213. cursor.execute("SET SESSION autocommit = 0")
  214. cursor.execute("SET SESSION unique_checks = 0")
  215. cursor.execute("SET SESSION foreign_key_checks = 0")
  216. total_inserted += len(batch)
  217. self.connection.commit()
  218. if i > 0 and i % (BATCH_SIZE * 3) == 0:
  219. logger.info(f"批量插入进度: {total_inserted}/{len(data_list)}")
  220. finally:
  221. self._safe_restore_session_defaults(cursor)
  222. try:
  223. cursor.close()
  224. except Exception:
  225. pass
  226. return total_inserted
  227. def insert_daily_price_middle(self, data_list: List[Dict[str, Any]]) -> int:
  228. """批量插入数据到buff_goods_daily_price_middle表"""
  229. if not data_list:
  230. return 0
  231. # 定义列和对应的数据字段
  232. columns = ['goods_id', 'name', 'min_price', 'buy_max_price', 'stat_time', 'create_time']
  233. # 准备数据
  234. now = datetime.now()
  235. processed_data = []
  236. logger.info(f"开始准备 {len(data_list)} 条无tag价格数据...")
  237. start_time = time.time()
  238. for item in data_list:
  239. processed_data.append({
  240. 'goods_id': str(item['goods_id']),
  241. 'name': item['name'],
  242. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  243. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  244. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  245. 'create_time': now
  246. })
  247. prep_time = time.time() - start_time
  248. logger.info(f"数据准备完成,耗时: {prep_time:.2f}秒")
  249. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_daily_price_middle...")
  250. insert_start = time.time()
  251. result = self.bulk_insert('buff_goods_daily_price_middle', processed_data, columns)
  252. insert_time = time.time() - insert_start
  253. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  254. return result
  255. def copy_from_middle_to_daily_price(self) -> int:
  256. """将中间表数据插入到buff_goods_daily_price表"""
  257. try:
  258. with self.connection.cursor() as cursor:
  259. # 使用INSERT IGNORE避免重复(如果有唯一索引冲突)
  260. sql = """
  261. INSERT IGNORE INTO buff_goods_daily_price
  262. (goods_id, name, min_price, buy_max_price, stat_time, create_time, update_time)
  263. SELECT goods_id, name, min_price, buy_max_price, stat_time, NOW(), NOW()
  264. FROM buff_goods_daily_price_middle
  265. """
  266. cursor.execute(sql)
  267. affected_rows = cursor.connection.affected_rows()
  268. self.connection.commit()
  269. return affected_rows
  270. except Exception as e:
  271. logger.error(f"复制数据失败: {e}")
  272. self._safe_rollback()
  273. return 0
  274. def cleanup_old_price_data(self):
  275. """清理超过60天的价格数据(分批删除,避免锁表)"""
  276. cutoff_date = datetime.now() - timedelta(days=PRICE_RETENTION_DAYS)
  277. try:
  278. with self.connection.cursor() as cursor:
  279. # 分批删除,每次10000条
  280. total_deleted = 0
  281. while True:
  282. sql = """
  283. DELETE FROM buff_goods_daily_price
  284. WHERE create_time < %s
  285. LIMIT 10000
  286. """
  287. cursor.execute(sql, (cutoff_date,))
  288. batch_deleted = cursor.connection.affected_rows()
  289. total_deleted += batch_deleted
  290. if batch_deleted == 0:
  291. break
  292. self.connection.commit()
  293. time.sleep(0.05) # 短暂休眠,避免锁表
  294. logger.info(f"已清理 {total_deleted} 条超过{PRICE_RETENTION_DAYS}天的旧价格数据")
  295. except Exception as e:
  296. logger.error(f"清理旧数据失败: {e}")
  297. self._safe_rollback()
  298. def insert_goods_info_middle(self, data_list: List[Dict[str, Any]]) -> int:
  299. """批量插入数据到buff_goods_info_middle表"""
  300. if not data_list:
  301. return 0
  302. # 定义列和对应的数据字段
  303. columns = ['goods_id', 'name', 'min_price', 'buy_max_price',
  304. 'd7_avg_min_price', 'd30_avg_min_price',
  305. 'buff_url', 'steam_url', 'stat_time', 'create_time']
  306. # 准备数据
  307. now = datetime.now()
  308. processed_data = []
  309. logger.info(f"开始准备 {len(data_list)} 条商品信息数据...")
  310. start_time = time.time()
  311. for item in data_list:
  312. processed_data.append({
  313. 'goods_id': str(item['goods_id']),
  314. 'name': item['name'],
  315. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  316. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  317. 'd7_avg_min_price': float(item['avg_7d_min_price']) if item['avg_7d_min_price'] else 0.00,
  318. 'd30_avg_min_price': float(item['avg_30d_min_price']) if item['avg_30d_min_price'] else 0.00,
  319. 'buff_url': item['buff_url'],
  320. 'steam_url': item['steam_url'],
  321. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  322. 'create_time': now
  323. })
  324. prep_time = time.time() - start_time
  325. logger.info(f"商品信息数据准备完成,耗时: {prep_time:.2f}秒")
  326. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_info_middle...")
  327. insert_start = time.time()
  328. result = self.bulk_insert('buff_goods_info_middle', processed_data, columns)
  329. insert_time = time.time() - insert_start
  330. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  331. return result
  332. def copy_from_middle_to_goods_info(self) -> int:
  333. """将中间表数据插入到buff_goods_info表(先清空再插入)"""
  334. try:
  335. # 先清空目标表
  336. self.clear_table("buff_goods_info")
  337. with self.connection.cursor() as cursor:
  338. # 使用INSERT INTO(因为已经清空了,不需要REPLACE)
  339. sql = """
  340. INSERT INTO buff_goods_info
  341. (goods_id, name, min_price, buy_max_price, d7_avg_min_price, d30_avg_min_price,
  342. buff_url, steam_url, stat_time, create_time, update_time)
  343. SELECT goods_id, name, min_price, buy_max_price, d7_avg_min_price, d30_avg_min_price,
  344. buff_url, steam_url, stat_time, NOW(), NOW()
  345. FROM buff_goods_info_middle
  346. """
  347. cursor.execute(sql)
  348. affected_rows = cursor.connection.affected_rows()
  349. self.connection.commit()
  350. return affected_rows
  351. except Exception as e:
  352. logger.error(f"复制商品信息失败: {e}")
  353. self._safe_rollback()
  354. return 0
  355. def insert_daily_tag_price_middle(self, data_list: List[Dict[str, Any]]) -> int:
  356. """批量插入数据到buff_goods_daily_tag_price_middle表"""
  357. if not data_list:
  358. return 0
  359. # 定义列和对应的数据字段
  360. columns = ['goods_id', 'name', 'min_price', 'buy_max_price',
  361. 'tag_id', 'tag_name', 'stat_time', 'create_time']
  362. # 准备数据
  363. now = datetime.now()
  364. processed_data = []
  365. logger.info(f"开始准备 {len(data_list)} 条有tag价格数据...")
  366. start_time = time.time()
  367. for item in data_list:
  368. processed_data.append({
  369. 'goods_id': str(item['goods_id']),
  370. 'name': item['name'],
  371. 'min_price': float(item['min_price']) if item['min_price'] else 0.00,
  372. 'buy_max_price': float(item['buy_max_price']) if item['buy_max_price'] else 0.00,
  373. 'tag_id': str(item['tag_id']) if item['tag_id'] else None,
  374. 'tag_name': item['tag_name'],
  375. 'stat_time': datetime.fromtimestamp(item['stat_time']),
  376. 'create_time': now
  377. })
  378. prep_time = time.time() - start_time
  379. logger.info(f"有tag价格数据准备完成,耗时: {prep_time:.2f}秒")
  380. logger.info(f"开始插入 {len(processed_data)} 条数据到buff_goods_daily_tag_price_middle...")
  381. insert_start = time.time()
  382. result = self.bulk_insert('buff_goods_daily_tag_price_middle', processed_data, columns)
  383. insert_time = time.time() - insert_start
  384. logger.info(f"插入完成: {result} 条,耗时: {insert_time:.2f}秒")
  385. return result
  386. def copy_from_middle_to_daily_tag_price(self) -> int:
  387. """将中间表数据插入到buff_goods_daily_tag_price表(先清空再插入)"""
  388. try:
  389. # 先清空目标表
  390. self.clear_table("buff_goods_daily_tag_price")
  391. with self.connection.cursor() as cursor:
  392. # 使用INSERT INTO(因为已经清空了,不需要INSERT IGNORE)
  393. sql = """
  394. INSERT INTO buff_goods_daily_tag_price
  395. (goods_id, name, min_price, buy_max_price, tag_id, tag_name, stat_time, create_time, update_time)
  396. SELECT goods_id, name, min_price, buy_max_price, tag_id, tag_name, stat_time, NOW(), NOW()
  397. FROM buff_goods_daily_tag_price_middle
  398. """
  399. cursor.execute(sql)
  400. affected_rows = cursor.connection.affected_rows()
  401. self.connection.commit()
  402. return affected_rows
  403. except Exception as e:
  404. logger.error(f"复制有tag价格失败: {e}")
  405. self._safe_rollback()
  406. return 0
  407. def calculate_average_prices_batch(self, goods_ids: List[str]) -> Dict[str, Tuple[float, float]]:
  408. """批量计算平均价格,优化性能"""
  409. if not goods_ids:
  410. return {}
  411. results = {}
  412. try:
  413. with self.connection.cursor() as cursor:
  414. # 创建临时表优化查询
  415. cursor.execute("""
  416. CREATE TEMPORARY TABLE IF NOT EXISTS temp_goods_ids (
  417. goods_id VARCHAR(64) PRIMARY KEY
  418. ) ENGINE=MEMORY
  419. """)
  420. # 批量插入goods_id到临时表
  421. for i in range(0, len(goods_ids), 10000):
  422. batch = goods_ids[i:i + 10000]
  423. # 使用VALUES列表批量插入
  424. values_placeholders = ','.join(['(%s)'] * len(batch))
  425. cursor.execute(f"INSERT IGNORE INTO temp_goods_ids (goods_id) VALUES {values_placeholders}", batch)
  426. # 使用临时表进行JOIN查询,大幅提高性能
  427. cursor.execute("""
  428. SELECT
  429. t.goods_id,
  430. COALESCE(ROUND(AVG(CASE
  431. WHEN dp.create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
  432. AND dp.min_price > 0
  433. THEN dp.min_price
  434. END), 2), 0.00) as avg_7d,
  435. COALESCE(ROUND(AVG(CASE
  436. WHEN dp.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  437. AND dp.min_price > 0
  438. THEN dp.min_price
  439. END), 2), 0.00) as avg_30d
  440. FROM temp_goods_ids t
  441. LEFT JOIN buff_goods_daily_price dp ON t.goods_id = dp.goods_id
  442. WHERE dp.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  443. GROUP BY t.goods_id
  444. """)
  445. rows = cursor.fetchall()
  446. for row in rows:
  447. results[row['goods_id']] = (
  448. float(row['avg_7d']),
  449. float(row['avg_30d'])
  450. )
  451. # 清理临时表
  452. cursor.execute("DROP TEMPORARY TABLE IF EXISTS temp_goods_ids")
  453. except Exception as e:
  454. logger.error(f"批量计算平均价格失败: {e}")
  455. # 回退到原方法
  456. return self._fallback_calculate_avg_prices(goods_ids)
  457. return results
  458. def _fallback_calculate_avg_prices(self, goods_ids: List[str]) -> Dict[str, Tuple[float, float]]:
  459. """后备的平均价格计算方法"""
  460. results = {}
  461. try:
  462. with self.connection.cursor() as cursor:
  463. for i in range(0, len(goods_ids), AVG_PRICE_BATCH_SIZE):
  464. batch = goods_ids[i:i + AVG_PRICE_BATCH_SIZE]
  465. placeholders = ','.join(['%s'] * len(batch))
  466. cursor.execute(f"""
  467. SELECT
  468. goods_id,
  469. COALESCE(ROUND(AVG(CASE
  470. WHEN create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
  471. AND min_price > 0
  472. THEN min_price
  473. END), 2), 0.00) as avg_7d,
  474. COALESCE(ROUND(AVG(CASE
  475. WHEN create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  476. AND min_price > 0
  477. THEN min_price
  478. END), 2), 0.00) as avg_30d
  479. FROM buff_goods_daily_price
  480. WHERE goods_id IN ({placeholders})
  481. AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
  482. GROUP BY goods_id
  483. """, batch)
  484. rows = cursor.fetchall()
  485. for row in rows:
  486. results[row['goods_id']] = (
  487. float(row['avg_7d']),
  488. float(row['avg_30d'])
  489. )
  490. if i % (AVG_PRICE_BATCH_SIZE * 5) == 0:
  491. logger.info(f"平均价格计算进度: {len(results)}/{len(goods_ids)}")
  492. except Exception as e:
  493. logger.error(f"后备平均价格计算失败: {e}")
  494. return results
  495. def get_last_sync_time(self) -> Optional[datetime]:
  496. """获取上次同步时间"""
  497. try:
  498. with self.connection.cursor() as cursor:
  499. cursor.execute("""
  500. SELECT MAX(stat_time) as last_sync
  501. FROM buff_goods_daily_price
  502. WHERE stat_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
  503. LIMIT 1
  504. """)
  505. result = cursor.fetchone()
  506. return result['last_sync'] if result['last_sync'] else None
  507. except Exception as e:
  508. logger.error(f"获取上次同步时间失败: {e}")
  509. return None
  510. # ============================================================
  511. # BUFF API 操作类(保持不变)
  512. # ============================================================
  513. class BuffPriceAPI:
  514. """BUFF价格API操作类"""
  515. def __init__(self):
  516. self.session = requests.Session()
  517. self.session.headers.update({
  518. "Authorization": f"Bearer {BUFF_API_TOKEN}",
  519. "Accept": "application/json",
  520. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
  521. })
  522. def fetch_price_data(self, game: str = "cs2", include_sticker: int = 1) -> Dict[str, Any]:
  523. """获取价格数据"""
  524. params = {"game": game, "include_sticker": include_sticker}
  525. for retry in range(MAX_RETRIES):
  526. try:
  527. logger.info(f"请求BUFF API (第{retry + 1}次尝试)...")
  528. response = self.session.get(BUFF_API_URL, params=params, timeout=REQUEST_TIMEOUT)
  529. response.raise_for_status()
  530. data = response.json()
  531. logger.info("=" * 60)
  532. logger.info("🔍 API返回数据结构:")
  533. logger.info(f" 顶层keys: {list(data.keys())}")
  534. # 获取数据统计信息
  535. stat_time = data.get("stat_time", 0)
  536. remaining = data.get("req_remaining", 0)
  537. info_data = data.get("info", [])
  538. logger.info(f" 统计时间: {datetime.fromtimestamp(stat_time)}")
  539. logger.info(f" 剩余调用次数: {remaining}")
  540. logger.info(f" 数据条数: {len(info_data)}")
  541. # 检查第一条数据是否有market_hash_name字段
  542. if info_data and len(info_data) > 0:
  543. first_item = info_data[0]
  544. logger.info(f" 第一条数据字段检查:")
  545. logger.info(f" goods_id: {first_item.get('goods_id')}")
  546. logger.info(f" name: {first_item.get('name')}")
  547. logger.info(f" market_hash_name: {first_item.get('market_hash_name', '不存在')}")
  548. logger.info("=" * 60)
  549. # 验证数据格式
  550. if "info" not in data:
  551. logger.error(f"API返回格式错误: 无info字段, 完整响应: {data}")
  552. raise Exception(f"API错误: {data.get('msg', '未知错误')}")
  553. logger.info(f"API请求成功,获取到 {len(info_data)} 条记录")
  554. logger.info(f"API剩余调用次数: {remaining}")
  555. return data
  556. except requests.exceptions.RequestException as e:
  557. logger.error(f"API请求失败 (尝试 {retry + 1}/{MAX_RETRIES}): {e}")
  558. if 'response' in locals():
  559. logger.error(f"响应状态码: {response.status_code}")
  560. logger.error(f"响应内容: {response.text[:500]}")
  561. if retry < MAX_RETRIES - 1:
  562. time.sleep(RETRY_DELAY)
  563. else:
  564. raise
  565. def generate_steam_url(self, market_hash_name: str) -> str:
  566. """生成Steam社区市场链接"""
  567. if not market_hash_name:
  568. return None
  569. try:
  570. # URL编码market_hash_name
  571. encoded_name = urllib.parse.quote(market_hash_name)
  572. steam_url = f"{STEAM_MARKET_BASE_URL}{encoded_name}"
  573. return steam_url
  574. except Exception as e:
  575. logger.error(f"生成Steam URL失败: {market_hash_name}, 错误: {e}")
  576. return None
  577. def parse_price_data(self, api_response: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
  578. """解析价格数据,返回三种类型的数据列表
  579. Returns:
  580. tuple: (daily_price_list, goods_info_list, tag_price_list)
  581. """
  582. logger.info("🔍 开始解析数据...")
  583. stat_time = api_response.get("stat_time", 0)
  584. info_data = api_response.get("info", [])
  585. logger.info(f" stat_time: {datetime.fromtimestamp(stat_time)}")
  586. logger.info(f" info列表长度: {len(info_data)}")
  587. daily_price_list = [] # 无tag的价格数据
  588. tag_price_list = [] # 有tag的价格数据
  589. goods_info_list = [] # 商品信息数据
  590. start_time = time.time()
  591. for item in info_data:
  592. goods_id = item.get("goods_id")
  593. name = item.get("name")
  594. market_hash_name = item.get("market_hash_name") # 获取market_hash_name
  595. update_time = item.get("update_time", stat_time)
  596. sales = item.get("sales", [])
  597. # 生成链接
  598. buff_url = f"https://buff.163.com/goods/{goods_id}"
  599. steam_url = self.generate_steam_url(market_hash_name)
  600. # 处理每个sales条目
  601. has_base_price = False
  602. for sale in sales:
  603. min_price = sale.get("min_price")
  604. buy_max_price = sale.get("buy_max_price")
  605. tag_id = sale.get("tag_id")
  606. tag_name = sale.get("tag_name")
  607. if tag_id and tag_name:
  608. # 有tag的数据
  609. tag_data = {
  610. "goods_id": goods_id,
  611. "name": name,
  612. "min_price": min_price,
  613. "buy_max_price": buy_max_price,
  614. "tag_id": tag_id,
  615. "tag_name": tag_name,
  616. "stat_time": stat_time,
  617. "update_time": update_time
  618. }
  619. tag_price_list.append(tag_data)
  620. else:
  621. # 无tag的数据
  622. price_data = {
  623. "goods_id": goods_id,
  624. "name": name,
  625. "min_price": min_price,
  626. "buy_max_price": buy_max_price,
  627. "stat_time": stat_time,
  628. "update_time": update_time
  629. }
  630. daily_price_list.append(price_data)
  631. # 如果是第一个无tag的条目,也用于商品信息
  632. if not has_base_price:
  633. has_base_price = True
  634. goods_info_data = {
  635. "goods_id": goods_id,
  636. "name": name,
  637. "min_price": min_price,
  638. "buy_max_price": buy_max_price,
  639. "avg_7d_min_price": None,
  640. "avg_30d_min_price": None,
  641. "stat_time": stat_time,
  642. "update_time": update_time,
  643. "buff_url": buff_url,
  644. "steam_url": steam_url # 使用生成的steam_url
  645. }
  646. goods_info_list.append(goods_info_data)
  647. elapsed = time.time() - start_time
  648. logger.info(f" 解析完成,耗时: {elapsed:.2f}秒")
  649. logger.info(f" 无tag价格数据: {len(daily_price_list)} 条")
  650. logger.info(f" 有tag价格数据: {len(tag_price_list)} 条")
  651. logger.info(f" 商品信息数据: {len(goods_info_list)} 条")
  652. # 统计steam_url生成情况
  653. steam_url_count = sum(1 for item in goods_info_list if item.get('steam_url'))
  654. logger.info(f" 成功生成steam_url: {steam_url_count}/{len(goods_info_list)} 条")
  655. return daily_price_list, goods_info_list, tag_price_list
  656. # ============================================================
  657. # 主逻辑(修正清空表顺序)
  658. # ============================================================
  659. class PriceSyncTask:
  660. """价格数据同步任务"""
  661. def __init__(self):
  662. self.db = None
  663. self.api = BuffPriceAPI()
  664. self.last_sync_time = None
  665. def _init_db(self):
  666. """初始化数据库连接"""
  667. if self.db is None:
  668. self.db = PriceDatabase()
  669. def run(self) -> bool:
  670. """执行同步任务"""
  671. start_time = datetime.now()
  672. logger.info("=" * 60)
  673. logger.info(f"开始执行价格数据同步任务: {start_time}")
  674. logger.info("=" * 60)
  675. try:
  676. # 初始化数据库连接
  677. self._init_db()
  678. # 检查是否需要执行
  679. last_sync = self.db.get_last_sync_time()
  680. if last_sync:
  681. time_diff = (start_time - last_sync).total_seconds()
  682. if time_diff < 300: # 5分钟内已同步过
  683. logger.info(f"最近 {time_diff:.0f} 秒内已同步过,跳过本次执行")
  684. return True
  685. # Step 1: 清空中间表
  686. logger.info("[Step 1/9] 清空中间表...")
  687. clear_start = time.time()
  688. self.db.clear_table("buff_goods_daily_price_middle")
  689. self.db.clear_table("buff_goods_info_middle")
  690. self.db.clear_table("buff_goods_daily_tag_price_middle")
  691. logger.info(f"清空中间表耗时: {time.time() - clear_start:.2f}秒")
  692. # Step 2: 调用BUFF API获取数据
  693. logger.info("[Step 2/9] 调用BUFF API获取价格数据...")
  694. api_start = time.time()
  695. api_response = self.api.fetch_price_data(game="cs2", include_sticker=1)
  696. logger.info(f"API调用耗时: {time.time() - api_start:.2f}秒")
  697. # Step 3: 解析数据
  698. logger.info("[Step 3/9] 解析数据...")
  699. parse_start = time.time()
  700. daily_price_list, goods_info_list, tag_price_list = self.api.parse_price_data(api_response)
  701. logger.info(f"数据解析耗时: {time.time() - parse_start:.2f}秒")
  702. if not daily_price_list:
  703. logger.warning("未获取到任何价格数据,任务取消")
  704. return False
  705. # Step 4: 插入无tag价格数据到中间表
  706. logger.info("[Step 4/9] 插入无tag价格数据到中间表...")
  707. inserted_daily_count = self.db.insert_daily_price_middle(daily_price_list)
  708. # Step 5: 更新主价格表
  709. logger.info("[Step 5/9] 更新主价格表...")
  710. copy_start = time.time()
  711. target_inserted = self.db.copy_from_middle_to_daily_price()
  712. logger.info(f"主价格表插入完成: {target_inserted} 条,耗时: {time.time() - copy_start:.2f}秒")
  713. # 每小时清理一次旧数据
  714. current_minute = datetime.now().minute
  715. if current_minute == 0:
  716. logger.info("[Step 5a/9] 清理旧价格数据...")
  717. cleanup_start = time.time()
  718. self.db.cleanup_old_price_data()
  719. logger.info(f"清理旧数据耗时: {time.time() - cleanup_start:.2f}秒")
  720. else:
  721. logger.info("[Step 5a/9] 跳过清理(非整点)")
  722. # Step 6: 批量计算平均价格
  723. logger.info("[Step 6/9] 批量计算平均价格...")
  724. avg_start = time.time()
  725. goods_ids = [str(item['goods_id']) for item in goods_info_list]
  726. avg_prices = self.db.calculate_average_prices_batch(goods_ids)
  727. # 更新goods_info_list中的平均价格
  728. updated_count = 0
  729. for goods_info in goods_info_list:
  730. goods_id = str(goods_info['goods_id'])
  731. if goods_id in avg_prices:
  732. avg_7d, avg_30d = avg_prices[goods_id]
  733. goods_info['avg_7d_min_price'] = avg_7d
  734. goods_info['avg_30d_min_price'] = avg_30d
  735. updated_count += 1
  736. logger.info(f"平均价格计算完成: {updated_count}/{len(goods_info_list)} 条,耗时: {time.time() - avg_start:.2f}秒")
  737. # Step 7: 插入商品信息到中间表
  738. logger.info("[Step 7/9] 插入商品信息到中间表...")
  739. inserted_info_count = self.db.insert_goods_info_middle(goods_info_list)
  740. # Step 7a: 清空目标表并更新商品信息主表
  741. logger.info("[Step 7a/9] 清空buff_goods_info表并更新数据...")
  742. copy_info_start = time.time()
  743. info_inserted = self.db.copy_from_middle_to_goods_info()
  744. logger.info(f"商品信息主表插入完成: {info_inserted} 条,耗时: {time.time() - copy_info_start:.2f}秒")
  745. # Step 8: 插入有tag价格数据到中间表
  746. if tag_price_list:
  747. logger.info("[Step 8/9] 插入有tag价格数据到中间表...")
  748. inserted_tag_count = self.db.insert_daily_tag_price_middle(tag_price_list)
  749. # Step 9: 清空目标表并更新有tag价格主表
  750. logger.info("[Step 9/9] 清空buff_goods_daily_tag_price表并更新数据...")
  751. copy_tag_start = time.time()
  752. tag_inserted = self.db.copy_from_middle_to_daily_tag_price()
  753. logger.info(f"有tag价格主表插入完成: {tag_inserted} 条,耗时: {time.time() - copy_tag_start:.2f}秒")
  754. else:
  755. logger.info("没有有tag的价格数据,跳过步骤8-9")
  756. inserted_tag_count = 0
  757. # 清理中间表
  758. logger.info("[清理] 清空中间表数据...")
  759. cleanup_mid_start = time.time()
  760. self.db.clear_table("buff_goods_daily_price_middle")
  761. self.db.clear_table("buff_goods_info_middle")
  762. self.db.clear_table("buff_goods_daily_tag_price_middle")
  763. logger.info(f"清理中间表耗时: {time.time() - cleanup_mid_start:.2f}秒")
  764. # 任务完成
  765. end_time = datetime.now()
  766. duration = (end_time - start_time).total_seconds()
  767. logger.info("=" * 60)
  768. logger.info(f"价格数据同步任务完成! 总耗时: {duration:.2f}秒")
  769. logger.info(f"同步数据量:")
  770. logger.info(f" 无tag价格: {inserted_daily_count} 条")
  771. logger.info(f" 商品信息: {inserted_info_count} 条")
  772. logger.info(f" 有tag价格: {inserted_tag_count} 条")
  773. logger.info(f" 总处理数据: {len(daily_price_list)} 条饰品记录")
  774. logger.info("=" * 60)
  775. return True
  776. except Exception as e:
  777. if is_database_connection_error(e):
  778. logger.exception("数据库连接异常,本轮跳过,下一轮将重试: %s", e)
  779. return True
  780. logger.exception(f"同步任务失败: {e}")
  781. return False
  782. finally:
  783. if self.db:
  784. self.db.close()
  785. self.db = None
  786. def run_once() -> bool:
  787. """执行一次同步任务"""
  788. task = PriceSyncTask()
  789. return task.run()
  790. if __name__ == "__main__":
  791. sys.exit(0 if run_once() else 1)