nodebb-plugin-ezoic-infinite 1.8.66 → 1.8.67

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