nodebb-plugin-ezoic-infinite 1.8.45 → 1.8.46

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 +631 -337
package/public/client.js CHANGED
@@ -1,16 +1,15 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v3.0.1
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.0.0
3
3
  *
4
- * Ezoic API usage per official docs:
5
- * https://docs.ezoic.com/docs/ezoicads/dynamic-content/
4
+ * Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
6
5
  *
7
- * NEW PLACEHOLDERS: ez.cmd.push(() => ez.showAds(id1, id2, ...))
8
- * RECYCLE (reuse ID): ez.cmd.push(() => ez.destroyPlaceholders(id))
9
- * remove old HTML create fresh placeholder
10
- * ez.cmd.push(() => ez.showAds(id))
11
- *
12
- * phState per placeholder ID:
13
- * newdisplayed[destroyednew] (recycle loop)
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 observedqueuedshown → recycled)
14
13
  */
15
14
  (function nbbEzoicInfinite() {
16
15
  'use strict';
@@ -20,6 +19,7 @@
20
19
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
21
20
  const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
22
21
 
22
+ // Data attributes
23
23
  const ATTR = {
24
24
  ANCHOR: 'data-ezoic-anchor',
25
25
  WRAPID: 'data-ezoic-wrapid',
@@ -27,21 +27,25 @@
27
27
  SHOWN: 'data-ezoic-shown',
28
28
  };
29
29
 
30
+ // Timing
30
31
  const TIMING = {
31
32
  EMPTY_CHECK_MS: 20_000,
32
33
  MIN_PRUNE_AGE_MS: 8_000,
33
34
  SHOW_THROTTLE_MS: 900,
34
35
  BURST_COOLDOWN_MS: 200,
35
36
  BLOCK_DURATION_MS: 1_500,
36
- // Batch Ezoic API calls a bit more aggressively on SPA transitions.
37
- // This reduces early collector calls (e.g. samo.go) during ajaxify route swaps.
38
- BATCH_FLUSH_MS: 500,
39
- RECYCLE_DESTROY_MS: 300,
40
- RECYCLE_SHOW_MS: 300,
37
+ SHOW_TIMEOUT_MS: 7_000,
38
+ SHOW_RELEASE_MS: 700,
39
+ BATCH_FLUSH_MS: 80,
40
+ RECYCLE_DELAY_MS: 450,
41
+
41
42
  };
42
43
 
44
+ // Limits
43
45
  const LIMITS = {
44
46
  MAX_INSERTS_RUN: 6,
47
+ MAX_INFLIGHT: 4,
48
+ BATCH_SIZE: 3,
45
49
  MAX_BURST_STEPS: 8,
46
50
  BURST_WINDOW_MS: 2_000,
47
51
  };
@@ -51,40 +55,47 @@
51
55
  MOBILE: '3500px 0px 3500px 0px',
52
56
  };
53
57
 
58
+ // Selectors
54
59
  const SEL = {
55
60
  post: '[component="post"][data-pid]',
56
61
  topic: 'li[component="category/topic"]',
57
62
  category: 'li[component="categories/category"]',
58
63
  };
59
64
 
65
+ // Kind configuration table — single source of truth per ad type
60
66
  const KIND = {
61
67
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
62
68
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
63
69
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
64
70
  };
65
71
 
72
+ // Selector for detecting filled ad slots
66
73
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
67
74
 
68
- // Reduce "ad popping" from aggressive ID recycling.
69
- const RECYCLE_MIN_AGE_MS = 20_000;
70
-
71
75
  // ── Utility ────────────────────────────────────────────────────────────────
72
76
 
73
77
  const now = () => Date.now();
74
78
  const isMobile = () => window.innerWidth < 768;
75
79
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
76
80
 
77
- function isFilled(node) { return node?.querySelector?.(FILL_SEL) != null; }
81
+ function isFilled(node) {
82
+ return node?.querySelector?.(FILL_SEL) != null;
83
+ }
84
+
78
85
  function isPlaceholderUsed(ph) {
79
86
  if (!ph?.isConnected) return false;
80
87
  return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
81
88
  }
82
89
 
83
90
  function parseIds(raw) {
84
- const out = [], seen = new Set();
91
+ const out = [];
92
+ const seen = new Set();
85
93
  for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
86
94
  const n = parseInt(line, 10);
87
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
95
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
96
+ seen.add(n);
97
+ out.push(n);
98
+ }
88
99
  }
89
100
  return out;
90
101
  }
@@ -92,29 +103,45 @@
92
103
  // ── State ──────────────────────────────────────────────────────────────────
93
104
 
94
105
  const state = {
95
- pageKey: null, kind: null, cfg: null,
106
+ // Page context
107
+ pageKey: null,
108
+ kind: null,
109
+ cfg: null,
96
110
 
111
+ // Pools
97
112
  poolsReady: false,
98
- pools: { topics: [], posts: [], categories: [] },
99
- cursors: { topics: 0, posts: 0, categories: 0 },
113
+ pools: { topics: [], posts: [], categories: [] },
114
+ cursors: { topics: 0, posts: 0, categories: 0 },
115
+
116
+ // Mounted placeholders
117
+ mountedIds: new Set(),
118
+ phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
+ lastShow: new Map(), // id → timestamp
120
+
121
+ // Wrap registry
122
+ wrapByKey: new Map(), // anchorKey → wrap element
123
+ wrapsByClass: new Map(), // kindClass → Set<wrap>
100
124
 
101
- mountedIds: new Set(),
102
- // phState: 'new' | 'displayed' | 'destroyed'
103
- phState: new Map(),
104
- lastShow: new Map(),
105
- wrapByKey: new Map(),
106
- wrapsByClass: new Map(),
125
+ // Observers
126
+ io: null,
127
+ domObs: null,
107
128
 
108
- io: null, domObs: null,
109
- mutGuard: 0, blockedUntil: 0,
129
+ // Guards
130
+ mutGuard: 0,
131
+ blockedUntil: 0,
110
132
 
111
- // Ezoic batch queue: ids waiting for define+displayMore
112
- ezBatch: new Set(),
113
- ezFlushTimer: null,
133
+ // Show queue
134
+ inflight: 0,
135
+ pending: [],
136
+ pendingSet: new Set(),
114
137
 
115
- // Lifecycle
138
+ // Scheduler
116
139
  runQueued: false,
117
- burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstTs: 0,
140
+ burstActive: false,
141
+ burstDeadline: 0,
142
+ burstCount: 0,
143
+ lastBurstTs: 0,
144
+ firstShown: false,
118
145
  };
