nodebb-plugin-ezoic-infinite 1.8.7 → 1.8.8

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