Buyee Seller Filter

Add infinite scrolling and options for filtering sellers to the Buyee search results page

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.55
// @description  Add infinite scrolling and options for filtering sellers to the Buyee search results page
// @author       rhgg2
// @match        https://buyee.jp/item/search/*
// @icon         https://www.google.com/s2/favicons?domain=buyee.jp
// @namespace https://greasyfork.org/users/1243343
// @grant        none
// @require     https://unpkg.com/[email protected]/js/smartphoto.min.js
// ==/UserScript==

// list containing metadata associated to sellers,
// including link to items and blacklist status
var sellersData;

// list containing metadata associated to items
// including seller name and blacklist status
var itemsData;

// should hidden sellers/items actually be hidden?
var hideHidden;

// smartPhoto instance

var smartPhoto = new SmartPhoto(".js-smartPhoto");
window.smartPhoto = smartPhoto;

// buyee watchlist

var buyeeWatchlist = [];

// are we on the desktop site?
var isDesktop = (navigator.userAgent.match(/Android/i)
              || navigator.userAgent.match(/webOS/i)
              || navigator.userAgent.match(/iPhone/i)
              || navigator.userAgent.match(/iPad/i)
              || navigator.userAgent.match(/iPod/i)
              || navigator.userAgent.match(/BlackBerry/i)
              || navigator.userAgent.match(/Windows Phone/i)) ? false : true;

// save state to local storage
function serialiseData() {
  localStorage.hideHidden = JSON.stringify(hideHidden);
  localStorage.sellersData = JSON.stringify(sellersData);
  localStorage.itemsData = JSON.stringify(itemsData);
}

// load state from local storage
function unSerialiseData() {
  sellersData = ("sellersData" in localStorage) ? JSON.parse(localStorage.sellersData) : {};
  itemsData = ("itemsData" in localStorage) ? JSON.parse(localStorage.itemsData) : {};
  hideHidden = ("hideHidden" in localStorage) ? JSON.parse(localStorage.hideHidden) : true;
}

// fetch a URL and return a document containing it
function fetchURL(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.text();
    })
    .then(html => new DOMParser().parseFromString(html, "text/html"));
}

// create a node which shows/hides the given seller when clicked
function makeSellerStatusNode(seller,status) {
  let node = document.createElement("button");
  node.type = "button";
  if (status == "Show seller") {
    node.onclick = (() => {
      sellersData[seller].hide = false;
      serialiseData();
      processCardsIn(document);
    });
  } else {
    node.onclick = (() => {
      sellersData[seller].hide = true;
      serialiseData();
      processCardsIn(document);
    });
  }
  node.innerText = status;
  node.classList.add('rg-node');
  if (!isDesktop) node.classList.add('g-text');
  return node;
}

// create a node which shows/hides the given item when clicked
function makeItemStatusNode(url,card,status) {
  let node = document.createElement("button");
  node.type = "button";
  if (status == "Show") {
    node.onclick = (() => {
      itemsData[url].hide = false;
      serialiseData();
      processCard(card);
    });
  } else {
    node.onclick = (() => {
      itemsData[url].hide = true;
      serialiseData();
      processCard(card);
    });
  }
  node.innerText = status;
  node.classList.add('auctionSearchResult__statusItem');
  node.classList.add('rg-node');
  return node;
}

// clear previous info nodes on a card
function clearInfoNodes(card)
{
  let infolist = card.querySelector(".itemCard__infoList");
  while (infolist.childElementCount > 2) {
    infolist.lastElementChild.remove();
  }
  let watchNumNode = card.querySelector(".watchList__watchNum");
  if (watchNumNode) watchNumNode.remove();
}

// add seller node to item card
function addSellerNode(card, sellerData)
{
  let infolist = card.querySelector(".itemCard__infoList");
  let newnode = infolist.firstElementChild.cloneNode(true);
  newnode.querySelector(".g-title").innerText = "Seller";
  newnode.querySelector(".g-text").innerText = "";
  let a = document.createElement("a");
  a.href = sellerData.url;
  a.innerText = sellerData.name;
  newnode.querySelector(".g-text").appendChild(a);
  //    infolist.lastElementChild.remove();
  infolist.appendChild(newnode);
  return newnode;
}

// add number of watches to item card
function addWatchersNode(card, watcherNum)
{
  let starNode = card.querySelector(".g-feather");
  let newNode = document.createElement("span");
  let watchButton = starNode.parentNode;
  newNode.classList.add("watchList__watchNum");
  newNode.innerText = watcherNum;
  watchButton.appendChild(newNode);
}

