nodebb-plugin-ezoic-infinite 1.7.22 → 1.7.24

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 +214 -249
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.22",
3
+ "version": "1.7.24",
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,74 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v30
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. 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.
19
+ * v25 Table KIND unifiée avec baseTag + ordinalAttr.
20
+ * Fix scroll-up / virtualisation NodeBB :
21
+ * – decluster : isFilled en premier, A_CREATED grace period (FILL_GRACE_MS).
22
+ * Tentative recyclage d'id (v25) → cause exactement le même bug (wraps
23
+ * déplacés laissent les positions originales libres → réinjection en haut).
21
24
  *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
25
+ * v26 Suppression définitive du recyclage d'id.
26
+ * KIND simplifié.
24
27
  *
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é)
28
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB).
29
+ *
30
+ * v28 decluster supprimé. pruneOrphans supprimé (v27). Wraps persistants sur session.
31
+ *
32
+ * v29 Fix ancrage topics : data-index → data-tid.
33
+ *
34
+ * v30 Fix adjacentWrap : ne compte plus les wraps orphelins (ancre hors DOM).
35
+ * Quand NodeBB virtualise et retire des topics du DOM, les wraps restent
36
+ * en place (div dans le ul). adjacentWrap(el) retournait true sur ces
37
+ * wraps orphelins → injection bloquée sur les topics suivants.
38
+ * Fix : adjacentWrap vérifie que le wrap voisin a son ancre dans le DOM.
39
+ * recycleOrphanId() : quand le pool est épuisé, recycle les wraps orphelins
40
+ * non remplis qui sont loin au-dessus du viewport.
41
+ * data-index = position relative dans le batch NodeBB, pas un ID stable.
42
+ * Lors du scroll infini, les nouveaux batches démarrent à data-index=0,
43
+ * ce qui créait des collisions de clés d'ancrage → mauvaise déduplication
44
+ * → wraps non injectés sur les nouveaux topics, puis réinjection en haut.
45
+ * Fix : anchorAttr = data-tid (stable et unique par topic).
46
+ * ordinalAttr reste data-index pour le calcul de l'intervalle.
47
+ * Analyse : decluster appelait dropWrap() qui faisait S.mountedIds.delete(id).
48
+ * Un wrap vide adjacent à un autre wrap → supprimé → id libéré → réinjecté
49
+ * en haut au prochain scroll. Exactement le bug observé.
50
+ * Les deux mécanismes de nettoyage actif (pruneOrphans + decluster) sont
51
+ * maintenant retirés. Seul cleanup() à la navigation peut supprimer des wraps.
31
52
  */
32
53
  (function () {
33
54
  'use strict';
34
55
 
35
56
  // ── Constantes ─────────────────────────────────────────────────────────────
36
57
 
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)
58
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
59
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
60
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
61
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
62
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
63
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
64
+
65
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
66
+ const MAX_INSERTS_RUN = 6;
67
+ const MAX_INFLIGHT = 4;
68
+ const SHOW_THROTTLE_MS = 900;
69
+ const BURST_COOLDOWN_MS = 200;
70
+
71
+ // IO : marges larges fixes — une seule instance, jamais recréée
53
72
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
73
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
74
 
@@ -60,40 +79,37 @@
60
79
  };
61
80
 
62
81
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
82
+ * Table KIND source de vérité par kindClass.
64
83
  *
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
84
+ * sel : sélecteur CSS complet
85
+ * baseTag : préfixe tag pour querySelector d'ancre
86
+ * (vide pour posts car sélecteur commence par '[')
87
+ * anchorAttr : attribut DOM stable clé unique du wrap
88
+ * data-pid posts / data-index topics / data-cid catégories
89
+ * ordinalAttr: attribut 0-based pour calcul de l'intervalle
90
+ * null → fallback positionnel (catégories)
71
91
  */
72
92
  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' },
93
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
94
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-tid', ordinalAttr: 'data-index' },
95
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
96
  };
77
97
 
78
98
  // ── État ───────────────────────────────────────────────────────────────────
