自動電子投票

自動電子投票,並且快速將結果保存成 JPG

נכון ליום 03-04-2025. ראה הגרסה האחרונה.

  1. // ==UserScript==
  2. // @name 自動電子投票
  3. // @namespace https://github.com/zxc88645/TdccAuto/blob/main/TdccAuto.js
  4. // @version 1.7.0
  5. // @description 自動電子投票,並且快速將結果保存成 JPG
  6. // @author Owen
  7. // @match https://stockservices.tdcc.com.tw/*
  8. // @icon https://raw.githubusercontent.com/zxc88645/TdccAuto/refs/heads/main/img/TdccAuto_icon.png
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
  13. // @license MIT
  14. // @homepage https://github.com/zxc88645/TdccAuto
  15. // ==/UserScript==
  16.  
  17. /* global html2pdf */
  18.  
  19. (function () {
  20. 'use strict';
  21.  
  22. const savedKey = 'savedStocks2';
  23. let savedStocks = GM_getValue(savedKey, {});
  24. let idNo = null;
  25.  
  26. fetchAndParseIdNO().then(_idNo => {
  27. idNo = _idNo;
  28. });
  29.  
  30.  
  31. // log 當前 savedStocks
  32. console.log('[所有帳號的已保存股票]');
  33. for (const [_idNo, stocks] of Object.entries(savedStocks)) {
  34. debugger;
  35. if (Array.isArray(stocks)) {
  36. console.log(`戶號 ${_idNo}:${stocks.join(', ')}`);
  37. } else {
  38. delete savedStocks[_idNo]; // 移除舊資料格式
  39. GM_setValue(savedKey, savedStocks); // 更新儲存的資料
  40. }
  41. }
  42.  
  43.  
  44. /** 延遲函式 */
  45. const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
  46.  
  47. function isXPath(selector) {
  48. return selector.startsWith('/') || selector.startsWith('(');
  49. }
  50.  
  51. /**
  52. * 查詢 DOM 元素,支援 CSS 選擇器與 XPath
  53. * @param {string} selector - CSS 選擇器或 XPath
  54. * @param {Element} [context=document] - 查詢範圍
  55. * @returns {Element|null} - 匹配的 DOM 元素
  56. */
  57. function querySelector(selector, context = document) {
  58. return isXPath(selector)
  59. ? document.evaluate(selector, context, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
  60. : context.querySelector(selector);
  61. }
  62.  
  63. /**
  64. * 點擊指定元素並等待執行完成
  65. * @param {string} selector - CSS 選擇器或 XPath
  66. * @param {string} [expectedText=null] - 預期的文字內容
  67. * @param {string} [logInfo=null] - 日誌輸出標籤
  68. */
  69. async function clickAndWait(selector, expectedText = null, logInfo = null) {
  70. try {
  71. const element = querySelector(selector);
  72. if (!element) {
  73. console.warn(`[未找到] ${selector}`);
  74. return;
  75. }
  76.  
  77. if (expectedText && element.innerText.trim() !== expectedText) {
  78. console.warn(`[文字不匹配] 預期: '${expectedText}',但實際為: '${element.innerText.trim()}'`);
  79. return;
  80. }
  81.  
  82. console.log(`[點擊] ${selector} ${logInfo ? `| ${logInfo}` : ''}`);
  83. element.click();
  84. await sleep(100);
  85. } catch (error) {
  86. console.error(`[錯誤] 點擊失敗: ${selector}`, error);
  87. }
  88. }
  89.  
  90. /**
  91. * 下載 JPG
  92. */
  93. function saveAsJPG() {
  94. const element = document.querySelector("body > div.c-main > form");
  95. if (!element) return;
  96.  
  97. const children = Array.from(element.children).slice(0, 4);
  98. const tempDiv = document.createElement("div");
  99. tempDiv.style.background = "white"; // 確保背景是白的
  100. children.forEach(el => tempDiv.appendChild(el.cloneNode(true)));
  101.  
  102. // 把 tempDiv 暫時加到 body 中,讓 html2canvas 能正確渲染
  103. document.body.appendChild(tempDiv);
  104. tempDiv.style.position = 'absolute';
  105. tempDiv.style.left = '-9999px';
  106.  
  107. // 提取股票代號
  108. const stockNumber = getStockNumber() ?? "投票結果";
  109.  
  110. html2canvas(tempDiv, { scale: 2, useCORS: true }).then(canvas => {
  111. const link = document.createElement("a");
  112. link.href = canvas.toDataURL("image/jpeg", 1.0);
  113. link.download = `${idNo}_${stockNumber}.jpg`;
  114. link.click();
  115. document.body.removeChild(tempDiv); // 清除暫時元素
  116. });
  117.  
  118. // 保存股票代號紀錄
  119. saveStockNumber();
  120. }
  121.  
  122. /**
  123. * 保存已下載截圖的代號
  124. */
  125. function saveStockNumber() {
  126. const stockNumber = getStockNumber();
  127.  
  128. if (!idNo || !stockNumber) {
  129. console.warn(`[saveStockNumber] 無法保存:idNo=${idNo}, stockNumber=${stockNumber}`);
  130. return;
  131. }
  132.  
  133. // 若該帳號尚無記錄,初始化為空陣列
  134. if (!savedStocks[idNo]) {
  135. savedStocks[idNo] = [];
  136. }
  137.  
  138. // 若尚未儲存此股票代號才加入
  139. if (!savedStocks[idNo].includes(stockNumber)) {
  140. savedStocks[idNo].push(stockNumber);
  141. GM_setValue(savedKey, savedStocks);
  142. console.log(`[saveStockNumber] 已儲存 ${stockNumber} 至帳號 ${idNo}`);
  143. } else {
  144. console.log(`[saveStockNumber] ${stockNumber} 已存在於帳號 ${idNo}`);
  145. }
  146. }
  147.  
  148.  
  149. /**
  150. * 從 /evote/shareholder/000/tc_estock_welshas.html 抓取 HTML 並解析 IdNO
  151. */
  152. async function fetchAndParseIdNO() {
  153. try {
  154. console.log('[fetchAndParseIdNO] 開始抓取 IdNO...');
  155. const response = await fetch('/evote/shareholder/000/tc_estock_welshas.html', {
  156. credentials: 'include' // 保留 cookie/session
  157. });
  158.  
  159. if (!response.ok) {
  160. console.warn(`[fetchAndParseIdNO] 請求失敗,HTTP ${response.status}`);
  161. return null;
  162. }
  163.  
  164. const html = await response.text();
  165. return getIdNO(html);
  166.  
  167. } catch (error) {
  168. console.error('[fetchAndParseIdNO] 發生錯誤:', error);
  169. return null;
  170. }
  171. }
  172.  
  173. /**
  174. * 取得 IdNO
  175. * @param {string} html - 從頁面取得的 HTML 原始碼
  176. * @returns {string|null}
  177. */
  178. function getIdNO(html) {
  179. const regex = /idNo\s*:\s*'([A-Z]\d{9})'/i;
  180. const match = html.match(regex);
  181.  
  182. if (match && match[1]) {
  183. console.log(`[getIdNO] HTML 解析 IdNO: ${match[1]}`);
  184. return match[1];
  185. } else {
  186. console.log('[getIdNO] 無法解析 IdNO');
  187. return null;
  188. }
  189. }
  190.  
  191. /**
  192. * 等待直到全域變數 idNo 有值(非 null),最多嘗試 maxRetries 次
  193. * @param {number} maxRetries 最大重試次數(預設 10)
  194. * @param {number} delay 每次檢查間隔毫秒(預設 500ms)
  195. * @returns {Promise<string|null>} 成功時回傳 idNo,失敗則回傳 null
  196. */
  197. async function waitUntilIdNOAvailable(maxRetries = 10, delay = 500) {
  198. for (let attempt = 1; attempt <= maxRetries; attempt++) {
  199. if (idNo) {
  200. console.log(`[waitUntilIdNOAvailable] idNo 已取得:${idNo}(第 ${attempt} 次)`);
  201. return idNo;
  202. }
  203.  
  204. console.log(`[waitUntilIdNOAvailable] ${attempt} 次等待 idNo...`);
  205. await new Promise(resolve => setTimeout(resolve, delay));
  206. }
  207.  
  208. console.warn(`[waitUntilIdNOAvailable] 超過最大次數,idNo 仍為 null`);
  209. return null;
  210. }
  211.  
  212.  
  213. /**
  214. * 取得股票代號
  215. */
  216. function getStockNumber() {
  217. const text = document.querySelector("body > div.c-main > form > div.c-votelist_title > h2")?.innerText.trim();
  218. const match = text?.match(/貴股東對(\d+)\s/);
  219. return match ? match[1] : null;
  220. }
  221.  
  222. /**
  223. * 標註已儲存的股票代號
  224. */
  225. function markSavedStockRows(savedStockList = []) {
  226. try {
  227. console.log(`[markSavedStockRows] 標記帳號 ${idNo} 的已保存股票:${savedStockList.join(', ')}`);
  228.  
  229. const stockRows = document.querySelectorAll('#stockInfo tbody tr');
  230.  
  231. stockRows.forEach(row => {
  232. const stockCodeCell = row.querySelector('div.u-width--40');
  233. const appendTargetCell = row.querySelector('td.u-width--20');
  234. if (!stockCodeCell || !appendTargetCell) return;
  235.  
  236. const stockCode = stockCodeCell.textContent.trim();
  237.  
  238. if (savedStockList.includes(stockCode)) {
  239. const alreadyTagged = stockCodeCell.innerHTML.includes('已保存');
  240. if (!alreadyTagged) {
  241. const savedTag = document.createElement('span');
  242. savedTag.textContent = '(已保存)';
  243. savedTag.className = 'savedTag';
  244. savedTag.style.color = 'green';
  245. savedTag.style.marginLeft = '5px';
  246. savedTag.style.fontSize = '7px';
  247. appendTargetCell.appendChild(savedTag);
  248. }
  249. }
  250. });
  251. } catch (error) {
  252. console.error('標記已保存股票時發生錯誤:', error);
  253. }
  254. }
  255.  
  256. /**
  257. * 創建懸浮窗口
  258. */
  259. function createFloatingPanel() {
  260. const panel = document.createElement('div');
  261. panel.id = 'tdcc-float-panel';
  262. panel.style.position = 'fixed';
  263. panel.style.bottom = '20px';
  264. panel.style.right = '20px';
  265. panel.style.zIndex = '9999';
  266. panel.style.backgroundColor = '#ffffff';
  267. panel.style.border = '1px solid #ccc';
  268. panel.style.borderRadius = '5px';
  269. panel.style.padding = '10px';
  270. panel.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)';
  271. panel.style.fontFamily = 'Arial, sans-serif';
  272. panel.style.fontSize = '14px';
  273.  
  274. const title = document.createElement('div');
  275. title.textContent = '自動電子投票助手';
  276. title.style.fontWeight = 'bold';
  277. title.style.marginBottom = '10px';
  278. panel.appendChild(title);
  279.  
  280. // 顯示idNo
  281. const idNoDiv = document.createElement('div');
  282. idNoDiv.textContent = 'idNo:' + idNo;
  283. idNoDiv.style.marginBottom = '10px';
  284. panel.appendChild(idNoDiv);
  285.  
  286. const clearBtn = document.createElement('button');
  287. clearBtn.textContent = '清除 \'已保存\' 標記';
  288. clearBtn.style.padding = '5px 10px';
  289. clearBtn.style.backgroundColor = '#ff6b6b';
  290. clearBtn.style.color = 'white';
  291. clearBtn.style.border = 'none';
  292. clearBtn.style.borderRadius = '3px';
  293. clearBtn.style.cursor = 'pointer';
  294. clearBtn.onclick = () => {
  295. if (confirm('確定要清除所有已保存的股票記錄嗎?')) {
  296. GM_setValue(savedKey, {});
  297. window.location.reload();
  298. }
  299. };
  300. panel.appendChild(clearBtn);
  301.  
  302. // 拖曳功能
  303. let isDragging = false;
  304. let offsetX, offsetY;
  305.  
  306. title.style.cursor = 'move';
  307. title.addEventListener('mousedown', (e) => {
  308. isDragging = true;
  309. offsetX = e.clientX - panel.getBoundingClientRect().left;
  310. offsetY = e.clientY - panel.getBoundingClientRect().top;
  311. panel.style.cursor = 'grabbing';
  312. });
  313.  
  314. document.addEventListener('mousemove', (e) => {
  315. if (!isDragging) return;
  316. panel.style.left = `${e.clientX - offsetX}px`;
  317. panel.style.top = `${e.clientY - offsetY}px`;
  318. panel.style.right = 'auto';
  319. panel.style.bottom = 'auto';
  320. });
  321.  
  322. document.addEventListener('mouseup', () => {
  323. isDragging = false;
  324. panel.style.cursor = 'default';
  325. });
  326.  
  327. document.body.appendChild(panel);
  328. }
  329.  
  330.  
  331. /**
  332. * 主程式
  333. */
  334. async function main() {
  335. const currentPath = window.location.pathname;
  336. console.log(`[當前網址] ${currentPath}`);
  337.  
  338. if (currentPath.includes('/evote/shareholder/001/6_01.html')) {
  339. console.log('進行電子投票 - 最後的確認');
  340. await clickAndWait('#go', '確認', '確認');
  341. } else if (currentPath.includes('/evote/shareholder/001/5_01.html') || currentPath.includes('/evote/shareholder/001/2_01.html')) {
  342. // 確認投票結果
  343. console.log('進行電子投票 - 投票確認');
  344. await sleep(500);
  345. await clickAndWait('body > div.c-main > form > div.c-votelist_actions > button:nth-child(1)', '確認投票結果', '確認投票結果');
  346. } else if (currentPath.includes('/evote/shareholder/001/')) {
  347. console.log('進行電子投票 - 投票中');
  348.  
  349. // 全部棄權
  350. await clickAndWait('body > div.c-main > form > table:nth-child(3) > tbody > tr.u-t_align--right > td:nth-child(2) > a:nth-child(3)', '全部棄權', '勾選全部棄權(1)');
  351. await clickAndWait('body > div.c-main > form > div.c-votelist_actions > button:nth-child(2)', '下一步', '按下 下一步(1)');
  352.  
  353. // 全部棄權 2
  354. await clickAndWait('#voteform > table:nth-child(5) > tbody > tr > td.u-t_align--right > a:nth-child(8)', '全部棄權', '勾選全部棄權(2)');
  355. await clickAndWait('#voteform > div.c-votelist_actions > button:nth-child(1)', '下一步', '按下 下一步(2)');
  356. await clickAndWait('body > div.jquery-modal.blocker.current > div > div:nth-child(2) > button:nth-child(1)', '下一步', '按下 下一步(2.2)');
  357.  
  358. } else if (currentPath === '/evote/shareholder/000/tc_estock_welshas.html') {
  359. console.log('位於投票列表首頁');
  360.  
  361. // 創建漂浮面板
  362. await waitUntilIdNOAvailable();
  363. createFloatingPanel();
  364.  
  365. // 標註已儲存的股票
  366. markSavedStockRows(savedStocks[idNo] ?? []);
  367.  
  368.  
  369. await clickAndWait('//*[@id="stockInfo"]/tbody/tr[1]/td[4]/a[1]', '投票', '進入投票');
  370.  
  371.  
  372. } else if (currentPath === '/evote/shareholder/002/01.html') {
  373. console.log('準備列印投票結果');
  374.  
  375. await waitUntilIdNOAvailable();
  376. if (document.querySelector("#printPage")?.innerText.trim() === '列印') {
  377. saveAsJPG();
  378. }
  379.  
  380. } else {
  381. console.warn('當前網址不在預期範圍內');
  382. }
  383.  
  384. console.log('✅ 完成');
  385. }
  386.  
  387. // 在網頁完全載入後執行 main 函式
  388. window.addEventListener("load", () => setTimeout(main, 500));
  389. })();