Buyee Seller Filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.72
// @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
// @require      https://unpkg.com/[email protected]/libs/lz-string.min.js
// ==/UserScript==

/* eslint-disable no-sequences */

/*** DOM utils ***/

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
}

Document.prototype.on = Element.prototype.on = function (type, selectorOrHandler, maybeHandler, capture=false) {
  // direct binding: el.on('click', fn)
  if (typeof selectorOrHandler === 'function') {
    this.addEventListener(type, selectorOrHandler, maybeHandler)
    return this
  }

  // delegated binding: el.on('click', 'button', fn)
  const selector = selectorOrHandler
  const handler = maybeHandler
  this.addEventListener(type, ev => {
    const el = ev.target.closest(selector)
    el && this.contains(el) && handler.call(el, ev, el)
  }, capture)
  return this
}

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

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

  children.flat().forEach(c => e.append(c instanceof Node ? c : document.createTextNode(c)))

  return e
}

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

const Dom = {
  hide: el => el && (el.style.display = 'none'),
  show: el => el && el.style.removeProperty('display'),
  hidden: el => getComputedStyle(el)?.display === 'none'
}

/*** state ***/

const BFState = (() => {

  const compress = str => 'BFLZ:' + LZString.compressToUTF16(str) // eslint-disable-line no-undef
  const decompress = str =>
    str?.startsWith('BFLZ:')
      ? LZString.decompressFromUTF16(str.slice(5)) // eslint-disable-line no-undef
      : str

  const savedKeys =['sellers', 'items', 'hide']

  const state = {
    sellers: {},
    items: {},
    hide: true,

    save() {
      const doSave = () =>
        savedKeys.forEach(k =>
          (localStorage[`BF${k}`] = compress(JSON.stringify(this[k]))));
      'requestIdleCallback' in window
        ? requestIdleCallback(doSave)
        : setTimeout(doSave, 50)
    },

    load() {
      savedKeys.forEach(k => {
        const v = localStorage[`BF${k}`]
        if (!v) return
        this[k] = JSON.parse(decompress(v))
        !v.startsWith('BFLZ:') && (localStorage[`BF${k}`] = compress(v))
      })
    },

    clean() {
      const now = Date.now(), WEEK = 6048e5
      Object.entries(this.items).forEach(([k,v]) =>
        now - v.lastSeen > WEEK && delete this.items[k])
      this.save()
    },

    isDesktop: !/android|webos|iphone|ipad|ipod|blackberry|windows phone/i.test(navigator.userAgent)
  }

  return state
})()

/*** image filters ***/

const BFFilters = (() => {
  const filters = {
    hue: { txt:'Hue', step:1, min:-180, max:180, val:0, def:0,
           filt() { return `hue-rotate(${this.val}deg)` }},
    sat: { txt:'Sat', step:2, min:0, max:300, val:100, def:100,
           filt() { return `saturate(${this.val}%)` }},
    bri: { txt:'Lum', step:1, min:0, max:300, val:100, def:100,
           filt() { return `brightness(${this.val}%)`}},
    con: { txt:'Con', step:2, min:0, max:300, val:100, def:100,
           filt() { return `contrast(${this.val}%)`}}
  }

  const act = (filt, sgn) => {
    const a = filters[filt]
      a.val = Math.max(a.min, Math.min(a.max, a.val + a.step * sgn))
  }
  const style = () => Object.values(filters).map(v => v.filt()).join(' ')
  const reset = o => Object.entries(filters).forEach(([k,v]) => (v.val = (o?.[k] ?? v.def)))
  const values = () => Object.fromEntries(Object.entries(filters).map(([k,v]) => [k, v.val]))
  const changed = () => Object.fromEntries(Object.entries(filters).filter(([k,v]) => v.val !== v.def).map(([k,v]) => [k, v.val]))
  const names = () => Object.fromEntries(Object.entries(filters).map(([k,v]) => [k, v.txt]))
  const styleOf = o => (reset(o), style())

  return { act, style, reset, values, changed, names, styleOf }
})()



/*** smartphoto enhancements ***/