// make a "loading" node for the infinite scrolling
function makeLoadingNode() {
  let card = document.createElement("li");
  card.classList.add('itemCard');
  card.classList.add('rg-loading');

  let innerDiv = document.createElement("div");
  innerDiv.classList.add('imgLoading');
  innerDiv.style.height = '150px';

  card.appendChild(innerDiv);

  return card;
}

// convert time to time left
function timeUntil(dateStr) {
  const jstDate = new Date(dateStr + " GMT+0900");
  const now = new Date();
  let diffSec = Math.floor((jstDate - now)/1000);

  if (diffSec <= 0) return "Ended";

  const diffDays = Math.floor(diffSec / (60 * 60 * 24));
  diffSec -= diffDays * 60 * 60 * 24;

  const diffHours = Math.floor(diffSec / (60 * 60));
  diffSec -= diffHours * 60 * 60;

  const diffMinutes = Math.floor(diffSec / 60);
  diffSec -= diffMinutes * 60;

  const fmt = (n, label) => n > 0 ? `${n} ${label}${n > 1 ? "s" : ""}` : null;

  let parts;
  const totalMinutes = diffDays * 24 * 60 + diffHours * 60 + diffMinutes;

  if (totalMinutes < 30) {
    // Show minutes and seconds
    parts = [fmt(diffMinutes, "minute"), fmt(diffSec, "second")].filter(Boolean);
  } else {
    // Show days, hours, minutes
    parts = [fmt(diffDays, "day"), fmt(diffHours, "hour"), fmt(diffMinutes, "minute")].filter(Boolean);
  }

  return parts.join(" ");
}

// Remove old items from items list if not seen for > 1 week.
function cleanItems() {
  var now = Date.now();
  Object.keys(itemsData).forEach( (url) => {
    if (now - itemsData[url].lastSeen > 604800000) {
      delete itemsData[url];
    }
  });
  serialiseData();
}

// countdown timers

const countdowns = [];

// Add a countdown
function addCountdown(dateStr, element) {
  countdowns.push({ dateStr, element });
  element.innerText = timeUntil(dateStr);
}

// update all countdowns
function updateCountdowns() {
  const now = new Date();
  for (const cd of countdowns) {
    cd.element.innerText = timeUntil(cd.dateStr);
  }
  setTimeout(updateCountdowns, 1000);
}

updateCountdowns();


// queue of cards to process
const MAX_CONCURRENT = 3;
const DELAY = 250;
const processingQueue = [];
var activeCount = 0;
const inFlightItems = new Set();

// load data for next card in processing queue
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function loadCard() {
  if (processingQueue.length === 0 || activeCount >= MAX_CONCURRENT) return;

  const { card, url, item } = processingQueue.shift();

  activeCount++;

  fetchURL(url)
    .then(doc => {
      fillCardData(card, doc, item);
      processCard(card);
    })
    .catch(err => {
      console.error('fetchURL failed for', url, err);
    })
    .finally(() => {
      inFlightItems.delete(item);
      sleep(DELAY).then(() => {
        activeCount--;
        loadCard();
      });
    });

  if (activeCount < MAX_CONCURRENT) {
    loadCard();
  }
}

// add card to processing queue

function addCardToQueue(card, url, item)
{
  if (inFlightItems.has(item)) return;

  processingQueue.push({ card, url, item });
  inFlightItems.add(item);

  loadCard();
}

// extract data from card page

function fillCardData(card, doc, item)
{
  let itemData = {};
  var xpath = '//a[contains(@href,"search/customer")]';
  let sellernode = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;

  //    let sellernode = doc.querySelector(".no_border.clearfix dd a");
  let seller = sellernode.href.match(/\/item\/search\/customer\/(.*)/);
  if (seller) seller = seller[1];

  itemData.hide = false;
  itemData.seller = seller;
  if (!(seller in sellersData)) {
    sellersData[seller] = {};
    sellersData[seller].name = sellernode.innerText.trim();
    sellersData[seller].url = sellernode.href;
    sellersData[seller].hide = false;
  }

  // get end time
  xpath = '//li[.//*[contains(text(),"Closing Time")]]';
  var result = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  if (result) {
      itemData.endTime = result.querySelector("span:last-child").innerText;
  }

  // get number of watchers

  doc.querySelectorAll('script').forEach(script => {
    const match = script.textContent.match(/buyee\.TOTAL_WATCH_COUNT\s*=\s*(\d+);/);
    if (match) itemData.watchNum = parseInt(match[1], 10);
  });
  if (!("watchNum" in itemData)) {
    let node = doc.querySelector(".watchButton__watchNum");
    if (node) itemData.watchNum = node.innerText;
  }

  // get image links

  itemData.images = Array.from(doc.querySelectorAll(".js-smartPhoto")).map(node => node.href);

  itemsData[item] = itemData;
  serialiseData();
}

