nodebb-plugin-ezoic-infinite 1.5.67 → 1.5.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.67",
3
+ "version": "1.5.69",
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
@@ -5,63 +5,41 @@
5
5
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
6
6
 
7
7
  // IMPORTANT: must NOT collide with Ezoic's own markup (they use `.ezoic-ad`).
8
- // If we reuse that class, cleanup/pruning can delete real ads and cause
9
- // "placeholder does not exist" spam + broken 1/X insertion.
10
8
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
11
9
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
10
+ const POOL_ID = 'nodebb-ezoic-placeholder-pool';
12
11
 
13
- // Insert at most N ads per run to keep the UI smooth on infinite scroll
12
+ // Smoothness caps
14
13
  const MAX_INSERTS_PER_RUN = 3;
15
14
 
16
- // Preload before viewport (earlier load for smoother scroll)
15
+ // Preload margins
17
16
  const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
18
17
  const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
19
-
20
- // When the user scrolls very fast, temporarily preload more aggressively.
21
- // This helps ensure ads are already in-flight before the user reaches them.
22
18
  const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
23
19
  const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
20
+
24
21
  const BOOST_DURATION_MS = 2500;
25
22
  const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
26
23
 
27
24
  const MAX_INFLIGHT_DESKTOP = 4;
28
25
  const MAX_INFLIGHT_MOBILE = 3;
29
26
 
30
- function isBoosted() {
31
- try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
32
- }
33
-
34
- function isMobile() {
35
- try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
36
- }
37
-
38
- function getPreloadRootMargin() {
39
- if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
40
- return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
41
- }
42
-
43
- function getMaxInflight() {
44
- const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
45
- return base + (isBoosted() ? 1 : 0);
46
- }
47
-
48
27
  const SELECTORS = {
49
28
  topicItem: 'li[component="category/topic"]',
50
29
  postItem: '[component="post"][data-pid]',
51
30
  categoryItem: 'li[component="categories/category"]',
52
31
  };
53
32
 
54
- // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
55
- let blockedUntil = 0;
56
- function isBlocked() {
57
- return Date.now() < blockedUntil;
58
- }
33
+ // Production build: debug disabled
34
+ function dbg() {}
35
+
36
+ // ---------------- state ----------------
59
37
 
60
38
  const state = {
61
39
  pageKey: null,
62
40
  cfg: null,
63
41
 
64
- // Full lists (never consumed) + cursors for round-robin reuse
42
+ // pools (full lists) + cursors
65
43
  allTopics: [],
66
44
  allPosts: [],
67
45
  allCategories: [],
@@ -69,63 +47,46 @@
69
47
  curPosts: 0,
70
48
  curCategories: 0,
71
49
 
72
- // throttle per placeholder id
50
+ // per-id throttle
73
51
  lastShowById: new Map(),
74
- internalDomChange: 0,
75
- lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
76
-
77
- // track placeholders that have been shown at least once in this pageview
78
- usedOnce: new Set(),
79
52
 
80
53
  // observers / schedulers
81
54
  domObs: null,
82
55
  io: null,
83
- runQueued: false,
56
+ ioMargin: null,
57
+
58
+ // internal mutations guard
59
+ internalDomChange: 0,
84
60
 
85
61
  // preloading budget
86
62
  inflight: 0,
87
63
  pending: [],
88
64
  pendingSet: new Set(),
89
65
 
90
- // fast scroll boosting
66
+ // scroll boost
91
67
  scrollBoostUntil: 0,
92
68
  lastScrollY: 0,
93
69
  lastScrollTs: 0,
94
- ioMargin: null,
95
70
 
96
- // hero)
71
+ // hero
97
72
  heroDoneForPage: false,
98
- burstRuns: 0,
73
+
74
+ // run scheduler
75
+ runQueued: false,
76
+ burstActive: false,
77
+ burstDeadline: 0,
78
+ burstCount: 0,
79
+ lastBurstReqTs: 0,
99
80
  };
100
81
 
82
+ // Soft block during navigation / heavy DOM churn
83
+ let blockedUntil = 0;
101
84
  const insertingIds = new Set();
102
85
 
86
+ function now() { return Date.now(); }
87
+ function isBlocked() { return now() < blockedUntil; }
103
88
 
104
- function markEmptyWrapper(id) {
105
- try {
106
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
107
- if (!ph || !ph.isConnected) return;
108
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
109
- if (!wrap) return;
110
- // If still empty after a delay, collapse it.
111
- setTimeout(() => {
112
- try {
113
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
114
- if (!ph2 || !ph2.isConnected) return;
115
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
116
- if (!w2) return;
117
- // consider empty if only whitespace and no iframes/ins/img
118
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
119
- if (!hasAd) w2.classList.add('is-empty');
120
- } catch (e) {}
121
- }, 3500);
122
- } catch (e) {}
123
- }
124
-
125
- // Production build: debug disabled
126
- function dbg() {}
127
-
128
- // ---------- small utils ----------
89
+ // ---------------- utils ----------------
129
90
 
130
91
  function normalizeBool(v) {
131
92
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
@@ -164,19 +125,31 @@
164
125
  return window.location.pathname;
165
126
  }
166
127
 
