// ==UserScript==
// @name Buyee Seller Filter
// @license MIT
// @version 1.55
// @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==
// 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 => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then(html => new DOMParser().parseFromString(html, "text/html"));
}
// create a node which shows/hides the given seller when clicked
function makeSellerStatusNode(seller,status) {
let node = document.createElement("button");
node.type = "button";
if (status == "Show seller") {
node.onclick = (() => {
sellersData[seller].hide = false;
serialiseData();
processCardsIn(document);
});
} else {
node.onclick = (() => {
sellersData[seller].hide = true;
serialiseData();
processCardsIn(document);
});
}
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("button");
node.type = "button";
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;
}
// clear previous info nodes on a card
function clearInfoNodes(card)
{
let infolist = card.querySelector(".itemCard__infoList");
while (infolist.childElementCount > 2) {
infolist.lastElementChild.remove();
}
let watchNumNode = card.querySelector(".watchList__watchNum");
if (watchNumNode) watchNumNode.remove();
}
// add seller node to item card
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;
}
// add number of watches to item card
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);
}
// 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;
}
// 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() {
var now = Date.now();
Object.keys(itemsData).forEach( (url) => {
if (now - itemsData[url].lastSeen > 604800000) {
delete itemsData[url];
}
});
serialiseData();
}
// countdown timers
const countdowns = [];
// Add a countdown
function addCountdown(dateStr, element) {
countdowns.push({ dateStr, element });
element.innerText = timeUntil(dateStr);
}
// update all countdowns
function updateCountdowns() {
const now = new Date();
for (const cd of countdowns) {
cd.element.innerText = timeUntil(cd.dateStr);
}
setTimeout(updateCountdowns, 1000);
}
updateCountdowns();
// queue of cards to process
const MAX_CONCURRENT = 3;
const DELAY = 250;
const processingQueue = [];
var activeCount = 0;
const inFlightItems = new Set();
// 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 } = processingQueue.shift();
activeCount++;
fetchURL(url)
.then(doc => {
fillCardData(card, doc, item);
processCard(card);
})
.catch(err => {
console.error('fetchURL failed for', url, err);
})
.finally(() => {
inFlightItems.delete(item);
sleep(DELAY).then(() => {
activeCount--;
loadCard();
});
});
if (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)
{
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;
if (result) {
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)) {
let node = doc.querySelector(".watchButton__watchNum");
if (node) itemData.watchNum = node.innerText;
}
// get image links
itemData.images = Array.from(doc.querySelectorAll(".js-smartPhoto")).map(node => node.href);
itemsData[item] = itemData;
serialiseData();
}
// handle smartphoto items gracefully
var smartPhotoOpen = false;
var pendingSmartPhotoItems = [];
var smartPhotoNode = document.querySelector(".smartphoto");
// patch SmartPhoto's internal click to track "opening in progress"
document.addEventListener('click', e => {
const node = e.target.closest('a.js-smartPhoto');
if (!node) return;
// Mark lightbox as opening
smartPhotoOpen = true;
});
smartPhotoNode.addEventListener('close', () => {
smartPhotoOpen = false;
pendingSmartPhotoItems.forEach(item => smartPhoto.addNewItem(item));
pendingSmartPhotoItems = [];
});
function addSmartPhotoItem(node) {
node.classList.add("js-smartPhoto");
if (smartPhotoOpen) {
pendingSmartPhotoItems.push(node);
} else {
smartPhoto.addNewItem(node);
}
}
// 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 = "#";
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 = "Time Left";
timeLeftNode.querySelector(".g-text").classList.remove("g-text--attention");
addCountdown(itemData.endTime, timeLeftNode.querySelector(".g-text"));
// timeLeftNode.querySelector(".g-text").innerText = itemData.endTime;
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);
// thumbnailNode.href = "#";
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;
imageDiv.appendChild(imgNode);
addSmartPhotoItem(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 seller'));
}
} 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 seller'));
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 seller'));
statusNode.appendChild(makeItemStatusNode(item,card,'Hide'));
}
}
// process changes to all results cards in a given element
function processCardsIn(element) {
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
let imgNode = card.querySelector('img.lazyLoadV2');
if (imgNode) observer.observe(imgNode);
// initialise favourite star
let watchButton = card.querySelector('div.watchButton');
let id;
if (isDesktop) {
id = watchButton.dataset.auctionId;
} else {
id = 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) {
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;
});
// 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.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("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.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);
// process favourite stars
document.addEventListener("click", e => {
const watchButton = e.target.closest("div.watchButton");
if (!watchButton) return;
e.preventDefault();
e.stopImmediatePropagation();
let id;
if (isDesktop) {
id = watchButton.dataset.auctionId;
} else {
id = watchButton.parentNode.parentNode.dataset.id;
}
const card = watchButton.closest("li.itemCard");
const url = card.querySelector('.itemCard__itemName a').href;
let item = url.match(/\/item\/jdirectitems\/auction\/(.*)(\?.*)?/);
if (item) { item = item[1]; }
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(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 100ms 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);
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" });
loadObserver.observe(document.querySelector(isDesktop ? "div.page_navi" : "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 });
}