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

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

目前為 2025-07-03 提交的版本,檢視 最新版本

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

})();