167
- function getKind() {
168
- const p = window.location.pathname || '';
169
- if (/^\/topic\//.test(p)) return 'topic';
170
- if (/^\/category\//.test(p)) return 'categoryTopics';
171
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
128
+ function isMobile() {
129
+ try { return window.innerWidth < 768; } catch (e) { return false; }
130
+ }
172
131
 
173
- // fallback by DOM
174
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
175
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
176
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
177
- return 'other';
132
+ function isBoosted() {
133
+ return now() < (state.scrollBoostUntil || 0);
134
+ }
135
+
136
+ function getPreloadRootMargin() {
137
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
138
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
139
+ }
140
+
141
+ function getMaxInflight() {
142
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
143
+ return base + (isBoosted() ? 1 : 0);
178
144
  }
179
145
 
146
+ function withInternalDomChange(fn) {
147
+ state.internalDomChange++;
148
+ try { fn(); } finally { state.internalDomChange--; }
149
+ }
150
+
151
+ // ---------------- DOM helpers ----------------
152
+
180
153
  function getTopicItems() {
181
154
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
182
155
  }
@@ -197,7 +170,59 @@
197
170
  });
198
171
  }
199
172
 
200
- // ---------- warm-up & patching ----------
173
+ function getKind() {
174
+ const p = window.location.pathname || '';
175
+ if (/^\/topic\//.test(p)) return 'topic';
176
+ if (/^\/category\//.test(p)) return 'categoryTopics';
177
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
178
+
179
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
180
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
181
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
182
+ return 'other';
183
+ }
184
+
185
+ function isAdjacentAd(target) {
186
+ if (!target) return false;
187
+ const next = target.nextElementSibling;
188
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
189
+ const prev = target.previousElementSibling;
190
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
191
+ return false;
192
+ }
193
+
194
+ function findWrap(kindClass, afterPos) {
195
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
196
+ }
197
+
198
+ // ---------------- placeholder pool ----------------
199
+
200
+ function getPoolEl() {
201
+ let el = document.getElementById(POOL_ID);
202
+ if (el) return el;
203
+ el = document.createElement('div');
204
+ el.id = POOL_ID;
205
+ el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
206
+ (document.body || document.documentElement).appendChild(el);
207
+ return el;
208
+ }
209
+
210
+ function isInPool(ph) {
211
+ try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
212
+ }
213
+
214
+ function releaseWrapNode(wrap) {
215
+ try {
216
+ const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
217
+ if (ph) {
218
+ try { getPoolEl().appendChild(ph); } catch (e) {}
219
+ try { state.io && state.io.unobserve(ph); } catch (e) {}
220
+ }
221
+ } catch (e) {}
222
+ try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
223
+ }
224
+
225
+ // ---------------- network warmup ----------------
201
226
 
202
227
  const _warmLinksDone = new Set();
203
228
  function warmUpNetwork() {
@@ -205,10 +230,18 @@
205
230
  const head = document.head || document.getElementsByTagName('head')[0];
206
231
  if (!head) return;
207
232
  const links = [
233
+ // Ezoic
208
234
  ['preconnect', 'https://g.ezoic.net', true],
209
235
  ['dns-prefetch', 'https://g.ezoic.net', false],
210
236
  ['preconnect', 'https://go.ezoic.net', true],
211
237
  ['dns-prefetch', 'https://go.ezoic.net', false],
238
+ // Google ads
239
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
240
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
241
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
242
+ ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
243
+ ['preconnect', 'https://tpc.googlesyndication.com', true],
244
+ ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
212
245
  ];
213
246
  for (const [rel, href, cors] of links) {
214
247
  const key = `${rel}|${href}`;
@@ -223,7 +256,9 @@
223
256
  } catch (e) {}
224
257
  }
225
258
 
226
- // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
259
+ // ---------------- Ezoic bridge ----------------
260
+
261
+ // Patch showAds to silently skip ids not in DOM. This prevents console spam.
227
262
  function patchShowAds() {
228
263
  const applyPatch = () => {
229
264
  try {
@@ -265,28 +300,7 @@
265
300
  }
266
301
  }
267
302
 
268
- const RECYCLE_COOLDOWN_MS = 1500;
269
-
270
- function kindKeyFromClass(kindClass) {
271
- if (kindClass === 'ezoic-ad-message') return 'topic';
272
- if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
273
- if (kindClass === 'ezoic-ad-categories') return 'categories';
274
- return 'topic';
275
- }
276
-
277
- function withInternalDomChange(fn) {
278
- state.internalDomChange++;
279
- try { fn(); } finally { state.internalDomChange--; }
280
- }
281
-
282
- function canRecycle(kind) {
283
- const now = Date.now();
284
- const last = state.lastRecycleAt[kind] || 0;
285
- if (now - last < RECYCLE_COOLDOWN_MS) return false;
286
- state.lastRecycleAt[kind] = now;
287
- return true;
288
- }
289
- // ---------- config & pools ----------
303
+ // ---------------- config ----------------
290
304
 
291
305
  async function fetchConfigOnce() {
292
306
  if (state.cfg) return state.cfg;
@@ -302,107 +316,30 @@ function withInternalDomChange(fn) {
302
316
 
303
317
  function initPools(cfg) {
304
318
  if (!cfg) return;
305
- if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
306
- if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
307
- if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
308
- }
309
-
310
- // ---------- insertion primitives ----------
311
-
312
- function isAdjacentAd(target) {
313
- if (!target) return false;
314
- const next = target.nextElementSibling;
315
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
316
- const prev = target.previousElementSibling;
317
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
318
- return false;
319
- }
320
-
321
-
322
- function getWrapIdFromWrap(wrap) {
323
- try {
324
- const v = wrap.getAttribute('data-ezoic-wrapid');
325
- if (v) return String(v);
326
- const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
327
- if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
328
- } catch (e) {}
329
- return null;
330
- }
331
-
332
- function safeDestroyById(id) {
333
- // IMPORTANT:
334
- // Do NOT call ez.destroyPlaceholders here.
335
- // In NodeBB ajaxify/infinite-scroll flows, Ezoic can be mid-refresh.
336
- // Destroy calls can create churn, reduce fill, and generate "does not exist" spam.
337
- // We only remove our wrapper; Ezoic manages slot lifecycle.
338
- return;
339
- }
340
-
341
- function pruneOrphanWraps(kindClass, items) {
342
- if (!items || !items.length) return 0;
343
- const itemSet = new Set(items);
344
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
345
- let removed = 0;
346
-
347
- wraps.forEach((wrap) => {
348
- // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
349
- let ok = false;
350
- let prev = wrap.previousElementSibling;
351
- for (let i = 0; i < 3 && prev; i++) {
352
- if (itemSet.has(prev)) { ok = true; break; }
353
- prev = prev.previousElementSibling;
354
- }
355
-
356
- if (!ok) {
357
- const id = getWrapIdFromWrap(wrap);
358
- withInternalDomChange(() => {
359
- try {
360
- if (id) safeDestroyById(id);
361
- wrap.remove();
362
- } catch (e) {}
363
- });
364
- removed++;
365
- }
366
- });
367
-
368
- if (removed) dbg('prune-orphan', kindClass, { removed });
369
- return removed;
319
+ if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
320
+ if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
321
+ if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
370
322
  }
371
323
 
372
- function refreshEmptyState(id) {
373
- // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
374
- window.setTimeout(() => {
375
- try {
376
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
377
- if (!ph || !ph.isConnected) return;
378
- const wrap = ph.closest(`.${WRAP_CLASS}`);
379
- if (!wrap) return;
380
- const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
381
- if (hasContent) wrap.classList.remove('is-empty');
382
- else wrap.classList.add('is-empty');
383
- } catch (e) {}
384
- }, 3500);
385
- }
324
+ // ---------------- insertion primitives ----------------
386
325
 
387
- function buildWrap(id, kindClass, afterPos) {
326
+ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
388
327
  const wrap = document.createElement('div');
389
328
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
390
329
  wrap.setAttribute('data-ezoic-after', String(afterPos));
391
330
  wrap.setAttribute('data-ezoic-wrapid', String(id));
392
331
  wrap.style.width = '100%';
393
332
 
394
- const ph = document.createElement('div');
395
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
396
- ph.setAttribute('data-ezoic-id', String(id));
397
- wrap.appendChild(ph);
333
+ if (createPlaceholder) {
334
+ const ph = document.createElement('div');
335
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
336
+ ph.setAttribute('data-ezoic-id', String(id));
337
+ wrap.appendChild(ph);
338
+ }
398
339
 
399
340
  return wrap;
400
341
  }
401
342
 
402
- function findWrap(kindClass, afterPos) {
403
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
404
- }
405
-
406
343
  function insertAfter(target, id, kindClass, afterPos) {
407
344
  if (!target || !target.insertAdjacentElement) return null;
408
345
  if (findWrap(kindClass, afterPos)) return null;
@@ -412,20 +349,18 @@ function buildWrap(id, kindClass, afterPos) {
412
349
 
413
350
  insertingIds.add(id);
414
351
  try {
415
- const wrap = buildWrap(id, kindClass, afterPos);
352
+ const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
416
353
  target.insertAdjacentElement('afterend', wrap);
417
354
 
418
- // If a placeholder with this id already exists elsewhere (some Ezoic flows
419
- // pre-create placeholders), move it into our wrapper instead of aborting.
420
- // replaceChild moves the node atomically (no detach window).
421
- if (existingPh && existingPh !== wrap.firstElementChild) {
355
+ // If placeholder exists elsewhere (including pool), move it into the wrapper.
356
+ if (existingPh) {
422
357
  try {
423
358
  existingPh.setAttribute('data-ezoic-id', String(id));
424
- wrap.replaceChild(existingPh, wrap.firstElementChild);
425
- } catch (e) {
426
- // Keep the new placeholder if replace fails.
427
- }
359
+ if (!wrap.firstElementChild) wrap.appendChild(existingPh);
360
+ else wrap.replaceChild(existingPh, wrap.firstElementChild);
361
+ } catch (e) {}
428
362
  }
363
+
429
364
  return wrap;
430
365
  } finally {
431
366
  insertingIds.delete(id);
@@ -436,142 +371,69 @@ function buildWrap(id, kindClass, afterPos) {
436
371
  const n = allIds.length;
437
372
  if (!n) return null;
438
373
 
439
- // Try at most n ids to find one that's not already in the DOM
440
374
  for (let tries = 0; tries < n; tries++) {
441
375
  const idx = state[cursorKey] % n;
442
376
  state[cursorKey] = (state[cursorKey] + 1) % n;
443
-
444
377
  const id = allIds[idx];
378
+
445
379
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
446
- if (ph && ph.isConnected) continue;
380
+ if (ph && ph.isConnected && !isInPool(ph)) continue;
447
381
 
448
382
  return id;
449
383
  }
384
+
450
385
  return null;
451
386
  }
452
387
 
388
+ function pruneOrphanWraps(kindClass, items) {
389
+ if (!items || !items.length) return 0;
390
+ const itemSet = new Set(items);
391
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
392
+ let removed = 0;
453
393
 
454
- function removeOneOldWrap(kindClass) {
455
- try {
456
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
457
- if (!wraps.length) return false;
458
-
459
- // Prefer a wrap far above the viewport
460
- let victim = null;
461
- for (const w of wraps) {
462
- const r = w.getBoundingClientRect();
463
- if (r.bottom < -2000) { victim = w; break; }
394
+ wraps.forEach((wrap) => {
395
+ let ok = false;
396
+ let prev = wrap.previousElementSibling;
397
+ for (let i = 0; i < 3 && prev; i++) {
398
+ if (itemSet.has(prev)) { ok = true; break; }
399
+ prev = prev.previousElementSibling;
464
400
  }
465
- // Otherwise remove the earliest one in the document
466
- if (!victim) victim = wraps[0];
467
-
468
- // Unobserve placeholder if still observed
469
- try {
470
- const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
471
- if (ph && state.io) state.io.unobserve(ph);
472
- } catch (e) {}
473
-
474
- victim.remove();
475
- return true;
476
- } catch (e) {
477
- return false;
478
- }
479
- }
480
401
 
481
- function enqueueShow(id) {
482
- if (!id || isBlocked()) return;
483
-
484
- // Basic per-id throttle (prevents rapid re-requests when DOM churns)
485
- const now = Date.now();
486
- const last = state.lastShowById.get(id) || 0;
487
- if (now - last < 900) return;
488
-
489
- const max = getMaxInflight();
490
- if (state.inflight >= max) {
491
- if (!state.pendingSet.has(id)) {
492
- state.pending.push(id);
493
- state.pendingSet.add(id);
494
- }
495
- return;
496
- }
497
- startShow(id);
498
- }
402
+ if (!ok) {
403
+ withInternalDomChange(() => releaseWrapNode(wrap));
404
+ removed++;
405
+ }
406
+ });
499
407
 
500
- function drainQueue() {
501
- if (isBlocked()) return;
502
- const max = getMaxInflight();
503
- while (state.inflight < max && state.pending.length) {
504
- const id = state.pending.shift();
505
- state.pendingSet.delete(id);
506
- startShow(id);
408
+ return removed;
507
409
  }
508
- }
509
-
510
- function startShow(id) {
511
- if (!id || isBlocked()) return;
512
410
 
513
- state.inflight++;
514
- let released = false;
515
- const release = () => {
516
- if (released) return;
517
- released = true;
518
- state.inflight = Math.max(0, state.inflight - 1);
519
- drainQueue();
520
- };
521
-
522
- const hardTimer = setTimeout(release, 6500);
523
-
524
- requestAnimationFrame(() => {
525
- try {
526
- if (isBlocked()) return;
527
-
528
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
529
- if (!ph || !ph.isConnected) return;
530
-
531
- const now2 = Date.now();
532
- const last2 = state.lastShowById.get(id) || 0;
533
- if (now2 - last2 < 900) return;
534
- state.lastShowById.set(id, now2);
535
-
536
- window.ezstandalone = window.ezstandalone || {};
537
- const ez = window.ezstandalone;
538
-
539
- const doShow = () => {
540
- // Do NOT call destroyPlaceholders here.
541
- // In ajaxify + infinite scroll flows, Ezoic can be in the middle of a refresh cycle.
542
- // Calling destroy on active placeholders is a common source of:
543
- // - "HTML element ... does not exist"
544
- // - "Placeholder Id ... already been defined"
545
- // Prefer a straight showAds; Ezoic will refresh as needed.
546
- try { ez.showAds(id); } catch (e) {}
547
- try { markEmptyWrapper(id); } catch (e) {}
548
-
549
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
550
- };
551
-
552
- if (Array.isArray(ez.cmd)) {
553
- try { ez.cmd.push(doShow); } catch (e) { doShow(); }
554
- } else {
555
- doShow();
411
+ function decluster(kindClass) {
412
+ // Remove consecutive wraps (keep the first)
413
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
414
+ if (wraps.length < 2) return 0;
415
+ let removed = 0;
416
+ for (const w of wraps) {
417
+ const prev = w.previousElementSibling;
418
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
419
+ withInternalDomChange(() => releaseWrapNode(w));
420
+ removed++;
556
421
  }
557
- } finally {
558
- // If we returned early, hardTimer will release.
559
422
  }
560
- });
561
- }
562
-
423
+ return removed;
424
+ }
563
425
 
564
- // ---------- preload / above-the-fold ----------
426
+ // ---------------- show (preload / fast fill) ----------------
565
427
 
566
428
  function ensurePreloadObserver() {
567
429
  const desiredMargin = getPreloadRootMargin();
568
430
  if (state.io && state.ioMargin === desiredMargin) return state.io;
569
431
 
570
- // Rebuild IO if margin changed (e.g., scroll boost toggled)
571
432
  if (state.io) {
572
433
  try { state.io.disconnect(); } catch (e) {}
573
434
  state.io = null;
574
435
  }
436
+
575
437
  try {
576
438
  state.io = new IntersectionObserver((entries) => {
577
439
  for (const ent of entries) {
@@ -584,29 +446,33 @@ function startShow(id) {
584
446
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
585
447
  }
586
448
  }, { root: null, rootMargin: desiredMargin, threshold: 0 });
449
+
587
450
  state.ioMargin = desiredMargin;
588
451
  } catch (e) {
589
452
  state.io = null;
590
453
  state.ioMargin = null;
591
454
  }
592
455
 
593
- // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
456
+ // Re-observe current placeholders
594
457
  try {
595
458
  if (state.io) {
596
459
  const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
597
460
  nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
598
461
  }
599
462
  } catch (e) {}
463
+
600
464
  return state.io;
601
465
  }
602
466
 
603
467
  function observePlaceholder(id) {
604
468
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
605
469
  if (!ph || !ph.isConnected) return;
470
+ ph.setAttribute('data-ezoic-id', String(id));
471
+
606
472
  const io = ensurePreloadObserver();
607
473
  try { io && io.observe(ph); } catch (e) {}
608
474
 
609
- // If already above fold, fire immediately
475
+ // If already near viewport, fire immediately.
610
476
  try {
611
477
  const r = ph.getBoundingClientRect();
612
478
  const screens = isBoosted() ? 5.0 : 3.0;
@@ -615,7 +481,104 @@ function startShow(id) {
615
481
  } catch (e) {}
616
482
  }
617
483
 
618
- // ---------- insertion logic ----------
484
+ function enqueueShow(id) {
485
+ if (!id || isBlocked()) return;
486
+
487
+ // per-id throttle
488
+ const t = now();
489
+ const last = state.lastShowById.get(id) || 0;
490
+ if (t - last < 900) return;
491
+
492
+ const max = getMaxInflight();
493
+ if (state.inflight >= max) {
494
+ if (!state.pendingSet.has(id)) {
495
+ state.pending.push(id);
496
+ state.pendingSet.add(id);
497
+ }
498
+ return;
499
+ }
500
+
501
+ startShow(id);
502
+ }
503
+
504
+ function drainQueue() {
505
+ if (isBlocked()) return;
506
+ const max = getMaxInflight();
507
+ while (state.inflight < max && state.pending.length) {
508
+ const id = state.pending.shift();
509
+ state.pendingSet.delete(id);
510
+ startShow(id);
511
+ }
512
+ }
513
+
514
+ function markEmptyWrapper(id) {
515
+ // If still empty after delay, mark empty for CSS (1px)
516
+ try {
517
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
518
+ if (!ph || !ph.isConnected) return;
519
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
520
+ if (!wrap) return;
521
+
522
+ setTimeout(() => {
523
+ try {
524
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
525
+ if (!ph2 || !ph2.isConnected) return;
526
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
527
+ if (!w2) return;
528
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
529
+ if (!hasAd) w2.classList.add('is-empty');
530
+ } catch (e) {}
531
+ }, 3500);
532
+ } catch (e) {}
533
+ }
534
+
535
+ function startShow(id) {
536
+ if (!id || isBlocked()) return;
537
+
538
+ state.inflight++;
539
+ let released = false;
540
+ const release = () => {
541
+ if (released) return;
542
+ released = true;
543
+ state.inflight = Math.max(0, state.inflight - 1);
544
+ drainQueue();
545
+ };
546
+
547
+ const hardTimer = setTimeout(release, 6500);
548
+
549
+ requestAnimationFrame(() => {
550
+ try {
551
+ if (isBlocked()) return;
552
+
553
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
554
+ if (!ph || !ph.isConnected) return;
555
+
556
+ const t = now();
557
+ const last = state.lastShowById.get(id) || 0;
558
+ if (t - last < 900) return;
559
+ state.lastShowById.set(id, t);
560
+
561
+ window.ezstandalone = window.ezstandalone || {};
562
+ const ez = window.ezstandalone;
563
+
564
+ const doShow = () => {
565
+ try { ez.showAds(id); } catch (e) {}
566
+ try { markEmptyWrapper(id); } catch (e) {}
567
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
568
+ };
569
+
570
+ if (Array.isArray(ez.cmd)) {
571
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
572
+ } else {
573
+ doShow();
574
+ }
575
+ } finally {
576
+ // hardTimer releases on early return
577
+ }
578
+ });
579
+ }
580
+
581
+ // ---------------- core injection ----------------
619
582
 
620
583
  function computeTargets(count, interval, showFirst) {
621
584
  const out = [];
@@ -624,16 +587,8 @@ function startShow(id) {
624
587
  for (let i = 1; i <= count; i++) {
625
588
  if (i % interval === 0) out.push(i);
626
589
  }
590
+ // unique + sorted
627
591
  return Array.from(new Set(out)).sort((a, b) => a - b);
628
- // If we inserted the maximum batch, likely there are more targets.
629
- // Schedule a follow-up pass (bounded via scheduleRun coalescing).
630
- const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
631
- if (insertedThisRun >= maxInserts) {
632
- scheduleRun(120);
633
- scheduleRun(420);
634
- }
635
- }
636
-
637
592
  }
638
593
 
639
594
  function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
@@ -651,43 +606,87 @@ function startShow(id) {
651
606
  if (isAdjacentAd(el)) continue;
652
607
  if (findWrap(kindClass, afterPos)) continue;
653
608
 
654
- let id = pickIdFromAll(allIds, cursorKey);
655
- if (!id) {
656
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
657
- // Guard against tight observer loops.
658
- if (!canRecycle(kindKeyFromClass(kindClass))) {
659
- dbg('recycle-skip-cooldown', kindClass);
660
- break;
661
- }
662
- let recycled = false;
663
- withInternalDomChange(() => {
664
- recycled = removeOneOldWrap(kindClass);
665
- });
666
- dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
667
- // Stop this run after a recycle; the next mutation/scroll will retry injection.
668
- break;
669
- }
609
+ const id = pickIdFromAll(allIds, cursorKey);
610
+ if (!id) break;
611
+
670
612
  const wrap = insertAfter(el, id, kindClass, afterPos);
671
- if (!wrap) {
672
- continue;
673
- }
613
+ if (!wrap) continue;
674
614
 
675
615
  observePlaceholder(id);
676
- inserted += 1;
616
+ inserted++;
677
617
  }
678
618
 
679
619
  return inserted;
680
620
  }
681
621
 
682
- async function insertHeroAdEarly() {
683
- if (state.heroDoneForPage) return;
622
+ async function runCore() {
623
+ if (isBlocked()) return 0;
624
+
625
+ patchShowAds();
626
+
684
627
  const cfg = await fetchConfigOnce();
685
- if (!cfg) { dbg('no-config'); return; }
686
- if (cfg.excluded) { dbg('excluded'); return; }
628
+ if (!cfg || cfg.excluded) return 0;
629
+ initPools(cfg);
630
+
631
+ const kind = getKind();
632
+ let inserted = 0;
687
633
 
634
+ if (kind === 'topic') {
635
+ if (normalizeBool(cfg.enableMessageAds)) {
636
+ const items = getPostContainers();
637
+ pruneOrphanWraps('ezoic-ad-message', items);
638
+ inserted += injectBetween(
639
+ 'ezoic-ad-message',
640
+ items,
641
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
642
+ normalizeBool(cfg.showFirstMessageAd),
643
+ state.allPosts,
644
+ 'curPosts'
645
+ );
646
+ decluster('ezoic-ad-message');
647
+ }
648
+ } else if (kind === 'categoryTopics') {
649
+ if (normalizeBool(cfg.enableBetweenAds)) {
650
+ const items = getTopicItems();
651
+ pruneOrphanWraps('ezoic-ad-between', items);
652
+ inserted += injectBetween(
653
+ 'ezoic-ad-between',
654
+ items,
655
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
656
+ normalizeBool(cfg.showFirstTopicAd),
657
+ state.allTopics,
658
+ 'curTopics'
659
+ );
660
+ decluster('ezoic-ad-between');
661
+ }
662
+ } else if (kind === 'categories') {
663
+ if (normalizeBool(cfg.enableCategoryAds)) {
664
+ const items = getCategoryItems();
665
+ pruneOrphanWraps('ezoic-ad-categories', items);
666
+ inserted += injectBetween(
667
+ 'ezoic-ad-categories',
668
+ items,
669
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
670
+ normalizeBool(cfg.showFirstCategoryAd),
671
+ state.allCategories,
672
+ 'curCategories'
673
+ );
674
+ decluster('ezoic-ad-categories');
675
+ }
676
+ }
677
+
678
+ return inserted;
679
+ }
680
+
681
+ async function insertHeroAdEarly() {
682
+ if (state.heroDoneForPage || isBlocked()) return;
683
+
684
+ const cfg = await fetchConfigOnce();
685
+ if (!cfg || cfg.excluded) return;
688
686
  initPools(cfg);
689
687
 
690
688
  const kind = getKind();
689
+
691
690
  let items = [];
692
691
  let allIds = [];
693
692
  let cursorKey = '';
@@ -719,9 +718,8 @@ function startShow(id) {
719
718
  if (!items.length) return;
720
719
  if (!showFirst) { state.heroDoneForPage = true; return; }
721
720
 
722
- // Insert after the very first item (above-the-fold)
723
721
  const afterPos = 1;
724
- const el = items[afterPos - 1];
722
+ const el = items[0];
725
723
  if (!el || !el.isConnected) return;
726
724
  if (isAdjacentAd(el)) return;
727
725
  if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
@@ -730,119 +728,76 @@ function startShow(id) {
730
728
  if (!id) return;
731
729
 
732
730
  const wrap = insertAfter(el, id, kindClass, afterPos);
733
- if (!wrap) {
734
- return;
735
- }
731
+ if (!wrap) return;
736
732
 
737
733
  state.heroDoneForPage = true;
738
734
  observePlaceholder(id);
739
735
  }
740
736
 
741
- async function runCore() {
742
- if (isBlocked()) { dbg('blocked'); return; }
743
- let insertedThisRun = 0;
744
-
745
- patchShowAds();
746
-
747
- const cfg = await fetchConfigOnce();
748
- if (!cfg) { dbg('no-config'); return; }
749
- if (cfg.excluded) { dbg('excluded'); return; }
750
- initPools(cfg);
751
-
752
- const kind = getKind();
753
-
754
- if (kind === 'topic') {
755
- if (normalizeBool(cfg.enableMessageAds)) {
756
- const __items = getPostContainers();
757
- pruneOrphanWraps('ezoic-ad-message', __items);
758
- insertedThisRun += injectBetween(
759
- 'ezoic-ad-message',
760
- __items,
761
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
762
- normalizeBool(cfg.showFirstMessageAd),
763
- state.allPosts,
764
- 'curPosts'
765
- );
766
- }
767
- } else if (kind === 'categoryTopics') {
768
- if (normalizeBool(cfg.enableBetweenAds)) {
769
- const __items = getTopicItems();
770
- pruneOrphanWraps('ezoic-ad-between', __items);
771
- insertedThisRun += injectBetween(
772
- 'ezoic-ad-between',
773
- __items,
774
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
775
- normalizeBool(cfg.showFirstTopicAd),
776
- state.allTopics,
777
- 'curTopics'
778
- );
779
- }
780
- } else if (kind === 'categories') {
781
- if (normalizeBool(cfg.enableCategoryAds)) {
782
- const __items = getCategoryItems();
783
- pruneOrphanWraps('ezoic-ad-categories', __items);
784
- insertedThisRun += injectBetween(
785
- 'ezoic-ad-categories',
786
- __items,
787
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
788
- normalizeBool(cfg.showFirstCategoryAd),
789
- state.allCategories,
790
- 'curCategories'
791
- );
792
- }
793
- }
794
- }
737
+ // ---------------- scheduler ----------------
795
738
 
796
- function scheduleRun(delayMs = 0) {
797
- // schedule a single run (coalesced)
739
+ function scheduleRun(delayMs = 0, cb) {
798
740
  if (state.runQueued) return;
799
741
  state.runQueued = true;
800
- const doRun = () => {
742
+
743
+ const run = async () => {
801
744
  state.runQueued = false;
802
745
  const pk = getPageKey();
803
746
  if (state.pageKey && pk !== state.pageKey) return;
804
- runCore().catch(() => {});
747
+ let inserted = 0;
748
+ try { inserted = await runCore(); } catch (e) { inserted = 0; }
749
+ try { cb && cb(inserted); } catch (e) {}
805
750
  };
806
- if (delayMs > 0) {
807
- window.setTimeout(() => window.requestAnimationFrame(doRun), delayMs);
808
- } else {
809
- window.requestAnimationFrame(doRun);
810
- }
751
+
752
+ const doRun = () => requestAnimationFrame(run);
753
+ if (delayMs > 0) setTimeout(doRun, delayMs);
754
+ else doRun();
811
755
  }
812
756
 
813
- function scheduleBurst() {
814
- // During ajaxify/infinite scroll, the DOM may arrive in waves.
815
- // We run a small, bounded burst to ensure all 1/X targets are reached.
757
+ function requestBurst() {
758
+ if (isBlocked()) return;
759
+
760
+ const t = now();
761
+ if (t - state.lastBurstReqTs < 120) return;
762
+ state.lastBurstReqTs = t;
763
+
816
764
  const pk = getPageKey();
817
765
  state.pageKey = pk;
818
- state.burstRuns = 0;
819
- const burst = () => {
820
- if (getPageKey() !== pk) return;
821
- if (state.burstRuns >= 6) return;
822
- state.burstRuns += 1;
823
- scheduleRun(0);
824
- // follow-up passes catch late-rendered items (especially on topics)
825
- window.setTimeout(() => scheduleRun(0), 180);
826
- window.setTimeout(() => scheduleRun(0), 650);
827
- window.setTimeout(() => scheduleRun(0), 1400);
766
+
767
+ state.burstDeadline = t + 1800;
768
+ if (state.burstActive) return;
769
+
770
+ state.burstActive = true;
771
+ state.burstCount = 0;
772
+
773
+ const step = () => {
774
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
775
+ if (isBlocked()) { state.burstActive = false; return; }
776
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
777
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
778
+
779
+ state.burstCount++;
780
+ scheduleRun(0, (inserted) => {
781
+ // Continue while we are still inserting or we have pending shows.
782
+ const hasWork = inserted > 0 || state.pending.length > 0;
783
+ if (!hasWork) { state.burstActive = false; return; }
784
+ // Short delay keeps UI smooth while catching late DOM waves.
785
+ setTimeout(step, inserted > 0 ? 120 : 220);
786
+ });
828
787
  };
829
- burst();
830
- }
831
788
 
789
+ step();
790
+ }
832
791
 
833
- // ---------- observers / lifecycle ----------
792
+ // ---------------- lifecycle ----------------
834
793
 
835
794
  function cleanup() {
836
- blockedUntil = Date.now() + 1200;
795
+ blockedUntil = now() + 1200;
837
796
 
838
- // remove all wrappers
839
797
  try {
840
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
841
- try { el.remove(); } catch (e) {}
842
- });
798
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => releaseWrapNode(el));
843
799
  } catch (e) {}
844
800
 
845
- // reset state
846
801
  state.cfg = null;
847
802
  state.allTopics = [];
848
803
  state.allPosts = [];
@@ -851,21 +806,48 @@ function startShow(id) {
851
806
  state.curPosts = 0;
852
807
  state.curCategories = 0;
853
808
  state.lastShowById.clear();
809
+
854
810
  state.inflight = 0;
855
811
  state.pending = [];
856
- try { state.pendingSet && state.pendingSet.clear(); } catch (e) {}
857
- try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
812
+ state.pendingSet.clear();
813
+
858
814
  state.heroDoneForPage = false;
859
815
 
860
- // keep observers alive (MutationObserver will re-trigger after navigation)
816
+ // keep observers alive
817
+ }
818
+
819
+ function shouldReactToMutations(mutations) {
820
+ // Fast filter: only react if relevant nodes were added/removed.
821
+ for (const m of mutations) {
822
+ if (!m.addedNodes || m.addedNodes.length === 0) continue;
823
+ for (const n of m.addedNodes) {
824
+ if (!n || n.nodeType !== 1) continue;
825
+ const el = /** @type {Element} */ (n);
826
+ if (
827
+ el.matches?.(SELECTORS.postItem) ||
828
+ el.matches?.(SELECTORS.topicItem) ||
829
+ el.matches?.(SELECTORS.categoryItem) ||
830
+ el.querySelector?.(SELECTORS.postItem) ||
831
+ el.querySelector?.(SELECTORS.topicItem) ||
832
+ el.querySelector?.(SELECTORS.categoryItem)
833
+ ) {
834
+ return true;
835
+ }
836
+ }
837
+ }
838
+ return false;
861
839
  }
862
840
 
863
841
  function ensureDomObserver() {
864
842
  if (state.domObs) return;
865
- state.domObs = new MutationObserver(() => {
843
+
844
+ state.domObs = new MutationObserver((mutations) => {
866
845
  if (state.internalDomChange > 0) return;
867
- if (!isBlocked()) scheduleBurst();
846
+ if (isBlocked()) return;
847
+ if (!shouldReactToMutations(mutations)) return;
848
+ requestBurst();
868
849
  });
850
+
869
851
  try {
870
852
  state.domObs.observe(document.body, { childList: true, subtree: true });
871
853
  } catch (e) {}
@@ -889,56 +871,52 @@ function startShow(id) {
889
871
  ensurePreloadObserver();
890
872
  ensureDomObserver();
891
873
 
892
- // Ultra-fast above-the-fold first
893
874
  insertHeroAdEarly().catch(() => {});
894
-
895
- // Then normal insertion
896
- scheduleBurst();
875
+ requestBurst();
897
876
  });
898
877
 
899
- // Infinite scroll / partial updates
900
- $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
901
- if (isBlocked()) return;
902
- scheduleBurst();
903
- });
878
+ $(window).on(
879
+ 'action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite',
880
+ () => {
881
+ if (isBlocked()) return;
882
+ requestBurst();
883
+ }
884
+ );
904
885
  }
905
886
 
906
887
  function bindScroll() {
907
888
  let ticking = false;
908
889
  window.addEventListener('scroll', () => {
909
- // Detect very fast scrolling and temporarily boost preload/parallelism.
890
+ // Fast-scroll boost
910
891
  try {
911
- const now = Date.now();
892
+ const t = now();
912
893
  const y = window.scrollY || window.pageYOffset || 0;
913
894
  if (state.lastScrollTs) {
914
- const dt = now - state.lastScrollTs;
895
+ const dt = t - state.lastScrollTs;
915
896
  const dy = Math.abs(y - (state.lastScrollY || 0));
916
897
  if (dt > 0) {
917
- const speed = dy / dt; // px/ms
898
+ const speed = dy / dt;
918
899
  if (speed >= BOOST_SPEED_PX_PER_MS) {
919
900
  const wasBoosted = isBoosted();
920
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
921
- if (!wasBoosted) {
922
- // margin changed -> rebuild IO so existing placeholders get earlier preload
923
- ensurePreloadObserver();
924
- }
901
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
902
+ if (!wasBoosted) ensurePreloadObserver();
925
903
  }
926
904
  }
927
905
  }
928
906
  state.lastScrollY = y;
929
- state.lastScrollTs = now;
907
+ state.lastScrollTs = t;
930
908
  } catch (e) {}
931
909
 
932
910
  if (ticking) return;
933
911
  ticking = true;
934
- window.requestAnimationFrame(() => {
912
+ requestAnimationFrame(() => {
935
913
  ticking = false;
936
- if (!isBlocked()) scheduleBurst();
914
+ requestBurst();
937
915
  });
938
916
  }, { passive: true });
939
917
  }
940
918
 
941
- // ---------- boot ----------
919
+ // ---------------- boot ----------------
942
920
 
943
921
  state.pageKey = getPageKey();
944
922
  warmUpNetwork();
@@ -949,8 +927,7 @@ function startShow(id) {
949
927
  bindNodeBB();
950
928
  bindScroll();
951
929
 
952
- // First paint: try hero + run
953
930
  blockedUntil = 0;
954
931
  insertHeroAdEarly().catch(() => {});
955
- scheduleBurst();
932
+ requestBurst();
956
933
  })();
package/public/style.css CHANGED
@@ -13,7 +13,8 @@
13
13
  .nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
14
14
  margin: 0 !important;
15
15
  padding: 0 !important;
16
- min-height: 1px; /* keeps placeholder measurable for IO */
16
+ /* Keep the placeholder measurable (IO) but visually negligible */
17
+ min-height: 1px;
17
18
  }
18
19
 
19
20
  /* If Ezoic wraps inside our wrapper, keep it tight */
@@ -23,25 +24,36 @@
23
24
  padding: 0 !important;
24
25
  }
25
26
 
27
+ /* Remove the classic "gap under iframe" (baseline/inline-block) */
28
+ .nodebb-ezoic-wrap,
29
+ .nodebb-ezoic-wrap * {
30
+ line-height: 0 !important;
31
+ font-size: 0 !important;
32
+ }
33
+
34
+ .nodebb-ezoic-wrap iframe,
35
+ .nodebb-ezoic-wrap div[id$="__container__"] iframe {
36
+ display: block !important;
37
+ vertical-align: top !important;
38
+ }
39
+
40
+ .nodebb-ezoic-wrap div[id$="__container__"] {
41
+ display: block !important;
42
+ line-height: 0 !important;
43
+ }
44
+
26
45
 
27
46
  /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
28
47
  .nodebb-ezoic-wrap.is-empty {
29
48
  display: block !important;
30
49
  margin: 0 !important;
31
50
  padding: 0 !important;
32
- height: 0 !important;
33
- min-height: 0 !important;
51
+ /* Don't fully collapse (can prevent fill / triggers "unused"), keep it at 1px */
52
+ height: 1px !important;
53
+ min-height: 1px !important;
34
54
  overflow: hidden !important;
35
55
  }
36
56
 
37
- .nodebb-ezoic-wrap {
38
- min-height: 0 !important;
39
- }
40
-
41
- .nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
42
- min-height: 0 !important;
43
- }
44
-
45
57
  /*
46
58
  Optional: also neutralize spacing on native Ezoic `.ezoic-ad` blocks.
47
59
  (Keeps your previous "CSS very good" behavior.)