nodebb-plugin-ezoic-infinite 1.8.65 → 1.8.66

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.
package/library.js CHANGED
@@ -235,4 +235,4 @@ plugin.init = async ({ router, middleware }) => {
235
235
  });
236
236
  };
237
237
 
238
- module.exports = plugin;
238
+ module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.65",
3
+ "version": "1.8.66",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.3.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.2.0
3
3
  *
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
4
+ * Based on working v2-fixed with targeted fixes:
5
+ * - Ezoic API: showAds() + destroyPlaceholders() only (per official docs)
6
+ * - cleanup(): destroyAll() before DOM removal, only on real page change
7
+ * - Recycling: conservative threshold (-3vh), age/inflight guards
8
+ * - aria-hidden: MutationObserver protection
9
+ * - Empty check: more conservative timing and GPT slot awareness
10
10
  */
11
11
  (function nbbEzoicInfinite() {
12
12
  'use strict';
@@ -24,25 +24,30 @@
24
24
  };
25
25
 
26
26
  const TIMING = {
27
- EMPTY_CHECK_MS_1: 30_000,
28
- EMPTY_CHECK_MS_2: 60_000,
29
- MIN_PRUNE_AGE_MS: 8_000,
30
- RECYCLE_MIN_AGE_MS: 5_000,
31
- SHOW_THROTTLE_MS: 900,
32
- BURST_COOLDOWN_MS: 200,
33
- BLOCK_DURATION_MS: 1_500,
34
- SHOW_TIMEOUT_MS: 7_000,
35
- SHOW_RELEASE_MS: 700,
36
- RECYCLE_DELAY_MS: 450,
27
+ EMPTY_CHECK_EARLY_MS: 30_000,
28
+ EMPTY_CHECK_LATE_MS: 60_000,
29
+ MIN_PRUNE_AGE_MS: 8_000,
30
+ SHOW_THROTTLE_MS: 900,
31
+ BURST_COOLDOWN_MS: 200,
32
+ BLOCK_DURATION_MS: 1_500,
33
+ SHOW_TIMEOUT_MS: 7_000,
34
+ SHOW_RELEASE_MS: 700,
35
+ BATCH_FLUSH_MS: 80,
36
+ RECYCLE_DELAY_MS: 450,
37
37
  };
38
38
 
39
- const MAX_INSERTS_RUN = 6;
40
- const MAX_INFLIGHT = 4;
41
- const MAX_BURST_STEPS = 8;
42
- const BURST_WINDOW_MS = 2_000;
39
+ const LIMITS = {
40
+ MAX_INSERTS_RUN: 6,
41
+ MAX_INFLIGHT: 4,
42
+ BATCH_SIZE: 3,
43
+ MAX_BURST_STEPS: 8,
44
+ BURST_WINDOW_MS: 2_000,
45
+ };
43
46
 
44
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
45
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
47
+ const IO_MARGIN = {
48
+ DESKTOP: '2500px 0px 2500px 0px',
49
+ MOBILE: '3500px 0px 3500px 0px',
50
+ };
46
51
 
47
52
  const SEL = {
48
53
  post: '[component="post"][data-pid]',
@@ -57,26 +62,39 @@
57
62
  };
58
63
 
59
64
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
65
+ const RECYCLE_MIN_AGE_MS = 5_000;
60
66
 
61
67
  // ── Utility ────────────────────────────────────────────────────────────────
62
68
 
63
69
  const now = () => Date.now();
64
70
  const isMobile = () => window.innerWidth < 768;
65
71
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
66
- const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
72
+
73
+ function isFilled(node) {
74
+ return node?.querySelector?.(FILL_SEL) != null;
75
+ }
76
+
77
+ function isPlaceholderUsed(ph) {
78
+ if (!ph?.isConnected) return false;
79
+ return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
80
+ }
67
81
 
68
82
  function parseIds(raw) {
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); }
83
+ const out = [];
84
+ const seen = new Set();
85
+ for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
86
+ const n = parseInt(line, 10);
87
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
88
+ seen.add(n);
89
+ out.push(n);
90
+ }
73
91
  }
74
92
  return out;
75
93
  }
76
94
 
77
95
  // ── State ──────────────────────────────────────────────────────────────────
78
96
 
