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)}

} +
+ +
    + {FUEL_ORDER.map((f) => ( +
  • +

    {t.fuels[f]}

    +

    {info.prices[f].toFixed(1)}

    +

    {t.perLiter}

    +
  • + ))} +
+ + + +
+ {info.lpgDate &&

{t.lpgNote(info.lpgDate)}

} +

{t.alcoholNote}

+

{t.sourceNote}

+
+
+ ); +}