nodebb-plugin-ezoic-infinite 1.8.51 → 1.8.53

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 +8 -21
  2. package/package.json +1 -1
  3. package/public/client.js +296 -564
package/public/client.js CHANGED
@@ -1,15 +1,12 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.0.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.3.0
3
3
  *
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)
4
+ * Architecture based on battle-tested v50, with targeted improvements:
5
+ * - Ezoic API: showAds() + destroyPlaceholders() per official docs
6
+ * - wrapsByClass Set for O(1) recycle lookup (no querySelectorAll)
7
+ * - MutationObserver: ad fill detection + virtualization re-observe
8
+ * - Conservative empty check (30s/60s, GPT-aware)
9
+ * - aria-hidden + TCF locator protection
13
10
  */
14
11
  (function nbbEzoicInfinite() {
15
12
  'use strict';
@@ -19,7 +16,6 @@
19
16
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
20
17
  const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
21
18
 
22
- // Data attributes
23
19
  const ATTR = {
24
20
  ANCHOR: 'data-ezoic-anchor',
25
21
  WRAPID: 'data-ezoic-wrapid',
@@ -27,49 +23,39 @@
27
23
  SHOWN: 'data-ezoic-shown',
28
24
  };
29
25
 
30
- // Timing
31
26
  const TIMING = {
32
- EMPTY_CHECK_MS: 20_000,
27
+ EMPTY_CHECK_MS_1: 30_000,
28
+ EMPTY_CHECK_MS_2: 60_000,
33
29
  MIN_PRUNE_AGE_MS: 8_000,
30
+ RECYCLE_MIN_AGE_MS: 5_000,
34
31
  SHOW_THROTTLE_MS: 900,
35
32
  BURST_COOLDOWN_MS: 200,
36
33
  BLOCK_DURATION_MS: 1_500,
37
34
  SHOW_TIMEOUT_MS: 7_000,
38
35
  SHOW_RELEASE_MS: 700,
39
- BATCH_FLUSH_MS: 80,
40
36
  RECYCLE_DELAY_MS: 450,
41
-
42
37
  };
43
38
 
44
- // Limits
45
- const LIMITS = {
46
- MAX_INSERTS_RUN: 6,
47
- MAX_INFLIGHT: 4,
48
- BATCH_SIZE: 3,
49
- MAX_BURST_STEPS: 8,
50
- BURST_WINDOW_MS: 2_000,
51
- };
39
+ const MAX_INSERTS_RUN = 6;
40
+ const MAX_INFLIGHT = 4;
41
+ const MAX_BURST_STEPS = 8;
42
+ const BURST_WINDOW_MS = 2_000;
52
43
 
53
- const IO_MARGIN = {
54
- DESKTOP: '2500px 0px 2500px 0px',
55
- MOBILE: '3500px 0px 3500px 0px',
56
- };
44
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
45
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
57
46
 
58
- // Selectors
59
47
  const SEL = {
60
48
  post: '[component="post"][data-pid]',
61
49
  topic: 'li[component="category/topic"]',
62
50
  category: 'li[component="categories/category"]',
63
51
  };
64
52
 
65
- // Kind configuration table — single source of truth per ad type
66
53
  const KIND = {
67
54
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
68
55
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
69
56
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
70
57
  };
71
58
 
72
- // Selector for detecting filled ad slots
73
59
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
74
60
 
75
61
  // ── Utility ────────────────────────────────────────────────────────────────
@@ -77,106 +63,79 @@
77
63
  const now = () => Date.now();
78
64
  const isMobile = () => window.innerWidth < 768;
79
65
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
80
-
81
- function isFilled(node) {
82
- return node?.querySelector?.(FILL_SEL) != null;
83
- }
84
-
85
- function isPlaceholderUsed(ph) {
86
- if (!ph?.isConnected) return false;
87
- return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
88
- }
66
+ const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
89
67
 
90
68
  function parseIds(raw) {
91
- const out = [];
92
- const seen = new Set();
93
- for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
94
- const n = parseInt(line, 10);
95
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
96
- seen.add(n);
97
- out.push(n);
98
- }
69
+ const out = [], seen = new Set();
70
+ for (const v of String(raw || '').split(/[\r\n,\s]+/)) {
71
+ const n = parseInt(v, 10);
72
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
99
73
  }
100
74
  return out;
101
75
  }
102
76
 
103
77
  // ── State ──────────────────────────────────────────────────────────────────
104
78
 
105
- const state = {
106
- // Page context
79
+ const S = {
107
80
  pageKey: null,
108
81
  kind: null,
109
82
  cfg: null,
110
83
 
111
- // Pools
112
84
  poolsReady: false,
113
85
  pools: { topics: [], posts: [], categories: [] },
114
86
  cursors: { topics: 0, posts: 0, categories: 0 },
115
87
 
116
- // Mounted placeholders
117
- mountedIds: new Set(),
118
- phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
- lastShow: new Map(), // id → timestamp
88
+ mountedIds: new Set(),
89
+ lastShow: new Map(),
120
90
 
121
- // Wrap registry
122
- wrapByKey: new Map(), // anchorKey → wrap element
123
- wrapsByClass: new Map(), // kindClass → Set<wrap>
91
+ wrapByKey: new Map(), // anchorKey → wrap element
92
+ wrapsByClass: new Map(), // kindClassSet<wrap>
124
93
 
125
- // Observers
126
94
  io: null,
127
95
  domObs: null,
128
96
 
129
- // Guards
130
- mutGuard: 0,
131
- blockedUntil: 0,
97
+ mutGuard: 0,
98
+ blockedUntil: 0,
132
99
 
133
- // Show queue
134
100
  inflight: 0,
135
101
  pending: [],
136
102
  pendingSet: new Set(),
137
103
 
138
- // Scheduler
139
- runQueued: false,
140
- burstActive: false,
104
+ runQueued: false,
105
+ burstActive: false,
141
106
  burstDeadline: 0,
142
- burstCount: 0,
143
- lastBurstTs: 0,
144
- firstShown: false,
107
+ burstCount: 0,
108
+ lastBurstTs: 0,
145
109
  };
146
110
 
147
- const isBlocked = () => now() < state.blockedUntil;
111
+ const isBlocked = () => now() < S.blockedUntil;
148
112
 
149
113
  function mutate(fn) {
150
- state.mutGuard++;
151
- try { fn(); } finally { state.mutGuard--; }
114
+ S.mutGuard++;
115
+ try { fn(); } finally { S.mutGuard--; }
152
116
  }
153
117
 
154
118
  // ── Config ─────────────────────────────────────────────────────────────────
155
119
 
156
120
  async function fetchConfig() {
157
- if (state.cfg) return state.cfg;
158
- // Prefer inline config injected by server (zero latency)
121
+ if (S.cfg) return S.cfg;
159
122
  try {
160
123
  const inline = window.__nbbEzoicCfg;
161
- if (inline && typeof inline === 'object') {
162
- state.cfg = inline;
163
- return state.cfg;
164
- }
124
+ if (inline && typeof inline === 'object') { S.cfg = inline; return S.cfg; }
165
125
  } catch (_) {}
166
- // Fallback to API
167
126
  try {
168
127
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
169
- if (r.ok) state.cfg = await r.json();
128
+ if (r.ok) S.cfg = await r.json();
170
129
  } catch (_) {}
171
- return state.cfg;
130
+ return S.cfg;
172
131
  }
173
132
 
174
133
  function initPools(cfg) {
175
- if (state.poolsReady) return;
176
- state.pools.topics = parseIds(cfg.placeholderIds);
177
- state.pools.posts = parseIds(cfg.messagePlaceholderIds);
178
- state.pools.categories = parseIds(cfg.categoryPlaceholderIds);
179
- state.poolsReady = true;
134
+ if (S.poolsReady) return;
135
+ S.pools.topics = parseIds(cfg.placeholderIds);
136
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
137
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
138
+ S.poolsReady = true;
180
139
  }
181
140
 
182
141
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -195,7 +154,6 @@
195
154
  if (/^\/topic\//.test(p)) return 'topic';
196
155
  if (/^\/category\//.test(p)) return 'categoryTopics';
197
156
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
- // DOM fallback
199
157
  if (document.querySelector(SEL.category)) return 'categories';
200
158
  if (document.querySelector(SEL.post)) return 'topic';
201
159
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
@@ -203,7 +161,7 @@
203
161
  }
204
162
 
205
163
  function getKind() {
206
- return state.kind || (state.kind = detectKind());
164
+ return S.kind || (S.kind = detectKind());
207
165
  }
208
166
 
209
167
  // ── DOM queries ────────────────────────────────────────────────────────────
@@ -215,7 +173,6 @@
215
173
  const el = all[i];
216
174
  if (!el.isConnected) continue;
217
175
  if (!el.querySelector('[component="post/content"]')) continue;
218
- // Skip nested quotes / parent posts
219
176
  const parent = el.parentElement?.closest(SEL.post);
220
177
  if (parent && parent !== el) continue;
221
178
  if (el.getAttribute('component') === 'post/parent') continue;
@@ -224,8 +181,8 @@
224
181
  return out;
225
182
  }
226
183
 
227
- function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
228
- function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
184
+ const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
185
+ const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
229
186
 
230
187
  // ── Anchor keys & wrap registry ────────────────────────────────────────────
231
188
 
@@ -235,7 +192,6 @@
235
192
  const v = el.getAttribute(attr);
236
193
  if (v != null && v !== '') return v;
237
194
  }
238
- // Positional fallback
239
195
  const children = el.parentElement?.children;
240
196
  if (!children) return 'i0';
241
197
  for (let i = 0; i < children.length; i++) {
@@ -247,86 +203,50 @@
247
203
  const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
248
204
 
249
205
  function findWrap(key) {
250
- const w = state.wrapByKey.get(key);
206
+ const w = S.wrapByKey.get(key);
251
207
  return w?.isConnected ? w : null;
252
208
  }
253
209
 
254
210
  function getWrapSet(klass) {
255
- let set = state.wrapsByClass.get(klass);
256
- if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
211
+ let set = S.wrapsByClass.get(klass);
212
+ if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
257
213
  return set;
258
214
  }
259
215
 
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.
216
+ // ── GC disconnected wraps ──────────────────────────────────────────────────
217
+ // NodeBB virtualizes posts off-viewport. The MutationObserver catches most
218
+ // removals, but this is a safety net for edge cases.
266
219
 
267
220
  function gcDisconnectedWraps() {
268
- // 1) Clean wrapByKey
269
- for (const [key, w] of Array.from(state.wrapByKey.entries())) {
270
- if (!w?.isConnected) state.wrapByKey.delete(key);
221
+ for (const [key, w] of S.wrapByKey) {
222
+ if (!w?.isConnected) S.wrapByKey.delete(key);
271
223
  }
272
-
273
- // 2) Clean wrapsByClass sets and free ids
274
- for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
275
- for (const w of Array.from(set)) {
224
+ for (const [klass, set] of S.wrapsByClass) {
225
+ for (const w of set) {
276
226
  if (w?.isConnected) continue;
277
227
  set.delete(w);
278
- try {
279
- const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
280
- if (Number.isFinite(id)) {
281
- state.mountedIds.delete(id);
282
- state.phState.delete(id);
283
- state.lastShow.delete(id);
284
- }
285
- } catch (_) {}
286
- }
287
- if (!set.size) state.wrapsByClass.delete(klass);
288
- }
289
-
290
- // 3) Authoritative rebuild of mountedIds from the live DOM
291
- try {
292
- const live = new Set();
293
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
294
- const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
295
- if (id > 0) live.add(id);
296
- }
297
- // Drop any ids that are no longer live
298
- for (const id of Array.from(state.mountedIds)) {
299
- if (!live.has(id)) {
300
- state.mountedIds.delete(id);
301
- state.phState.delete(id);
302
- state.lastShow.delete(id);
228
+ const id = parseInt(w.getAttribute?.(ATTR.WRAPID), 10);
229
+ if (Number.isFinite(id)) {
230
+ S.mountedIds.delete(id);
231
+ S.lastShow.delete(id);
303
232
  }
304
233
  }
305
- } catch (_) {}
234
+ if (!set.size) S.wrapsByClass.delete(klass);
235
+ }
306
236
  }
307
237
 
308
- // ── Wrap lifecycle detection ───────────────────────────────────────────────
238
+ // ── Wrap lifecycle ─────────────────────────────────────────────────────────
309
239
 
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
- */
314
240
  function wrapIsLive(wrap) {
315
241
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
316
242
  const key = wrap.getAttribute(ATTR.ANCHOR);
317
243
  if (!key) return false;
318
-
319
- // Fast path: registry match
320
- if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
321
-
322
- // Parse key
244
+ if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
323
245
  const colonIdx = key.indexOf(':');
324
246
  const klass = key.slice(0, colonIdx);
325
247
  const anchorId = key.slice(colonIdx + 1);
326
248
  const cfg = KIND[klass];
327
249
  if (!cfg) return false;
328
-
329
- // Sibling scan (cheap for adjacent anchors)
330
250
  const parent = wrap.parentElement;
331
251
  if (parent) {
332
252
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
@@ -336,8 +256,6 @@
336
256
  }
337
257
  }
338
258
  }
339
-
340
- // Global fallback (expensive, rare)
341
259
  try {
342
260
  return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
343
261
  } catch (_) { return false; }
@@ -354,57 +272,51 @@
354
272
  const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
355
273
  if (!ph || !isFilled(ph)) return false;
356
274
  wrap.classList.remove('is-empty');
357
- const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
358
- if (id > 0) state.phState.set(id, 'shown');
359
275
  return true;
360
276
  }
361
277
 
362
278
  function scheduleUncollapseChecks(wrap) {
363
279
  if (!wrap) return;
364
- const delays = [500, 1500, 3000, 7000, 15000];
365
- for (const ms of delays) {
366
- setTimeout(() => {
367
- try { clearEmptyIfFilled(wrap); } catch (_) {}
368
- }, ms);
280
+ for (const ms of [500, 3000, 10000]) {
281
+ setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
369
282
  }
370
283
  }
371
284
 
372
285
  // ── Pool management ────────────────────────────────────────────────────────
373
286
 
374
287
  function pickId(poolKey) {
375
- const pool = state.pools[poolKey];
288
+ const pool = S.pools[poolKey];
376
289
  if (!pool.length) return null;
377
290
  for (let t = 0; t < pool.length; t++) {
378
- const idx = state.cursors[poolKey] % pool.length;
379
- state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
291
+ const idx = S.cursors[poolKey] % pool.length;
292
+ S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
380
293
  const id = pool[idx];
381
- if (!state.mountedIds.has(id)) return id;
294
+ if (!S.mountedIds.has(id)) return id;
382
295
  }
383
296
  return null;
384
297
  }
385
298
 
386
299
  // ── Recycling ──────────────────────────────────────────────────────────────
300
+ // Per Ezoic docs: destroyPlaceholders(id) → remove HTML → fresh placeholder → showAds(id)
387
301
 
388
- /**
389
- * When pool is exhausted, recycle a wrap far above the viewport.
390
- * Sequence: destroy → delay → re-observe → enqueueShow
391
- */
392
302
  function recycleWrap(klass, targetEl, newKey) {
393
303
  const ez = window.ezstandalone;
394
304
  if (typeof ez?.destroyPlaceholders !== 'function' ||
395
- typeof ez?.define !== 'function' ||
396
- typeof ez?.displayMore !== 'function') return null;
305
+ typeof ez?.showAds !== 'function') return null;
397
306
 
398
307
  const vh = window.innerHeight || 800;
399
- const threshold = -vh;
308
+ const threshold = -(3 * vh);
309
+ const t = now();
400
310
  let bestEmpty = null, bestEmptyY = Infinity;
401
311
  let bestFull = null, bestFullY = Infinity;
402
312
 
403
- const wraps = state.wrapsByClass.get(klass);
313
+ const wraps = S.wrapsByClass.get(klass);
404
314
  if (!wraps) return null;
405
315
 
406
316
  for (const wrap of wraps) {
407
317
  try {
318
+ const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
319
+ if (t - created < TIMING.RECYCLE_MIN_AGE_MS) continue;
408
320
  const bottom = wrap.getBoundingClientRect().bottom;
409
321
  if (bottom > threshold) continue;
410
322
  if (!isFilled(wrap)) {
@@ -420,52 +332,44 @@
420
332
 
421
333
  const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
422
334
  if (!Number.isFinite(id)) return null;
423
-
424
335
  const oldKey = best.getAttribute(ATTR.ANCHOR);
425
336
 
426
- // Unobserve before moving to prevent stale showAds
337
+ // Unobserve before moving
427
338
  try {
428
339
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
429
- if (ph) state.io?.unobserve(ph);
340
+ if (ph) S.io?.unobserve(ph);
430
341
  } catch (_) {}
431
342
 
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
- });
447
-
448
- // Update registry
449
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
450
- state.wrapByKey.set(newKey, best);
343
+ // Ezoic recycle: destroy fresh DOM → showAds
344
+ const doRecycle = () => {
345
+ try { ez.destroyPlaceholders(id); } catch (_) {}
451
346
 
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
347
  setTimeout(() => {
459
- try { observePlaceholder(id); } catch (_) {}
460
- state.phState.set(id, 'new');
461
- try { enqueueShow(id); } catch (_) {}
348
+ mutate(() => {
349
+ best.setAttribute(ATTR.ANCHOR, newKey);
350
+ best.setAttribute(ATTR.CREATED, String(now()));
351
+ best.setAttribute(ATTR.SHOWN, '0');
352
+ best.classList.remove('is-empty');
353
+ best.replaceChildren();
354
+
355
+ const fresh = document.createElement('div');
356
+ fresh.id = `${PH_PREFIX}${id}`;
357
+ fresh.setAttribute('data-ezoic-id', String(id));
358
+ best.appendChild(fresh);
359
+ targetEl.insertAdjacentElement('afterend', best);
360
+ });
361
+
362
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
363
+ S.wrapByKey.set(newKey, best);
364
+
365
+ setTimeout(() => {
366
+ observePh(id);
367
+ enqueueShow(id);
368
+ }, TIMING.RECYCLE_DELAY_MS);
462
369
  }, TIMING.RECYCLE_DELAY_MS);
463
370
  };
464
371
 
465
- try {
466
- (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
467
- } catch (_) {}
468
-
372
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle(); } catch (_) {}
469
373
  return { id, wrap: best };
470
374
  }
471
375
 
@@ -479,11 +383,9 @@
479
383
  w.setAttribute(ATTR.CREATED, String(now()));
480
384
  w.setAttribute(ATTR.SHOWN, '0');
481
385
  w.style.cssText = 'width:100%;display:block';
482
-
483
386
  const ph = document.createElement('div');
484
387
  ph.id = `${PH_PREFIX}${id}`;
485
388
  ph.setAttribute('data-ezoic-id', String(id));
486
- ph.style.minHeight = '1px';
487
389
  w.appendChild(ph);
488
390
  return w;
489
391
  }
@@ -491,16 +393,12 @@
491
393
  function insertAfter(el, id, klass, key) {
492
394
  if (!el?.insertAdjacentElement) return null;
493
395
  if (findWrap(key)) return null;
494
- if (state.mountedIds.has(id)) return null;
495
- // Ensure no duplicate DOM element with same placeholder ID
496
- const existing = document.getElementById(`${PH_PREFIX}${id}`);
497
- if (existing?.isConnected) return null;
498
-
396
+ if (S.mountedIds.has(id)) return null;
397
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
499
398
  const w = makeWrap(id, klass, key);
500
399
  mutate(() => el.insertAdjacentElement('afterend', w));
501
- state.mountedIds.add(id);
502
- state.phState.set(id, 'new');
503
- state.wrapByKey.set(key, w);
400
+ S.mountedIds.add(id);
401
+ S.wrapByKey.set(key, w);
504
402
  getWrapSet(klass).add(w);
505
403
  return w;
506
404
  }
@@ -508,21 +406,17 @@
508
406
  function dropWrap(w) {
509
407
  try {
510
408
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
511
- if (ph instanceof Element) state.io?.unobserve(ph);
512
-
409
+ if (ph instanceof Element) S.io?.unobserve(ph);
513
410
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
514
411
  if (Number.isFinite(id)) {
515
- state.mountedIds.delete(id);
516
- state.phState.delete(id);
412
+ S.mountedIds.delete(id);
413
+ S.lastShow.delete(id);
517
414
  }
518
-
519
415
  const key = w.getAttribute(ATTR.ANCHOR);
520
- if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
521
-
522
- // Find the kind class to unregister
416
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
523
417
  for (const cls of w.classList) {
524
418
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
525
- state.wrapsByClass.get(cls)?.delete(w);
419
+ S.wrapsByClass.get(cls)?.delete(w);
526
420
  break;
527
421
  }
528
422
  }
@@ -531,33 +425,25 @@
531
425
  }
532
426
 
533
427
  // ── 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.
537
428
 
538
429
  function pruneOrphansBetween() {
539
430
  const klass = 'ezoic-ad-between';
540
431
  const cfg = KIND[klass];
541
- const wraps = state.wrapsByClass.get(klass);
432
+ const wraps = S.wrapsByClass.get(klass);
542
433
  if (!wraps?.size) return;
543
434
 
544
- // Build set of live anchor IDs
545
435
  const liveAnchors = new Set();
546
436
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
547
437
  const v = el.getAttribute(cfg.anchorAttr);
548
438
  if (v) liveAnchors.add(v);
549
439
  }
550
-
551
440
  const t = now();
552
441
  for (const w of wraps) {
553
442
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
554
443
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
555
-
556
444
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
557
445
  const sid = key.slice(klass.length + 1);
558
- if (!sid || !liveAnchors.has(sid)) {
559
- mutate(() => dropWrap(w));
560
- }
446
+ if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
561
447
  }
562
448
  }
