nodebb-plugin-ezoic-infinite 1.8.6 → 1.8.7

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.8.6",
3
+ "version": "1.8.7",
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
@@ -77,18 +77,12 @@
77
77
  const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
78
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
79
 
80
- // Tunables (stables en prod)
80
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
81
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
- const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
83
- const MAX_INFLIGHT = 4; // max showAds() simultanés (garde-fou)
84
- const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
85
- const SHOW_THROTTLE_MS = 600; // anti-spam showAds() par id (plus réactif)
86
- const SHOW_RELEASE_MS = 700; // relâche inflight après showAds() batché
87
- const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
88
- const BATCH_FLUSH_MS = 40; // micro-buffer pour regrouper les ids proches
89
- const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
90
- const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
91
- const BURST_COOLDOWN_MS = 120; // délai min entre deux déclenchements de burst
82
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
84
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
92
86
 
93
87
  // Marges IO larges et fixes — observer créé une seule fois au boot
94
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
@@ -132,23 +126,14 @@
132
126
  inflight: 0, // showAds() en cours
133
127
  pending: [], // ids en attente de slot inflight
134
128
  pendingSet: new Set(),
135
- showBatchTimer: 0,
136
- destroyBatchTimer: 0,
137
- destroyPending: [],
138
- destroyPendingSet: new Set(),
139
- sweepQueued: false,
140
129
  wrapByKey: new Map(), // anchorKey → wrap DOM node
141
- ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
142
- ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
143
- scrollDir: 1, // 1=bas, -1=haut
144
- scrollSpeed: 0, // px/s approx (EMA)
145
- lastScrollY: 0,
146
- lastScrollTs: 0,
147
130
  runQueued: false,
148
131
  burstActive: false,
149
132
  burstDeadline: 0,
150
133
  burstCount: 0,
151
134
  lastBurstTs: 0,
135
+ lastScrollY: 0,
136
+ scrollDir: 1, // 1=down, -1=up
152
137
  };
153
138
 
154
139
  let blockedUntil = 0;
@@ -159,106 +144,10 @@
159
144
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
160
145
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
161
146
 
162
- function phEl(id) {
163
- return document.getElementById(`${PH_PREFIX}${id}`);
164
- }
165
-
166
- function hasSinglePlaceholder(id) {
167
- try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
168
- }
169
-
170
- function canShowPlaceholderId(id, now = ts()) {
171
- const n = parseInt(id, 10);
172
- if (!Number.isFinite(n) || n <= 0) return false;
173
- if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
174
- const ph = phEl(n);
175
- if (!ph?.isConnected || isFilled(ph)) return false;
176
- if (!hasSinglePlaceholder(n)) return false;
177
- return true;
178
- }
179
-
180
- function queueSweepDeadWraps() {
181
- if (S.sweepQueued) return;
182
- S.sweepQueued = true;
183
- requestAnimationFrame(() => {
184
- S.sweepQueued = false;
185
- sweepDeadWraps();
186
- });
187
- }
188
-
189
- function getDynamicShowBatchMax() {
190
- const speed = S.scrollSpeed || 0;
191
- const pend = S.pending.length;
192
- // Scroll très rapide => petits batches (réduit le churn/unused)
193
- if (speed > 2600) return 2;
194
- if (speed > 1400) return 3;
195
- // Peu de candidats => flush plus vite, inutile d'attendre 4
196
- if (pend <= 1) return 1;
197
- if (pend <= 3) return 2;
198
- // Par défaut compromis dynamique
199
- return 3;
200
- }
201
-
202
147
  function mutate(fn) {
203
148
  S.mutGuard++;
204
149
  try { fn(); } finally { S.mutGuard--; }
205
150
  }
