nodebb-plugin-ezoic-infinite 1.7.0 → 1.7.2

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,419 @@
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(),
88
-
89
- lastShowById: new Map(),
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
90
88
 
91
- domObs: null,
92
- io: null, ioMargin: null,
89
+ io: null,
90
+ domObs: null,
91
+ mutGuard: 0, // compteur internalMutation
93
92
 
94
- internalMutation: 0,
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
95
96
 
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);
133
128
  }
134
129
 
135
- // ─── Page / Kind ──────────────────────────────────────────────────────────
136
- function getPageKey() {
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;
137
+ }
138
+
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
+ // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
+ // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
+ // "parameter 1 is not of type Element" sur le prochain observe).
271
+ try {
272
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
+ if (ph instanceof Element) S.io?.unobserve(ph);
274
+ } catch (_) {}
275
+ w.remove();
272
276
  } catch (_) {}
273
277
  }
274
278
 
275
- // ─── Prune (BUG FIX #1) ───────────────────────────────────────────────────
279
+ // ── Prune ──────────────────────────────────────────────────────────────────
280
+
276
281
  /**
277
- * Supprime les wraps dont l'ancre DOM n'est plus connectée.
282
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
278
283
  *
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.
284
+ * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
+ * Exemples :
286
+ * ezoic-ad-message cherche [data-pid="123"]
287
+ * ezoic-ad-between → cherche [data-index="5"]
288
+ * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
283
289
  *
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.
290
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
286
291
  */
287
- function pruneOrphans(kindClass, selector) {
288
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
292
+ function pruneOrphans(klass) {
293
+ const meta = KIND[klass];
294
+ if (!meta) return;
289
295
 
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
296
+ const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
293
297
 
294
- const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
295
- if (!anchorKey) { withInternalMutation(() => removeWrap(wrap)); return; }
298
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
299
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
296
300
 
297
- // Retrouver l'ordinal depuis la clé
298
- const ordStr = anchorKey.split(':').slice(1).join(':');
299
- const ord = parseInt(ordStr, 10);
301
+ const key = w.getAttribute(A_ANCHOR) ?? '';
302
+ const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
303
+ if (!sid) { mutate(() => dropWrap(w)); return; }
300
304
 
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}"]`);
305
-
306
- if (!anchorEl || !anchorEl.isConnected) {
307
- // Ancre disparue → suppression inconditionnelle
308
- withInternalMutation(() => removeWrap(wrap));
309
- }
305
+ const anchorEl = document.querySelector(
306
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
+ );
308
+ if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
310
309
  });
311
310
  }
312
311
 
313
- // ─── Decluster (BUG FIX #2) ───────────────────────────────────────────────
312
+ // ── Decluster ──────────────────────────────────────────────────────────────
313
+
314
314
  /**
315
- * Supprime les doublons adjacents.
316
- * Ne touche JAMAIS un wrap qui vient de recevoir un showAds() (fill async).
315
+ * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
316
+ * Priorité : filled > en grâce (fill en cours) > vide.
317
+ * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
317
318
  */
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++;
319
+ function decluster(klass) {
320
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
+ // Grace sur le wrap courant : on le saute entièrement
322
+ const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
+ if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
+
325
+ let prev = w.previousElementSibling, steps = 0;
326
+ while (prev && steps++ < 3) {
327
+ if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
+
329
+ const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
331
+
332
+ if (!isFilled(w)) mutate(() => dropWrap(w));
333
+ else if (!isFilled(prev)) mutate(() => dropWrap(prev));
334
+ break;
346
335
  }
347
336
  }
348
337
  }
349
338
 
350
- // ─── Injection (BUG FIX #3) ───────────────────────────────────────────────
339
+ // ── Injection ──────────────────────────────────────────────────────────────
340
+
351
341
  /**
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.
342
+ * Ordinal 0-based pour le calcul de l'intervalle.
343
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
363
345
  */
