nodebb-plugin-ezoic-infinite 1.7.81 → 1.7.83

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/library.js CHANGED
@@ -15,11 +15,9 @@ function normalizeExcludedGroups(value) {
15
15
  // NodeBB stocke les settings multi-valeurs comme string JSON "[\"group1\",\"group2\"]"
16
16
  const s = String(value).trim();
17
17
  if (s.startsWith('[')) {
18
- try {
19
- const parsed = JSON.parse(s);
20
- if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
21
- } catch (_) {}
18
+ try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
22
19
  }
20
+ // Fallback : séparation par virgule
23
21
  return s.split(',').map(v => v.trim()).filter(Boolean);
24
22
  }
25
23
 
@@ -30,15 +28,7 @@ function parseBool(v, def = false) {
30
28
  return s === '1' || s === 'true' || s === 'on' || s === 'yes';
31
29
  }
32
30
 
33
- // ── Cache groupes (fix #7 : getAllGroups sans cache sur page ACP) ───────────
34
-
35
- let _groupsCache = null;
36
- let _groupsCacheAt = 0;
37
- const GROUPS_TTL = 60_000; // 1 minute
38
-
39
31
  async function getAllGroups() {
40
- const now = Date.now();
41
- if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
42
32
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
43
33
  if (!names || !names.length) {
44
34
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
@@ -47,9 +37,7 @@ async function getAllGroups() {
47
37
  const data = await groups.getGroupsData(filtered);
48
38
  const valid = data.filter(g => g && g.name);
49
39
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
50
- _groupsCache = valid;
51
- _groupsCacheAt = now;
52
- return _groupsCache;
40
+ return valid;
53
41
  }
54
42
 
55
43
  // ── Settings cache (30s TTL) ────────────────────────────────────────────────
@@ -100,10 +88,7 @@ ezstandalone.cmd = ezstandalone.cmd || [];
100
88
  // ── Hooks ──────────────────────────────────────────────────────────────────
101
89
 
102
90
  plugin.onSettingsSet = function (data) {
103
- if (data && data.hash === SETTINGS_KEY) {
104
- _settingsCache = null;
105
- _groupsCache = null; // invalider aussi le cache groupes
106
- }
91
+ if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
107
92
  };
108
93
 
109
94
  plugin.addAdminNavigation = async (header) => {
@@ -116,11 +101,11 @@ plugin.addAdminNavigation = async (header) => {
116
101
  * Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
117
102
  *
118
103
  * NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
119
- * (render.js : templateValues.customHTML = meta.config.customHTML).
120
- * Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData.
121
- * On préfixe customHTML pour passer AVANT le customHTML admin tout en le préservant.
122
- *
123
- * Fix #3 : erreurs loggées côté serveur plutôt qu'avalées silencieusement.
104
+ * (render.js ligne 232 : templateValues.customHTML = meta.config.customHTML).
105
+ * Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
106
+ * et est rendu via req.app.renderAsync('header', hookReturn.templateData).
107
+ * On préfixe customHTML pour que nos scripts passent AVANT le customHTML admin,
108
+ * tout en préservant ce dernier.
124
109
  */
125
110
  plugin.injectEzoicHead = async (data) => {
126
111
  try {
@@ -128,18 +113,17 @@ plugin.injectEzoicHead = async (data) => {
128
113
  const uid = data.req?.uid ?? 0;
129
114
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
130
115
  if (!excluded) {
116
+ // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
131
117
  data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
132
118
  }
133
- } catch (err) {
134
- // Log l'erreur mais ne pas planter le rendu de la page
135
- console.error('[ezoic-infinite] injectEzoicHead error:', err.message || err);
136
- }
119
+ } catch (_) {}
137
120
  return data;
138
121
  };
139
122
 
140
123
  plugin.init = async ({ router, middleware }) => {
141
124
  async function render(req, res) {
142
- const [settings, allGroups] = await Promise.all([getSettings(), getAllGroups()]);
125
+ const settings = await getSettings();
126
+ const allGroups = await getAllGroups();
143
127
  res.render('admin/plugins/ezoic-infinite', {
144
128
  title: 'Ezoic Infinite Ads',
145
129
  ...settings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.81",
3
+ "version": "1.7.83",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,49 +1,92 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v69
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
3
3
  *
4
- * Historique
5
- * ──────────
6
- * v18 Ancrage stable par data-pid / data-index.
7
- * v20 Table KIND. IO fixe. Fix TCF locator.
8
- * v25 Fix scroll-up / virtualisation NodeBB.
9
- * v28 Wraps persistants pendant la session.
10
- * v36 S.wrapByKey Map O(1). MutationObserver optimisé.
11
- * v38 Pool épuisé break propre (ez.refresh supprimé).
12
- * v49 Fix normalizeExcludedGroups (JSON.parse tableau NodeBB).
13
- * v51 fetchConfig backoff 10s. IO recrée au resize.
14
- * v52 pruneOrphansBetween supprimé (NodeBB virtualise les topics).
15
- * v56 scheduleEmptyCheck supprimé (collapse prématuré).
16
- * v58 tcfObs survit aux navigations (iframe CMP permanente).
17
- * v62 is-empty réintroduit, déclenché à l'insertion du wrap.
18
- * v64 recycleAndMove supprimé (slots restaient en 'unused' après destroy).
19
- * v67 define(allIds) au boot — Ezoic enregistre tous les slots en interne.
20
- * v68 setIsSinglePageApplication(true) + newPage() à chaque navigation.
21
- * v69 scheduleEmptyCheck déplacé dans startShow (après showAds, comme v50).
22
- * IO_MARGIN réduit (800px/1200px) : évite que AMP charge une pub trop
23
- * tôt et la retire immédiatement car déjà hors viewport au chargement.
24
- * Nettoyage prod final : S.recycling orphelin supprimé, helpers Ezoic
25
- * SPA extraits en fonctions dédiées, commentaires legacy retirés.
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
7
+ *
8
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
+ * la position dans le batch courant.
10
+ *
11
+ * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
12
+ * Fix fatal catégories : data-cid au lieu de data-index inexistant.
13
+ * IO fixe (une instance, jamais recréée).
14
+ * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
15
+ *
16
+ * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
17
+ *
18
+ * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
19
+ *
20
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
21
+ *
22
+ * v28 decluster supprimé. Wraps persistants pendant la session.
23
+ *
24
+ * v32 Retour anchorAttr = data-index pour ezoic-ad-between.
25
+ * data-tid peut être absent clés invalides → wraps empilés.
26
+ * pruneOrphansBetween réactivé uniquement pour topics de catégorie :
27
+ * – NodeBB NE virtualise PAS les topics dans une liste de catégorie,
28
+ * les ancres (data-index) restent en DOM → prune safe et nécessaire
29
+ * pour éviter l'empilement après scroll long.
30
+ * – Toujours désactivé pour les posts : NodeBB virtualise les posts
31
+ * hors-viewport → faux-orphelins → bug réinjection en haut.
32
+ *
33
+ * v34 moveDistantWrap — voir v38.
34
+ *
35
+ * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
+ * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
+ * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
38
+ *
39
+ * v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
40
+ *
41
+ * v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
42
+ *
43
+ * v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
44
+ *
45
+ * v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
46
+ * Séquence : destroy → 300ms → define → 300ms → displayMore.
47
+ * Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
48
+ *
49
+ * v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
50
+ * sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
51
+ * déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
52
+ * break propre dans injectBetween. muteConsole : ajout warnings refresh.
53
+ *
54
+ * v36 Optimisations chemin critique (scroll → injectBetween) :
55
+ * – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
56
+ * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
57
+ * dropWrap et cleanup.
58
+ * – wrapIsLive allégé : pour les voisins immédiats on vérifie les
59
+ * attributs du nœud lui-même sans querySelector global.
60
+ * – MutationObserver : matches() vérifié avant querySelector() pour
61
+ * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
62
+ *
63
+ * v35 Revue complète prod-ready :
64
+ * – initPools protégé contre ré-initialisation inutile (S.poolsReady).
65
+ * – muteConsole élargit à "No valid placeholders for loadMore".
66
+ * – Commentaires et historique nettoyés.
26
67
  */
27
68
  (function nbbEzoicInfinite() {
28
69
  'use strict';
29
70
 
30
71
  // ── Constantes ─────────────────────────────────────────────────────────────
31
72
 
32
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
33
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
34
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
35
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
73
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
74
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
75
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
76
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
77
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
36
79
 
37
- const EMPTY_CHECK_MS = 15_000; // collapse wrap vide 15s après showAds
38
- const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
39
- const MAX_INFLIGHT = 4; // showAds() simultanés max
40
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
41
- const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
80
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
84
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
42
86
 
43
- // Marge asymétrique : précharge 1200px en bas, 0px en haut.
44
- // Évite que AMP charge une pub déjà sortie du viewport vers le haut.
45
- const IO_MARGIN_DESKTOP = '0px 0px 1200px 0px';
46
- const IO_MARGIN_MOBILE = '0px 0px 1500px 0px';
87
+ // Marges IO larges et fixes observer créé une seule fois au boot
88
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
89
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
47
90
 
48
91
  const SEL = {
49
92
  post: '[component="post"][data-pid]',
@@ -53,10 +96,13 @@
53
96
 
54
97
  /**
55
98
  * Table KIND — source de vérité par kindClass.
56
- * sel sélecteur CSS des éléments cibles
99
+ *
100
+ * sel sélecteur CSS complet des éléments cibles
57
101
  * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
58
103
  * anchorAttr attribut DOM stable → clé unique du wrap
59
- * ordinalAttr attribut 0-based pour calcul de l'intervalle (null = fallback positionnel)
104
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
+ * null → fallback positionnel (catégories)
60
106
  */
61
107
  const KIND = {
62
108
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -67,43 +113,73 @@
67
113
  // ── État global ────────────────────────────────────────────────────────────
68
114
 
69
115
  const S = {
70
- pageKey: null,
71
- cfg: null,
72
- poolsReady: false,
73
- pools: { topics: [], posts: [], categories: [] },
74
- cursors: { topics: 0, posts: 0, categories: 0 },
75
- mountedIds: new Set(),
76
- lastShow: new Map(),
77
- io: null,
78
- domObs: null,
79
- tcfObs: null, // survit aux navigations ne jamais déconnecter
80
- mutGuard: 0,
81
- inflight: 0,
82
- pending: [],
83
- pendingSet: new Set(),
84
- wrapByKey: new Map(), // anchorKey → wrap DOM node
85
- runQueued: false,
86
- burstActive: false,
116
+ pageKey: null,
117
+ cfg: null,
118
+ poolsReady: false,
119
+ pools: { topics: [], posts: [], categories: [] },
120
+ cursors: { topics: 0, posts: 0, categories: 0 },
121
+ mountedIds: new Set(),
122
+ lastShow: new Map(),
123
+ io: null,
124
+ domObs: null,
125
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
126
+ inflight: 0, // showAds() en cours
127
+ pending: [], // ids en attente de slot inflight
128
+ pendingSet: new Set(),
129
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
130
+ runQueued: false,
131
+ burstActive: false,
87
132
  burstDeadline: 0,
88
- burstCount: 0,
89
- lastBurstTs: 0,
133
+ burstCount: 0,
134
+ lastBurstTs: 0,
135
+ emptyChecks: new Map(), // id -> timeout ids[]
90
136
  };
91
137
 
92
- let blockedUntil = 0;
93
- let _cfgErrorUntil = 0;
94
- let _ioMobile = null;
95
-
96
- const ts = () => Date.now();
97
- const isBlocked = () => ts() < blockedUntil;
98
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
99
- const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
100
- const normBool = v => _BOOL_TRUE.has(v);
101
- // isFilled : exclut l'img reportline Ezoic (ezoicbwa.png) — faux positif
102
- // data-ez-filled : marqué par watchFill() dès qu'Ezoic touche au placeholder
103
- // (nécessaire pour les pubs AMP dont l'iframe est cross-origin et invisible)
104
- const isFilled = n => !!(n?.closest?.('.nodebb-ezoic-wrap')?.getAttribute('data-ez-filled'))
105
- || !!(n?.querySelector('iframe, ins, video, [data-google-container-id]'))
106
- || !!(n?.querySelector('img:not([src*="ezoicbwa"]):not([src*="ezodn.com"])'));
138
+ let blockedUntil = 0;
139
+
140
+ const ts = () => Date.now();
141
+ const isBlocked = () => ts() < blockedUntil;
142
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
143
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
144
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
145
+
146
+ function clearEmptyChecks(id) {
147
+ const timers = S.emptyChecks.get(id);
148
+ if (!timers) return;
149
+ for (const t of timers) { try { clearTimeout(t); } catch (_) {} }
150
+ S.emptyChecks.delete(id);
151
+ }
152
+
153
+ function queueEmptyCheck(id, timerId) {
154
+ const arr = S.emptyChecks.get(id) || [];
155
+ arr.push(timerId);
156
+ S.emptyChecks.set(id, arr);
157
+ }
158
+
159
+ function uncollapseIfFilled(ph) {
160
+ try {
161
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
162
+ if (!wrap) return false;
163
+ if (!isFilled(ph)) return false;
164
+ wrap.classList.remove('is-empty');
165
+ return true;
166
+ } catch (_) { return false; }
167
+ }
168
+
169
+ function watchPlaceholderFill(ph) {
170
+ if (!ph || ph.__nbbFillObs) return;
171
+ try {
172
+ const obs = new MutationObserver(() => { if (uncollapseIfFilled(ph)) return; });
173
+ obs.observe(ph, { childList: true, subtree: true, attributes: true });
174
+ ph.__nbbFillObs = obs;
175
+ } catch (_) {}
176
+ uncollapseIfFilled(ph);
177
+ }
178
+
179
+ function unwatchPlaceholderFill(ph) {
180
+ try { ph?.__nbbFillObs?.disconnect?.(); } catch (_) {}
181
+ try { if (ph) delete ph.__nbbFillObs; } catch (_) {}
182
+ }
107
183
 
108
184
  function mutate(fn) {
109
185
  S.mutGuard++;
@@ -114,12 +190,10 @@
114
190
 
115
191
  async function fetchConfig() {
116
192
  if (S.cfg) return S.cfg;
117
- if (Date.now() < _cfgErrorUntil) return null;
118
193
  try {
119
194
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
120
- if (r.ok) { S.cfg = await r.json(); }
121
- else { _cfgErrorUntil = Date.now() + 10_000; }
122
- } catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
195
+ if (r.ok) S.cfg = await r.json();
196
+ } catch (_) {}
123
197
  return S.cfg;
124
198
  }
125
199
 
@@ -138,26 +212,8 @@
138
212
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
139
213
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
140
214
  S.poolsReady = true;
141
- // Déclarer tous les ids en une seule fois — requis pour que showAds()
142
- // fonctionne sur des slots insérés dynamiquement (infinite scroll).
143
- const allIds = [...S.pools.topics, ...S.pools.posts, ...S.pools.categories];
144
- if (allIds.length) ezCmd(ez => ez.define(allIds));
145
- }
146
-
147
- // ── Helpers Ezoic ──────────────────────────────────────────────────────────
148
-
149
- function ezCmd(fn) {
150
- try {
151
- window.ezstandalone = window.ezstandalone || {};
152
- const ez = window.ezstandalone;
153
- const exec = () => { try { fn(ez); } catch (_) {} };
154
- typeof ez.cmd?.push === 'function' ? ez.cmd.push(exec) : exec();
155
- } catch (_) {}
156
215
  }
157
216
 
158
- function notifyEzoicSpa() { ezCmd(ez => ez.setIsSinglePageApplication?.(true)); }
159
- function notifyEzoicNewPage() { ezCmd(ez => ez.newPage?.()); }
160
-
161
217
  // ── Page identity ──────────────────────────────────────────────────────────
162
218
 
163
219
  function pageKey() {
@@ -197,37 +253,53 @@
197
253
 
198
254
  // ── Wraps — détection ──────────────────────────────────────────────────────
199
255
 
256
+ /**
257
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
258
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
259
+ */
200
260
  function wrapIsLive(wrap) {
201
261
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
202
262
  const key = wrap.getAttribute(A_ANCHOR);
203
263
  if (!key) return false;
264
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
265
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
204
266
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
267
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
205
268
  const colonIdx = key.indexOf(':');
206
269
  const klass = key.slice(0, colonIdx);
207
270
  const anchorId = key.slice(colonIdx + 1);
208
271
  const cfg = KIND[klass];
209
272
  if (!cfg) return false;
273
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
274
+ // de querySelector global — on cherche parmi les voisins immédiats.
210
275
  const parent = wrap.parentElement;
211
276
  if (parent) {
212
277
  for (const sib of parent.children) {
213
278
  if (sib === wrap) continue;
214
279
  try {
215
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
280
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
216
281
  return sib.isConnected;
282
+ }
217
283
  } catch (_) {}
218
284
  }
219
285
  }
286
+ // Dernier recours : querySelector global
220
287
  try {
221
288
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
222
289
  return !!(found?.isConnected);
223
290
  } catch (_) { return false; }
224
291
  }
225
292
 
226
- const adjacentWrap = el =>
227
- wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
293
+ function adjacentWrap(el) {
294
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
295
+ }
228
296
 
229
297
  // ── Ancres stables ─────────────────────────────────────────────────────────
230
298
 
299
+ /**
300
+ * Retourne la valeur de l'attribut stable pour cet élément,
301
+ * ou un fallback positionnel si l'attribut est absent.
302
+ */
231
303
  function stableId(klass, el) {
232
304
  const attr = KIND[klass]?.anchorAttr;
233
305
  if (attr) {
@@ -246,15 +318,18 @@
246
318
 
247
319
  function findWrap(key) {
248
320
  const w = S.wrapByKey.get(key);
249
- return w?.isConnected ? w : null;
321
+ return (w?.isConnected) ? w : null;
250
322
  }
251
323
 
252
324
  // ── Pool ───────────────────────────────────────────────────────────────────
253
325
 
326
+ /**
327
+ * Retourne le prochain id disponible dans le pool (round-robin),
328
+ * ou null si tous les ids sont montés.
329
+ */
254
330
  function pickId(poolKey) {
255
331
  const pool = S.pools[poolKey];
256
332
  if (!pool.length) return null;
257
- if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
258
333
  for (let t = 0; t < pool.length; t++) {
259
334
  const i = S.cursors[poolKey] % pool.length;
260
335
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -264,13 +339,78 @@
264
339
  return null;
265
340
  }
266
341
 
342
+ /**
343
+ * Pool épuisé : recycle un wrap loin au-dessus du viewport.
344
+ * Séquence avec délais (destroyPlaceholders est asynchrone) :
345
+ * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
346
+ * displayMore = API Ezoic prévue pour l'infinite scroll.
347
+ * Priorité : wraps vides d'abord, remplis si nécessaire.
348
+ */
349
+ function recycleAndMove(klass, targetEl, newKey) {
350
+ const ez = window.ezstandalone;
351
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
352
+ typeof ez?.define !== 'function' ||
353
+ typeof ez?.displayMore !== 'function') return null;
354
+
355
+ const vh = window.innerHeight || 800;
356
+ // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
357
+ // après pour neutraliser l'IO — plus de showAds parasite possible.
358
+ const threshold = -vh;
359
+ let bestEmpty = null, bestEmptyBottom = Infinity;
360
+ let bestFilled = null, bestFilledBottom = Infinity;
361
+
362
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
363
+ try {
364
+ const rect = wrap.getBoundingClientRect();
365
+ if (rect.bottom > threshold) return;
366
+ if (!isFilled(wrap)) {
367
+ if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
368
+ } else {
369
+ if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
370
+ }
371
+ } catch (_) {}
372
+ });
373
+
374
+ const best = bestEmpty ?? bestFilled;
375
+ if (!best) return null;
376
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
377
+ if (!Number.isFinite(id)) return null;
378
+
379
+ const oldKey = best.getAttribute(A_ANCHOR);
380
+ // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
381
+ // parasite si le nœud était encore dans la zone IO_MARGIN.
382
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
383
+ clearEmptyChecks(id);
384
+ mutate(() => {
385
+ best.setAttribute(A_ANCHOR, newKey);
386
+ best.setAttribute(A_CREATED, String(ts()));
387
+ best.setAttribute(A_SHOWN, '0');
388
+ best.classList.remove('is-empty');
389
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
390
+ if (ph) { ph.innerHTML = ''; watchPlaceholderFill(ph); }
391
+ targetEl.insertAdjacentElement('afterend', best);
392
+ });
393
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
394
+ S.wrapByKey.set(newKey, best);
395
+
396
+ // Délais requis : destroyPlaceholders est asynchrone en interne
397
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
398
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
399
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
400
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
401
+
402
+ return { id, wrap: best };
403
+ }
404
+
267
405
  // ── Wraps DOM — création / suppression ────────────────────────────────────
268
406
 
269
407
  function makeWrap(id, klass, key) {
270
408
  const w = document.createElement('div');
271
409
  w.className = `${WRAP_CLASS} ${klass}`;
272
- w.setAttribute(A_ANCHOR, key);
273
- w.setAttribute(A_WRAPID, String(id));
410
+ w.setAttribute(A_ANCHOR, key);
411
+ w.setAttribute(A_WRAPID, String(id));
412
+ w.setAttribute(A_CREATED, String(ts()));
413
+ w.setAttribute(A_SHOWN, '0');
274
414
  w.style.cssText = 'width:100%;display:block;';
275
415
  const ph = document.createElement('div');
276
416
  ph.id = `${PH_PREFIX}${id}`;
@@ -279,30 +419,7 @@
279
419
  return w;
280
420
  }
281
421
 
282
- function watchFill(id, wrap) {
283
- // Phase 1 : attend qu'une vraie pub apparaisse (iframe/ins).
284
- // Phase 2 : si la pub disparaît (AMP lazy unload) → réobserve le placeholder
285
- // pour relancer showAds quand il repasse dans le viewport.
286
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
287
- if (!ph) return;
288
- let filled = false;
289
- const obs = new MutationObserver(() => {
290
- const hasPub = !!(ph.querySelector('iframe, ins'));
291
- if (!filled && hasPub) {
292
- filled = true;
293
- wrap.setAttribute('data-ez-filled', '1');
294
- wrap.classList.remove('is-empty');
295
- } else if (filled && !hasPub) {
296
- // Pub disparue → retirer data-ez-filled et réobserver pour reload
297
- wrap.removeAttribute('data-ez-filled');
298
- filled = false;
299
- try { getIO()?.observe(ph); } catch (_) {}
300
- }
301
- });
302
- obs.observe(ph, { childList: true, subtree: true });
303
- }
304
-
305
- function insertAfter(el, id, klass, key) {
422
+ function insertAfter(el, id, klass, key) {
306
423
  if (!el?.insertAdjacentElement) return null;
307
424
  if (findWrap(key)) return null;
308
425
  if (S.mountedIds.has(id)) return null;
@@ -311,24 +428,60 @@
311
428
  mutate(() => el.insertAdjacentElement('afterend', w));
312
429
  S.mountedIds.add(id);
313
430
  S.wrapByKey.set(key, w);
314
- watchFill(id, w);
431
+ try { watchPlaceholderFill(w.querySelector(`#${PH_PREFIX}${id}`)); } catch (_) {}
315
432
  return w;
316
433
  }
317
434
 
318
435
  function dropWrap(w) {
319
436
  try {
320
437
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
321
- if (ph instanceof Element) S.io?.unobserve(ph);
438
+ if (ph instanceof Element) { S.io?.unobserve(ph); unwatchPlaceholderFill(ph); }
322
439
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
323
- if (Number.isFinite(id)) S.mountedIds.delete(id);
440
+ if (Number.isFinite(id)) { S.mountedIds.delete(id); clearEmptyChecks(id); }
324
441
  const key = w.getAttribute(A_ANCHOR);
325
442
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
326
443
  w.remove();
327
444
  } catch (_) {}
328
445
  }
329
446
 
447
+ // ── Prune (topics de catégorie uniquement) ────────────────────────────────
448
+ //
449
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
450
+ //
451
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
452
+ // les li[component="category/topic"] restent dans le DOM pendant toute
453
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
454
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
455
+ // liste après un long scroll et bloquent les nouvelles injections.
456
+ //
457
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
458
+ // NodeBB virtualise les posts hors-viewport — il les retire puis les
459
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
460
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
461
+
462
+ function pruneOrphansBetween() {
463
+ const klass = 'ezoic-ad-between';
464
+ const cfg = KIND[klass];
465
+
466
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
467
+ const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
468
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
469
+
470
+ const key = w.getAttribute(A_ANCHOR) ?? '';
471
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
472
+ if (!sid) { mutate(() => dropWrap(w)); return; }
473
+
474
+ const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
475
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
476
+ });
477
+ }
478
+
330
479
  // ── Injection ──────────────────────────────────────────────────────────────
331
480
 
481
+ /**
482
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
483
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
484
+ */
332
485
  function ordinal(klass, el) {
333
486
  const attr = KIND[klass]?.ordinalAttr;
334
487
  if (attr) {
@@ -347,18 +500,27 @@
347
500
  function injectBetween(klass, items, interval, showFirst, poolKey) {
348
501
  if (!items.length) return 0;
349
502
  let inserted = 0;
503
+
350
504
  for (const el of items) {
351
505
  if (inserted >= MAX_INSERTS_RUN) break;
352
506
  if (!el?.isConnected) continue;
507
+
353
508
  const ord = ordinal(klass, el);
354
509
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
355
510
  if (adjacentWrap(el)) continue;
511
+
356
512
  const key = anchorKey(klass, el);
357
513
  if (findWrap(key)) continue;
514
+
358
515
  const id = pickId(poolKey);
359
- if (!id) break;
360
- const w = insertAfter(el, id, klass, key);
361
- if (w) { observePh(id); inserted++; }
516
+ if (id) {
517
+ const w = insertAfter(el, id, klass, key);
518
+ if (w) { observePh(id); inserted++; }
519
+ } else {
520
+ const recycled = recycleAndMove(klass, el, key);
521
+ if (!recycled) break;
522
+ inserted++;
523
+ }
362
524
  }
363
525
  return inserted;
364
526
  }
@@ -366,10 +528,7 @@
366
528
  // ── IntersectionObserver & Show ────────────────────────────────────────────
367
529
 
368
530
  function getIO() {
369
- const mobile = isMobile();
370
- if (S.io && _ioMobile === mobile) return S.io;
371
- if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
372
- _ioMobile = mobile;
531
+ if (S.io) return S.io;
373
532
  try {
374
533
  S.io = new IntersectionObserver(entries => {
375
534
  for (const e of entries) {
@@ -378,30 +537,16 @@
378
537
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
379
538
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
380
539
  }
381
- }, { root: null, rootMargin: mobile ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
540
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
382
541
  } catch (_) { S.io = null; }
383
542
  return S.io;
384
543
  }
385
544
 
386
545
  function observePh(id) {
387
546
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
388
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
389
- }
390
-
391
- function scheduleEmptyCheck(id) {
392
- setTimeout(() => {
393
- try {
394
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
395
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
396
- if (!wrap || !ph?.isConnected) return;
397
- // Ne pas colapser si watchFill a détecté une vraie pub (iframe/ins)
398
- if (wrap.getAttribute('data-ez-filled')) return;
399
- // Ne pas colapser si une iframe est présente (pub AMP cross-origin)
400
- if (wrap.querySelector('iframe, ins')) return;
401
- // Pas de pub détectée → collapse
402
- wrap.classList.add('is-empty');
403
- } catch (_) {}
404
- }, EMPTY_CHECK_MS);
547
+ if (!ph?.isConnected) return;
548
+ watchPlaceholderFill(ph);
549
+ try { getIO()?.observe(ph); } catch (_) {}
405
550
  }
406
551
 
407
552
  function enqueueShow(id) {
@@ -434,30 +579,54 @@
434
579
  drainQueue();
435
580
  };
436
581
  const timer = setTimeout(release, 7000);
582
+
437
583
  requestAnimationFrame(() => {
438
584
  try {
439
585
  if (isBlocked()) { clearTimeout(timer); return release(); }
440
586
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
441
- if (!ph?.isConnected) { clearTimeout(timer); return release(); }
442
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
443
- if (isFilled(ph) || wrap?.getAttribute('data-ez-filled')) { clearTimeout(timer); return release(); }
587
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
588
+ clearEmptyChecks(id);
589
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
590
+
444
591
  const t = ts();
445
592
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
446
593
  S.lastShow.set(id, t);
447
- try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
448
- ezCmd(ez => {
594
+
595
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
596
+
597
+ window.ezstandalone = window.ezstandalone || {};
598
+ const ez = window.ezstandalone;
599
+ const doShow = () => {
449
600
  try { ez.showAds(id); } catch (_) {}
450
- scheduleEmptyCheck(id);
601
+ scheduleEmptyCheck(id, t);
451
602
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
452
- });
603
+ };
604
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
453
605
  } catch (_) { clearTimeout(timer); release(); }
454
606
  });
455
607
  }
456
608
 
609
+ function scheduleEmptyCheck(id, showTs) {
610
+ clearEmptyChecks(id);
611
+ const delays = [EMPTY_CHECK_MS, EMPTY_CHECK_MS + 5000, EMPTY_CHECK_MS + 15000];
612
+ const runCheck = () => {
613
+ try {
614
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
615
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
616
+ if (!wrap || !ph?.isConnected) return;
617
+ if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
618
+ if (uncollapseIfFilled(ph)) return;
619
+ wrap.classList.add('is-empty');
620
+ } catch (_) {}
621
+ };
622
+ for (const d of delays) queueEmptyCheck(id, setTimeout(runCheck, d));
623
+ }
624
+
457
625
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
458
626
  //
459
- // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
460
- // et filtrer les ids dont le placeholder n'est pas connecté au DOM.
627
+ // Intercepte ez.showAds() pour :
628
+ // ignorer les appels pendant blockedUntil
629
+ // – filtrer les ids dont le placeholder n'est pas en DOM
461
630
 
462
631
  function patchShowAds() {
463
632
  const apply = () => {
@@ -493,19 +662,37 @@
493
662
  async function runCore() {
494
663
  if (isBlocked()) return 0;
495
664
  patchShowAds();
665
+
496
666
  const cfg = await fetchConfig();
497
667
  if (!cfg || cfg.excluded) return 0;
498
668
  initPools(cfg);
669
+
499
670
  const kind = getKind();
500
671
  if (kind === 'other') return 0;
672
+
501
673
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
502
674
  if (!normBool(cfgEnable)) return 0;
503
675
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
504
676
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
505
677
  };
506
- if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
507
- if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
508
- return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
678
+
679
+ if (kind === 'topic') return exec(
680
+ 'ezoic-ad-message', getPosts,
681
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
682
+ );
683
+
684
+ if (kind === 'categoryTopics') {
685
+ pruneOrphansBetween();
686
+ return exec(
687
+ 'ezoic-ad-between', getTopics,
688
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
689
+ );
690
+ }
691
+
692
+ return exec(
693
+ 'ezoic-ad-categories', getCategories,
694
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
695
+ );
509
696
  }
510
697
 
511
698
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -529,9 +716,11 @@
529
716
  S.lastBurstTs = t;
530
717
  S.pageKey = pageKey();
531
718
  S.burstDeadline = t + 2000;
719
+
532
720
  if (S.burstActive) return;
533
721
  S.burstActive = true;
534
722
  S.burstCount = 0;
723
+
535
724
  const step = () => {
536
725
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
537
726
  S.burstActive = false; return;
@@ -551,25 +740,22 @@
551
740
  blockedUntil = ts() + 1500;
552
741
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
553
742
  S.cfg = null;
554
- _cfgErrorUntil = 0;
555
743
  S.poolsReady = false;
556
744
  S.pools = { topics: [], posts: [], categories: [] };
557
- S.cursors = { topics: 0, posts: 0, categories: 0 };
745
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
558
746
  S.mountedIds.clear();
559
747
  S.lastShow.clear();
560
748
  S.wrapByKey.clear();
561
- S.inflight = 0;
562
- S.pending = [];
749
+ S.inflight = 0;
750
+ S.pending = [];
563
751
  S.pendingSet.clear();
564
- S.burstActive = false;
565
- S.runQueued = false;
566
- if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
567
- // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
568
- // rester en vie pendant toute la session — la déconnecter entre deux
569
- // navigations cause des erreurs CMP postMessage.
752
+ for (const timers of S.emptyChecks.values()) for (const t of timers) { try { clearTimeout(t); } catch (_) {} }
753
+ S.emptyChecks.clear();
754
+ S.burstActive = false;
755
+ S.runQueued = false;
570
756
  }
571
757
 
572
- // ── MutationObserver DOM ───────────────────────────────────────────────────
758
+ // ── MutationObserver ───────────────────────────────────────────────────────
573
759
 
574
760
  function ensureDomObserver() {
575
761
  if (S.domObs) return;
@@ -579,8 +765,9 @@
579
765
  for (const m of muts) {
580
766
  for (const n of m.addedNodes) {
581
767
  if (n.nodeType !== 1) continue;
582
- if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
583
- allSel.some(s => { try { return !!n.querySelector(s); } catch (_) { return false; } })) {
768
+ // matches() d'abord (O(1)), querySelector() seulement si nécessaire
769
+ if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
770
+ allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
584
771
  requestBurst(); return;
585
772
  }
586
773
  }
@@ -600,7 +787,6 @@
600
787
  'cannot call refresh on the same page',
601
788
  'no placeholders are currently defined in Refresh',
602
789
  'Debugger iframe already exists',
603
- '[CMP] Error in custom getTCData',
604
790
  `with id ${PH_PREFIX}`,
605
791
  ];
606
792
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -623,9 +809,9 @@
623
809
  (document.body || document.documentElement).appendChild(f);
624
810
  };
625
811
  inject();
626
- if (!S.tcfObs) {
627
- S.tcfObs = new MutationObserver(inject);
628
- S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
812
+ if (!window.__nbbTcfObs) {
813
+ window.__nbbTcfObs = new MutationObserver(inject);
814
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
629
815
  }
630
816
  } catch (_) {}
631
817
  }
@@ -657,20 +843,22 @@
657
843
  function bindNodeBB() {
658
844
  const $ = window.jQuery;
659
845
  if (!$) return;
846
+
660
847
  $(window).off('.nbbEzoic');
661
848
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
662
849
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
663
850
  S.pageKey = pageKey();
664
851
  blockedUntil = 0;
665
- muteConsole();
666
- ensureTcfLocator();
852
+ muteConsole(); ensureTcfLocator(); warmNetwork();
667
853
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
668
854
  });
855
+
669
856
  const burstEvts = [
670
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
671
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
857
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
858
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
672
859
  ].map(e => `${e}.nbbEzoic`).join(' ');
673
860
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
861
+
674
862
  try {
675
863
  require(['hooks'], hooks => {
676
864
  if (typeof hooks?.on !== 'function') return;
@@ -689,11 +877,6 @@
689
877
  ticking = true;
690
878
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
691
879
  }, { passive: true });
692
- let resizeTimer = 0;
693
- window.addEventListener('resize', () => {
694
- clearTimeout(resizeTimer);
695
- resizeTimer = setTimeout(getIO, 500);
696
- }, { passive: true });
697
880
  }
698
881
 
699
882
  // ── Boot ───────────────────────────────────────────────────────────────────
package/public/style.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * NodeBB Ezoic Infinite Ads — style.css (v59)
2
+ * NodeBB Ezoic Infinite Ads — style.css (v20)
3
3
  */
4
4
 
5
5
  /* ── Wrapper ──────────────────────────────────────────────────────────────── */
@@ -56,23 +56,19 @@
56
56
  top: auto !important;
57
57
  }
58
58
 
59
- /* ── Réservation d'espace anti-CLS ───────────────────────────────────────── */
60
- /*
61
- Réserve 90px avant que la pub charge (hauteur standard leaderboard).
62
- Évite le saut de layout (CLS) quand AMP/Ezoic redimensionne l'iframe.
63
- */
64
- .nodebb-ezoic-wrap.ezoic-ad-between {
65
- min-height: 90px;
66
- }
67
-
68
59
  /* ── État vide ────────────────────────────────────────────────────────────── */
69
60
  /*
70
- Ajouté 60s après showAds si aucun fill détecté (délai généreux pour CMP/enchères).
71
- Collapse à 0 : évite la ligne de quelques pixels visible quand Ezoic
72
- injecte un conteneur vide mais ne sert pas de pub.
61
+ Ajouté 20s après showAds si aucun fill détecté.
62
+ Collapse à 1px (pas 0) : reste observable par l'IO si le fill arrive tard.
73
63
  */
74
64
  .nodebb-ezoic-wrap.is-empty {
75
- display: none !important;
65
+ display: block !important;
66
+ height: 1px !important;
67
+ min-height: 1px !important;
68
+ max-height: 1px !important;
69
+ margin: 0 !important;
70
+ padding: 0 !important;
71
+ overflow: hidden !important;
76
72
  }
77
73
 
78
74
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
@@ -80,3 +76,12 @@
80
76
  margin: 0 !important;
81
77
  padding: 0 !important;
82
78
  }
79
+
80
+
81
+ /* Filet de sécurité : si Ezoic a rempli le wrap, annuler le collapse même si .is-empty est resté */
82
+ .nodebb-ezoic-wrap.is-empty:has(iframe, ins, img, video, [data-google-container-id]) {
83
+ height: auto !important;
84
+ min-height: 1px !important;
85
+ max-height: none !important;
86
+ overflow: visible !important;
87
+ }