nodebb-plugin-ezoic-infinite 1.5.33 → 1.5.35

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.33",
3
+ "version": "1.5.35",
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
@@ -24,7 +24,53 @@
24
24
  const MAX_INFLIGHT_DESKTOP = 4;
25
25
  const MAX_INFLIGHT_MOBILE = 3;
26
26
 
27
- function isBoosted() {
27
+
28
+ // Adaptive performance profile (device/network aware)
29
+ const PERF_DEFAULTS = Object.freeze({
30
+ maxInflightDesktop: MAX_INFLIGHT_DESKTOP,
31
+ maxInflightMobile: MAX_INFLIGHT_MOBILE,
32
+ maxInsertsPerRun: MAX_INSERTS_PER_RUN,
33
+ });
34
+
35
+ function getPerfProfile() {
36
+ // Cache result for this pageview; recomputed on navigation via cleanup()
37
+ if (state.perfProfile) return state.perfProfile;
38
+ const p = {
39
+ maxInflightDesktop: PERF_DEFAULTS.maxInflightDesktop,
40
+ maxInflightMobile: PERF_DEFAULTS.maxInflightMobile,
41
+ maxInsertsPerRun: PERF_DEFAULTS.maxInsertsPerRun,
42
+ };
43
+
44
+ try {
45
+ const mem = typeof navigator !== 'undefined' ? navigator.deviceMemory : undefined; // GB
46
+ const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined;
47
+
48
+ // Conservative tuning for low-end devices
49
+ if (typeof mem === 'number' && mem > 0 && mem <= 2) {
50
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
51
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
52
+ p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
53
+ }
54
+ if (typeof cores === 'number' && cores > 0 && cores <= 4) {
55
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
56
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
57
+ p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
58
+ }
59
+
60
+ const conn = typeof navigator !== 'undefined' ? navigator.connection : undefined;
61
+ const eff = conn && conn.effectiveType ? String(conn.effectiveType).toLowerCase() : '';
62
+ // Slow connections: don't ramp concurrency too high (keeps page responsive).
63
+ if (eff.includes('2g')) {
64
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
65
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
66
+ }
67
+ } catch (e) {}
68
+
69
+ state.perfProfile = p;
70
+ return p;
71
+ }
72
+
73
+ function isBoosted() {
28
74
  try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
29
75
  }
30
76
 
@@ -38,7 +84,8 @@
38
84
  }
39
85
 
40
86
  function getMaxInflight() {
41
- const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
87
+ const perf = getPerfProfile();
88
+ const base = isMobile() ? perf.maxInflightMobile : perf.maxInflightDesktop;
42
89
  return base + (isBoosted() ? 1 : 0);
43
90
  }
44
91
 
@@ -48,6 +95,31 @@
48
95
  categoryItem: 'li[component="categories/category"]',
49
96
  };
50
97
 
98
+ const RELEVANT_MATCHERS = [
99
+ SELECTORS.postItem,
100
+ SELECTORS.topicItem,
101
+ SELECTORS.categoryItem,
102
+ ];
103
+
104
+ function mutationHasRelevantAddedNodes(mutations) {
105
+ try {
106
+ for (const m of mutations) {
107
+ if (!m || !m.addedNodes || !m.addedNodes.length) continue;
108
+ for (const n of m.addedNodes) {
109
+ if (!n || n.nodeType !== 1) continue;
110
+ const el = /** @type {Element} */ (n);
111
+ for (const sel of RELEVANT_MATCHERS) {
112
+ if (el.matches && el.matches(sel)) return true;
113
+ if (el.querySelector && el.querySelector(sel)) return true;
114
+ }
115
+ }
116
+ }
117
+ } catch (e) {}
118
+ return false;
119
+ }
120
+
121
+
122
+
51
123
  // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
52
124
  let blockedUntil = 0;
53
125
  function isBlocked() {
@@ -95,29 +167,6 @@
95
167
  };
96
168
 
97
169
  const insertingIds = new Set();
98
-
99
-
100
- function markEmptyWrapper(id) {
101
- try {
102
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
103
- if (!ph || !ph.isConnected) return;
104
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
105
- if (!wrap) return;
106
- // If still empty after a delay, collapse it.
107
- setTimeout(() => {
108
- try {
109
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
110
- if (!ph2 || !ph2.isConnected) return;
111
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
112
- if (!w2) return;
113
- // consider empty if only whitespace and no iframes/ins/img
114
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
115
- if (!hasAd) w2.classList.add('is-empty');
116
- } catch (e) {}
117
- }, 3500);
118
- } catch (e) {}
119
- }
120
-
121
170
  // Production build: debug disabled
122
171
  function dbg() {}
123
172
 
@@ -334,7 +383,33 @@ function withInternalDomChange(fn) {
334
383
  } catch (e) {}
335
384
  }
336
385
 
