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
39 changes: 39 additions & 0 deletions api/invoice-numbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
// 副檔名不可省:Vercel 以 nodenext 解析 api/,省略會編譯失敗(見 api/tsconfig.json)
import { parseInvoiceXml } from '../src/lib/invoice.js';

// 統一發票開獎號碼代理:抓財政部公開 RSS(固定網址、免金鑰),
// 解析成 JSON 回給前端。無使用者輸入,沒有 SSRF 面;不儲存任何資料。
// 開獎兩個月一次,CDN 快取一小時綽綽有餘,也把流量壓到對得起官方伺服器。

const SOURCE = 'https://invoice.etax.nat.gov.tw/invoice.xml';
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 xml: string;
try {
const response = await fetch(SOURCE, { signal: controller.signal, headers: { 'User-Agent': UA, Accept: 'application/xml,text/xml' } });
if (!response.ok) {
res.setHeader('Cache-Control', 'no-store');
return res.status(502).json({ error: 'fetch-failed', status: response.status });
}
xml = await response.text();
} catch {
res.setHeader('Cache-Control', 'no-store');
return res.status(502).json({ error: 'fetch-failed' });
} finally {
clearTimeout(timer);
}

const periods = parseInvoiceXml(xml);
if (periods.length === 0) {
res.setHeader('Cache-Control', 'no-store');
return res.status(502).json({ error: 'parse-failed' });
}

res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
return res.status(200).json({ periods });
}
79 changes: 79 additions & 0 deletions e2e/invoice.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { waitForIslands } from './helpers';

const PERIODS = {
periods: [
{
period: '115年 03~04月',
link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503',
special: '19531471',
grand: '85941329',
first: ['07225810', '20231230', '83518781'],
sixth: ['985'],
},
{
period: '115年 01~02月',
link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11501',
special: '87510041',
grand: '32220522',
first: ['21677046', '44662410', '31262513'],
sixth: [],
},
],
};

function stub(page: import('@playwright/test').Page) {
return page.route('**/api/invoice-numbers', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PERIODS) })
);
}

test('載入開獎號碼,末 3 碼沒中直接判定、8 碼對中頭獎', async ({ page }) => {
await stub(page);
await page.goto('/tools/invoice-lottery');
await waitForIslands(page);

// 最新一期的號碼上桌
await expect(page.getByText('19531471')).toBeVisible();
await expect(page.getByText('07225810')).toBeVisible();

const input = page.getByPlaceholder('例如 046');
// 末 3 碼快篩:沒對中 → 明說「確定沒獎」
await input.fill('000');
await expect(page.getByRole('status')).toContainText('確定沒獎');

// 切到上一期,輸入完整 8 碼頭獎
await page.getByLabel('開獎期別').selectOption({ label: '115年 01~02月' });
await input.fill('21677046');
await expect(page.getByRole('status')).toContainText('中頭獎');
await expect(page.getByRole('status')).toContainText('200,000');
});

test('末 3 碼對中增開六獎直接確定 200 元;對中頭獎末 3 提示可升級', async ({ page }) => {
await stub(page);
await page.goto('/tools/invoice-lottery');
await waitForIslands(page);

const input = page.getByPlaceholder('例如 046');
await input.fill('985');
await expect(page.getByRole('status')).toContainText('增開六獎');

await input.fill('810'); // 頭獎 07225810 的末 3 碼
await expect(page.getByRole('status')).toContainText('六獎');
await expect(page.getByRole('status')).toContainText('繼續輸入完整 8 碼');
});

test('API 掛掉顯示錯誤並可重試', async ({ page }) => {
let calls = 0;
await page.route('**/api/invoice-numbers', (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(PERIODS) });
});
await page.goto('/tools/invoice-lottery');
await waitForIslands(page);

