nodebb-plugin-ezoic-infinite 1.7.15 → 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 +210 -163
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.15",
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,49 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v21
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,11 +102,8 @@
96
102
  };
97
103
 
98
104
  let blockedUntil = 0;
99
- const ts = () => Date.now();
100
- const isBlocked = () => ts() < blockedUntil;
101
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
102
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
103
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
104
107
 
105
108
  function mutate(fn) {
106
109
  S.mutGuard++;
@@ -118,6 +121,12 @@
118
121
  return S.cfg;
119
122
  }
120
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
+
121
130
  function parseIds(raw) {
122
131
  const out = [], seen = new Set();
123
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -127,11 +136,12 @@
127
136
  return out;
128
137
  }
129
138
 
130
- function initPools(cfg) {
131
- S.pools.topics = parseIds(cfg.placeholderIds);
132
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
133
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
134
- }
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; } };
135
145
 
136
146
  // ── Page identity ──────────────────────────────────────────────────────────
137
147
 
@@ -155,13 +165,13 @@
155
165
  return 'other';
156
166
  }
157
167
 
158
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
159
169
 
160
170
  function getPosts() {
161
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
162
172
  if (!el.isConnected) return false;
163
173
  if (!el.querySelector('[component="post/content"]')) return false;
164
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
165
175
  if (p && p !== el) return false;
166
176
  return el.getAttribute('component') !== 'post/parent';
167
177
  });
@@ -177,28 +187,36 @@
177
187
  );
178
188
  }
179
189
 
180
- // ── Ancres stables ─────────────────────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
181
191
 
182
- function stableId(klass, el) {
183
- 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;
184
199
  if (attr) {
185
200
  const v = el.getAttribute(attr);
186
201
  if (v !== null && v !== '') return v;
187
202
  }
188
- let i = 0;
189
- for (const s of el.parentElement?.children ?? []) {
190
- if (s === el) return `i${i}`;
191
- i++;
192
- }
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 (_) {}
193
211
  return 'i0';
194
212
  }
195
213
 
196
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
197
215
 
198
- function findWrap(key) {
216
+ function findWrap(anchorKey) {
199
217
  try {
200
218
  return document.querySelector(
201
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
202
220
  );
203
221
  } catch (_) { return null; }
204
222
  }
@@ -208,7 +226,7 @@
208
226
  function pickId(poolKey) {
209
227
  const pool = S.pools[poolKey];
210
228
  for (let t = 0; t < pool.length; t++) {
211
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
212
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
213
231
  const id = pool[i];
214
232
  if (!S.mountedIds.has(id)) return id;
@@ -219,7 +237,7 @@
219
237
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
220
238
 
221
239
  function makeWrap(id, klass, key) {
222
- const w = document.createElement('div');
240
+ const w = document.createElement('div');
223
241
  w.className = `${WRAP_CLASS} ${klass}`;
224
242
  w.setAttribute(A_ANCHOR, key);
225
243
  w.setAttribute(A_WRAPID, String(id));
@@ -233,10 +251,10 @@
233
251
  }
234
252
 
235
253
  function insertAfter(el, id, klass, key) {
236
- if (!el?.insertAdjacentElement) return null;
237
- if (findWrap(key)) return null;
238
- if (S.mountedIds.has(id)) return null;
239
- 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;
240
258
  const w = makeWrap(id, klass, key);
241
259
  mutate(() => el.insertAdjacentElement('afterend', w));
242
260
  S.mountedIds.add(id);
@@ -245,12 +263,15 @@
245
263
 
246
264
  function dropWrap(w) {
247
265
  try {
248
- // Unobserve avant remove — guard instanceof évite unobserve(null)
249
- // qui corrompt l'état interne de l'IO (pubads error au scroll suivant)
250
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
251
- if (ph instanceof Element) S.io?.unobserve(ph);
252
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
253
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 (_) {}
254
275
  w.remove();
255
276
  } catch (_) {}
256
277
  }
@@ -258,42 +279,46 @@
258
279
  // ── Prune ──────────────────────────────────────────────────────────────────
259
280
 
260
281
  /**
261
- * 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.
262
283
  *
263
- * On ne supprime JAMAIS un wrap rempli (filled) :
264
- * - Les wraps remplis peuvent être temporairement orphelins lors d'une
265
- * virtualisation NodeBB — l'ancre reviendra.
266
- * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
267
- * retirer le nœud sous ses pieds génère des erreurs non critiques mais
268
- * inutiles. Le cleanup de navigation gère la suppression définitive.
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é).
269
291
  */
270
292
  function pruneOrphans(klass) {
271
293
  const meta = KIND[klass];
272
294
  if (!meta) return;
273
295
 
274
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
275
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
276
- 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;
277
300
 
278
301
  const key = w.getAttribute(A_ANCHOR) ?? '';
279
- const sid = key.slice(klass.length + 1);
280
- 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; }
281
304
 
282
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
283
- const anchorEl = document.querySelector(sel);
305
+ const anchorEl = document.querySelector(
306
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
+ );
284
308
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
285
- }
309
+ });
286
310
  }
