nodebb-plugin-ezoic-infinite 1.8.7 → 1.8.9

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.7",
3
+ "version": "1.8.9",
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,12 +77,19 @@
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
- const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
80
+ // Tunables (stables en prod)
81
81
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
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
82
+ const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
83
+ const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
84
+ const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
85
+ const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
86
+ const SHOW_RELEASE_MS = 300; // 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 = 30; // 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 = 100; // délai min entre deux déclenchements de burst
92
+ const MIN_RECYCLE_AGE_DESKTOP_MS = 3000; // évite le flash show→recycle trop rapide sur PC
86
93
 
87
94
  // Marges IO larges et fixes — observer créé une seule fois au boot
88
95
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
@@ -126,14 +133,23 @@
126
133
  inflight: 0, // showAds() en cours
127
134
  pending: [], // ids en attente de slot inflight
128
135
  pendingSet: new Set(),
136
+ showBatchTimer: 0,
137
+ destroyBatchTimer: 0,
138
+ destroyPending: [],
139
+ destroyPendingSet: new Set(),
140
+ sweepQueued: false,
129
141
  wrapByKey: new Map(), // anchorKey → wrap DOM node
142
+ ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
143
+ ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
144
+ scrollDir: 1, // 1=bas, -1=haut
145
+ scrollSpeed: 0, // px/s approx (EMA)
146
+ lastScrollY: 0,
147
+ lastScrollTs: 0,
130
148
  runQueued: false,
131
149
  burstActive: false,
132
150
  burstDeadline: 0,
133
151
  burstCount: 0,
134
152
  lastBurstTs: 0,
135
- lastScrollY: 0,
136
- scrollDir: 1, // 1=down, -1=up
137
153
  };
138
154
 
139
155
  let blockedUntil = 0;
@@ -144,10 +160,120 @@
144
160
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
145
161
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
146
162
 
163
+ function healFalseEmpty(root = document) {
164
+ try {
165
+ const list = [];
166
+ if (root instanceof Element && root.classList?.contains(WRAP_CLASS)) list.push(root);
167
+ const found = root.querySelectorAll ? root.querySelectorAll(`.${WRAP_CLASS}.is-empty`) : [];
168
+ for (const w of found) list.push(w);
169
+ for (const w of list) {
170
+ if (!w?.classList?.contains('is-empty')) continue;
171
+ if (isFilled(w)) w.classList.remove('is-empty');
172
+ }
173
+ } catch (_) {}
174
+ }
175
+
176
+ function phEl(id) {
177
+ return document.getElementById(`${PH_PREFIX}${id}`);
178
+ }
179
+
180
+ function hasSinglePlaceholder(id) {
181
+ try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
182
+ }
183
+
184
+ function canShowPlaceholderId(id, now = ts()) {
185
+ const n = parseInt(id, 10);
186
+ if (!Number.isFinite(n) || n <= 0) return false;
187
+ if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
188
+ const ph = phEl(n);
189
+ if (!ph?.isConnected || isFilled(ph)) return false;
190
+ if (!hasSinglePlaceholder(n)) return false;
191
+ return true;
192
+ }
193
+
194
+ function queueSweepDeadWraps() {
195
+ if (S.sweepQueued) return;
196
+ S.sweepQueued = true;
197
+ requestAnimationFrame(() => {
198
+ S.sweepQueued = false;
199
+ sweepDeadWraps();
200
+ healFalseEmpty();
201
+ });
202
+ }
203
+
204
+ function getDynamicShowBatchMax() {
205
+ const speed = S.scrollSpeed || 0;
206
+ const pend = S.pending.length;
207
+ // Scroll très rapide => petits batches (réduit le churn/unused)
208
+ if (speed > 2600) return 2;
209
+ if (speed > 1400) return 3;
210
+ // Peu de candidats => flush plus vite, inutile d'attendre 4
211
+ if (pend <= 1) return 1;
212
+ if (pend <= 3) return 2;
213
+ // Par défaut compromis dynamique
214
+ return 3;
215
+ }
216
+
147
217
  function mutate(fn) {
148
218
  S.mutGuard++;
149
219
  try { fn(); } finally { S.mutGuard--; }
150
220
  }
