nodebb-plugin-ezoic-infinite 1.8.45 → 1.8.47

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 (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +491 -257
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.45",
3
+ "version": "1.8.47",
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,16 +1,12 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v3.0.1
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.2.0
3
3
  *
4
- * Ezoic API usage per official docs:
5
- * https://docs.ezoic.com/docs/ezoicads/dynamic-content/
6
- *
7
- * NEW PLACEHOLDERS: ez.cmd.push(() => ez.showAds(id1, id2, ...))
8
- * RECYCLE (reuse ID): ez.cmd.push(() => ez.destroyPlaceholders(id))
9
- * remove old HTML create fresh placeholder
10
- * → ez.cmd.push(() => ez.showAds(id))
11
- *
12
- * phState per placeholder ID:
13
- * new → displayed → [destroyed → new] (recycle loop)
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
14
10
  */
15
11
  (function nbbEzoicInfinite() {
16
12
  'use strict';
@@ -28,20 +24,22 @@
28
24
  };
29
25
 
30
26
  const TIMING = {
31
- EMPTY_CHECK_MS: 20_000,
32
- MIN_PRUNE_AGE_MS: 8_000,
33
- SHOW_THROTTLE_MS: 900,
34
- BURST_COOLDOWN_MS: 200,
35
- BLOCK_DURATION_MS: 1_500,
36
- // Batch Ezoic API calls a bit more aggressively on SPA transitions.
37
- // This reduces early collector calls (e.g. samo.go) during ajaxify route swaps.
38
- BATCH_FLUSH_MS: 500,
39
- RECYCLE_DESTROY_MS: 300,
40
- RECYCLE_SHOW_MS: 300,
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,
41
37
  };
42
38
 
43
39
  const LIMITS = {
44
40
  MAX_INSERTS_RUN: 6,
41
+ MAX_INFLIGHT: 4,
42
+ BATCH_SIZE: 3,
45
43
  MAX_BURST_STEPS: 8,
46
44
  BURST_WINDOW_MS: 2_000,
47
45
  };
@@ -64,9 +62,7 @@
64
62
  };
65
63
 
66
64
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
67
-
68
- // Reduce "ad popping" from aggressive ID recycling.
69
- const RECYCLE_MIN_AGE_MS = 20_000;
65
+ const RECYCLE_MIN_AGE_MS = 5_000;
70
66
 
71
67
  // ── Utility ────────────────────────────────────────────────────────────────
72
68
 
@@ -74,17 +70,24 @@
74
70
  const isMobile = () => window.innerWidth < 768;
75
71
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
76
72
 
77
- function isFilled(node) { return node?.querySelector?.(FILL_SEL) != null; }
73
+ function isFilled(node) {
74
+ return node?.querySelector?.(FILL_SEL) != null;
75
+ }
76
+
78
77
  function isPlaceholderUsed(ph) {
79
78
  if (!ph?.isConnected) return false;
80
79
  return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
81
80
  }
82
81
 
83
82
  function parseIds(raw) {
84
- const out = [], seen = new Set();
83
+ const out = [];
84
+ const seen = new Set();
85
85
  for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
86
86
  const n = parseInt(line, 10);
87
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
87
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
88
+ seen.add(n);
89
+ out.push(n);
90
+ }
88
91
  }
89
92
  return out;
90
93
  }
@@ -92,29 +95,37 @@
92
95
  // ── State ──────────────────────────────────────────────────────────────────
93
96
 
94
97
  const state = {
95
- pageKey: null, kind: null, cfg: null,
98
+ pageKey: null,
99
+ kind: null,
100
+ cfg: null,
96
101
 
97
102
  poolsReady: false,
98
- pools: { topics: [], posts: [], categories: [] },
99
- cursors: { topics: 0, posts: 0, categories: 0 },
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
100
109
 
101
- mountedIds: new Set(),
102
- // phState: 'new' | 'displayed' | 'destroyed'
103
- phState: new Map(),
104
- lastShow: new Map(),
105
110
  wrapByKey: new Map(),
106
111
  wrapsByClass: new Map(),
107
112
 
108
- io: null, domObs: null,
109
- mutGuard: 0, blockedUntil: 0,
113
+ io: null,
114
+ domObs: null,
110
115
 
111
- // Ezoic batch queue: ids waiting for define+displayMore
112
- ezBatch: new Set(),
113
- ezFlushTimer: null,
116
+ mutGuard: 0,
117
+ blockedUntil: 0,
114
118
 
115
- // Lifecycle
116
- runQueued: false,
117
- burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstTs: 0,
119
+ inflight: 0,
120
+ pending: [],
121
+ pendingSet: new Set(),
122
+
123
+ runQueued: false,
124
+ burstActive: false,
125
+ burstDeadline: 0,
126
+ burstCount: 0,
127
+ lastBurstTs: 0,
128
+ firstShown: false,
118
129
  };
119
130
 
120
131
  const isBlocked = () => now() < state.blockedUntil;
