Add infinite scrolling and options for filtering sellers to the Buyee search results page
当前为
// ==UserScript== // @name Buyee Seller Filter // @license MIT // @version 1.51 // @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== // highlight interval for newly listed items; highlighted in green when new, slowly // fading to white over the number of hours specified below const newlyListedHighlightTime = 12; // list containing metadata associated to sellers, // including link to items and blacklist status var sellersData; // list containing metadata associated to items // including seller name and blacklist status var itemsData; // should hidden sellers/items actually be hidden? var hideHidden; // smartPhoto instance var smartPhoto = new SmartPhoto(".js-smartPhoto"); window.smartPhoto = smartPhoto // buyee watchlist var buyeeWatchlist; // are we on the desktop site? var 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; // save state to local storage function serialiseData() { localStorage.hideHidden = JSON.stringify(hideHidden); localStorage.sellersData = JSON.stringify(sellersData); localStorage.itemsData = JSON.stringify(itemsData); } // load state from local storage function unSerialiseData() { sellersData = ("sellersData" in localStorage) ? JSON.parse(localStorage.sellersData) : {}; itemsData = ("itemsData" in localStorage) ? JSON.parse(localStorage.itemsData) : {}; hideHidden = ("hideHidden" in localStorage) ? JSON.parse(localStorage.hideHidden) : true; } // fetch a URL and return a document containing it function fetchURL(url) { return fetch(url) .then(response => response.text()) .then(html => { var parser = new DOMParser(); return parser.parseFromString(html, "text/html"); }); } // make a bullet node function makeBullet() { let node = document.createElement("span"); node.innerText = ' • '; node.classList.add('rg-node'); node.style.width = '14px'; node.style['text-align'] = 'center'; if (!isDesktop) { node.classList.add('g-text'); } return node; } // RGB to hex const rgbToHex = (r, g, b) => '#' + [r, g, b].map(x => { const hex = x.toString(16) return hex.length === 1 ? '0' + hex : hex }).join('') // create a node which shows/hides the given seller when clicked function makeSellerStatusNode(seller,status) { let node = document.createElement("a"); node.href = "javascript:void(0);"; if (status == "Show") { node.onclick = (() => { sellersData[seller].hide = false; serialiseData(); processCardsIn(document, false); }); } else { node.onclick = (() => { sellersData[seller].hide = true; serialiseData(); processCardsIn(document, false); }); } node.innerText = status; node.classList.add('rg-node'); if (!isDesktop) { node.classList.add('g-text'); } return node; } // create a node which shows/hides the given item when clicked function makeItemStatusNode(url,card,status) { let node = document.createElement("a"); node.href = "javascript:void(0);"; if (status == "Show") { node.onclick = (() => { itemsData[url].hide = false; serialiseData(); processCard(card); }); } else { node.onclick = (() => { itemsData[url].hide = true; serialiseData(); processCard(card); }); } node.innerText = status; node.classList.add('auctionSearchResult__statusItem'); node.classList.add('rg-node'); return node; } function clearInfoNodes(card) { let infolist = card.querySelector(".itemCard__infoList"); while (infolist.childElementCount > 3) { infolist.lastElementChild.remove(); } let watchNumNode = card.querySelector(".watchList__watchNum"); if (watchNumNode) watchNumNode.remove(); } function addSellerNode(card, sellerData) { let infolist = card.querySelector(".itemCard__infoList"); let newnode = infolist.firstElementChild.cloneNode(true); newnode.querySelector(".g-title").innerText = "Seller"; newnode.querySelector(".g-text").innerText = ""; let a = document.createElement("a"); a.href = sellerData.url; a.innerText = sellerData.name; newnode.querySelector(".g-text").appendChild(a); infolist.lastElementChild.remove(); infolist.appendChild(newnode); return newnode; } function addWatchersNode(card, watcherNum) { let starNode = card.querySelector(".g-feather"); let newNode = document.createElement("span"); let watchButton = starNode.parentNode; newNode.classList.add("watchList__watchNum"); newNode.innerText = watcherNum; watchButton.appendChild(newNode); watchButton.addEventListener("click", e => { e.preventDefault(); console.log(newNode); if (watchButton.classList.contains('is-active')) { newNode.innerText = parseInt(newNode.innerText)-1; } else { newNode.innerText = parseInt(newNode.innerText)+1; } }); } // make a "loading" node for the infinite scrolling function makeLoadingNode() { let card = document.createElement("li"); card.classList.add('itemCard'); card.classList.add('rg-loading'); let innerDiv = document.createElement("div"); innerDiv.classList.add('imgLoading'); innerDiv.style.height = '150px'; card.appendChild(innerDiv); return card; } // Remove old items from items list if not seen for > 1 week. function cleanItems() { var now = Date.now(); Object.keys(itemsData).forEach( (url) => { if (now - itemsData[url].lastSeen > 604800000) { delete itemsData[url]; } }); serialiseData(); } // queue of cards to process const MAX_CONCURRENT = 3; const DELAY = 250; var processingQueue = []; var activeCount = 0; // load data for next card in processing queue function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function loadCard() { if (processingQueue.length == 0 || activeCount >= MAX_CONCURRENT) return; const { card, url, item, resolve, reject } = processingQueue.shift(); activeCount++; fetchURL(url) .then(doc => { fillCardData(card, doc, item); processCard(card); resolve(doc); }) .catch(err => reject(err)) .then(() => sleep(DELAY)) .then(() => { activeCount--; loadCard(); }); loadCard(); } // add card to processing queue function addCardToQueue(card, url, item) { return new Promise((resolve, reject) => { processingQueue.push({ card, url, item, resolve, reject }); loadCard(); }); } // extract data from card page function fillCardData(card, doc, item) { let itemData = {}; var xpath = '//a[contains(@href,"search/customer")]'; let sellernode = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; // let sellernode = doc.querySelector(".no_border.clearfix dd a"); let seller = sellernode.href.match(/\/item\/search\/customer\/(.*)/); if (seller) seller = seller[1]; itemData.hide = false; itemData.seller = seller; if (!(seller in sellersData)) { sellersData[seller] = {}; sellersData[seller].name = sellernode.innerText.trim(); sellersData[seller].url = sellernode.href; sellersData[seller].hide = false; } // get end time xpath = '//li[.//*[contains(text(),"Closing Time")]]'; var result = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; itemData.endTime = result.querySelector("span:last-child").innerText; // get number of watchers doc.querySelectorAll('script').forEach(script => { const match = script.textContent.match(/buyee\.TOTAL_WATCH_COUNT\s*=\s*(\d+);/); if (match) itemData.watchNum = parseInt(match[1], 10); }); if (!("watchNum" in itemData)) { itemData.watchNum = doc.querySelector(".watchButton__watchNum").innerText; } // get image links itemData.images = Array.from(doc.querySelectorAll(".js-smartPhoto")).map(node => node.href); itemsData[item] = itemData; serialiseData(); } // 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 let url = card.querySelector('.itemCard__itemName a').href; let item = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/); if (item) { item = item[1] } let thumbnailNode = card.querySelector(".g-thumbnail__outer a"); //thumbnailNode.href = "javascript:void(0);"; if (!(item in itemsData)) { addCardToQueue(card, url, item); return; } let itemData = itemsData[item]; let seller = itemData.seller; let sellerData = sellersData[seller]; // update last seen itemData.lastSeen = Date.now(); // clear old cruft card.querySelectorAll(".rg-node").forEach(node => { node.parentNode.removeChild(node); }); clearInfoNodes(card); let statusNode = card.querySelector("ul.auctionSearchResult__statusList"); let sellerNode = addSellerNode(card, sellerData); let timeLeftNode = card.querySelector(".itemCard__infoList").firstElementChild; timeLeftNode.querySelector(".g-title").innerText = "End Time"; timeLeftNode.querySelector(".g-text").innerText = itemData.endTime; timeLeftNode.querySelector(".g-text").classList.remove("g-text--attention"); 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"))) { smartPhoto.hidePhoto(); thumbnailNode.href = itemData.images[0]; thumbnailNode.dataset.group = item; thumbnailNode.classList.add("js-smartPhoto"); smartPhoto.addNewItem(thumbnailNode); thumbnailNode.href = "javascript:void(0);"; let imageDiv = document.createElement("div"); thumbnailNode.parentNode.appendChild(imageDiv); imageDiv.style.display = "none"; itemData.images.slice(1).forEach( image => { let imgNode = document.createElement("a"); imgNode.href = image; imgNode.dataset.group = item; imgNode.classList.add("js-smartPhoto"); imageDiv.appendChild(imgNode); smartPhoto.addNewItem(imgNode); }); } } if (sellerData.hide) { if (hideHidden) { // hide the card card.style.display = 'none'; card.style.removeProperty('opacity'); card.style.removeProperty('background-color'); } else { // show with red background card.style.opacity = '0.9'; card.style['background-color'] = '#ffbfbf'; card.style.removeProperty('display'); // add show link sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Show')); } } else if (itemData.hide) { if (hideHidden) { // hide the card card.style.display = 'none'; card.style.removeProperty('opacity'); card.style.removeProperty('background-color'); } else { // show with red background card.style.opacity = '0.9'; card.style['background-color'] = '#ffbfbf'; card.style.removeProperty('display'); // add show/hide links sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide')); statusNode.appendChild(makeItemStatusNode(item,card,'Show')); } } else { // unhide card card.style.removeProperty('opacity'); card.style.removeProperty('background-color'); card.style.removeProperty('order'); card.style.removeProperty('display'); // add hide links sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide')); statusNode.appendChild(makeItemStatusNode(item,card,'Hide')); } } // process changes to all results cards in a given element function processCardsIn(element, addPhotos) { element.querySelectorAll("ul.auctionSearchResult li.itemCard:not(.rg-loading)").forEach(card => { processCard(card); }); 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.appendChild(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 observer.observe(card.querySelector('img.lazyLoadV2')); // fix favourite star let watchButton = card.querySelector('div.watchButton'); const id = watchButton.dataset.auctionId; 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"); } watchButton.addEventListener("click", e => { e.preventDefault(); e.stopImmediatePropagation(); 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}`}); 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`}); buyeeWatchlist.push(id); watchButton.classList.add("is-active"); watchButton.firstElementChild.classList.remove("g-feather-star"); watchButton.firstElementChild.classList.add("g-feather-star-active"); } }); }); } // find the URL for the next page of search results after the given one function nextPageURL(url) { var 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:nth-last-child(2)"); return (button != null); } } // the main function function buyeeSellerFilter () { // initial load of data unSerialiseData(); // refresh watchlist 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; 1}); // 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, false); } }); 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.appendChild(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.appendChild(card); }); container.children[1].style.display = "none"; } // make link to show or hide hidden results let optionsLink = document.createElement("a"); optionsLink.href = "javascript:void(0);"; 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, false); }); 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.appendChild(optionsLink); if (isDesktop) { document.querySelector(".result-num").parentNode.appendChild(optionsNode); } else { optionsNode.style.display = 'inline'; document.querySelector(".result-num").appendChild(optionsNode); } // perform initial processing of cards processCardsIn(document, true); // image lazy loader const imageObserver = new IntersectionObserver(loadImage); function loadImage(entries, observer) { entries.forEach(entry => { if (!entry.isIntersecting) { return; } const target = entry.target; target.src = target.getAttribute('data-src'); target.removeAttribute('data-src'); target.style.background = ''; observer.unobserve(target); }); } // load subsequent pages of results if navi bar on screen var currentURL = document.location; var currentPage = document; var naviOnScreen = false; const loadingNode = makeLoadingNode(); function loadPageLoop() { // code to add further pages of results; loop over pages, // with a minimum 50ms delay between to avoid rampaging // robot alerts (unlikely as the loads are pretty slow) // stop if the navi bar is no longer on screen (so don't load infinitely) setTimeout(() => { if (naviOnScreen && notLastPage(currentPage)) { // display loading node resultsNode.appendChild(loadingNode); loadingNode.style.removeProperty('display'); // get next page of results currentURL = nextPageURL(currentURL); fetchURL(currentURL) .then((page) => { currentPage = page; processCardsIn(currentPage, true); console.log("Moving on up"); moveCards(currentPage,resultsNode, loadingNode, imageObserver); loadPageLoop(); }); } else if (naviOnScreen) { // finished loading pages, hide loading node loadingNode.style.display = 'none'; } }, 100); } // function to handle navi bar appearing/disappearing from screen function handleIntersection(entries) { entries.map((entry) => { naviOnScreen = entry.isIntersecting }); if (naviOnScreen) { loadPageLoop(); } } // 540 px bottom margin so that next page loads a bit before navi bar appears on screen const loadObserver = new IntersectionObserver(handleIntersection, { rootMargin: "0px 0px 540px 0px" }); if (isDesktop) { loadObserver.observe(document.querySelector("div.page_navi")); } else { loadObserver.observe(document.querySelector("ul.pagination")); } // clean up old blacklisted items cleanItems(); } // stuff to handle loading stage of page if (document.querySelector('.g-main:not(.g-modal)')) { buyeeSellerFilter(); } else { const startupObserver = new MutationObserver(() => { if (document.querySelector('.g-main:not(.g-modal)')) { startupObserver.disconnect(); buyeeSellerFilter(); } }); startupObserver.observe(document.body, { childList: true, subtree: true }); }