Skip to content

Instantly share code, notes, and snippets.

@bzimor
Last active August 18, 2026 18:42
Show Gist options
  • Select an option

  • Save bzimor/ce92c4d87b8c6529ab4de467d96a1c60 to your computer and use it in GitHub Desktop.

Select an option

Save bzimor/ce92c4d87b8c6529ab4de467d96a1c60 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Auction item parser
// @author RyuFive (parsing), bzimor (auth, multi-tab paging, top pagination)
// @match https://www.torn.com/amarket.php*
// @namespace https://torn.bzimor.dev/
// @version 2.0
// @description Sends the weapons/armor auction listings you browse to torn.bzimor.dev
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect torn.bzimor.dev
// @connect localhost
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const ENDPOINT = 'https://torn.bzimor.dev/api/auction_house_item';
// const ENDPOINT = 'http://127.0.0.1:8000/api/auction_house_item';
const KEY_STORE = 'torn_api_key';
// Only the two tabs that hold UID items. The third tab ("items") lists
// ordinary stock, which the server rejects as not-a-weapon -- and one bad
// row fails the whole batch.
const PANEL_SELECTOR = '.tabContent[data-itemtype="weapons"], .tabContent[data-itemtype="armor"]';
const ROW_SELECTOR = 'div.items-list-wrap > ul.items-list > li';
const SETTLE_MS = 400; // quiet time after the last DOM change before scraping
const MAX_BATCH = 100; // the server rejects anything larger
const MAX_RETRIES = 2;
const RETRY_AFTER_MS = 30000; // after a batch fails outright
// Dedup is by listing, not by page number. v1.4 kept a list of `start`
// values, which does not say *which* tab that offset belonged to, so after
// walking the weapons pages every armor page looked "already done" until
// you reloaded. A fingerprint of uid + end time is tab-agnostic, survives
// going back to a page you have already seen or Torn re-rendering it, and
// still re-sends an item that gets relisted with a new end time.
//
// This map, not the marker on the row, is what decides whether a listing
// has been handled: the marker belongs to a DOM node that Torn is free to
// recycle, so trusting it can hide a listing that only looks familiar.
const handled = new Map(); // fingerprint -> 'sent' | 'rejected'
let apiKey = String(GM_getValue(KEY_STORE, '') || '').trim();
let keyRejected = false;
let timer = null;
let running = false;
// --- API key ------------------------------------------------------------
function isWellFormedKey(value) {
return /^[A-Za-z0-9]{16}$/.test(value);
}
function askForKey() {
const entered = window.prompt(
'Torn API key for torn.bzimor.dev\n\n' +
'A "Public Only" key is enough - it only identifies you, and the server never stores it.\n' +
'Leave empty to stop sending.',
apiKey
);
if (entered === null) return; // cancelled
const trimmed = entered.trim();
if (trimmed && !isWellFormedKey(trimmed)) {
notify('That is not a 16-character Torn API key.', true);
return;
}
apiKey = trimmed;
keyRejected = false;
GM_setValue(KEY_STORE, trimmed);
notify(trimmed ? 'API key saved.' : 'API key cleared - nothing will be sent.');
if (trimmed) schedule(); // pick up whatever is on screen now
}
GM_registerMenuCommand('Set Torn API key', askForKey);
// --- row marking --------------------------------------------------------
//
// State lives in a data attribute styled from one injected stylesheet, so
// nothing is inserted into Torn's rows: v1.4 did `innerHTML += "DONE"`,
// which re-parses the row and throws away Torn's own listeners on the bid
// controls inside it. The stripe is drawn with an inset box-shadow rather
// than a border or a badge, so no box changes size and no absolutely
// positioned child of the row moves.
const ROW_STYLE = `
li[data-ah-state] { transition: background-color .2s ease; }
li[data-ah-state="pending"] {
box-shadow: inset 4px 0 0 #c9a227;
background-color: rgba(201,162,39,.10);
}
li[data-ah-state="sent"] {
box-shadow: inset 4px 0 0 #3f9c3f;
background-color: rgba(63,156,63,.09);
}
li[data-ah-state="rejected"] {
box-shadow: inset 4px 0 0 #b34a4a;
background-color: rgba(179,74,74,.11);
}
.ah-pagination-top { margin-bottom: 6px; }
`;
const STATE_TITLES = {
pending: 'Sending to torn.bzimor.dev...',
sent: 'Sent to torn.bzimor.dev',
rejected: 'Rejected by torn.bzimor.dev - see the browser console',
};
function injectStyle() {
if (document.getElementById('ah-parser-style')) return;
const style = document.createElement('style');
style.id = 'ah-parser-style';
style.textContent = ROW_STYLE;
document.head.appendChild(style);
}
function setState(row, state) {
if (!state) {
delete row.node.dataset.ahState;
row.node.removeAttribute('title');
return;
}
row.node.dataset.ahState = state;
row.node.title = STATE_TITLES[state];
}
// --- scraping -----------------------------------------------------------
/**
* "Ends on HH:MM:SS - DD/MM/YY" -> unix seconds.
*
* Torn renders this in TCT, which is UTC, so it is built with Date.UTC and
* sent as an epoch: no format or time-zone assumption is left for the
* server to make. The raw string is the fallback if Torn ever changes it.
*/
function endTimeToEpoch(title) {
const match = title.match(/(\d{2}):(\d{2}):(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})/);
if (!match) return null;
const [, hh, mm, ss, dd, mo, yy] = match.map(Number);
return Math.floor(Date.UTC(2000 + yy, mo - 1, dd, hh, mm, ss) / 1000);
}
function scrapeRows() {
const rows = [];
const nowEpoch = Math.floor(Date.now() / 1000);
document.querySelectorAll(PANEL_SELECTOR).forEach(panel => {
panel.querySelectorAll(ROW_SELECTOR).forEach(li => {
// 'pending' means a request for this row is still in flight.
if (!li.id || li.dataset.ahState === 'pending') return;
const hover = li.querySelector('span.item-hover');
const uid = Number(hover && hover.getAttribute('armoury'));
const itemId = Number(hover && hover.getAttribute('item'));
// div.name, not the whole seller-wrap: div.namehight right below
// it holds the *high bidder*, and a row whose seller link is
// missing would otherwise be filed under the bidder.
const sellerLink = li.querySelector('div.seller-wrap div.name a[href*="XID="]')
|| li.querySelector('div.mob-wrap .seller-mob-wrap a[href*="XID="]');
const sellerMatch = sellerLink && sellerLink.getAttribute('href').match(/XID=(\d+)/);
const seller = sellerMatch ? Number(sellerMatch[1]) : 0;
const timeEl = li.querySelector('div.time-wrap span[title]');
const endTitle = timeEl ? (timeEl.getAttribute('title') || '').trim() : '';
const endEpoch = endTitle ? endTimeToEpoch(endTitle) : null;
// A half-rendered row is left untouched on purpose: no state
// marker, so the next observer pass picks it up once Torn has
// filled it in.
if (!uid || !itemId || !seller || !endTitle) return;
// The inactive tab keeps whatever was loaded into it earlier,
// which can be an auction that has since finished. Dropping it
// here keeps stale listings out of the database.
if (endEpoch !== null && endEpoch < nowEpoch) return;
rows.push({
node: li,
fingerprint: uid + '@' + endTitle,
payload: {
uuid: uid,
item_id: itemId,
seller: seller,
auction_endtime: endEpoch === null ? endTitle : String(endEpoch),
}
});
});
});
return rows;
}
// --- pagination above the list -----------------------------------------
//
// Torn renders the pager only under the list. This clones it above, and
// forwards clicks to the original anchors instead of trying to reimplement
// paging: the clone has none of Torn's jQuery handlers, but its twin does.
const clonedFrom = new WeakMap(); // panel -> pager markup the clone was built from
function syncPagination() {
document.querySelectorAll(PANEL_SELECTOR).forEach(panel => {
const list = panel.querySelector(':scope > div.items-list-wrap');
const original = panel.querySelector(':scope > div.pagination-wrap');
if (!list || !original) return;
const source = original.innerHTML;
let clone = panel.querySelector(':scope > .ah-pagination-top');
// An unloaded tab ships an empty pager; nothing to mirror yet.
if (!original.querySelector('a')) {
if (clone) clone.remove();
clonedFrom.delete(panel);
return;
}
if (clone && clonedFrom.get(panel) === source) return;
const fresh = document.createElement('div');
// Deliberately *not* class="pagination-wrap": Torn's own pagination
// code looks that class up, and it must keep finding exactly one.
fresh.className = 'ah-pagination-top';
fresh.style.display = original.style.display || '';
fresh.innerHTML = source;
fresh.addEventListener('click', forwardPagerClick, true);
if (clone) clone.replaceWith(fresh);
else panel.insertBefore(fresh, list);
clonedFrom.set(panel, source);
});
}
function forwardPagerClick(event) {
const anchor = event.target.closest('a');
if (!anchor) return;
event.preventDefault();
event.stopPropagation();
const clone = event.currentTarget;
const panel = clone.parentElement;
const original = panel && panel.querySelector(':scope > div.pagination-wrap');
if (!original) return;
const cloneAnchors = Array.from(clone.querySelectorAll('a'));
const originalAnchors = Array.from(original.querySelectorAll('a'));
// Positional first -- the clone is a deep copy, so index i is the same
// control. href is the fallback for a pager that has re-rendered
// underneath us; several anchors share page="1", so that attribute
// alone is not a key.
let twin = cloneAnchors.length === originalAnchors.length
? originalAnchors[cloneAnchors.indexOf(anchor)]
: null;
if (!twin && anchor.getAttribute('href')) {
twin = originalAnchors.find(a => a.getAttribute('href') === anchor.getAttribute('href'));
}
if (twin) twin.click();
}
// --- tiny on-page status line -------------------------------------------
let toast = null;
let toastTimer = null;
function notify(message, isError) {
if (!toast) {
toast = document.createElement('div');
toast.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:2147483647',
'padding:6px 10px', 'border-radius:4px', 'font:12px/1.4 Arial, sans-serif',
'color:#fff', 'max-width:280px', 'pointer-events:none',
'box-shadow:0 1px 4px rgba(0,0,0,.4)'
].join(';');
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.background = isError ? '#a33' : '#357';
toast.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.style.display = 'none'; }, 4000);
}
// --- sending ------------------------------------------------------------
function post(batch) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'POST',
url: ENDPOINT,
headers: {
'Content-Type': 'application/json',
// Header, not body: it stays out of the URL, and so out of
// access logs and any Referer the page sends.
'X-Torn-Key': apiKey
},
data: JSON.stringify(batch.map(row => row.payload)),
timeout: 20000,
onload: response => resolve({ status: response.status, body: response.responseText }),
onerror: () => resolve({ status: 0, body: 'network error' }),
ontimeout: () => resolve({ status: 0, body: 'timeout' })
});
});
}
async function send(batch) {
batch.forEach(row => setState(row, 'pending'));
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const result = await post(batch);
if (result.status === 200 || result.status === 201) {
batch.forEach(row => { handled.set(row.fingerprint, 'sent'); setState(row, 'sent'); });
notify(`Sent ${batch.length} listing${batch.length === 1 ? '' : 's'}.`);
return true;
}
if (result.status === 401 || result.status === 403) {
keyRejected = true;
batch.forEach(row => setState(row, null));
notify('Torn key rejected. Tampermonkey menu -> Set Torn API key.', true);
return false;
}
if (result.status === 400) {
// Our payload is wrong; a retry sends the same bytes. Mark the
// batch so one bad row cannot wedge the session. The response
// carries one entry per row, so the console says which row.
batch.forEach(row => { handled.set(row.fingerprint, 'rejected'); setState(row, 'rejected'); });
console.error('[AH parser] rejected:', result.body);
notify('Server rejected the batch (see console).', true);
return false;
}
// 429 / 5xx / network: back off and try again.
if (attempt < MAX_RETRIES) {
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
} else {
batch.forEach(row => setState(row, null)); // retriable: leave them unmarked
console.error('[AH parser] giving up:', result.status, result.body);
notify('Could not reach torn.bzimor.dev.', true);
}
}
return false;
}
// --- scheduling ---------------------------------------------------------
async function run() {
injectStyle();
syncPagination();
if (running) { schedule(); return; }
const rows = scrapeRows().filter(row => {
const state = handled.get(row.fingerprint);
if (!state) return true;
setState(row, state); // same listing, freshly re-rendered by Torn
return false;
});
if (!rows.length) return;
if (!apiKey) {
notify('Set your Torn API key first: Tampermonkey menu -> Set Torn API key.', true);
return;
}
if (keyRejected) return; // stay quiet until a new key is entered
running = true;
try {
for (let i = 0; i < rows.length; i += MAX_BATCH) {
if (!await send(rows.slice(i, i + MAX_BATCH))) {
// A transient failure records no fingerprint, so this picks
// the rows up again without waiting for the DOM to move.
if (!keyRejected) setTimeout(schedule, RETRY_AFTER_MS);
break;
}
}
} finally {
running = false;
}
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(run, SETTLE_MS);
}
// Every page, tab switch and sort on amarket.php is an ajax re-render of
// the list in place, so a debounced observer -- not a one-shot on load --
// is what keeps up with it. This replaces the waitForKeyElements gist,
// which fired once per newly seen element and knew nothing about tabs.
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
window.addEventListener('hashchange', schedule);
schedule();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment