nodebb-plugin-ezoic-infinite 1.8.38 → 1.8.39

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 +308 -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.39",
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,30 @@
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
+ enableCalled: false, // ez.enable() called once per page
113
+ runQueued: false,
114
+ burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstTs: 0,
128
115
  };
129
116
 
130
117
  const isBlocked = () => now() < state.blockedUntil;
@@ -140,10 +127,7 @@
140
127
  if (state.cfg) return state.cfg;
141
128
  try {
142
129
  const inline = window.__nbbEzoicCfg;
143
- if (inline && typeof inline === 'object') {
144
- state.cfg = inline;
145
- return state.cfg;
146
- }
130
+ if (inline && typeof inline === 'object') { state.cfg = inline; return state.cfg; }
147
131
  } catch (_) {}
148
132
  try {
149
133
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
@@ -182,15 +166,12 @@
182
166
  return 'other';
183
167
  }
184
168
 
185
- function getKind() {
186
- return state.kind || (state.kind = detectKind());
187
- }
169
+ function getKind() { return state.kind || (state.kind = detectKind()); }
188
170
 
189
171
  // ── DOM queries ────────────────────────────────────────────────────────────
190
172
 
191
173
  function getPosts() {
192
- const all = document.querySelectorAll(SEL.post);
193
- const out = [];
174
+ const all = document.querySelectorAll(SEL.post), out = [];
194
175
  for (let i = 0; i < all.length; i++) {
195
176
  const el = all[i];
196
177
  if (!el.isConnected) continue;
@@ -202,7 +183,6 @@
202
183
  }
203
184
  return out;
204
185
  }
205
-
206
186
  function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
207
187
  function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
208
188
 
@@ -210,15 +190,10 @@
210
190
 
211
191
  function stableId(klass, el) {
212
192
  const attr = KIND[klass]?.anchorAttr;
213
- if (attr) {
214
- const v = el.getAttribute(attr);
215
- if (v != null && v !== '') return v;
216
- }
193
+ if (attr) { const v = el.getAttribute(attr); if (v != null && v !== '') return v; }
217
194
  const children = el.parentElement?.children;
218
195
  if (!children) return 'i0';
219
- for (let i = 0; i < children.length; i++) {
220
- if (children[i] === el) return `i${i}`;
221
- }
196
+ for (let i = 0; i < children.length; i++) { if (children[i] === el) return `i${i}`; }
222
197
  return 'i0';
223
198
  }
224
199
 
@@ -230,9 +205,9 @@
230
205
  }
231
206
 
232
207
  function getWrapSet(klass) {
233
- let set = state.wrapsByClass.get(klass);
234
- if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
235
- return set;
208
+ let s = state.wrapsByClass.get(klass);
209
+ if (!s) { s = new Set(); state.wrapsByClass.set(klass, s); }
210
+ return s;
236
211
  }
237
212
 
238
213
  // ── Wrap lifecycle ─────────────────────────────────────────────────────────
@@ -241,28 +216,19 @@
241
216
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
242
217
  const key = wrap.getAttribute(ATTR.ANCHOR);
243
218
  if (!key) return false;
244
-
245
219
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
246
-
247
220
  const colonIdx = key.indexOf(':');
248
- const klass = key.slice(0, colonIdx);
249
- const anchorId = key.slice(colonIdx + 1);
221
+ const klass = key.slice(0, colonIdx), anchorId = key.slice(colonIdx + 1);
250
222
  const cfg = KIND[klass];
251
223
  if (!cfg) return false;
252
-
253
224
  const parent = wrap.parentElement;
254
225
  if (parent) {
255
226
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
256
227
  for (const sib of parent.children) {
257
- if (sib !== wrap) {
258
- try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
259
- }
228
+ if (sib !== wrap) { try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {} }
260
229
  }
261
230
  }
262
-
263
- try {
264
- return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
265
- } catch (_) { return false; }
231
+ try { return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true; } catch (_) { return false; }
266
232
  }
267
233
 
