# Cơ chế lấy dữ liệu Shopee qua Bridge & Listener

> Tài liệu này giải thích **cách extension lấy dữ liệu từ API Shopee** thông qua hai
> cơ chế cốt lõi: **webRequest listener** (bắt headers thật của trang) và **webext-bridge**
> (truyền message giữa background ↔ content ↔ page). Đây là nền tảng chung cho mọi tính
> năng cần data Shopee: spy sản phẩm, search spy, clone, lấy tồn kho.
>
> Liên quan:
> - [`fetch-product-flow.md`](./fetch-product-flow.md) — flow chi tiết `get_pc` (1 sản phẩm)
> - [`spy.md`](./spy.md) — tính năng nghiên cứu thị trường
> - [`../dev/extension/bridge.md`](../dev/extension/bridge.md) — danh mục webext-bridge handlers

---

## 1. Vì sao cần cơ chế này?

Content script chạy trong **isolated world** — không chia sẻ JS context với trang Shopee.
Khi content script gọi `fetch()` trực tiếp tới API Shopee, request **thiếu** những thứ Shopee
bắt buộc để không bị coi là bot:

1. **Cookie / session** của Shopee (authentication).
2. **Same-origin context** — bị CORS chặn.
3. **Headers anti-bot** — `af-ac-enc-dat`, `af-ac-enc-sz-token`, csrftoken, session token.

Extension giải quyết bằng **hai hướng bổ trợ nhau**:

| Cơ chế | Bắt cái gì | Dùng để |
|--------|------------|---------|
| **webRequest listener** (background) | **Headers thật** của request Shopee frontend | Replay lại request với headers y hệt trình duyệt |
| **webext-bridge + postMessage** | Kênh truyền message | Đẩy fetch vào **main world** của trang (tự có cookie/session) |

Nói ngắn gọn: **listener** cung cấp *nguyên liệu* (headers thật đã bắt được), **bridge**
cung cấp *đường ống* để đưa fetch vào đúng context có quyền gọi API.

---

## 2. Toàn cảnh 3 tầng

```
  ┌─────────────────────────────────────────────────────────────────────┐
  │ BACKGROUND (service worker)                                          │
  │                                                                      │
  │  webRequest.onBeforeSendHeaders  ──►  bắt headers thật               │
  │        │                                                             │
  │        ▼                                                             │
  │  In-memory store:                                                    │
  │    RequestProductPayload / RequestSearchPayload / RequestShopeeClient│
  │                                                                      │
  │  onMessage(EVENT_*)  ◄──────────── nhận request từ content/popup     │
  └───────────────────────────▲─────────────────────────────────────────┘
                              │ webext-bridge (sendMessage / onMessage)
  ┌───────────────────────────┴─────────────────────────────────────────┐
  │ CONTENT SCRIPT (isolated world)                                      │
  │                                                                      │
  │  bridgeShopeeGetHeaderProduct('search') ──► xin headers đã bắt       │
  │  PostMessageFetchUrl / PostMessageFetchListener                      │
  └───────────────────────────▲─────────────────────────────────────────┘
                              │ window.postMessage
  ┌───────────────────────────┴─────────────────────────────────────────┐
  │ PAGE SCRIPT (main world — cùng context Shopee)                       │
  │                                                                      │
  │  fetchUrlService()   ──► fetch thật, tự đính cookie/session          │
  │  listenerFetchData() ──► ghi đè window.fetch để "nghe lén" response  │
  └─────────────────────────────────────────────────────────────────────┘
```

---

## 3. Tầng LISTENER — bắt headers thật ở background

### 3.1 Nơi đăng ký

**File**: `src/background/bridge/listener/index.js` — hàm `listenerService()`, được gọi từ
`src/background/index.js`. Dùng `browserWebrequest.onBeforeSendHeaders` (wrap của
`browser.webRequest`).

Mỗi listener khớp một **URL pattern** (định nghĩa ở `src/app/constant/shopee.js`) và gọi
handler tương ứng ở `src/background/bridge/listener/shopee/product.js`:

| URL pattern (`SHOPEE_URLS`) | Giá trị | Handler | Lưu vào store |
|---|---|---|---|
| `PRODUCT_GET_PC` | `https://shopee.vn/api/v4/pdp/get_pc*` | `onFetchProductId` | `RequestProductPayload` |
| `SEARCH_SEARCH_ITEMS` | `https://shopee.vn/api/v4/search/search_items*` | `onFetchSearchProduct` | `RequestSearchPayload` |
| `CLIENT_GLOBAL` | `https://shopee.vn/api*` | `onFetchClientAll` | `RequestShopeeClient` |
| `SELLER_ORDER_PRINT_PDF` | `.../download_sd_job*` | `onSellerPrintOrderShopee` | (in đơn) |