// handle smartphoto items gracefully

var smartPhotoOpen = false;
var pendingSmartPhotoItems = [];

var smartPhotoNode = document.querySelector(".smartphoto");

// patch SmartPhoto's internal click to track "opening in progress"
document.addEventListener('click', e => {
  const node = e.target.closest('a.js-smartPhoto');
  if (!node) return;

  // Mark lightbox as opening
  smartPhotoOpen = true;
});

smartPhotoNode.addEventListener('close', () => {
  smartPhotoOpen = false;

  pendingSmartPhotoItems.forEach(item => smartPhoto.addNewItem(item));
  pendingSmartPhotoItems = [];
});

function addSmartPhotoItem(node) {
  node.classList.add("js-smartPhoto");
  if (smartPhotoOpen) {
    pendingSmartPhotoItems.push(node);
  } else {
    smartPhoto.addNewItem(node);
  }
}


// process changes to a results card; this may be called to refresh the page on a state change,
// so be sure to account for the previous changes
function processCard(card) {

  // find url
  let url = card.querySelector('.itemCard__itemName a').href;
  let item = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/);
  if (item) { item = item[1] }

  let thumbnailNode = card.querySelector(".g-thumbnail__outer a");
  thumbnailNode.href = "#";

  if (!(item in itemsData)) {
    addCardToQueue(card, url, item);
    return;
  }

  let itemData = itemsData[item];
  let seller = itemData.seller;
  let sellerData = sellersData[seller];

  // update last seen
  itemData.lastSeen = Date.now();

  // clear old cruft
  card.querySelectorAll(".rg-node").forEach(node => { node.parentNode.removeChild(node); });
  clearInfoNodes(card);

  let statusNode = card.querySelector("ul.auctionSearchResult__statusList");
  let sellerNode = addSellerNode(card, sellerData);

  let timeLeftNode = card.querySelector(".itemCard__infoList").firstElementChild;
  timeLeftNode.querySelector(".g-title").innerText = "Time Left";
  timeLeftNode.querySelector(".g-text").classList.remove("g-text--attention");
  addCountdown(itemData.endTime, timeLeftNode.querySelector(".g-text"));

 // timeLeftNode.querySelector(".g-text").innerText = itemData.endTime;

  addWatchersNode(card, itemData.watchNum);

  // link images

  if (itemData.images.length > 0) {
    let thumbnailNode = card.querySelector(".g-thumbnail__outer a");
    if (!(thumbnailNode.classList.contains("js-smartPhoto"))) {
      thumbnailNode.href = itemData.images[0];
      thumbnailNode.dataset.group = item;
      addSmartPhotoItem(thumbnailNode);
 //     thumbnailNode.href = "#";

      let imageDiv = document.createElement("div");
      thumbnailNode.parentNode.appendChild(imageDiv);
      imageDiv.style.display = "none";
      itemData.images.slice(1).forEach( image => {
        let imgNode = document.createElement("a");
        imgNode.href = image;
        imgNode.dataset.group = item;
        imageDiv.appendChild(imgNode);
        addSmartPhotoItem(imgNode);
      });
    }
  }


  if (sellerData.hide) {
    if (hideHidden) {
      // hide the card
      card.style.display = 'none';
      card.style.removeProperty('opacity');
      card.style.removeProperty('background-color');
    } else {
      // show with red background
      card.style.opacity = '0.9';
      card.style['background-color'] = '#ffbfbf';
      card.style.removeProperty('display');

      // add show link
      sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Show seller'));
    }
  } else if (itemData.hide) {
    if (hideHidden) {
      // hide the card
      card.style.display = 'none';
      card.style.removeProperty('opacity');
      card.style.removeProperty('background-color');
    } else {
      // show with red background
      card.style.opacity = '0.9';
      card.style['background-color'] = '#ffbfbf';
      card.style.removeProperty('display');

      // add show/hide links
      sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide seller'));
      statusNode.appendChild(makeItemStatusNode(item,card,'Show'));
    }
  } else {
    // unhide card
    card.style.removeProperty('opacity');
    card.style.removeProperty('background-color');
    card.style.removeProperty('order');
    card.style.removeProperty('display');

    // add hide links
    sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide seller'));
    statusNode.appendChild(makeItemStatusNode(item,card,'Hide'));
  }
}

