// ==UserScript==
// @name Linux.do 自动点赞 V10.3
// @namespace http://tampermonkey.net/
// @version 10.3
// @description [v10.3 终极优化版] 引入UI更新缓存机制,解决倒计时显示跳动问题并提升性能。增加Cloudflare Turnstile错误过滤器,增强脚本在复杂网络环境下的稳定性。
// @author AIMYON & GOOGLE GEMINI 2.5 PRO & ANTHROPIC CLAUDE - SONNET 4
// @match https://linux.do/t/*
// @icon https://cdn.linux.do/uploads/default/optimized/1X/a2c163351a02241bc56303b6070622a55543c8d1_2_32x32.png
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
if (window.likeBotInstanceRunning) return;
window.likeBotInstanceRunning = true;
// =======================================================
// 1. 配置中心
// =======================================================
const CONFIG = {
maxPostAgeInDays: 7,
scanIntervalSeconds: 60,
dailyLikeLimit: 40,
dailyLimitPopupSelector: ".dialog-body",
maxLikesPerSession: 5,
sessionCooldownSeconds: 180,
minDelayBetweenLikes: 3500,
maxDelayBetweenLikes: 7500,
likeRetryAttempts: 3,
};
// =======================================================
// 2. 状态与统计
// =======================================================
const stats = {
totalScanned: 0, totalLiked: 0, totalSkippedOld: 0, totalSkippedUnknown: 0, errors: 0,
reset() { Object.keys(this).forEach(key => { if (typeof this[key] === 'number') this[key] = 0; }); },
getReport() { return `S:${stats.totalScanned}|L:${stats.totalLiked}|O:${stats.totalSkippedOld}|U:${stats.totalSkippedUnknown}|E:${stats.errors}`; }
};
const dailyCountKey = "likeBot_dailyCount", lastResetKey = "likeBot_lastResetDate", collapsedStateKey = "likeBot_isCollapsed";
const likeButtonSelector = ".btn-toggle-reaction-like";
let isPaused = false, isCollapsed = false, isProcessing = false;
let uiPanel, uiStatus, uiCountdown, uiCounter, uiStats, uiHeader;
let nextScanTimer = null;
let navigationObserver = null;
// 🔥 新增:UI点赞数缓存变量
let cachedDailyCount = null;
let lastCountUpdateTime = 0;
function log(message) { console.log(`[自动点赞脚本 v10.3] ${message}`); }
function err(message, error) { console.error(`[自动点赞脚本 v10.3] ${message}`, error || ''); }
// 🔥 优化后的全局错误处理 - 忽略Turnstile错误
window.addEventListener('error', (e) => {
const errorMsg = e.error?.message || e.message || '';
if (errorMsg.includes('TurnstileError') || errorMsg.includes('Turnstile')) {
console.log(`[自动点赞脚本 v10.3] 忽略Cloudflare验证错误:`, errorMsg);
return;
}
stats.errors++;
err("捕获到全局错误:", e.error);
updateUIPanel("错误", "⚠️");
});
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason?.message || e.reason || '';
if (reason.includes && (reason.includes('TurnstileError') || reason.includes('Turnstile'))) {
console.log(`[自动点赞脚本 v10.3] 忽略Turnstile Promise错误:`, reason);
return;
}
stats.errors++;
err("捕获到未处理的Promise rejection:", e.reason);
updateUIPanel("错误", "⚠️");
});
// =======================================================
// 3. 核心逻辑
// =======================================================
function extractTopicId(url) {
const match = url.match(/\/t\/[^\/]+\/(\d+)/);
return match ? match[1] : null;
}
function resetProcessingState() {
isProcessing = false;
if (nextScanTimer) {
clearTimeout(nextScanTimer);
clearInterval(nextScanTimer);
nextScanTimer = null;
}
}
async function getDailyCount() { const today = new Date().toISOString().slice(0, 10); if (today !== await GM_getValue(lastResetKey, null)) { log(`新的一天,重置点赞计数。`); await GM_setValue(dailyCountKey, 0); await GM_setValue(lastResetKey, today); stats.reset(); return 0; } return await GM_getValue(dailyCountKey, 0); }
async function setDailyCount(count) { await GM_setValue(dailyCountKey, count); }
function checkPostAge(postContainer) {
const recentPatterns = [
/(\d+)\s*(分|minute|分前)/i, /(\d+)\s*(小时|時間|hour|時間前)/i,
/(\d+)\s*(秒|second|秒前)/i, /刚刚|now|just now/i,
];
const oldPatterns = [
/(\d+)\s*(天|日|day|日前)/i, /(\d+)\s*(周|週間|week|週間前)/i,
/(\d+)\s*(月|ヶ月|month|ヶ月前)/i, /(\d+)\s*(年|year|年前)/i,
];
const dateElement = postContainer.querySelector('a.post-date[data-time]');
if (dateElement && dateElement.dataset.time) {
const ageInDays = (Date.now() - parseInt(dateElement.dataset.time, 10)) / 86400000;
if (ageInDays <= CONFIG.maxPostAgeInDays) {
return { shouldLike: true, reason: 'timestamp' };
} else {
return { shouldLike: false, reason: `Timestamp > ${CONFIG.maxPostAgeInDays}d` };
}
}
const timeTextElement = postContainer.querySelector('.relative-date, .post-date');
if (timeTextElement) {
const timeText = timeTextElement.textContent.trim();
if (oldPatterns.some(p => p.test(timeText))) {
return { shouldLike: false, reason: `Old keyword: "${timeText}"` };
}
if (recentPatterns.some(p => p.test(timeText))) {
return { shouldLike: true, reason: `Recent keyword: "${timeText}"` };
}
}
return { shouldLike: false, reason: 'Unknown age' };
}
async function performLikeWithRetry(button) {
for (let attempt = 1; attempt <= CONFIG.likeRetryAttempts; attempt++) {
try {
let observer, timeoutId;
const promise = new Promise((resolve) => {
observer = new MutationObserver(() => {
const popup = document.querySelector(CONFIG.dailyLimitPopupSelector);
if (popup && popup.offsetParent &&
(popup.textContent.includes('24 時間') || popup.textContent.includes('上限'))) {
clearTimeout(timeoutId);
resolve({ limitReached: true });
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => resolve({ limitReached: false }), 4000);
if (document.body.contains(button)) {
button.click();
} else {
resolve({ limitReached: false, error: 'Button not in DOM' });
}
});
try {
return await promise;
} finally {
if (observer) observer.disconnect();
}
} catch (error) {
stats.errors++;
err(`点赞失败 (尝试 ${attempt}/${CONFIG.likeRetryAttempts})`, error);
if (attempt === CONFIG.likeRetryAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
return { limitReached: false };
}
async function processLikes() {
if (isProcessing) return;
resetProcessingState();
isProcessing = true;
try {
await waitForResume();
if (!location.pathname.startsWith('/t/')) {
updateUIPanel("非帖子页", "-");
return;
}
await new Promise(resolve => setTimeout(resolve, 500));
const dailyCount = await getDailyCount();
if (dailyCount >= CONFIG.dailyLikeLimit) {
updateUIPanel("每日上限", "😴", dailyCount);
return;
}
updateUIPanel("扫描中...", "-", dailyCount);
const allButtons = Array.from(document.querySelectorAll(likeButtonSelector + ":not(.active)"));
const targetsQueue = [];
for (const btn of allButtons) {
stats.totalScanned++;
const post = btn.closest('.topic-post');
if (!post) {
stats.totalSkippedUnknown++;
continue;
}
const result = checkPostAge(post);
if (result.shouldLike) {
targetsQueue.push(btn);
log(`发现可点赞帖子: ${result.reason}`);
} else {
if (result.reason.includes('Timestamp >') || result.reason.includes('Old keyword:')) {
stats.totalSkippedOld++;
} else {
stats.totalSkippedUnknown++;
}
log(`跳过帖子: ${result.reason}`);
}
}
if (targetsQueue.length > 0) {
log(`发现 ${targetsQueue.length} 个可点赞帖子。`);
const sessionSize = Math.min(targetsQueue.length, CONFIG.maxLikesPerSession, CONFIG.dailyLikeLimit - dailyCount);
let sessionLikes = 0;
for (let i = 0; i < sessionSize; i++) {
await waitForResume();
const button = targetsQueue[i];
if (!document.body.contains(button)) continue;
const { limitReached } = await performLikeWithRetry(button);
if (limitReached) {
await setDailyCount(CONFIG.dailyLikeLimit);
updateUIPanel("达到上限", "😴", CONFIG.dailyLikeLimit);
return;
}
stats.totalLiked++;
sessionLikes++;
updateUIPanel(`点赞中...`, `${sessionLikes}/${sessionSize}`, dailyCount + sessionLikes);
if (i < sessionSize - 1) {
await new Promise(r => setTimeout(r, getRandomDelay(CONFIG.minDelayBetweenLikes, CONFIG.maxDelayBetweenLikes)));
}
}
if (sessionLikes > 0) await setDailyCount(dailyCount + sessionLikes);
scheduleNextScan(CONFIG.sessionCooldownSeconds);
} else {
log("未发现可点赞帖子。");
scheduleNextScan(CONFIG.scanIntervalSeconds);
}
} catch (error) {
stats.errors++;
err("处理流程异常:", error);
scheduleNextScan(30);
} finally {
isProcessing = false;
}
}
function scheduleNextScan(seconds) {
resetProcessingState();
const initialUrl = location.href;
let remaining = seconds;
const update = async () => {
if (location.href !== initialUrl) {
clearInterval(nextScanTimer);
nextScanTimer = null;
log("页面已切换,停止旧的倒计时。");
return;
}
if (isPaused || isProcessing) return;
if (remaining <= 0) {
clearInterval(nextScanTimer);
nextScanTimer = null;
processLikes();
return;
}
updateUIPanel("等待中...", `${remaining}s`);
remaining--;
};
nextScanTimer = setInterval(update, 1000);
update();
}
// =======================================================
// 4. UI 和辅助函数
// =======================================================
// 🔥 替换为带缓存优化的 updateUIPanel 函数
async function updateUIPanel(status, countdown = '-', count) {
if (!uiStatus) return;
uiStatus.textContent = status;
uiCountdown.textContent = countdown;
if (count === undefined || count === null) {
const now = Date.now();
if (!cachedDailyCount || now - lastCountUpdateTime > 5000) {
cachedDailyCount = await getDailyCount();
lastCountUpdateTime = now;
}
count = cachedDailyCount;
} else {
cachedDailyCount = count;
lastCountUpdateTime = Date.now();
}
uiCounter.textContent = `${count} / ${CONFIG.dailyLikeLimit}`;
if (uiStats) uiStats.textContent = stats.getReport();
}
function togglePause() {
isPaused = !isPaused;
const btn = document.getElementById('like-bot-pause-btn');
if (btn) btn.textContent = isPaused ? "▶️ 继续" : "⏸️ 暂停";
if (!isPaused) {
log("脚本已恢复");
processLikes();
} else {
log("脚本已暂停");
resetProcessingState();
updateUIPanel("已暂停", "⏸️");
}
}
async function waitForResume() { while (isPaused) { await new Promise(r => setTimeout(r, 1000)); } }
function createUIPanel() { if (document.getElementById('like-bot-panel')) return true; const panelHTML = ` <div id="like-bot-panel"> <div id="like-bot-header" title="点击折叠"><strong>自动点赞 V10.3</strong></div> <div id="like-bot-content"> <p><strong>状态:</strong> <span id="like-bot-status">初始化中...</span></p> <p><strong>倒计时:</strong> <span id="like-bot-countdown">-</span></p> <p><strong>今日已赞:</strong> <span id="like-bot-counter">0 / ${CONFIG.dailyLikeLimit}</span></p> <p style="font-size: 11px; color: #888;"><strong>统计:</strong> <span id="like-bot-stats">...</span></p> </div> <div id="like-bot-footer"><button id="like-bot-pause-btn">⏸️ 暂停</button></div> </div>`; document.body.insertAdjacentHTML('beforeend', panelHTML); uiPanel = document.getElementById('like-bot-panel'); uiHeader = document.getElementById('like-bot-header'); uiStatus = document.getElementById('like-bot-status'); uiCountdown = document.getElementById('like-bot-countdown'); uiCounter = document.getElementById('like-bot-counter'); uiStats = document.getElementById('like-bot-stats'); const pauseBtn = document.getElementById('like-bot-pause-btn'); if (uiHeader) uiHeader.addEventListener('click', async () => { isCollapsed = !isCollapsed; uiPanel.classList.toggle('collapsed', isCollapsed); await GM_setValue(collapsedStateKey, isCollapsed); }); if (pauseBtn) pauseBtn.addEventListener('click', togglePause); makeDraggable(uiPanel); return true; }
function getRandomDelay(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); }
function makeDraggable(element) { let p1=0,p2=0,p3=0,p4=0; const h=document.getElementById("like-bot-header"); if(h){h.onmousedown=d} function d(e){if(e.button!==0)return;e.preventDefault();p3=e.clientX;p4=e.clientY;document.onmouseup=c;document.onmousemove=m} function m(e){e.preventDefault();p1=p3-e.clientX;p2=p4-e.clientY;p3=e.clientX;p4=e.clientY;element.style.top=(element.offsetTop-p2)+"px";element.style.left=(element.offsetLeft-p1)+"px"} function c(){document.onmouseup=null;document.onmousemove=null} }
function injectStyles() { GM_addStyle(`/* ... 样式保持不变 ... */ #like-bot-panel{position:fixed;bottom:20px;right:20px;width:230px;height:fit-content;border-radius:16px;box-shadow:0 10px 40px rgba(0,0,0,.3);z-index:9999;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;font-size:14px;overflow:hidden;backdrop-filter:blur(22px);-webkit-backdrop-filter:blur(22px);transition:background .3s ease,border .3s ease,color .3s ease}#like-bot-header{padding:10px;cursor:move;text-align:center;font-weight:700}#like-bot-content{padding:15px}#like-bot-content p{margin:0 0 10px;display:flex;justify-content:space-between;align-items:center}#like-bot-content p:last-child{margin-bottom:0}#like-bot-status,#like-bot-countdown,#like-bot-counter{font-weight:700}#like-bot-footer{padding:10px;text-align:center}#like-bot-pause-btn{color:#fff;border:none;padding:9px 10px;border-radius:10px;cursor:pointer;width:90%;font-size:14px;font-weight:700;transition:all .2s ease}#like-bot-content,#like-bot-footer{transition:all .35s ease-in-out,max-height .35s ease-in-out,padding .35s ease-in-out;max-height:200px;overflow:hidden}#like-bot-panel.collapsed #like-bot-content,#like-bot-panel.collapsed #like-bot-footer{max-height:0;padding-top:0;padding-bottom:0;opacity:0;border-top:none}#like-bot-header{transition:background-color .3s ease}#like-bot-panel.collapsed #like-bot-header{border-bottom:none}@media (prefers-color-scheme:light){#like-bot-panel{background:rgba(255,255,255,.7);border:1px solid rgba(0,0,0,.08)}#like-bot-header,#like-bot-content p{color:#333}#like-bot-header{background-color:rgba(0,0,0,.05);border-bottom:1px solid rgba(0,0,0,.08)}#like-bot-footer{border-top:1px solid rgba(0,0,0,.08)}#like-bot-status{color:#e74c3c}#like-bot-countdown{color:#4285f4}#like-bot-counter{color:#f4b400}#like-bot-pause-btn{background-color:#4285f4}#like-bot-pause-btn:hover{background-color:#3367d6;transform:scale(1.02)}}@media (prefers-color-scheme:dark){#like-bot-panel{background:rgba(34,34,34,.6);border:1px solid rgba(255,255,255,.1)}#like-bot-header,#like-bot-content p{color:#f0f0f0;text-shadow:0 1px 3px rgba(0,0,0,.7)}#like-bot-header{background-color:rgba(255,255,255,.05);border-bottom:1px solid rgba(255,255,255,.1)}#like-bot-footer{border-top:1px solid rgba(255,255,255,.1)}#like-bot-status{color:#2ecc71}#like-bot-countdown{color:#3498db}#like-bot-counter{color:#f4b400}#like-bot-pause-btn{background-color:rgba(66,133,244,.8);border:1px solid rgba(255,255,255,.2)}#like-bot-pause-btn:hover{background-color:#4285f4;box-shadow:0 0 15px rgba(66,133,244,.6);transform:scale(1.02)}}`); }
function setupNavigationObserver() {
let lastPathname = location.pathname;
let lastTopicId = extractTopicId(location.href);
let debounceTimer = null;
const mainOutlet = document.querySelector('#main-outlet');
if (!mainOutlet) {
err("无法找到 #main-outlet,导航观察器启动失败!");
return;
}
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const currentPathname = location.pathname;
const currentTopicId = extractTopicId(location.href);
if (currentPathname !== lastPathname || currentTopicId !== lastTopicId) {
log(`检测到页面导航: ${lastPathname} -> ${currentPathname}`);
lastPathname = currentPathname;
lastTopicId = currentTopicId;
resetProcessingState();
setTimeout(processLikes, 500);
}
}, 300);
});
observer.observe(mainOutlet, { childList: true, subtree: false });
log("页面导航观察器已启动。");
window.addEventListener('beforeunload', () => {
if (observer) {
observer.disconnect();
log("导航观察器已清理。");
}
clearTimeout(debounceTimer);
resetProcessingState();
});
return observer;
}
// --- 脚本入口 ---
(async function init() {
try {
await new Promise(resolve => (document.body ? resolve() : window.addEventListener('DOMContentLoaded', resolve, { once: true })));
log("初始化脚本 v10.3...");
createUIPanel();
injectStyles();
isCollapsed = await GM_getValue(collapsedStateKey, false);
if(uiPanel) uiPanel.classList.toggle('collapsed', isCollapsed);
navigationObserver = setupNavigationObserver();
setTimeout(processLikes, 1000);
} catch (e) {
err("初始化失败:", e);
}
})();
})();