nodebb-plugin-ezoic-infinite 1.5.21 → 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 -640
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.21",
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,714 +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
- window.ezstandalone = window.ezstandalone || {};
179
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
180
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
181
- else window.ezstandalone.cmd.push(call);
182
- } catch (e) {}
183
- }
184
-
185
- function getRecyclable(liveArr) {
186
- var margin = 600; // px above viewport
187
- for (var i = 0; i < liveArr.length; i++) {
188
- var entry = liveArr[i];
189
- if (!entry || !entry.wrap || !entry.wrap.isConnected) {
190
- liveArr.splice(i, 1);
191
- i--;
192
- continue;
193
- }
194
- var r = safeRect(entry.wrap);
195
- if (r && r.bottom < -margin) {
196
- liveArr.splice(i, 1);
197
- return entry;
198
- }
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) {}
199
189
  }
200
- return null;
201
190
  }
202
191
 
203
- function pickId(pool, liveArr) {
204
- if (pool.length) return { id: pool.shift(), recycled: null };
192
+ // ---------- config & pools ----------
205
193
 
206
- var recycled = getRecyclable(liveArr);
207
- 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
+ }
208
205
 
209
- 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);
210
211
  }
211
212
 
212
- function resetPlaceholderInWrap(wrap, id) {
213
- if (!wrap) return null;
214
- try { wrap.innerHTML = ''; } catch (e) {}
213
+ // ---------- insertion primitives ----------
215
214
 
216
- var ph = document.createElement('div');
217
- ph.id = PLACEHOLDER_PREFIX + id;
218
- wrap.appendChild(ph);
219
- 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;
220
222
  }
221
223
 
222
- function isAdjacentAd(el) {
223
- var next = el && el.nextElementSibling;
224
- return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
225
- }
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%';
226
229
 
227
- function isPrevAd(el) {
228
- var prev = el && el.previousElementSibling;
229
- return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
230
- }
230
+ const ph = document.createElement('div');
231
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
232
+ ph.setAttribute('data-ezoic-id', String(id));
233
+ wrap.appendChild(ph);
231
234
 
232
- function buildWrap(id, kindClass) {
233
- var wrap = document.createElement('div');
234
- wrap.className = WRAP_CLASS + ' ' + kindClass;
235
- wrap.setAttribute('data-ezoic-id', String(id));
236
- resetPlaceholderInWrap(wrap, id);
237
235
  return wrap;
238
236
  }
239
237
 
240
238
  function findWrap(kindClass, afterPos) {
241
- // Search a wrapper marker that we set on insertion.
242
- return document.querySelector('.' + kindClass + '[data-after-pos="' + afterPos + '"]');
243
- }
244
-
245
- function insertAfter(el, id, kindClass, afterPos) {
246
- try {
247
- var wrap = buildWrap(id, kindClass);
248
- wrap.setAttribute('data-after-pos', String(afterPos));
249
-
250
- if (!el || !el.parentNode) return null;
251
- if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
252
- else el.parentNode.appendChild(wrap);
253
-
254
- attachFillObserver(wrap, id);
255
- return wrap;
256
- } catch (e) {}
257
- return null;
239
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
258
240
  }
259
241
 
260
- function destroyUsedPlaceholders() {
261
- var ids = [];
262
- state.usedTopics.forEach(function (id) { ids.push(id); });
263
- state.usedPosts.forEach(function (id) { ids.push(id); });
264
- state.usedCategories.forEach(function (id) { ids.push(id); });
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;
265
246
 
266
- // Only destroy placeholders that were filled at least once in this session.
267
- destroyPlaceholderIds(ids);
268
- }
247
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
248
+ if (existingPh && existingPh.isConnected) return null;
269
249
 
