nodebb-plugin-ezoic-infinite 1.7.13 → 1.7.14

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 +167 -213
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.13",
3
+ "version": "1.7.14",
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,51 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v23
2
+ * NodeBB Ezoic Infinite Ads — client.js v24
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 où 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 PRUNE_STABLE_MS = 45_000; // délai avant qu'un wrap vide puisse être purgé
40
+ // (évite la suppression lors du scroll up / virtualisation NodeBB)
41
+ const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
42
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
43
+ const MAX_INSERTS_RUN = 6;
44
+ const MAX_INFLIGHT = 4;
45
+ const SHOW_THROTTLE_MS = 900;
46
+ const BURST_COOLDOWN_MS = 200;
47
+
48
+ // IO : marges larges fixes — une seule instance, jamais recréée
53
49
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
50
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
51
 
@@ -60,40 +56,40 @@
60
56
  };
61
57
 
62
58
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
59
+ * Table KIND source de vérité par kindClass.
64
60
  *
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
61
+ * sel : sélecteur CSS complet
62
+ * baseTag : préfixe tag pour les querySelector de recherche d'ancre
63
+ * (vide pour posts car leur sélecteur commence par '[')
64
+ * anchorAttr : attribut DOM STABLE clé unique du wrap, permanent
65
+ * data-pid posts (id message, immuable)
66
+ * data-index topics (index dans la liste)
67
+ * data-cid catégories (id catégorie, immuable)
68
+ * ordinalAttr: attribut 0-based pour le calcul de l'intervalle
69
+ * data-index posts + topics (fourni par NodeBB)
70
+ * null catégories (page statique → fallback positionnel)
71
71
  */
72
72
  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' },
73
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
76
  };
77
77
 
78
78
  // ── État ───────────────────────────────────────────────────────────────────
79
79
 
80
80
  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
-
81
+ pageKey: null,
82
+ cfg: null,
83
+ pools: { topics: [], posts: [], categories: [] },
84
+ cursors: { topics: 0, posts: 0, categories: 0 },
85
+ mountedIds: new Set(), // IDs Ezoic montés dans le DOM
86
+ lastShow: new Map(), // id timestamp dernier show
87
+ io: null,
88
+ domObs: null,
89
+ mutGuard: 0,
90
+ inflight: 0,
91
+ pending: [],
92
+ pendingSet: new Set(),
97
93
  runQueued: false,
98
94
  burstActive: false,
99
95
  burstDeadline: 0,
@@ -102,8 +98,11 @@
102
98
  };
103
99
 
104
100
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
101
+ const ts = () => Date.now();
102
+ const isBlocked = () => ts() < blockedUntil;
103
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
104
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
105
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
106
 
108
107
  function mutate(fn) {
109
108
  S.mutGuard++;
@@ -121,12 +120,6 @@
121
120
  return S.cfg;
122
121
  }
123
122
 
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
123
  function parseIds(raw) {
131
124
  const out = [], seen = new Set();
132
125
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +129,11 @@
136
129
  return out;
137
130
  }
138
131
 
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; } };
132
+ function initPools(cfg) {
133
+ S.pools.topics = parseIds(cfg.placeholderIds);
134
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
135
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
136
+ }
145
137
 
146
138
  // ── Page identity ──────────────────────────────────────────────────────────
147
139
 
@@ -165,13 +157,13 @@
165
157
  return 'other';
166
158
  }
167
159
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
160
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
161
 
170
162
  function getPosts() {
171
163
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
164
  if (!el.isConnected) return false;
173
165
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
166
+ const p = el.parentElement?.closest(SEL.post);
175
167
  if (p && p !== el) return false;
176
168
  return el.getAttribute('component') !== 'post/parent';
177
169
  });
@@ -187,36 +179,28 @@
187
179
  );
188
180
  }
