Invite Recent Users (Auto-load all followers in batches)

Automatically load and invite all followers of any user in batches of 40

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Invite Recent Users (Auto-load all followers in batches)
// @version      1.5.1
// @description  Automatically load and invite all followers of any user in batches of 40
// @match        https://scratch.mit.edu/studios/*
// @license      MIT
// @grant        none
// @namespace https://greasyfork.org/users/1491229
// ==/UserScript==

(function () {
  'use strict';

  const studioId = location.pathname.split('/')[2];
  if (!studioId) return;

  const STORAGE_KEY = `invite_recent_users_invited_${studioId}`;

  function getInvitedUsers() {
    const data = localStorage.getItem(STORAGE_KEY);
    return data ? JSON.parse(data) : [];
  }

  function saveInvitedUser(username) {
    const invited = getInvitedUsers();
    if (!invited.includes(username)) {
      invited.push(username);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(invited));
    }
  }

  function getCookie(name) {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
    return null;
  }

  function waitForElement(selector, timeout = 7000) {
    return new Promise((resolve, reject) => {
      const el = document.querySelector(selector);
      if (el) return resolve(el);

      const observer = new MutationObserver(() => {
        const elNow = document.querySelector(selector);
        if (elNow) {
          observer.disconnect();
          resolve(elNow);
        }
      });

      observer.observe(document.body, { childList: true, subtree: true });

      if (timeout) setTimeout(() => {
        observer.disconnect();
        reject(new Error('Timeout waiting for element ' + selector));
      }, timeout);
    });
  }

  waitForElement('#sa-studio-followers-button').then(refButton => {
    const btn = document.createElement('button');
    btn.id = 'scratch-invite-recent-btn';
    btn.className = 'button';
    btn.textContent = 'Invite Recent';
    btn.style.marginLeft = '10px';
    btn.onclick = () => {
      if (document.querySelector('#scratch-invite-modal')) {
        document.querySelector('#scratch-invite-modal').remove();
      } else {
        showPanel();
      }
    };
    refButton.parentNode.insertBefore(btn, refButton.nextSibling);
  }).catch(console.warn);

  async function checkUserExists(username) {
    try {
      const res = await fetch(`https://api.scratch.mit.edu/users/${encodeURIComponent(username)}`);
      if (!res.ok) return false;
      const data = await res.json();
      return !!data.username;
    } catch {
      return false;
    }
  }

  async function fetchUsers(username, offset) {
    const url = `https://api.scratch.mit.edu/users/${encodeURIComponent(username)}/followers?limit=40&offset=${offset}`;
    const res = await fetch(url);
    if (!res.ok) throw new Error('Fetch failed');
    const data = await res.json();
    return data.map(u => u.username);
  }

  async function inviteUser(username) {
    try {
      const csrfToken = getCookie('scratchcsrftoken');
      if (!csrfToken) throw new Error('No CSRF token found');

      const res = await fetch(
        `https://scratch.mit.edu/site-api/users/curators-in/${studioId}/invite_curator/?usernames=${encodeURIComponent(username)}`,
        {
          method: 'PUT',
          credentials: 'include',
          headers: {
            'X-CSRFToken': csrfToken,
            'Content-Type': 'application/json',
            'Accept': 'application/json, text/javascript, */*; q=0.01',
            'X-Requested-With': 'XMLHttpRequest'
          },
          body: null
        }
      );
      const json = await res.json();

      if (!res.ok) {
        throw new Error(json?.detail || 'Unknown error');
      }

      return true;
    } catch (e) {
      console.error('Invite error:', e);
      return false;
    }
  }

  async function showPanel() {
    const modal = document.createElement('div');
    modal.id = 'scratch-invite-modal';
    modal.style = `
      position: fixed; top: 20px; right: 20px; width: 420px; height: 540px;
      background: white; border: 2px solid #333; padding: 15px;
      z-index: 10000; overflow-y: auto; font-family: Arial,sans-serif;
      display: flex; flex-direction: column; border-radius: 5px;
      box-shadow: 0 0 15px rgba(0,0,0,0.3);
    `;

    modal.innerHTML = `
      <button title="Close" style="position:absolute;top:5px;right:5px; font-size: 18px; background:none; border:none; cursor:pointer;">×</button>
      <h3>Invite Recent Users (Auto-batch)</h3>
      <label for="username-input" style="margin-top:10px;">Username to fetch followers:</label>
      <input id="username-input" type="text" value="griffpatch" style="width:100%; padding:6px; margin-bottom:10px;"/>
      <button id="load-user-btn" style="margin-bottom:10px;">Load Followers</button>
      <ul id="user-list" style="flex-grow:1; overflow-y:auto; border:1px solid #ccc; padding-left:20px; background:#fafafa; border-radius:3px; min-height: 200px;"></ul>
      <button id="invite-all-btn" style="margin-top:10px;">Invite All</button>
      <div id="status" style="margin-top:10px; font-size:0.9rem; color:#333; min-height: 24px;"></div>
    `;

    document.body.appendChild(modal);

    modal.querySelector('button[title="Close"]').onclick = () => modal.remove();

    const userInput = modal.querySelector('#username-input');
    const loadUserBtn = modal.querySelector('#load-user-btn');
    const userListEl = modal.querySelector('#user-list');
    const inviteAllBtn = modal.querySelector('#invite-all-btn');
    const statusEl = modal.querySelector('#status');

    let offset = 0;
    let currentUsers = [];
    let currentUsername = userInput.value.trim();
    let invitedUsers = getInvitedUsers();
    let stopLoading = false;
    const liMap = new Map(); // username => <li>

    function updateStatus(msg) {
      statusEl.textContent = msg;
    }

    async function loadAllUsers(reset = false) {
      if (reset) {
        offset = 0;
        currentUsers = [];
        invitedUsers = getInvitedUsers();
        userListEl.innerHTML = '';
        stopLoading = false;
      }

      loadUserBtn.disabled = true;
      updateStatus('Loading all followers in batches...');

      while (!stopLoading) {
        try {
          const batch = await fetchUsers(currentUsername, offset);
          if (batch.length === 0) break;

          const filtered = batch.filter(u => !invitedUsers.includes(u));
          currentUsers.push(...filtered);

          for (const user of filtered) {
            const li = document.createElement('li');
            li.textContent = user;
            li.style.color = '#666';
            userListEl.appendChild(li);
            liMap.set(user, li);
          }

          offset += 40;
          updateStatus(`Loaded ${currentUsers.length} users...`);
          await new Promise(r => setTimeout(r, 500));
        } catch (e) {
          updateStatus('Failed to fetch more users.');
          break;
        }
      }

      updateStatus(`Finished loading ${currentUsers.length} new followers`);
      loadUserBtn.disabled = false;
    }

    async function inviteAll() {
      if (currentUsers.length === 0) {
        updateStatus('No users to invite.');
        return;
      }
      updateStatus(`Loaded ${currentUsers.length} users...`);
      inviteAllBtn.disabled = true;
      loadUserBtn.disabled = true;

      for (let i = 0; i < currentUsers.length; i++) {
        const user = currentUsers[i];
        const li = liMap.get(user);
        updateStatus(`Inviting ${user} (${i + 1} / ${currentUsers.length})`);

        const success = await inviteUser(user);
        if (success) {
          li.textContent = `✅ ${user}`;
          li.style.color = 'green';
          saveInvitedUser(user);
          updateStatus(`✅ Invited ${user} (${i + 1} / ${currentUsers.length})`);
        } else {
          li.textContent = `❌ ${user}`;
          li.style.color = 'red';
          updateStatus(`❌ Failed to invite ${user} (${i + 1} / ${currentUsers.length})`);
        }

        await new Promise(r => setTimeout(r, 500));
      }

      updateStatus('All invitations complete!');
      currentUsers = [];
      inviteAllBtn.disabled = false;
      loadUserBtn.disabled = false;
    }

    loadUserBtn.onclick = async () => {
      const inputUser = userInput.value.trim();
      if (!inputUser) {
        updateStatus('Please enter a username.');
        return;
      }
      updateStatus('Checking user...');
      loadUserBtn.disabled = true;
      const exists = await checkUserExists(inputUser);
      if (!exists) {
        updateStatus('User not found.');
        loadUserBtn.disabled = false;
        return;
      }
      currentUsername = inputUser;
      await loadAllUsers(true);
    };

    inviteAllBtn.onclick = inviteAll;

    await loadAllUsers(true);
  }

})();