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.61
// @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==

/* eslint-disable no-alert, no-console */

Document.prototype.$ = Element.prototype.$ = function(sel) { return this.querySelector(sel) }
Document.prototype.$$ = Element.prototype.$$ = function(sel) { return this.querySelectorAll(sel) }
const $ = Element.prototype.$.bind(document)
const $$ = Element.prototype.$$.bind(document)

function $x(path, ctx = document) {
  return ctx.evaluate(path, ctx, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
}

//function el(tag, cls = '', style = '', text) {
//  const n = document.createElement(tag)
//  if (cls) n.className = cls
//  if (style) n.style.cssText = style
//  if (text) n.textContent = text
//  return n
//}

function el(sel = 'div', props = {}, ...children) {
  const [, tag = 'div', id, cls = ''] = sel.match(/^([a-z]+)?(?:#([\w-]+))?(.*)?$/) || []
  const e = document.createElement(tag)
  if (id) e.id = id
  if (cls) e.classList.add(...cls.split('.').filter(Boolean))

  for (const [k, v] of Object.entries(props)) {
    if (k === 'style') typeof v === 'string' ? e.style.cssText = v : Object.assign(e.style, v)
    else if (k === 'text') e.textContent = v
    else if (k === 'html') e.innerHTML = v
    else if (/^on[A-Z0-9]/i.test(k)) e[k] = v
    else e.setAttribute(k, v)
  }

  for (const c of children.flat()) e.append(c instanceof Node ? c : document.createTextNode(c))

  return e
}

// stored state
let sellersData = {}; // metadata associated to sellers
let itemsData = {}; // metadata associated to items
let hideHidden = true; // should hidden items actually be hidden?

// page state
let buyeeWatchlist = []
let smartPhoto = new SmartPhoto(".js-smartPhoto", { showAnimation: false });
let smartPhotoOpen = false;
let pendingSmartPhotoItems = [];
let smartPhotoNode = $('.smartphoto');

// buyee watchlist
// are we on the desktop site?
const 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

function serialiseData() {
  localStorage.sellersData = JSON.stringify(sellersData)
  localStorage.hideHidden = JSON.stringify(hideHidden)
  localStorage.itemsData = JSON.stringify(itemsData)
}

function unSerialiseData() {
  sellersData = localStorage.sellersData ? JSON.parse(localStorage.sellersData) : {}
  hideHidden = localStorage.hideHidden ? JSON.parse(localStorage.hideHidden) : true
  itemsData = localStorage.itemsData ? JSON.parse(localStorage.itemsData) : {}
}

function fetchURL(url) {
  return fetch(url)
    .then(r => r.ok ? r.text() : Promise.reject(`HTTP ${r.status}`))
    .then(html => new DOMParser().parseFromString(html, 'text/html'))
}

// create a node which shows/hides the given seller when clicked

function makeStatusToggle(data, caption, callback) {
  return el('button.rg-node', {
      type: 'button',
      text: caption,
      onclick: () => {
        console.log(data)
        data.hide = !data.hide
        serialiseData()
        callback()
      }
  })
}

function makeSellerStatusNode(seller,caption) {
  return makeStatusToggle(sellersData[seller], caption, () => processCardsIn(document))
}

function makeItemStatusNode(item,card,caption) {
  return makeStatusToggle(itemsData[item], caption, () => processCard(card))
}

//   const node = el('button', isDesktop ? 'rg-node' : 'rg-node g-text', '', status)
//   node.type = 'button'
//   node.onclick = () => {
//     const s = sellersData[seller]
//     s.hide = !s.hide
//     serialiseData()
//     processCardsIn(document)
//   }
//   return node
// }

// create a node which shows/hides the given item when clicked
// function makeItemStatusNode(item,card,status) {
//   const node = el('button', 'rg-node auctionSearchResult__statusItem', '', status)
//   node.type = 'button'
//   node.onclick = () => {
//     const s = itemsData[item]
//     s.hide = !s.hide
//     serialiseData()
//     processCard(card)
//   }
//   return node
// }

// clear previous info nodes on a card
function clearInfoNodes(card) {
  const list = card.$('.itemCard__infoList')
  while (list.childElementCount > 2) list.lastElementChild.remove()
  card.$('.watchList__watchNum')?.remove()
}

function addSellerNode(card, seller) {
  const list = card.$('.itemCard__infoList')
  const node = list.firstElementChild.cloneNode(true)
  node.$('.g-title').textContent = 'Seller'

  const text = node.$('.g-text')
  text.textContent = ''
  text.append(el('a', {
    text: seller.name,
    href: seller.url,
    target: '_blank',
    rel : 'noopener noreferrer'
  }))

  list.append(node)
  return node
}

// add number of watches to item card
function addWatchersNode(card, count) {
  const star = card.$('.g-feather').parentNode
  star.append(el('span.watchList__watchNum', { text: count }))
}

// make a "loading" node for the infinite scrolling
function makeLoadingNode() {
  return el('li.itemCard.rg-loading', {},
    el('div.imgLoading', { style: 'height: 50px' }),
    el('div.g-title', { text: 'Loading page 2', style: 'text-align: center; height: 50px' })
  )
}

// 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() {
  const now = Date.now()
  for (const url in itemsData) {
    if (now - itemsData[url].lastSeen > 604800000) delete itemsData[url]
  }
  serialiseData()
}

// countdown timers

const countdowns = [];

// Add a countdown
function addCountdown(date, el) {
  countdowns.push({ date, el })
  el.textContent = timeUntil(date)
}

// update all countdowns
function updateCountdowns() {
  const now = new Date()
  for (const cd of countdowns) {
    cd.el.textContent = timeUntil(cd.date)
  }
  setTimeout(updateCountdowns, 1000)
}

updateCountdowns()


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

function sleep(ms) {
  return new Promise(r => setTimeout(r, ms))
}

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

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

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

  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)
{
  const sellernode = $x('//a[contains(@href,"search/customer")]',doc)
  const [, seller] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || []
  const data = { hide: false, seller };

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

  // get end time
  data.endTime = $x('//li[.//*[contains(text(),"Closing Time")]]',doc)
    ?.$('span:last-child')
    ?.textContent

  // get number of watchers
  data.watchNum =
    Array.from(doc.$$('script'))
         .map(s => s.textContent.match(/buyee\.TOTAL_WATCH_COUNT\s*=\s*(\d+);/))
         .find(Boolean)?.[1]
    ?? doc.$('.watchButton__watchNum')?.textContent
    ?? 0

  // get image links
  data.images = Array.from(doc.$$('.js-smartPhoto')).map(node => node.href)

  itemsData[item] = data
  serialiseData()
}



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

smartPhotoNode.addEventListener('close', () => {
  smartPhotoOpen = false
  for (const item in pendingSmartPhotoItems) smartPhoto.addNewItem(item)
  pendingSmartPhotoItems = []
})

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

const actions = {
  hue: { txt:'H', step:1, min:-180, max:180, val:0, def:0},
  sat: { txt:'S', step:2, min:0, max:300, val:100, def:100 },
  bri: { txt:'V', step:1, min:0, max:300, val:100, def:100 }
}

const spObserver = new MutationObserver(() => smartPhotoNode?.$('.sp-filters') || addControls(smartPhotoNode))
spObserver.observe(smartPhotoNode, { childList: true, subtree: true })

smartPhotoNode.addEventListener('open', () => Object.values(actions).forEach(a => (a.val = a.def)))

function addControls(overlay) {
  if (overlay.$('.sp-filters')) return

  const toggle = el('button.sp-toggle', {
    text: 'Adjust',
    style: {
      position: 'absolute',
      top: '15px',
      left: '50%',
      transform: 'translateX(-50%)',
      background: 'rgba(0,0,0,0.4)',
      color: 'white',
      border: 'none',
      borderRadius: '10%',
      padding: '5px',
      height: '30px',
      cursor: 'pointer',
      zIndex: 10000
    },
    onclick: () => {
      const shown = panel.style.opacity === '1'
      panel.style.opacity = shown ? '0' : '1'
      panel.style.pointerEvents = shown ? 'none' : 'auto'
    }
  })

  const panel = el('div.sp-filters', {
    style: {
      position: 'absolute',
      top: '50px',
      left: '50%',
      transform: 'translateX(-50%)',
      display: 'flex',
      flexDirection: 'column',
      gap: '8px',
      zIndex: 9999,
      background: 'rgba(0,0,0,0.4)',
      padding: '6px 10px',
      borderRadius: '8px',
      userSelect: 'none',
      color: 'white',
      font: '14px sans-serif',
      touchAction: 'manipulation',
      opacity: 0,
      pointerEvents: 'none'
    }},
    ...Object.entries(actions).map(([k, a]) =>
      el('div.sp-row', { style: {
        display: 'flex',
        alignItems: 'center',
        gap: '8px'
      }},
        el('button', {
          'data-action' : k,
          'data-step': -a.step,
          translate: 'no',
          text: a.txt + '-',
          style: 'min-width: 42px'}),
        el('span.sp-val', {
          'data-for' : k,
          text: a.val,
          style: 'min-width: 25px; text-align: center'
        }),
        el('button', {
          'data-action' : k,
          'data-step': a.step,
          translate: 'no',
          text: a.txt + '+',
          style: 'min-width: 42px'
        })
      )),
    el('button', {
      'data-action' : 'reset',
      translate: 'no',
      text: 'Ø',
      style: 'min-width: 42px'
    })
  )

  overlay.append(toggle,panel)

  const update = () => {
    const img = $('.smartphoto-list .current .smartphoto-img')
    if (img) img.style.filter = `hue-rotate(${actions.hue.val}deg) saturate(${actions.sat.val}%) brightness(${actions.bri.val}%)`
    panel.$$('.sp-val').forEach(s => (s.textContent = actions[s.dataset.for].val))
  }

  const act = (name, step) => {
    if (name === 'reset') Object.values(actions).forEach(a => (a.val = a.def))
    else {
      const a = actions[name]
      a.val = Math.max(a.min, Math.min(a.max, a.val + step))
    }
    update()
  }

  // --- Hold-to-repeat setup ---
  let holdTimer, activeBtn

  function startHold(btn) {
    const {action, step} = btn.dataset
    activeBtn = btn
    btn.style.transform = 'scale(0.92)'
    act(action, +step)
    holdTimer = setTimeout(() => {
      holdTimer = setInterval(() => act(action, +step), 30)
    }, 150)
  }

  function stopHold() {
    activeBtn?.style && (activeBtn.style.transform = '')
    clearTimeout(holdTimer)
    clearInterval(holdTimer)
    holdTimer = activeBtn = null
  }

  ;['mousedown', 'touchstart'].forEach( ev =>
    panel.addEventListener(ev, e => {
      const btn = e.target.closest('button[data-action]')
      if (btn) {
        e.preventDefault()
        startHold(btn)
      }
    })
  )

  ;['mouseup', 'mouseleave', 'touchend', 'touchcancel']
    .forEach( ev => panel.addEventListener(ev, stopHold))

  update()
}

// 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
  const url = card.$('.itemCard__itemName a').href;
  const [, item] = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/) || []

  card.$('.g-thumbnail__outer a').href = "#"

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

  // refresh any cards on the watchlist
  if (buyeeWatchlist.includes(item) && !readItems.has(item)) addCardToQueue(card, url, item)

  const itemData = itemsData[item]
  const seller = itemData.seller
  const sellerData = sellersData[seller]

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

  // clear old cruft
  card.$$('.rg-node').forEach(node => node.remove())
  clearInfoNodes(card)

  let statusNode = card.$('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"))

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

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


  const hideCard = (() => {
    card.style.display = 'none';
    card.style.removeProperty('opacity');
    card.style.removeProperty('background-color');
  });

  const showHiddenCard = (() => {
    card.style.opacity = '0.9';
    card.style['background-color'] = '#ffbfbf';
    card.style.removeProperty('display');
  });

  const showCard = (() => {
    card.style.removeProperty('opacity');
    card.style.removeProperty('background-color');
    card.style.removeProperty('order');
    card.style.removeProperty('display');
  });

  if (sellerData.hide) {
    if (hideHidden) {
      hideCard();
    } else {
      showHiddenCard();
      sellerNode.parentNode.append(makeSellerStatusNode(seller,'Show seller'));
    }
  } else if (itemData.hide) {
    if (hideHidden) {
      hideCard();
    } else {
      showHiddenCard();
      sellerNode.parentNode.append(makeSellerStatusNode(seller,'Hide seller'));
      statusNode.append(makeItemStatusNode(item,card,'Show'));
    }
  } else {
    showCard();
    sellerNode.parentNode.append(makeSellerStatusNode(seller,'Hide seller'));
    statusNode.append(makeItemStatusNode(item,card,'Hide'));
    // refresh if auction ended
    if (timeUntil(itemData.endTime) === 'Ended' && !readItems.has(item)) {
      addCardToQueue(card, url, item);
    }
  }
}

