Buyee Seller Filter

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

当前为 2025-10-12 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.60
// @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.dateStr)
  }
  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%',
      width: '50px',
      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.querySelectorAll('.rg-node').forEach(node => node.remove())
  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"));

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