287
311
 
288
312
  // ── Decluster ──────────────────────────────────────────────────────────────
289
313
 
290
314
  /**
291
- * Deux wraps adjacents → supprimer le moins prioritaire.
292
- * Priorité : filled > en grâce de fill > vide.
293
- * 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.
294
318
  */
295
319
  function decluster(klass) {
296
320
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
+ // Grace sur le wrap courant : on le saute entièrement
297
322
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
298
323
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
299
324
 
@@ -302,11 +327,10 @@
302
327
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
303
328
 
304
329
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
305
- if (pShown && ts() - pShown < FILL_GRACE_MS) break;
330
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
306
331
 
307
332
  if (!isFilled(w)) mutate(() => dropWrap(w));
308
333
  else if (!isFilled(prev)) mutate(() => dropWrap(prev));
309
- // les deux remplis → on ne touche pas
310
334
  break;
311
335
  }
312
336
  }
@@ -316,22 +340,23 @@
316
340
 
317
341
  /**
318
342
  * Ordinal 0-based pour le calcul de l'intervalle.
319
- * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
320
- * 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).
321
345
  */
322
346
  function ordinal(klass, el) {
323
- const attr = KIND[klass]?.ordinalAttr;
324
- if (attr) {
325
- const v = el.getAttribute(attr);
326
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
327
- }
328
- // Fallback positionnel — compte uniquement les éléments du même type
329
- const fullSel = KIND[klass]?.sel ?? '';
330
- let i = 0;
331
- for (const s of el.parentElement?.children ?? []) {
332
- if (s === el) return i;
333
- if (!fullSel || s.matches?.(fullSel)) i++;
334
- }
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 (_) {}
335
360
  return 0;
336
361
  }
337
362
 
@@ -340,18 +365,20 @@
340
365
  let inserted = 0;
341
366
 
342
367
  for (const el of items) {
343
- if (inserted >= MAX_INSERTS_RUN) break;
368
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
344
369
  if (!el?.isConnected) continue;
345
370
 
346
- const ord = ordinal(klass, el);
347
- 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
+
348
375
  if (adjacentWrap(el)) continue;
349
376
 
350
- const key = anchorKey(klass, el);
351
- if (findWrap(key)) continue;
377
+ const key = makeAnchorKey(klass, el);
378
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
352
379
 
353
380
  const id = pickId(poolKey);
354
- if (!id) continue;
381
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
355
382
 
356
383
  const w = insertAfter(el, id, klass, key);
357
384
  if (w) { observePh(id); inserted++; }
@@ -363,6 +390,7 @@
363
390
 
364
391
  function getIO() {
365
392
  if (S.io) return S.io;
393
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
366
394
  try {
367
395
  S.io = new IntersectionObserver(entries => {
368
396
  for (const e of entries) {
@@ -371,7 +399,7 @@
371
399
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
372
400
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
373
401
  }
374
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
402
+ }, { root: null, rootMargin: margin, threshold: 0 });
375
403
  } catch (_) { S.io = null; }
376
404
  return S.io;
377
405
  }
@@ -422,6 +450,7 @@
422
450
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
423
451
  S.lastShow.set(id, t);
424
452
 
453
+ // Horodater le show sur le wrap pour grace period + emptyCheck
425
454
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
426
455
 
427
456
  window.ezstandalone = window.ezstandalone || {};
@@ -442,6 +471,7 @@
442
471
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
443
472
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
444
473
  if (!wrap || !ph?.isConnected) return;
474
+ // Un show plus récent → ne pas toucher
445
475
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
446
476
  wrap.classList.toggle('is-empty', !isFilled(ph));
447
477
  } catch (_) {}
@@ -460,7 +490,7 @@
460
490
  const orig = ez.showAds.bind(ez);
461
491
  ez.showAds = function (...args) {
462
492
  if (isBlocked()) return;
463
- 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;
464
494
  const seen = new Set();
465
495
  for (const v of ids) {
466
496
  const id = parseInt(v, 10);
@@ -479,7 +509,7 @@
479
509
  }
480
510
  }
481
511
 
482
- // ── Core ───────────────────────────────────────────────────────────────────
512
+ // ── Core run ───────────────────────────────────────────────────────────────
483
513
 
484
514
  async function runCore() {
485
515
  if (isBlocked()) return 0;
@@ -494,9 +524,10 @@
494
524
 
495
525
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
496
526
  if (!normBool(cfgEnable)) return 0;
527
+ const items = getItems();
497
528
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
498
529
  pruneOrphans(klass);
499
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
530
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
500
531
  if (n) decluster(klass);
501
532
  return n;
502
533
  };
@@ -509,13 +540,14 @@
509
540
  'ezoic-ad-between', getTopics,