270
- function patchShowAds() {
271
- // Some Ezoic setups require calling showAds via ezstandalone.cmd.
272
- // We keep existing behavior but make it resilient.
250
+ insertingIds.add(id);
273
251
  try {
274
- window.ezstandalone = window.ezstandalone || {};
275
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
276
- } catch (e) {}
277
- }
278
-
279
- function markFilled(id) {
280
- try { sessionDefinedIds.add(id); } catch (e) {}
281
- }
282
-
283
- function isWrapMarkedFilled(wrap) {
284
- try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
285
- }
286
-
287
- function attachFillObserver(wrap, id) {
288
- if (!wrap || !wrap.isConnected) return;
289
-
290
- // If already filled, mark and return.
291
- if (isPlaceholderFilled(wrap)) {
292
- try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
293
- markFilled(id);
294
- return;
252
+ const wrap = buildWrap(id, kindClass, afterPos);
253
+ target.insertAdjacentElement('afterend', wrap);
254
+ return wrap;
255
+ } finally {
256
+ insertingIds.delete(id);
295
257
  }
296
-
297
- // Observe for Ezoic inserting ad content into placeholder.
298
- try {
299
- var obs = new MutationObserver(function () {
300
- if (!wrap.isConnected) {
301
- try { obs.disconnect(); } catch (e) {}
302
- return;
303
- }
304
- if (isPlaceholderFilled(wrap)) {
305
- try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
306
- markFilled(id);
307
- try { obs.disconnect(); } catch (e) {}
308
- }
309
- });
310
- obs.observe(wrap, { childList: true, subtree: true });
311
- wrap.__ezoicFillObs = obs;
312
- } catch (e) {}
313
258
  }
314
259
 
315
- function isPlaceholderFilled(wrap) {
316
- // Heuristic: placeholder exists AND has descendants or meaningful height.
317
- try {
318
- var ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
319
- if (!ph) return false;
320
- if (ph.children && ph.children.length) return true;
321
- var r = safeRect(wrap);
322
- if (r && r.height > 20) return true;
323
- } catch (e) {}
324
- return false;
260
+ function pickId(pool) {
261
+ return pool.length ? pool.shift() : null;
325
262
  }
326
263
 
327
- function scheduleShowAdsBatch(ids) {
328
- if (!ids || !ids.length) return;
329
-
330
- // Ezoic expects DOM to be settled.
331
- var call = function () {
332
- try {
333
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
334
- window.ezstandalone.showAds(ids);
335
- }
336
- } catch (e) {}
337
- };
338
-
339
- try {
340
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') call();
341
- else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) window.ezstandalone.cmd.push(call);
342
- } catch (e) {}
343
- }
264
+ function showAd(id) {
265
+ if (!id || EZOIC_BLOCKED) return;
344
266
 
345
- function callShowAdsWhenReady(id) {
346
- if (!id) return;
267
+ const now = Date.now();
268
+ const last = state.lastShowById.get(id) || 0;
269
+ if (now - last < 1500) return; // basic throttle
347
270
 
348
- // Throttle per-id.
349
- var now = Date.now();
350
- var last = state.lastShowById.get(id) || 0;
351
- if (now - last < 1200) return;
271
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
272
+ if (!ph || !ph.isConnected) return;
352
273
 
353
- if (state.pendingById.has(id)) return;
354
- state.pendingById.add(id);
355
274
  state.lastShowById.set(id, now);
356
275
 
357
- // Guard against re-entrancy.
358
- if (insertingIds.has(id)) {
359
- state.pendingById.delete(id);
360
- return;
361
- }
362
- insertingIds.add(id);
363
-
364
- var attempts = 0;
365
-
366
- (function waitForPh() {
367
- attempts++;
276
+ try {
277
+ window.ezstandalone = window.ezstandalone || {};
278
+ const ez = window.ezstandalone;
368
279
 
369
- // Navigation safety: if we navigated away, stop.
370
- if (!state.canShowAds) {
371
- state.pendingById.delete(id);
372
- insertingIds.delete(id);
280
+ // Fast path
281
+ if (typeof ez.showAds === 'function') {
282
+ ez.showAds(id);
283
+ sessionDefinedIds.add(id);
373
284
  return;
374
285
  }
375
286
 
376
- var ph = document.getElementById(PLACEHOLDER_PREFIX + id);
377
-
378
- var doCall = function () {
379
- try {
380
- // If placeholder is gone, stop.
381
- if (!ph || !ph.isConnected) return false;
382
- scheduleShowAdsBatch([id]);
383
- return true;
384
- } catch (e) {}
385
- return false;
386
- };
387
-
388
- if (ph && ph.isConnected) {
389
- doCall();
390
- state.pendingById.delete(id);
391
- insertingIds.delete(id);
392
- 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
+ });
393
300
  }
301
+ } catch (e) {}
302
+ }
394
303
 
395
- if (attempts < 100) {
396
- setTimeoutTracked(waitForPh, 50);
397
- return;
398
- }
304
+ // ---------- preload / above-the-fold ----------
399
305
 
400
- // Timeout: give up silently.
401
- state.pendingById.delete(id);
402
- insertingIds.delete(id);
403
- })();
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;
404
324
  }
