Amazon Enhancements: Ratings Display and Filtering

Display rating scores, add filtering options, and hide sponsored items on Amazon pages with pagination, accessibility, and duplicate handling fixes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Amazon Enhancements: Ratings Display and Filtering
// @namespace    http://tampermonkey.net/
// @version      4.0.0
// @description  Display rating scores, add filtering options, and hide sponsored items on Amazon pages with pagination, accessibility, and duplicate handling fixes.
// @author       Dave
// @match        https://*.amazon.com/*
// @exclude      https://*.amazon.com/gp/cart/*
// @exclude      https://*.amazon.com/gp/help/*
// @exclude      https://*.amazon.com/ap/*
// @grant        none
// @license      MIT
// ==/UserScript==


(function () {
  'use strict';

  // ----------------- State -----------------
  const state = {
    currentFilter: parseFloat(localStorage.getItem('amazon-rating-filter')) || 0,
    minReviewCount: parseInt(localStorage.getItem('amazon-min-review-count')) || 0,
    hideSponsored: localStorage.getItem('amazon-hide-sponsored') === 'true',
    processedAsins: new Set(),
    hiddenResults: new Set()
  };

  // ----------------- Selectors -----------------
  const RATING_SELECTORS = [
    'span.a-size-small.a-color-base:not([aria-hidden])',
    'span[aria-hidden="true"].a-size-small.a-color-base',
    'span[aria-label*="out of 5 stars"]',
    'i[aria-label*="out of 5 stars"]',
    'a[aria-label*="out of 5 stars"]',
    'span.a-icon-alt',
    'i.a-icon-star-mini[aria-hidden="true"]'
  ];

  const REVIEW_COUNT_SELECTORS = [
    'span.a-size-mini.puis-normal-weight-text.s-underline-text',
    'a[aria-label*="ratings"] span.a-size-mini',
    'span[aria-label$=" ratings"]',
    'span[aria-label$=" rating"]',
    'span.a-size-base.s-underline-text'
  ];

  const SPONSORED_SELECTORS = [
    '[aria-label="Sponsored"]',
    '.s-label-popover-default',
    '.puis-sponsored-label-text',
    'span.s-label-popover-hover',
    '[data-component-type="sp-sponsored-result"]'
  ];

  // ----------------- Helpers -----------------
  const qFirst = (root, list) => {
    for (const sel of list) {
      const el = root.querySelector(sel);
      if (el) return el;
    }
    return null;
  };

  // Extract numeric rating, e.g. "4.7 out of 5 stars" or "4.7"
  const extractRating = (el) => {
    if (!el) return 0;
    const text = (el.getAttribute('aria-label') || el.parentElement?.getAttribute('aria-label') || el.textContent || '').trim();
    const direct = text.match(/^(\d+(?:\.\d+)?)(?:\s|$)/);
    if (direct) return parseFloat(direct[1]);
    const outOf = text.match(/(\d+(?:\.\d+)?)\s*out of/i);
    return outOf ? parseFloat(outOf[1]) : 0;
  };

  // Extract review count, handling plain numbers, (1,234), (43.9K), (1.2M)
  const extractReviewCount = (el) => {
    if (!el) return 0;
    const aria = el.getAttribute('aria-label') || '';
    const txt = el.textContent || '';

    const ariaMatch = aria.match(/(\d+(?:,\d+)*)\s*ratings?/i);
    if (ariaMatch) return parseInt(ariaMatch[1].replace(/,/g, ''), 10);

    const abbr = txt.match(/\((\d+(?:\.\d+)?)(K|M)\)/i);
    if (abbr) {
      const num = parseFloat(abbr[1]);
      const mul = abbr[2].toUpperCase() === 'K' ? 1e3 : 1e6;
      return Math.round(num * mul);
    }

    const paren = txt.match(/\((\d+(?:,\d+)*)\)/);
    if (paren) return parseInt(paren[1].replace(/,/g, ''), 10);

    const anyNum = txt.match(/(\d+(?:,\d+)*)/);
    return anyNum ? parseInt(anyNum[1].replace(/,/g, ''), 10) : 0;
  };

  // Cache sponsored status in dataset to avoid repeated DOM scans
  const isSponsored = (result) => {
    if (result.dataset.sponsored) return result.dataset.sponsored === '1';

    let sponsored = SPONSORED_SELECTORS.some(sel => result.querySelector(sel));
    if (!sponsored) {
      // Single textContent scan instead of scanning all spans/divs
      sponsored = /(^|\s)Sponsored(\s|$)/i.test(result.textContent);
    }
    result.dataset.sponsored = sponsored ? '1' : '0';
    return sponsored;
  };

  const getAsin = (result) => {
    if (result.dataset.asin) return result.dataset.asin;
    const link = result.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]');
    const m = link && link.href.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/);
    return m ? m[1] : '';
  };

  // Enhance rating display + attach rating/review counts to dataset
  const enhanceRatingDisplay = (result) => {
    const asin = getAsin(result);
    if (!asin || state.processedAsins.has(asin)) return;

    let ratingEl = null;
    let rating = 0;

    // Try new format: rating in small row as plain number
    const ratingRow = result.querySelector('.a-row.a-size-small');
    if (ratingRow) {
      const direct = ratingRow.querySelector('span.a-size-small.a-color-base');
      const txt = direct && direct.textContent.trim();
      if (txt && /^\d+(\.\d+)?$/.test(txt)) {
        ratingEl = direct;
        rating = parseFloat(txt);
      }
    }

    // Fallback to legacy selectors
    if (!ratingEl || !rating) {
      ratingEl = qFirst(result, RATING_SELECTORS);
      rating = extractRating(ratingEl);
    }

    if (!ratingEl || !rating) return;

    // Find review count element
    let countEl = qFirst(result, REVIEW_COUNT_SELECTORS);
    if (!countEl && ratingEl.tagName === 'A') {
      const inner = ratingEl.querySelector('span.a-size-small');
      if (inner) countEl = inner;
    }
    const reviewCount = extractReviewCount(countEl);

    result.dataset.asin = asin;
    result.dataset.ratingValue = rating;
    result.dataset.reviewCount = reviewCount;

    // If new layout already shows rating plainly, or parent already has rating text, skip extra span
    const parentText = ratingEl.parentElement?.textContent || '';
    if ((ratingRow && ratingEl.parentElement === ratingRow) || parentText.includes(rating.toFixed(1))) {
      state.processedAsins.add(asin);
      result.classList.add('enhanced-rating');
      return;
    }

    // Inject a bold rating span before existing rating element
    if (!result.querySelector('.enhanced-rating-display')) {
      const span = document.createElement('span');
      span.className = 'enhanced-rating-display';
      span.textContent = rating.toFixed(1) + ' ';
      span.style.cssText = 'font-weight:bold;color:#007600;margin-right:5px;font-size:14px;';
      const target = ratingEl.parentElement || ratingEl;
      target.insertBefore(span, ratingEl);
    }

    state.processedAsins.add(asin);
    result.classList.add('enhanced-rating');
  };

  // ----------------- Filter Bar UI -----------------
  const createFilterBar = () => {
    const bar = document.createElement('div');
    bar.id = 'rating-filter-bar';
    bar.style.cssText = 'padding:6px 12px;margin:8px auto;max-width:900px;font-size:13px;background:#f3f3f3;border:1px solid #ddd;border-radius:6px;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:10px;box-shadow:0 1px 3px rgba(0,0,0,.1);position:sticky;top:0;z-index:100;';

    const mkGroup = () => {
      const g = document.createElement('div');
      g.style.cssText = 'display:flex;align-items:center;gap:5px;';
      return g;
    };

    // Rating dropdown
    const ratingGroup = mkGroup();
    const ratingLabel = document.createElement('label');
    ratingLabel.textContent = 'Rating:';
    ratingLabel.style.cssText = 'font-weight:500;font-size:13px;';
    const ratingSelect = document.createElement('select');
    ratingSelect.setAttribute('aria-label', 'Filter by minimum star rating');
    ratingSelect.style.cssText = 'padding:3px 6px;border-radius:4px;border:1px solid #ccc;font-size:13px;';
    [0, 4.9, 4.8, 4.7, 4.6, 4.5, 4.4, 4.3, 4.2, 4.1, 4.0, 3.5, 3.0].forEach(v => {
      const o = document.createElement('option');
      o.value = v;
      o.textContent = v === 0 ? 'All Ratings' : `${v}+ stars`;
      ratingSelect.appendChild(o);
    });
    ratingSelect.value = state.currentFilter || 0;
    ratingSelect.addEventListener('change', e => {
      state.currentFilter = parseFloat(e.target.value) || 0;
      localStorage.setItem('amazon-rating-filter', state.currentFilter);
      applyFilters();
    });
    ratingGroup.append(ratingLabel, ratingSelect);
    bar.appendChild(ratingGroup);

    // Review count input
    const reviewGroup = mkGroup();
    const reviewLabel = document.createElement('label');
    reviewLabel.textContent = 'Reviews:';
    reviewLabel.style.cssText = 'font-weight:500;font-size:13px;';
    const reviewInput = document.createElement('input');
    reviewInput.type = 'number';
    reviewInput.min = 0;
    reviewInput.value = state.minReviewCount || 0;
    reviewInput.placeholder = '0';
    reviewInput.setAttribute('aria-label', 'Filter by minimum number of reviews');
    reviewInput.style.cssText = 'width:60px;padding:3px 6px;border-radius:4px;border:1px solid #ccc;font-size:13px;';
    reviewInput.addEventListener('input', e => {
      state.minReviewCount = parseInt(e.target.value, 10) || 0;
      localStorage.setItem('amazon-min-review-count', state.minReviewCount);
      applyFilters();
    });
    reviewGroup.append(reviewLabel, reviewInput);
    bar.appendChild(reviewGroup);

    // Sponsored toggle
    const sponsoredLabel = document.createElement('label');
    sponsoredLabel.style.cssText = 'display:flex;align-items:center;gap:4px;cursor:pointer;font-weight:500;font-size:13px;';
    const sponsoredCheckbox = document.createElement('input');
    sponsoredCheckbox.type = 'checkbox';
    sponsoredCheckbox.checked = state.hideSponsored;
    sponsoredCheckbox.setAttribute('aria-label', 'Hide sponsored items');
    sponsoredCheckbox.addEventListener('change', () => {
      state.hideSponsored = sponsoredCheckbox.checked;
      localStorage.setItem('amazon-hide-sponsored', state.hideSponsored);
      applyFilters();
    });
    sponsoredLabel.append(sponsoredCheckbox, document.createTextNode('Hide Sponsored'));
    bar.appendChild(sponsoredLabel);

    // Reset button
    const resetBtn = document.createElement('button');
    resetBtn.textContent = 'Reset';
    resetBtn.setAttribute('aria-label', 'Reset all filters');
    resetBtn.style.cssText = 'padding:4px 10px;background:#fff;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-weight:500;font-size:13px;';
    resetBtn.addEventListener('mouseover', () => resetBtn.style.background = '#f0f0f0');
    resetBtn.addEventListener('mouseout', () => resetBtn.style.background = '#fff');
    resetBtn.addEventListener('click', () => {
      state.currentFilter = 0;
      state.minReviewCount = 0;
      state.hideSponsored = false;
      ratingSelect.value = 0;
      reviewInput.value = 0;
      sponsoredCheckbox.checked = false;
      localStorage.removeItem('amazon-rating-filter');
      localStorage.removeItem('amazon-min-review-count');
      localStorage.removeItem('amazon-hide-sponsored');
      state.processedAsins.clear();
      processAllResults();
    });
    bar.appendChild(resetBtn);

    // Refresh button (force re-scan)
    const refreshBtn = document.createElement('button');
    refreshBtn.textContent = '🔄';
    refreshBtn.setAttribute('aria-label', 'Refresh filters');
    refreshBtn.title = 'Force refresh filters';
    refreshBtn.style.cssText = 'padding:4px 8px;background:#fff;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:13px;';
    refreshBtn.addEventListener('mouseover', () => refreshBtn.style.background = '#f0f0f0');
    refreshBtn.addEventListener('mouseout', () => refreshBtn.style.background = '#fff');
    refreshBtn.addEventListener('click', () => {
      state.processedAsins.clear();
      processAllResults();
    });
    bar.appendChild(refreshBtn);

    // Status
    const statusDiv = document.createElement('div');
    statusDiv.id = 'filter-status';
    statusDiv.style.cssText = 'font-size:11px;color:#666;font-style:italic;';
    bar.appendChild(statusDiv);

    return bar;
  };

  const updateFilterStatus = () => {
    const active = state.currentFilter > 0 || state.minReviewCount > 0 || state.hideSponsored;
    const bar = document.getElementById('rating-filter-bar');
    const status = document.getElementById('filter-status');
    if (!bar || !status) return;

    bar.style.background = active ? '#e8f5e9' : '#f3f3f3';
    bar.style.borderColor = active ? '#4caf50' : '#ddd';

    const hiddenCount = state.hiddenResults.size;
    if (active) {
      status.textContent = `Filters active (${hiddenCount} items hidden)`;
      status.style.color = '#2e7d32';
    } else {
      status.textContent = 'No filters applied';
      status.style.color = '#666';
    }
  };

  // ----------------- Filtering Logic -----------------
  const applyFilters = () => {
    state.hiddenResults.clear();
    const items = document.querySelectorAll('[data-asin]:not([data-asin=""])');
    items.forEach(result => {
      const rating = parseFloat(result.dataset.ratingValue) || 0;
      const count = parseInt(result.dataset.reviewCount, 10) || 0;
      const sponsored = isSponsored(result);

      const show =
        (state.currentFilter === 0 || rating >= state.currentFilter) &&
        (state.minReviewCount === 0 || count >= state.minReviewCount) &&
        (!state.hideSponsored || !sponsored);

      if (show) {
        result.style.display = '';
        result.classList.remove('hidden-by-filter');
      } else {
        result.style.display = 'none';
        result.classList.add('hidden-by-filter');
        if (result.dataset.asin) state.hiddenResults.add(result.dataset.asin);
      }
    });
    updateFilterStatus();
  };

  const processAllResults = () => {
    const items = document.querySelectorAll('[data-asin]:not([data-asin=""])');
    items.forEach(enhanceRatingDisplay);
    applyFilters();
  };

  // ----------------- Observers -----------------
  const observeResults = () => {
    const container = document.querySelector('.s-main-slot, .s-result-list, #search') || document.body;
    let pending = null;

    const scheduleProcess = () => {
      if (pending) return;
      pending = setTimeout(() => {
        pending = null;
        processAllResults();
      }, 150);
    };

    const observer = new MutationObserver(mutations => {
      for (const m of mutations) {
        if (m.type === 'childList' && m.addedNodes.length) {
          for (const n of m.addedNodes) {
            if (n.nodeType === 1 && (n.dataset?.asin || n.querySelector?.('[data-asin]'))) {
              scheduleProcess();
              return;
            }
          }
        }
      }
    });

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

    // Observe title changes for SPA navigation
    const titleEl = document.querySelector('title');
    if (titleEl) {
      const urlObserver = new MutationObserver(() => {
        if (location.href.includes('/s?') || location.href.includes('/s/')) {
          setTimeout(() => {
            if (!document.getElementById('rating-filter-bar')) insertFilterBar();
            processAllResults();
          }, 400);
        }
      });
      urlObserver.observe(titleEl, { childList: true, characterData: true, subtree: true });
    }
  };

  const insertFilterBar = () => {
    if (document.getElementById('rating-filter-bar')) return;
    const bar = createFilterBar();
    const targets = [
      '#search',
      '.s-desktop-toolbar',
      '.s-result-list-parent-container',
      'div[data-component-type="s-search-results"]'
    ];
    for (const sel of targets) {
      const container = document.querySelector(sel);
      if (container) {
        container.insertBefore(bar, container.firstChild);
        updateFilterStatus();
        return;
      }
    }
    const main = document.querySelector('[role="main"]') || document.body;
    main.insertBefore(bar, main.firstChild);
    updateFilterStatus();
  };

  // ----------------- Debug (kept small) -----------------
  window.debugAmazonFilter = () => {
    const items = document.querySelectorAll('[data-asin]:not([data-asin=""])');
    console.log('Filter state:', {
      rating: state.currentFilter,
      minReviews: state.minReviewCount,
      hideSponsored: state.hideSponsored
    });
    console.log(`Items: ${items.length}`);
    items.forEach((el, i) => {
      const asin = el.dataset.asin;
      const r = parseFloat(el.dataset.ratingValue) || 0;
      const c = parseInt(el.dataset.reviewCount, 10) || 0;
      const sponsored = isSponsored(el);
      console.log(i + 1, { asin, rating: r, reviews: c, sponsored, display: getComputedStyle(el).display });
    });
  };

  // ----------------- Init -----------------
  const init = () => {
    insertFilterBar();
    processAllResults();
    observeResults();

    // Handle back/forward nav
    window.addEventListener('popstate', () => {
      setTimeout(() => {
        insertFilterBar();
        processAllResults();
      }, 100);
    });
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();