nodebb-plugin-ezoic-infinite 1.8.5 → 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.5",
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,23 +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
92
- const PAGE_WARMUP_MS = 700; // délai minimal avant premiers showAds
93
- const PAGE_SETTLE_GATE_MS = 5000; // fenêtre où l'on surveille la croissance réelle du contenu
94
- const CONTENT_SETTLE_WINDOW_MS = 500;
95
- const CONTENT_SETTLE_MAX_DELAY_MS = 2500;
96
- const CONTENT_GROWTH_THRESHOLD_PX = 40;
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
97
86
 
98
87
  // Marges IO larges et fixes — observer créé une seule fois au boot
99
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
@@ -137,29 +126,14 @@
137
126
  inflight: 0, // showAds() en cours
138
127
  pending: [], // ids en attente de slot inflight
139
128
  pendingSet: new Set(),
140
- showBatchTimer: 0,
141
- destroyBatchTimer: 0,
142
- destroyPending: [],
143
- destroyPendingSet: new Set(),
144
- sweepQueued: false,
145
129
  wrapByKey: new Map(), // anchorKey → wrap DOM node
146
- ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
147
- ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
148
- scrollDir: 1, // 1=bas, -1=haut
149
- scrollSpeed: 0, // px/s approx (EMA)
150
- lastScrollY: 0,
151
- lastScrollTs: 0,
152
130
  runQueued: false,
153
131
  burstActive: false,
154
132
  burstDeadline: 0,
155
133
  burstCount: 0,
156
134
  lastBurstTs: 0,
157
- pageWarmUntil: 0,
158
- pageSettleUntil: 0,
159
- settleDelaySince: 0,
160
- contentStableStreak: 0,
161
- contentSampleH: 0,
162
- contentSampleTs: 0,
135
+ lastScrollY: 0,
136
+ scrollDir: 1, // 1=down, -1=up
163
137
  };
164
138
 
165
139
  let blockedUntil = 0;
@@ -170,166 +144,10 @@
170
144
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
171
145
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
172
146
 
173
- function phEl(id) {
174
- return document.getElementById(`${PH_PREFIX}${id}`);
175
- }
176
-
177
- function hasSinglePlaceholder(id) {
178
- try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
179
- }
180
-
181
- function canShowPlaceholderId(id, now = ts()) {
182
- const n = parseInt(id, 10);
183
- if (!Number.isFinite(n) || n <= 0) return false;
184
- if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
185
- const ph = phEl(n);
186
- if (!ph?.isConnected || isFilled(ph)) return false;
187
- if (!hasSinglePlaceholder(n)) return false;
188
- return true;
189
- }
190
-
191
- function queueSweepDeadWraps() {
192
- if (S.sweepQueued) return;
193
- S.sweepQueued = true;
194
- requestAnimationFrame(() => {
195
- S.sweepQueued = false;
196
- sweepDeadWraps();
197
- });
198
- }
199
-
200
- function getDynamicShowBatchMax() {
201
- const speed = S.scrollSpeed || 0;
202
- const pend = S.pending.length;
203
- // Scroll très rapide => petits batches (réduit le churn/unused)
204
- if (speed > 2600) return 2;
205
- if (speed > 1400) return 3;
206
- // Peu de candidats => flush plus vite, inutile d'attendre 4
207
- if (pend <= 1) return 1;
208
- if (pend <= 3) return 2;
209
- // Par défaut compromis dynamique
210
- return 3;
211
- }
212
-
213
147
  function mutate(fn) {
214
148
  S.mutGuard++;
215
149
  try { fn(); } finally { S.mutGuard--; }
216
150
  }
