nodebb-plugin-ezoic-infinite 1.8.48 → 1.8.49

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