nodebb-plugin-ezoic-infinite 1.7.18 → 1.7.19

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 +235 -214
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.18",
3
+ "version": "1.7.19",
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,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v25
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
+ * v25 Base v20.1 avec :
20
+ *Fix scroll-up / virtualisation NodeBB :
21
+ * – pruneOrphans : PRUNE_STABLE_MS = 45 s, isFilled guard en premier.
22
+ * decluster : isFilled en premier, A_CREATED grace period (FILL_GRACE_MS).
23
+ * Recyclage d'id (pool épuisé en infinite scroll) :
24
+ * – pickRecyclableWrap() : sélectionne le wrap vide le plus loin au-dessus
25
+ * du viewport (seuil -6 × vh), jamais pour ezoic-ad-message.
26
+ * moveWrapAfter() : déplace le wrap vers sa nouvelle ancre.
27
+ * scrollDir tracking pour n'autoriser le recyclage qu'en scroll down.
28
+ * Table KIND unifiée avec baseTag + ordinalAttr + recyclable flag.
29
+ * ordinal() : utilise KIND[klass].ordinalAttr, fallback positionnel propre.
31
30
  */
32
31
  (function () {
33
32
  'use strict';
34
33
 
35
34
  // ── Constantes ─────────────────────────────────────────────────────────────
36
35
 
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)
36
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
37
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
38
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
39
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
40
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
41
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
42
+
43
+ const PRUNE_STABLE_MS = 45_000; // délai avant pruning (évite faux-orphelins scroll-up)
44
+ const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
45
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
46
+ const RECYCLE_THRESHOLD = 6; // nb de viewports au-dessus du seuil de recyclage
47
+ const MAX_INSERTS_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // IO : marges larges fixes — une seule instance, jamais recréée
53
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
55
 
@@ -60,50 +60,54 @@
60
60
  };
61
61
 
62
62
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
63
+ * Table KIND source de vérité par kindClass.
64
64
  *
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
65
+ * sel : sélecteur CSS complet
66
+ * baseTag : préfixe tag pour querySelector d'ancre
67
+ * (vide pour posts car sélecteur commence par '[')
68
+ * anchorAttr : attribut DOM stable clé unique du wrap
69
+ * data-pid posts / data-index topics / data-cid catégories
70
+ * ordinalAttr: attribut 0-based pour calcul de l'intervalle
71
+ * null → fallback positionnel (catégories)
72
+ * recyclable : autoriser le recyclage d'id quand le pool est épuisé
73
+ * false pour ezoic-ad-message (sauts visuels indésirables)
71
74
  */
72
75
  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' },
76
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index', recyclable: false },
77
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index', recyclable: true },
78
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null, recyclable: true },
76
79
  };
77
80
 
78
81
  // ── État ───────────────────────────────────────────────────────────────────
79
82
 
80
83
  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
-
84
+ pageKey: null,
85
+ cfg: null,
86
+ pools: { topics: [], posts: [], categories: [] },
87
+ cursors: { topics: 0, posts: 0, categories: 0 },
88
+ mountedIds: new Set(),
89
+ lastShow: new Map(),
90
+ io: null,
91
+ domObs: null,
92
+ mutGuard: 0,
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
97
96
  runQueued: false,
98
97
  burstActive: false,
99
98
  burstDeadline: 0,
100
99
  burstCount: 0,
101
100
  lastBurstTs: 0,
101
+ scrollDir: 1, // 1 = down, -1 = up
102
+ lastScrollY: 0,
102
103
  };
103
104
 
104
105
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
106
+ const ts = () => Date.now();
107
+ const isBlocked = () => ts() < blockedUntil;
108
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
109
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
110
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
111
 
108
112
  function mutate(fn) {
109
113
  S.mutGuard++;
@@ -121,12 +125,6 @@
121
125
  return S.cfg;
122
126
  }
123
127
 
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
128
  function parseIds(raw) {
131
129
  const out = [], seen = new Set();
132
130
  for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
@@ -136,12 +134,11 @@
136
134
  return out;
137
135
  }
