nodebb-plugin-ezoic-infinite 1.5.68 → 1.5.70

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.68",
3
+ "version": "1.5.70",
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,93 +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
 
103
- // Hidden pool where we keep placeholder DIVs when they are not currently
104
- // mounted in the content flow. This avoids:
105
- // - Ezoic trying to act on ids that were removed from the DOM ("does not exist")
106
- // - re-defining placeholders (we re-use the same node)
107
- const POOL_ID = 'nodebb-ezoic-placeholder-pool';
108
- function getPoolEl() {
109
- let el = document.getElementById(POOL_ID);
110
- if (el) return el;
111
- el = document.createElement('div');
112
- el.id = POOL_ID;
113
- el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
114
- (document.body || document.documentElement).appendChild(el);
115
- return el;
116
- }
117
-
118
- function isInPool(ph) {
119
- try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
120
- }
86
+ function now() { return Date.now(); }
87
+ function isBlocked() { return now() < blockedUntil; }
121
88
 
122
- function releaseWrapNode(wrap) {
123
- try {
124
- const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
125
- if (ph) {
126
- try { getPoolEl().appendChild(ph); } catch (e) {}
127
- try { if (state.io) state.io.unobserve(ph); } catch (e) {}
128
- }
129
- } catch (e) {}
130
- try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
131
- }
132
-
133
-
134
- function markEmptyWrapper(id) {
135
- try {
136
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
137
- if (!ph || !ph.isConnected) return;
138
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
139
- if (!wrap) return;
140
- // If still empty after a delay, collapse it.
141
- setTimeout(() => {
142
- try {
143
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
144
- if (!ph2 || !ph2.isConnected) return;
145
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
146
- if (!w2) return;
147
- // consider empty if only whitespace and no iframes/ins/img
148
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
149
- if (!hasAd) w2.classList.add('is-empty');
150
- } catch (e) {}
151
- }, 3500);
152
- } catch (e) {}
153
- }
154
-
155
- // Production build: debug disabled
156
- function dbg() {}
157
-
158
- // ---------- small utils ----------
89
+ // ---------------- utils ----------------
159
90
 
160
91
  function normalizeBool(v) {
161
92
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
@@ -194,19 +125,31 @@
194
125
  return window.location.pathname;
195
126
  }
196
127
 
197
- function getKind() {
198
- const p = window.location.pathname || '';
199
- if (/^\/topic\//.test(p)) return 'topic';
200
- if (/^\/category\//.test(p)) return 'categoryTopics';
201
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
128
+ function isMobile() {
129
+ try { return window.innerWidth < 768; } catch (e) { return false; }
130
+ }
202
131
 
203
- // fallback by DOM
204
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
205
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
206
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
207
- 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);
144
+ }
145
+
146
+ function withInternalDomChange(fn) {
147
+ state.internalDomChange++;
148
+ try { fn(); } finally { state.internalDomChange--; }
208
149
  }
209
150
 
151
+ // ---------------- DOM helpers ----------------
152
+
210
153
  function getTopicItems() {
211
154
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
212
155
  }
@@ -227,7 +170,59 @@
227
170
  });
228
171
  }
229
172
 
230
- // ---------- 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 ----------------
231
226
 
232
227
  const _warmLinksDone = new Set();
233
228
  function warmUpNetwork() {
@@ -235,10 +230,18 @@
235
230
  const head = document.head || document.getElementsByTagName('head')[0];
236
231
  if (!head) return;
237
232
  const links = [
233
+ // Ezoic
238
234
  ['preconnect', 'https://g.ezoic.net', true],
239
235
  ['dns-prefetch', 'https://g.ezoic.net', false],
240
236
  ['preconnect', 'https://go.ezoic.net', true],
241
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],
242
245
  ];
243
246
  for (const [rel, href, cors] of links) {
244
247
  const key = `${rel}|${href}`;
@@ -253,7 +256,9 @@
253
256
  } catch (e) {}
254
257
  }
255
258
 
