nodebb-plugin-ezoic-infinite 1.5.22 → 1.5.23

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 +383 -636
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.23",
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,32 +1,30 @@
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
29
  poolTopics: [],
32
30
  poolPosts: [],
@@ -36,58 +34,32 @@
36
34
  usedPosts: new Set(),
37
35
  usedCategories: new Set(),
38
36
 
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(),
40
+ // observers / schedulers
41
+ domObs: null,
42
+ io: null,
43
+ runQueued: false,
52
44
 
53
- // Run scheduling / mutation observer.
54
- scheduled: false,
55
- timer: null,
56
- obs: null,
45
+ // hero
46
+ heroDoneForPage: false,
47
+ };
57
48
 
58
- // Scroll throttling.
59
- lastScrollRun: 0,
49
+ const sessionDefinedIds = new Set();
50
+ const insertingIds = new Set();
60
51
 
61
- // Navigation safety gate: we only insert after ajaxify.end settles.
62
- canShowAds: false,
63
-
64
- // Retry counters.
65
- poolWaitAttempts: 0,
66
- awaitItemsAttempts: 0
67
- };
52
+ // ---------- small utils ----------
68
53
 
69
54
  function normalizeBool(v) {
70
55
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
71
56
  }
72
57
 
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
58
  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);
59
+ const out = [];
60
+ const seen = new Set();
61
+ for (const v of lines) {
62
+ const n = parseInt(v, 10);
91
63
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
92
64
  seen.add(n);
93
65
  out.push(n);
@@ -98,31 +70,31 @@
98
70
 
99
71
  function parsePool(raw) {
100
72
  if (!raw) return [];
101
- var lines = String(raw)
73
+ const lines = String(raw)
102
74
  .split(/\r?\n/)
103
- .map(function (s) { return s.trim(); })
75
+ .map(s => s.trim())
104
76
  .filter(Boolean);
105
77
  return uniqInts(lines);
106
78
  }
107
79
 
108
80
  function getPageKey() {
109
81
  try {
110
- var ax = window.ajaxify;
82
+ const ax = window.ajaxify;
111
83
  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;
84
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
85
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
114
86
  }
115
87
  } catch (e) {}
116
88
  return window.location.pathname;
117
89
  }
118
90
 
