nodebb-plugin-ezoic-infinite 1.5.48 → 1.5.50

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.
package/library.js CHANGED
@@ -27,13 +27,21 @@ async function getAllGroups() {
27
27
  }
28
28
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
29
  const data = await groups.getGroupsData(filtered);
30
- // Sort alphabetically for ACP usability
31
- data.sort((a, b) => String(a.name).localeCompare(String(b.name), 'fr', { sensitivity: 'base' }));
32
- return data;
30
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
+ const valid = data.filter(g => g && g.name);
32
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
+ return valid;
33
34
  }
35
+ let _settingsCache = null;
36
+ let _settingsCacheAt = 0;
37
+ const SETTINGS_TTL = 30000; // 30s
38
+
34
39
  async function getSettings() {
40
+ const now = Date.now();
41
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
35
42
  const s = await meta.settings.get(SETTINGS_KEY);
36
- return {
43
+ _settingsCacheAt = Date.now();
44
+ _settingsCache = {
37
45
  // Between-post ads (simple blocks) in category topic list
38
46
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
39
47
  showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
@@ -54,6 +62,7 @@ async function getSettings() {
54
62
 
55
63
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
56
64
  };
65
+ return _settingsCache;
57
66
  }
58
67
 
59
68
  async function isUserExcluded(uid, excludedGroups) {
@@ -62,6 +71,13 @@ async function isUserExcluded(uid, excludedGroups) {
62
71
  return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
63
72
  }
64
73
 
74
+ plugin.onSettingsSet = function (data) {
75
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
76
+ if (data && data.hash === SETTINGS_KEY) {
77
+ _settingsCache = null;
78
+ }
79
+ };
80
+
65
81
  plugin.addAdminNavigation = async (header) => {
66
82
  header.plugins = header.plugins || [];
67
83
  header.plugins.push({
@@ -89,7 +105,7 @@ plugin.init = async ({ router, middleware }) => {
89
105
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
90
106
  router.get('/api/admin/plugins/ezoic-infinite', render);
91
107
 
92
- router.get('/api/plugins/ezoic-infinite/config', middleware.buildHeader, async (req, res) => {
108
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
93
109
  const settings = await getSettings();
94
110
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
95
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.48",
3
+ "version": "1.5.50",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "hook": "filter:admin.header.build",
13
13
  "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "action:settings.set",
17
+ "method": "onSettingsSet"
14
18
  }
15
19
  ],
16
20
  "staticDirs": {
package/public/admin.js CHANGED
@@ -13,11 +13,10 @@
13
13
  e.preventDefault();
14
14
 
15
15
  Settings.save('ezoic-infinite', $form, function () {
16
- // Toast vert (NodeBB core)
17
16
  if (alerts && typeof alerts.success === 'function') {
18
- alerts.success('Enregistré');
17
+ alerts.success('[[admin/settings:saved]]');
19
18
  } else if (window.app && typeof window.app.alertSuccess === 'function') {
20
- window.app.alertSuccess('Enregistré');
19
+ window.app.alertSuccess('[[admin/settings:saved]]');
21
20
  }
22
21
  });
23
22
  });
package/public/client.js CHANGED
@@ -1,43 +1,177 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ // NodeBB client context
4
5
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
6
 
7
+ const WRAP_CLASS = 'ezoic-ad';
8
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
9
+
10
+ // Insert at most N ads per run to keep the UI smooth on infinite scroll
11
+ const MAX_INSERTS_PER_RUN = 3;
12
+
13
+ // Preload before viewport (earlier load for smoother scroll)
14
+ const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
15
+ const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
16
+
17
+ // When the user scrolls very fast, temporarily preload more aggressively.
18
+ // This helps ensure ads are already in-flight before the user reaches them.
19
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
20
+ const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
21
+ const BOOST_DURATION_MS = 2500;
22
+ const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
23
+
24
+ const MAX_INFLIGHT_DESKTOP = 4;
25
+ const MAX_INFLIGHT_MOBILE = 3;
26
+
27
+ function isBoosted() {
28
+ try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
29
+ }
30
+
31
+ function isMobile() {
32
+ try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
33
+ }
34
+
35
+ function getPreloadRootMargin() {
36
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
37
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
38
+ }
39
+
40
+ function getMaxInflight() {
41
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
42
+ return base + (isBoosted() ? 1 : 0);
43
+ }
44
+
6
45
  const SELECTORS = {
7
46
  topicItem: 'li[component="category/topic"]',
8
47
  postItem: '[component="post"][data-pid]',
9
48
  categoryItem: 'li[component="categories/category"]',
10
49
  };
11
50
 
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
- const MAX_INSERTS_PER_RUN = 3;
51
+ // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
52
+ let blockedUntil = 0;
53
+ function isBlocked() {
54
+ return Date.now() < blockedUntil;
55
+ }
15
56
 
16
57
  const state = {
17
58
  pageKey: null,
18
59
  cfg: null,
19
- cfgPromise: null,
20
- poolTopics: [],
21
- poolPosts: [],
22
- poolCategories: [],
23
- usedTopics: new Set(),
24
- usedPosts: new Set(),
25
- usedCategories: new Set(),
60
+
61
+ // Full lists (never consumed) + cursors for round-robin reuse
62
+ allTopics: [],
63
+ allPosts: [],
64
+ allCategories: [],
65
+ curTopics: 0,
66
+ curPosts: 0,
67
+ curCategories: 0,
68
+
69
+ // throttle per placeholder id
26
70
  lastShowById: new Map(),
27
- canShowAds: false,
28
- scheduled: false,
29
- timer: null,
30
- obs: null,
71
+ internalDomChange: 0,
72
+ lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
73
+
74
+ // track placeholders that have been shown at least once in this pageview
75
+ usedOnce: new Set(),
76
+
77
+ // observers / schedulers
78
+ domObs: null,
79
+ io: null,
80
+ runQueued: false,
81
+
82
+ // preloading budget
83
+ inflight: 0,
84
+ pending: [],
85
+ pendingSet: new Set(),
86
+
87
+ // fast scroll boosting
88
+ scrollBoostUntil: 0,
89
+ lastScrollY: 0,
90
+ lastScrollTs: 0,
91
+ ioMargin: null,
92
+
93
+ // hero)
94
+ heroDoneForPage: false,
31
95
  };
32
96
 
33
- const sessionDefinedIds = new Set();
97
+ const insertingIds = new Set();
98
+
99
+ // ---------- lightweight "fade-in" for ad iframes ----------
100
+ // This reduces the perception of "flashing" when a slot appears empty then fills.
101
+ // We avoid scroll listeners and only react to DOM insertions.
102
+ const _faded = new WeakSet();
103
+ function _fadeInIframe(iframe) {
104
+ try {
105
+ if (!iframe || _faded.has(iframe)) return;
106
+ _faded.add(iframe);
107
+ iframe.style.opacity = '0';
108
+ iframe.style.transition = 'opacity 140ms ease';
109
+ // Next frame: show
110
+ requestAnimationFrame(() => {
111
+ try { iframe.style.opacity = '1'; } catch (e) {}
112
+ });
113
+ } catch (e) {}
114
+ }
115
+
116
+ const _adFillObserver = new MutationObserver((muts) => {
117
+ try {
118
+ for (const m of muts) {
119
+ if (m.addedNodes && m.addedNodes.length) {
120
+ for (const n of m.addedNodes) {
121
+ if (!n || n.nodeType !== 1) continue;
122
+ if (n.tagName === 'IFRAME') {
123
+ const w = n.closest && n.closest(`.${WRAP_CLASS}`);
124
+ if (w) _fadeInIframe(n);
125
+ continue;
126
+ }
127
+ // If a subtree is added, look for iframes inside ad wrappers only.
128
+ const ifs = n.querySelectorAll ? n.querySelectorAll(`.${WRAP_CLASS} iframe`) : null;
129
+ if (ifs && ifs.length) ifs.forEach(_fadeInIframe);
130
+ }
131
+ }
132
+ }
133
+ } catch (e) {}
134
+ });
135
+
136
+ try {
137
+ _adFillObserver.observe(document.documentElement, { subtree: true, childList: true });
138
+ } catch (e) {}
139
+
140
+
141
+
142
+ function markEmptyWrapper(id) {
143
+ try {
144
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
145
+ if (!ph || !ph.isConnected) return;
146
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
147
+ if (!wrap) return;
148
+ // If still empty after a delay, collapse it.
149
+ setTimeout(() => {
150
+ try {
151
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
152
+ if (!ph2 || !ph2.isConnected) return;
153
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
154
+ if (!w2) return;
155
+ // consider empty if only whitespace and no iframes/ins/img
156
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
157
+ if (!hasAd) w2.classList.add('is-empty');
158
+ } catch (e) {}
159
+ }, 3500);
160
+ } catch (e) {}
161
+ }
162
+
163
+ // Production build: debug disabled
164
+ function dbg() {}
165
+
166
+ // ---------- small utils ----------
34
167
 
35
168
  function normalizeBool(v) {
36
169
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
37
170
  }
38
171
 
39
172
  function uniqInts(lines) {
40
- const out = [], seen = new Set();
173
+ const out = [];
174
+ const seen = new Set();
41
175
  for (const v of lines) {
42
176
  const n = parseInt(v, 10);
43
177
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
@@ -50,7 +184,10 @@
50
184
 
51
185
  function parsePool(raw) {
52
186
  if (!raw) return [];
53
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
187
+ const lines = String(raw)
188
+ .split(/\r?\n/)
189
+ .map(s => s.trim())
190
+ .filter(Boolean);
54
191
  return uniqInts(lines);
55
192
  }
56
193
 
@@ -70,6 +207,11 @@
70
207
  if (/^\/topic\//.test(p)) return 'topic';
71
208
  if (/^\/category\//.test(p)) return 'categoryTopics';
72
209
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
210
+
211
+ // fallback by DOM
212
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
213
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
214
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
73
215
  return 'other';
74
216
  }
75
217
 
@@ -77,76 +219,234 @@
77
219
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
78
220
  }
79
221
 
222
+ function getCategoryItems() {
223
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
224
+ }
225
+
80
226
  function getPostContainers() {
81
- return Array.from(document.querySelectorAll(SELECTORS.postItem));
227
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
228
+ return nodes.filter((el) => {
229
+ if (!el || !el.isConnected) return false;
230
+ if (!el.querySelector('[component="post/content"]')) return false;
231
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
232
+ if (parentPost && parentPost !== el) return false;
233
+ if (el.getAttribute('component') === 'post/parent') return false;
234
+ return true;
235
+ });
82
236
  }
83
237
 
84
- function getCategoryItems() {
85
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
238
+ // ---------- warm-up & patching ----------
239
+
240
+ const _warmLinksDone = new Set();
241
+ function warmUpNetwork() {
242
+ try {
243
+ const head = document.head || document.getElementsByTagName('head')[0];
244
+ if (!head) return;
245
+ const links = [
246
+ ['preconnect', 'https://g.ezoic.net', true],
247
+ ['dns-prefetch', 'https://g.ezoic.net', false],
248
+ ['preconnect', 'https://go.ezoic.net', true],
249
+ ['dns-prefetch', 'https://go.ezoic.net', false],
250
+
251
+ // Google ad stack (helps Safeframe/GPT warm up)
252
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
253
+ ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
254
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
255
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
256
+ ['preconnect', 'https://tpc.googlesyndication.com', true],
257
+ ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
258
+ ['preconnect', 'https://googleads.g.doubleclick.net', true],
259
+ ['dns-prefetch', 'https://googleads.g.doubleclick.net', false],
260
+ ['preconnect', 'https://static.doubleclick.net', true],
261
+ ['dns-prefetch', 'https://static.doubleclick.net', false],
262
+ ];
263
+ for (const [rel, href, cors] of links) {
264
+ const key = `${rel}|${href}`;
265
+ if (_warmLinksDone.has(key)) continue;
266
+ _warmLinksDone.add(key);
267
+ const link = document.createElement('link');
268
+ link.rel = rel;
269
+ link.href = href;
270
+ if (cors) link.crossOrigin = 'anonymous';
271
+ head.appendChild(link);
272
+ }
273
+ } catch (e) {}
86
274
  }
87
275
 
88
- async function fetchConfig() {
89
- if (state.cfg) return state.cfg;
90
- if (state.cfgPromise) return state.cfgPromise;
276
+ // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
277
+ function patchShowAds() {
278
+ const applyPatch = () => {
279
+ try {
280
+ window.ezstandalone = window.ezstandalone || {};
281
+ const ez = window.ezstandalone;
282
+ if (window.__nodebbEzoicPatched) return;
283
+ if (typeof ez.showAds !== 'function') return;
284
+
285
+ window.__nodebbEzoicPatched = true;
286
+ const orig = ez.showAds;
287
+
288
+ ez.showAds = function (...args) {
289
+ if (isBlocked()) return;
290
+
291
+ let ids = [];
292
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
293
+ else ids = args;
294
+
295
+ const seen = new Set();
296
+ for (const v of ids) {
297
+ const id = parseInt(v, 10);
298
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
299
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
300
+ if (!ph || !ph.isConnected) continue;
301
+ seen.add(id);
302
+ try { orig.call(ez, id); } catch (e) {}
303
+ }
304
+ };
305
+ } catch (e) {}
306
+ };
91
307
 
92
- state.cfgPromise = (async () => {
308
+ applyPatch();
309
+ if (!window.__nodebbEzoicPatched) {
93
310
  try {
94
- const res = await fetch('/api/ezoic-infinite/config');
95
- if (!res.ok) return null;
96
- const cfg = await res.json();
97
- state.cfg = cfg;
98
- return cfg;
99
- } catch (e) {
100
- return null;
101
- }
102
- })();
311
+ window.ezstandalone = window.ezstandalone || {};
312
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
313
+ window.ezstandalone.cmd.push(applyPatch);
314
+ } catch (e) {}
315
+ }
316
+ }
103
317
 
104
- return state.cfgPromise;
318
+ const RECYCLE_COOLDOWN_MS = 1500;
319
+
320
+ function kindKeyFromClass(kindClass) {
321
+ if (kindClass === 'ezoic-ad-message') return 'topic';
322
+ if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
323
+ if (kindClass === 'ezoic-ad-categories') return 'categories';
324
+ return 'topic';
325
+ }
326
+
327
+ function withInternalDomChange(fn) {
328
+ state.internalDomChange++;
329
+ try { fn(); } finally { state.internalDomChange--; }
330
+ }
331
+
332
+ function canRecycle(kind) {
333
+ const now = Date.now();
334
+ const last = state.lastRecycleAt[kind] || 0;
335
+ if (now - last < RECYCLE_COOLDOWN_MS) return false;
336
+ state.lastRecycleAt[kind] = now;
337
+ return true;
338
+ }
339
+ // ---------- config & pools ----------
340
+
341
+ async function fetchConfigOnce() {
342
+ if (state.cfg) return state.cfg;
343
+ try {
344
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
345
+ if (!res.ok) return null;
346
+ state.cfg = await res.json();
347
+ return state.cfg;
348
+ } catch (e) {
349
+ return null;
350
+ }
105
351
  }
106
352
 
107
353
  function initPools(cfg) {
108
- if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.poolTopics);
109
- if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.poolPosts);
110
- if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.poolCategories);
354
+ if (!cfg) return;
355
+ if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
356
+ if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
357
+ if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
111
358
  }
112
359
 
113
- function destroyPlaceholderIds(ids) {
114
- if (!ids || !ids.length) return;
115
- const filtered = ids.filter(id => Number.isFinite(id) && id > 0);
116
- if (!filtered.length) return;
360
+ // ---------- insertion primitives ----------
361
+
362
+ function isAdjacentAd(target) {
363
+ if (!target) return false;
364
+ const next = target.nextElementSibling;
365
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
366
+ const prev = target.previousElementSibling;
367
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
368
+ return false;
369
+ }
117
370
 
371
+
372
+ function getWrapIdFromWrap(wrap) {
118
373
  try {
119
- window.ezstandalone = window.ezstandalone || {};
120
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
121
-
122
- const call = () => {
123
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
124
- window.ezstandalone.destroyPlaceholders(filtered);
125
- }
126
- };
374
+ const v = wrap.getAttribute('data-ezoic-wrapid');
375
+ if (v) return String(v);
376
+ const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
377
+ if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
378
+ } catch (e) {}
379
+ return null;
380
+ }
127
381
 
128
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
129
- call();
130
- } else {
131
- window.ezstandalone.cmd.push(call);
382
+ function safeDestroyById(id) {
383
+ try {
384
+ const ez = window.ezstandalone;
385
+ if (ez && typeof ez.destroyPlaceholders === 'function') {
386
+ ez.destroyPlaceholders([`${PLACEHOLDER_PREFIX}${id}`]);
132
387
  }
133
-
134
- // Recyclage: libérer après 100ms
135
- setTimeout(() => {
136
- filtered.forEach(id => sessionDefinedIds.delete(id));
137
- }, 100);
138
388
  } catch (e) {}
139
389
  }
140
390
 
141
- function buildWrap(id, kindClass, afterPos) {
391
+ function pruneOrphanWraps(kindClass, items) {
392
+ if (!items || !items.length) return 0;
393
+ const itemSet = new Set(items);
394
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
395
+ let removed = 0;
396
+
397
+ wraps.forEach((wrap) => {
398
+ // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
399
+ let ok = false;
400
+ let prev = wrap.previousElementSibling;
401
+ for (let i = 0; i < 3 && prev; i++) {
402
+ if (itemSet.has(prev)) { ok = true; break; }
403
+ prev = prev.previousElementSibling;
404
+ }
405
+
406
+ if (!ok) {
407
+ const id = getWrapIdFromWrap(wrap);
408
+ withInternalDomChange(() => {
409
+ try {
410
+ if (id) safeDestroyById(id);
411
+ wrap.remove();
412
+ } catch (e) {}
413
+ });
414
+ removed++;
415
+ }
416
+ });
417
+
418
+ if (removed) dbg('prune-orphan', kindClass, { removed });
419
+ return removed;
420
+ }
421
+
422
+ function refreshEmptyState(id) {
423
+ // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
424
+ window.setTimeout(() => {
425
+ try {
426
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
427
+ if (!ph || !ph.isConnected) return;
428
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
429
+ if (!wrap) return;
430
+ const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
431
+ if (hasContent) wrap.classList.remove('is-empty');
432
+ else wrap.classList.add('is-empty');
433
+ } catch (e) {}
434
+ }, 3500);
435
+ }
436
+
437
+ function buildWrap(id, kindClass, afterPos) {
142
438
  const wrap = document.createElement('div');
143
439
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
144
440
  wrap.setAttribute('data-ezoic-after', String(afterPos));
441
+ wrap.setAttribute('data-ezoic-wrapid', String(id));
442
+ wrap.setAttribute('data-ezoic-ts', String(Date.now()));
145
443
  wrap.style.width = '100%';
146
444
 
147
445
  const ph = document.createElement('div');
148
446
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
447
+ ph.setAttribute('data-ezoic-id', String(id));
149
448
  wrap.appendChild(ph);
449
+
150
450
  return wrap;
151
451
  }
152
452
 
@@ -157,215 +457,514 @@
157
457
  function insertAfter(target, id, kindClass, afterPos) {
158
458
  if (!target || !target.insertAdjacentElement) return null;
159
459
  if (findWrap(kindClass, afterPos)) return null;
460
+ if (insertingIds.has(id)) return null;
461
+
462
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
463
+ if (existingPh && existingPh.isConnected) return null;
160
464
 
465
+ insertingIds.add(id);
161
466
  try {
162
467
  const wrap = buildWrap(id, kindClass, afterPos);
163
468
  target.insertAdjacentElement('afterend', wrap);
164
469
  return wrap;
470
+ } finally {
471
+ insertingIds.delete(id);
472
+ }
473
+ }
474
+
475
+ function pickIdFromAll(allIds, cursorKey) {
476
+ const n = allIds.length;
477
+ if (!n) return null;
478
+
479
+ // Try at most n ids to find one that's not already in the DOM
480
+ for (let tries = 0; tries < n; tries++) {
481
+ const idx = state[cursorKey] % n;
482
+ state[cursorKey] = (state[cursorKey] + 1) % n;
483
+
484
+ const id = allIds[idx];
485
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
486
+ if (ph && ph.isConnected) continue;
487
+
488
+ return id;
489
+ }
490
+ return null;
491
+ }
492
+
493
+
494
+ function removeOneOldWrap(kindClass) {
495
+ try {
496
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
497
+ if (!wraps.length) return false;
498
+
499
+ const now = Date.now();
500
+
501
+ // Only recycle wraps that are clearly out of view AND old enough.
502
+ // This avoids the "unstable" feeling where ads disappear when the user scrolls back a bit.
503
+ const MIN_AGE_MS = 20000; // 20s
504
+ const OFFSCREEN_PX = -5000; // far above viewport
505
+
506
+ let victim = null;
507
+ for (const w of wraps) {
508
+ const r = w.getBoundingClientRect();
509
+ const ts = parseInt(w.getAttribute('data-ezoic-ts') || '0', 10);
510
+ const ageOk = !ts || (now - ts) >= MIN_AGE_MS;
511
+ if (ageOk && r.bottom < OFFSCREEN_PX) { victim = w; break; }
512
+ }
513
+
514
+ // If nothing is eligible, do not recycle. We'll simply skip inserting new ads this run.
515
+ if (!victim) return false;
516
+
517
+ // Unobserve placeholder if still observed
518
+ try {
519
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
520
+ if (ph && state.io) state.io.unobserve(ph);
521
+ } catch (e) {}
522
+
523
+ victim.remove();
524
+ return true;
165
525
  } catch (e) {
166
- return null;
526
+ return false;
167
527
  }
168
528
  }
169
529
 
170
- function pickId(pool) {
171
- if (!pool || !pool.length) return null;
172
- return pool.shift();
530
+ function enqueueShow(id) {
531
+ if (!id || isBlocked()) return;
532
+
533
+ // Basic per-id throttle (prevents rapid re-requests when DOM churns)
534
+ const now = Date.now();
535
+ const last = state.lastShowById.get(id) || 0;
536
+ if (now - last < 900) return;
537
+
538
+ const max = getMaxInflight();
539
+ if (state.inflight >= max) {
540
+ if (!state.pendingSet.has(id)) {
541
+ state.pending.push(id);
542
+ state.pendingSet.add(id);
543
+ }
544
+ return;
173
545
  }
546
+ startShow(id);
547
+ }
548
+
549
+ function drainQueue() {
550
+ if (isBlocked()) return;
551
+ const max = getMaxInflight();
552
+ while (state.inflight < max && state.pending.length) {
553
+ const id = state.pending.shift();
554
+ state.pendingSet.delete(id);
555
+ startShow(id);
556
+ }
557
+ }
558
+
559
+ function startShow(id) {
560
+ if (!id || isBlocked()) return;
561
+
562
+ state.inflight++;
563
+ let released = false;
564
+ const release = () => {
565
+ if (released) return;
566
+ released = true;
567
+ state.inflight = Math.max(0, state.inflight - 1);
568
+ drainQueue();
569
+ };
174
570
 
175
- function callShowAds(id) {
176
- if (!id) return;
177
-
571
+ const hardTimer = setTimeout(release, 6500);
572
+
573
+ requestAnimationFrame(() => {
178
574
  try {
575
+ if (isBlocked()) return;
576
+
577
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
578
+ if (!ph || !ph.isConnected) return;
579
+
580
+ const now2 = Date.now();
581
+ const last2 = state.lastShowById.get(id) || 0;
582
+ if (now2 - last2 < 900) return;
583
+ state.lastShowById.set(id, now2);
584
+
179
585
  window.ezstandalone = window.ezstandalone || {};
180
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
181
-
182
- window.ezstandalone.cmd.push(function() {
183
- if (typeof window.ezstandalone.showAds === 'function') {
184
- window.ezstandalone.showAds(id);
185
- sessionDefinedIds.add(id);
586
+ const ez = window.ezstandalone;
587
+
588
+ const doShow = () => {
589
+ try {
590
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
591
+ try { ez.destroyPlaceholders(id); } catch (e) {}
592
+ }
593
+ } catch (e) {}
594
+
595
+ try { ez.showAds(id); } catch (e) {}
596
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
597
+ try { markEmptyWrapper(id); } catch (e) {}
598
+
599
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
600
+ };
601
+
602
+ if (Array.isArray(ez.cmd)) {
603
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
604
+ } else {
605
+ doShow();
606
+ }
607
+ } finally {
608
+ // If we returned early, hardTimer will release.
609
+ }
610
+ });
611
+ }
612
+
613
+
614
+ // ---------- preload / above-the-fold ----------
615
+
616
+ function ensurePreloadObserver() {
617
+ const desiredMargin = getPreloadRootMargin();
618
+ if (state.io && state.ioMargin === desiredMargin) return state.io;
619
+
620
+ // Rebuild IO if margin changed (e.g., scroll boost toggled)
621
+ if (state.io) {
622
+ try { state.io.disconnect(); } catch (e) {}
623
+ state.io = null;
624
+ }
625
+ try {
626
+ state.io = new IntersectionObserver((entries) => {
627
+ for (const ent of entries) {
628
+ if (!ent.isIntersecting) continue;
629
+ const el = ent.target;
630
+ try { state.io && state.io.unobserve(el); } catch (e) {}
631
+
632
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
633
+ const id = parseInt(idAttr, 10);
634
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
186
635
  }
187
- });
636
+ }, { root: null, rootMargin: desiredMargin, threshold: 0 });
637
+ state.ioMargin = desiredMargin;
638
+ } catch (e) {
639
+ state.io = null;
640
+ state.ioMargin = null;
641
+ }
642
+
643
+ // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
644
+ try {
645
+ if (state.io) {
646
+ const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
647
+ nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
648
+ }
188
649
  } catch (e) {}
650
+ return state.io;
189
651
  }
190
652
 
191
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
192
- if (!items || items.length === 0) return 0;
653
+ function observePlaceholder(id) {
654
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
655
+ if (!ph || !ph.isConnected) return;
656
+ const io = ensurePreloadObserver();
657
+ try { io && io.observe(ph); } catch (e) {}
193
658
 
194
- let inserted = 0;
195
- const targets = [];
659
+ // If already above fold, fire immediately
660
+ try {
661
+ const r = ph.getBoundingClientRect();
662
+ const screens = isBoosted() ? 5.0 : 3.0;
663
+ const minBottom = isBoosted() ? -1500 : -800;
664
+ if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
665
+ } catch (e) {}
666
+ }
196
667
 
197
- for (let i = 0; i < items.length; i++) {
198
- const afterPos = i + 1;
199
- if (afterPos === 1 && !showFirst) continue;
200
- if (afterPos % interval !== (showFirst ? 1 : 0)) continue;
201
- targets.push(afterPos);
668
+ // ---------- insertion logic ----------
669
+
670
+ function computeTargets(count, interval, showFirst) {
671
+ const out = [];
672
+ if (count <= 0) return out;
673
+ if (showFirst) out.push(1);
674
+ for (let i = 1; i <= count; i++) {
675
+ if (i % interval === 0) out.push(i);
202
676
  }
677
+ return Array.from(new Set(out)).sort((a, b) => a - b);
678
+ }
679
+
680
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
681
+ if (!items.length) return 0;
682
+
683
+ const targets = computeTargets(items.length, interval, showFirst);
684
+ let inserted = 0;
685
+ const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
203
686
 
204
687
  for (const afterPos of targets) {
205
- if (inserted >= MAX_INSERTS_PER_RUN) break;
688
+ if (inserted >= maxInserts) break;
206
689
 
207
690
  const el = items[afterPos - 1];
208
691
  if (!el || !el.isConnected) continue;
692
+ if (isAdjacentAd(el)) continue;
209
693
  if (findWrap(kindClass, afterPos)) continue;
210
694
 
211
- const id = pickId(pool);
212
- if (!id) break;
213
-
695
+ let id = pickIdFromAll(allIds, cursorKey);
696
+ if (!id) {
697
+ // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
698
+ // Guard against tight observer loops.
699
+ if (!canRecycle(kindKeyFromClass(kindClass))) {
700
+ dbg('recycle-skip-cooldown', kindClass);
701
+ break;
702
+ }
703
+ let recycled = false;
704
+ withInternalDomChange(() => {
705
+ recycled = removeOneOldWrap(kindClass);
706
+ });
707
+ dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
708
+ // Stop this run after a recycle; the next mutation/scroll will retry injection.
709
+ break;
710
+ }
214
711
  const wrap = insertAfter(el, id, kindClass, afterPos);
215
- if (!wrap) continue;
712
+ if (!wrap) {
713
+ continue;
714
+ }
216
715
 
217
- usedSet.add(id);
218
- callShowAds(id);
716
+ observePlaceholder(id);
219
717
  inserted += 1;
220
718
  }
221
719
 
222
720
  return inserted;
223
721
  }
224
722
 
225
- async function runCore() {
226
- if (!state.canShowAds) return;
227
-
228
- const cfg = await fetchConfig();
229
- if (!cfg || cfg.excluded) return;
723
+ async function insertHeroAdEarly() {
724
+ if (state.heroDoneForPage) return;
725
+ const cfg = await fetchConfigOnce();
726
+ if (!cfg) { dbg('no-config'); return; }
727
+ if (cfg.excluded) { dbg('excluded'); return; }
230
728
 
231
729
  initPools(cfg);
232
730
 
233
731
  const kind = getKind();
234
- let inserted = 0;
732
+ let items = [];
733
+ let allIds = [];
734
+ let cursorKey = '';
735
+ let kindClass = '';
736
+ let showFirst = false;
235
737
 
236
738
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
237
- inserted = injectBetween(
238
- 'ezoic-ad-message',
239
- getPostContainers(),
240
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
241
- normalizeBool(cfg.showFirstMessageAd),
242
- state.poolPosts,
243
- state.usedPosts
244
- );
739
+ items = getPostContainers();
740
+ allIds = state.allPosts;
741
+ cursorKey = 'curPosts';
742
+ kindClass = 'ezoic-ad-message';
743
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
245
744
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
246
- inserted = injectBetween(
247
- 'ezoic-ad-between',
248
- getTopicItems(),
249
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
250
- normalizeBool(cfg.showFirstTopicAd),
251
- state.poolTopics,
252
- state.usedTopics
253
- );
745
+ items = getTopicItems();
746
+ allIds = state.allTopics;
747
+ cursorKey = 'curTopics';
748
+ kindClass = 'ezoic-ad-between';
749
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
254
750
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
255
- inserted = injectBetween(
256
- 'ezoic-ad-category',
257
- getCategoryItems(),
258
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 6),
259
- normalizeBool(cfg.showFirstCategoryAd),
260
- state.poolCategories,
261
- state.usedCategories
262
- );
751
+ items = getCategoryItems();
752
+ allIds = state.allCategories;
753
+ cursorKey = 'curCategories';
754
+ kindClass = 'ezoic-ad-categories';
755
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
756
+ } else {
757
+ return;
263
758
  }
759
+
760
+ if (!items.length) return;
761
+ if (!showFirst) { state.heroDoneForPage = true; return; }
762
+
763
+ // Insert after the very first item (above-the-fold)
764
+ const afterPos = 1;
765
+ const el = items[afterPos - 1];
766
+ if (!el || !el.isConnected) return;
767
+ if (isAdjacentAd(el)) return;
768
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
769
+
770
+ const id = pickIdFromAll(allIds, cursorKey);
771
+ if (!id) return;
772
+
773
+ const wrap = insertAfter(el, id, kindClass, afterPos);
774
+ if (!wrap) {
775
+ return;
776
+ }
777
+
778
+ state.heroDoneForPage = true;
779
+ observePlaceholder(id);
264
780
  }
265
781
 
266
- function scheduleRun() {
267
- if (state.scheduled) return;
268
- state.scheduled = true;
782
+ async function runCore() {
783
+ if (isBlocked()) { dbg('blocked'); return; }
784
+
785
+ patchShowAds();
786
+
787
+ const cfg = await fetchConfigOnce();
788
+ if (!cfg) { dbg('no-config'); return; }
789
+ if (cfg.excluded) { dbg('excluded'); return; }
790
+ initPools(cfg);
791
+
792
+ const kind = getKind();
269
793
 
270
- clearTimeout(state.timer);
271
- state.timer = setTimeout(() => {
272
- state.scheduled = false;
794
+ if (kind === 'topic') {
795
+ if (normalizeBool(cfg.enableMessageAds)) {
796
+ const __items = getPostContainers();
797
+ pruneOrphanWraps('ezoic-ad-message', __items);
798
+ injectBetween(
799
+ 'ezoic-ad-message',
800
+ __items,
801
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
802
+ normalizeBool(cfg.showFirstMessageAd),
803
+ state.allPosts,
804
+ 'curPosts'
805
+ );
806
+ }
807
+ } else if (kind === 'categoryTopics') {
808
+ if (normalizeBool(cfg.enableBetweenAds)) {
809
+ const __items = getTopicItems();
810
+ pruneOrphanWraps('ezoic-ad-between', __items);
811
+ injectBetween(
812
+ 'ezoic-ad-between',
813
+ __items,
814
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
815
+ normalizeBool(cfg.showFirstTopicAd),
816
+ state.allTopics,
817
+ 'curTopics'
818
+ );
819
+ }
820
+ } else if (kind === 'categories') {
821
+ if (normalizeBool(cfg.enableCategoryAds)) {
822
+ const __items = getCategoryItems();
823
+ pruneOrphanWraps('ezoic-ad-categories', __items);
824
+ injectBetween(
825
+ 'ezoic-ad-categories',
826
+ __items,
827
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
828
+ normalizeBool(cfg.showFirstCategoryAd),
829
+ state.allCategories,
830
+ 'curCategories'
831
+ );
832
+ }
833
+ }
834
+ }
835
+
836
+ function scheduleRun() {
837
+ if (state.runQueued) return;
838
+ state.runQueued = true;
839
+ window.requestAnimationFrame(() => {
840
+ state.runQueued = false;
273
841
  const pk = getPageKey();
274
842
  if (state.pageKey && pk !== state.pageKey) return;
275
843
  runCore().catch(() => {});
276
- }, 50);
844
+ });
277
845
  }
278
846
 
847
+ // ---------- observers / lifecycle ----------
848
+
279
849
  function cleanup() {
280
- const allIds = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
281
- if (allIds.length) destroyPlaceholderIds(allIds);
850
+ blockedUntil = Date.now() + 1200;
282
851
 
283
- document.querySelectorAll('.ezoic-ad').forEach(el => {
284
- try { el.remove(); } catch (e) {}
285
- });
852
+ // remove all wrappers
853
+ try {
854
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
855
+ try { el.remove(); } catch (e) {}
856
+ });
857
+ } catch (e) {}
286
858
 
287
- state.pageKey = getPageKey();
859
+ // reset state
288
860
  state.cfg = null;
289
- state.cfgPromise = null;
290
- state.poolTopics = [];
291
- state.poolPosts = [];
292
- state.poolCategories = [];
293
- state.usedTopics.clear();
294
- state.usedPosts.clear();
295
- state.usedCategories.clear();
861
+ state.allTopics = [];
862
+ state.allPosts = [];
863
+ state.allCategories = [];
864
+ state.curTopics = 0;
865
+ state.curPosts = 0;
866
+ state.curCategories = 0;
296
867
  state.lastShowById.clear();
297
- sessionDefinedIds.clear();
868
+ state.inflight = 0;
869
+ state.pending = [];
870
+ try { state.pendingSet && state.pendingSet.clear(); } catch (e) {}
871
+ try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
872
+ state.heroDoneForPage = false;
298
873
 
299
- if (state.obs) {
300
- state.obs.disconnect();
301
- state.obs = null;
302
- }
303
-
304
- state.scheduled = false;
305
- clearTimeout(state.timer);
306
- state.timer = null;
874
+ // keep observers alive (MutationObserver will re-trigger after navigation)
307
875
  }
308
876
 
309
- function ensureObserver() {
310
- if (state.obs) return;
311
- state.obs = new MutationObserver(() => scheduleRun());
877
+ function ensureDomObserver() {
878
+ if (state.domObs) return;
879
+ state.domObs = new MutationObserver(() => {
880
+ if (state.internalDomChange > 0) return;
881
+ if (!isBlocked()) scheduleRun();
882
+ });
312
883
  try {
313
- state.obs.observe(document.body, { childList: true, subtree: true });
884
+ state.domObs.observe(document.body, { childList: true, subtree: true });
314
885
  } catch (e) {}
315
886
  }
316
887
 
317
- function waitForContentThenRun() {
318
- const kind = getKind();
319
- let selector = SELECTORS.postItem;
320
- if (kind === 'categoryTopics') selector = SELECTORS.topicItem;
321
- else if (kind === 'categories') selector = SELECTORS.categoryItem;
322
-
323
- const check = () => {
324
- if (document.querySelector(selector)) {
325
- scheduleRun();
326
- } else {
327
- setTimeout(check, 200);
328
- }
329
- };
330
- check();
331
- }
332
-
333
- function bind() {
888
+ function bindNodeBB() {
334
889
  if (!$) return;
335
890
 
336
891
  $(window).off('.ezoicInfinite');
337
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
892
+
893
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
894
+ cleanup();
895
+ });
896
+
338
897
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
339
898
  state.pageKey = getPageKey();
340
- ensureObserver();
341
- state.canShowAds = true;
342
- scheduleRun();
343
- });
899
+ blockedUntil = 0;
900
+
901
+ warmUpNetwork();
902
+ patchShowAds();
903
+ ensurePreloadObserver();
904
+ ensureDomObserver();
905
+
906
+ // Ultra-fast above-the-fold first
907
+ insertHeroAdEarly().catch(() => {});
344
908
 
345
- $(window).on('action:category.loaded.ezoicInfinite', () => {
346
- ensureObserver();
347
- waitForContentThenRun();
909
+ // Then normal insertion
910
+ scheduleRun();
348
911
  });
349
912
 
350
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
351
- ensureObserver();
352
- waitForContentThenRun();
913
+ // Infinite scroll / partial updates
914
+ $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
915
+ if (isBlocked()) return;
916
+ scheduleRun();
353
917
  });
354
918
  }
355
919
 
356
- function init() {
357
- state.pageKey = getPageKey();
358
- state.canShowAds = true;
359
- bind();
360
- ensureObserver();
361
- waitForContentThenRun();
920
+ function bindScroll() {
921
+ let ticking = false;
922
+ window.addEventListener('scroll', () => {
923
+ // Detect very fast scrolling and temporarily boost preload/parallelism.
924
+ try {
925
+ const now = Date.now();
926
+ const y = window.scrollY || window.pageYOffset || 0;
927
+ if (state.lastScrollTs) {
928
+ const dt = now - state.lastScrollTs;
929
+ const dy = Math.abs(y - (state.lastScrollY || 0));
930
+ if (dt > 0) {
931
+ const speed = dy / dt; // px/ms
932
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
933
+ const wasBoosted = isBoosted();
934
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
935
+ if (!wasBoosted) {
936
+ // margin changed -> rebuild IO so existing placeholders get earlier preload
937
+ ensurePreloadObserver();
938
+ }
939
+ }
940
+ }
941
+ }
942
+ state.lastScrollY = y;
943
+ state.lastScrollTs = now;
944
+ } catch (e) {}
945
+
946
+ if (ticking) return;
947
+ ticking = true;
948
+ window.requestAnimationFrame(() => {
949
+ ticking = false;
950
+ if (!isBlocked()) scheduleRun();
951
+ });
952
+ }, { passive: true });
362
953
  }
363
954
 
364
- if ($ && $(document).ready) {
365
- $(document).ready(init);
366
- } else if (document.readyState === 'loading') {
367
- document.addEventListener('DOMContentLoaded', init);
368
- } else {
369
- init();
370
- }
955
+ // ---------- boot ----------
956
+
957
+ state.pageKey = getPageKey();
958
+ warmUpNetwork();
959
+ patchShowAds();
960
+ ensurePreloadObserver();
961
+ ensureDomObserver();
962
+
963
+ bindNodeBB();
964
+ bindScroll();
965
+
966
+ // First paint: try hero + run
967
+ blockedUntil = 0;
968
+ insertHeroAdEarly().catch(() => {});
969
+ scheduleRun();
371
970
  })();
package/public/style.css CHANGED
@@ -1,11 +1,46 @@
1
+ /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
1
2
  .ezoic-ad {
3
+ display: block;
4
+ width: 100%;
5
+ margin: 0 !important;
6
+ padding: 0 !important;
7
+ overflow: hidden;
8
+ }
9
+
10
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
+ margin: 0 !important;
2
12
  padding: 0 !important;
13
+ min-height: 1px; /* keeps placeholder measurable for IO */
14
+ }
15
+
16
+ /* Ezoic sometimes wraps in extra spans/divs with margins */
17
+ .ezoic-ad span.ezoic-ad,
18
+ .ezoic-ad .ezoic-ad {
3
19
  margin: 0 !important;
20
+ padding: 0 !important;
21
+ }
22
+
23
+
24
+ /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
25
+ .ezoic-ad.is-empty {
26
+ display: block !important;
27
+ margin: 0 !important;
28
+ padding: 0 !important;
29
+ height: 0 !important;
4
30
  min-height: 0 !important;
5
- min-width: 0 !important;
31
+ overflow: hidden !important;
6
32
  }
7
33
 
8
- .ezoic-ad * {
34
+ .ezoic-ad {
9
35
  min-height: 0 !important;
10
- min-width: 0 !important;
36
+ }
37
+
38
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
39
+ min-height: 0 !important;
40
+ }
41
+
42
+ /* Avoid baseline gap under iframes (can look like extra space) */
43
+ .ezoic-ad iframe {
44
+ display: block !important;
45
+ vertical-align: top !important;
11
46
  }