@@ -130,7 +141,10 @@
130
141
  if (state.cfg) return state.cfg;
131
142
  try {
132
143
  const inline = window.__nbbEzoicCfg;
133
- if (inline && typeof inline === 'object') { state.cfg = inline; return state.cfg; }
144
+ if (inline && typeof inline === 'object') {
145
+ state.cfg = inline;
146
+ return state.cfg;
147
+ }
134
148
  } catch (_) {}
135
149
  try {
136
150
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
@@ -169,12 +183,15 @@
169
183
  return 'other';
170
184
  }
171
185
 
172
- function getKind() { return state.kind || (state.kind = detectKind()); }
186
+ function getKind() {
187
+ return state.kind || (state.kind = detectKind());
188
+ }
173
189
 
174
190
  // ── DOM queries ────────────────────────────────────────────────────────────
175
191
 
176
192
  function getPosts() {
177
- const all = document.querySelectorAll(SEL.post), out = [];
193
+ const all = document.querySelectorAll(SEL.post);
194
+ const out = [];
178
195
  for (let i = 0; i < all.length; i++) {
179
196
  const el = all[i];
180
197
  if (!el.isConnected) continue;
@@ -186,6 +203,7 @@
186
203
  }
187
204
  return out;
188
205
  }
206
+
189
207
  function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
190
208
  function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
191
209
 
@@ -193,10 +211,15 @@
193
211
 
194
212
  function stableId(klass, el) {
195
213
  const attr = KIND[klass]?.anchorAttr;
196
- if (attr) { const v = el.getAttribute(attr); if (v != null && v !== '') return v; }
214
+ if (attr) {
215
+ const v = el.getAttribute(attr);
216
+ if (v != null && v !== '') return v;
217
+ }
197
218
  const children = el.parentElement?.children;
198
219
  if (!children) return 'i0';
199
- for (let i = 0; i < children.length; i++) { if (children[i] === el) return `i${i}`; }
220
+ for (let i = 0; i < children.length; i++) {
221
+ if (children[i] === el) return `i${i}`;
222
+ }
200
223
  return 'i0';
201
224
  }
202
225
 
@@ -208,12 +231,49 @@
208
231
  }
209
232
 
210
233
  function getWrapSet(klass) {
211
- let s = state.wrapsByClass.get(klass);
212
- if (!s) { s = new Set(); state.wrapsByClass.set(klass, s); }
213
- return s;
234
+ let set = state.wrapsByClass.get(klass);
235
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
236
+ return set;
237
+ }
238
+
239
+ // ── GC disconnected wraps (NodeBB virtualization) ──────────────────────────
240
+
241
+ function gcDisconnectedWraps() {
242
+ for (const [key, w] of Array.from(state.wrapByKey.entries())) {
243
+ if (!w?.isConnected) state.wrapByKey.delete(key);
244
+ }
245
+ for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
246
+ for (const w of Array.from(set)) {
247
+ if (w?.isConnected) continue;
248
+ 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);
271
+ }
272
+ }
273
+ } catch (_) {}
214
274
  }
215
275
 
216
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
276
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
217
277
 
218
278
  function wrapIsLive(wrap) {
219
279
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
@@ -221,17 +281,22 @@
221
281
  if (!key) return false;
222
282
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
223
283
  const colonIdx = key.indexOf(':');
224
- const klass = key.slice(0, colonIdx), anchorId = key.slice(colonIdx + 1);
284
+ const klass = key.slice(0, colonIdx);
285
+ const anchorId = key.slice(colonIdx + 1);
225
286
  const cfg = KIND[klass];
226
287
  if (!cfg) return false;
227
288
  const parent = wrap.parentElement;
228
289
  if (parent) {
229
290
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
230
291
  for (const sib of parent.children) {
231
- if (sib !== wrap) { try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {} }
292
+ if (sib !== wrap) {
293
+ try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
294
+ }
232
295
  }
233
296
  }
234
- try { return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true; } catch (_) { return false; }
297
+ try {
298
+ return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
299
+ } catch (_) { return false; }
235
300
  }
236
301
 
237
302
  function adjacentWrap(el) {
@@ -246,7 +311,7 @@
246
311
  if (!ph || !isFilled(ph)) return false;
247
312
  wrap.classList.remove('is-empty');
248
313
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
249
- if (id > 0) state.phState.set(id, 'displayed');
314
+ if (id > 0) state.phState.set(id, 'shown');
250
315
  return true;
251
316
  }
252
317
 
@@ -257,7 +322,7 @@
257
322
  }
258
323
  }
259
324
 
260
- // ── Pool ───────────────────────────────────────────────────────────────────
325
+ // ── Pool management ────────────────────────────────────────────────────────
261
326
 
262
327
  function pickId(poolKey) {
263
328
  const pool = state.pools[poolKey];
@@ -271,112 +336,10 @@
271
336
  return null;
272
337
  }
273
338
 
