Метод добавления товаров из xlsx с UI
+ ZH - категории теперь лежат в рекордсфолдер
This commit is contained in:
parent
5d077413c9
commit
bd765f8349
0
API-UI/history.json
Normal file
0
API-UI/history.json
Normal file
495
API-UI/main.py
Normal file
495
API-UI/main.py
Normal file
@ -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())
|
||||||
16
API-UI/mappings/brands.json
Normal file
16
API-UI/mappings/brands.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"endpoint": "http://localhost:3005/create-brands",
|
||||||
|
"description": "Импорт списка брендов",
|
||||||
|
"structure": {
|
||||||
|
"brands": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"brands": {
|
||||||
|
"type": "list",
|
||||||
|
"source": "названия брендов",
|
||||||
|
"example": ["IKEA", "ORGIE", "PASSION LABS"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
API-UI/mappings/categories.json
Normal file
18
API-UI/mappings/categories.json
Normal file
@ -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": "Полный путь категории через ' > '"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
API-UI/mappings/products.json
Normal file
48
API-UI/mappings/products.json
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
APIlocalhost/Brand-creation.py
Normal file
39
APIlocalhost/Brand-creation.py
Normal file
@ -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)
|
||||||
Binary file not shown.
@ -248,8 +248,6 @@ class Extractor:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if "models" not in model_info or not model_info["models"]:
|
if "models" not in model_info or not model_info["models"]:
|
||||||
print(f"Ошибка: нет 'models' для товара {product_url}")
|
print(f"Ошибка: нет 'models' для товара {product_url}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"request_delay": 2.5,
|
"request_delay": 0.1,
|
||||||
"request_repeats": 2,
|
"request_repeats": 2,
|
||||||
"request_repeat_delay": 3
|
"request_repeat_delay": 3
|
||||||
}
|
}
|
||||||
175
Pars_Decathlon/send_all_json_to_localAPI_only.py
Normal file
175
Pars_Decathlon/send_all_json_to_localAPI_only.py
Normal file
@ -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-теги, кроме <br>"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r"(\n\s*){1,}", "<br>", text)
|
||||||
|
text = re.sub(r"<br\s*/?>\s*\n", "<br>", text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r"\n\s*<br\s*/?>", "<br>", text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r"<br\s*/?>\s*<br\s*/?>", "<br>", text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r"<(?!br\s*/?)[^>]+>", "", text) # удалить всё, кроме <br>
|
||||||
|
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()
|
||||||
@ -1,11 +1,16 @@
|
|||||||
from openpyxl import load_workbook
|
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():
|
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 = []
|
categories = []
|
||||||
|
|
||||||
|
|||||||
@ -118,7 +118,11 @@ class Recorder:
|
|||||||
safe_str(row[idx["Параметр: Происхождение"]]).replace("\n", "<br/>")
|
safe_str(row[idx["Параметр: Происхождение"]]).replace("\n", "<br/>")
|
||||||
).strip("<br/>")
|
).strip("<br/>")
|
||||||
|
|
||||||
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/", "")
|
cat_raw = row[idx["Размещение на сайте"]].replace("Каталог/ZaraHome/WOMEN/", "")
|
||||||
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
|
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
|
||||||
|
|||||||
3
send_log.txt
Normal file
3
send_log.txt
Normal file
@ -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-файлов.
|
||||||
Loading…
Reference in New Issue
Block a user