Scratch Moderation Status + Index Badge

Show moderation and indexing status of a project.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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