nodebb-plugin-ezoic-infinite 1.7.4 → 1.7.5

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 +172 -274
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,55 +1,49 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v21.1
3
3
  *
4
- * Correctifs critiques vs v19
5
- * ───────────────────────────
6
- * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
- * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
- * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
- * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
- * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
- * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
+ * Suppression du recyclage de wraps (moveWrapAfter). Cleanup complet navigation.
12
8
  *
13
- * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
- * Fix : on skip uniquement le wrap courant, pas toute la boucle.
9
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
+ * la position dans le batch courant.
15
11
  *
16
- * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
- * existants sur les items suivants. Fix : `continue` au lieu de `break`.
12
+ * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
13
+ * Fix fatal catégories : data-cid au lieu de data-index inexistant.
14
+ * Fix posts : baseTag vide → ordinal fallback cassé → pubs jamais injectées.
15
+ * IO fixe (une instance, jamais recréée). Burst cooldown 200 ms.
16
+ * Fix unobserve(null) → corruption IO → pubads error au scroll retour.
17
+ * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
18
18
  *
19
- * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
- * Fix : marge large fixe par device, observer créé une seule fois.
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é)
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.
31
25
  */
32
26
  (function () {
33
27
  'use strict';
34
28
 
35
29
  // ── Constantes ─────────────────────────────────────────────────────────────
36
30
 
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)
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
53
47
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
48
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
49
 
@@ -60,20 +54,18 @@
60
54
  };
61
55
 
62
56
  /**
63
- * Table centrale par kindClass :
57
+ * Table KIND — source de vérité par kindClass.
64
58
  *
65
- * sel : sélecteur CSS complet de l'élément cible
66
- * baseTag : tag CSS à préfixer dans les querySelector de recherche
67
- * (déduit manuellement évite le fragile split('[')[0])
68
- * anchorAttr : attribut DOM STABLE qui identifie l'élément de façon unique
69
- * et permanente, utilisé comme clé d'ancre du wrap.
70
- *data-pid pour posts (id message, immuable)
71
- *data-index pour topics (position dans la liste)
72
- * data-cid pour catégories (id catégorie, immuable)
73
- * ordinalAttr: attribut utilisé pour calculer l'intervalle d'injection.
74
- * Doit être un entier 0-based fourni par NodeBB.
75
- * Pour posts ET topics : data-index (0-based, toujours présent).
76
- * Pour catégories : pas d'infinite scroll → fallback positionnel.
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)
77
69
  */
78
70
  const KIND = {
79
71
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -84,22 +76,18 @@
84
76
  // ── État ───────────────────────────────────────────────────────────────────
85
77
 
86
78
  const S = {
87
- pageKey: null,
88
- cfg: null,
89
-
90
- pools: { topics: [], posts: [], categories: [] },
91
- cursors: { topics: 0, posts: 0, categories: 0 },
92
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
93
- lastShow: new Map(), // id → timestamp dernier show
94
-
95
- io: null,
96
- domObs: null,
97
- mutGuard: 0, // compteur internalMutation
98
-
99
- inflight: 0,
100
- pending: [],
101
- pendingSet: new Set(),
102
-
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(),
103
91
  runQueued: false,
104
92
  burstActive: false,
105
93
  burstDeadline: 0,
@@ -108,8 +96,12 @@
108
96
  };
109
97
 
110
98
  let blockedUntil = 0;
111
- const isBlocked = () => Date.now() < blockedUntil;
112
- const ts = () => Date.now();
99
+ let poolsReady = false; // true dès que les pools sont initialisés pour la page courante
100
+ const ts = () => Date.now();
101
+ const isBlocked = () => ts() < blockedUntil;
102
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
103
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
104
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
113
105
 
114
106
  function mutate(fn) {
115
107
  S.mutGuard++;
@@ -127,12 +119,6 @@
127
119
  return S.cfg;
128
120
  }
129
121
 
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
- }
135
-
136
122
  function parseIds(raw) {
137
123
  const out = [], seen = new Set();
138
124
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -142,12 +128,13 @@
142
128
  return out;
143
129
  }
144
130
 
145
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
146
-
147
- const isFilled = (n) =>
148
- !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
149
-
150
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
131
+ function initPools(cfg) {
132
+ if (poolsReady) return;
133
+ S.pools.topics = parseIds(cfg.placeholderIds);
134
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
135
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
136
+ poolsReady = true;
137
+ }
151
138
 
