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}'