119
91
  function getKind() {
120
- var p = window.location.pathname || '';
92
+ const p = window.location.pathname || '';
121
93
  if (/^\/topic\//.test(p)) return 'topic';
122
94
  if (/^\/category\//.test(p)) return 'categoryTopics';
123
95
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
124
96
 
125
- // Fallback by DOM.
97
+ // fallback by DOM
126
98
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
127
99
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
128
100
  if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
@@ -138,710 +110,485 @@
138
110
  }
139
111
 
140
112
  function getPostContainers() {
141
- var nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
142
- return nodes.filter(function (el) {
113
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
114
+ return nodes.filter((el) => {
143
115
  if (!el || !el.isConnected) return false;
144
116
  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]');
117
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
148
118
  if (parentPost && parentPost !== el) return false;
149
119
  if (el.getAttribute('component') === 'post/parent') return false;
150
-
151
120
  return true;
152
121
  });
153
122
  }
154
123
 
155
- function safeRect(el) {
156
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
157
- }
158
-
159
- function destroyPlaceholderIds(ids) {
160
- if (!ids || !ids.length) return;
124
+ // ---------- warm-up & patching ----------
161
125
 
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;
126
+ const _warmLinksDone = new Set();
127
+ function warmUpNetwork() {
128
+ try {
129
+ const head = document.head || document.getElementsByTagName('head')[0];
130
+ if (!head) return;
131
+ const links = [
132
+ ['preconnect', 'https://g.ezoic.net', true],
133
+ ['dns-prefetch', 'https://g.ezoic.net', false],
134
+ ['preconnect', 'https://go.ezoic.net', true],
135
+ ['dns-prefetch', 'https://go.ezoic.net', false],
136
+ ];
137
+ for (const [rel, href, cors] of links) {
138
+ const key = `${rel}|${href}`;
139
+ if (_warmLinksDone.has(key)) continue;
140
+ _warmLinksDone.add(key);
141
+ const link = document.createElement('link');
142
+ link.rel = rel;
143
+ link.href = href;
144
+ if (cors) link.crossOrigin = 'anonymous';
145
+ head.appendChild(link);
146
+ }
147
+ } catch (e) {}
148
+ }
168
149
 
169
- var call = function () {
150
+ // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
151
+ function patchShowAds() {
152
+ const applyPatch = () => {
170
153
  try {
171
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
172
- window.ezstandalone.destroyPlaceholders(filtered);
173
- }
154
+ window.ezstandalone = window.ezstandalone || {};
155
+ const ez = window.ezstandalone;
156
+ if (window.__nodebbEzoicPatched) return;
157
+ if (typeof ez.showAds !== 'function') return;
158
+
159
+ window.__nodebbEzoicPatched = true;
160
+ const orig = ez.showAds;
161
+
162
+ ez.showAds = function (...args) {
163
+ if (EZOIC_BLOCKED) return;
164
+
165
+ let ids = [];
166
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
167
+ else ids = args;
168
+
169
+ const seen = new Set();
170
+ for (const v of ids) {
171
+ const id = parseInt(v, 10);
172
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
173
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
174
+ if (!ph || !ph.isConnected) continue;
175
+ seen.add(id);
176
+ try { orig.call(ez, id); } catch (e) {}
177
+ }
178
+ };
174
179
  } catch (e) {}
175
180
  };
176
181
 
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
- }
182
+ applyPatch();
183
+ if (!window.__nodebbEzoicPatched) {
184
+ try {
185
+ window.ezstandalone = window.ezstandalone || {};
186
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
187
+ window.ezstandalone.cmd.push(applyPatch);
188
+ } catch (e) {}
200
189
  }
201
- return null;
202
190
  }
203
191
 
204
- function pickId(pool, liveArr) {
205
- if (pool.length) return { id: pool.shift(), recycled: null };
192
+ // ---------- config & pools ----------
206
193
 
207
- var recycled = getRecyclable(liveArr);
208
- if (recycled) return { id: recycled.id, recycled: recycled };
194
+ async function fetchConfigOnce() {
195
+ if (state.cfg) return state.cfg;
196
+ try {
197
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
198
+ if (!res.ok) return null;
199
+ state.cfg = await res.json();
200
+ return state.cfg;
201
+ } catch (e) {
202
+ return null;
203
+ }
204
+ }
209
205
 
210
- return { id: null, recycled: null };
206
+ function initPools(cfg) {
207
+ if (!cfg) return;
208
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
209
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
210
+ if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
211
211
  }
212
212
 
213
- function resetPlaceholderInWrap(wrap, id) {
214
- if (!wrap) return null;
215
- try { wrap.innerHTML = ''; } catch (e) {}
213
+ // ---------- insertion primitives ----------
216
214
 
217
- var ph = document.createElement('div');
218
- ph.id = PLACEHOLDER_PREFIX + id;
219
- wrap.appendChild(ph);
220
- return ph;
215
+ function isAdjacentAd(target) {
216
+ if (!target) return false;
217
+ const next = target.nextElementSibling;
218
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
219
+ const prev = target.previousElementSibling;
220
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
221
+ return false;
221
222
  }
222
223
 
223
- function isAdjacentAd(el) {
224
- var next = el && el.nextElementSibling;
225
- return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
226
- }
224
+ function buildWrap(id, kindClass, afterPos) {
225
+ const wrap = document.createElement('div');
226
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
227
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
228
+ wrap.style.width = '100%';
227
229
 
228
- function isPrevAd(el) {
229
- var prev = el && el.previousElementSibling;
230
- return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
231
- }
230
+ const ph = document.createElement('div');
231
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
232
+ ph.setAttribute('data-ezoic-id', String(id));
233
+ wrap.appendChild(ph);
232
234
 
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
235
  return wrap;
239
236
  }
240
237
 
241
238
  function findWrap(kindClass, afterPos) {
242
- // Search a wrapper marker that we set on insertion.
243
- return document.querySelector('.' + kindClass + '[data-after-pos="' + afterPos + '"]');
239
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
244
240
  }
245
241
 
246
- function insertAfter(el, id, kindClass, afterPos) {
247
- try {
248
- var wrap = buildWrap(id, kindClass);
249
- wrap.setAttribute('data-after-pos', String(afterPos));
242
+ function insertAfter(target, id, kindClass, afterPos) {
243
+ if (!target || !target.insertAdjacentElement) return null;
244
+ if (findWrap(kindClass, afterPos)) return null;
245
+ if (insertingIds.has(id)) return null;
250
246
 
251
- if (!el || !el.parentNode) return null;
252
- if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
253
- else el.parentNode.appendChild(wrap);
247
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
248
+ if (existingPh && existingPh.isConnected) return null;
254
249
 
255
- attachFillObserver(wrap, id);
250
+ insertingIds.add(id);
251
+ try {
252
+ const wrap = buildWrap(id, kindClass, afterPos);
253
+ target.insertAdjacentElement('afterend', wrap);
256
254
  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.
273
- }
274
-
275
- function markFilled(id) {
276
- try { sessionDefinedIds.add(id); } catch (e) {}
277
- }
278
-
279
- function isWrapMarkedFilled(wrap) {
280
- try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
281
- }
282
-
283
- function attachFillObserver(wrap, id) {
284
- if (!wrap || !wrap.isConnected) return;
285
-
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;
255
+ } finally {
256
+ insertingIds.delete(id);
291
257
  }
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) {}
309
258
  }
310
259
 
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;
260
+ function pickId(pool) {
261
+ return pool.length ? pool.shift() : null;
321
262
  }
322
263
 
323
- function scheduleShowAdsBatch(ids) {
324
- if (!ids || !ids.length) return;
325
-
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
- };
334
-
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
- }
264
+ function showAd(id) {
265
+ if (!id || EZOIC_BLOCKED) return;
340
266
 
341
- function callShowAdsWhenReady(id) {
342
- if (!id) return;
267
+ const now = Date.now();
268
+ const last = state.lastShowById.get(id) || 0;
269
+ if (now - last < 1500) return; // basic throttle
343
270
 
344
- // Throttle per-id.
345
- var now = Date.now();
346
- var last = state.lastShowById.get(id) || 0;
347
- if (now - last < 1200) return;
271
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
272
+ if (!ph || !ph.isConnected) return;
348
273
 
349
- if (state.pendingById.has(id)) return;
350
- state.pendingById.add(id);
351
274
  state.lastShowById.set(id, now);
352
275
 
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++;
276
+ try {
277
+ window.ezstandalone = window.ezstandalone || {};
278
+ const ez = window.ezstandalone;
364
279
 
365
- // Navigation safety: if we navigated away, stop.
366
- if (!state.canShowAds) {
367
- state.pendingById.delete(id);
368
- insertingIds.delete(id);
280
+ // Fast path
281
+ if (typeof ez.showAds === 'function') {
282
+ ez.showAds(id);
283
+ sessionDefinedIds.add(id);
369
284
  return;
370
285
  }
371
286
 
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;
287
+ // Queue once for when Ezoic is ready
288
+ ez.cmd = ez.cmd || [];
289
+ if (!ph.__ezoicQueued) {
290
+ ph.__ezoicQueued = true;
291
+ ez.cmd.push(() => {
292
+ try {
293
+ if (EZOIC_BLOCKED) return;
294
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
295
+ if (!el || !el.isConnected) return;
296
+ window.ezstandalone.showAds(id);
297
+ sessionDefinedIds.add(id);
298
+ } catch (e) {}
299
+ });
389
300
  }
301
+ } catch (e) {}
302
+ }
390
303
 
391
- if (attempts < 100) {
392
- setTimeoutTracked(waitForPh, 50);
393
- return;
394
- }
304
+ // ---------- preload / above-the-fold ----------
395
305
 
396
- // Timeout: give up silently.
397
- state.pendingById.delete(id);
398
- insertingIds.delete(id);
399
- })();
306
+ function ensurePreloadObserver() {
307
+ if (state.io) return state.io;
308
+ try {
309
+ state.io = new IntersectionObserver((entries) => {
310
+ for (const ent of entries) {
311
+ if (!ent.isIntersecting) continue;
312
+ const el = ent.target;
313
+ try { state.io && state.io.unobserve(el); } catch (e) {}
314
+
315
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
316
+ const id = parseInt(idAttr, 10);
317
+ if (Number.isFinite(id) && id > 0) showAd(id);
318
+ }
319
+ }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
320
+ } catch (e) {
321
+ state.io = null;
322
+ }
323
+ return state.io;
400
324
  }
401
325
 
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);
326
+ function observePlaceholder(id) {
327
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
328
+ if (!ph || !ph.isConnected) return;
329
+ const io = ensurePreloadObserver();
330
+ try { io && io.observe(ph); } catch (e) {}
331
+
332
+ // If already above fold, fire immediately
333
+ try {
334
+ const r = ph.getBoundingClientRect();
335
+ if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
336
+ } catch (e) {}
406
337
  }
407
338
 
339
+ // ---------- insertion logic ----------
340
+
408
341
  function computeTargets(count, interval, showFirst) {
409
- var out = [];
342
+ const out = [];
410
343
  if (count <= 0) return out;
411
-
412
344
  if (showFirst) out.push(1);
413
-
414
- for (var i = 1; i <= count; i++) {
345
+ for (let i = 1; i <= count; i++) {
415
346
  if (i % interval === 0) out.push(i);
416
347
  }
417
-
418
- // Unique + sorted.
419
- return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
348
+ return Array.from(new Set(out)).sort((a, b) => a - b);
420
349
  }
421
350
 
422
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
423
- if (!items || !items.length) return 0;
351
+ function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
352
+ if (!items.length) return 0;
424
353
 
425
- var targets = computeTargets(items.length, interval, showFirst);
426
- var inserted = 0;
354
+ const targets = computeTargets(items.length, interval, showFirst);
355
+ let inserted = 0;
427
356
 
428
- for (var t = 0; t < targets.length; t++) {
429
- var afterPos = targets[t];
357
+ for (const afterPos of targets) {
430
358
  if (inserted >= MAX_INSERTS_PER_RUN) break;
431
359
 
432
- var el = items[afterPos - 1];
360
+ const el = items[afterPos - 1];
433
361
  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;
362
+ if (isAdjacentAd(el)) continue;
440
363
  if (findWrap(kindClass, afterPos)) continue;
441
364
 
442
- var pick = pickId(kindPool, liveArr);
443
- var id = pick.id;
365
+ const id = pickId(pool);
444
366
  if (!id) break;
445
367
 
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) {}
368
+ usedSet.add(id);
369
+ const wrap = insertAfter(el, id, kindClass, afterPos);
370
+ if (!wrap) {
371
+ usedSet.delete(id);
372
+ pool.unshift(id);
373
+ continue;
374
+ }
456
375
 
457
- wrap = insertAfter(el, id, kindClass, afterPos);
458
- if (!wrap) continue;
376
+ observePlaceholder(id);
377
+ inserted += 1;
378
+ }
459
379
 
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;
380
+ return inserted;
381
+ }
466
382
 
467
- // Micro-delay to allow layout/DOM settle.
468
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
469
- }
383
+ async function insertHeroAdEarly() {
384
+ if (state.heroDoneForPage) return;
385
+ const cfg = await fetchConfigOnce();
386
+ if (!cfg || cfg.excluded) return;
387
+
388
+ initPools(cfg);
389
+
390
+ const kind = getKind();
391
+ let items = [];
392
+ let pool = null;
393
+ let usedSet = null;
394
+ let kindClass = '';
395
+
396
+ if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
397
+ items = getPostContainers();
398
+ pool = state.poolPosts;
399
+ usedSet = state.usedPosts;
400
+ kindClass = 'ezoic-ad-message';
401
+ } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
402
+ items = getTopicItems();
403
+ pool = state.poolTopics;
404
+ usedSet = state.usedTopics;
405
+ kindClass = 'ezoic-ad-between';
406
+ } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
407
+ items = getCategoryItems();
408
+ pool = state.poolCategories;
409
+ usedSet = state.usedCategories;
410
+ kindClass = 'ezoic-ad-categories';
411
+ } else {
412
+ return;
413
+ }
470
414
 
471
- liveArr.push({ id: id, wrap: wrap });
415
+ if (!items.length) return;
472
416
 
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) {}
417
+ // Insert after the very first item (above-the-fold)
418
+ const afterPos = 1;
419
+ const el = items[afterPos - 1];
420
+ if (!el || !el.isConnected) return;
421
+ if (isAdjacentAd(el)) return;
422
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
478
423
 
479
- if (!(pick.recycled && pick.recycled.wrap)) {
480
- try { kindPool.unshift(id); } catch (e) {}
481
- usedSet.delete(id);
482
- }
483
- continue;
484
- }
424
+ const id = pickId(pool);
425
+ if (!id) return;
485
426
 
