Buyee Seller Filter

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

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

您需要先安装一个扩展,例如 篡改猴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.93
// @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==

(() => {

  'use strict'

  const hideStyle = el('style', { id: 'bf-hide-initial', text: '.g-main:not(.g-modal) { visibility: hidden !important; height: 100vh }' })
  document.head.appendChild(hideStyle)

  /*** utilities ***/

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

  const waitFor = sel => new Promise(resolve => {
    const el = $(sel)
    if (el) return resolve(el)

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

  /*** config ***/

  const BFConfig = {
    SCRAPER_THREADS: 5,
    SCRAPER_DELAY: 100,
    SCROLL_DELAY: 100,
    HIDE_TIMEOUT: 3500
  }

  /*** 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] ??= {}
          v._replace ? (store[k] = v) : 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 proxy = key => {
      const get = k => {
        return state[key][k]
      }

      const set = (k,v) => {
        v._replace = true
        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: proxy('sellers'), items: proxy('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', translate: 'no' }},
          ...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' }, 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-sellerid=${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, 'min'), fmt(sec, 'sec')].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, "hr"), fmt(min, "min")].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 processingQueue = []
    const inFlightItems = new Set()
    const readItems = new Set()
    let activeCount = 0

    function run() {
      if (!processingQueue.length || activeCount >= BFConfig.SCRAPER_THREADS) 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()
        }, BFConfig.SCRAPER_DELAY)
      })
      activeCount < BFConfig.SCRAPER_THREADS && 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 }
  }

  /*** 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 }

  })()

  const BF = (() => {

    const BFCardScraper = BFQueue((id, doc) => {
      const sellernode = $x('//a[contains(@href,"search/customer")]',doc)
      const [, sellerid] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || []
      const data = BFState.items.get(id) ?? { hide: false, seller: sellerid }
      const bidBtn = $x('//button[contains(normalize-space(.), "Snipe")]', 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
        : 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.set(id, data)
      const card = $(`li.itemCard[data-id="${id}"]`)
      if (card) annotateCard(card)
    })

    const setVisibilities = cards => {
      const upds = Array.from(cards).map(card => {
        const id = card.dataset?.id
        const sellerid = card.dataset?.sellerid
        const item = id && BFState.items.get(id)
        const seller = sellerid && BFState.sellers.get(sellerid)
        const hidden = seller?.hide || item?.hide
        return {card, hidden}
      })

      requestAnimationFrame(() =>
        upds.forEach(({card, hidden}) => {
          if (hidden && BFState.hide) {
            card.classList.add('bf-hide')
          } else {
            card.classList.remove('bf-hide')
            card.classList.toggle('bf-dimmed', hidden)
          }
      }))
    }

    const setVisibility = card => setVisibilities([card])

    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 = card.dataset.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() : BFState.isDesktop && (node.style.width = '20%')
        })
        card.$('.g-thumbnail__outer a').href = "#"
        BFState.isDesktop && (infoList.style.alignItems = 'center')
      }

      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 toggle = (obj, key, key2, txt, fn) => {
          const label = () => [obj.hide ? 'Show' : 'Hide', txt].join(' ')

          const btn = el('button.bf-node', {
            type: 'button',
            text: label(),
            style: { fontSize: '100%' }
          })

          let confirming = false

          btn.onclick = () => {
            if (!confirming && !obj.hide) {
              confirming = true
              const old = btn.textContent
              btn.textContent = 'Really?'
              setTimeout(() => {btn.textContent = old; confirming = false}, BFConfig.HIDE_TIMEOUT)
              return
            }

            obj.hide = !obj.hide
            BFState[key].assign(key2, { hide: obj.hide })
            btn.textContent = label()
            fn?.()
          }
          return btn
        }

        const sellerToggle = toggle(seller, 'sellers', sellerid, 'seller', () =>
          setVisibilities($$(`li.itemCard[data-sellerid="${sellerid}"]`)))
        if (!BFState.isDesktop) {
          sellerToggle.style.margin="0.5em 0px -2.5em"
          sellerToggle.style.width="40%"
        }
        infoList.append(sellerToggle)

        statusList.append(toggle(item, 'items', id, '', () => setVisibility(card)))
      }

      resetCard()
      if (!item) { scrape(); return }


      (BFWatchlist.has(id)
        || new Date(item.endTime + ' GMT+0900') < Date.now()
        || item.blocked
      ) && scrape()

      BFState.items.assign(id, { lastSeen: Date.now() })
      if (sellerid) BFState.sellers.assign(sellerid, { lastSeen: Date.now() })

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

    function annotateCardsIn(element) {
      element.$$('li.itemCard:not(.bf-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, firstpage = false) {
        if (!src || !tgt) return

        const batch = el('div', { style: {
          opacity: 0,
          transform: 'translateY(30px)',
          transition: 'opacity 0.4s, transform 0.4s'
        }})
        const imgs = []
        let anyVisible = false

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

          batch.append(card)
          anyVisible = anyVisible || card.style.display !== 'none'

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

          const img = card.$('img.lazyLoadV2')
          if (img && !firstpage) {
            img.dataset.src ??= img.src
            img.style.background = 'url(https://cdn.buyee.jp/images/common/loading-spinner.gif) no-repeat 50%;'
            img.src = 'https://cdn.buyee.jp/images/common/spacer.gif'
            imgs.push(img)
          }
        })

        tgt.insertBefore(batch, loadingEl)
        requestAnimationFrame(() => {
          batch.$$('li.itemCard:not(.bf-loading)').forEach(card => card.classList.add('bf-item'))
          anyVisible && requestAnimationFrame(() => { batch.style.opacity = 1; batch.style.transform = '' })
        })
        imgs.forEach(img => observer.observe(img))
      }
    })()

    const handleInfiniteScroll = () => {
      const container = $('.g-main:not(.g-modal)')
      const resultsNode = container?.firstElementChild
      const loadingNode = resultsNode?.$('.bf-loading')
      const KEY = 'BFSession'

      let currentURL = new URL(document.location)
      let currentPage = currentURL.searchParams.get('page') || 1
      let naviOnScreen = false
      let isLoading = false
      let isLastPage = false

      const saveSession = () => {
        const state = {
          currentPage: isLoading ? Math.max(1, currentPage - 1) : currentPage,
          cards: resultsNode?.outerHTML,
          scrollY: window.scrollY
        }
        sessionStorage.setItem(KEY, JSON.stringify(state))
      }

      const restoreSession = () => {
        if (performance.getEntriesByType('navigation')[0]?.type !== 'back_forward') {
          sessionStorage.removeItem(KEY)
          window.scrollTo(0, 0)
          return
        }

        const raw = sessionStorage.getItem(KEY)
        if (!raw) return false

        try {
          const state = JSON.parse(raw)
          if (state.currentPage) currentPage = state.currentPage
          setTimeout(() => window.scrollTo(0, state?.scrollY ?? 0), 50)
          const doc = new DOMParser().parseFromString(state.cards, 'text/html')

          if (doc) {
            resultsNode.innerHTML = ''
            annotateCardsIn(doc)
            moveCards(doc, resultsNode, loadingNode)
          }
        } catch (err) {
          console.error('[BFScrollCache] restore failed', err)
        } finally {
          sessionStorage.removeItem(KEY)
        }
      }

      const loadPageLoop = () => {
        if (isLoading) return

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

        isLoading = true

        currentPage++
        currentURL.searchParams.set('page', currentPage)
        loadingNode.$('.g-title').textContent = `Loading page ${currentPage}`

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

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

      restoreSession()
      window.addEventListener('beforeunload', saveSession)
      observer.observe($(BFState.isDesktop ? 'div.page_navi' : 'ul.pagination'))
    }

    function main() {
      const container = $('.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 = () => {
        const common = `
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; }
.bf-hide { max-height: 0 !important; padding: 0 !important; border: 0 !important }
.bf-dimmed { opacity: 0.9 !important; background-color: #ffbfbf !important }
.bf-item { max-height: 250px; will-change: max-height padding background-color opacity;
           transition: max-height 0.4s ease, padding 0.4s ease, background-color 0.4s ease, opacity 0.4s ease;
           contain: layout style paint; }
.bf-node { translate: no }
        `
        const desktop = `
button.bf-node:hover { filter: brightness(90%); }
`
        const mobile = `
#goog-gt-tt, .goog-te-balloon-frame{display: none !important;}
.goog-text-highlight { background: none !important; box-shadow: none !important;}
        `
        document.head.append(el('style', { text: common + (BFState.isDesktop ? desktop : mobile) }))
        if ($('.footer')) $('.footer').style.display = 'none'
      }

      const setupPage = () => {
        const loadingNode = el('li.itemCard.bf-loading', { translate: 'no' },
          el('div.imgLoading', { style: 'height: 50px' }),
          el('div.g-title', { text: 'Loading page 1', style: 'text-align: center; height: 50px' })
        )
        if (container.children.length > 1) Dom.hide(container.children[1])
        const initialCards = el('div')
        $$('ul.auctionSearchResult li.itemCard').forEach(card => initialCards.append(card))
        resultsNode.append(loadingNode)
        return {initialCards, loadingNode}
      }

      const handleShowHideLink = () => {
        const showHideParent = BFState.isDesktop ? $('.result-num').parentNode : $('.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'
                setVisibilities($$('.bf-item'))
              },
              style: 'display:inline-block; width:110px',
              translate: 'no'
            })
          )
        )
      }

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

      injectStyles()
      handleVisibilityChange()
      const {initialCards, loadingNode} = setupPage()
      handleShowHideLink()
      handleWatchBtns()
      BFWatchlist.refresh().then(() => {
        annotateCardsIn(initialCards)
        $('#bf-hide-initial')?.remove()
        moveCards(initialCards, resultsNode, loadingNode, true)
        handleInfiniteScroll()
      })
    }

    waitFor('.g-main:not(.g-modal)').then(main)
  })()

})()