nodebb-plugin-ezoic-infinite 1.7.13 → 1.7.15

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 +159 -215
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.13",
3
+ "version": "1.7.15",
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,55 +1,49 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v23
2
+ * NodeBB Ezoic Infinite Ads — client.js v21
3
3
  *
4
- * Correctifs critiques vs v19
5
- * ───────────────────────────
6
- * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
- * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
- * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
- * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
- * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
- * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
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.
12
8
  *
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.
9
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
+ * la position dans le batch courant.
15
11
  *
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`.
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.
18
18
  *
19
- * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
- * Fix : marge large fixe par device, observer créé une seule fois.
21
- *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
24
- *
25
- * Nettoyage
26
- * ─────────
27
- * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
- * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
- * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
- * - Commentaires internes allégés (code auto-documenté)
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.
31
25
  */
32
26
  (function () {
33
27
  'use strict';
34
28
 
35
29
  // ── Constantes ─────────────────────────────────────────────────────────────
36
30
 
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)
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
53
47
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
48
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
49
 
@@ -60,40 +54,40 @@
60
54
  };
61
55
 
62
56
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
57
+ * Table KIND source de vérité par kindClass.
64
58
  *
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
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)
71
69
  */
72
70
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
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 },
76
74
  };
77
75
 
78
76
  // ── État ───────────────────────────────────────────────────────────────────
79
77
 
80
78
  const S = {
81
- pageKey: null,
82
- cfg: null,
83
-
84
- pools: { topics: [], posts: [], categories: [] },
85
- cursors: { topics: 0, posts: 0, categories: 0 },
86
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
- lastShow: new Map(), // id → timestamp dernier show
88
-
89
- io: null,
90
- domObs: null,
91
- mutGuard: 0, // compteur internalMutation
92
-
93
- inflight: 0,
94
- pending: [],
95
- pendingSet: new Set(),
96
-
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(),
97
91
  runQueued: false,
98
92
  burstActive: false,
99
93
  burstDeadline: 0,
@@ -102,8 +96,11 @@
102
96
  };
103
97
 
104
98
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
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]'));
107
104
 
108
105
  function mutate(fn) {
109
106
  S.mutGuard++;
@@ -121,12 +118,6 @@
121
118
  return S.cfg;
122
119
  }
123
120
 
124
- function initPools(cfg) {
125
- S.pools.topics = parseIds(cfg.placeholderIds);
126
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
127
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
128
- }
129
-
130
121
  function parseIds(raw) {
131
122
  const out = [], seen = new Set();
132
123
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +127,11 @@
136
127
  return out;
137
128
  }
138
129
 
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; } };
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
+ }
145
135
 
146
136
  // ── Page identity ──────────────────────────────────────────────────────────
147
137
 
@@ -165,13 +155,13 @@
165
155
  return 'other';
166
156
  }
167
157
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
158
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
159
 
170
160
  function getPosts() {
171
161
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
162
  if (!el.isConnected) return false;
173
163
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
164
+ const p = el.parentElement?.closest(SEL.post);
175
165
  if (p && p !== el) return false;
176
166
  return el.getAttribute('component') !== 'post/parent';
177
167
  });
@@ -187,36 +177,28 @@
187
177
  );
188
178
  }
189
179
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
180
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
181
 
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;
182
+ function stableId(klass, el) {
183
+ const attr = KIND[klass]?.anchorAttr;
199
184
  if (attr) {
200
185
  const v = el.getAttribute(attr);
201
186
  if (v !== null && v !== '') return v;
202
187
  }
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 (_) {}
188
+ let i = 0;
189
+ for (const s of el.parentElement?.children ?? []) {
190
+ if (s === el) return `i${i}`;
191
+ i++;
192
+ }
211
193
  return 'i0';
212
194
  }
213
195
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
196
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
197
 
216
- function findWrap(anchorKey) {
198
+ function findWrap(key) {
217
199
  try {
218
200
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
201
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
202
  );
221
203
  } catch (_) { return null; }
222
204
  }
@@ -226,7 +208,7 @@
226
208
  function pickId(poolKey) {
227
209
  const pool = S.pools[poolKey];
228
210
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
211
+ const i = S.cursors[poolKey] % pool.length;
230
212
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
213
  const id = pool[i];
232
214
  if (!S.mountedIds.has(id)) return id;
@@ -237,7 +219,7 @@
237
219
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
220
 
239
221
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
222
+ const w = document.createElement('div');
241
223
  w.className = `${WRAP_CLASS} ${klass}`;
242
224
  w.setAttribute(A_ANCHOR, key);
243
225
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +233,10 @@
251
233
  }
252
234
 
253
235
  function insertAfter(el, id, klass, key) {
254
- if (!el?.insertAdjacentElement) return null;
255
- if (findWrap(key)) return null; // ancre déjà présente
256
- if (S.mountedIds.has(id)) return null; // id déjà monté
257
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
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;
258
240
  const w = makeWrap(id, klass, key);
259
241
  mutate(() => el.insertAdjacentElement('afterend', w));
260
242
  S.mountedIds.add(id);
@@ -263,15 +245,12 @@
263
245
 
264
246
  function dropWrap(w) {
265
247
  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);
266
252
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
253
  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 (_) {}
275
254
  w.remove();
276
255
  } catch (_) {}
277
256
  }
@@ -279,70 +258,55 @@
279
258
  // ── Prune ──────────────────────────────────────────────────────────────────
280
259
 
281
260
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
261
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
283
262
  *
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é).
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.
291
269
  */
292
270
  function pruneOrphans(klass) {
293
271
  const meta = KIND[klass];
294
272
  if (!meta) return;
295
273
 
296
- // baseTag déduit du sélecteur : 'li' pour topics/catégories, '' pour posts.
297
- // Pour les posts (baseTag=''), on cherche juste [data-pid="X"] sans préfixe
298
- // c'est correct car data-pid est unique dans le DOM.
299
- const baseTag = meta.sel.split('[')[0];
300
-
301
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
302
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
303
- if (isFilled(w)) return; // jamais supprimer un wrap rempli
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;
304
277
 
305
278
  const key = w.getAttribute(A_ANCHOR) ?? '';
306
279
  const sid = key.slice(klass.length + 1);
307
- if (!sid) { mutate(() => dropWrap(w)); return; }
280
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
308
281
 
309
- const anchorEl = document.querySelector(
310
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
311
- );
282
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
283
+ const anchorEl = document.querySelector(sel);
312
284
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
313
- });
285
+ }
314
286
  }
315
287
 
316
288
  // ── Decluster ──────────────────────────────────────────────────────────────
317
289
 
318
290
  /**
319
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
320
- * Règles absolues :
321
- * - Jamais supprimer un wrap filled (pub affichée)
322
- * - Jamais supprimer un wrap < FILL_GRACE_MS depuis création OU depuis showAds
323
- * (le fill Ezoic est async : l'enchère SSP peut prendre plusieurs secondes
324
- * après showAds, et showAds lui-même peut arriver bien après l'injection)
325
- * - Si les deux sont filled → on ne touche rien
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).
326
294
  */
327
295
  function decluster(klass) {
328
296
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
329
- if (isFilled(w)) continue; // filled = intouchable
330
- const wCreated = parseInt(w.getAttribute(A_CREATED) || '0', 10);
331
- if (ts() - wCreated < FILL_GRACE_MS) continue; // trop récent
332
297
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
333
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue; // showAds récent
298
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
334
299
 
335
300
  let prev = w.previousElementSibling, steps = 0;
336
301
  while (prev && steps++ < 3) {
337
302
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
338
- if (isFilled(prev)) break; // précédent filled = intouchable
339
- const pCreated = parseInt(prev.getAttribute(A_CREATED) || '0', 10);
340
- if (ts() - pCreated < FILL_GRACE_MS) break;
303
+
341
304
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
342
305
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
343
306
 
344
- // Les deux vides et hors grâce → supprimer le courant
345
- mutate(() => dropWrap(w));
307
+ if (!isFilled(w)) mutate(() => dropWrap(w));
308
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
309
+ // les deux remplis → on ne touche pas
346
310
  break;
347
311
  }
348
312
  }
@@ -352,14 +316,16 @@
352
316
 
353
317
  /**
354
318
  * Ordinal 0-based pour le calcul de l'intervalle.
355
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
356
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
319
+ * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
320
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
357
321
  */
358
322
  function ordinal(klass, el) {
359
- const di = el.getAttribute('data-index');
360
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
361
- // Fallback positionnel — filtre par sélecteur complet pour éviter le bug
362
- // baseTag='' (posts) `:scope > ` sans tag ne fonctionne pas.
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
363
329
  const fullSel = KIND[klass]?.sel ?? '';
364
330
  let i = 0;
365
331
  for (const s of el.parentElement?.children ?? []) {
@@ -374,20 +340,18 @@
374
340
  let inserted = 0;
375
341
 
376
342
  for (const el of items) {
377
- if (inserted >= MAX_INSERTS_PER_RUN) break;
343
+ if (inserted >= MAX_INSERTS_RUN) break;
378
344
  if (!el?.isConnected) continue;
379
345
 
380
- const ord = ordinal(klass, el);
381
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
382
- if (!isTarget) continue;
383
-
346
+ const ord = ordinal(klass, el);
347
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
384
348
  if (adjacentWrap(el)) continue;
385
349
 
386
- const key = makeAnchorKey(klass, el);
387
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
350
+ const key = anchorKey(klass, el);
351
+ if (findWrap(key)) continue;
388
352
 
389
353
  const id = pickId(poolKey);
390
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
354
+ if (!id) continue;
391
355
 
392
356
  const w = insertAfter(el, id, klass, key);
393
357
  if (w) { observePh(id); inserted++; }
@@ -399,7 +363,6 @@
399
363
 
400
364
  function getIO() {
401
365
  if (S.io) return S.io;
402
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
403
366
  try {
404
367
  S.io = new IntersectionObserver(entries => {
405
368
  for (const e of entries) {
@@ -408,7 +371,7 @@
408
371
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
409
372
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
410
373
  }
411
- }, { root: null, rootMargin: margin, threshold: 0 });
374
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
412
375
  } catch (_) { S.io = null; }
413
376
  return S.io;
414
377
  }
@@ -459,7 +422,6 @@
459
422
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
460
423
  S.lastShow.set(id, t);
461
424
 
462
- // Horodater le show sur le wrap pour grace period + emptyCheck
463
425
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
464
426
 
465
427
  window.ezstandalone = window.ezstandalone || {};
@@ -480,7 +442,6 @@
480
442
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
481
443
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
482
444
  if (!wrap || !ph?.isConnected) return;
483
- // Un show plus récent → ne pas toucher
484
445
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
485
446
  wrap.classList.toggle('is-empty', !isFilled(ph));
486
447
  } catch (_) {}
@@ -499,7 +460,7 @@
499
460
  const orig = ez.showAds.bind(ez);
500
461
  ez.showAds = function (...args) {
501
462
  if (isBlocked()) return;
502
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
463
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
503
464
  const seen = new Set();
504
465
  for (const v of ids) {
505
466
  const id = parseInt(v, 10);
@@ -518,7 +479,7 @@
518
479
  }
519
480
  }
520
481
 
521
- // ── Core run ───────────────────────────────────────────────────────────────
482
+ // ── Core ───────────────────────────────────────────────────────────────────
522
483
 
523
484
  async function runCore() {
524
485
  if (isBlocked()) return 0;
@@ -533,10 +494,9 @@
533
494
 
534
495
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
535
496
  if (!normBool(cfgEnable)) return 0;
536
- const items = getItems();
537
497
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
538
498
  pruneOrphans(klass);
539
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
499
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
540
500
  if (n) decluster(klass);
541
501
  return n;
542
502
  };
@@ -549,14 +509,13 @@
549
509
  'ezoic-ad-between', getTopics,
550
510
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
551
511
  );
552
- if (kind === 'categories') return exec(
512
+ return exec(
553
513
  'ezoic-ad-categories', getCategories,
554
514
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
555
515
  );
556
- return 0;
557
516
  }
558
517
 
559
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
518
+ // ── Scheduler ──────────────────────────────────────────────────────────────
560
519
 
561
520
  function scheduleRun(cb) {
562
521
  if (S.runQueued) return;
@@ -566,7 +525,7 @@
566
525
  if (S.pageKey && pageKey() !== S.pageKey) return;
567
526
  let n = 0;
568
527
  try { n = await runCore(); } catch (_) {}
569
- try { cb?.(n); } catch (_) {}
528
+ cb?.(n);
570
529
  });
571
530
  }
572
531
 
@@ -574,10 +533,8 @@
574
533
  if (isBlocked()) return;
575
534
  const t = ts();
576
535
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
577
- S.lastBurstTs = t;
578
-
579
- const pk = pageKey();
580
- S.pageKey = pk;
536
+ S.lastBurstTs = t;
537
+ S.pageKey = pageKey();
581
538
  S.burstDeadline = t + 2000;
582
539
 
583
540
  if (S.burstActive) return;
@@ -585,7 +542,7 @@
585
542
  S.burstCount = 0;
586
543
 
587
544
  const step = () => {
588
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
545
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
589
546
  S.burstActive = false; return;
590
547
  }
591
548
  S.burstCount++;
@@ -597,7 +554,7 @@
597
554
  step();
598
555
  }
599
556
 
600
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
557
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
601
558
 
602
559
  function cleanup() {
603
560
  blockedUntil = ts() + 1500;
@@ -614,19 +571,17 @@
614
571
  S.runQueued = false;
615
572
  }
616
573
 
617
- // ── DOM Observer ───────────────────────────────────────────────────────────
574
+ // ── MutationObserver ───────────────────────────────────────────────────────
618
575
 
619
576
  function ensureDomObserver() {
620
577
  if (S.domObs) return;
578
+ const allSel = [SEL.post, SEL.topic, SEL.category];
621
579
  S.domObs = new MutationObserver(muts => {
622
580
  if (S.mutGuard > 0 || isBlocked()) return;
623
581
  for (const m of muts) {
624
- if (!m.addedNodes?.length) continue;
625
582
  for (const n of m.addedNodes) {
626
583
  if (n.nodeType !== 1) continue;
627
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
628
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
629
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
584
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
630
585
  requestBurst(); return;
631
586
  }
632
587
  }
@@ -652,29 +607,21 @@
652
607
  }
653
608
 
654
609
  function ensureTcfLocator() {
655
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
656
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
657
- // iframe du DOM (vidage partiel du body), ce qui provoque :
658
- // "Cannot read properties of null (reading 'postMessage')"
659
- // "Cannot set properties of null (setting 'addtlConsent')"
660
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
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.
661
613
  try {
662
614
  if (!window.__tcfapi && !window.__cmp) return;
663
-
664
615
  const inject = () => {
665
616
  if (document.getElementById('__tcfapiLocator')) return;
666
617
  const f = document.createElement('iframe');
667
618
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
668
619
  (document.body || document.documentElement).appendChild(f);
669
620
  };
670
-
671
621
  inject();
672
-
673
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
674
622
  if (!window.__nbbTcfObs) {
675
- window.__nbbTcfObs = new MutationObserver(() => inject());
676
- window.__nbbTcfObs.observe(document.documentElement,
677
- { childList: true, subtree: true });
623
+ window.__nbbTcfObs = new MutationObserver(inject);
624
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
678
625
  }
679
626
  } catch (_) {}
680
627
  }
@@ -684,10 +631,10 @@
684
631
  const head = document.head;
685
632
  if (!head) return;
686
633
  for (const [rel, href, cors] of [
687
- ['preconnect', 'https://g.ezoic.net', true],
688
- ['preconnect', 'https://go.ezoic.net', true],
689
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
690
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
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 ],
691
638
  ['dns-prefetch', 'https://g.ezoic.net', false],
692
639
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
693
640
  ]) {
@@ -701,7 +648,7 @@
701
648
  }
702
649
  }
703
650
 
704
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
651
+ // ── Bindings ───────────────────────────────────────────────────────────────
705
652
 
706
653
  function bindNodeBB() {
707
654
  const $ = window.jQuery;
@@ -712,19 +659,16 @@
712
659
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
713
660
  S.pageKey = pageKey();
714
661
  blockedUntil = 0;
715
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
716
- getIO(); ensureDomObserver(); requestBurst();
662
+ muteConsole(); ensureTcfLocator(); warmNetwork();
663
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
717
664
  });
718
665
 
719
- const BURST_EVENTS = [
720
- 'action:ajaxify.contentLoaded',
721
- 'action:posts.loaded', 'action:topics.loaded',
666
+ const burstEvts = [
667
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
722
668
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
723
669
  ].map(e => `${e}.nbbEzoic`).join(' ');
670
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
724
671
 
725
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
726
-
727
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
728
672
  try {
729
673
  require(['hooks'], hooks => {
730
674
  if (typeof hooks?.on !== 'function') return;