> Block chat webchat (`onBeforeRequest` + `CHAT_WEBCHAT`) hiện **tắt cứng** bằng
> `if (false)` tại `listener/index.js:51` — không active.

### 3.2 Handler làm gì

Ví dụ `onFetchSearchProduct` (`src/background/bridge/listener/shopee/product.js:40`):

```javascript
export const onFetchSearchProduct = async (payload) => {
  const { query, headers, url, isIgnore } = parseRequest(payload);

  if (isIgnore || !query.by) {
    return false;
  }

  RequestSearchPayload.pushData({
    uuid: genUuid(),
    headers: {
      [SHOPEE_HEADER_KEYS.IGNORE]: 'yes',   // 'af-ac-ignore'
      ...headers,
    },
    url,
    query,
    time: moment().unix(),
  });

  return true;
};
```

Các bước:

1. **`parseRequest(payload)`** (`listener/utils.js`) — chuyển raw request của webRequest thành
   `{ url, headers, query, method, isIgnore }`. `headers` là object gom từ `requestHeaders`,
   `query` parse từ URL, `isIgnore` = có header `af-ac-ignore` hay không.
2. **Lọc**:
   - `onFetchProductId` chỉ nhận khi có `query.item_id`, `query.shop_id` và header
     `af-ac-enc-dat` (`SHOPEE_HEADER_KEYS.GET`).
   - `onFetchSearchProduct` chỉ nhận request **có `query.by`** (đảm bảo là request search thật,
     không phải suggest/hint).
3. **Chống loop**: mọi entry lưu kèm header `af-ac-ignore: 'yes'`. Khi extension replay lại
   request với chính headers này, listener thấy `isIgnore = true` → bỏ qua, **không bắt lại
   request của chính mình**.
4. **Push vào store** kèm `uuid` + `time`.

### 3.3 In-memory store

**File**: `src/background/store/index.js` — cả 3 store đều là instance của `GetListPayload`
(mảng đơn giản, có `pushData` / `getList` / `clearList`):

```javascript
export const RequestProductPayload = new GetListPayload();  // headers get_pc
export const RequestSearchPayload  = new GetListPayload();  // headers search_items
export const RequestShopeeClient   = new GetListPayload();  // headers mọi /api*
```

> ⚠️ **Đặc điểm quan trọng**: store nằm **in-memory của service worker** — reload extension là
> mất; và **không tự clear** (`clearList()` có nhưng không nơi nào gọi) → list dồn dài dần.
> Đồng thời store **chỉ có data sau khi trang Shopee đã tự gọi API đó**. Muốn có headers
> search, người dùng phải **thực sự search/scroll trên trang** để Shopee tự bắn
> `search_items` trước đã.

---

## 4. Tầng BRIDGE — content/popup xin lại headers đã bắt

Content script không đọc trực tiếp store background được (khác context). Nó phải hỏi qua
**webext-bridge**.

### 4.1 Định nghĩa event & wrapper

- **Event name**: `EVENT_SHOPEE_GET_HEADER_SHOPEE` (`src/app/bridge/config/event.js`).
- **Wrapper phía content** (`src/app/bridge/content/background.js:99`):

  ```javascript
  export const bridgeShopeeGetHeaderProduct = async (by) =>
    sendMessage(EVENT_SHOPEE_GET_HEADER_SHOPEE, { by });
  ```

- **Handler phía background** (`src/background/bridge/global.js:289`, đăng ký ở dòng 518):

  ```javascript
  const onBridgeShopeeGetHeaderShopee = async ({ data }) => {
    if (data.by === 'product') return RequestProductPayload.getList();
    if (data.by === 'search')  return RequestSearchPayload.getList();
    return [];
  };
  ```

Tham số `by` (`'product'` | `'search'`) chọn store nào để trả về nguyên list đã bắt.

### 4.2 Lọc đúng payload cần dùng

Content nhận về **cả list** rồi tự lọc đúng entry khớp truy vấn hiện tại
(`src/app/api/shopee/getter.js`):

```javascript
export const getRequestProductSearch = async (payload) => {
  const listPayloadSearch = await bridgeShopeeGetHeaderProduct('search');
  const listKeyPair = ['by', 'keyword', 'newest', 'limit'];

  const matchSearch = (item) =>
    item.query && item.query.keyword &&
    listKeyPair.every((key) => payload[key] === item.query[key]);

  const listMatch = listPayloadSearch.filter(matchSearch);

  if (!listMatch.length) {
    return { active: false, uuid: '', headers: {}, url: '' };
  }

  return { active: true, ...listMatch[listMatch.length - 1] };  // lấy entry mới nhất
};
```

