Buyee Seller Filter

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

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

您需要先安装一个扩展,例如 篡改猴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      2.04
// @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'
  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)
    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',
    style: (name, obj) => (t => `${name} {${t}}`)(
      Object.entries(obj).map(([k,v]) =>
        `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v}`
      ).join('; '))
  }

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

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

  /*** config ***/

  const BFConfig = (() => {
    const ANIM_DELAY = 350
    return {
      SCRAPER_THREADS: 5,
      SCRAPER_DELAY: 100,
      SCROLL_DELAY: 100,
      HIDE_TIMEOUT: 3500,
      UNBLOCK_DELAY: 3000,
      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`
        }),
        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: '10000',
           background: 'rgba(0,0,0,0.5)',
           color: 'white',
           border: 'none',
           borderRadius: '6px',
        }),
        Dom.style('div.sp-filters', {
          position: 'absolute',
          top: '60px',
          left: '50%',
          transform: 'translateX(-50%)',
          display: 'flex',
          flexDirection: 'column',
          gap: '8px',
          padding: '10px',
          zIndex: 9999,
          background: 'rgba(0,0,0,0.4)',
          color: 'white',
          borderRadius: '6px',
          userSelect: 'none',
          font: '14px sans-serif',
          touchAction: 'manipulation',
        }),
        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' }),
      ]
    }
  })()

  /*** 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 => 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])
      }
      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 }
    }

    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 sellerid
    let thumbsCallback

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

      overlay.append(toggle,panel)

      const update = () => {
        const style = BFFilters.style()
        const imgs = [
          ...($('.smartphoto-list')?.$$('.smartphoto-img') ?? []),
          ...($('.smartphoto-nav')?.$$('a') ?? []),
          ...(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
        })
      )

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

      update()
    }

    const load = id => {
      sellerid = BFState.items.get(id)?.seller
      const seller = sellerid && BFState.sellers.get(sellerid)
      BFFilters.reset(seller?.filters)
    }
    const save = () => BFState.sellers.assign(sellerid, { filters: BFFilters.changed() })
    const setCallback = fn => (thumbsCallback = fn)

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

    smartPhoto.on('open', () => {
      load(smartPhoto.data.currentGroup)
      $('.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, setCallback }
  })()

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

    function timeUntil(jstDate) {
      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(dateStr, el) {
      const date = new Date(dateStr + ' GMT+0900')
      const fast = (date - Date.now()) < 1800
      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) {
      countdowns.forEach(({date,paused,fast}, el) =>
        !paused && (fast === onlyFast) && (el.$('.g-text').textContent = timeUntil(date)))
      wait(onlyFast ? 1000 : 60000).then(() => update(onlyFast))
    }

    update()
    update(true)

    return { add, pause, 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)
        wait(BFConfig.SCRAPER_DELAY).then(() => {
          activeCount--
          run()
        })
      })
      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 }
  }

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

  })()

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

    // 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
    function scraperCallback(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(.), "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
        })
      }

      item.endTime = $x('//li[.//*[contains(text(),"Closing Time")]]',doc)?.$('span:last-child')?.textContent

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

      // can I request a review?
      msg?.$('a')?.href?.includes('inquiry') ? (item.review = msg.$('a').href) : 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)
      cards.get(id).annotate()
    }

    const scraper = BFQueue(scraperCallback)

    // translation helper
    const toTrans = []

    // 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(item.review, '_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 a')
        thumb.href = item.images[0]
        thumb.dataset.group = card.id
        BFPhoto.add(thumb)

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

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

    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('div.bf-inner')
        const div = el('div.bf-modal', {}, inner)
        const details = doc.$('.main .inner')
        details.$$('.googleTranslate, #google_translate_element, h1, script, style').forEach(el => el.remove())

        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())) toTrans.push(node)
        translateText()

        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))
        }})
      if (BFState.isDesktop) {
        card.statusList.append(card.detailsBtn)
      } else {
        card.detailsBtn.style.margin = '0 0 0 20%'
        card.infoToggles.append(card.detailsBtn)
      }
    }

    const translateText = (() => {
      let lock = false
      let pending = false
      const textarea = el('textarea')

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

        lock = true
        pending = false
        const nodes = [...toTrans]
        toTrans.length = 0
        console.log("Translating", nodes.map(t=>t.textContent.trim()))

        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) translateText()
        })
      }
      return translateText
    })()

    // make card object
    function newCard(node, url, scraped, id, data = {}) {
      return {
        node, url, scraped, id,
        annotated: false,
        onPage: false,
        hidden: false,
        ...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 ? requestAnimationFrame(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)
              requestAnimationFrame(() =>
                requestAnimationFrame(() => {
                  this.node.classList.remove('bf-hide')
                  this.node.classList.toggle('bf-dimmed', this.hidden)
                })
              )
            }
          })

          // countdowns
          if (this.timeLeft) {
            BFCountdown.remove(this.timeLeft)
            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)
          }
        },

        refreshUI: function () {
          const item = BFState.items.get(this.id)
          if (!this.annotated || !item) return

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

          this.watcherCount && (this.watcherCount.textContent = item.watchNum)
          attachBlockNodes(this)
          this.setVisibility()
        },

        annotate: function () {
          if (this.annotated) return this.refreshUI()

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

          this.infoList = this.$('.itemCard__infoList')
          this.statusList = this.$('ul.auctionSearchResult__statusList')

          // set up card
          this.$$('li.itemCard__infoItem').forEach(node => node.textContent.trim() === '' ? node.remove() : BFState.isDesktop && (node.style.width = '20%'))
          BFState.isDesktop && (this.infoList.style.alignItems = 'center')

          // title
          this.titleNode = this.$('.itemCard__itemName a')
          this.translated = false

          // seller
          this.infoList?.append(infoNode('Seller', el('a', { text: seller.name, href: seller.url, target: '_blank', rel: 'noopener noreferrer'})))

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

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

          // watchlist status
          const watchButton = this.$('div.watchButton')
          watchButton && BFWatchlist.updateBtn(watchButton, id)

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

          // block/unblock
          attachBlockNodes(this)

          // show/hide buttons
          this.sellerToggle = makeToggle(seller.hide, 'seller', hidden => toggleSeller(this.sellerid, hidden))
          if (!BFState.isDesktop) {
            this.infoToggles = el('div', { style: { flexDirection: 'row', margin: '0.5em 0px -2.7em' } })
            this.infoToggles.append(this.sellerToggle)
            this.infoList.append(this.infoToggles)
          } else {
            this.infoList.append(this.sellerToggle)
          }

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

          // details
          attachDetails(this)

          this.setVisibility()
          this.annotated = true
        },
      }
    }

    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
          toTrans.push(card.titleNode)
        }
        card.setVisibility()
      })
      translateText()
    }

    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()
        node.$('.g-thumbnail__outer a')?.setAttribute('href', '#')

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

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

        card.annotate()

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

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

      cards.forEach((card, id) => {
        if (!card.onPage) {
          card.onPage = true
          added.push(card.node)
          batch.append(card.node)

          if (!card.hidden) {
            console.log(id, card.titleNode.textContent.trim(), card.translated)
            anyVisible = true
            if (!card.translated) {
              toTrans.push(card.titleNode)
              card.translated = true
            }
          }

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

      anyVisible && batch.classList.add('animate')
      addNode(batch)
      anyVisible && void batch.offsetWidth

      added.forEach(node => node.classList.add('bf-item'))
      if (anyVisible) {
        void batch.offsetWidth
        batch.classList.remove('animate')
      }

      imgs.forEach(img => observer.observe(img))
      translateText()
    }

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

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

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

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

    const nodes = {}
    const handleInfiniteScroll = () => {
      const KEY = 'BFSession'
      const {container, resultsNode, loadingNode} = nodes

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

      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 => {
          BFCards.addFrom(doc)
          BFCards.updateDom(batch => resultsNode.insertBefore(batch, 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')
          wait(BFConfig.SCROLL_DELAY).then(loadPageLoop)
        })
        .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" })

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

    function main() {
      const container = $('.g-main:not(.g-modal)')
      const resultsNode = container.firstElementChild
      const loadingNode = el('li.itemCard.bf-loading', {},
          el('div.imgLoading', { style: 'height: 50px' }),
          el('div.g-title', { text: 'Loading page 1', style: 'text-align: center; height: 50px' })
        )
      Object.assign(nodes, { container, resultsNode, loadingNode })

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

        resultsNode.append(loadingNode)

        if (container.children.length > 1) Dom.hide(container.children[1])
        return 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 () {
                BFCards.toggleHidden(!BFState.hide)
                this.innerText = (BFState.hide ? 'Show' : 'Hide') + ' hidden'
              },
              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

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

      setupPage()
      handleShowHideLink()
      handleWatchBtns()
      BFWatchlist.refresh().then(() => {
        BFCards.addFrom(container)
        $('#bf-hide-initial')?.remove()
        BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode), true /* first page */)
        handleInfiniteScroll()
      })
    }

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