Метод добавления товаров из 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"]:
|
||||
print(f"Ошибка: нет 'models' для товара {product_url}")
|
||||
continue
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"proxy": "",
|
||||
"request_delay": 2.5,
|
||||
"request_delay": 0.1,
|
||||
"request_repeats": 2,
|
||||
"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 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 = []
|
||||
|
||||
|
||||
@ -118,7 +118,11 @@ class Recorder:
|
||||
safe_str(row[idx["Параметр: Происхождение"]]).replace("\n", "<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/", "")
|
||||
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