206
- function scheduleDestroyFlush() {
207
- if (S.destroyBatchTimer) return;
208
- S.destroyBatchTimer = setTimeout(() => {
209
- S.destroyBatchTimer = 0;
210
- flushDestroyBatch();
211
- }, DESTROY_FLUSH_MS);
212
- }
213
-
214
- function flushDestroyBatch() {
215
- if (!S.destroyPending.length) return;
216
- const ids = [];
217
- while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
218
- const id = S.destroyPending.shift();
219
- S.destroyPendingSet.delete(id);
220
- if (!Number.isFinite(id) || id <= 0) continue;
221
- ids.push(id);
222
- }
223
- if (ids.length) {
224
- try {
225
- const ez = window.ezstandalone;
226
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
227
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
228
- } catch (_) {}
229
- }
230
- if (S.destroyPending.length) scheduleDestroyFlush();
231
- }
232
-
233
- function destroyEzoicId(id) {
234
- if (!Number.isFinite(id) || id <= 0) return;
235
- if (!S.ezActiveIds.has(id)) return;
236
- S.ezActiveIds.delete(id);
237
- if (!S.destroyPendingSet.has(id)) {
238
- S.destroyPending.push(id);
239
- S.destroyPendingSet.add(id);
240
- }
241
- scheduleDestroyFlush();
242
- }
243
-
244
- function destroyBeforeReuse(ids) {
245
- const out = [];
246
- const toDestroy = [];
247
- const seen = new Set();
248
- for (const raw of (ids || [])) {
249
- const id = parseInt(raw, 10);
250
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
251
- seen.add(id);
252
- out.push(id);
253
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
254
- }
255
- if (toDestroy.length) {
256
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
257
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
258
- }
259
- return out;
260
- }
261
-
262
151
 
263
152
  // ── Config ─────────────────────────────────────────────────────────────────
264
153
 
@@ -413,25 +302,6 @@ function destroyBeforeReuse(ids) {
413
302
  return null;
414
303
  }
415
304
 
416
- function sweepDeadWraps() {
417
- // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
418
- // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
419
- for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
420
- if (wrap?.isConnected) continue;
421
- S.wrapByKey.delete(key);
422
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
423
- if (Number.isFinite(id)) {
424
- S.mountedIds.delete(id);
425
- S.pendingSet.delete(id);
426
- S.lastShow.delete(id);
427
- S.ezActiveIds.delete(id);
428
- }
429
- }
430
- if (S.pending.length) {
431
- S.pending = S.pending.filter(id => S.pendingSet.has(id));
432
- }
433
- }
434
-
435
305
  /**
436
306
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
437
307
  * Séquence avec délais (destroyPlaceholders est asynchrone) :
@@ -439,84 +309,83 @@ function destroyBeforeReuse(ids) {
439
309
  * displayMore = API Ezoic prévue pour l'infinite scroll.
440
310
  * Priorité : wraps vides d'abord, remplis si nécessaire.
441
311
  */
