您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); })();