Linux.do Credit Redenvelope

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
  }
})();