導入 browse.sh 比價診斷計畫
All checks were successful
CD Pipeline / deploy (push) Successful in 1m21s

This commit is contained in:
OoO
2026-05-21 18:40:14 +08:00
committed by AiderHeal Bot
parent 106c1935f4
commit 0cea70890a
9 changed files with 576 additions and 2 deletions

View File

@@ -31,6 +31,7 @@ import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import quote_plus
logger = logging.getLogger(__name__)
@@ -47,6 +48,11 @@ TTL_HOURS = 6 # competitor_prices 快取有效期
REQUEST_TIMEOUT = float(os.getenv("PCHOME_FEEDER_TIMEOUT", "12")) # 避免外部搜尋 API 長時間卡住排程
VARIANT_RECALL_SORTS = ("sale/dc", "new/dc")
RECOVERABLE_LOW_SCORE_FLOOR = max(MIN_MATCH_SCORE - 0.03, 0.72)
BROWSE_SH_DIAGNOSTIC_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_DIAGNOSTIC_ENABLED", "true").lower() in {"1", "true", "yes", "on"}
BROWSE_SH_EXECUTE_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_EXECUTE_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
BROWSE_SH_TIMEOUT_SECONDS = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_TIMEOUT", "20"))
BROWSE_SH_MAX_EXECUTIONS_PER_RUN = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_MAX_PER_RUN", "3"))
BROWSE_SH_OUTPUT_PREVIEW_CHARS = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_OUTPUT_PREVIEW_CHARS", "1200"))
RECOVERABLE_DIAGNOSTIC_REASONS = {
"strong_product_line_match",
"strong_exact_spec_match",
@@ -95,6 +101,43 @@ def _classify_low_score_attempt(score: float, diagnostics) -> str:
return "true_low_confidence"
def _has_variant_selection_gap(
momo_name: str,
ranked_matches: list[tuple],
best_score: float,
) -> bool:
"""True when source lacks explicit variant selection but top candidates require one."""
try:
from services.marketplace_product_matcher import (
_explicit_variant_option_tokens,
parse_product_identity,
)
except Exception:
return False
source_identity = parse_product_identity(momo_name)
source_options = set(_explicit_variant_option_tokens(source_identity))
if re.search(r"任選\s*[一二兩三四五六七八九十0-9]+\s*款", momo_name):
source_options -= {str(value) for value in range(1, 11)}
source_options -= {f"{value:02d}" for value in range(1, 11)}
if source_options:
return False
threshold = max(best_score - 0.02, RECOVERABLE_LOW_SCORE_FLOOR)
option_buckets: set[str] = set()
for product, score, diagnostics in ranked_matches[:5]:
if getattr(diagnostics, "hard_veto", False) or score < threshold:
continue
candidate_identity = parse_product_identity(getattr(product, "name", "") or "")
options = _explicit_variant_option_tokens(candidate_identity)
if len(options) >= 2:
return True
option_buckets.update(options)
if len(option_buckets) >= 2:
return True
return False
def _extract_tags(pchome_product) -> list:
"""
從 PChomeProduct 物件提取語意標籤
@@ -286,6 +329,66 @@ def _match_diagnostics_payload(diagnostics) -> dict:
}
def _pchome_search_url(keyword: str) -> str:
return f"https://ecshweb.pchome.com.tw/search/v3.3/?q={quote_plus(keyword or '')}"
def _build_browse_sh_diagnostic_payload(
momo_name: str,
search_terms: list[str] = None,
reason: str = "unknown",
best_product=None,
best_score: float = None,
diagnostics=None,
candidate_count: int = 0,
) -> dict:
"""Build a read-only browse.sh probe plan for low-confidence PChome cases."""
if not BROWSE_SH_DIAGNOSTIC_ENABLED:
return {}
terms = _dedupe_terms(search_terms or _build_search_keywords(momo_name))[:3]
urls = [_pchome_search_url(term) for term in terms]
product_url = getattr(best_product, "product_url", None)
if product_url:
urls.append(product_url)
urls = list(dict.fromkeys(url for url in urls if url))
primary_url = urls[0] if urls else _pchome_search_url(momo_name)
diagnostic_payload = _match_diagnostics_payload(diagnostics)
return {
"tool": "browse.sh",
"mode": "execute_on_demand" if BROWSE_SH_EXECUTE_ENABLED else "plan_only",
"reason": reason,
"execute_enabled": BROWSE_SH_EXECUTE_ENABLED,
"timeout_seconds": BROWSE_SH_TIMEOUT_SECONDS,
"candidate_count": int(candidate_count or 0),
"momo_name": (momo_name or "")[:300],
"search_terms": terms,
"urls": urls,
"suggested_commands": [
{
"purpose": "static_fetch_first_page",
"args": ["get", primary_url],
},
{
"purpose": "manual_browser_probe",
"args": ["open", primary_url],
},
],
"best_candidate": {
"product_id": getattr(best_product, "product_id", None),
"name": (getattr(best_product, "name", None) or "")[:300] or None,
"price": getattr(best_product, "price", None),
"url": product_url,
"score": best_score,
} if best_product else None,
"diagnostic_codes": diagnostic_payload.get("reasons") or [],
"comparison_mode": diagnostic_payload.get("comparison_mode"),
"hard_veto": diagnostic_payload.get("hard_veto"),
"execution": {"status": "disabled"},
}
def _product_snapshot_payload(product) -> dict:
payload = {
"competitor_product_url": None,
@@ -471,6 +574,7 @@ class CompetitorPriceFeeder:
self._history_table_ready = False
self._attempt_table_ready = False
self._price_table_columns_ready = False
self._browse_sh_executions = 0
def _ensure_table_columns(self, conn, table: str, column_specs: list[tuple[str, str]]) -> None:
"""補齊既有表欄位;避免正式端舊表在新 INSERT 時炸掉。"""
@@ -613,6 +717,7 @@ class CompetitorPriceFeeder:
comparison_mode VARCHAR(40),
hard_veto BOOLEAN,
diagnostic_codes JSONB,
browse_diagnostic_json JSONB,
error_message TEXT,
attempted_at TIMESTAMP NOT NULL DEFAULT NOW()
)
@@ -648,6 +753,7 @@ class CompetitorPriceFeeder:
comparison_mode VARCHAR(40),
hard_veto BOOLEAN,
diagnostic_codes TEXT,
browse_diagnostic_json TEXT,
error_message TEXT,
attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
@@ -669,9 +775,64 @@ class CompetitorPriceFeeder:
("comparison_mode", "VARCHAR(40)"),
("hard_veto", "BOOLEAN"),
("diagnostic_codes", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
("browse_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
])
self._attempt_table_ready = True
def _prepare_browse_diagnostic(
self,
momo_name: str,
search_terms: list = None,
reason: str = "unknown",
best_product=None,
best_score: float = None,
diagnostics=None,
candidate_count: int = 0,
) -> dict:
"""Return browse.sh diagnostic evidence; CLI execution remains opt-in and rate-limited."""
payload = _build_browse_sh_diagnostic_payload(
momo_name,
search_terms=search_terms,
reason=reason,
best_product=best_product,
best_score=best_score,
diagnostics=diagnostics,
candidate_count=candidate_count,
)
if not payload or not BROWSE_SH_EXECUTE_ENABLED:
return payload
if self._browse_sh_executions >= BROWSE_SH_MAX_EXECUTIONS_PER_RUN:
payload["execution"] = {"status": "rate_limited"}
return payload
command_args = tuple((payload.get("suggested_commands") or [{}])[0].get("args") or ())
if not command_args:
payload["execution"] = {"status": "missing_command"}
return payload
try:
from services.browse_sh_tool import BrowseShTool
self._browse_sh_executions += 1
result = BrowseShTool(timeout_seconds=BROWSE_SH_TIMEOUT_SECONDS).run(
command_args,
timeout_seconds=BROWSE_SH_TIMEOUT_SECONDS,
)
payload["execution"] = {
"status": "ok" if result.ok else "failed",
"returncode": result.returncode,
"timed_out": result.timed_out,
"unavailable_reason": result.unavailable_reason,
"stdout_preview": (result.stdout or "")[:BROWSE_SH_OUTPUT_PREVIEW_CHARS],
"stderr_preview": (result.stderr or "")[:BROWSE_SH_OUTPUT_PREVIEW_CHARS],
}
except Exception as exc:
payload["execution"] = {
"status": "error",
"error": str(exc)[:500],
}
return payload
def _record_match_attempt(
self,
sku: str,
@@ -684,6 +845,7 @@ class CompetitorPriceFeeder:
best_product=None,
best_score: float = None,
diagnostics=None,
browse_diagnostic: dict = None,
error_message: str = None,
source: str = "pchome",
) -> None:
@@ -695,9 +857,15 @@ class CompetitorPriceFeeder:
search_terms_expr = "CAST(:search_terms AS jsonb)" if conn.dialect.name == "postgresql" else ":search_terms"
json_cast = "CAST(:match_diagnostic_json AS jsonb)" if conn.dialect.name == "postgresql" else ":match_diagnostic_json"
codes_cast = "CAST(:diagnostic_codes AS jsonb)" if conn.dialect.name == "postgresql" else ":diagnostic_codes"
browse_cast = "CAST(:browse_diagnostic_json AS jsonb)" if conn.dialect.name == "postgresql" else ":browse_diagnostic_json"
diagnostic_payload = _match_diagnostics_payload(diagnostics)
diagnostic_codes = diagnostic_payload.get("reasons") or []
product_payload = _product_snapshot_payload(best_product)
browse_diagnostic_json = (
json.dumps(browse_diagnostic, ensure_ascii=False)
if browse_diagnostic
else None
)
conn.execute(text(f"""
INSERT INTO competitor_match_attempts
(sku, source, momo_product_id, momo_product_name, momo_price,
@@ -706,6 +874,7 @@ class CompetitorPriceFeeder:
competitor_product_url, competitor_image_url, competitor_stock,
best_competitor_price, best_match_score,
match_diagnostic_json, comparison_mode, hard_veto, diagnostic_codes,
browse_diagnostic_json,
error_message,
attempted_at)
VALUES
@@ -715,6 +884,7 @@ class CompetitorPriceFeeder:
:competitor_product_url, :competitor_image_url, :competitor_stock,
:best_price, :best_score,
{json_cast}, :comparison_mode, :hard_veto, {codes_cast},
{browse_cast},
:error_message,
CURRENT_TIMESTAMP)
"""), {
@@ -735,6 +905,7 @@ class CompetitorPriceFeeder:
"comparison_mode": diagnostic_payload.get("comparison_mode"),
"hard_veto": diagnostic_payload.get("hard_veto"),
"diagnostic_codes": json.dumps(diagnostic_codes, ensure_ascii=False) if diagnostic_codes else None,
"browse_diagnostic_json": browse_diagnostic_json,
"error_message": (error_message or "")[:1000] or None,
})
@@ -1197,6 +1368,12 @@ class CompetitorPriceFeeder:
products = _search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price)
if not products:
logger.debug(f"[Feeder] {sku} 無搜尋結果,跳過")
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason="no_result",
candidate_count=0,
)
self._record_match_attempt(
sku,
momo_name,
@@ -1205,6 +1382,7 @@ class CompetitorPriceFeeder:
search_terms=search_terms,
candidate_count=0,
attempt_status="no_result",
browse_diagnostic=browse_diagnostic,
source=source,
)
attempts_written += 1
@@ -1213,6 +1391,12 @@ class CompetitorPriceFeeder:
ranked_matches = _rank_match_details(momo_name, products, momo_price=momo_price)
if not ranked_matches:
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason="no_match",
candidate_count=len(products),
)
self._record_match_attempt(
sku,
momo_name,
@@ -1221,6 +1405,7 @@ class CompetitorPriceFeeder:
search_terms=search_terms,
candidate_count=len(products),
attempt_status="no_match",
browse_diagnostic=browse_diagnostic,
source=source,
)
attempts_written += 1
@@ -1305,6 +1490,15 @@ class CompetitorPriceFeeder:
f"[Feeder] {sku} 候選屬單位價可比但非同販售組合,"
f"不寫入正式價差 | {_format_match_diagnostics(diagnostics)}"
)
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason="unit_comparable",
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
candidate_count=len(products),
)
self._record_match_attempt(
sku,
momo_name,
@@ -1316,6 +1510,7 @@ class CompetitorPriceFeeder:
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
browse_diagnostic=browse_diagnostic,
error_message=_format_match_diagnostics(diagnostics),
source=source,
)
@@ -1325,10 +1520,24 @@ class CompetitorPriceFeeder:
if score < MIN_MATCH_SCORE and not manual_accept_override:
attempt_status = _classify_low_score_attempt(score, diagnostics)
if (
attempt_status == "recoverable_low_score"
and _has_variant_selection_gap(momo_name, ranked_matches, score)
):
attempt_status = "true_low_confidence"
logger.debug(
f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE})"
f"{_format_match_diagnostics(diagnostics)}"
)
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason=attempt_status,
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
candidate_count=len(products),
)
self._record_match_attempt(
sku,
momo_name,
@@ -1340,6 +1549,7 @@ class CompetitorPriceFeeder:
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
browse_diagnostic=browse_diagnostic,
error_message=_format_match_diagnostics(diagnostics),
source=source,
)
@@ -1365,6 +1575,15 @@ class CompetitorPriceFeeder:
write_reason = "manual_accept_override"
if not should_write:
logger.info(f"[Feeder] {sku} 進入人工覆核,不覆蓋既有配對 | {write_reason}")
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason="protected_existing_match",
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
candidate_count=len(products),
)
self._record_match_attempt(
sku,
momo_name,
@@ -1376,6 +1595,7 @@ class CompetitorPriceFeeder:
best_product=best_product,
best_score=score,
diagnostics=diagnostics,
browse_diagnostic=browse_diagnostic,
error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
source=source,
)
@@ -1418,6 +1638,12 @@ class CompetitorPriceFeeder:
except Exception as e:
logger.error(f"[Feeder] {sku} 處理失敗: {e}")
try:
browse_diagnostic = self._prepare_browse_diagnostic(
momo_name,
search_terms=search_terms,
reason="crawler_error",
candidate_count=0,
)
self._record_match_attempt(
sku,
momo_name,
@@ -1425,6 +1651,7 @@ class CompetitorPriceFeeder:
momo_price=momo_price,
search_terms=search_terms,
attempt_status="error",
browse_diagnostic=browse_diagnostic,
error_message=str(e),
source=source,
)

View File

@@ -520,6 +520,7 @@ BRAND_ALIAS_OVERRIDES = {
"xiaomi": ("小米有品", "小米", "xiaomi"),
"mac": ("m.a.c", "mac", "m a c"),
"opi": ("o.p.i", "opi", "o p i"),
"st雞仔牌": ("日本雞仔牌st", "日本st雞仔牌", "st雞仔牌", "雞仔牌st", "雞仔牌"),
}
PRODUCT_TYPES = {
@@ -1157,12 +1158,25 @@ def _has_refill_pack(identity: ProductIdentity) -> bool:
return bool(
"補充瓶" in text
or "補充包" in text
or "補充芯" in text
or "補充蕊" in text
or "替換蕊" in text
or "替換芯" in text
or "refill" in text
)
def _has_accessory_case(identity: ProductIdentity) -> bool:
text = identity.normalized_name
return bool(
"眉彩餅盒" in text
or "盒一入款" in text
or "盒三入款" in text
or "盒單入" in text
or "空盒" in text
)
def _spec_mention_count(identity: ProductIdentity) -> int:
return len(
re.findall(
@@ -1461,6 +1475,7 @@ def _build_evidence_flags(
"count_conflict",
"bundle_offer_conflict",
"multi_component_conflict",
"accessory_case_conflict",
"refill_pack_conflict",
"price_ratio_extreme",
"price_ratio_wide",
@@ -1557,6 +1572,9 @@ def score_marketplace_match(
reasons.append("multi_component_conflict")
if _has_refill_pack(left) != _has_refill_pack(right):
reasons.append("refill_pack_conflict")
accessory_case_conflict = _has_accessory_case(left) != _has_accessory_case(right)
if accessory_case_conflict:
reasons.append("accessory_case_conflict")
left_spec_mentions = _spec_mention_count(left)
right_spec_mentions = _spec_mention_count(right)
if left_spec_mentions and right_spec_mentions and left_spec_mentions != right_spec_mentions:
@@ -1579,6 +1597,8 @@ def score_marketplace_match(
hard_veto = True
if _has_refill_pack(left) != _has_refill_pack(right):
hard_veto = True
if accessory_case_conflict:
hard_veto = True
if model_line_conflict:
hard_veto = True
if left_spec_mentions and right_spec_mentions and left_spec_mentions != right_spec_mentions:
@@ -1752,6 +1772,20 @@ def score_marketplace_match(
):
score += 0.07
reasons.append("shared_identity_anchor_exact_line")
if (
"無印乾爽止汗爽身乳液" in shared_anchor
and {"nivea", "妮維雅"} & (left.brand_tokens | right.brand_tokens)
and brand_score >= 0.95
and not hard_veto
and price_penalty == 0
and type_score >= 0.95
and spec_score >= 0.45
and token_score >= 0.55
and sequence_score >= 0.62
and not variant_descriptor_conflict
):
score += 0.08
reasons.append("shared_identity_anchor_nivea_dry_lotion")
if (
"多效提亮防曬霜" in shared_anchor
and {"recipe", "box"} <= (left.brand_tokens | right.brand_tokens)
@@ -1967,6 +2001,10 @@ def _extract_anchor_phrases(token: str) -> list[str]:
phrases: list[str] = []
if "經典旋轉眉筆" in cleaned:
phrases.append("經典旋轉眉筆")
if "無印乾爽" in cleaned and "止汗爽身乳液" in cleaned:
phrases.append("無印乾爽止汗爽身乳液")
if "智能光感應" in cleaned and "無線自動除臭芳香噴霧機" in cleaned:
phrases.append("智能光感應無線自動除臭芳香噴霧機")
if "悠斯晶" in normalized and "經典乳霜" in normalized:
phrases.append("悠斯晶經典乳霜")
if "經典乳霜" in normalized: