nodebb-plugin-ezoic-infinite 1.8.60 → 1.8.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/library.js +38 -16
  2. package/package.json +1 -1
  3. package/public/client.js +370 -208
package/library.js CHANGED
@@ -87,8 +87,11 @@ async function isUserExcluded(uid, excludedGroups) {
87
87
  const value = (userGroups[0] || []).some(g => excludedSet.has(g.name));
88
88
 
89
89
  _excludeCache.set(key, { value, at: Date.now() });
90
+
91
+ // Limit cache size to prevent unbounded growth
90
92
  if (_excludeCache.size > 1000) {
91
- _excludeCache.delete(_excludeCache.keys().next().value);
93
+ const oldest = _excludeCache.keys().next().value;
94
+ _excludeCache.delete(oldest);
92
95
  }
93
96
 
94
97
  return value;
@@ -136,24 +139,15 @@ const HEAD_PRECONNECTS = [
136
139
  ].join('\n');
137
140
 
138
141
  const EZOIC_SCRIPTS = [
139
- HEAD_PRECONNECTS,
140
-
141
- // CMP en premier
142
142
  '<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>',
143
143
  '<script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>',
144
-
145
- // Standalone Ezoic
146
- '<script async src="https://www.ezojs.com/ezoic/sa.min.js"></script>',
147
-
148
- // cmd queue (OK en inline)
149
144
  '<script>',
145
+ 'window._ezaq = window._ezaq || {};',
150
146
  'window.ezstandalone = window.ezstandalone || {};',
151
147
  'ezstandalone.cmd = ezstandalone.cmd || [];',
152
148
  '</script>',
153
-
154
- // Analytics
155
- '<script src="https://ezoicanalytics.com/analytics.js"></script>',
156
- ].join('\n') + '\n';
149
+ '<script async src="//www.ezojs.com/ezoic/sa.min.js"></script>',
150
+ ].join('\n');
157
151
 
158
152
  // ── Hooks ────────────────────────────────────────────────────────────────────
159
153
 
@@ -171,15 +165,43 @@ plugin.addAdminNavigation = async (header) => {
171
165
  return header;
172
166
  };
173
167
 
168
+ /**
169
+ * Inject Ezoic scripts into <head> via templateData.customHTML.
170
+ * NodeBB v4/Harmony: header.tpl has {{customHTML}} in <head>.
171
+ */
174
172
  plugin.injectEzoicHead = async (data) => {
175
173
  try {
176
174
  const settings = await getSettings();
177
175
  const uid = data.req?.uid ?? 0;
178
176
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
179
- if (!excluded) {
180
- data.templateData.customHTML = EZOIC_SCRIPTS + data.templateData.customHTML || '';
177
+
178
+ if (excluded) {
179
+ // Even for excluded users, inject a minimal stub so that any script
180
+ // referencing ezstandalone doesn't throw a ReferenceError, plus
181
+ // the config with excluded=true so client.js bails early.
182
+ const cfg = buildClientConfig(settings, true);
183
+ const stub = [
184
+ '<script>',
185
+ 'window._ezaq = window._ezaq || {};',
186
+ 'window.ezstandalone = window.ezstandalone || {};',
187
+ 'ezstandalone.cmd = ezstandalone.cmd || [];',
188
+ '</script>',
189
+ ].join('\n');
190
+ data.templateData.customHTML =
191
+ stub + '\n' +
192
+ serializeInlineConfig(cfg) +
193
+ (data.templateData.customHTML || '');
194
+ } else {
195
+ const cfg = buildClientConfig(settings, false);
196
+ data.templateData.customHTML =
197
+ HEAD_PRECONNECTS + '\n' +
198
+ EZOIC_SCRIPTS + '\n' +
199
+ serializeInlineConfig(cfg) +
200
+ (data.templateData.customHTML || '');
181
201
  }
182
- } catch (_) {}
202
+ } catch (err) {
203
+ console.error('[ezoic-infinite] injectEzoicHead error:', err.message);
204
+ }
183
205
  return data;
184
206
  };
185
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.60",
3
+ "version": "1.8.61",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.3.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.2.0
3
3
  *
4
- * Architecture based on battle-tested v50, with targeted improvements:
5
- * - Ezoic API: showAds() + destroyPlaceholders() per official docs
6
- * - wrapsByClass Set for O(1) recycle lookup (no querySelectorAll)
7
- * - MutationObserver: ad fill detection + virtualization re-observe
8
- * - Conservative empty check (30s/60s, GPT-aware)
9
- * - aria-hidden + TCF locator protection
4
+ * Based on working v2-fixed with targeted fixes:
5
+ * - Ezoic API: showAds() + destroyPlaceholders() only (per official docs)
6
+ * - cleanup(): destroyAll() before DOM removal, only on real page change
7
+ * - Recycling: conservative threshold (-3vh), age/inflight guards
8
+ * - aria-hidden: MutationObserver protection
9
+ * - Empty check: more conservative timing and GPT slot awareness
10
10
  */
11
11
  (function nbbEzoicInfinite() {
12
12
  'use strict';
@@ -24,25 +24,28 @@
24
24
  };
25
25
 
26
26
  const TIMING = {
27
- EMPTY_CHECK_MS_1: 30_000,
28
- EMPTY_CHECK_MS_2: 60_000,
29
- MIN_PRUNE_AGE_MS: 8_000,
30
- RECYCLE_MIN_AGE_MS: 5_000,
31
- SHOW_THROTTLE_MS: 900,
32
- BURST_COOLDOWN_MS: 200,
33
- BLOCK_DURATION_MS: 1_500,
34
- SHOW_TIMEOUT_MS: 7_000,
35
- SHOW_RELEASE_MS: 700,
36
- RECYCLE_DELAY_MS: 450,
27
+ EMPTY_CHECK_EARLY_MS: 30_000,
28
+ EMPTY_CHECK_LATE_MS: 60_000,
29
+ MIN_PRUNE_AGE_MS: 8_000,
30
+ SHOW_THROTTLE_MS: 900,
31
+ BURST_COOLDOWN_MS: 200,
32
+ BLOCK_DURATION_MS: 1_500,
33
+ SHOW_TIMEOUT_MS: 7_000,
34
+ SHOW_RELEASE_MS: 700,
35
+ RECYCLE_DELAY_MS: 450,
37
36
  };
38
37
 
39
- const MAX_INSERTS_RUN = 6;
40
- const MAX_INFLIGHT = 4;
41
- const MAX_BURST_STEPS = 8;
42
- const BURST_WINDOW_MS = 2_000;
38
+ const LIMITS = {
39
+ MAX_INSERTS_RUN: 6,
40
+ MAX_INFLIGHT: 4,
41
+ MAX_BURST_STEPS: 8,
42
+ BURST_WINDOW_MS: 2_000,
43
+ };
43
44
 
44
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
45
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
45
+ const IO_MARGIN = {
46
+ DESKTOP: '2500px 0px 2500px 0px',
47
+ MOBILE: '3500px 0px 3500px 0px',
48
+ };
46
49
 
47
50
  const SEL = {
48
51
  post: '[component="post"][data-pid]',
@@ -57,26 +60,39 @@
57
60
  };
58
61
 
59
62
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
63
+ const RECYCLE_MIN_AGE_MS = 5_000;
60
64
 
61
65
  // ── Utility ────────────────────────────────────────────────────────────────
62
66
 
63
67
  const now = () => Date.now();
64
68
  const isMobile = () => window.innerWidth < 768;
65
69
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
66
- const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
70
+
71
+ function isFilled(node) {
72
+ return node?.querySelector?.(FILL_SEL) != null;
73
+ }
74
+
75
+ function isPlaceholderUsed(ph) {
76
+ if (!ph?.isConnected) return false;
77
+ return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
78
+ }
67
79
 
68
80
  function parseIds(raw) {
69
- const out = [], seen = new Set();
70
- for (const v of String(raw || '').split(/[\r\n,\s]+/)) {
71
- const n = parseInt(v, 10);
72
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
81
+ const out = [];
82
+ const seen = new Set();
83
+ for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
84
+ const n = parseInt(line, 10);
85
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
86
+ seen.add(n);
87
+ out.push(n);
88
+ }
73
89
  }
74
90
  return out;
75
91
  }
76
92
 
77
93
  // ── State ──────────────────────────────────────────────────────────────────
78
94
 
79
- const S = {
95
+ const state = {
80
96
  pageKey: null,
81
97
  kind: null,
82
98
  cfg: null,
@@ -85,17 +101,18 @@
85
101
  pools: { topics: [], posts: [], categories: [] },
86
102
  cursors: { topics: 0, posts: 0, categories: 0 },
87
103
 
88
- mountedIds: new Set(),
89
- lastShow: new Map(),
104
+ mountedIds: new Set(),
105
+ phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
106
+ lastShow: new Map(), // id → timestamp
90
107
 
91
- wrapByKey: new Map(), // anchorKey → wrap element
92
- wrapsByClass: new Map(), // kindClass → Set<wrap>
108
+ wrapByKey: new Map(),
109
+ wrapsByClass: new Map(),
93
110
 
94
111
  io: null,
95
112
  domObs: null,
96
113
 
97
- mutGuard: 0,
98
- blockedUntil: 0,
114
+ mutGuard: 0,
115
+ blockedUntil: 0,
99
116
 
100
117
  inflight: 0,
101
118
  pending: [],
@@ -106,36 +123,40 @@
106
123
  burstDeadline: 0,
107
124
  burstCount: 0,
108
125
  lastBurstTs: 0,
126
+ firstShown: false,
109
127
  };
110
128
 
111
- const isBlocked = () => now() < S.blockedUntil;
129
+ const isBlocked = () => now() < state.blockedUntil;
112
130
 
113
131
  function mutate(fn) {
114
- S.mutGuard++;
115
- try { fn(); } finally { S.mutGuard--; }
132
+ state.mutGuard++;
133
+ try { fn(); } finally { state.mutGuard--; }
116
134
  }
117
135
 
118
136
  // ── Config ─────────────────────────────────────────────────────────────────
119
137
 
120
138
  async function fetchConfig() {
121
- if (S.cfg) return S.cfg;
139
+ if (state.cfg) return state.cfg;
122
140
  try {
123
141
  const inline = window.__nbbEzoicCfg;
124
- if (inline && typeof inline === 'object') { S.cfg = inline; return S.cfg; }
142
+ if (inline && typeof inline === 'object') {
143
+ state.cfg = inline;
144
+ return state.cfg;
145
+ }
125
146
  } catch (_) {}
126
147
  try {
127
148
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
128
- if (r.ok) S.cfg = await r.json();
149
+ if (r.ok) state.cfg = await r.json();
129
150
  } catch (_) {}
130
- return S.cfg;
151
+ return state.cfg;
131
152
  }
132
153
 
133
154
  function initPools(cfg) {
134
- if (S.poolsReady) return;
135
- S.pools.topics = parseIds(cfg.placeholderIds);
136
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
137
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
138
- S.poolsReady = true;
155
+ if (state.poolsReady) return;
156
+ state.pools.topics = parseIds(cfg.placeholderIds);
157
+ state.pools.posts = parseIds(cfg.messagePlaceholderIds);
158
+ state.pools.categories = parseIds(cfg.categoryPlaceholderIds);
159
+ state.poolsReady = true;
139
160
  }
140
161
 
141
162
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -161,7 +182,7 @@
161
182
  }
162
183
 
163
184
  function getKind() {
164
- return S.kind || (S.kind = detectKind());
185
+ return state.kind || (state.kind = detectKind());
165
186
  }
166
187
 
167
188
  // ── DOM queries ────────────────────────────────────────────────────────────
