nodebb-plugin-ezoic-infinite 1.7.0 → 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,459 +1,413 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v19)
2
+ * NodeBB Ezoic Infinite Ads — client.js (v20)
3
3
  *
4
- * Correctifs v19 vs v18 :
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).
5
12
  *
6
- * [BUG 1] Pubs regroupées en haut après scroll up/down
7
- * Cause : pruneOrphans gardait les wraps "filled" même quand leur ancre DOM
8
- * avait disparu (post virtualisé). Ces wraps sans parent flottaient et
9
- * NodeBB les réordonnait arbitrairement.
10
- * Fix : un wrap dont l'ancre est absente du DOM EST supprimé, rempli ou non.
11
- * Exception : si l'ancre est simplement hors-viewport mais still connected
12
- * (NodeBB ne virtualise pas toujours le DOM), on la conserve.
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.
13
15
  *
14
- * [BUG 2] Pub qui apparaît puis disparaît
15
- * Cause : decluster() supprimait un wrap "vide" pendant la fenêtre de fill
16
- * async d'Ezoic. Le guard TTL de 90s était calculé depuis la création,
17
- * mais le show() peut avoir été appelé bien après la création.
18
- * Fix : on ajoute data-ezoic-shown (timestamp du show). decluster ne touche
19
- * pas un wrap dont le show date de moins de FILL_GRACE_MS (20s).
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`.
20
18
  *
21
- * [BUG 3] Intervalle 1/x non respecté sur infinite scroll
22
- * Cause : computeTargetIndices utilisait l'index dans le tableau courant
23
- * (items[0..N]), qui recommence à 0 à chaque batch de posts chargés.
24
- * Fix : on utilise l'ordinal GLOBAL du post (data-index fourni par NodeBB,
25
- * ou data-pid comme fallback numérique). L'intervalle est appliqué sur cet
26
- * ordinal global → pub tous les X posts absolus, quel que soit le batch.
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é)
27
31
  */
28
32
  (function () {
29
33
  'use strict';
30
34
 
31
- // ─── Constants ────────────────────────────────────────────────────────────
32
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
33
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
34
- const ANCHOR_ATTR = 'data-ezoic-anchor'; // "kindClass:globalOrdinal"
35
- const WRAPID_ATTR = 'data-ezoic-wrapid'; // ezoic placeholder id
36
- const CREATED_ATTR = 'data-ezoic-created'; // timestamp création
37
- const SHOWN_ATTR = 'data-ezoic-shown'; // timestamp dernier showAds
35
+ // ── Constantes ─────────────────────────────────────────────────────────────
38
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
39
47
  const MAX_INSERTS_PER_RUN = 6;
40
- // Après un showAds(), ne pas decluster pendant ce délai (fill async Ezoic)
41
- const FILL_GRACE_MS = 20_000;
42
- // Collapse is-empty seulement après ce délai post-show
43
- const EMPTY_CHECK_DELAY = 18_000;
44
-
45
- const PRELOAD_MARGIN = {
46
- desktop: '2000px 0px 2000px 0px',
47
- mobile: '3000px 0px 3000px 0px',
48
- desktopBoosted: '4500px 0px 4500px 0px',
49
- mobileBoosted: '4500px 0px 4500px 0px',
50
- };
51
- const BOOST_DURATION_MS = 2500;
52
- const BOOST_SPEED_PX_PER_MS = 2.2;
53
- const MAX_INFLIGHT_DESKTOP = 4;
54
- const MAX_INFLIGHT_MOBILE = 3;
55
- const SHOW_THROTTLE_MS = 900;
56
-
57
- const SELECTORS = {
58
- topicItem: 'li[component="category/topic"]',
59
- postItem: '[component="post"][data-pid]',
60
- 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"]',
61
60
  };
62
61
 
63
- // ─── Helpers ──────────────────────────────────────────────────────────────
64
- const now = () => Date.now();
65
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
66
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
67
- const isFilledNode = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
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
+ };
68
77
 
69
- function uniqInts(raw) {
70
- const out = [], seen = new Set();
71
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
72
- const n = parseInt(v, 10);
73
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
74
- }
75
- return out;
76
- }
78
+ // ── État ───────────────────────────────────────────────────────────────────
77
79
 
78
- // ─── State ────────────────────────────────────────────────────────────────
79
- const state = {
80
+ const S = {
80
81
  pageKey: null,
81
82
  cfg: null,
82
83
 
83
- pools: { topics: [], posts: [], categories: [] },
84
- cursors: { topics: 0, posts: 0, categories: 0 },
85
-
86
- // IDs Ezoic actuellement montés dans le DOM (Set<number>)
87
- mountedIds: new Set(),
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
88
88
 
89
- lastShowById: new Map(),
89
+ io: null,
90
+ domObs: null,
91
+ mutGuard: 0, // compteur internalMutation
90
92
 
91
- domObs: null,
92
- io: null, ioMargin: null,
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
93
96
 
94
- internalMutation: 0,
95
-
96
- inflight: 0,
97
- pending: [], pendingSet: new Set(),
98
-
99
- scrollBoostUntil: 0,
100
- lastScrollY: 0, lastScrollTs: 0,
101
-
102
- runQueued: false,
103
- burstActive: false, burstDeadline: 0,
104
- burstCount: 0, lastBurstReqTs: 0,
97
+ runQueued: false,
98
+ burstActive: false,
99
+ burstDeadline: 0,
100
+ burstCount: 0,
101
+ lastBurstTs: 0,
105
102
  };
106
103
 
107
104
  let blockedUntil = 0;
108
- const isBlocked = () => now() < blockedUntil;
109
- const isBoosted = () => now() < state.scrollBoostUntil;
105
+ const isBlocked = () => Date.now() < blockedUntil;
106
+ const ts = () => Date.now();
110
107
 
111
- function withInternalMutation(fn) {
112
- state.internalMutation++;
113
- try { fn(); } finally { state.internalMutation--; }
108
+ function mutate(fn) {
109
+ S.mutGuard++;
110
+ try { fn(); } finally { S.mutGuard--; }
114
111
  }
115
112
 
116
- // ─── Config ───────────────────────────────────────────────────────────────
113
+ // ── Config ─────────────────────────────────────────────────────────────────
114
+
117
115
  async function fetchConfig() {
118
- if (state.cfg) return state.cfg;
116
+ if (S.cfg) return S.cfg;
119
117
  try {
120
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
121
- if (!res.ok) return null;
122
- state.cfg = await res.json();
123
- } catch (_) { state.cfg = null; }
124
- 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;
125
122
  }
126
123
 
127
124
  function initPools(cfg) {
128
- if (!cfg) return;
129
- // Réinitialise à chaque page (cleanup() remet les curseurs à 0)
130
- state.pools.topics = uniqInts(cfg.placeholderIds);
131
- state.pools.posts = uniqInts(cfg.messagePlaceholderIds);
132
- state.pools.categories = uniqInts(cfg.categoryPlaceholderIds);
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;
133
137
  }
134
138
 
135
- // ─── Page / Kind ──────────────────────────────────────────────────────────
136
- 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() {
137
149
  try {
138
- const ax = window.ajaxify?.data;
139
- if (ax?.tid) return `topic:${ax.tid}`;
140
- 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}`;
141
153
  } catch (_) {}