486
- inserted++;
427
+ usedSet.add(id);
428
+ const wrap = insertAfter(el, id, kindClass, afterPos);
429
+ if (!wrap) {
430
+ usedSet.delete(id);
431
+ pool.unshift(id);
432
+ return;
487
433
  }
488
434
 
489
- return inserted;
435
+ state.heroDoneForPage = true;
436
+ observePlaceholder(id);
490
437
  }
491
438
 
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) {}
439
+ async function runCore() {
440
+ if (EZOIC_BLOCKED) return;
441
+
442
+ patchShowAds();
443
+
444
+ const cfg = await fetchConfigOnce();
445
+ if (!cfg || cfg.excluded) return;
446
+ initPools(cfg);
447
+
448
+ const kind = getKind();
449
+
450
+ if (kind === 'topic') {
451
+ if (normalizeBool(cfg.enableMessageAds)) {
452
+ injectBetween(
453
+ 'ezoic-ad-message',
454
+ getPostContainers(),
455
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
456
+ normalizeBool(cfg.showFirstMessageAd),
457
+ state.poolPosts,
458
+ state.usedPosts
459
+ );
460
+ }
461
+ } else if (kind === 'categoryTopics') {
462
+ if (normalizeBool(cfg.enableBetweenAds)) {
463
+ injectBetween(
464
+ 'ezoic-ad-between',
465
+ getTopicItems(),
466
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
467
+ normalizeBool(cfg.showFirstTopicAd),
468
+ state.poolTopics,
469
+ state.usedTopics
470
+ );
471
+ }
472
+ } else if (kind === 'categories') {
473
+ if (normalizeBool(cfg.enableCategoryAds)) {
474
+ injectBetween(
475
+ 'ezoic-ad-categories',
476
+ getCategoryItems(),
477
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
478
+ normalizeBool(cfg.showFirstCategoryAd),
479
+ state.poolCategories,
480
+ state.usedCategories
481
+ );
510
482
  }
511
483
  }
512
484
  }
