nodebb-plugin-ezoic-infinite 1.8.68 → 1.8.70

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,45 +1,90 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.4.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
3
3
  *
4
- * Architecture: proven v50 core + targeted improvements.
5
- * Ezoic API: showAds() + destroyPlaceholders() per official docs.
6
- * Key features: O(1) recycle via wrapsByClass, MutationObserver fill detect,
7
- * conservative empty check, aria-hidden + TCF protection, retry boot for
8
- * Cloudflare/async timing.
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.
9
67
  */
10
68
  (function nbbEzoicInfinite() {
11
69
  'use strict';
12
70
 
13
- // ── Constants ──────────────────────────────────────────────────────────────
14
-
15
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
16
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
71
+ // ── Constantes ─────────────────────────────────────────────────────────────
17
72
 
18
- const ATTR = {
19
- ANCHOR: 'data-ezoic-anchor',
20
- WRAPID: 'data-ezoic-wrapid',
21
- CREATED: 'data-ezoic-created',
22
- SHOWN: 'data-ezoic-shown',
23
- };
24
-
25
- const TIMING = {
26
- EMPTY_CHECK_MS_1: 30_000,
27
- EMPTY_CHECK_MS_2: 60_000,
28
- MIN_PRUNE_AGE_MS: 8_000,
29
- RECYCLE_MIN_AGE_MS: 5_000,
30
- SHOW_THROTTLE_MS: 900,
31
- BURST_COOLDOWN_MS: 200,
32
- BLOCK_DURATION_MS: 1_500,
33
- SHOW_TIMEOUT_MS: 7_000,
34
- SHOW_RELEASE_MS: 700,
35
- RECYCLE_DELAY_MS: 450,
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
37
79
 
38
- const MAX_INSERTS_RUN = 6;
39
- const MAX_INFLIGHT = 4;
40
- const MAX_BURST_STEPS = 8;
41
- const BURST_WINDOW_MS = 2_000;
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
42
86
 
87
+ // Marges IO larges et fixes — observer créé une seule fois au boot
43
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
44
89
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
45
90
 
@@ -49,50 +94,39 @@
49
94
  category: 'li[component="categories/category"]',
50
95
  };
51
96
 
97
+ /**
98
+ * Table KIND — source de vérité par kindClass.
99
+ *
100
+ * sel sélecteur CSS complet des éléments cibles
101
+ * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
103
+ * anchorAttr attribut DOM stable → clé unique du wrap
104
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
+ * null → fallback positionnel (catégories)
106
+ */
52
107
  const KIND = {
53
108
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
54
109
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
55
110
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
56
111
  };
57
112
 
58
- const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
59
-
60
- // ── Utility ────────────────────────────────────────────────────────────────
61
-
62
- const now = () => Date.now();
63
- const isMobile = () => window.innerWidth < 768;
64
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
65
- const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
66
-
67
- function parseIds(raw) {
68
- const out = [], seen = new Set();
69
- for (const v of String(raw || '').split(/[\r\n,\s]+/)) {
70
- const n = parseInt(v, 10);
71
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
72
- }
73
- return out;
74
- }
75
-
76
- // ── State ──────────────────────────────────────────────────────────────────
113
+ // ── État global ────────────────────────────────────────────────────────────
77
114
 
78
115
  const S = {
79
116
  pageKey: null,
80
- kind: null,
81
117
  cfg: null,
82
118
  poolsReady: false,
83
119
  pools: { topics: [], posts: [], categories: [] },
84
120
  cursors: { topics: 0, posts: 0, categories: 0 },
85
121
  mountedIds: new Set(),
86
122
  lastShow: new Map(),
87
- wrapByKey: new Map(),
88
- wrapsByClass: new Map(),
89
123
  io: null,
90
124
  domObs: null,
91
- mutGuard: 0,
92
- blockedUntil: 0,
93
- inflight: 0,
94
- pending: [],
125
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
126
+ inflight: 0, // showAds() en cours
127
+ pending: [], // ids en attente de slot inflight
95
128
  pendingSet: new Set(),
129
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
96
130
  runQueued: false,
97
131
  burstActive: false,
98
132
  burstDeadline: 0,
@@ -100,7 +134,13 @@
100
134
  lastBurstTs: 0,
101
135
  };
102
136
 
103
- const isBlocked = () => now() < S.blockedUntil;
137
+ let blockedUntil = 0;
138
+
139
+ const ts = () => Date.now();
140
+ const isBlocked = () => ts() < blockedUntil;
141
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
142
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
143
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
104
144
 
105
145
  function mutate(fn) {
106
146
  S.mutGuard++;
@@ -111,10 +151,6 @@
111
151
 
112
152
  async function fetchConfig() {
113
153
  if (S.cfg) return S.cfg;
114
- try {
115
- const inline = window.__nbbEzoicCfg;
116
- if (inline && typeof inline === 'object') { S.cfg = inline; return S.cfg; }
117
- } catch (_) {}
118
154
  try {
119
155
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
120
156
  if (r.ok) S.cfg = await r.json();
@@ -122,6 +158,15 @@
122
158
  return S.cfg;
123
159
  }
124
160
 
161
+ function parseIds(raw) {
162
+ const out = [], seen = new Set();
163
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
164
+ const n = parseInt(v, 10);
165
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
166
+ }
167
+ return out;
168
+ }
169
+
125
170
  function initPools(cfg) {
126
171
  if (S.poolsReady) return;
127
172
  S.pools.topics = parseIds(cfg.placeholderIds);
@@ -141,7 +186,7 @@
141
186
  return location.pathname;
142
187
  }
143
188
 
144
- function detectKind() {
189
+ function getKind() {
145
190
  const p = location.pathname;
146
191
  if (/^\/topic\//.test(p)) return 'topic';
147
192
  if (/^\/category\//.test(p)) return 'categoryTopics';
@@ -152,213 +197,181 @@
152
197
  return 'other';
153
198
  }
154
199
 
155
- function getKind() {
156
- return S.kind || (S.kind = detectKind());
157
- }
158
-
159
- // ── DOM queries ────────────────────────────────────────────────────────────
200
+ // ── Items DOM ──────────────────────────────────────────────────────────────
160
201
 
161
202
  function getPosts() {
162
- const all = document.querySelectorAll(SEL.post);
163
- const out = [];
164
- for (let i = 0; i < all.length; i++) {
165
- const el = all[i];
166
- if (!el.isConnected) continue;
167
- if (!el.querySelector('[component="post/content"]')) continue;
168
- const parent = el.parentElement?.closest(SEL.post);
169
- if (parent && parent !== el) continue;
170
- if (el.getAttribute('component') === 'post/parent') continue;
171
- out.push(el);
172
- }
173
- return out;
203
+ return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
204
+ if (!el.isConnected) return false;
205
+ if (!el.querySelector('[component="post/content"]')) return false;
206
+ const p = el.parentElement?.closest(SEL.post);
207
+ if (p && p !== el) return false;
208
+ return el.getAttribute('component') !== 'post/parent';
209
+ });
174
210
  }
175
211
 
176
212
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
177
213
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
178
214
 
179
- // ── Anchor keys & wrap registry ────────────────────────────────────────────
180
-
181
- function stableId(klass, el) {
182
- const attr = KIND[klass]?.anchorAttr;
183
- if (attr) {
184
- const v = el.getAttribute(attr);
185
- if (v != null && v !== '') return v;
186
- }
187
- const children = el.parentElement?.children;
188
- if (!children) return 'i0';
189
- for (let i = 0; i < children.length; i++) {
190
- if (children[i] === el) return `i${i}`;
191
- }
192
- return 'i0';
193
- }
194
-
195
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
196
-
197
- function findWrap(key) {
198
- const w = S.wrapByKey.get(key);
199
- return w?.isConnected ? w : null;
200
- }
201
-
202
- function getWrapSet(klass) {
203
- let set = S.wrapsByClass.get(klass);
204
- if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
205
- return set;
206
- }
207
-
208
- // ── GC disconnected wraps ──────────────────────────────────────────────────
209
-
210
- function gcDisconnectedWraps() {
211
- for (const [key, w] of Array.from(S.wrapByKey)) {
212
- if (!w?.isConnected) S.wrapByKey.delete(key);
213
- }
214
- for (const [klass, set] of Array.from(S.wrapsByClass)) {
215
- for (const w of Array.from(set)) {
216
- if (w?.isConnected) continue;
217
- set.delete(w);
218
- const id = parseInt(w.getAttribute?.(ATTR.WRAPID), 10);
219
- if (Number.isFinite(id)) {
220
- S.mountedIds.delete(id);
221
- S.lastShow.delete(id);
222
- }
223
- }
224
- if (!set.size) S.wrapsByClass.delete(klass);
225
- }
226
- }
227
-
228
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
215
+ // ── Wraps détection ──────────────────────────────────────────────────────
229
216
 
217
+ /**
218
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
219
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
220
+ */
230
221
  function wrapIsLive(wrap) {
231
222
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
232
- const key = wrap.getAttribute(ATTR.ANCHOR);
223
+ const key = wrap.getAttribute(A_ANCHOR);
233
224
  if (!key) return false;
225
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
226
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
234
227
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
235
229
  const colonIdx = key.indexOf(':');
236
230
  const klass = key.slice(0, colonIdx);
237
231
  const anchorId = key.slice(colonIdx + 1);
238
232
  const cfg = KIND[klass];
239
233
  if (!cfg) return false;
234
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
235
+ // de querySelector global — on cherche parmi les voisins immédiats.
240
236
  const parent = wrap.parentElement;
241
237
  if (parent) {
242
- const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
243
238
  for (const sib of parent.children) {
244
- if (sib !== wrap) {
245
- try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
246
- }
239
+ if (sib === wrap) continue;
240
+ try {
241
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
242
+ return sib.isConnected;
243
+ }
244
+ } catch (_) {}
247
245
  }
248
246
  }
247
+ // Dernier recours : querySelector global
249
248
  try {
250
- return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
249
+ const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
250
+ return !!(found?.isConnected);
251
251
  } catch (_) { return false; }
252
252
  }
253
253
 
254
- const adjacentWrap = el => wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
254
+ function adjacentWrap(el) {
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
+ }
255
257
 
256
- // ── Fill detection ─────────────────────────────────────────────────────────
258
+ // ── Ancres stables ─────────────────────────────────────────────────────────
257
259
 
258
- function clearEmptyIfFilled(wrap) {
259
- if (!wrap?.isConnected) return false;
260
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
261
- if (!ph || !isFilled(ph)) return false;
262
- wrap.classList.remove('is-empty');
263
- return true;
260
+ /**
261
+ * Retourne la valeur de l'attribut stable pour cet élément,
262
+ * ou un fallback positionnel si l'attribut est absent.
263
+ */
264
+ function stableId(klass, el) {
265
+ const attr = KIND[klass]?.anchorAttr;
266
+ if (attr) {
267
+ const v = el.getAttribute(attr);
268
+ if (v !== null && v !== '') return v;
269
+ }
270
+ let i = 0;
271
+ for (const s of el.parentElement?.children ?? []) {
272
+ if (s === el) return `i${i}`;
273
+ i++;
274
+ }
275
+ return 'i0';
264
276
  }
265
277
 
266
- function scheduleUncollapseChecks(wrap) {
267
- if (!wrap) return;
268
- for (const ms of [500, 3000, 10000]) {
269
- setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
270
- }
278
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
279
+
280
+ function findWrap(key) {
281
+ const w = S.wrapByKey.get(key);
282
+ return (w?.isConnected) ? w : null;
271
283
  }
272
284
 
273
- // ── Pool management ────────────────────────────────────────────────────────
285
+ // ── Pool ───────────────────────────────────────────────────────────────────
274
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
275
291
  function pickId(poolKey) {
276
292
  const pool = S.pools[poolKey];
277
293
  if (!pool.length) return null;
278
294
  for (let t = 0; t < pool.length; t++) {
279
- const idx = S.cursors[poolKey] % pool.length;
295
+ const i = S.cursors[poolKey] % pool.length;
280
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
281
- const id = pool[idx];
297
+ const id = pool[i];
282
298
  if (!S.mountedIds.has(id)) return id;
283
299
  }
284
300
  return null;
285
301
  }
286
302
 
287
- // ── Recycling ──────────────────────────────────────────────────────────────
288
-
289
- function recycleWrap(klass, targetEl, newKey) {
303
+ /**
304
+ * Pool épuisé : recycle un wrap loin au-dessus du viewport.
305
+ * Séquence avec délais (destroyPlaceholders est asynchrone) :
306
+ * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
307
+ * displayMore = API Ezoic prévue pour l'infinite scroll.
308
+ * Priorité : wraps vides d'abord, remplis si nécessaire.
309
+ */
310
+ function recycleAndMove(klass, targetEl, newKey) {
290
311
  const ez = window.ezstandalone;
291
312
  if (typeof ez?.destroyPlaceholders !== 'function' ||
292
- typeof ez?.showAds !== 'function') return null;
293
-
294
- const threshold = -(3 * (window.innerHeight || 800));
295
- const t = now();
296
- let bestEmpty = null, bestEmptyY = Infinity;
297
- let bestFull = null, bestFullY = Infinity;
313
+ typeof ez?.define !== 'function' ||
314
+ typeof ez?.displayMore !== 'function') return null;
298
315
 
299
- const wraps = S.wrapsByClass.get(klass);
300
- if (!wraps) return null;
316
+ const vh = window.innerHeight || 800;
317
+ // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
318
+ // après pour neutraliser l'IO — plus de showAds parasite possible.
319
+ const threshold = -vh;
320
+ let bestEmpty = null, bestEmptyBottom = Infinity;
321
+ let bestFilled = null, bestFilledBottom = Infinity;
301
322
 
302
- for (const wrap of wraps) {
323
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
303
324
  try {
304
- const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
305
- if (t - created < TIMING.RECYCLE_MIN_AGE_MS) continue;
306
- const bottom = wrap.getBoundingClientRect().bottom;
307
- if (bottom > threshold) continue;
325
+ const rect = wrap.getBoundingClientRect();
326
+ if (rect.bottom > threshold) return;
308
327
  if (!isFilled(wrap)) {
309
- if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
328
+ if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
310
329
  } else {
311
- if (bottom < bestFullY) { bestFullY = bottom; bestFull = wrap; }
330
+ if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
312
331
  }
313
332
  } catch (_) {}
314
- }
333
+ });
315
334
 
316
- const best = bestEmpty ?? bestFull;
335
+ const best = bestEmpty ?? bestFilled;
317
336
  if (!best) return null;
318
- const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
337
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
319
338
  if (!Number.isFinite(id)) return null;
320
- const oldKey = best.getAttribute(ATTR.ANCHOR);
321
339
 
322
- try {
340
+ const oldKey = best.getAttribute(A_ANCHOR);
341
+ // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
342
+ // parasite si le nœud était encore dans la zone IO_MARGIN.
343
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
344
+ mutate(() => {
345
+ best.setAttribute(A_ANCHOR, newKey);
346
+ best.setAttribute(A_CREATED, String(ts()));
347
+ best.setAttribute(A_SHOWN, '0');
348
+ best.classList.remove('is-empty');
323
349
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
324
- if (ph) S.io?.unobserve(ph);
325
- } catch (_) {}
350
+ if (ph) ph.innerHTML = '';
351
+ targetEl.insertAdjacentElement('afterend', best);
352
+ });
353
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
354
+ S.wrapByKey.set(newKey, best);
326
355
 
327
- const doRecycle = () => {
328
- try { ez.destroyPlaceholders(id); } catch (_) {}
329
- setTimeout(() => {
330
- mutate(() => {
331
- best.setAttribute(ATTR.ANCHOR, newKey);
332
- best.setAttribute(ATTR.CREATED, String(now()));
333
- best.setAttribute(ATTR.SHOWN, '0');
334
- best.classList.remove('is-empty');
335
- best.replaceChildren();
336
- const fresh = document.createElement('div');
337
- fresh.id = `${PH_PREFIX}${id}`;
338
- fresh.setAttribute('data-ezoic-id', String(id));
339
- best.appendChild(fresh);
340
- targetEl.insertAdjacentElement('afterend', best);
341
- });
342
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
343
- S.wrapByKey.set(newKey, best);
344
- setTimeout(() => { observePh(id); enqueueShow(id); }, TIMING.RECYCLE_DELAY_MS);
345
- }, TIMING.RECYCLE_DELAY_MS);
346
- };
356
+ // Délais requis : destroyPlaceholders est asynchrone en interne
357
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
359
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
360
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
347
361
 
348
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doRecycle) : doRecycle(); } catch (_) {}
349
362
  return { id, wrap: best };
350
363
  }
351
364
 
352
- // ── Wrap DOM operations ────────────────────────────────────────────────────
365
+ // ── Wraps DOM création / suppression ────────────────────────────────────
353
366
 
354
367
  function makeWrap(id, klass, key) {
355
368
  const w = document.createElement('div');
356
369
  w.className = `${WRAP_CLASS} ${klass}`;
357
- w.setAttribute(ATTR.ANCHOR, key);
358
- w.setAttribute(ATTR.WRAPID, String(id));
359
- w.setAttribute(ATTR.CREATED, String(now()));
360
- w.setAttribute(ATTR.SHOWN, '0');
361
- w.style.cssText = 'width:100%;display:block';
370
+ w.setAttribute(A_ANCHOR, key);
371
+ w.setAttribute(A_WRAPID, String(id));
372
+ w.setAttribute(A_CREATED, String(ts()));
373
+ w.setAttribute(A_SHOWN, '0');
374
+ w.style.cssText = 'width:100%;display:block;';
362
375
  const ph = document.createElement('div');
363
376
  ph.id = `${PH_PREFIX}${id}`;
364
377
  ph.setAttribute('data-ezoic-id', String(id));
@@ -367,15 +380,14 @@
367
380
  }
368
381
 
369
382
  function insertAfter(el, id, klass, key) {
370
- if (!el?.insertAdjacentElement) return null;
371
- if (findWrap(key)) return null;
372
- if (S.mountedIds.has(id)) return null;
383
+ if (!el?.insertAdjacentElement) return null;
384
+ if (findWrap(key)) return null;
385
+ if (S.mountedIds.has(id)) return null;
373
386
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
374
387
  const w = makeWrap(id, klass, key);
375
388
  mutate(() => el.insertAdjacentElement('afterend', w));
376
389
  S.mountedIds.add(id);
377
390
  S.wrapByKey.set(key, w);
378
- getWrapSet(klass).add(w);
379
391
  return w;
380
392
  }
381
393
 
@@ -383,58 +395,63 @@
383
395
  try {
384
396
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
385
397
  if (ph instanceof Element) S.io?.unobserve(ph);
386
- const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
387
- if (Number.isFinite(id)) {
388
- S.mountedIds.delete(id);
389
- S.lastShow.delete(id);
390
- }
391
- const key = w.getAttribute(ATTR.ANCHOR);
398
+ const id = parseInt(w.getAttribute(A_WRAPID), 10);
399
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
400
+ const key = w.getAttribute(A_ANCHOR);
392
401
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
393
- for (const cls of w.classList) {
394
- if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
395
- S.wrapsByClass.get(cls)?.delete(w);
396
- break;
397
- }
398
- }
399
402
  w.remove();
400
403
  } catch (_) {}
