nodebb-plugin-ezoic-infinite 1.7.18 → 1.7.20

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 +164 -211
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.18",
3
+ "version": "1.7.20",
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,54 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v26
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. 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.
19
+ * v25 Table KIND unifiée avec baseTag + ordinalAttr.
20
+ * Fix scroll-up / virtualisation NodeBB :
21
+ * – pruneOrphans : PRUNE_STABLE_MS = 45 s, isFilled guard en premier.
22
+ * – decluster : isFilled en premier, A_CREATED grace period (FILL_GRACE_MS).
23
+ * Tentative recyclage d'id (v25) → cause exactement le même bug (wraps
24
+ * déplacés laissent les positions originales libres → réinjection en haut).
21
25
  *
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é)
26
+ * v26 Suppression définitive du recyclage d'id.
27
+ * Pool épuisé = on attend que pruneOrphans libère des ids (> 45 s hors DOM).
28
+ * Suppression de scrollDir, pickRecyclableWrap, moveWrapAfter.
29
+ * KIND simplifié : retrait du flag recyclable inutile.
31
30
  */
32
31
  (function () {
33
32
  'use strict';
34
33
 
35
34
  // ── Constantes ─────────────────────────────────────────────────────────────
36
35
 
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)
36
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
37
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
38
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
39
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
40
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
41
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
42
+
43
+ const PRUNE_STABLE_MS = 45_000; // délai avant pruning (évite faux-orphelins scroll-up)
44
+ const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
45
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
46
+ const MAX_INSERTS_RUN = 6;
47
+ const MAX_INFLIGHT = 4;
48
+ const SHOW_THROTTLE_MS = 900;
49
+ const BURST_COOLDOWN_MS = 200;
50
+
51
+ // IO : marges larges fixes une seule instance, jamais recréée
53
52
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
53
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
54
 
@@ -60,40 +59,37 @@
60
59
  };
61
60
 
62
61
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
62
+ * Table KIND source de vérité par kindClass.
64
63
  *
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
64
+ * sel : sélecteur CSS complet
65
+ * baseTag : préfixe tag pour querySelector d'ancre
66
+ * (vide pour posts car sélecteur commence par '[')
67
+ * anchorAttr : attribut DOM stable clé unique du wrap
68
+ * data-pid posts / data-index topics / data-cid catégories
69
+ * ordinalAttr: attribut 0-based pour calcul de l'intervalle
70
+ * null → fallback positionnel (catégories)
71
71
  */
72
72
  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' },
73
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
76
  };
77
77
 
78
78
  // ── État ───────────────────────────────────────────────────────────────────
79
79
 
80
80
  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
-
81
+ pageKey: null,
82
+ cfg: null,
83
+ pools: { topics: [], posts: [], categories: [] },
84
+ cursors: { topics: 0, posts: 0, categories: 0 },
85
+ mountedIds: new Set(),
86
+ lastShow: new Map(),
87
+ io: null,
88
+ domObs: null,
89
+ mutGuard: 0,
90
+ inflight: 0,
91
+ pending: [],
92
+ pendingSet: new Set(),
97
93
  runQueued: false,
98
94
  burstActive: false,
99
95
  burstDeadline: 0,
@@ -102,8 +98,11 @@
102
98
  };
103
99
 
104
100
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
101
+ const ts = () => Date.now();
102
+ const isBlocked = () => ts() < blockedUntil;
103
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
104
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
105
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
106
 
108
107
  function mutate(fn) {
109
108
  S.mutGuard++;
@@ -121,12 +120,6 @@
121
120
  return S.cfg;
122
121
  }
123
122
 
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
123
  function parseIds(raw) {
131
124
  const out = [], seen = new Set();
132
125
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +129,11 @@
136
129
  return out;
137
130
  }
138
131
 
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; } };
132
+ function initPools(cfg) {
133
+ S.pools.topics = parseIds(cfg.placeholderIds);
134
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
135
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
136
+ }
145
137
 
146
138
  // ── Page identity ──────────────────────────────────────────────────────────
147
139
 
@@ -165,13 +157,13 @@
165
157
  return 'other';