364
- function injectBetween(kindClass, items, interval, showFirst, poolKey, selector) {
365
- if (!items.length) return 0;
346
+ function ordinal(klass, el) {
347
+ const di = el.getAttribute('data-index');
348
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
349
+ // Fallback positionnel
350
+ try {
351
+ const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
352
+ if (tag) {
353
+ let i = 0;
354
+ for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
355
+ if (n === el) return i;
356
+ i++;
357
+ }
358
+ }
359
+ } catch (_) {}
360
+ return 0;
361
+ }
366
362
 
367
- const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
363
+ function injectBetween(klass, items, interval, showFirst, poolKey) {
364
+ if (!items.length) return 0;
368
365
  let inserted = 0;
369
366
 
370
367
  for (const el of items) {
371
- if (inserted >= maxIns) break;
368
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
372
369
  if (!el?.isConnected) continue;
373
370
 
374
- const ord = getGlobalOrdinal(el, selector);
375
-
376
- // Est-ce une position cible ?
371
+ const ord = ordinal(klass, el);
377
372
  const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
378
373
  if (!isTarget) continue;
379
374
 
380
- if (hasAdjacentWrap(el)) continue;
375
+ if (adjacentWrap(el)) continue;
381
376
 
382
- const anchorKey = `${kindClass}:${ord}`;
383
- if (findWrapByAnchor(anchorKey)) continue;
377
+ const key = makeAnchorKey(klass, el);
378
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
384
379
 
385
380
  const id = pickId(poolKey);
386
- if (!id) break;
381
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
387
382
 
388
- const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
389
- if (!wrap) continue;
390
-
391
- observePlaceholder(id);
392
- inserted++;
383
+ const w = insertAfter(el, id, klass, key);
384
+ if (w) { observePh(id); inserted++; }
393
385
  }
394
-
395
386
  return inserted;
396
387
  }
397
388
 
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;
389
+ // ── IntersectionObserver & Show ────────────────────────────────────────────
414
390
 
391
+ function getIO() {
392
+ if (S.io) return S.io;
393
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
415
394
  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);
395
+ S.io = new IntersectionObserver(entries => {
396
+ for (const e of entries) {
397
+ if (!e.isIntersecting) continue;
398
+ if (e.target instanceof Element) S.io?.unobserve(e.target);
399
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
421
400
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
422
401
  }
423
402
  }, { 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;
403
+ } catch (_) { S.io = null; }
404
+ return S.io;
434
405
  }
435
406
 
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 (_) {}
407
+ function observePh(id) {
408
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
409
+ if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
448
410
  }
449
411
 
450
412
  function enqueueShow(id) {
451
413
  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); }
414
+ if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
415
+ if (S.inflight >= MAX_INFLIGHT) {
416
+ if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
457
417
  return;
458
418
  }
459
419
  startShow(id);
@@ -461,96 +421,73 @@
461
421
 
462
422
  function drainQueue() {
463
423
  if (isBlocked()) return;
464
- while (state.inflight < getMaxInflight() && state.pending.length) {
465
- const id = state.pending.shift();
466
- state.pendingSet.delete(id);
424
+ while (S.inflight < MAX_INFLIGHT && S.pending.length) {
425
+ const id = S.pending.shift();
426
+ S.pendingSet.delete(id);
467
427
  startShow(id);
468
428
  }
469
429
  }
470
430
 
471
431
  function startShow(id) {
472
432
  if (!id || isBlocked()) return;
473
- state.inflight++;
433
+ S.inflight++;
474
434
  let done = false;
475
-
476
435
  const release = () => {
477
436
  if (done) return;
478
437
  done = true;
479
- state.inflight = Math.max(0, state.inflight - 1);
438
+ S.inflight = Math.max(0, S.inflight - 1);
480
439
  drainQueue();
481
440
  };
482
-
483
- const timeout = setTimeout(release, 6500);
441
+ const timer = setTimeout(release, 7000);
484
442
 
485
443
  requestAnimationFrame(() => {
486
444
  try {
487
- if (isBlocked()) { clearTimeout(timeout); return release(); }
445
+ if (isBlocked()) { clearTimeout(timer); return release(); }
446
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
447
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
488
448
 
489
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
490
- if (!ph?.isConnected) { clearTimeout(timeout); return release(); }
491
- if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
492
-
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);
449
+ const t = ts();
450
+ if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
+ S.lastShow.set(id, t);
498
452
 
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 (_) {}
453
+ // Horodater le show sur le wrap pour grace period + emptyCheck
454
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
504
455
 
505
456
  window.ezstandalone = window.ezstandalone || {};
506
457
  const ez = window.ezstandalone;
507
-
508
458
  const doShow = () => {
509
459
  try { ez.showAds(id); } catch (_) {}
510
460
  scheduleEmptyCheck(id, t);
511
- setTimeout(() => { clearTimeout(timeout); release(); }, 650);
461
+ setTimeout(() => { clearTimeout(timer); release(); }, 700);
512
462
  };
513
-
514
- if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
515
- else doShow();
516
- } catch (_) { clearTimeout(timeout); release(); }
463
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
464
+ } catch (_) { clearTimeout(timer); release(); }
517
465
  });
