建立以歐噴資料庫為資料來源的純前端 Web 服務

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


你的角色

你是一個全端助理,負責從零開始生出一個可上線的 web 服務。這個服務:

  • 以純前端(HTML + JavaScript,無後端)為主
  • 使用者透過歐噴 Device Auth 取得 API Token,直接從瀏覽器呼叫歐噴 API
  • 部署到 GitHub Pages(或任何靜態托管平台)

Phase 1:確認需求

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

  1. 服務目的:這個服務要解決什麼問題?給誰用?
  2. 核心查詢:使用者會輸入什麼、期待看到什麼結果?(例:輸入統一編號 → 看到公司基本資料)
  3. 關鍵欄位:需要顯示哪些欄位?
  4. 互動方式:一次查詢?列表瀏覽?篩選搜尋?
  5. 通知推播:是否需要背景監控 + notify 推播?(影響 token 策略選擇)

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


Phase 2:取得 Token 並搜尋資料集

步驟 1:先取得 Bearer Token

搜尋私有資料集需要登入。在搜尋之前,先引導使用者完成 Device Auth,取得 Bearer Token(詳見 Phase 3 的 Device Auth 流程)。

步驟 2:搜尋相關資料集

呼叫搜尋 API:

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

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

步驟 3:確認資料集類型

歐噴資料庫有三種不同類型的資料集,查詢方式完全不同:

類型 slug 特徵 查詢端點 認證方式
標準資料集(/records) 一般 slug,如 tw.com.twse~ref~listed GET .../api/v1/datasets/{slug}/records?欄位=值 Bearer token
Elasticsearch API(如 mopsov) 有 ~api~elastic- 前綴,如 tw.openfun~api~elastic-mopsov POST https://{name}.api.openfun.dev/{index}/_search,body 為 ES Query DSL Bearer token 或 X-Api-Key
外部 API 封裝(如公司查詢) 有 ~api~ 前綴,如 tw.openfun~api~company 各有不同 base URL,見 skill.md 可能不需要認證

如何判斷:讀取該資料集的 skill.md(GET https://data.openfun.tw/datasets/{slug}/skill.md),「開始之前」段落會說明正確的 Base URL 和查詢格式。

步驟 4:確認候選資料集

  1. 列出找到的候選資料集(名稱、描述、類型、主要欄位)
  2. 對「沒找到」的功能需求,明確告訴使用者哪些功能無法從歐噴取得
  3. 與使用者確認要採用哪些資料集、哪些功能暫時跳過,再開始實作

Phase 3:實作

Token 策略選擇

根據需求選擇合適的 token 策略:

策略 適用情境 做法
Device Auth + sessionStorage 一次性工具、測試用、安全優先 device auth 完成後存 sessionStorage;關分頁即清除
Device Auth + localStorage 長期使用的個人工具、需要跨分頁登入 存 localStorage;需偵測過期並重新授權
API 金鑰(長效) 想完全省去登入流程,讓使用者貼金鑰 引導使用者從 /user 頁面申請 API 金鑰並貼入服務

Notify 推播需要 notify scope:device auth 時加上 requested_scopes: ["notify"] 即可。若同時需要永久 token,再加 "permanent" 並提供 dataset/category scope。

Device Auth 流程

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

Step 1:發起授權,取得 user_code

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 清單:選填,不填則只申請基本存取(24 小時 token)
    requested_scopes: ['permanent', 'notify'],  // permanent = 永久 token;notify = 可推播通知
    // ⚠️ permanent 必須「同時」搭配下方的 dataset/category scope,否則回 400

    // 資料集鎖定 scope(申請 permanent 時必填其一)
    // → token 只能存取鎖定的資料集/分類;任何不在清單內的 dataset 均拒絕(包含 public)
    // → 設計目的:限制 token 外流後的傷害範圍
    requested_dataset_scope: ['tw.openfun~api~elastic-mopsov'],  // 最多 3 個 dataset slug
    requested_category_scope: ['tw.kyc.business'],               // 最多 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 代表申請了 dataset/category scope,token 核准後為永久

permanent scope 使用規則:

  • 必須放進 requested_scopes,並同時提供 requested_dataset_scope 或 requested_category_scope(缺一回 400)
  • 產生的永久 token 只能存取鎖定清單內的 dataset,即使是 public dataset,不在清單內也會被拒絕
  • 資料集類型(/records 或 Elasticsearch API)不影響 scope 鎖定功能
  • requested_dataset_scope 最多 3 個 slug;requested_category_scope 最多 3 個 category slug;兩者可同時指定(聯集)

複合 scope 設計範例(同時用多個 /records 資料集 + ES API):

{
  "requested_scopes": ["permanent", "notify"],
  "requested_dataset_scope": [
    "tw.openfun~api~elastic-mopsov",
    "tw.com.twse~ref~listed",
    "tw.com.tpex~ref~otc"
  ]
}

以上鎖定 3 個 dataset(剛好達到上限),不需要 category scope。
若還需要存取某個 category 下的多個 dataset,改用 requested_category_scope 代替個別列舉。
⚠️ 確認每個需要查詢的 dataset 都在 scope 清單內,否則即使是 public dataset 也會回 403。

notify scope: 加入後,token 可呼叫 POST /api/v1/notify 推播通知。未加則回 403 scope_required。

Step 2:引導使用者授權

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

Step 3:輪詢等待授權完成

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);
  const expiresAt = tokenData.expires_in
    ? Date.now() + tokenData.expires_in * 1000
    : null;  // null = 永久 token
  localStorage.setItem('openfun_token', tokenData.access_token);
  localStorage.setItem('openfun_token_expires_at', expiresAt ?? 'never');
  onAuthenticated(tokenData.access_token);
}, data.interval * 1000);