166
158
  }
167
159
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
160
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
161
 
170
162
  function getPosts() {
171
163
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
164
  if (!el.isConnected) return false;
173
165
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
166
+ const p = el.parentElement?.closest(SEL.post);
175
167
  if (p && p !== el) return false;
176
168
  return el.getAttribute('component') !== 'post/parent';
177
169
  });
@@ -187,36 +179,28 @@
187
179
  );
188
180
  }
189
181
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
182
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
183
 
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
+ function stableId(klass, el) {
185
+ const attr = KIND[klass]?.anchorAttr;
199
186
  if (attr) {
200
187
  const v = el.getAttribute(attr);
201
188
  if (v !== null && v !== '') return v;
202
189
  }
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 (_) {}
190
+ let i = 0;
191
+ for (const s of el.parentElement?.children ?? []) {
192
+ if (s === el) return `i${i}`;
193
+ i++;
194
+ }
211
195
  return 'i0';
212
196
  }
213
197
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
198
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
199
 
216
- function findWrap(anchorKey) {
200
+ function findWrap(key) {
217
201
  try {
218
202
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
203
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
204
  );
221
205
  } catch (_) { return null; }
222
206
  }
@@ -226,7 +210,7 @@
226
210
  function pickId(poolKey) {
227
211
  const pool = S.pools[poolKey];
228
212
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
213
+ const i = S.cursors[poolKey] % pool.length;
230
214
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
215
  const id = pool[i];
232
216
  if (!S.mountedIds.has(id)) return id;
@@ -237,7 +221,7 @@
237
221
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
222
 
239
223
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
224
+ const w = document.createElement('div');
241
225
  w.className = `${WRAP_CLASS} ${klass}`;
242
226
  w.setAttribute(A_ANCHOR, key);
243
227
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +235,10 @@
251
235
  }
252
236
 
253
237
  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;
238
+ if (!el?.insertAdjacentElement) return null;
239
+ if (findWrap(key)) return null;
240
+ if (S.mountedIds.has(id)) return null;
241
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
242
  const w = makeWrap(id, klass, key);
259
243
  mutate(() => el.insertAdjacentElement('afterend', w));
260
244
  S.mountedIds.add(id);
@@ -263,15 +247,10 @@
263
247
 
264
248
  function dropWrap(w) {
265
249
  try {
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,58 +258,59 @@
279
258
  // ── Prune ──────────────────────────────────────────────────────────────────
280
259
 
281
260
  /**
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
261
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
289
262
  *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
263
+ * isFilled en premier : un wrap rempli n'est JAMAIS supprimé.
264
+ * PRUNE_STABLE_MS (45 s) : pendant cette fenêtre une ancre absente est
265
+ * considérée comme virtualisée par NodeBB (scroll up), pas comme orphelin réel.
291
266
  */
292
267
  function pruneOrphans(klass) {
293
268
  const meta = KIND[klass];
294
269
  if (!meta) return;
295
270
 
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;
271
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
272
+ if (isFilled(w)) continue;
273
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < PRUNE_STABLE_MS) continue;
300
274
 
301
275
  const key = w.getAttribute(A_ANCHOR) ?? '';
302
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
303
- if (!sid) { mutate(() => dropWrap(w)); return; }
276
+ const sid = key.slice(klass.length + 1);
277
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
304
278
 
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
279
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
280
+ const anchorEl = document.querySelector(sel);
308
281
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
- });
282
+ }
310
283
  }
311
284
 
312
285
  // ── Decluster ──────────────────────────────────────────────────────────────
313
286
 
314
287
  /**
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.
288
+ * Deux wraps adjacents → supprimer le courant s'il est vide et hors grâce.
289
+ * Guards dans l'ordre :
290
+ * 1. isFilled(w) → jamais toucher un wrap rempli
291
+ * 2. A_CREATED < FILL_GRACE → wrap trop récent (pas encore showAds'd)
292
+ * 3. A_SHOWN grace → fill en cours
293
+ * 4. isFilled(prev) → voisin rempli, intouchable → break
294
+ * 5. A_CREATED prev grace → voisin trop récent → break
295
+ * 6. A_SHOWN prev grace → break
296
+ * → les deux vides et hors grâce : supprimer le courant
318
297
  */
