nodebb-plugin-ezoic-infinite 1.4.95 → 1.4.97

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.4.95",
3
+ "version": "1.4.97",
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
@@ -7,8 +7,17 @@
7
7
  categoryItem: 'li[component="categories/category"]',
8
8
  }, WRAP_CLASS = 'ezoic-ad';
9
9
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-', MAX_INSERTS_PER_RUN = 3;
10
+ // Ultra-fast: preload ads before they enter viewport (especially above-the-fold)
11
+ const PRELOAD_ROOT_MARGIN = '1200px 0px';
12
+ const ABOVE_FOLD_MULT = 1.5; // render immediately if within 1.5× viewport height
13
+ const FAST_START_MS = 2500; // aggressive preload window after page load
14
+
15
+
16
+ // FLAG GLOBAL: Bloquer Ezoic pendant navigation
17
+ let EZOIC_BLOCKED = false;
18
+
19
+ // DEBUG: Vérifier que le plugin charge
10
20
 
11
- // Nécessaire pour savoir si on doit appeler destroyPlaceholders avant recyclage.
12
21
  const sessionDefinedIds = new Set();
13
22
 
14
23
  const insertingIds = new Set(), state = {
@@ -33,7 +42,43 @@
33
42
 
34
43
  obs: null,
35
44
  activeTimeouts: new Set(),
36
- lastScrollRun: 0, };
45
+ lastScrollRun: 0,
46
+ io: null,
47
+ fastStartUntil: 0,
48
+ };
49
+
50
+
51
+ // Track currently inserted ad wrappers for recycling
52
+ const liveArr = [];
53
+
54
+
55
+ // Network warm-up: reduce latency to Ezoic/CDN on first above-the-fold render
56
+ const _warmLinksDone = new Set();
57
+ function warmUpNetwork() {
58
+ try {
59
+ const head = document.head || document.getElementsByTagName('head')[0];
60
+ if (!head) return;
61
+ const links = [
62
+ ['preconnect', 'https://g.ezoic.net', true],
63
+ ['dns-prefetch', 'https://g.ezoic.net', false],
64
+ ['preconnect', 'https://go.ezoic.net', true],
65
+ ['dns-prefetch', 'https://go.ezoic.net', false],
66
+ ['preconnect', 'https://www.ezoic.net', true],
67
+ ['dns-prefetch', 'https://www.ezoic.net', false],
68
+ ];
69
+ for (const [rel, href, cors] of links) {
70
+ const key = rel + '|' + href;
71
+ if (_warmLinksDone.has(key)) continue;
72
+ _warmLinksDone.add(key);
73
+ const link = document.createElement('link');
74
+ link.rel = rel;
75
+ link.href = href;
76
+ if (cors) link.crossOrigin = 'anonymous';
77
+ head.appendChild(link);
78
+ }
79
+ } catch (e) {}
80
+ }
81
+
37
82
 
38
83
  function normalizeBool(v) {
39
84
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
@@ -106,9 +151,7 @@
106
151
 
107
152
  function destroyPlaceholderIds(ids) {
108
153
  if (!ids || !ids.length) return;
109
- // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
110
154
  const filtered = ids.filter((id) => {
111
- // Utiliser sessionDefinedIds (survit aux navigations) plutôt que state.definedIds
112
155
  try { return sessionDefinedIds.has(id); } catch (e) { return true; }
113
156
  });
114
157
  if (!filtered.length) return;
@@ -119,6 +162,11 @@
119
162
  window.ezstandalone.destroyPlaceholders(filtered);
120
163
  }
121
164
  } catch (e) {}
165
+
166
+ // Recyclage: libérer IDs après 100ms
167
+ setTimeout(() => {
168
+ filtered.forEach(id => sessionDefinedIds.delete(id));
169
+ }, 100);
122
170
  };
123
171
  try {
124
172
  window.ezstandalone = window.ezstandalone || {};
@@ -126,10 +174,63 @@
126
174
  if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
127
175
  else window.ezstandalone.cmd.push(call);
128
176
  } catch (e) {}
177
+
178
+ // Recyclage: libérer IDs après 100ms
179
+ setTimeout(() => {
180
+ filtered.forEach(id => sessionDefinedIds.delete(id));
181
+ }, 100);
129
182
  }
130
183
 