221
+ function scheduleDestroyFlush() {
222
+ if (S.destroyBatchTimer) return;
223
+ S.destroyBatchTimer = setTimeout(() => {
224
+ S.destroyBatchTimer = 0;
225
+ flushDestroyBatch();
226
+ }, DESTROY_FLUSH_MS);
227
+ }
228
+
229
+ function flushDestroyBatch() {
230
+ if (!S.destroyPending.length) return;
231
+ const ids = [];
232
+ while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
233
+ const id = S.destroyPending.shift();
234
+ S.destroyPendingSet.delete(id);
235
+ if (!Number.isFinite(id) || id <= 0) continue;
236
+ ids.push(id);
237
+ }
238
+ if (ids.length) {
239
+ try {
240
+ const ez = window.ezstandalone;
241
+ const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
242
+ try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
243
+ } catch (_) {}
244
+ }
245
+ if (S.destroyPending.length) scheduleDestroyFlush();
246
+ }
247
+
248
+ function destroyEzoicId(id) {
249
+ if (!Number.isFinite(id) || id <= 0) return;
250
+ if (!S.ezActiveIds.has(id)) return;
251
+ S.ezActiveIds.delete(id);
252
+ if (!S.destroyPendingSet.has(id)) {
253
+ S.destroyPending.push(id);
254
+ S.destroyPendingSet.add(id);
255
+ }
256
+ scheduleDestroyFlush();
257
+ }
258
+
259
+ function destroyBeforeReuse(ids) {
260
+ const out = [];
261
+ const toDestroy = [];
262
+ const seen = new Set();
263
+ for (const raw of (ids || [])) {
264
+ const id = parseInt(raw, 10);
265
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
266
+ seen.add(id);
267
+ out.push(id);
268
+ if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
269
+ }
270
+ if (toDestroy.length) {
271
+ try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
272
+ for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
273
+ }
274
+ return out;
275
+ }
276
+
151
277
 
152
278
  // ── Config ─────────────────────────────────────────────────────────────────
153
279
 
@@ -302,6 +428,25 @@
302
428
  return null;
303
429
  }
304
430
 
