nodebb-plugin-ezoic-infinite 1.7.29 → 1.7.32

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 +229 -127
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.29",
3
+ "version": "1.7.32",
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,68 +1,62 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v34
2
+ * NodeBB Ezoic Infinite Ads — client.js v37
3
3
  *
4
4
  * Historique des corrections majeures
5
5
  * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
- * Suppression du recyclage de wraps. Cleanup complet navigation.
6
+ * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
8
7
  *
9
- * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
8
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
10
9
  * la position dans le batch courant.
11
10
  *
12
- * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
11
+ * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
13
12
  * 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.
13
+ * IO fixe (une instance, jamais recréée).
17
14
  * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
18
15
  *
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).
16
+ * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
24
17
  *
25
- * v26 Suppression définitive du recyclage d'id.
26
- * KIND simplifié.
18
+ * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
27
19
  *
28
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB).
20
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
29
21
  *
30
- * v28 decluster supprimé. pruneOrphans supprimé (v27). Wraps persistants sur session.
22
+ * v28 decluster supprimé. Wraps persistants pendant la session.
31
23
  *
32
- * v29 Fix ancrage topics : data-index data-tid.
24
+ * v32 Retour anchorAttr = data-index pour ezoic-ad-between.
25
+ * data-tid peut être absent → clés invalides → wraps empilés.
26
+ * pruneOrphansBetween réactivé uniquement pour topics de catégorie :
27
+ * – NodeBB NE virtualise PAS les topics dans une liste de catégorie,
28
+ * les ancres (data-index) restent en DOM → prune safe et nécessaire
29
+ * pour éviter l'empilement après scroll long.
30
+ * – Toujours désactivé pour les posts : NodeBB virtualise les posts
31
+ * hors-viewport → faux-orphelins → bug réinjection en haut.
33
32
  *
34
- * v32 Retour anchorAttr = data-index pour ezoic-ad-between (comme v20.1).
35
- * data-tid peut être absent sur li[component="category/topic"] clés
36
- * invalides wraps empilés sur pos0. data-index est toujours présent.
37
- * pruneOrphansBetween cherche li[data-index="N"] cohérent avec v20.1.
33
+ * v34 moveDistantWrap : déplace le nœud wrap existant au lieu de le supprimer
34
+ * et recréer. Ezoic garde une registry interne des placeholders — recréer
35
+ * un div avec le même id déclenche "already been defined" et bloque
36
+ * showAds(). En déplaçant le même nœud DOM, la registry reste valide.
38
37
  *
39
- * v31 pruneOrphans réactivé UNIQUEMENT pour ezoic-ad-between (topics de catégorie).
40
- * NodeBB NE virtualise PAS les topics dans une liste de catégorie — les ancres
41
- * restent dans le DOM entre les scrolls. pruneOrphans est donc safe ici et
42
- * c'est lui qui empêche l'empilement des pubs en haut après un scroll long.
43
- * Pour ezoic-ad-message (posts de topic), pruneOrphans reste désactivé car
44
- * NodeBB virtualise les posts hors-viewport → faux-orphelins → bug réinjection.
38
+ * v37 Fix wraps vides persistants : ez.refresh() sur les placeholders
39
+ * en DOM, connectés, non remplis et visibles (dans les marges IO).
40
+ * Appelé dans runCore() après injection, une fois par burst max.
45
41
  *
