fix: professionalize marketplace product ux

This commit is contained in:
ogt
2026-06-26 12:29:20 +08:00
parent 2888bac597
commit 5327dfda1f
13 changed files with 596 additions and 73 deletions

View File

@@ -11,6 +11,10 @@ import json
import logging
import csv
import io
import os
import re
from urllib.parse import quote
from urllib.request import Request, urlopen
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
@@ -21,6 +25,10 @@ from services.pchome_growth_cache_state import mark_pchome_growth_cache_stale
logger = logging.getLogger(__name__)
PCHOME_PUBLIC_PRODUCT_API = "https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod"
PCHOME_IMAGE_CDN_BASE = "https://cs-a.ecimg.tw"
PCHOME_PRODUCT_ID_RE = re.compile(r"^[A-Z0-9]{6}-[A-Z0-9]{9}-[0-9]{3}$")
SOURCE_CONTRACTS = [
{
@@ -45,6 +53,116 @@ SOURCE_CONTRACTS = [
"data_quality_label": "暫不進告警",
"plain_note": "先保留資料接口,等有穩定合法來源後再啟用,不會影響目前作戰清單。",
},
{
"code": "lazada",
"display_name": "Lazada",
"platform_code": "lazada",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "先建立跨境平台資料欄位,等合法穩定來源後才啟用價格與活動監控。",
},
{
"code": "amazon",
"display_name": "Amazon",
"platform_code": "amazon",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留 SKU / ASIN / Marketplace 維度,未接正式來源前不參與自動判斷。",
},
{
"code": "google_merchant",
"display_name": "Google Merchant / Shopping",
"platform_code": "google_merchant",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方報表", "官方 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "用於價格競爭力、商品資料完整度與成效 benchmark待正式資料源接入。",
},
{
"code": "tiktok_shop",
"display_name": "TikTok Shop",
"platform_code": "tiktok_shop",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留短影音電商的商品、促銷與內容導流欄位;未接合法穩定來源前不參與自動判斷。",
},
{
"code": "line_shopping",
"display_name": "LINE 購物",
"platform_code": "line_shopping",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方報表", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留本地導購、點數回饋與活動檔期訊號;接入前只列為待補來源。",
},
{
"code": "rakuten",
"display_name": "Rakuten",
"platform_code": "rakuten",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留日本與海外平台比價欄位,未確認來源前只列入待接入清單。",
},
{
"code": "yahoo_shopping",
"display_name": "Yahoo 購物",
"platform_code": "yahoo_shopping",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留本地平台促銷與價格監控欄位,接入前不影響現有作戰清單。",
},
{
"code": "ruten",
"display_name": "露天",
"platform_code": "ruten",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留拍賣與長尾賣場價格訊號;正式來源未確認前不進入自動告警。",
},
{
"code": "shopify_brand_store",
"display_name": "品牌官網 / Shopify",
"platform_code": "shopify_brand_store",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "商品 Feed", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留品牌官網售價、組合與活動檔期,用來和 PChome 主推品對照。",
},
{
"code": "meta_commerce",
"display_name": "Meta Commerce",
"platform_code": "meta_commerce",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 Catalog API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留廣告商品目錄與商品連結欄位,用於後續曝光與導流成效對照。",
},
{
"code": "coupang",
"display_name": "酷澎",
@@ -230,6 +348,75 @@ def _load_json_dict(value: Any) -> dict[str, Any]:
return {}
def _looks_like_pchome_product_id(product_id: Any) -> bool:
return bool(PCHOME_PRODUCT_ID_RE.match(str(product_id or "").strip()))
def _absolute_pchome_image_url(path: Any) -> str:
image_path = str(path or "").strip()
if not image_path:
return ""
if image_path.startswith("http://") or image_path.startswith("https://"):
return image_path
if not image_path.startswith("/"):
image_path = f"/{image_path}"
return f"{PCHOME_IMAGE_CDN_BASE}{image_path}"
def _pchome_api_timeout_seconds() -> float:
try:
return max(1.0, min(float(os.getenv("PCHOME_PUBLIC_API_TIMEOUT_SECONDS", "4")), 10.0))
except (TypeError, ValueError):
return 4.0
def _parse_pchome_jsonp(payload: str) -> dict[str, Any]:
match = re.search(r"jsonp\((.*)\);\s*}\s*catch", payload or "", re.DOTALL)
if not match:
match = re.search(r"jsonp\((.*)\);", payload or "", re.DOTALL)
if not match:
return {}
parsed = json.loads(match.group(1))
return parsed if isinstance(parsed, dict) else {}
def _fetch_pchome_public_image_map(product_ids: list[str]) -> dict[str, str]:
"""Fetch PChome product images from the public product API without blocking the list."""
ids = [
str(product_id or "").strip()
for product_id in product_ids
if _looks_like_pchome_product_id(product_id)
]
ids = list(dict.fromkeys(ids))
if not ids:
return {}
url = (
f"{PCHOME_PUBLIC_PRODUCT_API}"
f"?id={quote(','.join(ids), safe=',')}"
"&fields=Id,Name,Nick,Pic,Price"
"&_callback=jsonp"
)
try:
request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urlopen(request, timeout=_pchome_api_timeout_seconds()) as response:
raw = response.read(512000).decode("utf-8", "ignore")
products = _parse_pchome_jsonp(raw)
except Exception as exc:
logger.warning("[ExternalOffer] PChome image API failed: %s", exc)
return {}
image_map: dict[str, str] = {}
for product_id, product in products.items():
if not isinstance(product, dict):
continue
pic = product.get("Pic") if isinstance(product.get("Pic"), dict) else {}
image_url = _absolute_pchome_image_url(pic.get("B") or pic.get("S") or pic.get("W"))
if image_url:
image_map[str(product_id)] = image_url
return image_map
_PCHOME_PRODUCT_URL_BASE = "https://24h.pchome.com.tw/prod/"
_REVIEW_REASON_LABELS = {
@@ -1491,7 +1678,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
"review_offer_count": review_offer_count,
"sources": sources,
"connector_contract": build_connector_contracts(),
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",
"plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。",
}
@@ -1541,6 +1728,11 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
LIMIT :limit
"""), {"limit": limit * 4}).mappings().all()
pchome_image_map = _fetch_pchome_public_image_map([
str(row.get("pchome_product_id") or "").strip()
for row in rows
])
seen: set[tuple[str, str]] = set()
items: list[dict[str, Any]] = []
for row in rows:
@@ -1567,19 +1759,28 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
pchome_price = _to_float(raw_payload.get("pchome_public_price"))
momo_price = _to_float(row.get("price"))
gap_pct = _to_float(raw_payload.get("target_gap_pct"))
pchome_image_url = (
raw_payload.get("pchome_image_url")
or raw_payload.get("pchome_public_image_url")
or pchome_image_map.get(str(pchome_product_id or "").strip())
or ""
)
momo_image_url = row.get("image_url")
items.append({
"id": int(row.get("id")),
"pchome_product_id": pchome_product_id,
"pchome_product_name": raw_payload.get("pchome_public_name") or "",
"pchome_url": _build_pchome_product_url(pchome_product_id),
"pchome_image_url": pchome_image_url,
"pchome_price": pchome_price,
"momo_sku": row.get("momo_sku") or row.get("source_product_id"),
"momo_title": row.get("title"),
"momo_price": momo_price,
"momo_url": momo_url,
"product_url": momo_url,
"image_url": row.get("image_url"),
"image_url": momo_image_url,
"momo_image_url": momo_image_url,
"quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2),
"alert_tier": raw_payload.get("alert_tier") or "identity_review",
"price_basis": raw_payload.get("price_basis") or "manual_review",