initial commit

This commit is contained in:
2026-06-25 21:26:53 +00:00
commit e5a1511098
68 changed files with 186669 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
// crafts.js
(() => {
const API = 'https://api.tarkov.dev/graphql'; // official GraphQL endpoint (playground at /)
// ---------- GraphQL ----------
// 1) All crafts: structure only (station, required items, rewards).
// NOTE: No variables here—keeps it compatible and avoids "unused variable" errors.
const CRAFTS_Q = `
query AllCrafts {
crafts {
id
duration
station { name } # station.level is not in the public schema we saw—just show the name.
requiredItems {
count
item { id name }
}
rewardItems {
count
item { id name }
}
}
}
`;
// 2) Items by IDs with prices for a specific mode (regular/pve)
// Official examples show avg24hPrice and sellFor { price, source } on items.
const ITEMS_BY_IDS_Q = `
query ItemsByIds($ids: [ID!], $mode: GameMode, $lang: LanguageCode) {
items(ids: $ids, gameMode: $mode, lang: $lang) {
id
name
avg24hPrice
sellFor { price source }
}
}
`;
// ---------- DOM ----------
const modeRadios = Array.from(document.querySelectorAll('input[name="hc-mode"]'));
const metricSel = document.getElementById('hc-metric');
const topNInput = document.getElementById('hc-topn');
const refreshBtn = document.getElementById('hc-refresh');
const updatedEl = document.getElementById('hc-updated');
const statusEl = document.getElementById('hc-status');
const tbody = document.getElementById('hc-tbody');
const chartCanvas = document.getElementById('hc-chart');
const applyFeeChk = document.getElementById('hc-applyfee');
const feePctInput = document.getElementById('hc-fee');
let chart;
let craftsCache = [];
let pricesCache = new Map(); // id -> item pricing record
// ---------- Helpers ----------
const fmt = n => '₽' + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(Math.round(n || 0));
const nowStr = () => new Date().toLocaleTimeString();
const getMode = () => (modeRadios.find(r => r.checked)?.value) || 'regular';
function fleaPriceFromSellFor(sellFor) {
if (!Array.isArray(sellFor)) return 0;
const flea = sellFor.find(x => x?.source === 'Flea Market');
return Number(flea?.price || 0);
}
function getUnitPrice(itemRec, metric) {
if (!itemRec) return 0;
if (metric === 'avg24hPrice') return Number(itemRec.avg24hPrice || 0);
// default flea: try sellFor(Flea Market), fallback to avg24h
return fleaPriceFromSellFor(itemRec.sellFor) || Number(itemRec.avg24hPrice || 0);
}
function netAfterFee(gross, applyFee, feePct) {
if (!applyFee) return gross;
const pct = Math.max(0, Math.min(100, Number(feePct || 0)));
return gross * (1 - pct / 100);
}
function collectIds(crafts) {
const ids = new Set();
for (const c of crafts) {
for (const ri of (c.requiredItems || [])) ids.add(ri.item?.id);
for (const ro of (c.rewardItems || [])) ids.add(ro.item?.id);
}
ids.delete(undefined);
return Array.from(ids);
}
async function gql(query, variables) {
const resp = await fetch(API, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
if (json.errors) throw new Error(json.errors[0]?.message || 'GraphQL error');
return json.data;
}
async function fetchCrafts() {
// Fix: no variables passed—prevents "Variable $lang is never used" error.
const data = await gql(CRAFTS_Q);
return data?.crafts ?? [];
}
async function fetchItemsByIds(ids, mode) {
// batch to avoid giant payloads
const out = new Map();
const BATCH = 80;
for (let i = 0; i < ids.length; i += BATCH) {
const slice = ids.slice(i, i + BATCH);
const data = await gql(ITEMS_BY_IDS_Q, { ids: slice, mode, lang: 'en' });
for (const it of data?.items || []) out.set(it.id, it);
}
return out;
}
function summarize(crafts, metric, applyFee, feePct) {
const rows = [];
for (const c of crafts) {
// Inputs (cost)
const inputs = (c.requiredItems || []).map(ri => {
const rec = pricesCache.get(ri.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ri.item?.name || 'Unknown', count: ri.count || 0, unit, total: unit * (ri.count || 0) };
});
// Outputs (sell)
const outputs = (c.rewardItems || []).map(ro => {
const rec = pricesCache.get(ro.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ro.item?.name || 'Unknown', count: ro.count || 0, unit, total: unit * (ro.count || 0) };
});
const cost = inputs.reduce((s, x) => s + x.total, 0);
const sellGross = outputs.reduce((s, x) => s + x.total, 0);
const sellNet = netAfterFee(sellGross, applyFee, feePct);
const profit = sellNet - cost;
// Only station name (no level), because station.level isn't public in the docs/examples we referenced.
const stationLabel = c.station?.name || 'Station';
let craftName = 'Unknown craft';
if (outputs.length === 1) craftName = `${outputs[0].name} ×${outputs[0].count}`;
else if (outputs.length > 1) craftName = `${outputs[0].name} ×${outputs[0].count} (+${outputs.length - 1} other)`;
rows.push({
id: c.id,
craft: craftName,
station: stationLabel,
cost, sellGross, sellNet, profit,
inputs
});
}
rows.sort((a, b) => b.profit - a.profit);
return rows;
}
function renderTable(rows) {
tbody.innerHTML = '';
if (!rows.length) {
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">No data</td></tr>`;
return;
}
const frag = document.createDocumentFragment();
for (const r of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td data-label="Craft">${r.craft}</td>
<td data-label="Station">${r.station}</td>
<td data-label="Cost" class="right">${fmt(r.cost)}</td>
<td data-label="Sell (gross)" class="right">${fmt(r.sellGross)}</td>
<td data-label="Sell (net)" class="right">${fmt(r.sellNet)}</td>
<td data-label="Profit" class="right ${r.profit >= 0 ? 'good' : 'bad'}">${fmt(r.profit)}</td>
<td data-label="Inputs">${r.inputs.map(i => `${i.name} ×${i.count} (${fmt(i.unit)})`).join(', ')}</td>
`;
frag.appendChild(tr);
}
tbody.appendChild(frag);
}
function renderChart(rows, applyFee) {
const labels = rows.map(r => r.craft);
const costs = rows.map(r => r.cost);
const sells = rows.map(r => applyFee ? r.sellNet : r.sellGross);
const ctx = chartCanvas.getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Cost to craft', data: costs, backgroundColor: 'rgba(255,99,132,.6)' },
{ label: applyFee ? 'Sell (net)' : 'Sell (gross)', data: sells, backgroundColor: 'rgba(75,192,192,.7)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
afterBody(ctx) {
const i = ctx[0].dataIndex;
const r = rows[i];
return [`Station: ${r.station}`, `Profit: ${fmt(r.profit)}`];
}
}
}
},
scales: {
y: { title: { display: true, text: 'Rubles (₽)' } }
}
}
});
}
async function refresh() {
const mode = getMode(); // 'regular' | 'pve' (items supports gameMode)
const metric = metricSel.value; // 'flea' | 'avg24hPrice'
const topN = Math.max(5, Math.min(200, parseInt(topNInput.value || '30', 10)));
const applyFee = applyFeeChk.checked;
const feePct = parseFloat(feePctInput.value || '0');
statusEl.textContent = 'Loading latest data…';
try {
// Step 1: crafts (structure)
const crafts = await fetchCrafts();
craftsCache = crafts;
// Step 2: price lookup by IDs in selected mode
const ids = collectIds(crafts);
pricesCache = await fetchItemsByIds(ids, mode);
const allRows = summarize(craftsCache, metric, applyFee, feePct);
const rows = allRows.slice(0, topN);
renderTable(rows);
renderChart(rows, applyFee);
updatedEl.textContent = `Data last updated: ${nowStr()}`;
statusEl.textContent = `Showing top ${rows.length} crafts by profit — Mode: ${mode.toUpperCase()}, Metric: ${metric}, Fee: ${applyFee ? (feePct + '%') : 'off'}.`;
} catch (err) {
console.error(err);
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">Error: ${String(err.message || err)}</td></tr>`;
statusEl.textContent = 'Failed to load data. Try again.';
}
}
// Events & timers
modeRadios.forEach(r => r.addEventListener('change', refresh));
metricSel.addEventListener('change', refresh);
topNInput.addEventListener('input', refresh);
refreshBtn.addEventListener('click', refresh);
applyFeeChk.addEventListener('change', refresh);
feePctInput.addEventListener('input', refresh);
refresh();
setInterval(refresh, 5 * 60 * 1000); // every 5 minutes
})();