nodebb-plugin-ezoic-infinite 1.7.21 → 1.7.22

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 +244 -149
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.21",
3
+ "version": "1.7.22",
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,58 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v28
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. 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
- * 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).
19
+ * [PERF] IntersectionObserver recréé à chaque scroll boost → très coûteux mobile.
20
+ * Fix : marge large fixe par device, observer créé une seule fois.
24
21
  *
25
- * v26 Suppression définitive du recyclage d'id.
26
- * KIND simplifié.
22
+ * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
+ * Fix : 200ms.
27
24
  *
28
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB).
29
- *
30
- * v28 decluster supprimé.
31
- * Analyse : decluster appelait dropWrap() qui faisait S.mountedIds.delete(id).
32
- * Un wrap vide adjacent à un autre wrap → supprimé → id libéré → réinjecté
33
- * en haut au prochain scroll. Exactement le bug observé.
34
- * Les deux mécanismes de nettoyage actif (pruneOrphans + decluster) sont
35
- * maintenant retirés. Seul cleanup() à la navigation peut supprimer des wraps.
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é)
36
31
  */
37
32
  (function () {
38
33
  'use strict';
39
34
 
40
35
  // ── Constantes ─────────────────────────────────────────────────────────────
41
36
 
42
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
43
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
44
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
45
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
46
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
47
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
48
-
49
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
50
- const MAX_INSERTS_RUN = 6;
51
- const MAX_INFLIGHT = 4;
52
- const SHOW_THROTTLE_MS = 900;
53
- const BURST_COOLDOWN_MS = 200;
54
-
55
- // 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 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)
56
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
57
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
58
55
 
@@ -63,37 +60,40 @@
63
60
  };
64
61
 
65
62
  /**
66
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
67
64
  *
68
- * sel : sélecteur CSS complet
69
- * baseTag : préfixe tag pour querySelector d'ancre
70
- * (vide pour posts car sélecteur commence par '[')
71
- * anchorAttr : attribut DOM stable clé unique du wrap
72
- * data-pid posts / data-index topics / data-cid catégories
73
- * ordinalAttr: attribut 0-based pour calcul de l'intervalle
74
- * null → fallback positionnel (catégories)
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
75
71
  */
76
72
  const KIND = {
77
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
78
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
79
- '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' },
80
76
  };
81
77
 
82
78
  // ── État ───────────────────────────────────────────────────────────────────
83
79
 
84
80
  const S = {
85
- pageKey: null,
86
- cfg: null,
87
- pools: { topics: [], posts: [], categories: [] },
88
- cursors: { topics: 0, posts: 0, categories: 0 },
89
- mountedIds: new Set(),
90
- lastShow: new Map(),
91
- io: null,
92
- domObs: null,
93
- mutGuard: 0,
94
- inflight: 0,
95
- pending: [],
96
- 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
+
97
97
  runQueued: false,
98
98
  burstActive: false,
99
99
  burstDeadline: 0,
@@ -102,11 +102,8 @@
102
102
  };
103
103
 
104
104
  let blockedUntil = 0;
105
- const ts = () => Date.now();
106
- const isBlocked = () => ts() < blockedUntil;
107
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
108
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
109
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
110
107
 
111
108
  function mutate(fn) {
112
109
  S.mutGuard++;
@@ -124,6 +121,12 @@
124
121
  return S.cfg;
125
122
  }
126
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
+
127
130
  function parseIds(raw) {
128
131
  const out = [], seen = new Set();
129
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -133,11 +136,12 @@
133
136
  return out;
134
137
  }
135
138
 
136
- function initPools(cfg) {
137
- S.pools.topics = parseIds(cfg.placeholderIds);
138
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
139
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
140
- }
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; } };
141
145
 
142
146
  // ── Page identity ──────────────────────────────────────────────────────────
143
147
 
@@ -161,13 +165,13 @@
161
165
  return 'other';
162
166
  }
163
167
 
164
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
165
169
 
166
170
  function getPosts() {
167
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
168
172
  if (!el.isConnected) return false;
169
173
  if (!el.querySelector('[component="post/content"]')) return false;
170
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
171
175
  if (p && p !== el) return false;
172
176
  return el.getAttribute('component') !== 'post/parent';
173
177
  });