const BFPhoto = (() => {
  const smartPhoto = new SmartPhoto(".js-smartPhoto", { showAnimation: false }) // eslint-disable-line no-undef
  const node = $('.smartphoto')
  const pending = []
  let open = false
  let id

  const add = img => {
    img.classList.add('js-smartPhoto')
    open ? pending.push(img) : smartPhoto.addNewItem(img)
  }

  const addControls = overlay => {
    if ($('.sp-filters')) return

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

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

    overlay.append(toggle,panel)

    const update = () => {
      const style = BFFilters.style()
      const sellerid = BFState.items[id]?.seller
      const sellerThumbs = Array.from($$('li.itemCard[data-id]'))
                          .filter(card => BFState.items[card.dataset.id]?.seller === sellerid)
                          .map(card => card.$('.g-thumbnail__outer img')).filter(Boolean)

      ;[...($('.smartphoto-list')?.$$('.smartphoto-img') ?? []),
        ...($('.smartphoto-nav')?.$$('a') ?? []),
        ...sellerThumbs
      ].forEach(img => (img.style.filter = style))
      panel.$$('.sp-val').forEach(s => (s.textContent = BFFilters.values()[s.dataset.for]))
    }

    const act = (name, step) => {
      name === 'reset' ? BFFilters.reset() : BFFilters.act(name, step)
      update()
    }

    let holdTimer, activeBtn

    const startHoldEvs = (...evs) => evs.forEach(ev =>
      panel.on(ev, 'button[data-action]', (e, btn) => {
        e.preventDefault()
        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)
      })
    )

    const stopHoldEvs = (...evs) => evs.forEach(ev =>
      panel.on(ev, () => {
        activeBtn && (activeBtn.style.transform = '')
        clearTimeout(holdTimer)
        clearInterval(holdTimer)
        holdTimer = activeBtn = null
      })
    )

    panel.on('bf-update', update)
    startHoldEvs('mousedown', 'touchstart')
    stopHoldEvs('mouseup', 'mouseleave', 'touchend', 'touchcancel')

    update()
  }

  const load = () => {
    const sellerid = BFState.items[id]?.seller
    const seller = sellerid && BFState.sellers[sellerid]
    BFFilters.reset(seller?.filters)
  }

  const save = () => {
    const sellerid = BFState.items[id]?.seller
    const seller = sellerid && BFState.sellers[sellerid]
    seller && (seller.filters = BFFilters.changed(), BFState.save())
  }

  document.on('pointerdown', 'a.js-smartPhoto', (e, a) => {
    open = true
    id = a.dataset.group
    load()
  }, true)

  smartPhoto.on('open', () => {
    id = smartPhoto.data.currentGroup
    load()
    $('.sp-filters')?.dispatchEvent(new CustomEvent('bf-update'))
  })

  smartPhoto.on('close', () => {
    save()
    open = false
    pending.forEach(e => smartPhoto.addNewItem(e))
    pending.length = 0
  })

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

  document.on('pointerdown', (e) => {
    const panel = $('.sp-filters')
    const toggle = $('.sp-toggle')
    panel && panel.style.opacity === '1'
          && !panel.contains(e.target)
          && !toggle.contains(e.target)
          && toggle.click()
  })

  return { add }
})()

/*** countdown timers ***/

const BFCountdown = (() => {
  const countdowns = new Map()

  function timeUntil(dateStr) {
    const jstDate = new Date(dateStr + ' GMT+0900')
    const now = new Date()
    const diff = (jstDate - now)/1000

    if (diff <= 0) return "Ended"

    const fmt = (n, t) => n > 0 ? `${n} ${t}${n > 1 ? 's' : ''}` : null

    if (diff < 1800) {
      const min = Math.floor((diff / 60) % 60)
      const sec = Math.floor(diff % 60)
      return [fmt(min, 'minute'), fmt(sec, 'second')].filter(Boolean).join(' ')
    } else {
      const days = Math.floor(diff / 86400)
      const hrs = Math.floor((diff / 3600) % 24)
      const min = Math.floor((diff / 60) % 60)
      return [fmt(days, "day"), fmt(hrs, "hour"), fmt(min, "minute")].filter(Boolean).join(' ')
    }
  }

  function add(date, el) {
    countdowns.set(el, date)
    el.$('.g-text').textContent = timeUntil(date)
  }

  function remove(el) {
    countdowns.delete(el)
  }

  function update() {
    Object.entries(countdowns).forEach(([el,date]) =>
      (el.$('.g-text').textContent = timeUntil(date)))
    setTimeout(update, 1000)
  }

  update()

  return { add, remove }
})()