268
234
  function adjacentWrap(el) {
@@ -277,20 +243,18 @@
277
243
  if (!ph || !isFilled(ph)) return false;
278
244
  wrap.classList.remove('is-empty');
279
245
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
280
- if (id > 0) state.phState.set(id, 'shown');
246
+ if (id > 0) state.phState.set(id, 'displayed');
281
247
  return true;
282
248
  }
283
249
 
284
250
  function scheduleUncollapseChecks(wrap) {
285
251
  if (!wrap) return;
286
252
  for (const ms of [500, 1500, 3000, 7000, 15000]) {
287
- setTimeout(() => {
288
- try { clearEmptyIfFilled(wrap); } catch (_) {}
289
- }, ms);
253
+ setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
290
254
  }
291
255
  }
292
256
 
293
- // ── Pool management ────────────────────────────────────────────────────────
257
+ // ── Pool ───────────────────────────────────────────────────────────────────
294
258
 
295
259
  function pickId(poolKey) {
296
260
  const pool = state.pools[poolKey];
@@ -304,17 +268,140 @@
304
268
  return null;
305
269
  }
306
270
 
307
- // ── Recycling ──────────────────────────────────────────────────────────────
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); })
308
278
  //
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)
279
+ // We batch define+displayMore calls using ezBatch to avoid calling the
280
+ // Ezoic API on every single placeholder insertion.
316
281
 
317
- const RECYCLE_MIN_AGE_MS = 5_000;
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
+ }
367
+
368
+ /**
369
+ * Destroy a placeholder in Ezoic (for recycling).
370
+ * Returns a Promise that resolves after the destroy delay.
371
+ */
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.
318
405
 
319
406
  function recycleWrap(klass, targetEl, newKey) {
320
407
  const ez = window.ezstandalone;
@@ -323,7 +410,6 @@
323
410
  typeof ez?.displayMore !== 'function') return null;
324
411
 
325
412
  const vh = window.innerHeight || 800;
326
- // Conservative threshold: 3× viewport height above the top of the screen
327
413
  const threshold = -(3 * vh);
328
414
  const t = now();
329
415
  let bestEmpty = null, bestEmptyY = Infinity;
@@ -334,17 +420,14 @@
334
420
 
335
421
  for (const wrap of wraps) {
336
422
  try {
337
- // Don't recycle wraps that are too young (ads might still be loading)
338
423
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
339
424
  if (t - created < RECYCLE_MIN_AGE_MS) continue;
340
-
341
- // Don't recycle wraps with inflight showAds
342
425
  const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
343
- if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
344
-
426
+ const st = state.phState.get(wid);
427
+ // Don't recycle placeholders that are still being processed
428
+ if (st === 'new' || st === 'defined') continue;
345
429
  const bottom = wrap.getBoundingClientRect().bottom;
346
430
  if (bottom > threshold) continue;
347
-
348
431
  if (!isFilled(wrap)) {
349
432
  if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
350
433
  } else {
@@ -367,43 +450,40 @@
367
450
  if (ph) state.io?.unobserve(ph);
368
451
  } catch (_) {}
369
452
 
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
- });
453
+ // Step 1: Destroy in Ezoic, then recreate DOM and re-define
454
+ state.phState.set(id, 'destroyed');
387
455
 
388
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
389
- state.wrapByKey.set(newKey, best);
456
+ ezCmd(() => {
457
+ try { ez.destroyPlaceholders(id); } catch (_) {}
390
458
 
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
459
  setTimeout(() => {
398
- try { observePlaceholder(id); } catch (_) {}
399
- state.phState.set(id, 'new');
400
- try { enqueueShow(id); } catch (_) {}
401
- }, TIMING.RECYCLE_DELAY_MS);
402
- };
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
+ });
403
475
 
404
- try {
405
- (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
406
- } catch (_) {}
476
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
477
+ state.wrapByKey.set(newKey, best);
478
+
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
+ });
407
487
 
408
488
  return { id, wrap: best };
409
489
  }
