Buyee Seller Filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.86
// @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'

  /*** 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, 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 });
  }

  /*** 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] ??= {}
          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) => {
        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' }},
          ...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 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 = { 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

      // 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($(`li.itemCard[data-id="${id}"]`))
    })

    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: e => fn.call(e.currentTarget, e),
            style: 'font-size: 100%'
          })
        const hidden = seller.hide || item.hide

        const confirm = (node, fn) => {
          if (node.textContent.includes('Hide')) {
              const old = node.textContent
              node.textContent = 'Really?'
              setTimeout(() => (node.textContent = old), BFConfig.HIDE_TIMEOUT)
          }
          else fn()
        }

        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', function () {
            confirm(this, () => {
              BFState.sellers.assign(sellerid, { hide: !seller.hide });
              annotateCardsIn(document)
            })
          }))
          if (!seller.hide || BFState.hide) {
            statusList.append(btn(item, '', function () {
              confirm(this, () => {
                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

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

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

          batch.append(card)
          anyVisible = anyVisible || !Dom.hidden(card)

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

          const img = card.$('img.lazyLoadV2')
          if (img) {
            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.append(batch)
        imgs.forEach(img => observer.observe(img))

        if (anyVisible) {
          Dom.hide(loadingEl)
          requestAnimationFrame(() => requestAnimationFrame(() => { batch.style.opacity = 1; batch.style.transform = '' }))
        }
      }
    })()

    const handleInfiniteScroll = () => {
      const container = $('.g-main:not(.g-modal)')
      const resultsNode = container?.firstElementChild
      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 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)
        resultsNode.append(loadingNode)
        loadingNode.$('.g-title').textContent = `Loading page ${currentPage}`
        Dom.show(loadingNode)

        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 pageNavi = $(BFState.isDesktop ? 'div.page_navi' : 'ul.pagination')
      let lastY = 0
      let isStopped = false
      let above = true

      window.addEventListener('scroll', () => {
        const rect = pageNavi.getBoundingClientRect()
        const stopY = rect.top - window.visualViewport.height
        const isDown = window.scrollY > lastY
        lastY = window.scrollY
        if (above && !isStopped && stopY < -50) {
          above = false
//        if (isDown && !isStopped && stopY > -125 && stopY < -50) {
          isStopped = true
          window.scrollTo({ top: window.scrollY + rect.top - window.visualViewport.height, behavior: 'smooth' })
          setTimeout(() => {
            document.body.overflow = 'hidden'
            setTimeout(() => {
              document.body.overflow = 'auto'
              isStopped = false
            }, 25)
          }, 100)
        }
        else if (!above & stopY > 0) above = true
      })

      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(pageNavi)

    }

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

      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 ? $('.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'
                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')
          })
        })
      }

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


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

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

})()