"""剪貼簿批次匯入解析器 — Gemma 4 E4B(text / html / 螢幕截圖三模式) 【這個模組在做什麼?】 使用者從 Grailed / Depop / Poshmark / eBay / Vinted 自己的 profile 頁複製內容, 丟給 Gemma 4 後產出多筆結構化商品 JSON,作為 bulk import 的預覽資料。 【為什麼不寫 per-site 爬蟲?】 - SPA rendering 不能直接 HTTP 抓 - 自家 profile 需要 auth(使用者自己複製就有 auth) - Cloudflare / anti-bot 免了 - 新平台上線等於 0 code 任何商品 URL 的單品匯入已由 `apps/scraper-service/src/universal/` 的 JSON-LD / OG / microdata extractor 處理;paste_parser 補的是「多筆一起拉進來」這條路。 【三種輸入模式】 text — 原始文字 copy-paste(最簡單,但可能掉 image URL) html — F12 → Copy outerHTML(最優,保留 product 和 image URL) image_base64 — 螢幕截圖(Gemma 4 vision OCR,~10-15s on M-series) 【輸出策略】 Gemma 會幻覺 —— 只要 item 缺 `title` 一律丟掉。image_url / source_url 上游 handler 會再跑一次 SSRF + universal extractor 補欄位,故此處不嚴格驗 URL。 """ from __future__ import annotations import asyncio import base64 import io import json import logging from typing import Literal from pydantic import BaseModel, Field from .gemma_inference import gemma_text, gemma_vision # email_parser._strip_code_fence 是純工具函式,不帶狀態,直接複用 from .email_parser import _strip_code_fence logger = logging.getLogger(__name__) # ── 資料模型 ────────────────────────────────────────────────────────────── class PastedItem(BaseModel): """從剪貼簿解析出的單筆商品。與 hub_items schema 對齊但留彈性。""" title: str brand: str | None = None price: float | None = None currency: str | None = None size: str | None = None # "new" | "like_new" | "good" | "fair" | "used" — 不強制 enum,Gemma 常回自由字串 condition: str | None = None image_url: str | None = None # 商品頁 URL(若 paste 裡有),給 hub-service 餵給 universal extractor 做 enrichment source_url: str | None = None description: str | None = None class PasteParseResult(BaseModel): """Gemma 4 回傳的多筆商品 + 解析 metadata。""" items: list[PastedItem] = Field(default_factory=list) # 若 Gemma 完全無法解析會是 0;handler 端據此決定是否要 fallback dropped: int = 0 # ── Prompt ──────────────────────────────────────────────────────────────── PASTE_INSTRUCTION = ( "You extract second-hand fashion marketplace listings from pasted content. " "The paste comes from the user's OWN profile page on Grailed / Depop / " "Poshmark / eBay / Vinted / other similar sites. " "Return ONLY valid JSON — no prose, no markdown code fences — matching:\n" '{"items": [{"title": string, "brand": string|null, ' '"price": number|null, "currency": string|null, "size": string|null, ' '"condition": string|null, "image_url": string|null, ' '"source_url": string|null, "description": string|null}]}\n' "Rules:\n" "- Extract ONLY real listings you can clearly identify. Drop anything ambiguous.\n" '- Do NOT invent items. If nothing parseable, return {"items": []}.\n' '- price: number only, strip currency symbols (e.g. "$45" → 45).\n' "- Skip any item missing a title.\n" "- image_url / source_url: include verbatim only if present in the paste.\n" "- condition: one of new / like_new / good / fair / used if inferable.\n" ) # ── 主函式 ──────────────────────────────────────────────────────────────── # text / html 不必再跑 OCR,用文字路徑;image_base64 走 vision 路徑。 Mode = Literal["text", "html", "image_base64"] # Paste 的文字上限:避免吃爆 Gemma 4 的 context(128k 名目,但本機 CPU 不會想跑滿) _TEXT_CAP = 12_000 async def parse_paste(mode: Mode, content: str) -> PasteParseResult: """解析 pasted profile,回傳結構化 items 清單。 【流程】 - text / html:`gemma_text("paste_parse", ...)` 直接跑 - image_base64:解 base64 → PIL Image → `gemma_vision("paste_parse", img, ...)` """ if mode == "image_base64": # 使用者貼進來的 base64 可能是壞掉的 padding/字元,或解出來根本不是圖片。 # base64.b64decode 會丟 binascii.Error(ValueError 子類),PIL 解非圖片 # bytes 會丟 UnidentifiedImageError/OSError。兩者都是「可復原的壞輸入」, # 比照 _coerce_and_validate 的 fail-soft:回空結果而非讓未捕捉例外冒到 # FastAPI 變成 HTTP 500。 try: img = await asyncio.to_thread(_decode_image_base64, content) except (ValueError, OSError) as exc: logger.warning( "paste image_base64 decode failed: %s", type(exc).__name__ ) return PasteParseResult(items=[], dropped=0) # 螢幕截圖 → vision 模式,prompt 給 context + instruction raw = await gemma_vision( "paste_parse", img, PASTE_INSTRUCTION + "\nThe image is a screenshot of the user's profile page.", max_tokens=4096, ) else: snippet = content[:_TEXT_CAP] label = "HTML source" if mode == "html" else "Pasted text" prompt = f"{PASTE_INSTRUCTION}\n\n{label}:\n{snippet}" raw = await gemma_text("paste_parse", prompt, max_tokens=4096) return _coerce_and_validate(raw) # PIL decompression bomb 上限 — Pillow 預設 178 MP(89 MB uncompressed),攻擊者 # 可用 4KB 壓縮好的 PNG 宣告 10000×10000 把我們的 ml-service 吃到 OOM。 # 40 MP(~6300×6300 = 120 MB RGB)對螢幕截圖足夠,且不會把 Gemma 4 vision # tower 的 input tensor 撐爆。一旦超過 Pillow 直接 raise DecompressionBombError。 _MAX_PASTE_IMAGE_PIXELS = 40_000_000 def _decode_image_base64(content: str): # -> PIL.Image.Image """解 base64 → PIL.Image。在 to_thread 內執行(PIL open 是 blocking I/O)。 支援: - 純 base64 字串 "iVBORw0KGgo..." - data URI "data:image/png;base64,iVBORw0KGgo..." 【decompression bomb 防線】 `Image.MAX_IMAGE_PIXELS` 是 class attribute,設在函式內部每次 call 會覆寫 一次(幾乎 no-op 開銷),避免外部程式碼把它 reset 成 None 的順序依賴問題。 """ # 延遲 import PIL 避免提高 ml-service 啟動延遲 from PIL import Image Image.MAX_IMAGE_PIXELS = _MAX_PASTE_IMAGE_PIXELS payload = content.split(",", 1)[-1] if content.startswith("data:") else content img_bytes = base64.b64decode(payload) # Image.open() 是 lazy——真正 decode 在 .convert();兩段合起來才會觸發 # DecompressionBombError(若聲明尺寸 × 3 channels 超過 MAX_IMAGE_PIXELS)。 img = Image.open(io.BytesIO(img_bytes)).convert("RGB") return img def _coerce_and_validate(raw: str) -> PasteParseResult: """剝 code fence + parse JSON + Pydantic 驗證 + 丟掉無 title 的 item。""" cleaned = _strip_code_fence(raw.strip()) try: data = json.loads(cleaned) except json.JSONDecodeError as e: logger.warning( "paste_parse: JSON decode failed (%s); raw head=%r", e, raw[:300] ) return PasteParseResult(items=[], dropped=0) # Gemma 有時會包一個 `{"output": {"items": [...]}}` 的額外層 — 攤平 if ( isinstance(data, dict) and "items" not in data and isinstance(data.get("output"), dict) ): data = data["output"] try: parsed = PasteParseResult.model_validate(data) except Exception as e: logger.warning( "paste_parse: Pydantic validation failed (%s); data head=%r", e, str(data)[:300], ) return PasteParseResult(items=[], dropped=0) total = len(parsed.items) parsed.items = [i for i in parsed.items if i.title and i.title.strip()] parsed.dropped = total - len(parsed.items) return parsed