nodebb-plugin-ezoic-infinite 1.8.18 → 1.8.19

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/library.js CHANGED
@@ -75,45 +75,6 @@ async function isUserExcluded(uid, excludedGroups) {
75
75
  return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
76
76
  }
77
77
 
78
-
79
-
80
- // ── Groups cache (5 min TTL) ───────────────────────────────────────────────
81
-
82
- let _groupsCache = null;
83
- let _groupsCacheAt = 0;
84
- const GROUPS_TTL = 5 * 60_000;
85
-
86
- async function getAllGroupsCached() {
87
- const now = Date.now();
88
- if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
89
- const g = await getAllGroups();
90
- _groupsCache = g;
91
- _groupsCacheAt = Date.now();
92
- return _groupsCache;
93
- }
94
-
95
- // ── Exclusion cache (per uid, 30s TTL) ─────────────────────────────────────
96
-
97
- const _excludedCache = new Map(); // uid -> { v:boolean, t:number, sig:string }
98
- const EXCLUDED_TTL = 30_000;
99
- const EXCLUDED_MAX = 10_000;
100
-
101
- function excludedSig(excludedGroups) {
102
- // signature stable to invalidate when groups list changes
103
- return excludedGroups.join('\u0001');
104
- }
105
-
106
- async function isUserExcludedCached(uid, excludedGroups) {
107
- if (!uid || !excludedGroups.length) return false;
108
- const now = Date.now();
109
- const sig = excludedSig(excludedGroups);
110
- const hit = _excludedCache.get(uid);
111
- if (hit && hit.sig === sig && (now - hit.t) < EXCLUDED_TTL) return hit.v;
112
- const v = await isUserExcluded(uid, excludedGroups);
113
- if (_excludedCache.size > EXCLUDED_MAX) _excludedCache.clear();
114
- _excludedCache.set(uid, { v, t: now, sig });
115
- return v;
116
- }
117
78
  // ── Scripts Ezoic ──────────────────────────────────────────────────────────
118
79
 
119
80
  const EZOIC_SCRIPTS = `<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>
@@ -127,7 +88,7 @@ ezstandalone.cmd = ezstandalone.cmd || [];
127
88
  // ── Hooks ──────────────────────────────────────────────────────────────────
128
89
 
129
90
  plugin.onSettingsSet = function (data) {
130
- if (data && data.hash === SETTINGS_KEY) { _settingsCache = null; _groupsCache = null; _excludedCache.clear(); }
91
+ if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
131
92
  };
132
93
 
133
94
  plugin.addAdminNavigation = async (header) => {
@@ -150,7 +111,7 @@ plugin.injectEzoicHead = async (data) => {
150
111
  try {
151
112
  const settings = await getSettings();
152
113
  const uid = data.req?.uid ?? 0;
153
- const excluded = await isUserExcludedCached(uid, settings.excludedGroups);
114
+ const excluded = await isUserExcluded(uid, settings.excludedGroups);
154
115
  if (!excluded) {
155
116
  // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
156
117
  data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
@@ -162,7 +123,7 @@ plugin.injectEzoicHead = async (data) => {
162
123
  plugin.init = async ({ router, middleware }) => {
163
124
  async function render(req, res) {
164
125
  const settings = await getSettings();
165
- const allGroups = await getAllGroupsCached();
126
+ const allGroups = await getAllGroups();
166
127
  res.render('admin/plugins/ezoic-infinite', {
167
128
  title: 'Ezoic Infinite Ads',
168
129
  ...settings,
@@ -177,7 +138,7 @@ plugin.init = async ({ router, middleware }) => {
177
138
 
178
139
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
179
140
  const settings = await getSettings();
180
- const excluded = await isUserExcludedCached(req.uid, settings.excludedGroups);
141
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
181
142
  res.json({
182
143
  excluded,
183
144
  enableBetweenAds: settings.enableBetweenAds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.18",
3
+ "version": "1.8.19",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -18,4 +18,4 @@
18
18
  "compatibility": "^4.0.0"
19
19
  },
20
20
  "private": false
21
- }
21
+ }
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,7 +132,18 @@
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,
@@ -142,10 +159,120 @@
142
159
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
143
160
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
144
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
+
145
216
  function mutate(fn) {
146
217
  S.mutGuard++;
147
218
  try { fn(); } finally { S.mutGuard--; }
148
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
+
149
276
 
150
277
  // ── Config ─────────────────────────────────────────────────────────────────
151
278
 
@@ -160,7 +287,7 @@
160
287
 
161
288
  function parseIds(raw) {
162
289
  const out = [], seen = new Set();
163
- for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
290
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
164
291
  const n = parseInt(v, 10);
165
292
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
166
293
  }
@@ -300,6 +427,25 @@
300
427
  return null;
301
428
  }
302
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
+
303
449
  /**
304
450
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
305
451
  * Séquence avec délais (destroyPlaceholders est asynchrone) :
@@ -307,60 +453,84 @@
307
453
  * displayMore = API Ezoic prévue pour l'infinite scroll.
308
454
  * Priorité : wraps vides d'abord, remplis si nécessaire.
309
455
  */
310
- function recycleAndMove(klass, targetEl, newKey) {
311
- const ez = window.ezstandalone;
312
- if (typeof ez?.destroyPlaceholders !== 'function' ||
313
- typeof ez?.define !== 'function' ||
314
- typeof ez?.displayMore !== 'function') return null;
315
-
316
- const vh = window.innerHeight || 800;
317
- // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
318
- // après pour neutraliser l'IO — plus de showAds parasite possible.
319
- const threshold = -vh;
320
- let bestEmpty = null, bestEmptyBottom = Infinity;
321
- let bestFilled = null, bestFilledBottom = Infinity;
322
-
323
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
324
- try {
325
- const rect = wrap.getBoundingClientRect();
326
- if (rect.bottom > threshold) return;
327
- if (!isFilled(wrap)) {
328
- if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
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; }
329
488
  } else {
330
- if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
489
+ if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
331
490
  }
332
- } catch (_) {}
333
- });
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
+ }
334
499
 
335
- const best = bestEmpty ?? bestFilled;
336
- if (!best) return null;
337
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
338
- if (!Number.isFinite(id)) return null;
339
-
340
- const oldKey = best.getAttribute(A_ANCHOR);
341
- // Neutraliser l'IO sur ce wrap avant déplacement évite un showAds
342
- // parasite si le nœud était encore dans la zone IO_MARGIN.
343
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
344
- mutate(() => {
345
- best.setAttribute(A_ANCHOR, newKey);
346
- best.setAttribute(A_CREATED, String(ts()));
347
- best.setAttribute(A_SHOWN, '0');
348
- best.classList.remove('is-empty');
349
- const ph = best.querySelector(`#${PH_PREFIX}${id}`);
350
- if (ph) ph.innerHTML = '';
351
- targetEl.insertAdjacentElement('afterend', best);
352
- });
353
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
354
- 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 (_) {}
355
530
 
