Hide/Show OLX Offers

Adds "Hide offer / Show offer" buttons to OLX listing and remembers hidden offers

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==

// @name         Hide/Show OLX Offers
// @name:ro      Ascunde/Arată ofertele OLX
// @name:bg      Скрий/Покажи обяви в OLX
// @name:uk      Приховати/Показати оголошення OLX
// @name:pt      Ocultar/Mostrar anúncios OLX
// @name:pl      Ukryj/Pokaż ogłoszenia OLX
//
// @description     Adds "Hide offer / Show offer" buttons to OLX listing and remembers hidden offers
// @description:ro  Adaugă butoane „Ascunde oferta / Arată oferta" în listările OLX și reține ofertele ascunse
// @description:bg  Добавя бутони „Скрий обявата / Покажи обявата" към обявите в OLX și запомня скритите обяви
// @description:uk  Додає кнопки «Приховати оголошення / Показати оголошення» до списків OLX і запам'ятовує приховані оголошення
// @description:pt  Adiciona botões "Ocultar anúncio / Mostrar anúncio" às listagens do OLX e memoriza os anúncios ocultos
// @description:pl  Dodaje przyciski „Ukryj ogłoszenie / Pokaż ogłoszenie" do list ogłoszeń OLX i zapamiętuje ukryte ogłoszenia
//
// @author       NWP
// @version      1.4.0
// @license      MIT
//
// @match        *://www.olx.ro/*
// @match        *://www.olx.bg/*
// @match        *://www.olx.ua/*
// @match        *://www.olx.pt/*
// @match        *://www.olx.pl/*
// @run-at       document-idle
// @grant        GM_addStyle
// @namespace https://greasyfork.org/users/877912
// ==/UserScript==

