Linux.do Credit Redenvelope

在 linux.do 帖子检测积分红包链接并在主帖操作栏添加快捷领取按钮

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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="关闭">&times;</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();
  }
})();