Snapcaster Multi Search Recommendation Improver

When doing multi searches on snapcaster.ca, will actually show which stores have the cards you want!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Snapcaster Multi Search Recommendation Improver
// @version      0.1
// @description  When doing multi searches on snapcaster.ca, will actually show which stores have the cards you want!
// @author       Derya
// @match        *://*.snapcaster.ca/*
// @match        *://api.snapcaster.ca/*
// @grant        none
// @namespace    https://github.com/derya
// @license      no license
// ==/UserScript==

(function() {
    'use strict';

    const { keys, values, entries } = Object;

    // config
    const NUM_STORES_SHOW = 6;

    // checks if URL is a multisearch call
    function shouldRunMain(url) {
        return typeof url === 'string' && url.includes('api.snapcaster.ca/api/v1/catalog/multisearch');
    }

    // generate HTML for the results
    function generateResultsHtml(bestStores) {
        const vendorCards = bestStores.map(vendor => {
            const cardList = entries(vendor.pricesByCard)
                .map(([cardName, price]) => `<div class="text-sm text-muted-foreground">${cardName} ($${price})</div>`)
                .join('');

            return `
                <div class="border-1 col-span-2 rounded-lg border border-border px-4 py-3 text-left sm:col-span-1">
                    <div class="flex items-center justify-between">
                        <div class="flex items-center gap-2">
                            <div class="text-sm font-semibold">${vendor.name}</div>
                        </div>
                    </div>
                    <div class="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
                        <div class="flex items-center gap-1">
                            <span>${vendor.numCards} cards found</span>
                        </div>
                    </div>
                    <div class="mt-1 text-lg font-bold text-foreground">$${vendor.totalPrice.toFixed(2)}</div>
                    <div class="mt-2 space-y-1 max-h-20 overflow-y-auto">
                        ${cardList}
                    </div>
                </div>
            `;
        }).join('');

        return `
            <div id="smsri-results" class="rounded-lg border bg-card text-card-foreground shadow-sm col-span-12 flex flex-col text-xs mb-6">
                <div class="flex flex-col space-y-1.5 p-4 text-left">
                    <h3 class="font-semibold tracking-tight text-lg">Top ${NUM_STORES_SHOW} Stores with Most Matches</h3>
                    <div class="text-xs text-muted-foreground">This section is injected, not original content from Snapcaster!!!</div>
                </div>
                <div class="p-4 pt-0 grid grid-cols-2 gap-2 overflow-clip">
                    ${vendorCards}
                </div>
            </div>
        `;
    }

    // inject results into the page
    function injectResults(html) {
        // remove existing results if they exist
        const existingResults = document.getElementById('smsri-results');
        if (existingResults) {
            existingResults.remove();
        }

        // targets the recommended stores section. utter hack hopefully the fine folks
        // at the snapcaster team don't ever change the html structure of this page!
        const targetElement = document.querySelector('div.col-span-12:nth-child(2)');

        if (targetElement) {
            // insert our better recs before the existing recs element
            targetElement.insertAdjacentHTML('beforebegin', html);
        } else {
            console.warn('SMSRI: could not find injection point for html');
        }
    }

    // reads and parses multisearch response data
    function main(responseData) {
        if (!responseData || !responseData.data || !responseData.data.results) {
            console.log('SMSRI: No valid response data found, aborting...');
            return;
        }

        const cardListings = responseData.data.results.flatMap(x => x ? x : []);

        if (cardListings.length === 0) {
            console.log('SMSRI: No card listings found, aborting...');
            return;
        }

        const uniqueCardsFound = [...new Set(cardListings.map(x => x.normalized_name))];
        const vendors = [...new Set(cardListings.map(x => x.vendor))];

        const vendorsWithListings = vendors.map(vendor => {
            const pricesByCard = {};

            for (const cardName of uniqueCardsFound) {
                const vendorListings = cardListings.filter(x =>
                    x.normalized_name === cardName && x.vendor === vendor
                );

                if (vendorListings.length > 0) {
                    const bestListing = vendorListings.reduce((min, current) =>
                        current.price < min.price ? current : min
                    );
                    pricesByCard[cardName] = bestListing.price;
                }
            }

            return {
                name: vendor,
                pricesByCard,
                numCards: keys(pricesByCard).length,
                totalPrice: values(pricesByCard).reduce((sum, price) => sum + price, 0)
            };
        });

        // Sort by number of cards
        const sorted = vendorsWithListings.sort((a, b) =>
            keys(b.pricesByCard).length - keys(a.pricesByCard).length
        );
        const bestStores = sorted.slice(0, NUM_STORES_SHOW);

        // Generate HTML for injection
        const resultsHtml = generateResultsHtml(bestStores);
        setTimeout(() => injectResults(resultsHtml), 1000);

        // Also log all to console for debugging
        console.log('=== MULTISEARCH RESULTS ===');
        sorted.forEach(vendor => {
            let pricesStr = `${vendor.name} has ${vendor.numCards} cards, total price: $${vendor.totalPrice.toFixed(2)}\n`;
            for (const [cardName, price] of entries(vendor.pricesByCard)) {
                pricesStr += "   " + cardName + " ($" + price + ")\n";
            }
            console.log(pricesStr);
        });
        console.log('=====================================');
    }

    // store original fetch, XHR functions
    const originalFetch = window.fetch;
    const originalXHROpen = XMLHttpRequest.prototype.open;
    const originalXHRSend = XMLHttpRequest.prototype.send;


    // intercept fetch calls
    window.fetch = function(...args) {
        const url = args[0];

        if (shouldRunMain(url)) {
            console.log('SMSRI: Intercepting multisearch call (fetch) to Snapcaster API...');

            return originalFetch.apply(this, args).then(response => {
                // operate on cloned response, don't mess with existing functionality of the web app
                response.clone().json().then(data => {
                    main(data);
                }).catch(err => {
                    console.log('SMSRI: Error parsing fetchresponse:', err);
                });

                return response;
            });
        }

        return originalFetch.apply(this, args);
    };

    // intercept XMLHttpRequest calls
    XMLHttpRequest.prototype.open = function(method, url, ...args) {
        this.runIntercept = shouldRunMain(url);
        return originalXHROpen.apply(this, [method, url, ...args]);
    };

    XMLHttpRequest.prototype.send = function(...args) {
        if (this.runIntercept) {
            console.log('SMSRI: Intercepting multisearch call (XMLHttpRequest) to Snapcaster API...');

            const originalOnReadyStateChange = this.onreadystatechange;

            this.onreadystatechange = function() {
                if (this.readyState === 4 && this.status === 200) {
                    try {
                        main(JSON.parse(this.responseText));
                    } catch (err) {
                        console.log('SMSRI: Error parsing XHR response:', err);
                    }
                }

                if (originalOnReadyStateChange) {
                    originalOnReadyStateChange.apply(this, arguments);
                }
            };
        }

        return originalXHRSend.apply(this, args);
    };

    console.log('Snapcaster Multi Search Recommendation Improver (SMSRI) is loaded!');
    console.log('SMSRI: Monitoring for calls to api.snapcaster.ca/api/v1/catalog/multisearch');
})();