diff --git a/api/fuel-price.ts b/api/fuel-price.ts
new file mode 100644
index 0000000..19d1f5c
--- /dev/null
+++ b/api/fuel-price.ts
@@ -0,0 +1,38 @@
+import type { VercelRequest, VercelResponse } from '@vercel/node';
+// 副檔名不可省:Vercel 以 nodenext 解析 api/,省略會編譯失敗(見 api/tsconfig.json)
+import { parseCpcOilJson } from '../src/lib/fuelPrice.js';
+
+// 中油今日牌價代理:固定網址、免金鑰、無使用者輸入(無 SSRF 面),不儲存任何資料。
+// 牌價每週一調整,CDN 快取 30 分鐘足夠即時,也不騷擾中油伺服器。
+
+const SOURCE = 'https://www.cpc.com.tw/GetOilPriceJson.aspx?type=TodayOilPriceString';
+const TIMEOUT_MS = 8000;
+const UA = 'Mozilla/5.0 (compatible; TigletBot/1.0; +https://tiglet.vercel.app)';
+
+export default async function handler(_req: VercelRequest, res: VercelResponse) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ let raw: string;
+ try {
+ const response = await fetch(SOURCE, { signal: controller.signal, headers: { 'User-Agent': UA, Accept: 'application/json,text/plain' } });
+ if (!response.ok) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'fetch-failed', status: response.status });
+ }
+ raw = await response.text();
+ } catch {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'fetch-failed' });
+ } finally {
+ clearTimeout(timer);
+ }
+
+ const info = parseCpcOilJson(raw);
+ if (!info) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'parse-failed' });
+ }
+
+ res.setHeader('Cache-Control', 'public, s-maxage=1800, stale-while-revalidate=86400');
+ return res.status(200).json(info);
+}
diff --git a/e2e/fuel-price.spec.ts b/e2e/fuel-price.spec.ts
new file mode 100644
index 0000000..8d0a14f
--- /dev/null
+++ b/e2e/fuel-price.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from '@playwright/test';
+import { waitForIslands } from './helpers';
+
+const INFO = {
+ updated: '7月13日',
+ lpgDate: '115年4月2日',
+ trend: '本週汽油價格 不調整',
+ prices: { gas92: 29.8, gas95: 31.3, gas98: 33.3, alcohol: 31.3, diesel: 28.8, lpg: 16.3 },
+};
+
+test('牌價上桌、調價訊息顯示、加油試算雙向換算', async ({ page }) => {
+ await page.route('**/api/fuel-price', (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(INFO) })
+ );
+ await page.goto('/tools/fuel-price');
+ await waitForIslands(page);
+
+ await expect(page.getByRole('list').getByText('95 無鉛')).toBeVisible();
+ await expect(page.getByText('31.3').first()).toBeVisible();
+ await expect(page.getByRole('status')).toContainText('本週汽油價格 不調整');
+ await expect(page.getByText('價格生效日:7月13日')).toBeVisible();
+
+ // 預設 95 無鉛 40 公升 → 1252 元
+ await expect(page.getByLabel('金額(元)')).toHaveValue('1252');
+ // 改公升 → 金額跟著動
+ await page.getByLabel('公升數').fill('10');
+ await expect(page.getByLabel('金額(元)')).toHaveValue('313');
+ // 反向:改金額 → 公升跟著動
+ await page.getByLabel('金額(元)').fill('626');
+ await expect(page.getByLabel('公升數')).toHaveValue('20');
+ // 換油品 → 用新單價重算
+ await page.getByLabel('油品').selectOption('gas92');
+ await expect(page.getByLabel('金額(元)')).toHaveValue('596');
+});
+
+test('API 掛掉顯示錯誤並可重試', async ({ page }) => {
+ let calls = 0;
+ await page.route('**/api/fuel-price', (route) => {
+ calls += 1;
+ if (calls === 1) return route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"fetch-failed"}' });
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(INFO) });
+ });
+ await page.goto('/tools/fuel-price');
+ await waitForIslands(page);
+
+ await expect(page.getByText('牌價取得失敗,請稍後再試。')).toBeVisible();
+ await page.getByRole('button', { name: '重試' }).click();
+ await expect(page.getByRole('list').getByText('95 無鉛')).toBeVisible();
+});
diff --git a/public/og/en/fuel-price.png b/public/og/en/fuel-price.png
new file mode 100644
index 0000000..d11de67
Binary files /dev/null and b/public/og/en/fuel-price.png differ
diff --git a/public/og/zh/fuel-price.png b/public/og/zh/fuel-price.png
new file mode 100644
index 0000000..e5ad267
Binary files /dev/null and b/public/og/zh/fuel-price.png differ
diff --git a/src/data/tools.ts b/src/data/tools.ts
index d053e88..dcff015 100644
--- a/src/data/tools.ts
+++ b/src/data/tools.ts
@@ -51,4 +51,5 @@ export const tools: Tool[] = [
{ id: 'url-audit', title: '網址健檢', titleEn: 'URL Audit', description: '檢查網址的 SEO、社群分享卡與 AI 引擎設定並評分。', descriptionEn: 'Audit a URL for SEO, social share cards and AI-engine readiness, with a score.', category: '實用工具', path: '/tools/url-audit', icon: '🔎', status: 'available', keywords: ['seo', 'og', 'open graph', 'geo', '健檢', 'meta', '分享', 'social', 'audit', 'twitter card'] },
{ id: 'invoice-lottery', title: '發票對獎', titleEn: 'Invoice Lottery', description: '財政部最新開獎號碼,輸入末 3 碼立刻對獎。', descriptionEn: 'Check Taiwan uniform-invoice numbers against the latest official draw.', category: '實用工具', path: '/tools/invoice-lottery', icon: '🧧', status: 'available', keywords: ['發票', '統一發票', '對獎', '中獎號碼', 'invoice', 'lottery', 'receipt', '雲端發票', '財政部'] },
{ id: 'qr-scanner', title: 'QR 掃描', titleEn: 'QR Scanner', description: '相機或圖片掃描 QR 碼,網址、WiFi、名片自動解析。', descriptionEn: 'Scan QR codes from camera or images; URLs, WiFi and vCards parsed.', category: '實用工具', path: '/tools/qr-scanner', icon: '📷', status: 'available', keywords: ['qr', 'scan', 'scanner', '掃描', '掃碼', 'barcode', 'wifi', 'vcard', '解碼'] },
+ { id: 'fuel-price', title: '中油油價', titleEn: 'Fuel Prices (TW)', description: '中油今日牌價與本週調價,附加油金額試算。', descriptionEn: 'Today’s CPC Taiwan fuel prices with a fill-up cost calculator.', category: '實用工具', path: '/tools/fuel-price', icon: '⛽', status: 'available', keywords: ['油價', '中油', '汽油', '柴油', '95', '92', '98', 'fuel', 'gas', 'petrol', 'diesel', '加油'] },
];
diff --git a/src/lib/__tests__/fuelPrice.test.ts b/src/lib/__tests__/fuelPrice.test.ts
new file mode 100644
index 0000000..3b8deed
--- /dev/null
+++ b/src/lib/__tests__/fuelPrice.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { parseCpcOilJson } from '../fuelPrice';
+
+// 中油 GetOilPriceJson.aspx?type=TodayOilPriceString 的實際回應(2026-07-13 實抓)
+const REAL = JSON.stringify({
+ PriceUpdate: '7月13日',
+ UpOrDown_Html:
+ '
本週汽油價格不調整
',
+ sPrice1: '29.8',
+ sPrice2: '31.3',
+ sPrice3: '33.3',
+ sPrice4: '31.3',
+ sPrice5: '28.8',
+ sPrice6: '16.3',
+ LPGdate: '115年4月2日',
+});
+
+describe('parseCpcOilJson', () => {
+ it('解析真實回應:六種油品、生效日、調價訊息(HTML 轉純文字)', () => {
+ const r = parseCpcOilJson(REAL)!;
+ expect(r.prices).toEqual({ gas92: 29.8, gas95: 31.3, gas98: 33.3, alcohol: 31.3, diesel: 28.8, lpg: 16.3 });
+ expect(r.updated).toBe('7月13日');
+ expect(r.lpgDate).toBe('115年4月2日');
+ // 調價訊息絕不原樣輸出 HTML —— 前端不做 innerHTML,這裡就要拆成純文字
+ expect(r.trend).toBe('本週汽油價格 不調整');
+ expect(r.trend).not.toContain('<');
+ });
+
+ it('漲跌訊息帶金額也解得出來', () => {
+ const raw = JSON.stringify({
+ PriceUpdate: '7月20日',
+ UpOrDown_Html: '本週汽油價格調漲0.1元
',
+ sPrice1: '29.9', sPrice2: '31.4', sPrice3: '33.4', sPrice4: '31.4', sPrice5: '28.9', sPrice6: '16.3',
+ LPGdate: '115年4月2日',
+ });
+ expect(parseCpcOilJson(raw)!.trend).toBe('本週汽油價格 調漲 0.1 元');
+ });
+
+ it('中油更新中會回空字串價格 → null(官網 JS 也是這樣擋)', () => {
+ const raw = JSON.stringify({ PriceUpdate: '', UpOrDown_Html: '', sPrice1: '', sPrice2: '', sPrice3: '', sPrice4: '', sPrice5: '', sPrice6: '', LPGdate: '' });
+ expect(parseCpcOilJson(raw)).toBeNull();
+ });
+
+ it('非 JSON、缺欄位、非數字 → null', () => {
+ expect(parseCpcOilJson('not json')).toBeNull();
+ expect(parseCpcOilJson('{}')).toBeNull();
+ expect(parseCpcOilJson(JSON.stringify({ sPrice1: 'abc', sPrice2: '1', sPrice3: '1', sPrice4: '1', sPrice5: '1', sPrice6: '1', PriceUpdate: 'x', UpOrDown_Html: '', LPGdate: '' }))).toBeNull();
+ });
+});
diff --git a/src/lib/fuelPrice.ts b/src/lib/fuelPrice.ts
new file mode 100644
index 0000000..5931782
--- /dev/null
+++ b/src/lib/fuelPrice.ts
@@ -0,0 +1,58 @@
+// 中油今日牌價:GetOilPriceJson.aspx 回應的解析與正規化。純函式,
+// 由 api/fuel-price.ts 使用。sPrice1~6 的對應抄自中油官網首頁的 widget JS:
+// 92無鉛 / 95無鉛 / 98無鉛 / 酒精汽油 / 超級柴油 / 車用液化石油氣。
+
+export interface FuelPrices {
+ gas92: number;
+ gas95: number;
+ gas98: number;
+ alcohol: number;
+ diesel: number;
+ lpg: number;
+}
+
+export interface FuelPriceInfo {
+ updated: string; // 價格生效日,如「7月13日」
+ lpgDate: string; // 液化石油氣實施日(民國紀年)
+ trend: string; // 本週調價訊息(純文字,絕不透傳 HTML)
+ prices: FuelPrices;
+}
+
+function toPrice(v: unknown): number | null {
+ if (typeof v !== 'string' || v.trim() === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+// 把中油的 UpOrDown_Html 拆成純文字:標籤界線補空格、收斂空白
+function stripHtml(html: string): string {
+ return html
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+export function parseCpcOilJson(raw: string): FuelPriceInfo | null {
+ let data: Record;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof data !== 'object' || data === null) return null;
+
+ const gas92 = toPrice(data.sPrice1);
+ const gas95 = toPrice(data.sPrice2);
+ const gas98 = toPrice(data.sPrice3);
+ const alcohol = toPrice(data.sPrice4);
+ const diesel = toPrice(data.sPrice5);
+ const lpg = toPrice(data.sPrice6);
+ if (gas92 === null || gas95 === null || gas98 === null || alcohol === null || diesel === null || lpg === null) return null;
+
+ return {
+ updated: typeof data.PriceUpdate === 'string' ? data.PriceUpdate : '',
+ lpgDate: typeof data.LPGdate === 'string' ? data.LPGdate : '',
+ trend: typeof data.UpOrDown_Html === 'string' ? stripHtml(data.UpOrDown_Html) : '',
+ prices: { gas92, gas95, gas98, alcohol, diesel, lpg },
+ };
+}
diff --git a/src/pages/en/tools/fuel-price.astro b/src/pages/en/tools/fuel-price.astro
new file mode 100644
index 0000000..f4ac331
--- /dev/null
+++ b/src/pages/en/tools/fuel-price.astro
@@ -0,0 +1,12 @@
+---
+import BaseLayout from '../../../layouts/BaseLayout.astro';
+import FuelPrice from '../../../tools/FuelPrice.tsx';
+import { tools } from '../../../data/tools';
+
+const tool = tools.find((t) => t.id === 'fuel-price')!;
+---
+
+ ← All tools
+ {tool.titleEn}
+
+
diff --git a/src/pages/tools/fuel-price.astro b/src/pages/tools/fuel-price.astro
new file mode 100644
index 0000000..2c89578
--- /dev/null
+++ b/src/pages/tools/fuel-price.astro
@@ -0,0 +1,9 @@
+---
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import FuelPrice from '../../tools/FuelPrice.tsx';
+---
+
+ ← 回到所有工具
+ 中油油價
+
+
diff --git a/src/tools/FuelPrice.tsx b/src/tools/FuelPrice.tsx
new file mode 100644
index 0000000..99f4848
--- /dev/null
+++ b/src/tools/FuelPrice.tsx
@@ -0,0 +1,158 @@
+import { useEffect, useState } from 'react';
+import type { FuelPriceInfo, FuelPrices } from '../lib/fuelPrice';
+import type { Locale } from '../lib/i18n';
+
+const L = {
+ zh: {
+ loading: '正在取得中油今日牌價…',
+ loadError: '牌價取得失敗,請稍後再試。',
+ retry: '重試',
+ updated: (d: string) => `價格生效日:${d}`,
+ lpgNote: (d: string) => `液化石油氣為車用價格,自 ${d} 起實施`,
+ alcoholNote: '酒精汽油未扣除能源署優惠補助',
+ perLiter: '元/公升',
+ fuels: { gas92: '92 無鉛', gas95: '95 無鉛', gas98: '98 無鉛', alcohol: '酒精汽油', diesel: '超級柴油', lpg: '液化石油氣' } as Record,
+ calcTitle: '加油試算',
+ calcFuel: '油品',
+ calcLiters: '公升數',
+ calcAmount: '金額(元)',
+ calcHint: '輸入公升數或金額,另一邊會自動換算。',
+ sourceNote: '資料來源:台灣中油公司牌價。實際售價以各加油站公告為準。',
+ },
+ en: {
+ loading: 'Fetching today’s CPC fuel prices…',
+ loadError: 'Could not load fuel prices. Please try again.',
+ retry: 'Retry',
+ updated: (d: string) => `Effective date: ${d}`,
+ lpgNote: (d: string) => `LPG is the vehicle price, effective from ${d}`,
+ alcoholNote: 'Alcohol gasoline price excludes the Energy Administration subsidy',
+ perLiter: 'NT$/L',
+ fuels: { gas92: '92 Unleaded', gas95: '95 Unleaded', gas98: '98 Unleaded', alcohol: 'Alcohol Gasoline', diesel: 'Premium Diesel', lpg: 'LPG (Autogas)' } as Record,
+ calcTitle: 'Fill-up calculator',
+ calcFuel: 'Fuel',
+ calcLiters: 'Liters',
+ calcAmount: 'Amount (NT$)',
+ calcHint: 'Type liters or an amount — the other side updates automatically.',
+ sourceNote: 'Source: CPC Corporation, Taiwan list prices. Actual pump prices may vary by station.',
+ },
+} as const;
+type Dict = (typeof L)[Locale];
+
+const FUEL_ORDER: (keyof FuelPrices)[] = ['gas92', 'gas95', 'gas98', 'alcohol', 'diesel', 'lpg'];
+
+const inputClass =
+ 'w-full rounded-lg border border-edge bg-surface px-3 py-2.5 font-mono tabular-nums text-ink outline-none transition-colors focus:border-accent';
+
+function Calculator({ prices, t }: { prices: FuelPrices; t: Dict }) {
+ const [fuel, setFuel] = useState('gas95');
+ const [liters, setLiters] = useState('40');
+ const [amount, setAmount] = useState(() => String(Math.round(40 * prices.gas95)));
+
+ const price = prices[fuel];
+
+ function fromLiters(v: string, f = fuel) {
+ setLiters(v);
+ const n = Number(v);
+ setAmount(v.trim() !== '' && Number.isFinite(n) && n >= 0 ? String(Math.round(n * prices[f] * 100) / 100) : '');
+ }
+ function fromAmount(v: string) {
+ setAmount(v);
+ const n = Number(v);
+ setLiters(v.trim() !== '' && Number.isFinite(n) && n >= 0 ? String(Math.round((n / price) * 100) / 100) : '');
+ }
+
+ return (
+
+
{t.calcTitle}
+
+
+
+
+
+
{t.calcHint}
+
+ );
+}
+
+export default function FuelPrice({ locale = 'zh' }: { locale?: Locale }) {
+ const t = L[locale];
+ const [info, setInfo] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ async function load() {
+ setFailed(false);
+ setInfo(null);
+ try {
+ const res = await fetch('/api/fuel-price');
+ if (!res.ok) throw new Error();
+ const data = (await res.json()) as FuelPriceInfo;
+ if (!data.prices) throw new Error();
+ setInfo(data);
+ } catch {
+ setFailed(true);
+ }
+ }
+ useEffect(() => {
+ load();
+ }, []);
+
+ if (failed) {
+ return (
+
+
{t.loadError}
+
+
+ );
+ }
+ if (!info) return {t.loading}
;
+
+ return (
+
+
+ {info.trend &&
{info.trend}
}
+ {info.updated &&
{t.updated(info.updated)}
}
+
+
+
+
+
+
+
+ {info.lpgDate &&
{t.lpgNote(info.lpgDate)}
}
+
{t.alcoholNote}
+
{t.sourceNote}
+
+
+ );
+}