274
- // ── Ezoic API layer ────────────────────────────────────────────────────────
275
- //
276
- // Correct Ezoic infinite scroll flow:
277
- // First batch: ez.cmd.push(() => { ez.define(...ids); ez.enable(); })
278
- // Next batches: ez.cmd.push(() => { ez.define(...ids); ez.displayMore(...ids); })
279
- // Recycle: ez.cmd.push(() => { ez.destroyPlaceholders(id); })
280
- // → wait → new DOM → ez.cmd.push(() => { ez.define(id); ez.displayMore(id); })
281
- //
282
- // We batch define+displayMore calls using ezBatch to avoid calling the
283
- // Ezoic API on every single placeholder insertion.
284
-
285
- function ezCmd(fn) {
286
- window.ezstandalone = window.ezstandalone || {};
287
- const ez = window.ezstandalone;
288
- if (Array.isArray(ez.cmd)) {
289
- ez.cmd.push(fn);
290
- } else {
291
- try { fn(); } catch (_) {}
292
- }
293
- }
294
-
295
- /**
296
- * Queue a placeholder ID for the next batched showAds call.
297
- */
298
- function ezEnqueue(id) {
299
- if (isBlocked()) return;
300
- state.ezBatch.add(id);
301
- if (!state.ezFlushTimer) {
302
- state.ezFlushTimer = setTimeout(ezFlush, TIMING.BATCH_FLUSH_MS);
303
- }
304
- }
305
-
306
- /**
307
- * Flush: call showAds() for all queued IDs.
308
- *
309
- * Per Ezoic docs (https://docs.ezoic.com/docs/ezoicads/dynamic-content/):
310
- * - New placeholders: ez.showAds(id1, id2, ...)
311
- * - After destroy+recreate: ez.showAds(id) again
312
- * - No define(), no enable(), no displayMore()
313
- */
314
- function ezFlush() {
315
- state.ezFlushTimer = null;
316
- if (isBlocked() || !state.ezBatch.size) return;
317
-
318
- // Filter to only valid, connected, unfilled placeholders
319
- const ids = [];
320
- for (const id of state.ezBatch) {
321
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
322
- if (!ph?.isConnected) { state.phState.delete(id); continue; }
323
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'displayed'); continue; }
324
- ids.push(id);
325
- }
326
- state.ezBatch.clear();
327
- if (!ids.length) return;
328
-
329
- ezCmd(() => {
330
- const ez = window.ezstandalone;
331
- if (typeof ez?.showAds !== 'function') return;
332
-
333
- try { ez.showAds(...ids); } catch (_) {}
334
-
335
- // Mark as displayed and schedule fill checks
336
- for (const id of ids) {
337
- state.phState.set(id, 'displayed');
338
- state.lastShow.set(id, now());
339
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
340
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
341
- if (wrap) {
342
- wrap.setAttribute(ATTR.SHOWN, String(now()));
343
- scheduleUncollapseChecks(wrap);
344
- }
345
- scheduleEmptyCheck(id);
346
- }
347
- });
348
- }
349
-
350
- function scheduleEmptyCheck(id) {
351
- const showTs = now();
352
- // Check at 30s, then again at 60s — very conservative to avoid
353
- // collapsing slow-loading ads
354
- for (const delay of [30_000, 60_000]) {
355
- setTimeout(() => {
356
- try {
357
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
358
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
359
- if (!wrap || !ph?.isConnected) return;
360
- // Don't collapse if a newer show happened
361
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
362
- // If already collapsed or uncollapsed, skip
363
- if (clearEmptyIfFilled(wrap)) return;
364
- // Don't collapse if there's any GPT slot, even unfilled
365
- // (GPT may still be processing the ad request)
366
- if (ph.querySelector('[id^="div-gpt-ad"]')) return;
367
- // Don't collapse if there's any child with meaningful height
368
- if (ph.offsetHeight > 10) return;
369
- wrap.classList.add('is-empty');
370
- } catch (_) {}
371
- }, delay);
372
- }
373
- }
374
-
375
339
  // ── Recycling ──────────────────────────────────────────────────────────────
376
340
  //
377
- // Per Ezoic docs: when reusing placeholder IDs in infinite scroll,
378
- // call destroyPlaceholders(id) first, remove the old HTML, recreate
379
- // a fresh placeholder div, then call showAds(id) again.
341
+ // Per Ezoic docs: destroyPlaceholders(id) remove old HTML
342
+ // recreate fresh placeholder → showAds(id).
380
343
 
381
344
  function recycleWrap(klass, targetEl, newKey) {
382
345
  const ez = window.ezstandalone;
@@ -384,8 +347,7 @@
384
347
  typeof ez?.showAds !== 'function') return null;
385
348
 
386
349
  const vh = window.innerHeight || 800;
387
- // Recycle only when the wrap is well above the viewport to avoid visible jumps.
388
- const threshold = -(6 * vh);
350
+ const threshold = -(3 * vh);
389
351
  const t = now();
390
352
  let bestEmpty = null, bestEmptyY = Infinity;
