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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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(); });
})();