nodebb-plugin-ezoic-infinite 1.6.99 → 1.7.0

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 +262 -233
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.99",
3
+ "version": "1.7.0",
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,46 +1,58 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v18)
2
+ * NodeBB Ezoic Infinite Ads — client.js (v19)
3
3
  *
4
- * Corrections majeures vs v17 :
5
- * 1. ANCRAGE PAR pid/tid (data-pid, data-index) au lieu d'ordinalMap fragile.
6
- * Le même post garde toujours le même ID de wrap, quelle que soit la virtualisation.
7
- * 2. CLEANUP COMPLET à chaque navigation ajaxify (wraps + curseurs + état pools).
8
- * Supprime les fantômes qui se réaffichaient après scroll up/down.
9
- * 3. DEDUPLICATION par ancrage : on vérifie `data-ezoic-anchor` avant d'insérer.
10
- * Empêche la création de doublons lors de multiples passes DOM.
11
- * 4. PAS de recyclage de wraps (moveWrapAfter supprimé).
12
- * La cause n°1 des pubs "qui sautent n'importe où".
13
- * 5. pruneOrphanWraps simplifié : suppression réelle (remove) au lieu de hide.
14
- * Un wrap orphelin = mort, pas caché. Libère l'id dans le curseur.
15
- * 6. Déduplication de l'id Ezoic par wrap : un id ne peut être monté qu'une fois.
16
- * 7. V17 pile-fix SUPPRIMÉ (was conflicting with main logic).
17
- * 8. Factorisation : helpers partagés, pas de duplication de logique entre kinds.
4
+ * Correctifs v19 vs v18 :
5
+ *
6
+ * [BUG 1] Pubs regroupées en haut après scroll up/down
7
+ * Cause : pruneOrphans gardait les wraps "filled" même quand leur ancre DOM
8
+ * avait disparu (post virtualisé). Ces wraps sans parent flottaient et
9
+ * NodeBB les réordonnait arbitrairement.
10
+ * Fix : un wrap dont l'ancre est absente du DOM EST supprimé, rempli ou non.
11
+ * Exception : si l'ancre est simplement hors-viewport mais still connected
12
+ * (NodeBB ne virtualise pas toujours le DOM), on la conserve.
13
+ *
14
+ * [BUG 2] Pub qui apparaît puis disparaît
15
+ * Cause : decluster() supprimait un wrap "vide" pendant la fenêtre de fill
16
+ * async d'Ezoic. Le guard TTL de 90s était calculé depuis la création,
17
+ * mais le show() peut avoir été appelé bien après la création.
18
+ * Fix : on ajoute data-ezoic-shown (timestamp du show). decluster ne touche
19
+ * pas un wrap dont le show date de moins de FILL_GRACE_MS (20s).
20
+ *
21
+ * [BUG 3] Intervalle 1/x non respecté sur infinite scroll
22
+ * Cause : computeTargetIndices utilisait l'index dans le tableau courant
23
+ * (items[0..N]), qui recommence à 0 à chaque batch de posts chargés.
24
+ * Fix : on utilise l'ordinal GLOBAL du post (data-index fourni par NodeBB,
25
+ * ou data-pid comme fallback numérique). L'intervalle est appliqué sur cet
26
+ * ordinal global → pub tous les X posts absolus, quel que soit le batch.
18
27
  */
