nodebb-plugin-ezoic-infinite 1.7.14 → 1.7.16

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 +213 -176
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.14",
3
+ "version": "1.7.16",
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,51 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v24
2
+ * NodeBB Ezoic Infinite Ads — client.js (v20)
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
- * Suppression du recyclage de wraps (moveWrapAfter). Cleanup complet navigation.
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).
8
12
  *
9
- * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
- * la position dans le batch courant.
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.
11
15
  *
12
- * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
13
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
14
- * Fix posts : baseTag vide → ordinal fallback cassé → pubs jamais injectées.
15
- * IO fixe (une instance, jamais recréée). Burst cooldown 200 ms.
16
- * Fix unobserve(null) → corruption IO → pubads error au scroll retour.
17
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
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
18
  *
19
- * v21 Suppression de toute la logique wyvern.js (pause/destroy avant remove) :
20
- * les erreurs wyvern viennent du SDK Ezoic lui-même lors de ses propres
21
- * refreshes internes, pas de nos suppressions. Nos wraps filled ne sont
22
- * de toute façon jamais supprimés (règle pruneOrphans/decluster).
23
- * Refactorisation finale prod-ready : code unifié, zéro duplication,
24
- * commentaires essentiels uniquement.
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é)
25
31
  */
26
32
  (function () {
27
33
  'use strict';
28
34
 
29
35
  // ── Constantes ─────────────────────────────────────────────────────────────
30
36
 
31
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
32
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
33
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
34
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
35
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
36
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
37
-
38
- const MIN_PRUNE_AGE_MS = 8_000; // garde-fou post-batch NodeBB
39
- const PRUNE_STABLE_MS = 45_000; // délai avant qu'un wrap vide puisse être purgé
40
- // (évite la suppression lors du scroll up / virtualisation NodeBB)
41
- const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
42
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
43
- const MAX_INSERTS_RUN = 6;
44
- const MAX_INFLIGHT = 4;
45
- const SHOW_THROTTLE_MS = 900;
46
- const BURST_COOLDOWN_MS = 200;
47
-
48
- // IO : marges larges fixes — une seule instance, jamais recréée
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 où l'on ne decluster pas
46
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
+ const MAX_INSERTS_PER_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
49
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
50
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
51
55
 
@@ -56,40 +60,40 @@
56
60
  };
57
61
 
58
62
  /**
59
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
60
64
  *
61
- * sel : sélecteur CSS complet
62
- * baseTag : préfixe tag pour les querySelector de recherche d'ancre
63
- * (vide pour posts car leur sélecteur commence par '[')
64
- * anchorAttr : attribut DOM STABLE clé unique du wrap, permanent
65
- * data-pid posts (id message, immuable)
66
- * data-index topics (index dans la liste)
67
- * data-cid catégories (id catégorie, immuable)
68
- * ordinalAttr: attribut 0-based pour le calcul de l'intervalle
69
- * data-index posts + topics (fourni par NodeBB)
70
- * null catégories (page statique → fallback positionnel)
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
71
71
  */
72
72
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
74
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
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' },
76
76
  };
77
77
 
78
78
  // ── État ───────────────────────────────────────────────────────────────────
79
79
 
80
80
  const S = {
81
- pageKey: null,
82
- cfg: null,
83
- pools: { topics: [], posts: [], categories: [] },
84
- cursors: { topics: 0, posts: 0, categories: 0 },
85
- mountedIds: new Set(), // IDs Ezoic montés dans le DOM
86
- lastShow: new Map(), // id timestamp dernier show
87
- io: null,
88
- domObs: null,
89
- mutGuard: 0,
90
- inflight: 0,
91
- pending: [],
92
- pendingSet: new Set(),
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
+
93
97
  runQueued: false,
94
98
  burstActive: false,
95
99
  burstDeadline: 0,
@@ -98,11 +102,8 @@
98
102
  };
99
103
 
100
104
  let blockedUntil = 0;
