nodebb-plugin-ezoic-infinite 1.5.26 → 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.26",
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,20 +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
- // Hard block during navigation to avoid “placeholder does not exist” spam
23
- let EZOIC_BLOCKED = false;
24
-
25
32
  const state = {
26
33
  pageKey: null,
27
34
  cfg: null,
28
35
 
29
- // Full lists (never consumed) + cursors for round-robin reuse
36
+ // Full ID lists + cursors (round-robin)
30
37
  allTopics: [],
31
38
  allPosts: [],
32
39
  allCategories: [],
@@ -37,9 +44,6 @@
37
44
  // throttle per placeholder id
38
45
  lastShowById: new Map(),
39
46
 
40
- // track placeholders that have been shown at least once in this pageview
41
- usedOnce: new Set(),
42
-
43
47
  // observers / schedulers
44
48
  domObs: null,
45
49
  io: null,
@@ -47,22 +51,28 @@
47
51
 
48
52
  // hero
49
53
  heroDoneForPage: false,
50
- };
51
54
 
52
- const insertingIds = new Set();
55
+ // internal DOM changes (to ignore our own mutations)
56
+ internalDomChange: 0,
53
57
 
54
- // Debug logs (enable with localStorage.ezoicInfiniteDebug = "1")
55
- function dbg(...args) {
56
- try {
57
- if (window && window.localStorage && window.localStorage.getItem('ezoicInfiniteDebug') === '1') {
58
- // eslint-disable-next-line no-console
59
- console.log('[ezoicInfinite]', ...args);
60
- }
61
- } 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) {}
62
73
  }
63
74
 
64
75
  // ---------- small utils ----------
65
-
66
76
  function normalizeBool(v) {
67
77
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
68
78
  }
@@ -89,6 +99,16 @@
89
99
  return uniqInts(lines);
90
100
  }
91
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
+
92
112
  function getPageKey() {
93
113
  try {
94
114
  const ax = window.ajaxify;
@@ -133,8 +153,12 @@
133
153
  });
134
154
  }
135
155
 
136
- // ---------- warm-up & patching ----------
156
+ function withInternalDomChange(fn) {
157
+ state.internalDomChange++;
158
+ try { return fn(); } finally { state.internalDomChange--; }
159
+ }
137
160
 
161
+ // ---------- warm-up & patching ----------
138
162
  const _warmLinksDone = new Set();
