// ==UserScript==
// @name Bazaar Auto-Sorter
// @namespace https://torn.com/
// @version 1.1
// @author swervelord [3637232]
// @description Sort your bazaar items by *Type* or *Price*
// @match https://www.torn.com/bazaar.php*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// ==/UserScript==
/*************************
* CONFIG & CONSTANTS *
*************************/
const API_BASE = "https://api.torn.com/";
const ITEMS_ENDPOINT = "torn/?selections=items&key=";
const CALLS_PER_MIN_LIMIT = 90;
const CALL_WINDOW_MS = 60_000;
/*************************
* RATE-LIMIT HELPERS *
*************************/
let callTimestamps = [];
function canCall () {
const now = Date.now();
callTimestamps = callTimestamps.filter(t => now - t < CALL_WINDOW_MS);
return callTimestamps.length < CALLS_PER_MIN_LIMIT;
}
function registerCall () { callTimestamps.push(Date.now()); }
function apiGet (url) {
return new Promise((resolve, reject) => {
const attempt = () => {
if (!canCall()) return setTimeout(attempt, 400);
registerCall();
GM_xmlhttpRequest({
method : 'GET',
url,
onload : r => { try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } },
onerror: reject
});
};
attempt();
});
}
/*************************
* API KEY CACHE *
*************************/
async function getApiKey () {
let key = GM_getValue('tornApiKey', '');
while (!/^[a-zA-Z0-9]{16,}$/.test(key)) {
key = prompt('Enter your Torn API key (16+ chars)', '') || '';
if (!/^[a-zA-Z0-9]{16,}$/.test(key.trim())) {
alert('Invalid API key, try again.');
key = '';
} else {
key = key.trim();
GM_setValue('tornApiKey', key);
}
}
return key;
}
/*************************
* STYLES *
*************************/
const style = document.createElement('style');
style.textContent = `
#bsz-controls{display:flex;justify-content:center;gap:12px;margin:12px auto;width:100%;max-width:780px}
#bsz-controls button{background:#333;color:#fff;border:1px solid #0ff;box-shadow:0 0 6px #0ff;padding:6px 14px;border-radius:4px;font-weight:600;cursor:pointer}
#bsz-controls button:disabled{opacity:.5;cursor:not-allowed}
#bsz-preview{position:fixed;top:90px;left:10px;width:360px;max-height:80vh;overflow:auto;border:1px solid #0ff;background:#222;color:#fff;font-size:13px;box-shadow:0 0 8px #0ff;padding:6px 10px;border-radius:4px;z-index:9999;display:none}
`;document.head.appendChild(style);
/*************************
* UI COMPONENTS *
*************************/
const controls = document.createElement('div');controls.id='bsz-controls';
const btnType = Object.assign(document.createElement('button'),{innerText:'Sort by Type'});
const btnPrice = Object.assign(document.createElement('button'),{innerText:'Sort by Price'});
controls.append(btnType,btnPrice);
const preview=document.createElement('div');preview.id='bsz-preview';document.body.appendChild(preview);
/*************************
* HELPERS *
*************************/
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function getRowPrice(row){const h=row.querySelector('.price___DoKP7 input[type="hidden"]');return h?parseInt(h.value,10):0;}
function getBonusString(row){
return [...row.querySelectorAll('.bonusValue____jVoc')]
.map(e=>e.innerText.trim())
.join('|'); // e.g. "32.43" or "38.91|0"
}
function getCurrentRows(){
return [...document.querySelectorAll('div[aria-label="grid"] .row___n2Uxh')]
.map(r=>{
const name = r.querySelector('.item___jLJcf').getAttribute('aria-label');
const bonus = getBonusString(r);
return {el:r,name,bonus,price:getRowPrice(r)};
});
}
/*************************
* PREVIEW RENDERING *
*************************/
function buildPreview(rows, sortByPrice=false){
const grouped={};
rows.forEach(r=>{
const k=r.type||'Unknown';
(grouped[k]??=[]).push(r);
});
const html=['<div style="padding:6px 4px">'];
for(const type of Object.keys(grouped).sort()){
html.push(`<div style="color:#0ff;font-size:16px;font-weight:bold;margin-top:10px;text-decoration:underline">${type}</div>`);
html.push('<ul style="list-style:none;padding-left:12px;margin:6px 0 10px">');
const sortFn=sortByPrice?(a,b)=>b.price-a.price:(a,b)=>a.name.localeCompare(b.name);
grouped[type].sort(sortFn).forEach(r=>{
const extra=sortByPrice?` - $${r.price.toLocaleString()}`:(r.bonus?` (${r.bonus})`:'');
html.push(`<li style="color:#fff;padding:2px 0">${r.name}${extra}</li>`);
});
html.push('</ul>');
}
html.push('</div>');
preview.innerHTML=html.join('');
preview.style.display='block';
}
/*************************
* ITEM DIRECTORY MAP *
*************************/
let itemDirectory=null;
async function ensureItemDirectory(key){
if(itemDirectory) return itemDirectory;
const data=await apiGet(`${API_BASE}${ITEMS_ENDPOINT}${key}`);
itemDirectory=new Map(Object.values(data.items).map(itm=>[itm.name,itm.type]));
return itemDirectory;
}
/*************************
* DRAG SIMULATION *
*************************/
async function performDrag(itemEl,targetIdx){
const handle=itemEl.querySelector('.draggableIconContainer___zlpp_ i, .draggableIconContainer___zlpp_');
if(!handle) return false;
const rows=[...itemEl.parentElement.children];
const tgtRow=rows[targetIdx];
if(!tgtRow||tgtRow===itemEl) return false;
const sRect=handle.getBoundingClientRect();
const tRect=tgtRow.getBoundingClientRect();
const dx=tRect.left+10-sRect.left;
const dy=tRect.top +10-sRect.top;
const sim=(type,x,y)=>document.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,clientX:x,clientY:y}));
handle.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,cancelable:true,clientX:sRect.left+5,clientY:sRect.top+5}));
sim('mousemove',sRect.left+5+dx/2,sRect.top+5+dy/2);await sleep(15);
sim('mousemove',sRect.left+5+dx, sRect.top+5+dy); await sleep(15);
sim('mouseup', sRect.left+5+dx, sRect.top+5+dy); await sleep(45);
return true;
}
/*************************
* SORT HELPERS *
*************************/
function buildTargetOrder(rows, sortByPrice){
if(sortByPrice){
return [...rows].sort((a,b)=>b.price-a.price);
}
return [...rows].sort((a,b)=>
a.type.localeCompare(b.type) ||
a.name.localeCompare(b.name) ||
a.bonus.localeCompare(b.bonus)
);
}
async function runSortPass(target, sortByPrice){
const handled=new Set();
let moves=0;
for(let i=0;i<target.length;i++){
const current=getCurrentRows();
const tgt=target[i];
const match=current.find(r=>
r.name===tgt.name &&
(sortByPrice ? r.price===tgt.price : true) &&
r.bonus===tgt.bonus &&
!handled.has(r.el)
);
if(!match) continue;
const curIdx=current.findIndex(r=>r.el===match.el);
if(curIdx===i){handled.add(match.el);continue;}
await performDrag(match.el,i);
handled.add(match.el);
moves++;
}
return moves; // how many rows we actually moved this pass
}
/*************************
* SORT LOGIC *
*************************/
async function sortController(sortByPrice){
const btn = sortByPrice ? btnPrice : btnType;
btn.disabled=true;
try{
const key = await getApiKey();
const dir = await ensureItemDirectory(key);
// prep first snapshot + preview
let rows=getCurrentRows();
rows.forEach(r=>r.type=dir.get(r.name)||'Unknown');
buildPreview(rows,sortByPrice);
/* multi-pass loop — max 5 attempts or until no moves */
let attempts=0, moved;
do{
rows=getCurrentRows();
rows.forEach(r=>r.type=dir.get(r.name)||'Unknown');
const target=buildTargetOrder(rows,sortByPrice);
moved=await runSortPass(target,sortByPrice);
attempts++;
if(moved>0) await sleep(120); // let React repaint
}while(moved>0 && attempts<5);
}catch(e){alert('Sort failed: '+e.message);console.error(e);}
finally{btn.disabled=false;}
}
const sortByType = () => sortController(false);
const sortByPrice = () => sortController(true);
/*************************
* BOOTSTRAP *
*************************/
(async function init(){
const waitFor=sel=>new Promise(res=>{
const int=setInterval(()=>{const el=document.querySelector(sel);if(el){clearInterval(int);res(el);}},500);
});
const header=await waitFor('ul.cellsHeader___hZgkv');
header.parentElement.insertBefore(controls,header);
})();
btnType .addEventListener('click',sortByType);
btnPrice.addEventListener('click',sortByPrice);