> 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/development/adding-a-new-tool.md).

# 新增工具

端到端導覽：從註冊表項目到 API 處理器、i18n 與測試，新增一個工具。

首頁上的「Advanced Tools」—— MAC Lookup、Whois、DNS Resolver、Censorship Check，以及其他工具——都遵循同一種接線模式。本頁將逐步說明如何從頭到尾新增一個新工具。

## 範例

我們將新增一個假想的 **憑證檢查**: 輸入網域，從上游 API 取回其 TLS 憑證詳情。

| 部分        | 值                                                  |
| --------- | -------------------------------------------------- |
| 代稱        | `certcheck`                                        |
| 元件        | `frontend/components/advanced-tools/CertCheck.vue` |
| API 處理器   | `api/cert-check.js`                                |
| 路由        | `GET /api/certcheck?domain=…`                      |
| i18n 命名空間 | `certcheck.*`                                      |

它直接以 **MAC Lookup** 工具（`macchecker` → `MacChecker.vue` → `api/mac-checker.js`）為藍本，這是 repo 中最小的完整範例。請將那三個檔案與本頁並排開啟。

{% hint style="info" %}
**並非每個工具都需要後端。** Browser Info、Security Checklist 和 IP Calculator 完全在瀏覽器中執行。若你的工具也是如此，請跳過步驟 1–3，直接進入元件——但不要把邏輯放進去。IP Calculator 就是這種模式：運算放在 `common/ip-math.js` （透過 `frontend/utils/ip-math.js`橋接，因為後端的 RDAP 查詢會共用它），計算器自己的分類器與分析器在 `frontend/utils/ip-calc.js`，而 `IpCalculator.vue` 只負責呈現 `calculate()` 回傳的內容。每個純模組都附帶其規格測試（`tests/ip-math.test.js`, `tests/ip-calc.test.js`）。參見 [沒有後端的工具](/developer/zh-tw/architecture/frontend.md#tools-without-a-backend).
{% endhint %}

## 命名

* **代稱** —— 全小寫、無分隔符號： `macchecker`, `dnsresolver`, `censorshipcheck`。它是 `/tools/<slug>` 的 URL，以及抽屜查詢 `?tool=<slug>`，因此一旦發布就等同永久。
* **元件** —— PascalCase `.vue` 下的 `frontend/components/advanced-tools/`.
* **處理器檔案** —— kebab-case `.js` 下的 `api/`.
* **路由路徑** —— 與舊工具的 slug 相符（`/api/macchecker`），新版則使用 kebab-case（`/api/ooni-blocking`, `/api/service-status`）。兩種都可以；擇一並保持一致即可。

***

{% stepper %}
{% step %}

### 撰寫 API 處理器

每條路由各自一個檔案，放在 `api/`下方，並以標頭註解開頭，說明路由及其用途。單一預設匯出， `fetchUpstream` 用於上游呼叫，失敗時則使用共用 logger。

{% code title="api/cert-check.js" %}

```js
// /api/certcheck — 針對某網域的 TLS 憑證詳情，從
// 上游憑證 API 取得。為前端 CertCheck 工具提供功能。

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

const CERT_API_URL = 'https://example-cert-api.test/v1/cert';

export default async (req, res) => {
    if (req.method !== 'GET') {
        return res.status(405).json({ error: '不允許的方法' });
    }

    // 存在性、形狀與小寫化皆由 requireValidDomain 保證。
    const domain = req.query.domain;

    const token = process.env.CERT_API_KEY || '';
    if (!token) {
        return res.status(500).json({ error: '缺少 API 金鑰' });
    }

    try {
        const upstream = await fetchUpstream(`${CERT_API_URL}?host=${domain}&key=${token}`);
        if (!upstream.ok) {
            throw new Error(`憑證 API 回應狀態 ${upstream.status}`);
        }
        res.json(await upstream.json());
    } catch (error) {
        logger.error({ err: error, domain }, 'cert-check handler 失敗');
        res.status(500).json({ error: error.message });
    }
};
```

{% endcode %}

有四件事不容商量：

* **`fetchUpstream`，絕對不要直接使用 `fetch()`.** 它會帶上 8 秒逾時與專案的 `User-Agent`.
* **共用 logger，絕不使用 `console.*`.** 先放 context 物件，再放簡短訊息。
* **簡短的錯誤格式。** `400` 在錯誤輸入時， `500` 以 `{ error: … }` 回傳失敗。
* **處理器中不做 referer 或參數檢查。** 中介層已經做過了——見下一步。

那個 `req.method !== 'GET'` 檢查是防禦性的：下方的路由已經限制了方法。不過仍保留它，因為冒煙測試會直接斷言它。
{% endstep %}

{% step %}

### 重用一個 guard——或新增一個

參數驗證放在 `common/guards.js`，絕不寫在處理器內。這個工具使用 `?domain=`，而且那個 guard 已經存在：

```js
requireValidDomain()   // 拒絕缺失或格式錯誤的網域，並就地轉成小寫
```

就地轉成小寫很重要：邊緣快取是依 URL 作為 key，因此大小寫混雜的查詢不能變成另一個獨立的快取項目。

目前可用的完整清單：

| Guard                      | 驗證項目                               |
| -------------------------- | ---------------------------------- |
| `requireReferer`           | 全域用於 `/api/*` —— 允許的網域 + localhost |
| `requirePublicIP()`        | `?ip=` —— 格式良好 **且** 可公開路由         |
| `requireValidDomain()`     | `?domain=` （也會轉成小寫）                |
| `requireValidPrefix()`     | `?prefix=` （CIDR）                  |
| `requireValidASN()`        | `?asn=` （移除 `AS`，並改寫為數字）           |
| `requireValidProviderId()` | `?id=` 對照 service-status slug 清單   |
| `requireValidRecordType()` | `?type=` 對照 DNS 記錄類型允許清單           |
| `requireValidReportId()`   | `/api/report/:id` 路由參數             |

請注意 `requirePublicIP()` 會問兩個問題，而不是一個：它會以 `無效的 IP 位址`拒絕格式錯誤的地址，並以 `192.168.1.1`, `127.0.0.1`拒絕格式正確但保留用途的地址—— `不是公開 IP 位址`——任何 RFC 1918 / CGNAT / link-local / 文件用途空間內的地址。這對於上游是登錄檔或地理位置來源的路由來說，是正確的 guard，因為它們對私有地址沒有任何可說的。如果你的工具真的接受私有地址（例如檢查 LAN 目標的工具），那這個 guard 就不對：請在旁邊寫一個只驗證有效性的 guard，而不是放寬這個 guard，因為有七條地理位置路由依賴它。

{% hint style="warning" %}
**新的參數形狀，就需要新的 guard。** 將它新增為 `common/guards.js`中的匯出工廠，並在 `backend-server.js`中掛載，並在 `tests/guards.test.js`中覆蓋它。不要在你的處理器裡手寫檢查——這正是 guard 層存在要防止的漂移。
{% endhint %}
{% endstep %}

{% step %}

### 在 `backend-server.js`

中接上路由

{% code title="backend-server.js" %}

```js
import certCheckHandler from './api/cert-check.js';
```

{% endcode %}

接著宣告路由。中介層順序是先 guard，再 cache，最後 handler：

{% code title="backend-server.js" %}

```js
app.get('/api/certcheck', requireValidDomain(), cacheable(ONE_DAY_CACHE), certCheckHandler);
```

{% endcode %}

TTL 要依上游資料實際變動速度來決定。該檔案已經定義好常數—— `FIVE_MIN_CACHE`, `ONE_HOUR_CACHE`, `ONE_DAY_CACHE`, `SEVEN_DAYS_CACHE`, `THIRTY_DAYS_CACHE`, `ONE_YEAR_CACHE` —— 全部都寫成乘法式，而不是原始秒數。

如果資料是每個使用者專屬、需要認證，或每次請求都會變動， **就省略 `cacheable()` 完全**。 `/api/*` 在 `預設為`，所以不寫它是比較安全的選擇。

新的環境變數？把它加到 `.env.example` ，並加上註解，然後在 [環境變數](/developer/zh-tw/reference/environment-variables.md) 且 [選用 API 金鑰](/developer/zh-tw/configuration/optional-api-keys.md).
{% endstep %}

{% step %}

### 建立元件

建立 `frontend/components/advanced-tools/CertCheck.vue`。它是一個標準的 `<script setup>` 元件——抽屜與獨立頁面都會原封不動地掛載它，所以不需要包裝框架、不需要標題列，也不需要知道路由。

與其自己發明，不如複製標準模式。從 `MacChecker.vue`:

{% code title="frontend/components/advanced-tools/CertCheck.vue" %}

```vue
<template>
    <div class="cert-check-section my-4 space-y-4">
        <p class="text-sm text-muted-foreground leading-relaxed">{{ t('certcheck.Note') }}</p>

        <div class="space-y-2">
            <Label for="queryDomain">{{ t('certcheck.Note2') }}</Label>
            <div class="flex items-center gap-2">
                <Input type="text" id="queryDomain" name="queryDomain"
                    autocomplete="off" autocorrect="off" autocapitalize="off"
                    spellcheck="false" data-1p-ignore data-lpignore="true"
                    :disabled="status === 'running'"
                    :placeholder="t('certcheck.Placeholder')"
                    v-model="queryDomain" @keyup.enter="onSubmit" />
                <Button variant="action" :disabled="status === 'running' || !queryDomain"
                    @click="onSubmit" class="cursor-pointer">
                    <Spinner v-if="status === 'running'" />
                    <Search v-else class="size-4 shrink-0" />
                </Button>
            </div>
            <p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
        </div>

        <!-- 結果區域 -->
        <Card v-if="result.subject">…</Card>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';

const { t } = useI18n();

const queryDomain = ref('');
const status = ref('idle');
const result = ref({});
const errorMsg = ref('');

const onSubmit = () => {
    trackEvent('Section', 'StartClick', 'CertCheck');
    errorMsg.value = '';
    result.value = {};
    if (queryDomain.value) fetchCert(queryDomain.value);
};

const fetchCert = async (domain) => {
    status.value = 'running';
    try {
        const response = await fetch(`/api/certcheck?domain=${domain}`);
        if (!response.ok) throw new Error('網路回應不正常');
        result.value = await response.json();
    } catch (error) {
        console.error('取得憑證時發生錯誤：', error);
        errorMsg.value = t('certcheck.fetchError');
    } finally {
        status.value = 'idle';
    }
};
</script>
```

{% endcode %}

值得特別指出的幾點：

* **觸發按鈕** — `variant="action"` 以 `<Spinner v-if />` 以及一個 `:disabled` guard。這是整個專案共用的「執行此項」入口。
* **可防 AutoFill 的輸入欄位** —— 每個自由輸入的 `Input` 都帶有上方顯示的六個屬性，而且 placeholder 避開了「address」這個字（以及其翻譯），因為即使搭配 `autocomplete="off"`.
* **`console.*` ，iOS QuickType 仍會根據這個字本身來判定。** 前端會使用它；只有後端檔案才有限制。
* **不要手動撰寫 state→color 切換。** 如果你的工具要把業務狀態對應到顏色，請透過 `composables/use-status-tone.js`.
* **每個字串都是一個 `t()` 呼叫。** 沒有任何對使用者可見的內容是硬寫死的。

更多標準模式——狀態卡片、旗標、表格 vs 清單、對話框標頭、動態效果——都收錄在 [前端](/developer/zh-tw/architecture/frontend.md).
{% endstep %}

{% step %}

### 註冊工具

`frontend/data/tools.js` 是唯一的真實來源。那裡的一筆項目會帶來首頁上的卡片、底部抽屜、獨立 `/tools/<slug>` 頁面，以及導覽選單項目—— **你不需要碰 router**。（獨立頁面是工具可以選擇不提供的部分；見 `noStandalone` 如下。）

{% code title="frontend/data/tools.js" %}

```js
export const ADVANCED_TOOLS = [
  // …既有項目…
  { slug: 'certcheck', emoji: '🔐', titleKey: 'certcheck.Title', noteKey: 'advancedtools.CertCheck', component: () => import('@/components/advanced-tools/CertCheck.vue') },
];
```

{% endcode %}

項目結構：

<table><thead><tr><th width="230">欄位</th><th>含義</th></tr></thead><tbody><tr><td><code>slug</code></td><td>穩定的 URL 識別碼—— <code>/tools/&#x3C;slug></code> 以及抽屜的 <code>?tool=&#x3C;slug></code> 查詢。</td></tr><tr><td><code>emoji</code></td><td>卡片圖示與抽屜標頭圖示。</td></tr><tr><td><code>titleKey</code></td><td>工具標題的 i18n key。</td></tr><tr><td><code>noteKey</code></td><td>單行卡片描述的 i18n key。</td></tr><tr><td><code>component</code></td><td>對該 <code>.vue</code> 檔案的延遲載入匯入——由抽屜與獨立頁面共用。</td></tr><tr><td><code>requiresOriginalSite</code></td><td>選用。 <code>true</code> 會在自架實例上隱藏該工具。公用工具可省略它。</td></tr><tr><td><code>noStandalone</code></td><td>選用。 <code>true</code> 表示此工具沒有 <code>/tools/&#x3C;slug></code> 頁面 — 首頁抽屜是它唯一的入口。一般工具可省略此欄位。</td></tr></tbody></table>

陣列中的順序就是首頁上的卡片順序。

{% hint style="info" %}
**`requiresOriginalSite: true`** 適用於依賴私有 IPCheck.ing API 與已登入帳號的工具 — 它們會在 forks 和自架實例中隱藏，因為那些環境無法連到該後端。請見 [與 IPCheck.ing 綁定的功能](/developer/zh-tw/configuration/features-tied-to-ipcheck-ing.md)。大多數新工具都應省略此欄位。
{% endhint %}

{% hint style="info" %}
**`noStandalone: true`** 適用於讀取首頁所擁有狀態的工具。 `StandaloneTool.vue` 會將這類 slug 視為未註冊，並重新導向 `/tools/<slug>` 到 `/?tool=<slug>`，卡片會直接連到那裡，而抽屜會隱藏其「在新分頁開啟」圖示。深入人格檢查是目前唯一的現成範例：它會交叉參照首頁測試的結果，所以若頁面離開這些結果，就沒有可用資料。自成一體的工具 — 幾乎全部都是如此 — 應省略此欄位，並保留其可分享、可索引的頁面。
{% endhint %}
{% endstep %}

{% step %}

### 新增文案 — 於每個 `完整` 語系

你所引用的每個字串，都需要在每個 `完整` 語系的包中，位於 `frontend/locales/` — 每個語系都 `common/locale-registry.js` 標記為 `完整`；該檔案就是目前的清單。全部都要在同一次變更中完成。這不是後續工作。

被標記為 `beta` 在註冊表中的語系可例外：你從中省略的鍵會自動回退為英文。請將該鍵保留為 `""` if `pnpm i18n-sync` 會把它放到那裡。

每個語系要編輯兩個地方 — 你的工具自己的命名空間：

{% code title="frontend/locales/en.json" %}

```json
"certcheck": {
  "Title": "憑證檢查",
  "Note": "查詢任何網域的 TLS 憑證：簽發者、有效期限與主體替代名稱。",
  "Note2": "輸入網域以開始檢查：",
  "Placeholder": "example.com",
  "fetchError": "無法取得憑證詳細資料"
}
```

{% endcode %}

……以及共享中的卡片描述 `advancedtools` 命名空間：

{% code title="frontend/locales/en.json" %}

```json
"advancedtools": {
  "CertCheck": "檢視網域的 TLS 憑證"
}
```

{% endcode %}

命名空間通常會與 slug 相同（`macchecker`, `dnsresolver`, `censorshipcheck`）。請在每個包中保持鍵名完全相同 — `en.json` 是參考檔，其他檔案需完全沿用其鍵路徑；只有值會變動。

關於載入、副包與回退鏈的詳細說明： [i18n](/developer/zh-tw/development/i18n.md).
{% endstep %}

{% step %}

### 新增變更記錄項目

將你的條目附加到 **最後一個** 版本區塊於 `frontend/data/changelog.json` — 此檔案以最舊在前的順序運作，而 UI 會反向呈現。

{% code title="frontend/data/changelog.json" %}

```json
{
  "type": "add",
  "change": {
    "en": "新增憑證檢查工具：檢視任何網域的 TLS 憑證",
    "zh": "新增憑證檢查工具：查看任意網域的 TLS 憑證",
    "zh-TW": "新增憑證檢查工具：查看任意網域的 TLS 憑證",
    "fr": "Nouvel outil de vérification de certificat : inspectez le certificat TLS de n'importe quel domaine",
    "ru": "Новый инструмент проверки сертификатов: просмотр TLS-сертификата любого домена"
  }
}
```

{% endcode %}

`type` 必須是以下其中之一 `add`, `improve`，或 `fix`。每個 `完整` 語系的字串都必須存在且非空白 — `tests/changelog.test.js` 否則會使建置失敗。beta 語系不受變更記錄歷史限制。
{% endstep %}

{% step %}

### 撰寫測試

兩種，且都位於 `tests/`.

**處理器煙霧測試** 放在 `tests/api-handlers.test.js`，放在一個 `describe` 區塊中，與其他測試並列。只對會回傳的分支進行斷言 **在……之前** 第一次 `fetchUpstream` 呼叫 — 這個測試套件從不碰觸真正的上游服務：

{% code title="tests/api-handlers.test.js" %}

```js
import certCheckHandler from '../api/cert-check.js';

// -- cert-check handler ---------------------------------------------------
// 網域是否存在／格式是否正確由 requireValidDomain middleware 強制驗證
// （tests/guards.test.js）；handler 自己在取得資料前的分支是
// 方法檢查與缺少 API key 的提早返回。

describe('憑證檢查處理器', () => {
    it('在碰到上游前，以 405 拒絕非 GET 請求', async () => {
        const res = createResponse();
        await certCheckHandler(createRequest({ method: 'POST', query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 405);
        assert.equal(res.body.error, '方法不允許');
    });

    it('當缺少 API key 時回傳 500', async () => {
        delete process.env.CERT_API_KEY;
        const res = createResponse();
        await certCheckHandler(createRequest({ query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 500);
        assert.equal(res.body.error, '缺少 API key');
    });
});
```

{% endcode %}

該檔案已提供 `createRequest()` / `createResponse()` 假物件。如果你的 handler 會讀取新的環境變數，請把它的名稱加入 `ENV_KEYS` 上方的陣列，讓備份／還原 hooks 能涵蓋它。

**單元規格** 涵蓋你的工具引入的任何純邏輯 — 驗證器、解析器、轉換、以及具有可 mock 輸入的 composable。為每個項目各自提供一個 `tests/<subject>.test.js`。如果你新增了 guard，請擴充 `tests/guards.test.js`.

什麼 *不* 需要測試：Vue 渲染、真實網路呼叫、瀏覽器 API。請見 [測試](/developer/zh-tw/development/testing.md).
{% endstep %}

{% step %}

### 執行自我檢查

```bash
pnpm check
```

測試加上正式版建置。你開 PR 之前必須全部通過。

接著自己在 `pnpm dev` — 在首頁卡片網格、抽屜，以及 `/tools/certcheck` — 因為這些都無法由機器驗證。請在 PR 描述中註明。
{% endstep %}
{% endstepper %}

***

## 檢查清單

| 步驟                | 檔案                                                                          |
| ----------------- | --------------------------------------------------------------------------- |
| 處理器               | `api/cert-check.js`                                                         |
| Guard（僅在有新的參數格式時） | `common/guards.js`                                                          |
| 路由                | `backend-server.js`                                                         |
| 元件                | `frontend/components/advanced-tools/CertCheck.vue`                          |
| 註冊表項目             | `frontend/data/tools.js`                                                    |
| 文案，每個 `完整` 語系     | `frontend/locales/<code>.json`                                              |
| 變更記錄，每個 `完整` 語系   | `frontend/data/changelog.json`                                              |
| 煙霧測試              | `tests/api-handlers.test.js`                                                |
| 單元規格              | `tests/*.test.js`                                                           |
| 新的環境變數            | `.env.example`                                                              |
| 首頁捷徑（選用）          | `frontend/composables/use-shortcuts.js` + `shortcutKeys.<Tool>` 在每個 `完整` 語系 |

## 更進一步

大多數工具都需要的兩個小便利：

* **首頁捷徑。** 一行於 `frontend/composables/use-shortcuts.js` — `{ keys: 'a', action: () => goToAdvancedTool('ipcalculator', 'IpCalculator'), description: t('shortcutKeys.IpCalculator') }` 是 IP Calculator 的 — 再加上 `shortcutKeys.<Tool>` 每個中的字串 `完整` 語系，這也是 <kbd>?</kbd> 說明面板所列出的內容。 `goToAdvancedTool` 會捲動到 Advanced Tools 區塊，並在該 slug 上開啟抽屜。捷徑只在首頁路由可用，而且在任何覆蓋層開啟時會暫停；請挑選在該檔案中仍可用的按鍵（單字母區分大小寫： `M` 且 `m` 是不同的綁定）。
* **可分享的輸入。** 如果結果值得建立連結，請讀取 `route.query.q` 在掛載時並執行它，並用 `router.replace` 在每次執行後回寫 — 絕不要 `push`，否則歷史記錄會在每次執行時多一筆。這在兩個入口點都適用（`/tools/<slug>?q=` 且 `/?tool=<slug>&q=`). `IpCalculator.vue` 是參考格式。

