nodebb-plugin-ezoic-infinite 1.8.46 → 1.8.48

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