nodebb-plugin-ezoic-infinite 1.7.9 → 1.7.11

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 +204 -156
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,49 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v21
2
+ * NodeBB Ezoic Infinite Ads — client.js v22
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
- * Suppression du recyclage de wraps (moveWrapAfter). Cleanup complet navigation.
4
+ * Correctifs critiques vs v19
5
+ * ───────────────────────────
6
+ * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
+ * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
+ * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
+ * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
+ * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
+ * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
8
12
  *
9
- * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
- * la position dans le batch courant.
13
+ * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
+ * Fix : on skip uniquement le wrap courant, pas toute la boucle.
11
15
  *
12
- * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
13
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
14
- * Fix posts : baseTag vide → ordinal fallback cassé → pubs jamais injectées.
15
- * IO fixe (une instance, jamais recréée). Burst cooldown 200 ms.
16
- * Fix unobserve(null) → corruption IO → pubads error au scroll retour.
17
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
16
+ * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
+ * existants sur les items suivants. Fix : `continue` au lieu de `break`.
18
18
  *
19
- * v21 Suppression de toute la logique wyvern.js (pause/destroy avant remove) :
20
- * les erreurs wyvern viennent du SDK Ezoic lui-même lors de ses propres
21
- * refreshes internes, pas de nos suppressions. Nos wraps filled ne sont
22
- * de toute façon jamais supprimés (règle pruneOrphans/decluster).
23
- * Refactorisation finale prod-ready : code unifié, zéro duplication,
24
- * commentaires essentiels uniquement.
19
+ * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
+ * Fix : marge large fixe par device, observer créé une seule fois.
21
+ *
22
+ * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
+ * Fix : 200ms.
24
+ *
25
+ * Nettoyage
26
+ * ─────────
27
+ * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
+ * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
+ * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
+ * - Commentaires internes allégés (code auto-documenté)
25
31
  */
26
32
  (function () {
27
33
  'use strict';
28
34
 
29
35
  // ── Constantes ─────────────────────────────────────────────────────────────
30
36
 
31
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
32
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
33
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
34
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
35
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
36
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
37
-
38
- const MIN_PRUNE_AGE_MS = 8_000; // garde-fou post-batch NodeBB
39
- const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
40
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
41
- const MAX_INSERTS_RUN = 6;
42
- const MAX_INFLIGHT = 4;
43
- const SHOW_THROTTLE_MS = 900;
44
- const BURST_COOLDOWN_MS = 200;
45
-
46
- // IO : marges larges fixes une seule instance, jamais recréée
37
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
+ const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
+
44
+ const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
+ const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
+ const MAX_INSERTS_PER_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
47
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
48
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
49
55
 
@@ -54,40 +60,40 @@
54
60
  };
55
61
 
56
62
  /**
57
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
58
64
  *
59
- * sel : sélecteur CSS complet
60
- * baseTag : préfixe tag pour les querySelector de recherche d'ancre
61
- * (vide pour posts car leur sélecteur commence par '[')
62
- * anchorAttr : attribut DOM STABLE clé unique du wrap, permanent
63
- * data-pid posts (id message, immuable)
64
- * data-index topics (index dans la liste)
65
- * data-cid catégories (id catégorie, immuable)
66
- * ordinalAttr: attribut 0-based pour le calcul de l'intervalle
67
- * data-index posts + topics (fourni par NodeBB)
68
- * null catégories (page statique → fallback positionnel)
65
+ * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
+ * quelle que soit la page ou la virtualisation :
67
+ * posts data-pid (id du message, unique et permanent)
68
+ * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
+ * catégories → data-cid (id de la catégorie, unique et permanent)
70
+ * ← C'était le bug v19 : on cherchait data-index ici
69
71
  */
70
72
  const KIND = {
71
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
72
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
73
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
73
+ 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
74
76
  };
75
77
 
76
78
  // ── État ───────────────────────────────────────────────────────────────────
77
79
 
78
80
  const S = {
79
- pageKey: null,
80
- cfg: null,
81
- pools: { topics: [], posts: [], categories: [] },
82
- cursors: { topics: 0, posts: 0, categories: 0 },
83
- mountedIds: new Set(), // IDs Ezoic montés dans le DOM
84
- lastShow: new Map(), // id timestamp dernier show
85
- io: null,
86
- domObs: null,
87
- mutGuard: 0,
88
- inflight: 0,
89
- pending: [],
90
- pendingSet: new Set(),
81
+ pageKey: null,
82
+ cfg: null,
83
+
84
+ pools: { topics: [], posts: [], categories: [] },
85
+ cursors: { topics: 0, posts: 0, categories: 0 },
86
+ mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
+ lastShow: new Map(), // id → timestamp dernier show
88
+
89
+ io: null,
90
+ domObs: null,
91
+ mutGuard: 0, // compteur internalMutation
92
+
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
96
+
91
97
  runQueued: false,
92
98
  burstActive: false,
93
99
  burstDeadline: 0,
@@ -96,11 +102,8 @@
96
102
  };
97
103
 
98
104
  let blockedUntil = 0;
99
- const ts = () => Date.now();
100
- const isBlocked = () => ts() < blockedUntil;
101
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
102
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
103
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
104
107
 
105
108
  function mutate(fn) {
106
109
  S.mutGuard++;
@@ -118,6 +121,12 @@
118
121
  return S.cfg;
119
122
  }
120
123
 
124
+ function initPools(cfg) {
125
+ S.pools.topics = parseIds(cfg.placeholderIds);
126
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
127
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
128
+ }
129
+
121
130
  function parseIds(raw) {
122
131
  const out = [], seen = new Set();
123
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -127,11 +136,12 @@
127
136
  return out;
128
137
  }
129
138
 
130
- function initPools(cfg) {
131
- S.pools.topics = parseIds(cfg.placeholderIds);
132
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
133
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
134
- }
139
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
140
+
141
+ const isFilled = (n) =>
142
+ !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
143
+
144
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
135
145
 
136
146
  // ── Page identity ──────────────────────────────────────────────────────────
137
147
 
@@ -155,13 +165,13 @@
155
165
  return 'other';
156
166
  }
