nodebb-plugin-ezoic-infinite 1.7.11 → 1.7.12

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 +143 -188
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.11",
3
+ "version": "1.7.12",
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,57 +1,38 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v22
2
+ * NodeBB Ezoic Infinite Ads — client.js v23
3
3
  *
4
- * Correctifs critiques vs v19
5
- * ───────────────────────────
6
- * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
- * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
- * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
- * anchorEl toujours null suppression à chaque runCore() → disparition.
10
- * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
- * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
12
- *
13
- * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
- * Fix : on skip uniquement le wrap courant, pas toute la boucle.
15
- *
16
- * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
- * existants sur les items suivants. Fix : `continue` au lieu de `break`.
18
- *
19
- * [PERF] IntersectionObserver recréé à chaque scroll boost → très coûteux mobile.
20
- * Fix : marge large fixe par device, observer créé une seule fois.
21
- *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
24
- *
25
- * Nettoyage
26
- * ─────────
27
- * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
- * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
- * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
- * - Commentaires internes allégés (code auto-documenté)
4
+ * v20 Table KIND : anchorAttr par kindClass. Fix catégories (data-cid).
5
+ * IO fixe une seule instance. Burst cooldown 200ms.
6
+ * v22 Fix ordinal fallback posts (baseTag vide cassait :scope>). isFilled guard pruneOrphans.
7
+ * v23 Fix "pubs écrasées" : INJECT_GRACE_MS protège les wraps non encore fills.
8
+ * decluster : ne supprime jamais un wrap filled, grace basée sur A_CREATED aussi.
9
+ * KIND table : baseTag explicite (évite le split fragile).
10
+ * wrapByKey Map O(1) pour findWrap. poolsReady (initPools une fois/page).
11
+ * patchShowAds sorti du hot path runCore. wrapByKey sync sur dropWrap/cleanup.
31
12
  */