138
136
 
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; } };
137
+ function initPools(cfg) {
138
+ S.pools.topics = parseIds(cfg.placeholderIds);
139
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
140
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
141
+ }
145
142
 
146
143
  // ── Page identity ──────────────────────────────────────────────────────────
147
144
 
@@ -165,13 +162,13 @@
165
162
  return 'other';
166
163
  }
167
164
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
165
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
166
 
170
167
  function getPosts() {
171
168
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
169
  if (!el.isConnected) return false;
173
170
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
171
+ const p = el.parentElement?.closest(SEL.post);
175
172
  if (p && p !== el) return false;
176
173
  return el.getAttribute('component') !== 'post/parent';
177
174
  });
@@ -187,36 +184,28 @@
187
184
  );
188
185
  }
189
186
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
187
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
188
 
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;
189
+ function stableId(klass, el) {
190
+ const attr = KIND[klass]?.anchorAttr;
199
191
  if (attr) {
200
192
  const v = el.getAttribute(attr);
201
193
  if (v !== null && v !== '') return v;
202
194
  }
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 (_) {}
195
+ let i = 0;
196
+ for (const s of el.parentElement?.children ?? []) {
197
+ if (s === el) return `i${i}`;
198
+ i++;
199
+ }
211
200
  return 'i0';
212
201
  }
213
202
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
203
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
204
 
216
- function findWrap(anchorKey) {
205
+ function findWrap(key) {
217
206
  try {
218
207
  return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
208
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
209
  );
221
210
  } catch (_) { return null; }
222
211
  }
@@ -226,7 +215,7 @@
226
215
  function pickId(poolKey) {
227
216
  const pool = S.pools[poolKey];
228
217
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
218
+ const i = S.cursors[poolKey] % pool.length;
230
219
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
220
  const id = pool[i];
232
221
  if (!S.mountedIds.has(id)) return id;
@@ -234,10 +223,53 @@
234
223
  return null;
235
224
  }
236
225
 
226
+ // ── Recyclage d'id ─────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Sélectionne le wrap vide le plus éloigné au-dessus du viewport.
230
+ * Conditions : kindClass.recyclable = true, scroll vers le bas,
231
+ * wrap vide (non filled), rect.bottom < -(RECYCLE_THRESHOLD × vh).
232
+ */
233
+ function pickRecyclableWrap(klass) {
234
+ if (!KIND[klass]?.recyclable) return null;
235
+ if (S.scrollDir < 0) return null;
236
+
237
+ const vh = Math.max(300, window.innerHeight || 800);
238
+ const threshold = -(vh * RECYCLE_THRESHOLD);
239
+ let best = null, bestBottom = Infinity;
240
+
241
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
242
+ if (!w.isConnected || isFilled(w)) continue;
243
+ try {
244
+ const rect = w.getBoundingClientRect();
245
+ if (rect.bottom < threshold && rect.bottom < bestBottom) {
246
+ bestBottom = rect.bottom;
247
+ best = w;
248
+ }
249
+ } catch (_) {}
250
+ }
251
+ return best;
252
+ }
253
+
254
+ /**
255
+ * Déplace un wrap recyclé vers sa nouvelle ancre el.
256
+ * Réinitialise A_ANCHOR, A_CREATED, supprime A_SHOWN.
257
+ */
258
+ function moveWrapAfter(el, wrap, newKey) {
259
+ try {
260
+ if (!el || !wrap?.isConnected) return null;
261
+ wrap.setAttribute(A_ANCHOR, newKey);
262
+ wrap.setAttribute(A_CREATED, String(ts()));
263
+ wrap.removeAttribute(A_SHOWN);
264
+ mutate(() => el.insertAdjacentElement('afterend', wrap));
265
+ return wrap;
266
+ } catch (_) { return null; }
267
+ }
268
+
237
269
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
238
270
 