101
- const ts = () => Date.now();
102
- const isBlocked = () => ts() < blockedUntil;
103
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
104
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
105
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
106
107
 
107
108
  function mutate(fn) {
108
109
  S.mutGuard++;
@@ -120,6 +121,12 @@
120
121
  return S.cfg;
121
122
  }
122
123
 
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
+
123
130
  function parseIds(raw) {
124
131
  const out = [], seen = new Set();
125
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -129,11 +136,12 @@
129
136
  return out;
130
137
  }
131
138
 
132
- function initPools(cfg) {
133
- S.pools.topics = parseIds(cfg.placeholderIds);
134
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
135
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
136
- }
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; } };
137
145
 
138
146
  // ── Page identity ──────────────────────────────────────────────────────────
139
147
 
@@ -157,13 +165,13 @@
157
165
  return 'other';
158
166
  }
159
167
 
160
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
161
169
 
162
170
  function getPosts() {
163
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
164
172
  if (!el.isConnected) return false;
165
173
  if (!el.querySelector('[component="post/content"]')) return false;
166
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
167
175
  if (p && p !== el) return false;
168
176
  return el.getAttribute('component') !== 'post/parent';
169
177
  });
@@ -179,28 +187,36 @@
179
187
  );
180
188
  }
181
189
 
182
- // ── Ancres stables ─────────────────────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
183
191
 
184
- function stableId(klass, el) {
185
- const attr = KIND[klass]?.anchorAttr;
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;
186
199
  if (attr) {
187
200
  const v = el.getAttribute(attr);
188
201
  if (v !== null && v !== '') return v;
189
202
  }
190
- let i = 0;
191
- for (const s of el.parentElement?.children ?? []) {
192
- if (s === el) return `i${i}`;
193
- i++;
194
- }
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 (_) {}
195
211
  return 'i0';
196
212
  }
197
213
 
198
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
199
215
 
200
- function findWrap(key) {
216
+ function findWrap(anchorKey) {
201
217
  try {
202
218
  return document.querySelector(
203
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
204
220
  );
205
221
  } catch (_) { return null; }
206
222
  }
@@ -210,7 +226,7 @@
210
226
  function pickId(poolKey) {
211
227
  const pool = S.pools[poolKey];
212
228
  for (let t = 0; t < pool.length; t++) {
213
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
214
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
215
231
  const id = pool[i];
216
232
  if (!S.mountedIds.has(id)) return id;
@@ -221,7 +237,7 @@
221
237
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
222
238
 
223
239
  function makeWrap(id, klass, key) {
224
- const w = document.createElement('div');
240
+ const w = document.createElement('div');
225
241
  w.className = `${WRAP_CLASS} ${klass}`;
226
242
  w.setAttribute(A_ANCHOR, key);
227
243
  w.setAttribute(A_WRAPID, String(id));
@@ -235,10 +251,10 @@
235
251
  }
236
252
 
237
253
  function insertAfter(el, id, klass, key) {
238
- if (!el?.insertAdjacentElement) return null;
239
- if (findWrap(key)) return null;
240
- if (S.mountedIds.has(id)) return null;
241
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
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é
257
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
242
258
  const w = makeWrap(id, klass, key);
243
259
  mutate(() => el.insertAdjacentElement('afterend', w));
244
260
  S.mountedIds.add(id);
@@ -247,12 +263,15 @@
247
263
 
248
264
  function dropWrap(w) {
249
265
  try {
250
- // Unobserve avant remove — guard instanceof évite unobserve(null)
251
- // qui corrompt l'état interne de l'IO (pubads error au scroll suivant)
252
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
253
- if (ph instanceof Element) S.io?.unobserve(ph);
254
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
255
267
  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).
271
+ try {
272
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
+ if (ph instanceof Element) S.io?.unobserve(ph);
274
+ } catch (_) {}
256
275
  w.remove();
257
276
  } catch (_) {}
258
277
  }
@@ -260,63 +279,58 @@
260
279
  // ── Prune ──────────────────────────────────────────────────────────────────
261
280
 
262
281
  /**
263
- * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
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
264
289
  *
265
- * On ne supprime JAMAIS un wrap rempli (filled) :
266
- * - Les wraps remplis peuvent être temporairement orphelins lors d'une
267
- * virtualisation NodeBB — l'ancre reviendra.
268
- * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
269
- * retirer le nœud sous ses pieds génère des erreurs non critiques mais
270
- * inutiles. Le cleanup de navigation gère la suppression définitive.
290
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
271
291
  */
272
292
  function pruneOrphans(klass) {
273
293
  const meta = KIND[klass];
274
294
  if (!meta) return;
275
295
 
276
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
277
- // Ne jamais supprimer un wrap filled
278
- if (isFilled(w)) continue;
279
- // Attendre PRUNE_STABLE_MS depuis la création : pendant ce délai, l'ancre
280
- // peut avoir temporairement disparu du DOM par virtualisation NodeBB au
281
- // scroll up — ce n'est pas un vrai orphelin.
282
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < PRUNE_STABLE_MS) continue;
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;
283
300
 
284
301
  const key = w.getAttribute(A_ANCHOR) ?? '';
285
- const sid = key.slice(klass.length + 1);
286
- if (!sid) { mutate(() => dropWrap(w)); continue; }
302
+ const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
303
+ if (!sid) { mutate(() => dropWrap(w)); return; }
287
304
 
288
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
289
- const anchorEl = document.querySelector(sel);
305
+ const anchorEl = document.querySelector(
306
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
+ );
290
308
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
291
- }
309
+ });
292
310
  }