119
146
 
120
147
  const isBlocked = () => now() < state.blockedUntil;
@@ -128,10 +155,15 @@
128
155
 
129
156
  async function fetchConfig() {
130
157
  if (state.cfg) return state.cfg;
158
+ // Prefer inline config injected by server (zero latency)
131
159
  try {
132
160
  const inline = window.__nbbEzoicCfg;
133
- if (inline && typeof inline === 'object') { state.cfg = inline; return state.cfg; }
161
+ if (inline && typeof inline === 'object') {
162
+ state.cfg = inline;
163
+ return state.cfg;
164
+ }
134
165
  } catch (_) {}
166
+ // Fallback to API
135
167
  try {
136
168
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
137
169
  if (r.ok) state.cfg = await r.json();
@@ -163,22 +195,27 @@
163
195
  if (/^\/topic\//.test(p)) return 'topic';
164
196
  if (/^\/category\//.test(p)) return 'categoryTopics';
165
197
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
+ // DOM fallback
166
199
  if (document.querySelector(SEL.category)) return 'categories';
167
200
  if (document.querySelector(SEL.post)) return 'topic';
168
201
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
169
202
  return 'other';
170
203
  }
171
204
 
172
- function getKind() { return state.kind || (state.kind = detectKind()); }
205
+ function getKind() {
206
+ return state.kind || (state.kind = detectKind());
207
+ }
173
208
 
174
209
  // ── DOM queries ────────────────────────────────────────────────────────────
175
210
 
176
211
  function getPosts() {
177
- const all = document.querySelectorAll(SEL.post), out = [];
212
+ const all = document.querySelectorAll(SEL.post);
213
+ const out = [];
178
214
  for (let i = 0; i < all.length; i++) {
179
215
  const el = all[i];
180
216
  if (!el.isConnected) continue;
181
217
  if (!el.querySelector('[component="post/content"]')) continue;
218
+ // Skip nested quotes / parent posts
182
219
  const parent = el.parentElement?.closest(SEL.post);
183
220
  if (parent && parent !== el) continue;
184
221
  if (el.getAttribute('component') === 'post/parent') continue;
@@ -186,6 +223,7 @@
186
223
  }
187
224
  return out;
188
225
  }
226
+
189
227
  function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
190
228
  function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
191
229
 
@@ -193,10 +231,16 @@
193
231
 
194
232
  function stableId(klass, el) {
195
233
  const attr = KIND[klass]?.anchorAttr;
196
- if (attr) { const v = el.getAttribute(attr); if (v != null && v !== '') return v; }
234
+ if (attr) {
235
+ const v = el.getAttribute(attr);
236
+ if (v != null && v !== '') return v;
237
+ }
238
+ // Positional fallback
197
239
  const children = el.parentElement?.children;
198
240
  if (!children) return 'i0';
199
- for (let i = 0; i < children.length; i++) { if (children[i] === el) return `i${i}`; }
241
+ for (let i = 0; i < children.length; i++) {
242
+ if (children[i] === el) return `i${i}`;
243
+ }
200
244
  return 'i0';
201
245
  }
202
246
 
@@ -208,30 +252,95 @@
208
252
  }
209
253
 
210
254
  function getWrapSet(klass) {
211
- let s = state.wrapsByClass.get(klass);
212
- if (!s) { s = new Set(); state.wrapsByClass.set(klass, s); }
213
- return s;
255
+ let set = state.wrapsByClass.get(klass);
256
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
257
+ return set;
258
+ }
259
+
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.
266
+
267
+ function gcDisconnectedWraps() {
268
+ // 1) Clean wrapByKey
269
+ for (const [key, w] of Array.from(state.wrapByKey.entries())) {
270
+ if (!w?.isConnected) state.wrapByKey.delete(key);
271
+ }
272
+
273
+ // 2) Clean wrapsByClass sets and free ids
274
+ for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
275
+ for (const w of Array.from(set)) {
276
+ if (w?.isConnected) continue;
277
+ set.delete(w);
278
+ try {
279
+ const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
280
+ if (Number.isFinite(id)) {
281
+ state.mountedIds.delete(id);
282
+ state.phState.delete(id);
283
+ state.lastShow.delete(id);
284
+ }
285
+ } catch (_) {}
286
+ }
287
+ if (!set.size) state.wrapsByClass.delete(klass);
288
+ }
289
+
290
+ // 3) Authoritative rebuild of mountedIds from the live DOM
291
+ try {
292
+ const live = new Set();
293
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
294
+ const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
295
+ if (id > 0) live.add(id);
296
+ }
297
+ // Drop any ids that are no longer live
298
+ for (const id of Array.from(state.mountedIds)) {
299
+ if (!live.has(id)) {
300
+ state.mountedIds.delete(id);
301
+ state.phState.delete(id);
302
+ state.lastShow.delete(id);
303
+ }
304
+ }
305
+ } catch (_) {}
214
306
  }
215
307
 
216
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
308
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
217
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
+ */
218
314
  function wrapIsLive(wrap) {
219
315
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
220
316
  const key = wrap.getAttribute(ATTR.ANCHOR);
221
317
  if (!key) return false;
318
+
319
+ // Fast path: registry match
222
320
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
321
+
322
+ // Parse key
223
323
  const colonIdx = key.indexOf(':');
224
- const klass = key.slice(0, colonIdx), anchorId = key.slice(colonIdx + 1);
324
+ const klass = key.slice(0, colonIdx);
325
+ const anchorId = key.slice(colonIdx + 1);
225
326
  const cfg = KIND[klass];
226
327
  if (!cfg) return false;
328
+
329
+ // Sibling scan (cheap for adjacent anchors)
227
330
  const parent = wrap.parentElement;
228
331
  if (parent) {
229
332
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
230
333
  for (const sib of parent.children) {
231
- if (sib !== wrap) { try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {} }
334
+ if (sib !== wrap) {
335
+ try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
336
+ }
232
337
  }
233
338
  }
234
- try { return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true; } catch (_) { return false; }
339
+
340
+ // Global fallback (expensive, rare)
341
+ try {
342
+ return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
343
+ } catch (_) { return false; }
235
344
  }
