nodebb-plugin-ezoic-infinite 1.7.50 → 1.7.52

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +98 -187
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.50",
3
+ "version": "1.7.52",
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
@@ -1,49 +1,45 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v51
2
+ * NodeBB Ezoic Infinite Ads — client.js v58
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
7
- * v19 Intervalle global basé sur l'ordinal absolu (data-index).
8
- * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
9
- * IO fixe (une instance, jamais recréée). Fix TCF locator.
4
+ * Historique
5
+ * ──────────
6
+ * v18 Ancrage stable par data-pid / data-index.
7
+ * v20 Table KIND. IO fixe. Fix TCF locator.
10
8
  * v25 Fix scroll-up / virtualisation NodeBB.
11
- * v26 Suppression recyclage d'id (causait réinjection en haut).
12
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation posts).
13
- * v28 decluster supprimé. Wraps persistants pendant la session.
14
- * v32 pruneOrphansBetween réactivé uniquement pour topics de catégorie.
15
- * NodeBB NE virtualise PAS les topics → prune safe.
16
- * Toujours désactivé pour posts (virtualisation → faux-orphelins).
17
- * v35 initPools protégé (S.poolsReady). muteConsole élargi.
18
- * v36 S.wrapByKey Map O(1). wrapIsLive allégé. MutationObserver optimisé.
19
- * v38 ez.refresh() interdit → supprimé. Pool épuisé → break propre.
20
- * v40 Recyclage via destroyPlaceholders+define+displayMore (délais 300ms).
9
+ * v28 Wraps persistants pendant la session.
10
+ * v35 S.poolsReady. muteConsole élargi.
11
+ * v36 S.wrapByKey Map O(1). MutationObserver optimisé.
12
+ * v38 ez.refresh() supprimé. Pool épuisé break propre.
13
+ * v40 Recyclage destroyPlaceholders+define+displayMore (délais 300ms).
21
14
  * v43 Seuil recyclage -1vh + unobserve avant déplacement.
22
- * v49 Fix normalizeExcludedGroups : JSON.parse du tableau NodeBB.
23
- * v50 bindLoginCheck() supprimé (NodeBB recharge après login).
24
- * v51 #2 fetchConfig() : backoff 10s sur échec réseau (évite spam API).
25
- * #4 recycleAndMove : re-observe le placeholder après displayMore.
26
- * #5 ensureTcfLocator : MutationObserver global disconnecté au cleanup.
27
- * #6 getIO() recrée l'observer si le type d'écran change (resize).
15
+ * v49 Fix normalizeExcludedGroups (JSON.parse tableau NodeBB).
16
+ * v51 fetchConfig backoff 10s. IO recrée au resize. tcfObs dans S.
17
+ * v52 pruneOrphansBetween supprimé (NodeBB virtualise aussi les topics).
18
+ * v53 S.recycling garde double-recyclage. pickId early-exit. cleanup complet.
19
+ * v54 ensureTcfLocator rappelé à chaque ajaxify.end.
20
+ * v56 scheduleEmptyCheck / is-empty supprimés (collapse prématuré).
21
+ * v58 tcfObs survit aux navigations : ne plus déconnecter dans cleanup().
22
+ * L'iframe __tcfapiLocator doit exister en permanence pour le CMP —
23
+ * la fenêtre entre cleanup() et ajaxify.end causait des erreurs
24
+ * "Cannot read properties of null (postMessage)" et disparition des pubs.
25
+ * v57 Nettoyage prod : A_SHOWN/A_CREATED supprimés, normBool Set O(1),
26
+ * muteConsole étend aux erreurs CMP getTCData, code nettoyé.
28
27
  */
