nodebb-plugin-ezoic-infinite 1.8.38 → 1.8.40

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 (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +318 -535
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.38",
3
+ "version": "1.8.40",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.1.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v3.0.0
3
3
  *
4
- * Fixes in v2.1:
5
- * - Early bail when user is excluded (no ezstandalone reference errors)
6
- * - aria-hidden on <body>: MutationObserver removes it continuously
7
- * - Recycle threshold raised to -3×vh to prevent mobile fast-scroll blank ads
8
- * - Scroll-up: re-observe placeholders whose wraps come back into DOM
9
- * - adsbygoogle "already have ads" error: clear ins before recycle
10
- * - TCF/CMP 3-layer protection (head iframe, API guard, MutationObserver)
4
+ * Proper Ezoic infinite scroll API flow:
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
+ * new → defined → displayed → [destroyed → new] (recycle loop)
11
13
  */
12
14
  (function nbbEzoicInfinite() {
13
15
  'use strict';
@@ -30,16 +32,13 @@
30
32
  SHOW_THROTTLE_MS: 900,
31
33
  BURST_COOLDOWN_MS: 200,
32
34
  BLOCK_DURATION_MS: 1_500,
33
- SHOW_TIMEOUT_MS: 7_000,
34
- SHOW_RELEASE_MS: 700,
35
- BATCH_FLUSH_MS: 80,
36
- RECYCLE_DELAY_MS: 450,
35
+ BATCH_FLUSH_MS: 120,
36
+ RECYCLE_DESTROY_MS: 300,
37
+ RECYCLE_DEFINE_MS: 300,
37
38
  };
38
39
 
39
40
  const LIMITS = {
40
41
  MAX_INSERTS_RUN: 6,
41
- MAX_INFLIGHT: 4,
42
- BATCH_SIZE: 3,
43
42
  MAX_BURST_STEPS: 8,
44
43
  BURST_WINDOW_MS: 2_000,
45
44
  };
@@ -63,30 +62,25 @@
63
62
 
64
63
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
65
64
 
65
+ const RECYCLE_MIN_AGE_MS = 5_000;
66
+
66
67
  // ── Utility ────────────────────────────────────────────────────────────────
67
68
 
68
69
  const now = () => Date.now();
69
70
  const isMobile = () => window.innerWidth < 768;
70
71
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
71
72
 
72
- function isFilled(node) {
73
- return node?.querySelector?.(FILL_SEL) != null;
74
- }
75
-
73
+ function isFilled(node) { return node?.querySelector?.(FILL_SEL) != null; }
76
74
  function isPlaceholderUsed(ph) {
77
75
  if (!ph?.isConnected) return false;
78
76
  return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
79
77
  }
80
78
 
81
79
  function parseIds(raw) {
82
- const out = [];
83
- const seen = new Set();
80
+ const out = [], seen = new Set();
84
81
  for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
85
82
  const n = parseInt(line, 10);
86
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
87
- seen.add(n);
88
- out.push(n);
89
- }
83
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
90
84
  }
91
85
  return out;
92
86
  }
@@ -94,37 +88,29 @@
94
88
  // ── State ──────────────────────────────────────────────────────────────────
95
89
 
96
90
  const state = {
97
- pageKey: null,
98
- kind: null,
99
- cfg: null,
91
+ pageKey: null, kind: null, cfg: null,
100
92
 
101
93
  poolsReady: false,
102
- pools: { topics: [], posts: [], categories: [] },
103
- cursors: { topics: 0, posts: 0, categories: 0 },
104
-
105
- mountedIds: new Set(),
106
- phState: new Map(),
107
- lastShow: new Map(),
94
+ pools: { topics: [], posts: [], categories: [] },
95
+ cursors: { topics: 0, posts: 0, categories: 0 },
108
96
 
97
+ mountedIds: new Set(),
98
+ // phState: 'new' | 'defined' | 'displayed' | 'destroyed'
99
+ phState: new Map(),
100
+ lastShow: new Map(),
109
101
  wrapByKey: new Map(),
110
102
  wrapsByClass: new Map(),
111
103
 
112
- io: null,
113
- domObs: null,
104
+ io: null, domObs: null,
105
+ mutGuard: 0, blockedUntil: 0,
114
106
 
115
- mutGuard: 0,
116
- blockedUntil: 0,
107
+ // Ezoic batch queue: ids waiting for define+displayMore
108
+ ezBatch: new Set(),
109
+ ezFlushTimer: null,
117
110
 
118
- inflight: 0,
119
- pending: [],
120
- pendingSet: new Set(),
121
-
122
- runQueued: false,
123
- burstActive: false,
124
- burstDeadline: 0,
125
- burstCount: 0,
126
- lastBurstTs: 0,
127
- firstShown: false,
111
+ // Lifecycle
112
+ runQueued: false,
113
+ burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstTs: 0,
128
114
  };
129
115
 
130
116
  const isBlocked = () => now() < state.blockedUntil;
@@ -140,10 +126,7 @@
140
126
  if (state.cfg) return state.cfg;
141
127
  try {
142
128
  const inline = window.__nbbEzoicCfg;
143
- if (inline && typeof inline === 'object') {
144
- state.cfg = inline;
145
- return state.cfg;
146
- }
129
+ if (inline && typeof inline === 'object') { state.cfg = inline; return state.cfg; }
147
130
  } catch (_) {}
148
131
  try {
149
132
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
@@ -182,15 +165,12 @@
182
165
  return 'other';
183
166
  }
184
167
 
185
- function getKind() {
186
- return state.kind || (state.kind = detectKind());
187
- }
168
+ function getKind() { return state.kind || (state.kind = detectKind()); }
188
169
 
189
170
  // ── DOM queries ────────────────────────────────────────────────────────────
190
171
 
191
172
  function getPosts() {
192
- const all = document.querySelectorAll(SEL.post);
193
- const out = [];
173
+ const all = document.querySelectorAll(SEL.post), out = [];
194
174
  for (let i = 0; i < all.length; i++) {
195
175
  const el = all[i];
196
176
  if (!el.isConnected) continue;
@@ -202,7 +182,6 @@
202
182
  }
203
183
  return out;
204
184
  }
