nodebb-plugin-ezoic-infinite 1.7.12 → 1.7.13

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 +192 -139
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.12",
3
+ "version": "1.7.13",
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,38 +1,57 @@
1
1
  /**
2
2
  * NodeBB Ezoic Infinite Ads — client.js v23
3
3
  *
4
- * v20 Table KIND : anchorAttr par kindClass. Fix catégories (data-cid).
5
- * IO fixe une seule instance. Burst cooldown 200ms.
6
- * v22 Fix ordinal fallback posts (baseTag vide cassait :scope>). isFilled guard pruneOrphans.
7
- * v23 Fix "pubs écrasées" : INJECT_GRACE_MS protège les wraps non encore fills.
8
- * decluster : ne supprime jamais un wrap filled, grace basée sur A_CREATED aussi.
9
- * KIND table : baseTag explicite (évite le split fragile).
10
- * wrapByKey Map O(1) pour findWrap. poolsReady (initPools une fois/page).
11
- * patchShowAds sorti du hot path runCore. wrapByKey sync sur dropWrap/cleanup.
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).
12
+ *
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.
15
+ *
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
+ *
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é)
12
31
  */
13
32
  (function () {
14
33
  'use strict';
15
34
 
16
35
  // ── Constantes ─────────────────────────────────────────────────────────────
17
36
 
18
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
19
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
20
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
21
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
22
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
23
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
24
-
25
- const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
26
- const INJECT_GRACE_MS = 30_000; // fenêtre post-injection : wrap non supprimable (fill async en cours)
27
- const FILL_GRACE_MS = 25_000; // fenêtre post-showAds pour decluster
28
- const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
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
29
47
  const MAX_INSERTS_PER_RUN = 6;
30
48
  const MAX_INFLIGHT = 4;
31
49
  const SHOW_THROTTLE_MS = 900;
32
50
  const BURST_COOLDOWN_MS = 200;
33
51
 
34
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
35
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
53
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
36
55
 
37
56
  const SEL = {
38
57
  post: '[component="post"][data-pid]',
@@ -41,53 +60,50 @@
41
60
  };
42
61
 
43
62
  /**
44
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
45
64
  *
46
- * anchorAttr : attribut DOM stable clé unique du wrap
47
- * data-pid posts (id message, immuable)
48
- * data-index topics (index dans la liste)
49
- * data-cid catégories (id catégorie, immuable)
50
- * baseTag : préfixe tag pour le querySelector d'ancre dans pruneOrphans.
51
- * Vide pour posts (sélecteur sans tag). Explicite pour éviter le split fragile.
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
52
71
  */
53
72
  const KIND = {
54
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid', baseTag: '' },
55
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index', baseTag: 'li' },
56
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid', baseTag: 'li' },
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' },
57
76
  };
58
77
 
59
78
  // ── État ───────────────────────────────────────────────────────────────────
60
79
 
61
80
  const S = {
62
- pageKey: null,
63
- cfg: null,
64
- pools: { topics: [], posts: [], categories: [] },
65
- cursors: { topics: 0, posts: 0, categories: 0 },
66
- mountedIds: new Set(), // IDs Ezoic dans le DOM
67
- lastShow: new Map(), // id timestamp dernier show
68
- io: null,
69
- domObs: null,
70
- mutGuard: 0,
71
- inflight: 0,
72
- pending: [],
73
- pendingSet: new Set(),
74
- runQueued: false,
75
- burstActive: false,
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
+ runQueued: false,
98
+ burstActive: false,
76
99
  burstDeadline: 0,
77
- burstCount: 0,
78
- lastBurstTs: 0,
100
+ burstCount: 0,
101
+ lastBurstTs: 0,
79
102
  };
80
103
 
81
- // Map anchorKey → wrap Element — lookup O(1) au lieu de querySelector full-DOM
82
- const wrapByKey = new Map();
83
- let blockedUntil = 0;
84
- let poolsReady = false; // initPools une seule fois par page
85
-
86
- const ts = () => Date.now();
87
- const isBlocked = () => ts() < blockedUntil;
88
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
89
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
90
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
104
+ let blockedUntil = 0;
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
91
107
 
92
108
  function mutate(fn) {
93
109
  S.mutGuard++;
@@ -105,6 +121,12 @@
105
121
  return S.cfg;
106
122
  }
107
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
+
108
130
  function parseIds(raw) {
109
131
  const out = [], seen = new Set();
110
132
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -114,13 +136,12 @@
114
136
  return out;
115
137
  }
116
138
 
117
- function initPools(cfg) {
118
- if (poolsReady) return;
119
- S.pools.topics = parseIds(cfg.placeholderIds);
120
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
121
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
122
- poolsReady = true;
123
- }
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; } };
124
145
 
125
146
  // ── Page identity ──────────────────────────────────────────────────────────
