銀河ERP - 憑證下載助手 Hub

雙版本自主佈署解決方案,提供台鐵購票證明的快速查詢、自訂重命名與一鍵下載服務。

Version A (外掛版)

Chrome Extension 本機外掛

直接安裝於 Chrome 瀏覽器內。無須設定中轉伺服器,100% 透過本機網路連線下載,極高隱私與下載速度。

  • 隱私防護極高,資料不經第三方
  • 本機直連台鐵官網,速度最快
  • 支援自動命名存檔與下載
下載 Chrome 外掛 ZIP 包
Version B (網頁版)

GAS + Web 網頁免安裝版

使用個人的 Google Apps Script 作為安全中轉節點。只要開啟網址即可使用,支援電腦與手機行動裝置。

  • 免裝外掛與軟體,開網頁即用
  • 支援跨裝置、手機端下載更名
  • 完全代碼開源,自行佈署節點
前往網頁版下載介面

方案 A:Chrome 本機外掛安裝說明

  1. 點擊上方按鈕下載 tra_downloader_extension.zip 壓縮包並將其解壓縮到本機資料夾。
  2. 在 Chrome 瀏覽器網址列輸入 chrome://extensions/ 並按下 Enter 進入外掛管理頁面。
  3. 開啟右上角的「開發人員模式」開關。
  4. 點擊左上角「載入未封裝項目」按鈕,選擇剛才解壓縮出來的外掛資料夾。
  5. 完成!在 Chrome 擴充功能列釘選「銀河ERP - 憑證下載器」,點開即可直接下載。

方案 B:GAS 後端節點佈署指引

為了保護您的敏感資料(如身分證字號),本方案不提供公共服務器,而是請您佈署於您個人 Google 帳戶的 GAS (Google Apps Script) 上,設定過程僅需 30 秒:

  1. 開啟瀏覽器造訪網址: https://sheets.new 建立一個新的 Google 試算表 (Google Sheet)。
  2. 點選上方選單列的「擴充功能 (Extensions)」➔「Apps Script」以開啟專案編輯器。
  3. 複製下方代碼框中的完整程式碼,並貼上覆蓋編輯器內原有的預設內容。
  4. 點擊右上角的「部署」 ➔ 「新部署」。
  5. 在選單中點選左上方齒輪圖示,選擇「網頁應用程式 (Web App)」。
  6. 設定部署設定:
    • 執行身分:選擇「我 (您的Google帳號)
    • 誰能存取:選擇「任何人 (Anyone)
  7. 點選「部署」按鈕。此時會要求授權您的 Google 帳戶,請點選核准以提供連線存取。
  8. 部署成功後,複製產生的「網頁應用程式 URL」網址。
  9. 點選上面的「前往網頁版下載介面」按鈕,在網址欄貼上您複製的 URL 後,即可開始下載。
gas_backend.js
下載 code.gs
/**
 * 銀河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('; ');
}
🔒 資訊安全與隱私防護重要提醒 (Watermark: Falo x Force Cheng 2026/6/22) 基於資安與防護考量(保護您的身分證字號與訂票電腦代碼),本專案將後端程式碼與前端網頁完全開源且透明。我們強烈建議您複製下方的「用戶端網頁 (gas_client.html)」程式碼,並於本機另存為一個 .html 檔案(例如 my_client.html)雙擊執行。
這可以 100% 確保您的敏感個資僅在您本機的瀏覽器與您個人擁有的 Google Apps Script 帳號間直接傳送,絕無任何中間人儲存或第三方資料外洩風險!
gas_client.html (用戶端網頁程式碼)
下載 ticket_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>