// process changes to all results cards in a given element
function processCardsIn(element) {
  element.querySelectorAll("ul.auctionSearchResult li.itemCard:not(.rg-loading)").forEach(card => {
    processCard(card);
  });

  serialiseData();
}

// move all results cards in a given element to the given element
// as soon as one visible card is moved, hide loadingElement
// add cards to observer for lazy loading
function moveCards(elementFrom, elementTo, loadingElement, observer) {
  var movedVisibleCard = (loadingElement === undefined) ? true : false;
  elementFrom.querySelectorAll("ul.auctionSearchResult li.itemCard").forEach(card => {
    // move the card
    elementTo.appendChild(card);

    // if we moved a visible card, hide the loading element
    if (!movedVisibleCard && card.style.display != "none") {
      movedVisibleCard = true;
      loadingElement.style.display = "none";
    }

    // add to lazy loading observer
    let imgNode = card.querySelector('img.lazyLoadV2');
    if (imgNode) observer.observe(imgNode);

    // initialise favourite star
    let watchButton = card.querySelector('div.watchButton');
    let id;

    if (isDesktop) {
      id = watchButton.dataset.auctionId;
    } else {
      id = watchButton.parentNode.parentNode.dataset.id;
    }

    if (buyeeWatchlist.includes(id)) {
      watchButton.classList.add("is-active");
      watchButton.firstElementChild.classList.remove("g-feather-star");
      watchButton.firstElementChild.classList.add("g-feather-star-active");
    } else {
      watchButton.classList.remove("is-active");
      watchButton.firstElementChild.classList.remove("g-feather-star-active");
      watchButton.firstElementChild.classList.add("g-feather-star");
    }

  });
}

// find the URL for the next page of search results after the given one
function nextPageURL(url) {
  var newURL = new URL(url);
  var currentPage = newURL.searchParams.get('page') ?? '1';
  newURL.searchParams.delete('page');
  newURL.searchParams.append('page', parseInt(currentPage) + 1);
  return newURL;
}

// check that the given HTML document is not the last page of results
function notLastPage(doc) {
  if (isDesktop) {
    let button = doc.querySelector("div.page_navi a:nth-last-child(2)");
    return (button && button.innerText === ">");
  } else {
    let button = doc.querySelector("li.page--arrow:nth-last-child(2)");
    return (button != null);
  }
}