217
- function scheduleDestroyFlush() {
218
- if (S.destroyBatchTimer) return;
219
- S.destroyBatchTimer = setTimeout(() => {
220
- S.destroyBatchTimer = 0;
221
- flushDestroyBatch();
222
- }, DESTROY_FLUSH_MS);
223
- }
224
-
225
- function flushDestroyBatch() {
226
- if (!S.destroyPending.length) return;
227
- const ids = [];
228
- while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
229
- const id = S.destroyPending.shift();
230
- S.destroyPendingSet.delete(id);
231
- if (!Number.isFinite(id) || id <= 0) continue;
232
- ids.push(id);
233
- }
234
- if (ids.length) {
235
- try {
236
- const ez = window.ezstandalone;
237
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
238
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
239
- } catch (_) {}
240
- }
241
- if (S.destroyPending.length) scheduleDestroyFlush();
242
- }
243
-
244
- function destroyEzoicId(id) {
245
- if (!Number.isFinite(id) || id <= 0) return;
246
- if (!S.ezActiveIds.has(id)) return;
247
- S.ezActiveIds.delete(id);
248
- if (!S.destroyPendingSet.has(id)) {
249
- S.destroyPending.push(id);
250
- S.destroyPendingSet.add(id);
251
- }
252
- scheduleDestroyFlush();
253
- }
254
-
255
- function destroyBeforeReuse(ids) {
256
- const out = [];
257
- const toDestroy = [];
258
- const seen = new Set();
259
- for (const raw of (ids || [])) {
260
- const id = parseInt(raw, 10);
261
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
262
- seen.add(id);
263
- out.push(id);
264
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
265
- }
266
- if (toDestroy.length) {
267
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
268
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
269
- }
270
- return out;
271
- }
272
-
273
-
274
- function getContentHeight() {
275
- try {
276
- const c = document.getElementById('content') || document.querySelector('main#panel') || document.body;
277
- if (!c) return 0;
278
- return Math.max(c.scrollHeight || 0, c.offsetHeight || 0);
279
- } catch (_) { return 0; }
280
- }
281
-
282
- function getShowSettleDelayMs(now = ts()) {
283
- try {
284
- // Warmup minimal fixe (évite de tirer des pubs avant le 1er rendu NodeBB)
285
- if (S.pageWarmUntil && now < S.pageWarmUntil) {
286
- return Math.max(0, Math.min(250, S.pageWarmUntil - now));
287
- }
288
-
289
- // En dehors de la fenêtre "page en train de se construire", pas de gate.
290
- if (!(S.pageSettleUntil && now < S.pageSettleUntil)) return 0;
291
-
292
- const h = getContentHeight();
293
- const prevH = S.contentSampleH || 0;
294
- const prevTs = S.contentSampleTs || 0;
295
- const dt = prevTs ? (now - prevTs) : 0;
296
- const grew = h - prevH;
297
-
298
- S.contentSampleH = h;
299
- S.contentSampleTs = now;
300
-
301
- if (!prevTs || h <= 0) {
302
- S.contentStableStreak = 0;
303
- return CONTENT_SETTLE_WINDOW_MS;
304
- }
305
-
306
- // Si les samples sont trop rapprochés, on attend une vraie fenêtre d'observation.
307
- if (dt < Math.max(120, CONTENT_SETTLE_WINDOW_MS * 0.6)) {
308
- return Math.max(80, CONTENT_SETTLE_WINDOW_MS - dt);
309
- }
310
-
311
- const significantGrowth = grew >= CONTENT_GROWTH_THRESHOLD_PX;
312
- if (significantGrowth) {
313
- if (!S.settleDelaySince) S.settleDelaySince = now;
314
- S.contentStableStreak = 0;
315
- if ((now - S.settleDelaySince) < CONTENT_SETTLE_MAX_DELAY_MS) {
316
- return CONTENT_SETTLE_WINDOW_MS;
317
- }
318
- // plafond atteint : on laisse passer pour ne pas bloquer indéfiniment
319
- S.settleDelaySince = 0;
320
- return 0;
321
- }
322
-
323
- // Nécessite 2 fenêtres stables de suite avant de laisser partir le batch (début de page)
324
- S.contentStableStreak = (S.contentStableStreak || 0) + 1;
325
- if (S.contentStableStreak < 2) return Math.min(200, CONTENT_SETTLE_WINDOW_MS);
326
-
327
- S.settleDelaySince = 0;
328
- return 0;
329
- } catch (_) {}
330
- return 0;
331
- }
332
-
333
151
 
334
152
  // ── Config ─────────────────────────────────────────────────────────────────
335
153
 
@@ -484,25 +302,6 @@ function destroyBeforeReuse(ids) {
484
302
  return null;
485
303
  }
486
304
 
487
- function sweepDeadWraps() {
488
- // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
489
- // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
490
- for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
491
- if (wrap?.isConnected) continue;
492
- S.wrapByKey.delete(key);
493
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
494
- if (Number.isFinite(id)) {
495
- S.mountedIds.delete(id);
496
- S.pendingSet.delete(id);
497
- S.lastShow.delete(id);
498
- S.ezActiveIds.delete(id);
499
- }
500
- }
501
- if (S.pending.length) {
502
- S.pending = S.pending.filter(id => S.pendingSet.has(id));
503
- }
504
- }
505
-
506
305
  /**
507
306
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
508
307
  * Séquence avec délais (destroyPlaceholders est asynchrone) :
@@ -510,84 +309,83 @@ function destroyBeforeReuse(ids) {
510
309
  * displayMore = API Ezoic prévue pour l'infinite scroll.
511
310
  * Priorité : wraps vides d'abord, remplis si nécessaire.
512
311
  */