431
+ function sweepDeadWraps() {
432
+ // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
433
+ // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
434
+ for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
435
+ if (wrap?.isConnected) continue;
436
+ S.wrapByKey.delete(key);
437
+ const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
438
+ if (Number.isFinite(id)) {
439
+ S.mountedIds.delete(id);
440
+ S.pendingSet.delete(id);
441
+ S.lastShow.delete(id);
442
+ S.ezActiveIds.delete(id);
443
+ }
444
+ }
445
+ if (S.pending.length) {
446
+ S.pending = S.pending.filter(id => S.pendingSet.has(id));
447
+ }
448
+ }
449
+
305
450
  /**
306
451
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
307
452
  * Séquence avec délais (destroyPlaceholders est asynchrone) :
@@ -309,83 +454,88 @@
309
454
  * displayMore = API Ezoic prévue pour l'infinite scroll.
310
455
  * Priorité : wraps vides d'abord, remplis si nécessaire.
311
456
  */
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;
345
- }
346
-
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
- }
457
+ function recycleAndMove(klass, targetEl, newKey) {
458
+ const ez = window.ezstandalone;
459
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
460
+ typeof ez?.define !== 'function' ||
461
+ typeof ez?.displayMore !== 'function') return null;
462
+
463
+ const vh = window.innerHeight || 800;
464
+ const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
465
+ const farAbove = -vh;
466
+ const farBelow = vh * 2;
467
+
468
+ let bestPrefEmpty = null, bestPrefMetric = Infinity;
469
+ let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
470
+ let bestAnyEmpty = null, bestAnyMetric = Infinity;
471
+ let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
472
+
473
+ for (const wrap of S.wrapByKey.values()) {
474
+ if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
475
+ try {
476
+ if (!isMobile()) {
477
+ const shownTs = parseInt(wrap.getAttribute(A_SHOWN) || '0', 10);
478
+ if (shownTs > 0 && (ts() - shownTs) < MIN_RECYCLE_AGE_DESKTOP_MS) continue;
479
+ }
480
+ const rect = wrap.getBoundingClientRect();
481
+ const isAbove = rect.bottom <= farAbove;
482
+ const isBelow = rect.top >= farBelow;
483
+ const anyFar = isAbove || isBelow;
484
+ if (!anyFar) continue;
485
+
486
+ const qualifies = preferAbove ? isAbove : isBelow;
487
+ const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
488
+ const filled = isFilled(wrap);
489
+
490
+ if (qualifies) {
491
+ if (!filled) {
492
+ if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
493
+ } else {
494
+ if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
353
495
  }
354
- } catch (_) {}
355
- });
496
+ }
497
+ if (!filled) {
498
+ if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
499
+ } else {
500
+ if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
501
+ }
502
+ } catch (_) {}
503
+ }
356
504
 
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);
505
+ const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
506
+ if (!best) return null;
507
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
508
+ if (!Number.isFinite(id)) return null;
509
+
510
+ const oldKey = best.getAttribute(A_ANCHOR);
511
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
512
+ mutate(() => {
513
+ best.setAttribute(A_ANCHOR, newKey);
514
+ best.setAttribute(A_CREATED, String(ts()));
515
+ best.setAttribute(A_SHOWN, '0');
516
+ best.classList.remove('is-empty');
517
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
518
+ if (ph) ph.innerHTML = '';
519
+ targetEl.insertAdjacentElement('afterend', best);
520
+ });
521
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
522
+ S.wrapByKey.set(newKey, best);
523
+
524
+ const doDestroy = () => {
525
+ if (S.ezShownSinceDestroy.has(id)) {
526
+ try { ez.destroyPlaceholders([id]); } catch (_) {}
527
+ S.ezShownSinceDestroy.delete(id);
528
+ }
529
+ S.ezActiveIds.delete(id);
530
+ setTimeout(doDefine, 330);
531
+ };
532
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
533
+ const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
534
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
380
535
 
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 (_) {}
536
+ return { id, wrap: best };
537
+ }
386
538
 
387
- return { id, wrap: best };
388
- }
389
539
 
390
540
  // ── Wraps DOM — création / suppression ────────────────────────────────────
391
541
 
@@ -421,7 +571,7 @@
421
571
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
422
572
  if (ph instanceof Element) S.io?.unobserve(ph);
423
573
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
424
- if (Number.isFinite(id)) S.mountedIds.delete(id);
574
+ if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
425
575
  const key = w.getAttribute(A_ANCHOR);
426
576
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
427
577
  w.remove();
@@ -496,7 +646,8 @@
496
646
  const key = anchorKey(klass, el);
497
647
  if (findWrap(key)) continue;
498
648
 
499
- const id = pickId(poolKey);
649
+ let id = pickId(poolKey);
650
+ if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
500
651
  if (id) {
501
652
  const w = insertAfter(el, id, klass, key);
502
653
  if (w) { observePh(id); inserted++; }
@@ -527,76 +678,107 @@
527
678
  }
528
679
 
529
680
  function observePh(id) {
530
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
681
+ const ph = phEl(id);
531
682
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
683
+ // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
684
+ try {
685
+ if (!ph?.isConnected) return;
686
+ const rect = ph.getBoundingClientRect();
687
+ const vh = window.innerHeight || 800;
688
+ const preload = isMobile() ? 1400 : 1000;
689
+ if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
690
+ } catch (_) {}
532
691
  }
