"""Public Yahoo/PChome evidence adapter with no session or credential access.""" from __future__ import annotations import hashlib import json import os import re import time import unicodedata from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Mapping from urllib.parse import quote, urlparse import requests from bs4 import BeautifulSoup from services.public_source_http_service import read_bounded_response_text POLICY = "controlled_yahoo_shopping_public_offer_v1" ADAPTER_VERSION = "yahoo_shopping_public_jsonld_v1" WORK_ITEM_ID = "GROWTH-P0-001-C" SOURCE_CODE = "yahoo_shopping" PLATFORM_CODE = "yahoo_shopping_tw" SOURCE_LABEL = "Yahoo 購物" INGESTION_METHOD = "public_product_jsonld" CONNECTOR_KEY = "pchome_targeted_yahoo_public_search" YAHOO_HOST = "tw.buy.yahoo.com" PCHOME_API_HOST = "ecapi.pchome.com.tw" YAHOO_SEARCH_URL = "https://tw.buy.yahoo.com/search/product?p={query}" PCHOME_PUBLIC_PRODUCT_API = "https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod" DEFAULT_RECEIPT_ROOT = os.getenv( "PCHOME_GROWTH_YAHOO_RECEIPT_ROOT", "/app/data/ai_automation/pchome_growth_yahoo_backfill_receipts" if Path("/app/data").exists() else "runtime_artifacts/pchome_growth_yahoo_backfill_receipts", ) DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; EwoooC-PublicEvidence/1.0)" MAX_RESPONSE_BYTES = 1_500_000 SELECTION_MARKERS = ( "任選", "多款", "可選", "隨機", "款式可選", "色號可選", "香味可選", ) def _bounded_int_env(name: str, default: int, minimum: int, maximum: int) -> int: try: value = int(os.getenv(name, str(default))) except (TypeError, ValueError): value = default return max(minimum, min(value, maximum)) def _bounded_float_env( name: str, default: float, minimum: float, maximum: float, ) -> float: try: value = float(os.getenv(name, str(default))) except (TypeError, ValueError): value = default return max(minimum, min(value, maximum)) def _to_float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _canonical_hash(payload: Any) -> str: encoded = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _compact_text(value: Any) -> str: text = unicodedata.normalize("NFKC", str(value or "")) return re.sub(r"\s+", "", text).lower() def _plain_text(value: Any) -> str: if value in (None, ""): return "" return BeautifulSoup(str(value), "html.parser").get_text(" ", strip=True) def _is_public_url(url: str, *, host: str, path_prefix: str = "") -> bool: parsed = urlparse(str(url or "")) return bool( parsed.scheme == "https" and parsed.hostname == host and (not path_prefix or parsed.path.startswith(path_prefix)) and not parsed.username and not parsed.password ) def _response_text(response: Any) -> str: return read_bounded_response_text(response, max_bytes=MAX_RESPONSE_BYTES) def _default_get(url: str, *, timeout: float) -> Any: return requests.get( url, timeout=timeout, allow_redirects=True, stream=True, headers={ "User-Agent": DEFAULT_USER_AGENT, "Accept-Language": "zh-TW,zh;q=0.9", "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", }, ) def _jsonld_nodes(value: Any): if isinstance(value, dict): yield value graph = value.get("@graph") if graph is not None: yield from _jsonld_nodes(graph) elif isinstance(value, list): for item in value: yield from _jsonld_nodes(item) def _type_is(node: Mapping[str, Any], expected: str) -> bool: value = node.get("@type") or node.get("type") if isinstance(value, str): return value.lower() == expected.lower() if isinstance(value, list): return any(str(item).lower() == expected.lower() for item in value) return False def _first_mapping(value: Any) -> dict[str, Any]: if isinstance(value, dict): return dict(value) if isinstance(value, list): for item in value: if isinstance(item, dict): return dict(item) return {} def _first_image(value: Any) -> str: if isinstance(value, str): return value.strip() if isinstance(value, dict): return str(value.get("url") or value.get("contentUrl") or "").strip() if isinstance(value, list): for item in value: image = _first_image(item) if image: return image return "" def _clean_yahoo_product_name(value: Any) -> str: text = str(value or "").strip() return re.split(r"\s+\|\s+", text, maxsplit=1)[0].strip() def _has_multi_variant_group_for_spec(title: str, target_spec: str) -> bool: compact_spec = _compact_text(target_spec) if not compact_spec: return False for group in re.findall(r"[((]([^))]+)[))]", title or ""): if compact_spec in _compact_text(group) and re.search(r"[//|、]", group): return True return False def parse_yahoo_search_html(html: str, *, search_url: str) -> dict[str, Any]: """Parse Yahoo public search state without executing page scripts.""" soup = BeautifulSoup(html or "", "html.parser") script = soup.find("script", id="isoredux-data") errors: list[str] = [] payload: dict[str, Any] = {} if script is None: errors.append("isoredux_data_missing") else: try: parsed = json.loads(script.string or script.get_text() or "{}") payload = parsed if isinstance(parsed, dict) else {} except json.JSONDecodeError: errors.append("isoredux_data_invalid") search = payload.get("search") if isinstance(payload.get("search"), dict) else {} ecsearch = ( search.get("ecsearch") if isinstance(search.get("ecsearch"), dict) else {} ) hits = ecsearch.get("hits") if isinstance(ecsearch.get("hits"), list) else [] candidates: list[dict[str, Any]] = [] for item in hits: if not isinstance(item, dict): continue product_id = str(item.get("ec_productid") or "").strip() title = str(item.get("ec_title") or "").strip() product_url = str(item.get("ec_item_url") or "").strip() price = _to_float(item.get("ec_price")) if not product_id or not title or price <= 0: continue if not _is_public_url(product_url, host=YAHOO_HOST, path_prefix="/gdsale/"): continue candidates.append( { "product_id": product_id, "name": title, "search_price": price, "original_price": _to_float(item.get("ec_listprice")), "product_url": product_url, "image_url": str(item.get("ec_image") or "").strip(), "brand": str(item.get("ec_brand") or "").strip(), "seller": str(item.get("ec_storename") or "").strip(), "stock_status": {"1": "in_stock", "0": "out_of_stock"}.get( str(item.get("ec_has_stock") or "") ), "rating": _to_float(item.get("ec_quality_rating")), "review_count": int(_to_float(item.get("ec_rating_count"))), } ) page_title = soup.title.get_text(strip=True) if soup.title else "" blocked = any( marker in page_title.lower() for marker in ("access denied", "captcha", "robot check") ) if blocked: errors.append("blocked_page_detected") return { "success": not errors, "policy": POLICY, "adapter_version": ADAPTER_VERSION, "source_method": "public_search_embedded_json", "search_url": search_url, "page_title": page_title, "candidate_count": len(candidates), "candidates": candidates, "errors": errors, "content_sha256": hashlib.sha256((html or "").encode("utf-8")).hexdigest(), } def parse_yahoo_product_jsonld(html: str, *, product_url: str) -> dict[str, Any]: """Parse and normalize one Yahoo public Product/Offer JSON-LD payload.""" soup = BeautifulSoup(html or "", "html.parser") products: list[dict[str, Any]] = [] warnings: list[str] = [] for script in soup.find_all( "script", attrs={"type": re.compile(r"ld\+json", re.IGNORECASE)}, ): text_value = script.string or script.get_text() or "" if not text_value.strip(): continue try: data = json.loads(text_value) except json.JSONDecodeError: warnings.append("invalid_jsonld_skipped") continue for node in _jsonld_nodes(data): if _type_is(node, "Product"): products.append(dict(node)) if not products: return { "success": False, "policy": POLICY, "adapter_version": ADAPTER_VERSION, "product_url": product_url, "errors": ["product_jsonld_missing"], "warnings": warnings, } product = products[0] offer = _first_mapping(product.get("offers")) brand = _first_mapping(product.get("brand")) seller = _first_mapping(offer.get("seller")) rating = _first_mapping(product.get("aggregateRating")) product_id = str(product.get("sku") or "").strip() title = _clean_yahoo_product_name(product.get("name")) price = _to_float(offer.get("price")) currency = str(offer.get("priceCurrency") or "").strip().upper() errors = [] if not product_id: errors.append("product_id_missing") if not title: errors.append("product_title_missing") if price <= 0: errors.append("positive_price_missing") if currency != "TWD": errors.append("currency_not_twd") if not _is_public_url(product_url, host=YAHOO_HOST, path_prefix="/gdsale/"): errors.append("product_url_outside_allowlist") normalized = { "product_id": product_id, "name": title, "price": price, "currency": currency, "product_url": product_url, "offer_url": str(offer.get("url") or "").strip(), "image_url": _first_image(product.get("image")), "brand": str(brand.get("name") or "").strip(), "gtin13": str(product.get("gtin13") or "").strip(), "seller": str(seller.get("name") or "").strip(), "availability": str(offer.get("availability") or "").strip(), "rating": _to_float(rating.get("ratingValue")), "review_count": int(_to_float(rating.get("ratingCount"))), } return { "success": not errors, "policy": POLICY, "adapter_version": ADAPTER_VERSION, "source_method": "public_product_jsonld", "product_url": product_url, "product": normalized, "errors": errors, "warnings": warnings, "content_sha256": hashlib.sha256((html or "").encode("utf-8")).hexdigest(), "product_fingerprint_sha256": _canonical_hash(normalized), } def parse_pchome_public_product_jsonp(payload: str) -> dict[str, Any]: """Parse the public PChome JSONP wrapper without evaluating JavaScript.""" start = str(payload or "").find("jsonp(") if start < 0: return {} start += len("jsonp(") end = str(payload or "").find(");}catch", start) if end < 0: end = str(payload or "").find(");", start) if end < 0: return {} try: parsed = json.loads(str(payload or "")[start:end]) except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} def _target_current_price(product: Mapping[str, Any], fallback: Any) -> tuple[float, str]: prices = product.get("Price") if isinstance(product.get("Price"), dict) else {} for key in ("P", "Low", "M"): value = _to_float(prices.get(key)) if value > 0: return value, key return _to_float(fallback), "sales_report_fallback" def enrich_targets_with_pchome_public_evidence( targets: list[dict[str, Any]], *, request_get: Callable[..., Any] | None = None, timeout: float | None = None, ) -> dict[str, Any]: """Attach current public PChome name, price, and Spec to bounded targets.""" ids = [ str(item.get("product_id") or "").strip() for item in targets or [] if str(item.get("product_id") or "").strip() ] if not ids: return {"success": True, "evidence_count": 0, "missing_count": 0} timeout_seconds = timeout or _bounded_float_env( "PCHOME_GROWTH_YAHOO_HTTP_TIMEOUT_SECONDS", 15, 3, 30 ) url = ( f"{PCHOME_PUBLIC_PRODUCT_API}?id={quote(','.join(ids), safe=',')}" "&fields=Id,Name,Nick,Price,Spec,Qty&_callback=jsonp" ) getter = request_get or _default_get try: response = getter(url, timeout=timeout_seconds) final_url = str(getattr(response, "url", url) or url) if not _is_public_url(final_url, host=PCHOME_API_HOST, path_prefix="/ecshop/"): raise ValueError("pchome_public_api_redirect_outside_allowlist") products = parse_pchome_public_product_jsonp(_response_text(response)) except Exception as exc: return { "success": False, "evidence_count": 0, "missing_count": len(ids), "error_type": type(exc).__name__, "error": str(exc)[:200], } evidence_count = 0 for target in targets: product_id = str(target.get("product_id") or "").strip() product = products.get(product_id) if not isinstance(product, dict): continue public_name = str(product.get("Name") or target.get("name") or "").strip() public_spec = _plain_text(product.get("Spec")) public_price, public_price_field = _target_current_price(product, target.get("price")) prices = product.get("Price") if isinstance(product.get("Price"), dict) else {} original_name = str(target.get("name") or "").strip() original_price = target.get("price") target["name"] = ( f"{public_name} {public_spec}" if public_spec else public_name ).strip() target["price"] = public_price target["target_public_evidence"] = { "source": "pchome_public_product_api", "product_id": product_id, "name": public_name, "spec": public_spec, "current_price": public_price, "current_price_field": public_price_field, "price_fields": { key: _to_float(prices.get(key)) for key in ("P", "Low", "M") if _to_float(prices.get(key)) > 0 }, "sales_realized_price": original_price, "sales_report_name": original_name, **({"quantity": int(_to_float(product.get("Qty")))} if product.get("Qty") not in (None, "") else {}), } evidence_count += 1 return { "success": evidence_count == len(ids), "evidence_count": evidence_count, "missing_count": max(0, len(ids) - evidence_count), "source_url": url, "source_method": "pchome_public_product_api", } def _source_contract_guard( *, target: Mapping[str, Any], search_candidate: Mapping[str, Any], product_readback: Mapping[str, Any], ) -> dict[str, Any]: detail = product_readback.get("product") detail = detail if isinstance(detail, dict) else {} reasons: list[str] = [] source_title = str(detail.get("name") or search_candidate.get("name") or "") target_evidence = target.get("target_public_evidence") target_evidence = target_evidence if isinstance(target_evidence, dict) else {} target_spec = str(target_evidence.get("spec") or "").strip() if not product_readback.get("success"): reasons.append("product_jsonld_invalid") if str(detail.get("product_id") or "") != str( search_candidate.get("product_id") or "" ): reasons.append("search_detail_product_id_mismatch") if any(marker in source_title for marker in SELECTION_MARKERS): reasons.append("source_selection_listing") if _has_multi_variant_group_for_spec(source_title, target_spec): reasons.append("source_multi_variant_listing") if target_spec and _compact_text(target_spec) not in _compact_text(source_title): reasons.append("target_spec_missing_from_source_title") if not target_evidence.get("current_price") or target_evidence.get("current_price_field") == "sales_report_fallback": reasons.append("pchome_current_price_missing") if "quantity" in target_evidence and int( _to_float(target_evidence.get("quantity")) ) <= 0: reasons.append("pchome_stock_not_available") source_stock_status = str( detail.get("availability") or search_candidate.get("stock_status") or "" ).lower() if source_stock_status and not source_stock_status.replace("_", "").endswith( "instock" ): reasons.append("source_stock_not_confirmed") if str(detail.get("currency") or "") != "TWD": reasons.append("source_currency_not_twd") if _to_float(detail.get("price")) <= 0: reasons.append("source_positive_price_missing") if not _is_public_url( str(detail.get("product_url") or ""), host=YAHOO_HOST, path_prefix="/gdsale/", ): reasons.append("source_url_outside_allowlist") return { "policy": POLICY, "version": ADAPTER_VERSION, "passed": not reasons, "reasons": reasons, "public_source_boundary": True, "rate_limit_contract": "bounded_targets_and_terms", "provenance_contract": "search_html_hash_plus_product_jsonld_hash", "target_spec_required_when_present": True, "stock_gate": "reject_explicit_unavailable_evidence", "selection_listing_rejected": True, "direct_price_write_allowed": False, "requires_independent_same_item_verifier": True, "requires_activation_canary": True, } def search_yahoo_candidates_for_pchome_products( targets: list[dict[str, Any]], limit: int, *, request_get: Callable[..., Any] | None = None, ) -> tuple[bool, str, list[dict[str, Any]]]: """Discover and validate Yahoo candidates for revenue-weighted PChome targets.""" from services.marketplace_product_matcher import ( build_search_terms, build_unit_price_comparison, score_marketplace_match, ) target_limit = max(1, min(int(limit or 12), 20)) term_limit = _bounded_int_env("PCHOME_GROWTH_YAHOO_MAX_TERMS_PER_TARGET", 1, 1, 3) detail_limit = _bounded_int_env( "PCHOME_GROWTH_YAHOO_DETAIL_CANDIDATES_PER_TARGET", 3, 1, 5 ) minimum_score = _bounded_float_env( "PCHOME_GROWTH_YAHOO_MIN_SCORE", 0.55, 0.45, 0.85 ) timeout = _bounded_float_env("PCHOME_GROWTH_YAHOO_HTTP_TIMEOUT_SECONDS", 15, 3, 30) raw_getter = request_get or _default_get request_delay = _bounded_float_env( "PCHOME_GROWTH_YAHOO_REQUEST_DELAY_SECONDS", 0.2, 0.0, 2.0, ) last_request_at = 0.0 def getter(url: str, *, timeout: float): nonlocal last_request_at if request_get is None and request_delay > 0: elapsed = time.monotonic() - last_request_at if elapsed < request_delay: time.sleep(request_delay - elapsed) response = raw_getter(url, timeout=timeout) last_request_at = time.monotonic() return response bounded_targets = list(targets or [])[:target_limit] pchome_evidence = enrich_targets_with_pchome_public_evidence( bounded_targets, request_get=getter, timeout=timeout, ) if not pchome_evidence.get("success"): return ( False, "PChome 公開商品規格或目前售價未完整讀回,本輪已停止正式候選。", [], ) candidates: list[dict[str, Any]] = [] searched_target_count = 0 search_request_count = 0 product_request_count = 0 source_errors: list[str] = [] for target in bounded_targets: target_id = str(target.get("product_id") or "").strip() target_name = str(target.get("name") or "").strip() target_price = _to_float(target.get("price")) if not target_id or not target_name or target_price <= 0: continue searched_target_count += 1 original_name = str( (target.get("target_public_evidence") or {}).get("name") or target_name ).strip() terms = [original_name] terms.extend(build_search_terms(original_name, max_terms=term_limit)) unique_terms: list[str] = [] for term in terms: term = re.sub(r"\s+", " ", str(term or "").strip()) if term and term.lower() not in {item.lower() for item in unique_terms}: unique_terms.append(term) if len(unique_terms) >= term_limit: break discovered: dict[str, dict[str, Any]] = {} for term in unique_terms: search_url = YAHOO_SEARCH_URL.format(query=quote(term)) try: response = getter(search_url, timeout=timeout) search_request_count += 1 final_url = str(getattr(response, "url", search_url) or search_url) if not _is_public_url( final_url, host=YAHOO_HOST, path_prefix="/search/" ): raise ValueError("yahoo_search_redirect_outside_allowlist") search_readback = parse_yahoo_search_html( _response_text(response), search_url=final_url, ) except Exception as exc: source_errors.append(f"{target_id}:{type(exc).__name__}") continue for item in search_readback.get("candidates") or []: product_id = str(item.get("product_id") or "") if product_id and product_id not in discovered: discovered[product_id] = { **item, "target_search_term": term, "search_provenance": { "search_url": final_url, "content_sha256": search_readback.get("content_sha256"), "source_method": search_readback.get("source_method"), }, } preliminaries: list[tuple[float, dict[str, Any]]] = [] for item in discovered.values(): diagnostics = score_marketplace_match( str(item.get("name") or ""), target_name, momo_price=_to_float(item.get("search_price")), competitor_price=target_price, ) score = _to_float(getattr(diagnostics, "score", 0.0)) if score >= minimum_score: preliminaries.append((score, item)) preliminaries.sort(key=lambda pair: pair[0], reverse=True) for _, search_candidate in preliminaries[:detail_limit]: product_url = str(search_candidate.get("product_url") or "") try: response = getter(product_url, timeout=timeout) product_request_count += 1 final_url = str(getattr(response, "url", product_url) or product_url) if not _is_public_url( final_url, host=YAHOO_HOST, path_prefix="/gdsale/" ): raise ValueError("yahoo_product_redirect_outside_allowlist") detail_readback = parse_yahoo_product_jsonld( _response_text(response), product_url=final_url, ) except Exception as exc: source_errors.append(f"{target_id}:{type(exc).__name__}") continue detail = detail_readback.get("product") detail = detail if isinstance(detail, dict) else {} source_name = str(detail.get("name") or search_candidate.get("name") or "") source_price = _to_float(detail.get("price")) diagnostics = score_marketplace_match( source_name, target_name, momo_price=source_price, competitor_price=target_price, ) score = _to_float(getattr(diagnostics, "score", 0.0)) if score < minimum_score: continue guard = _source_contract_guard( target=target, search_candidate=search_candidate, product_readback=detail_readback, ) comparison_mode = str(getattr(diagnostics, "comparison_mode", "") or "") price_basis = str(getattr(diagnostics, "price_basis", "") or "") alert_tier = str(getattr(diagnostics, "alert_tier", "") or "") match_type = str(getattr(diagnostics, "match_type", "") or "") hard_veto = bool(getattr(diagnostics, "hard_veto", True)) exact_ready = bool( guard.get("passed") and not hard_veto and comparison_mode == "exact_identity" and price_basis == "total_price" and alert_tier == "price_alert_exact" and match_type == "exact" ) unit_comparison: dict[str, Any] = {} unit_ready = False if comparison_mode == "unit_comparable" or price_basis == "unit_price": unit_comparison = build_unit_price_comparison( source_name, target_name, momo_price=source_price, competitor_price=target_price, ) unit_ready = bool( guard.get("passed") and unit_comparison.get("comparable") ) auto_type = ( "total_price" if exact_ready else "unit_price" if unit_ready else "manual_review" ) gap_pct = ( round((source_price - target_price) / target_price * 100, 2) if source_price > 0 and target_price > 0 else None ) source_provenance = { **(search_candidate.get("search_provenance") or {}), "product_url": final_url, "product_content_sha256": detail_readback.get("content_sha256"), "product_fingerprint_sha256": detail_readback.get( "product_fingerprint_sha256" ), "adapter_version": ADAPTER_VERSION, "observed_at": datetime.now(timezone.utc).isoformat(), } candidates.append( { "product_id": detail.get("product_id") or search_candidate.get("product_id"), "name": source_name, "title": source_name, "price": source_price, "original_price": search_candidate.get("original_price"), "currency": detail.get("currency") or "TWD", "product_url": final_url, "image_url": detail.get("image_url") or search_candidate.get("image_url"), "brand": detail.get("brand") or search_candidate.get("brand"), "gtin13": detail.get("gtin13"), "seller": detail.get("seller") or search_candidate.get("seller"), "stock_status": detail.get("availability") or search_candidate.get("stock_status"), "rating": detail.get("rating") or search_candidate.get("rating"), "review_count": detail.get("review_count") or search_candidate.get("review_count"), "target_pchome_product_id": target_id, "target_pchome_name": target_name, "target_pchome_price": target_price, "target_public_evidence": target.get("target_public_evidence") or {}, "target_search_term": search_candidate.get("target_search_term"), "target_match_score": round(score, 3), "target_match_reasons": list( getattr(diagnostics, "reasons", ()) or () ), "target_comparison_mode": comparison_mode, "target_match_type": match_type, "target_alert_tier": alert_tier, "target_hard_veto": hard_veto, "target_price_basis": "total_price" if exact_ready else price_basis, "target_gap_pct": gap_pct, "target_unit_price_comparison": unit_comparison, "auto_compare_type": auto_type, "can_auto_compare": auto_type in {"total_price", "unit_price"}, "source_strategy": CONNECTOR_KEY, "source_contract_guard": guard, "source_provenance": source_provenance, } ) exact_count = sum( 1 for item in candidates if item.get("auto_compare_type") == "total_price" ) guarded_count = sum( 1 for item in candidates if not bool((item.get("source_contract_guard") or {}).get("passed")) ) success = bool(searched_target_count and (candidates or not source_errors)) message = ( f"已用 {searched_target_count} 筆高業績商品搜尋 Yahoo," f"取得 {len(candidates)} 筆商品頁候選,{exact_count} 筆通過來源前置檢查," f"{guarded_count} 筆因規格或選項風險阻擋。" ) if source_errors and not candidates: message = "Yahoo 公開來源本輪無可用候選,已安全停止且不寫正式價格。" return success, message, candidates __all__ = [ "ADAPTER_VERSION", "CONNECTOR_KEY", "DEFAULT_RECEIPT_ROOT", "INGESTION_METHOD", "PLATFORM_CODE", "POLICY", "SOURCE_CODE", "SOURCE_LABEL", "WORK_ITEM_ID", "enrich_targets_with_pchome_public_evidence", "parse_pchome_public_product_jsonp", "parse_yahoo_product_jsonld", "parse_yahoo_search_html", "search_yahoo_candidates_for_pchome_products", ]