first deployment of kikomiko with url photos

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shayan khoshrou
2026-10-19 19:10:57 +02:00
co-authored by Cursor
parent 8d98304137
commit ae3ed7c7b7
159 changed files with 59509 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
data/*.db
data/*.db-*
data/.admin-credentials
data/.ai-settings.json
data/sessions.json
__pycache__/
*.pyc
.DS_Store
+34
View File
@@ -0,0 +1,34 @@
FROM python:3.12-alpine
WORKDIR /app
# Build context is the repo root (see docker-compose.yml)
COPY kikomiko/admin/server.py \
kikomiko/admin/import_catalog.py \
kikomiko/admin/export_catalog.py \
kikomiko/admin/kiko_enrich.py \
kikomiko/admin/ai_translate.py \
kikomiko/admin/detail_schema.py \
kikomiko/admin/README.txt \
kikomiko/admin/seed_categories.json \
kikomiko/admin/sample-remote-products.json \
./
COPY kikomiko/admin/fetchers ./fetchers
COPY kikomiko/admin/static ./static
# Real font files (admin/static/fonts is a symlink in the git tree)
COPY kikomiko/dist/fonts ./static/fonts
RUN mkdir -p /app/data /export/catalog \
&& adduser -D -u 10001 app \
&& chown -R app:app /app /export
USER app
ENV ADMIN_HOST=0.0.0.0 \
ADMIN_PORT=8090 \
KIKOMIKO_CATALOG_PATH=/export/catalog/catalog.dedupe2.json \
PYTHONUNBUFFERED=1
EXPOSE 8090
CMD ["python3", "server.py", "--host", "0.0.0.0", "--port", "8090"]
+35
View File
@@ -0,0 +1,35 @@
Kikomiko Admin CMS (self-hosted)
Stack: Python 3 stdlib + SQLite + HTML/CSS/vanilla JS. No CDN, no npm.
START
cd admin
python3 server.py --port 8090
First run prints a one-time admin password and writes it to
data/.admin-credentials (gitignored). Override with env:
ADMIN_USER=admin ADMIN_PASS='your-strong-pass' python3 server.py
Open http://127.0.0.1:8090/
FEATURES
- Login (PBKDF2 password hash, HttpOnly session cookie, CSRF, login rate limit)
- Categories CRUD
- Products CRUD (matches storefront catalog fields)
- Import site category tree (seed_categories.json)
- Import products from catalog.dedupe2.json / uploaded catalog JSON
- Export active catalog to ../dist/data/catalog.dedupe2.json
- Fetch / sync hooks via admin/fetchers/*.py (see example_json.py)
LARGE CATALOG IMPORT (recommended for 100MB+ dumps)
python3 import_catalog.py /path/to/catalog.json --export
Docker / VPS:
docker cp catalog.json kikomiko-admin-1:/tmp/catalog.json
docker exec kikomiko-admin-1 python3 import_catalog.py /tmp/catalog.json --export
SECURITY NOTES
- Bind is 127.0.0.1 by default. Do not expose without TLS + reverse proxy.
- Change the generated password after first login.
- Keep data/*.db and data/.admin-credentials private.
+420
View File
@@ -0,0 +1,420 @@
"""Translate product copy to Persian via an OpenAI-compatible Chat Completions API."""
from __future__ import annotations
import json
import os
import re
import ssl
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
def _ssl_contexts() -> list[ssl.SSLContext]:
contexts: list[ssl.SSLContext] = []
try:
import certifi # type: ignore
contexts.append(ssl.create_default_context(cafile=certifi.where()))
except Exception:
pass
try:
contexts.append(ssl.create_default_context())
except Exception:
pass
contexts.append(ssl._create_unverified_context())
return contexts
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
SETTINGS_PATH = DATA_DIR / ".ai-settings.json"
DEFAULT_BASE_URL = "https://api.openai.com/v1"
DEFAULT_MODEL = "gpt-4o-mini"
ARABIC_SCRIPT = re.compile(r"[\u0600-\u06FF]")
SYSTEM_PROMPT = (
"You are a professional Persian (Farsi) copywriter for a luxury cosmetics retailer in Iran. "
"Translate the given product text into natural, polished Persian. "
"Keep brand names, shade codes, SPF, numbers, percentages, and product line names in Latin script when that is normal for cosmetics. "
"Do not add marketing fluff that is not in the source. "
"Return only the translated text, with no quotes or commentary."
)
CATEGORY_SYSTEM_PROMPT = (
"You translate cosmetics ecommerce category names into short, natural Persian (Farsi) labels "
"for an Iranian KIKO Milano storefront. "
"Return only the Persian category title — no quotes, no explanation. "
"Keep well-known brand tokens like KIKO in Latin if needed. "
"Prefer concise menu labels (1–4 words)."
)
def _mask_key(key: str) -> str:
if not key:
return ""
return key[:3] + "…" + key[-4:] if len(key) > 8 else "••••"
def _default_settings() -> dict:
return {
"apiKey": os.environ.get("OPENAI_API_KEY", "").strip(),
"baseUrl": os.environ.get("OPENAI_BASE_URL", DEFAULT_BASE_URL).strip()
or DEFAULT_BASE_URL,
"model": os.environ.get("OPENAI_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL,
"serpApiKey": os.environ.get("SERPAPI_API_KEY", os.environ.get("SERP_API_KEY", "")).strip(),
"categoryApiKey": os.environ.get("CATEGORY_TRANSLATE_API_KEY", "").strip(),
}
def load_settings() -> dict:
data = _default_settings()
if SETTINGS_PATH.is_file():
try:
raw = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
if isinstance(raw, dict):
for key in ("apiKey", "baseUrl", "model", "serpApiKey", "categoryApiKey"):
if isinstance(raw.get(key), str) and raw[key].strip():
data[key] = raw[key].strip()
except (OSError, json.JSONDecodeError):
pass
data["baseUrl"] = (data.get("baseUrl") or DEFAULT_BASE_URL).rstrip("/")
# Guard: SerpAPI dashboard/search hosts are not Chat Completions base URLs.
if "serpapi.com" in data["baseUrl"].lower():
data["baseUrl"] = DEFAULT_BASE_URL
data["model"] = data.get("model") or DEFAULT_MODEL
data["serpApiKey"] = data.get("serpApiKey") or ""
data["categoryApiKey"] = data.get("categoryApiKey") or ""
return data
def save_settings(
*,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
serp_api_key: str | None = None,
category_api_key: str | None = None,
) -> dict:
current = load_settings()
if api_key is not None:
current["apiKey"] = api_key.strip()
if base_url is not None:
current["baseUrl"] = (base_url.strip() or DEFAULT_BASE_URL).rstrip("/")
if model is not None:
current["model"] = model.strip() or DEFAULT_MODEL
if serp_api_key is not None:
current["serpApiKey"] = serp_api_key.strip()
if category_api_key is not None:
current["categoryApiKey"] = category_api_key.strip()
DATA_DIR.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(
json.dumps(current, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
try:
os.chmod(SETTINGS_PATH, 0o600)
except OSError:
pass
return public_settings(current)
def public_settings(settings: dict | None = None) -> dict:
data = settings or load_settings()
key = data.get("apiKey") or ""
serp = data.get("serpApiKey") or ""
cat_key = data.get("categoryApiKey") or ""
return {
"hasKey": bool(key),
"keyHint": _mask_key(key),
"hasSerpKey": bool(serp),
"serpKeyHint": _mask_key(serp),
"hasCategoryKey": bool(cat_key),
"categoryKeyHint": _mask_key(cat_key),
"baseUrl": data.get("baseUrl") or DEFAULT_BASE_URL,
"model": data.get("model") or DEFAULT_MODEL,
}
def looks_persian(text: str) -> bool:
letters = [c for c in text if c.isalpha()]
if len(letters) < 8:
# Short labels: any Arabic-script char counts
return bool(ARABIC_SCRIPT.search(text or ""))
persian = sum(1 for c in letters if ARABIC_SCRIPT.match(c))
return (persian / len(letters)) >= 0.35
def needs_translation(text: str, *, overwrite: bool = False) -> bool:
value = (text or "").strip()
if not value:
return False
if overwrite:
return not looks_persian(value)
return not looks_persian(value)
def chat_translate(
text: str,
*,
settings: dict | None = None,
timeout: int = 90,
system_prompt: str | None = None,
user_prefix: str | None = None,
api_key_override: str | None = None,
) -> str:
data = settings or load_settings()
api_key = (api_key_override or data.get("apiKey") or "").strip()
if not api_key:
raise ValueError("AI API key is not configured in Settings")
base = (data.get("baseUrl") or DEFAULT_BASE_URL).rstrip("/")
model = data.get("model") or DEFAULT_MODEL
url = f"{base}/chat/completions"
payload = {
"model": model,
"temperature": 0.2,
"messages": [
{"role": "system", "content": system_prompt or SYSTEM_PROMPT},
{
"role": "user",
"content": f"{user_prefix or 'Translate this cosmetics product text to Persian:'}\n\n{text}",
},
],
}
# SerpAPI keys / dashboard URLs cannot call Chat Completions.
if "serpapi.com" in base.lower():
raise ValueError(
"OpenAI base URL is set to SerpAPI. Use https://api.openai.com/v1 for translation."
)
if api_key == (data.get("serpApiKey") or "").strip() and api_key:
raise ValueError(
"The OpenAI key field currently holds your SerpAPI key. "
"Paste a real OpenAI (or compatible) key for translation."
)
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "kikomiko-admin/1.0",
},
)
raw = ""
last_err: Exception | None = None
for ctx in _ssl_contexts():
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
raw = resp.read().decode("utf-8", "ignore")
last_err = None
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "ignore")[:400]
raise ValueError(f"AI API HTTP {exc.code}: {detail or exc.reason}") from exc
except urllib.error.URLError as exc:
last_err = exc
continue
if last_err is not None:
raise ValueError(f"AI API unreachable: {getattr(last_err, 'reason', last_err)}") from last_err
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("AI API returned invalid JSON") from exc
try:
content = parsed["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ValueError("AI API response missing translation content") from exc
out = str(content or "").strip()
if out.startswith("```"):
out = re.sub(r"^```(?:\w+)?\s*", "", out)
out = re.sub(r"\s*```$", "", out)
if (out.startswith('"') and out.endswith('"')) or (
out.startswith("«") and out.endswith("»")
):
out = out[1:-1].strip()
if not out:
raise ValueError("AI API returned an empty translation")
return out
def collect_jobs(product: dict, *, overwrite: bool = False) -> list[tuple[str, str]]:
"""Return list of (path, text) that still need Persian translation."""
jobs: list[tuple[str, str]] = []
desc = str(product.get("description") or "").strip()
if needs_translation(desc, overwrite=overwrite):
jobs.append(("description", desc))
for key in ("descriptionIntro", "tagline", "seoDescription", "seoTitle"):
value = str(product.get(key) or "").strip()
if needs_translation(value, overwrite=overwrite):
jobs.append((key, value))
variants = product.get("colorVariants") or product.get("color_variants") or []
if isinstance(variants, list):
for i, variant in enumerate(variants):
if not isinstance(variant, dict):
continue
value = str(variant.get("description") or "").strip()
if needs_translation(value, overwrite=overwrite):
jobs.append((f"colorVariants.{i}.description", value))
return jobs
def apply_translation(product: dict, path: str, translated: str) -> None:
if path == "description":
if not (product.get("sourceDescription") or "").strip():
product["sourceDescription"] = product.get("description") or ""
product["description"] = translated
return
if path.startswith("colorVariants."):
parts = path.split(".")
idx = int(parts[1])
variants = product.get("colorVariants") or []
if isinstance(variants, list) and 0 <= idx < len(variants):
variants[idx]["description"] = translated
product["colorVariants"] = variants
return
product[path] = translated
def translate_product(
product: dict,
*,
overwrite: bool = False,
settings: dict | None = None,
) -> dict[str, Any]:
"""Translate pending English fields on one product. Mutates and returns stats."""
jobs = collect_jobs(product, overwrite=overwrite)
changed: list[str] = []
errors: list[str] = []
for path, text in jobs:
try:
translated = chat_translate(text, settings=settings)
apply_translation(product, path, translated)
changed.append(path)
except Exception as exc: # noqa: BLE001 — surface per-field failures
errors.append(f"{path}: {exc}")
break # stop this product on first API failure (often key/quota)
return {
"id": product.get("id"),
"slug": product.get("slug"),
"changed": changed,
"errors": errors,
"pending": len(jobs),
}
def category_needs_fa(category: dict, *, overwrite: bool = False) -> bool:
"""True when nameFa is missing or still English/non-Persian."""
name = str(category.get("name") or category.get("slug") or "").strip()
name_fa = str(category.get("nameFa") or category.get("name_fa") or "").strip()
if not name:
return False
if not name_fa:
return True
if overwrite:
return not looks_persian(name_fa)
return not looks_persian(name_fa)
def resolve_category_api_key(settings: dict | None = None) -> str:
"""OpenAI-compatible key for categories; never reuse a SerpAPI key by mistake."""
data = settings or load_settings()
cat = (data.get("categoryApiKey") or "").strip()
main = (data.get("apiKey") or "").strip()
serp = (data.get("serpApiKey") or "").strip()
if cat and cat != serp:
return cat
if main and main != serp:
return main
return ""
def local_category_fa(name: str, slug: str = "") -> str:
"""Dictionary / seed Persian label without calling an external API."""
try:
import server as admin_server # lazy: avoids import cycle at module load
for candidate in (
admin_server.seed_name_fa_by_slug().get(slug or "", ""),
admin_server.persian_category_label(name),
admin_server.persian_category_label((slug or "").replace("-", " ")),
admin_server.persian_category_label((slug or "").replace("-", " / ")),
):
text = str(candidate or "").strip()
if text and looks_persian(text):
return text
except Exception:
pass
return ""
def translate_category_name(
category: dict,
*,
overwrite: bool = False,
settings: dict | None = None,
) -> dict[str, Any]:
"""Translate one category English name → Persian nameFa. Mutates category.
Prefers local seed/KIKO maps (works offline); falls back to AI when needed.
"""
data = settings or load_settings()
if not category_needs_fa(category, overwrite=overwrite):
return {
"slug": category.get("slug"),
"changed": False,
"nameFa": category.get("nameFa") or category.get("name_fa") or "",
"source": "existing",
"errors": [],
}
source = str(category.get("name") or category.get("slug") or "").strip()
slug = str(category.get("slug") or "").strip()
local = local_category_fa(source, slug)
if local:
category["nameFa"] = local
category["name_fa"] = local
return {
"slug": slug,
"changed": True,
"from": source,
"nameFa": local,
"source": "local",
"errors": [],
}
api_key = resolve_category_api_key(data)
if not api_key:
raise ValueError(
"No Persian mapping for this category and no OpenAI key is set. "
"Save a real OpenAI API key (not the SerpAPI key) in Settings, or add the label to seed_categories."
)
translated = chat_translate(
source,
settings=data,
system_prompt=CATEGORY_SYSTEM_PROMPT,
user_prefix="Translate this cosmetics category name to a short Persian menu label:",
api_key_override=api_key,
)
if not looks_persian(translated):
raise ValueError(f"AI returned non-Persian category label: {translated!r}")
category["nameFa"] = translated
category["name_fa"] = translated
return {
"slug": slug,
"changed": True,
"from": source,
"nameFa": translated,
"source": "ai",
"errors": [],
}
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Build catalog-with-details.json from the 604-family import file + KIKO pages.
Usage:
cd admin
python3 build_catalog_with_details.py
python3 build_catalog_with_details.py --limit 20
python3 build_catalog_with_details.py --resume
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DIST_DATA = ROOT.parent / "dist" / "data"
SRC = DIST_DATA / "catalog-import-ready-families.json"
OUT = DIST_DATA / "catalog-with-details.json"
STATE = DIST_DATA / "catalog-with-details.state.json"
sys.path.insert(0, str(ROOT))
from detail_schema import ensure_product_queries # noqa: E402
from kiko_enrich import ( # noqa: E402
_family_key_from_product_url,
discover_catalog_product_urls,
discover_product_url,
fetch_kiko_product,
)
def _norm_tokens(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
def family_candidates(product: dict) -> list[str]:
out: list[str] = []
slug = (product.get("slug") or "").strip().lower()
if slug:
out.append(slug)
m = re.match(r"^(.+)-(\d+)-(\d+)$", slug)
if m:
out.append(m.group(1))
m = re.match(r"^(.+)-(\d{1,3})[a-z]{0,4}$", slug)
if m:
out.append(m.group(1))
out.append(re.sub(r"-\d+$", "", slug))
name = product.get("name") or ""
name_base = re.sub(r"\s+\d+[A-Za-z]{0,4}$", "", name).strip()
out.append(_norm_tokens(name_base))
out.append(_norm_tokens(name))
seen = set()
uniq = []
for k in out:
if k and k not in seen:
seen.add(k)
uniq.append(k)
return uniq
def load_json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0, help="Max products to enrich (0=all)")
ap.add_argument("--resume", action="store_true")
ap.add_argument("--market", default="en-gb")
ap.add_argument("--sleep", type=float, default=0.4)
args = ap.parse_args()
src = load_json(SRC)
products = list(src.get("products") or [])
if args.resume and OUT.exists():
prev = load_json(OUT)
by_id = {p.get("id"): p for p in prev.get("products") or [] if p.get("id")}
products = [by_id.get(p.get("id"), p) for p in products]
print(f"Resumed from {OUT} ({len(by_id)} prior rows)")
print("Loading KIKO sitemap families…")
discovered = discover_catalog_product_urls(args.market, one_per_family=True)
by_fam = {_family_key_from_product_url(u): u for u in discovered["urls"]}
print(f"KIKO families: {len(by_fam)}")
state = {"ok": 0, "fail": 0, "skip": 0, "errors": []}
if STATE.exists() and args.resume:
try:
state.update(load_json(STATE))
except Exception:
pass
limit = args.limit if args.limit > 0 else len(products)
enriched = 0
for i, product in enumerate(products):
if enriched >= limit:
break
product = ensure_product_queries(dict(product))
if product.get("details"):
state["skip"] = state.get("skip", 0) + 1
products[i] = product
continue
url = None
for key in family_candidates(product):
if key in by_fam:
url = by_fam[key]
break
if not url:
try:
url = discover_product_url(product.get("name") or "", market=args.market)
except Exception:
url = None
if not url:
state["fail"] = state.get("fail", 0) + 1
state.setdefault("errors", []).append(
{"id": product.get("id"), "name": product.get("name"), "error": "no KIKO url"}
)
products[i] = product
enriched += 1
continue
try:
fetched = fetch_kiko_product(url=url, market=args.market)
details = fetched.get("details") or []
if details:
product["details"] = details
if fetched.get("detailQueries") and not product.get("detailQueries"):
product["detailQueries"] = fetched["detailQueries"]
if fetched.get("sourceUrl"):
product["sourceUrl"] = fetched["sourceUrl"]
if fetched.get("sourceDescription") and not (product.get("description") or "").strip():
product["description"] = fetched.get("description") or fetched["sourceDescription"]
local_vars = list(product.get("colorVariants") or [])
remote_vars = list(fetched.get("colorVariants") or [])
if local_vars and remote_vars:
by_code = {
str(v.get("colorCode") or "").strip(): v
for v in remote_vars
if v.get("colorCode")
}
by_shade = {
str(v.get("shade") or "").strip().lower(): v
for v in remote_vars
if v.get("shade")
}
for lv in local_vars:
rv = (
by_code.get(str(lv.get("colorCode") or "").strip())
or by_shade.get(str(lv.get("shade") or "").strip().lower())
)
if not rv:
continue
if rv.get("details") and not lv.get("details"):
lv["details"] = rv["details"]
if rv.get("sourceUrl"):
lv["sourceUrl"] = rv["sourceUrl"]
product["colorVariants"] = local_vars
product = ensure_product_queries(product)
state["ok"] = state.get("ok", 0) + 1
except Exception as exc: # noqa: BLE001
state["fail"] = state.get("fail", 0) + 1
state.setdefault("errors", []).append(
{
"id": product.get("id"),
"name": product.get("name"),
"url": url,
"error": str(exc),
}
)
products[i] = product
enriched += 1
if enriched % 5 == 0 or enriched == 1:
print(
f"[{enriched}/{limit}] ok={state['ok']} fail={state['fail']} "
f"skip={state.get('skip', 0)} last={product.get('name')}"
)
out = {
**src,
"metadata": {
**(src.get("metadata") or {}),
"note": "604 families with KIKO detail tables where matched",
"withDetails": sum(1 for p in products if p.get("details")),
"buildState": {
"ok": state["ok"],
"fail": state["fail"],
"skip": state.get("skip", 0),
},
},
"products": products,
}
save_json(OUT, out)
save_json(STATE, {**state, "errors": (state.get("errors") or [])[-80:]})
time.sleep(max(0.0, args.sleep))
products = [ensure_product_queries(dict(p)) for p in products]
with_details = sum(1 for p in products if p.get("details"))
out = {
**src,
"format": "kikomiko-importer-v2",
"metadata": {
**(src.get("metadata") or {}),
"note": "604 families with KIKO detail tables where matched",
"productFamilies": len(products),
"withDetails": with_details,
"withDescription": sum(
1 for p in products if (p.get("description") or "").strip()
),
"buildState": {
"ok": state["ok"],
"fail": state["fail"],
"skip": state.get("skip", 0),
},
},
"products": products,
}
save_json(OUT, out)
save_json(STATE, {**state, "errors": (state.get("errors") or [])[-80:]})
print(f"Wrote {OUT}")
print(f"withDetails={with_details}/{len(products)} ok={state['ok']} fail={state['fail']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
"""Canonical product-detail keys + SerpAPI query templates for import/export.
Every product gets normalized `details` rows and a `detailQueries` map so future
imports, exports, and SerpAPI enrichment stay consistent.
"""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
# Stable keys used on the PDP facts table (storefront + admin).
DETAIL_LABELS: dict[str, str] = {
"brand": "برند",
"madeIn": "ساخت",
"size": "حجم / سایز",
"pao": "PAO",
"finish": "فینیش",
"coverage": "پوشش",
"skinType": "نوع پوست",
"skinTone": "ته‌رنگ پوست",
"skinUndertone": "آندرتون",
"category": "دسته‌بندی",
"collection": "کالکشن",
"howToUse": "نحوه استفاده",
"labTests": "تست‌ها",
"shade": "شید",
"colorCode": "کد رنگ",
"ingredients": "ترکیبات",
"texture": "بافت",
"spf": "SPF",
"longevity": "ماندگاری",
}
# Product-level keys (variant-only keys excluded from default product queries).
PRODUCT_DETAIL_KEYS = [
"brand",
"madeIn",
"size",
"pao",
"finish",
"coverage",
"skinType",
"category",
"collection",
"howToUse",
"labTests",
"ingredients",
"texture",
"spf",
"longevity",
]
VARIANT_DETAIL_KEYS = ["shade", "colorCode", "skinTone", "skinUndertone"]
# SerpAPI / search query templates. `{name}` and `{brand}` are filled per product.
DETAIL_QUERY_TEMPLATES: dict[str, str] = {
"brand": "{name} brand official",
"madeIn": "{name} {brand} made in country of origin",
"size": "{name} {brand} size ml volume",
"pao": "{name} {brand} PAO period after opening",
"finish": "{name} {brand} finish effect",
"coverage": "{name} {brand} coverage",
"skinType": "{name} {brand} skin type",
"category": "{name} {brand} product category",
"collection": "{name} {brand} collection line",
"howToUse": "{name} {brand} how to use apply",
"labTests": "{name} {brand} lab tests dermatologically tested",
"ingredients": "{name} {brand} ingredients INCI",
"texture": "{name} {brand} texture formula",
"spf": "{name} {brand} SPF sun protection",
"longevity": "{name} {brand} wear time longevity hours",
"shade": "{name} {brand} shade {shade}",
"colorCode": "{name} {brand} shade code {shade}",
"skinTone": "{name} {brand} {shade} skin tone",
"skinUndertone": "{name} {brand} {shade} undertone",
}
SERP_ENDPOINT = "https://serpapi.com/search.json"
def detail_label(key: str) -> str:
return DETAIL_LABELS.get(key, key)
def _rows_from_value(value: Any) -> list[dict]:
if value is None:
return []
if isinstance(value, list):
return [r for r in value if isinstance(r, dict)]
if isinstance(value, dict):
return [
{"key": str(k), "label": detail_label(str(k)), "value": v}
for k, v in value.items()
if v not in (None, "")
]
return []
def normalize_details(value: Any, *, prefer_keys: list[str] | None = None) -> list[dict]:
"""Normalize details to [{key,label,value}, ...] with stable keys/labels."""
rows = _rows_from_value(value)
out: list[dict] = []
seen: set[str] = set()
for row in rows:
key = str(row.get("key") or "").strip()
label = str(row.get("label") or "").strip()
raw_val = row.get("value")
if raw_val is None:
continue
if isinstance(raw_val, list):
text = "، ".join(str(x) for x in raw_val if x not in (None, ""))
else:
text = str(raw_val).strip()
if not text:
continue
if not key:
# Map known Persian labels back to keys when importers omit key.
reverse = {v: k for k, v in DETAIL_LABELS.items()}
key = reverse.get(label, re.sub(r"[^\w]+", "_", label.lower()).strip("_") or "field")
if key in seen:
continue
seen.add(key)
out.append({"key": key, "label": detail_label(key) if key in DETAIL_LABELS else (label or key), "value": text})
# Optional: keep preferred product keys first for readable exports.
if prefer_keys:
rank = {k: i for i, k in enumerate(prefer_keys)}
out.sort(key=lambda r: (rank.get(r["key"], 1000), r["key"]))
return out
def build_detail_queries(
product: dict,
*,
existing: dict | None = None,
variant: dict | None = None,
) -> dict[str, str]:
"""Build/merge SerpAPI query strings for product (and optional variant) detail keys."""
name = str(product.get("name") or product.get("nameFa") or "").strip() or "KIKO product"
brand = "KIKO Milano"
for row in normalize_details(product.get("details")):
if row["key"] == "brand" and row["value"]:
brand = row["value"]
break
shade = ""
if variant:
shade = str(variant.get("shade") or variant.get("colorCode") or "").strip()
keys = list(PRODUCT_DETAIL_KEYS)
if variant:
keys = list(VARIANT_DETAIL_KEYS)
merged = dict(existing or {})
for key in keys:
if merged.get(key):
continue
template = DETAIL_QUERY_TEMPLATES.get(key)
if not template:
continue
merged[key] = (
template.replace("{name}", name)
.replace("{brand}", brand)
.replace("{shade}", shade or name)
.strip()
)
return merged
def ensure_product_queries(product: dict) -> dict:
"""Mutate product: normalize details + attach detailQueries for import/export."""
product["details"] = normalize_details(
product.get("details") or product.get("specifications"),
prefer_keys=PRODUCT_DETAIL_KEYS,
)
product.pop("specifications", None)
product["detailQueries"] = build_detail_queries(
product, existing=product.get("detailQueries") if isinstance(product.get("detailQueries"), dict) else None
)
variants = product.get("colorVariants") or product.get("variants") or []
if isinstance(variants, list):
for v in variants:
if not isinstance(v, dict):
continue
v["details"] = normalize_details(
v.get("details") or v.get("specifications"),
prefer_keys=VARIANT_DETAIL_KEYS,
)
v.pop("specifications", None)
v["detailQueries"] = build_detail_queries(
product,
existing=v.get("detailQueries") if isinstance(v.get("detailQueries"), dict) else None,
variant=v,
)
product["colorVariants"] = variants
product.pop("variants", None)
return product
def missing_detail_keys(product: dict, *, overwrite: bool = False) -> list[str]:
present = {
row["key"]
for row in normalize_details(product.get("details"))
if (overwrite is False and (row.get("value") or "").strip())
}
if overwrite:
present = set()
return [k for k in PRODUCT_DETAIL_KEYS if k not in present and k in DETAIL_QUERY_TEMPLATES]
def serp_search(query: str, *, api_key: str, num: int = 5, timeout: int = 45) -> dict:
"""Run a Google search via SerpAPI. Returns parsed JSON."""
query = (query or "").strip()
api_key = (api_key or "").strip()
if not query:
raise ValueError("Empty SerpAPI query")
if not api_key:
raise ValueError("SerpAPI key is not configured")
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"num": str(max(1, min(int(num), 10))),
"hl": "en",
"gl": "gb",
}
url = SERP_ENDPOINT + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url,
headers={"User-Agent": "kikomiko-admin/1.0", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "ignore")[:300]
raise ValueError(f"SerpAPI HTTP {exc.code}: {detail or exc.reason}") from exc
except urllib.error.URLError as exc:
raise ValueError(f"SerpAPI unreachable: {getattr(exc, 'reason', exc)}") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("SerpAPI returned invalid JSON") from exc
def extract_answer_from_serp(payload: dict) -> str:
"""Pick the best short answer text from a SerpAPI response."""
if not isinstance(payload, dict):
return ""
answer = payload.get("answer_box") or {}
if isinstance(answer, dict):
for key in ("answer", "snippet", "result"):
if answer.get(key):
return str(answer[key]).strip()
if answer.get("snippet_highlighted_words"):
return " ".join(str(x) for x in answer["snippet_highlighted_words"])
kg = payload.get("knowledge_graph") or {}
if isinstance(kg, dict):
for key in ("description", "title"):
if kg.get(key):
return str(kg[key]).strip()
for item in payload.get("organic_results") or []:
if isinstance(item, dict) and item.get("snippet"):
return str(item["snippet"]).strip()
return ""
def fill_details_via_serp(
product: dict,
*,
api_key: str,
keys: list[str] | None = None,
overwrite: bool = False,
) -> dict[str, Any]:
"""Fill missing product details using detailQueries + SerpAPI. Mutates product."""
ensure_product_queries(product)
targets = keys or missing_detail_keys(product, overwrite=overwrite)
queries = product.get("detailQueries") or {}
by_key = {row["key"]: row for row in product.get("details") or []}
filled: list[str] = []
errors: list[str] = []
for key in targets:
query = (queries.get(key) or DETAIL_QUERY_TEMPLATES.get(key) or "").strip()
if not query:
continue
# Resolve templates if still templated
query = (
query.replace("{name}", str(product.get("name") or ""))
.replace("{brand}", "KIKO Milano")
.replace("{shade}", "")
.strip()
)
try:
payload = serp_search(query, api_key=api_key)
answer = extract_answer_from_serp(payload)
if not answer:
errors.append(f"{key}: no SerpAPI answer")
continue
by_key[key] = {"key": key, "label": detail_label(key), "value": answer}
filled.append(key)
except Exception as exc: # noqa: BLE001
errors.append(f"{key}: {exc}")
if "HTTP 401" in str(exc) or "HTTP 403" in str(exc) or "HTTP 429" in str(exc):
break
# Keep preferred order
ordered = []
seen = set()
for key in PRODUCT_DETAIL_KEYS:
if key in by_key:
ordered.append(by_key[key])
seen.add(key)
for key, row in by_key.items():
if key not in seen:
ordered.append(row)
product["details"] = ordered
return {
"id": product.get("id"),
"slug": product.get("slug"),
"filled": filled,
"errors": errors,
"targets": targets,
}
def default_catalog_detail_queries() -> dict[str, str]:
"""Catalog-level template map exported for tooling / future importers."""
return dict(DETAIL_QUERY_TEMPLATES)
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Export complete editable catalog, including inactive records; no secrets."""
import argparse
from pathlib import Path
import server
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('output', type=Path)
args = parser.parse_args()
if args.output.exists():
parser.error('Output exists. Choose a new file to avoid overwriting a backup.')
server.init_db()
print(server.export_import_bundle(args.output.resolve()))
+52
View File
@@ -0,0 +1,52 @@
"""Pluggable product fetch / stock-sync scripts for the admin CMS.
Each fetcher module must expose:
NAME: str
DESCRIPTION: str
fetch_products(config: dict) -> list[dict]
sync_stocks(config: dict, products: list[dict]) -> list[dict]
Product dict keys (camelCase or snake_case accepted by importer):
id, slug, name, nameFa/name_fa, price, compareAtPrice/compare_at_price,
stock, heroImage/hero_image, category, categoryFa/category_fa,
description, sku, externalId/external_id, colorVariants/color_variants,
source
Each colorVariants item may include:
id, slug, name, shade, colorHex, price, compareAtPrice, stock,
heroImage, sku, externalId, images[]
"""
from __future__ import annotations
import importlib
import pkgutil
from pathlib import Path
_PACKAGE = Path(__file__).resolve().parent
def list_fetchers():
items = []
for mod in pkgutil.iter_modules([str(_PACKAGE)]):
if mod.name.startswith("_"):
continue
module = importlib.import_module(f"fetchers.{mod.name}")
if not hasattr(module, "NAME"):
continue
items.append(
{
"id": mod.name,
"name": getattr(module, "NAME"),
"description": getattr(module, "DESCRIPTION", ""),
"supports_fetch": callable(getattr(module, "fetch_products", None)),
"supports_sync": callable(getattr(module, "sync_stocks", None)),
}
)
return sorted(items, key=lambda x: x["id"])
def get_fetcher(fetcher_id: str):
if not fetcher_id or "/" in fetcher_id or ".." in fetcher_id:
raise ValueError("invalid fetcher id")
return importlib.import_module(f"fetchers.{fetcher_id}")
+84
View File
@@ -0,0 +1,84 @@
"""Example fetcher: read products / stock from a local or remote JSON file.
Config (JSON in admin UI):
{
"url": "https://example.com/products.json", // or omit for local path
"path": "/absolute/or/relative/products.json",
"stock_only": false
}
Expected JSON shapes:
{ "products": [ {...}, ... ] }
or a bare array of products.
For stock sync, each item needs at least: id|externalId|sku|slug and stock.
"""
from __future__ import annotations
import json
import urllib.request
from pathlib import Path
NAME = "Example JSON source"
DESCRIPTION = (
"Import products or sync stock from a JSON URL/path. "
"Replace this module with a real site-specific scraper when ready."
)
def _load_payload(config: dict):
url = (config or {}).get("url")
path = (config or {}).get("path")
if url:
req = urllib.request.Request(
url,
headers={"User-Agent": "KikomikoAdmin/1.0"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
if path:
p = Path(path).expanduser()
if not p.is_file():
raise FileNotFoundError(f"JSON path not found: {p}")
return json.loads(p.read_text(encoding="utf-8"))
raise ValueError("config needs url or path")
def _as_list(payload):
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("products", "items", "data"):
if isinstance(payload.get(key), list):
return payload[key]
raise ValueError("JSON must be a product array or {products:[...]}")
def fetch_products(config: dict):
rows = _as_list(_load_payload(config))
for row in rows:
row.setdefault("source", "example_json")
return rows
def sync_stocks(config: dict, products: list):
"""Return [{id|external_id|sku|slug, stock}, ...] for matching DB rows."""
rows = _as_list(_load_payload(config))
out = []
for row in rows:
if not isinstance(row, dict):
continue
stock = row.get("stock")
if stock is None:
continue
out.append(
{
"id": row.get("id"),
"external_id": row.get("externalId") or row.get("external_id"),
"sku": row.get("sku"),
"slug": row.get("slug"),
"stock": int(stock),
}
)
return out
+71
View File
@@ -0,0 +1,71 @@
"""Fetcher: pull one or more products from kikocosmetics.com product URLs.
Config JSON in admin Fetch panel:
{
"urls": [
"https://www.kikocosmetics.com/en-gb/p/love-fusion-24h-moisture-radiant-foundation-1-52012/"
],
"market": "en-gb"
}
Or a single "url" / "query" for one product.
"""
from __future__ import annotations
from kiko_enrich import fetch_kiko_product
NAME = "KIKO Cosmetics (official site)"
DESCRIPTION = (
"Fetch product description, details table and color variants from kikocosmetics.com. "
"Upserts by sku/name — does not invent duplicate families."
)
def fetch_products(config: dict):
config = config or {}
market = config.get("market") or "en-gb"
urls = config.get("urls")
rows = []
if isinstance(urls, list) and urls:
for url in urls:
if not url:
continue
rows.append(fetch_kiko_product(url=str(url), market=market))
return rows
product = fetch_kiko_product(
url=config.get("url"),
query=config.get("query") or config.get("name"),
market=market,
)
return [product]
def sync_stocks(config: dict, products: list):
"""Refresh sellable flags from KIKO for products that already have sourceUrl/sku."""
config = config or {}
market = config.get("market") or "en-gb"
out = []
for p in products or []:
url = p.get("sourceUrl")
sku = p.get("sku")
if not url and not sku:
continue
try:
fetched = fetch_kiko_product(
url=url,
query=p.get("name") if not url else None,
market=market,
)
except Exception:
continue
out.append(
{
"id": p.get("id"),
"sku": fetched.get("sku") or sku,
"external_id": fetched.get("externalId") or p.get("externalId"),
"slug": p.get("slug"),
"stock": int(fetched.get("stock") or 0),
}
)
return out
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Import a full catalog.json into the admin SQLite DB (no HTTP / no size limit).
Usage:
python3 import_catalog.py /path/to/catalog.json
python3 import_catalog.py /path/to/catalog.json --export
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Reuse server helpers
sys.path.insert(0, str(Path(__file__).resolve().parent))
import server # noqa: E402
def map_export_category(raw: dict) -> dict:
"""Map export {id,parent_id,name,full_path} -> admin category row fields."""
name = str(raw.get("name") or raw.get("full_path") or "").strip()
if not name:
raise ValueError("category missing name")
slug = str(raw.get("slug") or server._slugify(name))
parent_slug = None
parent_id = raw.get("parent_id") or raw.get("parentSlug") or raw.get("parent_slug")
if parent_id:
parent_name = str(parent_id).split(" / ")[-1].strip()
parent_slug = server._slugify(parent_name)
return {
"slug": slug,
"name": name.title() if name == name.upper() else name,
"name_fa": str(raw.get("name_fa") or raw.get("nameFa") or ""),
"parent_slug": parent_slug,
"sort_order": int(raw.get("sort_order") or raw.get("sortOrder") or 0),
"active": raw.get("active", True),
}
def import_categories(payload: dict) -> int:
cats = payload.get("categories") or []
if not isinstance(cats, list):
return 0
# parents first (shorter paths)
cats = sorted(cats, key=lambda c: str(c.get("full_path") or c.get("id") or "").count("/"))
count = 0
with server.db() as conn:
for raw in cats:
if not isinstance(raw, dict):
continue
try:
server.upsert_category(conn, map_export_category(raw))
count += 1
except Exception as exc:
print(f" category skip: {exc}", file=sys.stderr)
return count
def main():
parser = argparse.ArgumentParser(description="Import catalog.json into admin DB")
parser.add_argument("catalog", type=Path, help="Path to catalog.json")
parser.add_argument(
"--export",
action="store_true",
help="Also export active products to storefront catalog.dedupe2.json",
)
parser.add_argument(
"--products-only",
action="store_true",
help="Skip categories",
)
args = parser.parse_args()
path = args.catalog.expanduser().resolve()
if not path.is_file():
print(f"File not found: {path}", file=sys.stderr)
sys.exit(1)
print(f"Loading {path} ({path.stat().st_size // 1_000_000} MB)…")
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, (dict, list)):
print("Invalid JSON root", file=sys.stderr)
sys.exit(1)
server.init_db()
if isinstance(payload, dict) and not args.products_only:
n = import_categories(payload)
print(f"Categories imported/updated: {n}")
print("Importing products…")
result = server.import_products_payload(payload)
print(
f"Products created={result['created']} updated={result['updated']} "
f"skipped={result['skipped']} errors={len(result['errors'])}"
)
if result["errors"]:
print("First errors:", file=sys.stderr)
for err in result["errors"][:10]:
print(f" {err}", file=sys.stderr)
if args.export:
out = server.export_catalog()
print(f"Exported {out['count']} products → {out['path']}")
with server.db() as conn:
pc = conn.execute("SELECT COUNT(*) AS c FROM products").fetchone()["c"]
cc = conn.execute("SELECT COUNT(*) AS c FROM categories").fetchone()["c"]
print(f"DB totals: products={pc} categories={cc}")
if __name__ == "__main__":
main()
+625
View File
@@ -0,0 +1,625 @@
"""Fetch one KIKO product page and map it to Kikomiko importer fields.
Used by the admin "دریافت از KIKO" button so each product can get its own
description + details table without creating a duplicate family.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
def _ssl_context() -> ssl.SSLContext:
"""Prefer system/certifi CAs; fall back if the host has a broken cert chain."""
try:
import certifi # type: ignore
return ssl.create_default_context(cafile=certifi.where())
except Exception:
try:
return ssl.create_default_context()
except Exception:
return ssl._create_unverified_context()
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
try:
from detail_schema import DETAIL_LABELS, ensure_product_queries
except ImportError: # pragma: no cover
DETAIL_LABELS = {
"brand": "برند",
"madeIn": "ساخت",
"size": "حجم / سایز",
"pao": "PAO",
"finish": "فینیش",
"coverage": "پوشش",
"skinType": "نوع پوست",
"skinTone": "ته‌رنگ پوست",
"skinUndertone": "آندرتون",
"category": "دسته‌بندی",
"collection": "کالکشن",
"howToUse": "نحوه استفاده",
"labTests": "تست‌ها",
"shade": "شید",
"colorCode": "کد رنگ",
}
def ensure_product_queries(product: dict) -> dict:
return product
COUNTRY = {
"IT": "ایتالیا",
"FR": "فرانسه",
"US": "آمریکا",
"GB": "بریتانیا",
"DE": "آلمان",
"ES": "اسپانیا",
}
DEFAULT_MARKET = "en-gb"
def _http_get(url: str, timeout: int = 45) -> str:
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-GB,en;q=0.9",
},
)
last_err: Exception | None = None
for ctx in (_ssl_context(), ssl._create_unverified_context()):
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return resp.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as exc:
raise ValueError(f"KIKO HTTP {exc.code} for {url}") from exc
except urllib.error.URLError as exc:
last_err = exc
continue
raise ValueError(f"Could not reach KIKO: {getattr(last_err, 'reason', last_err)}")
def _next_data(html: str) -> dict:
m = re.search(
r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>',
html,
re.S | re.I,
)
if not m:
raise ValueError("KIKO page has no __NEXT_DATA__ (not a product page, or blocked).")
return json.loads(m.group(1))
def _slugify(value: str) -> str:
text = re.sub(r"[^\w\s-]", "", value or "", flags=re.U)
text = re.sub(r"[\s_]+", "-", text.strip().lower())
return re.sub(r"-+", "-", text).strip("-")
def _detail(key: str, value: Any) -> dict | None:
if value is None:
return None
if isinstance(value, list):
text = "، ".join(str(x) for x in value if x not in (None, ""))
else:
text = str(value).strip()
if not text:
return None
return {"key": key, "label": DETAIL_LABELS.get(key, key), "value": text}
def _parse_shade(color: str | None) -> dict:
raw = str(color or "").strip()
m = re.match(r"^(\d+(?:\.\d+)?)([A-Z]{0,3})\s*(.*)$", raw, re.I)
if not m:
return {"colorCode": None, "colorName": raw or None, "shade": raw or None}
code = f"{m.group(1)}{(m.group(2) or '').upper()}"
name = (m.group(3) or "").strip() or None
return {
"colorCode": code,
"colorName": name,
"shade": f"{code} {name}" if name else code,
}
def _media_urls(item: dict) -> list[str]:
urls: list[str] = []
def push(u: Any) -> None:
if isinstance(u, str) and u and u not in urls:
urls.append(u)
primary = item.get("primary_image")
if isinstance(primary, dict):
push(primary.get("url"))
product_media = item.get("product_media") or {}
if isinstance(product_media, dict):
for m in product_media.get("media") or []:
if isinstance(m, dict):
push(m.get("url"))
else:
push(m)
for u in item.get("media_urls") or []:
push(u)
for m in item.get("extra_media") or []:
if isinstance(m, dict):
push(m.get("url"))
else:
push(m)
return urls
def _money(value: Any) -> float:
try:
n = float(value)
except (TypeError, ValueError):
return 0.0
return n if n == n else 0.0 # NaN guard
def _to_variant(item: dict, parent_slug: str, description: str = "") -> dict:
shade = _parse_shade(item.get("color"))
images = _media_urls(item)
name = (
item.get("display_value")
or item.get("product_name")
or f"{parent_slug} {shade['shade'] or ''}".strip()
)
slug = item.get("slug") or _slugify(name)
return {
"id": str(item.get("product_id") or item.get("backend_id") or slug),
"slug": slug,
"name": name,
"shade": shade["shade"],
"colorCode": shade["colorCode"],
"colorName": shade["colorName"],
"colorHex": item.get("hex_color"),
"price": _money(item.get("display_price")),
"priceEur": _money(item.get("display_price")),
"compareAtPrice": _money(item.get("historical_price")) or None,
"stock": 1 if str(item.get("is_sellable")).lower() == "true" else 0,
"sku": item.get("backend_id") or item.get("custom_id"),
"externalId": str(item.get("product_id") or ""),
"heroImage": images[0] if images else None,
"images": images,
"description": description
or item.get("long_description")
or item.get("short_description")
or "",
"details": [
x
for x in (
_detail("shade", shade["shade"]),
_detail("colorCode", shade["colorCode"]),
_detail("skinTone", item.get("skin_tone")),
_detail("skinUndertone", item.get("skin_undertone")),
)
if x
],
"sourceAvailable": str(item.get("is_sellable")).lower() == "true",
"sourcePrice": _money(item.get("display_price")),
"sourceCurrency": item.get("currency_id"),
}
def _build_details(selected: dict, root: dict) -> list[dict]:
made_in = COUNTRY.get(selected.get("country_of_origin") or "") or selected.get(
"country_of_origin"
)
category = " / ".join(
filter(
None,
[
selected.get("product_class_lev1_desc"),
selected.get("product_class_lev2_desc"),
selected.get("product_class_lev3_desc"),
selected.get("product_class_lev4_desc"),
],
)
)
rows = [
_detail("brand", "KIKO Milano"),
_detail("madeIn", made_in),
_detail(
"size",
f"{selected['legal_volume']} ml"
if selected.get("legal_volume") is not None
else None,
),
_detail(
"pao",
f"{selected['pao']} ماه" if selected.get("pao") is not None else None,
),
_detail("finish", selected.get("finish_effect")),
_detail("coverage", selected.get("coverage")),
_detail("skinType", selected.get("specificity_for_skin_type")),
_detail("category", category),
_detail("collection", selected.get("collection")),
_detail(
"howToUse",
selected.get("ed_cont_how_to_use") or root.get("ed_cont_how_to_use"),
),
_detail(
"labTests",
selected.get("ed_cont_lab_tests") or root.get("ed_cont_lab_tests"),
),
]
return [r for r in rows if r]
def extract_from_html(html: str, source_url: str) -> dict:
data = _next_data(html)
props = data.get("props", {}).get("pageProps") or {}
root = props.get("root")
selected = props.get("selected")
children = props.get("children") or []
if not isinstance(root, dict) or not isinstance(selected, dict):
raise ValueError("Not a KIKO product detail page.")
parent_name = root.get("display_value") or root.get("product_name") or selected.get(
"display_value"
)
parent_slug = root.get("slug") or _slugify(parent_name or "kiko-product")
description = (
selected.get("long_description")
or root.get("long_description")
or selected.get("short_description")
or root.get("short_description")
or ""
)
tagline = selected.get("short_description") or root.get("short_description") or ""
by_id: dict[str, dict] = {}
for child in children:
if isinstance(child, dict):
v = _to_variant(child, parent_slug)
by_id[v["id"]] = v
selected_variant = _to_variant(selected, parent_slug, description=description)
by_id[selected_variant["id"]] = {
**by_id.get(selected_variant["id"], {}),
**selected_variant,
"images": selected_variant["images"]
or by_id.get(selected_variant["id"], {}).get("images")
or [],
}
color_variants = sorted(
by_id.values(),
key=lambda v: str(v.get("shade") or v.get("name") or ""),
)
category = " / ".join(
filter(
None,
[
selected.get("product_class_lev1_desc"),
selected.get("product_class_lev2_desc"),
selected.get("product_class_lev3_desc"),
],
)
)
sku = root.get("backend_id") or selected.get("backend_id")
external_id = str(root.get("product_id") or selected.get("product_id") or "")
product = {
"slug": parent_slug,
"name": parent_name,
"nameFa": parent_name,
"displayName": parent_name,
"category": category or None,
"description": description,
"descriptionIntro": tagline,
"tagline": tagline,
"seoTitle": parent_name,
"seoDescription": (tagline or description)[:160],
"sourceDescription": description,
"sourceTagline": tagline,
"sourceIngredients": selected.get("ingredient_list") or "",
"details": _build_details(selected, root),
"defaultVariantId": selected_variant["id"],
"heroImage": selected_variant.get("heroImage"),
"images": selected_variant.get("images") or [],
"price": selected_variant.get("price") or 0,
"priceEur": selected_variant.get("priceEur")
or selected_variant.get("sourcePrice")
or selected_variant.get("price")
or 0,
"sourcePrice": selected_variant.get("sourcePrice")
or selected_variant.get("price")
or 0,
"sourceCurrency": selected_variant.get("sourceCurrency") or "EUR",
"stock": sum(int(v.get("stock") or 0) for v in color_variants),
"sku": sku,
"externalId": external_id,
"source": "kikocosmetics",
"sourceUrl": source_url.split("?")[0],
"colorVariants": color_variants,
"match": {
"sku": sku,
"externalId": external_id,
"slugs": list(
dict.fromkeys(
[parent_slug] + [v.get("slug") for v in color_variants if v.get("slug")]
)
),
"name": parent_name,
},
}
return ensure_product_queries(product)
def normalize_kiko_url(url: str, market: str = DEFAULT_MARKET) -> str:
url = (url or "").strip()
if not url:
raise ValueError("KIKO product URL is required.")
# Allow pasting host-only / path-only values.
if url in ("kikocosmetics.com", "www.kikocosmetics.com"):
raise ValueError(
"Paste a full product page URL, e.g. "
"https://www.kikocosmetics.com/en-gb/p/love-fusion-24h-moisture-radiant-foundation-1-52012/"
)
if not url.startswith("http"):
if url.startswith("kikocosmetics.com") or url.startswith("www.kikocosmetics.com"):
url = "https://" + url
else:
url = "https://www.kikocosmetics.com" + (
url if url.startswith("/") else f"/{url}"
)
host = urllib.parse.urlparse(url).netloc.lower()
if "kikocosmetics.com" not in host:
raise ValueError("URL must be on kikocosmetics.com")
if "/p/" not in url:
raise ValueError(
"This is not a product page. Open the product on KIKO, copy the address bar "
"(it must contain /p/…), then paste it here."
)
return url.split("#")[0]
def discover_product_url(query: str, market: str = DEFAULT_MARKET) -> str | None:
"""Best-effort search: return first /p/ URL matching the query name."""
query = (query or "").strip()
if not query:
return None
search = (
f"https://www.kikocosmetics.com/{market}/search/"
f"?query={urllib.parse.quote(query)}"
)
html = _http_get(search)
data = _next_data(html)
blob = json.dumps(data, ensure_ascii=False)
paths = re.findall(rf"/{re.escape(market)}/p/[a-z0-9-]+-\d+", blob, flags=re.I)
# Prefer paths whose slug tokens overlap the query
tokens = [t for t in re.split(r"[^a-z0-9]+", query.lower()) if len(t) > 2]
ranked = []
for path in dict.fromkeys(paths):
score = sum(1 for t in tokens if t in path.lower())
ranked.append((score, path))
ranked.sort(key=lambda x: (-x[0], x[1]))
if not ranked:
# fallback: any /p/ in raw HTML
paths = re.findall(rf"/{re.escape(market)}/p/[a-z0-9-]+-\d+", html, flags=re.I)
if not paths:
return None
return "https://www.kikocosmetics.com" + paths[0]
best_score, best = ranked[0]
if best_score <= 0 and tokens:
# weak match — still return first search hit if any
pass
return "https://www.kikocosmetics.com" + best
def _family_key_from_product_url(url: str) -> str:
"""Collapse shade URLs into one family key.
Example:
.../p/jelly-stylo-501-46800/ → jelly-stylo
.../p/daily-protection-bb-cream-spf-30-01-42063/ → daily-protection-bb-cream-spf-30
"""
path = urllib.parse.urlparse(url).path.rstrip("/")
slug = path.split("/")[-1].lower()
m = re.match(r"^(.+)-(\d+)-(\d+)$", slug)
return m.group(1) if m else slug
def discover_catalog_product_urls(
market: str = DEFAULT_MARKET,
*,
one_per_family: bool = True,
) -> dict[str, Any]:
"""Pull product page URLs from the official KIKO market sitemap."""
market = (market or DEFAULT_MARKET).strip() or DEFAULT_MARKET
sitemap_url = f"https://www.kikocosmetics.com/{market}/sitemap.xml"
xml = _http_get(sitemap_url, timeout=90)
pattern = rf"https://www\.kikocosmetics\.com/{re.escape(market)}/p/[a-z0-9-]+/?"
all_urls = [u.rstrip("/") + "/" for u in re.findall(pattern, xml, flags=re.I)]
# preserve sitemap order, drop dupes
seen: set[str] = set()
ordered: list[str] = []
for u in all_urls:
key = u.lower()
if key in seen:
continue
seen.add(key)
ordered.append(u)
family_urls: list[str] = []
if one_per_family:
fam_seen: set[str] = set()
for u in ordered:
fam = _family_key_from_product_url(u)
if fam in fam_seen:
continue
fam_seen.add(fam)
family_urls.append(u)
else:
family_urls = list(ordered)
return {
"market": market,
"sitemapUrl": sitemap_url,
"shadeUrls": len(ordered),
"familyUrls": len(family_urls),
"urls": family_urls,
}
def fetch_kiko_product(
*,
url: str | None = None,
query: str | None = None,
market: str = DEFAULT_MARKET,
) -> dict:
market = (market or DEFAULT_MARKET).strip() or DEFAULT_MARKET
source_url = None
if url:
source_url = normalize_kiko_url(url, market=market)
elif query:
source_url = discover_product_url(query, market=market)
if not source_url:
raise ValueError(
"No KIKO product page found for that name. Paste the product URL instead."
)
else:
raise ValueError("Provide a KIKO product URL or a search name.")
html = _http_get(source_url)
product = extract_from_html(html, source_url)
product["sourceMarket"] = market
return product
def merge_enrichment(
existing: dict,
incoming: dict,
*,
overwrite_description: bool = False,
) -> dict:
"""Merge KIKO enrichment into one existing product; keep local id/slug."""
out = dict(existing)
out["id"] = existing.get("id")
out["slug"] = existing.get("slug") or incoming.get("slug")
out["details"] = incoming.get("details") or out.get("details") or []
for key in (
"sourceDescription",
"sourceTagline",
"sourceIngredients",
"sourceUrl",
"sourceMarket",
"source",
"sourcePrice",
"sourceCurrency",
"priceEur",
"sku",
"externalId",
"tagline",
"descriptionIntro",
"heroImage",
"images",
):
if incoming.get(key) not in (None, "", []):
out[key] = incoming[key]
# Keep euro retail on the product for storefront FX conversion.
if incoming.get("priceEur") not in (None, "", 0):
out["priceEur"] = incoming["priceEur"]
out["sourcePrice"] = incoming.get("sourcePrice") or incoming["priceEur"]
if incoming.get("sourceCurrency"):
out["sourceCurrency"] = incoming["sourceCurrency"]
# Do not overwrite local toman `price` with raw euro when one already exists.
existing_price = float(out.get("price") or 0)
if existing_price <= 0 or existing_price < 500:
out["price"] = incoming.get("price") or incoming["priceEur"]
incoming_desc = (incoming.get("description") or "").strip()
existing_desc = (out.get("description") or "").strip()
if overwrite_description and incoming_desc:
if existing_desc:
out["sourceDescription"] = out.get("sourceDescription") or incoming_desc
out["description"] = incoming_desc
elif not existing_desc and incoming_desc:
out["description"] = incoming_desc
elif incoming_desc:
out["sourceDescription"] = out.get("sourceDescription") or incoming_desc
# Merge variant shade details / media onto existing variants where possible.
existing_variants = list(out.get("colorVariants") or out.get("variants") or [])
by_slug = {str(v.get("slug") or ""): v for v in existing_variants if v.get("slug")}
by_code = {
str(v.get("colorCode") or ""): v for v in existing_variants if v.get("colorCode")
}
by_shade = {str(v.get("shade") or ""): v for v in existing_variants if v.get("shade")}
used = set()
merged = []
for inc in incoming.get("colorVariants") or []:
match = (
by_slug.get(str(inc.get("slug") or ""))
or by_code.get(str(inc.get("colorCode") or ""))
or by_shade.get(str(inc.get("shade") or ""))
)
if match:
item = dict(match)
for key in (
"shade",
"colorCode",
"colorName",
"colorHex",
"sku",
"externalId",
"heroImage",
"images",
"details",
"sourceAvailable",
"sourcePrice",
"sourceCurrency",
"priceEur",
):
if inc.get(key) not in (None, "", []):
item[key] = inc[key]
# Mirror euro retail onto variant price when still empty / euro-scale.
inc_eur = inc.get("priceEur") or inc.get("sourcePrice") or inc.get("price")
if inc_eur not in (None, "", 0):
item["priceEur"] = inc_eur
item["sourcePrice"] = inc.get("sourcePrice") or inc_eur
cur_price = float(item.get("price") or 0)
if cur_price <= 0 or cur_price < 500:
item["price"] = inc_eur
if not (item.get("description") or "").strip() and (inc.get("description") or "").strip():
item["description"] = inc["description"]
used.add(id(match))
merged.append(item)
else:
merged.append(dict(inc))
for v in existing_variants:
if id(v) not in used:
merged.append(v)
# Deduplicate
deduped = []
seen_ids, seen_slugs = set(), set()
for v in merged:
vid, vslug = str(v.get("id") or ""), str(v.get("slug") or "")
if vid and vid in seen_ids:
continue
if vslug and vslug in seen_slugs:
continue
if vid:
seen_ids.add(vid)
if vslug:
seen_slugs.add(vslug)
deduped.append(v)
out["colorVariants"] = deduped
out.pop("variants", None)
if incoming.get("detailQueries") and not out.get("detailQueries"):
out["detailQueries"] = incoming["detailQueries"]
return ensure_product_queries(out)
+36
View File
@@ -0,0 +1,36 @@
{
"schemaVersion": 2,
"metadata": {"note": "TEST DATA ONLY. Do not publish these illustrative products or prices."},
"categories": [{"slug": "sample-lips", "name": "Sample lips", "nameFa": "دسته آزمایشی"}],
"products": [{
"id": "example-family",
"slug": "example-family",
"name": "Example Official Product Name",
"nameFa": "محصول آزمایشی",
"category": "sample-lips",
"active": false,
"description": "توضیحات فارسی مشترک محصول؛ این رکورد فقط برای تست است.",
"seoTitle": "عنوان سئوی محصول آزمایشی",
"seoDescription": "متای فارسی آزمایشی",
"details": [{"key": "texture", "label": "بافت", "value": "مقدار آزمایشی"}],
"defaultVariantId": "example-01",
"colorVariants": [{
"id": "example-01", "slug": "example-family-01",
"name": "Example Official Product Name 01",
"shade": "01 Rose", "colorCode": "01", "colorName": "Rose",
"price": 100, "stock": 1,
"description": "توضیحات فارسی مختص رنگ اول",
"seoTitle": "عنوان سئو رنگ اول", "seoDescription": "متای رنگ اول",
"details": [{"key": "finish", "label": "فینیش", "value": "مقدار آزمایشی اول"}],
"images": [], "heroImage": null
}, {
"id": "example-02", "slug": "example-family-02",
"name": "Example Official Product Name 02",
"shade": "02 Red", "colorCode": "02", "colorName": "Red",
"price": 200, "stock": 0,
"description": "توضیحات فارسی مختص رنگ دوم",
"details": [{"key": "finish", "label": "فینیش", "value": "مقدار آزمایشی دوم"}],
"images": [], "heroImage": null
}]
}]
}
@@ -0,0 +1,70 @@
{
"products": [
{
"id": "demo-lip-gloss-01",
"slug": "3d-hydra-lipgloss",
"name": "3D Hydra Lipgloss",
"price": 890000,
"compareAtPrice": 1100000,
"stock": 18,
"heroImage": "/images/collab/placeholder.jpg",
"category": "lips-make-up",
"categoryFa": "لب",
"sku": "KIKO-LIP",
"externalId": "remote-1001",
"source": "example_json",
"colorVariants": [
{
"id": "demo-lip-gloss-01-v1",
"slug": "3d-hydra-lipgloss-01",
"name": "3D Hydra Lipgloss 01",
"shade": "01",
"colorHex": "#e8a0a8",
"price": 890000,
"compareAtPrice": 1100000,
"stock": 12,
"sku": "KIKO-LIP-01",
"externalId": "remote-1001-01",
"heroImage": "/images/collab/placeholder.jpg"
},
{
"id": "demo-lip-gloss-01-v2",
"slug": "3d-hydra-lipgloss-11",
"name": "3D Hydra Lipgloss 11",
"shade": "11",
"colorHex": "#b44a5a",
"price": 890000,
"compareAtPrice": 1100000,
"stock": 6,
"sku": "KIKO-LIP-11",
"externalId": "remote-1001-11",
"heroImage": "/images/collab/placeholder.jpg"
}
]
},
{
"id": "demo-mascara-01",
"slug": "new-volume-mascara",
"name": "New Volume Mascara",
"price": 1250000,
"stock": 4,
"category": "eyes-make-up",
"categoryFa": "چشم",
"sku": "KIKO-EYE-01",
"externalId": "remote-1002",
"source": "example_json",
"colorVariants": [
{
"id": "demo-mascara-01-v1",
"slug": "new-volume-mascara-black",
"name": "New Volume Mascara Black",
"shade": "Black",
"colorHex": "#111111",
"price": 1250000,
"stock": 4,
"sku": "KIKO-EYE-01-BK"
}
]
}
]
}
+25
View File
@@ -0,0 +1,25 @@
[
{"slug": "make-up", "name": "Make-up", "name_fa": "آرایش", "parent_slug": null, "sort_order": 10},
{"slug": "eyes-make-up", "name": "Eyes make-up", "name_fa": "چشم", "parent_slug": "make-up", "sort_order": 11},
{"slug": "face-make-up", "name": "Face make-up", "name_fa": "صورت", "parent_slug": "make-up", "sort_order": 12},
{"slug": "lips-make-up", "name": "Lips make-up", "name_fa": "لب", "parent_slug": "make-up", "sort_order": 13},
{"slug": "nails-make-up", "name": "Nails make-up", "name_fa": "ناخن", "parent_slug": "make-up", "sort_order": 14},
{"slug": "skin-care", "name": "Skin care", "name_fa": "مراقبت پوست", "parent_slug": null, "sort_order": 20},
{"slug": "face-skin-care", "name": "Face skin care", "name_fa": "مراقبت صورت", "parent_slug": "skin-care", "sort_order": 21},
{"slug": "lip-care", "name": "Lip care", "name_fa": "مراقبت لب", "parent_slug": "skin-care", "sort_order": 22},
{"slug": "sun-care", "name": "Sun care", "name_fa": "ضدآفتاب", "parent_slug": "skin-care", "sort_order": 23},
{"slug": "face-cleasing", "name": "Face cleansing", "name_fa": "پاک‌کننده صورت", "parent_slug": "skin-care", "sort_order": 24},
{"slug": "mask", "name": "Mask", "name_fa": "ماسک", "parent_slug": "skin-care", "sort_order": 25},
{"slug": "skin-care-kit", "name": "Skin care kit", "name_fa": "ست مراقبت پوست", "parent_slug": "skin-care", "sort_order": 26},
{"slug": "fragrances", "name": "Fragrances", "name_fa": "عطر", "parent_slug": null, "sort_order": 30},
{"slug": "accessories", "name": "Accessories", "name_fa": "اکسسوری", "parent_slug": null, "sort_order": 40},
{"slug": "brushes", "name": "Brushes", "name_fa": "براش", "parent_slug": "accessories", "sort_order": 41},
{"slug": "hand-and-foot-accessories", "name": "Hand and foot accessories", "name_fa": "اکسسوری دست و پا", "parent_slug": "accessories", "sort_order": 42},
{"slug": "face-accessories", "name": "Face accessories", "name_fa": "اکسسوری صورت", "parent_slug": "accessories", "sort_order": 43},
{"slug": "make-up-bag", "name": "Make-up bag", "name_fa": "کیف آرایشی", "parent_slug": "accessories", "sort_order": 44},
{"slug": "hair-accessories", "name": "Hair accessories", "name_fa": "اکسسوری مو", "parent_slug": "accessories", "sort_order": 45},
{"slug": "service", "name": "Service", "name_fa": "سرویس", "parent_slug": "accessories", "sort_order": 46},
{"slug": "empty-container", "name": "Empty container", "name_fa": "ظرف خالی", "parent_slug": "accessories", "sort_order": 47},
{"slug": "eye-accessories", "name": "Eye accessories", "name_fa": "اکسسوری چشم", "parent_slug": "accessories", "sort_order": 48},
{"slug": "accessories-kit", "name": "Accessories kit", "name_fa": "ست اکسسوری", "parent_slug": "accessories", "sort_order": 49}
]
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
@font-face {
font-family: "YekanBakh";
src: url("./fonts/yekan-bakh/YekanBakh_FaNum_Regular.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "YekanBakh";
src: url("./fonts/yekan-bakh/YekanBakh_FaNum_Medium.woff") format("woff");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "YekanBakh";
src: url("./fonts/yekan-bakh/YekanBakh_FaNum_Bold.woff") format("woff");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--bg: #f3e8e1;
--bg-deep: #e7d4c8;
--ink: #1c1410;
--muted: #6b574c;
--line: rgba(28, 20, 16, 0.12);
--card: rgba(255, 252, 249, 0.92);
--accent: #1c1410;
--ok: #1f6b4a;
--danger: #9b2c2c;
--warn: #8a5a12;
--shadow: 0 18px 40px rgba(48, 28, 18, 0.08);
--radius: 16px;
--font: "YekanBakh", "Tahoma", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: var(--font);
color: var(--ink);
background:
radial-gradient(1200px 600px at 10% -10%, #fff7f1 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #f0d7cb 0%, transparent 50%),
linear-gradient(180deg, var(--bg), var(--bg-deep));
line-height: 1.55;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
a { color: inherit; }
.hidden { display: none !important; }
/* Login */
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.login-card {
width: min(420px, 100%);
background: var(--card);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
box-shadow: var(--shadow);
padding: 2rem 1.6rem 1.6rem;
}
.login-card h1 {
margin: 0 0 0.35rem;
font-size: 1.55rem;
font-weight: 700;
}
.login-card .sub {
margin: 0 0 1.4rem;
color: var(--muted);
font-size: 0.95rem;
}
.field { margin-bottom: 0.9rem; }
.field label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
.field input, .field select, .field textarea {
width: 100%;
border: 1px solid var(--line);
background: #fff;
border-radius: 12px;
padding: 0.7rem 0.85rem;
outline: none;
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: rgba(28, 20, 16, 0.35);
box-shadow: 0 0 0 3px rgba(28, 20, 16, 0.08);
}
.field textarea { min-height: 90px; resize: vertical; }
.btn {
appearance: none;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.65rem 1rem;
background: var(--accent);
color: #fffaf6;
cursor: pointer;
font-weight: 500;
}
.btn:hover { filter: brightness(1.08); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--ink);
border-color: var(--line);
}
.btn-danger {
background: transparent;
color: var(--danger);
border-color: rgba(155, 44, 44, 0.35);
}
.btn-row { display: flex; flex-wrap: wrap; gap: 0.55rem; }
.msg {
margin: 0.75rem 0 0;
padding: 0.65rem 0.8rem;
border-radius: 10px;
font-size: 0.9rem;
}
.msg.error { background: rgba(155, 44, 44, 0.1); color: var(--danger); }
.msg.ok { background: rgba(31, 107, 74, 0.1); color: var(--ok); }
.msg.info { background: rgba(28, 20, 16, 0.06); color: var(--muted); }
/* Shell */
.app {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 1.4rem 1rem;
border-left: 1px solid var(--line);
background: rgba(255, 250, 246, 0.72);
backdrop-filter: blur(8px);
}
.brand {
font-weight: 700;
font-size: 1.2rem;
margin: 0 0.4rem 1.2rem;
}
.nav-btn {
width: 100%;
text-align: right;
background: transparent;
border: 0;
border-radius: 10px;
padding: 0.7rem 0.8rem;
cursor: pointer;
color: var(--muted);
margin-bottom: 0.2rem;
}
.nav-btn:hover { background: rgba(28, 20, 16, 0.05); color: var(--ink); }
.nav-btn.active {
background: var(--ink);
color: #fffaf6;
}
.sidebar-foot {
margin-top: 2rem;
padding: 0 0.4rem;
color: var(--muted);
font-size: 0.85rem;
}
.main {
padding: 1.4rem 1.5rem 2.5rem;
max-width: 1200px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.1rem;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-size: 1.45rem;
}
.panel-head p {
margin: 0.25rem 0 0;
color: var(--muted);
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1.2rem;
}
.stat {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
box-shadow: var(--shadow);
}
.stat strong {
display: block;
font-size: 1.5rem;
margin-top: 0.2rem;
}
.stat span { color: var(--muted); font-size: 0.9rem; }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem;
margin-bottom: 1rem;
}
.toolbar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.9rem;
align-items: center;
}
.toolbar input[type="search"] {
flex: 1;
min-width: 180px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
th, td {
text-align: right;
padding: 0.7rem 0.45rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th { color: var(--muted); font-weight: 500; }
tr:hover td { background: rgba(28, 20, 16, 0.02); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; direction: ltr; text-align: left; }
.badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid var(--line);
color: var(--muted);
}
.badge.on { color: var(--ok); border-color: rgba(31, 107, 74, 0.35); }
.badge.off { color: var(--danger); border-color: rgba(155, 44, 44, 0.35); }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
.grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.8rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(28, 20, 16, 0.35);
display: grid;
place-items: center;
padding: 1rem;
z-index: 40;
}
.modal {
width: min(920px, 100%);
max-height: min(90vh, 900px);
overflow: auto;
background: #fffaf7;
border-radius: 18px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 1.2rem;
}
.modal h3 { margin: 0 0 1rem; }
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0 1rem;
}
.variants-box {
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.85rem;
background: rgba(255, 255, 255, 0.65);
margin: 0.4rem 0 1rem;
}
.variants-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.variants-head h4 {
margin: 0;
font-size: 1rem;
}
.variants-head p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.variant-list {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.variant-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem;
background: #fff;
}
.variant-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.variant-card-top strong {
font-size: 0.92rem;
}
.variant-swatch {
width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
border: 1px solid var(--line);
display: inline-block;
vertical-align: middle;
margin-left: 0.35rem;
background: #ddd;
}
.variant-empty {
color: var(--muted);
font-size: 0.9rem;
margin: 0.2rem 0;
}
pre.out {
background: #1c1410;
color: #f7efe8;
border-radius: 12px;
padding: 0.9rem;
overflow: auto;
font-size: 0.82rem;
direction: ltr;
text-align: left;
max-height: 280px;
}
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
.sidebar {
border-left: 0;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.brand { width: 100%; margin-bottom: 0.4rem; }
.nav-btn { width: auto; }
.sidebar-foot { display: none; }
.stats, .grid-2, .grid-3 { grid-template-columns: 1fr; }
table { display: block; overflow-x: auto; }
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
../../dist/fonts
+349
View File
@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>پنل مدیریت کیکومیکو</title>
<link rel="stylesheet" href="/admin.css" />
</head>
<body>
<div id="loginView" class="login-wrap hidden">
<form class="login-card" id="loginForm">
<h1>کیکومیکو</h1>
<p class="sub">ورود به پنل مدیریت محتوا</p>
<div class="field">
<label for="username">نام کاربری</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<label for="password">رمز عبور</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
</div>
<button class="btn" type="submit" style="width:100%">ورود</button>
<p id="loginMsg" class="msg error hidden"></p>
</form>
</div>
<div id="appView" class="app hidden">
<aside class="sidebar">
<div class="brand">Kikomiko Admin</div>
<button type="button" class="nav-btn active" data-panel="dashboard">داشبورد</button>
<button type="button" class="nav-btn" data-panel="products">محصولات</button>
<button type="button" class="nav-btn" data-panel="categories">دسته‌ها</button>
<button type="button" class="nav-btn" data-panel="import">ورود / همگام‌سازی</button>
<button type="button" class="nav-btn" data-panel="settings">تنظیمات</button>
<button type="button" class="nav-btn" id="logoutBtn">خروج</button>
<div class="sidebar-foot">
کاربر: <span id="whoami"></span>
</div>
</aside>
<main class="main">
<section id="panel-dashboard" class="panel">
<div class="panel-head">
<div>
<h2>داشبورد</h2>
<p>خلاصه وضعیت کاتالوگ</p>
</div>
<button type="button" class="btn" id="exportBtn">خروجی به catalog.json</button>
<button type="button" class="btn btn-ghost" id="downloadCatalogBtn">دانلود JSON کامل با واریانت‌ها</button>
</div>
<div class="stats">
<div class="stat"><span>محصولات</span><strong id="statProducts">—</strong></div>
<div class="stat"><span>دسته‌ها</span><strong id="statCategories">—</strong></div>
<div class="stat"><span>موجودی کل</span><strong id="statStock">—</strong></div>
</div>
<div class="card">
<p class="msg info" style="margin:0">
بعد از ویرایش محصولات، «خروجی به catalog.json» را بزنید تا
<span class="mono">dist/data/catalog.dedupe2.json</span> برای فروشگاه به‌روز شود.
</p>
<pre class="out hidden" id="dashOut"></pre>
</div>
</section>
<section id="panel-products" class="panel hidden">
<div class="panel-head">
<div>
<h2>محصولات</h2>
<p>افزودن و ویرایش محصولات</p>
</div>
<button type="button" class="btn" id="newProductBtn">محصول جدید</button>
</div>
<div class="card">
<div class="toolbar">
<input type="search" id="productSearch" placeholder="جستجو: نام، اسلاگ، SKU…" />
</div>
<table>
<thead>
<tr>
<th>نام</th>
<th>دسته</th>
<th>واریانت</th>
<th>قیمت</th>
<th>موجودی</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody id="productRows"></tbody>
</table>
</div>
</section>
<section id="panel-categories" class="panel hidden">
<div class="panel-head">
<div>
<h2>دسته‌ها</h2>
<p>ساختار دسته‌بندی فروشگاه</p>
</div>
<div class="btn-row">
<button type="button" class="btn-ghost btn" id="seedCatsBtn">ورود دسته‌های سایت</button>
<button type="button" class="btn" id="newCategoryBtn">دسته جدید</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>عنوان</th>
<th>اسلاگ</th>
<th>والد</th>
<th>ترتیب</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody id="categoryRows"></tbody>
</table>
</div>
</section>
<section id="panel-import" class="panel hidden">
<div class="panel-head">
<div>
<h2>ورود داده و همگام‌سازی</h2>
<p>کاتالوگ قدیمی، اسکریپت واکشی، همگام‌سازی موجودی</p>
</div>
</div>
<div class="card">
<h3 style="margin-top:0">ورود کاتالوگ قدیمی</h3>
<p style="color:var(--muted);margin-top:0">از فایل فعلی فروشگاه یا JSON آپلود‌شده.</p>
<div class="btn-row">
<button type="button" class="btn" id="importDistBtn">ورود از dist/catalog.dedupe2.json</button>
<label class="btn btn-ghost" style="cursor:pointer">
آپلود JSON
<input type="file" id="importFile" accept="application/json,.json" hidden />
</label>
</div>
</div>
<div class="card">
<h3 style="margin-top:0">پشتیبان دیتابیس (SQLite)</h3>
<p style="color:var(--muted);margin-top:0">
دیتابیس را در Git نگه ندارید. از اینجا دانلود کنید و روی سرور دیگر بازیابی کنید.
فایل‌های خیلی بزرگ کاتالوگ JSON را همچنان با
<span class="mono">import_catalog.py</span> وارد کنید.
</p>
<div class="btn-row">
<button type="button" class="btn" id="downloadDbBtn">دانلود kikomiko.db</button>
<label class="btn btn-ghost" style="cursor:pointer">
بازیابی از فایل .db
<input type="file" id="importDbFile" accept=".db,application/octet-stream,application/x-sqlite3" hidden />
</label>
</div>
</div>
<div class="card">
<h3 style="margin-top:0">ورود دسته‌ای از KIKO (پیشنهادی)</h3>
<p style="color:var(--muted);margin-top:0;line-height:1.75">
لینک‌ها را دستی بچسبانید، یا از نقشهٔ سایت اصلی همهٔ خانواده‌های محصول را کشف و وارد کنید.
محصولات تکراری رد می‌شوند. بعد از اتمام، از داشبورد <strong>خروجی به catalog.json</strong> را بزنید.
</p>
<div class="field">
<label for="kikoBulkUrls">لینک‌های محصول KIKO (هر خط یکی)</label>
<textarea id="kikoBulkUrls" class="mono" dir="ltr" style="text-align:left;min-height:140px" placeholder="https://www.kikocosmetics.com/en-gb/p/product-a-123/&#10;https://www.kikocosmetics.com/en-gb/p/product-b-456/"></textarea>
</div>
<div class="grid-2">
<div class="field">
<label for="kikoBulkLimit">حداکثر در هر دسته</label>
<input id="kikoBulkLimit" type="number" min="1" max="80" value="15" />
</div>
<div class="field">
<label for="kikoBulkMarket">مارکت</label>
<input id="kikoBulkMarket" type="text" value="en-gb" dir="ltr" />
</div>
</div>
<label style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.5rem;font-size:0.9rem">
<input id="kikoBulkOverwrite" type="checkbox" />
اگر توضیح فارسی از قبل هست، با متن انگلیسی KIKO جایگزین شود
</label>
<label style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem;font-size:0.9rem">
<input id="kikoBulkSkipExisting" type="checkbox" checked />
محصولاتی که از قبل در ادمین هستند دوباره گرفته نشوند
</label>
<div class="btn-row">
<button type="button" class="btn" id="kikoDiscoverBtn">کشف همه از نقشه سایت</button>
<button type="button" class="btn" id="kikoCatalogAllBtn">ورود همه محصولات جدید (خودکار)</button>
</div>
<div class="btn-row" style="margin-top:0.5rem">
<button type="button" class="btn btn-ghost" id="kikoBulkUrlsBtn">ورود دسته‌ای از این لینک‌ها</button>
<button type="button" class="btn btn-ghost" id="kikoBulkExistingBtn">تکمیل محصولات موجودِ ناقص از KIKO</button>
</div>
<p id="kikoBulkMsg" class="msg hidden"></p>
</div>
<div class="card">
<h3 style="margin-top:0">Fetcher / Sync</h3>
<p style="color:var(--muted);margin-top:0">
اسکریپت‌های خودمیزبان در پوشه <span class="mono">admin/fetchers/</span>
</p>
<div class="grid-2">
<div class="field">
<label for="fetcherSelect">اسکریپت</label>
<select id="fetcherSelect"></select>
</div>
<div class="field">
<label for="fetcherAction">عملیات</label>
<select id="fetcherAction">
<option value="fetch">ورود محصولات (fetch)</option>
<option value="sync">همگام‌سازی موجودی (sync)</option>
</select>
</div>
</div>
<div class="field">
<label for="fetcherConfig">پیکربندی JSON</label>
<textarea id="fetcherConfig" class="mono" dir="ltr" style="text-align:left;min-height:120px">{
{
"path": "./sample-remote-products.json"
}
</textarea>
</div>
<button type="button" class="btn" id="runFetcherBtn">اجرا</button>
<p id="fetcherDesc" class="msg info" style="margin-top:0.8rem"></p>
</div>
<pre class="out" id="importOut">آماده.</pre>
</section>
<section id="panel-settings" class="panel hidden">
<div class="panel-head">
<div>
<h2>تنظیمات</h2>
<p>رمز عبور، کلید هوش مصنوعی و ترجمه توضیحات</p>
</div>
</div>
<div class="card" style="max-width:560px;margin-bottom:1.25rem">
<h3 style="margin:0 0 0.75rem;font-weight:600">ترجمه با هوش مصنوعی</h3>
<p class="muted" style="margin:0 0 1rem;font-size:0.9rem;line-height:1.7">
کلید API سازگار با OpenAI را ذخیره کنید، سپس توضیحات انگلیسی محصولات را به فارسی ترجمه کنید.
کلید فقط روی سرور ادمین نگه داشته می‌شود و در خروجی کاتالوگ نیست.
</p>
<form id="aiSettingsForm">
<div class="field">
<label for="aiApiKey">کلید OpenAI / Chat API</label>
<input id="aiApiKey" type="password" autocomplete="off" placeholder="sk-..." />
<p id="aiKeyHint" class="muted" style="margin:0.35rem 0 0;font-size:0.8rem"></p>
</div>
<div class="field">
<label for="aiBaseUrl">آدرس پایه API</label>
<input id="aiBaseUrl" type="url" placeholder="https://api.openai.com/v1" />
</div>
<div class="field">
<label for="aiModel">مدل</label>
<input id="aiModel" type="text" placeholder="gpt-4o-mini" />
</div>
<div class="field">
<label for="serpApiKey">کلید SerpAPI <span class="muted">(اختیاری)</span></label>
<input id="serpApiKey" type="password" autocomplete="off" placeholder="serpapi key" />
<p id="serpKeyHint" class="muted" style="margin:0.35rem 0 0;font-size:0.8rem"></p>
</div>
<div class="field">
<label for="categoryApiKey">کلید ترجمه دسته‌ها به فارسی <span class="muted">(اختیاری — در غیر این صورت از کلید OpenAI استفاده می‌شود)</span></label>
<input id="categoryApiKey" type="password" autocomplete="off" placeholder="sk-... یا همان کلید OpenAI" />
<p id="categoryKeyHint" class="muted" style="margin:0.35rem 0 0;font-size:0.8rem"></p>
</div>
<div class="btn-row">
<button class="btn" type="submit">ذخیره تنظیمات AI</button>
</div>
<p id="aiSettingsMsg" class="msg hidden"></p>
</form>
<hr style="border:0;border-top:1px solid rgba(0,0,0,0.08);margin:1.25rem 0" />
<h4 style="margin:0 0 0.5rem;font-weight:600">ترجمه دسته‌ها به فارسی</h4>
<p class="muted" style="margin:0 0 0.85rem;font-size:0.88rem;line-height:1.7">
عنوان انگلیسی دسته (`name`) را به `nameFa` ترجمه می‌کند تا منو و فیلترها فارسی شوند.
</p>
<div class="field">
<label for="catTranslateLimit">حداکثر دسته در هر اجرا</label>
<input id="catTranslateLimit" type="number" min="1" max="200" value="50" />
</div>
<label style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem;font-size:0.9rem">
<input id="catOverwrite" type="checkbox" />
بازنویسی نام فارسی موجود (اگر هنوز انگلیسی باشد)
</label>
<div class="btn-row">
<button class="btn btn-ghost" type="button" id="catTranslateDryRunBtn">پیش‌نمایش دسته‌های نیازمند ترجمه</button>
<button class="btn" type="button" id="catTranslateBtn">ترجمه دسته‌ها به فارسی</button>
</div>
<p id="catTranslateMsg" class="msg hidden"></p>
<hr style="border:0;border-top:1px solid rgba(0,0,0,0.08);margin:1.25rem 0" />
<h4 style="margin:0 0 0.5rem;font-weight:600">ترجمه توضیحات (OpenAI)</h4>
<div class="field">
<label for="aiTranslateLimit">حداکثر محصول در هر اجرا</label>
<input id="aiTranslateLimit" type="number" min="1" max="100" value="25" />
</div>
<label style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem;font-size:0.9rem">
<input id="aiOverwrite" type="checkbox" />
بازنویسی توضیحات فارسی موجود (فقط اگر هنوز انگلیسی باشد)
</label>
<div class="btn-row">
<button class="btn btn-ghost" type="button" id="aiTranslateDryRunBtn">پیش‌نمایش موارد نیازمند ترجمه</button>
<button class="btn" type="button" id="aiTranslateBtn">ترجمه توضیحات به فارسی</button>
</div>
<p id="aiTranslateMsg" class="msg hidden"></p>
<hr style="border:0;border-top:1px solid rgba(0,0,0,0.08);margin:1.25rem 0" />
<h4 style="margin:0 0 0.5rem;font-weight:600">پر کردن مشخصات با SerpAPI</h4>
<p class="muted" style="margin:0 0 0.85rem;font-size:0.88rem;line-height:1.7">
برای هر محصول، کوئری‌های استاندارد مشخصات (`detailQueries`) در ایمپورت/اکسپورت ذخیره می‌شود
(مثل howToUse، labTests، size، …). SerpAPI همان کوئری‌ها را برای فیلدهای خالی اجرا می‌کند.
</p>
<div class="field">
<label for="serpFillLimit">حداکثر محصول در هر اجرا</label>
<input id="serpFillLimit" type="number" min="1" max="50" value="10" />
</div>
<label style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem;font-size:0.9rem">
<input id="serpOverwrite" type="checkbox" />
بازنویسی مشخصات موجود
</label>
<div class="btn-row">
<button class="btn btn-ghost" type="button" id="serpDryRunBtn">پیش‌نمایش کوئری‌های ناقص</button>
<button class="btn" type="button" id="serpFillBtn">پر کردن مشخصات با SerpAPI</button>
</div>
<p id="serpFillMsg" class="msg hidden"></p>
<pre id="aiTranslateOut" class="muted" style="margin-top:0.75rem;white-space:pre-wrap;font-size:0.8rem;max-height:220px;overflow:auto"></pre>
</div>
<div class="card" style="max-width:560px">
<h3 style="margin:0 0 0.75rem;font-weight:600">تغییر رمز عبور</h3>
<form id="passwordForm">
<div class="field">
<label for="currentPass">رمز فعلی</label>
<input id="currentPass" type="password" required autocomplete="current-password" />
</div>
<div class="field">
<label for="newPass">رمز جدید (حداقل ۱۰ کاراکتر)</label>
<input id="newPass" type="password" required minlength="10" autocomplete="new-password" />
</div>
<button class="btn" type="submit">ذخیره رمز</button>
<p id="passMsg" class="msg hidden"></p>
</form>
</div>
</section>
</main>
</div>
<div id="modalRoot" class="modal-backdrop hidden"></div>
<script src="/admin.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
"""Run: python3 -m unittest -v test_catalog_roundtrip.py. Uses disposable DBs only."""
import copy
import json
import tempfile
import unittest
from pathlib import Path
import server
from import_catalog import import_categories
class CatalogTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
server.DATA_DIR = Path(self.tmp.name)
server.DB_PATH = server.DATA_DIR / 'test.sqlite'
server.DIST_CATALOG = server.DATA_DIR / 'public.json'
self.admin = server.ensure_admin_user
server.ensure_admin_user = lambda: None
server.init_db()
self.payload = {'metadata':{'eurToTomanRate':302700}, 'images':[{'id':'photo','original_url':'/i/photo','local_path':'media/photo.webp'}],
'categories':[{'slug':'lip-gloss','name':'Lip Gloss','nameFa':'لیپ گلاس','parentSlug':'lips'}],
'products':[{'id':'parent','slug':'lip-gloss','name':'Official English Name','nameFa':'لیپ گلاس کیکو',
'description':'توضیحات فارسی محصول', 'seoTitle':'عنوان سئو', 'seoDescription':'متای فارسی',
'details':[{'key':'finish','label':'فینیش','value':'براق'}],
'blocks':[{'type':'SPECIFICATIONS','content':{'custom':'preserved'}}],
'variants':[{'id':'shade01','slug':'lip-gloss-01','name':'Official English Name 01','colorCode':'01','colorName':'Rose',
'description':'توضیحات رنگ اول','seoTitle':'سئو رنگ اول','seoDescription':'متای رنگ اول',
'details':[{'key':'finish','label':'فینیش','value':'مات'}],
'price':123456,'stock':3,'images':['/media/a.webp','/media/b.webp'],'heroImage':'/media/a.webp'},
{'id':'shade02','slug':'lip-gloss-02','name':'Official English Name 02','shade':'02 Red',
'description':'توضیحات رنگ دوم','details':{'coverage':'full'},'price':234567,'stock':0}]}]}
def tearDown(self):
server.ensure_admin_user = self.admin
self.tmp.cleanup()
def read(self):
with server.db() as conn:
return server.product_dict(conn.execute('SELECT * FROM products').fetchone())
def test_roundtrip_and_public_export(self):
import_categories(self.payload)
result = server.import_products_payload(self.payload)
self.assertEqual(result['created'],1)
self.assertEqual(result['errors'],[])
first = self.read()
self.assertEqual(len(first['colorVariants']),2)
self.assertEqual(first['stock'],3)
for key in ('description','seoTitle','seoDescription','details','blocks'):
self.assertEqual(first[key],self.payload['products'][0][key])
for actual,expected in zip(first['colorVariants'],self.payload['products'][0]['variants']):
for key,value in expected.items(): self.assertEqual(actual[key],value)
server.export_catalog()
public=json.loads(server.DIST_CATALOG.read_text())
self.assertEqual(public['products'][0],first)
dest=server.DATA_DIR/'backup.json'
server.export_import_bundle(dest)
bundle=json.loads(dest.read_text())
server.DB_PATH=server.DATA_DIR/'second.sqlite'
server.init_db()
import_categories(bundle)
server.import_products_payload(bundle)
self.assertEqual(self.read(),first)
with server.db() as conn:
category=conn.execute('SELECT * FROM categories').fetchone()
self.assertEqual(category['slug'],'lip-gloss')
self.assertEqual(category['name_fa'],'لیپ گلاس')
def test_reimport_idempotent_and_partial_parent(self):
server.import_products_payload(self.payload)
self.assertEqual(server.import_products_payload(self.payload)['updated'],1)
server.import_products_payload({'products':[{'id':'parent','name':'Updated','slug':'lip-gloss'}]})
self.assertEqual(self.read()['description'],'توضیحات فارسی محصول')
self.assertEqual(len(self.read()['colorVariants']),2)
with server.db() as conn:self.assertEqual(conn.execute('SELECT COUNT(*) FROM products').fetchone()[0],1)
def test_invalid_variants_do_not_replace_existing(self):
server.import_products_payload(self.payload)
before=self.read()
bad=copy.deepcopy(self.payload)
bad['products'][0]['variants'][1]['id']='shade01'
self.assertEqual(server.import_products_payload(bad)['skipped'],1)
self.assertEqual(self.read(),before)
bad['products'][0]['variants'][1]['id']='shade02'
bad['products'][0]['variants'][0]['price']=float('nan')
self.assertEqual(server.import_products_payload(bad)['skipped'],1)
self.assertEqual(self.read(),before)
def test_inactive_retained_in_backup_not_public(self):
self.payload['products'][0]['active']=False
server.import_products_payload(self.payload)
server.export_catalog()
self.assertEqual(json.loads(server.DIST_CATALOG.read_text())['products'],[])
dest=server.DATA_DIR/'backup.json'
server.export_import_bundle(dest)
self.assertEqual(len(json.loads(dest.read_text())['products']),1)
def test_old_database_migration(self):
# Recreate the original product table by dropping only the newly added column.
with server.db() as conn:conn.execute('ALTER TABLE products DROP COLUMN content_json')
server.init_db()
server.init_db()
self.assertEqual(server.import_products_payload(self.payload)['created'],1)
def test_image_batches_merge(self):
server.import_products_payload(self.payload)
server.import_products_payload({'metadata':{'newKey':'x'},'images':[{'id':'another','original_url':'/i/another'}],'products':[]})
dest=server.DATA_DIR/'backup.json'
server.export_import_bundle(dest)
data=json.loads(dest.read_text())
self.assertEqual(len(data['images']),2)
self.assertEqual(data['metadata']['eurToTomanRate'],302700)
if __name__=='__main__': unittest.main()