189
181
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
182
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
183
 
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;
184
+ function stableId(klass, el) {
185
+ const attr = KIND[klass]?.anchorAttr;
199
186
  if (attr) {
200
187
  const v = el.getAttribute(attr);
201
188
  if (v !== null && v !== '') return v;
202
189
  }
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 (_) {}
190
+ let i = 0;
191
+ for (const s of el.parentElement?.children ?? []) {
192
+ if (s === el) return `i${i}`;
193
+ i++;
194
+ }
211
195
  return 'i0';
212
196
  }
213
197
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
198
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
199
 
216
- function findWrap(anchorKey) {
200
+ function findWrap(key) {
217
201
  try {
218
202
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
203
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
204
  );
221
205
  } catch (_) { return null; }
222
206
  }
@@ -226,7 +210,7 @@
226
210
  function pickId(poolKey) {
227
211
  const pool = S.pools[poolKey];
228
212
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
213
+ const i = S.cursors[poolKey] % pool.length;
230
214
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
215
  const id = pool[i];
232
216
  if (!S.mountedIds.has(id)) return id;
@@ -237,7 +221,7 @@
237
221
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
222
 
239
223
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
224
+ const w = document.createElement('div');
241
225
  w.className = `${WRAP_CLASS} ${klass}`;
242
226
  w.setAttribute(A_ANCHOR, key);
243
227
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +235,10 @@
251
235
  }
252
236
 
253
237
  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;
238
+ if (!el?.insertAdjacentElement) return null;
239
+ if (findWrap(key)) return null;
240
+ if (S.mountedIds.has(id)) return null;
241
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
242
  const w = makeWrap(id, klass, key);
259
243
  mutate(() => el.insertAdjacentElement('afterend', w));
260
244
  S.mountedIds.add(id);
@@ -263,15 +247,12 @@
263
247
 
264
248
  function dropWrap(w) {
265
249
  try {
250
+ // Unobserve avant remove — guard instanceof évite unobserve(null)
251
+ // qui corrompt l'état interne de l'IO (pubads error au scroll suivant)
252
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
253
+ if (ph instanceof Element) S.io?.unobserve(ph);
266
254
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
255
  if (Number.isFinite(id)) S.mountedIds.delete(id);
268
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
- // "parameter 1 is not of type Element" sur le prochain observe).
271
- try {
272
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
- if (ph instanceof Element) S.io?.unobserve(ph);
274
- } catch (_) {}
275
256
  w.remove();
276
257
  } catch (_) {}
277
258
  }
@@ -279,65 +260,58 @@
279
260
  // ── Prune ──────────────────────────────────────────────────────────────────
280
261
 
281
262
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
263
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
283
264
  *
284
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
- * Exemples :
286
- * ezoic-ad-message → cherche [data-pid="123"]
287
- * ezoic-ad-between → cherche [data-index="5"]
288
- * ezoic-ad-categories cherche [data-cid="7"] ← fix v20
289
- *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
265
+ * On ne supprime JAMAIS un wrap rempli (filled) :
266
+ * - Les wraps remplis peuvent être temporairement orphelins lors d'une
267
+ * virtualisation NodeBB — l'ancre reviendra.
268
+ * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
269
+ * retirer le nœud sous ses pieds génère des erreurs non critiques mais
270
+ * inutiles. Le cleanup de navigation gère la suppression définitive.
291
271
  */
292
272
  function pruneOrphans(klass) {
293
273
  const meta = KIND[klass];
294
274
  if (!meta) return;
295
275
 
296
- // baseTag déduit du sélecteur : 'li' pour topics/catégories, '' pour posts.
297
- // Pour les posts (baseTag=''), on cherche juste [data-pid="X"] sans préfixe
298
- // c'est correct car data-pid est unique dans le DOM.
299
- const baseTag = meta.sel.split('[')[0];
300
-
301
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
302
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
303
- if (isFilled(w)) return; // jamais supprimer un wrap rempli
276
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
277
+ // Ne jamais supprimer un wrap filled
278
+ if (isFilled(w)) continue;
279
+ // Attendre PRUNE_STABLE_MS depuis la création : pendant ce délai, l'ancre
280
+ // peut avoir temporairement disparu du DOM par virtualisation NodeBB au
281
+ // scroll up — ce n'est pas un vrai orphelin.
282
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < PRUNE_STABLE_MS) continue;
304
283
 