239
271
  function makeWrap(id, klass, key) {
240
- const w = document.createElement('div');
272
+ const w = document.createElement('div');
241
273
  w.className = `${WRAP_CLASS} ${klass}`;
242
274
  w.setAttribute(A_ANCHOR, key);
243
275
  w.setAttribute(A_WRAPID, String(id));
@@ -251,10 +283,10 @@
251
283
  }
252
284
 
253
285
  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;
286
+ if (!el?.insertAdjacentElement) return null;
287
+ if (findWrap(key)) return null;
288
+ if (S.mountedIds.has(id)) return null;
289
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
290
  const w = makeWrap(id, klass, key);
259
291
  mutate(() => el.insertAdjacentElement('afterend', w));
260
292
  S.mountedIds.add(id);
@@ -263,15 +295,10 @@
263
295
 
264
296
  function dropWrap(w) {
265
297
  try {
298
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
299
+ if (ph instanceof Element) S.io?.unobserve(ph);
266
300
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
301
  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
302
  w.remove();
276
303
  } catch (_) {}
277
304
  }
@@ -279,58 +306,59 @@
279
306
  // ── Prune ──────────────────────────────────────────────────────────────────
280
307
 
281
308
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
309
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
283
310
  *
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é).
311
+ * isFilled en premier : un wrap rempli n'est JAMAIS supprimé.
312
+ * PRUNE_STABLE_MS (45 s) : pendant cette fenêtre une ancre absente est
313
+ * considérée comme virtualisée par NodeBB (scroll up), pas comme orphelin réel.
291
314
  */
292
315
  function pruneOrphans(klass) {
293
316
  const meta = KIND[klass];
294
317
  if (!meta) return;
295
318
 
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;
319
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
320
+ if (isFilled(w)) continue;
321
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < PRUNE_STABLE_MS) continue;
300
322
 
301
323
  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; }
324
+ const sid = key.slice(klass.length + 1);
325
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
304
326
 
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
327
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
328
+ const anchorEl = document.querySelector(sel);
308
329
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
- });
330
+ }
310
331
  }
311
332
 
312
333
  // ── Decluster ──────────────────────────────────────────────────────────────
313
334
 
314
335
  /**
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.
336
+ * Deux wraps adjacents → supprimer le courant s'il est vide et hors grâce.
337
+ * Guards dans l'ordre :
338
+ * 1. isFilled(w) → jamais toucher un wrap rempli
339
+ * 2. A_CREATED < FILL_GRACE → wrap trop récent (pas encore showAds'd)
340
+ * 3. A_SHOWN grace → fill en cours
341
+ * 4. isFilled(prev) → voisin rempli, intouchable → break
342
+ * 5. A_CREATED prev grace → voisin trop récent → break
343
+ * 6. A_SHOWN prev grace → break
344
+ * → les deux vides et hors grâce : supprimer le courant
318
345
  */
319
346
  function decluster(klass) {
320
347
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
348
+ if (isFilled(w)) continue;
349
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) continue;
322
350
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
351
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
352
 
325
353
  let prev = w.previousElementSibling, steps = 0;
326
354
  while (prev && steps++ < 3) {
327
355
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
356
+ if (isFilled(prev)) break;
357
+ if (ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) break;
329
358
  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
359
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
331
360
 
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
361
+ mutate(() => dropWrap(w));
334
362
  break;
335
363
  }
336
364
  }
@@ -340,23 +368,21 @@
340
368
 
341
369
  /**
342
370
  * 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).
371
+ * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
372
+ * Catégories : ordinalAttr = null fallback positionnel.
345
373
  */
346
374
  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 (_) {}
375
+ const attr = KIND[klass]?.ordinalAttr;
376
+ if (attr) {
377
+ const v = el.getAttribute(attr);
378
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
379
+ }
380
+ const fullSel = KIND[klass]?.sel ?? '';
381
+ let i = 0;
382
+ for (const s of el.parentElement?.children ?? []) {
383
+ if (s === el) return i;
384
+ if (!fullSel || s.matches?.(fullSel)) i++;
385
+ }
360
386
  return 0;