405
325
 
406
- function initPools(cfg) {
407
- if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.placeholderIds);
408
- if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
409
- 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) {}
410
337
  }
411
338
 
339
+ // ---------- insertion logic ----------
340
+
412
341
  function computeTargets(count, interval, showFirst) {
413
- var out = [];
342
+ const out = [];
414
343
  if (count <= 0) return out;
415
-
416
344
  if (showFirst) out.push(1);
417
-
418
- for (var i = 1; i <= count; i++) {
345
+ for (let i = 1; i <= count; i++) {
419
346
  if (i % interval === 0) out.push(i);
420
347
  }
421
-
422
- // Unique + sorted.
423
- 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);
424
349
  }
425
350
 
426
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
427
- if (!items || !items.length) return 0;
351
+ function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
352
+ if (!items.length) return 0;
428
353
 
429
- var targets = computeTargets(items.length, interval, showFirst);
430
- var inserted = 0;
354
+ const targets = computeTargets(items.length, interval, showFirst);
355
+ let inserted = 0;
431
356
 
432
- for (var t = 0; t < targets.length; t++) {
433
- var afterPos = targets[t];
357
+ for (const afterPos of targets) {
434
358
  if (inserted >= MAX_INSERTS_PER_RUN) break;
435
359
 
436
- var el = items[afterPos - 1];
360
+ const el = items[afterPos - 1];
437
361
  if (!el || !el.isConnected) continue;
438
-
439
- // Prevent adjacent ads.
440
- if (isAdjacentAd(el) || isPrevAd(el)) continue;
441
-
442
- // Prevent duplicates at same logical position.
443
- if (findWrap(kindClass, afterPos - 1)) continue;
362
+ if (isAdjacentAd(el)) continue;
444
363
  if (findWrap(kindClass, afterPos)) continue;
445
364
 
446
- var pick = pickId(kindPool, liveArr);
447
- var id = pick.id;
365
+ const id = pickId(pool);
448
366
  if (!id) break;
449
367
 
450
- var wrap = null;
451
-
452
- if (pick.recycled && pick.recycled.wrap) {
453
- // Recycle: only destroy if Ezoic has actually defined this placeholder before.
454
- if (sessionDefinedIds.has(id)) destroyPlaceholderIds([id]);
455
-
456
- // Remove old wrapper.
457
- var oldWrap = pick.recycled.wrap;
458
- try { if (oldWrap && oldWrap.__ezoicFillObs) oldWrap.__ezoicFillObs.disconnect(); } catch (e) {}
459
- 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
+ }
460
375
 
461
- wrap = insertAfter(el, id, kindClass, afterPos);
462
- if (!wrap) continue;
376
+ observePlaceholder(id);
377
+ inserted += 1;
378
+ }
463
379
 
464
- // Give Ezoic a moment after DOM insertion.
465
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 700);
466
- } else {
467
- usedSet.add(id);
468
- wrap = insertAfter(el, id, kindClass, afterPos);
469
- if (!wrap) continue;
380
+ return inserted;
381
+ }
470
382
 
471
- // Micro-delay to allow layout/DOM settle.
472
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
473
- }
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
+ }
474
414
 
475
- liveArr.push({ id: id, wrap: wrap });
415
+ if (!items.length) return;
476
416
 
477
- // Final safety: if adjacency happened due to DOM shifts, rollback.
478
- var prev = wrap && wrap.previousElementSibling;
479
- var next = wrap && wrap.nextElementSibling;
480
- if (wrap && ((prev && prev.classList && prev.classList.contains(WRAP_CLASS)) || (next && next.classList && next.classList.contains(WRAP_CLASS)))) {
481
- 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; }
482
423
 
483
- if (!(pick.recycled && pick.recycled.wrap)) {
484
- try { kindPool.unshift(id); } catch (e) {}
485
- usedSet.delete(id);
486
- }
487
- continue;
488
- }
424
+ const id = pickId(pool);
425
+ if (!id) return;
489
426
 
490
- 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;
491
433
  }
492
434
 
493
- return inserted;
435
+ state.heroDoneForPage = true;
436
+ observePlaceholder(id);
494
437
  }
495
438
 
