nodebb-plugin-ezoic-infinite 1.5.22 → 1.5.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/library.js +5 -23
  2. package/package.json +1 -1
  3. package/public/client.js +399 -645
package/library.js CHANGED
@@ -9,11 +9,7 @@ const plugin = {};
9
9
 
10
10
  function normalizeExcludedGroups(value) {
11
11
  if (!value) return [];
12
- // NodeBB settings may return arrays, strings, or objects like {0:'a',1:'b'}
13
- if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean);
14
- if (typeof value === 'object') {
15
- return Object.values(value).map(String).map(s => s.trim()).filter(Boolean);
16
- }
12
+ if (Array.isArray(value)) return value;
17
13
  return String(value).split(',').map(s => s.trim()).filter(Boolean);
18
14
  }
19
15
 
@@ -70,19 +66,9 @@ async function getSettings() {
70
66
  }
71
67
 
72
68
  async function isUserExcluded(uid, excludedGroups) {
73
- const list = (excludedGroups || []).map(g => String(g).toLowerCase());
74
- const id = Number(uid) || 0;
75
-
76
- if (!list.length) return false;
77
-
78
- // Guests (uid=0) are not in groups.getUserGroups; treat explicitly if configured
79
- if (id === 0) {
80
- return list.includes('guests') || list.includes('guest');
81
- }
82
-
83
- const userGroups = await groups.getUserGroups([id]);
84
- const names = (userGroups[0] || []).map(g => (g && g.name) ? g.name : String(g));
85
- return names.some(name => list.includes(String(name).toLowerCase()));
69
+ if (!uid || !excludedGroups.length) return false;
70
+ const userGroups = await groups.getUserGroups([uid]);
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
86
72
  }
87
73
 
