nodebb-plugin-ezoic-infinite 1.7.3 → 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 +177 -275
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.3",
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,40 +54,40 @@
60
54
  };
61
55
 
62
56
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
57
+ * Table KIND source de vérité par kindClass.
64
58
  *
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
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)
71
69
  */
72
70
  const KIND = {
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' },
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 },
76
74
  };
77
75
 
78
76
  // ── État ───────────────────────────────────────────────────────────────────
79
77
 
80
78
  const S = {
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
-
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(),
97
91
  runQueued: false,
98
92
  burstActive: false,
99
93
  burstDeadline: 0,
@@ -102,8 +96,12 @@
102
96
  };
103
97
 
104
98
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- 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]'));
107
105
 
108
106
  function mutate(fn) {
109
107
  S.mutGuard++;
@@ -121,12 +119,6 @@
121
119
  return S.cfg;
122
120
  }
123
121
 
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
-
130
122
  function parseIds(raw) {
131
123
  const out = [], seen = new Set();
132
124
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +128,13 @@
136
128
  return out;
137
129
  }
138
130
 
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; } };
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
+ }
145
138
 
146
139
  // ── Page identity ──────────────────────────────────────────────────────────
147
140
 
@@ -165,13 +158,13 @@
165
158
  return 'other';
166
159
  }
167
160
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
161
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
162
 
170
163
  function getPosts() {
171
164
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
165
  if (!el.isConnected) return false;
173
166
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
167
+ const p = el.parentElement?.closest(SEL.post);
175
168
  if (p && p !== el) return false;
176
169
  return el.getAttribute('component') !== 'post/parent';
177
170
  });
@@ -187,46 +180,41 @@
187
180
  );
188
181
  }
189
182
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
183
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
184
 
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;
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;
199
190
  if (attr) {
200
191
  const v = el.getAttribute(attr);
201
192
  if (v !== null && v !== '') return v;
202
193
  }
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 (_) {}
194
+ let i = 0;
195
+ for (const s of el.parentElement?.children ?? []) {
196
+ if (s === el) return `i${i}`;
197
+ i++;
198
+ }
211
199
  return 'i0';
212
200
  }
213
201
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
202
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
203
 
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; }
222
- }
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
+ };
223
211
 
224
212
  // ── Pool ───────────────────────────────────────────────────────────────────
225
213
 
226
214
  function pickId(poolKey) {
227
215
  const pool = S.pools[poolKey];
228
216
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
217
+ const i = S.cursors[poolKey] % pool.length;
230
218
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
219
  const id = pool[i];
232
220
  if (!S.mountedIds.has(id)) return id;
@@ -237,7 +225,7 @@
237
225
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
226
 
239
227
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
228
+ const w = document.createElement('div');
241
229
  w.className = `${WRAP_CLASS} ${klass}`;
242
230
  w.setAttribute(A_ANCHOR, key);
243
231
  w.setAttribute(A_WRAPID, String(id));
@@ -251,55 +239,25 @@
251
239
  }
252
240
 
253
241
  function insertAfter(el, id, klass, key) {
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
+ 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;
258
246
  const w = makeWrap(id, klass, key);
259
247
  mutate(() => el.insertAdjacentElement('afterend', w));
260
248
  S.mountedIds.add(id);
249
+ wrapByKey.set(key, w);
261
250
  return w;
262
251
  }
263
252
 
264
- /**
265
- * Retire proprement un wrap du DOM.
266
- *
267
- * Sans précaution, retirer un wrap contenant un player vidéo Ezoic (wyvern.js)
268
- * déclenche des erreurs async sur des nœuds détachés :
269
- * "Cannot read properties of null (reading 'paused')"
270
- * "Cannot read properties of null (reading 'offsetWidth')"
271
- * "Invalid target for null#trigger / null#on"
272
- *
273
- * On pause les media et on tente de notifier l'API wyvern avant remove().
274
- */
275
253
  function dropWrap(w) {
276
254
  try {
277
- // 1. Pause tous les media actifs avant détachement
278
- try {
279
- w.querySelectorAll('video, audio').forEach(m => {
280
- try { if (!m.paused) m.pause(); } catch (_) {}
281
- });
282
- } catch (_) {}
283
-
284
- // 2. Notifier l'API wyvern si disponible
285
- try {
286
- if (window.wyvern && typeof window.wyvern === 'object') {
287
- w.querySelectorAll('[id^="ezoic-"],[class*="wyvern"],[class*="ezoic-video"]')
288
- .forEach(n => { try { window.wyvern.destroy?.(n); } catch (_) {} });
289
- }
290
- } catch (_) {}
291
-
292
- // 3. Unobserve (guard instanceof Element — unobserve(null) corrompt l'IO)
293
- try {
294
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
295
- if (ph instanceof Element) S.io?.unobserve(ph);
296
- } catch (_) {}
297
-
298
- // 4. Libérer l'id du pool
255
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
256
+ if (ph instanceof Element) S.io?.unobserve(ph);
299
257
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
300
258
  if (Number.isFinite(id)) S.mountedIds.delete(id);
301
-
302
- // 5. Retrait DOM
259
+ const key = w.getAttribute(A_ANCHOR);
260
+ if (key) wrapByKey.delete(key);
303
261
  w.remove();
304
262
  } catch (_) {}
305
263
  }
