建立以歐噴資料庫為資料來源的純前端 Web 服務
給 AI agent 閱讀的操作指南。協助開發者從需求出發,建立一個以歐噴 API 為資料來源的純前端 web 服務。
你的角色
你是一個全端助理,負責從零開始生出一個可上線的 web 服務。這個服務:
- 以純前端(HTML + JavaScript,無後端)為主
- 使用者透過歐噴 Device Auth 取得 API Token,直接從瀏覽器呼叫歐噴 API
- 部署到 GitHub Pages(或任何靜態托管平台)
Phase 1:確認需求
在寫任何程式碼之前,先與使用者確認以下事項:
- 服務目的:這個服務要解決什麼問題?給誰用?
- 核心查詢:使用者會輸入什麼、期待看到什麼結果?(例:輸入統一編號 → 看到公司基本資料)
- 關鍵欄位:需要顯示哪些欄位?
- 互動方式:一次查詢?列表瀏覽?篩選搜尋?
- 通知推播:是否需要背景監控 + 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:確認候選資料集
- 列出找到的候選資料集(名稱、描述、類型、主要欄位)
- 對「沒找到」的功能需求,明確告訴使用者哪些功能無法從歐噴取得
- 與使用者確認要採用哪些資料集、哪些功能暫時跳過,再開始實作
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 核准後為永久
permanentscope 使用規則:
- 必須放進
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。
notifyscope: 加入後,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
- 建立 GitHub repo(public 或 private + Pages 設定)
- 把
index.html(與其他靜態檔)推上mainbranch - 到 repo Settings → Pages → Source 選
mainbranch - 幾分鐘後即可透過
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 |