361
387
  }
362
388
 
@@ -365,23 +391,33 @@
365
391
  let inserted = 0;
366
392
 
367
393
  for (const el of items) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
394
+ if (inserted >= MAX_INSERTS_RUN) break;
369
395
  if (!el?.isConnected) continue;
370
396
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
374
-
397
+ const ord = ordinal(klass, el);
398
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
399
  if (adjacentWrap(el)) continue;
376
400
 
377
- const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
401
+ const key = anchorKey(klass, el);
402
+ if (findWrap(key)) continue;
379
403
 
404
+ // 1. Tentative pool normal
380
405
  const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
406
+ if (id) {
407
+ const w = insertAfter(el, id, klass, key);
408
+ if (w) { observePh(id); inserted++; }
409
+ continue;
410
+ }
382
411
 
383
- const w = insertAfter(el, id, klass, key);
384
- if (w) { observePh(id); inserted++; }
412
+ // 2. Pool épuisé tentative de recyclage
413
+ const recyclable = pickRecyclableWrap(klass);
414
+ if (recyclable) {
415
+ const rid = parseInt(recyclable.getAttribute(A_WRAPID), 10);
416
+ const w = moveWrapAfter(el, recyclable, key);
417
+ if (w && Number.isFinite(rid)) { observePh(rid); inserted++; }
418
+ }
419
+ // Pool épuisé et pas de recyclage : on continue (items suivants peuvent
420
+ // avoir un wrap existant via findWrap, on ne break pas)
385
421
  }
386
422
  return inserted;
387
423
  }
@@ -390,7 +426,6 @@
390
426
 
391
427
  function getIO() {
392
428
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
429
  try {
395
430
  S.io = new IntersectionObserver(entries => {
396
431
  for (const e of entries) {
@@ -399,7 +434,7 @@
399
434
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
435
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
436
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
437
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
438
  } catch (_) { S.io = null; }
404
439
  return S.io;
405
440
  }
@@ -450,7 +485,6 @@
450
485
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
486
  S.lastShow.set(id, t);
452
487
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
488
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
489
 
456
490
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +505,6 @@
471
505
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
506
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
507
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
508
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
509
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
510
  } catch (_) {}
@@ -490,7 +523,7 @@
490
523
  const orig = ez.showAds.bind(ez);
491
524
  ez.showAds = function (...args) {
492
525
  if (isBlocked()) return;
493
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
526
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
527
  const seen = new Set();
495
528
  for (const v of ids) {
496
529
  const id = parseInt(v, 10);
@@ -509,7 +542,7 @@
509
542
  }
510
543
  }
511
544
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
545
+ // ── Core ───────────────────────────────────────────────────────────────────
513
546
 
514
547
  async function runCore() {
515
548
  if (isBlocked()) return 0;
@@ -524,10 +557,9 @@
524
557
 
525
558
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
559
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
560
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
561
  pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
562
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
531
563
  if (n) decluster(klass);
532
564
  return n;
533
565
  };
@@ -540,14 +572,13 @@
540
572
  'ezoic-ad-between', getTopics,
541
573
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
574
  );
543
- if (kind === 'categories') return exec(
575
+ return exec(
544
576
  'ezoic-ad-categories', getCategories,
545
577
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
578
  );
547
- return 0;
548
579
  }
549
580
 
550
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
581
+ // ── Scheduler ──────────────────────────────────────────────────────────────
551
582
 
552
583
  function scheduleRun(cb) {
553
584
  if (S.runQueued) return;
@@ -565,10 +596,8 @@
565
596
  if (isBlocked()) return;
566
597
  const t = ts();
567
598
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
- S.lastBurstTs = t;
569
-
570
- const pk = pageKey();
571
- S.pageKey = pk;
599
+ S.lastBurstTs = t;
600
+ S.pageKey = pageKey();
572
601
  S.burstDeadline = t + 2000;
573
602
 
574
603
  if (S.burstActive) return;
@@ -576,7 +605,7 @@
576
605
  S.burstCount = 0;
577
606
 
578
607
  const step = () => {
579
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
608
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
609
  S.burstActive = false; return;
581
610
  }
582
611
  S.burstCount++;
@@ -588,7 +617,7 @@
588
617
  step();
589
618
  }
590
619
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
620
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
621
 
593
622
  function cleanup() {
594
623
  blockedUntil = ts() + 1500;
@@ -605,19 +634,17 @@
605
634
  S.runQueued = false;
606
635
  }
607
636
 
608
- // ── DOM Observer ───────────────────────────────────────────────────────────
637
+ // ── MutationObserver ───────────────────────────────────────────────────────
609
638
 
610
639
  function ensureDomObserver() {
611
640
  if (S.domObs) return;
641
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
642
  S.domObs = new MutationObserver(muts => {
613
643
  if (S.mutGuard > 0 || isBlocked()) return;
614
644
  for (const m of muts) {
615
- if (!m.addedNodes?.length) continue;
616
645
  for (const n of m.addedNodes) {
617
646
  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)) {
647
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
621
648
  requestBurst(); return;
622
649
  }
623
650
  }
@@ -643,29 +670,18 @@
643
670
  }
644
671
 
645
672
  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
673
  try {
653
674
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
675
  const inject = () => {
656
676
  if (document.getElementById('__tcfapiLocator')) return;
657
677
  const f = document.createElement('iframe');
658
678
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
679
  (document.body || document.documentElement).appendChild(f);
660
680
  };
661
-
662
681
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
682
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
683
+ window.__nbbTcfObs = new MutationObserver(inject);
684
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
685
  }
670
686
  } catch (_) {}
671
687
  }
