Stock / index.html
Joey889's picture
Update index.html
e191679 verified
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>股票殖利率查詢 (HTML5 版)</title>
<!-- Chosen Palette: Warm Neutrals -->
<!-- Application Structure Plan:
1. Header: 應用程式標題。
2. Search Area: 一個輸入框和搜尋按鈕,用於用戶輸入關鍵字。
3. History Area: 顯示在 localStorage 中儲存的搜尋記錄,可點擊回填。
4. Status Area: 顯示當前狀態 (載入中、載入完成、錯誤、搜尋結果)。
5. Results Area: 一個響應式表格 (Table),用於顯示從 Wespai 爬取並過濾後的資料。
User Flow:
1. 頁面載入時,JS 自動觸發 `fetchData()`。
2. `fetchData()` 透過 CORS 代理抓取 Wespai 的 HTML。
3. `parseHTMLTable()` 解析 HTML 字串,將表格轉換為 JS 物件陣列 `allStockData`。
4. 同時,`loadHistory()` 從 localStorage 讀取並顯示搜尋記錄。
5. 用戶輸入關鍵字點擊 "搜尋"。
6. `performSearch()` 過濾 `allStockData` 陣列。
7. `renderTable()` 將過濾後的結果動態渲染到表格中。
8. `addHistory()` 將新關鍵字儲存到 localStorage 並更新顯示。
Why this structure: 這是最直接的單頁應用 (SPA) 結構,將 "輸入" (搜尋框)、"狀態" (訊息欄) 和 "輸出" (表格) 清晰分離,並為 "搜尋記錄" 提供了專用區域,符合用戶的互動直覺。
-->
<!-- Visualization & Content Choices:
- Report Info: 來自 Wespai 的股票表格資料 (代號, 公司, 殖利率等)。
- Goal: 組織 (Organize) / 通知 (Inform)。
- Viz/Presentation Method: 互動式 HTML 表格。
- Interaction:
1. Page Load: 透過 CORS 代理異步獲取 Wespai 原始 HTML。
2. JS Parsing: 將 HTML Table 轉換為 JS Array of Objects (allStockData)。
3. Keyword Filter: 用戶輸入關鍵字 (代號或公司名稱),JS 使用 `.filter()` 篩選 allStockData。
4. Dynamic Render: `renderTable()` 函數清空並重新填充 `<tbody>` 以顯示過濾後的結果。
5. History: `localStorage` 用於保存搜尋詞,並動態生成可點擊的 `<span>` 標籤以觸發新搜尋。
- Justification: 這是對原始 Python 腳本 (過濾 dataframe) 最直接的 JS 實現。表格是呈現此類數據的最佳方式。搜尋記錄增加了可用性。
- Library/Method: Vanilla JavaScript (DOMParser, fetch, localStorage), Tailwind CSS (UI)。
-->
<!-- CONFIRMATION: NO SVG graphics used. NO Mermaid JS used. -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: 'Inter', 'Noto Sans TC', sans-serif;
}
.clickable-tag {
cursor: pointer;
transition: all 0.2s;
}
.clickable-tag:hover {
opacity: 0.7;
transform: translateY(-1px);
}
/* 針對小螢幕優化表格字體 */
@media (max-width: 640px) {
table {
font-size: 0.8rem;
}
.px-4 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
</style>
</head>
<body class="bg-gray-50 text-gray-800">
<div class="container mx-auto p-4 md:p-8 max-w-full">
<header class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">股票殖利率查詢 (HTML5 版)</h1>
<p class="text-gray-600 mt-1">此工具會即時爬取 Wespai 資料並在您的瀏覽器中進行過濾。</p>
</header>
<!-- Search and History Section -->
<div class="bg-white p-4 rounded-lg shadow-sm mb-6">
<div class="flex flex-col sm:flex-row gap-2">
<input type="text" id="search-input" class="flex-grow p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" placeholder="輸入股票代號或公司名稱 (例如: 2330 或 台積)">
<button id="search-button" class="bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg shadow hover:bg-blue-700 transition duration-200">
搜尋
</button>
</div>
<div id="history-container" class="mt-4 text-sm text-gray-600 flex flex-wrap gap-2 items-center">
<strong class="mr-2">搜尋記錄:</strong>
<!-- History tags will be injected here -->
</div>
</div>
<!-- Status Message -->
<div id="status-message" class="my-4 p-3 bg-blue-100 text-blue-800 rounded-lg text-center font-medium">
正在初始化...
</div>
<!-- Results Table -->
<div class="overflow-x-auto bg-white shadow-md rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<!-- 這些欄位是根據你的 app.py 中的 TARGET_COLUMNS -->
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">代號</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">公司</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">配息</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">除息日</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">配股</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">除權日</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">股價</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">現金殖利率</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">殖利率</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">發息日</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">配息率</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">董監持股</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">3年平均股利</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">6年平均股利</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">10年平均股利</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">10年股利次數</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">1QEPS</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">2QEPS</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">3QEPS</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">今年累積EPS</th>
</tr>
</thead>
<tbody id="results-table-body" class="bg-white divide-y divide-gray-200">
<!-- Data rows will be injected here -->
<tr>
<td colspan="20" class="px-4 py-6 text-center text-gray-500">請稍候,正在載入資料...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="module">
// -------------------------------------------------------------------------
// 狀態和常數
// -------------------------------------------------------------------------
const WESPAI_URL = 'https://stock.wespai.com/rate114';
const PROXY_URL = 'https://api.allorigins.win/raw?url='; // CORS 代理
const HISTORY_KEY = 'stockSearchHistory';
const MAX_HISTORY = 10;
// 來自你的 app.py,我們將只解析和顯示這些欄位
const TARGET_COLUMNS = [
"代號", "公司", "配息", "除息日", "配股", "除權日", "股價",
"現金殖利率", "殖利率", "發息日", "配息率", "董監持股",
"3年平均股利", "6年平均股利", "10年平均股利", "10年股利次數",
"1QEPS", "2QEPS", "3QEPS", "今年累積EPS",
];
let allStockData = []; // 儲存所有解析後的股票資料
let searchHistory = []; // 儲存搜尋記錄
// -------------------------------------------------------------------------
// DOM 元素
// -------------------------------------------------------------------------
const searchInput = document.getElementById('search-input');
const searchButton = document.getElementById('search-button');
const statusMessage = document.getElementById('status-message');
const tableBody = document.getElementById('results-table-body');
const historyContainer = document.getElementById('history-container');
// -------------------------------------------------------------------------
// 核心功能:資料抓取與解析
// -------------------------------------------------------------------------
/**
* 從 Wespai 抓取 HTML 並解析成資料陣列
*/
async function fetchData() {
statusMessage.textContent = '正在從 Wespai 載入即時資料,請稍候...';
statusMessage.className = 'my-4 p-3 bg-blue-100 text-blue-800 rounded-lg text-center font-medium';
tableBody.innerHTML = `<tr><td colspan="${TARGET_COLUMNS.length}" class="px-4 py-6 text-center text-gray-500">正在載入...</td></tr>`;
try {
const response = await fetch(PROXY_URL + encodeURIComponent(WESPAI_URL));
if (!response.ok) {
throw new Error(`HTTP 錯誤! 狀態: ${response.status}`);
}
const htmlString = await response.text();
allStockData = parseHTMLTable(htmlString);
if (allStockData.length === 0) {
statusMessage.textContent = '成功載入頁面,但無法解析到表格資料。';
statusMessage.className = 'my-4 p-3 bg-yellow-100 text-yellow-800 rounded-lg text-center font-medium';
tableBody.innerHTML = `<tr><td colspan="${TARGET_COLUMNS.length}" class="px-4 py-6 text-center text-gray-500">無法解析資料</td></tr>`;
} else {
statusMessage.textContent = `資料載入完成。共 ${allStockData.length} 筆。請輸入關鍵字搜尋。`;
statusMessage.className = 'my-4 p-3 bg-green-100 text-green-800 rounded-lg text-center font-medium';
renderTable(allStockData); // 初始顯示所有資料
}
} catch (error) {
console.error('抓取資料失敗:', error);
statusMessage.textContent = '資料載入失敗。可能是 CORS 代理或目標網站暫時無法訪問。';
statusMessage.className = 'my-4 p-3 bg-red-100 text-red-800 rounded-lg text-center font-medium';
tableBody.innerHTML = `<tr><td colspan="${TARGET_COLUMNS.length}" class="px-4 py-6 text-center text-red-500">載入失敗</td></tr>`;
}
}
/**
* 解析 Wespai HTML 字串,提取表格資料
* @param {string} htmlString - 包含表格的 HTML 原始碼
* @returns {Array<object>} - 解析後的資料陣列
*/
function parseHTMLTable(htmlString) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const table = doc.querySelector('table.table-hover'); // 根據 Wespai 網站原始碼微調選擇器
if (!table) {
console.error('在 HTML 中找不到 table.table-hover');
return [];
}
// 1. 獲取實際的表頭 (headers) 及其索引
const actualHeaders = [...table.querySelectorAll('thead th')].map(th => th.textContent.trim());
// 2. 建立目標欄位 (TARGET_COLUMNS) 到實際索引的映射
const colIndices = {};
TARGET_COLUMNS.forEach(targetCol => {
const index = actualHeaders.indexOf(targetCol);
colIndices[targetCol] = index; // index 為 -1 表示找不到
});
// 3. 遍歷資料列 (rows)
const rows = [...table.querySelectorAll('tbody tr')];
const parsedData = [];
rows.forEach(row => {
const cells = [...row.querySelectorAll('td')];
const rowData = {};
let hasData = false;
TARGET_COLUMNS.forEach(targetCol => {
const index = colIndices[targetCol];
if (index !== -1 && cells[index]) {
rowData[targetCol] = cells[index].textContent.trim();
hasData = true;
} else {
rowData[targetCol] = ''; // 找不到對應欄位
}
});
if (hasData) {
parsedData.push(rowData);
}
});
return parsedData;
} catch (error) {
console.error('解析 HTML 表格失敗:', error);
return [];
}
}
// -------------------------------------------------------------------------
// 核心功能:渲染與搜尋
// -------------------------------------------------------------------------
/**
* 將資料渲染到 HTML 表格中
* @param {Array<object>} data - 要顯示的股票資料
*/
function renderTable(data) {
tableBody.innerHTML = ''; // 清空舊資料
if (data.length === 0) {
tableBody.innerHTML = `<tr><td colspan="${TARGET_COLUMNS.length}" class="px-4 py-6 text-center text-gray-500">查無資料</td></tr>`;
return;
}
const fragment = document.createDocumentFragment();
data.forEach(stock => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
let cellsHTML = '';
TARGET_COLUMNS.forEach(header => {
cellsHTML += `<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700">${stock[header] || ''}</td>`;
});
tr.innerHTML = cellsHTML;
fragment.appendChild(tr);
});
tableBody.appendChild(fragment);
}
/**
* 執行搜尋
*/
function performSearch() {
const keyword = searchInput.value.trim();
if (!keyword) {
// 如果關鍵字為空,顯示所有資料
renderTable(allStockData);
statusMessage.textContent = `顯示所有 ${allStockData.length} 筆資料。`;
statusMessage.className = 'my-4 p-3 bg-green-100 text-green-800 rounded-lg text-center font-medium';
return;
}
// 儲存搜尋記錄
addHistory(keyword);
// 執行過濾 (邏輯同你的 app.py)
const lowerKeyword = keyword.toLowerCase();
const filteredData = allStockData.filter(stock => {
const nameMatch = stock['公司'] && stock['公司'].toLowerCase().includes(lowerKeyword);
const codeMatch = stock['代號'] && stock['代號'].includes(keyword); // 代號通常是精確比對或
return nameMatch || codeMatch;
});
// 渲染過濾後的表格
renderTable(filteredData);
// 更新狀態
if (filteredData.length === 0) {
statusMessage.textContent = `找不到關於 "${keyword}" 的資料。`;
statusMessage.className = 'my-4 p-3 bg-yellow-100 text-yellow-800 rounded-lg text-center font-medium';
} else {
statusMessage.textContent = `找到 ${filteredData.length} 筆關於 "${keyword}" 的資料。`;
statusMessage.className = 'my-4 p-3 bg-green-100 text-green-800 rounded-lg text-center font-medium';
}
}
// -------------------------------------------------------------------------
// 輔助功能:搜尋記錄 (LocalStorage)
// -------------------------------------------------------------------------
/**
* 從 localStorage 載入搜尋記錄
*/
function loadHistory() {
const stored = localStorage.getItem(HISTORY_KEY);
if (stored) {
searchHistory = JSON.parse(stored);
renderHistory();
}
}
/**
* 將新關鍵字添加到搜尋記錄
* @param {string} keyword - 搜尋關鍵字
*/
function addHistory(keyword) {
// 移除已存在的相同記錄,確保新的在最前面
const existingIndex = searchHistory.indexOf(keyword);
if (existingIndex > -1) {
searchHistory.splice(existingIndex, 1);
}
// 加到最前面
searchHistory.unshift(keyword);
// 限制記錄數量
if (searchHistory.length > MAX_HISTORY) {
searchHistory = searchHistory.slice(0, MAX_HISTORY);
}
// 儲存回 localStorage
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory));
// 重新渲染顯示
renderHistory();
}
/**
* 將搜尋記錄渲染到頁面上
*/
function renderHistory() {
// 清空舊標籤 (保留 "搜尋記錄:" 標題)
historyContainer.innerHTML = '<strong class="mr-2">搜尋記錄:</strong>';
searchHistory.forEach(term => {
const tag = document.createElement('span');
tag.textContent = term;
tag.className = 'clickable-tag bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-xs font-medium shadow-sm';
// 點擊標籤時,觸發搜尋
tag.onclick = () => {
searchInput.value = term;
performSearch();
};
historyContainer.appendChild(tag);
});
}
// -------------------------------------------------------------------------
// 初始化
// -------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
// 綁定事件
searchButton.addEventListener('click', performSearch);
searchInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
performSearch();
}
});
// 載入初始資料
loadHistory();
fetchData();
});
</script>
</body>
</html>