142
154
  return location.pathname;
143
155
  }
144
156
 
145
157
  function getKind() {
146
158
  const p = location.pathname;
147
- if (/^\/topic\//.test(p)) return 'topic';
148
- if (/^\/category\//.test(p)) return 'categoryTopics';
149
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
150
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
151
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
152
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
159
+ if (/^\/topic\//.test(p)) return 'topic';
160
+ if (/^\/category\//.test(p)) return 'categoryTopics';
161
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
162
+ if (document.querySelector(SEL.category)) return 'categories';
163
+ if (document.querySelector(SEL.post)) return 'topic';
164
+ if (document.querySelector(SEL.topic)) return 'categoryTopics';
153
165
  return 'other';
154
166
  }
155
167
 
156
- // ─── DOM helpers ──────────────────────────────────────────────────────────
157
- function getPostContainers() {
158
- 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 => {
159
172
  if (!el.isConnected) return false;
160
173
  if (!el.querySelector('[component="post/content"]')) return false;
161
- const parent = el.parentElement?.closest('[component="post"][data-pid]');
162
- if (parent && parent !== el) return false;
163
- if (el.getAttribute('component') === 'post/parent') return false;
164
- 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';
165
177
  });
166
178
  }
167
179
 
168
- function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
169
- 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));
170
182
 
171
- function hasAdjacentWrap(el) {
172
- return !!(el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
173
- el.previousElementSibling?.classList?.contains(WRAP_CLASS));
183
+ function adjacentWrap(el) {
184
+ return !!(
185
+ el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
186
+ el.previousElementSibling?.classList?.contains(WRAP_CLASS)
187
+ );
174
188
  }
175
189
 
176
- // ─── Ordinal global (BUG FIX #3) ──────────────────────────────────────────
190
+ // ── Ancres stables ────────────────────────────────────────────────────────
191
+
177
192
  /**
178
- * Retourne l'ordinal ABSOLU d'un élément dans la page complète (pas le batch).
179
- *
180
- * Pour les posts (topic) : NodeBB expose data-index (0-based) sur chaque
181
- * [component="post"]. On l'utilise directement.
182
- *
183
- * Pour les topics (liste catégorie) : idem, data-index sur le <li>.
184
- *
185
- * Fallback : on parcourt le DOM pour compter la position réelle de l'élément
186
- * parmi ses frères de même type.
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.
187
196
  */
188
- function getGlobalOrdinal(el, selector) {
189
- // 1. data-index (NodeBB 3+/4+) — 0-based → on retourne 1-based
190
- const di = el.getAttribute('data-index');
191
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10); // déjà 0-based, on le garde
192
-
193
- // 2. Compter dans le DOM parmi les frères du même type
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
194
204
  try {
195
- const all = el.parentElement?.querySelectorAll?.(':scope > ' + selector.split('[')[0]);
196
- if (all) {
197
- let i = 0;
198
- for (const node of all) {
199
- if (node === el) return i;
200
- i++;
201
- }
205
+ let i = 0;
206
+ for (const s of el.parentElement?.children ?? []) {
207
+ if (s === el) return `i${i}`;
208
+ i++;
202
209
  }
203
210
  } catch (_) {}
204
-
205
- return 0;
211
+ return 'i0';
206
212
  }
207
213
 
208
- /**
209
- * Clé d'ancre unique et stable pour un élément donné.
210
- * Format : "kindClass:globalOrdinal"
211
- * → Identique au scroll up/down, identique entre batches.
212
- */
213
- function getAnchorKey(kindClass, el, selector) {
214
- const ord = getGlobalOrdinal(el, selector);
215
- return `${kindClass}:${ord}`;
216
- }
214
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
217
215
 
218
- function findWrapByAnchor(anchorKey) {
219
- // CSS.escape pour les : dans la clé
216
+ function findWrap(anchorKey) {
220
217
  try {
221
- return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${anchorKey.replace(/"/g, '\\"')}"]`);
218
+ return document.querySelector(
219
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
+ );
222
221
  } catch (_) { return null; }
223
222
  }
224
223
 
225
- // ─── Pool rotation ────────────────────────────────────────────────────────
224
+ // ── Pool ───────────────────────────────────────────────────────────────────
225
+
226
226
  function pickId(poolKey) {
227
- const pool = state.pools[poolKey];
228
- if (!pool.length) return null;
229
- for (let tries = 0; tries < pool.length; tries++) {
230
- const idx = state.cursors[poolKey] % pool.length;
231
- state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
232
- const id = pool[idx];
233
- 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;
234
233
  }
235
234
  return null;
236
235
  }
237
236
 
238
- // ─── Wrap build / insert / remove ─────────────────────────────────────────
239
- function buildWrap(id, kindClass, anchorKey) {
240
- const wrap = document.createElement('div');
241
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
242
- wrap.setAttribute(ANCHOR_ATTR, anchorKey);
243
- wrap.setAttribute(WRAPID_ATTR, String(id));
244
- wrap.setAttribute(CREATED_ATTR, String(now()));
245
- wrap.style.cssText = 'width:100%;display:block;';
237
+ // ── Wraps DOM ──────────────────────────────────────────────────────────────
246
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;';
247
246
  const ph = document.createElement('div');
248
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
+ ph.id = `${PH_PREFIX}${id}`;
249
248
  ph.setAttribute('data-ezoic-id', String(id));
250
- wrap.appendChild(ph);
251
- return wrap;
249
+ w.appendChild(ph);
250
+ return w;
252
251
  }
253
252
 
254
- function insertWrapAfter(el, id, kindClass, anchorKey) {
253
+ function insertAfter(el, id, klass, key) {
255
254
  if (!el?.insertAdjacentElement) return null;
256
- if (findWrapByAnchor(anchorKey)) return null; // déjà inséré
257
- if (state.mountedIds.has(id)) return null; // id déjà monté
258
- if (document.getElementById(`${PLACEHOLDER_PREFIX}${id}`)?.isConnected) return null;
259
-
260
- const wrap = buildWrap(id, kindClass, anchorKey);
261
- withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
262
- state.mountedIds.add(id);
263
- 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;
264
262
  }
265
263
 
266
- function removeWrap(wrap) {
264
+ function dropWrap(w) {
267
265
  try {
268
- const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
269
- if (Number.isFinite(id)) state.mountedIds.delete(id);
270
- try { state.io?.unobserve(wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`)); } catch (_) {}
271
- 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();
272
270
  } catch (_) {}
273
271
  }
274
272
 
275
- // ─── Prune (BUG FIX #1) ───────────────────────────────────────────────────
273
+ // ── Prune ──────────────────────────────────────────────────────────────────
274
+
276
275
  /**
277
- * Supprime les wraps dont l'ancre DOM n'est plus connectée.
276
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
278
277
  *
279
- * Règle simple et sans exception "filled" :
280
- * - Si l'élément ancre est présent et connecté → wrap OK, rien à faire.
281
- * - Si l'élément ancre est absent (virtualisé/retiré) wrap supprimé,
282
- * qu'il soit rempli ou non. Cela libère l'ID pour réutilisation.
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
283
  *
284
- * On ne touche PAS aux wraps fraîchement créés (< 5s) pour laisser le
285
- * temps à NodeBB de finir d'insérer les posts du batch.
284
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
286
285
  */
287
- function pruneOrphans(kindClass, selector) {
288
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
289
-
290
- wraps.forEach(wrap => {
291
- const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
292
- if (now() - created < 5_000) return; // trop récent, on laisse
286
+ function pruneOrphans(klass) {
287
+ const meta = KIND[klass];
288
+ if (!meta) return;
293
289
 
294
- const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
295
- if (!anchorKey) { withInternalMutation(() => removeWrap(wrap)); return; }
290
+ const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
296
291
 
297
- // Retrouver l'ordinal depuis la clé
298
- const ordStr = anchorKey.split(':').slice(1).join(':');
299
- const ord = parseInt(ordStr, 10);
292
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
293
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
300
294
 
301
- // Chercher l'élément ancre par son ordinal global (data-index)
302
- const anchorEl = isNaN(ord)
303
- ? null
304
- : document.querySelector(`${selector.split('[')[0]}[data-index="${ord}"]`);
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; }
305
298
 
306
- if (!anchorEl || !anchorEl.isConnected) {
307
- // Ancre disparue → suppression inconditionnelle
308
- withInternalMutation(() => removeWrap(wrap));
309
- }
299
+ const anchorEl = document.querySelector(
300
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
301
+ );
302
+ if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
310
303
  });
311
304
  }
312
305
 
313
- // ─── Decluster (BUG FIX #2) ───────────────────────────────────────────────
306
+ // ── Decluster ──────────────────────────────────────────────────────────────
307
+
314
308
  /**
315
- * Supprime les doublons adjacents.
316
- * Ne touche JAMAIS un wrap qui vient de recevoir un showAds() (fill async).
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.
317
312
  */
318
- function decluster(kindClass) {
319
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
320
-
321
- for (const wrap of wraps) {
322
- let prev = wrap.previousElementSibling;
323
- let steps = 0;
324
- while (prev && steps < 3) {
325
- if (prev.classList?.contains(WRAP_CLASS)) {
326
- const wShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
327
- const pShown = parseInt(prev.getAttribute(SHOWN_ATTR) || '0', 10);
328
- const wFilled = isFilledNode(wrap);
329
- const pFilled = isFilledNode(prev);
330
-
331
- // Ne jamais toucher un wrap en cours de fill (dans les FILL_GRACE_MS après show)
332
- const wInGrace = wShown && (now() - wShown) < FILL_GRACE_MS;
333
- const pInGrace = pShown && (now() - pShown) < FILL_GRACE_MS;
334
-
335
- if (wInGrace || pInGrace) break; // les deux en grace → rien
336
-
337
- if (!wFilled && !wInGrace) {
338
- withInternalMutation(() => removeWrap(wrap));
339
- } else if (!pFilled && !pInGrace) {
340
- withInternalMutation(() => removeWrap(prev));
341
- }
342
- break;
343
- }
344
- prev = prev.previousElementSibling;
345
- 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;
346
329
  }
347
330
  }
348
331
  }
349
332
 
350
- // ─── Injection (BUG FIX #3) ───────────────────────────────────────────────
333
+ // ── Injection ──────────────────────────────────────────────────────────────
334
+
351
335
  /**
352
- * Calcule les positions cibles basées sur l'ordinal GLOBAL de chaque item.
353
- *
354
- * interval=3 pub après les posts dont (globalOrdinal % interval === interval-1)
355
- * c.-à-d. après les posts globaux 2, 5, 8, 11… (0-based)
356
- *
357
- * showFirst=true → aussi après le post global 0.
358
- *
359
- * Ce calcul est STABLE entre les batches : si les posts 0-19 sont en DOM,
360
- * les cibles sont 2, 5, 8, 11, 14, 17. Si les posts 20-39 arrivent,
361
- * les cibles deviennent 20 (si 20%3===2? non), 23, 26, 29, 32, 35, 38.
362
- * Jamais de recalcul depuis 0.
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).
363
339
  */
364
- function injectBetween(kindClass, items, interval, showFirst, poolKey, selector) {
365
- if (!items.length) return 0;
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;
355
+ }
366
356
 
367
- const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
357
+ function injectBetween(klass, items, interval, showFirst, poolKey) {
358
+ if (!items.length) return 0;
368
359
  let inserted = 0;
369
360
 
370
361
  for (const el of items) {
371
- if (inserted >= maxIns) break;
362
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
372
363
  if (!el?.isConnected) continue;
373
364
 
374
- const ord = getGlobalOrdinal(el, selector);
375
-
376
- // Est-ce une position cible ?
365
+ const ord = ordinal(klass, el);
377
366
  const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
378
367
  if (!isTarget) continue;
379
368
 
380
- if (hasAdjacentWrap(el)) continue;
369
+ if (adjacentWrap(el)) continue;
381
370
 
382
- const anchorKey = `${kindClass}:${ord}`;
383
- if (findWrapByAnchor(anchorKey)) continue;
371
+ const key = makeAnchorKey(klass, el);
372
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
384
373
 
385
374
  const id = pickId(poolKey);
386
- if (!id) break;
387
-
388
- const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
389
- if (!wrap) continue;
375
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
390
376
 
391
- observePlaceholder(id);
392
- inserted++;
377
+ const w = insertAfter(el, id, klass, key);
378
+ if (w) { observePh(id); inserted++; }
393
379
  }
394
-
395
380
  return inserted;
396
381
  }
397
382
 
398
- // ─── Preload / Show ───────────────────────────────────────────────────────
399
- function getPreloadMargin() {
400
- const m = isMobile() ? 'mobile' : 'desktop';
401
- return PRELOAD_MARGIN[m + (isBoosted() ? 'Boosted' : '')];
402
- }
403
-
404
- function getMaxInflight() {
405
- return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
406
- }
407
-
408
- function ensurePreloadObserver() {
409
- const margin = getPreloadMargin();
410
- if (state.io && state.ioMargin === margin) return state.io;
411
-
412
- state.io?.disconnect();
413
- state.io = null;
383
+ // ── IntersectionObserver & Show ────────────────────────────────────────────
414
384
 
385
+ function getIO() {
386
+ if (S.io) return S.io;
387
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
415
388
  try {
416
- state.io = new IntersectionObserver(entries => {
417
- for (const ent of entries) {
418
- if (!ent.isIntersecting) continue;
419
- state.io?.unobserve(ent.target);
420
- 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);
421
394
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
422
395
  }
423
396
  }, { root: null, rootMargin: margin, threshold: 0 });
424
- state.ioMargin = margin;
425
- } catch (_) { state.io = null; state.ioMargin = null; }
426
-
427
- try {
428
- document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
429
- try { state.io?.observe(n); } catch (_) {}
430
- });
431
- } catch (_) {}
432
-
433
- return state.io;
397
+ } catch (_) { S.io = null; }
398
+ return S.io;
434
399
  }
435
400
 
436
- function observePlaceholder(id) {
437
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
438
- if (!ph?.isConnected) return;
439
- try { state.io?.observe(ph); } catch (_) {}
440
-
441
- try {
442
- const r = ph.getBoundingClientRect();
443
- const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
444
- if (r.top < window.innerHeight * screens && r.bottom > -window.innerHeight * 2) {
445
- enqueueShow(id);
446
- }
447
- } catch (_) {}
401
+ function observePh(id) {
402
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
403
+ if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
448
404
  }
449
405
 
450
406
  function enqueueShow(id) {
451
407
  if (!id || isBlocked()) return;
452
- const t = now();
453
- if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
454
-
455
- if (state.inflight >= getMaxInflight()) {
456
- if (!state.pendingSet.has(id)) { state.pending.push(id); state.pendingSet.add(id); }
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); }
457
411
  return;
458
412
  }
459
413
  startShow(id);
@@ -461,96 +415,73 @@
461
415
 
462
416
  function drainQueue() {
463
417
  if (isBlocked()) return;
464
- while (state.inflight < getMaxInflight() && state.pending.length) {
465
- const id = state.pending.shift();
466
- state.pendingSet.delete(id);
418
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
419
+ const id = S.pending.shift();
420
+ S.pendingSet.delete(id);
467
421
  startShow(id);
468
422
  }
469
423
  }
470
424
 
471
425
  function startShow(id) {
472
426
  if (!id || isBlocked()) return;
473
- state.inflight++;
427
+ S.inflight++;
474
428
  let done = false;
475
-
476
429
  const release = () => {
477
430
  if (done) return;
478
431
  done = true;
479
- state.inflight = Math.max(0, state.inflight - 1);
432
+ S.inflight = Math.max(0, S.inflight - 1);
480
433
  drainQueue();
481
434
  };
482
-
483
- const timeout = setTimeout(release, 6500);
435
+ const timer = setTimeout(release, 7000);
484
436
 
485
437
  requestAnimationFrame(() => {
486
438
  try {
487
- if (isBlocked()) { 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(); }
488
442
 
489
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
490
- if (!ph?.isConnected) { clearTimeout(timeout); return release(); }
491
- if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
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);
492
446
 
493
- const t = now();
494
- if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) {
495
- clearTimeout(timeout); return release();
496
- }
497
- state.lastShowById.set(id, t);
498
-
499
- // Marquer le timestamp du show sur le wrap (pour decluster grace period)
500
- try {
501
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
502
- if (wrap) wrap.setAttribute(SHOWN_ATTR, String(t));
503
- } catch (_) {}
447
+ // Horodater le show sur le wrap pour grace period + emptyCheck
448
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
504
449
 
505
450
  window.ezstandalone = window.ezstandalone || {};
506
451
  const ez = window.ezstandalone;
507
-
508
452
  const doShow = () => {
509
453
  try { ez.showAds(id); } catch (_) {}
510
454
  scheduleEmptyCheck(id, t);
511
- setTimeout(() => { clearTimeout(timeout); release(); }, 650);
455
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
512
456
  };
513
-
514
- if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
515
- else doShow();
516
- } catch (_) { clearTimeout(timeout); release(); }
457
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
458
+ } catch (_) { clearTimeout(timer); release(); }
517
459
  });
518
460
  }
519
461
 
520
- /**
521
- * Vérifie si le wrap est toujours vide après EMPTY_CHECK_DELAY.
522
- * On compare avec le timestamp du show pour éviter de colapser
523
- * un wrap qui aurait reçu un nouveau show entre-temps.
524
- */
525
462
  function scheduleEmptyCheck(id, showTs) {
526
463
  setTimeout(() => {
527
464
  try {
528
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
529
- if (!ph?.isConnected) return;
530
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
531
- if (!wrap) return;
532
-
533
- // Si un nouveau show a eu lieu après celui-ci, ne pas colapser
534
- const lastShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
535
- if (lastShown > showTs) return;
536
-
537
- if (!isFilledNode(ph)) wrap.classList.add('is-empty');
538
- 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));
539
471
  } catch (_) {}
540
- }, EMPTY_CHECK_DELAY);
472
+ }, EMPTY_CHECK_MS);
541
473
  }
542
474
 
543
- // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
475
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
476
+
544
477
  function patchShowAds() {
545
478
  const apply = () => {
546
479
  try {
547
480
  window.ezstandalone = window.ezstandalone || {};
548
481
  const ez = window.ezstandalone;
549
- if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
550
-
551
- window.__nodebbEzoicPatched = true;
482
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
483
+ window.__nbbEzPatched = true;
552
484
  const orig = ez.showAds.bind(ez);
553
-
554
485
  ez.showAds = function (...args) {
555
486
  if (isBlocked()) return;
556
487
  const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
@@ -558,24 +489,22 @@
558
489
  for (const v of ids) {
559
490
  const id = parseInt(v, 10);
560
491
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
561
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
562
- if (!ph?.isConnected) continue;
492
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
563
493
  seen.add(id);
564
494
  try { orig(id); } catch (_) {}
565
495
  }
566
496
  };
567
497
  } catch (_) {}
568
498
  };
569
-
570
499
  apply();
571
- if (!window.__nodebbEzoicPatched) {
500
+ if (!window.__nbbEzPatched) {
572
501
  window.ezstandalone = window.ezstandalone || {};
573
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
574
- window.ezstandalone.cmd.push(apply);
502
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
575
503
  }
576
504
  }
577
505
 
578
- // ─── Core run ─────────────────────────────────────────────────────────────
506
+ // ── Core run ───────────────────────────────────────────────────────────────
507
+
579
508
  async function runCore() {
580
509
  if (isBlocked()) return 0;
581
510
  patchShowAds();
@@ -585,155 +514,126 @@
585
514
  initPools(cfg);
586
515
 
587
516
  const kind = getKind();
588
- let inserted = 0;
517
+ if (kind === 'other') return 0;
589
518
 
590
- /**
591
- * @param {string} kindClass
592
- * @param {() => Element[]} getItems
593
- * @param {string} selector - sélecteur CSS de base (pour ordinal fallback)
594
- * @param {*} cfgEnable
595
- * @param {number} cfgInterval
596
- * @param {*} cfgShowFirst
597
- * @param {string} poolKey
598
- */
599
- const run = (kindClass, getItems, selector, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
519
+ const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
600
520
  if (!normBool(cfgEnable)) return 0;
601
521
  const items = getItems();
602
522
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
603
- const first = normBool(cfgShowFirst);
604
-
605
- pruneOrphans(kindClass, selector);
606
- const n = injectBetween(kindClass, items, interval, first, poolKey, selector);
607
- if (n) decluster(kindClass);
523
+ pruneOrphans(klass);
524
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
525
+ if (n) decluster(klass);
608
526
  return n;
609
527
  };
610
528
 
611
- if (kind === 'topic') {
612
- inserted += run('ezoic-ad-message', getPostContainers, SELECTORS.postItem,
613
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
614
- } else if (kind === 'categoryTopics') {
615
- inserted += run('ezoic-ad-between', getTopicItems, SELECTORS.topicItem,
616
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
617
- } else if (kind === 'categories') {
618
- inserted += run('ezoic-ad-categories', getCategoryItems, SELECTORS.categoryItem,
619
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
620
- }
621
-
622
- return inserted;
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;
623
542
  }
624
543
 
625
- // ─── Scheduler / Burst ────────────────────────────────────────────────────
626
- function scheduleRun(delayMs, cb) {
627
- if (state.runQueued) return;
628
- state.runQueued = true;
544
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
629
545
 
630
- const run = async () => {
631
- state.runQueued = false;
632
- if (state.pageKey && getPageKey() !== state.pageKey) return;
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;
633
552
  let n = 0;
634
553
  try { n = await runCore(); } catch (_) {}
635
554
  try { cb?.(n); } catch (_) {}
636
- };
637
-
638
- const doRun = () => requestAnimationFrame(run);
639
- if (delayMs > 0) setTimeout(doRun, delayMs);
640
- else doRun();
555
+ });
641
556
  }
642
557
 
643
558
  function requestBurst() {
644
559
  if (isBlocked()) return;
645
- const t = now();
646
- if (t - state.lastBurstReqTs < 100) return;
647
- state.lastBurstReqTs = t;
560
+ const t = ts();
561
+ if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
562
+ S.lastBurstTs = t;
648
563
 
649
- const pk = getPageKey();
650
- state.pageKey = pk;
651
- state.burstDeadline = t + 1800;
564
+ const pk = pageKey();
565
+ S.pageKey = pk;
566
+ S.burstDeadline = t + 2000;
652
567
 
653
- if (state.burstActive) return;
654
- state.burstActive = true;
655
- state.burstCount = 0;
568
+ if (S.burstActive) return;
569
+ S.burstActive = true;
570
+ S.burstCount = 0;
656
571
 
657
572
  const step = () => {
658
- if (getPageKey() !== pk) { state.burstActive = false; return; }
659
- if (isBlocked()) { state.burstActive = false; return; }
660
- if (now() > state.burstDeadline) { state.burstActive = false; return; }
661
- if (state.burstCount >= 8) { state.burstActive = false; return; }
662
-
663
- state.burstCount++;
664
- scheduleRun(0, (n) => {
665
- if (!n && !state.pending.length) { state.burstActive = false; return; }
666
- 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);
667
580
  });
668
581
  };
669
-
670
582
  step();
671
583
  }
672
584
 
673
- // ─── Cleanup ──────────────────────────────────────────────────────────────
674
- function cleanup() {
675
- blockedUntil = now() + 1500;
585
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
676
586
 
677
- withInternalMutation(() => {
678
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
679
- });
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 ───────────────────────────────────────────────────────────
680
603
 
681
- state.cfg = null;
682
- state.pools = { topics: [], posts: [], categories: [] };
683
- state.cursors = { topics: 0, posts: 0, categories: 0 };
684
- state.mountedIds.clear();
685
- state.lastShowById.clear();
686
- state.inflight = 0;
687
- state.pending = [];
688
- state.pendingSet.clear();
689
- state.burstActive = false;
690
- state.runQueued = false;
691
- }
692
-
693
- // ─── DOM Observer ─────────────────────────────────────────────────────────
694
- function shouldReact(mutations) {
695
- for (const m of mutations) {
696
- if (!m.addedNodes?.length) continue;
697
- for (const n of m.addedNodes) {
698
- if (n.nodeType !== 1) continue;
699
- if (n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
700
- n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
701
- n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem))
702
- 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
+ }
703
618
  }
704
- }
705
- return false;
619
+ });
620
+ try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
706
621
  }
707
622
 
708
- function ensureDomObserver() {
709
- if (state.domObs) return;
710
- state.domObs = new MutationObserver(mutations => {
711
- if (state.internalMutation > 0) return;
712
- if (isBlocked()) return;
713
- if (!shouldReact(mutations)) return;
714
- requestBurst();
715
- });
716
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
717
- }
718
-
719
- // ─── Utilities ────────────────────────────────────────────────────────────
720
- function muteNoisyConsole() {
721
- if (window.__nodebbEzoicConsoleMuted) return;
722
- window.__nodebbEzoicConsoleMuted = true;
723
- const MUTED = [
724
- '[EzoicAds JS]: Placeholder Id',
725
- 'Debugger iframe already exists',
726
- 'HTML element with id ezoic-pub-ad-placeholder-',
727
- ];
728
- ['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']) {
729
630
  const orig = console[m];
730
- if (typeof orig !== 'function') return;
731
- console[m] = function (...args) {
732
- const s = typeof args[0] === 'string' ? args[0] : '';
733
- if (MUTED.some(p => s.includes(p))) return;
734
- 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);
735
635
  };
736
- });
636
+ }
737
637
  }
738
638
 
739
639
  function ensureTcfLocator() {
@@ -741,76 +641,64 @@
741
641
  if (!window.__tcfapi && !window.__cmp) return;
742
642
  if (document.getElementById('__tcfapiLocator')) return;
743
643
  const f = document.createElement('iframe');
744
- f.style.display = 'none'; f.id = '__tcfapiLocator'; f.name = '__tcfapiLocator';
644
+ f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
745
645
  (document.body || document.documentElement).appendChild(f);
746
646
  } catch (_) {}
747
647
  }
748
648
 
749
- const _warmedLinks = new Set();
649
+ const _warmed = new Set();
750
650
  function warmNetwork() {
751
651
  const head = document.head;
752
652
  if (!head) return;
753
- const links = [
653
+ for (const [rel, href, cors] of [
754
654
  ['preconnect', 'https://g.ezoic.net', true],
755
655
  ['preconnect', 'https://go.ezoic.net', true],
756
656
  ['preconnect', 'https://securepubads.g.doubleclick.net', true],
757
657
  ['preconnect', 'https://pagead2.googlesyndication.com', true],
758
658
  ['dns-prefetch', 'https://g.ezoic.net', false],
759
659
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
760
- ];
761
- for (const [rel, href, cors] of links) {
762
- const key = `${rel}|${href}`;
763
- if (_warmedLinks.has(key)) continue;
764
- _warmedLinks.add(key);
765
- const link = document.createElement('link');
766
- link.rel = rel; link.href = href;
767
- if (cors) link.crossOrigin = 'anonymous';
768
- head.appendChild(link);
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);
769
668
  }
770
669
  }
771
670
 
772
- // ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
671
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
672
+
773
673
  function bindNodeBB() {
774
674
  const $ = window.jQuery;
775
675
  if (!$) return;
776
676
 
777
- $(window).off('.ezoicInfinite');
778
-
779
- $(window).on('action:ajaxify.start.ezoicInfinite', cleanup);
780
-
781
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
782
- state.pageKey = getPageKey();
783
- blockedUntil = 0;
784
- muteNoisyConsole();
785
- ensureTcfLocator();
786
- warmNetwork();
787
- patchShowAds();
788
- ensurePreloadObserver();
789
- ensureDomObserver();
790
- 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();
791
684
  });
792
685
 
793
- const burstEvents = [
686
+ const BURST_EVENTS = [
794
687
  'action:ajaxify.contentLoaded',
795
- 'action:posts.loaded',
796
- 'action:topics.loaded',
797
- 'action:categories.loaded',
798
- 'action:category.loaded',
799
- 'action:topic.loaded',
800
- ].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(' ');
801
691
 
802
- $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
692
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
803
693
 
694
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
804
695
  try {
805
696
  require(['hooks'], hooks => {
806
697
  if (typeof hooks?.on !== 'function') return;
807
- [
808
- 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
809
- 'action:posts.loaded', 'action:topics.loaded',
810
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
811
- ].forEach(ev => {
698
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
699
+ 'action:categories.loaded', 'action:topic.loaded']) {
812
700
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
813
- });
701
+ }
814
702
  });
815
703
  } catch (_) {}
816
704
  }
@@ -818,35 +706,24 @@
818
706
  function bindScroll() {
819
707
  let ticking = false;
820
708
  window.addEventListener('scroll', () => {
821
- try {
822
- const t = now(), y = window.scrollY || window.pageYOffset || 0;
823
- if (state.lastScrollTs) {
824
- const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
825
- if (speed >= BOOST_SPEED_PX_PER_MS) {
826
- const was = isBoosted();
827
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
828
- if (!was) ensurePreloadObserver();
829
- }
830
- }
831
- state.lastScrollY = y; state.lastScrollTs = t;
832
- } catch (_) {}
833
-
834
709
  if (ticking) return;
835
710
  ticking = true;
836
711
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
837
712
  }, { passive: true });
838
713
  }
839
714
 
840
- // ─── Boot ─────────────────────────────────────────────────────────────────
841
- state.pageKey = getPageKey();
842
- muteNoisyConsole();
715
+ // ── Boot ───────────────────────────────────────────────────────────────────
716
+
717
+ S.pageKey = pageKey();
718
+ muteConsole();
843
719
  ensureTcfLocator();
844
720
  warmNetwork();
845
721
  patchShowAds();
846
- ensurePreloadObserver();
722
+ getIO();
847
723
  ensureDomObserver();
848
724
  bindNodeBB();
849
725
  bindScroll();
850
726
  blockedUntil = 0;
851
727
  requestBurst();
728
+
852
729
  })();