await expect(page.getByText('開獎號碼取得失敗,請稍後再試。')).toBeVisible();
await page.getByRole('button', { name: '重試' }).click();
await expect(page.getByText('19531471')).toBeVisible();
});
Binary file added public/og/en/invoice-lottery.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/invoice-lottery.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 @@ -49,4 +49,5 @@ export const tools: Tool[] = [
{ id: 'tw-id', title: '台灣證號', titleEn: 'Taiwan ID Validator', description: '身分證與統編檢查碼驗證,附測試號碼產生。', descriptionEn: 'Validate Taiwan national IDs and business numbers, with test generators.', category: '實用工具', path: '/tools/tw-id', icon: '🪪', status: 'available', keywords: ['身分證', '統編', '統一編號', 'id', 'gui', '驗證', 'validate', '檢查碼', 'taiwan'] },
{ id: 'hash-id', title: '雜湊 / UUID', titleEn: 'Hash / UUID', description: '文字與檔案的 SHA 雜湊,加上 UUID 批次產生。', descriptionEn: 'SHA hashes for text and files, plus batch UUID generation.', category: '文字', path: '/tools/hash-id', icon: '🔏', status: 'available', keywords: ['hash', 'sha256', 'sha1', 'uuid', 'guid', '雜湊', 'checksum'] },
{ 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', '雲端發票', '財政部'] },
];
142 changes: 142 additions & 0 deletions src/lib/__tests__/invoice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import { parseInvoiceXml, checkInvoice, PRIZE_AMOUNT, type WinningNumbers } from '../invoice';

// 財政部 invoice.xml 的實際格式(節錄自 https://invoice.etax.nat.gov.tw/invoice.xml)
const REAL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title><![CDATA[統一發票中獎號碼]]></title>
<item>
<title><![CDATA[115年 03~04月]]></title>
<link><![CDATA[https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503]]></link>
<description><![CDATA[<p>特別獎:19531471</p><p>特獎:85941329</p><p>頭獎:07225810、20231230、83518781</p>]]></description>
</item>
<item>
<title><![CDATA[115年 01~02月]]></title>
<link><![CDATA[https://www.etax.nat.gov.tw/etw-main/ETW183W2_11501]]></link>
<description><![CDATA[<p>特別獎:87510041</p><p>特獎:32220522</p><p>頭獎:21677046、44662410、31262513</p><p>增開六獎:985、951</p>]]></description>
</item>
</channel>
</rss>`;

describe('parseInvoiceXml', () => {
it('解析真實格式:期別、連結、各獎號碼', () => {
const periods = parseInvoiceXml(REAL_XML);
expect(periods).toHaveLength(2);
expect(periods[0]).toEqual({
period: '115年 03~04月',
link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503',
special: '19531471',
grand: '85941329',
first: ['07225810', '20231230', '83518781'],
sixth: [],
});
// 增開六獎有開的期別要抓到;號碼是字串,前導零不能掉
expect(periods[1].sixth).toEqual(['985', '951']);
expect(periods[1].grand).toBe('32220522');
});

it('號碼不完整的 item 直接略過,不產生半套資料', () => {
const broken = REAL_XML.replace('<p>特獎:85941329</p>', '');
const periods = parseInvoiceXml(broken);
expect(periods).toHaveLength(1);
expect(periods[0].period).toBe('115年 01~02月');
});

it('垃圾輸入回空陣列', () => {
expect(parseInvoiceXml('not xml at all')).toEqual([]);
expect(parseInvoiceXml('')).toEqual([]);
});
});

const W: WinningNumbers = {
period: '115年 01~02月',
link: '',
special: '87510041',
grand: '32220522',
first: ['21677046', '44662410', '31262513'],
sixth: ['985', '951'],
};

describe('checkInvoice(完整 8 碼)', () => {
it('特別獎:8 碼全中 1,000 萬', () => {
expect(checkInvoice('87510041', W)).toEqual({ level: 'special', amount: 10_000_000, matched: '87510041', confirmed: true, upgradable: false });
});

it('特獎:8 碼全中 200 萬', () => {
expect(checkInvoice('32220522', W)).toMatchObject({ level: 'grand', amount: 2_000_000 });
});

it('頭獎全中 20 萬;末 7~3 碼對應二獎到六獎', () => {
expect(checkInvoice('21677046', W)).toMatchObject({ level: 'first', amount: 200_000 });
expect(checkInvoice('91677046', W)).toMatchObject({ level: 'second', amount: 40_000 });
expect(checkInvoice('99677046', W)).toMatchObject({ level: 'third', amount: 10_000 });
expect(checkInvoice('99977046', W)).toMatchObject({ level: 'fourth', amount: 4_000 });
expect(checkInvoice('99997046', W)).toMatchObject({ level: 'fifth', amount: 1_000 });
expect(checkInvoice('99999046', W)).toMatchObject({ level: 'sixth', amount: 200 });
});

it('增開六獎:末 3 碼相同 200 元', () => {
expect(checkInvoice('12345985', W)).toMatchObject({ level: 'addSixth', amount: 200 });
});

it('特別獎末碼近似但非全中不算獎(特別獎只比全 8 碼)', () => {
// 末 7 碼與特別獎相同、與頭獎無關 → 沒中
expect(checkInvoice('97510041', W)).toBeNull();
});

it('多重命中取最高獎金', () => {
// 同時是頭獎末4(五獎)與增開六獎? 構造:末3=985 且末4 對中頭獎末4 —— 取五獎 1000
const w2: WinningNumbers = { ...W, first: ['11112985'], sixth: ['985'] };
expect(checkInvoice('99992985', w2)).toMatchObject({ level: 'fifth', amount: 1_000 });
});

it('沒中回 null', () => {
expect(checkInvoice('00000000', W)).toBeNull();
});
});

describe('checkInvoice(末 3~7 碼快速對獎)', () => {
it('末 3 碼對中頭獎末 3 → 至少六獎已確定,且可能升級', () => {
const hit = checkInvoice('046', W)!;
expect(hit).toMatchObject({ level: 'sixth', amount: 200, confirmed: true });
expect(hit.upgradable).toBe(true); // 補完 8 碼可能是二獎以上
});

it('末 3 碼對中增開六獎 → 200 元確定,不會再升級', () => {
const hit = checkInvoice('951', W)!;
expect(hit).toMatchObject({ level: 'addSixth', amount: 200, confirmed: true });
expect(hit.upgradable).toBe(false);
});

it('末 3 碼只對中特別獎末 3 → 未確定(特別獎要全 8 碼),confirmed false', () => {
const hit = checkInvoice('041', W)!;
expect(hit.confirmed).toBe(false);
expect(hit.level).toBe('special');
});

it('末 3 碼什麼都沒對中 → null(可以確定沒中,不用再輸入)', () => {
expect(checkInvoice('000', W)).toBeNull();
});

it('末 5 碼對中頭獎末 5 → 四獎確定、可能再升級', () => {
expect(checkInvoice('77046', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true, upgradable: true });
expect(checkInvoice('62513', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true });
});

it('少於 3 碼或含非數字 → null(無效輸入)', () => {
expect(checkInvoice('04', W)).toBeNull();
expect(checkInvoice('abc46', W)).toBeNull();
expect(checkInvoice('', W)).toBeNull();
});
});

describe('PRIZE_AMOUNT', () => {
it('獎金表符合財政部公告', () => {
expect(PRIZE_AMOUNT.special).toBe(10_000_000);
expect(PRIZE_AMOUNT.grand).toBe(2_000_000);
expect(PRIZE_AMOUNT.first).toBe(200_000);
expect(PRIZE_AMOUNT.sixth).toBe(200);
expect(PRIZE_AMOUNT.addSixth).toBe(200);
});
});
115 changes: 115 additions & 0 deletions src/lib/invoice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 統一發票對獎:財政部 invoice.xml 解析與對獎規則。純函式,
// 由 api/invoice-numbers.ts(解析)與 InvoiceLottery 島(對獎)共用。
// 號碼一律以字串處理,前導零不能掉(例:特獎 00507588)。

export interface WinningNumbers {
period: string; // 期別,如「115年 03~04月」
link: string; // 財政部該期公告頁
special: string; // 特別獎(8 碼,全中 1,000 萬)
grand: string; // 特獎(8 碼,全中 200 萬)
first: string[]; // 頭獎(8 碼三組;末 7~3 碼對應二獎~六獎)
sixth: string[]; // 增開六獎(3 碼,可能沒開)
}

export type PrizeLevel = 'special' | 'grand' | 'first' | 'second' | 'third' | 'fourth' | 'fifth' | 'sixth' | 'addSixth';

export const PRIZE_AMOUNT: Record<PrizeLevel, number> = {
special: 10_000_000,
grand: 2_000_000,
first: 200_000,
second: 40_000,
third: 10_000,
fourth: 4_000,
fifth: 1_000,
sixth: 200,
addSixth: 200,
};

/** 與頭獎共同末碼長度 → 獎級(8 碼全中即頭獎) */
const SUFFIX_LEVEL: Record<number, PrizeLevel> = {
8: 'first',
7: 'second',
6: 'third',
5: 'fourth',
4: 'fifth',
3: 'sixth',
};

export interface PrizeHit {
level: PrizeLevel;
amount: number;
matched: string; // 對中的官方號碼
confirmed: boolean; // 以目前輸入位數已確定中此獎;false = 需要完整 8 碼才能確認(特別獎/特獎)
upgradable: boolean; // 再多輸入幾碼有機會升到更高獎級
}

export function parseInvoiceXml(xml: string): WinningNumbers[] {
const items = xml.match(/<item>[\s\S]*?<\/item>/g) ?? [];
const out: WinningNumbers[] = [];
for (const item of items) {
const cdata = (tag: string) => item.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`))?.[1]?.trim() ?? '';
const desc = cdata('description');
const period = cdata('title');
const special = desc.match(/特別獎:(\d{8})/)?.[1];
const grand = desc.match(/特獎:(\d{8})/)?.[1];
const first = desc.match(/頭獎:([\d、,\s]+)/)?.[1]?.match(/\d{8}/g) ?? [];
const sixth = desc.match(/增開六獎:([\d、,\s]+)/)?.[1]?.match(/\d{3}/g) ?? [];
// 缺任何必要獎項就整期略過,寧缺勿錯
if (!period || !special || !grand || first.length === 0) continue;
out.push({ period, link: cdata('link'), special, grand, first, sixth });
}
return out;
}