319
298
  function decluster(klass) {
320
299
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
300
+ if (isFilled(w)) continue;
301
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) continue;
322
302
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
303
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
304
 
325
305
  let prev = w.previousElementSibling, steps = 0;
326
306
  while (prev && steps++ < 3) {
327
307
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
308
+ if (isFilled(prev)) break;
309
+ if (ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) break;
329
310
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
311
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
331
312
 
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
313
+ mutate(() => dropWrap(w));
334
314
  break;
335
315
  }
336
316
  }
@@ -340,23 +320,21 @@
340
320
 
341
321
  /**
342
322
  * Ordinal 0-based pour le calcul de l'intervalle.
343
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
323
+ * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
324
+ * Catégories : ordinalAttr = null fallback positionnel.
345
325
  */
346
326
  function ordinal(klass, el) {
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 (_) {}
327
+ const attr = KIND[klass]?.ordinalAttr;
328
+ if (attr) {
329
+ const v = el.getAttribute(attr);
330
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
331
+ }
332
+ const fullSel = KIND[klass]?.sel ?? '';
333
+ let i = 0;
334
+ for (const s of el.parentElement?.children ?? []) {
335
+ if (s === el) return i;
336
+ if (!fullSel || s.matches?.(fullSel)) i++;
337
+ }
360
338
  return 0;
361
339
  }
362
340
 
@@ -365,20 +343,18 @@
365
343
  let inserted = 0;
366
344
 
367
345
  for (const el of items) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
346
+ if (inserted >= MAX_INSERTS_RUN) break;
369
347
  if (!el?.isConnected) continue;
370
348
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
374
-
349
+ const ord = ordinal(klass, el);
350
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
351
  if (adjacentWrap(el)) continue;
376
352
 
377
- const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
353
+ const key = anchorKey(klass, el);
354
+ if (findWrap(key)) continue;
379
355
 
380
356
  const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants besoin d'id)
357
+ if (!id) continue; // pool épuisé : on attend que pruneOrphans libère des ids
382
358
 
383
359
  const w = insertAfter(el, id, klass, key);
384
360
  if (w) { observePh(id); inserted++; }
@@ -390,7 +366,6 @@
390
366
 