205
-
206
185
  function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
207
186
  function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
208
187
 
@@ -210,15 +189,10 @@
210
189
 
211
190
  function stableId(klass, el) {
212
191
  const attr = KIND[klass]?.anchorAttr;
213
- if (attr) {
214
- const v = el.getAttribute(attr);
215
- if (v != null && v !== '') return v;
216
- }
192
+ if (attr) { const v = el.getAttribute(attr); if (v != null && v !== '') return v; }
217
193
  const children = el.parentElement?.children;
218
194
  if (!children) return 'i0';
219
- for (let i = 0; i < children.length; i++) {
220
- if (children[i] === el) return `i${i}`;
221
- }
195
+ for (let i = 0; i < children.length; i++) { if (children[i] === el) return `i${i}`; }
222
196
  return 'i0';
223
197
  }
224
198
 
@@ -230,9 +204,9 @@
230
204
  }
231
205
 
232
206
  function getWrapSet(klass) {
233
- let set = state.wrapsByClass.get(klass);
234
- if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
235
- return set;
207
+ let s = state.wrapsByClass.get(klass);
208
+ if (!s) { s = new Set(); state.wrapsByClass.set(klass, s); }
209
+ return s;
236
210
  }
237
211
 
238
212
  // ── Wrap lifecycle ─────────────────────────────────────────────────────────
@@ -241,28 +215,19 @@
241
215
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
242
216
  const key = wrap.getAttribute(ATTR.ANCHOR);
243
217
  if (!key) return false;
244
-
245
218
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
246
-
247
219
  const colonIdx = key.indexOf(':');
248
- const klass = key.slice(0, colonIdx);
249
- const anchorId = key.slice(colonIdx + 1);
220
+ const klass = key.slice(0, colonIdx), anchorId = key.slice(colonIdx + 1);
250
221
  const cfg = KIND[klass];
251
222
  if (!cfg) return false;
252
-
253
223
  const parent = wrap.parentElement;
254
224
  if (parent) {
255
225
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
256
226
  for (const sib of parent.children) {
257
- if (sib !== wrap) {
258
- try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
259
- }
227
+ if (sib !== wrap) { try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {} }
260
228
  }
261
229
  }
262
-
263
- try {
264
- return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
265
- } catch (_) { return false; }
230
+ try { return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true; } catch (_) { return false; }
266
231
  }
267
232
 
268
233
  function adjacentWrap(el) {
@@ -277,20 +242,18 @@
277
242
  if (!ph || !isFilled(ph)) return false;
278
243
  wrap.classList.remove('is-empty');
279
244
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
280
- if (id > 0) state.phState.set(id, 'shown');
245
+ if (id > 0) state.phState.set(id, 'displayed');
281
246
  return true;
282
247
  }
283
248
 
284
249
  function scheduleUncollapseChecks(wrap) {
285
250
  if (!wrap) return;
286
251
  for (const ms of [500, 1500, 3000, 7000, 15000]) {
287
- setTimeout(() => {
288
- try { clearEmptyIfFilled(wrap); } catch (_) {}
289
- }, ms);
252
+ setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
290
253
  }
291
254
  }
292
255
 
293
- // ── Pool management ────────────────────────────────────────────────────────
256
+ // ── Pool ───────────────────────────────────────────────────────────────────
294
257
 
295
258
  function pickId(poolKey) {
296
259
  const pool = state.pools[poolKey];
@@ -304,17 +267,152 @@
304
267
  return null;
305
268
  }
306
269
 
307
- // ── Recycling ──────────────────────────────────────────────────────────────
270
+ // ── Ezoic API layer ────────────────────────────────────────────────────────
271
+ //
272
+ // Correct Ezoic infinite scroll flow:
273
+ // First batch: ez.cmd.push(() => { ez.define(...ids); ez.enable(); })
274
+ // Next batches: ez.cmd.push(() => { ez.define(...ids); ez.displayMore(...ids); })
275
+ // Recycle: ez.cmd.push(() => { ez.destroyPlaceholders(id); })
276
+ // → wait → new DOM → ez.cmd.push(() => { ez.define(id); ez.displayMore(id); })
308
277
  //
309
- // v2.1 changes:
310
- // - Threshold raised to -(3 × vh) — on mobile fast scroll, -vh was too close
311
- // and wraps got recycled before ads had time to load
312
- // - Never recycle a wrap whose showAds is still inflight (show-queued state)
313
- // - Clear ins.adsbygoogle children before re-creating placeholder to avoid
314
- // "All ins elements already have ads" error
315
- // - Skip wraps that were created less than 5s ago (give ads time to fill)
278
+ // We batch define+displayMore calls using ezBatch to avoid calling the
279
+ // Ezoic API on every single placeholder insertion.
316
280
 
