Add infinite scrolling and options for filtering sellers to the Buyee search results page
目前為
// ==UserScript== // @name Buyee Seller Filter // @license MIT // @version 2.22 // @description Add infinite scrolling and options for filtering sellers to the Buyee search results page // @author rhgg2 // @match https://buyee.jp/item/search/* // @icon https://www.google.com/s2/favicons?domain=buyee.jp // @namespace https://greasyfork.org/users/1243343 // @grant none // @require https://unpkg.com/[email protected]/dist/localforage.min.js // @require https://unpkg.com/[email protected]/dist/simple-lightbox.min.js // ==/UserScript== (() => { 'use strict' if (window.top !== window.self) return document.head.append(el('style', { id: 'bf-hide-initial', text: '.g-main:not(.g-modal) { visibility: hidden !important; height: 100vh }' })) document.head.append(el('meta', { name: 'google', content: 'notranslate' })) /*** utilities ***/ Document.prototype.$ = Element.prototype.$ = function(sel) { return this.querySelector(sel) } Document.prototype.$$ = Element.prototype.$$ = function(sel) { return this.querySelectorAll(sel) } const $ = Element.prototype.$.bind(document) const $$ = Element.prototype.$$.bind(document) function $x(path, ctx = document) { return ctx.evaluate(path, ctx, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue } Document.prototype.on = Element.prototype.on = function (type, selectorOrHandler, handlerOrCapture, capture=false) { // direct binding: el.on('click', fn, capture) if (typeof selectorOrHandler === 'function') { this.addEventListener(type, selectorOrHandler, handlerOrCapture) return this } // delegated binding: el.on('click', 'button', fn, capture) const selector = selectorOrHandler const handler = handlerOrCapture this.addEventListener(type, ev => { const el = ev.target.closest(selector) el && this.contains(el) && handler.call(el, ev, el) }, capture) return this } function el(sel = 'div', props = {}, ...children) { const [, tag = 'div', id, cls = ''] = sel.match(/^([a-z][\w-]*)?(?:#([\w-]+))?(.*)?$/) || [] const e = document.createElement(tag) id && (e.id = id) cls && e.classList.add(...cls.split('.').filter(Boolean)) Object.entries(props).forEach(([k,v]) => { k === 'style' ? typeof v === 'string' ? e.style.cssText = v : Object.assign(e.style, v) : k === 'data' ? Object.assign(e.dataset, v) : k === 'text' ? e.textContent = v : k === 'html' ? e.innerHTML = v : /^on[A-Z0-9]/i.test(k) ? e[k] = v : e.setAttribute(k, v) }) children.flat().forEach(c => e.append(c instanceof Node ? c : document.createTextNode(c))) return e } function fetchURL(url, timeout = 10000) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeout) return fetch(url, { signal: controller.signal }) .then(r => (clearTimeout(timer), r.ok) ? r.text() : Promise.reject({ type: 'http', status: r.status })) .then(html => new DOMParser().parseFromString(html, 'text/html')) .catch(err => { err.type ??= err.name === 'AbortError' ? 'timeout' : err instanceof TypeError ? 'network' : 'other' return Promise.reject(err) }) } const Dom = { hide: el => el && (el.style.display = 'none'), show: el => el && el.style.removeProperty('display'), hidden: el => getComputedStyle(el)?.display === 'none', reflow: el => { el.offsetWidth; return el }, style: (name, obj) => (t => `${name} {${t}}`)( Object.entries(obj).map(([k,v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v}` ).join('; ')) } function waitFor (sel) { return new Promise(resolve => { const el = $(sel) if (el) return resolve(el) new MutationObserver((o, obs) => { const el = $(sel) if (el) { obs.disconnect(); resolve(el) } }).observe(document.body, { childList: true, subtree: true }); }) } const wait = ms => ms ? new Promise(resolve => setTimeout(resolve, ms)) : new Promise(requestAnimationFrame) /*** config ***/ const BFConfig = (() => { const ANIM_DELAY = 350 return { SCRAPER_THREADS: 5, SCRAPER_DELAY: 100, SCROLL_DELAY: 100, HIDE_TIMEOUT: 3500, UNBLOCK_DELAY: 3000, BATCH_SIZE: 20, ANIM_DELAY, STYLES: [ 'span.watchList__watchNum.bf-node { user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none }', Dom.style('button.bf-button', { border: '1px solid silver', padding: '3px 5px', borderRadius: '3px', margin: '2px', fontSize: '100%', fontFamily: 'Arial,"Hiragino Kaku Gothic ProN",Meiryo,"MS PGothic",sans-serif' }), Dom.style('div.bf-batch', { transition: `opacity ${ANIM_DELAY}ms ease, transform ${ANIM_DELAY}ms ease`, overflowX: 'hidden' }), Dom.style('div.bf-batch.animate', { opacity: 0, transform: 'translateY(30px)' }), Dom.style('button.sp-toggle', { position: 'absolute', top: '15px', left: '50%', transform: 'translateX(-50%)', padding: '10px', height: '40px', cursor: 'pointer', zIndex: 13000, background: 'rgba(0,0,0,0.5)', color: 'white', border: 'none', borderRadius: '6px', fontSize: '1.1em !important' }), Dom.style('div.sp-filters', { position: 'absolute', top: '60px', left: '50%', transform: 'translateX(-50%)', display: 'flex', flexDirection: 'column', gap: '8px', padding: '10px', zIndex: 12999, background: 'rgba(0,0,0,0.4)', color: 'white', borderRadius: '6px', userSelect: 'none', font: '14px sans-serif', touchAction: 'manipulation', }), Dom.style('.sp-filters button', { background: 'white !important', fontSize: '100% !important' }), Dom.style('.bf-hide', { opacity: '0 !important', transform: 'translateX(60px) !important', }), Dom.style('.bf-dimmed', { opacity: '0.9 !important', backgroundColor: '#ffbfbf !important', }), Dom.style('.bf-item', { willChange: 'background-color opacity transform', transition: `background-color ${ANIM_DELAY}ms ease, opacity ${ANIM_DELAY}ms ease, transform ${ANIM_DELAY}ms ease`, opacity: '1', transform: 'translateX(0)' }), Dom.style('.bf-modal', { position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', zIndex: '10000', overflowY: 'auto', overflowX: 'hidden', padding: '4em 2em', boxSizing: 'border-box', }), Dom.style('.bf-inner', { background: 'white', borderRadius: '8px', maxWidth: '600px', width: '100%', padding: '10px', boxShadow: '0 4px 20px rgba(0,0,0,0.3)', overflowWrap: 'anywhere', boxSizing: 'border-box', }), Dom.style('.bf-inner *', { maxWidth: '100%', boxSizing: 'border-box' }), Dom.style('footer, .footer', { display: 'none !important' }) ], DESKTOP_STYLES: [ Dom.style('button.bf-button:hover', { filter: 'brightness(90%)' }), ], MOBILE_STYLES: [ Dom.style('#goog-gt-tt, .goog-te-balloon-frame', { display: 'none !important' }), Dom.style('.goog-text-highlight', { background: 'none !important', boxShadow: 'none !important' }), Dom.style('.auctionSearchResult .g-thumbnail__outer', { flex: '0 0 145px' }), Dom.style('.auctionSearchResult .g-thumbnail', { height: '145px', width: '145px' }), Dom.style('.auctionSearchResult .itemCard__itemInfo', { flex: '1 0 calc(100vw - 175px)' }), Dom.style('.auctionSearchResult .itemCard__itemName', { fontSize: '1.35rem', marginBottom: '5px', wordBreak: 'break-all' }), Dom.style('.auctionSearchResult .g-priceDetails__item .g-price__outer .g-price', { fontSize: '1.5rem', display: 'inline-block' }), Dom.style('.auctionSearchResult .itemCard__infoItem .g-text', { fontSize: '1.35rem' }), ] } })() /*** state ***/ const BFState = (() => { const isDesktop = !/android|webos|iphone|ipad|ipod|blackberry|windows phone/i.test(navigator.userAgent) let initialised = false let flushing = false const state = { sellers: {}, items: {} } const stateUpd = { sellers: {}, items: {} } const flush = () => { if (flushing) return Promise.resolve() flushing = true stateUpd.sellers = {} stateUpd.items = {} { const save = key => localforage.setItem(`BF${key}`, state[key]) // eslint-disable-line no-undef return Promise.all([save('sellers'), save('items')]) .then(() => { delete localStorage.BFUsellers delete localStorage.BFUitems }) .finally(() => { flushing = false }) } } const safeParse = s => { if (!s) return null if (typeof s === 'object') return s try { return JSON.parse(s) } catch { return null } } const initialise = () => { if (initialised) return Promise.resolve() const parse = key => localforage.getItem(`BF${key}`) // eslint-disable-line no-undef .then(v1 => { v1 = v1 ?? {} const v2 = safeParse(localStorage.getItem(`BFU${key}`)) ?? {} const store = state[key] Object.assign(store, v1) Object.entries(v2).forEach(([k,v]) => { if (v._replace) { store[k] = v; delete v._replace } else { store[k] ??= {} Object.assign(store[k], v) } }) }) const prune = () => { const now = Date.now(), WEEK = 6048e5 const pruneStore = (store, len) => { Object.entries(store).forEach(([k,v]) => { v.lastSeen && (now - v.lastSeen > len) && delete store[k] }) } pruneStore(state.items, WEEK) pruneStore(state.sellers, 24 * WEEK) } return Promise.all([parse('sellers'), parse('items')]) .then(prune) .then(flush) .then(() => (initialised = true)) } const proxy = key => { const get = k => state[key][k] const has = k => k in state[key] const set = (k,v) => { v._replace = true state[key][k] = v stateUpd[key][k] = v localStorage[`BFU${key}`] = JSON.stringify(stateUpd[key]) if (localStorage[`BFU${key}`].length > 500000) flush() } const assign = (k,v) => { state[key][k] ??= {} stateUpd[key][k] ??= {} Object.assign(state[key][k], v) Object.assign(stateUpd[key][k], v) localStorage[`BFU${key}`] = JSON.stringify(stateUpd[key]) } return { get, set, has, assign } } const safeKeys = new Set(['initialise', 'isDesktop', 'hide']) return new Proxy({ sellers: proxy('sellers'), items: proxy('items'), hide: JSON.parse(localStorage.BFhide ?? 'true'), isDesktop, initialise }, { get(obj, k) { if (!initialised && !safeKeys.has(k)) { console.error("Store not initialised before use") return undefined } return obj[k] }, set(obj, k, v) { obj[k] = v if (k === 'hide') localStorage.BFhide = JSON.stringify(v) return true } }) })() /*** image filters ***/ const BFFilters = (() => { const filters = { hue: { txt:'Hue', step:1, min:-180, max:180, val:0, def:0, filt() { return `hue-rotate(${this.val}deg)` }}, sat: { txt:'Sat', step:2, min:0, max:300, val:100, def:100, filt() { return `saturate(${this.val}%)` }}, bri: { txt:'Lum', step:1, min:0, max:300, val:100, def:100, filt() { return `brightness(${this.val}%)`}}, con: { txt:'Con', step:2, min:0, max:300, val:100, def:100, filt() { return `contrast(${this.val}%)`}} } const act = (filt, sgn) => { const a = filters[filt] a.val = Math.max(a.min, Math.min(a.max, a.val + a.step * sgn)) } const reset = o => Object.entries(filters).forEach(([k,v]) => (v.val = (o?.[k] ?? v.def))) const style = (o = null) => { o && reset(o); return Object.values(filters).map(v => v.filt()).join(' ') } const values = () => Object.fromEntries(Object.entries(filters).map(([k,v]) => [k, v.val])) const changed = () => Object.fromEntries(Object.entries(filters).filter(([k,v]) => v.val !== v.def).map(([k,v]) => [k, v.val])) const names = () => Object.fromEntries(Object.entries(filters).map(([k,v]) => [k, v.txt])) return { act, style, reset, values, changed, names } })() /*** photo enhancements ***/ const BFPhoto = (() => { let thumbsCallback const add = elt => { const elements = [...elt.$$('a')] if (!elements.length) return const id = elements[0].dataset?.id const sellerid = BFState.items.get(id)?.seller const lightbox = new SimpleLightbox(elements, { // eslint-disable-line no-undef animationSpeed: 100, fadeSpeed: 100, widthRatio: 1, loop: false }) lightbox.on('shown.simplelightbox', () => { const seller = sellerid && BFState.sellers.get(sellerid) BFFilters.reset(seller?.filters) addControls($('.simple-lightbox'), lightbox, sellerid) }) lightbox.on('close.simplelightbox', () => { BFState.sellers.assign(sellerid, { filters: BFFilters.changed() }) }) } const addControls = (overlay, lightbox, sellerid) => { const panel = el('.sp-filters', { style: { opacity: '0', pointerEvents: 'none' }}, ...Object.entries(BFFilters.names()).map(([k, txt]) => el('.sp-row', { style: { display: 'flex', alignItems: 'center', gap: '8px' }}, el('span', { text: `${txt}:`, style: 'min-width: 40px' }), el('span.sp-val', { data: { for: k }, style: 'min-width: 25px' }), el('button', { data: { action: k, step: '-1' }, text: '-', style: 'min-width: 40px; min-height: 40px' }), el('button', { data: { action: k, step: '+1' }, text: '+', style: 'min-width: 40px; min-height: 40px' }) )), el('button', { data: { action: 'reset' }, text: 'Ø', style: 'min-height: 40px' }) ) const toggle = el('button.sp-toggle', { text: 'Adjust', onclick: () => { const shown = panel.style.opacity === '1' panel.style.opacity = shown ? '0' : '1' panel.style.pointerEvents = shown ? 'none' : 'auto' } }) const update = () => { const style = BFFilters.style() const imgs = [ ...($('.sl-image')?.$$('img') ?? []), ...lightbox.relatedElements, ...(thumbsCallback?.(sellerid) ?? []) ] imgs.forEach(img => (img.style.filter = style)) panel.$$('.sp-val').forEach(s => (s.textContent = BFFilters.values()[s.dataset.for])) } const act = (name, step) => { name === 'reset' ? BFFilters.reset() : BFFilters.act(name, step) update() } let holdTimer, activeBtn const startHoldEvs = (...evs) => evs.forEach(ev => panel.on(ev, 'button[data-action]', (e, btn) => { e.preventDefault() const { action, step } = btn.dataset activeBtn = btn btn.style.transform = 'scale(0.92)' act(action, +step) holdTimer = setTimeout(() => { holdTimer = setInterval(() => act(action, +step), 30) }, 150) }) ) const stopHoldEvs = (...evs) => evs.forEach(ev => panel.on(ev, () => { activeBtn && (activeBtn.style.transform = '') clearTimeout(holdTimer) clearInterval(holdTimer) holdTimer = activeBtn = null }) ) if (!$('.sp-filters')) { overlay.append(toggle,panel) startHoldEvs('mousedown', 'touchstart') stopHoldEvs('mouseup', 'mouseleave', 'touchend', 'touchcancel') } update() } const setCallback = fn => (thumbsCallback = fn) document.head.append(el('link', { rel: 'stylesheet', href: 'https://unpkg.com/[email protected]/dist/simple-lightbox.min.css' })) document.on('pointerdown', e => { const panel = $('.sp-filters') const toggle = $('.sp-toggle') panel && panel.style.opacity === '1' && !panel.contains(e.target) && !toggle.contains(e.target) && toggle.click() }) return { add, setCallback } })() /*** countdown timers ***/ const BFCountdown = (() => { const countdowns = new Map() function timeUntil(date) { const diff = (date - Date.now())/1000 if (diff <= 0) return "Ended" const fmt = (n, t) => n > 0 ? `${n} ${t}${n > 1 ? 's' : ''}` : null if (diff < 1800) { const min = Math.floor((diff / 60) % 60) const sec = Math.floor(diff % 60) return [fmt(min, 'min'), fmt(sec, 'sec')].filter(Boolean).join(' ') } else { const days = Math.floor(diff / 86400) const hrs = Math.floor((diff / 3600) % 24) const min = Math.floor((diff / 60) % 60) return [fmt(days, "day"), fmt(hrs, "hr"), fmt(min, "min")].filter(Boolean).join(' ') } } function add(date, el) { const fast = (date - Date.now()) < 1.8e6 countdowns.set(el, { date, paused: false, fast }) el.$('.g-text').textContent = timeUntil(date) } function remove(el) { countdowns.delete(el) } function pause(el, state = true) { const {date, fast} = countdowns.get(el) ?? {} if (date) countdowns.set(el, { date, paused: state, fast }) } function update(onlyFast = false) { const now = Date.now() countdowns.forEach((item, el) => { if (!item.paused && item.fast === onlyFast) { el.$('.g-text').textContent = timeUntil(item.date) if (!onlyFast && (item.date - now) < 1.8e6) item.fast = true }}) wait(onlyFast ? 1000 : 60000).then(() => update(onlyFast)) } update() update(true) // fast updates return { add, pause, remove } })() /*** card scraping ***/ const BFScraper = callback => { const processingQueue = [] const inFlightItems = new Set() const readItems = new Set() let activeCount = 0 function run() { if (!processingQueue.length || activeCount >= BFConfig.SCRAPER_THREADS) return const { id, url } = processingQueue.shift() activeCount++ fetchURL(url) .then(doc => { process(id, doc) callback(id,doc) readItems.add(id) }) .catch(err => console.error('fetchURL failed for', url, err)) .finally(() => { inFlightItems.delete(id) wait(BFConfig.SCRAPER_DELAY).then(() => { activeCount-- run() }) }) activeCount < BFConfig.SCRAPER_THREADS && run() } function process(id, doc) { const sellernode = $x('//a[contains(@href,"search/customer")]', doc) const [, sellerid] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || [] const item = BFState.items.get(id) ?? { hide: false, seller: sellerid } const bidBtn = $x('//button[contains(normalize-space(.), "Bid")]', doc) const snipeBtn = $x('//button[contains(normalize-space(.), "Snipe")]', doc) const msg = doc.$('.g-textArea--error, .g-box--caution') if (sellerid && !BFState.sellers.get(sellerid)) { BFState.sellers.set(sellerid, { name: sellernode.innerText.trim(), hide: false }) } const endTimeText = $x('//*[contains(@class, "itemDetail__list")][.//*[contains(text(), "Closing Time")]]',doc) ?.$('.itemDetail__listValue') ?.textContent if (endTimeText) { item.endTime = new Date(endTimeText + ' GMT+0900').valueOf() } // can I bid? (bidBtn && !bidBtn.classList.contains('disable')) || (snipeBtn && !snipeBtn.classList.contains('disable')) ? delete item.blocked : (item.blocked = (msg?.textContent.includes('manually reviewing')) ? 'P' : true) // P = pending // can I request a review? msg?.$('a')?.href?.includes('inquiry') ? (item.review = true) : delete item.review // get number of watchers item.watchNum = Number( Array.from(doc.$$('script')) .map(s => s.textContent.match(/buyee\.TOTAL_WATCH_COUNT\s*=\s*(\d+);/)) .find(Boolean)?.[1] || doc.$('.watchButton__watchNum')?.textContent || 0) // get image links item.images = Array.from(doc.$$('.js-smartPhoto')).map(node => node.href) BFState.items.set(id, item) } // force = true allows reprocessing function add(item, force = false) { if (inFlightItems.has(item.id) || (readItems.has(item.id) && !force)) return processingQueue.push(item) inFlightItems.add(item.id) run() } return { add } } /*** watchlist API ***/ const BFWatchlist = (() => { let watchlist = [] const apiCall = (url, body = '') => fetch(`https://buyee.jp/api/v1/${url}`, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, method: 'POST', body }).then(r => r.json()) const has = id => watchlist.includes(id) const refresh = () => apiCall('watch_list/find') .then(data => (watchlist = data?.data?.list ?? [])) const add = id => apiCall('watch_list/add', `auctionId=${id}&buttonType=search&isSignupRedirect=false`) .then(() => watchlist.push(id)) const remove = id => apiCall('watch_list/remove', `auctionId=${id}`) .then(() => watchlist.splice(watchlist.indexOf(id), 1)) const toggle = id => has(id) ? remove(id) : add(id) const updateBtn = btn => { const id = btn?.dataset?.id if (!id) return const active = has(id) btn.classList.toggle('is-active', active) btn.firstElementChild.classList.toggle('g-feather-star-active', active) btn.firstElementChild.classList.toggle('g-feather-star', !active) } return { refresh, add, remove, toggle, has, updateBtn } })() /*** translation helper ***/ const BFTrans = (() => { const toTrans = [] const textarea = el('textarea') let lock = false let pending = false function add(node) { toTrans.push(node) } function run() { if (!toTrans.length) { pending = false; return } if (lock) { pending = true; return } lock = true pending = false const nodes = [...toTrans] toTrans.length = 0 fetch('https://translate-pa.googleapis.com/v1/translateHtml', { method: 'POST', headers: { accept: 'application/json', 'Content-Type': 'application/json+protobuf', 'X-Goog-API-Key': 'AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520' }, body: JSON.stringify( [[nodes.map(t=>t.textContent),"ja","en"],"wt_lib"] ) }) .then(r => {return r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)}) .then(r => r[0]?.forEach((t,i) => { const node = nodes[i] if (t) { textarea.innerHTML = t node.textContent = textarea.value } else toTrans.push(node) })) .catch(e => { console.error(e); toTrans.push(...nodes) } ) .finally(() => { lock = false if (pending) run() }) } return { add, run } })() /*** card factory ***/ function BFCard(node, url, scraped, id, data = {}) { return { node, url, scraped, id, annotated: false, onPage: false, hidden: false, translated: false, infoList: node.$('.itemCard__infoList'), statusList: node.$('ul.auctionSearchResult__statusList'), titleNode: node.$('.itemCard__itemName a'), ...data, $: node.$.bind(node), $$: node.$$.bind(node), setVisibility: function() { // hiding status const item = BFState.items.get(this.id) const seller = BFState.sellers.get(this.sellerid) if (!item || !seller) return this.hidden = item.hide || seller.hide // animation const wrap = fn => this.onPage ? wait().then(fn) : fn() const wrapWait = fn => this.onPage ? wait(BFConfig.ANIM_DELAY).then(fn) : fn() wrap(() => { if (this.hidden && BFState.hide) { this.node.classList.remove('bf-dimmed') this.node.classList.add('bf-hide') wrapWait(() => Dom.hide(this.node)) } else { Dom.show(this.node) wait().then(() => wait()).then(() => { this.node.classList.remove('bf-hide') this.node.classList.toggle('bf-dimmed', this.hidden) }) } }) // countdowns if (this.timeLeft) { BFCountdown.remove(this.timeLeft) if (!this.hidden || !BFState.hide) BFCountdown.add(item.endTime, this.timeLeft) } // item toggle if (this.itemToggle) { seller.hide ? Dom.hide(this.itemToggle) : Dom.show(this.itemToggle) } } } } /*** card annotation ***/ const BFAnnotate = (() => { let scraper, toggleSeller, toggleItem function init(params) { ({scraper, toggleSeller, toggleItem} = params) } // DOM helpers const infoNode = (text, ...children) => el('li.itemCard__infoItem', {}, el('span.g-title', { text }), el('span.g-text', {}, ...children)) const button = (text, args) => el('button.bf-button', { type: 'button', text, ...args }) function makeToggle(hide, txt, callback) { let timeoutId, confirming = false, hiding = hide const label = () => [hiding ? 'Show' : 'Hide', txt].join(' ') const btn = button(label(), { onclick: () => { if (!confirming && !hiding) { confirming = true const old = btn.textContent btn.textContent = 'Really?' timeoutId = setTimeout(() => { btn.textContent = old; confirming = false }, BFConfig.HIDE_TIMEOUT) return } confirming = false if (timeoutId) { clearTimeout(timeoutId); timeoutId = undefined } callback?.(!hiding) } }) btn.toggle = h => { hiding = h; btn.textContent = label() } return btn } // annotation helpers function attachBlockNodes(card) { const id = card.id const item = BFState.items.get(id) card.unblockBtn?.remove() card.blockedNode?.remove() delete card.unblockBtn delete card.blockedNode if (item.review) { card.unblockBtn = button('Unblock', { style: { color: 'red', backgroundColor: 'pink', border: 'solid 1px red' }, onclick: () => { window.open(`https://buyee.jp/item/jdirectitems/auction/inquiry/${id}`, '_blank') delete item.review BFState.items.set(id, item) wait(BFConfig.UNBLOCK_DELAY).then(scraper.add({ url: card.url, id }, 'force')) } }) card.statusList.append(card.unblockBtn) } else if (item.blocked) { card.blockedNode = el('li.bf-node.auctionSearchResult__statusItem', { text: item.blocked === 'P' ? 'Pending' : 'Blocked', style: { color: 'red', backgroundColor: 'pink' } }) card.statusList.append(card.blockedNode) } } function attachSmartphoto(card) { const item = BFState.items.get(card.id) if (item.images?.length) { const thumb = card.$('.g-thumbnail__outer') const thumbImg = thumb.$('a') thumbImg.href = item.images[0] thumbImg.dataset.id = card.id const hidden = el('div', { style: 'display:none' }) thumb.append(hidden) item.images.slice(1).forEach(img => { const link = el('a', { href: img, data: { group: card.id }}) hidden.append(link) }) BFPhoto.add(thumb) } } function trimWhitespace(node) { while (node.firstChild) { const child = node.firstChild; if ((child.nodeType === Node.TEXT_NODE && !child.textContent.trim()) || (child.nodeType === Node.ELEMENT_NODE && child.tagName === 'BR')) { child.remove() } else break } if (node.firstChild) trimWhitespace(node.firstChild) } function attachDetails(card) { const closeModal = el => { el.remove() document.body.style.overflow = '' if (el._escHandler) { document.removeEventListener('keydown', el._escHandler) delete el._escHandler } } const modal = doc => { const inner = el('.bf-inner') const div = el('.bf-modal', {}, inner) const details = doc.$('#item-description, .g-description__main .inner') trimWhitespace(details) const title = el('h1', { text: card.titleNode.textContent, style: 'text-align: center; margin-bottom: 10px; font-size:1em; text-wrap: balance;' }) details.insertBefore(title, details.firstChild) inner.innerHTML = details.innerHTML document.body.append(div) document.body.style.overflow = 'hidden' const walker = document.createTreeWalker(inner, NodeFilter.SHOW_TEXT, { acceptNode: node => node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT } ) let node while ((node = walker.nextNode())) BFTrans.add(node) BFTrans.run() div.addEventListener('click', e => (e.target === div) && closeModal(div)) div._escHandler = e => (e.key === 'Escape') && closeModal(div) document.addEventListener('keydown', div._escHandler) } card.detailsBtn = button(' • • • ', { onclick: () => { fetchURL(`${card.url}/detail`) .then(doc => modal(doc)) }}); (BFState.isDesktop ? card.statusList : card.infoToggles).append(card.detailsBtn) } function refresh(card) { const item = BFState.items.get(card.id) if (!card.annotated || !item) return const watchButton = card.$('div.watchButton') watchButton && BFWatchlist.updateBtn(watchButton) BFCountdown.remove(card.timeLeft) BFCountdown.add(item.endTime, card.timeLeft) card.watcherCount.textContent = item.watchNum attachBlockNodes(card) card.setVisibility() } function decorate(card) { if (card.annotated) return refresh(card) const item = BFState.items.get(card.id) card.sellerid = item?.seller const seller = card.sellerid && BFState.sellers.get(card.sellerid) item && BFState.items.assign(card.id, { lastSeen: Date.now() }) seller && BFState.sellers.assign(card.sellerid, { lastSeen: Date.now() }) if (!item || !seller) return // seller card.infoList.append(infoNode('Seller', el('a', { text: seller.name, href: `https://buyee.jp/item/search/customer/${card.sellerid}`, target: '_blank', rel: 'noopener noreferrer'}))) // countdown card.timeLeft = infoNode('Time Left') card.infoList.firstElementChild.replaceWith(card.timeLeft) BFCountdown.add(item.endTime, card.timeLeft) // watchers card.watcherCount = el('span.watchList__watchNum.bf-node', { text: item.watchNum }) card.$('.g-feather')?.parentNode.append(card.watcherCount) // images if (item.images?.length) attachSmartphoto(card) const thumb = card.$('.g-thumbnail__outer a img') thumb && seller.filters && (thumb.style.filter = BFFilters.style(seller.filters)) // show/hide buttons card.sellerToggle = makeToggle(seller.hide, 'seller', hidden => toggleSeller(card.sellerid, hidden)) if (!BFState.isDesktop) { card.infoToggles = el('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexDirection: 'row' } }) card.infoToggles.append(card.sellerToggle) card.infoList.append(card.infoToggles) } else { card.infoList.append(card.sellerToggle) } card.itemToggle = makeToggle(item.hide, '', hidden => toggleItem(card.id, hidden)) card.statusList.append(card.itemToggle) // details attachDetails(card) // move watcher star on mobile if (!BFState.isDesktop) { const watcherStar = card.$('.watchButton') watcherStar.style.marginBottom = '-6px' card.infoToggles.append(watcherStar) } // make link open in new tab card.titleNode && Object.assign(card.titleNode, { target: '_blank', rel: 'noopener noreferrer' }) // block/unblock attachBlockNodes(card) card.setVisibility() card.annotated = true } return { init, decorate } })() /*** card handling ***/ const BFCards = (() => { // cards metadata const cards = new Map() let cardsReady = 0 // lazy loader observer const observer = new IntersectionObserver((entries, obs) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue target.src = target.dataset.src delete target.dataset.src target.style.background = '' obs.unobserve(target) } }) // card scraper const scraper = BFScraper(id => BFAnnotate.decorate(cards.get(id))) function toggleSeller(sellerid, hide) { BFState.sellers.assign(sellerid, { hide }) Array.from(cards.values()) .filter(c => c.sellerid === sellerid) .forEach(card => { card.sellerToggle.toggle(hide) if (!hide && !card.translated) { card.translated = true BFTrans.add(card.titleNode) } card.setVisibility() }) BFTrans.run() } function toggleItem(id, hide) { BFState.items.assign(id, { hide }) const card = cards.get(id) card?.itemToggle.toggle(hide) card?.setVisibility() } function toggleHidden(hide) { BFState.hide = hide cards.forEach(card => card.setVisibility()) } function addFrom(src) { src.$$('li.itemCard:not(.bf-loading)').forEach(node => { const url = node.$('.itemCard__itemName a').href.match(/([^?&]*)/)?.[1] || '' const id = node.dataset.id = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/)?.[1] || '' if (cards.has(id)) return node.remove() const scraped = BFState.items.has(id) const card = BFCard(node, url, scraped, id) cards.set(id, card) // set up card node.$('.g-thumbnail__outer a')?.setAttribute('href', '#') node.$$('li.itemCard__infoItem').forEach(n => n.textContent.trim() === '' ? n.remove() : BFState.isDesktop && (n.style.width = '20%')) BFState.isDesktop && (card.infoList.style.alignItems = 'center') // watchlist status const watchButton = node.$('div.watchButton') if (watchButton) { watchButton.dataset.id = id BFWatchlist.updateBtn(watchButton) } if (!scraped) { scraper.add({id, url}); cardsReady++ return } BFAnnotate.decorate(card) if (!card.hidden) cardsReady++ const item = BFState.items.get(id) if (!card.hidden && (BFWatchlist.has(id) || (item.endTime < Date.now()) || item.blocked)) scraper.add({id, url}) }) } const { updateDom, nextBatch } = (() => { let pending = 0 function nextBatch() { pending = BFConfig.BATCH_SIZE } function updateDom (addNode) { const batch = el('.bf-batch') const imgs = [] const added = [] let anyVisible = false for (const [id,card] of cards) { if (pending == 0) break if (card.onPage) continue card.onPage = true added.push(card.node) batch.append(card.node) const img = card.$('img.lazyLoadV2') if (img) { img.dataset.id = id img.dataset.src ??= img.src img.style.background = 'url(https://cdn.buyee.jp/images/common/loading-spinner.gif) no-repeat 50%;' img.src = 'https://cdn.buyee.jp/images/common/spacer.gif' imgs.push(img) } if (card.hidden) continue anyVisible = true pending-- cardsReady-- if (!card.translated) { BFTrans.add(card.titleNode) card.translated = true } } const wrap = anyVisible ? fn => { batch.classList.add('animate') fn() Dom.reflow(batch) batch.classList.remove('animate') } : fn => fn() wrap(() => { addNode(batch) Dom.reflow(batch) added.forEach(node => node.classList.add('bf-item')) }) imgs.forEach(img => observer.observe(img)) BFTrans.run() return cardsReady } return { updateDom, nextBatch } })() function rescrape (id) { const card = cards.get(id) card && scraper.add({id, url: card.url}, 'force') } BFPhoto.setCallback(sellerid => Array.from(cards.values()) .filter(card => card.sellerid === sellerid) .map(card => card.$('.g-thumbnail__outer img')) .filter(Boolean) ) BFAnnotate.init({scraper, toggleItem, toggleSeller}) return { addFrom, updateDom, toggleHidden, rescrape, nextBatch } })() /*** page loader ***/ const BFPages = (() => { const queue = [] const pages = [] let naviOnScreen = false let cardsReady = 0 let cardHeight const container = $('.g-main:not(.g-modal)') const resultsNode = container.firstElementChild const loadingNode = el('li.itemCard.bf-loading', {}, el('.imgLoading', { style: 'height: 50px' }), el('.g-title', { text: 'Loading page 1', style: 'text-align: center; height: 50px' }) ) const sentinel = $(BFState.isDesktop ? 'div.page_navi' : 'ul.pagination') let maxThreads = 3 let nextToProcess = 2 let activeCount = 0 let lastPage const callback = (page, doc) => { pages[page] = doc if (page !== nextToProcess) return while (nextToProcess in pages) { BFCards.addFrom(pages[nextToProcess]) delete pages[nextToProcess] nextToProcess++ } cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode)) updateThreads() if (cardsReady > 0 && naviOnScreen) addNext() if (nextToProcess > lastPage) { Dom.hide(loadingNode) } else { loadingNode.$('.g-title').innerText = `Loading page ${nextToProcess}` } } function updateThreads() { maxThreads = cardsReady > (BFConfig.BATCH_SIZE * 1.5) ? 0 : cardsReady > (BFConfig.BATCH_SIZE) ? 1 : cardsReady > (BFConfig.BATCH_SIZE * 0.5) ? 2 : 3 run() } function run() { while (activeCount < maxThreads && queue.length) startNext() } function startNext() { const { page, url } = queue.shift() activeCount++ fetchURL(url) .then(doc => callback(page, doc)) .catch(err => console.error('fetchURL failed for', url, err)) .finally(() => { activeCount-- run() }) } function addNext() { BFCards.nextBatch() wait(150).then(() => { cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode)) updateThreads() }) } function init() { let currentURL = new URL(document.location) let currentPage = Number(currentURL.searchParams.get('page') || 1) nextToProcess = currentPage + 1 const results = Number($('.result-num').innerText.match(/.*\/\s*([0-9]*)\s*hits/)[1] ?? '') lastPage = Math.ceil(results/20) for (let page = nextToProcess; page <= lastPage; page++) { currentURL.searchParams.set('page', page) queue.push({page, url: currentURL.toString()}) } const observer = new IntersectionObserver(entries => { entries.forEach(e => (naviOnScreen = e.isIntersecting)) if (naviOnScreen && cardsReady > 0) addNext() }, { rootMargin: "0px 0px 0px 0px" }) observer.observe(sentinel) document.on('scroll', updateThreads, { passive: true }) updateThreads() resultsNode.append(loadingNode) pages[1] = el('div') let totalHeight = 0 document.$$('li.itemCard:not(.bf-loading)').forEach(node => { pages[1].append(node.cloneNode(true)) totalHeight += node.offsetHeight node.remove() }) cardHeight = totalHeight / pages[1].children.length BFCards.addFrom(pages[1]) delete pages[1] $('#bf-hide-initial')?.remove() BFCards.nextBatch() cardsReady = BFCards.updateDom(batch => resultsNode.insertBefore(batch, loadingNode)) updateThreads() } return { init } })() /*** main module ***/ const BF = (() => { function main() { const setupPage = () => { $$('#google_translate_element, .skiptranslate').forEach(el => el.remove()) new MutationObserver(muts => { for (const m of muts) { m.addedNodes.forEach(node => { if (node.nodeType === 1 && node.querySelector?.('.skiptranslate')) node.remove() if (node.id === 'google_translate_element') node.remove() }) } }).observe(document.documentElement, { childList: true, subtree: true }) const style = [...BFConfig.STYLES, ...(BFConfig.isDesktop ? BFConfig.DESKTOP_STYLES : BFConfig.MOBILE_STYLES)].join('\n') document.head.append(el('style', { text: style })) } const handleShowHideLink = () => { const showHideParent = BFState.isDesktop ? $('.result-num').parentNode : $('.result-num') showHideParent.append( el('div', { style: BFState.isDesktop ? 'left:20%' : 'display:inline' }, el('button#rg-show-hide-link', { type: 'button', text: (BFState.hide ? 'Show' : 'Hide') + ' hidden', onclick: function () { BFCards.toggleHidden(!BFState.hide) this.innerText = (BFState.hide ? 'Show' : 'Hide') + ' hidden' }, style: 'display:inline-block; width:110px' }) ) ) } const handleWatchBtns = () => { document.on('pointerdown', 'div.watchButton', (e, btn) => { e.preventDefault() e.stopImmediatePropagation() const id = btn.dataset.id BFWatchlist.toggle(id).then(() => { BFWatchlist.updateBtn(btn) BFWatchlist.has(id) && BFCards.rescrape(id) }) }) } setupPage() BFState.initialise() .then(BFWatchlist.refresh) .then(() => { handleShowHideLink() handleWatchBtns() BFPages.init() }) } waitFor('.g-main:not(.g-modal)').then(main) })() })()