391
353
  let bestFull = null, bestFullY = Infinity;
@@ -395,11 +357,13 @@
395
357
 
396
358
  for (const wrap of wraps) {
397
359
  try {
360
+ // Skip young wraps (ad might still be loading)
398
361
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
399
362
  if (t - created < RECYCLE_MIN_AGE_MS) continue;
363
+ // Skip wraps with inflight showAds
400
364
  const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
401
- const st = state.phState.get(wid);
402
- if (st === 'new') continue; // not yet shown, don't recycle
365
+ if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
366
+
403
367
  const bottom = wrap.getBoundingClientRect().bottom;
404
368
  if (bottom > threshold) continue;
405
369
  if (!isFilled(wrap)) {
@@ -424,16 +388,13 @@
424
388
  if (ph) state.io?.unobserve(ph);
425
389
  } catch (_) {}
426
390
 
427
- state.phState.set(id, 'destroyed');
428
-
429
- // Step 1: destroyPlaceholders(id)
430
- // Step 2: remove old HTML, create fresh placeholder at new position
431
- // Step 3: showAds(id)
432
- ezCmd(() => {
391
+ // Ezoic recycle: destroy → new DOM → showAds
392
+ const doRecycle = () => {
393
+ state.phState.set(id, 'destroyed');
433
394
  try { ez.destroyPlaceholders(id); } catch (_) {}
434
395
 
435
396
  setTimeout(() => {
436
- // Recreate placeholder DOM
397
+ // Recreate fresh placeholder DOM at new position
437
398
  mutate(() => {
438
399
  best.setAttribute(ATTR.ANCHOR, newKey);
439
400
  best.setAttribute(ATTR.CREATED, String(now()));
@@ -452,14 +413,18 @@
452
413
  if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
453
414
  state.wrapByKey.set(newKey, best);
454
415
 
455
- // showAds again after DOM is ready
416
+ // Re-show after DOM is settled
456
417
  setTimeout(() => {
457
- state.phState.set(id, 'new');
458
418
  observePlaceholder(id);
459
- ezEnqueue(id);
460
- }, TIMING.RECYCLE_SHOW_MS);
461
- }, TIMING.RECYCLE_DESTROY_MS);
462
- });
419
+ state.phState.set(id, 'new');
420
+ enqueueShow(id);
421
+ }, TIMING.RECYCLE_DELAY_MS);
422
+ }, TIMING.RECYCLE_DELAY_MS);
423
+ };
424
+
425
+ try {
426
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle();
427
+ } catch (_) {}
463
428
 
464
429
  return { id, wrap: best };
465
430
  }
@@ -474,6 +439,7 @@
474
439
  w.setAttribute(ATTR.CREATED, String(now()));
475
440
  w.setAttribute(ATTR.SHOWN, '0');
476
441
  w.style.cssText = 'width:100%;display:block';
442
+
477
443
  const ph = document.createElement('div');
478
444
  ph.id = `${PH_PREFIX}${id}`;
479
445
  ph.setAttribute('data-ezoic-id', String(id));
@@ -488,6 +454,7 @@
488
454
  if (state.mountedIds.has(id)) return null;
489
455
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
490
456
  if (existing?.isConnected) return null;
457
+
491
458
  const w = makeWrap(id, klass, key);
492
459
  mutate(() => el.insertAdjacentElement('afterend', w));
493
460
  state.mountedIds.add(id);
@@ -502,12 +469,16 @@
502
469
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
503
470
  if (ph instanceof Element) state.io?.unobserve(ph);
504
471
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
505
- if (Number.isFinite(id)) { state.mountedIds.delete(id); state.phState.delete(id); }
472
+ if (Number.isFinite(id)) {
473
+ state.mountedIds.delete(id);
474
+ state.phState.delete(id);
475
+ }
506
476
  const key = w.getAttribute(ATTR.ANCHOR);
507
477
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
508
478
  for (const cls of w.classList) {
509
479
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
510
- state.wrapsByClass.get(cls)?.delete(w); break;
480
+ state.wrapsByClass.get(cls)?.delete(w);
481
+ break;
511
482
  }
512
483
  }
513
484
  w.remove();
@@ -521,18 +492,22 @@
521
492
  const cfg = KIND[klass];
522
493
  const wraps = state.wrapsByClass.get(klass);
523
494
  if (!wraps?.size) return;
495
+
524
496
  const liveAnchors = new Set();
525
497
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
526
498
  const v = el.getAttribute(cfg.anchorAttr);
527
499
  if (v) liveAnchors.add(v);
528
500
  }
501
+
529
502
  const t = now();
530
503
  for (const w of wraps) {
531
504
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
532
505
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
533
506
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
534
507
  const sid = key.slice(klass.length + 1);
535
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
508
+ if (!sid || !liveAnchors.has(sid)) {
509
+ mutate(() => dropWrap(w));
510
+ }
536
511
  }
537
512
  }
538
513
 