@@ -307,58 +265,39 @@
307
265
  // ── Prune ──────────────────────────────────────────────────────────────────
308
266
 
309
267
  /**
310
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
311
- *
312
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
313
- * Exemples :
314
- * ezoic-ad-message → cherche [data-pid="123"]
315
- * ezoic-ad-between → cherche [data-index="5"]
316
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
268
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
317
269
  *
318
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
319
- */
320
- /**
321
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
322
- *
323
- * Règle stricte : on ne supprime JAMAIS un wrap rempli (filled).
324
- * - Il peut contenir un player wyvern actif → .remove() déclenche des
325
- * erreurs async ("Cannot read 'paused'", "offsetWidth", "getChild"…).
326
- * - Le post-ancre peut être temporairement virtualisé par NodeBB puis
327
- * revenir — dans ce cas le wrap filled doit rester en place.
328
- *
329
- * Seuls les wraps VIDES dont l'ancre a disparu sont supprimés.
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.
330
276
  */
331
277
  function pruneOrphans(klass) {
332
278
  const meta = KIND[klass];
333
279
  if (!meta) return;
334
280
 
335
- const baseTag = meta.sel.split('[')[0];
336
-
337
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
338
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
339
-
340
- // Ne jamais retirer un wrap qui contient du contenu (player potentiellement actif)
341
- if (isFilled(w)) return;
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;
342
284
 
343
285
  const key = w.getAttribute(A_ANCHOR) ?? '';
344
286
  const sid = key.slice(klass.length + 1);
345
- if (!sid) { mutate(() => dropWrap(w)); return; }
287
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
346
288
 
347
- const anchorEl = document.querySelector(
348
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
349
- );
289
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
290
+ const anchorEl = document.querySelector(sel);
350
291
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
351
- });
292
+ }
352
293
  }
353
294
 
354
295
  // ── Decluster ──────────────────────────────────────────────────────────────
355
296
 
356
297
  /**
357
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
358
- * Priorité : filled > en grâce (fill en cours) > vide.
359
- *
360
- * Règle de sécurité wyvern : on ne supprime JAMAIS un wrap rempli.
361
- * 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).
362
301
  */
363
302
  function decluster(klass) {
364
303
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
@@ -372,14 +311,9 @@
372
311
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
373
312
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
374
313
 
375
- const wFilled = isFilled(w);
376
- const pFilled = isFilled(prev);
377
-
378
- // Ne jamais retirer un wrap rempli (player actif potentiel)
379
- if (!wFilled && !pFilled) mutate(() => dropWrap(w)); // les deux vides → retirer le courant
380
- else if (!wFilled && pFilled) mutate(() => dropWrap(w)); // courant vide, précédent rempli → retirer le courant
381
- else if ( wFilled && !pFilled) mutate(() => dropWrap(prev)); // courant rempli, précédent vide → retirer le précédent
382
- // 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
383
317
  break;
384
318
  }
385
319
  }
@@ -389,23 +323,22 @@
389
323
 
390
324
  /**
391
325
  * Ordinal 0-based pour le calcul de l'intervalle.
392
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
393
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
326
+ * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
327
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
394
328
  */
395
329
  function ordinal(klass, el) {
396
- const di = el.getAttribute('data-index');
397
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
398
- // Fallback positionnel
399
- try {
400
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
401
- if (tag) {
402
- let i = 0;
403
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
404
- if (n === el) return i;
405
- i++;
406
- }
407
- }
408
- } catch (_) {}
330
+ const attr = KIND[klass]?.ordinalAttr;
331
+ if (attr) {
332
+ const v = el.getAttribute(attr);
333
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
334
+ }
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
+ }
409
342
  return 0;
410
343
  }
411
344
 
@@ -414,20 +347,18 @@
414
347
  let inserted = 0;
415
348
 