518
466
  }
519
467
 
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
468
  function scheduleEmptyCheck(id, showTs) {
526
469
  setTimeout(() => {
527
470
  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');
471
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
+ if (!wrap || !ph?.isConnected) return;
474
+ // Un show plus récent → ne pas toucher
475
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
+ wrap.classList.toggle('is-empty', !isFilled(ph));
539
477
  } catch (_) {}
540
- }, EMPTY_CHECK_DELAY);
478
+ }, EMPTY_CHECK_MS);
541
479
  }
542
480
 
543
- // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
481
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
482
+
544
483
  function patchShowAds() {
545
484
  const apply = () => {
546
485
  try {
547
486
  window.ezstandalone = window.ezstandalone || {};
548
487
  const ez = window.ezstandalone;
549
- if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
550
-
551
- window.__nodebbEzoicPatched = true;
488
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
489
+ window.__nbbEzPatched = true;
552
490
  const orig = ez.showAds.bind(ez);
553
-
554
491
  ez.showAds = function (...args) {
555
492
  if (isBlocked()) return;
556
493
  const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
@@ -558,24 +495,22 @@
558
495
  for (const v of ids) {
559
496
  const id = parseInt(v, 10);
560
497
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
561
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
562
- if (!ph?.isConnected) continue;
498
+ if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
563
499
  seen.add(id);
564
500
  try { orig(id); } catch (_) {}
565
501
  }
566
502
  };
567
503
  } catch (_) {}
568
504
  };
569
-
570
505
  apply();
571
- if (!window.__nodebbEzoicPatched) {
506
+ if (!window.__nbbEzPatched) {
572
507
  window.ezstandalone = window.ezstandalone || {};
573
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
574
- window.ezstandalone.cmd.push(apply);
508
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
575
509
  }
576
510
  }
577
511
 
578
- // ─── Core run ─────────────────────────────────────────────────────────────
512
+ // ── Core run ───────────────────────────────────────────────────────────────
513
+
579
514
  async function runCore() {
580
515
  if (isBlocked()) return 0;
581
516
  patchShowAds();
@@ -585,232 +520,209 @@
585
520
  initPools(cfg);
586
521
 
587
522
  const kind = getKind();
588
- let inserted = 0;
523
+ if (kind === 'other') return 0;
589
524
 
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) => {
525
+ const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
600
526
  if (!normBool(cfgEnable)) return 0;
601
527
  const items = getItems();
602
528
  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);
529
+ pruneOrphans(klass);
530
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
531
+ if (n) decluster(klass);
608
532
  return n;
609
533
  };
610
534
 
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;
535
+ if (kind === 'topic') return exec(
536
+ 'ezoic-ad-message', getPosts,
537
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
538
+ );
539
+ if (kind === 'categoryTopics') return exec(
540
+ 'ezoic-ad-between', getTopics,
541
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
+ );
543
+ if (kind === 'categories') return exec(
544
+ 'ezoic-ad-categories', getCategories,
545
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
+ );
547
+ return 0;
623
548
  }
624
549
 
