Amazon Triage

Triage Amazon search results.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name        Amazon Triage
// @namespace   nikhilweee
// @match       https://www.amazon.com/*
// @match       https://www.amazon.in/*
// @grant       none
// @version     1.0
// @author      nikhilweee
// @description Triage Amazon search results.
// @icon        https://www.amazon.com/favicon.ico
// ==/UserScript==

(function () {
  "use strict";

  const CONFIG = {
    key: "com.nikhilweee.amazon_triage",
    colors: {
      shortlist: {
        border: "#059669", // Emerald 600
        bg: "rgba(5, 150, 105, 0.05)",
        text: "#047857",
        bannerBg: "#ecfdf5",
        bannerBorder: "#6ee7b7"
      },
      discard: {
        border: "#ef4444", // Red 500
        bg: "rgba(239, 68, 68, 0.05)",
        text: "#b91c1c",
        bannerBg: "#fef2f2",
        bannerBorder: "#fca5a5"
      },
      neutral: {
        bg: "#f3f4f6",
        text: "#1f2937",
        border: "#d1d5db",
        hoverBg: "#e5e7eb",
        hoverBorder: "#9ca3af"
      }
    }
  };

  const Triage = {
    state: {},
    hoverAsin: null,

    init() {
      this.load();
      this.injectStyles();
      this.initObservers();
      this.initEvents();
      this.renderToolbar();
      this.checkDetailPage();
    },

    load() {
      try {
        this.state = JSON.parse(localStorage.getItem(CONFIG.key) || "{}");
      } catch (e) {
        this.state = {};
      }
    },

    save() {
      try {
        localStorage.setItem(CONFIG.key, JSON.stringify(this.state));
      } catch (e) { }
      this.updateToolbar();
    },

    // Helper to handle both old (string) and new (object) state formats
    getItem(asin) {
      const val = this.state[asin];
      if (!val) return null;
      return typeof val === "string" ? { status: val, title: asin } : val;
    },

    setStatus(asin, status, title = null) {
      if (!asin) return;
      const current = this.getItem(asin);

      if (current && current.status === status) {
        delete this.state[asin];
      } else {
        // Preserve existing title if not provided
        const finalTitle = title || (current ? current.title : asin);
        this.state[asin] = { status, title: finalTitle, ts: Date.now() };
      }

      this.save();
      this.sync(asin);
    },

    sync(asin) {
      const item = this.getItem(asin);
      const status = item ? item.status : null;

      document.querySelectorAll(`div[data-asin="${asin}"][data-at-ready="true"]`).forEach(el => {
        el.classList.remove("at-s", "at-d");
        if (status) el.classList.add(`at-${status}`);
      });
    },

    clearAll() {
      if (!confirm("Clear all triage data?")) return;
      this.state = {};
      this.save();
      document.querySelectorAll(".at-s, .at-d").forEach(el => el.classList.remove("at-s", "at-d"));
      document.querySelector(".at-banner")?.remove();
    },

    getCounts() {
      let s = 0, d = 0;
      Object.values(this.state).forEach(v => {
        const status = typeof v === "string" ? v : v.status;
        if (status === "s") s++;
        if (status === "d") d++;
      });
      return { s, d, total: s + d };
    },

    getItemsByStatus(statusKey) {
      return Object.entries(this.state)
        .map(([asin, val]) => {
          const data = typeof val === "string" ? { status: val, title: asin } : val;
          return { asin, ...data };
        })
        .filter(i => i.status === statusKey)
        .sort((a, b) => (b.ts || 0) - (a.ts || 0));
    },

    processCard(el) {
      if (el.hasAttribute("data-at-ready")) return;
      if (!el.querySelector('.s-image')) return;

      el.setAttribute("data-at-ready", "true");
      el.classList.add("at-card");

      const imgContainer = el.querySelector('.s-image-fixed-height, .s-product-image-container') || el;
      if (getComputedStyle(imgContainer).position === 'static') imgContainer.style.position = 'relative';

      const ui = document.createElement('div');
      ui.className = 'at-ui';
      ui.innerHTML = `
        <button class="at-btn s" title="Shortlist (S)">★</button>
        <button class="at-btn d" title="Discard (D)">✕</button>
      `;
      imgContainer.appendChild(ui);

      const asin = el.getAttribute("data-asin");
      const item = this.getItem(asin);
      if (item) el.classList.add(`at-${item.status}`);
    },

    injectStyles() {
      const c = CONFIG.colors;
      const css = `
        .at-card { position: relative !important; transition: all 0.2s ease; }
        .at-card::after {
          content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
          box-sizing: border-box; border: 0 solid transparent; pointer-events: none; z-index: 20;
        }
        
        /* Status Styles */
        .at-s { background: ${c.shortlist.bg}; }
        .at-s::after { border-width: 4px; border-color: ${c.shortlist.border}; }
        .at-btn.s { color: ${c.shortlist.border}; }

        .at-d { background: ${c.discard.bg}; }
        .at-d::after { border-width: 4px; border-color: ${c.discard.border}; }
        .at-btn.d { color: ${c.discard.border}; }

        /* UI Overlay */
        .at-ui { position: absolute; top: 6px; right: 6px; display: none; gap: 4px; z-index: 100; }
        [data-asin]:hover .at-ui { display: flex; }
        .at-btn { 
          width: 24px; height: 24px; border-radius: 50%; border: 1px solid #ddd; 
          background: #fff; cursor: pointer; padding: 0; display: grid; place-items: center; 
          font-size: 14px; transition: transform 0.1s; 
        }
        .at-btn:hover { transform: scale(1.15); background: #f8f8f8; }

        /* Toolbar */
        .at-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 0; font-family: inherit; }
        
        .at-dropdown-wrap { position: relative; }
        .at-dropdown {
          position: absolute; top: 100%; left: 0; min-width: 300px; max-height: 400px; overflow-y: auto;
          background: #fff; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
          z-index: 1000; display: none; flex-direction: column;
        }
        .at-dropdown.open { display: flex; }
        .at-dropdown-item {
          padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 13px; color: #333; text-decoration: none;
          display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .at-dropdown-item:hover { background: #f5f5f5; color: #111; }
        .at-dropdown-item:last-child { border-bottom: none; }

        /* Buttons */
        .at-btn-summary, .at-clear, .at-banner {
          display: inline-flex; align-items: center; justify-content: center;
          padding: 6px 12px; border-radius: 4px; font-size: 13px; font-weight: 700;
          font-family: inherit; cursor: pointer; border: 1px solid transparent;
        }
        
        .at-btn-summary.s { background: ${c.shortlist.bannerBg}; color: ${c.shortlist.text}; border-color: ${c.shortlist.bannerBorder}; }
        .at-btn-summary.s:hover { background: #d1fae5; }
        
        .at-btn-summary.d { background: ${c.discard.bannerBg}; color: ${c.discard.text}; border-color: ${c.discard.bannerBorder}; }
        .at-btn-summary.d:hover { background: #fee2e2; }

        .at-clear { background: ${c.neutral.bg}; color: ${c.neutral.text}; border-color: ${c.neutral.border}; }
        .at-clear:hover { background: ${c.neutral.hoverBg}; border-color: ${c.neutral.hoverBorder}; }

        /* Banner */
        .at-banner { display: flex; width: fit-content; margin-bottom: 8px; gap: 6px; }
        .at-banner.at-s { background: ${c.shortlist.bannerBg}; color: ${c.shortlist.text}; border: 1px solid ${c.shortlist.bannerBorder}; }
        .at-banner.at-d { background: ${c.discard.bannerBg}; color: ${c.discard.text}; border: 1px solid ${c.discard.bannerBorder}; }
      `;
      const style = document.createElement("style");
      style.textContent = css;
      document.head.appendChild(style);
    },

    renderToolbar() {
      const mainSlot = document.querySelector('.s-main-slot');
      if (!mainSlot || document.querySelector('.at-toolbar')) return;

      const wrapper = document.createElement('div');
      wrapper.className = "at-toolbar";
      mainSlot.parentNode.insertBefore(wrapper, mainSlot);
      this.updateToolbar();

      // Toolbar Events
      wrapper.addEventListener('click', (e) => {
        if (e.target.closest('.at-clear')) {
          this.clearAll();
        } else if (e.target.closest('.at-btn-summary')) {
          const type = e.target.closest('.at-btn-summary').dataset.type;
          this.toggleDropdown(type);
        }
      });

      // Close dropdowns on outside click
      document.addEventListener('click', (e) => {
        if (!e.target.closest('.at-dropdown-wrap')) {
          document.querySelectorAll('.at-dropdown').forEach(el => el.classList.remove('open'));
        }
      });
    },

    updateToolbar() {
      const wrapper = document.querySelector('.at-toolbar');
      if (!wrapper) return;

      const { s, d, total } = this.getCounts();

      wrapper.innerHTML = `
        <div class="at-dropdown-wrap">
          <button class="at-btn-summary s" data-type="s">Shortlist (${s}) ▼</button>
          <div class="at-dropdown" id="at-drop-s"></div>
        </div>

        <div class="at-dropdown-wrap">
          <button class="at-btn-summary d" data-type="d">Discard (${d}) ▼</button>
          <div class="at-dropdown" id="at-drop-d"></div>
        </div>

        <button class="at-clear">Reset</button>
      `;
    },

    toggleDropdown(type) {
      const drop = document.getElementById(`at-drop-${type}`);
      if (!drop) return;

      const wasOpen = drop.classList.contains('open');
      document.querySelectorAll('.at-dropdown').forEach(el => el.classList.remove('open'));

      if (!wasOpen) {
        const items = this.getItemsByStatus(type);
        if (items.length === 0) {
          drop.innerHTML = '<div class="at-dropdown-item">No items</div>';
        } else {
          drop.innerHTML = items.map(i => `
            <a href="/dp/${i.asin}" class="at-dropdown-item" target="_blank" title="${i.title}">
              ${i.title}
            </a>
          `).join('');
        }
        drop.classList.add('open');
      }
    },

    initEvents() {
      document.addEventListener("click", (e) => {
        const btn = e.target.closest(".at-btn");
        if (!btn) return;

        e.preventDefault();
        e.stopPropagation();

        const card = btn.closest("div[data-asin]");
        if (!card) return;

        // Try to grab title
        const titleEl = card.querySelector("h2");
        const title = titleEl ? titleEl.textContent.trim() : card.getAttribute("data-asin");

        this.setStatus(card.getAttribute("data-asin"), btn.classList.contains("s") ? "s" : "d", title);
      });

      document.addEventListener("mouseover", (e) => {
        const card = e.target.closest("div[data-asin]");
        this.hoverAsin = card ? card.getAttribute("data-asin") : null;
      });

      document.addEventListener("keydown", (e) => {
        if (!this.hoverAsin || /INPUT|TEXTAREA/.test(e.target.tagName)) return;
        const k = e.key.toLowerCase();

        let status = null;
        if (k === "s") status = "s";
        else if (k === "d") status = "d";
        else if (k === "x") status = null;
        else return;

        // Try to grab title from the hovered card
        const card = document.querySelector(`div[data-asin="${this.hoverAsin}"]`);
        const titleEl = card ? card.querySelector("h2") : null;
        const title = titleEl ? titleEl.textContent.trim() : this.hoverAsin;

        this.setStatus(this.hoverAsin, status, title);
      });
    },

    initObservers() {
      const mo = new MutationObserver((mutations) => {
        for (const m of mutations) {
          m.addedNodes.forEach(n => {
            if (n.nodeType === 1) {
              if (n.matches && n.matches('div[data-asin]')) this.processCard(n);
              n.querySelectorAll?.('div[data-asin]').forEach(el => this.processCard(el));
            }
          });
        }
      });
      mo.observe(document.body, { childList: true, subtree: true });
      document.querySelectorAll('div[data-asin]').forEach(el => this.processCard(el));
    },

    checkDetailPage() {
      const asin = document.getElementById("ASIN")?.value;
      if (asin) {
        const item = this.getItem(asin);
        if (item) {
          const title = document.getElementById("productTitle");
          if (title) {
            const b = document.createElement("div");
            b.className = `at-banner at-${item.status}`;
            b.innerHTML = item.status === "s" ? "<span>★</span> Shortlisted" : "<span>✕</span> Discarded";
            title.parentNode.insertBefore(b, title);
          }
        }
      }
    }
  };

  Triage.init();
})();