Greasy Fork is available in English.
Scan market every minute and get highlights when market items fall below or surpass thresholds
// ==UserScript==
// @name Market Stalker
// @version 4.3
// @description Scan market every minute and get highlights when market items fall below or surpass thresholds
// @author dingus
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @namespace https://greasyfork.org/users/1338514
// ==/UserScript==
(function() {
'use strict';
let stalkedItems = JSON.parse(localStorage.getItem('stalkedItems')) || [];
let itemDatabase = JSON.parse(localStorage.getItem('ms_item_cache')) || null;
let apiKey = localStorage.getItem('market_api_key') || "";
let thresholds = JSON.parse(localStorage.getItem('ms_thresholds')) || {};
let activeTab = 'items';
let isMinimized = JSON.parse(localStorage.getItem('ms_minimized')) || false;
const styles = `
#ms-container {
position: fixed; top: 20px; right: 20px; width: 330px;
background: #1a1a1a; color: #eee; border: 1px solid #444;
z-index: 999999 !important;
font-family: 'Segoe UI', sans-serif;
border-radius: 8px; box-shadow: 0 12px 40px rgba(0,0,0,0.8); overflow: hidden;
}
#ms-container.minimized {
width: 35px; height: 35px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
background: #28a745; border: 1px solid #1e7e34;
border-radius: 4px; font-weight: bold; font-size: 20px;
}
.ms-header { background: #252525; padding: 10px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; }
.ms-tabs { display: flex; background: #222; border-bottom: 1px solid #333; }
.ms-tab { flex: 1; padding: 10px; text-align: center; cursor: pointer; font-size: 11px; text-transform: uppercase; transition: 0.2s; color: #888; }
.ms-tab.active { background: #333; border-bottom: 2px solid #007bff; color: #fff; font-weight: bold; }
.ms-content { padding: 15px; max-height: 400px; overflow-y: auto; scrollbar-width: thin; }
.ms-item-row { display: flex; justify-content: space-between; padding: 8px; background: #2a2a2a; margin-bottom: 5px; border-radius: 4px; align-items: center; border: 1px solid transparent; }
.ms-item-highlight { border-color: #007bff; background: #1a2a3a; box-shadow: inset 0 0 10px rgba(0,123,255,0.3); }
.ms-btn { cursor: pointer; border: none; border-radius: 4px; padding: 5px 10px; font-size: 12px; transition: 0.1s; outline: none; }
.ms-btn-primary { background: #007bff; color: white; }
.ms-btn-success { background: #28a745; color: white; }
.ms-btn-danger { background: #d9534f; color: white; }
.ms-input { width: 100%; padding: 8px; margin-bottom: 10px; background: #333; border: 1px solid #444; color: white; border-radius: 4px; box-sizing: border-box; font-size: 12px; }
.price-up { color: #28a745; font-weight: bold; }
.price-down { color: #d9534f; font-weight: bold; }
.threshold-row { display: flex; justify-content: space-between; background: #222; padding: 8px; border-radius: 4px; margin-bottom: 5px; align-items: center; font-size: 11px; border-left: 3px solid #444; }
.qty-text { font-size: 11px; color: #007bff; margin-left: 8px; font-weight: bold; }
`;
const styleSheet = document.createElement("style");
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);
const container = document.createElement('div');
container.id = 'ms-container';
document.body.appendChild(container);
container.addEventListener('mousedown', () => { container.style.zIndex = "1000000"; });
function saveData() {
localStorage.setItem('stalkedItems', JSON.stringify(stalkedItems));
localStorage.setItem('market_api_key', apiKey);
localStorage.setItem('ms_minimized', isMinimized);
localStorage.setItem('ms_thresholds', JSON.stringify(thresholds));
if (itemDatabase) localStorage.setItem('ms_item_cache', JSON.stringify(itemDatabase));
}
function getItemIdByName(name) {
if (!itemDatabase) return null;
const search = name.toLowerCase().trim();
return Object.keys(itemDatabase).find(id => itemDatabase[id].name.toLowerCase() === search);
}
function formatNumberWithCommas(value) {
let cleanValue = value.toString().replace(/,/g, '');
if (/[kmbter]/i.test(cleanValue)) {
const val = cleanValue.toLowerCase();
if (val.includes('k')) cleanValue = (parseFloat(val.replace('k', '')) * 1000).toString();
else if (val.includes('m')) cleanValue = (parseFloat(val.replace('m', '')) * 1000000).toString();
else if (val.includes('b')) cleanValue = (parseFloat(val.replace('b', '')) * 1000000000).toString();
else if (val.includes('t')) cleanValue = (parseFloat(val.replace('t', '')) * 1000000000000).toString();
}
if (isNaN(cleanValue) || cleanValue === "") return cleanValue;
return Number(cleanValue).toLocaleString();
}
async function fetchSingleItem(itemId) {
if (!apiKey) return;
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET",
url: `https://api.torn.com/v2/market/${itemId}/itemmarket?limit=1&key=${apiKey}`,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
const listings = data.itemmarket?.listings;
if (listings && listings.length > 0) {
const newPrice = listings[0].price;
const newAmt = listings[0].amount;
const idx = stalkedItems.findIndex(i => i.id == itemId);
if (idx !== -1) {
if (stalkedItems[idx].lastPrice) {
stalkedItems[idx].trend = newPrice > stalkedItems[idx].lastPrice ? 'up' : (newPrice < stalkedItems[idx].lastPrice ? 'down' : 'steady');
}
stalkedItems[idx].lastPrice = newPrice;
stalkedItems[idx].lastAmt = newAmt;
stalkedItems[idx].lastUpdate = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
saveData();
render();
}
}
} catch (e) {}
resolve();
},
onerror: () => resolve()
});
});
}
async function checkMarket() {
if (!apiKey || stalkedItems.length === 0) return;
const btn = document.getElementById('manual-refresh');
if (btn) { btn.innerText = "Checking..."; btn.disabled = true; }
const promises = stalkedItems.map(item => fetchSingleItem(item.id));
await Promise.all(promises);
if (btn) { btn.innerText = "Check Now"; btn.disabled = false; }
saveData();
render();
}
function render() {
if (isMinimized) {
container.className = 'minimized';
container.innerHTML = `$`;
container.onclick = () => { isMinimized = false; container.onclick = null; saveData(); render(); };
return;
}
container.className = '';
container.innerHTML = `
<div class="ms-header">
<strong>Market Stalker</strong>
<button class="ms-btn" id="ms-hide-btn" style="background:transparent; color:#888;">_</button>
</div>
<div class="ms-tabs">
<div class="ms-tab ${activeTab === 'items' ? 'active' : ''}" data-tab="items">Stalking</div>
<div class="ms-tab ${activeTab === 'settings' ? 'active' : ''}" data-tab="settings">Alert Rules</div>
</div>
<div class="ms-content" id="ms-body"></div>
<div style="padding: 10px; border-top: 1px solid #333;">
<button class="ms-btn ms-btn-primary" id="manual-refresh" style="width:100%">Check Now</button>
</div>
<datalist id="ms-item-datalist"></datalist>
`;
const body = document.getElementById('ms-body');
const dl = document.getElementById('ms-item-datalist');
if (itemDatabase) Object.values(itemDatabase).forEach(i => { const o = document.createElement('option'); o.value = i.name; dl.appendChild(o); });
if (activeTab === 'items') {
body.innerHTML = `
<div style="display:flex; gap:5px; margin-bottom:12px;">
<input type="text" id="add-item-name" class="ms-input" list="ms-item-datalist" placeholder="Item name..." style="margin:0">
<button class="ms-btn ms-btn-primary" id="add-item-btn">Add</button>
</div>
<div id="item-list-container"></div>
`;
const list = document.getElementById('item-list-container');
stalkedItems.forEach(item => {
const name = itemDatabase?.[item.id]?.name || `ID: ${item.id}`;
const trendIcon = item.trend === 'up' ? '<span class="price-up">▲</span>' : (item.trend === 'down' ? '<span class="price-down">▼</span>' : '');
let highlighted = false;
const t = thresholds[item.id];
if (t && item.lastPrice) {
if (t.direction === 'under' && item.lastPrice <= t.price) highlighted = true;
if (t.direction === 'over' && item.lastPrice >= t.price) highlighted = true;
}
const row = document.createElement('div');
row.className = `ms-item-row ${highlighted ? 'ms-item-highlight' : ''}`;
row.innerHTML = `
<div style="flex:1">
<div style="font-size:12px; font-weight:bold;">${name} ${trendIcon}</div>
<div style="font-size:11px; color:#aaa;">
$${item.lastPrice?.toLocaleString() || '---'}
${item.lastAmt ? `<span class="qty-text">Qty ${item.lastAmt.toLocaleString()}</span>` : ''}
</div>
</div>
<button class="ms-btn ms-btn-danger remove-stalked" data-id="${item.id}">×</button>
`;
list.appendChild(row);
});
} else {
body.innerHTML = `
<label style="font-size:10px; color:#aaa;">API CONFIG</label>
<input type="password" id="api-key-input" class="ms-input" value="${apiKey}" placeholder="Key...">
<button class="ms-btn ms-btn-success" id="sync-db-btn" style="width:100%; margin-bottom:15px;">Sync Items</button>
<hr style="border:0; border-top:1px solid #333; margin:10px 0;">
<label style="font-size:10px; color:#aaa;">NEW RULE</label>
<input type="text" id="rule-item-name" class="ms-input" list="ms-item-datalist" placeholder="Search Item...">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:5px; margin-bottom:10px;">
<select id="rule-direction" class="ms-input" style="margin:0">
<option value="under">Below</option>
<option value="over">Above</option>
</select>
<input type="text" id="rule-price-val" class="ms-input" placeholder="Price (k, m, b)" style="margin:0">
</div>
<button class="ms-btn ms-btn-primary" id="add-rule-btn" style="width:100%">Create Highlight Rule</button>
<div id="rules-list-container" style="margin-top:15px;"></div>
${Object.keys(thresholds).length > 0 ? '<button class="ms-btn ms-btn-danger" id="clear-rules-btn" style="width:100%; margin-top:10px; font-size:10px;">Clear All Rules</button>' : ''}
`;
const ruleList = document.getElementById('rules-list-container');
Object.keys(thresholds).forEach(id => {
const r = thresholds[id];
const row = document.createElement('div');
row.className = 'threshold-row';
row.style.borderLeftColor = r.direction === 'under' ? '#d9534f' : '#28a745';
const formattedPrice = (r.price || 0).toLocaleString();
row.innerHTML = `
<div>
<b>${itemDatabase?.[id]?.name || id}</b><br>
<span style="color:#aaa;">${r.direction === 'under' ? 'Under' : 'Over'} $${formattedPrice}</span>
</div>
<button class="ms-btn ms-btn-danger remove-rule" data-id="${id}">×</button>
`;
ruleList.appendChild(row);
});
const priceInput = document.getElementById('rule-price-val');
priceInput.addEventListener('input', (e) => {
const cursorPosition = e.target.selectionStart;
const originalLength = e.target.value.length;
const formatted = formatNumberWithCommas(e.target.value);
e.target.value = formatted;
const newLength = e.target.value.length;
e.target.setSelectionRange(cursorPosition + (newLength - originalLength), cursorPosition + (newLength - originalLength));
});
}
attachEvents();
}
function attachEvents() {
document.querySelectorAll('.ms-tab').forEach(t => t.onclick = () => { activeTab = t.dataset.tab; render(); });
document.getElementById('ms-hide-btn').onclick = (e) => { e.stopPropagation(); isMinimized = true; saveData(); render(); };
document.getElementById('manual-refresh').onclick = checkMarket;
if (activeTab === 'items') {
document.getElementById('add-item-btn').onclick = async () => {
const id = getItemIdByName(document.getElementById('add-item-name').value);
if (id) {
if (!stalkedItems.find(i => i.id === id)) {
stalkedItems.push({ id, lastPrice: null, lastAmt: null, lastUpdate: null, trend: null });
saveData();
render();
await fetchSingleItem(id);
}
}
};
document.querySelectorAll('.remove-stalked').forEach(b => b.onclick = () => {
stalkedItems = stalkedItems.filter(i => i.id !== b.dataset.id);
saveData(); render();
});
} else {
document.getElementById('sync-db-btn').onclick = () => {
const key = document.getElementById('api-key-input').value;
if (!key) return alert("Key required.");
GM_xmlhttpRequest({
method: "GET",
url: `https://api.torn.com/torn/?selections=items&key=${key}`,
onload: (r) => {
const data = JSON.parse(r.responseText);
itemDatabase = {};
Object.keys(data.items).forEach(id => { itemDatabase[id] = { name: data.items[id].name }; });
apiKey = key; saveData(); render(); alert("Synced.");
}
});
};
document.getElementById('add-rule-btn').onclick = () => {
const name = document.getElementById('rule-item-name').value;
const rawPrice = document.getElementById('rule-price-val').value.replace(/,/g, '');
const price = parseFloat(rawPrice);
const dir = document.getElementById('rule-direction').value;
const id = getItemIdByName(name);
if (id && !isNaN(price)) {
thresholds[id] = { price, direction: dir };
saveData(); render();
}
};
document.querySelectorAll('.remove-rule').forEach(b => b.onclick = () => { delete thresholds[b.dataset.id]; saveData(); render(); });
const clearBtn = document.getElementById('clear-rules-btn');
if (clearBtn) clearBtn.onclick = () => { if(confirm("Clear all?")) { thresholds = {}; saveData(); render(); } };
}
}
render();
setInterval(checkMarket, 60000);
})();