Add infinite scrolling and options for filtering sellers to the Buyee search results page
当前为
// ==UserScript== // @name Buyee Seller Filter // @license MIT // @version 1.69 // @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 // ==/UserScript== /*** DOM utils ***/ 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, maybeHandler, capture=false) { // direct binding: el.on('click', fn) if (typeof selectorOrHandler === 'function') { this.addEventListener(type, selectorOrHandler) return this } // delegated binding: el.on('click', 'button', fn) const selector = selectorOrHandler const handler = maybeHandler this.addEventListener(type, ev => { const el = ev.target.closest(selector) if (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) if (id) e.id = id if (cls) e.classList.add(...cls.split('.').filter(Boolean)) for (const [k, v] of Object.entries(props)) { if (k === 'style') typeof v === 'string' ? e.style.cssText = v : Object.assign(e.style, v) else if (k === 'dataset') Object.assign(e.dataset, v) else if (k === 'text') e.textContent = v else if (k === 'html') e.innerHTML = v else if (/^on[A-Z0-9]/i.test(k)) e[k] = v else e.setAttribute(k, v) } for (const c of children.flat()) e.append(c instanceof Node ? c : document.createTextNode(c)) return e } function fetchURL(url) { return fetch(url) .then(r => r.ok ? r.text() : Promise.reject(`HTTP ${r.status}`)) .then(html => new DOMParser().parseFromString(html, 'text/html')) } const Dom = { hide: el => { if (el) el.style.display = 'none' }, show: el => { if (el) el.style.removeProperty('display') }, hidden: el => getComputedStyle(el)?.display === 'none' } /*** state ***/ const BFState = { sellers: {}, items: {}, hide: true, savedKeys: ['sellers', 'items', 'hide'], save() { this.savedKeys.forEach(k => (localStorage[`BF${k}`] = JSON.stringify(this[k]))) }, load() { this.savedKeys.forEach(k => { const v = localStorage[`BF${k}`] if (v) this[k] = JSON.parse(v) }) }, clean() { const now = Date.now() for (const url in this.items) { if (now - this.items[url].lastSeen > 604800000) delete this.items[url] } this.save() }, isDesktop: (navigator.userAgent.match(/Android/i) || navigator.userAgent.match(/webOS/i) || navigator.userAgent.match(/iPhone/i) || navigator.userAgent.match(/iPad/i) || navigator.userAgent.match(/iPod/i) || navigator.userAgent.match(/BlackBerry/i) || navigator.userAgent.match(/Windows Phone/i)) ? false : 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 style = () => Object.values(filters).map(v => v.filt()).join(' ') const reset = o => Object.entries(filters).forEach(([k,v]) => (v.val = (o && o[k]) ?? v.def)) const values = () => Object.fromEntries(Object.entries(filters).map(([k,v]) => [k, v.val])) const changedValues = () => 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])) const styleOf = o => { reset(o); return style() } return { act, style, reset, values, changedValues, names, styleOf } })() /*** 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 id const add = img => { img.classList.add('js-smartPhoto') if (open) pending.push(img) else smartPhoto.addNewItem(img) } const addControls = overlay => { if ($('.sp-filters')) return const panel = el('div.sp-filters', { style: { position: 'absolute', top: '60px', left: '50%', transform: 'translateX(-50%)', display: 'flex', flexDirection: 'column', gap: '8px', zIndex: 9999, background: 'rgba(0,0,0,0.4)', padding: '10px', borderRadius: '6px', userSelect: 'none', color: 'white', font: '14px sans-serif', touchAction: 'manipulation', opacity: 0, pointerEvents: 'none' }}, ...Object.entries(BFFilters.names()).map(([k, txt]) => el('div.sp-row', { style: { display: 'flex', alignItems: 'center', gap: '8px' }}, el('span', { text: `${txt}:`, style: 'min-width: 40px' }), el('span.sp-val', { 'data-for' : k, style: 'min-width: 25px' }), el('button', { 'data-action' : k, 'data-step': '-1', text: '-', style: 'min-width: 40px; min-height: 40px' }), el('button', { 'data-action' : k, 'data-step': '+1', text: '+', style: 'min-width: 40px; min-height: 40px' }) )), el('button', { 'data-action' : 'reset', translate: 'no', text: 'Ø', style: 'min-height: 40px' }) ) const toggle = el('button.sp-toggle', { text: 'Adjust', style: { position: 'absolute', top: '15px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.5)', color: 'white', border: 'none', borderRadius: '6px', padding: '10px', height: '40px', cursor: 'pointer', zIndex: 10000 }, 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 sellerid = BFState.items[id]?.seller const sellerThumbs = Array.from($$('li.itemCard[data-id]')) .filter(card => BFState.items[card.dataset.id]?.seller === sellerid) .map(card => card.$('.g-thumbnail__outer img')).filter(Boolean) ;[...($('.smartphoto-list')?.$$('.smartphoto-img') ?? []), ...($('.smartphoto-nav')?.$$('a') ?? []), ...sellerThumbs ].forEach(img => (img.style.filter = style)) panel.$$('.sp-val').forEach(s => (s.textContent = BFFilters.values()[s.dataset.for])) } const act = (name, step) => { if (name === 'reset') BFFilters.reset() else 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, () => { if (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 = () => { const sellerid = BFState.items[id]?.seller const seller = sellerid && BFState.sellers[sellerid] BFFilters.reset(seller?.filters) } const save = () => { const sellerid = BFState.items[id]?.seller const seller = BFState.sellers[sellerid] if (seller) { seller.filters = BFFilters.changedValues() BFState.save() } } document.on('pointerdown', 'a.js-smartPhoto', (e, a) => { open = true id = a.dataset.group load() }, true) smartPhoto.on('open', () => { const id = smartPhoto.data.currentGroup load() $('.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') if (panel && panel.style.opacity === '1' && !panel.contains(e.target) && !toggle.contains(e.target)) toggle.click() }) return { add } })() /*** countdown timers ***/ const BFCountdown = (() => { const countdowns = new Map() function timeUntil(dateStr) { const jstDate = new Date(dateStr + ' GMT+0900') const now = new Date() const diff = (jstDate - now)/1000 if (diff <= 0) return "Ended" const fmt = (n, t) => n > 0 ? `${n} ${t}${n > 1 ? 's' : ''}` : null if (diff < 1800) { const min = Math.floor((diff / 60) % 60) const sec = Math.floor(diff % 60) return [fmt(min, 'minute'), fmt(sec, 'second')].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, "hour"), fmt(min, "minute")].filter(Boolean).join(' ') } } function add(date, el) { countdowns.set(el, date) el.$('.g-text').textContent = timeUntil(date) } function remove(el) { countdowns.delete(el) } function update() { for (const [el, date] of countdowns) el.$('.g-text').textContent = timeUntil(date) setTimeout(update, 1000) } update() return { add, remove } })() /*** card scraping ***/ const BFQueue = (callback) => { const MAX_CONCURRENT = 3 const DELAY = 250 const processingQueue = [] const inFlightItems = new Set() const readItems = new Set() let activeCount = 0 const sleep = ms => new Promise(r => setTimeout(r, ms)) function run() { if (!processingQueue.length || activeCount >= MAX_CONCURRENT) 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) sleep(DELAY).then(() => { activeCount-- run() }) }) activeCount < MAX_CONCURRENT && 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 } } const BFCardScraper = BFQueue((id, doc) => { const sellernode = $x('//a[contains(@href,"search/customer")]',doc) const [, seller] = sellernode?.href?.match(/\/item\/search\/customer\/(.*)/) || [] const data = { hide: false, seller } if (seller && !(seller in BFState.sellers)) BFState.sellers[seller] = { name: sellernode.innerText.trim(), url: sellernode.href, hide: false } // get end time data.endTime = $x('//li[.//*[contains(text(),"Closing Time")]]',doc) ?.$('span:last-child') ?.textContent // get number of watchers data.watchNum = 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 data.images = Array.from(doc.$$('.js-smartPhoto')).map(node => node.href) BFState.items[id] = data BFState.save() annotateCard(document.$(`li.itemCard[data-id="${id}"]`)) }) /*** handle watchlist ***/ const BFWatchlist = (() => { let watchlist = [] const apiCall = (url, body = '') => fetch(`https://buyee.jp/api/v1/${url}`, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, method: 'POST', body }).then(r => r.json()) const has = id => watchlist.includes(id) const refresh = () => apiCall('watch_list/find') .then(data => (watchlist = data?.data?.list ?? [])) const add = id => apiCall('watch_list/add', `auctionId=${id}&buttonType=search&isSignupRedirect=false`) .then(() => watchlist.push(id)) const remove = id => apiCall('watch_list/remove', `auctionId=${id}`) .then(() => watchlist.splice(watchlist.indexOf(id), 1)) const toggle = id => has(id) ? remove(id) : add(id) const updateBtn = (btn, id) => { const active = has(id) btn.classList.toggle('is-active', active) btn.firstElementChild.classList.toggle('g-feather-star-active', active) btn.firstElementChild.classList.toggle('g-feather-star', !active) } return { refresh, add, remove, toggle, has, updateBtn } })() function annotateCard(card) { const url = card.$('.itemCard__itemName a').href const id = card.dataset.id = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/)?.[1] || '' const item = BFState.items[id] const seller = item && BFState.sellers[item.seller] const infoList = card.$('.itemCard__infoList') const statusList = card.$('ul.auctionSearchResult__statusList') const scrape = () => BFCardScraper.add({ url, id }) const newInfoNode = (title, body) => el('li.itemCard__infoItem.bf-node', {}, el('span.g-title', { text: title }), el('span.g-text', ...(typeof body === 'string' ? [{ text : body }] : [{}, body]))) const resetCard = () => { card.$$('.bf-node').forEach(node => node.remove()) card.$$('li.itemCard__infoItem').forEach(node => node.textContent.trim() === '' && node.remove()) card.$('.g-thumbnail__outer a').href = "#" } const handleSellerInfo = () => infoList.append(newInfoNode('Seller', el('a', { text: seller.name, href: seller.url, target: '_blank', rel : 'noopener noreferrer'}))) const handleTimeLeft = () => { const oldTime = infoList.firstElementChild const newTime = newInfoNode('Time Left','') newTime.classList.remove('bf-node') BFCountdown.remove(oldTime) oldTime.replaceWith(newTime) BFCountdown.add(item.endTime, newTime) } const handleWatchers = () => { const star = card.$('.g-feather').parentNode star.append(el('span.watchList__watchNum.bf-node', { text: item.watchNum })) } const attachSmartPhoto = () => { if (!item.images?.length) return const thumb = card.$('.g-thumbnail__outer a') if (thumb.classList.contains('js-smartPhoto')) return thumb.href = item.images[0] thumb.dataset.group = id BFPhoto.add(thumb) if (seller.filters) thumb.$('img').style.filter = BFFilters.styleOf(seller.filters) const hidden = el('div', { style: 'display:none' }) thumb.parentNode.append(hidden) item.images.slice(1).forEach( image => { const link = el('a', { href: image, dataset: { group: id }}) hidden.append(link) BFPhoto.add(link) }) } const handleShowHideBtns = () => { const btn = (v, txt, fn) => el('button.bf-node', { type: 'button', text: [v.hide ? 'Show' : 'Hide', txt].join(' '), onclick: () => { v.hide = !v.hide; BFState.save(); fn() } }) const hidden = seller.hide || item.hide if (hidden && BFState.hide) { Dom.hide(card) } else { Dom.show(card) card.style.opacity = hidden ? '0.9' : '' card.style['background-color'] = hidden ? '#ffbfbf' : '' infoList.append(btn(seller, 'seller', () => annotateCardsIn(document))) if (!seller.hide || BFState.hide) statusList.append(btn(item, '', () => annotateCard(card))) if (new Date(item.endTime + ' GMT+0900') < Date.now()) scrape() } } resetCard() if (!item) { scrape(); return } if (BFWatchlist.has(id)) scrape() item.lastSeen = Date.now() handleSellerInfo() handleTimeLeft() handleWatchers() attachSmartPhoto() handleShowHideBtns() } function annotateCardsIn(element) { element.$$('ul.auctionSearchResult li.itemCard:not(.rg-loading)').forEach(annotateCard) BFState.save() } const moveCards = (() => { 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) } }) return function moveCards(src, tgt, loadingEl) { if (!src || !tgt) return src.$$('ul.auctionSearchResult li.itemCard').forEach(card => { const id = card.dataset.id if (tgt.$(`li.itemCard[data-id="${id}"]`)) return tgt.append(card) if (!(Dom.hidden(card)) && !(Dom.hidden(loadingEl))) Dom.hide(loadingEl) const img = card.$('img.lazyLoadV2') if (img) observer.observe(img) const btn = card.$('div.watchButton') if (btn) BFWatchlist.updateBtn(btn, id) }) } })() function buyeeSellerFilter () { const container = document.$('.g-main:not(.g-modal)') const resultsNode = container.firstElementChild const handleVisibilityChange = () => { document.on('visibilitychange', () => { if (!document.hidden) { const hide = BFState.hide BFState.load() BFState.hide = hide annotateCardsIn(document) } }) } const hideTranslate = () => { if (!BFState.isDesktop) { document.head.append(el('style', { text: ` #goog-gt-tt, .goog-te-balloon-frame{display: none !important;} .goog-text-highlight { background: none !important; box-shadow: none !important;} `})) } } const mergeResultsLists = () => { if (container.children.length > 1) { const second = container.children[1] second.$$('ul.auctionSearchResult li.itemCard') .forEach(card => resultsNode.append(card)) Dom.hide(second) } } const handleShowHideLink = () => { const showHideParent = (BFState.isDesktop) ? document.$('.result-num').parentNode : document.$('.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 () { BFState.hide = !BFState.hide BFState.save() this.innerText = (BFState.hide ? 'Show' : 'Hide') + ' hidden' annotateCardsIn(document) }, style: 'display:inline-block; width:110px' }) ) ) } const handleWatchBtns = () => { document.on('click', 'div.watchButton', (e, btn) => { e.preventDefault() e.stopImmediatePropagation() const id = BFState.isDesktop ? btn.dataset.auctionId : btn.parentNode.parentNode.dataset.id const card = btn.closest("li.itemCard") const url = card.$('.itemCard__itemName a')?.href BFWatchlist.toggle(id) BFWatchlist.updateBtn(btn, id) if (BFWatchlist.has(id)) BFCardScraper.add({ url, id }, 'force') }) } const handleInfiniteScroll = () => { let currentURL = new URL(document.location) let currentPage = document let naviOnScreen = false const loadingNode = el('li.itemCard.rg-loading', {}, el('div.imgLoading', { style: 'height: 50px' }), el('div.g-title', { text: 'Loading page 2', style: 'text-align: center; height: 50px' }) ) const nextPageURL = url => { url.searchParams.set('page', 1 + (+url.searchParams.get('page') || 1)) return url } const lastPage = doc => BFState.isDesktop ? doc.$('div.page_navi a:nth-last-child(2)')?.innerText !== '>' : doc.$('li.page--arrow a span.last') === null const loadPageLoop = () => { if (!naviOnScreen || lastPage(currentPage)) { if (naviOnScreen) Dom.hide(loadingNode) return } currentURL = nextPageURL(currentURL) resultsNode.append(loadingNode) loadingNode.$('.g-title').innerText = 'Loading page ' + currentURL.searchParams.get('page') Dom.show(loadingNode) fetchURL(currentURL) .then(page => { currentPage = page annotateCardsIn(currentPage) moveCards(currentPage, resultsNode, loadingNode) }) .catch(e => console.error('Failed to load page: ', e)) .finally(() => setTimeout(loadPageLoop, 100)) } const observer = new IntersectionObserver(entries => { entries.forEach(e => (naviOnScreen = e.isIntersecting)) if (naviOnScreen) loadPageLoop() }, { rootMargin: "0px 0px 540px 0px" }) observer.observe($(BFState.isDesktop ? 'div.page_navi' : 'ul.pagination')) } BFState.load() hideTranslate() handleVisibilityChange() mergeResultsLists() handleShowHideLink() handleWatchBtns() handleInfiniteScroll() BFWatchlist.refresh() .then(() => annotateCardsIn(document)) .then(() => BFState.clean()) } // stuff to handle loading stage of page const waitFor = (sel, fn) => { const el = $(sel) if (el) return fn(el) new MutationObserver((o, obs) => { const el = $(sel) if (el) { obs.disconnect(); fn(el) } }).observe(document.body, { childList: true, subtree: true }); } waitFor('.g-main:not(.g-modal)', buyeeSellerFilter)