79
- const S = {
97
+ const state = {
80
98
  pageKey: null,
81
99
  kind: null,
82
100
  cfg: null,
@@ -85,17 +103,18 @@
85
103
  pools: { topics: [], posts: [], categories: [] },
86
104
  cursors: { topics: 0, posts: 0, categories: 0 },
87
105
 
88
- mountedIds: new Set(),
89
- lastShow: new Map(),
106
+ mountedIds: new Set(),
107
+ phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
108
+ lastShow: new Map(), // id → timestamp
90
109
 
91
- wrapByKey: new Map(), // anchorKey → wrap element
92
- wrapsByClass: new Map(), // kindClass → Set<wrap>
110
+ wrapByKey: new Map(),
111
+ wrapsByClass: new Map(),
93
112
 
94
113
  io: null,
95
114
  domObs: null,
96
115
 
97
- mutGuard: 0,
98
- blockedUntil: 0,
116
+ mutGuard: 0,
117
+ blockedUntil: 0,
99
118
 
100
119
  inflight: 0,
101
120
  pending: [],
@@ -106,36 +125,40 @@
106
125
  burstDeadline: 0,
107
126
  burstCount: 0,
108
127
  lastBurstTs: 0,
128
+ firstShown: false,
109
129
  };
110
130
 
111
- const isBlocked = () => now() < S.blockedUntil;
131
+ const isBlocked = () => now() < state.blockedUntil;
112
132
 
113
133
  function mutate(fn) {
114
- S.mutGuard++;
115
- try { fn(); } finally { S.mutGuard--; }
134
+ state.mutGuard++;
135
+ try { fn(); } finally { state.mutGuard--; }
116
136
  }
117
137
 
118
138
  // ── Config ─────────────────────────────────────────────────────────────────
119
139
 
120
140
  async function fetchConfig() {
121
- if (S.cfg) return S.cfg;
141
+ if (state.cfg) return state.cfg;
122
142
  try {
123
143
  const inline = window.__nbbEzoicCfg;
124
- if (inline && typeof inline === 'object') { S.cfg = inline; return S.cfg; }
144
+ if (inline && typeof inline === 'object') {
145
+ state.cfg = inline;
146
+ return state.cfg;
147
+ }
125
148
  } catch (_) {}
126
149
  try {
127
150
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
128
- if (r.ok) S.cfg = await r.json();
151
+ if (r.ok) state.cfg = await r.json();
129
152
  } catch (_) {}
130
- return S.cfg;
153
+ return state.cfg;
131
154
  }
132
155
 
133
156
  function initPools(cfg) {
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;
157
+ if (state.poolsReady) return;
158
+ state.pools.topics = parseIds(cfg.placeholderIds);
159
+ state.pools.posts = parseIds(cfg.messagePlaceholderIds);
160
+ state.pools.categories = parseIds(cfg.categoryPlaceholderIds);
161
+ state.poolsReady = true;
139
162
  }
140
163
 
141
164
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -161,7 +184,7 @@
161
184
  }
162
185
 
163
186
  function getKind() {
164
- return S.kind || (S.kind = detectKind());
187
+ return state.kind || (state.kind = detectKind());
165
188
  }
166
189
 
167
190
  // ── DOM queries ────────────────────────────────────────────────────────────
@@ -181,8 +204,8 @@
181
204
  return out;
182
205
  }
183
206
 
184
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
185
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
207
+ function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
208
+ function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
186
209
 
187
210
  // ── Anchor keys & wrap registry ────────────────────────────────────────────
188
211
 
@@ -203,45 +226,60 @@
203
226
  const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
204
227
 
205
228
  function findWrap(key) {
206
- const w = S.wrapByKey.get(key);
229
+ const w = state.wrapByKey.get(key);
207
230
  return w?.isConnected ? w : null;
208
231
  }
209
232
 
210
233
  function getWrapSet(klass) {
211
- let set = S.wrapsByClass.get(klass);
212
- if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
234
+ let set = state.wrapsByClass.get(klass);
235
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
213
236
  return set;
214
237
  }
215
238
 
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.
239
+ // ── GC disconnected wraps (NodeBB virtualization) ──────────────────────────
219
240
 
