first deployment of kikomiko with url photos

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shayan khoshrou
2026-10-19 19:10:57 +02:00
co-authored by Cursor
parent 8d98304137
commit ae3ed7c7b7
159 changed files with 59509 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
"""Canonical product-detail keys + SerpAPI query templates for import/export.
Every product gets normalized `details` rows and a `detailQueries` map so future
imports, exports, and SerpAPI enrichment stay consistent.
"""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
# Stable keys used on the PDP facts table (storefront + admin).
DETAIL_LABELS: dict[str, str] = {
"brand": "برند",
"madeIn": "ساخت",
"size": "حجم / سایز",
"pao": "PAO",
"finish": "فینیش",
"coverage": "پوشش",
"skinType": "نوع پوست",
"skinTone": "ته‌رنگ پوست",
"skinUndertone": "آندرتون",
"category": "دسته‌بندی",
"collection": "کالکشن",
"howToUse": "نحوه استفاده",
"labTests": "تست‌ها",
"shade": "شید",
"colorCode": "کد رنگ",
"ingredients": "ترکیبات",
"texture": "بافت",
"spf": "SPF",
"longevity": "ماندگاری",
}
# Product-level keys (variant-only keys excluded from default product queries).
PRODUCT_DETAIL_KEYS = [
"brand",
"madeIn",
"size",
"pao",
"finish",
"coverage",
"skinType",
"category",
"collection",
"howToUse",
"labTests",
"ingredients",
"texture",
"spf",
"longevity",
]
VARIANT_DETAIL_KEYS = ["shade", "colorCode", "skinTone", "skinUndertone"]
# SerpAPI / search query templates. `{name}` and `{brand}` are filled per product.
DETAIL_QUERY_TEMPLATES: dict[str, str] = {
"brand": "{name} brand official",
"madeIn": "{name} {brand} made in country of origin",
"size": "{name} {brand} size ml volume",
"pao": "{name} {brand} PAO period after opening",
"finish": "{name} {brand} finish effect",
"coverage": "{name} {brand} coverage",
"skinType": "{name} {brand} skin type",
"category": "{name} {brand} product category",
"collection": "{name} {brand} collection line",
"howToUse": "{name} {brand} how to use apply",
"labTests": "{name} {brand} lab tests dermatologically tested",
"ingredients": "{name} {brand} ingredients INCI",
"texture": "{name} {brand} texture formula",
"spf": "{name} {brand} SPF sun protection",
"longevity": "{name} {brand} wear time longevity hours",
"shade": "{name} {brand} shade {shade}",
"colorCode": "{name} {brand} shade code {shade}",
"skinTone": "{name} {brand} {shade} skin tone",
"skinUndertone": "{name} {brand} {shade} undertone",
}
SERP_ENDPOINT = "https://serpapi.com/search.json"
def detail_label(key: str) -> str:
return DETAIL_LABELS.get(key, key)
def _rows_from_value(value: Any) -> list[dict]:
if value is None:
return []
if isinstance(value, list):
return [r for r in value if isinstance(r, dict)]
if isinstance(value, dict):
return [
{"key": str(k), "label": detail_label(str(k)), "value": v}
for k, v in value.items()
if v not in (None, "")
]
return []
def normalize_details(value: Any, *, prefer_keys: list[str] | None = None) -> list[dict]:
"""Normalize details to [{key,label,value}, ...] with stable keys/labels."""
rows = _rows_from_value(value)
out: list[dict] = []
seen: set[str] = set()
for row in rows:
key = str(row.get("key") or "").strip()
label = str(row.get("label") or "").strip()
raw_val = row.get("value")
if raw_val is None:
continue
if isinstance(raw_val, list):
text = "، ".join(str(x) for x in raw_val if x not in (None, ""))
else:
text = str(raw_val).strip()
if not text:
continue
if not key:
# Map known Persian labels back to keys when importers omit key.
reverse = {v: k for k, v in DETAIL_LABELS.items()}
key = reverse.get(label, re.sub(r"[^\w]+", "_", label.lower()).strip("_") or "field")
if key in seen:
continue
seen.add(key)
out.append({"key": key, "label": detail_label(key) if key in DETAIL_LABELS else (label or key), "value": text})
# Optional: keep preferred product keys first for readable exports.
if prefer_keys:
rank = {k: i for i, k in enumerate(prefer_keys)}
out.sort(key=lambda r: (rank.get(r["key"], 1000), r["key"]))
return out
def build_detail_queries(
product: dict,
*,
existing: dict | None = None,
variant: dict | None = None,
) -> dict[str, str]:
"""Build/merge SerpAPI query strings for product (and optional variant) detail keys."""
name = str(product.get("name") or product.get("nameFa") or "").strip() or "KIKO product"
brand = "KIKO Milano"
for row in normalize_details(product.get("details")):
if row["key"] == "brand" and row["value"]:
brand = row["value"]
break
shade = ""
if variant:
shade = str(variant.get("shade") or variant.get("colorCode") or "").strip()
keys = list(PRODUCT_DETAIL_KEYS)
if variant:
keys = list(VARIANT_DETAIL_KEYS)
merged = dict(existing or {})
for key in keys:
if merged.get(key):
continue
template = DETAIL_QUERY_TEMPLATES.get(key)
if not template:
continue
merged[key] = (
template.replace("{name}", name)
.replace("{brand}", brand)
.replace("{shade}", shade or name)
.strip()
)
return merged
def ensure_product_queries(product: dict) -> dict:
"""Mutate product: normalize details + attach detailQueries for import/export."""
product["details"] = normalize_details(
product.get("details") or product.get("specifications"),
prefer_keys=PRODUCT_DETAIL_KEYS,
)
product.pop("specifications", None)
product["detailQueries"] = build_detail_queries(
product, existing=product.get("detailQueries") if isinstance(product.get("detailQueries"), dict) else None
)
variants = product.get("colorVariants") or product.get("variants") or []
if isinstance(variants, list):
for v in variants:
if not isinstance(v, dict):
continue
v["details"] = normalize_details(
v.get("details") or v.get("specifications"),
prefer_keys=VARIANT_DETAIL_KEYS,
)
v.pop("specifications", None)
v["detailQueries"] = build_detail_queries(
product,
existing=v.get("detailQueries") if isinstance(v.get("detailQueries"), dict) else None,
variant=v,
)
product["colorVariants"] = variants
product.pop("variants", None)
return product
def missing_detail_keys(product: dict, *, overwrite: bool = False) -> list[str]:
present = {
row["key"]
for row in normalize_details(product.get("details"))
if (overwrite is False and (row.get("value") or "").strip())
}
if overwrite:
present = set()
return [k for k in PRODUCT_DETAIL_KEYS if k not in present and k in DETAIL_QUERY_TEMPLATES]
def serp_search(query: str, *, api_key: str, num: int = 5, timeout: int = 45) -> dict:
"""Run a Google search via SerpAPI. Returns parsed JSON."""
query = (query or "").strip()
api_key = (api_key or "").strip()
if not query:
raise ValueError("Empty SerpAPI query")
if not api_key:
raise ValueError("SerpAPI key is not configured")
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"num": str(max(1, min(int(num), 10))),
"hl": "en",
"gl": "gb",
}
url = SERP_ENDPOINT + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url,
headers={"User-Agent": "kikomiko-admin/1.0", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "ignore")[:300]
raise ValueError(f"SerpAPI HTTP {exc.code}: {detail or exc.reason}") from exc
except urllib.error.URLError as exc:
raise ValueError(f"SerpAPI unreachable: {getattr(exc, 'reason', exc)}") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("SerpAPI returned invalid JSON") from exc
def extract_answer_from_serp(payload: dict) -> str:
"""Pick the best short answer text from a SerpAPI response."""
if not isinstance(payload, dict):
return ""
answer = payload.get("answer_box") or {}
if isinstance(answer, dict):
for key in ("answer", "snippet", "result"):
if answer.get(key):
return str(answer[key]).strip()
if answer.get("snippet_highlighted_words"):
return " ".join(str(x) for x in answer["snippet_highlighted_words"])
kg = payload.get("knowledge_graph") or {}
if isinstance(kg, dict):
for key in ("description", "title"):
if kg.get(key):
return str(kg[key]).strip()
for item in payload.get("organic_results") or []:
if isinstance(item, dict) and item.get("snippet"):
return str(item["snippet"]).strip()
return ""
def fill_details_via_serp(
product: dict,
*,
api_key: str,
keys: list[str] | None = None,
overwrite: bool = False,
) -> dict[str, Any]:
"""Fill missing product details using detailQueries + SerpAPI. Mutates product."""
ensure_product_queries(product)
targets = keys or missing_detail_keys(product, overwrite=overwrite)
queries = product.get("detailQueries") or {}
by_key = {row["key"]: row for row in product.get("details") or []}
filled: list[str] = []
errors: list[str] = []
for key in targets:
query = (queries.get(key) or DETAIL_QUERY_TEMPLATES.get(key) or "").strip()
if not query:
continue
# Resolve templates if still templated
query = (
query.replace("{name}", str(product.get("name") or ""))
.replace("{brand}", "KIKO Milano")
.replace("{shade}", "")
.strip()
)
try:
payload = serp_search(query, api_key=api_key)
answer = extract_answer_from_serp(payload)
if not answer:
errors.append(f"{key}: no SerpAPI answer")
continue
by_key[key] = {"key": key, "label": detail_label(key), "value": answer}
filled.append(key)
except Exception as exc: # noqa: BLE001
errors.append(f"{key}: {exc}")
if "HTTP 401" in str(exc) or "HTTP 403" in str(exc) or "HTTP 429" in str(exc):
break
# Keep preferred order
ordered = []
seen = set()
for key in PRODUCT_DETAIL_KEYS:
if key in by_key:
ordered.append(by_key[key])
seen.add(key)
for key, row in by_key.items():
if key not in seen:
ordered.append(row)
product["details"] = ordered
return {
"id": product.get("id"),
"slug": product.get("slug"),
"filled": filled,
"errors": errors,
"targets": targets,
}
def default_catalog_detail_queries() -> dict[str, str]:
"""Catalog-level template map exported for tooling / future importers."""
return dict(DETAIL_QUERY_TEMPLATES)