32
13
  (function () {
33
14
  'use strict';
34
15
 
35
16
  // ── Constantes ─────────────────────────────────────────────────────────────
36
17
 
37
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
- const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
-
44
- const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
- const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
- const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
18
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
19
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
20
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
21
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
22
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
23
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
24
+
25
+ const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
26
+ const INJECT_GRACE_MS = 30_000; // fenêtre post-injection : wrap non supprimable (fill async en cours)
27
+ const FILL_GRACE_MS = 25_000; // fenêtre post-showAds pour decluster
28
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
29
  const MAX_INSERTS_PER_RUN = 6;
48
30
  const MAX_INFLIGHT = 4;
49
31
  const SHOW_THROTTLE_MS = 900;
50
32
  const BURST_COOLDOWN_MS = 200;
51
33
 
52
- // Marges IO larges et fixes (pas de reconstruction d'observer)
53
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
34
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
35
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
36
 
56
37
  const SEL = {
57
38
  post: '[component="post"][data-pid]',
@@ -60,50 +41,53 @@
60
41
  };
61
42
 
62
43
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
44
+ * Table KIND source de vérité par kindClass.
64
45
  *
65
- * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
- * quelle que soit la page ou la virtualisation :
67
- * posts → data-pid (id du message, unique et permanent)
68
- * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
- * catégories data-cid (id de la catégorie, unique et permanent)
70
- * C'était le bug v19 : on cherchait data-index ici
46
+ * anchorAttr : attribut DOM stable clé unique du wrap
47
+ * data-pid posts (id message, immuable)
48
+ * data-index topics (index dans la liste)
49
+ * data-cid catégories (id catégorie, immuable)
50
+ * baseTag : préfixe tag pour le querySelector d'ancre dans pruneOrphans.
51
+ * Vide pour posts (sélecteur sans tag). Explicite pour éviter le split fragile.
71
52
  */
72
53
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
54
+ 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid', baseTag: '' },
55
+ 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index', baseTag: 'li' },
56
+ 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid', baseTag: 'li' },
76
57
  };
77
58
 
78
59
  // ── État ───────────────────────────────────────────────────────────────────
79
60
 
80
61
  const S = {
81
- pageKey: null,
82
- cfg: null,
83
-
84
- pools: { topics: [], posts: [], categories: [] },
85
- cursors: { topics: 0, posts: 0, categories: 0 },
86
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
- lastShow: new Map(), // id → timestamp dernier show
88
-
89
- io: null,
90
- domObs: null,
91
- mutGuard: 0, // compteur internalMutation
92
-
93
- inflight: 0,
94
- pending: [],
95
- pendingSet: new Set(),
96
-
97
- runQueued: false,
98
- burstActive: false,
62
+ pageKey: null,
63
+ cfg: null,
64
+ pools: { topics: [], posts: [], categories: [] },
65
+ cursors: { topics: 0, posts: 0, categories: 0 },
66
+ mountedIds: new Set(), // IDs Ezoic dans le DOM
67
+ lastShow: new Map(), // id timestamp dernier show
68
+ io: null,
69
+ domObs: null,
70
+ mutGuard: 0,
71
+ inflight: 0,
72
+ pending: [],
73
+ pendingSet: new Set(),
74
+ runQueued: false,
75
+ burstActive: false,
99
76
  burstDeadline: 0,
100
- burstCount: 0,
101
- lastBurstTs: 0,
77
+ burstCount: 0,
78
+ lastBurstTs: 0,
102
79
  };
103
80
 
104
- let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
81
+ // Map anchorKey → wrap Element — lookup O(1) au lieu de querySelector full-DOM
82
+ const wrapByKey = new Map();
83
+ let blockedUntil = 0;
84
+ let poolsReady = false; // initPools une seule fois par page
85
+
86
+ const ts = () => Date.now();
87
+ const isBlocked = () => ts() < blockedUntil;
88
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
89
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
90
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
91
 
108
92
  function mutate(fn) {
109
93
  S.mutGuard++;
@@ -121,12 +105,6 @@
121
105
  return S.cfg;
122
106
  }
123
107
 
124
- function initPools(cfg) {
125
- S.pools.topics = parseIds(cfg.placeholderIds);
126
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
127
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
128
- }
129
-
130
108
  function parseIds(raw) {
131
109
  const out = [], seen = new Set();
132
110
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +114,13 @@
136
114
  return out;
137
115
  }
138
116
 
139
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
140
-
141
- const isFilled = (n) =>
142
- !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
143
-
144
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
117
+ function initPools(cfg) {
118
+ if (poolsReady) return;
119
+ S.pools.topics = parseIds(cfg.placeholderIds);
120
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
121
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
122
+ poolsReady = true;
123
+ }
145
124
 
146
125
  // ── Page identity ──────────────────────────────────────────────────────────
147
126
 
@@ -165,13 +144,13 @@
165
144
  return 'other';
166
145
  }
167
146
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
147
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
148
 
170
149
  function getPosts() {
171
150
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
151
  if (!el.isConnected) return false;
173
152
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
153
+ const p = el.parentElement?.closest(SEL.post);
175
154
  if (p && p !== el) return false;
176
155
  return el.getAttribute('component') !== 'post/parent';
177
156
  });
@@ -187,38 +166,29 @@
187
166
  );
188
167
  }
189
168
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
169
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
170
 