625
- // ─── Scheduler / Burst ────────────────────────────────────────────────────
626
- function scheduleRun(delayMs, cb) {
627
- if (state.runQueued) return;
628
- state.runQueued = true;
550
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
629
551
 
630
- const run = async () => {
631
- state.runQueued = false;
632
- if (state.pageKey && getPageKey() !== state.pageKey) return;
552
+ function scheduleRun(cb) {
553
+ if (S.runQueued) return;
554
+ S.runQueued = true;
555
+ requestAnimationFrame(async () => {
556
+ S.runQueued = false;
557
+ if (S.pageKey && pageKey() !== S.pageKey) return;
633
558
  let n = 0;
634
559
  try { n = await runCore(); } catch (_) {}
635
560
  try { cb?.(n); } catch (_) {}
636
- };
637
-
638
- const doRun = () => requestAnimationFrame(run);
639
- if (delayMs > 0) setTimeout(doRun, delayMs);
640
- else doRun();
561
+ });
641
562
  }
642
563
 
643
564
  function requestBurst() {
644
565
  if (isBlocked()) return;
645
- const t = now();
646
- if (t - state.lastBurstReqTs < 100) return;
647
- state.lastBurstReqTs = t;
566
+ const t = ts();
567
+ if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
+ S.lastBurstTs = t;
648
569
 
649
- const pk = getPageKey();
650
- state.pageKey = pk;
651
- state.burstDeadline = t + 1800;
570
+ const pk = pageKey();
571
+ S.pageKey = pk;
572
+ S.burstDeadline = t + 2000;
652
573
 
653
- if (state.burstActive) return;
654
- state.burstActive = true;
655
- state.burstCount = 0;
574
+ if (S.burstActive) return;
575
+ S.burstActive = true;
576
+ S.burstCount = 0;
656
577
 
657
578
  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);
579
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
+ S.burstActive = false; return;
581
+ }
582
+ S.burstCount++;
583
+ scheduleRun(n => {
584
+ if (!n && !S.pending.length) { S.burstActive = false; return; }
585
+ setTimeout(step, n > 0 ? 150 : 300);
667
586
  });
668
587
  };
669
-
670
588
  step();
671
589
  }
672
590
 
673
- // ─── Cleanup ──────────────────────────────────────────────────────────────
674
- function cleanup() {
675
- blockedUntil = now() + 1500;
591
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
676
592
 
677
- withInternalMutation(() => {
678
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
679
- });
593
+ function cleanup() {
594
+ blockedUntil = ts() + 1500;
595
+ mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
596
+ S.cfg = null;
597
+ S.pools = { topics: [], posts: [], categories: [] };
598
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
599
+ S.mountedIds.clear();
600
+ S.lastShow.clear();
601
+ S.inflight = 0;
602
+ S.pending = [];
603
+ S.pendingSet.clear();
604
+ S.burstActive = false;
605
+ S.runQueued = false;
606
+ }
607
+
608
+ // ── DOM Observer ───────────────────────────────────────────────────────────
680
609
 
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;
610
+ function ensureDomObserver() {
611
+ if (S.domObs) return;
612
+ S.domObs = new MutationObserver(muts => {
613
+ if (S.mutGuard > 0 || isBlocked()) return;
614
+ for (const m of muts) {
615
+ if (!m.addedNodes?.length) continue;
616
+ for (const n of m.addedNodes) {
617
+ if (n.nodeType !== 1) continue;
618
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
619
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
620
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
621
+ requestBurst(); return;
622
+ }
623
+ }
703
624
  }
704
- }
705
- return false;
625
+ });
626
+ try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
706
627
  }
707
628
 
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 => {
629
+ // ── Utilitaires ────────────────────────────────────────────────────────────
630
+
631
+ function muteConsole() {
632
+ if (window.__nbbEzMuted) return;
633
+ window.__nbbEzMuted = true;
634
+ const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
635
+ for (const m of ['log', 'info', 'warn', 'error']) {
729
636
  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);
637
+ if (typeof orig !== 'function') continue;
638
+ console[m] = function (...a) {
639
+ if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
640
+ orig.apply(console, a);
735
641
  };
736
- });
642
+ }
737
643
  }
738
644
 