79
99
 
80
100
  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
-
101
+ pageKey: null,
102
+ cfg: null,
103
+ pools: { topics: [], posts: [], categories: [] },
104
+ cursors: { topics: 0, posts: 0, categories: 0 },
105
+ mountedIds: new Set(),
106
+ lastShow: new Map(),
107
+ io: null,
108
+ domObs: null,
109
+ mutGuard: 0,
110
+ inflight: 0,
111
+ pending: [],
112
+ pendingSet: new Set(),
97
113
  runQueued: false,
98
114
  burstActive: false,
99
115
  burstDeadline: 0,
@@ -102,8 +118,11 @@
102
118
  };
103
119
 
104
120
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
121
+ const ts = () => Date.now();
122
+ const isBlocked = () => ts() < blockedUntil;
123
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
124
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
125
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
126
 
108
127
  function mutate(fn) {
109
128
  S.mutGuard++;
@@ -121,12 +140,6 @@
121
140
  return S.cfg;
122
141
  }
123
142
 
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
143
  function parseIds(raw) {
131
144
  const out = [], seen = new Set();
132
145
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +149,11 @@
136
149
  return out;
137
150
  }
138
151
 
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; } };
152
+ function initPools(cfg) {
153
+ S.pools.topics = parseIds(cfg.placeholderIds);
154
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
155
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
156
+ }
145
157
 
146
158
  // ── Page identity ──────────────────────────────────────────────────────────
147
159
 
@@ -165,13 +177,13 @@
165
177
  return 'other';
166
178
  }
167
179
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
180
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
181
 
170
182
  function getPosts() {
171
183
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
184
  if (!el.isConnected) return false;
173
185
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
186
+ const p = el.parentElement?.closest(SEL.post);
175
187
  if (p && p !== el) return false;
176
188
  return el.getAttribute('component') !== 'post/parent';
177
189
  });
@@ -180,43 +192,47 @@
180
192
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
181
193
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
182
194
 
195
+ function wrapIsLive(wrap) {
196
+ if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
197
+ const key = wrap.getAttribute(A_ANCHOR);
198
+ if (!key) return false;
199
+ const colonIdx = key.indexOf(':');
200
+ const klass = key.slice(0, colonIdx);
201
+ const anchorId = key.slice(colonIdx + 1);
202
+ const cfg = KIND[klass];
203
+ if (!cfg) return false;
204
+ try {
205
+ const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
206
+ return !!(found?.isConnected);
207
+ } catch (_) { return false; }
208
+ }
209
+
183
210
  function adjacentWrap(el) {
184
- return !!(
185
- el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
186
- el.previousElementSibling?.classList?.contains(WRAP_CLASS)
187
- );
211
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
188
212
  }
189
213
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
214
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
215
 
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;
216
+ function stableId(klass, el) {
217
+ const attr = KIND[klass]?.anchorAttr;
199
218
  if (attr) {
200
219
  const v = el.getAttribute(attr);
201
220
  if (v !== null && v !== '') return v;
202
221
  }
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 (_) {}
222
+ let i = 0;
223
+ for (const s of el.parentElement?.children ?? []) {
224
+ if (s === el) return `i${i}`;
225
+ i++;
226
+ }
211
227
  return 'i0';
212
228
  }
213
229
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
230
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
231
 
216
- function findWrap(anchorKey) {
232
+ function findWrap(key) {
217
233
  try {
218
234
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
235
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
236
  );
221
237
  } catch (_) { return null; }
222
238
  }
@@ -226,7 +242,7 @@
226
242
  function pickId(poolKey) {
227
243
  const pool = S.pools[poolKey];
228
244
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
245
+ const i = S.cursors[poolKey] % pool.length;
230
246
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
247
  const id = pool[i];
232
248
  if (!S.mountedIds.has(id)) return id;
@@ -234,10 +250,42 @@
234
250
  return null;
235
251
  }
236
252
 
