From c083fc26ad71ab3a11de8198afd7dce066879a45 Mon Sep 17 00:00:00 2001 From: vasich Date: Mon, 19 Jan 2026 22:17:38 +0700 Subject: [PATCH] craft_parser_init --- .gitignore | 5 + craft_parser/craft.py | 26 +++ craft_parser/craft_facade.py | 71 +++++++++ craft_parser/craft_storage.py | 122 +++++++++++++++ craft_parser/device_extractor.py | 261 +++++++++++++++++++++++++++++++ craft_parser/dto.py | 23 +++ craft_parser/main.py | 17 ++ craft_parser/notebook.ipynb | 222 ++++++++++++++++++++++++++ craft_parser/simple_extractor.py | 231 +++++++++++++++++++++++++++ 9 files changed, 978 insertions(+) create mode 100644 .gitignore create mode 100644 craft_parser/craft.py create mode 100644 craft_parser/craft_facade.py create mode 100644 craft_parser/craft_storage.py create mode 100644 craft_parser/device_extractor.py create mode 100644 craft_parser/dto.py create mode 100644 craft_parser/main.py create mode 100644 craft_parser/notebook.ipynb create mode 100644 craft_parser/simple_extractor.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7b02ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.xlsx +*.csv +.mypy_cache +__pycache__ +*.log \ No newline at end of file diff --git a/craft_parser/craft.py b/craft_parser/craft.py new file mode 100644 index 0000000..afeecde --- /dev/null +++ b/craft_parser/craft.py @@ -0,0 +1,26 @@ +CRAFT_TYPES = [] + + + +class CraftItem: + def __init__(self, name: str, img_url: str, img_shift: tuple[int, int], is_base=False, ): + self.name = name + self.is_base = is_base + self.img_url = img_url + self.img_shift = img_shift + + +class CraftRecipe: + def __init__(self, output_item_name: str, output_count: int, craft_type: str): + self.output_item_name = output_item_name + self.output_count = output_count + self.craft_type = craft_type + self.recipe_id = None # будет назначен при сохранении + + +class CraftComponent: + def __init__(self, recipe_id: int, input_item: str, count: int): + self.recipe_id = recipe_id + self.input_item = input_item + self.count = count + diff --git a/craft_parser/craft_facade.py b/craft_parser/craft_facade.py new file mode 100644 index 0000000..64fd569 --- /dev/null +++ b/craft_parser/craft_facade.py @@ -0,0 +1,71 @@ +import sys +import time + +from bs4 import BeautifulSoup +import requests + +from craft_storage import CraftStorage +import device_extractor +import simple_extractor +from loguru import logger + +class CraftFacade: + + def __init__(self): + self.visited: set[str] = set() + logger.add('file_{time}.log') + logger.add(sys.stdout, colorize=True, format="{time} {message}") + + # ---------- low level ---------- + + def _get_page_content(self, url: str) -> str | None: + time.sleep(0.5) + try: + r = requests.get(url) + r.raise_for_status() + return r.text + except requests.RequestException as e: + logger.error(f'Ошибка загрузки {url}: {e}') + return None + + def _get_soup(self, html: str) -> BeautifulSoup: + return BeautifulSoup(html, "html.parser") + + + # ---------- public API ---------- + + def extract(self, url: str, storage: CraftStorage, recursive: bool = True): + if not url: + logger.error('Битая ссылка') + return + if url in self.visited: + logger.debug(f'Уже посещали: {url}') + return + + html = self._get_page_content(url) + if not html: + return + + soup = self._get_soup(html) + logger.info(f'Анализирую {url}') + + self.visited.add(url) + + all_links: set[str] = set() + + try: + links = simple_extractor.extract_crafts(soup, storage) + all_links.update(links) + links = device_extractor.extract_crafts(soup, storage) + all_links.update(all_links) + except Exception as e: + logger.error(f'Ошибка в парсере: {e}') + + logger.info(f'Готово: {url}') + + if not recursive: + return + + logger.info('Начинаю обход внешних ссылок...') + for link in all_links: + self.extract(link, storage, recursive=True) \ No newline at end of file diff --git a/craft_parser/craft_storage.py b/craft_parser/craft_storage.py new file mode 100644 index 0000000..e600a5b --- /dev/null +++ b/craft_parser/craft_storage.py @@ -0,0 +1,122 @@ +import os +import pandas as pd + +from craft import CraftComponent, CraftItem, CraftRecipe +from loguru import logger + +class CraftStorage: + def __init__(self): + # items + self.items_df = pd.DataFrame(columns=[ + "name", "img_url", "is_base", "img_shift" + ]) + + ##Чтобы на конкат не орал + self.items_df["is_base"] = self.items_df["is_base"].astype('bool') + + # recipes + self.recipes_df = pd.DataFrame(columns=[ + "recipe_id", "output_item_name", "output_count", "craft_type" + ]) + + # components + self.components_df = pd.DataFrame(columns=[ + "recipe_id", "input_item", "count" + ]) + self.recipe_signatures = set() + self._recipe_id_seq = 1 + + def try_add_recipe_signature(self, output_item_name: str, components: list[CraftComponent], craft_type: str) -> bool: + # сортируем, чтобы порядок не влиял + parts = sorted( + (c.input_item, c.count) for c in components + ) + sig = (output_item_name, tuple(parts), craft_type) + if sig in self.recipe_signatures: + logger.warning(f'Дубликат рецепта {output_item_name}. Пропускаю ') + #print(f'CRAFT: {}') + # for el in components: + # print(f'{el.input_item} : {el.count}') + # print('Пропускаю') + # print('....') + return False + + self.recipe_signatures.add(sig) + return True + + + def add_item(self, item: CraftItem): + # проверяем, есть ли уже такой item + if item.name in self.items_df["name"].values: + logger.warning(f'{item.name} уже есть в датасете, скипаю') + return # уже есть, не дублируем + + row = { + "name": item.name, + "img_url": item.img_url, + "is_base": bool(item.is_base), + "img_shift": item.img_shift + } + + self.items_df = pd.concat( + [self.items_df, pd.DataFrame([row])], + ignore_index=True + ) + + + def add_recipe(self, recipe: CraftRecipe) -> int: + recipe_id = self._recipe_id_seq + self._recipe_id_seq += 1 + + recipe.recipe_id = recipe_id + + row = { + "recipe_id": recipe_id, + "output_item_name": recipe.output_item_name, + "output_count": recipe.output_count, + "craft_type": recipe.craft_type + } + + self.recipes_df = pd.concat( + [self.recipes_df, pd.DataFrame([row])], + ignore_index=True + ) + + return recipe_id + + + + def add_component(self, component: CraftComponent): + # защита от дубликатов + exists = ( + (self.components_df["recipe_id"] == component.recipe_id) & + (self.components_df["input_item"] == component.input_item) & + (self.components_df['count'] == component.count) + ).any() + + if exists: + logger.warning(f""" + Уже существует такая запись для рецепта: {component.recipe_id} <--- {component.input_item}*{component.count} + Скипаем + """) + return + + row = { + "recipe_id": component.recipe_id, + "input_item": component.input_item, + "count": component.count + } + + self.components_df = pd.concat( + [self.components_df, pd.DataFrame([row])], + ignore_index=True + ) + + def save(self): + os.makedirs('data', exist_ok=True) + self.items_df.to_csv('data/items.csv', index=False) + self.recipes_df.to_csv('data/recipes.csv', index=False) + self.components_df.to_csv('data/recipe_components.csv', index=False) + self.items_df.to_excel('data/items.xlsx', index=False) + self.recipes_df.to_excel('data/recipes.xlsx', index=False) + self.components_df.to_excel('data/recipe_components.xlsx', index=False) diff --git a/craft_parser/device_extractor.py b/craft_parser/device_extractor.py new file mode 100644 index 0000000..0c30e63 --- /dev/null +++ b/craft_parser/device_extractor.py @@ -0,0 +1,261 @@ +import re +from bs4 import BeautifulSoup + +from craft import CraftComponent, CraftItem, CraftRecipe +from craft_storage import CraftStorage +from dto import ParsedItem, ParsedRecipeInput, SpriteData +from loguru import logger + + +def extract_crafts(soup: BeautifulSoup, storage: CraftStorage) -> set[str]: + + craft_containers = soup.find_all("div", class_="craft-gui") + if(craft_containers.__len__() < 1): + logger.info('Машинных рецептов не найдено') + return set() + + src_links : set[str] = set() + + for container in craft_containers: + is_hidden = check_is_hidden(container) + if(is_hidden): + continue + + output_item = parse_craft_item(container) + # Получаем список рецептов (может быть несколько для печи) + recipes_list = parse_craft_components_and_recipe( + container, output_item.item.name, output_item.amount + ) + + for recipe_input in recipes_list: # Обрабатываем каждый рецепт отдельно + if not storage.try_add_recipe_signature( + output_item.item.name, + recipe_input.components, + recipe_input.recipe.craft_type + ): + continue # Дубликат + + storage.add_item(output_item.item) + for input_item in recipe_input.items: + storage.add_item(input_item) + + recipe_id = storage.add_recipe(recipe_input.recipe) + for component in recipe_input.components: + component.recipe_id = recipe_id + storage.add_component(component) + + logger.info(f'Добавлен рецепт машинного крафта для {output_item.item.name} ' + f'(ингредиент: {recipe_input.components[0].input_item})') + + return src_links + +def check_is_hidden(container : BeautifulSoup) -> bool: + if(container.find_parent('table', class_='collapsed')): + logger.warning(f'{container} Не будет исследован. Причина: помечен как скрытый') + return True + return False + +def parse_craft_item(container) -> ParsedItem|None: + output_span = container.find('span', class_='gt-output') + if(output_span is None): + logger.error(f'ошибка для \n{container}\n: не найдено ячейки с результатом!') + return None + + output_span_name_container = output_span.find('span', class_='invslot-item') + if(output_span_name_container is None): + logger.error(f'ошибка для \n{output_span}\n: не найден текстовый контейнер!') + return None + + + data_from_span = extract_data_from_sprite_span(output_span_name_container) + if(data_from_span is not None): + output_item_title = data_from_span.title + output_item_img_shift = data_from_span.shift + output_item_img_url = data_from_span.img_url + output_amount = data_from_span.amount + else: + data_from_img = extract_data_from_sprite_img(output_span_name_container) + if(data_from_img is None): + logger.error(f'ошибка для \n{output_span_name_container}\n: не удалось извлечь картинку и заголовок ни одним из способов') + return None + output_item_title = data_from_img.title + output_item_img_shift = data_from_img.shift + output_item_img_url = data_from_img.img_url + output_amount = 1 + + item = CraftItem(output_item_title, output_item_img_url, output_item_img_shift) + return ParsedItem(item=item, amount=output_amount) + +def parse_craft_components_and_recipe(container, output_item_name: str, output_item_count: int) -> list[ParsedRecipeInput]: + input_span = container.find('span', class_='gt-input') + if not input_span: + logger.error(f'Ошибка: нет блока gt-input в {container}') + return [] + + recipe_info = extract_recipe_type(container) + if not recipe_info: + return [] + + is_furnace = 'Печь' in recipe_info + recipes = [] # Список рецептов + + if is_furnace: + # Для печи каждый предмет в левом слоте — отдельный рецепт + left_cell = input_span.find('span', class_='invslot') + if left_cell: + item_spans = left_cell.find_all('span', class_='invslot-item') + for item_span in item_spans: + data = extract_data_from_sprite_span(item_span) or extract_data_from_sprite_img(item_span) + if data: + item = CraftItem(data.title, data.img_url, data.shift) + # Создаём отдельный рецепт для каждого ингредиента + recipe = CraftRecipe(output_item_name, output_item_count, recipe_info) + component = CraftComponent(-1, item.name, data.amount) + recipes.append(ParsedRecipeInput( + items=[item], + recipe=recipe, + components=[component], + source_links=[] + )) + else: + # Для других машин — один рецепт со всеми ингредиентами + item_spans = input_span.find_all('span', class_='invslot-item') + items = [] + components = [] + for item_span in item_spans: + data = extract_data_from_sprite_span(item_span) or extract_data_from_sprite_img(item_span) + if data: + + + item = CraftItem(data.title, data.img_url, data.shift) + + ##skip ic2 stuff + if(item.name=='Энергия'): + continue + + items.append(item) + components.append(CraftComponent(-1, item.name, data.amount)) + + recipe = CraftRecipe(output_item_name, output_item_count, recipe_info) + recipes.append(ParsedRecipeInput( + items=items, + recipe=recipe, + components=components, + source_links=[] + )) + + return recipes + + + +def extract_recipe_type(container) -> str|None: + processor_container = container.find('span', class_='minetip') + if(processor_container is None): + logger.error(f'Ошибка для {container}: не найден тип машинного рецепта') + return None + return clean_recipe_type_str(processor_container.get('data-minetip-text')) + +def clean_recipe_type_str(input: str): + pattern = r"\s*//&7Модификация:/.*$" + + input = input.replace('&3','') + input = re.sub(pattern, "", input).strip() + return input + + +def extract_data_from_sprite_img(input_span_name_container) -> SpriteData|None: + input_item_img = input_span_name_container.find('img') + if(input_item_img is None): + return None + + title = input_item_img.get('alt') + if(title is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найдено описание картинки!') + return None + + url = input_item_img.get('src') + if(url is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найден url картинки!') + return None + return SpriteData( + title=title, + shift=(0, 0), + img_url=url, + source_link=None) + +def extract_data_from_sprite_span(input_span_name_container) -> SpriteData|None: + input_item_sprite_span = input_span_name_container.find('span', class_='sprite') + + + #Контейнера со спрайтом может не быть! + if(input_item_sprite_span is None): + return None + + input_item_title = input_item_sprite_span.get('title') + if(input_item_title is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найдено заголовка предмета') + return None + + + amount_container = input_span_name_container.find('span', class_='invslot-stacksize') + output_amount = 1 + if(amount_container is not None): + logger.info(f'Для объекта {input_item_title} найдено количество : {amount_container.text}') + output_amount = int(amount_container.text) + + + item_source_link = get_item_source_link(input_span_name_container) + + input_item_img_url = extract_img_classname(input_item_sprite_span) + if(input_item_img_url is None): + logger.error(f'Ошибка для \n{input_item_img_url}\n: не найден файл спрайта') + return None + + input_item_img_shift = extract_img_shift(input_item_sprite_span) + if(input_item_img_shift is None): + logger.error(f'Ошибка для \n{input_item_sprite_span}\n: не найдено смещения для спрайта') + return None + + return SpriteData( + title=input_item_title, + shift=input_item_img_shift, + img_url=input_item_img_url, + source_link=item_source_link, + amount=output_amount + ) + +def extract_img_shift(output_item_sprite_span): + if(output_item_sprite_span is None): + return None + css_value = output_item_sprite_span.get('style') + if(css_value is None): + return None + # Шаблон: ищем два числа (с возможным минусом) перед 'px' + pattern = r'(-?\d+)px\s+(-?\d+)px' + match = re.search(pattern, css_value) + + if match: + x = int(match.group(1)) + y = int(match.group(2)) + return (x, y) + else: + return None # Если шаблон не найден + +def extract_img_classname(sprite_span): + if(sprite_span is None): + return None + 'sprite industrialcraft-2-inv-sprite' + classes = sprite_span.get('class', []) + for cls in classes: + if cls.endswith('-sprite'): + return cls + return None # Если не найдено + +def get_item_source_link(input_span_name_container) -> str|None: + link_container = input_span_name_container.find('a') + if(link_container is None): + return None + link = link_container.get('href') + if(link is None): + return None + return f'https://ru.minecraft.wiki{link}' diff --git a/craft_parser/dto.py b/craft_parser/dto.py new file mode 100644 index 0000000..144d0a7 --- /dev/null +++ b/craft_parser/dto.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple +from craft import CraftItem, CraftComponent, CraftRecipe + +@dataclass +class ParsedItem: + item: CraftItem + amount: int + +@dataclass +class ParsedRecipeInput: + items: List[CraftItem] + recipe: CraftRecipe + components: List[CraftComponent] + source_links: List[str] + +@dataclass +class SpriteData: + amount: int + title: str + shift: Tuple[int, int] + img_url: str + source_link: Optional[str] = None diff --git a/craft_parser/main.py b/craft_parser/main.py new file mode 100644 index 0000000..cd42dbd --- /dev/null +++ b/craft_parser/main.py @@ -0,0 +1,17 @@ +from craft_facade import CraftFacade +from craft_storage import CraftStorage + + + + + +def main(): + url = 'https://ru.minecraft.wiki/w/IndustrialCraft_2/Термальная_центрифуга' + storage = CraftStorage() + facade = CraftFacade() + facade.extract(url, storage, recursive=False) + storage.save() + + +if __name__ == "__main__": + main() diff --git a/craft_parser/notebook.ipynb b/craft_parser/notebook.ipynb new file mode 100644 index 0000000..6a9a4a0 --- /dev/null +++ b/craft_parser/notebook.ipynb @@ -0,0 +1,222 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "d3b20ae9", + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "from bs4 import BeautifulSoup\n", + "from collections import defaultdict, deque\n", + "from urllib.parse import urljoin, urlparse\n", + "\n", + "from craft import CraftComponent, CraftItem, CraftRecipe\n", + "from craft_storage import CraftStorage\n", + "from collections import Counter\n", + "import simple_extractor\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6189c095", + "metadata": {}, + "outputs": [], + "source": [ + "def get_page_content(url):\n", + " \"\"\"Загружает HTML-контент страницы.\"\"\"\n", + " try:\n", + " response = requests.get(url)\n", + " response.raise_for_status()\n", + " return response.text\n", + " except requests.RequestException as e:\n", + " print(f\"Ошибка при загрузке страницы: {e}\")\n", + " return None\n", + " \n", + "def get_soup(content):\n", + " soup = BeautifulSoup(content, \"html.parser\")\n", + " return soup" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3ea684bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Анализирую https://ru.minecraft.wiki/w/IndustrialCraft_2/Квантовая_броня....\n", + "Укреплённое стекло уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Улучшенная электросхема уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовый шлем\n", + "Композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовый жилет\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Основной корпус машины уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Светопыль уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовые поножи\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Резиновые ботинки уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовые ботинки\n", + "Квантовый шлем уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Улучшенная электросхема уже есть в датасете, скипаю\n", + "Укреплённое стекло уже есть в датасете, скипаю\n", + "Улучшенная электросхема уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовый шлем\n", + "Квантовый жилет уже есть в датасете, скипаю\n", + "Композит уже есть в датасете, скипаю\n", + "Композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовый жилет\n", + "Квантовые поножи уже есть в датасете, скипаю\n", + "Основной корпус машины уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Основной корпус машины уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Светопыль уже есть в датасете, скипаю\n", + "Светопыль уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовые поножи\n", + "Квантовые ботинки уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Иридиевый композит уже есть в датасете, скипаю\n", + "Резиновые ботинки уже есть в датасете, скипаю\n", + "Лазуротроновый кристалл уже есть в датасете, скипаю\n", + "Резиновые ботинки уже есть в датасете, скипаю\n", + "Добавлен рецепт крафта для предмета Квантовые ботинки\n", + "Закончен поиск по странице https://ru.minecraft.wiki/w/IndustrialCraft_2/Квантовая_броня\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n", + "d:\\Development\\Python\\craft_calc\\craft_storage.py:56: FutureWarning: In a future version, object-dtype columns with all-bool values will not be included in reductions with bool_only=True. Explicitly cast to bool dtype instead.\n", + " self.items_df = pd.concat(\n" + ] + } + ], + "source": [ + "url = 'https://ru.minecraft.wiki/w/IndustrialCraft_2/Квантовая_броня'\n", + "content = get_page_content(url)\n", + "soup = get_soup(content)\n", + "storage = CraftStorage()\n", + "simple_extractor.extract_crafts(soup,url,storage)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "df4b0ee0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " recipe_id input_item count\n", + "0 1 Укреплённое стекло 2\n", + "1 1 Иридиевый композит 2\n", + "2 1 Улучшенная электросхема 2\n", + "3 1 Нановолоконный шлем 1\n", + "4 1 Лазуротроновый кристалл 1\n", + "5 1 Шлем-акваланг 1\n", + "6 2 Иридиевый композит 4\n", + "7 2 Композит 2\n", + "8 2 Нановолоконный жилет 1\n", + "9 2 Лазуротроновый кристалл 1\n", + "10 2 Электрический реактивный ранец 1\n", + "11 3 Основной корпус машины 2\n", + "12 3 Иридиевый композит 2\n", + "13 3 Светопыль 2\n", + "14 3 Лазуротроновый кристалл 1\n", + "15 3 Нановолоконные поножи 1\n", + "16 4 Иридиевый композит 2\n", + "17 4 Резиновые ботинки 2\n", + "18 4 Нановолоконные ботинки 1\n", + "19 4 Лазуротроновый кристалл 1\n" + ] + } + ], + "source": [ + "print(storage.components_df.head(20))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/craft_parser/simple_extractor.py b/craft_parser/simple_extractor.py new file mode 100644 index 0000000..087a598 --- /dev/null +++ b/craft_parser/simple_extractor.py @@ -0,0 +1,231 @@ +from collections import Counter +import re +from bs4 import BeautifulSoup +from craft import CraftComponent, CraftItem, CraftRecipe +from craft_storage import CraftStorage +from dto import ParsedItem, ParsedRecipeInput, SpriteData +from loguru import logger + +def extract_crafts(soup: BeautifulSoup, storage: CraftStorage) -> set[str]: + + craft_containers = soup.find_all("div", class_="mcui-Crafting_Table-container") + if(craft_containers.__len__() < 1): + logger.info('Классических рецептов не найдено') + return set() + + src_links : set[str] = set() + + for container in craft_containers: + is_hidden = check_is_hidden(container) + if(is_hidden): + continue + output_item = parse_craft_item(container) + if(output_item is None): + continue + input_kit = parse_craft_components_and_recipe(container, output_item.item.name, output_item.amount) + if(input_kit is None): + continue + + src_links.update(input_kit.source_links) + + already_exists = not storage.try_add_recipe_signature(output_item.item.name, input_kit.components, 'classic') + if(already_exists): + continue + + storage.add_item(output_item.item) + for input_item in input_kit.items: + storage.add_item(input_item) + + recipe_id = storage.add_recipe(input_kit.recipe) + for component in input_kit.components: + component.recipe_id = recipe_id + storage.add_component(component) + logger.info(f'Добавлен рецепт крафта для предмета {output_item.item.name}') + + return src_links + +def check_is_hidden(container : BeautifulSoup) -> bool: + if(container.find_parent('table', class_='collapsed')): + logger.warning(f'{container} Не будет исследован. Причина: помечен как скрытый') + return True + return False + +def parse_craft_item(container) -> ParsedItem|None: + output_amount = 1 + output_span = container.find('span', class_='mcui-output') + if(output_span is None): + logger.error(f'ошибка для \n{container}\n: не найдено ячейки с результатом!') + return None + + output_span_name_container = output_span.find('span', class_='invslot-item') + if(output_span_name_container is None): + logger.error(f'ошибка для \n{output_span}\n: не найден текстовый контейнер!') + return None + + + data_from_span = extract_data_from_sprite_span(output_span_name_container) + if(data_from_span is not None): + output_item_title = data_from_span.title + output_item_img_shift = data_from_span.shift + output_item_img_url = data_from_span.img_url + else: + data_from_img = extract_data_from_sprite_img(output_span_name_container) + if(data_from_img is None): + logger.error(f'ошибка для \n{output_span_name_container}\n: не удалось извлечь картинку и заголовок ни одним из способов') + return None + output_item_title = data_from_img.title + output_item_img_shift = data_from_img.shift + output_item_img_url = data_from_img.img_url + + item = CraftItem(output_item_title, output_item_img_url, output_item_img_shift) + return ParsedItem(item=item, amount=output_amount) + +def parse_craft_components_and_recipe(container, output_item_name: str, output_count = 1) -> ParsedRecipeInput|None: + input_span = container.find('span', class_='mcui-input') + if(input_span is None): + logger.error(f'ошибка для \n{container}\n: не найдено рецепта крафта') + return None + + input_span_name_containers = input_span.find_all('span', class_='invslot-item') + if(input_span_name_containers is None or input_span_name_containers.__len__() == 0): + logger.error(f'Ошибка для \n{input_span}\n: не найдено айтемов в рецепте') + return None + + craft_items = [] + recipe = None + craft_components = [] + src_links = [] + #existing_items = set() + + for input_span_name_container in input_span_name_containers: + + if(input_span_name_container is None): + logger.error('ошибка для набора контейнеров: не найден текстовый контейнер!') + return None + + data_from_span = extract_data_from_sprite_span(input_span_name_container) + if(data_from_span is not None): + input_item_title = data_from_span.title + input_item_img_shift = data_from_span.shift + input_item_img_url = data_from_span.img_url + if(data_from_span.source_link is not None): + src_links.append(data_from_span.source_link) + else: + data_from_img = extract_data_from_sprite_img(input_span_name_container) + if(data_from_img is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не удалось извлечь картинку и заголовок ни одним из способов') + return None + input_item_title = data_from_img.title + input_item_img_shift = data_from_img.shift + input_item_img_url = data_from_img.img_url + + + craft_items.append(CraftItem(input_item_title, input_item_img_url, input_item_img_shift)) + + counter = Counter(c.name for c in craft_items) + recipe = CraftRecipe(output_item_name, output_count, 'Верстак') + for element in counter.most_common(50): + ##WARN айдишник изменится при сохранении + craft_components.append(CraftComponent(-1, element[0], element[1])) + + return ParsedRecipeInput( + items=craft_items, + recipe=recipe, + components=craft_components, + source_links=[x for x in src_links if x is not None] + ) + +def extract_data_from_sprite_img(input_span_name_container) -> SpriteData|None: + input_item_img = input_span_name_container.find('img') + if(input_item_img is None): + return None + + title = input_item_img.get('alt') + if(title is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найдено описание картинки!') + return None + + url = input_item_img.get('src') + if(url is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найден url картинки!') + return None + return SpriteData( + amount=1, + title=title, + shift=(0, 0), + img_url=url, + source_link=None) + +def extract_data_from_sprite_span(input_span_name_container) -> SpriteData|None: + input_item_sprite_span = input_span_name_container.find('span', class_='sprite') + + #Контейнера со спрайтом может не быть! + if(input_item_sprite_span is None): + return None + + input_item_title = input_item_sprite_span.get('title') + if(input_item_title is None): + logger.error(f'ошибка для \n{input_span_name_container}\n: не найдено заголовка предмета') + return None + + output_amount=1 + amount_container = input_span_name_container.find('span', class_='invslot-stacksize') + if(amount_container is not None): + logger.info(f'Для объекта {input_item_title} найдено количество : {amount_container.text}') + output_amount = int(amount_container.text) + + item_source_link = get_item_source_link(input_span_name_container) + + input_item_img_url = extract_img_classname(input_item_sprite_span) + if(input_item_img_url is None): + logger.error(f'Ошибка для \n{input_item_img_url}\n: не найден файл спрайта') + return None + + input_item_img_shift = extract_img_shift(input_item_sprite_span) + if(input_item_img_shift is None): + logger.error(f'Ошибка для \n{input_item_sprite_span}\n: не найдено смещения для спрайта') + return None + + return SpriteData( + amount=output_amount, + title=input_item_title, + shift=input_item_img_shift, + img_url=input_item_img_url, + source_link=item_source_link + ) + +def extract_img_shift(output_item_sprite_span): + if(output_item_sprite_span is None): + return None + css_value = output_item_sprite_span.get('style') + if(css_value is None): + return None + # Шаблон: ищем два числа (с возможным минусом) перед 'px' + pattern = r'(-?\d+)px\s+(-?\d+)px' + match = re.search(pattern, css_value) + + if match: + x = int(match.group(1)) + y = int(match.group(2)) + return (x, y) + else: + return None # Если шаблон не найден + +def extract_img_classname(sprite_span): + if(sprite_span is None): + return None + 'sprite industrialcraft-2-inv-sprite' + classes = sprite_span.get('class', []) + for cls in classes: + if cls.endswith('-sprite'): + return cls + return None # Если не найдено + +def get_item_source_link(input_span_name_container) -> str|None: + link_container = input_span_name_container.find('a') + if(link_container is None): + return None + link = link_container.get('href') + if(link is None): + return None + return f'https://ru.minecraft.wiki{link}'