nodebb-plugin-ezoic-infinite 1.7.22 → 1.7.23

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 +157 -244
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.22",
3
+ "version": "1.7.23",
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,66 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v29
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
+ * – decluster : isFilled en premier, A_CREATED grace period (FILL_GRACE_MS).
22
+ * Tentative recyclage d'id (v25) → cause exactement le même bug (wraps
23
+ * déplacés laissent les positions originales libres → réinjection en haut).
21
24
  *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
25
+ * v26 Suppression définitive du recyclage d'id.
26
+ * KIND simplifié.
24
27
  *
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é)
28
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB).
29
+ *
30
+ * v28 decluster supprimé. pruneOrphans supprimé (v27). Wraps persistants sur session.
31
+ *
32
+ * v29 Fix ancrage topics : data-index → data-tid.
33
+ * data-index = position relative dans le batch NodeBB, pas un ID stable.
34
+ * Lors du scroll infini, les nouveaux batches démarrent à data-index=0,
35
+ * ce qui créait des collisions de clés d'ancrage → mauvaise déduplication
36
+ * → wraps non injectés sur les nouveaux topics, puis réinjection en haut.
37
+ * Fix : anchorAttr = data-tid (stable et unique par topic).
38
+ * ordinalAttr reste data-index pour le calcul de l'intervalle.
39
+ * Analyse : decluster appelait dropWrap() qui faisait S.mountedIds.delete(id).
40
+ * Un wrap vide adjacent à un autre wrap → supprimé → id libéré → réinjecté
41
+ * en haut au prochain scroll. Exactement le bug observé.
42
+ * Les deux mécanismes de nettoyage actif (pruneOrphans + decluster) sont
43
+ * maintenant retirés. Seul cleanup() à la navigation peut supprimer des wraps.
31
44
  */
32
45
  (function () {
33
46
  'use strict';
34
47
 
35
48
  // ── Constantes ─────────────────────────────────────────────────────────────
36
49
 
37
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
- const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
-
44
- const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
- const FILL_GRACE_MS = 25_000; // fenêtre post-showAds où l'on ne decluster pas
46
- const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
- const MAX_INSERTS_PER_RUN = 6;
48
- const MAX_INFLIGHT = 4;
49
- const SHOW_THROTTLE_MS = 900;
50
- const BURST_COOLDOWN_MS = 200;
51
-
52
- // Marges IO larges et fixes (pas de reconstruction d'observer)
50
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
51
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
52
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
53
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
54
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
55
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
56
+
57
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
58
+ const MAX_INSERTS_RUN = 6;
59
+ const MAX_INFLIGHT = 4;
60
+ const SHOW_THROTTLE_MS = 900;
61
+ const BURST_COOLDOWN_MS = 200;
62
+
63
+ // IO : marges larges fixes — une seule instance, jamais recréée
53
64
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
65
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
66
 
@@ -60,40 +71,37 @@
60
71
  };
61
72
 
62
73
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
74
+ * Table KIND source de vérité par kindClass.
64
75
  *
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
76
+ * sel : sélecteur CSS complet
77
+ * baseTag : préfixe tag pour querySelector d'ancre
78
+ * (vide pour posts car sélecteur commence par '[')
79
+ * anchorAttr : attribut DOM stable clé unique du wrap
80
+ * data-pid posts / data-index topics / data-cid catégories
81
+ * ordinalAttr: attribut 0-based pour calcul de l'intervalle
82
+ * null → fallback positionnel (catégories)
71
83
  */
72
84
  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' },
85
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
86
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-tid', ordinalAttr: 'data-index' },
87
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
88
  };
77
89
 
78
90
  // ── État ───────────────────────────────────────────────────────────────────
79
91
 
80
92
  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
-
93
+ pageKey: null,
94
+ cfg: null,
95
+ pools: { topics: [], posts: [], categories: [] },
96
+ cursors: { topics: 0, posts: 0, categories: 0 },
97
+ mountedIds: new Set(),
98
+ lastShow: new Map(),
99
+ io: null,
100
+ domObs: null,
101
+ mutGuard: 0,
102
+ inflight: 0,
103
+ pending: [],
104
+ pendingSet: new Set(),
97
105
  runQueued: false,