@@ -560,9 +535,11 @@
560
535
  for (const el of items) {
561
536
  if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
562
537
  if (!el?.isConnected) continue;
538
+
563
539
  const ord = ordinal(klass, el);
564
540
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
565
541
  if (adjacentWrap(el)) continue;
542
+
566
543
  const key = anchorKey(klass, el);
567
544
  if (findWrap(key)) continue;
568
545
 
@@ -571,8 +548,7 @@
571
548
  const w = insertAfter(el, id, klass, key);
572
549
  if (w) {
573
550
  observePlaceholder(id);
574
- // Queue for batched define+enable/displayMore
575
- ezEnqueue(id);
551
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
576
552
  inserted++;
577
553
  }
578
554
  } else {
@@ -585,12 +561,6 @@
585
561
  }
586
562
 
587
563
  // ── IntersectionObserver ───────────────────────────────────────────────────
588
- //
589
- // The IO is used to eagerly observe placeholders so that when they enter
590
- // the viewport margin, we can queue them for Ezoic. However, the actual
591
- // Ezoic API calls (define/displayMore) happen in the batched flush.
592
- // The IO callback is mainly useful for re-triggering after NodeBB
593
- // virtualisation re-inserts posts.
594
564
 
595
565
  function getIO() {
596
566
  if (state.io) return state.io;
@@ -598,19 +568,9 @@
598
568
  state.io = new IntersectionObserver(entries => {
599
569
  for (const entry of entries) {
600
570
  if (!entry.isIntersecting) continue;
601
- const target = entry.target;
602
- if (target instanceof Element) state.io?.unobserve(target);
603
- const id = parseInt(target.getAttribute('data-ezoic-id'), 10);
604
- if (!id || id <= 0) continue;
605
- const st = state.phState.get(id);
606
- // Only enqueue if not yet processed by Ezoic
607
- if (st === 'new') {
608
- ezEnqueue(id);
609
- } else if (st === 'displayed') {
610
- // Already shown — check if the placeholder is actually filled.
611
- // If not (Ezoic had no ad), don't re-trigger — it won't help.
612
- // If yes, nothing to do.
613
- }
571
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
572
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
573
+ if (id > 0) enqueueShow(id);
614
574
  }
615
575
  }, {
616
576
  root: null,
@@ -623,16 +583,196 @@
623
583
 
624
584
  function observePlaceholder(id) {
625
585
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
626
- if (ph?.isConnected) { try { getIO()?.observe(ph); } catch (_) {} }
586
+ if (ph?.isConnected) {
587
+ try { getIO()?.observe(ph); } catch (_) {}
588
+ }
589
+ }
590
+
591
+ // ── Show queue ─────────────────────────────────────────────────────────────
592
+
593
+ function enqueueShow(id) {
594
+ 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
+ }
605
+ return;
606
+ }
607
+ state.phState.set(id, 'show-queued');
608
+ startShow(id);
609
+ }
610
+
611
+ function drainQueue() {
612
+ if (isBlocked()) return;
613
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
614
+ const id = state.pending.shift();
615
+ state.pendingSet.delete(id);
616
+ startShow(id);
617
+ }
618
+ }
619
+
620
+ function startShow(id) {
621
+ if (!id || isBlocked()) return;
622
+ state.inflight++;
623
+
624
+ let released = false;
625
+ const release = () => {
626
+ if (released) return;
627
+ released = true;
628
+ state.inflight = Math.max(0, state.inflight - 1);
629
+ drainQueue();
630
+ };
631
+ const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
632
+
633
+ requestAnimationFrame(() => {
634
+ try {
635
+ if (isBlocked()) { clearTimeout(timer); return release(); }
636
+
637
+ 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
+
650
+ 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');
659
+
660
+ window.ezstandalone = window.ezstandalone || {};
661
+ const ez = window.ezstandalone;
662
+
663
+ const doShow = () => {
664
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
665
+ try { ez.showAds(id); } catch (_) {}
666
+ if (wrap) scheduleUncollapseChecks(wrap);
667
+ scheduleEmptyCheck(id, t);
668
+ setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
669
+ };
670
+
671
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
672
+ } catch (_) {
673
+ clearTimeout(timer);
674
+ release();
675
+ }
676
+ });
677
+ }
678
+
679
+ 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]) {
682
+ setTimeout(() => {
683
+ try {
684
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
685
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
686
+ if (!wrap || !ph?.isConnected) return;
687
+ if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
688
+ if (clearEmptyIfFilled(wrap)) return;
689
+ // Don't collapse if a GPT slot exists (might still be loading)
690
+ if (ph.querySelector('[id^="div-gpt-ad"]')) return;
691
+ // Don't collapse if placeholder has meaningful height
692
+ if (ph.offsetHeight > 10) return;
693
+ wrap.classList.add('is-empty');
694
+ } catch (_) {}
695
+ }, delay);
696
+ }
697
+ }
698
+
699
+ // ── 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
+
705
+ function patchShowAds() {
706
+ const apply = () => {
707
+ try {
708
+ window.ezstandalone = window.ezstandalone || {};
709
+ const ez = window.ezstandalone;
710
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
711
+ window.__nbbEzPatched = true;
712
+
713
+ 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
+ ez.showAds = function (...args) {
737
+ // No-arg call = Ezoic page refresh — pass through unmodified
738
+ if (args.length === 0) {
739
+ return orig();
740
+ }
741
+ if (isBlocked()) return;
742
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
743
+ for (const v of ids) {
744
+ 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);
754
+ }
755
+ };
756
+ } catch (_) {}
757
+ };
758
+
759
+ apply();
760
+ if (!window.__nbbEzPatched) {
761
+ window.ezstandalone = window.ezstandalone || {};
762
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
763
+ }
627
764
  }