416
349
  for (const el of items) {
417
- if (inserted >= MAX_INSERTS_PER_RUN) break;
350
+ if (inserted >= MAX_INSERTS_RUN) break;
418
351
  if (!el?.isConnected) continue;
419
352
 
420
- const ord = ordinal(klass, el);
421
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
422
- if (!isTarget) continue;
423
-
353
+ const ord = ordinal(klass, el);
354
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
424
355
  if (adjacentWrap(el)) continue;
425
356
 
426
- const key = makeAnchorKey(klass, el);
427
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
357
+ const key = anchorKey(klass, el);
358
+ if (findWrap(key)) continue;
428
359
 
429
360
  const id = pickId(poolKey);
430
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
361
+ if (!id) continue;
431
362
 
432
363
  const w = insertAfter(el, id, klass, key);
433
364
  if (w) { observePh(id); inserted++; }
@@ -439,7 +370,6 @@
439
370
 
440
371
  function getIO() {
441
372
  if (S.io) return S.io;
442
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
443
373
  try {
444
374
  S.io = new IntersectionObserver(entries => {
445
375
  for (const e of entries) {
@@ -448,7 +378,7 @@
448
378
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
449
379
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
450
380
  }
451
- }, { root: null, rootMargin: margin, threshold: 0 });
381
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
452
382
  } catch (_) { S.io = null; }
453
383
  return S.io;
454
384
  }
@@ -499,7 +429,6 @@
499
429
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
500
430
  S.lastShow.set(id, t);
501
431
 
502
- // Horodater le show sur le wrap pour grace period + emptyCheck
503
432
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
504
433
 
505
434
  window.ezstandalone = window.ezstandalone || {};
@@ -520,7 +449,6 @@
520
449
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
521
450
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
522
451
  if (!wrap || !ph?.isConnected) return;
523
- // Un show plus récent → ne pas toucher
524
452
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
525
453
  wrap.classList.toggle('is-empty', !isFilled(ph));
526
454
  } catch (_) {}
@@ -539,7 +467,7 @@
539
467
  const orig = ez.showAds.bind(ez);
540
468
  ez.showAds = function (...args) {
541
469
  if (isBlocked()) return;
542
- 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;
543
471
  const seen = new Set();
544
472
  for (const v of ids) {
545
473
  const id = parseInt(v, 10);
@@ -558,11 +486,10 @@
558
486
  }
559
487
  }
560
488
 
561
- // ── Core run ───────────────────────────────────────────────────────────────
489
+ // ── Core ───────────────────────────────────────────────────────────────────
562
490
 
563
491
  async function runCore() {
564
492
  if (isBlocked()) return 0;
565
- patchShowAds();
566
493
 
567
494
  const cfg = await fetchConfig();
568
495
  if (!cfg || cfg.excluded) return 0;
@@ -573,10 +500,9 @@
573
500
 
574
501
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
575
502
  if (!normBool(cfgEnable)) return 0;
576
- const items = getItems();
577
503
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
578
504
  pruneOrphans(klass);
579
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
505
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
580
506
  if (n) decluster(klass);
581
507
  return n;
582
508
  };
@@ -589,14 +515,13 @@
589
515
  'ezoic-ad-between', getTopics,
590
516
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
591
517
  );
592
- if (kind === 'categories') return exec(
518
+ return exec(
593
519
  'ezoic-ad-categories', getCategories,
594
520
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
595
521
  );
596
- return 0;
597
522
  }
598
523
 
599
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
524
+ // ── Scheduler ──────────────────────────────────────────────────────────────
600
525
 
601
526
  function scheduleRun(cb) {
602
527
  if (S.runQueued) return;
@@ -606,7 +531,7 @@
606
531
  if (S.pageKey && pageKey() !== S.pageKey) return;
607
532
  let n = 0;
608
533
  try { n = await runCore(); } catch (_) {}
609
- try { cb?.(n); } catch (_) {}
534
+ cb?.(n);
610
535
  });
611
536
  }
612
537
 
@@ -614,10 +539,8 @@
614
539
  if (isBlocked()) return;
615
540
  const t = ts();
616
541
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
617
- S.lastBurstTs = t;
618
-
619
- const pk = pageKey();
620
- S.pageKey = pk;
542
+ S.lastBurstTs = t;
543
+ S.pageKey = pageKey();
621
544
  S.burstDeadline = t + 2000;
622
545
 
623
546
  if (S.burstActive) return;
@@ -625,7 +548,7 @@
625
548
  S.burstCount = 0;
626
549
 
627
550
  const step = () => {
628
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
551
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
629
552
  S.burstActive = false; return;
630
553
  }