126
147
 
@@ -144,13 +165,13 @@
144
165
  return 'other';
145
166
  }
146
167
 
147
- // ── Items DOM ──────────────────────────────────────────────────────────────
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
148
169
 
149
170
  function getPosts() {
150
171
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
151
172
  if (!el.isConnected) return false;
152
173
  if (!el.querySelector('[component="post/content"]')) return false;
153
- const p = el.parentElement?.closest(SEL.post);
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
154
175
  if (p && p !== el) return false;
155
176
  return el.getAttribute('component') !== 'post/parent';
156
177
  });
@@ -166,29 +187,38 @@
166
187
  );
167
188
  }
168
189
 
169
- // ── Ancres stables ─────────────────────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
170
191
 
171
- function stableId(klass, el) {
172
- 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;
173
199
  if (attr) {
174
200
  const v = el.getAttribute(attr);
175
201
  if (v !== null && v !== '') return v;
176
202
  }
177
- let i = 0;
178
- for (const s of el.parentElement?.children ?? []) {
179
- if (s === el) return `i${i}`;
180
- i++;
181
- }
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 (_) {}
182
211
  return 'i0';
183
212
  }
184
213
 
185
214
  const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
186
215
 
187
- function findWrap(key) {
188
- const w = wrapByKey.get(key);
189
- if (w && w.isConnected) return w;
190
- if (w) wrapByKey.delete(key); // nettoyage lazy si wrap retiré hors de notre contrôle
191
- return null;
216
+ function findWrap(anchorKey) {
217
+ try {
218
+ return document.querySelector(
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
+ );
221
+ } catch (_) { return null; }
192
222
  }
193
223
 
194
224
  // ── Pool ───────────────────────────────────────────────────────────────────
@@ -196,7 +226,7 @@
196
226
  function pickId(poolKey) {
197
227
  const pool = S.pools[poolKey];
198
228
  for (let t = 0; t < pool.length; t++) {
199
- const i = S.cursors[poolKey] % pool.length;
229
+ const i = S.cursors[poolKey] % pool.length;
200
230
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
201
231
  const id = pool[i];
202
232
  if (!S.mountedIds.has(id)) return id;
@@ -221,14 +251,13 @@
221
251
  }
222
252
 
223
253
  function insertAfter(el, id, klass, key) {
224
- if (!el?.insertAdjacentElement) return null;
225
- if (findWrap(key)) return null;
226
- if (S.mountedIds.has(id)) 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é
227
257
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
228
258
  const w = makeWrap(id, klass, key);
229
259
  mutate(() => el.insertAdjacentElement('afterend', w));
230
260
  S.mountedIds.add(id);
231
- wrapByKey.set(key, w);
232
261
  return w;
233
262
  }
234
263
 
@@ -236,9 +265,9 @@
236
265
  try {
237
266
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
238
267
  if (Number.isFinite(id)) S.mountedIds.delete(id);
239
- const key = w.getAttribute(A_ANCHOR);
240
- if (key) wrapByKey.delete(key);
241
- // unobserve avant remove guard instanceof (unobserve(null) corrompt l'IO pubads)
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).
242
271
  try {
243
272
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
244
273
  if (ph instanceof Element) S.io?.unobserve(ph);
@@ -250,58 +279,65 @@
250
279
  // ── Prune ──────────────────────────────────────────────────────────────────
251
280
 
252
281
  /**
253
- * 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.
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
254
289
  *
255
- * Protections :
256
- * 1. Pas avant MIN_PRUNE_AGE_MS (DOM post-batch pas encore stabilisé)
257
- * 2. Jamais si filled (pub affichée — SDK Ezoic a des callbacks async dessus)
258
- * 3. Pas avant INJECT_GRACE_MS depuis création (fill Ezoic peut être très async)
290
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
259
291
  */
260
292
  function pruneOrphans(klass) {
261
293
  const meta = KIND[klass];
262
294
  if (!meta) return;
263
295
 
264
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
265
- const age = ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10);
266
- if (age < MIN_PRUNE_AGE_MS) continue;
267
- if (isFilled(w)) continue;
268
- if (age < INJECT_GRACE_MS) 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
269
304
 
270
305
  const key = w.getAttribute(A_ANCHOR) ?? '';
271
306
  const sid = key.slice(klass.length + 1);
272
- if (!sid) { mutate(() => dropWrap(w)); continue; }
307
+ if (!sid) { mutate(() => dropWrap(w)); return; }
273
308
 
274
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
275
- const anchorEl = document.querySelector(sel);
309
+ const anchorEl = document.querySelector(
310
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
311
+ );
276
312
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
277
- }
313
+ });
278
314
  }
279
315
 
280
316
  // ── Decluster ──────────────────────────────────────────────────────────────
281
317
 