533
692
 
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);
693
+ function enqueueShow(id) {
694
+ if (!id || isBlocked()) return;
695
+ const n = parseInt(id, 10);
696
+ if (!Number.isFinite(n) || n <= 0) return;
697
+ if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
698
+ if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
699
+ scheduleDrainQueue();
700
+ }
701
+
702
+ function scheduleDrainQueue() {
703
+ if (isBlocked()) return;
704
+ if (S.showBatchTimer) return;
705
+ S.showBatchTimer = setTimeout(() => {
706
+ S.showBatchTimer = 0;
707
+ drainQueue();
708
+ }, BATCH_FLUSH_MS);
709
+ }
710
+
711
+ function drainQueue() {
712
+ if (isBlocked()) return;
713
+ const free = Math.max(0, MAX_INFLIGHT - S.inflight);
714
+ if (!free || !S.pending.length) return;
715
+
716
+ const picked = [];
717
+ const seen = new Set();
718
+ const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
719
+ while (S.pending.length && picked.length < batchCap) {
720
+ const id = S.pending.shift();
721
+ S.pendingSet.delete(id);
722
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
723
+ if (!phEl(id)?.isConnected) continue;
724
+ seen.add(id);
725
+ picked.push(id);
542
726
  }
727
+ if (picked.length) startShowBatch(picked);
728
+ if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
729
+ }
730
+
731
+ function startShowBatch(ids) {
732
+ if (!ids?.length || isBlocked()) return;
733
+ const reserve = ids.length;
734
+ S.inflight += reserve;
735
+
736
+ let done = false;
737
+ const release = () => {
738
+ if (done) return;
739
+ done = true;
740
+ S.inflight = Math.max(0, S.inflight - reserve);
741
+ drainQueue();
742
+ };
743
+ const timer = setTimeout(release, SHOW_FAILSAFE_MS);
543
744
 
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
- }
745
+ requestAnimationFrame(() => {
746
+ try {
747
+ if (isBlocked()) { clearTimeout(timer); return release(); }
552
748
 
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);
749
+ const valid = [];
750
+ const t = ts();
564
751
 
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(); }
752
+ for (const raw of ids) {
753
+ const id = parseInt(raw, 10);
754
+ if (!Number.isFinite(id) || id <= 0) continue;
755
+ const ph = phEl(id);
756
+ if (!canShowPlaceholderId(id, t)) continue;
570
757
 
571
- const t = ts();
572
- if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
573
758
  S.lastShow.set(id, t);
574
-
575
759
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
760
+ valid.push(id);
761
+ }
576
762
 
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
- }
763
+ if (!valid.length) { clearTimeout(timer); return release(); }
764
+
765
+ window.ezstandalone = window.ezstandalone || {};
766
+ const ez = window.ezstandalone;
767
+ const doShow = () => {
768
+ const prepared = destroyBeforeReuse(valid);
769
+ if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
770
+ try { ez.showAds(...prepared); } catch (_) {}
771
+ for (const id of prepared) {
772
+ S.ezActiveIds.add(id);
773
+ S.ezShownSinceDestroy.add(id);
774
+ }
775
+ setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
776
+ };
777
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
778
+ } catch (_) { clearTimeout(timer); release(); }
779
+ });
780
+ }
588
781
 
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
- }
600
782
 
601
783
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
602
784
  //
@@ -614,14 +796,21 @@
614
796
  const orig = ez.showAds.bind(ez);
615
797
  ez.showAds = function (...args) {
616
798
  if (isBlocked()) return;
617
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
799
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
800
+ const valid = [];
618
801
  const seen = new Set();
619
802
  for (const v of ids) {
620
803
  const id = parseInt(v, 10);
621
804
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
622
- if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
805
+ if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
623
806
  seen.add(id);
624
- try { orig(id); } catch (_) {}
807
+ valid.push(id);
808
+ }
809
+ if (!valid.length) return;
810
+ try { orig(...valid); } catch (_) {
811
+ for (const id of valid) {
812
+ try { orig(id); } catch (_) {}
813
+ }
625
814
  }
626
815
  };
627
816
  } catch (_) {}
@@ -638,6 +827,7 @@
638
827
  async function runCore() {
639
828
  if (isBlocked()) return 0;
640
829
  patchShowAds();
830
+ sweepDeadWraps();
641
831
 
642
832
  const cfg = await fetchConfig();
643
833
  if (!cfg || cfg.excluded) return 0;
@@ -704,7 +894,7 @@
704
894
  S.burstCount++;
705
895
  scheduleRun(n => {
706
896
  if (!n && !S.pending.length) { S.burstActive = false; return; }
707
- setTimeout(step, n > 0 ? 150 : 300);
897
+ setTimeout(step, n > 0 ? 80 : 180);
708
898
  });
709
899
  };