628
765
 
629
766
  // ── Core ───────────────────────────────────────────────────────────────────
630
767
 
631
768
  async function runCore() {
632
769
  if (isBlocked()) return 0;
770
+ try { gcDisconnectedWraps(); } catch (_) {}
771
+
633
772
  const cfg = await fetchConfig();
634
773
  if (!cfg || cfg.excluded) return 0;
635
774
  initPools(cfg);
775
+
636
776
  const kind = getKind();
637
777
  if (kind === 'other') return 0;
638
778
 
@@ -643,16 +783,22 @@
643
783
  };
644
784
 
645
785
  if (kind === 'topic') {
646
- return exec('ezoic-ad-message', getPosts,
647
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
786
+ return exec(
787
+ 'ezoic-ad-message', getPosts,
788
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
789
+ );
648
790
  }
649
791
  if (kind === 'categoryTopics') {
650
792
  pruneOrphansBetween();
651
- return exec('ezoic-ad-between', getTopics,
652
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
793
+ return exec(
794
+ 'ezoic-ad-between', getTopics,
795
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
796
+ );
653
797
  }
654
- return exec('ezoic-ad-categories', getCategories,
655
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
798
+ return exec(
799
+ 'ezoic-ad-categories', getCategories,
800
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
801
+ );
656
802
  }
657
803
 
658
804
  // ── Scheduler & burst ──────────────────────────────────────────────────────
@@ -681,30 +827,25 @@
681
827
  state.burstCount = 0;
682
828
  const step = () => {
683
829
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
684
- state.burstActive = false; return;
830
+ state.burstActive = false;
831
+ return;
685
832
  }
686
833
  state.burstCount++;
687
834
  scheduleRun(n => {
688
- if (!n && !state.ezBatch.size) { state.burstActive = false; return; }
835
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
689
836
  setTimeout(step, n > 0 ? 150 : 300);
690
837
  });
691
838
  };
692
839
  step();
693
840
  }
694
841
 
695
- // ── Cleanup on navigation ────────────────────────────────────────────────
696
- //
697
- // Only runs on actual page transitions (pageKey changes).
698
- // Uses destroyAll() to properly clean up Ezoic state before removing DOM.
842
+ // ── Cleanup on navigation ──────────────────────────────────────────────────
699
843
 
700
844
  function cleanup() {
701
845
  state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
702
846
 
703
- // Cancel pending Ezoic batch
704
- if (state.ezFlushTimer) { clearTimeout(state.ezFlushTimer); state.ezFlushTimer = null; }
705
- state.ezBatch.clear();
706
-
707
- // Tell Ezoic to destroy all its placeholders BEFORE we remove DOM
847
+ // Tell Ezoic to destroy all placeholders BEFORE we remove DOM elements.
848
+ // This prevents GPT slotDestroyed events and Ezoic 400 errors.
708
849
  try {
709
850
  const ez = window.ezstandalone;
710
851
  if (typeof ez?.destroyAll === 'function') {
@@ -715,26 +856,32 @@
715
856
  mutate(() => {
716
857
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
717
858
  });
718
- state.cfg = null;
719
- state.poolsReady = false;
720
- state.pools = { topics: [], posts: [], categories: [] };
721
- state.cursors = { topics: 0, posts: 0, categories: 0 };
859
+ state.cfg = null;
860
+ state.poolsReady = false;
861
+ state.pools = { topics: [], posts: [], categories: [] };
862
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
722
863
  state.mountedIds.clear();
723
864
  state.lastShow.clear();
724
865
  state.wrapByKey.clear();
725
866
  state.wrapsByClass.clear();
726
- state.kind = null;
867
+ state.kind = null;
727
868
  state.phState.clear();
869
+ state.inflight = 0;
870
+ state.pending = [];
871
+ state.pendingSet.clear();
728
872
  state.burstActive = false;
729
873
  state.runQueued = false;
874
+ state.firstShown = false;
730
875
  }
731
876
 
732
877
  // ── MutationObserver ───────────────────────────────────────────────────────
733
878
 
734
879
  function ensureDomObserver() {
735
880
  if (state.domObs) return;
881
+
736
882
  state.domObs = new MutationObserver(muts => {
737
883
  if (state.mutGuard > 0 || isBlocked()) return;
884
+
738
885
  let needsBurst = false;
739
886
  const kind = getKind();
740
887
  const relevantSels =
@@ -745,6 +892,20 @@
745
892
 
746
893
  for (const m of muts) {
747
894
  if (m.type !== 'childList') continue;
895
+
896
+ // Free IDs from wraps removed by NodeBB virtualization
897
+ for (const node of m.removedNodes) {
898
+ if (!(node instanceof Element)) continue;
899
+ 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
+ }
906
+ } catch (_) {}
907
+ }
908
+
748
909
  for (const node of m.addedNodes) {
749
910
  if (!(node instanceof Element)) continue;
750
911
 
@@ -781,54 +942,88 @@
781
942
  }
782
943
  if (needsBurst) break;
783
944
  }
945
+
784
946
  if (needsBurst) requestBurst();
785
947
  });
786
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
948
+
949
+ try {
950
+ state.domObs.observe(document.body, { childList: true, subtree: true });
951
+ } catch (_) {}
787
952
  }