152
139
  // ── Page identity ──────────────────────────────────────────────────────────
153
140
 
@@ -171,13 +158,13 @@
171
158
  return 'other';
172
159
  }
173
160
 
174
- // ── DOM helpers ────────────────────────────────────────────────────────────
161
+ // ── Items DOM ──────────────────────────────────────────────────────────────
175
162
 
176
163
  function getPosts() {
177
164
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
178
165
  if (!el.isConnected) return false;
179
166
  if (!el.querySelector('[component="post/content"]')) return false;
180
- const p = el.parentElement?.closest('[component="post"][data-pid]');
167
+ const p = el.parentElement?.closest(SEL.post);
181
168
  if (p && p !== el) return false;
182
169
  return el.getAttribute('component') !== 'post/parent';
183
170
  });
@@ -193,46 +180,41 @@
193
180
  );
194
181
  }
195
182
 
196
- // ── Ancres stables ────────────────────────────────────────────────────────
183
+ // ── Ancres stables ─────────────────────────────────────────────────────────
197
184
 
198
- /**
199
- * Retourne l'identifiant stable de l'élément selon son kindClass.
200
- * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
201
- * Fallback positionnel si l'attribut est absent.
202
- */
203
- function stableId(kindClass, el) {
204
- const attr = KIND[kindClass]?.anchorAttr;
185
+ // Map anchorKey → wrap Element — évite un querySelector full-DOM à chaque injection
186
+ const wrapByKey = new Map();
187
+
188
+ function stableId(klass, el) {
189
+ const attr = KIND[klass]?.anchorAttr;
205
190
  if (attr) {
206
191
  const v = el.getAttribute(attr);
207
192
  if (v !== null && v !== '') return v;
208
193
  }
209
- // Fallback : position dans le parent
210
- try {
211
- let i = 0;
212
- for (const s of el.parentElement?.children ?? []) {
213
- if (s === el) return `i${i}`;
214
- i++;
215
- }
216
- } catch (_) {}
194
+ let i = 0;
195
+ for (const s of el.parentElement?.children ?? []) {
196
+ if (s === el) return `i${i}`;
197
+ i++;
198
+ }
217
199
  return 'i0';
218
200
  }
219
201
 
220
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
202
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
221
203
 
222
- function findWrap(anchorKey) {
223
- try {
224
- return document.querySelector(
225
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
226
- );
227
- } catch (_) { return null; }
228
- }
204
+ const findWrap = (key) => {
205
+ const w = wrapByKey.get(key);
206
+ // Vérifier que le wrap est toujours dans le DOM (il peut avoir été dropWrap'd)
207
+ if (w && w.isConnected) return w;
208
+ if (w) wrapByKey.delete(key); // nettoyage lazy
209
+ return null;
210
+ };
229
211
 
230
212
  // ── Pool ───────────────────────────────────────────────────────────────────
231
213
 
232
214
  function pickId(poolKey) {
233
215
  const pool = S.pools[poolKey];
234
216
  for (let t = 0; t < pool.length; t++) {
235
- const i = S.cursors[poolKey] % pool.length;
217
+ const i = S.cursors[poolKey] % pool.length;
236
218
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
237
219
  const id = pool[i];
238
220
  if (!S.mountedIds.has(id)) return id;
@@ -243,7 +225,7 @@
243
225
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
244
226
 
245
227
  function makeWrap(id, klass, key) {
246
- const w = document.createElement('div');
228
+ const w = document.createElement('div');
247
229
  w.className = `${WRAP_CLASS} ${klass}`;
248
230
  w.setAttribute(A_ANCHOR, key);
249
231
  w.setAttribute(A_WRAPID, String(id));
@@ -257,55 +239,25 @@
257
239
  }
258
240
 
259
241
  function insertAfter(el, id, klass, key) {
260
- if (!el?.insertAdjacentElement) return null;
261
- if (findWrap(key)) return null; // ancre déjà présente
262
- if (S.mountedIds.has(id)) return null; // id déjà monté
263
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
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;
264
246
  const w = makeWrap(id, klass, key);
265
247
  mutate(() => el.insertAdjacentElement('afterend', w));
266
248
  S.mountedIds.add(id);
249
+ wrapByKey.set(key, w);
267
250
  return w;
268
251
  }
269
252
 
270
- /**
271
- * Retire proprement un wrap du DOM.
272
- *
273
- * Sans précaution, retirer un wrap contenant un player vidéo Ezoic (wyvern.js)
274
- * déclenche des erreurs async sur des nœuds détachés :
275
- * "Cannot read properties of null (reading 'paused')"
276
- * "Cannot read properties of null (reading 'offsetWidth')"
277
- * "Invalid target for null#trigger / null#on"
278
- *
279
- * On pause les media et on tente de notifier l'API wyvern avant remove().
280
- */
281
253
  function dropWrap(w) {
282
254
  try {
283
- // 1. Pause tous les media actifs avant détachement
284
- try {
285
- w.querySelectorAll('video, audio').forEach(m => {
286
- try { if (!m.paused) m.pause(); } catch (_) {}
287
- });
288
- } catch (_) {}
289
-
290
- // 2. Notifier l'API wyvern si disponible
291
- try {
292
- if (window.wyvern && typeof window.wyvern === 'object') {
293
- w.querySelectorAll('[id^="ezoic-"],[class*="wyvern"],[class*="ezoic-video"]')
294
- .forEach(n => { try { window.wyvern.destroy?.(n); } catch (_) {} });
295
- }
296
- } catch (_) {}
297
-
298
- // 3. Unobserve (guard instanceof Element — unobserve(null) corrompt l'IO)
299
- try {
300
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
301
- if (ph instanceof Element) S.io?.unobserve(ph);
302
- } catch (_) {}
303
-
304
- // 4. Libérer l'id du pool
255
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
256
+ if (ph instanceof Element) S.io?.unobserve(ph);
305
257
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
306
258
  if (Number.isFinite(id)) S.mountedIds.delete(id);
307
-
308
- // 5. Retrait DOM
259
+ const key = w.getAttribute(A_ANCHOR);
260
+ if (key) wrapByKey.delete(key);
309
261
  w.remove();
310
262
  } catch (_) {}
311
263
  }
@@ -313,45 +265,39 @@
313
265
  // ── Prune ──────────────────────────────────────────────────────────────────
314
266
 
315
267
  /**
316
- * Supprime les wraps VIDES dont l'élément-ancre n'est plus dans le DOM.
268
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
317
269
  *
318
- * Règles :
319
- * 1. Jamais avant MIN_PRUNE_AGE_MS (DOM post-batch pas encore stabilisé).
320
- * 2. Jamais un wrap rempli (player wyvern potentiellement actif).
321
- * 3. L'ancre est retrouvée via KIND[klass].anchorAttr (stable par design) :
322
- * ezoic-ad-message → [data-pid="123"]
323
- * ezoic-ad-between → li[data-index="5"]
324
- * ezoic-ad-categories → li[data-cid="7"]
270
+ * On ne supprime JAMAIS un wrap rempli (filled) :
271
+ * - Les wraps remplis peuvent être temporairement orphelins lors d'une
272
+ * virtualisation NodeBB l'ancre reviendra.
273
+ * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
274
+ * retirer le nœud sous ses pieds génère des erreurs non critiques mais
275
+ * inutiles. Le cleanup de navigation gère la suppression définitive.
325
276
  */
326
277
  function pruneOrphans(klass) {
327
278
  const meta = KIND[klass];
328
279
  if (!meta) return;
329
280
 
330
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
331
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
332
- if (isFilled(w)) return; // jamais supprimer un wrap rempli (wyvern)
281
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
282
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
283
+ if (isFilled(w)) continue;
333
284
 
334
285
  const key = w.getAttribute(A_ANCHOR) ?? '';
335
286
  const sid = key.slice(klass.length + 1);
336
- if (!sid) { mutate(() => dropWrap(w)); return; }
287
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
337
288
 
338
- // Construire le sélecteur avec baseTag explicite depuis KIND
339
- // baseTag='' pour posts → sélecteur : [data-pid="123"] (correct, sans tag ambigu)
340
- // baseTag='li' pour topics/categories → li[data-index="5"], li[data-cid="7"]
341
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
289
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
342
290
  const anchorEl = document.querySelector(sel);
343
291
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
344
- });
292
+ }
345
293
  }