256
- // 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.
257
262
  function patchShowAds() {
258
263
  const applyPatch = () => {
259
264
  try {
@@ -295,28 +300,7 @@
295
300
  }
296
301
  }
297
302
 
298
- const RECYCLE_COOLDOWN_MS = 1500;
299
-
300
- function kindKeyFromClass(kindClass) {
301
- if (kindClass === 'ezoic-ad-message') return 'topic';
302
- if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
303
- if (kindClass === 'ezoic-ad-categories') return 'categories';
304
- return 'topic';
305
- }
306
-
307
- function withInternalDomChange(fn) {
308
- state.internalDomChange++;
309
- try { fn(); } finally { state.internalDomChange--; }
310
- }
311
-
312
- function canRecycle(kind) {
313
- const now = Date.now();
314
- const last = state.lastRecycleAt[kind] || 0;
315
- if (now - last < RECYCLE_COOLDOWN_MS) return false;
316
- state.lastRecycleAt[kind] = now;
317
- return true;
318
- }
319
- // ---------- config & pools ----------
303
+ // ---------------- config ----------------
320
304
 
321
305
  async function fetchConfigOnce() {
322
306
  if (state.cfg) return state.cfg;
@@ -332,89 +316,14 @@ function withInternalDomChange(fn) {
332
316
 
333
317
  function initPools(cfg) {
334
318
  if (!cfg) return;
335
- if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
336
- if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
337
- if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
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);
338
322
  }
339
323
 
340
- // ---------- insertion primitives ----------
324
+ // ---------------- insertion primitives ----------------
341
325
 
342
- function isAdjacentAd(target) {
343
- if (!target) return false;
344
- const next = target.nextElementSibling;
345
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
346
- const prev = target.previousElementSibling;
347
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
348
- return false;
349
- }
350
-
351
-
352
- function getWrapIdFromWrap(wrap) {
353
- try {
354
- const v = wrap.getAttribute('data-ezoic-wrapid');
355
- if (v) return String(v);
356
- const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
357
- if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
358
- } catch (e) {}
359
- return null;
360
- }
361
-
362
- function safeDestroyById(id) {
363
- // IMPORTANT:
364
- // Do NOT call ez.destroyPlaceholders here.
365
- // In NodeBB ajaxify/infinite-scroll flows, Ezoic can be mid-refresh.
366
- // Destroy calls can create churn, reduce fill, and generate "does not exist" spam.
367
- // We only remove our wrapper; Ezoic manages slot lifecycle.
368
- return;
369
- }
370
-
371
- function pruneOrphanWraps(kindClass, items) {
372
- if (!items || !items.length) return 0;
373
- const itemSet = new Set(items);
374
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
375
- let removed = 0;
376
-
377
- wraps.forEach((wrap) => {
378
- // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
379
- let ok = false;
380
- let prev = wrap.previousElementSibling;
381
- for (let i = 0; i < 3 && prev; i++) {
382
- if (itemSet.has(prev)) { ok = true; break; }
383
- prev = prev.previousElementSibling;
384
- }
385
-
386
- if (!ok) {
387
- withInternalDomChange(() => {
388
- try {
389
- // Do not destroy placeholders; move them back to the pool so
390
- // Ezoic won't log "does not exist" and we can reuse them later.
391
- releaseWrapNode(wrap);
392
- } catch (e) {}
393
- });
394
- removed++;
395
- }
396
- });
397
-
398
- if (removed) dbg('prune-orphan', kindClass, { removed });
399
- return removed;
400
- }
401
-
402
- function refreshEmptyState(id) {
403
- // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
404
- window.setTimeout(() => {
405
- try {
406
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
407
- if (!ph || !ph.isConnected) return;
408
- const wrap = ph.closest(`.${WRAP_CLASS}`);
409
- if (!wrap) return;
410
- const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
411
- if (hasContent) wrap.classList.remove('is-empty');
412
- else wrap.classList.add('is-empty');
413
- } catch (e) {}
414
- }, 3500);
415
- }
416
-
417
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
326
+ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
418
327
  const wrap = document.createElement('div');
419
328
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
420
329
  wrap.setAttribute('data-ezoic-after', String(afterPos));
@@ -431,10 +340,6 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
431
340
  return wrap;
432
341
  }
433
342
 
434
- function findWrap(kindClass, afterPos) {
435
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
436
- }
437
-
438
343
  function insertAfter(target, id, kindClass, afterPos) {
439
344
  if (!target || !target.insertAdjacentElement) return null;
440
345
  if (findWrap(kindClass, afterPos)) return null;
@@ -444,24 +349,18 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
444
349
 
445
350
  insertingIds.add(id);
446
351
  try {
447
- // If a placeholder already exists (either in content or in our pool),
448
- // do NOT create a new DOM node with the same id even temporarily.
449
352
  const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
450
353
  target.insertAdjacentElement('afterend', wrap);
451
354
 
452
- // If a placeholder with this id already exists elsewhere (some Ezoic flows
453
- // pre-create placeholders), move it into our wrapper instead of aborting.
454
- // replaceChild moves the node atomically (no detach window).
355
+ // If placeholder exists elsewhere (including pool), move it into the wrapper.
455
356
  if (existingPh) {
456
357
  try {
457
358
  existingPh.setAttribute('data-ezoic-id', String(id));
458
- // If we didn't create a placeholder, just append.
459
359
  if (!wrap.firstElementChild) wrap.appendChild(existingPh);
460
360
  else wrap.replaceChild(existingPh, wrap.firstElementChild);
461
- } catch (e) {
462
- // Keep the new placeholder if replace fails.
463
- }
361
+ } catch (e) {}
464
362
  }
363
+
465
364
  return wrap;
466
365
  } finally {
467
366
  insertingIds.delete(id);
@@ -472,138 +371,79 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
472
371
  const n = allIds.length;
473
372
  if (!n) return null;
474
373
 
475
- // Try at most n ids to find one that's not already in the DOM
476
374
  for (let tries = 0; tries < n; tries++) {
477
375
  const idx = state[cursorKey] % n;
478
376
  state[cursorKey] = (state[cursorKey] + 1) % n;
479
-
480
377
  const id = allIds[idx];
378
+
481
379
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
482
- // If placeholder is currently mounted in the content flow, skip.
483
- // If it's in our hidden pool, it's available for reuse.
484
380
  if (ph && ph.isConnected && !isInPool(ph)) continue;
485
381
 
486
382
  return id;
487
383
  }
384
+
488
385
  return null;
489
386
  }
490
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;
491
393
 
492
- function removeOneOldWrap(kindClass) {
493
- try {
494
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
495
- if (!wraps.length) return false;
496
-
497
- // Prefer a wrap far above the viewport
498
- let victim = null;
499
- for (const w of wraps) {
500
- const r = w.getBoundingClientRect();
501
- if (r.bottom < -2000) { victim = w; break; }
394
+ wraps.forEach((wrap) => {
395
+ let ok = false;
396
+ // NodeBB/skins can insert helper nodes between posts (clearfix, separators, etc.).
397
+ // Be tolerant so we don't remove a valid ad too early ("disappears too fast").
398
+ let prev = wrap.previousElementSibling;
399
+ for (let i = 0; i < 8 && prev; i++) {
400
+ if (itemSet.has(prev)) { ok = true; break; }
401
+ prev = prev.previousElementSibling;
502
402
  }
503
- // Otherwise remove the earliest one in the document
504
- if (!victim) victim = wraps[0];
505
403
 
506
- releaseWrapNode(victim);
507
- return true;
508
- } catch (e) {
509
- return false;
510
- }
511
- }
404
+ if (!ok) {
405
+ let next = wrap.nextElementSibling;
406
+ for (let i = 0; i < 3 && next; i++) {
407
+ if (itemSet.has(next)) { ok = true; break; }
408
+ next = next.nextElementSibling;
409
+ }
410
+ }
512
411
 
513
- function enqueueShow(id) {
514
- if (!id || isBlocked()) return;
515
-
516
- // Basic per-id throttle (prevents rapid re-requests when DOM churns)
517
- const now = Date.now();
518
- const last = state.lastShowById.get(id) || 0;
519
- if (now - last < 900) return;
520
-
521
- const max = getMaxInflight();
522
- if (state.inflight >= max) {
523
- if (!state.pendingSet.has(id)) {
524
- state.pending.push(id);
525
- state.pendingSet.add(id);
526
- }
527
- return;
528
- }
529
- startShow(id);
530
- }
412
+ if (!ok) {
413
+ withInternalDomChange(() => releaseWrapNode(wrap));
414
+ removed++;
415
+ }
416
+ });
531
417
 
532
- function drainQueue() {
533
- if (isBlocked()) return;
534
- const max = getMaxInflight();
535
- while (state.inflight < max && state.pending.length) {
536
- const id = state.pending.shift();
537
- state.pendingSet.delete(id);
538
- startShow(id);
418
+ return removed;
539
419
  }
540
- }
541
-
542
- function startShow(id) {
543
- if (!id || isBlocked()) return;
544
420
 
545
- state.inflight++;
546
- let released = false;
547
- const release = () => {
548
- if (released) return;
549
- released = true;
550
- state.inflight = Math.max(0, state.inflight - 1);
551
- drainQueue();
552
- };
553
-
554
- const hardTimer = setTimeout(release, 6500);
555
-
556
- requestAnimationFrame(() => {
557
- try {
558
- if (isBlocked()) return;
559
-
560
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
561
- if (!ph || !ph.isConnected) return;
562
-
563
- const now2 = Date.now();
564
- const last2 = state.lastShowById.get(id) || 0;
565
- if (now2 - last2 < 900) return;
566
- state.lastShowById.set(id, now2);
567
-
568
- window.ezstandalone = window.ezstandalone || {};
569
- const ez = window.ezstandalone;
570
-
571
- const doShow = () => {
572
- // Do NOT call destroyPlaceholders here.
573
- // In ajaxify + infinite scroll flows, Ezoic can be in the middle of a refresh cycle.
574
- // Calling destroy on active placeholders is a common source of:
575
- // - "HTML element ... does not exist"
576
- // - "Placeholder Id ... already been defined"
577
- // Prefer a straight showAds; Ezoic will refresh as needed.
578
- try { ez.showAds(id); } catch (e) {}
579
- try { markEmptyWrapper(id); } catch (e) {}
580
-
581
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
582
- };
583
-
584
- if (Array.isArray(ez.cmd)) {
585
- try { ez.cmd.push(doShow); } catch (e) { doShow(); }
586
- } else {
587
- doShow();
421
+ function decluster(kindClass) {
422
+ // Remove consecutive wraps (keep the first)
423
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
424
+ if (wraps.length < 2) return 0;
425
+ let removed = 0;
426
+ for (const w of wraps) {
427
+ const prev = w.previousElementSibling;
428
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
429
+ withInternalDomChange(() => releaseWrapNode(w));
430
+ removed++;
588
431
  }
589
- } finally {
590
- // If we returned early, hardTimer will release.
591
432
  }
592
- });
593
- }
594
-
433
+ return removed;
434
+ }
595
435
 
596
- // ---------- preload / above-the-fold ----------
436
+ // ---------------- show (preload / fast fill) ----------------
597
437
 
598
438
  function ensurePreloadObserver() {
599
439
  const desiredMargin = getPreloadRootMargin();
600
440
  if (state.io && state.ioMargin === desiredMargin) return state.io;
601
441
 
602
- // Rebuild IO if margin changed (e.g., scroll boost toggled)
603
442
  if (state.io) {
604
443
  try { state.io.disconnect(); } catch (e) {}
605
444
  state.io = null;
606
445
  }
446
+
607
447
  try {
608
448
  state.io = new IntersectionObserver((entries) => {
609
449
  for (const ent of entries) {
@@ -616,29 +456,36 @@ function startShow(id) {
616
456
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
617
457
  }
618
458
  }, { root: null, rootMargin: desiredMargin, threshold: 0 });
459
+
619
460
  state.ioMargin = desiredMargin;
620
461
  } catch (e) {
621
462
  state.io = null;
622
463
  state.ioMargin = null;
623
464
  }
624
465
 
625
- // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
466
+ // Re-observe current placeholders
626
467
  try {
627
468
  if (state.io) {
628
469
  const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
629
470
  nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
630
471
  }
631
472
  } catch (e) {}
473
+
632
474
  return state.io;
633
475
  }
634
476
 
635
477
  function observePlaceholder(id) {
636
478
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
637
479
  if (!ph || !ph.isConnected) return;
480
+ ph.setAttribute('data-ezoic-id', String(id));
481
+
482
+ // When the ad actually fills, tighten height to the real iframe height and remove empty-collapsing.
483
+ armFillObserver(ph);
484
+
638
485
  const io = ensurePreloadObserver();
639
486
  try { io && io.observe(ph); } catch (e) {}
640
487
 
641
- // If already above fold, fire immediately
488
+ // If already near viewport, fire immediately.
642
489
  try {
643
490
  const r = ph.getBoundingClientRect();
644
491
  const screens = isBoosted() ? 5.0 : 3.0;
@@ -647,7 +494,154 @@ function startShow(id) {
647
494
  } catch (e) {}
648
495
  }
649
496
 
650
- // ---------- insertion logic ----------
497
+ // ---------------- fill tightening ----------------
498
+
499
+ const _fillObserved = new WeakSet();
500
+ function tightenFilled(ph) {
501
+ if (!ph || !ph.isConnected) return;
502
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
503
+ if (!wrap) return;
504
+
505
+ // If we have any real ad content, we should not be in "empty" mode.
506
+ const frame = ph.querySelector ? ph.querySelector('iframe') : null;
507
+ const hasAd = !!(frame || (ph.querySelector && ph.querySelector('ins, img, .ez-ad, .ezoic-ad')));
508
+ if (!hasAd) return;
509
+
510
+ try { wrap.classList.remove('is-empty'); } catch (e) {}
511
+
512
+ // Remove the common "extra reserved space" created by Ezoic's min-height styles.
513
+ // Set minHeight to the actual iframe height when available.
514
+ try {
515
+ if (frame && frame.offsetHeight) {
516
+ const h = frame.offsetHeight;
517
+ const ezoicSpan = ph.closest ? ph.closest('span.ezoic-ad, .ezoic-ad') : null;
518
+ if (ezoicSpan && ezoicSpan.style) {
519
+ ezoicSpan.style.minHeight = `${h}px`;
520
+ }
521
+ // Also tighten the wrapper itself to avoid "space under" when the parent is taller.
522
+ wrap.style.minHeight = `${h}px`;
523
+ }
524
+ } catch (e) {}
525
+ }
526
+
527
+ function armFillObserver(ph) {
528
+ if (!ph || _fillObserved.has(ph)) return;
529
+ _fillObserved.add(ph);
530
+
531
+ // Fast path: already filled
532
+ try { tightenFilled(ph); } catch (e) {}
533
+
534
+ // Observe until it fills, then disconnect.
535
+ try {
536
+ const mo = new MutationObserver(() => {
537
+ try {
538
+ tightenFilled(ph);
539
+ const hasFrame = !!(ph.querySelector && ph.querySelector('iframe'));
540
+ if (hasFrame) mo.disconnect();
541
+ } catch (e) {}
542
+ });
543
+ mo.observe(ph, { childList: true, subtree: true });
544
+ } catch (e) {}
545
+ }
546
+
547
+ function enqueueShow(id) {
548
+ if (!id || isBlocked()) return;
549
+
550
+ // per-id throttle
551
+ const t = now();
552
+ const last = state.lastShowById.get(id) || 0;
553
+ if (t - last < 900) return;
554
+
555
+ const max = getMaxInflight();
556
+ if (state.inflight >= max) {
557
+ if (!state.pendingSet.has(id)) {
558
+ state.pending.push(id);
559
+ state.pendingSet.add(id);
560
+ }
561
+ return;
562
+ }
563
+
564
+ startShow(id);
565
+ }
566
+
567
+ function drainQueue() {
568
+ if (isBlocked()) return;
569
+ const max = getMaxInflight();
570
+ while (state.inflight < max && state.pending.length) {
571
+ const id = state.pending.shift();
572
+ state.pendingSet.delete(id);
573
+ startShow(id);
574
+ }
575
+ }
576
+
577
+ function markEmptyWrapper(id) {
578
+ // If still empty after delay, mark empty for CSS (1px)
579
+ try {
580
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
581
+ if (!ph || !ph.isConnected) return;
582
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
583
+ if (!wrap) return;
584
+
585
+ setTimeout(() => {
586
+ try {
587
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
588
+ if (!ph2 || !ph2.isConnected) return;
589
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
590
+ if (!w2) return;
591
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
592
+ if (!hasAd) w2.classList.add('is-empty');
593
+ } catch (e) {}
594
+ }, 3500);
595
+ } catch (e) {}
596
+ }
597
+
598
+ function startShow(id) {
599
+ if (!id || isBlocked()) return;
600
+
601
+ state.inflight++;
602
+ let released = false;
603
+ const release = () => {
604
+ if (released) return;
605
+ released = true;
606
+ state.inflight = Math.max(0, state.inflight - 1);
607
+ drainQueue();
608
+ };
609
+
610
+ const hardTimer = setTimeout(release, 6500);
611
+
612
+ requestAnimationFrame(() => {
613
+ try {
614
+ if (isBlocked()) return;
615
+
616
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
617
+ if (!ph || !ph.isConnected) return;
618
+
619
+ const t = now();
620
+ const last = state.lastShowById.get(id) || 0;
621
+ if (t - last < 900) return;
622
+ state.lastShowById.set(id, t);
623
+
624
+ window.ezstandalone = window.ezstandalone || {};
625
+ const ez = window.ezstandalone;
626
+
627
+ const doShow = () => {
628
+ try { ez.showAds(id); } catch (e) {}
629
+ try { markEmptyWrapper(id); } catch (e) {}
630
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
631
+ };
632
+
633
+ if (Array.isArray(ez.cmd)) {
634
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
635
+ } else {
636
+ doShow();
637
+ }
638
+ } finally {
639
+ // hardTimer releases on early return
640
+ }
641
+ });
642
+ }
643
+
644
+ // ---------------- core injection ----------------
651
645
 
652
646
  function computeTargets(count, interval, showFirst) {
653
647
  const out = [];
@@ -656,6 +650,7 @@ function startShow(id) {
656
650
  for (let i = 1; i <= count; i++) {
657
651
  if (i % interval === 0) out.push(i);
658
652
  }
653
+ // unique + sorted
659
654
  return Array.from(new Set(out)).sort((a, b) => a - b);
660
655
  }
661
656
 
@@ -674,43 +669,87 @@ function startShow(id) {
674
669
  if (isAdjacentAd(el)) continue;
675
670
  if (findWrap(kindClass, afterPos)) continue;
676
671
 
677
- let id = pickIdFromAll(allIds, cursorKey);
678
- if (!id) {
679
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
680
- // Guard against tight observer loops.
681
- if (!canRecycle(kindKeyFromClass(kindClass))) {
682
- dbg('recycle-skip-cooldown', kindClass);
683
- break;
684
- }
685
- let recycled = false;
686
- withInternalDomChange(() => {
687
- recycled = removeOneOldWrap(kindClass);
688
- });
689
- dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
690
- // Stop this run after a recycle; the next mutation/scroll will retry injection.
691
- break;
692
- }
672
+ const id = pickIdFromAll(allIds, cursorKey);
673
+ if (!id) break;
674
+
693
675
  const wrap = insertAfter(el, id, kindClass, afterPos);
694
- if (!wrap) {
695
- continue;
696
- }
676
+ if (!wrap) continue;
697
677
 
698
678
  observePlaceholder(id);
699
- inserted += 1;
679
+ inserted++;
700
680
  }
701
681
 
702
682
  return inserted;
703
683
  }
704
684
 
705
- async function insertHeroAdEarly() {
706
- if (state.heroDoneForPage) return;
685
+ async function runCore() {
686
+ if (isBlocked()) return 0;
687
+
688
+ patchShowAds();
689
+
707
690
  const cfg = await fetchConfigOnce();
708
- if (!cfg) { dbg('no-config'); return; }
709
- if (cfg.excluded) { dbg('excluded'); return; }
691
+ if (!cfg || cfg.excluded) return 0;
692
+ initPools(cfg);
710
693
 
694
+ const kind = getKind();
695
+ let inserted = 0;
696
+
697
+ if (kind === 'topic') {
698
+ if (normalizeBool(cfg.enableMessageAds)) {
699
+ const items = getPostContainers();
700
+ pruneOrphanWraps('ezoic-ad-message', items);
701
+ inserted += injectBetween(
702
+ 'ezoic-ad-message',
703
+ items,
704
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
705
+ normalizeBool(cfg.showFirstMessageAd),
706
+ state.allPosts,
707
+ 'curPosts'
708
+ );
709
+ decluster('ezoic-ad-message');
710
+ }
711
+ } else if (kind === 'categoryTopics') {
712
+ if (normalizeBool(cfg.enableBetweenAds)) {
713
+ const items = getTopicItems();
714
+ pruneOrphanWraps('ezoic-ad-between', items);
715
+ inserted += injectBetween(
716
+ 'ezoic-ad-between',
717
+ items,
718
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
719
+ normalizeBool(cfg.showFirstTopicAd),
720
+ state.allTopics,
721
+ 'curTopics'
722
+ );
723
+ decluster('ezoic-ad-between');
724
+ }
725
+ } else if (kind === 'categories') {
726
+ if (normalizeBool(cfg.enableCategoryAds)) {
727
+ const items = getCategoryItems();
728
+ pruneOrphanWraps('ezoic-ad-categories', items);
729
+ inserted += injectBetween(
730
+ 'ezoic-ad-categories',
731
+ items,
732
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
733
+ normalizeBool(cfg.showFirstCategoryAd),
734
+ state.allCategories,
735
+ 'curCategories'
736
+ );
737
+ decluster('ezoic-ad-categories');
738
+ }
739
+ }
740
+
741
+ return inserted;
742
+ }
743
+
744
+ async function insertHeroAdEarly() {
745
+ if (state.heroDoneForPage || isBlocked()) return;
746
+
747
+ const cfg = await fetchConfigOnce();
748
+ if (!cfg || cfg.excluded) return;
711
749
  initPools(cfg);
712
750
 
713
751
  const kind = getKind();
752
+
714
753
  let items = [];
715
754
  let allIds = [];
716
755
  let cursorKey = '';
@@ -742,9 +781,8 @@ function startShow(id) {
742
781
  if (!items.length) return;
743
782
  if (!showFirst) { state.heroDoneForPage = true; return; }
744
783
 
745
- // Insert after the very first item (above-the-fold)
746
784
  const afterPos = 1;
747
- const el = items[afterPos - 1];
785
+ const el = items[0];
748
786
  if (!el || !el.isConnected) return;
749
787
  if (isAdjacentAd(el)) return;
750
788
  if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
@@ -753,119 +791,76 @@ function startShow(id) {
753
791
  if (!id) return;
754
792
 
755
793
  const wrap = insertAfter(el, id, kindClass, afterPos);
756
- if (!wrap) {
757
- return;
758
- }
794
+ if (!wrap) return;
759
795
 
760
796
  state.heroDoneForPage = true;
761
797
  observePlaceholder(id);
762
798
  }
763
799
 
764
- async function runCore() {
765
- if (isBlocked()) { dbg('blocked'); return; }
766
- let insertedThisRun = 0;
767
-
768
- patchShowAds();
769
-
770
- const cfg = await fetchConfigOnce();
771
- if (!cfg) { dbg('no-config'); return; }
772
- if (cfg.excluded) { dbg('excluded'); return; }
773
- initPools(cfg);
800
+ // ---------------- scheduler ----------------
774
801
 
775
- const kind = getKind();
776
-
777
- if (kind === 'topic') {
778
- if (normalizeBool(cfg.enableMessageAds)) {
779
- const __items = getPostContainers();
780
- pruneOrphanWraps('ezoic-ad-message', __items);
781
- insertedThisRun += injectBetween(
782
- 'ezoic-ad-message',
783
- __items,
784
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
785
- normalizeBool(cfg.showFirstMessageAd),
786
- state.allPosts,
787
- 'curPosts'
788
- );
789
- }
790
- } else if (kind === 'categoryTopics') {
791
- if (normalizeBool(cfg.enableBetweenAds)) {
792
- const __items = getTopicItems();
793
- pruneOrphanWraps('ezoic-ad-between', __items);
794
- insertedThisRun += injectBetween(
795
- 'ezoic-ad-between',
796
- __items,
797
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
798
- normalizeBool(cfg.showFirstTopicAd),
799
- state.allTopics,
800
- 'curTopics'
801
- );
802
- }
803
- } else if (kind === 'categories') {
804
- if (normalizeBool(cfg.enableCategoryAds)) {
805
- const __items = getCategoryItems();
806
- pruneOrphanWraps('ezoic-ad-categories', __items);
807
- insertedThisRun += injectBetween(
808
- 'ezoic-ad-categories',
809
- __items,
810
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
811
- normalizeBool(cfg.showFirstCategoryAd),
812
- state.allCategories,
813
- 'curCategories'
814
- );
815
- }
816
- }
817
- }
818
-
819
- function scheduleRun(delayMs = 0) {
820
- // schedule a single run (coalesced)
802
+ function scheduleRun(delayMs = 0, cb) {
821
803
  if (state.runQueued) return;
822
804
  state.runQueued = true;
823
- const doRun = () => {
805
+
806
+ const run = async () => {
824
807
  state.runQueued = false;
825
808
  const pk = getPageKey();
826
809
  if (state.pageKey && pk !== state.pageKey) return;
827
- runCore().catch(() => {});
810
+ let inserted = 0;
811
+ try { inserted = await runCore(); } catch (e) { inserted = 0; }
812
+ try { cb && cb(inserted); } catch (e) {}
828
813
  };
829
- if (delayMs > 0) {
830
- window.setTimeout(() => window.requestAnimationFrame(doRun), delayMs);
831
- } else {
832
- window.requestAnimationFrame(doRun);
833
- }
814
+
815
+ const doRun = () => requestAnimationFrame(run);
816
+ if (delayMs > 0) setTimeout(doRun, delayMs);
817
+ else doRun();
834
818
  }
835
819
 
836
- function scheduleBurst() {
837
- // During ajaxify/infinite scroll, the DOM may arrive in waves.
838
- // We run a small, bounded burst to ensure all 1/X targets are reached.
820
+ function requestBurst() {
821
+ if (isBlocked()) return;
822
+
823
+ const t = now();
824
+ if (t - state.lastBurstReqTs < 120) return;
825
+ state.lastBurstReqTs = t;
826
+
839
827
  const pk = getPageKey();
840
828
  state.pageKey = pk;
841
- state.burstRuns = 0;
842
- const burst = () => {
843
- if (getPageKey() !== pk) return;
844
- if (state.burstRuns >= 6) return;
845
- state.burstRuns += 1;
846
- scheduleRun(0);
847
- // follow-up passes catch late-rendered items (especially on topics)
848
- window.setTimeout(() => scheduleRun(0), 180);
849
- window.setTimeout(() => scheduleRun(0), 650);
850
- window.setTimeout(() => scheduleRun(0), 1400);
829
+
830
+ state.burstDeadline = t + 1800;
831
+ if (state.burstActive) return;
832
+
833
+ state.burstActive = true;
834
+ state.burstCount = 0;
835
+
836
+ const step = () => {
837
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
838
+ if (isBlocked()) { state.burstActive = false; return; }
839
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
840
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
841
+
842
+ state.burstCount++;
843
+ scheduleRun(0, (inserted) => {
844
+ // Continue while we are still inserting or we have pending shows.
845
+ const hasWork = inserted > 0 || state.pending.length > 0;
846
+ if (!hasWork) { state.burstActive = false; return; }
847
+ // Short delay keeps UI smooth while catching late DOM waves.
848
+ setTimeout(step, inserted > 0 ? 120 : 220);
849
+ });
851
850
  };
852
- burst();
853
- }
854
851
 
852
+ step();
853
+ }
855
854
 
856
- // ---------- observers / lifecycle ----------
855
+ // ---------------- lifecycle ----------------
857
856
 
858
857
  function cleanup() {
859
- blockedUntil = Date.now() + 1200;
858
+ blockedUntil = now() + 1200;
860
859
 
861
- // remove all wrappers
862
860
  try {
863
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
864
- releaseWrapNode(el);
865
- });
861
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => releaseWrapNode(el));
866
862
  } catch (e) {}
867
863
 
868
- // reset state
869
864
  state.cfg = null;
870
865
  state.allTopics = [];
871
866
  state.allPosts = [];
@@ -874,21 +869,48 @@ function startShow(id) {
874
869
  state.curPosts = 0;
875
870
  state.curCategories = 0;
876
871
  state.lastShowById.clear();
872
+
877
873
  state.inflight = 0;
878
874
  state.pending = [];
879
- try { state.pendingSet && state.pendingSet.clear(); } catch (e) {}
880
- try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
875
+ state.pendingSet.clear();
876
+
881
877
  state.heroDoneForPage = false;
882
878
 
883
- // keep observers alive (MutationObserver will re-trigger after navigation)
879
+ // keep observers alive
880
+ }
881
+
882
+ function shouldReactToMutations(mutations) {
883
+ // Fast filter: only react if relevant nodes were added/removed.
884
+ for (const m of mutations) {
885
+ if (!m.addedNodes || m.addedNodes.length === 0) continue;
886
+ for (const n of m.addedNodes) {
887
+ if (!n || n.nodeType !== 1) continue;
888
+ const el = /** @type {Element} */ (n);
889
+ if (
890
+ el.matches?.(SELECTORS.postItem) ||
891
+ el.matches?.(SELECTORS.topicItem) ||
892
+ el.matches?.(SELECTORS.categoryItem) ||
893
+ el.querySelector?.(SELECTORS.postItem) ||
894
+ el.querySelector?.(SELECTORS.topicItem) ||
895
+ el.querySelector?.(SELECTORS.categoryItem)
896
+ ) {
897
+ return true;
898
+ }
899
+ }
900
+ }
901
+ return false;
884
902
  }
885
903
 
886
904
  function ensureDomObserver() {
887
905
  if (state.domObs) return;
888
- state.domObs = new MutationObserver(() => {
906
+
907
+ state.domObs = new MutationObserver((mutations) => {
889
908
  if (state.internalDomChange > 0) return;
890
- if (!isBlocked()) scheduleBurst();
909
+ if (isBlocked()) return;
910
+ if (!shouldReactToMutations(mutations)) return;
911
+ requestBurst();
891
912
  });
913
+
892
914
  try {
893
915
  state.domObs.observe(document.body, { childList: true, subtree: true });
894
916
  } catch (e) {}
@@ -912,56 +934,52 @@ function startShow(id) {
912
934
  ensurePreloadObserver();
913
935
  ensureDomObserver();
914
936
 
915
- // Ultra-fast above-the-fold first
916
937
  insertHeroAdEarly().catch(() => {});
917
-
918
- // Then normal insertion
919
- scheduleBurst();
938
+ requestBurst();
920
939
  });
921
940
 
922
- // Infinite scroll / partial updates
923
- $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
924
- if (isBlocked()) return;
925
- scheduleBurst();
926
- });
941
+ $(window).on(
942
+ 'action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite',
943
+ () => {
944
+ if (isBlocked()) return;
945
+ requestBurst();
946
+ }
947
+ );
927
948
  }
928
949
 
929
950
  function bindScroll() {
930
951
  let ticking = false;
931
952
  window.addEventListener('scroll', () => {
932
- // Detect very fast scrolling and temporarily boost preload/parallelism.
953
+ // Fast-scroll boost
933
954
  try {
934
- const now = Date.now();
955
+ const t = now();
935
956
  const y = window.scrollY || window.pageYOffset || 0;
936
957
  if (state.lastScrollTs) {
937
- const dt = now - state.lastScrollTs;
958
+ const dt = t - state.lastScrollTs;
938
959
  const dy = Math.abs(y - (state.lastScrollY || 0));
939
960
  if (dt > 0) {
940
- const speed = dy / dt; // px/ms
961
+ const speed = dy / dt;
941
962
  if (speed >= BOOST_SPEED_PX_PER_MS) {
942
963
  const wasBoosted = isBoosted();
943
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
944
- if (!wasBoosted) {
945
- // margin changed -> rebuild IO so existing placeholders get earlier preload
946
- ensurePreloadObserver();
947
- }
964
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
965
+ if (!wasBoosted) ensurePreloadObserver();
948
966
  }
949
967
  }
950
968
  }
951
969
  state.lastScrollY = y;
952
- state.lastScrollTs = now;
970
+ state.lastScrollTs = t;
953
971
  } catch (e) {}
954
972
 
955
973
  if (ticking) return;
956
974
  ticking = true;
957
- window.requestAnimationFrame(() => {
975
+ requestAnimationFrame(() => {
958
976
  ticking = false;
959
- if (!isBlocked()) scheduleBurst();
977
+ requestBurst();
960
978
  });
961
979
  }, { passive: true });
962
980
  }
963
981
 
964
- // ---------- boot ----------
982
+ // ---------------- boot ----------------
965
983
 
966
984
  state.pageKey = getPageKey();
967
985
  warmUpNetwork();
@@ -972,8 +990,7 @@ function startShow(id) {
972
990
  bindNodeBB();
973
991
  bindScroll();
974
992
 
975
- // First paint: try hero + run
976
993
  blockedUntil = 0;
977
994
  insertHeroAdEarly().catch(() => {});
978
- scheduleBurst();
995
+ requestBurst();
979
996
  })();
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 */
@@ -21,6 +22,28 @@
21
22
  .nodebb-ezoic-wrap .ezoic-ad {
22
23
  margin: 0 !important;
23
24
  padding: 0 !important;
25
+ /* Ezoic sometimes reserves extra height (e.g. min-height: 400px) even when the filled iframe is smaller.
26
+ We prefer zero unused space; JS will also tighten after fill to the real iframe height. */
27
+ min-height: 0 !important;
28
+ height: auto !important;
29
+ }
30
+
31
+ /* Remove the classic "gap under iframe" (baseline/inline-block) */
32
+ .nodebb-ezoic-wrap,
33
+ .nodebb-ezoic-wrap * {
34
+ line-height: 0 !important;
35
+ font-size: 0 !important;
36
+ }
37
+
38
+ .nodebb-ezoic-wrap iframe,
39
+ .nodebb-ezoic-wrap div[id$="__container__"] iframe {
40
+ display: block !important;
41
+ vertical-align: top !important;
42
+ }
43
+
44
+ .nodebb-ezoic-wrap div[id$="__container__"] {
45
+ display: block !important;
46
+ line-height: 0 !important;
24
47
  }
25
48
 
26
49
 
@@ -29,19 +52,12 @@
29
52
  display: block !important;
30
53
  margin: 0 !important;
31
54
  padding: 0 !important;
32
- height: 0 !important;
33
- min-height: 0 !important;
55
+ /* Don't fully collapse (can prevent fill / triggers "unused"), keep it at 1px */
56
+ height: 1px !important;
57
+ min-height: 1px !important;
34
58
  overflow: hidden !important;
35
59
  }
36
60
 
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
61
  /*
46
62
  Optional: also neutralize spacing on native Ezoic `.ezoic-ad` blocks.
47
63
  (Keeps your previous "CSS very good" behavior.)