Last active
August 18, 2026 18:42
-
-
Save bzimor/ce92c4d87b8c6529ab4de467d96a1c60 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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