192
- /**
193
- * Retourne l'identifiant stable de l'élément selon son kindClass.
194
- * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
195
- * Fallback positionnel si l'attribut est absent.
196
- */
197
- function stableId(kindClass, el) {
198
- const attr = KIND[kindClass]?.anchorAttr;
171
+ function stableId(klass, el) {
172
+ const attr = KIND[klass]?.anchorAttr;
199
173
  if (attr) {
200
174
  const v = el.getAttribute(attr);
201
175
  if (v !== null && v !== '') return v;
202
176
  }
203
- // Fallback : position dans le parent
204
- try {
205
- let i = 0;
206
- for (const s of el.parentElement?.children ?? []) {
207
- if (s === el) return `i${i}`;
208
- i++;
209
- }
210
- } catch (_) {}
177
+ let i = 0;
178
+ for (const s of el.parentElement?.children ?? []) {
179
+ if (s === el) return `i${i}`;
180
+ i++;
181
+ }
211
182
  return 'i0';
212
183
  }
213
184
 
214
185
  const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
186
 
216
- function findWrap(anchorKey) {
217
- try {
218
- return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
- );
221
- } catch (_) { return null; }
187
+ function findWrap(key) {
188
+ const w = wrapByKey.get(key);
189
+ if (w && w.isConnected) return w;
190
+ if (w) wrapByKey.delete(key); // nettoyage lazy si wrap retiré hors de notre contrôle
191
+ return null;
222
192
  }
223
193
 
224
194
  // ── Pool ───────────────────────────────────────────────────────────────────
@@ -226,7 +196,7 @@
226
196
  function pickId(poolKey) {
227
197
  const pool = S.pools[poolKey];
228
198
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
199
+ const i = S.cursors[poolKey] % pool.length;
230
200
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
201
  const id = pool[i];
232
202
  if (!S.mountedIds.has(id)) return id;
@@ -251,13 +221,14 @@
251
221
  }
252
222
 
253
223
  function insertAfter(el, id, klass, key) {
254
- if (!el?.insertAdjacentElement) return null;
255
- if (findWrap(key)) return null; // ancre déjà présente
256
- if (S.mountedIds.has(id)) return null; // id déjà monté
224
+ if (!el?.insertAdjacentElement) return null;
225
+ if (findWrap(key)) return null;
226
+ if (S.mountedIds.has(id)) return null;
257
227
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
228
  const w = makeWrap(id, klass, key);
259
229
  mutate(() => el.insertAdjacentElement('afterend', w));
260
230
  S.mountedIds.add(id);
231
+ wrapByKey.set(key, w);
261
232
  return w;
262
233
  }
263
234
 
@@ -265,9 +236,9 @@
265
236
  try {
266
237
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
238
  if (Number.isFinite(id)) S.mountedIds.delete(id);
268
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
- // "parameter 1 is not of type Element" sur le prochain observe).
239
+ const key = w.getAttribute(A_ANCHOR);
240
+ if (key) wrapByKey.delete(key);
241
+ // unobserve avant remove guard instanceof (unobserve(null) corrompt l'IO pubads)
271
242
  try {
272
243
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
244
  if (ph instanceof Element) S.io?.unobserve(ph);
@@ -279,62 +250,63 @@
279
250
  // ── Prune ──────────────────────────────────────────────────────────────────
280
251
 
281
252
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
253
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
283
254
  *
284
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
- * Exemples :
286
- * ezoic-ad-message → cherche [data-pid="123"]
287
- * ezoic-ad-between → cherche [data-index="5"]
288
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
289
- *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
255
+ * Protections :
256
+ * 1. Pas avant MIN_PRUNE_AGE_MS (DOM post-batch pas encore stabilisé)
257
+ * 2. Jamais si filled (pub affichée — SDK Ezoic a des callbacks async dessus)
258
+ * 3. Pas avant INJECT_GRACE_MS depuis création (fill Ezoic peut être très async)
291
259
  */
292
260
  function pruneOrphans(klass) {
293
261
  const meta = KIND[klass];
294
262
  if (!meta) return;
295
263
 
296
- // baseTag déduit du sélecteur : 'li' pour topics/catégories, '' pour posts.
297
- // Pour les posts (baseTag=''), on cherche juste [data-pid="X"] sans préfixe
298
- // c'est correct car data-pid est unique dans le DOM.
299
- const baseTag = meta.sel.split('[')[0];
300
-
301
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
302
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
303
- if (isFilled(w)) return; // jamais supprimer un wrap rempli
264
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
265
+ const age = ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10);
266
+ if (age < MIN_PRUNE_AGE_MS) continue;
267
+ if (isFilled(w)) continue;
268
+ if (age < INJECT_GRACE_MS) continue;
304
269
 
305
270
  const key = w.getAttribute(A_ANCHOR) ?? '';
306
271
  const sid = key.slice(klass.length + 1);
307
- if (!sid) { mutate(() => dropWrap(w)); return; }
272
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
308
273
 
309
- const anchorEl = document.querySelector(
310
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
311
- );
274
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
275
+ const anchorEl = document.querySelector(sel);
312
276
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
313
- });
277
+ }
314
278
  }
315
279
 
316
280
  // ── Decluster ──────────────────────────────────────────────────────────────
317
281
 
318
282
  /**
319
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
320
- * Priorité : filled > en grâce (fill en cours) > vide.
321
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
283
+ * Deux wraps adjacents → supprimer le moins prioritaire.
284
+ *
285
+ * Règles absolues :
286
+ * - Jamais supprimer un wrap filled
287
+ * - Jamais supprimer un wrap < INJECT_GRACE_MS (fill Ezoic potentiellement en cours)
288
+ * - Jamais supprimer un wrap < FILL_GRACE_MS depuis le dernier showAds
289
+ * - Si les deux wraps adjacents sont vides et hors grâce → supprimer le courant
322
290
  */
