雙版本自主佈署解決方案,提供台鐵購票證明的快速查詢、自訂重命名與一鍵下載服務。
直接安裝於 Chrome 瀏覽器內。無須設定中轉伺服器,100% 透過本機網路連線下載,極高隱私與下載速度。
使用個人的 Google Apps Script 作為安全中轉節點。只要開啟網址即可使用,支援電腦與手機行動裝置。
chrome://extensions/ 並按下 Enter 進入外掛管理頁面。為了保護您的敏感資料(如身分證字號),本方案不提供公共服務器,而是請您佈署於您個人 Google 帳戶的 GAS (Google Apps Script) 上,設定過程僅需 30 秒:
/**
* 銀河ERP - 台鐵憑證下載助手 GAS 後端
*
* 系統浮水印:Falo x Force Cheng 2026/6/22
*
* 部署指引:
* 1. 瀏覽網頁 https://sheets.new 建立一個新的 Google 試算表 (Google Sheet)。
* 2. 點選選單列的「擴充功能 (Extensions)」➔「Apps Script」進入編輯器。
* 3. 將此檔案的所有內容複製,並貼入程式碼編輯器中(覆蓋預設的 Code.gs 內容)。
* 4. 點選右上角「部署」->「新部署」。
* 5. 點選左側齒輪,選擇「網頁應用程式 (Web App)」。
* 6. 設定:
* - 說明:銀河ERP 台鐵憑證下載服務 (Falo x Force Cheng)
* - 執行身分:您的 Google 帳戶 (Me)
* - 誰能存取:任何人 (Anyone)
* 7. 點選「部署」,授權存取。
* 8. 複製產生的「網頁應用程式 URL」,將其貼入您的網頁版用戶端 (gas_client.html) 中使用。
*/
function doGet(e) {
return handleRequest(e);
}
function doPost(e) {
return handleRequest(e);
}
function handleRequest(e) {
var params = e.parameter || {};
if (e.postData && e.postData.contents) {
try {
var body = JSON.parse(e.postData.contents);
params = Object.assign({}, params, body);
} catch(err) {}
}
var pid = params.pid;
var recNo = params.recNo;
if (!pid || !recNo) {
return makeJsonResponse({ success: false, error: "缺少必要參數" });
}
try {
var result = downloadTraPdf(pid, recNo);
return makeJsonResponse(result);
} catch (error) {
return makeJsonResponse({ success: false, error: error.message || "伺服器內部錯誤" });
}
}
function makeJsonResponse(obj) {
var output = ContentService.createTextOutput(JSON.stringify(obj));
output.setMimeType(ContentService.MimeType.JSON);
return output;
}
function downloadTraPdf(pid, recNo) {
var userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
var cookies = "";
// 1. GET CSRF
var queryUrl = "https://tip.railway.gov.tw/tra-tip-web/tip/tip001/tip115/query";
var res1 = UrlFetchApp.fetch(queryUrl, {
method: "get",
headers: { "User-Agent": userAgent },
muteHttpExceptions: true
});
if (res1.getResponseCode() !== 200) throw new Error("無法連線至台鐵官方網站");
var headers1 = res1.getAllHeaders();
cookies = mergeCookies(cookies, headers1['Set-Cookie'] || headers1['set-cookie']);
var html1 = res1.getContentText();
var csrfMatch = html1.match(/name="_csrf" value="([^"]+)"/);
if (!csrfMatch) throw new Error("解析安全標記 (CSRF 1) 失敗");
var csrfToken = csrfMatch[1];
// 2. POST queryHistory
var historyUrl = "https://tip.railway.gov.tw/tra-tip-web/tip/tip001/tip115/queryHistory";
var payload2 = {
"_csrf": csrfToken,
"custIdTypeEnum": "PERSON_ID",
"pid": pid,
"queryMethod": "ORD_NO",
"recNo": recNo,
"rideDate": "",
"startStation": "",
"endStation": ""
};
var res2 = UrlFetchApp.fetch(historyUrl, {
method: "post",
headers: {
"User-Agent": userAgent,
"Cookie": cookies,
"Referer": queryUrl,
"Origin": "https://tip.railway.gov.tw"
},
payload: payload2,
muteHttpExceptions: true
});
if (res2.getResponseCode() !== 200) throw new Error("向台鐵提交查詢失敗");
var headers2 = res2.getAllHeaders();
cookies = mergeCookies(cookies, headers2['Set-Cookie'] || headers2['set-cookie']);
var html2 = res2.getContentText();
if (html2.indexOf("無訂票記錄") !== -1 || html2.indexOf("查無訂單") !== -1 || html2.indexOf("實付金額") === -1) {
throw new Error("台鐵伺服器查無此訂票紀錄");
}
// 3. Parse download path & CSRF
var actionMatch = html2.match(/action="([^"]+purchaseDownload[^"]+)"/);
if (!actionMatch) throw new Error("解析下載連結失敗");
var downloadUrl = "https://tip.railway.gov.tw" + actionMatch[1];
var afterActionHtml = html2.substring(html2.indexOf(actionMatch[0]));
var downloadCsrfMatch = afterActionHtml.match(/name="_csrf" value="([^"]+)"/);
var downloadCsrf = downloadCsrfMatch ? downloadCsrfMatch[1] : csrfToken;
// 4. POST download PDF
var payload3 = { "_csrf": downloadCsrf, "pid": pid, "recNo": recNo };
var res3 = UrlFetchApp.fetch(downloadUrl, {
method: "post",
headers: {
"User-Agent": userAgent,
"Cookie": cookies,
"Referer": historyUrl,
"Origin": "https://tip.railway.gov.tw"
},
payload: payload3,
muteHttpExceptions: true
});
if (res3.getResponseCode() !== 200) throw new Error("取得 PDF 串流失敗");
var contentType = res3.getHeaders()['Content-Type'] || res3.getHeaders()['content-type'] || "";
var pdfBlob = res3.getBlob();
var bytes = pdfBlob.getBytes();
if (contentType.indexOf("application/pdf") === -1 && (bytes.length < 4 || (bytes[0] !== 0x25 || bytes[1] !== 0x50 || bytes[2] !== 0x44 || bytes[3] !== 0x46))) {
throw new Error("台鐵未返回正確的 PDF 格式憑證");
}
var base64Data = Utilities.base64Encode(bytes);
return { success: true, pdfBase64: base64Data };
}
function mergeCookies(existingCookies, newCookieHeader) {
if (!newCookieHeader) return existingCookies;
var cookieMap = {};
if (existingCookies) {
var pairs = existingCookies.split(';');
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].trim();
if (!pair) continue;
var eqIdx = pair.indexOf('=');
if (eqIdx > 0) cookieMap[pair.substring(0, eqIdx)] = pair.substring(eqIdx + 1);
}
}
var rawCookies = Array.isArray(newCookieHeader) ? newCookieHeader : [newCookieHeader];
for (var i = 0; i < rawCookies.length; i++) {
var parts = rawCookies[i].split(';');
var mainPart = parts[0].trim();
if (!mainPart) continue;
var eqIdx = mainPart.indexOf('=');
if (eqIdx > 0) cookieMap[mainPart.substring(0, eqIdx)] = mainPart.substring(eqIdx + 1);
}
var cookieList = [];
for (var key in cookieMap) cookieList.push(key + '=' + cookieMap[key]);
return cookieList.join('; ');
}
.html 檔案(例如 my_client.html)雙擊執行。<!--
銀河ERP - 台鐵憑證網頁下載版 (GAS)
系統浮水印:Falo x Force Cheng 2026/6/22
-->
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>銀河ERP - 台鐵憑證網頁下載版 (GAS)</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #080c14;
--card-bg: rgba(30, 41, 59, 0.45);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent-primary: #6366f1; /* Indigo */
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25);
--accent-amber: #d97706; /* Amber */
--accent-amber-hover: #b45309;
--success-color: #10b981;
--error-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', 'Noto Sans TC', sans-serif;
background-color: var(--bg-color);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.1) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(217, 119, 6, 0.06) 0px, transparent 50%);
color: var(--text-primary);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.container {
width: 100%;
max-width: 460px;
background: var(--card-bg);
border: 1px solid var(--border-color);
backdrop-filter: blur(16px);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.header {
margin-bottom: 1.75rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
position: relative;
}
.back-link {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.8rem;
transition: color 0.2s ease;
margin-bottom: 0.5rem;
}
.back-link:hover {
color: var(--text-primary);
}
.header h2 {
font-size: 1.4rem;
font-weight: 700;
background: linear-gradient(to right, #818cf8, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-top: 1.5rem;
margin-bottom: 0.25rem;
}
.header p {
color: var(--text-secondary);
font-size: 0.85rem;
}
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.4rem;
font-weight: 500;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
input {
width: 100%;
padding: 0.65rem 0.85rem;
border-radius: 6px;
border: 1px solid var(--border-color);
background: rgba(15, 23, 42, 0.6);
color: var(--text-primary);
font-size: 0.9rem;
transition: all 0.2s ease;
}
.input-wrapper input {
padding-right: 2.25rem;
}
input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input::placeholder {
color: rgba(148, 163, 184, 0.4);
}
.icon-btn {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.icon-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.gas-save-info {
font-size: 0.75rem;
color: var(--success-color);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
.gas-save-info.show {
opacity: 0.8;
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 6px;
background: var(--accent-amber);
color: var(--text-primary);
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(217, 119, 6, 0.15);
margin-top: 0.5rem;
}
.btn:hover {
background: var(--accent-amber-hover);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
background: rgba(217, 119, 6, 0.4);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn.loading {
background: #b45309 !important;
cursor: wait !important;
}
#status {
margin-top: 1.25rem;
font-size: 0.85rem;
padding: 0.75rem;
border-radius: 6px;
line-height: 1.4;
display: none;
}
#status.info {
display: block;
background: rgba(56, 189, 248, 0.08);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.15);
}
#status.success {
display: block;
background: rgba(16, 185, 129, 0.08);
color: var(--success-color);
border: 1px solid rgba(16, 185, 129, 0.15);
}
#status.error {
display: block;
background: rgba(239, 68, 68, 0.08);
color: var(--error-color);
border: 1px solid rgba(239, 68, 68, 0.15);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="index.html" class="back-link">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回說明首頁
</a>
<h2>台鐵憑證網頁下載版 (GAS)</h2>
<p>使用您的個人 Google Apps Script 節點進行安全下載</p>
</div>
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
<label for="gasUrl" style="margin-bottom: 0;">Google Apps Script Web App 網址</label>
<a href="#" id="loadDemoUrlBtn" style="color: var(--accent-primary); font-size: 0.75rem; text-decoration: none; font-weight: 500;">[ 帶入測試示範網址 ]</a>
</div>
<input type="text" id="gasUrl" placeholder="https://script.google.com/macros/s/.../exec" autocomplete="off">
<div id="gasSaveInfo" class="gas-save-info">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
已自動安全儲存至本機,下次無須再輸入
</div>
</div>
<div class="form-group">
<label for="pid">身分證字號 / 統一編號</label>
<div class="input-wrapper">
<input type="password" id="pid" placeholder="例如:A123456789" autocomplete="off">
<button id="togglePidBtn" type="button" class="icon-btn" title="顯示/隱藏身分證">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="form-group">
<label for="recNo">訂票電腦代碼</label>
<input type="text" id="recNo" placeholder="例如:9310417" autocomplete="off">
</div>
<div class="form-group">
<label for="filename">存檔名稱</label>
<input type="text" id="filename" placeholder="例如:TRA_Ticket_9310417.pdf" value="TRA_Ticket_代碼.pdf" autocomplete="off">
</div>
<button id="downloadBtn" class="btn">連線並下載憑證</button>
<div id="status"></div>
</div>
<script>
const gasUrlInput = document.getElementById('gasUrl');
const loadDemoUrlBtn = document.getElementById('loadDemoUrlBtn');
const gasSaveInfo = document.getElementById('gasSaveInfo');
const pidInput = document.getElementById('pid');
const recNoInput = document.getElementById('recNo');
const filenameInput = document.getElementById('filename');
const togglePidBtn = document.getElementById('togglePidBtn');
const downloadBtn = document.getElementById('downloadBtn');
const statusDiv = document.getElementById('status');
// 0. 密碼解鎖並自動填入示範 URL
loadDemoUrlBtn.addEventListener('click', (e) => {
e.preventDefault();
const pwd = prompt("請輸入解鎖密碼以帶入示範網址:");
if (pwd === "falo") {
const demoUrl = "https://script.google.com/macros/s/AKfycbxHbc_RKfVg9EI9tlBX454U5V9Pna5IuUC65T68eqtqVPllSEg9pITEuInhLWkwuzVyHQ/exec";
gasUrlInput.value = demoUrl;
localStorage.setItem('tra_gas_url', demoUrl);
gasSaveInfo.classList.add('show');
alert("解鎖成功,已成功帶入示範網址!");
} else if (pwd !== null) {
alert("密碼錯誤,無法解鎖示範網址。");
}
});
// 1. 初始化讀取 LocalStorage 中的 GAS 網址
if (localStorage.getItem('tra_gas_url')) {
gasUrlInput.value = localStorage.getItem('tra_gas_url');
gasSaveInfo.classList.add('show');
}
// 監聽 GAS 網址變更以儲存
gasUrlInput.addEventListener('input', () => {
const url = gasUrlInput.value.trim();
if (url) {
localStorage.setItem('tra_gas_url', url);
gasSaveInfo.classList.add('show');
} else {
localStorage.removeItem('tra_gas_url');
gasSaveInfo.classList.remove('show');
}
});
// 2. 身分證顯示切換
togglePidBtn.addEventListener('click', () => {
const isPassword = pidInput.type === 'password';
pidInput.type = isPassword ? 'text' : 'password';
togglePidBtn.innerHTML = isPassword
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-off"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
});
// 3. 電腦代碼輸入連動檔名
recNoInput.addEventListener('input', () => {
const code = recNoInput.value.trim();
filenameInput.value = `TRA_Ticket_${code || '代碼'}.pdf`;
});
// 4. 下載邏輯
downloadBtn.addEventListener('click', async () => {
const gasUrl = gasUrlInput.value.trim();
const pid = pidInput.value.trim();
const recNo = recNoInput.value.trim();
const filename = filenameInput.value.trim() || `TRA_Ticket_${recNo}.pdf`;
if (!gasUrl) {
showStatus("請輸入您的 Google Apps Script Web App 網址", "error");
return;
}
if (!pid || !recNo) {
showStatus("請填寫身分證字號與訂票電腦代碼", "error");
return;
}
const originalText = downloadBtn.innerText;
downloadBtn.disabled = true;
downloadBtn.classList.add('loading');
downloadBtn.innerText = "查詢下載中,請稍候...";
showStatus("正在連線至您的 GAS 伺服器節點...", "info");
try {
// 發送 POST 請求給 GAS
const response = await fetch(gasUrl, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'text/plain;charset=utf-8' // 使用 text/plain 避免觸發 preflight CORS 限制
},
body: JSON.stringify({ pid, recNo })
});
if (!response.ok) {
throw new Error(`GAS 節點回應錯誤 (代碼: ${response.status})`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "GAS 轉接下載失敗");
}
showStatus("成功取得資料,正在本機還原 PDF 檔案...", "info");
// 將 Base64 解密為 Blob 物件
const pdfBlob = base64ToBlob(data.pdfBase64, 'application/pdf');
const downloadUrl = URL.createObjectURL(pdfBlob);
// 建立隱藏連結觸發下載
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
showStatus("✓ 憑證下載成功並已自訂命名存檔!", "success");
} catch (error) {
showStatus(`連線錯誤: ${error.message}<br><small style="opacity: 0.7;">請確認您的 GAS 部署網址是否正確,且權限設為「任何人 (Anyone)」</small>`, "error");
} finally {
downloadBtn.disabled = false;
downloadBtn.classList.remove('loading');
downloadBtn.innerText = originalText;
}
});
// 狀態顯示輔助
function showStatus(message, type) {
statusDiv.className = type;
statusDiv.innerHTML = message;
}
// Base64 還原 Blob
function base64ToBlob(base64, mime) {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mime });
}
</script>
</body>
</html>