刷新過期 PChome identity 價格
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-19 22:46:43 +08:00
parent cc3be7fd4f
commit d88d9b7326
5 changed files with 350 additions and 3 deletions

View File

@@ -155,6 +155,11 @@ def _format_match_diagnostics(diagnostics) -> str:
)
def _product_id_key(product_id: str) -> str:
"""Normalize PChome IDs for comparing cached IDs with API-returned IDs."""
return re.sub(r"[^A-Z0-9]", "", str(product_id or "").upper())
def _find_best_match_detail(
momo_name: str,
pchome_products: list,
@@ -529,6 +534,59 @@ class CompetitorPriceFeeder:
).fetchall()
return [dict(r._mapping) for r in rows]
def _fetch_expired_identity_skus(self, limit: int = 120) -> list:
"""
取得 identity_v2 已確認、但 PChome 價格快取過期的商品。
這些商品不需重新 keyword search先用既有 PChome product_id 批次刷新價格。
"""
if self.engine is None:
raise RuntimeError("需要注入 SQLAlchemy engine")
from sqlalchemy import text
sql = text("""
WITH latest_momo AS (
SELECT
p.id AS product_id,
p.i_code AS sku,
p.name,
p.category,
pr.price AS momo_price,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
)
SELECT
lm.product_id,
lm.sku,
lm.name,
lm.category,
lm.momo_price,
cp.competitor_product_id,
cp.competitor_product_name,
cp.match_score,
cp.expires_at
FROM latest_momo lm
JOIN competitor_prices cp
ON cp.sku = lm.sku
AND cp.source = 'pchome'
AND cp.competitor_product_id IS NOT NULL
AND cp.competitor_product_id <> ''
AND cp.expires_at IS NOT NULL
AND cp.expires_at <= CURRENT_TIMESTAMP
AND COALESCE(cp.match_score, 0) >= :match_score_floor
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
WHERE lm.rn = 1
ORDER BY cp.expires_at ASC, lm.momo_price DESC NULLS LAST, lm.sku
LIMIT :limit
""")
with self.engine.connect() as conn:
rows = conn.execute(
sql,
{"limit": max(1, min(int(limit), 500)), "match_score_floor": MIN_MATCH_SCORE},
).fetchall()
return [dict(r._mapping) for r in rows]
def _upsert_competitor_price(
self,
sku: str,
@@ -834,6 +892,195 @@ class CompetitorPriceFeeder:
attempts_written=attempts_written,
)
def _run_known_identity_refresh_items(
self,
skus: list,
source: str = "pchome",
label: str = "已確認身份價格刷新",
) -> FeederResult:
start = time.time()
if source != "pchome":
logger.warning(f"[Feeder] 尚未支援 source={source},跳過")
return FeederResult(0, 0, 0, 0, 0, 0.0)
if not skus:
return FeederResult(0, 0, 0, 0, 0, 0.0)
from services.pchome_crawler import PChomeCrawler
crawler = PChomeCrawler(timeout=30, delay=RATE_DELAY)
requested_ids = [
str(item.get("competitor_product_id") or "").strip()
for item in skus
if str(item.get("competitor_product_id") or "").strip()
]
ok, message, products = crawler.fetch_product_details(requested_ids, batch_size=20)
product_map = {_product_id_key(product.product_id): product for product in products} if ok else {}
logger.info(
f"[Feeder] {label} product_id 批次查詢 | requested={len(requested_ids)} "
f"returned={len(product_map)} msg={message}"
)
matched = 0
skipped_no = 0
skipped_low = 0
errors = 0
history_written = 0
attempts_written = 0
for item in skus:
sku = item["sku"]
momo_name = item["name"]
momo_product_id = item.get("product_id")
momo_price = item.get("momo_price")
competitor_product_id = str(item.get("competitor_product_id") or "").strip()
search_terms = [f"known_product_id:{competitor_product_id}"] if competitor_product_id else []
try:
product = product_map.get(_product_id_key(competitor_product_id))
if not product:
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
candidate_count=0,
attempt_status="refresh_no_result",
error_message=f"PChome product_id not returned: {competitor_product_id}",
source=source,
)
skipped_no += 1
attempts_written += 1
continue
result = _find_best_match_detail(momo_name, [product], momo_price=momo_price)
if not result:
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
candidate_count=1,
attempt_status="refresh_no_match",
source=source,
)
skipped_no += 1
attempts_written += 1
continue
best_product, score, diagnostics = result
if score < MIN_MATCH_SCORE:
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
candidate_count=1,
attempt_status="refresh_low_score",
best_product=best_product,
best_score=score,
error_message=_format_match_diagnostics(diagnostics),
source=source,
)
skipped_low += 1
attempts_written += 1
continue
tags = _extract_tags(best_product)
tags.extend(getattr(diagnostics, "tags", []))
for reason in getattr(diagnostics, "reasons", ()) or ():
tags.append(f"match_{reason}")
tags.append("refresh_known_identity")
tags = list(dict.fromkeys(tags))
should_write, write_reason = self._should_upsert_competitor_price(
sku,
best_product,
score,
source=source,
)
if not should_write:
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
candidate_count=1,
attempt_status="refresh_needs_review",
best_product=best_product,
best_score=score,
error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
source=source,
)
skipped_low += 1
attempts_written += 1
continue
tags.append(write_reason)
self._upsert_competitor_price(
sku,
best_product,
score,
tags,
momo_product_id=momo_product_id,
momo_price=momo_price,
source=source,
)
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
candidate_count=1,
attempt_status="matched",
best_product=best_product,
best_score=score,
source=source,
)
matched += 1
history_written += 1
attempts_written += 1
except Exception as e:
logger.error(f"[Feeder] {sku} 已確認身份刷新失敗: {e}")
try:
self._record_match_attempt(
sku,
momo_name,
momo_product_id=momo_product_id,
momo_price=momo_price,
search_terms=search_terms,
attempt_status="refresh_error",
error_message=str(e),
source=source,
)
attempts_written += 1
except Exception as attempt_error:
logger.warning(f"[Feeder] {sku} 刷新嘗試紀錄寫入失敗: {attempt_error}")
errors += 1
duration = round(time.time() - start, 2)
logger.info(
f"[Feeder] {label} 完成 matched={matched}/{len(skus)} "
f"skip_no={skipped_no} skip_low={skipped_low} errors={errors} "
f"history_written={history_written} attempts_written={attempts_written} 耗時={duration}s"
)
return FeederResult(
total_skus=len(skus),
matched=matched,
skipped_no_result=skipped_no,
skipped_low_score=skipped_low,
errors=errors,
duration_sec=duration,
history_written=history_written,
attempts_written=attempts_written,
)
def run(self, source: str = "pchome") -> FeederResult:
"""
執行一輪競品價格抓取與寫入
@@ -852,6 +1099,16 @@ class CompetitorPriceFeeder:
return self._run_sku_items(skus, source=source, label="PChome 競品價格")
def run_expired_identity_refresh(self, limit: int = 120, source: str = "pchome") -> FeederResult:
"""刷新已通過 identity_v2、但 PChome 價格快取過期的商品。"""
try:
skus = self._fetch_expired_identity_skus(limit=limit)
except Exception as e:
logger.error(f"[Feeder] 讀取過期 identity_v2 商品失敗: {e}")
return FeederResult(0, 0, 0, 0, 1, 0.0)
return self._run_known_identity_refresh_items(skus, source=source, label="identity_v2 過期價格刷新")
def run_unmatched_priority(self, limit: int = 80, source: str = "pchome") -> FeederResult:
"""優先補抓尚未有有效 PChome 配對的高價商品。"""
try: