在 linux.do 帖子检测积分红包链接并在主帖操作栏添加快捷领取按钮
// ==UserScript==
// @name Linux.do Credit Redenvelope
// @namespace https://github.com/talentedman/Tampermonkey-linux-do-agent
// @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @version 1.2.21
// @description 在 linux.do 帖子检测积分红包链接并在主帖操作栏添加快捷领取按钮
// @author ccc9527-c
// @match https://linux.do/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @connect credit.linux.do
// @run-at document-idle
// @license GPL-3.0-or-later
// ==/UserScript==
(function () {
"use strict";
// 匹配红包链接:ID 前可能混入非链接字符(中文注释、百分号编码等),跳过后再提取 ID
const REDENVELOPE_URL_PATTERN =
/https:\/\/credit\.linux\.do\/redenvelope\/(?:%[a-f0-9]{2}|[^a-f0-9])*([a-f0-9]+)/i;
// 纯文本匹配模式:scheme 可省略,用于扫描文本节点中的红包链接(可能包含多个)
// 允许 ID 前混入非链接字符,如 /redenvelope/删除94736656812212224
const REDENVELOPE_TEXT_PATTERN =
/(?:https?:\/\/)?credit\.linux\.do\/redenvelope\/(?:%[a-f0-9]{2}|[^a-f0-9])*([a-f0-9]+)/gi;
const API_CLAIM = "https://credit.linux.do/api/v1/redenvelope/claim";
const PREFIX = "credit-redenvelope-";
// 领取接口请求超时时间(毫秒)
const CLAIM_TIMEOUT_MS = 10000;
// 已注入按钮的元素集合
const injectedElements = new Set();
// 注入样式
GM_addStyle(`
/* 注入按钮样式 */
.${PREFIX}btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #e74c3c;
background: transparent;
border: none;
transition: all 0.2s ease;
}
.${PREFIX}btn:hover {
background: rgba(231, 76, 60, 0.1);
color: #c0392b;
}
.${PREFIX}btn.loading {
opacity: 0.6;
pointer-events: none;
}
.${PREFIX}btn.claimed {
color: #95a5a6;
}
.${PREFIX}btn.claimed:hover {
background: rgba(149, 165, 166, 0.1);
}
.${PREFIX}btn:disabled {
cursor: not-allowed;
}
.${PREFIX}btn img {
width: 16px;
height: 16px;
display: block;
}
/* 弹窗样式(浮动窗:不拦截页面操作,可拖拽、可多开) */
.${PREFIX}modal {
position: fixed;
background: #fff;
border-radius: 12px;
padding: 16px 24px 24px;
width: 300px;
max-width: calc(100vw - 16px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: ${PREFIX}slideUp 0.25s ease;
}
@keyframes ${PREFIX}slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.${PREFIX}modal.dragging {
user-select: none;
cursor: grabbing;
}
.${PREFIX}modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
cursor: move;
touch-action: none;
}
.${PREFIX}modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.${PREFIX}modal-close {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 0;
line-height: 1;
}
.${PREFIX}modal-close:hover {
color: #333;
}
.${PREFIX}modal-body {
margin-bottom: 24px;
}
.${PREFIX}info-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.${PREFIX}info-row:last-child {
border-bottom: none;
}
.${PREFIX}info-label {
color: #666;
font-size: 14px;
}
.${PREFIX}info-value {
color: #333;
font-size: 14px;
font-weight: 500;
}
.${PREFIX}amount {
font-size: 28px;
font-weight: 700;
color: #e74c3c;
}
.${PREFIX}modal-footer {
display: flex;
gap: 12px;
}
.${PREFIX}modal-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.${PREFIX}modal-btn-cancel {
background: #f0f0f0;
color: #666;
}
.${PREFIX}modal-btn-cancel:hover {
background: #e0e0e0;
}
.${PREFIX}modal-btn-confirm {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: #fff;
}
.${PREFIX}modal-btn-confirm:hover {
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
.${PREFIX}modal-btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.${PREFIX}loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.${PREFIX}spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top-color: #e74c3c;
border-radius: 50%;
animation: ${PREFIX}spin 0.8s linear infinite;
}
@keyframes ${PREFIX}spin {
to { transform: rotate(360deg); }
}
.${PREFIX}countdown {
margin-top: 12px;
font-size: 13px;
color: #666;
}
.${PREFIX}error {
color: #e74c3c;
text-align: center;
padding: 20px;
}
`);
// 提取 ID
function extractId(url) {
// 1. 直接匹配红包链接
const directMatch = url.match(REDENVELOPE_URL_PATTERN);
if (directMatch) {
return directMatch[1];
}
return null;
}
// 检查是否已领取
function isClaimed(id) {
const claimedList = GM_getValue("claimed_redenvelopes", []);
return claimedList.includes(id);
}
// 标记为已领取
function markAsClaimed(id, desc = "已领取") {
const claimedList = GM_getValue("claimed_redenvelopes", []);
if (!claimedList.includes(id)) {
claimedList.push(id);
GM_setValue("claimed_redenvelopes", claimedList);
}
// 如果页面上有对应的按钮,点击后也应该更新状态
document
.querySelectorAll(`.${PREFIX}btn[data-id="${id}"]`)
.forEach((btn) => {
btn.classList.add("claimed");
btn.querySelector("span").textContent = desc;
});
}
// 检查是否已领完(红包被抢光,与"本人已领取"区分)
function isFinished(id) {
const finishedList = GM_getValue("finished_redenvelopes", []);
return finishedList.includes(id);
}
// 标记为已领完:按钮置灰禁用,避免反复点击
function markAsFinished(id) {
const finishedList = GM_getValue("finished_redenvelopes", []);
if (!finishedList.includes(id)) {
finishedList.push(id);
GM_setValue("finished_redenvelopes", finishedList);
}
document
.querySelectorAll(`.${PREFIX}btn[data-id="${id}"]`)
.forEach((btn) => {
btn.classList.add("claimed");
btn.disabled = true;
btn.querySelector("span").textContent = "已领完";
});
}
// 领取红包
function claimRedenvelope(id) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: API_CLAIM,
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
data: JSON.stringify({
id: id,
}),
timeout: CLAIM_TIMEOUT_MS,
onload: function (response) {
try {
console.log(
"[Credit Redenvelope] 领取红包响应: ",
response,
);
const status = response.status;
if (status > 400) {
reject(new Error("服务器内部错误,请稍后重试"));
return;
}
const data = JSON.parse(response.responseText);
if (data.error_msg) {
// 已领完:按钮置灰禁用,标记为已领完
if (
data.error_msg.indexOf("已领完") != -1 ||
data.error_msg.indexOf("抢完") != -1
) {
markAsFinished(id);
} else if (data.error_msg.indexOf("领取过") != -1) {
// 本人已领取过:标记为已领取
markAsClaimed(id);
}
reject(new Error(data.error_msg));
} else {
markAsClaimed(id, "已领取");
resolve(data.data);
}
} catch (e) {
reject(new Error("领取请求失败: " + e.message));
}
},
ontimeout: function () {
reject(new Error(`请求超时(${CLAIM_TIMEOUT_MS / 1000} 秒),请稍后重试`));
},
onerror: function () {
reject(new Error("网络请求失败"));
},
});
});
}
// ============================================================================
// 弹窗拖拽(模块级共享监听,多个弹窗互不干扰)
// ============================================================================
let topZIndex = 10000;
let dragState = null;
document.addEventListener("mousemove", (e) => {
if (!dragState) return;
const { modal, startX, startY, originLeft, originTop } = dragState;
const left = Math.min(
Math.max(0, originLeft + e.clientX - startX),
window.innerWidth - modal.offsetWidth,
);
const top = Math.min(
Math.max(0, originTop + e.clientY - startY),
window.innerHeight - modal.offsetHeight,
);
modal.style.left = `${left}px`;
modal.style.top = `${top}px`;
});
document.addEventListener("mouseup", () => {
if (!dragState) return;
dragState.modal.classList.remove("dragging");
dragState = null;
});
// 创建红包弹窗(浮动窗:不拦截页面操作,可拖拽、可多开)
function showRedenvelopeModal(id) {
// 同一红包链接已打开过弹窗则不再重复弹出,直接置顶已有弹窗
const existing = document.querySelector(`.${PREFIX}modal[data-id="${id}"]`);
if (existing) {
existing.style.zIndex = String(++topZIndex);
return;
}
const modal = document.createElement("div");
modal.className = `${PREFIX}modal`;
modal.setAttribute("data-id", id);
modal.innerHTML = `
<div class="${PREFIX}modal-header">
<h3 class="${PREFIX}modal-title">领取红包</h3>
<button class="${PREFIX}modal-close" title="关闭">×</button>
</div>
<div class="${PREFIX}modal-body">
<div class="${PREFIX}loading">
<div class="${PREFIX}spinner"></div>
</div>
</div>
<div class="${PREFIX}modal-footer"></div>
`;
// 初始位置:视口居中,多个弹窗级联错开避免完全重叠
const cascade = document.querySelectorAll(`.${PREFIX}modal`).length * 24;
modal.style.left = `${Math.max(8, (window.innerWidth - 480) / 2 + cascade)}px`;
modal.style.top = `${Math.max(8, (window.innerHeight - 300) / 2 + cascade)}px`;
modal.style.zIndex = String(++topZIndex);
document.body.appendChild(modal);
const header = modal.querySelector(`.${PREFIX}modal-header`);
const closeModal = () => modal.remove();
header.querySelector(`.${PREFIX}modal-close`).onclick = closeModal;
// 点击弹窗任意位置时置顶
modal.addEventListener("mousedown", () => {
modal.style.zIndex = String(++topZIndex);
});
// 拖拽(仅通过标题栏)
header.addEventListener("mousedown", (e) => {
if (e.target.closest(`.${PREFIX}modal-close`)) return;
dragState = {
modal,
startX: e.clientX,
startY: e.clientY,
originLeft: modal.offsetLeft,
originTop: modal.offsetTop,
};
modal.classList.add("dragging");
e.preventDefault();
});
const body = modal.querySelector(`.${PREFIX}modal-body`);
const footer = modal.querySelector(`.${PREFIX}modal-footer`);
// 弹窗打开后直接调用领取接口,无需二次确认
async function claim() {
// 本地已标记为领取过:直接提示,不再重复请求
if (isClaimed(id)) {
body.innerHTML = `
<div style="text-align: center; padding: 20px; color: #666; font-size: 15px;">该红包已领取过啦!</div>
`;
footer.innerHTML = `
<button class="${PREFIX}modal-btn ${PREFIX}modal-btn-cancel">关闭</button>
`;
footer.querySelector("button").onclick = closeModal;
return;
}
body.innerHTML = `
<div class="${PREFIX}loading">
<div class="${PREFIX}spinner"></div>
<div class="${PREFIX}countdown">领取中... ${CLAIM_TIMEOUT_MS / 1000} 秒后超时</div>
</div>
`;
footer.innerHTML = "";
// 超时倒计时显示(与请求超时同步,请求结束即清除)
const countdownEl = body.querySelector(`.${PREFIX}countdown`);
let remain = Math.round(CLAIM_TIMEOUT_MS / 1000);
const countdownTimer = setInterval(() => {
remain--;
if (countdownEl) {
countdownEl.textContent = `领取中... ${Math.max(remain, 0)} 秒后超时`;
}
if (remain <= 0) clearInterval(countdownTimer);
}, 1000);
try {
const result = await claimRedenvelope(id);
const envelope = result.red_envelope;
body.innerHTML = `
<div style="text-align: center; padding: 20px;">
<div style="font-size: 48px; margin-bottom: 12px;">🎊</div>
<div style="font-size: 24px; color: #e74c3c; font-weight: 700; margin-bottom: 8px;">+${result.amount
} 积分</div>
<div style="font-size: 14px; color: #666;">${envelope.greeting || "恭喜发财,大吉大利"
}</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #eee; text-align: left;">
<div class="${PREFIX}info-row">
<span class="${PREFIX}info-label">发件人</span>
<span class="${PREFIX}info-value">@${envelope.creator_username
}</span>
</div>
<div class="${PREFIX}info-row">
<span class="${PREFIX}info-label">剩余/总计</span>
<span class="${PREFIX}info-value">${envelope.remaining_count
}/${envelope.total_count} 个</span>
</div>
</div>
</div>
`;
footer.innerHTML = `
<button class="${PREFIX}modal-btn ${PREFIX}modal-btn-confirm" style="flex: 1;">查看账户余额</button>
`;
footer.querySelector("button").onclick = () => {
closeModal();
window.open("https://credit.linux.do/balance", "_blank");
};
} catch (err) {
// 错误信息直接显示在弹窗内,不再使用 alert
body.innerHTML = `
<div class="${PREFIX}error">${err.message}</div>
`;
footer.innerHTML = `
<button class="${PREFIX}modal-btn ${PREFIX}modal-btn-cancel">关闭</button>
<button class="${PREFIX}modal-btn ${PREFIX}modal-btn-confirm">重试</button>
`;
const [closeBtn, retryBtn] = footer.querySelectorAll("button");
closeBtn.onclick = closeModal;
retryBtn.onclick = claim;
} finally {
clearInterval(countdownTimer);
}
}
claim();
}
// 创建红包按钮
function createRedenvelopeButton(id) {
const btn = document.createElement("button");
btn.className = `${PREFIX}btn`;
btn.setAttribute("data-id", id);
const claimed = isClaimed(id);
const finished = isFinished(id);
if (claimed || finished) {
btn.classList.add("claimed");
}
if (finished) {
btn.disabled = true;
}
btn.innerHTML = `
<span>${finished ? "已领完" : claimed ? "已领取" : "领取红包"}</span>
`;
btn.title = finished
? "该红包已被领完"
: claimed
? "该红包已领取"
: "点击领取积分红包";
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
showRedenvelopeModal(id);
});
return btn;
}
// 找到按钮注入位置:若链接位于 <details> 折叠块内,提升到最外层 details 之后,避免层层点开
function findButtonAnchor(el) {
let node = el;
let outermost = null;
while (node && node !== document.body) {
if (node.tagName === "DETAILS") outermost = node;
node = node.parentElement;
}
return outermost || el;
}
// 注入按钮到包含红包链接的元素后面
function injectButtons() {
// 1. 查找包含红包链接的 <a> 标签
document.querySelectorAll("a[href]").forEach((el) => {
if (injectedElements.has(el)) return;
const href = el.getAttribute("href");
const id = extractId(href);
if (id) {
injectedElements.add(el);
const btn = createRedenvelopeButton(id);
const anchor = findButtonAnchor(el);
anchor.parentNode.insertBefore(btn, anchor.nextSibling);
}
});
// 2. 查找纯文本形式的红包链接(<details> 折叠块内未转成 <a> 的情况)
if (typeof NodeFilter === "undefined") return;
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
);
let node;
while ((node = walker.nextNode())) {
const text = node.nodeValue || "";
// 快速过滤,避免对每个文本节点都做正则匹配
if (!text.includes("credit.linux.do")) continue;
// 排除 <a> 内部文本(已由上面的链接扫描处理)及 script/style 等非渲染内容
// (Discourse 会把帖子 HTML 预加载在页面顶部 <script> 中,避免误匹配)
const parent = node.parentElement;
if (
!parent ||
parent.closest("a, script, style, noscript, template")
) {
continue;
}
const matches = [...text.matchAll(REDENVELOPE_TEXT_PATTERN)];
if (!matches.length) continue;
for (const match of matches) {
const id = match[1];
// 插入目标:折叠块内则提升到最外层 details 之后,保证按钮可见
const anchor = findButtonAnchor(parent);
// 同一插入目标内已注入过该红包按钮则跳过(防止重复触发)
const injectedAttr = `data-${PREFIX}injected`;
const injectedIds = new Set(
(anchor.getAttribute(injectedAttr) || "").split(",").filter(Boolean),
);
if (injectedIds.has(id)) continue;
injectedIds.add(id);
anchor.setAttribute(injectedAttr, [...injectedIds].join(","));
console.log(
`[Credit Redenvelope] 检测到纯文本红包链接: ${id}(位于 <${parent.tagName.toLowerCase()}> 内)`,
);
const btn = createRedenvelopeButton(id);
anchor.parentNode.insertBefore(btn, anchor.nextSibling);
}
}
}
// 监听 DOM 变化
function observe() {
const observer = new MutationObserver(() => {
injectButtons();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
// 初始化
function init() {
injectButtons();
observe();
console.log("[Credit Redenvelope] 积分红包助手已加载(元素注入模式)");
}
// 等待页面加载完成
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();