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