513
485
 
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;
527
- }
486
+ function scheduleRun() {
487
+ if (state.runQueued) return;
488
+ state.runQueued = true;
489
+ window.requestAnimationFrame(() => {
490
+ state.runQueued = false;
491
+ const pk = getPageKey();
492
+ if (state.pageKey && pk !== state.pageKey) return;
493
+ runCore().catch(() => {});
494
+ });
495
+ }
528
496
 
529
- // Destroy placeholders that were used (only those that were actually defined).
530
- destroyUsedPlaceholders();
497
+ // ---------- observers / lifecycle ----------
531
498
 
532
- // Remove wrappers from DOM (safe because insertion is now blocked).
499
+ function cleanup() {
500
+ EZOIC_BLOCKED = true;
501
+
502
+ // remove all wrappers
533
503
  try {
534
- document.querySelectorAll('.' + WRAP_CLASS).forEach(function (el) {
535
- try { if (el && el.__ezoicFillObs) el.__ezoicFillObs.disconnect(); } catch (e) {}
504
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
536
505
  try { el.remove(); } catch (e) {}
537
506
  });
538
507
  } catch (e) {}
539
508
 
540
- // Reset runtime caches.
541
- state.pageKey = getPageKey();
509
+ // reset state
542
510
  state.cfg = null;
543
- state.cfgPromise = null;
544
-
545
511
  state.poolTopics = [];
546
512
  state.poolPosts = [];
547
513
  state.poolCategories = [];
548
-
549
514
  state.usedTopics.clear();
550
515
  state.usedPosts.clear();
551
516
  state.usedCategories.clear();
552
-
553
- state.liveTopics = [];
554
- state.livePosts = [];
555
- state.liveCategories = [];
556
-
557
517
  state.lastShowById.clear();
558
- state.pendingById.clear();
559
- insertingIds.clear();
518
+ state.heroDoneForPage = false;
560
519
 
561
- state.scheduled = false;
562
- if (state.timer) {
563
- try { clearTimeout(state.timer); } catch (e) {}
564
- state.timer = null;
565
- }
520
+ sessionDefinedIds.clear();
521
+
522
+ // keep observers alive (MutationObserver will re-trigger after navigation)
566
523
  }
