Like/view ratio badges (0–200 score, rarity tiers) + read-ahead pagination on jable.tv video cards. ⚙ settings apply live.
// ==UserScript==
// @name Jable-Tools
// @namespace https://greasyfork.org/scripts/599011
// @version 0.4.1
// @description Like/view ratio badges (0–200 score, rarity tiers) + read-ahead pagination on jable.tv video cards. ⚙ settings apply live.
// @author vedesh137
// @match https://jable.tv/*
// @match https://www.jable.tv/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
/* ================= defaults: ratio-config.js (shared) ================= */
/**
* Jable-Tools — defaults for the like/view score and rarity tiers.
*
* score = (likes / views) × scoreScale, capped at scoreMax
* (default: 1% like rate → 100 points, 2% → the 200 cap, 0 likes → 0).
*
* Calibrated on ~240 cards sampled from 10 pages of /tags/fishnets/
* (2025-07): median like rate ≈ 0.77% (score ≈ 77), p90 ≈ 1.27%
* (score ≈ 127), max ≈ 2.03% (hits the cap).
*
* Tier order = precedence: a card belongs to the first tier whose `max`
* exceeds its score; the last tier catches everything else (use
* `max: Infinity` or leave it empty in the popup).
*
* These are the factory defaults — the user changes everything in the
* extension popup (persisted to chrome.storage.local).
*/
window.JT_DEFAULTS = {
enabled: true,
scoreScale: 10000,
scoreMax: 200,
// Listing pages fetched and appended beyond the current one (0 = off).
pagesAhead: 0,
tiers: [
// common 0 – 49.9 (≈25% of the sample)
{ name: "common", color: "#9ca3af", max: 50, visible: true },
// uncommon 50 – 99.9 (≈42% of the sample)
{ name: "uncommon", color: "#22c55e", max: 100, visible: true },
// rare 100 – 139.9 (≈25% of the sample)
{ name: "rare", color: "#3b82f6", max: 140, visible: true },
// epic 140 – 174.9 (≈4% of the sample)
{ name: "epic", color: "#a855f7", max: 175, visible: true },
// legendary 175 – 200 (≈4% of the sample)
{ name: "legendary", color: "#f59e0b", max: Infinity, visible: true },
],
};
/* ================= shared core: common.js (shared) ================= */
/**
* Jable-Tools — shared helpers, used by the content script and the popup.
* Load after ratio-config.js (needs window.JT_DEFAULTS).
*/
(() => {
"use strict";
const D = window.JT_DEFAULTS;
/** Max "read ahead pages" accepted from the settings UI. */
const AHEAD_MAX = 9;
/**
* Normalizes a stored config (or null) into a safe shape:
* { enabled, tiers: [{ name, color, max, visible }], pagesAhead }
* `max` null / empty / NaN → Infinity (catch-all tier).
*/
function normalize(raw) {
const tiers =
raw && Array.isArray(raw.tiers) && raw.tiers.length
? raw.tiers.map((t) => {
const n = Number(t.max);
return {
name: t.name != null ? String(t.name) : "tier",
color: t.color != null ? String(t.color) : "#9ca3af",
max:
t.max === null || t.max === undefined || t.max === "" || !Number.isFinite(n)
? Infinity
: n,
visible: t.visible !== false,
};
})
: D.tiers.map((t) => ({ ...t }));
const pagesAhead = raw ? Number(raw.pagesAhead) : NaN;
return {
enabled: raw ? raw.enabled !== false : D.enabled,
tiers,
pagesAhead: Number.isFinite(pagesAhead)
? Math.min(AHEAD_MAX, Math.max(0, Math.floor(pagesAhead)))
: D.pagesAhead,
};
}
/** score(views, likes) on the 0..scoreMax scale. */
function score(views, likes) {
if (views > 0) return Math.min(D.scoreMax, (likes / views) * D.scoreScale);
return likes > 0 ? D.scoreMax : 0;
}
/** First tier whose max exceeds the score; last tier catches the rest. */
function tierFor(score, tiers) {
for (const tier of tiers) if (score < tier.max) return tier;
return tiers[tiers.length - 1] || { name: "none", color: "#9ca3af", visible: true };
}
function formatScore(score) {
return score.toFixed(1);
}
/**
* Builds the settings tier rows into `container` (shared by the extension
* popup and the userscript floating panel). `persist` is called after any
* value that must be saved (name changes included).
*/
function mountTierRows(container, config, persist) {
container.textContent = "";
config.tiers.forEach((tier) => {
const row = document.createElement("label");
row.className = "tier-row";
const visible = document.createElement("input");
visible.type = "checkbox";
visible.checked = tier.visible;
visible.title = "Show videos of this tier";
visible.addEventListener("change", () => {
tier.visible = visible.checked;
persist();
});
const name = document.createElement("input");
name.type = "text";
name.value = tier.name;
name.title = "Tier name";
name.addEventListener("change", () => {
tier.name = name.value || "tier";
persist();
});
const color = document.createElement("input");
color.type = "color";
color.value = tier.color;
color.title = "Badge color";
color.addEventListener("input", () => {
tier.color = color.value;
persist();
});
const max = document.createElement("input");
max.type = "number";
max.min = "0";
max.step = "5";
max.placeholder = "∞";
max.value = Number.isFinite(tier.max) ? String(tier.max) : "";
max.title = "This tier applies to scores below this value";
max.addEventListener("change", () => {
const n = Number(max.value);
tier.max = max.value === "" || !Number.isFinite(n) ? Infinity : n;
persist();
});
row.append(visible, name, color, max);
container.appendChild(row);
});
}
/** Converts an in-memory config into something storage can persist
* (JSON has no Infinity). */
function forStorage(config) {
return {
enabled: config.enabled,
pagesAhead: config.pagesAhead,
tiers: config.tiers.map((t) => ({
...t,
max: Number.isFinite(t.max) ? t.max : null,
})),
};
}
window.JT = {
STORE_KEY: "jableTools",
normalize,
score,
tierFor,
formatScore,
forStorage,
mountTierRows,
};
})();
/* ================= host adapter: userscript/src/host.js ================= */
/**
* Jable-Tools — storage host for the userscript.
*
* Backs `window.JT_HOST` (same API as the extension's host-extension.js)
* with Tampermonkey's GM values when available, localStorage otherwise.
* A light 2s poll keeps other tabs/pages in sync — userscripts have no
* cross-page storage event.
*/
(() => {
"use strict";
const KEY = window.JT.STORE_KEY;
const useGM = typeof GM_setValue === "function" && typeof GM_getValue === "function";
const listeners = new Set();
let lastRawText = null;
function readText() {
let text = null;
if (useGM) {
text = GM_getValue(KEY, null);
} else {
try {
text = localStorage.getItem(KEY);
} catch {
text = null;
}
}
return text;
}
function readRaw() {
const text = readText();
if (text == null) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
window.JT_HOST = {
async load() {
return readRaw();
},
save(raw) {
const text = JSON.stringify(raw);
if (useGM) GM_setValue(KEY, text);
else {
try {
localStorage.setItem(KEY, text);
} catch {
/* storage full / blocked — in-memory only */
}
}
lastRawText = text;
emit(raw);
},
onChange(fn) {
listeners.add(fn);
},
};
function emit(raw) {
for (const fn of listeners) fn(raw);
}
// Cross-tab sync: pick up config written by another tab/page.
setInterval(() => {
const text = readText();
if (text === lastRawText) return;
lastRawText = text;
let raw = null;
if (text != null) {
try {
raw = JSON.parse(text);
} catch {
raw = null;
}
}
if (raw !== null) emit(raw);
}, 2000);
})();
/* ================= content core: content.js (shared) ================= */
/**
* Jable-Tools content script.
*
* On every jable.tv browse page (home, latest updates, hot, search,
* categories, tags) each video card carries its stats in
* `<p class="sub-title">`:
*
* <svg><use xlink:href="#icon-eye"></use></svg>28 330
* <svg><use xlink:href="#icon-heart-inline"></use></svg>210
*
* (numbers are space-grouped, e.g. "28 330" = 28330)
*
* For every card we:
* 1. parse views and likes,
* 2. compute the normalized score = (likes / views) × scale, capped
* (0–200; see ratio-config.js),
* 3. pick the rarity tier from the live config,
* 4. log it to the DevTools console (once per video),
* 5. append a colored badge (icon + score) to the card,
* 6. hide the card if the tier is switched off in the popup.
*
* Read ahead: with "read ahead pages" > 0 in the settings, the next N
* listing pages are fetched and their cards appended to the grid, so a
* filtered page (e.g. only epic + legendary) still shows the matches from
* the pages ahead.
*
* The config is persisted through `window.JT_HOST` (chrome.storage.local in
* the extension, GM values / localStorage in the userscript), edited from
* the extension popup or the userscript's floating ⚙ panel; the host's
* `onChange` re-applies it live, and a MutationObserver picks up cards
* added later (pagination, search, re-renders).
*/
(() => {
"use strict";
if (window.__jtBootstrapped) return;
window.__jtBootstrapped = true;
/** subTitle → { views, likes, badge, cardRoot } (pruned on each pass) */
const cards = new Map();
const loggedKeys = new Set();
let config = window.JT.normalize(null);
let scanQueued = false;
/** Read-ahead pages, in order: { url, cells, nextUrl }. */
const ahead = [];
let aheadToken = 0;
const AHEAD_MAX = 9;
/** "28 330" / "28,330" / "28330" → 28330; null when not a number. */
function parseCount(text) {
const cleaned = (text || "").replace(/[\s,]/g, "");
if (!cleaned) return null;
const value = Number(cleaned);
return Number.isFinite(value) ? value : null;
}
/** Reads views/likes from a card's sub-title. Missing parts are null. */
function parseStats(subTitle) {
let metric = null;
let views = null;
let likes = null;
for (const node of subTitle.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const value = parseCount(node.textContent);
if (value === null || metric === null) continue;
if (metric === "views") views = views === null ? value : views + value;
else likes = likes === null ? value : likes + value;
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const use = node.querySelector("use");
const href = use
? use.getAttribute("xlink:href") || use.getAttribute("href") || ""
: "";
if (href.includes("icon-eye")) metric = "views";
else if (href.includes("heart")) metric = "likes";
else metric = null;
}
return { views, likes };
}
/**
* The element to hide/show: the grid cell the card sits in, so the
* flex row reflows and the remaining cards flow sequentially.
* Falls back through carousel wrappers to the card itself.
*/
function cardRoot(subTitle) {
const card = subTitle.closest(".video-img-box");
if (!card) return null;
return (
card.closest(".owl-item") ||
card.closest('[class*="col-"]') ||
card.closest(".item") ||
card
);
}
function cardTitle(subTitle) {
const detail = subTitle.closest(".detail");
const title = detail ? detail.querySelector(".title") : null;
return title && title.textContent.trim() ? title.textContent.trim() : "unknown video";
}
/** Grid row holding the video cells; null on non-listing pages. */
function liveRow() {
const subTitle = document.querySelector("p.sub-title");
const cell = subTitle ? subTitle.closest('[class*="col-"]') : null;
return cell ? cell.parentElement : null;
}
/**
* Page number of a pagination element (`a.page-link` / the active span),
* null when it can't be told. Prefers the `data-jt-page` marker set by
* `syncPagination()` (visible labels are screen labels, not pages);
* an anchor falls back to its href (trailing path number, `?page=`,
* bare listing URL = 1); the active span (no href) to its number text.
*/
function pageOf(el) {
if (!el) return null;
if (el.dataset && el.dataset.jtPage) return Number(el.dataset.jtPage);
if (el.tagName === "A") {
const href = el.getAttribute("href");
if (href) {
try {
const u = new URL(href, location.origin);
const segs = u.pathname.split("/").filter(Boolean);
const seg = segs[segs.length - 1];
if (/^\d+$/.test(seg)) return Number(seg);
const q = Number.parseInt(u.searchParams.get("page") || "", 10);
if (Number.isFinite(q) && q > 0) return q;
return 1; // bare listing URL = page 1
} catch {
return null;
}
}
}
const n = Number.parseInt((el.textContent || "").trim(), 10);
return Number.isFinite(n) ? n : null;
}
/**
* URL of the page after `scope`'s current one. Uses the learned URL
* pattern (`learnPagination`) when available — that also covers the
* reshaped pagination, whose buttons are no longer plain page links;
* otherwise falls back to reading the site's stock links' hrefs.
* Null when the pagination is missing or this is the last page.
*/
function nextPageUrl(scope) {
const pagination = scope ? scope.querySelector("ul.pagination") : null;
if (!pagination) return null;
const current = pageOf(pagination.querySelector(".page-link.active"));
if (current === null) return null;
if (pgPattern && pgPattern.total) {
if (current >= pgPattern.total) return null;
return urlForPage(current + 1);
}
const links = [...pagination.querySelectorAll("a.page-link")];
const next = links.find((l) => pageOf(l) === current + 1);
const nextHref = next ? next.getAttribute("href") : null;
if (nextHref) return new URL(nextHref, location.origin).href;
// current+1 is outside the shown window (active is the last shown page
// but not the site's last page): build it by swapping the page number
// in the "last" link's URL.
const last = links[links.length - 1];
const lastHref = last ? last.getAttribute("href") : null;
const lastNum = last ? pageOf(last) : null;
if (lastHref && lastNum !== null && lastNum > current) {
const qIdx = lastHref.indexOf("?");
const pathPart = qIdx === -1 ? lastHref : lastHref.slice(0, qIdx);
const queryPart = qIdx === -1 ? "" : lastHref.slice(qIdx);
const nextPath = pathPart.replace(
/(\d+)(\/?)$/, String(current + 1) + (pathPart.endsWith("/") ? "/" : "")
);
return new URL(nextPath + queryPart, location.origin).href;
}
return null;
}
/**
* The `sort_by` value the site's pagination is currently using (the
* stock links' data-parameters always reflect the active sort — the site
* rewrites them when the user changes it), null when it can't be told.
*/
function sortOf(pagination) {
if (!pagination) return null;
for (const a of pagination.querySelectorAll("a.page-link")) {
if (a.dataset.jtOurs) continue;
const m = /sort_by:([\w]+)/.exec(a.getAttribute("data-parameters") || "");
if (m) return m[1];
}
return null;
}
// URL pattern of the listing's pagination, learned from the site's own
// (stock) links before they are replaced: { firstUrl (page 1 href),
// total (last page), prefix/tail (page N = prefix + N + tail), template
// (a stock numeric <a>, so injected links keep the site's attributes —
// e.g. data-action="ajax" — and trigger the site's own handler) }.
let pgPattern = null;
function learnPagination(pagination) {
let firstUrl = null;
let total = null;
let prefix = null;
let tail = null;
let template = null;
let templateParams = null;
let sort = null;
for (const a of pagination.querySelectorAll("a.page-link")) {
if (a.dataset.jtOurs) continue; // our own injected links
const p = pageOf(a);
if (p === null) continue;
const href = a.getAttribute("href") || "";
if (p === 1 && !firstUrl) firstUrl = href;
if (total === null || p > total) total = p;
if (!template && /^\d+$/.test((a.textContent || "").trim())) {
template = a;
templateParams = a.getAttribute("data-parameters");
}
if (sort === null) {
const ms = /sort_by:([\w]+)/.exec(a.getAttribute("data-parameters") || "");
if (ms) sort = ms[1];
}
if (p >= 2 && prefix === null) {
const m = /^(.*\/)\d+(\/?)$/.exec(href);
if (m) {
prefix = m[1];
tail = m[2] || "";
}
}
}
if (firstUrl === null) {
// Page 1 renders no page-1 <a> (the active span stands in for it),
// so synthesize: the prefix is the canonical page-1 URL; when the
// pattern is unknown, a URL with no page number in its path is
// page 1 itself.
if (prefix) firstUrl = prefix;
else {
const path = location.href.split(/[?#]/)[0];
const segs = path.split("/").filter(Boolean);
if (!/^\d+$/.test(segs[segs.length - 1] || "")) firstUrl = path;
}
}
if (firstUrl === null) return;
pgPattern = {
firstUrl,
total: total ?? (pgPattern ? pgPattern.total : null),
prefix: prefix ?? (pgPattern ? pgPattern.prefix : null),
tail: tail ?? (pgPattern ? pgPattern.tail : "/"),
template: template
? template.cloneNode(false)
: pgPattern
? pgPattern.template
: null,
templateParams:
templateParams ?? (pgPattern ? pgPattern.templateParams : null),
sort: sort ?? (pgPattern ? pgPattern.sort : null),
};
}
/**
* URL of listing page p per the learned pattern (root-relative), with the
* listing's current sort appended: the site's sort (Recent Update / Most
* Viewed / Most Favourited / Best Recently) is AJAX-only and never part
* of the URL, so without this every fetch of page N > 1 would silently
* fall back to the server's default sort (Recent Update).
*/
function urlForPage(p) {
if (!pgPattern || !pgPattern.prefix) return null;
const url =
p <= 1
? pgPattern.firstUrl
: pgPattern.prefix + String(p) + (pgPattern.tail || "");
if (!url || !pgPattern.sort) return url;
return url + (url.includes("?") ? "&" : "?") + "sort_by=" + pgPattern.sort;
}
/**
* Reshape the site's pagination around the read-ahead window: with N
* ahead pages, one page on screen + N fetched means each button should
* be a "screen" of (1+N) pages. The site's per-page buttons for
* in-between pages are removed and replaced by one button per screen,
* labelled with that screen's LAST page — so the highlighted number is
* always the last page currently on screen and clicking "K" shows up to
* page K (no mental math). Buttons navigate the site to the screen's
* FIRST page through its normal (AJAX) href, so the site's own handler
* does the re-render; "« First" / "Last »" are left alone. N=0 degrades
* to exactly the site's stock numbering (one page per button).
* Re-applying is a no-op (labels+hrefs compared before touching the DOM).
*/
function syncPagination() {
const n =
config && config.enabled
? Math.min(AHEAD_MAX, Math.max(0, config.pagesAhead | 0))
: 0;
const pagination = document.querySelector("ul.pagination");
if (!pagination) return;
const span = pagination.querySelector(".page-link.active");
if (!span) return;
const current = lastActivePage;
if (current === null || current === undefined) return;
learnPagination(pagination);
const total = pgPattern ? pgPattern.total : null;
if (!total || total <= 1) return;
const size = n + 1;
const screens = Math.ceil(total / size);
const here = Math.min(screens, Math.ceil(current / size));
const spanLabel = String(Math.min(current + n, total)).padStart(2, "0");
if (span.textContent !== spanLabel) span.textContent = spanLabel;
spanLabelSet = spanLabel;
span.dataset.jtPage = String(current);
span.dataset.jtOurs = "1";
const want = [];
for (let s = Math.max(1, here - 2); s <= Math.min(screens, here + 2); s++) {
if (s === here) continue; // the active span stands in for it
const first = (s - 1) * size + 1;
want.push({
s,
label: String(Math.min(s * size, total)).padStart(2, "0"),
href: urlForPage(first),
});
}
const desired = want.map((w) => w.label + "@" + w.href);
const have = [...pagination.querySelectorAll("li[data-jt-li] a.page-link")].map(
(a) => (a.textContent || "").trim() + "@" + (a.getAttribute("href") || "")
);
if (have.join("|") === desired.join("|")) return; // already in sync
for (const li of [...pagination.querySelectorAll("li")]) {
const a = li.querySelector("a.page-link");
if (li.dataset.jtLi || (a && /^\d+$/.test((a.textContent || "").trim())))
li.remove();
}
const spanLi = span.closest("li");
const make = (w) => {
const li = document.createElement("li");
li.dataset.jtLi = "1";
const a = pgPattern && pgPattern.template
? pgPattern.template.cloneNode(false)
: (() => {
const x = document.createElement("a");
x.className = "page-link";
return x;
})();
const first = (w.s - 1) * size + 1;
a.setAttribute("href", w.href);
a.setAttribute("aria-label", "Page " + w.label);
// The site's AJAX handler reads the page from data-parameters
// ("...;from:NN"), not from the href — refresh it per button.
if (pgPattern && pgPattern.templateParams !== null) {
a.setAttribute(
"data-parameters",
pgPattern.templateParams.replace(
/from:\d+/i,
"from:" + String(first).padStart(2, "0")
)
);
}
a.dataset.jtPage = String(first);
a.dataset.jtOurs = "1";
a.textContent = w.label;
li.appendChild(a);
return li;
};
for (const w of want) {
if (!w.href || w.s >= here) continue;
spanLi.insertAdjacentElement("beforebegin", make(w));
}
for (const w of [...want].reverse()) {
if (!w.href || w.s <= here) continue;
spanLi.insertAdjacentElement("afterend", make(w));
}
}
/**
* Fetch one listing page. Returns { cells (adopted into this document,
* deduped by video href against `knownHrefs`), nextUrl } or null when
* the page cannot be fetched or has no video grid.
*/
async function fetchListingPage(url, knownHrefs) {
// The listing changes (uploads, views): never trust a cached copy.
const busted = url + (url.includes("?") ? "&" : "?") + "_=" + Date.now();
const response = await fetch(busted, { credentials: "same-origin" });
if (!response.ok) return null;
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
const nextUrl = nextPageUrl(doc);
const firstCell = doc.querySelector("p.sub-title");
const row = firstCell ? firstCell.closest('[class*="col-"]')?.parentElement : null;
if (!row) return null;
const cells = [];
// Snapshot first: adoptNode() below removes each cell from this live
// collection, which would otherwise shift children up and make the loop
// skip every other cell (half of each page's cards would be dropped).
const cellList = [...row.children].filter((child) =>
String(child.className || "").includes("col-")
);
for (const cell of cellList) {
const title = cell.querySelector(".detail .title a");
const href = title ? title.href : null;
if (href) {
if (knownHrefs.has(href)) continue;
knownHrefs.add(href);
}
cells.push(document.adoptNode(cell));
}
return { cells, nextUrl };
}
/**
* Keep the number of appended pages in line with config.pagesAhead:
* trim from the end when it shrank (or the tool is disabled), fetch +
* append when it grew. Appended cells flow through the normal scan
* (badges, tier hiding, console log) exactly like native cards.
*/
async function syncReadAhead() {
const token = ++aheadToken;
try {
const target =
config && config.enabled
? Math.min(AHEAD_MAX, Math.max(0, config.pagesAhead | 0))
: 0;
while (ahead.length > target) {
const page = ahead.pop();
for (const cell of page.cells) cell.remove();
}
// The site re-renders the whole grid (sort change, AJAX pagination):
// our appended cells may be gone from the DOM while the bookkeeping
// above still lists them — drop them and re-fetch below.
for (let i = ahead.length - 1; i >= 0; i--) {
const cells = ahead[i].cells;
if (!cells.length || !cells[cells.length - 1].isConnected) {
for (const cell of cells) cell.remove();
ahead.splice(0, i + 1);
break;
}
}
if (!config || !config.enabled || ahead.length >= target || !liveRow()) return;
const knownHrefs = new Set();
for (const subTitle of document.querySelectorAll("p.sub-title")) {
const detail = subTitle.closest(".detail");
const title = detail ? detail.querySelector(".title a") : null;
if (title && title.href) knownHrefs.add(title.href);
}
let guard = 0;
while (ahead.length < target && guard++ <= AHEAD_MAX) {
const url = ahead.length ? ahead[ahead.length - 1].nextUrl : nextPageUrl(document);
if (!url) break;
let page = null;
try {
page = await fetchListingPage(url, knownHrefs);
} catch {
page = null;
}
if (token !== aheadToken) return; // a newer sync took over
if (!page || !page.cells.length) break;
const row = liveRow();
if (!row) break;
row.append(...page.cells);
ahead.push({ url, cells: page.cells, nextUrl: page.nextUrl });
}
} catch (err) {
console.warn("[Jable-Tools] read-ahead failed:", err);
}
}
function createBadge() {
const SVG_NS = "http://www.w3.org/2000/svg";
const badge = document.createElement("span");
badge.className = "jt-ratio";
// Small ascending-bars icon; color is set per tier on every update.
const icon = document.createElementNS(SVG_NS, "svg");
icon.setAttribute("class", "jt-ratio-icon");
icon.setAttribute("width", "10");
icon.setAttribute("height", "10");
icon.setAttribute("viewBox", "0 0 10 10");
icon.setAttribute("aria-hidden", "true");
for (const [x, y, h] of [[0, 6, 4], [4, 3, 7], [8, 0, 10]]) {
const bar = document.createElementNS(SVG_NS, "rect");
bar.setAttribute("x", String(x));
bar.setAttribute("y", String(y));
bar.setAttribute("width", "2");
bar.setAttribute("height", String(h));
icon.appendChild(bar);
}
badge.appendChild(icon);
const num = document.createElement("span");
num.className = "jt-ratio-num";
badge.appendChild(num);
return badge;
}
function collect() {
for (const subTitle of document.querySelectorAll("p.sub-title")) {
if (cards.has(subTitle)) continue;
const { views, likes } = parseStats(subTitle);
if (views === null || likes === null) continue;
cards.set(subTitle, { views, likes, badge: null, cardRoot: cardRoot(subTitle) });
}
}
function update(subTitle, rec) {
const { views, likes } = rec;
const score = window.JT.score(views, likes);
const tier = window.JT.tierFor(score, config.tiers);
const scoreText = window.JT.formatScore(score);
const title = `${views.toLocaleString("en-US")} views / ${likes.toLocaleString("en-US")} likes`;
if (config.enabled) {
if (!rec.badge) {
rec.badge = createBadge();
subTitle.appendChild(rec.badge);
}
const icon = rec.badge.querySelector(".jt-ratio-icon");
icon.style.color = tier.color;
rec.badge.querySelector(".jt-ratio-num").textContent = scoreText;
rec.badge.title = `Jable-Tools — ${title} → score ${scoreText} (${tier.name})`;
} else if (rec.badge) {
rec.badge.remove();
rec.badge = null;
}
if (rec.cardRoot) {
rec.cardRoot.style.display = config.enabled && !tier.visible ? "none" : "";
}
if (config.enabled) {
const key = `${cardTitle(subTitle)}|${views}|${likes}`;
if (!loggedKeys.has(key)) {
loggedKeys.add(key);
const likeRate = views > 0 ? `likes/views: ${((likes / views) * 100).toFixed(2)}%, ` : "";
console.info(
`[Jable-Tools] ${cardTitle(subTitle)} — views: ${views.toLocaleString("en-US")}, ` +
`likes: ${likes.toLocaleString("en-US")}, ${likeRate}score: ${scoreText} (${tier.name})`
);
}
}
}
function applyAll() {
for (const [subTitle] of cards) {
if (!subTitle.isConnected) cards.delete(subTitle);
}
collect();
for (const [subTitle, rec] of cards) {
update(subTitle, rec);
}
}
function queueScan() {
if (scanQueued) return;
scanQueued = true;
setTimeout(() => {
scanQueued = false;
try {
applyAll();
} catch (err) {
console.warn("[Jable-Tools] update failed:", err);
}
}, 150);
}
/** Current page per the site's pagination (dataset-aware, see `pageOf`). */
function activePage() {
const pagination = document.querySelector("ul.pagination");
return pagination
? pageOf(pagination.querySelector(".page-link.active"))
: null;
}
// The site's pagination is AJAX (`data-action="ajax"`): clicking a page
// link re-renders the grid in place, no navigation, URL unchanged. Track
// the active page so read-ahead re-syncs and always follows the page on
// screen (otherwise the appended pages stay the ones fetched before the
// click).
let lastActivePage = activePage();
// The sort the pagination was using last time we checked; the site's
// stock links always carry the current one in data-parameters.
let lastSort = null;
// The label we last wrote into the site's active span (see
// syncPagination); the site itself always writes a stock page number
// there when it re-renders, so a changed text is the page-change signal.
let spanLabelSet = null;
const observer = new MutationObserver((mutations) => {
// The site's pagination is AJAX (`data-action="ajax"`): clicking a page
// link re-renders the grid in place — no navigation, URL unchanged.
// Detect the move: the site writes a stock page number into the active
// span (fresh element, or the same element with updated text); if that
// number differs from what we last saw (resp. from the label we wrote),
// the page moved and the read-ahead must re-sync.
let cur = null;
const span = document.querySelector("ul.pagination .page-link.active");
if (span) {
const t = Number.parseInt((span.textContent || "").trim(), 10);
if (Number.isFinite(t)) {
const known = span.dataset.jtOurs
? Number(spanLabelSet || "")
: lastActivePage;
if (Number.isFinite(known) && t !== known) cur = t;
}
}
let pagDirty = false;
if (cur !== null && cur !== lastActivePage) {
lastActivePage = cur;
aheadToken++; // invalidate any in-flight sync
for (const page of ahead.splice(0)) {
for (const cell of page.cells) cell.remove();
}
syncReadAhead();
pagDirty = true;
}
let sortAttrDirty = false;
let gridTouched = false;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches?.("p.sub-title") || node.querySelector?.("p.sub-title")) {
queueScan();
gridTouched = true;
}
if (
node.matches?.(".page-link") ||
node.querySelector?.(".page-link") ||
node.closest?.("ul.pagination")
) {
pagDirty = true;
}
}
// The site rewrites the stock links' data-parameters (which includes
// sort_by) when the user changes the sort — an attribute mutation, no
// new nodes.
if (
mutation.type === "attributes" &&
mutation.attributeName === "data-parameters" &&
!mutation.target.dataset.jtOurs
) {
sortAttrDirty = true;
}
}
if (pagDirty || sortAttrDirty) {
const sortNow = sortOf(document.querySelector("ul.pagination"));
if (sortNow !== lastSort) {
lastSort = sortNow;
aheadToken++; // invalidate any in-flight sync
for (const page of ahead.splice(0)) {
for (const cell of page.cells) cell.remove();
}
syncPagination(); // re-learn the template (data-parameters now sorted)
syncReadAhead(); // fetch the ahead pages with the new sort
} else if (pagDirty) {
syncPagination();
}
}
// Any grid re-render (re-sort to the same sort, site-side refresh, …)
// removes our appended cells: re-fetch them. No-op when they survived.
if (gridTouched) syncReadAhead();
});
async function start() {
let raw = null;
try {
raw = await window.JT_HOST.load();
} catch {
raw = null;
}
config = window.JT.normalize(raw);
// Live config updates (popup / ⚙ panel / another tab).
window.JT_HOST.onChange((newRaw) => {
config = window.JT.normalize(newRaw);
queueScan();
syncReadAhead();
syncPagination();
});
applyAll();
syncPagination();
lastSort = sortOf(document.querySelector("ul.pagination"));
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-parameters"],
});
syncReadAhead();
}
if (document.body) start();
else window.addEventListener("DOMContentLoaded", start, { once: true });
})();
window.JT_PAGE_CSS = "/* Jable-Tools ratio badge (injected into jable.tv video cards). */\n.jt-ratio {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n margin-left: 12px;\n padding: 1px 6px;\n border-radius: 6px;\n background: rgba(0, 0, 0, 0.55);\n color: #fff;\n font-size: 12px;\n line-height: 1.4;\n vertical-align: middle;\n user-select: none;\n}\n\n.jt-ratio-icon {\n display: block;\n fill: currentColor;\n}\n\n.jt-ratio-num {\n font-variant-numeric: tabular-nums;\n}\n\n/* Jable-Tools floating settings panel (userscript, page context).\n * Scoped to #jt-ui-* / .tier-row inside #jt-ui-root so it cannot leak\n * into the page. The badge styles themselves live in styles.css. */\n\n#jt-ui-btn {\n position: fixed;\n right: 16px;\n bottom: 16px;\n z-index: 2147483646;\n width: 34px;\n height: 34px;\n border: 1px solid #374151;\n border-radius: 50%;\n background: #111827;\n color: #f3f4f6;\n font-size: 17px;\n line-height: 1;\n cursor: pointer;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);\n opacity: 0.8;\n}\n#jt-ui-btn:hover {\n opacity: 1;\n}\n\n#jt-ui-root {\n position: fixed;\n right: 16px;\n bottom: 58px;\n z-index: 2147483647;\n width: 320px;\n max-height: 80vh;\n overflow: auto;\n background: #111827;\n color: #f3f4f6;\n border: 1px solid #374151;\n border-radius: 10px;\n font: 13px system-ui, sans-serif;\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);\n}\n\n#jt-ui-root header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 10px 12px;\n border-bottom: 1px solid #374151;\n}\n#jt-ui-root h1 {\n font-size: 14px;\n margin: 0;\n}\n\n/* master on/off slider (same markup as the extension popup) */\n#jt-ui-root .switch {\n position: relative;\n display: inline-block;\n width: 40px;\n height: 22px;\n}\n#jt-ui-root .switch input {\n opacity: 0;\n width: 0;\n height: 0;\n}\n#jt-ui-root .switch .slider {\n position: absolute;\n inset: 0;\n background: #4b5563;\n border-radius: 999px;\n transition: background 0.15s;\n cursor: pointer;\n}\n#jt-ui-root .switch .slider::before {\n content: \"\";\n position: absolute;\n width: 16px;\n height: 16px;\n left: 3px;\n top: 3px;\n background: #fff;\n border-radius: 50%;\n transition: transform 0.15s;\n}\n#jt-ui-root .switch input:checked + .slider {\n background: #22c55e;\n}\n#jt-ui-root .switch input:checked + .slider::before {\n transform: translateX(18px);\n}\n\n#jt-ui-root .hint {\n padding: 8px 12px 2px;\n margin: 0;\n color: #9ca3af;\n font-size: 11px;\n}\n#jt-ui-root .hint.inline {\n padding: 0;\n}\n\n/* column header + tier rows share the same grid */\n#jt-ui-root .jt-cols,\n#jt-ui-root .tier-row {\n display: grid;\n grid-template-columns: 20px 64px 1fr 64px;\n gap: 4px 8px;\n align-items: center;\n padding: 3px 12px;\n}\n#jt-ui-root .jt-cols {\n color: #9ca3af;\n font-size: 11px;\n}\n#jt-ui-root .tier-row {\n border-top: 1px solid #1f2937;\n}\n\n#jt-ui-root .tier-row input[type=\"checkbox\"] {\n width: 15px;\n height: 15px;\n justify-self: center;\n accent-color: #22c55e;\n}\n#jt-ui-root .tier-row input[type=\"text\"] {\n width: 100%;\n min-width: 0;\n background: transparent;\n border: none;\n color: inherit;\n font: inherit;\n padding: 0;\n}\n#jt-ui-root .tier-row input[type=\"text\"]:focus {\n outline: 1px solid #3b82f6;\n}\n#jt-ui-root .tier-row input[type=\"color\"] {\n width: 100%;\n height: 18px;\n padding: 0;\n border: none;\n background: none;\n cursor: pointer;\n}\n#jt-ui-root .tier-row input[type=\"number\"] {\n width: 100%;\n min-width: 0;\n background: #1f2937;\n border: 1px solid #374151;\n color: inherit;\n border-radius: 4px;\n padding: 2px 5px;\n font: inherit;\n}\n#jt-ui-root .tier-row input[type=\"number\"]:focus {\n outline: 1px solid #3b82f6;\n}\n\n#jt-ui-root .jt-ahead {\n display: grid;\n grid-template-columns: 1fr 64px;\n gap: 8px;\n align-items: center;\n padding: 6px 12px 2px;\n border-top: 1px solid #1f2937;\n}\n#jt-ui-root .jt-ahead input[type=\"number\"] {\n width: 100%;\n min-width: 0;\n background: #1f2937;\n border: 1px solid #374151;\n color: inherit;\n border-radius: 4px;\n padding: 2px 5px;\n font: inherit;\n}\n#jt-ui-root .jt-ahead input[type=\"number\"]:focus {\n outline: 1px solid #3b82f6;\n}\n\n#jt-ui-root footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 10px 12px;\n border-top: 1px solid #374151;\n margin-top: 6px;\n}\n#jt-ui-root button {\n background: #1f2937;\n color: inherit;\n border: 1px solid #374151;\n border-radius: 6px;\n padding: 4px 10px;\n cursor: pointer;\n font: inherit;\n}\n#jt-ui-root button:hover {\n background: #273449;\n}\n\n#jt-ui-root.disabled .jt-cols,\n#jt-ui-root.disabled .tier-row,\n#jt-ui-root.disabled .jt-ahead {\n opacity: 0.45;\n pointer-events: none;\n}\n";
/**
* Jable-Tools — floating settings panel (userscript, page context).
*
* A small ⚙ button (bottom right) opens the same settings as the extension
* popup: master on/off, per-tier show checkbox / name / color / boundary,
* and reset. Changes are persisted through window.JT_HOST and applied
* live — no reload.
*
* All markup carries `jt-ui` ids/classes; all styles come from
* `window.JT_PAGE_CSS` (inlined by the build: styles.css + panel.css).
*/
(() => {
"use strict";
if (window.__jtPanelMounted) return;
window.__jtPanelMounted = true;
const styleEl = document.createElement("style");
styleEl.id = "jt-ui-styles";
styleEl.textContent = window.JT_PAGE_CSS || "";
const btn = document.createElement("button");
btn.id = "jt-ui-btn";
btn.type = "button";
btn.title = "Jable-Tools settings";
btn.textContent = "⚙";
const root = document.createElement("div");
root.id = "jt-ui-root";
root.style.display = "none";
const header = document.createElement("header");
const h1 = document.createElement("h1");
h1.textContent = "Jable-Tools";
const switchLabel = document.createElement("label");
switchLabel.className = "switch";
switchLabel.title = "Enable / disable the script";
const enabled = document.createElement("input");
enabled.type = "checkbox";
const slider = document.createElement("span");
slider.className = "slider";
switchLabel.append(enabled, slider);
header.append(h1, switchLabel);
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "score = likes ÷ views × 10000, capped at 200";
const cols = document.createElement("div");
cols.className = "jt-cols";
cols.innerHTML = "<span></span><span>show</span><span>tier</span><span>score <</span>";
const tiers = document.createElement("div");
tiers.id = "jt-ui-tiers";
const aheadRow = document.createElement("div");
aheadRow.className = "jt-ahead";
const aheadLabel = document.createElement("label");
aheadLabel.htmlFor = "jt-ui-pages-ahead";
aheadLabel.textContent = "read ahead pages";
const aheadInput = document.createElement("input");
aheadInput.type = "number";
aheadInput.id = "jt-ui-pages-ahead";
aheadInput.min = "0";
aheadInput.max = "9";
aheadInput.step = "1";
aheadRow.append(aheadLabel, aheadInput);
const aheadHint = document.createElement("p");
aheadHint.className = "hint";
aheadHint.textContent = "fetches the next N listing pages and appends their cards (0 = off)";
const footer = document.createElement("footer");
const reset = document.createElement("button");
reset.type = "button";
reset.title = "Restore default colors and boundaries";
reset.textContent = "Reset defaults";
const live = document.createElement("span");
live.className = "hint inline";
live.textContent = "changes apply live";
footer.append(reset, live);
root.append(header, hint, cols, tiers, aheadRow, aheadHint, footer);
function attach() {
(document.head || document.documentElement).appendChild(styleEl);
(document.body || document.documentElement).append(root, btn);
}
if (document.body) attach();
else window.addEventListener("DOMContentLoaded", attach, { once: true });
let config = null;
function save() {
window.JT_HOST.save(window.JT.forStorage(config));
}
function setEnabled(value) {
config.enabled = value;
root.classList.toggle("disabled", !value);
enabled.checked = value;
}
function render() {
window.JT.mountTierRows(tiers, config, save);
aheadInput.value = String(config.pagesAhead);
}
aheadInput.addEventListener("change", () => {
const n = Math.floor(Number(aheadInput.value));
config.pagesAhead = Number.isFinite(n) ? Math.min(9, Math.max(0, n)) : 0;
aheadInput.value = String(config.pagesAhead);
save();
});
btn.addEventListener("click", async () => {
const wasHidden = root.style.display === "none";
if (!wasHidden) {
root.style.display = "none";
return;
}
if (!config) {
const raw = await window.JT_HOST.load();
config = window.JT.normalize(raw);
render();
}
setEnabled(config.enabled);
root.style.display = "";
});
enabled.addEventListener("change", () => {
setEnabled(enabled.checked);
save();
});
reset.addEventListener("click", () => {
config = window.JT.normalize(null);
save();
render();
setEnabled(config.enabled);
});
})();