139
163
  function warmUpNetwork() {
140
164
  try {
@@ -172,7 +196,7 @@
172
196
  const orig = ez.showAds;
173
197
 
174
198
  ez.showAds = function (...args) {
175
- if (EZOIC_BLOCKED) return;
199
+ if (isBlocked()) return;
176
200
 
177
201
  let ids = [];
178
202
  if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
@@ -201,8 +225,7 @@
201
225
  }
202
226
  }
203
227
 
204
- // ---------- config & pools ----------
205
-
228
+ // ---------- config & ids ----------
206
229
  async function fetchConfigOnce() {
207
230
  if (state.cfg) return state.cfg;
208
231
  try {
@@ -215,7 +238,7 @@
215
238
  }
216
239
  }
217
240
 
218
- function initPools(cfg) {
241
+ function initIds(cfg) {
219
242
  if (!cfg) return;
220
243
  if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
221
244
  if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
@@ -223,7 +246,6 @@
223
246
  }
224
247
 
225
248
  // ---------- insertion primitives ----------
226
-
227
249
  function isAdjacentAd(target) {
228
250
  if (!target) return false;
229
251
  const next = target.nextElementSibling;
@@ -235,8 +257,9 @@
235
257
 
236
258
  function buildWrap(id, kindClass, afterPos) {
237
259
  const wrap = document.createElement('div');
238
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
260
+ wrap.className = `${WRAP_CLASS} ${kindClass} is-empty`;
239
261
  wrap.setAttribute('data-ezoic-after', String(afterPos));
262
+ wrap.setAttribute('data-ezoic-wrapid', String(id));
240
263
  wrap.style.width = '100%';
241
264
 
242
265
  const ph = document.createElement('div');
@@ -254,26 +277,68 @@
254
277
  function insertAfter(target, id, kindClass, afterPos) {
255
278
  if (!target || !target.insertAdjacentElement) return null;
256
279
  if (findWrap(kindClass, afterPos)) return null;
257
- if (insertingIds.has(id)) return null;
258
280
 
259
281
  const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
260
282
  if (existingPh && existingPh.isConnected) return null;
261
283
 
262
- insertingIds.add(id);
263
- try {
284
+ return withInternalDomChange(() => {
264
285
  const wrap = buildWrap(id, kindClass, afterPos);
265
286
  target.insertAdjacentElement('afterend', wrap);
266
287
  return wrap;
267
- } finally {
268
- insertingIds.delete(id);
269
- }
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;
270
335
  }
271
336
 
272
337
  function pickIdFromAll(allIds, cursorKey) {
273
338
  const n = allIds.length;
274
339
  if (!n) return null;
275
340
 
276
- // 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
277
342
  for (let tries = 0; tries < n; tries++) {
278
343
  const idx = state[cursorKey] % n;
279
344
  state[cursorKey] = (state[cursorKey] + 1) % n;
@@ -281,93 +346,79 @@
281
346
  const id = allIds[idx];
282
347
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
283
348
  if (ph && ph.isConnected) continue;
284
-
285
349
  return id;
286
350
  }
287
351
  return null;
288
352
  }
289
353
 
290
-
291
- function removeOneOldWrap(kindClass) {
292
- try {
293
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
294
- if (!wraps.length) return false;
295
-
296
- // Prefer a wrap far above the viewport
297
- let victim = null;
298
- for (const w of wraps) {
299
- const r = w.getBoundingClientRect();
300
- if (r.bottom < -2000) { victim = w; break; }
301
- }
302
- // Otherwise remove the earliest one in the document
303
- if (!victim) victim = wraps[0];
304
-
305
- // Unobserve placeholder if still observed
354
+ function markEmptyLater(id) {
355
+ // Collapse empty blocks if ad never fills
356
+ window.setTimeout(() => {
306
357
  try {
307
- const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
308
- 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');
309
365
  } catch (e) {}
310
-
311
- victim.remove();
312
- return true;
313
- } catch (e) {
314
- return false;
315
- }
366
+ }, 1800);
316
367
  }
317
368
 
318
369
  function showAd(id) {
319
- if (!id || EZOIC_BLOCKED) return;
370
+ if (!id || isBlocked()) return;
320
371
 
321
372
  const now = Date.now();
322
373
  const last = state.lastShowById.get(id) || 0;
323
374
  if (now - last < 1500) return; // basic throttle
324
375
 
325
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
326
- if (!ph || !ph.isConnected) return;
376
+ const doShow = () => {
377
+ if (isBlocked()) return;
378
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
379
+ if (!ph || !ph.isConnected) return;
327
380
 
328
- state.lastShowById.set(id, now);
381
+ state.lastShowById.set(id, Date.now());
329
382
 
330
- try {
331
- window.ezstandalone = window.ezstandalone || {};
332
- const ez = window.ezstandalone;
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) {}
333
388
 
334
- // Fast path
335
- if (typeof ez.showAds === 'function') {
336
- try {
337
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
338
- ez.destroyPlaceholders(id);
339
- }
340
- } catch (e) {}
341
- ez.showAds(id);
342
- try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
343
- return;
344
- }
389
+ try {
390
+ window.ezstandalone = window.ezstandalone || {};
391
+ const ez = window.ezstandalone;
345
392
 
346
- // Queue once for when Ezoic is ready
347
- ez.cmd = ez.cmd || [];
348
- if (!ph.__ezoicQueued) {
349
- ph.__ezoicQueued = true;
350
- ez.cmd.push(() => {
351
- try {
352
- if (EZOIC_BLOCKED) return;
353
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
354
- if (!el || !el.isConnected) return;
355
- const ez2 = window.ezstandalone;
393
+ // Fast path
394
+ if (typeof ez.showAds === 'function') {
395
+ ez.showAds(id);
396
+ markEmptyLater(id);
397
+ return;
398
+ }
399
+
400
+ // Queue once for when Ezoic is ready
401
+ ez.cmd = ez.cmd || [];
402
+ if (!ph.__ezoicQueued) {
403
+ ph.__ezoicQueued = true;
404
+ ez.cmd.push(() => {
356
405
  try {
357
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez2.destroyPlaceholders === 'function') {
358
- ez2.destroyPlaceholders(id);
359
- }
406
+ if (isBlocked()) return;
407
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
+ if (!el || !el.isConnected) return;
409
+ window.ezstandalone.showAds(id);
410
+ markEmptyLater(id);
360
411
  } catch (e) {}
361
- ez2.showAds(id);
362
- try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
363
- } catch (e) {}
364
- });
365
- }
366
- } catch (e) {}
412
+ });
413
+ }
414
+ } catch (e) {}
415
+ };
416
+
417
+ // Defer one frame to reduce "element does not exist" warnings
418
+ window.requestAnimationFrame(doShow);
367
419
  }
368
420
 
369
421
  // ---------- preload / above-the-fold ----------
370
-
371
422
  function ensurePreloadObserver() {
372
423
  if (state.io) return state.io;
373
424
  try {
@@ -401,22 +452,72 @@
401
452
  } catch (e) {}
402
453
  }
403
454
 
404
- // ---------- 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
+ }
405
462
 
406
- function computeTargets(count, interval, showFirst) {
407
- const out = [];
408
- if (count <= 0) return out;
409
- if (showFirst) out.push(1);
410
- for (let i = 1; i <= count; i++) {
411
- 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;
412
480
  }
413
- 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;
414
498
  }
415
499
 
416
- function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
500
+ function injectWindowed(kind, kindClass, items, interval, showFirst, allIds, cursorKey) {
417
501
  if (!items.length) return 0;
418
502
 
419
- 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);
420
521
  let inserted = 0;
421
522
 
422
523
  for (const afterPos of targets) {
@@ -428,17 +529,25 @@
428
529
  if (findWrap(kindClass, afterPos)) continue;
429
530
 
430
531
  let id = pickIdFromAll(allIds, cursorKey);
532
+
533
+ // If no ID is currently free, try a purge (only occasionally) then try again
431
534
  if (!id) {
432
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id
433
- const recycled = removeOneOldWrap(kindClass);
434
- dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
435
- id = pickIdFromAll(allIds, cursorKey);
535
+ if (!canRecycle(kind)) {
536
+ debug('recycle-skip-cooldown', kindClass);
537
+ break;
538
+ }
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);
546
+ break;
436
547
  }
437
- if (!id) break;
548
+
438
549
  const wrap = insertAfter(el, id, kindClass, afterPos);
439
- if (!wrap) {
440
- continue;
441
- }
550
+ if (!wrap) continue;
442
551
 
443
552
  observePlaceholder(id);
444
553
  inserted += 1;
@@ -450,10 +559,9 @@
450
559
  async function insertHeroAdEarly() {
451
560
  if (state.heroDoneForPage) return;
452
561
  const cfg = await fetchConfigOnce();
453
- if (!cfg) { dbg('no-config'); return; }
454
- if (cfg.excluded) { dbg('excluded'); return; }
562
+ if (!cfg || cfg.excluded) return;
455
563
 
456
- initPools(cfg);
564
+ initIds(cfg);
457
565
 
458
566
  const kind = getKind();
459
567
  let items = [];
@@ -463,31 +571,32 @@
463
571
  let showFirst = false;
464
572
 
465
573
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
574
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
575
+ if (!showFirst) return;
466
576
  items = getPostContainers();
467
577
  allIds = state.allPosts;
468
578
  cursorKey = 'curPosts';
469
579
  kindClass = 'ezoic-ad-message';
470
- showFirst = normalizeBool(cfg.showFirstMessageAd);
471
580
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
581
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
582
+ if (!showFirst) return;
472
583
  items = getTopicItems();
473
584
  allIds = state.allTopics;
474
585
  cursorKey = 'curTopics';
475
586
  kindClass = 'ezoic-ad-between';
476
- showFirst = normalizeBool(cfg.showFirstTopicAd);
477
587
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
588
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
589
+ if (!showFirst) return;
478
590
  items = getCategoryItems();
479
591
  allIds = state.allCategories;
480
592
  cursorKey = 'curCategories';
481
593
  kindClass = 'ezoic-ad-categories';
482
- showFirst = normalizeBool(cfg.showFirstCategoryAd);
483
594
  } else {
484
595
  return;
485
596
  }
486
597
 
487
598
  if (!items.length) return;
488
- if (!showFirst) { state.heroDoneForPage = true; return; }
489
599
 
490
- // Insert after the very first item (above-the-fold)
491
600
  const afterPos = 1;
492
601
  const el = items[afterPos - 1];
493
602
  if (!el || !el.isConnected) return;
@@ -498,29 +607,27 @@
498
607
  if (!id) return;
499
608
 
500
609
  const wrap = insertAfter(el, id, kindClass, afterPos);
501
- if (!wrap) {
502
- return;
503
- }
610
+ if (!wrap) return;
504
611
 
505
612
  state.heroDoneForPage = true;
506
613
  observePlaceholder(id);
507
614
  }
508
615
 
509
616
  async function runCore() {
510
- if (EZOIC_BLOCKED) { dbg('blocked'); return; }
617
+ if (isBlocked()) return;
511
618
 
512
619
  patchShowAds();
513
620
 
514
621
  const cfg = await fetchConfigOnce();
515
- if (!cfg) { dbg('no-config'); return; }
516
- if (cfg.excluded) { dbg('excluded'); return; }
517
- initPools(cfg);
622
+ if (!cfg || cfg.excluded) return;
623
+ initIds(cfg);
518
624
 
519
625
  const kind = getKind();
520
626
 
521
627
  if (kind === 'topic') {
522
628
  if (normalizeBool(cfg.enableMessageAds)) {
523
- injectBetween(
629
+ injectWindowed(
630
+ 'topic',
524
631
  'ezoic-ad-message',
525
632
  getPostContainers(),
526
633
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
@@ -531,7 +638,8 @@
531
638
  }
532
639
  } else if (kind === 'categoryTopics') {
533
640
  if (normalizeBool(cfg.enableBetweenAds)) {
534
- injectBetween(
641
+ injectWindowed(
642
+ 'categoryTopics',
535
643
  'ezoic-ad-between',
536
644
  getTopicItems(),
537
645
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
@@ -542,7 +650,8 @@
542
650
  }
543
651
  } else if (kind === 'categories') {
544
652
  if (normalizeBool(cfg.enableCategoryAds)) {
545
- injectBetween(
653
+ injectWindowed(
654
+ 'categories',
546
655
  'ezoic-ad-categories',
547
656
  getCategoryItems(),
548
657
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
@@ -566,14 +675,19 @@
566
675
  }
567
676
 
568
677
  // ---------- observers / lifecycle ----------
569
-
570
678
  function cleanup() {
571
- EZOIC_BLOCKED = true;
679
+ softBlock(2000);
572
680
 
573
681
  // remove all wrappers
574
682
  try {
575
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
576
- 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
+ });
577
691
  });
578
692
  } catch (e) {}
579
693
 
@@ -585,17 +699,20 @@
585
699
  state.curTopics = 0;
586
700
  state.curPosts = 0;
587
701
  state.curCategories = 0;
702
+
588
703
  state.lastShowById.clear();
589
- try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
590
704
  state.heroDoneForPage = false;
591
-
592
- // keep observers alive (MutationObserver will re-trigger after navigation)
705
+ state.lastRecycleAt.topic = 0;
706
+ state.lastRecycleAt.categoryTopics = 0;
707
+ state.lastRecycleAt.categories = 0;
593
708
  }
594
709
 
595
710
  function ensureDomObserver() {
596
711
  if (state.domObs) return;
597
712
  state.domObs = new MutationObserver(() => {
598
- if (!EZOIC_BLOCKED) scheduleRun();
713
+ if (state.internalDomChange > 0) return;
714
+ if (isBlocked()) return;
715
+ scheduleRun();
599
716
  });
600
717
  try {
601
718
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -613,7 +730,7 @@
613
730
 
614
731
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
615
732
  state.pageKey = getPageKey();
616
- EZOIC_BLOCKED = false;
733
+ softBlock(500);
617
734
 
618
735
  warmUpNetwork();
619
736
  patchShowAds();
@@ -629,7 +746,7 @@
629
746
 
630
747
  // Infinite scroll / partial updates
631
748
  $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
632
- if (EZOIC_BLOCKED) return;
749
+ if (isBlocked()) return;
633
750
  scheduleRun();
634
751
  });
635
752
  }
@@ -641,13 +758,12 @@
641
758
  ticking = true;
642
759
  window.requestAnimationFrame(() => {
643
760
  ticking = false;
644
- if (!EZOIC_BLOCKED) scheduleRun();
761
+ if (!isBlocked()) scheduleRun();
645
762
  });
646
763
  }, { passive: true });
647
764
  }
648
765
 
649
766
  // ---------- boot ----------
650
-
651
767
  state.pageKey = getPageKey();
652
768
  warmUpNetwork();
653
769
  patchShowAds();
@@ -658,7 +774,7 @@
658
774
  bindScroll();
659
775
 
660
776
  // First paint: try hero + run
661
- EZOIC_BLOCKED = false;
777
+ softBlock(300);
662
778
  insertHeroAdEarly().catch(() => {});
663
779
  scheduleRun();
664
- })();
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
+ }