☰

Jable-Tools

Like/view ratio badges (0–200 score, rarity tiers) + read-ahead pagination on jable.tv video cards. ⚙ settings apply live.

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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&nbsp;&lt;</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);
  });
})();