nodebb-plugin-ezoic-infinite 1.7.8 → 1.7.10

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 +216 -182
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
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,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v21.4
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 FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
40
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
41
- const MAX_INSERTS_RUN = 6;
42
- const MAX_INFLIGHT = 4;
43
- const SHOW_THROTTLE_MS = 900;
44
- const BURST_COOLDOWN_MS = 200;
45
-
46
- // 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 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)
47
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
48
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
49
55
 
@@ -54,40 +60,40 @@
54
60
  };
55
61
 
56
62
  /**
57
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
58
64
  *
59
- * sel : sélecteur CSS complet
60
- * baseTag : préfixe tag pour les querySelector de recherche d'ancre
61
- * (vide pour posts car leur sélecteur commence par '[')
62
- * anchorAttr : attribut DOM STABLE clé unique du wrap, permanent
63
- * data-pid posts (id message, immuable)
64
- * data-index topics (index dans la liste)
65
- * data-cid catégories (id catégorie, immuable)
66
- * ordinalAttr: attribut 0-based pour le calcul de l'intervalle
67
- * data-index posts + topics (fourni par NodeBB)
68
- * 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
69
71
  */
70
72
  const KIND = {
71
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
72
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
73
- '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' },
74
76
  };
75
77
 
76
78
  // ── État ───────────────────────────────────────────────────────────────────
77
79
 
78
80
  const S = {
79
- pageKey: null,
80
- cfg: null,
81
- pools: { topics: [], posts: [], categories: [] },
82
- cursors: { topics: 0, posts: 0, categories: 0 },
83
- mountedIds: new Set(), // IDs Ezoic montés dans le DOM
84
- lastShow: new Map(), // id timestamp dernier show
85
- io: null,
86
- domObs: null,
87
- mutGuard: 0,
88
- inflight: 0,
89
- pending: [],
90
- 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
+
91
97
  runQueued: false,
92
98
  burstActive: false,
93
99
  burstDeadline: 0,
@@ -96,12 +102,8 @@
96
102
  };
97
103
 
98
104
  let blockedUntil = 0;
99
- let poolsReady = false; // true dès que les pools sont initialisés pour la page courante
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]'));
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
105
107
 
106
108
  function mutate(fn) {
107
109
  S.mutGuard++;
@@ -119,6 +121,12 @@
119
121
  return S.cfg;
120
122
  }
121
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
+
122
130
  function parseIds(raw) {
123
131
  const out = [], seen = new Set();
124
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -128,13 +136,12 @@
128
136
  return out;
129
137
  }
130
138
 
131
- function initPools(cfg) {
132
- if (poolsReady) return;
133
- S.pools.topics = parseIds(cfg.placeholderIds);
134
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
135
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
136
- poolsReady = true;
137
- }
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; } };
138
145
 
139
146
  // ── Page identity ──────────────────────────────────────────────────────────
140
147
 
@@ -158,13 +165,13 @@
158
165
  return 'other';
159
166
  }
160
167
 
161
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
162
169
 
163
170
  function getPosts() {
164
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
165
172
  if (!el.isConnected) return false;
166
173
  if (!el.querySelector('[component="post/content"]')) return false;
167
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
168
175
  if (p && p !== el) return false;
169
176
  return el.getAttribute('component') !== 'post/parent';
170
177
  });
@@ -180,41 +187,46 @@
180
187
  );
181
188
  }
182
189
 
183
- // ── Ancres stables ─────────────────────────────────────────────────────────
184
-
185
- // Map anchorKey → wrap Element — évite un querySelector full-DOM à chaque injection
186
- const wrapByKey = new Map();
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
187
191
 
188
- function stableId(klass, el) {
189
- 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;
190
199
  if (attr) {
191
200
  const v = el.getAttribute(attr);
192
201
  if (v !== null && v !== '') return v;
193
202
  }
194
- let i = 0;
195
- for (const s of el.parentElement?.children ?? []) {
196
- if (s === el) return `i${i}`;
197
- i++;
198
- }
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 (_) {}
199
211
  return 'i0';
200
212
  }
201
213
 
202
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
203
215
 
204
- const findWrap = (key) => {
205
- const w = wrapByKey.get(key);
206
- // Vérifier que le wrap est toujours dans le DOM (il peut avoir été dropWrap'd)
207
- if (w && w.isConnected) return w;
208
- if (w) wrapByKey.delete(key); // nettoyage lazy
209
- return null;
210
- };
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; }
222
+ }
211
223
 
212
224
  // ── Pool ───────────────────────────────────────────────────────────────────
213
225
 