19
28
  (function () {
20
29
  'use strict';
21
30
 
22
31
  // ─── Constants ────────────────────────────────────────────────────────────
23
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
24
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
25
- const ANCHOR_ATTR = 'data-ezoic-anchor'; // unique key = kind:anchorId
26
- const WRAPID_ATTR = 'data-ezoic-wrapid';
27
- const CREATED_ATTR = 'data-ezoic-created';
32
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
33
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
34
+ const ANCHOR_ATTR = 'data-ezoic-anchor'; // "kindClass:globalOrdinal"
35
+ const WRAPID_ATTR = 'data-ezoic-wrapid'; // ezoic placeholder id
36
+ const CREATED_ATTR = 'data-ezoic-created'; // timestamp création
37
+ const SHOWN_ATTR = 'data-ezoic-shown'; // timestamp dernier showAds
28
38
 
29
39
  const MAX_INSERTS_PER_RUN = 6;
30
- const EMPTY_WRAP_TTL_MS = 90_000; // 90 s avant de colapser un wrap vide
31
- const FILL_WATCH_MS = 7_000;
40
+ // Après un showAds(), ne pas decluster pendant ce délai (fill async Ezoic)
41
+ const FILL_GRACE_MS = 20_000;
42
+ // Collapse is-empty seulement après ce délai post-show
43
+ const EMPTY_CHECK_DELAY = 18_000;
32
44
 
33
45
  const PRELOAD_MARGIN = {
34
- desktop: '2000px 0px 2000px 0px',
35
- mobile: '3000px 0px 3000px 0px',
36
- desktopBoosted:'4500px 0px 4500px 0px',
37
- mobileBoosted: '4500px 0px 4500px 0px',
46
+ desktop: '2000px 0px 2000px 0px',
47
+ mobile: '3000px 0px 3000px 0px',
48
+ desktopBoosted: '4500px 0px 4500px 0px',
49
+ mobileBoosted: '4500px 0px 4500px 0px',
38
50
  };
39
- const BOOST_DURATION_MS = 2500;
40
- const BOOST_SPEED_PX_PER_MS = 2.2;
41
- const MAX_INFLIGHT_DESKTOP = 4;
42
- const MAX_INFLIGHT_MOBILE = 3;
43
- const SHOW_THROTTLE_MS = 900;
51
+ const BOOST_DURATION_MS = 2500;
52
+ const BOOST_SPEED_PX_PER_MS = 2.2;
53
+ const MAX_INFLIGHT_DESKTOP = 4;
54
+ const MAX_INFLIGHT_MOBILE = 3;
55
+ const SHOW_THROTTLE_MS = 900;
44
56
 
45
57
  const SELECTORS = {
46
58
  topicItem: 'li[component="category/topic"]',
@@ -49,62 +61,47 @@
49
61
  };
50
62
 
51
63
  // ─── Helpers ──────────────────────────────────────────────────────────────
52
- const now = () => Date.now();
64
+ const now = () => Date.now();
53
65
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
54
66
  const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
67
+ const isFilledNode = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
55
68
 
56
69
  function uniqInts(raw) {
57
70
  const out = [], seen = new Set();
58
- for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
71
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
59
72
  const n = parseInt(v, 10);
60
73
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
61
74
  }
62
75
  return out;
63
76
  }
64
77
 
65
- function isFilledNode(node) {
66
- return !!(node?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
67
- }
68
-
69
78
  // ─── State ────────────────────────────────────────────────────────────────
70
79
  const state = {
71
80
  pageKey: null,
72
81
  cfg: null,
73
82
 
74
- // ID pools + curseurs rotatifs
75
- pools: { topics: [], posts: [], categories: [] },
76
- cursors: { topics: 0, posts: 0, categories: 0 },
83
+ pools: { topics: [], posts: [], categories: [] },
84
+ cursors: { topics: 0, posts: 0, categories: 0 },
77
85
 
78
- // Suivi des IDs Ezoic actuellement montés dans le DOM (évite les doublons)
86
+ // IDs Ezoic actuellement montés dans le DOM (Set<number>)
79
87
  mountedIds: new Set(),
80
88
 
81
- // Throttle par id
82
89
  lastShowById: new Map(),
83
90
 
84
- // Observers
85
91
  domObs: null,
86
- io: null,
87
- ioMargin: null,
92
+ io: null, ioMargin: null,
88
93
 
89
- // Guard contre nos propres mutations
90
94
  internalMutation: 0,
91
95
 
92
- // File de show
93
96
  inflight: 0,
94
- pending: [],
95
- pendingSet: new Set(),
97
+ pending: [], pendingSet: new Set(),
96
98
 
97
- // Scroll boost
98
99
  scrollBoostUntil: 0,
99
- lastScrollY: 0,
100
- lastScrollTs: 0,
100
+ lastScrollY: 0, lastScrollTs: 0,
101
101
 
102
- // Scheduler
103
102
  runQueued: false,
104
- burstActive: false,
105
- burstDeadline: 0,
106
- burstCount: 0,
107
- lastBurstReqTs: 0,
103
+ burstActive: false, burstDeadline: 0,
104
+ burstCount: 0, lastBurstReqTs: 0,
108
105
  };
109
106
 
110
107
  let blockedUntil = 0;
@@ -129,10 +126,10 @@
129
126
 
130
127
  function initPools(cfg) {
131
128
  if (!cfg) return;
132
- state.pools.topics = uniqInts(cfg.placeholderIds || '');
133
- state.pools.posts = uniqInts(cfg.messagePlaceholderIds || '');
134
- state.pools.categories = uniqInts(cfg.categoryPlaceholderIds || '');
135
- // Ne pas réinitialiser les curseurs ici (ils sont remis à 0 dans cleanup).
129
+ // Réinitialise à chaque page (cleanup() remet les curseurs à 0)
130
+ state.pools.topics = uniqInts(cfg.placeholderIds);
131
+ state.pools.posts = uniqInts(cfg.messagePlaceholderIds);
132
+ state.pools.categories = uniqInts(cfg.categoryPlaceholderIds);
136
133
  }
137
134
 
138
135
  // ─── Page / Kind ──────────────────────────────────────────────────────────
@@ -147,9 +144,9 @@
147
144
 
148
145
  function getKind() {
149
146
  const p = location.pathname;
150
- if (/^\/topic\//.test(p)) return 'topic';
151
- if (/^\/category\//.test(p)) return 'categoryTopics';
152
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
147
+ if (/^\/topic\//.test(p)) return 'topic';
148
+ if (/^\/category\//.test(p)) return 'categoryTopics';
149
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
153
150
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
154
151
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
155
152
  if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
@@ -161,60 +158,84 @@
161
158
  return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter(el => {
162
159
  if (!el.isConnected) return false;
163
160
  if (!el.querySelector('[component="post/content"]')) return false;
164
- const parentPost = el.parentElement?.closest('[component="post"][data-pid]');
165
- if (parentPost && parentPost !== el) return false;
161
+ const parent = el.parentElement?.closest('[component="post"][data-pid]');
162
+ if (parent && parent !== el) return false;
166
163
  if (el.getAttribute('component') === 'post/parent') return false;
167
164
  return true;
168
165
  });
169
166
  }
170
167
 
171
- function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
168
+ function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
172
169
  function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
173
170
 
171
+ function hasAdjacentWrap(el) {
172
+ return !!(el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
173
+ el.previousElementSibling?.classList?.contains(WRAP_CLASS));
174
+ }
175
+
176
+ // ─── Ordinal global (BUG FIX #3) ──────────────────────────────────────────
174
177
  /**
175
- * Calcule l'ancre stable d'un élément : on préfère data-pid (posts) puis
176
- * data-index, sinon on retombe sur l'index dans le tableau.
177
- * La clé est préfixée par kindClass pour éviter les collisions.
178
+ * Retourne l'ordinal ABSOLU d'un élément dans la page complète (pas le batch).
179
+ *
180
+ * Pour les posts (topic) : NodeBB expose data-index (0-based) sur chaque
181
+ * [component="post"]. On l'utilise directement.
182
+ *
183
+ * Pour les topics (liste catégorie) : idem, data-index sur le <li>.
184
+ *
185
+ * Fallback : on parcourt le DOM pour compter la position réelle de l'élément
186
+ * parmi ses frères de même type.
178
187
  */
179
- function getAnchorKey(kindClass, el, fallbackIndex) {
180
- const pid = el.getAttribute('data-pid');
181
- const index = el.getAttribute('data-index') ?? el.getAttribute('data-idx');
182
- const id = pid ?? index ?? String(fallbackIndex);
183
- return `${kindClass}:${id}`;
184
- }
188
+ function getGlobalOrdinal(el, selector) {
189
+ // 1. data-index (NodeBB 3+/4+) — 0-based → on retourne 1-based
190
+ const di = el.getAttribute('data-index');
191
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10); // déjà 0-based, on le garde
185
192
 
186
- function findWrapByAnchor(anchorKey) {
187
- return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${CSS.escape(anchorKey)}"]`);
188
- }
193
+ // 2. Compter dans le DOM parmi les frères du même type
194
+ try {
195
+ const all = el.parentElement?.querySelectorAll?.(':scope > ' + selector.split('[')[0]);
196
+ if (all) {
197
+ let i = 0;
198
+ for (const node of all) {
199
+ if (node === el) return i;
200
+ i++;
201
+ }
202
+ }
203
+ } catch (_) {}
189
204
 
190
- function hasAdjacentWrap(el) {
191
- const next = el.nextElementSibling;
192
- if (next?.classList?.contains(WRAP_CLASS)) return true;
193
- const prev = el.previousElementSibling;
194
- if (prev?.classList?.contains(WRAP_CLASS)) return true;
195
- return false;
205
+ return 0;
196
206
  }
197
207
 
198
- // ─── ID pool / rotation ───────────────────────────────────────────────────
199
208
  /**
200
- * Retourne le prochain id disponible du pool (non déjà monté dans le DOM),
201
- * en avançant le curseur rotatif.
209
+ * Clé d'ancre unique et stable pour un élément donné.
210
+ * Format : "kindClass:globalOrdinal"
211
+ * → Identique au scroll up/down, identique entre batches.
202
212
  */
213
+ function getAnchorKey(kindClass, el, selector) {
214
+ const ord = getGlobalOrdinal(el, selector);
215
+ return `${kindClass}:${ord}`;
216
+ }
217
+
218
+ function findWrapByAnchor(anchorKey) {
219
+ // CSS.escape pour les : dans la clé
220
+ try {
221
+ return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${anchorKey.replace(/"/g, '\\"')}"]`);
222
+ } catch (_) { return null; }
223
+ }
224
+
225
+ // ─── Pool rotation ────────────────────────────────────────────────────────
203
226
  function pickId(poolKey) {
204
227
  const pool = state.pools[poolKey];
205
- const n = pool.length;
206
- if (!n) return null;
207
-
208
- for (let tries = 0; tries < n; tries++) {
209
- const idx = state.cursors[poolKey] % n;
210
- state.cursors[poolKey] = (state.cursors[poolKey] + 1) % n;
228
+ if (!pool.length) return null;
229
+ for (let tries = 0; tries < pool.length; tries++) {
230
+ const idx = state.cursors[poolKey] % pool.length;
231
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
211
232
  const id = pool[idx];
212
233
  if (!state.mountedIds.has(id)) return id;
213
234
  }
214
- return null; // Tous les IDs déjà montés
235
+ return null;
215
236
  }
216
237
 
217
- // ─── Wrap construction ────────────────────────────────────────────────────
238
+ // ─── Wrap build / insert / remove ─────────────────────────────────────────
218
239
  function buildWrap(id, kindClass, anchorKey) {
219
240
  const wrap = document.createElement('div');
220
241
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
@@ -227,20 +248,14 @@
227
248
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
228
249
  ph.setAttribute('data-ezoic-id', String(id));
229
250
  wrap.appendChild(ph);
230
-
231
251
  return wrap;
232
252
  }
233
253
 
234
254
  function insertWrapAfter(el, id, kindClass, anchorKey) {
235
255
  if (!el?.insertAdjacentElement) return null;
236
- if (findWrapByAnchor(anchorKey)) return null; // déjà présent
237
- if (state.mountedIds.has(id)) return null; // id déjà monté
238
-
239
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
240
- if (existingPh?.isConnected) {
241
- // Cet id a déjà un placeholder dans le DOM → on ne peut pas le dupliquer
242
- return null;
243
- }
256
+ if (findWrapByAnchor(anchorKey)) return null; // déjà inséré
257
+ if (state.mountedIds.has(id)) return null; // id déjà monté
258
+ if (document.getElementById(`${PLACEHOLDER_PREFIX}${id}`)?.isConnected) return null;
244
259
 
245
260
  const wrap = buildWrap(id, kindClass, anchorKey);
246
261
  withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
@@ -252,119 +267,123 @@
252
267
  try {
253
268
  const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
254
269
  if (Number.isFinite(id)) state.mountedIds.delete(id);
270
+ try { state.io?.unobserve(wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`)); } catch (_) {}
255
271
  wrap.remove();
256
272
  } catch (_) {}
257
273
  }
258
274
 
259
- // ─── Prune & Decluster ────────────────────────────────────────────────────
275
+ // ─── Prune (BUG FIX #1) ───────────────────────────────────────────────────
260
276
  /**
261
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
262
- * On supprime proprement (remove) plutôt que de cacher → libère l'id.
277
+ * Supprime les wraps dont l'ancre DOM n'est plus connectée.
278
+ *
279
+ * Règle simple et sans exception "filled" :
280
+ * - Si l'élément ancre est présent et connecté → wrap OK, rien à faire.
281
+ * - Si l'élément ancre est absent (virtualisé/retiré) → wrap supprimé,
282
+ * qu'il soit rempli ou non. Cela libère l'ID pour réutilisation.
283
+ *
284
+ * On ne touche PAS aux wraps fraîchement créés (< 5s) pour laisser le
285
+ * temps à NodeBB de finir d'insérer les posts du batch.
263
286
  */
264
- function pruneOrphans(kindClass) {
287
+ function pruneOrphans(kindClass, selector) {
265
288
  const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
266
- let removed = 0;
267
289
 
268
290
  wraps.forEach(wrap => {
269
- // Ne jamais pruner un wrap tout juste créé (fill lent côté Ezoic)
270
291
  const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
271
- if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
292
+ if (now() - created < 5_000) return; // trop récent, on laisse
272
293
 
273
294
  const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
274
- if (!anchorKey) {
275
- withInternalMutation(() => removeWrap(wrap));
276
- removed++;
277
- return;
278
- }
295
+ if (!anchorKey) { withInternalMutation(() => removeWrap(wrap)); return; }
279
296
 
280
- // L'ancre existe-t-elle encore dans le DOM ?
281
- const [, anchorId] = anchorKey.split(':');
282
- const isPost = kindClass === 'ezoic-ad-message';
283
- let anchorEl = null;
297
+ // Retrouver l'ordinal depuis la clé
298
+ const ordStr = anchorKey.split(':').slice(1).join(':');
299
+ const ord = parseInt(ordStr, 10);
284
300
 
285
- if (isPost) {
286
- anchorEl = document.querySelector(`[component="post"][data-pid="${CSS.escape(anchorId)}"]`);
287
- } else {
288
- anchorEl = document.querySelector(`${SELECTORS.topicItem}[data-index="${CSS.escape(anchorId)}"]`)
289
- ?? document.querySelector(`${SELECTORS.categoryItem}[data-index="${CSS.escape(anchorId)}"]`);
290
- }
301
+ // Chercher l'élément ancre par son ordinal global (data-index)
302
+ const anchorEl = isNaN(ord)
303
+ ? null
304
+ : document.querySelector(`${selector.split('[')[0]}[data-index="${ord}"]`);
291
305
 
292
306
  if (!anchorEl || !anchorEl.isConnected) {
293
- // Ancre disparue → si rempli on garde (scrolling back), si vide on supprime
294
- if (!isFilledNode(wrap)) {
295
- withInternalMutation(() => removeWrap(wrap));
296
- removed++;
297
- }
307
+ // Ancre disparue → suppression inconditionnelle
308
+ withInternalMutation(() => removeWrap(wrap));
298
309
  }
299
310
  });
300
-
301
- return removed;
302
311
  }
303
312
 
313
+ // ─── Decluster (BUG FIX #2) ───────────────────────────────────────────────
304
314
  /**
305
- * Si deux wraps se retrouvent consécutifs (sans item entre eux),
306
- * on supprime le plus récent des deux s'il est vide.
315
+ * Supprime les doublons adjacents.
316
+ * Ne touche JAMAIS un wrap qui vient de recevoir un showAds() (fill async).
307
317
  */
308
318
  function decluster(kindClass) {
309
319
  const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
310
- let removed = 0;
311
320
 
312
321
  for (const wrap of wraps) {
313
322
  let prev = wrap.previousElementSibling;
314
323
  let steps = 0;
315
324
  while (prev && steps < 3) {
316
325
  if (prev.classList?.contains(WRAP_CLASS)) {
317
- // Deux wraps consécutifs : supprimer le plus vide/récent
318
- const wCreated = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
319
- const pCreated = parseInt(prev.getAttribute(CREATED_ATTR) || '0', 10);
320
- const wFilled = isFilledNode(wrap);
321
- const pFilled = isFilledNode(prev);
326
+ const wShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
327
+ const pShown = parseInt(prev.getAttribute(SHOWN_ATTR) || '0', 10);
328
+ const wFilled = isFilledNode(wrap);
329
+ const pFilled = isFilledNode(prev);
330
+
331
+ // Ne jamais toucher un wrap en cours de fill (dans les FILL_GRACE_MS après show)
332
+ const wInGrace = wShown && (now() - wShown) < FILL_GRACE_MS;
333
+ const pInGrace = pShown && (now() - pShown) < FILL_GRACE_MS;
334
+
335
+ if (wInGrace || pInGrace) break; // les deux en grace → rien
322
336
 
323
- if (!wFilled) {
337
+ if (!wFilled && !wInGrace) {
324
338
  withInternalMutation(() => removeWrap(wrap));
325
- removed++;
326
- } else if (!pFilled) {
339
+ } else if (!pFilled && !pInGrace) {
327
340
  withInternalMutation(() => removeWrap(prev));
328
- removed++;
329
341
  }
330
- // Si les deux sont remplis, laisser en place
331
342
  break;
332
343
  }
333
344
  prev = prev.previousElementSibling;
334
345
  steps++;
335
346
  }
336
347
  }
337
-
338
- return removed;
339
- }
340
-
341
- // ─── Injection ────────────────────────────────────────────────────────────
342
- function computeTargetIndices(count, interval, showFirst) {
343
- const targets = new Set();
344
- if (showFirst && count > 0) targets.add(0);
345
- for (let i = interval - 1; i < count; i += interval) targets.add(i);
346
- return [...targets].sort((a, b) => a - b);
347
348
  }
348
349
 
349
- function injectBetween(kindClass, items, interval, showFirst, poolKey) {
350
+ // ─── Injection (BUG FIX #3) ───────────────────────────────────────────────
351
+ /**
352
+ * Calcule les positions cibles basées sur l'ordinal GLOBAL de chaque item.
353
+ *
354
+ * interval=3 → pub après les posts dont (globalOrdinal % interval === interval-1)
355
+ * c.-à-d. après les posts globaux 2, 5, 8, 11… (0-based)
356
+ *
357
+ * showFirst=true → aussi après le post global 0.
358
+ *
359
+ * Ce calcul est STABLE entre les batches : si les posts 0-19 sont en DOM,
360
+ * les cibles sont 2, 5, 8, 11, 14, 17. Si les posts 20-39 arrivent,
361
+ * les cibles deviennent 20 (si 20%3===2? non), 23, 26, 29, 32, 35, 38.
362
+ * Jamais de recalcul depuis 0.
363
+ */
364
+ function injectBetween(kindClass, items, interval, showFirst, poolKey, selector) {
350
365
  if (!items.length) return 0;
351
366
 
352
- const targets = computeTargetIndices(items.length, interval, showFirst);
353
- const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
354
- let inserted = 0;
367
+ const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
368
+ let inserted = 0;
355
369
 
356
- for (const idx of targets) {
370
+ for (const el of items) {
357
371
  if (inserted >= maxIns) break;
358
-
359
- const el = items[idx];
360
372
  if (!el?.isConnected) continue;
373
+
374
+ const ord = getGlobalOrdinal(el, selector);
375
+
376
+ // Est-ce une position cible ?
377
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
378
+ if (!isTarget) continue;
379
+
361
380
  if (hasAdjacentWrap(el)) continue;
362
381
 
363
- const anchorKey = getAnchorKey(kindClass, el, idx);
364
- if (findWrapByAnchor(anchorKey)) continue; // déjà là
382
+ const anchorKey = `${kindClass}:${ord}`;
383
+ if (findWrapByAnchor(anchorKey)) continue;
365
384
 
366
385
  const id = pickId(poolKey);
367
- if (!id) break; // pool épuisé pour cette passe
386
+ if (!id) break;
368
387
 
369
388
  const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
370
389
  if (!wrap) continue;
@@ -405,7 +424,6 @@
405
424
  state.ioMargin = margin;
406
425
  } catch (_) { state.io = null; state.ioMargin = null; }
407
426
 
408
- // Ré-observer les placeholders déjà dans le DOM
409
427
  try {
410
428
  document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
411
429
  try { state.io?.observe(n); } catch (_) {}
@@ -420,7 +438,6 @@
420
438
  if (!ph?.isConnected) return;
421
439
  try { state.io?.observe(ph); } catch (_) {}
422
440
 
423
- // Si déjà proche du viewport → show immédiat
424
441
  try {
425
442
  const r = ph.getBoundingClientRect();
426
443
  const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
@@ -436,10 +453,7 @@
436
453
  if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
437
454
 
438
455
  if (state.inflight >= getMaxInflight()) {
439
- if (!state.pendingSet.has(id)) {
440
- state.pending.push(id);
441
- state.pendingSet.add(id);
442
- }
456
+ if (!state.pendingSet.has(id)) { state.pending.push(id); state.pendingSet.add(id); }
443
457
  return;
444
458
  }
445
459
  startShow(id);
@@ -470,45 +484,60 @@
470
484
 
471
485
  requestAnimationFrame(() => {
472
486
  try {
473
- if (isBlocked()) return release();
487
+ if (isBlocked()) { clearTimeout(timeout); return release(); }
488
+
474
489
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
475
- if (!ph?.isConnected) return release();
490
+ if (!ph?.isConnected) { clearTimeout(timeout); return release(); }
476
491
  if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
477
492
 
478
493
  const t = now();
479
- if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return release();
494
+ if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) {
495
+ clearTimeout(timeout); return release();
496
+ }
480
497
  state.lastShowById.set(id, t);
481
498
 
499
+ // Marquer le timestamp du show sur le wrap (pour decluster grace period)
500
+ try {
501
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
502
+ if (wrap) wrap.setAttribute(SHOWN_ATTR, String(t));
503
+ } catch (_) {}
504
+
482
505
  window.ezstandalone = window.ezstandalone || {};
483
506
  const ez = window.ezstandalone;
484
507
 
485
508
  const doShow = () => {
486
509
  try { ez.showAds(id); } catch (_) {}
487
- scheduleEmptyCheck(id);
488
- setTimeout(release, 650);
510
+ scheduleEmptyCheck(id, t);
511
+ setTimeout(() => { clearTimeout(timeout); release(); }, 650);
489
512
  };
490
513
 
491
514
  if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
492
515
  else doShow();
493
- } finally { /* timeout covers us */ }
516
+ } catch (_) { clearTimeout(timeout); release(); }
494
517
  });
495
518
  }
496
519
 
497
- function scheduleEmptyCheck(id) {
520
+ /**
521
+ * Vérifie si le wrap est toujours vide après EMPTY_CHECK_DELAY.
522
+ * On compare avec le timestamp du show pour éviter de colapser
523
+ * un wrap qui aurait reçu un nouveau show entre-temps.
524
+ */
525
+ function scheduleEmptyCheck(id, showTs) {
498
526
  setTimeout(() => {
499
527
  try {
500
528
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
501
529
  if (!ph?.isConnected) return;
502
- const wrap = ph.closest(`.${WRAP_CLASS}`);
530
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
503
531
  if (!wrap) return;
504
532
 
505
- const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
506
- if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
533
+ // Si un nouveau show a eu lieu après celui-ci, ne pas colapser
534
+ const lastShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
535
+ if (lastShown > showTs) return;
507
536
 
508
537
  if (!isFilledNode(ph)) wrap.classList.add('is-empty');
509
538
  else wrap.classList.remove('is-empty');
510
539
  } catch (_) {}
511
- }, 15_000);
540
+ }, EMPTY_CHECK_DELAY);
512
541
  }
513
542
 
514
543
  // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
@@ -558,23 +587,35 @@
558
587
  const kind = getKind();
559
588
  let inserted = 0;
560
589
 
561
- const run = (kindClass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
590
+ /**
591
+ * @param {string} kindClass
592
+ * @param {() => Element[]} getItems
593
+ * @param {string} selector - sélecteur CSS de base (pour ordinal fallback)
594
+ * @param {*} cfgEnable
595
+ * @param {number} cfgInterval
596
+ * @param {*} cfgShowFirst
597
+ * @param {string} poolKey
598
+ */
599
+ const run = (kindClass, getItems, selector, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
562
600
  if (!normBool(cfgEnable)) return 0;
563
- const items = getItems();
564
- pruneOrphans(kindClass);
565
- const n = injectBetween(kindClass, items, Math.max(1, parseInt(cfgInterval, 10) || 3), normBool(cfgShowFirst), poolKey);
601
+ const items = getItems();
602
+ const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
603
+ const first = normBool(cfgShowFirst);
604
+
605
+ pruneOrphans(kindClass, selector);
606
+ const n = injectBetween(kindClass, items, interval, first, poolKey, selector);
566
607
  if (n) decluster(kindClass);
567
608
  return n;
568
609
  };
569
610
 
570
611
  if (kind === 'topic') {
571
- inserted += run('ezoic-ad-message', getPostContainers,
612
+ inserted += run('ezoic-ad-message', getPostContainers, SELECTORS.postItem,
572
613
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
573
614
  } else if (kind === 'categoryTopics') {
574
- inserted += run('ezoic-ad-between', getTopicItems,
615
+ inserted += run('ezoic-ad-between', getTopicItems, SELECTORS.topicItem,
575
616
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
576
617
  } else if (kind === 'categories') {
577
- inserted += run('ezoic-ad-categories', getCategoryItems,
618
+ inserted += run('ezoic-ad-categories', getCategoryItems, SELECTORS.categoryItem,
578
619
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
579
620
  }
580
621
 
@@ -582,7 +623,7 @@
582
623
  }
583
624
 
584
625
  // ─── Scheduler / Burst ────────────────────────────────────────────────────
585
- function scheduleRun(delayMs = 0, cb) {
626
+ function scheduleRun(delayMs, cb) {
586
627
  if (state.runQueued) return;
587
628
  state.runQueued = true;
588
629
 
@@ -606,7 +647,7 @@
606
647
  state.lastBurstReqTs = t;
607
648
 
608
649
  const pk = getPageKey();
609
- state.pageKey = pk;
650
+ state.pageKey = pk;
610
651
  state.burstDeadline = t + 1800;
611
652
 
612
653
  if (state.burstActive) return;
@@ -614,10 +655,10 @@
614
655
  state.burstCount = 0;
615
656
 
616
657
  const step = () => {
617
- if (getPageKey() !== pk) { state.burstActive = false; return; }
618
- if (isBlocked()) { state.burstActive = false; return; }
619
- if (now() > state.burstDeadline) { state.burstActive = false; return; }
620
- if (state.burstCount >= 8) { state.burstActive = false; return; }
658
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
659
+ if (isBlocked()) { state.burstActive = false; return; }
660
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
661
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
621
662
 
622
663
  state.burstCount++;
623
664
  scheduleRun(0, (n) => {
@@ -629,26 +670,22 @@
629
670
  step();
630
671
  }
631
672
 
632
- // ─── Cleanup (ajaxify navigation) ─────────────────────────────────────────
673
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
633
674
  function cleanup() {
634
- // Bloquer toute injection pendant la transition
635
675
  blockedUntil = now() + 1500;
636
676
 
637
- // Supprimer tous les wraps injectés → libère les IDs
638
677
  withInternalMutation(() => {
639
678
  document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
640
679
  });
641
680
 
642
- // Réinitialiser l'état complet
643
- state.cfg = null;
644
- state.pools = { topics: [], posts: [], categories: [] };
645
- state.cursors = { topics: 0, posts: 0, categories: 0 };
681
+ state.cfg = null;
682
+ state.pools = { topics: [], posts: [], categories: [] };
683
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
646
684
  state.mountedIds.clear();
647
685
  state.lastShowById.clear();
648
- state.inflight = 0;
649
- state.pending = [];
686
+ state.inflight = 0;
687
+ state.pending = [];
650
688
  state.pendingSet.clear();
651
-
652
689
  state.burstActive = false;
653
690
  state.runQueued = false;
654
691
  }
@@ -659,11 +696,10 @@
659
696
  if (!m.addedNodes?.length) continue;
660
697
  for (const n of m.addedNodes) {
661
698
  if (n.nodeType !== 1) continue;
662
- if (
663
- n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
664
- n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
665
- n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem)
666
- ) return true;
699
+ if (n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
700
+ n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
701
+ n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem))
702
+ return true;
667
703
  }
668
704
  }
669
705
  return false;
@@ -680,7 +716,7 @@
680
716
  try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
681
717
  }
682
718
 
683
- // ─── Utilities: console mute + TCF + network warm ─────────────────────────
719
+ // ─── Utilities ────────────────────────────────────────────────────────────
684
720
  function muteNoisyConsole() {
685
721
  if (window.__nodebbEzoicConsoleMuted) return;
686
722
  window.__nodebbEzoicConsoleMuted = true;
@@ -704,9 +740,8 @@
704
740
  try {
705
741
  if (!window.__tcfapi && !window.__cmp) return;
706
742
  if (document.getElementById('__tcfapiLocator')) return;
707
- const f = Object.assign(document.createElement('iframe'), {
708
- style: 'display:none', id: '__tcfapiLocator', name: '__tcfapiLocator',
709
- });
743
+ const f = document.createElement('iframe');
744
+ f.style.display = 'none'; f.id = '__tcfapiLocator'; f.name = '__tcfapiLocator';
710
745
  (document.body || document.documentElement).appendChild(f);
711
746
  } catch (_) {}
712
747
  }
@@ -716,12 +751,12 @@
716
751
  const head = document.head;
717
752
  if (!head) return;
718
753
  const links = [
719
- ['preconnect', 'https://g.ezoic.net', true],
720
- ['preconnect', 'https://go.ezoic.net', true],
721
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
722
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
723
- ['dns-prefetch', 'https://g.ezoic.net', false],
724
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
754
+ ['preconnect', 'https://g.ezoic.net', true],
755
+ ['preconnect', 'https://go.ezoic.net', true],
756
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
757
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
758
+ ['dns-prefetch', 'https://g.ezoic.net', false],
759
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
725
760
  ];
726
761
  for (const [rel, href, cors] of links) {
727
762
  const key = `${rel}|${href}`;
@@ -741,9 +776,7 @@
741
776
 
742
777
  $(window).off('.ezoicInfinite');
743
778
 
744
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
745
- cleanup();
746
- });
779
+ $(window).on('action:ajaxify.start.ezoicInfinite', cleanup);
747
780
 
748
781
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
749
782
  state.pageKey = getPageKey();
@@ -768,7 +801,6 @@
768
801
 
769
802
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
770
803
 
771
- // Hooks AMD (NodeBB 4.x)
772
804
  try {
773
805
  require(['hooks'], hooks => {
774
806
  if (typeof hooks?.on !== 'function') return;
@@ -786,20 +818,17 @@
786
818
  function bindScroll() {
787
819
  let ticking = false;
788
820
  window.addEventListener('scroll', () => {
789
- // Scroll boost
790
821
  try {
791
- const t = now();
792
- const y = window.scrollY || window.pageYOffset || 0;
822
+ const t = now(), y = window.scrollY || window.pageYOffset || 0;
793
823
  if (state.lastScrollTs) {
794
824
  const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
795
825
  if (speed >= BOOST_SPEED_PX_PER_MS) {
796
- const wasBoosted = isBoosted();
826
+ const was = isBoosted();
797
827
  state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
798
- if (!wasBoosted) ensurePreloadObserver();
828
+ if (!was) ensurePreloadObserver();
799
829
  }
800
830
  }
801
- state.lastScrollY = y;
802
- state.lastScrollTs = t;
831
+ state.lastScrollY = y; state.lastScrollTs = t;
803
832
  } catch (_) {}
804
833
 
805
834
  if (ticking) return;