Auto Bazaar Pricing

Individually update your bazaar prices to undercut the lowest by $1 using data from weav3r.dev

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Auto Bazaar Pricing
// @namespace    https://www.torn.com/
// @version      1.4
// @description  Individually update your bazaar prices to undercut the lowest by $1 using data from weav3r.dev
// @author       swervelord [3637232]
// @match        https://www.torn.com/bazaar.php*
// @grant        GM_xmlhttpRequest
// @connect      weav3r.dev
// @connect      api.torn.com
// ==/UserScript==

(function () {
    'use strict';

    const SELECTORS = {
        linksBar: '[class*="linksContainer___"]',
        item: '[data-testid^="item-"]',
        row: '[data-testid="sortable-item"], [class*="row___"]',
        desc: '[class*="desc___"]',
        moneyInput: 'input.input-money:not([type=hidden])'
    };

    const waitFor = (sel, cb) => {
        const el = document.querySelector(sel);
        if (el) return cb(el);
        const mo = new MutationObserver(() => {
            if (!isManagePage()) {
                mo.disconnect();
                return;
            }

            const f = document.querySelector(sel);
            if (f) {
                mo.disconnect();
                cb(f);
            }
        });
        routeObservers.add(mo);
        mo.observe(document.body, { childList: true, subtree: true });
    };

    const getJSON = (url) => new Promise((res, rej) => {
        GM_xmlhttpRequest({
            method: 'GET',
            url,
            onload: r => {
                if (r.status !== 200) {
                    rej(new Error(`HTTP ${r.status}`));
                    return;
                }
                try {
                    res(JSON.parse(r.responseText));
                } catch (e) {
                    rej(e);
                }
            },
            onerror: () => rej(new Error('Network error'))
        });
    });

    const setNativeInputValue = (input, value) => {
        const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
        if (!setter) {
            input.value = String(value);
        } else {
            setter.call(input, String(value));
        }
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.dispatchEvent(new Event('change', { bubbles: true }));
    };

    const addStyles = () => {
        if (document.querySelector('#undercut-style')) return;
        const s = document.createElement('style');
        s.id = 'undercut-style';
        s.textContent = `
            .undercut-label {
                display:inline-flex;
                align-items:center;
                cursor:pointer;
                margin-left:8px;
                position:relative;
                z-index:10;
                vertical-align:middle;
            }
            [data-testid="sortable-item"] .undercut-label,
            [class*="row___"] .undercut-label {
                margin-left:6px;
            }
            .undercut-checkbox {
                appearance:none;
                background:#1f1f1f;
                border:1px solid #444;
                width:16px;
                height:16px;
                border-radius:3px;
                position:relative;
                cursor:pointer;
                flex:0 0 auto;
            }
            .undercut-checkbox:checked::before {
                content:'';
                position:absolute;
                top:2px;
                left:5px;
                width:4px;
                height:8px;
                border:solid #666;
                border-width:0 2px 2px 0;
                transform:rotate(45deg);
            }
            .undercut-checkbox:disabled {
                cursor:wait;
                opacity:.55;
            }
        `;
        document.head.appendChild(s);
    };

    const idCache = new Map();
    const routeObservers = new Set();
    let started = false;

    const isManagePage = () => location.hash.startsWith('#/manage');

    const ensureItemCache = async (key) => {
        if (idCache.size) return;
        const data = await getJSON(`https://api.torn.com/torn/?selections=items&key=${encodeURIComponent(key)}`);
        if (!data?.items) throw new Error('items response missing');
        Object.entries(data.items).forEach(([id, obj]) => {
            if (obj?.name) idCache.set(obj.name.toLowerCase(), Number(id));
        });
    };

    const fetchWeav3r = (id) => getJSON(`https://weav3r.dev/api/marketplace/${id}`);

    const getItemName = (itemEl) => {
        return itemEl.getAttribute('aria-label') ||
            itemEl.querySelector('img[alt]')?.alt ||
            itemEl.getAttribute('data-testid')?.replace(/^item-/, '') ||
            '';
    };

    const parseMoney = (value) => {
        const n = Number(String(value || '').replace(/[^\d.-]/g, ''));
        return Number.isFinite(n) ? n : 0;
    };

    const findPriceBox = (itemEl) => {
        const row = itemEl.closest(SELECTORS.row) || itemEl;
        const candidates = [
            ...itemEl.querySelectorAll(SELECTORS.moneyInput),
            ...row.querySelectorAll(SELECTORS.moneyInput)
        ].filter((input, index, arr) => arr.indexOf(input) === index);

        if (!candidates.length) return null;

        const visible = candidates.filter(input => {
            const r = input.getBoundingClientRect();
            return r.width > 0 && r.height > 0;
        });
        const pool = visible.length ? visible : candidates;

        const priceLike = pool.filter(input => {
            const value = parseMoney(input.value);
            const context = (input.closest('label, div, li, tr')?.textContent || '').toLowerCase();
            return value >= 1000 ||
                context.includes('price') ||
                context.includes('cost') ||
                input.value.includes(',');
        });

        return priceLike[0] || (pool.length === 1 ? pool[0] : null);
    };

    const openRowIfNeeded = async (itemEl) => {
        if (findPriceBox(itemEl)) return;

        const row = itemEl.closest(SELECTORS.row);
        const opener = row?.querySelector('[data-testid="view-icon"], [class*="viewIcon___"], [class*="arrow"], svg');
        if (!opener) return;

        opener.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
        await new Promise(resolve => setTimeout(resolve, 250));
    };

    const undercutItem = async (itemEl, key) => {
        const name = getItemName(itemEl);
        try {
            if (!name) throw new Error('item name missing');
            await ensureItemCache(key);
            const id = idCache.get(name.toLowerCase());
            if (!id) throw new Error('ID not found');
            const { listings = [] } = await fetchWeav3r(id);
            if (!listings.length) throw new Error('no listings');
            const lowest = Number(listings[0].price);
            if (!Number.isFinite(lowest)) throw new Error('listing price missing');
            const newP = Math.max(1, lowest - 1);
            await openRowIfNeeded(itemEl);
            const box = findPriceBox(itemEl);
            if (!box) throw new Error('price box missing or ambiguous; expand the row first');
            setNativeInputValue(box, newP);
            console.info(`[Bazaar] ${name}: set price to ${newP}`);
        } catch (e) {
            console.error(`[Bazaar] ${name || 'unknown item'}: ${e?.message || e}`);
        }
    };

    const buildSettingsBtn = () => {
        if (!isManagePage()) return;

        const bar = document.querySelector(SELECTORS.linksBar);
        if (!bar || document.querySelector('#undercut-settings-btn')) return;
        const existingLink = bar.querySelector('a');
        const a = document.createElement('a');
        a.id = 'undercut-settings-btn';
        a.href = '#';
        a.className = existingLink?.className || '';
        a.innerHTML = `<span class="linkTitle____NPyM">Settings</span>`;
        a.onclick = e => {
            e.preventDefault();
            e.stopPropagation();
            const k = prompt('Enter your Torn API key (requires "items" access):', localStorage.getItem('torn_api_key') || '');
            if (k) {
                localStorage.setItem('torn_api_key', k.trim());
                alert('API key saved');
            }
        };
        bar.prepend(a);
    };

    const appendCheckbox = (item) => {
        if (!isManagePage() || item.querySelector('.undercut-checkbox')) return;

        const label = document.createElement('label');
        label.className = 'undercut-label';
        label.title = 'Undercut lowest Weav3r listing by $1';

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.className = 'undercut-checkbox';

        ['pointerdown', 'pointerup', 'click', 'mousedown', 'mouseup']
            .forEach(ev => label.addEventListener(ev, e => {
                e.stopPropagation();
                e.stopImmediatePropagation();
            }, true));

        checkbox.onchange = async () => {
            if (!checkbox.checked) return;
            const key = localStorage.getItem('torn_api_key');
            if (!key) {
                checkbox.checked = false;
                alert('Set your Torn API key first using the settings tab.');
                return;
            }
            checkbox.disabled = true;
            try {
                await undercutItem(item, key);
            } finally {
                checkbox.disabled = false;
            }
        };

        label.appendChild(checkbox);
        const titleTarget = item.querySelector(`${SELECTORS.desc} span`) ||
            item.querySelector(SELECTORS.desc) ||
            [...item.querySelectorAll('span, div')].find(el => {
                const t = el.textContent?.trim() || '';
                return t && t.includes(getItemName(item));
            }) ||
            item;
        titleTarget.appendChild(label);
    };

    const addCheckboxes = () => {
        if (!isManagePage()) return;
        document.querySelectorAll(SELECTORS.item).forEach(appendCheckbox);
    };

    const promptForApiKeyIfMissing = () => {
        const existing = localStorage.getItem('torn_api_key');
        if (!existing) {
            const key = prompt('This script requires your Torn API key with "items" access.\nPlease enter it now:');
            if (key) localStorage.setItem('torn_api_key', key.trim());
        }
    };

    const stopScriptUi = () => {
        document.querySelector('#undercut-style')?.remove();
        document.querySelector('#undercut-settings-btn')?.remove();
        document.querySelectorAll('.undercut-label').forEach(el => el.remove());

        routeObservers.forEach(observer => observer.disconnect());
        routeObservers.clear();

        started = false;
    };

    const init = () => {
        if (started || !isManagePage()) return;
        started = true;

        addStyles();
        promptForApiKeyIfMissing();
        waitFor(SELECTORS.linksBar, buildSettingsBtn);
        waitFor(SELECTORS.item, () => {
            addCheckboxes();

            const mo = new MutationObserver(() => {
                if (!isManagePage()) {
                    stopScriptUi();
                    return;
                }

                buildSettingsBtn();
                addCheckboxes();
            });
            routeObservers.add(mo);
            mo.observe(document.body, { childList: true, subtree: true });
        });
    };

    const route = () => {
        if (isManagePage()) {
            init();
        } else {
            stopScriptUi();
        }
    };

    window.addEventListener('hashchange', route);
    route();
})();