nodebb-plugin-ezoic-infinite 1.6.99 → 1.7.1

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,445 +1,413 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v18)
2
+ * NodeBB Ezoic Infinite Ads — client.js (v20)
3
3
  *
4
- * Corrections majeures vs v17 :
5
- * 1. ANCRAGE PAR pid/tid (data-pid, data-index) au lieu d'ordinalMap fragile.
6
- * Le même post garde toujours le même ID de wrap, quelle que soit la virtualisation.
7
- * 2. CLEANUP COMPLET à chaque navigation ajaxify (wraps + curseurs + état pools).
8
- * Supprime les fantômes qui se réaffichaient après scroll up/down.
9
- * 3. DEDUPLICATION par ancrage : on vérifie `data-ezoic-anchor` avant d'insérer.
10
- * Empêche la création de doublons lors de multiples passes DOM.
11
- * 4. PAS de recyclage de wraps (moveWrapAfter supprimé).
12
- * → La cause n°1 des pubs "qui sautent n'importe où".
13
- * 5. pruneOrphanWraps simplifié : suppression réelle (remove) au lieu de hide.
14
- * Un wrap orphelin = mort, pas caché. Libère l'id dans le curseur.
15
- * 6. Déduplication de l'id Ezoic par wrap : un id ne peut être monté qu'une fois.
16
- * 7. V17 pile-fix SUPPRIMÉ (was conflicting with main logic).
17
- * 8. Factorisation : helpers partagés, pas de duplication de logique entre kinds.
4
+ * Correctifs critiques vs v19
5
+ * ───────────────────────────
6
+ * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
+ * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
+ * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
+ * anchorEl toujours null suppression à chaque runCore() → disparition.
10
+ * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
+ * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
12
+ *
13
+ * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
+ * Fix : on skip uniquement le wrap courant, pas toute la boucle.
15
+ *
16
+ * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
+ * existants sur les items suivants. Fix : `continue` au lieu de `break`.
18
+ *
19
+ * [PERF] IntersectionObserver recréé à chaque scroll boost → très coûteux mobile.
20
+ * Fix : marge large fixe par device, observer créé une seule fois.
21
+ *
22
+ * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
+ * Fix : 200ms.
24
+ *
25
+ * Nettoyage
26
+ * ─────────
27
+ * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
+ * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
+ * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
+ * - Commentaires internes allégés (code auto-documenté)
18
31
  */
