Buyee Seller Filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      2.11
// @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/async-lz-string.js
// @require      https://unpkg.com/[email protected]/dist/localforage.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`,
          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: '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' }),
        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 compress = str => compressToUTF16(str).then(v => `BFLZ:${v}`) // eslint-disable-line no-undef
    const decompress = str => str?.startsWith('BFLZ:') ? decompressFromUTF16(str.slice(5)) : Promise.resolve(str) // eslint-disable-line no-undef
    const safeParse = s => {
      if (!s) return Promise.resolve({})
      return decompress(s).then(t => {
        try { return JSON.parse(t) }
        catch { return {} }
      }).catch(() => ({}))
    }

    const flush = () => {
      if (flushing) return Promise.resolve()

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

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

      const parse = key => localforage.getItem(`BF${key}`) // eslint-disable-line no-undef
        .then(r1 => {
          const r2 = localStorage.getItem(`BFU${key}`)
          if (!r1 && !r2) return Promise.resolve([{},{}])
          return Promise.all([safeParse(r1), safeParse(r2)])
        })
        .then(([v1, v2]) => {
          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 }
  })()

  /*** 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('.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'
        }
      })

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

    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 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 => {
      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 }

  })()

  /*** 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(),
          hide: false
        })
      }

      const endTimeText = $x('//li[.//*[contains(text(),"Closing Time")]]',doc)?.$('span:last-child')?.textContent
      if (endTimeText) item.endTime = new Date(endTimeText + ' GMT+0900').valueOf()

      // 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 = 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)
      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(`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 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('.bf-inner')
        const div = el('.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))
        }});

      (BFState.isDesktop ? card.statusList : 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

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

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

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

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

          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

          // seller
          this.infoList.append(infoNode('Seller', el('a', { text: seller.name, href: `https://buyee.jp/item/search/customer/${this.sellerid}`, 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)

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

          // show/hide buttons
          this.sellerToggle = makeToggle(seller.hide, 'seller', hidden => toggleSeller(this.sellerid, hidden))
          if (!BFState.isDesktop) {
            this.infoToggles = el('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexDirection: 'row' } })
            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)
          if (!BFState.isDesktop) {
            const watcherStar = this.$('.watchButton')
            watcherStar.style.marginBottom = '-6px'
            this.infoToggles.append(watcherStar)
          }

          // block/unblock
          attachBlockNodes(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()

        const scraped = BFState.items.has(id)
        const card = newCard(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}); return }

        card.annotate()

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

    function updateDom (addNode, firstpage = false) {
      const batch = el('.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) {
            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 && void batch.offsetWidth
      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('.imgLoading', { style: 'height: 50px' }),
          el('.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 = btn.dataset.id

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

      setupPage()
      BFState.initialise().then(() => {
        handleShowHideLink()
        handleWatchBtns()
        return 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)
  })()
})()