253
+ function recycleOrphanId(klass) {
254
+ // Quand le pool est épuisé : cherche un wrap orphelin (ancre hors DOM, non rempli)
255
+ // loin au-dessus du viewport et libère son ID.
256
+ const vh = window.innerHeight || 800;
257
+ const threshold = -vh * 3;
258
+ let best = null, bestBottom = Infinity;
259
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
260
+ if (wrap.getAttribute(A_CREATED) === null) return;
261
+ if (isFilled(wrap)) return;
262
+ const key = wrap.getAttribute(A_ANCHOR);
263
+ if (!key) return;
264
+ const colonIdx = key.indexOf(':');
265
+ const anchorId = key.slice(colonIdx + 1);
266
+ const cfg = KIND[klass];
267
+ if (!cfg) return;
268
+ try {
269
+ const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
270
+ if (found?.isConnected) return; // ancre encore dans le DOM, pas orphelin
271
+ } catch (_) { return; }
272
+ try {
273
+ const rect = wrap.getBoundingClientRect();
274
+ if (rect.bottom > threshold) return;
275
+ if (rect.bottom < bestBottom) { bestBottom = rect.bottom; best = wrap; }
276
+ } catch (_) {}
277
+ });
278
+ if (!best) return null;
279
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
280
+ if (!Number.isFinite(id)) return null;
281
+ mutate(() => dropWrap(best));
282
+ return id;
283
+ }
284
+
237
285
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
286
 
239
287
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
288
+ const w = document.createElement('div');
241
289
  w.className = `${WRAP_CLASS} ${klass}`;
242
290
  w.setAttribute(A_ANCHOR, key);
243
291
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +299,10 @@
251
299
  }
252
300
 
253
301
  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;
302
+ if (!el?.insertAdjacentElement) return null;
303
+ if (findWrap(key)) return null;
304
+ if (S.mountedIds.has(id)) return null;
305
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
306
  const w = makeWrap(id, klass, key);
259
307
  mutate(() => el.insertAdjacentElement('afterend', w));
260
308
  S.mountedIds.add(id);
@@ -263,100 +311,44 @@
263
311
 
264
312
  function dropWrap(w) {
265
313
  try {
314
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
315
+ if (ph instanceof Element) S.io?.unobserve(ph);
266
316
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
317
  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
318
  w.remove();
276
319
  } catch (_) {}
277
320
  }
278
321
 
279
- // ── Prune ──────────────────────────────────────────────────────────────────
280
-
281
- /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
283
- *
284
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
- * Exemples :
286
- * ezoic-ad-message → cherche [data-pid="123"]
287
- * ezoic-ad-between → cherche [data-index="5"]
288
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
289
- *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
291
- */
292
- function pruneOrphans(klass) {
293
- const meta = KIND[klass];
294
- if (!meta) return;
295
-
296
- const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
297
-
298
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
299
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
300
-
301
- const key = w.getAttribute(A_ANCHOR) ?? '';
302
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
303
- if (!sid) { mutate(() => dropWrap(w)); return; }
304
-
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
308
- if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
- });
310
- }
311
-
312
- // ── Decluster ──────────────────────────────────────────────────────────────
322
+ // ── Prune : désactivé ─────────────────────────────────────────────────────
323
+ //
324
+ // pruneOrphans() a été supprimé car il causait le bug "pubs en haut".
325
+ // NodeBB virtualise les posts hors viewport les ancres disparaissent du DOM
326
+ // temporairement → pruneOrphans supprimait les wraps → scroll retour → les
327
+ // ancres revenaient injectBetween réinjectait tout en haut.
328
+ //
329
+ // Les wraps ne sont supprimés que par cleanup() à chaque navigation ajaxify.
330
+ // decluster() et pruneOrphans() sont désactivés — voir v28.
313
331
 
314
- /**
315
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
316
- * Priorité : filled > en grâce (fill en cours) > vide.
317
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
318
- */
319
- function decluster(klass) {
320
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
322
- const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
-
325
- let prev = w.previousElementSibling, steps = 0;
326
- while (prev && steps++ < 3) {
327
- if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
329
- const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
331
-
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
334
- break;
335
- }
336
- }
337
- }
338
332
 