98
106
  burstActive: false,
99
107
  burstDeadline: 0,
@@ -102,8 +110,11 @@
102
110
  };
103
111
 
104
112
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
113
+ const ts = () => Date.now();
114
+ const isBlocked = () => ts() < blockedUntil;
115
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
116
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
117
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
118
 
108
119
  function mutate(fn) {
109
120
  S.mutGuard++;
@@ -121,12 +132,6 @@
121
132
  return S.cfg;
122
133
  }
123
134
 
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
135
  function parseIds(raw) {
131
136
  const out = [], seen = new Set();
132
137
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +141,11 @@
136
141
  return out;
137
142
  }
138
143
 
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; } };
144
+ function initPools(cfg) {
145
+ S.pools.topics = parseIds(cfg.placeholderIds);
146
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
147
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
148
+ }
145
149
 
146
150
  // ── Page identity ──────────────────────────────────────────────────────────
147
151
 
@@ -165,13 +169,13 @@
165
169
  return 'other';
166
170
  }
167
171
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
172
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
173
 
170
174
  function getPosts() {
171
175
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
176
  if (!el.isConnected) return false;
173
177
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
178
+ const p = el.parentElement?.closest(SEL.post);
175
179
  if (p && p !== el) return false;
176
180
  return el.getAttribute('component') !== 'post/parent';
177
181
  });
@@ -187,36 +191,28 @@
187
191
  );
188
192
  }
189
193
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
194
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
195
 
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;
196
+ function stableId(klass, el) {
197
+ const attr = KIND[klass]?.anchorAttr;
199
198
  if (attr) {
200
199
  const v = el.getAttribute(attr);
201
200
  if (v !== null && v !== '') return v;
202
201
  }
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 (_) {}
202
+ let i = 0;
203
+ for (const s of el.parentElement?.children ?? []) {
204
+ if (s === el) return `i${i}`;
205
+ i++;
206
+ }
211
207
  return 'i0';
212
208
  }
213
209
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
210
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
211
 
216
- function findWrap(anchorKey) {
212
+ function findWrap(key) {
217
213
  try {
218
214
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
215
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
216
  );
221
217
  } catch (_) { return null; }
222
218
  }
@@ -226,7 +222,7 @@
226
222
  function pickId(poolKey) {
227
223
  const pool = S.pools[poolKey];
228
224
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
225
+ const i = S.cursors[poolKey] % pool.length;
230
226
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
227
  const id = pool[i];
232
228
  if (!S.mountedIds.has(id)) return id;
@@ -237,7 +233,7 @@
237
233
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
234
 
239
235
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
236
+ const w = document.createElement('div');
241
237
  w.className = `${WRAP_CLASS} ${klass}`;
242
238
  w.setAttribute(A_ANCHOR, key);
243
239
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +247,10 @@
251
247
  }
252
248
 
253
249
  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;
250
+ if (!el?.insertAdjacentElement) return null;
251
+ if (findWrap(key)) return null;
252
+ if (S.mountedIds.has(id)) return null;
253
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
254
  const w = makeWrap(id, klass, key);
259
255
  mutate(() => el.insertAdjacentElement('afterend', w));
260
256
  S.mountedIds.add(id);
@@ -263,100 +259,44 @@
263
259
 
264
260
  function dropWrap(w) {
265
261
  try {
262
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
263
+ if (ph instanceof Element) S.io?.unobserve(ph);
266
264
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
265
  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
266
  w.remove();
276
267
  } catch (_) {}
277
268
  }
278
269
 
