nodebb-plugin-ezoic-infinite 1.5.27 → 1.5.28

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.27",
3
+ "version": "1.5.28",
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
@@ -13,23 +13,27 @@
13
13
  // Preload before viewport (tune if you want even earlier)
14
14
  const PRELOAD_ROOT_MARGIN = '1200px 0px';
15
15
 
16
+ // Windowing: keep ads mostly around the viewport to avoid DOM bloat + ID saturation
17
+ const WINDOW_MIN_ITEMS = 24; // minimum scan window
18
+ const WINDOW_MAX_ITEMS = 120; // maximum scan window
19
+ const WINDOW_BUFFER_PX = 900; // how far outside viewport counts as "near"
20
+ const PURGE_BUFFER_ITEMS = 12; // extra items outside the window where we keep wrappers
21
+ const RECYCLE_COOLDOWN_MS = 1500; // anti-loop when at saturation / heavy churn
22
+
23
+ // Soft-block during navigation or heavy DOM churn (avoid "placeholder does not exist" spam)
24
+ let EZOIC_BLOCKED_UNTIL = 0;
25
+
16
26
  const SELECTORS = {
17
27
  topicItem: 'li[component="category/topic"]',
18
28
  postItem: '[component="post"][data-pid]',
19
29
  categoryItem: 'li[component="categories/category"]',
20
30
  };
21
31
 
22
- // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
23
- let blockedUntil = 0;
24
- function isBlocked() {
25
- return Date.now() < blockedUntil;
26
- }
27
-
28
32
  const state = {
29
33
  pageKey: null,
30
34
  cfg: null,
31
35
 
32
- // Full lists (never consumed) + cursors for round-robin reuse
36
+ // Full ID lists + cursors (round-robin)
33
37
  allTopics: [],
34
38
  allPosts: [],
35
39
  allCategories: [],
@@ -39,11 +43,6 @@
39
43
 
40
44
  // throttle per placeholder id
41
45
  lastShowById: new Map(),
42
- internalDomChange: 0,
43
- lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
44
-
45
- // track placeholders that have been shown at least once in this pageview
46
- usedOnce: new Set(),
47
46
 
48
47
  // observers / schedulers
49
48
  domObs: null,
@@ -52,22 +51,28 @@
52
51
 
53
52
  // hero
54
53
  heroDoneForPage: false,
55
- };
56
54
 
57
- const insertingIds = new Set();
55
+ // internal DOM changes (to ignore our own mutations)
56
+ internalDomChange: 0,
58
57
 
59
- // Debug logs (enable with localStorage.ezoicInfiniteDebug = "1")
60
- function dbg(...args) {
61
- try {
62
- if (window && window.localStorage && window.localStorage.getItem('ezoicInfiniteDebug') === '1') {
63
- // eslint-disable-next-line no-console
64
- console.log('[ezoicInfinite]', ...args);
65
- }
66
- } catch (e) {}
58
+ // recycle cooldown per kind
59
+ lastRecycleAt: {
60
+ topic: 0,
61
+ categoryTopics: 0,
62
+ categories: 0,
63
+ },
64
+ };
65
+
66
+ // ---------- debug ----------
67
+ function debugEnabled() {
68
+ try { return window.localStorage && window.localStorage.getItem('ezoicInfiniteDebug') === '1'; } catch (e) { return false; }
69
+ }
70
+ function debug(...args) {
71
+ if (!debugEnabled()) return;
72
+ try { console.log('[ezoicInfinite]', ...args); } catch (e) {}
67
73
  }
68
74
 
69
75
  // ---------- small utils ----------
70
-
71
76
  function normalizeBool(v) {
72
77
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
73
78
  }
@@ -94,6 +99,16 @@
94
99
  return uniqInts(lines);
95
100
  }
96
101
 