317
- const RECYCLE_MIN_AGE_MS = 5_000;
281
+ function ezCmd(fn) {
282
+ window.ezstandalone = window.ezstandalone || {};
283
+ const ez = window.ezstandalone;
284
+ if (Array.isArray(ez.cmd)) {
285
+ ez.cmd.push(fn);
286
+ } else {
287
+ try { fn(); } catch (_) {}
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Queue a placeholder ID for the next batched define+displayMore call.
293
+ */
294
+ function ezEnqueue(id) {
295
+ if (isBlocked()) return;
296
+ state.ezBatch.add(id);
297
+ if (!state.ezFlushTimer) {
298
+ state.ezFlushTimer = setTimeout(ezFlush, TIMING.BATCH_FLUSH_MS);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Flush: define new IDs with Ezoic, then call displayMore.
304
+ *
305
+ * IMPORTANT: We NEVER call ez.enable() — Ezoic's sa.min.js calls it
306
+ * automatically on page load. Calling it again causes
307
+ * "Enable should only ever be called once" error.
308
+ *
309
+ * We also check ez.getSelectedPlaceholders() or equivalent to skip
310
+ * IDs that Ezoic already knows about (defined during initial enable).
311
+ */
312
+ function ezFlush() {
313
+ state.ezFlushTimer = null;
314
+ if (isBlocked() || !state.ezBatch.size) return;
315
+
316
+ // Filter to only valid, connected placeholders
317
+ const ids = [];
318
+ for (const id of state.ezBatch) {
319
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
320
+ if (!ph?.isConnected) { state.phState.delete(id); continue; }
321
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'displayed'); continue; }
322
+ ids.push(id);
323
+ }
324
+ state.ezBatch.clear();
325
+ if (!ids.length) return;
326
+
327
+ ezCmd(() => {
328
+ const ez = window.ezstandalone;
329
+ if (!ez) return;
330
+
331
+ // Check which IDs Ezoic already knows about to avoid "already defined"
332
+ const alreadyDefined = new Set();
333
+ try {
334
+ // ez.allPlaceholders is an array of IDs Ezoic has already seen
335
+ if (Array.isArray(ez.allPlaceholders)) {
336
+ for (const p of ez.allPlaceholders) alreadyDefined.add(Number(p));
337
+ }
338
+ } catch (_) {}
339
+
340
+ const toDefine = ids.filter(id => !alreadyDefined.has(id));
341
+ // All IDs get displayMore, but only new ones get define
342
+ const toDisplay = ids;
343
+
344
+ // define() only for truly new placeholders
345
+ if (toDefine.length) {
346
+ try {
347
+ if (typeof ez.define === 'function') {
348
+ ez.define(...toDefine);
349
+ }
350
+ } catch (_) {}
351
+ }
352
+
353
+ for (const id of ids) state.phState.set(id, 'defined');
354
+
355
+ // displayMore() for all IDs — this is the infinite scroll API
356
+ if (toDisplay.length) {
357
+ try {
358
+ if (typeof ez.displayMore === 'function') {
359
+ ez.displayMore(...toDisplay);
360
+ }
361
+ } catch (_) {}
362
+ }
363
+
364
+ // Mark as displayed and schedule fill checks
365
+ for (const id of ids) {
366
+ state.phState.set(id, 'displayed');
367
+ state.lastShow.set(id, now());
368
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
369
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
370
+ if (wrap) {
371
+ wrap.setAttribute(ATTR.SHOWN, String(now()));
372
+ scheduleUncollapseChecks(wrap);
373
+ }
374
+ scheduleEmptyCheck(id);
375
+ }
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Destroy a placeholder in Ezoic (for recycling).
381
+ * Returns a Promise that resolves after the destroy delay.
382
+ */
383
+ function ezDestroy(id) {
384
+ return new Promise(resolve => {
385
+ ezCmd(() => {
386
+ state.phState.set(id, 'destroyed');
387
+ const ez = window.ezstandalone;
388
+ try {
389
+ if (typeof ez?.destroyPlaceholders === 'function') {
390
+ ez.destroyPlaceholders(id);
391
+ }
392
+ } catch (_) {}
393
+ setTimeout(resolve, TIMING.RECYCLE_DESTROY_MS);
394
+ });
395
+ });
396
+ }
397
+
398
+ function scheduleEmptyCheck(id) {
399
+ const showTs = now();
400
+ setTimeout(() => {
401
+ try {
402
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
403
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
404
+ if (!wrap || !ph?.isConnected) return;
405
+ if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
406
+ if (clearEmptyIfFilled(wrap)) return;
407
+ wrap.classList.add('is-empty');
408
+ } catch (_) {}
409
+ }, TIMING.EMPTY_CHECK_MS);
410
+ }
411
+
412
+ // ── Recycling ──────────────────────────────────────────────────────────────
413
+ //
414
+ // When pool is exhausted, find the farthest wrap above viewport,
415
+ // destroy its Ezoic placeholder, recreate the DOM, then define+displayMore.
318
416
 
319
417
  function recycleWrap(klass, targetEl, newKey) {
320
418
  const ez = window.ezstandalone;
@@ -323,7 +421,6 @@
323
421
  typeof ez?.displayMore !== 'function') return null;
324
422
 
325
423
  const vh = window.innerHeight || 800;
326
- // Conservative threshold: 3× viewport height above the top of the screen
327
424
  const threshold = -(3 * vh);
328
425
  const t = now();
329
426
  let bestEmpty = null, bestEmptyY = Infinity;
@@ -334,17 +431,14 @@
334
431
 
335
432
  for (const wrap of wraps) {
336
433
  try {
337
- // Don't recycle wraps that are too young (ads might still be loading)
338
434
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
339
435
  if (t - created < RECYCLE_MIN_AGE_MS) continue;
340
-
341
- // Don't recycle wraps with inflight showAds
342
436
  const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
343
- if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
344
-
437
+ const st = state.phState.get(wid);
438
+ // Don't recycle placeholders that are still being processed
439
+ if (st === 'new' || st === 'defined') continue;
345
440
  const bottom = wrap.getBoundingClientRect().bottom;
346
441
  if (bottom > threshold) continue;
347
-
348
442
  if (!isFilled(wrap)) {
349
443
  if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
350
444
  } else {
@@ -367,43 +461,40 @@
367
461
  if (ph) state.io?.unobserve(ph);
368
462
  } catch (_) {}
369
463
 
370
- // Move the wrap to new position with a clean placeholder
371
- mutate(() => {
372
- best.setAttribute(ATTR.ANCHOR, newKey);
373
- best.setAttribute(ATTR.CREATED, String(now()));
374
- best.setAttribute(ATTR.SHOWN, '0');
375
- best.classList.remove('is-empty');
376
- // Clear ALL children including ins.adsbygoogle to avoid
377
- // "All ins elements already have ads" error on re-show
378
- best.replaceChildren();
379
-
380
- const fresh = document.createElement('div');
381
- fresh.id = `${PH_PREFIX}${id}`;
382
- fresh.setAttribute('data-ezoic-id', String(id));
383
- fresh.style.minHeight = '1px';
384
- best.appendChild(fresh);
385
- targetEl.insertAdjacentElement('afterend', best);
386
- });
464
+ // Step 1: Destroy in Ezoic, then recreate DOM and re-define
465
+ state.phState.set(id, 'destroyed');
387
466
 
388
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
389
- state.wrapByKey.set(newKey, best);
467
+ ezCmd(() => {
468
+ try { ez.destroyPlaceholders(id); } catch (_) {}
390
469
 
391
- // Ezoic destroy → re-define → re-show sequence
392
- const doDestroy = () => {
393
- state.phState.set(id, 'destroyed');
394
- try { ez.destroyPlaceholders(id); } catch (_) {
395
- try { ez.destroyPlaceholders([id]); } catch (_) {}
396
- }
397
470
  setTimeout(() => {
398
- try { observePlaceholder(id); } catch (_) {}
399
- state.phState.set(id, 'new');
400
- try { enqueueShow(id); } catch (_) {}
401
- }, TIMING.RECYCLE_DELAY_MS);
402
- };
471
+ // Step 2: Recreate placeholder DOM at new position
472
+ mutate(() => {
473
+ best.setAttribute(ATTR.ANCHOR, newKey);
474
+ best.setAttribute(ATTR.CREATED, String(now()));
475
+ best.setAttribute(ATTR.SHOWN, '0');
476
+ best.classList.remove('is-empty');
477
+ best.replaceChildren();
478
+
479
+ const fresh = document.createElement('div');
480
+ fresh.id = `${PH_PREFIX}${id}`;
481
+ fresh.setAttribute('data-ezoic-id', String(id));
482
+ fresh.style.minHeight = '1px';
483
+ best.appendChild(fresh);
484
+ targetEl.insertAdjacentElement('afterend', best);
485
+ });
403
486
 
404
- try {
405
- (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
406
- } catch (_) {}
487
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
488
+ state.wrapByKey.set(newKey, best);
489
+
490
+ // Step 3: Re-define + displayMore via the batch queue
491
+ setTimeout(() => {
492
+ state.phState.set(id, 'new');
493
+ observePlaceholder(id);
494
+ ezEnqueue(id);
495
+ }, TIMING.RECYCLE_DEFINE_MS);
496
+ }, TIMING.RECYCLE_DESTROY_MS);
497
+ });
407
498
 
408
499
  return { id, wrap: best };
409
500
  }
@@ -418,7 +509,6 @@
418
509
  w.setAttribute(ATTR.CREATED, String(now()));
419
510
  w.setAttribute(ATTR.SHOWN, '0');
420
511
  w.style.cssText = 'width:100%;display:block';
421
-
422
512
  const ph = document.createElement('div');
423
513
  ph.id = `${PH_PREFIX}${id}`;
424
514
  ph.setAttribute('data-ezoic-id', String(id));
@@ -433,7 +523,6 @@
433
523
  if (state.mountedIds.has(id)) return null;
434
524
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
435
525
  if (existing?.isConnected) return null;
436
-
437
526
  const w = makeWrap(id, klass, key);
438
527
  mutate(() => el.insertAdjacentElement('afterend', w));
439
528
  state.mountedIds.add(id);
@@ -447,20 +536,13 @@
447
536
  try {
448
537
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
449
538
  if (ph instanceof Element) state.io?.unobserve(ph);
450
-
451
539
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
452
- if (Number.isFinite(id)) {
453
- state.mountedIds.delete(id);
454
- state.phState.delete(id);
455
- }
456
-
540
+ if (Number.isFinite(id)) { state.mountedIds.delete(id); state.phState.delete(id); }
457
541
  const key = w.getAttribute(ATTR.ANCHOR);
458
542
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
459
-
460
543
  for (const cls of w.classList) {
461
544
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
462
- state.wrapsByClass.get(cls)?.delete(w);
463
- break;
545
+ state.wrapsByClass.get(cls)?.delete(w); break;
464
546
  }
465
547
  }
466
548
  w.remove();
@@ -474,22 +556,18 @@
474
556
  const cfg = KIND[klass];
475
557
  const wraps = state.wrapsByClass.get(klass);
476
558
  if (!wraps?.size) return;
477
-
478
559
  const liveAnchors = new Set();
479
560
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
480
561
  const v = el.getAttribute(cfg.anchorAttr);
481
562
  if (v) liveAnchors.add(v);
482
563
  }
483
-
484
564
  const t = now();
485
565
  for (const w of wraps) {
486
566
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
487
567
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
488
568
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
489
569
  const sid = key.slice(klass.length + 1);
490
- if (!sid || !liveAnchors.has(sid)) {
491
- mutate(() => dropWrap(w));
492
- }
570
+ if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
493
571
  }
494
572
  }
495
573
 
@@ -517,11 +595,9 @@
517
595
  for (const el of items) {
518
596
  if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
519
597
  if (!el?.isConnected) continue;
520
-
521
598
  const ord = ordinal(klass, el);
522
599
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
523
600
  if (adjacentWrap(el)) continue;
524
-
525
601
  const key = anchorKey(klass, el);
526
602
  if (findWrap(key)) continue;
527
603
 
@@ -530,7 +606,8 @@
530
606
  const w = insertAfter(el, id, klass, key);
531
607
  if (w) {
532
608
  observePlaceholder(id);
533
- if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
609
+ // Queue for batched define+enable/displayMore
610
+ ezEnqueue(id);
534
611
  inserted++;
535
612
  }
536
613
  } else {
@@ -543,6 +620,12 @@
543
620
  }
544
621
 
545
622
  // ── IntersectionObserver ───────────────────────────────────────────────────
623
+ //
624
+ // The IO is used to eagerly observe placeholders so that when they enter
625
+ // the viewport margin, we can queue them for Ezoic. However, the actual
626
+ // Ezoic API calls (define/displayMore) happen in the batched flush.
627
+ // The IO callback is mainly useful for re-triggering after NodeBB
628
+ // virtualisation re-inserts posts.
546
629
 
547
630
  function getIO() {
548
631
  if (state.io) return state.io;
@@ -550,12 +633,19 @@
550
633
  state.io = new IntersectionObserver(entries => {
551
634
  for (const entry of entries) {
552
635
  if (!entry.isIntersecting) continue;
553
- // DON'T unobserve — we need to re-trigger on scroll-up when NodeBB
554
- // re-inserts virtualized posts back into the DOM.
555
- // Instead, the show throttle (SHOW_THROTTLE_MS) and phState check
556
- // in enqueueShow prevent duplicate calls.
557
- const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
558
- if (id > 0) enqueueShow(id);
636
+ const target = entry.target;
637
+ if (target instanceof Element) state.io?.unobserve(target);
638
+ const id = parseInt(target.getAttribute('data-ezoic-id'), 10);
639
+ if (!id || id <= 0) continue;
640
+ const st = state.phState.get(id);
641
+ // Only enqueue if not yet processed by Ezoic
642
+ if (st === 'new') {
643
+ ezEnqueue(id);
644
+ } else if (st === 'displayed') {
645
+ // Already shown — check if the placeholder is actually filled.
646
+ // If not (Ezoic had no ad), don't re-trigger — it won't help.
647
+ // If yes, nothing to do.
648
+ }
559
649
  }
560
650
  }, {
561
651
  root: null,
@@ -568,227 +658,19 @@
568
658
 
569
659
  function observePlaceholder(id) {
570
660
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
571
- if (ph?.isConnected) {
572
- try { getIO()?.observe(ph); } catch (_) {}
573
- }
574
- }
575
-
576
- // ── Show queue ─────────────────────────────────────────────────────────────
577
-
578
- function enqueueShow(id) {
579
- if (!id || isBlocked()) return;
580
- const st = state.phState.get(id);
581
- if (st === 'show-queued' || st === 'shown') return;
582
- if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
583
-
584
- if (state.inflight >= LIMITS.MAX_INFLIGHT) {
585
- if (!state.pendingSet.has(id)) {
586
- state.pending.push(id);
587
- state.pendingSet.add(id);
588
- state.phState.set(id, 'show-queued');
589
- }
590
- return;
591
- }
592
- state.phState.set(id, 'show-queued');
593
- startShow(id);
594
- }
595
-
596
- function drainQueue() {
597
- if (isBlocked()) return;
598
- while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
599
- const id = state.pending.shift();
600
- state.pendingSet.delete(id);
601
- startShow(id);
602
- }
603
- }
604
-
605
- function startShow(id) {
606
- if (!id || isBlocked()) return;
607
- state.inflight++;
608
-
609
- let released = false;
610
- const release = () => {
611
- if (released) return;
612
- released = true;
613
- state.inflight = Math.max(0, state.inflight - 1);
614
- drainQueue();
615
- };
616
- const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
617
-
618
- requestAnimationFrame(() => {
619
- try {
620
- if (isBlocked()) { clearTimeout(timer); return release(); }
621
-
622
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
623
- if (!ph?.isConnected) {
624
- state.phState.delete(id);
625
- clearTimeout(timer);
626
- return release();
627
- }
628
-
629
- if (isFilled(ph) || isPlaceholderUsed(ph)) {
630
- state.phState.set(id, 'shown');
631
- clearTimeout(timer);
632
- return release();
633
- }
634
-
635
- const t = now();
636
- if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
637
- clearTimeout(timer);
638
- return release();
639
- }
640
- state.lastShow.set(id, t);
641
-
642
- try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
643
- state.phState.set(id, 'shown');
644
-
645
- window.ezstandalone = window.ezstandalone || {};
646
- const ez = window.ezstandalone;
647
-
648
- const doShow = () => {
649
- const wrap = ph.closest(`.${WRAP_CLASS}`);
650
- try { ez.showAds(id); } catch (_) {}
651
- if (wrap) scheduleUncollapseChecks(wrap);
652
- scheduleEmptyCheck(id, t);
653
- setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
654
- };
655
-
656
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
657
- } catch (_) {
658
- clearTimeout(timer);
659
- release();
660
- }
661
- });
662
- }
663
-
664
- function scheduleEmptyCheck(id, showTs) {
665
- setTimeout(() => {
666
- try {
667
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
668
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
669
- if (!wrap || !ph?.isConnected) return;
670
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
671
- if (clearEmptyIfFilled(wrap)) return;
672
- wrap.classList.add('is-empty');
673
- } catch (_) {}
674
- }, TIMING.EMPTY_CHECK_MS);
675
- }
676
-
677
- // ── Patch Ezoic showAds ────────────────────────────────────────────────────
678
-
679
- function patchShowAds() {
680
- const apply = () => {
681
- try {
682
- window.ezstandalone = window.ezstandalone || {};
683
- const ez = window.ezstandalone;
684
- if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
685
- window.__nbbEzPatched = true;
686
-
687
- const orig = ez.showAds.bind(ez);
688
- const queue = new Set();
689
- let flushTimer = null;
690
-
691
- const flush = () => {
692
- flushTimer = null;
693
- if (isBlocked() || !queue.size) return;
694
-
695
- const ids = Array.from(queue).sort((a, b) => a - b);
696
- queue.clear();
697
-
698
- const valid = ids.filter(id => {
699
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
700
- if (!ph?.isConnected) { state.phState.delete(id); return false; }
701
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
702
- return true;
703
- });
704
-
705
- for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
706
- const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
707
- try { orig(...chunk); } catch (_) {
708
- for (const cid of chunk) { try { orig(cid); } catch (_) {} }
709
- }
710
- }
711
- };
712
-
713
- ez.showAds = function (...args) {
714
- if (isBlocked()) return;
715
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
716
- for (const v of ids) {
717
- const id = parseInt(v, 10);
718
- if (!Number.isFinite(id) || id <= 0) continue;
719
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
720
- if (!ph?.isConnected) continue;
721
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
722
- state.phState.set(id, 'show-queued');
723
- queue.add(id);
724
- }
725
- if (queue.size && !flushTimer) {
726
- flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
727
- }
728
- };
729
- } catch (_) {}
730
- };
731
-
732
- apply();
733
- if (!window.__nbbEzPatched) {
734
- window.ezstandalone = window.ezstandalone || {};
735
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
736
- }
737
- }
738
-
739
- // ── Re-observe on scroll-up ────────────────────────────────────────────────
740
- //
741
- // NodeBB virtualizes posts: removes them from DOM when off-viewport, then
742
- // re-inserts them when scrolling back up. Our wraps stay in the DOM but
743
- // the placeholder was already unobserved by IO (in v2.0) or the phState
744
- // was 'shown'. We need to check if wraps that are back in viewport have
745
- // unfilled placeholders and re-trigger show for them.
746
-
747
- function reobserveVisibleWraps() {
748
- const vh = window.innerHeight || 800;
749
- for (const [key, wrap] of state.wrapByKey) {
750
- if (!wrap.isConnected) continue;
751
- const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
752
- if (!id || id <= 0) continue;
753
-
754
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
755
- if (!ph?.isConnected) continue;
756
-
757
- // Already filled — nothing to do
758
- if (isFilled(ph) || isPlaceholderUsed(ph)) continue;
759
-
760
- // Check if the wrap is in or near the viewport
761
- const rect = wrap.getBoundingClientRect();
762
- if (rect.bottom < -vh || rect.top > 2 * vh) continue;
763
-
764
- // This wrap is visible-ish but unfilled — re-trigger
765
- const st = state.phState.get(id);
766
- if (st === 'shown' || st === 'show-queued') {
767
- // Reset state so enqueueShow accepts it again
768
- state.phState.set(id, 'new');
769
- state.lastShow.delete(id);
770
- }
771
- // Re-observe in IO (safe to call multiple times)
772
- try { getIO()?.observe(ph); } catch (_) {}
773
- enqueueShow(id);
774
- }
661
+ if (ph?.isConnected) { try { getIO()?.observe(ph); } catch (_) {} }
775
662
  }
776
663
 
777
664
  // ── Core ───────────────────────────────────────────────────────────────────
778
665
 
779
666
  async function runCore() {
780
667
  if (isBlocked()) return 0;
781
-
782
668
  const cfg = await fetchConfig();
783
669
  if (!cfg || cfg.excluded) return 0;
784
670
  initPools(cfg);
785
-
786
671
  const kind = getKind();
787
672
  if (kind === 'other') return 0;
788
673
 
789
- // Re-observe wraps that came back into viewport (scroll-up fix)
790
- reobserveVisibleWraps();
791
-
792
674
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
793
675
  if (!normBool(cfgEnable)) return 0;
794
676
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
@@ -796,24 +678,16 @@
796
678
  };
797
679
 
798
680
  if (kind === 'topic') {
799
- return exec(
800
- 'ezoic-ad-message', getPosts,
801
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
802
- );
681
+ return exec('ezoic-ad-message', getPosts,
682
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
803
683
  }
804
-
805
684
  if (kind === 'categoryTopics') {
806
685
  pruneOrphansBetween();
807
- return exec(
808
- 'ezoic-ad-between', getTopics,
809
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
810
- );
686
+ return exec('ezoic-ad-between', getTopics,
687
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
811
688
  }
812
-
813
- return exec(
814
- 'ezoic-ad-categories', getCategories,
815
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
816
- );
689
+ return exec('ezoic-ad-categories', getCategories,
690
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
817
691
  }
818
692
 
819
693
  // ── Scheduler & burst ──────────────────────────────────────────────────────
@@ -837,62 +711,55 @@
837
711
  state.lastBurstTs = t;
838
712
  state.pageKey = pageKey();
839
713
  state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
840
-
841
714
  if (state.burstActive) return;
842
715
  state.burstActive = true;
843
716
  state.burstCount = 0;
844
-
845
717
  const step = () => {
846
718
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
847
- state.burstActive = false;
848
- return;
719
+ state.burstActive = false; return;
849
720
  }
850
721
  state.burstCount++;
851
722
  scheduleRun(n => {
852
- if (!n && !state.pending.length) { state.burstActive = false; return; }
723
+ if (!n && !state.ezBatch.size) { state.burstActive = false; return; }
853
724
  setTimeout(step, n > 0 ? 150 : 300);
854
725
  });
855
726
  };
856
727
  step();
857
728
  }
858
729
 
859
- // ── Cleanup on navigation ──────────────────────────────────────────────────
730
+ // ── Cleanup ────────────────────────────────────────────────────────────────
860
731
 
861
732
  function cleanup() {
862
733
  state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
734
+
735
+ // Cancel pending Ezoic batch
736
+ if (state.ezFlushTimer) { clearTimeout(state.ezFlushTimer); state.ezFlushTimer = null; }
737
+ state.ezBatch.clear();
738
+
863
739
  mutate(() => {
864
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
865
- dropWrap(w);
866
- }
740
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
867
741
  });
868
- state.cfg = null;
869
- state.poolsReady = false;
870
- state.pools = { topics: [], posts: [], categories: [] };
871
- state.cursors = { topics: 0, posts: 0, categories: 0 };
742
+ state.cfg = null;
743
+ state.poolsReady = false;
744
+ state.pools = { topics: [], posts: [], categories: [] };
745
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
872
746
  state.mountedIds.clear();
873
747
  state.lastShow.clear();
874
748
  state.wrapByKey.clear();
875
749
  state.wrapsByClass.clear();
876
- state.kind = null;
750
+ state.kind = null;
877
751
  state.phState.clear();
878
- state.inflight = 0;
879
- state.pending = [];
880
- state.pendingSet.clear();
881
752
  state.burstActive = false;
882
753
  state.runQueued = false;
883
- state.firstShown = false;
884
754
  }
885
755
 
886
756
  // ── MutationObserver ───────────────────────────────────────────────────────
887
757
 
888
758
  function ensureDomObserver() {
889
759
  if (state.domObs) return;
890
-
891
760
  state.domObs = new MutationObserver(muts => {
892
761
  if (state.mutGuard > 0 || isBlocked()) return;
893
-
894
762
  let needsBurst = false;
895
-
896
763
  const kind = getKind();
897
764
  const relevantSels =
898
765
  kind === 'topic' ? [SEL.post] :
@@ -905,7 +772,7 @@
905
772
  for (const node of m.addedNodes) {
906
773
  if (!(node instanceof Element)) continue;
907
774
 
908
- // Check for ad fill events in wraps
775
+ // Ad fill detection
909
776
  try {
910
777
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
911
778
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -914,140 +781,91 @@
914
781
  }
915
782
  } catch (_) {}
916
783
 
917
- // Check for new content items
784
+ // Re-observe wraps re-inserted by NodeBB virtualization
785
+ try {
786
+ const wraps = node.classList?.contains(WRAP_CLASS)
787
+ ? [node]
788
+ : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
789
+ for (const wrap of wraps) {
790
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
791
+ if (!id || id <= 0) continue;
792
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
793
+ if (ph) { try { getIO()?.observe(ph); } catch (_) {} }
794
+ }
795
+ } catch (_) {}
796
+
797
+ // New content detection
918
798
  if (!needsBurst) {
919
799
  for (const sel of relevantSels) {
920
800
  try {
921
- if (node.matches(sel) || node.querySelector(sel)) {
922
- needsBurst = true;
923
- break;
924
- }
801
+ if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
925
802
  } catch (_) {}
926
803
  }
927
804
  }
928
805
  }
929
806
  if (needsBurst) break;
930
807
  }
931
-
932
808
  if (needsBurst) requestBurst();
933
809
  });
934
-
935
- try {
936
- state.domObs.observe(document.body, { childList: true, subtree: true });
937
- } catch (_) {}
810
+ try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
938
811
  }