496
- function enforceNoAdjacentAds() {
497
- var ads = Array.from(document.querySelectorAll('.' + WRAP_CLASS));
498
- for (var i = 0; i < ads.length; i++) {
499
- var ad = ads[i];
500
- var prev = ad.previousElementSibling;
501
-
502
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
503
- // Remove adjacent wrapper (do not hide).
504
- try {
505
- var ph = ad.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
506
- if (ph) {
507
- var id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
508
- if (Number.isFinite(id) && id > 0 && sessionDefinedIds.has(id)) {
509
- destroyPlaceholderIds([id]);
510
- }
511
- }
512
- ad.remove();
513
- } 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
+ );
514
482
  }
515
483
  }
516
484
  }
517
485
 
518
- function cleanup() {
519
- // Stop any insertion during navigation / DOM teardown.
520
- state.canShowAds = false;
521
- state.poolWaitAttempts = 0;
522
- state.awaitItemsAttempts = 0;
523
-
524
- // Cancel any pending showAds timeouts.
525
- clearAllTrackedTimeouts();
526
-
527
- // Disconnect global observer to avoid mutations during teardown.
528
- if (state.obs) {
529
- try { state.obs.disconnect(); } catch (e) {}
530
- state.obs = null;
531
- }
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
+ }
532
496
 
533
- // Destroy placeholders that were used (only those that were actually defined).
534
- destroyUsedPlaceholders();
497
+ // ---------- observers / lifecycle ----------
535
498
 
536
- // Remove wrappers from DOM (safe because insertion is now blocked).
499
+ function cleanup() {
500
+ EZOIC_BLOCKED = true;
501
+
502
+ // remove all wrappers
537
503
  try {
538
- document.querySelectorAll('.' + WRAP_CLASS).forEach(function (el) {
539
- try { if (el && el.__ezoicFillObs) el.__ezoicFillObs.disconnect(); } catch (e) {}
504
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
540
505
  try { el.remove(); } catch (e) {}
541
506
  });
542
507
  } catch (e) {}
543
508
 
544
- // Reset runtime caches.
545
- state.pageKey = getPageKey();
509
+ // reset state
546
510
  state.cfg = null;
547
- state.cfgPromise = null;
548
-
549
511
  state.poolTopics = [];
550
512
  state.poolPosts = [];
551
513
  state.poolCategories = [];
552
-
553
514
  state.usedTopics.clear();
554
515
  state.usedPosts.clear();
555
516
  state.usedCategories.clear();
556
-
557
- state.liveTopics = [];
558
- state.livePosts = [];
559
- state.liveCategories = [];
560
-
561
517
  state.lastShowById.clear();
562
- state.pendingById.clear();
563
- insertingIds.clear();
518
+ state.heroDoneForPage = false;
564
519
 
565
- state.scheduled = false;
566
- if (state.timer) {
567
- try { clearTimeout(state.timer); } catch (e) {}
568
- state.timer = null;
569
- }
520
+ sessionDefinedIds.clear();
521
+
522
+ // keep observers alive (MutationObserver will re-trigger after navigation)
570
523
  }
571
524
 