184
+ // Nettoyer éléments Ezoic invisibles qui créent espace vertical
185
+ function cleanupInvisibleEzoicElements() {
186
+ try {
187
+ document.querySelectorAll('.ezoic-ad').forEach(wrapper => {
188
+ const ph = wrapper.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
189
+ if (!ph) return;
131
190
 
132
- // Nettoyer les wrappers vides (sans pub) pour éviter espaces verticaux
191
+ // ULTRA-AGRESSIF: Supprimer TOUT sauf placeholder
192
+ Array.from(wrapper.children).forEach(child => {
193
+ if (child === ph || child.contains(ph)) return;
194
+ if (child.id && child.id.startsWith('ezoic-pub-ad-placeholder-')) return;
195
+ child.remove();
196
+ });
197
+
198
+ // Forcer wrapper collé
199
+ wrapper.style.height = 'auto';
200
+ wrapper.style.overflow = 'hidden';
201
+ wrapper.style.lineHeight = '0';
202
+ wrapper.style.fontSize = '0';
203
+ wrapper.style.margin = '0';
204
+ wrapper.style.padding = '0';
205
+
206
+ // CRITIQUE: Forcer suppression margin sur TOUS les .ezoic-ad enfants
207
+ // Pour overrider les margin-top/bottom:15px !important d'Ezoic
208
+ wrapper.querySelectorAll('.ezoic-ad, span.ezoic-ad').forEach(ezSpan => {
209
+ ezSpan.style.setProperty('margin-top', '0', 'important');
210
+ ezSpan.style.setProperty('margin-bottom', '0', 'important');
211
+ ezSpan.style.setProperty('margin-left', '0', 'important');
212
+ ezSpan.style.setProperty('margin-right', '0', 'important');
213
+ });
214
+
215
+ // Nettoyer aussi DANS le placeholder
216
+ if (ph) {
217
+ Array.from(ph.children).forEach(child => {
218
+ // Garder seulement iframe, ins, img
219
+ const tag = child.tagName;
220
+ if (tag !== 'IFRAME' && tag !== 'INS' && tag !== 'IMG' && tag !== 'DIV') {
221
+ child.remove();
222
+ }
223
+ });
224
+ }
225
+ });
226
+ } catch (e) {}
227
+ }
228
+
229
+ // Lancer cleanup périodique toutes les 2s
230
+ setInterval(() => {
231
+ try { cleanupInvisibleEzoicElements(); } catch (e) {}
232
+ }, 2000);
233
+
133
234
  function cleanupEmptyWrappers() {
134
235
  try {
135
236
  document.querySelectorAll('.ezoic-ad').forEach(wrapper => {
@@ -137,11 +238,17 @@
137
238
  if (ph && ph.children.length === 0) {
138
239
  // Placeholder vide après 3s = pub non chargée
139
240
  setTimeout(() => {
140
- if (ph.children.length === 0) {
141
- wrapper.remove();
142
- }
143
- }, 3000);
144
- }
241
+ try {
242
+ // Give Ezoic more time on slower connections, and don't remove if we recently requested an ad.
243
+ const phId = ph && ph.id;
244
+ const id = phId ? parseInt(phId.replace(PLACEHOLDER_PREFIX, ''), 10) : 0;
245
+ const last = (id && state.lastShowById && state.lastShowById.get(id)) || 0;
246
+ if (ph.children.length === 0 && (!last || Date.now() - last > 10000)) {
247
+ wrapper.remove();
248
+ }
249
+ } catch (e) {}
250
+ }, 8000);
251
+ }
145
252
  });
146
253
  } catch (e) {}
147
254
  }
@@ -217,10 +324,8 @@
217
324
  if (findWrap(kindClass, afterPos)) return null;
218
325
 
219
326
  // CRITICAL: Double-lock pour éviter race conditions sur les doublons
220
- // 1. Vérifier qu'aucun autre thread n'est en train d'insérer cet ID
221
327
  if (insertingIds.has(id)) return null;
222
328
 
223
- // 2. Vérifier qu'aucun placeholder avec cet ID n'existe déjà dans le DOM
224
329
  const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
225
330
  if (existingPh && existingPh.isConnected) return null;
226
331
 
@@ -233,7 +338,6 @@
233
338
  attachFillObserver(wrap, id);
234
339
  return wrap;
235
340
  } finally {
236
- // Libérer le lock après 100ms (le temps que le DOM soit stable)
237
341
  setTimeout(() => insertingIds.delete(id), 50);
238
342
  }
