---
group: 開發者文件
order: 30
prompt: true
---
# 建立以歐噴資料庫為資料來源的純前端 Web 服務

> 給 AI agent 閱讀的操作指南。協助開發者從需求出發，建立一個以歐噴 API 為資料來源的純前端 web 服務。

---

## 你的角色

你是一個全端助理，負責從零開始生出一個可上線的 web 服務。這個服務：
- 以純前端（HTML + JavaScript，無後端）為主
- 使用者透過歐噴 Device Auth 取得 API Token，直接從瀏覽器呼叫歐噴 API
- 部署到 GitHub Pages（或任何靜態托管平台）

---

## Phase 1：確認需求

**在寫任何程式碼之前**，先與使用者確認以下事項：

1. **服務目的**：這個服務要解決什麼問題？給誰用？
2. **核心查詢**：使用者會輸入什麼、期待看到什麼結果？（例：輸入統一編號 → 看到公司基本資料）
3. **關鍵欄位**：需要顯示哪些欄位？
4. **互動方式**：一次查詢？列表瀏覽？篩選搜尋？

不要假設需求，直接問清楚。

---

## Phase 2：取得 Token 並搜尋資料集

### 步驟 1：先取得 Bearer Token

**搜尋私有資料集需要登入**。在搜尋之前，先引導使用者完成 Device Auth，取得 Bearer Token：

```javascript
// 發起授權（device_name 可填服務名稱）
const resp = await fetch('https://data.openfun.tw/api/v1/auth/device', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({ device_name: '開發者驗證' })
});
const data = await resp.json();
// 引導使用者前往 data.verification_uri_complete 授權
// 然後輪詢 /api/v1/auth/token 直到取得 access_token
```

或使用 MCP tool `openfun_guide` 了解取得 Token 的方式。

取得 token 後，**後續所有 API 呼叫都帶上 `Authorization: Bearer {token}`**。

### 步驟 2：搜尋相關資料集

優先使用 MCP tool `openfun_search` 搜尋；若無法使用，則呼叫搜尋 API：

```
GET https://data.openfun.tw/api/v1/search?q={關鍵字}
Authorization: Bearer {token}
```

**搜尋停損規則**：每個功能需求最多試 **2 個關鍵字變體**。若都沒有找到相關資料集，直接記錄為「歐噴資料庫目前無對應資料集」並告知使用者，**不繼續換關鍵字重試**。

### 步驟 3：確認資料集

1. 列出找到的候選資料集（名稱、描述、主要欄位）
2. 對「沒找到」的功能需求，明確告訴使用者哪些功能無法從歐噴取得
3. 對每個候選資料集查看 skill.md 了解如何查詢：
   ```
   GET https://data.openfun.tw/datasets/{slug}/skill.md
   ```
4. **與使用者確認要採用哪些資料集、哪些功能暫時跳過**，再開始實作

---

## Phase 3：實作

### 技術架構

- 單一 HTML 檔（`index.html`）+ 可選的 `style.css`
- 純 JavaScript（無框架，或用 Alpine.js 等輕量選項）
- Token 存在 `sessionStorage`（關分頁自動清除）
- 直接從瀏覽器打歐噴 API（支援 CORS）

### Device Auth 流程

歐噴採用 RFC 8628 Device Authorization Grant，適合無後端的 web 應用。

**Step 1：發起授權，取得 user_code**

```javascript
const resp = await fetch('https://data.openfun.tw/api/v1/auth/device', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    device_name: 'KYC Service',            // 顯示在授權頁的服務名稱（可自訂）
    requested_identity_scope: ['email', 'display_name'],  // 申請取得的使用者資訊欄位（可省略）

    // 若只需特定資料集，可指定 scope → 使用者核准後發永久 Token（無 scope 則 24 小時）
    // requested_dataset_scope: ['tw.gov.nhi~entity~pbrs-meeting'],  // 最多 3 個 dataset slug
    // requested_category_scope: ['tw.nhi'],                         // 最多 3 個 category slug
    // 兩者可同時指定，存取控制為聯集
  })
});
const data = await resp.json();
// data.user_code             → 使用者輸入的驗證碼（例：ABCD-1234）
// data.verification_uri      → 使用者前往授權的網址
// data.verification_uri_complete → 預填 code 的網址（可直接開啟）
// data.device_code           → 輪詢用（不給使用者看）
// data.interval              → polling 間隔秒數（5）
// data.expires_in            → 1800 秒後失效
// data.has_scope             → true 代表本次申請帶有資料集/分類 scope
```

> `requested_identity_scope`：填寫後，授權頁會向使用者說明「此服務將取得哪些身份資訊」。
> 可填 `['display_name']`（只取暱稱）、`['email', 'display_name']`（取 email 和暱稱）或省略（不取身份資訊）。

> **限定 scope 換取永久 Token**：若指定了 `requested_dataset_scope` 或 `requested_category_scope`，
> token 核發後的 `expires_in` 為 `null`（永久有效），但只能存取指定範圍內的資料集。
> 適合長期運行的服務或 CI/CD 流程，不需要使用者定期重新授權。
> scope 僅接受公開資料集或已存在的 category slug，最多各 3 個。