346
294
 
347
295
  // ── Decluster ──────────────────────────────────────────────────────────────
348
296
 
349
297
  /**
350
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
351
- * Priorité : filled > en grâce (fill en cours) > vide.
352
- *
353
- * Règle de sécurité wyvern : on ne supprime JAMAIS un wrap rempli.
354
- * Si les deux wraps adjacents sont remplis, on laisse en place (edge case rare).
298
+ * Deux wraps adjacents → supprimer le moins prioritaire.
299
+ * Priorité : filled > en grâce de fill > vide.
300
+ * Jamais de suppression d'un wrap rempli (même raison que pruneOrphans).
355
301
  */
356
302
  function decluster(klass) {
357
303
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
@@ -365,14 +311,9 @@
365
311
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
366
312
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
367
313
 
368
- const wFilled = isFilled(w);
369
- const pFilled = isFilled(prev);
370
-
371
- // Ne jamais retirer un wrap rempli (player actif potentiel)
372
- if (!wFilled && !pFilled) mutate(() => dropWrap(w)); // les deux vides → retirer le courant
373
- else if (!wFilled && pFilled) mutate(() => dropWrap(w)); // courant vide, précédent rempli → retirer le courant
374
- else if ( wFilled && !pFilled) mutate(() => dropWrap(prev)); // courant rempli, précédent vide → retirer le précédent
375
- // les deux remplis → rien (on ne touche pas)
314
+ if (!isFilled(w)) mutate(() => dropWrap(w));
315
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
316
+ // les deux remplis → on ne touche pas
376
317
  break;
377
318
  }
