# main.py from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel, field_validator, validator from typing import List, Optional, Dict, Any import json from pathlib import Path from collections import Counter, defaultdict import random import sys from tqdm import tqdm app = FastAPI( title="CS2 Trade-Up Contract Simulator API", description="Симуляция контрактов 10→1 по механике CS2", version="1.0.0" ) DATA_FILE = Path("skins.json") CUSTOM_DATA_FILE = Path("custom_items.json") # ─── Загрузка кастомных предметов ───────────────────────────────────────── def load_custom_items() -> List[dict]: """Загружает кастомные предметы из отдельного файла""" custom_items = [] if CUSTOM_DATA_FILE.exists(): try: with CUSTOM_DATA_FILE.open("r", encoding="utf-8") as f: custom_items = json.load(f) print(f"Загружено {len(custom_items)} кастомных предметов") except Exception as e: print(f"Ошибка загрузки кастомных предметов: {e}") else: # Создаем пример файла с кастомными предметами example_custom = [ { "market_hash_name": "★ Dildo | Fade (Factory New)", "weapon": "Dildo", "pattern": "Fade", "rarity": "Covert", "collection": "", "type": "Normal", "wear": "Factory New", "wear_range": [0.00, 0.07], "mid_float_used": 0.01, "doppler_phase": None, "price_usd": 1000.0, "price_rub": 100000.0, "price_source": "custom", "image_url": "", "crates_names": ["create: dildo_case"], "float_range": [0.00, 0.07], "can_be_assigned_from": ["create: dildo_case"], "is_craftable": True }, { "market_hash_name": "★ Dildo | Crimson Web (Minimal Wear)", "weapon": "Dildo", "pattern": "Crimson Web", "rarity": "Covert", "collection": "", "type": "Normal", "wear": "Minimal Wear", "wear_range": [0.07, 0.15], "mid_float_used": 0.10, "doppler_phase": None, "price_usd": 800.0, "price_rub": 80000.0, "price_source": "custom", "image_url": "", "crates_names": ["create: dildo_case"], "float_range": [0.07, 0.15], "can_be_assigned_from": ["create: dildo_case"], "is_craftable": True }, { "market_hash_name": "Dildo | Blue Gem (Factory New)", "weapon": "Dildo", "pattern": "Blue Gem", "rarity": "Classified", "collection": "", "type": "Normal", "wear": "Factory New", "wear_range": [0.00, 0.07], "mid_float_used": 0.01, "doppler_phase": None, "price_usd": 500.0, "price_rub": 50000.0, "price_source": "custom", "image_url": "", "crates_names": ["create: dildo_case"], "float_range": [0.00, 0.07], "can_be_assigned_from": ["create: dildo_case"], "is_craftable": True } ] try: with CUSTOM_DATA_FILE.open("w", encoding="utf-8") as f: json.dump(example_custom, f, ensure_ascii=False, indent=2) print(f"Создан пример файла кастомных предметов: {CUSTOM_DATA_FILE}") custom_items = example_custom except Exception as e: print(f"Не удалось создать файл кастомных предметов: {e}") return custom_items # ─── Генерация sub-вариаций цен и float ─────────────────────────────────── def generate_item_variants(num_variants: int = 6): """Для каждого предмета генерирует sub-вариации с разными ценами и float. Оригиналы остаются на своих местах (индексы 0..N-1 не меняются), sub-вариации добавляются в конец списка (индексы N..N+N*5-1), чтобы не ломать ссылки по индексам (cases.json, база данных). """ global ALL_ITEMS n_originals = len(ALL_ITEMS) sub_variants = [] for idx, item in enumerate(ALL_ITEMS): base_price = item.get("price_rub", 0) wr = item.get("wear_range", [0, 1]) mid = item.get("mid_float_used", 0.5) wr_min, wr_max = wr f_range = max(wr_max - wr_min, 0.001) # v=0 — сам оригинал (оставляем как есть) # v=1..num_variants-1 — sub-вариации (добавляем в конец) for v in range(1, num_variants): variant = dict(item) variant["is_subvariant"] = True t = v / (num_variants - 1) if num_variants > 1 else 0 # price factor: 0.55 to 1.40 across variants factor = 0.55 + t * 0.85 factor += random.uniform(-0.05, 0.05) factor = max(0.30, min(1.50, factor)) p = int(base_price * factor) if p >= 1_000_000: prefix = p // 1000 suffix = random.randint(100, 999) p = prefix * 1000 + suffix elif p >= 100_000: prefix = p // 100 suffix = random.randint(10, 99) p = prefix * 100 + suffix elif p >= 10_000: prefix = p // 10 suffix = random.randint(1, 9) p = prefix * 10 + suffix variant["price_rub"] = max(p, 100) variant["price_usd"] = round(variant["price_rub"] / 100, 2) # float spreads across the wear range float_offset = -0.4 + t * 0.8 float_offset += random.uniform(-0.1, 0.1) new_mid = mid + float_offset * f_range new_mid = max(wr_min + 0.001, min(wr_max - 0.001, new_mid)) variant["mid_float_used"] = round(new_mid, 4) sub_variants.append(variant) ALL_ITEMS = ALL_ITEMS + sub_variants print(f"Сгенерировано {len(sub_variants)} sub-вариаций для {n_originals} предметов (x{num_variants})") print(f"Оригиналы: индексы 0..{n_originals - 1}, sub-вариации: {n_originals}..{len(ALL_ITEMS) - 1}") # ─── Загрузка основных данных ───────────────────────────────────────────── def load_base_items() -> List[dict]: """Загружает основные предметы из skins.json""" try: with DATA_FILE.open("r", encoding="utf-8") as f: items = json.load(f) print(f"Загружено {len(items):,} основных записей") return items except FileNotFoundError: print(f"Критическая ошибка: файл {DATA_FILE} не найден") sys.exit(1) except Exception as e: print(f"Ошибка чтения файла данных: {e}") sys.exit(1) # Загружаем все предметы (основные + кастомные) BASE_ITEMS = load_base_items() CUSTOM_ITEMS = load_custom_items() # Объединяем предметы: сначала основные, потом кастомные ALL_ITEMS = BASE_ITEMS + CUSTOM_ITEMS print(f"Всего предметов после объединения: {len(ALL_ITEMS):,}") # Генерируем sub-вариации цен и float для каждого предмета generate_item_variants(num_variants=6) print(f"Всего предметов после генерации вариаций: {len(ALL_ITEMS):,}") ITEM_BY_ID: Dict[int, dict] = {i: item for i, item in enumerate(ALL_ITEMS)} # ─── Глобальные кэши / индексы ────────────────────────────────────────────── ALL_ITEMS_WITH_ID: List[dict] = [] ITEMS_BY_TYPE: Dict[str, List[dict]] = defaultdict(list) ITEMS_BY_RARITY: Dict[str, List[dict]] = defaultdict(list) NAME_LOWER_TO_IDS: Dict[str, List[int]] = defaultdict(list) COLLECTIONS_INDEX: Dict[str, List[int]] = {} CAN_BE_ASSIGNED_FROM_INDEX: Dict[str, List[int]] = defaultdict(list) # ─── Проверка типа предмета ─────────────────────────────────────────────── def is_knife(item: dict) -> bool: """Проверяет, является ли предмет ножом""" weapon = item.get("weapon", "") return "Knife" in weapon or "Bayonet" in weapon def is_gloves(item: dict) -> bool: """Проверяет, являются ли предмет перчатками""" weapon = item.get("weapon", "") return "Wraps" in weapon or "Gloves" in weapon or "Hand" in weapon def is_rare_special_item(item: dict) -> bool: """Проверяет, является ли предмет Rare Special Item (ножи и перчатки)""" return is_knife(item) or is_gloves(item) # ─── Редкости ────────────────────────────────────────────────────────────── RARITY_ORDER = { "Consumer Grade": 0, "Industrial Grade": 1, "Mil-Spec": 2, "Mil-Spec Grade": 2, "Restricted": 3, "Classified": 4, "Covert": 5, # Базовый уровень для Covert "Rare Special Item": 6, # Уровень для ножей/перчаток (конвертируется из Covert) "Extraordinary": 7, # Для некоторых перчаток "Contraband": 8, "Unknown": -1, } def get_rarity_level(rarity: str | None, item: Optional[dict] = None) -> int: """ Возвращает числовой уровень редкости. Для ножей и перчаток с редкостью Covert возвращает уровень Rare Special Item (6) """ if not rarity: return -1 rarity = rarity.strip() # Если это Extraordinary - это уже правильный уровень if rarity == "Extraordinary": return RARITY_ORDER["Extraordinary"] # Если передан предмет и это Covert if item and rarity == "Covert": # Проверяем, является ли предмет ножом или перчатками if is_rare_special_item(item): return RARITY_ORDER["Rare Special Item"] else: return RARITY_ORDER["Covert"] return RARITY_ORDER.get(rarity, -1) # ─── Модели ──────────────────────────────────────────────────────────────── class ItemShort(BaseModel): id: int market_hash_name: str rarity: Optional[str] = None collection: Optional[str] = None type: Optional[str] = None price_rub: Optional[float] = None price_usd: Optional[float] = None class ContractSubmitRequest(BaseModel): item_ids: List[int] @field_validator("item_ids") @classmethod def check_length(cls, v): if len(v) != 10: raise ValueError("Нужно ровно 10 предметов") return v class TradeUpSimulationResult(BaseModel): success: bool input_items: List[ItemShort] received_item: Optional[ItemShort] = None received_float: Optional[float] = None probability: Optional[float] = None message: str class CollectionItemBase(BaseModel): base_name: str rarity: str item_ids_normal: List[int] item_ids_stattrak: List[int] item_ids_souvenir: List[int] count_variants: int class CollectionDetail(BaseModel): collection: str total_unique_skins: int items: List[CollectionItemBase] # ─── Вспомогательные ─────────────────────────────────────────────────────── def get_item(idx: int) -> Optional[dict]: return ITEM_BY_ID.get(idx) def is_final_in_collection(item: dict) -> bool: """Проверяет, есть ли предметы выше редкостью в той же коллекции/источнике""" my_level = get_rarity_level(item.get("rarity"), item) # Собираем все источники, откуда мог выпасть предмет sources = [] # Из collection coll = item.get("collection", "").strip() if coll and coll != "Без коллекции": sources.append(coll) # Из can_be_assigned_from assigned = item.get("can_be_assigned_from", []) if isinstance(assigned, list): sources.extend(assigned) elif isinstance(assigned, str) and assigned: sources.append(assigned) # Из crates_names (кейсы) crates = item.get("crates_names", []) if crates and isinstance(crates, list): for crate in crates: sources.append(f"Case: {crate}") # Проверяем каждый источник for source in sources: # Проверяем в COLLECTIONS_INDEX for idx in COLLECTIONS_INDEX.get(source, []): other = get_item(idx) if other: other_level = get_rarity_level(other.get("rarity"), other) if other_level > my_level: return False # Проверяем в CAN_BE_ASSIGNED_FROM_INDEX for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source, []): other = get_item(idx) if other: other_level = get_rarity_level(other.get("rarity"), other) if other_level > my_level: return False return True def can_be_upgraded(items: List[dict]) -> tuple[bool, str]: """Проверяет, можно ли использовать эти предметы в контракте""" if len(items) != 10: return False, "Нужно ровно 10 предметов" # Проверяем is_craftable for item in items: if not item.get("is_craftable", True): return False, f"Предмет нельзя использовать в крафте: {item.get('market_hash_name', '???')}" # Проверяем, что ни один предмет не является финальным for item in items: if is_final_in_collection(item): return False, f"Нельзя использовать финальный предмет коллекции: {item.get('market_hash_name', '???')}" # Проверяем тип (Normal/StatTrak/Souvenir) types = {it.get("type") for it in items if it.get("type")} if len(types) != 1: return False, "Все предметы должны быть одного типа" # Проверяем редкость с учетом типа предмета rarities = set() for it in items: level = get_rarity_level(it.get("rarity"), it) rarities.add(level) if len(rarities) != 1: return False, "Все предметы должны быть одной редкости" return True, "" def extract_base_name(market_hash_name: str) -> str: """Извлекает базовое имя скина без StatTrak, Souvenir и качества""" name = market_hash_name.strip() # Убираем префиксы for prefix in ["StatTrak™ ", "Souvenir "]: if name.startswith(prefix): name = name[len(prefix):].strip() # Убираем качество в скобках if " (" in name: name = name.split(" (", 1)[0].strip() return name def build_indexes(): """Строит все индексы""" global COLLECTIONS_INDEX, CAN_BE_ASSIGNED_FROM_INDEX coll = defaultdict(list) assigned_from = defaultdict(list) for idx, item in ITEM_BY_ID.items(): # Индекс коллекций c = item.get("collection", "").strip() if c and c != "Без коллекции": coll[c].append(idx) # Индекс can_be_assigned_from assigned = item.get("can_be_assigned_from", []) if isinstance(assigned, list): for source in assigned: assigned_from[source].append(idx) elif isinstance(assigned, str) and assigned: assigned_from[assigned].append(idx) # Индекс кейсов (crates_names) crates = item.get("crates_names", []) if crates and isinstance(crates, list): for crate in crates: collection_name = f"Case: {crate}" coll[collection_name].append(idx) COLLECTIONS_INDEX = dict(coll) CAN_BE_ASSIGNED_FROM_INDEX = dict(assigned_from) def load_and_index_data(): """Индексирует все данные для быстрого поиска""" global ALL_ITEMS_WITH_ID, ITEMS_BY_TYPE, ITEMS_BY_RARITY, NAME_LOWER_TO_IDS ALL_ITEMS_WITH_ID = [] ITEMS_BY_TYPE.clear() ITEMS_BY_RARITY.clear() NAME_LOWER_TO_IDS.clear() print("Создание индексов...") for i in tqdm(range(len(ALL_ITEMS)), desc="Индексация"): item = ALL_ITEMS[i] item_with_id = {**item, "id": i} ALL_ITEMS_WITH_ID.append(item_with_id) ITEM_BY_ID[i] = item # По типу t = item.get("type", "Normal") ITEMS_BY_TYPE[t].append(item_with_id) # По редкости r = item.get("rarity", "Unknown") ITEMS_BY_RARITY[r].append(item_with_id) # Поиск по имени name = item.get("market_hash_name", "").lower().strip() if name: NAME_LOWER_TO_IDS[name].append(i) base_name = extract_base_name(item.get("market_hash_name", "")).lower().strip() if base_name and base_name != name: NAME_LOWER_TO_IDS[base_name].append(i) # Строим индексы коллекций build_indexes() print(f"Готово. Уникальных имён для поиска: {len(NAME_LOWER_TO_IDS):,}") print(f"Типов: {len(ITEMS_BY_TYPE)}") print(f"Редкостей: {len(ITEMS_BY_RARITY)}") print(f"Коллекций/кейсов: {len(COLLECTIONS_INDEX)}") print(f"Источников (can_be_assigned_from): {len(CAN_BE_ASSIGNED_FROM_INDEX)}") def warmup_and_debug_data(): """Диагностика данных при старте""" print("\n" + "="*80) print("ПРОГРЕВ ДАННЫХ И ДИАГНОСТИКА") print("="*80) rarities = Counter() types_cnt = Counter() knife_count = 0 glove_count = 0 for item in ALL_ITEMS: rarities[item.get("rarity", "Без редкости")] += 1 types_cnt[item.get("type", "Без типа")] += 1 if is_knife(item): knife_count += 1 elif is_gloves(item): glove_count += 1 print(f"Ножей: {knife_count:,}") print(f"Перчаток: {glove_count:,}") print(f"Rare Special Items всего: {knife_count + glove_count:,}") print("\nРедкости:") for r, c in sorted(rarities.items(), key=lambda x: x[1], reverse=True): print(f" {r:24} → {c:6,}") print("\nТипы:") for t, c in types_cnt.most_common(5): print(f" {t:24} → {c:6,}") # Проверяем Covert предметы covert_items = [it for it in ALL_ITEMS if it.get("rarity") == "Covert"] covert_knives = [it for it in covert_items if is_knife(it)] covert_skins = [it for it in covert_items if not is_knife(it) and not is_gloves(it)] print(f"\nCovert предметы:") print(f" Всего: {len(covert_items):,}") print(f" Ножи: {len(covert_knives):,}") print(f" Обычные скины: {len(covert_skins):,}") # Проверяем кейсы print(f"\nКейсов (Case: *): {sum(1 for k in COLLECTIONS_INDEX.keys() if k.startswith('Case:')):,}") print("="*80 + "\n") # Загружаем и индексируем данные load_and_index_data() warmup_and_debug_data() def calculate_average_float(items: List[dict]) -> float: """Вычисляет средний float из входных предметов""" if not items: return 0.5 return sum(it.get("mid_float_used", 0.5) for it in items) / len(items) def get_possible_outcomes(items: List[dict]) -> tuple[List[tuple], Dict[str, float]]: """ Определяет возможные исходы контракта и их вероятности. Возвращает список (item_id, probability) и словарь весов источников. """ input_type = items[0].get("type", "Normal") first_item = items[0] # Определяем текущий уровень редкости current_level = get_rarity_level(first_item.get("rarity"), first_item) target_level = current_level + 1 # Собираем источники из входных предметов source_counter = Counter() for it in items: # Из can_be_assigned_from assigned = it.get("can_be_assigned_from", []) if isinstance(assigned, list): for source in assigned: source_counter[source] += 1 elif isinstance(assigned, str) and assigned: source_counter[assigned] += 1 # Из collection coll = it.get("collection", "").strip() if coll and coll != "Без коллекции": source_counter[coll] += 1 # Из crates_names crates = it.get("crates_names", []) if crates and isinstance(crates, list): for crate in crates: source_counter[f"Case: {crate}"] += 1 total = sum(source_counter.values()) if total == 0: return [], {} # Веса источников пропорциональны количеству предметов из них source_weights = {s: cnt / total for s, cnt in source_counter.items()} # Группируем возможные исходы по базовому имени base_to_ids = defaultdict(list) for source_name in source_weights.keys(): # Проверяем в COLLECTIONS_INDEX for idx in COLLECTIONS_INDEX.get(source_name, []): cand = get_item(idx) if not cand: continue # Проверяем уровень редкости cand_level = get_rarity_level(cand.get("rarity"), cand) if cand_level != target_level: continue # Проверяем тип if cand.get("type") != input_type: continue base = extract_base_name(cand["market_hash_name"]) base_to_ids[base].append(idx) # Проверяем в CAN_BE_ASSIGNED_FROM_INDEX for idx in CAN_BE_ASSIGNED_FROM_INDEX.get(source_name, []): cand = get_item(idx) if not cand: continue cand_level = get_rarity_level(cand.get("rarity"), cand) if cand_level != target_level: continue if cand.get("type") != input_type: continue base = extract_base_name(cand["market_hash_name"]) base_to_ids[base].append(idx) if not base_to_ids: return [], source_weights # Равномерный шанс на каждый уникальный базовый скин num_unique = len(base_to_ids) prob_per_base = 1.0 / num_unique possible_outcomes = [] for base, ids_list in base_to_ids.items(): prob_per_id = prob_per_base / len(ids_list) for idx in ids_list: possible_outcomes.append((idx, prob_per_id)) return possible_outcomes, source_weights def simulate_trade_up(items: List[dict]) -> TradeUpSimulationResult: """Симулирует контракт обмена 10→1""" # Подготовка input_items input_short = [] for it in items: if isinstance(it, dict): enriched = it.copy() else: enriched = {} if "id" not in enriched: enriched["id"] = -1 try: short = ItemShort(**enriched) input_short.append(short) except Exception: input_short.append(ItemShort( id=enriched.get("id", -1), market_hash_name=enriched.get("market_hash_name", "???") )) ok, msg = can_be_upgraded(items) if not ok: return TradeUpSimulationResult( success=False, input_items=input_short, received_item=None, received_float=None, probability=None, message=msg ) possible_outcomes, source_weights = get_possible_outcomes(items) if not possible_outcomes: input_type = items[0].get("type", "Normal") first_item = items[0] current_level = get_rarity_level(first_item.get("rarity"), first_item) target_level = current_level + 1 # Определяем название целевой редкости rarity_names = {v: k for k, v in RARITY_ORDER.items()} target_rarity_name = rarity_names.get(target_level, f"уровень {target_level}") return TradeUpSimulationResult( success=False, input_items=input_short, received_item=None, received_float=None, probability=None, message=f"Нет {input_type} предметов редкостью {target_rarity_name} в выбранных источниках" ) # Выбираем случайный предмет indices = [o[0] for o in possible_outcomes] weights = [o[1] for o in possible_outcomes] chosen_idx = random.choices(indices, weights=weights, k=1)[0] received = get_item(chosen_idx) # Вычисляем float (среднее арифметическое входных float) avg_float = calculate_average_float(items) # Находим вероятность выпавшего предмета probability = next((p for idx, p in possible_outcomes if idx == chosen_idx), 0) return TradeUpSimulationResult( success=True, input_items=input_short, received_item=ItemShort(**{**received, "id": chosen_idx}), received_float=round(avg_float, 4), probability=round(probability * 100, 2), message="Контракт успешен" ) # ─── Эндпоинты ───────────────────────────────────────────────────────────── @app.get("/items") def get_all_items( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), search: Optional[str] = Query(None), id: Optional[int] = Query(None), item_type: Optional[str] = Query(None), rarity: Optional[str] = Query(None), min_price: Optional[float] = Query(None, ge=0), max_price: Optional[float] = Query(None, ge=0), wear: Optional[str] = Query(None), ): """Возвращает список предметов с фильтрацией и поиском""" if id is not None: item = get_item(id) if item is None: return [] return [ItemShort(**{**item, "id": id})] # Базовый набор кандидатов if item_type and item_type.lower() != "all": candidates = ITEMS_BY_TYPE.get(item_type, []) else: candidates = ALL_ITEMS_WITH_ID # Фильтр по редкости if rarity: r_norm = rarity.strip() candidates = [it for it in candidates if it.get("rarity", "").strip() == r_norm] # Поиск по имени if search: query = search.lower().strip() found_ids = set() # Точное совпадение if query in NAME_LOWER_TO_IDS: found_ids.update(NAME_LOWER_TO_IDS[query]) # Частичное совпадение по словам search_words = query.split() for name_lower, ids in NAME_LOWER_TO_IDS.items(): if all(word in name_lower for word in search_words): found_ids.update(ids) elif name_lower.startswith(query): found_ids.update(ids) if not found_ids: return [] candidates = [it for it in candidates if it["id"] in found_ids] # Фильтры по цене и wear filtered = candidates if min_price is not None or max_price is not None: filtered = [ it for it in filtered if it.get("price_rub") is not None and (min_price is None or it["price_rub"] >= min_price) and (max_price is None or it["price_rub"] <= max_price) ] if wear: wear_norm = wear.strip() filtered = [it for it in filtered if it.get("wear", "").strip() == wear_norm] # Пагинация sliced = filtered[skip:skip + limit] return [ItemShort(**it) for it in sliced] @app.get("/filters") def get_filters(): """Возвращает доступные значения для фильтров""" rarities = set() types = set() wears = set() price_range = {"min": float('inf'), "max": float('-inf')} knife_count = 0 glove_count = 0 normal_count = 0 for item in ALL_ITEMS: if item.get("rarity"): rarities.add(item["rarity"]) if item.get("type"): types.add(item["type"]) if item.get("wear"): wears.add(item["wear"]) if item.get("price_rub"): price = item["price_rub"] if price < price_range["min"]: price_range["min"] = price if price > price_range["max"]: price_range["max"] = price if is_knife(item): knife_count += 1 elif is_gloves(item): glove_count += 1 else: normal_count += 1 return { "rarities": sorted(list(rarities)), "types": sorted(list(types)), "wears": sorted(list(wears)), "price_range": { "min_rub": price_range["min"] if price_range["min"] != float('inf') else 0, "max_rub": price_range["max"] if price_range["max"] != float('-inf') else 0 }, "statistics": { "total_items": len(ALL_ITEMS), "knives": knife_count, "gloves": glove_count, "normal_skins": normal_count, "note": "Ножи и перчатки нельзя использовать в trade-up контрактах" } } @app.post("/contract/submit", response_model=TradeUpSimulationResult) def submit_contract(req: ContractSubmitRequest): """Выполняет симуляцию контракта обмена""" try: selected = [] for idx in req.item_ids: item = get_item(idx) if item is None: raise HTTPException(400, detail=f"id {idx} не найден") item_with_id = item.copy() item_with_id["id"] = idx selected.append(item_with_id) return simulate_trade_up(selected) except Exception as e: import traceback traceback.print_exc() raise HTTPException(500, detail=str(e)) @app.get("/collections/") def get_collections_list(): """Возвращает список всех коллекций и источников""" result = [] all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys()) for name in sorted(all_sources): ids = set() if name in COLLECTIONS_INDEX: ids.update(COLLECTIONS_INDEX[name]) if name in CAN_BE_ASSIGNED_FROM_INDEX: ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name]) unique_bases = set() for idx in ids: item = get_item(idx) if item: base = extract_base_name(item.get("market_hash_name", "")) if base: unique_bases.add(base) result.append({ "name": name, "total_items": len(ids), "unique_skins": len(unique_bases), "is_case": name.startswith("Case: ") }) return { "total_collections": len(result), "collections": result } @app.get("/collections/all") def get_collection_names(): """Возвращает подробную информацию о всех коллекциях""" def rarity_key(idx: int) -> int: item = get_item(idx) return get_rarity_level(item.get("rarity"), item) if item else -1 result = [] all_sources = set(COLLECTIONS_INDEX.keys()) | set(CAN_BE_ASSIGNED_FROM_INDEX.keys()) for source_name in sorted(all_sources): ids = set() if source_name in COLLECTIONS_INDEX: ids.update(COLLECTIONS_INDEX[source_name]) if source_name in CAN_BE_ASSIGNED_FROM_INDEX: ids.update(CAN_BE_ASSIGNED_FROM_INDEX[source_name]) base_names = set() items_info = [] for i in ids: item = get_item(i) if item: base = extract_base_name(item["market_hash_name"]) if base: base_names.add(base) items_info.append({ "id": i, "name": item.get("market_hash_name", "Unknown"), "rarity": item.get("rarity", "Unknown"), "type": item.get("type", "Normal"), "wear": item.get("wear", "Unknown"), "price_rub": item.get("price_rub"), "price_usd": item.get("price_usd"), "is_craftable": item.get("is_craftable", True) }) sorted_ids = sorted(ids, key=rarity_key) items_info.sort(key=lambda x: ( get_rarity_level(x["rarity"], get_item(x["id"])), x["name"] )) result.append({ "name": source_name, "unique_skin_count": len(base_names), "total_variants": len(ids), "item_ids_by_rarity": sorted_ids, "items": items_info }) return result @app.get("/collection/{name}", response_model=CollectionDetail) def get_one_collection(name: str): """Возвращает детальную информацию о конкретной коллекции/источнике""" ids = set() if name in COLLECTIONS_INDEX: ids.update(COLLECTIONS_INDEX[name]) if name in CAN_BE_ASSIGNED_FROM_INDEX: ids.update(CAN_BE_ASSIGNED_FROM_INDEX[name]) if not ids: raise HTTPException(404, detail="Коллекция или источник не найдены") groups = defaultdict(list) for idx in ids: item = get_item(idx) if not item: continue base = extract_base_name(item["market_hash_name"]) if not base: continue groups[base].append(( item.get("mid_float_used", 999.9), idx, item.get("type", "Normal"), item.get("rarity", "Unknown") )) result_items = [] for base_name, variants in sorted(groups.items()): rarity = next((v[3] for v in variants if v[3] != "Unknown"), "Unknown") normal = [v[1] for v in sorted(variants) if v[2] == "Normal"] stattrak = [v[1] for v in sorted(variants) if v[2] == "StatTrak"] souvenir = [v[1] for v in sorted(variants) if v[2] == "Souvenir"] result_items.append(CollectionItemBase( base_name=base_name, rarity=rarity, item_ids_normal=normal, item_ids_stattrak=stattrak, item_ids_souvenir=souvenir, count_variants=len(variants) )) return CollectionDetail( collection=name, total_unique_skins=len(result_items), items=result_items ) @app.get("/health") def health(): """Проверка работоспособности сервера""" return { "status": "ok", "items_loaded": len(ALL_ITEMS), "base_items": len(BASE_ITEMS), "custom_items": len(CUSTOM_ITEMS) } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=13668)