> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/zh-tw/architecture/ip-data-sources.md).

# IP 資料來源

多個 IP 地理定位來源如何被查詢並正規化為一種標準格式。

兩個問題主導了 MyIP 的大多數功能，而它們由不同的機制來回答：

* **「我的 IP 是什麼？」** — 瀏覽器會直接向數個第三方回應端點查詢。後端不參與。
* **「這個 IP 在哪裡？」** — 瀏覽器會詢問 *我們的* 後端，會查詢一個地理位置提供者（或本機資料庫）並將答案標準化。

刻意將它們分開：回應端點必須看見訪客自己的連線，因此不能經過代理。

```mermaid
flowchart TD
    subgraph 瀏覽器
      G["utils/getips/ — 針對每個來源各執行一次 runChain()"]
      T["utils/transform-ip-data.js"]
      C["IP 卡片"]
    end
    subgraph 後端
      H["api/<br/>ipinfo-io, ipapi-com, ipapi-is,<br/>ip2location-io, ip-sb, ipcheck-ing, maxmind 中的地理位置處理器"]
      M["common/maxmind-service.js — 本機 mmdb"]
    end
    P["第三方回應端點<br/>Cloudflare, IPCheck.ing, IPIP.net, ..."]
    U["地理位置服務提供者"]

    G -->|"直接抓取"| P
    G -->|"IP"| H
    H --> U
    H --> M
    H -->|"標準化 JSON"| T --> C
```

## 步驟 1 — 解析你自己的 IP

`frontend/utils/getips/` 保存每個來源一個小模組。每個模組都匯出一個自我描述的 **provider 物件**:

```js
{ id: 'cloudflare-v4', name: 'Cloudflare IPv4', run: async (originalSite) => '203.0.113.7' }
```

provider 的唯一工作是透過 `fetchWithTimeout` （瀏覽器預設 5 秒）去打單一上游，並解析原始 IP 字串，或者丟出錯誤。它不做驗證，也不知道任何其他 provider。

驗證與回退位於上一層，也就是 `runChain()` 執行器，由 `frontend/utils/getips/index.js`匯出。它會依序遍歷 providers，並回傳第一個答案通過 `isValidIP()` 自 `common/valid-ip.js`，格式為 `{ ip, source }` 其中 `source` 是勝出 provider 的顯示名稱。耗盡的鏈會解析為 `{ ip: null, source }` ，帶著最後一個 provider 的名稱——執行器從不丟出例外。

`frontend/components/IpInfos.vue` 會依索引渲染最多六張卡片，並為每張卡分配一條鏈：

| 卡片 | 主要來源               | 端點                                     | 回退到                              |
| -- | ------------------ | -------------------------------------- | -------------------------------- |
| 0  | IPCheck.ing IPv4   | `4.ipcheck.ing`                        | IPify IPv4（`api4.ipify.org`)     |
| 1  | IPCheck.ing IPv6   | `6.ipcheck.ing`                        | IPify IPv6（`api6.ipify.org`)     |
| 2  | Cloudflare IPv4    | `1.0.0.1/cdn-cgi/trace`                | MyExternalIP IPv4                |
| 3  | Cloudflare IPv6    | `[2606:4700:4700::1111]/cdn-cgi/trace` | MyExternalIP IPv6                |
| 4  | IPIP.net           | `myip.ipip.net/json`                   | Upai（`pubstatic.b0.upaiyun.com`) |
| 5  | IPCheck.ing IPv6/4 | `64.ipcheck.ing`                       | —（JSON，然後同一主機的 `/cdn-cgi/trace`) |

{% hint style="info" %}
這三個 IPCheck.ing 來源都接收一個 `originalSite` 參數。在標準部署上它們讀取 JSON 端點；在其他地方則讀取同一主機的 Cloudflare 風格 `/cdn-cgi/trace` 文字。如果 JSON 呼叫失敗，它們會在放棄前透過 trace 重試。
{% endhint %}

