# Cơ chế lấy thông tin sản phẩm Shopee — tham chiếu dự án EcomereFlow / ExtensionShopee

> **Nguồn:** đọc từ dự án `J:\Development\EcomereFlow Project\ExtensionShopee`
> (WXT + Vue 3 + TypeScript, Chrome MV3). Đây là **tài liệu tham chiếu** mô tả cách một
> extension khác lấy dữ liệu sản phẩm trên các trang buyer Shopee toàn cầu. Cách tiếp cận
> của dự án đó khác với AutoShopee Kit hiện tại (xem đối chiếu ở §9) — ghi lại để tham khảo
> khi cần chọn hướng cho các tính năng lấy dữ liệu.
>
> Liên quan trong AutoShopee Kit:
> - [`fetch-data-flow.md`](./fetch-data-flow.md) — cơ chế của AutoShopee Kit (webRequest replay + bridge)
> - [`fetch-product-flow.md`](./fetch-product-flow.md) — flow `get_pc` của AutoShopee Kit
> - [`ecomflow-shopee-capture-overview.md`](./ecomflow-shopee-capture-overview.md) — bản tóm tắt ngắn

---

## 1. Ý tưởng cốt lõi: nghe thụ động, không tự gọi API

Nguyên tắc kiến trúc bắt buộc của dự án đó: **KHÔNG** parse SSR, **KHÔNG** tự gọi
`/api/v4/item/get`, **KHÔNG** tự fetch `/api/v4/pdp/get_pc`. Extension chỉ **lắng nghe**
đúng request mà **chính trang Shopee** phát ra khi user xem trang chi tiết sản phẩm, rồi
clone response body.

Lý do chọn hướng passive-listener:

- **Đủ field & đồng bộ tuyệt đối** — dùng chung nguồn dữ liệu mà Shopee UI đang render, nên
  luôn có `price`, `shop`, `attributes`, `tier_variations`, `models`… khớp đúng những gì user thấy.
- **Tránh bot-detection / CAPTCHA / rate-limit** — không phát sinh request "lạ". Request gốc
  đã được Shopee tự xác thực bằng cookie + fingerprint + các token anti-bot
  (`af-ac-enc-sz-token`…), extension không cần dựng lại header nhạy cảm.
- **Chạy đồng nhất cho cả 8 quốc gia** mà không phụ thuộc SSR (mfe-initial-data chỉ có ở VN).

Endpoint duy nhất được nghe: **`/api/v4/pdp/get_pc`** (hằng số `PDP_TARGET_PATH` /
`SHOPEE_PDP_ENDPOINT`).

---

## 2. Vì sao không dùng `webRequest` thuần

Trên Chrome MV3:

- `webRequest.onCompleted` **không** expose response body.
- `declarativeNetRequest` chỉ **rewrite/block** header, không đọc được body.
- `chrome.debugger` phải xin quyền mỗi phiên và hiện cảnh báo — UX kém.

→ Giải pháp: một **content script chạy trong MAIN world** monkey-patch `window.fetch` và
`XMLHttpRequest.prototype.open/send` của chính trang Shopee. Khi code Shopee gọi endpoint,
hook clone response và forward về extension. Đây vẫn là "listener" — extension không chủ
động gọi API.

`webRequest` vẫn được dùng nhưng chỉ để lấy **tín hiệu thời điểm** (timing), không đọc body —
xem §5.

---

## 3. Pipeline tổng thể

