Parse Ranked War Report, value caches via Torn API, subtract faction cut, divide by hits to estimate pay per hit for both teams.
// ==UserScript==
// @name Torn War Pay Estimator (Caches → Pay/Hit)
// @namespace https://torn.example/killercleat/war-pay // any URL works
// @version 2025.09.12.10
// @author KillerCleat [2842410]
// @description Parse Ranked War Report, value caches via Torn API, subtract faction cut, divide by hits to estimate pay per hit for both teams.
// @match https://www.torn.com/war.php?step=rankreport*
// @icon https://www.torn.com/favicon.ico
// @license MIT
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
//
// --------------------------- NOTES & REQUIREMENTS ---------------------------
// • Reads team cache rewards and sums Attacks from the two member tables.
// • Values caches using Torn items endpoint (market_value). Cached 24h in storage.
// • Configurable faction cut (%) and API key via Tampermonkey menu.
// • If API fetch fails or times out, prints on-screen note to refresh in ~1 min.
// • Caches recognized: Armor Cache, Heavy Arms Cache, Medium Arms Cache, Melee Cache, Small Arms Cache.
// • All math: (Sum(cache_value) * qty) → subtotal; then subtract faction_cut% → pool; pool / total_hits(team) = pay_per_hit.
// • This is an estimator. Torn UI and endpoints can change; script is defensive where possible.
// ---------------------------------------------------------------------------
// CHANGELOG:
// 2025-09-12: Initial release.
// ---------------------------------------------------------------------------
// ==/UserScript==
(function () {
'use strict';
// ---------------------- Config & storage ----------------------
const LS = {
apiKey: 'warpay_api_key',
cut: 'warpay_cut_pct',
price: 'warpay_price_cache_v1', // { timestamp, prices: { name: value } }
};
const DEF = {
cutPct: 20,
timeout: 8000,
dayMs: 86400000,
cacheNames: [
'Armor Cache',
'Heavy Arms Cache',
'Medium Arms Cache',
'Melee Cache',
'Small Arms Cache',
],
};
// ---------------------- Menu ----------------------
GM_registerMenuCommand('Set Torn API Key', () => {
const cur = GM_getValue(LS.apiKey, '');
const next = prompt('Enter your Torn API key (Limited Access):', cur || '');
if (next !== null) GM_setValue(LS.apiKey, next.trim());
location.reload();
});
GM_registerMenuCommand('Set Faction Cut %', () => {
const cur = Number(GM_getValue(LS.cut, DEF.cutPct));
const next = prompt('Enter faction cut percent (e.g. 20):', String(cur));
if (next !== null && !Number.isNaN(Number(next))) {
GM_setValue(LS.cut, Number(next));
location.reload();
}
});
GM_registerMenuCommand('Refresh Prices Now', async () => {
await fetchPrices(true).catch(() => {});
location.reload();
});
// ---------------------- Styles ----------------------
GM_addStyle(`
.warpay-card{
background:#171717;border:1px solid #3c3c3c;color:#eee;border-radius:8px;
padding:10px 12px;margin:10px 0 18px 0;box-shadow:0 3px 14px rgba(0,0,0,.25)
}
.warpay-card .hl{color:#fff;font-weight:700}
.warpay-row{margin:4px 0}
.warpay-small{opacity:.8;font-size:12px}
.warpay-mono{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}
.warpay-dash{margin-top:6px;border-top:1px dashed #555;padding-top:6px}
.warpay-warn{color:#ffd25c}
.warpay-bad{color:#ff7a7a}
.warpay-good{color:#6bff8a}
`);
// ---------------------- Helpers ----------------------
const money = n => '$' + Math.round(n).toLocaleString();
const cutPct = () => Number(GM_getValue(LS.cut, DEF.cutPct)) || DEF.cutPct;
const getJSON = (url, timeout) =>
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout,
onload: r => {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('PARSE_FAIL')); }
},
ontimeout: () => reject(new Error('TIMEOUT')),
onerror: () => reject(new Error('XHR_ERROR')),
});
});
const loadPricesBlob = () => {
const raw = GM_getValue(LS.price, null);
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
};
const savePricesBlob = blob => GM_setValue(LS.price, JSON.stringify(blob));
async function fetchPrices(force = false) {
const cached = loadPricesBlob();
if (!force && cached && (Date.now() - cached.timestamp) < DEF.dayMs) {
return cached.prices;
}
const key = (GM_getValue(LS.apiKey, '') || '').trim();
if (!key) throw new Error('NO_API_KEY');
const url = `https://api.torn.com/torn/?selections=items&key=${encodeURIComponent(key)}`;
const json = await getJSON(url, DEF.timeout);
if (!json || !json.items) throw new Error('BAD_ITEMS');
const out = {};
for (const id in json.items) {
const it = json.items[id];
if (DEF.cacheNames.includes(it.name)) out[it.name] = Number(it.market_value) || 0;
}
// ensure all names present (0 if missing)
DEF.cacheNames.forEach(n => { if (!(n in out)) out[n] = 0; });
savePricesBlob({ timestamp: Date.now(), prices: out });
return out;
}
// ---------------------- Page parsing ----------------------
// Award sentence → { team, caches: {name: qty} }
function parseAwardText(txt) {
const t = txt.replace(/\s+/g, ' ').trim();
// Team name until " ranked"/" remained"/" moved"
const head = t.match(/^([A-Za-z0-9'()[\].\- ]+?)\s+(?:ranked|remained|moved)\b/i);
if (!head) return null;
const team = head[1].trim();
const caches = {};
const re = /(\d+)x\s+(Armor Cache|Heavy Arms Cache|Medium Arms Cache|Melee Cache|Small Arms Cache)/gi;
let m; while ((m = re.exec(t)) !== null) {
caches[m[2]] = (caches[m[2]] || 0) + Number(m[1]);
}
return { team, caches };
}
// Find the two award lines (leaves that contain “received … Cache”)
function findAwardElements() {
const all = Array.from(document.querySelectorAll('div, p, li, span'));
const hits = [];
for (const el of all) {
const text = (el.textContent || '').trim();
if (!/received/i.test(text) || !/Cache/i.test(text)) continue;
// ignore container elements that only wrap children with same text
const childHas = Array.from(el.children).some(c =>
/received/i.test((c.textContent || '')) && /Cache/i.test((c.textContent || ''))
);
if (childHas) continue;
const parsed = parseAwardText(text);
if (parsed) hits.push({ el, ...parsed });
}
// Keep first unique by team, max 2
const out = [];
const seen = new Set();
for (const h of hits) {
if (seen.has(h.team)) continue;
seen.add(h.team);
out.push(h);
if (out.length === 2) break;
}
return out;
}
// Build an index of elements that contain a team name (for proximity mapping)
function indexTeamHeaders(teamNames) {
const names = [...teamNames].sort((a, b) => b.length - a.length); // longer first
const all = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,div,span,p,li,strong,b'));
const res = [];
for (const el of all) {
const txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!txt) continue;
for (const name of names) {
if (txt.toLowerCase().includes(name.toLowerCase())) {
res.push({ name, el, rect: el.getBoundingClientRect() });
break;
}
}
}
return res;
}
// Read total attacks from a members <ul class="members-list ...">
function readHitsFromList(list) {
if (!list) return null;
// Prefer "Total" row if present
const items = Array.from(list.querySelectorAll(':scope > li'));
const totalRow = [...items].reverse().find(li => /Total/i.test(li.textContent || ''));
const parseNum = n => {
if (!n) return null;
const v = Number((n.textContent || '').replace(/[, ]+/g, ''));
return Number.isFinite(v) ? v : null;
};
const total = parseNum(totalRow?.querySelector('.points'));
if (Number.isFinite(total)) return total;
// Fallback: sum each row’s Attacks column ('.points')
let sum = 0;
for (const li of items) {
const v = parseNum(li.querySelector('.points'));
if (Number.isFinite(v)) sum += v;
}
return sum || null;
}
// Find all team lists and map them to the nearest header that contains a team name
function mapListsToTeamNames(teamNames) {
const lists = Array.from(document.querySelectorAll('.members-list.membersCont___USwcq.report___srhC_'));
if (lists.length === 0) return {};
const headers = indexTeamHeaders(teamNames);
const out = {};
for (const list of lists) {
const targetTop = list.getBoundingClientRect().top;
// choose header with smallest vertical distance
let best = null, bestDist = Infinity;
for (const h of headers) {
const d = Math.abs((h.rect?.top || 0) - targetTop);
if (d < bestDist) { bestDist = d; best = h; }
}
const team = best?.name || null;
if (!team) continue;
out[team] = readHitsFromList(list) ?? 0;
}
return out;
}
// ---------------------- Render ----------------------
function renderCardHTML(team, caches, hits, prices, ts) {
const pct = cutPct();
const lines = [];
let subtotal = 0;
for (const name of DEF.cacheNames) {
const qty = caches[name] || 0;
if (!qty) continue;
const unit = prices ? (prices[name] || 0) : 0;
const val = unit * qty;
subtotal += val;
lines.push(`${qty}× ${name} @ ${money(unit)} = <span class="warpay-mono">${money(val)}</span>`);
}
const cutAmt = subtotal * (pct / 100);
const pool = subtotal - cutAmt;
const pph = hits > 0 ? pool / hits : 0;
return `
<div class="warpay-card">
<div class="warpay-small">Using item prices ${ts ? ('from ' + new Date(ts).toLocaleString()) : '(no prices)'}.</div>
<div class="warpay-row"><span class="hl">${team}</span></div>
<div class="warpay-row warpay-small">${lines.length ? lines.join(' · ') : 'No caches parsed.'}</div>
<div class="warpay-row warpay-dash">
caches est. value: <span class="hl">${money(subtotal)}</span><br/>
Faction cut at ${pct}%: <span class="hl">${money(cutAmt)}</span><br/>
Team hits counted: <span class="hl">${(hits || 0).toLocaleString()}</span><br/>
Pay per hit est.: <span class="hl warpay-good">${money(pph)}</span>
</div>
</div>`;
}
function insertAfter(node, html) {
const wrap = document.createElement('div');
wrap.innerHTML = html;
const card = wrap.firstElementChild;
node.parentNode.insertBefore(card, node.nextSibling);
}
// ---------------------- Main ----------------------
async function runOnce() {
// Clean previous runs
document.querySelectorAll('.warpay-card').forEach(n => n.remove());
// 1) Find the two awards (with faction names)
const awards = findAwardElements(); // [{el, team, caches}, ...]
if (awards.length !== 2) return false; // try again shortly
const teamNames = awards.map(a => a.team);
// 2) Read prices (cached) or fallback to last saved
let prices = null, ts = null, priceWarn = '';
try {
prices = await fetchPrices(false);
ts = loadPricesBlob()?.timestamp || null;
} catch (e) {
const cached = loadPricesBlob();
if (cached?.prices) {
prices = cached.prices; ts = cached.timestamp || null;
priceWarn = `<div class="warpay-row warpay-warn warpay-small">Price lookup failed; using last saved prices. You can refresh via menu.</div>`;
} else {
priceWarn = `<div class="warpay-row warpay-bad warpay-small">No prices available. Set your API key in the Tampermonkey menu.</div>`;
}
}
// 3) Map lists → team names and read hits
const hitsByTeam = mapListsToTeamNames(teamNames); // { name: hits }
// If we only got one or zero, try a simple left-to-right fallback
if (Object.keys(hitsByTeam).length < 2) {
const lists = Array.from(document.querySelectorAll('.members-list.membersCont___USwcq.report___srhC_'));
if (lists.length >= 2) {
const leftHits = readHitsFromList(lists[0]) || 0;
const rightHits = readHitsFromList(lists[1]) || 0;
if (!(teamNames[0] in hitsByTeam)) hitsByTeam[teamNames[0]] = leftHits;
if (!(teamNames[1] in hitsByTeam)) hitsByTeam[teamNames[1]] = rightHits;
}
}
// 4) Render one card under each award line
for (const a of awards) {
const html =
renderCardHTML(a.team, a.caches, hitsByTeam[a.team] || 0, prices, ts) +
(priceWarn || '');
insertAfter(a.el, html);
}
return true;
}
// Try a few times while React finishes rendering
let tries = 0;
const tick = async () => {
tries++;
const ok = await runOnce().catch(() => false);
if (!ok && tries < 8) setTimeout(tick, 600);
};
tick();
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址