88
74
  plugin.onSettingsSet = function (data) {
@@ -121,11 +107,7 @@ plugin.init = async ({ router, middleware }) => {
121
107
 
122
108
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
123
109
  const settings = await getSettings();
124
- const uid = (typeof req.uid !== 'undefined' && req.uid !== null) ? req.uid
125
- : (req.user && req.user.uid) ? req.user.uid
126
- : (res.locals && res.locals.uid) ? res.locals.uid
127
- : 0;
128
- const excluded = await isUserExcluded(uid, settings.excludedGroups);
110
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
129
111
 
130
112
  res.json({
131
113
  excluded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.22",
3
+ "version": "1.5.24",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,93 +1,64 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- var $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
4
+ // NodeBB client context
5
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
6
 
6
- var SELECTORS = {
7
- topicItem: 'li[component="category/topic"]',
8
- postItem: '[component="post"][data-pid]',
9
- categoryItem: 'li[component="categories/category"]'
10
- };
7
+ const WRAP_CLASS = 'ezoic-ad';
8
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
11
9
 
12
- var WRAP_CLASS = 'ezoic-ad';
13
- var PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
10
+ // Insert at most N ads per run to keep the UI smooth on infinite scroll
11
+ const MAX_INSERTS_PER_RUN = 3;
14
12
 
15
- // Hard limits to avoid runaway insertion during mutations / infinite scroll.
16
- var MAX_INSERTS_PER_RUN = 3;
13
+ // Preload before viewport (tune if you want even earlier)
14
+ const PRELOAD_ROOT_MARGIN = '1200px 0px';
17
15
 
18
- // Placeholders that have been "defined" (filled) at least once in this browser session.
19
- // This survives ajaxify navigations, which is important for safe recycle/destroy logic.
20
- var sessionDefinedIds = new Set();
16
+ const SELECTORS = {
17
+ topicItem: 'li[component="category/topic"]',
18
+ postItem: '[component="post"][data-pid]',
19
+ categoryItem: 'li[component="categories/category"]',
20
+ };
21
21
 
22
- // Prevent re-entrant insertion of the same id while Ezoic is processing it.
23
- var insertingIds = new Set();
22
+ // Hard block during navigation to avoid “placeholder does not exist” spam
23
+ let EZOIC_BLOCKED = false;
24
24
 
25
- var state = {
25
+ const state = {
26
26
  pageKey: null,
27
-
28
27
  cfg: null,
29
- cfgPromise: null,
30
28
 
31
- poolTopics: [],
32
- poolPosts: [],
33
- poolCategories: [],
29
+ // Full lists (never consumed) + cursors for round-robin reuse
30
+ allTopics: [],
31
+ allPosts: [],
32
+ allCategories: [],
33
+ curTopics: 0,
34
+ curPosts: 0,
35
+ curCategories: 0,
34
36
 
35
- usedTopics: new Set(),
36
- usedPosts: new Set(),
37
- usedCategories: new Set(),
38
-
39
- // Track wrappers that are still in the DOM to recycle ids once they are far above viewport.
40
- liveTopics: [],
41
- livePosts: [],
42
- liveCategories: [],
43
-
44
- // Throttle showAds calls per id.
37
+ // throttle per placeholder id
45
38
  lastShowById: new Map(),
46
39
 
47
- // Ids for which we scheduled/attempted showAds and should not schedule again immediately.
48
- pendingById: new Set(),
49
-
50
- // Timeouts created by this script (so we can cancel on ajaxify.start).
51
- activeTimeouts: new Set(),
52
-
53
- // Run scheduling / mutation observer.
54
- scheduled: false,
55
- timer: null,
56
- obs: null,
40
+ // observers / schedulers
41
+ domObs: null,
42
+ io: null,
43
+ runQueued: false,
57
44
 
58
- // Scroll throttling.
59
- lastScrollRun: 0,
45
+ // hero
46
+ heroDoneForPage: false,
47
+ };
60
48
 
61
- // Navigation safety gate: we only insert after ajaxify.end settles.
62
- canShowAds: false,
49
+ const insertingIds = new Set();
63
50
 
64
- // Retry counters.
65
- poolWaitAttempts: 0,
66
- awaitItemsAttempts: 0
67
- };
51
+ // ---------- small utils ----------
68
52
 
69
53
  function normalizeBool(v) {
70
54
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
71
55
  }
72
56
 
73
- function setTimeoutTracked(fn, ms) {
74
- var id = setTimeout(fn, ms);
75
- state.activeTimeouts.add(id);
76
- return id;
77
- }
78
-
79
- function clearAllTrackedTimeouts() {
80
- state.activeTimeouts.forEach(function (id) {
81
- try { clearTimeout(id); } catch (e) {}
82
- });
83
- state.activeTimeouts.clear();
84
- }
85
-
86
57
  function uniqInts(lines) {
87
- var out = [];
88
- var seen = new Set();
89
- for (var i = 0; i < lines.length; i++) {
90
- var n = parseInt(lines[i], 10);
58
+ const out = [];
59
+ const seen = new Set();
60
+ for (const v of lines) {
61
+ const n = parseInt(v, 10);
91
62
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
92
63
  seen.add(n);
93
64
  out.push(n);
@@ -98,31 +69,31 @@
98
69
 
99
70
  function parsePool(raw) {
100
71
  if (!raw) return [];
101
- var lines = String(raw)
72
+ const lines = String(raw)
102
73
  .split(/\r?\n/)
103
- .map(function (s) { return s.trim(); })
74
+ .map(s => s.trim())
104
75
  .filter(Boolean);
105
76
  return uniqInts(lines);
106
77
  }
107
78
 
108
79
  function getPageKey() {
109
80
  try {
110
- var ax = window.ajaxify;
81
+ const ax = window.ajaxify;
111
82
  if (ax && ax.data) {
112
- if (ax.data.tid) return 'topic:' + ax.data.tid;
113
- if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
83
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
84
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
114
85
  }
115
86
  } catch (e) {}
116
87
  return window.location.pathname;
117
88
  }
118
89
 
119
90
  function getKind() {
120
- var p = window.location.pathname || '';
91
+ const p = window.location.pathname || '';
121
92
  if (/^\/topic\//.test(p)) return 'topic';
122
93
  if (/^\/category\//.test(p)) return 'categoryTopics';
123
94
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
124
95
 
125
- // Fallback by DOM.
96
+ // fallback by DOM
126
97
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
127
98
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
128
99
  if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
@@ -138,710 +109,493 @@
138
109
  }
139
110
 
140
111
  function getPostContainers() {
141
- var nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
142
- return nodes.filter(function (el) {
112
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
113
+ return nodes.filter((el) => {
143
114
  if (!el || !el.isConnected) return false;
144
115
  if (!el.querySelector('[component="post/content"]')) return false;
145
-
146
- // Prevent nested / duplicated post wrappers.
147
- var parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
116
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
148
117
  if (parentPost && parentPost !== el) return false;
149
118
  if (el.getAttribute('component') === 'post/parent') return false;
150
-
151
119
  return true;
152
120
  });
153
121
  }
154
122
 
155
- function safeRect(el) {
156
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
157
- }
123
+ // ---------- warm-up & patching ----------
158
124
 
159
- function destroyPlaceholderIds(ids) {
160
- if (!ids || !ids.length) return;
161
-
162
- // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
163
- var filtered = ids.filter(function (id) {
164
- try { return sessionDefinedIds.has(id); } catch (e) { return true; }
165
- });
166
-
167
- if (!filtered.length) return;
125
+ const _warmLinksDone = new Set();
126
+ function warmUpNetwork() {
127
+ try {
128
+ const head = document.head || document.getElementsByTagName('head')[0];
129
+ if (!head) return;
130
+ const links = [
131
+ ['preconnect', 'https://g.ezoic.net', true],
132
+ ['dns-prefetch', 'https://g.ezoic.net', false],
133
+ ['preconnect', 'https://go.ezoic.net', true],
134
+ ['dns-prefetch', 'https://go.ezoic.net', false],
135
+ ];
136
+ for (const [rel, href, cors] of links) {
137
+ const key = `${rel}|${href}`;
138
+ if (_warmLinksDone.has(key)) continue;
139
+ _warmLinksDone.add(key);
140
+ const link = document.createElement('link');
141
+ link.rel = rel;
142
+ link.href = href;
143
+ if (cors) link.crossOrigin = 'anonymous';
144
+ head.appendChild(link);
145
+ }
146
+ } catch (e) {}
147
+ }
168
148
 
169
- var call = function () {
149
+ // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
150
+ function patchShowAds() {
151
+ const applyPatch = () => {
170
152
  try {
171
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
172
- window.ezstandalone.destroyPlaceholders(filtered);
173
- }
153
+ window.ezstandalone = window.ezstandalone || {};
154
+ const ez = window.ezstandalone;
155
+ if (window.__nodebbEzoicPatched) return;
156
+ if (typeof ez.showAds !== 'function') return;
157
+
158
+ window.__nodebbEzoicPatched = true;
159
+ const orig = ez.showAds;
160
+
161
+ ez.showAds = function (...args) {
162
+ if (EZOIC_BLOCKED) return;
163
+
164
+ let ids = [];
165
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
166
+ else ids = args;
167
+
168
+ const seen = new Set();
169
+ for (const v of ids) {
170
+ const id = parseInt(v, 10);
171
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
172
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
173
+ if (!ph || !ph.isConnected) continue;
174
+ seen.add(id);
175
+ try { orig.call(ez, id); } catch (e) {}
176
+ }
177
+ };
174
178
  } catch (e) {}
175
179
  };
176
180
 
177
- try {
178
- // Do NOT initialize ezstandalone here; if you load Ezoic elsewhere, it will manage its own queue.
179
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
180
- call();
181
- } else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) {
182
- window.ezstandalone.cmd.push(call);
183
- }
184
- } catch (e) {}}
185
-
186
- function getRecyclable(liveArr) {
187
- var margin = 600; // px above viewport
188
- for (var i = 0; i < liveArr.length; i++) {
189
- var entry = liveArr[i];
190
- if (!entry || !entry.wrap || !entry.wrap.isConnected) {
191
- liveArr.splice(i, 1);
192
- i--;
193
- continue;
194
- }
195
- var r = safeRect(entry.wrap);
196
- if (r && r.bottom < -margin) {
197
- liveArr.splice(i, 1);
198
- return entry;
199
- }
181
+ applyPatch();
182
+ if (!window.__nodebbEzoicPatched) {
183
+ try {
184
+ window.ezstandalone = window.ezstandalone || {};
185
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
186
+ window.ezstandalone.cmd.push(applyPatch);
187
+ } catch (e) {}
200
188
  }
201
- return null;
202
189
  }
203
190
 
204
- function pickId(pool, liveArr) {
205
- if (pool.length) return { id: pool.shift(), recycled: null };
191
+ // ---------- config & pools ----------
206
192
 
207
- var recycled = getRecyclable(liveArr);
208
- if (recycled) return { id: recycled.id, recycled: recycled };
193
+ async function fetchConfigOnce() {
194
+ if (state.cfg) return state.cfg;
195
+ try {
196
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
197
+ if (!res.ok) return null;
198
+ state.cfg = await res.json();
199
+ return state.cfg;
200
+ } catch (e) {
201
+ return null;
202
+ }
203
+ }
209
204
 
210
- return { id: null, recycled: null };
205
+ function initPools(cfg) {
206
+ if (!cfg) return;
207
+ if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
208
+ if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
209
+ if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
211
210
  }
212
211
 
213
- function resetPlaceholderInWrap(wrap, id) {
214
- if (!wrap) return null;
215
- try { wrap.innerHTML = ''; } catch (e) {}
212
+ // ---------- insertion primitives ----------
216
213
 
217
- var ph = document.createElement('div');
218
- ph.id = PLACEHOLDER_PREFIX + id;
219
- wrap.appendChild(ph);
220
- return ph;
214
+ function isAdjacentAd(target) {
215
+ if (!target) return false;
216
+ const next = target.nextElementSibling;
217
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
218
+ const prev = target.previousElementSibling;
219
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
220
+ return false;
221
221
  }
222
222
 
223
- function isAdjacentAd(el) {
224
- var next = el && el.nextElementSibling;
225
- return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
226
- }
223
+ function buildWrap(id, kindClass, afterPos) {
224
+ const wrap = document.createElement('div');
225
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
226
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
227
+ wrap.style.width = '100%';
227
228
 
228
- function isPrevAd(el) {
229
- var prev = el && el.previousElementSibling;
230
- return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
231
- }
229
+ const ph = document.createElement('div');
230
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
231
+ ph.setAttribute('data-ezoic-id', String(id));
232
+ wrap.appendChild(ph);
232
233
 
233
- function buildWrap(id, kindClass) {
234
- var wrap = document.createElement('div');
235
- wrap.className = WRAP_CLASS + ' ' + kindClass;
236
- wrap.setAttribute('data-ezoic-id', String(id));
237
- resetPlaceholderInWrap(wrap, id);
238
234
  return wrap;
239
235
  }
240
236
 
241
237
  function findWrap(kindClass, afterPos) {
242
- // Search a wrapper marker that we set on insertion.
243
- return document.querySelector('.' + kindClass + '[data-after-pos="' + afterPos + '"]');
238
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
244
239
  }
245
240
 
246
- function insertAfter(el, id, kindClass, afterPos) {
247
- try {
248
- var wrap = buildWrap(id, kindClass);
249
- wrap.setAttribute('data-after-pos', String(afterPos));
241
+ function insertAfter(target, id, kindClass, afterPos) {
242
+ if (!target || !target.insertAdjacentElement) return null;
243
+ if (findWrap(kindClass, afterPos)) return null;
244
+ if (insertingIds.has(id)) return null;
250
245
 
251
- if (!el || !el.parentNode) return null;
252
- if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
253
- else el.parentNode.appendChild(wrap);
246
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
247
+ if (existingPh && existingPh.isConnected) return null;
254
248
 
255
- attachFillObserver(wrap, id);
249
+ insertingIds.add(id);
250
+ try {
251
+ const wrap = buildWrap(id, kindClass, afterPos);
252
+ target.insertAdjacentElement('afterend', wrap);
256
253
  return wrap;
257
- } catch (e) {}
258
- return null;
259
- }
260
-
261
- function destroyUsedPlaceholders() {
262
- var ids = [];
263
- state.usedTopics.forEach(function (id) { ids.push(id); });
264
- state.usedPosts.forEach(function (id) { ids.push(id); });
265
- state.usedCategories.forEach(function (id) { ids.push(id); });
266
-
267
- // Only destroy placeholders that were filled at least once in this session.
268
- destroyPlaceholderIds(ids);
269
- }
270
-
271
- function patchShowAds() {
272
- // Intentionally left blank: ezstandalone is managed elsewhere.
254
+ } finally {
255
+ insertingIds.delete(id);
256
+ }
273
257
  }
274
258
 
275
- function markFilled(id) {
276
- try { sessionDefinedIds.add(id); } catch (e) {}
277
- }
259
+ function pickIdFromAll(allIds, cursorKey) {
260
+ const n = allIds.length;
261
+ if (!n) return null;
278
262
 
279
- function isWrapMarkedFilled(wrap) {
280
- try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
281
- }
263
+ // Try at most n ids to find one that's not already in the DOM
264
+ for (let tries = 0; tries < n; tries++) {
265
+ const idx = state[cursorKey] % n;
266
+ state[cursorKey] = (state[cursorKey] + 1) % n;
282
267
 
283
- function attachFillObserver(wrap, id) {
284
- if (!wrap || !wrap.isConnected) return;
268
+ const id = allIds[idx];
269
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
270
+ if (ph && ph.isConnected) continue;
285
271
 
286
- // If already filled, mark and return.
287
- if (isPlaceholderFilled(wrap)) {
288
- try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
289
- markFilled(id);
290
- return;
272
+ return id;
291
273
  }
292
-
293
- // Observe for Ezoic inserting ad content into placeholder.
294
- try {
295
- var obs = new MutationObserver(function () {
296
- if (!wrap.isConnected) {
297
- try { obs.disconnect(); } catch (e) {}
298
- return;
299
- }
300
- if (isPlaceholderFilled(wrap)) {
301
- try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
302
- markFilled(id);
303
- try { obs.disconnect(); } catch (e) {}
304
- }
305
- });
306
- obs.observe(wrap, { childList: true, subtree: true });
307
- wrap.__ezoicFillObs = obs;
308
- } catch (e) {}
274
+ return null;
309
275
  }
310
276
 
311
- function isPlaceholderFilled(wrap) {
312
- // Heuristic: placeholder exists AND has descendants or meaningful height.
313
- try {
314
- var ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
315
- if (!ph) return false;
316
- if (ph.children && ph.children.length) return true;
317
- var r = safeRect(wrap);
318
- if (r && r.height > 20) return true;
319
- } catch (e) {}
320
- return false;
321
- }
277
+ function showAd(id) {
278
+ if (!id || EZOIC_BLOCKED) return;
322
279
 
323
- function scheduleShowAdsBatch(ids) {
324
- if (!ids || !ids.length) return;
280
+ const now = Date.now();
281
+ const last = state.lastShowById.get(id) || 0;
282
+ if (now - last < 1500) return; // basic throttle
325
283
 
326
- // Ezoic expects DOM to be settled.
327
- var call = function () {
328
- try {
329
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
330
- window.ezstandalone.showAds(ids);
331
- }
332
- } catch (e) {}
333
- };
284
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
285
+ if (!ph || !ph.isConnected) return;
334
286
 
335
- try {
336
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') call();
337
- else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) window.ezstandalone.cmd.push(call);
338
- } catch (e) {}
339
- }
340
-
341
- function callShowAdsWhenReady(id) {
342
- if (!id) return;
343
-
344
- // Throttle per-id.
345
- var now = Date.now();
346
- var last = state.lastShowById.get(id) || 0;
347
- if (now - last < 1200) return;
348
-
349
- if (state.pendingById.has(id)) return;
350
- state.pendingById.add(id);
351
287
  state.lastShowById.set(id, now);
352
288
 
353
- // Guard against re-entrancy.
354
- if (insertingIds.has(id)) {
355
- state.pendingById.delete(id);
356
- return;
357
- }
358
- insertingIds.add(id);
359
-
360
- var attempts = 0;
361
-
362
- (function waitForPh() {
363
- attempts++;
289
+ try {
290
+ window.ezstandalone = window.ezstandalone || {};
291
+ const ez = window.ezstandalone;
364
292
 
365
- // Navigation safety: if we navigated away, stop.
366
- if (!state.canShowAds) {
367
- state.pendingById.delete(id);
368
- insertingIds.delete(id);
293
+ // Fast path
294
+ if (typeof ez.showAds === 'function') {
295
+ ez.showAds(id);
369
296
  return;
370
297
  }
371
298
 
372
- var ph = document.getElementById(PLACEHOLDER_PREFIX + id);
373
-
374
- var doCall = function () {
375
- try {
376
- // If placeholder is gone, stop.
377
- if (!ph || !ph.isConnected) return false;
378
- scheduleShowAdsBatch([id]);
379
- return true;
380
- } catch (e) {}
381
- return false;
382
- };
383
-
384
- if (ph && ph.isConnected) {
385
- doCall();
386
- state.pendingById.delete(id);
387
- insertingIds.delete(id);
388
- return;
299
+ // Queue once for when Ezoic is ready
300
+ ez.cmd = ez.cmd || [];
301
+ if (!ph.__ezoicQueued) {
302
+ ph.__ezoicQueued = true;
303
+ ez.cmd.push(() => {
304
+ try {
305
+ if (EZOIC_BLOCKED) return;
306
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
307
+ if (!el || !el.isConnected) return;
308
+ window.ezstandalone.showAds(id);
309
+ } catch (e) {}
310
+ });
389
311
  }
312
+ } catch (e) {}
313
+ }
390
314
 
391
- if (attempts < 100) {
392
- setTimeoutTracked(waitForPh, 50);
393
- return;
394
- }
315
+ // ---------- preload / above-the-fold ----------
395
316
 
396
- // Timeout: give up silently.
397
- state.pendingById.delete(id);
398
- insertingIds.delete(id);
399
- })();
317
+ function ensurePreloadObserver() {
318
+ if (state.io) return state.io;
319
+ try {
320
+ state.io = new IntersectionObserver((entries) => {
321
+ for (const ent of entries) {
322
+ if (!ent.isIntersecting) continue;
323
+ const el = ent.target;
324
+ try { state.io && state.io.unobserve(el); } catch (e) {}
325
+
326
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
327
+ const id = parseInt(idAttr, 10);
328
+ if (Number.isFinite(id) && id > 0) showAd(id);
329
+ }
330
+ }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
331
+ } catch (e) {
332
+ state.io = null;
333
+ }
334
+ return state.io;
400
335
  }
401
336
 
402
- function initPools(cfg) {
403
- if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.placeholderIds);
404
- if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
405
- if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
337
+ function observePlaceholder(id) {
338
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
339
+ if (!ph || !ph.isConnected) return;
340
+ const io = ensurePreloadObserver();
341
+ try { io && io.observe(ph); } catch (e) {}
342
+
343
+ // If already above fold, fire immediately
344
+ try {
345
+ const r = ph.getBoundingClientRect();
346
+ if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
347
+ } catch (e) {}
406
348
  }
407
349
 
350
+ // ---------- insertion logic ----------
351
+
408
352
  function computeTargets(count, interval, showFirst) {
409
- var out = [];
353
+ const out = [];
410
354
  if (count <= 0) return out;
411
-
412
355
  if (showFirst) out.push(1);
413
-
414
- for (var i = 1; i <= count; i++) {
356
+ for (let i = 1; i <= count; i++) {
415
357
  if (i % interval === 0) out.push(i);
416
358
  }
417
-
418
- // Unique + sorted.
419
- return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
359
+ return Array.from(new Set(out)).sort((a, b) => a - b);
420
360
  }
421
361
 
422
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
423
- if (!items || !items.length) return 0;
362
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
363
+ if (!items.length) return 0;
424
364
 
425
- var targets = computeTargets(items.length, interval, showFirst);
426
- var inserted = 0;
365
+ const targets = computeTargets(items.length, interval, showFirst);
366
+ let inserted = 0;
427
367
 
428
- for (var t = 0; t < targets.length; t++) {
429
- var afterPos = targets[t];
368
+ for (const afterPos of targets) {
430
369
  if (inserted >= MAX_INSERTS_PER_RUN) break;
431
370
 
432
- var el = items[afterPos - 1];
371
+ const el = items[afterPos - 1];
433
372
  if (!el || !el.isConnected) continue;
434
-
435
- // Prevent adjacent ads.
436
- if (isAdjacentAd(el) || isPrevAd(el)) continue;
437
-
438
- // Prevent duplicates at same logical position.
439
- if (findWrap(kindClass, afterPos - 1)) continue;
373
+ if (isAdjacentAd(el)) continue;
440
374
  if (findWrap(kindClass, afterPos)) continue;
441
375
 
442
- var pick = pickId(kindPool, liveArr);
443
- var id = pick.id;
376
+ const id = pickIdFromAll(allIds, cursorKey);
444
377
  if (!id) break;
445
-
446
- var wrap = null;
447
-
448
- if (pick.recycled && pick.recycled.wrap) {
449
- // Recycle: only destroy if Ezoic has actually defined this placeholder before.
450
- if (sessionDefinedIds.has(id)) destroyPlaceholderIds([id]);
451
-
452
- // Remove old wrapper.
453
- var oldWrap = pick.recycled.wrap;
454
- try { if (oldWrap && oldWrap.__ezoicFillObs) oldWrap.__ezoicFillObs.disconnect(); } catch (e) {}
455
- try { if (oldWrap) oldWrap.remove(); } catch (e) {}
456
-
457
- wrap = insertAfter(el, id, kindClass, afterPos);
458
- if (!wrap) continue;
459
-
460
- // Give Ezoic a moment after DOM insertion.
461
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 700);
462
- } else {
463
- usedSet.add(id);
464
- wrap = insertAfter(el, id, kindClass, afterPos);
465
- if (!wrap) continue;
466
-
467
- // Micro-delay to allow layout/DOM settle.
468
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
469
- }
470
-
471
- liveArr.push({ id: id, wrap: wrap });
472
-
473
- // Final safety: if adjacency happened due to DOM shifts, rollback.
474
- var prev = wrap && wrap.previousElementSibling;
475
- var next = wrap && wrap.nextElementSibling;
476
- if (wrap && ((prev && prev.classList && prev.classList.contains(WRAP_CLASS)) || (next && next.classList && next.classList.contains(WRAP_CLASS)))) {
477
- try { wrap.remove(); } catch (e) {}
478
-
479
- if (!(pick.recycled && pick.recycled.wrap)) {
480
- try { kindPool.unshift(id); } catch (e) {}
481
- usedSet.delete(id);
482
- }
378
+ const wrap = insertAfter(el, id, kindClass, afterPos);
379
+ if (!wrap) {
483
380
  continue;
484
381
  }
485
382
 
486
- inserted++;
383
+ observePlaceholder(id);
384
+ inserted += 1;
487
385
  }
488
386
 
489
387
  return inserted;
490
388
  }
491
389
 
492
- function enforceNoAdjacentAds() {
493
- var ads = Array.from(document.querySelectorAll('.' + WRAP_CLASS));
494
- for (var i = 0; i < ads.length; i++) {
495
- var ad = ads[i];
496
- var prev = ad.previousElementSibling;
497
-
498
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
499
- // Remove adjacent wrapper (do not hide).
500
- try {
501
- var ph = ad.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
502
- if (ph) {
503
- var id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
504
- if (Number.isFinite(id) && id > 0 && sessionDefinedIds.has(id)) {
505
- destroyPlaceholderIds([id]);
506
- }
507
- }
508
- ad.remove();
509
- } catch (e) {}
510
- }
511
- }
512
- }
513
-
514
- function cleanup() {
515
- // Stop any insertion during navigation / DOM teardown.
516
- state.canShowAds = false;
517
- state.poolWaitAttempts = 0;
518
- state.awaitItemsAttempts = 0;
519
-
520
- // Cancel any pending showAds timeouts.
521
- clearAllTrackedTimeouts();
522
-
523
- // Disconnect global observer to avoid mutations during teardown.
524
- if (state.obs) {
525
- try { state.obs.disconnect(); } catch (e) {}
526
- state.obs = null;
390
+ async function insertHeroAdEarly() {
391
+ if (state.heroDoneForPage) return;
392
+ const cfg = await fetchConfigOnce();
393
+ if (!cfg || cfg.excluded) return;
394
+
395
+ initPools(cfg);
396
+
397
+ const kind = getKind();
398
+ let items = [];
399
+ let allIds = [];
400
+ let cursorKey = '';
401
+ let kindClass = '';
402
+ let showFirst = false;
403
+
404
+ if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
405
+ items = getPostContainers();
406
+ allIds = state.allPosts;
407
+ cursorKey = 'curPosts';
408
+ kindClass = 'ezoic-ad-message';
409
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
410
+ } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
411
+ items = getTopicItems();
412
+ allIds = state.allTopics;
413
+ cursorKey = 'curTopics';
414
+ kindClass = 'ezoic-ad-between';
415
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
416
+ } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
417
+ items = getCategoryItems();
418
+ allIds = state.allCategories;
419
+ cursorKey = 'curCategories';
420
+ kindClass = 'ezoic-ad-categories';
421
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
422
+ } else {
423
+ return;
527
424
  }
528
425
 
529
- // Destroy placeholders that were used (only those that were actually defined).
530
- destroyUsedPlaceholders();
426
+ if (!items.length) return;
427
+ if (!showFirst) { state.heroDoneForPage = true; return; }
531
428
 
532
- // Remove wrappers from DOM (safe because insertion is now blocked).
533
- try {
534
- document.querySelectorAll('.' + WRAP_CLASS).forEach(function (el) {
535
- try { if (el && el.__ezoicFillObs) el.__ezoicFillObs.disconnect(); } catch (e) {}
536
- try { el.remove(); } catch (e) {}
537
- });
538
- } catch (e) {}
539
-
540
- // Reset runtime caches.
541
- state.pageKey = getPageKey();
542
- state.cfg = null;
543
- state.cfgPromise = null;
429
+ // Insert after the very first item (above-the-fold)
430
+ const afterPos = 1;
431
+ const el = items[afterPos - 1];
432
+ if (!el || !el.isConnected) return;
433
+ if (isAdjacentAd(el)) return;
434
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
544
435
 
545
- state.poolTopics = [];
546
- state.poolPosts = [];
547
- state.poolCategories = [];
548
-
549
- state.usedTopics.clear();
550
- state.usedPosts.clear();
551
- state.usedCategories.clear();
552
-
553
- state.liveTopics = [];
554
- state.livePosts = [];
555
- state.liveCategories = [];
556
-
557
- state.lastShowById.clear();
558
- state.pendingById.clear();
559
- insertingIds.clear();
560
-
561
- state.scheduled = false;
562
- if (state.timer) {
563
- try { clearTimeout(state.timer); } catch (e) {}
564
- state.timer = null;
565
- }
566
- }
567
-
568
- function ensureObserver() {
569
- if (state.obs) return;
570
- try {
571
- state.obs = new MutationObserver(function () { scheduleRun('mutation'); });
572
- state.obs.observe(document.body, { childList: true, subtree: true });
573
- } catch (e) {}
574
- }
575
-
576
- function scheduleRun(/* reason */) {
577
- if (state.scheduled) return;
578
- state.scheduled = true;
436
+ const id = pickIdFromAll(allIds, cursorKey);
437
+ if (!id) return;
579
438
 
580
- if (state.timer) {
581
- try { clearTimeout(state.timer); } catch (e) {}
582
- state.timer = null;
439
+ const wrap = insertAfter(el, id, kindClass, afterPos);
440
+ if (!wrap) {
441
+ return;
583
442
  }
584
443
 
585
- state.timer = setTimeoutTracked(function () {
586
- state.scheduled = false;
587
-
588
- // If user navigated away, stop.
589
- var pk = getPageKey();
590
- if (state.pageKey && pk !== state.pageKey) return;
591
-
592
- runCore().catch(function () {});
593
- }, 80);
444
+ state.heroDoneForPage = true;
445
+ observePlaceholder(id);
594
446
  }
595
447
 
596
- function waitForItemsThenRun(kind) {
597
- // If list isn't in DOM yet (ajaxify transition), retry a bit.
598
- var count = 0;
599
- if (kind === 'topic') count = getPostContainers().length;
600
- else if (kind === 'categoryTopics') count = getTopicItems().length;
601
- else if (kind === 'categories') count = getCategoryItems().length;
448
+ async function runCore() {
449
+ if (EZOIC_BLOCKED) return;
602
450
 
603
- if (count > 0) return true;
604
-
605
- if (state.awaitItemsAttempts < 25) {
606
- state.awaitItemsAttempts++;
607
- setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
608
- }
609
- return false;
610
- }
611
-
612
- function waitForContentThenRun() {
613
- // Avoid inserting ads on pages with too little content.
614
- var MIN_WORDS = 250;
615
- var attempts = 0;
616
- var maxAttempts = 20; // 20 × 200ms = 4s
617
-
618
- (function check() {
619
- attempts++;
620
-
621
- var text = '';
622
- try { text = document.body.innerText || ''; } catch (e) {}
623
- var wordCount = text.split(/\s+/).filter(Boolean).length;
624
-
625
- if (wordCount >= MIN_WORDS) {
626
- scheduleRun('content-ok');
627
- return;
628
- }
451
+ patchShowAds();
629
452
 
630
- if (attempts >= maxAttempts) {
631
- scheduleRun('content-timeout');
632
- return;
453
+ const cfg = await fetchConfigOnce();
454
+ if (!cfg || cfg.excluded) return;
455
+ initPools(cfg);
456
+
457
+ const kind = getKind();
458
+
459
+ if (kind === 'topic') {
460
+ if (normalizeBool(cfg.enableMessageAds)) {
461
+ injectBetween(
462
+ 'ezoic-ad-message',
463
+ getPostContainers(),
464
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
465
+ normalizeBool(cfg.showFirstMessageAd),
466
+ state.allPosts,
467
+ 'curPosts'
468
+ );
633
469
  }
634
-
635
- setTimeoutTracked(check, 200);
636
- })();
637
- }
638
-
639
- function waitForEzoicThenRun() {
640
- var attempts = 0;
641
- var maxAttempts = 50; // 50 × 200ms = 10s
642
-
643
- (function check() {
644
- attempts++;
645
-
646
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
647
- scheduleRun('ezoic-ready');
648
- waitForContentThenRun();
649
- return;
470
+ } else if (kind === 'categoryTopics') {
471
+ if (normalizeBool(cfg.enableBetweenAds)) {
472
+ injectBetween(
473
+ 'ezoic-ad-between',
474
+ getTopicItems(),
475
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
476
+ normalizeBool(cfg.showFirstTopicAd),
477
+ state.allTopics,
478
+ 'curTopics'
479
+ );
650
480
  }
651
-
652
- if (attempts >= maxAttempts) {
653
- scheduleRun('ezoic-timeout');
654
- return;
481
+ } else if (kind === 'categories') {
482
+ if (normalizeBool(cfg.enableCategoryAds)) {
483
+ injectBetween(
484
+ 'ezoic-ad-categories',
485
+ getCategoryItems(),
486
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
487
+ normalizeBool(cfg.showFirstCategoryAd),
488
+ state.allCategories,
489
+ 'curCategories'
490
+ );
655
491
  }
656
-
657
- setTimeoutTracked(check, 200);
658
- })();
492
+ }
659
493
  }
660
494
 
661
- function fetchConfig() {
662
- if (state.cfg) return Promise.resolve(state.cfg);
663
- if (state.cfgPromise) return state.cfgPromise;
664
-
665
- state.cfgPromise = (function () {
666
- var MAX_TRIES = 3;
667
- var delay = 800;
668
-
669
- function attemptFetch(attempt) {
670
- return fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' })
671
- .then(function (res) {
672
- if (!res || !res.ok) throw new Error('bad response');
673
- return res.json();
674
- })
675
- .then(function (json) {
676
- state.cfg = json;
677
- return json;
678
- })
679
- .catch(function () {
680
- if (attempt >= MAX_TRIES) return null;
681
- return new Promise(function (r) { setTimeoutTracked(r, delay); }).then(function () {
682
- delay *= 2;
683
- return attemptFetch(attempt + 1);
684
- });
685
- });
686
- }
687
-
688
- return attemptFetch(1).finally(function () { state.cfgPromise = null; });
689
- })();
690
-
691
- return state.cfgPromise;
495
+ function scheduleRun() {
496
+ if (state.runQueued) return;
497
+ state.runQueued = true;
498
+ window.requestAnimationFrame(() => {
499
+ state.runQueued = false;
500
+ const pk = getPageKey();
501
+ if (state.pageKey && pk !== state.pageKey) return;
502
+ runCore().catch(() => {});
503
+ });
692
504
  }
693
505
 
694
- function runCore() {
695
- // Navigation safety: never insert during ajaxify teardown.
696
- if (!state.canShowAds) return Promise.resolve();
697
-
698
- patchShowAds();
699
-
700
- return fetchConfig().then(function (cfg) {
701
- if (!cfg || cfg.excluded) return;
506
+ // ---------- observers / lifecycle ----------
702
507
 
703
- initPools(cfg);
704
-
705
- var kind = getKind();
706
- var inserted = 0;
707
-
708
- if (!waitForItemsThenRun(kind)) return;
508
+ function cleanup() {
509
+ EZOIC_BLOCKED = true;
709
510
 
710
- if (kind === 'topic') {
711
- if (normalizeBool(cfg.enableMessageAds)) {
712
- inserted = injectBetween(
713
- 'ezoic-ad-message',
714
- getPostContainers(),
715
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
716
- normalizeBool(cfg.showFirstMessageAd),
717
- state.poolPosts,
718
- state.usedPosts,
719
- state.livePosts
720
- );
721
- }
722
- } else if (kind === 'categoryTopics') {
723
- if (normalizeBool(cfg.enableBetweenAds)) {
724
- inserted = injectBetween(
725
- 'ezoic-ad-between',
726
- getTopicItems(),
727
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
728
- normalizeBool(cfg.showFirstTopicAd),
729
- state.poolTopics,
730
- state.usedTopics,
731
- state.liveTopics
732
- );
733
- }
734
- } else if (kind === 'categories') {
735
- if (normalizeBool(cfg.enableCategoryAds)) {
736
- inserted = injectBetween(
737
- 'ezoic-ad-categories',
738
- getCategoryItems(),
739
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
740
- normalizeBool(cfg.showFirstCategoryAd),
741
- state.poolCategories,
742
- state.usedCategories,
743
- state.liveCategories
744
- );
745
- }
746
- }
511
+ // remove all wrappers
512
+ try {
513
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
514
+ try { el.remove(); } catch (e) {}
515
+ });
516
+ } catch (e) {}
747
517
 
748
- enforceNoAdjacentAds();
518
+ // reset state
519
+ state.cfg = null;
520
+ state.allTopics = [];
521
+ state.allPosts = [];
522
+ state.allCategories = [];
523
+ state.curTopics = 0;
524
+ state.curPosts = 0;
525
+ state.curCategories = 0;
526
+ state.lastShowById.clear();
527
+ state.heroDoneForPage = false;
749
528
 
750
- // Recycling: if pool is exhausted, retry a few times to allow old wrappers to scroll off-screen.
751
- if (inserted === 0) {
752
- if (state.poolWaitAttempts < 8) {
753
- state.poolWaitAttempts++;
754
- setTimeoutTracked(function () { scheduleRun('pool-wait'); }, 400);
755
- }
756
- } else {
757
- // Reset pool wait attempts once we successfully insert something.
758
- state.poolWaitAttempts = 0;
759
- }
529
+ // keep observers alive (MutationObserver will re-trigger after navigation)
530
+ }
760
531
 
761
- // If we hit max inserts, continue quickly.
762
- if (inserted >= MAX_INSERTS_PER_RUN) {
763
- setTimeoutTracked(function () { scheduleRun('continue'); }, 140);
764
- }
765
- }).catch(function () {});
532
+ function ensureDomObserver() {
533
+ if (state.domObs) return;
534
+ state.domObs = new MutationObserver(() => {
535
+ if (!EZOIC_BLOCKED) scheduleRun();
536
+ });
537
+ try {
538
+ state.domObs.observe(document.body, { childList: true, subtree: true });
539
+ } catch (e) {}
766
540
  }
767
541
 
768
- function bind() {
542
+ function bindNodeBB() {
769
543
  if (!$) return;
770
544
 
771
545
  $(window).off('.ezoicInfinite');
772
546
 
773
- $(window).on('action:ajaxify.start.ezoicInfinite', function () {
547
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
774
548
  cleanup();
775
549
  });
776
550
 
777
- $(window).on('action:ajaxify.end.ezoicInfinite', function () {
551
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
778
552
  state.pageKey = getPageKey();
779
- ensureObserver();
553
+ EZOIC_BLOCKED = false;
780
554
 
781
- // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
782
- setTimeoutTracked(function () {
783
- state.canShowAds = true;
784
- waitForEzoicThenRun();
785
- }, 300);
786
- });
555
+ warmUpNetwork();
556
+ patchShowAds();
557
+ ensurePreloadObserver();
558
+ ensureDomObserver();
787
559
 
788
- // Infinite-scroll and "loaded" events.
789
- $(window).on('action:category.loaded.ezoicInfinite', function () {
790
- ensureObserver();
791
- waitForContentThenRun();
792
- });
560
+ // Ultra-fast above-the-fold first
561
+ insertHeroAdEarly().catch(() => {});
793
562
 
794
- $(window).on('action:topics.loaded.ezoicInfinite', function () {
795
- ensureObserver();
796
- waitForContentThenRun();
563
+ // Then normal insertion
564
+ scheduleRun();
797
565
  });
798
566
 
799
- $(window).on('action:topic.loaded.ezoicInfinite', function () {
800
- ensureObserver();
801
- waitForContentThenRun();
802
- });
803
-
804
- $(window).on('action:posts.loaded.ezoicInfinite', function () {
805
- ensureObserver();
806
- waitForContentThenRun();
567
+ // Infinite scroll / partial updates
568
+ $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
569
+ if (EZOIC_BLOCKED) return;
570
+ scheduleRun();
807
571
  });
808
572
  }
809
573
 
810
574
  function bindScroll() {
811
- if (state.lastScrollRun > 0) return;
812
- state.lastScrollRun = Date.now();
813
-
814
- var ticking = false;
815
- window.addEventListener('scroll', function () {
575
+ let ticking = false;
576
+ window.addEventListener('scroll', () => {
816
577
  if (ticking) return;
817
578
  ticking = true;
818
-
819
- window.requestAnimationFrame(function () {
579
+ window.requestAnimationFrame(() => {
820
580
  ticking = false;
821
-
822
- enforceNoAdjacentAds();
823
-
824
- // Debounce scheduleRun (max once every 2s on scroll).
825
- var now = Date.now();
826
- if (!state.lastScrollRun || (now - state.lastScrollRun > 2000)) {
827
- state.lastScrollRun = now;
828
- scheduleRun('scroll');
829
- }
581
+ if (!EZOIC_BLOCKED) scheduleRun();
830
582
  });
831
583
  }, { passive: true });
832
584
  }
833
585
 
834
- // Boot.
835
- cleanup();
836
- bind();
837
- bindScroll();
838
- ensureObserver();
586
+ // ---------- boot ----------
839
587
 
840
588
  state.pageKey = getPageKey();
589
+ warmUpNetwork();
590
+ patchShowAds();
591
+ ensurePreloadObserver();
592
+ ensureDomObserver();
593
+
594
+ bindNodeBB();
595
+ bindScroll();
841
596
 
842
- // Direct page load: allow insertion after initial tick (no ajaxify.end).
843
- setTimeoutTracked(function () {
844
- state.canShowAds = true;
845
- waitForEzoicThenRun();
846
- }, 0);
597
+ // First paint: try hero + run
598
+ EZOIC_BLOCKED = false;
599
+ insertHeroAdEarly().catch(() => {});
600
+ scheduleRun();
847
601
  })();