939
812
 
940
813
  // ── TCF / CMP Protection ───────────────────────────────────────────────────
941
- //
942
- // 3 layers:
943
- // 1. PROTECT: locator iframe in <head> (ajaxify never touches <head>)
944
- // 2. GUARD: wrap __tcfapi/__cmp to catch null contentWindow errors
945
- // 3. RESTORE: MutationObserver for immediate re-creation
946
814
 
947
815
  function ensureTcfLocator() {
948
816
  if (!window.__tcfapi && !window.__cmp) return;
949
-
950
817
  const LOCATOR_ID = '__tcfapiLocator';
951
-
952
818
  const ensureInHead = () => {
953
819
  let existing = document.getElementById(LOCATOR_ID);
954
820
  if (existing) {
955
821
  if (existing.parentElement !== document.head) {
956
822
  try { document.head.appendChild(existing); } catch (_) {}
957
823
  }
958
- return existing;
824
+ return;
959
825
  }
960
826
  const f = document.createElement('iframe');
961
- f.style.display = 'none';
962
- f.id = f.name = LOCATOR_ID;
827
+ f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
963
828
  try { document.head.appendChild(f); } catch (_) {
964
829
  (document.body || document.documentElement).appendChild(f);
965
830
  }
966
- return f;
967
831
  };
968
-
969
832
  ensureInHead();
970
-
971
- // Guard CMP API calls
972
833
  if (!window.__nbbCmpGuarded) {
973
834
  window.__nbbCmpGuarded = true;
974
-
975
835
  if (typeof window.__tcfapi === 'function') {
976
- const origTcf = window.__tcfapi;
977
- window.__tcfapi = function (cmd, version, cb, param) {
978
- try {
979
- return origTcf.call(this, cmd, version, function (...args) {
980
- try { cb?.(...args); } catch (_) {}
981
- }, param);
982
- } catch (e) {
983
- if (e?.message?.includes('null')) {
984
- ensureInHead();
985
- try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
986
- }
987
- }
836
+ const orig = window.__tcfapi;
837
+ window.__tcfapi = function (cmd, ver, cb, param) {
838
+ try { return orig.call(this, cmd, ver, function (...a) { try { cb?.(...a); } catch (_) {} }, param); }
839
+ catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.call(this, cmd, ver, cb, param); } catch (_) {} } }
988
840
  };
989
841
  }