失敗處理是分層的：

1. **在鏈中**，丟出例外的 provider 和回傳垃圾內容的 provider 會被同等對待： `runChain()` 記錄一則 `console.warn` 並前進到下一個 provider。格式錯誤的答案不會結束鏈——只會損失那一跳。如果沒有任何 provider 產生有效 IP，鏈會解析為 `{ ip: null, source }`.
2. 每張卡都運行自己的解析後再取詳情流程，而全部六張都在 `Promise.allSettled`之下執行，所以壞掉的來源不會拖垮整個批次。卡片會在各自完成時獨立呈現。
3. 當某張卡的整條鏈都失敗時，SPA 會在應用總線上發出一個 `ip-source:exhausted` 事件。Sentry（若已設定）只有在同一 IP 版本的另一張卡已成功解析時才會捕捉它——否則「我們的鏈失敗了」與訪客沒有 IPv6 是無法區分的，而這屬於正常雜訊。

個別來源失敗 `console.warn` 是刻意如此設計的，所以它們永遠不會進入錯誤監控；每卡的耗盡事件才是健康訊號。

## 步驟 2 — 將 IP 進行地理定位

一旦卡片拿到 IP，它就會呼叫後端。 `frontend/data/ip-databases.js` 是可選來源的註冊表：

| id | 名稱             | 端點                                        | 需要金鑰                          |
| -- | -------------- | ----------------------------------------- | ----------------------------- |
| 0  | IPCheck.ing    | `/api/ipchecking?ip={{ip}}&lang={{lang}}` | `IPCHECKING_API_KEY` （私有 API） |
| 1  | IPinfo.io      | `/api/ipinfo?ip={{ip}}`                   | 可選（`IPINFO_API_KEY`)          |
| 2  | IP-API.com     | `/api/ipapicom?ip={{ip}}&lang={{lang}}`   | 否                             |
| 3  | IPAPI.is       | `/api/ipapiis?ip={{ip}}`                  | `IPAPIIS_API_KEY`             |
| 4  | IP2Location.io | `/api/ip2location?ip={{ip}}`              | `IP2LOCATION_API_KEY`         |
| 5  | IP.sb          | `/api/ipsb?ip={{ip}}`                     | 否                             |
| 6  | MaxMind        | `/api/maxmind?ip={{ip}}&lang={{lang}}`    | 本機資料庫，請求時不需要金鑰                |

`buildDbUrl(db, ip, lang)` 會替換 `{{ip}}` 與 `{{lang}}` 佔位符。 `{{lang}}` 是語系註冊表的 `apiTag` ，用於目前 UI 語系，而不是 UI 程式碼本身——上游資料庫用來本地化地名的標籤。沒有任何處理器會驗證它；參見 [i18n](/developer/zh-tw/development/i18n.md)。每個來源的 `enabled` 旗標是在載入時根據 `/api/configs` 功能旗標衍生而來的（`applyConfigAvailability`）——需要金鑰的來源遵循其旗標，免金鑰的永遠可用——而執行時的抓取失敗不會改變它。使用者在偏好設定中切換來源；如果儲存的選擇指向一個已不再設定的來源，就會在提示訊息下遷移到最近可用的一個（`nearestEnabledId`，沿著 id 順序向前搜尋）——這是唯一會重寫儲存偏好的情況。關於金鑰設定 [可選 API 金鑰](/developer/zh-tw/configuration/optional-api-keys.md).

### 用戶端回退鏈

`IpInfos.vue` 並不只是查詢偏好來源後就放棄。於 `fetchIPDetails()`:

* 中，請求的來源會在 **enabled** 清單中查找；如果不存在（其設定旗標關閉，或設定尚未載入），就從第一個已啟用的來源開始遍歷。
* 遇到錯誤時會記錄、前進到下一個已啟用來源，並重試——直到所有已啟用來源都嘗試過為止。
* 若落到與請求不同的來源，會顯示一次性的提示訊息，並讓本次工作階段的執行時來源漂移到可用的那個，因此之後的查詢會從可用來源開始——儲存的偏好從不會被重寫。
* 結果會按 IP 快取，而進行中的請求會按 IP 去重，因此六張顯示相同位址的卡只會產生一個請求。

