Created
May 24, 2026 17:55
-
-
Save vdeemann/c84c29ceeed3e2f16b11f72bfbb3321c to your computer and use it in GitHub Desktop.
Tracks posts you scroll past on Tumblr's Explore page. On the next page refresh, those posts are hidden — before they paint, no flash.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Tumblr Explore - Hide Seen Posts | |
| // @namespace https://github.com/vdeemann | |
| // @version 2.2.2 | |
| // @description Tracks posts you scroll past on Tumblr's Explore page. On the next page refresh, those posts are hidden — before they paint, no flash. | |
| // @author Dee | |
| // @match https://www.tumblr.com/explore* | |
| // @match https://www.tumblr.com/dashboard/explore* | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_registerMenuCommand | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // -------------------- config & storage keys -------------------- | |
| const STORAGE_KEY = 'tumblr_explore_seen_posts'; | |
| const PANEL_POS_KEY = 'tumblr_explore_panel_pos'; | |
| const PANEL_COLLAPSED_KEY = 'tumblr_explore_panel_collapsed'; | |
| const DWELL_KEY = 'tumblr_explore_dwell_ms'; | |
| const VISIBILITY_KEY = 'tumblr_explore_visibility_ratio'; | |
| const MAX_STORED_IDS = 10000; | |
| const DEFAULT_DWELL_MS = 400; | |
| const DEFAULT_VISIBILITY_RATIO = 0.2; | |
| // Default anchor: bottom-right, clearing Tumblr's footer row | |
| const DEFAULT_RIGHT = 20; | |
| const DEFAULT_BOTTOM = 60; | |
| let VIEW_DWELL_MS = GM_getValue(DWELL_KEY, DEFAULT_DWELL_MS); | |
| let VIEW_VISIBILITY_RATIO = GM_getValue(VISIBILITY_KEY, DEFAULT_VISIBILITY_RATIO); | |
| // -------------------- seen-set (loaded synchronously at document-start) -------------------- | |
| function loadSeen() { | |
| try { return new Set(JSON.parse(GM_getValue(STORAGE_KEY, '[]'))); } | |
| catch (e) { return new Set(); } | |
| } | |
| function saveSeen(set) { | |
| let arr = Array.from(set); | |
| if (arr.length > MAX_STORED_IDS) arr = arr.slice(arr.length - MAX_STORED_IDS); | |
| GM_setValue(STORAGE_KEY, JSON.stringify(arr)); | |
| } | |
| let seen = loadSeen(); | |
| const seenAtLoad = new Set(seen); // snapshot for "only hide what was seen before this tab loaded" | |
| let pendingSave = false; | |
| function queueSave() { | |
| if (pendingSave) return; | |
| pendingSave = true; | |
| setTimeout(() => { | |
| saveSeen(seen); | |
| pendingSave = false; | |
| updatePanelCount(); | |
| }, 500); | |
| } | |
| // -------------------- PRE-PAINT HIDING -------------------- | |
| // Inject CSS as early as possible — before <body> even exists — so any | |
| // element flagged with [data-tm-hide] is invisible from the first paint. | |
| function injectHideCSS() { | |
| const css = ` | |
| [data-tm-hide="1"] { display: none !important; } | |
| `; | |
| if (document.head) { | |
| const style = document.createElement('style'); | |
| style.id = 'tm-tumblr-prehide-style'; | |
| style.textContent = css; | |
| document.head.appendChild(style); | |
| } else { | |
| // <head> doesn't exist yet — inject as soon as it does | |
| const obs = new MutationObserver(() => { | |
| if (document.head) { | |
| obs.disconnect(); | |
| const style = document.createElement('style'); | |
| style.id = 'tm-tumblr-prehide-style'; | |
| style.textContent = css; | |
| document.head.appendChild(style); | |
| } | |
| }); | |
| obs.observe(document.documentElement, { childList: true, subtree: true }); | |
| } | |
| } | |
| injectHideCSS(); | |
| // Synchronously flag any post that's in seenAtLoad the instant it appears | |
| // in the DOM. Because MutationObserver microtasks run before the next | |
| // paint, the [data-tm-hide] attribute lands before the browser ever | |
| // shows the post — no flash. | |
| function checkAndFlag(node) { | |
| if (!node || node.nodeType !== 1) return; | |
| // The post itself | |
| if (node.hasAttribute && node.hasAttribute('data-id')) { | |
| const id = node.getAttribute('data-id'); | |
| if (seenAtLoad.has(id)) { | |
| const cell = node.closest('[data-cell-id]') || node; | |
| cell.setAttribute('data-tm-hide', '1'); | |
| } | |
| } | |
| // Or a descendant of the inserted subtree | |
| if (node.querySelectorAll) { | |
| const inner = node.querySelectorAll('[data-id]'); | |
| for (const el of inner) { | |
| const id = el.getAttribute('data-id'); | |
| if (seenAtLoad.has(id)) { | |
| const cell = el.closest('[data-cell-id]') || el; | |
| cell.setAttribute('data-tm-hide', '1'); | |
| } | |
| } | |
| } | |
| } | |
| const prepaintObserver = new MutationObserver(muts => { | |
| for (const m of muts) { | |
| for (const node of m.addedNodes) checkAndFlag(node); | |
| } | |
| }); | |
| prepaintObserver.observe(document.documentElement, { childList: true, subtree: true }); | |
| // -------------------- post detection -------------------- | |
| function findPosts(root = document) { | |
| return root.querySelectorAll('div[data-id]'); | |
| } | |
| function getPostId(el) { | |
| if (el.dataset && el.dataset.id) return el.dataset.id; | |
| let node = el.parentElement; | |
| while (node && node !== document.body) { | |
| if (node.dataset && node.dataset.id) return node.dataset.id; | |
| node = node.parentElement; | |
| } | |
| return null; | |
| } | |
| function getPostContainer(el) { | |
| return el.closest('[data-cell-id]') || el.closest('article') || el.closest('li') || el; | |
| } | |
| // -------------------- unhide -------------------- | |
| function unhideAll() { | |
| document.querySelectorAll('[data-tm-hide="1"]').forEach(el => { | |
| el.removeAttribute('data-tm-hide'); | |
| }); | |
| } | |
| // -------------------- IntersectionObserver (silent tracking) -------------------- | |
| const dwellTimers = new WeakMap(); | |
| let io = null; | |
| function buildObserver() { | |
| if (io) io.disconnect(); | |
| io = new IntersectionObserver(entries => { | |
| entries.forEach(entry => { | |
| const container = entry.target; | |
| const id = getPostId(container); | |
| if (!id) return; | |
| if (seen.has(id)) { io.unobserve(container); return; } | |
| if (entry.isIntersecting && entry.intersectionRatio >= VIEW_VISIBILITY_RATIO) { | |
| if (!dwellTimers.has(container)) { | |
| const t = setTimeout(() => { | |
| seen.add(id); | |
| queueSave(); | |
| dwellTimers.delete(container); | |
| io.unobserve(container); | |
| }, VIEW_DWELL_MS); | |
| dwellTimers.set(container, t); | |
| } | |
| } else { | |
| const t = dwellTimers.get(container); | |
| if (t) { clearTimeout(t); dwellTimers.delete(container); } | |
| } | |
| }); | |
| }, { threshold: [VIEW_VISIBILITY_RATIO] }); | |
| } | |
| function observeNewPosts() { | |
| findPosts().forEach(p => { | |
| if (p.dataset.tmObserved === '1') return; | |
| p.dataset.tmObserved = '1'; | |
| io.observe(p); | |
| }); | |
| } | |
| function reobserveAll() { | |
| document.querySelectorAll('[data-tm-observed="1"]').forEach(el => delete el.dataset.tmObserved); | |
| buildObserver(); | |
| observeNewPosts(); | |
| } | |
| // -------------------- mutation observer for infinite scroll & panel resilience -------------------- | |
| const mo = new MutationObserver(muts => { | |
| let changed = false; | |
| for (const m of muts) { | |
| if (m.addedNodes.length || m.removedNodes.length) { changed = true; break; } | |
| } | |
| if (!changed) return; | |
| if (panelEl && !document.body.contains(panelEl)) { | |
| panelEl = null; | |
| countEl = null; | |
| buildPanel(); | |
| } | |
| observeNewPosts(); | |
| }); | |
| // -------------------- control panel -------------------- | |
| let panelEl = null; | |
| let countEl = null; | |
| function updatePanelCount() { | |
| if (countEl) countEl.textContent = seen.size.toLocaleString(); | |
| } | |
| function buildPanel() { | |
| if (!document.body) return; | |
| const panel = document.createElement('div'); | |
| panel.id = 'tm-tumblr-hide-panel'; | |
| panel.innerHTML = ` | |
| <div class="tm-panel-header" title="Drag to move"> | |
| <span class="tm-panel-title">🙈 Hide Seen</span> | |
| <button class="tm-panel-collapse" title="Collapse">_</button> | |
| </div> | |
| <div class="tm-panel-body"> | |
| <div class="tm-panel-stat"> | |
| <span class="tm-panel-count">0</span> | |
| <span class="tm-panel-label">posts tracked</span> | |
| </div> | |
| <div class="tm-panel-hint">Hidden on next refresh</div> | |
| <div class="tm-panel-slider-row"> | |
| <label>Dwell: <span class="tm-dwell-val">${VIEW_DWELL_MS}</span>ms</label> | |
| <input type="range" class="tm-dwell-slider" min="0" max="3000" step="100" value="${VIEW_DWELL_MS}"> | |
| </div> | |
| <div class="tm-panel-slider-row"> | |
| <label>Visible: <span class="tm-vis-val">${Math.round(VIEW_VISIBILITY_RATIO * 100)}</span>%</label> | |
| <input type="range" class="tm-vis-slider" min="5" max="90" step="5" value="${Math.round(VIEW_VISIBILITY_RATIO * 100)}"> | |
| </div> | |
| <button class="tm-panel-btn" data-action="clear">Clear history</button> | |
| <button class="tm-panel-btn" data-action="export">Export to clipboard</button> | |
| <button class="tm-panel-btn" data-action="import">Import from clipboard</button> | |
| <button class="tm-panel-btn tm-panel-btn-secondary" data-action="unhide">Show all (this session)</button> | |
| </div> | |
| `; | |
| if (!document.getElementById('tm-tumblr-hide-style')) { | |
| const style = document.createElement('style'); | |
| style.id = 'tm-tumblr-hide-style'; | |
| style.textContent = ` | |
| #tm-tumblr-hide-panel { | |
| position: fixed; z-index: 2147483647; | |
| background: rgba(20, 30, 45, 0.95); color: #e6e6e6; | |
| border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| font-size: 13px; width: 220px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| user-select: none; backdrop-filter: blur(8px); | |
| /* column-reverse so body renders ABOVE header visually, | |
| making the panel grow upward when expanded. */ | |
| display: flex; flex-direction: column-reverse; | |
| } | |
| #tm-tumblr-hide-panel.tm-collapsed .tm-panel-body { display: none; } | |
| #tm-tumblr-hide-panel .tm-panel-header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px 10px; cursor: move; | |
| border-top: 1px solid rgba(255,255,255,0.08); | |
| } | |
| #tm-tumblr-hide-panel.tm-collapsed .tm-panel-header { border-top: none; } | |
| #tm-tumblr-hide-panel .tm-panel-title { font-weight: 600; } | |
| #tm-tumblr-hide-panel .tm-panel-collapse { | |
| background: transparent; border: none; color: #e6e6e6; | |
| cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-collapse:hover { color: #00b8ff; } | |
| #tm-tumblr-hide-panel .tm-panel-body { | |
| padding: 10px; display: flex; flex-direction: column; gap: 8px; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-stat { text-align: center; padding: 4px 0; } | |
| #tm-tumblr-hide-panel .tm-panel-count { | |
| font-size: 22px; font-weight: 700; color: #00b8ff; display: block; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-label { font-size: 11px; opacity: 0.7; } | |
| #tm-tumblr-hide-panel .tm-panel-hint { | |
| font-size: 10px; opacity: 0.5; text-align: center; | |
| font-style: italic; margin-top: -6px; margin-bottom: 4px; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-slider-row { | |
| display: flex; flex-direction: column; gap: 2px; | |
| font-size: 11px; opacity: 0.9; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-slider-row input { | |
| width: 100%; accent-color: #00b8ff; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-btn { | |
| background: rgba(255,255,255,0.08); color: #e6e6e6; | |
| border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; | |
| padding: 6px 8px; font-size: 12px; cursor: pointer; | |
| font-family: inherit; transition: background 0.15s; | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-btn:hover { | |
| background: rgba(0, 184, 255, 0.2); | |
| border-color: rgba(0, 184, 255, 0.4); | |
| } | |
| #tm-tumblr-hide-panel .tm-panel-btn-secondary { | |
| opacity: 0.7; font-size: 11px; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| document.body.appendChild(panel); | |
| // Anchor by bottom-right so the panel grows upward when expanded. | |
| // We only honor saved positions stored as {right, bottom}; legacy | |
| // {left, top} positions from older versions are discarded so the | |
| // panel re-anchors to the bottom-right by default. | |
| const savedPos = GM_getValue(PANEL_POS_KEY, null); | |
| let usedSaved = false; | |
| if (savedPos) { | |
| try { | |
| const pos = JSON.parse(savedPos); | |
| if (pos && pos.right != null && pos.bottom != null) { | |
| panel.style.right = pos.right + 'px'; | |
| panel.style.bottom = pos.bottom + 'px'; | |
| usedSaved = true; | |
| } | |
| } catch (e) { /* fall through to defaults */ } | |
| } | |
| if (!usedSaved) { | |
| panel.style.right = DEFAULT_RIGHT + 'px'; | |
| panel.style.bottom = DEFAULT_BOTTOM + 'px'; | |
| } | |
| if (GM_getValue(PANEL_COLLAPSED_KEY, false)) panel.classList.add('tm-collapsed'); | |
| const header = panel.querySelector('.tm-panel-header'); | |
| let dragging = false, offsetX = 0, offsetY = 0; | |
| header.addEventListener('mousedown', e => { | |
| if (e.target.classList.contains('tm-panel-collapse')) return; | |
| dragging = true; | |
| const rect = panel.getBoundingClientRect(); | |
| offsetX = e.clientX - rect.left; | |
| offsetY = e.clientY - rect.top; | |
| // Switch to left/top during drag for free movement | |
| panel.style.left = rect.left + 'px'; | |
| panel.style.top = rect.top + 'px'; | |
| panel.style.right = 'auto'; | |
| panel.style.bottom = 'auto'; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', e => { | |
| if (!dragging) return; | |
| const left = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - offsetX)); | |
| const top = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, e.clientY - offsetY)); | |
| panel.style.left = left + 'px'; | |
| panel.style.top = top + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (!dragging) return; | |
| dragging = false; | |
| // Convert final left/top back to right/bottom so the panel stays | |
| // anchored to the bottom-right and grows upward on expand. | |
| const rect = panel.getBoundingClientRect(); | |
| const right = Math.max(0, window.innerWidth - rect.right); | |
| const bottom = Math.max(0, window.innerHeight - rect.bottom); | |
| panel.style.left = 'auto'; | |
| panel.style.top = 'auto'; | |
| panel.style.right = right + 'px'; | |
| panel.style.bottom = bottom + 'px'; | |
| GM_setValue(PANEL_POS_KEY, JSON.stringify({ right, bottom })); | |
| }); | |
| panel.querySelector('.tm-panel-collapse').addEventListener('click', () => { | |
| panel.classList.toggle('tm-collapsed'); | |
| GM_setValue(PANEL_COLLAPSED_KEY, panel.classList.contains('tm-collapsed')); | |
| }); | |
| const dwellSlider = panel.querySelector('.tm-dwell-slider'); | |
| const dwellVal = panel.querySelector('.tm-dwell-val'); | |
| const visSlider = panel.querySelector('.tm-vis-slider'); | |
| const visVal = panel.querySelector('.tm-vis-val'); | |
| let sliderDebounce; | |
| dwellSlider.addEventListener('input', () => { | |
| VIEW_DWELL_MS = parseInt(dwellSlider.value, 10); | |
| dwellVal.textContent = VIEW_DWELL_MS; | |
| clearTimeout(sliderDebounce); | |
| sliderDebounce = setTimeout(() => { | |
| GM_setValue(DWELL_KEY, VIEW_DWELL_MS); | |
| reobserveAll(); | |
| }, 200); | |
| }); | |
| visSlider.addEventListener('input', () => { | |
| VIEW_VISIBILITY_RATIO = parseInt(visSlider.value, 10) / 100; | |
| visVal.textContent = visSlider.value; | |
| clearTimeout(sliderDebounce); | |
| sliderDebounce = setTimeout(() => { | |
| GM_setValue(VISIBILITY_KEY, VIEW_VISIBILITY_RATIO); | |
| reobserveAll(); | |
| }, 200); | |
| }); | |
| panel.addEventListener('click', e => { | |
| const btn = e.target.closest('[data-action]'); | |
| if (!btn) return; | |
| const action = btn.dataset.action; | |
| if (action === 'clear') { | |
| if (confirm(`Clear all ${seen.size} tracked posts?`)) { | |
| seen = new Set(); | |
| seenAtLoad.clear(); | |
| saveSeen(seen); | |
| unhideAll(); | |
| updatePanelCount(); | |
| } | |
| } else if (action === 'export') { | |
| const data = JSON.stringify(Array.from(seen)); | |
| navigator.clipboard.writeText(data).then( | |
| () => flashButton(btn, `Copied ${seen.size} IDs!`), | |
| () => prompt('Copy this JSON:', data) | |
| ); | |
| } else if (action === 'import') { | |
| navigator.clipboard.readText().then(text => { | |
| try { | |
| const arr = JSON.parse(text); | |
| if (!Array.isArray(arr)) throw new Error('Not an array'); | |
| arr.forEach(id => seen.add(String(id))); | |
| saveSeen(seen); | |
| updatePanelCount(); | |
| flashButton(btn, `Imported! Total: ${seen.size}`); | |
| } catch (err) { | |
| alert('Invalid clipboard JSON: ' + err.message); | |
| } | |
| }).catch(() => { | |
| const input = prompt('Paste JSON array of post IDs:'); | |
| if (!input) return; | |
| try { | |
| const arr = JSON.parse(input); | |
| if (!Array.isArray(arr)) throw new Error('Not an array'); | |
| arr.forEach(id => seen.add(String(id))); | |
| saveSeen(seen); | |
| updatePanelCount(); | |
| } catch (err) { | |
| alert('Invalid JSON: ' + err.message); | |
| } | |
| }); | |
| } else if (action === 'unhide') { | |
| unhideAll(); | |
| flashButton(btn, 'Shown until reload'); | |
| } | |
| }); | |
| countEl = panel.querySelector('.tm-panel-count'); | |
| panelEl = panel; | |
| updatePanelCount(); | |
| } | |
| function flashButton(btn, msg) { | |
| const original = btn.textContent; | |
| btn.textContent = msg; | |
| btn.style.background = 'rgba(0, 184, 255, 0.35)'; | |
| setTimeout(() => { | |
| btn.textContent = original; | |
| btn.style.background = ''; | |
| }, 1500); | |
| } | |
| // -------------------- start (deferred until body exists) -------------------- | |
| function start() { | |
| buildObserver(); | |
| buildPanel(); | |
| observeNewPosts(); | |
| mo.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| function whenReady(fn) { | |
| if (document.body) { fn(); return; } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', fn, { once: true }); | |
| } else { | |
| // body not there yet but parsing complete? unusual — retry shortly | |
| setTimeout(() => whenReady(fn), 50); | |
| } | |
| } | |
| whenReady(() => setTimeout(start, 100)); | |
| GM_registerMenuCommand('Show seen-posts count', () => { | |
| alert(`Tumblr Explore: ${seen.size} posts tracked.`); | |
| }); | |
| GM_registerMenuCommand('Clear seen-posts history', () => { | |
| if (confirm(`Clear all ${seen.size} tracked posts?`)) { | |
| seen = new Set(); | |
| seenAtLoad.clear(); | |
| saveSeen(seen); | |
| unhideAll(); | |
| updatePanelCount(); | |
| } | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment