Buyee Seller Filter

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

目前為 2025-10-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.79
// @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, handlerOrCapture, capture=false) {
  // direct binding: el.on('click', fn)
  if (typeof selectorOrHandler === 'function') {
    this.addEventListener(type, selectorOrHandler, handlerOrCapture)
    return this
  }

  // delegated binding: el.on('click', 'button', fn)
  const selector = selectorOrHandler
  const handler = handlerOrCapture
  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 === 'data' ? 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, timeout = 10000) {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeout)

  return fetch(url, { signal: controller.signal })
    .then(r => (clearTimeout(timer), r.ok) ? r.text() : Promise.reject({ type: 'http', status: r.status }))
    .then(html => new DOMParser().parseFromString(html, 'text/html'))
    .catch(err => {
      err.type ??= err.name === 'AbortError' ? 'timeout' : err instanceof TypeError ? 'network' : 'other'
      return Promise.reject(err)
    })
}

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 isDesktop = !/android|webos|iphone|ipad|ipod|blackberry|windows phone/i.test(navigator.userAgent)

  const state = { sellers: {}, items: {}, hide: true }
  const stateUpd = { sellers: {}, items: {} }
  let initialised = false

  const compress = str => 'BFLZ:' + LZString.compressToUTF16(str) // eslint-disable-line no-undef
  const decompress = str => str?.startsWith('BFLZ:') ? LZString.decompressFromUTF16(str.slice(5)) : str // eslint-disable-line no-undef
  const safeParse = s => { if (s) try { return JSON.parse(decompress(s)) } catch { return undefined }
                           else return undefined }
  const load = () => {
    ['sellers', 'items'].forEach(key => {
      const r1 = localStorage[`BF${key}`], r2 = localStorage[`BFU${key}`]
      if (!r1 && !r2) return

      const v1 = safeParse(r1) ?? {}
      const v2 = safeParse(r2) ?? {}

      const store = state[key]
      Object.assign(store, v1)
      Object.entries(v2).forEach(([k,v]) => {
        store[k] ??= {}
        Object.assign(store[k], v)
      })
      delete localStorage[`BFU${key}`]
    })
    state.hide = safeParse(localStorage.BFhide) ?? true

    const now = Date.now(), WEEK = 6048e5
    const prune = (store, len) =>
      Object.entries(store).forEach(([k,v]) => v.lastSeen && now - v.lastSeen > len && delete store[k])
    prune(state.items, WEEK)
    prune(state.sellers, 24 * WEEK)

    ;['sellers', 'items'].forEach(k => (localStorage[`BF${k}`] = compress(JSON.stringify(state[k]))))
    initialised = true
  }

  const interface = key => {
    const get = k => {
      return state[key][k]
    }

    const set = (k,v) => {
      state[key][k] = v
      stateUpd[key][k] = v
      localStorage[`BFU${key}`] = JSON.stringify(stateUpd[key])
    }

    const assign = (k,v) => {
      state[key][k] ??= {}
      stateUpd[key][k] ??= {}
      Object.assign(state[key][k], v)
      Object.assign(stateUpd[key][k], v)
      localStorage[`BFU${key}`] = JSON.stringify(stateUpd[key])
    }

    return { get, set, assign }
  }

  return new Proxy({ sellers: interface('sellers'), items: interface('items'), hide: true, isDesktop }, {
    get(obj, k) {
      if (!initialised) load()
      return obj[k]
    },
    set(obj, k, v) {
      obj[k] = v
      if (k === 'hide') localStorage.BFhide = JSON.stringify(v)
      return true
    }
  })
})()