```
┌──────────────────────────────┐  fetch(/api/v4/pdp/get_pc)   ┌──────────────────┐
│ Trang Shopee (MAIN world)    │ ───────────────────────────► │ Shopee backend   │
│  pdp-hook-main:              │ ◄──── response JSON ──────    │                  │
│  patched fetch + XHR clone   │                               └──────────────────┘
└──────────────┬───────────────┘
               │ window.postMessage({ source:'ecf-pdp-hook', url, capturedAt, body })
               ▼
┌──────────────────────────────┐  sendMessage('PRODUCT_DATA_CAPTURED', …)
│ pdp-hook-bridge (ISOLATED)   │ ────────────────────────────► ┌────────────────────┐
│ lắng nghe message, lọc,      │                               │ Background SW      │
│ parse URL → shopId/itemId    │                               │  productCache Map  │
└──────────────────────────────┘                               │  key shopId/itemId │
                                                               │  → { data, ts }    │
        ┌──────────────────────────────────────────────────────┤  TTL 10 phút       │
        │ sendMessage('EXTRACT_PRODUCT', { waitMs })            └────────┬───────────┘
        ▼                                                                │
┌──────────────────────────────┐   response { ok, product, source }     │
│ Sidepanel / Overlay UI       │ ◄──────────────────────────────────────┘
│ (đọc ProductPlatform đã       │        (adapter chạy trong background)
│  chuẩn hóa, không thấy raw)   │
└──────────────────────────────┘
```

Bốn context tham gia, mỗi cái một file entrypoint riêng (thin shell):

| Context | Entrypoint | Logic thật |
|---------|-----------|------------|
| MAIN world hook | `entrypoints/pdp-hook-main.content.ts` (`world: 'MAIN'`, `runAt: 'document_start'`) | `installPdpHookMain()` — `src/platforms/shopee/product/hooks/pdp-hook-main.ts` |
| ISOLATED bridge | `entrypoints/pdp-hook.content.ts` (`runAt: 'document_start'`) | `installPdpHookBridge()` — `.../hooks/pdp-hook-bridge.ts` |
| Content chính | `entrypoints/content.ts` | HTML-tag fallback qua `shopeeHtmlAdapter` |
| Background | `entrypoints/background.ts` → `bootstrapBackground()` | cache + adapter + `EXTRACT_PRODUCT` — `src/app/background/messages.ts` |

---

## 4. Chi tiết từng tầng

### 4.1 MAIN-world hook — bắt response body

`installPdpHookMain()` chạy ở `document_start` trong **MAIN world** (chung JS context với
Shopee, nên patch được `fetch`/`XHR` gốc). Điểm mấu chốt:

- **`fetch`**: gọi hàm gốc, nếu URL khớp `/api/v4/pdp/get_pc` thì `res.clone().json()` rồi
  `post(url, json)`. Không đụng vào response gốc → trang Shopee vẫn nhận đúng dữ liệu.
- **`XMLHttpRequest`**: patch `open` để tag URL cần theo dõi (`__ecfWatch`), patch `send` để
  gắn listener `load`; chỉ đọc khi `status` 2xx và response là text/JSON.
- **Không bao giờ throw vào code Shopee**: mọi thao tác bọc `try/catch`, lỗi thì bỏ qua.
- **Ngụy trang chống phát hiện**: `patchedFetch.toString = () => origFetch.toString()` để
  Shopee kiểm tra `Function.prototype.toString` trên `fetch` vẫn thấy "native".
- MAIN world **không** có `browser.*`/`chrome.*` → cách duy nhất chuyển dữ liệu ra ngoài là
  `window.postMessage`.

Shape message phát ra:

```js
window.postMessage(
  { source: 'ecf-pdp-hook', url, capturedAt: Date.now(), body },
  window.origin,
);
```

### 4.2 ISOLATED bridge — chuyển tiếp về background

`installPdpHookBridge()` chạy ở ISOLATED world (có `browser.*`). Nó:

1. Nghe `window.addEventListener('message')`, chỉ nhận `event.source === window` và
   `msg.source === 'ecf-pdp-hook'`.
2. `parsePdpGetPcUrl(msg.url)` → tách `shopId`, `itemId`, `modelId` từ query string.
3. Lấy `body.data ?? body` (chấp nhận cả envelope `{ data, error }` lẫn `data` trần).
4. `sendMessage('PRODUCT_DATA_CAPTURED', { url, shopId, itemId, modelId, capturedAt, data })`
   lên background.

### 4.3 Background — cache + phục vụ `EXTRACT_PRODUCT`

`src/app/background/messages.ts`:

