Compare commits

...

7 Commits

5 changed files with 194 additions and 193 deletions
+1
View File
@@ -10,3 +10,4 @@ pnpm-debug.log*
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
gym-rendered.html
+49 -81
View File
@@ -3,104 +3,72 @@
* { attr: 'strength'|'speed'|..., current: number, gym: string } * { attr: 'strength'|'speed'|..., current: number, gym: string }
* or `null` if the page doesn't look like a Torn gym page. * or `null` if the page doesn't look like a Torn gym page.
* *
* The selectors below are best-effort matches for torn.com/gym.php * Torn's gym page is a React app using CSS modules with hash suffixes
* and will need adjustment if Torn changes the markup. * (e.g. class="strength___iXqEf", class="propertyValue___IYxjf"). This
* scraper targets Torn's actual structure rather than guessing at selectors.
*/ */
const KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
export function currentAttribute() { export function currentAttribute() {
// The attribute name is shown in the gym page header. const li = findActiveAttributeLi();
// Torn displays it as a capitalized word (e.g. "Strength") near the if (!li) return null;
// top of the gym form. const attr = extractAttrFromLi(li);
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
const ATTR_RE = new RegExp('\\b(' + KNOWN.join('|') + ')\\b');
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]');
let attr = null;
let attrEl = null;
for (const el of headers) {
const t = (el.textContent || '').trim().toLowerCase();
const m = t.match(ATTR_RE);
if (m) { attr = m[1]; attrEl = el; break; }
}
if (!attr) return null; if (!attr) return null;
const current = extractValueFromLi(li);
// Current value: look for the prominent number on the page that is
// formatted like a Torn attribute (e.g. "14,328,501"). Search near
// the attribute element so we don't pick up unrelated global numbers.
let valEl = findValueNear(attrEl);
if (!valEl) valEl = findValueElement(); // fallback: whole-page scan
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null; if (current == null) return null;
// Gym name: any element on the page containing the word "Gym" or
// "Bastion" / "Frontline" / etc. Torn's gym names vary.
const gym = findGymName() || 'Unknown gym'; const gym = findGymName() || 'Unknown gym';
return { attr, current, gym }; return { attr, current, gym };
} }
function findValueNear(el) { function findActiveAttributeLi() {
// Look at the element itself, then up to a few ancestors, then their descendants. // Priority 1: the <li> with the "success" class (just trained).
// Prefer the largest formatted number within ~2 parent levels. const lis = document.querySelectorAll('ul[class*="properties"] > li[class*="success"]');
const scope = []; for (const li of lis) {
let cur = el; if (extractAttrFromLi(li)) return li;
for (let depth = 0; depth < 3 && cur; depth++) {
scope.push(cur);
cur = cur.parentElement;
} }
let best = null; // Priority 2: the <li> corresponding to the .gained message's attribute.
let bestN = -Infinity; const gained = document.querySelector('[class*="gained"]');
for (const root of scope) { if (gained) {
const candidates = root.querySelectorAll('*'); const text = (gained.textContent || '').toLowerCase();
for (const c of candidates) { for (const attr of KNOWN_ATTRS) {
if (c.children.length > 0) continue; if (text.includes(attr)) {
const t = (c.textContent || '').trim(); const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; if (li) return li;
const n = parseNumber(t); }
if (n == null || n < 1) continue;
if (n > bestN) { best = c; bestN = n; }
} }
} }
return best; // Priority 3: the first <li> in the properties list.
const all = document.querySelectorAll('ul[class*="properties"] > li');
for (const li of all) {
if (extractAttrFromLi(li)) return li;
}
return null;
} }
function findValueElement() { function extractAttrFromLi(li) {
// Fallback only used when no element is found near the attribute. const cls = li.className || '';
// Walk all elements; pick the largest formatted number on the page. for (const attr of KNOWN_ATTRS) {
const candidates = document.querySelectorAll('*'); if (cls.split(/\s+/).some((c) => c.startsWith(attr + '___'))) return attr;
let best = null;
let bestN = -Infinity;
for (const el of candidates) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
const n = parseNumber(t);
if (n == null || n < 1) continue;
if (n > bestN) { best = el; bestN = n; }
} }
return best; return null;
}
function extractValueFromLi(li) {
const valueSpan = li.querySelector('[class^="propertyValue"]');
if (!valueSpan) return null;
return parseNumber(valueSpan.textContent);
} }
function findGymName() { function findGymName() {
// Look for a known set of Torn gym name fragments. Adjust as needed. // Find the currently selected gym button. It has the "active" class.
// Prefer an element that looks like the gym panel so we don't match const activeBtn = document.querySelector('button[class*="gymButton"][class*="active"]');
// against global widgets (news, sidebar, ads). if (activeBtn) {
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]'); const label = activeBtn.getAttribute('aria-label') || '';
const roots = panel ? [panel, document.body] : [document.body]; // aria-label format: "<Gym Name>. Membership cost - $X. Energy usage - N per train."
const known = [ // The gym name is everything before the first ". ".
'Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', const dot = label.indexOf('. ');
'Baldr\'s Gym', 'Sportscience Laboratory', 'Premier Fitness', if (dot !== -1) return label.slice(0, dot);
'Chrome Gym', 'Mr. Miyagi\'s', 'Power House', return label; // no period, return whole label as fallback
];
for (const root of roots) {
const all = root.querySelectorAll('h1, h2, h3, h4, p, span, div, li');
for (const el of all) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
for (const name of known) {
if (t.includes(name)) return name;
}
}
} }
return null; return null;
} }
+40 -20
View File
@@ -5,15 +5,16 @@
* compared against the previous one, and `onTrain({attr, delta, ts})` * compared against the previous one, and `onTrain({attr, delta, ts})`
* is invoked. * is invoked.
* *
* `parseTrainResponse(text, url)` is intentionally permissive and * `parseTrainResponse(text, url)` returns:
* returns `{ newValue, attr } | null`. The default implementation * { delta, attr } — when a "You gained X.XX <attr>" message is found
* tries JSON first, then a regex fallback. * { newValue, attr } — when a propertyValue span or JSON newValue is found
* null — when nothing usable is found
*/ */
export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) { export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) {
let lastValue = prevValue; let lastValue = prevValue;
let warnedFor = null; let warnedFor = null;
const handle = (text, url) => { function handle(text, url) {
const parsed = parseTrainResponse(text, url, currentAttr); const parsed = parseTrainResponse(text, url, currentAttr);
if (!parsed) { if (!parsed) {
if (warnedFor !== url) { if (warnedFor !== url) {
@@ -22,11 +23,22 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
} }
return; return;
} }
if (parsed.attr && currentAttr && parsed.attr !== currentAttr) return;
const delta = parsed.newValue - lastValue; let delta;
lastValue = parsed.newValue; if (typeof parsed.delta === 'number' && parsed.delta > 0) {
if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() }); delta = parsed.delta;
}; } else if (typeof parsed.newValue === 'number' && parsed.newValue > 0) {
delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
} else {
return;
}
if (delta <= 0) return;
const attr = parsed.attr || currentAttr;
if (!attr) return;
onTrain({ attr, delta, ts: Date.now() });
}
wrapXhr(handle); wrapXhr(handle);
wrapFetch(handle); wrapFetch(handle);
@@ -37,23 +49,31 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
} }
function parseTrainResponse(text, url, fallbackAttr) { function parseTrainResponse(text, url, fallbackAttr) {
// Try JSON // Strategy 1: look for the "gained" message in the response.
// Format: "You gained <number> <attr>" (e.g. "You gained 10,885.76 dexterity").
// Torn sometimes prefixes with other text (e.g. "You gained 10,885.76 dexterity"),
// so we match the number-and-attribute-name pattern directly.
const gainedMatch = text.match(/[Yy]ou\s+gained\s+([\d,]+(?:\.\d+)?)\s+(strength|defense|speed|dexterity|endurance|intelligence)\b/i);
if (gainedMatch) {
const delta = parseFloat(gainedMatch[1].replace(/,/g, ''));
const attr = gainedMatch[2].toLowerCase();
if (Number.isFinite(delta) && delta >= 0) {
return { delta, attr };
}
}
// Strategy 2: JSON with newValue + attr.
try { try {
const j = JSON.parse(text); 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) { if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) }; return { newValue: Number(j.newValue), attr: String(j.attr) };
} }
} catch { /* not JSON */ } } catch { /* not JSON */ }
// Strategy 3: regex fallback (last resort). Don't use the first number
// Regex fallback: scan text for a number formatted like an attribute. // blindly; look specifically for the propertyValue span content, which
// If we find one and the caller passed a fallbackAttr, use it; otherwise // is the authoritative source.
// the caller can choose to ignore the result. const propertyValueMatch = text.match(/class="propertyValue[^"]*"[^>]*>([\d,]+(?:\.\d+)?)</);
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); if (propertyValueMatch) {
if (m) { const newValue = parseInt(propertyValueMatch[1].replace(/,/g, ''), 10);
const newValue = parseInt(m[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) { if (Number.isFinite(newValue) && newValue > 0) {
return { newValue, attr: fallbackAttr || null }; return { newValue, attr: fallbackAttr || null };
} }
+8 -14
View File
@@ -4,9 +4,14 @@ import { currentAttribute } from './dom.js';
import { startRequestInterceptor } from './interceptor.js'; import { startRequestInterceptor } from './interceptor.js';
function findAnchorElement() { function findAnchorElement() {
// Try several selectors in priority order. Torn's gym page structure // Try several selectors in priority order. Torn's gym page renders the
// varies; we cast a wide net and return the first match. // training UI as <ul class="properties___HASH"> (the list of attribute
// rows). Anchor the dialog above that list.
const candidates = [ const candidates = [
'ul[class*="properties"]',
'[class*="gymContent"]',
'[class*="gymContentWrapper"]',
// Legacy fallbacks (kept in case Torn ever wraps the list in a form):
'form[action*="train"]', 'form[action*="train"]',
'form.train-form', 'form.train-form',
'form[class*="train"]', 'form[class*="train"]',
@@ -18,18 +23,7 @@ function findAnchorElement() {
]; ];
for (const sel of candidates) { for (const sel of candidates) {
const el = document.querySelector(sel); const el = document.querySelector(sel);
if (el) { if (el) return el;
// Prefer the form ancestor if the match is a button/link, since we
// want to anchor above the whole form, not just the button.
return el.closest('form') || el;
}
}
// Last-ditch: any element inside the gym panel that looks like the
// training form.
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]');
if (panel) {
const form = panel.querySelector('form');
if (form) return form;
} }
return null; return null;
} }
+96 -78
View File
@@ -122,76 +122,73 @@
} }
// ===== dom.js (embedded) ===== // ===== dom.js (embedded) =====
const TAT_KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
function currentAttribute() { function currentAttribute() {
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence']; const li = tatFindActiveAttributeLi();
const ATTR_RE = new RegExp('\\b(' + KNOWN.join('|') + ')\\b'); if (!li) return null;
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]'); const attr = tatExtractAttrFromLi(li);
let attr = null, attrEl = null;
for (const el of headers) {
const t = (el.textContent || '').trim().toLowerCase();
const m = t.match(ATTR_RE);
if (m) { attr = m[1]; attrEl = el; break; }
}
if (!attr) return null; if (!attr) return null;
let valEl = findValueNear(attrEl); const current = tatExtractValueFromLi(li);
if (!valEl) valEl = findValueElement();
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null; if (current == null) return null;
const gym = findGymName() || 'Unknown gym'; const gym = tatFindGymName() || 'Unknown gym';
return { attr: attr, current: current, gym: gym }; return { attr: attr, current: current, gym: tatEsc(gym) };
} }
function findValueNear(el) { function tatFindActiveAttributeLi() {
const scope = []; // Priority 1: the <li> with the "success" class (just trained).
let cur = el; const lis = document.querySelectorAll('ul[class*="properties"] > li[class*="success"]');
for (let depth = 0; depth < 3 && cur; depth++) { for (const li of lis) {
scope.push(cur); if (tatExtractAttrFromLi(li)) return li;
cur = cur.parentElement;
} }
let best = null, bestN = -Infinity; // Priority 2: the <li> corresponding to the .gained message's attribute.
for (const root of scope) { const gained = document.querySelector('[class*="gained"]');
const candidates = root.querySelectorAll('*'); if (gained) {
for (const c of candidates) { const text = (gained.textContent || '').toLowerCase();
if (c.children.length > 0) continue; for (const attr of TAT_KNOWN_ATTRS) {
const t = (c.textContent || '').trim(); if (text.indexOf(attr) !== -1) {
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
const n = parseNumber(t); if (li) return li;
if (n == null || n < 1) continue; }
if (n > bestN) { best = c; bestN = n; }
} }
} }
return best; // Priority 3: the first <li> in the properties list.
} const all = document.querySelectorAll('ul[class*="properties"] > li');
function findValueElement() { for (const li of all) {
const candidates = document.querySelectorAll('*'); if (tatExtractAttrFromLi(li)) return li;
let best = null, bestN = -Infinity;
for (const el of candidates) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
const n = parseNumber(t);
if (n == null || n < 1) continue;
if (n > bestN) { best = el; bestN = n; }
} }
return best; return null;
} }
function findGymName() { function tatExtractAttrFromLi(li) {
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]'); const cls = li.className || '';
const roots = panel ? [panel, document.body] : [document.body]; const parts = cls.split(/\s+/);
const known = ['Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', "Baldr's Gym", 'Sportscience Laboratory', 'Premier Fitness', 'Chrome Gym', "Mr. Miyagi's", 'Power House']; for (const attr of TAT_KNOWN_ATTRS) {
for (const root of roots) { const prefix = attr + '___';
const all = root.querySelectorAll('h1, h2, h3, h4, p, span, div, li'); for (const c of parts) {
for (const el of all) { if (c.indexOf(prefix) === 0) return attr;
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
for (const name of known) { if (t.includes(name)) return name; }
} }
} }
return null; return null;
} }
function parseNumber(text) { function tatExtractValueFromLi(li) {
const valueSpan = li.querySelector('[class^="propertyValue"]');
if (!valueSpan) return null;
return tatParseNumber(valueSpan.textContent);
}
function tatFindGymName() {
// Find the currently selected gym button. It has the "active" class.
const activeBtn = document.querySelector('button[class*="gymButton"][class*="active"]');
if (activeBtn) {
const label = activeBtn.getAttribute('aria-label') || '';
// aria-label format: "<Gym Name>. Membership cost - $X. Energy usage - N per train."
// The gym name is everything before the first ". ".
const dot = label.indexOf('. ');
if (dot !== -1) return label.slice(0, dot);
return label; // no period, return whole label as fallback
}
return null;
}
function tatParseNumber(text) {
if (!text) return null; if (!text) return null;
const cleaned = text.replace(/,/g, '').trim(); const cleaned = String(text).replace(/,/g, '').trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null; if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
const n = parseFloat(cleaned); const n = parseFloat(cleaned);
return Number.isFinite(n) ? Math.floor(n) : null; return Number.isFinite(n) ? Math.floor(n) : null;
@@ -204,25 +201,52 @@
function handle(text, url) { function handle(text, url) {
const parsed = parseTrainResponse(text, url, opts.currentAttr); const parsed = parseTrainResponse(text, url, opts.currentAttr);
if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; } if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; }
if (parsed.attr && opts.currentAttr && parsed.attr !== opts.currentAttr) return; let delta;
const delta = parsed.newValue - lastValue; if (typeof parsed.delta === 'number' && parsed.delta > 0) {
lastValue = parsed.newValue; delta = parsed.delta;
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() }); } else if (typeof parsed.newValue === 'number' && parsed.newValue > 0) {
delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
} else {
return;
}
if (delta <= 0) return;
const attr = parsed.attr || opts.currentAttr;
if (!attr) return;
opts.onTrain({ attr: attr, delta: delta, ts: Date.now() });
} }
wrapXhr(handle); wrapFetch(handle); wrapXhr(handle); wrapFetch(handle);
return { updatePrevValue: function (v) { lastValue = v; } }; return { updatePrevValue: function (v) { lastValue = v; } };
} }
function parseTrainResponse(text, url, fallbackAttr) { function parseTrainResponse(text, url, fallbackAttr) {
// Strategy 1: look for the "gained" message in the response.
// Format: "You gained <number> <attr>" (e.g. "You gained 10,885.76 dexterity").
// Torn sometimes prefixes with other text (e.g. "You gained 10,885.76 dexterity"),
// so we match the number-and-attribute-name pattern directly.
const gainedMatch = text.match(/[Yy]ou\s+gained\s+([\d,]+(?:\.\d+)?)\s+(strength|defense|speed|dexterity|endurance|intelligence)\b/i);
if (gainedMatch) {
const delta = parseFloat(gainedMatch[1].replace(/,/g, ''));
const attr = gainedMatch[2].toLowerCase();
if (Number.isFinite(delta) && delta >= 0) {
return { delta: delta, attr: attr };
}
}
// Strategy 2: JSON with newValue + attr.
try { try {
const j = JSON.parse(text); const j = JSON.parse(text);
if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) }; return { newValue: Number(j.newValue), attr: String(j.attr) };
} }
} catch {} } catch {}
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); // Strategy 3: regex fallback (last resort). Don't use the first number
if (m) { // blindly; look specifically for the propertyValue span content, which
const newValue = parseInt(m[1].replace(/,/g, ''), 10); // is the authoritative source.
if (Number.isFinite(newValue) && newValue > 0) return { newValue: newValue, attr: fallbackAttr || null }; const propertyValueMatch = text.match(/class="propertyValue[^"]*"[^>]*>([\d,]+(?:\.\d+)?)</);
if (propertyValueMatch) {
const newValue = parseInt(propertyValueMatch[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) {
return { newValue: newValue, attr: fallbackAttr || null };
}
} }
return null; return null;
} }
@@ -414,9 +438,14 @@
// ===== main.js (embedded) ===== // ===== main.js (embedded) =====
function findAnchorElement() { function findAnchorElement() {
// Try several selectors in priority order. Torn's gym page structure // Try several selectors in priority order. Torn's gym page renders the
// varies; we cast a wide net and return the first match. // training UI as <ul class="properties___HASH"> (the list of attribute
// rows). Anchor the dialog above that list.
const candidates = [ const candidates = [
'ul[class*="properties"]',
'[class*="gymContent"]',
'[class*="gymContentWrapper"]',
// Legacy fallbacks (kept in case Torn ever wraps the list in a form):
'form[action*="train"]', 'form[action*="train"]',
'form.train-form', 'form.train-form',
'form[class*="train"]', 'form[class*="train"]',
@@ -428,18 +457,7 @@
]; ];
for (const sel of candidates) { for (const sel of candidates) {
const el = document.querySelector(sel); const el = document.querySelector(sel);
if (el) { if (el) return el;
// Prefer the form ancestor if the match is a button/link, since we
// want to anchor above the whole form, not just the button.
return el.closest('form') || el;
}
}
// Last-ditch: any element inside the gym panel that looks like the
// training form.
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]');
if (panel) {
const form = panel.querySelector('form');
if (form) return form;
} }
return null; return null;
} }