356
- // Délais requis : destroyPlaceholders est asynchrone en interne
357
- const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
359
- const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
360
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
531
+ return { id, wrap: best };
532
+ }
361
533
 
362
- return { id, wrap: best };
363
- }
364
534
 
365
535
  // ── Wraps DOM — création / suppression ────────────────────────────────────
366
536
 
@@ -396,7 +566,7 @@
396
566
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
397
567
  if (ph instanceof Element) S.io?.unobserve(ph);
398
568
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
399
- if (Number.isFinite(id)) S.mountedIds.delete(id);
569
+ if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
400
570
  const key = w.getAttribute(A_ANCHOR);
401
571
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
402
572
  w.remove();
@@ -422,13 +592,6 @@
422
592
  const klass = 'ezoic-ad-between';
423
593
  const cfg = KIND[klass];
424
594
 
425
- // Build a fast lookup of existing anchors once (avoid querySelector per wrap)
426
- const anchors = new Set();
427
- document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`).forEach(el => {
428
- const v = el.getAttribute(cfg.anchorAttr);
429
- if (v) anchors.add(String(v));
430
- });
431
-
432
595
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
433
596
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
434
597
  if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
@@ -437,7 +600,8 @@
437
600
  const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
438
601
  if (!sid) { mutate(() => dropWrap(w)); return; }
439
602
 
440
- if (!anchors.has(String(sid))) mutate(() => dropWrap(w));
603
+ const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
604
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
441
605
  });
442
606
  }
443
607
 
@@ -477,7 +641,8 @@
477
641
  const key = anchorKey(klass, el);
478
642
  if (findWrap(key)) continue;
479
643
 
480
- const id = pickId(poolKey);
644
+ let id = pickId(poolKey);
645
+ if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
481
646
  if (id) {
482
647
  const w = insertAfter(el, id, klass, key);
483
648
  if (w) { observePh(id); inserted++; }
@@ -508,76 +673,107 @@
508
673
  }
509
674
 
510
675
  function observePh(id) {
511
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
676
+ const ph = phEl(id);
512
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 (_) {}
513
686
  }
514
687
 
515
- function enqueueShow(id) {
516
- if (!id || isBlocked()) return;
517
- if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
518
- if (S.inflight >= MAX_INFLIGHT) {
519
- if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
520
- return;
521
- }
522
- 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);
523
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);
524
739
 
525
- function drainQueue() {
526
- if (isBlocked()) return;
527
- while (S.inflight < MAX_INFLIGHT && S.pending.length) {
528
- const id = S.pending.shift();
529
- S.pendingSet.delete(id);
530
- startShow(id);
531
- }
532
- }
740
+ requestAnimationFrame(() => {
741
+ try {
742
+ if (isBlocked()) { clearTimeout(timer); return release(); }
533
743
 
534
- function startShow(id) {
535
- if (!id || isBlocked()) return;
536
- S.inflight++;
537
- let done = false;
538
- const release = () => {
539
- if (done) return;
540
- done = true;
541
- S.inflight = Math.max(0, S.inflight - 1);
542
- drainQueue();
543
- };
544
- const timer = setTimeout(release, 7000);
744
+ const valid = [];
745
+ const t = ts();
545
746
 
546
- requestAnimationFrame(() => {
547
- try {
548
- if (isBlocked()) { clearTimeout(timer); return release(); }
549
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
550
- 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;
551
752
 
552
- const t = ts();
553
- if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
554
753
  S.lastShow.set(id, t);
555
-
556
754
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
755
+ valid.push(id);
756
+ }
557
757
 
558
- window.ezstandalone = window.ezstandalone || {};
559
- const ez = window.ezstandalone;
560
- const doShow = () => {
561
- try { ez.showAds(id); } catch (_) {}
562
- scheduleEmptyCheck(id, t);
563
- setTimeout(() => { clearTimeout(timer); release(); }, 700);
564
- };
565
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
566
- } catch (_) { clearTimeout(timer); release(); }
567
- });
568
- }
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
+ }
569
776
 
570
- function scheduleEmptyCheck(id, showTs) {
571
- setTimeout(() => {
572
- try {
573
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
574
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
575
- if (!wrap || !ph?.isConnected) return;
576
- if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
577
- wrap.classList.toggle('is-empty', !isFilled(ph));
578
- } catch (_) {}
579
- }, EMPTY_CHECK_MS);
580
- }
581
777
 
582
778
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
583
779
  //
@@ -595,14 +791,21 @@
595
791
  const orig = ez.showAds.bind(ez);
596
792
  ez.showAds = function (...args) {
597
793
  if (isBlocked()) return;
598
- 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 = [];
599
796
  const seen = new Set();
600
797
  for (const v of ids) {
601
798
  const id = parseInt(v, 10);
602
799
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
603
- if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
800
+ if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
604
801
  seen.add(id);
605
- 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
+ }
606
809
  }
607
810
  };
608
811
  } catch (_) {}
@@ -619,6 +822,7 @@
619
822
  async function runCore() {
620
823
  if (isBlocked()) return 0;
621
824
  patchShowAds();
825
+ sweepDeadWraps();
622
826
 
623
827
  const cfg = await fetchConfig();
624
828
  if (!cfg || cfg.excluded) return 0;
@@ -685,7 +889,7 @@
685
889
  S.burstCount++;
686
890
  scheduleRun(n => {
687
891
  if (!n && !S.pending.length) { S.burstActive = false; return; }
688
- setTimeout(step, n > 0 ? 150 : 300);
892
+ setTimeout(step, n > 0 ? 80 : 180);
689
893
  });
690
894
  };
691
895
  step();
@@ -703,11 +907,21 @@
703
907
  S.mountedIds.clear();
704
908
  S.lastShow.clear();
705
909
  S.wrapByKey.clear();
910
+ S.ezActiveIds.clear();
911
+ S.ezShownSinceDestroy.clear();
706
912
  S.inflight = 0;
707
913
  S.pending = [];
708
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();
709
919
  S.burstActive = false;
710
920
  S.runQueued = false;
921
+ S.sweepQueued = false;
922
+ S.scrollSpeed = 0;
923
+ S.lastScrollY = 0;
924
+ S.lastScrollTs = 0;
711
925
  }
712
926
 
713
927
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -718,8 +932,17 @@
718
932
  S.domObs = new MutationObserver(muts => {
719
933
  if (S.mutGuard > 0 || isBlocked()) return;
720
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();
721
943
  for (const n of m.addedNodes) {
722
944
  if (n.nodeType !== 1) continue;
945
+ try { healFalseEmpty(n); } catch (_) {}
723
946
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
724
947
  if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
725
948
  allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
@@ -805,7 +1028,7 @@
805
1028
  S.pageKey = pageKey();
806
1029
  blockedUntil = 0;
807
1030
  muteConsole(); ensureTcfLocator(); warmNetwork();
808
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
1031
+ patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
809
1032
  });
810
1033
 
811
1034
  const burstEvts = [
@@ -827,7 +1050,22 @@
827
1050
 
828
1051
  function bindScroll() {
829
1052
  let ticking = false;
1053
+ try {
1054
+ S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1055
+ S.lastScrollTs = ts();
1056
+ } catch (_) {}
830
1057
  window.addEventListener('scroll', () => {
1058
+ try {
1059
+ const y = window.scrollY || window.pageYOffset || 0;
1060
+ const t = ts();
1061
+ const dy = y - (S.lastScrollY || 0);
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;
1066
+ S.lastScrollY = y;
1067
+ S.lastScrollTs = t;
1068
+ } catch (_) {}
831
1069
  if (ticking) return;
832
1070
  ticking = true;
833
1071
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
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
+ }