nodebb-plugin-ezoic-infinite 1.6.66 → 1.6.68

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 +626 -930
package/public/client.js CHANGED
@@ -1,751 +1,460 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- // ─── Scroll direction tracker ───────────────────────────────────────────────
5
- // Prevents aggressive recycling when the user scrolls upward.
6
- let lastScrollY = 0;
7
- let scrollDir = 1; // 1 = down, -1 = up
8
- try {
9
- lastScrollY = window.scrollY || 0;
10
- window.addEventListener('scroll', () => {
11
- const y = window.scrollY || 0;
12
- const d = y - lastScrollY;
13
- if (Math.abs(d) > 4) { scrollDir = d > 0 ? 1 : -1; lastScrollY = y; }
14
- }, { passive: true });
15
- } catch (e) {}
4
+ // ─── Constants ───────────────────────────────────────────────────────────────
5
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
6
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
7
+ const HOST_CLASS = 'nodebb-ezoic-host';
8
+ const MAX_INSERTS_PER_RUN = 8;
16
9
 
17
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
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';
18
13
 
19
- // ─── Constants ──────────────────────────────────────────────────────────────
20
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
21
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
22
- const HOST_CLASS = 'nodebb-ezoic-host';
23
- const MAX_INSERTS_PER_RUN = 8;
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;
24
19
 
25
- function keepEmptyWrapMs() { return isMobile() ? 120_000 : 60_000; }
26
-
27
- const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
28
- const PRELOAD_MARGIN_MOBILE = '3200px 0px 3200px 0px';
29
- const PRELOAD_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
30
- const PRELOAD_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
31
- const BOOST_DURATION_MS = 2500;
32
- const BOOST_SPEED_PX_PER_MS = 2.2;
33
- const MAX_INFLIGHT_DESKTOP = 4;
34
- const MAX_INFLIGHT_MOBILE = 3;
35
-
36
- const SELECTORS = {
37
- topicItem: 'li[component="category/topic"]',
38
- postItem: '[component="post"][data-pid]',
39
- categoryItem: 'li[component="categories/category"]',
20
+ const MAX_INFLIGHT = { desktop: 4, mobile: 3 };
21
+
22
+ const SEL = {
23
+ topic: 'li[component="category/topic"]',
24
+ post: '[component="post"][data-pid]',
25
+ category: 'li[component="categories/category"]',
40
26
  };
41
27
 
42
- // ─── Shared helpers ─────────────────────────────────────────────────────────
28
+ // ─── Utils ───────────────────────────────────────────────────────────────────
29
+
30
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
43
31
 
44
32
  function now() { return Date.now(); }
45
33
  function isMobile() { try { return window.innerWidth < 768; } catch (e) { return false; } }
46
- function isBoosted(){ return now() < (state.scrollBoostUntil || 0); }
34
+ function isBoosted(){ return now() < (st.boostUntil || 0); }
35
+ function isBlocked(){ return now() < blockedUntil; }
47
36
 
48
- function normalizeBool(v) {
37
+ function normBool(v) {
49
38
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
50
39
  }
51
40
 
52
- /** Returns true if the node contains a rendered ad creative. */
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)) {
45
+ const n = parseInt(v, 10);
46
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
47
+ }
48
+ return out;
49
+ }
50
+
53
51
  function isFilled(node) {
54
52
  return !!(node && node.querySelector &&
55
53
  node.querySelector('iframe, ins, img, video, [data-google-container-id]'));
56
54
  }
57
55
 
58
- // ─── Console noise filter ───────────────────────────────────────────────────
59
- function muteNoisyConsole() {
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);
64
+ }
65
+
66
+ function getPageKey() {
60
67
  try {
61
- if (window.__nodebbEzoicConsoleMuted) return;
62
- window.__nodebbEzoicConsoleMuted = true;
63
- const shouldMute = (args) => {
64
- try {
65
- const s = typeof args[0] === 'string' ? args[0] : '';
66
- return (
67
- (s.includes('[EzoicAds JS]: Placeholder Id') && s.includes('has already been defined')) ||
68
- s.includes('Debugger iframe already exists') ||
69
- (s.includes('HTML element with id ezoic-pub-ad-placeholder-') && s.includes('does not exist'))
70
- );
71
- } catch (e) { return false; }
72
- };
73
- const wrap = (m) => {
74
- const orig = console[m];
75
- if (typeof orig !== 'function') return;
76
- console[m] = function (...a) { if (shouldMute(a)) return; return orig.apply(console, a); };
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;
72
+ }
73
+ } catch (e) {}
74
+ return window.location.pathname;
75
+ }
76
+
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';
86
+ }
87
+
88
+ function withInternal(fn) {
89
+ st.internalMut++;
90
+ try { fn(); } finally { st.internalMut--; }
91
+ }
92
+
93
+ // ─── State ───────────────────────────────────────────────────────────────────
94
+
95
+ const st = {
96
+ pageKey: null,
97
+ cfg: null,
98
+
99
+ pools: { topic: [], post: [], category: [] },
100
+ cursors: { topic: 0, post: 0, category: 0 },
101
+
102
+ lastShow: new Map(), // id → timestamp
103
+ inflight: 0,
104
+ pending: [],
105
+ pendingSet: new Set(),
106
+
107
+ io: null,
108
+ ioMargin: null,
109
+ domObs: null,
110
+
111
+ internalMut: 0,
112
+ boostUntil: 0,
113
+ lastScrollY: 0,
114
+ lastScrollTs: 0,
115
+
116
+ heroDone: false,
117
+ runQueued: false,
118
+ burstActive: false,
119
+ burstDeadline:0,
120
+ burstCount: 0,
121
+ lastBurstTs: 0,
122
+ };
123
+
124
+ let blockedUntil = 0;
125
+ const inserting = new Set();
126
+
127
+ // ─── Console mute (Ezoic SPA spam) ───────────────────────────────────────────
128
+
129
+ function muteConsole() {
130
+ try {
131
+ if (window.__nbbEzoicMuted) return;
132
+ window.__nbbEzoicMuted = true;
133
+ const bad = (a) => {
134
+ const s = typeof a[0] === 'string' ? a[0] : '';
135
+ return (s.includes('[EzoicAds JS]: Placeholder Id') && s.includes('has already been defined'))
136
+ || s.includes('Debugger iframe already exists')
137
+ || (s.includes('HTML element with id ezoic-pub-ad-placeholder-') && s.includes('does not exist'));
77
138
  };
78
- ['log','info','warn','error'].forEach(wrap);
139
+ for (const m of ['log','info','warn','error']) {
140
+ const o = console[m];
141
+ if (typeof o !== 'function') continue;
142
+ console[m] = function(...a) { if (!bad(a)) o.apply(console, a); };
143
+ }
144
+ } catch (e) {}
145
+ }
146
+
147
+ // ─── Network warmup ──────────────────────────────────────────────────────────
148
+
149
+ const _warmed = new Set();
150
+ function warmNetwork() {
151
+ const head = document.head;
152
+ if (!head) return;
153
+ for (const [rel, href, cors] of [
154
+ ['preconnect', 'https://g.ezoic.net', true],
155
+ ['dns-prefetch', 'https://g.ezoic.net', false],
156
+ ['preconnect', 'https://go.ezoic.net', true],
157
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
158
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
159
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
160
+ ]) {
161
+ const k = rel + href;
162
+ if (_warmed.has(k)) continue;
163
+ _warmed.add(k);
164
+ const l = document.createElement('link');
165
+ l.rel = rel; l.href = href;
166
+ if (cors) l.crossOrigin = 'anonymous';
167
+ head.appendChild(l);
168
+ }
169
+ }
170
+
171
+ // ─── Ezoic bridge ────────────────────────────────────────────────────────────
172
+ // Patch showAds so it silently skips IDs whose placeholder is not in the DOM.
173
+
174
+ function patchShowAds() {
175
+ const patch = () => {
176
+ try {
177
+ const ez = window.ezstandalone = window.ezstandalone || {};
178
+ if (window.__nbbEzoicPatched || typeof ez.showAds !== 'function') return;
179
+ window.__nbbEzoicPatched = true;
180
+ const orig = ez.showAds;
181
+ ez.showAds = function (...args) {
182
+ if (isBlocked()) return;
183
+ const ids = Array.isArray(args[0]) ? args[0] : args;
184
+ const seen = new Set();
185
+ for (const v of ids) {
186
+ const id = parseInt(v, 10);
187
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
188
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
189
+ if (!ph || !ph.isConnected) continue;
190
+ seen.add(id);
191
+ try { orig.call(ez, id); } catch (e) {}
192
+ }
193
+ };
194
+ } catch (e) {}
195
+ };
196
+ patch();
197
+ if (!window.__nbbEzoicPatched) {
198
+ try {
199
+ (window.ezstandalone = window.ezstandalone || {}).cmd =
200
+ window.ezstandalone.cmd || [];
201
+ window.ezstandalone.cmd.push(patch);
202
+ } catch (e) {}
203
+ }
204
+ }
205
+
206
+ // ─── Config ──────────────────────────────────────────────────────────────────
207
+
208
+ async function fetchConfig() {
209
+ if (st.cfg) return st.cfg;
210
+ try {
211
+ const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
212
+ if (r.ok) st.cfg = await r.json();
79
213
  } catch (e) {}
214
+ return st.cfg;
80
215
  }
81
216
 
