Instagram Preference AI + Insights — Pro Pack Ultra

Local-only preference learner for Instagram with robust watch-time (video & image exposure), AB-learning, overlays, filters, snapshots, resizable/responsive UI, per-media tuning, debug tools, creator overexposure dampener, and more. Fully standalone.

当前为 2025-09-05 提交的版本,查看 最新版本

// ==UserScript==
// @name         Instagram Preference AI + Insights — Pro Pack Ultra
// @namespace    https://github.com/you/ig-preference-ai
// @version      1.8.0
// @description  Local-only preference learner for Instagram with robust watch-time (video & image exposure), AB-learning, overlays, filters, snapshots, resizable/responsive UI, per-media tuning, debug tools, creator overexposure dampener, and more. Fully standalone.
// @author       You
// @match        https://www.instagram.com/*
// @match        https://instagram.com/*
// @match        https://m.instagram.com/*
// @icon         https://www.instagram.com/static/images/ico/favicon-192.png/68d99ba29cc8.png
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(() => {
  'use strict';

  /********************
   * LIGHTWEIGHT LOG + GM FALLBACKS
   ********************/
  let DEBUG = false; // can be toggled in Settings
  const log = (...a)=>{ if (DEBUG) console.debug('[IG-PA Ultra]',...a); };
  const warn = (...a)=>console.warn('[IG-PA Ultra]',...a);

  function gmGet(k, def){
    try { if (typeof GM_getValue === 'function') return GM_getValue(k, def); } catch {}
    try { const raw = localStorage.getItem('IGPAULTRA_'+k); return raw ? JSON.parse(raw) : def; } catch { return def; }
  }
  function gmSet(k, v){
    try { if (typeof GM_setValue === 'function') return GM_setValue(k, v); } catch {}
    try { localStorage.setItem('IGPAULTRA_'+k, JSON.stringify(v)); } catch {}
  }
  function addStyle(css){
    try { if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const s=document.createElement('style'); s.textContent=css; document.head.appendChild(s); } } catch {}
  }

  /********************
   * CONFIG
   ********************/
  const CONFIG = {
    version: '1.8.0',
    ui: {
      fabSize: 52,
      zIndex: 2147483000,
      defaultPanelWidth: Math.min(520, Math.max(380, Math.floor(window.innerWidth*0.42))),
      defaultPanelHeight: Math.min(700, Math.max(520, Math.floor(window.innerHeight*0.65))),
      badgeBorderRadius: 10,
      minPanelWidth: 340,
      minPanelHeight: 420
    },
    learn: {
      lr: 0.10, lrA: 0.10, lrB: 0.20, abPeriod: 90,
      minNegWatchSec: 1.0,
      minPosWatchSec: 6.0,
      maxWatchImpactSec: 30.0,
      watchWeightImage: 1.00,
      watchWeightVideo: 1.10,
      clickBoost: 0.15,
      decay: 0.0006,
      maxEvents: 5000,
      vocab: { maxHashtags: 200, maxCreators: 220, maxStems: 600 },
      overexposureWindow: 10,     // last N events
      overexposurePenalty: 0.08,  // small penalty if same creator dominates recent window
    },
    uiPrefsDefault: {
      theme: 'auto', // 'auto' | 'dark' | 'light'
      showBadge: true, compactBadge: false, showExplain: true,
      dimLowScore: true, blurLowScore: false, lowScoreThreshold: 0.40,
      highlightHighScore: true, highScoreThreshold: 0.78,
      eyeComfort: true, capCreator: true, creatorCapWeight: 1.2,
      autoSkipLow: false, autoSkipDelayMs: 740, autoskipJitterMax: 240,
      pauseLearning: false, showDebugOverlay: false
    }
  };

  const SELECTORS = {
    article: 'article, div[role="article"]',
    postAnchor: 'a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]',
    video: 'video',
    usernameAnchor: 'header a[href^="/"], a[role="link"][href^="/"]',
    captionCandidates: ['h1','h2','span','div[role="button"]','li','div']
  };

  /********************
   * UTILS
   ********************/
  const clamp01 = x=>Math.min(1,Math.max(0,x));
  const EPS = 1e-7;
  function sigmoid(z){ if (z >= 0){ const ez=Math.exp(-z); return 1/(1+ez); } const ez=Math.exp(z); return ez/(1+ez); }
  const now = ()=>performance.now();
  const sleep = (ms)=>new Promise(r=>setTimeout(r,ms));
  const randInt = (n)=> (Math.random()*n)|0;

  function hashStr(str){ let h=5381; for(let i=0;i<str.length;i++) h=((h<<5)+h)+str.charCodeAt(i); return (h>>>0).toString(36); }

  function getPostIdFromEl(el){
    try{
      const a = el.querySelector(SELECTORS.postAnchor);
      if (a){
        const url = new URL(a.href, location.origin);
        const parts = url.pathname.split('/').filter(Boolean);
        if (parts.length >= 2) return `${parts[0]}:${parts[1]}`;
      }
      const dataId = el.getAttribute('data-testid') || el.getAttribute('data-post-id');
      if (dataId) return 'd:'+dataId;
      return 'x:'+hashStr((el.textContent||'').slice(0,400));
    } catch { return 'x:'+Math.random().toString(36).slice(2); }
  }

  function isPost(el){
    if (!(el instanceof HTMLElement)) return false;
    const a = el.querySelector(SELECTORS.postAnchor);
    const hasVid = !!el.querySelector(SELECTORS.video);
    const hasImg = !!el.querySelector('img');
    const roleArticle = el.matches(SELECTORS.article);
    return !!(a || hasVid || (roleArticle && hasImg));
  }

  function getUsername(el){
    try{
      const h = el.querySelector(SELECTORS.usernameAnchor);
      if (!h) return null;
      const href = h.getAttribute('href') || '';
      const m = href.match(/^\/([^\/?#]+)\/?$/);
      return m ? m[1].toLowerCase() : null;
    } catch { return null; }
  }

  function getCaptionText(el){
    let best = '';
    try {
      for (const sel of SELECTORS.captionCandidates) {
        const nodes = el.querySelectorAll(sel);
        for (const node of nodes) {
          const txt = (node.textContent||'').trim();
          if (!txt) continue;
          const hashCount = (txt.match(/#\w/g)||[]).length;
          const bestHash = (best.match(/#\w/g)||[]).length;
          if (hashCount > bestHash) best = txt;
          else if (txt.length > best.length && txt.length < 3000) best = txt;
        }
        if (best) break;
      }
    } catch {}
    return best.trim();
  }

  function extractHashtags(text){
    const s = new Set();
    if (!text) return [];
    text.replace(/#([0-9A-Za-z_]+)/g,(_,t)=>{ if (t) s.add(t.toLowerCase()); return ''; });
    return [...s];
  }

  function getPostType(el){
    try{
      if (el.querySelector(SELECTORS.video)) return 'video';
      const imgs = el.querySelectorAll('img');
      if (imgs && imgs.length >= 2) return 'carousel';
      return 'image';
    } catch { return 'image'; }
  }

  function getCaptionLenBucket(len){
    if (len < 40) return 'cap:short';
    if (len < 140) return 'cap:med';
    return 'cap:long';
  }

  const SPONSORED_RE = /\b(sponsored|advert(?:isement|orial)?|paid (?:partnership|collab(?:oration)?)|promoted|partnered with)\b/i;
  function likelySponsored(el){
    if (!STATE.ui.eyeComfort) return false;
    const txt = (el.innerText||'').toLowerCase();
    return SPONSORED_RE.test(txt) || !!el.querySelector('[aria-label*="Paid partnership" i]');
  }

  /********************
   * TOPIC MODEL (tokenize + stem)
   ********************/
  const STOP=new Set(('a an the and or but if then else than when where who why how to in on at from for with by of as is are was were be been being this that these those i you he she it we they them me my your our their not no do does did doing so such very just over under into out up down more most less few many each any some only own same').split(' '));
  function tokenize(text){
    return (text||'')
      .toLowerCase()
      .replace(/https?:\/\/\S+|[@#]\w+/g,' ')
      .replace(/[^a-z0-9\s]/g,' ')
      .split(/\s+/)
      .filter(w=>w && !STOP.has(w) && w.length>=3 && w.length<=24)
      .map(stem);
  }
  function stem(w){
    return w
      .replace(/(ing|ers|er|ies|ied|iness|ness|ments|ment|ation|ations)$/,'')
      .replace(/(ed|ly|es|s)$/,'')
      .replace(/(.)\1{2,}/g,'$1');
  }

  /********************
   * STATE
   ********************/
  const DEFAULT_STATE = {
    ui: {...CONFIG.uiPrefsDefault},
    weights: { __bias: 0 },
    stats: {
      events: 0, positives: 0, negatives: 0, updatedAt: Date.now(),
      totalWatchSec: 0, totalImageExposureSec: 0, totalVideoPlaySec: 0
    },
    pins: { creators: {}, hashtags: {}, keywords: {} },
    bans: { creators: {}, hashtags: {}, keywords: {} },
    mutedHashtags: {},
    history: [],               // {ts,id,label,seconds,feats,via,pred,loss,arm,lr}
    ab: { aLoss: 0, bLoss: 0, count: 0, armNext: 'A' },
    colorCache: {},            // srcHash -> {avg:[r,g,b], ts}
    snapshots: {},             // name -> snapshot
    status: { booted: false, observing: false, lastError: '' },
    panel: { x: null, y: null, w: null, h: null, minimized:false }
  };
  let STATE = Object.assign({}, DEFAULT_STATE, gmGet('state_ultra', DEFAULT_STATE));
  function persist(){ gmSet('state_ultra', STATE); DEBUG = !!STATE.ui.showDebugOverlay; }

  /********************
   * FEATURES & SCORE
   ********************/
  function featuresFromPost(el, extra = {}){
    const feats = {};
    const type = getPostType(el);
    feats['type:'+type] = 1;

    const creator = (getUsername(el)||'unknown');
    feats['user:'+creator] = 1;

    const caption = getCaptionText(el);
    const tags = extractHashtags(caption).filter(t=>t.length<=32);
    for (const t of tags) feats['tag:'+t] = 1;

    for (const s of tokenize(caption).slice(0,40)) feats['kw:'+s] = 1;

    feats[getCaptionLenBucket(caption.length)] = 1;
    feats['has:hashtags'] = tags.length>0 ? 1 : 0;
    feats['has:video'] = (type==='video') ? 1 : 0;
    feats['has:carousel'] = (type==='carousel') ? 1 : 0;
    feats['flag:sponsored'] = likelySponsored(el) ? 1 : 0;
    feats['flag:mutedTag'] = tags.some(t=>STATE.mutedHashtags[t]) ? 1 : 0;

    if (extra.watchBucket) feats['watch:'+extra.watchBucket] = 1;
    if (extra.exposureHeavy) feats['watch:exposureHeavy'] = 1;
    if (extra.playHeavy) feats['watch:playHeavy'] = 1;

    // Overexposure dampener feature (softly penalize when same creator appears too frequently recently)
    const damp = overexposureDampener(creator);
    if (damp > 0) feats['reg:overexposed'] = damp;

    return feats;
  }

  function overexposureDampener(creator){
    try{
      const N = CONFIG.learn.overexposureWindow;
      const recent = STATE.history.slice(-N);
      let count = 0;
      for (const ev of recent){
        if (!ev || !ev.id || !ev.metaCreator) continue;
        if (ev.metaCreator === creator) count++;
      }
      if (count <= Math.floor(N*0.4)) return 0;
      const ratio = count / Math.max(1,N);
      return Math.min(1, ratio) * CONFIG.learn.overexposurePenalty;
    } catch { return 0; }
  }

  function dot(weights, feats){
    let z = (weights.__bias || 0);
    let creatorKey = null;
    for (const k in feats) { if (k.startsWith('user:')) { creatorKey = k; break; } }

    for (const k in feats){
      let w = (weights[k] || 0);
      const v = feats[k];

      if (STATE.ui.capCreator && creatorKey && k===creatorKey){
        const cap = STATE.ui.creatorCapWeight;
        if (w > cap) w = cap; if (w < -cap) w = -cap;
      }
      if (k.startsWith('tag:')){
        const tag = k.slice(4);
        if (STATE.pins.hashtags[tag]) w += 0.25;
        if (STATE.bans.hashtags[tag]) w -= 0.35;
      }
      if (k.startsWith('kw:')){
        const kw = k.slice(3);
        if (STATE.pins.keywords[kw]) w += 0.18;
        if (STATE.bans.keywords[kw]) w -= 0.25;
      }
      if (k.startsWith('user:')){
        const u = k.slice(5);
        if (STATE.pins.creators[u]) w += 0.25;
        if (STATE.bans.creators[u]) w -= 0.40;
      }
      z += w * v;
    }
    return z;
  }

  function predictScore(el, feats=null){
    feats = feats || featuresFromPost(el);
    return sigmoid(dot(STATE.weights, feats));
  }

  /********************
   * LEARNING + AB (continuous label with watch-time)
   ********************/
  function applyDecay(){
    try{
      const w = STATE.weights, d = CONFIG.learn.decay;
      for (const k in w) if (k !== '__bias') w[k] *= (1 - d);
    } catch (e) { STATE.status.lastError = 'decay:'+String(e); }
  }

  function softPrune(w, prefix, max){
    try{
      const keys = Object.keys(w).filter(k=>k.startsWith(prefix));
      if (keys.length <= max) return;
      keys.sort((a,b)=>Math.abs(w[a])-Math.abs(w[b]));
      const dropN = Math.min(keys.length - max, Math.max(1, Math.floor(keys.length*0.12)));
      for (let i=0;i<dropN;i++) delete w[keys[i]];
    } catch (e) { STATE.status.lastError = 'prune:'+String(e); }
  }

  function sgdUpdate(feats, label){
    if (STATE.ui.pauseLearning) return { pred: sigmoid(dot(STATE.weights, feats)), loss: 0, arm: 'P', lrUsed: 0 };
    const arm = (STATE.ab.armNext === 'A') ? 'A' : 'B';
    const lr = (arm==='A') ? CONFIG.learn.lrA : CONFIG.learn.lrB;

    const pred = sigmoid(dot(STATE.weights, feats));
    const loss = -(label ? Math.log(clamp01(pred)+EPS) : Math.log(clamp01(1 - pred)+EPS));
    const err = (label - pred);

    const w = STATE.weights;
    w.__bias = (w.__bias || 0) + lr * err;
    for (const k in feats) w[k] = (w[k] || 0) + lr * err * feats[k];

    softPrune(w, 'kw:', CONFIG.learn.vocab.maxStems);
    softPrune(w, 'tag:', CONFIG.learn.vocab.maxHashtags);
    softPrune(w, 'user:', CONFIG.learn.vocab.maxCreators);

    applyDecay();

    if (arm==='A') STATE.ab.aLoss += loss; else STATE.ab.bLoss += loss;
    STATE.ab.count++;
    STATE.ab.armNext = (arm==='A') ? 'B' : 'A';

    if (STATE.ab.count >= CONFIG.learn.abPeriod){
      const win = (STATE.ab.aLoss <= STATE.ab.bLoss) ? 'A' : 'B';
      CONFIG.learn.lr = (win==='A') ? CONFIG.learn.lrA : CONFIG.learn.lrB;
      STATE.ab = { aLoss: 0, bLoss: 0, count: 0, armNext: (win==='A' ? 'B' : 'A') };
    }

    STATE.stats.events++;
    STATE.stats.updatedAt = Date.now();

    return { pred, loss, arm, lrUsed: lr };
  }

  function pickTopFeats(feats){
    try{
      const arr = Object.entries(feats).map(([k,v])=>({k,v,w:STATE.weights[k]||0,contrib:(STATE.weights[k]||0)*v}));
      arr.sort((a,b)=>Math.abs(b.contrib)-Math.abs(a.contrib));
      return arr.slice(0,6);
    } catch { return []; }
  }

  /********************
   * WATCHTIME: Video playtime & Image exposure
   ********************/
  const activeTimers = new Map(); // id -> { el, start, accum, autoskipTimer, skipped, lastVisibleAt }
  const videoTrackers = new Map(); // videoEl -> { id, playAccum, lastT, playing, lastTick, cleanup }

  function normalizedWatchLabel(sec, type='image'){
    const {minNegWatchSec, minPosWatchSec, maxWatchImpactSec, watchWeightImage, watchWeightVideo} = CONFIG.learn;
    const x = clamp01(sec / maxWatchImpactSec);
    const smooth = x*x*(3 - 2*x);
    let label = (sec < minNegWatchSec) ? 0
                : (sec >= minPosWatchSec) ? 1
                : smooth;
    const mult = (type==='video') ? watchWeightVideo : watchWeightImage;
    return clamp01(label * mult);
  }

  function watchBucket(sec){
    if (sec < 2) return 'tiny';
    if (sec < 5) return 'short';
    if (sec < 10) return 'med';
    if (sec < 20) return 'long';
    return 'vlong';
  }

  let io = null;
  function ensureIO(){
    if (io) return io;
    io = new IntersectionObserver((entries)=>{
      try {
        for (const entry of entries) {
          const el = entry.target;
          if (!isPost(el)) continue;
          const id = getPostIdFromEl(el);
          let rec = activeTimers.get(id) || { el, start: null, accum: 0, autoskipTimer: null, skipped:false, lastVisibleAt: 0 };
          rec.el = el;

          if (entry.isIntersecting && entry.intersectionRatio >= 0.55) {
            if (!rec.start) { rec.start = now(); rec.lastVisibleAt = rec.start; }
            scheduleAutoSkip(el, id, rec);
          } else {
            if (rec.start) {
              rec.accum += (now() - rec.start)/1000;
              rec.start = null;
              onExposureCompleteImageSide(el, id, rec.accum);
              rec.accum = 0;
            }
            if (rec.autoskipTimer) { clearTimeout(rec.autoskipTimer); rec.autoskipTimer = null; }
          }
          activeTimers.set(id, rec);
        }
      } catch (e) { STATE.status.lastError = 'io:'+String(e); }
    }, { threshold:[0,0.55,1] });
    return io;
  }

  function attachVideoTracker(el, id){
    try{
      const vids = el.querySelectorAll(SELECTORS.video);
      vids.forEach(v=>{
        if (videoTrackers.has(v)) return;
        const tracker = { id, playAccum: 0, lastT: 0, playing: false, lastTick: 0 };
        const onPlay = ()=>{ tracker.playing = true; tracker.lastTick = now(); if (STATE.ui.showDebugOverlay) v.setAttribute('data-igpa-playing','1'); };
        const onPause = ()=>{ if (tracker.playing){ tracker.playAccum += (now()-tracker.lastTick)/1000; tracker.playing = false; } if (STATE.ui.showDebugOverlay) v.removeAttribute('data-igpa-playing'); };
        const onTimeUpdate = ()=>{ if (tracker.playing){ tracker.playAccum += (now()-tracker.lastTick)/1000; tracker.lastTick = now(); } };
        const onEnded = ()=>{ onPause(); flushVideoTracker(v, tracker); };
        v.addEventListener('play', onPlay);
        v.addEventListener('pause', onPause);
        v.addEventListener('timeupdate', onTimeUpdate);
        v.addEventListener('ended', onEnded);
        tracker.cleanup = ()=>{ v.removeEventListener('play', onPlay); v.removeEventListener('pause', onPause); v.removeEventListener('timeupdate', onTimeUpdate); v.removeEventListener('ended', onEnded); };
        videoTrackers.set(v, tracker);
      });
    } catch (e){ STATE.status.lastError = 'vid:'+String(e); }
  }

  function flushVideoTracker(v, tr){
    try{
      if (!tr) tr = videoTrackers.get(v);
      if (!tr) return;
      if (tr.playing){ tr.playAccum += (now()-tr.lastTick)/1000; tr.playing = false; }
      const rec = activeTimers.get(tr.id);
      const el = rec?.el || document.querySelector(SELECTORS.article);
      if (el) onVideoPlayComplete(el, tr.id, tr.playAccum);
      tr.playAccum = 0;
    } catch {}
  }

  function onExposureCompleteImageSide(el, id, seconds){
    try{
      if (getPostType(el) === 'video') return; // videos handled separately
      if (seconds < 0.01) return;

      STATE.stats.totalWatchSec += seconds;
      STATE.stats.totalImageExposureSec += seconds;

      const nlabel = normalizedWatchLabel(seconds, 'image');
      const label = (seconds < CONFIG.learn.minNegWatchSec) ? 0 : nlabel;

      const extra = { watchBucket: watchBucket(seconds), exposureHeavy: 1 };
      const feats = featuresFromPost(el, extra);
      const upd = sgdUpdate(feats, label);
      const creator = getUsername(el)||'unknown';
      addHistory({ ts: Date.now(), id, metaCreator: creator, label, seconds, feats: pickTopFeats(feats), via: 'exposure', pred: upd.pred, loss: upd.loss, arm: upd.arm, lr: upd.lrUsed });
      persist(); paintBadge(el);
    } catch (e) { STATE.status.lastError = 'imgwatch:'+String(e); }
  }

  function onVideoPlayComplete(el, id, seconds){
    try{
      if (seconds < 0.01) return;

      STATE.stats.totalWatchSec += seconds;
      STATE.stats.totalVideoPlaySec += seconds;

      const nlabel = normalizedWatchLabel(seconds, 'video');
      const label = (seconds < CONFIG.learn.minNegWatchSec) ? 0 : nlabel;

      const extra = { watchBucket: watchBucket(seconds), playHeavy: 1 };
      const feats = featuresFromPost(el, extra);
      const upd = sgdUpdate(feats, label);
      const creator = getUsername(el)||'unknown';
      addHistory({ ts: Date.now(), id, metaCreator: creator, label, seconds, feats: pickTopFeats(feats), via: 'video', pred: upd.pred, loss: upd.loss, arm: upd.arm, lr: upd.lrUsed });
      persist(); paintBadge(el);
    } catch (e) { STATE.status.lastError = 'vidwatch:'+String(e); }
  }

  function wasLiked(el){
    try{
      const btn = el.querySelector('button[aria-label*="Like" i], svg[aria-label*="Like" i]');
      if (!btn) return false;
      const pressed = btn.getAttribute('aria-pressed');
      if (pressed === 'true') return true;
      const aria = (btn.getAttribute('aria-label')||'').toLowerCase();
      return /\bunlike\b|\balready liked\b/.test(aria);
    } catch { return false; }
  }
  function wasSaved(el){
    try{
      const btn = el.querySelector('button[aria-label*="Save" i], svg[aria-label*="Save" i]');
      if (!btn) return false;
      const pressed = btn.getAttribute('aria-pressed');
      if (pressed === 'true') return true;
      const aria = (btn.getAttribute('aria-label')||'').toLowerCase();
      return /\bremove\b|\balready saved\b/.test(aria);
    } catch { return false; }
  }

  document.addEventListener('click', (e) => {
    try{
      const target = e.target;
      if (!(target instanceof Element)) return;
      const el = target.closest(SELECTORS.article);
      if (!el || !isPost(el)) return;
      setTimeout(()=>{
        const liked = wasLiked(el), saved = wasSaved(el);
        if (liked || saved){
          const id = getPostIdFromEl(el);
          let sec = 0;
          const rec = activeTimers.get(id);
          if (rec && rec.start){ sec += (now()-rec.start)/1000; rec.start = null; }
          el.querySelectorAll(SELECTORS.video).forEach(v=>{
            const tr = videoTrackers.get(v);
            if (!tr) return;
            if (tr.playing){ tr.playAccum += (now()-tr.lastTick)/1000; tr.playing = false; }
            sec += tr.playAccum; tr.playAccum = 0;
          });

          const nlabel = clamp01(Math.max(normalizedWatchLabel(sec, getPostType(el)), 0) + CONFIG.learn.clickBoost);
          const feats = featuresFromPost(el, { watchBucket: watchBucket(sec) });
          const upd = sgdUpdate(feats, nlabel);
          const creator = getUsername(el)||'unknown';
          addHistory({ ts: Date.now(), id, metaCreator: creator, label: nlabel, seconds: sec||null, feats: pickTopFeats(feats), via: liked?'like':'save', pred: upd.pred, loss: upd.loss, arm: upd.arm, lr: upd.lrUsed });
          persist(); paintBadge(el);
        }
      }, 120);
    } catch (e2) { STATE.status.lastError = 'click:'+String(e2); }
  }, true);

  function flushActiveTimers(){
    const t = now();
    for (const [id, rec] of activeTimers){
      try{
        if (rec.start){
          rec.accum += (t - rec.start)/1000;
          rec.start = null;
          if (rec.el && document.contains(rec.el)) onExposureCompleteImageSide(rec.el, id, rec.accum);
          rec.accum = 0;
        }
        if (rec.el){
          rec.el.querySelectorAll?.(SELECTORS.video).forEach(v=>{
            const tr = videoTrackers.get(v);
            if (tr) flushVideoTracker(v, tr);
          });
        }
      } catch {}
    }
  }
  window.addEventListener('pagehide', flushActiveTimers);
  document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') flushActiveTimers(); });

  /********************
   * AUTO-SKIP (optional)
   ********************/
  function scheduleAutoSkip(el, id, rec){
    try{
      if (!STATE.ui.autoSkipLow || rec.autoskipTimer || rec.skipped) return;
      const jitter = randInt(STATE.ui.autoskipJitterMax);
      const delay = STATE.ui.autoSkipDelayMs + jitter;
      rec.autoskipTimer = setTimeout(()=>{
        try{
          const score = predictScore(el);
          if (score < STATE.ui.lowScoreThreshold){
            rec.skipped = true;
            const r = el.getBoundingClientRect();
            window.scrollBy({ top: Math.max(120, r.height + 28), behavior: 'smooth' });
          }
        } finally { rec.autoskipTimer = null; }
      }, delay);
    } catch {}
  }

  /********************
   * STYLES (responsive, theme-aware)
   ********************/
  addStyle(`
    :root { --igpa-bg:#0b0b0b; --igpa-elev:#141414; --igpa-text:#eee; --igpa-sub:#a9a9a9; --igpa-border:rgba(255,255,255,.08); }
    [data-igpa-theme="light"] { --igpa-bg:#f7f7f7; --igpa-elev:#ffffff; --igpa-text:#0a0a0a; --igpa-sub:#454545; --igpa-border:rgba(0,0,0,.12); }
    .igpa-fab{position:fixed;right:16px;bottom:16px;width:${CONFIG.ui.fabSize}px;height:${CONFIG.ui.fabSize}px;border-radius:50%;background:var(--igpa-elev);color:var(--igpa-text);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:${CONFIG.ui.zIndex};box-shadow:0 6px 20px rgba(0,0,0,.35);user-select:none;border:1px solid var(--igpa-border)}
    .igpa-fab:hover{transform:translateY(-1px)}
    .igpa-panel{position:fixed;right:84px;bottom:16px;background:var(--igpa-bg);color:var(--igpa-text);border-radius:14px;box-shadow:0 12px 32px rgba(0,0,0,.45);z-index:${CONFIG.ui.zIndex};display:none;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;border:1px solid var(--igpa-border)}
    .igpa-panel.show{display:flex;flex-direction:column}
    .igpa-titlebar{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--igpa-elev);cursor:move;border-bottom:1px solid var(--igpa-border)}
    .igpa-titlebar .title{display:flex;align-items:center;gap:8px}
    .igpa-titlebar .window-btns{display:flex;gap:8px}
    .igpa-tabs{display:flex;flex-wrap:wrap;gap:8px;padding:8px 10px;border-bottom:1px solid var(--igpa-border);background:var(--igpa-elev)}
    .igpa-tab{padding:6px 10px;border-radius:8px;background:transparent;cursor:pointer;border:1px solid var(--igpa-border)}
    .igpa-tab.active{background:rgba(255,255,255,.06)}
    .igpa-body{flex:1;overflow:auto;padding:12px}
    .igpa-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0}
    .igpa-range{width:55%}
    .igpa-badge{position:absolute;top:8px;right:8px;padding:4px 8px;font-size:12px;line-height:18px;background:rgba(0,0,0,.72);color:#fff;border-radius:${CONFIG.ui.badgeBorderRadius}px;border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(2px);z-index:${CONFIG.ui.zIndex - 1};pointer-events:auto}
    [data-igpa-theme="light"] .igpa-badge{background:rgba(255,255,255,.82);color:#000;border:1px solid rgba(0,0,0,.12)}
    .igpa-badge.compact{padding:3px 6px;font-size:11px}
    .igpa-badge .exp{font-size:10px;opacity:.85;display:block;margin-top:2px}
    .igpa-pill{display:inline-block;padding:2px 6px;border-radius:999px;background:var(--igpa-elev);margin:2px;font-size:11px;border:1px solid var(--igpa-border)}
    .igpa-btn{padding:6px 10px;background:var(--igpa-elev);border:1px solid var(--igpa-border);border-radius:8px;cursor:pointer;color:var(--igpa-text)}
    .igpa-btn:hover{filter:brightness(1.05)}
    .igpa-muted{filter:grayscale(.25) brightness(.8);opacity:.55}
    .igpa-blur{filter:blur(2px) grayscale(.2) brightness(.9)}
    .igpa-highlight{outline:2px solid rgba(88,199,250,.85);outline-offset:-2px;box-shadow:0 0 0 2px rgba(88,199,250,.35) inset;border-radius:8px}
    .igpa-chip{display:inline-flex;gap:6px;align-items:center;padding:4px 8px;background:var(--igpa-elev);border:1px solid var(--igpa-border);border-radius:999px;margin:3px}
    .igpa-small{font-size:12px;opacity:.8}
    .igpa-session{display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:center}
    .igpa-kv{display:grid;grid-template-columns:170px 1fr;gap:6px}
    .igpa-colorchip{display:inline-block;width:12px;height:12px;border-radius:3px;border:1px solid rgba(255,255,255,.2);margin-left:6px}
    .igpa-badge .mini-btn{margin-left:6px;padding:0 6px;border-radius:6px;background:var(--igpa-elev);border:1px solid var(--igpa-border);cursor:pointer}
    .igpa-badge .pct{font-weight:700}
    .igpa-resize{position:absolute;right:6px;bottom:6px;width:14px;height:14px;border-right:2px solid var(--igpa-sub);border-bottom:2px solid var(--igpa-sub);cursor:nwse-resize;opacity:.7}
    .igpa-search{display:flex;gap:8px;margin:6px 0}
    .igpa-search input{flex:1;padding:6px 8px;border-radius:8px;border:1px solid var(--igpa-border);background:var(--igpa-elev);color:var(--igpa-text)}
    .igpa-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px}
    .igpa-badge-debug{position:absolute;left:8px;top:8px;padding:2px 6px;font-size:10px;background:rgba(0,0,0,.5);color:#fff;border-radius:6px;z-index:${CONFIG.ui.zIndex - 1}}
    @media (max-width: 900px){
      .igpa-panel{right:12px;left:12px !important;width:auto !important}
      .igpa-range{width:50%}
      .igpa-kv{grid-template-columns:140px 1fr}
    }
  `);

  /********************
   * PANEL + FAB (resizable, responsive, theme)
   ********************/
  let fab = null, panel = null, bodyEl=null;
  function setThemeAttr(){
    let theme = STATE.ui.theme;
    if (theme==='auto'){
      const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
      theme = prefersDark ? 'dark' : 'light';
    }
    document.documentElement.setAttribute('data-igpa-theme', theme==='dark'?'dark':'light');
  }

  function buildUI(){
    if (fab && panel) return;

    // FAB
    fab = document.createElement('div');
    fab.className = 'igpa-fab';
    fab.title = 'Preference AI & Insights';
    fab.textContent = '★';
    fab.setAttribute('role','button');
    fab.setAttribute('aria-label','Open Instagram Preference AI panel');
    fab.tabIndex = 0;
    fab.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fab.click(); });
    document.documentElement.appendChild(fab);

    // Panel
    panel = document.createElement('div');
    panel.className = 'igpa-panel';
    const w = STATE.panel.w || CONFIG.ui.defaultPanelWidth;
    const h = STATE.panel.h || CONFIG.ui.defaultPanelHeight;
    panel.style.width = w+'px';
    panel.style.height = h+'px';
    if (STATE.panel.x!=null) panel.style.left = STATE.panel.x+'px';
    if (STATE.panel.y!=null) panel.style.top = STATE.panel.y+'px';

    panel.innerHTML=`
      <div class="igpa-titlebar">
        <div class="title"><strong>IG Preference AI — Ultra</strong> <span class="igpa-small">v${CONFIG.version}</span></div>
        <div class="window-btns">
          <button class="igpa-btn" data-act="minimize">${STATE.panel.minimized?'Restore':'Minimize'}</button>
          <button class="igpa-btn" data-act="next">Next High-Score</button>
          <button class="igpa-btn" data-act="close">✕</button>
        </div>
      </div>
      <div class="igpa-tabs">
        <div class="igpa-tab active" data-tab="overview">Overview</div>
        <div class="igpa-tab" data-tab="session">Session</div>
        <div class="igpa-tab" data-tab="weights">Weights</div>
        <div class="igpa-tab" data-tab="prefs">Pins/Bans</div>
        <div class="igpa-tab" data-tab="settings">Settings</div>
        <div class="igpa-tab" data-tab="data">Data</div>
        <div class="igpa-tab" data-tab="help">Help</div>
      </div>
      <div class="igpa-body"></div>
      <div class="igpa-resize" title="Resize"></div>
    `;
    document.documentElement.appendChild(panel);

    bodyEl = panel.querySelector('.igpa-body');

    // Drag panel
    const titlebar = panel.querySelector('.igpa-titlebar');
    let dragging=false,sx=0,sy=0,px=0,py=0;
    titlebar.addEventListener('mousedown',(e)=>{ if ((e.target instanceof HTMLElement) && e.target.closest('.window-btns')) return; dragging=true; sx=e.clientX; sy=e.clientY; const r=panel.getBoundingClientRect(); px=r.left; py=r.top; e.preventDefault(); });
    document.addEventListener('mousemove',(e)=>{ if(!dragging) return; const nx=px+(e.clientX-sx), ny=py+(e.clientY-sy); panel.style.right='auto'; panel.style.bottom='auto'; panel.style.left=`${nx}px`; panel.style.top=`${ny}px`; });
    document.addEventListener('mouseup',()=>{ if (!dragging) return; dragging=false; persistPanelPos(); });

    // Resize
    const resizer = panel.querySelector('.igpa-resize');
    let resizing=false, rw=0, rh=0, rsx=0, rsy=0;
    resizer.addEventListener('mousedown',(e)=>{ resizing=true; rsx=e.clientX; rsy=e.clientY; const r=panel.getBoundingClientRect(); rw=r.width; rh=r.height; e.preventDefault(); e.stopPropagation(); });
    document.addEventListener('mousemove',(e)=>{
      if(!resizing) return;
      let nw = Math.max(CONFIG.ui.minPanelWidth, rw + (e.clientX - rsx));
      let nh = Math.max(CONFIG.ui.minPanelHeight, rh + (e.clientY - rsy));
      panel.style.width = nw+'px';
      panel.style.height = nh+'px';
    });
    document.addEventListener('mouseup',()=>{ if(!resizing) return; resizing=false; persistPanelPos(); });

    // Window controls
    function togglePanel(force){ const show=(force!==undefined)?force:!panel.classList.contains('show'); panel.classList.toggle('show',show); if (!show) STATE.panel.minimized=false; persist(); }
    fab.addEventListener('click',()=>togglePanel(true));
    panel.querySelector('[data-act="close"]').addEventListener('click',()=>togglePanel(false));
    panel.querySelector('[data-act="next"]').addEventListener('click',()=>scrollToNextHighScore());
    panel.querySelector('[data-act="minimize"]').addEventListener('click',()=>{
      STATE.panel.minimized = !STATE.panel.minimized;
      if (STATE.panel.minimized){ panel.style.height = '48px'; bodyEl.style.display='none'; }
      else { panel.style.height = (STATE.panel.h || CONFIG.ui.defaultPanelHeight)+'px'; bodyEl.style.display='block'; }
      persist();
    });

    if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand('Open IG Preference Panel',()=>togglePanel(true));

    document.addEventListener('keydown',(e)=>{
      const target = e.target;
      if (target && (/input|textarea|select/i).test(target.tagName)) return;
      if (!location.hostname.includes('instagram.com')) return;
      if(e.ctrlKey&&e.shiftKey&&e.code==='KeyI') togglePanel();
      if(e.code==='KeyJ'){ jumpHighScore('next'); }
      if(e.code==='KeyK'){ jumpHighScore('prev'); }
      if(e.code==='KeyH'){ STATE.ui.autoSkipLow=!STATE.ui.autoSkipLow; persist(); tip(`Auto-skip ${STATE.ui.autoSkipLow?'ON':'OFF'}`); }
    });

    panel.querySelectorAll('.igpa-tab').forEach(tab=>{
      tab.addEventListener('click',()=>{
        panel.querySelectorAll('.igpa-tab').forEach(t=>t.classList.remove('active'));
        tab.classList.add('active');
        renderTab(tab.getAttribute('data-tab'));
      });
    });

    if (STATE.panel.minimized){ bodyEl.style.display='none'; panel.style.height='48px'; }

    setThemeAttr();
    renderTab('overview');
    if (!panel.classList.contains('show')) panel.classList.add('show');
  }

  function persistPanelPos(){
    try{
      const r = panel.getBoundingClientRect();
      STATE.panel.x = r.left; STATE.panel.y = r.top; STATE.panel.w = r.width; STATE.panel.h = r.height;
      persist();
    } catch {}
  }

  /********************
   * RENDER TABS
   ********************/
  function renderTab(name){
    if (!panel || !bodyEl) return;

    if (name==='overview'){
      const topTags=topN(weightSubset('tag:'),12);
      const topUsers=topN(weightSubset('user:'),12);
      const topKWs=topN(weightSubset('kw:'),12);
      bodyEl.innerHTML=`
        <div class="igpa-kv">
          <div>Status</div><div>${STATE.status.observing?'Observing ✅':'Idle ⏸️'} ${STATE.status.lastError?`<span class="igpa-small" style="color:#f88">(${STATE.status.lastError})</span>`:''}</div>
          <div>Events</div><div>${STATE.stats.events}</div>
          <div>Labels</div><div><span class="igpa-pill">≥0.5: ${STATE.stats.positives}</span> <span class="igpa-pill">&lt;0.5: ${STATE.stats.negatives}</span></div>
          <div>Watch-time</div><div>${STATE.stats.totalWatchSec.toFixed(1)}s — <span class="igpa-small">vid ${STATE.stats.totalVideoPlaySec.toFixed(1)}s / img ${STATE.stats.totalImageExposureSec.toFixed(1)}s</span></div>
          <div>Active LR</div><div>${CONFIG.learn.lr.toFixed(2)} (A:${CONFIG.learn.lrA}, B:${CONFIG.learn.lrB})</div>
          <div>Theme</div><div><span class="igpa-pill">${STATE.ui.theme}</span></div>
          <div>Learning</div><div><span class="igpa-pill">${STATE.ui.pauseLearning?'Paused ⏸️':'Active ▶️'}</span></div>
        </div>
        <h4>Top Keywords</h4>
        <div class="igpa-grid">${topKWs.map(([k,w])=>`<span class="igpa-chip">${k.slice(3)} <span class="igpa-small">${w.toFixed(2)}</span></span>`).join('')}</div>
        <h4>Top Hashtags</h4>
        <div class="igpa-grid">${topTags.map(([k,w])=>`<span class="igpa-chip">#${k.slice(4)} <span class="igpa-small">${w.toFixed(2)}</span></span>`).join('')}</div>
        <h4>Top Creators</h4>
        <div class="igpa-grid">${topUsers.map(([k,w])=>`<span class="igpa-chip">@${k.slice(5)} <span class="igpa-small">${w.toFixed(2)}</span></span>`).join('')}</div>
      `;
    }

    else if (name==='session'){
      const last=STATE.history.slice(-200).reverse();
      bodyEl.innerHTML = last.map(ev=>{
        const t=new Date(ev.ts).toLocaleTimeString();
        const lbl=(ev.label>=0.999?'Positive':(ev.label<=0.001?'Negative':`Label ${ev.label.toFixed(2)}`));
        const ft=(ev.feats||[]).map(f=>`${prettyFeat(f.k)}:${(f.contrib||0).toFixed(2)}`).join(' · ');
        const color=ev.label>=0.5?'#38b000':'#d00000';
        return `
          <div class="igpa-session" style="border-bottom:1px solid var(--igpa-border);padding:6px 0;">
            <div class="igpa-small" style="opacity:.7">${t}</div>
            <div><span class="igpa-pill">${lbl}</span>
              <span class="igpa-small">via ${ev.via||'watch'}</span>
              <span class="igpa-small">pred ${(ev.pred||0).toFixed(2)}, loss ${(ev.loss||0).toFixed(3)}, arm ${ev.arm||'-'} (lr ${ev.lr?ev.lr.toFixed(2):'-'})</span>
              <div class="igpa-small" style="margin-top:2px;color:${color}">${ft}</div>
            </div>
            <div class="igpa-small" style="text-align:right">${ev.seconds!=null?`${ev.seconds.toFixed(1)}s`:''}</div>
          </div>
        `;
      }).join('') || '<p class="igpa-small">No events yet — scroll or interact to generate data.</p>';
      const bar = document.createElement('div');
      bar.className = 'igpa-search';
      bar.innerHTML = `<button class="igpa-btn" data-export="csv">Export CSV</button>`;
      bodyEl.prepend(bar);
      bar.querySelector('[data-export="csv"]').onclick=exportHistoryCSV;
    }

    else if (name==='weights'){
      bodyEl.innerHTML = `
        <div class="igpa-search">
          <input type="text" id="igpa-wsearch" placeholder="Search @creator, #hashtag, keyword, feature..." />
          <button class="igpa-btn" id="igpa-zero">Zero Selected</button>
        </div>
        <div id="igpa-wlist"></div>
      `;
      const list = bodyEl.querySelector('#igpa-wlist');
      const input = bodyEl.querySelector('#igpa-wsearch');
      const renderList = ()=>{
        const q = (input.value||'').trim().toLowerCase();
        const items = Object.entries(STATE.weights).filter(([k])=>k!=='__bias' && (q? k.toLowerCase().includes(q) : true));
        items.sort((a,b)=>Math.abs(b[1]) - Math.abs(a[1]));
        list.innerHTML = items.slice(0, 400).map(([k,w])=>`
          <div class="igpa-row" data-key="${k}">
            <div style="max-width:60%">${prettyFeat(k)}</div>
            <div class="igpa-small">${w.toFixed(3)}</div>
            <div>
              <button class="igpa-btn" data-delta="0.05">+0.05</button>
              <button class="igpa-btn" data-delta="-0.05">-0.05</button>
              <button class="igpa-btn" data-delta="zero">Zero</button>
            </div>
          </div>`).join('') || '<p class="igpa-small">No weights (try training first).</p>';
        list.querySelectorAll('.igpa-row .igpa-btn').forEach(btn=>{
          btn.onclick=()=>{
            const row = btn.closest('.igpa-row'); const key=row.getAttribute('data-key'); const d=btn.getAttribute('data-delta');
            if (d==='zero') STATE.weights[key]=0; else STATE.weights[key]=(STATE.weights[key]||0)+Number(d);
            persist(); renderList();
          };
        });
      };
      input.oninput=renderList;
      bodyEl.querySelector('#igpa-zero').onclick=()=>{
        const q = (input.value||'').trim().toLowerCase(); if (!q) return;
        Object.keys(STATE.weights).forEach(k=>{ if (k!=='__bias' && k.toLowerCase().includes(q)) STATE.weights[k]=0; });
        persist(); renderList();
      };
      renderList();
    }

    else if (name==='prefs'){
      const creators = Object.keys(STATE.pins.creators).map(u=>['@'+u, 'pin']);
      const creatorsB = Object.keys(STATE.bans.creators).map(u=>['@'+u, 'ban']);
      const tags = Object.keys(STATE.pins.hashtags).map(t=>['#'+t, 'pin']);
      const tagsB = Object.keys(STATE.bans.hashtags).map(t=>['#'+t, 'ban']);
      const kws = Object.keys(STATE.pins.keywords).map(k=>[k, 'pin']);
      const kwsB = Object.keys(STATE.bans.keywords).map(k=>[k, 'ban']);
      bodyEl.innerHTML = `
        <div class="igpa-search">
          <input type="text" id="igpa-pref-key" placeholder="Add @creator, #hashtag or keyword" />
          <select id="igpa-pref-type">
            <option value="pin">Pin / Boost</option>
            <option value="ban">Ban / Mute</option>
          </select>
          <button class="igpa-btn" id="igpa-pref-add">Add</button>
        </div>
        <h4>Pinned</h4>
        <div id="igpa-pref-pins" class="igpa-grid"></div>
        <h4>Banned</h4>
        <div id="igpa-pref-bans" class="igpa-grid"></div>
      `;
      const renderPrefLists = ()=>{
        const pins = [...creators, ...tags, ...kws].map(([v])=>renderPrefChip(v, 'pin')).join('');
        const bans = [...creatorsB, ...tagsB, ...kwsB].map(([v])=>renderPrefChip(v, 'ban')).join('');
        bodyEl.querySelector('#igpa-pref-pins').innerHTML = pins || '<p class="igpa-small">No pins.</p>';
        bodyEl.querySelector('#igpa-pref-bans').innerHTML = bans || '<p class="igpa-small">No bans.</p>';
        bodyEl.querySelectorAll('[data-del]').forEach(btn=>{
          btn.onclick=()=>{
            const v = btn.getAttribute('data-del'); const kind=btn.getAttribute('data-kind');
            deletePref(v, kind); renderTab('prefs');
          };
        });
      };
      function renderPrefChip(val, kind){
        return `<span class="igpa-chip">${val} <button class="igpa-btn" data-kind="${kind}" data-del="${val}" title="Remove">✕</button></span>`;
      }
      function deletePref(v, kind){
        if (v.startsWith('@')){ const u = v.slice(1).toLowerCase(); if (kind==='pin') delete STATE.pins.creators[u]; else delete STATE.bans.creators[u]; }
        else if (v.startsWith('#')){ const t = v.slice(1).toLowerCase(); if (kind==='pin') delete STATE.pins.hashtags[t]; else delete STATE.bans.hashtags[t]; delete STATE.mutedHashtags[t]; }
        else { const k = v.toLowerCase(); if (kind==='pin') delete STATE.pins.keywords[k]; else delete STATE.bans.keywords[k]; }
        persist();
      }
      bodyEl.querySelector('#igpa-pref-add').onclick=()=>{
        const raw = bodyEl.querySelector('#igpa-pref-key').value.trim();
        const kind = bodyEl.querySelector('#igpa-pref-type').value;
        if (!raw) return;
        if (raw.startsWith('@')){ const u = raw.slice(1).toLowerCase(); (kind==='pin'?STATE.pins.creators:STATE.bans.creators)[u]=true; }
        else if (raw.startsWith('#')){ const t = raw.slice(1).toLowerCase(); (kind==='pin'?STATE.pins.hashtags:STATE.bans.hashtags)[t]=true; if (kind==='ban') STATE.mutedHashtags[t]=true; }
        else { const k = raw.toLowerCase(); (kind==='pin'?STATE.pins.keywords:STATE.bans.keywords)[k]=true; }
        bodyEl.querySelector('#igpa-pref-key').value=''; persist(); renderTab('prefs');
      };
      renderPrefLists();
    }

    else if (name==='settings'){
      bodyEl.innerHTML=`
        <div class="igpa-row"><div>Theme</div>
          <div>
            <select id="igpa-theme">
              <option value="auto">Auto</option>
              <option value="dark">Dark</option>
              <option value="light">Light</option>
            </select>
          </div>
        </div>

        ${sliderRow('Learning rate A','lrA',CONFIG.learn.lrA,0.02,0.35,0.01)}
        ${sliderRow('Learning rate B','lrB',CONFIG.learn.lrB,0.02,0.35,0.01)}
        ${sliderRow('Low-score threshold','lowScoreThreshold',STATE.ui.lowScoreThreshold,0.05,0.85,0.05)}
        ${sliderRow('High-score threshold','highScoreThreshold',STATE.ui.highScoreThreshold,0.5,0.98,0.01)}
        ${sliderRow('Watch cap (sec)','maxWatchImpactSec',CONFIG.learn.maxWatchImpactSec,10,90,5)}
        ${sliderRow('Image watch weight','watchWeightImage',CONFIG.learn.watchWeightImage,0.4,1.6,0.05)}
        ${sliderRow('Video watch weight','watchWeightVideo',CONFIG.learn.watchWeightVideo,0.4,1.8,0.05)}

        ${toggleRow('Show score badge','showBadge',STATE.ui.showBadge)}
        ${toggleRow('Compact badge','compactBadge',STATE.ui.compactBadge)}
        ${toggleRow('Explain top features','showExplain',STATE.ui.showExplain)}
        ${toggleRow('Dim low-score posts','dimLowScore',STATE.ui.dimLowScore)}
        ${toggleRow('Blur low-score posts','blurLowScore',STATE.ui.blurLowScore)}
        ${toggleRow('Highlight high-score posts','highlightHighScore',STATE.ui.highlightHighScore)}
        ${toggleRow('Eye-comfort: blur sponsored/muted','eyeComfort',STATE.ui.eyeComfort)}
        ${toggleRow('Creator mixer (cap influence)','capCreator',STATE.ui.capCreator)}
        ${toggleRow('Pause learning','pauseLearning',STATE.ui.pauseLearning)}
        ${toggleRow('Debug overlay','showDebugOverlay',STATE.ui.showDebugOverlay)}
        ${toggleRow('Auto-skip low-score','autoSkipLow',STATE.ui.autoSkipLow)}
        <div class="igpa-row"><div>Auto-skip delay (ms)</div><input class="igpa-range" data-key="autoSkipDelayMs" type="range" min="250" max="2000" step="50" value="${STATE.ui.autoSkipDelayMs}"><div class="igpa-small">${STATE.ui.autoSkipDelayMs}</div></div>
        <div class="igpa-row"><div>Autoskip jitter max (ms)</div><input class="igpa-range" data-key="autoskipJitterMax" type="range" min="0" max="1000" step="20" value="${STATE.ui.autoskipJitterMax}"><div class="igpa-small">${STATE.ui.autoskipJitterMax}</div></div>

        <hr/>
        <div class="igpa-row">
          <button class="igpa-btn" id="igpa-hide-low">Hide visible low-score</button>
          <button class="igpa-btn" id="igpa-show-all">Show all</button>
        </div>
        <p class="igpa-small" style="opacity:.8">Note: Auto-scrolling may be considered automated behavior. Use responsibly.</p>
      `;
      const themeSel = bodyEl.querySelector('#igpa-theme'); themeSel.value = STATE.ui.theme;
      themeSel.onchange = ()=>{ STATE.ui.theme = themeSel.value; persist(); setThemeAttr(); };
      const setHandlers = ()=>{
        const ranges = bodyEl.querySelectorAll('.igpa-range');
        ranges.forEach(r=>{
          r.addEventListener('input', ()=>{
            const key=r.getAttribute('data-key');
            const val=Number(r.value);
            const label=r.closest('.igpa-row')?.querySelector('.igpa-small');
            if (label) label.textContent = (key==='autoSkipDelayMs' || key==='autoskipJitterMax') ? val : val.toFixed(2);
            if (key==='lrA') CONFIG.learn.lrA=val;
            else if (key==='lrB') CONFIG.learn.lrB=val;
            else if (key==='lowScoreThreshold'){ STATE.ui.lowScoreThreshold=val; persist(); applyVisualFilters(); }
            else if (key==='highScoreThreshold'){ STATE.ui.highScoreThreshold=val; persist(); applyVisualFilters(); }
            else if (key==='autoSkipDelayMs'){ STATE.ui.autoSkipLow && (STATE.ui.autoSkipDelayMs=val); STATE.ui.autoSkipDelayMs=val; persist(); }
            else if (key==='autoskipJitterMax'){ STATE.ui.autoskipJitterMax=val; persist(); }
            else if (key==='maxWatchImpactSec'){ CONFIG.learn.maxWatchImpactSec=val; persist(); }
            else if (key==='watchWeightImage'){ CONFIG.learn.watchWeightImage=val; persist(); }
            else if (key==='watchWeightVideo'){ CONFIG.learn.watchWeightVideo=val; persist(); }
          });
        });
        const checks = bodyEl.querySelectorAll('input[type="checkbox"][data-key]');
        checks.forEach(c=>{
          c.addEventListener('change', ()=>{
            const k=c.getAttribute('data-key'); STATE.ui[k] = !!c.checked; persist(); DEBUG = !!STATE.ui.showDebugOverlay; setThemeAttr(); applyVisualFilters();
          });
        });
        const hideBtn = bodyEl.querySelector('#igpa-hide-low');
        const showBtn = bodyEl.querySelector('#igpa-show-all');
        if (hideBtn) hideBtn.onclick=()=>applyVisualFilters(true);
        if (showBtn) showBtn.onclick=()=>applyVisualFilters(false,true);
      };
      setHandlers();
    }

    else if (name==='data'){
      bodyEl.innerHTML=`
        <div class="igpa-row"><button class="igpa-btn" id="igpa-export">Export JSON</button><span class="igpa-small">Backup model & prefs</span></div>
        <div class="igpa-row"><input type="file" id="igpa-import" accept="application/json"/><span class="igpa-small">Import JSON</span></div>
        <div class="igpa-row"><button class="igpa-btn" id="igpa-reset">Reset model</button><span class="igpa-small">Start fresh</span></div>
        <h4>Snapshots</h4>
        <div class="igpa-row"><input id="igpa-snap-name" placeholder="snapshot name" /><button class="igpa-btn" id="igpa-snap-save">Save</button></div>
        <div id="igpa-snap-list"></div>
      `;
      const exp = bodyEl.querySelector('#igpa-export');
      const imp = bodyEl.querySelector('#igpa-import');
      const rst = bodyEl.querySelector('#igpa-reset');
      const save= bodyEl.querySelector('#igpa-snap-save');
      if (exp) exp.onclick=exportJSON;
      if (imp) imp.onchange=(e)=>importJSON(e.target.files[0]);
      if (rst) rst.onclick=resetAll;
      if (save) save.onclick=saveSnapshot;
      renderSnapshots();
    }

    else if (name==='help'){
      bodyEl.innerHTML=`
        <p>Local-only learner. Topic model (stemmed keywords) + hashtags + creator + media type + <strong>watch-time</strong> (images & videos) with continuous labels.</p>
        <ul>
          <li><strong>Shortcuts:</strong> J/K next/prev high-score, H auto-skip toggle, Ctrl+Shift+I panel.</li>
          <li><strong>Training:</strong> watch-time maps to label [0..1]; like/save adds a small boost. Tiny exposure &lt; ${CONFIG.learn.minNegWatchSec}s → negative; ≥ ${CONFIG.learn.minPosWatchSec}s → positive.</li>
          <li><strong>Badge actions:</strong> pin/ban creator, boost/mute tags/keywords.</li>
          <li><strong>Overexposure dampener:</strong> softly reduces weight impact when the same creator dominates recent feed.</li>
        </ul>
      `;
    }
  }

  function sliderRow(label,key,val,min,max,step){
    const shown = (typeof val==='number') ? val.toFixed(2) : String(val);
    return `<div class="igpa-row"><div>${label}</div><input class="igpa-range" data-key="${key}" type="range" min="${min}" max="${max}" step="${step}" value="${val}"><div class="igpa-small">${shown}</div></div>`;
  }
  function toggleRow(label,key,val){
    return `<div class="igpa-row"><div>${label}</div><input type="checkbox" data-key="${key}" ${val?'checked':''}></div>`;
  }

  /********************
   * BADGE + QUICK ACTIONS + DEBUG OVERLAY
   ********************/
  function currentMeta(el){
    const creator=(getUsername(el)||'unknown');
    const caption=getCaptionText(el);
    const tags=extractHashtags(caption);
    const kws=tokenize(caption);
    return {creator,tags,kws};
  }

  function explainTop(el){
    try{
      const feats=featuresFromPost(el);
      const contribs=Object.entries(feats).map(([k,v])=>[k,(STATE.weights[k]||0)*v]).sort((a,b)=>Math.abs(b[1])-Math.abs(a[1])).slice(0,2);
      return contribs.map(([k,w])=>{
        if(k.startsWith('tag:')) return `#${k.slice(4)}:${w.toFixed(2)}`;
        if(k.startsWith('user:')) return `@${k.slice(5)}:${w.toFixed(2)}`;
        if(k.startsWith('kw:')) return `${k.slice(3)}:${w.toFixed(2)}`;
        return `${k}:${w.toFixed(2)}`;
      }).join(' · ');
    } catch { return ''; }
  }

  function paintBadge(el){
    try{
      let badge = el.querySelector(':scope > .igpa-badge');
      const score = predictScore(el);
      if (!STATE.ui.showBadge){ if (badge) badge.remove(); return; }

      if (!badge){
        badge = document.createElement('div');
        badge.className = 'igpa-badge';
        if (!el.style.position || el.style.position==='static') el.style.position='relative';
        el.appendChild(badge);
      }
      badge.classList.toggle('compact', !!STATE.ui.compactBadge);
      const pct = Math.round(score*100);
      const explain = STATE.ui.showExplain ? explainTop(el) : '';
      const color = score>=STATE.ui.highScoreThreshold?'#58c7fa':(score<STATE.ui.lowScoreThreshold?'#999':'#ddd');
      const meta = currentMeta(el);

      const main = STATE.ui.compactBadge
        ? `<span class="pct" style="color:${color}">${pct}</span>`
        : `<span class="pct" style="color:${color}">${pct}</span> <span class="igpa-small">% match</span>
           ${explain?`<span class="exp">${explain}</span>`:''}
           <div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:4px;">
             <button class="mini-btn" data-act="pin-user" title="Pin creator">@${meta.creator} ⊕</button>
             <button class="mini-btn" data-act="ban-user" title="Ban creator">@${meta.creator} ⊖</button>
             ${meta.tags.slice(0,2).map(t=>`<button class="mini-btn" data-act="boost-tag" data-tag="${t}">#${t} ⊕</button><button class="mini-btn" data-act="mute-tag" data-tag="${t}">#${t} ⊖</button>`).join('')}
             ${meta.kws.slice(0,2).map(s=>`<button class="mini-btn" data-act="boost-kw" data-kw="${s}">${s} ⊕</button><button class="mini-btn" data-act="mute-kw" data-kw="${s}">${s} ⊖</button>`).join('')}
           </div>`;

      badge.innerHTML = main;

      badge.onclick=(ev)=>{
        const btn = ev.target.closest('.mini-btn'); if (!btn) return;
        const act = btn.getAttribute('data-act');
        if (act==='pin-user'){ STATE.pins.creators[meta.creator]=true; persist(); tip(`Pinned @${meta.creator}`); }
        else if (act==='ban-user'){ STATE.bans.creators[meta.creator]=true; persist(); tip(`Banned @${meta.creator}`); }
        else if (act==='boost-tag'){ const tag=btn.getAttribute('data-tag'); STATE.pins.hashtags[tag]=true; persist(); tip(`Boosted #${tag}`); }
        else if (act==='mute-tag'){ const tag=btn.getAttribute('data-tag'); STATE.mutedHashtags[tag]=true; STATE.bans.hashtags[tag]=true; persist(); tip(`Muted #${tag}`); applyVisualFilters(); }
        else if (act==='boost-kw'){ const kw=btn.getAttribute('data-kw'); STATE.pins.keywords[kw]=true; persist(); tip(`Boosted "${kw}"`); }
        else if (act==='mute-kw'){ const kw=btn.getAttribute('data-kw'); STATE.bans.keywords[kw]=true; persist(); tip(`Muted "${kw}"`); }
        ev.stopPropagation();
      };

      // Filters (including eye-comfort)
      const caption = getCaptionText(el);
      const tags = extractHashtags(caption);
      const muted = tags.some(t=>STATE.mutedHashtags[t]);
      const sponsored = likelySponsored(el);

      el.classList.remove('igpa-muted','igpa-blur','igpa-highlight');
      if (STATE.ui.dimLowScore && score < STATE.ui.lowScoreThreshold) el.classList.add('igpa-muted');
      if ((STATE.ui.blurLowScore && score < STATE.ui.lowScoreThreshold) || (STATE.ui.eyeComfort && (muted||sponsored))) el.classList.add('igpa-blur');
      if (STATE.ui.highlightHighScore && score >= STATE.ui.highScoreThreshold) el.classList.add('igpa-highlight');

      // Debug overlay (intersection ratio / timers)
      drawDebugOverlay(el);

      tryColorStats(el, badge);
    } catch (e) { STATE.status.lastError = 'badge:'+String(e); }
  }

  function drawDebugOverlay(el){
    try{
      let dbg = el.querySelector(':scope > .igpa-badge-debug');
      if (!STATE.ui.showDebugOverlay){ if (dbg) dbg.remove(); return; }
      if (!dbg){ dbg = document.createElement('div'); dbg.className='igpa-badge-debug'; el.appendChild(dbg); }
      const r = el.getBoundingClientRect();
      const id = getPostIdFromEl(el);
      const rec = activeTimers.get(id);
      const vids = el.querySelectorAll(SELECTORS.video);
      let vsecs = 0;
      vids.forEach(v=>{ const tr=videoTrackers.get(v); if (tr){ vsecs += tr.playAccum + (tr.playing ? (now()-tr.lastTick)/1000 : 0); } });
      const secs = (rec ? (rec.accum + (rec.start ? (now()-rec.start)/1000 : 0)) : 0).toFixed(1);
      dbg.textContent = `vis:${Math.max(0, Math.min(1, r.height ? (Math.min(window.innerHeight, r.bottom) - Math.max(0, r.top)) / r.height : 0)).toFixed(2)} img:${secs}s vid:${vsecs.toFixed(1)}s`;
    } catch {}
  }

  /********************
   * FEED OBSERVERS + COLOR CHIP
   ********************/
  let mo = null;
  function ensureMO(){
    if (mo) return mo;
    mo = new MutationObserver((muts)=>{
      try{
        const added = [];
        for (const m of muts){
          for (const node of m.addedNodes){
            if (node.nodeType !== 1) continue;
            const el = /** @type {HTMLElement} */ (node);
            if (isPost(el)) added.push(el);
            if (el.children && el.children.length < 120){
              el.querySelectorAll?.(SELECTORS.article).forEach(x=>{ if (isPost(x)) added.push(x); });
            }
          }
        }
        const seen = new Set();
        for (const el of added){
          if (seen.has(el)) continue; seen.add(el); preparePost(el);
        }
      } catch (e){ STATE.status.lastError = 'mo:'+String(e); }
    });
    return mo;
  }

  function preparePost(el){
    try{
      if (el.dataset && el.dataset.igpaReady==='1') return;
      if (el.dataset) el.dataset.igpaReady='1';
      ensureIO().observe(el);
      paintBadge(el);
      attachFooter(el);
      attachVideoTracker(el, getPostIdFromEl(el));
      STATE.status.observing = true;
    } catch (e) { STATE.status.lastError = 'prep:'+String(e); }
  }

  function attachFooter(el){
    try{
      const footer=document.createElement('div');
      footer.style.position='absolute';
      footer.style.left='8px'; footer.style.bottom='8px';
      footer.style.zIndex=String(CONFIG.ui.zIndex - 1);
      footer.innerHTML=`<button class="igpa-btn" data-train="up">👍</button> <button class="igpa-btn" data-train="down">👎</button> <button class="igpa-btn" data-hide="1">Hide</button>`;
      el.appendChild(footer);
      footer.addEventListener('click',(e)=>{
        const t=e.target; if(!(t instanceof HTMLElement)) return;
        if(t.dataset.train==='up' || t.dataset.train==='down'){
          const id=getPostIdFromEl(el);
          let sec = 0;
          const rec = activeTimers.get(id);
          if (rec && rec.start){ sec += (now()-rec.start)/1000; rec.start = null; }
          el.querySelectorAll(SELECTORS.video).forEach(v=>{
            const tr = videoTrackers.get(v);
            if (!tr) return;
            if (tr.playing){ tr.playAccum += (now()-tr.lastTick)/1000; tr.playing = false; }
            sec += tr.playAccum; tr.playAccum = 0;
          });
          const base = t.dataset.train==='up' ? 1 : 0;
          const label = clamp01(Math.max(base, normalizedWatchLabel(sec, getPostType(el))));
          const feats=featuresFromPost(el, { watchBucket: watchBucket(sec) });
          const upd=sgdUpdate(feats,label);
          const creator = getUsername(el)||'unknown';
          if (label>=0.5) STATE.stats.positives++; else STATE.stats.negatives++;
          addHistory({ ts:Date.now(), id, metaCreator: creator, label, via:'thumb', seconds:sec||null, pred:upd.pred, loss:upd.loss, arm:upd.arm, lr:upd.lrUsed, feats:pickTopFeats(feats) });
          persist(); paintBadge(el);
        } else if(t.dataset.hide==='1'){ el.style.display='none'; }
        e.stopPropagation();
      });
    } catch {}
  }

  const COLOR_CACHE_MAX = 900;
  function vacuumColorCache(){
    const keys = Object.keys(STATE.colorCache);
    if (keys.length <= COLOR_CACHE_MAX) return;
    keys.sort((a,b)=>STATE.colorCache[a].ts - STATE.colorCache[b].ts);
    for (let i=0;i<keys.length - COLOR_CACHE_MAX;i++) delete STATE.colorCache[keys[i]];
  }

  function tryColorStats(el, badge){
    try{
      const img = el.querySelector('img');
      if (!img || !img.src) return;
      const key = hashStr(img.src);
      if (STATE.colorCache[key]){ addChip(STATE.colorCache[key].avg); return; }
      const onload=()=>{
        try{
          const sameOrigin = img.src.startsWith(location.origin) || img.crossOrigin === 'anonymous';
          if (!sameOrigin) return;
          const canvas=document.createElement('canvas'), ctx=canvas.getContext('2d');
          const w=Math.min(48,img.naturalWidth||48), h=Math.min(48,img.naturalHeight||48);
          canvas.width=w; canvas.height=h; ctx.drawImage(img,0,0,w,h);
          const data=ctx.getImageData(0,0,w,h).data;
          let r=0,g=0,b=0,n=0; for(let i=0;i<data.length;i+=4){ r+=data[i]; g+=data[i+1]; b+=data[i+2]; n++; }
          const avg=[Math.round(r/n),Math.round(g/n),Math.round(b/n)];
          STATE.colorCache[key]={avg,ts:Date.now()}; vacuumColorCache(); persist(); addChip(avg);
        }catch(e){ /* tainted canvas; ignore */ }
      };
      function addChip(avg){
        try{
          const holder = badge || el.querySelector(':scope > .igpa-badge');
          if(!holder) return;
          const chip=document.createElement('span'); chip.className='igpa-colorchip'; chip.style.background=`rgb(${avg.join(',')})`;
          holder.appendChild(chip);
        }catch{}
      }
      if (img.complete) onload(); else img.addEventListener('load', onload, {once:true});
    } catch {}
  }

  /********************
   * NAV
   ********************/
  function jumpHighScore(dir='next'){
    try{
      const posts=[...document.querySelectorAll(SELECTORS.article)].filter(isPost);
      const y=window.scrollY;
      const candidates = posts.map(el=>({ el, score: predictScore(el), top: el.getBoundingClientRect().top + window.scrollY }))
        .filter(p=>p.score>=STATE.ui.highScoreThreshold)
        .sort((a,b)=>a.top-b.top);
      if(!candidates.length){ tip('No high-score posts in view.'); return; }
      if(dir==='next'){
        const cand=candidates.find(p=>p.top>y+60) || candidates[0];
        window.scrollTo({ top:cand.top-80, behavior:'smooth' });
      }else{
        const cand=[...candidates].reverse().find(p=>p.top<y-60) || candidates[candidates.length-1];
        window.scrollTo({ top:cand.top-80, behavior:'smooth' });
      }
    } catch {}
  }
  function scrollToNextHighScore(){ jumpHighScore('next'); }

  /********************
   * SESSION & DATA
   ********************/
  function addHistory(ev){
    const h=STATE.history; h.push(ev); if(h.length>CONFIG.learn.maxEvents) h.shift();
    if (ev.label>=0.5) STATE.stats.positives++; else STATE.stats.negatives++;
  }
  function weightSubset(prefix){ const out={}; for(const k in STATE.weights) if(k.startsWith(prefix)) out[k]=STATE.weights[k]; return out; }
  function topN(objOrWeights, n=10){
    const entries = Array.isArray(objOrWeights) ? objOrWeights : Object.entries(objOrWeights);
    return entries.filter(([k])=>k!=='__bias').sort((a,b)=>Math.abs(b[1])-Math.abs(a[1])).slice(0,n);
  }
  function prettyFeat(k){ if(k.startsWith('tag:')) return '#'+k.slice(4); if(k.startsWith('user:')) return '@'+k.slice(5); if(k.startsWith('kw:')) return k.slice(3); return k; }

  function exportJSON(){
    const snapshot = { version: CONFIG.version, state: STATE };
    const blob=new Blob([JSON.stringify(snapshot,null,2)],{type:'application/json'});
    const url=URL.createObjectURL(blob); const a=document.createElement('a');
    a.href=url; a.download='ig-preference-ai-ultra.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(url),4000);
  }
  async function importJSON(file){
    if(!file) return; const text=await file.text();
    try{
      const obj=JSON.parse(text);
      const state = obj.state || obj;
      if (!state || !state.weights || !state.ui) throw new Error('Invalid snapshot');
      STATE = Object.assign({}, DEFAULT_STATE, state);
      persist(); applyVisualFilters(false,true); tip('Imported model & preferences.');
    } catch { tip('Invalid JSON.'); }
  }
  function saveSnapshot(){
    const name = bodyEl?.querySelector('#igpa-snap-name')?.value?.trim();
    if(!name){ tip('Name your snapshot.'); return; }
    const snap={ version: CONFIG.version, weights: STATE.weights, ui: STATE.ui, pins: STATE.pins, bans: STATE.bans, mutedHashtags: STATE.mutedHashtags, ts: Date.now() };
    STATE.snapshots[name] = JSON.parse(JSON.stringify(snap));
    persist(); renderSnapshots(); tip(`Saved snapshot "${name}"`);
  }
  function renderSnapshots(){
    const box = bodyEl?.querySelector('#igpa-snap-list'); if(!box) return;
    const items=Object.entries(STATE.snapshots).sort((a,b)=>b[1].ts-a[1].ts);
    box.innerHTML = items.length? items.map(([n,s])=>`
      <div class="igpa-row">
        <div>${n} <span class="igpa-small">(${new Date(s.ts).toLocaleString()})</span></div>
        <div>
          <button class="igpa-btn" data-snap="${n}" data-act="load">Load</button>
          <button class="igpa-btn" data-snap="${n}" data-act="del">Delete</button>
        </div>
      </div>`).join('') : '<p class="igpa-small">No snapshots yet.</p>';
    box.querySelectorAll('[data-snap]').forEach(btn=>{
      btn.onclick=()=>{
        const n=btn.getAttribute('data-snap'); const act=btn.getAttribute('data-act');
        if(act==='load'){ const s=STATE.snapshots[n]; if(!s) return;
          STATE.weights=JSON.parse(JSON.stringify(s.weights));
          STATE.ui=JSON.parse(JSON.stringify(s.ui));
          STATE.pins=JSON.parse(JSON.stringify(s.pins));
          STATE.bans=JSON.parse(JSON.stringify(s.bans));
          STATE.mutedHashtags=JSON.parse(JSON.stringify(s.mutedHashtags));
          persist(); applyVisualFilters(false,true); tip(`Loaded snapshot "${n}"`);
        } else if(act==='del'){ delete STATE.snapshots[n]; persist(); renderSnapshots(); }
      };
    });
  }

  function exportHistoryCSV(){
    try{
      const rows = [['ts','id','creator','via','label','seconds','pred','loss','arm','lr']];
      for (const ev of STATE.history){
        rows.push([
          new Date(ev.ts).toISOString(),
          ev.id || '',
          ev.metaCreator || '',
          ev.via || '',
          (ev.label!=null?ev.label:''),
          (ev.seconds!=null?ev.seconds:''),
          (ev.pred!=null?ev.pred:''),
          (ev.loss!=null?ev.loss:''),
          ev.arm || '',
          (ev.lr!=null?ev.lr:'')
        ]);
      }
      const csv = rows.map(r=>r.map(x=>String(x).replace(/"/g,'""')).map(x=>`"${x}"`).join(',')).join('\n');
      const blob = new Blob([csv],{type:'text/csv'});
      const url=URL.createObjectURL(blob); const a=document.createElement('a');
      a.href=url; a.download='igpa-history.csv'; a.click(); setTimeout(()=>URL.revokeObjectURL(url),4000);
    } catch(e){ tip('CSV export failed.'); }
  }

  function resetAll(){
    if (!confirm('Reset model, preferences, and history?')) return;
    STATE = JSON.parse(JSON.stringify(DEFAULT_STATE));
    persist(); applyVisualFilters(false,true); tip('Reset complete.');
  }

  /********************
   * FILTERS & INIT (batched with rAF)
   ********************/
  let raf = null;
  function applyVisualFilters(hide=false, showAll=false){
    if (raf) cancelAnimationFrame(raf);
    raf = requestAnimationFrame(()=>{
      try{
        document.querySelectorAll(SELECTORS.article).forEach(el=>{
          if(!isPost(el)) return;
          const score = predictScore(el);
          if (showAll) el.style.display='';
          else if (hide && score<STATE.ui.lowScoreThreshold) el.style.display='none';
          else el.style.display='';
          paintBadge(el);
        });
      } finally { raf = null; }
    });
  }

  function tip(msg){
    const t=document.createElement('div');
    t.textContent=msg;
    Object.assign(t.style,{position:'fixed',left:'50%',bottom:'24px',transform:'translateX(-50%)',background:'var(--igpa-elev)',color:'var(--igpa-text)',padding:'8px 12px',borderRadius:'8px',border:'1px solid var(--igpa-border)',zIndex:String(CONFIG.ui.zIndex)});
    document.body.appendChild(t); setTimeout(()=>{ t.remove(); },1200);
  }

  function keepPanelInViewport(){
    try{
      if (!panel) return;
      const r = panel.getBoundingClientRect();
      let nx = r.left, ny = r.top;
      const pad = 8;
      if (r.right > window.innerWidth - pad) nx -= (r.right - (window.innerWidth - pad));
      if (r.bottom > window.innerHeight - pad) ny -= (r.bottom - (window.innerHeight - pad));
      if (r.left < pad) nx = pad;
      if (r.top < pad) ny = pad;
      panel.style.left = nx+'px'; panel.style.top = ny+'px';
      persistPanelPos();
    } catch {}
  }
  window.addEventListener('resize', keepPanelInViewport);

  async function boot(){
    try{
      buildUI();
      let attempts = 0;
      while (attempts < 140){
        const ready = document.querySelector(SELECTORS.postAnchor) || document.querySelector(SELECTORS.article);
        if (ready) break;
        await sleep(100);
        attempts++;
      }
      const root = document.body;
      ensureMO().observe(root, { childList: true, subtree: true });

      document.querySelectorAll(SELECTORS.article).forEach(el=>{ if (isPost(el)) preparePost(el); });
      setInterval(()=>{ STATE.status.observing = !!io; }, 1500);

      STATE.status.booted = true;
      persist();
      renderTab('overview');
      log('initialized Ultra');
    } catch (e) {
      STATE.status.lastError = 'boot:'+String(e);
      persist();
      warn('Boot error', e);
    }
  }

  // Kickoff
  setThemeAttr();
  boot();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址