236
345
 
237
346
  function adjacentWrap(el) {
@@ -246,18 +355,21 @@
246
355
  if (!ph || !isFilled(ph)) return false;
247
356
  wrap.classList.remove('is-empty');
248
357
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
249
- if (id > 0) state.phState.set(id, 'displayed');
358
+ if (id > 0) state.phState.set(id, 'shown');
250
359
  return true;
251
360
  }
252
361
 
253
362
  function scheduleUncollapseChecks(wrap) {
254
363
  if (!wrap) return;
255
- for (const ms of [500, 1500, 3000, 7000, 15000]) {
256
- 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);
257
369
  }
258
370
  }
259
371
 
260
- // ── Pool ───────────────────────────────────────────────────────────────────
372
+ // ── Pool management ────────────────────────────────────────────────────────
261
373
 
262
374
  function pickId(poolKey) {
263
375
  const pool = state.pools[poolKey];
@@ -271,122 +383,20 @@
271
383
  return null;
272
384
  }
273
385
 
274
- // ── Ezoic API layer ────────────────────────────────────────────────────────
275
- //
276
- // Correct Ezoic infinite scroll flow:
277
- // First batch: ez.cmd.push(() => { ez.define(...ids); ez.enable(); })
278
- // Next batches: ez.cmd.push(() => { ez.define(...ids); ez.displayMore(...ids); })
279
- // Recycle: ez.cmd.push(() => { ez.destroyPlaceholders(id); })
280
- // → wait → new DOM → ez.cmd.push(() => { ez.define(id); ez.displayMore(id); })
281
- //
282
- // We batch define+displayMore calls using ezBatch to avoid calling the
283
- // Ezoic API on every single placeholder insertion.
284
-
285
- function ezCmd(fn) {
286
- window.ezstandalone = window.ezstandalone || {};
287
- const ez = window.ezstandalone;
288
- if (Array.isArray(ez.cmd)) {
289
- ez.cmd.push(fn);
290
- } else {
291
- try { fn(); } catch (_) {}
292
- }
293
- }
294
-
295
- /**
296
- * Queue a placeholder ID for the next batched showAds call.
297
- */
298
- function ezEnqueue(id) {
299
- if (isBlocked()) return;
300
- state.ezBatch.add(id);
301
- if (!state.ezFlushTimer) {
302
- state.ezFlushTimer = setTimeout(ezFlush, TIMING.BATCH_FLUSH_MS);
303
- }
304
- }
386
+ // ── Recycling ──────────────────────────────────────────────────────────────
305
387
 
306
388
  /**
307
- * Flush: call showAds() for all queued IDs.
308
- *
309
- * Per Ezoic docs (https://docs.ezoic.com/docs/ezoicads/dynamic-content/):
310
- * - New placeholders: ez.showAds(id1, id2, ...)
311
- * - After destroy+recreate: ez.showAds(id) again
312
- * - No define(), no enable(), no displayMore()
389
+ * When pool is exhausted, recycle a wrap far above the viewport.
390
+ * Sequence: destroy → delay → re-observe → enqueueShow
313
391
  */
314
- function ezFlush() {
315
- state.ezFlushTimer = null;
316
- if (isBlocked() || !state.ezBatch.size) return;
317
-
318
- // Filter to only valid, connected, unfilled placeholders
319
- const ids = [];
320
- for (const id of state.ezBatch) {
321
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
322
- if (!ph?.isConnected) { state.phState.delete(id); continue; }
323
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'displayed'); continue; }
324
- ids.push(id);
325
- }
326
- state.ezBatch.clear();
327
- if (!ids.length) return;
328
-
329
- ezCmd(() => {
330
- const ez = window.ezstandalone;
331
- if (typeof ez?.showAds !== 'function') return;
332
-
333
- try { ez.showAds(...ids); } catch (_) {}
334
-
335
- // Mark as displayed and schedule fill checks
336
- for (const id of ids) {
337
- state.phState.set(id, 'displayed');
338
- state.lastShow.set(id, now());
339
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
340
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
341
- if (wrap) {
342
- wrap.setAttribute(ATTR.SHOWN, String(now()));
343
- scheduleUncollapseChecks(wrap);
344
- }
345
- scheduleEmptyCheck(id);
346
- }
347
- });
348
- }
349
-
350
- function scheduleEmptyCheck(id) {
351
- const showTs = now();
352
- // Check at 30s, then again at 60s — very conservative to avoid
353
- // collapsing slow-loading ads
354
- for (const delay of [30_000, 60_000]) {
355
- setTimeout(() => {
356
- try {
357
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
358
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
359
- if (!wrap || !ph?.isConnected) return;
360
- // Don't collapse if a newer show happened
361
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
362
- // If already collapsed or uncollapsed, skip
363
- if (clearEmptyIfFilled(wrap)) return;
364
- // Don't collapse if there's any GPT slot, even unfilled
365
- // (GPT may still be processing the ad request)
366
- if (ph.querySelector('[id^="div-gpt-ad"]')) return;
367
- // Don't collapse if there's any child with meaningful height
368
- if (ph.offsetHeight > 10) return;
369
- wrap.classList.add('is-empty');
370
- } catch (_) {}
371
- }, delay);
372
- }
373
- }
374
-
375
- // ── Recycling ──────────────────────────────────────────────────────────────
376
- //
377
- // Per Ezoic docs: when reusing placeholder IDs in infinite scroll,
378
- // call destroyPlaceholders(id) first, remove the old HTML, recreate
379
- // a fresh placeholder div, then call showAds(id) again.
380
-
381
392
  function recycleWrap(klass, targetEl, newKey) {
382
393
  const ez = window.ezstandalone;
383
394
  if (typeof ez?.destroyPlaceholders !== 'function' ||
384
- typeof ez?.showAds !== 'function') return null;
395
+ typeof ez?.define !== 'function' ||
396
+ typeof ez?.displayMore !== 'function') return null;
385
397
 
