SG QuickJoin

一个基于 Tampermonkey的用户脚本,为 SteamGifts.com上的每个抽奖添加一键"Join / Leave"按钮。

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name            SG QuickJoin
// @namespace       https://github.com/HCLonely/SG-QuickJoin
// @version         1.0.8
// @description     一个基于 Tampermonkey的用户脚本,为 SteamGifts.com上的每个抽奖添加一键"Join / Leave"按钮。
// @description:en  Adds a 'one-click "Join / Leave"' button to each giveaway on SteamGifts
// @author          HCLonely
// @match           https://www.steamgifts.com/*
// @license         MIT
// @tag             games
// @homepage        https://github.com/HCLonely/SG-QuickJoin
// @supportURL      https://github.com/HCLonely/SG-QuickJoin/issues
// @icon            https://github.com/HCLonely/SG-QuickJoin/blob/main/icon.ico?raw=true
// @grant           GM_addStyle
// @grant           GM_registerMenuCommand
// @grant           GM_unregisterMenuCommand
// @grant           GM_setValue
// @grant           GM_getValue
// ==/UserScript==

"use strict";
(() => {
  // src/main.ts
  GM_addStyle(`
  .sg-quickjoin-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    align-self: stretch;
    flex-shrink: 0;
    white-space: nowrap;
    box-sizing: border-box;
    min-width: 180px;
    padding: 0 16px;
    border-radius: 0 4px 4px 0;
    font-size: 13px;
    font-weight: 500;
    transition: background 0.2s;
  }

  .sg-quickjoin-btn[data-state="idle"] {
    background: #7ba4f7;
    color: #fff;
    cursor: pointer;
    border: none;
  }

  .sg-quickjoin-btn[data-state="loading"] {
    background: #a0a7b3;
    color: #fff;
    cursor: wait;
    border: none;
  }

  .sg-quickjoin-btn[data-state="joined"] {
    background: #e8a860;
    color: #fff;
    cursor: pointer;
    border: none;
  }

  .sg-quickjoin-btn[data-state="error"] {
    background: #e07b7b;
    color: #fff;
    cursor: pointer;
    border: none;
  }

  .sg-quickjoin-btn[data-state="insufficient"] {
    background: #c5cad2;
    color: #fff;
    cursor: not-allowed;
    border: none;
  }

  .sg-quickjoin-btn[data-state="entered"] {
    background: #e8a860;
    color: #fff;
    cursor: pointer;
    border: none;
  }

  .sg-quickjoin-btn[data-state="leaving"] {
    background: #a0a7b3;
    color: #fff;
    cursor: wait;
    border: none;
  }

  .sg-quickjoin-header-fixed {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    z-index: 1000;
  }

  .sg-hide-joined .giveaway__row-outer-wrap:has(.sg-quickjoin-btn[data-state="entered"],.sg-quickjoin-btn[data-state="joined"]) {
    display: none;
  }
`);
  function extractCode(href) {
    if (!href) return "";
    const parts = href.split("/");
    return parts[2] ?? "";
  }
  function extractRequiredPoints(text) {
    const match = text.match(/\((\d+)P\)/i);
    return match ? parseInt(match[1], 10) : 0;
  }
  function getXsrfToken() {
    const el = document.querySelector(
      'input[name="xsrf_token"]'
    );
    return el?.value ?? "";
  }
  function getCurrentPoints() {
    const el = document.querySelector("span.nav__points");
    const text = el?.innerText?.trim() ?? "0";
    return parseInt(text, 10) || 0;
  }
  function updatePointsDisplay(points) {
    const el = document.querySelector("span.nav__points");
    if (el) {
      el.innerText = String(points);
    }
  }
  var STATE_TEXT = {
    idle: "Join",
    loading: "Joining...",
    joined: "Leave",
    error: "⚠ Error",
    insufficient: "Need more P",
    entered: "Leave",
    leaving: "Leaving...",
    ended: "Ended"
  };
  var CLICKABLE_STATES = /* @__PURE__ */ new Set(["idle", "error", "entered", "joined"]);
  var BUTTON_STATE_BATCH_SIZE = 50;
  var BUTTON_STATE_IDLE_TIMEOUT_MS = 200;
  var MIN_IDLE_TIME_REMAINING_MS = 4;
  function setButtonState(btn, state, extraText) {
    const text = STATE_TEXT[state];
    btn.textContent = extraText ? `${text} ${extraText}` : text;
    btn.disabled = !CLICKABLE_STATES.has(state);
    btn.dataset.state = state;
  }
  var allGiveaways = [];
  var isRequestInProgress = false;
  var scheduledButtonStateUpdate = null;
  var nextButtonStateUpdateIndex = 0;
  async function handleJoin(info) {
    if (isRequestInProgress) return;
    const { code, requiredPoints, endTime, button } = info;
    if (endTime > 0 && Date.now() / 1e3 >= endTime) {
      setButtonState(button, "ended");
      return;
    }
    isRequestInProgress = true;
    setButtonState(button, "loading");
    for (const gi of allGiveaways) {
      if (gi.button !== button) {
        gi.button.disabled = true;
      }
    }
    const currentPoints = getCurrentPoints();
    if (currentPoints < requiredPoints) {
      setButtonState(
        button,
        "insufficient",
        `(${currentPoints}/${requiredPoints}P)`
      );
      isRequestInProgress = false;
      updateAllButtonStates();
      return;
    }
    const xsrfToken = getXsrfToken();
    if (!xsrfToken) {
      setButtonState(button, "error");
      console.error("[SG-QuickJoin] Missing xsrf_token");
      isRequestInProgress = false;
      updateAllButtonStates();
      return;
    }
    try {
      const body = new URLSearchParams({
        xsrf_token: xsrfToken,
        do: "entry_insert",
        code
      });
      const resp = await fetch("https://www.steamgifts.com/ajax.php", {
        method: "POST",
        headers: {
          accept: "application/json, text/javascript, */*; q=0.01",
          "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
          "x-requested-with": "XMLHttpRequest"
        },
        body: body.toString(),
        credentials: "include"
      });
      if (!resp.ok) {
        setButtonState(button, "error");
        return;
      }
      const data = await resp.json();
      const newPoints = parseInt(data.points ?? "0", 10);
      updatePointsDisplay(newPoints);
      updateAllButtonStates();
      if (data.type === "success") {
        setButtonState(button, "joined");
      } else {
        console.info(data);
        const errMsg = data.msg;
        setButtonState(button, "error", errMsg);
      }
    } catch (err) {
      console.error("[SG-QuickJoin] Request failed:", err);
      setButtonState(button, "error");
    } finally {
      isRequestInProgress = false;
    }
  }
  async function handleLeave(info) {
    if (isRequestInProgress) return;
    const { code, button } = info;
    isRequestInProgress = true;
    setButtonState(button, "leaving");
    for (const gi of allGiveaways) {
      if (gi.button !== button) {
        gi.button.disabled = true;
      }
    }
    const xsrfToken = getXsrfToken();
    if (!xsrfToken) {
      setButtonState(button, "error");
      button.dataset.action = "leave";
      console.error("[SG-QuickJoin] Missing xsrf_token for leave");
      isRequestInProgress = false;
      updateAllButtonStates();
      return;
    }
    try {
      const body = new URLSearchParams({
        xsrf_token: xsrfToken,
        do: "entry_delete",
        code
      });
      const resp = await fetch("https://www.steamgifts.com/ajax.php", {
        method: "POST",
        headers: {
          accept: "application/json, text/javascript, */*; q=0.01",
          "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
          "x-requested-with": "XMLHttpRequest"
        },
        body: body.toString(),
        credentials: "include"
      });
      if (!resp.ok) {
        setButtonState(button, "error");
        button.dataset.action = "leave";
        return;
      }
      const data = await resp.json();
      if (data.type === "success") {
        setButtonState(button, "idle");
        button.dataset.action = "join";
        const newPoints = parseInt(data.points ?? "0", 10);
        if (newPoints > 0) {
          updatePointsDisplay(newPoints);
        }
      } else {
        const errMsg = data.msg;
        setButtonState(button, "error", errMsg);
        button.dataset.action = "leave";
        const errPoints = parseInt(data.points ?? "0", 10);
        if (errPoints > 0) {
          updatePointsDisplay(errPoints);
        }
      }
    } catch (err) {
      console.error("[SG-QuickJoin] Leave request failed:", err);
      setButtonState(button, "error");
      button.dataset.action = "leave";
    } finally {
      isRequestInProgress = false;
      updateAllButtonStates();
    }
  }
  function handleButtonClick(info) {
    const state = info.button.dataset.state;
    if (state === "entered" || state === "joined") {
      handleLeave(info);
    } else if (state === "error" && info.button.dataset.action === "leave") {
      handleLeave(info);
    } else {
      handleJoin(info);
    }
  }
  function scheduleButtonStateWork() {
    if (scheduledButtonStateUpdate) return;
    const win = window;
    if (typeof win.requestIdleCallback === "function") {
      scheduledButtonStateUpdate = {
        handle: win.requestIdleCallback(processButtonStateBatch, {
          timeout: BUTTON_STATE_IDLE_TIMEOUT_MS
        }),
        viaIdleCallback: true
      };
      return;
    }
    scheduledButtonStateUpdate = {
      handle: window.setTimeout(() => {
        processButtonStateBatch({
          didTimeout: true,
          timeRemaining: () => 0
        });
      }, 0),
      viaIdleCallback: false
    };
  }
  function clearScheduledButtonStateWork() {
    if (!scheduledButtonStateUpdate) return;
    const { handle, viaIdleCallback } = scheduledButtonStateUpdate;
    scheduledButtonStateUpdate = null;
    if (viaIdleCallback) {
      const win = window;
      win.cancelIdleCallback?.(handle);
    } else {
      window.clearTimeout(handle);
    }
  }
  function applyButtonStateForPoints(info, currentPoints) {
    const btn = info.button;
    const state = btn.dataset.state;
    if (state === "entered" || state === "joined") {
      btn.disabled = false;
      return;
    }
    if (state === "idle" || state === "insufficient" || state === "error" && btn.dataset.action !== "leave") {
      if (currentPoints < info.requiredPoints) {
        setButtonState(
          btn,
          "insufficient",
          `(${currentPoints}/${info.requiredPoints}P)`
        );
      } else {
        setButtonState(btn, "idle");
      }
    }
  }
  function processButtonStateBatch(deadline) {
    scheduledButtonStateUpdate = null;
    const currentPoints = getCurrentPoints();
    let processed = 0;
    while (nextButtonStateUpdateIndex < allGiveaways.length) {
      applyButtonStateForPoints(
        allGiveaways[nextButtonStateUpdateIndex],
        currentPoints
      );
      nextButtonStateUpdateIndex++;
      processed++;
      if (processed >= BUTTON_STATE_BATCH_SIZE || !deadline.didTimeout && deadline.timeRemaining() < MIN_IDLE_TIME_REMAINING_MS) {
        break;
      }
    }
    if (nextButtonStateUpdateIndex < allGiveaways.length) {
      scheduleButtonStateWork();
    } else {
      nextButtonStateUpdateIndex = 0;
    }
  }
  function updateAllButtonStates() {
    clearScheduledButtonStateWork();
    nextButtonStateUpdateIndex = 0;
    scheduleButtonStateWork();
  }
  function createJoinButton() {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "sg-quickjoin-btn";
    return btn;
  }
  function setupGiveawayRow(outWrap) {
    const headingName = outWrap.querySelector(
      "a.giveaway__heading__name"
    );
    if (!headingName) return;
    const code = extractCode(headingName.getAttribute("href") ?? "");
    if (!code) return;
    const thinSpans = outWrap.querySelectorAll(
      "span.giveaway__heading__thin"
    );
    const thinSpan = Array.from(thinSpans).find(
      (span) => /\(\d+P\)/i.test(span.innerText)
    );
    const requiredPoints = thinSpan ? extractRequiredPoints(thinSpan.innerText) : 0;
    const timestampEl = outWrap.querySelector("span[data-timestamp]");
    const endTime = timestampEl ? Number(timestampEl.getAttribute("data-timestamp")) || 0 : 0;
    const info = {
      outWrap,
      headingName,
      code,
      requiredPoints: requiredPoints || 0,
      endTime,
      button: null,
      syncHeight: null
    };
    const innerWrap = outWrap.querySelector(
      ".giveaway__row-inner-wrap"
    );
    const parent = innerWrap ?? outWrap;
    const alreadyEntered = innerWrap?.classList.contains("is-faded") ?? false;
    const btn = createJoinButton();
    info.button = btn;
    if (alreadyEntered) {
      setButtonState(btn, "entered");
    } else {
      const currentPoints = getCurrentPoints();
      if (currentPoints < requiredPoints) {
        setButtonState(
          btn,
          "insufficient",
          `(${currentPoints}/${requiredPoints}P)`
        );
      } else {
        setButtonState(btn, "idle");
      }
    }
    btn.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      handleButtonClick(info);
    });
    function syncButtonHeight() {
      const parentHeight = parent.getBoundingClientRect().height;
      if (parentHeight === 0) return;
      const cs = getComputedStyle(parent);
      const pt = parseFloat(cs.paddingTop) || 0;
      const pb = parseFloat(cs.paddingBottom) || 0;
      if (pt > 0 || pb > 0) {
        btn.style.marginTop = -pt + "px";
        btn.style.marginBottom = -pb + "px";
        btn.style.paddingTop = pt + "px";
        btn.style.paddingBottom = pb + "px";
      }
      btn.style.height = parentHeight + "px";
    }
    info.syncHeight = syncButtonHeight;
    parent.appendChild(btn);
    syncButtonHeight();
    window.addEventListener("resize", syncButtonHeight, { passive: true });
    allGiveaways.push(info);
  }
  function fixHeader() {
    const header = document.querySelector("header");
    if (!header) return;
    header.classList.add("sg-quickjoin-header-fixed");
    document.body.style.marginTop = header.offsetHeight + "px";
  }
  var HIDE_JOINED_KEY = "hideJoined";
  function applyHideJoinedSetting(shouldHide) {
    document.body.classList.toggle("sg-hide-joined", shouldHide);
    if (!shouldHide) {
      for (const info of allGiveaways) {
        info.syncHeight();
      }
    }
  }
  function toggleHideJoined() {
    const current = GM_getValue(HIDE_JOINED_KEY, false);
    const next = !current;
    GM_setValue(HIDE_JOINED_KEY, next);
    applyHideJoinedSetting(next);
  }
  function showMore() {
    const pinnedGiveaways = document.querySelector(".pinned-giveaways");
    if (!pinnedGiveaways) return;
    pinnedGiveaways.classList.add("pinned-giveaways-expanded");
    const pinnedGiveawaysText = document.querySelector(".pinned-giveaways-expand");
    if (!pinnedGiveawaysText) return;
    pinnedGiveawaysText.innerText = "Show Fewer";
  }
  var menuCommandId;
  function registerHideJoinedMenu() {
    if (menuCommandId) {
      GM_unregisterMenuCommand(menuCommandId);
    }
    const isHidden = GM_getValue(HIDE_JOINED_KEY, false);
    const caption = isHidden ? "☑ 显示已加入的 Giveaway" : "☐ 隐藏已加入的 Giveaway";
    menuCommandId = GM_registerMenuCommand(caption, () => {
      toggleHideJoined();
      registerHideJoinedMenu();
    });
  }
  registerHideJoinedMenu();
  function main() {
    fixHeader();
    showMore();
    applyHideJoinedSetting(GM_getValue(HIDE_JOINED_KEY, false));
    const outWraps = document.querySelectorAll(
      "div.giveaway__row-outer-wrap"
    );
    if (outWraps.length === 0) return;
    outWraps.forEach(setupGiveawayRow);
    updateAllButtonStates();
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", main);
  } else {
    main();
  }
})();