157
167
 
158
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
159
169
 
160
170
  function getPosts() {
161
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
162
172
  if (!el.isConnected) return false;
163
173
  if (!el.querySelector('[component="post/content"]')) return false;
164
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
165
175
  if (p && p !== el) return false;
166
176
  return el.getAttribute('component') !== 'post/parent';
167
177
  });
@@ -177,28 +187,36 @@
177
187
  );
178
188
  }
179
189
 
180
- // ── Ancres stables ─────────────────────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
181
191
 
182
- function stableId(klass, el) {
183
- const attr = KIND[klass]?.anchorAttr;
192
+ /**
193
+ * Retourne l'identifiant stable de l'élément selon son kindClass.
194
+ * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
195
+ * Fallback positionnel si l'attribut est absent.
196
+ */
197
+ function stableId(kindClass, el) {
198
+ const attr = KIND[kindClass]?.anchorAttr;
184
199
  if (attr) {
185
200
  const v = el.getAttribute(attr);
186
201
  if (v !== null && v !== '') return v;
187
202
  }
188
- let i = 0;
189
- for (const s of el.parentElement?.children ?? []) {
190
- if (s === el) return `i${i}`;
191
- i++;
192
- }
203
+ // Fallback : position dans le parent
204
+ try {
205
+ let i = 0;
206
+ for (const s of el.parentElement?.children ?? []) {
207
+ if (s === el) return `i${i}`;
208
+ i++;
209
+ }
210
+ } catch (_) {}
193
211
  return 'i0';
194
212
  }
195
213
 
196
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
197
215
 
198
- function findWrap(key) {
216
+ function findWrap(anchorKey) {
199
217
  try {
200
218
  return document.querySelector(
201
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
202
220
  );
203
221
  } catch (_) { return null; }
204
222
  }
@@ -208,7 +226,7 @@
208
226
  function pickId(poolKey) {
209
227
  const pool = S.pools[poolKey];
210
228
  for (let t = 0; t < pool.length; t++) {
211
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
212
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
213
231
  const id = pool[i];
214
232
  if (!S.mountedIds.has(id)) return id;
@@ -219,7 +237,7 @@
219
237
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
220
238
 
221
239
  function makeWrap(id, klass, key) {
222
- const w = document.createElement('div');
240
+ const w = document.createElement('div');
223
241
  w.className = `${WRAP_CLASS} ${klass}`;
224
242
  w.setAttribute(A_ANCHOR, key);
225
243
  w.setAttribute(A_WRAPID, String(id));
@@ -233,10 +251,10 @@
233
251
  }
234
252
 
235
253
  function insertAfter(el, id, klass, key) {
236
- if (!el?.insertAdjacentElement) return null;
237
- if (findWrap(key)) return null;
238
- if (S.mountedIds.has(id)) return null;
239
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
254
+ if (!el?.insertAdjacentElement) return null;
255
+ if (findWrap(key)) return null; // ancre déjà présente
256
+ if (S.mountedIds.has(id)) return null; // id déjà monté
257
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
240
258
  const w = makeWrap(id, klass, key);
241
259
  mutate(() => el.insertAdjacentElement('afterend', w));
242
260
  S.mountedIds.add(id);
@@ -245,12 +263,15 @@
245
263
 
246
264
  function dropWrap(w) {
247
265
  try {
248
- // Unobserve avant remove — guard instanceof évite unobserve(null)
249
- // qui corrompt l'état interne de l'IO (pubads error au scroll suivant)
250
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
251
- if (ph instanceof Element) S.io?.unobserve(ph);
252
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
253
267
  if (Number.isFinite(id)) S.mountedIds.delete(id);
268
+ // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
+ // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
+ // "parameter 1 is not of type Element" sur le prochain observe).
271
+ try {
272
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
+ if (ph instanceof Element) S.io?.unobserve(ph);
274
+ } catch (_) {}
254
275
  w.remove();
255
276
  } catch (_) {}
256
277
  }
@@ -258,42 +279,50 @@
258
279
  // ── Prune ──────────────────────────────────────────────────────────────────
259
280
 
260
281
  /**
261
- * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
282
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
262
283
  *
263
- * On ne supprime JAMAIS un wrap rempli (filled) :
264
- * - Les wraps remplis peuvent être temporairement orphelins lors d'une
265
- * virtualisation NodeBB — l'ancre reviendra.
266
- * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
267
- * retirer le nœud sous ses pieds génère des erreurs non critiques mais
268
- * inutiles. Le cleanup de navigation gère la suppression définitive.
284
+ * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
+ * Exemples :
286
+ * ezoic-ad-message → cherche [data-pid="123"]
287
+ * ezoic-ad-between → cherche [data-index="5"]
288
+ * ezoic-ad-categories cherche [data-cid="7"] ← fix v20
289
+ *
290
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
269
291
  */
270
292
  function pruneOrphans(klass) {
271
293
  const meta = KIND[klass];
272
294
  if (!meta) return;
273
295
 
274
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
275
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
276
- if (isFilled(w)) continue;
296
+ // 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
277
304
 
278
305
  const key = w.getAttribute(A_ANCHOR) ?? '';
279
306
  const sid = key.slice(klass.length + 1);
280
- if (!sid) { mutate(() => dropWrap(w)); continue; }
307
+ if (!sid) { mutate(() => dropWrap(w)); return; }
281
308
 
282
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
283
- const anchorEl = document.querySelector(sel);
309
+ const anchorEl = document.querySelector(
310
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
311
+ );
284
312
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
285
- }
313
+ });
286
314
  }
287
315
 
288
316
  // ── Decluster ──────────────────────────────────────────────────────────────
289
317
 
290
318
  /**
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).
319
+ * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
320
+ * Priorité : filled > en grâce (fill en cours) > vide.
321
+ * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
294
322
  */
295
323
  function decluster(klass) {
296
324
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
325
+ // Grace sur le wrap courant : on le saute entièrement
297
326
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
298
327
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
299
328
 
@@ -302,11 +331,10 @@
302
331
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
303
332
 
304
333
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
305
- if (pShown && ts() - pShown < FILL_GRACE_MS) break;
334
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
306
335
 
307
336
  if (!isFilled(w)) mutate(() => dropWrap(w));
308
337
  else if (!isFilled(prev)) mutate(() => dropWrap(prev));
309
- // les deux remplis → on ne touche pas
310
338
  break;
311
339
  }
312
340
  }
@@ -316,16 +344,14 @@
316
344
 
317
345
  /**
318
346
  * Ordinal 0-based pour le calcul de l'intervalle.
319
- * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
320
- * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
347
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
348
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
321
349
  */
322
350
  function ordinal(klass, el) {
323
- const attr = KIND[klass]?.ordinalAttr;
324
- if (attr) {
325
- const v = el.getAttribute(attr);
326
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
327
- }
328
- // Fallback positionnel — compte uniquement les éléments du même type
351
+ const di = el.getAttribute('data-index');
352
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
353
+ // Fallback positionnel — filtre par sélecteur complet pour éviter le bug
354
+ // baseTag='' (posts) `:scope > ` sans tag ne fonctionne pas.
329
355
  const fullSel = KIND[klass]?.sel ?? '';
330
356
  let i = 0;
331
357
  for (const s of el.parentElement?.children ?? []) {
@@ -340,18 +366,20 @@
340
366
  let inserted = 0;
341
367
 
342
368
  for (const el of items) {
343
- if (inserted >= MAX_INSERTS_RUN) break;
369
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
344
370
  if (!el?.isConnected) continue;
345
371
 
346
- const ord = ordinal(klass, el);
347
- if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
372
+ const ord = ordinal(klass, el);
373
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
374
+ if (!isTarget) continue;
375
+
348
376
  if (adjacentWrap(el)) continue;
349
377
 
350
- const key = anchorKey(klass, el);
351
- if (findWrap(key)) continue;
378
+ const key = makeAnchorKey(klass, el);
379
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
352
380
 
353
381
  const id = pickId(poolKey);
354
- if (!id) continue;
382
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
355
383
 
356
384
  const w = insertAfter(el, id, klass, key);
357
385
  if (w) { observePh(id); inserted++; }
@@ -363,6 +391,7 @@
363
391
 
364
392
  function getIO() {
365
393
  if (S.io) return S.io;
394
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
366
395
  try {
367
396
  S.io = new IntersectionObserver(entries => {
368
397
  for (const e of entries) {
@@ -371,7 +400,7 @@
371
400
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
372
401
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
373
402
  }
374
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
+ }, { root: null, rootMargin: margin, threshold: 0 });
375
404
  } catch (_) { S.io = null; }
376
405
  return S.io;
377
406
  }
@@ -422,6 +451,7 @@
422
451
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
423
452
  S.lastShow.set(id, t);
424
453
 
454
+ // Horodater le show sur le wrap pour grace period + emptyCheck
425
455
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
426
456
 
427
457
  window.ezstandalone = window.ezstandalone || {};
@@ -442,6 +472,7 @@
442
472
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
443
473
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
444
474
  if (!wrap || !ph?.isConnected) return;
475
+ // Un show plus récent → ne pas toucher
445
476
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
446
477
  wrap.classList.toggle('is-empty', !isFilled(ph));
447
478
  } catch (_) {}
@@ -460,7 +491,7 @@
460
491
  const orig = ez.showAds.bind(ez);
461
492
  ez.showAds = function (...args) {
462
493
  if (isBlocked()) return;
463
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
464
495
  const seen = new Set();
465
496
  for (const v of ids) {
466
497
  const id = parseInt(v, 10);
@@ -479,7 +510,7 @@
479
510
  }
480
511
  }
481
512
 
482
- // ── Core ───────────────────────────────────────────────────────────────────
513
+ // ── Core run ───────────────────────────────────────────────────────────────
483
514
 
484
515
  async function runCore() {
485
516
  if (isBlocked()) return 0;
@@ -494,9 +525,10 @@
494
525
 
495
526
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
496
527
  if (!normBool(cfgEnable)) return 0;
528
+ const items = getItems();
497
529
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
498
530
  pruneOrphans(klass);
499
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
531
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
500
532
  if (n) decluster(klass);
501
533
  return n;
502
534
  };
@@ -509,13 +541,14 @@
509
541
  'ezoic-ad-between', getTopics,
510
542
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
511
543
  );
512
- return exec(
544
+ if (kind === 'categories') return exec(
513
545
  'ezoic-ad-categories', getCategories,
514
546
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
515
547
  );
548
+ return 0;
516
549
  }
517
550
 
518
- // ── Scheduler ──────────────────────────────────────────────────────────────
551
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
519
552
 
520
553
  function scheduleRun(cb) {
521
554
  if (S.runQueued) return;
@@ -525,7 +558,7 @@
525
558
  if (S.pageKey && pageKey() !== S.pageKey) return;
526
559
  let n = 0;
527
560
  try { n = await runCore(); } catch (_) {}
528
- cb?.(n);
561
+ try { cb?.(n); } catch (_) {}
529
562
  });
530
563
  }
