Buyee Seller Filter

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

目前為 2025-09-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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