990
-
991
842
  if (typeof window.__cmp === 'function') {
992
- const origCmp = window.__cmp;
993
- window.__cmp = function (...args) {
994
- try {
995
- return origCmp.apply(this, args);
996
- } catch (e) {
997
- if (e?.message?.includes('null')) {
998
- ensureInHead();
999
- try { return origCmp.apply(this, args); } catch (_) {}
1000
- }
1001
- }
843
+ const orig = window.__cmp;
844
+ window.__cmp = function (...a) {
845
+ try { return orig.apply(this, a); }
846
+ catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.apply(this, a); } catch (_) {} } }
1002
847
  };
1003
848
  }
1004
849
  }
1005
-
1006
850
  if (!window.__nbbTcfObs) {
1007
851
  window.__nbbTcfObs = new MutationObserver(() => {
1008
- if (document.getElementById(LOCATOR_ID)) return;
1009
- ensureInHead();
852
+ if (!document.getElementById(LOCATOR_ID)) ensureInHead();
1010
853
  });
1011
- try {
1012
- window.__nbbTcfObs.observe(document.body || document.documentElement, {
1013
- childList: true, subtree: false,
1014
- });
1015
- } catch (_) {}
1016
- try {
1017
- if (document.head) {
1018
- window.__nbbTcfObs.observe(document.head, {
1019
- childList: true, subtree: false,
1020
- });
1021
- }
1022
- } catch (_) {}
854
+ try { window.__nbbTcfObs.observe(document.body || document.documentElement, { childList: true, subtree: false }); } catch (_) {}
855
+ try { if (document.head) window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false }); } catch (_) {}
1023
856
  }