@@ -418,7 +498,6 @@
418
498
  w.setAttribute(ATTR.CREATED, String(now()));
419
499
  w.setAttribute(ATTR.SHOWN, '0');
420
500
  w.style.cssText = 'width:100%;display:block';
421
-
422
501
  const ph = document.createElement('div');
423
502
  ph.id = `${PH_PREFIX}${id}`;
424
503
  ph.setAttribute('data-ezoic-id', String(id));
@@ -433,7 +512,6 @@
433
512
  if (state.mountedIds.has(id)) return null;
434
513
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
435
514
  if (existing?.isConnected) return null;
436
-
437
515
  const w = makeWrap(id, klass, key);
438
516
  mutate(() => el.insertAdjacentElement('afterend', w));
439
517
  state.mountedIds.add(id);
@@ -447,20 +525,13 @@
447
525
  try {
448
526
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
449
527
  if (ph instanceof Element) state.io?.unobserve(ph);
450
-
451
528
  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
-
529
+ if (Number.isFinite(id)) { state.mountedIds.delete(id); state.phState.delete(id); }
457
530
  const key = w.getAttribute(ATTR.ANCHOR);
458
531
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
459
-
460
532
  for (const cls of w.classList) {
461
533
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
462
- state.wrapsByClass.get(cls)?.delete(w);
463
- break;
534
+ state.wrapsByClass.get(cls)?.delete(w); break;
464
535
  }
465
536
  }
466
537
  w.remove();
@@ -474,22 +545,18 @@
474
545
  const cfg = KIND[klass];
475
546
  const wraps = state.wrapsByClass.get(klass);
476
547
  if (!wraps?.size) return;
477
-
478
548
  const liveAnchors = new Set();
479
549
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
480
550
  const v = el.getAttribute(cfg.anchorAttr);
481
551
  if (v) liveAnchors.add(v);
482
552
  }
483
-
484
553
  const t = now();
485
554
  for (const w of wraps) {
486
555
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
487
556
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
488
557
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
489
558
  const sid = key.slice(klass.length + 1);
490
- if (!sid || !liveAnchors.has(sid)) {
491
- mutate(() => dropWrap(w));
492
- }
559
+ if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
493
560
  }
494
561
  }
495
562
 
@@ -517,11 +584,9 @@
517
584
  for (const el of items) {
518
585
  if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
519
586
  if (!el?.isConnected) continue;
520
-
521
587
  const ord = ordinal(klass, el);
522
588
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
523
589
  if (adjacentWrap(el)) continue;
524
-
525
590
  const key = anchorKey(klass, el);
526
591
  if (findWrap(key)) continue;
527
592
 
@@ -530,7 +595,8 @@
530
595
  const w = insertAfter(el, id, klass, key);
531
596
  if (w) {
532
597
  observePlaceholder(id);
533
- if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
598
+ // Queue for batched define+enable/displayMore
599
+ ezEnqueue(id);
534
600
  inserted++;
535
601
  }
536
602
  } else {
@@ -543,6 +609,12 @@
543
609
  }
544
610
 
545
611
  // ── 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.
546
618
 
547
619
  function getIO() {
548
620
  if (state.io) return state.io;
@@ -550,12 +622,19 @@
550
622
  state.io = new IntersectionObserver(entries => {
551
623
  for (const entry of entries) {
552
624
  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);
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
+ }
559
638
  }
560
639
  }, {
561
640
  root: null,
@@ -568,227 +647,19 @@
568
647
 
569
648
  function observePlaceholder(id) {
570
649
  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
- }
650
+ if (ph?.isConnected) { try { getIO()?.observe(ph); } catch (_) {} }
775
651
  }
776
652
 
777
653
  // ── Core ───────────────────────────────────────────────────────────────────
778
654
 
