feat(interceptor): XHR/fetch wrap to detect Train responses

This commit is contained in:
dev
2026-06-01 16:41:01 -05:00
parent aec9c40835
commit 491d3201f8
+92
View File
@@ -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;
}