386
398
  const vh = window.innerHeight || 800;
387
- // Recycle only when the wrap is well above the viewport to avoid visible jumps.
388
- const threshold = -(6 * vh);
389
- const t = now();
399
+ const threshold = -vh;
390
400
  let bestEmpty = null, bestEmptyY = Infinity;
391
401
  let bestFull = null, bestFullY = Infinity;
392
402
 
@@ -395,11 +405,6 @@
395
405
 
396
406
  for (const wrap of wraps) {
397
407
  try {
398
- const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
399
- if (t - created < RECYCLE_MIN_AGE_MS) continue;
400
- const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
401
- const st = state.phState.get(wid);
402
- if (st === 'new') continue; // not yet shown, don't recycle
403
408
  const bottom = wrap.getBoundingClientRect().bottom;
404
409
  if (bottom > threshold) continue;
405
410
  if (!isFilled(wrap)) {
@@ -418,48 +423,48 @@
418
423
 
419
424
  const oldKey = best.getAttribute(ATTR.ANCHOR);
420
425
 
421
- // Unobserve before moving
426
+ // Unobserve before moving to prevent stale showAds
422
427
  try {
423
428
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
424
429
  if (ph) state.io?.unobserve(ph);
425
430
  } catch (_) {}
426
431
 
427
- state.phState.set(id, 'destroyed');
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
+ });
428
447
 
429
- // Step 1: destroyPlaceholders(id)
430
- // Step 2: remove old HTML, create fresh placeholder at new position
431
- // Step 3: showAds(id)
432
- ezCmd(() => {
433
- try { ez.destroyPlaceholders(id); } catch (_) {}
448
+ // Update registry
449
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
450
+ state.wrapByKey.set(newKey, best);
434
451
 
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
+ }
435
458
  setTimeout(() => {
436
- // Recreate placeholder DOM
437
- mutate(() => {
438
- best.setAttribute(ATTR.ANCHOR, newKey);
439
- best.setAttribute(ATTR.CREATED, String(now()));
440
- best.setAttribute(ATTR.SHOWN, '0');
441
- best.classList.remove('is-empty');
442
- best.replaceChildren();
443
-
444
- const fresh = document.createElement('div');
445
- fresh.id = `${PH_PREFIX}${id}`;
446
- fresh.setAttribute('data-ezoic-id', String(id));
447
- fresh.style.minHeight = '1px';
448
- best.appendChild(fresh);
449
- targetEl.insertAdjacentElement('afterend', best);
450
- });
451
-
452
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
453
- state.wrapByKey.set(newKey, best);
459
+ try { observePlaceholder(id); } catch (_) {}
460
+ state.phState.set(id, 'new');
461
+ try { enqueueShow(id); } catch (_) {}
462
+ }, TIMING.RECYCLE_DELAY_MS);
463
+ };
454
464
 
455
- // showAds again after DOM is ready
456
- setTimeout(() => {
457
- state.phState.set(id, 'new');
458
- observePlaceholder(id);
459
- ezEnqueue(id);
460
- }, TIMING.RECYCLE_SHOW_MS);
461
- }, TIMING.RECYCLE_DESTROY_MS);
462
- });
465
+ try {
466
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
467
+ } catch (_) {}
463
468
 
464
469
  return { id, wrap: best };
465
470
  }
@@ -474,6 +479,7 @@
474
479
  w.setAttribute(ATTR.CREATED, String(now()));
475
480
  w.setAttribute(ATTR.SHOWN, '0');
476
481
  w.style.cssText = 'width:100%;display:block';
482
+
477
483
  const ph = document.createElement('div');
478
484
  ph.id = `${PH_PREFIX}${id}`;
479
485
  ph.setAttribute('data-ezoic-id', String(id));
@@ -486,8 +492,10 @@
486
492
  if (!el?.insertAdjacentElement) return null;
487
493
  if (findWrap(key)) return null;
488
494
  if (state.mountedIds.has(id)) return null;
495
+ // Ensure no duplicate DOM element with same placeholder ID
489
496
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
490
497
  if (existing?.isConnected) return null;
498
+
491
499
  const w = makeWrap(id, klass, key);
492
500
  mutate(() => el.insertAdjacentElement('afterend', w));
493
501
  state.mountedIds.add(id);
@@ -501,13 +509,21 @@
501
509
  try {
502
510
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
503
511
  if (ph instanceof Element) state.io?.unobserve(ph);
512
+
504
513
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
505
- if (Number.isFinite(id)) { state.mountedIds.delete(id); state.phState.delete(id); }
514
+ if (Number.isFinite(id)) {
515
+ state.mountedIds.delete(id);
516
+ state.phState.delete(id);
517
+ }
518
+
506
519
  const key = w.getAttribute(ATTR.ANCHOR);
507
520
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
521
+
522
+ // Find the kind class to unregister
508
523
  for (const cls of w.classList) {
509
524
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
510
- state.wrapsByClass.get(cls)?.delete(w); break;
525
+ state.wrapsByClass.get(cls)?.delete(w);
526
+ break;
511
527
  }
512
528
  }
513
529
  w.remove();
@@ -515,24 +531,33 @@
515
531
  }
516
532
 
517
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.
518
537
 
519
538
  function pruneOrphansBetween() {
520
539
  const klass = 'ezoic-ad-between';
521
540
  const cfg = KIND[klass];
522
541
  const wraps = state.wrapsByClass.get(klass);
523
542
  if (!wraps?.size) return;
543
+
544
+ // Build set of live anchor IDs
524
545
  const liveAnchors = new Set();
525
546
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
526
547
  const v = el.getAttribute(cfg.anchorAttr);
527
548
  if (v) liveAnchors.add(v);
528
549
  }
550
+
529
551
  const t = now();
530
552
  for (const w of wraps) {
531
553
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
532
554
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
555
+
533
556
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
534
557
  const sid = key.slice(klass.length + 1);
535
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
558
+ if (!sid || !liveAnchors.has(sid)) {
559
+ mutate(() => dropWrap(w));
560
+ }
536
561
  }
