nodebb-plugin-ezoic-infinite 1.7.91 → 1.7.93

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.7.91",
3
+ "version": "1.7.93",
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
@@ -79,8 +79,10 @@
79
79
 
80
80
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
81
81
  const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
82
- const MAX_INFLIGHT = 4; // max showAds() simultanés
83
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
82
+ const MAX_INFLIGHT = 2; // max showAds() simultanés (réduit le churn/unused)
83
+ const SHOW_THROTTLE_MS = 1200; // anti-spam showAds() par id
84
+ const UNUSED_COOLDOWN_MS = 15_000; // cooldown après tentative sans fill (unused probable)
85
+ const PENDING_TIMEOUT_MS = 12_000; // au-delà, tentative considérée obsolète
84
86
  const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
85
87
 
86
88
  // Marges IO larges et fixes — observer créé une seule fois au boot
@@ -123,17 +125,19 @@
123
125
  domObs: null,
124
126
  mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
125
127
  inflight: 0, // showAds() en cours
126
- pending: [], // ids en attente de slot inflight
128
+ pending: [], // ids en attente de slot inflight (triés par priorité)
127
129
  pendingSet: new Set(),
130
+ pendingShows: new Set(), // ids avec tentative show en cours
131
+ cooldownUntil:new Map(), // id -> ts de fin de cooldown (unused probable)
132
+ fillObsById: new Map(), // id -> MutationObserver placeholder fill
128
133
  wrapByKey: new Map(), // anchorKey → wrap DOM node
129
- fillObsById: new Map(), // id -> MutationObserver
130
134
  runQueued: false,
131
135
  burstActive: false,
132
136
  burstDeadline: 0,
133
137
  burstCount: 0,
134
138
  lastBurstTs: 0,
135
139
  lastScrollY: 0,
136
- scrollDir: 1, // 1=down, -1=up
140
+ scrollDir: 1,
137
141
  };
138
142
 
139
143
  let blockedUntil = 0;
@@ -143,34 +147,70 @@
143
147
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
144
148
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
145
149
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
150
+ const isCooling = id => (S.cooldownUntil.get(id) || 0) > ts();
146
151
 
152
+ function viewportDistance(el) {
153
+ try {
154
+ const r = el?.getBoundingClientRect?.();
155
+ if (!r) return Infinity;
156
+ const vh = window.innerHeight || 800;
157
+ if (r.bottom >= 0 && r.top <= vh) return 0;
158
+ if (r.top > vh) return Math.max(0, r.top - vh);
159
+ return Math.max(0, -r.bottom);
160
+ } catch (_) { return Infinity; }
161
+ }
147
162
 