531
564
 
@@ -533,8 +566,10 @@
533
566
  if (isBlocked()) return;
534
567
  const t = ts();
535
568
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
536
- S.lastBurstTs = t;
537
- S.pageKey = pageKey();
569
+ S.lastBurstTs = t;
570
+
571
+ const pk = pageKey();
572
+ S.pageKey = pk;
538
573
  S.burstDeadline = t + 2000;
539
574
 
540
575
  if (S.burstActive) return;
@@ -542,7 +577,7 @@
542
577
  S.burstCount = 0;
543
578
 
544
579
  const step = () => {
545
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
546
581
  S.burstActive = false; return;
547
582
  }
548
583
  S.burstCount++;
@@ -554,7 +589,7 @@
554
589
  step();
555
590
  }
556
591
 
557
- // ── Cleanup navigation ─────────────────────────────────────────────────────
592
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
558
593
 
559
594
  function cleanup() {
560
595
  blockedUntil = ts() + 1500;
@@ -571,17 +606,19 @@
571
606
  S.runQueued = false;
572
607
  }
573
608
 
574
- // ── MutationObserver ───────────────────────────────────────────────────────
609
+ // ── DOM Observer ───────────────────────────────────────────────────────────
575
610
 
576
611
  function ensureDomObserver() {
577
612
  if (S.domObs) return;
578
- const allSel = [SEL.post, SEL.topic, SEL.category];
579
613
  S.domObs = new MutationObserver(muts => {
580
614
  if (S.mutGuard > 0 || isBlocked()) return;
581
615
  for (const m of muts) {
616
+ if (!m.addedNodes?.length) continue;
582
617
  for (const n of m.addedNodes) {
583
618
  if (n.nodeType !== 1) continue;
584
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
619
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
620
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
621
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
585
622
  requestBurst(); return;
586
623
  }
587
624
  }
@@ -607,21 +644,29 @@
607
644
  }
608
645
 
609
646
  function ensureTcfLocator() {
610
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
611
- // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
612
- // Un MutationObserver la recrée dès qu'elle disparaît.
647
+ // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
648
+ // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
649
+ // iframe du DOM (vidage partiel du body), ce qui provoque :
650
+ // "Cannot read properties of null (reading 'postMessage')"
651
+ // "Cannot set properties of null (setting 'addtlConsent')"
652
+ // Solution : la recrée immédiatement si elle disparaît, via un observer.
613
653
  try {
614
654
  if (!window.__tcfapi && !window.__cmp) return;
655
+
615
656
  const inject = () => {
616
657
  if (document.getElementById('__tcfapiLocator')) return;
617
658
  const f = document.createElement('iframe');
618
659
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
619
660
  (document.body || document.documentElement).appendChild(f);
620
661
  };
662
+
621
663
  inject();
664
+
665
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
622
666
  if (!window.__nbbTcfObs) {
623
- window.__nbbTcfObs = new MutationObserver(inject);
624
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
667
+ window.__nbbTcfObs = new MutationObserver(() => inject());
668
+ window.__nbbTcfObs.observe(document.documentElement,
669
+ { childList: true, subtree: true });
625
670
  }
626
671
  } catch (_) {}
627
672
  }
@@ -631,10 +676,10 @@
631
676
  const head = document.head;
632
677
  if (!head) return;
633
678
  for (const [rel, href, cors] of [
634
- ['preconnect', 'https://g.ezoic.net', true ],
635
- ['preconnect', 'https://go.ezoic.net', true ],
636
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
637
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
679
+ ['preconnect', 'https://g.ezoic.net', true],
680
+ ['preconnect', 'https://go.ezoic.net', true],
681
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
682
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
638
683
  ['dns-prefetch', 'https://g.ezoic.net', false],
639
684
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
640
685
  ]) {
@@ -648,7 +693,7 @@
648
693
  }
649
694
  }
650
695
 
651
- // ── Bindings ───────────────────────────────────────────────────────────────
696
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
652
697
 
653
698
  function bindNodeBB() {
654
699
  const $ = window.jQuery;
@@ -659,16 +704,19 @@
659
704
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
660
705
  S.pageKey = pageKey();
661
706
  blockedUntil = 0;
662
- muteConsole(); ensureTcfLocator(); warmNetwork();
663
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
707
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
708
+ getIO(); ensureDomObserver(); requestBurst();
664
709
  });
665
710
 
666
- const burstEvts = [
667
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
711
+ const BURST_EVENTS = [
712
+ 'action:ajaxify.contentLoaded',
713
+ 'action:posts.loaded', 'action:topics.loaded',
668
714
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
669
715
  ].map(e => `${e}.nbbEzoic`).join(' ');
670
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
671
716
 
717
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
718
+
719
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
672
720
  try {
673
721
  require(['hooks'], hooks => {
674
722
  if (typeof hooks?.on !== 'function') return;