/*** card scraping ***/

const BFQueue = (callback) => {
  const MAX_CONCURRENT = 5
  const DELAY = 100

  const processingQueue = []
  const inFlightItems = new Set()
  const readItems = new Set()
  let activeCount = 0

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

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

    fetchURL(url)
    .then(doc => {
      callback(id, doc)
      readItems.add(id)
    })
    .catch(err => console.error('fetchURL failed for', url, err))
    .finally(() => {
      inFlightItems.delete(id)
      setTimeout(() => {
        activeCount--
        run()
      }, DELAY)
    })
    activeCount < MAX_CONCURRENT && run()
  }

  // force = true allows reprocessing of a card
  function add(item, force = false) {
    if (inFlightItems.has(item.id) || (readItems.has(item.id) && !force)) return
    processingQueue.push(item)
    inFlightItems.add(item.id)
    run()
  }

  return { add }
}

const BFCardScraper = BFQueue((id, doc) => {
  const sellernode = $x('//a[contains(@href,"search/customer")]',doc)
  const [, seller] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || []
  const data = { hide: false, seller }
  const bidBtn = $x('//button[contains(normalize-space(.), "Place Bid")]', doc)
  const msg = doc.$('.messagebox, .message.error')

  seller && (BFState.sellers[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

  // can I bid?
  bidBtn?.classList.contains('disable')
    ? data.blocked = (msg?.textContent.includes('manually reviewing')) ? 'P' : true // P = pending
    : delete data.blocked

  // can I request a review?
  msg?.$('a')?.href?.includes('inquiry')
    ? (data.review = msg.$('a').href)
    : delete data.review

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

  BFState.items[id] = data
  BFState.save()
  annotateCard(document.$(`li.itemCard[data-id="${id}"]`))
})

/*** handle watchlist ***/

const BFWatchlist = (() => {
  let watchlist = []

  const apiCall = (url, body = '') =>
    fetch(`https://buyee.jp/api/v1/${url}`, {
      credentials: 'include',
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
      },
      method: 'POST',
      body
    }).then(r => r.json())

  const has = id => watchlist.includes(id)

  const refresh = () => apiCall('watch_list/find')
    .then(data => (watchlist = data?.data?.list ?? []))

  const add = id => apiCall('watch_list/add', `auctionId=${id}&buttonType=search&isSignupRedirect=false`)
    .then(() => watchlist.push(id))

  const remove = id => apiCall('watch_list/remove', `auctionId=${id}`)
    .then(() => watchlist.splice(watchlist.indexOf(id), 1))

  const toggle = id => has(id) ? remove(id) : add(id)

  const updateBtn = (btn, id) => {
    const active = has(id)
    btn.classList.toggle('is-active', active)
    btn.firstElementChild.classList.toggle('g-feather-star-active', active)
    btn.firstElementChild.classList.toggle('g-feather-star', !active)
  }

  return { refresh, add, remove, toggle, has, updateBtn }

})()

function annotateCard(card) {
  const url = card.$('.itemCard__itemName a').href
  const id = card.dataset.id = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/)?.[1] || ''
  const item = BFState.items[id]
  const seller = item && BFState.sellers[item.seller]
  const infoList = card.$('.itemCard__infoList')
  const statusList = card.$('ul.auctionSearchResult__statusList')

  const scrape = (force = false) => BFCardScraper.add({ url, id }, force)

  const newInfoNode = (title, body) =>
    el('li.itemCard__infoItem.bf-node', {},
      el('span.g-title', { text: title }),
      el('span.g-text', ...(typeof body === 'string' ? [{ text : body }] : [{}, body])))

  const resetCard = () => {
    card.$$('.bf-node').forEach(node => node.remove())
    card.$$('li.itemCard__infoItem').forEach(node => node.textContent.trim() === '' && node.remove())
    card.$('.g-thumbnail__outer a').href = "#"
  }

  const handleSellerInfo = () =>
    infoList.append(newInfoNode(
      'Seller',
      el('a', {
        text: seller.name,
        href: seller.url,
        target: '_blank',
        rel: 'noopener noreferrer'
      })
    ))

  const handleTimeLeft = () => {
    const oldTime = infoList.firstElementChild
    const newTime = newInfoNode('Time Left','')
    newTime.classList.remove('bf-node')
    BFCountdown.remove(oldTime)
    oldTime.replaceWith(newTime)
    BFCountdown.add(item.endTime, newTime)
  }

  const handleWatchers = () => 
    card.$('.g-feather')?.parentNode
    .append(el('span.watchList__watchNum.bf-node', {
        text: item.watchNum,
        style: 'user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none'
    }))

  const handleBlocked = () => {
    const node =
      item.review ? el('button.bf-node.g-feather-alert-triangle', {
        type: 'button',
        text: 'Unblock',
        style: { color: 'red', backgroundColor: 'pink' },
        onclick: () => { window.open(item.review, '_blank'); delete item.review; scrape('force') }
      }) :
      item.blocked ? el('li.bf-node.g-feather-alert-triangle.bf-node.auctionSearchResult__statusItem', {
        text: item.blocked === 'P' ? 'Pending' : 'Blocked',
        style: { color: 'red', backgroundColor: 'pink' }
      }) :
      null

    node && statusList.append(node)
  }

  const attachSmartPhoto = () => {
    const thumb = card.$('.g-thumbnail__outer a')
    if (thumb?.classList.contains('js-smartPhoto') || !item.images?.length) return

    thumb.href = item.images[0]
    thumb.dataset.group = id
    BFPhoto.add(thumb)

    seller.filters && (thumb.$('img').style.filter = BFFilters.styleOf(seller.filters))

    const hidden = el('div', { style: 'display:none' })
    thumb.parentNode.append(hidden)

    item.images.slice(1).forEach( image => {
      const link = el('a', { href: image, dataset: { group: id }})
      hidden.append(link)
      BFPhoto.add(link)
    })
  }

  const handleShowHideBtns = () => {
    const btn = (v, txt, fn) =>
      el('button.bf-node', {
        type: 'button',
        text: [v.hide ? 'Show' : 'Hide', txt].join(' '),
        onclick: () => { v.hide = !v.hide; BFState.save(); fn() }
      })
    const hidden = seller.hide || item.hide

    if (hidden && BFState.hide) {
      Dom.hide(card)
    } else {
      Dom.show(card)
      card.style.opacity = hidden ? '0.9' : ''
      card.style['background-color'] = hidden ? '#ffbfbf' : ''
      infoList.append(btn(seller, 'seller', () => annotateCardsIn(document)))
      if (!seller.hide || BFState.hide) statusList.append(btn(item, '', () => annotateCard(card)))
      new Date(item.endTime + ' GMT+0900') < Date.now() && scrape()
      item.blocked && scrape()
    }
  }

  resetCard()
  if (!item) { scrape(); return }
  BFWatchlist.has(id) && scrape()
  item.lastSeen = Date.now()

  handleSellerInfo()
  handleTimeLeft()
  handleWatchers()
  handleBlocked()
  attachSmartPhoto()
  handleShowHideBtns()
}