282
318
  /**
283
- * Deux wraps adjacents → supprimer le moins prioritaire.
284
- *
319
+ * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
285
320
  * Règles absolues :
286
- * - Jamais supprimer un wrap filled
287
- * - Jamais supprimer un wrap < INJECT_GRACE_MS (fill Ezoic potentiellement en cours)
288
- * - Jamais supprimer un wrap < FILL_GRACE_MS depuis le dernier showAds
289
- * - Si les deux wraps adjacents sont vides et hors grâce → supprimer le courant
321
+ * - Jamais supprimer un wrap filled (pub affichée)
322
+ * - Jamais supprimer un wrap < FILL_GRACE_MS depuis création OU depuis showAds
323
+ * (le fill Ezoic est async : l'enchère SSP peut prendre plusieurs secondes
324
+ * après showAds, et showAds lui-même peut arriver bien après l'injection)
325
+ * - Si les deux sont filled → on ne touche rien
290
326
  */
291
327
  function decluster(klass) {
292
328
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
293
- if (isFilled(w)) continue;
294
- const wAge = ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10);
295
- if (wAge < INJECT_GRACE_MS) continue;
329
+ if (isFilled(w)) continue; // filled = intouchable
330
+ const wCreated = parseInt(w.getAttribute(A_CREATED) || '0', 10);
331
+ if (ts() - wCreated < FILL_GRACE_MS) continue; // trop récent
296
332
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
297
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
333
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue; // showAds récent
298
334
 
299
335
  let prev = w.previousElementSibling, steps = 0;
300
336
  while (prev && steps++ < 3) {
301
337
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
302
- if (isFilled(prev)) break;
303
- const pAge = ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10);
304
- if (pAge < INJECT_GRACE_MS) break;
338
+ if (isFilled(prev)) break; // précédent filled = intouchable
339
+ const pCreated = parseInt(prev.getAttribute(A_CREATED) || '0', 10);
340
+ if (ts() - pCreated < FILL_GRACE_MS) break;
305
341
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
306
342
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
307
343
 
@@ -316,13 +352,14 @@
316
352
 
317
353
  /**
318
354
  * Ordinal 0-based pour le calcul de l'intervalle.
319
- * Posts/topics : data-index (NodeBB 4.x).
320
- * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
355
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
356
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
321
357
  */
322
358
  function ordinal(klass, el) {
323
359
  const di = el.getAttribute('data-index');
324
360
  if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
325
- // Fallback — s.matches(fullSel) pour ne pas compter les wraps intercalés
361
+ // Fallback positionnel filtre par sélecteur complet pour éviter le bug
362
+ // baseTag='' (posts) où `:scope > ` sans tag ne fonctionne pas.
326
363
  const fullSel = KIND[klass]?.sel ?? '';
327
364
  let i = 0;
328
365
  for (const s of el.parentElement?.children ?? []) {
@@ -340,16 +377,17 @@
340
377
  if (inserted >= MAX_INSERTS_PER_RUN) break;
341
378
  if (!el?.isConnected) continue;
342
379
 
343
- const ord = ordinal(klass, el);
344
- if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
380
+ const ord = ordinal(klass, el);
381
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
382
+ if (!isTarget) continue;
345
383
 
346
384
  if (adjacentWrap(el)) continue;
347
385
 
348
386
  const key = makeAnchorKey(klass, el);
349
- if (findWrap(key)) continue;
387
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
350
388
 
351
389
  const id = pickId(poolKey);
352
- if (!id) continue;
390
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
353
391
 
354
392
  const w = insertAfter(el, id, klass, key);
355
393
  if (w) { observePh(id); inserted++; }
@@ -361,6 +399,7 @@
361
399
 
362
400
  function getIO() {
363
401
  if (S.io) return S.io;
402
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
364
403
  try {
365
404
  S.io = new IntersectionObserver(entries => {
366
405
  for (const e of entries) {
@@ -369,7 +408,7 @@
369
408
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
370
409
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
371
410
  }
372
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
411
+ }, { root: null, rootMargin: margin, threshold: 0 });
373
412
  } catch (_) { S.io = null; }
374
413
  return S.io;
375
414
  }
@@ -420,6 +459,7 @@
420
459
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
421
460
  S.lastShow.set(id, t);
422
461
 
462
+ // Horodater le show sur le wrap pour grace period + emptyCheck
423
463
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
424
464
 
425
465
  window.ezstandalone = window.ezstandalone || {};
@@ -440,6 +480,7 @@
440
480
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
441
481
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
442
482
  if (!wrap || !ph?.isConnected) return;
483
+ // Un show plus récent → ne pas toucher
443
484
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
444
485
  wrap.classList.toggle('is-empty', !isFilled(ph));
445
486
  } catch (_) {}
@@ -458,7 +499,7 @@
458
499
  const orig = ez.showAds.bind(ez);
459
500
  ez.showAds = function (...args) {
460
501
  if (isBlocked()) return;
461
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
502
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
462
503
  const seen = new Set();
463
504
  for (const v of ids) {
464
505
  const id = parseInt(v, 10);
@@ -477,10 +518,11 @@
477
518
  }
478
519
  }
479
520
 
480
- // ── Core ───────────────────────────────────────────────────────────────────
521
+ // ── Core run ───────────────────────────────────────────────────────────────
481
522
 
482
523
  async function runCore() {
483
524
  if (isBlocked()) return 0;
525
+ patchShowAds();
484
526
 
485
527
  const cfg = await fetchConfig();
486
528
  if (!cfg || cfg.excluded) return 0;
@@ -491,9 +533,10 @@
491
533
 
492
534
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
493
535
  if (!normBool(cfgEnable)) return 0;
536
+ const items = getItems();
494
537
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
495
538
  pruneOrphans(klass);
496
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
539
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
497
540
  if (n) decluster(klass);
498
541
  return n;
499
542
  };
@@ -534,7 +577,7 @@
534
577
  S.lastBurstTs = t;
535
578
 
536
579
  const pk = pageKey();
537
- S.pageKey = pk;
580
+ S.pageKey = pk;
538
581
  S.burstDeadline = t + 2000;
539
582
 
540
583
  if (S.burstActive) return;
@@ -554,13 +597,11 @@
554
597
  step();
555
598
  }