779
655
  async function runCore() {
780
656
  if (isBlocked()) return 0;
781
-
782
657
  const cfg = await fetchConfig();
783
658
  if (!cfg || cfg.excluded) return 0;
784
659
  initPools(cfg);
785
-
786
660
  const kind = getKind();
787
661
  if (kind === 'other') return 0;
788
662
 
789
- // Re-observe wraps that came back into viewport (scroll-up fix)
790
- reobserveVisibleWraps();
791
-
792
663
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
793
664
  if (!normBool(cfgEnable)) return 0;
794
665
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
@@ -796,24 +667,16 @@
796
667
  };
797
668
 
798
669
  if (kind === 'topic') {
799
- return exec(
800
- 'ezoic-ad-message', getPosts,
801
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
802
- );
670
+ return exec('ezoic-ad-message', getPosts,
671
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
803
672
  }
804
-
805
673
  if (kind === 'categoryTopics') {
806
674
  pruneOrphansBetween();
807
- return exec(
808
- 'ezoic-ad-between', getTopics,
809
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
810
- );
675
+ return exec('ezoic-ad-between', getTopics,
676
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
811
677
  }
812
-
813
- return exec(
814
- 'ezoic-ad-categories', getCategories,
815
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
816
- );
678
+ return exec('ezoic-ad-categories', getCategories,
679
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
817
680
  }
818
681
 
819
682
  // ── Scheduler & burst ──────────────────────────────────────────────────────
@@ -837,62 +700,56 @@
837
700
  state.lastBurstTs = t;
838
701
  state.pageKey = pageKey();
839
702
  state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
840
-
841
703
  if (state.burstActive) return;
842
704
  state.burstActive = true;
843
705
  state.burstCount = 0;
844
-
845
706
  const step = () => {
846
707
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
847
- state.burstActive = false;
848
- return;
708
+ state.burstActive = false; return;
849
709
  }
850
710
  state.burstCount++;
851
711
  scheduleRun(n => {
852
- if (!n && !state.pending.length) { state.burstActive = false; return; }
712
+ if (!n && !state.ezBatch.size) { state.burstActive = false; return; }
853
713
  setTimeout(step, n > 0 ? 150 : 300);
854
714
  });
855
715
  };
856
716
  step();
857
717
  }
858
718
 
859
- // ── Cleanup on navigation ──────────────────────────────────────────────────
719
+ // ── Cleanup ────────────────────────────────────────────────────────────────
860
720
 
861
721
  function cleanup() {
862
722
  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
+
863
728
  mutate(() => {
864
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
865
- dropWrap(w);
866
- }
729
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
867
730
  });
868
- state.cfg = null;
869
- state.poolsReady = false;
870
- state.pools = { topics: [], posts: [], categories: [] };
871
- state.cursors = { topics: 0, posts: 0, categories: 0 };
731
+ state.cfg = null;
732
+ state.poolsReady = false;
733
+ state.pools = { topics: [], posts: [], categories: [] };
734
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
872
735
  state.mountedIds.clear();
873
736
  state.lastShow.clear();
874
737
  state.wrapByKey.clear();
875
738
  state.wrapsByClass.clear();
876
- state.kind = null;
739
+ state.kind = null;
877
740
  state.phState.clear();
878
- state.inflight = 0;
879
- state.pending = [];
880
- state.pendingSet.clear();
741
+ state.enableCalled = false;
881
742
  state.burstActive = false;
882
743
  state.runQueued = false;
883
- state.firstShown = false;
884
744
  }
885
745
 
886
746
  // ── MutationObserver ───────────────────────────────────────────────────────
887
747
 
888
748
  function ensureDomObserver() {
889
749
  if (state.domObs) return;
890
-
891
750
  state.domObs = new MutationObserver(muts => {
892
751
  if (state.mutGuard > 0 || isBlocked()) return;
893
-
894
752
  let needsBurst = false;
895
-
896
753
  const kind = getKind();
897
754
  const relevantSels =
898
755
  kind === 'topic' ? [SEL.post] :
@@ -905,7 +762,7 @@
905
762
  for (const node of m.addedNodes) {
906
763
  if (!(node instanceof Element)) continue;
907
764
 
908
- // Check for ad fill events in wraps
765
+ // Ad fill detection
909
766
  try {
910
767
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
911
768
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -914,140 +771,91 @@
914
771
  }
915
772
  } catch (_) {}
916
773
 
917
- // Check for new content items
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
918
788
  if (!needsBurst) {
919
789
  for (const sel of relevantSels) {
920
790
  try {
921
- if (node.matches(sel) || node.querySelector(sel)) {
922
- needsBurst = true;
923
- break;
924
- }
791
+ if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
925
792
  } catch (_) {}
926
793
  }
927
794
  }
928
795
  }
929
796
  if (needsBurst) break;
930
797
  }
931
-
932
798
  if (needsBurst) requestBurst();
933
799
  });