305
284
  const key = w.getAttribute(A_ANCHOR) ?? '';
306
285
  const sid = key.slice(klass.length + 1);
307
- if (!sid) { mutate(() => dropWrap(w)); return; }
286
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
308
287
 
309
- const anchorEl = document.querySelector(
310
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
311
- );
288
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
289
+ const anchorEl = document.querySelector(sel);
312
290
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
313
- });
291
+ }
314
292
  }
315
293
 
316
294
  // ── Decluster ──────────────────────────────────────────────────────────────
317
295
 
318
296
  /**
319
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
320
- * Règles absolues :
321
- * - Jamais supprimer un wrap filled (pub affichée)
322
- * - Jamais supprimer un wrap < FILL_GRACE_MS depuis création OU depuis showAds
323
- * (le fill Ezoic est async : l'enchère SSP peut prendre plusieurs secondes
324
- * après showAds, et showAds lui-même peut arriver bien après l'injection)
325
- * - Si les deux sont filled → on ne touche rien
297
+ * Deux wraps adjacents → supprimer le moins prioritaire.
298
+ * Priorité : filled > en grâce de fill > vide.
299
+ * Jamais de suppression d'un wrap rempli (même raison que pruneOrphans).
326
300
  */
327
301
  function decluster(klass) {
328
302
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
329
- if (isFilled(w)) continue; // filled = intouchable
330
- const wCreated = parseInt(w.getAttribute(A_CREATED) || '0', 10);
331
- if (ts() - wCreated < FILL_GRACE_MS) continue; // trop récent
303
+ // Ne jamais toucher un wrap filled
304
+ if (isFilled(w)) continue;
305
+ // Protéger par A_CREATED : un wrap récent attend encore son showAds async
306
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) continue;
332
307
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
333
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue; // showAds récent
308
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
334
309
 
335
310
  let prev = w.previousElementSibling, steps = 0;
