nodebb-plugin-ezoic-infinite 1.8.13 → 1.8.15

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.
package/public/client.js CHANGED
@@ -1,96 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js (v20)
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
4
+ * Correctifs critiques vs v19
5
+ * ───────────────────────────
6
+ * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
+ * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
+ * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
+ * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
+ * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
+ * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
7
12
  *
8
- * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
- * la position dans le batch courant.
13
+ * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
+ * Fix : on skip uniquement le wrap courant, pas toute la boucle.
10
15
  *
11
- * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
12
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
13
- * IO fixe (une instance, jamais recréée).
14
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
16
+ * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
+ * existants sur les items suivants. Fix : `continue` au lieu de `break`.
15
18
  *
16
- * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
19
+ * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
+ * Fix : marge large fixe par device, observer créé une seule fois.
17
21
  *
18
- * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
22
+ * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
+ * Fix : 200ms.
19
24
  *
20
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
21
- *
22
- * v28 decluster supprimé. Wraps persistants pendant la session.
23
- *
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.
32
- *
33
- * v34 moveDistantWrap — voir v38.
34
- *
35
- * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
- * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
- * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
38
- *
39
- * v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
40
- *
41
- * v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
42
- *
43
- * v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
44
- *
45
- * v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
46
- * Séquence : destroy → 300ms → define → 300ms → displayMore.
47
- * Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
48
- *
49
- * v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
50
- * sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
51
- * déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
52
- * break propre dans injectBetween. muteConsole : ajout warnings refresh.
53
- *
54
- * v36 Optimisations chemin critique (scroll → injectBetween) :
55
- * – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
56
- * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
57
- * dropWrap et cleanup.
58
- * – wrapIsLive allégé : pour les voisins immédiats on vérifie les
59
- * attributs du nœud lui-même sans querySelector global.
60
- * – MutationObserver : matches() vérifié avant querySelector() pour
61
- * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
62
- *
63
- * v35 Revue complète prod-ready :
64
- * – initPools protégé contre ré-initialisation inutile (S.poolsReady).
65
- * – muteConsole élargit à "No valid placeholders for loadMore".
66
- * – Commentaires et historique nettoyés.
25
+ * Nettoyage
26
+ * ─────────
27
+ * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
+ * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
+ * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
+ * - Commentaires internes allégés (code auto-documenté)
67
31
  */