537
562
  }
538
563
 
@@ -544,6 +569,7 @@
544
569
  const v = el.getAttribute(attr);
545
570
  if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
546
571
  }
572
+ // Positional fallback
547
573
  const fullSel = KIND[klass]?.sel ?? '';
548
574
  let i = 0;
549
575
  for (const s of el.parentElement?.children ?? []) {
@@ -560,9 +586,11 @@
560
586
  for (const el of items) {
561
587
  if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
562
588
  if (!el?.isConnected) continue;
589
+
563
590
  const ord = ordinal(klass, el);
564
591
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
565
592
  if (adjacentWrap(el)) continue;
593
+
566
594
  const key = anchorKey(klass, el);
567
595
  if (findWrap(key)) continue;
568
596
 
@@ -571,13 +599,12 @@
571
599
  const w = insertAfter(el, id, klass, key);
572
600
  if (w) {
573
601
  observePlaceholder(id);
574
- // Queue for batched define+enable/displayMore
575
- ezEnqueue(id);
602
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
576
603
  inserted++;
577
604
  }
578
605
  } else {
579
606
  const recycled = recycleWrap(klass, el, key);
580
- if (!recycled) break;
607
+ if (!recycled) break; // Pool truly exhausted
581
608
  inserted++;
582
609
  }
583
610
  }
@@ -585,12 +612,6 @@
585
612
  }
586
613
 
587
614
  // ── IntersectionObserver ───────────────────────────────────────────────────
588
- //
589
- // The IO is used to eagerly observe placeholders so that when they enter
590
- // the viewport margin, we can queue them for Ezoic. However, the actual
591
- // Ezoic API calls (define/displayMore) happen in the batched flush.
592
- // The IO callback is mainly useful for re-triggering after NodeBB
593
- // virtualisation re-inserts posts.
594
615
 
595
616
  function getIO() {
596
617
  if (state.io) return state.io;
@@ -598,19 +619,9 @@
598
619
  state.io = new IntersectionObserver(entries => {
599
620
  for (const entry of entries) {
600
621
  if (!entry.isIntersecting) continue;
601
- const target = entry.target;
602
- if (target instanceof Element) state.io?.unobserve(target);
603
- const id = parseInt(target.getAttribute('data-ezoic-id'), 10);
604
- if (!id || id <= 0) continue;
605
- const st = state.phState.get(id);
606
- // Only enqueue if not yet processed by Ezoic
607
- if (st === 'new') {
608
- ezEnqueue(id);
609
- } else if (st === 'displayed') {
610
- // Already shown — check if the placeholder is actually filled.
611
- // If not (Ezoic had no ad), don't re-trigger — it won't help.
612
- // If yes, nothing to do.
613
- }
622
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
623
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
624
+ if (id > 0) enqueueShow(id);
614
625
  }
615
626
  }, {
616
627
  root: null,
@@ -623,16 +634,193 @@
623
634
 
624
635
  function observePlaceholder(id) {
625
636
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
626
- if (ph?.isConnected) { try { getIO()?.observe(ph); } catch (_) {} }
637
+ if (ph?.isConnected) {
638
+ try { getIO()?.observe(ph); } catch (_) {}
639
+ }
640
+ }
641
+
642
+ // ── Show queue ─────────────────────────────────────────────────────────────
643
+
644
+ function enqueueShow(id) {
645
+ if (!id || isBlocked()) return;
646
+ const st = state.phState.get(id);
647
+ if (st === 'show-queued' || st === 'shown') return;
648
+ if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
649
+
650
+ if (state.inflight >= LIMITS.MAX_INFLIGHT) {
651
+ if (!state.pendingSet.has(id)) {
652
+ state.pending.push(id);
653
+ state.pendingSet.add(id);
654
+ state.phState.set(id, 'show-queued');
655
+ }
656
+ return;
657
+ }
658
+ state.phState.set(id, 'show-queued');
659
+ startShow(id);
660
+ }
661
+
662
+ function drainQueue() {
663
+ if (isBlocked()) return;
664
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
665
+ const id = state.pending.shift();
666
+ state.pendingSet.delete(id);
667
+ startShow(id);
668
+ }
669
+ }
670
+
671
+ function startShow(id) {
672
+ if (!id || isBlocked()) return;
673
+ state.inflight++;
674
+
675
+ let released = false;
676
+ const release = () => {
677
+ if (released) return;
678
+ released = true;
679
+ state.inflight = Math.max(0, state.inflight - 1);
680
+ drainQueue();
681
+ };
682
+ const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
683
+
684
+ requestAnimationFrame(() => {
685
+ try {
686
+ if (isBlocked()) { clearTimeout(timer); return release(); }
687
+
688
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
689
+ if (!ph?.isConnected) {
690
+ state.phState.delete(id);
691
+ clearTimeout(timer);
692
+ return release();
693
+ }
694
+
695
+ if (isFilled(ph) || isPlaceholderUsed(ph)) {
696
+ state.phState.set(id, 'shown');
697
+ clearTimeout(timer);
698
+ return release();
699
+ }
700
+
701
+ const t = now();
702
+ if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
703
+ clearTimeout(timer);
704
+ return release();
705
+ }
706
+ state.lastShow.set(id, t);
707
+
708
+ try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
709
+ state.phState.set(id, 'shown');
710
+
711
+ window.ezstandalone = window.ezstandalone || {};
712
+ const ez = window.ezstandalone;
713
+
714
+ const doShow = () => {
715
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
716
+ try { ez.showAds(id); } catch (_) {}
717
+ if (wrap) scheduleUncollapseChecks(wrap);
718
+ scheduleEmptyCheck(id, t);
719
+ setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
720
+ };
721
+
722
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
723
+ } catch (_) {
724
+ clearTimeout(timer);
725
+ release();
726
+ }
727
+ });
728
+ }
729
+
730
+ 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);
742
+ }
743
+
744
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
745
+ //
746
+ // Intercepts ez.showAds() to:
747
+ // - block calls during navigation transitions
748
+ // - filter out disconnected placeholders
749
+ // - batch calls for efficiency
750
+
751
+ function patchShowAds() {
752
+ const apply = () => {
753
+ try {
754
+ window.ezstandalone = window.ezstandalone || {};
755
+ const ez = window.ezstandalone;
756
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
757
+ window.__nbbEzPatched = true;
758
+
759
+ 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
+ ez.showAds = function (...args) {
786
+ if (isBlocked()) return;
787
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
788
+ for (const v of ids) {
789
+ 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);
799
+ }
800
+ };
801
+ } catch (_) {}
802
+ };
803
+
804
+ apply();
805
+ if (!window.__nbbEzPatched) {
806
+ window.ezstandalone = window.ezstandalone || {};
807
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
808
+ }
627
809
  }