19
32
  (function () {
20
33
  'use strict';
21
34
 
22
- // ─── Constants ────────────────────────────────────────────────────────────
23
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
24
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
25
- const ANCHOR_ATTR = 'data-ezoic-anchor'; // unique key = kind:anchorId
26
- const WRAPID_ATTR = 'data-ezoic-wrapid';
27
- const CREATED_ATTR = 'data-ezoic-created';
35
+ // ── Constantes ─────────────────────────────────────────────────────────────
28
36
 
37
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
+ const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
+
44
+ const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
+ const FILL_GRACE_MS = 25_000; // fenêtre post-showAds où l'on ne decluster pas
46
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
29
47
  const MAX_INSERTS_PER_RUN = 6;
30
- const EMPTY_WRAP_TTL_MS = 90_000; // 90 s avant de colapser un wrap vide
31
- const FILL_WATCH_MS = 7_000;
32
-
33
- const PRELOAD_MARGIN = {
34
- desktop: '2000px 0px 2000px 0px',
35
- mobile: '3000px 0px 3000px 0px',
36
- desktopBoosted:'4500px 0px 4500px 0px',
37
- mobileBoosted: '4500px 0px 4500px 0px',
38
- };
39
- const BOOST_DURATION_MS = 2500;
40
- const BOOST_SPEED_PX_PER_MS = 2.2;
41
- const MAX_INFLIGHT_DESKTOP = 4;
42
- const MAX_INFLIGHT_MOBILE = 3;
43
- const SHOW_THROTTLE_MS = 900;
44
-
45
- const SELECTORS = {
46
- topicItem: 'li[component="category/topic"]',
47
- postItem: '[component="post"][data-pid]',
48
- categoryItem: 'li[component="categories/category"]',
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
53
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
+
56
+ const SEL = {
57
+ post: '[component="post"][data-pid]',
58
+ topic: 'li[component="category/topic"]',
59
+ category: 'li[component="categories/category"]',
49
60
  };
50
61
 
51
- // ─── Helpers ──────────────────────────────────────────────────────────────
52
- const now = () => Date.now();
53
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
54
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
55
-
56
- function uniqInts(raw) {
57
- const out = [], seen = new Set();
58
- for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
59
- const n = parseInt(v, 10);
60
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
61
- }
62
- return out;
63
- }
62
+ /**
63
+ * Table centrale : kindClass → { selector DOM, attribut d'ancre stable }
64
+ *
65
+ * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
+ * quelle que soit la page ou la virtualisation :
67
+ * posts → data-pid (id du message, unique et permanent)
68
+ * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
+ * catégories → data-cid (id de la catégorie, unique et permanent)
70
+ * ← C'était le bug v19 : on cherchait data-index ici
71
+ */
72
+ const KIND = {
73
+ 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
76
+ };
64
77
 
65
- function isFilledNode(node) {
66
- return !!(node?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
67
- }
78
+ // ── État ───────────────────────────────────────────────────────────────────
68
79
 
69
- // ─── State ────────────────────────────────────────────────────────────────
70
- const state = {
80
+ const S = {
71
81
  pageKey: null,
72
82
  cfg: null,
73
83
 
74
- // ID pools + curseurs rotatifs
75
- pools: { topics: [], posts: [], categories: [] },
76
- cursors: { topics: 0, posts: 0, categories: 0 },
77
-
78
- // Suivi des IDs Ezoic actuellement montés dans le DOM (évite les doublons)
79
- mountedIds: new Set(),
80
-
81
- // Throttle par id
82
- lastShowById: new Map(),
83
-
84
- // Observers
85
- domObs: null,
86
- io: null,
87
- ioMargin: null,
88
-
89
- // Guard contre nos propres mutations
90
- internalMutation: 0,
84
+ pools: { topics: [], posts: [], categories: [] },
85
+ cursors: { topics: 0, posts: 0, categories: 0 },
86
+ mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
+ lastShow: new Map(), // id → timestamp dernier show
91
88
 
92
- // File de show
93
- inflight: 0,
94
- pending: [],
95
- pendingSet: new Set(),
89
+ io: null,
90
+ domObs: null,
91
+ mutGuard: 0, // compteur internalMutation
96
92
 
97
- // Scroll boost
98
- scrollBoostUntil: 0,
99
- lastScrollY: 0,
100
- lastScrollTs: 0,
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
101
96
 
102
- // Scheduler
103
- runQueued: false,
104
- burstActive: false,
97
+ runQueued: false,
98
+ burstActive: false,
105
99
  burstDeadline: 0,
106
- burstCount: 0,
107
- lastBurstReqTs: 0,
100
+ burstCount: 0,
101
+ lastBurstTs: 0,
108
102
  };
109
103
 
110
104
  let blockedUntil = 0;
111
- const isBlocked = () => now() < blockedUntil;
112
- const isBoosted = () => now() < state.scrollBoostUntil;
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
113
107
 
114
- function withInternalMutation(fn) {
115
- state.internalMutation++;
116
- try { fn(); } finally { state.internalMutation--; }
108
+ function mutate(fn) {
109
+ S.mutGuard++;
110
+ try { fn(); } finally { S.mutGuard--; }
117
111
  }
118
112
 
119
- // ─── Config ───────────────────────────────────────────────────────────────
113
+ // ── Config ─────────────────────────────────────────────────────────────────
114
+
120
115
  async function fetchConfig() {
121
- if (state.cfg) return state.cfg;
116
+ if (S.cfg) return S.cfg;
122
117
  try {
123
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
124
- if (!res.ok) return null;
125
- state.cfg = await res.json();
126
- } catch (_) { state.cfg = null; }
127
- return state.cfg;
118
+ const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
119
+ if (r.ok) S.cfg = await r.json();
120
+ } catch (_) {}
121
+ return S.cfg;
128
122
  }
129
123
 
130
124
  function initPools(cfg) {
131
- if (!cfg) return;
132
- state.pools.topics = uniqInts(cfg.placeholderIds || '');
133
- state.pools.posts = uniqInts(cfg.messagePlaceholderIds || '');
134
- state.pools.categories = uniqInts(cfg.categoryPlaceholderIds || '');
135
- // Ne pas réinitialiser les curseurs ici (ils sont remis à 0 dans cleanup).
125
+ S.pools.topics = parseIds(cfg.placeholderIds);
126
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
127
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
128
+ }
129
+
130
+ function parseIds(raw) {
131
+ const out = [], seen = new Set();
132
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
133
+ const n = parseInt(v, 10);
134
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
135
+ }
136
+ return out;
136
137
  }
137
138
 
138
- // ─── Page / Kind ──────────────────────────────────────────────────────────
139
- function getPageKey() {
139
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
140
+
141
+ const isFilled = (n) =>
142
+ !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
143
+
144
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
145
+
146
+ // ── Page identity ──────────────────────────────────────────────────────────
147
+
148
+ function pageKey() {
140
149
  try {
141
- const ax = window.ajaxify?.data;
142
- if (ax?.tid) return `topic:${ax.tid}`;
143
- if (ax?.cid) return `cid:${ax.cid}:${location.pathname}`;
150
+ const d = window.ajaxify?.data;
151
+ if (d?.tid) return `t:${d.tid}`;
152
+ if (d?.cid) return `c:${d.cid}`;
144
153
  } catch (_) {}
145
154
  return location.pathname;
146
155
  }
147
156
 
148
157
  function getKind() {
149
158
  const p = location.pathname;
150
- if (/^\/topic\//.test(p)) return 'topic';
151
- if (/^\/category\//.test(p)) return 'categoryTopics';
159
+ if (/^\/topic\//.test(p)) return 'topic';
160
+ if (/^\/category\//.test(p)) return 'categoryTopics';
152
161
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
153
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
154
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
155
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
162
+ if (document.querySelector(SEL.category)) return 'categories';
163
+ if (document.querySelector(SEL.post)) return 'topic';
164
+ if (document.querySelector(SEL.topic)) return 'categoryTopics';
156
165
  return 'other';
157
166
  }
158
167
 
159
- // ─── DOM helpers ──────────────────────────────────────────────────────────
160
- function getPostContainers() {
161
- return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter(el => {
168
+ // ── DOM helpers ────────────────────────────────────────────────────────────
169
+
170
+ function getPosts() {
171
+ return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
162
172
  if (!el.isConnected) return false;
163
173
  if (!el.querySelector('[component="post/content"]')) return false;
164
- const parentPost = el.parentElement?.closest('[component="post"][data-pid]');
165
- if (parentPost && parentPost !== el) return false;
166
- if (el.getAttribute('component') === 'post/parent') return false;
167
- return true;
174
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
175
+ if (p && p !== el) return false;
176
+ return el.getAttribute('component') !== 'post/parent';
168
177
  });
169
178
  }
170
179
 
171
- function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
172
- function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
180
+ const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
181
+ const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
182
+
183
+ function adjacentWrap(el) {
184
+ return !!(
185
+ el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
186
+ el.previousElementSibling?.classList?.contains(WRAP_CLASS)
187
+ );
188
+ }
189
+
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
173
191
 
174
192
  /**
175
- * Calcule l'ancre stable d'un élément : on préfère data-pid (posts) puis
176
- * data-index, sinon on retombe sur l'index dans le tableau.
177
- * La clé est préfixée par kindClass pour éviter les collisions.
193
+ * Retourne l'identifiant stable de l'élément selon son kindClass.
194
+ * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
195
+ * Fallback positionnel si l'attribut est absent.
178
196
  */
179
- function getAnchorKey(kindClass, el, fallbackIndex) {
180
- const pid = el.getAttribute('data-pid');
181
- const index = el.getAttribute('data-index') ?? el.getAttribute('data-idx');
182
- const id = pid ?? index ?? String(fallbackIndex);
183
- return `${kindClass}:${id}`;
197
+ function stableId(kindClass, el) {
198
+ const attr = KIND[kindClass]?.anchorAttr;
199
+ if (attr) {
200
+ const v = el.getAttribute(attr);
201
+ if (v !== null && v !== '') return v;
202
+ }
203
+ // Fallback : position dans le parent
204
+ try {
205
+ let i = 0;
206
+ for (const s of el.parentElement?.children ?? []) {
207
+ if (s === el) return `i${i}`;
208
+ i++;
209
+ }
210
+ } catch (_) {}
211
+ return 'i0';
184
212
  }
185
213
 
186
- function findWrapByAnchor(anchorKey) {
187
- return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${CSS.escape(anchorKey)}"]`);
188
- }
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
189
215
 
190
- function hasAdjacentWrap(el) {
191
- const next = el.nextElementSibling;
192
- if (next?.classList?.contains(WRAP_CLASS)) return true;
193
- const prev = el.previousElementSibling;
194
- if (prev?.classList?.contains(WRAP_CLASS)) return true;
195
- return false;
216
+ function findWrap(anchorKey) {
217
+ try {
218
+ return document.querySelector(
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
+ );
221
+ } catch (_) { return null; }
196
222
  }
197
223
 
198
- // ─── ID pool / rotation ───────────────────────────────────────────────────
199
- /**
200
- * Retourne le prochain id disponible du pool (non déjà monté dans le DOM),
201
- * en avançant le curseur rotatif.
202
- */
224
+ // ── Pool ───────────────────────────────────────────────────────────────────
225
+
203
226
  function pickId(poolKey) {
204
- const pool = state.pools[poolKey];
205
- const n = pool.length;
206
- if (!n) return null;
207
-
208
- for (let tries = 0; tries < n; tries++) {
209
- const idx = state.cursors[poolKey] % n;
210
- state.cursors[poolKey] = (state.cursors[poolKey] + 1) % n;
211
- const id = pool[idx];
212
- if (!state.mountedIds.has(id)) return id;
227
+ const pool = S.pools[poolKey];
228
+ for (let t = 0; t < pool.length; t++) {
229
+ const i = S.cursors[poolKey] % pool.length;
230
+ S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
+ const id = pool[i];
232
+ if (!S.mountedIds.has(id)) return id;
213
233
  }
214
- return null; // Tous les IDs déjà montés
234
+ return null;
215
235
  }
216
236
 
217
- // ─── Wrap construction ────────────────────────────────────────────────────
218
- function buildWrap(id, kindClass, anchorKey) {
219
- const wrap = document.createElement('div');
220
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
221
- wrap.setAttribute(ANCHOR_ATTR, anchorKey);
222
- wrap.setAttribute(WRAPID_ATTR, String(id));
223
- wrap.setAttribute(CREATED_ATTR, String(now()));
224
- wrap.style.cssText = 'width:100%;display:block;';
237
+ // ── Wraps DOM ──────────────────────────────────────────────────────────────
225
238
 
239
+ function makeWrap(id, klass, key) {
240
+ const w = document.createElement('div');
241
+ w.className = `${WRAP_CLASS} ${klass}`;
242
+ w.setAttribute(A_ANCHOR, key);
243
+ w.setAttribute(A_WRAPID, String(id));
244
+ w.setAttribute(A_CREATED, String(ts()));
245
+ w.style.cssText = 'width:100%;display:block;';
226
246
  const ph = document.createElement('div');
227
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
+ ph.id = `${PH_PREFIX}${id}`;
228
248
  ph.setAttribute('data-ezoic-id', String(id));
229
- wrap.appendChild(ph);
230
-
231
- return wrap;
249
+ w.appendChild(ph);
250
+ return w;
232
251
  }
233
252
 
234
- function insertWrapAfter(el, id, kindClass, anchorKey) {
253
+ function insertAfter(el, id, klass, key) {
235
254
  if (!el?.insertAdjacentElement) return null;
236
- if (findWrapByAnchor(anchorKey)) return null; // déjà présent
237
- if (state.mountedIds.has(id)) return null; // id déjà monté
238
-
239
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
240
- if (existingPh?.isConnected) {
241
- // Cet id a déjà un placeholder dans le DOM → on ne peut pas le dupliquer
242
- return null;
243
- }
244
-
245
- const wrap = buildWrap(id, kindClass, anchorKey);
246
- withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
247
- state.mountedIds.add(id);
248
- return wrap;
255
+ if (findWrap(key)) return null; // ancre déjà présente
256
+ if (S.mountedIds.has(id)) return null; // id déjà monté
257
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
+ const w = makeWrap(id, klass, key);
259
+ mutate(() => el.insertAdjacentElement('afterend', w));
260
+ S.mountedIds.add(id);
261
+ return w;
249
262
  }
250
263
 
251
- function removeWrap(wrap) {
264
+ function dropWrap(w) {
252
265
  try {
253
- const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
254
- if (Number.isFinite(id)) state.mountedIds.delete(id);
255
- wrap.remove();
266
+ const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
268
+ try { S.io?.unobserve(w.querySelector(`[id^="${PH_PREFIX}"]`)); } catch (_) {}
269
+ w.remove();
256
270
  } catch (_) {}
257
271
  }
258
272
 
259
- // ─── Prune & Decluster ────────────────────────────────────────────────────
273
+ // ── Prune ──────────────────────────────────────────────────────────────────
274
+
260
275
  /**
261
276
  * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
262
- * On supprime proprement (remove) plutôt que de cacher → libère l'id.
277
+ *
278
+ * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
279
+ * Exemples :
280
+ * ezoic-ad-message → cherche [data-pid="123"]
281
+ * ezoic-ad-between → cherche [data-index="5"]
282
+ * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
283
+ *
284
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
263
285
  */
264
- function pruneOrphans(kindClass) {
265
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
266
- let removed = 0;
267
-
268
- wraps.forEach(wrap => {
269
- // Ne jamais pruner un wrap tout juste créé (fill lent côté Ezoic)
270
- const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
271
- if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
272
-
273
- const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
274
- if (!anchorKey) {
275
- withInternalMutation(() => removeWrap(wrap));
276
- removed++;
277
- return;
278
- }
286
+ function pruneOrphans(klass) {
287
+ const meta = KIND[klass];
288
+ if (!meta) return;
279
289
 
280
- // L'ancre existe-t-elle encore dans le DOM ?
281
- const [, anchorId] = anchorKey.split(':');
282
- const isPost = kindClass === 'ezoic-ad-message';
283
- let anchorEl = null;
290
+ const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." "" (géré)
284
291
 
285
- if (isPost) {
286
- anchorEl = document.querySelector(`[component="post"][data-pid="${CSS.escape(anchorId)}"]`);
287
- } else {
288
- anchorEl = document.querySelector(`${SELECTORS.topicItem}[data-index="${CSS.escape(anchorId)}"]`)
289
- ?? document.querySelector(`${SELECTORS.categoryItem}[data-index="${CSS.escape(anchorId)}"]`);
290
- }
292
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
293
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
291
294
 
292
- if (!anchorEl || !anchorEl.isConnected) {
293
- // Ancre disparue si rempli on garde (scrolling back), si vide on supprime
294
- if (!isFilledNode(wrap)) {
295
- withInternalMutation(() => removeWrap(wrap));
296
- removed++;
297
- }
298
- }
299
- });
295
+ const key = w.getAttribute(A_ANCHOR) ?? '';
296
+ const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
297
+ if (!sid) { mutate(() => dropWrap(w)); return; }
300
298
 
301
- return removed;
299
+ const anchorEl = document.querySelector(
300
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
301
+ );
302
+ if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
303
+ });
302
304
  }
303
305
 
306
+ // ── Decluster ──────────────────────────────────────────────────────────────
307
+
304
308
  /**
305
- * Si deux wraps se retrouvent consécutifs (sans item entre eux),
306
- * on supprime le plus récent des deux s'il est vide.
309
+ * Deux wraps adjacents = situation anormale supprimer le moins prioritaire.
310
+ * Priorité : filled > en grâce (fill en cours) > vide.
311
+ * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
307
312
  */
308
- function decluster(kindClass) {
309
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
310
- let removed = 0;
311
-
312
- for (const wrap of wraps) {
313
- let prev = wrap.previousElementSibling;
314
- let steps = 0;
315
- while (prev && steps < 3) {
316
- if (prev.classList?.contains(WRAP_CLASS)) {
317
- // Deux wraps consécutifs : supprimer le plus vide/récent
318
- const wCreated = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
319
- const pCreated = parseInt(prev.getAttribute(CREATED_ATTR) || '0', 10);
320
- const wFilled = isFilledNode(wrap);
321
- const pFilled = isFilledNode(prev);
322
-
323
- if (!wFilled) {
324
- withInternalMutation(() => removeWrap(wrap));
325
- removed++;
326
- } else if (!pFilled) {
327
- withInternalMutation(() => removeWrap(prev));
328
- removed++;
329
- }
330
- // Si les deux sont remplis, laisser en place
331
- break;
332
- }
333
- prev = prev.previousElementSibling;
334
- steps++;
313
+ function decluster(klass) {
314
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
315
+ // Grace sur le wrap courant : on le saute entièrement
316
+ const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
317
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
318
+
319
+ let prev = w.previousElementSibling, steps = 0;
320
+ while (prev && steps++ < 3) {
321
+ if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
322
+
323
+ const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
324
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
325
+
326
+ if (!isFilled(w)) mutate(() => dropWrap(w));
327
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
328
+ break;
335
329
  }
336
330
  }
337
-
338
- return removed;
339
331
  }
340
332
 
341
- // ─── Injection ────────────────────────────────────────────────────────────
342
- function computeTargetIndices(count, interval, showFirst) {
343
- const targets = new Set();
344
- if (showFirst && count > 0) targets.add(0);
345
- for (let i = interval - 1; i < count; i += interval) targets.add(i);
346
- return [...targets].sort((a, b) => a - b);
333
+ // ── Injection ──────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Ordinal 0-based pour le calcul de l'intervalle.
337
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
338
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
339
+ */
340
+ function ordinal(klass, el) {
341
+ const di = el.getAttribute('data-index');
342
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
343
+ // Fallback positionnel
344
+ try {
345
+ const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
346
+ if (tag) {
347
+ let i = 0;
348
+ for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
349
+ if (n === el) return i;
350
+ i++;
351
+ }
352
+ }
353
+ } catch (_) {}
354
+ return 0;
347
355
  }
348
356
 
349
- function injectBetween(kindClass, items, interval, showFirst, poolKey) {
357
+ function injectBetween(klass, items, interval, showFirst, poolKey) {
350
358
  if (!items.length) return 0;
359
+ let inserted = 0;
351
360
 
352
- const targets = computeTargetIndices(items.length, interval, showFirst);
353
- const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
354
- let inserted = 0;
361
+ for (const el of items) {
362
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
363
+ if (!el?.isConnected) continue;
355
364
 
356
- for (const idx of targets) {
357
- if (inserted >= maxIns) break;
365
+ const ord = ordinal(klass, el);
366
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
367
+ if (!isTarget) continue;
358
368
 
359
- const el = items[idx];
360
- if (!el?.isConnected) continue;
361
- if (hasAdjacentWrap(el)) continue;
369
+ if (adjacentWrap(el)) continue;
362
370
 
363
- const anchorKey = getAnchorKey(kindClass, el, idx);
364
- if (findWrapByAnchor(anchorKey)) continue; // déjà là
371
+ const key = makeAnchorKey(klass, el);
372
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
365
373
 
366
374
  const id = pickId(poolKey);
367
- if (!id) break; // pool épuisé pour cette passe
375
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
368
376
 
369
- const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
370
- if (!wrap) continue;
371
-
372
- observePlaceholder(id);
373
- inserted++;
377
+ const w = insertAfter(el, id, klass, key);
378
+ if (w) { observePh(id); inserted++; }
374
379
  }
375
-
376
380
  return inserted;
377
381
  }
378
382
 
379
- // ─── Preload / Show ───────────────────────────────────────────────────────
380
- function getPreloadMargin() {
381
- const m = isMobile() ? 'mobile' : 'desktop';
382
- return PRELOAD_MARGIN[m + (isBoosted() ? 'Boosted' : '')];
383
- }
384
-
385
- function getMaxInflight() {
386
- return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
387
- }
388
-
389
- function ensurePreloadObserver() {
390
- const margin = getPreloadMargin();
391
- if (state.io && state.ioMargin === margin) return state.io;
392
-
393
- state.io?.disconnect();
394
- state.io = null;
383
+ // ── IntersectionObserver & Show ────────────────────────────────────────────
395
384
 
385
+ function getIO() {
386
+ if (S.io) return S.io;
387
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
396
388
  try {
397
- state.io = new IntersectionObserver(entries => {
398
- for (const ent of entries) {
399
- if (!ent.isIntersecting) continue;
400
- state.io?.unobserve(ent.target);
401
- const id = parseInt(ent.target.getAttribute('data-ezoic-id'), 10);
389
+ S.io = new IntersectionObserver(entries => {
390
+ for (const e of entries) {
391
+ if (!e.isIntersecting) continue;
392
+ S.io?.unobserve(e.target);
393
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
402
394
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
403
395
  }
404
396
  }, { root: null, rootMargin: margin, threshold: 0 });
405
- state.ioMargin = margin;
406
- } catch (_) { state.io = null; state.ioMargin = null; }
407
-
408
- // Ré-observer les placeholders déjà dans le DOM
409
- try {
410
- document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
411
- try { state.io?.observe(n); } catch (_) {}
412
- });
413
- } catch (_) {}
414
-
415
- return state.io;
397
+ } catch (_) { S.io = null; }
398
+ return S.io;
416
399
  }
417
400
 
418
- function observePlaceholder(id) {
419
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
420
- if (!ph?.isConnected) return;
421
- try { state.io?.observe(ph); } catch (_) {}
422
-
423
- // Si déjà proche du viewport → show immédiat
424
- try {
425
- const r = ph.getBoundingClientRect();
426
- const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
427
- if (r.top < window.innerHeight * screens && r.bottom > -window.innerHeight * 2) {
428
- enqueueShow(id);
429
- }
430
- } catch (_) {}
401
+ function observePh(id) {
402
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
403
+ if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
431
404
  }
432
405
 
433
406
  function enqueueShow(id) {
434
407
  if (!id || isBlocked()) return;
435
- const t = now();
436
- if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
437
-
438
- if (state.inflight >= getMaxInflight()) {
439
- if (!state.pendingSet.has(id)) {
440
- state.pending.push(id);
441
- state.pendingSet.add(id);
442
- }
408
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
409
+ if (S.inflight >= MAX_INFLIGHT) {
410
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
443
411
  return;
444
412
  }
445
413
  startShow(id);
@@ -447,81 +415,73 @@
447
415
 
448
416
  function drainQueue() {
449
417
  if (isBlocked()) return;
450
- while (state.inflight < getMaxInflight() && state.pending.length) {
451
- const id = state.pending.shift();
452
- state.pendingSet.delete(id);
418
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
419
+ const id = S.pending.shift();
420
+ S.pendingSet.delete(id);
453
421
  startShow(id);
454
422
  }
455
423
  }
456
424
 
457
425
  function startShow(id) {
458
426
  if (!id || isBlocked()) return;
459
- state.inflight++;
427
+ S.inflight++;
460
428
  let done = false;
461
-
462
429
  const release = () => {
463
430
  if (done) return;
464
431
  done = true;
465
- state.inflight = Math.max(0, state.inflight - 1);
432
+ S.inflight = Math.max(0, S.inflight - 1);
466
433
  drainQueue();
467
434
  };
468
-
469
- const timeout = setTimeout(release, 6500);
435
+ const timer = setTimeout(release, 7000);
470
436
 
471
437
  requestAnimationFrame(() => {
472
438
  try {
473
- if (isBlocked()) return release();
474
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
475
- if (!ph?.isConnected) return release();
476
- if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
439
+ if (isBlocked()) { clearTimeout(timer); return release(); }
440
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
441
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
477
442
 
478
- const t = now();
479
- if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return release();
480
- state.lastShowById.set(id, t);
443
+ const t = ts();
444
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
445
+ S.lastShow.set(id, t);
446
+
447
+ // Horodater le show sur le wrap pour grace period + emptyCheck
448
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
481
449
 
482
450
  window.ezstandalone = window.ezstandalone || {};
483
451
  const ez = window.ezstandalone;
484
-
485
452
  const doShow = () => {
486
453
  try { ez.showAds(id); } catch (_) {}
487
- scheduleEmptyCheck(id);
488
- setTimeout(release, 650);
454
+ scheduleEmptyCheck(id, t);
455
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
489
456
  };
490
-
491
- if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
492
- else doShow();
493
- } finally { /* timeout covers us */ }
457
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
458
+ } catch (_) { clearTimeout(timer); release(); }
494
459
  });
495
460
  }
496
461
 
497
- function scheduleEmptyCheck(id) {
462
+ function scheduleEmptyCheck(id, showTs) {
498
463
  setTimeout(() => {
499
464
  try {
500
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
501
- if (!ph?.isConnected) return;
502
- const wrap = ph.closest(`.${WRAP_CLASS}`);
503
- if (!wrap) return;
504
-
505
- const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
506
- if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
507
-
508
- if (!isFilledNode(ph)) wrap.classList.add('is-empty');
509
- else wrap.classList.remove('is-empty');
465
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
466
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
467
+ if (!wrap || !ph?.isConnected) return;
468
+ // Un show plus récent → ne pas toucher
469
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
470
+ wrap.classList.toggle('is-empty', !isFilled(ph));
510
471
  } catch (_) {}
511
- }, 15_000);
472
+ }, EMPTY_CHECK_MS);
512
473
  }
513
474
 
514
- // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
475
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
476
+
515
477
  function patchShowAds() {
516
478
  const apply = () => {
517
479
  try {
518
480
  window.ezstandalone = window.ezstandalone || {};
519
481
  const ez = window.ezstandalone;
520
- if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
521
-
522
- window.__nodebbEzoicPatched = true;
482
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
483
+ window.__nbbEzPatched = true;
523
484
  const orig = ez.showAds.bind(ez);
524
-
525
485
  ez.showAds = function (...args) {
526
486
  if (isBlocked()) return;
527
487
  const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
@@ -529,24 +489,22 @@
529
489
  for (const v of ids) {
530
490
  const id = parseInt(v, 10);
531
491
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
532
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
533
- if (!ph?.isConnected) continue;
492
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
534
493
  seen.add(id);
535
494
  try { orig(id); } catch (_) {}
536
495
  }
537
496
  };
538
497
  } catch (_) {}
539
498
  };
540
-
541
499
  apply();
542
- if (!window.__nodebbEzoicPatched) {
500
+ if (!window.__nbbEzPatched) {
543
501
  window.ezstandalone = window.ezstandalone || {};
544
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
545
- window.ezstandalone.cmd.push(apply);
502
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
546
503
  }
547
504
  }
548
505
 
549
- // ─── Core run ─────────────────────────────────────────────────────────────
506
+ // ── Core run ───────────────────────────────────────────────────────────────
507
+
550
508
  async function runCore() {
551
509
  if (isBlocked()) return 0;
552
510
  patchShowAds();
@@ -556,229 +514,191 @@
556
514
  initPools(cfg);
557
515
 
558
516
  const kind = getKind();
559
- let inserted = 0;
517
+ if (kind === 'other') return 0;
560
518
 
561
- const run = (kindClass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
519
+ const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
562
520
  if (!normBool(cfgEnable)) return 0;
563
- const items = getItems();
564
- pruneOrphans(kindClass);
565
- const n = injectBetween(kindClass, items, Math.max(1, parseInt(cfgInterval, 10) || 3), normBool(cfgShowFirst), poolKey);
566
- if (n) decluster(kindClass);
521
+ const items = getItems();
522
+ const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
523
+ pruneOrphans(klass);
524
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
525
+ if (n) decluster(klass);
567
526
  return n;
568
527
  };
569
528
 
570
- if (kind === 'topic') {
571
- inserted += run('ezoic-ad-message', getPostContainers,
572
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
573
- } else if (kind === 'categoryTopics') {
574
- inserted += run('ezoic-ad-between', getTopicItems,
575
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
576
- } else if (kind === 'categories') {
577
- inserted += run('ezoic-ad-categories', getCategoryItems,
578
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
579
- }
580
-
581
- return inserted;
582
- }
583
-
584
- // ─── Scheduler / Burst ────────────────────────────────────────────────────
585
- function scheduleRun(delayMs = 0, cb) {
586
- if (state.runQueued) return;
587
- state.runQueued = true;
588
-
589
- const run = async () => {
590
- state.runQueued = false;
591
- if (state.pageKey && getPageKey() !== state.pageKey) return;
529
+ if (kind === 'topic') return exec(
530
+ 'ezoic-ad-message', getPosts,
531
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
532
+ );
533
+ if (kind === 'categoryTopics') return exec(
534
+ 'ezoic-ad-between', getTopics,
535
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
536
+ );
537
+ if (kind === 'categories') return exec(
538
+ 'ezoic-ad-categories', getCategories,
539
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
540
+ );
541
+ return 0;
542
+ }
543
+
544
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
545
+
546
+ function scheduleRun(cb) {
547
+ if (S.runQueued) return;
548
+ S.runQueued = true;
549
+ requestAnimationFrame(async () => {
550
+ S.runQueued = false;
551
+ if (S.pageKey && pageKey() !== S.pageKey) return;
592
552
  let n = 0;
593
553
  try { n = await runCore(); } catch (_) {}
594
554
  try { cb?.(n); } catch (_) {}
595
- };
596
-
597
- const doRun = () => requestAnimationFrame(run);
598
- if (delayMs > 0) setTimeout(doRun, delayMs);
599
- else doRun();
555
+ });
600
556
  }
601
557
 
602
558
  function requestBurst() {
603
559
  if (isBlocked()) return;
604
- const t = now();
605
- if (t - state.lastBurstReqTs < 100) return;
606
- state.lastBurstReqTs = t;
560
+ const t = ts();
561
+ if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
562
+ S.lastBurstTs = t;
607
563
 
608
- const pk = getPageKey();
609
- state.pageKey = pk;
610
- state.burstDeadline = t + 1800;
564
+ const pk = pageKey();
565
+ S.pageKey = pk;
566
+ S.burstDeadline = t + 2000;
611
567
 
612
- if (state.burstActive) return;
613
- state.burstActive = true;
614
- state.burstCount = 0;
568
+ if (S.burstActive) return;
569
+ S.burstActive = true;
570
+ S.burstCount = 0;
615
571
 
616
572
  const step = () => {
617
- if (getPageKey() !== pk) { state.burstActive = false; return; }
618
- if (isBlocked()) { state.burstActive = false; return; }
619
- if (now() > state.burstDeadline) { state.burstActive = false; return; }
620
- if (state.burstCount >= 8) { state.burstActive = false; return; }
621
-
622
- state.burstCount++;
623
- scheduleRun(0, (n) => {
624
- if (!n && !state.pending.length) { state.burstActive = false; return; }
625
- setTimeout(step, n > 0 ? 120 : 250);
573
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
574
+ S.burstActive = false; return;
575
+ }
576
+ S.burstCount++;
577
+ scheduleRun(n => {
578
+ if (!n && !S.pending.length) { S.burstActive = false; return; }
579
+ setTimeout(step, n > 0 ? 150 : 300);
626
580
  });
627
581
  };
628
-
629
582
  step();
630
583
  }
631
584
 
632
- // ─── Cleanup (ajaxify navigation) ─────────────────────────────────────────
633
- function cleanup() {
634
- // Bloquer toute injection pendant la transition
635
- blockedUntil = now() + 1500;
585
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
636
586
 
637
- // Supprimer tous les wraps injectés → libère les IDs
638
- withInternalMutation(() => {
639
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
640
- });
587
+ function cleanup() {
588
+ blockedUntil = ts() + 1500;
589
+ mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
590
+ S.cfg = null;
591
+ S.pools = { topics: [], posts: [], categories: [] };
592
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
593
+ S.mountedIds.clear();
594
+ S.lastShow.clear();
595
+ S.inflight = 0;
596
+ S.pending = [];
597
+ S.pendingSet.clear();
598
+ S.burstActive = false;
599
+ S.runQueued = false;
600
+ }
601
+
602
+ // ── DOM Observer ───────────────────────────────────────────────────────────
641
603
 
642
- // Réinitialiser l'état complet
643
- state.cfg = null;
644
- state.pools = { topics: [], posts: [], categories: [] };
645
- state.cursors = { topics: 0, posts: 0, categories: 0 };
646
- state.mountedIds.clear();
647
- state.lastShowById.clear();
648
- state.inflight = 0;
649
- state.pending = [];
650
- state.pendingSet.clear();
651
-
652
- state.burstActive = false;
653
- state.runQueued = false;
654
- }
655
-
656
- // ─── DOM Observer ─────────────────────────────────────────────────────────
657
- function shouldReact(mutations) {
658
- for (const m of mutations) {
659
- if (!m.addedNodes?.length) continue;
660
- for (const n of m.addedNodes) {
661
- if (n.nodeType !== 1) continue;
662
- if (
663
- n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
664
- n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
665
- n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem)
666
- ) return true;
604
+ function ensureDomObserver() {
605
+ if (S.domObs) return;
606
+ S.domObs = new MutationObserver(muts => {
607
+ if (S.mutGuard > 0 || isBlocked()) return;
608
+ for (const m of muts) {
609
+ if (!m.addedNodes?.length) continue;
610
+ for (const n of m.addedNodes) {
611
+ if (n.nodeType !== 1) continue;
612
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
613
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
614
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
615
+ requestBurst(); return;
616
+ }
617
+ }
667
618
  }
668
- }
669
- return false;
619
+ });
620
+ try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
670
621
  }
671
622
 
672
- function ensureDomObserver() {
673
- if (state.domObs) return;
674
- state.domObs = new MutationObserver(mutations => {
675
- if (state.internalMutation > 0) return;
676
- if (isBlocked()) return;
677
- if (!shouldReact(mutations)) return;
678
- requestBurst();
679
- });
680
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
681
- }
682
-
683
- // ─── Utilities: console mute + TCF + network warm ─────────────────────────
684
- function muteNoisyConsole() {
685
- if (window.__nodebbEzoicConsoleMuted) return;
686
- window.__nodebbEzoicConsoleMuted = true;
687
- const MUTED = [
688
- '[EzoicAds JS]: Placeholder Id',
689
- 'Debugger iframe already exists',
690
- 'HTML element with id ezoic-pub-ad-placeholder-',
691
- ];
692
- ['log', 'info', 'warn', 'error'].forEach(m => {
623
+ // ── Utilitaires ────────────────────────────────────────────────────────────
624
+
625
+ function muteConsole() {
626
+ if (window.__nbbEzMuted) return;
627
+ window.__nbbEzMuted = true;
628
+ const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
629
+ for (const m of ['log', 'info', 'warn', 'error']) {
693
630
  const orig = console[m];
694
- if (typeof orig !== 'function') return;
695
- console[m] = function (...args) {
696
- const s = typeof args[0] === 'string' ? args[0] : '';
697
- if (MUTED.some(p => s.includes(p))) return;
698
- orig.apply(console, args);
631
+ if (typeof orig !== 'function') continue;
632
+ console[m] = function (...a) {
633
+ if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
634
+ orig.apply(console, a);
699
635
  };
700
- });
636
+ }
701
637
  }
702
638
 
703
639
  function ensureTcfLocator() {
704
640
  try {
705
641
  if (!window.__tcfapi && !window.__cmp) return;
706
642
  if (document.getElementById('__tcfapiLocator')) return;
707
- const f = Object.assign(document.createElement('iframe'), {
708
- style: 'display:none', id: '__tcfapiLocator', name: '__tcfapiLocator',
709
- });
643
+ const f = document.createElement('iframe');
644
+ f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
710
645
  (document.body || document.documentElement).appendChild(f);
711
646
  } catch (_) {}
712
647
  }
713
648
 
714
- const _warmedLinks = new Set();
649
+ const _warmed = new Set();
715
650
  function warmNetwork() {
716
651
  const head = document.head;
717
652
  if (!head) return;
718
- const links = [
719
- ['preconnect', 'https://g.ezoic.net', true],
720
- ['preconnect', 'https://go.ezoic.net', true],
721
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
722
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
723
- ['dns-prefetch', 'https://g.ezoic.net', false],
724
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
725
- ];
726
- for (const [rel, href, cors] of links) {
727
- const key = `${rel}|${href}`;
728
- if (_warmedLinks.has(key)) continue;
729
- _warmedLinks.add(key);
730
- const link = document.createElement('link');
731
- link.rel = rel; link.href = href;
732
- if (cors) link.crossOrigin = 'anonymous';
733
- head.appendChild(link);
653
+ for (const [rel, href, cors] of [
654
+ ['preconnect', 'https://g.ezoic.net', true],
655
+ ['preconnect', 'https://go.ezoic.net', true],
656
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
657
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
658
+ ['dns-prefetch', 'https://g.ezoic.net', false],
659
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
660
+ ]) {
661
+ const k = `${rel}|${href}`;
662
+ if (_warmed.has(k)) continue;
663
+ _warmed.add(k);
664
+ const l = document.createElement('link');
665
+ l.rel = rel; l.href = href;
666
+ if (cors) l.crossOrigin = 'anonymous';
667
+ head.appendChild(l);
734
668
  }
735
669
  }
736
670
 
737
- // ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
671
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
672
+
738
673
  function bindNodeBB() {
739
674
  const $ = window.jQuery;
740
675
  if (!$) return;
741
676
 
742
- $(window).off('.ezoicInfinite');
743
-
744
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
745
- cleanup();
746
- });
747
-
748
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
749
- state.pageKey = getPageKey();
750
- blockedUntil = 0;
751
- muteNoisyConsole();
752
- ensureTcfLocator();
753
- warmNetwork();
754
- patchShowAds();
755
- ensurePreloadObserver();
756
- ensureDomObserver();
757
- requestBurst();
677
+ $(window).off('.nbbEzoic');
678
+ $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
679
+ $(window).on('action:ajaxify.end.nbbEzoic', () => {
680
+ S.pageKey = pageKey();
681
+ blockedUntil = 0;
682
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
683
+ getIO(); ensureDomObserver(); requestBurst();
758
684
  });
759
685
 
760
- const burstEvents = [
686
+ const BURST_EVENTS = [
761
687
  'action:ajaxify.contentLoaded',
762
- 'action:posts.loaded',
763
- 'action:topics.loaded',
764
- 'action:categories.loaded',
765
- 'action:category.loaded',
766
- 'action:topic.loaded',
767
- ].map(e => `${e}.ezoicInfinite`).join(' ');
688
+ 'action:posts.loaded', 'action:topics.loaded',
689
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
690
+ ].map(e => `${e}.nbbEzoic`).join(' ');
768
691
 
769
- $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
692
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
770
693
 
771
- // Hooks AMD (NodeBB 4.x)
694
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
772
695
  try {
773
696
  require(['hooks'], hooks => {
774
697
  if (typeof hooks?.on !== 'function') return;
775
- [
776
- 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
777
- 'action:posts.loaded', 'action:topics.loaded',
778
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
779
- ].forEach(ev => {
698
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
699
+ 'action:categories.loaded', 'action:topic.loaded']) {
780
700
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
781
- });
701
+ }
782
702
  });
783
703
  } catch (_) {}
784
704
  }
@@ -786,38 +706,24 @@
786
706
  function bindScroll() {
787
707
  let ticking = false;
788
708
  window.addEventListener('scroll', () => {
789
- // Scroll boost
790
- try {
791
- const t = now();
792
- const y = window.scrollY || window.pageYOffset || 0;
793
- if (state.lastScrollTs) {
794
- const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
795
- if (speed >= BOOST_SPEED_PX_PER_MS) {
796
- const wasBoosted = isBoosted();
797
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
798
- if (!wasBoosted) ensurePreloadObserver();
799
- }
800
- }
801
- state.lastScrollY = y;
802
- state.lastScrollTs = t;
803
- } catch (_) {}
804
-
805
709
  if (ticking) return;
806
710
  ticking = true;
807
711
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
808
712
  }, { passive: true });
809
713
  }
810
714
 
811
- // ─── Boot ─────────────────────────────────────────────────────────────────
812
- state.pageKey = getPageKey();
813
- muteNoisyConsole();
715
+ // ── Boot ───────────────────────────────────────────────────────────────────
716
+
717
+ S.pageKey = pageKey();
718
+ muteConsole();
814
719
  ensureTcfLocator();
815
720
  warmNetwork();
816
721
  patchShowAds();
817
- ensurePreloadObserver();
722
+ getIO();
818
723
  ensureDomObserver();
819
724
  bindNodeBB();
820
725
  bindScroll();
821
726
  blockedUntil = 0;
822
727
  requestBurst();
728
+
823
729
  })();