nodebb-plugin-ezoic-infinite 1.6.68 → 1.6.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +156 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.68",
3
+ "version": "1.6.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
@@ -119,6 +119,10 @@
119
119
  burstDeadline:0,
120
120
  burstCount: 0,
121
121
  lastBurstTs: 0,
122
+
123
+ // Detached wraps cache (to survive NodeBB virtual/infinite scroll recycling)
124
+ // kindClass -> Map(afterPos -> wrapNode)
125
+ detached: new Map(),
122
126
  };
123
127
 
124
128
  let blockedUntil = 0;
@@ -228,7 +232,22 @@
228
232
  try {
229
233
  if (!wrap) return;
230
234
  const iframes = wrap.querySelectorAll('iframe');
231
- if (!iframes.length) return;
235
+
236
+ // If no iframe yet (or no-fill), Ezoic can still reserve a large gap via
237
+ // inline `min-height:400px !important` on `.ezoic-ad` elements.
238
+ // Inline !important beats our CSS, so we proactively shrink it.
239
+ if (!iframes.length) {
240
+ const bad = wrap.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"], span.ezoic-ad[style*="min-height"]');
241
+ bad.forEach(n => {
242
+ const s = (n.getAttribute('style') || '').toLowerCase();
243
+ if (s.includes('min-height:400') || s.includes('min-height: 400')) {
244
+ try { n.style.setProperty('min-height', '1px', 'important'); } catch (e) { n.style.minHeight = '1px'; }
245
+ try { n.style.setProperty('height', 'auto', 'important'); } catch (e) {}
246
+ try { n.style.setProperty('line-height', '0', 'important'); } catch (e) {}
247
+ }
248
+ });
249
+ return;
250
+ }
232
251
 
233
252
  // Find the nearest ezoic-ad ancestor with the 400px inline min-height.
234
253
  let ref = null;
@@ -394,6 +413,54 @@
394
413
  try { wrap.remove(); } catch (e) {}
395
414
  }
396
415
 
416
+ function getDetachedMap(kindClass) {
417
+ let m = st.detached.get(kindClass);
418
+ if (!m) { m = new Map(); st.detached.set(kindClass, m); }
419
+ return m;
420
+ }
421
+
422
+ function detachWrap(kindClass, wrap) {
423
+ // Keep a wrap node in memory so we can re-attach it later without
424
+ // re-requesting the same placement (avoids GPT "format already created" and
425
+ // prevents ads from vanishing permanently when NodeBB recycles DOM nodes).
426
+ try {
427
+ const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
428
+ if (!afterPos) return dropWrap(wrap);
429
+
430
+ // Unobserve placeholder while detached
431
+ try {
432
+ const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
433
+ if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
434
+ } catch (e) {}
435
+
436
+ const m = getDetachedMap(kindClass);
437
+ // If a cached wrap for that position already exists, prefer the newest
438
+ // and fully drop the old one.
439
+ if (m.has(afterPos)) {
440
+ try { dropWrap(m.get(afterPos)); } catch (e) {}
441
+ }
442
+ m.set(afterPos, wrap);
443
+
444
+ // Hard cap cache size per kind to avoid unbounded memory growth.
445
+ if (m.size > 80) {
446
+ const oldest = [...m.entries()].sort((a, b) => {
447
+ const ta = parseInt((a[1] && a[1].getAttribute('data-created')) || '0', 10);
448
+ const tb = parseInt((b[1] && b[1].getAttribute('data-created')) || '0', 10);
449
+ return ta - tb;
450
+ }).slice(0, m.size - 80);
451
+ oldest.forEach(([pos, node]) => {
452
+ m.delete(pos);
453
+ try { dropWrap(node); } catch (e) {}
454
+ });
455
+ }
456
+
457
+ // Actually detach from DOM
458
+ try { wrap.remove(); } catch (e) {}
459
+ } catch (e) {
460
+ try { dropWrap(wrap); } catch (x) {}
461
+ }
462
+ }
463
+
397
464
  function pickId(poolKey, cursorKey) {
398
465
  const pool = st.pools[poolKey];
399
466
  if (!pool.length) return null;
@@ -477,11 +544,44 @@
477
544
  const w = ph.closest('.' + WRAP_CLASS);
478
545
  if (!w) return;
479
546
  if (isFilled(ph)) { w.classList.remove('is-empty'); tightenMinHeight(w); }
480
- else { w.classList.add('is-empty'); watchFill(w); }
547
+ else {
548
+ w.classList.add('is-empty');
549
+ // Kill any large reserved gap even when there is no iframe.
550
+ tightenMinHeight(w);
551
+ watchFill(w);
552
+ scheduleHardPruneEmpty(id);
553
+ }
481
554
  } catch (e) {}
482
555
  }, 15_000);
483
556
  }
484
557
 
