// ==UserScript== // @name Soybooru - Pending Approval Fix // @namespace https://tampermonkey.net/ // @version 1.0.0 // @description Optimized pending approval counter // @match https://soybooru.com/* // @run-at document-idle // @grant none // ==/UserScript== (() => { 'use strict'; // ---------------------------- // Config (DO NOT CHANGE VALUES) // ---------------------------- const PER_PAGE = 32; const REFRESH_MS = 1_000; // After clicking Approve/submit, refresh soon const AFTER_ACTION_MS = 300; // Fetch extra pages when queue is large const MAX_PAGES = 10; // Storage keys (DO NOT CHANGE) const K_QUEUE_URL_CACHE = 'tm_sb_queue_url_cache_v130'; const K_BASELINE_SNAPSHOT = 'tm_sb_pending_baseline_v130'; // {count:number, idsSig:string} // Extra state storage (safe to add) const K_REMOVED_IDS = 'tm_sb_pending_removed_ids_v130'; // { [id]: ts } const K_GREEN_ALERT = 'tm_sb_pending_green_alert_v130'; // { on:boolean, ts:number, ids?:string[] } // Optimization knobs (in-memory; not user-config) const OFFQUEUE_VERIFY_MIN_MS = 12_000; const REMOVED_TTL_MS = 36 * 60 * 60 * 1000; // 36h // ---------------------------- // State // ---------------------------- let timer = null; let inflight = null; let cachedPendingTab = null; // Live queue snapshot (queue context only) let lastLive = null; // {count:number, idsSig:string, idsArr:string[]} // Sticky update gate: only update highlight when observed changes let lastObservedSig = null; // Behavior highlight state (sticky) let highlightOn = false; // Visual mode: 'none' | 'red' | 'green' (green is visual override only) let highlightMode = 'none'; // Off-queue verification throttle/cache let lastOffQueueVerifyAt = 0; let cachedVerifiedOffQueueCount = null; // Keep last seen queue target to avoid repeated URL parsing let lastQueueTarget = ''; // ---------------------------- // Small utilities // ---------------------------- const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const now = () => Date.now(); function safeJsonParse(raw, fallback) { try { return JSON.parse(raw); } catch { return fallback; } } function abortInflight() { if (inflight) { try { inflight.abort(); } catch {} inflight = null; } } // ---------------------------- // DOM: find/update Pending Approval tab // ---------------------------- function getPendingTabElement() { if (cachedPendingTab && document.contains(cachedPendingTab)) return cachedPendingTab; const bar = document.querySelector('div.bar') || document; const els = bar.querySelectorAll('a.tab, .tab, .tab-selected'); for (const el of els) { const t = norm(el.textContent).toLowerCase(); if (t === 'pending approval' || t.startsWith('pending approval (')) { cachedPendingTab = el; return el; } } cachedPendingTab = null; return null; } function setNavbarCount(text) { const el = getPendingTabElement(); if (!el) return; const label = norm(el.textContent); if (!el.dataset.tmOrig) el.dataset.tmOrig = label; const base = norm(el.dataset.tmOrig).replace(/\s*\([^)]*\)\s*$/, '').trim(); el.textContent = `${base} (${text})`; } function getNavbarPendingCount() { const el = getPendingTabElement(); if (!el) return null; const m = (el.textContent || '').match(/\((\d+)\)\s*$/); return m ? Number(m[1]) : null; } // ---------------------------- // Highlight: solid background only; force-disable glow // Red = more accurate light red; Green = same strength in green. // ---------------------------- function ensureHighlightStyle() { if (document.getElementById('tm-pending-style')) return; const st = document.createElement('style'); st.id = 'tm-pending-style'; st.textContent = ` /* RED behavior now shows green */ .tm-pending-alert-red{ background:#b3ffb3 !important; /* light green */ border-radius:4px; box-shadow:none !important; text-shadow:none !important; filter:none !important; outline:none !important; } /* GREEN override now shows red */ .tm-pending-alert-green{ background:#ffb3b3 !important; /* light red */ border-radius:4px; box-shadow:none !important; text-shadow:none !important; filter:none !important; outline:none !important; } `; document.head.appendChild(st); } function applyHighlight(mode) { const el = getPendingTabElement(); if (!el) return; ensureHighlightStyle(); el.classList.toggle('tm-pending-alert-red', mode === 'red'); el.classList.toggle('tm-pending-alert-green', mode === 'green'); // hard-disable any theme glow on the element while highlighted if (mode !== 'none') { el.style.boxShadow = 'none'; el.style.textShadow = 'none'; el.style.filter = 'none'; el.style.outline = 'none'; } else { el.style.boxShadow = ''; el.style.textShadow = ''; el.style.filter = ''; el.style.outline = ''; } } // ---------------------------- // Queue URL discovery (cached) // ---------------------------- function getQueueTarget() { const el = getPendingTabElement(); const href = el && el.getAttribute ? (el.getAttribute('href') || '') : ''; if (href) { const abs = new URL(href, location.origin).toString(); if (abs !== lastQueueTarget) lastQueueTarget = abs; try { localStorage.setItem(K_QUEUE_URL_CACHE, abs); } catch {} return abs; } try { const cached = localStorage.getItem(K_QUEUE_URL_CACHE) || ''; if (cached !== lastQueueTarget) lastQueueTarget = cached; return cached; } catch { return lastQueueTarget || ''; } } // ---------------------------- // Context helpers // ---------------------------- function currentViewId() { return location.pathname.match(/^\/post\/view\/(\d+)/)?.[1] || null; } function pageHasApprove(doc) { return !!doc.querySelector('form[action^="/approve_image/"] input[type="submit"][value="Approve"]'); } function isListUrl(absUrl) { try { const u = new URL(absUrl, location.origin); return /^\/post\/list\/.+\/\d+\/?$/.test(u.pathname); } catch { return false; } } function listUrlForPage(absUrl, pageNum) { const u = new URL(absUrl, location.origin); const parts = u.pathname.split('/').filter(Boolean); // ["post","list","",""] if (parts.length >= 4 && parts[0] === 'post' && parts[1] === 'list') { parts[parts.length - 1] = String(pageNum); u.pathname = '/' + parts.join('/') + (u.pathname.endsWith('/') ? '/' : ''); return u.toString(); } return absUrl; } // Queue context: used for baseline/live updates and ID fetching function isQueueContext(queueTargetAbs) { // pending post view (Approve button) if (pageHasApprove(document)) return true; // same list path as queue target try { if (queueTargetAbs) { const qt = new URL(queueTargetAbs, location.origin); if (location.pathname === qt.pathname) return true; } } catch {} // heuristic: pending list is /post/list/... approved ... no ... const p = location.pathname.toLowerCase(); if (p.startsWith('/post/list/')) { const full = location.href.toLowerCase(); if (full.includes('approved') && full.includes('no')) return true; } return false; } // "Checked" means: opening pending list OR opening pending post view (Approve button), // even if you did not approve. function isCheckedContext(queueTargetAbs) { if (pageHasApprove(document)) return true; try { if (queueTargetAbs) { const qt = new URL(queueTargetAbs, location.origin); if (location.pathname === qt.pathname) return true; } } catch {} return false; } // ---------------------------- // Fetch + parse IDs (robust; works even when thumbs don't render) // ---------------------------- async function fetchDoc(url, signal) { const res = await fetch(url, { credentials: 'include', cache: 'no-store', signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); return { doc, finalUrl: res.url || url }; } function idsFromDoc(doc) { const root = doc.querySelector('div.shm-image-list') || doc.querySelector('#thumbs') || doc.querySelector('.thumbs') || doc.querySelector('#content') || doc; const ids = []; const seen = new Set(); // Prefer thumb wrappers WITH images (best signal) const thumbs = root.querySelectorAll('.thumb, span.thumb, a.thumb'); for (const t of thumbs) { const a = t.matches('a[href*="/post/view/"]') ? t : t.querySelector('a[href*="/post/view/"]'); if (!a) continue; if (!a.querySelector('img')) continue; const m = (a.getAttribute('href') || '').match(/\/post\/view\/(\d+)/); if (m && !seen.has(m[1])) { seen.add(m[1]); ids.push(m[1]); } } if (ids.length) return ids; // Fallback 1: view links containing an for (const a of root.querySelectorAll('a[href*="/post/view/"]')) { if (!a.querySelector('img')) continue; const m = (a.getAttribute('href') || '').match(/\/post\/view\/(\d+)/); if (m && !seen.has(m[1])) { seen.add(m[1]); ids.push(m[1]); } } if (ids.length) return ids; // Fallback 2: no visible thumbs -> still extract IDs from view links (restricted to root) for (const a of root.querySelectorAll('a[href^="/post/view/"], a[href*="/post/view/"]')) { const href = a.getAttribute('href') || ''; const m = href.match(/\/post\/view\/(\d+)/); if (m && !seen.has(m[1])) { seen.add(m[1]); ids.push(m[1]); } } return ids; } function idsSig(ids) { return (ids || []).join(','); } async function fetchQueueIdsMultiPage(queueTarget, totalCount, signal) { if (!queueTarget || !isListUrl(queueTarget)) return { ids: [], redirectedToViewId: null }; const pagesNeeded = Math.min( MAX_PAGES, Math.max(1, Math.ceil((Number(totalCount) || 0) / PER_PAGE)) ); const all = []; const seen = new Set(); for (let p = 1; p <= pagesNeeded; p++) { const url = listUrlForPage(queueTarget, p); const { doc, finalUrl } = await fetchDoc(url, signal); const finalPath = new URL(finalUrl, location.origin).pathname; const mView = finalPath.match(/^\/post\/view\/(\d+)/); // Some installs redirect list -> /post/view/ when only 1 pending exists if (mView || pageHasApprove(doc)) { const id = mView ? mView[1] : (currentViewId() || ''); return { ids: [id].filter(Boolean), redirectedToViewId: mView?.[1] || null }; } const ids = idsFromDoc(doc); for (const id of ids) { if (!seen.has(id)) { seen.add(id); all.push(id); } } // Stop early if page isn't full (helps if navbar count briefly stale) if (ids.length < PER_PAGE) break; if (p < pagesNeeded) await sleep(60); } return { ids: all, redirectedToViewId: null }; } // ---------------------------- // Baseline snapshot (persisted) — updates ONLY in queue context // ---------------------------- function loadBaseline() { try { const raw = localStorage.getItem(K_BASELINE_SNAPSHOT); if (!raw) return { count: 0, idsSig: '' }; const obj = safeJsonParse(raw, null); return { count: Number(obj?.count || 0) || 0, idsSig: String(obj?.idsSig || '') }; } catch { return { count: 0, idsSig: '' }; } } function saveBaseline(count, idsSigStr) { try { localStorage.setItem(K_BASELINE_SNAPSHOT, JSON.stringify({ count: Number(count) || 0, idsSig: String(idsSigStr || '') })); } catch {} } // ---------------------------- // Removed IDs + Green (visual override only) // ---------------------------- function loadRemovedIds() { try { const raw = localStorage.getItem(K_REMOVED_IDS); const obj = raw ? safeJsonParse(raw, {}) : {}; return (obj && typeof obj === 'object') ? obj : {}; } catch { return {}; } } function saveRemovedIds(obj) { try { localStorage.setItem(K_REMOVED_IDS, JSON.stringify(obj || {})); } catch {} } function cleanupRemovedIds(obj) { const cutoff = now() - REMOVED_TTL_MS; let changed = false; for (const [id, ts] of Object.entries(obj)) { if (!ts || ts < cutoff) { delete obj[id]; changed = true; } } if (changed) saveRemovedIds(obj); return obj; } function setGreenAlert(on, ids = []) { try { localStorage.setItem(K_GREEN_ALERT, JSON.stringify({ on: !!on, ts: now(), ids: Array.isArray(ids) ? ids.slice(0, 50) : [] })); } catch {} } function getGreenAlert() { try { const raw = localStorage.getItem(K_GREEN_ALERT); if (!raw) return { on: false, ts: 0, ids: [] }; const obj = safeJsonParse(raw, null); return { on: !!obj?.on, ts: Number(obj?.ts || 0) || 0, ids: Array.isArray(obj?.ids) ? obj.ids : [] }; } catch { return { on: false, ts: 0, ids: [] }; } } // ---------------------------- // Sticky highlight logic // - highlightOn = behavior (your red logic) // - green = visual override only (does not affect behavior) // - "checked" clears BOTH the same way // ---------------------------- function setVisualFromBehavior() { const greenOn = getGreenAlert().on; highlightMode = greenOn ? 'green' : (highlightOn ? 'red' : 'none'); applyHighlight(highlightMode); } function maybeUpdateHighlight(observedSignature, shouldHighlightNow) { if (observedSignature === lastObservedSig) return; lastObservedSig = observedSignature; highlightOn = !!shouldHighlightNow; setVisualFromBehavior(); } function clearAllAlertsBecauseChecked() { setGreenAlert(false, []); highlightOn = false; highlightMode = 'none'; lastObservedSig = null; // break sticky immediately applyHighlight('none'); } // ---------------------------- // Off-queue: verify "fake 0/1" by fetching queue occasionally // ---------------------------- async function maybeVerifyOffQueueCount(queueTarget, signal) { const t = now(); if (!queueTarget || !isListUrl(queueTarget)) return null; if ((t - lastOffQueueVerifyAt) < OFFQUEUE_VERIFY_MIN_MS) { return cachedVerifiedOffQueueCount; } lastOffQueueVerifyAt = t; try { const { ids, redirectedToViewId } = await fetchQueueIdsMultiPage(queueTarget, 2, signal); const n = redirectedToViewId ? 1 : (ids?.length || 0); cachedVerifiedOffQueueCount = n; return n; } catch { return cachedVerifiedOffQueueCount; } } // ---------------------------- // Main refresh // ---------------------------- async function refresh(force = false) { if (!force && document.hidden) return; abortInflight(); const ac = new AbortController(); inflight = ac; const queueTarget = getQueueTarget(); const inQueue = isQueueContext(queueTarget); const baseline = loadBaseline(); try { const navN = getNavbarPendingCount(); const trustNavCount = Number.isFinite(navN) && navN >= 2; // 1) Count source let count = 0; if (trustNavCount) { count = navN; } else { // Keep count stable while browsing pending posts: // if you're in queue context and baseline says 2+, don't let view fallback force it down to 1. if (inQueue && baseline.count >= 2) { count = baseline.count; } else { // 0/1 fallback: approve-button view => 1 else 0 (best effort) const vid = currentViewId(); count = (vid && pageHasApprove(document)) ? 1 : (Number.isFinite(navN) ? navN : 0); } // Off-queue fix: main menu 0/1 can be fake. Verify occasionally. if (!inQueue && (count === 0 || count === 1)) { const verified = await maybeVerifyOffQueueCount(queueTarget, ac.signal); if (Number.isFinite(verified)) count = verified; } } // 2) IDs signature (queue context only) let sig = ''; let idsArr = []; if (inQueue && count >= 2 && queueTarget && isListUrl(queueTarget)) { const { ids, redirectedToViewId } = await fetchQueueIdsMultiPage(queueTarget, count, ac.signal); idsArr = ids || []; if (redirectedToViewId) count = 1; sig = idsSig(idsArr); } else if (inQueue && count === 1) { const only = currentViewId() || ''; idsArr = only ? [only] : []; sig = only; } else { sig = ''; idsArr = []; } // 3) Always update navbar label setNavbarCount(String(count)); // 4) Detect "went away then came back" => set green flag (queue context + IDs only) if (inQueue && lastLive && Array.isArray(lastLive.idsArr) && Array.isArray(idsArr)) { const prevSet = new Set(lastLive.idsArr); const curSet = new Set(idsArr); // disappeared since last refresh const removed = []; for (const id of prevSet) if (!curSet.has(id)) removed.push(id); // appeared since last refresh const added = []; for (const id of curSet) if (!prevSet.has(id)) added.push(id); if (removed.length) { const store = cleanupRemovedIds(loadRemovedIds()); const ts = now(); for (const id of removed) store[id] = ts; saveRemovedIds(store); } if (added.length) { const store = cleanupRemovedIds(loadRemovedIds()); const requeued = added.filter((id) => !!store[id]); if (requeued.length) { // Green overrides visually until checked. setGreenAlert(true, requeued); } } } // 5) If checked (pending list OR pending post view), clear BOTH glow states if (isCheckedContext(queueTarget) && (getGreenAlert().on || highlightOn)) { clearAllAlertsBecauseChecked(); } // 6) Decide behavior highlight (red logic) — sticky gate applied later let shouldHighlightNow = false; if (inQueue) { if (lastLive) { const countUp = count > lastLive.count; const idsDiff = (sig && sig !== lastLive.idsSig); shouldHighlightNow = (count > 0) && (countUp || idsDiff); } else { // avoid instant highlight just for arriving shouldHighlightNow = false; } // update live snapshot lastLive = { count, idsSig: sig || '', idsArr: idsArr || [] }; // update baseline only in queue context saveBaseline(count, sig || baseline.idsSig || ''); } else { // off-queue: count-based alert vs baseline, only if count is reliable const haveReliableCount = trustNavCount || Number.isFinite(cachedVerifiedOffQueueCount); if (haveReliableCount) { const countUp = count > baseline.count; shouldHighlightNow = (count > 0) && countUp; } else { shouldHighlightNow = false; } lastLive = null; } // 7) Sticky gate signature (do not include green flag; green is visual-only) const observedSignature = inQueue ? `${count}|${sig}` : `off|${count}|${trustNavCount ? 'T' : 'F'}`; maybeUpdateHighlight(observedSignature, shouldHighlightNow); } catch { maybeUpdateHighlight('error', false); lastLive = null; } finally { inflight = null; } } // ---------------------------- // Scheduling & hooks // ---------------------------- function reschedule() { if (timer) clearInterval(timer); timer = setInterval(() => refresh(false), REFRESH_MS); } function hookApproveClicks() { document.addEventListener('click', (ev) => { const t = ev.target; if (!(t instanceof Element)) return; if (!t.matches('input[type="submit"]')) return; setTimeout(() => refresh(true), AFTER_ACTION_MS); }, true); } function hookVisibilityAndNav() { document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(true); }); window.addEventListener('popstate', () => { cachedPendingTab = null; reschedule(); refresh(true); }); window.addEventListener('hashchange', () => { cachedPendingTab = null; reschedule(); refresh(true); }); } function start() { hookApproveClicks(); hookVisibilityAndNav(); reschedule(); refresh(true); // Re-evaluate in case navigation changes tab DOM setInterval(() => { cachedPendingTab = null; }, 6_000); } start(); })();