Instrukcja użyciaInstrukcja instalacji→ Zainstaluj rozszerzenie Tampermonkey w Chrome
→ Otwórz dashboard Tampermonkey → Utwórz nowy skrypt
→ Wklej poniższy kod i zapisz (Ctrl+S)
→ Odwiedź dowolną stronę → widget GTM Checker pojawi się w prawym dolnym rogu
→ Alt+Shift+A — ukrywa/przywraca widget na dowolnej domenie
Skrypt GTM Checker
// ==UserScript==
// @name GTM Checker
// @namespace https://pawelpiekarski.pl/
// @version 3.9.2
// @description GTM injection (per domena, localStorage), live monitor dataLayer, edytowalny push dataLayer (presety GA4 w grupach Ecommerce / Leady, z wyborem liczby produktów, wczytywane do pola + wklejanie z GTM Preview / konsoli), detektor pixeli z consent mode, filtry DevTools Network per vendor i biblioteka komend — narzędzia web analityka
// @author Paweł Piekarski
// @match *://*/*
// @icon https://www.gstatic.com/analytics-suite/header/suite/v2/ic_tag_manager.svg
// @run-at document-start
// @noframes
// @grant none
// ==/UserScript==
(function () {
'use strict';
// Tylko top-level okno (zapas na wypadek, gdyby @noframes nie zadziałało).
// Bez tego widget + auto-inject GTM odpalają się też w iframe'ach
// (np. WP update.php) → dwa widgety i zdublowane hity.
if (window.top !== window.self) return;
// Jedna instancja na dokument (np. zdublowana instalacja skryptu w TM)
const INST = 'data-pk-gtmc';
const de = document.documentElement;
if (de) {
if (de.hasAttribute(INST)) return;
de.setAttribute(INST, '1');
}
const WID = 'pk-gtmc-host';
const SS_OPEN = 'pkGtmcOpen'; // sessionStorage — rozwinięcie per karta+domena
const SS_TAB = 'pkGtmcTab'; // sessionStorage — ostatnia aktywna zakładka
const LS_POS = 'pkGtmcPos';
const LS_INJECT = 'pkGtmcInject'; // localStorage — GTM wstrzykiwany na tej domenie (origin)
const LS_ITEMS = 'pkGtmcItems'; // localStorage — liczba produktów w presetach (per domena)
let collapsed = true; // domyślnie zwinięty na każdej nowej stronie
try { collapsed = sessionStorage.getItem(SS_OPEN) !== '1'; } catch (e) {}
let manuallyHidden = false; // Alt+Shift+A — ukrycie tylko do reloadu
let host = null, root = null;
let activeTab = 'pixels';
try {
const t = sessionStorage.getItem(SS_TAB);
if (['pixels', 'dl', 'push', 'cmds', 'net'].includes(t)) activeTab = t;
} catch (e) {}
let bridgeReady = false;
let lastSnapshot = null;
let lastConsent = null;
const C = {
bg: '#111827', hover: '#1e293b',
accent: '#3b82f6', accentDk: '#1e3a5f',
green: '#22c55e', red: '#ef4444', yellow: '#eab308', purple: '#c084fc',
text: '#e5e7eb', dim: '#6b7280', border: '#374151',
};
// Hosty, na których widget NIE pokazuje się automatycznie (panele narzędzi,
// nie "normalne" strony). Alt+Shift+A nadal działa, gdyby był potrzebny.
const BLOCKED_HOSTS = [
/(^|\.)tagmanager\.google\.com$/,
/(^|\.)analytics\.google\.com$/,
/(^|\.)tagassistant\.google\.com$/,
/(^|\.)ads\.google\.com$/,
/(^|\.)adsense\.google\.com$/,
/(^|\.)merchants\.google\.com$/,
/(^|\.)marketingplatform\.google\.com$/,
/(^|\.)search\.google\.com$/, // Search Console
/(^|\.)lookerstudio\.google\.com$/,
/(^|\.)datastudio\.google\.com$/,
/(^|\.)optimize\.google\.com$/,
/(^|\.)accounts\.google\.com$/,
/(^|\.)mail\.google\.com$/,
/(^|\.)drive\.google\.com$/,
/(^|\.)docs\.google\.com$/,
/(^|\.)calendar\.google\.com$/,
/(^|\.)meet\.google\.com$/,
/(^|\.)console\.cloud\.google\.com$/,
/(^|\.)business\.facebook\.com$/, // Meta Business Suite
/(^|\.)adsmanager\.facebook\.com$/,
/(^|\.)events\.facebook\.com$/, // Events Manager
/(^|\.)clarity\.microsoft\.com$/,
/(^|\.)ads\.tiktok\.com$/,
/(^|\.)ads\.microsoft\.com$/,
/(^|\.)campaignmanager\.linkedin\.com$/,
/(^|\.)analytics\.tiktok\.com$/,
/(^|\.)app\.salesmanago\.com$/,
];
function isBlockedHost() {
const h = location.hostname;
return BLOCKED_HOSTS.some(re => re.test(h));
}
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function escAttr(s) { return escHtml(s).replace(/"/g, '"'); }
function fmtTime(ts) { const d = new Date(ts); return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':'); }
function isGtmId(id) { return /^GTM-[A-Z0-9]+$/.test(id); }
/* ═══════════════════ STORAGE (GTM per domena — localStorage) ═══════════════════ */
// localStorage jest per origin, więc zapis "przykleja się" do domeny:
// wchodzisz na sklep bez GTM → wpisujesz GTM-XXX → przy każdym kolejnym
// wejściu kontener ładuje się przy document-start, jakby siedział w kodzie.
function getDomainInject() {
try {
const raw = localStorage.getItem(LS_INJECT);
if (!raw) return null;
const o = JSON.parse(raw);
return o && isGtmId(o.id) ? o : null;
} catch (e) { return null; }
}
function setDomainInject(id) {
try { localStorage.setItem(LS_INJECT, JSON.stringify({ id: id, ts: Date.now() })); return true; } catch (e) { return false; }
}
function clearDomainInject() {
try { localStorage.removeItem(LS_INJECT); } catch (e) {}
}
/* ═══════════════════ BRIDGE (kod działający w kontekście strony) ═══════════════════ */
// Wstrzykiwany jako <script>, komunikacja przez CustomEvent — działa niezależnie
// od trybu sandboxa Tampermonkey. Monitoruje dataLayer, wykonuje pushe
// symulatora i zwraca snapshot globali/consentu.
//
// Monitor dataLayer — dlaczego tak, a nie zwykły wrap dataLayer.push:
// 1. GTM po załadowaniu NADPISUJE dataLayer.push własną funkcją — każdy wrapper
// założony wcześniej znika. Dlatego podstawą jest licznik przetworzonych
// indeksów per tablica + flush interwałem; wrap pusha jest tylko
// przyspieszaczem (natychmiastowy flush) i jest ponawiany, gdy GTM go zje.
// 2. Strony potrafią zrobić `dataLayer = []` (reassignment) — wykrywane
// porównaniem referencji, licznik startuje od zera na nowej tablicy.
// 3. GTM przy > ~300 wpisach robi splice starych — gdy length spadnie poniżej
// licznika, licznik jest przycinany do length (bez duplikatów).
// 4. gtag() pushuje obiekty `arguments` — wykrywane i oznaczane jako komenda.
// 5. Niestandardowe nazwy dataLayer (np. dane z GTM &l=) — okresowy skan
// window po /datalayer/i.
function pageBridge() {
if (window.__pkGtmcBridge) return;
window.__pkGtmcBridge = true;
function safeStringify(obj) {
var seen = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
return JSON.stringify(obj, function (k, v) {
if (typeof v === 'function') return '[function]';
try {
if (typeof Node !== 'undefined' && v instanceof Node) return '[DOM <' + (v.nodeName || 'node').toLowerCase() + '>]';
if (typeof Window !== 'undefined' && v instanceof Window) return '[window]';
} catch (e) {}
if (typeof v === 'object' && v !== null && seen) {
if (seen.has(v)) return '[circular]';
seen.add(v);
}
return v;
});
}
function send(type, data) {
try {
document.dispatchEvent(new CustomEvent('pk-gtmc', { detail: safeStringify({ type: type, data: data, ts: Date.now() }) }));
} catch (e) {}
}
// ── snapshot globali ──
function snapshot() {
var names = ['google_tag_manager', 'gtag', 'fbq', 'clarity', 'ttq', 'hj', '_linkedin_partner_id', 'pintrk', 'uetq', 'twq', 'snaptr', 'criteo_q', '_hsq', '_paq', 'UC_UI', 'Cookiebot', 'OneTrust', '__cmp'];
var g = {};
names.forEach(function (k) { try { g[k] = !!window[k]; } catch (e) { g[k] = false; } });
var gtmKeys = []; try { gtmKeys = Object.keys(window.google_tag_manager || {}); } catch (e) {}
var fbqIds = []; try { if (window.fbq && window.fbq.getState) (window.fbq.getState().pixels || []).forEach(function (p) { if (p.id) fbqIds.push(String(p.id)); }); } catch (e) {}
var hjid = null; try { hjid = window._hjSettings && window._hjSettings.hjid ? String(window._hjSettings.hjid) : null; } catch (e) {}
var liId = null; try { liId = window._linkedin_partner_id ? String(window._linkedin_partner_id) : null; } catch (e) {}
send('snapshot', { g: g, gtmKeys: gtmKeys, fbqIds: fbqIds, hjid: hjid, liId: liId });
}
// ── consent mode ──
function consent() {
var out = null;
try {
var e = window.google_tag_data && window.google_tag_data.ics && window.google_tag_data.ics.entries;
if (e) {
out = {};
Object.keys(e).forEach(function (k) {
out[k] = { def: e[k]['default'], upd: e[k].update };
});
}
} catch (err) {}
send('consent', out);
}
/* ── MONITOR DATALAYER ── */
var dlNames = ['dataLayer'];
var dlRefs = []; // [ {name, arr, seen} ] — stan per referencja tablicy
var MAX_PAYLOAD = 50000;
function isArguments(v) {
try {
return v && typeof v === 'object' && !Array.isArray(v) &&
typeof v.length === 'number' &&
Object.prototype.toString.call(v) === '[object Arguments]';
} catch (e) { return false; }
}
function emit(raw, dlName, idx) {
var kind = 'obj', entry = raw;
try {
if (isArguments(raw)) { kind = 'gtag'; entry = Array.prototype.slice.call(raw); }
} catch (e) {}
var payload = { dl: dlName, i: idx, kind: kind, e: entry };
try {
if (safeStringify(payload).length > MAX_PAYLOAD) {
payload.e = '[payload > 50 kB — pominięty; zobacz w konsoli: ' + dlName + '[' + idx + ']]';
}
} catch (err) {
payload.e = '[nieserializowalny wpis — zobacz w konsoli: ' + dlName + '[' + idx + ']]';
}
send('dl', payload);
}
function stateFor(name, arr) {
for (var i = 0; i < dlRefs.length; i++) {
if (dlRefs[i].name === name) {
if (dlRefs[i].arr !== arr) { dlRefs[i].arr = arr; dlRefs[i].seen = 0; } // reassignment
return dlRefs[i];
}
}
var st = { name: name, arr: arr, seen: 0 };
dlRefs.push(st);
return st;
}
function flush(name) {
var arr;
try { arr = window[name]; } catch (e) { return; }
if (!arr || typeof arr.length !== 'number' || typeof arr.push !== 'function') return;
var st = stateFor(name, arr);
if (st.seen > arr.length) st.seen = arr.length; // GTM zrobił splice — bez re-emisji
while (st.seen < arr.length) {
emit(arr[st.seen], name, st.seen);
st.seen++;
}
}
function hookPush(name) {
var arr;
try { arr = window[name]; } catch (e) { return; }
if (!arr || typeof arr.push !== 'function') return;
if (arr.push.__pkGtmcH) return; // już owinięty (nasz wrap wciąż żyje)
var orig = arr.push;
var wrapped = function () {
var r = orig.apply(this, arguments);
try { flush(name); } catch (e) {}
return r;
};
wrapped.__pkGtmcH = 1;
try { arr.push = wrapped; } catch (e) {}
}
function scanCustomNames() {
try {
Object.keys(window).forEach(function (k) {
if (dlNames.indexOf(k) >= 0) return;
if (!/datalayer/i.test(k)) return;
var v; try { v = window[k]; } catch (e) { return; }
if (v && typeof v.length === 'number' && typeof v.push === 'function') dlNames.push(k);
});
} catch (e) {}
}
function dlTick() {
for (var i = 0; i < dlNames.length; i++) {
flush(dlNames[i]); // dogania wszystko, nawet gdy GTM zjadł nasz wrap
hookPush(dlNames[i]); // re-hook po ewentualnym nadpisaniu przez GTM
}
}
scanCustomNames();
dlTick(); // złap wpisy istniejące już przy document-start
setInterval(dlTick, 300);
setInterval(scanCustomNames, 5000);
// ── komendy z userscriptu ──
document.addEventListener('pk-gtmc-cmd', function (ev) {
var d; try { d = JSON.parse(ev.detail); } catch (e) { return; }
if (d.a === 'snapshot') snapshot();
else if (d.a === 'consent') consent();
else if (d.a === 'push') {
try {
window.dataLayer = window.dataLayer || [];
if (d.clear) window.dataLayer.push({ ecommerce: null });
window.dataLayer.push(d.data);
} catch (e) {}
}
});
send('ready', 1);
}
function injectBridge() {
try {
const s = document.createElement('script');
s.textContent = '(' + pageBridge.toString() + ')();';
(document.documentElement || document.head || document.body).appendChild(s);
s.remove();
} catch (e) {}
}
function sendCmd(a, extra) {
try {
document.dispatchEvent(new CustomEvent('pk-gtmc-cmd', { detail: JSON.stringify(Object.assign({ a: a }, extra || {})) }));
} catch (e) {}
}
/* ═══════════════════ ZDARZENIA DL (bufor po stronie userscriptu) ═══════════════════ */
const DL_MAX = 300;
let dlEvents = []; // chronologicznie; render od końca (najnowsze na górze)
let dlSeq = 0;
let dlPaused = false;
let dlHideSys = false; // filtr gtm.*
let dlFilter = '';
const dlExpanded = new Set();
function dlEventName(d) {
if (d.kind === 'gtag') {
const a = d.e;
const parts = Array.isArray(a) ? a.slice(0, 2).map(x => typeof x === 'string' ? x : (x === null ? 'null' : typeof x)) : [];
return 'gtag(' + parts.join(', ') + (Array.isArray(a) && a.length > 2 ? ', …' : '') + ')';
}
const e = d.e;
if (e && typeof e === 'object' && !Array.isArray(e)) {
if (e.event) return String(e.event);
const ks = Object.keys(e);
if (ks.length) return '{' + ks.slice(0, 3).join(', ') + (ks.length > 3 ? ', …' : '') + '}';
return '{}';
}
if (typeof e === 'string') return JSON.stringify(e);
return String(e);
}
function dlEventClass(d) {
if (d.kind === 'gtag') return 'gt';
const e = d.e;
if (e && typeof e === 'object' && e.ecommerce) return 'ec';
if (e && typeof e === 'object' && typeof e.event === 'string' && e.event.indexOf('gtm.') === 0) return 'sys';
return '';
}
function dlIsSys(d) {
const e = d.e;
return !!(e && typeof e === 'object' && typeof e.event === 'string' && e.event.indexOf('gtm.') === 0);
}
function dlMatches(rec) {
if (dlHideSys && dlIsSys(rec.d)) return false;
if (!dlFilter) return true;
const q = dlFilter.toLowerCase();
if (rec.name.toLowerCase().includes(q)) return true;
try { return JSON.stringify(rec.d.e).toLowerCase().includes(q); } catch (e) { return false; }
}
function addDlEvent(d, ts) {
const rec = { id: ++dlSeq, ts: ts || Date.now(), d: d, name: dlEventName(d), cls: dlEventClass(d) };
dlEvents.push(rec);
if (dlEvents.length > DL_MAX) {
const drop = dlEvents.splice(0, dlEvents.length - DL_MAX);
drop.forEach(r => dlExpanded.delete(r.id));
}
if (root && activeTab === 'dl' && !dlPaused) dlLiveInsert(rec);
else if (root && activeTab === 'dl') updateDlFoot();
}
/* ═══════════════════ ODBIÓR Z BRIDGE'A ═══════════════════ */
document.addEventListener('pk-gtmc', ev => {
let msg; try { msg = JSON.parse(ev.detail); } catch (e) { return; }
if (msg.type === 'ready') { bridgeReady = true; return; }
if (msg.type === 'snapshot') { lastSnapshot = msg.data; return; }
if (msg.type === 'consent') { lastConsent = msg.data; return; }
if (msg.type === 'dl') { addDlEvent(msg.data, msg.ts); }
});
/* ═══════════════════ GTM INJECTION ═══════════════════ */
const injectedIds = new Set();
function injectGTM(containerId, dlName) {
dlName = dlName || 'dataLayer';
if (injectedIds.has(containerId) ||
document.querySelector('script[src*="googletagmanager.com/gtm.js?id=' + containerId + '"]')) {
console.log('%c[GTM Checker] ' + containerId + ' już istnieje — pomijam', 'color:#eab308;');
return false;
}
injectedIds.add(containerId);
const s = document.createElement('script');
s.textContent = `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','${dlName.replace(/[^A-Za-z0-9_$]/g, '')}','${containerId}');
`;
document.documentElement.appendChild(s);
const insertNoscript = () => {
const ns = document.createElement('noscript');
const iframe = document.createElement('iframe');
iframe.src = 'https://www.googletagmanager.com/ns.html?id=' + containerId;
iframe.height = '0'; iframe.width = '0';
iframe.style.display = 'none'; iframe.style.visibility = 'hidden';
ns.appendChild(iframe);
document.body.insertBefore(ns, document.body.firstChild);
};
if (document.body) insertNoscript();
else document.addEventListener('DOMContentLoaded', insertNoscript);
console.log('%c[GTM Checker] ✓ Wstrzyknięto: ' + containerId, 'color:#22c55e;font-weight:bold;');
return true;
}
let autoInjectedId = null;
if (!isBlockedHost()) injectBridge();
(function autoInject() {
if (isBlockedHost()) return;
const dom = getDomainInject();
if (dom) { injectGTM(dom.id); autoInjectedId = dom.id; }
})();
/* ═══════════════════ BIBLIOTEKA KOMEND ═══════════════════ */
const COMMANDS = [
{ label: 'dataLayer', code: 'dataLayer', desc: 'Klasyk — jakie eventy i parametry są pushowane (GTM)' },
{ label: 'dataLayer — tylko eventy', code: 'dataLayer.filter(e => e.event)', desc: 'Odfiltrowane pushe bez eventu' },
{ label: 'dataLayer — tabela eventów', code: "console.table(dataLayer.filter(e => e.event).map((e,i) => ({'#': i, event: e.event})))", desc: 'Czytelna lista kolejności eventów' },
{ label: 'dataLayer — ostatni ecommerce', code: 'dataLayer.filter(e => e.ecommerce).pop()', desc: 'Ostatni push e-commerce (items, value, currency)' },
{ label: 'dataLayer — kopiuj jako JSON', code: 'copy(JSON.stringify(dataLayer, null, 2))', desc: 'Cały dataLayer do schowka — idealne do wklejenia AI' },
{ label: 'Live monitor dataLayer', code: "(function(){var p=dataLayer.push;dataLayer.push=function(){console.log('%c[DL push]','color:#22c55e;font-weight:bold',...arguments);return p.apply(this,arguments)}})()", desc: 'Loguje na żywo każdy kolejny push do dataLayer' },
{ label: 'window.google_tag_manager', code: 'window.google_tag_manager', desc: 'Czy GTM jest obecny i które kontenery działają' },
{ label: 'Google Consent Mode', code: "window.google_tag_data && google_tag_data.ics ? google_tag_data.ics.entries : 'brak google_tag_data.ics'", desc: 'Stan consent mode per storage type' },
{ label: 'document.referrer', code: 'document.referrer', desc: 'Skąd przyszedł użytkownik' },
{ label: 'Parametry UTM z URL', code: "Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^(utm_|gclid|fbclid|msclkid)/.test(k)))", desc: 'utm_*, gclid, fbclid, msclkid z bieżącego URL' },
{ label: 'document.cookie', code: 'document.cookie', desc: 'Wszystkie cookies dostępne z JS' },
{ label: 'Cookies GA (_ga*)', code: "document.cookie.split('; ').filter(c => /^_ga/.test(c))", desc: 'Client ID i cookies sesji GA4' },
{ label: 'Cookies Meta (_fbp / _fbc)', code: "document.cookie.split('; ').filter(c => /^_fb[pc]/.test(c))", desc: 'Browser ID i click ID Meta — kluczowe dla CAPI' },
{ label: 'Zasoby / requesty', code: 'performance.getEntriesByType("resource")', desc: 'Co faktycznie się załadowało (w tym trackery)' },
{ label: 'Requesty trackerów', code: 'performance.getEntriesByType("resource").map(r => r.name).filter(n => /collect|gtm\\.js|gtag|facebook|clarity|hotjar|tiktok|doubleclick/i.test(n))', desc: 'Tylko hity trackingowe — walidacja czy coś odpaliło' },
{ label: 'Consent (Usercentrics)', code: "localStorage.getItem('uc_settings')", desc: 'Stan zgody, jeśli stoi Usercentrics' },
{ label: 'Iframes', code: "document.querySelectorAll('iframe')", desc: 'Osadzone narzędzia i skrypty 3rd party' },
{ label: 'Ukryte pola formularzy', code: "document.querySelectorAll('input[type=\"hidden\"]')", desc: 'Czy formularz zbiera dane trackingowe' },
{ label: 'Skrypty zewnętrzne', code: '[...document.scripts].map(s => s.src).filter(Boolean)', desc: 'Lista wszystkich zewnętrznych <script>' },
{ label: 'Timing ładowania strony', code: "(({domContentLoadedEventEnd:d,loadEventEnd:l,responseStart:r}) => ({TTFB_ms: Math.round(r), DOMContentLoaded_ms: Math.round(d), Load_ms: Math.round(l)}))(performance.getEntriesByType('navigation')[0])", desc: 'TTFB, DOMContentLoaded, Load — szybki health check' },
{ label: 'Niestandardowe dataLayer', code: "Object.keys(window).filter(k => /datalayer/i.test(k))", desc: 'Znajdź niestandardowe nazwy dataLayer w window' },
];
/* ═══════════════════ FILTRY DEVTOOLS → NETWORK ═══════════════════ */
// Składnia pola filtra w Network: zwykły tekst = substring URL, /regex/,
// domain:*.x.com, method:POST, -term = negacja, kilka warunków po spacji = AND.
const NET_FILTERS = [
{ vendor: 'Google Analytics 4 (GA4)', aliases: ['google analytics', 'ga4', 'ga', 'gtag', 'analytics', 'collect', 'sgtm', 'server side'], items: [
{ f: 'g/collect', d: 'Hity GA4 — działa też dla sGTM na własnej domenie' },
{ f: 'g/collect en=', d: 'Hity z nazwą eventu w URL (pojedyncze, nie batch)' },
{ f: 'g/collect en=purchase', d: 'Tylko purchase (jeśli nie poszedł w batchu POST)' },
{ f: 'g/collect _dbg=1', d: 'Hity z flagą DebugView' },
{ f: 'domain:*.google-analytics.com', d: 'Wszystko do google-analytics.com (regiony też)' },
{ f: 'g/collect -domain:*.google-analytics.com -domain:analytics.google.com', d: 'Tylko hity do sGTM / first-party (bez Google)' },
]},
{ vendor: 'Google Tag Manager (GTM) / gtag.js', aliases: ['google tag manager', 'gtm', 'gtag', 'tag manager', 'container', 'kontener', 'preview', 'tag assistant'], items: [
{ f: 'gtm.js', d: 'Ładowanie kontenera GTM' },
{ f: 'gtag/js', d: 'Ładowanie biblioteki gtag.js' },
{ f: 'googletagmanager.com', d: 'Wszystko z GTM (gtm.js, gtag/js, gtm/debug)' },
{ f: 'gtm/debug', d: 'Requesty trybu Preview / Tag Assistant' },
]},
{ vendor: 'Google Ads (AdWords)', aliases: ['google ads', 'gads', 'adwords', 'aw', 'ads', 'conversion', 'konwersje', 'remarketing', 'doubleclick', 'dc', 'floodlight'], items: [
{ f: 'pagead/conversion', d: 'Hity konwersji (googleadservices)' },
{ f: 'pagead/viewthroughconversion', d: 'Remarketing / view-through (doubleclick)' },
{ f: 'pagead/1p-conversion', d: 'Konwersje first-party (enhanced conversions)' },
{ f: '/googleadservices|doubleclick\\.net|googlesyndication/', d: 'Regex: cały ekosystem Google Ads' },
]},
{ vendor: 'Meta Pixel (Facebook / Instagram)', aliases: ['meta', 'facebook', 'fb', 'fbq', 'instagram', 'ig', 'pixel', 'piksel', 'capi', 'fbp', 'fbc'], items: [
{ f: 'facebook.com/tr', d: 'Hity pixela (PageView, Purchase…)' },
{ f: 'facebook.com/tr ev=Purchase', d: 'Tylko event Purchase' },
{ f: 'connect.facebook.net', d: 'Ładowanie fbevents.js' },
{ f: '/facebook\\.com\\/tr|connect\\.facebook\\.net/', d: 'Regex: biblioteka + hity' },
]},
{ vendor: 'TikTok Pixel', aliases: ['tiktok', 'tt', 'ttq', 'pixel'], items: [
{ f: 'analytics.tiktok.com', d: 'Biblioteka + hity' },
{ f: 'tiktok.com/api/v2/pixel', d: 'Same hity eventów' },
]},
{ vendor: 'Microsoft Clarity', aliases: ['clarity', 'ms clarity', 'microsoft', 'heatmap', 'heatmapa', 'nagrania', 'session recording'], items: [
{ f: 'clarity.ms', d: 'Biblioteka + collect' },
{ f: 'clarity.ms/collect', d: 'Same hity sesji' },
]},
{ vendor: 'Hotjar', aliases: ['hotjar', 'hj', 'heatmap', 'heatmapa', 'nagrania', 'session recording'], items: [
{ f: 'hotjar', d: 'hotjar.com + hotjar.io (biblioteka, sesje, WS)' },
]},
{ vendor: 'LinkedIn Insight Tag', aliases: ['linkedin', 'li', 'insight', 'licdn', 'b2b'], items: [
{ f: 'px.ads.linkedin.com', d: 'Hity (collect)' },
{ f: 'snap.licdn.com', d: 'Ładowanie insight.min.js' },
]},
{ vendor: 'Microsoft Ads (Bing UET)', aliases: ['microsoft ads', 'bing', 'bing ads', 'uet', 'msads', 'uetq'], items: [
{ f: 'bat.bing.com', d: 'Biblioteka + hity UET' },
{ f: 'bat.bing.com/action', d: 'Same hity eventów' },
]},
{ vendor: 'Pinterest Tag', aliases: ['pinterest', 'pin', 'pintrk'], items: [
{ f: 'ct.pinterest.com', d: 'Biblioteka + hity' },
]},
{ vendor: 'X (Twitter) Pixel', aliases: ['x', 'twitter', 'twq'], items: [
{ f: '/ads-twitter\\.com|t\\.co\\/i\\/adsct/', d: 'Regex: biblioteka + hity' },
]},
{ vendor: 'Snapchat Pixel', aliases: ['snap', 'snapchat', 'snaptr'], items: [
{ f: 'tr.snapchat.com', d: 'Hity pixela' },
]},
{ vendor: 'Criteo', aliases: ['criteo', 'retargeting'], items: [
{ f: 'criteo', d: 'static.criteo.net + hity' },
]},
{ vendor: 'HubSpot', aliases: ['hubspot', 'hs', 'hsq', 'crm', 'formularze'], items: [
{ f: '/hs-scripts|hs-analytics|hsforms|hubspot/', d: 'Regex: tracking + formularze' },
]},
{ vendor: 'Matomo (Piwik)', aliases: ['matomo', 'piwik', 'paq', 'self-hosted'], items: [
{ f: 'matomo.php', d: 'Hity (lub piwik.php w starszych)' },
]},
{ vendor: 'CMP / Consent (zgody)', aliases: ['cmp', 'consent', 'zgody', 'cookie', 'cookies', 'rodo', 'gdpr', 'usercentrics', 'cookiebot', 'onetrust', 'cookieyes', 'banner'], items: [
{ f: 'usercentrics', d: 'Usercentrics' },
{ f: 'consent.cookiebot.com', d: 'Cookiebot' },
{ f: 'cookielaw.org', d: 'OneTrust' },
{ f: 'cdn-cookieyes.com', d: 'CookieYes' },
]},
{ vendor: 'Ogólne / wszystko', aliases: ['ogólne', 'ogolne', 'all', 'wszystko', 'third party', '3rd party', 'post', 'błędy', 'bledy', 'blokada', 'adblock', 'cache'], items: [
{ f: '/collect|gtm\\.js|gtag|facebook\\.com\\/tr|clarity\\.ms|hotjar|tiktok|doubleclick|linkedin|pinterest|bing\\.com\\/action/', d: 'Regex: wszystkie hity trackerów naraz' },
{ f: '-domain:*.' + location.hostname.replace(/^www\./, '') + ' -domain:' + location.hostname, d: 'Tylko requesty poza tę domenę (3rd party)', dyn: true },
{ f: 'method:POST', d: 'Same POST-y (batch GA4, Meta, sGTM)' },
{ f: '-status-code:200 -status-code:204 -status-code:302', d: 'Wszystko, co nie wróciło OK — blokady, błędy' },
{ f: 'is:from-cache', d: 'Tylko z cache (czy biblioteka faktycznie się pobrała?)' },
]},
];
let netQuery = '';
function renderNet(panel) {
panel.innerHTML = `
<div class="at-dl-bar">
<input class="at-input" id="at-net-q" placeholder="szukaj: ga4, meta|tiktok, ^google…" value="${escAttr(netQuery)}">
</div>
<div class="at-note" style="margin:0 0 6px 2px">Kliknij, aby skopiować → wklej w pole filtra ▽ w DevTools → Network. Tekst = substring URL, <b>/…/</b> = regex, <b>-</b> = negacja, spacja = AND.</div>
<div id="at-net-list"></div>
`;
const list = panel.querySelector('#at-net-list');
const draw = () => {
list.innerHTML = '';
const q = netQuery.toLowerCase();
// szukajka: regex, jeśli się parsuje (np. "ga4|meta", "^goo"); inaczej substring
let m;
try { const re = new RegExp(netQuery, 'i'); m = str => re.test(str); }
catch (e) { m = str => str.toLowerCase().includes(q); }
let n = 0;
NET_FILTERS.forEach(g => {
const vendorHit = !q || m(g.vendor) || (g.aliases || []).some(a => m(a));
const items = g.items.filter(it => vendorHit || m(it.f) || m(it.d));
if (!items.length) return;
const lab = document.createElement('div');
lab.className = 'at-label';
lab.textContent = g.vendor;
list.appendChild(lab);
items.forEach(it => {
n++;
const row = document.createElement('div');
row.className = 'at-cmd';
row.innerHTML = `<div class="at-cmd-code" style="font-size:11px;color:#bfdbfe;white-space:normal;word-break:break-all">${escHtml(it.f)} <span class="at-copied" style="display:none">✓ skopiowano</span></div>` +
`<div class="at-cmd-desc">${escHtml(it.d)}</div>`;
row.addEventListener('click', () => {
copyText(it.f);
const c = row.querySelector('.at-copied');
c.style.display = '';
setTimeout(() => { c.style.display = 'none'; }, 900);
});
list.appendChild(row);
});
});
if (!n) list.innerHTML = '<div class="at-empty">Nic nie pasuje.</div>';
root.querySelector('#at-foot-txt').textContent = `${n} filtrów Network`;
};
panel.querySelector('#at-net-q').addEventListener('input', ev => { netQuery = ev.target.value.trim(); draw(); });
panel.querySelector('#at-net-q').addEventListener('keydown', e => e.stopPropagation());
draw();
}
/* ═══════════════════ PRESETY PUSHY E-COMMERCE ═══════════════════ */
// Neutralne dane testowe — łatwe do odfiltrowania w raportach (item_id TEST-*, transaction_id TEST-*).
// Liczba produktów wybierana w zakładce Push (1 / 2 / 3 / 5) i pamiętana per domena.
// Produkt k ma price = k, więc przy 2 produktach value = 1 + 2 = 3 — od razu widać,
// czy GA4 / Ads / Meta liczą sumę, a nie np. tylko pierwszy item.
const ITEM_COUNTS = [1, 2, 3, 5];
let itemCount = 1;
try {
const n = parseInt(localStorage.getItem(LS_ITEMS), 10);
if (ITEM_COUNTS.includes(n)) itemCount = n;
} catch (e) {}
function setItemCount(n) {
itemCount = n;
try { localStorage.setItem(LS_ITEMS, String(n)); } catch (e) {}
}
function round2(x) { return Math.round(x * 100) / 100; }
function sampleItems(n) {
n = Math.max(1, n || 1);
const out = [];
for (let i = 0; i < n; i++) {
out.push({
item_id: 'TEST-' + String(i + 1).padStart(3, '0'),
item_name: 'Produkt testowy ' + (i + 1),
item_brand: 'Noname',
item_category: 'Testowe',
item_list_name: 'Lista testowa',
index: i,
price: i + 1,
quantity: 1,
});
}
return out;
}
function itemsValue(items) {
return round2(items.reduce((s, it) => s + (Number(it.price) || 0) * (Number(it.quantity) || 1), 0));
}
function txId() { return 'TEST-' + Date.now(); }
function plProd(n) {
if (n === 1) return '1 produkt';
const d = n % 10, dd = n % 100;
return n + (d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? ' produkty' : ' produktów');
}
// Standardowy event ecommerce: currency, value (= suma items), parametry dodatkowe, items
function ecomEvent(event, n, extra) {
const items = sampleItems(n);
return { event: event, ecommerce: Object.assign({ currency: 'PLN', value: itemsValue(items) }, extra || {}, { items: items }) };
}
// Grupy presetów = podzakładki w zakładce Push.
const PRESET_GROUPS = [
{ id: 'ecom', label: '🛒 Ecommerce' },
{ id: 'lead', label: '📨 Leady' },
];
const SS_PUSH_GRP = 'pkGtmcPushGrp'; // sessionStorage — ostatnio wybrana grupa presetów
let presetGroup = 'ecom';
try {
const g = sessionStorage.getItem(SS_PUSH_GRP);
if (PRESET_GROUPS.some(x => x.id === g)) presetGroup = g;
} catch (e) {}
// W obrębie grupy kolejność = ważność: konwersje i dół lejka na górze, mikro-eventy na dole.
// items: true → preset bierze liczbę produktów z przełącznika
// items: 1 → zawsze 1 produkt (GA4 dla select_item oczekuje jednego itemu)
// brak items → event bez produktów
// Leady: rekomendowane eventy GA4 dla lead gen (cykl życia leada) + konto.
const PRESETS = [
// ── Ecommerce ──
{ group: 'ecom', label: 'purchase', desc: 'Zakup (nowy transaction_id przy każdym wczytaniu)', items: true, build: n => {
const items = sampleItems(n);
const value = itemsValue(items);
return { event: 'purchase', ecommerce: { transaction_id: txId(), value: value, tax: round2(value - value / 1.23), shipping: 0, currency: 'PLN', items: items } };
} },
{ group: 'ecom', label: 'begin_checkout', desc: 'Rozpoczęcie checkoutu', items: true, build: n => ecomEvent('begin_checkout', n) },
{ group: 'ecom', label: 'add_payment_info', desc: 'Wybór płatności', items: true, build: n => ecomEvent('add_payment_info', n, { payment_type: 'Testowa płatność' }) },
{ group: 'ecom', label: 'add_shipping_info', desc: 'Wybór dostawy', items: true, build: n => ecomEvent('add_shipping_info', n, { shipping_tier: 'Testowa dostawa' }) },
{ group: 'ecom', label: 'add_to_cart', desc: 'Dodanie do koszyka', items: true, build: n => ecomEvent('add_to_cart', n) },
{ group: 'ecom', label: 'view_cart', desc: 'Wyświetlenie koszyka', items: true, build: n => ecomEvent('view_cart', n) },
{ group: 'ecom', label: 'view_item', desc: 'Wyświetlenie karty produktu', items: true, build: n => ecomEvent('view_item', n) },
{ group: 'ecom', label: 'refund', desc: 'Zwrot (pełny)', items: true, build: n => {
const items = sampleItems(n);
return { event: 'refund', ecommerce: { transaction_id: txId(), value: itemsValue(items), currency: 'PLN', items: items } };
} },
{ group: 'ecom', label: 'select_item', desc: 'Kliknięcie produktu na liście (zawsze 1 produkt)', items: 1, build: () => ({ event: 'select_item', ecommerce: { item_list_id: 'lista_testowa', item_list_name: 'Lista testowa', items: sampleItems(1) } }) },
{ group: 'ecom', label: 'view_item_list', desc: 'Wyświetlenie listy produktów', items: true, build: n => ({ event: 'view_item_list', ecommerce: { item_list_id: 'lista_testowa', item_list_name: 'Lista testowa', items: sampleItems(n) } }) },
{ group: 'ecom', label: 'remove_from_cart', desc: 'Usunięcie z koszyka', items: true, build: n => ecomEvent('remove_from_cart', n) },
{ group: 'ecom', label: 'add_to_wishlist', desc: 'Dodanie do listy życzeń', items: true, build: n => ecomEvent('add_to_wishlist', n) },
// ── Leady ──
{ group: 'lead', label: 'generate_lead', desc: 'Wysłanie formularza / nowy lead', build: () => ({ event: 'generate_lead', currency: 'PLN', value: 1, lead_source: 'test' }) },
{ group: 'lead', label: 'qualify_lead', desc: 'Lead zakwalifikowany (MQL / SQL)', build: () => ({ event: 'qualify_lead', currency: 'PLN', value: 1 }) },
{ group: 'lead', label: 'working_lead', desc: 'Kontakt z leadem w toku', build: () => ({ event: 'working_lead', currency: 'PLN', value: 1, lead_status: 'Rozmowa z handlowcem' }) },
{ group: 'lead', label: 'close_convert_lead', desc: 'Lead zamieniony w klienta', build: () => ({ event: 'close_convert_lead', currency: 'PLN', value: 1 }) },
{ group: 'lead', label: 'disqualify_lead', desc: 'Lead odrzucony', build: () => ({ event: 'disqualify_lead', currency: 'PLN', value: 1, disqualified_lead_reason: 'Testowy powód' }) },
{ group: 'lead', label: 'close_unconvert_lead', desc: 'Lead zamknięty bez sprzedaży', build: () => ({ event: 'close_unconvert_lead', currency: 'PLN', value: 1, unconvert_lead_reason: 'Testowy powód' }) },
{ group: 'lead', label: 'sign_up', desc: 'Rejestracja konta', build: () => ({ event: 'sign_up', method: 'email' }) },
{ group: 'lead', label: 'login', desc: 'Logowanie użytkownika', build: () => ({ event: 'login', method: 'email' }) },
];
/* ═══════════════════ PARSER WŁASNEGO DATALAYER ═══════════════════ */
// Przyjmuje to, co realnie się kopiuje:
// • GTM Preview → "Data Layer" / "Message" — notacja JS: klucze bez cudzysłowów,
// także z kropką (gtm.uniqueEventId: 25), wartości undefined itd.
// • zwykły JSON, tablicę pushy (np. copy(JSON.stringify(dataLayer)))
// • snippet z dataLayer.push({...}) — także kilka wywołań naraz
// • tekst z numerami linii z gutteru Preview (fallback)
// Bez eval/new Function — CSP strony tego nie zablokuje.
function makeLooseParser(s) {
let i = 0;
const UNDEF = {};
const KEY = /[A-Za-z0-9_$.\-]+/y;
const BARE = /[^,}\]\)\r\n]+/y;
const NUM = /^[+-]?(?:\d+\.?\d*(?:e[+-]?\d+)?|\.\d+(?:e[+-]?\d+)?|0x[0-9a-f]+)$/i;
function fail(msg) {
const p = Math.min(i, s.length);
const before = s.slice(0, p);
const line = before.split('\n').length;
const col = p - before.lastIndexOf('\n');
let hint = '';
if (s.indexOf('…') >= 0) hint = ' — w tekście jest „…” (zwinięty obiekt z konsoli?) — skopiuj pełny, np. copy(dataLayer[N])';
throw new Error(msg + ' — linia ' + line + ', kol. ' + col + hint);
}
function ws() {
for (;;) {
const c = s[i];
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\u00a0' || c === '\ufeff') { i++; continue; }
if (c === '/' && s[i + 1] === '/') { while (i < s.length && s[i] !== '\n') i++; continue; }
if (c === '/' && s[i + 1] === '*') { const j = s.indexOf('*/', i + 2); i = j < 0 ? s.length : j + 2; continue; }
break;
}
}
function str() {
const q = s[i++];
let out = '';
while (i < s.length) {
const c = s[i++];
if (c === q) return out;
if (c === '\\') {
const n = s[i++];
if (n === 'u') { out += String.fromCharCode(parseInt(s.substr(i, 4), 16)); i += 4; }
else if (n === 'x') { out += String.fromCharCode(parseInt(s.substr(i, 2), 16)); i += 2; }
else if (n === '\n') { /* kontynuacja linii */ }
else {
const map = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' };
out += map[n] !== undefined ? map[n] : n;
}
} else out += c;
}
fail('Niezamknięty string');
}
function key() {
ws();
const c = s[i];
if (c === '"' || c === "'" || c === '`') return str();
KEY.lastIndex = i;
const m = KEY.exec(s);
if (!m) fail('Oczekiwano klucza');
i += m[0].length;
return m[0];
}
function obj() {
i++;
const o = {};
for (;;) {
ws();
if (s[i] === '}') { i++; return o; }
const k = key();
ws();
if (s[i] !== ':') fail('Oczekiwano „:” po kluczu „' + k + '”');
i++;
const v = value();
if (v !== UNDEF) o[k] = v;
ws();
if (s[i] === ',') { i++; continue; }
if (s[i] === '}') { i++; return o; }
fail('Oczekiwano „,” lub „}”');
}
}
function arr() {
i++;
const a = [];
for (;;) {
ws();
if (s[i] === ']') { i++; return a; }
const v = value();
a.push(v === UNDEF ? null : v);
ws();
if (s[i] === ',') { i++; continue; }
if (s[i] === ']') { i++; return a; }
fail('Oczekiwano „,” lub „]”');
}
}
function value() {
ws();
const c = s[i];
if (c === '{') return obj();
if (c === '[') return arr();
if (c === '"' || c === "'" || c === '`') return str();
BARE.lastIndex = i;
const m = BARE.exec(s);
const t = m ? m[0].trim() : '';
if (!t) fail('Oczekiwano wartości');
i += m[0].length;
if (t === 'true') return true;
if (t === 'false') return false;
if (t === 'null') return null;
if (t === 'undefined') return UNDEF;
if (t === 'NaN' || t === 'Infinity' || t === '-Infinity') return null;
if (NUM.test(t)) return Number(t);
return t; // np. "HTMLDivElement: …" z gtm.element — zostaje jako string
}
return {
// wywołanie dataLayer.push( … ) — od pozycji po "(" do ")"
parseArgs(pos) {
i = pos;
const vals = [];
ws();
if (s[i] === ')') { i++; return { values: vals, end: i }; }
for (;;) {
const v = value();
vals.push(v === UNDEF ? null : v);
ws();
if (s[i] === ',') { i++; continue; }
if (s[i] === ')') { i++; return { values: vals, end: i }; }
fail('Oczekiwano „,” lub „)” w dataLayer.push(…)');
}
},
// jeden lub kilka obiektów/tablic jeden po drugim
parseAll() {
i = 0;
const vals = [];
ws();
while (i < s.length) {
const v = value();
vals.push(v === UNDEF ? null : v);
ws();
while (s[i] === ';' || s[i] === ',') { i++; ws(); }
}
if (!vals.length) fail('Pusty tekst');
return vals;
},
};
}
function parseLayerText(txt) {
const s = txt.replace(/^\uFEFF/, '');
const P = makeLooseParser(s);
const PUSH_RE = /dataLayer\s*\.\s*push\s*\(/g;
if (PUSH_RE.test(s)) {
PUSH_RE.lastIndex = 0;
const out = [];
let m;
while ((m = PUSH_RE.exec(s))) {
const r = P.parseArgs(m.index + m[0].length);
out.push(...r.values);
PUSH_RE.lastIndex = r.end;
}
return out;
}
return P.parseAll();
}
// Fallback, gdy w schowku wylądowały numery linii z gutteru GTM Preview
function stripLineNumbers(txt) {
return txt.split(/\r?\n/)
.filter(l => !/^\s*\d+\s*$/.test(l))
.map(l => l.replace(/^\s*\d+(?:[ \t]+|(?=[{}\[\]A-Za-z_$"'`]))/, ''))
.join('\n');
}
function parseCustomLayer(txt) {
let vals;
try { vals = parseLayerText(txt); }
catch (e1) {
const stripped = stripLineNumbers(txt);
if (stripped === txt) throw e1;
try { vals = parseLayerText(stripped); } catch (e2) { throw e1; }
}
// tablica obiektów = lista pushy (np. skopiowany cały dataLayer)
const raw = [];
vals.forEach(v => { if (Array.isArray(v)) raw.push(...v); else raw.push(v); });
const entries = [];
const dropped = new Set();
let skipped = 0;
raw.forEach(v => {
if (!v || typeof v !== 'object' || Array.isArray(v)) { skipped++; return; } // gtag()/arguments, prymitywy
const o = {};
Object.keys(v).forEach(k => {
// gtm / gtm.uniqueEventId / gtm.start / gtm.element… — GTM nada własne
if (k === 'gtm' || k.indexOf('gtm.') === 0 || k === 'eventModel') { dropped.add(k); return; } // eventModel = wewnętrzna kopia GTM z Preview
o[k] = v[k];
});
if (typeof o.event === 'string' && o.event.indexOf('gtm.') === 0) { skipped++; return; } // gtm.js, gtm.dom…
const ks = Object.keys(o);
if (!ks.length) { skipped++; return; }
if (ks.length === 1 && ks[0] === 'ecommerce' && o.ecommerce === null) { skipped++; return; } // czyszczenie dodajemy sami
entries.push(o);
});
if (!entries.length) {
throw new Error('Brak obiektów do pushowania' + (skipped ? ' (pominięto ' + skipped + ' systemowych / pustych / gtag)' : ''));
}
return { entries: entries, dropped: [...dropped], skipped: skipped };
}
function pushEntries(entries) {
entries.forEach(e => sendCmd('push', { data: e, clear: !!(e && e.ecommerce) }));
}
function entriesToSnippet(entries) {
return 'window.dataLayer = window.dataLayer || [];\n' + entries.map(e =>
(e && e.ecommerce ? 'dataLayer.push({ ecommerce: null });\n' : '') +
'dataLayer.push(' + JSON.stringify(e, null, 2) + ');'
).join('\n');
}
/* ═══════════════════ DETEKTOR PIXELI ═══════════════════ */
const TRACKERS = [
{ name: 'Google Tag Manager', re: /googletagmanager\.com\/gtm\.js/i, global: 'google_tag_manager',
ids: (u, s) => (s ? s.gtmKeys.filter(k => /^GTM-/i.test(k)) : []) },
{ name: 'GA4 / gtag', re: /googletagmanager\.com\/gtag\/js|google-analytics\.com|analytics\.google\.com\/g\/collect/i, global: 'gtag',
ids: (urls, s) => {
const out = new Set(s ? s.gtmKeys.filter(k => /^G-/i.test(k)) : []);
urls.forEach(u => {
let m = u.match(/gtag\/js\?id=(G-[A-Z0-9]+)/i); if (m) out.add(m[1].toUpperCase());
m = u.match(/\/g\/collect\?[^ ]*tid=(G-[A-Z0-9]+)/i); if (m) out.add(m[1].toUpperCase());
});
return [...out];
} },
{ name: 'Google Ads / DoubleClick', re: /googleadservices\.com|doubleclick\.net|googlesyndication/i, global: null,
ids: (u, s) => (s ? s.gtmKeys.filter(k => /^(AW|DC)-/i.test(k)) : []) },
{ name: 'Meta Pixel', re: /connect\.facebook\.net|facebook\.com\/tr/i, global: 'fbq',
ids: (urls, s) => {
const out = new Set(s ? s.fbqIds : []);
urls.forEach(u => { const m = u.match(/facebook\.com\/tr[/?].*?id=(\d+)/i); if (m) out.add(m[1]); });
return [...out];
} },
{ name: 'Microsoft Clarity', re: /clarity\.ms/i, global: 'clarity',
ids: (urls) => { const out = new Set(); urls.forEach(u => { const m = u.match(/clarity\.ms\/tag\/([a-z0-9]+)/i); if (m) out.add(m[1]); }); return [...out]; } },
{ name: 'TikTok Pixel', re: /analytics\.tiktok\.com/i, global: 'ttq',
ids: (urls) => { const out = new Set(); urls.forEach(u => { const m = u.match(/sdkid=([A-Z0-9]+)/i); if (m) out.add(m[1]); }); return [...out]; } },
{ name: 'Hotjar', re: /static\.hotjar\.com|script\.hotjar\.com/i, global: 'hj',
ids: (u, s) => (s && s.hjid ? [s.hjid] : []) },
{ name: 'LinkedIn Insight', re: /snap\.licdn\.com|px\.ads\.linkedin\.com/i, global: '_linkedin_partner_id',
ids: (u, s) => (s && s.liId ? [s.liId] : []) },
{ name: 'Pinterest Tag', re: /ct\.pinterest\.com|s\.pinimg\.com\/ct/i, global: 'pintrk', ids: () => [] },
{ name: 'Microsoft Ads (UET)', re: /bat\.bing\.com/i, global: 'uetq', ids: () => [] },
{ name: 'X / Twitter Pixel', re: /static\.ads-twitter\.com|t\.co\/i\/adsct/i, global: 'twq', ids: () => [] },
{ name: 'Snap Pixel', re: /sc-static\.net|tr\.snapchat\.com/i, global: 'snaptr', ids: () => [] },
{ name: 'Criteo', re: /static\.criteo\.net|criteo\.com/i, global: 'criteo_q', ids: () => [] },
{ name: 'HubSpot', re: /js\.hs-scripts\.com|hs-analytics\.net|hsforms/i, global: '_hsq', ids: () => [] },
{ name: 'Matomo / Piwik', re: /matomo\.js|piwik\.js|matomo\.php/i, global: '_paq', ids: () => [] },
{ name: 'Usercentrics (CMP)', re: /usercentrics\.eu|usercentrics\.com/i, global: 'UC_UI', ids: () => [] },
{ name: 'Cookiebot (CMP)', re: /consent\.cookiebot\.com/i, global: 'Cookiebot', ids: () => [] },
{ name: 'OneTrust (CMP)', re: /cdn\.cookielaw\.org|onetrust/i, global: 'OneTrust', ids: () => [] },
{ name: 'CookieYes (CMP)', re: /cdn-cookieyes\.com/i, global: null, ids: () => [] },
{ name: 'Consentmanager (CMP)', re: /consentmanager\.net|delivery\.consentmanager/i, global: '__cmp', ids: () => [] },
];
function detectTrackers() {
sendCmd('snapshot'); // dispatch jest synchroniczny — lastSnapshot aktualny
const s = lastSnapshot;
let urls = [];
try { urls = performance.getEntriesByType('resource').map(r => r.name); } catch (e) {}
[...document.scripts].forEach(sc => { if (sc.src) urls.push(sc.src); });
document.querySelectorAll('iframe[src]').forEach(f => urls.push(f.src));
urls = [...new Set(urls)];
return TRACKERS.map(t => {
const urlHit = urls.some(u => t.re.test(u));
const globalHit = !!(s && t.global && s.g[t.global]);
let ids = [];
try { ids = t.ids(urls, s) || []; } catch (e) {}
return { name: t.name, detected: urlHit || globalHit || ids.length > 0, ids };
});
}
/* ═══════════════════ CLIPBOARD ═══════════════════ */
function copyText(txt) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(txt).catch(() => fallbackCopy(txt));
}
return Promise.resolve(fallbackCopy(txt));
}
function fallbackCopy(txt) {
const ta = document.createElement('textarea');
ta.value = txt;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
ta.remove();
}
/* ═══════════════════ WIDGET (Shadow DOM) ═══════════════════ */
const CSS = `
:host{all:initial}
*{box-sizing:border-box}
.wg{position:relative;width:360px;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};font-family:'Google Sans',system-ui,sans-serif;user-select:none;text-align:left;line-height:1.4;color:${C.text}}
.at-hdr{background:linear-gradient(135deg,${C.accentDk},${C.bg});padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:move;border-bottom:1px solid ${C.border}}
.at-logo{width:22px;height:22px;flex-shrink:0}
.at-logo svg{display:block}
.at-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.at-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px;max-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-badge.inj{background:rgba(34,197,94,.15);color:${C.green}}
.at-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s;font-family:inherit}
.at-hdr-btn:hover{opacity:1}
.at-wrap{overflow:hidden;transition:max-height .25s ease}
.at-wrap.open{max-height:85vh}.at-wrap.closed{max-height:0!important}
/* ── pasek wstrzykiwania GTM (per domena) ── */
.at-inj{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid ${C.border};background:rgba(59,130,246,.05)}
.at-inj .at-input{flex:1;padding:5px 9px;font-size:11px;text-transform:uppercase}
.at-inj .at-input::placeholder{text-transform:none}
.at-inj-dot{width:8px;height:8px;border-radius:50%;background:${C.green};flex-shrink:0;box-shadow:0 0 6px ${C.green}}
.at-inj-dot.warn{background:${C.yellow};box-shadow:0 0 6px ${C.yellow}}
.at-inj-txt{flex:1;min-width:0;font-size:11px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-inj-txt b{font-family:'Consolas','Monaco',monospace;color:${C.green}}
.at-inj-txt small{color:${C.dim};font-size:10px}
.at-inj-err{font-size:10px;color:${C.red};padding:0 12px 6px;background:rgba(59,130,246,.05)}
.at-tabs{display:flex;border-bottom:1px solid ${C.border}}
.at-tab{flex:1;background:none;border:none;color:${C.dim};font-size:10px;font-weight:600;padding:8px 2px;cursor:pointer;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap}
.at-tab:hover{color:${C.text};background:${C.hover}}
.at-tab.on{color:${C.accent};border-bottom-color:${C.accent}}
.at-panel{padding:10px 14px;max-height:min(440px,62vh);overflow-y:auto}
.at-panel::-webkit-scrollbar{width:5px}.at-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.at-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin:8px 0 4px}
.at-label:first-child{margin-top:0}
.at-input{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:6px 9px;border-radius:7px;font-size:12px;outline:none;font-family:'Consolas','Monaco',monospace}
.at-input:focus{border-color:${C.accent}}
.at-input::placeholder{color:#6b7280;font-family:'Google Sans',system-ui,sans-serif}
.at-btn{border:none;border-radius:7px;padding:6px 12px;cursor:pointer;font-size:11px;font-weight:600;font-family:inherit;transition:.15s}
.at-btn-primary{background:${C.accent};color:#fff}
.at-btn-primary:hover{background:#2563eb}
.at-btn-sm{padding:3px 9px;font-size:10px;border-radius:6px}
.at-btn-ghost{background:transparent;border:1px dashed ${C.border};color:${C.dim};width:100%}
.at-btn-ghost:hover{border-color:${C.accent};color:${C.accent}}
.at-btn-sec{background:${C.border};color:${C.text}}
.at-btn-sec:hover{background:#4b5563}
.at-btn-danger{background:rgba(239,68,68,.15);color:${C.red}}
.at-btn-danger:hover{background:rgba(239,68,68,.3)}
.at-cmd{padding:7px 8px;border-radius:7px;cursor:pointer;transition:background .1s;margin-bottom:2px}
.at-cmd:hover{background:${C.hover}}
.at-cmd-label{font-size:11px;font-weight:600;color:${C.text};display:flex;align-items:center;gap:6px}
.at-copied{color:${C.green};font-size:10px;font-weight:700}
.at-cmd-code{font-family:'Consolas','Monaco',monospace;font-size:10px;color:#93c5fd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0}
.at-cmd-desc{font-size:10px;color:${C.dim}}
.at-px{display:flex;align-items:center;gap:9px;padding:5px 4px;border-radius:6px}
.at-px:hover{background:${C.hover}}
.at-px-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.at-px-name{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-px-ids{font-size:9px;font-family:'Consolas',monospace;color:${C.green};background:rgba(34,197,94,.1);padding:1px 6px;border-radius:6px;max-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.at-px-ids:hover{background:rgba(34,197,94,.25)}
.at-empty{font-size:12px;color:${C.dim};text-align:center;padding:14px 8px;line-height:1.5}
.at-warnbox{font-size:11px;color:${C.yellow};background:rgba(234,179,8,.08);border:1px solid rgba(234,179,8,.25);border-radius:8px;padding:6px 9px;margin-bottom:8px;line-height:1.45}
.at-consent{display:grid;grid-template-columns:1fr auto auto;gap:2px 8px;font-size:10px;margin-bottom:4px}
.at-consent .h{color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.4px;font-size:9px}
.at-consent .k{color:${C.text};font-family:'Consolas',monospace}
.at-consent .g{color:${C.green};font-weight:700}
.at-consent .d{color:${C.red};font-weight:700}
.at-consent .u{color:${C.dim}}
.at-push{display:flex;align-items:center;gap:8px;padding:6px 7px;border-radius:7px;margin-bottom:2px;cursor:pointer;border:1px solid transparent}
.at-push:hover{background:${C.hover}}
.at-push.on{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.4)}
.at-push-name{flex:1;min-width:0}
.at-push-ev{font-size:11px;font-weight:600;color:${C.text};font-family:'Consolas',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-push.on .at-push-ev{color:${C.accent}}
.at-push-desc{font-size:10px;color:${C.dim};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-push-load{font-size:10px;color:${C.dim};flex-shrink:0;opacity:0;transition:.1s}
.at-push:hover .at-push-load{opacity:1}
.at-push-n{font-size:9px;color:${C.dim};font-family:'Consolas',monospace;background:${C.border};padding:1px 5px;border-radius:5px;flex-shrink:0}
.at-push.on .at-push-n{color:${C.accent};background:rgba(59,130,246,.15)}
/* ── przełącznik liczby produktów ── */
.at-qty{display:flex;align-items:center;gap:4px;margin:0 0 6px;padding:0 2px 0 7px}
.at-qty-lab{flex:1;font-size:9px;color:${C.dim};text-transform:uppercase;letter-spacing:.4px;font-weight:700;white-space:nowrap}
.at-qty .at-chip{padding:3px 8px;min-width:26px}
/* ── podzakładki grup presetów (Ecommerce / Leady) — pełna szerokość ── */
.at-seg{display:flex;width:100%;background:#0b1120;border:1px solid ${C.border};border-radius:8px;padding:2px;gap:2px;margin:12px 0 8px}
.at-seg-btn{flex:1;background:none;border:none;color:${C.dim};font-size:11px;font-weight:700;font-family:inherit;padding:6px 8px;border-radius:6px;cursor:pointer;white-space:nowrap;transition:.15s;text-align:center}
.at-seg-btn:hover{color:${C.text}}
.at-seg-btn.on{background:rgba(59,130,246,.2);color:${C.accent}}
.at-seg-n{font-family:'Consolas',monospace;font-size:9px;opacity:.7;margin-left:2px}
.at-note{font-size:10px;color:${C.dim};margin:4px 0 0 2px;line-height:1.4}
.at-note:empty{display:none}
.at-note.warn{color:${C.yellow}}
.at-note.err{color:${C.red}}
.at-note.ok{color:${C.green}}
/* ── edytowalny dataLayer ── */
.at-ta{display:block;width:100%;min-height:150px;max-height:260px;resize:vertical;background:#0b1120;border:1px solid #4b5563;color:#bfdbfe;border-radius:7px;padding:7px 9px;font-family:'Consolas','Monaco',monospace;font-size:10px;line-height:1.5;outline:none;user-select:text;-webkit-user-select:text;white-space:pre;overflow:auto;tab-size:2}
.at-ta:focus{border-color:${C.accent}}
.at-ta.err{border-color:${C.red}}
.at-paste-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:6px}
.at-paste-btns{display:flex;gap:5px;flex-shrink:0}
.at-ta::placeholder{color:#4b5563}
.at-foot{border-top:1px solid ${C.border};padding:7px 10px 7px 14px;display:flex;justify-content:space-between;align-items:center;gap:6px}
.at-foot-txt{font-size:10px;color:${C.dim};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
.at-foot-btn{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:600;padding:2px 5px;border-radius:4px;flex-shrink:0;font-family:inherit}
.at-foot-btn:hover{background:rgba(59,130,246,.12)}
/* ── DL tab ── */
.at-dl-bar{display:flex;gap:5px;align-items:center;margin-bottom:7px}
.at-dl-bar .at-input{flex:1;padding:4px 8px;font-size:11px}
.at-chip{background:${C.border};border:none;color:${C.dim};font-size:10px;font-weight:700;font-family:'Consolas',monospace;padding:4px 8px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:.15s}
.at-chip:hover{color:${C.text}}
.at-chip.on{background:rgba(59,130,246,.2);color:${C.accent}}
.at-chip.pause-on{background:rgba(234,179,8,.2);color:${C.yellow}}
.at-dl-row{margin-bottom:1px}
.at-dl-hd{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:6px;cursor:pointer}
.at-dl-hd:hover{background:${C.hover}}
.at-dl-t{font-size:9px;color:${C.dim};font-family:'Consolas',monospace;flex-shrink:0}
.at-dl-ev{flex:1;min-width:0;font-size:11px;font-weight:600;font-family:'Consolas',monospace;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.at-dl-ev.ec{color:${C.green}}
.at-dl-ev.sys{color:${C.dim};font-weight:400}
.at-dl-ev.gt{color:${C.purple}}
.at-dl-name{font-size:8px;color:${C.yellow};background:rgba(234,179,8,.12);padding:1px 5px;border-radius:5px;flex-shrink:0;font-family:'Consolas',monospace}
.at-dl-copy{background:none;border:none;color:${C.dim};font-size:11px;cursor:pointer;padding:1px 4px;border-radius:4px;flex-shrink:0;font-family:inherit;opacity:0;transition:.1s}
.at-dl-hd:hover .at-dl-copy{opacity:.8}
.at-dl-copy:hover{color:${C.accent};opacity:1}
.at-dl-pre{background:#0b1120;border:1px solid ${C.border};border-radius:7px;padding:7px 9px;margin:2px 2px 6px;font-family:'Consolas','Monaco',monospace;font-size:10px;line-height:1.5;color:#bfdbfe;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto;user-select:text;cursor:text}
.at-dl-pre::-webkit-scrollbar{width:5px}.at-dl-pre::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
`;
function buildWidget() {
// Usuń każdy host z DOM (też osierocony z innej instancji), nie tylko ten ze zmiennej
document.querySelectorAll('#' + WID).forEach(n => n.remove());
host = document.createElement('div');
host.id = WID;
host.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:2147483647;';
root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>${CSS}</style>
<div class="wg">
<div class="at-hdr" id="at-hdr">
<div class="at-logo"><svg width="22" height="22" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg"><rect width="192" height="192" rx="24" fill="#3b82f6"/><path d="M96 36L140 96l-44 60-44-60L96 36z" fill="#fff" opacity=".9"/><circle cx="96" cy="96" r="20" fill="#fff"/></svg></div>
<span class="at-title">GTM Checker</span>
<span class="at-badge" id="at-badge" title=""></span>
<button class="at-hdr-btn" id="at-col">▾</button>
</div>
<div class="at-wrap ${collapsed ? 'closed' : 'open'}" id="at-wrap">
<div class="at-inj" id="at-inj"></div>
<div class="at-inj-err" id="at-inj-err" style="display:none"></div>
<div class="at-tabs">
<button class="at-tab" data-tab="pixels">🔍 Pixele</button>
<button class="at-tab" data-tab="dl">⚡ DataLayer</button>
<button class="at-tab" data-tab="push">🛒 Push</button>
<button class="at-tab" data-tab="cmds">📋 Cmd</button>
<button class="at-tab" data-tab="net">🌐 Net</button>
</div>
<div class="at-panel" id="at-panel"></div>
<div class="at-foot">
<span class="at-foot-txt" id="at-foot-txt"></span>
<button class="at-foot-btn" id="at-audit" title="Skopiuj audyt trackingu jako markdown">⤓ Audyt</button>
<button class="at-foot-btn" id="at-refresh">↻</button>
</div>
</div>
</div>
`;
document.body.appendChild(host);
restorePosition();
const $ = sel => root.querySelector(sel);
const colBtn = $('#at-col');
colBtn.textContent = collapsed ? '▸' : '▾';
colBtn.addEventListener('click', () => {
collapsed = !collapsed;
try { sessionStorage.setItem(SS_OPEN, collapsed ? '0' : '1'); } catch (e) {}
updateExpandDirection();
$('#at-wrap').classList.toggle('open', !collapsed);
$('#at-wrap').classList.toggle('closed', collapsed);
colBtn.textContent = collapsed ? '▸' : '▾';
});
root.querySelectorAll('.at-tab').forEach(t => t.addEventListener('click', () => {
activeTab = t.dataset.tab;
try { sessionStorage.setItem(SS_TAB, activeTab); } catch (e) {}
renderTabs();
}));
$('#at-refresh').addEventListener('click', renderTabs);
$('#at-audit').addEventListener('click', exportAudit);
makeDraggable(host, $('#at-hdr'));
renderInjectBar();
renderTabs();
updateBadge();
setTimeout(updateBadge, 2000);
setTimeout(updateBadge, 5000);
}
/* ─────────── PASEK: WSTRZYKNIJ GTM (per domena, localStorage) ─────────── */
function renderInjectBar() {
const bar = root && root.querySelector('#at-inj');
const err = root && root.querySelector('#at-inj-err');
if (!bar) return;
err.style.display = 'none';
const stored = getDomainInject();
if (stored) {
// Sprawdź, czy kontener faktycznie działa (google_tag_manager[ID])
sendCmd('snapshot');
const live = !!(lastSnapshot && lastSnapshot.gtmKeys.some(k => k.toUpperCase() === stored.id));
bar.innerHTML = `
<span class="at-inj-dot ${live ? '' : 'warn'}" title="${live ? 'Kontener załadowany' : 'Jeszcze się ładuje albo zablokowany (CSP/adblock)'}"></span>
<span class="at-inj-txt"><b>${escHtml(stored.id)}</b> <small>· ${escHtml(location.hostname)}</small></span>
<button class="at-btn at-btn-sec at-btn-sm" id="at-inj-edit" title="Zmień ID">✎</button>
<button class="at-btn at-btn-danger at-btn-sm" id="at-inj-off" title="Usuń z localStorage tej domeny i przeładuj">✕</button>
`;
bar.querySelector('#at-inj-off').addEventListener('click', () => {
clearDomainInject();
location.reload();
});
bar.querySelector('#at-inj-edit').addEventListener('click', () => {
clearDomainInject();
renderInjectBar();
const inp = bar.querySelector('#at-inj-id');
if (inp) { inp.value = stored.id; inp.focus(); inp.select(); }
});
return;
}
bar.innerHTML = `
<input class="at-input" id="at-inj-id" placeholder="GTM-XXXXXX — wstrzyknij i zapamiętaj dla tej domeny" maxlength="16" spellcheck="false">
<button class="at-btn at-btn-primary at-btn-sm" id="at-inj-go" title="Wstrzyknij teraz i zapisz w localStorage (ładuje się przy każdym wejściu)">💉 Wstrzyknij</button>
`;
const inp = bar.querySelector('#at-inj-id');
const go = () => {
const id = inp.value.trim().toUpperCase();
if (!isGtmId(id)) {
err.textContent = 'Nieprawidłowy format — oczekiwano GTM-XXXXXX';
err.style.display = '';
return;
}
if (!setDomainInject(id)) {
err.textContent = 'localStorage niedostępny na tej stronie — wstrzykuję tylko jednorazowo';
err.style.display = '';
}
const ok = injectGTM(id);
autoInjectedId = id;
if (!ok) {
err.textContent = id + ' już jest na stronie (natywnie lub wstrzyknięty) — zapis zachowany na kolejne wejścia';
err.style.display = '';
}
renderInjectBar();
updateBadge();
setTimeout(() => { renderInjectBar(); updateBadge(); }, 1500); // odśwież kropkę, gdy gtm.js dojedzie
setTimeout(() => { renderInjectBar(); updateBadge(); }, 4000);
};
inp.addEventListener('input', () => { inp.value = inp.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''); });
inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); e.stopPropagation(); });
bar.querySelector('#at-inj-go').addEventListener('click', go);
}
function updateBadge() {
if (!root) return;
sendCmd('snapshot');
const b = root.querySelector('#at-badge');
const gtm = lastSnapshot ? lastSnapshot.gtmKeys.filter(k => /^GTM-/i.test(k)) : [];
b.classList.remove('inj');
if (autoInjectedId) {
b.textContent = '💉 ' + autoInjectedId;
b.classList.add('inj');
b.title = 'Wstrzyknięty z localStorage tej domeny';
}
else if (gtm.length) { b.textContent = gtm.join(', '); b.title = 'GTM na stronie: ' + gtm.join(', '); }
else { b.textContent = 'brak GTM'; b.title = ''; }
}
function renderTabs() {
if (!root) return;
root.querySelectorAll('.at-tab').forEach(t => t.classList.toggle('on', t.dataset.tab === activeTab));
const panel = root.querySelector('#at-panel');
if (activeTab === 'pixels') renderPixels(panel);
else if (activeTab === 'dl') renderDL(panel);
else if (activeTab === 'push') renderPush(panel);
else if (activeTab === 'cmds') renderCommands(panel);
else renderNet(panel);
updateBadge();
}
/* ─────────── CONSENT MODE (live) ─────────── */
let lastConsentKey = '';
function renderConsentBox() {
const box = root && root.querySelector('#at-consent-box');
if (!box) return;
lastConsentKey = JSON.stringify(lastConsent);
if (!lastConsent || !Object.keys(lastConsent).length) { box.innerHTML = ''; return; }
const fmt = v => v === true ? '<span class="g">granted</span>' : v === false ? '<span class="d">denied</span>' : '<span class="u">—</span>';
let html = '<div class="at-label">Consent Mode <span style="color:' + C.green + ';font-size:8px;vertical-align:middle">● live</span></div>' +
'<div class="at-consent"><span class="h"></span><span class="h">default</span><span class="h">update</span>';
Object.keys(lastConsent).forEach(k => {
html += `<span class="k">${escHtml(k)}</span>${fmt(lastConsent[k].def)}${fmt(lastConsent[k].upd)}`;
});
box.innerHTML = html + '</div>';
}
function refreshConsent() {
if (!root || activeTab !== 'pixels') return;
sendCmd('consent'); // dispatch synchroniczny — lastConsent już zaktualizowany
if (JSON.stringify(lastConsent) !== lastConsentKey) renderConsentBox();
}
setInterval(refreshConsent, 1500);
/* ─────────── TAB: PIXELE ─────────── */
function renderPixels(panel) {
const found = detectTrackers().filter(r => r.detected);
sendCmd('consent');
panel.innerHTML = '';
const warns = [];
if (lastSnapshot) {
const gtms = lastSnapshot.gtmKeys.filter(k => /^GTM-/i.test(k));
if (gtms.length > 1) warns.push('⚠️ ' + gtms.length + ' kontenery GTM naraz: ' + gtms.join(', '));
const ga4s = lastSnapshot.gtmKeys.filter(k => /^G-/i.test(k));
if (ga4s.length > 1) warns.push('ℹ️ ' + ga4s.length + ' strumienie GA4: ' + ga4s.join(', ') + ' — sprawdź czy to celowe');
}
if (!bridgeReady) warns.push('⚠️ Bridge zablokowany (CSP?) — detekcja tylko po URL-ach, zakładki DataLayer i Push mogą nie działać.');
if (warns.length) {
const wb = document.createElement('div');
wb.className = 'at-warnbox';
wb.innerHTML = warns.map(escHtml).join('<br>');
panel.appendChild(wb);
}
const consentBox = document.createElement('div');
consentBox.id = 'at-consent-box';
panel.appendChild(consentBox);
renderConsentBox();
const label = document.createElement('div');
label.className = 'at-label';
label.textContent = `Wykryte trackery (${found.length})`;
panel.appendChild(label);
if (!found.length) {
const em = document.createElement('div');
em.className = 'at-empty';
em.innerHTML = 'Nie wykryto żadnych trackerów.<br>Spróbuj ↻ po pełnym załadowaniu strony.';
panel.appendChild(em);
}
found.forEach(r => {
const row = document.createElement('div');
row.className = 'at-px';
row.innerHTML = `<div class="at-px-dot" style="background:${C.green}"></div>` +
`<span class="at-px-name">${escHtml(r.name)}</span>` +
(r.ids.length ? `<span class="at-px-ids" title="Kliknij, aby skopiować">${escHtml(r.ids.join(', '))}</span>` : '');
const idsEl = row.querySelector('.at-px-ids');
if (idsEl) idsEl.addEventListener('click', () => {
copyText(r.ids.join(', '));
idsEl.textContent = '✓ skopiowano';
setTimeout(() => { idsEl.textContent = r.ids.join(', '); }, 900);
});
panel.appendChild(row);
});
root.querySelector('#at-foot-txt').textContent = `${found.length} trackerów · ${location.hostname}`;
}
/* ─────────── TAB: DL (live monitor dataLayer) ─────────── */
function dlRawJson(rec) {
try { return JSON.stringify(rec.d.e, null, 2); } catch (e) { return String(rec.d.e); }
}
function makeDlRow(rec) {
const row = document.createElement('div');
row.className = 'at-dl-row';
row.dataset.id = rec.id;
const showName = rec.d.dl && rec.d.dl !== 'dataLayer';
row.innerHTML = `
<div class="at-dl-hd">
<span class="at-dl-t">${fmtTime(rec.ts)}</span>
<span class="at-dl-ev ${rec.cls}">${escHtml(rec.name)}</span>
${showName ? `<span class="at-dl-name" title="Niestandardowy dataLayer">${escHtml(rec.d.dl)}</span>` : ''}
<button class="at-dl-copy" title="Kopiuj JSON">⧉</button>
</div>
`;
const hd = row.querySelector('.at-dl-hd');
const copyBtn = row.querySelector('.at-dl-copy');
copyBtn.addEventListener('click', ev => {
ev.stopPropagation();
copyText(dlRawJson(rec));
copyBtn.textContent = '✓';
setTimeout(() => { copyBtn.textContent = '⧉'; }, 900);
});
hd.addEventListener('click', () => {
const existing = row.querySelector('.at-dl-pre');
if (existing) { existing.remove(); dlExpanded.delete(rec.id); return; }
dlExpanded.add(rec.id);
const pre = document.createElement('div');
pre.className = 'at-dl-pre';
let txt = dlRawJson(rec);
if (txt.length > 6000) txt = txt.slice(0, 6000) + '\n… [obcięte — ⧉ kopiuje całość]';
pre.textContent = txt;
row.appendChild(pre);
});
if (dlExpanded.has(rec.id)) {
const pre = document.createElement('div');
pre.className = 'at-dl-pre';
let txt = dlRawJson(rec);
if (txt.length > 6000) txt = txt.slice(0, 6000) + '\n… [obcięte — ⧉ kopiuje całość]';
pre.textContent = txt;
row.appendChild(pre);
}
return row;
}
function updateDlFoot() {
if (!root || activeTab !== 'dl') return;
const shown = dlEvents.filter(dlMatches).length;
root.querySelector('#at-foot-txt').textContent =
`${shown}/${dlEvents.length} pushy` + (dlPaused ? ' · ⏸ pauza' : ' · ● live') + (dlHideSys ? ' · bez gtm.*' : '');
}
function dlLiveInsert(rec) {
const list = root && root.querySelector('#at-dl-list');
if (!list) return;
const empty = list.querySelector('.at-empty');
if (empty) empty.remove();
if (dlMatches(rec)) list.insertBefore(makeDlRow(rec), list.firstChild);
// przytnij DOM do limitu bufora
while (list.children.length > DL_MAX) list.removeChild(list.lastChild);
updateDlFoot();
}
function renderDlList() {
const list = root && root.querySelector('#at-dl-list');
if (!list) return;
list.innerHTML = '';
const filtered = dlEvents.filter(dlMatches);
if (!filtered.length) {
const em = document.createElement('div');
em.className = 'at-empty';
em.innerHTML = dlEvents.length
? 'Brak pushy pasujących do filtra.'
: (bridgeReady
? 'Jeszcze żadnych pushy do dataLayer.<br>Poklikaj po stronie albo zrób push z zakładki 🛒.'
: '⚠️ Bridge zablokowany (CSP?) — monitor dataLayer nie działa na tej stronie.');
list.appendChild(em);
} else {
// od najnowszych
for (let i = filtered.length - 1; i >= 0; i--) list.appendChild(makeDlRow(filtered[i]));
}
updateDlFoot();
}
function renderDL(panel) {
panel.innerHTML = `
<div class="at-dl-bar">
<input class="at-input" id="at-dl-filter" placeholder="filtruj: event / treść JSON…" value="${escAttr(dlFilter)}">
<button class="at-chip ${dlHideSys ? 'on' : ''}" id="at-dl-sys" title="Ukryj systemowe eventy gtm.js / gtm.dom / gtm.load itd.">gtm.*</button>
<button class="at-chip ${dlPaused ? 'pause-on' : ''}" id="at-dl-pause" title="Wstrzymaj dopisywanie do listy (bufor dalej zbiera)">${dlPaused ? '▶' : '⏸'}</button>
<button class="at-chip" id="at-dl-clear" title="Wyczyść listę">🧹</button>
<button class="at-chip" id="at-dl-copyall" title="Kopiuj widoczne pushe jako JSON">⧉</button>
</div>
<div id="at-dl-list"></div>
`;
panel.querySelector('#at-dl-filter').addEventListener('input', ev => {
dlFilter = ev.target.value.trim();
renderDlList();
});
panel.querySelector('#at-dl-sys').addEventListener('click', ev => {
dlHideSys = !dlHideSys;
ev.target.classList.toggle('on', dlHideSys);
renderDlList();
});
panel.querySelector('#at-dl-pause').addEventListener('click', ev => {
dlPaused = !dlPaused;
ev.target.classList.toggle('pause-on', dlPaused);
ev.target.textContent = dlPaused ? '▶' : '⏸';
if (!dlPaused) renderDlList(); // dogoń bufor z okresu pauzy
else updateDlFoot();
});
panel.querySelector('#at-dl-clear').addEventListener('click', () => {
dlEvents = [];
dlExpanded.clear();
renderDlList();
});
panel.querySelector('#at-dl-copyall').addEventListener('click', ev => {
const filtered = dlEvents.filter(dlMatches);
copyText(JSON.stringify(filtered.map(r => r.d.e), null, 2));
ev.target.textContent = '✓';
setTimeout(() => { ev.target.textContent = '⧉'; }, 900);
});
renderDlList();
}
/* ─────────── TAB: PUSH (edytowalny dataLayer + presety GA4) ─────────── */
// Jedno źródło prawdy: pole tekstowe. Preset tylko WCZYTUJE dane do pola,
// ▶ Push wysyła dokładnie to, co w polu jest (po ewentualnej edycji).
let customDraft = ''; // treść pola — przeżywa przełączanie zakładek
let activePreset = null; // ostatnio wczytany preset (podświetlenie)
let presetDirty = false; // czy pole edytowano po wczytaniu presetu
function flashBtn(btn, txt, ms) {
const old = btn.textContent;
btn.textContent = txt;
setTimeout(() => { btn.textContent = old; }, ms || 900);
}
function renderPush(panel) {
panel.innerHTML = `
<div class="at-paste-hd">
<span class="at-label" style="margin:0">Warstwa danych do wysłania</span>
<span class="at-paste-btns">
<button class="at-btn at-btn-sec at-btn-sm" id="at-paste-copy" title="Kopiuj zawartość pola jako snippet dataLayer.push">⧉</button>
<button class="at-btn at-btn-sec at-btn-sm" id="at-paste-clear" title="Wyczyść pole">🧹</button>
<button class="at-btn at-btn-primary at-btn-sm" id="at-paste-push" title="Ctrl+Enter">▶ Push</button>
</span>
</div>
<textarea class="at-ta" id="at-paste-ta" spellcheck="false" placeholder="Kliknij preset poniżej albo wklej własny dataLayer, np.
{
event: "purchase",
ecommerce: {
transaction_id: "TEST-123",
value: 1,
currency: "PLN",
items: [{ item_id: "TEST-001", item_name: "Produkt testowy", price: 1, quantity: 1 }]
}
}"></textarea>
<div class="at-note" id="at-paste-msg"></div>
<div class="at-seg" id="at-grp" title="Presety GA4 — kliknij event, aby wczytać go do pola">
${PRESET_GROUPS.map(g => `<button class="at-seg-btn ${g.id === presetGroup ? 'on' : ''}" data-g="${g.id}">${g.label} <span class="at-seg-n">${PRESETS.filter(p => p.group === g.id).length}</span></button>`).join('')}
</div>
<div class="at-qty" id="at-qty" title="Ile produktów w items[] (value = suma price × quantity)"${presetGroup === 'ecom' ? '' : ' style="display:none"'}>
<span class="at-qty-lab">Liczba produktów w items[]</span>
${ITEM_COUNTS.map(n => `<button class="at-chip ${n === itemCount ? 'on' : ''}" data-n="${n}">${n}</button>`).join('')}
</div>
<div id="at-push-list"></div>`;
const ta = panel.querySelector('#at-paste-ta');
const pushBtn = panel.querySelector('#at-paste-push');
const copyBtn = panel.querySelector('#at-paste-copy');
const clearBtn = panel.querySelector('#at-paste-clear');
const msg = panel.querySelector('#at-paste-msg');
const list = panel.querySelector('#at-push-list');
const qty = panel.querySelector('#at-qty');
const grp = panel.querySelector('#at-grp');
// Bez stałego komunikatu — linia pojawia się tylko przy statusie (wczytano / wysłano / błąd)
const setMsg = (txt, cls) => { msg.className = 'at-note' + (cls ? ' ' + cls : ''); msg.textContent = txt || ''; };
const showErr = e => { ta.classList.add('err'); setMsg('✗ ' + e.message, 'err'); };
const updateFoot = () => {
const g = PRESET_GROUPS.find(x => x.id === presetGroup);
const cnt = PRESETS.filter(p => p.group === presetGroup).length;
root.querySelector('#at-foot-txt').textContent =
`${g ? g.label.replace(/^\S+\s/, '') : ''}: ${cnt} presetów · ` + (presetGroup === 'ecom' ? `${itemCount} prod. · ` : '') + (activePreset ?'w polu: ' + activePreset + (presetDirty ? ' (edytowany)' : '') : (customDraft.trim() ? 'w polu: własny dataLayer' : 'pole puste'));
};
const presetN = p => (p.items === true ? itemCount : (p.items === 1 ? 1 : 0));
setMsg();
ta.value = customDraft;
ta.addEventListener('input', () => {
customDraft = ta.value;
if (activePreset) presetDirty = true;
if (ta.classList.contains('err')) { ta.classList.remove('err'); setMsg(); }
updateFoot();
});
// Nie oddawaj klawiszy stronie (skróty sklepu, Alt+Shift+A itd.)
['keydown', 'keyup', 'keypress'].forEach(t => ta.addEventListener(t, e => {
e.stopPropagation();
if (t === 'keydown' && e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); pushBtn.click(); }
}));
pushBtn.addEventListener('click', () => {
if (!ta.value.trim()) { ta.focus(); setMsg('Pole jest puste — kliknij preset albo wklej dataLayer.', 'warn'); return; }
try {
const r = parseCustomLayer(ta.value);
pushEntries(r.entries);
ta.classList.remove('err');
const names = r.entries.map(e => e.event || '{bez event}').join(', ');
setMsg('✓ Wysłano ' + r.entries.length + '× — ' + names +
(r.dropped.length ? ' · pominięte klucze: ' + r.dropped.join(', ') : ''), 'ok');
flashBtn(pushBtn, '✓ ' + r.entries.length + '×');
} catch (e) {
showErr(e);
flashBtn(pushBtn, '✗', 1200);
}
});
copyBtn.addEventListener('click', () => {
if (!ta.value.trim()) { ta.focus(); return; }
try {
const r = parseCustomLayer(ta.value);
copyText(entriesToSnippet(r.entries));
flashBtn(copyBtn, '✓');
} catch (e) {
showErr(e);
flashBtn(copyBtn, '✗', 1200);
}
});
clearBtn.addEventListener('click', () => {
ta.value = customDraft = '';
activePreset = null;
presetDirty = false;
ta.classList.remove('err');
list.querySelectorAll('.at-push').forEach(r => r.classList.remove('on'));
setMsg();
updateFoot();
ta.focus();
});
/* ── presety: klik = wczytaj do pola ── */
const loadPreset = p => {
const n = presetN(p);
const data = p.build(n || 1);
ta.value = customDraft = JSON.stringify(data, null, 2);
activePreset = p.label;
presetDirty = false;
ta.classList.remove('err');
list.querySelectorAll('.at-push').forEach(r => r.classList.toggle('on', r.dataset.label === p.label));
let info = '';
if (n) {
const v = data.ecommerce && typeof data.ecommerce.value === 'number' ? ', value ' + data.ecommerce.value : '';
info = ' (' + plProd(n) + v + ')';
}
setMsg('Wczytano ' + p.label + info + ' — popraw w polu i ▶ Push (Ctrl+Enter).');
updateFoot();
ta.scrollTop = 0;
ta.focus();
ta.setSelectionRange(0, 0);
};
const refreshBadges = () => {
list.querySelectorAll('.at-push').forEach(r => {
const p = PRESETS.find(x => x.label === r.dataset.label);
const b = r.querySelector('.at-push-n');
if (p && b) b.textContent = '×' + presetN(p);
});
};
const drawList = () => {
list.innerHTML = '';
PRESETS.filter(p => p.group === presetGroup).forEach(p => {
const row = document.createElement('div');
row.className = 'at-push' + (activePreset === p.label ? ' on' : '');
row.dataset.label = p.label;
row.title = 'Wczytaj do pola (nadpisuje obecną treść)';
const n = presetN(p);
row.innerHTML = `
<div class="at-push-name">
<div class="at-push-ev">${escHtml(p.label)}</div>
<div class="at-push-desc">${escHtml(p.desc)}</div>
</div>
<span class="at-push-load">✎ wczytaj</span>
${n ? `<span class="at-push-n" title="Liczba produktów w items[]">×${n}</span>` : ''}
`;
row.addEventListener('click', () => loadPreset(p));
list.appendChild(row);
});
};
drawList();
/* ── podzakładki grup presetów ── */
grp.querySelectorAll('.at-seg-btn').forEach(btn => btn.addEventListener('click', () => {
const g = btn.dataset.g;
if (g === presetGroup) return;
presetGroup = g;
try { sessionStorage.setItem(SS_PUSH_GRP, g); } catch (e) {}
grp.querySelectorAll('.at-seg-btn').forEach(b => b.classList.toggle('on', b === btn));
qty.style.display = g === 'ecom' ? '' : 'none'; // liczba produktów dotyczy tylko ecommerce
drawList();
updateFoot();
}));
/* ── przełącznik liczby produktów ── */
qty.querySelectorAll('.at-chip').forEach(btn => btn.addEventListener('click', () => {
const n = parseInt(btn.dataset.n, 10);
if (n === itemCount) return;
setItemCount(n);
qty.querySelectorAll('.at-chip').forEach(b => b.classList.toggle('on', b === btn));
refreshBadges();
const cur = activePreset && PRESETS.find(x => x.label === activePreset);
if (cur && cur.items === true && !presetDirty) {
loadPreset(cur); // pole niezmienione → przeładuj od razu z nową liczbą produktów
} else if (cur && cur.items === true && presetDirty) {
setMsg('Ustawiono ' + plProd(n) + ' — pole ma Twoje zmiany, kliknij ' + cur.label + ', aby wczytać ponownie.', 'warn');
updateFoot();
} else {
setMsg('Presety z produktami będą wczytywane z: ' + plProd(n) + '.');
updateFoot();
}
}));
updateFoot();
}
/* ─────────── TAB: KOMENDY ─────────── */
function renderCommands(panel) {
panel.innerHTML = `<div class="at-label">Kliknij, aby skopiować do schowka</div>`;
COMMANDS.forEach(cmd => {
const row = document.createElement('div');
row.className = 'at-cmd';
row.innerHTML = `<div class="at-cmd-label">${escHtml(cmd.label)} <span class="at-copied" style="display:none">✓ skopiowano</span></div>` +
`<div class="at-cmd-code">${escHtml(cmd.code)}</div>` +
`<div class="at-cmd-desc">${escHtml(cmd.desc)}</div>`;
row.addEventListener('click', () => {
copyText(cmd.code);
const c = row.querySelector('.at-copied');
c.style.display = '';
setTimeout(() => { c.style.display = 'none'; }, 900);
});
panel.appendChild(row);
});
root.querySelector('#at-foot-txt').textContent = `${COMMANDS.length} komend`;
}
/* ═══════════════════ AUDYT (markdown do schowka) ═══════════════════ */
function exportAudit() {
sendCmd('snapshot'); sendCmd('consent');
const found = detectTrackers().filter(r => r.detected);
const lines = [];
lines.push('# Audyt trackingu — ' + location.hostname);
lines.push('URL: ' + location.href);
lines.push('Data: ' + new Date().toLocaleString('pl-PL'));
if (autoInjectedId) lines.push('Wstrzyknięty przez GTM Checker: ' + autoInjectedId + ' (nie ma go w kodzie strony)');
lines.push('');
lines.push('## Wykryte trackery (' + found.length + ')');
found.forEach(r => lines.push('- ' + r.name + (r.ids.length ? ' — `' + r.ids.join(', ') + '`' : '')));
if (lastConsent && Object.keys(lastConsent).length) {
lines.push('');
lines.push('## Consent Mode');
lines.push('| typ | default | update |');
lines.push('|---|---|---|');
const f = v => v === true ? 'granted' : v === false ? 'denied' : '—';
Object.keys(lastConsent).forEach(k => lines.push('| ' + k + ' | ' + f(lastConsent[k].def) + ' | ' + f(lastConsent[k].upd) + ' |'));
}
if (dlEvents.length) {
lines.push('');
lines.push('## Eventy dataLayer (' + dlEvents.length + ' pushy w buforze)');
const counts = {};
dlEvents.forEach(r => { counts[r.name] = (counts[r.name] || 0) + 1; });
Object.keys(counts).forEach(n => lines.push('- `' + n + '` × ' + counts[n]));
}
copyText(lines.join('\n'));
const btn = root.querySelector('#at-audit');
const old = btn.textContent;
btn.textContent = '✓ skopiowano';
setTimeout(() => { btn.textContent = old; }, 1200);
}
/* ═══════════════════ POZYCJA / KIERUNEK / DRAG ═══════════════════ */
const WIDGET_W = 360;
function clampToViewport() {
if (!host) return;
const r = host.getBoundingClientRect();
// pozycjonowany przez left/top?
if (host.style.left && host.style.left !== 'auto') {
const l = Math.max(0, Math.min(r.left, window.innerWidth - WIDGET_W));
const t = Math.max(0, Math.min(r.top, window.innerHeight - 56));
host.style.left = l + 'px'; host.style.top = t + 'px';
host.style.right = 'auto'; host.style.bottom = 'auto';
}
}
window.addEventListener('resize', () => { clampToViewport(); });
function restorePosition() {
try {
const pos = JSON.parse(localStorage.getItem(LS_POS) || 'null');
if (pos && typeof pos.l === 'number' && typeof pos.t === 'number') {
const l = Math.max(0, Math.min(pos.l, window.innerWidth - WIDGET_W));
const t = Math.max(0, Math.min(pos.t, window.innerHeight - 56));
host.style.left = l + 'px'; host.style.top = t + 'px';
host.style.right = 'auto'; host.style.bottom = 'auto';
}
} catch (e) {}
}
function savePosition() {
try {
const r = host.getBoundingClientRect();
const l = Math.max(0, Math.min(r.left, window.innerWidth - WIDGET_W));
const t = Math.max(0, Math.min(r.top, window.innerHeight - 56));
localStorage.setItem(LS_POS, JSON.stringify({ l, t }));
} catch (e) {}
}
function updateExpandDirection() {
if (!host || !root) return;
const rect = host.getBoundingClientRect();
const hdrH = root.querySelector('#at-hdr').offsetHeight;
const hdrMid = rect.top + hdrH / 2;
if (hdrMid > window.innerHeight / 2) {
host.style.bottom = (window.innerHeight - rect.bottom) + 'px';
host.style.top = 'auto';
} else {
host.style.top = rect.top + 'px';
host.style.bottom = 'auto';
}
}
function makeDraggable(el, handle) {
let sx, sy, sl, st;
handle.addEventListener('mousedown', e => {
if (e.composedPath().some(n => n.tagName === 'BUTTON')) return;
e.preventDefault();
sx = e.clientX; sy = e.clientY; const r = el.getBoundingClientRect(); sl = r.left; st = r.top;
const mv = ev => { el.style.left = (sl + ev.clientX - sx) + 'px'; el.style.top = (st + ev.clientY - sy) + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; };
const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); updateExpandDirection(); savePosition(); };
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
});
}
/* ═══════════════════ SPA / TOGGLE / MENU / INIT ═══════════════════ */
function toggleWidget() {
if (host) {
manuallyHidden = true; // tylko do reloadu — bez zapisu w storage
host.remove(); host = null; root = null;
} else {
manuallyHidden = false;
injectBridge(); // na zablokowanym hoście bridge mógł nie wejść przy starcie
buildWidget(); updateExpandDirection();
}
}
document.addEventListener('keydown', e => {
if (e.altKey && e.shiftKey && e.code === 'KeyA') toggleWidget();
});
// SPA: zmiana URL bez reloadu → odśwież aktywną zakładkę
// Watchdog: frameworki potrafią podmienić <body> i wywalić hosta z DOM —
// jeśli widget powinien być widoczny, a zniknął, odbuduj go.
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// DL nie resetujemy (historia SPA zostaje), Push też nie (żeby nie zgubić fokusu w polu)
if (root && activeTab !== 'dl' && activeTab !== 'push') setTimeout(renderTabs, 800);
}
if (!manuallyHidden && !isBlockedHost() && document.body) {
if (!host || !host.isConnected) {
buildWidget();
updateExpandDirection();
}
}
}, 1200);
function init() {
if (isBlockedHost()) return; // panele narzędzi — nie pokazuj (Alt+Shift+A wymusza)
buildWidget();
updateExpandDirection();
}
if (document.readyState === 'complete') setTimeout(init, 800);
else window.addEventListener('load', () => setTimeout(init, 800));
})();