513
- function recycleAndMove(klass, targetEl, newKey) {
514
- const ez = window.ezstandalone;
515
- if (typeof ez?.destroyPlaceholders !== 'function' ||
516
- typeof ez?.define !== 'function' ||
517
- typeof ez?.displayMore !== 'function') return null;
518
-
519
- const vh = window.innerHeight || 800;
520
- const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
521
- const farAbove = -vh;
522
- const farBelow = vh * 2;
523
-
524
- let bestPrefEmpty = null, bestPrefMetric = Infinity;
525
- let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
526
- let bestAnyEmpty = null, bestAnyMetric = Infinity;
527
- let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
528
-
529
- for (const wrap of S.wrapByKey.values()) {
530
- if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
531
- try {
532
- const rect = wrap.getBoundingClientRect();
533
- const isAbove = rect.bottom <= farAbove;
534
- const isBelow = rect.top >= farBelow;
535
- const anyFar = isAbove || isBelow;
536
- if (!anyFar) continue;
537
-
538
- const qualifies = preferAbove ? isAbove : isBelow;
539
- const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
540
- const filled = isFilled(wrap);
541
-
542
- if (qualifies) {
543
- if (!filled) {
544
- if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
545
- } else {
546
- 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;
547
345
  }
548
- }
549
- if (!filled) {
550
- if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
551
- } else {
552
- if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
553
- }
554
- } catch (_) {}
555
- }
556
346
 
557
- const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
558
- if (!best) return null;
559
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
560
- if (!Number.isFinite(id)) return null;
561
-
562
- const oldKey = best.getAttribute(A_ANCHOR);
563
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
564
- mutate(() => {
565
- best.setAttribute(A_ANCHOR, newKey);
566
- best.setAttribute(A_CREATED, String(ts()));
567
- best.setAttribute(A_SHOWN, '0');
568
- best.classList.remove('is-empty');
569
- const ph = best.querySelector(`#${PH_PREFIX}${id}`);
570
- if (ph) ph.innerHTML = '';
571
- targetEl.insertAdjacentElement('afterend', best);
572
- });
573
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
574
- S.wrapByKey.set(newKey, best);
575
-
576
- const doDestroy = () => {
577
- if (S.ezShownSinceDestroy.has(id)) {
578
- try { ez.destroyPlaceholders([id]); } catch (_) {}
579
- S.ezShownSinceDestroy.delete(id);
580
- }
581
- S.ezActiveIds.delete(id);
582
- setTimeout(doDefine, 330);
583
- };
584
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
585
- const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
586
- 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
+ });
587
356
 
588
- return { id, wrap: best };
589
- }
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);
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 (_) {}
590
386
 
387
+ return { id, wrap: best };
388
+ }
591
389
 
592
390
  // ── Wraps DOM — création / suppression ────────────────────────────────────
593
391
 