628
810
 
629
811
  // ── Core ───────────────────────────────────────────────────────────────────
630
812
 
631
813
  async function runCore() {
632
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.
818
+ try { gcDisconnectedWraps(); } catch (_) {}
819
+
633
820
  const cfg = await fetchConfig();
634
821
  if (!cfg || cfg.excluded) return 0;
635
822
  initPools(cfg);
823
+
636
824
  const kind = getKind();
637
825
  if (kind === 'other') return 0;
638
826
 
@@ -643,16 +831,24 @@
643
831
  };
644
832
 
645
833
  if (kind === 'topic') {
646
- return exec('ezoic-ad-message', getPosts,
647
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
834
+ return exec(
835
+ 'ezoic-ad-message', getPosts,
836
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
837
+ );
648
838
  }
839
+
649
840
  if (kind === 'categoryTopics') {
650
841
  pruneOrphansBetween();
651
- return exec('ezoic-ad-between', getTopics,
652
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
842
+ return exec(
843
+ 'ezoic-ad-between', getTopics,
844
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
845
+ );
653
846
  }
654
- return exec('ezoic-ad-categories', getCategories,
655
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
847
+
848
+ return exec(
849
+ 'ezoic-ad-categories', getCategories,
850
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
851
+ );
656
852
  }
657
853
 
658
854
  // ── Scheduler & burst ──────────────────────────────────────────────────────
@@ -676,79 +872,94 @@
676
872
  state.lastBurstTs = t;
677
873
  state.pageKey = pageKey();
678
874
  state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
875
+
679
876
  if (state.burstActive) return;
680
877
  state.burstActive = true;
681
878
  state.burstCount = 0;
879
+
682
880
  const step = () => {
683
881
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
684
- state.burstActive = false; return;
882
+ state.burstActive = false;
883
+ return;
685
884
  }
686
885
  state.burstCount++;
687
886
  scheduleRun(n => {
688
- if (!n && !state.ezBatch.size) { state.burstActive = false; return; }
887
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
689
888
  setTimeout(step, n > 0 ? 150 : 300);
690
889
  });
691
890
  };
692
891
  step();
693
892
  }
694
893
 
695
- // ── Cleanup on navigation ────────────────────────────────────────────────
696
- //
697
- // Only runs on actual page transitions (pageKey changes).
698
- // Uses destroyAll() to properly clean up Ezoic state before removing DOM.
894
+ // ── Cleanup on navigation ──────────────────────────────────────────────────
699
895
 
700
896
  function cleanup() {
701
897
  state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
702
-
703
- // Cancel pending Ezoic batch
704
- if (state.ezFlushTimer) { clearTimeout(state.ezFlushTimer); state.ezFlushTimer = null; }
705
- state.ezBatch.clear();
706
-
707
- // Tell Ezoic to destroy all its placeholders BEFORE we remove DOM
708
- try {
709
- const ez = window.ezstandalone;
710
- if (typeof ez?.destroyAll === 'function') {
711
- ez.destroyAll();
712
- }
713
- } catch (_) {}
714
-
715
898
  mutate(() => {
716
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
899
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
900
+ dropWrap(w);
901
+ }
717
902
  });
718
- state.cfg = null;
719
- state.poolsReady = false;
720
- state.pools = { topics: [], posts: [], categories: [] };
721
- state.cursors = { topics: 0, posts: 0, categories: 0 };
903
+ state.cfg = null;
904
+ state.poolsReady = false;
905
+ state.pools = { topics: [], posts: [], categories: [] };
906
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
722
907
  state.mountedIds.clear();
723
908
  state.lastShow.clear();
724
909
  state.wrapByKey.clear();
725
910
  state.wrapsByClass.clear();
726
- state.kind = null;
911
+ state.kind = null;
727
912
  state.phState.clear();
913
+ state.inflight = 0;
914
+ state.pending = [];
915
+ state.pendingSet.clear();
728
916
  state.burstActive = false;
729
917
  state.runQueued = false;
918
+ state.firstShown = false;
730
919
  }
731
920
 
732
921
  // ── MutationObserver ───────────────────────────────────────────────────────
922
+ //
923
+ // Scoped to detect: (1) ad fill events in wraps, (2) new content items
733
924
 
734
925
  function ensureDomObserver() {
735
926
  if (state.domObs) return;
927
+
736
928
  state.domObs = new MutationObserver(muts => {
737
929
  if (state.mutGuard > 0 || isBlocked()) return;
930
+
738
931
  let needsBurst = false;
932
+
933
+ // Determine relevant selectors for current page kind
739
934
  const kind = getKind();
740
935
  const relevantSels =
741
- kind === 'topic' ? [SEL.post] :
742
- kind === 'categoryTopics' ? [SEL.topic] :
743
- kind === 'categories' ? [SEL.category] :
744
- [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];
745
940
 
746
941
  for (const m of muts) {
747
942
  if (m.type !== 'childList') continue;
943
+
944
+ // If NodeBB removed wraps as part of virtualization, free ids immediately
945
+ for (const node of m.removedNodes) {
946
+ if (!(node instanceof Element)) continue;
947
+ try {
948
+ if (node.classList?.contains(WRAP_CLASS)) {
949
+ dropWrap(node);
950
+ } else {
951
+ const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
952
+ if (wraps?.length) {
953
+ for (const w of wraps) dropWrap(w);
954
+ }
955
+ }
956
+ } catch (_) {}
957
+ }
958
+
748
959
  for (const node of m.addedNodes) {
749
960
  if (!(node instanceof Element)) continue;
750
961
 
751
- // Ad fill detection
962
+ // Check for ad fill events in wraps
752
963
  try {
753
964
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
754
965
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -757,121 +968,178 @@
757
968
  }
758
969
  } catch (_) {}
759
970
 
760
- // Re-observe wraps re-inserted by NodeBB virtualization
761
- try {
762
- const wraps = node.classList?.contains(WRAP_CLASS)
763
- ? [node]
764
- : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
765
- for (const wrap of wraps) {
766
- const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
767
- if (!id || id <= 0) continue;
768
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
769
- if (ph) { try { getIO()?.observe(ph); } catch (_) {} }
770
- }
771
- } catch (_) {}
772
-
773
- // New content detection
971
+ // Check for new content items (posts, topics, categories)
774
972
  if (!needsBurst) {
775
973
  for (const sel of relevantSels) {
776
974
  try {
777
- if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
975
+ if (node.matches(sel) || node.querySelector(sel)) {
976
+ needsBurst = true;
977
+ break;
978
+ }
778
979
  } catch (_) {}
779
980
  }
780
981
  }
781
982
  }
782
983
  if (needsBurst) break;
783
984
  }
985
+
784
986
  if (needsBurst) requestBurst();
785
987
  });