239
343
  }
@@ -244,48 +348,57 @@
244
348
  }
245
349
 
246
350
  function patchShowAds() {
247
- // Minimal safety net: batch showAds can be triggered by other scripts; split into individual calls.
248
- // Also ensures the patch is applied even if Ezoic loads after our script.
249
351
  const applyPatch = () => {
250
- try {
251
- window.ezstandalone = window.ezstandalone || {}, ez = window.ezstandalone;
252
- if (window.__nodebbEzoicPatched) return;
253
- if (typeof ez.showAds !== 'function') return;
254
-
255
- window.__nodebbEzoicPatched = true;
256
- const orig = ez.showAds;
257
-
258
- ez.showAds = function (arg) {
259
- if (Array.isArray(arg)) {
260
- const seen = new Set();
261
- for (const v of arg) {
262
- const id = parseInt(v, 10);
263
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
264
- seen.add(id);
265
- try { orig.call(ez, id); } catch (e) {}
266
- }
267
- return;
268
- }
269
- return orig.apply(ez, arguments);
270
- };
271
- } catch (e) {}
352
+ try {
353
+ window.ezstandalone = window.ezstandalone || {};
354
+ const ez = window.ezstandalone;
355
+ if (window.__nodebbEzoicPatched) return;
356
+ if (typeof ez.showAds !== 'function') return;
357
+
358
+ window.__nodebbEzoicPatched = true;
359
+ const orig = ez.showAds;
360
+
361
+ // Ezoic's ez-standalone.js logs warnings when asked to render an ID
362
+ // that isn't present in the DOM anymore. In an infinite-scroll context
363
+ // we must filter and call per-id.
364
+ ez.showAds = function (...args) {
365
+ if (EZOIC_BLOCKED) return;
366
+
367
+ let ids = [];
368
+ if (args.length === 1 && Array.isArray(args[0])) {
369
+ ids = args[0];
370
+ } else {
371
+ ids = args;
372
+ }
373
+
374
+ const seen = new Set();
375
+ for (const v of ids) {
376
+ const id = parseInt(v, 10);
377
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
378
+
379
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
380
+ if (!ph || !ph.isConnected) continue;
381
+
382
+ seen.add(id);
383
+ try { orig.call(ez, id); } catch (e) {}
384
+ }
385
+ };
386
+ } catch (e) {}
272
387
  };
273
388
 
274
389
  applyPatch();
275
- // Si Ezoic n'est pas encore chargé, appliquer le patch via sa cmd queue
276
390
  if (!window.__nodebbEzoicPatched) {
277
- try {
278
- window.ezstandalone = window.ezstandalone || {};
279
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
280
- window.ezstandalone.cmd.push(applyPatch);
281
- } catch (e) {}
282
- }
391
+ try {
392
+ window.ezstandalone = window.ezstandalone || {};
393
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
394
+ window.ezstandalone.cmd.push(applyPatch);
395
+ } catch (e) {}
283
396
  }
397
+ }
284
398
 
285
399
  function markFilled(wrap) {
286
400
  try {
287
401
  if (!wrap) return;
288
- // Disconnect the fill observer first (no need to remove+re-add the attribute)
289
402
  if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
290
403
  wrap.setAttribute('data-ezoic-filled', '1');
291
404
  } catch (e) {}
@@ -301,19 +414,18 @@
301
414
  if (!ph) return;
302
415
  // Already filled?
303
416
  if (ph.childNodes && ph.childNodes.length > 0) {
304
- markFilled(wrap);
305
- state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id);
417
+ markFilled(wrap); // Afficher wrapper
418
+ sessionDefinedIds.add(id);
306
419
  return;
307
420
  }
308
421
  const obs = new MutationObserver(() => {
309
422
  if (ph.childNodes && ph.childNodes.length > 0) {
310
- markFilled(wrap);
311
- try { state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id); } catch (e) {}
423
+ markFilled(wrap); // CRITIQUE: Afficher wrapper maintenant
424
+ try { sessionDefinedIds.add(id); } catch (e) {}
312
425
  try { obs.disconnect(); } catch (e) {}
313
426
  }
314
427
  });
315
428
  obs.observe(ph, { childList: true, subtree: true });