567
524
 
568
- function ensureObserver() {
569
- if (state.obs) return;
525
+ function ensureDomObserver() {
526
+ if (state.domObs) return;
527
+ state.domObs = new MutationObserver(() => {
528
+ if (!EZOIC_BLOCKED) scheduleRun();
529
+ });
570
530
  try {
571
- state.obs = new MutationObserver(function () { scheduleRun('mutation'); });
572
- state.obs.observe(document.body, { childList: true, subtree: true });
531
+ state.domObs.observe(document.body, { childList: true, subtree: true });
573
532
  } catch (e) {}
574
533
  }
575
534
 
576
- function scheduleRun(/* reason */) {
577
- if (state.scheduled) return;
578
- state.scheduled = true;
579
-
580
- if (state.timer) {
581
- try { clearTimeout(state.timer); } catch (e) {}
582
- state.timer = null;
583
- }
584
-
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);
594
- }
595
-
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;
602
-
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
- }
629
-
630
- if (attempts >= maxAttempts) {
631
- scheduleRun('content-timeout');
632
- return;
633
- }
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;
650
- }
651
-
652
- if (attempts >= maxAttempts) {
653
- scheduleRun('ezoic-timeout');
654
- return;
655
- }
656
-
657
- setTimeoutTracked(check, 200);
658
- })();
659
- }
660
-
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;
692
- }
693
-
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;
702
-
703
- initPools(cfg);
704
-
705
- var kind = getKind();
706
- var inserted = 0;
707
-
708
- if (!waitForItemsThenRun(kind)) return;
709
-
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
- }
747
-
748
- enforceNoAdjacentAds();
749
-
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
- }
760
-
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 () {});
766
- }
767
-
768
- function bind() {
535
+ function bindNodeBB() {
769
536
  if (!$) return;
770
537
 
771
538
  $(window).off('.ezoicInfinite');
772
539
 
773
- $(window).on('action:ajaxify.start.ezoicInfinite', function () {
540
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
774
541
  cleanup();
775
542
  });
776
543
 
777
- $(window).on('action:ajaxify.end.ezoicInfinite', function () {
544
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
778
545
  state.pageKey = getPageKey();
779
- ensureObserver();
546
+ EZOIC_BLOCKED = false;
780
547
 
781
- // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
782
- setTimeoutTracked(function () {
783
- state.canShowAds = true;
784
- waitForEzoicThenRun();
785
- }, 300);
786
- });
548
+ warmUpNetwork();
549
+ patchShowAds();
550
+ ensurePreloadObserver();
551
+ ensureDomObserver();
787
552
 
788
- // Infinite-scroll and "loaded" events.
789
- $(window).on('action:category.loaded.ezoicInfinite', function () {
790
- ensureObserver();
791
- waitForContentThenRun();
792
- });
553
+ // Ultra-fast above-the-fold first
554
+ insertHeroAdEarly().catch(() => {});
793
555
 
794
- $(window).on('action:topics.loaded.ezoicInfinite', function () {
795
- ensureObserver();
796
- waitForContentThenRun();
556
+ // Then normal insertion
557
+ scheduleRun();
797
558
  });
798
559
 
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();
560
+ // Infinite scroll / partial updates
561
+ $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
562
+ if (EZOIC_BLOCKED) return;
563
+ scheduleRun();
807
564
  });