// the main function
function buyeeSellerFilter () {

  // initial load of data
  unSerialiseData();

  // refresh watchlist

  fetch("https://buyee.jp/api/v1/watch_list/find", {
    credentials: "include",
    headers: { "X-Requested-With": "XMLHttpRequest" }
  })
    .then(response => response.json())
    .then(data => {
      buyeeWatchlist = data.data.list;
    });

  // reload data when tab regains focus
  document.addEventListener("visibilitychange", () => {
    if (!document.hidden) {
      // don't change hideHidden, so save its value first
      let hideHiddenSaved = hideHidden;
      unSerialiseData();
      hideHidden = hideHiddenSaved;
      processCardsIn(document);
    }
  });

  if (!isDesktop) {
    // disable the google translate popup (annoying with show/hide buttons)
    var style = `
#goog-gt-tt, .goog-te-balloon-frame{display: none !important;}
.goog-text-highlight { background: none !important; box-shadow: none !important;}
    `;
    var styleSheet = document.createElement("style");
    styleSheet.innerText = style;
    document.head.appendChild(styleSheet);
  }

  let container = document.querySelector('.g-main:not(.g-modal)');
  let resultsNode = container.children[0];

  // sometimes the results are broken into two lists of ten; if so, merge them.
  if (container.children.length > 1) {
    container.children[1].querySelectorAll("ul.auctionSearchResult li.itemCard").forEach(card => {
      resultsNode.appendChild(card);
    });
    container.children[1].style.display = "none";
  }

  // make link to show or hide hidden results
  let optionsLink = document.createElement("button");
  optionsLink.type = "button";
  optionsLink.id = "rg-show-hide-link";
  optionsLink.innerText = hideHidden ? "Show hidden" : "Hide hidden";
  optionsLink.onclick = (function() {
    hideHidden = !hideHidden;
    serialiseData();
    optionsLink.innerText = hideHidden ? "Show hidden" : "Hide hidden";
    processCardsIn(document);
  });
  optionsLink.style.display = 'inline-block';
  optionsLink.style.width = '110px';

  // put link in the search options bar
  let optionsNode = document.createElement("span");
  optionsNode.classList.add('result-num');
  if (isDesktop) {
    optionsNode.style.left = '20%';
  }
  optionsNode.appendChild(optionsLink);

  if (isDesktop) {
    document.querySelector(".result-num").parentNode.appendChild(optionsNode);
  } else {
    optionsNode.style.display = 'inline';
    document.querySelector(".result-num").appendChild(optionsNode);
  }

  // perform initial processing of cards
  processCardsIn(document);

  // process favourite stars

  document.addEventListener("click", e => {
    const watchButton = e.target.closest("div.watchButton");
    if (!watchButton) return;

    e.preventDefault();
    e.stopImmediatePropagation();

    let id;
    if (isDesktop) {
      id = watchButton.dataset.auctionId;
    } else {
      id = watchButton.parentNode.parentNode.dataset.id;
    }
    const card = watchButton.closest("li.itemCard");
    const url = card.querySelector('.itemCard__itemName a').href;
    let item = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/);
    if (item) { item = item[1]; }

    if (watchButton.classList.contains('is-active')) {
      fetch("https://buyee.jp/api/v1/watch_list/remove", {
        "credentials": "include",
        "headers": {
          "X-Requested-With": "XMLHttpRequest",
          "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
        },
        "method": "POST",
        "body": `auctionId=${id}`})
        .then(() => {
          buyeeWatchlist.splice(buyeeWatchlist.indexOf(id), 1);
          watchButton.classList.remove("is-active");
          watchButton.firstElementChild.classList.remove("g-feather-star-active");
          watchButton.firstElementChild.classList.add("g-feather-star");
        });
    } else {
      fetch("https://buyee.jp/api/v1/watch_list/add", {
        "credentials": "include",
        "headers": {
          "X-Requested-With": "XMLHttpRequest",
          "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
        },
        "method": "POST",
        "body": `auctionId=${id}&buttonType=search&isSignupRedirect=false`})
        .then(() => {
          buyeeWatchlist.push(id);
          watchButton.classList.add("is-active");
          watchButton.firstElementChild.classList.remove("g-feather-star");
          watchButton.firstElementChild.classList.add("g-feather-star-active");
        });
    }
    addCardToQueue(card,url,item);
  });

  // image lazy loader
  const imageObserver = new IntersectionObserver(loadImage);

  function loadImage(entries, observer) {
    entries.forEach(entry => {
      if (!entry.isIntersecting) {
        return;
      }

      const target = entry.target;
      target.src = target.getAttribute('data-src');
      target.removeAttribute('data-src');
      target.style.background = '';

      observer.unobserve(target);
    });
  }

  // load subsequent pages of results if navi bar on screen
  var currentURL = document.location;
  var currentPage = document;
  var naviOnScreen = false;

  const loadingNode = makeLoadingNode();

  function loadPageLoop() {
    // code to add further pages of results; loop over pages,
    // with a minimum 100ms delay between to avoid rampaging
    // robot alerts (unlikely as the loads are pretty slow)
    // stop if the navi bar is no longer on screen (so don't load infinitely)
    setTimeout(() => {
      if (naviOnScreen && notLastPage(currentPage)) {
        // display loading node
        resultsNode.appendChild(loadingNode);
        loadingNode.style.removeProperty('display');

        // get next page of results
        currentURL = nextPageURL(currentURL);
        fetchURL(currentURL)
          .then(page => {
            currentPage = page;
            processCardsIn(currentPage);
            moveCards(currentPage,resultsNode, loadingNode, imageObserver);
            loadPageLoop();
          });
      } else if (naviOnScreen) {
        // finished loading pages, hide loading node
        loadingNode.style.display = 'none';
      }
    }, 100);
  }

  // function to handle navi bar appearing/disappearing from screen
  function handleIntersection(entries) {
    entries.map(entry => {
      naviOnScreen = entry.isIntersecting
    });
    if (naviOnScreen) loadPageLoop();
  }

  // 540 px bottom margin so that next page loads a bit before navi bar appears on screen
  const loadObserver = new IntersectionObserver(handleIntersection, { rootMargin: "0px 0px 540px 0px" });

  loadObserver.observe(document.querySelector(isDesktop ? "div.page_navi" : "ul.pagination"));

  // clean up old blacklisted items
  cleanItems();
}

// stuff to handle loading stage of page


if (document.querySelector('.g-main:not(.g-modal)')) {
  buyeeSellerFilter();
} else {
  const startupObserver = new MutationObserver(() => {
    if (document.querySelector('.g-main:not(.g-modal)')) {
      startupObserver.disconnect();
      buyeeSellerFilter();
    }
  });

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