@@ -623,7 +421,7 @@ function recycleAndMove(klass, targetEl, newKey) {
623
421
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
624
422
  if (ph instanceof Element) S.io?.unobserve(ph);
625
423
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
626
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
424
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
627
425
  const key = w.getAttribute(A_ANCHOR);
628
426
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
629
427
  w.remove();
@@ -698,8 +496,7 @@ function recycleAndMove(klass, targetEl, newKey) {
698
496
  const key = anchorKey(klass, el);
699
497
  if (findWrap(key)) continue;
700
498
 
701
- let id = pickId(poolKey);
702
- if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
499
+ const id = pickId(poolKey);
703
500
  if (id) {
704
501
  const w = insertAfter(el, id, klass, key);
705
502
  if (w) { observePh(id); inserted++; }
@@ -730,109 +527,76 @@ function recycleAndMove(klass, targetEl, newKey) {
730
527
  }
731
528
 
732
529
  function observePh(id) {
733
- const ph = phEl(id);
530
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
734
531
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
735
- // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
736
- try {
737
- if (!ph?.isConnected) return;
738
- const rect = ph.getBoundingClientRect();
739
- const vh = window.innerHeight || 800;
740
- const preload = isMobile() ? 1400 : 1000;
741
- if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
742
- } catch (_) {}
743
532
  }
744
533
 
745
- function enqueueShow(id) {
746
- if (!id || isBlocked()) return;
747
- const n = parseInt(id, 10);
748
- if (!Number.isFinite(n) || n <= 0) return;
749
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
750
- if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
751
- scheduleDrainQueue();
752
- }
753
-
754
- function scheduleDrainQueue(delayMs = BATCH_FLUSH_MS) {
755
- if (isBlocked()) return;
756
- if (S.showBatchTimer) return;
757
- S.showBatchTimer = setTimeout(() => {
758
- S.showBatchTimer = 0;
759
- drainQueue();
760
- }, Math.max(0, delayMs|0));
761
- }
762
-
763
- function drainQueue() {
764
- if (isBlocked()) return;
765
- const settleDelay = getShowSettleDelayMs();
766
- if (settleDelay > 0) { scheduleDrainQueue(Math.max(BATCH_FLUSH_MS, settleDelay)); return; }
767
- const free = Math.max(0, MAX_INFLIGHT - S.inflight);
768
- if (!free || !S.pending.length) return;
769
-
770
- const picked = [];
771
- const seen = new Set();
772
- const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
773
- while (S.pending.length && picked.length < batchCap) {
774
- const id = S.pending.shift();
775
- S.pendingSet.delete(id);
776
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
777
- if (!phEl(id)?.isConnected) continue;
778
- seen.add(id);
779
- 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);
780
542
  }
781
- if (picked.length) startShowBatch(picked);
782
- if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
783
- }
784
-
785
- function startShowBatch(ids) {
786
- if (!ids?.length || isBlocked()) return;
787
- const reserve = ids.length;
788
- S.inflight += reserve;
789
-
790
- let done = false;
791
- const release = () => {
792
- if (done) return;
793
- done = true;
794
- S.inflight = Math.max(0, S.inflight - reserve);
795
- drainQueue();
796
- };
797
- const timer = setTimeout(release, SHOW_FAILSAFE_MS);
798
543
 
799
- requestAnimationFrame(() => {
800
- try {
801
- 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
+ }
802
552
 
803
- const valid = [];
804
- 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);
805
564
 
806
- for (const raw of ids) {
807
- const id = parseInt(raw, 10);
808
- if (!Number.isFinite(id) || id <= 0) continue;
809
- const ph = phEl(id);
810
- 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(); }
811
570
 
571
+ const t = ts();
572
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
812
573
  S.lastShow.set(id, t);
813
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
814
- valid.push(id);
815
- }
816
574
 
817
- if (!valid.length) { clearTimeout(timer); return release(); }
575
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
818
576
 
819
- window.ezstandalone = window.ezstandalone || {};
820
- const ez = window.ezstandalone;
821
- const doShow = () => {
822
- const prepared = destroyBeforeReuse(valid);
823
- if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
824
- try { ez.showAds(...prepared); } catch (_) {}
825
- for (const id of prepared) {
826
- S.ezActiveIds.add(id);
827
- S.ezShownSinceDestroy.add(id);
828
- }
829
- setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
830
- };
831
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
832
- } catch (_) { clearTimeout(timer); release(); }
833
- });
834
- }
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
+ }
835
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
+ }
836
600
 
837
601
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
838
602
  //
@@ -850,21 +614,14 @@ function startShowBatch(ids) {
850
614
  const orig = ez.showAds.bind(ez);
851
615
  ez.showAds = function (...args) {
852
616
  if (isBlocked()) return;
853
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
854
- const valid = [];
617
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
855
618
  const seen = new Set();
856
619
  for (const v of ids) {
857
620
  const id = parseInt(v, 10);
858
621
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
859
- if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
622
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
860
623
  seen.add(id);
861
- valid.push(id);
862
- }
863
- if (!valid.length) return;
864
- try { orig(...valid); } catch (_) {
865
- for (const id of valid) {
866
- try { orig(id); } catch (_) {}
867
- }
624
+ try { orig(id); } catch (_) {}
868
625
  }
869
626
  };
870
627
  } catch (_) {}