336
311
  while (prev && steps++ < 3) {
337
312
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
338
- if (isFilled(prev)) break; // précédent filled = intouchable
339
- const pCreated = parseInt(prev.getAttribute(A_CREATED) || '0', 10);
340
- if (ts() - pCreated < FILL_GRACE_MS) break;
313
+ if (isFilled(prev)) break;
314
+ if (ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) break;
341
315
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
342
316
  if (pShown && ts() - pShown < FILL_GRACE_MS) break;
343
317
 
@@ -352,14 +326,16 @@
352
326
 
353
327
  /**
354
328
  * Ordinal 0-based pour le calcul de l'intervalle.
355
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
356
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
329
+ * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
330
+ * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
357
331
  */
358
332
  function ordinal(klass, el) {
359
- const di = el.getAttribute('data-index');
360
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
361
- // Fallback positionnel — filtre par sélecteur complet pour éviter le bug
362
- // baseTag='' (posts) `:scope > ` sans tag ne fonctionne pas.
333
+ const attr = KIND[klass]?.ordinalAttr;
334
+ if (attr) {
335
+ const v = el.getAttribute(attr);
336
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
337
+ }
338
+ // Fallback positionnel — compte uniquement les éléments du même type
363
339
  const fullSel = KIND[klass]?.sel ?? '';
364
340
  let i = 0;
365
341
  for (const s of el.parentElement?.children ?? []) {
@@ -374,20 +350,18 @@
374
350
  let inserted = 0;
375
351
 
376
352
  for (const el of items) {
377
- if (inserted >= MAX_INSERTS_PER_RUN) break;
353
+ if (inserted >= MAX_INSERTS_RUN) break;
378
354
  if (!el?.isConnected) continue;
379
355
 
380
- const ord = ordinal(klass, el);
381
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
382
- if (!isTarget) continue;
383
-
356
+ const ord = ordinal(klass, el);
357
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
384
358
  if (adjacentWrap(el)) continue;
385
359
 
386
- const key = makeAnchorKey(klass, el);
387
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
360
+ const key = anchorKey(klass, el);
361
+ if (findWrap(key)) continue;
388
362
 
389
363
  const id = pickId(poolKey);
390
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
364
+ if (!id) continue;
391
365
 
392
366
  const w = insertAfter(el, id, klass, key);
393
367
  if (w) { observePh(id); inserted++; }
@@ -399,7 +373,6 @@
399
373
 
400
374
  function getIO() {
401
375
  if (S.io) return S.io;
402
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
403
376
  try {
404
377
  S.io = new IntersectionObserver(entries => {
405
378
  for (const e of entries) {
@@ -408,7 +381,7 @@
408
381
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
409
382
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
410
383
  }
411
- }, { root: null, rootMargin: margin, threshold: 0 });
384
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
412
385
  } catch (_) { S.io = null; }
413
386
  return S.io;
414
387
  }
@@ -459,7 +432,6 @@
459
432
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
460
433
  S.lastShow.set(id, t);
461
434
 
462
- // Horodater le show sur le wrap pour grace period + emptyCheck
463
435
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
464
436
 
465
437
  window.ezstandalone = window.ezstandalone || {};
@@ -480,7 +452,6 @@
480
452
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
481
453
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
482
454
  if (!wrap || !ph?.isConnected) return;
483
- // Un show plus récent → ne pas toucher
484
455
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
485
456
  wrap.classList.toggle('is-empty', !isFilled(ph));
486
457
  } catch (_) {}
@@ -499,7 +470,7 @@
499
470
  const orig = ez.showAds.bind(ez);
500
471
  ez.showAds = function (...args) {
501
472
  if (isBlocked()) return;
502
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
473
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
503
474
  const seen = new Set();
504
475
  for (const v of ids) {
505
476
  const id = parseInt(v, 10);
@@ -518,7 +489,7 @@
518
489
  }
519
490
  }
520
491
 
521
- // ── Core run ───────────────────────────────────────────────────────────────
492
+ // ── Core ───────────────────────────────────────────────────────────────────
522
493
 
523
494
  async function runCore() {
524
495
  if (isBlocked()) return 0;
@@ -533,10 +504,9 @@
533
504
 
534
505
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
535
506
  if (!normBool(cfgEnable)) return 0;
536
- const items = getItems();
537
507
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
538
508
  pruneOrphans(klass);
539
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
509
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
540
510
  if (n) decluster(klass);
541
511
  return n;
542
512
  };
@@ -549,14 +519,13 @@
549
519
  'ezoic-ad-between', getTopics,
550
520
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
551
521
  );
552
- if (kind === 'categories') return exec(
522
+ return exec(
553
523
  'ezoic-ad-categories', getCategories,
554
524
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
555
525
  );
556
- return 0;
557
526
  }
558
527
 
559
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
528
+ // ── Scheduler ──────────────────────────────────────────────────────────────
560
529
 
561
530
  function scheduleRun(cb) {
562
531
  if (S.runQueued) return;
@@ -566,7 +535,7 @@
566
535
  if (S.pageKey && pageKey() !== S.pageKey) return;
567
536
  let n = 0;
568
537
  try { n = await runCore(); } catch (_) {}
569
- try { cb?.(n); } catch (_) {}
538
+ cb?.(n);
570
539
  });
571
540
  }
572
541
 
@@ -574,10 +543,8 @@
574
543
  if (isBlocked()) return;
575
544
  const t = ts();
576
545
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
577
- S.lastBurstTs = t;
578
-
579
- const pk = pageKey();
580
- S.pageKey = pk;
546
+ S.lastBurstTs = t;
547
+ S.pageKey = pageKey();
581
548
  S.burstDeadline = t + 2000;
582
549
 
583
550
  if (S.burstActive) return;
@@ -585,7 +552,7 @@
585
552
  S.burstCount = 0;
586
553
 
587
554
  const step = () => {
588
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
555
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
589
556
  S.burstActive = false; return;
590
557
  }
591
558
  S.burstCount++;
@@ -597,7 +564,7 @@
597
564
  step();