934
-
935
- try {
936
- state.domObs.observe(document.body, { childList: true, subtree: true });
937
- } catch (_) {}
800
+ try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
938
801
  }
939
802
 
940
803
  // ── 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
804
 
947
805
  function ensureTcfLocator() {
948
806
  if (!window.__tcfapi && !window.__cmp) return;
949
-
950
807
  const LOCATOR_ID = '__tcfapiLocator';
951
-
952
808
  const ensureInHead = () => {
953
809
  let existing = document.getElementById(LOCATOR_ID);
954
810
  if (existing) {
955
811
  if (existing.parentElement !== document.head) {
956
812
  try { document.head.appendChild(existing); } catch (_) {}
957
813
  }
958
- return existing;
814
+ return;
959
815
  }
960
816
  const f = document.createElement('iframe');
961
- f.style.display = 'none';
962
- f.id = f.name = LOCATOR_ID;
817
+ f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
963
818
  try { document.head.appendChild(f); } catch (_) {
964
819
  (document.body || document.documentElement).appendChild(f);
965
820
  }
966
- return f;
967
821
  };
968
-
969
822
  ensureInHead();
970
-
971
- // Guard CMP API calls
972
823
  if (!window.__nbbCmpGuarded) {
973
824
  window.__nbbCmpGuarded = true;
974
-
975
825
  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
- }
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 (_) {} } }
988
830
  };
989
831
  }
990
-
991
832
  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
- }
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 (_) {} } }
1002
837
  };
1003
838
  }
1004
839
  }
1005
-
1006
840
  if (!window.__nbbTcfObs) {
1007
841
  window.__nbbTcfObs = new MutationObserver(() => {
1008
- if (document.getElementById(LOCATOR_ID)) return;
1009
- ensureInHead();
842
+ if (!document.getElementById(LOCATOR_ID)) ensureInHead();
1010
843
  });
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 (_) {}
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 (_) {}
1023
846
  }
1024
847
  }
1025
848
 
1026
849
  // ── 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
850
 
1034
851
  function protectAriaHidden() {
1035
852
  if (window.__nbbAriaObs) return;
1036
853
  const remove = () => {
1037
- try {
1038
- if (document.body.getAttribute('aria-hidden') === 'true') {
1039
- document.body.removeAttribute('aria-hidden');
1040
- }
1041
- } catch (_) {}
854
+ try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {}
1042
855
  };
1043
856
  remove();
1044
857
  window.__nbbAriaObs = new MutationObserver(remove);
1045
- try {
1046
- window.__nbbAriaObs.observe(document.body, {
1047
- attributes: true,
1048
- attributeFilter: ['aria-hidden'],
1049
- });
1050
- } catch (_) {}
858
+ try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
1051
859
  }
1052
860
 
1053
861
  // ── Console muting ─────────────────────────────────────────────────────────