710
900
  step();
@@ -722,11 +912,21 @@
722
912
  S.mountedIds.clear();
723
913
  S.lastShow.clear();
724
914
  S.wrapByKey.clear();
915
+ S.ezActiveIds.clear();
916
+ S.ezShownSinceDestroy.clear();
725
917
  S.inflight = 0;
726
918
  S.pending = [];
727
919
  S.pendingSet.clear();
920
+ if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
921
+ if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
922
+ S.destroyPending = [];
923
+ S.destroyPendingSet.clear();
728
924
  S.burstActive = false;
729
925
  S.runQueued = false;
926
+ S.sweepQueued = false;
927
+ S.scrollSpeed = 0;
928
+ S.lastScrollY = 0;
929
+ S.lastScrollTs = 0;
730
930
  }
731
931
 
732
932
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -737,8 +937,17 @@
737
937
  S.domObs = new MutationObserver(muts => {
738
938
  if (S.mutGuard > 0 || isBlocked()) return;
739
939
  for (const m of muts) {
940
+ let sawWrapRemoval = false;
941
+ for (const n of m.removedNodes) {
942
+ if (n.nodeType !== 1) continue;
943
+ if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
944
+ sawWrapRemoval = true;
945
+ }
946
+ }
947
+ if (sawWrapRemoval) queueSweepDeadWraps();
740
948
  for (const n of m.addedNodes) {
741
949
  if (n.nodeType !== 1) continue;
950
+ try { healFalseEmpty(n); } catch (_) {}
742
951
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
743
952
  if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
744
953
  allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
@@ -824,7 +1033,7 @@
824
1033
  S.pageKey = pageKey();
825
1034
  blockedUntil = 0;
826
1035
  muteConsole(); ensureTcfLocator(); warmNetwork();
827
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
1036
+ patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
828
1037
  });
829
1038
 
830
1039
  const burstEvts = [
@@ -846,12 +1055,21 @@
846
1055
 
847
1056
  function bindScroll() {
848
1057
  let ticking = false;
1058
+ try {
1059
+ S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1060
+ S.lastScrollTs = ts();
1061
+ } catch (_) {}
849
1062
  window.addEventListener('scroll', () => {
850
1063
  try {
851
1064
  const y = window.scrollY || window.pageYOffset || 0;
1065
+ const t = ts();
852
1066
  const dy = y - (S.lastScrollY || 0);
853
- if (Math.abs(dy) > 2) S.scrollDir = dy > 0 ? 1 : -1;
1067
+ const dt = Math.max(1, t - (S.lastScrollTs || t));
1068
+ if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
1069
+ const inst = Math.abs(dy) * 1000 / dt;
1070
+ S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
854
1071
  S.lastScrollY = y;
1072
+ S.lastScrollTs = t;
855
1073
  } catch (_) {}
856
1074
  if (ticking) return;
857
1075
  ticking = true;
@@ -862,7 +1080,6 @@
862
1080
  // ── Boot ───────────────────────────────────────────────────────────────────
863
1081
 
864
1082
  S.pageKey = pageKey();
865
- try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) { S.lastScrollY = 0; }
866
1083
  muteConsole();
867
1084
  ensureTcfLocator();
868
1085
  warmNetwork();
package/public/style.css CHANGED
@@ -56,23 +56,17 @@
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
-
74
59
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
75
60
  .ezoic-ad {
76
61
  margin: 0 !important;
77
62
  padding: 0 !important;
78
63
  }
64
+
65
+
66
+ /* Filet anti faux-empty : si la pub est rendue, ne pas laisser le wrap replié */
67
+ .nodebb-ezoic-wrap.is-empty:has(iframe, [data-google-container-id], [id^="google_ads_iframe_"]) {
68
+ height: auto !important;
69
+ min-height: 1px !important;
70
+ max-height: none !important;
71
+ overflow: visible !important;
72
+ }