nodebb-plugin-ezoic-infinite 1.7.29 → 1.7.30

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 +172 -121
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.30",
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,58 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v34
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
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
+ * v36 Optimisations chemin critique (scroll injectBetween) :
39
+ * S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
40
+ * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
41
+ * moveDistantWrap, dropWrap et cleanup.
42
+ * wrapIsLive allégé : pour les voisins immédiats on vérifie les
43
+ * attributs du nœud lui-même sans querySelector global.
44
+ * – MutationObserver : matches() vérifié avant querySelector() pour
45
+ * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
45
46
  *
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.
47
+ * v35 Revue complète prod-ready :
48
+ * initPools protégé contre ré-initialisation inutile (S.poolsReady).
49
+ * muteConsole élargit à "No valid placeholders for loadMore".
50
+ * moveDistantWrap appelle ezstandalone.refresh([id]) après déplacement
51
+ * pour forcer Ezoic à réactiver le placeholder sur son nouveau nœud.
52
+ * A_SHOWN initialisé à '0' dans moveDistantWrap (évite NaN dans parseInt).
53
+ * Commentaires et historique nettoyés.
64
54
  */
65
- (function () {
55
+ (function nbbEzoicInfinite() {
66
56
  'use strict';
67
57
 
68
58
  // ── Constantes ─────────────────────────────────────────────────────────────
@@ -74,13 +64,14 @@
74
64
  const A_CREATED = 'data-ezoic-created'; // timestamp création ms
75
65
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
76
66
 
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;
67
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
68
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
69
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
70
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
71
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
72
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
82
73
 
83
- // IO : marges larges fixes — une seule instance, jamais recréée
74
+ // Marges IO larges et fixes — observer créé une seule fois au boot
84
75
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
85
76
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
86
77
 
@@ -93,13 +84,12 @@
93
84
  /**
94
85
  * Table KIND — source de vérité par kindClass.
95
86
  *
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)
87
+ * sel sélecteur CSS complet des éléments cibles
88
+ * baseTag préfixe tag pour querySelector d'ancre
89
+ * (vide pour posts : le sélecteur commence par '[')
90
+ * anchorAttr attribut DOM stable → clé unique du wrap
91
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
92
+ * null fallback positionnel (catégories)
103
93
  */
104
94
  const KIND = {
105
95
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -107,21 +97,23 @@
107
97
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
108
98
  };
109
99
 
110
- // ── État ───────────────────────────────────────────────────────────────────
100
+ // ── État global ────────────────────────────────────────────────────────────
111
101
 
112
102
  const S = {
113
103
  pageKey: null,
114
104
  cfg: null,
105
+ poolsReady: false,
115
106
  pools: { topics: [], posts: [], categories: [] },
116
107
  cursors: { topics: 0, posts: 0, categories: 0 },
117
108
  mountedIds: new Set(),
118
109
  lastShow: new Map(),
119
110
  io: null,
120
111
  domObs: null,
121
- mutGuard: 0,
122
- inflight: 0,
123
- pending: [],
112
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
113
+ inflight: 0, // showAds() en cours
114
+ pending: [], // ids en attente de slot inflight
124
115
  pendingSet: new Set(),
116
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
125
117
  runQueued: false,
126
118
  burstActive: false,
127
119
  burstDeadline: 0,
@@ -130,11 +122,12 @@
130
122
  };
131
123
 
132
124
  let blockedUntil = 0;
125
+
133
126
  const ts = () => Date.now();
134
127
  const isBlocked = () => ts() < blockedUntil;
135
128
  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]'));
129
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
130
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
138
131
 
139
132
  function mutate(fn) {
140
133
  S.mutGuard++;
@@ -162,9 +155,11 @@
162
155
  }
163
156
 
164
157
  function initPools(cfg) {
158
+ if (S.poolsReady) return;
165
159
  S.pools.topics = parseIds(cfg.placeholderIds);
166
160
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
167
161
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
162
+ S.poolsReady = true;
168
163
  }
169
164
 
170
165
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -204,15 +199,39 @@
204
199
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
205
200
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
206
201
 
202
+ // ── Wraps — détection ──────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
206
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
207
+ */
207
208
  function wrapIsLive(wrap) {
208
209
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
209
210
  const key = wrap.getAttribute(A_ANCHOR);
210
211
  if (!key) return false;
212
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
213
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
214
+ if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
215
+ // Fallback : wrap déplacé (moveDistantWrap) ou registre pas encore à jour.
211
216
  const colonIdx = key.indexOf(':');
212
- const klass = key.slice(0, colonIdx);
217
+ const klass = key.slice(0, colonIdx);
213
218
  const anchorId = key.slice(colonIdx + 1);
214
219
  const cfg = KIND[klass];
215
220
  if (!cfg) return false;
221
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
222
+ // de querySelector global — on cherche parmi les voisins immédiats.
223
+ const parent = wrap.parentElement;
224
+ if (parent) {
225
+ for (const sib of parent.children) {
226
+ if (sib === wrap) continue;
227
+ try {
228
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
229
+ return sib.isConnected;
230
+ }
231
+ } catch (_) {}
232
+ }
233
+ }
234
+ // Dernier recours : querySelector global
216
235
  try {
217
236
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
218
237
  return !!(found?.isConnected);
@@ -225,6 +244,10 @@
225
244
 
226
245
  // ── Ancres stables ─────────────────────────────────────────────────────────
227
246
 
247
+ /**
248
+ * Retourne la valeur de l'attribut stable pour cet élément,
249
+ * ou un fallback positionnel si l'attribut est absent.
250
+ */
228
251
  function stableId(klass, el) {
229
252
  const attr = KIND[klass]?.anchorAttr;
230
253
  if (attr) {
@@ -242,17 +265,19 @@
242
265
  const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
243
266
 
244
267
  function findWrap(key) {
245
- try {
246
- return document.querySelector(
247
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
248
- );
249
- } catch (_) { return null; }
268
+ const w = S.wrapByKey.get(key);
269
+ return (w?.isConnected) ? w : null;
250
270
  }
251
271
 
252
272
  // ── Pool ───────────────────────────────────────────────────────────────────
253
273
 
274
+ /**
275
+ * Retourne le prochain id disponible dans le pool (round-robin),
276
+ * ou null si tous les ids sont montés.
277
+ */
254
278
  function pickId(poolKey) {
255
279
  const pool = S.pools[poolKey];
280
+ if (!pool.length) return null;
256
281
  for (let t = 0; t < pool.length; t++) {
257
282
  const i = S.cursors[poolKey] % pool.length;
258
283
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -262,15 +287,19 @@
262
287
  return null;
263
288
  }
264
289
 
290
+ /**
291
+ * Pool épuisé : déplace physiquement le wrap le plus loin au-dessus du
292
+ * viewport vers targetEl. On NE supprime PAS le placeholder — Ezoic garde
293
+ * une registry interne, et recréer un div avec le même id déclenche
294
+ * "already been defined" puis bloque showAds(). En déplaçant le même nœud,
295
+ * la registry reste valide. On appelle ensuite ezstandalone.refresh([id])
296
+ * pour forcer Ezoic à réactiver la pub sur sa nouvelle position.
297
+ *
298
+ * Priorité : wraps non remplis (pas de pub visible perdue), puis remplis.
299
+ */
265
300
  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;
301
+ const vh = window.innerHeight || 800;
302
+ const threshold = -vh * 4; // 4 viewports au-dessus = hors vue assurée
274
303
 
275
304
  let bestEmpty = null, bestEmptyBottom = Infinity;
276
305
  let bestFilled = null, bestFilledBottom = Infinity;
@@ -293,26 +322,40 @@
293
322
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
294
323
  if (!Number.isFinite(id)) return null;
295
324
 
296
- // Déplacer le wrap après targetEl (même nœud DOM, pas de recréation)
325
+ const oldKey = best.getAttribute(A_ANCHOR);
297
326
  mutate(() => {
298
- best.setAttribute(A_ANCHOR, newKey);
327
+ best.setAttribute(A_ANCHOR, newKey);
299
328
  best.setAttribute(A_CREATED, String(ts()));
300
- best.setAttribute(A_SHOWN, '');
329
+ best.setAttribute(A_SHOWN, '0');
301
330
  best.classList.remove('is-empty');
302
331
  targetEl.insertAdjacentElement('afterend', best);
303
332
  });
333
+ // Mettre à jour le registre : ancienne clé → nouvelle clé
334
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
335
+ S.wrapByKey.set(newKey, best);
336
+
337
+ // Signaler à Ezoic que ce placeholder a bougé dans le DOM
338
+ try {
339
+ const ez = window.ezstandalone;
340
+ if (typeof ez?.refresh === 'function') {
341
+ (Array.isArray(ez.cmd) ? p => ez.cmd.push(p) : p => p())(() => {
342
+ try { ez.refresh([id]); } catch (_) {}
343
+ });
344
+ }
345
+ } catch (_) {}
304
346
 
305
347
  return { id, wrap: best };
306
348
  }
307
349
 
308
- // ── Wraps DOM ──────────────────────────────────────────────────────────────
350
+ // ── Wraps DOM — création / suppression ────────────────────────────────────
309
351
 
310
352
  function makeWrap(id, klass, key) {
311
- const w = document.createElement('div');
353
+ const w = document.createElement('div');
312
354
  w.className = `${WRAP_CLASS} ${klass}`;
313
355
  w.setAttribute(A_ANCHOR, key);
314
356
  w.setAttribute(A_WRAPID, String(id));
315
357
  w.setAttribute(A_CREATED, String(ts()));
358
+ w.setAttribute(A_SHOWN, '0');
316
359
  w.style.cssText = 'width:100%;display:block;';
317
360
  const ph = document.createElement('div');
318
361
  ph.id = `${PH_PREFIX}${id}`;
@@ -322,13 +365,14 @@
322
365
  }
323
366
 
324
367
  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;
368
+ if (!el?.insertAdjacentElement) return null;
369
+ if (findWrap(key)) return null;
370
+ if (S.mountedIds.has(id)) return null;
371
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
329
372
  const w = makeWrap(id, klass, key);
330
373
  mutate(() => el.insertAdjacentElement('afterend', w));
331
374
  S.mountedIds.add(id);
375
+ S.wrapByKey.set(key, w);
332
376
  return w;
333
377
  }
334
378
 
@@ -338,57 +382,49 @@
338
382
  if (ph instanceof Element) S.io?.unobserve(ph);
339
383
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
340
384
  if (Number.isFinite(id)) S.mountedIds.delete(id);
385
+ const key = w.getAttribute(A_ANCHOR);
386
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
341
387
  w.remove();
342
388
  } catch (_) {}
343
389
  }
344
390
 
345
391
  // ── Prune (topics de catégorie uniquement) ────────────────────────────────
346
392
  //
347
- // pruneOrphans est réactivé UNIQUEMENT pour 'ezoic-ad-between'.
348
- //
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.).
393
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
354
394
  //
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.
395
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
396
+ // les li[component="category/topic"] restent dans le DOM pendant toute
397
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
398
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
399
+ // liste après un long scroll et bloquent les nouvelles injections.
359
400
  //
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;
401
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
402
+ // NodeBB virtualise les posts hors-viewport — il les retire puis les
403
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
404
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
363
405
 
364
406
  function pruneOrphansBetween() {
365
407
  const klass = 'ezoic-ad-between';
366
408
  const cfg = KIND[klass];
367
409
 
368
410
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
369
- // Délai de grâce : ne pas pruner un wrap trop récent
370
411
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
371
- if (ts() - created < MIN_PRUNE_AGE_MS) return;
412
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
372
413
 
373
414
  const key = w.getAttribute(A_ANCHOR) ?? '';
374
- const sid = key.slice(klass.length + 1); // partie après "ezoic-ad-between:"
415
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
375
416
  if (!sid) { mutate(() => dropWrap(w)); return; }
376
417
 
377
- // Chercher l'ancre par data-tid
378
418
  const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
379
- if (!anchorEl || !anchorEl.isConnected) {
380
- mutate(() => dropWrap(w));
381
- }
419
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
382
420
  });
383
421
  }