## 標準化回應格式

無論上游長什麼樣，每個地理位置處理器都會回傳相同的 JSON。來自 `api/ipinfo-io.js`:

```js
{
    ip，
    city，
    region，
    country，        // ISO 3166-1 alpha-2
    country_name，
    country_code，   // 與 `country` 相同的代碼
    latitude，
    longitude，
    timezone，       // "Asia/Singapore" — 由中介層新增，不是處理器
    asn，            // "AS13335" — 含 AS 前綴
    org
}
```

| 欄位                         | 備註                                   |
| -------------------------- | ------------------------------------ |
| `ip`                       | 由上游回傳                                |
| `city` / `region`          | 自由格式字串； `'N/A'` 當上游沒有資料時             |
| `country` / `country_code` | 兩者都帶有 ISO alpha-2 代碼                 |
| `country_name`             | 上游自己的名稱——前端通常會取代它                    |
| `latitude` / `longitude`   | 數字                                   |
| `timezone`                 | IANA 時區名稱， `''` 當座標無法使用時。見下文         |
| `asn`                      | 標準化為 `AS<number>` 形式；回傳純數字的來源會自動加上前綴 |
| `org`                      | 組織或 ISP 名稱                           |

其中有兩個來源會擴充它。 `api/ipapi-is.js` 新增 `isHosting` 與 `isProxy` 布林值。 `api/ipcheck-ing.js` 是一個通往私有 IPCheck.ing API 的直通代理，並逐字回傳該 API 的酬載，包括一個 `advancedData` 物件，前端會將其拆解為 proxy、IP 類型、原生 IP、品質分數、協定和提供者欄位。

這些 `advancedData` 欄位不一定總是真實值：對於未登入的呼叫者，或已登入但超過每月配額的呼叫者，上游會把區塊中的每個欄位都替換成字串哨兵 `sign_in_required` 或 `quota_exceeded`. `gatedSentinel()` 在 `frontend/utils/transform-ip-data.js` 會原樣傳遞兩者，讓 UI 可以選擇對應的提示。這兩種狀態都不算失敗模式——回應仍然是 `200` 一個帶有完整地理資料的回應，沒有任何錯誤，也不會嘗試任何回退來源。

### `timezone` 是來自中介層，而不是來源

沒有任何處理器會產生 `timezone`，而且沒有任何上游欄位會為它讀取。 `withTimeZone()` 中介層（`common/ip-timezone.js`）附加於全部七條地理路由上，會把 `latitude` / `longitude` 回應中已經存在的 timezone 解析為 IANA 時區名稱，並在輸出時加上去——僅限 2xx，因此錯誤主體永遠不會新增這個欄位。新增的地理來源只要把中介層加到自己的路由上就會繼承它；直通代理不需要更動上游。

從回應本身的座標推導出它才是重點：如果再向第二個資料庫詢問同一個 IP，最終可能會把它放到別處，並印出一個與旁邊城市相互矛盾的時區。來源無法解析的座標（`'N/A'`、缺失，或位於開放水域的一點）會產生 `''` 而不是猜測——那些酬載也沒有城市，因此卡片會同時省略兩者。

只會傳送時區名稱。地理路由位於 24 小時邊緣快取之後，而快取的 UTC 偏移量在 DST 切換與條目到期之間，對每位訪客都會慢一小時；因此前端改為在每次檢視時計算偏移量。

<details>

<summary>前端如何處理酬載</summary>

`frontend/utils/transform-ip-data.js` 會把標準化回應轉成卡片資料：