631
554
  S.burstCount++;
@@ -637,21 +560,13 @@
637
560
  step();
638
561
  }
639
562
 
640
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
563
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
641
564
 
642
565
  function cleanup() {
643
566
  blockedUntil = ts() + 1500;
644
-
645
- // Pause tous les media dans nos wraps AVANT de les retirer du DOM.
646
- // Empêche wyvern.js de continuer d'exécuter ses callbacks async sur des
647
- // nœuds détachés (erreurs "Cannot read 'paused'", "offsetWidth"…).
648
- try {
649
- document.querySelectorAll(`.${WRAP_CLASS} video, .${WRAP_CLASS} audio`).forEach(m => {
650
- try { if (!m.paused) m.pause(); } catch (_) {}
651
- });
652
- } catch (_) {}
653
-
567
+ poolsReady = false;
654
568
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
569
+ wrapByKey.clear();
655
570
  S.cfg = null;
656
571
  S.pools = { topics: [], posts: [], categories: [] };
657
572
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -664,19 +579,17 @@
664
579
  S.runQueued = false;
665
580
  }
666
581
 
667
- // ── DOM Observer ───────────────────────────────────────────────────────────
582
+ // ── MutationObserver ───────────────────────────────────────────────────────
668
583
 
669
584
  function ensureDomObserver() {
670
585
  if (S.domObs) return;
586
+ const allSel = [SEL.post, SEL.topic, SEL.category];
671
587
  S.domObs = new MutationObserver(muts => {
672
588
  if (S.mutGuard > 0 || isBlocked()) return;
673
589
  for (const m of muts) {
674
- if (!m.addedNodes?.length) continue;
675
590
  for (const n of m.addedNodes) {
676
591
  if (n.nodeType !== 1) continue;
677
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
678
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
679
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
592
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
680
593
  requestBurst(); return;
681
594
  }
682
595
  }
@@ -702,29 +615,21 @@
702
615
  }
703
616
 
704
617
  function ensureTcfLocator() {
705
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
706
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
707
- // iframe du DOM (vidage partiel du body), ce qui provoque :
708
- // "Cannot read properties of null (reading 'postMessage')"
709
- // "Cannot set properties of null (setting 'addtlConsent')"
710
- // 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.
711
621
  try {
712
622
  if (!window.__tcfapi && !window.__cmp) return;
713
-
714
623
  const inject = () => {
715
624
  if (document.getElementById('__tcfapiLocator')) return;
716
625
  const f = document.createElement('iframe');
717
626
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
718
627
  (document.body || document.documentElement).appendChild(f);
719
628
  };
720
-
721
629
  inject();
722
-
723
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
724
630
  if (!window.__nbbTcfObs) {
725
- window.__nbbTcfObs = new MutationObserver(() => inject());
726
- window.__nbbTcfObs.observe(document.documentElement,
727
- { childList: true, subtree: true });
631
+ window.__nbbTcfObs = new MutationObserver(inject);
632
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
728
633
  }
729
634
  } catch (_) {}
730
635
  }
@@ -734,10 +639,10 @@
734
639
  const head = document.head;
735
640
  if (!head) return;
736
641
  for (const [rel, href, cors] of [
737
- ['preconnect', 'https://g.ezoic.net', true],
738
- ['preconnect', 'https://go.ezoic.net', true],
739
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
740
- ['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 ],
741
646
  ['dns-prefetch', 'https://g.ezoic.net', false],
742
647
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
743
648
  ]) {
@@ -751,7 +656,7 @@
751
656
  }
752
657
  }
753
658
 
754
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
659
+ // ── Bindings ───────────────────────────────────────────────────────────────
755
660
 
756
661
  function bindNodeBB() {
757
662
  const $ = window.jQuery;
@@ -762,19 +667,16 @@
762
667
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
763
668
  S.pageKey = pageKey();
764
669
  blockedUntil = 0;
765
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
766
- getIO(); ensureDomObserver(); requestBurst();
670
+ muteConsole(); ensureTcfLocator(); warmNetwork();
671
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
767
672
  });
768
673
 
769
- const BURST_EVENTS = [
770
- 'action:ajaxify.contentLoaded',
771
- 'action:posts.loaded', 'action:topics.loaded',
674
+ const burstEvts = [
675
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
772
676
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
773
677
  ].map(e => `${e}.nbbEzoic`).join(' ');
678
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
774
679
 
775
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
776
-
777
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
778
680
  try {
779
681
  require(['hooks'], hooks => {
780
682
  if (typeof hooks?.on !== 'function') return;