Token 過期偵測

使用 localStorage 時,頁面載入和 API 呼叫前都要檢查:

function getToken() {
  const token = localStorage.getItem('openfun_token');
  const expiresAt = localStorage.getItem('openfun_token_expires_at');
  if (!token) return null;
  if (expiresAt !== 'never' && Date.now() > parseInt(expiresAt)) {
    // Token 已過期,清除並要求重新授權
    localStorage.removeItem('openfun_token');
    localStorage.removeItem('openfun_token_expires_at');
    return null;
  }
  return token;
}

呼叫歐噴 API(標準 /records 資料集)

async function openfunFetch(path, params = {}) {
  const token = getToken();
  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) {
    localStorage.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   → 總筆數

// 查詢參數說明:
// ?q=台積電          → 全文搜尋(跨所有欄位,適合關鍵字)
// ?公司代號=2330     → 精確比對(只比對該欄位,適合查 ID、代號)
// ⚠️ 使用者輸入股票代號(純數字)時,必須用精確比對而非 ?q=,
//    否則會比對到其他欄位含該數字的無關記錄(如電話、地址)
//
// 【重要】欄位名稱拼錯時,API 會在回應的 warnings 陣列中說明:
// 若 ?公司代碼=2330(誤)而非 ?公司代號=2330(正確),回應會包含:
// "warnings": ["未知查詢參數:公司代碼(已忽略,請確認欄位名稱)"]
// 且 total 仍會是全表筆數(篩選無效)。務必檢查回應的 warnings 欄位,
// 有 warning 就代表查詢條件未生效,需修正欄位名稱再重試。

// /records 回應結構範例(每筆資料的欄位名稱以該資料集的 schema 為準,見 skill.md):
// {
//   "total": 1000,
//   "per_page": 20,
//   "page": 1,
//   "records": [
//     {
//       "_id": "2330",           // 系統 ID,不一定有語意(視資料集而定)
//       "公司代號": "2330",      // 欄位名稱來自 schema,用中文或英文視資料集而定
//       "公司名稱": "台灣積體電路製造股份有限公司",
//       "產業別": "半導體業"
//     }
//   ]
// }

呼叫 Elasticsearch 類 API 資料集

部分資料集使用 Elasticsearch,Base URL 與查詢格式完全不同,需用 POST + JSON body:

// 範例:查詢 tw.openfun~api~elastic-mopsov(公告快易查)
// Base URL: https://elastic-mopsov.api.openfun.dev
// 支援 CORS,可直接從瀏覽器呼叫

async function elasticSearch(baseUrl, index, esQuery) {
  const token = getToken();
  const resp = await fetch(`${baseUrl}/${index}/_search`, {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + token,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(esQuery)
  });
  if (!resp.ok) throw new Error('ES API 錯誤:' + resp.status);
  return resp.json();
}

// 查詢特定公司最新 5 筆重訊
const result = await elasticSearch(
  'https://elastic-mopsov.api.openfun.dev',
  'mopsov_announcements',
  {
    size: 5,
    query: { term: { company_id: '2330' } },
    sort: [{ spoke_at: 'desc' }],
    _source: ['company_name', 'subject', 'spoke_at', 'an_code', 'hyperlink']
  }
);
// result.hits.hits → 文件陣列(每筆在 ._source 裡)
// result.hits.total.value → 總筆數

ES 回應格式與 /records 不同:資料在 result.hits.hits,每筆在 ._source。

Notify 推播 API

需要 token 具備 notify scope(device auth 時加 requested_scopes: ['notify'],或使用 API 金鑰)。

// 免費帳號只能用 digest: true;付費帳號可用 digest: false(即時)。
// /api/v1/me 不回傳帳號層級,建議用 try-and-fallback:先送即時,失敗再送 digest。
async function sendNotify(message, url = null) {
  const token = getToken();
  async function doPost(digest) {
    const resp = await fetch('https://data.openfun.tw/api/v1/notify', {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer ' + token,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message, url, digest })
    });
    return resp.json();
  }

  let data = await doPost(false);  // 先試即時推播
  if (data.error === 'membership_required') {
    data = await doPost(true);     // 付費功能不可用 → 改用 digest
  }
  if (!data.ok) throw new Error(data.error + ': ' + data.message);
  return data;
}

await sendNotify('台積電有新重訊!', 'https://mopsov.twse.com.tw/...');

Notify API 回傳碼:

HTTP error 說明
200 — 成功,{"ok": true, "queued": true}
403 scope_required token 未含 notify scope,需重新授權
403 membership_required 即時推播需付費帳號(digest: false)
429 daily_quota_exceeded 今日推播額度已用完
400 no_channel_bound 使用者尚未綁定推播頻道(需到 /user 頁面設定 Slack 等)

背景輪詢模式

若服務需要監控並在有新資料時推播通知:

let lastSeenAt = {};  // { "2330": "2026-09-18T10:00:00+08:00", ... }
let watchList = JSON.parse(localStorage.getItem('watchList') || '[]');

async function checkNewAnnouncements() {
  for (const companyId of watchList) {
    const result = await elasticSearch(
      'https://elastic-mopsov.api.openfun.dev',
      'mopsov_announcements',
      {
        size: 1,
        query: { term: { company_id: companyId } },
        sort: [{ spoke_at: 'desc' }],
        _source: ['company_name', 'subject', 'spoke_at', 'hyperlink']
      }
    );
    const latest = result.hits.hits[0]?._source;
    if (!latest) continue;

    const prev = lastSeenAt[companyId];
    if (!prev || latest.spoke_at > prev) {
      lastSeenAt[companyId] = latest.spoke_at;
      if (prev) {  // 不是第一次讀取,才推播
        await sendNotify(
          `${latest.company_name} 有新重訊:${latest.subject}`,
          latest.hyperlink,
          false  // 即時推播(付費帳號)或改 true 走 digest
        );
      }
    }
  }
}

// 頁面開啟時開始輪詢(5 分鐘一次)
let pollingTimer = null;
function startPolling() {
  if (pollingTimer) return;
  checkNewAnnouncements();  // 立即執行一次
  pollingTimer = setInterval(checkNewAnnouncements, 5 * 60 * 1000);
}
function stopPolling() {
  clearInterval(pollingTimer);
  pollingTimer = null;
}

// 頁面可見性 API:頁籤被切到背景時不必停,但可以降頻
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    clearInterval(pollingTimer);
    pollingTimer = setInterval(checkNewAnnouncements, 15 * 60 * 1000); // 背景降為 15 分鐘
  } else {
    clearInterval(pollingTimer);
    pollingTimer = setInterval(checkNewAnnouncements, 5 * 60 * 1000);  // 前景回 5 分鐘
  }
});

