您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add infinite scrolling and options for filtering sellers to the Buyee search results page
// ==UserScript== // @name Buyee Seller Filter // @license MIT // @version 1.61 // @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== /* eslint-disable no-alert, no-console */ 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 } //function el(tag, cls = '', style = '', text) { // const n = document.createElement(tag) // if (cls) n.className = cls // if (style) n.style.cssText = style // if (text) n.textContent = text // return n //} 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 === '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 } // stored state let sellersData = {}; // metadata associated to sellers let itemsData = {}; // metadata associated to items let hideHidden = true; // should hidden items actually be hidden? // page state let buyeeWatchlist = [] let smartPhoto = new SmartPhoto(".js-smartPhoto", { showAnimation: false }); let smartPhotoOpen = false; let pendingSmartPhotoItems = []; let smartPhotoNode = $('.smartphoto'); // buyee watchlist // are we on the desktop site? const 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 function serialiseData() { localStorage.sellersData = JSON.stringify(sellersData) localStorage.hideHidden = JSON.stringify(hideHidden) localStorage.itemsData = JSON.stringify(itemsData) } function unSerialiseData() { sellersData = localStorage.sellersData ? JSON.parse(localStorage.sellersData) : {} hideHidden = localStorage.hideHidden ? JSON.parse(localStorage.hideHidden) : true itemsData = localStorage.itemsData ? JSON.parse(localStorage.itemsData) : {} } 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')) } // create a node which shows/hides the given seller when clicked function makeStatusToggle(data, caption, callback) { return el('button.rg-node', { type: 'button', text: caption, onclick: () => { console.log(data) data.hide = !data.hide serialiseData() callback() } }) } function makeSellerStatusNode(seller,caption) { return makeStatusToggle(sellersData[seller], caption, () => processCardsIn(document)) } function makeItemStatusNode(item,card,caption) { return makeStatusToggle(itemsData[item], caption, () => processCard(card)) } // const node = el('button', isDesktop ? 'rg-node' : 'rg-node g-text', '', status) // node.type = 'button' // node.onclick = () => { // const s = sellersData[seller] // s.hide = !s.hide // serialiseData() // processCardsIn(document) // } // return node // } // create a node which shows/hides the given item when clicked // function makeItemStatusNode(item,card,status) { // const node = el('button', 'rg-node auctionSearchResult__statusItem', '', status) // node.type = 'button' // node.onclick = () => { // const s = itemsData[item] // s.hide = !s.hide // serialiseData() // processCard(card) // } // return node // } // clear previous info nodes on a card function clearInfoNodes(card) { const list = card.$('.itemCard__infoList') while (list.childElementCount > 2) list.lastElementChild.remove() card.$('.watchList__watchNum')?.remove() } function addSellerNode(card, seller) { const list = card.$('.itemCard__infoList') const node = list.firstElementChild.cloneNode(true) node.$('.g-title').textContent = 'Seller' const text = node.$('.g-text') text.textContent = '' text.append(el('a', { text: seller.name, href: seller.url, target: '_blank', rel : 'noopener noreferrer' })) list.append(node) return node } // add number of watches to item card function addWatchersNode(card, count) { const star = card.$('.g-feather').parentNode star.append(el('span.watchList__watchNum', { text: count })) } // make a "loading" node for the infinite scrolling function makeLoadingNode() { return 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' }) ) } // convert time to time left function timeUntil(dateStr) { const jstDate = new Date(dateStr + " GMT+0900"); const now = new Date(); let diffSec = Math.floor((jstDate - now)/1000); if (diffSec <= 0) return "Ended"; const diffDays = Math.floor(diffSec / (60 * 60 * 24)); diffSec -= diffDays * 60 * 60 * 24; const diffHours = Math.floor(diffSec / (60 * 60)); diffSec -= diffHours * 60 * 60; const diffMinutes = Math.floor(diffSec / 60); diffSec -= diffMinutes * 60; const fmt = (n, label) => n > 0 ? `${n} ${label}${n > 1 ? "s" : ""}` : null; let parts; const totalMinutes = diffDays * 24 * 60 + diffHours * 60 + diffMinutes; if (totalMinutes < 30) { // Show minutes and seconds parts = [fmt(diffMinutes, "minute"), fmt(diffSec, "second")].filter(Boolean); } else { // Show days, hours, minutes parts = [fmt(diffDays, "day"), fmt(diffHours, "hour"), fmt(diffMinutes, "minute")].filter(Boolean); } return parts.join(" "); } // Remove old items from items list if not seen for > 1 week. function cleanItems() { const now = Date.now() for (const url in itemsData) { if (now - itemsData[url].lastSeen > 604800000) delete itemsData[url] } serialiseData() } // countdown timers const countdowns = []; // Add a countdown function addCountdown(date, el) { countdowns.push({ date, el }) el.textContent = timeUntil(date) } // update all countdowns function updateCountdowns() { const now = new Date() for (const cd of countdowns) { cd.el.textContent = timeUntil(cd.date) } setTimeout(updateCountdowns, 1000) } updateCountdowns() // queue of cards to process const MAX_CONCURRENT = 3; const DELAY = 250; const processingQueue = []; let activeCount = 0; const inFlightItems = new Set(); const readItems = new Set(); function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } function loadCard() { if (!processingQueue.length || activeCount >= MAX_CONCURRENT) return const { card, url, item } = processingQueue.shift() activeCount++ fetchURL(url) .then(doc => { fillCardData(card, doc, item) processCard(card) readItems.add(item) }) .catch(err => console.error('fetchURL failed for', url, err)) .finally(() => { inFlightItems.delete(item) sleep(DELAY).then(() => { activeCount-- loadCard() }) }) activeCount < MAX_CONCURRENT && loadCard() } // add card to processing queue function addCardToQueue(card, url, item) { if (inFlightItems.has(item)) return processingQueue.push({ card, url, item }) inFlightItems.add(item) loadCard() } // extract data from card page function fillCardData(card, doc, item) { 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 sellersData)) { sellersData[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) itemsData[item] = data serialiseData() } // patch SmartPhoto's internal click to track "opening in progress" document.addEventListener('click', e => { const node = e.target.closest('a.js-smartPhoto') if (node) smartPhotoOpen = true }) smartPhotoNode.addEventListener('close', () => { smartPhotoOpen = false for (const item in pendingSmartPhotoItems) smartPhoto.addNewItem(item) pendingSmartPhotoItems = [] }) function addSmartPhotoItem(node) { node.classList.add('js-smartPhoto') if (smartPhotoOpen) pendingSmartPhotoItems.push(node) else smartPhoto.addNewItem(node) } const actions = { hue: { txt:'H', step:1, min:-180, max:180, val:0, def:0}, sat: { txt:'S', step:2, min:0, max:300, val:100, def:100 }, bri: { txt:'V', step:1, min:0, max:300, val:100, def:100 } } const spObserver = new MutationObserver(() => smartPhotoNode?.$('.sp-filters') || addControls(smartPhotoNode)) spObserver.observe(smartPhotoNode, { childList: true, subtree: true }) smartPhotoNode.addEventListener('open', () => Object.values(actions).forEach(a => (a.val = a.def))) function addControls(overlay) { if (overlay.$('.sp-filters')) return const toggle = el('button.sp-toggle', { text: 'Adjust', style: { position: 'absolute', top: '15px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.4)', color: 'white', border: 'none', borderRadius: '10%', padding: '5px', height: '30px', cursor: 'pointer', zIndex: 10000 }, onclick: () => { const shown = panel.style.opacity === '1' panel.style.opacity = shown ? '0' : '1' panel.style.pointerEvents = shown ? 'none' : 'auto' } }) const panel = el('div.sp-filters', { style: { position: 'absolute', top: '50px', left: '50%', transform: 'translateX(-50%)', display: 'flex', flexDirection: 'column', gap: '8px', zIndex: 9999, background: 'rgba(0,0,0,0.4)', padding: '6px 10px', borderRadius: '8px', userSelect: 'none', color: 'white', font: '14px sans-serif', touchAction: 'manipulation', opacity: 0, pointerEvents: 'none' }}, ...Object.entries(actions).map(([k, a]) => el('div.sp-row', { style: { display: 'flex', alignItems: 'center', gap: '8px' }}, el('button', { 'data-action' : k, 'data-step': -a.step, translate: 'no', text: a.txt + '-', style: 'min-width: 42px'}), el('span.sp-val', { 'data-for' : k, text: a.val, style: 'min-width: 25px; text-align: center' }), el('button', { 'data-action' : k, 'data-step': a.step, translate: 'no', text: a.txt + '+', style: 'min-width: 42px' }) )), el('button', { 'data-action' : 'reset', translate: 'no', text: 'Ø', style: 'min-width: 42px' }) ) overlay.append(toggle,panel) const update = () => { const img = $('.smartphoto-list .current .smartphoto-img') if (img) img.style.filter = `hue-rotate(${actions.hue.val}deg) saturate(${actions.sat.val}%) brightness(${actions.bri.val}%)` panel.$$('.sp-val').forEach(s => (s.textContent = actions[s.dataset.for].val)) } const act = (name, step) => { if (name === 'reset') Object.values(actions).forEach(a => (a.val = a.def)) else { const a = actions[name] a.val = Math.max(a.min, Math.min(a.max, a.val + step)) } update() } // --- Hold-to-repeat setup --- let holdTimer, activeBtn function startHold(btn) { 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) } function stopHold() { activeBtn?.style && (activeBtn.style.transform = '') clearTimeout(holdTimer) clearInterval(holdTimer) holdTimer = activeBtn = null } ;['mousedown', 'touchstart'].forEach( ev => panel.addEventListener(ev, e => { const btn = e.target.closest('button[data-action]') if (btn) { e.preventDefault() startHold(btn) } }) ) ;['mouseup', 'mouseleave', 'touchend', 'touchcancel'] .forEach( ev => panel.addEventListener(ev, stopHold)) update() } // process changes to a results card; this may be called to refresh the page on a state change, // so be sure to account for the previous changes function processCard(card) { // find url const url = card.$('.itemCard__itemName a').href; const [, item] = url.match(/\/item\/jdirectitems\/auction\/([^?&]*)/) || [] card.$('.g-thumbnail__outer a').href = "#" if (!(item in itemsData)) { addCardToQueue(card, url, item) return } // refresh any cards on the watchlist if (buyeeWatchlist.includes(item) && !readItems.has(item)) addCardToQueue(card, url, item) const itemData = itemsData[item] const seller = itemData.seller const sellerData = sellersData[seller] // update last seen itemData.lastSeen = Date.now() // clear old cruft card.$$('.rg-node').forEach(node => node.remove()) clearInfoNodes(card) let statusNode = card.$('ul.auctionSearchResult__statusList') let sellerNode = addSellerNode(card, sellerData) let timeLeftNode = card.querySelector(".itemCard__infoList").firstElementChild timeLeftNode.querySelector(".g-title").innerText = "Time Left" timeLeftNode.querySelector(".g-text").classList.remove("g-text--attention") addCountdown(itemData.endTime, timeLeftNode.querySelector(".g-text")) addWatchersNode(card, itemData.watchNum); // link images if (itemData.images.length > 0) { let thumbnailNode = card.querySelector(".g-thumbnail__outer a"); if (!(thumbnailNode.classList.contains("js-smartPhoto"))) { thumbnailNode.href = itemData.images[0]; thumbnailNode.dataset.group = item; addSmartPhotoItem(thumbnailNode); let imageDiv = document.createElement("div"); thumbnailNode.parentNode.append(imageDiv); imageDiv.style.display = "none"; itemData.images.slice(1).forEach( image => { let imgNode = document.createElement("a"); imgNode.href = image; imgNode.dataset.group = item; imageDiv.append(imgNode); addSmartPhotoItem(imgNode); }); } } const hideCard = (() => { card.style.display = 'none'; card.style.removeProperty('opacity'); card.style.removeProperty('background-color'); }); const showHiddenCard = (() => { card.style.opacity = '0.9'; card.style['background-color'] = '#ffbfbf'; card.style.removeProperty('display'); }); const showCard = (() => { card.style.removeProperty('opacity'); card.style.removeProperty('background-color'); card.style.removeProperty('order'); card.style.removeProperty('display'); }); if (sellerData.hide) { if (hideHidden) { hideCard(); } else { showHiddenCard(); sellerNode.parentNode.append(makeSellerStatusNode(seller,'Show seller')); } } else if (itemData.hide) { if (hideHidden) { hideCard(); } else { showHiddenCard(); sellerNode.parentNode.append(makeSellerStatusNode(seller,'Hide seller')); statusNode.append(makeItemStatusNode(item,card,'Show')); } } else { showCard(); sellerNode.parentNode.append(makeSellerStatusNode(seller,'Hide seller')); statusNode.append(makeItemStatusNode(item,card,'Hide')); // refresh if auction ended if (timeUntil(itemData.endTime) === 'Ended' && !readItems.has(item)) { addCardToQueue(card, url, item); } } } // process changes to all results cards in a given element function processCardsIn(element) { element.querySelectorAll("ul.auctionSearchResult li.itemCard:not(.rg-loading)").forEach(processCard); serialiseData(); } // move all results cards in a given element to the given element // as soon as one visible card is moved, hide loadingElement // add cards to observer for lazy loading function moveCards(elementFrom, elementTo, loadingElement, observer) { var movedVisibleCard = (loadingElement === undefined) ? true : false; elementFrom.querySelectorAll("ul.auctionSearchResult li.itemCard").forEach(card => { // move the card elementTo.append(card); // if we moved a visible card, hide the loading element if (!movedVisibleCard && card.style.display != "none") { movedVisibleCard = true; loadingElement.style.display = "none"; } // add to lazy loading observer let imgNode = card.querySelector('img.lazyLoadV2'); if (imgNode) observer.observe(imgNode); // initialise favourite star let watchButton = card.querySelector('div.watchButton'); let id = isDesktop ? watchButton.dataset.auctionId : watchButton.parentNode.parentNode.dataset.id; if (buyeeWatchlist.includes(id)) { watchButton.classList.add("is-active"); watchButton.firstElementChild.classList.remove("g-feather-star"); watchButton.firstElementChild.classList.add("g-feather-star-active"); } else { watchButton.classList.remove("is-active"); watchButton.firstElementChild.classList.remove("g-feather-star-active"); watchButton.firstElementChild.classList.add("g-feather-star"); } }); } // find the URL for the next page of search results after the given one function nextPageURL(url) { const newURL = new URL(url); var currentPage = newURL.searchParams.get('page') ?? '1'; newURL.searchParams.delete('page'); newURL.searchParams.append('page', parseInt(currentPage) + 1); return newURL; } // check that the given HTML document is not the last page of results function notLastPage(doc) { if (isDesktop) { let button = doc.querySelector("div.page_navi a:nth-last-child(2)"); return (button && button.innerText === ">"); } else { let button = doc.querySelector("li.page--arrow a span.last"); return (button != null); } } // the main function function buyeeSellerFilter () { // initial load of data unSerialiseData(); // reload data when tab regains focus document.addEventListener("visibilitychange", () => { if (!document.hidden) { // don't change hideHidden, so save its value first let hideHiddenSaved = hideHidden; unSerialiseData(); hideHidden = hideHiddenSaved; processCardsIn(document); } }); if (!isDesktop) { // disable the google translate popup (annoying with show/hide buttons) var style = ` #goog-gt-tt, .goog-te-balloon-frame{display: none !important;} .goog-text-highlight { background: none !important; box-shadow: none !important;} `; var styleSheet = document.createElement("style"); styleSheet.innerText = style; document.head.append(styleSheet); } let container = document.querySelector('.g-main:not(.g-modal)'); let resultsNode = container.children[0]; // sometimes the results are broken into two lists of ten; if so, merge them. if (container.children.length > 1) { container.children[1].querySelectorAll("ul.auctionSearchResult li.itemCard").forEach(card => { resultsNode.append(card); }); container.children[1].style.display = "none"; } // make link to show or hide hidden results let optionsLink = document.createElement("button"); optionsLink.type = "button"; optionsLink.id = "rg-show-hide-link"; optionsLink.innerText = hideHidden ? "Show hidden" : "Hide hidden"; optionsLink.onclick = (function() { hideHidden = !hideHidden; serialiseData(); optionsLink.innerText = hideHidden ? "Show hidden" : "Hide hidden"; processCardsIn(document); }); optionsLink.style.display = 'inline-block'; optionsLink.style.width = '110px'; // put link in the search options bar let optionsNode = document.createElement("span"); optionsNode.classList.add('result-num'); if (isDesktop) optionsNode.style.left = '20%'; optionsNode.append(optionsLink); if (isDesktop) { document.querySelector(".result-num").parentNode.append(optionsNode); } else { optionsNode.style.display = 'inline'; document.querySelector(".result-num").append(optionsNode); } // refresh watchlist // perform initial processing of cards // clean up old items fetch("https://buyee.jp/api/v1/watch_list/find", { credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } }) .then(response => response.json()) .then(data => (buyeeWatchlist = data.data.list)) .then(() => processCardsIn(document)) .then(cleanItems) // handle favourite stars document.addEventListener("click", e => { const watchButton = e.target.closest("div.watchButton") if (!watchButton) return e.preventDefault() e.stopImmediatePropagation() const id = isDesktop ? watchButton.dataset.auctionId : watchButton.parentNode.parentNode.dataset.id const card = watchButton.closest("li.itemCard") const url = card.querySelector('.itemCard__itemName a').href const [, item] = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/) || [] if (watchButton.classList.contains('is-active')) { fetch("https://buyee.jp/api/v1/watch_list/remove", { "credentials": "include", "headers": { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, "method": "POST", "body": `auctionId=${id}`}) .then(() => { buyeeWatchlist.splice(buyeeWatchlist.indexOf(id), 1) watchButton.classList.remove('is-active') watchButton.firstElementChild.classList.remove('g-feather-star-active') watchButton.firstElementChild.classList.add('g-feather-star') }); } else { fetch("https://buyee.jp/api/v1/watch_list/add", { "credentials": "include", "headers": { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, "method": "POST", "body": `auctionId=${id}&buttonType=search&isSignupRedirect=false`}) .then(() => { buyeeWatchlist.push(id) watchButton.classList.add('is-active') watchButton.firstElementChild.classList.remove('g-feather-star') watchButton.firstElementChild.classList.add('g-feather-star-active') }) addCardToQueue(card,url,item) } }) // image lazy loader const imageObserver = new IntersectionObserver((entries, observer) => { for (const entry of entries) { if (!entry.isIntersecting) return const target = entry.target target.src = target.dataset.src delete target.dataset.src target.style.background = '' observer.unobserve(target) } }) // load subsequent pages of results if navi bar on screen let currentURL = document.location let currentPage = document let naviOnScreen = false const loadingNode = makeLoadingNode() function loadPageLoop() { setTimeout(() => { if (naviOnScreen && notLastPage(currentPage)) { currentURL = nextPageURL(currentURL) // display loading node resultsNode.append(loadingNode) loadingNode.$('.g-title').innerText = 'Loading page ' + currentURL.searchParams.get('page') loadingNode.style.removeProperty('display') // get next page of results fetchURL(currentURL) .then(page => { currentPage = page processCardsIn(currentPage) moveCards(currentPage,resultsNode, loadingNode, imageObserver) loadPageLoop() }) } else if (naviOnScreen) loadingNode.style.display = 'none' }, 100) } // 540 px bottom margin so that next page loads a bit before navi bar appears on screen const loadObserver = new IntersectionObserver(entries => { entries.map(e => (naviOnScreen = e.isIntersecting)) if (naviOnScreen) loadPageLoop() }, { rootMargin: "0px 0px 540px 0px" }) loadObserver.observe($(isDesktop ? 'div.page_navi' : 'ul.pagination')) } // stuff to handle loading stage of page if ($('.g-main:not(.g-modal)')) buyeeSellerFilter() else { const startupObserver = new MutationObserver(() => { if ($('.g-main:not(.g-modal)')) { startupObserver.disconnect() buyeeSellerFilter() } }) startupObserver.observe(document.body, { childList: true, subtree: true }); }