"""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)