786
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
988
+
989
+ try {
990
+ state.domObs.observe(document.body, { childList: true, subtree: true });
991
+ } catch (_) {}
787
992
  }
788
993
 
789
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
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.
790
1018
 
791
1019
  function ensureTcfLocator() {
792
1020
  if (!window.__tcfapi && !window.__cmp) return;
1021
+
793
1022
  const LOCATOR_ID = '__tcfapiLocator';
1023
+
1024
+ // Create or relocate the locator iframe into <head> for protection
794
1025
  const ensureInHead = () => {
795
1026
  let existing = document.getElementById(LOCATOR_ID);
796
1027
  if (existing) {
1028
+ // If it's in <body>, move it to <head> where ajaxify can't reach it
797
1029
  if (existing.parentElement !== document.head) {
798
1030
  try { document.head.appendChild(existing); } catch (_) {}
799
1031
  }
800
- return;
1032
+ return existing;
801
1033
  }
1034
+ // Create fresh
802
1035
  const f = document.createElement('iframe');
803
- f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
1036
+ f.style.display = 'none';
1037
+ f.id = f.name = LOCATOR_ID;
804
1038
  try { document.head.appendChild(f); } catch (_) {
1039
+ // Fallback to body if head insertion fails
805
1040
  (document.body || document.documentElement).appendChild(f);
806
1041
  }
1042
+ return f;
807
1043
  };
1044
+
808
1045
  ensureInHead();
1046
+
1047
+ // Layer 2: Guard the CMP API calls against null contentWindow
809
1048
  if (!window.__nbbCmpGuarded) {
810
1049
  window.__nbbCmpGuarded = true;
1050
+
1051
+ // Wrap __tcfapi
811
1052
  if (typeof window.__tcfapi === 'function') {
812
- const orig = window.__tcfapi;
813
- window.__tcfapi = function (cmd, ver, cb, param) {
814
- try { return orig.call(this, cmd, ver, function (...a) { try { cb?.(...a); } catch (_) {} }, param); }
815
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.call(this, cmd, ver, cb, param); } catch (_) {} } }
1053
+ const origTcf = window.__tcfapi;
1054
+ window.__tcfapi = function (cmd, version, cb, param) {
1055
+ try {
1056
+ return origTcf.call(this, cmd, version, function (...args) {
1057
+ try { cb?.(...args); } catch (_) {}
1058
+ }, param);
1059
+ } catch (e) {
1060
+ // If the error is the null postMessage/addtlConsent, swallow it
1061
+ if (e?.message?.includes('null')) {
1062
+ // Re-ensure locator exists, then retry once
1063
+ ensureInHead();
1064
+ try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
1065
+ }
1066
+ }
816
1067
  };
817
1068
  }
1069
+
1070
+ // Wrap __cmp (legacy CMP v1 API)
818
1071
  if (typeof window.__cmp === 'function') {
819
- const orig = window.__cmp;
820
- window.__cmp = function (...a) {
821
- try { return orig.apply(this, a); }
822
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.apply(this, a); } catch (_) {} } }
1072
+ const origCmp = window.__cmp;
1073
+ window.__cmp = function (...args) {
1074
+ try {
1075
+ return origCmp.apply(this, args);
1076
+ } catch (e) {
1077
+ if (e?.message?.includes('null')) {
1078
+ ensureInHead();
1079
+ try { return origCmp.apply(this, args); } catch (_) {}
1080
+ }
1081
+ }
823
1082
  };
824
1083
  }
825
1084
  }
1085
+
1086
+ // Layer 3: MutationObserver to immediately restore if removed
826
1087
  if (!window.__nbbTcfObs) {
827
- window.__nbbTcfObs = new MutationObserver(() => {
828
- 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();
829
1093
  });
830
- try { window.__nbbTcfObs.observe(document.body || document.documentElement, { childList: true, subtree: false }); } catch (_) {}
831
- try { if (document.head) window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false }); } catch (_) {}
1094
+ // Observe body direct children only (the most likely removal point)
1095
+ try {
1096
+ window.__nbbTcfObs.observe(document.body || document.documentElement, {
1097
+ childList: true,
1098
+ subtree: false,
1099
+ });
1100
+ } catch (_) {}
1101
+ // Also observe <head> in case something cleans it
1102
+ try {
1103
+ if (document.head) {
1104
+ window.__nbbTcfObs.observe(document.head, {
1105
+ childList: true,
1106
+ subtree: false,
1107
+ });
1108
+ }
1109
+ } catch (_) {}
832
1110
  }
833
1111
  }
834
1112
 
835
- // ── aria-hidden protection ─────────────────────────────────────────────────
836
-
837
- function protectAriaHidden() {
838
- if (window.__nbbAriaObs) return;
839
- const remove = () => {
840
- try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {}
841
- };
842
- remove();
843
- window.__nbbAriaObs = new MutationObserver(remove);
844
- try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
845
- }
846
-
847
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.
848
1117
 