/*** 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 reset = o => Object.entries(filters).forEach(([k,v]) => (v.val = (o?.[k] ?? v.def)))
  const style = (o = null) => { o && reset(o); return Object.values(filters).map(v => v.filt()).join(' ') }
  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]))

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



/*** 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, sellerid, seller

  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: { 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, step: '-1' }, text: '-', style: 'min-width: 40px; min-height: 40px' }),
            el('button', { data: { action: k, 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',
      translate: 'no',
      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 sellerThumbs = Array.from($$('li.itemCard[data-id]'))
                          .filter(card => BFState.items.get(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 = () => BFFilters.reset(seller?.filters)
  const save = () => seller && BFState.sellers.assign(sellerid, { filters: BFFilters.changed() })

  document.on('pointerdown', 'a.js-smartPhoto', (e, a) => {
    open = true
    id = a.dataset.group
    sellerid = BFState.items.get(id)?.seller
    seller = sellerid && BFState.sellers.get(sellerid)
    load()
  }, true)

  smartPhoto.on('open', () => {
    id = smartPhoto.data.currentGroup
    sellerid = BFState.items.get(id)?.seller
    seller = sellerid && BFState.sellers.get(sellerid)
    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() {
    countdowns.forEach((date, el) =>
      (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 [, sellerid] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || []
  const data = { hide: false, seller: sellerid }
  const bidBtn = $x('//button[contains(normalize-space(.), "Place Bid")]', doc)
  const msg = doc.$('.messagebox, .message.error')

  if(sellerid && !BFState.sellers.get(sellerid)) {
    BFState.sellers.set(sellerid, {
      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

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

  // 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.set(id, data)
  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.get(id)
  const sellerid = item?.seller
  const seller = sellerid && BFState.sellers.get(sellerid)
  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', {
        type: 'button',
        text: 'Unblock',
        style: { color: 'red', backgroundColor: 'pink', border: 'solid 1px red', fontSize: '100%' },
        onclick: () => {
          window.open(item.review, '_blank')
          delete item.review
          scrape('force')
        }
      }) :
      item.blocked ? el('li.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.style(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, data: { 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: fn,
        style: 'font-size: 100%'
      })
    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', () => {
        BFState.sellers.assign(sellerid, { hide: !seller.hide });
        annotateCardsIn(document)
      }))
      if (!seller.hide || BFState.hide) statusList.append(btn(item, '', () => {
        BFState.items.assign(id, { hide: !item.hide })
        annotateCard(card)
      }))

      new Date(item.endTime + ' GMT+0900') < Date.now() && scrape()
      item.blocked && scrape()
    }
  }

  resetCard()
  if (!item) { scrape(); return }
  BFWatchlist.has(id) && scrape()
  BFState.items.assign(id, { lastSeen: Date.now() })
  if (sellerid) BFState.sellers.assign(sellerid, { lastSeen: Date.now() })

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

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

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: `
button.bf-node { border: 1px solid silver; padding: 3px 5px; border-radius: 3px; margin: 2px;
                 font-family: Arial,"Hiragino Kaku Gothic ProN",Meiryo,"MS PGothic",sans-serif; }
button.sp-toggle { position: absolute; top: 15px; left: 50%; transform: translateX(-50%);
                   padding: 10px; height: 40px; cursor: pointer; z-index: 10000;
                   background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 6px; }
div.sp-filters { position: absolute; top: 60px; left: 50%; transform: translateX(-50%);
                 display: flex; flex-direction: column; gap: 8px; padding: 10px; z-index: 9999;
                 background: rgba(0,0,0,0.4); color: white; border-radius: 6px; user-select: none;
                 font: 14px sans-serif; touch-action: manipulation; }`
    + (BFState.isDesktop ? `
button.bf-node:hover { filter: brightness(90%); }
    ` : `
#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
            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.toggle(id).then(() => {
        BFWatchlist.updateBtn(btn,id)
        BFWatchlist.has(id) && BFCardScraper.add({ url, id }, 'force')
      })
    })
  }

  const handleInfiniteScroll = () => {
    let currentURL = new URL(document.location)
    let currentPage = document
    let naviOnScreen = false
    let isLoading = 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 = (advance = true) => {
      if (isLoading) return

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

      isLoading = true

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

      fetchURL(currentURL)
      .then(page => {
        currentPage = page
        annotateCardsIn(currentPage)
        moveCards(currentPage, resultsNode, loadingNode)
        isLoading = false
        setTimeout(loadPageLoop, 100)
      })
      .catch(e => {
        isLoading = false
        e.type === 'http' && e.status === 404
          ? Dom.hide(loadingNode)
          : 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'))
  }

  injectStyles()
  handleVisibilityChange()
  mergeResultsLists()
  handleShowHideLink()
  handleWatchBtns()
  handleInfiniteScroll()

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

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