// ==UserScript==
// @name Bazaar Mate
// @namespace onebazaar.zero.nao
// @version 4.0.0
// @description Your own bazaar helper
// @author GFOUR
// @match https://www.torn.com/bazaar.php*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
const NS = 'OBM4';
const WATCH_URL_PART = '/bazaar.php?sid=bazaarData&step=getBazaarItems';
const DEFAULTS = {
sort: 'price-asc',
maxPrice: Infinity,
feePercent: 5,
showNegatives: false,
notifications: false,
search: '',
minimized: false,
showLog: false,
pos: {
top: 64,
left: null,
right: 16
},
favorites: [],
favoriteCaps: {},
favoriteNames: {},
};
const SEL = {
container: '#obm',
header: '#obmHdr',
list: '#obmList',
log: '#obmLog',
search: '#obmSearch',
maxPrice: '#obmMax',
cashBtn: '#obmCashBtn',
fab: '#obmFab',
counters: {
items: '#obmItems',
dollars: '#obmDollars',
cash: '#obmCash',
favs: '#obmFavs',
},
settingsBtn: '#obmSettingsBtn',
minimizeBtn: '#obmMinimize',
closeBtn: '#obmClose',
settings: {
overlay: '#obmSettingsPane',
sort: '#obmSort',
fee: '#obmFee',
showNeg: '#obmShowNeg',
notif: '#obmNotif',
logToggle: '#obmLogToggle',
close: '#obmSettingsBack',
favCaps: '#obmFavCaps',
},
};
const ICONS = {
drag: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="currentColor" aria-hidden="true">
<circle cx="7" cy="8" r="1.2"/><circle cx="12" cy="8" r="1.2"/><circle cx="17" cy="8" r="1.2"/>
<circle cx="7" cy="15" r="1.2"/><circle cx="12" cy="15" r="1.2"/><circle cx="17" cy="15" r="1.2"/>
</svg>`,
scan: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="7"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2"/>
</svg>`,
settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16"/><circle cx="8" cy="6" r="2"/><circle cx="16" cy="12" r="2"/><circle cx="10" cy="18" r="2"/>
</svg>`,
minimize: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"/>
</svg>`,
restore: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5l14 14M19 5l-14 14"/>
</svg>`,
cash: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3.5" y="7" width="17" height="10" rx="2"/><circle cx="12" cy="12" r="3"/>
</svg>`,
back: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6"/>
</svg>`,
star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>`,
starFilled: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="currentColor" aria-hidden="true">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
</svg>`,
};
let settings = loadAll();
let items = new Map();
let bought = new Set();
let lastClick = 0;
let latestSeller = null;
let updateScheduled = false;
let currentCash = 0;
let hookedFetch = false;
let hookedXHR = false;
let cashObs = null;
let cashIntervalId = 0;
let scanHintDone = false;
function stopScanHint() {
scanHintDone = true;
$('#obmScanBtn').removeClass('blink');
}
// Stop scan as soon as we see any item above the max price (for current seller).
function shouldStopScanByMax() {
if (!isFinite(settings.maxPrice) || !latestSeller) return false;
const cutoff = settings.maxPrice;
for (const it of items.values()) {
if (it.sellerUserId === latestSeller && it.price > cutoff) {
return true;
}
}
return false;
}
function getSortIndicatorText() {
// Prefer the classes you referenced; fall back to a generic search
const el =
document.querySelector("div.loadingSame___XWj3U .loaderText___d8TAE") ||
document.querySelector("[class*='loaderText']");
return (el && el.textContent ? el.textContent.trim() : '');
}
function getCostSortState() {
const txt = (getSortIndicatorText() || '').toLowerCase();
if (!txt) return 'unknown';
const isCost = /(cost|price)/.test(txt);
if (isCost && /ascending/.test(txt)) return 'cost-asc';
if (isCost && /descending/.test(txt)) return 'cost-desc';
return 'unknown';
}
function isCostAscending() {
return getCostSortState() === 'cost-asc';
}
function findCostSortButton() {
// Try to find the "Cost/Price" sort button (best-effort)
return (
document.querySelector("button[title*='Cost' i]") ||
document.querySelector("button[title*='Price' i]") ||
Array.from(document.querySelectorAll('button, [role="button"]'))
.find(el => /cost|price/i.test(el.textContent || '')) ||
document.querySelector('button.item___UN3Mg:nth-child(5)') // your original fallback
);
}
function k(key) {
return `${NS}:${key}`;
}
function load(key, def = DEFAULTS[key]) {
try {
const raw = localStorage.getItem(k(key));
if (raw == null) return def;
if (raw === 'Infinity') return Infinity;
return JSON.parse(raw);
} catch {
return def;
}
}
function save(key, val) {
try {
localStorage.setItem(k(key), val === Infinity ? 'Infinity' : JSON.stringify(val));
} catch {}
}
function loadAll() {
const s = {};
for (const key of Object.keys(DEFAULTS)) s[key] = load(key);
return s;
}
function formatNum(n) {
try {
return Math.floor(Number(n)).toLocaleString();
} catch {
return String(Math.floor(n));
}
}
function parseMoney(str) {
if (typeof str === 'number') return str;
const x = parseInt(String(str || '').replace(/[^\d-]/g, ''), 10);
return isNaN(x) ? 0 : x;
}
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&').replaceAll('<', '<')
.replaceAll('>', '>').replaceAll('"', '"')
.replaceAll("'", ''');
}
function getRFC() {
return (window.$ && $.cookie && $.cookie('rfc_v')) ||
document.cookie.split('; ').find(r => r.startsWith('rfc_v='))?.split('=')[1] || '';
}
function notify(title, body) {
try {
if (!settings.notifications) return;
if (!('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
const n = new Notification(title, {
body
});
n.onclick = () => window.focus();
} catch {}
}
function profitPerUnit(price, mv, feePercent) {
return (mv * (1 - feePercent / 100)) - price;
}
function isFavItem(itemId) {
const arr = settings.favorites || [];
return arr.includes(Number(itemId));
}
function setFavorite(itemId, on) {
itemId = Number(itemId);
let arr = (settings.favorites || []).map(Number).filter(n => !isNaN(n));
const i = arr.indexOf(itemId);
if (on && i === -1) arr.push(itemId);
if (!on && i !== -1) arr.splice(i, 1);
settings.favorites = arr;
save('favorites', arr);
}
function toggleFavorite(itemId) {
const nowOn = !isFavItem(itemId);
setFavorite(itemId, nowOn);
return nowOn;
}
function getFavMax(itemId) {
const caps = settings.favoriteCaps || {};
const v = caps[itemId];
return (v == null || !(v >= 0)) ? Infinity : Number(v);
}
function favCapProfit(it) {
const cap = getFavMax(it.itemId);
// If no cap set (Infinity), use -Infinity so it sorts last among favorites
return isFinite(cap) ? (cap - it.price) : Number.NEGATIVE_INFINITY;
}
function setFavMax(itemId, val) {
const caps = settings.favoriteCaps || {};
if (val == null || !isFinite(val) || val <= 0) {
delete caps[itemId]; // Infinity when cleared
} else {
caps[itemId] = Math.floor(Number(val));
}
settings.favoriteCaps = caps;
save('favoriteCaps', caps);
scheduleUI();
}
function formatCapInputLive(el, itemId) {
const raw = el.value;
const caret = el.selectionStart ?? raw.length;
const digitsBefore = getDigitsBeforeCaret(raw, caret);
const digitsOnly = raw.replace(/[^\d]/g, '');
if (!digitsOnly.length) {
el.value = '';
setFavMax(itemId, Infinity);
return;
}
const n = parseInt(digitsOnly, 10);
const formatted = formatMoneyInline(n);
el.value = formatted;
const newPos = caretPosForDigits(formatted, digitsBefore);
try { el.setSelectionRange(newPos, newPos); } catch {}
setFavMax(itemId, n);
}
function setFavName(itemId, name) {
if (!name) return;
const m = settings.favoriteNames || {};
if (m[itemId] !== name) {
m[itemId] = name;
settings.favoriteNames = m;
save('favoriteNames', m);
}
}
function getFavName(itemId) {
return (settings.favoriteNames && settings.favoriteNames[itemId]) ||
(() => {
for (const it of items.values()) if (it.itemId === itemId) return it.name;
return 0;
})();
}
function isFavActiveForPin(item) {
return isFavItem(item.itemId) && item.price <= getFavMax(item.itemId);
}
function renderFavCapsList() {
const $wrap = $(SEL.settings.favCaps);
if (!$wrap.length) return;
const favIds = (settings.favorites || []).map(Number).filter(n => !isNaN(n));
if (!favIds.length) {
$wrap.html('<div class="muted">No favorites yet. Star items to manage caps here.</div>');
return;
}
const rows = favIds.map(id => {
const name = escapeHtml(getFavName(id));
const cap = getFavMax(id);
const val = isFinite(cap) ? formatMoneyInline(cap) : '';
if (name == 0) return;
return `
<div class="caprow" data-itemid="${id}">
<div class="iname" title="${name}">${name}</div>
<input class="input capinput" data-itemid="${id}" placeholder="∞" value="${val}" title="Max price">
<button class="btn-icon removefav" data-itemid="${id}" title="Remove">${ICONS.close}</button>
</div>
`;
}).join('');
$wrap.html(rows);
}
function buildUI() {
$(SEL.container).remove();
$('#obmStyles').remove();
$(SEL.fab).remove();
const top = settings.pos?.top ?? DEFAULTS.pos.top;
const right = settings.pos?.right ?? DEFAULTS.pos.right;
const left = settings.pos?.left;
const fabPos = `${left != null ? `left:${left}px;` : ''}${right != null ? `right:${right}px;` : ''}top:${top}px;`;
const styles = `
<style id="obmStyles">
#obm, #obm * { box-sizing: border-box; }
${SEL.container}{
position: fixed;
${left != null ? `left:${left}px;` : ''}
${right != null ? `right:${right}px;` : ''}
top:${top}px;
width:340px; height:60vh; max-height:80vh; min-width:300px;
display:flex; flex-direction:column; overflow:hidden;
background:#1f1f1f; border:1px solid #3a3a3a; border-radius:10px; color:#eaeaea;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
box-shadow:0 10px 26px rgba(0,0,0,0.35); z-index:9999;
}
${SEL.header}{
display:flex; align-items:center; gap:6px; padding:6px 8px;
background:#2b2b2b; border-bottom:1px solid #3a3a3a; user-select:none;
}
#obmDrag{ display:flex; align-items:center; gap:8px; font-weight:600; color:#ddd; cursor:move; -webkit-app-region:drag; }
#obmHdr .spacer{flex:1;}
/* Views */
#obmMain{display:flex; flex-direction:column; min-height:0; flex:1;}
#obmSettingsPane{display:none; flex:1; overflow:auto; padding:8px; background:#1b1b1b;}
.mode-settings #obmMain{display:none;}
.mode-settings #obmSettingsPane{display:flex; flex-direction:column;}
/* Controls, list, status */
#obmCtrls{ display:flex; align-items:center; gap:6px; padding:6px 8px; background:#262626; border-bottom:1px solid #333; }
${SEL.list}{flex:1; overflow:auto; padding:8px; background:#1b1b1b;}
#obmStatus{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 8px; background:#202020; border-top:1px solid #333; }
.chip{font-size:11px; padding:2px 6px; border-radius:999px; background:#444; color:#fff; font-weight:600;}
#obmDollars{background:#a53939;}
#obmCash{background:#385a38;}
#obmFavs{background:#5a43a5;}
.input{ background:#2a2a2a; border:1px solid #444; color:#fff; border-radius:6px; padding:5px 7px; font-size:12px; outline:none; }
.input::placeholder{color:#aaa;}
.btn{ background:#2a2a2a; border:1px solid #444; color:#ddd; border-radius:6px; padding:5px 7px; font-size:12px; cursor:pointer; }
.btn:hover{background:#383838;}
.btn-icon{width:28px; height:28px; display:flex; align-items:center; justify-content:center; padding:0;}
/* Items */
.it{ background:#2a2a2a; border:1px solid #3a3a3a; border-radius:8px; padding:8px; margin:0 0 6px; color:#eee; cursor:pointer; position:relative; }
.it:hover{background:#313131; border-color:#4a4a4a;}
.it.dollar{border-left:4px solid #ff4747; background:#332626;}
.it.fav{border-left:4px solid #8f6bff; background:linear-gradient(0deg, #2b2640, #2a2a2a);}
.it .top{display:flex; justify-content:space-between; gap:8px; align-items:center;}
.it .name{font-weight:700; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.tag{font-size:10px; padding:0 6px; border-radius:999px; background:#3a3a3a; color:#ccc; border:1px solid #4a4a4a;}
.tag.price{background:#3a3a3a;}
.tag.profit{background:#214a2a; color:#9ae39a; border-color:#2f6b3b;}
.una{opacity:0.55;}
.act{display:flex; gap:6px; align-items:center; margin-top:6px;}
.act .qty{width:66px; text-align:right;}
.act .hint{margin-left:auto; font-size:10px; color:#aaa; pointer-events:none;}
.lack{ margin-top:4px; font-size:11px; color:#f15a5a; }
.it.inflight{opacity:0.6; pointer-events:none;}
/* Fav button */
.favtoggle { border:none; background:transparent; color:#b9b1e6; padding:0; width:24px; height:24px; display:flex; align-items:center; justify-content:center; cursor:pointer; }
.favtoggle:hover{ color:#e6d8ff; }
.favtoggle.on{ color:#ffd666; }
/* In-panel settings */
#obmSettingsPane .panel{ background:#222; border:1px solid #3a3a3a; color:#eee; border-radius:10px; width:100%; max-width:360px; margin:0 auto; padding:10px; }
#obmSettingsPane .h{display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;}
#obmSettingsPane .row{display:flex; gap:8px; align-items:center; margin:6px 0;}
#obmSettingsPane label{font-size:12px; color:#bbb; min-width:90px;}
/* Log */
#obmLog{display:none; height:80px; overflow:auto; padding:6px 8px; background:#1a1a1a; border-top:1px solid #333;}
#obmLog .count { color:#9ae39a; font-weight:700; }
#obmLog .total { color:#f6d98a; font-weight:700; }
.show-log #obmLog{display:block;}
.loge{margin:2px 0; font-size:11px;}
.ok{color:#7ad07a;} .err{color:#f15a5a;} .info{color:#8ab4f8;}
.mini #obmCtrls, .mini #obmLog, .mini #obmStatus{display:none;}
.mini #obmSettingsPane{display:none;}
.ico { width:16px; height:16px; }
.btn-icon svg { width:16px; height:16px; }
#obmDrag .ico { margin-right:6px; }
/* Floating mini icon */
#obmFab{
position:fixed; ${fabPos}
width:44px; height:44px; border-radius:50%;
background:#2b2b2b; border:1px solid #3a3a3a; color:#ddd;
display:flex; align-items:center; justify-content:center;
box-shadow:0 10px 26px rgba(0,0,0,0.35); z-index:9999; cursor:pointer;
}
#obmFab:hover{ background:#343434; }
#obmFab .ico{ width:18px; height:18px; }
#obmSettingsPane .favcaps .caprow{display:flex; align-items:center; gap:8px; margin:4px 0;}
#obmSettingsPane .favcaps .iname{flex:1; font-size:12px; color:#ddd; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
#obmSettingsPane .favcaps .capinput{width:120px;}
#obmSettingsPane .favcaps .muted{font-size:12px; color:#888; padding:6px 0;}
#obmSettingsPane .favcaps .removefav {
width: 24px;
height: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
/* Make item cards unselectable */
#obmList .it {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none; /* iOS long-press menu */
}
/* Re-enable selection where you need it (inputs/textareas) */
#obmList .it input,
#obmList .it textarea {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
</style>
`;
const html = `
<div id="obm" class="${settings.minimized ? '' : ''} ${settings.showLog ? 'show-log' : ''}">
<div id="obmHdr" title="Bar">
<div id="obmDrag" title="Drag">${ICONS.drag} One Bazaar</div>
<div class="spacer"></div>
<button id="obmScanBtn" class="btn btn-icon" title="Scan">${ICONS.scan}</button>
<button id="obmSettingsBtn" class="btn btn-icon" title="Settings">${ICONS.settings}</button>
<button id="obmMinimize" class="btn btn-icon" title="Minimize">${ICONS.minimize}</button>
<button id="obmClose" class="btn btn-icon" title="Close">${ICONS.close}</button>
</div>
<div id="obmMain">
<div id="obmCtrls" title="Filters">
<input id="obmSearch" class="input" placeholder="Search" title="Search" value="${escapeHtml(settings.search || '')}" style="flex:1;">
<input id="obmMax" class="input" type="text" inputmode="numeric" placeholder="Max $" title="Max price" style="width:110px;">
<button id="obmCashBtn" class="btn btn-icon" title="Cash">${ICONS.cash}</button>
</div>
<div id="obmList" title="Items"></div>
<div id="obmStatus" title="Status">
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
<span id="obmItems" class="chip" title="Items">0</span>
<span id="obmDollars" class="chip" title="$1">0</span>
<span id="obmFavs" class="chip" title="Favorites">0</span>
</div>
<span id="obmCash" class="chip" title="Cash">$0</span>
</div>
<div id="obmLog" title="Log"><div class="loge info">Ready — listening for bazaar updates</div></div>
</div>
<div id="obmSettingsPane" title="Settings">
<div class="panel">
<div class="h">
<div>Settings</div>
<button id="obmSettingsBack" class="btn btn-icon" title="Back">${ICONS.back}</button>
</div>
<div class="row">
<label>Sort</label>
<select id="obmSort" class="input" style="flex:1;" title="Sort">
<option value="price-asc">Price ↑</option>
<option value="price-desc">Price ↓</option>
<option value="profit-desc">Profit/u ↓</option>
<option value="totalprofit-desc">Total profit ↓</option>
<option value="gain-desc">Gain % ↓</option>
<option value="name-asc">Name ↑</option>
</select>
</div>
<div class="row">
<label>Fee %</label>
<input id="obmFee" class="input" type="number" min="0" max="20" step="0.5" style="width:80px;" title="Fee %">
</div>
<div class="row">
<label>Negatives</label>
<input id="obmShowNeg" type="checkbox" title="Negatives">
</div>
<div class="row">
<label>Notify</label>
<input id="obmNotif" type="checkbox" title="Notify">
</div>
<div class="row">
<label>Log</label>
<input id="obmLogToggle" type="checkbox" title="Log">
</div>
<div class="row">
<label>Fav caps</label>
<div style="flex:1; font-size:12px; color:#888;">Only pin/highlight favorites priced at or under their cap.</div>
</div>
<div id="obmFavCaps" class="favcaps"></div>
</div>
</div>
</div>
<button id="obmFab" title="Open One Bazaar" style="display:${settings.minimized ? 'flex' : 'none'};">
${ICONS.restore}
</button>
`;
$('head').append(styles);
$('body').append(html);
$(SEL.settings.sort).val(settings.sort);
$(SEL.settings.fee).val(settings.feePercent);
$(SEL.settings.showNeg).prop('checked', !!settings.showNegatives);
$(SEL.settings.notif).prop('checked', !!settings.notifications);
$(SEL.settings.logToggle).prop('checked', !!settings.showLog);
setMaxInputFromSettings();
// Drag full panel
let dragging = false;
let offset = { x: 0, y: 0 };
$('#obmDrag').on('mousedown', (e) => {
dragging = true;
const r = $(SEL.container)[0].getBoundingClientRect();
offset.x = e.clientX - r.left;
offset.y = e.clientY - r.top;
});
$(document).off('mousemove.obm mouseup.obm')
.on('mousemove.obm', (e) => {
if (!dragging) return;
$(SEL.container).css({ left: e.clientX - offset.x, right: '', top: e.clientY - offset.y });
})
.on('mouseup.obm', () => {
if (!dragging) return;
dragging = false;
const r = $(SEL.container)[0].getBoundingClientRect();
settings.pos.left = r.left;
settings.pos.top = r.top;
settings.pos.right = null;
save('pos', settings.pos);
// also move fab accordingly
$(SEL.fab).css({ left: r.left, right: '', top: r.top });
});
// Drag FAB
let fabDragging = false;
let fabOffset = { x: 0, y: 0 };
$(SEL.fab).on('mousedown', (e) => {
fabDragging = true;
const r = $(SEL.fab)[0].getBoundingClientRect();
fabOffset.x = e.clientX - r.left;
fabOffset.y = e.clientY - r.top;
e.preventDefault();
});
$(document)
.on('mousemove.obmFab', (e) => {
if (!fabDragging) return;
$(SEL.fab).css({ left: e.clientX - fabOffset.x, right: '', top: e.clientY - fabOffset.y });
})
.on('mouseup.obmFab', () => {
if (!fabDragging) return;
fabDragging = false;
const r = $(SEL.fab)[0].getBoundingClientRect();
settings.pos.left = r.left;
settings.pos.top = r.top;
settings.pos.right = null;
save('pos', settings.pos);
});
// Filters and UI controls
$(SEL.search).on('input', (e) => { settings.search = e.target.value || ''; save('search', settings.search); scheduleUI(); });
$(SEL.maxPrice).on('input', (e) => { formatMaxInputLive(e.target); });
$(SEL.maxPrice).on('focus', (e) => { setTimeout(() => { try { e.target.select(); } catch {} }, 0); });
$(SEL.cashBtn).on('click', () => { settings.maxPrice = currentCash || 0; save('maxPrice', settings.maxPrice); setMaxInputFromSettings(); scheduleUI(); });
$('#obmScanBtn').on('click', async () => { stopScanHint?.(); await runScan(); });
$(SEL.settingsBtn).on('click', () => showSettings());
$(SEL.settings.close).on('click', () => showSettings(false));
// Minimize to FAB
$(SEL.minimizeBtn).off('click').on('click', () => {
$(SEL.container).hide();
$(SEL.fab).show();
settings.minimized = true;
save('minimized', true);
});
// Restore from FAB
$(SEL.fab).on('click', (e) => {
// ignore if we just dragged (mouse moved > few px handled inherently by mousedown/mouseup)
$(SEL.fab).hide();
$(SEL.container).show();
settings.minimized = false;
save('minimized', false);
});
$(SEL.closeBtn).on('click', () => $(SEL.container).hide());
$(SEL.list).on('selectstart', '.it', function (e) {
if (!$(e.target).is('input,textarea')) e.preventDefault();
});
$(SEL.settings.sort).on('change', (e) => { settings.sort = e.target.value; save('sort', settings.sort); scheduleUI(); });
$(SEL.settings.fee).on('input', (e) => { settings.feePercent = Math.max(0, Math.min(20, Number(e.target.value) || 0)); save('feePercent', settings.feePercent); recomputeProfits(); scheduleUI(); });
$(SEL.settings.showNeg).on('change', (e) => { settings.showNegatives = !!e.target.checked; save('showNegatives', settings.showNegatives); scheduleUI(); });
$(SEL.settings.notif).on('change', async (e) => {
if (e.target.checked) {
if ('Notification' in window) {
const perm = await Notification.requestPermission().catch(() => 'denied');
settings.notifications = perm === 'granted';
} else settings.notifications = false;
} else settings.notifications = false;
save('notifications', settings.notifications);
log(`Notifications ${settings.notifications ? 'enabled' : 'disabled'}`, 'info');
});
$(SEL.settings.logToggle).on('change', (e) => {
settings.showLog = !!e.target.checked;
save('showLog', settings.showLog);
$(SEL.container).toggleClass('show-log', settings.showLog);
});
renderFavCapsList();
$(SEL.settings.overlay) // '#obmSettingsPane'
.off('input.obmCap click.obmCap')
.on('input.obmCap', '.capinput', function () {
const itemId = Number(this.dataset.itemid);
if (!itemId) return;
formatCapInputLive(this, itemId);
})
.on('click', '.removefav', function () {
const id = Number($(this).data('itemid'));
setFavorite(id, false); // remove from favorites
scheduleUI(); // re-render UI
});
// Buy handlers
$(SEL.list).on('click', '.it', async function (e) {
if ($(e.target).closest('.buyqty, .qty, .favtoggle').length) return;
const id = Number(this.getAttribute('data-id'));
const data = items.get(id);
if (!data) return;
const enoughForFull = (data.price * data.amount) <= currentCash;
if (!enoughForFull) return;
const t = Date.now();
if (t - lastClick < 800) return;
lastClick = t;
$(this).addClass('inflight');
await buyItem(data, data.amount);
});
$(SEL.list).on('click', '.buyqty', async function (e) {
e.preventDefault(); e.stopPropagation();
const $it = $(this).closest('.it');
const id = Number($it.attr('data-id'));
const data = items.get(id);
if (!data) return;
const qty = Math.max(1, Math.min(data.amount, parseMoney($it.find('.qty').val() || 0)));
const cost = qty * data.price;
if (cost > currentCash) return;
const t = Date.now();
if (t - lastClick < 800) return;
lastClick = t;
$it.addClass('inflight');
await buyItem(data, qty);
});
$(SEL.list).on('input', '.qty', function () {
const $it = $(this).closest('.it');
const id = Number($it.attr('data-id'));
const data = items.get(id);
if (!data) return;
let qty = parseMoney($(this).val() || 0);
qty = Math.max(1, Math.min(data.amount, qty));
const cost = qty * data.price;
$it.find('.buyqty').prop('disabled', cost > currentCash || qty < 1);
});
// Favorite toggle
$(SEL.list).on('click', '.favtoggle', function (e) {
e.preventDefault(); e.stopPropagation();
const $it = $(this).closest('.it');
const id = Number($it.attr('data-id'));
const data = items.get(id);
if (!data) return;
const nowOn = toggleFavorite(data.itemId);
if (nowOn) setFavName(data.itemId, data.name);
log(`${nowOn ? '⭐ Added to' : '☆ Removed from'} favorites: ${data.name}`, 'info');
// If settings is open, refresh the list
if ($(SEL.container).hasClass('mode-settings')) renderFavCapsList();
scheduleUI();
});
if (typeof scanHintDone !== 'undefined' && !scanHintDone) $('#obmScanBtn').addClass('blink');
log('Settings now open inline. Click ⚙️ to toggle.', 'info');
// Panel / FAB initial visibility
if (settings.minimized) {
$(SEL.container).hide();
$(SEL.fab).show();
} else {
$(SEL.container).show();
$(SEL.fab).hide();
}
}
function scheduleUI() {
if (updateScheduled) return;
updateScheduled = true;
requestAnimationFrame(() => {
updateUI();
updateScheduled = false;
});
}
function formatMoneyInline(n) {
try {
return Number(n).toLocaleString();
} catch {
return String(n || '');
}
}
function getDigitsBeforeCaret(str, caret) {
return (str.slice(0, caret).match(/\d/g) || []).length;
}
function caretPosForDigits(formatted, targetDigits) {
let pos = 0,
seen = 0;
while (pos < formatted.length && seen < targetDigits) {
if (/\d/.test(formatted[pos])) seen++;
pos++;
}
return pos;
}
function formatMaxInputLive(el) {
const raw = el.value;
const caret = el.selectionStart ?? raw.length;
const digitsBefore = getDigitsBeforeCaret(raw, caret);
const digitsOnly = raw.replace(/[^\d]/g, '');
if (!digitsOnly.length) {
el.value = '';
settings.maxPrice = Infinity;
save('maxPrice', settings.maxPrice);
scheduleUI();
return;
}
const n = parseInt(digitsOnly, 10);
const formatted = formatMoneyInline(n);
el.value = formatted;
const newPos = caretPosForDigits(formatted, digitsBefore);
try {
el.setSelectionRange(newPos, newPos);
} catch {}
settings.maxPrice = n;
save('maxPrice', n);
scheduleUI();
}
function setMaxInputFromSettings() {
const $el = $(SEL.maxPrice);
const v = isFinite(settings.maxPrice) ? settings.maxPrice : '';
$el.val(v === '' ? '' : formatMoneyInline(v));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function scrollEl() {
return document.scrollingElement || document.documentElement || document.body;
}
async function scrollToBottomLoad(maxLoops = 80, stopWhen) {
const s = scrollEl();
let lastH = -1, stable = 0;
for (let i = 0; i < maxLoops; i++) {
if (typeof stopWhen === 'function' && stopWhen()) break;
window.scrollTo(0, s.scrollHeight);
await sleep(200);
if (typeof stopWhen === 'function' && stopWhen()) break;
const h = s.scrollHeight;
if (h === lastH) {
if (++stable >= 3) break;
} else {
stable = 0;
lastH = h;
}
}
}
async function scrollToTopSmooth() {
const s = scrollEl();
for (let i = 0; i < 40 && s.scrollTop > 0; i++) {
window.scrollBy(0, -Math.max(200, window.innerHeight * 0.8));
await sleep(30);
}
window.scrollTo(0, 0);
}
async function runScan() {
try {
stopScanHint();
const wasAsc = isCostAscending();
let clicked = false;
if (!wasAsc) {
const btn = findCostSortButton();
if (btn) {
// One click max (compliant)
btn.click();
clicked = true;
await sleep(250);
}
}
const asc = isCostAscending();
if (asc) {
log(`Scanning (Cost ↑ ${clicked ? 'after 1 click' : 'detected'})`, 'info');
} else {
log(`Scanning (Cost ↑ not detected${clicked ? ' after 1 click' : ''}). Early-stop disabled.`, 'info');
}
const hasMax = isFinite(settings.maxPrice);
const canEarlyStop = asc && hasMax;
if (hasMax) {
log(`Max price: $${formatNum(settings.maxPrice)}${canEarlyStop ? ' — early-stop enabled' : ''}`, 'info');
}
await scrollToBottomLoad(80, () => canEarlyStop && shouldStopScanByMax());
await sleep(250);
await scrollToTopSmooth();
if (canEarlyStop && shouldStopScanByMax()) {
log(`Scan stopped early — reached first item above $${formatNum(settings.maxPrice)}.`, 'ok');
} else {
log('Scan complete', 'ok');
}
} catch (e) {
log(`Scan error: ${e.message}`, 'err');
}
}
function sortItems(arr) {
const fns = {
'price-asc': (a, b) => a.price - b.price,
'price-desc': (a, b) => b.price - a.price,
'profit-desc': (a, b) => b.profit - a.profit,
'totalprofit-desc': (a, b) => b.totalProfit - a.totalProfit,
'gain-desc': (a, b) => b.gainPct - a.gainPct,
'name-asc': (a, b) => a.name.localeCompare(b.name),
};
const cmp = fns[settings.sort] || fns['price-asc'];
arr.sort((a, b) => {
if (a.price === 1 && b.price !== 1) return -1;
if (b.price === 1 && a.price !== 1) return 1;
return cmp(a, b);
});
return arr;
}
function updateUI() {
$(SEL.counters.cash).text(`$${formatNum(currentCash)}`);
let arr = Array.from(items.values());
// Filters
if (settings.search) {
const s = settings.search.toLowerCase();
arr = arr.filter(x => x.name && x.name.toLowerCase().includes(s));
}
if (!settings.showNegatives) arr = arr.filter(x => x.profit >= 0 && x.gainPct >= 0);
if (isFinite(settings.maxPrice)) arr = arr.filter(x => x.price <= settings.maxPrice);
// Grouping: $1 first, then favorites (only if price <= cap), then the rest
const dollars = arr.filter(x => x.price === 1);
const favPinned = arr.filter(x => x.price !== 1 && isFavActiveForPin(x));
const rest = arr.filter(x => x.price !== 1 && !isFavActiveForPin(x));
// Sort $1 and rest with your chosen sort, favorites by cap-profit desc
sortItems(dollars);
favPinned.sort((a, b) => {
const db = favCapProfit(b);
const da = favCapProfit(a);
if (db !== da) return db - da; // cap diff (desc)
// tie-breakers to keep things stable
if (a.price !== b.price) return a.price - b.price;
return a.name.localeCompare(b.name);
});
sortItems(rest);
// Final order
arr = [...dollars, ...favPinned, ...rest];
$(SEL.counters.items).text(arr.length);
$(SEL.counters.dollars).text(dollars.length);
const totalFavIds = (settings.favorites || []).length;
$(SEL.counters.favs).text(favPinned.length).attr('title', `Favorites pinned: ${favPinned.length}/${totalFavIds}`);
const html = arr.map(x => {
const fullCost = x.price * x.amount;
const affordableFull = fullCost <= currentCash;
const maxQtyAffordable = Math.min(x.amount, Math.floor((currentCash || 0) / x.price));
const favOn = isFavItem(x.itemId);
const cap = getFavMax(x.itemId);
const favPinnedNow = favOn && x.price <= cap;
const capProfitVal = isFinite(cap) ? (cap - x.price) : null;
const profitTag = (favOn && isFinite(cap) && capProfitVal > 0)
? `<span class="tag profit" title="Profit vs cap">+$${formatNum(capProfitVal)}</span>`
: (x.profit > 0
? `<span class="tag profit" title="Profit/u (MV-based)">+$${formatNum(x.profit)}</span>`
: ``);
return `
<div class="it ${x.price === 1 ? 'dollar' : ''} ${favPinnedNow ? 'fav' : ''} ${affordableFull ? '' : 'una'}"
data-id="${x.id}"
title="${escapeHtml(x.name)}">
<div class="top">
<div class="name" title="Name">${escapeHtml(x.name)}</div>
<div class="row" style="display:flex; gap:6px; align-items:center;">
<button class="favtoggle ${favOn ? 'on' : ''}" title="${favOn ? 'Unfavorite' : 'Favorite'}">
${favOn ? ICONS.starFilled : ICONS.star}
</button>
<span class="tag" title="Qty">${x.amount}×</span>
<span class="tag price" title="Price">$${formatNum(x.price)}</span>
${profitTag}
</div>
</div>
<div class="act" title="Buy qty">
<input class="input qty" type="number" min="1" max="${x.amount}"
value="${Math.min( Math.max(1, maxQtyAffordable || 1), x.amount)}"
title="Qty (max ${x.amount})">
<button class="btn buyqty" ${maxQtyAffordable < 1 ? 'disabled' : ''} title="Buy">Buy</button>
${affordableFull ? `<span class="hint" title="Buy all">Click card to buy all</span>` : ``}
</div>
${affordableFull ? `` : `<div class="lack" title="Need cash">Not enough cash</div>`}
</div>
`;
}).join('');
$(SEL.list).html(html);
}
function recomputeProfits() {
for (const it of items.values()) {
it.profit = profitPerUnit(it.price, it.marketValue, settings.feePercent);
it.totalProfit = it.profit * it.amount;
it.gainPct = it.price > 0 ? (it.profit / it.price) * 100 : 0;
}
}
function log(msg, level = 'ok', opts = {}) {
const time = new Date().toLocaleTimeString([], {
hour12: false
});
const cls = level === 'err' ? 'err' : level === 'info' ? 'info' : 'ok';
const asHtml = !!opts.html;
const content = asHtml ? sanitizeLogHTML(String(msg)) : escapeHtml(String(msg));
$(SEL.log).prepend(`<div class="loge ${cls}">[${time}] ${content}</div>`);
const kids = $(SEL.log).children();
if (kids.length > 60) kids.slice(60).remove();
}
function sanitizeLogHTML(s) {
try {
const tpl = document.createElement('template');
tpl.innerHTML = String(s);
function clean(node) {
const kids = Array.from(node.childNodes);
for (const n of kids) {
if (n.nodeType === Node.TEXT_NODE) continue;
if (n.nodeType === Node.ELEMENT_NODE) {
const el = n;
const tag = el.tagName;
const spanAllowed = tag === 'SPAN' && (el.classList.contains('count') || el.classList.contains('total'));
const allowBr = tag === 'BR';
const allowEm = tag === 'B' || tag === 'STRONG' || tag === 'EM' || tag === 'I' || tag === 'U' || tag === 'S';
if (spanAllowed || allowBr || allowEm) {
if (spanAllowed) {
const keep = ['count', 'total'].filter(c => el.classList.contains(c)).join(' ');
el.setAttribute('class', keep);
[...el.attributes].forEach(a => {
if (a.name !== 'class') el.removeAttribute(a.name);
});
} else {
[...el.attributes].forEach(a => el.removeAttribute(a.name));
}
clean(el);
} else {
el.replaceWith(document.createTextNode(el.textContent || ''));
}
} else {
n.remove();
}
}
}
clean(tpl.content);
tpl.content.querySelectorAll('span.total').forEach(span => {
const prev = span.previousSibling;
if (prev && prev.nodeType === Node.TEXT_NODE) {
const m = /(.*?)(\s*)\$(\s*)$/.exec(prev.textContent || '');
if (m) {
prev.textContent = (m[1] || '') + (m[2] || '');
span.insertBefore(document.createTextNode('$'), span.firstChild);
}
}
});
return tpl.innerHTML;
} catch {
return escapeHtml(String(s));
}
}
function htmlToText(s) {
const div = document.createElement('div');
div.innerHTML = String(s || '');
return (div.textContent || '').trim();
}
function showSettings(force) {
const on = force === undefined ? !$(SEL.container).hasClass('mode-settings') : !!force;
$(SEL.container).toggleClass('mode-settings', on);
$(SEL.settingsBtn).attr('title', on ? 'Items' : 'Settings');
if (on) renderFavCapsList();
}
async function buyItem(it, qty) {
try {
qty = Math.max(1, Math.min(it.amount, Number(qty) || 1));
const url = `https://www.torn.com/bazaar.php?sid=bazaarData&step=buyItem&rfcv=${getRFC()}`;
const body = new URLSearchParams({
userID: String(it.sellerUserId),
id: String(it.id),
itemid: String(it.itemId),
amount: String(qty),
price: String(it.price),
beforeval: String(it.price * qty),
});
let data = null;
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
},
body: body.toString(),
});
data = await res.json();
} catch {
if (window.$ && $.post) {
const raw = await $.post(url, Object.fromEntries(body));
data = JSON.parse(raw);
} else {
throw new Error('Could not post buy request');
}
}
if (data?.success) {
if (data?.text) log(data.text, 'ok', {
html: true
});
else log(`Bought ${it.name} x${qty} @ $${formatNum(it.price)}`, 'ok');
if (qty >= it.amount) {
bought.add(it.id);
items.delete(it.id);
} else {
it.amount -= qty;
it.totalProfit = it.profit * it.amount;
}
scheduleUI();
} else {
const text = data?.text ? htmlToText(data.text) : 'Buy failed';
log(text, 'err');
if (text.toLowerCase().includes('someone else') || text.toLowerCase().includes('no longer available')) {
items.delete(it.id);
scheduleUI();
}
}
} catch (e) {
log(`Error: ${e.message}`, 'err');
} finally {
$(`.it[data-id="${it.id}"]`).removeClass('inflight');
}
}
function onBazaarData(payload) {
try {
latestSeller = payload?.ID ?? latestSeller;
const list = Array.isArray(payload?.list) ? payload.list : [];
let newDollar = 0;
for (const x of list) {
const id = Number(x?.bazaarID ?? x?.id);
if (!id || bought.has(id)) continue;
if (x?.isBlockedForBuying) continue;
const price = parseMoney(x?.price);
const amount = Number(x?.amount) || 1;
const mv = Number(x?.averageprice ?? 0);
const name = x?.name || 'Unknown';
const sellerUserId = Number(payload?.ID) || latestSeller || 0;
const ppu = profitPerUnit(price, mv, settings.feePercent);
const item = {
id,
itemId: Number(x?.ID ?? 0),
sellerUserId,
price,
amount,
marketValue: mv,
profit: ppu,
totalProfit: ppu * amount,
gainPct: price > 0 ? (ppu / price) * 100 : 0,
name,
};
items.set(id, item);
if (isFavItem(item.itemId)) setFavName(item.itemId, item.name);
if (price === 1) {
newDollar++;
if (settings.notifications) notify('One Bazaar: $1 item', `${name} x${amount}`);
}
}
if (newDollar > 0) log(`${newDollar} new $1 item(s)`, 'info');
scheduleUI();
} catch {}
}
function hookFetch() {
if (hookedFetch || !window.fetch) return;
hookedFetch = true;
const orig = window.fetch;
window.fetch = async function(...args) {
const res = await orig.apply(this, args);
try {
const url = res?.url || (args[0] && args[0].url) || '';
if (url.includes(WATCH_URL_PART)) {
const clone = res.clone();
const data = await clone.json();
onBazaarData(data);
}
} catch {}
return res;
};
}
function hookXHR() {
if (hookedXHR) return;
hookedXHR = true;
const oOpen = XMLHttpRequest.prototype.open;
const oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(...args) {
this._obm_url = args[1];
return oOpen.apply(this, args);
};
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('readystatechange', function() {
try {
if (this.readyState === 4) {
const url = this.responseURL || this._obm_url || '';
if (url.includes(WATCH_URL_PART)) {
try {
onBazaarData(JSON.parse(this.responseText));
} catch {}
}
}
} catch {}
});
return oSend.apply(this, args);
};
}
function updateCashFromDOM() {
try {
const moneyEls = $(".user-info .money, .money, [class*='money'], [data-money]");
let best = 0;
moneyEls.each(function() {
const text = $(this).text() || $(this).attr('data-money') || '';
const val = parseMoney(text);
if (!isNaN(val) && val > best) best = val;
});
if (best !== currentCash) {
currentCash = best;
scheduleUI();
}
} catch {}
}
function initCashWatcher() {
if (cashObs) return;
updateCashFromDOM();
cashObs = new MutationObserver(() => {
if (updateScheduled) setTimeout(updateCashFromDOM, 150);
else updateCashFromDOM();
});
cashObs.observe(document.body, {
subtree: true,
childList: true,
characterData: true
});
cashIntervalId = window.setInterval(updateCashFromDOM, 3000);
}
function init() {
if (!/bazaar\.php.*userId=/.test(location.href)) return;
buildUI();
hookFetch();
hookXHR();
initCashWatcher();
}
function onReady(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, {
once: true
});
else fn();
}
onReady(() => {
init();
window.addEventListener('popstate', () => setTimeout(init, 150));
window.addEventListener('hashchange', () => setTimeout(init, 150));
});
})();