563
449
 
@@ -569,7 +455,6 @@
569
455
  const v = el.getAttribute(attr);
570
456
  if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
571
457
  }
572
- // Positional fallback
573
458
  const fullSel = KIND[klass]?.sel ?? '';
574
459
  let i = 0;
575
460
  for (const s of el.parentElement?.children ?? []) {
@@ -582,29 +467,21 @@
582
467
  function injectBetween(klass, items, interval, showFirst, poolKey) {
583
468
  if (!items.length) return 0;
584
469
  let inserted = 0;
585
-
586
470
  for (const el of items) {
587
- if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
471
+ if (inserted >= MAX_INSERTS_RUN) break;
588
472
  if (!el?.isConnected) continue;
589
-
590
473
  const ord = ordinal(klass, el);
591
474
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
592
475
  if (adjacentWrap(el)) continue;
593
-
594
476
  const key = anchorKey(klass, el);
595
477
  if (findWrap(key)) continue;
596
-
597
478
  const id = pickId(poolKey);
598
479
  if (id) {
599
480
  const w = insertAfter(el, id, klass, key);
600
- if (w) {
601
- observePlaceholder(id);
602
- if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
603
- inserted++;
604
- }
481
+ if (w) { observePh(id); inserted++; }
605
482
  } else {
606
483
  const recycled = recycleWrap(klass, el, key);
607
- if (!recycled) break; // Pool truly exhausted
484
+ if (!recycled) break;
608
485
  inserted++;
609
486
  }
610
487
  }
@@ -614,69 +491,58 @@
614
491
  // ── IntersectionObserver ───────────────────────────────────────────────────
615
492
 
616
493
  function getIO() {
617
- if (state.io) return state.io;
494
+ if (S.io) return S.io;
618
495
  try {
619
- state.io = new IntersectionObserver(entries => {
620
- for (const entry of entries) {
621
- if (!entry.isIntersecting) continue;
622
- if (entry.target instanceof Element) state.io?.unobserve(entry.target);
623
- const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
496
+ S.io = new IntersectionObserver(entries => {
497
+ for (const e of entries) {
498
+ if (!e.isIntersecting) continue;
499
+ if (e.target instanceof Element) S.io?.unobserve(e.target);
500
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
624
501
  if (id > 0) enqueueShow(id);
625
502
  }
626
503
  }, {
627
504
  root: null,
628
- rootMargin: isMobile() ? IO_MARGIN.MOBILE : IO_MARGIN.DESKTOP,
505
+ rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP,
629
506
  threshold: 0,
630
507
  });
631
- } catch (_) { state.io = null; }
632
- return state.io;
508
+ } catch (_) { S.io = null; }
509
+ return S.io;
633
510
  }
634
511
 
635
- function observePlaceholder(id) {
512
+ function observePh(id) {
636
513
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
637
- if (ph?.isConnected) {
638
- try { getIO()?.observe(ph); } catch (_) {}
639
- }
514
+ if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
640
515
  }
641
516
 
642
517
  // ── Show queue ─────────────────────────────────────────────────────────────
643
518
 
644
519
  function enqueueShow(id) {
645
520
  if (!id || isBlocked()) return;
646
- const st = state.phState.get(id);
647
- if (st === 'show-queued' || st === 'shown') return;
648
- if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
649
-
650
- if (state.inflight >= LIMITS.MAX_INFLIGHT) {
651
- if (!state.pendingSet.has(id)) {
652
- state.pending.push(id);
653
- state.pendingSet.add(id);
654
- state.phState.set(id, 'show-queued');
655
- }
521
+ if (now() - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
522
+ if (S.inflight >= MAX_INFLIGHT) {
523
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
656
524
  return;
657
525
  }
658
- state.phState.set(id, 'show-queued');
659
526
  startShow(id);
660
527
  }
661
528
 
662
529
  function drainQueue() {
663
530
  if (isBlocked()) return;
664
- while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
665
- const id = state.pending.shift();
666
- state.pendingSet.delete(id);
531
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
532
+ const id = S.pending.shift();
533
+ S.pendingSet.delete(id);
667
534
  startShow(id);
668
535
  }
669
536
  }
670
537
 
671
538
  function startShow(id) {
672
539
  if (!id || isBlocked()) return;
673
- state.inflight++;
674
-
675
- let released = false;
540
+ S.inflight++;
541
+ let done = false;
676
542
  const release = () => {
677
- if (released) return;
678
- released = true;
679
- state.inflight = Math.max(0, state.inflight - 1);
543
+ if (done) return;
544
+ done = true;
545
+ S.inflight = Math.max(0, S.inflight - 1);
680
546
  drainQueue();
681
547
  };
682
548
  const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
@@ -684,69 +550,49 @@
684
550
  requestAnimationFrame(() => {
685
551
  try {
686
552
  if (isBlocked()) { clearTimeout(timer); return release(); }
687
-
688
553
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
689
- if (!ph?.isConnected) {
690
- state.phState.delete(id);
691
- clearTimeout(timer);
692
- return release();
693
- }
694
-
695
- if (isFilled(ph) || isPlaceholderUsed(ph)) {
696
- state.phState.set(id, 'shown');
697
- clearTimeout(timer);
698
- return release();
699
- }
554
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
700
555
 
701
556
  const t = now();
702
- if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
703
- clearTimeout(timer);
704
- return release();
705
- }
706
- state.lastShow.set(id, t);
557
+ if (t - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
558
+ S.lastShow.set(id, t);
707
559
 
708
- try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
709
- state.phState.set(id, 'shown');
560
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
561
+ try { wrap?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
710
562
 
711
563
  window.ezstandalone = window.ezstandalone || {};
712
564
  const ez = window.ezstandalone;
713
-
714
565
  const doShow = () => {
715
- const wrap = ph.closest(`.${WRAP_CLASS}`);
716
566
  try { ez.showAds(id); } catch (_) {}
717
567
  if (wrap) scheduleUncollapseChecks(wrap);
718
568
  scheduleEmptyCheck(id, t);
719
569
  setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
720
570
  };
721
-
722
571
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
723
- } catch (_) {
724
- clearTimeout(timer);
725
- release();
726
- }
572
+ } catch (_) { clearTimeout(timer); release(); }
727
573
  });
728
574
  }
729
575
 
730
576
  function scheduleEmptyCheck(id, showTs) {
731
- setTimeout(() => {
732
- try {
733
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
734
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
735
- if (!wrap || !ph?.isConnected) return;
736
- // Skip if a newer show happened since
737
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
738
- if (clearEmptyIfFilled(wrap)) return;
739
- wrap.classList.add('is-empty');
740
- } catch (_) {}
741
- }, TIMING.EMPTY_CHECK_MS);
577
+ for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
578
+ setTimeout(() => {
579
+ try {
580
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
581
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
582
+ if (!wrap || !ph?.isConnected) return;
583
+ if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
584
+ if (clearEmptyIfFilled(wrap)) return;
585
+ // Don't collapse if GPT slot exists (still loading)
586
+ if (ph.querySelector('[id^="div-gpt-ad"]')) return;
587
+ if (ph.offsetHeight > 10) return;
588
+ wrap.classList.add('is-empty');
589
+ } catch (_) {}
590
+ }, delay);
591
+ }
742
592
  }
743
593
 
744
594
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
745
- //
746
- // Intercepts ez.showAds() to:
747
- // - block calls during navigation transitions
748
- // - filter out disconnected placeholders
749
- // - batch calls for efficiency
595
+ // Matches v50: individual calls, no batching, pass-through no-arg calls.
750
596
 
751
597
  function patchShowAds() {
752
598
  const apply = () => {
@@ -755,52 +601,22 @@
755
601
  const ez = window.ezstandalone;
756
602
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
757
603
  window.__nbbEzPatched = true;
758
-
759
604
  const orig = ez.showAds.bind(ez);
760
- const queue = new Set();
761
- let flushTimer = null;
762
-
763
- const flush = () => {
764
- flushTimer = null;
765
- if (isBlocked() || !queue.size) return;
766
-
767
- const ids = Array.from(queue).sort((a, b) => a - b);
768
- queue.clear();
769
-
770
- const valid = ids.filter(id => {
771
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
772
- if (!ph?.isConnected) { state.phState.delete(id); return false; }
773
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
774
- return true;
775
- });
776
-
777
- for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
778
- const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
779
- try { orig(...chunk); } catch (_) {
780
- for (const cid of chunk) { try { orig(cid); } catch (_) {} }
781
- }
782
- }
783
- };
784
-
785
605
  ez.showAds = function (...args) {
606
+ if (args.length === 0) return orig();
786
607
  if (isBlocked()) return;
787
608
  const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
609
+ const seen = new Set();
788
610
  for (const v of ids) {
789
611
  const id = parseInt(v, 10);
790
- if (!Number.isFinite(id) || id <= 0) continue;
791
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
792
- if (!ph?.isConnected) continue;
793
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
794
- state.phState.set(id, 'show-queued');
795
- queue.add(id);
796
- }
797
- if (queue.size && !flushTimer) {
798
- flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
612
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
613
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
614
+ seen.add(id);
615
+ try { orig(id); } catch (_) {}
799
616
  }
800
617
  };
801
618
  } catch (_) {}
802
619
  };
803
-
804
620
  apply();
805
621
  if (!window.__nbbEzPatched) {
806
622
  window.ezstandalone = window.ezstandalone || {};
@@ -812,9 +628,7 @@
812
628
 
813
629
  async function runCore() {
814
630
  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.
631
+ patchShowAds();
818
632
  try { gcDisconnectedWraps(); } catch (_) {}
819
633
 
820
634
  const cfg = await fetchConfig();
@@ -831,34 +645,26 @@
831
645
  };
832
646
 
833
647
  if (kind === 'topic') {
834
- return exec(
835
- 'ezoic-ad-message', getPosts,
836
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
837
- );
648
+ return exec('ezoic-ad-message', getPosts,
649
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
838
650
  }
839
-
840
651
  if (kind === 'categoryTopics') {
841
652
  pruneOrphansBetween();
842
- return exec(
843
- 'ezoic-ad-between', getTopics,
844
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
845
- );
653
+ return exec('ezoic-ad-between', getTopics,
654
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
846
655
  }
847
-
848
- return exec(
849
- 'ezoic-ad-categories', getCategories,
850
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
851
- );
656
+ return exec('ezoic-ad-categories', getCategories,
657
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
852
658
  }
853
659
 
854
660
  // ── Scheduler & burst ──────────────────────────────────────────────────────
855
661
 
856
662
  function scheduleRun(cb) {
857
- if (state.runQueued) return;
858
- state.runQueued = true;
663
+ if (S.runQueued) return;
664
+ S.runQueued = true;
859
665
  requestAnimationFrame(async () => {
860
- state.runQueued = false;
861
- if (state.pageKey && pageKey() !== state.pageKey) return;
666
+ S.runQueued = false;
667
+ if (S.pageKey && pageKey() !== S.pageKey) return;
862
668
  let n = 0;
863
669
  try { n = await runCore(); } catch (_) {}
864
670
  try { cb?.(n); } catch (_) {}
@@ -868,23 +674,20 @@
868
674
  function requestBurst() {
869
675
  if (isBlocked()) return;
870
676
  const t = now();
871
- if (t - state.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
872
- state.lastBurstTs = t;
873
- state.pageKey = pageKey();
874
- state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
875
-
876
- if (state.burstActive) return;
877
- state.burstActive = true;
878
- state.burstCount = 0;
879
-
677
+ if (t - S.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
678
+ S.lastBurstTs = t;
679
+ S.pageKey = pageKey();
680
+ S.burstDeadline = t + BURST_WINDOW_MS;
681
+ if (S.burstActive) return;
682
+ S.burstActive = true;
683
+ S.burstCount = 0;
880
684
  const step = () => {
881
- if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
882
- state.burstActive = false;
883
- return;
685
+ if (pageKey() !== S.pageKey || isBlocked() || now() > S.burstDeadline || S.burstCount >= MAX_BURST_STEPS) {
686
+ S.burstActive = false; return;
884
687
  }
885
- state.burstCount++;
688
+ S.burstCount++;
886
689
  scheduleRun(n => {
887
- if (!n && !state.pending.length) { state.burstActive = false; return; }
690
+ if (!n && !S.pending.length) { S.burstActive = false; return; }
888
691
  setTimeout(step, n > 0 ? 150 : 300);
889
692
  });
890
693
  };
@@ -894,54 +697,42 @@
894
697
  // ── Cleanup on navigation ──────────────────────────────────────────────────
895
698
 
896
699
  function cleanup() {
897
- state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
700
+ S.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
898
701
  mutate(() => {
899
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
900
- dropWrap(w);
901
- }
702
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
902
703
  });
903
- state.cfg = null;
904
- state.poolsReady = false;
905
- state.pools = { topics: [], posts: [], categories: [] };
906
- state.cursors = { topics: 0, posts: 0, categories: 0 };
907
- state.mountedIds.clear();
908
- state.lastShow.clear();
909
- state.wrapByKey.clear();
910
- state.wrapsByClass.clear();
911
- state.kind = null;
912
- state.phState.clear();
913
- state.inflight = 0;
914
- state.pending = [];
915
- state.pendingSet.clear();
916
- state.burstActive = false;
917
- state.runQueued = false;
918
- state.firstShown = false;
704
+ S.cfg = null;
705
+ S.poolsReady = false;
706
+ S.pools = { topics: [], posts: [], categories: [] };
707
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
708
+ S.mountedIds.clear();
709
+ S.lastShow.clear();
710
+ S.wrapByKey.clear();
711
+ S.wrapsByClass.clear();
712
+ S.kind = null;
713
+ S.inflight = 0;
714
+ S.pending = [];
715
+ S.pendingSet.clear();
716
+ S.burstActive = false;
717
+ S.runQueued = false;
919
718
  }
920
719
 
921
720
  // ── MutationObserver ───────────────────────────────────────────────────────
922
- //
923
- // Scoped to detect: (1) ad fill events in wraps, (2) new content items
924
721
 
925
722
  function ensureDomObserver() {
926
- if (state.domObs) return;
927
-
928
- state.domObs = new MutationObserver(muts => {
929
- if (state.mutGuard > 0 || isBlocked()) return;
930
-
723
+ if (S.domObs) return;
724
+ S.domObs = new MutationObserver(muts => {
725
+ if (S.mutGuard > 0 || isBlocked()) return;
931
726
  let needsBurst = false;
932
-
933
- // Determine relevant selectors for current page kind
934
727
  const kind = getKind();
935
728
  const relevantSels =
936
- kind === 'topic' ? [SEL.post] :
937
- kind === 'categoryTopics'? [SEL.topic] :
938
- kind === 'categories' ? [SEL.category] :
939
- [SEL.post, SEL.topic, SEL.category];
940
-
729
+ kind === 'topic' ? [SEL.post] :
730
+ kind === 'categoryTopics' ? [SEL.topic] :
731
+ kind === 'categories' ? [SEL.category] :
732
+ [SEL.post, SEL.topic, SEL.category];
941
733
  for (const m of muts) {
942
734
  if (m.type !== 'childList') continue;
943
-
944
- // If NodeBB removed wraps as part of virtualization, free ids immediately
735
+ // Free IDs from wraps removed by NodeBB virtualization
945
736
  for (const node of m.removedNodes) {
946
737
  if (!(node instanceof Element)) continue;
947
738
  try {
@@ -949,17 +740,13 @@
949
740
  dropWrap(node);
950
741
  } else {
951
742
  const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
952
- if (wraps?.length) {
953
- for (const w of wraps) dropWrap(w);
954
- }
743
+ if (wraps?.length) for (const w of wraps) dropWrap(w);
955
744
  }
956
745
  } catch (_) {}
957
746
  }
958
-
959
747
  for (const node of m.addedNodes) {
960
748
  if (!(node instanceof Element)) continue;
961
-
962
- // Check for ad fill events in wraps
749
+ // Ad fill detection → uncollapse
963
750
  try {
964
751
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
965
752
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -967,88 +754,59 @@
967
754
  if (wrap) clearEmptyIfFilled(wrap);
968
755
  }
969
756
  } catch (_) {}
970
-
971
- // Check for new content items (posts, topics, categories)
757
+ // Re-observe wraps re-inserted by NodeBB virtualization
758
+ try {
759
+ const reinserted = node.classList?.contains(WRAP_CLASS)
760
+ ? [node]
761
+ : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
762
+ for (const wrap of reinserted) {
763
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
764
+ if (id > 0) {
765
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
766
+ if (ph) try { getIO()?.observe(ph); } catch (_) {}
767
+ }
768
+ }
769
+ } catch (_) {}
770
+ // New content detection
972
771
  if (!needsBurst) {
973
772
  for (const sel of relevantSels) {
974
773
  try {
975
- if (node.matches(sel) || node.querySelector(sel)) {
976
- needsBurst = true;
977
- break;
978
- }
774
+ if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
979
775
  } catch (_) {}
980
776
  }
981
777
  }
982
778
  }
983
779
  if (needsBurst) break;
984
780
  }
985
-
986
781
  if (needsBurst) requestBurst();
987
782
  });
988
-
989
- try {
990
- state.domObs.observe(document.body, { childList: true, subtree: true });
991
- } catch (_) {}
783
+ try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
992
784
  }
993
785
 
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.
786
+ // ── TCF / CMP Protection ───────────────────────────────────────────────────
1018
787
 
1019
788
  function ensureTcfLocator() {
1020
789
  if (!window.__tcfapi && !window.__cmp) return;
1021
-
1022
790
  const LOCATOR_ID = '__tcfapiLocator';
1023
-
1024
- // Create or relocate the locator iframe into <head> for protection
1025
791
  const ensureInHead = () => {
1026
792
  let existing = document.getElementById(LOCATOR_ID);
1027
793
  if (existing) {
1028
- // If it's in <body>, move it to <head> where ajaxify can't reach it
1029
794
  if (existing.parentElement !== document.head) {
1030
795
  try { document.head.appendChild(existing); } catch (_) {}
1031
796
  }
1032
- return existing;
797
+ return;
1033
798
  }
1034
- // Create fresh
1035
799
  const f = document.createElement('iframe');
1036
800
  f.style.display = 'none';
1037
801
  f.id = f.name = LOCATOR_ID;
1038
802
  try { document.head.appendChild(f); } catch (_) {
1039
- // Fallback to body if head insertion fails
1040
803
  (document.body || document.documentElement).appendChild(f);
1041
804
  }
1042
- return f;
1043
805
  };
1044
-
1045
806
  ensureInHead();
1046
807
 
1047
- // Layer 2: Guard the CMP API calls against null contentWindow
1048
808
  if (!window.__nbbCmpGuarded) {
1049
809
  window.__nbbCmpGuarded = true;
1050
-
1051
- // Wrap __tcfapi
1052
810
  if (typeof window.__tcfapi === 'function') {
1053
811
  const origTcf = window.__tcfapi;
1054
812
  window.__tcfapi = function (cmd, version, cb, param) {
@@ -1057,23 +815,18 @@
1057
815
  try { cb?.(...args); } catch (_) {}
1058
816
  }, param);
1059
817
  } catch (e) {
1060
- // If the error is the null postMessage/addtlConsent, swallow it
1061
818
  if (e?.message?.includes('null')) {
1062
- // Re-ensure locator exists, then retry once
1063
819
  ensureInHead();
1064
820
  try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
1065
821
  }
1066
822
  }
1067
823
  };
1068
824
  }
1069
-
1070
- // Wrap __cmp (legacy CMP v1 API)
1071
825
  if (typeof window.__cmp === 'function') {
1072
826
  const origCmp = window.__cmp;
1073
827
  window.__cmp = function (...args) {
1074
- try {
1075
- return origCmp.apply(this, args);
1076
- } catch (e) {
828
+ try { return origCmp.apply(this, args); }
829
+ catch (e) {
1077
830
  if (e?.message?.includes('null')) {
1078
831
  ensureInHead();
1079
832
  try { return origCmp.apply(this, args); } catch (_) {}
@@ -1083,43 +836,47 @@
1083
836
  }
1084
837
  }
1085
838
 
1086
- // Layer 3: MutationObserver to immediately restore if removed
1087
839
  if (!window.__nbbTcfObs) {
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();
840
+ window.__nbbTcfObs = new MutationObserver(() => {
841
+ if (!document.getElementById(LOCATOR_ID)) ensureInHead();
1093
842
  });
1094
- // Observe body direct children only (the most likely removal point)
1095
843
  try {
1096
844
  window.__nbbTcfObs.observe(document.body || document.documentElement, {
1097
- childList: true,
1098
- subtree: false,
845
+ childList: true, subtree: false,
1099
846
  });
1100
- } catch (_) {}
1101
- // Also observe <head> in case something cleans it
1102
- try {
1103
847
  if (document.head) {
1104
- window.__nbbTcfObs.observe(document.head, {
1105
- childList: true,
1106
- subtree: false,
1107
- });
848
+ window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false });
1108
849
  }
1109
850
  } catch (_) {}
1110
851
  }
1111
852
  }
1112
853
 
854
+ // ── aria-hidden protection ─────────────────────────────────────────────────
855
+
856
+ function protectAriaHidden() {
857
+ if (window.__nbbAriaObs) return;
858
+ const remove = () => {
859
+ try {
860
+ if (document.body.getAttribute('aria-hidden') === 'true') {
861
+ document.body.removeAttribute('aria-hidden');
862
+ }
863
+ } catch (_) {}
864
+ };
865
+ remove();
866
+ window.__nbbAriaObs = new MutationObserver(remove);
867
+ try {
868
+ window.__nbbAriaObs.observe(document.body, {
869
+ attributes: true, attributeFilter: ['aria-hidden'],
870
+ });
871
+ } catch (_) {}
872
+ }
873
+
1113
874
  // ── Console muting ─────────────────────────────────────────────────────────
1114
- //
1115
- // Mute noisy Ezoic warnings that are expected in infinite scroll context.
1116
- // Uses startsWith checks instead of includes for performance.
1117
875
 
1118
876
  function muteConsole() {
1119
877
  if (window.__nbbEzMuted) return;
1120
878
  window.__nbbEzMuted = true;
1121
-
1122
- const PREFIXES = [
879
+ const MUTED = [
1123
880
  '[EzoicAds JS]: Placeholder Id',
1124
881
  'No valid placeholders for loadMore',
1125
882
  'cannot call refresh on the same page',
@@ -1127,19 +884,23 @@
1127
884
  'Debugger iframe already exists',
1128
885
  '[CMP] Error in custom getTCData',
1129
886
  'vignette: no interstitial API',
887
+ 'Ezoic JS-Enable should only ever',
888
+ '### sending slotDestroyed',
889
+ 'Error loading identity bridging',
890
+ `with id ${PH_PREFIX}`,
891
+ 'adsbygoogle.push() error: All',
892
+ 'has already been defined',
893
+ 'bad response. Status',
894
+ 'slotDestroyed event',
895
+ 'identity bridging',
1130
896
  ];
1131
- const PH_PATTERN = `with id ${PH_PREFIX}`;
1132
-
1133
897
  for (const method of ['log', 'info', 'warn', 'error']) {
1134
898
  const orig = console[method];
1135
899
  if (typeof orig !== 'function') continue;
1136
900
  console[method] = function (...args) {
1137
901
  if (typeof args[0] === 'string') {
1138
902
  const msg = args[0];
1139
- for (const prefix of PREFIXES) {
1140
- if (msg.startsWith(prefix)) return;
1141
- }
1142
- if (msg.includes(PH_PATTERN)) return;
903
+ for (const p of MUTED) { if (msg.includes(p)) return; }
1143
904
  }
1144
905
  return orig.apply(console, args);
1145
906
  };
@@ -1147,33 +908,26 @@
1147
908
  }
1148
909
 
1149
910
  // ── Network warmup ─────────────────────────────────────────────────────────
1150
- // Run once per session — preconnect hints are in <head> via server-side injection
1151
-
1152
- let _networkWarmed = false;
1153
911
 
912
+ const _warmed = new Set();
1154
913
  function warmNetwork() {
1155
- if (_networkWarmed) return;
1156
- _networkWarmed = true;
1157
-
1158
914
  const head = document.head;
1159
915
  if (!head) return;
1160
-
1161
- const hints = [
916
+ for (const [rel, href, cors] of [
1162
917
  ['preconnect', 'https://g.ezoic.net', true ],
1163
918
  ['preconnect', 'https://go.ezoic.net', true ],
1164
919
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
1165
920
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
1166
921
  ['dns-prefetch', 'https://g.ezoic.net', false],
1167
922
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1168
- ];
1169
-
1170
- for (const [rel, href, cors] of hints) {
1171
- if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1172
- const link = document.createElement('link');
1173
- link.rel = rel;
1174
- link.href = href;
1175
- if (cors) link.crossOrigin = 'anonymous';
1176
- head.appendChild(link);
923
+ ]) {
924
+ const k = `${rel}|${href}`;
925
+ if (_warmed.has(k)) continue;
926
+ _warmed.add(k);
927
+ const l = document.createElement('link');
928
+ l.rel = rel; l.href = href;
929
+ if (cors) l.crossOrigin = 'anonymous';
930
+ head.appendChild(l);
1177
931
  }
1178
932
  }
1179
933
 
@@ -1182,50 +936,30 @@
1182
936
  function bindNodeBB() {
1183
937
  const $ = window.jQuery;
1184
938
  if (!$) return;
1185
-
1186
939
  $(window).off('.nbbEzoic');
1187
-
1188
940
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1189
-
1190
941
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1191
- state.pageKey = pageKey();
1192
- state.kind = null;
1193
- state.blockedUntil = 0;
1194
-
1195
- // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1196
- try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1197
-
1198
- muteConsole();
1199
- ensureTcfLocator();
1200
- warmNetwork();
1201
- patchShowAds();
1202
- getIO();
1203
- ensureDomObserver();
942
+ S.pageKey = pageKey();
943
+ S.kind = null;
944
+ S.blockedUntil = 0;
945
+ muteConsole(); ensureTcfLocator(); protectAriaHidden();
946
+ warmNetwork(); patchShowAds(); getIO(); ensureDomObserver();
1204
947
  requestBurst();
1205
948
  });
1206
949
 
1207
- // Content-loaded events trigger burst
1208
950
  const burstEvents = [
1209
- 'action:ajaxify.contentLoaded',
1210
- 'action:posts.loaded',
1211
- 'action:topics.loaded',
1212
- 'action:categories.loaded',
1213
- 'action:category.loaded',
1214
- 'action:topic.loaded',
951
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded',
952
+ 'action:topics.loaded', 'action:categories.loaded',
953
+ 'action:category.loaded', 'action:topic.loaded',
1215
954
  ].map(e => `${e}.nbbEzoic`).join(' ');
1216
-
1217
955
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1218
956
 
1219
- // Also bind via NodeBB hooks module (for compatibility)
1220
957
  try {
1221
958
  require(['hooks'], hooks => {
1222
959
  if (typeof hooks?.on !== 'function') return;
1223
960
  for (const ev of [
1224
- 'action:ajaxify.end',
1225
- 'action:posts.loaded',
1226
- 'action:topics.loaded',
1227
- 'action:categories.loaded',
1228
- 'action:topic.loaded',
961
+ 'action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
962
+ 'action:categories.loaded', 'action:topic.loaded',
1229
963
  ]) {
1230
964
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
1231
965
  }
@@ -1238,25 +972,23 @@
1238
972
  window.addEventListener('scroll', () => {
1239
973
  if (ticking) return;
1240
974
  ticking = true;
1241
- requestAnimationFrame(() => {
1242
- ticking = false;
1243
- requestBurst();
1244
- });
975
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
1245
976
  }, { passive: true });
1246
977
  }
1247
978
 
1248
979
  // ── Boot ───────────────────────────────────────────────────────────────────
1249
980
 
1250
- state.pageKey = pageKey();
981
+ S.pageKey = pageKey();
1251
982
  muteConsole();
1252
983
  ensureTcfLocator();
984
+ protectAriaHidden();
1253
985
  warmNetwork();
1254
986
  patchShowAds();
1255
987
  getIO();
1256
988
  ensureDomObserver();
1257
989
  bindNodeBB();
1258
990
  bindScroll();
1259
- state.blockedUntil = 0;
991
+ S.blockedUntil = 0;
1260
992
  requestBurst();
1261
993
 
1262
994
  })();