384
422
 
385
-
386
423
  // ── Injection ──────────────────────────────────────────────────────────────
387
424
 
388
425
  /**
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.
426
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
427
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
392
428
  */
393
429
  function ordinal(klass, el) {
394
430
  const attr = KIND[klass]?.ordinalAttr;
@@ -420,12 +456,13 @@
420
456
  const key = anchorKey(klass, el);
421
457
  if (findWrap(key)) continue;
422
458
 
423
- let id = pickId(poolKey);
459
+ const id = pickId(poolKey);
424
460
  if (id) {
461
+ // Pool disponible : créer un nouveau wrap
425
462
  const w = insertAfter(el, id, klass, key);
426
463
  if (w) { observePh(id); inserted++; }
427
464
  } else {
428
- // Pool épuisé : déplacer un wrap distant (ne recrée PAS le placeholder)
465
+ // Pool épuisé : déplacer un wrap distant (préserve le nœud placeholder)
429
466
  const moved = moveDistantWrap(klass, el, key);
430
467
  if (!moved) continue;
431
468
  observePh(moved.id);
@@ -525,6 +562,10 @@
525
562
  }
526
563
 
527
564
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
565
+ //
566
+ // Intercepte ez.showAds() pour :
567
+ // – ignorer les appels pendant blockedUntil
568
+ // – filtrer les ids dont le placeholder n'est pas en DOM
528
569
 
529
570
  function patchShowAds() {
530
571
  const apply = () => {
@@ -571,21 +612,22 @@
571
612
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
572
613
  if (!normBool(cfgEnable)) return 0;
573
614
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
574
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
575
- return n;
615
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
576
616
  };
577
617
 
578
618
  if (kind === 'topic') return exec(
579
619
  'ezoic-ad-message', getPosts,
580
620
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
581
621
  );
622
+
582
623
  if (kind === 'categoryTopics') {
583
- pruneOrphansBetween(); // nettoie les wraps dont le topic a disparu du DOM
624
+ pruneOrphansBetween();
584
625
  return exec(
585
626
  'ezoic-ad-between', getTopics,
586
627
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
587
628
  );
588
629
  }
630
+
589
631
  return exec(
590
632
  'ezoic-ad-categories', getCategories,
591
633
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
@@ -637,10 +679,12 @@
637
679
  blockedUntil = ts() + 1500;
638
680
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
639
681
  S.cfg = null;
682
+ S.poolsReady = false;
640
683
  S.pools = { topics: [], posts: [], categories: [] };
641
684
  S.cursors = { topics: 0, posts: 0, categories: 0 };
642
685
  S.mountedIds.clear();
643
686
  S.lastShow.clear();
687
+ S.wrapByKey.clear();
644
688
  S.inflight = 0;
645
689
  S.pending = [];
646
690
  S.pendingSet.clear();
@@ -658,7 +702,9 @@
658
702
  for (const m of muts) {
659
703
  for (const n of m.addedNodes) {
660
704
  if (n.nodeType !== 1) continue;
661
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
705
+ // matches() d'abord (O(1)), querySelector() seulement si nécessaire
706
+ if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
707
+ allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
662
708
  requestBurst(); return;
663
709
  }
664
710
  }
@@ -672,7 +718,12 @@
672
718
  function muteConsole() {
673
719
  if (window.__nbbEzMuted) return;
674
720
  window.__nbbEzMuted = true;
675
- const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
721
+ const MUTED = [
722
+ '[EzoicAds JS]: Placeholder Id',
723
+ 'No valid placeholders for loadMore',
724
+ 'Debugger iframe already exists',
725
+ `with id ${PH_PREFIX}`,
726
+ ];
676
727
  for (const m of ['log', 'info', 'warn', 'error']) {
677
728
  const orig = console[m];
678
729
  if (typeof orig !== 'function') continue;