Buyee Seller Filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      2.22
// @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]/dist/localforage.min.js
// @require      https://unpkg.com/[email protected]/dist/simple-lightbox.min.js
// ==/UserScript==

(() => {
  'use strict'
  if (window.top !== window.self) return

  document.head.append(el('style', { id: 'bf-hide-initial', text: '.g-main:not(.g-modal) { visibility: hidden !important; height: 100vh }' }))
  document.head.append(el('meta', { name: 'google', content: 'notranslate' }))

  /*** 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, capture)
    if (typeof selectorOrHandler === 'function') {
      this.addEventListener(type, selectorOrHandler, handlerOrCapture)
      return this
    }

    // delegated binding: el.on('click', 'button', fn, capture)
    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-]*)?(?:#([\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',
    reflow: el => { el.offsetWidth; return el },
    style: (name, obj) => (t => `${name} {${t}}`)(
      Object.entries(obj).map(([k,v]) =>
        `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v}`
      ).join('; '))
  }

  function waitFor (sel) {
    return 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 });
    })
  }

  const wait = ms => ms ? new Promise(resolve => setTimeout(resolve, ms)) : new Promise(requestAnimationFrame)

  /*** config ***/

  const BFConfig = (() => {
    const ANIM_DELAY = 350
    return {
      SCRAPER_THREADS: 5,
      SCRAPER_DELAY: 100,
      SCROLL_DELAY: 100,
      HIDE_TIMEOUT: 3500,
      UNBLOCK_DELAY: 3000,
      BATCH_SIZE: 20,
      ANIM_DELAY,
      STYLES: [
        'span.watchList__watchNum.bf-node { user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none }',
        Dom.style('button.bf-button', {
          border: '1px solid silver',
          padding: '3px 5px',
          borderRadius: '3px',
          margin: '2px',
          fontSize: '100%',
          fontFamily: 'Arial,"Hiragino Kaku Gothic ProN",Meiryo,"MS PGothic",sans-serif'
        }),
        Dom.style('div.bf-batch', {
          transition: `opacity ${ANIM_DELAY}ms ease, transform ${ANIM_DELAY}ms ease`,
          overflowX: 'hidden'
        }),
        Dom.style('div.bf-batch.animate', {
          opacity: 0,
          transform: 'translateY(30px)'
        }),
        Dom.style('button.sp-toggle', {
           position: 'absolute',
           top: '15px',
           left: '50%',
           transform: 'translateX(-50%)',
           padding: '10px',
           height: '40px',
           cursor: 'pointer',
           zIndex: 13000,
           background: 'rgba(0,0,0,0.5)',
           color: 'white',
           border: 'none',
           borderRadius: '6px',
           fontSize: '1.1em !important'
        }),
        Dom.style('div.sp-filters', {
          position: 'absolute',
          top: '60px',
          left: '50%',
          transform: 'translateX(-50%)',
          display: 'flex',
          flexDirection: 'column',
          gap: '8px',
          padding: '10px',
          zIndex: 12999,
          background: 'rgba(0,0,0,0.4)',
          color: 'white',
          borderRadius: '6px',
          userSelect: 'none',
          font: '14px sans-serif',
          touchAction: 'manipulation',
        }),
        Dom.style('.sp-filters button', {
          background: 'white !important',
          fontSize: '100% !important'
        }),
        Dom.style('.bf-hide', {
          opacity: '0 !important',
          transform: 'translateX(60px) !important',
        }),
        Dom.style('.bf-dimmed', {
          opacity: '0.9 !important',
          backgroundColor: '#ffbfbf !important',
        }),
        Dom.style('.bf-item', {
          willChange: 'background-color opacity transform',
          transition: `background-color ${ANIM_DELAY}ms ease, opacity ${ANIM_DELAY}ms ease, transform ${ANIM_DELAY}ms ease`,
          opacity: '1',
          transform: 'translateX(0)'
        }),
        Dom.style('.bf-modal', {
          position: 'fixed',
          inset: '0',
          background: 'rgba(0,0,0,0.5)',
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'flex-start',
          zIndex: '10000',
          overflowY: 'auto',
          overflowX: 'hidden',
          padding: '4em 2em',
          boxSizing: 'border-box',
        }),
        Dom.style('.bf-inner', {
          background: 'white',
          borderRadius: '8px',
          maxWidth: '600px',
          width: '100%',
          padding: '10px',
          boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
          overflowWrap: 'anywhere',
          boxSizing: 'border-box',
        }),
        Dom.style('.bf-inner *', {
          maxWidth: '100%',
          boxSizing: 'border-box'
        }),
        Dom.style('footer, .footer', { display: 'none !important' })
      ],
      DESKTOP_STYLES: [
        Dom.style('button.bf-button:hover', { filter: 'brightness(90%)' }),
      ],
      MOBILE_STYLES: [
        Dom.style('#goog-gt-tt, .goog-te-balloon-frame', { display: 'none !important' }),
        Dom.style('.goog-text-highlight', { background: 'none !important', boxShadow: 'none !important' }),
        Dom.style('.auctionSearchResult .g-thumbnail__outer', { flex: '0 0 145px' }),
        Dom.style('.auctionSearchResult .g-thumbnail', { height: '145px', width: '145px' }),
        Dom.style('.auctionSearchResult .itemCard__itemInfo', { flex: '1 0 calc(100vw - 175px)' }),
        Dom.style('.auctionSearchResult .itemCard__itemName', { fontSize: '1.35rem', marginBottom: '5px', wordBreak: 'break-all' }),
        Dom.style('.auctionSearchResult .g-priceDetails__item .g-price__outer .g-price', { fontSize: '1.5rem', display: 'inline-block' }),
        Dom.style('.auctionSearchResult .itemCard__infoItem .g-text', { fontSize: '1.35rem' }),
      ]
    }
  })()

  /*** state ***/
  const BFState = (() => {

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

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

    const flush = () => {
      if (flushing) return Promise.resolve()
      flushing = true
      stateUpd.sellers = {}
      stateUpd.items = {}
      {
        const save = key => localforage.setItem(`BF${key}`, state[key]) // eslint-disable-line no-undef
        return Promise.all([save('sellers'), save('items')])
          .then(() => {
            delete localStorage.BFUsellers
            delete localStorage.BFUitems
          })
          .finally(() => { flushing = false })
      }
    }

    const safeParse = s => {
      if (!s) return null
      if (typeof s === 'object') return s
      try { return JSON.parse(s) }
      catch { return null }
    }

    const initialise = () => {
      if (initialised) return Promise.resolve()

      const parse = key => localforage.getItem(`BF${key}`) // eslint-disable-line no-undef
        .then(v1 => {
          v1 = v1 ?? {}
          const v2 = safeParse(localStorage.getItem(`BFU${key}`)) ?? {}
          const store = state[key]
          Object.assign(store, v1)
          Object.entries(v2).forEach(([k,v]) => {
            if (v._replace) {
              store[k] = v;
              delete v._replace
            } else {
              store[k] ??= {}
              Object.assign(store[k], v)
            }
          })
        })

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

      return Promise.all([parse('sellers'), parse('items')])
        .then(prune)
        .then(flush)
        .then(() => (initialised = true))
    }

    const proxy = key => {
      const get = k => state[key][k]
      const has = k => k in state[key]
      const set = (k,v) => {
        v._replace = true
        state[key][k] = v
        stateUpd[key][k] = v
        localStorage[`BFU${key}`] = JSON.stringify(stateUpd[key])
        if (localStorage[`BFU${key}`].length > 500000) flush()
      }
      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, has, assign }
    }

    const safeKeys = new Set(['initialise', 'isDesktop', 'hide'])

    return new Proxy({
      sellers: proxy('sellers'),
      items: proxy('items'),
      hide: JSON.parse(localStorage.BFhide ?? 'true'),
      isDesktop,
      initialise
    }, {
      get(obj, k) {
        if (!initialised && !safeKeys.has(k)) {
          console.error("Store not initialised before use")
          return undefined
        }
        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 }
  })()

  /*** photo enhancements ***/
  const BFPhoto = (() => {
    let thumbsCallback

    const add = elt => {
      const elements = [...elt.$$('a')]
      if (!elements.length) return

      const id = elements[0].dataset?.id
      const sellerid = BFState.items.get(id)?.seller

      const lightbox = new SimpleLightbox(elements, { // eslint-disable-line no-undef
        animationSpeed: 100,
        fadeSpeed: 100,
        widthRatio: 1,
        loop: false
      })

      lightbox.on('shown.simplelightbox', () => {
        const seller = sellerid && BFState.sellers.get(sellerid)
        BFFilters.reset(seller?.filters)
        addControls($('.simple-lightbox'), lightbox, sellerid)
      })

      lightbox.on('close.simplelightbox', () => {
        BFState.sellers.assign(sellerid, { filters: BFFilters.changed() })
      })
    }

    const addControls = (overlay, lightbox, sellerid) => {

      const panel =
        el('.sp-filters', { style: { opacity: '0', pointerEvents: 'none' }},
          ...Object.entries(BFFilters.names()).map(([k, txt]) =>
            el('.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',
        onclick: () => {
          const shown = panel.style.opacity === '1'
          panel.style.opacity = shown ? '0' : '1'
          panel.style.pointerEvents = shown ? 'none' : 'auto'
        }
      })

      const update = () => {
        const style = BFFilters.style()
        const imgs = [
          ...($('.sl-image')?.$$('img') ?? []),
          ...lightbox.relatedElements,
          ...(thumbsCallback?.(sellerid) ?? [])
        ]

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

      if (!$('.sp-filters')) {
        overlay.append(toggle,panel)
        startHoldEvs('mousedown', 'touchstart')
        stopHoldEvs('mouseup', 'mouseleave', 'touchend', 'touchcancel')
      }

      update()
    }

    const setCallback = fn => (thumbsCallback = fn)

    document.head.append(el('link', { rel: 'stylesheet', href: 'https://unpkg.com/[email protected]/dist/simple-lightbox.min.css' }))

    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, setCallback }
  })()

  /*** countdown timers ***/
  const BFCountdown = (() => {
    const countdowns = new Map()

    function timeUntil(date) {
      const diff = (date - Date.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) {
      const fast = (date - Date.now()) < 1.8e6
      countdowns.set(el, { date, paused: false, fast })
      el.$('.g-text').textContent = timeUntil(date)
    }

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

    function pause(el, state = true)
    {
      const {date, fast} = countdowns.get(el) ?? {}
      if (date) countdowns.set(el, { date, paused: state, fast })
    }

    function update(onlyFast = false) {
      const now = Date.now()
      countdowns.forEach((item, el) => {
        if (!item.paused && item.fast === onlyFast) {
          el.$('.g-text').textContent = timeUntil(item.date)
          if (!onlyFast && (item.date - now) < 1.8e6) item.fast = true
        }})
      wait(onlyFast ? 1000 : 60000).then(() => update(onlyFast))
    }

    update()
    update(true) // fast updates

    return { add, pause, remove }
  })()

  /*** card scraping ***/
  const BFScraper = 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 => {
        process(id, doc)
        callback(id,doc)
        readItems.add(id)
      })
      .catch(err => console.error('fetchURL failed for', url, err))
      .finally(() => {
        inFlightItems.delete(id)
        wait(BFConfig.SCRAPER_DELAY).then(() => {
          activeCount--
          run()
        })
      })
      activeCount < BFConfig.SCRAPER_THREADS && run()
    }

    function process(id, doc) {
      const sellernode = $x('//a[contains(@href,"search/customer")]', doc)
      const [, sellerid] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || []
      const item = BFState.items.get(id) ?? { hide: false, seller: sellerid }
      const bidBtn = $x('//button[contains(normalize-space(.), "Bid")]', doc)
      const snipeBtn = $x('//button[contains(normalize-space(.), "Snipe")]', doc)
      const msg = doc.$('.g-textArea--error, .g-box--caution')

      if (sellerid && !BFState.sellers.get(sellerid)) {
        BFState.sellers.set(sellerid, {
          name: sellernode.innerText.trim(),
          hide: false
        })
      }

      const endTimeText = $x('//*[contains(@class, "itemDetail__list")][.//*[contains(text(), "Closing Time")]]',doc)
                          ?.$('.itemDetail__listValue')
                          ?.textContent
      if (endTimeText) {
        item.endTime = new Date(endTimeText + ' GMT+0900').valueOf()
      }

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

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

      // get number of watchers
      item.watchNum = Number(
        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
      item.images = Array.from(doc.$$('.js-smartPhoto')).map(node => node.href)
      BFState.items.set(id, item)
    }

    // force = true allows reprocessing
    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 }
  }

  /*** watchlist API ***/
  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 => {
      const id = btn?.dataset?.id
      if (!id) return

      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 }

  })()

  /*** translation helper ***/
  const BFTrans = (() => {
    const toTrans = []
    const textarea = el('textarea')
    let lock = false
    let pending = false

    function add(node) {
      toTrans.push(node)
    }

    function run() {
      if (!toTrans.length) { pending = false; return }
      if (lock) { pending = true; return }

      lock = true
      pending = false
      const nodes = [...toTrans]
      toTrans.length = 0

      fetch('https://translate-pa.googleapis.com/v1/translateHtml', {
        method: 'POST',
        headers: {
          accept: 'application/json',
          'Content-Type': 'application/json+protobuf',
          'X-Goog-API-Key': 'AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520'
        },
        body: JSON.stringify(
          [[nodes.map(t=>t.textContent),"ja","en"],"wt_lib"]
        )
      })
      .then(r => {return r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)})
      .then(r => r[0]?.forEach((t,i) => {
        const node = nodes[i]
        if (t) {
          textarea.innerHTML = t
          node.textContent = textarea.value
        } else toTrans.push(node)
      }))
      .catch(e => { console.error(e); toTrans.push(...nodes) } )
      .finally(() => {
        lock = false
        if (pending) run()
      })
    }

    return { add, run }
  })()

  /*** card factory ***/
  function BFCard(node, url, scraped, id, data = {}) {
    return {
      node, url, scraped, id,
      annotated: false,
      onPage: false,
      hidden: false,
      translated: false,
      infoList: node.$('.itemCard__infoList'),
      statusList: node.$('ul.auctionSearchResult__statusList'),
      titleNode: node.$('.itemCard__itemName a'),

      ...data,

      $: node.$.bind(node),
      $$: node.$$.bind(node),

      setVisibility: function() {
        // hiding status
        const item = BFState.items.get(this.id)
        const seller = BFState.sellers.get(this.sellerid)
        if (!item || !seller) return
        this.hidden = item.hide || seller.hide

        // animation
        const wrap = fn => this.onPage ? wait().then(fn) : fn()
        const wrapWait = fn => this.onPage ? wait(BFConfig.ANIM_DELAY).then(fn) : fn()
        wrap(() => {
          if (this.hidden && BFState.hide) {
            this.node.classList.remove('bf-dimmed')
            this.node.classList.add('bf-hide')
            wrapWait(() => Dom.hide(this.node))
          } else {
            Dom.show(this.node)
            wait().then(() => wait()).then(() => {
              this.node.classList.remove('bf-hide')
              this.node.classList.toggle('bf-dimmed', this.hidden)
            })
          }
        })

        // countdowns
        if (this.timeLeft) {
          BFCountdown.remove(this.timeLeft)
          if (!this.hidden || !BFState.hide) BFCountdown.add(item.endTime, this.timeLeft)
        }

        // item toggle
        if (this.itemToggle) {
          seller.hide ? Dom.hide(this.itemToggle) : Dom.show(this.itemToggle)
        }
      }
    }
  }

  /*** card annotation ***/
  const BFAnnotate = (() => {
    let scraper, toggleSeller, toggleItem

    function init(params) {
      ({scraper, toggleSeller, toggleItem} = params)
    }

    // DOM helpers
    const infoNode = (text, ...children) => el('li.itemCard__infoItem', {}, el('span.g-title', { text }), el('span.g-text', {}, ...children))
    const button = (text, args) => el('button.bf-button', { type: 'button', text, ...args })

    function makeToggle(hide, txt, callback) {
      let timeoutId, confirming = false, hiding = hide
      const label = () => [hiding ? 'Show' : 'Hide', txt].join(' ')
      const btn = button(label(), {
        onclick: () => {
          if (!confirming && !hiding) {
            confirming = true
            const old = btn.textContent
            btn.textContent = 'Really?'
            timeoutId = setTimeout(() => { btn.textContent = old; confirming = false }, BFConfig.HIDE_TIMEOUT)
            return
          }
          confirming = false
          if (timeoutId) { clearTimeout(timeoutId); timeoutId = undefined }
          callback?.(!hiding)
        }
      })
      btn.toggle = h => { hiding = h; btn.textContent = label() }
      return btn
    }

    // annotation helpers
    function attachBlockNodes(card) {
      const id = card.id
      const item = BFState.items.get(id)

      card.unblockBtn?.remove()
      card.blockedNode?.remove()
      delete card.unblockBtn
      delete card.blockedNode

      if (item.review) {
        card.unblockBtn = button('Unblock', {
          style: { color: 'red', backgroundColor: 'pink', border: 'solid 1px red' },
          onclick: () => {
            window.open(`https://buyee.jp/item/jdirectitems/auction/inquiry/${id}`, '_blank')
            delete item.review
            BFState.items.set(id, item)
            wait(BFConfig.UNBLOCK_DELAY).then(scraper.add({ url: card.url, id }, 'force'))
          }
        })
        card.statusList.append(card.unblockBtn)
      } else if (item.blocked) {
        card.blockedNode = el('li.bf-node.auctionSearchResult__statusItem', {
          text: item.blocked === 'P' ? 'Pending' : 'Blocked',
          style: { color: 'red', backgroundColor: 'pink' }
        })
        card.statusList.append(card.blockedNode)
      }
    }

    function attachSmartphoto(card) {
      const item = BFState.items.get(card.id)

      if (item.images?.length) {
        const thumb = card.$('.g-thumbnail__outer')
        const thumbImg = thumb.$('a')
        thumbImg.href = item.images[0]
        thumbImg.dataset.id = card.id

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

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

    function trimWhitespace(node) {
      while (node.firstChild) {
        const child = node.firstChild;
        if ((child.nodeType === Node.TEXT_NODE && !child.textContent.trim())
        || (child.nodeType === Node.ELEMENT_NODE && child.tagName === 'BR')) {
          child.remove()
        } else break
      }
      if (node.firstChild) trimWhitespace(node.firstChild)
    }

    function attachDetails(card) {
      const closeModal = el => {
        el.remove()
        document.body.style.overflow = ''
        if (el._escHandler) {
          document.removeEventListener('keydown', el._escHandler)
          delete el._escHandler
        }
      }

      const modal = doc => {
        const inner = el('.bf-inner')
        const div = el('.bf-modal', {}, inner)
        const details = doc.$('#item-description, .g-description__main .inner')

        trimWhitespace(details)
        const title = el('h1', { text: card.titleNode.textContent, style: 'text-align: center; margin-bottom: 10px; font-size:1em; text-wrap: balance;' })
        details.insertBefore(title, details.firstChild)

        inner.innerHTML = details.innerHTML
        document.body.append(div)
        document.body.style.overflow = 'hidden'

        const walker = document.createTreeWalker(inner, NodeFilter.SHOW_TEXT,
          { acceptNode: node => node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT }
        )
        let node
        while ((node = walker.nextNode())) BFTrans.add(node)
        BFTrans.run()

        div.addEventListener('click', e => (e.target === div) && closeModal(div))
        div._escHandler = e => (e.key === 'Escape') && closeModal(div)
        document.addEventListener('keydown', div._escHandler)
      }

      card.detailsBtn = button(' • • • ', {
        onclick: () => {
          fetchURL(`${card.url}/detail`)
          .then(doc => modal(doc))
        }});

      (BFState.isDesktop ? card.statusList : card.infoToggles).append(card.detailsBtn)
    }

    function refresh(card) {
      const item = BFState.items.get(card.id)
      if (!card.annotated || !item) return

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

      BFCountdown.remove(card.timeLeft)
      BFCountdown.add(item.endTime, card.timeLeft)

      card.watcherCount.textContent = item.watchNum
      attachBlockNodes(card)
      card.setVisibility()
    }

    function decorate(card) {
      if (card.annotated) return refresh(card)

      const item = BFState.items.get(card.id)
      card.sellerid = item?.seller
      const seller = card.sellerid && BFState.sellers.get(card.sellerid)
      item && BFState.items.assign(card.id, { lastSeen: Date.now() })
      seller && BFState.sellers.assign(card.sellerid, { lastSeen: Date.now() })
      if (!item || !seller) return

      // seller
      card.infoList.append(infoNode('Seller', el('a', { text: seller.name, href: `https://buyee.jp/item/search/customer/${card.sellerid}`, target: '_blank', rel: 'noopener noreferrer'})))

      //  countdown
      card.timeLeft = infoNode('Time Left')
      card.infoList.firstElementChild.replaceWith(card.timeLeft)
      BFCountdown.add(item.endTime, card.timeLeft)

      // watchers
      card.watcherCount = el('span.watchList__watchNum.bf-node', { text: item.watchNum })
      card.$('.g-feather')?.parentNode.append(card.watcherCount)

      // images
      if (item.images?.length) attachSmartphoto(card)
      const thumb = card.$('.g-thumbnail__outer a img')
      thumb && seller.filters && (thumb.style.filter = BFFilters.style(seller.filters))

      // show/hide buttons
      card.sellerToggle = makeToggle(seller.hide, 'seller', hidden => toggleSeller(card.sellerid, hidden))
      if (!BFState.isDesktop) {
        card.infoToggles = el('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexDirection: 'row' } })
        card.infoToggles.append(card.sellerToggle)
        card.infoList.append(card.infoToggles)
      } else {
        card.infoList.append(card.sellerToggle)
      }

      card.itemToggle = makeToggle(item.hide, '', hidden => toggleItem(card.id, hidden))
      card.statusList.append(card.itemToggle)

      // details
      attachDetails(card)

      // move watcher star on mobile
      if (!BFState.isDesktop) {
        const watcherStar = card.$('.watchButton')
        watcherStar.style.marginBottom = '-6px'
        card.infoToggles.append(watcherStar)
      }

      // make link open in new tab
      card.titleNode && Object.assign(card.titleNode, { target: '_blank', rel: 'noopener noreferrer' })

      // block/unblock
      attachBlockNodes(card)

      card.setVisibility()
      card.annotated = true
    }

    return { init, decorate }
  })()

  /*** card handling ***/
  const BFCards = (() => {
    // cards metadata
    const cards = new Map()
    let cardsReady = 0

    // lazy loader observer
    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)
      }
    })

    // card scraper
    const scraper = BFScraper(id => BFAnnotate.decorate(cards.get(id)))

    function toggleSeller(sellerid, hide) {
      BFState.sellers.assign(sellerid, { hide })
      Array.from(cards.values())
      .filter(c => c.sellerid === sellerid)
      .forEach(card => {
        card.sellerToggle.toggle(hide)
        if (!hide && !card.translated) {
          card.translated = true
          BFTrans.add(card.titleNode)
        }
        card.setVisibility()
      })
      BFTrans.run()
    }

    function toggleItem(id, hide) {
      BFState.items.assign(id, { hide })
      const card = cards.get(id)
      card?.itemToggle.toggle(hide)
      card?.setVisibility()
    }

    function toggleHidden(hide) {
      BFState.hide = hide
      cards.forEach(card => card.setVisibility())
    }

    function addFrom(src) {
      src.$$('li.itemCard:not(.bf-loading)').forEach(node => {
        const url = node.$('.itemCard__itemName a').href.match(/([^?&]*)/)?.[1] || ''
        const id = node.dataset.id = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/)?.[1] || ''
        if (cards.has(id)) return

        node.remove()

        const scraped = BFState.items.has(id)
        const card = BFCard(node, url, scraped, id)
        cards.set(id, card)

        // set up card
        node.$('.g-thumbnail__outer a')?.setAttribute('href', '#')
        node.$$('li.itemCard__infoItem').forEach(n => n.textContent.trim() === '' ? n.remove() : BFState.isDesktop && (n.style.width = '20%'))
        BFState.isDesktop && (card.infoList.style.alignItems = 'center')

        // watchlist status
        const watchButton = node.$('div.watchButton')
        if (watchButton) {
          watchButton.dataset.id = id
          BFWatchlist.updateBtn(watchButton)
        }

        if (!scraped) {
          scraper.add({id, url});
          cardsReady++
          return
        }

        BFAnnotate.decorate(card)

        if (!card.hidden) cardsReady++

        const item = BFState.items.get(id)
        if (!card.hidden && (BFWatchlist.has(id) || (item.endTime < Date.now()) || item.blocked)) scraper.add({id, url})
      })
    }

    const { updateDom, nextBatch } = (() => {
      let pending = 0

      function nextBatch() {
        pending = BFConfig.BATCH_SIZE
      }

      function updateDom (addNode) {
        const batch = el('.bf-batch')
        const imgs = []
        const added = []
        let anyVisible = false

        for (const [id,card] of cards) {
          if (pending == 0) break
          if (card.onPage) continue

          card.onPage = true
          added.push(card.node)
          batch.append(card.node)

          const img = card.$('img.lazyLoadV2')
          if (img) {
            img.dataset.id = id
            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)
          }

          if (card.hidden) continue

          anyVisible = true
          pending--
          cardsReady--
          if (!card.translated) {
            BFTrans.add(card.titleNode)
            card.translated = true
          }
        }

        const wrap = anyVisible ? fn => {
          batch.classList.add('animate')
          fn()
          Dom.reflow(batch)
          batch.classList.remove('animate')
        } : fn => fn()

        wrap(() => {
          addNode(batch)
          Dom.reflow(batch)
          added.forEach(node => node.classList.add('bf-item'))
        })

        imgs.forEach(img => observer.observe(img))
        BFTrans.run()
        return cardsReady
      }

      return { updateDom, nextBatch }
    })()

    function rescrape (id) {
      const card = cards.get(id)
      card && scraper.add({id, url: card.url}, 'force')
    }

    BFPhoto.setCallback(sellerid =>
      Array.from(cards.values())
        .filter(card => card.sellerid === sellerid)
        .map(card => card.$('.g-thumbnail__outer img'))
        .filter(Boolean)
    )

    BFAnnotate.init({scraper, toggleItem, toggleSeller})

    return { addFrom, updateDom, toggleHidden, rescrape, nextBatch }
  })()

  /*** page loader ***/
  const BFPages = (() => {
    const queue = []
    const pages = []
    let naviOnScreen = false
    let cardsReady = 0
    let cardHeight

    const container = $('.g-main:not(.g-modal)')
    const resultsNode = container.firstElementChild
    const loadingNode = el('li.itemCard.bf-loading', {},
                           el('.imgLoading', { style: 'height: 50px' }),
                           el('.g-title', { text: 'Loading page 1', style: 'text-align: center; height: 50px' })
                          )
    const sentinel = $(BFState.isDesktop ? 'div.page_navi' : 'ul.pagination')

    let maxThreads = 3
    let nextToProcess = 2
    let activeCount = 0
    let lastPage

    const callback = (page, doc) => {
      pages[page] = doc
      if (page !== nextToProcess) return
      while (nextToProcess in pages) {
        BFCards.addFrom(pages[nextToProcess])
        delete pages[nextToProcess]
        nextToProcess++
      }
      cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode))
      updateThreads()
      if (cardsReady > 0 && naviOnScreen) addNext()

      if (nextToProcess > lastPage) {
        Dom.hide(loadingNode)
      } else {
        loadingNode.$('.g-title').innerText = `Loading page ${nextToProcess}`
      }
    }

    function updateThreads() {
      maxThreads = cardsReady > (BFConfig.BATCH_SIZE * 1.5) ? 0
                 : cardsReady > (BFConfig.BATCH_SIZE) ? 1
                 : cardsReady > (BFConfig.BATCH_SIZE * 0.5) ? 2
                 : 3
      run()
    }

    function run() {
      while (activeCount < maxThreads && queue.length) startNext()
    }

    function startNext() {
      const { page, url } = queue.shift()
      activeCount++

      fetchURL(url)
      .then(doc => callback(page, doc))
      .catch(err => console.error('fetchURL failed for', url, err))
      .finally(() => {
        activeCount--
        run()
      })
    }

    function addNext() {
      BFCards.nextBatch()
      wait(150).then(() => {
        cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode))
        updateThreads()
      })
    }

    function init() {

      let currentURL = new URL(document.location)
      let currentPage = Number(currentURL.searchParams.get('page') || 1)
      nextToProcess = currentPage + 1
      const results = Number($('.result-num').innerText.match(/.*\/\s*([0-9]*)\s*hits/)[1] ?? '')
      lastPage = Math.ceil(results/20)

      for (let page = nextToProcess; page <= lastPage; page++) {
        currentURL.searchParams.set('page', page)
        queue.push({page, url: currentURL.toString()})
      }

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

      observer.observe(sentinel)

      document.on('scroll', updateThreads, { passive: true })
      updateThreads()

      resultsNode.append(loadingNode)

      pages[1] = el('div')
      let totalHeight = 0

      document.$$('li.itemCard:not(.bf-loading)').forEach(node => {
        pages[1].append(node.cloneNode(true))
        totalHeight += node.offsetHeight
        node.remove()
      })

      cardHeight = totalHeight / pages[1].children.length

      BFCards.addFrom(pages[1])
      delete pages[1]

      $('#bf-hide-initial')?.remove()
      BFCards.nextBatch()

      cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode))
      updateThreads()
    }

    return { init }
  })()

  /*** main module ***/
  const BF = (() => {

    function main() {

      const setupPage = () => {
        $$('#google_translate_element, .skiptranslate').forEach(el => el.remove())

        new MutationObserver(muts => {
          for (const m of muts) {
            m.addedNodes.forEach(node => {
              if (node.nodeType === 1 && node.querySelector?.('.skiptranslate')) node.remove()
              if (node.id === 'google_translate_element') node.remove()
            })
          }
        }).observe(document.documentElement, { childList: true, subtree: true })

        const style = [...BFConfig.STYLES, ...(BFConfig.isDesktop ? BFConfig.DESKTOP_STYLES : BFConfig.MOBILE_STYLES)].join('\n')
        document.head.append(el('style', { text: style }))
      }

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

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

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

          const id = btn.dataset.id

          BFWatchlist.toggle(id).then(() => {
            BFWatchlist.updateBtn(btn)
            BFWatchlist.has(id) && BFCards.rescrape(id)
          })
        })
      }

      setupPage()
      BFState.initialise()
      .then(BFWatchlist.refresh)
      .then(() => {
        handleShowHideLink()
        handleWatchBtns()
        BFPages.init()
      })
    }
    waitFor('.g-main:not(.g-modal)').then(main)
  })()
})()