first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
@@ -0,0 +1,8 @@
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/.admin-credentials
|
||||
data/.ai-settings.json
|
||||
data/sessions.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()))
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../dist/fonts
|
||||
@@ -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/ 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>
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user