558
+ // If a placement stays empty for a long time, remove it entirely so the user
559
+ // doesn't get persistent blank blocks. This also frees the placeholder ID so
560
+ // a future injection can try a different one.
561
+ function scheduleHardPruneEmpty(id) {
562
+ setTimeout(() => {
563
+ try {
564
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
565
+ if (!ph || !ph.isConnected) return;
566
+ const w = ph.closest('.' + WRAP_CLASS);
567
+ if (!w) return;
568
+ // If still not filled after 45s, drop it.
569
+ if (!isFilled(w)) {
570
+ // Also ensure it isn't cached as detached.
571
+ try {
572
+ const kind = Array.from(w.classList).find(c => c.startsWith('ezoic-ad-'));
573
+ if (kind) {
574
+ const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '0', 10);
575
+ const m = st.detached.get(kind);
576
+ if (m && afterPos && m.get(afterPos) === w) m.delete(afterPos);
577
+ }
578
+ } catch (e) {}
579
+ withInternal(() => dropWrap(w));
580
+ }
581
+ } catch (e) {}
582
+ }, 45_000);
583
+ }
584
+
485
585
  function startShow(id) {
486
586
  if (!id || isBlocked()) return;
487
587
  st.inflight++;
@@ -573,16 +673,38 @@
573
673
  if (itemSet.has(el)) { found = true; break; }
574
674
  }
575
675
 
576
- if (!found) withInternal(() => dropWrap(wrap));
676
+ if (!found) {
677
+ // Detach instead of destroy so ads can survive DOM recycling.
678
+ withInternal(() => detachWrap(kindClass, wrap));
679
+ }
577
680
  });
578
681
  }
579
682
 
683
+ function reapDetached(kindClass) {
684
+ // Soft-prune very old detached nodes.
685
+ try {
686
+ const m = st.detached.get(kindClass);
687
+ if (!m || !m.size) return;
688
+ for (const [pos, wrap] of m.entries()) {
689
+ if (!wrap) { m.delete(pos); continue; }
690
+ if (wrap.isConnected) { m.delete(pos); continue; }
691
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
692
+ if (created && (now() - created) > 15 * 60 * 1000) {
693
+ m.delete(pos);
694
+ try { dropWrap(wrap); } catch (e) {}
695
+ }
696
+ }
697
+ } catch (e) {}
698
+ }
699
+
580
700
  function injectWraps(kindClass, items, interval, showFirst, poolKey, cursorKey) {
581
701
  if (!items.length) return 0;
582
702
  const { map, targets } = computeTargets(items, interval, showFirst);
583
703
  const max = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
584
704
  let n = 0;
585
705
 
706
+ const detachedMap = st.detached.get(kindClass);
707
+
586
708
  for (const pos of targets) {
587
709
  if (n >= max) break;
588
710
  const el = map.get(pos);
@@ -597,6 +719,27 @@
597
719
  if (isAdjacentWrap(el)) continue;
598
720
  if (findWrapAt(kindClass, pos)) continue;
599
721
 
722
+ // If we previously had a wrap at this exact position but NodeBB recycled
723
+ // the DOM, re-attach the cached wrap instead of creating a new one.
724
+ if (detachedMap && detachedMap.has(pos)) {
725
+ const cached = detachedMap.get(pos);
726
+ detachedMap.delete(pos);
727
+ if (cached) {
728
+ withInternal(() => el.insertAdjacentElement('afterend', cached));
729
+ // Re-observe placeholder and trigger show only if the cached wrap is empty.
730
+ try {
731
+ const ph = cached.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
732
+ const id = ph ? parseInt(ph.getAttribute('data-ezoic-id') || (ph.id || '').split('-').pop(), 10) : 0;
733
+ if (id > 0) {
734
+ observePh(id);
735
+ if (!isFilled(cached)) enqueueShow(id);
736
+ }
737
+ } catch (e) {}
738
+ n++;
739
+ continue;
740
+ }
741
+ }
742
+
600
743
  const id = pickId(poolKey, cursorKey);
601
744
  if (!id) break;
602
745
 
@@ -684,6 +827,7 @@
684
827
  const items = getItems('topic');
685
828
  const set = new Set(items);
686
829
  removeOrphanWraps('ezoic-ad-message', set);
830
+ reapDetached('ezoic-ad-message');
687
831
  inserted += injectWraps('ezoic-ad-message', items,
688
832
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
689
833
  normBool(cfg.showFirstMessageAd), 'post', 'post');
@@ -692,6 +836,7 @@
692
836
  const items = getItems('categoryTopics');
693
837
  const set = new Set(items);
694
838
  removeOrphanWraps('ezoic-ad-between', set);
839
+ reapDetached('ezoic-ad-between');
695
840
  inserted += injectWraps('ezoic-ad-between', items,
696
841
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
697
842
  normBool(cfg.showFirstTopicAd), 'topic', 'topic');
@@ -701,6 +846,7 @@
701
846
  const items = getItems('categories');
702
847
  const set = new Set(items);
703
848
  removeOrphanWraps('ezoic-ad-categories', set);
849
+ reapDetached('ezoic-ad-categories');
704
850
  inserted += injectWraps('ezoic-ad-categories', items,
705
851
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
706
852
  normBool(cfg.showFirstCategoryAd), 'category', 'category');
@@ -792,6 +938,13 @@
792
938
  function cleanup() {
793
939
  blockedUntil = now() + 1200;
794
940
  try { document.querySelectorAll('.' + WRAP_CLASS).forEach(dropWrap); } catch (e) {}
941
+ try {
942
+ for (const m of st.detached.values()) {
943
+ for (const w of m.values()) try { dropWrap(w); } catch (e) {}
944
+ m.clear();
945
+ }
946
+ st.detached.clear();
947
+ } catch (e) {}
795
948
  st.cfg = null;
796
949
  st.pools = { topic: [], post: [], category: [] };
797
950
  st.cursors = { topic: 0, post: 0, category: 0 };