293
311
 
294
312
  // ── Decluster ──────────────────────────────────────────────────────────────
295
313
 
296
314
  /**
297
- * Deux wraps adjacents → supprimer le moins prioritaire.
298
- * Priorité : filled > en grâce de fill > vide.
299
- * Jamais de suppression d'un wrap rempli (même raison que pruneOrphans).
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.
300
318
  */
301
319
  function decluster(klass) {
302
320
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
303
- // Ne jamais toucher un wrap filled
304
- if (isFilled(w)) continue;
305
- // Protéger par A_CREATED : un wrap récent attend encore son showAds async
306
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) continue;
321
+ // Grace sur le wrap courant : on le saute entièrement
307
322
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
308
323
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
309
324
 
310
325
  let prev = w.previousElementSibling, steps = 0;
311
326
  while (prev && steps++ < 3) {
312
327
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
313
- if (isFilled(prev)) break;
314
- if (ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) break;
328
+
315
329
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
316
- if (pShown && ts() - pShown < FILL_GRACE_MS) break;
330
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
317
331
 
318
- // Les deux vides et hors grâce → supprimer le courant
319
- mutate(() => dropWrap(w));
332
+ if (!isFilled(w)) mutate(() => dropWrap(w));
333
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
320
334
  break;
321
335
  }
322
336
  }
@@ -326,22 +340,23 @@
326
340
 
327
341
  /**
328
342
  * Ordinal 0-based pour le calcul de l'intervalle.
329
- * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
330
- * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
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).
331
345
  */
332
346
  function ordinal(klass, el) {
333
- const attr = KIND[klass]?.ordinalAttr;
334
- if (attr) {
335
- const v = el.getAttribute(attr);
336
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
337
- }
338
- // Fallback positionnel — compte uniquement les éléments du même type
339
- const fullSel = KIND[klass]?.sel ?? '';
340
- let i = 0;
341
- for (const s of el.parentElement?.children ?? []) {
342
- if (s === el) return i;
343
- if (!fullSel || s.matches?.(fullSel)) i++;
344
- }
347
+ const di = el.getAttribute('data-index');
348
+ 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 (_) {}
345
360
  return 0;
346
361
  }
347
362
 
@@ -350,18 +365,20 @@
350
365
  let inserted = 0;
351
366
 