279
- // ── Prune ──────────────────────────────────────────────────────────────────
280
-
281
- /**
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
289
- *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
291
- */
292
- function pruneOrphans(klass) {
293
- const meta = KIND[klass];
294
- if (!meta) return;
295
-
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;
300
-
301
- 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; }
304
-
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
308
- if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
- });
310
- }
311
-
312
- // ── Decluster ──────────────────────────────────────────────────────────────
270
+ // ── Prune : désactivé ─────────────────────────────────────────────────────
271
+ //
272
+ // pruneOrphans() a été supprimé car il causait le bug "pubs en haut".
273
+ // NodeBB virtualise les posts hors viewport les ancres disparaissent du DOM
274
+ // temporairement → pruneOrphans supprimait les wraps → scroll retour → les
275
+ // ancres revenaient injectBetween réinjectait tout en haut.
276
+ //
277
+ // Les wraps ne sont supprimés que par cleanup() à chaque navigation ajaxify.
278
+ // decluster() et pruneOrphans() sont désactivés — voir v28.
313
279
 
314
- /**
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.
318
- */
319
- function decluster(klass) {
320
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
322
- const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
-
325
- let prev = w.previousElementSibling, steps = 0;
326
- while (prev && steps++ < 3) {
327
- if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
329
- 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
331
-
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
334
- break;
335
- }
336
- }
337
- }
338
280
 
339
281
  // ── Injection ──────────────────────────────────────────────────────────────
340
282
 
341
283
  /**
342
284
  * 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).
285
+ * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
286
+ * Catégories : ordinalAttr = null fallback positionnel.
345
287
  */
346
288
  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 (_) {}
289
+ const attr = KIND[klass]?.ordinalAttr;
290
+ if (attr) {
291
+ const v = el.getAttribute(attr);
292
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
293
+ }
294
+ const fullSel = KIND[klass]?.sel ?? '';
295
+ let i = 0;
296
+ for (const s of el.parentElement?.children ?? []) {
297
+ if (s === el) return i;
298
+ if (!fullSel || s.matches?.(fullSel)) i++;
299
+ }
360
300
  return 0;
361
301
  }
362
302
 
@@ -365,20 +305,18 @@
365
305
  let inserted = 0;
366
306
 
367
307
  for (const el of items) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
308
+ if (inserted >= MAX_INSERTS_RUN) break;
369
309
  if (!el?.isConnected) continue;
370
310
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
374
-
311
+ const ord = ordinal(klass, el);
312
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
313
  if (adjacentWrap(el)) continue;
376
314
 
377
- const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
315
+ const key = anchorKey(klass, el);
316
+ if (findWrap(key)) continue;
379
317
 
380
318
  const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants besoin d'id)
319
+ if (!id) continue; // pool épuisé : tous les ids sont montés, on passe au suivant
382
320
 
383
321
  const w = insertAfter(el, id, klass, key);
384
322
  if (w) { observePh(id); inserted++; }
@@ -390,7 +328,6 @@
390
328
 
