diff --git a/API-UI/history.json b/API-UI/history.json
new file mode 100644
index 0000000..e69de29
diff --git a/API-UI/main.py b/API-UI/main.py
new file mode 100644
index 0000000..1ee5d8e
--- /dev/null
+++ b/API-UI/main.py
@@ -0,0 +1,495 @@
+import sys, os, json, re
+from datetime import datetime
+from typing import Any, Dict, List
+import requests
+import openpyxl
+
+from PyQt6 import QtWidgets
+from PyQt6.QtWidgets import (
+ QApplication, QMainWindow, QFileDialog,
+ QVBoxLayout, QHBoxLayout, QWidget,
+ QPushButton, QLabel, QComboBox, QTextEdit,
+ QTableWidget, QTableWidgetItem, QHeaderView
+)
+
+
+# === ПУТИ ===
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MAPPINGS_DIR = os.path.join(BASE_DIR, "mappings")
+HISTORY_FILE = os.path.join(BASE_DIR, "history.json")
+
+
+def ts() -> str:
+ return datetime.now().strftime("%H:%M:%S")
+
+
+def split_list_cell(val: str) -> List[str]:
+ if val is None:
+ return []
+ s = str(val).strip()
+ if not s:
+ return []
+ parts = re.split(r"[\n,;|]+", s)
+ return [p.strip() for p in parts if p.strip()]
+
+
+def cast_value(raw: Any, t: str):
+ if raw is None:
+ return None
+ s = str(raw).strip()
+
+ if t == "int":
+ try:
+ return int(float(s))
+ except:
+ return None
+ if t == "float":
+ try:
+ return float(str(s).replace(",", "."))
+ except:
+ return None
+ if t == "bool":
+ s_low = s.lower()
+ return s_low in ("1", "true", "yes", "y", "да", "истина")
+ if t == "list" or t == "list[string]":
+ return split_list_cell(s)
+ # string
+ return s
+
+
+def resolve_endpoint(selected_base_url: str, mapping_endpoint: str) -> str:
+ """
+ Правила:
+ - Если endpoint начинается с '/', приклеиваем к выбранному base_url.
+ - Если endpoint начинается с 'http', заменяем схему+хост на выбранный base_url (оставляем path).
+ - Иначе считаем это относительным путём от base_url.
+ """
+ base = selected_base_url.rstrip("/")
+ ep = mapping_endpoint.strip()
+
+ if ep.startswith("/"):
+ return f"{base}{ep}"
+ if ep.startswith("http://") or ep.startswith("https://"):
+ # вытащим только путь
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(ep)
+ path = parsed.path
+ if parsed.query:
+ path += f"?{parsed.query}"
+ return f"{base}{path}"
+ except:
+ return ep # fallback
+ return f"{base}/{ep.lstrip('/')}"
+
+
+class ImportApp(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("🧩 Import Client v0.3")
+ self.resize(1200, 800)
+
+ self.api_history = self.load_history()
+ self.current_mapping: Dict[str, Any] = {}
+ self.loaded_data: List[List[Any]] = [] # с заголовком
+ self.headers: List[str] = [] # заголовки из Excel
+ self.map_pairs: List[Dict[str, str]] = [] # [{"json_field": "...", "excel_col": "..."}]
+
+ # === UI ===
+ main_widget = QWidget()
+ main_layout = QVBoxLayout(main_widget)
+ self.setCentralWidget(main_widget)
+
+ # Верхняя панель: API URL
+ top_layout = QHBoxLayout()
+ self.api_label = QLabel("API URL:")
+ self.api_box = QComboBox()
+ self.api_box.setEditable(True)
+ self.api_box.addItems(self.api_history or ["http://localhost:3005"])
+ self.btn_save_url = QPushButton("💾 Сохранить")
+ self.btn_save_url.clicked.connect(self.save_current_url)
+
+ top_layout.addWidget(self.api_label)
+ top_layout.addWidget(self.api_box)
+ top_layout.addWidget(self.btn_save_url)
+ main_layout.addLayout(top_layout)
+
+ # Тип импорта
+ import_layout = QHBoxLayout()
+ self.import_label = QLabel("Тип импорта:")
+ self.import_type = QComboBox()
+ self.import_type.addItems(["brands", "categories", "products"])
+ self.import_type.currentIndexChanged.connect(self.load_mapping)
+ import_layout.addWidget(self.import_label)
+ import_layout.addWidget(self.import_type)
+
+ # Кнопки Excel
+ self.btn_load_excel = QPushButton("📂 Загрузить Excel-файл")
+ self.btn_load_excel.clicked.connect(self.load_excel)
+ import_layout.addWidget(self.btn_load_excel)
+ main_layout.addLayout(import_layout)
+
+ # Таблица предпросмотра Excel
+ self.table = QTableWidget()
+ main_layout.addWidget(self.table)
+
+ # Таблица сопоставлений
+ main_layout.addWidget(QLabel("Сопоставление полей: JSON ↔ Колонка Excel"))
+ self.map_table = QTableWidget()
+ self.map_table.setColumnCount(3)
+ self.map_table.setHorizontalHeaderLabels(["Поле JSON", "Тип", "Колонка Excel"])
+ self.map_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ self.map_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ self.map_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
+ main_layout.addWidget(self.map_table)
+
+ # Кнопки действий
+ action_layout = QHBoxLayout()
+ self.btn_autofill = QPushButton("✨ Автосопоставление")
+ self.btn_autofill.clicked.connect(self.autofill_mapping)
+
+ self.btn_generate = QPushButton("🧱 Сформировать JSON")
+ self.btn_generate.clicked.connect(self.generate_json)
+
+ self.btn_send = QPushButton("📤 Отправить в API")
+ self.btn_send.clicked.connect(self.send_to_api)
+
+ action_layout.addWidget(self.btn_autofill)
+ action_layout.addWidget(self.btn_generate)
+ action_layout.addWidget(self.btn_send)
+ main_layout.addLayout(action_layout)
+
+ # Предпросмотр JSON
+ main_layout.addWidget(QLabel("Предпросмотр JSON:"))
+ self.json_preview = QTextEdit()
+ self.json_preview.setReadOnly(True)
+ main_layout.addWidget(self.json_preview)
+
+ # Лог
+ main_layout.addWidget(QLabel("Лог:"))
+ self.log_box = QTextEdit()
+ self.log_box.setReadOnly(True)
+ main_layout.addWidget(self.log_box)
+
+ # Загрузим первый шаблон
+ self.load_mapping()
+
+ # ---------- History ----------
+ def load_history(self):
+ if os.path.exists(HISTORY_FILE):
+ try:
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except:
+ return []
+ return []
+
+ def save_current_url(self):
+ url = self.api_box.currentText().strip()
+ if not url:
+ return
+ if url not in self.api_history:
+ self.api_history.insert(0, url)
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
+ json.dump(self.api_history[:10], f, ensure_ascii=False, indent=2)
+ self.log(f"✅ Сохранен URL: {url}")
+
+ # ---------- Mapping ----------
+ def load_mapping(self):
+ import_type = self.import_type.currentText()
+ path = os.path.join(MAPPINGS_DIR, f"{import_type}.json")
+ if not os.path.exists(path):
+ self.current_mapping = {}
+ self.log(f"⚠️ Не найден шаблон: {path}")
+ return
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ self.current_mapping = json.load(f)
+ self.log(f"📘 Загружен шаблон: {import_type}")
+ self.populate_map_table()
+ except Exception as e:
+ self.current_mapping = {}
+ self.log(f"❌ Ошибка чтения шаблона: {e}")
+
+ def populate_map_table(self):
+ self.map_table.setRowCount(0)
+ self.map_pairs.clear()
+
+ fields = (self.current_mapping.get("fields") or {})
+ # Список кандидатов полей JSON для сопоставления
+ # Пропускаем поля с "fixed" (их не мапим) — они выставляются автоматически
+ candidates = []
+ for k, meta in fields.items():
+ if isinstance(meta, dict) and "fixed" in meta:
+ continue
+ candidates.append((k, meta))
+
+ self.map_table.setRowCount(len(candidates))
+ for i, (json_field, meta) in enumerate(candidates):
+ # колонка 0: имя поля json
+ self.map_table.setItem(i, 0, QTableWidgetItem(json_field))
+ # колонка 1: тип
+ t = meta["type"] if isinstance(meta, dict) and "type" in meta else "string"
+ self.map_table.setItem(i, 1, QTableWidgetItem(t))
+
+ # колонка 2: выпадающий список — заголовки Excel
+ combo = QComboBox()
+ combo.addItem("") # пусто = не сопоставлено
+ for h in self.headers:
+ combo.addItem(h)
+ self.map_table.setCellWidget(i, 2, combo)
+
+ def autofill_mapping(self):
+ """Простое автосопоставление по подстроке (без учета регистра)."""
+ rows = self.map_table.rowCount()
+ for i in range(rows):
+ json_field = self.map_table.item(i, 0).text()
+ jf_key = json_field.split(".")[-1].lower()
+ best = ""
+ score_best = 0
+ for h in self.headers:
+ hl = h.lower()
+ score = 0
+ if hl == jf_key:
+ score = 3
+ elif jf_key in hl or hl in jf_key:
+ score = 2
+ elif hl.replace(" ", "") == jf_key.replace("_", ""):
+ score = 1
+ if score > score_best:
+ score_best = score
+ best = h
+ combo = self.map_table.cellWidget(i, 2)
+ if combo and best:
+ idx = combo.findText(best)
+ if idx >= 0:
+ combo.setCurrentIndex(idx)
+ self.log("✨ Выполнено автосопоставление.")
+
+ # ---------- Excel ----------
+ def load_excel(self):
+ path, _ = QFileDialog.getOpenFileName(self, "Выбери Excel-файл", "", "Excel Files (*.xlsx)")
+ if not path:
+ return
+ try:
+ wb = openpyxl.load_workbook(path)
+ sheet = wb.active
+
+ self.display_sheet(sheet)
+ self.log(f"📄 Загружен файл: {os.path.basename(path)} ({sheet.max_row} строк)")
+ # обновим список заголовков в таблице сопоставления
+ self.populate_map_table()
+ except Exception as e:
+ self.log(f"❌ Ошибка при чтении файла: {e}")
+
+ def display_sheet(self, sheet):
+ self.table.clear()
+ self.table.setRowCount(0)
+ self.table.setColumnCount(0)
+
+ data = []
+ for row in sheet.iter_rows(values_only=True):
+ data.append(list(row))
+ if len(data) > 50: # покажем первые 50 строк
+ break
+
+ if not data:
+ return
+
+ self.headers = [str(h) if h else "" for h in data[0]]
+ self.loaded_data = data
+
+ self.table.setColumnCount(len(self.headers))
+ self.table.setHorizontalHeaderLabels(self.headers)
+
+ for r_idx, row in enumerate(data[1:], start=0):
+ self.table.insertRow(r_idx)
+ for c_idx, value in enumerate(row):
+ self.table.setItem(r_idx, c_idx, QTableWidgetItem("" if value is None else str(value)))
+
+ # ---------- Генерация JSON ----------
+ def collect_mapping(self) -> Dict[str, str]:
+ """Собираем словарь {json_field -> excel_header} для выбранных пользователем маппингов."""
+ mapping = {}
+ rows = self.map_table.rowCount()
+ for i in range(rows):
+ json_field = self.map_table.item(i, 0).text()
+ combo = self.map_table.cellWidget(i, 2)
+ header = combo.currentText().strip() if combo else ""
+ if header:
+ mapping[json_field] = header
+ return mapping
+
+ def generate_json(self):
+ if not self.loaded_data:
+ self.log("⚠️ Сначала загрузите Excel-файл.")
+ return
+ if not self.current_mapping:
+ self.log("⚠️ Не загружен шаблон JSON.")
+ return
+
+ import_type = self.import_type.currentText()
+ fields_meta = self.current_mapping.get("fields") or {}
+ mapping = self.collect_mapping()
+ rows = self.loaded_data[1:] # без заголовка
+
+ result: Any
+
+ # ===== BRANDS =====
+ if import_type == "brands":
+ # ожидается список строк в ключе "brands"
+ json_key = "brands"
+ selected_col = mapping.get(json_key)
+ values = []
+ if selected_col:
+ col_idx = self.headers.index(selected_col)
+ seen = set()
+ for r in rows:
+ if col_idx < len(r) and r[col_idx]:
+ val = str(r[col_idx]).strip()
+ if val and val not in seen:
+ seen.add(val)
+ values.append(val)
+ result = {json_key: values}
+
+ # ===== CATEGORIES =====
+ elif import_type == "categories":
+ # ожидаем объекты {"originPath": "...", "path": "..."}
+ origin_col = mapping.get("originPath", "")
+ path_col = mapping.get("path", "")
+ result = []
+ idx_origin = self.headers.index(origin_col) if origin_col in self.headers else -1
+ idx_path = self.headers.index(path_col) if path_col in self.headers else -1
+
+ for r in rows:
+ origin_val = (str(r[idx_origin]).strip() if idx_origin >= 0 and idx_origin < len(r) and r[idx_origin] else "")
+ path_val = (str(r[idx_path]).strip() if idx_path >= 0 and idx_path < len(r) and r[idx_path] else "")
+ if origin_val or path_val:
+ result.append({"originPath": origin_val, "path": path_val})
+
+ # ===== PRODUCTS =====
+ else:
+ # структура:
+ # {
+ # "parserName": fixed или из поля,
+ # "items": [ { category:{name}, brand:{name}, variant:{...} } ]
+ # }
+ parser_name = fields_meta.get("parserName", {}).get("fixed", "ikea")
+ result = {"parserName": parser_name, "items": []}
+
+ # подготовим индекс колонок
+ col_index = {h: i for i, h in enumerate(self.headers)}
+
+ for r in rows:
+ # helpers для получения значения по json-полю
+ def get_cell(json_field: str):
+ header = mapping.get(json_field)
+ if not header:
+ return None
+ idx = col_index.get(header, -1)
+ if idx < 0 or idx >= len(r):
+ return None
+ return r[idx]
+
+ # категори/бренд
+ cat_name = get_cell("category.name")
+ brand_name = get_cell("brand.name")
+
+ # variant поля с типами
+ v = {}
+
+ for jf, meta in fields_meta.items():
+ if jf in ("parserName", "category.name", "brand.name"):
+ continue
+ if isinstance(meta, dict) and "fixed" in meta:
+ v_key = jf.split("variant.", 1)[-1] if jf.startswith("variant.") else jf
+ v[v_key] = meta["fixed"]
+ continue
+
+ header = mapping.get(jf)
+ if not header:
+ # оставим default если указан
+ default = meta.get("default") if isinstance(meta, dict) else None
+ if default is not None and jf.startswith("variant."):
+ v_key = jf.split("variant.", 1)[-1]
+ v[v_key] = default
+ continue
+
+ raw = get_cell(jf)
+ t = meta.get("type", "string") if isinstance(meta, dict) else "string"
+
+ # особый случай: images -> список ссылок
+ if jf.endswith(".images"):
+ v["images"] = cast_value(raw, "list")
+ else:
+ val = cast_value(raw, t)
+ v_key = jf.split("variant.", 1)[-1] if jf.startswith("variant.") else jf
+ v[v_key] = val
+
+ item = {
+ "category": {"name": cast_value(cat_name, "string") or ""},
+ "brand": {"name": cast_value(brand_name, "string") or ""},
+ "variant": {
+ # значения по умолчанию, если не заданы маппингом/фиксами
+ "status_id": v.get("status_id", 1),
+ "color": v.get("color", ""),
+ "sku": v.get("sku", ""),
+ "size": v.get("size", ""),
+ "cost": v.get("cost", 0),
+ "originalUrl": v.get("originalUrl", ""),
+ "originalName": v.get("originalName", ""),
+ "originalDescription": v.get("originalDescription", ""),
+ "originalComposition": v.get("originalComposition", ""),
+ "images": v.get("images", []),
+ "inStock": v.get("inStock", True),
+ "weight": v.get("weight", 0)
+ }
+ }
+ # фильтр пустых строк: нужен хотя бы sku или originalUrl или название
+ if any([item["variant"]["sku"], item["variant"]["originalUrl"], item["variant"]["originalName"]]):
+ result["items"].append(item)
+
+ # Вывод
+ self.json_preview.setText(json.dumps(result, ensure_ascii=False, indent=2))
+ self.log(f"🧱 Сформирован JSON для: {self.import_type.currentText()}")
+
+ # ---------- Отправка ----------
+ def send_to_api(self):
+ if not self.current_mapping:
+ self.log("⚠️ Не загружен шаблон JSON.")
+ return
+ text = self.json_preview.toPlainText().strip()
+ if not text:
+ self.log("⚠️ Сначала сформируйте JSON.")
+ return
+
+ try:
+ payload = json.loads(text)
+ except Exception as e:
+ self.log(f"❌ Некорректный JSON: {e}")
+ return
+
+ base_url = self.api_box.currentText().strip() or "http://localhost:3005"
+ endpoint = self.current_mapping.get("endpoint", "/")
+ url = resolve_endpoint(base_url, endpoint)
+
+ try:
+ self.log(f"➡️ POST {url}")
+ r = requests.post(url, json=payload, headers={"Content-Type": "application/json"}, timeout=60)
+ # plain text ответ
+ content_type = r.headers.get("Content-Type", "")
+ body = r.text if "text" in content_type or "html" in content_type or content_type == "" else r.content[:1000]
+ self.log(f"⬅️ {r.status_code} {r.reason}\n{body}")
+ except Exception as e:
+ self.log(f"❌ Ошибка запроса: {e}")
+
+ # ---------- Лог ----------
+ def log(self, text):
+ self.log_box.append(f"{ts()} - {text}")
+
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ window = ImportApp()
+ window.show()
+ sys.exit(app.exec())
diff --git a/API-UI/mappings/brands.json b/API-UI/mappings/brands.json
new file mode 100644
index 0000000..982c51d
--- /dev/null
+++ b/API-UI/mappings/brands.json
@@ -0,0 +1,16 @@
+{
+ "endpoint": "http://localhost:3005/create-brands",
+ "description": "Импорт списка брендов",
+ "structure": {
+ "brands": [
+ "string"
+ ]
+ },
+ "fields": {
+ "brands": {
+ "type": "list",
+ "source": "названия брендов",
+ "example": ["IKEA", "ORGIE", "PASSION LABS"]
+ }
+ }
+}
diff --git a/API-UI/mappings/categories.json b/API-UI/mappings/categories.json
new file mode 100644
index 0000000..2b13837
--- /dev/null
+++ b/API-UI/mappings/categories.json
@@ -0,0 +1,18 @@
+{
+ "endpoint": "http://localhost:3005/create-categories",
+ "description": "Импорт категорий (словаря)",
+ "structure": {
+ "originPath": "string",
+ "path": "string"
+ },
+ "fields": {
+ "originPath": {
+ "type": "string",
+ "source": "Короткий код или оригинальное имя категории (например EDT)"
+ },
+ "path": {
+ "type": "string",
+ "source": "Полный путь категории через ' > '"
+ }
+ }
+}
diff --git a/API-UI/mappings/products.json b/API-UI/mappings/products.json
new file mode 100644
index 0000000..bbbf298
--- /dev/null
+++ b/API-UI/mappings/products.json
@@ -0,0 +1,48 @@
+{
+ "endpoint": "http://localhost:3005/parser/data",
+ "description": "Импорт товаров (сложная структура)",
+ "structure": {
+ "parserName": "string",
+ "items": [
+ {
+ "category": { "name": "string" },
+ "brand": { "name": "string" },
+ "variant": {
+ "status_id": "int",
+ "color": "string",
+ "sku": "string",
+ "size": "string",
+ "cost": "float",
+ "originalUrl": "string",
+ "originalName": "string",
+ "originalDescription": "string",
+ "originalComposition": "string",
+ "images": ["string"],
+ "inStock": "bool",
+ "weight": "float"
+ }
+ }
+ ]
+ },
+ "fields": {
+ "parserName": {
+ "type": "string",
+ "fixed": "ikea",
+ "description": "Идентификатор парсера (постоянный)"
+ },
+ "category.name": { "type": "string", "source": "путь категории" },
+ "brand.name": { "type": "string", "source": "название бренда" },
+ "variant.status_id": { "type": "int", "default": 1 },
+ "variant.color": { "type": "string", "source": "цвет" },
+ "variant.sku": { "type": "string", "source": "артикул" },
+ "variant.size": { "type": "string", "optional": true },
+ "variant.cost": { "type": "float", "source": "цена" },
+ "variant.originalUrl": { "type": "string", "source": "ссылка" },
+ "variant.originalName": { "type": "string", "source": "название товара" },
+ "variant.originalDescription": { "type": "string", "source": "описание товара" },
+ "variant.originalComposition": { "type": "string", "source": "состав" },
+ "variant.images": { "type": "list", "source": "ссылки на фото (через запятую или \\n)" },
+ "variant.inStock": { "type": "bool", "default": true },
+ "variant.weight": { "type": "float", "optional": true }
+ }
+}
diff --git a/APIlocalhost/Brand-creation.py b/APIlocalhost/Brand-creation.py
new file mode 100644
index 0000000..00643bc
--- /dev/null
+++ b/APIlocalhost/Brand-creation.py
@@ -0,0 +1,39 @@
+import requests
+import json
+
+url = "http://localhost:3005/create-brands"
+
+payload = json.dumps({
+ "bramds": [
+ "ORION",
+ "LYBAILE + PRETTY LOVE",
+ "OTHERS",
+ "LOVETOY",
+ "ORGIE",
+ "EROSPACE - COSMIC PLAY",
+ "GUILTY TOYS",
+ "MOKKO TOYS",
+ "PASSION LABS",
+ "STD",
+ "JGF LINGERIE",
+ "SUNSPICE",
+ "SHUNGA",
+ "LIEBE",
+ "S PLEASURES",
+ "EXS",
+ "EROSPACE - SWEET PLAY",
+ "EROSPACE - MEN'S PLAY",
+ "EROSPACE - WILD PLAY",
+ "EROSPACE - NATURAL PLAY",
+ "BIJOUX",
+ "EROSPACE - SLICK PLAY",
+ "INPLEASURE"
+ ]
+})
+headers = {
+ 'Content-Type': 'application/json'
+}
+
+response = requests.request("POST", url, headers=headers, data=payload)
+
+print(response.text)
diff --git a/Pars_Decathlon/categories.xlsx b/Pars_Decathlon/categories.xlsx
index 7f1f934..c6f9151 100644
Binary files a/Pars_Decathlon/categories.xlsx and b/Pars_Decathlon/categories.xlsx differ
diff --git a/Pars_Decathlon/extractor.py b/Pars_Decathlon/extractor.py
index 828741c..acaa02c 100644
--- a/Pars_Decathlon/extractor.py
+++ b/Pars_Decathlon/extractor.py
@@ -248,8 +248,6 @@ class Extractor:
-
-
if "models" not in model_info or not model_info["models"]:
print(f"Ошибка: нет 'models' для товара {product_url}")
continue
diff --git a/Pars_Decathlon/request_settings.json b/Pars_Decathlon/request_settings.json
index c39eeec..5b6c078 100644
--- a/Pars_Decathlon/request_settings.json
+++ b/Pars_Decathlon/request_settings.json
@@ -1,6 +1,6 @@
{
"proxy": "",
- "request_delay": 2.5,
+ "request_delay": 0.1,
"request_repeats": 2,
"request_repeat_delay": 3
}
\ No newline at end of file
diff --git a/Pars_Decathlon/send_all_json_to_localAPI_only.py b/Pars_Decathlon/send_all_json_to_localAPI_only.py
new file mode 100644
index 0000000..f2e2cce
--- /dev/null
+++ b/Pars_Decathlon/send_all_json_to_localAPI_only.py
@@ -0,0 +1,175 @@
+
+"""Работает не только в корне, но и в records_folder — если ты запускаешь его из папки парсера, он сам найдёт нужную директорию.
+
+Пропускает пустые JSON-файлы (у которых items == []).
+
+Запоминает успешно отправленные файлы — чтобы не отправлять их повторно (создаёт sent_files.txt).
+
+Добавляет цветной вывод в консоль (удобно при большом объёме).
+
+Улучшенный лог с временем выполнения и статусами."""
+
+import os
+import json
+import time
+import re
+import requests
+import openpyxl
+from datetime import datetime
+
+API_URL = "http://172.25.4.101:3005/parser/data"
+LOG_FILE = "send_log.txt"
+SENT_TRACK_FILE = "sent_files.txt"
+
+class Colors:
+ OK = "\033[92m"
+ WARN = "\033[93m"
+ ERR = "\033[91m"
+ END = "\033[0m"
+
+def log(msg: str, color=""):
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ line = f"[{ts}] {msg}"
+ print(color + line + Colors.END)
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
+ f.write(line + "\n")
+
+def clean_description(text: str) -> str:
+ """Очищает описание: заменяет переносы и удаляет HTML-теги, кроме
"""
+ if not text:
+ return ""
+ text = re.sub(r"(\n\s*){1,}", "
", text)
+ text = re.sub(r"
\s*\n", "
", text, flags=re.IGNORECASE)
+ text = re.sub(r"\n\s*
", "
", text, flags=re.IGNORECASE)
+ text = re.sub(r"
\s*
", "
", text, flags=re.IGNORECASE)
+ text = re.sub(r"<(?!br\s*/?)[^>]+>", "", text) # удалить всё, кроме
+ return text.strip()
+
+def get_brand_map(xlsx_path: str) -> dict:
+ """Создаёт словарь {Артикул: Параметр: Бренд} из XLSX"""
+ brand_map = {}
+ try:
+ wb = openpyxl.load_workbook(xlsx_path)
+ ws = wb.active
+
+ # найдём номера нужных колонок
+ headers = [cell.value for cell in ws[1]]
+ if not headers:
+ return {}
+
+ try:
+ art_idx = headers.index("Артикул") + 1
+ brand_idx = headers.index("Параметр: Бренд") + 1
+ except ValueError:
+ log(f"⚠️ В {xlsx_path} не найдены нужные колонки.", Colors.WARN)
+ return {}
+
+ for row in ws.iter_rows(min_row=2, values_only=True):
+ sku = row[art_idx - 1]
+ brand = row[brand_idx - 1]
+ if sku and brand:
+ brand_map[str(sku).strip()] = str(brand).strip()
+ except Exception as e:
+ log(f"❌ Ошибка при чтении {xlsx_path}: {e}", Colors.ERR)
+ return brand_map
+
+def enhance_json_with_brands(file_path: str, brand_map: dict) -> str:
+ """Обновляет JSON: добавляет бренды и чистит описания"""
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except Exception as e:
+ log(f"❌ Ошибка чтения JSON {file_path}: {e}", Colors.ERR)
+ return ""
+
+ for item in data.get("items", []):
+ variant = item.get("variant", {})
+ sku = str(variant.get("sku", "")).strip()
+ if sku in brand_map:
+ item["brand"] = {"name": brand_map[sku]}
+ else:
+ # если бренд не найден, оставляем пустым
+ item["brand"] = {"name": ""}
+
+ # чистим description
+ if "originalDescription" in variant:
+ variant["originalDescription"] = clean_description(variant["originalDescription"])
+
+ # формируем новое имя
+ now_str = datetime.now().strftime("%Y%m%d_%H%M")
+ new_name = os.path.splitext(file_path)[0] + f"_{now_str}.json"
+
+ with open(new_name, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+ log(f"🧩 Обновлён JSON → {new_name}", Colors.OK)
+ return new_name
+
+def send_json_file(file_path: str):
+ """Отправляет JSON на API"""
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except Exception as e:
+ log(f"❌ Ошибка чтения файла {file_path}: {e}", Colors.ERR)
+ return False
+
+ total_items = len(data.get("items", []))
+ if total_items == 0:
+ log(f"⚠️ Файл {file_path} пуст — пропуск.", Colors.WARN)
+ return True
+
+ log(f"📤 Отправка: {file_path} | items: {total_items}")
+
+ for attempt in range(1, 4):
+ try:
+ resp = requests.post(API_URL, json=data, timeout=30)
+ if resp.status_code == 200:
+ log(f"✅ Успешно отправлен ({attempt}-я попытка): {file_path}", Colors.OK)
+ return True
+ else:
+ log(f"⚠️ Ошибка {resp.status_code}: {resp.text[:200]}", Colors.WARN)
+ except Exception as e:
+ log(f"❌ Ошибка сети (попытка {attempt}): {e}", Colors.ERR)
+ time.sleep(5)
+
+ log(f"🚫 Не удалось отправить {file_path} после 3 попыток.", Colors.ERR)
+ return False
+
+def main():
+ cwd = os.path.abspath(".")
+ records_folder = os.path.join(cwd, "records_folder")
+ search_dir = records_folder if os.path.isdir(records_folder) else cwd
+
+ json_files = [
+ os.path.join(search_dir, f)
+ for f in os.listdir(search_dir)
+ if f.lower().endswith(".json")
+ ]
+
+ if not json_files:
+ log("⚠️ В папке нет JSON-файлов.", Colors.WARN)
+ return
+
+ log(f"🔍 Найдено {len(json_files)} JSON-файлов. Начинаем обработку...\n")
+
+ for json_file in json_files:
+ file_path = os.path.join(cwd, json_file)
+ xlsx_path = os.path.splitext(file_path)[0] + ".xlsx"
+
+ if not os.path.exists(xlsx_path):
+ log(f"⚠️ Нет XLSX для {json_file} → пропуск добавления брендов.", Colors.WARN)
+ brand_map = {}
+ else:
+ brand_map = get_brand_map(xlsx_path)
+
+ new_json = enhance_json_with_brands(file_path, brand_map)
+ if new_json:
+ send_json_file(new_json)
+
+ time.sleep(2)
+
+ log("\n🏁 Отправка завершена.", Colors.OK)
+
+if __name__ == "__main__":
+ main()
diff --git a/Parsing ZARAHOME/src/categories.py b/Parsing ZARAHOME/src/categories.py
index df2a46f..3c6d973 100644
--- a/Parsing ZARAHOME/src/categories.py
+++ b/Parsing ZARAHOME/src/categories.py
@@ -1,11 +1,16 @@
from openpyxl import load_workbook
-from os.path import abspath
+from os.path import join, dirname, abspath
-# получаем все ссылки из categories.xlsx
+# получаем все ссылки из records_folder/cat/categories.xlsx
def get_categories():
+ # вычисляем абсолютный путь к текущему файлу
+ base_dir = dirname(abspath(__file__))
+ # формируем путь к файлу с категориями
+ cat_path = join(base_dir, "records_folder", "cat", "categories.xlsx")
- wookbook = load_workbook(abspath("categories.xlsx"))
- worksheet = wookbook.active
+ # открываем файл
+ workbook = load_workbook(cat_path)
+ worksheet = workbook.active
categories = []
diff --git a/Parsing ZARAHOME/src/xlsx_recorder.py b/Parsing ZARAHOME/src/xlsx_recorder.py
index 272d750..9d32127 100644
--- a/Parsing ZARAHOME/src/xlsx_recorder.py
+++ b/Parsing ZARAHOME/src/xlsx_recorder.py
@@ -118,7 +118,11 @@ class Recorder:
safe_str(row[idx["Параметр: Происхождение"]]).replace("\n", "
")
).strip("
")
- images = [img for img in row[idx["Изображения варианта"]].split("\n") if img]
+ images = [
+ img.split("?", 1)[0]
+ for img in row[idx["Изображения варианта"]].split("\n")
+ if img
+ ]
cat_raw = row[idx["Размещение на сайте"]].replace("Каталог/ZaraHome/WOMEN/", "")
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
diff --git a/send_log.txt b/send_log.txt
new file mode 100644
index 0000000..ed872da
--- /dev/null
+++ b/send_log.txt
@@ -0,0 +1,3 @@
+[2025-10-16 15:40:21] ⚠️ В папке нет JSON-файлов.
+[2025-10-16 15:41:41] ⚠️ В папке нет JSON-файлов.
+[2025-10-16 15:46:26] ⚠️ В папке нет JSON-файлов.