nodebb-plugin-ezoic-infinite 1.6.70 → 1.6.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +1429 -776
package/public/client.js CHANGED
@@ -1,195 +1,594 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- // ─── Constants ───────────────────────────────────────────────────────────────
5
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
6
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
7
- const HOST_CLASS = 'nodebb-ezoic-host';
4
+ // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
5
+ // Recycling while scrolling up is a common cause of ads "bunching" and a "disappearing too fast" feeling.
6
+ let lastScrollY = 0;
7
+ let scrollDir = 1; // 1 = down, -1 = up
8
+ try {
9
+ lastScrollY = window.scrollY || 0;
10
+ window.addEventListener(
11
+ 'scroll',
12
+ () => {
13
+ const y = window.scrollY || 0;
14
+ const d = y - lastScrollY;
15
+ if (Math.abs(d) > 4) {
16
+ scrollDir = d > 0 ? 1 : -1;
17
+ lastScrollY = y;
18
+ }
19
+ },
20
+ { passive: true }
21
+ );
22
+ } catch (e) {}
23
+
24
+ // NodeBB client context
25
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
26
+
27
+ // IMPORTANT: must NOT collide with Ezoic's own markup (they use `.ezoic-ad`).
28
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
29
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
30
+ const POOL_ID = 'nodebb-ezoic-placeholder-pool';
31
+
32
+ // Smoothness caps
33
+ // Limit how many placements we inject per scan pass.
34
+ // Too low = you end up with only a handful of placeholders after ajaxify.
35
+ // Too high = jank on very long pages.
8
36
  const MAX_INSERTS_PER_RUN = 8;
9
37
 