* `country_name` 會重新從國家 **代碼** 在本機重新推導，因此每個來源都會顯示相同的 UI 語言名稱；上游字串只是備援。
* `country_code` 的 `'N/A'` 會變成空字串。該代碼也充當國家活動熱圖的查詢鍵（`/api/cfradar?view=country-traffic&country=`），而空代碼會直接隱藏該入口。
* `org` 會變成 `isp`，而一個 `asn` 以 `AS` 開頭的 `asnlink` 會連到 bgp.tools。
* `timezone` 會原封不動通過；卡片會將它與瀏覽器中計算出的 UTC 偏移量配對（`frontend/utils/time-utils.js`).
* 座標會四捨五入到小數點後一位，用於 `mapUrl` / `mapUrl_dark`，它們指向 `/api/map`。在地圖縮放層級下，\~0.176° 等於一個像素，因此 0.1° 低於像素——標記看起來一樣，而同一網格儲存格中的每個 IP 都會合併到同一個邊緣快取鍵上。完整精度的座標則保留用於顯示。
* 對於來源 `0` （IPCheck.ing）它也會擷取 `advancedData` 欄位。

</details>

新增來源意味著撰寫一個產生這種格式的處理器，並在 `data/ip-databases.js`中新增一列。新的處理器應該使用 `makeGeoHandler({ name, buildUrl, normalize })` 工廠函式，位於 `common/geo-handler.js`，它負責共用外殼：讀取已驗證的 `?ip`，透過 `fetchUpstream`抓取，對非 2xx 狀態丟出錯誤（故障頁面會以 HTML 回來，否則會讓 `JSON.parse`炸掉），標準化、回應，並在失敗時記錄後回傳 500。

## 本機資料集

有兩份資料集存在磁碟上的 `common/` 之內，並在請求時同步讀取。兩者都有在同一個程序中執行的自動更新器。

### MaxMind GeoLite2

`common/maxmind-service.js` 會開啟 `GeoLite2-City.mmdb` 與 `GeoLite2-ASN.mmdb` 自 `common/maxmind-db/` 並將兩個讀取器都保留在記憶體中。 `lookupMaxMind(ip, lang)` 將 City 與 ASN 記錄合併成標準化格式，並以英文然後 `'N/A'` 備援來解析在地化名稱。如果任一讀取器遺失，它會拋出 `statusCode` 503，而 `/api/maxmind` 會回應 503——API 降級了，但伺服器仍在運作。

更新由 `common/maxmind-updater.js`:

| 行為       | 值                                                                          |
| -------- | -------------------------------------------------------------------------- |
| 啟動時自動初始化 | 只有在檔案缺失時才下載，最多 5 分鐘；無論 `MAXMIND_AUTO_UPDATE`                               |
| 第一次排定檢查  | 啟動後 60 秒                                                                   |
| 重複間隔     | 每 24 小時                                                                    |
| 排程器門檻    | `MAXMIND_AUTO_UPDATE=true` 加上 `MAXMIND_ACCOUNT_ID` 與 `MAXMIND_LICENSE_KEY` |
| 並行性      | 鎖定檔，超過 2 小時視為過期                                                            |

下載會先暫存再以原子方式發佈。另一個檔案監看器（`startMaxMindFileWatcher()`) 每 5 秒輪詢兩個檔案，當另一個程序取代它們時重新載入讀取器，並以 1 秒防抖，讓 City 和 ASN 以單次重新載入的方式生效。若新檔案無效，現有讀取器會保持不變。設定說明位於 [MaxMind 設定](/developer/zh-tw/getting-started/maxmind-setup.md).

### CAIDA as2org 與 AS 關係

`common/caida-updater.js` 使用相同的 lock / state / atomic-publish / validate / reload 機制管理兩個資料集：