@@ -1055,10 +863,8 @@
1055
863
  function muteConsole() {
1056
864
  if (window.__nbbEzMuted) return;
1057
865
  window.__nbbEzMuted = true;
1058
-
1059
866
  const PREFIXES = [
1060
867
  '[EzoicAds JS]: Placeholder Id',
1061
- 'No valid placeholders for loadMore',
1062
868
  'cannot call refresh on the same page',
1063
869
  'no placeholders are currently defined in Refresh',
1064
870
  'Debugger iframe already exists',
@@ -1068,20 +874,18 @@
1068
874
  const PATTERNS = [
1069
875
  `with id ${PH_PREFIX}`,
1070
876
  'adsbygoogle.push() error: All',
877
+ 'has already been defined',
878
+ 'No valid placeholders for loadMore',
879
+ 'bad response. Status',
1071
880
  ];
1072
-
1073
881
  for (const method of ['log', 'info', 'warn', 'error']) {
1074
882
  const orig = console[method];
1075
883
  if (typeof orig !== 'function') continue;
1076
884
  console[method] = function (...args) {
1077
885
  if (typeof args[0] === 'string') {
1078
886
  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
- }
887
+ for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
888
+ for (const p of PATTERNS) { if (msg.includes(p)) return; }
1085
889
  }
1086
890
  return orig.apply(console, args);
1087
891
  };
@@ -1091,28 +895,22 @@
1091
895
  // ── Network warmup ─────────────────────────────────────────────────────────
1092
896
 
1093
897
  let _networkWarmed = false;
1094
-
1095
898
  function warmNetwork() {
1096
899
  if (_networkWarmed) return;
1097
900
  _networkWarmed = true;
1098
-
1099
901
  const head = document.head;
1100
902
  if (!head) return;
1101
-
1102
- const hints = [
903
+ for (const [rel, href, cors] of [
1103
904
  ['preconnect', 'https://g.ezoic.net', true ],
1104
905
  ['preconnect', 'https://go.ezoic.net', true ],
1105
906
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
1106
907
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
1107
908
  ['dns-prefetch', 'https://g.ezoic.net', false],
1108
909
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1109
- ];
1110
-
1111
- for (const [rel, href, cors] of hints) {
910
+ ]) {
1112
911
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1113
912
  const link = document.createElement('link');
1114
- link.rel = rel;
1115
- link.href = href;
913
+ link.rel = rel; link.href = href;
1116
914
  if (cors) link.crossOrigin = 'anonymous';
1117
915
  head.appendChild(link);
1118
916
  }
@@ -1123,46 +921,25 @@
1123
921
  function bindNodeBB() {
1124
922
  const $ = window.jQuery;
1125
923
  if (!$) return;
1126
-
1127
924
  $(window).off('.nbbEzoic');
1128
925
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1129
-
1130
926
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1131
- state.pageKey = pageKey();
1132
- state.kind = null;
927
+ state.pageKey = pageKey();
928
+ state.kind = null;
1133
929
  state.blockedUntil = 0;
1134
-
1135
- muteConsole();
1136
- ensureTcfLocator();
1137
- protectAriaHidden();
1138
- warmNetwork();
1139
- patchShowAds();
1140
- getIO();
1141
- ensureDomObserver();
1142
- requestBurst();
930
+ muteConsole(); ensureTcfLocator(); protectAriaHidden(); warmNetwork();
931
+ getIO(); ensureDomObserver(); requestBurst();
1143
932
  });
1144
-
1145
933
  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',
934
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
935
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1152
936
  ].map(e => `${e}.nbbEzoic`).join(' ');
1153
-
1154
937
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1155
-
1156
938
  try {
1157
939
  require(['hooks'], hooks => {
1158
940
  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
- ]) {
941
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
942
+ 'action:categories.loaded', 'action:topic.loaded']) {
1166
943
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
1167
944
  }
1168
945
  });
@@ -1174,10 +951,7 @@
1174
951
  window.addEventListener('scroll', () => {
1175
952
  if (ticking) return;
1176
953
  ticking = true;
1177
- requestAnimationFrame(() => {
1178
- ticking = false;
1179
- requestBurst();
1180
- });
954
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
1181
955
  }, { passive: true });
1182
956
  }
1183
957
 
@@ -1188,7 +962,6 @@
1188
962
  ensureTcfLocator();
1189
963
  protectAriaHidden();
1190
964
  warmNetwork();
1191
- patchShowAds();
1192
965
  getIO();
1193
966
  ensureDomObserver();
1194
967
  bindNodeBB();