401
404
  }
402
405
 
403
- // ── Prune (category topic lists only) ──────────────────────────────────────
406
+ // ── Prune (topics de catégorie uniquement) ────────────────────────────────
407
+ //
408
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
409
+ //
410
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
411
+ // les li[component="category/topic"] restent dans le DOM pendant toute
412
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
413
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
414
+ // liste après un long scroll et bloquent les nouvelles injections.
415
+ //
416
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
417
+ // NodeBB virtualise les posts hors-viewport — il les retire puis les
418
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
419
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
404
420
 
405
421
  function pruneOrphansBetween() {
406
422
  const klass = 'ezoic-ad-between';
407
423
  const cfg = KIND[klass];
408
- const wraps = S.wrapsByClass.get(klass);
409
- if (!wraps?.size) return;
410
- const liveAnchors = new Set();
411
- for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
412
- const v = el.getAttribute(cfg.anchorAttr);
413
- if (v) liveAnchors.add(v);
414
- }
415
- const t = now();
416
- for (const w of Array.from(wraps)) {
417
- const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
418
- if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
419
- const key = w.getAttribute(ATTR.ANCHOR) ?? '';
420
- const sid = key.slice(klass.length + 1);
421
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
422
- }
424
+
425
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
426
+ const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
427
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
428
+
429
+ const key = w.getAttribute(A_ANCHOR) ?? '';
430
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
431
+ if (!sid) { mutate(() => dropWrap(w)); return; }
432
+
433
+ const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
434
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
435
+ });
423
436
  }