10
- // IntersectionObserver preload margins (large = request fills early).
11
- const IO_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
12
- const IO_MARGIN_MOBILE = '3200px 0px 3200px 0px';
38
+ // Keep empty (unfilled) wraps alive for a while. Topics/messages can fill late (auction/CMP).
39
+ // Pruning too early makes ads look like they "disappear" while scrolling.
40
+ // Keep empty wraps alive; mobile fills can be slow.
41
+ function keepEmptyWrapMs() { return isMobile() ? 120000 : 60000; }
42
+
43
+ // Preload margins
44
+ const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
45
+ // Mobile: larger preload window so ad fill requests start earlier and
46
+ // users don't scroll past empty placeholders.
47
+ const PRELOAD_MARGIN_MOBILE = '3200px 0px 3200px 0px';
48
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
49
+ const PRELOAD_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
50
+
51
+ const BOOST_DURATION_MS = 2500;
52
+ const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
53
+
54
+ const MAX_INFLIGHT_DESKTOP = 4;
55
+ const MAX_INFLIGHT_MOBILE = 3;
56
+
57
+ const SELECTORS = {
58
+ topicItem: 'li[component="category/topic"]',
59
+ postItem: '[component="post"][data-pid]',
60
+ categoryItem: 'li[component="categories/category"]',
61
+ };
62
+
63
+ // Production build: debug disabled
64
+ function dbg() {}
65
+
66
+ // Ezoic (and some partner scripts) can be very noisy in console on SPA/Ajaxify setups.
67
+ // These warnings are not actionable for end-users and can flood the console.
68
+ // We selectively silence the known spam patterns while keeping other warnings intact.
69
+ function muteNoisyConsole() {
70
+ try {
71
+ if (window.__nodebbEzoicConsoleMuted) return;
72
+ window.__nodebbEzoicConsoleMuted = true;
73
+
74
+ const shouldMute = (args) => {
75
+ try {
76
+ if (!args || !args.length) return false;
77
+ const s0 = typeof args[0] === 'string' ? args[0] : '';
78
+ // Duplicate placeholder definition spam (common when reusing ids in SPA/Ajaxify).
79
+ if (s0.includes('[EzoicAds JS]: Placeholder Id') && s0.includes('has already been defined')) return true;
80
+ // Ezoic debugger iframe spam.
81
+ if (s0.includes('Debugger iframe already exists')) return true;
82
+ // Missing placeholder spam (we already guard showAds; Ezoic still logs sometimes).
83
+ if (s0.includes('HTML element with id ezoic-pub-ad-placeholder-') && s0.includes('does not exist')) return true;
84
+ return false;
85
+ } catch (e) {
86
+ return false;
87
+ }
88
+ };
89
+
90
+ const wrap = (method) => {
91
+ const orig = console[method];
92
+ if (typeof orig !== 'function') return;
93
+ console[method] = function (...args) {
94
+ if (shouldMute(args)) return;
95
+ return orig.apply(console, args);
96
+ };
97
+ };
98
+
99
+ wrap('log');
100
+ wrap('info');
101
+ wrap('warn');
102
+ wrap('error');
103
+ } catch (e) {}
104
+ }
105
+
106
+ // Some CMP/TCF stubs rely on a hidden iframe named `__tcfapiLocator` to route postMessage calls.
107
+ // In SPA/Ajaxify navigations, that iframe can be removed/replaced unexpectedly, causing noisy
108
+ // "postMessage" / "addtlConsent" null errors. Ensuring it's present makes the environment stable.
109
+ function ensureTcfApiLocator() {
110
+ try {
111
+ // If a CMP is not present, do nothing.
112
+ if (typeof window.__tcfapi !== 'function' && typeof window.__cmp !== 'function') return;
113
+ if (document.getElementById('__tcfapiLocator')) return;
114
+ const f = document.createElement('iframe');
115
+ f.style.display = 'none';
116
+ f.id = '__tcfapiLocator';
117
+ f.name = '__tcfapiLocator';
118
+ (document.body || document.documentElement).appendChild(f);
119
+ } catch (e) {}
120
+ }
121
+
122
+
123
+
124
+ function isFilledNode(node) {
125
+ return !!(node && node.querySelector && node.querySelector('iframe, ins, img, video, [data-google-container-id]'));
126
+ }
127
+
128
+ // Ezoic injects inline `min-height:400px !important` on one or more nested wrappers.
129
+ // If the creative is 250px, this leaves ~150px empty space. Because it's inline+important,
130
+ // CSS alone cannot fix it reliably — we must rewrite the inline style.
131
+ function tightenEzoicMinHeight(wrap) {
132
+ try {
133
+ if (!wrap || !wrap.querySelector) return;
134
+
135
+ const iframes = wrap.querySelectorAll('iframe');
136
+ if (!iframes || !iframes.length) return;
137
+
138
+ // Find the closest "big" ezoic container that carries the 400px min-height.
139
+ const firstIframe = iframes[0];
140
+ let refNode = null;
141
+ let p = firstIframe && firstIframe.parentElement;
142
+ while (p && p !== wrap) {
143
+ if (p.classList && p.classList.contains('ezoic-ad')) {
144
+ const st = (p.getAttribute('style') || '').toLowerCase();
145
+ if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
146
+ refNode = p;
147
+ break;
148
+ }
149
+ }
150
+ p = p.parentElement;
151
+ }
152
+ if (!refNode) {
153
+ refNode = wrap.querySelector('.ezoic-ad-adaptive') || wrap.querySelector('.ezoic-ad') || wrap;
154
+ }
155
+
156
+ let refTop = 0;
157
+ try { refTop = refNode.getBoundingClientRect().top; } catch (e) { refTop = 0; }
158
+
159
+ // Compute the rendered height needed inside refNode (visible iframes only).
160
+ let maxBottom = 0;
161
+ iframes.forEach((f) => {
162
+ if (!f || !f.getBoundingClientRect) return;
163
+ const rect = f.getBoundingClientRect();
164
+ if (rect.width <= 1 || rect.height <= 1) return;
165
+ const bottom = rect.bottom - refTop;
166
+ maxBottom = Math.max(maxBottom, bottom);
167
+ });
168
+
169
+ // Fallback to attr/offset if layout metrics are not available.
170
+ if (!maxBottom) {
171
+ iframes.forEach((f) => {
172
+ const ah = parseInt(f.getAttribute('height') || '0', 10);
173
+ const oh = f.offsetHeight || 0;
174
+ maxBottom = Math.max(maxBottom, ah, oh);
175
+ });
176
+ }
177
+ if (!maxBottom) return;
178
+
179
+ // NOTE: Do NOT add the Ezoic badge (reportline) height here.
180
+ // It is absolutely positioned and should not reserve layout space.
181
+
182
+ const h = Math.max(1, Math.ceil(maxBottom));
183
+
184
+ const tightenNode = (node) => {
185
+ if (!node || !node.style) return;
186
+ try { node.style.setProperty('min-height', h + 'px', 'important'); } catch (e) { node.style.minHeight = h + 'px'; }
187
+ try { node.style.setProperty('height', 'auto', 'important'); } catch (e) {}
188
+ try { node.style.setProperty('line-height', '0', 'important'); } catch (e) {}
189
+ };
190
+
191
+ // Tighten refNode and any ancestor ezoic-ad nodes with the problematic min-height.
192
+ let cur = refNode;
193
+ while (cur && cur !== wrap) {
194
+ if (cur.classList && cur.classList.contains('ezoic-ad')) {
195
+ const st = (cur.getAttribute('style') || '').toLowerCase();
196
+ if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
197
+ tightenNode(cur);
198
+ }
199
+ }
200
+ cur = cur.parentElement;
201
+ }
202
+ tightenNode(refNode);
203
+
204
+ // Tighten any nested wrappers that also have the 400px min-height inline.
205
+ refNode.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach((n) => {
206
+ const st = (n.getAttribute('style') || '').toLowerCase();
207
+ if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
208
+ tightenNode(n);
209
+ }
210
+ });
211
+
212
+ // Mobile friendliness: avoid giant fixed widths causing overflow/reflow.
213
+ if (isMobile()) {
214
+ [refNode].forEach((n) => {
215
+ try { n.style.setProperty('width', '100%', 'important'); } catch (e) {}
216
+ try { n.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
217
+ try { n.style.setProperty('min-width', '0', 'important'); } catch (e) {}
218
+ });
219
+ }
220
+ } catch (e) {}
221
+ }
222
+
223
+ function watchWrapForFill(wrap) {
224
+ try {
225
+ if (!wrap || wrap.__ezFillObs) return;
226
+
227
+ // Ezoic can (re)apply inline styles after fill; keep tightening for a short window.
228
+ const start = now();
229
+ const tightenBurst = () => {
230
+ try { tightenEzoicMinHeight(wrap); } catch (e) {}
231
+ if (now() - start < 6000) {
232
+ setTimeout(tightenBurst, 350);
233
+ }
234
+ };
235
+
236
+ const obs = new MutationObserver((muts) => {
237
+ // If anything that looks like ad content appears, treat as filled.
238
+ if (isFilledNode(wrap)) {
239
+ wrap.classList.remove('is-empty');
240
+ tightenBurst();
241
+ }
242
+
243
+ // If Ezoic changes inline style on descendants (min-height:400!important), tighten again.
244
+ for (const m of muts) {
245
+ if (m.type === 'attributes' && m.attributeName === 'style') {
246
+ try { tightenEzoicMinHeight(wrap); } catch (e) {}
247
+ break;
248
+ }
249
+ }
250
+
251
+ // Disconnect only after the burst window to avoid missing late style rewrites.
252
+ if (now() - start > 7000) {
253
+ try { obs.disconnect(); } catch (e) {}
254
+ wrap.__ezFillObs = null;
255
+ }
256
+ });
257
+
258
+ obs.observe(wrap, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
259
+ wrap.__ezFillObs = obs;
260
+ } catch (e) {}
261
+ }
262
+
263
+ // Global safety net: sometimes Ezoic swaps nodes in ways that bypass our per-wrap observers.
264
+ // When we see an Ezoic container with min-height:400!important inside posts/topics, shrink it.
265
+ function globalGapFixInit() {
266
+ try {
267
+ if (window.__nodebbEzoicGapFix) return;
268
+ window.__nodebbEzoicGapFix = true;
269
+
270
+ // Observe only the main content area to minimize overhead.
271
+ const root = document.getElementById('content') || document.querySelector('[component="content"], #panel') || document.body;
272
+
273
+ const inPostArea = (el) => {
274
+ try {
275
+ return !!(el && el.closest && el.closest('[component="post"], .topic, .posts, [component="topic"]'));
276
+ } catch (e) { return false; }
277
+ };
278
+
279
+ const maybeFix = (root) => {
280
+ if (!root || !root.querySelectorAll) return;
281
+ const nodes = root.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"]');
282
+ nodes.forEach((n) => {
283
+ const st = (n.getAttribute('style') || '').toLowerCase();
284
+ if (!st.includes('min-height:400')) return;
285
+ if (!inPostArea(n)) return;
286
+ try {
287
+ const tmpWrap = n.closest('.' + WRAP_CLASS) || n.parentElement;
288
+ tightenEzoicMinHeight(tmpWrap || n);
289
+ } catch (e) {}
290
+ });
291
+ };
292
+
293
+ requestAnimationFrame(() => maybeFix(root));
294
+
295
+ // Batch DOM mutation processing into a single rAF to avoid doing work per mutation.
296
+ const pending = new Set();
297
+ let scheduled = false;
298
+ const scheduleFlush = () => {
299
+ if (scheduled) return;
300
+ scheduled = true;
301
+ requestAnimationFrame(() => {
302
+ scheduled = false;
303
+ pending.forEach((n) => {
304
+ try { maybeFix(n); } catch (e) {}
305
+ });
306
+ pending.clear();
307
+ });
308
+ };
309
+
310
+ const obs = new MutationObserver((muts) => {
311
+ for (const m of muts) {
312
+ if (m.type === 'attributes') {
313
+ const t = m.target && m.target.nodeType === 1 ? m.target : m.target && m.target.parentElement;
314
+ if (t) pending.add(t);
315
+ } else if (m.addedNodes && m.addedNodes.length) {
316
+ m.addedNodes.forEach((n) => {
317
+ if (n && n.nodeType === 1) pending.add(n);
318
+ });
319
+ }
320
+ }
321
+ if (pending.size) scheduleFlush();
322
+ });
323
+ obs.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
324
+ } catch (e) {}
325
+ }
326
+
327
+ // ---------------- state ----------------
328
+
329
+ const state = {
330
+ pageKey: null,
331
+ cfg: null,
332
+
333
+ // pools (full lists) + cursors
334
+ allTopics: [],
335
+ allPosts: [],
336
+ allCategories: [],
337
+ curTopics: 0,
338
+ curPosts: 0,
339
+ curCategories: 0,
340
+
341
+ // per-id throttle
342
+ lastShowById: new Map(),
13
343
 
14
- // Fast-scroll boost: temporarily widen IO margins.
15
- const IO_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
16
- const IO_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
17
- const BOOST_DURATION_MS = 2500;
18
- const BOOST_SPEED_PX_PER_MS = 2.2;
344
+ // observers / schedulers
345
+ domObs: null,
346
+ io: null,
347
+ ioMargin: null,
19
348
 
20
- const MAX_INFLIGHT = { desktop: 4, mobile: 3 };
349
+ // internal mutations guard
350
+ internalDomChange: 0,
21
351
 
22
- const SEL = {
23
- topic: 'li[component="category/topic"]',
24
- post: '[component="post"][data-pid]',
25
- category: 'li[component="categories/category"]',
352
+ // preloading budget
353
+ inflight: 0,
354
+ pending: [],
355
+ pendingSet: new Set(),
356
+
357
+ // scroll boost
358
+ scrollBoostUntil: 0,
359
+ lastScrollY: 0,
360
+ lastScrollTs: 0,
361
+
362
+ // hero
363
+ heroDoneForPage: false,
364
+
365
+ // run scheduler
366
+ runQueued: false,
367
+ burstActive: false,
368
+ burstDeadline: 0,
369
+ burstCount: 0,
370
+ lastBurstReqTs: 0,
26
371
  };
27
372
 
28
- // ─── Utils ───────────────────────────────────────────────────────────────────
373
+ // Soft block during navigation / heavy DOM churn
374
+ let blockedUntil = 0;
375
+ const insertingIds = new Set();
29
376
 
30
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
377
+ function now() { return Date.now(); }
378
+ function isBlocked() { return now() < blockedUntil; }
31
379
 
32
- function now() { return Date.now(); }
33
- function isMobile() { try { return window.innerWidth < 768; } catch (e) { return false; } }
34
- function isBoosted(){ return now() < (st.boostUntil || 0); }
35
- function isBlocked(){ return now() < blockedUntil; }
380
+ // ---------------- utils ----------------
36
381
 
37
- function normBool(v) {
382
+ function normalizeBool(v) {
38
383
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
39
384
  }
40
385
 
41
- function parsePool(raw) {
42
- if (!raw) return [];
43
- const seen = new Set(), out = [];
44
- for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
386
+ function uniqInts(lines) {
387
+ const out = [];
388
+ const seen = new Set();
389
+ for (const v of lines) {
45
390
  const n = parseInt(v, 10);
46
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
391
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
392
+ seen.add(n);
393
+ out.push(n);
394
+ }
47
395
  }
48
396
  return out;
49
397
  }
50
398
 
51
- function isFilled(node) {
52
- return !!(node && node.querySelector &&
53
- node.querySelector('iframe, ins, img, video, [data-google-container-id]'));
54
- }
55
-
56
- function getMaxInflight() {
57
- return (isMobile() ? MAX_INFLIGHT.mobile : MAX_INFLIGHT.desktop) + (isBoosted() ? 1 : 0);
58
- }
59
-
60
- function getIoMargin() {
61
- return isMobile()
62
- ? (isBoosted() ? IO_MARGIN_MOBILE_BOOST : IO_MARGIN_MOBILE)
63
- : (isBoosted() ? IO_MARGIN_DESKTOP_BOOST : IO_MARGIN_DESKTOP);
399
+ function parsePool(raw) {
400
+ if (!raw) return [];
401
+ const lines = String(raw)
402
+ .split(/\r?\n/)
403
+ .map(s => s.trim())
404
+ .filter(Boolean);
405
+ return uniqInts(lines);
64
406
  }
65
407
 
66
408
  function getPageKey() {
67
409
  try {
68
- const d = window.ajaxify && window.ajaxify.data;
69
- if (d) {
70
- if (d.tid) return 'topic:' + d.tid;
71
- if (d.cid) return 'category:' + d.cid;
410
+ const ax = window.ajaxify;
411
+ if (ax && ax.data) {
412
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
413
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
72
414
  }
73
415
  } catch (e) {}
74
416
  return window.location.pathname;
75
417
  }
76
418
 
77
- function getKind() {
78
- const p = window.location.pathname || '';
79
- if (/^\/topic\//.test(p)) return 'topic';
80
- if (/^\/category\//.test(p)) return 'categoryTopics';
81
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
82
- if (document.querySelector(SEL.category)) return 'categories';
83
- if (document.querySelector(SEL.post)) return 'topic';
84
- if (document.querySelector(SEL.topic)) return 'categoryTopics';
85
- return 'other';
419
+ function isMobile() {
420
+ try { return window.innerWidth < 768; } catch (e) { return false; }
86
421
  }
87
422
 
88
- function withInternal(fn) {
89
- st.internalMut++;
90
- try { fn(); } finally { st.internalMut--; }
423
+ function isBoosted() {
424
+ return now() < (state.scrollBoostUntil || 0);
91
425
  }
92
426
 
93
- // ─── State ───────────────────────────────────────────────────────────────────
427
+ function getPreloadRootMargin() {
428
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
429
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
430
+ }
94
431
 
95
- const st = {
96
- pageKey: null,
97
- cfg: null,
432
+ function getMaxInflight() {
433
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
434
+ return base + (isBoosted() ? 1 : 0);
435
+ }
98
436
 
99
- pools: { topic: [], post: [], category: [] },
100
- cursors: { topic: 0, post: 0, category: 0 },
437
+ function withInternalDomChange(fn) {
438
+ state.internalDomChange++;
439
+ try { fn(); } finally { state.internalDomChange--; }
440
+ }
101
441
 
102
- lastShow: new Map(), // id timestamp
103
- inflight: 0,
104
- pending: [],
105
- pendingSet: new Set(),
442
+ // ---------------- DOM helpers ----------------
106
443
 
107
- io: null,
108
- ioMargin: null,
109
- domObs: null,
444
+ function getTopicItems() {
445
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
446
+ }
110
447
 
111
- internalMut: 0,
112
- boostUntil: 0,
113
- lastScrollY: 0,
114
- lastScrollTs: 0,
448
+ function getCategoryItems() {
449
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
450
+ }
115
451
 
116
- heroDone: false,
117
- runQueued: false,
118
- burstActive: false,
119
- burstDeadline:0,
120
- burstCount: 0,
121
- lastBurstTs: 0,
452
+ function getPostContainers() {
453
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
454
+ return nodes.filter((el) => {
455
+ if (!el || !el.isConnected) return false;
456
+ if (!el.querySelector('[component="post/content"]')) return false;
457
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
458
+ if (parentPost && parentPost !== el) return false;
459
+ if (el.getAttribute('component') === 'post/parent') return false;
460
+ return true;
461
+ });
462
+ }
122
463
 
123
- // Detached wraps cache (to survive NodeBB virtual/infinite scroll recycling)
124
- // kindClass -> Map(afterPos -> wrapNode)
125
- detached: new Map(),
126
- };
464
+ function getKind() {
465
+ const p = window.location.pathname || '';
466
+ if (/^\/topic\//.test(p)) return 'topic';
467
+ if (/^\/category\//.test(p)) return 'categoryTopics';
468
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
127
469
 
128
- let blockedUntil = 0;
129
- const inserting = new Set();
470
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
471
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
472
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
473
+ return 'other';
474
+ }
475
+
476
+ function isAdjacentAd(target) {
477
+ if (!target) return false;
478
+ const next = target.nextElementSibling;
479
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
480
+ const prev = target.previousElementSibling;
481
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
482
+ return false;
483
+ }
484
+
485
+ function findWrap(kindClass, afterPos) {
486
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
487
+ }
130
488
 
131
- // ─── Console mute (Ezoic SPA spam) ───────────────────────────────────────────
489
+ // ---------------- placeholder pool ----------------
132
490
 
133
- function muteConsole() {
491
+ function getPoolEl() {
492
+ let el = document.getElementById(POOL_ID);
493
+ if (el) return el;
494
+ el = document.createElement('div');
495
+ el.id = POOL_ID;
496
+ el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
497
+ (document.body || document.documentElement).appendChild(el);
498
+ return el;
499
+ }
500
+
501
+ function primePlaceholderPool(allIds) {
134
502
  try {
135
- if (window.__nbbEzoicMuted) return;
136
- window.__nbbEzoicMuted = true;
137
- const bad = (a) => {
138
- const s = typeof a[0] === 'string' ? a[0] : '';
139
- return (s.includes('[EzoicAds JS]: Placeholder Id') && s.includes('has already been defined'))
140
- || s.includes('Debugger iframe already exists')
141
- || (s.includes('HTML element with id ezoic-pub-ad-placeholder-') && s.includes('does not exist'));
142
- };
143
- for (const m of ['log','info','warn','error']) {
144
- const o = console[m];
145
- if (typeof o !== 'function') continue;
146
- console[m] = function(...a) { if (!bad(a)) o.apply(console, a); };
503
+ if (!Array.isArray(allIds) || !allIds.length) return;
504
+ const pool = getPoolEl();
505
+ for (const id of allIds) {
506
+ if (!id) continue;
507
+ const domId = `${PLACEHOLDER_PREFIX}${id}`;
508
+ if (document.getElementById(domId)) continue;
509
+ const ph = document.createElement('div');
510
+ ph.id = domId;
511
+ ph.setAttribute('data-ezoic-id', String(id));
512
+ pool.appendChild(ph);
147
513
  }
148
514
  } catch (e) {}
149
515
  }
150
516
 
151
- // ─── Network warmup ──────────────────────────────────────────────────────────
152
-
153
- const _warmed = new Set();
154
- function warmNetwork() {
155
- const head = document.head;
156
- if (!head) return;
157
- for (const [rel, href, cors] of [
158
- ['preconnect', 'https://g.ezoic.net', true],
159
- ['dns-prefetch', 'https://g.ezoic.net', false],
160
- ['preconnect', 'https://go.ezoic.net', true],
161
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
162
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
163
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
164
- ]) {
165
- const k = rel + href;
166
- if (_warmed.has(k)) continue;
167
- _warmed.add(k);
168
- const l = document.createElement('link');
169
- l.rel = rel; l.href = href;
170
- if (cors) l.crossOrigin = 'anonymous';
171
- head.appendChild(l);
172
- }
517
+ function isInPool(ph) {
518
+ try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
519
+ }
520
+
521
+ function releaseWrapNode(wrap) {
522
+ try {
523
+ const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
524
+ if (ph) {
525
+ try { getPoolEl().appendChild(ph); } catch (e) {}
526
+ try { state.io && state.io.unobserve(ph); } catch (e) {}
527
+ }
528
+ } catch (e) {}
529
+ try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
173
530
  }
174
531
 
175
- // ─── Ezoic bridge ────────────────────────────────────────────────────────────
176
- // Patch showAds so it silently skips IDs whose placeholder is not in the DOM.
532
+ // ---------------- network warmup ----------------
533
+
534
+ const _warmLinksDone = new Set();
535
+ function warmUpNetwork() {
536
+ try {
537
+ const head = document.head || document.getElementsByTagName('head')[0];
538
+ if (!head) return;
539
+ const links = [
540
+ // Ezoic
541
+ ['preconnect', 'https://g.ezoic.net', true],
542
+ ['dns-prefetch', 'https://g.ezoic.net', false],
543
+ ['preconnect', 'https://go.ezoic.net', true],
544
+ ['dns-prefetch', 'https://go.ezoic.net', false],
545
+ // Google ads
546
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
547
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
548
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
549
+ ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
550
+ ['preconnect', 'https://tpc.googlesyndication.com', true],
551
+ ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
552
+ ];
553
+ for (const [rel, href, cors] of links) {
554
+ const key = `${rel}|${href}`;
555
+ if (_warmLinksDone.has(key)) continue;
556
+ _warmLinksDone.add(key);
557
+ const link = document.createElement('link');
558
+ link.rel = rel;
559
+ link.href = href;
560
+ if (cors) link.crossOrigin = 'anonymous';
561
+ head.appendChild(link);
562
+ }
563
+ } catch (e) {}
564
+ }
177
565
 
566
+ // ---------------- Ezoic bridge ----------------
567
+
568
+ // Patch showAds to silently skip ids not in DOM. This prevents console spam.
178
569
  function patchShowAds() {
179
- const patch = () => {
570
+ const applyPatch = () => {
180
571
  try {
181
- const ez = window.ezstandalone = window.ezstandalone || {};
182
- if (window.__nbbEzoicPatched || typeof ez.showAds !== 'function') return;
183
- window.__nbbEzoicPatched = true;
572
+ window.ezstandalone = window.ezstandalone || {};
573
+ const ez = window.ezstandalone;
574
+ if (window.__nodebbEzoicPatched) return;
575
+ if (typeof ez.showAds !== 'function') return;
576
+
577
+ window.__nodebbEzoicPatched = true;
184
578
  const orig = ez.showAds;
579
+
185
580
  ez.showAds = function (...args) {
186
581
  if (isBlocked()) return;
187
- const ids = Array.isArray(args[0]) ? args[0] : args;
582
+
583
+ let ids = [];
584
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
585
+ else ids = args;
586
+
188
587
  const seen = new Set();
189
588
  for (const v of ids) {
190
589
  const id = parseInt(v, 10);
191
590
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
192
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
591
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
193
592
  if (!ph || !ph.isConnected) continue;
194
593
  seen.add(id);
195
594
  try { orig.call(ez, id); } catch (e) {}
@@ -197,817 +596,873 @@
197
596
  };
198
597
  } catch (e) {}
199
598
  };
200
- patch();
201
- if (!window.__nbbEzoicPatched) {
599
+
600
+ applyPatch();
601
+ if (!window.__nodebbEzoicPatched) {
202
602
  try {
203
- (window.ezstandalone = window.ezstandalone || {}).cmd =
204
- window.ezstandalone.cmd || [];
205
- window.ezstandalone.cmd.push(patch);
603
+ window.ezstandalone = window.ezstandalone || {};
604
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
605
+ window.ezstandalone.cmd.push(applyPatch);
206
606
  } catch (e) {}
207
607
  }
208
608
  }
209
609
 
210
- // ─── Config ──────────────────────────────────────────────────────────────────
610
+ // ---------------- config ----------------
211
611
 
212
- async function fetchConfig() {
213
- if (st.cfg) return st.cfg;
612
+ async function fetchConfigOnce() {
613
+ if (state.cfg) return state.cfg;
214
614
  try {
215
- const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
216
- if (r.ok) st.cfg = await r.json();
217
- } catch (e) {}
218
- return st.cfg;
615
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
616
+ if (!res.ok) return null;
617
+ state.cfg = await res.json();
618
+ return state.cfg;
619
+ } catch (e) {
620
+ return null;
621
+ }
219
622
  }
220
623
 
221
624
  function initPools(cfg) {
222
625
  if (!cfg) return;
223
- if (!st.pools.topic.length) st.pools.topic = parsePool(cfg.placeholderIds);
224
- if (!st.pools.post.length) st.pools.post = parsePool(cfg.messagePlaceholderIds);
225
- if (!st.pools.category.length) st.pools.category = parsePool(cfg.categoryPlaceholderIds);
626
+ if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
627
+ if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
628
+ if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
629
+
630
+ // IMPORTANT:
631
+ // We do NOT prime a DOM pool anymore.
632
+ // Keeping placeholders connected (even offscreen) can lead Ezoic/GPT to
633
+ // pre-define slots, which then causes "Placeholder Id X has already been defined".
634
+ // Instead, we create the placeholder element only when we actually inject its wrapper.
226
635
  }
227
636
 
228
- // ─── Ezoic min-height tightener ──────────────────────────────────────────────
229
- // Ezoic injects `min-height:400px !important` via inline style; we override it.
637
+ // ---------------- insertion primitives ----------------
230
638
 
231
- function tightenMinHeight(wrap) {
232
- try {
233
- if (!wrap) return;
234
- const iframes = wrap.querySelectorAll('iframe');
235
-
236
- // If no iframe yet (or no-fill), Ezoic can still reserve a large gap via
237
- // inline `min-height:400px !important` on `.ezoic-ad` elements.
238
- // Inline !important beats our CSS, so we proactively shrink it.
239
- if (!iframes.length) {
240
- const bad = wrap.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"], span.ezoic-ad[style*="min-height"]');
241
- bad.forEach(n => {
242
- const s = (n.getAttribute('style') || '').toLowerCase();
243
- if (s.includes('min-height:400') || s.includes('min-height: 400')) {
244
- try { n.style.setProperty('min-height', '1px', 'important'); } catch (e) { n.style.minHeight = '1px'; }
245
- try { n.style.setProperty('height', 'auto', 'important'); } catch (e) {}
246
- try { n.style.setProperty('line-height', '0', 'important'); } catch (e) {}
247
- }
248
- });
249
- return;
250
- }
639
+ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
640
+ const wrap = document.createElement('div');
641
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
642
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
643
+ wrap.setAttribute('data-ezoic-wrapid', String(id));
644
+ wrap.setAttribute('data-created', String(now()));
645
+ // "Pinned" placements (after the first item) should remain stable.
646
+ if (afterPos === 1) {
647
+ wrap.setAttribute('data-ezoic-pin', '1');
648
+ }
649
+ wrap.style.width = '100%';
251
650
 
252
- // Find the nearest ezoic-ad ancestor with the 400px inline min-height.
253
- let ref = null;
254
- let p = iframes[0].parentElement;
255
- while (p && p !== wrap) {
256
- if (p.classList && p.classList.contains('ezoic-ad')) {
257
- const st = (p.getAttribute('style') || '').toLowerCase();
258
- if (st.includes('min-height:400') || st.includes('min-height: 400')) { ref = p; break; }
259
- }
260
- p = p.parentElement;
261
- }
262
- ref = ref || wrap.querySelector('.ezoic-ad-adaptive, .ezoic-ad') || wrap;
651
+ if (createPlaceholder) {
652
+ const ph = document.createElement('div');
653
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
654
+ ph.setAttribute('data-ezoic-id', String(id));
655
+ wrap.appendChild(ph);
656
+ }
263
657
 
264
- let refTop = 0;
265
- try { refTop = ref.getBoundingClientRect().top; } catch (e) {}
658
+ return wrap;
659
+ }
266
660
 
267
- let maxH = 0;
268
- iframes.forEach(f => {
269
- const r = f.getBoundingClientRect();
270
- if (r.width > 1 && r.height > 1) maxH = Math.max(maxH, r.bottom - refTop);
271
- });
272
- if (!maxH) iframes.forEach(f => {
273
- maxH = Math.max(maxH,
274
- parseInt(f.getAttribute('height') || '0', 10), f.offsetHeight || 0);
275
- });
276
- if (!maxH) return;
277
-
278
- const h = Math.max(1, Math.ceil(maxH)) + 'px';
279
- const set = (n) => {
280
- if (!n || !n.style) return;
281
- try { n.style.setProperty('min-height', h, 'important'); } catch (e) { n.style.minHeight = h; }
282
- try { n.style.setProperty('height', 'auto', 'important'); } catch (e) {}
283
- try { n.style.setProperty('line-height', '0', 'important'); } catch (e) {}
284
- };
661
+ function insertAfter(target, id, kindClass, afterPos) {
662
+ if (!target || !target.insertAdjacentElement) return null;
663
+ if (findWrap(kindClass, afterPos)) return null;
664
+ if (insertingIds.has(id)) return null;
285
665
 
286
- let cur = ref;
287
- while (cur && cur !== wrap) {
288
- if (cur.classList && cur.classList.contains('ezoic-ad')) {
289
- const s = (cur.getAttribute('style') || '').toLowerCase();
290
- if (s.includes('min-height:400') || s.includes('min-height: 400')) set(cur);
291
- }
292
- cur = cur.parentElement;
293
- }
294
- set(ref);
295
- ref.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach(n => {
296
- const s = (n.getAttribute('style') || '').toLowerCase();
297
- if (s.includes('min-height:400') || s.includes('min-height: 400')) set(n);
298
- });
666
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
299
667
 
300
- if (isMobile()) {
301
- try { ref.style.setProperty('width', '100%', 'important'); } catch (e) {}
302
- try { ref.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
303
- try { ref.style.setProperty('min-width', '0', 'important'); } catch (e) {}
668
+ insertingIds.add(id);
669
+ try {
670
+ const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
671
+ target.insertAdjacentElement('afterend', wrap);
672
+
673
+ // If placeholder exists elsewhere (including pool), move it into the wrapper.
674
+ if (existingPh) {
675
+ try {
676
+ existingPh.setAttribute('data-ezoic-id', String(id));
677
+ if (!wrap.firstElementChild) wrap.appendChild(existingPh);
678
+ else wrap.replaceChild(existingPh, wrap.firstElementChild);
679
+ } catch (e) {}
304
680
  }
305
- } catch (e) {}
306
- }
307
681
 
308
- function watchFill(wrap) {
309
- try {
310
- if (!wrap || wrap.__ezFillObs) return;
311
- const start = now();
312
- const burst = () => {
313
- try { tightenMinHeight(wrap); } catch (e) {}
314
- if (now() - start < 6000) setTimeout(burst, 350);
315
- };
316
- const obs = new MutationObserver(muts => {
317
- if (isFilled(wrap)) { wrap.classList.remove('is-empty'); burst(); }
318
- for (const m of muts)
319
- if (m.type === 'attributes' && m.attributeName === 'style') { tightenMinHeight(wrap); break; }
320
- if (now() - start > 7000) { obs.disconnect(); wrap.__ezFillObs = null; }
321
- });
322
- obs.observe(wrap, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
323
- wrap.__ezFillObs = obs;
324
- } catch (e) {}
682
+ return wrap;
683
+ } finally {
684
+ insertingIds.delete(id);
685
+ }
325
686
  }
326
687
 
327
- // Global safety net: catch Ezoic 400px min-height inside posts.
328
- function globalGapFix() {
329
- try {
330
- if (window.__nbbEzoicGapFix) return;
331
- window.__nbbEzoicGapFix = true;
332
- const root = document.getElementById('content') ||
333
- document.querySelector('[component="content"], #panel') || document.body;
334
- const inPost = el => !!(el && el.closest &&
335
- el.closest('[component="post"], .topic, .posts, [component="topic"]'));
336
- const fix = r => {
337
- if (!r || !r.querySelectorAll) return;
338
- r.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"]')
339
- .forEach(n => {
340
- if (!(n.getAttribute('style') || '').toLowerCase().includes('min-height:400')) return;
341
- if (!inPost(n)) return;
342
- tightenMinHeight(n.closest('.' + WRAP_CLASS) || n.parentElement || n);
343
- });
344
- };
345
- requestAnimationFrame(() => fix(root));
346
- const pending = new Set();
347
- let sched = false;
348
- new MutationObserver(muts => {
349
- for (const m of muts) {
350
- const t = m.type === 'attributes'
351
- ? (m.target.nodeType === 1 ? m.target : m.target.parentElement)
352
- : null;
353
- if (t) pending.add(t);
354
- else m.addedNodes && m.addedNodes.forEach(n => n.nodeType === 1 && pending.add(n));
355
- }
356
- if (pending.size && !sched) {
357
- sched = true;
358
- requestAnimationFrame(() => { sched = false; pending.forEach(n => fix(n)); pending.clear(); });
359
- }
360
- }).observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
361
- } catch (e) {}
362
- }
688
+ function pickIdFromAll(allIds, cursorKey) {
689
+ const n = allIds.length;
690
+ if (!n) return null;
363
691
 
364
- // ─── DOM helpers ─────────────────────────────────────────────────────────────
692
+ for (let tries = 0; tries < n; tries++) {
693
+ const idx = state[cursorKey] % n;
694
+ state[cursorKey] = (state[cursorKey] + 1) % n;
695
+ const id = allIds[idx];
365
696
 
366
- function getItems(kind) {
367
- if (kind === 'topic') return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
368
- if (!el.isConnected) return false;
369
- if (!el.querySelector('[component="post/content"]')) return false;
370
- const pp = el.parentElement && el.parentElement.closest(SEL.post);
371
- return !(pp && pp !== el) && el.getAttribute('component') !== 'post/parent';
372
- });
373
- if (kind === 'categoryTopics') return Array.from(document.querySelectorAll(SEL.topic));
374
- if (kind === 'categories') return Array.from(document.querySelectorAll(SEL.category));
375
- return [];
376
- }
697
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
698
+ if (ph && ph.isConnected && !isInPool(ph)) continue;
377
699
 
378
- function isAdjacentWrap(el) {
379
- const n = el.nextElementSibling, p = el.previousElementSibling;
380
- return (n && n.classList.contains(WRAP_CLASS)) || (p && p.classList.contains(WRAP_CLASS));
381
- }
700
+ return id;
701
+ }
382
702
 
383
- function findWrapAt(kindClass, afterPos) {
384
- return document.querySelector('.' + WRAP_CLASS + '.' + kindClass +
385
- '[data-ezoic-after="' + afterPos + '"]');
703
+ return null;
386
704
  }
387
705
 
388
- // ─── Wrap creation / destruction ─────────────────────────────────────────────
706
+ function pruneOrphanWraps(kindClass, items) {
707
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
708
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
709
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
710
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
711
+ if (!items || !items.length) return 0;
712
+ const itemSet = new Set(items);
713
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
714
+ let removed = 0;
715
+
716
+ const isFilled = (wrap) => {
717
+ return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
718
+ };
389
719
 
390
- function buildWrap(id, kindClass, afterPos) {
391
- const wrap = document.createElement('div');
392
- wrap.className = WRAP_CLASS + ' ' + kindClass;
393
- wrap.setAttribute('data-ezoic-after', String(afterPos));
394
- wrap.setAttribute('data-ezoic-wrapid', String(id));
395
- wrap.setAttribute('data-created', String(now()));
396
- if (afterPos === 1) wrap.setAttribute('data-ezoic-pin', '1');
397
- wrap.style.width = '100%';
398
- const ph = document.createElement('div');
399
- ph.id = PLACEHOLDER_PREFIX + id;
400
- ph.setAttribute('data-ezoic-id', String(id));
401
- wrap.appendChild(ph);
402
- return wrap;
403
- }
720
+ const hasNearbyItem = (wrap) => {
721
+ // NodeBB/skins can inject separators/spacers; be tolerant.
722
+ let prev = wrap.previousElementSibling;
723
+ for (let i = 0; i < 14 && prev; i++) {
724
+ if (itemSet.has(prev)) return true;
725
+ prev = prev.previousElementSibling;
726
+ }
727
+ let next = wrap.nextElementSibling;
728
+ for (let i = 0; i < 14 && next; i++) {
729
+ if (itemSet.has(next)) return true;
730
+ next = next.nextElementSibling;
731
+ }
732
+ return false;
733
+ };
404
734
 
405
- function dropWrap(wrap) {
406
- // Unobserve placeholder, then remove the whole wrap node.
407
- // The placeholder ID is freed back to the pool automatically because
408
- // it no longer has a DOM element — pickId will pick it again.
409
- try {
410
- const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
411
- if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
412
- } catch (e) {}
413
- try { wrap.remove(); } catch (e) {}
735
+ wraps.forEach((wrap) => {
736
+ // Never prune pinned placements.
737
+ try {
738
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
739
+ } catch (e) {}
740
+
741
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
742
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
743
+ const isMessage = (kindClass === 'ezoic-ad-message');
744
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
745
+
746
+ // Never prune a fresh wrap: it may fill late.
747
+ try {
748
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
749
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
750
+ } catch (e) {}
751
+
752
+ if (hasNearbyItem(wrap)) {
753
+ try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
754
+ return;
755
+ }
756
+
757
+ // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
758
+ // back-to-back while scrolling. We'll recycle it when its anchor comes back.
759
+ try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
760
+
761
+ // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
762
+ if (isMessage) {
763
+ try {
764
+ const r = wrap.getBoundingClientRect();
765
+ const vh = Math.max(1, window.innerHeight || 1);
766
+ const farAbove = r.bottom < -vh * 2;
767
+ const farBelow = r.top > vh * 4;
768
+ if (!farAbove && !farBelow) return;
769
+ } catch (e) {
770
+ return;
414
771
  }
772
+ }
773
+
774
+ withInternalDomChange(() => releaseWrapNode(wrap));
775
+ removed++;
776
+ });
415
777
 
416
- function getDetachedMap(kindClass) {
417
- let m = st.detached.get(kindClass);
418
- if (!m) { m = new Map(); st.detached.set(kindClass, m); }
419
- return m;
778
+ return removed;
420
779
  }
421
780
 
422
- function detachWrap(kindClass, wrap) {
423
- // Keep a wrap node in memory so we can re-attach it later without
424
- // re-requesting the same placement (avoids GPT "format already created" and
425
- // prevents ads from vanishing permanently when NodeBB recycles DOM nodes).
426
- try {
427
- const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
428
- if (!afterPos) return dropWrap(wrap);
781
+ function decluster(kindClass) {
782
+ // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
783
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
784
+ if (wraps.length < 2) return 0;
785
+
786
+ const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
787
+
788
+ const isFilled = (wrap) => {
789
+ return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
790
+ };
791
+
792
+ const isFresh = (wrap) => {
793
+ try {
794
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
795
+ return created && (now() - created) < keepEmptyWrapMs();
796
+ } catch (e) {
797
+ return false;
798
+ }
799
+ };
429
800
 
430
- // Unobserve placeholder while detached
801
+ let removed = 0;
802
+ for (const w of wraps) {
803
+ // Never decluster pinned placements.
431
804
  try {
432
- const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
433
- if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
805
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
434
806
  } catch (e) {}
435
807
 
436
- const m = getDetachedMap(kindClass);
437
- // If a cached wrap for that position already exists, prefer the newest
438
- // and fully drop the old one.
439
- if (m.has(afterPos)) {
440
- try { dropWrap(m.get(afterPos)); } catch (e) {}
441
- }
442
- m.set(afterPos, wrap);
443
-
444
- // Hard cap cache size per kind to avoid unbounded memory growth.
445
- if (m.size > 80) {
446
- const oldest = [...m.entries()].sort((a, b) => {
447
- const ta = parseInt((a[1] && a[1].getAttribute('data-created')) || '0', 10);
448
- const tb = parseInt((b[1] && b[1].getAttribute('data-created')) || '0', 10);
449
- return ta - tb;
450
- }).slice(0, m.size - 80);
451
- oldest.forEach(([pos, node]) => {
452
- m.delete(pos);
453
- try { dropWrap(node); } catch (e) {}
454
- });
455
- }
808
+ let prev = w.previousElementSibling;
809
+ for (let i = 0; i < 3 && prev; i++) {
810
+ if (isWrap(prev)) {
811
+ // If the previous wrap is pinned, keep this one (spacing is intentional).
812
+ try {
813
+ if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
814
+ } catch (e) {}
456
815
 
457
- // Actually detach from DOM
458
- try { wrap.remove(); } catch (e) {}
459
- } catch (e) {
460
- try { dropWrap(wrap); } catch (x) {}
461
- }
462
- }
816
+ // Never remove a wrap that is already filled; otherwise it looks like
817
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
818
+ const prevFilled = isFilled(prev);
819
+ const curFilled = isFilled(w);
463
820
 
464
- function pickId(poolKey, cursorKey) {
465
- const pool = st.pools[poolKey];
466
- if (!pool.length) return null;
467
- const n = pool.length;
468
- for (let tries = 0; tries < n; tries++) {
469
- const idx = st.cursors[cursorKey] % n;
470
- st.cursors[cursorKey] = (idx + 1) % n;
471
- const id = pool[idx];
472
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
473
- // ID is available if its placeholder is absent from DOM or is floating detached.
474
- if (!ph || !ph.isConnected || !ph.closest('.' + WRAP_CLASS)) return id;
821
+ if (curFilled) {
822
+ // If the previous one is empty (and not fresh), drop the previous instead.
823
+ if (!prevFilled && !isFresh(prev)) {
824
+ withInternalDomChange(() => releaseWrapNode(prev));
825
+ removed++;
826
+ }
827
+ break;
828
+ }
829
+
830
+ // Current is empty.
831
+ // Don't decluster two "fresh" empty wraps it can reduce fill on slow auctions.
832
+ // Only decluster when previous is filled, or when current is stale.
833
+ if (prevFilled || !isFresh(w)) {
834
+ withInternalDomChange(() => releaseWrapNode(w));
835
+ removed++;
836
+ }
837
+ break;
838
+ }
839
+ prev = prev.previousElementSibling;
840
+ }
475
841
  }
476
- return null; // all IDs currently mounted
842
+ return removed;
477
843
  }
478
844
 
479
- // ─── IntersectionObserver ────────────────────────────────────────────────────
845
+ // ---------------- show (preload / fast fill) ----------------
846
+
847
+ function ensurePreloadObserver() {
848
+ const desiredMargin = getPreloadRootMargin();
849
+ if (state.io && state.ioMargin === desiredMargin) return state.io;
850
+
851
+ if (state.io) {
852
+ try { state.io.disconnect(); } catch (e) {}
853
+ state.io = null;
854
+ }
480
855
 
481
- function ensureIO() {
482
- const margin = getIoMargin();
483
- if (st.io && st.ioMargin === margin) return st.io;
484
- try { st.io && st.io.disconnect(); } catch (e) {}
485
- st.io = null;
486
856
  try {
487
- st.io = new IntersectionObserver(entries => {
488
- for (const e of entries) {
489
- if (!e.isIntersecting) continue;
490
- try { st.io && st.io.unobserve(e.target); } catch (x) {}
491
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
492
- if (id > 0) enqueueShow(id);
857
+ state.io = new IntersectionObserver((entries) => {
858
+ for (const ent of entries) {
859
+ if (!ent.isIntersecting) continue;
860
+ const el = ent.target;
861
+ try { state.io && state.io.unobserve(el); } catch (e) {}
862
+
863
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
864
+ const id = parseInt(idAttr, 10);
865
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
493
866
  }
494
- }, { rootMargin: margin, threshold: 0 });
495
- st.ioMargin = margin;
496
- // Re-observe all live placeholders.
497
- document.querySelectorAll('[id^="' + PLACEHOLDER_PREFIX + '"]')
498
- .forEach(ph => { try { st.io.observe(ph); } catch (e) {} });
499
- } catch (e) { st.io = st.ioMargin = null; }
500
- return st.io;
867
+ }, { root: null, rootMargin: desiredMargin, threshold: 0 });
868
+
869
+ state.ioMargin = desiredMargin;
870
+ } catch (e) {
871
+ state.io = null;
872
+ state.ioMargin = null;
873
+ }
874
+
875
+ // Re-observe current placeholders
876
+ try {
877
+ if (state.io) {
878
+ const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
879
+ nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
880
+ }
881
+ } catch (e) {}
882
+
883
+ return state.io;
501
884
  }
502
885
 
503
- function observePh(id) {
504
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
886
+ function observePlaceholder(id) {
887
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
505
888
  if (!ph || !ph.isConnected) return;
506
889
  ph.setAttribute('data-ezoic-id', String(id));
507
- try { ensureIO() && ensureIO().observe(ph); } catch (e) {}
508
- // Immediate fire if already near viewport.
890
+
891
+ const io = ensurePreloadObserver();
892
+ try { io && io.observe(ph); } catch (e) {}
893
+
894
+ // If already near viewport, fire immediately.
895
+ // Mobile tends to scroll faster + has slower auctions, so we fire earlier.
509
896
  try {
510
897
  const r = ph.getBoundingClientRect();
511
- const mob = isMobile(), boost = isBoosted();
512
- const scr = boost ? (mob ? 9 : 5) : (mob ? 6 : 3);
513
- const bot = boost ? (mob ? -2600 : -1500) : (mob ? -1400 : -800);
514
- if (r.top < window.innerHeight * scr && r.bottom > bot) enqueueShow(id);
898
+ const mobile = isMobile();
899
+ const screens = isBoosted()
900
+ ? (mobile ? 9.0 : 5.0)
901
+ : (mobile ? 6.0 : 3.0);
902
+ const minBottom = isBoosted()
903
+ ? (mobile ? -2600 : -1500)
904
+ : (mobile ? -1400 : -800);
905
+ if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
515
906
  } catch (e) {}
516
907
  }
517
908
 
518
- // ─── Show queue ──────────────────────────────────────────────────────────────
519
-
520
909
  function enqueueShow(id) {
521
910
  if (!id || isBlocked()) return;
522
- if (now() - (st.lastShow.get(id) || 0) < 900) return;
523
- if (st.inflight >= getMaxInflight()) {
524
- if (!st.pendingSet.has(id)) { st.pending.push(id); st.pendingSet.add(id); }
911
+
912
+ // per-id throttle
913
+ const t = now();
914
+ const last = state.lastShowById.get(id) || 0;
915
+ if (t - last < 900) return;
916
+
917
+ const max = getMaxInflight();
918
+ if (state.inflight >= max) {
919
+ if (!state.pendingSet.has(id)) {
920
+ state.pending.push(id);
921
+ state.pendingSet.add(id);
922
+ }
525
923
  return;
526
924
  }
925
+
527
926
  startShow(id);
528
927
  }
529
928
 
530
929
  function drainQueue() {
531
930
  if (isBlocked()) return;
532
- while (st.inflight < getMaxInflight() && st.pending.length) {
533
- const id = st.pending.shift();
534
- st.pendingSet.delete(id);
931
+ const max = getMaxInflight();
932
+ while (state.inflight < max && state.pending.length) {
933
+ const id = state.pending.shift();
934
+ state.pendingSet.delete(id);
535
935
  startShow(id);
536
936
  }
537
937
  }
538
938
 
539
- function scheduleMarkEmpty(id) {
540
- setTimeout(() => {
541
- try {
542
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
543
- if (!ph || !ph.isConnected) return;
544
- const w = ph.closest('.' + WRAP_CLASS);
545
- if (!w) return;
546
- if (isFilled(ph)) { w.classList.remove('is-empty'); tightenMinHeight(w); }
547
- else {
548
- w.classList.add('is-empty');
549
- // Kill any large reserved gap even when there is no iframe.
550
- tightenMinHeight(w);
551
- watchFill(w);
552
- scheduleHardPruneEmpty(id);
553
- }
554
- } catch (e) {}
555
- }, 15_000);
556
- }
939
+ function markEmptyWrapper(id) {
940
+ // If still empty after delay, mark empty for CSS (1px)
941
+ try {
942
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
943
+ if (!ph || !ph.isConnected) return;
944
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
945
+ if (!wrap) return;
557
946
 
558
- // If a placement stays empty for a long time, remove it entirely so the user
559
- // doesn't get persistent blank blocks. This also frees the placeholder ID so
560
- // a future injection can try a different one.
561
- function scheduleHardPruneEmpty(id) {
562
- setTimeout(() => {
563
- try {
564
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
565
- if (!ph || !ph.isConnected) return;
566
- const w = ph.closest('.' + WRAP_CLASS);
567
- if (!w) return;
568
- // If still not filled after 45s, drop it.
569
- if (!isFilled(w)) {
570
- // Also ensure it isn't cached as detached.
947
+ setTimeout(() => {
948
+ try {
949
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
950
+ if (!ph2 || !ph2.isConnected) return;
951
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
952
+ if (!w2) return;
953
+
954
+ // Don't collapse "fresh" placements; slow auctions/CMP can fill late.
571
955
  try {
572
- const kind = Array.from(w.classList).find(c => c.startsWith('ezoic-ad-'));
573
- if (kind) {
574
- const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '0', 10);
575
- const m = st.detached.get(kind);
576
- if (m && afterPos && m.get(afterPos) === w) m.delete(afterPos);
577
- }
956
+ const created = parseInt(w2.getAttribute('data-created') || '0', 10);
957
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
578
958
  } catch (e) {}
579
- withInternal(() => dropWrap(w));
580
- }
581
- } catch (e) {}
582
- }, 45_000);
959
+
960
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
961
+ if (!hasAd) {
962
+ w2.classList.add('is-empty');
963
+ watchWrapForFill(w2);
964
+ } else {
965
+ w2.classList.remove('is-empty');
966
+ tightenEzoicMinHeight(w2);
967
+ }
968
+ } catch (e) {}
969
+ }, 15000);
970
+ } catch (e) {}
583
971
  }
584
972
 
585
973
  function startShow(id) {
586
974
  if (!id || isBlocked()) return;
587
- st.inflight++;
588
- let done = false;
589
- const finish = () => {
590
- if (done) return; done = true;
591
- st.inflight = Math.max(0, st.inflight - 1);
975
+
976
+ state.inflight++;
977
+ let released = false;
978
+ const release = () => {
979
+ if (released) return;
980
+ released = true;
981
+ state.inflight = Math.max(0, state.inflight - 1);
592
982
  drainQueue();
593
983
  };
594
- const timeout = setTimeout(finish, 6500);
984
+
985
+ const hardTimer = setTimeout(release, 6500);
595
986
 
596
987
  requestAnimationFrame(() => {
597
988
  try {
598
989
  if (isBlocked()) return;
599
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
990
+
991
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
600
992
  if (!ph || !ph.isConnected) return;
601
- if (isFilled(ph)) { clearTimeout(timeout); finish(); return; }
602
- if (now() - (st.lastShow.get(id) || 0) < 900) return;
603
- st.lastShow.set(id, now());
604
993
 
605
- const ez = window.ezstandalone = window.ezstandalone || {};
994
+ // If the placeholder already has creative, avoid re-showing.
995
+ // Re-showing is a common source of "Placeholder Id X has already been defined".
996
+ try {
997
+ if (ph.querySelector && ph.querySelector('iframe, ins, img, video, [data-google-container-id]')) {
998
+ clearTimeout(hardTimer);
999
+ release();
1000
+ return;
1001
+ }
1002
+ } catch (e) {}
1003
+
1004
+ const t = now();
1005
+ const last = state.lastShowById.get(id) || 0;
1006
+ if (t - last < 900) return;
1007
+ state.lastShowById.set(id, t);
1008
+
1009
+ window.ezstandalone = window.ezstandalone || {};
1010
+ const ez = window.ezstandalone;
1011
+
606
1012
  const doShow = () => {
607
1013
  try { ez.showAds(id); } catch (e) {}
608
- scheduleMarkEmpty(id);
1014
+ try { markEmptyWrapper(id); } catch (e) {}
609
1015
  try {
610
- const w = document.getElementById(PLACEHOLDER_PREFIX + id)
611
- ?.closest('.' + WRAP_CLASS);
612
- if (w) {
613
- watchFill(w);
614
- setTimeout(() => tightenMinHeight(w), 900);
615
- setTimeout(() => tightenMinHeight(w), 2200);
1016
+ const phw = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
1017
+ const ww = phw && phw.closest ? phw.closest(`.${WRAP_CLASS}`) : null;
1018
+ if (ww) {
1019
+ watchWrapForFill(ww);
1020
+ setTimeout(() => { try { tightenEzoicMinHeight(ww); } catch (e) {} }, 900);
1021
+ setTimeout(() => { try { tightenEzoicMinHeight(ww); } catch (e) {} }, 2200);
616
1022
  }
617
1023
  } catch (e) {}
618
- setTimeout(() => { clearTimeout(timeout); finish(); }, 650);
1024
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
619
1025
  };
620
- if (Array.isArray(ez.cmd)) try { ez.cmd.push(doShow); } catch (e) { doShow(); }
621
- else doShow();
622
- } finally {} // timeout covers early returns
623
- });
624
- }
625
-
626
- // ─── Core injection ──────────────────────────────────────────────────────────
627
-
628
- function getOrdinal(el, idx) {
629
- try {
630
- const v = el.getAttribute('data-index') || (el.dataset && el.dataset.index);
631
- if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10) + 1;
632
- } catch (e) {}
633
- return idx + 1;
634
- }
635
1026
 
636
- function computeTargets(items, interval, showFirst) {
637
- // Build ordinal map and determine target positions.
638
- const map = new Map();
639
- let max = 0;
640
- items.forEach((el, i) => {
641
- const ord = getOrdinal(el, i);
642
- map.set(ord, el);
643
- if (ord > max) max = ord;
1027
+ if (Array.isArray(ez.cmd)) {
1028
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
1029
+ } else {
1030
+ doShow();
1031
+ }
1032
+ } finally {
1033
+ // hardTimer releases on early return
1034
+ }
644
1035
  });
645
- const targets = [];
646
- if (showFirst && max >= 1) targets.push(1);
647
- for (let i = interval; i <= max; i += interval) targets.push(i);
648
- return { map, targets: [...new Set(targets)].sort((a, b) => a - b) };
649
1036
  }
650
1037
 
651
- /**
652
- * Remove all wraps whose anchor item is no longer in the live item set.
653
- *
654
- * Strategy: always fully remove (not just hide).
655
- * - The placeholder ID returns to the pool naturally (no DOM element → pickId skips).
656
- * - When the user scrolls back down and those items reload, fresh wraps are injected.
657
- * - The viewport guard in injectWraps (rect.bottom < 0) prevents re-injection
658
- * on items that are above the fold (just loaded by NodeBB during upward scroll).
659
- */
660
- function removeOrphanWraps(kindClass, itemSet) {
661
- document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach(wrap => {
662
- if (wrap.getAttribute('data-ezoic-pin') === '1') return;
663
-
664
- // Check if anchor is still alive using DOM proximity.
665
- const pivot = (wrap.parentElement && wrap.parentElement.classList.contains(HOST_CLASS))
666
- ? wrap.parentElement : wrap;
667
- let found = false, el = pivot.previousElementSibling;
668
- for (let i = 0; i < 4 && el; i++, el = el.previousElementSibling)
669
- if (itemSet.has(el)) { found = true; break; }
670
- if (!found) {
671
- el = pivot.nextElementSibling;
672
- for (let i = 0; i < 4 && el; i++, el = el.nextElementSibling)
673
- if (itemSet.has(el)) { found = true; break; }
674
- }
1038
+ // ---------------- core injection ----------------
675
1039
 
676
- if (!found) {
677
- // Detach instead of destroy so ads can survive DOM recycling.
678
- withInternal(() => detachWrap(kindClass, wrap));
679
- }
680
- });
1040
+ function getItemOrdinal(el, fallbackIndex) {
1041
+ try {
1042
+ if (!el) return fallbackIndex + 1;
1043
+ const di = el.getAttribute('data-index') || (el.dataset && (el.dataset.index || el.dataset.postIndex));
1044
+ if (di !== null && di !== undefined && di !== '' && !isNaN(di)) {
1045
+ const n = parseInt(di, 10);
1046
+ if (Number.isFinite(n) && n >= 0) return n + 1;
1047
+ }
1048
+ const d1 = el.getAttribute('data-idx') || el.getAttribute('data-position') || (el.dataset && (el.dataset.idx || el.dataset.position));
1049
+ if (d1 !== null && d1 !== undefined && d1 !== '' && !isNaN(d1)) {
1050
+ const n = parseInt(d1, 10);
1051
+ if (Number.isFinite(n) && n > 0) return n;
1052
+ }
1053
+ } catch (e) {}
1054
+ return fallbackIndex + 1;
1055
+ }
1056
+
1057
+ function buildOrdinalMap(items) {
1058
+ const map = new Map();
1059
+ let max = 0;
1060
+ for (let i = 0; i < items.length; i++) {
1061
+ const el = items[i];
1062
+ const ord = getItemOrdinal(el, i);
1063
+ map.set(ord, el);
1064
+ if (ord > max) max = ord;
681
1065
  }
1066
+ return { map, max };
1067
+ }
682
1068
 
683
- function reapDetached(kindClass) {
684
- // Soft-prune very old detached nodes.
685
- try {
686
- const m = st.detached.get(kindClass);
687
- if (!m || !m.size) return;
688
- for (const [pos, wrap] of m.entries()) {
689
- if (!wrap) { m.delete(pos); continue; }
690
- if (wrap.isConnected) { m.delete(pos); continue; }
691
- const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
692
- if (created && (now() - created) > 15 * 60 * 1000) {
693
- m.delete(pos);
694
- try { dropWrap(wrap); } catch (e) {}
695
- }
696
- }
697
- } catch (e) {}
1069
+
1070
+ function computeTargets(count, interval, showFirst) {
1071
+ const out = [];
1072
+ if (count <= 0) return out;
1073
+ if (showFirst) out.push(1);
1074
+ for (let i = 1; i <= count; i++) {
1075
+ if (i % interval === 0) out.push(i);
1076
+ }
1077
+ // unique + sorted
1078
+ return Array.from(new Set(out)).sort((a, b) => a - b);
698
1079
  }
699
1080
 
700
- function injectWraps(kindClass, items, interval, showFirst, poolKey, cursorKey) {
1081
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
701
1082
  if (!items.length) return 0;
702
- const { map, targets } = computeTargets(items, interval, showFirst);
703
- const max = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
704
- let n = 0;
705
1083
 
706
- const detachedMap = st.detached.get(kindClass);
1084
+ const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
1085
+ const targets = computeTargets(maxOrdinal, interval, showFirst);
1086
+ let inserted = 0;
1087
+ const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
707
1088
 
708
- for (const pos of targets) {
709
- if (n >= max) break;
710
- const el = map.get(pos);
1089
+ for (const afterPos of targets) {
1090
+ if (inserted >= maxInserts) break;
1091
+ const el = ordinalMap.get(afterPos);
1092
+ if (!el) continue;
711
1093
  if (!el || !el.isConnected) continue;
712
1094
 
713
- // ── Viewport guard ────────────────────────────────────────────────────────
714
- // Never inject after an element whose bottom is above the viewport top.
715
- // NodeBB loads items above the fold when the user scrolls up; those items
716
- // have rect.bottom < 0 at load time. Injecting there causes the pile-up.
717
- try { if (el.getBoundingClientRect().bottom < 0) continue; } catch (e) {}
718
-
719
- if (isAdjacentWrap(el)) continue;
720
- if (findWrapAt(kindClass, pos)) continue;
721
-
722
- // If we previously had a wrap at this exact position but NodeBB recycled
723
- // the DOM, re-attach the cached wrap instead of creating a new one.
724
- if (detachedMap && detachedMap.has(pos)) {
725
- const cached = detachedMap.get(pos);
726
- detachedMap.delete(pos);
727
- if (cached) {
728
- withInternal(() => el.insertAdjacentElement('afterend', cached));
729
- // Re-observe placeholder and trigger show only if the cached wrap is empty.
730
- try {
731
- const ph = cached.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
732
- const id = ph ? parseInt(ph.getAttribute('data-ezoic-id') || (ph.id || '').split('-').pop(), 10) : 0;
733
- if (id > 0) {
734
- observePh(id);
735
- if (!isFilled(cached)) enqueueShow(id);
736
- }
737
- } catch (e) {}
738
- n++;
739
- continue;
1095
+ // Viewport guard: when the user scrolls UP, NodeBB may load items above the fold.
1096
+ // Injecting ads there immediately causes wrap pile-ups near the top.
1097
+ // Skip targets whose anchor is above the viewport top at injection time.
1098
+ try {
1099
+ if (scrollDir < 0 && el.getBoundingClientRect().bottom < 0) continue;
1100
+ } catch (e) {}
1101
+
1102
+ if (isAdjacentAd(el)) continue;
1103
+ if (findWrap(kindClass, afterPos)) continue;
1104
+
1105
+ let id = pickIdFromAll(allIds, cursorKey);
1106
+ let recycledWrap = null;
1107
+
1108
+ // If the pool is exhausted (all placeholder ids already mounted), recycle a wrap that is far
1109
+ // above the viewport by moving it to the new target instead of creating a new placeholder.
1110
+ // This avoids "Placeholder Id X has already been defined" and also ensures ads keep
1111
+ // appearing on very long infinite scroll sessions.
1112
+ if (!id) {
1113
+ // Safe mode: disable recycling for topic message ads to prevent visual "jumping"
1114
+ // (ads seemingly moving back under the first post during virtualized scroll).
1115
+ const allowRecycle = kindClass !== 'ezoic-ad-message';
1116
+ recycledWrap = (allowRecycle && scrollDir > 0) ? pickRecyclableWrap(kindClass) : null;
1117
+ if (recycledWrap) {
1118
+ id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
740
1119
  }
741
1120
  }
742
1121
 
743
- const id = pickId(poolKey, cursorKey);
744
1122
  if (!id) break;
745
1123
 
746
- const wrap = buildWrap(id, kindClass, pos);
747
- withInternal(() => el.insertAdjacentElement('afterend', wrap));
748
- observePh(id);
749
- n++;
1124
+ const wrap = recycledWrap
1125
+ ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
1126
+ : insertAfter(el, id, kindClass, afterPos);
1127
+ if (!wrap) continue;
1128
+
1129
+ // observePlaceholder is safe for existing ids (it is idempotent) and helps with "late fill"
1130
+ // after ajaxify/infinite scroll mutations.
1131
+ observePlaceholder(id);
1132
+ inserted++;
750
1133
  }
751
- return n;
752
- }
753
1134
 
754
- // ─── Pile-up fix for category topic lists ────────────────────────────────────
755
- // If the skin requires ad divs inside a <ul>, wrap them in <li> hosts and
756
- // correct their position after NodeBB re-renders.
1135
+ return inserted;
1136
+ }
757
1137
 
758
- let _pileFixSched = false, _pileFixLast = 0;
759
- function schedulePileFix() {
760
- if (now() - _pileFixLast < 180 || _pileFixSched) return;
761
- _pileFixSched = true;
762
- requestAnimationFrame(() => {
763
- _pileFixSched = false; _pileFixLast = now();
764
- try {
765
- const li = document.querySelector(SEL.topic);
766
- const ul = li && li.closest('ul,ol');
767
- if (!ul) return;
1138
+ function pickRecyclableWrap(kindClass) {
1139
+ // Only recycle wrappers that are well above the viewport to avoid visible "disappearing".
1140
+ // With very small id pools (e.g. 6 ids), recycling is the only way to keep ads appearing
1141
+ // on long topics without redefining placeholders.
1142
+ const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
1143
+ if (!wraps || !wraps.length) return null;
1144
+
1145
+ const vh = Math.max(300, window.innerHeight || 800);
1146
+ // Recycle only when the wrapper is far above the viewport.
1147
+ // This keeps ads on-screen longer (especially on mobile) and reduces "blink".
1148
+ const threshold = -Math.min(9000, Math.round(vh * 6));
1149
+
1150
+ let best = null;
1151
+ let bestBottom = Infinity;
1152
+ for (const w of wraps) {
1153
+ if (!w || !w.isConnected) continue;
1154
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
1155
+ const rect = w.getBoundingClientRect();
1156
+ if (rect.bottom < threshold) {
1157
+ if (rect.bottom < bestBottom) {
1158
+ bestBottom = rect.bottom;
1159
+ best = w;
1160
+ }
1161
+ }
1162
+ }
1163
+ return best;
1164
+ }
768
1165
 
769
- // Wrap bare ad divs that are direct <ul> children into <li> hosts.
770
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
771
- ul.querySelectorAll(':scope > ' + wrapSel).forEach(w => {
772
- if (w.parentElement !== ul) return;
773
- const host = document.createElement('li');
774
- host.className = HOST_CLASS;
775
- host.setAttribute('role', 'listitem');
776
- host.style.cssText = 'list-style:none;width:100%;';
777
- ul.insertBefore(host, w);
778
- host.appendChild(w);
779
- });
1166
+ function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
1167
+ try {
1168
+ if (!anchorEl || !wrap || !wrap.isConnected) return null;
780
1169
 
781
- // Detect pile-up: 2+ between-ad hosts adjacent with no topic between them.
782
- let run = 0, maxRun = 0;
783
- for (const c of ul.children) {
784
- const isBetween = (c.tagName === 'LI' && c.classList.contains(HOST_CLASS) &&
785
- c.querySelector(wrapSel)) ||
786
- (c.matches && c.matches(wrapSel));
787
- if (isBetween) maxRun = Math.max(maxRun, ++run);
788
- else if (c.matches && c.matches(SEL.topic)) run = 0;
789
- else run = 0;
790
- }
791
- if (maxRun < 2) return;
792
-
793
- // Build ordinal → topic LI map.
794
- const ordMap = new Map();
795
- Array.from(ul.querySelectorAll(':scope > ' + SEL.topic)).forEach((li, i) => {
796
- const di = li.dataset && li.dataset.index;
797
- const ord = (di != null && di !== '' && !isNaN(di)) ? parseInt(di, 10) + 1 : i + 1;
798
- ordMap.set(ord, li);
799
- });
1170
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
1171
+ anchorEl.insertAdjacentElement('afterend', wrap);
800
1172
 
801
- // Move each host to after its correct anchor topic.
802
- ul.querySelectorAll(':scope > li.' + HOST_CLASS).forEach(host => {
803
- const wrap = host.querySelector(wrapSel);
804
- if (!wrap) return;
805
- const pos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
806
- const anchor = pos && ordMap.get(pos);
807
- if (anchor && host.previousElementSibling !== anchor)
808
- anchor.insertAdjacentElement('afterend', host);
809
- });
810
- } catch (e) {}
811
- });
1173
+ // Ensure minimal layout impact.
1174
+ try { wrap.style.contain = 'layout style paint'; } catch (e) {}
1175
+ try { tightenStickyIn(wrap); } catch (e) {}
1176
+ return wrap;
1177
+ } catch (e) {
1178
+ return null;
1179
+ }
812
1180
  }
813
1181
 
814
- // ─── runCore ─────────────────────────────────────────────────────────────────
815
-
816
1182
  async function runCore() {
817
1183
  if (isBlocked()) return 0;
1184
+
818
1185
  patchShowAds();
819
- const cfg = await fetchConfig();
1186
+
1187
+ const cfg = await fetchConfigOnce();
820
1188
  if (!cfg || cfg.excluded) return 0;
821
1189
  initPools(cfg);
822
1190
 
823
1191
  const kind = getKind();
824
1192
  let inserted = 0;
825
1193
 
826
- if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
827
- const items = getItems('topic');
828
- const set = new Set(items);
829
- removeOrphanWraps('ezoic-ad-message', set);
830
- reapDetached('ezoic-ad-message');
831
- inserted += injectWraps('ezoic-ad-message', items,
832
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
833
- normBool(cfg.showFirstMessageAd), 'post', 'post');
834
-
835
- } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
836
- const items = getItems('categoryTopics');
837
- const set = new Set(items);
838
- removeOrphanWraps('ezoic-ad-between', set);
839
- reapDetached('ezoic-ad-between');
840
- inserted += injectWraps('ezoic-ad-between', items,
841
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
842
- normBool(cfg.showFirstTopicAd), 'topic', 'topic');
843
- schedulePileFix();
844
-
845
- } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
846
- const items = getItems('categories');
847
- const set = new Set(items);
848
- removeOrphanWraps('ezoic-ad-categories', set);
849
- reapDetached('ezoic-ad-categories');
850
- inserted += injectWraps('ezoic-ad-categories', items,
851
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
852
- normBool(cfg.showFirstCategoryAd), 'category', 'category');
1194
+ if (kind === 'topic') {
1195
+ if (normalizeBool(cfg.enableMessageAds)) {
1196
+ const items = getPostContainers();
1197
+ pruneOrphanWraps('ezoic-ad-message', items);
1198
+ inserted += injectBetween(
1199
+ 'ezoic-ad-message',
1200
+ items,
1201
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
1202
+ normalizeBool(cfg.showFirstMessageAd),
1203
+ state.allPosts,
1204
+ 'curPosts'
1205
+ );
1206
+ decluster('ezoic-ad-message');
1207
+ }
1208
+ } else if (kind === 'categoryTopics') {
1209
+ if (normalizeBool(cfg.enableBetweenAds)) {
1210
+ const items = getTopicItems();
1211
+ pruneOrphanWraps('ezoic-ad-between', items);
1212
+ inserted += injectBetween(
1213
+ 'ezoic-ad-between',
1214
+ items,
1215
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
1216
+ normalizeBool(cfg.showFirstTopicAd),
1217
+ state.allTopics,
1218
+ 'curTopics'
1219
+ );
1220
+ decluster('ezoic-ad-between');
1221
+ }
1222
+ } else if (kind === 'categories') {
1223
+ if (normalizeBool(cfg.enableCategoryAds)) {
1224
+ const items = getCategoryItems();
1225
+ pruneOrphanWraps('ezoic-ad-categories', items);
1226
+ inserted += injectBetween(
1227
+ 'ezoic-ad-categories',
1228
+ items,
1229
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
1230
+ normalizeBool(cfg.showFirstCategoryAd),
1231
+ state.allCategories,
1232
+ 'curCategories'
1233
+ );
1234
+ decluster('ezoic-ad-categories');
1235
+ }
853
1236
  }
854
1237
 
855
1238
  return inserted;
856
1239
  }
857
1240
 
858
- // ─── Hero ad (inject immediately after first item) ───────────────────────────
1241
+ async function insertHeroAdEarly() {
1242
+ if (state.heroDoneForPage || isBlocked()) return;
859
1243
 
860
- async function heroAd() {
861
- if (st.heroDone || isBlocked()) return;
862
- const cfg = await fetchConfig();
1244
+ const cfg = await fetchConfigOnce();
863
1245
  if (!cfg || cfg.excluded) return;
864
1246
  initPools(cfg);
865
1247
 
866
1248
  const kind = getKind();
867
- let items = [], showFirst = false, poolKey = '', cursorKey = '', kindClass = '';
868
1249
 
869
- if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
870
- items = getItems('topic'); showFirst = normBool(cfg.showFirstMessageAd);
871
- poolKey = cursorKey = 'post'; kindClass = 'ezoic-ad-message';
872
- } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
873
- items = getItems('categoryTopics'); showFirst = normBool(cfg.showFirstTopicAd);
874
- poolKey = cursorKey = 'topic'; kindClass = 'ezoic-ad-between';
875
- } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
876
- items = getItems('categories'); showFirst = normBool(cfg.showFirstCategoryAd);
877
- poolKey = cursorKey = 'category'; kindClass = 'ezoic-ad-categories';
878
- } else { return; }
1250
+ let items = [];
1251
+ let allIds = [];
1252
+ let cursorKey = '';
1253
+ let kindClass = '';
1254
+ let showFirst = false;
1255
+
1256
+ if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
1257
+ items = getPostContainers();
1258
+ allIds = state.allPosts;
1259
+ cursorKey = 'curPosts';
1260
+ kindClass = 'ezoic-ad-message';
1261
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
1262
+ } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
1263
+ items = getTopicItems();
1264
+ allIds = state.allTopics;
1265
+ cursorKey = 'curTopics';
1266
+ kindClass = 'ezoic-ad-between';
1267
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
1268
+ } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
1269
+ items = getCategoryItems();
1270
+ allIds = state.allCategories;
1271
+ cursorKey = 'curCategories';
1272
+ kindClass = 'ezoic-ad-categories';
1273
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
1274
+ } else {
1275
+ return;
1276
+ }
879
1277
 
880
- if (!items.length || !showFirst) { st.heroDone = true; return; }
1278
+ if (!items.length) return;
1279
+ if (!showFirst) { state.heroDoneForPage = true; return; }
881
1280
 
1281
+ const afterPos = 1;
882
1282
  const el = items[0];
883
- if (!el || !el.isConnected || isAdjacentWrap(el) || findWrapAt(kindClass, 1))
884
- { st.heroDone = true; return; }
1283
+ if (!el || !el.isConnected) return;
1284
+ if (isAdjacentAd(el)) return;
1285
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
885
1286
 
886
- const id = pickId(poolKey, cursorKey);
1287
+ const id = pickIdFromAll(allIds, cursorKey);
887
1288
  if (!id) return;
888
1289
 
889
- const wrap = buildWrap(id, kindClass, 1);
890
- withInternal(() => el.insertAdjacentElement('afterend', wrap));
891
- st.heroDone = true;
892
- observePh(id);
1290
+ const wrap = insertAfter(el, id, kindClass, afterPos);
1291
+ if (!wrap) return;
1292
+
1293
+ state.heroDoneForPage = true;
1294
+ observePlaceholder(id);
1295
+ // Hero placement is expected to be visible right away (after first item),
1296
+ // so kick a fill request immediately instead of waiting only for IO callbacks.
893
1297
  enqueueShow(id);
894
- drainQueue();
1298
+ startShowQueue();
895
1299
  }
896
1300
 
897
- // ─── Burst scheduler ─────────────────────────────────────────────────────────
1301
+ // ---------------- scheduler ----------------
1302
+
1303
+ function scheduleRun(delayMs = 0, cb) {
1304
+ if (state.runQueued) return;
1305
+ state.runQueued = true;
898
1306
 
899
- function scheduleRun(delay, cb) {
900
- if (st.runQueued) return;
901
- st.runQueued = true;
902
1307
  const run = async () => {
903
- st.runQueued = false;
904
- if (getPageKey() !== st.pageKey) return;
905
- let n = 0;
906
- try { n = await runCore(); } catch (e) {}
907
- try { cb && cb(n); } catch (e) {}
1308
+ state.runQueued = false;
1309
+ const pk = getPageKey();
1310
+ if (state.pageKey && pk !== state.pageKey) return;
1311
+ let inserted = 0;
1312
+ try { inserted = await runCore(); } catch (e) { inserted = 0; }
1313
+ try { cb && cb(inserted); } catch (e) {}
908
1314
  };
909
- const go = () => requestAnimationFrame(run);
910
- delay > 0 ? setTimeout(go, delay) : go();
1315
+
1316
+ const doRun = () => requestAnimationFrame(run);
1317
+ if (delayMs > 0) setTimeout(doRun, delayMs);
1318
+ else doRun();
911
1319
  }
912
1320
 
913
- function burst() {
1321
+ function requestBurst() {
914
1322
  if (isBlocked()) return;
1323
+
915
1324
  const t = now();
916
- if (t - st.lastBurstTs < 120) return;
917
- st.lastBurstTs = t;
918
- const pk = st.pageKey = getPageKey();
919
- st.burstDeadline = t + 1800;
920
- if (st.burstActive) return;
921
- st.burstActive = true;
922
- st.burstCount = 0;
1325
+ if (t - state.lastBurstReqTs < 120) return;
1326
+ state.lastBurstReqTs = t;
1327
+
1328
+ const pk = getPageKey();
1329
+ state.pageKey = pk;
1330
+
1331
+ state.burstDeadline = t + 1800;
1332
+ if (state.burstActive) return;
1333
+
1334
+ state.burstActive = true;
1335
+ state.burstCount = 0;
1336
+
923
1337
  const step = () => {
924
- if (getPageKey() !== pk || isBlocked() ||
925
- now() > st.burstDeadline || st.burstCount >= 8)
926
- { st.burstActive = false; return; }
927
- st.burstCount++;
928
- scheduleRun(0, n => {
929
- if (!n && !st.pending.length) { st.burstActive = false; return; }
930
- setTimeout(step, n > 0 ? 120 : 220);
1338
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
1339
+ if (isBlocked()) { state.burstActive = false; return; }
1340
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
1341
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
1342
+
1343
+ state.burstCount++;
1344
+ scheduleRun(0, (inserted) => {
1345
+ // Continue while we are still inserting or we have pending shows.
1346
+ const hasWork = inserted > 0 || state.pending.length > 0;
1347
+ if (!hasWork) { state.burstActive = false; return; }
1348
+ // Short delay keeps UI smooth while catching late DOM waves.
1349
+ setTimeout(step, inserted > 0 ? 120 : 220);
931
1350
  });
932
1351
  };
1352
+
933
1353
  step();
934
1354
  }
935
1355
 
936
- // ─── Lifecycle ───────────────────────────────────────────────────────────────
1356
+ // ---------------- lifecycle ----------------
937
1357
 
938
1358
  function cleanup() {
939
1359
  blockedUntil = now() + 1200;
940
- try { document.querySelectorAll('.' + WRAP_CLASS).forEach(dropWrap); } catch (e) {}
1360
+
941
1361
  try {
942
- for (const m of st.detached.values()) {
943
- for (const w of m.values()) try { dropWrap(w); } catch (e) {}
944
- m.clear();
945
- }
946
- st.detached.clear();
1362
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => releaseWrapNode(el));
947
1363
  } catch (e) {}
948
- st.cfg = null;
949
- st.pools = { topic: [], post: [], category: [] };
950
- st.cursors = { topic: 0, post: 0, category: 0 };
951
- st.lastShow.clear();
952
- st.inflight = 0;
953
- st.pending = []; st.pendingSet.clear();
954
- st.heroDone = false;
955
- // IO and domObs kept alive intentionally.
1364
+
1365
+ state.cfg = null;
1366
+ state.allTopics = [];
1367
+ state.allPosts = [];
1368
+ state.allCategories = [];
1369
+ state.curTopics = 0;
1370
+ state.curPosts = 0;
1371
+ state.curCategories = 0;
1372
+ state.lastShowById.clear();
1373
+
1374
+ state.inflight = 0;
1375
+ state.pending = [];
1376
+ state.pendingSet.clear();
1377
+
1378
+ state.heroDoneForPage = false;
1379
+
1380
+ // keep observers alive
956
1381
  }
957
1382
 
958
- function shouldReact(mutations) {
1383
+ function shouldReactToMutations(mutations) {
1384
+ // Fast filter: only react if relevant nodes were added/removed.
959
1385
  for (const m of mutations) {
960
- if (!m.addedNodes || !m.addedNodes.length) continue;
1386
+ if (!m.addedNodes || m.addedNodes.length === 0) continue;
961
1387
  for (const n of m.addedNodes) {
962
1388
  if (!n || n.nodeType !== 1) continue;
963
- if ((n.matches && (n.matches(SEL.post) || n.matches(SEL.topic) || n.matches(SEL.category))) ||
964
- (n.querySelector && (n.querySelector(SEL.post) || n.querySelector(SEL.topic) || n.querySelector(SEL.category))))
1389
+ const el = /** @type {Element} */ (n);
1390
+ if (
1391
+ el.matches?.(SELECTORS.postItem) ||
1392
+ el.matches?.(SELECTORS.topicItem) ||
1393
+ el.matches?.(SELECTORS.categoryItem) ||
1394
+ el.querySelector?.(SELECTORS.postItem) ||
1395
+ el.querySelector?.(SELECTORS.topicItem) ||
1396
+ el.querySelector?.(SELECTORS.categoryItem)
1397
+ ) {
965
1398
  return true;
1399
+ }
966
1400
  }
967
1401
  }
968
1402
  return false;
969
1403
  }
970
1404
 
971
- function ensureDomObs() {
972
- if (st.domObs) return;
973
- st.domObs = new MutationObserver(muts => {
974
- if (st.internalMut > 0 || isBlocked()) return;
975
- if (shouldReact(muts)) burst();
1405
+ function ensureDomObserver() {
1406
+ if (state.domObs) return;
1407
+
1408
+ state.domObs = new MutationObserver((mutations) => {
1409
+ if (state.internalDomChange > 0) return;
1410
+ if (isBlocked()) return;
1411
+ if (!shouldReactToMutations(mutations)) return;
1412
+ requestBurst();
976
1413
  });
977
- try { st.domObs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
978
- }
979
1414
 
980
- // ─── NodeBB event bindings ───────────────────────────────────────────────────
1415
+ try {
1416
+ state.domObs.observe(document.body, { childList: true, subtree: true });
1417
+ } catch (e) {}
1418
+ }
981
1419
 
982
1420
  function bindNodeBB() {
983
1421
  if (!$) return;
984
- $(window).off('.ezoicNbb');
985
- $(window).on('action:ajaxify.start.ezoicNbb', () => cleanup());
986
- $(window).on('action:ajaxify.end.ezoicNbb', () => {
987
- st.pageKey = getPageKey();
1422
+
1423
+ $(window).off('.ezoicInfinite');
1424
+
1425
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
1426
+ cleanup();
1427
+ });
1428
+
1429
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
1430
+ state.pageKey = getPageKey();
988
1431
  blockedUntil = 0;
989
- muteConsole(); warmNetwork(); patchShowAds();
990
- globalGapFix(); ensureIO(); ensureDomObs();
991
- heroAd().catch(() => {});
992
- burst();
993
- setTimeout(schedulePileFix, 80);
994
- setTimeout(schedulePileFix, 500);
1432
+
1433
+ muteNoisyConsole();
1434
+ ensureTcfApiLocator();
1435
+ warmUpNetwork();
1436
+ patchShowAds();
1437
+ globalGapFixInit();
1438
+ ensurePreloadObserver();
1439
+ ensureDomObserver();
1440
+
1441
+ insertHeroAdEarly().catch(() => {});
1442
+ requestBurst();
995
1443
  });
996
- $(window).on('action:ajaxify.contentLoaded.ezoicNbb', () => { if (!isBlocked()) burst(); });
997
- $(window).on([
998
- 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
999
- 'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
1000
- ].map(e => e + '.ezoicNbb').join(' '), () => {
1001
- if (!isBlocked()) { burst(); setTimeout(schedulePileFix, 80); setTimeout(schedulePileFix, 500); }
1444
+
1445
+ // Some setups populate content in multiple phases; ensure we re-scan.
1446
+ $(window).on('action:ajaxify.contentLoaded.ezoicInfinite', () => {
1447
+ if (isBlocked()) return;
1448
+ requestBurst();
1002
1449
  });
1450
+
1451
+ $(window).on(
1452
+ 'action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:categories.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite',
1453
+ () => {
1454
+ if (isBlocked()) return;
1455
+ requestBurst();
1456
+ }
1457
+ );
1458
+
1459
+ // Also listen through NodeBB's AMD hooks module when available.
1003
1460
  try {
1004
- require(['hooks'], hooks => {
1005
- if (typeof hooks.on !== 'function') return;
1006
- for (const ev of [
1007
- 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
1008
- 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
1009
- 'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
1010
- ]) try { hooks.on(ev, () => { if (!isBlocked()) burst(); }); } catch (e) {}
1461
+ require(['hooks'], (hooks) => {
1462
+ if (!hooks || typeof hooks.on !== 'function') return;
1463
+ ['action:ajaxify.end', 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded'].forEach((ev) => {
1464
+ try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (e) {}
1465
+ });
1011
1466
  });
1012
1467
  } catch (e) {}
1013
1468
  }
@@ -1015,35 +1470,233 @@
1015
1470
  function bindScroll() {
1016
1471
  let ticking = false;
1017
1472
  window.addEventListener('scroll', () => {
1018
- // Fast-scroll boost: widen IO margins.
1473
+ // Fast-scroll boost
1019
1474
  try {
1020
- const t = now(), y = window.scrollY || 0;
1021
- if (st.lastScrollTs) {
1022
- const speed = Math.abs(y - st.lastScrollY) / (t - st.lastScrollTs);
1023
- if (speed >= BOOST_SPEED_PX_PER_MS) {
1024
- const was = isBoosted();
1025
- st.boostUntil = Math.max(st.boostUntil, t + BOOST_DURATION_MS);
1026
- if (!was) ensureIO();
1475
+ const t = now();
1476
+ const y = window.scrollY || window.pageYOffset || 0;
1477
+ if (state.lastScrollTs) {
1478
+ const dt = t - state.lastScrollTs;
1479
+ const dy = Math.abs(y - (state.lastScrollY || 0));
1480
+ if (dt > 0) {
1481
+ const speed = dy / dt;
1482
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
1483
+ const wasBoosted = isBoosted();
1484
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
1485
+ if (!wasBoosted) ensurePreloadObserver();
1486
+ }
1027
1487
  }
1028
1488
  }
1029
- st.lastScrollY = y; st.lastScrollTs = t;
1489
+ state.lastScrollY = y;
1490
+ state.lastScrollTs = t;
1030
1491
  } catch (e) {}
1031
1492
 
1032
1493
  if (ticking) return;
1033
1494
  ticking = true;
1034
- requestAnimationFrame(() => { ticking = false; burst(); });
1495
+ requestAnimationFrame(() => {
1496
+ ticking = false;
1497
+ requestBurst();
1498
+ });
1035
1499
  }, { passive: true });
1036
1500
  }
1037
1501
 
1038
- // ─── Boot ────────────────────────────────────────────────────────────────────
1502
+ // ---------------- boot ----------------
1503
+
1504
+ state.pageKey = getPageKey();
1505
+ muteNoisyConsole();
1506
+ ensureTcfApiLocator();
1507
+ warmUpNetwork();
1508
+ patchShowAds();
1509
+ ensurePreloadObserver();
1510
+ ensureDomObserver();
1511
+
1512
+ bindNodeBB();
1513
+ bindScroll();
1039
1514
 
1040
- st.pageKey = getPageKey();
1041
- muteConsole(); warmNetwork(); patchShowAds();
1042
- ensureIO(); ensureDomObs();
1043
- bindNodeBB(); bindScroll();
1044
1515
  blockedUntil = 0;
1045
- heroAd().catch(() => {});
1046
- burst();
1047
- schedulePileFix();
1516
+ insertHeroAdEarly().catch(() => {});
1517
+ requestBurst();
1518
+ })();
1519
+
1048
1520
 
1521
+
1522
+ // ===== V17 minimal pile-fix (no insert hooks) =====
1523
+ (function () {
1524
+ // Goal: keep ad injection intact. Only repair when we detect "pile-up" of between wraps.
1525
+ var TOPIC_LI_SEL = 'li[component="category/topic"]';
1526
+ var BETWEEN_WRAP_SEL = 'div.nodebb-ezoic-wrap.ezoic-ad-between';
1527
+ var HOST_CLASS = 'nodebb-ezoic-host';
1528
+
1529
+ var scheduled = false;
1530
+ var lastRun = 0;
1531
+ var COOLDOWN = 180;
1532
+
1533
+ function getTopicList() {
1534
+ try {
1535
+ var li = document.querySelector(TOPIC_LI_SEL);
1536
+ if (!li) return null;
1537
+ return li.closest ? li.closest('ul,ol') : null;
1538
+ } catch (e) { return null; }
1539
+ }
1540
+
1541
+ function isHost(node) {
1542
+ return !!(node && node.nodeType === 1 && node.tagName === 'LI' && node.classList && node.classList.contains(HOST_CLASS));
1543
+ }
1544
+
1545
+ function ensureHostForWrap(wrap, ul) {
1546
+ try {
1547
+ if (!wrap || wrap.nodeType !== 1) return null;
1548
+ if (!(wrap.matches && wrap.matches(BETWEEN_WRAP_SEL))) return null;
1549
+
1550
+ var host = wrap.closest ? wrap.closest('li.' + HOST_CLASS) : null;
1551
+ if (host) return host;
1552
+
1553
+ if (!ul) ul = wrap.closest ? wrap.closest('ul,ol') : null;
1554
+ if (!ul || !(ul.tagName === 'UL' || ul.tagName === 'OL')) return null;
1555
+
1556
+ // Only wrap if direct child of list (invalid / fragile)
1557
+ if (wrap.parentElement === ul) {
1558
+ host = document.createElement('li');
1559
+ host.className = HOST_CLASS;
1560
+ host.setAttribute('role', 'listitem');
1561
+ host.style.listStyle = 'none';
1562
+ host.style.width = '100%';
1563
+ ul.insertBefore(host, wrap);
1564
+ host.appendChild(wrap);
1565
+ try { wrap.style.width = '100%'; } catch (e) {}
1566
+ return host;
1567
+ }
1568
+ } catch (e) {}
1569
+ return null;
1570
+ }
1571
+
1572
+ function previousTopicLi(node) {
1573
+ try {
1574
+ var prev = node.previousElementSibling;
1575
+ while (prev) {
1576
+ if (prev.matches && prev.matches(TOPIC_LI_SEL)) return prev;
1577
+ // skip other hosts/wraps
1578
+ prev = prev.previousElementSibling;
1579
+ }
1580
+ } catch (e) {}
1581
+ return null;
1582
+ }
1583
+
1584
+ function detectPileUp(ul) {
1585
+ // Pile-up signature: 2+ between wraps/hosts adjacent with no topics between, often near top.
1586
+ try {
1587
+ var kids = ul.children;
1588
+ var run = 0;
1589
+ var maxRun = 0;
1590
+ for (var i = 0; i < kids.length; i++) {
1591
+ var el = kids[i];
1592
+ var isBetween = false;
1593
+ if (isHost(el)) {
1594
+ isBetween = !!(el.querySelector && el.querySelector(BETWEEN_WRAP_SEL));
1595
+ } else if (el.matches && el.matches(BETWEEN_WRAP_SEL)) {
1596
+ isBetween = true;
1597
+ }
1598
+ if (isBetween) {
1599
+ run++;
1600
+ if (run > maxRun) maxRun = run;
1601
+ } else if (el.matches && el.matches(TOPIC_LI_SEL)) {
1602
+ run = 0;
1603
+ } else {
1604
+ // other nodes reset lightly
1605
+ run = 0;
1606
+ }
1607
+ }
1608
+ return maxRun >= 2;
1609
+ } catch (e) {}
1610
+ return false;
1611
+ }
1612
+
1613
+ function redistribute(ul) {
1614
+ try {
1615
+ if (!ul) return;
1616
+
1617
+ // Step 1: wrap any direct child between DIVs into LI hosts (makes list stable)
1618
+ ul.querySelectorAll(':scope > ' + BETWEEN_WRAP_SEL).forEach(function(w){ ensureHostForWrap(w, ul); });
1619
+
1620
+ // Step 2: only act if we see pile-up (avoid touching infinite scroll during normal flow)
1621
+ if (!detectPileUp(ul)) return;
1622
+
1623
+ // Move each host to immediately after the closest previous topic LI at its current position.
1624
+ var hosts = ul.querySelectorAll(':scope > li.' + HOST_CLASS);
1625
+ hosts.forEach(function(host){
1626
+ try {
1627
+ var wrap = host.querySelector && host.querySelector(BETWEEN_WRAP_SEL);
1628
+ if (!wrap) return;
1629
+
1630
+ var anchor = previousTopicLi(host);
1631
+ if (!anchor) return; // if none, don't move (prevents yanking to top/bottom)
1632
+
1633
+ if (host.previousElementSibling !== anchor) {
1634
+ anchor.insertAdjacentElement('afterend', host);
1635
+ }
1636
+ } catch (e) {}
1637
+ });
1638
+ } catch (e) {}
1639
+ }
1640
+
1641
+ function schedule(reason) {
1642
+ var now = Date.now();
1643
+ if (now - lastRun < COOLDOWN) return;
1644
+ if (scheduled) return;
1645
+ scheduled = true;
1646
+ requestAnimationFrame(function () {
1647
+ scheduled = false;
1648
+ lastRun = Date.now();
1649
+ try {
1650
+ var ul = getTopicList();
1651
+ if (!ul) return;
1652
+ redistribute(ul);
1653
+ } catch (e) {}
1654
+ });
1655
+ }
1656
+
1657
+ function init() {
1658
+ schedule('init');
1659
+
1660
+ // Observe only the topic list once available
1661
+ try {
1662
+ if (typeof MutationObserver !== 'undefined') {
1663
+ var observeList = function(ul){
1664
+ if (!ul) return;
1665
+ var mo = new MutationObserver(function(muts){
1666
+ // schedule on any change; redistribute() itself is guarded by pile-up detection
1667
+ schedule('mo');
1668
+ });
1669
+ mo.observe(ul, { childList: true, subtree: true });
1670
+ };
1671
+
1672
+ var ul = getTopicList();
1673
+ if (ul) observeList(ul);
1674
+ else {
1675
+ var mo2 = new MutationObserver(function(){
1676
+ var u2 = getTopicList();
1677
+ if (u2) {
1678
+ try { observeList(u2); } catch(e){}
1679
+ try { mo2.disconnect(); } catch(e){}
1680
+ }
1681
+ });
1682
+ mo2.observe(document.documentElement || document.body, { childList: true, subtree: true });
1683
+ }
1684
+ }
1685
+ } catch (e) {}
1686
+
1687
+ // NodeBB events: run after infinite scroll batches
1688
+ if (window.jQuery) {
1689
+ try {
1690
+ window.jQuery(window).on('action:ajaxify.end action:infiniteScroll.loaded', function(){
1691
+ setTimeout(function(){ schedule('event'); }, 50);
1692
+ setTimeout(function(){ schedule('event2'); }, 400);
1693
+ });
1694
+ } catch (e) {}
1695
+ }
1696
+ }
1697
+
1698
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
1699
+ else init();
1049
1700
  })();
1701
+ // ===== /V17 =====
1702
+