fix: professionalize marketplace product ux
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user