你的工具可接入三個選用系統。前兩個是事件驅動 — 元件會在 `utils/app-events.js` 匯流排上發出事件，且不會直接呼叫這些系統：

* **成就。** 發出一個網域事件，並在 `frontend/data/achievement-rules.js`中將它對應到一個成就 slug，然後把該成就加入 `frontend/data/achievements.js`.
* **可分享的診斷報告。** 一個「my network」測試會發出 `<domain>:finished` 與其結構化結果；在 `frontend/utils/report-builders.js` 進行標準化，而 `common/report-schema.js` 會將欄位列入白名單。builder 以柔性失敗方式運作，所以缺少 schema 項目時，會表現為悄悄缺失的欄位，而不是錯誤 — 請在同一次變更中加入 builder 和 schema 項目。
* **命令匯流排。** 如果應用程式的其他部分應該能夠 *觸發* 你的工具 — 鍵盤捷徑、重新整理協調器、文件助理 — 請在設定時透過 `composables/use-app-command.js` 取代透過 template ref 暴露方法。安裝在抽屜中的工具只有在其 `?tool=` 路由開啟時才擁有其命令，因此呼叫端會將 `waitForAppCommand` 與派發搭配使用。

這三者都在更詳細地說明於 [前端](/developer/zh-tw/architecture/frontend.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/development/adding-a-new-tool.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.