1024
857
  }
1025
858
 
1026
859
  // ── aria-hidden protection ─────────────────────────────────────────────────
1027
- //
1028
- // The CMP modal sets aria-hidden="true" on <body> when opening, and may not
1029
- // remove it after ajaxify navigation. A simple removeAttribute in ajaxify.end
1030
- // isn't enough because the CMP can re-set it asynchronously.
1031
- //
1032
- // Use a MutationObserver on <body> attributes to remove it immediately.
1033
860
 
1034
861
  function protectAriaHidden() {
1035
862
  if (window.__nbbAriaObs) return;
1036
863
  const remove = () => {
1037
- try {
1038
- if (document.body.getAttribute('aria-hidden') === 'true') {
1039
- document.body.removeAttribute('aria-hidden');
1040
- }
1041
- } catch (_) {}
864
+ try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {}
1042
865
  };
1043
866
  remove();
1044
867
  window.__nbbAriaObs = new MutationObserver(remove);
1045
- try {
1046
- window.__nbbAriaObs.observe(document.body, {
1047
- attributes: true,
1048
- attributeFilter: ['aria-hidden'],
1049
- });
1050
- } catch (_) {}
868
+ try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
1051
869
  }
1052
870
 
1053
871
  // ── Console muting ─────────────────────────────────────────────────────────
