From bd765f83498f540f174c235c74f0ea3a85b6f81d Mon Sep 17 00:00:00 2001 From: va1is Date: Mon, 27 Oct 2025 11:47:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B5=D1=82=D0=BE=D0=B4=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20=D0=B8=D0=B7=20xlsx=20=D1=81?= =?UTF-8?q?=20UI=20+=20ZH=20-=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=B6=D0=B0=D1=82=20=D0=B2=20=D1=80=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D1=81=D1=84=D0=BE=D0=BB=D0=B4=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API-UI/history.json | 0 API-UI/main.py | 495 ++++++++++++++++++ API-UI/mappings/brands.json | 16 + API-UI/mappings/categories.json | 18 + API-UI/mappings/products.json | 48 ++ APIlocalhost/Brand-creation.py | 39 ++ Pars_Decathlon/categories.xlsx | Bin 12609 -> 12988 bytes Pars_Decathlon/extractor.py | 2 - Pars_Decathlon/request_settings.json | 2 +- .../send_all_json_to_localAPI_only.py | 175 +++++++ Parsing ZARAHOME/src/categories.py | 13 +- Parsing ZARAHOME/src/xlsx_recorder.py | 6 +- send_log.txt | 3 + 13 files changed, 809 insertions(+), 8 deletions(-) create mode 100644 API-UI/history.json create mode 100644 API-UI/main.py create mode 100644 API-UI/mappings/brands.json create mode 100644 API-UI/mappings/categories.json create mode 100644 API-UI/mappings/products.json create mode 100644 APIlocalhost/Brand-creation.py create mode 100644 Pars_Decathlon/send_all_json_to_localAPI_only.py create mode 100644 send_log.txt 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 7f1f93400875e98bcdfea0b9c73f4eecb2dddc65..c6f915131121fbb79c9724dc6d7eeb404ff2d4da 100644 GIT binary patch delta 7132 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 delta 6730 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 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-файлов.