391
367
  function getIO() {
392
368
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
369
  try {
395
370
  S.io = new IntersectionObserver(entries => {
396
371
  for (const e of entries) {
@@ -399,7 +374,7 @@
399
374
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
375
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
376
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
377
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
378
  } catch (_) { S.io = null; }
404
379
  return S.io;
405
380
  }
@@ -450,7 +425,6 @@
450
425
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
426
  S.lastShow.set(id, t);
452
427
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
428
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
429
 
456
430
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +445,6 @@
471
445
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
446
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
447
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
448
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
449
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
450
  } catch (_) {}
@@ -490,7 +463,7 @@
490
463
  const orig = ez.showAds.bind(ez);
491
464
  ez.showAds = function (...args) {
492
465
  if (isBlocked()) return;
493
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
466
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
467
  const seen = new Set();
495
468
  for (const v of ids) {
496
469
  const id = parseInt(v, 10);
@@ -509,7 +482,7 @@
509
482
  }
510
483
  }
511
484
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
485
+ // ── Core ───────────────────────────────────────────────────────────────────
513
486
 
514
487
  async function runCore() {
515
488
  if (isBlocked()) return 0;
@@ -524,10 +497,9 @@
524
497
 
525
498
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
499
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
500
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
501
  pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
502
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
531
503
  if (n) decluster(klass);
532
504
  return n;
533
505
  };
@@ -540,14 +512,13 @@
540
512
  'ezoic-ad-between', getTopics,
541
513
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
514
  );
543
- if (kind === 'categories') return exec(
515
+ return exec(
544
516
  'ezoic-ad-categories', getCategories,
545
517
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
518
  );
547
- return 0;
548
519
  }
549
520
 
550
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
521
+ // ── Scheduler ──────────────────────────────────────────────────────────────
551
522
 
552
523
  function scheduleRun(cb) {
553
524
  if (S.runQueued) return;
@@ -565,10 +536,8 @@
565
536
  if (isBlocked()) return;
566
537
  const t = ts();
567
538
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
- S.lastBurstTs = t;
569
-
570
- const pk = pageKey();
571
- S.pageKey = pk;
539
+ S.lastBurstTs = t;
540
+ S.pageKey = pageKey();
572
541
  S.burstDeadline = t + 2000;
573
542
 
574
543
  if (S.burstActive) return;
@@ -576,7 +545,7 @@
576
545
  S.burstCount = 0;
577
546
 
578
547
  const step = () => {
579
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
548
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
549
  S.burstActive = false; return;
581
550
  }
582
551
  S.burstCount++;
@@ -588,7 +557,7 @@
588
557
  step();
589
558
  }
590
559
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
560
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
561
 
593
562
  function cleanup() {
594
563
  blockedUntil = ts() + 1500;
@@ -605,19 +574,17 @@
605
574
  S.runQueued = false;
606
575
  }
607
576
 
608
- // ── DOM Observer ───────────────────────────────────────────────────────────
577
+ // ── MutationObserver ───────────────────────────────────────────────────────
609
578
 
610
579
  function ensureDomObserver() {
611
580
  if (S.domObs) return;
581
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
582
  S.domObs = new MutationObserver(muts => {
613
583
  if (S.mutGuard > 0 || isBlocked()) return;
614
584
  for (const m of muts) {
615
- if (!m.addedNodes?.length) continue;
616
585
  for (const n of m.addedNodes) {
617
586
  if (n.nodeType !== 1) continue;
618
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
619
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
620
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
587
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
621
588
  requestBurst(); return;
622
589
  }
623
590
  }
@@ -643,29 +610,18 @@
643
610
  }
644
611
 
645
612
  function ensureTcfLocator() {
646
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
647
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
648
- // iframe du DOM (vidage partiel du body), ce qui provoque :
649
- // "Cannot read properties of null (reading 'postMessage')"
650
- // "Cannot set properties of null (setting 'addtlConsent')"
651
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
652
613
  try {
653
614
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
615
  const inject = () => {
656
616
  if (document.getElementById('__tcfapiLocator')) return;
657
617
  const f = document.createElement('iframe');
658
618
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
619
  (document.body || document.documentElement).appendChild(f);
660
620
  };
661
-
662
621
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
622
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
623
+ window.__nbbTcfObs = new MutationObserver(inject);
624
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
625
  }
670
626
  } catch (_) {}
671
627
  }
@@ -675,10 +631,10 @@
675
631
  const head = document.head;
676
632
  if (!head) return;
677
633
  for (const [rel, href, cors] of [
678
- ['preconnect', 'https://g.ezoic.net', true],
679
- ['preconnect', 'https://go.ezoic.net', true],
680
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
681
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
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 ],
682
638
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
639
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
640
  ]) {
@@ -692,7 +648,7 @@
692
648
  }
693
649
  }
694
650
 
695
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
651
+ // ── Bindings ───────────────────────────────────────────────────────────────
696
652
 
697
653
  function bindNodeBB() {
698
654
  const $ = window.jQuery;
@@ -703,19 +659,16 @@
703
659
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
660
  S.pageKey = pageKey();
705
661
  blockedUntil = 0;
706
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
- getIO(); ensureDomObserver(); requestBurst();
662
+ muteConsole(); ensureTcfLocator(); warmNetwork();
663
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
708
664
  });
709
665
 
710
- const BURST_EVENTS = [
711
- 'action:ajaxify.contentLoaded',
712
- 'action:posts.loaded', 'action:topics.loaded',
666
+ const burstEvts = [
667
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
713
668
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
669
  ].map(e => `${e}.nbbEzoic`).join(' ');
670
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
715
671
 
716
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
-
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
672
  try {
720
673
  require(['hooks'], hooks => {
721
674
  if (typeof hooks?.on !== 'function') return;