Skip to content

Instantly share code, notes, and snippets.

@PsychoSmiley
Last active February 14, 2026 18:43
Show Gist options
  • Select an option

  • Save PsychoSmiley/91588d448cb573509833c7685cee96a0 to your computer and use it in GitHub Desktop.

Select an option

Save PsychoSmiley/91588d448cb573509833c7685cee96a0 to your computer and use it in GitHub Desktop.
Userscript to track and get notified in F95zone of game updates - inspired by LenAnderson
// ==UserScript==
// @name F95zone Game Watcher
// @namespace f95-watcher
// @version 1.1
// @description Track F95zone game updates - adds a Tracked tab in the account dropdown
// @match https://f95zone.to/*
// @author PsychoSmiley
// @downloadURL https://gist.github.com/PsychoSmiley/raw/f95-watcher.user.js
// @updateURL https://gist.github.com/PsychoSmiley/raw/f95-watcher.user.js
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_addStyle
// @run-at document-end
// @noframes
// ==/UserScript==
(async () => {
'use strict';
// ==================== CONFIG ====================
// NOTIF_TYPE controls badge aggressiveness:
// 0 = no badge anywhere (Tracked tab in dropdown only)
// 1 = original only (account avatar on non-latest_alpha pages)
// 2 = max (account avatar on normal pages + Forums nav on latest_alpha pages)
const NOTIF_TYPE = 2;
const K = 'f95_watchlist',
BATCH = 50, DELAY = 600, CHECK_H = 12;
const esc = s => s ? s.replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])) : '';
const ago = ts => {
const d = Date.now() - ts;
return d < 6e4 ? 'just now' : d < 36e5 ? ~~(d/6e4)+'m ago'
: d < 864e5 ? ~~(d/36e5)+'h ago' : ~~(d/864e5)+'d ago';
};
const wait = ms => new Promise(r => setTimeout(r, ms));
const stop = e => { e.preventDefault(); e.stopPropagation(); };
const isLatestAlpha = () => /\/sam\/latest_alpha\//.test(location.pathname);
// ==================== STYLES ====================
GM_addStyle(`
.f95w-spin{display:inline-block;animation:f95w-s .6s linear infinite}
@keyframes f95w-s{to{transform:rotate(360deg)}}
.f95w-badge{background:#b71c1c;color:#fff;font-size:10px;font-weight:600;
padding:1px 5px;border-radius:8px;position:absolute;top:-2px;right:-6px}
.f95w-badge-wrap{position:relative;display:inline-block}
.f95w-new{background:#b71c1c;color:#fff;font-size:10px;font-weight:600;
padding:1px 5px;border-radius:2px;margin-left:6px;vertical-align:middle;
cursor:help}
.f95w-dim{opacity:.5}.f95w-dim:hover{opacity:.8}
.f95w-grp{color:#888!important;font-size:11px!important;text-transform:uppercase;
letter-spacing:.3px;padding:8px 12px 4px!important;background:0 0!important;
border-bottom:1px solid #2a2a2a!important}
.f95w-dd-search{width:100%;box-sizing:border-box}
.f95w-dd-empty{padding:24px;text-align:center;color:#555;font-size:13px}
.f95w-dd-footer{display:flex;justify-content:space-between;align-items:center}
.f95w-dd-footer a{color:#9398A0!important;text-decoration:none;font-size:13px}
.f95w-dd-footer a:hover{color:#eee!important}
.f95w-dd-status{color:#666;font-size:12px}
.f95w-cover{width:72px;height:72px;object-fit:cover;border-radius:3px;background:#2a2a2a;display:block;flex-shrink:0}
.f95w-fig{align-self:stretch;display:flex;align-items:center;margin-right:0!important;padding:0!important}
.f95w-acts{display:flex;gap:6px;align-items:center;font-size:13px;font-family:Lato,Roboto,-apple-system,sans-serif}
.f95w-acts a{color:#9398A0;text-decoration:none;font-size:13px}
.f95w-acts a:hover{color:#eee}
.f95w-acts a.act-played{color:#4caf50}
.f95w-acts a.act-played:hover{color:#66bb6a}
.f95w-acts a.act-remove:hover{color:#b71c1c}
.f95w-acts-sep{color:#555}
.f95w-title{font-size:15px;color:#eee;font-family:Lato,Roboto,-apple-system,sans-serif}
.f95w-title a{color:#eee;text-decoration:none}
.f95w-title a:hover{color:#b71c1c}
.f95w-minor{font-size:13px;color:#9398A0;margin-top:2px;font-family:Lato,Roboto,-apple-system,sans-serif}
.f95w-minor .ver{color:#4caf50}.f95w-minor s{color:#666}
`);
// ==================== STORE ====================
const Store = {
_d: null,
async _load() {
if (this._d) return this._d;
let d = await GM.getValue(K, null);
if (!d) { const l = localStorage.getItem(K); d = l ? JSON.parse(l) : null; }
d = d || { games: [], lastCheck: 0 };
const renames = { userVersion:'uv', userVer:'uv', developer:'dev', addedAt:'added' };
let dirty = false;
for (const g of d.games) {
for (const [old, nw] of Object.entries(renames))
if (old in g) { g[nw] = g[old]; delete g[old]; dirty = true; }
if ('status' in g) { delete g.status; dirty = true; }
if (Array.isArray(g.cl)) { g.cl = null; dirty = true; }
if (g.cl && g.version === g.uv) { g.cl = null; dirty = true; }
}
if (dirty) await this._flush(d);
return this._d = d;
},
async _flush(d) {
this._d = d;
await GM.setValue(K, d);
localStorage.setItem(K, JSON.stringify(d));
},
async games() { return (await this._load()).games; },
async add(g) {
const d = await this._load();
if (d.games.find(x => x.id === g.id)) return false;
d.games.push({ id:g.id, title:g.title, dev:g.dev, version:g.version,
uv:g.version, url:g.url, cover:g.cover||null, added:Date.now() });
await this._flush(d); return true;
},
async rm(id) { const d = await this._load(); d.games = d.games.filter(g=>g.id!==id); await this._flush(d); },
async up(id, u) {
const d = await this._load();
const g = d.games.find(x=>x.id===id);
if (g) { Object.assign(g, u); await this._flush(d); }
},
async stamp() { const d = await this._load(); d.lastCheck = Date.now(); await this._flush(d); },
async last() { return (await this._load()).lastCheck; }
};
// ==================== API ====================
const API = {
async versions(ids) {
const out = {};
for (let i = 0; i < ids.length; i += BATCH) {
const b = ids.slice(i, i + BATCH);
try {
const r = await fetch(`/sam/checker.php?threads=${b.join(',')}`);
if (r.ok) { const d = await r.json(); if (d.status==='ok'&&d.msg) Object.assign(out,d.msg); }
} catch(e) { console.warn('[F95W]', e); }
if (i + BATCH < ids.length) await wait(500);
}
return out;
},
async search(q) {
if (!q||q.length<2) return [];
try {
const r = await fetch(`/sam/latest_alpha/latest_data.php?cmd=list&cat=games&search=${encodeURIComponent(q)}&rows=15&sort=likes&_=${Date.now()}`);
if (!r.ok) return [];
const d = await r.json();
return d.status==='ok'&&d.msg?.data
? d.msg.data.map(g=>({ id:g.thread_id, title:g.title, dev:g.creator,
version:g.version==='Unknown'?null:g.version,
url:`/threads/${g.thread_id}/` }))
: [];
} catch(e) { console.warn('[F95W]', e); return []; }
}
};
// ==================== SCRAPER ====================
const RE_CL = /^(Changelog|Change-?logs?|Changelog history):?$/i;
const Scraper = {
tid: () => location.href.match(/threads\/[^.]*\.(\d+)/)?.[1],
isThread: () => /threads\/[^.]*\.\d+/.test(location.href),
_cl(post) {
if (!post) return null;
let hdr = null;
for (const b of post.querySelectorAll('b')) {
if (RE_CL.test(b.textContent.trim())) { hdr = b; break; }
}
if (!hdr) return null;
let sib = hdr.nextElementSibling;
while (sib && !sib.classList.contains('bbCodeSpoiler')) sib = sib.nextElementSibling;
if (!sib) return null;
const outer = sib.querySelector('.bbCodeBlock-content');
if (!outer) return null;
for (const node of outer.children) {
if (node.classList?.contains('bbCodeSpoiler')) {
const inner = node.querySelector('.bbCodeBlock-content');
const txt = inner ? (inner.innerText||inner.textContent||'').trim() : '';
return txt ? txt.substring(0, 1000) : null;
}
}
return null;
},
_parse(doc, url) {
const el = doc.querySelector('.p-title-value');
const post = doc.querySelector('.message-threadStarterPost article .bbWrapper');
if (!el) return null;
const full = el.innerText?.trim()||'', txt = post?.innerText||'';
const tid = url.match(/\.(\d+)/)?.[1];
const vT = full.match(/\[([^\]]*?v?[\d.]+[^\]]*?)\]/i)?.[1];
const vP = txt.match(/Version\s*:\s*([^\r\n]+)/i)?.[1]?.trim();
const title = full.replace(/^\s*\[[^\]]+\]\s*/g,'')
.replace(/\s*\[[^\]]*v?[\d.]+[^\]]*\]\s*/gi,'')
.replace(/\s*\[[^\]]+\]\s*$/g,'').trim();
const img = post?.querySelector('img.bbImage');
return {
id: parseInt(tid),
title: title || `Thread #${tid}`,
dev: txt.match(/Developer(?:\/Publisher)?\s*:\s*([^\r\n]+)/i)?.[1]?.trim()?.substring(0,50)||null,
version: vT||(vP&&vP.length<50?vP:null),
cover: img ? (img.getAttribute('data-src')||img.getAttribute('src')||null) : null,
cl: this._cl(post),
url: url.split('?')[0].split('#')[0]
};
},
current() {
return this.tid()
? this._parse(document, location.href.replace(/(threads\/[^.]*\.\d+\/).*$/,'$1'))
: null;
},
async fetch(url) {
try {
const r = await fetch(url);
if (!r.ok) return null;
return this._parse(new DOMParser().parseFromString(await r.text(),'text/html'), url);
} catch(e) { console.warn('[F95W]', e); return null; }
}
};
// ==================== CHECKER ====================
function scrapeFields(s, g) {
const u = {};
if (s?.cover && !g.cover) u.cover = s.cover;
if (s?.title) u.title = s.title;
if (s?.dev) u.dev = s.dev;
return u;
}
async function checkAll() {
const games = await Store.games();
if (!games.length) return 0;
let n = 0;
const ver = await API.versions(games.map(g => g.id));
for (const g of games) {
const v = ver[g.id];
const unplayed = g.version !== g.uv;
if (v && v !== 'Unknown') {
if (v !== g.version) {
const u = { version: v };
await wait(DELAY);
const s = await Scraper.fetch(g.url);
Object.assign(u, scrapeFields(s, g));
if (s?.cl) u.cl = s.cl;
await Store.up(g.id, u);
n++;
} else if (!g.cover || (unplayed && !g.cl)) {
await wait(DELAY);
const s = await Scraper.fetch(g.url);
const u = scrapeFields(s, g);
if (s?.cl && unplayed) u.cl = s.cl;
if (Object.keys(u).length) await Store.up(g.id, u);
}
} else if (g.url) {
await wait(DELAY);
const s = await Scraper.fetch(g.url);
if (!s) continue;
const u = scrapeFields(s, g);
if (s.version && s.version !== g.version) { u.version = s.version; u.cl = s.cl; n++; }
if (Object.keys(u).length) await Store.up(g.id, u);
}
}
await Store.stamp();
return n;
}
// ==================== BADGE ====================
async function badge() {
if (NOTIF_TYPE === 0) return;
const n = (await Store.games()).filter(g => g.version && g.version !== g.uv).length;
if (isLatestAlpha()) {
if (NOTIF_TYPE < 2) return;
const forums = document.querySelector('a.p-navEl-link[href="/"]');
if (!forums) return;
forums.querySelector('.f95w-badge')?.remove();
if (!n) return;
const b = document.createElement('span');
b.className = 'f95w-badge';
b.style.cssText = 'position:static;display:inline-block;margin-left:4px;vertical-align:middle';
b.textContent = n;
const txt = forums.querySelector('span');
if (txt) txt.after(b);
else forums.appendChild(b);
} else {
// --- normal pages: badge on account avatar (NOTIF_TYPE 1 or 2) ---
const av = document.querySelector('.p-navgroup--member .p-navgroup-link--user');
if (!av) return;
av.querySelector('.f95w-badge')?.remove();
if (!n) return;
let w = av.querySelector('.f95w-badge-wrap');
if (!w) {
const a = av.querySelector('.avatar');
if (!a) return;
w = document.createElement('span');
w.className = 'f95w-badge-wrap';
a.before(w); w.appendChild(a);
}
const b = document.createElement('span');
b.className = 'f95w-badge'; b.textContent = n;
w.appendChild(b);
}
}
// ==================== TRACK BUTTON ====================
function trackBtn() {
if (!Scraper.isThread()) return;
const wb = document.querySelector('[data-sk-watch]');
if (!wb || wb.parentElement.querySelector('.f95w-track')) return;
const btn = document.createElement('a');
btn.className = 'button--link button rippleButton f95w-track';
btn.href = 'javascript:void(0)';
const sp = document.createElement('span');
sp.className = 'button-text';
btn.appendChild(sp);
btn.appendChild(Object.assign(document.createElement('div'), { className:'ripple-container' }));
wb.insertAdjacentElement('afterend', btn);
const sync = async () => {
const tid = parseInt(Scraper.tid());
const on = (await Store.games()).some(g => g.id === tid);
sp.textContent = on ? 'Untrack' : 'Track';
btn.onclick = async e => {
e.preventDefault(); sp.textContent = '...';
if (on) await Store.rm(tid);
else { const g = Scraper.current(); if (g) await Store.add(g); }
sync(); badge();
};
};
sync();
}
// ==================== DROPDOWN ====================
let _tab, _panel;
function watchDropdown() {
new MutationObserver(() => {
const s = document.querySelector('.menu--account .hScroller-scroll');
if (s && !s.querySelector('.f95w-tab')) inject();
}).observe(document.body, { childList:true, subtree:true });
}
function inject() {
const mc = document.querySelector('.menu--account .menu-content');
if (!mc) return;
const scroll = mc.querySelector('.hScroller-scroll');
const panes = mc.querySelector('.tabPanes');
if (!scroll || !panes) return;
_tab = Object.assign(document.createElement('a'),
{ className:'tabs-tab f95w-tab', href:'#', textContent:'Tracked' });
_tab.setAttribute('role','tab');
_tab.setAttribute('tabindex','0');
scroll.appendChild(_tab);
_panel = document.createElement('li');
_panel.className = 'f95w-panel';
_panel.setAttribute('role','tabpanel');
_panel.setAttribute('aria-expanded','false');
panes.appendChild(_panel);
mc.querySelector('.menu-tabHeader').addEventListener('click', e => {
const t = e.target.closest('.tabs-tab');
if (!t) return;
if (t.classList.contains('f95w-tab')) { stop(e); activate(mc); }
else deactivate();
}, true);
window.dispatchEvent(new Event('resize'));
}
function setTab(el, on) {
el.classList.toggle('is-active', on);
el.setAttribute(el.tagName === 'A' ? 'aria-selected' : 'aria-expanded', String(on));
}
function activate(mc) {
mc.querySelectorAll('.tabs-tab').forEach(t => setTab(t, false));
mc.querySelector('.tabPanes').querySelectorAll(':scope>li').forEach(p => setTab(p, false));
setTab(_tab, true); setTab(_panel, true);
renderPanel();
}
function deactivate() {
if (!_tab) return;
setTab(_tab, false); setTab(_panel, false);
}
// ==================== PANEL ====================
async function renderPanel() {
const lc = await Store.last();
_panel.innerHTML = `
<div class="menu-row menu-row--alt menu-row--close">
<input type="text" class="input f95w-dd-search" placeholder="Search F95 to add...">
</div>
<div class="menu-scroller"><ol class="listPlain f95w-dd-list"></ol></div>
<div class="menu-footer menu-footer--close f95w-dd-footer">
<a href="#" class="f95w-dd-reload">&#x21bb; Check updates</a>
<span class="f95w-dd-status">${lc ? ago(lc) : 'Never'}</span>
</div>`;
await renderGames();
let st;
_panel.querySelector('.f95w-dd-search').oninput = function () {
clearTimeout(st);
const q = this.value.trim();
if (q.length < 2) { renderGames(); return; }
st = setTimeout(() => searchResults(q), 300);
};
_panel.querySelector('.f95w-dd-reload').onclick = async e => {
stop(e);
const el = _panel.querySelector('.f95w-dd-reload');
el.innerHTML = '<span class="f95w-spin">&#x21bb;</span> Checking...';
const n = await checkAll();
el.innerHTML = `&#x21bb; Done (${n})`;
setTimeout(() => { el.innerHTML = '&#x21bb; Check updates'; }, 2000);
_panel.querySelector('.f95w-dd-status').textContent = 'just now';
await renderGames(); badge();
};
}
async function searchResults(q) {
const list = _panel.querySelector('.f95w-dd-list');
list.innerHTML = '<li class="f95w-dd-empty">Searching...</li>';
const res = await API.search(q);
const ids = (await Store.games()).map(g => g.id);
if (!res.length) { list.innerHTML = '<li class="f95w-dd-empty">No results</li>'; return; }
list.innerHTML = res.map(g => `
<li class="menu-row menu-row--close menu-row--separated" data-sid="${g.id}">
<div class="contentRow"><div class="contentRow-main">
<div class="contentRow-extra">
<button class="button--link button--smaller button f95w-add"
${ids.includes(g.id)?'disabled':''}>
<span class="button-text">${ids.includes(g.id)?'Added':'Add'}</span>
</button>
</div>
<div class="contentRow-title">${esc(g.title)}</div>
<div class="contentRow-minor contentRow-minor--smaller">
${g.dev?esc(g.dev)+' · ':''}${g.version||'?'}
</div>
</div></div>
</li>`).join('');
list.querySelectorAll('.f95w-add:not([disabled])').forEach(btn => {
btn.onclick = async e => {
stop(e);
const game = res.find(g => g.id === parseInt(btn.closest('[data-sid]').dataset.sid));
if (!game) return;
btn.querySelector('.button-text').textContent = '...'; btn.disabled = true;
try {
const s = await Scraper.fetch(game.url);
if (s) Object.assign(game, scrapeFields(s, game),
{ title:s.title||game.title, dev:s.dev||game.dev, version:s.version||game.version });
} catch(e) { console.warn('[F95W]', e); }
await Store.add(game);
btn.querySelector('.button-text').textContent = 'Added';
badge();
};
});
}
async function renderGames() {
const list = _panel.querySelector('.f95w-dd-list');
const games = await Store.games();
if (!games.length) {
list.innerHTML = '<li class="f95w-dd-empty">No games tracked.<br>Visit a thread and click Track, or search above.</li>';
return;
}
const by = a => [...a].sort((x,y) => (y.added||0) - (x.added||0));
const hasUp = g => g.version && g.version !== g.uv;
const up = by(games.filter(hasUp));
const ok = by(games.filter(g => !hasUp(g)));
let h = '';
if (up.length) h += `<li class="f95w-grp menu-row">Updates · ${up.length}</li>` + up.map(g => card(g,true)).join('');
if (ok.length) h += `<li class="f95w-grp menu-row">Up to date · ${ok.length}</li>` + ok.map(g => card(g,false)).join('');
list.innerHTML = h;
list.querySelectorAll('[data-gid]').forEach(el => {
const id = parseInt(el.dataset.gid);
el.querySelector('.act-played')?.addEventListener('click', async e => {
stop(e);
const g = (await Store.games()).find(x => x.id === id);
if (g) { await Store.up(id, { uv:g.version, cl:null }); renderGames(); badge(); }
});
el.querySelector('.act-remove')?.addEventListener('click', async e => {
stop(e);
el.style.cssText = 'transition:opacity .15s;opacity:0';
setTimeout(async () => { await Store.rm(id); renderGames(); badge(); }, 150);
});
});
}
function card(g, up) {
const url = g.url || `/threads/${g.id}/`;
const fig = g.cover
? `<div class="contentRow-figure f95w-fig"><img src="${esc(g.cover)}" class="f95w-cover" loading="lazy" onerror="this.style.display='none'"></div>`
: '';
const acts = (up ? '<a href="#" class="act-played">Played</a><span class="f95w-acts-sep">·</span>' : '')
+ '<a href="#" class="act-remove">Untrack</a>';
let tag = '';
if (up) {
tag = g.cl
? `<span class="f95w-new" title="${esc(g.cl)}">NEW</span>`
: '<span class="f95w-new">NEW</span>';
}
return `<li class="menu-row menu-row--close menu-row--separated ${up?'':'f95w-dim'}" data-gid="${g.id}">
<div class="contentRow">${fig}<div class="contentRow-main">
<div class="contentRow-extra"><div class="f95w-acts">${acts}</div></div>
<div class="f95w-title"><a href="${url}">${esc(g.title)}</a>${tag}</div>
<div class="f95w-minor">${g.dev?esc(g.dev)+' · ':''}<span class="ver">${g.version||'?'}</span>${up&&g.uv?' <s>'+g.uv+'</s>':''}</div>
</div></div></li>`;
}
// ==================== INIT ====================
trackBtn();
watchDropdown();
badge();
if ((await Store.games()).length && Date.now() - await Store.last() > CHECK_H * 36e5) {
await Store.stamp();
setTimeout(async () => { await checkAll(); badge(); }, 2000);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment