Add infinite scrolling and options for filtering sellers to the Buyee search results page
当前为
// ==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) })() })()