nodebb-plugin-ezoic-infinite 1.7.4 → 1.7.6

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 +183 -273
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.6",
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.2
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,29 @@
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
+ // Unobserve seulement si le placeholder est encore dans le DOM.
256
+ // unobserve() sur un nœud détaché corrompt l'IO interne de pubads
257
+ // → "Failed to execute 'observe': parameter 1 is not of type 'Element'"
258
+ // sur tous les observe() suivants.
259
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
260
+ if (ph instanceof Element && ph.isConnected) S.io?.unobserve(ph);
305
261
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
306
262
  if (Number.isFinite(id)) S.mountedIds.delete(id);
307
-
308
- // 5. Retrait DOM
263
+ const key = w.getAttribute(A_ANCHOR);
264
+ if (key) wrapByKey.delete(key);
309
265
  w.remove();
310
266
  } catch (_) {}
311
267
  }
@@ -313,45 +269,39 @@
313
269
  // ── Prune ──────────────────────────────────────────────────────────────────
314
270
 
315
271
  /**
316
- * Supprime les wraps VIDES dont l'élément-ancre n'est plus dans le DOM.
272
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
317
273
  *
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"]
274
+ * On ne supprime JAMAIS un wrap rempli (filled) :
275
+ * - Les wraps remplis peuvent être temporairement orphelins lors d'une
276
+ * virtualisation NodeBB l'ancre reviendra.
277
+ * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
278
+ * retirer le nœud sous ses pieds génère des erreurs non critiques mais
279
+ * inutiles. Le cleanup de navigation gère la suppression définitive.
325
280
  */
326
281
  function pruneOrphans(klass) {
327
282
  const meta = KIND[klass];
328
283
  if (!meta) return;
329
284
 
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)
285
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
286
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
287
+ if (isFilled(w)) continue;
333
288
 
334
289
  const key = w.getAttribute(A_ANCHOR) ?? '';
335
290
  const sid = key.slice(klass.length + 1);
336
- if (!sid) { mutate(() => dropWrap(w)); return; }
291
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
337
292
 
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, '\\"')}"]`;
293
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
342
294
  const anchorEl = document.querySelector(sel);
343
295
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
344
- });
296
+ }
345
297
  }
346
298
 
347
299
  // ── Decluster ──────────────────────────────────────────────────────────────
348
300
 
349
301
  /**
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).
302
+ * Deux wraps adjacents → supprimer le moins prioritaire.
303
+ * Priorité : filled > en grâce de fill > vide.
304
+ * Jamais de suppression d'un wrap rempli (même raison que pruneOrphans).
355
305
  */
356
306
  function decluster(klass) {
357
307
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
@@ -365,14 +315,9 @@
365
315
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
366
316
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
367
317
 
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)
318
+ if (!isFilled(w)) mutate(() => dropWrap(w));
319
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
320
+ // les deux remplis → on ne touche pas
376
321
  break;
377
322
  }
378
323
  }
@@ -381,35 +326,23 @@
381
326
  // ── Injection ──────────────────────────────────────────────────────────────
382
327
 
383
328
  /**
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).
329
+ * Ordinal 0-based pour le calcul de l'intervalle.
330
+ * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
331
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
391
332
  */
392
333
  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);
334
+ const attr = KIND[klass]?.ordinalAttr;
335
+ if (attr) {
336
+ const v = el.getAttribute(attr);
398
337
  if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
399
338
  }
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
-
339
+ // Fallback positionnel — compte uniquement les éléments du même type
340
+ const fullSel = KIND[klass]?.sel ?? '';
341
+ let i = 0;
342
+ for (const s of el.parentElement?.children ?? []) {
343
+ if (s === el) return i;
344
+ if (!fullSel || s.matches?.(fullSel)) i++;
345
+ }
413
346
  return 0;
414
347
  }
415
348
 
@@ -418,20 +351,18 @@
418
351
  let inserted = 0;
419
352
 