556
599
 
557
- // ── Cleanup navigation ─────────────────────────────────────────────────────
600
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
558
601
 
559
602
  function cleanup() {
560
603
  blockedUntil = ts() + 1500;
561
- poolsReady = false;
562
604
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
563
- wrapByKey.clear();
564
605
  S.cfg = null;
565
606
  S.pools = { topics: [], posts: [], categories: [] };
566
607
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -577,14 +618,15 @@
577
618
 
578
619
  function ensureDomObserver() {
579
620
  if (S.domObs) return;
580
- const allSel = [SEL.post, SEL.topic, SEL.category];
581
621
  S.domObs = new MutationObserver(muts => {
582
622
  if (S.mutGuard > 0 || isBlocked()) return;
583
623
  for (const m of muts) {
584
624
  if (!m.addedNodes?.length) continue;
585
625
  for (const n of m.addedNodes) {
586
626
  if (n.nodeType !== 1) continue;
587
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
627
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
628
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
629
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
588
630
  requestBurst(); return;
589
631
  }
590
632
  }
@@ -610,20 +652,29 @@
610
652
  }
611
653
 
612
654
  function ensureTcfLocator() {
613
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
614
- // En navigation ajaxify, NodeBB peut la retirer → erreurs CMP.
655
+ // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
656
+ // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
657
+ // iframe du DOM (vidage partiel du body), ce qui provoque :
658
+ // "Cannot read properties of null (reading 'postMessage')"
659
+ // "Cannot set properties of null (setting 'addtlConsent')"
660
+ // Solution : la recrée immédiatement si elle disparaît, via un observer.
615
661
  try {
616
662
  if (!window.__tcfapi && !window.__cmp) return;
663
+
617
664
  const inject = () => {
618
665
  if (document.getElementById('__tcfapiLocator')) return;
619
666
  const f = document.createElement('iframe');
620
667
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
621
668
  (document.body || document.documentElement).appendChild(f);
622
669
  };
670
+
623
671
  inject();
672
+
673
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
624
674
  if (!window.__nbbTcfObs) {
625
- window.__nbbTcfObs = new MutationObserver(inject);
626
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
675
+ window.__nbbTcfObs = new MutationObserver(() => inject());
676
+ window.__nbbTcfObs.observe(document.documentElement,
677
+ { childList: true, subtree: true });
627
678
  }
628
679
  } catch (_) {}
629
680
  }
@@ -633,10 +684,10 @@
633
684
  const head = document.head;
634
685
  if (!head) return;
635
686
  for (const [rel, href, cors] of [
636
- ['preconnect', 'https://g.ezoic.net', true ],
637
- ['preconnect', 'https://go.ezoic.net', true ],
638
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
639
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
687
+ ['preconnect', 'https://g.ezoic.net', true],
688
+ ['preconnect', 'https://go.ezoic.net', true],
689
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
690
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
640
691
  ['dns-prefetch', 'https://g.ezoic.net', false],
641
692
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
642
693
  ]) {
@@ -670,8 +721,10 @@
670
721
  'action:posts.loaded', 'action:topics.loaded',
671
722
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
672
723
  ].map(e => `${e}.nbbEzoic`).join(' ');
724
+
673
725
  $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
674
726
 
727
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
675
728
  try {
676
729
  require(['hooks'], hooks => {
677
730
  if (typeof hooks?.on !== 'function') return;