424
437
 
425
438
  // ── Injection ──────────────────────────────────────────────────────────────
426
439
 
440
+ /**
441
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
442
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
443
+ */
427
444
  function ordinal(klass, el) {
428
445
  const attr = KIND[klass]?.ordinalAttr;
429
446
  if (attr) {
430
447
  const v = el.getAttribute(attr);
431
- if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
448
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
432
449
  }
433
450
  const fullSel = KIND[klass]?.sel ?? '';
434
451
  let i = 0;
435
452
  for (const s of el.parentElement?.children ?? []) {
436
453
  if (s === el) return i;
437
- try { if (!fullSel || s.matches(fullSel)) i++; } catch (_) {}
454
+ if (!fullSel || s.matches?.(fullSel)) i++;
438
455
  }
439
456
  return 0;
440
457
  }
@@ -442,27 +459,32 @@
442
459
  function injectBetween(klass, items, interval, showFirst, poolKey) {
443
460
  if (!items.length) return 0;
444
461
  let inserted = 0;
462
+
445
463
  for (const el of items) {
446
464
  if (inserted >= MAX_INSERTS_RUN) break;
447
465
  if (!el?.isConnected) continue;
466
+
448
467
  const ord = ordinal(klass, el);
449
468
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
450
469
  if (adjacentWrap(el)) continue;
470
+
451
471
  const key = anchorKey(klass, el);
452
472
  if (findWrap(key)) continue;
473
+
453
474
  const id = pickId(poolKey);
454
475
  if (id) {
455
476
  const w = insertAfter(el, id, klass, key);
456
477
  if (w) { observePh(id); inserted++; }
457
478
  } else {
458
- if (!recycleWrap(klass, el, key)) break;
479
+ const recycled = recycleAndMove(klass, el, key);
480
+ if (!recycled) break;
459
481
  inserted++;
460
482
  }
461
483
  }
462
484
  return inserted;
463
485
  }