808
565
  }
809
566
 
810
567
  function bindScroll() {
811
- if (state.lastScrollRun > 0) return;
812
- state.lastScrollRun = Date.now();
813
-
814
- var ticking = false;
815
- window.addEventListener('scroll', function () {
568
+ let ticking = false;
569
+ window.addEventListener('scroll', () => {
816
570
  if (ticking) return;
817
571
  ticking = true;
818
-
819
- window.requestAnimationFrame(function () {
572
+ window.requestAnimationFrame(() => {
820
573
  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
- }
574
+ if (!EZOIC_BLOCKED) scheduleRun();
830
575
  });
831
576
  }, { passive: true });
832
577
  }
833
578
 
834
- // Boot.
835
- cleanup();
836
- bind();
837
- bindScroll();
838
- ensureObserver();
579
+ // ---------- boot ----------
839
580
 
840
581
  state.pageKey = getPageKey();
582
+ warmUpNetwork();
583
+ patchShowAds();
584
+ ensurePreloadObserver();
585
+ ensureDomObserver();
586
+
587
+ bindNodeBB();
588
+ bindScroll();
841
589
 
842
- // Direct page load: allow insertion after initial tick (no ajaxify.end).
843
- setTimeoutTracked(function () {
844
- state.canShowAds = true;
845
- waitForEzoicThenRun();
846
- }, 0);
590
+ // First paint: try hero + run
591
+ EZOIC_BLOCKED = false;
592
+ insertHeroAdEarly().catch(() => {});
593
+ scheduleRun();
847
594
  })();