Bazaar Auto-Sorter

Sort your bazaar items by *Type* or *Price*

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