Linux.do 抽奖器

在Linux.do平台上进行抽奖,支持文章切换时自动更新,以表格形式展示结果,包含用户头像和参与时间,支持时间范围选择

// ==UserScript==
// @name         Linux.do 抽奖器
// @namespace    http://linux.do/
// @version      1.0.3
// @description  在Linux.do平台上进行抽奖,支持文章切换时自动更新,以表格形式展示结果,包含用户头像和参与时间,支持时间范围选择
// @author       PastKing
// @match        https://www.linux.do/t/topic/*
// @match        https://linux.do/t/topic/*
// @grant        none
// @license      MIT
// @icon         https://cdn.linux.do/uploads/default/optimized/1X/3a18b4b0da3e8cf96f7eea15241c3d251f28a39b_2_32x32.png
// ==/UserScript==
(function () {
  "use strict";
  let uiElements = null;
  // 创建UI元素
  function createUI() {
    const container = document.createElement("div");
    container.style.cssText = `
            background-color: #ffffff;
            padding: 30px;
            border-radius: 10px;
            margin: 30px auto;
            text-align: center;
            max-width: 800px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            font-family: Arial, sans-serif;
            margin-bottom: 0 !important;
        `;
    const title = document.createElement("h2");
    title.textContent = "🎉 Linux.do 抽奖器 - @PastKing";
    title.style.cssText = `
            color: #2c3e50;
            margin-bottom: 25px;
            font-weight: bold;
        `;
    const dateContainer = document.createElement("div");
    dateContainer.style.cssText = `
            display: flex;
            justify-content: center;
            align-items: center;
            margin-bottom: 25px;
        `;
    const startDateTimeInput = document.createElement("input");
    startDateTimeInput.type = "datetime-local";
    startDateTimeInput.style.cssText = `
            padding: 10px;
            margin: 0 10px;
            border: 1px solid #bdc3c7;
            border-radius: 5px;
            font-size: 14px;
        `;
    const endDateTimeInput = document.createElement("input");
    endDateTimeInput.type = "datetime-local";
    endDateTimeInput.style.cssText = startDateTimeInput.style.cssText;
    dateContainer.appendChild(createLabel("开始时间:"));
    dateContainer.appendChild(startDateTimeInput);
    dateContainer.appendChild(createLabel("结束时间:"));
    dateContainer.appendChild(endDateTimeInput);
    const inputContainer = document.createElement("div");
    inputContainer.style.cssText = `
            display: flex;
            justify-content: center;
            align-items: center;
            margin-bottom: 25px;
        `;
    const input = document.createElement("input");
    input.type = "number";
    input.min = "1";
    input.placeholder = "抽取数量";
    input.style.cssText = `
            padding: 10px;
            margin-right: 15px;
            border: 1px solid #bdc3c7;
            border-radius: 5px;
            font-size: 14px;
            width: 120px;
            marginBottom: '0 !important'
        `;
    const button = document.createElement("button");
    button.textContent = "开始抽奖";
    button.style.cssText = `
            padding: 10px 20px;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            cursor: pointer;
            transition: background-color 0.3s;
        `;
    button.onmouseover = () => (button.style.backgroundColor = "#2980b9");
    button.onmouseout = () => (button.style.backgroundColor = "#3498db");
    inputContainer.appendChild(input);
    inputContainer.appendChild(button);
    const result = document.createElement("div");
    container.appendChild(title);
    container.appendChild(dateContainer);
    container.appendChild(inputContainer);
    container.appendChild(result);
    return {
      container,
      input,
      button,
      result,
      startDateTimeInput,
      endDateTimeInput,
    };
  }
  function createLabel(text) {
    const label = document.createElement("label");
    label.textContent = text;
    label.style.cssText = `
            font-size: 14px;
            color: #34495e;
            margin-right: 5px;
        `;
    return label;
  }
  // 格式化日期
  function formatDate(dateString) {
    const date = new Date(dateString);
    return date.toLocaleString("zh-CN", {
      year: "numeric",
      month: "numeric",
      day: "numeric",
      hour: "2-digit",
      minute: "2-digit",
    });
  }
  // 获取候选人列表
  async function getCandidateList(startDateTime, endDateTime) {
    const topicId = window.location.pathname.split("/")[3];
    let candidateList = [];
    let nameList = new Set();
    const start = startDateTime ? new Date(startDateTime) : null;
    const end = endDateTime ? new Date(endDateTime) : null;

    // 首先获取主题信息以确定总页数
    const initialResponse = await fetch(`/t/${topicId}.json`);
    const initialData = await initialResponse.json();
    const totalPosts = initialData.posts_count;
    const totalPages = Math.ceil(totalPosts / 20); // 每页20个帖子
    const topicOwner = initialData.details.created_by.username;

    // 更新进度显示
    const progressDiv = document.createElement("div");
    progressDiv.style.cssText = `
        margin: 10px 0;
        padding: 10px;
        background-color: #f8f9fa;
        border-radius: 5px;
    `;
    uiElements.result.appendChild(progressDiv);

    // 分批处理页面
    const batchSize = 5; // 每批处理的页面数
    for (let page = 1; page <= totalPages; page += batchSize) {
      const batchPromises = [];

      // 创建这一批次的请求
      for (let i = 0; i < batchSize && page + i <= totalPages; i++) {
        const currentPage = page + i;
        batchPromises.push(
          fetch(`/t/${topicId}.json?page=${currentPage}`).then((response) =>
            response.ok ? response.json() : null
          )
        );
      }

      // 等待这一批次的所有请求完成
      const results = await Promise.all(batchPromises);

      // 处理结果
      results.forEach((result) => {
        if (result && result.post_stream && result.post_stream.posts) {
          result.post_stream.posts.forEach((post) => {
            const postDate = new Date(post.created_at);
            if ((start && postDate < start) || (end && postDate > end)) return;

            const onlyName = post.username;
            if (!nameList.has(onlyName) && onlyName !== topicOwner) {
              const candidate = {
                only_name: onlyName,
                display_name: post.display_username,
                post_number: post.post_number,
                created_at: post.created_at,
                avatar: post.avatar_template.replace("{size}", "90"),
              };
              candidateList.push(candidate);
              nameList.add(onlyName);
            }
          });
        }
      });

      // 更新进度
      const progress = Math.min(
        100,
        Math.round(((page + batchSize - 1) / totalPages) * 100)
      );
      progressDiv.innerHTML = `正在加载数据... ${progress}% (${candidateList.length} 个候选人)`;

      // 添加短暂延迟以避免请求过快
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }

    progressDiv.remove();
    return candidateList;
  }
  // 执行抽奖
  async function performLottery(count, startDateTime, endDateTime) {
    uiElements.result.innerHTML =
      '<p style="color: #3498db; font-weight: bold;">正在收集候选人数据...</p>';

    const candidates = await getCandidateList(startDateTime, endDateTime);

    if (candidates.length === 0) {
      return { error: "在选定的时间范围内没有找到任何候选人。" };
    }

    if (count > candidates.length) {
      return {
        error: `抽奖人数不能多于唯一发帖人数。当前只有 ${candidates.length} 个符合条件的候选人。`,
      };
    }

    const chosenPosts = [];
    const winners = new Set();
    while (winners.size < count && candidates.length > 0) {
      const randomIndex = Math.floor(Math.random() * candidates.length);
      const winner = candidates.splice(randomIndex, 1)[0];
      if (!winners.has(winner.only_name)) {
        winners.add(winner.only_name);
        chosenPosts.push(winner);
      }
    }

    return { winners: chosenPosts };
  }
  // 显示抽奖结果
  function displayResults(results) {
    uiElements.result.innerHTML =
      '<h3 style="color: #2c3e50; margin-bottom: 20px;">🏆 抽奖结果 <button id="copyAllButton" style="padding: 5px 10px; background-color: #e67e22; color: white; border: none; border-radius: 5px; font-size: 14px; cursor: pointer;">一键复制全体中奖信息</button></h3>';
    const copyAllButton = document.getElementById("copyAllButton");
    copyAllButton.onclick = () => {
      const winnerNames = results
        .map((result) => `@${result.only_name}`)
        .join(", ");
      const currentDate = new Date().toLocaleString("zh-CN", {
        year: "numeric",
        month: "numeric",
        day: "numeric",
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: false,
      });
      const markdownText = `🎉📢 恭喜以下幸运用户成功中奖:\n   ${winnerNames}\n\n📅 开奖日期:${currentDate}\n🎁 奖品信息:\n   \n\n✨ 再次感谢所有参与者的热情支持!\n💫 未中奖的小伙伴也不要灰心,继续关注我们的后续活动哦~\n\n**请中奖用户及时关注私信**`;
      navigator.clipboard.writeText(markdownText).then(
        () => {
          alert("全体中奖信息已复制到剪贴板!");
        },
        () => {
          alert("复制失败,请手动复制。");
        }
      );
    };

    const table = document.createElement("table");
    table.style.cssText = `
            width: 100%;
            border-collapse: separate;
            border-spacing: 0;
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
        `;
    const headerRow = table.insertRow();
    ["序号", "头像", "用户名", "楼层", "参与时间", "独立中奖信息"].forEach(
      (text) => {
        const th = document.createElement("th");
        th.textContent = text;
        th.style.cssText = `
                padding: 15px;
                background-color: #f2f2f2;
                color: #333;
                font-weight: bold;
                text-align: left;
                border-bottom: 2px solid #ddd;
            `;
        headerRow.appendChild(th);
      }
    );
    results.forEach((result, index) => {
      const row = table.insertRow();
      row.style.backgroundColor = index % 2 === 0 ? "#ffffff" : "#f9f9f9";

      const cellIndex = row.insertCell();
      cellIndex.textContent = index + 1;
      cellIndex.style.cssText = `
                padding: 12px 15px;
                text-align: center;
                font-weight: bold;
                color: #3498db;
            `;

      const cellAvatar = row.insertCell();
      const avatar = document.createElement("img");
      avatar.src = result.avatar.startsWith("http")
        ? result.avatar
        : `https://linux.do${result.avatar}`;
      avatar.style.cssText = `
                width: 40px;
                height: 40px;
                border-radius: 50%;
                display: block;
                margin: 0 auto;
                border: 2px solid #3498db;
            `;
      cellAvatar.appendChild(avatar);
      cellAvatar.style.padding = "12px 15px";

      const cellUsername = row.insertCell();
      const userLink = document.createElement("a");
      userLink.href = `https://linux.do/u/${encodeURIComponent(
        result.only_name
      )}/summary`;
      userLink.textContent = `@${result.only_name}`;
      userLink.target = "_blank";
      userLink.style.cssText = `
                text-decoration: none;
                color: #3498db;
                font-weight: bold;
                transition: color 0.3s;
            `;
      userLink.onmouseover = () => (userLink.style.color = "#2980b9");
      userLink.onmouseout = () => (userLink.style.color = "#3498db");
      cellUsername.appendChild(userLink);
      cellUsername.style.cssText = `
                padding: 12px 15px;
                text-align: left;
            `;

      const cellNumber = row.insertCell();
      cellNumber.textContent = `#${result.post_number}`;
      cellNumber.style.cssText = `
                padding: 12px 15px;
                text-align: center;
                color: #7f8c8d;
            `;

      const cellTime = row.insertCell();
      cellTime.textContent = formatDate(result.created_at);
      cellTime.style.cssText = `
                padding: 12px 15px;
                text-align: center;
                color: #7f8c8d;
            `;

      const cellCopy = row.insertCell();
      const copyButton = document.createElement("button");
      copyButton.textContent = "复制信息";
      copyButton.style.cssText = `
                padding: 5px 10px;
                background-color: #2ecc71;
                color: white;
                border: none;
                border-radius: 5px;
                font-size: 14px;
                cursor: pointer;
            `;
      copyButton.onclick = () => {
        const currentDate = new Date().toLocaleDateString("zh-CN", {
          year: "numeric",
          month: "long",
          day: "numeric",
          hour: "2-digit",
          minute: "2-digit",
          second: "2-digit",
        });
        const markdownText = `🎉🎊 热烈祝贺 @${result.only_name}!成功中奖!🏆\n\n📅 中奖日期: ${currentDate}\n🔢 幸运楼层: #${result.post_number}\n🎁 获得奖品:\n   -   (具体奖品信息请查看活动详情)\n\n🙏 感谢你的热情参与和支持!\n🌟 希望你能继续关注我们的后续活动哦~`;
        navigator.clipboard.writeText(markdownText).then(
          () => {
            alert("信息已复制到剪贴板!");
          },
          () => {
            alert("复制失败,请手动复制。");
          }
        );
      };
      cellCopy.appendChild(copyButton);
      cellCopy.style.cssText = `
                padding: 12px 15px;
                text-align: center;
            `;
    });
    uiElements.result.appendChild(table);
  }
  // 主函数
  function main() {
    uiElements = createUI();
    // 插入UI到指定位置
    const targetElement = document.querySelector("#post_1 > div.row");
    if (targetElement) {
      targetElement.parentNode.insertBefore(
        uiElements.container,
        targetElement.nextSibling
      );
      // 强制移除目标元素的 marginBottom
      function removeMarginBottom() {
        targetElement.style.setProperty("margin-bottom", "0", "important");
        const computedStyle = window.getComputedStyle(targetElement);
        if (computedStyle.getPropertyValue("margin-bottom") !== "0px") {
          targetElement.style.setProperty("margin-bottom", "-9px", "important");
        }
      }
      removeMarginBottom();
      const observer = new MutationObserver(removeMarginBottom);
      observer.observe(targetElement, {
        attributes: true,
        attributeFilter: ["style"],
      });
      setInterval(removeMarginBottom, 100);
    } else {
      console.error("无法找到目标插入位置");
      return;
    }
    uiElements.button.addEventListener("click", async () => {
      const count = parseInt(uiElements.input.value);
      if (isNaN(count) || count < 1) {
        uiElements.result.innerHTML =
          '<p style="color: #e74c3c; font-weight: bold;">请输入有效的抽取数量。</p>';
        return;
      }
      const startDateTime = uiElements.startDateTimeInput.value
        ? new Date(uiElements.startDateTimeInput.value)
        : null;
      const endDateTime = uiElements.endDateTimeInput.value
        ? new Date(uiElements.endDateTimeInput.value)
        : null;
      if (startDateTime && endDateTime && startDateTime > endDateTime) {
        uiElements.result.innerHTML =
          '<p style="color: #e74c3c; font-weight: bold;">开始时间不能晚于结束时间。</p>';
        return;
      }
      uiElements.button.disabled = true;
      uiElements.button.textContent = "抽奖中...";
      uiElements.button.style.backgroundColor = "#bdc3c7";
      uiElements.result.innerHTML =
        '<p style="color: #3498db; font-weight: bold;">正在抽奖,请稍候...</p>';
      const lotteryResults = await performLottery(
        count,
        startDateTime,
        endDateTime
      );
      if (lotteryResults.error) {
        uiElements.result.innerHTML = `<p style="color: #e74c3c; font-weight: bold;">${lotteryResults.error}</p>`;
      } else {
        displayResults(lotteryResults.winners);
      }
      uiElements.button.disabled = false;
      uiElements.button.textContent = "开始抽奖";
      uiElements.button.style.backgroundColor = "#3498db";
    });
  }
  // 运行主函数
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", main);
  } else {
    main();
  }
})();