Bazaar Auto-Sorter

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

// ==UserScript==
// @name         Bazaar Auto-Sorter
// @namespace    https://torn.com/
// @version      1.0
// @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:480px;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 getCurrentRows(){
  const rows=[...document.querySelectorAll("div[aria-label='grid'] .row___n2Uxh")];
  return rows.map(r=>{
    const name=r.querySelector('.item___jLJcf').getAttribute('aria-label');
    const dmg = r.querySelector('.damageBonusIcon___MSlMm+span')?.innerText||'';
    const acc = r.querySelector('.accuracyBonusIcon___p4tfD+span')?.innerText||'';
    return {el:r,name,price:getRowPrice(r),dmg,acc};
  });
}

/*************************
 *  PREVIEW RENDERING    *
 *************************/
function buildPreview(rows, sortByPrice=false){
  const grouped={};
  for(const r of rows){
    const key=r.type||'Unknown';
    if(!grouped[key])grouped[key]=[];
    grouped[key].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);
    for(const r of grouped[type].sort(sortFn)){
      const extra=sortByPrice?` - $${r.price.toLocaleString()}`:(r.dmg||r.acc?` (${r.dmg}/${r.acc})`:"");
      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 LOGIC       *
 *************************/
async function sortByType(){
  btnType.disabled=true;
  try{
    const key=await getApiKey();
    const dir=await ensureItemDirectory(key);
    let rows=getCurrentRows();
    rows.forEach(r=>r.type=dir.get(r.name)||'Unknown');
    rows.sort((a,b)=>a.type.localeCompare(b.type)||a.name.localeCompare(b.name)||a.dmg.localeCompare(b.dmg)||a.acc.localeCompare(b.acc));
    buildPreview(rows,false);
    for(let i=0;i<rows.length;i++){
      const current=getCurrentRows();
      const tgt=rows[i];
      const idx=current.findIndex(r=>r.name===tgt.name&&r.dmg===tgt.dmg&&r.acc===tgt.acc);
      if(idx===-1||idx===i) continue;
      await performDrag(current[idx].el,i);
    }
  }catch(e){alert('Sort failed: '+e.message);console.error(e);}finally{btnType.disabled=false;}
}

async function sortByPrice(){
  btnPrice.disabled=true;
  try{
    let rows=getCurrentRows();
    const key=await getApiKey();const dir=await ensureItemDirectory(key);rows.forEach(r=>r.type=dir.get(r.name)||'Unknown');
    rows.sort((a,b)=>b.price-a.price);
    buildPreview(rows,true);
    for(let i=0;i<rows.length;i++){
      const current=getCurrentRows();
      const tgt=rows[i];
      const idx=current.findIndex(r=>r.name===tgt.name&&r.price===tgt.price&&r.dmg===tgt.dmg&&r.acc===tgt.acc);
      if(idx===-1||idx===i) continue;
      await performDrag(current[idx].el,i);
    }
  }catch(e){alert('Sort failed: '+e.message);console.error(e);}finally{btnPrice.disabled=false;}
}

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