function commonSuffixLen(a: string, b: string): number {
let n = 0;
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++;
return n;
}

/**
* 對獎。輸入發票號碼的「末 3~8 碼」:
* - 8 碼:完整判定(特別獎/特獎要全中;頭獎系列取最長共同末碼;增開六獎比末 3)。
* - 3~7 碼:能確定的獎(頭獎末碼系列、增開六獎)直接判定;
* 只有特別獎/特獎的末碼吻合時回未確認命中(confirmed: false),提示補完 8 碼。
* - 什麼都沒對中回 null —— 末 3 碼就沒中的發票可以直接確定沒獎。
*/
export function checkInvoice(digits: string, w: WinningNumbers): PrizeHit | null {
if (!/^\d{3,8}$/.test(digits)) return null;
const k = digits.length;
const full = k === 8;

const confirmed: PrizeHit[] = [];

if (full && digits === w.special) confirmed.push({ level: 'special', amount: PRIZE_AMOUNT.special, matched: w.special, confirmed: true, upgradable: false });
if (full && digits === w.grand) confirmed.push({ level: 'grand', amount: PRIZE_AMOUNT.grand, matched: w.grand, confirmed: true, upgradable: false });

// 頭獎系列:共同末碼長度不能超過已輸入的位數;部分輸入時整段吻合(L === k)才有升級空間
let anyFullTailMatch = false;
for (const f of w.first) {
const L = Math.min(commonSuffixLen(digits, f), k);
if (L < 3) continue;
if (!full && L === k) anyFullTailMatch = true;
confirmed.push({ level: SUFFIX_LEVEL[L], amount: PRIZE_AMOUNT[SUFFIX_LEVEL[L]], matched: f, confirmed: true, upgradable: false });
}

const last3 = digits.slice(-3);
if (w.sixth.includes(last3)) confirmed.push({ level: 'addSixth', amount: PRIZE_AMOUNT.addSixth, matched: last3, confirmed: true, upgradable: false });

// 特別獎/特獎:未滿 8 碼只能算「有機會」,要補完才能確認
const potential: PrizeHit[] = [];
if (!full) {
if (w.special.endsWith(digits)) potential.push({ level: 'special', amount: PRIZE_AMOUNT.special, matched: w.special, confirmed: false, upgradable: false });
if (w.grand.endsWith(digits)) potential.push({ level: 'grand', amount: PRIZE_AMOUNT.grand, matched: w.grand, confirmed: false, upgradable: false });
}

const best = (hits: PrizeHit[]) => hits.reduce((a, b) => (b.amount > a.amount ? b : a));

if (confirmed.length > 0) {
const hit = best(confirmed);
hit.upgradable = !full && (anyFullTailMatch || potential.length > 0);
return hit;
}
if (potential.length > 0) return best(potential);
return null;
}
12 changes: 12 additions & 0 deletions src/pages/en/tools/invoice-lottery.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import InvoiceLottery from '../../../tools/InvoiceLottery.tsx';
import { tools } from '../../../data/tools';

const tool = tools.find((t) => t.id === 'invoice-lottery')!;
---
<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>
<InvoiceLottery client:visible locale="en" />
</BaseLayout>
Loading
Loading