nodebb-plugin-ezoic-infinite 1.7.28 → 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 +191 -122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.28",
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 v33
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,13 +287,19 @@
262
287
  return null;
263
288
  }
264
289
 
265
- function recycleDistantWrap(klass) {
266
- // Quand le pool est épuisé : récupère le wrap le plus loin au-dessus du
267
- // viewport (qu'il soit orphelin ou non) pour libérer son ID.
268
- // Les topics/pubs à 4+ viewports au-dessus ne sont plus visibles on peut
269
- // recycler leur slot. Non-remplis en priorité, remplis si rien d'autre.
270
- const vh = window.innerHeight || 800;
271
- const threshold = -vh * 4;
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
+ */
300
+ function moveDistantWrap(klass, targetEl, newKey) {
301
+ const vh = window.innerHeight || 800;
302
+ const threshold = -vh * 4; // 4 viewports au-dessus = hors vue assurée
272
303
 
273
304
  let bestEmpty = null, bestEmptyBottom = Infinity;
274
305
  let bestFilled = null, bestFilledBottom = Infinity;
@@ -287,20 +318,44 @@
287
318
 
288
319
  const best = bestEmpty ?? bestFilled;
289
320
  if (!best) return null;
321
+
290
322
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
291
323
  if (!Number.isFinite(id)) return null;
292
- mutate(() => dropWrap(best));
293
- return id;
324
+
325
+ const oldKey = best.getAttribute(A_ANCHOR);
326
+ mutate(() => {
327
+ best.setAttribute(A_ANCHOR, newKey);
328
+ best.setAttribute(A_CREATED, String(ts()));
329
+ best.setAttribute(A_SHOWN, '0');
330
+ best.classList.remove('is-empty');
331
+ targetEl.insertAdjacentElement('afterend', best);
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 (_) {}
346
+
347
+ return { id, wrap: best };
294
348
  }
295
349
 
296
- // ── Wraps DOM ──────────────────────────────────────────────────────────────
350
+ // ── Wraps DOM — création / suppression ────────────────────────────────────
297
351
 
298
352
  function makeWrap(id, klass, key) {
299
- const w = document.createElement('div');
353
+ const w = document.createElement('div');
300
354
  w.className = `${WRAP_CLASS} ${klass}`;
301
355
  w.setAttribute(A_ANCHOR, key);
302
356
  w.setAttribute(A_WRAPID, String(id));
303
357
  w.setAttribute(A_CREATED, String(ts()));
358
+ w.setAttribute(A_SHOWN, '0');
304
359
  w.style.cssText = 'width:100%;display:block;';
305
360
  const ph = document.createElement('div');
306
361
  ph.id = `${PH_PREFIX}${id}`;
@@ -310,13 +365,14 @@
310
365
  }
311
366
 
312
367
  function insertAfter(el, id, klass, key) {
313
- if (!el?.insertAdjacentElement) return null;
314
- if (findWrap(key)) return null;
315
- if (S.mountedIds.has(id)) return null;
316
- 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;
317
372
  const w = makeWrap(id, klass, key);
318
373
  mutate(() => el.insertAdjacentElement('afterend', w));
319
374
  S.mountedIds.add(id);
375
+ S.wrapByKey.set(key, w);
320
376
  return w;
321
377
  }
322
378
 
@@ -326,57 +382,49 @@
326
382
  if (ph instanceof Element) S.io?.unobserve(ph);
327
383
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
328
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);
329
387
  w.remove();
330
388
  } catch (_) {}
331
389
  }
332
390
 
333
391
  // ── Prune (topics de catégorie uniquement) ────────────────────────────────
334
392
  //
335
- // pruneOrphans est réactivé UNIQUEMENT pour 'ezoic-ad-between'.
336
- //
337
- // Pourquoi safe pour les topics ?
338
- // NodeBB ne virtualise PAS la liste des topics dans une catégorie.
339
- // Les <li component="category/topic"> restent dans le DOM pendant toute
340
- // la session. Leurs ancres (data-tid) sont donc stables — un wrap orphelin
341
- // signifie vraiment que le topic a été retiré (navigation, filtre, etc.).
393
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
342
394
  //
343
- // Pourquoi désactivé pour les posts ?
344
- // NodeBB virtualise les posts hors-viewport : il retire les <li> du DOM
345
- // puis les réinsère au scroll retour. pruneOrphans verait des ancres
346
- // 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.
347
400
  //
