OLX Power Tools

Czyszczenie reklam, ukrywanie/wygaszanie promowanych, blokada słów i sprzedawców, oznaczanie oglądanych, wiek ogłoszenia oraz wykrywanie „ciągle odświeżanych" (bumpowanych) i prawdziwy wiek z API. Odporny na re-render React.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name         OLX Power Tools
// @namespace    https://www.olx.pl/
// @version      1.2.1
// @description  Czyszczenie reklam, ukrywanie/wygaszanie promowanych, blokada słów i sprzedawców, oznaczanie oglądanych, wiek ogłoszenia oraz wykrywanie „ciągle odświeżanych" (bumpowanych) i prawdziwy wiek z API. Odporny na re-render React.
// @author       Oskar
// @license      MIT
// @match        https://www.olx.pl/*
// @icon         https://www.olx.pl/favicon.ico
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  /* Oznaczenia kart robimy przez KLASY + data-* na węźle karty + CSS (przeżywają
     re-render React). Interakcje przez delegację zdarzeń. Pływające UI dopięte do body. */

  const DEFAULTS = {
    removeAds: true,
    promotedMode: 'dim',      // 'off' | 'dim' | 'hide'
    refreshedMode: 'flag',    // 'off' | 'flag' | 'dim' | 'hide'  — ogłoszenia „Odświeżono" (bumpowane)
    keywordBlock: true,
    sellerBlock: true,
    markSeen: true,
    showAge: true,
    apiAge: false,            // pobierz prawdziwą datę utworzenia z API (wolniejsze) — pokazuje „ciągle wznawiane"
    newTab: false,
  };

  const LS = {
    keywords: 'olxpt:keywords',
    sellers: 'olxpt:blockedSellers',
    seen: 'olxpt:seen',
    info: 'olxpt:offerInfo',   // {offerId: {s: sellerId, c: createdISO}}
  };

  const cfg = Object.assign({}, DEFAULTS, GM_getValue('config', {}));
  const saveCfg = () => GM_setValue('config', cfg);

  const lsGet = (k, fb) => { try { const v = JSON.parse(localStorage.getItem(k)); return v ?? fb; } catch { return fb; } };
  const lsSet = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} };

  let keywords = lsGet(LS.keywords, ['na części', 'uszkodzony', 'niesprawny', 'zamienię']);
  let blockedSellers = new Set(lsGet(LS.sellers, []));
  let seen = new Set(lsGet(LS.seen, []));
  let infoCache = lsGet(LS.info, {});

  GM_addStyle(`
    [data-cy="l-card"] { position: relative !important; }
    [data-cy="l-card"].olxpt-hide { display: none !important; }
    [data-cy="l-card"].olxpt-dim  { opacity: .4; filter: grayscale(.55); transition: opacity .15s; }
    [data-cy="l-card"].olxpt-dim:hover { opacity: 1; filter: none; }
    [data-cy="l-card"].olxpt-seen { opacity: .62; box-shadow: inset 0 3px 0 #6b7280; }
    [data-cy="l-card"].olxpt-promoted::before {
      content: "PROMO"; position: absolute; top: 6px; left: 6px; z-index: 4;
      background: #fde68a; color: #92400e; font: 800 10px/16px system-ui, sans-serif;
      padding: 1px 6px; border-radius: 4px; pointer-events: none;
    }
    [data-cy="l-card"][data-olxpt-age]::after {
      content: attr(data-olxpt-age); position: absolute; bottom: 8px; left: 8px; z-index: 4;
      font: 700 10px/16px system-ui, sans-serif; padding: 1px 6px; border-radius: 4px;
      pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.2);
    }
    [data-cy="l-card"][data-olxpt-agecls="new"]::after { background: #dcfce7; color: #166534; }
    [data-cy="l-card"][data-olxpt-agecls="mid"]::after { background: #fef9c3; color: #854d0e; }
    [data-cy="l-card"][data-olxpt-agecls="old"]::after { background: #fee2e2; color: #991b1b; }
    [data-cy="l-card"][data-olxpt-agecls="ref"]::after { background: #e0e7ff; color: #3730a3; }

    @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap');

    #olxpt-fab {
      position: fixed; right: 18px; bottom: 18px; z-index: 99999;
      width: 48px; height: 48px; border-radius: 14px; border: none; cursor: pointer;
      background: linear-gradient(135deg, #003b41, #00565f); color: #fff; font-size: 21px;
      box-shadow: 0 6px 18px -4px rgba(0,47,52,.55); transition: transform .15s, box-shadow .15s;
    }
    #olxpt-fab:hover { transform: translateY(-2px); box-shadow: 0 9px 22px -4px rgba(0,47,52,.6); }

    #olxpt-panel {
      --ink:#002f34; --muted:#5f7378; --line:#e7edee; --teal:#00565f; --surface:#f6f9f9;
      position: fixed; right: 18px; bottom: 78px; z-index: 99999; width: 336px; max-height: 84vh; overflow: auto;
      background: #fff; color: var(--ink); border: 1px solid var(--line); border-radius: 16px; overflow-x: hidden;
      box-shadow: 0 18px 48px -16px rgba(0,47,52,.45), 0 3px 8px rgba(0,47,52,.08);
      font-family: 'Manrope', system-ui, -apple-system, 'Segoe UI', sans-serif; display: none;
      animation: olxpt-in .26s cubic-bezier(.2,.8,.2,1);
    }
    @keyframes olxpt-in { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform:none; } }
    #olxpt-panel.open { display: block; }

    #olxpt-panel .p-head {
      display: flex; align-items: center; gap: 10px; padding: 14px 16px;
      background: linear-gradient(115deg, #003b41, #00565f); color: #fff; position: sticky; top: 0; z-index: 2;
    }
    #olxpt-panel .p-head .p-badge { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: rgba(255,255,255,.16); font-size: 15px; }
    #olxpt-panel .p-head b { font-size: 14.5px; font-weight: 800; letter-spacing: -.01em; }
    #olxpt-panel .p-head small { display: block; font-size: 9.5px; font-weight: 600; opacity: .68; text-transform: uppercase; letter-spacing: .05em; }
    #olxpt-panel .p-head .p-x { margin-left: auto; background: rgba(255,255,255,.14); border: none; color: #fff; width: 26px; height: 26px; border-radius: 8px; cursor: pointer; font-size: 15px; }

    #olxpt-panel .p-body { padding: 6px 16px 16px; }
    #olxpt-panel .p-sec { font-size: 9.5px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); margin: 16px 0 6px; }

    #olxpt-panel .tgl { display: flex; align-items: center; gap: 10px; padding: 7px 0; cursor: pointer; font-size: 13px; font-weight: 600; }
    #olxpt-panel .tgl span.t { flex: 1; }
    #olxpt-panel .tgl input { position: absolute; opacity: 0; width: 0; height: 0; }
    #olxpt-panel .tgl .sw { position: relative; width: 40px; height: 23px; border-radius: 23px; background: #cdd8d9; transition: background .18s; flex: 0 0 auto; }
    #olxpt-panel .tgl .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 19px; height: 19px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.28); transition: transform .18s; }
    #olxpt-panel .tgl input:checked + .sw { background: var(--teal); }
    #olxpt-panel .tgl input:checked + .sw::after { transform: translateX(17px); }

    #olxpt-panel .fld { margin: 9px 0; }
    #olxpt-panel .fld .k { font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 4px; display: block; }
    #olxpt-panel select, #olxpt-panel textarea {
      width: 100%; box-sizing: border-box; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px;
      font: 600 12.5px/1.3 'Manrope', system-ui, sans-serif; color: var(--ink); background: var(--surface);
    }
    #olxpt-panel select:focus, #olxpt-panel textarea:focus { outline: none; border-color: #9fc3c3; background: #fff; }
    #olxpt-panel textarea { height: 62px; resize: vertical; }
    #olxpt-panel .hint { font-size: 10.5px; color: var(--muted); font-weight: 500; margin-top: 3px; }

    #olxpt-panel .p-acts { display: flex; gap: 7px; margin-top: 14px; }
    #olxpt-panel button.act { flex: 1; padding: 8px 10px; border: 1px solid var(--line); background: var(--surface); color: var(--teal); border-radius: 9px; cursor: pointer; font: 600 12px 'Manrope', system-ui, sans-serif; transition: background .12s; }
    #olxpt-panel button.act:hover { background: #eaf4f4; }

    #olxpt-panel .p-stats { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; padding-top: 13px; border-top: 1px solid var(--line); }
    #olxpt-panel .p-stats .chip { font-size: 10.5px; font-weight: 700; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 3px 9px; color: var(--muted); }
    #olxpt-panel .p-stats .chip b { color: var(--ink); font-variant-numeric: tabular-nums; }
    #olxpt-panel .hotkeys { font-size: 10.5px; color: var(--muted); margin-top: 12px; line-height: 1.6; }
    #olxpt-panel kbd { background: #eef4f4; border: 1px solid #cfe0e0; border-radius: 5px; padding: 1px 5px; font-size: 10.5px; font-family: 'Manrope', monospace; color: var(--teal); font-weight: 700; }

    /* ---------- refinement pass ---------- */
    #olxpt-panel .tgl { border-radius: 9px; padding: 7px 8px; margin: 1px -8px; transition: background .13s ease; }
    #olxpt-panel .tgl:hover { background: #f2f8f8; }
    #olxpt-panel .tgl .sw { box-shadow: inset 0 1px 2px rgba(0,47,52,.14); }
    #olxpt-panel .tgl .sw::after { transition: transform .2s cubic-bezier(.2,.8,.2,1); }
    #olxpt-panel .tgl input:checked + .sw { box-shadow: inset 0 1px 2px rgba(0,47,52,.2); }
    #olxpt-panel .tgl input:focus-visible + .sw { outline: 2px solid transparent; box-shadow: 0 0 0 3px rgba(0,86,95,.32); }
    #olxpt-panel select:focus-visible, #olxpt-panel textarea:focus-visible { outline: none; border-color: #6aa8a8; box-shadow: 0 0 0 3px rgba(0,86,95,.22); }
    #olxpt-panel button.act { transition: background .12s, transform .1s, box-shadow .12s; }
    #olxpt-panel button.act:hover { box-shadow: 0 2px 6px -2px rgba(0,47,52,.2); }
    #olxpt-panel button.act:active { transform: translateY(1px); box-shadow: none; }
    #olxpt-panel button.act:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(0,86,95,.28); }
    #olxpt-panel .p-head .p-x { transition: background .12s, transform .1s; }
    #olxpt-panel .p-head .p-x:hover { background: rgba(255,255,255,.26); }
    #olxpt-panel .p-head .p-x:active { transform: scale(.9); }
    #olxpt-panel .p-sec { display: flex; align-items: center; gap: 8px; }
    #olxpt-panel .p-sec::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line), transparent); }
    #olxpt-panel .p-stats .chip { transition: transform .1s, box-shadow .12s; }
    #olxpt-panel .p-stats .chip:hover { box-shadow: 0 2px 6px -2px rgba(0,47,52,.18); transform: translateY(-1px); }
    #olxpt-fab { transition: transform .15s cubic-bezier(.2,.8,.2,1), box-shadow .15s; }
    #olxpt-fab:active { transform: translateY(0) scale(.94); }
    #olxpt-fab:focus-visible { outline: none; box-shadow: 0 6px 18px -4px rgba(0,47,52,.55), 0 0 0 3px rgba(35,229,219,.5); }
  `);

  if (cfg.removeAds) GM_addStyle(`[data-cy^="baxter-slot"], [data-testid="qa-advert-slot"] { display: none !important; }`);

  /* ---- parsowanie dat ---- */
  const PL_MONTHS = { 'stycznia': 0, 'lutego': 1, 'marca': 2, 'kwietnia': 3, 'maja': 4, 'czerwca': 5, 'lipca': 6, 'sierpnia': 7, 'września': 8, 'października': 9, 'listopada': 10, 'grudnia': 11 };
  function parseAgeDays(text) {
    if (!text) return null;
    const t = text.toLowerCase();
    if (t.includes('dzisiaj')) return 0;
    if (t.includes('wczoraj')) return 1;
    const m = t.match(/(\d{1,2})\s+([a-ząćęłńóśźż]+)\s+(\d{4})/);
    if (!m) return null;
    const mon = PL_MONTHS[m[2]];
    if (mon === undefined) return null;
    const days = Math.round((Date.now() - new Date(parseInt(m[3], 10), mon, parseInt(m[1], 10)).getTime()) / 86400000);
    return days >= 0 ? days : null;
  }
  const ageDaysFromIso = (iso) => Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400000));

  const cardOfferId = (card) => parseInt(card.id, 10) || null;
  function isPromoted(card) {
    const a = card.querySelector('a[href]');
    if (a && /promoted/i.test(a.getAttribute('href') || '')) return true;
    return [...card.querySelectorAll('[data-nx-name="Tag"], .css-144z9p2')].some(t => /wyróżnione/i.test(t.textContent));
  }
  function cardTitle(card) {
    const h4 = card.querySelector('[data-testid="ad-card-title"] h4');
    if (h4) return h4.textContent.trim();
    const el = card.querySelector('[data-testid="ad-card-title"]');
    return el ? el.textContent.trim() : '';
  }

  /* ---- lazy pobieranie {sellerId, created} przez API ---- */
  const queue = []; let running = 0; const CONC = 3;
  function resolveOffer(offerId) {
    if (infoCache[offerId]) return Promise.resolve(infoCache[offerId]);
    return new Promise((resolve) => { queue.push({ offerId, resolve }); pump(); });
  }
  function pump() {
    while (running < CONC && queue.length) {
      const job = queue.shift(); running++;
      fetch(`/api/v1/offers/${job.offerId}/`, { headers: { Accept: 'application/json' } })
        .then(r => r.ok ? r.json() : null)
        .then(j => {
          const d = j && (j.data || j);
          const info = { s: d && d.user && d.user.id, c: d && d.created_time };
          infoCache[job.offerId] = info; lsSet(LS.info, infoCache);
          job.resolve(info);
        })
        .catch(() => job.resolve({}))
        .finally(() => { running--; pump(); });
    }
  }

  /* ---- przetwarzanie karty ---- */
  let stats = { promoted: 0, refreshed: 0, hiddenKeyword: 0, hiddenSeller: 0 };

  function setAge(card, days, refreshed) {
    card.dataset.olxptAge = (refreshed ? '♻ ' : '') + (days === 0 ? 'dziś' : days === 1 ? 'wczoraj' : days + ' dni');
    card.dataset.olxptAgecls = refreshed ? 'ref' : days <= 3 ? 'new' : days <= 30 ? 'mid' : 'old';
  }

  function processCard(card) {
    if (card.dataset.olxpt) return;
    card.dataset.olxpt = '1';

    const offerId = cardOfferId(card);
    const titleLc = cardTitle(card).toLowerCase();
    const ld = card.querySelector('[data-testid="location-date"]');
    const ldText = ld ? ld.textContent : '';
    const refreshed = /odświeżono/i.test(ldText);
    if (refreshed) card.dataset.olxptRefreshed = '1';

    if (isPromoted(card)) {
      stats.promoted++;
      if (cfg.promotedMode === 'hide') { card.classList.add('olxpt-hide'); return; }
      if (cfg.promotedMode !== 'off') card.classList.add('olxpt-promoted');
      if (cfg.promotedMode === 'dim') card.classList.add('olxpt-dim');
    }

    if (cfg.keywordBlock && keywords.length) {
      const hit = keywords.find(k => k && titleLc.includes(k.toLowerCase()));
      if (hit) { card.classList.add('olxpt-hide'); stats.hiddenKeyword++; updatePanelStats(); return; }
    }

    if (refreshed) {
      stats.refreshed++;
      if (cfg.refreshedMode === 'hide') { card.classList.add('olxpt-hide'); updatePanelStats(); return; }
      if (cfg.refreshedMode === 'dim') card.classList.add('olxpt-dim');
    }

    if (cfg.markSeen && offerId && seen.has(offerId)) card.classList.add('olxpt-seen');

    if (cfg.showAge) {
      const days = parseAgeDays(ldText);
      if (days !== null) setAge(card, days, refreshed);
    }

    if (cfg.newTab) card.querySelectorAll('a[href^="/d/oferta"]').forEach(a => a.target = '_blank');

    // jedno zapytanie API obsługuje i blokadę sprzedawcy, i prawdziwy wiek
    if (offerId && ((cfg.sellerBlock && blockedSellers.size) || cfg.apiAge)) {
      resolveOffer(offerId).then(info => {
        if (cfg.sellerBlock && info.s && blockedSellers.has(info.s)) { card.classList.add('olxpt-hide'); stats.hiddenSeller++; updatePanelStats(); return; }
        if (cfg.apiAge && info.c && cfg.showAge) {
          const cdays = ageDaysFromIso(info.c);
          card.dataset.olxptAge = (refreshed ? '♻ ' : '') + 'utw. ' + (cdays === 0 ? 'dziś' : cdays + ' dni');
          card.dataset.olxptAgecls = cdays <= 3 ? 'new' : cdays <= 30 ? 'mid' : 'old';
        }
      });
    }
  }

  /* ---- akcje przez skróty (delegacja) ---- */
  document.addEventListener('click', async (e) => {
    const card = e.target.closest('[data-cy="l-card"]');
    if (!card) return;
    if (e.altKey && e.ctrlKey) {
      e.preventDefault(); e.stopPropagation();
      const word = prompt('Ukrywaj ogłoszenia zawierające w tytule:', firstWords(cardTitle(card)));
      if (word && word.trim()) { addKeyword(word.trim()); reprocessAll(); }
    } else if (e.altKey) {
      e.preventDefault(); e.stopPropagation();
      const offerId = cardOfferId(card);
      if (!offerId) return;
      const info = await resolveOffer(offerId);
      if (info && info.s) {
        blockSeller(info.s);
        document.querySelectorAll('[data-cy="l-card"]').forEach(c => {
          const oid = cardOfferId(c);
          if (oid && infoCache[oid] && infoCache[oid].s === info.s) c.classList.add('olxpt-hide');
        });
      }
    }
  }, true);

  document.addEventListener('mousedown', (e) => {
    if (!cfg.markSeen) return;
    const a = e.target.closest('a[href^="/d/oferta"]');
    if (!a) return;
    const card = a.closest('[data-cy="l-card"]');
    if (card) markSeen(cardOfferId(card));
  }, true);

  const firstWords = (t) => (t || '').split(/\s+/).slice(0, 2).join(' ');
  function markSeen(offerId) { if (!offerId || seen.has(offerId)) return; seen.add(offerId); const arr = [...seen].slice(-4000); seen = new Set(arr); lsSet(LS.seen, arr); }
  function addKeyword(w) { if (!keywords.includes(w)) { keywords.push(w); lsSet(LS.keywords, keywords); syncPanelFields(); } }
  function blockSeller(uid) { blockedSellers.add(uid); lsSet(LS.sellers, [...blockedSellers]); }

  function reprocessAll() {
    document.querySelectorAll('[data-cy="l-card"]').forEach(c => {
      c.classList.remove('olxpt-hide', 'olxpt-dim', 'olxpt-seen', 'olxpt-promoted');
      delete c.dataset.olxpt; delete c.dataset.olxptAge; delete c.dataset.olxptAgecls; delete c.dataset.olxptRefreshed;
    });
    stats = { promoted: 0, refreshed: 0, hiddenKeyword: 0, hiddenSeller: 0 };
    scan();
  }

  function scan() { document.querySelectorAll('[data-cy="l-card"]').forEach(processCard); updatePanelStats(); }
  const debounce = (fn, ms) => { let t; return () => { clearTimeout(t); t = setTimeout(fn, ms); }; };
  const debScan = debounce(scan, 150);
  new MutationObserver(debScan).observe(document.documentElement, { childList: true, subtree: true });

  (function hookHistory() {
    const wrap = (type) => { const o = history[type]; return function () { const r = o.apply(this, arguments); window.dispatchEvent(new Event('olxpt:url')); return r; }; };
    history.pushState = wrap('pushState'); history.replaceState = wrap('replaceState');
    window.addEventListener('popstate', () => window.dispatchEvent(new Event('olxpt:url')));
    window.addEventListener('olxpt:url', debounce(scan, 300));
  })();
  scan();

  /* ---- panel ---- */
  function buildPanel() {
    if (document.getElementById('olxpt-fab')) return;
    const fab = document.createElement('button');
    fab.id = 'olxpt-fab'; fab.textContent = '⚙'; fab.title = 'OLX Power Tools';
    document.body.appendChild(fab);

    const panel = document.createElement('div');
    panel.id = 'olxpt-panel';
    panel.innerHTML = `
      <div class="p-head">
        <span class="p-badge">⚙</span>
        <span><b>OLX Power Tools</b><small>filtry listy</small></span>
        <button class="p-x" title="Zamknij">✕</button>
      </div>
      <div class="p-body">
        <div class="p-sec">Ogłoszenia</div>
        <div class="fld">
          <span class="k">Promowane („Wyróżnione")</span>
          <select data-k="promotedMode">
            <option value="off">Nie ruszaj</option>
            <option value="dim">Wygaś + etykieta PROMO</option>
            <option value="hide">Ukryj całkowicie</option>
          </select>
        </div>
        <div class="fld">
          <span class="k">Odświeżane / bumpowane („Odświeżono")</span>
          <select data-k="refreshedMode">
            <option value="off">Nie ruszaj</option>
            <option value="flag">Oznacz ♻ na karcie</option>
            <option value="dim">Wygaś</option>
            <option value="hide">Ukryj całkowicie</option>
          </select>
        </div>

        <div class="p-sec">Filtry</div>
        <label class="tgl"><span class="t">Usuwaj reklamy (sloty/bannery)</span><input type="checkbox" data-k="removeAds"><span class="sw"></span></label>
        <label class="tgl"><span class="t">Blokuj słowa w tytule</span><input type="checkbox" data-k="keywordBlock"><span class="sw"></span></label>
        <textarea data-k="keywords" placeholder="jedno słowo/fraza na linię — ukryje pasujące ogłoszenia"></textarea>
        <label class="tgl"><span class="t">Ukrywaj zablokowanych sprzedawców</span><input type="checkbox" data-k="sellerBlock"><span class="sw"></span></label>
        <label class="tgl"><span class="t">Prawdziwy wiek z API</span><input type="checkbox" data-k="apiAge"><span class="sw"></span></label>
        <div class="hint">Wolniejsze — pokazuje realny wiek (łapie ciągle wznawiane, np. oferty pracy).</div>

        <div class="p-sec">Wyświetlanie</div>
        <label class="tgl"><span class="t">Oznaczaj już oglądane</span><input type="checkbox" data-k="markSeen"><span class="sw"></span></label>
        <label class="tgl"><span class="t">Pokaż wiek ogłoszenia</span><input type="checkbox" data-k="showAge"><span class="sw"></span></label>
        <label class="tgl"><span class="t">Otwieraj w nowej karcie</span><input type="checkbox" data-k="newTab"><span class="sw"></span></label>

        <div class="hotkeys"><kbd>Alt</kbd> + klik = zablokuj sprzedawcę<br><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + klik = ukryj po słowie</div>

        <div class="p-acts">
          <button class="act" data-act="clearSeen">Wyczyść „oglądane"</button>
          <button class="act" data-act="clearSellers">Odblokuj sprzedawców</button>
        </div>

        <div class="p-stats">
          <span class="chip">Promowane <b id="olxpt-s-promo">0</b></span>
          <span class="chip">Odświeżane <b id="olxpt-s-ref">0</b></span>
          <span class="chip">Ukryte słowa <b id="olxpt-s-kw">0</b></span>
          <span class="chip">Sprzedawcy <b id="olxpt-s-sel">0</b></span>
        </div>
      </div>
    `;
    document.body.appendChild(panel);
    fab.addEventListener('click', () => panel.classList.toggle('open'));
    panel.querySelector('.p-x').addEventListener('click', () => panel.classList.remove('open'));

    panel.querySelectorAll('[data-k]').forEach(el => {
      const k = el.dataset.k;
      if (k === 'keywords') el.value = keywords.join('\n');
      else if (el.type === 'checkbox') el.checked = !!cfg[k];
      else el.value = cfg[k];
      el.addEventListener('change', () => {
        if (k === 'keywords') { keywords = el.value.split('\n').map(s => s.trim()).filter(Boolean); lsSet(LS.keywords, keywords); }
        else if (el.type === 'checkbox') { cfg[k] = el.checked; saveCfg(); }
        else { cfg[k] = el.value; saveCfg(); }
        if (k === 'removeAds') location.reload(); else reprocessAll();
      });
    });
    panel.querySelector('[data-act="clearSeen"]').addEventListener('click', () => { seen = new Set(); lsSet(LS.seen, []); reprocessAll(); });
    panel.querySelector('[data-act="clearSellers"]').addEventListener('click', () => { blockedSellers = new Set(); lsSet(LS.sellers, []); reprocessAll(); });
  }
  function syncPanelFields() { const ta = document.querySelector('#olxpt-panel textarea[data-k="keywords"]'); if (ta) ta.value = keywords.join('\n'); }
  function updatePanelStats() {
    const p = document.getElementById('olxpt-s-promo'); if (!p) return;
    p.textContent = stats.promoted;
    document.getElementById('olxpt-s-ref').textContent = stats.refreshed;
    document.getElementById('olxpt-s-kw').textContent = stats.hiddenKeyword;
    document.getElementById('olxpt-s-sel').textContent = stats.hiddenSeller;
  }

  if (document.body) buildPanel(); else window.addEventListener('DOMContentLoaded', buildPanel);
  new MutationObserver(debounce(buildPanel, 500)).observe(document.documentElement, { childList: true, subtree: true });

  GM_registerMenuCommand('Ustawienia OLX Power Tools', () => { buildPanel(); document.getElementById('olxpt-panel')?.classList.add('open'); });
  GM_registerMenuCommand('Przełącz ukrywanie promowanych', () => { cfg.promotedMode = cfg.promotedMode === 'hide' ? 'dim' : 'hide'; saveCfg(); reprocessAll(); });
})();