Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions api/fuel-price.ts
Original file line number Diff line number Diff line change
@@ -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);
}
49 changes: 49 additions & 0 deletions e2e/fuel-price.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Binary file added public/og/en/fuel-price.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/zh/fuel-price.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/data/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '加油'] },
];
49 changes: 49 additions & 0 deletions src/lib/__tests__/fuelPrice.test.ts
Original file line number Diff line number Diff line change
@@ -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:
'<div class="ups_and_downs same"><b class="name">本週汽油價格</b><b class="sys">不調整</b><b class="rate"><i></i></b></div>',
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: '<div class="ups_and_downs up"><b class="name">本週汽油價格</b><b class="sys">調漲</b><b class="rate">0.1<i>元</i></b></div>',
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();
});
});
58 changes: 58 additions & 0 deletions src/lib/fuelPrice.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 },
};
}
12 changes: 12 additions & 0 deletions src/pages/en/tools/fuel-price.astro
Original file line number Diff line number Diff line change
@@ -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')!;
---
<BaseLayout title={`${tool.titleEn} — Tiglet`} description={tool.descriptionEn} toolId={tool.id}>
<a href="/en/" class="text-sm text-muted hover:text-accent">← All tools</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">{tool.titleEn}</h1>
<FuelPrice client:visible locale="en" />
</BaseLayout>
9 changes: 9 additions & 0 deletions src/pages/tools/fuel-price.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FuelPrice from '../../tools/FuelPrice.tsx';
---
<BaseLayout title="中油油價 — Tiglet" description="台灣中油今日牌價:92/95/98 無鉛、超級柴油與車用液化石油氣,本週調價一目瞭然,附加油金額試算。" toolId="fuel-price">
<a href="/" class="text-sm text-muted hover:text-accent">← 回到所有工具</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">中油油價</h1>
<FuelPrice client:visible />
</BaseLayout>
158 changes: 158 additions & 0 deletions src/tools/FuelPrice.tsx
Original file line number Diff line number Diff line change
@@ -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<keyof FuelPrices, string>,
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<keyof FuelPrices, string>,
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<keyof FuelPrices>('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 (
<div className="rounded-[var(--radius-card)] border border-edge bg-surface p-4">
<h2 className="mb-3 text-sm font-medium text-ink">{t.calcTitle}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-1 block text-sm text-muted">{t.calcFuel}</span>
<select
value={fuel}
onChange={(e) => {
const f = e.target.value as keyof FuelPrices;
setFuel(f);
fromLiters(liters, f);
}}
className="w-full rounded-lg border border-edge bg-surface px-3 py-2.5 text-ink outline-none focus:border-accent"
>
{FUEL_ORDER.map((f) => (
<option key={f} value={f}>{t.fuels[f]}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm text-muted">{t.calcLiters}</span>
<input value={liters} onChange={(e) => fromLiters(e.target.value)} inputMode="decimal" className={inputClass} />
</label>
<label className="block">
<span className="mb-1 block text-sm text-muted">{t.calcAmount}</span>
<input value={amount} onChange={(e) => fromAmount(e.target.value)} inputMode="decimal" className={inputClass} />
</label>
</div>
<p className="mt-2 text-xs text-muted">{t.calcHint}</p>
</div>
);
}

export default function FuelPrice({ locale = 'zh' }: { locale?: Locale }) {
const t = L[locale];
const [info, setInfo] = useState<FuelPriceInfo | null>(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 (
<div className="mx-auto max-w-xl space-y-3 text-center">
<p className="text-sm text-red-500">{t.loadError}</p>
<button onClick={load} className="rounded-lg border border-edge px-4 py-2 text-sm text-ink hover:border-accent hover:text-accent">
{t.retry}
</button>
</div>
);
}
if (!info) return <p className="text-center text-sm text-muted">{t.loading}</p>;

return (
<div className="mx-auto max-w-xl space-y-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
{info.trend && <p className="text-sm font-medium text-ink" role="status">{info.trend}</p>}
{info.updated && <p className="text-xs text-muted">{t.updated(info.updated)}</p>}
</div>

<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{FUEL_ORDER.map((f) => (
<li key={f} className="rounded-[var(--radius-card)] border border-edge bg-surface px-4 py-3">
<p className="text-sm text-muted">{t.fuels[f]}</p>
<p className="font-mono text-2xl tabular-nums text-ink">{info.prices[f].toFixed(1)}</p>
<p className="text-xs text-muted">{t.perLiter}</p>
</li>
))}
</ul>

<Calculator prices={info.prices} t={t} />

<div className="space-y-1 text-xs text-muted">
{info.lpgDate && <p>{t.lpgNote(info.lpgDate)}</p>}
<p>{t.alcoholNote}</p>
<p>{t.sourceNote}</p>
</div>
</div>
);
}
Loading