348
- // MIN_PRUNE_AGE_MS : délai de grâce après création (stabilisation du DOM).
349
-
350
- 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.
351
405
 
352
406
  function pruneOrphansBetween() {
353
407
  const klass = 'ezoic-ad-between';
354
408
  const cfg = KIND[klass];
355
409
 
356
410
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
357
- // Délai de grâce : ne pas pruner un wrap trop récent
358
411
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
359
- if (ts() - created < MIN_PRUNE_AGE_MS) return;
412
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
360
413
 
361
414
  const key = w.getAttribute(A_ANCHOR) ?? '';
362
- 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:"
363
416
  if (!sid) { mutate(() => dropWrap(w)); return; }
364
417
 
365
- // Chercher l'ancre par data-tid
366
418
  const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
367
- if (!anchorEl || !anchorEl.isConnected) {
368
- mutate(() => dropWrap(w));
369
- }
419
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
370
420
  });
371
421
  }
372
422
 
373
-
374
423
  // ── Injection ──────────────────────────────────────────────────────────────
375
424
 
376
425
  /**
377
- * Ordinal 0-based pour le calcul de l'intervalle.
378
- * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
379
- * 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.
380
428
  */
381
429
  function ordinal(klass, el) {
382
430
  const attr = KIND[klass]?.ordinalAttr;
@@ -408,11 +456,18 @@
408
456
  const key = anchorKey(klass, el);
409
457
  if (findWrap(key)) continue;
410
458
 
411
- let id = pickId(poolKey);
412
- if (!id) { id = recycleDistantWrap(klass); if (!id) continue; }
413
-
414
- const w = insertAfter(el, id, klass, key);
415
- if (w) { observePh(id); inserted++; }
459
+ const id = pickId(poolKey);
460
+ if (id) {
461
+ // Pool disponible : créer un nouveau wrap
462
+ const w = insertAfter(el, id, klass, key);
463
+ if (w) { observePh(id); inserted++; }
464
+ } else {
465
+ // Pool épuisé : déplacer un wrap distant (préserve le nœud placeholder)
466
+ const moved = moveDistantWrap(klass, el, key);
467
+ if (!moved) continue;
468
+ observePh(moved.id);
469
+ inserted++;
470
+ }
416
471
  }
417
472
  return inserted;
418
473
  }
@@ -507,6 +562,10 @@
507
562
  }
508
563
 
509
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
510
569
 
511
570
  function patchShowAds() {
512
571
  const apply = () => {
@@ -553,21 +612,22 @@
553
612
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
554
613
  if (!normBool(cfgEnable)) return 0;
555
614
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
556
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
557
- return n;
615
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
558
616
  };
559
617
 
560
618
  if (kind === 'topic') return exec(
561
619
  'ezoic-ad-message', getPosts,
562
620
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
563
621
  );
622
+
564
623
  if (kind === 'categoryTopics') {
565
- pruneOrphansBetween(); // nettoie les wraps dont le topic a disparu du DOM
624
+ pruneOrphansBetween();
566
625
  return exec(
567
626
  'ezoic-ad-between', getTopics,
568
627
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
569
628
  );
570
629
  }
630
+
571
631
  return exec(
572
632
  'ezoic-ad-categories', getCategories,
573
633
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
@@ -619,10 +679,12 @@
619
679
  blockedUntil = ts() + 1500;
620
680
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
621
681
  S.cfg = null;
682
+ S.poolsReady = false;
622
683
  S.pools = { topics: [], posts: [], categories: [] };
623
684
  S.cursors = { topics: 0, posts: 0, categories: 0 };
624
685
  S.mountedIds.clear();
625
686
  S.lastShow.clear();
687
+ S.wrapByKey.clear();
626
688
  S.inflight = 0;
627
689
  S.pending = [];
628
690
  S.pendingSet.clear();
@@ -640,7 +702,9 @@
640
702
  for (const m of muts) {
641
703
  for (const n of m.addedNodes) {
642
704
  if (n.nodeType !== 1) continue;
643
- 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;} })) {
644
708
  requestBurst(); return;
645
709
  }
646
710
  }
@@ -654,7 +718,12 @@
654
718
  function muteConsole() {
655
719
  if (window.__nbbEzMuted) return;
656
720
  window.__nbbEzMuted = true;
657
- 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
+ ];
658
727
  for (const m of ['log', 'info', 'warn', 'error']) {
659
728
  const orig = console[m];
660
729
  if (typeof orig !== 'function') continue;