378
319
  }
@@ -381,35 +322,23 @@
381
322
  // ── Injection ──────────────────────────────────────────────────────────────
382
323
 
383
324
  /**
384
- * Ordinal 0-based d'un élément pour le calcul de l'intervalle d'injection.
385
- *
386
- * Utilise KIND[klass].ordinalAttr en priorité (data-index pour posts et topics,
387
- * null pour catégories). Si absent, fallback positionnel dans le parent.
388
- *
389
- * Pour les posts : KIND.baseTag='' donc le fallback itère sur les enfants directs
390
- * du parent en filtrant par sélecteur complet (pas juste le tag).
325
+ * Ordinal 0-based pour le calcul de l'intervalle.
326
+ * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
327
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
391
328
  */
392
329
  function ordinal(klass, el) {
393
- const meta = KIND[klass];
394
-
395
- // 1. Attribut ordinal explicite (data-index sur posts et topics)
396
- if (meta?.ordinalAttr) {
397
- const v = el.getAttribute(meta.ordinalAttr);
330
+ const attr = KIND[klass]?.ordinalAttr;
331
+ if (attr) {
332
+ const v = el.getAttribute(attr);
398
333
  if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
399
334
  }
400
-
401
- // 2. Fallback positionnel compte parmi les frères qui matchent le même sélecteur
402
- try {
403
- let i = 0;
404
- const siblings = el.parentElement?.children ?? [];
405
- const fullSel = meta?.sel ?? '';
406
- for (const s of siblings) {
407
- if (s === el) return i;
408
- // Compter uniquement les éléments du même type (pas les wraps ou autres)
409
- if (!fullSel || s.matches?.(fullSel)) i++;
410
- }
411
- } catch (_) {}
412
-
335
+ // Fallback positionnel — compte uniquement les éléments du même type
336
+ const fullSel = KIND[klass]?.sel ?? '';
337
+ let i = 0;
338
+ for (const s of el.parentElement?.children ?? []) {
339
+ if (s === el) return i;
340
+ if (!fullSel || s.matches?.(fullSel)) i++;
341
+ }
413
342
  return 0;
414
343
  }
415
344
 
@@ -418,20 +347,18 @@
418
347
  let inserted = 0;
419
348
 
420
349
  for (const el of items) {
421
- if (inserted >= MAX_INSERTS_PER_RUN) break;
350
+ if (inserted >= MAX_INSERTS_RUN) break;
422
351
  if (!el?.isConnected) continue;
423
352
 
424
- const ord = ordinal(klass, el);
425
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
426
- if (!isTarget) continue;
427
-
353
+ const ord = ordinal(klass, el);
354
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
428
355
  if (adjacentWrap(el)) continue;
429
356
 
430
- const key = makeAnchorKey(klass, el);
431
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
357
+ const key = anchorKey(klass, el);
358
+ if (findWrap(key)) continue;
432
359
 
433
360
  const id = pickId(poolKey);
434
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
361
+ if (!id) continue;
435
362
 
436
363
  const w = insertAfter(el, id, klass, key);
437
364
  if (w) { observePh(id); inserted++; }
@@ -443,7 +370,6 @@
443
370
 
444
371
  function getIO() {
445
372
  if (S.io) return S.io;
446
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
447
373
  try {
448
374
  S.io = new IntersectionObserver(entries => {
449
375
  for (const e of entries) {
@@ -452,7 +378,7 @@
452
378
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
453
379
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
454
380
  }
455
- }, { root: null, rootMargin: margin, threshold: 0 });
381
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
456
382
  } catch (_) { S.io = null; }
457
383
  return S.io;
458
384
  }
@@ -503,7 +429,6 @@
503
429
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
504
430
  S.lastShow.set(id, t);
505
431
 
506
- // Horodater le show sur le wrap pour grace period + emptyCheck
507
432
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
508
433
 
509
434
  window.ezstandalone = window.ezstandalone || {};
@@ -524,7 +449,6 @@
524
449
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
525
450
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
526
451
  if (!wrap || !ph?.isConnected) return;
527
- // Un show plus récent → ne pas toucher
528
452
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
529
453
  wrap.classList.toggle('is-empty', !isFilled(ph));
530
454
  } catch (_) {}
@@ -543,7 +467,7 @@
543
467
  const orig = ez.showAds.bind(ez);
544
468
  ez.showAds = function (...args) {
545
469
  if (isBlocked()) return;
546
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
470
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
547
471
  const seen = new Set();
548
472
  for (const v of ids) {
549
473
  const id = parseInt(v, 10);
@@ -562,11 +486,10 @@
562
486
  }
563
487
  }
564
488
 
565
- // ── Core run ───────────────────────────────────────────────────────────────
489
+ // ── Core ───────────────────────────────────────────────────────────────────
566
490
 
567
491
  async function runCore() {
568
492
  if (isBlocked()) return 0;
569
- patchShowAds();
570
493
 
571
494
  const cfg = await fetchConfig();
572
495
  if (!cfg || cfg.excluded) return 0;
@@ -577,10 +500,9 @@
577
500
 
578
501
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
579
502
  if (!normBool(cfgEnable)) return 0;
580
- const items = getItems();
581
503
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
582
504
  pruneOrphans(klass);
583
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
505
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
584
506
  if (n) decluster(klass);
585
507
  return n;
586
508
  };
@@ -593,14 +515,13 @@
593
515
  'ezoic-ad-between', getTopics,
594
516
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
595
517
  );