598
565
  }
599
566
 
600
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
567
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
601
568
 
602
569
  function cleanup() {
603
570
  blockedUntil = ts() + 1500;
@@ -614,19 +581,17 @@
614
581
  S.runQueued = false;
615
582
  }
616
583
 
617
- // ── DOM Observer ───────────────────────────────────────────────────────────
584
+ // ── MutationObserver ───────────────────────────────────────────────────────
618
585
 
619
586
  function ensureDomObserver() {
620
587
  if (S.domObs) return;
588
+ const allSel = [SEL.post, SEL.topic, SEL.category];
621
589
  S.domObs = new MutationObserver(muts => {
622
590
  if (S.mutGuard > 0 || isBlocked()) return;
623
591
  for (const m of muts) {
624
- if (!m.addedNodes?.length) continue;
625
592
  for (const n of m.addedNodes) {
626
593
  if (n.nodeType !== 1) continue;
627
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
628
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
629
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
594
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
630
595
  requestBurst(); return;
631
596
  }
632
597
  }
@@ -652,29 +617,21 @@
652
617
  }
653
618
 
654
619
  function ensureTcfLocator() {
655
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
656
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
657
- // iframe du DOM (vidage partiel du body), ce qui provoque :
658
- // "Cannot read properties of null (reading 'postMessage')"
659
- // "Cannot set properties of null (setting 'addtlConsent')"
660
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
620
+ // L'iframe __tcfapiLocator route les appels postMessage du CMP.
621
+ // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
622
+ // Un MutationObserver la recrée dès qu'elle disparaît.
661
623
  try {
662
624
  if (!window.__tcfapi && !window.__cmp) return;
663
-
664
625
  const inject = () => {
665
626
  if (document.getElementById('__tcfapiLocator')) return;
666
627
  const f = document.createElement('iframe');
667
628
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
668
629
  (document.body || document.documentElement).appendChild(f);
669
630
  };
670
-
671
631
  inject();
672
-
673
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
674
632
  if (!window.__nbbTcfObs) {
675
- window.__nbbTcfObs = new MutationObserver(() => inject());
676
- window.__nbbTcfObs.observe(document.documentElement,
677
- { childList: true, subtree: true });
633
+ window.__nbbTcfObs = new MutationObserver(inject);
634
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
678
635
  }
679
636
  } catch (_) {}
680
637
  }
@@ -684,10 +641,10 @@
684
641
  const head = document.head;
685
642
  if (!head) return;
686
643
  for (const [rel, href, cors] of [
687
- ['preconnect', 'https://g.ezoic.net', true],
688
- ['preconnect', 'https://go.ezoic.net', true],
689
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
690
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
644
+ ['preconnect', 'https://g.ezoic.net', true ],
645
+ ['preconnect', 'https://go.ezoic.net', true ],
646
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
647
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
691
648
  ['dns-prefetch', 'https://g.ezoic.net', false],
692
649
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
693
650
  ]) {
@@ -701,7 +658,7 @@
701
658
  }
702
659
  }
703
660
 
704
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
661
+ // ── Bindings ───────────────────────────────────────────────────────────────
705
662
 
706
663
  function bindNodeBB() {
707
664
  const $ = window.jQuery;
@@ -712,19 +669,16 @@
712
669
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
713
670
  S.pageKey = pageKey();
714
671
  blockedUntil = 0;
715
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
716
- getIO(); ensureDomObserver(); requestBurst();
672
+ muteConsole(); ensureTcfLocator(); warmNetwork();
673
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
717
674
  });
718
675
 
719
- const BURST_EVENTS = [
720
- 'action:ajaxify.contentLoaded',
721
- 'action:posts.loaded', 'action:topics.loaded',
676
+ const burstEvts = [
677
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
722
678
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
723
679
  ].map(e => `${e}.nbbEzoic`).join(' ');
680
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
724
681
 
725
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
726
-
727
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
728
682
  try {
729
683
  require(['hooks'], hooks => {
730
684
  if (typeof hooks?.on !== 'function') return;