148
- function unwatchPlaceholderFillById(id) {
149
- const obs = S.fillObsById.get(id);
150
- if (obs) { try { obs.disconnect(); } catch (_) {} S.fillObsById.delete(id); }
163
+ function pendingEnqueue(id) {
164
+ if (S.pendingSet.has(id)) return;
165
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
166
+ const prio = viewportDistance(ph?.closest?.(`.${WRAP_CLASS}`) || ph);
167
+ const item = { id, prio, at: ts() };
168
+ let i = S.pending.length;
169
+ while (i > 0 && S.pending[i - 1].prio > prio) i--;
170
+ S.pending.splice(i, 0, item);
171
+ S.pendingSet.add(id);
151
172
  }
152
173
 
153
- function uncollapseIfFilled(ph) {
154
- try {
155
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
156
- if (!wrap) return false;
157
- if (!isFilled(ph)) return false;
158
- wrap.classList.remove('is-empty');
159
- return true;
160
- } catch (_) { return false; }
174
+ function markUnusedProbable(id) {
175
+ if (!id) return;
176
+ S.pendingShows.delete(id);
177
+ S.cooldownUntil.set(id, ts() + UNUSED_COOLDOWN_MS);
178
+ }
179
+
180
+ function clearCooldownIfFilled(id) {
181
+ if (!id) return;
182
+ S.cooldownUntil.delete(id);
183
+ S.pendingShows.delete(id);
184
+ }
185
+
186
+ function unobserveFill(id) {
187
+ const obs = S.fillObsById.get(id);
188
+ if (obs) { try { obs.disconnect(); } catch (_) {} }
189
+ S.fillObsById.delete(id);
161
190
  }
162
191
 
163
192
  function watchPlaceholderFill(id) {
164
193
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
165
- if (!ph?.isConnected || S.fillObsById.has(id)) return;
194
+ if (!ph?.isConnected) return;
195
+ unobserveFill(id);
196
+ const onMut = () => {
197
+ try {
198
+ if (!ph.isConnected) return unobserveFill(id);
199
+ if (!isFilled(ph)) return;
200
+ clearCooldownIfFilled(id);
201
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
202
+ if (wrap) wrap.classList.remove('is-empty');
203
+ } catch (_) {}
204
+ };
166
205
  try {
167
- const obs = new MutationObserver(() => { try { uncollapseIfFilled(ph); } catch (_) {} });
206
+ const obs = new MutationObserver(onMut);
168
207
  obs.observe(ph, { childList: true, subtree: true, attributes: true });
169
208
  S.fillObsById.set(id, obs);
170
- uncollapseIfFilled(ph);
171
209
  } catch (_) {}
210
+ onMut();
172
211
  }
173
212
 
213
+
174
214
  function mutate(fn) {
175
215
  S.mutGuard++;
176
216
  try { fn(); } finally { S.mutGuard--; }
@@ -312,23 +352,18 @@
312
352
  }
313
353
 
314
354
  function sweepDeadWraps() {
315
- let freed = 0;
316
- for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
317
- if (wrap?.isConnected) continue;
318
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID) || '0', 10);
319
- if (Number.isFinite(id) && id > 0) {
355
+ for (const [key, w] of Array.from(S.wrapByKey.entries())) {
356
+ if (w?.isConnected) continue;
357
+ S.wrapByKey.delete(key);
358
+ const id = parseInt(w?.getAttribute?.(A_WRAPID), 10);
359
+ if (Number.isFinite(id)) {
320
360
  S.mountedIds.delete(id);
321
- unwatchPlaceholderFillById(id);
322
- S.lastShow.delete(id);
323
361
  S.pendingSet.delete(id);
362
+ S.pending = S.pending.filter(it => (it?.id ?? it) !== id);
363
+ S.pendingShows.delete(id);
364
+ unobserveFill(id);
324
365
  }
325
- S.wrapByKey.delete(key);
326
- freed++;
327
- }
328
- if (freed && S.pending.length) {
329
- S.pending = S.pending.filter(id => !S.mountedIds.has(id));
330
366
  }
331
- return freed;
332
367
  }
333
368
 
334
369
  // ── Pool ───────────────────────────────────────────────────────────────────
@@ -363,57 +398,39 @@
363
398
  typeof ez?.displayMore !== 'function') return null;
364
399
 
365
400
  const vh = window.innerHeight || 800;
366
- const targetRect = targetEl?.getBoundingClientRect?.() || { top: vh, bottom: vh };
367
-
368
- // Recyclage bidirectionnel :
369
- // - scroll vers le bas -> recycle préférentiellement des wraps loin au-dessus
370
- // - scroll vers le haut -> recycle préférentiellement des wraps loin en-dessous
371
- // Fallback sur l'autre côté si aucun candidat.
372
- const aboveThreshold = -vh; // wrap entièrement/suffisamment au-dessus
373
- const belowThreshold = vh * 2; // wrap loin sous le viewport
401
+ const targetRect = targetEl?.getBoundingClientRect?.();
402
+ const dir = S.scrollDir >= 0 ? 1 : -1;
374
403
 
