feat(interceptor): XHR/fetch wrap to detect Train responses
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user