339
333
  // ── Injection ──────────────────────────────────────────────────────────────
340
334
 
341
335
  /**
342
336
  * Ordinal 0-based pour le calcul de l'intervalle.
343
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
337
+ * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
338
+ * Catégories : ordinalAttr = null fallback positionnel.
345
339
  */
346
340
  function ordinal(klass, el) {
347
- const di = el.getAttribute('data-index');
348
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
349
- // Fallback positionnel
350
- try {
351
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
352
- if (tag) {
353
- let i = 0;
354
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
355
- if (n === el) return i;
356
- i++;
357
- }
358
- }
359
- } catch (_) {}
341
+ const attr = KIND[klass]?.ordinalAttr;
342
+ if (attr) {
343
+ const v = el.getAttribute(attr);
344
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
345
+ }
346
+ const fullSel = KIND[klass]?.sel ?? '';
347
+ let i = 0;
348
+ for (const s of el.parentElement?.children ?? []) {
349
+ if (s === el) return i;
350
+ if (!fullSel || s.matches?.(fullSel)) i++;
351
+ }
360
352
  return 0;
361
353
  }
362
354
 
@@ -365,20 +357,18 @@
365
357
  let inserted = 0;
366
358
 
367
359
  for (const el of items) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
360
+ if (inserted >= MAX_INSERTS_RUN) break;
369
361
  if (!el?.isConnected) continue;
370
362
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
374
-
363
+ const ord = ordinal(klass, el);
364
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
365
  if (adjacentWrap(el)) continue;
376
366
 
377
- const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
367
+ const key = anchorKey(klass, el);
368
+ if (findWrap(key)) continue;
379
369
 
380
- const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
370
+ let id = pickId(poolKey);
371
+ if (!id) { id = recycleOrphanId(klass); if (!id) continue; }
382
372
 
383
373
  const w = insertAfter(el, id, klass, key);
384
374
  if (w) { observePh(id); inserted++; }
@@ -390,7 +380,6 @@
390
380
 
