# Fetch 路由

`Fetch` 如何在 headless 與有頭瀏覽器之間選擇，以及唯一會嘗試兩次的情況。

## 決策流程

```mermaid
graph TB
    A[Fetch] --> B{Option.Headless}
    B -->|true| C[headless，不重試]
    B -->|false| D{session domain 且有 GUI}
    D -->|是| E[headed，不重試]
    D -->|否| F[headless 首發]
    F --> G{403 / 429 / 503}
    G -->|否| H[回傳結果]
    G -->|是| I{有 GUI}
    I -->|否| H
    I -->|是| J[headed 重試一次]
```

| 分支 | 條件 | 嘗試次數 |
|---|---|---|
| 強制 headless | `Option.Headless` 為 true | 1 次，headless |
| Session 網域 | 主機在 session 清單內且有 GUI | 1 次，有頭 |
| 預設 | 其他情況 | 先 headless，僅被擋時以有頭重試一次 |

## 什麼算被擋

只有三個狀態碼：`403`、`429`、`503`，來源可以是 `*Error` 也可以是 `Result.Status`。`404`、`204`、`no article extracted` 都**不**觸發重試——那些是頁面不存在或萃取失敗，換一種瀏覽器模式不會改變結果。非 `*Error` 的錯誤（啟動失敗、逾時）直接回傳，不重試。

`just a moment` 這類驗證頁標題會在此判斷前先轉成 `403`，Cloudflare 類型的中介頁因此能進入有頭重試。

## Session 網域

headless 下鮮少能渲染出有效內容的社群網站，在有 GUI 時直接走有頭瀏覽器。清單內嵌於 `core/embed/session_domains.json`，主機等於清單項目或以 `.` 加項目結尾即命中：

`facebook.com`、`fb.com`、`fb.watch`、`twitter.com`、`x.com`、`instagram.com`、`linkedin.com`、`threads.net`、`tiktok.com`、`pinterest.com`、`snapchat.com`、`weibo.com`、`weibo.cn`、`xiaohongshu.com`、`xhs.link`、`douyin.com`、`bilibili.com`、`zhihu.com`、`discord.com`、`telegram.org`、`t.me`、`vk.com`、`quora.com`

沒有 GUI 時這些主機走預設分支。

## GUI 判定

`hasDisplay()` 在 macOS 恆為真；Linux 上 `DISPLAY` 或 `WAYLAND_DISPLAY` 非空即為真。容器與無桌面的伺服器因此永遠不會嘗試啟動有頭瀏覽器。有頭視窗放在 `-32000,-32000`，位於螢幕外。