使用者電腦的 JS 輪詢僅在瀏覽器開著時有效。若需「使用者不在線也能收到通知」,則需要後端 cron,超出純前端架構範疇。

頁面結構範本

<!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 = getToken();
      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 有效期:device auth 預設 24 小時(expires_in: 86400);若 requested_scopes 含 permanent 且同時提供了 requested_dataset_scope 或 requested_category_scope,則核發永久 Token(expires_in: null)。永久 token 只能存取鎖定清單內的 dataset,任何不在清單內的 dataset(含 public)均拒絕存取。資料集類型(/records 或 Elasticsearch API)不影響此機制。
  • sessionStorage vs localStorage:sessionStorage 關分頁即清除(較安全);需要背景輪詢或跨分頁保留登入狀態,改用 localStorage,同時實作過期偵測(見上方)。
  • 歐噴 CORS:data.openfun.tw 和所有 *.api.openfun.dev 子網域均已支援 CORS,瀏覽器直打 API 不需 proxy。
  • 資料集權限:使用者只能查詢自己有權限存取的資料集;無權限的資料集 API 會回 403。
  • 不要在 URL query string 放 token:token 只放 Authorization header 或 storage。
  • onclick 不要用 JSON.stringify 直接嵌入物件:雙引號會破壞 HTML 屬性,導致點擊失效。正確做法是用 data- attribute 傳資料:data-company='${escHtml(JSON.stringify(obj))}',onclick 裡用 this.dataset.company 取值再 JSON.parse。
  • 顯示使用者名稱:取得 token 後可呼叫 GET /api/v1/me 取得 {email, display_name}。
  • Notify 前置條件:使用者須先到 /user 頁面完成 Slack(或其他頻道)綁定,否則 notify API 回 no_channel_bound。
  • Notify 免費帳號限制:免費帳號只能用 digest: true(彙整推播),即時推播 (digest: false) 需付費帳號;每日額度上限:免費帳號 30 則、付費帳號 100 則。
  • 背景輪詢只在網頁開啟時有效:若需要「頁籤關閉也能通知」,告知使用者這超出純前端範疇,需後端 cron。

快速參考

項目 端點
發起 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
查詢 ES 資料集(mopsov) POST https://elastic-mopsov.api.openfun.dev/{index}/_search
搜尋資料集 GET https://data.openfun.tw/api/v1/search?q={keyword}
讀 skill.md GET https://data.openfun.tw/datasets/{slug}/skill.md
發送通知推播 POST https://data.openfun.tw/api/v1/notify

📄 查看原始 Markdown