337
- function pruneOrphanWraps(kindClass, items) {
386
+
387
+ function findWrapById(id) {
388
+ try {
389
+ return document.querySelector(`.${WRAP_CLASS}[data-ezoic-wrapid="${id}"]`);
390
+ } catch (e) {}
391
+ return null;
392
+ }
393
+
394
+ function armPlaceholder(wrap, id) {
395
+ try {
396
+ if (!wrap || !wrap.isConnected) return null;
397
+ const domId = `${PLACEHOLDER_PREFIX}${id}`;
398
+
399
+ // If the id is already present somewhere, do not reassign it.
400
+ const existing = document.getElementById(domId);
401
+ if (existing && existing.isConnected) return existing;
402
+
403
+ const ph = wrap.querySelector('[data-ezoic-ph="1"]');
404
+ if (!ph) return null;
405
+
406
+ if (!ph.id) ph.id = domId;
407
+ return ph;
408
+ } catch (e) {}
409
+ return null;
410
+ }
411
+
412
+ function pruneOrphanWraps(kindClass, items) {
338
413
  if (!items || !items.length) return 0;
339
414
  const itemSet = new Set(items);
340
415
  const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
@@ -364,22 +439,6 @@ function withInternalDomChange(fn) {
364
439
  if (removed) dbg('prune-orphan', kindClass, { removed });
365
440
  return removed;
366
441
  }
367
-
368
- function refreshEmptyState(id) {
369
- // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
370
- window.setTimeout(() => {
371
- try {
372
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
373
- if (!ph || !ph.isConnected) return;
374
- const wrap = ph.closest(`.${WRAP_CLASS}`);
375
- if (!wrap) return;
376
- const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
377
- if (hasContent) wrap.classList.remove('is-empty');
378
- else wrap.classList.add('is-empty');
379
- } catch (e) {}
380
- }, 3500);
381
- }
382
-
383
442
  function buildWrap(id, kindClass, afterPos) {
384
443
  const wrap = document.createElement('div');
385
444
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
@@ -388,8 +447,10 @@ function buildWrap(id, kindClass, afterPos) {
388
447
  wrap.style.width = '100%';
389
448
 
390
449
  const ph = document.createElement('div');
391
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
450
+ // Do not assign the Ezoic placeholder id until we actually want to load the ad.
451
+ // This avoids Ezoic defining placeholders too early during DOM churn/infinite scroll.
392
452
  ph.setAttribute('data-ezoic-id', String(id));
453
+ ph.setAttribute('data-ezoic-ph', '1');
393
454
  wrap.appendChild(ph);
394
455
 
395
456
  return wrap;
@@ -404,8 +465,8 @@ function buildWrap(id, kindClass, afterPos) {
404
465
  if (findWrap(kindClass, afterPos)) return null;
405
466
  if (insertingIds.has(id)) return null;
406
467
 
407
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
- if (existingPh && existingPh.isConnected) return null;
468
+ const existingWrap = findWrapById(id);
469
+ if (existingWrap && existingWrap.isConnected) return null;
409
470
 
410
471
  insertingIds.add(id);
411
472
  try {
@@ -427,8 +488,8 @@ function buildWrap(id, kindClass, afterPos) {
427
488
  state[cursorKey] = (state[cursorKey] + 1) % n;
428
489
 
429
490
  const id = allIds[idx];
430
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
431
- if (ph && ph.isConnected) continue;
491
+ const w = findWrapById(id);
492
+ if (w && w.isConnected) continue;
432
493
 
433
494
  return id;
434
495
  }
@@ -450,13 +511,20 @@ function buildWrap(id, kindClass, afterPos) {
450
511
  // Otherwise remove the earliest one in the document
451
512
  if (!victim) victim = wraps[0];
452
513
 
453
- // Unobserve placeholder if still observed
454
- try {
455
- const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
456
- if (ph && state.io) state.io.unobserve(ph);
457
- } catch (e) {}
514
+ const id = getWrapIdFromWrap(victim);
515
+
516
+ withInternalDomChange(() => {
517
+ // Unobserve placeholder if still observed
518
+ try {
519
+ const t = victim;
520
+ if (t && state.io) state.io.unobserve(t);
521
+ } catch (e) {}
522
+
523
+ try { if (id) safeDestroyById(id); } catch (e) {}
524
+
525
+ try { victim.remove(); } catch (e) {}
526
+ });
458
527
 
459
- victim.remove();
460
528
  return true;
461
529
  } catch (e) {
462
530
  return false;
@@ -523,14 +591,13 @@ function startShow(id) {
523
591
 
524
592
  const doShow = () => {
525
593
  try {
526
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
527
- try { ez.destroyPlaceholders(id); } catch (e) {}
594
+ if (state.usedOnce && state.usedOnce.has(id)) {
595
+ safeDestroyById(id);
528
596
  }
529
597
  } catch (e) {}
530
598
 
531
599
  try { ez.showAds(id); } catch (e) {}
532
600
  try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
533
- try { markEmptyWrapper(id); } catch (e) {}
534
601
 
535
602
  setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
536
603
  };
@@ -565,9 +632,12 @@ function startShow(id) {
565
632
  const el = ent.target;
566
633
  try { state.io && state.io.unobserve(el); } catch (e) {}
567
634
 
568
- const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
635
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-wrapid');
569
636
  const id = parseInt(idAttr, 10);
570
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
637
+ if (Number.isFinite(id) && id > 0) {
638
+ armPlaceholder(el, id);
639
+ enqueueShow(id);
640
+ }
571
641
  }
572
642
  }, { root: null, rootMargin: desiredMargin, threshold: 0 });
573
643
  state.ioMargin = desiredMargin;
@@ -579,7 +649,7 @@ function startShow(id) {
579
649
  // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
580
650
  try {
581
651
  if (state.io) {
582
- const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
652
+ const nodes = document.querySelectorAll(`.${WRAP_CLASS}[data-ezoic-wrapid]`);
583
653
  nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
584
654
  }
585
655
  } catch (e) {}
@@ -587,17 +657,20 @@ function startShow(id) {
587
657
  }
588
658
 
589
659
  function observePlaceholder(id) {
590
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
591
- if (!ph || !ph.isConnected) return;
660
+ const wrap = findWrapById(id);
661
+ if (!wrap || !wrap.isConnected) return;
592
662
  const io = ensurePreloadObserver();
593
- try { io && io.observe(ph); } catch (e) {}
663
+ try { io && io.observe(wrap); } catch (e) {}
594
664
 
595
- // If already above fold, fire immediately
665
+ // If already near the fold, arm & fire immediately
596
666
  try {
597
- const r = ph.getBoundingClientRect();
667
+ const r = wrap.getBoundingClientRect();
598
668
  const screens = isBoosted() ? 5.0 : 3.0;
599
- const minBottom = isBoosted() ? -1500 : -800;
600
- if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
669
+ const h = (window.innerHeight || 800);
670
+ if (r.top < screens * h && r.bottom > -screens * h) {
671
+ armPlaceholder(wrap, id);
672
+ enqueueShow(id);
673
+ }
601
674
  } catch (e) {}
602
675
  }
603
676
 
@@ -618,7 +691,8 @@ function startShow(id) {
618
691
 
619
692
  const targets = computeTargets(items.length, interval, showFirst);
620
693
  let inserted = 0;
621
- const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
694
+ const perf = getPerfProfile();
695
+ const maxInserts = perf.maxInsertsPerRun + (isBoosted() ? 1 : 0);
622
696
 
623
697
  for (const afterPos of targets) {
624
698
  if (inserted >= maxInserts) break;
@@ -787,11 +861,16 @@ function startShow(id) {
787
861
 
788
862
  // remove all wrappers
789
863
  try {
790
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
791
- try { el.remove(); } catch (e) {}
864
+ withInternalDomChange(() => {
865
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
866
+ try { el.remove(); } catch (e) {}
867
+ });
792
868
  });
793
869
  } catch (e) {}
794
870
 
871
+ // reset perf profile cache
872
+ state.perfProfile = null;
873
+
795
874
  // reset state
796
875
  state.cfg = null;
797
876
  state.allTopics = [];
@@ -812,9 +891,11 @@ function startShow(id) {
812
891
 
813
892
  function ensureDomObserver() {
814
893
  if (state.domObs) return;
815
- state.domObs = new MutationObserver(() => {
894
+ state.domObs = new MutationObserver((mutations) => {
816
895
  if (state.internalDomChange > 0) return;
817
- if (!isBlocked()) scheduleRun();
896
+ if (isBlocked()) return;
897
+ // Only rescan when NodeBB actually added posts/topics/categories.
898
+ if (mutationHasRelevantAddedNodes(mutations)) scheduleRun();
818
899
  });
819
900
  try {
820
901
  state.domObs.observe(document.body, { childList: true, subtree: true });
package/public/style.css CHANGED
@@ -1,40 +1,9 @@
1
- /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
1
+ /* Minimal styling for injected Ezoic wrappers.
2
+ Spacing (margins/padding) is intentionally NOT forced here, because it can be
3
+ configured inside Ezoic and may vary by placement/device.
4
+ */
5
+
2
6
  .ezoic-ad {
3
7
  display: block;
4
8
  width: 100%;
5
- margin: 0 !important;
6
- padding: 0 !important;
7
- overflow: hidden;
8
- }
9
-
10
- .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
- margin: 0 !important;
12
- padding: 0 !important;
13
- min-height: 1px; /* keeps placeholder measurable for IO */
14
- }
15
-
16
- /* Ezoic sometimes wraps in extra spans/divs with margins */
17
- .ezoic-ad span.ezoic-ad,
18
- .ezoic-ad .ezoic-ad {
19
- margin: 0 !important;
20
- padding: 0 !important;
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: block !important;
27
- margin: 0 !important;
28
- padding: 0 !important;
29
- height: 0 !important;
30
- min-height: 0 !important;
31
- overflow: hidden !important;
32
- }
33
-
34
- .ezoic-ad {
35
- min-height: 0 !important;
36
- }
37
-
38
- .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
39
- min-height: 0 !important;
40
9
  }