// process changes to all results cards in a given element
function processCardsIn(element) {
  element.querySelectorAll("ul.auctionSearchResult li.itemCard:not(.rg-loading)").forEach(processCard);
  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.append(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 = isDesktop ? watchButton.dataset.auctionId : 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) {
  const 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 a span.last");
    return (button != null);
  }
}

// the main function
function buyeeSellerFilter () {

  // initial load of data
  unSerialiseData();

  // 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.append(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.append(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.append(optionsLink);

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

  // refresh watchlist
  // perform initial processing of cards
  // clean up old items

  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))
  .then(() => processCardsIn(document))
  .then(cleanItems)

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

    e.preventDefault()
    e.stopImmediatePropagation()

    const id = isDesktop ? watchButton.dataset.auctionId : watchButton.parentNode.parentNode.dataset.id
    const card = watchButton.closest("li.itemCard")
    const url = card.querySelector('.itemCard__itemName a').href
    const [, item] = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/) || []

    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((entries, observer) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) return

      const target = entry.target
      target.src = target.dataset.src
      delete target.dataset.src
      target.style.background = ''

      observer.unobserve(target)
    }
  })

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

  const loadingNode = makeLoadingNode()

  function loadPageLoop() {
    setTimeout(() => {
      if (naviOnScreen && notLastPage(currentPage)) {
        currentURL = nextPageURL(currentURL)
        // display loading node
        resultsNode.append(loadingNode)
        loadingNode.$('.g-title').innerText = 'Loading page ' + currentURL.searchParams.get('page')
        loadingNode.style.removeProperty('display')

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

  // 540 px bottom margin so that next page loads a bit before navi bar appears on screen
  const loadObserver = new IntersectionObserver(entries => {
    entries.map(e => (naviOnScreen = e.isIntersecting))
    if (naviOnScreen) loadPageLoop()
  }, { rootMargin: "0px 0px 540px 0px" })

  loadObserver.observe($(isDesktop ? 'div.page_navi' : 'ul.pagination'))
}

// stuff to handle loading stage of page

if ($('.g-main:not(.g-modal)')) buyeeSellerFilter()
else {
  const startupObserver = new MutationObserver(() => {
    if ($('.g-main:not(.g-modal)')) {
      startupObserver.disconnect()
      buyeeSellerFilter()
    }
  })
  startupObserver.observe(document.body, { childList: true, subtree: true });
}