214
226
  function pickId(poolKey) {
215
227
  const pool = S.pools[poolKey];
216
228
  for (let t = 0; t < pool.length; t++) {
217
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
218
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
219
231
  const id = pool[i];
220
232
  if (!S.mountedIds.has(id)) return id;
@@ -225,7 +237,7 @@
225
237
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
226
238
 
227
239
  function makeWrap(id, klass, key) {
228
- const w = document.createElement('div');
240
+ const w = document.createElement('div');
229
241
  w.className = `${WRAP_CLASS} ${klass}`;
230
242
  w.setAttribute(A_ANCHOR, key);
231
243
  w.setAttribute(A_WRAPID, String(id));
@@ -239,28 +251,27 @@
239
251
  }
240
252
 
241
253
  function insertAfter(el, id, klass, key) {
242
- if (!el?.insertAdjacentElement) return null;
243
- if (findWrap(key)) return null;
244
- if (S.mountedIds.has(id)) return null;
245
- 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;
246
258
  const w = makeWrap(id, klass, key);
247
259
  mutate(() => el.insertAdjacentElement('afterend', w));
248
260
  S.mountedIds.add(id);
249
- wrapByKey.set(key, w);
250
261
  return w;
251
262
  }
252
263
 
253
264
  function dropWrap(w) {
254
265
  try {
255
- // Unobserve AVANT w.remove() — le placeholder est encore dans le DOM
256
- // à ce stade, ce qui est requis par l'IO. Guard instanceof uniquement
257
- // (même logique que v20.3 qui fonctionnait).
258
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
259
- if (ph instanceof Element) S.io?.unobserve(ph);
260
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
261
267
  if (Number.isFinite(id)) S.mountedIds.delete(id);
262
- const key = w.getAttribute(A_ANCHOR);
263
- if (key) wrapByKey.delete(key);
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 (_) {}
264
275
  w.remove();
265
276
  } catch (_) {}
266
277
  }
@@ -268,42 +279,46 @@
268
279
  // ── Prune ──────────────────────────────────────────────────────────────────
269
280
 
270
281
  /**
271
- * 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
272
289
  *
273
- * On ne supprime JAMAIS un wrap rempli (filled) :
274
- * - Les wraps remplis peuvent être temporairement orphelins lors d'une
275
- * virtualisation NodeBB — l'ancre reviendra.
276
- * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
277
- * retirer le nœud sous ses pieds génère des erreurs non critiques mais
278
- * 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é).
279
291
  */
280
292
  function pruneOrphans(klass) {
281
293
  const meta = KIND[klass];
282
294
  if (!meta) return;
283
295
 
284
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
285
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
286
- if (isFilled(w)) 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;
287
300
 
288
301
  const key = w.getAttribute(A_ANCHOR) ?? '';
289
- const sid = key.slice(klass.length + 1);
290
- 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; }
291
304
 
292
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
293
- const anchorEl = document.querySelector(sel);
305
+ const anchorEl = document.querySelector(
306
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
+ );
294
308
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
295
- }
309
+ });
296
310
  }
297
311
 
298
312
  // ── Decluster ──────────────────────────────────────────────────────────────
299
313
 
300
314
  /**
301
- * Deux wraps adjacents → supprimer le moins prioritaire.
302
- * Priorité : filled > en grâce de fill > vide.
303
- * 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.
304
318
  */
305
319
  function decluster(klass) {
306
320
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
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
 
@@ -312,11 +327,10 @@
312
327
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
313
328
 
314
329
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
315
- if (pShown && ts() - pShown < FILL_GRACE_MS) break;
330
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
316
331
 
317
332
  if (!isFilled(w)) mutate(() => dropWrap(w));
318
333
  else if (!isFilled(prev)) mutate(() => dropWrap(prev));
319
- // les deux remplis → on ne touche pas
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,10 +509,11 @@
489
509
  }
490
510
  }
491
511
 
492
- // ── Core ───────────────────────────────────────────────────────────────────
512
+ // ── Core run ───────────────────────────────────────────────────────────────
493
513
 
494
514
  async function runCore() {
495
515
  if (isBlocked()) return 0;
516
+ patchShowAds();
496
517
 
497
518
  const cfg = await fetchConfig();
498
519
  if (!cfg || cfg.excluded) return 0;
@@ -503,9 +524,10 @@
503
524
 
504
525
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
505
526
  if (!normBool(cfgEnable)) return 0;
527
+ const items = getItems();
506
528
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
507
529
  pruneOrphans(klass);
508
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
530
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
509
531
  if (n) decluster(klass);
510
532
  return n;
511
533
  };
@@ -518,13 +540,14 @@
518
540
  'ezoic-ad-between', getTopics,
519
541
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
520
542
  );
521
- return exec(
543
+ if (kind === 'categories') return exec(
522
544
  'ezoic-ad-categories', getCategories,
523
545
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
524
546
  );
547
+ return 0;
525
548
  }
526
549
 
527
- // ── Scheduler ──────────────────────────────────────────────────────────────
550
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
528
551
 
