// ==UserScript== // @name Linux.do CDK 倒计时提醒 // @namespace https://linux.do/ // @version 1.5.0 // @description 自动识别 cdk.linux.do/receive 页项目名与时间段并去重添加倒计时; // @author popy // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_notification // @grant GM_openInTab // @connect raw.githubusercontent.com // @icon https://linux.do/uploads/default/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_180x180.png // ==/UserScript== (() => { "use strict"; if (window.top !== window.self) return; // ⚠️ 不再改 key,避免你之前存的任务丢失 const KEY_TIMERS = "linuxdo_cdk_timers_v5"; const KEY_SETTINGS = "linuxdo_cdk_settings_v5"; const DEFAULT_SETTINGS = { preAlertSec: 60, enablePreAlert: true, enableAtAlert: true, openInNewTab: true, beep: true, panelMinimized: false, autoHideWhenEmpty: true, panelHidden: false, // 位置仍用 right/bottom(更符合你最初需求),但加入“边界限制”避免拖出屏幕 panelPos: { right: 16, bottom: 16 }, // ✅ 自动抓取时:当前时间 > 开始领取时间 => 不添加(并删除已自动添加的同任务) ignorePastStartOnAutoAdd: true, startGraceSec: 0, // ✅ 清理:目标时间已过(只清理 autoCreated=true 的自动任务,避免出现负数) autoPurgePastTarget: true, targetGraceSec: 0, // 兜底:结束时间过了也可清理(防历史垃圾) autoPurgeExpiredByEnd: true, endGraceSec: 10 }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const nowMs = () => Date.now(); function safeJsonParse(str, fallback) { try { return JSON.parse(str); } catch { return fallback; } } function getSettings() { const raw = GM_getValue(KEY_SETTINGS, ""); const s = raw ? safeJsonParse(raw, null) : null; return { ...DEFAULT_SETTINGS, ...(s || {}) }; } function setSettings(next) { GM_setValue(KEY_SETTINGS, JSON.stringify(next)); } function normalizeUrl(url) { try { const u = new URL(url, location.href); u.hash = ""; u.search = ""; u.pathname = u.pathname.replace(/\/+$/, ""); return u.toString(); } catch { return String(url || "").split("#")[0].split("?")[0].replace(/\/+$/, ""); } } function extractUuid(url) { try { const u = new URL(url, location.href); const seg = u.pathname.split("/").filter(Boolean); return seg.length ? seg[seg.length - 1] : null; } catch { const seg = String(url).split("?")[0].split("#")[0].split("/").filter(Boolean); return seg.length ? seg[seg.length - 1] : null; } } function isCdkReceiveUrl(url) { const n = normalizeUrl(url); return /^https?:\/\/cdk\.linux\.do\/receive\/[^/]+$/i.test(n); } function parseLocalDateTime(str) { // "YYYY/MM/DD HH:MM:SS" const m = String(str).trim().match(/^(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = +m[1], mo = +m[2] - 1, d = +m[3], h = +m[4], mi = +m[5], s = +m[6]; const dt = new Date(y, mo, d, h, mi, s); const ts = dt.getTime(); return Number.isFinite(ts) ? ts : null; } function formatTs(ts) { if (!Number.isFinite(ts)) return "-"; const d = new Date(ts); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } // ✅ 永不显示负数:<=0 显示“已到” function formatRemainSmart(ms) { if (!Number.isFinite(ms)) return "-"; if (ms <= 0) return "已到"; const sec = Math.floor(ms / 1000); const s = sec % 60; const m = Math.floor(sec / 60) % 60; const h = Math.floor(sec / 3600) % 24; const d = Math.floor(sec / 86400); const parts = []; if (d) parts.push(`${d}天`); if (h || d) parts.push(`${h}小时`); if (m || h || d) parts.push(`${m}分`); parts.push(`${s}秒`); return parts.join(""); } function shortIdFromUrl(url) { const uuid = extractUuid(url) || "CDK"; return uuid.slice(0, 8); } function makeStableIdForCdk(url) { const uuid = extractUuid(url); return uuid ? `cdk_${uuid}` : `cdk_${shortIdFromUrl(url)}_${Date.now()}`; } function tryBeep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.type = "sine"; o.frequency.value = 880; g.gain.value = 0.05; o.start(); setTimeout(() => { o.stop(); ctx.close(); }, 160); } catch (_) {} } function openUrl(url) { const settings = getSettings(); const nurl = normalizeUrl(url); if (settings.openInNewTab && typeof GM_openInTab === "function") { GM_openInTab(nurl, { active: true, insert: true }); } else { window.location.href = nurl; } } function notify(title, text, url) { const settings = getSettings(); GM_notification({ title, text, timeout: 9000, onclick: () => { if (url) openUrl(url); } }); if (settings.beep) tryBeep(); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } /********************** * timers 存储 + 去重 **********************/ function dedupeTimers(arr) { const byId = new Map(); const byUrl = new Map(); for (const t of (Array.isArray(arr) ? arr : [])) { if (!t) continue; const url = normalizeUrl(t.url || ""); const id = String(t.id || "").trim(); const upd = Number.isFinite(t.updatedAt) ? t.updatedAt : 0; if (id) { const old = byId.get(id); if (!old || (Number.isFinite(old.updatedAt) ? old.updatedAt : 0) <= upd) { byId.set(id, { ...old, ...t, url }); } } else if (url) { const old = byUrl.get(url); if (!old || (Number.isFinite(old.updatedAt) ? old.updatedAt : 0) <= upd) { byUrl.set(url, { ...old, ...t, url }); } } } const out = []; const usedUrl = new Set(); for (const v of byId.values()) { if (v.url) usedUrl.add(v.url); out.push(v); } for (const v of byUrl.values()) { if (v.url && usedUrl.has(v.url)) continue; out.push(v); } return out; } function getTimers() { const raw = GM_getValue(KEY_TIMERS, "[]"); const arr = safeJsonParse(raw, []); const fixed = dedupeTimers(arr); if (Array.isArray(arr) && fixed.length !== arr.length) { GM_setValue(KEY_TIMERS, JSON.stringify(fixed)); } return fixed; } function setTimers(nextArr) { GM_setValue(KEY_TIMERS, JSON.stringify(dedupeTimers(nextArr))); } /********************** * 解析 cdk 页面:时间段 + 项目名 **********************/ function findTimeRangeText() { const nodes = Array.from(document.querySelectorAll("div.text-sm.text-muted-foreground")); for (const n of nodes) { const t = (n.textContent || "").trim(); if (/\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s*-\s*\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}/.test(t)) return t; } const all = Array.from(document.querySelectorAll("div")); for (const n of all) { const t = (n.textContent || "").trim(); if (t.length > 80) continue; if (/\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s*-\s*\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}/.test(t)) return t; } return null; } function parseTimeRangeToTs(rangeText) { const m = String(rangeText).trim().match( /(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})\s*-\s*(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})/ ); if (!m) return null; const startTs = parseLocalDateTime(m[1]); const endTs = parseLocalDateTime(m[2]); if (!startTs || !endTs) return null; return { startTs, endTs }; } function findProjectName() { let el = document.querySelector(".text-left.space-y-4 .text-4xl.font-bold"); if (el && (el.textContent || "").trim()) return (el.textContent || "").trim(); el = document.querySelector(".text-4xl.font-bold"); if (el && (el.textContent || "").trim()) return (el.textContent || "").trim(); try { const xp = "/html/body/div[2]/div[2]/div/div/div/div/div/div/div[2]/div[1]/div[1]"; const r = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const host = r.singleNodeValue; if (host) { const t = host.querySelector(".text-4xl.font-bold"); if (t && (t.textContent || "").trim()) return (t.textContent || "").trim(); } } catch (_) {} return null; } function getTargetTs(timer) { return timer.target === "end" ? timer.endTs : timer.startTs; } /********************** * ✅ 清理逻辑(避免出现负数任务) **********************/ function shouldPurgeByPastTarget(timer, settings) { if (!settings.autoPurgePastTarget) return false; if (!timer || timer.autoCreated !== true) return false; // 只清理自动添加的 const targetTs = getTargetTs(timer); if (!Number.isFinite(targetTs)) return false; const graceMs = (settings.targetGraceSec || 0) * 1000; return nowMs() > (targetTs + graceMs); } function shouldPurgeByEnd(timer, settings) { if (!settings.autoPurgeExpiredByEnd) return false; if (!timer) return false; const endTs = timer.endTs; if (!Number.isFinite(endTs)) return false; const graceMs = (settings.endGraceSec || 0) * 1000; return nowMs() > (endTs + graceMs); } function purgeIfNeeded() { const settings = getSettings(); const timers = getTimers(); if (!timers.length) return; const kept = timers.filter(t => !(shouldPurgeByPastTarget(t, settings) || shouldPurgeByEnd(t, settings))); if (kept.length !== timers.length) setTimers(kept); } /********************** * ✅ 自动添加:如果当前时间 > 开始领取时间 => 不添加,并移除该自动任务 **********************/ function upsertTimerFromCdkPage() { const settings = getSettings(); const url = normalizeUrl(location.href); if (!isCdkReceiveUrl(url)) return false; const rangeText = findTimeRangeText(); if (!rangeText) return false; const ts = parseTimeRangeToTs(rangeText); if (!ts) return false; const graceMs = (settings.startGraceSec || 0) * 1000; const pastStart = nowMs() > (ts.startTs + graceMs); const name = findProjectName(); const id = makeStableIdForCdk(url); const timers = getTimers(); const idx = timers.findIndex(x => x.id === id || normalizeUrl(x.url || "") === url); const autoLabel = name ? `${name}(${shortIdFromUrl(url)})` : `CDK ${shortIdFromUrl(url)}`; // ✅ 如果开始时间已过:不新增;若之前已有“自动创建”的同 id 任务,直接删掉(彻底防止负数) if (settings.ignorePastStartOnAutoAdd && pastStart) { if (idx >= 0 && timers[idx]?.autoCreated === true) { timers.splice(idx, 1); setTimers(timers); } return false; } // 未过开始:可以新增/更新 if (idx >= 0) { const t = timers[idx]; const oldTarget = getTargetTs(t); if (t.autoLabel !== false) { t.label = autoLabel; t.autoLabel = true; } t.id = id; t.url = url; t.projectName = name || t.projectName || null; t.startTs = ts.startTs; t.endTs = ts.endTs; t.updatedAt = Date.now(); t.autoCreated = true; const newTarget = getTargetTs(t); if (Number.isFinite(oldTarget) && Number.isFinite(newTarget) && oldTarget !== newTarget) { t.preFired = false; t.atFired = false; } setTimers(timers); return true; } else { timers.push({ id, url, label: autoLabel, autoLabel: true, autoCreated: true, projectName: name || null, startTs: ts.startTs, endTs: ts.endTs, target: "start", preFired: false, atFired: false, createdAt: Date.now(), updatedAt: Date.now() }); setTimers(timers); notify("已自动添加 CDK 倒计时", `${autoLabel}\n开始:${formatTs(ts.startTs)}\n结束:${formatTs(ts.endTs)}`, url); return true; } } /********************** * 提醒逻辑(每秒检查) **********************/ function checkAndFireAlerts() { const settings = getSettings(); const timers = getTimers(); if (!timers.length) return; const tnow = nowMs(); let changed = false; for (const t of timers) { const targetTs = getTargetTs(t); if (!Number.isFinite(targetTs)) continue; // 目标已过就不提醒 //if (tnow > targetTs) continue; if (settings.enablePreAlert && !t.preFired) { const preTs = targetTs - settings.preAlertSec * 1000; if (tnow >= preTs && tnow < targetTs) { t.preFired = true; changed = true; notify( "CDK领取时间快到了", `${t.label}\n剩余:${formatRemainSmart(targetTs - tnow)}\n目标:${t.target === "end" ? "结束" : "开始"} ${formatTs(targetTs)}`, t.url ); } } if (settings.enableAtAlert && !t.atFired) { if (tnow >= targetTs) { t.atFired = true; changed = true; notify( "CDK 到时间了!", `${t.label}\n目标:${t.target === "end" ? "结束" : "开始"} ${formatTs(targetTs)}\n点击通知打开领取页`, t.url ); } } } if (changed) setTimers(timers); } /********************** * UI:Shadow DOM **********************/ const UI = { root: null, shadow: null }; function getNextTimer(timers) { const valid = timers.filter(t => Number.isFinite(getTargetTs(t))); if (!valid.length) return null; valid.sort((a, b) => getTargetTs(a) - getTargetTs(b)); const n = nowMs(); return valid.find(t => getTargetTs(t) >= n) || valid[valid.length - 1]; } // ✅ 位置边界限制:防止拖出屏幕导致“卡住” function clampPos(right, bottom, rect) { const maxRight = Math.max(0, window.innerWidth - rect.width); const maxBottom = Math.max(0, window.innerHeight - rect.height); const r = Math.min(Math.max(0, right), maxRight); const b = Math.min(Math.max(0, bottom), maxBottom); return { right: r, bottom: b }; } function applyPanelPos(pos) { if (!UI.root) return; UI.root.style.right = `${pos.right}px`; UI.root.style.bottom = `${pos.bottom}px`; } function ensurePanelInViewportAndSave() { if (!UI.root || UI.root.style.display === "none") return; const rect = UI.root.getBoundingClientRect(); const st = getSettings(); const curRight = Number.isFinite(st.panelPos?.right) ? st.panelPos.right : parseInt(UI.root.style.right || "16", 10); const curBottom = Number.isFinite(st.panelPos?.bottom) ? st.panelPos.bottom : parseInt(UI.root.style.bottom || "16", 10); const cl = clampPos(curRight, curBottom, rect); applyPanelPos(cl); // 写回(避免 resize 后越界) st.panelPos = { right: cl.right, bottom: cl.bottom }; setSettings(st); } function isInteractiveTarget(target) { if (!target) return false; return !!(target.closest && target.closest("button, input, select, textarea, a, .link")); } // ✅ 修复:让“头部 + 底部(footer)”都可拖动;并且带 clamp,永远不会拖出屏幕卡住 function setupDrag(handleEl) { if (!handleEl) return; handleEl.style.touchAction = "none"; let dragging = false; let startX = 0, startY = 0; let startRight = 0, startBottom = 0; let dragRect = null; let pid = null; const onDown = (e) => { if (e.button !== 0) return; if (isInteractiveTarget(e.target)) return; dragging = true; pid = e.pointerId; startX = e.clientX; startY = e.clientY; dragRect = UI.root.getBoundingClientRect(); const st = getSettings(); startRight = Number.isFinite(st.panelPos?.right) ? st.panelPos.right : parseInt(UI.root.style.right || "16", 10); startBottom = Number.isFinite(st.panelPos?.bottom) ? st.panelPos.bottom : parseInt(UI.root.style.bottom || "16", 10); try { handleEl.setPointerCapture(pid); } catch (_) {} e.preventDefault(); }; const onMove = (e) => { if (!dragging || e.pointerId !== pid) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // right/bottom 体系:向右拖 => right 变小;向下拖 => bottom 变小 let nextRight = startRight - dx; let nextBottom = startBottom - dy; const cl = clampPos(nextRight, nextBottom, dragRect || UI.root.getBoundingClientRect()); applyPanelPos(cl); }; const onUp = (e) => { if (!dragging || e.pointerId !== pid) return; dragging = false; try { handleEl.releasePointerCapture(pid); } catch (_) {} // 保存最终位置(再 clamp 一次) const rect = UI.root.getBoundingClientRect(); const st = getSettings(); const curRight = parseInt(UI.root.style.right || "16", 10); const curBottom = parseInt(UI.root.style.bottom || "16", 10); const cl = clampPos(curRight, curBottom, rect); st.panelPos = { right: cl.right, bottom: cl.bottom }; setSettings(st); applyPanelPos(cl); renderPanel(); }; handleEl.addEventListener("pointerdown", onDown); handleEl.addEventListener("pointermove", onMove); handleEl.addEventListener("pointerup", onUp); handleEl.addEventListener("pointercancel", onUp); } function buildPanel() { if (UI.root) return; const settings = getSettings(); UI.root = document.createElement("div"); UI.root.id = "linuxdo-cdk-panel-root"; UI.root.style.cssText = ` position: fixed; z-index: 2147483647; right: ${settings.panelPos.right}px; bottom: ${settings.panelPos.bottom}px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; pointer-events: auto; `; UI.shadow = UI.root.attachShadow({ mode: "open" }); UI.shadow.innerHTML = `