375
- let aboveEmpty = null, aboveEmptyBottom = Infinity;
376
- let aboveFilled = null, aboveFilledBottom = Infinity;
377
- let belowEmpty = null, belowEmptyTop = -Infinity;
378
- let belowFilled = null, belowFilledTop = -Infinity;
404
+ let best = null, bestScore = Infinity;
405
+ let fallback = null, fallbackScore = Infinity;
379
406
 
380
407
  for (const wrap of S.wrapByKey.values()) {
381
- if (!wrap?.classList?.contains?.(klass)) continue;
382
408
  try {
383
- if (!wrap?.isConnected) continue;
409
+ if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
384
410
  const rect = wrap.getBoundingClientRect();
385
-
386
- if (rect.bottom < aboveThreshold) {
387
- if (!isFilled(wrap)) {
388
- if (rect.bottom < aboveEmptyBottom) { aboveEmptyBottom = rect.bottom; aboveEmpty = wrap; }
389
- } else {
390
- if (rect.bottom < aboveFilledBottom) { aboveFilledBottom = rect.bottom; aboveFilled = wrap; }
391
- }
392
- continue;
393
- }
394
-
395
- if (rect.top > belowThreshold) {
396
- if (!isFilled(wrap)) {
397
- if (rect.top > belowEmptyTop) { belowEmptyTop = rect.top; belowEmpty = wrap; }
398
- } else {
399
- if (rect.top > belowFilledTop) { belowFilledTop = rect.top; belowFilled = wrap; }
400
- }
411
+ const farAbove = rect.bottom < -vh;
412
+ const farBelow = rect.top > (window.innerHeight || 800) + vh;
413
+ if (!farAbove && !farBelow) continue;
414
+
415
+ const preferredSide = dir > 0 ? farAbove : farBelow;
416
+ const dist = targetRect ? Math.abs(rect.top - targetRect.top) : Math.abs(rect.top);
417
+ const score = (isFilled(wrap) ? 10000000 : 0) + dist;
418
+
419
+ if (preferredSide) {
420
+ if (score < bestScore) { best = wrap; bestScore = score; }
421
+ } else if (score < fallbackScore) {
422
+ fallback = wrap; fallbackScore = score;
401
423
  }
402
424
  } catch (_) {}
403
425
  }
404
426
 
405
- const preferBelow = (S.scrollDir < 0) || (targetRect.top < vh * 0.5);
406
- const pickAbove = () => aboveEmpty ?? aboveFilled;
407
- const pickBelow = () => belowEmpty ?? belowFilled;
408
- const best = preferBelow ? (pickBelow() ?? pickAbove()) : (pickAbove() ?? pickBelow());
427
+ best = best || fallback;
409
428
  if (!best) return null;
410
429
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
411
430
  if (!Number.isFinite(id)) return null;
412
431
 
413
432
  const oldKey = best.getAttribute(A_ANCHOR);
414
- // Neutraliser l'IO sur ce wrap avant déplacement évite un showAds
415
- // parasite si le nœud était encore dans la zone IO_MARGIN.
416
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
433
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) { S.io?.unobserve(ph); unobserveFill(id); } } catch (_) {}
417
434
  mutate(() => {
418
435
  best.setAttribute(A_ANCHOR, newKey);
419
436
  best.setAttribute(A_CREATED, String(ts()));
@@ -426,10 +443,9 @@
426
443
  if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
427
444
  S.wrapByKey.set(newKey, best);
428
445
 
429
- // Délais requis : destroyPlaceholders est asynchrone en interne
430
- const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
431
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
432
- const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
446
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
447
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
448
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
433
449
  try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
434
450
 
435
451
  return { id, wrap: best };
@@ -461,7 +477,6 @@
461
477
  mutate(() => el.insertAdjacentElement('afterend', w));
462
478
  S.mountedIds.add(id);
463
479
  S.wrapByKey.set(key, w);
464
- watchPlaceholderFill(id);
465
480
  return w;
466
481
  }
467
482
 
@@ -470,7 +485,8 @@
470
485
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
471
486
  if (ph instanceof Element) S.io?.unobserve(ph);
472
487
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
473
- if (Number.isFinite(id)) { S.mountedIds.delete(id); unwatchPlaceholderFillById(id); S.lastShow.delete(id); S.pendingSet.delete(id); }
488
+ if (Number.isFinite(id)) unobserveFill(id);
489
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
474
490
  const key = w.getAttribute(A_ANCHOR);
475
491
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
476
492
  w.remove();
@@ -533,6 +549,7 @@
533
549
  function injectBetween(klass, items, interval, showFirst, poolKey) {
534
550
  if (!items.length) return 0;
535
551
  let inserted = 0;
552
+ sweepDeadWraps();
536
553
 
537
554
  for (const el of items) {
538
555
  if (inserted >= MAX_INSERTS_RUN) break;
@@ -553,6 +570,7 @@
553
570
  } else {
554
571
  const recycled = recycleAndMove(klass, el, key);
555
572
  if (!recycled) break;
573
+ observePh(recycled.id);
556
574
  inserted++;
557
575
  }
558
576
  }
@@ -578,49 +596,54 @@
578
596
 
579
597
  function observePh(id) {
580
598
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
581
- if (!ph?.isConnected) return;
582
- watchPlaceholderFill(id);
583
- try { getIO()?.observe(ph); } catch (_) {}
599
+ if (ph?.isConnected) {
600
+ try { getIO()?.observe(ph); } catch (_) {}
601
+ watchPlaceholderFill(id);
602
+ }
584
603
  }
585
604
 
586
605
  function enqueueShow(id) {
587
606
  if (!id || isBlocked()) return;
607
+ if (isCooling(id) || S.pendingShows.has(id)) return;
588
608
  if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
589
- if (S.inflight >= MAX_INFLIGHT) {
590
- if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
591
- return;
592
- }
609
+ if (S.inflight >= MAX_INFLIGHT) { pendingEnqueue(id); return; }
593
610
  startShow(id);
594
611
  }
595
612
 
596
613
  function drainQueue() {
597
614
  if (isBlocked()) return;
598
615
  while (S.inflight < MAX_INFLIGHT && S.pending.length) {
599
- const id = S.pending.shift();
616
+ const item = S.pending.shift();
617
+ const id = item?.id ?? item;
600
618
  S.pendingSet.delete(id);
619
+ if (!id || isCooling(id) || S.pendingShows.has(id)) continue;
620
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
621
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) continue;
601
622
  startShow(id);
602
623
  }
603
624
  }
