From 491d3201f8660cbc1bcbb8f46d85102ce202b54a Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 1 Jun 2026 16:41:01 -0500 Subject: [PATCH] feat(interceptor): XHR/fetch wrap to detect Train responses --- src/interceptor.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/interceptor.js diff --git a/src/interceptor.js b/src/interceptor.js new file mode 100644 index 0000000..4e0d46a --- /dev/null +++ b/src/interceptor.js @@ -0,0 +1,92 @@ +/** + * Wraps XHR and fetch to listen for the request Torn sends when the + * user clicks "Train". When such a request is detected, the response + * is parsed via `parseTrainResponse`, the new attribute value is + * compared against the previous one, and `onTrain({attr, delta, ts})` + * is invoked. + * + * `parseTrainResponse(text, url)` is intentionally permissive and + * returns `{ newValue, attr } | null`. The default implementation + * tries JSON first, then a regex fallback. + */ +export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) { + let lastValue = prevValue; + let warnedFor = null; + + const handle = (text, url) => { + const parsed = parseTrainResponse(text, url); + if (!parsed) { + if (warnedFor !== url) { + warnedFor = url; + onParseFail && onParseFail(url); + } + return; + } + if (parsed.attr !== currentAttr) return; + const delta = parsed.newValue - lastValue; + lastValue = parsed.newValue; + if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() }); + }; + + wrapXhr(handle); + wrapFetch(handle); + + return { + updatePrevValue(v) { lastValue = v; }, + }; +} + +function parseTrainResponse(text, url) { + // Try JSON + try { + const j = JSON.parse(text); + // Torn historically returns an HTML fragment; if it's JSON, look + // for a known shape. This is a placeholder — adjust after manual + // verification. + if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { + return { newValue: Number(j.newValue), attr: String(j.attr) }; + } + } catch { /* not JSON */ } + + // Fallback: scan text for a number formatted like an attribute. + const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); + if (m) { + const newValue = parseInt(m[1].replace(/,/g, ''), 10); + if (Number.isFinite(newValue) && newValue > 0) { + // Without a confirmed attr, fall back to whatever currentAttr + // the caller passed in. + return { newValue, attr: null }; + } + } + return null; +} + +function wrapXhr(handle) { + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this.__tatUrl = String(url); + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + this.addEventListener('load', () => { + try { handle(this.responseText, this.__tatUrl); } catch { /* noop */ } + }); + return origSend.apply(this, arguments); + }; +} + +function wrapFetch(handle) { + const origFetch = window.fetch; + if (origFetch.__tatWrapped) return; + window.fetch = async function (...args) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + const res = await origFetch.apply(this, args); + try { + const text = await res.clone().text(); + handle(text, String(url)); + } catch { /* noop */ } + return res; + }; + window.fetch.__tatWrapped = true; +}