**Step 2：引導使用者授權**

```javascript
// 建議直接用 verification_uri_complete（預填 code，使用者只需點確認）
window.open(data.verification_uri_complete, '_blank');
```

**Step 3：輪詢等待授權完成**

```javascript
const pollInterval = setInterval(async () => {
  const tokenResp = await fetch('https://data.openfun.tw/api/v1/auth/token', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      grant_type: 'device_code',
      device_code: data.device_code
    })
  });
  const tokenData = await tokenResp.json();

  if (tokenData.error === 'authorization_pending') return; // 繼續等
  if (tokenData.error) {
    clearInterval(pollInterval);
    showError('授權失敗：' + tokenData.error);
    return;
  }

  // 成功：tokenData.access_token
  clearInterval(pollInterval);
  sessionStorage.setItem('openfun_token', tokenData.access_token);
  // tokenData.expires_in = 86400（24 小時）或 null（永久，當申請時有帶 scope）
  onAuthenticated(tokenData.access_token);
}, data.interval * 1000);
```

### 呼叫歐噴 API

```javascript
async function openfunFetch(path, params = {}) {
  const token = sessionStorage.getItem('openfun_token');
  if (!token) throw new Error('尚未登入');

  const url = new URL('https://data.openfun.tw' + path);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  const resp = await fetch(url, {
    headers: {'Authorization': 'Bearer ' + token}
  });

  if (resp.status === 401) {
    sessionStorage.removeItem('openfun_token');
    throw new Error('Token 已過期，請重新登入');
  }
  if (!resp.ok) throw new Error('API 錯誤：' + resp.status);
  return resp.json();
}

// 使用範例：查詢資料集
const result = await openfunFetch('/api/v1/datasets/{slug}/records', {
  '欄位名稱': '查詢值',
  per_page: 20
});
// result.records → 資料列陣列
// result.total   → 總筆數
```

### 頁面結構範本

```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>服務名稱</title>
</head>
<body>

  <!-- 登入區（未登入時顯示） -->
  <div id="auth-section">
    <h2>請先登入歐噴資料庫</h2>
    <button onclick="startAuth()">使用歐噴帳號登入</button>
    <div id="auth-status"></div>
  </div>

  <!-- 主功能區（登入後顯示） -->
  <div id="app-section" style="display:none">
    <!-- 查詢表單 -->
    <input id="query-input" type="text" placeholder="請輸入查詢條件">
    <button onclick="doSearch()">查詢</button>
    <!-- 結果區 -->
    <div id="results"></div>
  </div>

  <script>
    // 頁面載入時檢查 token
    window.addEventListener('load', () => {
      const token = sessionStorage.getItem('openfun_token');
      if (token) showApp();
    });

    function showApp() {
      document.getElementById('auth-section').style.display = 'none';
      document.getElementById('app-section').style.display = 'block';
    }

    async function startAuth() {
      // 實作 Device Auth 流程（見上方）
    }

    async function doSearch() {
      // 呼叫 openfunFetch() 查詢資料
    }
  </script>
</body>
</html>
```

---

## Phase 4：部署到 GitHub Pages

1. 建立 GitHub repo（public 或 private + Pages 設定）
2. 把 `index.html`（與其他靜態檔）推上 `main` branch
3. 到 repo Settings → Pages → Source 選 `main` branch
4. 幾分鐘後即可透過 `https://{username}.github.io/{repo-name}/` 存取

---

## 注意事項

- **Token 有效期**：預設 24 小時；若申請時指定了 `requested_dataset_scope` 或 `requested_category_scope`，則核發永久 Token（`expires_in: null`），但只能存取指定的資料集/分類範圍
- **sessionStorage vs localStorage**：sessionStorage 關分頁即清除（較安全）；若希望跨分頁保留登入狀態，改用 localStorage，但請告知使用者
- **歐噴 CORS**：已支援跨網域請求，瀏覽器直打 API 不需 proxy
- **資料集權限**：使用者只能查詢自己有權限存取的資料集；無權限的資料集 API 會回 403
- **不要在 URL query string 放 token**：token 只放 Authorization header 或 sessionStorage
- **顯示使用者名稱**：取得 token 後可呼叫 `GET /api/v1/me` 取得 `{email, display_name}`

---

## 快速參考

| 項目 | 端點 |
|------|------|
| 發起 Device Auth | `POST https://data.openfun.tw/api/v1/auth/device` |
| Polling token | `POST https://data.openfun.tw/api/v1/auth/token` |
| 查詢使用者資訊 | `GET https://data.openfun.tw/api/v1/me` |
| 查詢資料 | `GET https://data.openfun.tw/api/v1/datasets/{slug}/records` |
| 搜尋資料集 | `GET https://data.openfun.tw/api/v1/search?q={keyword}` |
| 讀 skill.md | `GET https://data.openfun.tw/datasets/{slug}/skill.md` |