@@ -1055,10 +873,8 @@
1055
873
  function muteConsole() {
1056
874
  if (window.__nbbEzMuted) return;
1057
875
  window.__nbbEzMuted = true;
1058
-
1059
876
  const PREFIXES = [
1060
877
  '[EzoicAds JS]: Placeholder Id',
1061
- 'No valid placeholders for loadMore',
1062
878
  'cannot call refresh on the same page',
1063
879
  'no placeholders are currently defined in Refresh',
1064
880
  'Debugger iframe already exists',
@@ -1068,20 +884,18 @@
1068
884
  const PATTERNS = [
1069
885
  `with id ${PH_PREFIX}`,
1070
886
  'adsbygoogle.push() error: All',
887
+ 'has already been defined',
888
+ 'No valid placeholders for loadMore',
889
+ 'bad response. Status',
1071
890
  ];
1072
-
1073
891
  for (const method of ['log', 'info', 'warn', 'error']) {
1074
892
  const orig = console[method];
1075
893
  if (typeof orig !== 'function') continue;
1076
894
  console[method] = function (...args) {
1077
895
  if (typeof args[0] === 'string') {
1078
896
  const msg = args[0];
1079
- for (const prefix of PREFIXES) {
1080
- if (msg.startsWith(prefix)) return;
1081
- }
1082
- for (const pat of PATTERNS) {
1083
- if (msg.includes(pat)) return;
1084
- }
897
+ for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
898
+ for (const p of PATTERNS) { if (msg.includes(p)) return; }
1085
899
  }
1086
900
  return orig.apply(console, args);
1087
901
  };
@@ -1091,28 +905,22 @@
1091
905
  // ── Network warmup ─────────────────────────────────────────────────────────
1092
906
 
1093
907
  let _networkWarmed = false;
1094
-
1095
908
  function warmNetwork() {
1096
909
  if (_networkWarmed) return;
1097
910
  _networkWarmed = true;
1098
-
1099
911
  const head = document.head;
1100
912
  if (!head) return;
1101
-
1102
- const hints = [
913
+ for (const [rel, href, cors] of [
1103
914
  ['preconnect', 'https://g.ezoic.net', true ],
1104
915
  ['preconnect', 'https://go.ezoic.net', true ],
1105
916
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
1106
917
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
1107
918
  ['dns-prefetch', 'https://g.ezoic.net', false],
1108
919
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1109
- ];
1110
-
1111
- for (const [rel, href, cors] of hints) {
920
+ ]) {
1112
921
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1113
922
  const link = document.createElement('link');
1114
- link.rel = rel;
1115
- link.href = href;
923
+ link.rel = rel; link.href = href;
1116
924
  if (cors) link.crossOrigin = 'anonymous';
1117
925
  head.appendChild(link);
1118
926
  }
@@ -1123,46 +931,25 @@
1123
931
  function bindNodeBB() {
1124
932
  const $ = window.jQuery;
1125
933
  if (!$) return;
1126
-
1127
934
  $(window).off('.nbbEzoic');
1128
935
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1129
-
1130
936
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1131
- state.pageKey = pageKey();
1132
- state.kind = null;
937
+ state.pageKey = pageKey();
938
+ state.kind = null;
1133
939
  state.blockedUntil = 0;
1134
-
1135
- muteConsole();
1136
- ensureTcfLocator();
1137
- protectAriaHidden();
1138
- warmNetwork();
1139
- patchShowAds();
1140
- getIO();
1141
- ensureDomObserver();
1142
- requestBurst();
940
+ muteConsole(); ensureTcfLocator(); protectAriaHidden(); warmNetwork();
941
+ getIO(); ensureDomObserver(); requestBurst();
1143
942
  });
1144
-
1145
943
  const burstEvents = [
1146
- 'action:ajaxify.contentLoaded',
1147
- 'action:posts.loaded',
1148
- 'action:topics.loaded',
1149
- 'action:categories.loaded',
1150
- 'action:category.loaded',
1151
- 'action:topic.loaded',
944
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
945
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1152
946
  ].map(e => `${e}.nbbEzoic`).join(' ');
1153
-
1154
947
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1155
-
1156
948
  try {
1157
949
  require(['hooks'], hooks => {
1158
950
  if (typeof hooks?.on !== 'function') return;
1159
- for (const ev of [
1160
- 'action:ajaxify.end',
1161
- 'action:posts.loaded',
1162
- 'action:topics.loaded',
1163
- 'action:categories.loaded',
1164
- 'action:topic.loaded',
1165
- ]) {
951
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
952
+ 'action:categories.loaded', 'action:topic.loaded']) {
1166
953
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
1167
954
  }
1168
955
  });
@@ -1174,10 +961,7 @@
1174
961
  window.addEventListener('scroll', () => {
1175
962
  if (ticking) return;
1176
963
  ticking = true;
1177
- requestAnimationFrame(() => {
1178
- ticking = false;
1179
- requestBurst();
1180
- });
964
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
1181
965
  }, { passive: true });
1182
966
  }
1183
967
 
@@ -1188,7 +972,6 @@
1188
972
  ensureTcfLocator();
1189
973
  protectAriaHidden();
1190
974
  warmNetwork();
1191
- patchShowAds();
1192
975
  getIO();
1193
976
  ensureDomObserver();
1194
977
  bindNodeBB();