@@ -675,10 +691,10 @@
675
691
  const head = document.head;
676
692
  if (!head) return;
677
693
  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],
694
+ ['preconnect', 'https://g.ezoic.net', true ],
695
+ ['preconnect', 'https://go.ezoic.net', true ],
696
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
697
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
682
698
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
699
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
700
  ]) {
@@ -692,7 +708,7 @@
692
708
  }
693
709
  }
694
710
 
695
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
711
+ // ── Bindings ───────────────────────────────────────────────────────────────
696
712
 
697
713
  function bindNodeBB() {
698
714
  const $ = window.jQuery;
@@ -703,19 +719,16 @@
703
719
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
720
  S.pageKey = pageKey();
705
721
  blockedUntil = 0;
706
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
- getIO(); ensureDomObserver(); requestBurst();
722
+ muteConsole(); ensureTcfLocator(); warmNetwork();
723
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
708
724
  });
709
725
 
710
- const BURST_EVENTS = [
711
- 'action:ajaxify.contentLoaded',
712
- 'action:posts.loaded', 'action:topics.loaded',
726
+ const burstEvts = [
727
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
713
728
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
729
  ].map(e => `${e}.nbbEzoic`).join(' ');
730
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
715
731
 
716
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
-
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
732
  try {
720
733
  require(['hooks'], hooks => {
721
734
  if (typeof hooks?.on !== 'function') return;
@@ -730,6 +743,13 @@
730
743
  function bindScroll() {
731
744
  let ticking = false;
732
745
  window.addEventListener('scroll', () => {
746
+ // Suivi direction du scroll (nécessaire pour le recyclage conditionnel)
747
+ try {
748
+ const y = window.scrollY || window.pageYOffset || 0;
749
+ const d = y - S.lastScrollY;
750
+ if (Math.abs(d) > 4) { S.scrollDir = d > 0 ? 1 : -1; S.lastScrollY = y; }
751
+ } catch (_) {}
752
+
733
753
  if (ticking) return;
734
754
  ticking = true;
735
755
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
@@ -739,6 +759,7 @@
739
759
  // ── Boot ───────────────────────────────────────────────────────────────────
740
760
 
741
761
  S.pageKey = pageKey();
762
+ try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) {}
742
763
  muteConsole();
743
764
  ensureTcfLocator();
744
765
  warmNetwork();