// 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 = `No data`; return; } const frag = document.createDocumentFragment(); for (const r of rows) { const tr = document.createElement('tr'); tr.innerHTML = ` ${r.craft} ${r.station} ${fmt(r.cost)} ${fmt(r.sellGross)} ${fmt(r.sellNet)} ${fmt(r.profit)} ${r.inputs.map(i => `${i.name} ×${i.count} (${fmt(i.unit)})`).join(', ')} `; 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 = `Error: ${String(err.message || err)}`; 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 })();