46
- * v30 Fix adjacentWrap : ne compte plus les wraps orphelins (ancre hors DOM).
47
- * Quand NodeBB virtualise et retire des topics du DOM, les wraps restent
48
- * en place (div dans le ul). adjacentWrap(el) retournait true sur ces
49
- * wraps orphelins injection bloquée sur les topics suivants.
50
- * Fix : adjacentWrap vérifie que le wrap voisin a son ancre dans le DOM.
51
- * recycleOrphanId() : quand le pool est épuisé, recycle les wraps orphelins
52
- * non remplis qui sont loin au-dessus du viewport.
53
- * data-index = position relative dans le batch NodeBB, pas un ID stable.
54
- * Lors du scroll infini, les nouveaux batches démarrent à data-index=0,
55
- * ce qui créait des collisions de clés d'ancrage → mauvaise déduplication
56
- * wraps non injectés sur les nouveaux topics, puis réinjection en haut.
57
- * Fix : anchorAttr = data-tid (stable et unique par topic).
58
- * ordinalAttr reste data-index pour le calcul de l'intervalle.
59
- * Analyse : decluster appelait dropWrap() qui faisait S.mountedIds.delete(id).
60
- * Un wrap vide adjacent à un autre wrap supprimé id libéré → réinjecté
61
- * en haut au prochain scroll. Exactement le bug observé.
62
- * Les deux mécanismes de nettoyage actif (pruneOrphans + decluster) sont
63
- * maintenant retirés. Seul cleanup() à la navigation peut supprimer des wraps.
42
+ * v36 Optimisations chemin critique (scroll injectBetween) :
43
+ * S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
44
+ * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
45
+ * moveDistantWrap, dropWrap et cleanup.
46
+ * wrapIsLive allégé : pour les voisins immédiats on vérifie les
47
+ * attributs du nœud lui-même sans querySelector global.
48
+ * MutationObserver : matches() vérifié avant querySelector() pour
49
+ * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
50
+ *
51
+ * v35 Revue complète prod-ready :
52
+ * initPools protégé contre ré-initialisation inutile (S.poolsReady).
53
+ * muteConsole élargit à "No valid placeholders for loadMore".
54
+ * moveDistantWrap appelle ezstandalone.refresh([id]) après déplacement
55
+ * pour forcer Ezoic à réactiver le placeholder sur son nouveau nœud.
56
+ * A_SHOWN initialisé à '0' dans moveDistantWrap (évite NaN dans parseInt).
57
+ * Commentaires et historique nettoyés.
64
58
  */
