Something went wrong. Try again.
this post sux
Something went wrong. Try again.
41 kB · 1237 lines
JavaScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238/* * sux userscript — the extension's background page, content script, and * sidebar merged into one scope running on bsky.app. * * - Engine (was background.js): settings, classification queue, Jev HTTP * client via GM_xmlhttpRequest, verdict cache persisted with GM_setValue. * - Feed (was content.js): DOM post discovery, item mapping, hiding. * - Panel (was sidebar.*): floating settings panel in a closed Shadow DOM. * * Differences from the extension: no page-world fetch interceptor (it was a * supplement; the DOM scan is the source of truth), no browser-theme colors, * and the queue/stats are per tab. Settings and the verdict cache are shared * across tabs through userscript storage. *//* global SuxShared, GM_xmlhttpRequest, GM_getValue, GM_setValue, GM_addValueChangeListener, GM_registerMenuCommand, unsafeWindow */
var SH = SuxShared;
/* ================= storage ================= */
function loadValue(name, fallback) { try { var v = GM_getValue(name, fallback); return v === undefined ? fallback : v; } catch (e) { return fallback; }}
function saveValue(name, value) { try { GM_setValue(name, value); } catch (e) { /* storage may be unavailable */ }}
/* ================= engine (was background.js) ================= */
var API_URL = "https://api.typesafe.ai/v1/systemone";var CONCURRENCY = 2;var BATCH = 3; // posts per Jev requestvar MAX_ATTEMPTS = 5;var CACHE_CAP = 20000; // per-clause scores (a post with 3 clauses = 3 entries)var VERDICT_CAP = 10000;var REQUEST_TIMEOUT_MS = 30000;
var settings = SH.normalizeSettings(loadValue("settings", null));var cache = new Map(); // clauseScoreKeys key -> score (persisted as "scores")var gen = 0; // bumps on every settings change; tags jobsvar verdictsById = new Map(); // post id -> verdict (session only)var queue = []; // pending jobs, next first: {key, text, alt, ids, settings, gen, have, need}var running = 0;var pumpScheduled = false;var cacheWriteTimer = null;var settingsWriteTimer = null;
(function () { var stored = loadValue("scores", null); if (stored && typeof stored === "object") { Object.keys(stored).forEach(function (k) { cache.set(k, stored[k]); }); } saveValue("cache", null); // pre-0.2 verdict cache})();
var stats = { judged: 0, // verdicts served (API-computed + cache hits) failed: 0, // API attempts that exhausted retries (failed open) lastError: "", lastErrorAt: 0,};
function recordError(context, err) { stats.failed++; stats.lastError = (context ? context + ": " : "") + String((err && err.message) || err).slice(0, 500); stats.lastErrorAt = Date.now();}
function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); });}
/* ---------------- Jev API ---------------- */
// GM_xmlhttpRequest runs outside the page, so bsky.app's CSP and CORS don't apply.function gmPost(url, headers, body) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "POST", url: url, headers: headers, data: body, timeout: REQUEST_TIMEOUT_MS, onload: function (r) { resolve(r); }, onerror: function () { reject(new Error("network error")); }, ontimeout: function () { reject(new Error("request timed out after " + REQUEST_TIMEOUT_MS / 1000 + "s")); }, onabort: function () { reject(new Error("request aborted")); }, }); });}
function headerOf(raw, name) { var m = String(raw || "").match(new RegExp("^" + name + ":\\s*(.*)$", "im")); return m ? m[1].trim() : null;}
async function jevRequest(apiKey, payload) { var delay = 2000; for (var attempt = 1; ; attempt++) { var res = await gmPost(API_URL, { Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, JSON.stringify(payload)); if (res.status === 429 || res.status === 529) { if (attempt >= MAX_ATTEMPTS) { throw new Error("Jev API overloaded (HTTP " + res.status + "), gave up after " + MAX_ATTEMPTS + " attempts"); } var retryAfter = headerOf(res.responseHeaders, "Retry-After"); var wait = retryAfter ? Math.min((parseInt(retryAfter, 10) || 0) * 1000, 60000) : delay; if (!wait || wait < 0) wait = delay; await sleep(wait); delay = Math.min(delay * 2, 30000); continue; } if (res.status < 200 || res.status >= 300) { var detail = String(res.responseText || "").slice(0, 200); throw new Error("Jev API HTTP " + res.status + (detail ? ": " + detail : "")); } return JSON.parse(res.responseText); }}
// Judge up to BATCH jobs that share one settings snapshot in a single Jev// call. Only the clauses each job still needs (the rest came from the score// cache) are asked.async function classifyBatch(jobs) { // Parse with the settings the requests were built with — the live settings // may have changed while the jobs were queued. var st = jobs[0].settings; var per = null; try { var payload = SH.buildBatchRequest(st, jobs.map(function (j) { return { post: { text: j.text, alt: j.alt }, only: j.need }; })); var res = await jevRequest(settings.apiKey, payload); var split = SH.splitBatchAnswers((res && res.answers) || null, jobs.length); per = split.map(function (a) { return SH.jevToScores(st, a); }); } catch (err) { recordError("classify", err); } jobs.forEach(function (job, k) { if (!per) { finish(job, null); return; } // A missing clause score isn't cached, so the next sighting asks again // instead of serving a fail-open forever. SH.clauseScoreKeys(st, job.text, job.alt).forEach(function (c) { if (typeof per[k][c.score] === "number") cache.set(c.key, per[k][c.score]); }); finish(job, Object.assign({}, job.have, per[k])); }); trimCache(); scheduleCacheWrite();}
// Deliver a job's verdict to every post id waiting on it. scores null (or// undecidable) → fail open: a broken filter must not brick the feed.function finish(job, scores) { var st = job.settings; var verdict = scores ? SH.decideFromScores(st, scores) : null; var details = null; if (verdict === null) { if (scores) { recordError("classify", new Error( "Unexpected Jev response shape: " + JSON.stringify(scores).slice(0, 400))); } verdict = "show"; } else { details = SH.roundScores(scores); if (!Object.keys(details).length) details = null; } stats.judged++; for (var i = 0; i < job.ids.length; i++) { verdictsById.set(job.ids[i], { key: job.key, verdict: verdict }); if (verdictsById.size > VERDICT_CAP) { verdictsById.delete(verdictsById.keys().next().value); } } if (job.gen !== gen) return; // judged under rules we've since left onVerdicts(job.ids.map(function (id) { var item = { id: id, verdict: verdict }; if (details) item.d = details; return item; }));}
/* ---------------- queue ---------------- */
function schedulePump() { if (pumpScheduled) return; pumpScheduled = true; setTimeout(function () { pumpScheduled = false; pump(); }, 0);}
function pump() { if (!SH.isActive(settings)) { queue.length = 0; // filtering off → drop pending work return; } if (!settings.apiKey) return; // resolvePost fails open before enqueueing while (running < CONCURRENCY && queue.length) { // Same settings snapshot only — one request, one question set. var batch = [queue.shift()]; for (var i = 0; i < queue.length && batch.length < BATCH; ) { if (queue[i].settings === batch[0].settings) batch.push(queue.splice(i, 1)[0]); else i++; } running++; classifyBatch(batch) .catch(function (err) { recordError("classify", err); }) .then(function () { running--; schedulePump(); }); }}
// Returns an immediate verdict object, or null when the post is (now)// pending — its job is appended to `fresh` for the caller to enqueue.// In-memory verdicts are tagged with the rule-config key they belong to, so// flipping a rule or a direction never serves a stale verdict.function resolvePost(p, fresh) { var key = SH.isActive(settings) && SH.isJudgeable(p) && settings.apiKey ? SH.cacheKey(settings, p.text, p.alt) : null; var prev = verdictsById.get(p.id); if (prev && prev.key === key) { return { id: p.id, verdict: prev.verdict }; } if (!SH.isJudgeable(p)) { var open = { id: p.id, verdict: "show" }; // nothing to judge → fail open verdictsById.set(p.id, { key: key, verdict: open.verdict }); return open; } if (!SH.isActive(settings)) { return { id: p.id, verdict: "show" }; // deliberately not cached } if (!settings.apiKey) { stats.lastError = "Missing TypeSafe API key — add it in the sux panel."; stats.lastErrorAt = Date.now(); var v = { id: p.id, verdict: "show" }; verdictsById.set(p.id, { key: key, verdict: v.verdict }); return v; } // Per-clause cache: whatever clauses are already scored for this text // count; only the rest go to Jev. var have = {}; var need = []; SH.clauseScoreKeys(settings, p.text, p.alt).forEach(function (c) { if (cache.has(c.key)) have[c.score] = cache.get(c.key); else need.push(c.id); }); if (!need.length) { var verdict = SH.decideFromScores(settings, have) || "show"; verdictsById.set(p.id, { key: key, verdict: verdict }); stats.judged++; var r = { id: p.id, verdict: verdict }; var d = SH.roundScores(have); if (Object.keys(d).length) r.d = d; return r; } var existing = null; var lists = [queue, fresh]; for (var l = 0; l < lists.length && !existing; l++) { for (var i = 0; i < lists[l].length; i++) { if (lists[l][i].key === key) { existing = lists[l][i]; break; } } } if (existing) { if (existing.ids.indexOf(p.id) === -1) existing.ids.push(p.id); } else { fresh.push({ key: key, text: p.text || "", alt: p.alt || "", ids: [p.id], settings: settings, // snapshot for request build + response parse gen: gen, have: have, need: need, }); } return null; // pending}
function testKey(apiKey) { if (!apiKey) return Promise.resolve({ ok: false, error: "Enter an API key first." }); var probe = { state: { post: { text: "The kittens are playing in the garden again.", alt: "" } }, model: "jev-latest", questions: { ping: { type: "noul", instructions: "Does the `post` mention an animal?", }, }, }; return jevRequest(apiKey, probe).then( function (res) { var ok = !!(res && res.answers && res.answers.ping && typeof res.answers.ping.noul === "number"); return ok ? { ok: true, model: res.model || "jev-latest" } : { ok: false, error: "Response shape was not what sux expects." }; }, function (err) { return { ok: false, error: String((err && err.message) || err).slice(0, 300) }; } );}
/* ---------------- persistence ---------------- */
function trimCache() { while (cache.size > CACHE_CAP) { cache.delete(cache.keys().next().value); }}
// Tabs share the cache: merge what other tabs stored before writing ours back.function scheduleCacheWrite() { if (cacheWriteTimer) return; cacheWriteTimer = setTimeout(function () { cacheWriteTimer = null; var stored = loadValue("scores", null); if (stored && typeof stored === "object") { Object.keys(stored).forEach(function (k) { if (!cache.has(k)) cache.set(k, stored[k]); }); trimCache(); } var obj = {}; cache.forEach(function (v, k) { obj[k] = v; }); saveValue("scores", obj); }, 2000);}
function scheduleSettingsWrite() { if (settingsWriteTimer) clearTimeout(settingsWriteTimer); settingsWriteTimer = setTimeout(function () { settingsWriteTimer = null; saveValue("settings", settings); }, 300);}
function setSettings(next, persist) { next = SH.normalizeSettings(next); // A save that changes nothing (e.g. typing in an example box) must not // restart judging. if (JSON.stringify(next) === JSON.stringify(settings)) return; settings = next; if (persist) scheduleSettingsWrite(); // Everything queued was built for the old rules; the feed re-requests // what's on screen under the new generation. gen++; queue.length = 0; onSettings(); panelSettingsChanged(!persist);}
// Another tab saved settings: adopt them here too.try { GM_addValueChangeListener("settings", function (name, oldValue, newValue, remote) { if (remote && newValue) setSettings(newValue, false); });} catch (e) { /* manager without value listeners: tabs sync on reload */ }
/* ================= feed (was content.js) ================= */
var POSTS_CAP = 8000;var HIDDEN_ATTR = "data-sux-hidden";var HIDDEN_CLASS = "sux-hidden";var ACTION_SEL = '[aria-label^="Reply"], [aria-label^="Like"], [aria-label^="Repost"]';
/* ---- diagnostics (mirrored to page world) ---- */var diag = { domExtracted: 0, // posts extracted from the DOM items: 0, // feed item elements currently tracked hidden: 0, // of those, currently hidden unmatchedAnchors: 0, // feed items we could not map to a post path nested: 0, // nested (quote/reply-preview) links on tracked items scans: 0, settingsActive: false, near50: 0, near50Samples: "",};
function publishDiag() { try { // Primitives/strings pass the cross-world boundary; objects don't. if (typeof unsafeWindow !== "undefined") unsafeWindow.__suxDiagJSON = JSON.stringify(diag); } catch (e) { /* ignore */ }}
/* ---- our hiding style ---- *//* At document-start the <html> element may not exist yet; scan() retries. */var style = document.createElement("style");style.id = "sux-style";style.textContent = "." + HIDDEN_CLASS + "{display:none!important}";function ensureStyle() { if (!style.isConnected && document.documentElement) document.documentElement.appendChild(style);}ensureStyle();
/* ---- state ---- */var active = SH.isActive(settings);diag.settingsActive = active;
var posts = new Map(); // post path ("profile/<author>/post/<rkey>") -> {id, text, alt}var verdicts = new Map(); // post path -> "show" | "hide"var scores = new Map(); // post path -> {p0?, n0?, ...} judgment scores (telemetry)var elements = new Map(); // feed item element -> {main, nested: []}var askedGen = new Map(); // post path -> generation it was last requested undervar itemOf = new WeakMap(); // anchor -> its feed item (itemContainer is a DOM climb)
function indexPost(p) { if (!p || !p.id || posts.has(p.id)) return false; posts.set(p.id, p); if (posts.size > POSTS_CAP) { var old = posts.keys().next().value; posts.delete(old); askedGen.delete(old); } return true;}
// First usable score among enabled valid clauses: rule B nouls preferred,// then rule A probabilities. Detail keys are index-aligned with the clauses// (n0, n1, … / p0, p1, …), so a missing answer just falls through.function primaryScore(st, d) { var i, c; for (i = 0; i < st.rulesB.length; i++) { c = st.rulesB[i]; if (c.enabled && SH.clauseBValid(c) && typeof d["n" + i] === "number") return d["n" + i]; } for (i = 0; i < st.rulesA.length; i++) { c = st.rulesA[i]; if (c.enabled && SH.clauseAValid(c) && typeof d["p" + i] === "number") return d["p" + i]; } return null;}
/* ---- engine hand-off (was the Port to the background) ---- */
function requestVerdicts(list) { var items = []; var fresh = []; for (var i = 0; i < list.length; i++) { askedGen.set(list[i].id, gen); var r = resolvePost(list[i], fresh); if (r) items.push(r); } // Newest request first: it's what the user is looking at now. Each request // arrives nearest-the-viewport first. if (fresh.length) { Array.prototype.unshift.apply(queue, fresh); schedulePump(); } if (items.length) onVerdicts(items);}
function onSettings() { active = SH.isActive(settings); diag.settingsActive = active; // Old verdicts stay on screen until the new ones land — re-hiding // everything would blank the feed and refill it post by post. askedGen no // longer matches, so each post re-requests once. publishDiag(); reconcile();}
function onVerdicts(items) { items.forEach(function (it) { verdicts.set(it.id, it.verdict); if (it.d) { scores.set(it.id, it.d); if (scores.size > 1000) scores.delete(scores.keys().next().value); } }); applyVerdicts();}
/* ---- DOM mapping ---- */
// "/profile/<author>/post/<rkey>" from either relative or absolute hrefs, else null.function normalizePostPath(href) { if (!href || typeof href !== "string") return null; var h = href.replace(/^https?:\/\/[^/]+/i, "").split("?")[0].split("#")[0]; var m = h.match(/^\/profile\/([^/]+)\/post\/([^/]+)\/?$/); if (!m) return null; return "profile/" + m[1].toLowerCase() + "/post/" + m[2];}
function normalizeProfilePath(href) { if (!href || typeof href !== "string") return null; var h = href.replace(/^https?:\/\/[^/]+/i, "").split("?")[0].split("#")[0]; var m = h.match(/^\/profile\/([^/]+)\/?$/); return m ? m[1].toLowerCase() : null;}
// A feed item is the ancestor whose parent holds at least two action-bar-// bearing children (sibling items in the virtualized list). Quote embeds and// reply previews have no action bar of their own, so every anchor in the item// — main or nested — climbs to the same wrapper.function itemContainer(anchor) { var node = anchor.parentElement; for (var depth = 0; node && node !== document.body && depth < 14; depth++) { var parent = node.parentElement; if (parent) { var kidsWithBars = 0; for (var i = 0; i < parent.children.length; i++) { if (parent.children[i].querySelector(ACTION_SEL)) kidsWithBars++; } if (kidsWithBars >= 2) return node; } node = parent; } return null;}
// Smallest block holding this anchor but no sibling post anchor — for a// nested (quote / reply-preview) anchor that is the embed box itself.function nestedContainer(anchor) { var node = anchor.parentElement; for (var depth = 0; node && node !== document.body && depth < 12; depth++) { var parent = node.parentElement; if (parent) { var siblings = parent.querySelectorAll('a[href*="/post/"]'); for (var i = 0; i < siblings.length; i++) { if (!node.contains(siblings[i])) return node; } } node = parent; } return anchor.parentElement;}
function depthWithin(root, el) { var d = 0; while (el && el !== root) { d++; el = el.parentElement; } return d;}
// Which post does this item represent? The main post's permalink is the anchor// whose author matches the item's dominant profile links; ties break on// shallowest depth, then document order.function chooseMainAnchor(item, anchors) { var counts = new Map(); var profileLinks = item.querySelectorAll('a[href*="/profile/"]'); for (var i = 0; i < profileLinks.length; i++) { var pp = normalizeProfilePath(profileLinks[i].getAttribute("href")); if (pp) counts.set(pp, (counts.get(pp) || 0) + 1); } var dominant = null; var best = 0; counts.forEach(function (n, author) { if (n > best) { best = n; dominant = author; } }); var fallback = null; var bestMatch = null; for (var j = 0; j < anchors.length; j++) { var a = anchors[j]; var path = normalizePostPath(a.getAttribute("href")); if (!path) continue; var rec = { a: a, depth: depthWithin(item, a), order: j }; if (!fallback || rec.depth < fallback.depth) fallback = rec; if (dominant && path.slice("profile/".length).split("/")[0] === dominant) { if (!bestMatch || rec.depth < bestMatch.depth) bestMatch = rec; } } var winner = bestMatch || fallback; return winner ? winner.a : null;}
/* ---- DOM text extraction ---- */
var ALT_CAP = 4000;
function itemAlts(item) { var out = []; var imgs = item.querySelectorAll("img"); for (var i = 0; i < imgs.length; i++) { var a = imgs[i].alt; if (a && a.trim()) out.push(a.trim()); } return out.join("\n").slice(0, ALT_CAP);}
// Header lines (repost credit, display name, handle, "· 19h") and the// trailing action-bar counts are noise for Jev; keep the post's own words.function cleanItemText(item) { var raw = item.innerText || ""; if (!raw.trim()) return ""; var lines = raw.split("\n"); var cut = -1; for (var i = 0; i < lines.length && i < 14; i++) { if (/^\s*·\s/.test(lines[i])) { cut = i; break; } } var body = cut >= 0 ? lines.slice(cut + 1) : lines; body = body.filter(function (l) { return !/^\s*reposted by\b/i.test(l); }); while (body.length) { var last = body[body.length - 1].trim(); if (last === "" || /^\d+([.,]\d+)?\s*[kKmM]?$/.test(last)) { body.pop(); } else break; } return body.join("\n").trim().slice(0, SH.TEXT_CAP);}
/* ---- hiding ---- */
function setHidden(el, hide) { if (el.classList.contains(HIDDEN_CLASS) === hide) return; if (hide) { el.setAttribute(HIDDEN_ATTR, "1"); el.classList.add(HIDDEN_CLASS); } else { el.removeAttribute(HIDDEN_ATTR); el.classList.remove(HIDDEN_CLASS); }}
// An item hides when its own post is not "show" (hide-until-judged), or when// any post it quotes/previews has an explicit "hide" verdict — no husks.function applyFor(item, rec) { if (!active) { setHidden(item, false); return; } var hide = verdicts.get(rec.main) !== "show"; if (!hide && rec.nested) { for (var i = 0; i < rec.nested.length; i++) { if (verdicts.get(rec.nested[i]) === "hide") { hide = true; break; } } } setHidden(item, hide);}
function applyVerdicts() { if (!active) return; elements.forEach(function (rec, el) { if (el.isConnected) applyFor(el, rec); });}
function reconcile() { if (!active) { elements.forEach(function (rec, el) { setHidden(el, false); }); return; } requestVerdicts(onScreenPosts()); elements.forEach(function (rec, el) { if (el.isConnected) applyFor(el, rec); }); scheduleScan();}
// Posts of tracked items still in the DOM and not yet asked about under the// current generation, nearest the viewport first — judged in that order.function onScreenPosts() { var mid = window.innerHeight / 2; var list = []; elements.forEach(function (rec, el) { if (!el.isConnected) return; var r = el.getBoundingClientRect(); var dist = Math.abs((r.top + r.bottom) / 2 - mid); [rec.main].concat(rec.nested || []).forEach(function (path) { var p = posts.get(path); if (p && askedGen.get(path) !== gen) list.push({ p: p, dist: dist }); }); }); list.sort(function (a, b) { return a.dist - b.dist; }); var seen = new Set(); return list.filter(function (x) { if (seen.has(x.p.id)) return false; seen.add(x.p.id); return true; }).map(function (x) { return x.p; });}
/* ---- scanning ---- */
var scanScheduled = false;function scheduleScan() { if (scanScheduled) return; scanScheduled = true; // Next frame, before it paints: a freshly rendered post is hidden before it // is ever shown, and a burst of mutations costs one scan. requestAnimationFrame(function () { scanScheduled = false; scan(); });}
function scan() { diag.scans++; ensureStyle(); elements.forEach(function (rec, el) { if (!el.isConnected) elements.delete(el); });
var anchors = document.querySelectorAll('a[href*="/post/"]'); var byItem = new Map(); for (var i = 0; i < anchors.length; i++) { if (!normalizePostPath(anchors[i].getAttribute("href"))) continue; // Cache only a sibling-confirmed item; the structural fallback can be // wrong while the list holds a single item, so it's recomputed. var item = itemOf.get(anchors[i]); if (!item || !item.isConnected || !item.contains(anchors[i])) { item = itemContainer(anchors[i]); if (item) itemOf.set(anchors[i], item); else item = nestedContainer(anchors[i]); // fallback: structural heuristic } if (!byItem.has(item)) byItem.set(item, []); byItem.get(item).push(anchors[i]); }
var unmatched = 0; var fresh = []; var nestedCount = 0; byItem.forEach(function (list, item) { var main = chooseMainAnchor(item, list); if (!main) { unmatched++; return; } var mainPath = normalizePostPath(main.getAttribute("href")); if (!mainPath) { unmatched++; return; } if (!posts.has(mainPath)) { var mp = { id: mainPath, text: cleanItemText(item), alt: itemAlts(item) }; if (indexPost(mp)) { diag.domExtracted++; fresh.push(mp); } } else if (askedGen.get(mainPath) !== gen) { fresh.push(posts.get(mainPath)); } var nested = []; for (var j = 0; j < list.length; j++) { var a = list[j]; if (a === main) continue; var np = normalizePostPath(a.getAttribute("href")); if (!np || np === mainPath || nested.indexOf(np) !== -1) continue; nested.push(np); if (!posts.has(np)) { var box = nestedContainer(a); var post = { id: np, text: cleanItemText(box), alt: itemAlts(box) }; if (indexPost(post)) { diag.domExtracted++; fresh.push(post); } } else if (askedGen.get(np) !== gen) { fresh.push(posts.get(np)); } } nestedCount += nested.length; var rec = { main: mainPath, nested: nested.length ? nested : null }; elements.set(item, rec); applyFor(item, rec); }); diag.items = elements.size; diag.hidden = countHidden(); diag.unmatchedAnchors = unmatched; diag.nested = nestedCount; // Near-threshold telemetry: which tracked posts sit close to the 50% line, // under the currently active clauses' scores. var near = 0; var samples = []; elements.forEach(function (rec) { var d = scores.get(rec.main); if (!d) return; var s = primaryScore(settings, d); if (typeof s !== "number") return; if (Math.abs(s - 0.5) <= 0.08) { near++; if (samples.length < 6) samples.push(rec.main.slice(-16) + "=" + s); } }); diag.near50 = near; diag.near50Samples = samples.join(", "); publishDiag(); if (fresh.length) { requestVerdicts(fresh.filter(function (p, i) { return fresh.indexOf(p) === i; })); }}
function countHidden() { var hidden = 0; elements.forEach(function (rec, el) { if (el.isConnected && el.classList.contains(HIDDEN_CLASS)) hidden++; }); return hidden;}
var observer = new MutationObserver(scheduleScan);observer.observe(document, { childList: true, subtree: true });
/* ================= panel (was sidebar.*) ================= */
var PANEL_CSS = __SUX_PANEL_CSS__;var PANEL_HTML = __SUX_PANEL_HTML__;
var fields = null;var panelSaveTimer = null;
function mountPanel() { var host = document.createElement("div"); host.id = "sux-panel-host"; var root = host.attachShadow({ mode: "closed" }); var css = document.createElement("style"); css.textContent = PANEL_CSS; root.appendChild(css); var wrap = document.createElement("div"); wrap.innerHTML = PANEL_HTML; while (wrap.firstChild) root.appendChild(wrap.firstChild);
// bsky.app has global keyboard shortcuts; typing in the panel must not trigger them. ["keydown", "keyup", "keypress"].forEach(function (type) { host.addEventListener(type, function (ev) { ev.stopPropagation(); }); });
function el(id) { return root.getElementById(id); } fields = { toggle: el("toggle"), panel: el("panel"), close: el("close"), enabled: el("enabled"), rowsA: el("rowsA"), rowsB: el("rowsB"), rowsE: el("rowsE"), addA: el("addA"), addB: el("addB"), addE: el("addE"), ruleAHint: el("ruleAHint"), ruleBHint: el("ruleBHint"), ruleEHint: el("ruleEHint"), combine: el("combine"), apiKey: el("apiKey"), testKey: el("testKey"), testResult: el("testResult"), stJudged: el("stJudged"), stHidden: el("stHidden"), stWaiting: el("stWaiting"), diag: el("diag"), lastError: el("lastError"), };
fields.toggle.addEventListener("click", function () { setPanelOpen(fields.panel.hidden); }); fields.close.addEventListener("click", function () { setPanelOpen(false); });
["enabled", "combine"].forEach(function (id) { fields[id].addEventListener("change", saveSoon); }); fields.addA.addEventListener("click", function () { addClause("A"); }); fields.addB.addEventListener("click", function () { addClause("B"); }); fields.addE.addEventListener("click", function () { addClause("E"); }); wireClauseContainer(fields.rowsA, "A"); wireClauseContainer(fields.rowsB, "B"); wireClauseContainer(fields.rowsE, "E"); fields.apiKey.addEventListener("input", function () { fields.testResult.textContent = ""; fields.testResult.classList.remove("ok"); saveSoon(); });
fields.testKey.addEventListener("click", function () { var key = fields.apiKey.value.trim(); fields.testResult.classList.remove("ok"); if (!key) { fields.testResult.textContent = "Enter an API key first."; return; } fields.testKey.disabled = true; fields.testResult.textContent = "Testing…"; testKey(key) .then(function (res) { if (res && res.ok) { fields.testResult.textContent = "Connected (" + (res.model || "jev") + ")."; fields.testResult.classList.add("ok"); } else { fields.testResult.textContent = (res && res.error) || "Test failed."; } }) .then(function () { fields.testKey.disabled = false; }); });
writeForm(settings); setPanelOpen(!!loadValue("panelOpen", false)); (document.body || document.documentElement).appendChild(host);
setInterval(refreshStats, 1000); refreshStats();}
function setPanelOpen(open) { if (!fields) return; fields.panel.hidden = !open; saveValue("panelOpen", !!open);}
/* ---- clause rows (any number per rule type) ---- */
function toggleBox(checked) { var on = document.createElement("input"); on.type = "checkbox"; on.className = "on"; on.title = "Enable this clause"; on.checked = checked; return on;}
function wordInput(cls, placeholder, value) { var inp = document.createElement("input"); inp.type = "text"; inp.className = cls; inp.placeholder = placeholder; inp.autocomplete = "off"; inp.value = value; return inp;}
function removeBtn() { var btn = document.createElement("button"); btn.type = "button"; btn.className = "rm"; btn.title = "Remove this clause"; btn.textContent = "−"; return btn;}
function buildRowA(c, removable) { var row = document.createElement("div"); row.className = "clause" + (c.enabled ? "" : " off"); row.appendChild(toggleBox(c.enabled)); row.appendChild(wordInput("w1", "word 1", c.word1)); var vs = document.createElement("span"); vs.className = "vs"; vs.textContent = "vs"; row.appendChild(vs); row.appendChild(wordInput("w2", "word 2", c.word2)); if (removable) row.appendChild(removeBtn()); return row;}
function buildRowB(c, removable) { var row = document.createElement("div"); row.className = "clause" + (c.enabled ? "" : " off"); row.appendChild(toggleBox(c.enabled)); var dir = document.createElement("select"); dir.className = "dir"; dir.innerHTML = '<option value="above">are mostly</option>' + '<option value="below">are NOT</option>'; dir.value = c.above ? "above" : "below"; row.appendChild(dir); row.appendChild(wordInput("w", "word", c.word)); if (removable) row.appendChild(removeBtn()); return row;}
// Example rule (same shape as the extension's sidebar): a name, example// chips, paste-to-add. Strictness/cutoff/badge are extension-only (they need// the local model); the userscript judges example rules through Jev, so the// stored fields just round-trip via row._clause.function buildRowE(c, removable) { var row = document.createElement("div"); row.className = "clause e" + (c.enabled ? "" : " off"); row._clause = c; var head = document.createElement("div"); head.className = "ehead"; head.appendChild(toggleBox(c.enabled)); head.appendChild(wordInput("name", "rule name (optional)", c.name)); if (removable) head.appendChild(removeBtn()); row.appendChild(head);
var chips = document.createElement("div"); chips.className = "examples"; c.examples.forEach(function (x) { chips.appendChild(exampleChip(x)); }); row.appendChild(chips);
var addRow = document.createElement("div"); addRow.className = "eadd"; var inp = document.createElement("input"); inp.type = "text"; inp.className = "exin"; inp.placeholder = "paste a post you don't want to see"; inp.autocomplete = "off"; var addBtn = document.createElement("button"); addBtn.type = "button"; addBtn.className = "exadd"; addBtn.title = "Add as example"; addBtn.textContent = "+"; addRow.appendChild(inp); addRow.appendChild(addBtn); row.appendChild(addRow); return row;}
function exampleChip(x) { var chip = document.createElement("span"); chip.className = "chip"; var label = document.createElement("span"); label.className = "chiptext"; label.textContent = (x.text || x.alt || "").trim().slice(0, 60); chip.appendChild(label); var rm = document.createElement("button"); rm.type = "button"; rm.className = "chiprm"; rm.title = "Remove this example"; rm.textContent = "×"; chip.appendChild(rm); return chip;}
function renderRows(container, type, clauses) { container.textContent = ""; var removable = clauses.length > 1; for (var i = 0; i < clauses.length; i++) { container.appendChild( type === "A" ? buildRowA(clauses[i], removable) : type === "B" ? buildRowB(clauses[i], removable) : buildRowE(clauses[i], removable)); }}
function renderClauses(st) { renderRows(fields.rowsA, "A", st.rulesA); renderRows(fields.rowsB, "B", st.rulesB); renderRows(fields.rowsE, "E", st.rulesE);}
function readRows(container, type) { var out = []; var rows = container.children; for (var i = 0; i < rows.length; i++) { var r = rows[i]; if (type === "A") { out.push({ enabled: r.querySelector(".on").checked, word1: r.querySelector(".w1").value, word2: r.querySelector(".w2").value, }); } else if (type === "B") { out.push({ enabled: r.querySelector(".on").checked, word: r.querySelector(".w").value, above: r.querySelector(".dir").value !== "below", }); } else { // Preserve stored fields the panel doesn't edit (cutoff, strictness, // embeddings) — row._clause is the object renderClauses received. var prev = r._clause || { examples: [] }; var examples = []; var chips = r.querySelectorAll(".chip"); for (var j = 0; j < chips.length; j++) { var ex = prev.examples[j]; if (ex) examples.push({ text: ex.text, alt: ex.alt, emb: ex.emb }); } out.push({ enabled: r.querySelector(".on").checked, name: r.querySelector(".name").value, examples: examples, cutoff: prev.cutoff, strictness: prev.strictness, counters: prev.counters, }); } } return out;}
function addClause(type) { var form = readForm(); var clauses = type === "A" ? form.rulesA : type === "B" ? form.rulesB : form.rulesE; clauses.push(type === "A" ? { enabled: true, word1: "", word2: "" } : type === "B" ? { enabled: true, word: "", above: true } : { enabled: true, name: "", examples: [] }); renderRows( type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE, type, clauses); saveSoon(); var container = type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE; var focusTarget = container.lastElementChild && container.lastElementChild.querySelector("input.w1, input.w, input.name, select.dir"); if (focusTarget) focusTarget.focus();}
function wireClauseContainer(container, type) { container.addEventListener("input", saveSoon); container.addEventListener("change", function (ev) { if (ev.target.classList.contains("on")) { ev.target.closest(".clause").classList.toggle("off", !ev.target.checked); } saveSoon(); }); container.addEventListener("click", function (ev) { var row = ev.target.closest ? ev.target.closest(".clause") : null; if (!row) return; if (ev.target.closest(".exadd")) { var inp = row.querySelector(".exin"); var text = inp.value.trim(); if (!text) return; row._clause = row._clause || { examples: [] }; row._clause.examples.push({ text: text.slice(0, SH.TEXT_CAP), alt: "" }); inp.value = ""; rerender(row, container, type); saveSoon(); return; } if (ev.target.closest(".chiprm")) { var chip = ev.target.closest(".chip"); var chips = chip.parentElement; var idx = Array.prototype.indexOf.call(chips.children, chip); if (idx !== -1) row._clause.examples.splice(idx, 1); rerender(row, container, type); saveSoon(); return; } var btn = ev.target.closest ? ev.target.closest(".rm") : null; if (!btn) return; var form = readForm(); var clauses = type === "A" ? form.rulesA : type === "B" ? form.rulesB : form.rulesE; var idx = Array.prototype.indexOf.call(container.children, row); if (idx !== -1) clauses.splice(idx, 1); renderRows( type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE, type, clauses); saveSoon(); }); container.addEventListener("keydown", function (ev) { if (ev.key !== "Enter" || !ev.target.classList.contains("exin")) return; ev.preventDefault(); var row = ev.target.closest(".clause"); var btn = row && row.querySelector(".exadd"); if (btn) btn.click(); });}
function rerender(row, container, type) { var idx = Array.prototype.indexOf.call(container.children, row); if (idx === -1 || type !== "E") return; var fresh = buildRowE(row._clause, container.children.length > 1); container.replaceChild(fresh, row); var inp = fresh.querySelector(".exin"); if (inp) inp.focus();}
function readForm() { return { enabled: fields.enabled.checked, apiKey: fields.apiKey.value.trim(), combine: fields.combine.value === "or" ? "or" : "and", // Fields the userscript panel doesn't edit (peek, mode) round-trip from // the live settings so a save never silently drops them. peek: settings.peek, mode: settings.mode, rulesA: readRows(fields.rowsA, "A"), rulesB: readRows(fields.rowsB, "B"), rulesE: readRows(fields.rowsE, "E"), };}
function writeForm(s) { fields.enabled.checked = s.enabled; renderClauses(s); fields.combine.value = s.combine; fields.apiKey.value = s.apiKey; validate();}
function clauseHint(clauses, isValid, msg) { var problems = []; for (var i = 0; i < clauses.length; i++) { if (clauses[i].enabled && !isValid(clauses[i])) { problems.push(clauses.length > 1 ? "clause " + (i + 1) + ": " + msg : msg); } } return problems.join("; ");}
function validate() { var st = SH.normalizeSettings(readForm()); fields.ruleAHint.textContent = clauseHint(st.rulesA, SH.clauseAValid, "needs two different words"); fields.ruleBHint.textContent = clauseHint(st.rulesB, SH.clauseBValid, "needs a word"); var eProblems = []; for (var i = 0; i < st.rulesE.length; i++) { if (st.rulesE[i].enabled && !SH.clauseEValid(st.rulesE[i])) { eProblems.push(st.rulesE.length > 1 ? "rule " + (i + 1) + ": add at least one example" : "add at least one example"); } } fields.ruleEHint.textContent = eProblems.join("; "); return st;}
// Every save re-judges the feed under the new rules, so typing waits for a// real pause; clicks and toggles save quickly.function saveSoon(ev) { validate(); if (panelSaveTimer) clearTimeout(panelSaveTimer); var typing = ev && ev.type === "input" && ev.target && ev.target.type === "text"; panelSaveTimer = setTimeout(function () { panelSaveTimer = null; setSettings(readForm(), true); }, typing ? 800 : 250);}
// fromElsewhere: settings arrived from another tab, so the form must follow.// Local edits are left alone — rewriting the form would clobber the caret.function panelSettingsChanged(fromElsewhere) { if (!fields) return; if (fromElsewhere) writeForm(settings); refreshStats();}
function refreshStats() { if (!fields) return; fields.toggle.classList.toggle("on", active); fields.stJudged.textContent = stats.judged; diag.hidden = countHidden(); fields.stHidden.textContent = diag.hidden; fields.stWaiting.textContent = queue.length + running; fields.lastError.textContent = stats.lastError || ""; fields.diag.textContent = "feed: extracted " + diag.domExtracted + " · items " + diag.items + " · hidden " + diag.hidden + " · unmatched " + diag.unmatchedAnchors + " · active " + (diag.settingsActive ? "yes" : "no");}
try { GM_registerMenuCommand("sux settings", function () { setPanelOpen(true); });} catch (e) { /* menu commands unsupported */ }
if (document.body) { mountPanel();} else { document.addEventListener("DOMContentLoaded", mountPanel, { once: true });}