739
645
  function ensureTcfLocator() {
646
+ // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
647
+ // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
648
+ // iframe du DOM (vidage partiel du body), ce qui provoque :
649
+ // "Cannot read properties of null (reading 'postMessage')"
650
+ // "Cannot set properties of null (setting 'addtlConsent')"
651
+ // Solution : la recrée immédiatement si elle disparaît, via un observer.
740
652
  try {
741
653
  if (!window.__tcfapi && !window.__cmp) return;
742
- if (document.getElementById('__tcfapiLocator')) return;
743
- const f = document.createElement('iframe');
744
- f.style.display = 'none'; f.id = '__tcfapiLocator'; f.name = '__tcfapiLocator';
745
- (document.body || document.documentElement).appendChild(f);
654
+
655
+ const inject = () => {
656
+ if (document.getElementById('__tcfapiLocator')) return;
657
+ const f = document.createElement('iframe');
658
+ f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
+ (document.body || document.documentElement).appendChild(f);
660
+ };
661
+
662
+ inject();
663
+
664
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
+ if (!window.__nbbTcfObs) {
666
+ window.__nbbTcfObs = new MutationObserver(() => inject());
667
+ window.__nbbTcfObs.observe(document.documentElement,
668
+ { childList: true, subtree: true });
669
+ }
746
670
  } catch (_) {}
747
671
  }
748
672
 
749
- const _warmedLinks = new Set();
673
+ const _warmed = new Set();
750
674
  function warmNetwork() {
751
675
  const head = document.head;
752
676
  if (!head) return;
753
- const links = [
677
+ for (const [rel, href, cors] of [
754
678
  ['preconnect', 'https://g.ezoic.net', true],
755
679
  ['preconnect', 'https://go.ezoic.net', true],
756
680
  ['preconnect', 'https://securepubads.g.doubleclick.net', true],
757
681
  ['preconnect', 'https://pagead2.googlesyndication.com', true],
758
682
  ['dns-prefetch', 'https://g.ezoic.net', false],
759
683
  ['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);
684
+ ]) {
685
+ const k = `${rel}|${href}`;
686
+ if (_warmed.has(k)) continue;
687
+ _warmed.add(k);
688
+ const l = document.createElement('link');
689
+ l.rel = rel; l.href = href;
690
+ if (cors) l.crossOrigin = 'anonymous';
691
+ head.appendChild(l);
769
692
  }
770
693
  }
771
694
 
772
- // ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
695
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
696
+
773
697
  function bindNodeBB() {
774
698
  const $ = window.jQuery;
775
699
  if (!$) return;
776
700
 
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();
701
+ $(window).off('.nbbEzoic');
702
+ $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
703
+ $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
+ S.pageKey = pageKey();
705
+ blockedUntil = 0;
706
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
+ getIO(); ensureDomObserver(); requestBurst();
791
708
  });
792
709
 
793
- const burstEvents = [
710
+ const BURST_EVENTS = [
794
711
  '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(' ');
712
+ 'action:posts.loaded', 'action:topics.loaded',
713
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
+ ].map(e => `${e}.nbbEzoic`).join(' ');
801
715
 
802
- $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
716
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
803
717
 
718
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
804
719
  try {
805
720
  require(['hooks'], hooks => {
806
721
  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 => {
722
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
723
+ 'action:categories.loaded', 'action:topic.loaded']) {
812
724
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
813
- });
725
+ }
814
726
  });
815
727
  } catch (_) {}
816
728
  }
@@ -818,35 +730,24 @@
818
730
  function bindScroll() {
819
731
  let ticking = false;
820
732
  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
733
  if (ticking) return;
835
734
  ticking = true;
836
735
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
837
736
  }, { passive: true });
838
737
  }
839
738
 
840
- // ─── Boot ─────────────────────────────────────────────────────────────────
841
- state.pageKey = getPageKey();
842
- muteNoisyConsole();
739
+ // ── Boot ───────────────────────────────────────────────────────────────────
740
+
741
+ S.pageKey = pageKey();
742
+ muteConsole();
843
743
  ensureTcfLocator();
844
744
  warmNetwork();
845
745
  patchShowAds();
846
- ensurePreloadObserver();
746
+ getIO();
847
747
  ensureDomObserver();
848
748
  bindNodeBB();
849
749
  bindScroll();
850
750
  blockedUntil = 0;
851
751
  requestBurst();
752
+
852
753
  })();