nodebb-plugin-ezoic-infinite 1.8.42 → 1.8.43

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