323
291
  function decluster(klass) {
324
292
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
325
- // Grace sur le wrap courant : on le saute entièrement
293
+ if (isFilled(w)) continue;
294
+ const wAge = ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10);
295
+ if (wAge < INJECT_GRACE_MS) continue;
326
296
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
327
297
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
328
298
 
329
299
  let prev = w.previousElementSibling, steps = 0;
330
300
  while (prev && steps++ < 3) {
331
301
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
332
-
302
+ if (isFilled(prev)) break;
303
+ const pAge = ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10);
304
+ if (pAge < INJECT_GRACE_MS) break;
333
305
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
334
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
306
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
335
307
 
336
- if (!isFilled(w)) mutate(() => dropWrap(w));
337
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
308
+ // Les deux vides et hors grâce → supprimer le courant
309
+ mutate(() => dropWrap(w));
338
310
  break;
339
311
  }
340
312
  }
@@ -344,14 +316,13 @@
344
316
 
345
317
  /**
346
318
  * Ordinal 0-based pour le calcul de l'intervalle.
347
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
348
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
319
+ * Posts/topics : data-index (NodeBB 4.x).
320
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
349
321
  */
350
322
  function ordinal(klass, el) {
351
323
  const di = el.getAttribute('data-index');
352
324
  if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
353
- // Fallback positionnel filtre par sélecteur complet pour éviter le bug
354
- // baseTag='' (posts) où `:scope > ` sans tag ne fonctionne pas.
325
+ // Fallback — s.matches(fullSel) pour ne pas compter les wraps intercalés
355
326
  const fullSel = KIND[klass]?.sel ?? '';
356
327
  let i = 0;
357
328
  for (const s of el.parentElement?.children ?? []) {
@@ -369,17 +340,16 @@
369
340
  if (inserted >= MAX_INSERTS_PER_RUN) break;
370
341
  if (!el?.isConnected) continue;
371
342
 
372
- const ord = ordinal(klass, el);
373
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
374
- if (!isTarget) continue;
343
+ const ord = ordinal(klass, el);
344
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
345
 
376
346
  if (adjacentWrap(el)) continue;
377
347
 
378
348
  const key = makeAnchorKey(klass, el);
379
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
349
+ if (findWrap(key)) continue;
380
350
 
381
351
  const id = pickId(poolKey);
382
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
352
+ if (!id) continue;
383
353
 
384
354
  const w = insertAfter(el, id, klass, key);
385
355
  if (w) { observePh(id); inserted++; }
@@ -391,7 +361,6 @@
391
361
 
392
362
  function getIO() {
393
363
  if (S.io) return S.io;
394
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
395
364
  try {
396
365
  S.io = new IntersectionObserver(entries => {
397
366
  for (const e of entries) {
@@ -400,7 +369,7 @@
400
369
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
401
370
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
402
371
  }
403
- }, { root: null, rootMargin: margin, threshold: 0 });
372
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
404
373
  } catch (_) { S.io = null; }
405
374
  return S.io;
406
375
  }
@@ -451,7 +420,6 @@
451
420
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
452
421
  S.lastShow.set(id, t);
453
422
 
454
- // Horodater le show sur le wrap pour grace period + emptyCheck
455
423
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
456
424
 
457
425
  window.ezstandalone = window.ezstandalone || {};
@@ -472,7 +440,6 @@
472
440
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
473
441
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
474
442
  if (!wrap || !ph?.isConnected) return;
475
- // Un show plus récent → ne pas toucher
476
443
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
477
444
  wrap.classList.toggle('is-empty', !isFilled(ph));
478
445
  } catch (_) {}
@@ -491,7 +458,7 @@
491
458
  const orig = ez.showAds.bind(ez);
492
459
  ez.showAds = function (...args) {
493
460
  if (isBlocked()) return;
494
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
461
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
495
462
  const seen = new Set();
496
463
  for (const v of ids) {
497
464
  const id = parseInt(v, 10);
@@ -510,11 +477,10 @@
510
477
  }
511
478
  }
512
479
 
513
- // ── Core run ───────────────────────────────────────────────────────────────
480
+ // ── Core ───────────────────────────────────────────────────────────────────
514
481
 
515
482
  async function runCore() {
516
483
  if (isBlocked()) return 0;
517
- patchShowAds();
518
484
 
519
485
  const cfg = await fetchConfig();
520
486
  if (!cfg || cfg.excluded) return 0;
@@ -525,10 +491,9 @@
525
491
 
526
492
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
527
493
  if (!normBool(cfgEnable)) return 0;
528
- const items = getItems();
529
494
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
530
495
  pruneOrphans(klass);
531
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
496
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
532
497
  if (n) decluster(klass);
533
498
  return n;
534
499
  };
@@ -569,7 +534,7 @@
569
534
  S.lastBurstTs = t;
570
535
 
571
536
  const pk = pageKey();
572
- S.pageKey = pk;
537
+ S.pageKey = pk;
573
538
  S.burstDeadline = t + 2000;
574
539
 
575
540
  if (S.burstActive) return;
@@ -589,11 +554,13 @@
589
554
  step();
590
555
  }