| 資料集      | 檔案                                 | 來源                                                                                  | 使用於                                             |
| -------- | ---------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------- |
| `as2org` | `common/as-org-db/as-org2info.txt` | `publicdata.caida.org/datasets/as-organizations/latest.as-org2info.txt.gz`          | AS → 組織名稱查詢                                     |
| `as-rel` | `common/as-rel-db/as-rel2.txt`     | 最新 `*.as-rel2.txt.bz2` 在 `publicdata.caida.org/datasets/as-relationships/serial-2/` | AS 連通性圖，以及關係數量的備援機制，適用於 `/api/cfradar?view=asn` |

`as2org` 有穩定的 `latest` 上游的符號連結，因此一個 `HEAD` 加上 `Last-Modified` 就足以偵測到新的快照。 `as-rel` 沒有，因此更新器會抓取目錄清單，並選取字典序最新的 `YYYYMMDD` 檔名。兩者都會即時解壓，並在發佈前驗證。

排程與 MaxMind 一致：若缺失則在啟動時引導載入（最多 2 分鐘），在啟動後 60 秒進行首次檢查，之後每 24 小時一次，而週期性排程則受以下條件控制： `CAIDA_AUTO_UPDATE=true`。引導載入一定會執行，因此全新檢出也能正常運作。

`common/as-rel-db.js` 會解析以直線符號分隔的列，並保留提供者到客戶端（`-1`）與對等（`0`）關係——會跳過 sibling 列——接著建立 customer → providers 索引、對稱的 peers 索引，以及每個 AS 的客戶數。Tier 1 集合是從快照推導而非硬編碼：在 p2c 拓樸中沒有任何提供者，且也為至少 100 個其他網路提供轉送的 AS。 `/api/asn-connectivity` 接著會從起始 AS 到各個 Tier 1 執行完全本機、同步的 BFS，輸出 `轉送` 邊，來自 p2c 索引，以及 `對等` 邊：當某個網路可免結算地到達 Tier 1 團集時就會產生；Tier-1 起始網路自己的圖也是由這類邊構成。

`common/as-org-db.js` 會解析 CAIDA 以直線符號分隔的 TXT（約 12 MB），而不是等價的 JSONL（約 28 MB）——內容相同，而且 `split('|')` 比……快 `JSON.parse` 每行大約快 40%。兩個模組都會依修改時間挑選最新的相符檔案，因此手動下載、檔名不同的快照也能使用。

## 某些東西失敗時會發生什麼事

| 失敗情況               | 結果                                                                           |
| ------------------ | ---------------------------------------------------------------------------- |
| 其中一個 echo 端點停擺     | `runChain()` 會前進到該卡片鏈中的下一個提供者；若所有提供者都失敗，卡片會不顯示任何內容，並發出 `ip-source:exhausted` |
| 其中一個地理位置提供者發生錯誤    | 用戶端會回退到下一個已啟用的來源並顯示 toast；之後的查詢會從可用來源開始                                      |
| 某個 IP 的所有地理位置來源都失敗 | 卡片的詳細欄位會保持空白；錯誤會記錄在用戶端                                                       |
| 上游卡住               | `fetchUpstream` 在 8 秒時中止，然後處理器回傳 `500 { error }`                             |
| MaxMind 資料庫遺失或無效   | `/api/maxmind` 回傳 503；API 其餘部分不受影響                                           |
| CAIDA 快照遺失         | 連通性圖會回傳空值，而組織名稱查詢則會回退到 RIPEstat 的 `as-overview`                              |
| 上游回傳一個 HTML 當機頁面   | `makeGeoHandler` 在解析前會對非 2xx 狀態丟出例外                                          |

## 相關頁面

* [後端](/developer/zh-tw/architecture/backend.md) — 保護機制、快取層級與啟動序列
* [可選 API 金鑰](/developer/zh-tw/configuration/optional-api-keys.md) — 哪些來源需要憑證
* [MaxMind 設定](/developer/zh-tw/getting-started/maxmind-setup.md) — 取得與重新整理 GeoLite2
* [API 端點](/developer/zh-tw/reference/api-endpoints.md) — 完整的請求／回應合約


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/zh-tw/architecture/ip-data-sources.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