391
381
  function getIO() {
392
382
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
383
  try {
395
384
  S.io = new IntersectionObserver(entries => {
396
385
  for (const e of entries) {
@@ -399,7 +388,7 @@
399
388
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
389
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
390
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
391
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
392
  } catch (_) { S.io = null; }
404
393
  return S.io;
405
394
  }
@@ -450,7 +439,6 @@
450
439
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
440
  S.lastShow.set(id, t);
452
441
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
442
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
443
 
456
444
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +459,6 @@
471
459
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
460
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
461
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
462
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
463
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
464
  } catch (_) {}
@@ -490,7 +477,7 @@
490
477
  const orig = ez.showAds.bind(ez);
491
478
  ez.showAds = function (...args) {
492
479
  if (isBlocked()) return;
493
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
480
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
481
  const seen = new Set();
495
482
  for (const v of ids) {
496
483
  const id = parseInt(v, 10);
@@ -509,7 +496,7 @@
509
496
  }
510
497
  }
511
498
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
499
+ // ── Core ───────────────────────────────────────────────────────────────────
513
500
 
514
501
  async function runCore() {
515
502
  if (isBlocked()) return 0;
@@ -524,11 +511,8 @@
524
511
 
525
512
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
513
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
514
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
- pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
531
- if (n) decluster(klass);
515
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
532
516
  return n;
533
517
  };
534
518
 
@@ -540,14 +524,13 @@
540
524
  'ezoic-ad-between', getTopics,
541
525
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
526
  );
543
- if (kind === 'categories') return exec(
527
+ return exec(
544
528
  'ezoic-ad-categories', getCategories,
545
529
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
530
  );
547
- return 0;
548
531
  }
549
532
 
550
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
533
+ // ── Scheduler ──────────────────────────────────────────────────────────────
551
534
 
552
535
  function scheduleRun(cb) {
553
536
  if (S.runQueued) return;
@@ -565,10 +548,8 @@
565
548
  if (isBlocked()) return;
566
549
  const t = ts();
567
550
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
- S.lastBurstTs = t;
569
-
570
- const pk = pageKey();
571
- S.pageKey = pk;
551
+ S.lastBurstTs = t;
552
+ S.pageKey = pageKey();
572
553
  S.burstDeadline = t + 2000;
573
554
 
574
555
  if (S.burstActive) return;
@@ -576,7 +557,7 @@
576
557
  S.burstCount = 0;
577
558
 
578
559
  const step = () => {
579
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
560
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
561
  S.burstActive = false; return;
581
562
  }
582
563
  S.burstCount++;
@@ -588,7 +569,7 @@
588
569
  step();
589
570
  }
590
571
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
572
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
573
 
593
574
  function cleanup() {
594
575
  blockedUntil = ts() + 1500;
@@ -605,19 +586,17 @@
605
586
  S.runQueued = false;
606
587
  }
607
588
 
608
- // ── DOM Observer ───────────────────────────────────────────────────────────
589
+ // ── MutationObserver ───────────────────────────────────────────────────────
609
590
 
610
591
  function ensureDomObserver() {
611
592
  if (S.domObs) return;
593
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
594
  S.domObs = new MutationObserver(muts => {
613
595
  if (S.mutGuard > 0 || isBlocked()) return;
614
596
  for (const m of muts) {
615
- if (!m.addedNodes?.length) continue;
616
597
  for (const n of m.addedNodes) {
617
598
  if (n.nodeType !== 1) continue;
618
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
619
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
620
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
599
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
621
600
  requestBurst(); return;
622
601
  }
623
602
  }
@@ -643,29 +622,18 @@
643
622
  }
644
623
 
645
624
  function ensureTcfLocator() {
646
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
647
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
648
- // iframe du DOM (vidage partiel du body), ce qui provoque :
649
- // "Cannot read properties of null (reading 'postMessage')"
650
- // "Cannot set properties of null (setting 'addtlConsent')"
651
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
652
625
  try {
653
626
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
627
  const inject = () => {
656
628
  if (document.getElementById('__tcfapiLocator')) return;
657
629
  const f = document.createElement('iframe');
658
630
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
631
  (document.body || document.documentElement).appendChild(f);
660
632
  };
661
-
662
633
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
634
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
635
+ window.__nbbTcfObs = new MutationObserver(inject);
636
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
637
  }
670
638
  } catch (_) {}
671
639
  }
@@ -675,10 +643,10 @@
675
643
  const head = document.head;
676
644
  if (!head) return;
677
645
  for (const [rel, href, cors] of [
678
- ['preconnect', 'https://g.ezoic.net', true],
679
- ['preconnect', 'https://go.ezoic.net', true],
680
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
681
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
646
+ ['preconnect', 'https://g.ezoic.net', true ],
647
+ ['preconnect', 'https://go.ezoic.net', true ],
648
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
649
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
682
650
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
651
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
652
  ]) {
@@ -692,7 +660,7 @@
692
660
  }
693
661
  }
694
662
 
695
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
663
+ // ── Bindings ───────────────────────────────────────────────────────────────
696
664
 
697
665
  function bindNodeBB() {
698
666
  const $ = window.jQuery;
@@ -703,19 +671,16 @@
703
671
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
672
  S.pageKey = pageKey();
705
673
  blockedUntil = 0;
706
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
- getIO(); ensureDomObserver(); requestBurst();
674
+ muteConsole(); ensureTcfLocator(); warmNetwork();
675
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
708
676
  });
709
677
 
710
- const BURST_EVENTS = [
711
- 'action:ajaxify.contentLoaded',
712
- 'action:posts.loaded', 'action:topics.loaded',
678
+ const burstEvts = [
679
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
713
680
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
681
  ].map(e => `${e}.nbbEzoic`).join(' ');
682
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
715
683
 
716
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
-
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
684
  try {
720
685
  require(['hooks'], hooks => {
721
686
  if (typeof hooks?.on !== 'function') return;