(() => {
  "use strict";

  // ===== DEBUG =====
  const DEBUG = false; // set false to silence logs
  const TAG = "[olx-userscript]";
  const dbg = (...a) => DEBUG && console.log(TAG, ...a);
  const dbgBtn = (offerId, action, details) => DEBUG && console.log(TAG, `[BTN:${offerId}]`, action, details || "");

  // ===== Per-language button labels (based on browser language) =====
  const LABELS_BY_LANG = {
    ro: { hide: "Ascunde oferta", show: "Arată oferta" },
    bg: { hide: "Скрий обявата", show: "Покажи обявата" },
    uk: { hide: "Приховати оголошення", show: "Показати оголошення" },
    pt: { hide: "Ocultar anúncio", show: "Mostrar anúncio" },
    pl: { hide: "Ukryj ogłoszenie", show: "Pokaż ogłoszenie" },
  };

  const SUPPORTED_LANGS = new Set(Object.keys(LABELS_BY_LANG));
  const FALLBACK_LABELS = { hide: "Hide offer", show: "Show offer" };

  function detectLabels() {
    const primary = (navigator.language || "").split("-")[0].toLowerCase();
    if (SUPPORTED_LANGS.has(primary)) return LABELS_BY_LANG[primary];
    return FALLBACK_LABELS;
  }

  const LABELS = detectLabels();
  dbg("Labels", LABELS);

  // ===== URL Matching (for SPA navigation) =====
  const ALLOWED_PATHS = [
    '/oferte/',
    '/auto-masini-moto-ambarcatiuni/',
    '/imobiliare/',
    '/locuri-de-munca/',
    '/electronice-si-electrocasnice/',
    '/moda-frumusete/',
    '/piese-auto/',
    '/casa-gradina/',
    '/mama-si-copilul/',
    '/hobby-sport-turism/',
    '/animale-de-companie/',
    '/anunturi-agricole/',
    '/servicii-afaceri-colaborari/',
    '/firme-echipamente-profesionale/',
    '/cazare-turism/',
    '/inchiriere-vehicule-echipamente-articole/'
  ];

  const BLOCKED_PATHS = [
    '/oferte/user/'
  ];

  function isAllowedPage() {
    const path = location.pathname;
    if (BLOCKED_PATHS.some(p => path.startsWith(p))) return false;
    return ALLOWED_PATHS.some(p => path.startsWith(p));
  }

  // ===== SPA Navigation Detection =====
  let lastUrl = location.href;
  const origPushState = history.pushState;
  const origReplaceState = history.replaceState;

  history.pushState = function(...args) {
    dbg('history.pushState called:', args[2]);
    origPushState.apply(this, args);
    onUrlChange();
  };

  history.replaceState = function(...args) {
    dbg('history.replaceState called:', args[2]);
    origReplaceState.apply(this, args);
    onUrlChange();
  };

  window.addEventListener('popstate', () => {
    dbg('popstate event fired');
    onUrlChange();
  });

  function onUrlChange() {
    const currentUrl = location.href;
    if (currentUrl !== lastUrl) {
      dbg('onUrlChange: URL CHANGED from', lastUrl, 'to', currentUrl, 'allowed:', isAllowedPage());
      lastUrl = currentUrl;
      handleVisibility();
    }
  }

  function handleVisibility() {
    const allowed = isAllowedPage();
    dbg('handleVisibility: allowed?', allowed, 'pathname:', location.pathname);

    // Hide or show all injected buttons based on page
    const allBtns = document.querySelectorAll('.olx-ext-btn');
    allBtns.forEach(btn => {
      btn.style.setProperty('display', allowed ? '' : 'none', 'important');
    });

    // Restore hidden cards if we're not on an allowed page
    if (!allowed) {
      const cards = document.querySelectorAll('[data-testid="l-card"].olx-ext-hidden-card');
      cards.forEach(card => card.classList.remove('olx-ext-hidden-card'));
    } else {
      // Re-scan when navigating to an allowed page
      scanAndApply();
    }
  }

  // ===== Storage =====
  const STORAGE_KEY = "olx-ext-hidden";
  const MAX_STATES = 1000;

  // ===== CSS =====
  if (typeof GM_addStyle === "function") {
    GM_addStyle(`
      .olx-ext-btn {
        display: block !important;
        width: calc(100% - 20px) !important;
        margin: 10px !important;
        padding: 12px 14px !important;
        border: 0 !important;
        border-radius: 10px !important;
        cursor: pointer !important;
        font: 14px/1.1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif !important;
        box-shadow: 0 6px 16px rgba(0,0,0,0.14) !important;
        user-select: none !important;
        text-align: center !important;
      }

      .olx-ext-btn--hide {
        background: #d32f2f !important;
        color: #fff !important;
      }

      .olx-ext-btn--hide:hover {
        background: #b71c1c !important;
      }

      .olx-ext-btn--show {
        background: #2e7d32 !important;
        color: #fff !important;
      }

      .olx-ext-btn--show:hover {
        background: #1b5e20 !important;
      }

      [data-testid="l-card"] {
        display: flex;
        flex-direction: column;
      }
      .olx-ext-btn {
        margin-top: auto !important;
      }

      .olx-ext-hidden-card {
        overflow: hidden !important;
        pointer-events: none !important;
      }
      .olx-ext-hidden-card > :not(.olx-ext-btn) {
        display: none !important;
      }
      .olx-ext-hidden-card .olx-ext-btn {
        pointer-events: auto !important;
        opacity: 1 !important;
        filter: none !important;
      }
    `);
  }

  // ===== Hidden IDs (localStorage) with max 1000 and oldest eviction =====
  function readHiddenList() {
    try {
      const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
      if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
      return [];
    } catch {
      return [];
    }
  }

  function writeHiddenList(list) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
  }

  function isHidden(id) {
    return readHiddenList().includes(id);
  }

  function addHidden(id) {
    let list = readHiddenList();
    list = list.filter((x) => x !== id);
    list.push(id);
    if (list.length > MAX_STATES) list = list.slice(list.length - MAX_STATES);
    writeHiddenList(list);
    dbg("addHidden", { id, size: list.length });
  }

  function removeHidden(id) {
    const list = readHiddenList().filter((x) => x !== id);
    writeHiddenList(list);
    dbg("removeHidden", { id, size: list.length });
  }

  // ===== Offer ID =====
  function deriveOfferId(card) {
    if (!card) return null;

    if (card.id) return card.id;

    const dataId = card.getAttribute("data-id") || card.dataset?.id;
    if (dataId) return String(dataId);

    const a = card.querySelector('a[href*="/d/oferta/"], a[href*="ID"], a[href]');
    const href = a?.getAttribute("href") || "";
    const m = href.match(/(ID[a-zA-Z0-9]+)/);
    if (m?.[1]) return m[1];

    if (href) return `href:${href.split("?")[0]}`;
    return null;
  }

  function setCardHidden(card, hidden) {
    if (hidden) card.classList.add("olx-ext-hidden-card");
    else card.classList.remove("olx-ext-hidden-card");

    // Also hide/show the .olx-rating-box sibling within the same wrapper
    const wrapper = card.closest('.olx-rating-rowwrap') || card.closest('.olx-rating-cardhost')?.parentElement;
    if (wrapper) {
      const ratingBox = wrapper.querySelector('.olx-rating-box');
      if (ratingBox) {
        ratingBox.style.setProperty('display', hidden ? 'none' : '', 'important');
      }
    }
  }

  function upsertButton(card, offerId) {
    let btn = card.querySelector(`button.olx-ext-btn[data-offer-id="${CSS.escape(offerId)}"]`);

    if (!btn) {
      btn = document.createElement("button");
      btn.type = "button";
      btn.className = "olx-ext-btn custom-button";
      btn.dataset.offerId = offerId;

      card.appendChild(btn);

      btn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        toggleHidden(card, offerId);
      });

      dbgBtn(offerId, "CREATED", "Button inserted into DOM");
    }

    const hidden = isHidden(offerId);
    const currentText = btn.textContent;
    const newText = hidden ? LABELS.show : LABELS.hide;
    const hasShowClass = btn.classList.contains("olx-ext-btn--show");
    const hasHideClass = btn.classList.contains("olx-ext-btn--hide");

    if (currentText !== newText) {
      dbgBtn(offerId, "TEXT CHANGE", `"${currentText}" → "${newText}"`);
      btn.textContent = newText;
    }

    if (hidden && !hasShowClass) {
      dbgBtn(offerId, "CLASS ADD", "olx-ext-btn--show");
      btn.classList.add("olx-ext-btn--show");
    } else if (!hidden && hasShowClass) {
      dbgBtn(offerId, "CLASS REMOVE", "olx-ext-btn--show");
      btn.classList.remove("olx-ext-btn--show");
    }

    if (!hidden && !hasHideClass) {
      dbgBtn(offerId, "CLASS ADD", "olx-ext-btn--hide");
      btn.classList.add("olx-ext-btn--hide");
    } else if (hidden && hasHideClass) {
      dbgBtn(offerId, "CLASS REMOVE", "olx-ext-btn--hide");
      btn.classList.remove("olx-ext-btn--hide");
    }

    return btn;
  }

  function toggleHidden(card, offerId) {
    if (isHidden(offerId)) {
      dbgBtn(offerId, "USER ACTION", "Unhiding offer");
      removeHidden(offerId);
      setCardHidden(card, false);
      dbg("Unhid offer", { offerId });
    } else {
      dbgBtn(offerId, "USER ACTION", "Hiding offer");
      addHidden(offerId);
      setCardHidden(card, true);
      dbg("Hid offer", { offerId });
    }

    upsertButton(card, offerId);
  }

  // ===== Scan =====
  function scanAndApply() {
    if (!isAllowedPage()) {
      dbg("scanAndApply SKIPPED — not on allowed page", location.pathname);
      return;
    }

    dbg("scanAndApply START", { isUpdating });
    isUpdating = true;
    observer.disconnect();
    dbg("Observer disconnected");

    const cards = document.querySelectorAll('[data-testid="l-card"]');
    const hiddenSet = new Set(readHiddenList());
    dbg(`Found ${cards.length} cards, ${hiddenSet.size} hidden IDs`);

    for (const card of cards) {
      const offerId = deriveOfferId(card);
      if (!offerId) continue;

      upsertButton(card, offerId);
      setCardHidden(card, hiddenSet.has(offerId));
    }

    // Use requestAnimationFrame to ensure DOM mutations are processed before resetting flag
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        isUpdating = false;
        observer.observe(document.body, { childList: true, subtree: true });
        dbg("scanAndApply END - Observer reconnected, isUpdating reset");
      });
    });
    dbg("scanAndApply END (observer will reconnect after RAF)");
  }

  // ===== Mutation observer (throttled) =====
  let scheduled = false;
  let isUpdating = false;
  const observer = new MutationObserver((mutations) => {
    // Check for URL changes on every mutation (SPA navigation)
    onUrlChange();

    if (scheduled || isUpdating) {
      dbg("Observer: skipped (scheduled or isUpdating)", { scheduled, isUpdating, mutations: mutations.length });
      return;
    }
    if (!isAllowedPage()) {
      dbg("Observer: skipped (not on allowed page)");
      return;
    }
    dbg("Observer: scheduling scan", { mutations: mutations.length });
    scheduled = true;
    setTimeout(() => {
      scanAndApply();
      scheduled = false;
    }, 250);
  });

  observer.observe(document.body, { childList: true, subtree: true });

  // ===== Startup + integrity scan =====
  dbg("Initial scan on startup");
  scanAndApply();
  setInterval(() => {
    dbg("Periodic integrity scan (5s interval)");
    scanAndApply();
  }, 5000);
})();