220
241
  function gcDisconnectedWraps() {
221
- for (const [key, w] of S.wrapByKey) {
222
- if (!w?.isConnected) S.wrapByKey.delete(key);
242
+ for (const [key, w] of Array.from(state.wrapByKey.entries())) {
243
+ if (!w?.isConnected) state.wrapByKey.delete(key);
223
244
  }
224
- for (const [klass, set] of S.wrapsByClass) {
225
- for (const w of set) {
245
+ for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
246
+ for (const w of Array.from(set)) {
226
247
  if (w?.isConnected) continue;
227
248
  set.delete(w);
228
- const id = parseInt(w.getAttribute?.(ATTR.WRAPID), 10);
229
- if (Number.isFinite(id)) {
230
- S.mountedIds.delete(id);
231
- S.lastShow.delete(id);
232
- }
249
+ try {
250
+ const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
251
+ if (Number.isFinite(id)) {
252
+ state.mountedIds.delete(id);
253
+ state.phState.delete(id);
254
+ state.lastShow.delete(id);
255
+ }
256
+ } catch (_) {}
233
257
  }
234
- if (!set.size) S.wrapsByClass.delete(klass);
258
+ if (!set.size) state.wrapsByClass.delete(klass);
235
259
  }
260
+ try {
261
+ const live = new Set();
262
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
263
+ const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
264
+ if (id > 0) live.add(id);
265
+ }
266
+ for (const id of Array.from(state.mountedIds)) {
267
+ if (!live.has(id)) {
268
+ state.mountedIds.delete(id);
269
+ state.phState.delete(id);
270
+ state.lastShow.delete(id);
271
+ }
272
+ }
273
+ } catch (_) {}
236
274
  }
237
275
 
238
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
276
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
239
277
 
240
278
  function wrapIsLive(wrap) {
241
279
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
242
280
  const key = wrap.getAttribute(ATTR.ANCHOR);
243
281
  if (!key) return false;
244
- if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
282
+ if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
245
283
  const colonIdx = key.indexOf(':');
246
284
  const klass = key.slice(0, colonIdx);
247
285
  const anchorId = key.slice(colonIdx + 1);
@@ -272,12 +310,14 @@
272
310
  const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
273
311
  if (!ph || !isFilled(ph)) return false;
274
312
  wrap.classList.remove('is-empty');
313
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
314
+ if (id > 0) state.phState.set(id, 'shown');
275
315
  return true;
276
316
  }
277
317
 
278
318
  function scheduleUncollapseChecks(wrap) {
279
319
  if (!wrap) return;
280
- for (const ms of [500, 3000, 10000]) {
320
+ for (const ms of [500, 1500, 3000, 7000, 15000]) {
281
321
  setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
282
322
  }
283
323
  }
@@ -285,19 +325,21 @@
285
325
  // ── Pool management ────────────────────────────────────────────────────────
286
326
 
287
327
  function pickId(poolKey) {
288
- const pool = S.pools[poolKey];
328
+ const pool = state.pools[poolKey];
289
329
  if (!pool.length) return null;
290
330
  for (let t = 0; t < pool.length; t++) {
291
- const idx = S.cursors[poolKey] % pool.length;
292
- S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
331
+ const idx = state.cursors[poolKey] % pool.length;
332
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
293
333
  const id = pool[idx];
294
- if (!S.mountedIds.has(id)) return id;
334
+ if (!state.mountedIds.has(id)) return id;
295
335
  }
296
336
  return null;
297
337
  }
298
338
 
299
339
  // ── Recycling ──────────────────────────────────────────────────────────────
300
- // Per Ezoic docs: destroyPlaceholders(id) → remove HTML → fresh placeholder → showAds(id)
340
+ //
341
+ // Per Ezoic docs: destroyPlaceholders(id) → remove old HTML →
342
+ // recreate fresh placeholder → showAds(id).
301
343
 
302
344
  function recycleWrap(klass, targetEl, newKey) {
303
345
  const ez = window.ezstandalone;
@@ -310,13 +352,18 @@
310
352
  let bestEmpty = null, bestEmptyY = Infinity;
311
353
  let bestFull = null, bestFullY = Infinity;
312
354
 
313
- const wraps = S.wrapsByClass.get(klass);
355
+ const wraps = state.wrapsByClass.get(klass);
314
356
  if (!wraps) return null;
315
357
 
316
358
  for (const wrap of wraps) {
317
359
  try {
360
+ // Skip young wraps (ad might still be loading)
318
361
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
319
- if (t - created < TIMING.RECYCLE_MIN_AGE_MS) continue;
362
+ if (t - created < RECYCLE_MIN_AGE_MS) continue;
363
+ // Skip wraps with inflight showAds
364
+ const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
365
+ if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
366
+
320
367
  const bottom = wrap.getBoundingClientRect().bottom;
321
368
  if (bottom > threshold) continue;
322
369
  if (!isFilled(wrap)) {
@@ -332,19 +379,22 @@
332
379
 
333
380
  const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
334
381
  if (!Number.isFinite(id)) return null;
382
+
335
383
  const oldKey = best.getAttribute(ATTR.ANCHOR);
336
384
 
337
385
  // Unobserve before moving
338
386
  try {
339
387
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
340
- if (ph) S.io?.unobserve(ph);
388
+ if (ph) state.io?.unobserve(ph);
341
389
  } catch (_) {}
342
390
 
343
- // Ezoic recycle: destroy → fresh DOM → showAds
391
+ // Ezoic recycle: destroy → new DOM → showAds
344
392
  const doRecycle = () => {
393
+ state.phState.set(id, 'destroyed');
345
394
  try { ez.destroyPlaceholders(id); } catch (_) {}
346
395
 
347
396
  setTimeout(() => {
397
+ // Recreate fresh placeholder DOM at new position
348
398
  mutate(() => {
349
399
  best.setAttribute(ATTR.ANCHOR, newKey);
350
400
  best.setAttribute(ATTR.CREATED, String(now()));
@@ -355,21 +405,27 @@
355
405
  const fresh = document.createElement('div');
356
406
  fresh.id = `${PH_PREFIX}${id}`;
357
407
  fresh.setAttribute('data-ezoic-id', String(id));
408
+ fresh.style.minHeight = '1px';
358
409
  best.appendChild(fresh);
359
410
  targetEl.insertAdjacentElement('afterend', best);
360
411
  });
361
412
 
362
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
363
- S.wrapByKey.set(newKey, best);
413
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
414
+ state.wrapByKey.set(newKey, best);
364
415
 
416
+ // Re-show after DOM is settled
365
417
  setTimeout(() => {
366
- observePh(id);
418
+ observePlaceholder(id);
419
+ state.phState.set(id, 'new');
367
420
  enqueueShow(id);
368
421
  }, TIMING.RECYCLE_DELAY_MS);
369
422
  }, TIMING.RECYCLE_DELAY_MS);
370
423
  };
371
424
 
372
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle(); } catch (_) {}
425
+ try {
426
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle();
427
+ } catch (_) {}
428
+
373
429
  return { id, wrap: best };
374
430
  }
375
431
 
@@ -383,9 +439,11 @@
383
439
  w.setAttribute(ATTR.CREATED, String(now()));
384
440
  w.setAttribute(ATTR.SHOWN, '0');
385
441
  w.style.cssText = 'width:100%;display:block';
442
+
386
443
  const ph = document.createElement('div');
387
444
  ph.id = `${PH_PREFIX}${id}`;
388
445
  ph.setAttribute('data-ezoic-id', String(id));
446
+ ph.style.minHeight = '1px';
389
447
  w.appendChild(ph);
390
448
  return w;
391
449
  }
@@ -393,12 +451,15 @@
393
451
  function insertAfter(el, id, klass, key) {
394
452
  if (!el?.insertAdjacentElement) return null;
395
453
  if (findWrap(key)) return null;
396
- if (S.mountedIds.has(id)) return null;
397
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
454
+ if (state.mountedIds.has(id)) return null;
455
+ const existing = document.getElementById(`${PH_PREFIX}${id}`);
456
+ if (existing?.isConnected) return null;
457
+
398
458
  const w = makeWrap(id, klass, key);
399
459
  mutate(() => el.insertAdjacentElement('afterend', w));
400
- S.mountedIds.add(id);
401
- S.wrapByKey.set(key, w);
460
+ state.mountedIds.add(id);
461
+ state.phState.set(id, 'new');
462
+ state.wrapByKey.set(key, w);
402
463
  getWrapSet(klass).add(w);
403
464
  return w;
404
465
  }
@@ -406,17 +467,17 @@
406
467
  function dropWrap(w) {
407
468
  try {
408
469
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
409
- if (ph instanceof Element) S.io?.unobserve(ph);
470
+ if (ph instanceof Element) state.io?.unobserve(ph);
410
471
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
411
472
  if (Number.isFinite(id)) {
412
- S.mountedIds.delete(id);
413
- S.lastShow.delete(id);
473
+ state.mountedIds.delete(id);
474
+ state.phState.delete(id);
414
475
  }
415
476
  const key = w.getAttribute(ATTR.ANCHOR);
416
- if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
477
+ if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
417
478
  for (const cls of w.classList) {
418
479
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
419
- S.wrapsByClass.get(cls)?.delete(w);
480
+ state.wrapsByClass.get(cls)?.delete(w);
420
481
  break;
421
482
  }
422
483
  }
@@ -429,7 +490,7 @@
429
490
  function pruneOrphansBetween() {
430
491
  const klass = 'ezoic-ad-between';
431
492
  const cfg = KIND[klass];
432
- const wraps = S.wrapsByClass.get(klass);
493
+ const wraps = state.wrapsByClass.get(klass);
433
494
  if (!wraps?.size) return;
434
495
 
435
496
  const liveAnchors = new Set();
@@ -437,13 +498,16 @@
437
498
  const v = el.getAttribute(cfg.anchorAttr);
438
499
  if (v) liveAnchors.add(v);
439
500
  }
501
+
440
502
  const t = now();
441
503
  for (const w of wraps) {
442
504
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
443
505
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
444
506
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
445
507
  const sid = key.slice(klass.length + 1);
446
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
508
+ if (!sid || !liveAnchors.has(sid)) {
509
+ mutate(() => dropWrap(w));
510
+ }
447
511
  }
448
512
  }
449
513
 
@@ -467,18 +531,26 @@
467
531
  function injectBetween(klass, items, interval, showFirst, poolKey) {
468
532
  if (!items.length) return 0;
469
533
  let inserted = 0;
534
+
470
535
  for (const el of items) {
471
- if (inserted >= MAX_INSERTS_RUN) break;
536
+ if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
472
537
  if (!el?.isConnected) continue;
538
+
473
539
  const ord = ordinal(klass, el);
474
540
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
475
541
  if (adjacentWrap(el)) continue;
542
+
476
543
  const key = anchorKey(klass, el);
477
544
  if (findWrap(key)) continue;
545
+
478
546
  const id = pickId(poolKey);
479
547
  if (id) {
480
548
  const w = insertAfter(el, id, klass, key);
481
- if (w) { observePh(id); inserted++; }
549
+ if (w) {
550
+ observePlaceholder(id);
551
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
552
+ inserted++;
553
+ }
482
554
  } else {
483
555
  const recycled = recycleWrap(klass, el, key);
484
556
  if (!recycled) break;
@@ -491,58 +563,69 @@
491
563
  // ── IntersectionObserver ───────────────────────────────────────────────────
492
564
 
493
565
  function getIO() {
494
- if (S.io) return S.io;
566
+ if (state.io) return state.io;
495
567
  try {
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);
568
+ state.io = new IntersectionObserver(entries => {
569
+ for (const entry of entries) {
570
+ if (!entry.isIntersecting) continue;
571
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
572
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
501
573
  if (id > 0) enqueueShow(id);
502
574
  }
503
575
  }, {
504
576
  root: null,
505
- rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP,
577
+ rootMargin: isMobile() ? IO_MARGIN.MOBILE : IO_MARGIN.DESKTOP,
506
578
  threshold: 0,
507
579
  });
508
- } catch (_) { S.io = null; }
509
- return S.io;
580
+ } catch (_) { state.io = null; }
581
+ return state.io;
510
582
  }
511
583
 
512
- function observePh(id) {
584
+ function observePlaceholder(id) {
513
585
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
514
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
586
+ if (ph?.isConnected) {
587
+ try { getIO()?.observe(ph); } catch (_) {}
588
+ }
515
589
  }
516
590
 
517
591
  // ── Show queue ─────────────────────────────────────────────────────────────
518
592
 
519
593
  function enqueueShow(id) {
520
594
  if (!id || isBlocked()) return;
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); }
595
+ const st = state.phState.get(id);
596
+ if (st === 'show-queued' || st === 'shown') return;
597
+ if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
598
+
599
+ if (state.inflight >= LIMITS.MAX_INFLIGHT) {
600
+ if (!state.pendingSet.has(id)) {
601
+ state.pending.push(id);
602
+ state.pendingSet.add(id);
603
+ state.phState.set(id, 'show-queued');
604
+ }
524
605
  return;
525
606
  }
607
+ state.phState.set(id, 'show-queued');
526
608
  startShow(id);
527
609
  }
528
610
 
529
611
  function drainQueue() {
530
612
  if (isBlocked()) return;
531
- while (S.inflight < MAX_INFLIGHT && S.pending.length) {
532
- const id = S.pending.shift();
533
- S.pendingSet.delete(id);
613
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
614
+ const id = state.pending.shift();
615
+ state.pendingSet.delete(id);
534
616
  startShow(id);
535
617
  }
536
618
  }
537
619
 
538
620
  function startShow(id) {
539
621
  if (!id || isBlocked()) return;
540
- S.inflight++;
541
- let done = false;
622
+ state.inflight++;
623
+
624
+ let released = false;
542
625
  const release = () => {
543
- if (done) return;
544
- done = true;
545
- S.inflight = Math.max(0, S.inflight - 1);
626
+ if (released) return;
627
+ released = true;
628
+ state.inflight = Math.max(0, state.inflight - 1);
546
629
  drainQueue();
547
630
  };
548
631
  const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
@@ -550,31 +633,52 @@
550
633
  requestAnimationFrame(() => {
551
634
  try {
552
635
  if (isBlocked()) { clearTimeout(timer); return release(); }
636
+
553
637
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
554
- if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
638
+ if (!ph?.isConnected) {
639
+ state.phState.delete(id);
640
+ clearTimeout(timer);
641
+ return release();
642
+ }
643
+
644
+ if (isFilled(ph) || isPlaceholderUsed(ph)) {
645
+ state.phState.set(id, 'shown');
646
+ clearTimeout(timer);
647
+ return release();
648
+ }
555
649
 
556
650
  const t = now();
557
- if (t - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
558
- S.lastShow.set(id, t);
651
+ if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
652
+ clearTimeout(timer);
653
+ return release();
654
+ }
655
+ state.lastShow.set(id, t);
559
656
 
560
- const wrap = ph.closest(`.${WRAP_CLASS}`);
561
- try { wrap?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
657
+ try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
658
+ state.phState.set(id, 'shown');
562
659
 
563
660
  window.ezstandalone = window.ezstandalone || {};
564
661
  const ez = window.ezstandalone;
662
+
565
663
  const doShow = () => {
664
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
566
665
  try { ez.showAds(id); } catch (_) {}
567
666
  if (wrap) scheduleUncollapseChecks(wrap);
568
667
  scheduleEmptyCheck(id, t);
569
668
  setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
570
669
  };
670
+
571
671
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
572
- } catch (_) { clearTimeout(timer); release(); }
672
+ } catch (_) {
673
+ clearTimeout(timer);
674
+ release();
675
+ }
573
676
  });
574
677
  }
575
678
 
576
679
  function scheduleEmptyCheck(id, showTs) {
577
- for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
680
+ // Two-pass check: conservative to avoid collapsing slow-loading ads
681
+ for (const delay of [TIMING.EMPTY_CHECK_EARLY_MS, TIMING.EMPTY_CHECK_LATE_MS]) {
578
682
  setTimeout(() => {
579
683
  try {
580
684
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
@@ -582,8 +686,9 @@
582
686
  if (!wrap || !ph?.isConnected) return;
583
687
  if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
584
688
  if (clearEmptyIfFilled(wrap)) return;
585
- // Don't collapse if GPT slot exists (still loading)
689
+ // Don't collapse if a GPT slot exists (might still be loading)
586
690
  if (ph.querySelector('[id^="div-gpt-ad"]')) return;
691
+ // Don't collapse if placeholder has meaningful height
587
692
  if (ph.offsetHeight > 10) return;
588
693
  wrap.classList.add('is-empty');
589
694
  } catch (_) {}
@@ -592,7 +697,10 @@
592
697
  }
593
698
 
594
699
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
595
- // Matches v50: individual calls, no batching, pass-through no-arg calls.
700
+ //
701
+ // Intercepts ez.showAds() to batch calls and filter disconnected placeholders.
702
+ // IMPORTANT: no-arg showAds() calls (used by Ezoic for page transitions)
703
+ // are passed through unmodified.
596
704
 
597
705
  function patchShowAds() {
598
706
  const apply = () => {
@@ -601,22 +709,53 @@
601
709
  const ez = window.ezstandalone;
602
710
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
603
711
  window.__nbbEzPatched = true;
712
+
604
713
  const orig = ez.showAds.bind(ez);
714
+ const queue = new Set();
715
+ let flushTimer = null;
716
+
717
+ const flush = () => {
718
+ flushTimer = null;
719
+ if (isBlocked() || !queue.size) return;
720
+ const ids = Array.from(queue).sort((a, b) => a - b);
721
+ queue.clear();
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
+ for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
729
+ const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
730
+ try { orig(...chunk); } catch (_) {
731
+ for (const cid of chunk) { try { orig(cid); } catch (_) {} }
732
+ }
733
+ }
734
+ };
735
+
605
736
  ez.showAds = function (...args) {
606
- if (args.length === 0) return orig();
737
+ // No-arg call = Ezoic page refresh — pass through unmodified
738
+ if (args.length === 0) {
739
+ return orig();
740
+ }
607
741
  if (isBlocked()) return;
608
742
  const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
609
- const seen = new Set();
610
743
  for (const v of ids) {
611
744
  const id = parseInt(v, 10);
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 (_) {}
745
+ if (!Number.isFinite(id) || id <= 0) continue;
746
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
747
+ if (!ph?.isConnected) continue;
748
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
749
+ state.phState.set(id, 'show-queued');
750
+ queue.add(id);
751
+ }
752
+ if (queue.size && !flushTimer) {
753
+ flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
616
754
  }
617
755
  };
618
756
  } catch (_) {}
619
757
  };
758
+
620
759
  apply();
621
760
  if (!window.__nbbEzPatched) {
622
761
  window.ezstandalone = window.ezstandalone || {};
@@ -628,7 +767,6 @@
628
767
 
629
768
  async function runCore() {
630
769
  if (isBlocked()) return 0;
631
- patchShowAds();
632
770
  try { gcDisconnectedWraps(); } catch (_) {}
633
771
 
634
772
  const cfg = await fetchConfig();
@@ -645,26 +783,32 @@
645
783
  };
646
784
 
647
785
  if (kind === 'topic') {
648
- return exec('ezoic-ad-message', getPosts,
649
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
786
+ return exec(
787
+ 'ezoic-ad-message', getPosts,
788
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
789
+ );
650
790
  }
651
791
  if (kind === 'categoryTopics') {
652
792
  pruneOrphansBetween();
653
- return exec('ezoic-ad-between', getTopics,
654
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
793
+ return exec(
794
+ 'ezoic-ad-between', getTopics,
795
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
796
+ );
655
797
  }
656
- return exec('ezoic-ad-categories', getCategories,
657
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
798
+ return exec(
799
+ 'ezoic-ad-categories', getCategories,
800
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
801
+ );
658
802
  }
659
803
 
660
804
  // ── Scheduler & burst ──────────────────────────────────────────────────────
661
805
 
662
806
  function scheduleRun(cb) {
663
- if (S.runQueued) return;
664
- S.runQueued = true;
807
+ if (state.runQueued) return;
808
+ state.runQueued = true;
665
809
  requestAnimationFrame(async () => {
666
- S.runQueued = false;
667
- if (S.pageKey && pageKey() !== S.pageKey) return;
810
+ state.runQueued = false;
811
+ if (state.pageKey && pageKey() !== state.pageKey) return;
668
812
  let n = 0;
669
813
  try { n = await runCore(); } catch (_) {}
670
814
  try { cb?.(n); } catch (_) {}
@@ -674,20 +818,21 @@
674
818
  function requestBurst() {
675
819
  if (isBlocked()) return;
676
820
  const t = now();
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;
821
+ if (t - state.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
822
+ state.lastBurstTs = t;
823
+ state.pageKey = pageKey();
824
+ state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
825
+ if (state.burstActive) return;
826
+ state.burstActive = true;
827
+ state.burstCount = 0;
684
828
  const step = () => {
685
- if (pageKey() !== S.pageKey || isBlocked() || now() > S.burstDeadline || S.burstCount >= MAX_BURST_STEPS) {
686
- S.burstActive = false; return;
829
+ if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
830
+ state.burstActive = false;
831
+ return;
687
832
  }
688
- S.burstCount++;
833
+ state.burstCount++;
689
834
  scheduleRun(n => {
690
- if (!n && !S.pending.length) { S.burstActive = false; return; }
835
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
691
836
  setTimeout(step, n > 0 ? 150 : 300);
692
837
  });
693
838
  };
@@ -697,32 +842,46 @@
697
842
  // ── Cleanup on navigation ──────────────────────────────────────────────────
698
843
 
699
844
  function cleanup() {
700
- S.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
845
+ state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
846
+
847
+ // Tell Ezoic to destroy all placeholders BEFORE we remove DOM elements.
848
+ // This prevents GPT slotDestroyed events and Ezoic 400 errors.
849
+ try {
850
+ const ez = window.ezstandalone;
851
+ if (typeof ez?.destroyAll === 'function') {
852
+ ez.destroyAll();
853
+ }
854
+ } catch (_) {}
855
+
701
856
  mutate(() => {
702
857
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
703
858
  });
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;
859
+ state.cfg = null;
860
+ state.poolsReady = false;
861
+ state.pools = { topics: [], posts: [], categories: [] };
862
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
863
+ state.mountedIds.clear();
864
+ state.lastShow.clear();
865
+ state.wrapByKey.clear();
866
+ state.wrapsByClass.clear();
867
+ state.kind = null;
868
+ state.phState.clear();
869
+ state.inflight = 0;
870
+ state.pending = [];
871
+ state.pendingSet.clear();
872
+ state.burstActive = false;
873
+ state.runQueued = false;
874
+ state.firstShown = false;
718
875
  }
719
876
 
720
877
  // ── MutationObserver ───────────────────────────────────────────────────────
721
878
 
722
879
  function ensureDomObserver() {
723
- if (S.domObs) return;
724
- S.domObs = new MutationObserver(muts => {
725
- if (S.mutGuard > 0 || isBlocked()) return;
880
+ if (state.domObs) return;
881
+
882
+ state.domObs = new MutationObserver(muts => {
883
+ if (state.mutGuard > 0 || isBlocked()) return;
884
+
726
885
  let needsBurst = false;
727
886
  const kind = getKind();
728
887
  const relevantSels =
@@ -730,8 +889,10 @@
730
889
  kind === 'categoryTopics' ? [SEL.topic] :
731
890
  kind === 'categories' ? [SEL.category] :
732
891
  [SEL.post, SEL.topic, SEL.category];
892
+
733
893
  for (const m of muts) {
734
894
  if (m.type !== 'childList') continue;
895
+
735
896
  // Free IDs from wraps removed by NodeBB virtualization
736
897
  for (const node of m.removedNodes) {
737
898
  if (!(node instanceof Element)) continue;
@@ -740,13 +901,15 @@
740
901
  dropWrap(node);
741
902
  } else {
742
903
  const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
743
- if (wraps?.length) for (const w of wraps) dropWrap(w);
904
+ if (wraps?.length) { for (const w of wraps) dropWrap(w); }
744
905
  }
745
906
  } catch (_) {}
746
907
  }
908
+
747
909
  for (const node of m.addedNodes) {
748
910
  if (!(node instanceof Element)) continue;
749
- // Ad fill detection → uncollapse
911
+
912
+ // Ad fill detection
750
913
  try {
751
914
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
752
915
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -754,19 +917,20 @@
754
917
  if (wrap) clearEmptyIfFilled(wrap);
755
918
  }
756
919
  } catch (_) {}
920
+
757
921
  // Re-observe wraps re-inserted by NodeBB virtualization
758
922
  try {
759
- const reinserted = node.classList?.contains(WRAP_CLASS)
923
+ const wraps = node.classList?.contains(WRAP_CLASS)
760
924
  ? [node]
761
925
  : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
762
- for (const wrap of reinserted) {
926
+ for (const wrap of wraps) {
763
927
  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
- }
928
+ if (!id || id <= 0) continue;
929
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
930
+ if (ph) { try { getIO()?.observe(ph); } catch (_) {} }
768
931
  }
769
932
  } catch (_) {}
933
+
770
934
  // New content detection
771
935
  if (!needsBurst) {
772
936
  for (const sel of relevantSels) {
@@ -778,23 +942,29 @@
778
942
  }
779
943
  if (needsBurst) break;
780
944
  }
945
+
781
946
  if (needsBurst) requestBurst();
782
947
  });
783
- try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
948
+
949
+ try {
950
+ state.domObs.observe(document.body, { childList: true, subtree: true });
951
+ } catch (_) {}
784
952
  }
785
953
 
786
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
954
+ // ── TCF / CMP Protection (3 layers) ────────────────────────────────────────
787
955
 
788
956
  function ensureTcfLocator() {
789
957
  if (!window.__tcfapi && !window.__cmp) return;
958
+
790
959
  const LOCATOR_ID = '__tcfapiLocator';
960
+
791
961
  const ensureInHead = () => {
792
962
  let existing = document.getElementById(LOCATOR_ID);
793
963
  if (existing) {
794
964
  if (existing.parentElement !== document.head) {
795
965
  try { document.head.appendChild(existing); } catch (_) {}
796
966
  }
797
- return;
967
+ return existing;
798
968
  }
799
969
  const f = document.createElement('iframe');
800
970
  f.style.display = 'none';
@@ -802,7 +972,9 @@
802
972
  try { document.head.appendChild(f); } catch (_) {
803
973
  (document.body || document.documentElement).appendChild(f);
804
974
  }
975
+ return f;
805
976
  };
977
+
806
978
  ensureInHead();
807
979
 
808
980
  if (!window.__nbbCmpGuarded) {
@@ -844,8 +1016,12 @@
844
1016
  window.__nbbTcfObs.observe(document.body || document.documentElement, {
845
1017
  childList: true, subtree: false,
846
1018
  });
1019
+ } catch (_) {}
1020
+ try {
847
1021
  if (document.head) {
848
- window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false });
1022
+ window.__nbbTcfObs.observe(document.head, {
1023
+ childList: true, subtree: false,
1024
+ });
849
1025
  }
850
1026
  } catch (_) {}
851
1027
  }
@@ -866,7 +1042,8 @@
866
1042
  window.__nbbAriaObs = new MutationObserver(remove);
867
1043
  try {
868
1044
  window.__nbbAriaObs.observe(document.body, {
869
- attributes: true, attributeFilter: ['aria-hidden'],
1045
+ attributes: true,
1046
+ attributeFilter: ['aria-hidden'],
870
1047
  });
871
1048
  } catch (_) {}
872
1049
  }
@@ -876,7 +1053,8 @@
876
1053
  function muteConsole() {
877
1054
  if (window.__nbbEzMuted) return;
878
1055
  window.__nbbEzMuted = true;
879
- const MUTED = [
1056
+
1057
+ const PREFIXES = [
880
1058
  '[EzoicAds JS]: Placeholder Id',
881
1059
  'No valid placeholders for loadMore',
882
1060
  'cannot call refresh on the same page',
@@ -885,22 +1063,22 @@
885
1063
  '[CMP] Error in custom getTCData',
886
1064
  'vignette: no interstitial API',
887
1065
  'Ezoic JS-Enable should only ever',
888
- '### sending slotDestroyed',
889
- 'Error loading identity bridging',
1066
+ ];
1067
+ const PATTERNS = [
890
1068
  `with id ${PH_PREFIX}`,
891
1069
  'adsbygoogle.push() error: All',
892
1070
  'has already been defined',
893
1071
  'bad response. Status',
894
- 'slotDestroyed event',
895
- 'identity bridging',
896
1072
  ];
1073
+
897
1074
  for (const method of ['log', 'info', 'warn', 'error']) {
898
1075
  const orig = console[method];
899
1076
  if (typeof orig !== 'function') continue;
900
1077
  console[method] = function (...args) {
901
1078
  if (typeof args[0] === 'string') {
902
1079
  const msg = args[0];
903
- for (const p of MUTED) { if (msg.includes(p)) return; }
1080
+ for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
1081
+ for (const p of PATTERNS) { if (msg.includes(p)) return; }
904
1082
  }
905
1083
  return orig.apply(console, args);
906
1084
  };
@@ -909,25 +1087,28 @@
909
1087
 
910
1088
  // ── Network warmup ─────────────────────────────────────────────────────────
911
1089
 
912
- const _warmed = new Set();
1090
+ let _networkWarmed = false;
1091
+
913
1092
  function warmNetwork() {
1093
+ if (_networkWarmed) return;
1094
+ _networkWarmed = true;
914
1095
  const head = document.head;
915
1096
  if (!head) return;
916
- for (const [rel, href, cors] of [
1097
+ const hints = [
917
1098
  ['preconnect', 'https://g.ezoic.net', true ],
918
1099
  ['preconnect', 'https://go.ezoic.net', true ],
919
1100
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
920
1101
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
921
1102
  ['dns-prefetch', 'https://g.ezoic.net', false],
922
1103
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
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);
1104
+ ];
1105
+ for (const [rel, href, cors] of hints) {
1106
+ if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
1107
+ const link = document.createElement('link');
1108
+ link.rel = rel;
1109
+ link.href = href;
1110
+ if (cors) link.crossOrigin = 'anonymous';
1111
+ head.appendChild(link);
931
1112
  }
932
1113
  }
933
1114
 
@@ -936,30 +1117,54 @@
936
1117
  function bindNodeBB() {
937
1118
  const $ = window.jQuery;
938
1119
  if (!$) return;
1120
+
939
1121
  $(window).off('.nbbEzoic');
940
- $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1122
+
1123
+ // Only cleanup on actual page change, not same-page pagination
1124
+ $(window).on('action:ajaxify.start.nbbEzoic', (ev, data) => {
1125
+ const targetUrl = data?.url || data?.tpl_url || '';
1126
+ const currentPath = location.pathname.replace(/^\//, '');
1127
+ if (targetUrl && targetUrl.replace(/[?#].*$/, '') === currentPath.replace(/[?#].*$/, '')) {
1128
+ return; // Same page — skip cleanup
1129
+ }
1130
+ cleanup();
1131
+ });
1132
+
941
1133
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
942
- S.pageKey = pageKey();
943
- S.kind = null;
944
- S.blockedUntil = 0;
945
- muteConsole(); ensureTcfLocator(); protectAriaHidden();
946
- warmNetwork(); patchShowAds(); getIO(); ensureDomObserver();
1134
+ state.pageKey = pageKey();
1135
+ state.kind = null;
1136
+ state.blockedUntil = 0;
1137
+
1138
+ muteConsole();
1139
+ ensureTcfLocator();
1140
+ protectAriaHidden();
1141
+ warmNetwork();
1142
+ patchShowAds();
1143
+ getIO();
1144
+ ensureDomObserver();
947
1145
  requestBurst();
948
1146
  });
949
1147
 
950
1148
  const burstEvents = [
951
- 'action:ajaxify.contentLoaded', 'action:posts.loaded',
952
- 'action:topics.loaded', 'action:categories.loaded',
953
- 'action:category.loaded', 'action:topic.loaded',
1149
+ 'action:ajaxify.contentLoaded',
1150
+ 'action:posts.loaded',
1151
+ 'action:topics.loaded',
1152
+ 'action:categories.loaded',
1153
+ 'action:category.loaded',
1154
+ 'action:topic.loaded',
954
1155
  ].map(e => `${e}.nbbEzoic`).join(' ');
1156
+
955
1157
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
956
1158
 
957
1159
  try {
958
1160
  require(['hooks'], hooks => {
959
1161
  if (typeof hooks?.on !== 'function') return;
960
1162
  for (const ev of [
961
- 'action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
962
- 'action:categories.loaded', 'action:topic.loaded',
1163
+ 'action:ajaxify.end',
1164
+ 'action:posts.loaded',
1165
+ 'action:topics.loaded',
1166
+ 'action:categories.loaded',
1167
+ 'action:topic.loaded',
963
1168
  ]) {
964
1169
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
965
1170
  }
@@ -972,13 +1177,16 @@
972
1177
  window.addEventListener('scroll', () => {
973
1178
  if (ticking) return;
974
1179
  ticking = true;
975
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1180
+ requestAnimationFrame(() => {
1181
+ ticking = false;
1182
+ requestBurst();
1183
+ });
976
1184
  }, { passive: true });
977
1185
  }
978
1186
 
979
1187
  // ── Boot ───────────────────────────────────────────────────────────────────
980
1188
 
981
- S.pageKey = pageKey();
1189
+ state.pageKey = pageKey();
982
1190
  muteConsole();
983
1191
  ensureTcfLocator();
984
1192
  protectAriaHidden();
@@ -988,7 +1196,7 @@
988
1196
  ensureDomObserver();
989
1197
  bindNodeBB();
990
1198
  bindScroll();
991
- S.blockedUntil = 0;
1199
+ state.blockedUntil = 0;
992
1200
  requestBurst();
993
1201
 
994
1202
  })();