464
486
 
465
- // ── IntersectionObserver ───────────────────────────────────────────────────
487
+ // ── IntersectionObserver & Show ────────────────────────────────────────────
466
488
 
467
489
  function getIO() {
468
490
  if (S.io) return S.io;
@@ -472,13 +494,9 @@
472
494
  if (!e.isIntersecting) continue;
473
495
  if (e.target instanceof Element) S.io?.unobserve(e.target);
474
496
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
475
- if (id > 0) enqueueShow(id);
497
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
476
498
  }
477
- }, {
478
- root: null,
479
- rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP,
480
- threshold: 0,
481
- });
499
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
482
500
  } catch (_) { S.io = null; }
483
501
  return S.io;
484
502
  }
@@ -488,11 +506,9 @@
488
506
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
489
507
  }
490
508
 
491
- // ── Show queue ─────────────────────────────────────────────────────────────
492
-
493
509
  function enqueueShow(id) {
494
510
  if (!id || isBlocked()) return;
495
- if (now() - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
511
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
496
512
  if (S.inflight >= MAX_INFLIGHT) {
497
513
  if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
498
514
  return;
@@ -519,26 +535,26 @@
519
535
  S.inflight = Math.max(0, S.inflight - 1);
520
536
  drainQueue();
521
537
  };
522
- const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
538
+ const timer = setTimeout(release, 7000);
523
539
 
524
540
  requestAnimationFrame(() => {
525
541
  try {
526
542
  if (isBlocked()) { clearTimeout(timer); return release(); }
527
543
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
528
544
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
529
- const t = now();
530
- if (t - (S.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
545
+
546
+ const t = ts();
547
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
531
548
  S.lastShow.set(id, t);
532
- const wrap = ph.closest(`.${WRAP_CLASS}`);
533
- try { wrap?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
549
+
550
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
534
551
 
535
552
  window.ezstandalone = window.ezstandalone || {};
536
553
  const ez = window.ezstandalone;
537
554
  const doShow = () => {
538
555
  try { ez.showAds(id); } catch (_) {}
539
- if (wrap) scheduleUncollapseChecks(wrap);
540
556
  scheduleEmptyCheck(id, t);
541
- setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
557
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
542
558
  };
543
559
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
544
560
  } catch (_) { clearTimeout(timer); release(); }
@@ -546,23 +562,22 @@
546
562
  }
547
563
 
548
564
  function scheduleEmptyCheck(id, showTs) {
549
- for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
550
- setTimeout(() => {
551
- try {
552
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
553
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
554
- if (!wrap || !ph?.isConnected) return;
555
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
556
- if (clearEmptyIfFilled(wrap)) return;
557
- if (ph.querySelector('[id^="div-gpt-ad"]')) return;
558
- if (ph.offsetHeight > 10) return;
559
- wrap.classList.add('is-empty');
560
- } catch (_) {}
561
- }, delay);
562
- }
565
+ setTimeout(() => {
566
+ try {
567
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
568
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
569
+ if (!wrap || !ph?.isConnected) return;
570
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
571
+ wrap.classList.toggle('is-empty', !isFilled(ph));
572
+ } catch (_) {}
573
+ }, EMPTY_CHECK_MS);
563
574
  }
564
575
 
565
576
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
577
+ //
578
+ // Intercepte ez.showAds() pour :
579
+ // – ignorer les appels pendant blockedUntil
580
+ // – filtrer les ids dont le placeholder n'est pas en DOM
566
581
 
567
582
  function patchShowAds() {
568
583
  const apply = () => {
@@ -573,9 +588,8 @@
573
588
  window.__nbbEzPatched = true;
574
589
  const orig = ez.showAds.bind(ez);
575
590
  ez.showAds = function (...args) {
576
- if (!args.length) return orig();
577
591
  if (isBlocked()) return;
578
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
592
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
579
593
  const seen = new Set();
580
594
  for (const v of ids) {
581
595
  const id = parseInt(v, 10);
@@ -599,7 +613,6 @@
599
613
  async function runCore() {
600
614
  if (isBlocked()) return 0;
601
615
  patchShowAds();
602
- try { gcDisconnectedWraps(); } catch (_) {}
603
616
 
604
617
  const cfg = await fetchConfig();
605
618
  if (!cfg || cfg.excluded) return 0;
@@ -610,16 +623,27 @@
610
623
 
611
624
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
612
625
  if (!normBool(cfgEnable)) return 0;
613
- return injectBetween(klass, getItems(), Math.max(1, parseInt(cfgInterval, 10) || 3), normBool(cfgShowFirst), poolKey);
626
+ const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
627
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
614
628
  };
615
629
 
616
- if (kind === 'topic')
617
- return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
630
+ if (kind === 'topic') return exec(
631
+ 'ezoic-ad-message', getPosts,
632
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
633
+ );
634
+
618
635
  if (kind === 'categoryTopics') {
619
636
  pruneOrphansBetween();
620
- return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
637
+ return exec(
638
+ 'ezoic-ad-between', getTopics,
639
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
640
+ );
621
641
  }
622
- return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
642
+
643
+ return exec(
644
+ 'ezoic-ad-categories', getCategories,
645
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
646
+ );
623
647
  }
624
648
 
625
649
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -638,16 +662,18 @@
638
662
 
639
663
  function requestBurst() {
640
664
  if (isBlocked()) return;
641
- const t = now();
642
- if (t - S.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
665
+ const t = ts();
666
+ if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
643
667
  S.lastBurstTs = t;
644
668
  S.pageKey = pageKey();
645
- S.burstDeadline = t + BURST_WINDOW_MS;
669
+ S.burstDeadline = t + 2000;
670
+
646
671
  if (S.burstActive) return;
647
672
  S.burstActive = true;
648
673
  S.burstCount = 0;
674
+
649
675
  const step = () => {
650
- if (pageKey() !== S.pageKey || isBlocked() || now() > S.burstDeadline || S.burstCount >= MAX_BURST_STEPS) {
676
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
651
677
  S.burstActive = false; return;
652
678
  }
653
679
  S.burstCount++;
@@ -659,163 +685,88 @@
659
685
  step();
660
686
  }
661
687
 
662
- // ── Cleanup ────────────────────────────────────────────────────────────────
688
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
663
689
 
664
690
  function cleanup() {
665
- S.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
666
- mutate(() => {
667
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
668
- });
669
- S.cfg = null;
670
- S.poolsReady = false;
671
- S.pools = { topics: [], posts: [], categories: [] };
672
- S.cursors = { topics: 0, posts: 0, categories: 0 };
691
+ blockedUntil = ts() + 1500;
692
+ mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
693
+ S.cfg = null;
694
+ S.poolsReady = false;
695
+ S.pools = { topics: [], posts: [], categories: [] };
696
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
673
697
  S.mountedIds.clear();
674
698
  S.lastShow.clear();
675
699
  S.wrapByKey.clear();
676
- S.wrapsByClass.clear();
677
- S.kind = null;
678
- S.inflight = 0;
679
- S.pending = [];
700
+ S.inflight = 0;
701
+ S.pending = [];
680
702
  S.pendingSet.clear();
681
- S.burstActive = false;
682
- S.runQueued = false;
703
+ S.burstActive = false;
704
+ S.runQueued = false;
683
705
  }
684
706
 
685
707
  // ── MutationObserver ───────────────────────────────────────────────────────
686
708
 
687
709
  function ensureDomObserver() {
688
710
  if (S.domObs) return;
711
+ const allSel = [SEL.post, SEL.topic, SEL.category];
689
712
  S.domObs = new MutationObserver(muts => {
690
713
  if (S.mutGuard > 0 || isBlocked()) return;
691
- let needsBurst = false;
692
- const kind = getKind();
693
- const relevantSels =
694
- kind === 'topic' ? [SEL.post] :
695
- kind === 'categoryTopics' ? [SEL.topic] :
696
- kind === 'categories' ? [SEL.category] :
697
- [SEL.post, SEL.topic, SEL.category];
698
- outer:
699
714
  for (const m of muts) {
700
- if (m.type !== 'childList') continue;
701
- for (const node of m.removedNodes) {
702
- if (!(node instanceof Element)) continue;
703
- try {
704
- if (node.classList?.contains(WRAP_CLASS)) dropWrap(node);
705
- else { const ws = node.querySelectorAll?.(`.${WRAP_CLASS}`); if (ws?.length) for (const w of ws) dropWrap(w); }
706
- } catch (_) {}
707
- }
708
- for (const node of m.addedNodes) {
709
- if (!(node instanceof Element)) continue;
710
- try {
711
- if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
712
- const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) || m.target?.closest?.(`.${WRAP_CLASS}.is-empty`);
713
- if (wrap) clearEmptyIfFilled(wrap);
714
- }
715
- } catch (_) {}
716
- try {
717
- const reinserted = node.classList?.contains(WRAP_CLASS) ? [node] : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
718
- for (const wrap of reinserted) {
719
- const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
720
- if (id > 0) { const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`); if (ph) try { getIO()?.observe(ph); } catch (_) {} }
721
- }
722
- } catch (_) {}
723
- if (!needsBurst) {
724
- for (const sel of relevantSels) {
725
- try { if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break outer; } } catch (_) {}
726
- }
715
+ for (const n of m.addedNodes) {
716
+ if (n.nodeType !== 1) continue;
717
+ // matches() d'abord (O(1)), querySelector() seulement si nécessaire
718
+ if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
719
+ allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
720
+ requestBurst(); return;
727
721
  }
728
722
  }
729
723
  }
730
- if (needsBurst) requestBurst();
731
724
  });
732
725
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
733
726
  }
734
727
 
735
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
736
-
737
- function ensureTcfLocator() {
738
- if (!window.__tcfapi && !window.__cmp) return;
739
- const LOCATOR_ID = '__tcfapiLocator';
740
- const ensureInHead = () => {
741
- let el = document.getElementById(LOCATOR_ID);
742
- if (el) { if (el.parentElement !== document.head) try { document.head.appendChild(el); } catch (_) {} return; }
743
- const f = document.createElement('iframe');
744
- f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
745
- try { document.head.appendChild(f); } catch (_) { (document.body || document.documentElement).appendChild(f); }
746
- };
747
- ensureInHead();
748
- if (!window.__nbbCmpGuarded) {
749
- window.__nbbCmpGuarded = true;
750
- if (typeof window.__tcfapi === 'function') {
751
- const orig = window.__tcfapi;
752
- window.__tcfapi = function (cmd, ver, cb, param) {
753
- try { return orig.call(this, cmd, ver, function (...a) { try { cb?.(...a); } catch (_) {} }, param); }
754
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.call(this, cmd, ver, cb, param); } catch (_) {} } }
755
- };
756
- }
757
- if (typeof window.__cmp === 'function') {
758
- const orig = window.__cmp;
759
- window.__cmp = function (...a) {
760
- try { return orig.apply(this, a); }
761
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.apply(this, a); } catch (_) {} } }
762
- };
763
- }
764
- }
765
- if (!window.__nbbTcfObs) {
766
- window.__nbbTcfObs = new MutationObserver(() => { if (!document.getElementById(LOCATOR_ID)) ensureInHead(); });
767
- try { window.__nbbTcfObs.observe(document.body || document.documentElement, { childList: true, subtree: false }); } catch (_) {}
768
- try { if (document.head) window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false }); } catch (_) {}
769
- }
770
- }
771
-
772
- // ── aria-hidden protection ─────────────────────────────────────────────────
773
-
774
- function protectAriaHidden() {
775
- if (window.__nbbAriaObs) return;
776
- const fix = () => { try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {} };
777
- fix();
778
- window.__nbbAriaObs = new MutationObserver(fix);
779
- try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
780
- }
781
-
782
- // ── Console muting ─────────────────────────────────────────────────────────
728
+ // ── Utilitaires ────────────────────────────────────────────────────────────
783
729
 
784
730
  function muteConsole() {
785
731
  if (window.__nbbEzMuted) return;
786
732
  window.__nbbEzMuted = true;
787
733
  const MUTED = [
734
+ '[EzoicAds JS]: Placeholder Id',
735
+ 'No valid placeholders for loadMore',
736
+ 'cannot call refresh on the same page',
737
+ 'no placeholders are currently defined in Refresh',
738
+ 'Debugger iframe already exists',
788
739
  `with id ${PH_PREFIX}`,
789
- 'adsbygoogle.push() error',
790
- 'already been defined',
791
- 'bad response. Status',
792
- 'slotDestroyed',
793
- 'identity bridging',
794
- '[EzoicAds JS]: Placeholder',
795
- 'No valid placeholders',
796
- 'cannot call refresh',
797
- 'no placeholders are currently defined',
798
- 'Debugger iframe already',
799
- 'Error in custom getTCData',
800
- 'no interstitial API',
801
- 'JS-Enable should only',
802
740
  ];
803
- for (const method of ['log', 'info', 'warn', 'error']) {
804
- const orig = console[method];
741
+ for (const m of ['log', 'info', 'warn', 'error']) {
742
+ const orig = console[m];
805
743
  if (typeof orig !== 'function') continue;
806
- console[method] = function (...args) {
807
- if (typeof args[0] === 'string') { for (const p of MUTED) if (args[0].includes(p)) return; }
808
- return orig.apply(console, args);
744
+ console[m] = function (...a) {
745
+ if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
746
+ orig.apply(console, a);
809
747
  };
810
748
  }
811
749
  }
812
750
 
813
- // ── Network warmup ─────────────────────────────────────────────────────────
751
+ function ensureTcfLocator() {
752
+ try {
753
+ if (!window.__tcfapi && !window.__cmp) return;
754
+ const inject = () => {
755
+ if (document.getElementById('__tcfapiLocator')) return;
756
+ const f = document.createElement('iframe');
757
+ f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
758
+ (document.body || document.documentElement).appendChild(f);
759
+ };
760
+ inject();
761
+ if (!window.__nbbTcfObs) {
762
+ window.__nbbTcfObs = new MutationObserver(inject);
763
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
764
+ }
765
+ } catch (_) {}
766
+ }
814
767
 
815
- let _warmed = false;
768
+ const _warmed = new Set();
816
769
  function warmNetwork() {
817
- if (_warmed) return;
818
- _warmed = true;
819
770
  const head = document.head;
820
771
  if (!head) return;
821
772
  for (const [rel, href, cors] of [
@@ -826,7 +777,9 @@
826
777
  ['dns-prefetch', 'https://g.ezoic.net', false],
827
778
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
828
779
  ]) {
829
- if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
780
+ const k = `${rel}|${href}`;
781
+ if (_warmed.has(k)) continue;
782
+ _warmed.add(k);
830
783
  const l = document.createElement('link');
831
784
  l.rel = rel; l.href = href;
832
785
  if (cors) l.crossOrigin = 'anonymous';
@@ -839,25 +792,29 @@
839
792
  function bindNodeBB() {
840
793
  const $ = window.jQuery;
841
794
  if (!$) return;
795
+
842
796
  $(window).off('.nbbEzoic');
843
797
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
844
798
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
845
- S.pageKey = pageKey(); S.kind = null; S.blockedUntil = 0;
846
- muteConsole(); ensureTcfLocator(); protectAriaHidden();
847
- warmNetwork(); patchShowAds(); getIO(); ensureDomObserver();
848
- requestBurst();
799
+ S.pageKey = pageKey();
800
+ blockedUntil = 0;
801
+ muteConsole(); ensureTcfLocator(); warmNetwork();
802
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
849
803
  });
804
+
850
805
  const burstEvts = [
851
- 'action:ajaxify.contentLoaded', 'action:posts.loaded',
852
- 'action:topics.loaded', 'action:categories.loaded',
853
- 'action:category.loaded', 'action:topic.loaded',
806
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
807
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
854
808
  ].map(e => `${e}.nbbEzoic`).join(' ');
855
809
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
+
856
811
  try {
857
812
  require(['hooks'], hooks => {
858
813
  if (typeof hooks?.on !== 'function') return;
859
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:topic.loaded'])
814
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
815
+ 'action:categories.loaded', 'action:topic.loaded']) {
860
816
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
817
+ }
861
818
  });
862
819
  } catch (_) {}
863
820
  }
@@ -865,7 +822,8 @@
865
822
  function bindScroll() {
866
823
  let ticking = false;
867
824
  window.addEventListener('scroll', () => {
868
- if (ticking) return; ticking = true;
825
+ if (ticking) return;
826
+ ticking = true;
869
827
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
870
828
  }, { passive: true });
871
829
  }
@@ -875,30 +833,13 @@
875
833
  S.pageKey = pageKey();
876
834
  muteConsole();
877
835
  ensureTcfLocator();
878
- protectAriaHidden();
879
836
  warmNetwork();
880
837
  patchShowAds();
881
838
  getIO();
882
839
  ensureDomObserver();
883
840
  bindNodeBB();
884
841
  bindScroll();
885
- S.blockedUntil = 0;
842
+ blockedUntil = 0;
886
843
  requestBurst();
887
844
 
888
- // Retry boot: sa.min.js async + Cloudflare Rocket Loader + NodeBB SPA
889
- // can cause client.js to boot before DOM/Ezoic are ready.
890
- // Retries stop once ads are mounted or after ~10s.
891
- let _retries = 0;
892
- function retryBoot() {
893
- if (_retries >= 12 || S.mountedIds.size > 0) return;
894
- _retries++;
895
- patchShowAds();
896
- if (!isBlocked() && !S.burstActive) {
897
- S.lastBurstTs = 0;
898
- requestBurst();
899
- }
900
- setTimeout(retryBoot, _retries <= 4 ? 300 : 1000);
901
- }
902
- setTimeout(retryBoot, 250);
903
-
904
845
  })();