// ==UserScript==
// @name Taiwan SportsLottery 綜合腳本
// @namespace https://github.com/jjj9458/
// @version 1.2
// @description MyBets 自動計算 + 維持登入及超時提醒
// @author haley80208@PTT
// @match https://member.sportslottery.com.tw/*
// @match https://www-talo-ssb-pr.sportslottery.com.tw/*
// @run-at document-end
// @grant none
// @license GPL-3.0
// @updateURL https://raw.githubusercontent.com/jjj9458/TampermonkeyUserscripts/main/src/TSL_toolA/TSL_toolA.user.js
// @downloadURL https://raw.githubusercontent.com/jjj9458/TampermonkeyUserscripts/main/src/TSL_toolA/TSL_toolA.user.js
// @homepageURL https://github.com/jjj9458/TampermonkeyUserscripts
// @supportURL https://github.com/jjj9458/TampermonkeyUserscripts/issues
// ==/UserScript==
(function(){
'use strict';
(function(){
const wrap = fn => function(){
const ret = fn.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
history.pushState = wrap(history.pushState);
history.replaceState = wrap(history.replaceState);
window.addEventListener('popstate', ()=> window.dispatchEvent(new Event('locationchange')));
})();
/* KEEPALIVE_DEBUG_LOG:功能註解:輸出 KeepAlive 除錯紀錄,方便在 Console 搜尋 [KeepAliveDebug] */
const KEEPALIVE_DEBUG = false;
function keepAliveLog(message, data){
if (!KEEPALIVE_DEBUG) return;
console.log(`[KeepAliveDebug] ${message}`, data || '');
}
/* KEEPALIVE_BOTTOM_BANNER:功能註解:建立底部提醒橫幅,可設定是否自動關閉,也保留手動關閉功能 */
function showBottomBanner(bannerId, html, options){
const old = document.getElementById(bannerId);
if (old) {
old.remove();
keepAliveLog(`已移除舊橫幅:${bannerId}`);
}
const banner = document.createElement('div');
banner.id = bannerId;
banner.innerHTML = html;
Object.assign(banner.style, {
position: 'fixed',
bottom: '0',
left: '0',
width: '100%',
padding: '1rem',
backgroundColor: options.backgroundColor,
color: '#fff',
fontSize: '1rem',
textAlign: 'center',
zIndex: options.zIndex || '2147483647',
boxShadow: '0 -2px 5px rgba(0,0,0,0.3)',
boxSizing: 'border-box'
});
let closed = false;
const closeBanner = (reason) => {
if (closed) return;
closed = true;
if (banner && banner.parentNode) {
banner.remove();
}
keepAliveLog(`橫幅已關閉:${bannerId}`, { reason });
if (reason === 'manual' && typeof options.onManualClose === 'function') {
options.onManualClose();
}
};
banner.querySelector('button')?.addEventListener('click', () => {
closeBanner('manual');
});
const appendBanner = () => {
if (!document.body) {
keepAliveLog(`document.body 尚未準備好,延後顯示:${bannerId}`);
setTimeout(appendBanner, 300);
return;
}
document.body.appendChild(banner);
keepAliveLog(`橫幅已顯示:${bannerId}`);
if (typeof options.onShow === 'function') {
options.onShow();
}
if (Number(options.autoCloseMs) > 0) {
keepAliveLog(`已設定自動關閉:${bannerId}`, {
autoCloseMs: options.autoCloseMs
});
setTimeout(() => {
closeBanner('auto');
}, Number(options.autoCloseMs));
}
};
appendBanner();
return true;
}
/* KEEPALIVE_PUBLIC_WARNING:功能註解:顯示登入後公共電腦提醒,底部顯示,2 秒後自動關閉 */
function showPublicWarning() {
keepAliveLog('準備顯示公共電腦提醒');
showBottomBanner(
'public-warning-banner',
`
⚠️ 禁止在公共電腦使用本腳本
`,
{
backgroundColor: '#faad14',
zIndex: '2147483647',
autoCloseMs: 3500
}
);
}
if (location.host === 'member.sportslottery.com.tw') {
if (window.__TSL_KEEPALIVE_INSTALLED__) {
keepAliveLog('KeepAlive 已安裝過,略過重複初始化');
} else {
window.__TSL_KEEPALIVE_INSTALLED__ = true;
/* KEEPALIVE_3_5H_NOTIFY_CONFIG:功能註解:網站約 4 小時強制登出,腳本在 3.5 小時提醒 */
let latestInit = null;
const START_TS_KEY = 'KeepAliveStartTs';
const TOKEN_KEY = 'KeepAliveSessionToken';
const LAST_REMINDER_TS_KEY = 'KeepAliveLastReminderTs';
const REMIND_DURATION_MS = 3.5 * 3600 * 1000;
const FORCE_LOGOUT_DURATION_MS = 4 * 3600 * 1000;
const REMINDER_REPEAT_MS = 8 * 60 * 1000;
let startTs = parseInt(localStorage.getItem(START_TS_KEY), 10) || null;
keepAliveLog('腳本啟動', {
path: location.pathname,
startTs,
now: Date.now()
});
/* KEEPALIVE_CLEAR_STATE:功能註解:清除登入計時與提醒紀錄,避免登出後重新開頁誤提醒 */
function clearKeepAliveState(reason) {
startTs = null;
latestInit = null;
localStorage.removeItem(START_TS_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(LAST_REMINDER_TS_KEY);
const oldExpiryBanner = document.getElementById('session-expiry-banner');
if (oldExpiryBanner) oldExpiryBanner.remove();
keepAliveLog(`已清除登入計時狀態:${reason}`);
}
/* KEEPALIVE_3_5H_BANNER:功能註解:顯示登入滿 3.5 小時提醒,不自動關閉,手動關閉後 8 分鐘才可再次提醒 */
function showExpiryBanner(){
keepAliveLog('準備顯示 3.5 小時提醒');
showBottomBanner(
'session-expiry-banner',
`
⚠️ 您已登入滿 3.5 小時,
網站約 4 小時會強制登出,建議立即重新登入
`,
{
backgroundColor: '#ff4d4f',
zIndex: '2147483647',
autoCloseMs: 0,
onShow: () => {
localStorage.setItem(LAST_REMINDER_TS_KEY, String(Date.now()));
keepAliveLog('已記錄 3.5 小時提醒顯示時間', {
lastReminderTs: Date.now()
});
},
onManualClose: () => {
localStorage.setItem(LAST_REMINDER_TS_KEY, String(Date.now()));
keepAliveLog('已記錄 3.5 小時提醒手動關閉時間', {
lastReminderTs: Date.now()
});
}
}
);
}
if (location.pathname === '/login') {
clearKeepAliveState('腳本啟動時位於 login 頁');
}
/* KEEPALIVE_FETCH_HEARTBEAT:功能註解:攔截 heartbeat,取得 sessionToken 並建立或沿用登入計時 */
const _origFetch = window.fetch;
window.fetch = function(input, init){
const url = typeof input === 'string' ? input : input?.url;
if (url && url.includes('/session-manager/v2/session/heartBeat')) {
try {
keepAliveLog('攔截到 heartbeat', { url });
const bodyText = init && init.body;
const tokenNow = bodyText && JSON.parse(bodyText).sessionToken;
const tokenPrev = localStorage.getItem(TOKEN_KEY);
const savedStartTs = parseInt(localStorage.getItem(START_TS_KEY), 10) || null;
latestInit = init;
if (!tokenNow) {
keepAliveLog('heartbeat 未抓到 sessionToken', { bodyText });
}
if (tokenNow && tokenNow !== tokenPrev) {
if (!savedStartTs) {
startTs = Date.now();
localStorage.setItem(START_TS_KEY, String(startTs));
keepAliveLog('偵測到 sessionToken,沒有舊計時,建立新的登入計時', {
startTs
});
} else {
startTs = savedStartTs;
keepAliveLog('偵測到 sessionToken 變化,但沿用原本登入計時', {
startTs,
elapsedMinutes: Math.floor((Date.now() - startTs) / 60000)
});
}
localStorage.setItem(TOKEN_KEY, tokenNow);
}
if (tokenNow && tokenNow === tokenPrev) {
startTs = savedStartTs || startTs || Date.now();
localStorage.setItem(START_TS_KEY, String(startTs));
keepAliveLog('同一個 session,沿用登入計時', {
startTs,
elapsedMinutes: Math.floor((Date.now() - startTs) / 60000)
});
}
} catch(e){
keepAliveLog('heartbeat 處理失敗', e);
}
}
return _origFetch(input, init);
};
/* KEEPALIVE_SEND_HEARTBEAT:功能註解:使用最新 heartbeat 初始化資料主動送出 heartbeat */
async function sendHeartbeat(){
if (!latestInit) return;
try {
await _origFetch(
'https://member.sportslottery.com.tw/session-manager/v2/session/heartBeat',
latestInit
);
} catch(err){
keepAliveLog('手動 heartbeat 失敗', err);
}
}
window.sendHeartbeat = sendHeartbeat;
/* KEEPALIVE_SCHEDULE_HEARTBEAT:功能註解:定期送 heartbeat,維持原本隨機間隔邏輯 */
function scheduleHeartbeat(){
const baseMs = 4 * 60 * 1000;
const randSec = Math.floor(Math.random()*31);
const varMs = randSec * 1000;
const interval= (randSec%2===0) ? baseMs + varMs : baseMs - varMs;
setTimeout(async ()=>{
await sendHeartbeat();
scheduleHeartbeat();
}, interval);
}
scheduleHeartbeat();
/* KEEPALIVE_REMINDER_CHECK:功能註解:滿 3.5 小時提醒;手動關閉或曾顯示後,至少間隔 8 分鐘才再次提醒 */
function checkThreePointFiveHourReminder(reason){
const savedStartTs = parseInt(localStorage.getItem(START_TS_KEY), 10) || null;
const lastReminderTs = parseInt(localStorage.getItem(LAST_REMINDER_TS_KEY), 10) || null;
if (!startTs && savedStartTs) {
startTs = savedStartTs;
keepAliveLog('從 localStorage 補回 startTs', { startTs });
}
if (!startTs && location.pathname !== '/login') {
startTs = Date.now();
localStorage.setItem(START_TS_KEY, String(startTs));
keepAliveLog('沒有 startTs,先以目前時間建立計時起點', {
reason,
startTs
});
}
if (!startTs) {
keepAliveLog('尚未有 startTs,略過 3.5 小時提醒檢查', { reason });
return;
}
const now = Date.now();
const elapsedMs = now - startTs;
const elapsedMinutes = Math.floor(elapsedMs / 60000);
const remindRemainMinutes = Math.ceil((REMIND_DURATION_MS - elapsedMs) / 60000);
const forceRemainMinutes = Math.ceil((FORCE_LOGOUT_DURATION_MS - elapsedMs) / 60000);
const bannerExists = !!document.getElementById('session-expiry-banner');
const canShowAgain = !lastReminderTs || now - lastReminderTs >= REMINDER_REPEAT_MS;
keepAliveLog('3.5 小時提醒檢查', {
reason,
elapsedMinutes,
remindRemainMinutes,
forceRemainMinutes,
bannerExists,
lastReminderTs,
canShowAgain
});
if (elapsedMs < REMIND_DURATION_MS) return;
if (bannerExists) {
keepAliveLog('3.5 小時提醒已在畫面上,不重複建立');
return;
}
if (!canShowAgain) {
keepAliveLog('尚未滿 8 分鐘,不再次顯示 3.5 小時提醒', {
nextShowRemainMinutes: Math.ceil((REMINDER_REPEAT_MS - (now - lastReminderTs)) / 60000)
});
return;
}
keepAliveLog('已達 3.5 小時,顯示提醒橫幅', {
elapsedMinutes
});
showExpiryBanner();
}
setTimeout(()=>{
checkThreePointFiveHourReminder('啟動後初次檢查');
}, 3000);
setInterval(()=>{
checkThreePointFiveHourReminder('每分鐘檢查');
}, 60 * 1000);
/* KEEPALIVE_LOCATION_RESET:功能註解:監聽登入與登出路由,登入後開始計時,回 login 清除狀態 */
let prevPath = location.pathname;
window.addEventListener('locationchange', ()=>{
const newPath = location.pathname;
keepAliveLog('locationchange', {
prevPath,
newPath
});
if (newPath === '/login') {
clearKeepAliveState('進入 login 頁');
}
if (prevPath === '/login' && newPath !== '/login') {
startTs = Date.now();
localStorage.setItem(START_TS_KEY, String(startTs));
localStorage.removeItem(LAST_REMINDER_TS_KEY);
keepAliveLog('從 login 離開,判定登入成功,開始 3.5 小時計時', {
startTs
});
showPublicWarning();
}
prevPath = newPath;
});
}
}
function runMyBets(){
const host = location.host;
if (!((host==='member.sportslottery.com.tw' && location.pathname.startsWith('/account/my-bets')) ||
host==='www-talo-ssb-pr.sportslottery.com.tw')) return;
const isTop = window.top === window.self;
function injectButton(){
if (document.querySelector('#myCalcButton')) return;
const bcInfo = document.querySelector('.styled__BreadcrumbsInfo-sc-1ipvy8g-6');
if (!bcInfo) return;
bcInfo.style.display = 'flex';
bcInfo.style.justifyContent = 'space-between';
bcInfo.style.gap = '1rem';
const ctrl = document.createElement('div');
ctrl.style.display = 'inline-flex';
ctrl.style.alignItems = 'center';
ctrl.style.gap = '0.5rem';
const txt = document.createElement('span');
txt.textContent = '先到已派彩,設定日期區間,再按';
const btn = document.createElement('button');
btn.id = 'myCalcButton';
btn.textContent = '計算';
const out = document.createElement('span');
out.id = 'myCalcText';
ctrl.append(txt, btn, out);
bcInfo.appendChild(ctrl);
let pending=0, sumS=0, sumR=0;
window.addEventListener('message', e => {
if (e.origin==='https://www-talo-ssb-pr.sportslottery.com.tw' &&
e.data?.type==='BET_RESULTS') {
sumS += e.data.totalStake;
sumR += e.data.totalReturn;
pending--;
if (pending===0) {
out.textContent = `共花費:${sumS},共中獎:${sumR},淨利:${sumR - sumS}`;
btn.disabled = false;
}
}
});
btn.addEventListener('click', ()=> {
out.textContent = '計算中...';
btn.disabled = true;
sumS = 0; sumR = 0;
const iframes = Array.from(
document.querySelectorAll('iframe[src*="talo-ssb-pr"]')
);
pending = iframes.length;
if (pending===0) {
out.textContent = '找不到 iframe';
btn.disabled = false;
return;
}
iframes.forEach(f => {
f.contentWindow.postMessage(
{ type:'START_CALC' },
'https://www-talo-ssb-pr.sportslottery.com.tw'
);
});
});
}
function initTop(){
const iv = setInterval(()=>{
injectButton();
if (document.querySelector('#myCalcButton')) clearInterval(iv);
}, 300);
}
function initIframe(){
window.addEventListener('message', e => {
if (e.origin==='https://member.sportslottery.com.tw' && e.data?.type==='START_CALC') {
let nf = 0;
const d = window.document;
const ci = setInterval(()=>{
let b = d.querySelector('.MyBetsstyled__LoadMoreButton-sc-nwucds-1')
|| Array.from(d.querySelectorAll('button'))
.find(x => /載入更多|更多|Load\s?More/i.test(x.innerText));
if (b) { b.click(); nf = 0; }
else {
nf++;
if (nf>=3) {
clearInterval(ci);
let tS=0, tR=0;
d.querySelectorAll('[data-test-id="amount-mybets-mgs-totalstake"]')
.forEach(s => {
const r = s.closest('[title]')?.title || s.innerText;
tS += parseFloat(r.replace(/[^\d\.-]/g,''))||0;
});
d.querySelectorAll('[data-test-id="amount-mybets-mgs-potentialreturn"]')
.forEach(s => {
const r = s.closest('[title]')?.title || s.innerText;
tR += parseFloat(r.replace(/[^\d\.-]/g,''))||0;
});
window.parent.postMessage(
{ type:'BET_RESULTS', totalStake:tS, totalReturn:tR },
'https://member.sportslottery.com.tw'
);
}
}
}, 500);
}
});
}
if (isTop) initTop(); else initIframe();
}
window.addEventListener('load', runMyBets);
window.addEventListener('locationchange', runMyBets);
})();
/*
* 匯集過去PTT@SportLottery各位前輩們分享過的架構
* 與鄉民們的使用回饋,就不一一提及
* 僅供未來接棒者了解並繼續傳承
* 祝各位繼續順利尻剛
**/