- **`PRODUCT_DATA_CAPTURED`** → ghi vào `productCache` (Map trong service worker), key
  `${shopId}/${itemId}`, kèm `capturedAt`. Sau đó `notifyWaiters()` (giải phóng promise đang
  chờ) và broadcast `PRODUCT_UPDATED` cho sidepanel/popup đang mở.
- **`EXTRACT_PRODUCT`** (từ UI) → xác định `shopId/itemId` (từ payload hoặc parse URL tab),
  đọc cache (TTL 10 phút). Nếu miss và có `waitMs` thì `waitForEntry()` chờ tối đa 15s.
  - Cache hit → chạy `shopeePdpGetPcAdapter.toUnified()` → trả `{ ok:true, product, source:'hook' }`.
  - Cache miss → gọi content script `EXTRACT_HTML_TAGS` → `{ source:'html' }` (partial).
  - Cả hai fail → `{ ok:false, reason:'no-capture' }`.
- **`INVALIDATE_PRODUCT`** → xóa entry (nút Refresh trong UI).

Cache là **best-effort**: MV3 service worker bị kill sẽ mất cache — chấp nhận, vì hook sẽ bắt
lại ngay khi user còn ở trang, hoặc user bấm Refresh.

### 4.4 HTML-tag fallback — khi hook chưa kịp bắt

Khi panel mount sớm / trang serve từ cache / deep-link lần đầu, hook chưa fire. Background nhờ
content script chạy `shopeeHtmlAdapter` đọc **chỉ các tag cấu trúc**:

| Tag | Map vào |
|-----|---------|
| `<link rel="canonical" href="…-i.{shopId}.{itemId}">` | `shopId`, `itemId`, `url` (qua `parseProductUrl`) |
| `<meta property="og:title">` ← fallback `<title>` | `title` (strip suffix `\| Shopee <Region>`) |
| `<meta property="og:description">` ← fallback `<meta name="description">` | `description` |
| `<meta property="og:image" content="…/file/{fileId}">` | `images[0]` (extract fileId, build lại URL CDN) |
| `<meta property="og:url">` | canonical thay thế |

**Ràng buộc tuyệt đối:** không parse `<script application/ld+json>`, không đọc
`mfe-initial-data`/`__INITIAL_STATE__`, không eval bất kỳ JSON blob nào. Mọi giá trị chạy qua
`decodeHtmlEntities`. Các field không có trong tag (price, rating, variants, shop…) giữ default
trung tính. UI biết đây là bản rút gọn (`source: 'html'`, `fidelity: 'minimal'`) để hiển thị
badge "partial" và chờ `PRODUCT_UPDATED` nâng cấp lên bản đầy đủ.

---

## 5. Vai trò của `webRequest`, `cookies`, `declarativeNetRequest`

Ba permission bổ trợ (đăng ký trong `bootstrapBackground()`):

- **`webRequest`** (`session/web-request.ts`) — nghe `onCompleted` cho URL
  `*://shopee.<tld>/api/v4/pdp/get_pc*` của cả 8 nước. **Chỉ lấy tín hiệu thời điểm**: khi
  Shopee bắn request mới cho cùng sản phẩm → `invalidateProductCache(shopId, itemId)` để cache
  không phục vụ dữ liệu cũ. Không đọc body (MV3 không cho).
- **`cookies`** (`session/cookies.ts`) — đọc `SPC_U`, `SPC_EC`, `SPC_F`, `SPC_ST`, `csrftoken`
  per-domain để biết trạng thái đăng nhập (`loggedIn` = có `SPC_U` khác `-`). Theo dõi
  `cookies.onChanged` → broadcast `AUTH_CHANGED`.
- **`declarativeNetRequest`** (`session/dnr.ts`) — cài **session rules** rewrite `Referer`,
  `Origin`, `X-Requested-With`, `X-Shopee-Language` cho request `||{domain}/api/`, nhưng
  **chỉ áp cho request do chính extension khởi tạo** (`initiatorDomains: [runtime.id]`), không
  đụng XHR của tab Shopee. Đây là hạ tầng dự phòng cho fetch chủ động (nếu về sau cần), không
  phải đường lấy dữ liệu chính.

---