596
- if (kind === 'categories') return exec(
518
+ return exec(
597
519
  'ezoic-ad-categories', getCategories,
598
520
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
599
521
  );
600
- return 0;
601
522
  }
602
523
 
603
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
524
+ // ── Scheduler ──────────────────────────────────────────────────────────────
604
525
 
605
526
  function scheduleRun(cb) {
606
527
  if (S.runQueued) return;
@@ -610,7 +531,7 @@
610
531
  if (S.pageKey && pageKey() !== S.pageKey) return;
611
532
  let n = 0;
612
533
  try { n = await runCore(); } catch (_) {}
613
- try { cb?.(n); } catch (_) {}
534
+ cb?.(n);
614
535
  });
615
536
  }
616
537
 
@@ -618,10 +539,8 @@
618
539
  if (isBlocked()) return;
619
540
  const t = ts();
620
541
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
621
- S.lastBurstTs = t;
622
-
623
- const pk = pageKey();
624
- S.pageKey = pk;
542
+ S.lastBurstTs = t;
543
+ S.pageKey = pageKey();
625
544
  S.burstDeadline = t + 2000;
626
545
 
627
546
  if (S.burstActive) return;
@@ -629,7 +548,7 @@
629
548
  S.burstCount = 0;
630
549
 
631
550
  const step = () => {
632
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
551
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
633
552
  S.burstActive = false; return;
634
553
  }
635
554
  S.burstCount++;
@@ -641,21 +560,13 @@
641
560
  step();
642
561
  }
643
562
 
644
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
563
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
645
564
 