316
- // Keep a weak reference on the wrapper so we can disconnect on recycle/remove
317
429
  wrap.__ezoicFillObs = obs;
318
430
  } catch (e) {}
319
431
  }
@@ -333,15 +445,12 @@
333
445
  return filled;
334
446
  }
335
447
 
336
- // Appeler showAds() en batch selon recommandations Ezoic
337
- // Au lieu de showAds(id1), showAds(id2)... faire showAds(id1, id2, id3...)
338
448
  let batchShowAdsTimer = null;
339
449
  const pendingShowAdsIds = new Set();
340
450
 
341
451
  function scheduleShowAdsBatch(id) {
342
452
  if (!id) return;
343
453
 
344
- // CRITIQUE: Si cet ID a déjà été défini (sessionDefinedIds), le détruire d'abord
345
454
  if (sessionDefinedIds.has(id)) {
346
455
  try {
347
456
  destroyPlaceholderIds([id]);
@@ -356,84 +465,144 @@
356
465
  // Ajouter à la batch
357
466
  pendingShowAdsIds.add(id);
358
467
 
359
- // Debounce: attendre 100ms pour collecter tous les IDs
360
468
  clearTimeout(batchShowAdsTimer);
361
469
  batchShowAdsTimer = setTimeout(() => {
470
+ // CRITIQUE: Vérifier que nous sommes toujours sur la même page
471
+ const currentPageKey = getPageKey();
472
+ if (state.pageKey && currentPageKey !== state.pageKey) {
473
+ pendingShowAdsIds.clear();
474
+ return; // Page a changé, annuler
475
+ }
476
+
362
477
  if (pendingShowAdsIds.size === 0) return;
363
478
 
364
479
  const idsArray = Array.from(pendingShowAdsIds);
365
480
  pendingShowAdsIds.clear();
366
481
 
482
+ // CRITIQUE: Vérifier que placeholders existent encore
483
+ const validIds = idsArray.filter(id => {
484
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
485
+ return ph && ph.isConnected;
486
+ });
487
+
488
+ if (validIds.length === 0) return;
489
+
367
490
  // Appeler showAds avec TOUS les IDs en une fois
368
491
  try {
492
+ // CRITIQUE: Re-patcher AVANT chaque appel pour être sûr
493
+ patchShowAds();
494
+
369
495
  window.ezstandalone = window.ezstandalone || {};
370
496
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
371
497
  window.ezstandalone.cmd.push(function() {
372
498
  if (typeof window.ezstandalone.showAds === 'function') {
373
499
  // Appel batch: showAds(id1, id2, id3...)
374
- window.ezstandalone.showAds(...idsArray);
500
+ window.ezstandalone.showAds(validIds);
375
501
  // Tracker tous les IDs
376
- idsArray.forEach(id => {
502
+ validIds.forEach(id => {
377
503
  state.lastShowById.set(id, Date.now());
378
504
  sessionDefinedIds.add(id);
379
505
  });
380
506
  }
381
507
  });
382
508
  } catch (e) {}
509
+
510
+ // CRITIQUE: Nettoyer éléments invisibles APRÈS que pubs soient chargées
511
+ setTimeout(() => {
512
+ cleanupInvisibleEzoicElements();
513
+ }, 1500); // 1.5s pour laisser Ezoic charger
383
514
  }, 100);
384
515
  }
385
516
 
386
- function callShowAdsWhenReady(id) {
517
+
518
+ function callShowAdsWhenReady(id) {
387
519
  if (!id) return;
388
520
 
389
521
  const now = Date.now(), last = state.lastShowById.get(id) || 0;
390
- if (now - last < 3500) return;
391
-
392
- const phId = `${PLACEHOLDER_PREFIX}${id}`, doCall = () => {
393
- try {
394
- window.ezstandalone = window.ezstandalone || {};
395
- if (typeof window.ezstandalone.showAds === 'function') {
396
-
397
- state.lastShowById.set(id, Date.now());
398
- window.ezstandalone.showAds(id);
399
- sessionDefinedIds.add(id);
400
- return true;
401
- }
402
- } catch (e) {}
403
- return false;
522
+ if (now - last < 2500) return;
523
+
524
+ const phId = `${PLACEHOLDER_PREFIX}${id}`;
525
+
526
+ const tryShowOrQueue = () => {
527
+ if (EZOIC_BLOCKED) return false;
528
+ const el = document.getElementById(phId);
529
+ if (!el || !el.isConnected) return false;
530
+
531
+ try {
532
+ window.ezstandalone = window.ezstandalone || {};
533
+ const ez = window.ezstandalone;
534
+
535
+ if (typeof ez.showAds === 'function') {
536
+ state.lastShowById.set(id, Date.now());
537
+ ez.showAds(id);
538
+ sessionDefinedIds.add(id);
539
+ return true;
540
+ }
541
+
542
+ // Queue once to run as soon as Ezoic is ready
543
+ ez.cmd = ez.cmd || [];
544
+ if (!el.__ezoicQueued) {
545
+ el.__ezoicQueued = true;
546
+ ez.cmd.push(() => {
547
+ try {
548
+ if (EZOIC_BLOCKED) return;
549
+ const ph = document.getElementById(phId);
550
+ if (!ph || !ph.isConnected) return;
551
+ state.lastShowById.set(id, Date.now());
552
+ window.ezstandalone.showAds(id);
553
+ sessionDefinedIds.add(id);
554
+ } catch (e) {}
555
+ });
556
+ }
557
+ } catch (e) {}
558
+ return false;
404
559
  };
405
560
 
406
561
  const startPageKey = state.pageKey;
407
562
  let attempts = 0;
408
- (function waitForPh() {
409
- // Abort if the user navigated away since this showAds was scheduled
410
- if (state.pageKey !== startPageKey) return;
411
- // Abort if another concurrent call is already handling this id
412
- if (state.pendingById.has(id)) return;
413
-
414
- attempts += 1;
415
- const el = document.getElementById(phId);
416
- if (el && el.isConnected) {
417
- // CRITIQUE: Vérifier que le placeholder est VISIBLE
418
563
 
419
- // Si on arrive ici, soit visible, soit timeout
420
-
421
- // Si doCall() réussit, Ezoic est chargé et showAds a été appelé → sortir
422
- if (doCall()) {
423
- state.pendingById.delete(id);
424
- return;
425
- }
426
-
427
- }
428
-
429
- if (attempts < 100) {
430
- const timeoutId = setTimeout(waitForPh, 50);
431
- state.activeTimeouts.add(timeoutId);
432
- }
564
+ (function waitForPh() {
565
+ if (state.pageKey !== startPageKey) return;
566
+
567
+ const el = document.getElementById(phId);
568
+ if (el && el.isConnected) {
569
+ // tag id for IO callback
570
+ try { el.setAttribute('data-ezoic-id', String(id)); } catch (e) {}
571
+
572
+ // If above-the-fold (or during the fast-start window), fire immediately.
573
+ const inFastWindow = state.fastStartUntil && Date.now() < state.fastStartUntil;
574
+ if (isAboveFold(el) || (inFastWindow && (el.getBoundingClientRect().top < window.innerHeight * 3))) {
575
+ if (tryShowOrQueue()) {
576
+ state.pendingById.delete(id);
577
+ return;
578
+ }
579
+ }
580
+
581
+ // Otherwise, preload via IntersectionObserver
582
+ if (!state.pendingById.has(id)) {
583
+ state.pendingById.add(id);
584
+ const io = ensurePreloadObserver();
585
+ try { io && io.observe(el); } catch (e) {}
586
+
587
+ // Safety fallback: if it still hasn't rendered after 6s, force attempt.
588
+ const t = setTimeout(() => {
589
+ tryShowOrQueue();
590
+ state.pendingById.delete(id);
591
+ }, 6000);
592
+ state.activeTimeouts.add(t);
593
+ }
594
+ return;
595
+ }
596
+
597
+ attempts += 1;
598
+ if (attempts < 100) {
599
+ const timeoutId = setTimeout(waitForPh, 25);
600
+ state.activeTimeouts.add(timeoutId);
601
+ }
433
602
  })();
434
- }
603
+ }
435
604
 
436
- async function fetchConfig() {
605
+ async function fetchConfig() {
437
606
  if (state.cfg) return state.cfg;
438
607
  if (state.cfgPromise) return state.cfgPromise;
439
608
 
@@ -484,7 +653,6 @@
484
653
  const el = items[afterPos - 1];
485
654
  if (!el || !el.isConnected) continue;
486
655
 
487
- // Prevent adjacent ads (DOM-based, robust against virtualization)
488
656
  if (isAdjacentAd(el) || isPrevAd(el)) {
489
657
  continue;
490
658
  }
@@ -501,17 +669,14 @@
501
669
 
502
670
  let wrap = null;
503
671
  if (pick.recycled && pick.recycled.wrap) {
504
- // Only destroy if Ezoic has actually defined this placeholder before
505
672
  if (sessionDefinedIds.has(id)) {
506
673
  destroyPlaceholderIds([id]);
507
674
  }
508
- // Remove the old wrapper entirely, then create a fresh wrapper at the new position (same id)
509
675
  const oldWrap = pick.recycled.wrap;
510
676
  try { if (oldWrap && oldWrap.__ezoicFillObs) { oldWrap.__ezoicFillObs.disconnect(); } } catch (e) {}
511
677
  try { oldWrap && oldWrap.remove(); } catch (e) {}
512
678
  wrap = insertAfter(el, id, kindClass, afterPos);
513
679
  if (!wrap) continue;
514
- // Attendre que le wrapper soit dans le DOM puis appeler showAds
515
680
  setTimeout(() => {
516
681
  callShowAdsWhenReady(id);
517
682
  }, 50);
@@ -525,12 +690,10 @@
525
690
  }
526
691
 
527
692
  liveArr.push({ id, wrap });
528
- // If adjacency ended up happening (e.g. DOM shifts), rollback this placement.
529
693
  if (wrap && (
530
694
  (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))
531
695
  )) {
532
696
  try { wrap.remove(); } catch (e) {}
533
- // Put id back if it was newly consumed (not recycled)
534
697
  if (!(pick.recycled && pick.recycled.wrap)) {
535
698
  try { kindPool.unshift(id); } catch (e) {}
536
699
  usedSet.delete(id);
@@ -547,7 +710,6 @@
547
710
  for (let i = 0; i < ads.length; i++) {
548
711
  const ad = ads[i], prev = ad.previousElementSibling;
549
712
  if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
550
- // Supprimer le wrapper adjacent au lieu de le cacher
551
713
  try {
552
714
  const ph = ad.querySelector && ad.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
553
715
  if (ph) {
@@ -566,11 +728,45 @@
566
728
  }
567
729
 
568
730
  function cleanup() {
569
- destroyUsedPlaceholders();
731
+ // DEBUG: Vérifier que cleanup est appelé
732
+ // CRITIQUE: BLOQUER Ezoic immédiatement
733
+ EZOIC_BLOCKED = true;
734
+
735
+ // Détruire TOUS les placeholders Ezoic AVANT de supprimer DOM
736
+ const allWrappers = document.querySelectorAll('.ezoic-ad');
737
+ const allIds = [];
738
+ allWrappers.forEach(wrapper => {
739
+ const ph = wrapper.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
740
+ if (ph) {
741
+ const match = ph.id.match(/\d+/);
742
+ if (match) allIds.push(parseInt(match[0]));
743
+ }
744
+ });
745
+
746
+ // CRITIQUE: Vider COMPLÈTEMENT sessionDefinedIds
747
+ // Pour éviter que d'anciens IDs soient encore en mémoire
748
+
749
+ // CRITIQUE: Détruire AUSSI tous les IDs tracés dans state
750
+ // Pour annuler les anciens IDs qu'Ezoic a en mémoire
751
+ const trackedIds = [
752
+ ...Array.from(state.usedTopics),
753
+ ...Array.from(state.usedPosts),
754
+ ...Array.from(state.usedCategories)
755
+ ];
756
+
757
+ const allIdsToDestroy = [...new Set([...allIds, ...trackedIds, ...Array.from(sessionDefinedIds)])];
758
+ sessionDefinedIds.clear(); // ✅ VIDER TOUT
759
+
760
+ if (allIdsToDestroy.length > 0) {
761
+ destroyPlaceholderIds(allIdsToDestroy);
762
+ }
763
+
764
+ // Annuler batch showAds en attente
765
+ pendingShowAdsIds.clear();
766
+ clearTimeout(batchShowAdsTimer);
570
767
 
571
- // CRITIQUE: Supprimer TOUS les wrappers .ezoic-ad du DOM
572
- // Sinon ils restent et deviennent "unused" sur la nouvelle page
573
- document.querySelectorAll('.ezoic-ad').forEach(el => {
768
+ // Maintenant supprimer DOM
769
+ allWrappers.forEach(el => {
574
770
  try { el.remove(); } catch (e) {}
575
771
  });
576
772
 
@@ -585,26 +781,18 @@
585
781
  state.usedPosts.clear();
586
782
  state.usedCategories.clear();
587
783
  state.lastShowById.clear();
588
- // CRITIQUE: Vider pendingById pour annuler tous les showAds en cours
589
- // Sinon Ezoic essaie d'accéder aux placeholders pendant que NodeBB vide le DOM
590
784
  state.pendingById.clear();
591
785
  state.definedIds.clear();
592
786
 
593
- // NE PAS supprimer les wrappers Ezoic ici - ils seront supprimés naturellement
594
- // quand NodeBB vide le DOM lors de la navigation ajaxify
595
- // Les supprimer manuellement cause des problèmes avec l'état interne d'Ezoic
596
-
597
- // CRITIQUE: Annuler TOUS les timeouts en cours pour éviter que les anciens
598
- // showAds() continuent à s'exécuter après la navigation
599
787
  state.activeTimeouts.forEach(id => {
600
788
  try { clearTimeout(id); } catch (e) {}
601
789
  });
602
790
  state.activeTimeouts.clear();
603
791
 
604
- // Vider aussi pendingById pour annuler les showAds en attente
605
792
  state.pendingById.clear();
606
793
 
607
794
  if (state.obs) { state.obs.disconnect(); state.obs = null; }
795
+ if (state.io) { try { state.io.disconnect(); } catch (e) {} state.io = null; }
608
796
 
609
797
  state.scheduled = false;
610
798
  clearTimeout(state.timer);
@@ -617,8 +805,40 @@
617
805
  try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
618
806
  }
619
807
 
808
+ function ensurePreloadObserver() {
809
+ if (state.io) return state.io;
810
+ try {
811
+ state.io = new IntersectionObserver((entries) => {
812
+ for (const ent of entries) {
813
+ if (!ent.isIntersecting) continue;
814
+ const el = ent.target;
815
+ try { state.io && state.io.unobserve(el); } catch (e) {}
816
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
817
+ const id = parseInt(idAttr, 10);
818
+ if (Number.isFinite(id) && id > 0) {
819
+ // Try immediately; otherwise it will queue via ezstandalone.cmd
820
+ try { callShowAdsWhenReady(id); } catch (e) {}
821
+ }
822
+ }
823
+ }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
824
+ } catch (e) { state.io = null; }
825
+ return state.io;
826
+ }
827
+
828
+ function isAboveFold(el) {
829
+ try {
830
+ if (!el || !el.getBoundingClientRect) return false;
831
+ const r = el.getBoundingClientRect();
832
+ return r.top < (window.innerHeight * ABOVE_FOLD_MULT) && r.bottom > -200;
833
+ } catch (e) { return false; }
834
+ }
835
+
620
836
  async function runCore() {
621
- // Attendre que canInsert soit true (protection race condition navigation)
837
+ // CRITIQUE: Ne rien insérer si navigation en cours
838
+ if (EZOIC_BLOCKED) {
839
+ return;
840
+ }
841
+
622
842
  if (!state.canShowAds) {
623
843
  return;
624
844
  }
@@ -661,7 +881,6 @@
661
881
 
662
882
  enforceNoAdjacentAds();
663
883
 
664
- // If nothing inserted and list isn't in DOM yet (first click), retry a bit
665
884
  let count = 0;
666
885
  if (kind === 'topic') count = getPostContainers().length;
667
886
  else if (kind === 'categoryTopics') count = getTopicItems().length;
@@ -673,12 +892,9 @@
673
892
  }
674
893
 
675
894
  if (inserted >= MAX_INSERTS_PER_RUN) {
676
- // Plus d'insertions possibles ce cycle, continuer immédiatement
677
895
  setTimeout(arguments[0], 50);
678
896
  } else if (inserted === 0 && count > 0) {
679
897
  // Pool épuisé ou recyclage pas encore disponible.
680
- // Réessayer jusqu'à 8 fois (toutes les 400ms) pour laisser aux anciens wrappers
681
- // le temps de défiler hors écran et devenir recyclables.
682
898
  if (state.poolWaitAttempts < 8) {
683
899
  state.poolWaitAttempts += 1;
684
900
  setTimeout(arguments[0], 50);
@@ -708,19 +924,33 @@
708
924
 
709
925
  $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
710
926
 
711
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
927
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
712
928
  state.pageKey = getPageKey();
929
+
930
+ // Débloquer Ezoic IMMÉDIATEMENT pour la nouvelle page
931
+ EZOIC_BLOCKED = false;
932
+
933
+ // Warm-up réseau + runtime Ezoic dès que possible
934
+ warmUpNetwork();
935
+ patchShowAds();
936
+
937
+ // Ultra-fast preload window
938
+ state.fastStartUntil = Date.now() + FAST_START_MS;
939
+
713
940
  ensureObserver();
941
+ ensurePreloadObserver();
714
942
 
715
- // CRITIQUE: Attendre 300ms avant de permettre l'insertion de nouveaux placeholders
716
- // pour laisser les anciens showAds() (en cours) se terminer ou échouer proprement
717
- // Sinon race condition: NodeBB vide le DOM pendant que Ezoic essaie d'accéder aux placeholders
718
943
  state.canShowAds = true;
944
+
945
+ // HERO slot ASAP (above-the-fold)
946
+ insertHeroAdEarly();
947
+
948
+ // Relancer l'insertion normale
949
+ scheduleRun();
719
950
  });
720
951
 
721
952
  $(window).on('action:category.loaded.ezoicInfinite', () => {
722
953
  ensureObserver();
723
- // category.loaded = infinite scroll, Ezoic déjà chargé normalement
724
954
  waitForContentThenRun();
725
955
  });
726
956
  $(window).on('action:topics.loaded.ezoicInfinite', () => {
@@ -750,7 +980,6 @@
750
980
  window.requestAnimationFrame(() => {
751
981
  ticking = false;
752
982
  enforceNoAdjacentAds();
753
- // Debounce scheduleRun - une fois toutes les 2 secondes max au scroll
754
983
  const now = Date.now();
755
984
  if (!state.lastScrollRun || now - state.lastScrollRun > 2000) {
756
985
  state.lastScrollRun = now;
@@ -760,7 +989,6 @@
760
989
  }, { passive: true });
761
990
  }
762
991
 
763
- // Fonction qui attend que la page ait assez de contenu avant d'insérer les pubs
764
992
  function waitForContentThenRun() {
765
993
  const MIN_WORDS = 250;
766
994
  let attempts = 0;
@@ -791,7 +1019,6 @@
791
1019
  })();
792
1020
  }
793
1021
 
794
- // Fonction qui attend que Ezoic soit vraiment chargé
795
1022
  function waitForEzoicThenRun() {
796
1023
  let attempts = 0;
797
1024
  const maxAttempts = 50; // 50 × 200ms = 10s max
@@ -820,8 +1047,16 @@
820
1047
  bind();
821
1048
  bindScroll();
822
1049
  ensureObserver();
1050
+ ensurePreloadObserver();
823
1051
  state.pageKey = getPageKey();
824
1052
 
1053
+ // Warm-up réseau dès le boot
1054
+ warmUpNetwork();
1055
+ patchShowAds();
1056
+ state.fastStartUntil = Date.now() + FAST_START_MS;
1057
+ // HERO slot ASAP au premier chargement
1058
+ insertHeroAdEarly();
1059
+
825
1060
  // Attendre que Ezoic soit chargé avant d'insérer
826
1061
  waitForEzoicThenRun();
827
1062
  })();
package/public/style.css CHANGED
@@ -1,3 +1,19 @@
1
- .ezoic-ad{height:auto !important; padding:0 !important; margin: 0.25rem 0;}
2
- .ezoic-ad .ezoic-ad-inner{padding:0;margin:0;}
3
- .ezoic-ad .ezoic-ad-inner > div{padding:0;margin:0;}
1
+ .ezoic-ad,
2
+ .ezoic-ad *,
3
+ span.ezoic-ad,
4
+ span[class*="ezoic"] {
5
+ min-height: 0 !important;
6
+ min-width: 0 !important;
7
+ }
8
+ /* Reduce layout shifts and kill extra spacing around Ezoic wrappers */
9
+ .ezoic-ad {
10
+ display: block;
11
+ width: 100%;
12
+ margin: 0 !important;
13
+ padding: 0 !important;
14
+ overflow: hidden;
15
+ }
16
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
17
+ margin: 0 !important;
18
+ padding: 0 !important;
19
+ }