572
- function ensureObserver() {
573
- if (state.obs) return;
525
+ function ensureDomObserver() {
526
+ if (state.domObs) return;
527
+ state.domObs = new MutationObserver(() => {
528
+ if (!EZOIC_BLOCKED) scheduleRun();
529
+ });
574
530
  try {
575
- state.obs = new MutationObserver(function () { scheduleRun('mutation'); });
576
- state.obs.observe(document.body, { childList: true, subtree: true });
531
+ state.domObs.observe(document.body, { childList: true, subtree: true });
577
532
  } catch (e) {}
578
533
  }
579
534
 
580
- function scheduleRun(/* reason */) {
581
- if (state.scheduled) return;
582
- state.scheduled = true;
583
-
584
- if (state.timer) {
585
- try { clearTimeout(state.timer); } catch (e) {}
586
- state.timer = null;
587
- }
588
-
589
- state.timer = setTimeoutTracked(function () {
590
- state.scheduled = false;
591
-
592
- // If user navigated away, stop.
593
- var pk = getPageKey();
594
- if (state.pageKey && pk !== state.pageKey) return;
595
-
596
- runCore().catch(function () {});
597
- }, 80);
598
- }
599
-
600
- function waitForItemsThenRun(kind) {
601
- // If list isn't in DOM yet (ajaxify transition), retry a bit.
602
- var count = 0;
603
- if (kind === 'topic') count = getPostContainers().length;
604
- else if (kind === 'categoryTopics') count = getTopicItems().length;
605
- else if (kind === 'categories') count = getCategoryItems().length;
606
-
607
- if (count > 0) return true;
608
-
609
- if (state.awaitItemsAttempts < 25) {
610
- state.awaitItemsAttempts++;
611
- setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
612
- }
613
- return false;
614
- }
615
-
616
- function waitForContentThenRun() {
617
- // Avoid inserting ads on pages with too little content.
618
- var MIN_WORDS = 250;
619
- var attempts = 0;
620
- var maxAttempts = 20; // 20 × 200ms = 4s
621
-
622
- (function check() {
623
- attempts++;
624
-
625
- var text = '';
626
- try { text = document.body.innerText || ''; } catch (e) {}
627
- var wordCount = text.split(/\s+/).filter(Boolean).length;
628
-
629
- if (wordCount >= MIN_WORDS) {
630
- scheduleRun('content-ok');
631
- return;
632
- }
633
-
634
- if (attempts >= maxAttempts) {
635
- scheduleRun('content-timeout');
636
- return;
637
- }
638
-
639
- setTimeoutTracked(check, 200);
640
- })();
641
- }
642
-
643
- function waitForEzoicThenRun() {
644
- var attempts = 0;
645
- var maxAttempts = 50; // 50 × 200ms = 10s
646
-
647
- (function check() {
648
- attempts++;
649
-
650
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
651
- scheduleRun('ezoic-ready');
652
- waitForContentThenRun();
653
- return;
654
- }
655
-
656
- if (attempts >= maxAttempts) {
657
- scheduleRun('ezoic-timeout');
658
- return;
659
- }
660
-
661
- setTimeoutTracked(check, 200);
662
- })();
663
- }
664
-
665
- function fetchConfig() {
666
- if (state.cfg) return Promise.resolve(state.cfg);
667
- if (state.cfgPromise) return state.cfgPromise;
668
-
669
- state.cfgPromise = (function () {
670
- var MAX_TRIES = 3;
671
- var delay = 800;
672
-
673
- function attemptFetch(attempt) {
674
- return fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' })
675
- .then(function (res) {
676
- if (!res || !res.ok) throw new Error('bad response');
677
- return res.json();
678
- })
679
- .then(function (json) {
680
- state.cfg = json;
681
- return json;
682
- })
683
- .catch(function () {
684
- if (attempt >= MAX_TRIES) return null;
685
- return new Promise(function (r) { setTimeoutTracked(r, delay); }).then(function () {
686
- delay *= 2;
687
- return attemptFetch(attempt + 1);
688
- });
689
- });
690
- }
691
-
692
- return attemptFetch(1).finally(function () { state.cfgPromise = null; });
693
- })();
694
-
695
- return state.cfgPromise;
696
- }
697
-
698
- function runCore() {
699
- // Navigation safety: never insert during ajaxify teardown.
700
- if (!state.canShowAds) return Promise.resolve();
701
-
702
- patchShowAds();
703
-
704
- return fetchConfig().then(function (cfg) {
705
- if (!cfg || cfg.excluded) return;
706
-
707
- initPools(cfg);
708
-
709
- var kind = getKind();
710
- var inserted = 0;
711
-
712
- if (!waitForItemsThenRun(kind)) return;
713
-
714
- if (kind === 'topic') {
715
- if (normalizeBool(cfg.enableMessageAds)) {
716
- inserted = injectBetween(
717
- 'ezoic-ad-message',
718
- getPostContainers(),
719
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
720
- normalizeBool(cfg.showFirstMessageAd),
721
- state.poolPosts,
722
- state.usedPosts,
723
- state.livePosts
724
- );
725
- }
726
- } else if (kind === 'categoryTopics') {
727
- if (normalizeBool(cfg.enableBetweenAds)) {
728
- inserted = injectBetween(
729
- 'ezoic-ad-between',
730
- getTopicItems(),
731
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
732
- normalizeBool(cfg.showFirstTopicAd),
733
- state.poolTopics,
734
- state.usedTopics,
735
- state.liveTopics
736
- );
737
- }
738
- } else if (kind === 'categories') {
739
- if (normalizeBool(cfg.enableCategoryAds)) {
740
- inserted = injectBetween(
741
- 'ezoic-ad-categories',
742
- getCategoryItems(),
743
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
744
- normalizeBool(cfg.showFirstCategoryAd),
745
- state.poolCategories,
746
- state.usedCategories,
747
- state.liveCategories
748
- );
749
- }
750
- }
751
-
752
- enforceNoAdjacentAds();
753
-
754
- // Recycling: if pool is exhausted, retry a few times to allow old wrappers to scroll off-screen.
755
- if (inserted === 0) {
756
- if (state.poolWaitAttempts < 8) {
757
- state.poolWaitAttempts++;
758
- setTimeoutTracked(function () { scheduleRun('pool-wait'); }, 400);
759
- }
760
- } else {
761
- // Reset pool wait attempts once we successfully insert something.
762
- state.poolWaitAttempts = 0;
763
- }
764
-
765
- // If we hit max inserts, continue quickly.
766
- if (inserted >= MAX_INSERTS_PER_RUN) {
767
- setTimeoutTracked(function () { scheduleRun('continue'); }, 140);
768
- }
769
- }).catch(function () {});
770
- }
771
-
772
- function bind() {
535
+ function bindNodeBB() {
773
536
  if (!$) return;
774
537
 
775
538
  $(window).off('.ezoicInfinite');
776
539
 
777
- $(window).on('action:ajaxify.start.ezoicInfinite', function () {
540
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
778
541
  cleanup();
779
542
  });
780
543
 
781
- $(window).on('action:ajaxify.end.ezoicInfinite', function () {
544
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
782
545
  state.pageKey = getPageKey();
783
- ensureObserver();
546
+ EZOIC_BLOCKED = false;
784
547
 
785
- // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
786
- setTimeoutTracked(function () {
787
- state.canShowAds = true;
788
- waitForEzoicThenRun();
789
- }, 300);
790
- });
548
+ warmUpNetwork();
549
+ patchShowAds();
550
+ ensurePreloadObserver();
551
+ ensureDomObserver();
791
552
 
792
- // Infinite-scroll and "loaded" events.
793
- $(window).on('action:category.loaded.ezoicInfinite', function () {
794
- ensureObserver();
795
- waitForContentThenRun();
796
- });
553
+ // Ultra-fast above-the-fold first
554
+ insertHeroAdEarly().catch(() => {});
797
555
 
798
- $(window).on('action:topics.loaded.ezoicInfinite', function () {
799
- ensureObserver();
800
- waitForContentThenRun();
556
+ // Then normal insertion
557
+ scheduleRun();
801
558
  });
802
559
 
803
- $(window).on('action:topic.loaded.ezoicInfinite', function () {
804
- ensureObserver();
805
- waitForContentThenRun();
806
- });
807
-
808
- $(window).on('action:posts.loaded.ezoicInfinite', function () {
809
- ensureObserver();
810
- 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();
811
564
  });
812
565
  }
813
566
 
814
567
  function bindScroll() {
815
- if (state.lastScrollRun > 0) return;
816
- state.lastScrollRun = Date.now();
817
-
818
- var ticking = false;
819
- window.addEventListener('scroll', function () {
568
+ let ticking = false;
569
+ window.addEventListener('scroll', () => {
820
570
  if (ticking) return;
821
571
  ticking = true;
822
-
823
- window.requestAnimationFrame(function () {
572
+ window.requestAnimationFrame(() => {
824
573
  ticking = false;
825
-
826
- enforceNoAdjacentAds();
827
-
828
- // Debounce scheduleRun (max once every 2s on scroll).
829
- var now = Date.now();
830
- if (!state.lastScrollRun || (now - state.lastScrollRun > 2000)) {
831
- state.lastScrollRun = now;
832
- scheduleRun('scroll');
833
- }
574
+ if (!EZOIC_BLOCKED) scheduleRun();
834
575
  });
835
576
  }, { passive: true });
836
577
  }
837
578
 
838
- // Boot.
839
- cleanup();
840
- bind();
841
- bindScroll();
842
- ensureObserver();
579
+ // ---------- boot ----------
843
580
 
844
581
  state.pageKey = getPageKey();
582
+ warmUpNetwork();
583
+ patchShowAds();
584
+ ensurePreloadObserver();
585
+ ensureDomObserver();
586
+
587
+ bindNodeBB();
588
+ bindScroll();
845
589
 
846
- // Direct page load: allow insertion after initial tick (no ajaxify.end).
847
- setTimeoutTracked(function () {
848
- state.canShowAds = true;
849
- waitForEzoicThenRun();
850
- }, 0);
590
+ // First paint: try hero + run
591
+ EZOIC_BLOCKED = false;
592
+ insertHeroAdEarly().catch(() => {});
593
+ scheduleRun();
851
594
  })();