646
565
  function cleanup() {
647
566
  blockedUntil = ts() + 1500;
648
-
649
- // Pause tous les media dans nos wraps AVANT de les retirer du DOM.
650
- // Empêche wyvern.js de continuer d'exécuter ses callbacks async sur des
651
- // nœuds détachés (erreurs "Cannot read 'paused'", "offsetWidth"…).
652
- try {
653
- document.querySelectorAll(`.${WRAP_CLASS} video, .${WRAP_CLASS} audio`).forEach(m => {
654
- try { if (!m.paused) m.pause(); } catch (_) {}
655
- });
656
- } catch (_) {}
657
-
567
+ poolsReady = false;
658
568
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
569
+ wrapByKey.clear();
659
570
  S.cfg = null;
660
571
  S.pools = { topics: [], posts: [], categories: [] };
661
572
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -668,19 +579,17 @@
668
579
  S.runQueued = false;
669
580
  }
670
581
 
671
- // ── DOM Observer ───────────────────────────────────────────────────────────
582
+ // ── MutationObserver ───────────────────────────────────────────────────────
672
583
 
673
584
  function ensureDomObserver() {
674
585
  if (S.domObs) return;
586
+ const allSel = [SEL.post, SEL.topic, SEL.category];
675
587
  S.domObs = new MutationObserver(muts => {
676
588
  if (S.mutGuard > 0 || isBlocked()) return;
677
589
  for (const m of muts) {
678
- if (!m.addedNodes?.length) continue;
679
590
  for (const n of m.addedNodes) {
680
591
  if (n.nodeType !== 1) continue;
681
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
682
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
683
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
592
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
684
593
  requestBurst(); return;
685
594
  }
686
595
  }
@@ -706,29 +615,21 @@
706
615
  }
707
616
 
708
617
  function ensureTcfLocator() {
709
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
710
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
711
- // iframe du DOM (vidage partiel du body), ce qui provoque :
712
- // "Cannot read properties of null (reading 'postMessage')"
713
- // "Cannot set properties of null (setting 'addtlConsent')"
714
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
618
+ // L'iframe __tcfapiLocator route les appels postMessage du CMP.
619
+ // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
620
+ // Un MutationObserver la recrée dès qu'elle disparaît.
715
621
  try {
716
622
  if (!window.__tcfapi && !window.__cmp) return;
717
-
718
623
  const inject = () => {
719
624
  if (document.getElementById('__tcfapiLocator')) return;
720
625
  const f = document.createElement('iframe');
721
626
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
722
627
  (document.body || document.documentElement).appendChild(f);
723
628
  };
724
-
725
629
  inject();
726
-
727
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
728
630
  if (!window.__nbbTcfObs) {
729
- window.__nbbTcfObs = new MutationObserver(() => inject());
730
- window.__nbbTcfObs.observe(document.documentElement,
731
- { childList: true, subtree: true });
631
+ window.__nbbTcfObs = new MutationObserver(inject);
632
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
732
633
  }
733
634
  } catch (_) {}
734
635
  }
@@ -738,10 +639,10 @@
738
639
  const head = document.head;
739
640
  if (!head) return;
740
641
  for (const [rel, href, cors] of [
741
- ['preconnect', 'https://g.ezoic.net', true],
742
- ['preconnect', 'https://go.ezoic.net', true],
743
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
744
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
642
+ ['preconnect', 'https://g.ezoic.net', true ],
643
+ ['preconnect', 'https://go.ezoic.net', true ],
644
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
645
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
745
646
  ['dns-prefetch', 'https://g.ezoic.net', false],
746
647
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
747
648
  ]) {
@@ -755,7 +656,7 @@
755
656
  }
756
657
  }
757
658
 
758
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
659
+ // ── Bindings ───────────────────────────────────────────────────────────────
759
660
 
760
661
  function bindNodeBB() {
761
662
  const $ = window.jQuery;
@@ -766,19 +667,16 @@
766
667
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
767
668
  S.pageKey = pageKey();
768
669
  blockedUntil = 0;
769
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
770
- getIO(); ensureDomObserver(); requestBurst();
670
+ muteConsole(); ensureTcfLocator(); warmNetwork();
671
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
771
672
  });
772
673
 
773
- const BURST_EVENTS = [
774
- 'action:ajaxify.contentLoaded',
775
- 'action:posts.loaded', 'action:topics.loaded',
674
+ const burstEvts = [
675
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
776
676
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
777
677
  ].map(e => `${e}.nbbEzoic`).join(' ');
678
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
778
679
 
779
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
780
-
781
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
782
680
  try {
783
681
  require(['hooks'], hooks => {
784
682
  if (typeof hooks?.on !== 'function') return;