@@ -181,8 +202,8 @@
181
202
  return out;
182
203
  }
183
204
 
184
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
185
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
205
+ function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
206
+ function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
186
207
 
187
208
  // ── Anchor keys & wrap registry ────────────────────────────────────────────
188
209
 
@@ -203,45 +224,60 @@
203
224
  const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
204
225
 
205
226
  function findWrap(key) {
206
- const w = S.wrapByKey.get(key);
227
+ const w = state.wrapByKey.get(key);
207
228
  return w?.isConnected ? w : null;
208
229
  }
209
230
 
210
231
  function getWrapSet(klass) {
211
- let set = S.wrapsByClass.get(klass);
212
- if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
232
+ let set = state.wrapsByClass.get(klass);
233
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
213
234
  return set;
214
235
  }
215
236
 
216
- // ── GC disconnected wraps ──────────────────────────────────────────────────
217
- // NodeBB virtualizes posts off-viewport. The MutationObserver catches most
218
- // removals, but this is a safety net for edge cases.
237
+ // ── GC disconnected wraps (NodeBB virtualization) ──────────────────────────
219
238
 
220
239
  function gcDisconnectedWraps() {
221
- for (const [key, w] of S.wrapByKey) {
222
- if (!w?.isConnected) S.wrapByKey.delete(key);
240
+ for (const [key, w] of Array.from(state.wrapByKey.entries())) {
241
+ if (!w?.isConnected) state.wrapByKey.delete(key);
223
242
  }
224
- for (const [klass, set] of S.wrapsByClass) {
225
- for (const w of set) {
243
+ for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
244
+ for (const w of Array.from(set)) {
226
245
  if (w?.isConnected) continue;
227
246
  set.delete(w);
228
- const id = parseInt(w.getAttribute?.(ATTR.WRAPID), 10);
229
- if (Number.isFinite(id)) {
230
- S.mountedIds.delete(id);
231
- S.lastShow.delete(id);
232
- }
247
+ try {
248
+ const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
249
+ if (Number.isFinite(id)) {
250
+ state.mountedIds.delete(id);
251
+ state.phState.delete(id);
252
+ state.lastShow.delete(id);
253
+ }
254
+ } catch (_) {}
233
255
  }
234
- if (!set.size) S.wrapsByClass.delete(klass);
256
+ if (!set.size) state.wrapsByClass.delete(klass);
235
257
  }
258
+ try {
259
+ const live = new Set();
260
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
261
+ const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
262
+ if (id > 0) live.add(id);
263
+ }
264
+ for (const id of Array.from(state.mountedIds)) {
265
+ if (!live.has(id)) {
266
+ state.mountedIds.delete(id);
267
+ state.phState.delete(id);
268
+ state.lastShow.delete(id);
269
+ }
270
+ }
271
+ } catch (_) {}
236
272
  }
237
273
 
238
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
274
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
239
275
 
240
276
  function wrapIsLive(wrap) {
241
277
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
242
278
  const key = wrap.getAttribute(ATTR.ANCHOR);
243
279
  if (!key) return false;
244
- if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
280
+ if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
245
281
  const colonIdx = key.indexOf(':');
246
282
  const klass = key.slice(0, colonIdx);
247
283
  const anchorId = key.slice(colonIdx + 1);
@@ -272,12 +308,14 @@
272
308
  const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
273
309
  if (!ph || !isFilled(ph)) return false;
274
310
  wrap.classList.remove('is-empty');
311
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
312
+ if (id > 0) state.phState.set(id, 'shown');
275
313
  return true;
276
314
  }
277
315
 
278
316
  function scheduleUncollapseChecks(wrap) {
279
317
  if (!wrap) return;
280
- for (const ms of [500, 3000, 10000]) {
318
+ for (const ms of [500, 1500, 3000, 7000, 15000]) {
281
319
  setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
282
320
  }
283
321
  }
@@ -285,19 +323,21 @@
285
323
  // ── Pool management ────────────────────────────────────────────────────────
286
324
 
287
325
  function pickId(poolKey) {
288
- const pool = S.pools[poolKey];
326
+ const pool = state.pools[poolKey];
289
327
  if (!pool.length) return null;
290
328
  for (let t = 0; t < pool.length; t++) {
291
- const idx = S.cursors[poolKey] % pool.length;
292
- S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
329
+ const idx = state.cursors[poolKey] % pool.length;
330
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
293
331
  const id = pool[idx];
294
- if (!S.mountedIds.has(id)) return id;
332
+ if (!state.mountedIds.has(id)) return id;
295
333
  }
296
334
  return null;
297
335
  }
298
336
 
299
337
  // ── Recycling ──────────────────────────────────────────────────────────────
300
- // Per Ezoic docs: destroyPlaceholders(id) → remove HTML → fresh placeholder → showAds(id)
338
+ //
339
+ // Per Ezoic docs: destroyPlaceholders(id) → remove old HTML →
340
+ // recreate fresh placeholder → showAds(id).
301
341
 
302
342
  function recycleWrap(klass, targetEl, newKey) {
303
343
  const ez = window.ezstandalone;
@@ -310,13 +350,18 @@
310
350
  let bestEmpty = null, bestEmptyY = Infinity;
311
351
  let bestFull = null, bestFullY = Infinity;
312
352
 
313
- const wraps = S.wrapsByClass.get(klass);
353
+ const wraps = state.wrapsByClass.get(klass);
314
354
  if (!wraps) return null;
315
355
 
316
356
  for (const wrap of wraps) {
317
357
  try {
358
+ // Skip young wraps (ad might still be loading)
318
359
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
319
- if (t - created < TIMING.RECYCLE_MIN_AGE_MS) continue;
360
+ if (t - created < RECYCLE_MIN_AGE_MS) continue;
361
+ // Skip wraps with inflight showAds
362
+ const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
363
+ if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
364
+
320
365
  const bottom = wrap.getBoundingClientRect().bottom;
321
366
  if (bottom > threshold) continue;
322
367
  if (!isFilled(wrap)) {
@@ -332,19 +377,22 @@
332
377
 
333
378
  const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
334
379
  if (!Number.isFinite(id)) return null;
380
+
335
381
  const oldKey = best.getAttribute(ATTR.ANCHOR);
336
382
 
337
383
  // Unobserve before moving
338
384
  try {
339
385
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
340
- if (ph) S.io?.unobserve(ph);
386
+ if (ph) state.io?.unobserve(ph);
341
387
  } catch (_) {}
342
388
 
343
- // Ezoic recycle: destroy → fresh DOM → showAds
389
+ // Ezoic recycle: destroy → new DOM → showAds
344
390
  const doRecycle = () => {
391
+ state.phState.set(id, 'destroyed');
345
392
  try { ez.destroyPlaceholders(id); } catch (_) {}
346
393
 
347
394
  setTimeout(() => {
395
+ // Recreate fresh placeholder DOM at new position
348
396
  mutate(() => {
349
397
  best.setAttribute(ATTR.ANCHOR, newKey);
350
398
  best.setAttribute(ATTR.CREATED, String(now()));
@@ -355,21 +403,27 @@
355
403
  const fresh = document.createElement('div');
356
404
  fresh.id = `${PH_PREFIX}${id}`;
357
405
  fresh.setAttribute('data-ezoic-id', String(id));
406
+ fresh.style.minHeight = '1px';
358
407
  best.appendChild(fresh);
359
408
  targetEl.insertAdjacentElement('afterend', best);
360
409
  });
361
410
 
362
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
363
- S.wrapByKey.set(newKey, best);
411
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
412
+ state.wrapByKey.set(newKey, best);
364
413
 
414
+ // Re-show after DOM is settled
365
415
  setTimeout(() => {
366
- observePh(id);
416
+ observePlaceholder(id);
417
+ state.phState.set(id, 'new');
367
418
  enqueueShow(id);
368
419
  }, TIMING.RECYCLE_DELAY_MS);
369
420
  }, TIMING.RECYCLE_DELAY_MS);
370
421
  };
371
422
 
372
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle(); } catch (_) {}
423
+ try {
424
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle();
425
+ } catch (_) {}
426
+
373
427
  return { id, wrap: best };
374
428
  }
375
429
 
@@ -383,9 +437,11 @@
383
437
  w.setAttribute(ATTR.CREATED, String(now()));
384
438
  w.setAttribute(ATTR.SHOWN, '0');
385
439
  w.style.cssText = 'width:100%;display:block';
440
+
386
441
  const ph = document.createElement('div');
387
442
  ph.id = `${PH_PREFIX}${id}`;
388
443
  ph.setAttribute('data-ezoic-id', String(id));
444
+ ph.style.minHeight = '1px';
389
445
  w.appendChild(ph);
390
446
  return w;
391
447
  }
@@ -393,12 +449,15 @@
393
449
  function insertAfter(el, id, klass, key) {
394
450
  if (!el?.insertAdjacentElement) return null;
395
451
  if (findWrap(key)) return null;
396
- if (S.mountedIds.has(id)) return null;
397
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
452
+ if (state.mountedIds.has(id)) return null;
453
+ const existing = document.getElementById(`${PH_PREFIX}${id}`);
454
+ if (existing?.isConnected) return null;
455
+
398
456
  const w = makeWrap(id, klass, key);
399
457
  mutate(() => el.insertAdjacentElement('afterend', w));
400
- S.mountedIds.add(id);
401
- S.wrapByKey.set(key, w);
458
+ state.mountedIds.add(id);
459
+ state.phState.set(id, 'new');
460
+ state.wrapByKey.set(key, w);
402
461
  getWrapSet(klass).add(w);
403
462
  return w;
404
463
  }
@@ -406,17 +465,17 @@
406
465
  function dropWrap(w) {
407
466
  try {
408
467
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
409
- if (ph instanceof Element) S.io?.unobserve(ph);
468
+ if (ph instanceof Element) state.io?.unobserve(ph);
410
469
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
411
470
  if (Number.isFinite(id)) {
412
- S.mountedIds.delete(id);
413
- S.lastShow.delete(id);
471
+ state.mountedIds.delete(id);
472
+ state.phState.delete(id);
414
473
  }
415
474
  const key = w.getAttribute(ATTR.ANCHOR);
416
- if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
475
+ if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
417
476
  for (const cls of w.classList) {
418
477
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
419
- S.wrapsByClass.get(cls)?.delete(w);
478
+ state.wrapsByClass.get(cls)?.delete(w);
420
479
  break;
421
480
  }
422
481
  }
@@ -429,7 +488,7 @@
429
488
  function pruneOrphansBetween() {
430
489
  const klass = 'ezoic-ad-between';
431
490
  const cfg = KIND[klass];
432
- const wraps = S.wrapsByClass.get(klass);
491
+ const wraps = state.wrapsByClass.get(klass);
433
492
  if (!wraps?.size) return;
434
493
 
435
494
  const liveAnchors = new Set();
@@ -437,13 +496,16 @@
437
496
  const v = el.getAttribute(cfg.anchorAttr);
438
497
  if (v) liveAnchors.add(v);
439
498
  }
499
+
440
500
  const t = now();
441
501
  for (const w of wraps) {
442
502
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
443
503
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
444
504
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
445
505
  const sid = key.slice(klass.length + 1);
446
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
506
+ if (!sid || !liveAnchors.has(sid)) {
507
+ mutate(() => dropWrap(w));
508
+ }
447
509
  }
448
510
  }
449
511
 
@@ -467,18 +529,26 @@
467
529
  function injectBetween(klass, items, interval, showFirst, poolKey) {
468
530
  if (!items.length) return 0;
469
531
  let inserted = 0;
532
+
470
533
  for (const el of items) {
471
- if (inserted >= MAX_INSERTS_RUN) break;
534
+ if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
472
535
  if (!el?.isConnected) continue;
536
+
473
537
  const ord = ordinal(klass, el);
474
538
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
475
539
  if (adjacentWrap(el)) continue;
540
+
476
541
  const key = anchorKey(klass, el);
477
542
  if (findWrap(key)) continue;
543
+
478
544
  const id = pickId(poolKey);
479
545
  if (id) {
480
546
  const w = insertAfter(el, id, klass, key);
481
- if (w) { observePh(id); inserted++; }
547
+ if (w) {
548
+ observePlaceholder(id);
549
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
550
+ inserted++;
551
+ }
482
552
  } else {
483
553
  const recycled = recycleWrap(klass, el, key);
484
554
  if (!recycled) break;
@@ -491,58 +561,69 @@
491
561
  // ── IntersectionObserver ───────────────────────────────────────────────────
492
562
 
493
563
  function getIO() {
494
- if (S.io) return S.io;
564
+ if (state.io) return state.io;
495
565
  try {
496
- S.io = new IntersectionObserver(entries => {
497
- for (const e of entries) {
498
- if (!e.isIntersecting) continue;
499
- if (e.target instanceof Element) S.io?.unobserve(e.target);
500
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
566
+ state.io = new IntersectionObserver(entries => {
567
+ for (const entry of entries) {
568
+ if (!entry.isIntersecting) continue;
569
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
570
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
501
571
  if (id > 0) enqueueShow(id);
502
572
  }
503
573
  }, {
504
574
  root: null,
505
- rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP,
575
+ rootMargin: isMobile() ? IO_MARGIN.MOBILE : IO_MARGIN.DESKTOP,
506
576
  threshold: 0,
507
577
  });
508
- } catch (_) { S.io = null; }
509
- return S.io;
578
+ } catch (_) { state.io = null; }
579
+ return state.io;
510
580
  }
511
581
 
512
- function observePh(id) {
582
+ function observePlaceholder(id) {
513
583
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
514
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
584
+ if (ph?.isConnected) {
585
+ try { getIO()?.observe(ph); } catch (_) {}
586
+ }
515
587
  }
516
588
 
517
589
  // ── Show queue ─────────────────────────────────────────────────────────────
518
590
 
519
591
  function enqueueShow(id) {
520
592
  if (!id || isBlocked()) return;
521
- if (now() - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
522
- if (S.inflight >= MAX_INFLIGHT) {
523
- if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
593
+ const st = state.phState.get(id);
594
+ if (st === 'show-queued' || st === 'shown') return;
595
+ if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
596
+
597
+ if (state.inflight >= LIMITS.MAX_INFLIGHT) {
598
+ if (!state.pendingSet.has(id)) {
599
+ state.pending.push(id);
600
+ state.pendingSet.add(id);
601
+ state.phState.set(id, 'show-queued');
602
+ }
524
603
  return;
525
604
  }
605
+ state.phState.set(id, 'show-queued');
526
606
  startShow(id);
527
607
  }
528
608
 
529
609
  function drainQueue() {
530
610
  if (isBlocked()) return;
531
- while (S.inflight < MAX_INFLIGHT && S.pending.length) {
532
- const id = S.pending.shift();
533
- S.pendingSet.delete(id);
611
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
612
+ const id = state.pending.shift();
613
+ state.pendingSet.delete(id);
534
614
  startShow(id);
535
615
  }
536
616
  }
537
617
 
538
618
  function startShow(id) {
539
619
  if (!id || isBlocked()) return;
540
- S.inflight++;
541
- let done = false;
620
+ state.inflight++;
621
+
622
+ let released = false;
542
623
  const release = () => {
543
- if (done) return;
544
- done = true;
545
- S.inflight = Math.max(0, S.inflight - 1);
624
+ if (released) return;
625
+ released = true;
626
+ state.inflight = Math.max(0, state.inflight - 1);
546
627
  drainQueue();
547
628
  };
548
629
  const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
@@ -550,31 +631,52 @@
550
631
  requestAnimationFrame(() => {
551
632
  try {
552
633
  if (isBlocked()) { clearTimeout(timer); return release(); }
634
+
553
635
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
554
- if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
636
+ if (!ph?.isConnected) {
637
+ state.phState.delete(id);
638
+ clearTimeout(timer);
639
+ return release();
640
+ }
641
+
642
+ if (isFilled(ph) || isPlaceholderUsed(ph)) {
643
+ state.phState.set(id, 'shown');
644
+ clearTimeout(timer);
645
+ return release();
646
+ }
555
647
 
556
648
  const t = now();
557
- if (t - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
558
- S.lastShow.set(id, t);
649
+ if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
650
+ clearTimeout(timer);
651
+ return release();
652
+ }
653
+ state.lastShow.set(id, t);
559
654
 
560
- const wrap = ph.closest(`.${WRAP_CLASS}`);
561
- try { wrap?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
655
+ try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
656
+ state.phState.set(id, 'shown');
562
657
 
563
658
  window.ezstandalone = window.ezstandalone || {};
564
659
  const ez = window.ezstandalone;
660
+
565
661
  const doShow = () => {
662
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
566
663
  try { ez.showAds(id); } catch (_) {}
567
664
  if (wrap) scheduleUncollapseChecks(wrap);
568
665
  scheduleEmptyCheck(id, t);
569
666
  setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
570
667
  };
668
+
571
669
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
572
- } catch (_) { clearTimeout(timer); release(); }
670
+ } catch (_) {
671
+ clearTimeout(timer);
672
+ release();
673
+ }
573
674
  });
574
675
  }
575
676
 
576
677
  function scheduleEmptyCheck(id, showTs) {
577
- for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
678
+ // Two-pass check: conservative to avoid collapsing slow-loading ads
679
+ for (const delay of [TIMING.EMPTY_CHECK_EARLY_MS, TIMING.EMPTY_CHECK_LATE_MS]) {
578
680
  setTimeout(() => {
579
681
  try {
580
682
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
@@ -582,8 +684,9 @@
582
684
  if (!wrap || !ph?.isConnected) return;
583
685
  if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
584
686
  if (clearEmptyIfFilled(wrap)) return;
585
- // Don't collapse if GPT slot exists (still loading)
687
+ // Don't collapse if a GPT slot exists (might still be loading)
586
688
  if (ph.querySelector('[id^="div-gpt-ad"]')) return;
689
+ // Don't collapse if placeholder has meaningful height
587
690
  if (ph.offsetHeight > 10) return;
588
691
  wrap.classList.add('is-empty');
589
692
  } catch (_) {}
@@ -592,7 +695,10 @@
592
695
  }
593
696
 
594
697
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
595
- // Matches v50: individual calls, no batching, pass-through no-arg calls.
698
+ //
699
+ // Intercepts ez.showAds() to filter disconnected placeholders and
700
+ // block calls during navigation. Matches v50 behavior: individual calls,
701
+ // no batching.
596
702
 
597
703
  function patchShowAds() {
598
704
  const apply = () => {
@@ -601,8 +707,10 @@
601
707
  const ez = window.ezstandalone;
602
708
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
603
709
  window.__nbbEzPatched = true;
710
+
604
711
  const orig = ez.showAds.bind(ez);
605
712
  ez.showAds = function (...args) {
713
+ // No-arg call = Ezoic internal page refresh — pass through
606
714
  if (args.length === 0) return orig();
607
715
  if (isBlocked()) return;
608
716
  const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
@@ -617,6 +725,7 @@
617
725
  };
618
726
  } catch (_) {}
619
727
  };
728
+
620
729
  apply();
621
730
  if (!window.__nbbEzPatched) {
622
731
  window.ezstandalone = window.ezstandalone || {};
@@ -628,7 +737,6 @@
628
737
 
629
738
  async function runCore() {
630
739
  if (isBlocked()) return 0;
631
- patchShowAds();
632
740
  try { gcDisconnectedWraps(); } catch (_) {}
633
741
 
634
742
  const cfg = await fetchConfig();
@@ -645,26 +753,32 @@
645
753
  };
646
754
 
647
755
  if (kind === 'topic') {
648
- return exec('ezoic-ad-message', getPosts,
649
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
756
+ return exec(
757
+ 'ezoic-ad-message', getPosts,
758
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
759
+ );
650
760
  }
651
761
  if (kind === 'categoryTopics') {
652
762
  pruneOrphansBetween();
653
- return exec('ezoic-ad-between', getTopics,
654
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
763
+ return exec(
764
+ 'ezoic-ad-between', getTopics,
765
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
766
+ );
655
767
  }
656
- return exec('ezoic-ad-categories', getCategories,
657
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
768
+ return exec(
769
+ 'ezoic-ad-categories', getCategories,
770
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
771
+ );
658
772
  }
659
773
 
660
774
  // ── Scheduler & burst ──────────────────────────────────────────────────────
661
775
 
662
776
  function scheduleRun(cb) {
663
- if (S.runQueued) return;
664
- S.runQueued = true;
777
+ if (state.runQueued) return;
778
+ state.runQueued = true;
665
779
  requestAnimationFrame(async () => {
666
- S.runQueued = false;
667
- if (S.pageKey && pageKey() !== S.pageKey) return;
780
+ state.runQueued = false;
781
+ if (state.pageKey && pageKey() !== state.pageKey) return;
668
782
  let n = 0;
669
783
  try { n = await runCore(); } catch (_) {}
670
784
  try { cb?.(n); } catch (_) {}
@@ -674,20 +788,21 @@
674
788
  function requestBurst() {
675
789
  if (isBlocked()) return;
676
790
  const t = now();
677
- if (t - S.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
678
- S.lastBurstTs = t;
679
- S.pageKey = pageKey();
680
- S.burstDeadline = t + BURST_WINDOW_MS;
681
- if (S.burstActive) return;
682
- S.burstActive = true;
683
- S.burstCount = 0;
791
+ if (t - state.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
792
+ state.lastBurstTs = t;
793
+ state.pageKey = pageKey();
794
+ state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
795
+ if (state.burstActive) return;
796
+ state.burstActive = true;
797
+ state.burstCount = 0;
684
798
  const step = () => {
685
- if (pageKey() !== S.pageKey || isBlocked() || now() > S.burstDeadline || S.burstCount >= MAX_BURST_STEPS) {
686
- S.burstActive = false; return;
799
+ if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
800
+ state.burstActive = false;
801
+ return;
687
802
  }
688
- S.burstCount++;
803
+ state.burstCount++;
689
804
  scheduleRun(n => {
690
- if (!n && !S.pending.length) { S.burstActive = false; return; }
805
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
691
806
  setTimeout(step, n > 0 ? 150 : 300);
692
807
  });
693
808
  };
@@ -697,32 +812,37 @@
697
812
  // ── Cleanup on navigation ──────────────────────────────────────────────────
698
813
 
699
814
  function cleanup() {
700
- S.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
815
+ state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
816
+
701
817
  mutate(() => {
702
818
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
703
819
  });
704
- S.cfg = null;
705
- S.poolsReady = false;
706
- S.pools = { topics: [], posts: [], categories: [] };
707
- S.cursors = { topics: 0, posts: 0, categories: 0 };
708
- S.mountedIds.clear();
709
- S.lastShow.clear();
710
- S.wrapByKey.clear();
711
- S.wrapsByClass.clear();
712
- S.kind = null;
713
- S.inflight = 0;
714
- S.pending = [];
715
- S.pendingSet.clear();
716
- S.burstActive = false;
717
- S.runQueued = false;
820
+ state.cfg = null;
821
+ state.poolsReady = false;
822
+ state.pools = { topics: [], posts: [], categories: [] };
823
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
824
+ state.mountedIds.clear();
825
+ state.lastShow.clear();
826
+ state.wrapByKey.clear();
827
+ state.wrapsByClass.clear();
828
+ state.kind = null;
829
+ state.phState.clear();
830
+ state.inflight = 0;
831
+ state.pending = [];
832
+ state.pendingSet.clear();
833
+ state.burstActive = false;
834
+ state.runQueued = false;
835
+ state.firstShown = false;
718
836
  }
719
837
 
720
838
  // ── MutationObserver ───────────────────────────────────────────────────────
721
839
 
722
840
  function ensureDomObserver() {
723
- if (S.domObs) return;
724
- S.domObs = new MutationObserver(muts => {
725
- if (S.mutGuard > 0 || isBlocked()) return;
841
+ if (state.domObs) return;
842
+
843
+ state.domObs = new MutationObserver(muts => {
844
+ if (state.mutGuard > 0 || isBlocked()) return;
845
+
726
846
  let needsBurst = false;
727
847
  const kind = getKind();
728
848
  const relevantSels =
@@ -730,8 +850,10 @@
730
850
  kind === 'categoryTopics' ? [SEL.topic] :
731
851
  kind === 'categories' ? [SEL.category] :
732
852
  [SEL.post, SEL.topic, SEL.category];
853
+
733
854
  for (const m of muts) {
734
855
  if (m.type !== 'childList') continue;
856
+
735
857
  // Free IDs from wraps removed by NodeBB virtualization
736
858
  for (const node of m.removedNodes) {
737
859
  if (!(node instanceof Element)) continue;
@@ -740,13 +862,15 @@
740
862
  dropWrap(node);
741
863
  } else {
742
864
  const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
743
- if (wraps?.length) for (const w of wraps) dropWrap(w);
865
+ if (wraps?.length) { for (const w of wraps) dropWrap(w); }
744
866
  }
745
867
  } catch (_) {}
746
868
  }
869
+
747
870
  for (const node of m.addedNodes) {
748
871
  if (!(node instanceof Element)) continue;
749
- // Ad fill detection → uncollapse
872
+
873
+ // Ad fill detection
750
874
  try {
751
875
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
752
876
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -754,19 +878,20 @@
754
878
  if (wrap) clearEmptyIfFilled(wrap);
755
879
  }
756
880
  } catch (_) {}
881
+
757
882
  // Re-observe wraps re-inserted by NodeBB virtualization
758
883
  try {
759
- const reinserted = node.classList?.contains(WRAP_CLASS)
884
+ const wraps = node.classList?.contains(WRAP_CLASS)
760
885
  ? [node]
761
886
  : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
762
- for (const wrap of reinserted) {
887
+ for (const wrap of wraps) {
763
888
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
764
- if (id > 0) {
765
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
766
- if (ph) try { getIO()?.observe(ph); } catch (_) {}
767
- }
889
+ if (!id || id <= 0) continue;
890
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
891
+ if (ph) { try { getIO()?.observe(ph); } catch (_) {} }
768
892
  }
769
893
  } catch (_) {}
894
+
770
895
  // New content detection
771
896
  if (!needsBurst) {
772
897
  for (const sel of relevantSels) {
@@ -778,23 +903,29 @@
778
903
  }
779
904
  if (needsBurst) break;
780
905
  }
906
+
781
907
  if (needsBurst) requestBurst();
782
908
  });
783
- try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
909
+
910
+ try {
911
+ state.domObs.observe(document.body, { childList: true, subtree: true });
912
+ } catch (_) {}
784
913
  }
785
914
 
786
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
915
+ // ── TCF / CMP Protection (3 layers) ────────────────────────────────────────
787
916
 
788
917
  function ensureTcfLocator() {
789
918
  if (!window.__tcfapi && !window.__cmp) return;
919
+
790
920
  const LOCATOR_ID = '__tcfapiLocator';
921
+
791
922
  const ensureInHead = () => {
792
923
  let existing = document.getElementById(LOCATOR_ID);
793
924
  if (existing) {
794
925
  if (existing.parentElement !== document.head) {
795
926
  try { document.head.appendChild(existing); } catch (_) {}
796
927
  }
797
- return;
928
+ return existing;
798
929
  }
799
930
  const f = document.createElement('iframe');
800
931
  f.style.display = 'none';
@@ -802,7 +933,9 @@
802
933
  try { document.head.appendChild(f); } catch (_) {
803
934
  (document.body || document.documentElement).appendChild(f);
804
935
  }
936
+ return f;
805
937
  };
938
+
806
939
  ensureInHead();
807
940
 
808
941
  if (!window.__nbbCmpGuarded) {
@@ -844,8 +977,12 @@
844
977
  window.__nbbTcfObs.observe(document.body || document.documentElement, {
845
978
  childList: true, subtree: false,
846
979
  });
980
+ } catch (_) {}
981
+ try {
847
982
  if (document.head) {
848
- window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false });
983
+ window.__nbbTcfObs.observe(document.head, {
984
+ childList: true, subtree: false,
985
+ });
849
986
  }
850
987
  } catch (_) {}
851
988
  }
@@ -866,7 +1003,8 @@
866
1003
  window.__nbbAriaObs = new MutationObserver(remove);
867
1004
  try {
868
1005
  window.__nbbAriaObs.observe(document.body, {
869
- attributes: true, attributeFilter: ['aria-hidden'],
1006
+ attributes: true,
1007
+ attributeFilter: ['aria-hidden'],
870
1008
  });
871
1009
  } catch (_) {}
872
1010
  }
@@ -876,7 +1014,8 @@
876
1014
  function muteConsole() {
877
1015
  if (window.__nbbEzMuted) return;
878
1016
  window.__nbbEzMuted = true;
879
- const MUTED = [
1017
+
1018
+ const PREFIXES = [
880
1019
  '[EzoicAds JS]: Placeholder Id',
881
1020
  'No valid placeholders for loadMore',
882
1021
  'cannot call refresh on the same page',
@@ -885,22 +1024,22 @@
885
1024
  '[CMP] Error in custom getTCData',
886
1025
  'vignette: no interstitial API',
887
1026
  'Ezoic JS-Enable should only ever',
888
- '### sending slotDestroyed',
889
- 'Error loading identity bridging',
1027
+ ];
1028
+ const PATTERNS = [
890
1029
  `with id ${PH_PREFIX}`,
891
1030
  'adsbygoogle.push() error: All',
892
1031
  'has already been defined',
893
1032
  'bad response. Status',
894
- 'slotDestroyed event',
895
- 'identity bridging',
896
1033
  ];
1034
+
897
1035
  for (const method of ['log', 'info', 'warn', 'error']) {
898
1036
  const orig = console[method];
899
1037
  if (typeof orig !== 'function') continue;
900
1038
  console[method] = function (...args) {
901
1039
  if (typeof args[0] === 'string') {
902
1040
  const msg = args[0];
903
- for (const p of MUTED) { if (msg.includes(p)) return; }
1041
+ for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
1042
+ for (const p of PATTERNS) { if (msg.includes(p)) return; }
904
1043
  }
905
1044
  return orig.apply(console, args);
906
1045
  };
@@ -909,25 +1048,28 @@
909
1048
 
910
1049
  // ── Network warmup ─────────────────────────────────────────────────────────
911
1050
 
912
- const _warmed = new Set();
1051
+ let _networkWarmed = false;
1052
+
913
1053
  function warmNetwork() {
1054
+ if (_networkWarmed) return;
1055
+ _networkWarmed = true;
914
1056
  const head = document.head;
915
1057
  if (!head) return;
916
- for (const [rel, href, cors] of [
1058
+ const hints = [
917
1059
  ['preconnect', 'https://g.ezoic.net', true ],
918
1060
  ['preconnect', 'https://go.ezoic.net', true ],
919
1061
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
920
1062
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
921
1063
  ['dns-prefetch', 'https://g.ezoic.net', false],
922
1064
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
923
- ]) {
924
- const k = `${rel}|${href}`;
925
- if (_warmed.has(k)) continue;
926
- _warmed.add(k);
927
- const l = document.createElement('link');
928
- l.rel = rel; l.href = href;
929
- if (cors) l.crossOrigin = 'anonymous';
930
- head.appendChild(l);
1065
+ ];
1066
+ for (const [rel, href, cors] of hints) {
1067
+ if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1068
+ const link = document.createElement('link');
1069
+ link.rel = rel;
1070
+ link.href = href;
1071
+ if (cors) link.crossOrigin = 'anonymous';
1072
+ head.appendChild(link);
931
1073
  }
932
1074
  }
933
1075
 
@@ -936,30 +1078,47 @@
936
1078
  function bindNodeBB() {
937
1079
  const $ = window.jQuery;
938
1080
  if (!$) return;
1081
+
939
1082
  $(window).off('.nbbEzoic');
1083
+
1084
+ // Cleanup on every navigation, same as v50
940
1085
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1086
+
941
1087
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
942
- S.pageKey = pageKey();
943
- S.kind = null;
944
- S.blockedUntil = 0;
945
- muteConsole(); ensureTcfLocator(); protectAriaHidden();
946
- warmNetwork(); patchShowAds(); getIO(); ensureDomObserver();
1088
+ state.pageKey = pageKey();
1089
+ state.kind = null;
1090
+ state.blockedUntil = 0;
1091
+
1092
+ muteConsole();
1093
+ ensureTcfLocator();
1094
+ protectAriaHidden();
1095
+ warmNetwork();
1096
+ patchShowAds();
1097
+ getIO();
1098
+ ensureDomObserver();
947
1099
  requestBurst();
948
1100
  });
949
1101
 
950
1102
  const burstEvents = [
951
- 'action:ajaxify.contentLoaded', 'action:posts.loaded',
952
- 'action:topics.loaded', 'action:categories.loaded',
953
- 'action:category.loaded', 'action:topic.loaded',
1103
+ 'action:ajaxify.contentLoaded',
1104
+ 'action:posts.loaded',
1105
+ 'action:topics.loaded',
1106
+ 'action:categories.loaded',
1107
+ 'action:category.loaded',
1108
+ 'action:topic.loaded',
954
1109
  ].map(e => `${e}.nbbEzoic`).join(' ');
1110
+
955
1111
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
956
1112
 
957
1113
  try {
958
1114
  require(['hooks'], hooks => {
959
1115
  if (typeof hooks?.on !== 'function') return;
960
1116
  for (const ev of [
961
- 'action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
962
- 'action:categories.loaded', 'action:topic.loaded',
1117
+ 'action:ajaxify.end',
1118
+ 'action:posts.loaded',
1119
+ 'action:topics.loaded',
1120
+ 'action:categories.loaded',
1121
+ 'action:topic.loaded',
963
1122
  ]) {
964
1123
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
965
1124
  }
@@ -972,13 +1131,16 @@
972
1131
  window.addEventListener('scroll', () => {
973
1132
  if (ticking) return;
974
1133
  ticking = true;
975
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1134
+ requestAnimationFrame(() => {
1135
+ ticking = false;
1136
+ requestBurst();
1137
+ });
976
1138
  }, { passive: true });
977
1139
  }
978
1140
 
979
1141
  // ── Boot ───────────────────────────────────────────────────────────────────
980
1142
 
981
- S.pageKey = pageKey();
1143
+ state.pageKey = pageKey();
982
1144
  muteConsole();
983
1145
  ensureTcfLocator();
984
1146
  protectAriaHidden();
@@ -988,7 +1150,7 @@
988
1150
  ensureDomObserver();
989
1151
  bindNodeBB();
990
1152
  bindScroll();
991
- S.blockedUntil = 0;
1153
+ state.blockedUntil = 0;
992
1154
  requestBurst();
993
1155
 
994
1156
  })();