## 6. Chuẩn hóa dữ liệu — `shopeePdpGetPcAdapter` → `ProductPlatform`

Background **không bao giờ** đẩy raw payload vào UI. Nó chạy adapter
(`src/platforms/shopee/adapter.ts`) để ra shape platform-agnostic `ProductPlatform`
(`src/modules/product/types.ts`, schema v1.0.0). Nhờ vậy UI/storage/API không thấy key riêng
Shopee (`shop_id`, `modelid`, `tier_index`…), và khi thêm Lazada/TikTok chỉ cần viết adapter mới.

Shape response `/api/v4/pdp/get_pc` và mapping chính:

| `ProductPlatform` | Nguồn Shopee | Ghi chú |
|-------------------|--------------|---------|
| `title`, `description`, `brand` | `data.item` | |
| `pricing.current/original/min/max` | `data.product_price.price.*`, `price_before_discount.*` | **chia 10⁵** (micro → major unit) qua `toMoney()` |
| `pricing.discountPercent` | `data.product_price.discount` | |
| `inventory.stock`, `soldTotal` | `data.item.stock`, `historical_sold`/`global_sold` | |
| `media.images` | `data.product_images.images[]` (hash) | `buildImageUrl(fileId, size, countryCode)` |
| `media.videos` | `product_images.video` + `gallery_contents[].video` | chọn format qua `pickVideoFormat` (HD `V720P`, SD `V540P/V480P`) |
| `variantAxes` | `item.tier_variations[]` (+ `first_tier_variations[].gallery_image`) | |
| `variants` | `item.models[]` + `extinfo.tier_index` | map option theo `tier_index` |
| `seller` | `data.shop_detailed` | badges: `shopee_verified`, `preferred_plus`, `official_shop` |
| `reviews.ratingDistribution` | `product_review.rating_count[]` | đảo về order chuẩn `[5★,4★,3★,2★,1★]` |
| `engagement` | `product_review.liked_count`, `cmt_count` | |

`source.dataSource`: `'api_hook'` (fidelity `full`) khi từ hook, `'html_tags'` (fidelity
`minimal`) khi từ fallback.

---

## 7. Parse URL sản phẩm (đồng nhất 8 quốc gia)

`parseProductUrl()` (`product/normalize.ts`) làm việc trên `location.pathname` nên **không**
bind vào TLD nào:

| Dạng URL | Regex | Output |
|----------|-------|--------|
| SEO (slug) | `/-i\.(\d+)\.(\d+)(?:\/\|$)/` | `shopId`, `itemId` |
| Short | `^/product/(\d+)/(\d+)` | `shopId`, `itemId` |
| Query `extraParams` (JSON) | decode | `display_model_id`, `model_selection_logic` |

`parsePdpGetPcUrl()` tách `shop_id`, `item_id`, `display_model_id` từ query string của chính
URL API.

---

## 8. Phủ 8 quốc gia Shopee

Nguồn chuẩn: `SHOPEE_COUNTRIES` + `SELLER_DOMAIN_MAP` (`config/countries.ts`). Domain patterns
dùng chung từ `config/patterns.ts` (`SHOPEE_BUYER_MATCHES` / `SHOPEE_SELLER_MATCHES`) — cấm lặp
mảng inline, cấm regex `/shopee\./` trong logic nghiệp vụ; luôn dùng
`detectCountryFromDomain(hostname)`.

| Country | Buyer domain | Seller domain | CDN ảnh | Currency |
|---------|--------------|---------------|---------|----------|
| VN | `shopee.vn` | `banhang.shopee.vn` | `down-vn.img.susercontent.com` | VND |
| TH | `shopee.co.th` | `seller.shopee.co.th` | `down-th…` | THB |
| SG | `shopee.sg` | `seller.shopee.sg` | `down-sg…` | SGD |
| MY | `shopee.com.my` | `seller.shopee.com.my` | `down-sg…` | MYR |
| ID | `shopee.co.id` | `seller.shopee.co.id` | `down-id…` | IDR |
| PH | `shopee.ph` | `seller.shopee.ph` | `down-ph…` | PHP |
| TW | `shopee.tw` | `seller.shopee.tw` | `down-tw…` | TWD |
| BR | `shopee.com.br` | `seller.shopee.com.br` | `down-br…` | BRL |