352
367
  for (const el of items) {
353
- if (inserted >= MAX_INSERTS_RUN) break;
368
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
354
369
  if (!el?.isConnected) continue;
355
370
 
356
- const ord = ordinal(klass, el);
357
- if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
371
+ const ord = ordinal(klass, el);
372
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
+ if (!isTarget) continue;
374
+
358
375
  if (adjacentWrap(el)) continue;
359
376
 
360
- const key = anchorKey(klass, el);
361
- if (findWrap(key)) continue;
377
+ const key = makeAnchorKey(klass, el);
378
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
362
379
 
363
380
  const id = pickId(poolKey);
364
- if (!id) continue;
381
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
365
382
 
366
383
  const w = insertAfter(el, id, klass, key);
367
384
  if (w) { observePh(id); inserted++; }
@@ -373,6 +390,7 @@
373
390
 
374
391
  function getIO() {
375
392
  if (S.io) return S.io;
393
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
376
394
  try {
377
395
  S.io = new IntersectionObserver(entries => {
378
396
  for (const e of entries) {
@@ -381,7 +399,7 @@
381
399
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
382
400
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
383
401
  }
384
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
402
+ }, { root: null, rootMargin: margin, threshold: 0 });
385
403
  } catch (_) { S.io = null; }
386
404
  return S.io;
387
405
  }
@@ -432,6 +450,7 @@
432
450
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
433
451
  S.lastShow.set(id, t);
434
452
 
453
+ // Horodater le show sur le wrap pour grace period + emptyCheck
435
454
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
436
455
 
437
456
  window.ezstandalone = window.ezstandalone || {};
@@ -452,6 +471,7 @@
452
471
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
453
472
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
454
473
  if (!wrap || !ph?.isConnected) return;
474
+ // Un show plus récent → ne pas toucher
455
475
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
456
476
  wrap.classList.toggle('is-empty', !isFilled(ph));
457
477
  } catch (_) {}
@@ -470,7 +490,7 @@
470
490
  const orig = ez.showAds.bind(ez);
471
491
  ez.showAds = function (...args) {
472
492
  if (isBlocked()) return;
473
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
493
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
474
494
  const seen = new Set();
475
495
  for (const v of ids) {
476
496
  const id = parseInt(v, 10);
@@ -489,7 +509,7 @@
489
509
  }
490
510
  }
491
511
 
492
- // ── Core ───────────────────────────────────────────────────────────────────
512
+ // ── Core run ───────────────────────────────────────────────────────────────
493
513
 
494
514
  async function runCore() {
495
515
  if (isBlocked()) return 0;
@@ -504,9 +524,10 @@
504
524
 
505
525
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
506
526
  if (!normBool(cfgEnable)) return 0;
527
+ const items = getItems();
507
528
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
508
529
  pruneOrphans(klass);
509
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
530
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
510
531
  if (n) decluster(klass);
511
532
  return n;
512
533
  };
@@ -519,13 +540,14 @@
519
540
  'ezoic-ad-between', getTopics,
520
541
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
521
542
  );
522
- return exec(
543
+ if (kind === 'categories') return exec(
523
544
  'ezoic-ad-categories', getCategories,
524
545
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
525
546
  );
547
+ return 0;
526
548
  }
527
549
 
528
- // ── Scheduler ──────────────────────────────────────────────────────────────
550
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
529
551
 
530
552
  function scheduleRun(cb) {
531
553
  if (S.runQueued) return;
@@ -535,7 +557,7 @@
535
557
  if (S.pageKey && pageKey() !== S.pageKey) return;
536
558
  let n = 0;
537
559
  try { n = await runCore(); } catch (_) {}
538
- cb?.(n);
560
+ try { cb?.(n); } catch (_) {}
539
561
  });
540
562
  }
541
563
 
@@ -543,8 +565,10 @@
543
565
  if (isBlocked()) return;
544
566
  const t = ts();
545
567
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
546
- S.lastBurstTs = t;
547
- S.pageKey = pageKey();
568
+ S.lastBurstTs = t;
569
+
570
+ const pk = pageKey();
571
+ S.pageKey = pk;
548
572
  S.burstDeadline = t + 2000;