420
353
  for (const el of items) {
421
- if (inserted >= MAX_INSERTS_PER_RUN) break;
354
+ if (inserted >= MAX_INSERTS_RUN) break;
422
355
  if (!el?.isConnected) continue;
423
356
 
424
- const ord = ordinal(klass, el);
425
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
426
- if (!isTarget) continue;
427
-
357
+ const ord = ordinal(klass, el);
358
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
428
359
  if (adjacentWrap(el)) continue;
429
360
 
430
- const key = makeAnchorKey(klass, el);
431
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
361
+ const key = anchorKey(klass, el);
362
+ if (findWrap(key)) continue;
432
363
 
433
364
  const id = pickId(poolKey);
434
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
365
+ if (!id) continue;
435
366
 
436
367
  const w = insertAfter(el, id, klass, key);
437
368
  if (w) { observePh(id); inserted++; }
@@ -443,7 +374,6 @@
443
374
 
444
375
  function getIO() {
445
376
  if (S.io) return S.io;
446
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
447
377
  try {
448
378
  S.io = new IntersectionObserver(entries => {
449
379
  for (const e of entries) {
@@ -452,14 +382,14 @@
452
382
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
453
383
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
454
384
  }
455
- }, { root: null, rootMargin: margin, threshold: 0 });
385
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
456
386
  } catch (_) { S.io = null; }
457
387
  return S.io;
458
388
  }
459
389
 
460
390
  function observePh(id) {
461
391
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
462
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
392
+ if (ph instanceof Element && ph.isConnected) try { getIO()?.observe(ph); } catch (_) {}
463
393
  }
464
394
 
465
395
  function enqueueShow(id) {
@@ -503,7 +433,6 @@
503
433
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
504
434
  S.lastShow.set(id, t);
505
435
 
506
- // Horodater le show sur le wrap pour grace period + emptyCheck
507
436
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
508
437
 
509
438
  window.ezstandalone = window.ezstandalone || {};
@@ -524,7 +453,6 @@
524
453
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
525
454
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
526
455
  if (!wrap || !ph?.isConnected) return;
527
- // Un show plus récent → ne pas toucher
528
456
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
529
457
  wrap.classList.toggle('is-empty', !isFilled(ph));
530
458
  } catch (_) {}
@@ -543,7 +471,7 @@
543
471
  const orig = ez.showAds.bind(ez);
544
472
  ez.showAds = function (...args) {
545
473
  if (isBlocked()) return;
546
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
474
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
547
475
  const seen = new Set();
548
476
  for (const v of ids) {
549
477
  const id = parseInt(v, 10);
@@ -562,11 +490,10 @@
562
490
  }
563
491
  }
564
492
 
565
- // ── Core run ───────────────────────────────────────────────────────────────
493
+ // ── Core ───────────────────────────────────────────────────────────────────
566
494
 
567
495
  async function runCore() {
568
496
  if (isBlocked()) return 0;
569
- patchShowAds();
570
497
 
571
498
  const cfg = await fetchConfig();
572
499
  if (!cfg || cfg.excluded) return 0;
@@ -577,10 +504,9 @@
577
504
 
578
505
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
579
506
  if (!normBool(cfgEnable)) return 0;
580
- const items = getItems();
581
507
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
582
508
  pruneOrphans(klass);
583
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
509
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
584
510
  if (n) decluster(klass);
585
511
  return n;
586
512
  };
@@ -593,14 +519,13 @@
593
519
  'ezoic-ad-between', getTopics,
594
520
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
595
521
  );
596
- if (kind === 'categories') return exec(
522
+ return exec(
597
523
  'ezoic-ad-categories', getCategories,
598
524
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
599
525
  );
600
- return 0;
601
526
  }
602
527
 
603
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
528
+ // ── Scheduler ──────────────────────────────────────────────────────────────
604
529
 
605
530
  function scheduleRun(cb) {
606
531
  if (S.runQueued) return;
@@ -610,7 +535,7 @@
610
535
  if (S.pageKey && pageKey() !== S.pageKey) return;
611
536
  let n = 0;
612
537
  try { n = await runCore(); } catch (_) {}
613
- try { cb?.(n); } catch (_) {}
538
+ cb?.(n);
614
539
  });
615
540
  }
616
541
 
@@ -618,10 +543,8 @@
618
543
  if (isBlocked()) return;
619
544
  const t = ts();
620
545
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
621
- S.lastBurstTs = t;
622
-
623
- const pk = pageKey();
624
- S.pageKey = pk;
546
+ S.lastBurstTs = t;
547
+ S.pageKey = pageKey();
625
548
  S.burstDeadline = t + 2000;
626
549
 
627
550
  if (S.burstActive) return;
@@ -629,7 +552,7 @@
629
552
  S.burstCount = 0;
630
553
 