@@ -881,7 +638,6 @@ function startShowBatch(ids) {
881
638
  async function runCore() {
882
639
  if (isBlocked()) return 0;
883
640
  patchShowAds();
884
- sweepDeadWraps();
885
641
 
886
642
  const cfg = await fetchConfig();
887
643
  if (!cfg || cfg.excluded) return 0;
@@ -948,7 +704,7 @@ function startShowBatch(ids) {
948
704
  S.burstCount++;
949
705
  scheduleRun(n => {
950
706
  if (!n && !S.pending.length) { S.burstActive = false; return; }
951
- setTimeout(step, n > 0 ? 80 : 180);
707
+ setTimeout(step, n > 0 ? 150 : 300);
952
708
  });
953
709
  };
954
710
  step();
@@ -966,27 +722,11 @@ function startShowBatch(ids) {
966
722
  S.mountedIds.clear();
967
723
  S.lastShow.clear();
968
724
  S.wrapByKey.clear();
969
- S.ezActiveIds.clear();
970
- S.ezShownSinceDestroy.clear();
971
725
  S.inflight = 0;
972
726
  S.pending = [];
973
727
  S.pendingSet.clear();
974
- if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
975
- if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
976
- S.destroyPending = [];
977
- S.destroyPendingSet.clear();
978
728
  S.burstActive = false;
979
729
  S.runQueued = false;
980
- S.sweepQueued = false;
981
- S.scrollSpeed = 0;
982
- S.lastScrollY = 0;
983
- S.lastScrollTs = 0;
984
- S.pageWarmUntil = 0;
985
- S.pageSettleUntil = 0;
986
- S.contentStableStreak = 0;
987
- S.settleDelaySince = 0;
988
- S.contentSampleH = 0;
989
- S.contentSampleTs = 0;
990
730
  }
991
731
 
992
732
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -997,14 +737,6 @@ function startShowBatch(ids) {
997
737
  S.domObs = new MutationObserver(muts => {
998
738
  if (S.mutGuard > 0 || isBlocked()) return;
999
739
  for (const m of muts) {
1000
- let sawWrapRemoval = false;
1001
- for (const n of m.removedNodes) {
1002
- if (n.nodeType !== 1) continue;
1003
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
1004
- sawWrapRemoval = true;
1005
- }
1006
- }
1007
- if (sawWrapRemoval) queueSweepDeadWraps();
1008
740
  for (const n of m.addedNodes) {
1009
741
  if (n.nodeType !== 1) continue;
1010
742
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
@@ -1092,13 +824,7 @@ function startShowBatch(ids) {
1092
824
  S.pageKey = pageKey();
1093
825
  blockedUntil = 0;
1094
826
  muteConsole(); ensureTcfLocator(); warmNetwork();
1095
- S.pageWarmUntil = ts() + PAGE_WARMUP_MS;
1096
- S.pageSettleUntil = ts() + PAGE_SETTLE_GATE_MS;
1097
- S.settleDelaySince = 0;
1098
- S.contentStableStreak = 0;
1099
- S.contentSampleH = getContentHeight();
1100
- S.contentSampleTs = ts();
1101
- patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
827
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
1102
828
  });
1103
829
 
1104
830
  const burstEvts = [
@@ -1120,21 +846,12 @@ function startShowBatch(ids) {
1120
846
 
1121
847
  function bindScroll() {
1122
848
  let ticking = false;
1123
- try {
1124
- S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1125
- S.lastScrollTs = ts();
1126
- } catch (_) {}
1127
849
  window.addEventListener('scroll', () => {
1128
850
  try {
1129
851
  const y = window.scrollY || window.pageYOffset || 0;
1130
- const t = ts();
1131
852
  const dy = y - (S.lastScrollY || 0);
1132
- const dt = Math.max(1, t - (S.lastScrollTs || t));
1133
- if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
1134
- const inst = Math.abs(dy) * 1000 / dt;
1135
- S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
853
+ if (Math.abs(dy) > 2) S.scrollDir = dy > 0 ? 1 : -1;
1136
854
  S.lastScrollY = y;
1137
- S.lastScrollTs = t;
1138
855
  } catch (_) {}
1139
856
  if (ticking) return;
1140
857
  ticking = true;
@@ -1145,6 +862,7 @@ function startShowBatch(ids) {
1145
862
  // ── Boot ───────────────────────────────────────────────────────────────────
1146
863
 
1147
864
  S.pageKey = pageKey();
865
+ try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) { S.lastScrollY = 0; }
1148
866
  muteConsole();
1149
867
  ensureTcfLocator();
1150
868
  warmNetwork();
@@ -1153,11 +871,6 @@ function startShowBatch(ids) {
1153
871
  ensureDomObserver();
1154
872
  bindNodeBB();
1155
873
  bindScroll();
1156
- S.pageWarmUntil = ts() + PAGE_WARMUP_MS;
1157
- S.pageSettleUntil = ts() + PAGE_SETTLE_GATE_MS;
1158
- S.contentStableStreak = 0;
1159
- S.contentSampleH = getContentHeight();
1160
- S.contentSampleTs = ts();
1161
874
  blockedUntil = 0;
1162
875
  requestBurst();
1163
876
 
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;