nodebb-plugin-ezoic-infinite 1.8.22 → 1.8.24

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,83 +1,192 @@
1
- (function nbbEzoicInfiniteV22() {
1
+ /**
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
3
+ *
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
7
+ *
8
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
+ * la position dans le batch courant.
10
+ *
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.
15
+ *
16
+ * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
17
+ *
18
+ * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
19
+ *
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.
67
+ */
68
+ (function nbbEzoicInfinite() {
2
69
  'use strict';
3
70
 
4
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
5
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
6
- const A_ANCHOR = 'data-ezoic-anchor';
7
- const A_WRAPID = 'data-ezoic-wrapid';
8
- const A_CREATED = 'data-ezoic-created';
9
- const A_SHOWN = 'data-ezoic-shown';
10
- const A_STATE = 'data-ezoic-state';
71
+ // ── Constantes ─────────────────────────────────────────────────────────────
72
+
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
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
84
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
86
+ const MIN_PLACEHOLDER_HEIGHT = 50; // réservation minimale perçue
87
+
88
+ // Marges IO larges et fixes — observer créé une seule fois au boot
89
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
90
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
11
91
 
12
92
  const SEL = {
13
- post: '[component="post"][data-pid]',
14
- topic: 'li[component="category/topic"]',
93
+ post: '[component="post"][data-pid]',
94
+ topic: 'li[component="category/topic"]',
15
95
  category: 'li[component="categories/category"]',
16
96
  };
17
97
 
98
+ /**
99
+ * Table KIND — source de vérité par kindClass.
100
+ *
101
+ * sel sélecteur CSS complet des éléments cibles
102
+ * baseTag préfixe tag pour querySelector d'ancre
103
+ * (vide pour posts : le sélecteur commence par '[')
104
+ * anchorAttr attribut DOM stable → clé unique du wrap
105
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
106
+ * null → fallback positionnel (catégories)
107
+ */
18
108
  const KIND = {
19
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index', pool: 'posts' },
20
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index', pool: 'topics' },
21
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null, pool: 'categories' },
109
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
110
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
111
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
22
112
  };
23
113
 
24
- const CFG = {
25
- maxInsertsPerRun: 8,
26
- maxShowInflight: 1,
27
- maxShowBatch: 1,
28
- showThrottleMs: 1500,
29
- showCooldownMs: 15000,
30
- showReleaseMs: 350,
31
- showFailsafeMs: 8000,
32
- batchFlushMs: 80,
33
- runDebounceMs: 80,
34
- orphanGraceMs: 12000,
35
- retireGraceMs: 25000,
36
- recentActivityMs: 6000,
37
- viewportBufferDesktop: 700,
38
- viewportBufferMobile: 350,
39
- ioMarginDesktop: '1800px 0px 2200px 0px',
40
- ioMarginMobile: '2400px 0px 2800px 0px',
41
- maintenanceEveryMs: 1200,
42
- minHeightRememberMin: 40,
43
- };
114
+ // ── État global ────────────────────────────────────────────────────────────
44
115
 
45
116
  const S = {
46
- cfg: null,
47
- poolsReady: false,
48
- pools: { topics: [], posts: [], categories: [] },
49
- cursors: { topics: 0, posts: 0, categories: 0 },
50
- pageKey: null,
51
- blockedUntil: 0,
52
- runTimer: 0,
53
- maintenanceTimer: 0,
54
- io: null,
55
- domObs: null,
56
- muting: 0,
57
- mountedIds: new Set(),
58
- wrapByKey: new Map(),
59
- regById: new Map(),
60
- lastShowById: new Map(),
61
- lastWrapHeightByClass: new Map(),
62
- pendingShow: [],
63
- pendingSet: new Set(),
64
- showTimer: 0,
65
- inflightShow: 0,
117
+ pageKey: null,
118
+ cfg: null,
119
+ poolsReady: false,
120
+ pools: { topics: [], posts: [], categories: [] },
121
+ cursors: { topics: 0, posts: 0, categories: 0 },
122
+ mountedIds: new Set(),
123
+ lastShow: new Map(),
124
+ io: null,
125
+ domObs: null,
126
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
127
+ inflight: 0, // showAds() en cours
128
+ pending: [], // ids en attente de slot inflight
129
+ pendingSet: new Set(),
130
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
131
+ runQueued: false,
132
+ burstActive: false,
133
+ burstDeadline: 0,
134
+ burstCount: 0,
135
+ lastBurstTs: 0,
136
+ firstShown: false,
137
+ wrapsByClass: new Map(),
138
+ kind: null,
66
139
  };
67
140
 
68
- const now = () => Date.now();
69
- const isBlocked = () => now() < S.blockedUntil;
70
- const isMobile = () => {
71
- try { return window.innerWidth < 768; } catch (_) { return false; }
72
- };
73
- const viewportBuffer = () => (isMobile() ? CFG.viewportBufferMobile : CFG.viewportBufferDesktop);
74
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
141
+ let blockedUntil = 0;
142
+
143
+ const ts = () => Date.now();
144
+ const isBlocked = () => ts() < blockedUntil;
145
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
146
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
147
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
75
148
 
76
149
  function mutate(fn) {
77
- S.muting++;
78
- try { return fn(); } finally { S.muting = Math.max(0, S.muting - 1); }
150
+ S.mutGuard++;
151
+ try { fn(); } finally { S.mutGuard--; }
79
152
  }
80
153
 
154
+ // ── Config ─────────────────────────────────────────────────────────────────
155
+
156
+ async function fetchConfig() {
157
+ if (S.cfg) return S.cfg;
158
+ try {
159
+ if (window.__nbbEzoicCfg && typeof window.__nbbEzoicCfg === 'object') {
160
+ S.cfg = window.__nbbEzoicCfg;
161
+ return S.cfg;
162
+ }
163
+ } catch (_) {}
164
+ try {
165
+ const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
166
+ if (r.ok) S.cfg = await r.json();
167
+ } catch (_) {}
168
+ return S.cfg;
169
+ }
170
+
171
+ function parseIds(raw) {
172
+ const out = [], seen = new Set();
173
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
174
+ const n = parseInt(v, 10);
175
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function initPools(cfg) {
181
+ if (S.poolsReady) return;
182
+ S.pools.topics = parseIds(cfg.placeholderIds);
183
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
184
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
185
+ S.poolsReady = true;
186
+ }
187
+
188
+ // ── Page identity ──────────────────────────────────────────────────────────
189
+
81
190
  function pageKey() {
82
191
  try {
83
192
  const d = window.ajaxify?.data;
@@ -87,718 +196,668 @@
87
196
  return location.pathname;
88
197
  }
89
198
 
90
- function getKind() {
199
+ function detectKind() {
91
200
  const p = location.pathname;
92
- if (/^\/topic\//.test(p)) return 'topic';
93
- if (/^\/category\//.test(p)) return 'categoryTopics';
201
+ if (/^\/topic\//.test(p)) return 'topic';
202
+ if (/^\/category\//.test(p)) return 'categoryTopics';
94
203
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
95
- if (document.querySelector(SEL.post)) return 'topic';
96
- if (document.querySelector(SEL.topic)) return 'categoryTopics';
97
204
  if (document.querySelector(SEL.category)) return 'categories';
205
+ if (document.querySelector(SEL.post)) return 'topic';
206
+ if (document.querySelector(SEL.topic)) return 'categoryTopics';
98
207
  return 'other';
99
208
  }
100
209
 
210
+ function getKind() {
211
+ if (S.kind) return S.kind;
212
+ S.kind = detectKind();
213
+ return S.kind;
214
+ }
215
+
216
+ // ── Items DOM ──────────────────────────────────────────────────────────────
217
+
101
218
  function getPosts() {
102
219
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
103
220
  if (!el.isConnected) return false;
104
221
  if (!el.querySelector('[component="post/content"]')) return false;
105
- const p = el.parentElement?.closest?.(SEL.post);
222
+ const p = el.parentElement?.closest(SEL.post);
106
223
  if (p && p !== el) return false;
107
224
  return el.getAttribute('component') !== 'post/parent';
108
225
  });
109
226
  }
110
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
111
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
112
-
113
- async function fetchConfig() {
114
- if (S.cfg) return S.cfg;
115
- try {
116
- const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
117
- if (r.ok) S.cfg = await r.json();
118
- } catch (_) {}
119
- return S.cfg;
120
- }
121
227
 
122
- function parseIds(raw) {
123
- const out = [];
124
- const seen = new Set();
125
- for (const line of String(raw || '').split(/\r?\n/)) {
126
- const n = parseInt(line.trim(), 10);
127
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
128
- }
129
- return out;
130
- }
228
+ const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
229
+ const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
131
230
 
132
- function initPools(cfg) {
133
- if (S.poolsReady) return;
134
- S.pools = {
135
- topics: parseIds(cfg.placeholderIds),
136
- posts: parseIds(cfg.messagePlaceholderIds),
137
- categories: parseIds(cfg.categoryPlaceholderIds),
138
- };
139
- S.poolsReady = true;
140
- }
231
+ // ── Wraps — détection ──────────────────────────────────────────────────────
141
232
 
142
- function textSig(el) {
143
- if (!(el instanceof Element)) return '';
144
- const parts = [el.id || '', String(el.className || ''), el.getAttribute('name') || ''];
145
- for (const a of ['data-slot', 'data-ad-slot', 'data-google-container-id']) {
146
- try { const v = el.getAttribute(a); if (v) parts.push(v); } catch (_) {}
233
+ /**
234
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
235
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
236
+ */
237
+ function wrapIsLive(wrap) {
238
+ if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
239
+ const key = wrap.getAttribute(A_ANCHOR);
240
+ if (!key) return false;
241
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
242
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
243
+ if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
244
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
245
+ const colonIdx = key.indexOf(':');
246
+ const klass = key.slice(0, colonIdx);
247
+ const anchorId = key.slice(colonIdx + 1);
248
+ const cfg = KIND[klass];
249
+ if (!cfg) return false;
250
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
251
+ // de querySelector global — on cherche parmi les voisins immédiats.
252
+ const parent = wrap.parentElement;
253
+ if (parent) {
254
+ for (const sib of parent.children) {
255
+ if (sib === wrap) continue;
256
+ try {
257
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
258
+ return sib.isConnected;
259
+ }
260
+ } catch (_) {}
261
+ }
147
262
  }
148
- return parts.join(' ').toLowerCase();
149
- }
150
-
151
- function isSpecialLike(root) {
152
- if (!(root instanceof Element)) return false;
153
- const test = el => /adhesion|interstitial|anchor|sticky|outofpage/.test(textSig(el));
154
- if (test(root)) return true;
263
+ // Dernier recours : querySelector global
155
264
  try {
156
- const nodes = root.querySelectorAll('[id],[class],[name],[data-slot],[data-ad-slot]');
157
- for (const n of nodes) if (test(n)) return true;
158
- } catch (_) {}
159
- return false;
160
- }
161
-
162
- function hasFixedLike(root, max = 16) {
163
- if (!(root instanceof Element)) return false;
164
- const q = [root];
165
- let seen = 0;
166
- while (q.length && seen < max) {
167
- const n = q.shift();
168
- seen++;
169
- try {
170
- const cs = window.getComputedStyle(n);
171
- if (cs.position === 'fixed' || cs.position === 'sticky') return true;
172
- } catch (_) {}
173
- for (const c of n.children || []) q.push(c);
174
- }
175
- return false;
265
+ const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
266
+ return !!(found?.isConnected);
267
+ } catch (_) { return false; }
176
268
  }
177
269
 
178
- function isFilled(wrap) {
179
- try { return !!wrap?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'); } catch (_) { return false; }
270
+ function adjacentWrap(el) {
271
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
180
272
  }
181
273
 
182
- function phEl(id) { return document.getElementById(`${PH_PREFIX}${id}`); }
274
+ // ── Ancres stables ─────────────────────────────────────────────────────────
183
275
 
184
- function anchorStableId(klass, el) {
276
+ /**
277
+ * Retourne la valeur de l'attribut stable pour cet élément,
278
+ * ou un fallback positionnel si l'attribut est absent.
279
+ */
280
+ function stableId(klass, el) {
185
281
  const attr = KIND[klass]?.anchorAttr;
186
282
  if (attr) {
187
283
  const v = el.getAttribute(attr);
188
284
  if (v !== null && v !== '') return v;
189
285
  }
190
286
  let i = 0;
191
- for (const s of el.parentElement?.children || []) { if (s === el) return `i${i}`; i++; }
287
+ for (const s of el.parentElement?.children ?? []) {
288
+ if (s === el) return `i${i}`;
289
+ i++;
290
+ }
192
291
  return 'i0';
193
292
  }
194
- const anchorKey = (klass, el) => `${klass}:${anchorStableId(klass, el)}`;
195
293
 
196
- function findWrapByKey(key) {
294
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
295
+
296
+ function findWrap(key) {
197
297
  const w = S.wrapByKey.get(key);
198
298
  return (w?.isConnected) ? w : null;
199
299
  }
200
300
 
201
- function rememberWrapHeight(wrap) {
202
- try {
203
- if (!(wrap instanceof Element)) return;
204
- const klass = [...(wrap.classList || [])].find(c => c.startsWith('ezoic-ad-'));
205
- if (!klass) return;
206
- const h = Math.round(wrap.getBoundingClientRect().height || 0);
207
- if (h >= CFG.minHeightRememberMin) S.lastWrapHeightByClass.set(klass, h);
208
- } catch (_) {}
301
+ function wrapsSet(klass) {
302
+ let set = S.wrapsByClass.get(klass);
303
+ if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
304
+ return set;
209
305
  }
306
+ const registerWrap = (klass, w) => wrapsSet(klass).add(w);
307
+ const unregisterWrap = (klass, w) => S.wrapsByClass.get(klass)?.delete(w);
210
308
 
211
- function wrapNearViewport(wrap) {
212
- try {
213
- const r = wrap.getBoundingClientRect();
214
- const b = viewportBuffer();
215
- const vh = window.innerHeight || 800;
216
- return r.bottom > -b && r.top < vh + b;
217
- } catch (_) { return true; }
218
- }
219
-
220
- function ensureRegistry(id, wrap) {
221
- let rec = S.regById.get(id);
222
- if (!rec) {
223
- rec = {
224
- id,
225
- wrap,
226
- key: wrap?.getAttribute(A_ANCHOR) || '',
227
- state: 'idle',
228
- typeClass: [...(wrap?.classList || [])].find(c => c.startsWith('ezoic-ad-')) || '',
229
- isSpecial: false,
230
- isFixedLike: false,
231
- createdAt: now(),
232
- shownAt: 0,
233
- lastSeenAt: 0,
234
- lastMutationAt: now(),
235
- cooldownUntil: 0,
236
- };
237
- S.regById.set(id, rec);
238
- }
239
- if (wrap && rec.wrap !== wrap) rec.wrap = wrap;
240
- if (wrap) {
241
- rec.key = wrap.getAttribute(A_ANCHOR) || rec.key;
242
- rec.typeClass = [...(wrap.classList || [])].find(c => c.startsWith('ezoic-ad-')) || rec.typeClass;
243
- }
244
- return rec;
245
- }
246
-
247
- function updateWrapState(rec, state) {
248
- rec.state = state;
249
- try { rec.wrap?.setAttribute?.(A_STATE, state); } catch (_) {}
250
- }
309
+ // ── Pool ───────────────────────────────────────────────────────────────────
251
310
 
311
+ /**
312
+ * Retourne le prochain id disponible dans le pool (round-robin),
313
+ * ou null si tous les ids sont montés.
314
+ */
252
315
  function pickId(poolKey) {
253
- const pool = S.pools[poolKey] || [];
316
+ const pool = S.pools[poolKey];
254
317
  if (!pool.length) return null;
255
318
  for (let t = 0; t < pool.length; t++) {
256
- const idx = S.cursors[poolKey] % pool.length;
319
+ const i = S.cursors[poolKey] % pool.length;
257
320
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
258
- const id = pool[idx];
321
+ const id = pool[i];
259
322
  if (!S.mountedIds.has(id)) return id;
260
323
  }
261
324
  return null;
262
325
  }
263
326
 
327
+ /**
328
+ * Pool épuisé : recycle un wrap loin au-dessus du viewport.
329
+ * Séquence avec délais (destroyPlaceholders est asynchrone) :
330
+ * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
331
+ * displayMore = API Ezoic prévue pour l'infinite scroll.
332
+ * Priorité : wraps vides d'abord, remplis si nécessaire.
333
+ */
334
+ function recycleAndMove(klass, targetEl, newKey) {
335
+ const ez = window.ezstandalone;
336
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
337
+ typeof ez?.define !== 'function' ||
338
+ typeof ez?.displayMore !== 'function') return null;
339
+
340
+ const vh = window.innerHeight || 800;
341
+ // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
342
+ // après pour neutraliser l'IO — plus de showAds parasite possible.
343
+ const threshold = -vh;
344
+ let bestEmpty = null, bestEmptyBottom = Infinity;
345
+ let bestFilled = null, bestFilledBottom = Infinity;
346
+
347
+ for (const wrap of (S.wrapsByClass.get(klass) || [])) {
348
+ try {
349
+ const rect = wrap.getBoundingClientRect();
350
+ if (rect.bottom > threshold) return;
351
+ if (!isFilled(wrap)) {
352
+ if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
353
+ } else {
354
+ if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
355
+ }
356
+ } catch (_) {}
357
+ }
358
+
359
+ const best = bestEmpty ?? bestFilled;
360
+ if (!best) return null;
361
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
362
+ if (!Number.isFinite(id)) return null;
363
+
364
+ const oldKey = best.getAttribute(A_ANCHOR);
365
+ // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
366
+ // parasite si le nœud était encore dans la zone IO_MARGIN.
367
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
368
+ mutate(() => {
369
+ best.setAttribute(A_ANCHOR, newKey);
370
+ best.setAttribute(A_CREATED, String(ts()));
371
+ best.setAttribute(A_SHOWN, '0');
372
+ best.classList.remove('is-empty');
373
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
374
+ if (ph) ph.innerHTML = '';
375
+ targetEl.insertAdjacentElement('afterend', best);
376
+ });
377
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
378
+ S.wrapByKey.set(newKey, best);
379
+
380
+ // Délais requis : destroyPlaceholders est asynchrone en interne
381
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
382
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
383
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
384
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
385
+
386
+ return { id, wrap: best };
387
+ }
388
+
389
+ // ── Wraps DOM — création / suppression ────────────────────────────────────
390
+
264
391
  function makeWrap(id, klass, key) {
265
392
  const w = document.createElement('div');
266
393
  w.className = `${WRAP_CLASS} ${klass}`;
267
- w.setAttribute(A_ANCHOR, key);
268
- w.setAttribute(A_WRAPID, String(id));
269
- w.setAttribute(A_CREATED, String(now()));
270
- w.setAttribute(A_SHOWN, '0');
271
- w.setAttribute(A_STATE, 'idle');
272
- w.style.cssText = 'display:block;width:100%;';
273
- const h = S.lastWrapHeightByClass.get(klass);
274
- if (Number.isFinite(h) && h > 0) w.style.minHeight = `${h}px`;
394
+ w.setAttribute(A_ANCHOR, key);
395
+ w.setAttribute(A_WRAPID, String(id));
396
+ w.setAttribute(A_CREATED, String(ts()));
397
+ w.setAttribute(A_SHOWN, '0');
398
+ w.style.cssText = 'width:100%;display:block;';
275
399
  const ph = document.createElement('div');
276
400
  ph.id = `${PH_PREFIX}${id}`;
277
401
  ph.setAttribute('data-ezoic-id', String(id));
402
+ ph.style.minHeight = `${MIN_PLACEHOLDER_HEIGHT}px`;
278
403
  w.appendChild(ph);
279
404
  return w;
280
405
  }
281
406
 
282
- function insertWrapAfter(anchorEl, id, klass, key) {
283
- if (!anchorEl?.insertAdjacentElement) return null;
284
- if (findWrapByKey(key)) return null;
285
- if (S.mountedIds.has(id)) return null;
286
- if (phEl(id)?.isConnected) return null;
407
+ function insertAfter(el, id, klass, key) {
408
+ if (!el?.insertAdjacentElement) return null;
409
+ if (findWrap(key)) return null;
410
+ if (S.mountedIds.has(id)) return null;
411
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
287
412
  const w = makeWrap(id, klass, key);
288
- mutate(() => anchorEl.insertAdjacentElement('afterend', w));
413
+ mutate(() => el.insertAdjacentElement('afterend', w));
289
414
  S.mountedIds.add(id);
290
415
  S.wrapByKey.set(key, w);
291
- const rec = ensureRegistry(id, w);
292
- rec.key = key;
293
- rec.createdAt = now();
294
- rec.lastMutationAt = now();
295
- rec.isSpecial = false;
296
- rec.isFixedLike = false;
297
- updateWrapState(rec, 'idle');
298
- observePlaceholder(id);
416
+ registerWrap(klass, w);
299
417
  return w;
300
418
  }
301
419
 
302
- function releaseWrap(wrap) {
420
+ function dropWrap(w) {
303
421
  try {
304
- rememberWrapHeight(wrap);
305
- const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
306
- const key = wrap.getAttribute(A_ANCHOR) || '';
307
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
308
- if (ph) try { S.io?.unobserve(ph); } catch (_) {}
309
- if (key && S.wrapByKey.get(key) === wrap) S.wrapByKey.delete(key);
310
- if (Number.isFinite(id)) {
311
- S.mountedIds.delete(id);
312
- S.pendingSet.delete(id);
313
- S.lastShowById.delete(id);
314
- const rec = S.regById.get(id);
315
- if (rec) {
316
- rec.wrap = null;
317
- rec.state = 'retired';
318
- rec.cooldownUntil = Math.max(rec.cooldownUntil || 0, now() + 3000);
319
- }
320
- }
321
- wrap.remove();
422
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
423
+ if (ph instanceof Element) S.io?.unobserve(ph);
424
+ const id = parseInt(w.getAttribute(A_WRAPID), 10);
425
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
426
+ const key = w.getAttribute(A_ANCHOR);
427
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
428
+ const klass = Array.from(w.classList || []).find(c => c !== WRAP_CLASS && c.startsWith('ezoic-ad-'));
429
+ if (klass) unregisterWrap(klass, w);
430
+ w.remove();
322
431
  } catch (_) {}
323
432
  }
324
433
 
325
- function wrapHasAnchor(wrap) {
326
- const key = wrap?.getAttribute?.(A_ANCHOR);
327
- if (!key) return false;
328
- const i = key.indexOf(':');
329
- const klass = key.slice(0, i);
330
- const sid = key.slice(i + 1);
331
- const def = KIND[klass];
332
- if (!def) return false;
333
- try {
334
- const sel = `${def.sel}[${def.anchorAttr}="${sid}"]`;
335
- return !!document.querySelector(sel);
336
- } catch (_) { return false; }
337
- }
338
-
339
- function sweepDisconnected() {
340
- for (const [key, wrap] of [...S.wrapByKey.entries()]) {
341
- if (wrap?.isConnected) continue;
342
- S.wrapByKey.delete(key);
343
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
344
- if (Number.isFinite(id)) {
345
- S.mountedIds.delete(id);
346
- S.pendingSet.delete(id);
347
- }
348
- }
349
- }
350
-
351
- function classifyWrap(rec) {
352
- const w = rec.wrap;
353
- if (!w?.isConnected) return;
354
- if (!rec.isSpecial) rec.isSpecial = isSpecialLike(w);
355
- if (!rec.isFixedLike) rec.isFixedLike = hasFixedLike(w);
356
- }
357
-
358
- function maintenance() {
359
- if (isBlocked()) return;
360
- sweepDisconnected();
361
- const t = now();
362
- for (const [id, rec] of S.regById) {
363
- const w = rec.wrap;
364
- if (!w?.isConnected) continue;
365
- classifyWrap(rec);
366
- if (wrapNearViewport(w)) {
367
- rec.lastSeenAt = t;
368
- if (rec.state === 'retiring') updateWrapState(rec, 'live');
369
- continue;
370
- }
371
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10) || rec.createdAt || 0;
372
- const shown = parseInt(w.getAttribute(A_SHOWN) || '0', 10) || rec.shownAt || 0;
373
- const age = t - Math.max(created, shown || 0, rec.lastMutationAt || 0);
374
- const activeRecently = (t - (rec.lastMutationAt || 0)) < CFG.recentActivityMs;
375
- const filled = isFilled(w);
376
- if (filled) { rec.lastMutationAt = t; rec.shownAt = Math.max(rec.shownAt || 0, shown || 0); }
377
- if (rec.isSpecial || rec.isFixedLike) continue; // never retire plugin-side
378
- if (activeRecently) continue;
379
- if (filled) continue; // keep rendered slots stable; no aggressive destroy/recycle
380
- if (age < CFG.retireGraceMs) continue;
381
- if (rec.state !== 'retiring') {
382
- updateWrapState(rec, 'retiring');
383
- continue;
384
- }
385
- // second pass confirmation
386
- if (wrapNearViewport(w) || isFilled(w)) { updateWrapState(rec, 'live'); continue; }
387
- releaseWrap(w);
434
+ // ── Prune (topics de catégorie uniquement) ────────────────────────────────
435
+ //
436
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
437
+ //
438
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
439
+ // les li[component="category/topic"] restent dans le DOM pendant toute
440
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
441
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
442
+ // liste après un long scroll et bloquent les nouvelles injections.
443
+ //
444
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
445
+ // NodeBB virtualise les posts hors-viewport il les retire puis les
446
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
447
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
448
+
449
+ function pruneOrphansBetween() {
450
+ const klass = 'ezoic-ad-between';
451
+ const cfg = KIND[klass];
452
+ const liveAnchors = new Set(Array.from(document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`))
453
+ .map(el => el.getAttribute(cfg.anchorAttr)).filter(Boolean));
454
+
455
+ for (const w of (S.wrapsByClass.get(klass) || [])) {
456
+ const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
457
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
458
+
459
+ const key = w.getAttribute(A_ANCHOR) ?? '';
460
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
461
+ if (!sid) { mutate(() => dropWrap(w)); return; }
462
+
463
+ if (!liveAnchors.has(sid)) mutate(() => dropWrap(w));
388
464
  }
389
465
  }
390
466
 
391
- function scheduleMaintenance() {
392
- if (S.maintenanceTimer) return;
393
- S.maintenanceTimer = setTimeout(() => {
394
- S.maintenanceTimer = 0;
395
- maintenance();
396
- }, CFG.maintenanceEveryMs);
397
- }
467
+ // ── Injection ──────────────────────────────────────────────────────────────
398
468
 
469
+ /**
470
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
471
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
472
+ */
399
473
  function ordinal(klass, el) {
400
474
  const attr = KIND[klass]?.ordinalAttr;
401
475
  if (attr) {
402
476
  const v = el.getAttribute(attr);
403
477
  if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
404
478
  }
479
+ const fullSel = KIND[klass]?.sel ?? '';
405
480
  let i = 0;
406
- const fullSel = KIND[klass]?.sel || '';
407
- for (const s of el.parentElement?.children || []) {
481
+ for (const s of el.parentElement?.children ?? []) {
408
482
  if (s === el) return i;
409
483
  if (!fullSel || s.matches?.(fullSel)) i++;
410
484
  }
411
485
  return 0;
412
486
  }
413
487
 
414
- function adjacentManagedWrap(el) {
415
- const isManaged = n => !!(n?.classList?.contains(WRAP_CLASS) && n.isConnected);
416
- return isManaged(el.nextElementSibling) || isManaged(el.previousElementSibling);
417
- }
418
-
419
- function injectFor(klass, items, interval, showFirst) {
488
+ function injectBetween(klass, items, interval, showFirst, poolKey) {
489
+ if (!items.length) return 0;
420
490
  let inserted = 0;
421
- const poolKey = KIND[klass].pool;
491
+
422
492
  for (const el of items) {
423
- if (inserted >= CFG.maxInsertsPerRun) break;
493
+ if (inserted >= MAX_INSERTS_RUN) break;
424
494
  if (!el?.isConnected) continue;
495
+
425
496
  const ord = ordinal(klass, el);
426
497
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
427
- if (adjacentManagedWrap(el)) continue;
498
+ if (adjacentWrap(el)) continue;
499
+
428
500
  const key = anchorKey(klass, el);
429
- if (findWrapByKey(key)) continue;
501
+ if (findWrap(key)) continue;
502
+
430
503
  const id = pickId(poolKey);
431
- if (!id) break; // no recycle = anti-churn by design
432
- if (insertWrapAfter(el, id, klass, key)) inserted++;
504
+ if (id) {
505
+ const w = insertAfter(el, id, klass, key);
506
+ if (w) {
507
+ observePh(id);
508
+ if (!S.firstShown) { S.firstShown = true; enqueueShow(id); }
509
+ inserted++;
510
+ }
511
+ } else {
512
+ const recycled = recycleAndMove(klass, el, key);
513
+ if (!recycled) break;
514
+ inserted++;
515
+ }
433
516
  }
434
517
  return inserted;
435
518
  }
436
519
 
437
- function canShowId(id) {
438
- const t = now();
439
- const last = S.lastShowById.get(id) || 0;
440
- if (t - last < CFG.showThrottleMs) return false;
441
- const rec = S.regById.get(id);
442
- if (rec && rec.cooldownUntil && t < rec.cooldownUntil) return false;
443
- const ph = phEl(id);
444
- if (!ph?.isConnected) return false;
445
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
446
- if (!wrap?.isConnected) return false;
447
- if (!wrapNearViewport(wrap)) {
448
- // allow preloading within IO margin only if reasonably close
449
- try {
450
- const r = ph.getBoundingClientRect();
451
- const vh = window.innerHeight || 800;
452
- const preload = isMobile() ? 1800 : 1400;
453
- if (r.top > vh + preload || r.bottom < -preload) return false;
454
- } catch (_) {}
455
- }
456
- return true;
520
+ // ── IntersectionObserver & Show ────────────────────────────────────────────
521
+
522
+ function getIO() {
523
+ if (S.io) return S.io;
524
+ try {
525
+ S.io = new IntersectionObserver(entries => {
526
+ for (const e of entries) {
527
+ if (!e.isIntersecting) continue;
528
+ if (e.target instanceof Element) S.io?.unobserve(e.target);
529
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
530
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
531
+ }
532
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
533
+ } catch (_) { S.io = null; }
534
+ return S.io;
535
+ }
536
+
537
+ function observePh(id) {
538
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
539
+ if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
457
540
  }
458
541
 
459
542
  function enqueueShow(id) {
460
- if (isBlocked()) return;
461
- const n = parseInt(id, 10);
462
- if (!Number.isFinite(n) || n <= 0) return;
463
- if (!S.pendingSet.has(n)) { S.pendingSet.add(n); S.pendingShow.push(n); }
464
- if (!S.showTimer) {
465
- S.showTimer = setTimeout(() => {
466
- S.showTimer = 0;
467
- drainShowQueue();
468
- }, CFG.batchFlushMs);
543
+ if (!id || isBlocked()) return;
544
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
545
+ if (S.inflight >= MAX_INFLIGHT) {
546
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
547
+ return;
469
548
  }
549
+ startShow(id);
470
550
  }
471
551
 
472
- function observePlaceholder(id) {
473
- const ph = phEl(id);
474
- if (!ph?.isConnected) return;
475
- try { getIO()?.observe(ph); } catch (_) {}
476
- try {
477
- const r = ph.getBoundingClientRect();
478
- const vh = window.innerHeight || 800;
479
- const preload = isMobile() ? 1600 : 1200;
480
- if (r.top <= vh + preload && r.bottom >= -preload) enqueueShow(id);
481
- } catch (_) {}
552
+ function drainQueue() {
553
+ if (isBlocked()) return;
554
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
555
+ const id = S.pending.shift();
556
+ S.pendingSet.delete(id);
557
+ startShow(id);
558
+ }
482
559
  }
483
560
 
484
- function startShow(ids) {
485
- if (!ids.length || isBlocked()) return;
486
- S.inflightShow += ids.length;
487
- let released = false;
561
+ function startShow(id) {
562
+ if (!id || isBlocked()) return;
563
+ S.inflight++;
564
+ let done = false;
488
565
  const release = () => {
489
- if (released) return;
490
- released = true;
491
- S.inflightShow = Math.max(0, S.inflightShow - ids.length);
492
- drainShowQueue();
566
+ if (done) return;
567
+ done = true;
568
+ S.inflight = Math.max(0, S.inflight - 1);
569
+ drainQueue();
493
570
  };
494
- const guard = setTimeout(release, CFG.showFailsafeMs);
571
+ const timer = setTimeout(release, 7000);
495
572
 
496
573
  requestAnimationFrame(() => {
497
574
  try {
498
- if (isBlocked()) { clearTimeout(guard); return release(); }
575
+ if (isBlocked()) { clearTimeout(timer); return release(); }
576
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
577
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
578
+
579
+ const t = ts();
580
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
581
+ S.lastShow.set(id, t);
582
+
583
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
584
+
499
585
  window.ezstandalone = window.ezstandalone || {};
500
586
  const ez = window.ezstandalone;
501
- const valid = [];
502
- const t = now();
503
- for (const id of ids) {
504
- if (!canShowId(id)) continue;
505
- const ph = phEl(id);
506
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
507
- if (!wrap) continue;
508
- wrap.setAttribute(A_SHOWN, String(t));
509
- const rec = ensureRegistry(id, wrap);
510
- rec.wrap = wrap;
511
- rec.shownAt = t;
512
- rec.lastMutationAt = t;
513
- rec.cooldownUntil = t + CFG.showCooldownMs;
514
- classifyWrap(rec);
515
- updateWrapState(rec, 'showing');
516
- S.lastShowById.set(id, t);
517
- valid.push(id);
518
- }
519
- if (!valid.length) { clearTimeout(guard); return release(); }
520
- const run = () => {
521
- try { ez.showAds(...valid); } catch (_) {
522
- try { ez.showAds(valid); } catch (_) {}
523
- }
524
- setTimeout(() => {
525
- for (const id of valid) {
526
- const rec = S.regById.get(id);
527
- if (!rec) continue;
528
- const w = rec.wrap;
529
- if (w?.isConnected) {
530
- if (isFilled(w)) rec.lastMutationAt = now();
531
- updateWrapState(rec, 'live');
532
- }
533
- }
534
- clearTimeout(guard);
535
- release();
536
- }, CFG.showReleaseMs);
587
+ const doShow = () => {
588
+ try { ez.showAds(id); } catch (_) {}
589
+ scheduleEmptyCheck(id, t);
590
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
537
591
  };
538
- Array.isArray(ez.cmd) ? ez.cmd.push(run) : run();
539
- } catch (_) {
540
- clearTimeout(guard);
541
- release();
542
- }
592
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
593
+ } catch (_) { clearTimeout(timer); release(); }
543
594
  });
544
595
  }
545
596
 
546
- function drainShowQueue() {
547
- if (isBlocked()) return;
548
- if (S.inflightShow >= CFG.maxShowInflight) return;
549
- if (!S.pendingShow.length) return;
550
- const cap = Math.min(CFG.maxShowBatch, CFG.maxShowInflight - S.inflightShow);
551
- const picked = [];
552
- while (S.pendingShow.length && picked.length < cap) {
553
- const id = S.pendingShow.shift();
554
- S.pendingSet.delete(id);
555
- if (!Number.isFinite(id) || id <= 0) continue;
556
- if (!phEl(id)?.isConnected) continue;
557
- picked.push(id);
558
- }
559
- if (picked.length) startShow(picked);
560
- if (S.pendingShow.length) scheduleMaintenance();
597
+ function scheduleEmptyCheck(id, showTs) {
598
+ setTimeout(() => {
599
+ try {
600
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
601
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
602
+ if (!wrap || !ph?.isConnected) return;
603
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
604
+ wrap.classList.toggle('is-empty', !isFilled(ph));
605
+ } catch (_) {}
606
+ }, EMPTY_CHECK_MS);
561
607
  }
562
608
 
563
- function getIO() {
564
- if (S.io) return S.io;
565
- try {
566
- S.io = new IntersectionObserver(entries => {
567
- for (const e of entries) {
568
- const t = e.target;
569
- if (!(t instanceof Element)) continue;
570
- const id = parseInt(t.getAttribute('data-ezoic-id'), 10);
571
- if (!Number.isFinite(id) || id <= 0) continue;
572
- const rec = S.regById.get(id);
573
- if (rec) rec.lastSeenAt = now();
574
- if (e.isIntersecting) enqueueShow(id);
575
- }
576
- }, {
577
- root: null,
578
- rootMargin: isMobile() ? CFG.ioMarginMobile : CFG.ioMarginDesktop,
579
- threshold: 0,
580
- });
581
- } catch (_) { S.io = null; }
582
- return S.io;
583
- }
609
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
610
+ //
611
+ // Intercepte ez.showAds() pour :
612
+ // – ignorer les appels pendant blockedUntil
613
+ // – filtrer les ids dont le placeholder n'est pas en DOM
584
614
 
585
615
  function patchShowAds() {
586
- const install = () => {
616
+ const apply = () => {
587
617
  try {
588
618
  window.ezstandalone = window.ezstandalone || {};
589
619
  const ez = window.ezstandalone;
590
- if (window.__nbbEzPatchedV22 || typeof ez.showAds !== 'function') return;
591
- window.__nbbEzPatchedV22 = true;
620
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
621
+ window.__nbbEzPatched = true;
592
622
  const orig = ez.showAds.bind(ez);
593
623
  ez.showAds = function (...args) {
594
624
  if (isBlocked()) return;
595
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
596
- const valid = [];
625
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
597
626
  const seen = new Set();
598
627
  for (const v of ids) {
599
628
  const id = parseInt(v, 10);
600
629
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
601
- const ph = phEl(id);
602
- if (!ph?.isConnected) continue;
603
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
604
- if (!wrap?.isConnected) continue;
605
- const rec = ensureRegistry(id, wrap);
606
- if (rec.cooldownUntil && now() < rec.cooldownUntil && (now() - (rec.shownAt || 0)) < 500) continue;
630
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
607
631
  seen.add(id);
608
- valid.push(id);
632
+ try { orig(id); } catch (_) {}
609
633
  }
610
- if (!valid.length) return;
611
- try { return orig(...valid); } catch (_) { try { return orig(valid); } catch (_) {} }
612
634
  };
613
635
  } catch (_) {}
614
636
  };
615
- install();
616
- if (!window.__nbbEzPatchedV22) {
637
+ apply();
638
+ if (!window.__nbbEzPatched) {
617
639
  window.ezstandalone = window.ezstandalone || {};
618
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(install);
640
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
619
641
  }
620
642
  }
621
643
 
622
- function healFalseEmpty(root) {
623
- try {
624
- const list = [];
625
- if (root instanceof Element && root.classList.contains(WRAP_CLASS)) list.push(root);
626
- if (root?.querySelectorAll) {
627
- for (const w of root.querySelectorAll(`.${WRAP_CLASS}.is-empty`)) list.push(w);
628
- }
629
- for (const w of list) {
630
- if (w.classList.contains('is-empty') && isFilled(w)) w.classList.remove('is-empty');
644
+ // ── Core ───────────────────────────────────────────────────────────────────
645
+
646
+ async function runCore() {
647
+ if (isBlocked()) return 0;
648
+ patchShowAds();
649
+
650
+ const cfg = await fetchConfig();
651
+ if (!cfg || cfg.excluded) return 0;
652
+ initPools(cfg);
653
+
654
+ const kind = getKind();
655
+ if (kind === 'other') return 0;
656
+
657
+ const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
658
+ if (!normBool(cfgEnable)) return 0;
659
+ const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
660
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
661
+ };
662
+
663
+ if (kind === 'topic') return exec(
664
+ 'ezoic-ad-message', getPosts,
665
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
666
+ );
667
+
668
+ if (kind === 'categoryTopics') {
669
+ pruneOrphansBetween();
670
+ return exec(
671
+ 'ezoic-ad-between', getTopics,
672
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
673
+ );
674
+ }
675
+
676
+ return exec(
677
+ 'ezoic-ad-categories', getCategories,
678
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
679
+ );
680
+ }
681
+
682
+ // ── Scheduler ──────────────────────────────────────────────────────────────
683
+
684
+ function scheduleRun(cb) {
685
+ if (S.runQueued) return;
686
+ S.runQueued = true;
687
+ requestAnimationFrame(async () => {
688
+ S.runQueued = false;
689
+ if (S.pageKey && pageKey() !== S.pageKey) return;
690
+ let n = 0;
691
+ try { n = await runCore(); } catch (_) {}
692
+ try { cb?.(n); } catch (_) {}
693
+ });
694
+ }
695
+
696
+ function requestBurst() {
697
+ if (isBlocked()) return;
698
+ const t = ts();
699
+ if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
700
+ S.lastBurstTs = t;
701
+ S.pageKey = pageKey();
702
+ S.burstDeadline = t + 2000;
703
+
704
+ if (S.burstActive) return;
705
+ S.burstActive = true;
706
+ S.burstCount = 0;
707
+
708
+ const step = () => {
709
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
710
+ S.burstActive = false; return;
631
711
  }
632
- } catch (_) {}
712
+ S.burstCount++;
713
+ scheduleRun(n => {
714
+ if (!n && !S.pending.length) { S.burstActive = false; return; }
715
+ setTimeout(step, n > 0 ? 150 : 300);
716
+ });
717
+ };
718
+ step();
719
+ }
720
+
721
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
722
+
723
+ function cleanup() {
724
+ blockedUntil = ts() + 1500;
725
+ mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
726
+ S.cfg = null;
727
+ S.poolsReady = false;
728
+ S.pools = { topics: [], posts: [], categories: [] };
729
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
730
+ S.mountedIds.clear();
731
+ S.lastShow.clear();
732
+ S.wrapByKey.clear();
733
+ S.wrapsByClass.clear();
734
+ S.kind = null;
735
+ S.inflight = 0;
736
+ S.pending = [];
737
+ S.pendingSet.clear();
738
+ S.burstActive = false;
739
+ S.runQueued = false;
740
+ S.firstShown = false;
633
741
  }
634
742
 
635
- function ensureObserver() {
743
+ // ── MutationObserver ───────────────────────────────────────────────────────
744
+
745
+ function ensureDomObserver() {
636
746
  if (S.domObs) return;
637
747
  S.domObs = new MutationObserver(muts => {
638
- if (S.muting > 0 || isBlocked()) return;
639
- let shouldRun = false;
748
+ if (S.mutGuard > 0 || isBlocked()) return;
749
+ const relevant = (() => {
750
+ const k = getKind();
751
+ if (k === 'topic') return [SEL.post];
752
+ if (k === 'categoryTopics') return [SEL.topic];
753
+ if (k === 'categories') return [SEL.category];
754
+ return [SEL.post, SEL.topic, SEL.category];
755
+ })();
640
756
  for (const m of muts) {
641
757
  for (const n of m.addedNodes) {
642
- if (!(n instanceof Element)) continue;
643
- healFalseEmpty(n);
644
- const wrap = n.classList?.contains(WRAP_CLASS) ? n : n.querySelector?.(`.${WRAP_CLASS}`);
645
- if (wrap) {
646
- const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
647
- if (Number.isFinite(id)) {
648
- const rec = ensureRegistry(id, wrap);
649
- rec.lastMutationAt = now();
650
- classifyWrap(rec);
651
- }
652
- }
653
- if (n.matches?.(SEL.post) || n.matches?.(SEL.topic) || n.matches?.(SEL.category) ||
654
- n.querySelector?.(SEL.post) || n.querySelector?.(SEL.topic) || n.querySelector?.(SEL.category)) {
655
- shouldRun = true;
758
+ if (n.nodeType !== 1) continue;
759
+ // matches() d'abord (O(1)), querySelector() seulement si nécessaire
760
+ if (relevant.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
761
+ relevant.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
762
+ requestBurst(); return;
656
763
  }
657
764
  }
658
- for (const n of m.removedNodes) {
659
- if (!(n instanceof Element)) continue;
660
- if (n.classList?.contains(WRAP_CLASS) || n.querySelector?.(`.${WRAP_CLASS}`)) {
661
- shouldRun = true;
662
- }
663
- }
664
- }
665
- if (shouldRun) {
666
- scheduleRun();
667
- scheduleMaintenance();
668
765
  }
669
766
  });
670
767
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
671
768
  }
672
769
 
770
+ // ── Utilitaires ────────────────────────────────────────────────────────────
771
+
772
+ function muteConsole() {
773
+ if (window.__nbbEzMuted) return;
774
+ window.__nbbEzMuted = true;
775
+ const MUTED = [
776
+ '[EzoicAds JS]: Placeholder Id',
777
+ 'No valid placeholders for loadMore',
778
+ 'cannot call refresh on the same page',
779
+ 'no placeholders are currently defined in Refresh',
780
+ 'Debugger iframe already exists',
781
+ `with id ${PH_PREFIX}`,
782
+ ];
783
+ for (const m of ['log', 'info', 'warn', 'error']) {
784
+ const orig = console[m];
785
+ if (typeof orig !== 'function') continue;
786
+ console[m] = function (...a) {
787
+ if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
788
+ orig.apply(console, a);
789
+ };
790
+ }
791
+ }
792
+
673
793
  function ensureTcfLocator() {
674
794
  try {
675
795
  if (!window.__tcfapi && !window.__cmp) return;
676
796
  const inject = () => {
677
797
  if (document.getElementById('__tcfapiLocator')) return;
678
798
  const f = document.createElement('iframe');
679
- f.style.display = 'none';
680
- f.id = f.name = '__tcfapiLocator';
799
+ f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
681
800
  (document.body || document.documentElement).appendChild(f);
682
801
  };
683
802
  inject();
684
- if (!window.__nbbTcfObsV22) {
685
- window.__nbbTcfObsV22 = new MutationObserver(inject);
686
- window.__nbbTcfObsV22.observe(document.documentElement, { childList: true, subtree: true });
803
+ if (!window.__nbbTcfObs) {
804
+ window.__nbbTcfObs = new MutationObserver(inject);
805
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
687
806
  }
688
807
  } catch (_) {}
689
808
  }
690
809
 
691
- const warmed = new Set();
810
+ const _warmed = new Set();
692
811
  function warmNetwork() {
693
812
  const head = document.head;
694
813
  if (!head) return;
695
- const frag = document.createDocumentFragment();
696
- const defs = [
697
- ['preconnect', 'https://g.ezoic.net', true],
698
- ['preconnect', 'https://go.ezoic.net', true],
699
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
700
- ['dns-prefetch', 'https://g.ezoic.net', false],
814
+ for (const [rel, href, cors] of [
815
+ ['preconnect', 'https://g.ezoic.net', true ],
816
+ ['preconnect', 'https://go.ezoic.net', true ],
817
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
818
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
819
+ ['dns-prefetch', 'https://g.ezoic.net', false],
701
820
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
702
- ];
703
- for (const [rel, href, cors] of defs) {
704
- const key = `${rel}|${href}`;
705
- if (warmed.has(key)) continue;
706
- warmed.add(key);
821
+ ]) {
822
+ const k = `${rel}|${href}`;
823
+ if (_warmed.has(k)) continue;
824
+ _warmed.add(k);
825
+ if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
707
826
  const l = document.createElement('link');
708
827
  l.rel = rel; l.href = href;
709
828
  if (cors) l.crossOrigin = 'anonymous';
710
- frag.appendChild(l);
829
+ head.appendChild(l);
711
830
  }
712
- head.appendChild(frag);
713
831
  }
714
832
 
715
- async function runCore() {
716
- if (isBlocked()) return 0;
717
- patchShowAds();
718
- const cfg = await fetchConfig();
719
- if (!cfg || cfg.excluded) return 0;
720
- initPools(cfg);
721
-
722
- const kind = getKind();
723
- if (kind === 'other') return 0;
724
-
725
- if (kind === 'topic') {
726
- if (!normBool(cfg.enableMessageAds)) return 0;
727
- const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
728
- return injectFor('ezoic-ad-message', getPosts(), interval, normBool(cfg.showFirstMessageAd));
729
- }
730
-
731
- if (kind === 'categoryTopics') {
732
- if (!normBool(cfg.enableBetweenAds)) return 0;
733
- const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 3);
734
- return injectFor('ezoic-ad-between', getTopics(), interval, normBool(cfg.showFirstTopicAd));
735
- }
736
-
737
- if (!normBool(cfg.enableCategoryAds)) return 0;
738
- const interval = Math.max(1, parseInt(cfg.intervalCategories, 10) || 3);
739
- return injectFor('ezoic-ad-categories', getCategories(), interval, normBool(cfg.showFirstCategoryAd));
740
- }
741
-
742
- function scheduleRun() {
743
- if (isBlocked()) return;
744
- if (S.runTimer) return;
745
- S.runTimer = setTimeout(async () => {
746
- S.runTimer = 0;
747
- const pk = pageKey();
748
- if (S.pageKey && S.pageKey !== pk) return;
749
- try { await runCore(); } catch (_) {}
750
- scheduleMaintenance();
751
- }, CFG.runDebounceMs);
752
- }
753
-
754
- function cleanup() {
755
- S.blockedUntil = now() + 1500;
756
- if (S.runTimer) { clearTimeout(S.runTimer); S.runTimer = 0; }
757
- if (S.showTimer) { clearTimeout(S.showTimer); S.showTimer = 0; }
758
- if (S.maintenanceTimer) { clearTimeout(S.maintenanceTimer); S.maintenanceTimer = 0; }
759
- mutate(() => {
760
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) releaseWrap(w);
761
- });
762
- S.cfg = null;
763
- S.poolsReady = false;
764
- S.pools = { topics: [], posts: [], categories: [] };
765
- S.cursors = { topics: 0, posts: 0, categories: 0 };
766
- S.mountedIds.clear();
767
- S.wrapByKey.clear();
768
- S.regById.clear();
769
- S.pendingShow = [];
770
- S.pendingSet.clear();
771
- S.lastShowById.clear();
772
- S.inflightShow = 0;
773
- }
833
+ // ── Bindings ───────────────────────────────────────────────────────────────
774
834
 
775
835
  function bindNodeBB() {
776
836
  const $ = window.jQuery;
777
837
  if (!$) return;
778
- $(window).off('.nbbEzoicV22');
779
- $(window).on('action:ajaxify.start.nbbEzoicV22', cleanup);
780
- $(window).on('action:ajaxify.end.nbbEzoicV22', () => {
781
- S.pageKey = pageKey();
782
- S.blockedUntil = 0;
783
- ensureTcfLocator();
784
- warmNetwork();
785
- patchShowAds();
786
- getIO();
787
- ensureObserver();
788
- scheduleRun();
838
+
839
+ $(window).off('.nbbEzoic');
840
+ $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
841
+ $(window).on('action:ajaxify.end.nbbEzoic', () => {
842
+ S.pageKey = pageKey();
843
+ S.kind = null;
844
+ blockedUntil = 0;
845
+ muteConsole(); ensureTcfLocator(); warmNetwork();
846
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
789
847
  });
790
- const events = [
848
+
849
+ const burstEvts = [
791
850
  'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
792
851
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
793
- ];
794
- for (const ev of events) {
795
- $(window).on(`${ev}.nbbEzoicV22`, () => { if (!isBlocked()) scheduleRun(); });
796
- }
852
+ ].map(e => `${e}.nbbEzoic`).join(' ');
853
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
854
+
797
855
  try {
798
856
  require(['hooks'], hooks => {
799
857
  if (typeof hooks?.on !== 'function') return;
800
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:topic.loaded']) {
801
- try { hooks.on(ev, () => { if (!isBlocked()) scheduleRun(); }); } catch (_) {}
858
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
859
+ 'action:categories.loaded', 'action:topic.loaded']) {
860
+ try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
802
861
  }
803
862
  });
804
863
  } catch (_) {}
@@ -809,24 +868,22 @@
809
868
  window.addEventListener('scroll', () => {
810
869
  if (ticking) return;
811
870
  ticking = true;
812
- requestAnimationFrame(() => {
813
- ticking = false;
814
- if (!isBlocked()) {
815
- scheduleMaintenance();
816
- scheduleRun();
817
- }
818
- });
871
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
819
872
  }, { passive: true });
820
873
  }
821
874
 
875
+ // ── Boot ───────────────────────────────────────────────────────────────────
876
+
822
877
  S.pageKey = pageKey();
878
+ muteConsole();
823
879
  ensureTcfLocator();
824
880
  warmNetwork();
825
881
  patchShowAds();
826
882
  getIO();
827
- ensureObserver();
883
+ ensureDomObserver();
828
884
  bindNodeBB();
829
885
  bindScroll();
830
- S.blockedUntil = 0;
831
- scheduleRun();
886
+ blockedUntil = 0;
887
+ requestBurst();
888
+
832
889
  })();