nodebb-plugin-ezoic-infinite 1.8.47 → 1.8.49

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