@@ -183,28 +187,36 @@
183
187
  );
184
188
  }
185
189
 
186
- // ── Ancres stables ─────────────────────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
187
191
 
188
- function stableId(klass, el) {
189
- const attr = KIND[klass]?.anchorAttr;
192
+ /**
193
+ * Retourne l'identifiant stable de l'élément selon son kindClass.
194
+ * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
195
+ * Fallback positionnel si l'attribut est absent.
196
+ */
197
+ function stableId(kindClass, el) {
198
+ const attr = KIND[kindClass]?.anchorAttr;
190
199
  if (attr) {
191
200
  const v = el.getAttribute(attr);
192
201
  if (v !== null && v !== '') return v;
193
202
  }
194
- let i = 0;
195
- for (const s of el.parentElement?.children ?? []) {
196
- if (s === el) return `i${i}`;
197
- i++;
198
- }
203
+ // Fallback : position dans le parent
204
+ try {
205
+ let i = 0;
206
+ for (const s of el.parentElement?.children ?? []) {
207
+ if (s === el) return `i${i}`;
208
+ i++;
209
+ }
210
+ } catch (_) {}
199
211
  return 'i0';
200
212
  }
201
213
 
202
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
203
215
 
204
- function findWrap(key) {
216
+ function findWrap(anchorKey) {
205
217
  try {
206
218
  return document.querySelector(
207
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
208
220
  );
209
221
  } catch (_) { return null; }
210
222
  }
@@ -214,7 +226,7 @@
214
226
  function pickId(poolKey) {
215
227
  const pool = S.pools[poolKey];
216
228
  for (let t = 0; t < pool.length; t++) {
217
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
218
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
219
231
  const id = pool[i];
220
232
  if (!S.mountedIds.has(id)) return id;
@@ -225,7 +237,7 @@
225
237
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
226
238
 
227
239
  function makeWrap(id, klass, key) {
228
- const w = document.createElement('div');
240
+ const w = document.createElement('div');
229
241
  w.className = `${WRAP_CLASS} ${klass}`;
230
242
  w.setAttribute(A_ANCHOR, key);
231
243
  w.setAttribute(A_WRAPID, String(id));
@@ -239,10 +251,10 @@
239
251
  }
240
252
 
241
253
  function insertAfter(el, id, klass, key) {
242
- if (!el?.insertAdjacentElement) return null;
243
- if (findWrap(key)) return null;
244
- if (S.mountedIds.has(id)) return null;
245
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
254
+ if (!el?.insertAdjacentElement) return null;
255
+ if (findWrap(key)) return null; // ancre déjà présente
256
+ if (S.mountedIds.has(id)) return null; // id déjà monté
257
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
246
258
  const w = makeWrap(id, klass, key);
247
259
  mutate(() => el.insertAdjacentElement('afterend', w));
248
260
  S.mountedIds.add(id);
@@ -251,44 +263,100 @@
251
263
 
252
264
  function dropWrap(w) {
253
265
  try {
254
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
255
- if (ph instanceof Element) S.io?.unobserve(ph);
256
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
257
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 (_) {}
258
275
  w.remove();
259
276
  } catch (_) {}
260
277
  }
261
278
 
262
- // ── Prune : désactivé ─────────────────────────────────────────────────────
263
- //
264
- // pruneOrphans() a été supprimé car il causait le bug "pubs en haut".
265
- // NodeBB virtualise les posts hors viewport → les ancres disparaissent du DOM
266
- // temporairement → pruneOrphans supprimait les wraps → scroll retour → les
267
- // ancres revenaient → injectBetween réinjectait tout en haut.
268
- //
269
- // Les wraps ne sont supprimés que par cleanup() à chaque navigation ajaxify.
270
- // decluster() et pruneOrphans() sont désactivés — voir v28.
279
+ // ── Prune ──────────────────────────────────────────────────────────────────
271
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 ──────────────────────────────────────────────────────────────
313
+
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
+ }
272
338
 
273
339
  // ── Injection ──────────────────────────────────────────────────────────────
274
340
 
275
341
  /**
276
342
  * Ordinal 0-based pour le calcul de l'intervalle.
277
- * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
278
- * Catégories : ordinalAttr = null fallback positionnel.
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).
279
345
  */
280
346
  function ordinal(klass, el) {
281
- const attr = KIND[klass]?.ordinalAttr;
282
- if (attr) {
283
- const v = el.getAttribute(attr);
284
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
285
- }
286
- const fullSel = KIND[klass]?.sel ?? '';
287
- let i = 0;
288
- for (const s of el.parentElement?.children ?? []) {
289
- if (s === el) return i;
290
- if (!fullSel || s.matches?.(fullSel)) i++;
291
- }
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 (_) {}
292
360
  return 0;
293
361
  }
294
362
 
@@ -297,18 +365,20 @@
297
365
  let inserted = 0;
298
366
 
299
367
  for (const el of items) {
300
- if (inserted >= MAX_INSERTS_RUN) break;
368
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
301
369
  if (!el?.isConnected) continue;
302
370
 
303
- const ord = ordinal(klass, el);
304
- 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
+
305
375
  if (adjacentWrap(el)) continue;
306
376
 
307
- const key = anchorKey(klass, el);
308
- if (findWrap(key)) continue;
377
+ const key = makeAnchorKey(klass, el);
378
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
309
379
 
310
380
  const id = pickId(poolKey);
311
- if (!id) continue; // pool épuisé : tous les ids sont montés, on passe au suivant
381
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants besoin d'id)
312
382
 
313
383
  const w = insertAfter(el, id, klass, key);
314
384
  if (w) { observePh(id); inserted++; }
@@ -320,6 +390,7 @@
320
390
 
321
391
  function getIO() {
322
392
  if (S.io) return S.io;
393
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
323
394
  try {
324
395
  S.io = new IntersectionObserver(entries => {
325
396
  for (const e of entries) {
@@ -328,7 +399,7 @@
328
399
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
329
400
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
330
401
  }
331
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
402
+ }, { root: null, rootMargin: margin, threshold: 0 });
332
403
  } catch (_) { S.io = null; }
333
404
  return S.io;
334
405
  }
@@ -379,6 +450,7 @@
379
450
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
380
451
  S.lastShow.set(id, t);
381
452
 
453
+ // Horodater le show sur le wrap pour grace period + emptyCheck
382
454
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
383
455
 
384
456
  window.ezstandalone = window.ezstandalone || {};
@@ -399,6 +471,7 @@
399
471
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
400
472
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
401
473
  if (!wrap || !ph?.isConnected) return;
474
+ // Un show plus récent → ne pas toucher
402
475
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
403
476
  wrap.classList.toggle('is-empty', !isFilled(ph));
404
477
  } catch (_) {}
@@ -417,7 +490,7 @@
417
490
  const orig = ez.showAds.bind(ez);
418
491
  ez.showAds = function (...args) {
419
492
  if (isBlocked()) return;
420
- 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;
421
494
  const seen = new Set();
422
495
  for (const v of ids) {
423
496
  const id = parseInt(v, 10);
@@ -436,7 +509,7 @@
436
509
  }
437
510
  }
438
511
 
439
- // ── Core ───────────────────────────────────────────────────────────────────
512
+ // ── Core run ───────────────────────────────────────────────────────────────
440
513
 
441
514
  async function runCore() {
442
515
  if (isBlocked()) return 0;
@@ -451,8 +524,11 @@
451
524
 
452
525
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
453
526
  if (!normBool(cfgEnable)) return 0;
527
+ const items = getItems();
454
528
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
455
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
529
+ pruneOrphans(klass);
530
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
531
+ if (n) decluster(klass);
456
532
  return n;
457
533
  };
458
534
 
@@ -464,13 +540,14 @@
464
540
  'ezoic-ad-between', getTopics,
465
541
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
466
542
  );
467
- return exec(
543
+ if (kind === 'categories') return exec(
468
544
  'ezoic-ad-categories', getCategories,
469
545
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
470
546
  );
547
+ return 0;
471
548
  }
472
549
 
473
- // ── Scheduler ──────────────────────────────────────────────────────────────
550
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
474
551
 
475
552
  function scheduleRun(cb) {
476
553
  if (S.runQueued) return;
@@ -488,8 +565,10 @@
488
565
  if (isBlocked()) return;
489
566
  const t = ts();
490
567
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
491
- S.lastBurstTs = t;
492
- S.pageKey = pageKey();
568
+ S.lastBurstTs = t;
569
+
570
+ const pk = pageKey();
571
+ S.pageKey = pk;
493
572
  S.burstDeadline = t + 2000;
494
573
 
495
574
  if (S.burstActive) return;
@@ -497,7 +576,7 @@
497
576
  S.burstCount = 0;
498
577
 
499
578
  const step = () => {
500
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
579
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
501
580
  S.burstActive = false; return;
502
581
  }
503
582
  S.burstCount++;
@@ -509,7 +588,7 @@
509
588
  step();
510
589
  }
511
590
 
512
- // ── Cleanup navigation ─────────────────────────────────────────────────────
591
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
513
592
 
514
593
  function cleanup() {
515
594
  blockedUntil = ts() + 1500;
@@ -526,17 +605,19 @@
526
605
  S.runQueued = false;
527
606
  }
528
607
 
529
- // ── MutationObserver ───────────────────────────────────────────────────────
608
+ // ── DOM Observer ───────────────────────────────────────────────────────────
530
609
 
531
610
  function ensureDomObserver() {
532
611
  if (S.domObs) return;
533
- const allSel = [SEL.post, SEL.topic, SEL.category];
534
612
  S.domObs = new MutationObserver(muts => {
535
613
  if (S.mutGuard > 0 || isBlocked()) return;
536
614
  for (const m of muts) {
615
+ if (!m.addedNodes?.length) continue;
537
616
  for (const n of m.addedNodes) {
538
617
  if (n.nodeType !== 1) continue;
539
- 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)) {
540
621
  requestBurst(); return;
541
622
  }
542
623
  }
@@ -562,18 +643,29 @@
562
643
  }
563
644
 
564
645
  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.
565
652
  try {
566
653
  if (!window.__tcfapi && !window.__cmp) return;
654
+
567
655
  const inject = () => {
568
656
  if (document.getElementById('__tcfapiLocator')) return;
569
657
  const f = document.createElement('iframe');
570
658
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
571
659
  (document.body || document.documentElement).appendChild(f);
572
660
  };
661
+
573
662
  inject();
663
+
664
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
574
665
  if (!window.__nbbTcfObs) {
575
- window.__nbbTcfObs = new MutationObserver(inject);
576
- 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 });
577
669
  }
578
670
  } catch (_) {}
579
671
  }
@@ -583,10 +675,10 @@
583
675
  const head = document.head;
584
676
  if (!head) return;
585
677
  for (const [rel, href, cors] of [
586
- ['preconnect', 'https://g.ezoic.net', true ],
587
- ['preconnect', 'https://go.ezoic.net', true ],
588
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
589
- ['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],
590
682
  ['dns-prefetch', 'https://g.ezoic.net', false],
591
683
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
592
684
  ]) {
@@ -600,7 +692,7 @@
600
692
  }
601
693
  }
602
694
 
603
- // ── Bindings ───────────────────────────────────────────────────────────────
695
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
604
696
 
605
697
  function bindNodeBB() {
606
698
  const $ = window.jQuery;
@@ -611,16 +703,19 @@
611
703
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
612
704
  S.pageKey = pageKey();
613
705
  blockedUntil = 0;
614
- muteConsole(); ensureTcfLocator(); warmNetwork();
615
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
706
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
+ getIO(); ensureDomObserver(); requestBurst();
616
708
  });
617
709
 
618
- const burstEvts = [
619
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
710
+ const BURST_EVENTS = [
711
+ 'action:ajaxify.contentLoaded',
712
+ 'action:posts.loaded', 'action:topics.loaded',
620
713
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
621
714
  ].map(e => `${e}.nbbEzoic`).join(' ');
622
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
623
715
 
716
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
+
718
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
624
719
  try {
625
720
  require(['hooks'], hooks => {
626
721
  if (typeof hooks?.on !== 'function') return;