Compare commits

...

1 Commits

Author SHA1 Message Date
ogt
c3824119cd feat(crawler): expand pixelrag marketplace automation
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
2026-07-09 22:09:00 +08:00
10 changed files with 784 additions and 7 deletions

1
.gitignore vendored
View File

@@ -91,6 +91,7 @@ data/excel_exports/
data/*_cache/ data/*_cache/
data/ai_automation_smoke_history.jsonl data/ai_automation_smoke_history.jsonl
data/ai_automation/sitewide_visual_qa_latest.json data/ai_automation/sitewide_visual_qa_latest.json
runtime_artifacts/
# 上傳檔案 # 上傳檔案
web/static/uploads/ web/static/uploads/

View File

@@ -54,7 +54,7 @@ python scripts/tools/browse_sh_probe.py -- screenshot
## PixelRAG-style 視覺證據 fallback ## PixelRAG-style 視覺證據 fallback
2026-07-09 評估 PixelRAG 後,結論是「可導入,但不可直接取代正式爬蟲」。PixelRAG 的核心價值是把渲染後頁面截圖切成 tiles讓 AI 讀到 HTML parser 可能丟失的視覺結構;本專案第一階段採用這個視覺證據思路,不直接拉外部 runtime、不用外部 embedding API、不把像素結果寫入正式價格表。 2026-07-09 評估 PixelRAG 後,結論是「可導入,而且不應只限 MOMO/PChome」。PixelRAG 的核心價值是把渲染後頁面截圖切成 tiles讓 AI 讀到 HTML parser 可能丟失的視覺結構;本專案第一階段採用這個視覺證據思路,擴充成多電商 public marketplace visual evidence layer不直接拉外部 runtime、不用外部 embedding API、不把像素結果寫入正式價格表。
導入順序: 導入順序:
@@ -65,11 +65,44 @@ python scripts/tools/browse_sh_probe.py -- screenshot
5. Phase 4 若需要索引,優先設計 pgvector-compatible evidence metadataFAISS 只能先當本地研究/ADR 候選,不直接進 production。 5. Phase 4 若需要索引,優先設計 pgvector-compatible evidence metadataFAISS 只能先當本地研究/ADR 候選,不直接進 production。
6. Phase 5 才談 crawler fusion正式 `competitor_prices` / `competitor_price_history` 寫入仍需 matcher replay/canary 證據。 6. Phase 5 才談 crawler fusion正式 `competitor_prices` / `competitor_price_history` 寫入仍需 matcher replay/canary 證據。
多電商優先順序:
| Priority | Platform | PixelRAG 用途 | 邊界 |
|---|---|---|---|
| P0 | MOMO / PChome | 既有 structured crawler 失敗時自動產生 visual manifest queue | 正式價格仍以既有 parser/API/matcher 為準 |
| P1 | Shopee Taiwan / Coupang Taiwan | 抓公共搜尋頁商品卡、促銷 badge、店家/配送/評分視覺證據 | 不登入、不碰購物車、不繞過 anti-bot |
| P2 | Yahoo Shopping / ETMall | 補促銷券、快速到貨、活動價格等視覺訊號 | 先做 controlled probe再決定 parser |
| P3 | friDay / Rakuten Taiwan | 先建立 probe profile 與 search manifest | URL pattern 需 runtime receipt 驗證後再排程 |
已支援的 platform code
```text
momo
pchome
shopee_tw
coupang_tw
yahoo_shopping_tw
etmall_tw
friday_tw
rakuten_tw
```
自動觸發:
- `PChomeCrawler.fetch_region_page` 找不到商品 ID 時會 queue PixelRAG manifest。
- `PChomeCrawler.fetch_product_details` API 回傳但無可解析商品時會 queue 商品頁 manifest。
- `PChomeCrawler.search_products` 搜尋無商品 ID 或 request error 時會 queue 公開搜尋頁 manifest。
- queue 預設路徑:正式 app 容器 `/app/data/ai_automation/pixelrag_manifest_queue`,本機為 `runtime_artifacts/pixelrag_manifest_queue`
- queue 只寫 artifact不寫 DB、不寫正式價格、不讀 secretcapture worker 後續消費 manifest 產生 screenshot/tile receipt。
機器可讀評估: 機器可讀評估:
```bash ```bash
python scripts/ops/report_pixelrag_crawler_integration.py python scripts/ops/report_pixelrag_crawler_integration.py
python scripts/ops/report_pixelrag_crawler_integration.py --platform momo python scripts/ops/report_pixelrag_crawler_integration.py --platform momo
python scripts/ops/report_pixelrag_crawler_integration.py --ecommerce-expansion-plan
python scripts/ops/report_pixelrag_crawler_integration.py --platform shopee_tw --marketplace-keyword "防曬乳"
python scripts/ops/report_pixelrag_crawler_integration.py --platform coupang_tw --marketplace-keyword "iphone"
python scripts/ops/report_pixelrag_crawler_integration.py --capability ollama_multimodal_ready --capability pgvector_visual_ready python scripts/ops/report_pixelrag_crawler_integration.py --capability ollama_multimodal_ready --capability pgvector_visual_ready
python scripts/ops/report_pixelrag_crawler_integration.py \ python scripts/ops/report_pixelrag_crawler_integration.py \
--platform momo \ --platform momo \
@@ -84,6 +117,9 @@ python scripts/ops/report_pixelrag_crawler_integration.py \
```text ```text
/api/ai-automation/pixelrag-crawler-integration?platform=momo /api/ai-automation/pixelrag-crawler-integration?platform=momo
/api/ai-automation/pixelrag-crawler-integration?expansion_plan=true
/api/ai-automation/pixelrag-crawler-integration?platform=shopee_tw&marketplace_keyword=%E9%98%B2%E6%9B%AC%E4%B9%B3
/api/ai-automation/pixelrag-crawler-integration?platform=coupang_tw&marketplace_keyword=iphone
/api/ai-automation/pixelrag-crawler-integration?platform=pchome&manifest_url=https://24h.pchome.com.tw/prod/TEST-000000001&crawler=PChomeCrawler.search_products&trigger_reason=parser_empty /api/ai-automation/pixelrag-crawler-integration?platform=pchome&manifest_url=https://24h.pchome.com.tw/prod/TEST-000000001&crawler=PChomeCrawler.search_products&trigger_reason=parser_empty
/api/ai-automation/pixelrag-visual-evidence-readback?platform=pchome&manifest_id=4a93e95e5afb414bc8c3 /api/ai-automation/pixelrag-visual-evidence-readback?platform=pchome&manifest_id=4a93e95e5afb414bc8c3
``` ```

View File

@@ -640,10 +640,26 @@ def ai_automation_pixelrag_crawler_integration_api():
"""Read-only PixelRAG-style crawler visual evidence assessment.""" """Read-only PixelRAG-style crawler visual evidence assessment."""
from services.pixelrag_crawler_integration_service import ( from services.pixelrag_crawler_integration_service import (
build_pixelrag_crawler_integration_assessment, build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest, build_pixelrag_visual_evidence_manifest,
) )
platforms = request.args.getlist('platform') or None platforms = request.args.getlist('platform') or None
if str(request.args.get('expansion_plan') or '').lower() in {'1', 'true', 'yes'}:
return jsonify(build_pixelrag_ecommerce_expansion_plan(
target_platforms=tuple(platforms) if platforms else None,
))
marketplace_keyword = str(request.args.get('marketplace_keyword') or '').strip()
if marketplace_keyword:
platform = (platforms or ['shopee_tw'])[0]
return jsonify(build_pixelrag_marketplace_search_manifest(
platform=platform,
keyword=marketplace_keyword,
crawler=str(request.args.get('crawler') or 'PixelRAGMarketplaceSearch.visual_fallback'),
trigger_reason=str(request.args.get('trigger_reason') or 'marketplace_search_visual_scan'),
evidence_intent=str(request.args.get('evidence_intent') or 'collect_public_marketplace_offer_cards'),
))
manifest_url = str(request.args.get('manifest_url') or '').strip() manifest_url = str(request.args.get('manifest_url') or '').strip()
if manifest_url: if manifest_url:
platform = (platforms or ['momo'])[0] platform = (platforms or ['momo'])[0]

View File

@@ -21,6 +21,12 @@ const DEFAULT_MAX_TILES = 80;
const ALLOWED_PLATFORM_HOSTS = { const ALLOWED_PLATFORM_HOSTS = {
momo: new Set(['m.momoshop.com.tw', 'www.momoshop.com.tw']), momo: new Set(['m.momoshop.com.tw', 'www.momoshop.com.tw']),
pchome: new Set(['24h.pchome.com.tw', 'ecshweb.pchome.com.tw', 'ecapi-cdn.pchome.com.tw']), pchome: new Set(['24h.pchome.com.tw', 'ecshweb.pchome.com.tw', 'ecapi-cdn.pchome.com.tw']),
shopee_tw: new Set(['shopee.tw', 'mall.shopee.tw']),
coupang_tw: new Set(['www.tw.coupang.com', 'tw.coupang.com']),
yahoo_shopping_tw: new Set(['tw.buy.yahoo.com']),
etmall_tw: new Set(['www.etmall.com.tw', 'etmall.com.tw']),
friday_tw: new Set(['shopping.friday.tw', 'ec-m.shopping.friday.tw', 'ec-w.shopping.friday.tw']),
rakuten_tw: new Set(['www.rakuten.com.tw', 'rakuten.com.tw']),
market_intel: new Set(), market_intel: new Set(),
external_market: new Set(), external_market: new Set(),
}; };

View File

@@ -26,6 +26,12 @@ DEFAULT_MAX_TILES = 80
ALLOWED_PLATFORM_HOSTS = { ALLOWED_PLATFORM_HOSTS = {
"momo": {"m.momoshop.com.tw", "www.momoshop.com.tw"}, "momo": {"m.momoshop.com.tw", "www.momoshop.com.tw"},
"pchome": {"24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"}, "pchome": {"24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"},
"shopee_tw": {"shopee.tw", "mall.shopee.tw"},
"coupang_tw": {"www.tw.coupang.com", "tw.coupang.com"},
"yahoo_shopping_tw": {"tw.buy.yahoo.com"},
"etmall_tw": {"www.etmall.com.tw", "etmall.com.tw"},
"friday_tw": {"shopping.friday.tw", "ec-m.shopping.friday.tw", "ec-w.shopping.friday.tw"},
"rakuten_tw": {"www.rakuten.com.tw", "rakuten.com.tw"},
"market_intel": set(), "market_intel": set(),
"external_market": set(), "external_market": set(),
} }

View File

@@ -15,6 +15,8 @@ if str(ROOT) not in sys.path:
from services.pixelrag_crawler_integration_service import ( # noqa: E402 from services.pixelrag_crawler_integration_service import ( # noqa: E402
build_pixelrag_crawler_integration_assessment, build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest, build_pixelrag_visual_evidence_manifest,
) )
@@ -62,6 +64,12 @@ def main() -> int:
help="覆寫 readiness 假設,用於模擬下一階段條件。", help="覆寫 readiness 假設,用於模擬下一階段條件。",
) )
parser.add_argument("--manifest-url", help="輸出單一 URL 的視覺證據 manifest。") parser.add_argument("--manifest-url", help="輸出單一 URL 的視覺證據 manifest。")
parser.add_argument("--marketplace-keyword", help="依平台與關鍵字輸出多電商視覺搜尋 manifest。")
parser.add_argument(
"--ecommerce-expansion-plan",
action="store_true",
help="輸出 MOMO/PChome 以外的 PixelRAG 多電商擴充計畫。",
)
parser.add_argument("--crawler", default="crawler_diagnostics", help="manifest 來源 crawler 名稱。") parser.add_argument("--crawler", default="crawler_diagnostics", help="manifest 來源 crawler 名稱。")
parser.add_argument( parser.add_argument(
"--trigger-reason", "--trigger-reason",
@@ -90,6 +98,43 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
if args.ecommerce_expansion_plan:
payload = build_pixelrag_ecommerce_expansion_plan(
target_platforms=tuple(args.platforms) if args.platforms else None,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if payload.get("success") else 1
if args.marketplace_keyword:
platform = (args.platforms or ["shopee_tw"])[0]
crawler = (
"PixelRAGMarketplaceSearch.visual_fallback"
if args.crawler == "crawler_diagnostics"
else args.crawler
)
trigger_reason = (
"marketplace_search_visual_scan"
if args.trigger_reason == "parser_empty_or_low_confidence"
else args.trigger_reason
)
evidence_intent = (
"collect_public_marketplace_offer_cards"
if args.evidence_intent == "recover_visual_offer_evidence"
else args.evidence_intent
)
payload = build_pixelrag_marketplace_search_manifest(
platform=platform,
keyword=args.marketplace_keyword,
crawler=crawler,
trigger_reason=trigger_reason,
evidence_intent=evidence_intent,
viewport=args.viewport,
page_size=args.page_size,
tile_size=args.tile_size,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if payload.get("success") else 1
if args.manifest_url: if args.manifest_url:
platform = (args.platforms or ["momo"])[0] platform = (args.platforms or ["momo"])[0]
payload = build_pixelrag_visual_evidence_manifest( payload = build_pixelrag_visual_evidence_manifest(

View File

@@ -18,6 +18,7 @@ import logging
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
import requests import requests
@@ -139,6 +140,7 @@ class PChomeCrawler:
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update(self.DEFAULT_HEADERS) self.session.headers.update(self.DEFAULT_HEADERS)
self._last_request_time = 0 self._last_request_time = 0
self.last_visual_evidence_action = None
def _rate_limit(self): def _rate_limit(self):
"""速率限制""" """速率限制"""
@@ -188,6 +190,58 @@ class PChomeCrawler:
raise last_error raise last_error
raise requests.RequestException(f"GET failed: {url}") raise requests.RequestException(f"GET failed: {url}")
def _public_search_url(self, keyword: str) -> str:
return f"{self.BASE_URL}/search/?{urlencode({'q': str(keyword or '').strip()})}"
def _emit_visual_evidence_action(
self,
*,
url: str,
crawler: str,
failure_reason: str,
parsed_item_count: int = 0,
missing_fields: Tuple[str, ...] = ("product_id", "title", "price"),
) -> Optional[dict]:
"""Queue a PixelRAG visual manifest for parser-empty crawler cases."""
try:
from services.pixelrag_crawler_integration_service import (
build_pixelrag_crawler_failure_action,
)
action = build_pixelrag_crawler_failure_action(
url=url,
platform="pchome",
crawler=crawler,
parser_success=False,
parsed_item_count=parsed_item_count,
missing_fields=missing_fields,
failure_reason=failure_reason,
persist_manifest=True,
)
self.last_visual_evidence_action = action
if action.get("success"):
logger.info(
"[PChome] PixelRAG visual evidence manifest queued | crawler=%s status=%s path=%s",
crawler,
action.get("status"),
((action.get("persistence") or {}).get("path") or ""),
)
else:
logger.warning(
"[PChome] PixelRAG visual evidence action failed | crawler=%s status=%s",
crawler,
action.get("status"),
)
return action
except Exception as exc:
logger.warning("[PChome] PixelRAG visual evidence action skipped: %s", exc)
self.last_visual_evidence_action = {
"success": False,
"status": "exception",
"error": str(exc)[:300],
}
return self.last_visual_evidence_action
def _normalize_product_id(self, product_id: str) -> str: def _normalize_product_id(self, product_id: str) -> str:
""" """
正規化商品 ID 格式 正規化商品 ID 格式
@@ -248,11 +302,22 @@ class PChomeCrawler:
product_ids = self._extract_product_ids_from_html(response.text) product_ids = self._extract_product_ids_from_html(response.text)
logger.info(f"{url} 取得 {len(product_ids)} 個商品 ID") logger.info(f"{url} 取得 {len(product_ids)} 個商品 ID")
if not product_ids:
self._emit_visual_evidence_action(
url=url,
crawler="PChomeCrawler.fetch_region_page",
failure_reason="HTML parser found no product IDs in region page",
)
return True, f"成功取得 {len(product_ids)} 個商品", product_ids return True, f"成功取得 {len(product_ids)} 個商品", product_ids
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"爬取 {url} 失敗: {e}") logger.error(f"爬取 {url} 失敗: {e}")
self._emit_visual_evidence_action(
url=url,
crawler="PChomeCrawler.fetch_region_page",
failure_reason=f"request_error: {e}",
)
return False, f"請求失敗: {str(e)}", [] return False, f"請求失敗: {str(e)}", []
def fetch_product_details(self, product_ids: List[str], batch_size: int = 20) -> Tuple[bool, str, List[PChomeProduct]]: def fetch_product_details(self, product_ids: List[str], batch_size: int = 20) -> Tuple[bool, str, List[PChomeProduct]]:
@@ -327,6 +392,14 @@ class PChomeCrawler:
message = f"成功取得 {len(all_products)} 個商品資料" message = f"成功取得 {len(all_products)} 個商品資料"
if failed_count > 0: if failed_count > 0:
message += f"{failed_count} 個失敗" message += f"{failed_count} 個失敗"
if product_ids and not all_products:
first_id = self._normalize_product_id(str(product_ids[0]))
self._emit_visual_evidence_action(
url=f"{self.BASE_URL}/prod/{first_id}",
crawler="PChomeCrawler.fetch_product_details",
failure_reason=message,
missing_fields=("title", "price", "product_id", "image_url"),
)
return len(all_products) > 0, message, all_products return len(all_products) > 0, message, all_products
@@ -487,6 +560,11 @@ class PChomeCrawler:
break break
if not product_ids: if not product_ids:
self._emit_visual_evidence_action(
url=self._public_search_url(keyword),
crawler="PChomeCrawler.search_products",
failure_reason="search API returned no product IDs",
)
return False, "沒有找到符合的商品", [] return False, "沒有找到符合的商品", []
# 取得詳細資料 # 取得詳細資料
@@ -497,6 +575,11 @@ class PChomeCrawler:
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"搜尋失敗: {e}") logger.error(f"搜尋失敗: {e}")
self._emit_visual_evidence_action(
url=self._public_search_url(keyword),
crawler="PChomeCrawler.search_products",
failure_reason=f"request_error: {e}",
)
return False, f"搜尋失敗: {str(e)}", [] return False, f"搜尋失敗: {str(e)}", []

View File

@@ -15,20 +15,116 @@ from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
from urllib.parse import urlparse from urllib.parse import quote_plus, urlparse
POLICY = "read_only_pixelrag_crawler_integration_assessment_v1" POLICY = "read_only_pixelrag_crawler_integration_assessment_v1"
MANIFEST_POLICY = "read_only_pixelrag_visual_evidence_manifest_v1" MANIFEST_POLICY = "read_only_pixelrag_visual_evidence_manifest_v1"
READBACK_POLICY = "read_only_pixelrag_visual_evidence_readback_v1" READBACK_POLICY = "read_only_pixelrag_visual_evidence_readback_v1"
ECOMMERCE_EXPANSION_POLICY = "read_only_pixelrag_ecommerce_expansion_plan_v1"
CRAWLER_FAILURE_ACTION_POLICY = "read_only_pixelrag_crawler_failure_action_v1"
RESULT_PHASE1_READY = "PIXELRAG_VISUAL_EVIDENCE_PHASE1_READY" RESULT_PHASE1_READY = "PIXELRAG_VISUAL_EVIDENCE_PHASE1_READY"
RESULT_BLOCKED_NO_CAPTURE = "PIXELRAG_BLOCKED_NO_VISUAL_CAPTURE" RESULT_BLOCKED_NO_CAPTURE = "PIXELRAG_BLOCKED_NO_VISUAL_CAPTURE"
RESULT_MANIFEST_READY = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY" RESULT_MANIFEST_READY = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
RESULT_MANIFEST_REJECTED = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_REJECTED" RESULT_MANIFEST_REJECTED = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_REJECTED"
ALLOWED_PLATFORMS = ("momo", "pchome", "market_intel", "external_market")
DEFAULT_VIEWPORT = {"name": "desktop-1440", "width": 1440, "height": 950} DEFAULT_VIEWPORT = {"name": "desktop-1440", "width": 1440, "height": 950}
DEFAULT_TILE_SIZE = {"width": 512, "height": 512} DEFAULT_TILE_SIZE = {"width": 512, "height": 512}
ECOMMERCE_PLATFORM_PROFILES: dict[str, dict[str, Any]] = {
"momo": {
"display_name": "MOMO",
"hosts": ["m.momoshop.com.tw", "www.momoshop.com.tw"],
"search_url_template": "https://m.momoshop.com.tw/search.momo?searchKeyword={query}",
"priority": "incumbent_structured_parser_plus_visual_fallback",
"best_for": ["rendered promo blocks", "bundle/spec cards", "parser-empty search pages"],
"data_targets": ["title", "price", "bundle_spec", "promo_badge", "image"],
},
"pchome": {
"display_name": "PChome 24h",
"hosts": ["24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"],
"search_url_template": "https://24h.pchome.com.tw/search/?q={query}",
"priority": "incumbent_api_parser_plus_visual_fallback",
"best_for": ["search result cards", "campaign badges", "rendered price cards"],
"data_targets": ["title", "price", "product_id", "campaign_badge", "stock_hint"],
},
"shopee_tw": {
"display_name": "Shopee Taiwan",
"hosts": ["shopee.tw", "mall.shopee.tw"],
"search_url_template": "https://shopee.tw/search?keyword={query}",
"priority": "P1_marketplace_expansion",
"best_for": ["JS-heavy marketplace cards", "seller badges", "shipping/promo chips"],
"data_targets": ["title", "price", "shop", "sold_count", "rating", "shipping_badge"],
},
"coupang_tw": {
"display_name": "Coupang Taiwan",
"hosts": ["www.tw.coupang.com", "tw.coupang.com"],
"search_url_template": "https://www.tw.coupang.com/search?q={query}",
"priority": "P1_marketplace_expansion",
"best_for": ["Rocket delivery badges", "discount panels", "spec tables"],
"data_targets": ["title", "price", "discount", "delivery_badge", "spec", "review_count"],
},
"yahoo_shopping_tw": {
"display_name": "Yahoo Shopping Taiwan",
"hosts": ["tw.buy.yahoo.com"],
"search_url_template": "https://tw.buy.yahoo.com/search/product?p={query}",
"priority": "P2_marketplace_expansion",
"best_for": ["search cards", "campaign coupons", "rating and sales badges"],
"data_targets": ["title", "price", "coupon", "rating", "sales_hint"],
},
"etmall_tw": {
"display_name": "ETMall",
"hosts": ["www.etmall.com.tw", "etmall.com.tw"],
"search_url_template": "https://www.etmall.com.tw/Search?keyword={query}",
"priority": "P2_marketplace_expansion",
"best_for": ["promotion blocks", "coupon and member badges"],
"data_targets": ["title", "price", "promo_badge", "member_price", "shipping_hint"],
},
"friday_tw": {
"display_name": "friDay Shopping",
"hosts": ["shopping.friday.tw", "ec-m.shopping.friday.tw", "ec-w.shopping.friday.tw"],
"search_url_template": "https://ec-m.shopping.friday.tw/search?keyword={query}",
"priority": "P3_probe_required",
"best_for": ["campaign cards", "member reward hints", "mobile rendered offers"],
"data_targets": ["title", "price", "reward_badge", "campaign_badge"],
},
"rakuten_tw": {
"display_name": "Rakuten Taiwan",
"hosts": ["www.rakuten.com.tw", "rakuten.com.tw"],
"search_url_template": "https://www.rakuten.com.tw/search/{query}/",
"priority": "P3_probe_required",
"best_for": ["storefront cards", "cross-store price comparisons", "coupon badges"],
"data_targets": ["title", "price", "shop", "coupon", "point_reward"],
},
"market_intel": {
"display_name": "Market intelligence URL",
"hosts": [],
"search_url_template": "",
"priority": "custom_url_only",
"best_for": ["controlled external market diagnostics"],
"data_targets": ["public_page_visual_evidence"],
},
"external_market": {
"display_name": "External market URL",
"hosts": [],
"search_url_template": "",
"priority": "custom_url_only",
"best_for": ["controlled public external URL diagnostics"],
"data_targets": ["public_page_visual_evidence"],
},
}
ALLOWED_PLATFORMS = tuple(ECOMMERCE_PLATFORM_PROFILES)
MARKETPLACE_EXPANSION_ORDER = (
"momo",
"pchome",
"shopee_tw",
"coupang_tw",
"yahoo_shopping_tw",
"etmall_tw",
"friday_tw",
"rakuten_tw",
)
def _default_artifact_root() -> str: def _default_artifact_root() -> str:
container_root = Path("/app/data/ai_automation") container_root = Path("/app/data/ai_automation")
if container_root.exists(): if container_root.exists():
@@ -36,7 +132,18 @@ def _default_artifact_root() -> str:
return "runtime_artifacts/pixelrag_visual_evidence" return "runtime_artifacts/pixelrag_visual_evidence"
def _default_manifest_queue_root() -> str:
container_root = Path("/app/data/ai_automation")
if container_root.exists():
return str(container_root / "pixelrag_manifest_queue")
return "runtime_artifacts/pixelrag_manifest_queue"
DEFAULT_ARTIFACT_ROOT = os.getenv("PIXELRAG_VISUAL_EVIDENCE_ROOT", _default_artifact_root()) DEFAULT_ARTIFACT_ROOT = os.getenv("PIXELRAG_VISUAL_EVIDENCE_ROOT", _default_artifact_root())
DEFAULT_MANIFEST_QUEUE_ROOT = os.getenv(
"PIXELRAG_VISUAL_EVIDENCE_MANIFEST_QUEUE_ROOT",
_default_manifest_queue_root(),
)
DEFAULT_ARTIFACT_MAX_AGE_HOURS = int(os.getenv("PIXELRAG_VISUAL_EVIDENCE_MAX_AGE_HOURS", "168")) DEFAULT_ARTIFACT_MAX_AGE_HOURS = int(os.getenv("PIXELRAG_VISUAL_EVIDENCE_MAX_AGE_HOURS", "168"))
VISUAL_FALLBACK_CONFIDENCE_TRIGGERS = { VISUAL_FALLBACK_CONFIDENCE_TRIGGERS = {
"low", "low",
@@ -188,6 +295,38 @@ def _safe_int(value: Any, default: int = 0) -> int:
return default return default
def _visual_barrier_reason(receipt: Mapping[str, Any]) -> str:
http_status = _safe_int(receipt.get("http_status"))
page_metrics = receipt.get("page_metrics") or {}
final_url = str(page_metrics.get("final_url") or "").lower()
title = str(page_metrics.get("title") or "").lower()
if http_status >= 400:
return f"http_status_{http_status}"
barrier_tokens = (
"access denied",
"verify/traffic/error",
"captcha",
"blocked",
"robot",
)
haystack = f"{final_url} {title}"
for token in barrier_tokens:
if token in haystack:
return token.replace("/", "_").replace(" ", "_")
return ""
def _platform_profile(platform: str) -> dict[str, Any] | None:
return ECOMMERCE_PLATFORM_PROFILES.get(str(platform or "").strip().lower())
def _search_url_for_platform(platform: str, keyword: str) -> str:
profile = _platform_profile(platform) or {}
template = str(profile.get("search_url_template") or "")
query = quote_plus(str(keyword or "").strip())
return template.format(query=query) if template and query else ""
def _build_phases(capabilities: Mapping[str, bool]) -> list[dict[str, Any]]: def _build_phases(capabilities: Mapping[str, bool]) -> list[dict[str, Any]]:
capture_ready = bool(capabilities["playwright_artifact_pipeline"]) capture_ready = bool(capabilities["playwright_artifact_pipeline"])
structured_ready = bool(capabilities["structured_crawler_api"]) structured_ready = bool(capabilities["structured_crawler_api"])
@@ -316,6 +455,131 @@ def should_emit_visual_evidence_fallback(
} }
def build_pixelrag_ecommerce_expansion_plan(
*,
target_platforms: tuple[str, ...] | list[str] | None = None,
) -> dict[str, Any]:
"""Return a machine-readable PixelRAG rollout plan for public commerce sites."""
requested = [
str(platform or "").strip().lower()
for platform in (target_platforms or MARKETPLACE_EXPANSION_ORDER)
if str(platform or "").strip()
]
selected = [
platform
for platform in requested
if platform in ECOMMERCE_PLATFORM_PROFILES
]
unknown = [
platform
for platform in requested
if platform not in ECOMMERCE_PLATFORM_PROFILES
]
profiles = []
for platform in selected:
profile = deepcopy(ECOMMERCE_PLATFORM_PROFILES[platform])
profile["platform"] = platform
profile["manifest_ready"] = bool(profile.get("search_url_template"))
profile["capture_scope"] = "public_read_only_no_login_no_cart"
profiles.append(profile)
return {
"success": True,
"policy": ECOMMERCE_EXPANSION_POLICY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": "marketplace_profiles_ready",
"platform_count": len(profiles),
"unknown_platforms": unknown,
"platforms": profiles,
"advantages": [
"Covers JS-heavy cards and visual promotion badges that HTML parsers often miss.",
"Creates replayable screenshots and tiles before adding platform-specific parsers.",
"Lets AI compare public offer presentation across marketplaces without writing price truth.",
"Keeps each platform behind host allowlists and read-only artifact boundaries.",
],
"limitations": [
"Screenshots are slower and heavier than structured APIs.",
"Visual evidence is not a formal price source until OCR/VLM replay is verified.",
"Login-only, cart-only, coupon-personalized, or anti-bot-gated states are out of scope.",
"Search URL patterns still need runtime probes before production scheduling per platform.",
],
"next_actions": [
{
"priority": "P0",
"action": "capture_public_search_visual_receipts",
"target_platforms": ["shopee_tw", "coupang_tw"],
"status": "ready_for_controlled_probe",
},
{
"priority": "P1",
"action": "add_visual_card_extraction_replay",
"target_fields": ["title", "price", "promo_badge", "shop", "rating"],
"status": "requires_receipt_samples",
},
{
"priority": "P2",
"action": "promote_stable_platform_parser",
"status": "only_after_replay_beats_visual_only_diagnostics",
},
],
"controlled_apply": _controlled_apply_boundary(),
}
def build_pixelrag_marketplace_search_manifest(
*,
platform: str,
keyword: str,
crawler: str = "PixelRAGMarketplaceSearch.visual_fallback",
trigger_reason: str = "marketplace_search_visual_scan",
evidence_intent: str = "collect_public_marketplace_offer_cards",
viewport: Mapping[str, Any] | None = None,
page_size: Mapping[str, Any] | None = None,
tile_size: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a visual evidence manifest from a public marketplace keyword."""
platform_code = str(platform or "").strip().lower()
keyword_text = str(keyword or "").strip()
profile = _platform_profile(platform_code)
errors: list[str] = []
if not profile:
errors.append(f"platform must be one of: {', '.join(ALLOWED_PLATFORMS)}.")
if not keyword_text:
errors.append("keyword is required.")
search_url = _search_url_for_platform(platform_code, keyword_text)
if profile and not search_url:
errors.append("platform does not define a public search URL template.")
if errors:
return {
"success": False,
"policy": MANIFEST_POLICY,
"result": RESULT_MANIFEST_REJECTED,
"errors": errors,
"controlled_apply": _controlled_apply_boundary(),
}
manifest = build_pixelrag_visual_evidence_manifest(
url=search_url,
platform=platform_code,
crawler=crawler,
trigger_reason=trigger_reason,
evidence_intent=evidence_intent,
viewport=viewport,
page_size=page_size,
tile_size=tile_size,
)
if manifest.get("success"):
manifest["marketplace_profile"] = {
"platform": platform_code,
"display_name": profile.get("display_name"),
"hosts": profile.get("hosts"),
"priority": profile.get("priority"),
"data_targets": profile.get("data_targets"),
"keyword": keyword_text,
}
return manifest
def build_pixelrag_visual_evidence_manifest( def build_pixelrag_visual_evidence_manifest(
*, *,
url: str, url: str,
@@ -425,6 +689,144 @@ def build_pixelrag_visual_evidence_manifest(
} }
def persist_pixelrag_visual_evidence_manifest(
manifest: Mapping[str, Any],
*,
queue_root: str | Path | None = None,
) -> dict[str, Any]:
"""Persist a capture manifest to the local artifact queue."""
root = Path(queue_root or DEFAULT_MANIFEST_QUEUE_ROOT)
platform = str((manifest.get("capture_target") or {}).get("platform") or "unknown").strip().lower()
manifest_id = str(manifest.get("manifest_id") or "").strip()
errors: list[str] = []
if manifest.get("policy") != MANIFEST_POLICY:
errors.append(f"manifest policy must be {MANIFEST_POLICY}")
if not manifest.get("success"):
errors.append("manifest must be successful before persistence")
if not manifest_id:
errors.append("manifest_id is required")
if platform not in ALLOWED_PLATFORMS:
errors.append(f"platform must be one of: {', '.join(ALLOWED_PLATFORMS)}.")
if errors:
return {
"success": False,
"status": "rejected",
"errors": errors,
"queue_root": str(root),
"controlled_apply": {
"artifact_write": False,
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
target_dir = root / platform
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / f"{manifest_id}.json"
temp_path = target_path.with_suffix(".json.tmp")
payload = dict(manifest)
payload["queued_at"] = datetime.now(timezone.utc).isoformat()
payload["queue_policy"] = CRAWLER_FAILURE_ACTION_POLICY
temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temp_path.replace(target_path)
return {
"success": True,
"status": "queued",
"path": str(target_path),
"queue_root": str(root),
"controlled_apply": {
"artifact_write": True,
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
def build_pixelrag_crawler_failure_action(
*,
url: str,
platform: str,
crawler: str,
parser_success: bool,
parsed_item_count: int = 0,
confidence_band: str = "",
missing_fields: tuple[str, ...] | list[str] | None = None,
failure_reason: str = "",
evidence_intent: str = "recover_visual_offer_evidence",
persist_manifest: bool = False,
queue_root: str | Path | None = None,
viewport: Mapping[str, Any] | None = None,
page_size: Mapping[str, Any] | None = None,
tile_size: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Turn a crawler parse failure into a queued PixelRAG capture action."""
selector = should_emit_visual_evidence_fallback(
parser_success=parser_success,
parsed_item_count=parsed_item_count,
confidence_band=confidence_band,
missing_fields=missing_fields,
failure_reason=failure_reason,
)
if not selector["should_emit"]:
return {
"success": True,
"policy": CRAWLER_FAILURE_ACTION_POLICY,
"status": "skipped_structured_evidence_sufficient",
"selector": selector,
"manifest": None,
"persistence": None,
"next_machine_action": "keep_structured_crawler_result",
"controlled_apply": _controlled_apply_boundary(),
}
manifest = build_pixelrag_visual_evidence_manifest(
url=url,
platform=platform,
crawler=crawler,
trigger_reason=selector["fallback_reason"],
evidence_intent=evidence_intent,
viewport=viewport,
page_size=page_size,
tile_size=tile_size,
)
persistence = None
if persist_manifest and manifest.get("success"):
persistence = persist_pixelrag_visual_evidence_manifest(
manifest,
queue_root=queue_root,
)
manifest_ready = bool(manifest.get("success"))
queued = bool((persistence or {}).get("success"))
return {
"success": manifest_ready and (not persist_manifest or queued),
"policy": CRAWLER_FAILURE_ACTION_POLICY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": (
"manifest_persisted"
if queued
else ("manifest_ready" if manifest_ready else "manifest_rejected")
),
"selector": selector,
"manifest": manifest,
"persistence": persistence,
"next_machine_action": (
"run_pixelrag_visual_capture_worker"
if manifest_ready
else "fix_manifest_input"
),
"controlled_apply": {
"network_call": False,
"artifact_write": bool(queued),
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
def build_pixelrag_crawler_integration_assessment( def build_pixelrag_crawler_integration_assessment(
*, *,
capabilities: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None,
@@ -634,7 +1036,19 @@ def build_pixelrag_visual_evidence_readback(
capture_target = receipt.get("capture_target") or {} capture_target = receipt.get("capture_target") or {}
tile_plan = receipt.get("tile_plan") or {} tile_plan = receipt.get("tile_plan") or {}
status = "ok" if receipt.get("status") == "captured" and missing_file_count == 0 and not stale else "warning" page_metrics = receipt.get("page_metrics") or {}
http_status = _safe_int(receipt.get("http_status"))
barrier_reason = _visual_barrier_reason(receipt)
status = (
"ok"
if (
receipt.get("status") == "captured"
and missing_file_count == 0
and not stale
and not barrier_reason
)
else "warning"
)
if errors: if errors:
status = "critical" status = "critical"
@@ -651,9 +1065,11 @@ def build_pixelrag_visual_evidence_readback(
"missing_file_count": missing_file_count, "missing_file_count": missing_file_count,
"planned_tile_count": _safe_int(tile_plan.get("planned_tile_count")), "planned_tile_count": _safe_int(tile_plan.get("planned_tile_count")),
"emitted_tile_count": _safe_int(tile_plan.get("emitted_tile_count")), "emitted_tile_count": _safe_int(tile_plan.get("emitted_tile_count")),
"http_status": _safe_int(receipt.get("http_status")), "http_status": http_status,
"age_hours": round(age_hours, 3) if age_hours is not None else None, "age_hours": round(age_hours, 3) if age_hours is not None else None,
"stale": stale, "stale": stale,
"visual_barrier_detected": bool(barrier_reason),
"visual_barrier_reason": barrier_reason,
"errors": errors, "errors": errors,
}, },
"latest_receipt": { "latest_receipt": {
@@ -661,14 +1077,18 @@ def build_pixelrag_visual_evidence_readback(
"generated_at": receipt.get("generated_at"), "generated_at": receipt.get("generated_at"),
"capture_status": receipt.get("status"), "capture_status": receipt.get("status"),
"capture_target": capture_target, "capture_target": capture_target,
"page_metrics": receipt.get("page_metrics") or {}, "page_metrics": page_metrics,
"tile_plan": tile_plan, "tile_plan": tile_plan,
"files": file_statuses, "files": file_statuses,
}, },
"next_machine_action": ( "next_machine_action": (
"keep_pixelrag_visual_evidence_receipt" "keep_pixelrag_visual_evidence_receipt"
if status == "ok" if status == "ok"
else "run_pixelrag_visual_capture_worker" else (
"run_platform_probe_or_use_structured_api"
if barrier_reason
else "run_pixelrag_visual_capture_worker"
)
), ),
"controlled_apply": { "controlled_apply": {
"network_call": False, "network_call": False,
@@ -680,6 +1100,9 @@ def build_pixelrag_visual_evidence_readback(
__all__ = [ __all__ = [
"CRAWLER_FAILURE_ACTION_POLICY",
"ECOMMERCE_EXPANSION_POLICY",
"ECOMMERCE_PLATFORM_PROFILES",
"MANIFEST_POLICY", "MANIFEST_POLICY",
"POLICY", "POLICY",
"READBACK_POLICY", "READBACK_POLICY",
@@ -688,7 +1111,11 @@ __all__ = [
"RESULT_MANIFEST_READY", "RESULT_MANIFEST_READY",
"RESULT_MANIFEST_REJECTED", "RESULT_MANIFEST_REJECTED",
"build_pixelrag_crawler_integration_assessment", "build_pixelrag_crawler_integration_assessment",
"build_pixelrag_crawler_failure_action",
"build_pixelrag_ecommerce_expansion_plan",
"build_pixelrag_marketplace_search_manifest",
"build_pixelrag_visual_evidence_readback", "build_pixelrag_visual_evidence_readback",
"build_pixelrag_visual_evidence_manifest", "build_pixelrag_visual_evidence_manifest",
"persist_pixelrag_visual_evidence_manifest",
"should_emit_visual_evidence_fallback", "should_emit_visual_evidence_fallback",
] ]

View File

@@ -69,6 +69,32 @@ def test_pchome_search_scans_multiple_pages_until_limit(monkeypatch):
assert [product.product_id for product in products] == ["A001", "A002", "A003"] assert [product.product_id for product in products] == ["A001", "A002", "A003"]
def test_pchome_search_auto_queues_pixelrag_manifest_on_empty_result(monkeypatch, tmp_path):
from services import pixelrag_crawler_integration_service as pixelrag
from services.pchome_crawler import PChomeCrawler
monkeypatch.setattr(pixelrag, "DEFAULT_MANIFEST_QUEUE_ROOT", str(tmp_path))
crawler = PChomeCrawler(timeout=1, delay=0, max_retries=0)
class FakeSession:
headers = {}
def get(self, url, params=None, timeout=None):
return _FakeResponse({"Prods": []})
crawler.session = FakeSession()
success, message, products = crawler.search_products("找不到的商品", limit=3, max_pages=1)
assert success is False
assert message == "沒有找到符合的商品"
assert products == []
action = crawler.last_visual_evidence_action
assert action["status"] == "manifest_persisted"
assert action["manifest"]["capture_target"]["crawler"] == "PChomeCrawler.search_products"
assert action["persistence"]["path"].startswith(str(tmp_path))
def test_pchome_get_retries_transient_timeout(): def test_pchome_get_retries_transient_timeout():
from services.pchome_crawler import PChomeCrawler from services.pchome_crawler import PChomeCrawler

View File

@@ -124,6 +124,67 @@ def test_visual_fallback_selector_routes_parser_empty_and_low_confidence_cases()
assert clean_parser["fallback_reason"] == "structured_evidence_sufficient" assert clean_parser["fallback_reason"] == "structured_evidence_sufficient"
def test_pixelrag_ecommerce_expansion_plan_includes_shopee_and_coupang():
from services.pixelrag_crawler_integration_service import (
ECOMMERCE_EXPANSION_POLICY,
build_pixelrag_ecommerce_expansion_plan,
)
payload = build_pixelrag_ecommerce_expansion_plan()
platforms = {item["platform"]: item for item in payload["platforms"]}
assert payload["policy"] == ECOMMERCE_EXPANSION_POLICY
assert payload["status"] == "marketplace_profiles_ready"
assert platforms["shopee_tw"]["manifest_ready"] is True
assert platforms["coupang_tw"]["manifest_ready"] is True
assert "price" in platforms["shopee_tw"]["data_targets"]
assert payload["next_actions"][0]["target_platforms"] == ["shopee_tw", "coupang_tw"]
assert payload["controlled_apply"]["production_price_write"] is False
def test_marketplace_search_manifest_builds_shopee_visual_capture_target():
from services.pixelrag_crawler_integration_service import build_pixelrag_marketplace_search_manifest
payload = build_pixelrag_marketplace_search_manifest(
platform="shopee_tw",
keyword="防曬乳",
page_size={"width": 1440, "height": 1024},
tile_size={"width": 512, "height": 512},
)
assert payload["success"] is True
assert payload["capture_target"]["platform"] == "shopee_tw"
assert payload["capture_target"]["url"].startswith("https://shopee.tw/search?keyword=")
assert "%E9%98%B2%E6%9B%AC%E4%B9%B3" in payload["capture_target"]["url"]
assert payload["marketplace_profile"]["display_name"] == "Shopee Taiwan"
assert payload["tile_plan"]["tile_count"] == 6
def test_crawler_failure_action_persists_manifest_queue(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_crawler_failure_action
payload = build_pixelrag_crawler_failure_action(
url="https://24h.pchome.com.tw/search/?q=test",
platform="pchome",
crawler="PChomeCrawler.search_products",
parser_success=False,
parsed_item_count=0,
missing_fields=["price", "product_id"],
failure_reason="search API returned no product IDs",
persist_manifest=True,
queue_root=tmp_path,
)
assert payload["success"] is True
assert payload["status"] == "manifest_persisted"
assert payload["next_machine_action"] == "run_pixelrag_visual_capture_worker"
assert payload["controlled_apply"]["artifact_write"] is True
manifest_path = payload["persistence"]["path"]
queued = json.loads(open(manifest_path, encoding="utf-8").read())
assert queued["capture_target"]["platform"] == "pchome"
assert queued["queue_policy"] == "read_only_pixelrag_crawler_failure_action_v1"
def test_visual_evidence_manifest_builds_tile_plan_without_runtime_writes(): def test_visual_evidence_manifest_builds_tile_plan_without_runtime_writes():
from services.pixelrag_crawler_integration_service import ( from services.pixelrag_crawler_integration_service import (
RESULT_MANIFEST_READY, RESULT_MANIFEST_READY,
@@ -226,6 +287,17 @@ def test_pixelrag_ai_automation_route_returns_assessment_and_manifest():
): ):
manifest_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__() manifest_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
manifest = manifest_response.get_json() manifest = manifest_response.get_json()
with app.test_request_context(
"/api/ai-automation/pixelrag-crawler-integration?expansion_plan=true"
):
expansion_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
expansion = expansion_response.get_json()
with app.test_request_context(
"/api/ai-automation/pixelrag-crawler-integration"
"?platform=shopee_tw&marketplace_keyword=防曬乳"
):
marketplace_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
marketplace = marketplace_response.get_json()
assert assessment["policy"] == "read_only_pixelrag_crawler_integration_assessment_v1" assert assessment["policy"] == "read_only_pixelrag_crawler_integration_assessment_v1"
assert assessment["target_platforms"] == ["momo"] assert assessment["target_platforms"] == ["momo"]
@@ -233,6 +305,10 @@ def test_pixelrag_ai_automation_route_returns_assessment_and_manifest():
assert manifest["policy"] == "read_only_pixelrag_visual_evidence_manifest_v1" assert manifest["policy"] == "read_only_pixelrag_visual_evidence_manifest_v1"
assert manifest["result"] == "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY" assert manifest["result"] == "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
assert manifest["capture_target"]["platform"] == "pchome" assert manifest["capture_target"]["platform"] == "pchome"
assert expansion["policy"] == "read_only_pixelrag_ecommerce_expansion_plan_v1"
assert expansion["next_actions"][0]["target_platforms"] == ["shopee_tw", "coupang_tw"]
assert marketplace["capture_target"]["platform"] == "shopee_tw"
assert marketplace["marketplace_profile"]["keyword"] == "防曬乳"
def _write_capture_receipt(root, *, platform="pchome", manifest_id="manifest-1"): def _write_capture_receipt(root, *, platform="pchome", manifest_id="manifest-1"):
@@ -303,6 +379,30 @@ def test_pixelrag_visual_evidence_readback_reports_latest_receipt(tmp_path):
assert payload["controlled_apply"]["db_write"] is False assert payload["controlled_apply"]["db_write"] is False
def test_pixelrag_visual_evidence_readback_warns_on_access_denied_page(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback
receipt_path = _write_capture_receipt(tmp_path, platform="coupang_tw", manifest_id="denied")
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
receipt["http_status"] = 403
receipt["page_metrics"] = {
"final_url": "https://www.tw.coupang.com/search?q=iphone",
"title": "Access Denied",
}
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
payload = build_pixelrag_visual_evidence_readback(
artifact_root=tmp_path,
platform="coupang_tw",
manifest_id="denied",
)
assert payload["status"] == "warning"
assert payload["summary"]["visual_barrier_detected"] is True
assert payload["summary"]["visual_barrier_reason"] == "http_status_403"
assert payload["next_machine_action"] == "run_platform_probe_or_use_structured_api"
def test_pixelrag_visual_evidence_readback_warns_when_missing(tmp_path): def test_pixelrag_visual_evidence_readback_warns_when_missing(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback
@@ -467,3 +567,34 @@ def test_pixelrag_python_capture_worker_dry_run_plans_artifact_outputs(tmp_path)
assert payload["controlled_apply"]["artifact_write"] is False assert payload["controlled_apply"]["artifact_write"] is False
assert payload["tile_plan"]["planned_tile_count"] == 4 assert payload["tile_plan"]["planned_tile_count"] == 4
assert payload["capture_target"]["platform"] == "pchome" assert payload["capture_target"]["platform"] == "pchome"
def test_pixelrag_python_capture_worker_accepts_shopee_manifest(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_marketplace_search_manifest
manifest = build_pixelrag_marketplace_search_manifest(
platform="shopee_tw",
keyword="防曬乳",
page_size={"width": 1024, "height": 1024},
tile_size={"width": 512, "height": 512},
)
completed = subprocess.run(
[
sys.executable,
"scripts/ops/capture_pixelrag_visual_evidence.py",
"--manifest-json",
json.dumps(manifest),
"--output-dir",
str(tmp_path),
"--dry-run",
],
capture_output=True,
check=False,
text=True,
)
assert completed.returncode == 0
payload = json.loads(completed.stdout)
assert payload["status"] == "dry_run_ready"
assert payload["capture_target"]["platform"] == "shopee_tw"
assert payload["tile_plan"]["planned_tile_count"] == 4