529
552
  function scheduleRun(cb) {
530
553
  if (S.runQueued) return;
@@ -534,7 +557,7 @@
534
557
  if (S.pageKey && pageKey() !== S.pageKey) return;
535
558
  let n = 0;
536
559
  try { n = await runCore(); } catch (_) {}
537
- cb?.(n);
560
+ try { cb?.(n); } catch (_) {}
538
561
  });
539
562
  }
540
563
 
@@ -542,8 +565,10 @@
542
565
  if (isBlocked()) return;
543
566
  const t = ts();
544
567
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
545
- S.lastBurstTs = t;
546
- S.pageKey = pageKey();
568
+ S.lastBurstTs = t;
569
+
570
+ const pk = pageKey();
571
+ S.pageKey = pk;
547
572
  S.burstDeadline = t + 2000;
548
573
 
549
574
  if (S.burstActive) return;
@@ -551,7 +576,7 @@
551
576
  S.burstCount = 0;
552
577
 
553
578
  const step = () => {
554
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
579
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
555
580
  S.burstActive = false; return;
556
581
  }
557
582
  S.burstCount++;
@@ -563,13 +588,11 @@
563
588
  step();
564
589
  }
565
590
 
566
- // ── Cleanup navigation ─────────────────────────────────────────────────────
591
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
567
592
 
568
593
  function cleanup() {
569
594
  blockedUntil = ts() + 1500;
570
- poolsReady = false;
571
595
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
572
- wrapByKey.clear();
573
596
  S.cfg = null;
574
597
  S.pools = { topics: [], posts: [], categories: [] };
575
598
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -582,19 +605,19 @@
582
605
  S.runQueued = false;
583
606
  }
584
607
 
585
- // ── MutationObserver ───────────────────────────────────────────────────────
608
+ // ── DOM Observer ───────────────────────────────────────────────────────────
586
609
 
587
610
  function ensureDomObserver() {
588
611
  if (S.domObs) return;
589
- const allSel = [SEL.post, SEL.topic, SEL.category];
590
612
  S.domObs = new MutationObserver(muts => {
591
- // Ne rien faire pendant la navigation (cleanup posé blockedUntil)
592
- // ou si c'est nous qui mutons le DOM.
593
613
  if (S.mutGuard > 0 || isBlocked()) return;
594
614
  for (const m of muts) {
615
+ if (!m.addedNodes?.length) continue;
595
616
  for (const n of m.addedNodes) {
596
617
  if (n.nodeType !== 1) continue;
597
- 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)) {
598
621
  requestBurst(); return;
599
622
  }
600
623
  }
@@ -620,21 +643,29 @@
620
643
  }
621
644
 
622
645
  function ensureTcfLocator() {
623
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
624
- // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
625
- // 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.
626
652
  try {
627
653
  if (!window.__tcfapi && !window.__cmp) return;
654
+
628
655
  const inject = () => {
629
656
  if (document.getElementById('__tcfapiLocator')) return;
630
657
  const f = document.createElement('iframe');
631
658
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
632
659
  (document.body || document.documentElement).appendChild(f);
633
660
  };
661
+
634
662
  inject();
663
+
664
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
635
665
  if (!window.__nbbTcfObs) {
636
- window.__nbbTcfObs = new MutationObserver(inject);
637
- 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 });
638
669
  }
639
670
  } catch (_) {}
640
671
  }
@@ -644,10 +675,10 @@
644
675
  const head = document.head;
645
676
  if (!head) return;
646
677
  for (const [rel, href, cors] of [
647
- ['preconnect', 'https://g.ezoic.net', true ],
648
- ['preconnect', 'https://go.ezoic.net', true ],
649
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
650
- ['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],
651
682
  ['dns-prefetch', 'https://g.ezoic.net', false],
652
683
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
653
684
  ]) {
@@ -661,7 +692,7 @@
661
692
  }
662
693
  }
663
694
 
664
- // ── Bindings ───────────────────────────────────────────────────────────────
695
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
665
696
 
666
697
  function bindNodeBB() {
667
698
  const $ = window.jQuery;
@@ -672,16 +703,19 @@
672
703
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
673
704
  S.pageKey = pageKey();
674
705
  blockedUntil = 0;
675
- muteConsole(); ensureTcfLocator(); warmNetwork();
676
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
706
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
+ getIO(); ensureDomObserver(); requestBurst();
677
708
  });
678
709
 
679
- const burstEvts = [
680
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
710
+ const BURST_EVENTS = [
711
+ 'action:ajaxify.contentLoaded',
712
+ 'action:posts.loaded', 'action:topics.loaded',
681
713
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
682
714
  ].map(e => `${e}.nbbEzoic`).join(' ');
683
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
684
715
 
716
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
+
718
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
685
719
  try {
686
720
  require(['hooks'], hooks => {
687
721
  if (typeof hooks?.on !== 'function') return;