Trả về `{ active, headers, url, ... }`:
- `active: true` → đã có headers thật → có thể `fetchJson(url, headers)`.
- `active: false` → chưa bắt được request khớp → **không fetch được theo hướng này**.

`getRequestProductId` (dòng 9) làm tương tự cho `get_pc`, match theo `item_id` + `shop_id`.

---

## 5. Tầng PAGE — đưa fetch vào main world

Có headers rồi vẫn cần **chạy fetch trong main world** để có cookie/session same-origin.
Extension dùng `window.postMessage` qua lớp `PostMessageApi`
(`src/app/bridge/content/postMessage.js`):

| Instance | Channel | Timeout | Mục đích |
|----------|---------|---------|----------|
| `PostMessageFetchUrl` | `ATSP_FETCH_URL_*` | 5000ms | Content nhờ page fetch 1 URL |
| `PostMessageFetchListener` | `ATSP_FETCH_LISTENER_*` | 3000ms | Page đẩy response đã "nghe lén" về content |

### 5.1 Chiều đi — content nhờ page fetch

**Page script** (`src/content/shopee/script/fetch.js`) đăng ký handler:

```javascript
const fetchUrlService = async ({ url, method, body }) => {
  const payloadFetch = {
    method,
    headers: {
      accept: 'application/json',
      'af-ac-enc-dat': null,
      'af-ac-enc-sz-token': '',
      'content-type': 'application/json',
    },
  };
  if (method === 'POST') payloadFetch.body = JSON.stringify(body);

  const res = await fetch(url, payloadFetch);   // chạy trong main world → có cookie
  return res.json();
};

// startService()
PostMessageFetchUrl.startHandler(fetchUrlService);
```

**Content script** (`src/content/service/event.js`) nối bridge background ↔ postMessage page:

```javascript
onMessage(EVENT_TAB_FETCH_URL, async ({ data }) => {
  const fetchJson = await PostMessageFetchUrl.sendMessage(data);  // nhờ page fetch
  return fetchJson;
});
```

### 5.2 Chiều nghe — page "nghe lén" mọi fetch của Shopee

Cũng trong `fetch.js`, `listenerFetchData()` **ghi đè `window.fetch`** để bắt response mà
Shopee frontend tự gọi (không phải extension gọi):

```javascript
const listenerFetchData = (callback) => {
  const { fetch: originalFetch } = window;
  window.fetch = async (...args) => {
    const [url, payload] = args;
    const response = await originalFetch(...args);

    if (url.includes('/api/')) {
      response.clone().json().then((json) => callback({ json, url, payload }));
    }
    return response;   // trả nguyên bản, không can thiệp
  };
};
```

Response bắt được đẩy qua `PostMessageFetchListener` → content
(`PostMessageFetchListener.startHandler(bridgeShopeeListenFetch)`) → background
`onBridgeShopeeListenFetch` (`global.js:185`) qua event `EVENT_SHOPEE_LISTEN_FETCH`.

Ở background, response này dùng để:
- Gom vào `FetchListenerBucketShopee` (nếu bucket đang `active`) để đẩy lên server.
- Nếu URL chứa `select_variation_pc` / `get_pc_vsku` → trích **tồn kho** variant, lưu vào
  `ShopeeProductStock` + cache + KV (xem `fetch-product-flow.md` mục 5).

---

## 6. Ghép lại — ví dụ Search Spy (chiến lược 2 tầng fetch)

Đây là ví dụ điển hình dùng **cả** listener lẫn bridge. Entry point:
`getSearchKeywordProduct()` (`src/app/api/shopee/client/product/index.js:27`):

```
ProductSearchLoader.updateSearchData()          [content/shopee/client/product/search.js]
        │
        ▼
getSearchKeywordProduct(keyword, page, limit, opts)
        │
        │ ── Tầng 1 (PRIMARY): fetch qua page-context inject ────────────┐
        ▼                                                                │
fetchSearchKeywordProduct(payload)                                       │
  → excuteFetchUrl(url)   [api/shopee/fetch.js]                          │
     · inject <script> tự build headers (csrftoken, af-ac-enc-dat:null…) │
     · fetch trong main world, trả kết quả qua 1 <script> DOM element    │
     · có cache CacheFetchUrl                                            │
        │                                                                │
        │ null? (cache lỗi / element không hiện / crawler) ──────────────┘
        ▼
   ── Tầng 2 (FALLBACK): replay headers thật đã bắt ──
getRequestProductSearch(payload)     [api/shopee/getter.js]
  → bridgeShopeeGetHeaderProduct('search')  → RequestSearchPayload (background)
  → { active, headers, url }
        │
        │ active === true?
        ▼
fetchJson(url, headers)              [api/shopee/fetch.js]
        │
        ▼
checkCrawlerDetected(result)  → nếu error 90309999 thì log CRAWLER_DETECT
```