788
953
 
789
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
954
+ // ── TCF / CMP Protection (3 layers) ────────────────────────────────────────
790
955
 
791
956
  function ensureTcfLocator() {
792
957
  if (!window.__tcfapi && !window.__cmp) return;
958
+
793
959
  const LOCATOR_ID = '__tcfapiLocator';
960
+
794
961
  const ensureInHead = () => {
795
962
  let existing = document.getElementById(LOCATOR_ID);
796
963
  if (existing) {
797
964
  if (existing.parentElement !== document.head) {
798
965
  try { document.head.appendChild(existing); } catch (_) {}
799
966
  }
800
- return;
967
+ return existing;
801
968
  }
802
969
  const f = document.createElement('iframe');
803
- f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
970
+ f.style.display = 'none';
971
+ f.id = f.name = LOCATOR_ID;
804
972
  try { document.head.appendChild(f); } catch (_) {
805
973
  (document.body || document.documentElement).appendChild(f);
806
974
  }
975
+ return f;
807
976
  };
977
+
808
978
  ensureInHead();
979
+
809
980
  if (!window.__nbbCmpGuarded) {
810
981
  window.__nbbCmpGuarded = true;
811
982
  if (typeof window.__tcfapi === 'function') {
812
- const orig = window.__tcfapi;
813
- window.__tcfapi = function (cmd, ver, cb, param) {
814
- try { return orig.call(this, cmd, ver, function (...a) { try { cb?.(...a); } catch (_) {} }, param); }
815
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.call(this, cmd, ver, cb, param); } catch (_) {} } }
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
+ }
816
995
  };
817
996
  }
818
997
  if (typeof window.__cmp === 'function') {
819
- const orig = window.__cmp;
820
- window.__cmp = function (...a) {
821
- try { return orig.apply(this, a); }
822
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.apply(this, a); } catch (_) {} } }
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
+ }
823
1007
  };
824
1008
  }
825
1009
  }
1010
+
826
1011
  if (!window.__nbbTcfObs) {
827
1012
  window.__nbbTcfObs = new MutationObserver(() => {
828
1013
  if (!document.getElementById(LOCATOR_ID)) ensureInHead();
829
1014
  });
830
- try { window.__nbbTcfObs.observe(document.body || document.documentElement, { childList: true, subtree: false }); } catch (_) {}
831
- try { if (document.head) window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false }); } catch (_) {}
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 (_) {}
832
1027
  }
833
1028
  }
834
1029
 
@@ -837,11 +1032,20 @@
837
1032
  function protectAriaHidden() {
838
1033
  if (window.__nbbAriaObs) return;
839
1034
  const remove = () => {
840
- try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {}
1035
+ try {
1036
+ if (document.body.getAttribute('aria-hidden') === 'true') {
1037
+ document.body.removeAttribute('aria-hidden');
1038
+ }
1039
+ } catch (_) {}
841
1040
  };
842
1041
  remove();
843
1042
  window.__nbbAriaObs = new MutationObserver(remove);
844
- try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
1043
+ try {
1044
+ window.__nbbAriaObs.observe(document.body, {
1045
+ attributes: true,
1046
+ attributeFilter: ['aria-hidden'],
1047
+ });
1048
+ } catch (_) {}
845
1049
  }
846
1050
 
847
1051
  // ── Console muting ─────────────────────────────────────────────────────────
