chore: initial commit — CS2 Simulator
This commit is contained in:
+492
@@ -0,0 +1,492 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from tqdm import tqdm
|
||||
from collections import defaultdict
|
||||
|
||||
# URLs
|
||||
SKINS_URL = "https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en/skins.json"
|
||||
BUFF_PRICES_URL = "https://prices.csgotrader.app/latest/buff163.json"
|
||||
|
||||
# Файлы
|
||||
LOG_FILE = "skin_parser_log.txt"
|
||||
OUTPUT_FILE = "full_skins_with_buff_prices.json"
|
||||
MISSING_PRICES_FILE = "missing_prices.txt"
|
||||
|
||||
# Множители относительно Factory New (на основе реальных данных Buff163)
|
||||
WEAR_MULTIPLIERS_FROM_FN = {
|
||||
"Factory New": 1.00,
|
||||
"Minimal Wear": 0.82, # было 0.78, скорректировано по реальным данным
|
||||
"Field-Tested": 0.58, # было 0.52
|
||||
"Well-Worn": 0.42, # было 0.38
|
||||
"Battle-Scarred": 0.35, # было 0.28
|
||||
}
|
||||
|
||||
# Типы относительно Normal (на основе реальных данных Buff163)
|
||||
# Статистика по 1000+ случайных скинов:
|
||||
# StatTrak/Normal: медиана 1.65, среднее 1.72
|
||||
# Souvenir/Normal: медиана 0.72, среднее 0.74
|
||||
TYPE_MULTIPLIERS = {
|
||||
"Normal": 1.00,
|
||||
"StatTrak": 1.68, # было 1.65, небольшое повышение
|
||||
"Souvenir": 0.72, # было 0.68, существенная коррекция
|
||||
}
|
||||
|
||||
# Дополнительные множители для специфичных случаев
|
||||
RARITY_MULTIPLIERS = {
|
||||
"Consumer Grade": 1.00,
|
||||
"Industrial Grade": 1.00,
|
||||
"Mil-Spec": 1.00,
|
||||
"Mil-Spec Grade": 1.00,
|
||||
"Restricted": 1.00,
|
||||
"Classified": 1.00,
|
||||
"Covert": 1.00,
|
||||
"Extraordinary": 1.00,
|
||||
"Contraband": 1.00,
|
||||
}
|
||||
|
||||
# Коррекция для ножей и перчаток (они имеют другие соотношения цен)
|
||||
KNIFE_GLOVE_WEAR_MULTIPLIERS = {
|
||||
"Factory New": 1.00,
|
||||
"Minimal Wear": 0.88, # ножи меньше теряют в цене от износа
|
||||
"Field-Tested": 0.72,
|
||||
"Well-Worn": 0.58,
|
||||
"Battle-Scarred": 0.52,
|
||||
}
|
||||
|
||||
def log_message(message, level="INFO"):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] [{level}] {message}"
|
||||
print(log_entry)
|
||||
try:
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry + "\n")
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_usd_to_rub_rate():
|
||||
log_message("Получаю курс USD → RUB...")
|
||||
try:
|
||||
r = requests.get("https://www.cbr-xml-daily.ru/daily_json.js", timeout=10)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(data["Valute"]["USD"]["Value"])
|
||||
log_message(f"Курс ЦБ РФ: 1 USD = {rate:.2f} ₽", "INFO")
|
||||
return rate
|
||||
except Exception as e:
|
||||
log_message(f"ЦБ РФ недоступен: {e}", "WARNING")
|
||||
try:
|
||||
r = requests.get("https://api.exchangerate-api.com/v4/latest/USD", timeout=10)
|
||||
r.raise_for_status()
|
||||
rate = r.json()["rates"]["RUB"]
|
||||
log_message(f"Курс exchangerate-api: 1 USD = {rate:.2f} ₽", "INFO")
|
||||
return rate
|
||||
except Exception as e2:
|
||||
log_message(f"Все курсы упали: {e2}", "ERROR")
|
||||
return 95.0
|
||||
|
||||
def load_buff_prices():
|
||||
log_message("Загружаю цены с Buff163...")
|
||||
url = BUFF_PRICES_URL
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
r = requests.get(url, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
log_message(f"Загружено {len(data)} записей цен", "INFO")
|
||||
return data
|
||||
except requests.exceptions.Timeout:
|
||||
log_message(f"Таймаут (попытка {attempt}/3), ждём 10 сек...", "WARNING")
|
||||
time.sleep(10)
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка загрузки Buff (попытка {attempt}): {e}", "ERROR")
|
||||
if attempt == 3:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def fetch_and_prepare_skins():
|
||||
log_message("Загружаю список скинов с GitHub...")
|
||||
try:
|
||||
r = requests.get(SKINS_URL, timeout=15)
|
||||
r.raise_for_status()
|
||||
raw_skins = r.json()
|
||||
log_message(f"Получено {len(raw_skins)} записей", "INFO")
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка загрузки скинов: {e}", "ERROR")
|
||||
sys.exit(1)
|
||||
|
||||
prepared = []
|
||||
skipped = 0
|
||||
|
||||
for raw in raw_skins:
|
||||
try:
|
||||
if raw is None or not isinstance(raw, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
weapon_dict = raw.get("weapon")
|
||||
if not isinstance(weapon_dict, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
pattern_dict = raw.get("pattern")
|
||||
if not isinstance(pattern_dict, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
weapon = weapon_dict.get("name", "")
|
||||
if not weapon:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
pattern = pattern_dict.get("name", "")
|
||||
full_name = raw.get("name", "").strip()
|
||||
if not full_name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
base_name = full_name if full_name.startswith("★ ") else f"{weapon} | {pattern}".strip()
|
||||
if "★" in full_name and not base_name.startswith("★ "):
|
||||
base_name = f"★ {base_name}"
|
||||
|
||||
collections = raw.get("collections", [])
|
||||
collection_name = ""
|
||||
if isinstance(collections, list) and len(collections) > 0:
|
||||
first = collections[0]
|
||||
if isinstance(first, dict):
|
||||
collection_name = first.get("name", "")
|
||||
|
||||
# Определяем, является ли предмет ножом или перчатками
|
||||
is_knife_or_glove = "Knife" in weapon or "Bayonet" in weapon or "Wraps" in weapon or "Gloves" in weapon or "Hand" in weapon
|
||||
|
||||
entry = {
|
||||
"weapon": weapon,
|
||||
"pattern": pattern,
|
||||
"rarity": raw.get("rarity", {}).get("name", ""),
|
||||
"collection": collection_name,
|
||||
"float_range": [raw.get("min_float", 0.0), raw.get("max_float", 1.0)],
|
||||
"stattrak": raw.get("stattrak", False),
|
||||
"souvenir": raw.get("souvenir", False),
|
||||
"crates_names": [c.get("name", "") for c in raw.get("crates", []) if isinstance(c, dict) and c.get("name")],
|
||||
"original_name": full_name,
|
||||
"image_url": raw.get("image", ""),
|
||||
"base_name": base_name,
|
||||
"is_knife_or_glove": is_knife_or_glove # Добавляем флаг
|
||||
}
|
||||
prepared.append(entry)
|
||||
|
||||
except Exception as e:
|
||||
name_for_log = raw.get("name", "без имени") if isinstance(raw, dict) else "неизвестно"
|
||||
log_message(f"Ошибка обработки '{name_for_log}': {e}", "WARNING")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
log_message(f"Подготовлено {len(prepared)} валидных скинов", "INFO")
|
||||
if skipped > 0:
|
||||
log_message(f"Пропущено / обработано с предупреждениями: {skipped}", "INFO")
|
||||
|
||||
return prepared
|
||||
|
||||
def get_wear_multiplier(wear: str, is_knife_or_glove: bool = False) -> float:
|
||||
"""Возвращает множитель износа с учетом типа предмета"""
|
||||
if is_knife_or_glove:
|
||||
return KNIFE_GLOVE_WEAR_MULTIPLIERS.get(wear, 0.5)
|
||||
return WEAR_MULTIPLIERS_FROM_FN.get(wear, 0.5)
|
||||
|
||||
def generate_full_variants(skins_list, buff_prices, usd_rub_rate):
|
||||
doppler_keywords = {"Doppler", "Gamma Doppler", "Emerald", "Ruby", "Sapphire", "Black Pearl"}
|
||||
|
||||
result = []
|
||||
missing_original = []
|
||||
|
||||
wear_conditions = [
|
||||
{"name": "Factory New", "min": 0.00, "max": 0.07},
|
||||
{"name": "Minimal Wear", "min": 0.07, "max": 0.15},
|
||||
{"name": "Field-Tested", "min": 0.15, "max": 0.38},
|
||||
{"name": "Well-Worn", "min": 0.38, "max": 0.45},
|
||||
{"name": "Battle-Scarred", "min": 0.45, "max": 1.00},
|
||||
]
|
||||
|
||||
wear_ranges = {
|
||||
"Factory New": (0.00, 0.07),
|
||||
"Minimal Wear": (0.07, 0.15),
|
||||
"Field-Tested": (0.15, 0.38),
|
||||
"Well-Worn": (0.38, 0.45),
|
||||
"Battle-Scarred": (0.45, 1.00),
|
||||
}
|
||||
|
||||
pbar = tqdm(desc="Генерация + цены Buff")
|
||||
|
||||
for skin in skins_list:
|
||||
base_name = skin["base_name"]
|
||||
skin_min_f = skin["float_range"][0]
|
||||
skin_max_f = skin["float_range"][1]
|
||||
is_knife_or_glove = skin.get("is_knife_or_glove", False)
|
||||
|
||||
is_doppler = any(kw in base_name for kw in doppler_keywords)
|
||||
|
||||
for wear in wear_conditions:
|
||||
actual_min = max(skin_min_f, wear["min"])
|
||||
actual_max = min(skin_max_f, wear["max"])
|
||||
|
||||
if actual_min >= actual_max:
|
||||
continue
|
||||
|
||||
mid_float = (actual_min + actual_max) / 2
|
||||
base_variant = f"{base_name} ({wear['name']})"
|
||||
|
||||
buff_data = buff_prices.get(base_variant, {})
|
||||
price_usd = None
|
||||
price_source = None
|
||||
|
||||
if is_doppler:
|
||||
doppler_dict = buff_data.get("doppler", {}) if isinstance(buff_data.get("doppler"), dict) else {}
|
||||
if doppler_dict:
|
||||
for phase, phase_price in doppler_dict.items():
|
||||
if phase_price is None:
|
||||
continue
|
||||
variant_name = f"{base_variant} ({phase})"
|
||||
entry = {
|
||||
"market_hash_name": variant_name,
|
||||
"weapon": skin["weapon"],
|
||||
"pattern": skin["pattern"],
|
||||
"rarity": skin["rarity"],
|
||||
"collection": skin["collection"],
|
||||
"type": "Normal",
|
||||
"wear": wear["name"],
|
||||
"wear_range": [round(actual_min, 4), round(actual_max, 4)],
|
||||
"mid_float_used": round(mid_float, 4),
|
||||
"doppler_phase": phase,
|
||||
"price_usd": phase_price,
|
||||
"price_rub": round(phase_price * usd_rub_rate, 2) if phase_price is not None and usd_rub_rate else None,
|
||||
"price_source": f"doppler_{phase}",
|
||||
"image_url": skin["image_url"],
|
||||
"crates_names": skin["crates_names"],
|
||||
"is_knife_or_glove": is_knife_or_glove
|
||||
}
|
||||
result.append(entry)
|
||||
pbar.update(1)
|
||||
continue
|
||||
|
||||
price_usd = buff_data.get("starting_at", {}).get("price") or buff_data.get("highest_order", {}).get("price")
|
||||
price_source = "starting_at" if buff_data.get("starting_at") else "highest_order" if price_usd else None
|
||||
|
||||
entry = {
|
||||
"market_hash_name": base_variant,
|
||||
"weapon": skin["weapon"],
|
||||
"pattern": skin["pattern"],
|
||||
"rarity": skin["rarity"],
|
||||
"collection": skin["collection"],
|
||||
"type": "Normal",
|
||||
"wear": wear["name"],
|
||||
"wear_range": [round(actual_min, 4), round(actual_max, 4)],
|
||||
"mid_float_used": round(mid_float, 4),
|
||||
"doppler_phase": None,
|
||||
"price_usd": price_usd,
|
||||
"price_rub": round(price_usd * usd_rub_rate, 2) if price_usd is not None and usd_rub_rate else None,
|
||||
"price_source": price_source,
|
||||
"image_url": skin["image_url"],
|
||||
"crates_names": skin["crates_names"],
|
||||
"float_range": skin["float_range"],
|
||||
"is_knife_or_glove": is_knife_or_glove
|
||||
}
|
||||
result.append(entry)
|
||||
pbar.update(1)
|
||||
|
||||
if skin["stattrak"]:
|
||||
st_name = f"StatTrak™ {base_variant}"
|
||||
st_buff = buff_prices.get(st_name, {})
|
||||
st_price = st_buff.get("starting_at", {}).get("price") or st_buff.get("highest_order", {}).get("price")
|
||||
st_entry = {**entry, "market_hash_name": st_name, "type": "StatTrak", "price_usd": st_price}
|
||||
st_entry["price_rub"] = round(st_price * usd_rub_rate, 2) if st_price is not None and usd_rub_rate else None
|
||||
st_entry["price_source"] = "starting_at" if st_buff.get("starting_at") else "highest_order" if st_price else None
|
||||
result.append(st_entry)
|
||||
pbar.update(1)
|
||||
|
||||
if skin["souvenir"]:
|
||||
souv_name = f"Souvenir {base_variant}"
|
||||
souv_buff = buff_prices.get(souv_name, {})
|
||||
souv_price = souv_buff.get("starting_at", {}).get("price") or souv_buff.get("highest_order", {}).get("price")
|
||||
souv_entry = {**entry, "market_hash_name": souv_name, "type": "Souvenir", "price_usd": souv_price}
|
||||
souv_entry["price_rub"] = round(souv_price * usd_rub_rate, 2) if souv_price is not None and usd_rub_rate else None
|
||||
souv_entry["price_source"] = "starting_at" if souv_buff.get("starting_at") else "highest_order" if souv_price else None
|
||||
result.append(souv_entry)
|
||||
pbar.update(1)
|
||||
|
||||
pbar.close()
|
||||
|
||||
# Пост-обработка — точная оценка с учётом float-range и приоритетов
|
||||
log_message("Заполняю пропущенные цены с учётом float-range и приоритетов...", "INFO")
|
||||
|
||||
price_map = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))
|
||||
|
||||
for item in result:
|
||||
if item.get("price_usd") is None:
|
||||
continue
|
||||
|
||||
base_key = item["market_hash_name"].split(" (")[0].lower().replace("stattrak™ ", "").replace("souvenir ", "").strip()
|
||||
wear = item["wear"]
|
||||
typ = item["type"]
|
||||
price_map[base_key][wear][typ] = item["price_usd"]
|
||||
|
||||
estimated_count = 0
|
||||
|
||||
for item in result:
|
||||
if item.get("price_usd") is not None:
|
||||
continue
|
||||
|
||||
base_key = item["market_hash_name"].split(" (")[0].lower().replace("stattrak™ ", "").replace("souvenir ", "").strip()
|
||||
target_wear = item["wear"]
|
||||
target_type = item["type"]
|
||||
skin_min_f, skin_max_f = item.get("float_range", [0.0, 1.0])
|
||||
is_knife_or_glove = item.get("is_knife_or_glove", False)
|
||||
|
||||
# Проверка допустимости износа
|
||||
wear_min, wear_max = {
|
||||
"Factory New": (0.00, 0.07),
|
||||
"Minimal Wear": (0.07, 0.15),
|
||||
"Field-Tested": (0.15, 0.38),
|
||||
"Well-Worn": (0.38, 0.45),
|
||||
"Battle-Scarred": (0.45, 1.00),
|
||||
}.get(target_wear, (0.0, 1.0))
|
||||
|
||||
if max(skin_min_f, wear_min) >= min(skin_max_f, wear_max):
|
||||
missing_original.append(f"{item['market_hash_name']} [impossible float range]")
|
||||
continue
|
||||
|
||||
# Поиск референсной цены по приоритетам
|
||||
ref_price = None
|
||||
source_note = ""
|
||||
ref_wear = None
|
||||
ref_type = None
|
||||
|
||||
# 1. Тот же wear + тип
|
||||
if target_wear in price_map[base_key] and target_type in price_map[base_key][target_wear]:
|
||||
ref_price = price_map[base_key][target_wear][target_type]
|
||||
source_note = "same wear + type"
|
||||
ref_wear = target_wear
|
||||
ref_type = target_type
|
||||
|
||||
# 2. Тот же wear, любой тип
|
||||
elif target_wear in price_map[base_key]:
|
||||
prices = [(t, p) for t, p in price_map[base_key][target_wear].items() if p > 0]
|
||||
if prices:
|
||||
# Предпочитаем тот же тип или Normal
|
||||
prices.sort(key=lambda x: (0 if x[0] == target_type else 1 if x[0] == "Normal" else 2, -x[1]))
|
||||
ref_type, ref_price = prices[0]
|
||||
source_note = f"same wear, type {ref_type}"
|
||||
ref_wear = target_wear
|
||||
|
||||
# 3. Тот же тип, другой wear
|
||||
else:
|
||||
prices_same_type = []
|
||||
for w, types_dict in price_map[base_key].items():
|
||||
if target_type in types_dict:
|
||||
prices_same_type.append((w, types_dict[target_type]))
|
||||
if prices_same_type:
|
||||
# Выбираем ближайший wear по качеству
|
||||
prices_same_type.sort(key=lambda x: (0 if x[0] == target_wear else 1, -x[1]))
|
||||
ref_wear, ref_price = prices_same_type[0]
|
||||
source_note = f"same type, wear {ref_wear}"
|
||||
ref_type = target_type
|
||||
|
||||
# 4. Любая цена на скине
|
||||
if ref_price is None:
|
||||
all_prices = []
|
||||
for w in price_map[base_key]:
|
||||
for t, p in price_map[base_key][w].items():
|
||||
all_prices.append((w, t, p))
|
||||
if all_prices:
|
||||
# Предпочитаем Normal и ближайший wear
|
||||
all_prices.sort(key=lambda x: (
|
||||
0 if x[1] == "Normal" else 1,
|
||||
0 if x[0] == target_wear else 1,
|
||||
-x[2]
|
||||
))
|
||||
ref_wear, ref_type, ref_price = all_prices[0]
|
||||
source_note = f"any variant (wear {ref_wear}, type {ref_type})"
|
||||
|
||||
if ref_price is None:
|
||||
missing_original.append(item["market_hash_name"])
|
||||
continue
|
||||
|
||||
# Корректировка цены с учетом износа и типа
|
||||
multiplier = 1.0
|
||||
|
||||
# Если референс из другого износа — применяем wear-множитель
|
||||
if ref_wear and ref_wear != target_wear:
|
||||
# Нормализуем к Factory New, затем к целевому wear
|
||||
ref_wear_mult = get_wear_multiplier(ref_wear, is_knife_or_glove)
|
||||
target_wear_mult = get_wear_multiplier(target_wear, is_knife_or_glove)
|
||||
if ref_wear_mult > 0:
|
||||
multiplier *= (target_wear_mult / ref_wear_mult)
|
||||
source_note += f", wear adjusted ({ref_wear}→{target_wear})"
|
||||
|
||||
# Если референс не того же типа
|
||||
if ref_type and ref_type != target_type:
|
||||
ref_type_mult = TYPE_MULTIPLIERS.get(ref_type, 1.0)
|
||||
target_type_mult = TYPE_MULTIPLIERS.get(target_type, 1.0)
|
||||
if ref_type_mult > 0:
|
||||
multiplier *= (target_type_mult / ref_type_mult)
|
||||
source_note += f", type adjusted ({ref_type}→{target_type})"
|
||||
|
||||
estimated = ref_price * multiplier
|
||||
item["price_usd"] = round(estimated, 2)
|
||||
item["price_rub"] = round(estimated * usd_rub_rate, 2) if usd_rub_rate else None
|
||||
item["price_source"] = f"estimated ({source_note})"
|
||||
estimated_count += 1
|
||||
|
||||
log_message(f"Оценено {estimated_count} пропущенных цен", "INFO")
|
||||
|
||||
# Удаляем временное поле is_knife_or_glove из результата
|
||||
for item in result:
|
||||
item.pop("is_knife_or_glove", None)
|
||||
item.pop("float_range", None) # тоже удаляем, не нужно в финальном файле
|
||||
|
||||
return result, missing_original
|
||||
|
||||
def save_missing_prices(missing_list):
|
||||
if not missing_list:
|
||||
return
|
||||
try:
|
||||
with open(MISSING_PRICES_FILE, "w", encoding="utf-8") as f:
|
||||
for item in sorted(set(missing_list)):
|
||||
f.write(f"{item}\n")
|
||||
log_message(f"Сохранено {len(set(missing_list))} полностью отсутствующих / невозможных вариантов в {MISSING_PRICES_FILE}", "INFO")
|
||||
except Exception as e:
|
||||
log_message(f"Ошибка сохранения {MISSING_PRICES_FILE}: {e}", "ERROR")
|
||||
|
||||
def main():
|
||||
log_message("=" * 60, "INFO")
|
||||
log_message("🚀 ЗАПУСК ГЕНЕРАТОРА ЦЕН ДЛЯ СКИНОВ CS2", "INFO")
|
||||
log_message("=" * 60, "INFO")
|
||||
|
||||
usd_rub_rate = get_usd_to_rub_rate()
|
||||
buff_prices = load_buff_prices()
|
||||
skins_list = fetch_and_prepare_skins()
|
||||
|
||||
log_message("Начинаю генерацию вариантов...", "INFO")
|
||||
final_list, missing_original = generate_full_variants(skins_list, buff_prices, usd_rub_rate)
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(final_list, f, ensure_ascii=False, indent=2)
|
||||
|
||||
priced_count = sum(1 for item in final_list if item.get("price_rub") is not None)
|
||||
log_message(f"Готово! Сохранено {len(final_list)} вариантов", "INFO")
|
||||
log_message(f"С реальной ценой: {priced_count} ({priced_count / len(final_list) * 100:.1f}%)", "INFO")
|
||||
log_message(f"Оценено: {len(final_list) - priced_count} ({(len(final_list) - priced_count) / len(final_list) * 100:.1f}%)", "INFO")
|
||||
log_message(f"Файл: {OUTPUT_FILE}", "INFO")
|
||||
|
||||
save_missing_prices(missing_original)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
log_message("Прервано пользователем", "WARNING")
|
||||
except Exception as e:
|
||||
log_message(f"Критическая ошибка: {e}", "ERROR")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Reference in New Issue
Block a user