442
- function recycleAndMove(klass, targetEl, newKey) {
443
- const ez = window.ezstandalone;
444
- if (typeof ez?.destroyPlaceholders !== 'function' ||
445
- typeof ez?.define !== 'function' ||
446
- typeof ez?.displayMore !== 'function') return null;
447
-
448
- const vh = window.innerHeight || 800;
449
- const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
450
- const farAbove = -vh;
451
- const farBelow = vh * 2;
452
-
453
- let bestPrefEmpty = null, bestPrefMetric = Infinity;
454
- let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
455
- let bestAnyEmpty = null, bestAnyMetric = Infinity;
456
- let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
457
-
458
- for (const wrap of S.wrapByKey.values()) {
459
- if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
460
- try {
461
- const rect = wrap.getBoundingClientRect();
462
- const isAbove = rect.bottom <= farAbove;
463
- const isBelow = rect.top >= farBelow;
464
- const anyFar = isAbove || isBelow;
465
- if (!anyFar) continue;
466
-
467
- const qualifies = preferAbove ? isAbove : isBelow;
468
- const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
469
- const filled = isFilled(wrap);
470
-
471
- if (qualifies) {
472
- if (!filled) {
473
- if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
474
- } else {
475
- if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
312
+ function recycleAndMove(klass, targetEl, newKey) {
313
+ const ez = window.ezstandalone;
314
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
315
+ typeof ez?.define !== 'function' ||
316
+ typeof ez?.displayMore !== 'function') return null;
317
+
318
+ const vh = window.innerHeight || 800;
319
+ const targetRect = targetEl?.getBoundingClientRect?.() || { top: vh, bottom: vh };
320
+
321
+ // Recyclage bidirectionnel :
322
+ // - scroll vers le bas -> recycle préférentiellement des wraps loin au-dessus
323
+ // - scroll vers le haut -> recycle préférentiellement des wraps loin en-dessous
324
+ // Fallback sur l'autre côté si aucun candidat.
325
+ const aboveThreshold = -vh; // wrap entièrement/suffisamment au-dessus
326
+ const belowThreshold = vh * 2; // wrap loin sous le viewport
327
+
328
+ let aboveEmpty = null, aboveEmptyBottom = Infinity;
329
+ let aboveFilled = null, aboveFilledBottom = Infinity;
330
+ let belowEmpty = null, belowEmptyTop = -Infinity;
331
+ let belowFilled = null, belowFilledTop = -Infinity;
332
+
333
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
334
+ try {
335
+ if (!wrap?.isConnected) return;
336
+ const rect = wrap.getBoundingClientRect();
337
+
338
+ if (rect.bottom < aboveThreshold) {
339
+ if (!isFilled(wrap)) {
340
+ if (rect.bottom < aboveEmptyBottom) { aboveEmptyBottom = rect.bottom; aboveEmpty = wrap; }
341
+ } else {
342
+ if (rect.bottom < aboveFilledBottom) { aboveFilledBottom = rect.bottom; aboveFilled = wrap; }
343
+ }
344
+ return;
476
345
  }
477
- }
478
- if (!filled) {
479
- if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
480
- } else {
481
- if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
482
- }
483
- } catch (_) {}
484
- }
485
346
 
486
- const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
487
- if (!best) return null;
488
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
489
- if (!Number.isFinite(id)) return null;
490
-
491
- const oldKey = best.getAttribute(A_ANCHOR);
492
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
493
- mutate(() => {
494
- best.setAttribute(A_ANCHOR, newKey);
495
- best.setAttribute(A_CREATED, String(ts()));
496
- best.setAttribute(A_SHOWN, '0');
497
- best.classList.remove('is-empty');
498
- const ph = best.querySelector(`#${PH_PREFIX}${id}`);
499
- if (ph) ph.innerHTML = '';
500
- targetEl.insertAdjacentElement('afterend', best);
501
- });
502
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
503
- S.wrapByKey.set(newKey, best);
504
-
505
- const doDestroy = () => {
506
- if (S.ezShownSinceDestroy.has(id)) {
507
- try { ez.destroyPlaceholders([id]); } catch (_) {}
508
- S.ezShownSinceDestroy.delete(id);
509
- }
510
- S.ezActiveIds.delete(id);
511
- setTimeout(doDefine, 330);
512
- };
513
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
514
- const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
515
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
347
+ if (rect.top > belowThreshold) {
348
+ if (!isFilled(wrap)) {
349
+ if (rect.top > belowEmptyTop) { belowEmptyTop = rect.top; belowEmpty = wrap; }
350
+ } else {
351
+ if (rect.top > belowFilledTop) { belowFilledTop = rect.top; belowFilled = wrap; }
352
+ }
353
+ }
354
+ } catch (_) {}
355
+ });
516
356
 