**CDN**: `fileId` đã bao prefix quốc gia (`vn-…`, `th-…`), và `*.susercontent.com` là global —
một host `down-vn` cũng serve được file mọi nước; map theo region chỉ để tối ưu latency. Size
suffix: full (không suffix), `@resize_w900_nl`, `@resize_w450_nl`, `_tn` (thumb). Video host:
`*.vod.susercontent.com`.

`host_permissions` (`wxt.config.ts`) khai đủ 8 buyer + 8 seller + `*.shopee.com` +
`webchat.shopee.com` + `*.susercontent.com`.

---

## 9. Đối chiếu với cách của AutoShopee Kit

Hai dự án giải cùng bài toán "content script isolated world không có cookie/session Shopee"
theo hai hướng khác nhau:

| | **ExtensionShopee (EcomereFlow)** | **AutoShopee Kit** (xem [`fetch-data-flow.md`](./fetch-data-flow.md)) |
|--|-----------------------------------|----------------------------------------------------------------------|
| Triết lý | **Passive** — chỉ nghe request Shopee tự phát | **Active replay** — tự phát lại request với headers thật đã bắt |
| Cơ chế chính | MAIN-world monkey-patch `fetch`/`XHR`, clone body | `webRequest.onBeforeSendHeaders` bắt headers → replay + main-world `postMessage` fetch |
| Endpoint | Chỉ `/api/v4/pdp/get_pc` (1 sản phẩm đang xem) | Nhiều endpoint (get_pc, search, category, shop…) — chủ động gọi |
| Anti-bot | Né hoàn toàn (không có request lạ) | Phải bắt & replay `af-ac-enc-*`, csrftoken để không bị chặn |
| Phạm vi | Sản phẩm user đang mở | Lấy hàng loạt (search/category/shop spy) |

**Rút ra**: hướng passive rất hợp khi chỉ cần dữ liệu **sản phẩm đang xem** (an toàn, đồng bộ
tuyệt đối, không lo anti-bot). Nhưng nó **không** lấy được dữ liệu hàng loạt / trang không do
user mở — đó là lúc phải dùng cơ chế active-replay của AutoShopee Kit.

---

## 10. Bản đồ file (ExtensionShopee)

| File | Vai trò |
|------|---------|
| `entrypoints/pdp-hook-main.content.ts` | Thin shell — MAIN world, gọi `installPdpHookMain()` |
| `entrypoints/pdp-hook.content.ts` | Thin shell — ISOLATED bridge, gọi `installPdpHookBridge()` |
| `entrypoints/content.ts` | Content chính, handler `EXTRACT_HTML_TAGS` |
| `src/platforms/shopee/product/hooks/pdp-hook-main.ts` | Patch `fetch` + `XHR`, `window.postMessage` |
| `src/platforms/shopee/product/hooks/pdp-hook-bridge.ts` | Lọc message, parse URL, `PRODUCT_DATA_CAPTURED` |
| `src/platforms/shopee/product/normalize.ts` | `parseProductUrl`, `parsePdpGetPcUrl`, `buildImageUrl`, `pickVideoFormat` |
| `src/platforms/shopee/adapter.ts` | `shopeePdpGetPcAdapter`, `shopeeHtmlAdapter` → `ProductPlatform` |
| `src/platforms/shopee/config/{countries,patterns,constants}.ts` | 8 nước, match patterns, CDN/endpoint |
| `src/platforms/shopee/session/{web-request,cookies,dnr}.ts` | Timing signal, login state, header rewrite |
| `src/app/background/messages.ts` | `productCache`, `EXTRACT_PRODUCT`, `INVALIDATE_PRODUCT` |
| `src/app/background/index.ts` | `bootstrapBackground()` đăng ký tất cả listener |

> Xem thêm doc gốc trong dự án đó: `ExtensionShopee/docs/shopee/product-info-panel.md`.
