Scratch Moderation Status + Index Badge

Show moderation and indexing status of a project.

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