517
- return { id, wrap: best };
518
- }
357
+ const preferBelow = (S.scrollDir < 0) || (targetRect.top < vh * 0.5);
358
+ const pickAbove = () => aboveEmpty ?? aboveFilled;
359
+ const pickBelow = () => belowEmpty ?? belowFilled;
360
+ const best = preferBelow ? (pickBelow() ?? pickAbove()) : (pickAbove() ?? pickBelow());
361
+ if (!best) return null;
362
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
363
+ if (!Number.isFinite(id)) return null;
364
+
365
+ const oldKey = best.getAttribute(A_ANCHOR);
366
+ // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
367
+ // parasite si le nœud était encore dans la zone IO_MARGIN.
368
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
369
+ mutate(() => {
370
+ best.setAttribute(A_ANCHOR, newKey);
371
+ best.setAttribute(A_CREATED, String(ts()));
372
+ best.setAttribute(A_SHOWN, '0');
373
+ best.classList.remove('is-empty');
374
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
375
+ if (ph) ph.innerHTML = '';
376
+ targetEl.insertAdjacentElement('afterend', best);
377
+ });
378
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
379
+ S.wrapByKey.set(newKey, best);
519
380
 
381
+ // Délais requis : destroyPlaceholders est asynchrone en interne
382
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
383
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
384
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
385
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
386
+
387
+ return { id, wrap: best };
388
+ }
520
389
 
521
390
  // ── Wraps DOM — création / suppression ────────────────────────────────────
522
391
 
