Buyee Seller Filter

Add infinite scrolling and options for filtering sellers to the Buyee search results page

// ==UserScript==
// @name         Buyee Seller Filter
// @license      MIT
// @version      1.0
// @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
// ==/UserScript==

// stuff to handle loading stage of page

var notYetRun = true;

if ((document.readyState === 'complete') && notYetRun) {
    notYetRun = false;
    buyeeSellerFilter();
} else {
    window.addEventListener('load', () => { notYetRun = false; buyeeSellerFilter() } );
}

// 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;

// sellers to hide; each seller to be hidden added as a key with value TRUE
var sellersBlacklist;

// items to hide; each item to be hidden added as a key with value
// a timestamp indicating when we last checked if the auction is active
var itemsBlacklist;

// list of item that have already been seen; items are added with a timestamp indicating
// when last seen
var alreadySeenList;

// should hidden sellers/items actually be hidden?
var hideHidden;

// should new items be highlighted?
var highlightNew;

// 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.highlightNew = JSON.stringify(highlightNew);
   localStorage.sellersBlacklist = JSON.stringify(sellersBlacklist);
   localStorage.itemsBlacklist = JSON.stringify(itemsBlacklist);
   localStorage.alreadySeenList = JSON.stringify(alreadySeenList);
}

// load state from local storage
function unSerialiseData() {
    sellersBlacklist = ("sellersBlacklist" in localStorage) ? JSON.parse(localStorage.sellersBlacklist) : {};
    itemsBlacklist = ("itemsBlacklist" in localStorage) ? JSON.parse(localStorage.itemsBlacklist) : {};
    alreadySeenList = ("alreadySeenList" in localStorage) ? JSON.parse(localStorage.alreadySeenList) : {};
    hideHidden = ("hideHidden" in localStorage) ? JSON.parse(localStorage.hideHidden) : true;
    highlightNew = ("highlightNew" in localStorage) ? JSON.parse(localStorage.highlightNew) : true;
}

// fetch a URL and return a document containing it
function fetchURL(url) {
    return fetch(url)
    .then((response) => {
        return response.text()
    })
    .then((html) => {
        // Parse the text
        var parser = new DOMParser();
        var doc = parser.parseFromString(html, "text/html");
        return doc;
    });
}

// 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/demotes the given seller when clicked
function makeSellerStatusNode(seller,status) {
    let node = document.createElement("a");
    node.href = "javascript:void(0);";
    if (status == "Show") {
        node.onclick = (() => {
            delete sellersBlacklist[seller];
            serialiseData();
            processCardsIn(document);
        });
    } else {
        node.onclick = (() => {
            sellersBlacklist[seller] = 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("a");
    node.href = "javascript:void(0);";
    if (status == "Show") {
        node.onclick = (() => {
            delete itemsBlacklist[url];
            serialiseData();
            processCard(card);
        });
    } else {
        node.onclick = (() => {
            // use current timestamp so we can delete old listings later
            itemsBlacklist[url] = Date.now();
            serialiseData();
            processCard(card);
        });
    }
    node.innerText = status;
    node.classList.add('auctionSearchResult__statusItem');
    node.classList.add('rg-node');
    return node;
}

// 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 blacklist and already-seen list if the corresponding auction more than one week old.
function cleanItems() {
    var now = Date.now();
    Object.keys(itemsBlacklist).foreach( (url) => {
        if (now - itemsBlacklist[url] > 604800000) {
            delete itemsBlacklist[url];
        }
    });
    Object.keys(alreadySeenList).foreach( (url) => {
        if (now - alreadySeenList[url] > 604800000) {
            delete alreadySeenList[url];
        }
    });
    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) {

    // set up seller field; delete any existing hide/demote/show links
    let sellerNode;
    if (isDesktop) {
        sellerNode = card.querySelector("span.auctionSearchResult__seller");
    } else {
        sellerNode = card.querySelector("ul.itemCard__infoList li:nth-child(2) span.g-text")
    }
    let seller = sellerNode.querySelector("a").innerText.trim();

    card.querySelectorAll(".rg-node").forEach(node => { node.parentNode.removeChild(node); });

    let statusNode = card.querySelector("ul.auctionSearchResult__statusList");
    let url = card.querySelector('.itemCard__itemName a').href.match(/https:\/\/buyee.jp\/item\/jdirectitems\/auction\/(.*)\?.*/);
    if (url) { url = url[1] }

    // if item is not already seen, add it to the already seen list
    if (!(url in alreadySeenList)) {
        alreadySeenList[url] = Date.now();
    }


    if (seller in sellersBlacklist) {
        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
            if (!isDesktop) { sellerNode.parentNode.appendChild(makeBullet()); }
            sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Show'));
        }
    } else if (url in itemsBlacklist) {

        // update timestamp to prevent item from being cleaned up for next seven days
        itemsBlacklist[url] = Date.now();

        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
            if (!isDesktop) { sellerNode.parentNode.appendChild(makeBullet()); }
            sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide'));
            statusNode.appendChild(makeItemStatusNode(url,card,'Show'));
        }
    } else {
        // unhide card
        card.style.removeProperty('opacity');
        card.style.removeProperty('background-color');
        card.style.removeProperty('order');
        card.style.removeProperty('display');



        // if new within the last few hours, colour background green, slowly fading to white
        // "few" here is defined by newlyListedHighlightTime
        var timeSinceFirstSeen = Date.now() - alreadySeenList[url];
        if (highlightNew && (timeSinceFirstSeen < (newlyListedHighlightTime * 3600000))) {
            var redBlueIntensity = 191 + Math.floor((64 * timeSinceFirstSeen) / (3600000 * newlyListedHighlightTime));
            card.style['background-color'] = rgbToHex(redBlueIntensity,255,redBlueIntensity);
        }

        // add hide links
        if (!isDesktop) { sellerNode.parentNode.appendChild(makeBullet()); }
        sellerNode.parentNode.appendChild(makeSellerStatusNode(seller,'Hide'));
        statusNode.appendChild(makeItemStatusNode(url,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);
    });

    // some itemsBlacklist timestamps may have been updated, so serialise
    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 => {
        // add to lazy loading observer
        observer.observe(card.querySelector('img.lazyLoadV2'));
        // make the seller link work properly on desktop (a hack)
        if (isDesktop) {
            var newURL = new URL(document.location);
            var sellerLink = card.querySelector("span.auctionSearchResult__seller a");
            var sellerText = sellerLink.getAttribute('data-bind').match(/click: search\.bind\(\$data, \{ seller: \'(.*)\' \}\)/)[1];
            newURL.pathname = newURL.pathname + '/seller/' + encodeURIComponent(sellerText.replaceAll("/", "%2F"));
            sellerLink.href = newURL.toString();
        }
        // 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";
        }
    });
}

// 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();

    // 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("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);
    });
    optionsLink.style.display = 'inline-block';
    optionsLink.style.width = '110px';

    // make link to highlight new results
    let highlightLink = document.createElement("a");
    highlightLink.href = "javascript:void(0);";
    highlightLink.id = "rg-highlight-new-link";
    highlightLink.innerText = highlightNew ? "Don't highlight new" : "Highlight new";
    highlightLink.onclick = (function() {
        highlightNew = !highlightNew;
        serialiseData();
        highlightLink.innerText = highlightNew ? "Don't highlight new" : "Highlight new";
        processCardsIn(document);
    });
    highlightLink.style.display = 'inline-block';
    highlightLink.style.width = '150px';


    // 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);
    optionsNode.appendChild(highlightLink);

    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);

    // 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);
                    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();
}