Điểm mấu chốt của thiết kế 2 tầng:

1. **Primary** (`excuteFetchUrl`) không phụ thuộc listener — tự dựng headers và fetch qua
   injected script. Nhanh, chạy được ngay cả khi store chưa có gì.
2. **Fallback** (`getRequestProductSearch` + `fetchJson`) chỉ chạy khi primary trả `null`, và
   **chỉ `active` nếu background đã bắt được request search thật** của trang. Đây là lý do
   fallback đôi khi `active=false`: người dùng chưa search/scroll để Shopee tự gọi
   `search_items`.

---

## 7. Bảng tra nhanh file

### Background (listener + store + bridge handler)

| File | Vai trò |
|------|---------|
| `src/background/bridge/listener/index.js` | Đăng ký các `onBeforeSendHeaders` listener |
| `src/background/bridge/listener/shopee/product.js` | `onFetchProductId` / `onFetchSearchProduct` / `onFetchClientAll` — bắt headers |
| `src/background/bridge/listener/utils.js` | `parseRequest()` — chuẩn hoá raw request |
| `src/background/store/index.js` | `RequestProductPayload` / `RequestSearchPayload` / `RequestShopeeClient` / `ShopeeProductStock` |
| `src/background/bridge/global.js` | `onBridgeShopeeGetHeaderShopee` (trả headers), `onBridgeShopeeListenFetch` (nhận response nghe lén) |

### App / API (consumer phía content)

| File | Vai trò |
|------|---------|
| `src/app/bridge/content/background.js` | `bridgeShopeeGetHeaderProduct()`, `bridgeShopeeListenFetch()` — wrapper sendMessage |
| `src/app/bridge/content/postMessage.js` | `PostMessageFetchUrl`, `PostMessageFetchListener` — cầu isolated ↔ main world |
| `src/app/api/shopee/getter.js` | `getRequestProductId()`, `getRequestProductSearch()`, `checkCrawlerDetected()` |
| `src/app/api/shopee/fetch.js` | `excuteFetchUrl()` (inject page fetch), `fetchJson()`, `fetchHtml()` |
| `src/app/api/shopee/client/product/index.js` | `getSearchKeywordProduct()` — chiến lược 2 tầng |
| `src/app/constant/shopee.js` | `SHOPEE_URLS`, `SHOPEE_HEADER_KEYS` |

### Content / Page script

| File | Vai trò |
|------|---------|
| `src/content/service/event.js` | `listenerFetch()` — nối bridge ↔ postMessage, đăng ký handler |
| `src/content/shopee/script/fetch.js` | Page script (main world): `fetchUrlService()`, `listenerFetchData()` |
| `src/content/shopee/client/product/search.js` | `ProductSearchLoader` — consumer search spy |

---

## 8. Các "gotcha" cần nhớ

- **`af-ac-ignore: 'yes'`** là cơ chế chống loop — extension đánh dấu request nó tự replay để
  listener không bắt lại. **Không được bỏ header này.**
- **Store chỉ có data khi trang đã tự gọi API tương ứng.** Fallback headers-thật `active=false`
  không phải lỗi code — mà là chưa có nguyên liệu. Cần người dùng thao tác trên trang trước.
- **Store in-memory, không tự clear** — mất khi reload extension, và dồn dài theo thời gian
  (chưa có giới hạn size).
- **`excuteFetchUrl` build script bằng string interpolation** (`fetch.js:102`) — URL có ký tự
  đặc biệt (dấu nháy) có thể vỡ script. Đây là điểm nợ kỹ thuật đã ghi trong
  `fetch-product-flow.md` mục 10.
- **Test anti-crawler**: CloakBrowser trả `error: 90309999` (crawler detected) cho
  `search_items` dù code đúng → **false negative**. Ground truth cho các path fetch nhạy cảm là
  **Chrome thật của user** (qua claude-in-chrome MCP), không phải CloakBrowser. Xem CLAUDE.md
  mục "CloakBrowser limitation".
