nodebb-plugin-ezoic-infinite 1.8.46 → 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 (3) hide show
  1. package/library.js +21 -5
  2. package/package.json +1 -1
  3. package/public/client.js +179 -239
package/library.js CHANGED
@@ -141,12 +141,12 @@ const HEAD_PRECONNECTS = [
141
141
  const EZOIC_SCRIPTS = [
142
142
  '<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>',
143
143
  '<script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>',
144
- '<script async src="//www.ezojs.com/ezoic/sa.min.js"></script>',
145
144
  '<script>',
145
+ 'window._ezaq = window._ezaq || {};',
146
146
  'window.ezstandalone = window.ezstandalone || {};',
147
- // Always reference through window to avoid ReferenceError in stricter contexts
148
- 'window.ezstandalone.cmd = window.ezstandalone.cmd || [];',
147
+ 'ezstandalone.cmd = ezstandalone.cmd || [];',
149
148
  '</script>',
149
+ '<script async src="//www.ezojs.com/ezoic/sa.min.js"></script>',
150
150
  ].join('\n');
151
151
 
152
152
  // ── Hooks ────────────────────────────────────────────────────────────────────
@@ -174,7 +174,24 @@ plugin.injectEzoicHead = async (data) => {
174
174
  const settings = await getSettings();
175
175
  const uid = data.req?.uid ?? 0;
176
176
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
177
- if (!excluded) {
177
+
178
+ if (excluded) {
179
+ // Even for excluded users, inject a minimal stub so that any script
180
+ // referencing ezstandalone doesn't throw a ReferenceError, plus
181
+ // the config with excluded=true so client.js bails early.
182
+ const cfg = buildClientConfig(settings, true);
183
+ const stub = [
184
+ '<script>',
185
+ 'window._ezaq = window._ezaq || {};',
186
+ 'window.ezstandalone = window.ezstandalone || {};',
187
+ 'ezstandalone.cmd = ezstandalone.cmd || [];',
188
+ '</script>',
189
+ ].join('\n');
190
+ data.templateData.customHTML =
191
+ stub + '\n' +
192
+ serializeInlineConfig(cfg) +
193
+ (data.templateData.customHTML || '');
194
+ } else {
178
195
  const cfg = buildClientConfig(settings, false);
179
196
  data.templateData.customHTML =
180
197
  HEAD_PRECONNECTS + '\n' +
@@ -183,7 +200,6 @@ plugin.injectEzoicHead = async (data) => {
183
200
  (data.templateData.customHTML || '');
184
201
  }
185
202
  } catch (err) {
186
- // Log but don't break rendering
187
203
  console.error('[ezoic-infinite] injectEzoicHead error:', err.message);
188
204
  }
189
205
  return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.46",
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,15 +1,12 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.0.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.2.0
3
3
  *
4
- * Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
5
- *
6
- * Key changes from v1.x:
7
- * - TCF locator: debounced recreation instead of per-mutation, prevents CMP postMessage null errors
8
- * - MutationObserver: scoped to content containers instead of document.body subtree
9
- * - Console muting: regex-free, prefix-based matching
10
- * - showAds batching: microtask-based flush instead of setTimeout
11
- * - Warm network: runs once per session, not per navigation
12
- * - State machine: clear lifecycle for placeholders (idle → observed → queued → shown → recycled)
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
13
10
  */
14
11
  (function nbbEzoicInfinite() {
15
12
  'use strict';
@@ -19,7 +16,6 @@
19
16
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
20
17
  const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
21
18
 
22
- // Data attributes
23
19
  const ATTR = {
24
20
  ANCHOR: 'data-ezoic-anchor',
25
21
  WRAPID: 'data-ezoic-wrapid',
@@ -27,21 +23,19 @@
27
23
  SHOWN: 'data-ezoic-shown',
28
24
  };
29
25
 
30
- // Timing
31
26
  const TIMING = {
32
- EMPTY_CHECK_MS: 20_000,
33
- MIN_PRUNE_AGE_MS: 8_000,
34
- SHOW_THROTTLE_MS: 900,
35
- BURST_COOLDOWN_MS: 200,
36
- BLOCK_DURATION_MS: 1_500,
37
- SHOW_TIMEOUT_MS: 7_000,
38
- SHOW_RELEASE_MS: 700,
39
- BATCH_FLUSH_MS: 80,
40
- RECYCLE_DELAY_MS: 450,
41
-
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,
42
37
  };
43
38
 
44
- // Limits
45
39
  const LIMITS = {
46
40
  MAX_INSERTS_RUN: 6,
47
41
  MAX_INFLIGHT: 4,
@@ -55,22 +49,20 @@
55
49
  MOBILE: '3500px 0px 3500px 0px',
56
50
  };
57
51
 
58
- // Selectors
59
52
  const SEL = {
60
53
  post: '[component="post"][data-pid]',
61
54
  topic: 'li[component="category/topic"]',
62
55
  category: 'li[component="categories/category"]',
63
56
  };
64
57
 
65
- // Kind configuration table — single source of truth per ad type
66
58
  const KIND = {
67
59
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
68
60
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
69
61
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
70
62
  };
71
63
 
72
- // Selector for detecting filled ad slots
73
64
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
65
+ const RECYCLE_MIN_AGE_MS = 5_000;
74
66
 
75
67
  // ── Utility ────────────────────────────────────────────────────────────────
76
68
 
@@ -103,45 +95,37 @@
103
95
  // ── State ──────────────────────────────────────────────────────────────────
104
96
 
105
97
  const state = {
106
- // Page context
107
98
  pageKey: null,
108
99
  kind: null,
109
100
  cfg: null,
110
101
 
111
- // Pools
112
102
  poolsReady: false,
113
103
  pools: { topics: [], posts: [], categories: [] },
114
104
  cursors: { topics: 0, posts: 0, categories: 0 },
115
105
 
116
- // Mounted placeholders
117
106
  mountedIds: new Set(),
118
107
  phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
108
  lastShow: new Map(), // id → timestamp
120
109
 
121
- // Wrap registry
122
- wrapByKey: new Map(), // anchorKey → wrap element
123
- wrapsByClass: new Map(), // kindClass → Set<wrap>
110
+ wrapByKey: new Map(),
111
+ wrapsByClass: new Map(),
124
112
 
125
- // Observers
126
113
  io: null,
127
114
  domObs: null,
128
115
 
129
- // Guards
130
- mutGuard: 0,
116
+ mutGuard: 0,
131
117
  blockedUntil: 0,
132
118
 
133
- // Show queue
134
119
  inflight: 0,
135
120
  pending: [],
136
121
  pendingSet: new Set(),
137
122
 
138
- // Scheduler
139
- runQueued: false,
140
- burstActive: false,
123
+ runQueued: false,
124
+ burstActive: false,
141
125
  burstDeadline: 0,
142
- burstCount: 0,
143
- lastBurstTs: 0,
144
- firstShown: false,
126
+ burstCount: 0,
127
+ lastBurstTs: 0,
128
+ firstShown: false,
145
129
  };
146
130
 
147
131
  const isBlocked = () => now() < state.blockedUntil;
@@ -155,7 +139,6 @@
155
139
 
156
140
  async function fetchConfig() {
157
141
  if (state.cfg) return state.cfg;
158
- // Prefer inline config injected by server (zero latency)
159
142
  try {
160
143
  const inline = window.__nbbEzoicCfg;
161
144
  if (inline && typeof inline === 'object') {
@@ -163,7 +146,6 @@
163
146
  return state.cfg;
164
147
  }
165
148
  } catch (_) {}
166
- // Fallback to API
167
149
  try {
168
150
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
169
151
  if (r.ok) state.cfg = await r.json();
@@ -195,7 +177,6 @@
195
177
  if (/^\/topic\//.test(p)) return 'topic';
196
178
  if (/^\/category\//.test(p)) return 'categoryTopics';
197
179
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
- // DOM fallback
199
180
  if (document.querySelector(SEL.category)) return 'categories';
200
181
  if (document.querySelector(SEL.post)) return 'topic';
201
182
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
@@ -215,7 +196,6 @@
215
196
  const el = all[i];
216
197
  if (!el.isConnected) continue;
217
198
  if (!el.querySelector('[component="post/content"]')) continue;
218
- // Skip nested quotes / parent posts
219
199
  const parent = el.parentElement?.closest(SEL.post);
220
200
  if (parent && parent !== el) continue;
221
201
  if (el.getAttribute('component') === 'post/parent') continue;
@@ -235,7 +215,6 @@
235
215
  const v = el.getAttribute(attr);
236
216
  if (v != null && v !== '') return v;
237
217
  }
238
- // Positional fallback
239
218
  const children = el.parentElement?.children;
240
219
  if (!children) return 'i0';
241
220
  for (let i = 0; i < children.length; i++) {
@@ -257,20 +236,12 @@
257
236
  return set;
258
237
  }
259
238
 
260
- // ── Garbage collection (NodeBB post virtualization safe) ─────────────────
261
- //
262
- // NodeBB can remove large portions of the DOM during infinite scrolling
263
- // (especially posts in topics). If a wrap is removed outside of our own
264
- // dropWrap(), we must free its placeholder id; otherwise the pool appears
265
- // exhausted and ads won't re-insert when the user scrolls back up.
239
+ // ── GC disconnected wraps (NodeBB virtualization) ──────────────────────────
266
240
 
267
241
  function gcDisconnectedWraps() {
268
- // 1) Clean wrapByKey
269
242
  for (const [key, w] of Array.from(state.wrapByKey.entries())) {
270
243
  if (!w?.isConnected) state.wrapByKey.delete(key);
271
244
  }
272
-
273
- // 2) Clean wrapsByClass sets and free ids
274
245
  for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
275
246
  for (const w of Array.from(set)) {
276
247
  if (w?.isConnected) continue;
@@ -286,15 +257,12 @@
286
257
  }
287
258
  if (!set.size) state.wrapsByClass.delete(klass);
288
259
  }
289
-
290
- // 3) Authoritative rebuild of mountedIds from the live DOM
291
260
  try {
292
261
  const live = new Set();
293
262
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
294
263
  const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
295
264
  if (id > 0) live.add(id);
296
265
  }
297
- // Drop any ids that are no longer live
298
266
  for (const id of Array.from(state.mountedIds)) {
299
267
  if (!live.has(id)) {
300
268
  state.mountedIds.delete(id);
@@ -307,26 +275,16 @@
307
275
 
308
276
  // ── Wrap lifecycle detection ───────────────────────────────────────────────
309
277
 
310
- /**
311
- * Check if a wrap element still has its corresponding anchor in the DOM.
312
- * Uses O(1) registry lookup first, then sibling scan, then global querySelector.
313
- */
314
278
  function wrapIsLive(wrap) {
315
279
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
316
280
  const key = wrap.getAttribute(ATTR.ANCHOR);
317
281
  if (!key) return false;
318
-
319
- // Fast path: registry match
320
282
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
321
-
322
- // Parse key
323
283
  const colonIdx = key.indexOf(':');
324
284
  const klass = key.slice(0, colonIdx);
325
285
  const anchorId = key.slice(colonIdx + 1);
326
286
  const cfg = KIND[klass];
327
287
  if (!cfg) return false;
328
-
329
- // Sibling scan (cheap for adjacent anchors)
330
288
  const parent = wrap.parentElement;
331
289
  if (parent) {
332
290
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
@@ -336,8 +294,6 @@
336
294
  }
337
295
  }
338
296
  }
339
-
340
- // Global fallback (expensive, rare)
341
297
  try {
342
298
  return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
343
299
  } catch (_) { return false; }
@@ -361,11 +317,8 @@
361
317
 
362
318
  function scheduleUncollapseChecks(wrap) {
363
319
  if (!wrap) return;
364
- const delays = [500, 1500, 3000, 7000, 15000];
365
- for (const ms of delays) {
366
- setTimeout(() => {
367
- try { clearEmptyIfFilled(wrap); } catch (_) {}
368
- }, ms);
320
+ for (const ms of [500, 1500, 3000, 7000, 15000]) {
321
+ setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
369
322
  }
370
323
  }
371
324
 
@@ -384,19 +337,18 @@
384
337
  }
385
338
 
386
339
  // ── Recycling ──────────────────────────────────────────────────────────────
340
+ //
341
+ // Per Ezoic docs: destroyPlaceholders(id) → remove old HTML →
342
+ // recreate fresh placeholder → showAds(id).
387
343
 
388
- /**
389
- * When pool is exhausted, recycle a wrap far above the viewport.
390
- * Sequence: destroy → delay → re-observe → enqueueShow
391
- */
392
344
  function recycleWrap(klass, targetEl, newKey) {
393
345
  const ez = window.ezstandalone;
394
346
  if (typeof ez?.destroyPlaceholders !== 'function' ||
395
- typeof ez?.define !== 'function' ||
396
- typeof ez?.displayMore !== 'function') return null;
347
+ typeof ez?.showAds !== 'function') return null;
397
348
 
398
349
  const vh = window.innerHeight || 800;
399
- const threshold = -vh;
350
+ const threshold = -(3 * vh);
351
+ const t = now();
400
352
  let bestEmpty = null, bestEmptyY = Infinity;
401
353
  let bestFull = null, bestFullY = Infinity;
402
354
 
@@ -405,6 +357,13 @@
405
357
 
406
358
  for (const wrap of wraps) {
407
359
  try {
360
+ // Skip young wraps (ad might still be loading)
361
+ 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
+
408
367
  const bottom = wrap.getBoundingClientRect().bottom;
409
368
  if (bottom > threshold) continue;
410
369
  if (!isFilled(wrap)) {
@@ -423,47 +382,48 @@
423
382
 
424
383
  const oldKey = best.getAttribute(ATTR.ANCHOR);
425
384
 
426
- // Unobserve before moving to prevent stale showAds
385
+ // Unobserve before moving
427
386
  try {
428
387
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
429
388
  if (ph) state.io?.unobserve(ph);
430
389
  } catch (_) {}
431
390
 
432
- // Move the wrap to new position
433
- mutate(() => {
434
- best.setAttribute(ATTR.ANCHOR, newKey);
435
- best.setAttribute(ATTR.CREATED, String(now()));
436
- best.setAttribute(ATTR.SHOWN, '0');
437
- best.classList.remove('is-empty');
438
- best.replaceChildren();
439
-
440
- const fresh = document.createElement('div');
441
- fresh.id = `${PH_PREFIX}${id}`;
442
- fresh.setAttribute('data-ezoic-id', String(id));
443
- fresh.style.minHeight = '1px';
444
- best.appendChild(fresh);
445
- targetEl.insertAdjacentElement('afterend', best);
446
- });
447
-
448
- // Update registry
449
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
450
- state.wrapByKey.set(newKey, best);
451
-
452
- // Ezoic recycle sequence
453
- const doDestroy = () => {
391
+ // Ezoic recycle: destroy new DOM → showAds
392
+ const doRecycle = () => {
454
393
  state.phState.set(id, 'destroyed');
455
- try { ez.destroyPlaceholders(id); } catch (_) {
456
- try { ez.destroyPlaceholders([id]); } catch (_) {}
457
- }
394
+ try { ez.destroyPlaceholders(id); } catch (_) {}
395
+
458
396
  setTimeout(() => {
459
- try { observePlaceholder(id); } catch (_) {}
460
- state.phState.set(id, 'new');
461
- try { enqueueShow(id); } catch (_) {}
397
+ // Recreate fresh placeholder DOM at new position
398
+ mutate(() => {
399
+ best.setAttribute(ATTR.ANCHOR, newKey);
400
+ best.setAttribute(ATTR.CREATED, String(now()));
401
+ best.setAttribute(ATTR.SHOWN, '0');
402
+ best.classList.remove('is-empty');
403
+ best.replaceChildren();
404
+
405
+ const fresh = document.createElement('div');
406
+ fresh.id = `${PH_PREFIX}${id}`;
407
+ fresh.setAttribute('data-ezoic-id', String(id));
408
+ fresh.style.minHeight = '1px';
409
+ best.appendChild(fresh);
410
+ targetEl.insertAdjacentElement('afterend', best);
411
+ });
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);
462
422
  }, TIMING.RECYCLE_DELAY_MS);
463
423
  };
464
424
 
465
425
  try {
466
- (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
426
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle();
467
427
  } catch (_) {}
468
428
 
469
429
  return { id, wrap: best };
@@ -492,7 +452,6 @@
492
452
  if (!el?.insertAdjacentElement) return null;
493
453
  if (findWrap(key)) return null;
494
454
  if (state.mountedIds.has(id)) return null;
495
- // Ensure no duplicate DOM element with same placeholder ID
496
455
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
497
456
  if (existing?.isConnected) return null;
498
457
 
@@ -509,17 +468,13 @@
509
468
  try {
510
469
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
511
470
  if (ph instanceof Element) state.io?.unobserve(ph);
512
-
513
471
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
514
472
  if (Number.isFinite(id)) {
515
473
  state.mountedIds.delete(id);
516
474
  state.phState.delete(id);
517
475
  }
518
-
519
476
  const key = w.getAttribute(ATTR.ANCHOR);
520
477
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
521
-
522
- // Find the kind class to unregister
523
478
  for (const cls of w.classList) {
524
479
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
525
480
  state.wrapsByClass.get(cls)?.delete(w);
@@ -531,9 +486,6 @@
531
486
  }
532
487
 
533
488
  // ── Prune (category topic lists only) ──────────────────────────────────────
534
- //
535
- // Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
536
- // NOT safe for posts: NodeBB virtualizes posts off-viewport.
537
489
 
538
490
  function pruneOrphansBetween() {
539
491
  const klass = 'ezoic-ad-between';
@@ -541,7 +493,6 @@
541
493
  const wraps = state.wrapsByClass.get(klass);
542
494
  if (!wraps?.size) return;
543
495
 
544
- // Build set of live anchor IDs
545
496
  const liveAnchors = new Set();
546
497
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
547
498
  const v = el.getAttribute(cfg.anchorAttr);
@@ -552,7 +503,6 @@
552
503
  for (const w of wraps) {
553
504
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
554
505
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
555
-
556
506
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
557
507
  const sid = key.slice(klass.length + 1);
558
508
  if (!sid || !liveAnchors.has(sid)) {
@@ -569,7 +519,6 @@
569
519
  const v = el.getAttribute(attr);
570
520
  if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
571
521
  }
572
- // Positional fallback
573
522
  const fullSel = KIND[klass]?.sel ?? '';
574
523
  let i = 0;
575
524
  for (const s of el.parentElement?.children ?? []) {
@@ -604,7 +553,7 @@
604
553
  }
605
554
  } else {
606
555
  const recycled = recycleWrap(klass, el, key);
607
- if (!recycled) break; // Pool truly exhausted
556
+ if (!recycled) break;
608
557
  inserted++;
609
558
  }
610
559
  }
@@ -728,25 +677,30 @@
728
677
  }
729
678
 
730
679
  function scheduleEmptyCheck(id, showTs) {
731
- setTimeout(() => {
732
- try {
733
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
734
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
735
- if (!wrap || !ph?.isConnected) return;
736
- // Skip if a newer show happened since
737
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
738
- if (clearEmptyIfFilled(wrap)) return;
739
- wrap.classList.add('is-empty');
740
- } catch (_) {}
741
- }, TIMING.EMPTY_CHECK_MS);
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
+ }
742
697
  }
743
698
 
744
699
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
745
700
  //
746
- // Intercepts ez.showAds() to:
747
- // - block calls during navigation transitions
748
- // - filter out disconnected placeholders
749
- // - batch calls for efficiency
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.
750
704
 
751
705
  function patchShowAds() {
752
706
  const apply = () => {
@@ -763,17 +717,14 @@
763
717
  const flush = () => {
764
718
  flushTimer = null;
765
719
  if (isBlocked() || !queue.size) return;
766
-
767
720
  const ids = Array.from(queue).sort((a, b) => a - b);
768
721
  queue.clear();
769
-
770
722
  const valid = ids.filter(id => {
771
723
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
772
724
  if (!ph?.isConnected) { state.phState.delete(id); return false; }
773
725
  if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
774
726
  return true;
775
727
  });
776
-
777
728
  for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
778
729
  const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
779
730
  try { orig(...chunk); } catch (_) {
@@ -783,6 +734,10 @@
783
734
  };
784
735
 
785
736
  ez.showAds = function (...args) {
737
+ // No-arg call = Ezoic page refresh — pass through unmodified
738
+ if (args.length === 0) {
739
+ return orig();
740
+ }
786
741
  if (isBlocked()) return;
787
742
  const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
788
743
  for (const v of ids) {
@@ -812,9 +767,6 @@
812
767
 
813
768
  async function runCore() {
814
769
  if (isBlocked()) return 0;
815
-
816
- // Keep internal pools in sync with NodeBB DOM virtualization/removals
817
- // so ads can re-insert correctly when users scroll back up.
818
770
  try { gcDisconnectedWraps(); } catch (_) {}
819
771
 
820
772
  const cfg = await fetchConfig();
@@ -836,7 +788,6 @@
836
788
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
837
789
  );
838
790
  }
839
-
840
791
  if (kind === 'categoryTopics') {
841
792
  pruneOrphansBetween();
842
793
  return exec(
@@ -844,7 +795,6 @@
844
795
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
845
796
  );
846
797
  }
847
-
848
798
  return exec(
849
799
  'ezoic-ad-categories', getCategories,
850
800
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
@@ -872,11 +822,9 @@
872
822
  state.lastBurstTs = t;
873
823
  state.pageKey = pageKey();
874
824
  state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
875
-
876
825
  if (state.burstActive) return;
877
826
  state.burstActive = true;
878
827
  state.burstCount = 0;
879
-
880
828
  const step = () => {
881
829
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
882
830
  state.burstActive = false;
@@ -895,10 +843,18 @@
895
843
 
896
844
  function cleanup() {
897
845
  state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
898
- mutate(() => {
899
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
900
- dropWrap(w);
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();
901
853
  }
854
+ } catch (_) {}
855
+
856
+ mutate(() => {
857
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
902
858
  });
903
859
  state.cfg = null;
904
860
  state.poolsReady = false;
@@ -919,8 +875,6 @@
919
875
  }
920
876
 
921
877
  // ── MutationObserver ───────────────────────────────────────────────────────
922
- //
923
- // Scoped to detect: (1) ad fill events in wraps, (2) new content items
924
878
 
925
879
  function ensureDomObserver() {
926
880
  if (state.domObs) return;
@@ -929,19 +883,17 @@
929
883
  if (state.mutGuard > 0 || isBlocked()) return;
930
884
 
931
885
  let needsBurst = false;
932
-
933
- // Determine relevant selectors for current page kind
934
886
  const kind = getKind();
935
887
  const relevantSels =
936
- kind === 'topic' ? [SEL.post] :
937
- kind === 'categoryTopics'? [SEL.topic] :
938
- kind === 'categories' ? [SEL.category] :
939
- [SEL.post, SEL.topic, SEL.category];
888
+ kind === 'topic' ? [SEL.post] :
889
+ kind === 'categoryTopics' ? [SEL.topic] :
890
+ kind === 'categories' ? [SEL.category] :
891
+ [SEL.post, SEL.topic, SEL.category];
940
892
 
941
893
  for (const m of muts) {
942
894
  if (m.type !== 'childList') continue;
943
895
 
944
- // If NodeBB removed wraps as part of virtualization, free ids immediately
896
+ // Free IDs from wraps removed by NodeBB virtualization
945
897
  for (const node of m.removedNodes) {
946
898
  if (!(node instanceof Element)) continue;
947
899
  try {
@@ -949,9 +901,7 @@
949
901
  dropWrap(node);
950
902
  } else {
951
903
  const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
952
- if (wraps?.length) {
953
- for (const w of wraps) dropWrap(w);
954
- }
904
+ if (wraps?.length) { for (const w of wraps) dropWrap(w); }
955
905
  }
956
906
  } catch (_) {}
957
907
  }
@@ -959,7 +909,7 @@
959
909
  for (const node of m.addedNodes) {
960
910
  if (!(node instanceof Element)) continue;
961
911
 
962
- // Check for ad fill events in wraps
912
+ // Ad fill detection
963
913
  try {
964
914
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
965
915
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -968,14 +918,24 @@
968
918
  }
969
919
  } catch (_) {}
970
920
 
971
- // Check for new content items (posts, topics, categories)
921
+ // Re-observe wraps re-inserted by NodeBB virtualization
922
+ try {
923
+ const wraps = node.classList?.contains(WRAP_CLASS)
924
+ ? [node]
925
+ : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
926
+ for (const wrap of wraps) {
927
+ 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 (_) {} }
931
+ }
932
+ } catch (_) {}
933
+
934
+ // New content detection
972
935
  if (!needsBurst) {
973
936
  for (const sel of relevantSels) {
974
937
  try {
975
- if (node.matches(sel) || node.querySelector(sel)) {
976
- needsBurst = true;
977
- break;
978
- }
938
+ if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
979
939
  } catch (_) {}
980
940
  }
981
941
  }
@@ -991,52 +951,25 @@
991
951
  } catch (_) {}
992
952
  }
993
953
 
994
- // ── TCF / CMP Protection ─────────────────────────────────────────────────
995
- //
996
- // Root cause of the CMP errors:
997
- // "Cannot read properties of null (reading 'postMessage')"
998
- // "Cannot set properties of null (setting 'addtlConsent')"
999
- //
1000
- // The CMP (Gatekeeper Consent) communicates via postMessage on the
1001
- // __tcfapiLocator iframe's contentWindow. During NodeBB ajaxify navigation,
1002
- // jQuery's html() or empty() on the content area can cascade and remove
1003
- // iframes from <body>. The CMP then calls getTCData on a stale reference
1004
- // where contentWindow is null.
1005
- //
1006
- // Strategy (3 layers):
1007
- //
1008
- // 1. PROTECT: Move the locator iframe into <head> where ajaxify never
1009
- // touches it. The TCF spec only requires the iframe to exist in the
1010
- // document with name="__tcfapiLocator" — it works from <head>.
1011
- //
1012
- // 2. GUARD: Wrap __tcfapi and __cmp with a safety layer that catches
1013
- // errors in the CMP's internal getTCData, preventing the uncaught
1014
- // TypeError from propagating.
1015
- //
1016
- // 3. RESTORE: MutationObserver on <body> childList (not subtree) to
1017
- // immediately re-create the locator if something still removes it.
954
+ // ── TCF / CMP Protection (3 layers) ────────────────────────────────────────
1018
955
 
1019
956
  function ensureTcfLocator() {
1020
957
  if (!window.__tcfapi && !window.__cmp) return;
1021
958
 
1022
959
  const LOCATOR_ID = '__tcfapiLocator';
1023
960
 
1024
- // Create or relocate the locator iframe into <head> for protection
1025
961
  const ensureInHead = () => {
1026
962
  let existing = document.getElementById(LOCATOR_ID);
1027
963
  if (existing) {
1028
- // If it's in <body>, move it to <head> where ajaxify can't reach it
1029
964
  if (existing.parentElement !== document.head) {
1030
965
  try { document.head.appendChild(existing); } catch (_) {}
1031
966
  }
1032
967
  return existing;
1033
968
  }
1034
- // Create fresh
1035
969
  const f = document.createElement('iframe');
1036
970
  f.style.display = 'none';
1037
971
  f.id = f.name = LOCATOR_ID;
1038
972
  try { document.head.appendChild(f); } catch (_) {
1039
- // Fallback to body if head insertion fails
1040
973
  (document.body || document.documentElement).appendChild(f);
1041
974
  }
1042
975
  return f;
@@ -1044,11 +977,8 @@
1044
977
 
1045
978
  ensureInHead();
1046
979
 
1047
- // Layer 2: Guard the CMP API calls against null contentWindow
1048
980
  if (!window.__nbbCmpGuarded) {
1049
981
  window.__nbbCmpGuarded = true;
1050
-
1051
- // Wrap __tcfapi
1052
982
  if (typeof window.__tcfapi === 'function') {
1053
983
  const origTcf = window.__tcfapi;
1054
984
  window.__tcfapi = function (cmd, version, cb, param) {
@@ -1057,23 +987,18 @@
1057
987
  try { cb?.(...args); } catch (_) {}
1058
988
  }, param);
1059
989
  } catch (e) {
1060
- // If the error is the null postMessage/addtlConsent, swallow it
1061
990
  if (e?.message?.includes('null')) {
1062
- // Re-ensure locator exists, then retry once
1063
991
  ensureInHead();
1064
992
  try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
1065
993
  }
1066
994
  }
1067
995
  };
1068
996
  }
1069
-
1070
- // Wrap __cmp (legacy CMP v1 API)
1071
997
  if (typeof window.__cmp === 'function') {
1072
998
  const origCmp = window.__cmp;
1073
999
  window.__cmp = function (...args) {
1074
- try {
1075
- return origCmp.apply(this, args);
1076
- } catch (e) {
1000
+ try { return origCmp.apply(this, args); }
1001
+ catch (e) {
1077
1002
  if (e?.message?.includes('null')) {
1078
1003
  ensureInHead();
1079
1004
  try { return origCmp.apply(this, args); } catch (_) {}
@@ -1083,37 +1008,47 @@
1083
1008
  }
1084
1009
  }
1085
1010
 
1086
- // Layer 3: MutationObserver to immediately restore if removed
1087
1011
  if (!window.__nbbTcfObs) {
1088
- window.__nbbTcfObs = new MutationObserver(muts => {
1089
- // Fast check: still in document?
1090
- if (document.getElementById(LOCATOR_ID)) return;
1091
- // Something removed it — restore immediately (no debounce)
1092
- ensureInHead();
1012
+ window.__nbbTcfObs = new MutationObserver(() => {
1013
+ if (!document.getElementById(LOCATOR_ID)) ensureInHead();
1093
1014
  });
1094
- // Observe body direct children only (the most likely removal point)
1095
1015
  try {
1096
1016
  window.__nbbTcfObs.observe(document.body || document.documentElement, {
1097
- childList: true,
1098
- subtree: false,
1017
+ childList: true, subtree: false,
1099
1018
  });
1100
1019
  } catch (_) {}
1101
- // Also observe <head> in case something cleans it
1102
1020
  try {
1103
1021
  if (document.head) {
1104
1022
  window.__nbbTcfObs.observe(document.head, {
1105
- childList: true,
1106
- subtree: false,
1023
+ childList: true, subtree: false,
1107
1024
  });
1108
1025
  }
1109
1026
  } catch (_) {}
1110
1027
  }
1111
1028
  }
1112
1029
 
1030
+ // ── aria-hidden protection ─────────────────────────────────────────────────
1031
+
1032
+ function protectAriaHidden() {
1033
+ 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 (_) {}
1049
+ }
1050
+
1113
1051
  // ── Console muting ─────────────────────────────────────────────────────────
1114
- //
1115
- // Mute noisy Ezoic warnings that are expected in infinite scroll context.
1116
- // Uses startsWith checks instead of includes for performance.
1117
1052
 
1118
1053
  function muteConsole() {
1119
1054
  if (window.__nbbEzMuted) return;
@@ -1127,8 +1062,14 @@
1127
1062
  'Debugger iframe already exists',
1128
1063
  '[CMP] Error in custom getTCData',
1129
1064
  'vignette: no interstitial API',
1065
+ 'Ezoic JS-Enable should only ever',
1066
+ ];
1067
+ const PATTERNS = [
1068
+ `with id ${PH_PREFIX}`,
1069
+ 'adsbygoogle.push() error: All',
1070
+ 'has already been defined',
1071
+ 'bad response. Status',
1130
1072
  ];
1131
- const PH_PATTERN = `with id ${PH_PREFIX}`;
1132
1073
 
1133
1074
  for (const method of ['log', 'info', 'warn', 'error']) {
1134
1075
  const orig = console[method];
@@ -1136,10 +1077,8 @@
1136
1077
  console[method] = function (...args) {
1137
1078
  if (typeof args[0] === 'string') {
1138
1079
  const msg = args[0];
1139
- for (const prefix of PREFIXES) {
1140
- if (msg.startsWith(prefix)) return;
1141
- }
1142
- if (msg.includes(PH_PATTERN)) return;
1080
+ for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
1081
+ for (const p of PATTERNS) { if (msg.includes(p)) return; }
1143
1082
  }
1144
1083
  return orig.apply(console, args);
1145
1084
  };
@@ -1147,17 +1086,14 @@
1147
1086
  }
1148
1087
 
1149
1088
  // ── Network warmup ─────────────────────────────────────────────────────────
1150
- // Run once per session — preconnect hints are in <head> via server-side injection
1151
1089
 
1152
1090
  let _networkWarmed = false;
1153
1091
 
1154
1092
  function warmNetwork() {
1155
1093
  if (_networkWarmed) return;
1156
1094
  _networkWarmed = true;
1157
-
1158
1095
  const head = document.head;
1159
1096
  if (!head) return;
1160
-
1161
1097
  const hints = [
1162
1098
  ['preconnect', 'https://g.ezoic.net', true ],
1163
1099
  ['preconnect', 'https://go.ezoic.net', true ],
@@ -1166,7 +1102,6 @@
1166
1102
  ['dns-prefetch', 'https://g.ezoic.net', false],
1167
1103
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1168
1104
  ];
1169
-
1170
1105
  for (const [rel, href, cors] of hints) {
1171
1106
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1172
1107
  const link = document.createElement('link');
@@ -1185,18 +1120,24 @@
1185
1120
 
1186
1121
  $(window).off('.nbbEzoic');
1187
1122
 
1188
- $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
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
+ });
1189
1132
 
1190
1133
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1191
1134
  state.pageKey = pageKey();
1192
1135
  state.kind = null;
1193
1136
  state.blockedUntil = 0;
1194
1137
 
1195
- // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1196
- try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1197
-
1198
1138
  muteConsole();
1199
1139
  ensureTcfLocator();
1140
+ protectAriaHidden();
1200
1141
  warmNetwork();
1201
1142
  patchShowAds();
1202
1143
  getIO();
@@ -1204,7 +1145,6 @@
1204
1145
  requestBurst();
1205
1146
  });
1206
1147
 
1207
- // Content-loaded events trigger burst
1208
1148
  const burstEvents = [
1209
1149
  'action:ajaxify.contentLoaded',
1210
1150
  'action:posts.loaded',
@@ -1216,7 +1156,6 @@
1216
1156
 
1217
1157
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1218
1158
 
1219
- // Also bind via NodeBB hooks module (for compatibility)
1220
1159
  try {
1221
1160
  require(['hooks'], hooks => {
1222
1161
  if (typeof hooks?.on !== 'function') return;
@@ -1250,6 +1189,7 @@
1250
1189
  state.pageKey = pageKey();
1251
1190
  muteConsole();
1252
1191
  ensureTcfLocator();
1192
+ protectAriaHidden();
1253
1193
  warmNetwork();
1254
1194
  patchShowAds();
1255
1195
  getIO();