102
+ function isBlocked() {
103
+ return Date.now() < EZOIC_BLOCKED_UNTIL;
104
+ }
105
+
106
+ function softBlock(ms) {
107
+ const until = Date.now() + Math.max(0, ms || 0);
108
+ if (until > EZOIC_BLOCKED_UNTIL) EZOIC_BLOCKED_UNTIL = until;
109
+ debug('blocked', { until: EZOIC_BLOCKED_UNTIL });
110
+ }
111
+
97
112
  function getPageKey() {
98
113
  try {
99
114
  const ax = window.ajaxify;
@@ -138,8 +153,12 @@
138
153
  });
139
154
  }
140
155
 
141
- // ---------- warm-up & patching ----------
156
+ function withInternalDomChange(fn) {
157
+ state.internalDomChange++;
158
+ try { return fn(); } finally { state.internalDomChange--; }
159
+ }
142
160
 
161
+ // ---------- warm-up & patching ----------
143
162
  const _warmLinksDone = new Set();
144
163
  function warmUpNetwork() {
145
164
  try {
@@ -206,29 +225,7 @@
206
225
  }
207
226
  }
208
227
 
209
- const RECYCLE_COOLDOWN_MS = 1500;
210
-
211
- function kindKeyFromClass(kindClass) {
212
- if (kindClass === 'ezoic-ad-message') return 'topic';
213
- if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
214
- if (kindClass === 'ezoic-ad-categories') return 'categories';
215
- return 'topic';
216
- }
217
-
218
- function withInternalDomChange(fn) {
219
- state.internalDomChange++;
220
- try { fn(); } finally { state.internalDomChange--; }
221
- }
222
-
223
- function canRecycle(kind) {
224
- const now = Date.now();
225
- const last = state.lastRecycleAt[kind] || 0;
226
- if (now - last < RECYCLE_COOLDOWN_MS) return false;
227
- state.lastRecycleAt[kind] = now;
228
- return true;
229
- }
230
- // ---------- config & pools ----------
231
-
228
+ // ---------- config & ids ----------
232
229
  async function fetchConfigOnce() {
233
230
  if (state.cfg) return state.cfg;
234
231
  try {
@@ -241,7 +238,7 @@ function withInternalDomChange(fn) {
241
238
  }
242
239
  }
243
240
 
244
- function initPools(cfg) {
241
+ function initIds(cfg) {
245
242
  if (!cfg) return;
246
243
  if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
247
244
  if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
@@ -249,7 +246,6 @@ function withInternalDomChange(fn) {
249
246
  }
250
247
 
251
248
  // ---------- insertion primitives ----------
252
-
253
249
  function isAdjacentAd(target) {
254
250
  if (!target) return false;
255
251
  const next = target.nextElementSibling;
@@ -261,8 +257,9 @@ function withInternalDomChange(fn) {
261
257
 
262
258
  function buildWrap(id, kindClass, afterPos) {
263
259
  const wrap = document.createElement('div');
264
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
260
+ wrap.className = `${WRAP_CLASS} ${kindClass} is-empty`;
265
261
  wrap.setAttribute('data-ezoic-after', String(afterPos));
262
+ wrap.setAttribute('data-ezoic-wrapid', String(id));
266
263
  wrap.style.width = '100%';
267
264
 
268
265
  const ph = document.createElement('div');
@@ -280,26 +277,68 @@ function withInternalDomChange(fn) {
280
277
  function insertAfter(target, id, kindClass, afterPos) {
281
278
  if (!target || !target.insertAdjacentElement) return null;
282
279
  if (findWrap(kindClass, afterPos)) return null;
283
- if (insertingIds.has(id)) return null;
284
280
 
285
281
  const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
286
282
  if (existingPh && existingPh.isConnected) return null;
287
283
 
288
- insertingIds.add(id);
289
- try {
284
+ return withInternalDomChange(() => {
290
285
  const wrap = buildWrap(id, kindClass, afterPos);
291
286
  target.insertAdjacentElement('afterend', wrap);
292
287
  return wrap;
293
- } finally {
294
- insertingIds.delete(id);
295
- }
288
+ });
289
+ }
290
+
291
+ function destroyPlaceholderId(id) {
292
+ try {
293
+ window.ezstandalone = window.ezstandalone || {};
294
+ const ez = window.ezstandalone;
295
+ if (typeof ez.destroyPlaceholders === 'function') {
296
+ try { ez.destroyPlaceholders(id); } catch (e) {}
297
+ } else if (ez.cmd && Array.isArray(ez.cmd)) {
298
+ ez.cmd.push(() => {
299
+ try {
300
+ if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
301
+ window.ezstandalone.destroyPlaceholders(id);
302
+ }
303
+ } catch (e) {}
304
+ });
305
+ }
306
+ } catch (e) {}
307
+ }
308
+
309
+ function canRecycle(kind) {
310
+ const now = Date.now();
311
+ if (now - (state.lastRecycleAt[kind] || 0) < RECYCLE_COOLDOWN_MS) return false;
312
+ state.lastRecycleAt[kind] = now;
313
+ return true;
314
+ }
315
+
316
+ function purgeOutsideWindow(kindClass, keepFrom, keepTo) {
317
+ // Remove wrappers too far away to free placeholder ids + reduce DOM bloat
318
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
319
+ if (!wraps.length) return 0;
320
+
321
+ let removed = 0;
322
+ withInternalDomChange(() => {
323
+ for (const w of wraps) {
324
+ const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '', 10);
325
+ if (!Number.isFinite(afterPos)) continue;
326
+ if (afterPos >= keepFrom && afterPos <= keepTo) continue;
327
+
328
+ const id = parseInt(w.getAttribute('data-ezoic-wrapid') || '', 10);
329
+ if (Number.isFinite(id) && id > 0) destroyPlaceholderId(id);
330
+
331
+ try { w.remove(); removed++; } catch (e) {}
332
+ }
333
+ });
334
+ return removed;
296
335
  }
297
336
 
298
337
  function pickIdFromAll(allIds, cursorKey) {
299
338
  const n = allIds.length;
300
339
  if (!n) return null;
301
340
 
302
- // Try at most n ids to find one that's not already in the DOM
341
+ // Try at most n IDs to find one not currently present
303
342
  for (let tries = 0; tries < n; tries++) {
304
343
  const idx = state[cursorKey] % n;
305
344
  state[cursorKey] = (state[cursorKey] + 1) % n;
@@ -307,38 +346,24 @@ function withInternalDomChange(fn) {
307
346
  const id = allIds[idx];
308
347
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
309
348
  if (ph && ph.isConnected) continue;
310
-
311
349
  return id;
312
350
  }
313
351
  return null;
314
352
  }
315
353
 
316
-
317
- function removeOneOldWrap(kindClass) {
318
- try {
319
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
320
- if (!wraps.length) return false;
321
-
322
- // Prefer a wrap far above the viewport
323
- let victim = null;
324
- for (const w of wraps) {
325
- const r = w.getBoundingClientRect();
326
- if (r.bottom < -2000) { victim = w; break; }
327
- }
328
- // Otherwise remove the earliest one in the document
329
- if (!victim) victim = wraps[0];
330
-
331
- // Unobserve placeholder if still observed
354
+ function markEmptyLater(id) {
355
+ // Collapse empty blocks if ad never fills
356
+ window.setTimeout(() => {
332
357
  try {
333
- const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
334
- if (ph && state.io) state.io.unobserve(ph);
358
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
359
+ if (!ph || !ph.isConnected) return;
360
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
361
+ if (!wrap) return;
362
+ const hasContent = ph.childElementCount > 0;
363
+ if (hasContent) wrap.classList.remove('is-empty');
364
+ else wrap.classList.add('is-empty');
335
365
  } catch (e) {}
336
-
337
- victim.remove();
338
- return true;
339
- } catch (e) {
340
- return false;
341
- }
366
+ }, 1800);
342
367
  }
343
368
 
344
369
  function showAd(id) {
@@ -348,36 +373,27 @@ function withInternalDomChange(fn) {
348
373
  const last = state.lastShowById.get(id) || 0;
349
374
  if (now - last < 1500) return; // basic throttle
350
375
 
351
- // Defer one frame so the placeholder is definitely in DOM after insertion/recycle
352
- requestAnimationFrame(() => {
376
+ const doShow = () => {
353
377
  if (isBlocked()) return;
354
-
355
378
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
356
379
  if (!ph || !ph.isConnected) return;
357
380
 
358
- const now2 = Date.now();
359
- const last2 = state.lastShowById.get(id) || 0;
360
- if (now2 - last2 < 1200) return;
361
- state.lastShowById.set(id, now2);
381
+ state.lastShowById.set(id, Date.now());
382
+
383
+ // mark empty by default; remove later if filled
384
+ try {
385
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
386
+ if (wrap) wrap.classList.add('is-empty');
387
+ } catch (e) {}
362
388
 
363
389
  try {
364
390
  window.ezstandalone = window.ezstandalone || {};
365
391
  const ez = window.ezstandalone;
366
392
 
367
- const doShow = () => {
368
- try {
369
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
370
- // Avoid Ezoic caching state for reused placeholders
371
- ez.destroyPlaceholders(id);
372
- }
373
- } catch (e) {}
374
- try { ez.showAds(id); } catch (e) {}
375
- try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
376
- };
377
-
378
393
  // Fast path
379
394
  if (typeof ez.showAds === 'function') {
380
- doShow();
395
+ ez.showAds(id);
396
+ markEmptyLater(id);
381
397
  return;
382
398
  }
383
399
 
@@ -390,25 +406,19 @@ function withInternalDomChange(fn) {
390
406
  if (isBlocked()) return;
391
407
  const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
392
408
  if (!el || !el.isConnected) return;
393
- const ez2 = window.ezstandalone;
394
- if (!ez2 || typeof ez2.showAds !== 'function') return;
395
- try {
396
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez2.destroyPlaceholders === 'function') {
397
- ez2.destroyPlaceholders(id);
398
- }
399
- } catch (e) {}
400
- try { ez2.showAds(id); } catch (e) {}
401
- try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
409
+ window.ezstandalone.showAds(id);
410
+ markEmptyLater(id);
402
411
  } catch (e) {}
403
412
  });
404
413
  }
405
414
  } catch (e) {}
406
- });
407
- }
415
+ };
408
416
 
417
+ // Defer one frame to reduce "element does not exist" warnings
418
+ window.requestAnimationFrame(doShow);
419
+ }
409
420
 
410
421
  // ---------- preload / above-the-fold ----------
411
-
412
422
  function ensurePreloadObserver() {
413
423
  if (state.io) return state.io;
414
424
  try {
@@ -442,22 +452,72 @@ function withInternalDomChange(fn) {
442
452
  } catch (e) {}
443
453
  }
444
454
 
445
- // ---------- insertion logic ----------
455
+ // ---------- windowed insertion logic ----------
456
+ function estimateWindowItems(interval, idsCount) {
457
+ // Ensure we can roughly keep "every X" within the window using available ids
458
+ // If ids are low, keep a smaller window to avoid constant recycling.
459
+ const base = Math.max(WINDOW_MIN_ITEMS, Math.floor((idsCount || 1) * Math.max(1, interval) * 1.1));
460
+ return Math.min(WINDOW_MAX_ITEMS, base);
461
+ }
446
462
 
447
- function computeTargets(count, interval, showFirst) {
448
- const out = [];
449
- if (count <= 0) return out;
450
- if (showFirst) out.push(1);
451
- for (let i = 1; i <= count; i++) {
452
- if (i % interval === 0) out.push(i);
463
+ function getVisibleRange(items) {
464
+ // Returns [startIdx, endIdx] (0-based, inclusive) for items near viewport
465
+ let start = 0;
466
+ let end = items.length - 1;
467
+ let foundAny = false;
468
+
469
+ const topBound = -WINDOW_BUFFER_PX;
470
+ const bottomBound = window.innerHeight + WINDOW_BUFFER_PX;
471
+
472
+ for (let i = 0; i < items.length; i++) {
473
+ const el = items[i];
474
+ if (!el || !el.isConnected) continue;
475
+ let r;
476
+ try { r = el.getBoundingClientRect(); } catch (e) { continue; }
477
+ if (r.bottom < topBound) { start = i + 1; continue; }
478
+ if (r.top > bottomBound) { end = i - 1; break; }
479
+ foundAny = true;
453
480
  }
454
- return Array.from(new Set(out)).sort((a, b) => a - b);
481
+
482
+ if (!foundAny) return [0, Math.min(items.length - 1, 30)];
483
+ start = Math.max(0, Math.min(start, items.length - 1));
484
+ end = Math.max(start, Math.min(end, items.length - 1));
485
+ return [start, end];
486
+ }
487
+
488
+ function computeTargetsInRange(fromPos, toPos, interval, showFirst) {
489
+ const out = [];
490
+ if (toPos < fromPos) return out;
491
+
492
+ if (showFirst && 1 >= fromPos && 1 <= toPos) out.push(1);
493
+
494
+ const startK = Math.ceil(fromPos / interval) * interval;
495
+ for (let p = startK; p <= toPos; p += interval) out.push(p);
496
+
497
+ return out;
455
498
  }
456
499
 
457
- function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
500
+ function injectWindowed(kind, kindClass, items, interval, showFirst, allIds, cursorKey) {
458
501
  if (!items.length) return 0;
459
502
 
460
- const targets = computeTargets(items.length, interval, showFirst);
503
+ const idsCount = allIds.length;
504
+ const windowItems = estimateWindowItems(interval, idsCount);
505
+
506
+ const [visStart, visEnd] = getVisibleRange(items);
507
+ const mid = Math.floor((visStart + visEnd) / 2);
508
+ const winStartIdx = Math.max(0, mid - Math.floor(windowItems / 2));
509
+ const winEndIdx = Math.min(items.length - 1, winStartIdx + windowItems - 1);
510
+
511
+ const keepFrom = Math.max(1, (winStartIdx + 1) - PURGE_BUFFER_ITEMS);
512
+ const keepTo = Math.min(items.length, (winEndIdx + 1) + PURGE_BUFFER_ITEMS);
513
+
514
+ const purged = purgeOutsideWindow(kindClass, keepFrom, keepTo);
515
+ if (purged) debug('purge', kindClass, { purged, keepFrom, keepTo });
516
+
517
+ const fromPos = winStartIdx + 1;
518
+ const toPos = winEndIdx + 1;
519
+
520
+ const targets = computeTargetsInRange(fromPos, toPos, interval, showFirst);
461
521
  let inserted = 0;
462
522
 
463
523
  for (const afterPos of targets) {
@@ -469,25 +529,25 @@ function withInternalDomChange(fn) {
469
529
  if (findWrap(kindClass, afterPos)) continue;
470
530
 
471
531
  let id = pickIdFromAll(allIds, cursorKey);
532
+
533
+ // If no ID is currently free, try a purge (only occasionally) then try again
472
534
  if (!id) {
473
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
474
- // Guard against tight observer loops.
475
- if (!canRecycle(kindKeyFromClass(kindClass))) {
476
- dbg('recycle-skip-cooldown', kindClass);
535
+ if (!canRecycle(kind)) {
536
+ debug('recycle-skip-cooldown', kindClass);
477
537
  break;
478
538
  }
479
- let recycled = false;
480
- withInternalDomChange(() => {
481
- recycled = removeOneOldWrap(kindClass);
482
- });
483
- dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
484
- // Stop this run after a recycle; the next mutation/scroll will retry injection.
539
+ const extraKeepFrom = Math.max(1, keepFrom + PURGE_BUFFER_ITEMS);
540
+ const extraKeepTo = Math.min(items.length, keepTo - PURGE_BUFFER_ITEMS);
541
+ const purgedMore = purgeOutsideWindow(kindClass, extraKeepFrom, extraKeepTo);
542
+ debug('recycle-needed', kindClass, { recycled: purgedMore > 0, ids: idsCount });
543
+
544
+ // After a recycle attempt, stop this run and wait for next tick to stabilize DOM
545
+ softBlock(400);
485
546
  break;
486
547
  }
548
+
487
549
  const wrap = insertAfter(el, id, kindClass, afterPos);
488
- if (!wrap) {
489
- continue;
490
- }
550
+ if (!wrap) continue;
491
551
 
492
552
  observePlaceholder(id);
493
553
  inserted += 1;
@@ -499,10 +559,9 @@ function withInternalDomChange(fn) {
499
559
  async function insertHeroAdEarly() {
500
560
  if (state.heroDoneForPage) return;
501
561
  const cfg = await fetchConfigOnce();
502
- if (!cfg) { dbg('no-config'); return; }
503
- if (cfg.excluded) { dbg('excluded'); return; }
562
+ if (!cfg || cfg.excluded) return;
504
563
 
505
- initPools(cfg);
564
+ initIds(cfg);
506
565
 
507
566
  const kind = getKind();
508
567
  let items = [];
@@ -512,31 +571,32 @@ function withInternalDomChange(fn) {
512
571
  let showFirst = false;
513
572
 
514
573
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
574
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
575
+ if (!showFirst) return;
515
576
  items = getPostContainers();
516
577
  allIds = state.allPosts;
517
578
  cursorKey = 'curPosts';
518
579
  kindClass = 'ezoic-ad-message';
519
- showFirst = normalizeBool(cfg.showFirstMessageAd);
520
580
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
581
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
582
+ if (!showFirst) return;
521
583
  items = getTopicItems();
522
584
  allIds = state.allTopics;
523
585
  cursorKey = 'curTopics';
524
586
  kindClass = 'ezoic-ad-between';
525
- showFirst = normalizeBool(cfg.showFirstTopicAd);
526
587
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
588
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
589
+ if (!showFirst) return;
527
590
  items = getCategoryItems();
528
591
  allIds = state.allCategories;
529
592
  cursorKey = 'curCategories';
530
593
  kindClass = 'ezoic-ad-categories';
531
- showFirst = normalizeBool(cfg.showFirstCategoryAd);
532
594
  } else {
533
595
  return;
534
596
  }
535
597
 
536
598
  if (!items.length) return;
537
- if (!showFirst) { state.heroDoneForPage = true; return; }
538
599
 
539
- // Insert after the very first item (above-the-fold)
540
600
  const afterPos = 1;
541
601
  const el = items[afterPos - 1];
542
602
  if (!el || !el.isConnected) return;
@@ -547,29 +607,27 @@ function withInternalDomChange(fn) {
547
607
  if (!id) return;
548
608
 
549
609
  const wrap = insertAfter(el, id, kindClass, afterPos);
550
- if (!wrap) {
551
- return;
552
- }
610
+ if (!wrap) return;
553
611
 
554
612
  state.heroDoneForPage = true;
555
613
  observePlaceholder(id);
556
614
  }
557
615
 
558
616
  async function runCore() {
559
- if (isBlocked()) { dbg('blocked'); return; }
617
+ if (isBlocked()) return;
560
618
 
561
619
  patchShowAds();
562
620
 
563
621
  const cfg = await fetchConfigOnce();
564
- if (!cfg) { dbg('no-config'); return; }
565
- if (cfg.excluded) { dbg('excluded'); return; }
566
- initPools(cfg);
622
+ if (!cfg || cfg.excluded) return;
623
+ initIds(cfg);
567
624
 
568
625
  const kind = getKind();
569
626
 
570
627
  if (kind === 'topic') {
571
628
  if (normalizeBool(cfg.enableMessageAds)) {
572
- injectBetween(
629
+ injectWindowed(
630
+ 'topic',
573
631
  'ezoic-ad-message',
574
632
  getPostContainers(),
575
633
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
@@ -580,7 +638,8 @@ function withInternalDomChange(fn) {
580
638
  }
581
639
  } else if (kind === 'categoryTopics') {
582
640
  if (normalizeBool(cfg.enableBetweenAds)) {
583
- injectBetween(
641
+ injectWindowed(
642
+ 'categoryTopics',
584
643
  'ezoic-ad-between',
585
644
  getTopicItems(),
586
645
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
@@ -591,7 +650,8 @@ function withInternalDomChange(fn) {
591
650
  }
592
651
  } else if (kind === 'categories') {
593
652
  if (normalizeBool(cfg.enableCategoryAds)) {
594
- injectBetween(
653
+ injectWindowed(
654
+ 'categories',
595
655
  'ezoic-ad-categories',
596
656
  getCategoryItems(),
597
657
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
@@ -615,14 +675,19 @@ function withInternalDomChange(fn) {
615
675
  }
616
676
 
617
677
  // ---------- observers / lifecycle ----------
618
-
619
678
  function cleanup() {
620
- blockedUntil = Date.now() + 1200;
679
+ softBlock(2000);
621
680
 
622
681
  // remove all wrappers
623
682
  try {
624
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
625
- try { el.remove(); } catch (e) {}
683
+ withInternalDomChange(() => {
684
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
685
+ try {
686
+ const id = parseInt(el.getAttribute('data-ezoic-wrapid') || '', 10);
687
+ if (Number.isFinite(id) && id > 0) destroyPlaceholderId(id);
688
+ } catch (e) {}
689
+ try { el.remove(); } catch (e) {}
690
+ });
626
691
  });
627
692
  } catch (e) {}
628
693
 
@@ -634,18 +699,20 @@ function withInternalDomChange(fn) {
634
699
  state.curTopics = 0;
635
700
  state.curPosts = 0;
636
701
  state.curCategories = 0;
702
+
637
703
  state.lastShowById.clear();
638
- try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
639
704
  state.heroDoneForPage = false;
640
-
641
- // keep observers alive (MutationObserver will re-trigger after navigation)
705
+ state.lastRecycleAt.topic = 0;
706
+ state.lastRecycleAt.categoryTopics = 0;
707
+ state.lastRecycleAt.categories = 0;
642
708
  }
643
709
 
644
710
  function ensureDomObserver() {
645
711
  if (state.domObs) return;
646
712
  state.domObs = new MutationObserver(() => {
647
713
  if (state.internalDomChange > 0) return;
648
- if (!isBlocked()) scheduleRun();
714
+ if (isBlocked()) return;
715
+ scheduleRun();
649
716
  });
650
717
  try {
651
718
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -663,7 +730,7 @@ function withInternalDomChange(fn) {
663
730
 
664
731
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
665
732
  state.pageKey = getPageKey();
666
- blockedUntil = 0;
733
+ softBlock(500);
667
734
 
668
735
  warmUpNetwork();
669
736
  patchShowAds();
@@ -697,7 +764,6 @@ function withInternalDomChange(fn) {
697
764
  }
698
765
 
699
766
  // ---------- boot ----------
700
-
701
767
  state.pageKey = getPageKey();
702
768
  warmUpNetwork();
703
769
  patchShowAds();
@@ -708,7 +774,7 @@ function withInternalDomChange(fn) {
708
774
  bindScroll();
709
775
 
710
776
  // First paint: try hero + run
711
- blockedUntil = 0;
777
+ softBlock(300);
712
778
  insertHeroAdEarly().catch(() => {});
713
779
  scheduleRun();
714
- })();
780
+ })();
package/public/style.css CHANGED
@@ -19,3 +19,21 @@
19
19
  margin: 0 !important;
20
20
  padding: 0 !important;
21
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: none !important;
27
+ margin: 0 !important;
28
+ padding: 0 !important;
29
+ height: 0 !important;
30
+ min-height: 0 !important;
31
+ }
32
+
33
+ .ezoic-ad {
34
+ min-height: 0 !important;
35
+ }
36
+
37
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
38
+ min-height: 0 !important;
39
+ }