591
556
 
592
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
557
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
593
558
 
594
559
  function cleanup() {
595
560
  blockedUntil = ts() + 1500;
561
+ poolsReady = false;
596
562
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
563
+ wrapByKey.clear();
597
564
  S.cfg = null;
598
565
  S.pools = { topics: [], posts: [], categories: [] };
599
566
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -610,15 +577,14 @@
610
577
 
611
578
  function ensureDomObserver() {
612
579
  if (S.domObs) return;
580
+ const allSel = [SEL.post, SEL.topic, SEL.category];
613
581
  S.domObs = new MutationObserver(muts => {
614
582
  if (S.mutGuard > 0 || isBlocked()) return;
615
583
  for (const m of muts) {
616
584
  if (!m.addedNodes?.length) continue;
617
585
  for (const n of m.addedNodes) {
618
586
  if (n.nodeType !== 1) continue;
619
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
620
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
621
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
587
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
622
588
  requestBurst(); return;
623
589
  }
624
590
  }
@@ -644,29 +610,20 @@
644
610
  }
645
611
 
646
612
  function ensureTcfLocator() {
647
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
648
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
649
- // iframe du DOM (vidage partiel du body), ce qui provoque :
650
- // "Cannot read properties of null (reading 'postMessage')"
651
- // "Cannot set properties of null (setting 'addtlConsent')"
652
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
613
+ // L'iframe __tcfapiLocator route les appels postMessage du CMP.
614
+ // En navigation ajaxify, NodeBB peut la retirer → erreurs CMP.
653
615
  try {
654
616
  if (!window.__tcfapi && !window.__cmp) return;
655
-
656
617
  const inject = () => {
657
618
  if (document.getElementById('__tcfapiLocator')) return;
658
619
  const f = document.createElement('iframe');
659
620
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
660
621
  (document.body || document.documentElement).appendChild(f);
661
622
  };
662
-
663
623
  inject();
664
-
665
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
666
624
  if (!window.__nbbTcfObs) {
667
- window.__nbbTcfObs = new MutationObserver(() => inject());
668
- window.__nbbTcfObs.observe(document.documentElement,
669
- { childList: true, subtree: true });
625
+ window.__nbbTcfObs = new MutationObserver(inject);
626
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
670
627
  }
671
628
  } catch (_) {}
672
629
  }
@@ -676,10 +633,10 @@
676
633
  const head = document.head;
677
634
  if (!head) return;
678
635
  for (const [rel, href, cors] of [
679
- ['preconnect', 'https://g.ezoic.net', true],
680
- ['preconnect', 'https://go.ezoic.net', true],
681
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
682
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
636
+ ['preconnect', 'https://g.ezoic.net', true ],
637
+ ['preconnect', 'https://go.ezoic.net', true ],
638
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
639
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
683
640
  ['dns-prefetch', 'https://g.ezoic.net', false],
684
641
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
685
642
  ]) {
@@ -713,10 +670,8 @@
713
670
  'action:posts.loaded', 'action:topics.loaded',
714
671
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
715
672
  ].map(e => `${e}.nbbEzoic`).join(' ');
716
-
717
673
  $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
718
674
 
719
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
720
675
  try {
721
676
  require(['hooks'], hooks => {
722
677
  if (typeof hooks?.on !== 'function') return;