// ==UserScript==
// @name PoE2 Ninja → Trade (Arpg_community)
// @namespace poe2.ninja.trade.helper
// @version 0.1.2
// @description Кнопка Trade на плитке. Глобальный тумблер в правом верхнем углу: включать/выключать передачу числовых значений модов, а так же доработал Jewels
// @match https://poe.ninja/poe2/builds/*
// @match https://poe.ninja/poe2/builds/*/character/*
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM.openInTab
// @grant GM_getValue
// @grant GM_setValue
// @connect www.pathofexile.com
// @connect pathofexile.com
// @run-at document-start
// @author TheStorey and ChatGPT
// ==/UserScript==
(function () {
'use strict';
const ON_NINJA = /poe\.ninja$/i.test(location.hostname) || /\.poe\.ninja$/i.test(location.hostname);
// ---------- persistent setting ----------
const CFG_KEY = "poe2_trade_include_values";
let includeValues = (typeof GM_getValue === "function") ? !!GM_getValue(CFG_KEY, false) : false;
function setIncludeValues(v) {
includeValues = !!v;
try { GM_setValue && GM_setValue(CFG_KEY, includeValues); } catch {}
updateToggleUI();
toast(`Статы: ${includeValues ? "включены (с цифрами)" : "выключены (без цифр)"}`);
}
// ---------- helpers ----------
const openInTab = url => {
try {
if (typeof GM_openInTab === "function") return GM_openInTab(url, {active:true, insert:true});
if (typeof GM !== "undefined" && typeof GM.openInTab === "function") return GM.openInTab(url, {active:true, insert:true});
} catch {}
window.open(url, "_blank", "noopener");
};
function leagueSlug() {
const m = location.href.match(/poe2\/builds\/([^/]+)/i);
if (m && m[1].toLowerCase().includes("abyss")) return "Rise%20of%20the%20Abyssal";
return "Rise%20of%20the%20Abyssal";
}
const norm = s => (s||"")
.toLowerCase()
.replace(/<[^>]*>/g,"")
.replace(/\(implicit\)|\(crafted\)|\(fractured\)|\(local\)|\(global\)|\(enchanted\)/gi,"")
.replace(/[“”"′’]/g,'"')
.replace(/\u00A0/g, ' ')
.replace(/\s+/g," ")
.trim();
const normTemplate = s => norm(s)
.replace(/[\+\-]?\d+(?:\.\d+)?/g, "#")
.replace(/#\s*to\s*#/g, "# to #")
.replace(/^\+\s*/, '')
.replace(/\+#%/g, "#%")
.replace(/\+#/g, "#");
const parseNums = s => (s.match(/[\+\-]?\d+(?:\.\d+)?/g) || []).map(Number);
const tokenize = s => norm(s).replace(/[^a-z0-9#% ]+/g,' ').split(/\s+/).filter(Boolean);
function applySynonyms(t) {
return t
.replace(/critical hit chance/g, "critical strike chance")
.replace(/critical hit multiplier/g, "critical strike multiplier")
.replace(/to accuracy rating/g, "to accuracy");
}
function isUniqueLike(d) {
const r = (d.rarity || d.frameTypeName || "").toString().toLowerCase();
return r === "unique" || d.frameType === 3;
}
// ---------- capture poe.ninja API ----------
let EQUIPMENT = null;
const _fetch = window.fetch;
window.fetch = async function (...args) {
const res = await _fetch.apply(this, args);
try {
const url = (args[0] && args[0].url) || args[0];
if (typeof url === "string" && url.includes("/poe2/api/builds/") && url.includes("/character")) {
res.clone().json().then(j => (EQUIPMENT=j)).catch(()=>{});
}
} catch {}
return res;
};
const _open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.addEventListener("load", function () {
try {
if (typeof url === "string" && url.includes("/poe2/api/builds/") && url.includes("/character")) {
try { EQUIPMENT = JSON.parse(this.responseText); } catch {}
}
} catch {}
});
return _open.call(this, method, url, ...rest);
};
setTimeout(() => {
try {
for (const e of performance.getEntriesByType("resource")) {
if (e.name.includes("/poe2/api/builds/") && e.name.includes("/character")) {
fetch(e.name).then(r=>r.json()).then(j => (EQUIPMENT=j)).catch(()=>{});
break;
}
}
} catch {}
}, 500);
// ---------- load trade2 stats & indices ----------
let EXACT = null; // Map normalized template -> [id...]
let SUFFIX = null; // Map last2 tokens -> [{id,text}]
async function loadTrade2Stats() {
if (EXACT) return;
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method:"GET",
url:"https://www.pathofexile.com/api/trade2/data/stats",
headers:{Accept:"application/json"},
onload:res=>{ try {
const data = JSON.parse(res.responseText);
const entries = [];
for (const g of (data.result||[])) for (const e of (g.entries||[])) if (e && e.id && e.text) entries.push(e);
EXACT = new Map();
SUFFIX = new Map();
for (const e of entries) {
const t0 = normTemplate(e.text);
const t = applySynonyms(t0);
const vars = new Set([t, t.replace(/^\+\s*/,'').replace('+#%','#%').replace('+#','#')]);
for (const v of vars) {
if (!EXACT.has(v)) EXACT.set(v, []);
const arr = EXACT.get(v);
if (!arr.includes(e.id)) arr.push(e.id);
}
const toks = tokenize(t).filter(w=>w!=="#");
const last2 = toks.slice(-2).join(' ');
if (last2) {
if (!SUFFIX.has(last2)) SUFFIX.set(last2, []);
SUFFIX.get(last2).push({id:e.id, text:t});
}
}
resolve();
} catch(e){ reject(e);} },
onerror: reject, ontimeout: reject
});
});
}
function similar(a, b) {
const A = new Set(tokenize(a).filter(w=>w!=="#"));
const B = new Set(tokenize(b).filter(w=>w!=="#"));
if (!A.size || !B.size) return 0;
let inter = 0; for (const w of A) if (B.has(w)) inter++;
return inter / Math.max(A.size, B.size);
}
// ---------- findStatIds with preferred prefix (explicit/enchant/...) ----------
function findStatIds(raw, preferredPrefix=null) {
const t0 = normTemplate(raw);
const t = applySynonyms(t0);
// exact match
let ids = EXACT.get(t);
if (!ids || !ids.length) {
const t2 = t.replace(/^\+\s*/,'').replace('+#%','#%').replace('+#','#');
ids = EXACT.get(t2);
}
if (ids && ids.length) {
if (preferredPrefix) {
const pref = ids.filter(id => id.startsWith(preferredPrefix + "."));
if (pref.length) return {ids: pref, how: "exact+"+preferredPrefix};
}
return {ids, how:"exact"};
}
// suffix-similarity
const toks = tokenize(t).filter(w=>w!=="#");
const last2 = toks.slice(-2).join(' ');
let cands = (last2 && SUFFIX.get(last2)) ? SUFFIX.get(last2) : [];
if (!cands.length) {
const last1 = toks.slice(-1).join(' ');
cands = (last1 && SUFFIX.get(last1)) ? SUFFIX.get(last1) : [];
}
let best=null, bestScore=0;
const LIMIT = cands.length ? Math.min(200, cands.length) : 0;
for (let i=0;i<LIMIT;i++) {
const s = similar(t, cands[i].text);
if (s > bestScore) { bestScore = s; best = cands[i]; }
}
if (best && bestScore >= 0.72) {
let out = [best.id];
if (preferredPrefix && !best.id.startsWith(preferredPrefix + ".")) {
const all = EXACT.get(applySynonyms(normTemplate(best.text))) || [];
const pref = all.filter(id => id.startsWith(preferredPrefix + "."));
if (pref.length) out = [pref[0]];
}
return {ids: out, how:`suffix-sim(${bestScore.toFixed(2)})`};
}
// global-similarity
let bestG=null, bestGS=0;
for (const [tpl, arr] of EXACT.entries()) {
const s = similar(t, tpl);
if (s > bestGS) { bestGS = s; bestG = {tpl, arr}; }
}
if (bestG && bestGS >= 0.82) {
if (preferredPrefix) {
const pref = bestG.arr.filter(id => id.startsWith(preferredPrefix + "."));
if (pref.length) return {ids:[pref[0]], how:`global-sim(${bestGS.toFixed(2)})+${preferredPrefix}`};
}
return {ids:[bestG.arr[0]], how:`global-sim(${bestGS.toFixed(2)})`};
}
return {ids:[], how:"no-match"};
}
// ---------- build query ----------
function collectMods(d) {
const cols = ["implicitMods","explicitMods","enchantMods","craftedMods","fracturedMods","desecratedMods","runeMods"];
const out = [];
for (const k of cols) if (Array.isArray(d[k])) out.push(...d[k]);
return out;
}
function buildQueryFromItem(it) {
const d = it.itemData || it;
const name = d.name || "";
const type = d.baseType || d.typeLine || "";
const corrupted = !!d.corrupted;
const mods = collectMods(d);
const details = [];
const seen = new Set();
const filters = [];
for (const raw of mods) {
const nums = parseNums(raw);
const tpl = normTemplate(raw);
const {ids, how} = findStatIds(raw);
let chosenId = null;
if (!seen.has(tpl) && ids.length) {
chosenId = ids[0];
const f = { id: chosenId, disabled:false };
if (includeValues) {
if (nums.length === 1) f.value = { min: nums[0] };
else if (nums.length >= 2) f.value = { min: Math.min(nums[0], nums[1]), max: Math.max(nums[0], nums[1]) };
else f.value = null;
} else {
f.value = null;
}
filters.push(f);
seen.add(tpl);
}
details.push({
raw, template: tpl, how, ids, chosenId,
numbers: includeValues ? nums : [],
valueSent: includeValues ? (nums.length ? (nums.length===1?{min:nums[0]}:{min:Math.min(nums[0],nums[1]),max:Math.max(nums[0],nums[1])}) : null) : null,
note: chosenId ? (includeValues ? "MATCHED (value from item)" : "MATCHED (value=null)") : "NOT MATCHED"
});
}
try {
const inv = (d.inventoryId || "").toString();
console.groupCollapsed(`[PoE2→Trade] ${inv} | ${name || "(no name)"} — ${type || "(no type)"} (mods: ${mods.length}, matched: ${filters.length}, values: ${includeValues ? "ON" : "OFF"})`);
console.table(details);
console.log("Final query.query.stats:", [{ type:"and", filters }]);
console.groupEnd();
} catch {}
const q = {
query: {
status: { option: "online" },
stats: [ { type: "and", filters } ],
filters: { misc_filters: { filters: corrupted ? { corrupted: { option: "true" } } : {} } }
},
sort: { price: "asc" }
};
if (isUniqueLike(d) && name) {
q.query.name = name;
if (type) q.query.type = type;
} else {
if (type) q.query.type = type;
}
return q;
}
// ---------- JEWELS helpers (source-aware) ----------
function collectModsDetailed(d) {
const out = [];
const push = (arr, source) => Array.isArray(arr) && arr.forEach(raw => out.push({raw, source}));
push(d.implicitMods, "implicit");
push(d.explicitMods, "explicit");
push(d.enchantMods, "enchant");
push(d.craftedMods, "crafted");
push(d.fracturedMods, "fractured");
push(d.desecratedMods,"unknown");
push(d.runeMods, "unknown");
return out;
}
// JEWELS: всегда Item Category -> Any Jewel;
function buildJewelQueryFromItem(it) {
const d = it.itemData || it;
const corrupted = !!d.corrupted;
const mods = collectModsDetailed(d);
const seen = new Set();
const details = [];
const filters = [];
for (const {raw, source} of mods) {
const nums = parseNums(raw);
const tpl = normTemplate(raw);
const pref = (source === "implicit" || source === "explicit" || source === "enchant" || source === "crafted" || source === "fractured")
? source : null;
const {ids, how} = findStatIds(raw, pref);
const chosenId = (ids && ids.length) ? ids[0] : null;
if (!seen.has(tpl) && chosenId) {
const f = { id: chosenId, disabled:false };
if (includeValues) {
if (nums.length === 1) f.value = { min: nums[0] };
else if (nums.length >= 2) f.value = { min: Math.min(nums[0], nums[1]), max: Math.max(nums[0], nums[1]) };
else f.value = null;
} else f.value = null;
filters.push(f);
seen.add(tpl);
}
details.push({
source, raw, template: tpl, how, ids, chosenId,
numbers: includeValues ? nums : [],
valueSent: includeValues ? (nums.length ? (nums.length===1?{min:nums[0]}:{min:Math.min(nums[0],nums[1]),max:Math.max(nums[0],nums[1])}) : null) : null,
note: chosenId ? (includeValues ? "MATCHED (value from jewel)" : "MATCHED (value=null)") : "NOT MATCHED"
});
}
try {
console.groupCollapsed(`[PoE2→Trade][JEWEL] mods: ${mods.length}, matched: ${filters.length}, values: ${includeValues ? "ON" : "OFF"}`);
console.table(details);
console.log("Final JEWEL query (category=jewel):", [{ type:"and", filters }]);
if (corrupted) console.log("misc_filters.corrupted = true");
console.groupEnd();
} catch {}
return {
query: {
status: { option: "online" },
stats: [ { type: "and", filters } ],
filters: {
type_filters: { filters: { category: { option: "jewel" } } }, // Any Jewel обязательно
misc_filters: { filters: corrupted ? { corrupted: { option: "true" } } : {} }
}
},
sort: { price: "asc" }
};
}
// ---------- POST + retry ----------
function postSearchAndOpen(league, bodyObj) {
const url = `https://www.pathofexile.com/api/trade2/search/poe2/${league}`;
const doPost = (payload, retried) => {
GM_xmlhttpRequest({
method: "POST",
url,
headers: { "Content-Type":"application/json", "Accept":"application/json" },
data: JSON.stringify(payload),
onload: (res) => {
let j = null;
try { j = JSON.parse(res.responseText); } catch {}
if (j && j.error && j.error.code === 2 && !retried && payload?.query?.name) {
const p2 = JSON.parse(JSON.stringify(payload));
delete p2.query.name;
console.warn("[PoE2→Trade] Unknown item name → retry без query.name");
return doPost(p2, true);
}
if (j && j.id) {
openInTab(`https://www.pathofexile.com/trade2/search/poe2/${league}/${j.id}`);
} else {
console.warn("[PoE2→Trade] POST ok, но без id. Ответ:", res.responseText);
openInTab(`https://www.pathofexile.com/trade2/search/poe2/${league}?q=${encodeURIComponent(JSON.stringify(payload))}`);
}
},
onerror: (e) => {
console.error("[PoE2→Trade] POST error:", e);
openInTab(`https://www.pathofexile.com/trade2/search/poe2/${league}?q=${encodeURIComponent(JSON.stringify(payload))}`);
}
});
};
doPost(bodyObj, false);
}
// ---------- UI: кнопка Trade на плитках (экипировка) ----------
function makeTradeBtn() {
const a = document.createElement("a");
a.textContent = "Trade";
a.href = "#";
Object.assign(a.style, {
display:"inline-flex", alignItems:"center", justifyContent:"center",
height:"24px", padding:"0 8px", borderRadius:"8px",
background:"rgba(0,0,0,.65)", border:"1px solid rgba(255,255,255,.25)",
color:"#d9f7ff", fontSize:"12px", textDecoration:"none", cursor:"pointer",
whiteSpace:"nowrap"
});
return a;
}
function makeBtnWrap() {
const wrap = document.createElement("div");
Object.assign(wrap.style, {
position:"absolute", top:"6px", right:"6px", zIndex:"2147483647",
display:"flex", gap:"6px", opacity:"0", transition:"opacity .12s ease"
});
return wrap;
}
function applyHover(tile, wrap){
tile.addEventListener("mouseenter", ()=> wrap.style.opacity = "1");
tile.addEventListener("mouseleave", ()=> { setTimeout(()=>{ if(!tile.matches(":hover")) wrap.style.opacity = "0"; }, 0); });
}
function ensureButtonOnTile(tile){
if (tile.__poe2_trade_btn__) return;
const wrap = makeBtnWrap();
const tradeBtn = makeTradeBtn();
wrap.appendChild(tradeBtn);
tile.style.position = tile.style.position || "relative";
tile.appendChild(wrap);
applyHover(tile, wrap);
tile.__poe2_trade_btn__ = wrap;
tradeBtn.addEventListener("click", async (e)=>{
e.preventDefault();
if (!EQUIPMENT) { alert("Подожди секунду — данные предметов ещё не подгрузились."); return; }
await loadTrade2Stats();
const m = /grid-area:\s*([a-zA-Z0-9_-]+)/.exec(tile.getAttribute("style")||"");
if (!m) return;
const inv = m[1].toLowerCase();
const it = (EQUIPMENT.items||[]).find(x => ((x.itemData?.inventoryId||"").toLowerCase() === inv))
|| (inv==="ring" ? (EQUIPMENT.items||[]).find(x => /ring/i.test(x.itemData?.inventoryId||"")) : null);
if (!it) { console.warn("[PoE2→Trade] Не нашёл предмет по grid-area:", inv); return; }
const q = buildQueryFromItem(it);
const league = leagueSlug();
postSearchAndOpen(league, q);
});
}
function scanTiles(root=document){
root.querySelectorAll("div.group.relative.rounded-xs.bg-center[style*='grid-area']").forEach(ensureButtonOnTile);
}
// ---------- BASE JEWELS: кнопка Trade на камнях (только моды + лог) ----------
function enhanceBaseJewelsSection() {
const headers = Array.from(document.querySelectorAll('h2, h3, [data-scale]'))
.filter(el => (el.textContent||'').trim().toUpperCase() === 'BASE JEWELS');
headers.forEach(header => {
const section = header.closest('div');
if (!section) return;
const grid = section.querySelector('div._layout-cluster_hedo7_1');
const tiles = (grid || section).querySelectorAll('div.w-16');
tiles.forEach(tile => {
if (tile.__poe2_jewel_btn__) return;
const box = tile.querySelector('div.rounded-sm') || tile;
const img = tile.querySelector('img');
const btn = makeTradeBtn();
btn.style.background = "rgba(30,120,210,.9)";
btn.style.color = "#fff";
const wrap = makeBtnWrap();
wrap.appendChild(btn);
const cs = getComputedStyle(box);
if (cs.position === 'static') box.style.position = 'relative';
box.appendChild(wrap);
applyHover(box, wrap);
tile.__poe2_jewel_btn__ = wrap;
btn.addEventListener('click', async e => {
e.preventDefault();
if (!EQUIPMENT) { alert("Подожди секунду — данные предметов ещё не подгрузились."); return; }
await loadTrade2Stats();
const src = img?.src?.split('?')[0] || '';
let jewel = (EQUIPMENT?.jewels || []).find(j => (j.itemData?.icon||'').split('?')[0] === src);
if (!jewel) {
const alt = (img?.alt || '').toLowerCase();
jewel = (EQUIPMENT?.jewels || []).find(j => {
const combo = `${(j.itemData?.name||'').toLowerCase()} ${(j.itemData?.typeLine||'').toLowerCase()}`.trim();
return alt === combo || alt.includes((j.itemData?.name||'').toLowerCase());
});
}
if (!jewel) { console.warn("[PoE2→Trade] Jewel не найден по DOM/иконке"); return; }
const q = buildJewelQueryFromItem(jewel);
postSearchAndOpen(leagueSlug(), q);
});
});
});
}
function initBaseJewelsWatcher() {
enhanceBaseJewelsSection();
const mo = new MutationObserver(() => enhanceBaseJewelsSection());
mo.observe(document.documentElement, { childList:true, subtree:true });
}
// ---------- UI: глобальная иконка (справа сверху) ----------
function injectToggleUI() {
if (document.getElementById("poe2-trade-toggle-top")) return;
const wrap = document.createElement("div");
wrap.id = "poe2-trade-toggle-top";
wrap.innerHTML = `
<div class="poe2-toggle-btn" title="PoE2 Trade: настройки">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path fill="currentColor" d="M3 6h13v2H3V6zm0 5h18v2H3v-2zm0 5h10v2H3v-2z"/>
</svg>
</div>
<div class="poe2-toggle-panel">
<div class="row">
<span>Передавать цифры модов</span>
<label class="switch">
<input type="checkbox" id="poe2-toggle-checkbox">
<span class="slider"></span>
</label>
</div>
<div class="hint">${includeValues ? "включено: будут подставляться цифры с предмета" : "выключено: будут уходить пустые значения модов"}</div>
</div>
`;
document.documentElement.appendChild(wrap);
const style = document.createElement("style");
style.textContent = `
#poe2-trade-toggle-top{
position: fixed; right: 14px; top: 18px; z-index: 2147483647;
font-family: system-ui,-apple-system,Segoe UI,Roboto,Arial;
pointer-events: none;
}
#poe2-trade-toggle-top .poe2-toggle-btn{
pointer-events: auto;
width: 38px; height: 38px; border-radius: 10px;
background: rgba(0,0,0,.7); color: #e2f1ff; display:flex; align-items:center; justify-content:center;
border: 1px solid rgba(255,255,255,.25); cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.25);
}
#poe2-trade-toggle-top .poe2-toggle-btn:hover{ background: rgba(0,0,0,.85); }
#poe2-trade-toggle-top .poe2-toggle-panel{
position: absolute; right: 0; top: 46px; min-width: 280px; max-width: 340px;
padding: 12px 14px; pointer-events: auto;
background: rgba(10,12,14,.95); color: #d7e2ea; border: 1px solid rgba(255,255,255,.2);
border-radius: 12px; box-shadow: 0 10px 24px rgba(0,0,0,.35);
display: none; gap: 10px; backdrop-filter: blur(2px);
}
#poe2-trade-toggle-top .row{ display:flex; align-items:center; justify-content:space-between; gap:14px; font-size: 14px; }
#poe2-trade-toggle-top .hint{ margin-top: 8px; font-size: 12px; color:#9db0be; }
#poe2-trade-toggle-top .switch { position: relative; display: inline-block; width: 46px; height: 24px; }
#poe2-trade-toggle-top .switch input {display:none;}
#poe2-trade-toggle-top .slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #5a6872; transition: .2s; border-radius: 12px;
}
#poe2-trade-toggle-top .slider:before {
position: absolute; content: ""; height: 18px; width: 18px; left: 3px; top: 3px; background-color: white; transition: .2s; border-radius: 50%;
}
#poe2-trade-toggle-top input:checked + .slider { background-color: #32c48d; }
#poe2-trade-toggle-top input:checked + .slider:before { transform: translateX(22px); }
#poe2-toast{
position: fixed; right: 14px; bottom: 18px; background: rgba(0,0,0,.82);
padding: 8px 12px; border-radius: 8px; border:1px solid rgba(255,255,255,.2);
z-index:2147483647; font-size:13px; display:none; pointer-events: none;
}`;
document.documentElement.appendChild(style);
const btn = wrap.querySelector(".poe2-toggle-btn");
const panel = wrap.querySelector(".poe2-toggle-panel");
const checkbox = wrap.querySelector("#poe2-toggle-checkbox");
checkbox.checked = includeValues;
btn.addEventListener("click", () => {
panel.style.display = (panel.style.display === "block") ? "none" : "block";
});
checkbox.addEventListener("change", () => {
setIncludeValues(checkbox.checked);
const hint = wrap.querySelector(".hint");
hint.textContent = includeValues ? "включено: будут подставляться цифры с предмета" : "выключено: не будут подставляться статы с предмета";
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) panel.style.display = "none";
});
}
function updateToggleUI(){
const cb = document.getElementById("poe2-toggle-checkbox");
if (cb) cb.checked = includeValues;
const hint = document.querySelector("#poe2-trade-toggle-top .hint");
if (hint) hint.textContent = includeValues ? "включено: будут подставляться статы с предмета" : "выключено: не будут подставляться статы с предмета";
}
function toast(msg){
let t = document.getElementById("poe2-toast");
if (!t) {
t = document.createElement("div");
t.id = "poe2-toast";
document.documentElement.appendChild(t);
}
t.textContent = msg;
t.style.display = "block";
clearTimeout(toast.__tmr);
toast.__tmr = setTimeout(()=>{ t.style.display = "none"; }, 1600);
}
// ---------- init ----------
if (ON_NINJA) {
const initUI = () => injectToggleUI();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initUI);
} else { initUI(); }
// экипировка
const moEquip = new MutationObserver(ml => { for (const m of ml) for (const n of m.addedNodes) if (n instanceof HTMLElement) scanTiles(n); });
moEquip.observe(document.documentElement || document.body, {childList:true,subtree:true});
scanTiles();
// Base Jewels
initBaseJewelsWatcher();
}
// исходная
function scanTiles(root=document){
root.querySelectorAll("div.group.relative.rounded-xs.bg-center[style*='grid-area']").forEach(ensureButtonOnTile);
}
})();