@@ -849,21 +1053,24 @@
849
1053
  function muteConsole() {
850
1054
  if (window.__nbbEzMuted) return;
851
1055
  window.__nbbEzMuted = true;
1056
+
852
1057
  const PREFIXES = [
853
1058
  '[EzoicAds JS]: Placeholder Id',
1059
+ 'No valid placeholders for loadMore',
854
1060
  'cannot call refresh on the same page',
855
1061
  'no placeholders are currently defined in Refresh',
856
1062
  'Debugger iframe already exists',
857
1063
  '[CMP] Error in custom getTCData',
858
1064
  'vignette: no interstitial API',
1065
+ 'Ezoic JS-Enable should only ever',
859
1066
  ];
860
1067
  const PATTERNS = [
861
1068
  `with id ${PH_PREFIX}`,
862
1069
  'adsbygoogle.push() error: All',
863
1070
  'has already been defined',
864
- 'No valid placeholders for loadMore',
865
1071
  'bad response. Status',
866
1072
  ];
1073
+
867
1074
  for (const method of ['log', 'info', 'warn', 'error']) {
868
1075
  const orig = console[method];
869
1076
  if (typeof orig !== 'function') continue;
@@ -881,22 +1088,25 @@
881
1088
  // ── Network warmup ─────────────────────────────────────────────────────────
882
1089
 
883
1090
  let _networkWarmed = false;
1091
+
884
1092
  function warmNetwork() {
885
1093
  if (_networkWarmed) return;
886
1094
  _networkWarmed = true;
887
1095
  const head = document.head;
888
1096
  if (!head) return;
889
- for (const [rel, href, cors] of [
1097
+ const hints = [
890
1098
  ['preconnect', 'https://g.ezoic.net', true ],
891
1099
  ['preconnect', 'https://go.ezoic.net', true ],
892
1100
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
893
1101
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
894
1102
  ['dns-prefetch', 'https://g.ezoic.net', false],
895
1103
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
896
- ]) {
1104
+ ];
1105
+ for (const [rel, href, cors] of hints) {
897
1106
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
898
1107
  const link = document.createElement('link');
899
- link.rel = rel; link.href = href;
1108
+ link.rel = rel;
1109
+ link.href = href;
900
1110
  if (cors) link.crossOrigin = 'anonymous';
901
1111
  head.appendChild(link);
902
1112
  }
@@ -907,35 +1117,55 @@
907
1117
  function bindNodeBB() {
908
1118
  const $ = window.jQuery;
909
1119
  if (!$) return;
1120
+
910
1121
  $(window).off('.nbbEzoic');
1122
+
1123
+ // Only cleanup on actual page change, not same-page pagination
911
1124
  $(window).on('action:ajaxify.start.nbbEzoic', (ev, data) => {
912
- // Only cleanup if navigating to a different page
913
- // NodeBB fires ajaxify.start for pagination/sorting on the same page
914
1125
  const targetUrl = data?.url || data?.tpl_url || '';
915
1126
  const currentPath = location.pathname.replace(/^\//, '');
916
- // If the URL is basically the same (ignoring query/hash), skip cleanup
917
1127
  if (targetUrl && targetUrl.replace(/[?#].*$/, '') === currentPath.replace(/[?#].*$/, '')) {
918
- return;
1128
+ return; // Same page — skip cleanup
919
1129
  }
920
1130
  cleanup();
921
1131
  });
1132
+
922
1133
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
923
- state.pageKey = pageKey();
924
- state.kind = null;
1134
+ state.pageKey = pageKey();
1135
+ state.kind = null;
925
1136
  state.blockedUntil = 0;
926
- muteConsole(); ensureTcfLocator(); protectAriaHidden(); warmNetwork();
927
- getIO(); ensureDomObserver(); requestBurst();
1137
+
1138
+ muteConsole();
1139
+ ensureTcfLocator();
1140
+ protectAriaHidden();
1141
+ warmNetwork();
1142
+ patchShowAds();
1143
+ getIO();
1144
+ ensureDomObserver();
1145
+ requestBurst();
928
1146
  });
1147
+
929
1148
  const burstEvents = [
930
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
931
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1149
+ 'action:ajaxify.contentLoaded',
1150
+ 'action:posts.loaded',
1151
+ 'action:topics.loaded',
1152
+ 'action:categories.loaded',
1153
+ 'action:category.loaded',
1154
+ 'action:topic.loaded',
932
1155
  ].map(e => `${e}.nbbEzoic`).join(' ');
1156
+
933
1157
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1158
+
934
1159
  try {
935
1160
  require(['hooks'], hooks => {
936
1161
  if (typeof hooks?.on !== 'function') return;
937
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
938
- 'action:categories.loaded', 'action:topic.loaded']) {
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
+ ]) {
939
1169
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
940
1170
  }
941
1171
  });
@@ -947,7 +1177,10 @@
947
1177
  window.addEventListener('scroll', () => {
948
1178
  if (ticking) return;
949
1179
  ticking = true;
950
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1180
+ requestAnimationFrame(() => {
1181
+ ticking = false;
1182
+ requestBurst();
1183
+ });
951
1184
  }, { passive: true });
952
1185
  }
953
1186
 
@@ -958,6 +1191,7 @@
958
1191
  ensureTcfLocator();
959
1192
  protectAriaHidden();
960
1193
  warmNetwork();
1194
+ patchShowAds();
961
1195
  getIO();
962
1196
  ensureDomObserver();
963
1197
  bindNodeBB();