nodebb-plugin-ezoic-infinite 1.7.10 → 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 +150 -194
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.10",
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 (v20)
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,58 +250,63 @@
279
250
  // ── Prune ──────────────────────────────────────────────────────────────────
280
251
 
281
252
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
283
- *
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
253
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
289
254
  *
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
- const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
297
-
298
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
299
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
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;
300
269
 
301
270
  const key = w.getAttribute(A_ANCHOR) ?? '';
302
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
303
- if (!sid) { mutate(() => dropWrap(w)); return; }
271
+ const sid = key.slice(klass.length + 1);
272
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
304
273
 
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
274
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
275
+ const anchorEl = document.querySelector(sel);
308
276
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
- });
277
+ }
310
278
  }
311
279
 
312
280
  // ── Decluster ──────────────────────────────────────────────────────────────
313
281
 
314
282
  /**
315
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
316
- * Priorité : filled > en grâce (fill en cours) > vide.
317
- * 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
318
290
  */
319
291
  function decluster(klass) {
320
292
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // 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;
322
296
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
297
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
298
 
325
299
  let prev = w.previousElementSibling, steps = 0;
326
300
  while (prev && steps++ < 3) {
327
301
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
302
+ if (isFilled(prev)) break;
303
+ const pAge = ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10);
304
+ if (pAge < INJECT_GRACE_MS) break;
329
305
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
306
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
331
307
 
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
308
+ // Les deux vides et hors grâce → supprimer le courant
309
+ mutate(() => dropWrap(w));
334
310
  break;
335
311
  }
336
312
  }
@@ -340,23 +316,19 @@
340
316
 
341
317
  /**
342
318
  * Ordinal 0-based pour le calcul de l'intervalle.
343
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
- * 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).
345
321
  */
346
322
  function ordinal(klass, el) {
347
323
  const di = el.getAttribute('data-index');
348
324
  if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
349
- // Fallback positionnel
350
- try {
351
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
352
- if (tag) {
353
- let i = 0;
354
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
355
- if (n === el) return i;
356
- i++;
357
- }
358
- }
359
- } catch (_) {}
325
+ // Fallback — s.matches(fullSel) pour ne pas compter les wraps intercalés
326
+ const fullSel = KIND[klass]?.sel ?? '';
327
+ let i = 0;
328
+ for (const s of el.parentElement?.children ?? []) {
329
+ if (s === el) return i;
330
+ if (!fullSel || s.matches?.(fullSel)) i++;
331
+ }
360
332
  return 0;
361
333
  }
362
334
 
@@ -368,17 +340,16 @@
368
340
  if (inserted >= MAX_INSERTS_PER_RUN) break;
369
341
  if (!el?.isConnected) continue;
370
342
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
343
+ const ord = ordinal(klass, el);
344
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
374
345
 
375
346
  if (adjacentWrap(el)) continue;
376
347
 
377
348
  const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
349
+ if (findWrap(key)) continue;
379
350
 
380
351
  const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
352
+ if (!id) continue;
382
353
 
383
354
  const w = insertAfter(el, id, klass, key);
384
355
  if (w) { observePh(id); inserted++; }
@@ -390,7 +361,6 @@
390
361
 
391
362
  function getIO() {
392
363
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
364
  try {
395
365
  S.io = new IntersectionObserver(entries => {
396
366
  for (const e of entries) {
@@ -399,7 +369,7 @@
399
369
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
370
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
371
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
372
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
373
  } catch (_) { S.io = null; }
404
374
  return S.io;
405
375
  }
@@ -450,7 +420,6 @@
450
420
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
421
  S.lastShow.set(id, t);
452
422
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
423
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
424
 
456
425
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +440,6 @@
471
440
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
441
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
442
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
443
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
444
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
445
  } catch (_) {}
@@ -490,7 +458,7 @@
490
458
  const orig = ez.showAds.bind(ez);
491
459
  ez.showAds = function (...args) {
492
460
  if (isBlocked()) return;
493
- 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;
494
462
  const seen = new Set();
495
463
  for (const v of ids) {
496
464
  const id = parseInt(v, 10);
@@ -509,11 +477,10 @@
509
477
  }
510
478
  }
511
479
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
480
+ // ── Core ───────────────────────────────────────────────────────────────────
513
481
 
514
482
  async function runCore() {
515
483
  if (isBlocked()) return 0;
516
- patchShowAds();
517
484
 
518
485
  const cfg = await fetchConfig();
519
486
  if (!cfg || cfg.excluded) return 0;
@@ -524,10 +491,9 @@
524
491
 
525
492
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
493
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
494
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
495
  pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
496
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
531
497
  if (n) decluster(klass);
532
498
  return n;
533
499
  };
@@ -568,7 +534,7 @@
568
534
  S.lastBurstTs = t;
569
535
 
570
536
  const pk = pageKey();
571
- S.pageKey = pk;
537
+ S.pageKey = pk;
572
538
  S.burstDeadline = t + 2000;
573
539
 
574
540
  if (S.burstActive) return;
@@ -588,11 +554,13 @@
588
554
  step();
589
555
  }
590
556
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
557
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
558
 
593
559
  function cleanup() {
594
560
  blockedUntil = ts() + 1500;
561
+ poolsReady = false;
595
562
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
563
+ wrapByKey.clear();
596
564
  S.cfg = null;
597
565
  S.pools = { topics: [], posts: [], categories: [] };
598
566
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -609,15 +577,14 @@
609
577
 
610
578
  function ensureDomObserver() {
611
579
  if (S.domObs) return;
580
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
581
  S.domObs = new MutationObserver(muts => {
613
582
  if (S.mutGuard > 0 || isBlocked()) return;
614
583
  for (const m of muts) {
615
584
  if (!m.addedNodes?.length) continue;
616
585
  for (const n of m.addedNodes) {
617
586
  if (n.nodeType !== 1) continue;
618
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
619
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
620
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
587
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
621
588
  requestBurst(); return;
622
589
  }
623
590
  }
@@ -643,29 +610,20 @@
643
610
  }
644
611
 
645
612
  function ensureTcfLocator() {
646
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
647
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
648
- // iframe du DOM (vidage partiel du body), ce qui provoque :
649
- // "Cannot read properties of null (reading 'postMessage')"
650
- // "Cannot set properties of null (setting 'addtlConsent')"
651
- // 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.
652
615
  try {
653
616
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
617
  const inject = () => {
656
618
  if (document.getElementById('__tcfapiLocator')) return;
657
619
  const f = document.createElement('iframe');
658
620
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
621
  (document.body || document.documentElement).appendChild(f);
660
622
  };
661
-
662
623
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
624
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
625
+ window.__nbbTcfObs = new MutationObserver(inject);
626
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
627
  }
670
628
  } catch (_) {}
671
629
  }
@@ -675,10 +633,10 @@
675
633
  const head = document.head;
676
634
  if (!head) return;
677
635
  for (const [rel, href, cors] of [
678
- ['preconnect', 'https://g.ezoic.net', true],
679
- ['preconnect', 'https://go.ezoic.net', true],
680
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
681
- ['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 ],
682
640
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
641
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
642
  ]) {
@@ -712,10 +670,8 @@
712
670
  'action:posts.loaded', 'action:topics.loaded',
713
671
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
672
  ].map(e => `${e}.nbbEzoic`).join(' ');
715
-
716
673
  $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
674
 
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
675
  try {
720
676
  require(['hooks'], hooks => {
721
677
  if (typeof hooks?.on !== 'function') return;