849
1118
  function muteConsole() {
850
1119
  if (window.__nbbEzMuted) return;
851
1120
  window.__nbbEzMuted = true;
1121
+
852
1122
  const PREFIXES = [
853
1123
  '[EzoicAds JS]: Placeholder Id',
1124
+ 'No valid placeholders for loadMore',
854
1125
  'cannot call refresh on the same page',
855
1126
  'no placeholders are currently defined in Refresh',
856
1127
  'Debugger iframe already exists',
857
1128
  '[CMP] Error in custom getTCData',
858
1129
  'vignette: no interstitial API',
859
1130
  ];
860
- const PATTERNS = [
861
- `with id ${PH_PREFIX}`,
862
- 'adsbygoogle.push() error: All',
863
- 'has already been defined',
864
- 'No valid placeholders for loadMore',
865
- 'bad response. Status',
866
- ];
1131
+ const PH_PATTERN = `with id ${PH_PREFIX}`;
1132
+
867
1133
  for (const method of ['log', 'info', 'warn', 'error']) {
868
1134
  const orig = console[method];
869
1135
  if (typeof orig !== 'function') continue;
870
1136
  console[method] = function (...args) {
871
1137
  if (typeof args[0] === 'string') {
872
1138
  const msg = args[0];
873
- for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
874
- 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;
875
1143
  }
876
1144
  return orig.apply(console, args);
877
1145
  };
@@ -879,24 +1147,31 @@
879
1147
  }
880
1148
 
881
1149
  // ── Network warmup ─────────────────────────────────────────────────────────
1150
+ // Run once per session — preconnect hints are in <head> via server-side injection
882
1151
 
883
1152
  let _networkWarmed = false;
1153
+
884
1154
  function warmNetwork() {
885
1155
  if (_networkWarmed) return;
886
1156
  _networkWarmed = true;
1157
+
887
1158
  const head = document.head;
888
1159
  if (!head) return;
889
- for (const [rel, href, cors] of [
1160
+
1161
+ const hints = [
890
1162
  ['preconnect', 'https://g.ezoic.net', true ],
891
1163
  ['preconnect', 'https://go.ezoic.net', true ],
892
1164
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
893
1165
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
894
1166
  ['dns-prefetch', 'https://g.ezoic.net', false],
895
1167
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
896
- ]) {
1168
+ ];
1169
+
1170
+ for (const [rel, href, cors] of hints) {
897
1171
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
898
1172
  const link = document.createElement('link');
899
- link.rel = rel; link.href = href;
1173
+ link.rel = rel;
1174
+ link.href = href;
900
1175
  if (cors) link.crossOrigin = 'anonymous';
901
1176
  head.appendChild(link);
902
1177
  }
@@ -907,35 +1182,51 @@
907
1182
  function bindNodeBB() {
908
1183
  const $ = window.jQuery;
909
1184
  if (!$) return;
1185
+
910
1186
  $(window).off('.nbbEzoic');
911
- $(window).on('action:ajaxify.start.nbbEzoic', (ev, data) => {
912
- // Only cleanup if navigating to a different page
913
- // NodeBB fires ajaxify.start for pagination/sorting on the same page
914
- const targetUrl = data?.url || data?.tpl_url || '';
915
- const currentPath = location.pathname.replace(/^\//, '');
916
- // If the URL is basically the same (ignoring query/hash), skip cleanup
917
- if (targetUrl && targetUrl.replace(/[?#].*$/, '') === currentPath.replace(/[?#].*$/, '')) {
918
- return;
919
- }
920
- cleanup();
921
- });
1187
+
1188
+ $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1189
+
922
1190
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
923
- state.pageKey = pageKey();
924
- state.kind = null;
1191
+ state.pageKey = pageKey();
1192
+ state.kind = null;
925
1193
  state.blockedUntil = 0;
926
- muteConsole(); ensureTcfLocator(); protectAriaHidden(); warmNetwork();
927
- getIO(); ensureDomObserver(); requestBurst();
1194
+
1195
+ // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1196
+ try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1197
+
1198
+ muteConsole();
1199
+ ensureTcfLocator();
1200
+ warmNetwork();
1201
+ patchShowAds();
1202
+ getIO();
1203
+ ensureDomObserver();
1204
+ requestBurst();
928
1205
  });
1206
+
1207
+ // Content-loaded events trigger burst
929
1208
  const burstEvents = [
930
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
931
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1209
+ 'action:ajaxify.contentLoaded',
1210
+ 'action:posts.loaded',
1211
+ 'action:topics.loaded',
1212
+ 'action:categories.loaded',
1213
+ 'action:category.loaded',
1214
+ 'action:topic.loaded',
932
1215
  ].map(e => `${e}.nbbEzoic`).join(' ');
1216
+
933
1217
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1218
+
1219
+ // Also bind via NodeBB hooks module (for compatibility)
934
1220
  try {
935
1221
  require(['hooks'], hooks => {
936
1222
  if (typeof hooks?.on !== 'function') return;
937
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
938
- 'action:categories.loaded', 'action:topic.loaded']) {
1223
+ for (const ev of [
1224
+ 'action:ajaxify.end',
1225
+ 'action:posts.loaded',
1226
+ 'action:topics.loaded',
1227
+ 'action:categories.loaded',
1228
+ 'action:topic.loaded',
1229
+ ]) {
939
1230
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
940
1231
  }
941
1232
  });
@@ -947,7 +1238,10 @@
947
1238
  window.addEventListener('scroll', () => {
948
1239
  if (ticking) return;
949
1240
  ticking = true;
950
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1241
+ requestAnimationFrame(() => {
1242
+ ticking = false;
1243
+ requestBurst();
1244
+ });
951
1245
  }, { passive: true });
952
1246
  }
953
1247
 
@@ -956,8 +1250,8 @@
956
1250
  state.pageKey = pageKey();
957
1251
  muteConsole();
958
1252
  ensureTcfLocator();
959
- protectAriaHidden();
960
1253
  warmNetwork();
1254
+ patchShowAds();
961
1255
  getIO();
962
1256
  ensureDomObserver();
963
1257
  bindNodeBB();