@@ -552,7 +421,7 @@ function recycleAndMove(klass, targetEl, newKey) {
552
421
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
553
422
  if (ph instanceof Element) S.io?.unobserve(ph);
554
423
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
555
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
424
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
556
425
  const key = w.getAttribute(A_ANCHOR);
557
426
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
558
427
  w.remove();
@@ -627,8 +496,7 @@ function recycleAndMove(klass, targetEl, newKey) {
627
496
  const key = anchorKey(klass, el);
628
497
  if (findWrap(key)) continue;
629
498
 
630
- let id = pickId(poolKey);
631
- if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
499
+ const id = pickId(poolKey);
632
500
  if (id) {
633
501
  const w = insertAfter(el, id, klass, key);
634
502
  if (w) { observePh(id); inserted++; }
@@ -659,107 +527,76 @@ function recycleAndMove(klass, targetEl, newKey) {
659
527
  }
660
528
 
661
529
  function observePh(id) {
662
- const ph = phEl(id);
530
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
663
531
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
664
- // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
665
- try {
666
- if (!ph?.isConnected) return;
667
- const rect = ph.getBoundingClientRect();
668
- const vh = window.innerHeight || 800;
669
- const preload = isMobile() ? 1400 : 1000;
670
- if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
671
- } catch (_) {}
672
532
  }
673
533
 
674
- function enqueueShow(id) {
675
- if (!id || isBlocked()) return;
676
- const n = parseInt(id, 10);
677
- if (!Number.isFinite(n) || n <= 0) return;
678
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
679
- if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
680
- scheduleDrainQueue();
681
- }
682
-
683
- function scheduleDrainQueue() {
684
- if (isBlocked()) return;
685
- if (S.showBatchTimer) return;
686
- S.showBatchTimer = setTimeout(() => {
687
- S.showBatchTimer = 0;
688
- drainQueue();
689
- }, BATCH_FLUSH_MS);
690
- }
691
-
692
- function drainQueue() {
693
- if (isBlocked()) return;
694
- const free = Math.max(0, MAX_INFLIGHT - S.inflight);
695
- if (!free || !S.pending.length) return;
696
-
697
- const picked = [];
698
- const seen = new Set();
699
- const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
700
- while (S.pending.length && picked.length < batchCap) {
701
- const id = S.pending.shift();
702
- S.pendingSet.delete(id);
703
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
704
- if (!phEl(id)?.isConnected) continue;
705
- seen.add(id);
706
- picked.push(id);
534
+ function enqueueShow(id) {
535
+ if (!id || isBlocked()) return;
536
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
537
+ if (S.inflight >= MAX_INFLIGHT) {
538
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
539
+ return;
540
+ }
541
+ startShow(id);
707
542
  }
708
- if (picked.length) startShowBatch(picked);
709
- if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
710
- }
711
-
712
- function startShowBatch(ids) {
713
- if (!ids?.length || isBlocked()) return;
714
- const reserve = ids.length;
715
- S.inflight += reserve;
716
-
717
- let done = false;
718
- const release = () => {
719
- if (done) return;
720
- done = true;
721
- S.inflight = Math.max(0, S.inflight - reserve);
722
- drainQueue();
723
- };
724
- const timer = setTimeout(release, SHOW_FAILSAFE_MS);
725
543
 
726
- requestAnimationFrame(() => {
727
- try {
728
- if (isBlocked()) { clearTimeout(timer); return release(); }
544
+ function drainQueue() {
545
+ if (isBlocked()) return;
546
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
547
+ const id = S.pending.shift();
548
+ S.pendingSet.delete(id);
549
+ startShow(id);
550
+ }
551
+ }
729
552
 
730
- const valid = [];
731
- const t = ts();
553
+ function startShow(id) {
554
+ if (!id || isBlocked()) return;
555
+ S.inflight++;
556
+ let done = false;
557
+ const release = () => {
558
+ if (done) return;
559
+ done = true;
560
+ S.inflight = Math.max(0, S.inflight - 1);
561
+ drainQueue();
562
+ };
563
+ const timer = setTimeout(release, 7000);
732
564
 
733
- for (const raw of ids) {
734
- const id = parseInt(raw, 10);
735
- if (!Number.isFinite(id) || id <= 0) continue;
736
- const ph = phEl(id);
737
- if (!canShowPlaceholderId(id, t)) continue;
565
+ requestAnimationFrame(() => {
566
+ try {
567
+ if (isBlocked()) { clearTimeout(timer); return release(); }
568
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
569
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
738
570
 
571
+ const t = ts();
572
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
739
573
  S.lastShow.set(id, t);
740
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
741
- valid.push(id);
742
- }
743
574
 
744
- if (!valid.length) { clearTimeout(timer); return release(); }
575
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
745
576
 
746
- window.ezstandalone = window.ezstandalone || {};
747
- const ez = window.ezstandalone;
748
- const doShow = () => {
749
- const prepared = destroyBeforeReuse(valid);
750
- if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
751
- try { ez.showAds(...prepared); } catch (_) {}
752
- for (const id of prepared) {
753
- S.ezActiveIds.add(id);
754
- S.ezShownSinceDestroy.add(id);
755
- }
756
- setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
757
- };
758
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
759
- } catch (_) { clearTimeout(timer); release(); }
760
- });
761
- }
577
+ window.ezstandalone = window.ezstandalone || {};
578
+ const ez = window.ezstandalone;
579
+ const doShow = () => {
580
+ try { ez.showAds(id); } catch (_) {}
581
+ scheduleEmptyCheck(id, t);
582
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
583
+ };
584
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
585
+ } catch (_) { clearTimeout(timer); release(); }
586
+ });
587
+ }
762
588
 
589
+ function scheduleEmptyCheck(id, showTs) {
590
+ setTimeout(() => {
591
+ try {
592
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
593
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
594
+ if (!wrap || !ph?.isConnected) return;
595
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
596
+ wrap.classList.toggle('is-empty', !isFilled(ph));
597
+ } catch (_) {}
598
+ }, EMPTY_CHECK_MS);
599
+ }
763
600
 
764
601
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
765
602
  //
@@ -777,21 +614,14 @@ function startShowBatch(ids) {
777
614
  const orig = ez.showAds.bind(ez);
778
615
  ez.showAds = function (...args) {
779
616
  if (isBlocked()) return;
780
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
781
- const valid = [];
617
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
782
618
  const seen = new Set();
783
619
  for (const v of ids) {
784
620
  const id = parseInt(v, 10);
785
621
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
786
- if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
622
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
787
623
  seen.add(id);
788
- valid.push(id);
789
- }
790
- if (!valid.length) return;
791
- try { orig(...valid); } catch (_) {
792
- for (const id of valid) {
793
- try { orig(id); } catch (_) {}
794
- }
624
+ try { orig(id); } catch (_) {}
795
625
  }
796
626
  };
797
627
  } catch (_) {}
@@ -808,7 +638,6 @@ function startShowBatch(ids) {
808
638
  async function runCore() {
809
639
  if (isBlocked()) return 0;
810
640
  patchShowAds();
811
- sweepDeadWraps();
812
641
 
813
642
  const cfg = await fetchConfig();
814
643
  if (!cfg || cfg.excluded) return 0;
@@ -875,7 +704,7 @@ function startShowBatch(ids) {
875
704
  S.burstCount++;
876
705
  scheduleRun(n => {
877
706
  if (!n && !S.pending.length) { S.burstActive = false; return; }
878
- setTimeout(step, n > 0 ? 80 : 180);
707
+ setTimeout(step, n > 0 ? 150 : 300);
879
708
  });
880
709
  };
881
710
  step();
@@ -893,21 +722,11 @@ function startShowBatch(ids) {
893
722
  S.mountedIds.clear();
894
723
  S.lastShow.clear();
895
724
  S.wrapByKey.clear();
896
- S.ezActiveIds.clear();
897
- S.ezShownSinceDestroy.clear();
898
725
  S.inflight = 0;
899
726
  S.pending = [];
900
727
  S.pendingSet.clear();
901
- if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
902
- if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
903
- S.destroyPending = [];
904
- S.destroyPendingSet.clear();
905
728
  S.burstActive = false;
906
729
  S.runQueued = false;
907
- S.sweepQueued = false;
908
- S.scrollSpeed = 0;
909
- S.lastScrollY = 0;
910
- S.lastScrollTs = 0;
911
730
  }
912
731
 
913
732
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -918,14 +737,6 @@ function startShowBatch(ids) {
918
737
  S.domObs = new MutationObserver(muts => {
919
738
  if (S.mutGuard > 0 || isBlocked()) return;
920
739
  for (const m of muts) {
921
- let sawWrapRemoval = false;
922
- for (const n of m.removedNodes) {
923
- if (n.nodeType !== 1) continue;
924
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
925
- sawWrapRemoval = true;
926
- }
927
- }
928
- if (sawWrapRemoval) queueSweepDeadWraps();
929
740
  for (const n of m.addedNodes) {
930
741
  if (n.nodeType !== 1) continue;
931
742
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
@@ -1013,7 +824,7 @@ function startShowBatch(ids) {
1013
824
  S.pageKey = pageKey();
1014
825
  blockedUntil = 0;
1015
826
  muteConsole(); ensureTcfLocator(); warmNetwork();
1016
- patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
827
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
1017
828
  });
1018
829
 
1019
830
  const burstEvts = [
@@ -1035,21 +846,12 @@ function startShowBatch(ids) {
1035
846
 
1036
847
  function bindScroll() {
1037
848
  let ticking = false;
1038
- try {
1039
- S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1040
- S.lastScrollTs = ts();
1041
- } catch (_) {}
1042
849
  window.addEventListener('scroll', () => {
1043
850
  try {
1044
851
  const y = window.scrollY || window.pageYOffset || 0;
1045
- const t = ts();
1046
852
  const dy = y - (S.lastScrollY || 0);
1047
- const dt = Math.max(1, t - (S.lastScrollTs || t));
1048
- if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
1049
- const inst = Math.abs(dy) * 1000 / dt;
1050
- S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
853
+ if (Math.abs(dy) > 2) S.scrollDir = dy > 0 ? 1 : -1;
1051
854
  S.lastScrollY = y;
1052
- S.lastScrollTs = t;
1053
855
  } catch (_) {}
1054
856
  if (ticking) return;
1055
857
  ticking = true;
@@ -1060,6 +862,7 @@ function startShowBatch(ids) {
1060
862
  // ── Boot ───────────────────────────────────────────────────────────────────
1061
863
 
1062
864
  S.pageKey = pageKey();
865
+ try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) { S.lastScrollY = 0; }
1063
866
  muteConsole();
1064
867
  ensureTcfLocator();
1065
868
  warmNetwork();
package/public/style.css CHANGED
@@ -56,6 +56,21 @@
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
+ }
73
+
59
74
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
60
75
  .ezoic-ad {
61
76
  margin: 0 !important;