391
329
  function getIO() {
392
330
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
331
  try {
395
332
  S.io = new IntersectionObserver(entries => {
396
333
  for (const e of entries) {
@@ -399,7 +336,7 @@
399
336
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
337
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
338
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
339
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
340
  } catch (_) { S.io = null; }
404
341
  return S.io;
405
342
  }
@@ -450,7 +387,6 @@
450
387
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
388
  S.lastShow.set(id, t);
452
389
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
390
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
391
 
456
392
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +407,6 @@
471
407
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
408
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
409
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
410
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
411
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
412
  } catch (_) {}
@@ -490,7 +425,7 @@
490
425
  const orig = ez.showAds.bind(ez);
491
426
  ez.showAds = function (...args) {
492
427
  if (isBlocked()) return;
493
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
428
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
429
  const seen = new Set();
495
430
  for (const v of ids) {
496
431
  const id = parseInt(v, 10);
@@ -509,7 +444,7 @@
509
444
  }
510
445
  }
511
446
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
447
+ // ── Core ───────────────────────────────────────────────────────────────────
513
448
 
514
449
  async function runCore() {
515
450
  if (isBlocked()) return 0;
@@ -524,11 +459,8 @@
524
459
 
525
460
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
461
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
462
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
- pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
531
- if (n) decluster(klass);
463
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
532
464
  return n;
533
465
  };
534
466
 
@@ -540,14 +472,13 @@
540
472
  'ezoic-ad-between', getTopics,
541
473
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
474
  );
543
- if (kind === 'categories') return exec(
475
+ return exec(
544
476
  'ezoic-ad-categories', getCategories,
545
477
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
478
  );
547
- return 0;
548
479
  }
549
480
 
550
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
481
+ // ── Scheduler ──────────────────────────────────────────────────────────────
551
482
 
552
483
  function scheduleRun(cb) {
553
484
  if (S.runQueued) return;
@@ -565,10 +496,8 @@
565
496
  if (isBlocked()) return;
566
497
  const t = ts();
567
498
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
- S.lastBurstTs = t;
569
-
570
- const pk = pageKey();
571
- S.pageKey = pk;
499
+ S.lastBurstTs = t;
500
+ S.pageKey = pageKey();
572
501
  S.burstDeadline = t + 2000;
573
502
 
574
503
  if (S.burstActive) return;
@@ -576,7 +505,7 @@
576
505
  S.burstCount = 0;
577
506
 
578
507
  const step = () => {
579
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
508
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
509
  S.burstActive = false; return;
581
510
  }
582
511
  S.burstCount++;
@@ -588,7 +517,7 @@
588
517
  step();
589
518
  }
590
519
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
520
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
521
 
593
522
  function cleanup() {
594
523
  blockedUntil = ts() + 1500;
@@ -605,19 +534,17 @@
605
534
  S.runQueued = false;
606
535
  }
607
536
 
608
- // ── DOM Observer ───────────────────────────────────────────────────────────
537
+ // ── MutationObserver ───────────────────────────────────────────────────────
609
538
 
610
539
  function ensureDomObserver() {
611
540
  if (S.domObs) return;
541
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
542
  S.domObs = new MutationObserver(muts => {
613
543
  if (S.mutGuard > 0 || isBlocked()) return;
614
544
  for (const m of muts) {
615
- if (!m.addedNodes?.length) continue;
616
545
  for (const n of m.addedNodes) {
617
546
  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)) {
547
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
621
548
  requestBurst(); return;
622
549
  }
623
550
  }
@@ -643,29 +570,18 @@
643
570
  }
644
571
 
645
572
  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
573
  try {
653
574
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
575
  const inject = () => {
656
576
  if (document.getElementById('__tcfapiLocator')) return;
657
577
  const f = document.createElement('iframe');
658
578
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
579
  (document.body || document.documentElement).appendChild(f);
660
580
  };
661
-
662
581
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
582
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
583
+ window.__nbbTcfObs = new MutationObserver(inject);
584
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
585
  }
670
586
  } catch (_) {}
671
587
  }
@@ -675,10 +591,10 @@
675
591
  const head = document.head;
676
592
  if (!head) return;
677
593
  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],
594
+ ['preconnect', 'https://g.ezoic.net', true ],
595
+ ['preconnect', 'https://go.ezoic.net', true ],
596
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
597
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
682
598
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
599
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
600
  ]) {
@@ -692,7 +608,7 @@
692
608
  }
693
609
  }
694
610
 
695
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
611
+ // ── Bindings ───────────────────────────────────────────────────────────────
696
612
 
697
613
  function bindNodeBB() {
698
614
  const $ = window.jQuery;
@@ -703,19 +619,16 @@
703
619
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
620
  S.pageKey = pageKey();
705
621
  blockedUntil = 0;
706
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
- getIO(); ensureDomObserver(); requestBurst();
622
+ muteConsole(); ensureTcfLocator(); warmNetwork();
623
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
708
624
  });
709
625
 
710
- const BURST_EVENTS = [
711
- 'action:ajaxify.contentLoaded',
712
- 'action:posts.loaded', 'action:topics.loaded',
626
+ const burstEvts = [
627
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
713
628
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
629
  ].map(e => `${e}.nbbEzoic`).join(' ');
630
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
715
631
 
716
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
-
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
632
  try {
720
633
  require(['hooks'], hooks => {
721
634
  if (typeof hooks?.on !== 'function') return;