nodebb-plugin-ezoic-infinite 1.8.39 → 1.8.41

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