65
- (function () {
59
+ (function nbbEzoicInfinite() {
66
60
  'use strict';
67
61
 
68
62
  // ── Constantes ─────────────────────────────────────────────────────────────
@@ -74,13 +68,14 @@
74
68
  const A_CREATED = 'data-ezoic-created'; // timestamp création ms
75
69
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
76
70
 
77
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
78
- const MAX_INSERTS_RUN = 6;
79
- const MAX_INFLIGHT = 4;
80
- const SHOW_THROTTLE_MS = 900;
81
- const BURST_COOLDOWN_MS = 200;
71
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
72
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
73
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
74
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
75
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
76
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
82
77
 
83
- // IO : marges larges fixes — une seule instance, jamais recréée
78
+ // Marges IO larges et fixes — observer créé une seule fois au boot
84
79
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
85
80
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
86
81
 
@@ -93,13 +88,12 @@
93
88
  /**
94
89
  * Table KIND — source de vérité par kindClass.
95
90
  *
96
- * sel : sélecteur CSS complet
97
- * baseTag : préfixe tag pour querySelector d'ancre
98
- * (vide pour posts car sélecteur commence par '[')
99
- * anchorAttr : attribut DOM stable → clé unique du wrap
100
- * data-pid posts / data-index topics / data-cid catégories
101
- * ordinalAttr: attribut 0-based pour calcul de l'intervalle
102
- * null → fallback positionnel (catégories)
91
+ * sel sélecteur CSS complet des éléments cibles
92
+ * baseTag préfixe tag pour querySelector d'ancre
93
+ * (vide pour posts : le sélecteur commence par '[')
94
+ * anchorAttr attribut DOM stable → clé unique du wrap
95
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
96
+ * null fallback positionnel (catégories)
103
97
  */
104
98
  const KIND = {
105
99
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -107,21 +101,23 @@
107
101
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
108
102
  };
109
103
 
110
- // ── État ───────────────────────────────────────────────────────────────────
104
+ // ── État global ────────────────────────────────────────────────────────────
111
105
 
112
106
  const S = {
113
107
  pageKey: null,
114
108
  cfg: null,
109
+ poolsReady: false,
115
110
  pools: { topics: [], posts: [], categories: [] },
116
111
  cursors: { topics: 0, posts: 0, categories: 0 },
117
112
  mountedIds: new Set(),
118
113
  lastShow: new Map(),
119
114
  io: null,
120
115
  domObs: null,
121
- mutGuard: 0,
122
- inflight: 0,
123
- pending: [],
116
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
117
+ inflight: 0, // showAds() en cours
118
+ pending: [], // ids en attente de slot inflight
124
119
  pendingSet: new Set(),
120
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
125
121
  runQueued: false,
126
122
  burstActive: false,
127
123
  burstDeadline: 0,
@@ -130,11 +126,12 @@
130
126
  };
131
127
 
132
128
  let blockedUntil = 0;
129
+
133
130
  const ts = () => Date.now();
134
131
  const isBlocked = () => ts() < blockedUntil;
135
132
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
136
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
137
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
133
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
134
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
138
135
 
139
136
  function mutate(fn) {
140
137
  S.mutGuard++;
@@ -162,9 +159,11 @@
162
159
  }
163
160
 
164
161
  function initPools(cfg) {
162
+ if (S.poolsReady) return;
165
163
  S.pools.topics = parseIds(cfg.placeholderIds);
166
164
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
167
165
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
166
+ S.poolsReady = true;
168
167
  }
169
168
 
170
169
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -204,15 +203,39 @@
204
203
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
205
204
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
206
205
 
206
+ // ── Wraps — détection ──────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
210
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
211
+ */
207
212
  function wrapIsLive(wrap) {
208
213
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
209
214
  const key = wrap.getAttribute(A_ANCHOR);
210
215
  if (!key) return false;
216
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
217
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
218
+ if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
219
+ // Fallback : wrap déplacé (moveDistantWrap) ou registre pas encore à jour.
211
220
  const colonIdx = key.indexOf(':');
212
- const klass = key.slice(0, colonIdx);
221
+ const klass = key.slice(0, colonIdx);
213
222
  const anchorId = key.slice(colonIdx + 1);
214
223
  const cfg = KIND[klass];
215
224
  if (!cfg) return false;
225
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
226
+ // de querySelector global — on cherche parmi les voisins immédiats.
227
+ const parent = wrap.parentElement;
228
+ if (parent) {
229
+ for (const sib of parent.children) {
230
+ if (sib === wrap) continue;
231
+ try {
232
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
233
+ return sib.isConnected;
234
+ }
235
+ } catch (_) {}
236
+ }
237
+ }
238
+ // Dernier recours : querySelector global
216
239
  try {
217
240
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
218
241
  return !!(found?.isConnected);
@@ -225,6 +248,10 @@
225
248
 
226
249
  // ── Ancres stables ─────────────────────────────────────────────────────────
227
250
 
251
+ /**
252
+ * Retourne la valeur de l'attribut stable pour cet élément,
253
+ * ou un fallback positionnel si l'attribut est absent.
254
+ */
228
255
  function stableId(klass, el) {
229
256
  const attr = KIND[klass]?.anchorAttr;
230
257
  if (attr) {
@@ -242,17 +269,19 @@
242
269
  const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
243
270
 
244
271
  function findWrap(key) {
245
- try {
246
- return document.querySelector(
247
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
248
- );
249
- } catch (_) { return null; }
272
+ const w = S.wrapByKey.get(key);
273
+ return (w?.isConnected) ? w : null;
250
274
  }
251
275
 
252
276
  // ── Pool ───────────────────────────────────────────────────────────────────
253
277
 
278
+ /**
279
+ * Retourne le prochain id disponible dans le pool (round-robin),
280
+ * ou null si tous les ids sont montés.
281
+ */
254
282
  function pickId(poolKey) {
255
283
  const pool = S.pools[poolKey];
284
+ if (!pool.length) return null;
256
285
  for (let t = 0; t < pool.length; t++) {
257
286
  const i = S.cursors[poolKey] % pool.length;
258
287
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -262,15 +291,19 @@
262
291
  return null;
263
292
  }
264
293
 
294
+ /**
295
+ * Pool épuisé : déplace physiquement le wrap le plus loin au-dessus du
296
+ * viewport vers targetEl. On NE supprime PAS le placeholder — Ezoic garde
297
+ * une registry interne, et recréer un div avec le même id déclenche
298
+ * "already been defined" puis bloque showAds(). En déplaçant le même nœud,
299
+ * la registry reste valide. On appelle ensuite ezstandalone.refresh([id])
300
+ * pour forcer Ezoic à réactiver la pub sur sa nouvelle position.
301
+ *
302
+ * Priorité : wraps non remplis (pas de pub visible perdue), puis remplis.
303
+ */
265
304
  function moveDistantWrap(klass, targetEl, newKey) {
266
- // Quand le pool est épuisé : déplace un wrap loin au-dessus du viewport
267
- // vers la nouvelle position cible. On NE supprime PAS le div placeholder —
268
- // Ezoic garde sa registry interne et "already been defined" bloque showAds()
269
- // si on recrée le même ID. En déplaçant le wrap, le placeholder DOM reste
270
- // le même nœud → Ezoic ne se plaint pas.
271
- // Priorité : wraps vides d'abord (non remplis = pas de pub perdue).
272
- const vh = window.innerHeight || 800;
273
- const threshold = -vh * 4;
305
+ const vh = window.innerHeight || 800;
306
+ const threshold = -vh * 4; // 4 viewports au-dessus = hors vue assurée
274
307
 
275
308
  let bestEmpty = null, bestEmptyBottom = Infinity;
276
309
  let bestFilled = null, bestFilledBottom = Infinity;
@@ -293,26 +326,40 @@
293
326
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
294
327
  if (!Number.isFinite(id)) return null;
295
328
 
296
- // Déplacer le wrap après targetEl (même nœud DOM, pas de recréation)
329
+ const oldKey = best.getAttribute(A_ANCHOR);
297
330
  mutate(() => {
298
- best.setAttribute(A_ANCHOR, newKey);
331
+ best.setAttribute(A_ANCHOR, newKey);
299
332
  best.setAttribute(A_CREATED, String(ts()));
300
- best.setAttribute(A_SHOWN, '');
333
+ best.setAttribute(A_SHOWN, '0');
301
334
  best.classList.remove('is-empty');
302
335
  targetEl.insertAdjacentElement('afterend', best);
303
336
  });
337
+ // Mettre à jour le registre : ancienne clé → nouvelle clé
338
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
339
+ S.wrapByKey.set(newKey, best);
340
+
341
+ // Signaler à Ezoic que ce placeholder a bougé dans le DOM
342
+ try {
343
+ const ez = window.ezstandalone;
344
+ if (typeof ez?.refresh === 'function') {
345
+ (Array.isArray(ez.cmd) ? p => ez.cmd.push(p) : p => p())(() => {
346
+ try { ez.refresh([id]); } catch (_) {}
347
+ });
348
+ }
349
+ } catch (_) {}
304
350
 
305
351
  return { id, wrap: best };
306
352
  }
307
353
 
308
- // ── Wraps DOM ──────────────────────────────────────────────────────────────
354
+ // ── Wraps DOM — création / suppression ────────────────────────────────────
309
355
 
310
356
  function makeWrap(id, klass, key) {
311
- const w = document.createElement('div');
357
+ const w = document.createElement('div');
312
358
  w.className = `${WRAP_CLASS} ${klass}`;
313
359
  w.setAttribute(A_ANCHOR, key);
314
360
  w.setAttribute(A_WRAPID, String(id));
315
361
  w.setAttribute(A_CREATED, String(ts()));
362
+ w.setAttribute(A_SHOWN, '0');
316
363
  w.style.cssText = 'width:100%;display:block;';
317
364
  const ph = document.createElement('div');
318
365
  ph.id = `${PH_PREFIX}${id}`;
@@ -322,13 +369,14 @@
322
369
  }
323
370
 
324
371
  function insertAfter(el, id, klass, key) {
325
- if (!el?.insertAdjacentElement) return null;
326
- if (findWrap(key)) return null;
327
- if (S.mountedIds.has(id)) return null;
328
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
372
+ if (!el?.insertAdjacentElement) return null;
373
+ if (findWrap(key)) return null;
374
+ if (S.mountedIds.has(id)) return null;
375
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
329
376
  const w = makeWrap(id, klass, key);
330
377
  mutate(() => el.insertAdjacentElement('afterend', w));
331
378
  S.mountedIds.add(id);
379
+ S.wrapByKey.set(key, w);
332
380
  return w;
333
381
  }
334
382
 
@@ -338,57 +386,49 @@
338
386
  if (ph instanceof Element) S.io?.unobserve(ph);
339
387
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
340
388
  if (Number.isFinite(id)) S.mountedIds.delete(id);
389
+ const key = w.getAttribute(A_ANCHOR);
390
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
341
391
  w.remove();
342
392
  } catch (_) {}
343
393
  }
344
394
 
345
395
  // ── Prune (topics de catégorie uniquement) ────────────────────────────────
346
396
  //
347
- // pruneOrphans est réactivé UNIQUEMENT pour 'ezoic-ad-between'.
397
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
348
398
  //
349
- // Pourquoi safe pour les topics ?
350
- // NodeBB ne virtualise PAS la liste des topics dans une catégorie.
351
- // Les <li component="category/topic"> restent dans le DOM pendant toute
352
- // la session. Leurs ancres (data-tid) sont donc stables un wrap orphelin
353
- // signifie vraiment que le topic a été retiré (navigation, filtre, etc.).
399
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
400
+ // les li[component="category/topic"] restent dans le DOM pendant toute
401
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
402
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
403
+ // liste après un long scroll et bloquent les nouvelles injections.
354
404
  //
355
- // Pourquoi désactivé pour les posts ?
356
- // NodeBB virtualise les posts hors-viewport : il retire les <li> du DOM
357
- // puis les réinsère au scroll retour. pruneOrphans verait des ancres
358
- // absentes → supprimerait les wraps réinjection en haut au scroll retour.
359
- //
360
- // MIN_PRUNE_AGE_MS : délai de grâce après création (stabilisation du DOM).
361
-
362
- const MIN_PRUNE_AGE_MS = 8_000;
405
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
406
+ // NodeBB virtualise les posts hors-viewport il les retire puis les
407
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
408
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
363
409
 
364
410
  function pruneOrphansBetween() {
365
411
  const klass = 'ezoic-ad-between';
366
412
  const cfg = KIND[klass];
367
413
 
368
414
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
369
- // Délai de grâce : ne pas pruner un wrap trop récent
370
415
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
371
- if (ts() - created < MIN_PRUNE_AGE_MS) return;
416
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
372
417
 
373
418
  const key = w.getAttribute(A_ANCHOR) ?? '';
374
- const sid = key.slice(klass.length + 1); // partie après "ezoic-ad-between:"
419
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
375
420
  if (!sid) { mutate(() => dropWrap(w)); return; }
376
421
 
377
- // Chercher l'ancre par data-tid
378
422
  const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
379
- if (!anchorEl || !anchorEl.isConnected) {
380
- mutate(() => dropWrap(w));
381
- }
423
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
382
424
  });
383
425
  }
384
426
 
385
-
386
427
  // ── Injection ──────────────────────────────────────────────────────────────
387
428
 
388
429
  /**
389
- * Ordinal 0-based pour le calcul de l'intervalle.
390
- * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
391
- * Catégories : ordinalAttr = null → fallback positionnel.
430
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
431
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
392
432
  */
393
433
  function ordinal(klass, el) {
394
434
  const attr = KIND[klass]?.ordinalAttr;
@@ -420,12 +460,13 @@
420
460
  const key = anchorKey(klass, el);
421
461
  if (findWrap(key)) continue;
422
462
 
423
- let id = pickId(poolKey);
463
+ const id = pickId(poolKey);
424
464
  if (id) {
465
+ // Pool disponible : créer un nouveau wrap
425
466
  const w = insertAfter(el, id, klass, key);
426
467
  if (w) { observePh(id); inserted++; }
427
468
  } else {
428
- // Pool épuisé : déplacer un wrap distant (ne recrée PAS le placeholder)
469
+ // Pool épuisé : déplacer un wrap distant (préserve le nœud placeholder)
429
470
  const moved = moveDistantWrap(klass, el, key);
430
471
  if (!moved) continue;
431
472
  observePh(moved.id);
@@ -525,6 +566,10 @@
525
566
  }
526
567
 
527
568
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
569
+ //
570
+ // Intercepte ez.showAds() pour :
571
+ // – ignorer les appels pendant blockedUntil
572
+ // – filtrer les ids dont le placeholder n'est pas en DOM
528
573
 
529
574
  function patchShowAds() {
530
575
  const apply = () => {
@@ -571,25 +616,73 @@
571
616
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
572
617
  if (!normBool(cfgEnable)) return 0;
573
618
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
574
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
575
- return n;
619
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
576
620
  };
577
621
 
578
- if (kind === 'topic') return exec(
579
- 'ezoic-ad-message', getPosts,
580
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
581
- );
622
+ if (kind === 'topic') {
623
+ const n = exec(
624
+ 'ezoic-ad-message', getPosts,
625
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
626
+ );
627
+ refreshStaleWraps();
628
+ return n;
629
+ }
630
+
582
631
  if (kind === 'categoryTopics') {
583
- pruneOrphansBetween(); // nettoie les wraps dont le topic a disparu du DOM
584
- return exec(
632
+ pruneOrphansBetween();
633
+ const n = exec(
585
634
  'ezoic-ad-between', getTopics,
586
635
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
587
636
  );
637
+ refreshStaleWraps();
638
+ return n;
588
639
  }
589
- return exec(
640
+
641
+ const n2 = exec(
590
642
  'ezoic-ad-categories', getCategories,
591
643
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
592
644
  );
645
+ refreshStaleWraps();
646
+ return n2;
647
+ }
648
+
649
+ /**
650
+ * Appelle ez.refresh() sur les placeholders en DOM, non remplis,
651
+ * dans la zone visible (marges IO). Cela force Ezoic à re-servir
652
+ * une pub sur des slots qu'il avait refusé lors du showAds() initial.
653
+ * Limité à MAX_REFRESH_PER_RUN appels par invocation.
654
+ */
655
+ const MAX_REFRESH_PER_RUN = 3;
656
+
657
+ function refreshStaleWraps() {
658
+ const ez = window.ezstandalone;
659
+ if (typeof ez?.refresh !== 'function') return;
660
+
661
+ const vh = window.innerHeight || 800;
662
+ const margin = (isMobile() ? 3500 : 2500);
663
+ const top = -margin;
664
+ const bot = vh + margin;
665
+
666
+ const ids = [];
667
+ for (const [, wrap] of S.wrapByKey) {
668
+ if (ids.length >= MAX_REFRESH_PER_RUN) break;
669
+ if (!wrap.isConnected || isFilled(wrap)) continue;
670
+ // Vérifier que le wrap a déjà eu un showAds (A_SHOWN > 0)
671
+ const shown = parseInt(wrap.getAttribute(A_SHOWN) || '0', 10);
672
+ if (!shown) continue;
673
+ try {
674
+ const rect = wrap.getBoundingClientRect();
675
+ if (rect.top > bot || rect.bottom < top) continue;
676
+ const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
677
+ if (Number.isFinite(id) && id > 0) ids.push(id);
678
+ } catch (_) {}
679
+ }
680
+
681
+ if (!ids.length) return;
682
+ try {
683
+ const doRefresh = () => { try { ez.refresh(ids); } catch (_) {} };
684
+ Array.isArray(ez.cmd) ? ez.cmd.push(doRefresh) : doRefresh();
685
+ } catch (_) {}
593
686
  }
594
687
 
595
688
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -637,10 +730,12 @@
637
730
  blockedUntil = ts() + 1500;
638
731
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
639
732
  S.cfg = null;
733
+ S.poolsReady = false;
640
734
  S.pools = { topics: [], posts: [], categories: [] };
641
735
  S.cursors = { topics: 0, posts: 0, categories: 0 };
642
736
  S.mountedIds.clear();
643
737
  S.lastShow.clear();
738
+ S.wrapByKey.clear();
644
739
  S.inflight = 0;
645
740
  S.pending = [];
646
741
  S.pendingSet.clear();
@@ -658,7 +753,9 @@
658
753
  for (const m of muts) {
659
754
  for (const n of m.addedNodes) {
660
755
  if (n.nodeType !== 1) continue;
661
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
756
+ // matches() d'abord (O(1)), querySelector() seulement si nécessaire
757
+ if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
758
+ allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
662
759
  requestBurst(); return;
663
760
  }
664
761
  }
@@ -672,7 +769,12 @@
672
769
  function muteConsole() {
673
770
  if (window.__nbbEzMuted) return;
674
771
  window.__nbbEzMuted = true;
675
- const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
772
+ const MUTED = [
773
+ '[EzoicAds JS]: Placeholder Id',
774
+ 'No valid placeholders for loadMore',
775
+ 'Debugger iframe already exists',
776
+ `with id ${PH_PREFIX}`,
777
+ ];
676
778
  for (const m of ['log', 'info', 'warn', 'error']) {
677
779
  const orig = console[m];
678
780
  if (typeof orig !== 'function') continue;