631
554
  const step = () => {
632
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
555
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
633
556
  S.burstActive = false; return;
634
557
  }
635
558
  S.burstCount++;
@@ -641,21 +564,21 @@
641
564
  step();
642
565
  }
643
566
 
644
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
567
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
645
568
 
646
569
  function cleanup() {
647
570
  blockedUntil = ts() + 1500;
571
+ poolsReady = false;
648
572
 
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 (_) {}
573
+ // Déconnecter l'IO AVANT les dropWrap pour éviter tout unobserve parasite.
574
+ // disconnect() vide la liste interne des cibles observées les références
575
+ // aux placeholders de la page courante sont effacées proprement.
576
+ // L'IO sera recréé à ajaxify.end via getIO().
577
+ try { S.io?.disconnect(); } catch (_) {}
578
+ S.io = null;
657
579
 
658
580
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
581
+ wrapByKey.clear();
659
582
  S.cfg = null;
660
583
  S.pools = { topics: [], posts: [], categories: [] };
661
584
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -668,19 +591,17 @@
668
591
  S.runQueued = false;
669
592
  }
670
593
 
671
- // ── DOM Observer ───────────────────────────────────────────────────────────
594
+ // ── MutationObserver ───────────────────────────────────────────────────────
672
595
 
673
596
  function ensureDomObserver() {
674
597
  if (S.domObs) return;
598
+ const allSel = [SEL.post, SEL.topic, SEL.category];
675
599
  S.domObs = new MutationObserver(muts => {
676
600
  if (S.mutGuard > 0 || isBlocked()) return;
677
601
  for (const m of muts) {
678
- if (!m.addedNodes?.length) continue;
679
602
  for (const n of m.addedNodes) {
680
603
  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)) {
604
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
684
605
  requestBurst(); return;
685
606
  }
686
607
  }
@@ -706,29 +627,21 @@
706
627
  }
707
628
 
708
629
  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.
630
+ // L'iframe __tcfapiLocator route les appels postMessage du CMP.
631
+ // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
632
+ // Un MutationObserver la recrée dès qu'elle disparaît.
715
633
  try {
716
634
  if (!window.__tcfapi && !window.__cmp) return;
717
-
718
635
  const inject = () => {
719
636
  if (document.getElementById('__tcfapiLocator')) return;
720
637
  const f = document.createElement('iframe');
721
638
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
722
639
  (document.body || document.documentElement).appendChild(f);
723
640
  };
724
-
725
641
  inject();
726
-
727
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
728
642
  if (!window.__nbbTcfObs) {
729
- window.__nbbTcfObs = new MutationObserver(() => inject());
730
- window.__nbbTcfObs.observe(document.documentElement,
731
- { childList: true, subtree: true });
643
+ window.__nbbTcfObs = new MutationObserver(inject);
644
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
732
645
  }
733
646
  } catch (_) {}
734
647
  }
@@ -738,10 +651,10 @@
738
651
  const head = document.head;
739
652
  if (!head) return;
740
653
  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],
654
+ ['preconnect', 'https://g.ezoic.net', true ],
655
+ ['preconnect', 'https://go.ezoic.net', true ],
656
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
657
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
745
658
  ['dns-prefetch', 'https://g.ezoic.net', false],
746
659
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
747
660
  ]) {
@@ -755,7 +668,7 @@
755
668
  }
756
669
  }
757
670
 
758
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
671
+ // ── Bindings ───────────────────────────────────────────────────────────────
759
672
 
760
673
  function bindNodeBB() {
761
674
  const $ = window.jQuery;
@@ -766,19 +679,16 @@
766
679
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
767
680
  S.pageKey = pageKey();
768
681
  blockedUntil = 0;
769
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
770
- getIO(); ensureDomObserver(); requestBurst();
682
+ muteConsole(); ensureTcfLocator(); warmNetwork();
683
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
771
684
  });
772
685
 
773
- const BURST_EVENTS = [
774
- 'action:ajaxify.contentLoaded',
775
- 'action:posts.loaded', 'action:topics.loaded',
686
+ const burstEvts = [
687
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
776
688
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
777
689
  ].map(e => `${e}.nbbEzoic`).join(' ');
690
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
778
691
 
779
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
780
-
781
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
782
692
  try {
783
693
  require(['hooks'], hooks => {
784
694
  if (typeof hooks?.on !== 'function') return;