549
573
 
550
574
  if (S.burstActive) return;
@@ -552,7 +576,7 @@
552
576
  S.burstCount = 0;
553
577
 
554
578
  const step = () => {
555
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
579
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
556
580
  S.burstActive = false; return;
557
581
  }
558
582
  S.burstCount++;
@@ -564,7 +588,7 @@
564
588
  step();
565
589
  }
566
590
 
567
- // ── Cleanup navigation ─────────────────────────────────────────────────────
591
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
568
592
 
569
593
  function cleanup() {
570
594
  blockedUntil = ts() + 1500;
@@ -581,17 +605,19 @@
581
605
  S.runQueued = false;
582
606
  }
583
607
 
584
- // ── MutationObserver ───────────────────────────────────────────────────────
608
+ // ── DOM Observer ───────────────────────────────────────────────────────────
585
609
 
586
610
  function ensureDomObserver() {
587
611
  if (S.domObs) return;
588
- const allSel = [SEL.post, SEL.topic, SEL.category];
589
612
  S.domObs = new MutationObserver(muts => {
590
613
  if (S.mutGuard > 0 || isBlocked()) return;
591
614
  for (const m of muts) {
615
+ if (!m.addedNodes?.length) continue;
592
616
  for (const n of m.addedNodes) {
593
617
  if (n.nodeType !== 1) continue;
594
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
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)) {
595
621
  requestBurst(); return;
596
622
  }
597
623
  }
@@ -617,21 +643,29 @@
617
643
  }
618
644
 
619
645
  function ensureTcfLocator() {
620
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
621
- // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
622
- // Un MutationObserver la recrée dès qu'elle disparaît.
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.
623
652
  try {
624
653
  if (!window.__tcfapi && !window.__cmp) return;
654
+
625
655
  const inject = () => {
626
656
  if (document.getElementById('__tcfapiLocator')) return;
627
657
  const f = document.createElement('iframe');
628
658
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
629
659
  (document.body || document.documentElement).appendChild(f);
630
660
  };
661
+
631
662
  inject();
663
+
664
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
632
665
  if (!window.__nbbTcfObs) {
633
- window.__nbbTcfObs = new MutationObserver(inject);
634
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
666
+ window.__nbbTcfObs = new MutationObserver(() => inject());
667
+ window.__nbbTcfObs.observe(document.documentElement,
668
+ { childList: true, subtree: true });
635
669
  }
636
670
  } catch (_) {}
637
671
  }
@@ -641,10 +675,10 @@
641
675
  const head = document.head;
642
676
  if (!head) return;
643
677
  for (const [rel, href, cors] of [
644
- ['preconnect', 'https://g.ezoic.net', true ],
645
- ['preconnect', 'https://go.ezoic.net', true ],
646
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
647
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
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],
648
682
  ['dns-prefetch', 'https://g.ezoic.net', false],
649
683
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
650
684
  ]) {
@@ -658,7 +692,7 @@
658
692
  }
659
693
  }
660
694
 
661
- // ── Bindings ───────────────────────────────────────────────────────────────
695
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
662
696
 
663
697
  function bindNodeBB() {
664
698
  const $ = window.jQuery;
@@ -669,16 +703,19 @@
669
703
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
670
704
  S.pageKey = pageKey();
671
705
  blockedUntil = 0;
672
- muteConsole(); ensureTcfLocator(); warmNetwork();
673
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
706
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
+ getIO(); ensureDomObserver(); requestBurst();
674
708
  });
675
709
 
676
- const burstEvts = [
677
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
710
+ const BURST_EVENTS = [
711
+ 'action:ajaxify.contentLoaded',
712
+ 'action:posts.loaded', 'action:topics.loaded',
678
713
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
679
714
  ].map(e => `${e}.nbbEzoic`).join(' ');
680
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
681
715
 
716
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
+
718
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
682
719
  try {
683
720
  require(['hooks'], hooks => {
684
721
  if (typeof hooks?.on !== 'function') return;