function annotateCardsIn(element) {
  element.$$('ul.auctionSearchResult li.itemCard:not(.rg-loading)').forEach(annotateCard)
  BFState.save()
}

const moveCards = (() => {
  const observer = new IntersectionObserver((entries, obs) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue
      target.src = target.dataset.src
      delete target.dataset.src
      target.style.background = ''
      obs.unobserve(target)
    }
  })

  return function moveCards(src, tgt, loadingEl) {
    if (!src || !tgt) return

    src.$$('ul.auctionSearchResult li.itemCard').forEach(card => {
      const id = card.dataset.id
      if (tgt.$(`li.itemCard[data-id="${id}"]`)) return

      tgt.append(card)

      !(Dom.hidden(card)) && !(Dom.hidden(loadingEl)) && Dom.hide(loadingEl)

      const img = card.$('img.lazyLoadV2')
      img && observer.observe(img)

      const btn = card.$('div.watchButton')
      btn && BFWatchlist.updateBtn(btn, id)
    })
  }
})()

function buyeeSellerFilter () {
  const container = document.$('.g-main:not(.g-modal)')
  const resultsNode = container.firstElementChild

  const handleVisibilityChange = () => {
    document.on('visibilitychange', () => {
      if (!document.hidden) {
        const hide = BFState.hide
        BFState.load()
        BFState.hide = hide
        annotateCardsIn(document)
      }
    })
  }

  const injectStyles = () =>
    document.head.append(el('style', { text: BFState.isDesktop
    ? `
button.bf-node { border: 1px solid silver; padding: 3px 5px; border-radius: 3px; margin: 2px }
button.bf-node:hover { filter: brightness(90%); }
    ` : `
button.bf-node { border: 1px solid silver; padding: 3px 5px; border-radius: 3px; margin: 2px }
#goog-gt-tt, .goog-te-balloon-frame{display: none !important;}
.goog-text-highlight { background: none !important; box-shadow: none !important;}
    `}))

  const mergeResultsLists = () => {
    if (container.children.length > 1) {
      const second = container.children[1]
      second.$$('ul.auctionSearchResult li.itemCard')
            .forEach(card => resultsNode.append(card))
      Dom.hide(second)
    }
  }

  const handleShowHideLink = () => {
    const showHideParent = BFState.isDesktop
      ? document.$('.result-num').parentNode
      : document.$('.result-num')

    showHideParent.append(
      el('span.result-num', { style: BFState.isDesktop ? 'left:20%' : 'display:inline' },
        el('button#rg-show-hide-link', {
          type: 'button',
          text: (BFState.hide ? 'Show' : 'Hide') + ' hidden',
          onclick: function () {
            BFState.hide = !BFState.hide
            BFState.save()
            this.innerText = (BFState.hide ? 'Show' : 'Hide') + ' hidden'
            annotateCardsIn(document)
          },
          style: 'display:inline-block; width:110px'
        })
      )
    )
  }

  const handleWatchBtns = () => {
    document.on('click', 'div.watchButton', (e, btn) => {
      e.preventDefault()
      e.stopImmediatePropagation()

      const id = BFState.isDesktop
        ? btn.dataset.auctionId
        : btn.parentNode.parentNode.dataset.id

      const card = btn.closest("li.itemCard")
      const url = card.$('.itemCard__itemName a')?.href

      BFWatchlist.has(id) || BFCardScraper.add({ url, id }, 'force')
      BFWatchlist.toggle(id)
      BFWatchlist.updateBtn(btn, id)
    })
  }

  const handleInfiniteScroll = () => {
    let currentURL = new URL(document.location)
    let currentPage = document
    let naviOnScreen = false

    const loadingNode = 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' })
    )

    const nextPageURL = url => {
      url.searchParams.set('page', 1 + (+url.searchParams.get('page') || 1))
      return url
    }

    const lastPage = doc => BFState.isDesktop
      ? !doc.$('div.page_navi a:nth-last-child(2)')
        || doc.$('div.page_navi a:nth-last-child(2)').innerText !== '>'
      : !doc.$('li.page--arrow a span.last')

    const loadPageLoop = () => {
      if (!naviOnScreen || lastPage(currentPage)) {
        naviOnScreen && Dom.hide(loadingNode)
        return
      }

      currentURL = nextPageURL(currentURL)
      resultsNode.append(loadingNode)
      loadingNode.$('.g-title').innerText = 'Loading page ' + currentURL.searchParams.get('page')
      Dom.show(loadingNode)

      fetchURL(currentURL)
      .then(page => {
        currentPage = page
        annotateCardsIn(currentPage)
        moveCards(currentPage, resultsNode, loadingNode)
      })
      .catch(e => console.error('Failed to load page: ', e))
      .finally(() => setTimeout(loadPageLoop, 100))
    }

    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => (naviOnScreen = e.isIntersecting))
      naviOnScreen && loadPageLoop()
    }, { rootMargin: "0px 0px 540px 0px" })

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

  BFState.load()
  injectStyles()
  handleVisibilityChange()
  mergeResultsLists()
  handleShowHideLink()
  handleWatchBtns()
  handleInfiniteScroll()

  BFWatchlist.refresh()
  .then(() => annotateCardsIn(document))
  .then(() => BFState.clean())
}

// stuff to handle loading stage of page

const waitFor = (sel, fn) => {
  const el = $(sel)
  if (el) return fn(el)
  new MutationObserver((o, obs) => {
    const el = $(sel)
    if (el) { obs.disconnect(); fn(el) }
  }).observe(document.body, { childList: true, subtree: true });
}

waitFor('.g-main:not(.g-modal)', buyeeSellerFilter)