68
- (function nbbEzoicInfinite() {
32
+ (function () {
69
33
  'use strict';
70
34
 
71
35
  // ── Constantes ─────────────────────────────────────────────────────────────
72
36
 
73
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
74
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
75
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
76
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
77
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
-
80
- // Tunables (stables en prod)
81
- const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
- const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
83
- const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
84
- const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
85
- const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
86
- const SHOW_RELEASE_MS = 300; // relâche inflight après showAds() batché
87
- const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
88
- const BATCH_FLUSH_MS = 30; // micro-buffer pour regrouper les ids proches
89
- const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
90
- const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
91
- const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
92
-
93
- // Marges IO larges et fixes — observer créé une seule fois au boot
37
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
+ const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
+
44
+ const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
+ const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
+ const MAX_INSERTS_PER_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
94
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
95
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
96
55
 
@@ -101,49 +60,42 @@
101
60
  };
102
61
 
103
62
  /**
104
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
105
64
  *
106
- * sel sélecteur CSS complet des éléments cibles
107
- * baseTag préfixe tag pour querySelector d'ancre
108
- * (vide pour posts : le sélecteur commence par '[')
109
- * anchorAttr attribut DOM stable clé unique du wrap
110
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle
111
- * null fallback positionnel (catégories)
65
+ * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
+ * quelle que soit la page ou la virtualisation :
67
+ * posts data-pid (id du message, unique et permanent)
68
+ * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
+ * catégories data-cid (id de la catégorie, unique et permanent)
70
+ * C'était le bug v19 : on cherchait data-index ici
112
71
  */
113
72
  const KIND = {
114
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
115
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
116
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
73
+ 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
117
76
  };
118
77
 
119
- // ── État global ────────────────────────────────────────────────────────────
78
+ // ── État ───────────────────────────────────────────────────────────────────
120
79
 
121
80
  const S = {
122
- pageKey: null,
123
- cfg: null,
124
- poolsReady: false,
125
- pools: { topics: [], posts: [], categories: [] },
126
- cursors: { topics: 0, posts: 0, categories: 0 },
127
- mountedIds: new Set(),
128
- lastShow: new Map(),
129
- io: null,
130
- domObs: null,
131
- mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
132
- inflight: 0, // showAds() en cours
133
- pending: [], // ids en attente de slot inflight
134
- pendingSet: new Set(),
135
- showBatchTimer: 0,
136
- destroyBatchTimer: 0,
137
- destroyPending: [],
138
- destroyPendingSet: new Set(),
139
- sweepQueued: false,
140
- wrapByKey: new Map(), // anchorKey → wrap DOM node
141
- ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
142
- ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
143
- scrollDir: 1, // 1=bas, -1=haut
144
- scrollSpeed: 0, // px/s approx (EMA)
145
- lastScrollY: 0,
146
- lastScrollTs: 0,
81
+ pageKey: null,
82
+ cfg: null,
83
+ poolSig: null,
84
+
85
+ pools: { topics: [], posts: [], categories: [] },
86
+ cursors: { topics: 0, posts: 0, categories: 0 },
87
+ mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
88
+ lastShow: new Map(), // id → timestamp dernier show
89
+
90
+ io: null,
91
+ ioMargin: null,
92
+ domObs: null,
93
+ mutGuard: 0, // compteur internalMutation
94
+
95
+ inflight: 0,
96
+ pending: [],
97
+ pendingSet: new Set(),
98
+
147
99
  runQueued: false,
148
100
  burstActive: false,
149
101
  burstDeadline: 0,
@@ -152,127 +104,13 @@
152
104
  };
153
105
 
154
106
  let blockedUntil = 0;
155
-
156
- const ts = () => Date.now();
157
- const isBlocked = () => ts() < blockedUntil;
158
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
159
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
160
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
161
-
162
- function healFalseEmpty(root = document) {
163
- try {
164
- const list = [];
165
- if (root instanceof Element && root.classList?.contains(WRAP_CLASS)) list.push(root);
166
- const found = root.querySelectorAll ? root.querySelectorAll(`.${WRAP_CLASS}.is-empty`) : [];
167
- for (const w of found) list.push(w);
168
- for (const w of list) {
169
- if (!w?.classList?.contains('is-empty')) continue;
170
- if (isFilled(w)) w.classList.remove('is-empty');
171
- }
172
- } catch (_) {}
173
- }
174
-
175
- function phEl(id) {
176
- return document.getElementById(`${PH_PREFIX}${id}`);
177
- }
178
-
179
- function hasSinglePlaceholder(id) {
180
- try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
181
- }
182
-
183
- function canShowPlaceholderId(id, now = ts()) {
184
- const n = parseInt(id, 10);
185
- if (!Number.isFinite(n) || n <= 0) return false;
186
- if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
187
- const ph = phEl(n);
188
- if (!ph?.isConnected || isFilled(ph)) return false;
189
- if (!hasSinglePlaceholder(n)) return false;
190
- return true;
191
- }
192
-
193
- function queueSweepDeadWraps() {
194
- if (S.sweepQueued) return;
195
- S.sweepQueued = true;
196
- requestAnimationFrame(() => {
197
- S.sweepQueued = false;
198
- sweepDeadWraps();
199
- healFalseEmpty();
200
- });
201
- }
202
-
203
- function getDynamicShowBatchMax() {
204
- const speed = S.scrollSpeed || 0;
205
- const pend = S.pending.length;
206
- // Scroll très rapide => petits batches (réduit le churn/unused)
207
- if (speed > 2600) return 2;
208
- if (speed > 1400) return 3;
209
- // Peu de candidats => flush plus vite, inutile d'attendre 4
210
- if (pend <= 1) return 1;
211
- if (pend <= 3) return 2;
212
- // Par défaut compromis dynamique
213
- return 3;
214
- }
107
+ const isBlocked = () => Date.now() < blockedUntil;
108
+ const ts = () => Date.now();
215
109
 
216
110
  function mutate(fn) {
217
111
  S.mutGuard++;
218
112
  try { fn(); } finally { S.mutGuard--; }
219
113
  }
220
- function scheduleDestroyFlush() {
221
- if (S.destroyBatchTimer) return;
222
- S.destroyBatchTimer = setTimeout(() => {
223
- S.destroyBatchTimer = 0;
224
- flushDestroyBatch();
225
- }, DESTROY_FLUSH_MS);
226
- }
227
-
228
- function flushDestroyBatch() {
229
- if (!S.destroyPending.length) return;
230
- const ids = [];
231
- while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
232
- const id = S.destroyPending.shift();
233
- S.destroyPendingSet.delete(id);
234
- if (!Number.isFinite(id) || id <= 0) continue;
235
- ids.push(id);
236
- }
237
- if (ids.length) {
238
- try {
239
- const ez = window.ezstandalone;
240
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
241
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
242
- } catch (_) {}
243
- }
244
- if (S.destroyPending.length) scheduleDestroyFlush();
245
- }
246
-
247
- function destroyEzoicId(id) {
248
- if (!Number.isFinite(id) || id <= 0) return;
249
- if (!S.ezActiveIds.has(id)) return;
250
- S.ezActiveIds.delete(id);
251
- if (!S.destroyPendingSet.has(id)) {
252
- S.destroyPending.push(id);
253
- S.destroyPendingSet.add(id);
254
- }
255
- scheduleDestroyFlush();
256
- }
257
-
258
- function destroyBeforeReuse(ids) {
259
- const out = [];
260
- const toDestroy = [];
261
- const seen = new Set();
262
- for (const raw of (ids || [])) {
263
- const id = parseInt(raw, 10);
264
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
265
- seen.add(id);
266
- out.push(id);
267
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
268
- }
269
- if (toDestroy.length) {
270
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
271
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
272
- }
273
- return out;
274
- }
275
-
276
114
 
277
115
  // ── Config ─────────────────────────────────────────────────────────────────
278
116
 
@@ -285,22 +123,44 @@ function destroyBeforeReuse(ids) {
285
123
  return S.cfg;
286
124
  }
287
125
 
126
+ function initPools(cfg) {
127
+ // (Perf) Ne reparse les pools que si les strings ont changé.
128
+ const sig = `${cfg.placeholderIds || ''}§${cfg.messagePlaceholderIds || ''}§${cfg.categoryPlaceholderIds || ''}`;
129
+ if (S.poolSig === sig) return;
130
+ S.poolSig = sig;
131
+
132
+ S.pools.topics = parseIds(cfg.placeholderIds);
133
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
134
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
135
+
136
+ // Réinitialise les curseurs si une pool change
137
+ S.cursors.topics = 0;
138
+ S.cursors.posts = 0;
139
+ S.cursors.categories = 0;
140
+ }
141
+
288
142
  function parseIds(raw) {
143
+ // Accepte : un ID par ligne, ou séparés par virgules/espaces (ACP le mentionne).
289
144
  const out = [], seen = new Set();
290
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
145
+ const parts = String(raw || '')
146
+ .replace(/\r/g, '\n')
147
+ .split(/[\n\s,]+/)
148
+ .map(s => s.trim())
149
+ .filter(Boolean);
150
+
151
+ for (const v of parts) {
291
152
  const n = parseInt(v, 10);
292
153
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
293
154
  }
294
155
  return out;
295
156
  }
296
157
 
297
- function initPools(cfg) {
298
- if (S.poolsReady) return;
299
- S.pools.topics = parseIds(cfg.placeholderIds);
300
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
301
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
302
- S.poolsReady = true;
303
- }
158
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
159
+
160
+ const isFilled = (n) =>
161
+ !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
162
+
163
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
304
164
 
305
165
  // ── Page identity ──────────────────────────────────────────────────────────
306
166
 
@@ -324,13 +184,13 @@ function destroyBeforeReuse(ids) {
324
184
  return 'other';
325
185
  }
326
186
 
327
- // ── Items DOM ──────────────────────────────────────────────────────────────
187
+ // ── DOM helpers ────────────────────────────────────────────────────────────
328
188
 
329
189
  function getPosts() {
330
190
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
331
191
  if (!el.isConnected) return false;
332
192
  if (!el.querySelector('[component="post/content"]')) return false;
333
- const p = el.parentElement?.closest(SEL.post);
193
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
334
194
  if (p && p !== el) return false;
335
195
  return el.getAttribute('component') !== 'post/parent';
336
196
  });
@@ -339,87 +199,53 @@ function destroyBeforeReuse(ids) {
339
199
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
340
200
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
341
201
 
342
- // ── Wraps — détection ──────────────────────────────────────────────────────
343
-
344
- /**
345
- * Vérifie qu'un wrap a encore son ancre dans le DOM.
346
- * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
347
- */
348
- function wrapIsLive(wrap) {
349
- if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
350
- const key = wrap.getAttribute(A_ANCHOR);
351
- if (!key) return false;
352
- // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
353
- // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
354
- if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
355
- // Fallback : registre pas encore à jour ou wrap non enregistré.
356
- const colonIdx = key.indexOf(':');
357
- const klass = key.slice(0, colonIdx);
358
- const anchorId = key.slice(colonIdx + 1);
359
- const cfg = KIND[klass];
360
- if (!cfg) return false;
361
- // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
362
- // de querySelector global — on cherche parmi les voisins immédiats.
363
- const parent = wrap.parentElement;
364
- if (parent) {
365
- for (const sib of parent.children) {
366
- if (sib === wrap) continue;
367
- try {
368
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
369
- return sib.isConnected;
370
- }
371
- } catch (_) {}
372
- }
373
- }
374
- // Dernier recours : querySelector global
375
- try {
376
- const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
377
- return !!(found?.isConnected);
378
- } catch (_) { return false; }
379
- }
380
-
381
202
  function adjacentWrap(el) {
382
- return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
203
+ return !!(
204
+ el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
205
+ el.previousElementSibling?.classList?.contains(WRAP_CLASS)
206
+ );
383
207
  }
384
208
 
385
- // ── Ancres stables ─────────────────────────────────────────────────────────
209
+ // ── Ancres stables ────────────────────────────────────────────────────────
386
210
 
387
211
  /**
388
- * Retourne la valeur de l'attribut stable pour cet élément,
389
- * ou un fallback positionnel si l'attribut est absent.
212
+ * Retourne l'identifiant stable de l'élément selon son kindClass.
213
+ * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
214
+ * Fallback positionnel si l'attribut est absent.
390
215
  */
391
- function stableId(klass, el) {
392
- const attr = KIND[klass]?.anchorAttr;
216
+ function stableId(kindClass, el) {
217
+ const attr = KIND[kindClass]?.anchorAttr;
393
218
  if (attr) {
394
219
  const v = el.getAttribute(attr);
395
220
  if (v !== null && v !== '') return v;
396
221
  }
397
- let i = 0;
398
- for (const s of el.parentElement?.children ?? []) {
399
- if (s === el) return `i${i}`;
400
- i++;
401
- }
222
+ // Fallback : position dans le parent
223
+ try {
224
+ let i = 0;
225
+ for (const s of el.parentElement?.children ?? []) {
226
+ if (s === el) return `i${i}`;
227
+ i++;
228
+ }
229
+ } catch (_) {}
402
230
  return 'i0';
403
231
  }
404
232
 
405
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
233
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
406
234
 
407
- function findWrap(key) {
408
- const w = S.wrapByKey.get(key);
409
- return (w?.isConnected) ? w : null;
235
+ function findWrap(anchorKey) {
236
+ try {
237
+ return document.querySelector(
238
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
239
+ );
240
+ } catch (_) { return null; }
410
241
  }
411
242
 
412
243
  // ── Pool ───────────────────────────────────────────────────────────────────
413
244
 
414
- /**
415
- * Retourne le prochain id disponible dans le pool (round-robin),
416
- * ou null si tous les ids sont montés.
417
- */
418
245
  function pickId(poolKey) {
419
246
  const pool = S.pools[poolKey];
420
- if (!pool.length) return null;
421
247
  for (let t = 0; t < pool.length; t++) {
422
- const i = S.cursors[poolKey] % pool.length;
248
+ const i = S.cursors[poolKey] % pool.length;
423
249
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
424
250
  const id = pool[i];
425
251
  if (!S.mountedIds.has(id)) return id;
@@ -427,112 +253,7 @@ function destroyBeforeReuse(ids) {
427
253
  return null;
428
254
  }
429
255
 
430
- function sweepDeadWraps() {
431
- // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
432
- // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
433
- for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
434
- if (wrap?.isConnected) continue;
435
- S.wrapByKey.delete(key);
436
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
437
- if (Number.isFinite(id)) {
438
- S.mountedIds.delete(id);
439
- S.pendingSet.delete(id);
440
- S.lastShow.delete(id);
441
- S.ezActiveIds.delete(id);
442
- }
443
- }
444
- if (S.pending.length) {
445
- S.pending = S.pending.filter(id => S.pendingSet.has(id));
446
- }
447
- }
448
-
449
- /**
450
- * Pool épuisé : recycle un wrap loin au-dessus du viewport.
451
- * Séquence avec délais (destroyPlaceholders est asynchrone) :
452
- * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
453
- * displayMore = API Ezoic prévue pour l'infinite scroll.
454
- * Priorité : wraps vides d'abord, remplis si nécessaire.
455
- */
456
- function recycleAndMove(klass, targetEl, newKey) {
457
- const ez = window.ezstandalone;
458
- if (typeof ez?.destroyPlaceholders !== 'function' ||
459
- typeof ez?.define !== 'function' ||
460
- typeof ez?.displayMore !== 'function') return null;
461
-
462
- const vh = window.innerHeight || 800;
463
- const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
464
- const farAbove = -vh;
465
- const farBelow = vh * 2;
466
-
467
- let bestPrefEmpty = null, bestPrefMetric = Infinity;
468
- let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
469
- let bestAnyEmpty = null, bestAnyMetric = Infinity;
470
- let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
471
-
472
- for (const wrap of S.wrapByKey.values()) {
473
- if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
474
- try {
475
- const rect = wrap.getBoundingClientRect();
476
- const isAbove = rect.bottom <= farAbove;
477
- const isBelow = rect.top >= farBelow;
478
- const anyFar = isAbove || isBelow;
479
- if (!anyFar) continue;
480
-
481
- const qualifies = preferAbove ? isAbove : isBelow;
482
- const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
483
- const filled = isFilled(wrap);
484
-
485
- if (qualifies) {
486
- if (!filled) {
487
- if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
488
- } else {
489
- if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
490
- }
491
- }
492
- if (!filled) {
493
- if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
494
- } else {
495
- if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
496
- }
497
- } catch (_) {}
498
- }
499
-
500
- const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
501
- if (!best) return null;
502
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
503
- if (!Number.isFinite(id)) return null;
504
-
505
- const oldKey = best.getAttribute(A_ANCHOR);
506
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
507
- mutate(() => {
508
- best.setAttribute(A_ANCHOR, newKey);
509
- best.setAttribute(A_CREATED, String(ts()));
510
- best.setAttribute(A_SHOWN, '0');
511
- best.classList.remove('is-empty');
512
- const ph = best.querySelector(`#${PH_PREFIX}${id}`);
513
- if (ph) ph.innerHTML = '';
514
- targetEl.insertAdjacentElement('afterend', best);
515
- });
516
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
517
- S.wrapByKey.set(newKey, best);
518
-
519
- const doDestroy = () => {
520
- if (S.ezShownSinceDestroy.has(id)) {
521
- try { ez.destroyPlaceholders([id]); } catch (_) {}
522
- S.ezShownSinceDestroy.delete(id);
523
- }
524
- S.ezActiveIds.delete(id);
525
- setTimeout(doDefine, 330);
526
- };
527
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
528
- const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
529
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
530
-
531
- return { id, wrap: best };
532
- }
533
-
534
-
535
- // ── Wraps DOM — création / suppression ────────────────────────────────────
256
+ // ── Wraps DOM ──────────────────────────────────────────────────────────────
536
257
 
537
258
  function makeWrap(id, klass, key) {
538
259
  const w = document.createElement('div');
@@ -540,7 +261,6 @@ function recycleAndMove(klass, targetEl, newKey) {
540
261
  w.setAttribute(A_ANCHOR, key);
541
262
  w.setAttribute(A_WRAPID, String(id));
542
263
  w.setAttribute(A_CREATED, String(ts()));
543
- w.setAttribute(A_SHOWN, '0');
544
264
  w.style.cssText = 'width:100%;display:block;';
545
265
  const ph = document.createElement('div');
546
266
  ph.id = `${PH_PREFIX}${id}`;
@@ -550,79 +270,115 @@ function recycleAndMove(klass, targetEl, newKey) {
550
270
  }
551
271
 
552
272
  function insertAfter(el, id, klass, key) {
553
- if (!el?.insertAdjacentElement) return null;
554
- if (findWrap(key)) return null;
555
- if (S.mountedIds.has(id)) return null;
273
+ if (!el?.insertAdjacentElement) return null;
274
+ if (findWrap(key)) return null; // ancre déjà présente
275
+ if (S.mountedIds.has(id)) return null; // id déjà monté
556
276
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
557
277
  const w = makeWrap(id, klass, key);
558
278
  mutate(() => el.insertAdjacentElement('afterend', w));
559
279
  S.mountedIds.add(id);
560
- S.wrapByKey.set(key, w);
561
280
  return w;
562
281
  }
563
282
 
564
283
  function dropWrap(w) {
565
284
  try {
566
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
567
- if (ph instanceof Element) S.io?.unobserve(ph);
568
285
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
569
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
570
- const key = w.getAttribute(A_ANCHOR);
571
- if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
286
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
287
+ // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
288
+ // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
289
+ // "parameter 1 is not of type Element" sur le prochain observe).
290
+ try {
291
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
292
+ if (ph instanceof Element) S.io?.unobserve(ph);
293
+ } catch (_) {}
572
294
  w.remove();
573
295
  } catch (_) {}
574
296
  }
575
297
 
576
- // ── Prune (topics de catégorie uniquement) ────────────────────────────────
577
- //
578
- // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
579
- //
580
- // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
581
- // les li[component="category/topic"] restent dans le DOM pendant toute
582
- // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
583
- // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
584
- // liste après un long scroll et bloquent les nouvelles injections.
585
- //
586
- // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
587
- // NodeBB virtualise les posts hors-viewport il les retire puis les
588
- // réinsère. pruneOrphans verrait des ancres temporairement absentes,
589
- // supprimerait les wraps, et provoquerait une réinjection en haut.
590
-
591
- function pruneOrphansBetween() {
592
- const klass = 'ezoic-ad-between';
593
- const cfg = KIND[klass];
298
+ // ── Prune ──────────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
302
+ *
303
+ * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
304
+ * Exemples :
305
+ * ezoic-ad-message → cherche [data-pid="123"]
306
+ * ezoic-ad-between → cherche [data-index="5"]
307
+ * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
308
+ *
309
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
310
+ */
311
+ function pruneOrphans(klass) {
312
+ const meta = KIND[klass];
313
+ if (!meta) return;
314
+
315
+ // (Perf) Construire 1 set d'ancres présentes, au lieu de querySelector par wrap.
316
+ const present = new Set();
317
+ try {
318
+ document.querySelectorAll(meta.sel).forEach(el => {
319
+ const v = el.getAttribute(meta.anchorAttr);
320
+ if (v !== null && v !== '') present.add(String(v));
321
+ });
322
+ } catch (_) {}
594
323
 
595
324
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
596
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
597
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
325
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
598
326
 
599
327
  const key = w.getAttribute(A_ANCHOR) ?? '';
600
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
328
+ const sid = key.slice(klass.length + 1); // après "kindClass:"
601
329
  if (!sid) { mutate(() => dropWrap(w)); return; }
602
330
 
603
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
604
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
331
+ if (!present.has(String(sid))) mutate(() => dropWrap(w));
605
332
  });
606
333
  }
334
+ // ── Decluster ──────────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
338
+ * Priorité : filled > en grâce (fill en cours) > vide.
339
+ * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
340
+ */
341
+ function decluster(klass) {
342
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
343
+ // Grace sur le wrap courant : on le saute entièrement
344
+ const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
345
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
346
+
347
+ let prev = w.previousElementSibling, steps = 0;
348
+ while (prev && steps++ < 3) {
349
+ if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
350
+
351
+ const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
352
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
353
+
354
+ if (!isFilled(w)) mutate(() => dropWrap(w));
355
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
356
+ break;
357
+ }
358
+ }
359
+ }
607
360
 
608
361
  // ── Injection ──────────────────────────────────────────────────────────────
609
362
 
610
363
  /**
611
- * Ordinal 0-based pour le calcul de l'intervalle d'injection.
612
- * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
364
+ * Ordinal 0-based pour le calcul de l'intervalle.
365
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
366
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
613
367
  */
614
368
  function ordinal(klass, el) {
615
- const attr = KIND[klass]?.ordinalAttr;
616
- if (attr) {
617
- const v = el.getAttribute(attr);
618
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
619
- }
620
- const fullSel = KIND[klass]?.sel ?? '';
621
- let i = 0;
622
- for (const s of el.parentElement?.children ?? []) {
623
- if (s === el) return i;
624
- if (!fullSel || s.matches?.(fullSel)) i++;
625
- }
369
+ const di = el.getAttribute('data-index');
370
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
371
+ // Fallback positionnel
372
+ try {
373
+ const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
374
+ if (tag) {
375
+ let i = 0;
376
+ for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
377
+ if (n === el) return i;
378
+ i++;
379
+ }
380
+ }
381
+ } catch (_) {}
626
382
  return 0;
627
383
  }
628
384
 
@@ -631,26 +387,23 @@ function recycleAndMove(klass, targetEl, newKey) {
631
387
  let inserted = 0;
632
388
 
633
389
  for (const el of items) {
634
- if (inserted >= MAX_INSERTS_RUN) break;
390
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
635
391
  if (!el?.isConnected) continue;
636
392
 
637
- const ord = ordinal(klass, el);
638
- if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
393
+ const ord = ordinal(klass, el);
394
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
395
+ if (!isTarget) continue;
396
+
639
397
  if (adjacentWrap(el)) continue;
640
398
 
641
- const key = anchorKey(klass, el);
642
- if (findWrap(key)) continue;
643
-
644
- let id = pickId(poolKey);
645
- if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
646
- if (id) {
647
- const w = insertAfter(el, id, klass, key);
648
- if (w) { observePh(id); inserted++; }
649
- } else {
650
- const recycled = recycleAndMove(klass, el, key);
651
- if (!recycled) break;
652
- inserted++;
653
- }
399
+ const key = makeAnchorKey(klass, el);
400
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
401
+
402
+ const id = pickId(poolKey);
403
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants besoin d'id)
404
+
405
+ const w = insertAfter(el, id, klass, key);
406
+ if (w) { observePh(id); inserted++; }
654
407
  }
655
408
  return inserted;
656
409
  }
@@ -658,7 +411,14 @@ function recycleAndMove(klass, targetEl, newKey) {
658
411
  // ── IntersectionObserver & Show ────────────────────────────────────────────
659
412
 
660
413
  function getIO() {
661
- if (S.io) return S.io;
414
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
415
+ if (S.io && S.ioMargin === margin) return S.io;
416
+ // Si la marge doit changer (resize/orientation), on recrée l'observer.
417
+ if (S.io && S.ioMargin !== margin) {
418
+ try { S.io.disconnect(); } catch (_) {}
419
+ S.io = null;
420
+ }
421
+ S.ioMargin = margin;
662
422
  try {
663
423
  S.io = new IntersectionObserver(entries => {
664
424
  for (const e of entries) {
@@ -667,119 +427,86 @@ function recycleAndMove(klass, targetEl, newKey) {
667
427
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
668
428
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
669
429
  }
670
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
430
+ }, { root: null, rootMargin: margin, threshold: 0 });
671
431
  } catch (_) { S.io = null; }
672
432
  return S.io;
673
433
  }
674
434
 
675
435
  function observePh(id) {
676
- const ph = phEl(id);
436
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
677
437
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
678
- // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
679
- try {
680
- if (!ph?.isConnected) return;
681
- const rect = ph.getBoundingClientRect();
682
- const vh = window.innerHeight || 800;
683
- const preload = isMobile() ? 1400 : 1000;
684
- if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
685
- } catch (_) {}
686
438
  }
687
439
 
688
- function enqueueShow(id) {
689
- if (!id || isBlocked()) return;
690
- const n = parseInt(id, 10);
691
- if (!Number.isFinite(n) || n <= 0) return;
692
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
693
- if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
694
- scheduleDrainQueue();
695
- }
696
-
697
- function scheduleDrainQueue() {
698
- if (isBlocked()) return;
699
- if (S.showBatchTimer) return;
700
- S.showBatchTimer = setTimeout(() => {
701
- S.showBatchTimer = 0;
702
- drainQueue();
703
- }, BATCH_FLUSH_MS);
704
- }
705
-
706
- function drainQueue() {
707
- if (isBlocked()) return;
708
- const free = Math.max(0, MAX_INFLIGHT - S.inflight);
709
- if (!free || !S.pending.length) return;
710
-
711
- const picked = [];
712
- const seen = new Set();
713
- const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
714
- while (S.pending.length && picked.length < batchCap) {
715
- const id = S.pending.shift();
716
- S.pendingSet.delete(id);
717
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
718
- if (!phEl(id)?.isConnected) continue;
719
- seen.add(id);
720
- picked.push(id);
440
+ function enqueueShow(id) {
441
+ if (!id || isBlocked()) return;
442
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
443
+ if (S.inflight >= MAX_INFLIGHT) {
444
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
445
+ return;
446
+ }
447
+ startShow(id);
721
448
  }
722
- if (picked.length) startShowBatch(picked);
723
- if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
724
- }
725
-
726
- function startShowBatch(ids) {
727
- if (!ids?.length || isBlocked()) return;
728
- const reserve = ids.length;
729
- S.inflight += reserve;
730
-
731
- let done = false;
732
- const release = () => {
733
- if (done) return;
734
- done = true;
735
- S.inflight = Math.max(0, S.inflight - reserve);
736
- drainQueue();
737
- };
738
- const timer = setTimeout(release, SHOW_FAILSAFE_MS);
739
449
 
740
- requestAnimationFrame(() => {
741
- try {
742
- if (isBlocked()) { clearTimeout(timer); return release(); }
450
+ function drainQueue() {
451
+ if (isBlocked()) return;
452
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
453
+ const id = S.pending.shift();
454
+ S.pendingSet.delete(id);
455
+ startShow(id);
456
+ }
457
+ }
743
458
 
744
- const valid = [];
745
- const t = ts();
459
+ function startShow(id) {
460
+ if (!id || isBlocked()) return;
461
+ S.inflight++;
462
+ let done = false;
463
+ const release = () => {
464
+ if (done) return;
465
+ done = true;
466
+ S.inflight = Math.max(0, S.inflight - 1);
467
+ drainQueue();
468
+ };
469
+ const timer = setTimeout(release, 7000);
746
470
 
747
- for (const raw of ids) {
748
- const id = parseInt(raw, 10);
749
- if (!Number.isFinite(id) || id <= 0) continue;
750
- const ph = phEl(id);
751
- if (!canShowPlaceholderId(id, t)) continue;
471
+ requestAnimationFrame(() => {
472
+ try {
473
+ if (isBlocked()) { clearTimeout(timer); return release(); }
474
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
475
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
752
476
 
477
+ const t = ts();
478
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
753
479
  S.lastShow.set(id, t);
754
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
755
- valid.push(id);
756
- }
757
480
 
758
- if (!valid.length) { clearTimeout(timer); return release(); }
481
+ // Horodater le show sur le wrap pour grace period + emptyCheck
482
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
759
483
 
760
- window.ezstandalone = window.ezstandalone || {};
761
- const ez = window.ezstandalone;
762
- const doShow = () => {
763
- const prepared = destroyBeforeReuse(valid);
764
- if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
765
- try { ez.showAds(...prepared); } catch (_) {}
766
- for (const id of prepared) {
767
- S.ezActiveIds.add(id);
768
- S.ezShownSinceDestroy.add(id);
769
- }
770
- setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
771
- };
772
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
773
- } catch (_) { clearTimeout(timer); release(); }
774
- });
775
- }
484
+ window.ezstandalone = window.ezstandalone || {};
485
+ const ez = window.ezstandalone;
486
+ const doShow = () => {
487
+ try { ez.showAds(id); } catch (_) {}
488
+ scheduleEmptyCheck(id, t);
489
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
490
+ };
491
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
492
+ } catch (_) { clearTimeout(timer); release(); }
493
+ });
494
+ }
776
495
 
496
+ function scheduleEmptyCheck(id, showTs) {
497
+ setTimeout(() => {
498
+ try {
499
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
500
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
501
+ if (!wrap || !ph?.isConnected) return;
502
+ // Un show plus récent → ne pas toucher
503
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
504
+ wrap.classList.toggle('is-empty', !isFilled(ph));
505
+ } catch (_) {}
506
+ }, EMPTY_CHECK_MS);
507
+ }
777
508
 
778
509
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
779
- //
780
- // Intercepte ez.showAds() pour :
781
- // – ignorer les appels pendant blockedUntil
782
- // – filtrer les ids dont le placeholder n'est pas en DOM
783
510
 
784
511
  function patchShowAds() {
785
512
  const apply = () => {
@@ -791,21 +518,14 @@ function startShowBatch(ids) {
791
518
  const orig = ez.showAds.bind(ez);
792
519
  ez.showAds = function (...args) {
793
520
  if (isBlocked()) return;
794
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
795
- const valid = [];
521
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
796
522
  const seen = new Set();
797
523
  for (const v of ids) {
798
524
  const id = parseInt(v, 10);
799
525
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
800
- if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
526
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
801
527
  seen.add(id);
802
- valid.push(id);
803
- }
804
- if (!valid.length) return;
805
- try { orig(...valid); } catch (_) {
806
- for (const id of valid) {
807
- try { orig(id); } catch (_) {}
808
- }
528
+ try { orig(id); } catch (_) {}
809
529
  }
810
530
  };
811
531
  } catch (_) {}
@@ -817,12 +537,11 @@ function startShowBatch(ids) {
817
537
  }
818
538
  }
819
539
 
820
- // ── Core ───────────────────────────────────────────────────────────────────
540
+ // ── Core run ───────────────────────────────────────────────────────────────
821
541
 
822
542
  async function runCore() {
823
543
  if (isBlocked()) return 0;
824
544
  patchShowAds();
825
- sweepDeadWraps();
826
545
 
827
546
  const cfg = await fetchConfig();
828
547
  if (!cfg || cfg.excluded) return 0;
@@ -833,30 +552,30 @@ function startShowBatch(ids) {
833
552
 
834
553
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
835
554
  if (!normBool(cfgEnable)) return 0;
555
+ const items = getItems();
836
556
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
837
- return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
557
+ pruneOrphans(klass);
558
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
559
+ if (n) decluster(klass);
560
+ return n;
838
561
  };
839
562
 
840
563
  if (kind === 'topic') return exec(
841
564
  'ezoic-ad-message', getPosts,
842
565
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
843
566
  );
844
-
845
- if (kind === 'categoryTopics') {
846
- pruneOrphansBetween();
847
- return exec(
848
- 'ezoic-ad-between', getTopics,
849
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
850
- );
851
- }
852
-
853
- return exec(
567
+ if (kind === 'categoryTopics') return exec(
568
+ 'ezoic-ad-between', getTopics,
569
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
570
+ );
571
+ if (kind === 'categories') return exec(
854
572
  'ezoic-ad-categories', getCategories,
855
573
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
856
574
  );
575
+ return 0;
857
576
  }
858
577
 
859
- // ── Scheduler ──────────────────────────────────────────────────────────────
578
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
860
579
 
861
580
  function scheduleRun(cb) {
862
581
  if (S.runQueued) return;
@@ -874,8 +593,10 @@ function startShowBatch(ids) {
874
593
  if (isBlocked()) return;
875
594
  const t = ts();
876
595
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
877
- S.lastBurstTs = t;
878
- S.pageKey = pageKey();
596
+ S.lastBurstTs = t;
597
+
598
+ const pk = pageKey();
599
+ S.pageKey = pk;
879
600
  S.burstDeadline = t + 2000;
880
601
 
881
602
  if (S.burstActive) return;
@@ -883,69 +604,49 @@ function startShowBatch(ids) {
883
604
  S.burstCount = 0;
884
605
 
885
606
  const step = () => {
886
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
607
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
887
608
  S.burstActive = false; return;
888
609
  }
889
610
  S.burstCount++;
890
611
  scheduleRun(n => {
891
612
  if (!n && !S.pending.length) { S.burstActive = false; return; }
892
- setTimeout(step, n > 0 ? 80 : 180);
613
+ setTimeout(step, n > 0 ? 150 : 300);
893
614
  });
894
615
  };
895
616
  step();
896
617
  }
897
618
 
898
- // ── Cleanup navigation ─────────────────────────────────────────────────────
619
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
899
620
 
900
621
  function cleanup() {
901
622
  blockedUntil = ts() + 1500;
902
623
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
903
624
  S.cfg = null;
904
- S.poolsReady = false;
625
+ S.poolSig = null;
905
626
  S.pools = { topics: [], posts: [], categories: [] };
906
627
  S.cursors = { topics: 0, posts: 0, categories: 0 };
907
628
  S.mountedIds.clear();
908
629
  S.lastShow.clear();
909
- S.wrapByKey.clear();
910
- S.ezActiveIds.clear();
911
- S.ezShownSinceDestroy.clear();
912
630
  S.inflight = 0;
913
631
  S.pending = [];
914
632
  S.pendingSet.clear();
915
- if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
916
- if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
917
- S.destroyPending = [];
918
- S.destroyPendingSet.clear();
919
633
  S.burstActive = false;
920
634
  S.runQueued = false;
921
- S.sweepQueued = false;
922
- S.scrollSpeed = 0;
923
- S.lastScrollY = 0;
924
- S.lastScrollTs = 0;
925
635
  }
926
636
 
927
- // ── MutationObserver ───────────────────────────────────────────────────────
637
+ // ── DOM Observer ───────────────────────────────────────────────────────────
928
638
 
929
639
  function ensureDomObserver() {
930
640
  if (S.domObs) return;
931
- const allSel = [SEL.post, SEL.topic, SEL.category];
932
641
  S.domObs = new MutationObserver(muts => {
933
642
  if (S.mutGuard > 0 || isBlocked()) return;
934
643
  for (const m of muts) {
935
- let sawWrapRemoval = false;
936
- for (const n of m.removedNodes) {
937
- if (n.nodeType !== 1) continue;
938
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
939
- sawWrapRemoval = true;
940
- }
941
- }
942
- if (sawWrapRemoval) queueSweepDeadWraps();
644
+ if (!m.addedNodes?.length) continue;
943
645
  for (const n of m.addedNodes) {
944
646
  if (n.nodeType !== 1) continue;
945
- try { healFalseEmpty(n); } catch (_) {}
946
- // matches() d'abord (O(1)), querySelector() seulement si nécessaire
947
- if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
948
- allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
647
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
648
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
649
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
949
650
  requestBurst(); return;
950
651
  }
951
652
  }
@@ -959,14 +660,7 @@ function startShowBatch(ids) {
959
660
  function muteConsole() {
960
661
  if (window.__nbbEzMuted) return;
961
662
  window.__nbbEzMuted = true;
962
- const MUTED = [
963
- '[EzoicAds JS]: Placeholder Id',
964
- 'No valid placeholders for loadMore',
965
- 'cannot call refresh on the same page',
966
- 'no placeholders are currently defined in Refresh',
967
- 'Debugger iframe already exists',
968
- `with id ${PH_PREFIX}`,
969
- ];
663
+ const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
970
664
  for (const m of ['log', 'info', 'warn', 'error']) {
971
665
  const orig = console[m];
972
666
  if (typeof orig !== 'function') continue;
@@ -978,18 +672,29 @@ function startShowBatch(ids) {
978
672
  }
979
673
 
980
674
  function ensureTcfLocator() {
675
+ // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
676
+ // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
677
+ // iframe du DOM (vidage partiel du body), ce qui provoque :
678
+ // "Cannot read properties of null (reading 'postMessage')"
679
+ // "Cannot set properties of null (setting 'addtlConsent')"
680
+ // Solution : la recrée immédiatement si elle disparaît, via un observer.
981
681
  try {
982
682
  if (!window.__tcfapi && !window.__cmp) return;
683
+
983
684
  const inject = () => {
984
685
  if (document.getElementById('__tcfapiLocator')) return;
985
686
  const f = document.createElement('iframe');
986
687
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
987
688
  (document.body || document.documentElement).appendChild(f);
988
689
  };
690
+
989
691
  inject();
692
+
693
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
990
694
  if (!window.__nbbTcfObs) {
991
- window.__nbbTcfObs = new MutationObserver(inject);
992
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
695
+ window.__nbbTcfObs = new MutationObserver(() => inject());
696
+ window.__nbbTcfObs.observe(document.documentElement,
697
+ { childList: true, subtree: true });
993
698
  }
994
699
  } catch (_) {}
995
700
  }
@@ -999,10 +704,10 @@ function startShowBatch(ids) {
999
704
  const head = document.head;
1000
705
  if (!head) return;
1001
706
  for (const [rel, href, cors] of [
1002
- ['preconnect', 'https://g.ezoic.net', true ],
1003
- ['preconnect', 'https://go.ezoic.net', true ],
1004
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
1005
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
707
+ ['preconnect', 'https://g.ezoic.net', true],
708
+ ['preconnect', 'https://go.ezoic.net', true],
709
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
710
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
1006
711
  ['dns-prefetch', 'https://g.ezoic.net', false],
1007
712
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1008
713
  ]) {
@@ -1016,7 +721,7 @@ function startShowBatch(ids) {
1016
721
  }
1017
722
  }
1018
723
 
1019
- // ── Bindings ───────────────────────────────────────────────────────────────
724
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
1020
725
 
1021
726
  function bindNodeBB() {
1022
727
  const $ = window.jQuery;
@@ -1027,16 +732,19 @@ function startShowBatch(ids) {
1027
732
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1028
733
  S.pageKey = pageKey();
1029
734
  blockedUntil = 0;
1030
- muteConsole(); ensureTcfLocator(); warmNetwork();
1031
- patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
735
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
736
+ getIO(); ensureDomObserver(); requestBurst();
1032
737
  });
1033
738
 
1034
- const burstEvts = [
1035
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
739
+ const BURST_EVENTS = [
740
+ 'action:ajaxify.contentLoaded',
741
+ 'action:posts.loaded', 'action:topics.loaded',
1036
742
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1037
743
  ].map(e => `${e}.nbbEzoic`).join(' ');
1038
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
1039
744
 
745
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
746
+
747
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
1040
748
  try {
1041
749
  require(['hooks'], hooks => {
1042
750
  if (typeof hooks?.on !== 'function') return;
@@ -1048,24 +756,25 @@ function startShowBatch(ids) {
1048
756
  } catch (_) {}
1049
757
  }
1050
758
 
759
+
760
+ function bindResize() {
761
+ let t = null;
762
+ window.addEventListener('resize', () => {
763
+ clearTimeout(t);
764
+ t = setTimeout(() => {
765
+ try { getIO(); } catch (_) {}
766
+ // Ré-observer les placeholders existants (si IO recréé)
767
+ try {
768
+ document.querySelectorAll(`.${WRAP_CLASS} [id^="${PH_PREFIX}"]`).forEach(ph => {
769
+ if (ph instanceof Element) { try { S.io?.observe(ph); } catch (_) {} }
770
+ });
771
+ } catch (_) {}
772
+ }, 200);
773
+ }, { passive: true });
774
+ }
1051
775
  function bindScroll() {
1052
776
  let ticking = false;
1053
- try {
1054
- S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1055
- S.lastScrollTs = ts();
1056
- } catch (_) {}
1057
777
  window.addEventListener('scroll', () => {
1058
- try {
1059
- const y = window.scrollY || window.pageYOffset || 0;
1060
- const t = ts();
1061
- const dy = y - (S.lastScrollY || 0);
1062
- const dt = Math.max(1, t - (S.lastScrollTs || t));
1063
- if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
1064
- const inst = Math.abs(dy) * 1000 / dt;
1065
- S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
1066
- S.lastScrollY = y;
1067
- S.lastScrollTs = t;
1068
- } catch (_) {}
1069
778
  if (ticking) return;
1070
779
  ticking = true;
1071
780
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
@@ -1082,6 +791,7 @@ function startShowBatch(ids) {
1082
791
  getIO();
1083
792
  ensureDomObserver();
1084
793
  bindNodeBB();
794
+ bindResize();
1085
795
  bindScroll();
1086
796
  blockedUntil = 0;
1087
797
  requestBurst();