510
541
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
511
542
  );
512
- return exec(
543
+ if (kind === 'categories') return exec(
513
544
  'ezoic-ad-categories', getCategories,
514
545
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
515
546
  );
547
+ return 0;
516
548
  }
517
549
 
518
- // ── Scheduler ──────────────────────────────────────────────────────────────
550
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
519
551
 
520
552
  function scheduleRun(cb) {
521
553
  if (S.runQueued) return;
@@ -525,7 +557,7 @@
525
557
  if (S.pageKey && pageKey() !== S.pageKey) return;
526
558
  let n = 0;
527
559
  try { n = await runCore(); } catch (_) {}
528
- cb?.(n);
560
+ try { cb?.(n); } catch (_) {}
529
561
  });
530
562
  }
531
563
 
@@ -533,8 +565,10 @@
533
565
  if (isBlocked()) return;
534
566
  const t = ts();
535
567
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
536
- S.lastBurstTs = t;
537
- S.pageKey = pageKey();
568
+ S.lastBurstTs = t;
569
+
570
+ const pk = pageKey();
571
+ S.pageKey = pk;
538
572
  S.burstDeadline = t + 2000;
539
573
 
540
574
  if (S.burstActive) return;
@@ -542,7 +576,7 @@
542
576
  S.burstCount = 0;
543
577
 
544
578
  const step = () => {
545
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
579
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
546
580
  S.burstActive = false; return;
547
581
  }
548
582
  S.burstCount++;
@@ -554,7 +588,7 @@
554
588
  step();
555
589
  }
556
590
 
557
- // ── Cleanup navigation ─────────────────────────────────────────────────────
591
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
558
592
 
559
593
  function cleanup() {
560
594
  blockedUntil = ts() + 1500;
@@ -571,17 +605,19 @@
571
605
  S.runQueued = false;
572
606
  }
573
607
 
574
- // ── MutationObserver ───────────────────────────────────────────────────────
608
+ // ── DOM Observer ───────────────────────────────────────────────────────────
575
609
 
576
610
  function ensureDomObserver() {
577
611
  if (S.domObs) return;
578
- const allSel = [SEL.post, SEL.topic, SEL.category];
579
612
  S.domObs = new MutationObserver(muts => {
580
613
  if (S.mutGuard > 0 || isBlocked()) return;
581
614
  for (const m of muts) {
615
+ if (!m.addedNodes?.length) continue;
582
616
  for (const n of m.addedNodes) {
583
617
  if (n.nodeType !== 1) continue;
584
- 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)) {
585
621
  requestBurst(); return;
586
622
  }
587
623
  }
@@ -607,21 +643,29 @@
607
643
  }
608
644
 
609
645
  function ensureTcfLocator() {
610
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
611
- // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
612
- // 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.
613
652
  try {
614
653
  if (!window.__tcfapi && !window.__cmp) return;
654
+
615
655
  const inject = () => {
616
656
  if (document.getElementById('__tcfapiLocator')) return;
617
657
  const f = document.createElement('iframe');
618
658
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
619
659
  (document.body || document.documentElement).appendChild(f);
620
660
  };
661
+
621
662
  inject();
663
+
664
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
622
665
  if (!window.__nbbTcfObs) {
623
- window.__nbbTcfObs = new MutationObserver(inject);
624
- 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 });
625
669
  }
626
670
  } catch (_) {}
627
671
  }
@@ -631,10 +675,10 @@
631
675
  const head = document.head;
632
676
  if (!head) return;
633
677
  for (const [rel, href, cors] of [
634
- ['preconnect', 'https://g.ezoic.net', true ],
635
- ['preconnect', 'https://go.ezoic.net', true ],
636
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
637
- ['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],
638
682
  ['dns-prefetch', 'https://g.ezoic.net', false],
639
683
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
640
684
  ]) {
@@ -648,7 +692,7 @@
648
692
  }
649
693
  }
650
694
 
651
- // ── Bindings ───────────────────────────────────────────────────────────────
695
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
652
696
 
653
697
  function bindNodeBB() {
654
698
  const $ = window.jQuery;
@@ -659,16 +703,19 @@
659
703
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
660
704
  S.pageKey = pageKey();
661
705
  blockedUntil = 0;
662
- muteConsole(); ensureTcfLocator(); warmNetwork();
663
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
706
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
+ getIO(); ensureDomObserver(); requestBurst();
664
708
  });
665
709
 
666
- const burstEvts = [
667
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
710
+ const BURST_EVENTS = [
711
+ 'action:ajaxify.contentLoaded',
712
+ 'action:posts.loaded', 'action:topics.loaded',
668
713
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
669
714
  ].map(e => `${e}.nbbEzoic`).join(' ');
670
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
671
715
 
716
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
+
718
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
672
719
  try {
673
720
  require(['hooks'], hooks => {
674
721
  if (typeof hooks?.on !== 'function') return;