604
625
 
605
626
  function startShow(id) {
606
627
  if (!id || isBlocked()) return;
628
+ if (S.pendingShows.has(id) || isCooling(id)) return;
629
+ S.pendingShows.add(id);
607
630
  S.inflight++;
608
631
  let done = false;
609
632
  const release = () => {
610
633
  if (done) return;
611
634
  done = true;
635
+ S.pendingShows.delete(id);
612
636
  S.inflight = Math.max(0, S.inflight - 1);
613
637
  drainQueue();
614
638
  };
615
- const timer = setTimeout(release, 7000);
639
+ const timer = setTimeout(() => { markUnusedProbable(id); release(); }, PENDING_TIMEOUT_MS);
616
640
 
617
641
  requestAnimationFrame(() => {
618
642
  try {
619
643
  if (isBlocked()) { clearTimeout(timer); return release(); }
620
644
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
621
- if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
622
-
623
- try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
645
+ if (!ph?.isConnected) { clearTimeout(timer); markUnusedProbable(id); return release(); }
646
+ if (isFilled(ph)) { clearTimeout(timer); clearCooldownIfFilled(id); return release(); }
624
647
 
625
648
  const t = ts();
626
649
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
@@ -765,11 +788,12 @@
765
788
  S.mountedIds.clear();
766
789
  S.lastShow.clear();
767
790
  S.wrapByKey.clear();
768
- for (const obs of S.fillObsById.values()) { try { obs.disconnect(); } catch (_) {} }
769
- S.fillObsById.clear();
770
791
  S.inflight = 0;
771
792
  S.pending = [];
772
793
  S.pendingSet.clear();
794
+ S.pendingShows.clear();
795
+ S.cooldownUntil.clear();
796
+ for (const id of Array.from(S.fillObsById.keys())) unobserveFill(id);
773
797
  S.burstActive = false;
774
798
  S.runQueued = false;
775
799
  }
@@ -781,16 +805,12 @@
781
805
  const allSel = [SEL.post, SEL.topic, SEL.category];
782
806
  S.domObs = new MutationObserver(muts => {
783
807
  if (S.mutGuard > 0 || isBlocked()) return;
808
+ let sawWrapRemoval = false;
784
809
  for (const m of muts) {
785
- let sawWrapRemoval = false;
786
- for (const n of m.removedNodes || []) {
787
- if (n.nodeType !== 1) continue;
788
- try {
789
- if (n.matches?.(`.${WRAP_CLASS}`) || n.querySelector?.(`.${WRAP_CLASS}`)) { sawWrapRemoval = true; }
790
- } catch (_) {}
810
+ for (const n of (m.removedNodes || [])) {
811
+ if (n?.nodeType !== 1) continue;
812
+ try { if ((n.classList && n.classList.contains(WRAP_CLASS)) || n.querySelector?.(`.${WRAP_CLASS}`)) sawWrapRemoval = true; } catch (_) {}
791
813
  }
792
- if (sawWrapRemoval) sweepDeadWraps();
793
-
794
814
  for (const n of m.addedNodes) {
795
815
  if (n.nodeType !== 1) continue;
796
816
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
@@ -800,6 +820,7 @@
800
820
  }
801
821
  }
802
822
  }
823
+ if (sawWrapRemoval) sweepDeadWraps();
803
824
  });
804
825
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
805
826
  }
@@ -901,14 +922,11 @@
901
922
  function bindScroll() {
902
923
  let ticking = false;
903
924
  window.addEventListener('scroll', () => {
904
- try {
905
- const y = window.scrollY || window.pageYOffset || 0;
906
- const dy = y - (S.lastScrollY || 0);
907
- if (Math.abs(dy) > 2) S.scrollDir = dy > 0 ? 1 : -1;
908
- S.lastScrollY = y;
909
- } catch (_) {}
910
925
  if (ticking) return;
911
926
  ticking = true;
927
+ const y = window.scrollY || window.pageYOffset || 0;
928
+ S.scrollDir = y >= (S.lastScrollY || 0) ? 1 : -1;
929
+ S.lastScrollY = y;
912
930
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
913
931
  }, { passive: true });
914
932
  }
@@ -916,7 +934,6 @@
916
934
  // ── Boot ───────────────────────────────────────────────────────────────────
917
935
 
918
936
  S.pageKey = pageKey();
919
- try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) { S.lastScrollY = 0; }
920
937
  muteConsole();
921
938
  ensureTcfLocator();
922
939
  warmNetwork();
package/public/style.css CHANGED
@@ -56,6 +56,20 @@
56
56
  top: auto !important;
57
57
  }
58
58
 
59
+ /* ── État vide ────────────────────────────────────────────────────────────── */
60
+ /*
61
+ Ajouté 20s après showAds si aucun fill détecté.
62
+ Collapse à 1px (pas 0) : reste observable par l'IO si le fill arrive tard.
63
+ */
64
+ .nodebb-ezoic-wrap.is-empty {
65
+ display: block !important;
66
+ height: 1px !important;
67
+ min-height: 1px !important;
68
+ max-height: 1px !important;
69
+ margin: 0 !important;
70
+ padding: 0 !important;
71
+ overflow: hidden !important;
72
+ }
59
73
 
60
74
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
61
75
  .ezoic-ad {