Scratch Moderation Status + Index Badge

Show moderation and indexing status of a project.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Scratch Moderation Status + Index Badge
// @namespace    https://scratch.mit.edu/
// @version      1.4.0
// @description  Show moderation and indexing status of a project.
// @match        https://scratch.mit.edu/projects/*
// @run-at       document-idle
// @grant        GM_addStyle
// @license      MIT
// @author       scratchinghead
// ==/UserScript==

(function () {
  "use strict";

  GM_addStyle(`
    .spmsb-badges {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      margin-left: 8px;
    }
    .spmsb-badge {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 4px 8px;
      border-radius: 999px;
      font: 600 12px/1 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
      color: #0b1020;
      background: #eef2f7;
      border: 1px solid rgba(0,0,0,.08);
      box-shadow: 0 1px 2px rgba(0,0,0,.06);
      white-space: nowrap;
      user-select: none;
    }
    .spmsb-dot { width: 8px; height: 8px; border-radius: 999px; background:#9ca3af; }
    /* moderation colors */
    .spmsb-safe .spmsb-dot { background:#10b981; }        /* green */
    .spmsb-notsafe .spmsb-dot { background:#ef4444; }     /* red */
    .spmsb-notreviewed .spmsb-dot { background:#f59e0b; } /* amber */
    .spmsb-unknown .spmsb-dot { background:#6b7280; }     /* gray */
    .spmsb-muted { opacity:.85 }
    /* index colors */
    .spmsb-index-yes .spmsb-dot { background:#10b981; }   /* green */
    .spmsb-index-no  .spmsb-dot { background:#ef4444; }   /* red */
    .spmsb-index-maybe .spmsb-dot { background:#f59e0b; } /* amber */
  `);

  const waitForElement = (selector, { root = document, timeout = 10000 } = {}) =>
    new Promise((resolve, reject) => {
      const el = root.querySelector(selector);
      if (el) return resolve(el);
      const obs = new MutationObserver(() => {
        const e2 = root.querySelector(selector);
        if (e2) {
          obs.disconnect();
          resolve(e2);
        }
      });
      obs.observe(root, { childList: true, subtree: true });
      if (timeout) {
        setTimeout(() => {
          obs.disconnect();
          reject(new Error(`Timeout waiting for ${selector}`));
        }, timeout);
      }
    });

  const getProjectIdFromLocation = () => {
    const m = location.pathname.match(/^\/projects\/(\d+)\/?/);
    return m ? m[1] : null;
  };

  const ensureBadges = async () => {
    const controls = await waitForElement('.controls_controls-container_FKkXX').catch(() => null);
    if (!controls) return null;

    let wrap = controls.querySelector(':scope > .spmsb-badges');
    if (!wrap) {
      wrap = document.createElement('span');
      wrap.className = 'spmsb-badges';
      controls.appendChild(wrap);
    }

    let mod = wrap.querySelector(':scope > .spmsb-badge[data-kind="mod"]');
    if (!mod) {
      mod = document.createElement('span');
      mod.className = 'spmsb-badge spmsb-unknown spmsb-muted';
      mod.dataset.kind = 'mod';
      mod.innerHTML = `<span class="spmsb-dot"></span><span class="spmsb-text">Moderation: Loading…</span>`;
      wrap.appendChild(mod);
    }

    let idx = wrap.querySelector(':scope > .spmsb-badge[data-kind="index"]');
    if (!idx) {
      idx = document.createElement('span');
      idx.className = 'spmsb-badge spmsb-unknown spmsb-muted';
      idx.dataset.kind = 'index';
      idx.innerHTML = `<span class="spmsb-dot"></span><span class="spmsb-text">Index: Loading…</span>`;
      wrap.appendChild(idx);
    }

    return { wrap, mod, idx };
  };

  const setBadge = async (kind, status, note) => {
    const holders = await ensureBadges();
    if (!holders) return;
    const badge = kind === 'index' ? holders.idx : holders.mod;
    if (!badge) return;

    // reset classes
    badge.className = 'spmsb-badge';
    const txt = badge.querySelector('.spmsb-text');

    if (kind === 'mod') {
      const map = {
        safe:        { cls: 'spmsb-safe',        label: 'Reviewed: Safe' },
        notsafe:     { cls: 'spmsb-notsafe',     label: 'Marked NFE (Not For Everyone)' },
        notreviewed: { cls: 'spmsb-notreviewed', label: 'Not Reviewed' },
        nodata:      { cls: 'spmsb-unknown',     label: 'No Remix Data' },
        error:       { cls: 'spmsb-unknown spmsb-muted', label: 'Status unavailable' },
        unknown:     { cls: 'spmsb-unknown',     label: 'Status: Unknown' },
      };
      const meta = map[status] || map.unknown;
      badge.className = `spmsb-badge ${meta.cls}`;
      txt.textContent = note ? `${meta.label} — ${note}` : meta.label;
    } else {
      const map = {
        yes:    { cls: 'spmsb-index-yes',   label: 'Indexed' },
        no:     { cls: 'spmsb-index-no',    label: 'Not Indexed' },
        maybe:  { cls: 'spmsb-index-maybe', label: 'Index check: Partial' },
        error:  { cls: 'spmsb-unknown spmsb-muted', label: 'Index check failed' },
        busy:   { cls: 'spmsb-unknown spmsb-muted', label: 'Index: Checking…' },
        unknown:{ cls: 'spmsb-unknown',     label: 'Index: Unknown' },
      };
      const meta = map[status] || map.unknown;
      badge.className = `spmsb-badge ${meta.cls}`;
      txt.textContent = note ? `${meta.label} — ${note}` : meta.label;
    }
  };

  function decodeHTML(str) {
    const txt = document.createElement('textarea');
    txt.innerHTML = str;
    return txt.value;
  }

  function extractBalancedObject(source, startIdx) {
    let i = startIdx;
    let depth = 0;
    let inStr = null;
    let esc = false;

    while (i < source.length) {
      const ch = source[i];

      if (inStr) {
        if (esc) {
          esc = false;
        } else if (ch === '\\') {
          esc = true;
        } else if (ch === inStr) {
          inStr = null;
        }
        i++;
        continue;
      }

      if (ch === '"' || ch === "'" || ch === '`') {
        inStr = ch;
        i++;
        continue;
      }

      if (ch === '{') depth++;
      if (ch === '}') {
        depth--;
        if (depth === 0) {
          return source.slice(startIdx, i + 1);
        }
      }
      i++;
    }
    return null;
  }

  function extractProjectDataFromHTML(html) {
    const reJSONParse = /\bprojectData\s*=\s*JSON\.parse\(\s*(['"])([\s\S]*?)\1\s*\)/i;
    const mParse = reJSONParse.exec(html);
    if (mParse) {
      try {
        return JSON.parse(decodeHTML(mParse[2]));
      } catch {}
    }

    const reAssign = /\b(?:(?:var|let|const)\s+|window\.\s*)?projectData\s*=\s*/ig;
    let m;
    while ((m = reAssign.exec(html))) {
      let i = reAssign.lastIndex;
      while (i < html.length && /\s/.test(html[i])) i++;
      if (html[i] !== '{') continue;
      const objText = extractBalancedObject(html, i);
      if (!objText) continue;

      try {
        return JSON.parse(objText);
      } catch {
        try {
          return (new Function('"use strict";return (' + objText + ')'))();
        } catch {}
      }
    }

    try {
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const scripts = Array.from(doc.querySelectorAll('script'));
      for (const s of scripts) {
        const t = s.textContent || '';
        const mp = reJSONParse.exec(t);
        if (mp) {
          try { return JSON.parse(decodeHTML(mp[2])); } catch {}
        }
        reAssign.lastIndex = 0;
        let mm;
        while ((mm = reAssign.exec(t))) {
          let j = reAssign.lastIndex;
          while (j < t.length && /\s/.test(t[j])) j++;
          if (t[j] !== '{') continue;
          const objText = extractBalancedObject(t, j);
          if (!objText) continue;
          try { return JSON.parse(objText); } catch {
            try { return (new Function('"use strict";return (' + objText + ')'))(); } catch {}
          }
        }
      }
    } catch {}

    return null;
  }

  async function fetchModerationStatus(projectId) {
    if (!projectId) { setBadge('mod','error'); return; }
    await setBadge('mod','unknown','Fetching…');
    try {
      const res = await fetch(`https://scratch.mit.edu/projects/${projectId}/remixtree/`, { credentials: 'include' });
      const html = await res.text();

      const projectData = extractProjectDataFromHTML(html);
      if (!projectData) {
        await setBadge('mod','nodata','projectData missing');
        return;
      }
      const node = projectData[String(projectId)];
      const status = node?.moderation_status;

      if (status === 'safe') {
        await setBadge('mod','safe');
      } else if (status === 'notsafe') {
        await setBadge('mod','notsafe');
      } else if (status === 'notreviewed') {
        await setBadge('mod','notreviewed');
      } else if (status == null) {
        await setBadge('mod','nodata','status missing');
      } else {
        await setBadge('mod','unknown', String(status));
      }
    } catch {
      await setBadge('mod','error');
    }
  }

  async function fetchProjectTitle(projectId) {
    const res = await fetch(`https://api.scratch.mit.edu/projects/${projectId}`);
    if (!res.ok) throw new Error('project API failed');
    const j = await res.json();
    return (j && typeof j.title === 'string') ? j.title : null;
  }

    async function isIndexedByTitleAndId(title, projectId) {
        const limit = 40;
        const maxPages = 10;

        // Split by "/", trim parts, dedupe, drop empties
        const parts = Array.from(
            new Set(
                title.split("/").map(s => s.trim()).filter(Boolean)
            )
        );

        for (const part of parts) {
            const q = encodeURIComponent(part);
            let offset = 0;

            for (let page = 0; page < maxPages; page++) {
                const url = `https://api.scratch.mit.edu/search/projects?limit=${limit}&offset=${offset}&language=en&mode=popular&q=${q}`;
                const res = await fetch(url);
                if (!res.ok) break;
                const arr = await res.json();
                if (!Array.isArray(arr) || arr.length === 0) break;

                const found = arr.some(p => p && String(p.id) === String(projectId));
                if (found) return true;

                if (arr.length < limit) break; // no more pages
                offset += limit;

                await new Promise(r => setTimeout(r, 200)); // be polite
            }
        }

        return false;
    }

  async function fetchIndexStatus(projectId) {
    if (!projectId) { setBadge('index','error'); return; }
    await setBadge('index','busy');
    try {
      const title = await fetchProjectTitle(projectId);
      if (!title) {
        await setBadge('index','error','No title');
        return;
      }
      const indexed = await isIndexedByTitleAndId(title, projectId);
      if (indexed) {
        await setBadge('index','yes');
      } else {
        await setBadge('index','no');
      }
    } catch (e) {
      await setBadge('index','error');
    }
  }

  let lastProjectId = null;
  const runForCurrentPage = async () => {
    const pid = getProjectIdFromLocation();
    if (!pid || pid === lastProjectId) return;
    lastProjectId = pid;
    await ensureBadges();
    fetchModerationStatus(pid);
    fetchIndexStatus(pid);
  };

  runForCurrentPage();

  const _pushState = history.pushState;
  const _replaceState = history.replaceState;
  history.pushState = function () { const ret = _pushState.apply(this, arguments); window.dispatchEvent(new Event('spmsb:navigation')); return ret; };
  history.replaceState = function () { const ret = _replaceState.apply(this, arguments); window.dispatchEvent(new Event('spmsb:navigation')); return ret; };
  window.addEventListener('popstate', () => window.dispatchEvent(new Event('spmsb:navigation')));
  window.addEventListener('spmsb:navigation', () => setTimeout(runForCurrentPage, 60));

  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      runForCurrentPage();
    }
  }, 500);
})();