29
28
  (function nbbEzoicInfinite() {
30
29
  'use strict';
31
30
 
32
31
  // ── Constantes ─────────────────────────────────────────────────────────────
33
32
 
34
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
35
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
36
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
37
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
38
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
39
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
33
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
34
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
35
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
36
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
40
37
 
41
- const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
42
- const MAX_INFLIGHT = 4; // max showAds() simultanés
43
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
44
- const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
38
+ const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
39
+ const MAX_INFLIGHT = 4; // showAds() simultanés max
40
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
41
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
45
42
 
46
- // Marges IO larges et fixes — observer créé une seule fois au boot
47
43
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
48
44
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
49
45
 
@@ -55,13 +51,10 @@
55
51
 
56
52
  /**
57
53
  * Table KIND — source de vérité par kindClass.
58
- *
59
- * sel sélecteur CSS complet des éléments cibles
54
+ * sel sélecteur CSS des éléments cibles
60
55
  * baseTag préfixe tag pour querySelector d'ancre
61
- * (vide pour posts : le sélecteur commence par '[')
62
56
  * anchorAttr attribut DOM stable → clé unique du wrap
63
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle
64
- * null → fallback positionnel (catégories)
57
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle (null = fallback positionnel)
65
58
  */
66
59
  const KIND = {
67
60
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -72,36 +65,39 @@
72
65
  // ── État global ────────────────────────────────────────────────────────────
73
66
 
74
67
  const S = {
75
- pageKey: null,
76
- cfg: null,
77
- poolsReady: false,
78
- pools: { topics: [], posts: [], categories: [] },
79
- cursors: { topics: 0, posts: 0, categories: 0 },
80
- mountedIds: new Set(),
81
- lastShow: new Map(),
82
- io: null,
83
- domObs: null,
84
- mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
85
- inflight: 0, // showAds() en cours
86
- pending: [], // ids en attente de slot inflight
87
- pendingSet: new Set(),
88
- wrapByKey: new Map(), // anchorKey → wrap DOM node
89
- recycling: new Set(), // ids en cours de séquence destroy→define→displayMore
90
- tcfObs: null, // MutationObserver TCF locator
91
- runQueued: false,
92
- burstActive: false,
68
+ pageKey: null,
69
+ cfg: null,
70
+ poolsReady: false,
71
+ pools: { topics: [], posts: [], categories: [] },
72
+ cursors: { topics: 0, posts: 0, categories: 0 },
73
+ mountedIds: new Set(),
74
+ lastShow: new Map(),
75
+ io: null,
76
+ domObs: null,
77
+ tcfObs: null,
78
+ mutGuard: 0,
79
+ inflight: 0,
80
+ pending: [],
81
+ pendingSet: new Set(),
82
+ wrapByKey: new Map(), // anchorKey wrap DOM node
83
+ recycling: new Set(), // ids en cours de séquence destroy→define→displayMore
84
+ runQueued: false,
85
+ burstActive: false,
93
86
  burstDeadline: 0,
94
- burstCount: 0,
95
- lastBurstTs: 0,
87
+ burstCount: 0,
88
+ lastBurstTs: 0,
96
89
  };
97
90
 
98
- let blockedUntil = 0;
91
+ let blockedUntil = 0;
92
+ let _cfgErrorUntil = 0;
93
+ let _ioMobile = null;
99
94
 
100
- const ts = () => Date.now();
101
- const isBlocked = () => ts() < blockedUntil;
102
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
103
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
104
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
95
+ const ts = () => Date.now();
96
+ const isBlocked = () => ts() < blockedUntil;
97
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
98
+ const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
99
+ const normBool = v => _BOOL_TRUE.has(v);
100
+ const isFilled = n => !!(n?.querySelector('iframe, ins, img, video, [data-google-container-id]'));
105
101
 
106
102
  function mutate(fn) {
107
103
  S.mutGuard++;
@@ -110,9 +106,6 @@
110
106
 
111
107
  // ── Config ─────────────────────────────────────────────────────────────────
112
108
 
113
- // Fix #2 : backoff 10s sur échec pour éviter de spammer l'API
114
- // Fix #3 : _cfgErrorUntil au scope IIFE pour être réinitialisé dans cleanup()
115
- let _cfgErrorUntil = 0;
116
109
  async function fetchConfig() {
117
110
  if (S.cfg) return S.cfg;
118
111
  if (Date.now() < _cfgErrorUntil) return null;
@@ -180,53 +173,37 @@
180
173
 
181
174
  // ── Wraps — détection ──────────────────────────────────────────────────────
182
175
 
183
- /**
184
- * Vérifie qu'un wrap a encore son ancre dans le DOM.
185
- * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
186
- */
187
176
  function wrapIsLive(wrap) {
188
177
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
189
178
  const key = wrap.getAttribute(A_ANCHOR);
190
179
  if (!key) return false;
191
- // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
192
- // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
193
180
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
194
- // Fallback : registre pas encore à jour ou wrap non enregistré.
195
181
  const colonIdx = key.indexOf(':');
196
182
  const klass = key.slice(0, colonIdx);
197
183
  const anchorId = key.slice(colonIdx + 1);
198
184
  const cfg = KIND[klass];
199
185
  if (!cfg) return false;
200
- // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
201
- // de querySelector global — on cherche parmi les voisins immédiats.
202
186
  const parent = wrap.parentElement;
203
187
  if (parent) {
204
188
  for (const sib of parent.children) {
205
189
  if (sib === wrap) continue;
206
190
  try {
207
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
191
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
208
192
  return sib.isConnected;
209
- }
210
193
  } catch (_) {}
211
194
  }
212
195
  }
213
- // Dernier recours : querySelector global
214
196
  try {
215
197
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
216
198
  return !!(found?.isConnected);
217
199
  } catch (_) { return false; }
218
200
  }
219
201
 
220
- function adjacentWrap(el) {
221
- return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
222
- }
202
+ const adjacentWrap = el =>
203
+ wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
223
204
 
224
205
  // ── Ancres stables ─────────────────────────────────────────────────────────
225
206
 
226
- /**
227
- * Retourne la valeur de l'attribut stable pour cet élément,
228
- * ou un fallback positionnel si l'attribut est absent.
229
- */
230
207
  function stableId(klass, el) {
231
208
  const attr = KIND[klass]?.anchorAttr;
232
209
  if (attr) {
@@ -245,21 +222,15 @@
245
222
 
246
223
  function findWrap(key) {
247
224
  const w = S.wrapByKey.get(key);
248
- return (w?.isConnected) ? w : null;
225
+ return w?.isConnected ? w : null;
249
226
  }
250
227
 
251
228
  // ── Pool ───────────────────────────────────────────────────────────────────
252
229
 
253
- /**
254
- * Retourne le prochain id disponible dans le pool (round-robin),
255
- * ou null si tous les ids sont montés.
256
- */
257
230
  function pickId(poolKey) {
258
231
  const pool = S.pools[poolKey];
259
232
  if (!pool.length) return null;
260
- // Fix #2 : early-exit si tous les ids sont déjà montés — évite O(n) inutile
261
- if (S.mountedIds.size >= pool.length &&
262
- pool.every(id => S.mountedIds.has(id))) return null;
233
+ if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
263
234
  for (let t = 0; t < pool.length; t++) {
264
235
  const i = S.cursors[poolKey] % pool.length;
265
236
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -271,9 +242,7 @@
271
242
 
272
243
  /**
273
244
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
274
- * Séquence avec délais (destroyPlaceholders est asynchrone) :
275
- * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
276
- * displayMore = API Ezoic prévue pour l'infinite scroll.
245
+ * Séquence : destroy([id]) → 300ms → define([id]) 300ms → displayMore([id])
277
246
  * Priorité : wraps vides d'abord, remplis si nécessaire.
278
247
  */
279
248
  function recycleAndMove(klass, targetEl, newKey) {
@@ -282,10 +251,7 @@
282
251
  typeof ez?.define !== 'function' ||
283
252
  typeof ez?.displayMore !== 'function') return null;
284
253
 
285
- const vh = window.innerHeight || 800;
286
- // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
287
- // après pour neutraliser l'IO — plus de showAds parasite possible.
288
- const threshold = -vh;
254
+ const threshold = -(window.innerHeight || 800);
289
255
  let bestEmpty = null, bestEmptyBottom = Infinity;
290
256
  let bestFilled = null, bestFilledBottom = Infinity;
291
257
 
@@ -305,19 +271,13 @@
305
271
  if (!best) return null;
306
272
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
307
273
  if (!Number.isFinite(id)) return null;
308
- // Fix #1 : éviter de recycler un slot dont la séquence Ezoic est en cours
309
274
  if (S.recycling.has(id)) return null;
310
275
  S.recycling.add(id);
311
276
 
312
277
  const oldKey = best.getAttribute(A_ANCHOR);
313
- // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
314
- // parasite si le nœud était encore dans la zone IO_MARGIN.
315
278
  try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
316
279
  mutate(() => {
317
- best.setAttribute(A_ANCHOR, newKey);
318
- best.setAttribute(A_CREATED, String(ts()));
319
- best.setAttribute(A_SHOWN, '0');
320
- best.classList.remove('is-empty');
280
+ best.setAttribute(A_ANCHOR, newKey);
321
281
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
322
282
  if (ph) ph.innerHTML = '';
323
283
  targetEl.insertAdjacentElement('afterend', best);
@@ -325,16 +285,14 @@
325
285
  if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
326
286
  S.wrapByKey.set(newKey, best);
327
287
 
328
- // Délais requis : destroyPlaceholders est asynchrone en interne
329
288
  const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
330
289
  const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
331
290
  const doDisplay = () => {
332
291
  try { ez.displayMore([id]); } catch (_) {}
333
- S.recycling.delete(id); // Fix #1 : séquence terminée
292
+ S.recycling.delete(id);
334
293
  observePh(id);
335
- try { best.setAttribute(A_SHOWN, String(ts())); } catch (_) {}
336
294
  };
337
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
295
+ try { typeof ez.cmd?.push === 'function' ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
338
296
 
339
297
  return { id, wrap: best };
340
298
  }
@@ -344,10 +302,8 @@
344
302
  function makeWrap(id, klass, key) {
345
303
  const w = document.createElement('div');
346
304
  w.className = `${WRAP_CLASS} ${klass}`;
347
- w.setAttribute(A_ANCHOR, key);
348
- w.setAttribute(A_WRAPID, String(id));
349
- w.setAttribute(A_CREATED, String(ts()));
350
- w.setAttribute(A_SHOWN, '0');
305
+ w.setAttribute(A_ANCHOR, key);
306
+ w.setAttribute(A_WRAPID, String(id));
351
307
  w.style.cssText = 'width:100%;display:block;';
352
308
  const ph = document.createElement('div');
353
309
  ph.id = `${PH_PREFIX}${id}`;
@@ -380,13 +336,8 @@
380
336
  } catch (_) {}
381
337
  }
382
338
 
383
-
384
339
  // ── Injection ──────────────────────────────────────────────────────────────
385
340
 
386
- /**
387
- * Ordinal 0-based pour le calcul de l'intervalle d'injection.
388
- * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
389
- */
390
341
  function ordinal(klass, el) {
391
342
  const attr = KIND[klass]?.ordinalAttr;
392
343
  if (attr) {
@@ -405,18 +356,14 @@
405
356
  function injectBetween(klass, items, interval, showFirst, poolKey) {
406
357
  if (!items.length) return 0;
407
358
  let inserted = 0;
408
-
409
359
  for (const el of items) {
410
360
  if (inserted >= MAX_INSERTS_RUN) break;
411
361
  if (!el?.isConnected) continue;
412
-
413
362
  const ord = ordinal(klass, el);
414
363
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
415
364
  if (adjacentWrap(el)) continue;
416
-
417
365
  const key = anchorKey(klass, el);
418
366
  if (findWrap(key)) continue;
419
-
420
367
  const id = pickId(poolKey);
421
368
  if (id) {
422
369
  const w = insertAfter(el, id, klass, key);
@@ -432,13 +379,9 @@
432
379
 
433
380
  // ── IntersectionObserver & Show ────────────────────────────────────────────
434
381
 
435
- // Fix #6 : recréer l'observer si le type d'écran change (rotation, resize).
436
- // isMobile() est évalué à chaque appel pour détecter un changement.
437
- let _ioMobile = null; // dernier état mobile/desktop pour lequel l'IO a été créé
438
382
  function getIO() {
439
383
  const mobile = isMobile();
440
384
  if (S.io && _ioMobile === mobile) return S.io;
441
- // Type d'écran changé ou première création : (re)créer l'observer
442
385
  if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
443
386
  _ioMobile = mobile;
444
387
  try {
@@ -489,19 +432,14 @@
489
432
  drainQueue();
490
433
  };
491
434
  const timer = setTimeout(release, 7000);
492
-
493
435
  requestAnimationFrame(() => {
494
436
  try {
495
437
  if (isBlocked()) { clearTimeout(timer); return release(); }
496
438
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
497
439
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
498
-
499
440
  const t = ts();
500
441
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
501
442
  S.lastShow.set(id, t);
502
-
503
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
504
-
505
443
  window.ezstandalone = window.ezstandalone || {};
506
444
  const ez = window.ezstandalone;
507
445
  const doShow = () => {
@@ -513,12 +451,10 @@
513
451
  });
514
452
  }
515
453
 
516
-
517
454
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
518
455
  //
519
- // Intercepte ez.showAds() pour :
520
- // ignorer les appels pendant blockedUntil
521
- // – filtrer les ids dont le placeholder n'est pas en DOM
456
+ // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
457
+ // et filtrer les ids dont le placeholder n'est pas connecté au DOM.
522
458
 
523
459
  function patchShowAds() {
524
460
  const apply = () => {
@@ -554,36 +490,19 @@
554
490
  async function runCore() {
555
491
  if (isBlocked()) return 0;
556
492
  patchShowAds();
557
-
558
493
  const cfg = await fetchConfig();
559
494
  if (!cfg || cfg.excluded) return 0;
560
495
  initPools(cfg);
561
-
562
496
  const kind = getKind();
563
497
  if (kind === 'other') return 0;
564
-
565
498
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
566
499
  if (!normBool(cfgEnable)) return 0;
567
500
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
568
501
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
569
502
  };
570
-
571
- if (kind === 'topic') return exec(
572
- 'ezoic-ad-message', getPosts,
573
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
574
- );
575
-
576
- if (kind === 'categoryTopics') {
577
- return exec(
578
- 'ezoic-ad-between', getTopics,
579
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
580
- );
581
- }
582
-
583
- return exec(
584
- 'ezoic-ad-categories', getCategories,
585
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
586
- );
503
+ if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
504
+ if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
505
+ return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
587
506
  }
588
507
 
589
508
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -607,11 +526,9 @@
607
526
  S.lastBurstTs = t;
608
527
  S.pageKey = pageKey();
609
528
  S.burstDeadline = t + 2000;
610
-
611
529
  if (S.burstActive) return;
612
530
  S.burstActive = true;
613
531
  S.burstCount = 0;
614
-
615
532
  const step = () => {
616
533
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
617
534
  S.burstActive = false; return;
@@ -630,25 +547,27 @@
630
547
  function cleanup() {
631
548
  blockedUntil = ts() + 1500;
632
549
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
633
- S.cfg = null;
634
- _cfgErrorUntil = 0; // Fix #3 : réinitialiser le backoff entre les pages
635
- S.poolsReady = false;
636
- S.pools = { topics: [], posts: [], categories: [] };
637
- S.cursors = { topics: 0, posts: 0, categories: 0 };
550
+ S.cfg = null;
551
+ _cfgErrorUntil = 0;
552
+ S.poolsReady = false;
553
+ S.pools = { topics: [], posts: [], categories: [] };
554
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
638
555
  S.mountedIds.clear();
639
556
  S.lastShow.clear();
640
557
  S.wrapByKey.clear();
641
- S.inflight = 0;
642
- S.pending = [];
643
- S.pendingSet.clear();
644
- S.burstActive = false;
645
- S.runQueued = false;
646
558
  S.recycling.clear();
559
+ S.inflight = 0;
560
+ S.pending = [];
561
+ S.pendingSet.clear();
562
+ S.burstActive = false;
563
+ S.runQueued = false;
647
564
  if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
648
- if (S.tcfObs) { S.tcfObs.disconnect(); S.tcfObs = null; }
565
+ // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
566
+ // exister en permanence — la déconnecter pendant la navigation cause
567
+ // des erreurs CMP postMessage et la disparition des pubs.
649
568
  }
650
569
 
651
- // ── MutationObserver ───────────────────────────────────────────────────────
570
+ // ── MutationObserver DOM ───────────────────────────────────────────────────
652
571
 
653
572
  function ensureDomObserver() {
654
573
  if (S.domObs) return;
@@ -658,9 +577,8 @@
658
577
  for (const m of muts) {
659
578
  for (const n of m.addedNodes) {
660
579
  if (n.nodeType !== 1) continue;
661
- // matches() d'abord (O(1)), querySelector() seulement si nécessaire
662
- if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
663
- allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
580
+ if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
581
+ allSel.some(s => { try { return !!n.querySelector(s); } catch (_) { return false; } })) {
664
582
  requestBurst(); return;
665
583
  }
666
584
  }
@@ -680,6 +598,7 @@
680
598
  'cannot call refresh on the same page',
681
599
  'no placeholders are currently defined in Refresh',
682
600
  'Debugger iframe already exists',
601
+ '[CMP] Error in custom getTCData',
683
602
  `with id ${PH_PREFIX}`,
684
603
  ];
685
604
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -702,7 +621,6 @@
702
621
  (document.body || document.documentElement).appendChild(f);
703
622
  };
704
623
  inject();
705
- // Fix #5 : ref stockée dans S pour pouvoir déconnecter au cleanup
706
624
  if (!S.tcfObs) {
707
625
  S.tcfObs = new MutationObserver(inject);
708
626
  S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
@@ -737,25 +655,19 @@
737
655
  function bindNodeBB() {
738
656
  const $ = window.jQuery;
739
657
  if (!$) return;
740
-
741
658
  $(window).off('.nbbEzoic');
742
659
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
743
660
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
744
661
  S.pageKey = pageKey();
745
662
  blockedUntil = 0;
746
- // muteConsole et warmNetwork sont idempotents — appelés au boot seulement.
747
- // ensureTcfLocator doit être rappelé : ajaxify retire l'iframe __tcfapiLocator
748
- // du DOM à chaque navigation, le CMP lève une erreur postMessage sans elle.
749
663
  muteConsole(); ensureTcfLocator();
750
664
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
751
665
  });
752
-
753
666
  const burstEvts = [
754
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
755
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
667
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
668
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
756
669
  ].map(e => `${e}.nbbEzoic`).join(' ');
757
670
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
758
-
759
671
  try {
760
672
  require(['hooks'], hooks => {
761
673
  if (typeof hooks?.on !== 'function') return;
@@ -774,11 +686,10 @@
774
686
  ticking = true;
775
687
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
776
688
  }, { passive: true });
777
- // Fix #6 : détecter rotation/resize pour recréer l'IO avec la bonne marge
778
689
  let resizeTimer = 0;
779
690
  window.addEventListener('resize', () => {
780
691
  clearTimeout(resizeTimer);
781
- resizeTimer = setTimeout(() => { getIO(); }, 500);
692
+ resizeTimer = setTimeout(getIO, 500);
782
693
  }, { passive: true });
783
694
  }
784
695