Spaces:
Running
Running
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>博客來書單抓取工具</title> | |
| <!-- 載入 Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| /* 使用 Inter 字體 */ | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| } | |
| /* 讓 textarea 有基本的樣式 */ | |
| textarea { | |
| font-family: monospace; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen flex items-center justify-center p-4"> | |
| <div class="bg-white p-8 rounded-lg shadow-xl w-full max-w-4xl"> | |
| <h1 class="text-3xl font-bold text-center text-blue-600 mb-6">博客來書單抓取工具</h1> | |
| <!-- 步驟說明 --> | |
| <div class="mb-6 bg-blue-50 border border-blue-200 p-4 rounded-lg text-blue-700"> | |
| <h2 class="font-bold text-lg mb-2">如何使用:</h2> | |
| <ol class="list-decimal list-inside space-y-1"> | |
| <li>在瀏覽器中打開您要抓取的博客來書籍頁面。</li> | |
| <li>在頁面空白處按右鍵,選擇「<b>檢視網頁原始碼</b>」(View Page Source)。</li> | |
| <li>複製「所有」的網頁原始碼 (通常是 Ctrl+A, Ctrl+C)。</li> | |
| <li>將原始碼貼到下方的「網頁原始碼」輸入框中。</li> | |
| <li>點擊「<b>解析並加入清單</b>」按鈕。</li> | |
| </ol> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <!-- 左側:輸入區域 --> | |
| <div> | |
| <label for="sourceHtml" class="block text-sm font-medium text-gray-700 mb-2"> | |
| 1. 貼入網頁原始碼 (HTML): | |
| </label> | |
| <textarea id="sourceHtml" rows="15" class="w-full p-3 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" placeholder="請在此貼上博客來網頁的完整原始碼..."></textarea> | |
| <div class="mt-4 flex space-x-2"> | |
| <button id="parseButton" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 font-semibold shadow-md transition-colors"> | |
| 解析並加入清單 | |
| </button> | |
| <button id="clearSourceButton" class="w-1/3 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 font-semibold shadow-md transition-colors"> | |
| 清除 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 右側:結果區域 --> | |
| <div> | |
| <label for="resultList" class="block text-sm font-medium text-gray-700 mb-2"> | |
| 2. 您的購書清單 (CSV 格式): | |
| </label> | |
| <textarea id="resultList" rows="15" class="w-full p-3 border border-gray-300 rounded-md shadow-sm bg-gray-50" readonly placeholder="解析結果將會顯示在這裡..."></textarea> | |
| <div class="mt-4 flex space-x-2"> | |
| <button id="copyButton" class="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 font-semibold shadow-md transition-colors"> | |
| 複製清單 | |
| </button> | |
| <!-- *** 新增匯出按鈕 *** --> | |
| <button id="exportCsvButton" class="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 font-semibold shadow-md transition-colors"> | |
| 匯出 CSV | |
| </button> | |
| <button id="clearListButton" class="w-1/3 bg-red-500 text-white py-2 px-4 rounded-md hover:bg-red-600 font-semibold shadow-md transition-colors"> | |
| 清空 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 訊息提示框 --> | |
| <div id="messageBox" class="hidden fixed top-5 right-5 p-4 rounded-md shadow-lg transition-all"> | |
| <span id="messageText"></span> | |
| </div> | |
| </div> | |
| <script> | |
| const sourceHtmlEl = document.getElementById('sourceHtml'); | |
| const parseButtonEl = document.getElementById('parseButton'); | |
| const clearSourceButtonEl = document.getElementById('clearSourceButton'); | |
| const resultListEl = document.getElementById('resultList'); | |
| const copyButtonEl = document.getElementById('copyButton'); | |
| <!-- *** 取得新按鈕 *** --> | |
| const exportCsvButtonEl = document.getElementById('exportCsvButton'); | |
| const clearListButtonEl = document.getElementById('clearListButton'); | |
| const messageBoxEl = document.getElementById('messageBox'); | |
| const messageTextEl = document.getElementById('messageText'); | |
| // 顯示提示訊息 | |
| function showMessage(text, type = 'success') { | |
| messageTextEl.textContent = text; | |
| if (type === 'success') { | |
| messageBoxEl.className = 'fixed top-5 right-5 p-4 rounded-md shadow-lg bg-green-500 text-white transition-all'; | |
| } else { | |
| messageBoxEl.className = 'fixed top-5 right-5 p-4 rounded-md shadow-lg bg-red-500 text-white transition-all'; | |
| } | |
| messageBoxEl.classList.remove('hidden'); | |
| setTimeout(() => { | |
| messageBoxEl.classList.add('hidden'); | |
| }, 3000); | |
| } | |
| // 解析 HTML 並抓取資料 | |
| function parseHtml() { | |
| const htmlString = sourceHtmlEl.value; | |
| if (!htmlString) { | |
| showMessage('原始碼為空!', 'error'); | |
| return; | |
| } | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(htmlString, 'text/html'); | |
| // *** 新增:優先解析 meta description *** | |
| const metaDescContent = doc.querySelector('meta[name="description"]')?.getAttribute('content') || ''; | |
| const metaData = {}; | |
| if (metaDescContent) { | |
| // 使用全形逗號 ',' 分割 | |
| const parts = metaDescContent.split(','); | |
| parts.forEach(part => { | |
| // 使用全形冒號 ':' 分割 | |
| const pieces = part.split(':'); | |
| if (pieces.length === 2) { | |
| const key = pieces[0].trim(); // e.g., "作者" | |
| const value = pieces[1].trim(); // e.g., "款款" | |
| metaData[key] = value; | |
| } | |
| }); | |
| } | |
| // *** meta description 解析完畢 *** | |
| // 1. 書名 (og:title 優先,metaData['書名'] 為備案) | |
| const title = doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || metaData['書名'] || '未找到書名'; | |
| // 輔助函數:從 li 列表中尋找特定開頭的項目 | |
| const allListItems = Array.from(doc.querySelectorAll('li')); | |
| const findLiText = (prefix) => { | |
| const li = allListItems.find(item => item.textContent.trim().startsWith(prefix)); | |
| return li ? li : null; | |
| }; | |
| // 2. 作者/譯者 | |
| // *** 修正:優先從 metaData 抓取 *** | |
| let author = metaData['作者'] || ''; | |
| let translator = metaData['譯者'] || ''; | |
| // 如果 metaData 找不到作者,才使用 li 尋找 | |
| if (!author) { | |
| const authorLi = findLiText('作者:'); | |
| // *** 修正:改用更穩健的方式抓取作者 *** | |
| if (authorLi) { | |
| const authorLinks = Array.from(authorLi.querySelectorAll('a')); | |
| if (authorLinks.length > 0) { | |
| // 方案 A:抓取所有 <a> 標籤的文字,用 / 分隔 | |
| author = authorLinks.map(a => a.textContent.trim()).join('/'); | |
| } else { | |
| // 方案 B:如果沒有 <a>,直接取 li 的文字並移除前綴 | |
| author = authorLi.textContent.replace('作者:', '').trim(); | |
| } | |
| } | |
| } | |
| // 如果 metaData 找不到譯者,才使用 li 尋找 | |
| if (!translator) { | |
| const translatorLi = findLiText('譯者:'); | |
| // *** 修正:改用更穩健的方式抓取譯者 *** | |
| if (translatorLi) { | |
| const translatorLinks = Array.from(translatorLi.querySelectorAll('a')); | |
| if (translatorLinks.length > 0) { | |
| translator = translatorLinks.map(a => a.textContent.trim()).join('/'); | |
| } else { | |
| translator = translatorLi.textContent.replace('譯者:', '').trim(); | |
| } | |
| } | |
| } | |
| // *** 修正點:將 const 改為 let *** | |
| let authorTranslator = [author, translator].filter(Boolean).join('/'); | |
| if (!authorTranslator) { | |
| authorTranslator = '未找到作者'; | |
| } | |
| // 3. 出版社 | |
| // *** 修正:優先從 metaData 抓取 *** | |
| let publisher = metaData['出版社'] || ''; | |
| if (!publisher) { | |
| const publisherLi = findLiText('出版社:'); | |
| publisher = publisherLi?.querySelector('a')?.textContent.trim() || '未找到出版社'; | |
| } | |
| // 4. ISBN | |
| // *** 修正:優先從 metaData 抓取 *** | |
| let isbn = metaData['ISBN'] || ''; | |
| if (!isbn) { | |
| const isbnLi = findLiText('ISBN:'); | |
| isbn = isbnLi ? isbnLi.textContent.replace('ISBN:', '').trim() : '未找到ISBN'; | |
| } | |
| // 5. 數量 | |
| const quantity = 1; | |
| // 6. 定價 (*** 修正:改為優先抓取 "定價",而不是優惠價 ***) | |
| let price = '未找到價格'; | |
| const priceLi = findLiText('定價:'); | |
| if (priceLi) { | |
| // 抓取 li 內的文字,例如 "定價:$450" 或 "定價:450元" | |
| price = priceLi.textContent | |
| .replace('定價:', '') // 移除 "定價:" | |
| .replace('$', '') // 移除 $ 符號 | |
| .replace('元', '') // 移除 "元" | |
| .trim(); // 移除空白 | |
| } else { | |
| // 備用方案 (如果找不到 "定價:" li):嘗試抓取 meta tag (可能是優惠價) | |
| const metaPrice = doc.querySelector('meta[property="product:price:amount"]')?.getAttribute('content'); | |
| if (metaPrice) { | |
| price = metaPrice; | |
| } | |
| // (移除了 .price_e strong b 的備用方案,因為那確定是優惠價) | |
| } | |
| // *** 修正點:將 ISBN 格式化以防止 Excel 轉為科學記號 *** | |
| // 透過 `="<ISBN>"` 格式強制 Excel 將其視為文字 | |
| const excelSafeIsbn = `="${isbn}"`; | |
| // 組合 CSV 字串 | |
| const csvRow = [title, authorTranslator, publisher, excelSafeIsbn, quantity, price].join(','); | |
| // 加入到結果列表 | |
| const currentList = resultListEl.value; | |
| resultListEl.value = currentList ? (currentList + '\n' + csvRow) : csvRow; | |
| // 清空來源框 | |
| sourceHtmlEl.value = ''; | |
| showMessage('成功解析並加入!', 'success'); | |
| } catch (error) { | |
| console.error('解析失敗:', error); | |
| showMessage('解析失敗,請確認貼上完整的 HTML 原始碼。', 'error'); | |
| } | |
| } | |
| // 複製到剪貼簿 | |
| function copyToClipboard() { | |
| const text = resultListEl.value; | |
| if (!text) { | |
| showMessage('清單是空的!', 'error'); | |
| return; | |
| } | |
| // 使用 document.execCommand (因為 navigator.clipboard 在 iframe 中可能受限) | |
| const textArea = document.createElement('textarea'); | |
| textArea.value = text; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| try { | |
| document.execCommand('copy'); | |
| showMessage('已複製到剪貼簿!', 'success'); | |
| } catch (err) { | |
| showMessage('複製失敗。', 'error'); | |
| } | |
| document.body.removeChild(textArea); | |
| } | |
| // *** 新增匯出 CSV 函式 *** | |
| function exportAsCsv() { | |
| const text = resultListEl.value; | |
| if (!text) { | |
| showMessage('清單是空的!', 'error'); | |
| return; | |
| } | |
| // 加上 CSV 標頭 | |
| const header = "書名,作者/譯者,出版社,ISBN,數量,定價\n"; | |
| // 加上 BOM (UFEFF) 確保 Excel 正確讀取 UTF-8 (尤其是中文) | |
| const bom = "\ufeff"; | |
| const csvContent = bom + header + text; | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| // 產生下載連結 | |
| const link = document.createElement("a"); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute("href", url); | |
| // 產生檔案名稱 (包含日期時間) | |
| const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-').replace('T', '_'); | |
| link.setAttribute("download", `book_list_${timestamp}.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| showMessage('CSV 檔案已開始下載!', 'success'); | |
| } | |
| // 綁定事件 | |
| parseButtonEl.addEventListener('click', parseHtml); | |
| clearSourceButtonEl.addEventListener('click', () => { | |
| sourceHtmlEl.value = ''; | |
| showMessage('已清除原始碼。', 'success'); | |
| }); | |
| copyButtonEl.addEventListener('click', copyToClipboard); | |
| <!-- *** 綁定新按鈕事件 *** --> | |
| exportCsvButtonEl.addEventListener('click', exportAsCsv); | |
| clearListButtonEl.addEventListener('click', () => { | |
| resultListEl.value = ''; | |
| showMessage('已清空列表。', 'success'); | |
| }); | |
| </script> | |
| </body> | |
| </html> |