82
- // ensureTcfApiLocator intentionally removed:
83
- // Creating our own __tcfapiLocator iframe conflicts with the CMP's own locator
84
- // management and causes "Cannot read properties of null (reading 'postMessage')"
85
- // errors when the CMP tries to postMessage to an iframe we created then navigated away.
86
- // The CMP (Ezoic/IAB TCF) handles its own locator iframe lifecycle.
87
- function ensureTcfApiLocator() { /* no-op */ }
88
-
89
- // ─── Ezoic min-height tightener ─────────────────────────────────────────────
90
- // Ezoic injects `min-height:400px !important` on nested wrappers via inline
91
- // style. CSS alone can't fix it — we must rewrite the inline style.
92
- function tightenEzoicMinHeight(wrap) {
217
+ function initPools(cfg) {
218
+ if (!cfg) return;
219
+ if (!st.pools.topic.length) st.pools.topic = parsePool(cfg.placeholderIds);
220
+ if (!st.pools.post.length) st.pools.post = parsePool(cfg.messagePlaceholderIds);
221
+ if (!st.pools.category.length) st.pools.category = parsePool(cfg.categoryPlaceholderIds);
222
+ }
223
+
224
+ // ─── Ezoic min-height tightener ──────────────────────────────────────────────
225
+ // Ezoic injects `min-height:400px !important` via inline style; we override it.
226
+
227
+ function tightenMinHeight(wrap) {
93
228
  try {
94
- if (!wrap || !wrap.querySelector) return;
229
+ if (!wrap) return;
95
230
  const iframes = wrap.querySelectorAll('iframe');
96
231
  if (!iframes.length) return;
97
232
 
98
- let refNode = null;
233
+ // Find the nearest ezoic-ad ancestor with the 400px inline min-height.
234
+ let ref = null;
99
235
  let p = iframes[0].parentElement;
100
236
  while (p && p !== wrap) {
101
237
  if (p.classList && p.classList.contains('ezoic-ad')) {
102
238
  const st = (p.getAttribute('style') || '').toLowerCase();
103
- if (st.includes('min-height:400') || st.includes('min-height: 400')) { refNode = p; break; }
239
+ if (st.includes('min-height:400') || st.includes('min-height: 400')) { ref = p; break; }
104
240
  }
105
241
  p = p.parentElement;
106
242
  }
107
- if (!refNode) refNode = wrap.querySelector('.ezoic-ad-adaptive') || wrap.querySelector('.ezoic-ad') || wrap;
243
+ ref = ref || wrap.querySelector('.ezoic-ad-adaptive, .ezoic-ad') || wrap;
108
244
 
109
245
  let refTop = 0;
110
- try { refTop = refNode.getBoundingClientRect().top; } catch (e) {}
246
+ try { refTop = ref.getBoundingClientRect().top; } catch (e) {}
111
247
 
112
- let maxBottom = 0;
113
- iframes.forEach((f) => {
114
- const rect = f.getBoundingClientRect();
115
- if (rect.width <= 1 || rect.height <= 1) return;
116
- maxBottom = Math.max(maxBottom, rect.bottom - refTop);
248
+ let maxH = 0;
249
+ iframes.forEach(f => {
250
+ const r = f.getBoundingClientRect();
251
+ if (r.width > 1 && r.height > 1) maxH = Math.max(maxH, r.bottom - refTop);
117
252
  });
118
- if (!maxBottom) {
119
- iframes.forEach((f) => {
120
- maxBottom = Math.max(maxBottom, parseInt(f.getAttribute('height') || '0', 10), f.offsetHeight || 0);
121
- });
122
- }
123
- if (!maxBottom) return;
253
+ if (!maxH) iframes.forEach(f => {
254
+ maxH = Math.max(maxH,
255
+ parseInt(f.getAttribute('height') || '0', 10), f.offsetHeight || 0);
256
+ });
257
+ if (!maxH) return;
124
258
 
125
- const h = Math.max(1, Math.ceil(maxBottom));
126
- const tightenNode = (n) => {
259
+ const h = Math.max(1, Math.ceil(maxH)) + 'px';
260
+ const set = (n) => {
127
261
  if (!n || !n.style) return;
128
- try { n.style.setProperty('min-height', h + 'px', 'important'); } catch (e) { n.style.minHeight = h + 'px'; }
262
+ try { n.style.setProperty('min-height', h, 'important'); } catch (e) { n.style.minHeight = h; }
129
263
  try { n.style.setProperty('height', 'auto', 'important'); } catch (e) {}
130
264
  try { n.style.setProperty('line-height', '0', 'important'); } catch (e) {}
131
265
  };
132
266
 
133
- let cur = refNode;
267
+ let cur = ref;
134
268
  while (cur && cur !== wrap) {
135
269
  if (cur.classList && cur.classList.contains('ezoic-ad')) {
136
- const st = (cur.getAttribute('style') || '').toLowerCase();
137
- if (st.includes('min-height:400') || st.includes('min-height: 400')) tightenNode(cur);
270
+ const s = (cur.getAttribute('style') || '').toLowerCase();
271
+ if (s.includes('min-height:400') || s.includes('min-height: 400')) set(cur);
138
272
  }
139
273
  cur = cur.parentElement;
140
274
  }
141
- tightenNode(refNode);
142
- refNode.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach((n) => {
143
- const st = (n.getAttribute('style') || '').toLowerCase();
144
- if (st.includes('min-height:400') || st.includes('min-height: 400')) tightenNode(n);
275
+ set(ref);
276
+ ref.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach(n => {
277
+ const s = (n.getAttribute('style') || '').toLowerCase();
278
+ if (s.includes('min-height:400') || s.includes('min-height: 400')) set(n);
145
279
  });
280
+
146
281
  if (isMobile()) {
147
- try { refNode.style.setProperty('width', '100%', 'important'); } catch (e) {}
148
- try { refNode.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
149
- try { refNode.style.setProperty('min-width', '0', 'important'); } catch (e) {}
282
+ try { ref.style.setProperty('width', '100%', 'important'); } catch (e) {}
283
+ try { ref.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
284
+ try { ref.style.setProperty('min-width', '0', 'important'); } catch (e) {}
150
285
  }
151
286
  } catch (e) {}
152
287
  }
153
288
 
154
- function watchWrapForFill(wrap) {
289
+ function watchFill(wrap) {
155
290
  try {
156
291
  if (!wrap || wrap.__ezFillObs) return;
157
292
  const start = now();
158
- const tightenBurst = () => {
159
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
160
- if (now() - start < 6000) setTimeout(tightenBurst, 350);
293
+ const burst = () => {
294
+ try { tightenMinHeight(wrap); } catch (e) {}
295
+ if (now() - start < 6000) setTimeout(burst, 350);
161
296
  };
162
- const obs = new MutationObserver((muts) => {
163
- if (isFilled(wrap)) { wrap.classList.remove('is-empty'); tightenBurst(); }
164
- for (const m of muts) {
165
- if (m.type === 'attributes' && m.attributeName === 'style') {
166
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
167
- break;
168
- }
169
- }
170
- if (now() - start > 7000) { try { obs.disconnect(); } catch (e) {} wrap.__ezFillObs = null; }
297
+ const obs = new MutationObserver(muts => {
298
+ if (isFilled(wrap)) { wrap.classList.remove('is-empty'); burst(); }
299
+ for (const m of muts)
300
+ if (m.type === 'attributes' && m.attributeName === 'style') { tightenMinHeight(wrap); break; }
301
+ if (now() - start > 7000) { obs.disconnect(); wrap.__ezFillObs = null; }
171
302
  });
172
303
  obs.observe(wrap, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
173
304
  wrap.__ezFillObs = obs;
174
305
  } catch (e) {}
175
306
  }
176
307
 
177
- function globalGapFixInit() {
308
+ // Global safety net: catch Ezoic 400px min-height inside posts.
309
+ function globalGapFix() {
178
310
  try {
179
- if (window.__nodebbEzoicGapFix) return;
180
- window.__nodebbEzoicGapFix = true;
311
+ if (window.__nbbEzoicGapFix) return;
312
+ window.__nbbEzoicGapFix = true;
181
313
  const root = document.getElementById('content') ||
182
314
  document.querySelector('[component="content"], #panel') || document.body;
183
- const inPostArea = (el) => {
184
- try { return !!(el && el.closest && el.closest('[component="post"], .topic, .posts, [component="topic"]')); }
185
- catch (e) { return false; }
186
- };
187
- const maybeFix = (r) => {
315
+ const inPost = el => !!(el && el.closest &&
316
+ el.closest('[component="post"], .topic, .posts, [component="topic"]'));
317
+ const fix = r => {
188
318
  if (!r || !r.querySelectorAll) return;
189
319
  r.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"]')
190
- .forEach((n) => {
191
- const st = (n.getAttribute('style') || '').toLowerCase();
192
- if (!st.includes('min-height:400')) return;
193
- if (!inPostArea(n)) return;
194
- try { tightenEzoicMinHeight(n.closest('.' + WRAP_CLASS) || n.parentElement || n); } catch (e) {}
320
+ .forEach(n => {
321
+ if (!(n.getAttribute('style') || '').toLowerCase().includes('min-height:400')) return;
322
+ if (!inPost(n)) return;
323
+ tightenMinHeight(n.closest('.' + WRAP_CLASS) || n.parentElement || n);
195
324
  });
196
325
  };
197
- requestAnimationFrame(() => maybeFix(root));
326
+ requestAnimationFrame(() => fix(root));
198
327
  const pending = new Set();
199
- let scheduled = false;
200
- const flush = () => {
201
- scheduled = false;
202
- pending.forEach((n) => { try { maybeFix(n); } catch (e) {} });
203
- pending.clear();
204
- };
205
- new MutationObserver((muts) => {
328
+ let sched = false;
329
+ new MutationObserver(muts => {
206
330
  for (const m of muts) {
207
- if (m.type === 'attributes') {
208
- const t = m.target && m.target.nodeType === 1 ? m.target : m.target && m.target.parentElement;
209
- if (t) pending.add(t);
210
- } else if (m.addedNodes) {
211
- m.addedNodes.forEach((n) => { if (n && n.nodeType === 1) pending.add(n); });
212
- }
331
+ const t = m.type === 'attributes'
332
+ ? (m.target.nodeType === 1 ? m.target : m.target.parentElement)
333
+ : null;
334
+ if (t) pending.add(t);
335
+ else m.addedNodes && m.addedNodes.forEach(n => n.nodeType === 1 && pending.add(n));
336
+ }
337
+ if (pending.size && !sched) {
338
+ sched = true;
339
+ requestAnimationFrame(() => { sched = false; pending.forEach(n => fix(n)); pending.clear(); });
213
340
  }
214
- if (pending.size && !scheduled) { scheduled = true; requestAnimationFrame(flush); }
215
341
  }).observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
216
342
  } catch (e) {}
217
343
  }
218
344
 
219
- // ─── State ──────────────────────────────────────────────────────────────────
220
- const state = {
221
- pageKey: null, cfg: null,
222
- allTopics: [], allPosts: [], allCategories: [],
223
- curTopics: 0, curPosts: 0, curCategories: 0,
224
- lastShowById: new Map(),
225
- domObs: null, io: null, ioMargin: null,
226
- internalDomChange: 0,
227
- inflight: 0, pending: [], pendingSet: new Set(),
228
- scrollBoostUntil: 0, lastScrollY: 0, lastScrollTs: 0,
229
- heroDoneForPage: false,
230
- runQueued: false, burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstReqTs: 0,
231
- };
232
-
233
- let blockedUntil = 0;
234
- const insertingIds = new Set();
235
- function isBlocked() { return now() < blockedUntil; }
345
+ // ─── DOM helpers ─────────────────────────────────────────────────────────────
236
346
 
237
- // ─── Utils ──────────────────────────────────────────────────────────────────
238
- function parsePool(raw) {
239
- if (!raw) return [];
240
- const seen = new Set(), out = [];
241
- String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean).forEach((v) => {
242
- const n = parseInt(v, 10);
243
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
244
- });
245
- return out;
246
- }
247
-
248
- function getPageKey() {
249
- try {
250
- const ax = window.ajaxify;
251
- if (ax && ax.data) {
252
- if (ax.data.tid) return 'topic:' + ax.data.tid;
253
- if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
254
- }
255
- } catch (e) {}
256
- return window.location.pathname;
257
- }
258
-
259
- function getPreloadRootMargin() {
260
- return isMobile()
261
- ? (isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE)
262
- : (isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP);
263
- }
264
-
265
- function getMaxInflight() {
266
- return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
267
- }
268
-
269
- function withInternalDomChange(fn) {
270
- state.internalDomChange++;
271
- try { fn(); } finally { state.internalDomChange--; }
272
- }
273
-
274
- // ─── DOM helpers ────────────────────────────────────────────────────────────
275
- function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
276
- function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
277
-
278
- function getPostContainers() {
279
- return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter((el) => {
347
+ function getItems(kind) {
348
+ if (kind === 'topic') return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
280
349
  if (!el.isConnected) return false;
281
350
  if (!el.querySelector('[component="post/content"]')) return false;
282
- const pp = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
283
- if (pp && pp !== el) return false;
284
- if (el.getAttribute('component') === 'post/parent') return false;
285
- return true;
351
+ const pp = el.parentElement && el.parentElement.closest(SEL.post);
352
+ return !(pp && pp !== el) && el.getAttribute('component') !== 'post/parent';
286
353
  });
354
+ if (kind === 'categoryTopics') return Array.from(document.querySelectorAll(SEL.topic));
355
+ if (kind === 'categories') return Array.from(document.querySelectorAll(SEL.category));
356
+ return [];
287
357
  }
288
358
 
289
- function getKind() {
290
- const p = window.location.pathname || '';
291
- if (/^\/topic\//.test(p)) return 'topic';
292
- if (/^\/category\//.test(p)) return 'categoryTopics';
293
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
294
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
295
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
296
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
297
- return 'other';
298
- }
299
-
300
- function isAdjacentAd(target) {
301
- if (!target) return false;
302
- const next = target.nextElementSibling;
303
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
304
- const prev = target.previousElementSibling;
305
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
306
- return false;
307
- }
308
-
309
- function findWrap(kindClass, afterPos) {
310
- return document.querySelector('.' + WRAP_CLASS + '.' + kindClass + '[data-ezoic-after="' + afterPos + '"]');
311
- }
312
-
313
- // ─── Network warmup ─────────────────────────────────────────────────────────
314
- const _warmLinksDone = new Set();
315
- function warmUpNetwork() {
316
- try {
317
- const head = document.head || document.getElementsByTagName('head')[0];
318
- if (!head) return;
319
- [
320
- ['preconnect', 'https://g.ezoic.net', true],
321
- ['dns-prefetch', 'https://g.ezoic.net', false],
322
- ['preconnect', 'https://go.ezoic.net', true],
323
- ['dns-prefetch', 'https://go.ezoic.net', false],
324
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
325
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
326
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
327
- ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
328
- ['preconnect', 'https://tpc.googlesyndication.com', true],
329
- ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
330
- ].forEach(([rel, href, cors]) => {
331
- const key = rel + '|' + href;
332
- if (_warmLinksDone.has(key)) return;
333
- _warmLinksDone.add(key);
334
- const link = document.createElement('link');
335
- link.rel = rel; link.href = href;
336
- if (cors) link.crossOrigin = 'anonymous';
337
- head.appendChild(link);
338
- });
339
- } catch (e) {}
340
- }
341
-
342
- // ─── Ezoic bridge ───────────────────────────────────────────────────────────
343
- // Patch showAds so it silently skips IDs whose placeholder is not in the DOM.
344
- function patchShowAds() {
345
- const applyPatch = () => {
346
- try {
347
- window.ezstandalone = window.ezstandalone || {};
348
- const ez = window.ezstandalone;
349
- if (window.__nodebbEzoicPatched) return;
350
- if (typeof ez.showAds !== 'function') return;
351
- window.__nodebbEzoicPatched = true;
352
- const orig = ez.showAds;
353
- ez.showAds = function (...args) {
354
- if (isBlocked()) return;
355
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
356
- const seen = new Set();
357
- for (const v of ids) {
358
- const id = parseInt(v, 10);
359
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
360
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
361
- if (!ph || !ph.isConnected) continue;
362
- seen.add(id);
363
- try { orig.call(ez, id); } catch (e) {}
364
- }
365
- };
366
- } catch (e) {}
367
- };
368
- applyPatch();
369
- if (!window.__nodebbEzoicPatched) {
370
- try {
371
- window.ezstandalone = window.ezstandalone || {};
372
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
373
- window.ezstandalone.cmd.push(applyPatch);
374
- } catch (e) {}
375
- }
376
- }
377
-
378
- // ─── Config ─────────────────────────────────────────────────────────────────
379
- async function fetchConfigOnce() {
380
- if (state.cfg) return state.cfg;
381
- try {
382
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
383
- if (!res.ok) return null;
384
- state.cfg = await res.json();
385
- return state.cfg;
386
- } catch (e) { return null; }
359
+ function isAdjacentWrap(el) {
360
+ const n = el.nextElementSibling, p = el.previousElementSibling;
361
+ return (n && n.classList.contains(WRAP_CLASS)) || (p && p.classList.contains(WRAP_CLASS));
387
362
  }
388
363
 
389
- function initPools(cfg) {
390
- if (!cfg) return;
391
- // Pools are NOT pre-seeded into the DOM: connecting placeholders offscreen
392
- // causes Ezoic to pre-define slots → "already defined" errors on SPA navigations.
393
- if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
394
- if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
395
- if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
364
+ function findWrapAt(kindClass, afterPos) {
365
+ return document.querySelector('.' + WRAP_CLASS + '.' + kindClass +
366
+ '[data-ezoic-after="' + afterPos + '"]');
396
367
  }
397
368
 
398
- // ─── Insertion primitives ───────────────────────────────────────────────────
369
+ // ─── Wrap creation / destruction ─────────────────────────────────────────────
399
370
 
400
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
371
+ function buildWrap(id, kindClass, afterPos) {
401
372
  const wrap = document.createElement('div');
402
- wrap.className = WRAP_CLASS + ' ' + kindClass;
373
+ wrap.className = WRAP_CLASS + ' ' + kindClass;
403
374
  wrap.setAttribute('data-ezoic-after', String(afterPos));
404
375
  wrap.setAttribute('data-ezoic-wrapid', String(id));
405
376
  wrap.setAttribute('data-created', String(now()));
406
377
  if (afterPos === 1) wrap.setAttribute('data-ezoic-pin', '1');
407
378
  wrap.style.width = '100%';
408
- if (createPlaceholder) {
409
- const ph = document.createElement('div');
410
- ph.id = PLACEHOLDER_PREFIX + id;
411
- ph.setAttribute('data-ezoic-id', String(id));
412
- wrap.appendChild(ph);
413
- }
379
+ const ph = document.createElement('div');
380
+ ph.id = PLACEHOLDER_PREFIX + id;
381
+ ph.setAttribute('data-ezoic-id', String(id));
382
+ wrap.appendChild(ph);
414
383
  return wrap;
415
384
  }
416
385
 
417
- function insertAfter(target, id, kindClass, afterPos) {
418
- if (!target || !target.insertAdjacentElement) return null;
419
- if (findWrap(kindClass, afterPos)) return null;
420
- if (insertingIds.has(id)) return null;
421
- const existingPh = document.getElementById(PLACEHOLDER_PREFIX + id);
422
- insertingIds.add(id);
386
+ function dropWrap(wrap) {
387
+ // Unobserve placeholder, then remove the whole wrap node.
388
+ // The placeholder ID is freed back to the pool automatically because
389
+ // it no longer has a DOM element — pickId will pick it again.
423
390
  try {
424
- const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
425
- target.insertAdjacentElement('afterend', wrap);
426
- if (existingPh) {
427
- existingPh.setAttribute('data-ezoic-id', String(id));
428
- if (!wrap.firstElementChild) wrap.appendChild(existingPh);
429
- else wrap.replaceChild(existingPh, wrap.firstElementChild);
430
- }
431
- return wrap;
432
- } finally {
433
- insertingIds.delete(id);
434
- }
435
- }
436
-
437
- function releaseWrapNode(wrap) {
438
- try {
439
- const ph = wrap && wrap.querySelector && wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
440
- if (ph) {
441
- try { (document.body || document.documentElement).appendChild(ph); } catch (e) {}
442
- try { state.io && state.io.unobserve(ph); } catch (e) {}
443
- }
391
+ const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
392
+ if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
444
393
  } catch (e) {}
445
- try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
394
+ try { wrap.remove(); } catch (e) {}
446
395
  }
447
396
 
448
- function pickIdFromAll(allIds, cursorKey) {
449
- const n = allIds.length;
450
- if (!n) return null;
397
+ function pickId(poolKey, cursorKey) {
398
+ const pool = st.pools[poolKey];
399
+ if (!pool.length) return null;
400
+ const n = pool.length;
451
401
  for (let tries = 0; tries < n; tries++) {
452
- const idx = state[cursorKey] % n;
453
- state[cursorKey] = (state[cursorKey] + 1) % n;
454
- const id = allIds[idx];
455
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
456
- // Skip if placeholder is already mounted in a visible wrap.
457
- if (ph && ph.isConnected && ph.closest('.' + WRAP_CLASS)) continue;
458
- return id;
402
+ const idx = st.cursors[cursorKey] % n;
403
+ st.cursors[cursorKey] = (idx + 1) % n;
404
+ const id = pool[idx];
405
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
406
+ // ID is available if its placeholder is absent from DOM or is floating detached.
407
+ if (!ph || !ph.isConnected || !ph.closest('.' + WRAP_CLASS)) return id;
459
408
  }
460
- return null;
461
- }
462
-
463
- // ─── Orphan / cluster management ────────────────────────────────────────────
464
- function pruneOrphanWraps(kindClass, items) {
465
- if (!items || !items.length) return 0;
466
- const itemSet = new Set(items);
467
- const isMessage = kindClass === 'ezoic-ad-message';
468
- // Between/category ads are NEVER fully released — only hidden/shown.
469
- // Releasing frees IDs into the pool → re-injection at wrong positions.
470
- const allowRelease = isMessage;
471
- let removed = 0;
472
-
473
- const hasNearbyItem = (wrap) => {
474
- // If wrap is inside a li.nodebb-ezoic-host, check the HOST's siblings.
475
- const pivot = (wrap.parentElement && wrap.parentElement.classList &&
476
- wrap.parentElement.classList.contains(HOST_CLASS))
477
- ? wrap.parentElement : wrap;
478
- let prev = pivot.previousElementSibling;
479
- for (let i = 0; i < 14 && prev; i++) {
480
- if (itemSet.has(prev)) return true;
481
- prev = prev.previousElementSibling;
482
- }
483
- let next = pivot.nextElementSibling;
484
- for (let i = 0; i < 14 && next; i++) {
485
- if (itemSet.has(next)) return true;
486
- next = next.nextElementSibling;
487
- }
488
- return false;
489
- };
490
-
491
- document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach((wrap) => {
492
- if (wrap.getAttribute('data-ezoic-pin') === '1') return;
493
- const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
494
- if (created && (now() - created) < keepEmptyWrapMs()) return;
495
-
496
- if (hasNearbyItem(wrap)) {
497
- try { wrap.classList.remove('ez-orphan-hidden'); wrap.style.display = ''; } catch (e) {}
498
- return;
499
- }
500
-
501
- // Anchor gone → hide.
502
- try { wrap.classList.add('ez-orphan-hidden'); wrap.style.display = 'none'; } catch (e) {}
503
- if (!allowRelease) return;
504
-
505
- // Message-ads only: release when far off-screen.
506
- try {
507
- const r = wrap.getBoundingClientRect();
508
- const vh = Math.max(1, window.innerHeight || 1);
509
- if (r.bottom > -vh * 2 && r.top < vh * 4) return;
510
- } catch (e) { return; }
511
-
512
- withInternalDomChange(() => releaseWrapNode(wrap));
513
- removed++;
514
- });
515
- return removed;
516
- }
517
-
518
- function decluster(kindClass) {
519
- const wraps = Array.from(document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass));
520
- if (wraps.length < 2) return 0;
521
- // Between/category ads: hide the duplicate rather than releasing it.
522
- // Releasing frees IDs into the pool → re-injection at wrong positions on scroll-up.
523
- const allowRelease = kindClass === 'ezoic-ad-message';
524
- const evict = (n) => {
525
- if (allowRelease) { withInternalDomChange(() => releaseWrapNode(n)); }
526
- else { try { n.classList.add('ez-orphan-hidden'); n.style.display = 'none'; } catch (e) {} }
527
- };
528
- const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
529
- const isFresh = (w) => {
530
- const c = parseInt(w.getAttribute('data-created') || '0', 10);
531
- return c && (now() - c) < keepEmptyWrapMs();
532
- };
533
- let removed = 0;
534
- for (const w of wraps) {
535
- if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
536
- if (w.classList && w.classList.contains('ez-orphan-hidden')) continue;
537
- let prev = w.previousElementSibling;
538
- for (let i = 0; i < 3 && prev; i++) {
539
- if (isWrap(prev)) {
540
- if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
541
- if (prev.classList && prev.classList.contains('ez-orphan-hidden')) break;
542
- const prevFilled = isFilled(prev);
543
- const curFilled = isFilled(w);
544
- if (curFilled) {
545
- if (!prevFilled && !isFresh(prev)) { evict(prev); removed++; }
546
- break;
547
- }
548
- if (prevFilled || !isFresh(w)) { evict(w); removed++; }
549
- break;
550
- }
551
- prev = prev.previousElementSibling;
552
- }
553
- }
554
- return removed;
555
- }
556
-
557
- // ─── Pile-up repair for between-ads ─────────────────────────────────────────
558
- // When NodeBB's DOM structure requires between-ad DIVs to live inside a <ul>,
559
- // they must be wrapped in a <li> host. This module also corrects position
560
- // drift that occurs after infinite-scroll mutations.
561
- //
562
- // FIX (v18): replaced the previous blind `previousTopicLi()` DOM walk with
563
- // anchor lookup via `data-ezoic-after` + topic `data-index`. The old approach
564
- // was the ROOT CAUSE of ads piling below the first topic: during pile-up
565
- // detection it moved ALL piled wraps to after the SAME first topic it found.
566
- //
567
- // Now each wrap is moved to after the specific topic whose ordinal matches
568
- // the wrap's own `data-ezoic-after` attribute.
569
-
570
- function pileFixGetTopicList() {
571
- try {
572
- const li = document.querySelector(SELECTORS.topicItem);
573
- if (!li) return null;
574
- return li.closest ? li.closest('ul,ol') : null;
575
- } catch (e) { return null; }
576
- }
577
-
578
- function ensureHostForWrap(wrap, ul) {
579
- try {
580
- if (!wrap || wrap.nodeType !== 1) return null;
581
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
582
- if (!wrap.matches || !wrap.matches(wrapSel)) return null;
583
- const existing = wrap.closest ? wrap.closest('li.' + HOST_CLASS) : null;
584
- if (existing) return existing;
585
- if (!ul) ul = wrap.closest ? wrap.closest('ul,ol') : null;
586
- if (!ul || !/^(UL|OL)$/i.test(ul.tagName)) return null;
587
- if (wrap.parentElement !== ul) return null;
588
- const host = document.createElement('li');
589
- host.className = HOST_CLASS;
590
- host.setAttribute('role', 'listitem');
591
- host.style.cssText = 'list-style:none;width:100%;';
592
- ul.insertBefore(host, wrap);
593
- host.appendChild(wrap);
594
- return host;
595
- } catch (e) { return null; }
596
- }
597
-
598
- function detectPileUp(ul) {
599
- try {
600
- let run = 0, maxRun = 0;
601
- const kids = ul.children;
602
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
603
- for (let i = 0; i < kids.length; i++) {
604
- const el = kids[i];
605
- const isHost = el.tagName === 'LI' && el.classList && el.classList.contains(HOST_CLASS);
606
- const isBetween = (isHost && el.querySelector && el.querySelector(wrapSel)) ||
607
- (el.matches && el.matches(wrapSel));
608
- if (isBetween) { maxRun = Math.max(maxRun, ++run); }
609
- else if (el.matches && el.matches(SELECTORS.topicItem)) { run = 0; }
610
- else { run = 0; }
611
- }
612
- return maxRun >= 2;
613
- } catch (e) { return false; }
614
- }
615
-
616
- /**
617
- * Move each pile-up host to after its correct anchor topic.
618
- *
619
- * Anchor is determined by matching the wrap's `data-ezoic-after` (1-based ordinal)
620
- * against each topic LI's `data-index` (0-based) → ordinal = data-index + 1.
621
- */
622
- function redistributePileUp(ul) {
623
- try {
624
- if (!ul) return;
625
- // Step 1: wrap bare between-ad DIVs that are direct <ul> children.
626
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
627
- const directs = Array.from(ul.querySelectorAll(':scope > ' + wrapSel));
628
- directs.forEach((w) => ensureHostForWrap(w, ul));
629
-
630
- // Step 2: bail if no pile-up.
631
- if (!detectPileUp(ul)) return;
632
-
633
- // Step 3: build ordinal → topic LI map.
634
- const topicLis = Array.from(ul.querySelectorAll(':scope > ' + SELECTORS.topicItem));
635
- const ordinalMap = new Map();
636
- topicLis.forEach((li, i) => {
637
- const di = li.dataset && (li.dataset.index !== undefined ? li.dataset.index : null);
638
- const raw = di !== null && di !== undefined ? di : li.getAttribute('data-index');
639
- const ord = (raw !== null && raw !== undefined && raw !== '' && !isNaN(raw))
640
- ? parseInt(raw, 10) + 1 // data-index is 0-based; ordinals are 1-based
641
- : i + 1;
642
- ordinalMap.set(ord, li);
643
- });
644
-
645
- // Step 4: move each host to immediately after its correct anchor topic.
646
- const hosts = Array.from(ul.querySelectorAll(':scope > li.' + HOST_CLASS));
647
- hosts.forEach((host) => {
648
- try {
649
- const wrap = host.querySelector && host.querySelector(wrapSel);
650
- if (!wrap) return;
651
-
652
- const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
653
- if (!afterPos) return;
654
-
655
- const anchor = ordinalMap.get(afterPos);
656
- if (!anchor) return; // anchor topic not loaded yet — leave in place
657
-
658
- if (host.previousElementSibling !== anchor) {
659
- anchor.insertAdjacentElement('afterend', host);
660
- }
661
- } catch (e) {}
662
- });
663
- } catch (e) {}
664
- }
665
-
666
- let _pileFixScheduled = false;
667
- let _pileFixLastRun = 0;
668
- const PILE_FIX_COOLDOWN = 180;
669
-
670
- function schedulePileFix() {
671
- if (now() - _pileFixLastRun < PILE_FIX_COOLDOWN) return;
672
- if (_pileFixScheduled) return;
673
- _pileFixScheduled = true;
674
- requestAnimationFrame(() => {
675
- _pileFixScheduled = false;
676
- _pileFixLastRun = now();
677
- try { const ul = pileFixGetTopicList(); if (ul) redistributePileUp(ul); } catch (e) {}
678
- });
409
+ return null; // all IDs currently mounted
679
410
  }
680
411
 
681
- function initPileFixObserver() {
682
- try {
683
- if (typeof MutationObserver === 'undefined') return;
684
- const observe = (ul) => {
685
- new MutationObserver(() => schedulePileFix())
686
- .observe(ul, { childList: true, subtree: true });
687
- };
688
- const ul = pileFixGetTopicList();
689
- if (ul) {
690
- observe(ul);
691
- } else {
692
- const sentinel = new MutationObserver(() => {
693
- const u = pileFixGetTopicList();
694
- if (u) { try { sentinel.disconnect(); } catch (e) {} observe(u); }
695
- });
696
- sentinel.observe(document.documentElement || document.body, { childList: true, subtree: true });
697
- }
698
- } catch (e) {}
699
- }
700
-
701
- // ─── IntersectionObserver (preload / fast fill) ─────────────────────────────
702
- function ensurePreloadObserver() {
703
- const desiredMargin = getPreloadRootMargin();
704
- if (state.io && state.ioMargin === desiredMargin) return state.io;
705
-
706
- try { state.io && state.io.disconnect(); } catch (e) {}
707
- state.io = null;
412
+ // ─── IntersectionObserver ────────────────────────────────────────────────────
708
413
 
414
+ function ensureIO() {
415
+ const margin = getIoMargin();
416
+ if (st.io && st.ioMargin === margin) return st.io;
417
+ try { st.io && st.io.disconnect(); } catch (e) {}
418
+ st.io = null;
709
419
  try {
710
- state.io = new IntersectionObserver((entries) => {
711
- for (const ent of entries) {
712
- if (!ent.isIntersecting) continue;
713
- const el = ent.target;
714
- try { state.io && state.io.unobserve(el); } catch (e) {}
715
- const id = parseInt(el && el.getAttribute && el.getAttribute('data-ezoic-id'), 10);
716
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
420
+ st.io = new IntersectionObserver(entries => {
421
+ for (const e of entries) {
422
+ if (!e.isIntersecting) continue;
423
+ try { st.io && st.io.unobserve(e.target); } catch (x) {}
424
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
425
+ if (id > 0) enqueueShow(id);
717
426
  }
718
- }, { root: null, rootMargin: desiredMargin, threshold: 0 });
719
- state.ioMargin = desiredMargin;
427
+ }, { rootMargin: margin, threshold: 0 });
428
+ st.ioMargin = margin;
429
+ // Re-observe all live placeholders.
720
430
  document.querySelectorAll('[id^="' + PLACEHOLDER_PREFIX + '"]')
721
- .forEach((n) => { try { state.io.observe(n); } catch (e) {} });
722
- } catch (e) { state.io = state.ioMargin = null; }
723
-
724
- return state.io;
431
+ .forEach(ph => { try { st.io.observe(ph); } catch (e) {} });
432
+ } catch (e) { st.io = st.ioMargin = null; }
433
+ return st.io;
725
434
  }
726
435
 
727
- function observePlaceholder(id) {
436
+ function observePh(id) {
728
437
  const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
729
438
  if (!ph || !ph.isConnected) return;
730
439
  ph.setAttribute('data-ezoic-id', String(id));
731
- try { const io = ensurePreloadObserver(); io && io.observe(ph); } catch (e) {}
440
+ try { ensureIO() && ensureIO().observe(ph); } catch (e) {}
441
+ // Immediate fire if already near viewport.
732
442
  try {
733
443
  const r = ph.getBoundingClientRect();
734
- const mobile = isMobile(), boosted = isBoosted();
735
- const screens = boosted ? (mobile ? 9.0 : 5.0) : (mobile ? 6.0 : 3.0);
736
- const minBot = boosted ? (mobile ? -2600 : -1500) : (mobile ? -1400 : -800);
737
- if (r.top < window.innerHeight * screens && r.bottom > minBot) enqueueShow(id);
444
+ const mob = isMobile(), boost = isBoosted();
445
+ const scr = boost ? (mob ? 9 : 5) : (mob ? 6 : 3);
446
+ const bot = boost ? (mob ? -2600 : -1500) : (mob ? -1400 : -800);
447
+ if (r.top < window.innerHeight * scr && r.bottom > bot) enqueueShow(id);
738
448
  } catch (e) {}
739
449
  }
740
450
 
741
- // ─── Show queue ─────────────────────────────────────────────────────────────
451
+ // ─── Show queue ──────────────────────────────────────────────────────────────
452
+
742
453
  function enqueueShow(id) {
743
454
  if (!id || isBlocked()) return;
744
- const t = now();
745
- if (t - (state.lastShowById.get(id) || 0) < 900) return;
746
- const max = getMaxInflight();
747
- if (state.inflight >= max) {
748
- if (!state.pendingSet.has(id)) { state.pending.push(id); state.pendingSet.add(id); }
455
+ if (now() - (st.lastShow.get(id) || 0) < 900) return;
456
+ if (st.inflight >= getMaxInflight()) {
457
+ if (!st.pendingSet.has(id)) { st.pending.push(id); st.pendingSet.add(id); }
749
458
  return;
750
459
  }
751
460
  startShow(id);
@@ -753,414 +462,399 @@
753
462
 
754
463
  function drainQueue() {
755
464
  if (isBlocked()) return;
756
- const max = getMaxInflight();
757
- while (state.inflight < max && state.pending.length) {
758
- const id = state.pending.shift();
759
- state.pendingSet.delete(id);
465
+ while (st.inflight < getMaxInflight() && st.pending.length) {
466
+ const id = st.pending.shift();
467
+ st.pendingSet.delete(id);
760
468
  startShow(id);
761
469
  }
762
470
  }
763
471
 
764
- function markEmptyWrapper(id) {
765
- try {
766
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
767
- if (!ph || !ph.isConnected) return;
768
- const wrap = ph.closest && ph.closest('.' + WRAP_CLASS);
769
- if (!wrap) return;
770
- setTimeout(() => {
771
- try {
772
- const ph2 = document.getElementById(PLACEHOLDER_PREFIX + id);
773
- if (!ph2 || !ph2.isConnected) return;
774
- const w2 = ph2.closest && ph2.closest('.' + WRAP_CLASS);
775
- if (!w2) return;
776
- const created = parseInt(w2.getAttribute('data-created') || '0', 10);
777
- if (created && (now() - created) < keepEmptyWrapMs()) return;
778
- if (!isFilled(ph2)) { w2.classList.add('is-empty'); watchWrapForFill(w2); }
779
- else { w2.classList.remove('is-empty'); tightenEzoicMinHeight(w2); }
780
- } catch (e) {}
781
- }, 15_000);
782
- } catch (e) {}
472
+ function scheduleMarkEmpty(id) {
473
+ setTimeout(() => {
474
+ try {
475
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
476
+ if (!ph || !ph.isConnected) return;
477
+ const w = ph.closest('.' + WRAP_CLASS);
478
+ if (!w) return;
479
+ if (isFilled(ph)) { w.classList.remove('is-empty'); tightenMinHeight(w); }
480
+ else { w.classList.add('is-empty'); watchFill(w); }
481
+ } catch (e) {}
482
+ }, 15_000);
783
483
  }
784
484
 
785
485
  function startShow(id) {
786
486
  if (!id || isBlocked()) return;
787
- state.inflight++;
788
- let released = false;
789
- const release = () => {
790
- if (released) return;
791
- released = true;
792
- state.inflight = Math.max(0, state.inflight - 1);
487
+ st.inflight++;
488
+ let done = false;
489
+ const finish = () => {
490
+ if (done) return; done = true;
491
+ st.inflight = Math.max(0, st.inflight - 1);
793
492
  drainQueue();
794
493
  };
795
- const hardTimer = setTimeout(release, 6500);
494
+ const timeout = setTimeout(finish, 6500);
495
+
796
496
  requestAnimationFrame(() => {
797
497
  try {
798
498
  if (isBlocked()) return;
799
499
  const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
800
500
  if (!ph || !ph.isConnected) return;
801
- if (isFilled(ph)) { clearTimeout(hardTimer); release(); return; }
802
- const t = now();
803
- if (t - (state.lastShowById.get(id) || 0) < 900) return;
804
- state.lastShowById.set(id, t);
805
- window.ezstandalone = window.ezstandalone || {};
806
- const ez = window.ezstandalone;
501
+ if (isFilled(ph)) { clearTimeout(timeout); finish(); return; }
502
+ if (now() - (st.lastShow.get(id) || 0) < 900) return;
503
+ st.lastShow.set(id, now());
504
+
505
+ const ez = window.ezstandalone = window.ezstandalone || {};
807
506
  const doShow = () => {
808
507
  try { ez.showAds(id); } catch (e) {}
809
- try { markEmptyWrapper(id); } catch (e) {}
508
+ scheduleMarkEmpty(id);
810
509
  try {
811
- const ww = document.getElementById(PLACEHOLDER_PREFIX + id);
812
- const w2 = ww && ww.closest && ww.closest('.' + WRAP_CLASS);
813
- if (w2) {
814
- watchWrapForFill(w2);
815
- setTimeout(() => { try { tightenEzoicMinHeight(w2); } catch (e) {} }, 900);
816
- setTimeout(() => { try { tightenEzoicMinHeight(w2); } catch (e) {} }, 2200);
510
+ const w = document.getElementById(PLACEHOLDER_PREFIX + id)
511
+ ?.closest('.' + WRAP_CLASS);
512
+ if (w) {
513
+ watchFill(w);
514
+ setTimeout(() => tightenMinHeight(w), 900);
515
+ setTimeout(() => tightenMinHeight(w), 2200);
817
516
  }
818
517
  } catch (e) {}
819
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
518
+ setTimeout(() => { clearTimeout(timeout); finish(); }, 650);
820
519
  };
821
- if (Array.isArray(ez.cmd)) { try { ez.cmd.push(doShow); } catch (e) { doShow(); } }
520
+ if (Array.isArray(ez.cmd)) try { ez.cmd.push(doShow); } catch (e) { doShow(); }
822
521
  else doShow();
823
- } finally {
824
- // hardTimer covers early returns.
825
- }
522
+ } finally {} // timeout covers early returns
826
523
  });
827
524
  }
828
525
 
829
- // ─── Core injection ─────────────────────────────────────────────────────────
830
- function getItemOrdinal(el, fallbackIndex) {
526
+ // ─── Core injection ──────────────────────────────────────────────────────────
527
+
528
+ function getOrdinal(el, idx) {
831
529
  try {
832
- if (!el) return fallbackIndex + 1;
833
- const di = el.getAttribute('data-index') ||
834
- (el.dataset && (el.dataset.index || el.dataset.postIndex));
835
- if (di != null && di !== '' && !isNaN(di)) {
836
- const n = parseInt(di, 10);
837
- if (Number.isFinite(n) && n >= 0) return n + 1;
838
- }
839
- const d1 = el.getAttribute('data-idx') || el.getAttribute('data-position') ||
840
- (el.dataset && (el.dataset.idx || el.dataset.position));
841
- if (d1 != null && d1 !== '' && !isNaN(d1)) {
842
- const n = parseInt(d1, 10);
843
- if (Number.isFinite(n) && n > 0) return n;
844
- }
530
+ const v = el.getAttribute('data-index') || (el.dataset && el.dataset.index);
531
+ if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10) + 1;
845
532
  } catch (e) {}
846
- return fallbackIndex + 1;
533
+ return idx + 1;
847
534
  }
848
535
 
849
- function buildOrdinalMap(items) {
536
+ function computeTargets(items, interval, showFirst) {
537
+ // Build ordinal map and determine target positions.
850
538
  const map = new Map();
851
539
  let max = 0;
852
- for (let i = 0; i < items.length; i++) {
853
- const ord = getItemOrdinal(items[i], i);
854
- map.set(ord, items[i]);
540
+ items.forEach((el, i) => {
541
+ const ord = getOrdinal(el, i);
542
+ map.set(ord, el);
855
543
  if (ord > max) max = ord;
856
- }
857
- return { map, max };
544
+ });
545
+ const targets = [];
546
+ if (showFirst && max >= 1) targets.push(1);
547
+ for (let i = interval; i <= max; i += interval) targets.push(i);
548
+ return { map, targets: [...new Set(targets)].sort((a, b) => a - b) };
858
549
  }
859
550
 
860
- function computeTargets(count, interval, showFirst) {
861
- const out = [];
862
- if (count <= 0) return out;
863
- if (showFirst) out.push(1);
864
- for (let i = 1; i <= count; i++) { if (i % interval === 0) out.push(i); }
865
- return Array.from(new Set(out)).sort((a, b) => a - b);
866
- }
551
+ /**
552
+ * Remove all wraps whose anchor item is no longer in the live item set.
553
+ *
554
+ * Strategy: always fully remove (not just hide).
555
+ * - The placeholder ID returns to the pool naturally (no DOM element pickId skips).
556
+ * - When the user scrolls back down and those items reload, fresh wraps are injected.
557
+ * - The viewport guard in injectWraps (rect.bottom < 0) prevents re-injection
558
+ * on items that are above the fold (just loaded by NodeBB during upward scroll).
559
+ */
560
+ function removeOrphanWraps(kindClass, itemSet) {
561
+ document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach(wrap => {
562
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
867
563
 
868
- function pickRecyclableWrap(kindClass) {
869
- const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
870
- if (!wraps || !wraps.length) return null;
871
- const vh = Math.max(300, window.innerHeight || 800);
872
- const threshold = -Math.min(9000, Math.round(vh * 6));
873
- let best = null, bestBottom = Infinity;
874
- for (const w of wraps) {
875
- if (!w.isConnected) continue;
876
- if (w.getAttribute('data-ezoic-pin') === '1') continue;
877
- // FIX: do not recycle near-top wraps (afterPos ≤ 6).
878
- // These are likely to be visible again when the user scrolls back up.
879
- const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '0', 10);
880
- if (afterPos > 0 && afterPos <= 6) continue;
881
- const rect = w.getBoundingClientRect();
882
- if (rect.bottom < threshold && rect.bottom < bestBottom) {
883
- bestBottom = rect.bottom;
884
- best = w;
564
+ // Check if anchor is still alive using DOM proximity.
565
+ const pivot = (wrap.parentElement && wrap.parentElement.classList.contains(HOST_CLASS))
566
+ ? wrap.parentElement : wrap;
567
+ let found = false, el = pivot.previousElementSibling;
568
+ for (let i = 0; i < 4 && el; i++, el = el.previousElementSibling)
569
+ if (itemSet.has(el)) { found = true; break; }
570
+ if (!found) {
571
+ el = pivot.nextElementSibling;
572
+ for (let i = 0; i < 4 && el; i++, el = el.nextElementSibling)
573
+ if (itemSet.has(el)) { found = true; break; }
885
574
  }
886
- }
887
- return best;
888
- }
889
575
 
890
- function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
891
- try {
892
- if (!anchorEl || !wrap || !wrap.isConnected) return null;
893
- wrap.setAttribute('data-ezoic-after', String(afterPos));
894
- anchorEl.insertAdjacentElement('afterend', wrap);
895
- try { wrap.style.contain = 'layout style paint'; } catch (e) {}
896
- return wrap;
897
- } catch (e) { return null; }
576
+ if (!found) withInternal(() => dropWrap(wrap));
577
+ });
898
578
  }
899
579
 
900
- function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
580
+ function injectWraps(kindClass, items, interval, showFirst, poolKey, cursorKey) {
901
581
  if (!items.length) return 0;
902
- const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
903
- const targets = computeTargets(maxOrdinal, interval, showFirst);
904
- const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
905
- let inserted = 0;
582
+ const { map, targets } = computeTargets(items, interval, showFirst);
583
+ const max = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
584
+ let n = 0;
906
585
 
907
- for (const afterPos of targets) {
908
- if (inserted >= maxInserts) break;
909
- const el = ordinalMap.get(afterPos);
586
+ for (const pos of targets) {
587
+ if (n >= max) break;
588
+ const el = map.get(pos);
910
589
  if (!el || !el.isConnected) continue;
911
590
 
912
- // Never inject after an element that is fully above the viewport top.
913
- // This is the definitive guard: NodeBB loads items above the fold when the
914
- // user scrolls up. Those items have rect.bottom < 0 at the moment of load.
915
- // Any negative margin ("a little above is ok") still causes the pile-up
916
- // because NodeBB loads a whole batch just above we must use exactly 0.
917
- try {
918
- if (el.getBoundingClientRect().bottom < 0) continue;
919
- } catch (e) {}
591
+ // ── Viewport guard ────────────────────────────────────────────────────────
592
+ // Never inject after an element whose bottom is above the viewport top.
593
+ // NodeBB loads items above the fold when the user scrolls up; those items
594
+ // have rect.bottom < 0 at load time. Injecting there causes the pile-up.
595
+ try { if (el.getBoundingClientRect().bottom < 0) continue; } catch (e) {}
920
596
 
921
- if (isAdjacentAd(el)) continue;
922
- if (findWrap(kindClass, afterPos)) continue;
597
+ if (isAdjacentWrap(el)) continue;
598
+ if (findWrapAt(kindClass, pos)) continue;
923
599
 
924
- let id = pickIdFromAll(allIds, cursorKey);
925
- let recycledWrap = null;
600
+ const id = pickId(poolKey, cursorKey);
601
+ if (!id) break;
926
602
 
927
- if (!id) {
928
- // Pool exhausted: try to recycle a wrap that is far above the viewport.
929
- // Recycling is disabled when scrolling up and for message ads.
930
- // Recycling is disabled for between/category ads (we never release those wraps).
931
- const allowRecycle = kindClass === 'ezoic-ad-message' && scrollDir > 0;
932
- recycledWrap = allowRecycle ? pickRecyclableWrap(kindClass) : null;
933
- if (recycledWrap) id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
934
- }
603
+ const wrap = buildWrap(id, kindClass, pos);
604
+ withInternal(() => el.insertAdjacentElement('afterend', wrap));
605
+ observePh(id);
606
+ n++;
607
+ }
608
+ return n;
609
+ }
935
610
 
936
- if (!id) break;
611
+ // ─── Pile-up fix for category topic lists ────────────────────────────────────
612
+ // If the skin requires ad divs inside a <ul>, wrap them in <li> hosts and
613
+ // correct their position after NodeBB re-renders.
937
614
 
938
- const wrap = recycledWrap
939
- ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
940
- : insertAfter(el, id, kindClass, afterPos);
941
- if (!wrap) continue;
615
+ let _pileFixSched = false, _pileFixLast = 0;
616
+ function schedulePileFix() {
617
+ if (now() - _pileFixLast < 180 || _pileFixSched) return;
618
+ _pileFixSched = true;
619
+ requestAnimationFrame(() => {
620
+ _pileFixSched = false; _pileFixLast = now();
621
+ try {
622
+ const li = document.querySelector(SEL.topic);
623
+ const ul = li && li.closest('ul,ol');
624
+ if (!ul) return;
625
+
626
+ // Wrap bare ad divs that are direct <ul> children into <li> hosts.
627
+ const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
628
+ ul.querySelectorAll(':scope > ' + wrapSel).forEach(w => {
629
+ if (w.parentElement !== ul) return;
630
+ const host = document.createElement('li');
631
+ host.className = HOST_CLASS;
632
+ host.setAttribute('role', 'listitem');
633
+ host.style.cssText = 'list-style:none;width:100%;';
634
+ ul.insertBefore(host, w);
635
+ host.appendChild(w);
636
+ });
942
637
 
943
- observePlaceholder(id);
944
- inserted++;
945
- }
946
- return inserted;
638
+ // Detect pile-up: 2+ between-ad hosts adjacent with no topic between them.
639
+ let run = 0, maxRun = 0;
640
+ for (const c of ul.children) {
641
+ const isBetween = (c.tagName === 'LI' && c.classList.contains(HOST_CLASS) &&
642
+ c.querySelector(wrapSel)) ||
643
+ (c.matches && c.matches(wrapSel));
644
+ if (isBetween) maxRun = Math.max(maxRun, ++run);
645
+ else if (c.matches && c.matches(SEL.topic)) run = 0;
646
+ else run = 0;
647
+ }
648
+ if (maxRun < 2) return;
649
+
650
+ // Build ordinal → topic LI map.
651
+ const ordMap = new Map();
652
+ Array.from(ul.querySelectorAll(':scope > ' + SEL.topic)).forEach((li, i) => {
653
+ const di = li.dataset && li.dataset.index;
654
+ const ord = (di != null && di !== '' && !isNaN(di)) ? parseInt(di, 10) + 1 : i + 1;
655
+ ordMap.set(ord, li);
656
+ });
657
+
658
+ // Move each host to after its correct anchor topic.
659
+ ul.querySelectorAll(':scope > li.' + HOST_CLASS).forEach(host => {
660
+ const wrap = host.querySelector(wrapSel);
661
+ if (!wrap) return;
662
+ const pos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
663
+ const anchor = pos && ordMap.get(pos);
664
+ if (anchor && host.previousElementSibling !== anchor)
665
+ anchor.insertAdjacentElement('afterend', host);
666
+ });
667
+ } catch (e) {}
668
+ });
947
669
  }
948
670
 
949
- // ─── runCore ────────────────────────────────────────────────────────────────
671
+ // ─── runCore ─────────────────────────────────────────────────────────────────
672
+
950
673
  async function runCore() {
951
674
  if (isBlocked()) return 0;
952
675
  patchShowAds();
953
- const cfg = await fetchConfigOnce();
676
+ const cfg = await fetchConfig();
954
677
  if (!cfg || cfg.excluded) return 0;
955
678
  initPools(cfg);
956
679
 
957
680
  const kind = getKind();
958
681
  let inserted = 0;
959
682
 
960
- // When the user is scrolling UP, NodeBB loads content above the viewport.
961
- // Injecting new ad wraps at that moment targets those freshly-loaded top items
962
- // (low ordinals) and makes ads appear right at the top of the list.
963
- // While scrolling up we only restore previously-hidden wraps (pruneOrphanWraps
964
- // un-hides them as their anchor posts return) — no new injections.
965
- const canInject = scrollDir >= 0;
966
-
967
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
968
- const items = getPostContainers();
969
- pruneOrphanWraps('ezoic-ad-message', items);
970
- if (canInject) {
971
- inserted += injectBetween('ezoic-ad-message', items,
972
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
973
- normalizeBool(cfg.showFirstMessageAd), state.allPosts, 'curPosts');
974
- decluster('ezoic-ad-message');
975
- }
976
-
977
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
978
- const items = getTopicItems();
979
- pruneOrphanWraps('ezoic-ad-between', items);
980
- if (canInject) {
981
- inserted += injectBetween('ezoic-ad-between', items,
982
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
983
- normalizeBool(cfg.showFirstTopicAd), state.allTopics, 'curTopics');
984
- decluster('ezoic-ad-between');
985
- schedulePileFix();
986
- }
987
-
988
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
989
- const items = getCategoryItems();
990
- pruneOrphanWraps('ezoic-ad-categories', items);
991
- if (canInject) {
992
- inserted += injectBetween('ezoic-ad-categories', items,
993
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
994
- normalizeBool(cfg.showFirstCategoryAd), state.allCategories, 'curCategories');
995
- decluster('ezoic-ad-categories');
996
- }
683
+ if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
684
+ const items = getItems('topic');
685
+ const set = new Set(items);
686
+ removeOrphanWraps('ezoic-ad-message', set);
687
+ inserted += injectWraps('ezoic-ad-message', items,
688
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
689
+ normBool(cfg.showFirstMessageAd), 'post', 'post');
690
+
691
+ } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
692
+ const items = getItems('categoryTopics');
693
+ const set = new Set(items);
694
+ removeOrphanWraps('ezoic-ad-between', set);
695
+ inserted += injectWraps('ezoic-ad-between', items,
696
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
697
+ normBool(cfg.showFirstTopicAd), 'topic', 'topic');
698
+ schedulePileFix();
699
+
700
+ } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
701
+ const items = getItems('categories');
702
+ const set = new Set(items);
703
+ removeOrphanWraps('ezoic-ad-categories', set);
704
+ inserted += injectWraps('ezoic-ad-categories', items,
705
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
706
+ normBool(cfg.showFirstCategoryAd), 'category', 'category');
997
707
  }
998
708
 
999
709
  return inserted;
1000
710
  }
1001
711
 
1002
- // ─── Hero ad (first item, early insert) ─────────────────────────────────────
1003
- async function insertHeroAdEarly() {
1004
- if (state.heroDoneForPage || isBlocked()) return;
1005
- const cfg = await fetchConfigOnce();
712
+ // ─── Hero ad (inject immediately after first item) ───────────────────────────
713
+
714
+ async function heroAd() {
715
+ if (st.heroDone || isBlocked()) return;
716
+ const cfg = await fetchConfig();
1006
717
  if (!cfg || cfg.excluded) return;
1007
718
  initPools(cfg);
1008
719
 
1009
720
  const kind = getKind();
1010
- let items = [], allIds = [], cursorKey = '', kindClass = '', showFirst = false;
1011
-
1012
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
1013
- items = getPostContainers(); allIds = state.allPosts; cursorKey = 'curPosts';
1014
- kindClass = 'ezoic-ad-message'; showFirst = normalizeBool(cfg.showFirstMessageAd);
1015
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
1016
- items = getTopicItems(); allIds = state.allTopics; cursorKey = 'curTopics';
1017
- kindClass = 'ezoic-ad-between'; showFirst = normalizeBool(cfg.showFirstTopicAd);
1018
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
1019
- items = getCategoryItems(); allIds = state.allCategories; cursorKey = 'curCategories';
1020
- kindClass = 'ezoic-ad-categories'; showFirst = normalizeBool(cfg.showFirstCategoryAd);
721
+ let items = [], showFirst = false, poolKey = '', cursorKey = '', kindClass = '';
722
+
723
+ if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
724
+ items = getItems('topic'); showFirst = normBool(cfg.showFirstMessageAd);
725
+ poolKey = cursorKey = 'post'; kindClass = 'ezoic-ad-message';
726
+ } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
727
+ items = getItems('categoryTopics'); showFirst = normBool(cfg.showFirstTopicAd);
728
+ poolKey = cursorKey = 'topic'; kindClass = 'ezoic-ad-between';
729
+ } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
730
+ items = getItems('categories'); showFirst = normBool(cfg.showFirstCategoryAd);
731
+ poolKey = cursorKey = 'category'; kindClass = 'ezoic-ad-categories';
1021
732
  } else { return; }
1022
733
 
1023
- if (!items.length || !showFirst) { state.heroDoneForPage = true; return; }
734
+ if (!items.length || !showFirst) { st.heroDone = true; return; }
1024
735
 
1025
736
  const el = items[0];
1026
- if (!el || !el.isConnected || isAdjacentAd(el)) return;
1027
- if (findWrap(kindClass, 1)) { state.heroDoneForPage = true; return; }
737
+ if (!el || !el.isConnected || isAdjacentWrap(el) || findWrapAt(kindClass, 1))
738
+ { st.heroDone = true; return; }
1028
739
 
1029
- const id = pickIdFromAll(allIds, cursorKey);
740
+ const id = pickId(poolKey, cursorKey);
1030
741
  if (!id) return;
1031
742
 
1032
- const wrap = insertAfter(el, id, kindClass, 1);
1033
- if (!wrap) return;
1034
-
1035
- state.heroDoneForPage = true;
1036
- observePlaceholder(id);
743
+ const wrap = buildWrap(id, kindClass, 1);
744
+ withInternal(() => el.insertAdjacentElement('afterend', wrap));
745
+ st.heroDone = true;
746
+ observePh(id);
1037
747
  enqueueShow(id);
1038
- drainQueue(); // FIX: was calling undefined startShowQueue()
748
+ drainQueue();
1039
749
  }
1040
750
 
1041
- // ─── Scheduler ──────────────────────────────────────────────────────────────
1042
- function scheduleRun(delayMs, cb) {
1043
- if (state.runQueued) return;
1044
- state.runQueued = true;
751
+ // ─── Burst scheduler ─────────────────────────────────────────────────────────
752
+
753
+ function scheduleRun(delay, cb) {
754
+ if (st.runQueued) return;
755
+ st.runQueued = true;
1045
756
  const run = async () => {
1046
- state.runQueued = false;
1047
- const pk = getPageKey();
1048
- if (state.pageKey && pk !== state.pageKey) return;
1049
- let inserted = 0;
1050
- try { inserted = await runCore(); } catch (e) {}
1051
- try { cb && cb(inserted); } catch (e) {}
757
+ st.runQueued = false;
758
+ if (getPageKey() !== st.pageKey) return;
759
+ let n = 0;
760
+ try { n = await runCore(); } catch (e) {}
761
+ try { cb && cb(n); } catch (e) {}
1052
762
  };
1053
- const doRun = () => requestAnimationFrame(run);
1054
- if (delayMs > 0) setTimeout(doRun, delayMs);
1055
- else doRun();
763
+ const go = () => requestAnimationFrame(run);
764
+ delay > 0 ? setTimeout(go, delay) : go();
1056
765
  }
1057
766
 
1058
- function requestBurst() {
767
+ function burst() {
1059
768
  if (isBlocked()) return;
1060
769
  const t = now();
1061
- if (t - state.lastBurstReqTs < 120) return;
1062
- state.lastBurstReqTs = t;
1063
- const pk = getPageKey();
1064
- state.pageKey = pk;
1065
- state.burstDeadline = t + 1800;
1066
- if (state.burstActive) return;
1067
- state.burstActive = true;
1068
- state.burstCount = 0;
770
+ if (t - st.lastBurstTs < 120) return;
771
+ st.lastBurstTs = t;
772
+ const pk = st.pageKey = getPageKey();
773
+ st.burstDeadline = t + 1800;
774
+ if (st.burstActive) return;
775
+ st.burstActive = true;
776
+ st.burstCount = 0;
1069
777
  const step = () => {
1070
- if (getPageKey() !== pk || isBlocked() || now() > state.burstDeadline || state.burstCount >= 8) {
1071
- state.burstActive = false; return;
1072
- }
1073
- state.burstCount++;
1074
- scheduleRun(0, (inserted) => {
1075
- if (!inserted && !state.pending.length) { state.burstActive = false; return; }
1076
- setTimeout(step, inserted > 0 ? 120 : 220);
778
+ if (getPageKey() !== pk || isBlocked() ||
779
+ now() > st.burstDeadline || st.burstCount >= 8)
780
+ { st.burstActive = false; return; }
781
+ st.burstCount++;
782
+ scheduleRun(0, n => {
783
+ if (!n && !st.pending.length) { st.burstActive = false; return; }
784
+ setTimeout(step, n > 0 ? 120 : 220);
1077
785
  });
1078
786
  };
1079
787
  step();
1080
788
  }
1081
789
 
1082
- // ─── Lifecycle ──────────────────────────────────────────────────────────────
790
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
791
+
1083
792
  function cleanup() {
1084
793
  blockedUntil = now() + 1200;
1085
- try { document.querySelectorAll('.' + WRAP_CLASS).forEach((el) => releaseWrapNode(el)); } catch (e) {}
1086
- state.cfg = null;
1087
- state.allTopics = []; state.allPosts = []; state.allCategories = [];
1088
- state.curTopics = 0; state.curPosts = 0; state.curCategories = 0;
1089
- state.lastShowById.clear();
1090
- state.inflight = 0; state.pending = []; state.pendingSet.clear();
1091
- state.heroDoneForPage = false;
1092
- // Observers are intentionally kept alive across ajaxify cycles.
1093
- }
1094
-
1095
- function shouldReactToMutations(mutations) {
794
+ try { document.querySelectorAll('.' + WRAP_CLASS).forEach(dropWrap); } catch (e) {}
795
+ st.cfg = null;
796
+ st.pools = { topic: [], post: [], category: [] };
797
+ st.cursors = { topic: 0, post: 0, category: 0 };
798
+ st.lastShow.clear();
799
+ st.inflight = 0;
800
+ st.pending = []; st.pendingSet.clear();
801
+ st.heroDone = false;
802
+ // IO and domObs kept alive intentionally.
803
+ }
804
+
805
+ function shouldReact(mutations) {
1096
806
  for (const m of mutations) {
1097
807
  if (!m.addedNodes || !m.addedNodes.length) continue;
1098
808
  for (const n of m.addedNodes) {
1099
809
  if (!n || n.nodeType !== 1) continue;
1100
- const el = n;
1101
- if (
1102
- (el.matches && (
1103
- el.matches(SELECTORS.postItem) ||
1104
- el.matches(SELECTORS.topicItem) ||
1105
- el.matches(SELECTORS.categoryItem)
1106
- )) ||
1107
- (el.querySelector && (
1108
- el.querySelector(SELECTORS.postItem) ||
1109
- el.querySelector(SELECTORS.topicItem) ||
1110
- el.querySelector(SELECTORS.categoryItem)
1111
- ))
1112
- ) return true;
810
+ if ((n.matches && (n.matches(SEL.post) || n.matches(SEL.topic) || n.matches(SEL.category))) ||
811
+ (n.querySelector && (n.querySelector(SEL.post) || n.querySelector(SEL.topic) || n.querySelector(SEL.category))))
812
+ return true;
1113
813
  }
1114
814
  }
1115
815
  return false;
1116
816
  }
1117
817
 
1118
- function ensureDomObserver() {
1119
- if (state.domObs) return;
1120
- state.domObs = new MutationObserver((mutations) => {
1121
- if (state.internalDomChange > 0 || isBlocked()) return;
1122
- if (!shouldReactToMutations(mutations)) return;
1123
- requestBurst();
818
+ function ensureDomObs() {
819
+ if (st.domObs) return;
820
+ st.domObs = new MutationObserver(muts => {
821
+ if (st.internalMut > 0 || isBlocked()) return;
822
+ if (shouldReact(muts)) burst();
1124
823
  });
1125
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
824
+ try { st.domObs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
1126
825
  }
1127
826
 
1128
- // ─── NodeBB / scroll bindings ────────────────────────────────────────────────
827
+ // ─── NodeBB event bindings ───────────────────────────────────────────────────
828
+
1129
829
  function bindNodeBB() {
1130
830
  if (!$) return;
1131
- $(window).off('.ezoicInfinite');
1132
- $(window).on('action:ajaxify.start.ezoicInfinite', () => { cleanup(); });
1133
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
1134
- state.pageKey = getPageKey();
831
+ $(window).off('.ezoicNbb');
832
+ $(window).on('action:ajaxify.start.ezoicNbb', () => cleanup());
833
+ $(window).on('action:ajaxify.end.ezoicNbb', () => {
834
+ st.pageKey = getPageKey();
1135
835
  blockedUntil = 0;
1136
- muteNoisyConsole(); ensureTcfApiLocator(); warmUpNetwork(); patchShowAds();
1137
- globalGapFixInit(); ensurePreloadObserver(); ensureDomObserver(); initPileFixObserver();
1138
- insertHeroAdEarly().catch(() => {});
1139
- requestBurst();
836
+ muteConsole(); warmNetwork(); patchShowAds();
837
+ globalGapFix(); ensureIO(); ensureDomObs();
838
+ heroAd().catch(() => {});
839
+ burst();
1140
840
  setTimeout(schedulePileFix, 80);
1141
841
  setTimeout(schedulePileFix, 500);
1142
842
  });
1143
- $(window).on('action:ajaxify.contentLoaded.ezoicInfinite', () => {
1144
- if (!isBlocked()) requestBurst();
1145
- });
843
+ $(window).on('action:ajaxify.contentLoaded.ezoicNbb', () => { if (!isBlocked()) burst(); });
1146
844
  $(window).on([
1147
845
  'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
1148
846
  'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
1149
- ].map((e) => e + '.ezoicInfinite').join(' '), () => {
1150
- if (!isBlocked()) {
1151
- requestBurst();
1152
- setTimeout(schedulePileFix, 80);
1153
- setTimeout(schedulePileFix, 500);
1154
- }
847
+ ].map(e => e + '.ezoicNbb').join(' '), () => {
848
+ if (!isBlocked()) { burst(); setTimeout(schedulePileFix, 80); setTimeout(schedulePileFix, 500); }
1155
849
  });
1156
850
  try {
1157
- require(['hooks'], (hooks) => {
1158
- if (!hooks || typeof hooks.on !== 'function') return;
1159
- ['action:ajaxify.end','action:ajaxify.contentLoaded','action:posts.loaded',
1160
- 'action:topics.loaded','action:categories.loaded','action:category.loaded',
1161
- 'action:topic.loaded','action:infiniteScroll.loaded'].forEach((ev) => {
1162
- try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (e) {}
1163
- });
851
+ require(['hooks'], hooks => {
852
+ if (typeof hooks.on !== 'function') return;
853
+ for (const ev of [
854
+ 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
855
+ 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
856
+ 'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
857
+ ]) try { hooks.on(ev, () => { if (!isBlocked()) burst(); }); } catch (e) {}
1164
858
  });
1165
859
  } catch (e) {}
1166
860
  }
@@ -1168,33 +862,35 @@
1168
862
  function bindScroll() {
1169
863
  let ticking = false;
1170
864
  window.addEventListener('scroll', () => {
865
+ // Fast-scroll boost: widen IO margins.
1171
866
  try {
1172
- const t = now(), y = window.scrollY || window.pageYOffset || 0;
1173
- if (state.lastScrollTs) {
1174
- const dt = t - state.lastScrollTs;
1175
- const dy = Math.abs(y - (state.lastScrollY || 0));
1176
- if (dt > 0 && dy / dt >= BOOST_SPEED_PX_PER_MS) {
1177
- const wasBoosted = isBoosted();
1178
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
1179
- if (!wasBoosted) ensurePreloadObserver();
867
+ const t = now(), y = window.scrollY || 0;
868
+ if (st.lastScrollTs) {
869
+ const speed = Math.abs(y - st.lastScrollY) / (t - st.lastScrollTs);
870
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
871
+ const was = isBoosted();
872
+ st.boostUntil = Math.max(st.boostUntil, t + BOOST_DURATION_MS);
873
+ if (!was) ensureIO();
1180
874
  }
1181
875
  }
1182
- state.lastScrollY = y; state.lastScrollTs = t;
876
+ st.lastScrollY = y; st.lastScrollTs = t;
1183
877
  } catch (e) {}
878
+
1184
879
  if (ticking) return;
1185
880
  ticking = true;
1186
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
881
+ requestAnimationFrame(() => { ticking = false; burst(); });
1187
882
  }, { passive: true });
1188
883
  }
1189
884
 
1190
885
  // ─── Boot ────────────────────────────────────────────────────────────────────
1191
- state.pageKey = getPageKey();
1192
- muteNoisyConsole(); ensureTcfApiLocator(); warmUpNetwork(); patchShowAds();
1193
- ensurePreloadObserver(); ensureDomObserver(); initPileFixObserver();
886
+
887
+ st.pageKey = getPageKey();
888
+ muteConsole(); warmNetwork(); patchShowAds();
889
+ ensureIO(); ensureDomObs();
1194
890
  bindNodeBB(); bindScroll();
1195
891
  blockedUntil = 0;
1196
- insertHeroAdEarly().catch(() => {});
1197
- requestBurst();
892
+ heroAd().catch(() => {});
893
+ burst();
1198
894
  schedulePileFix();
1199
895
 
1200
896
  })();