nodebb-plugin-ezoic-infinite 1.7.54 → 1.7.56

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,9 +15,11 @@ 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 { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
18
+ try {
19
+ const parsed = JSON.parse(s);
20
+ if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
21
+ } catch (_) {}
19
22
  }
20
- // Fallback : séparation par virgule
21
23
  return s.split(',').map(v => v.trim()).filter(Boolean);
22
24
  }
23
25
 
@@ -28,7 +30,15 @@ function parseBool(v, def = false) {
28
30
  return s === '1' || s === 'true' || s === 'on' || s === 'yes';
29
31
  }
30
32
 
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
+
31
39
  async function getAllGroups() {
40
+ const now = Date.now();
41
+ if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
32
42
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
33
43
  if (!names || !names.length) {
34
44
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
@@ -37,7 +47,9 @@ async function getAllGroups() {
37
47
  const data = await groups.getGroupsData(filtered);
38
48
  const valid = data.filter(g => g && g.name);
39
49
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
40
- return valid;
50
+ _groupsCache = valid;
51
+ _groupsCacheAt = now;
52
+ return _groupsCache;
41
53
  }
42
54
 
43
55
  // ── Settings cache (30s TTL) ────────────────────────────────────────────────
@@ -88,7 +100,10 @@ ezstandalone.cmd = ezstandalone.cmd || [];
88
100
  // ── Hooks ──────────────────────────────────────────────────────────────────
89
101
 
90
102
  plugin.onSettingsSet = function (data) {
91
- if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
103
+ if (data && data.hash === SETTINGS_KEY) {
104
+ _settingsCache = null;
105
+ _groupsCache = null; // invalider aussi le cache groupes
106
+ }
92
107
  };
93
108
 
94
109
  plugin.addAdminNavigation = async (header) => {
@@ -101,11 +116,11 @@ plugin.addAdminNavigation = async (header) => {
101
116
  * Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
102
117
  *
103
118
  * NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
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.
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.
109
124
  */
110
125
  plugin.injectEzoicHead = async (data) => {
111
126
  try {
@@ -113,17 +128,18 @@ plugin.injectEzoicHead = async (data) => {
113
128
  const uid = data.req?.uid ?? 0;
114
129
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
115
130
  if (!excluded) {
116
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
117
131
  data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
118
132
  }
119
- } catch (_) {}
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
+ }
120
137
  return data;
121
138
  };
122
139
 
123
140
  plugin.init = async ({ router, middleware }) => {
124
141
  async function render(req, res) {
125
- const settings = await getSettings();
126
- const allGroups = await getAllGroups();
142
+ const [settings, allGroups] = await Promise.all([getSettings(), getAllGroups()]);
127
143
  res.render('admin/plugins/ezoic-infinite', {
128
144
  title: 'Ezoic Infinite Ads',
129
145
  ...settings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.54",
3
+ "version": "1.7.56",
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,90 +1,50 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v61
3
3
  *
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.
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
+ * v35 S.poolsReady. muteConsole élargi.
11
+ * v36 S.wrapByKey Map O(1). MutationObserver optimisé.
12
+ * v38 ez.refresh() supprimé. Pool épuisé break propre.
13
+ * v40 Recyclage destroyPlaceholders+define+displayMore (délais 300ms).
14
+ * v43 Seuil recyclage -1vh + unobserve avant déplacement.
15
+ * v49 Fix normalizeExcludedGroups (JSON.parse tableau NodeBB).
16
+ * v51 fetchConfig backoff 10s. IO recrée au resize. tcfObs dans S.
17
+ * v52 pruneOrphansBetween supprimé (NodeBB virtualise aussi les topics).
18
+ * v53 S.recycling garde double-recyclage. pickId early-exit. cleanup complet.
19
+ * v54 ensureTcfLocator rappelé à chaque ajaxify.end.
20
+ * v56 scheduleEmptyCheck / is-empty supprimés (collapse prématuré).
21
+ * v61 recycleAndMove : ne pas recycler un wrap rempli depuis moins de 30s.
22
+ * Empêche qu'une pub qui vient de charger soit déplacée immédiatement.
23
+ * v59 CSS : min-height 90px sur ezoic-ad-between (anti-CLS AMP ads).
24
+ * v58 tcfObs survit aux navigations : ne plus déconnecter dans cleanup().
25
+ * L'iframe __tcfapiLocator doit exister en permanence pour le CMP
26
+ * la fenêtre entre cleanup() et ajaxify.end causait des erreurs
27
+ * "Cannot read properties of null (postMessage)" et disparition des pubs.
28
+ * v57 Nettoyage prod : A_SHOWN/A_CREATED supprimés, normBool Set O(1),
29
+ * muteConsole étend aux erreurs CMP getTCData, code nettoyé.
67
30
  */
68
31
  (function nbbEzoicInfinite() {
69
32
  'use strict';
70
33
 
71
34
  // ── Constantes ─────────────────────────────────────────────────────────────
72
35
 
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
79
-
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
86
-
87
- // Marges IO larges et fixes — observer créé une seule fois au boot
36
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
37
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
38
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
39
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
40
+
41
+ const EMPTY_CHECK_MS = 60_000;
42
+ const RECYCLE_MIN_AGE_MS = 30_000; // délai minimal avant recyclage d'un wrap rempli // délai avant collapse wrap vide (60s — laisser le temps au CMP/enchères)
43
+ const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
44
+ const MAX_INFLIGHT = 4; // showAds() simultanés max
45
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
46
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
47
+
88
48
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
89
49
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
90
50
 
@@ -96,13 +56,10 @@
96
56
 
97
57
  /**
98
58
  * Table KIND — source de vérité par kindClass.
99
- *
100
- * sel sélecteur CSS complet des éléments cibles
59
+ * sel sélecteur CSS des éléments cibles
101
60
  * baseTag préfixe tag pour querySelector d'ancre
102
- * (vide pour posts : le sélecteur commence par '[')
103
61
  * anchorAttr attribut DOM stable → clé unique du wrap
104
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
- * null → fallback positionnel (catégories)
62
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle (null = fallback positionnel)
106
63
  */
107
64
  const KIND = {
108
65
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -113,34 +70,39 @@
113
70
  // ── État global ────────────────────────────────────────────────────────────
114
71
 
115
72
  const S = {
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,
73
+ pageKey: null,
74
+ cfg: null,
75
+ poolsReady: false,
76
+ pools: { topics: [], posts: [], categories: [] },
77
+ cursors: { topics: 0, posts: 0, categories: 0 },
78
+ mountedIds: new Set(),
79
+ lastShow: new Map(),
80
+ io: null,
81
+ domObs: null,
82
+ tcfObs: null,
83
+ mutGuard: 0,
84
+ inflight: 0,
85
+ pending: [],
86
+ pendingSet: new Set(),
87
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
88
+ recycling: new Set(), // ids en cours de séquence destroy→define→displayMore
89
+ runQueued: false,
90
+ burstActive: false,
132
91
  burstDeadline: 0,
133
- burstCount: 0,
134
- lastBurstTs: 0,
92
+ burstCount: 0,
93
+ lastBurstTs: 0,
135
94
  };
136
95
 
137
- let blockedUntil = 0;
96
+ let blockedUntil = 0;
97
+ let _cfgErrorUntil = 0;
98
+ let _ioMobile = null;
138
99
 
139
- const ts = () => Date.now();
140
- const isBlocked = () => ts() < blockedUntil;
141
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
142
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
143
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
100
+ const ts = () => Date.now();
101
+ const isBlocked = () => ts() < blockedUntil;
102
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
103
+ const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
104
+ const normBool = v => _BOOL_TRUE.has(v);
105
+ const isFilled = n => !!(n?.querySelector('iframe, ins, img, video, [data-google-container-id]'));
144
106
 
145
107
  function mutate(fn) {
146
108
  S.mutGuard++;
@@ -151,10 +113,12 @@
151
113
 
152
114
  async function fetchConfig() {
153
115
  if (S.cfg) return S.cfg;
116
+ if (Date.now() < _cfgErrorUntil) return null;
154
117
  try {
155
118
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
156
- if (r.ok) S.cfg = await r.json();
157
- } catch (_) {}
119
+ if (r.ok) { S.cfg = await r.json(); }
120
+ else { _cfgErrorUntil = Date.now() + 10_000; }
121
+ } catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
158
122
  return S.cfg;
159
123
  }
160
124
 
@@ -214,53 +178,37 @@
214
178
 
215
179
  // ── Wraps — détection ──────────────────────────────────────────────────────
216
180
 
217
- /**
218
- * Vérifie qu'un wrap a encore son ancre dans le DOM.
219
- * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
220
- */
221
181
  function wrapIsLive(wrap) {
222
182
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
223
183
  const key = wrap.getAttribute(A_ANCHOR);
224
184
  if (!key) return false;
225
- // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
226
- // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
227
185
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
- // Fallback : registre pas encore à jour ou wrap non enregistré.
229
186
  const colonIdx = key.indexOf(':');
230
187
  const klass = key.slice(0, colonIdx);
231
188
  const anchorId = key.slice(colonIdx + 1);
232
189
  const cfg = KIND[klass];
233
190
  if (!cfg) return false;
234
- // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
235
- // de querySelector global — on cherche parmi les voisins immédiats.
236
191
  const parent = wrap.parentElement;
237
192
  if (parent) {
238
193
  for (const sib of parent.children) {
239
194
  if (sib === wrap) continue;
240
195
  try {
241
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
196
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
242
197
  return sib.isConnected;
243
- }
244
198
  } catch (_) {}
245
199
  }
246
200
  }
247
- // Dernier recours : querySelector global
248
201
  try {
249
202
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
250
203
  return !!(found?.isConnected);
251
204
  } catch (_) { return false; }
252
205
  }
253
206
 
254
- function adjacentWrap(el) {
255
- return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
- }
207
+ const adjacentWrap = el =>
208
+ wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
257
209
 
258
210
  // ── Ancres stables ─────────────────────────────────────────────────────────
259
211
 
260
- /**
261
- * Retourne la valeur de l'attribut stable pour cet élément,
262
- * ou un fallback positionnel si l'attribut est absent.
263
- */
264
212
  function stableId(klass, el) {
265
213
  const attr = KIND[klass]?.anchorAttr;
266
214
  if (attr) {
@@ -279,18 +227,15 @@
279
227
 
280
228
  function findWrap(key) {
281
229
  const w = S.wrapByKey.get(key);
282
- return (w?.isConnected) ? w : null;
230
+ return w?.isConnected ? w : null;
283
231
  }
284
232
 
285
233
  // ── Pool ───────────────────────────────────────────────────────────────────
286
234
 
287
- /**
288
- * Retourne le prochain id disponible dans le pool (round-robin),
289
- * ou null si tous les ids sont montés.
290
- */
291
235
  function pickId(poolKey) {
292
236
  const pool = S.pools[poolKey];
293
237
  if (!pool.length) return null;
238
+ if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
294
239
  for (let t = 0; t < pool.length; t++) {
295
240
  const i = S.cursors[poolKey] % pool.length;
296
241
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -302,9 +247,7 @@
302
247
 
303
248
  /**
304
249
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
305
- * Séquence avec délais (destroyPlaceholders est asynchrone) :
306
- * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
307
- * displayMore = API Ezoic prévue pour l'infinite scroll.
250
+ * Séquence : destroy([id]) → 300ms → define([id]) 300ms → displayMore([id])
308
251
  * Priorité : wraps vides d'abord, remplis si nécessaire.
309
252
  */
310
253
  function recycleAndMove(klass, targetEl, newKey) {
@@ -313,10 +256,7 @@
313
256
  typeof ez?.define !== 'function' ||
314
257
  typeof ez?.displayMore !== 'function') return null;
315
258
 
316
- const vh = window.innerHeight || 800;
317
- // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
318
- // après pour neutraliser l'IO — plus de showAds parasite possible.
319
- const threshold = -vh;
259
+ const threshold = -(window.innerHeight || 800);
320
260
  let bestEmpty = null, bestEmptyBottom = Infinity;
321
261
  let bestFilled = null, bestFilledBottom = Infinity;
322
262
 
@@ -336,16 +276,18 @@
336
276
  if (!best) return null;
337
277
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
338
278
  if (!Number.isFinite(id)) return null;
279
+ if (S.recycling.has(id)) return null;
280
+ // Ne pas recycler un wrap rempli depuis moins de RECYCLE_MIN_AGE_MS
281
+ if (best === bestFilled) {
282
+ const filledAt = parseInt(best.getAttribute('data-ezoic-filled') || '0', 10);
283
+ if (ts() - filledAt < RECYCLE_MIN_AGE_MS) return null;
284
+ }
285
+ S.recycling.add(id);
339
286
 
340
287
  const oldKey = best.getAttribute(A_ANCHOR);
341
- // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
342
- // parasite si le nœud était encore dans la zone IO_MARGIN.
343
288
  try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
344
289
  mutate(() => {
345
- best.setAttribute(A_ANCHOR, newKey);
346
- best.setAttribute(A_CREATED, String(ts()));
347
- best.setAttribute(A_SHOWN, '0');
348
- best.classList.remove('is-empty');
290
+ best.setAttribute(A_ANCHOR, newKey);
349
291
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
350
292
  if (ph) ph.innerHTML = '';
351
293
  targetEl.insertAdjacentElement('afterend', best);
@@ -353,11 +295,14 @@
353
295
  if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
354
296
  S.wrapByKey.set(newKey, best);
355
297
 
356
- // Délais requis : destroyPlaceholders est asynchrone en interne
357
298
  const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
299
  const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
359
- const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
360
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
300
+ const doDisplay = () => {
301
+ try { ez.displayMore([id]); } catch (_) {}
302
+ S.recycling.delete(id);
303
+ observePh(id);
304
+ };
305
+ try { typeof ez.cmd?.push === 'function' ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
361
306
 
362
307
  return { id, wrap: best };
363
308
  }
@@ -367,10 +312,8 @@
367
312
  function makeWrap(id, klass, key) {
368
313
  const w = document.createElement('div');
369
314
  w.className = `${WRAP_CLASS} ${klass}`;
370
- w.setAttribute(A_ANCHOR, key);
371
- w.setAttribute(A_WRAPID, String(id));
372
- w.setAttribute(A_CREATED, String(ts()));
373
- w.setAttribute(A_SHOWN, '0');
315
+ w.setAttribute(A_ANCHOR, key);
316
+ w.setAttribute(A_WRAPID, String(id));
374
317
  w.style.cssText = 'width:100%;display:block;';
375
318
  const ph = document.createElement('div');
376
319
  ph.id = `${PH_PREFIX}${id}`;
@@ -403,44 +346,8 @@
403
346
  } catch (_) {}
404
347
  }
405
348
 
406
- // ── Prune (topics de catégorie uniquement) ────────────────────────────────
407
- //
408
- // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
409
- //
410
- // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
411
- // les li[component="category/topic"] restent dans le DOM pendant toute
412
- // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
413
- // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
414
- // liste après un long scroll et bloquent les nouvelles injections.
415
- //
416
- // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
417
- // NodeBB virtualise les posts hors-viewport — il les retire puis les
418
- // réinsère. pruneOrphans verrait des ancres temporairement absentes,
419
- // supprimerait les wraps, et provoquerait une réinjection en haut.
420
-
421
- function pruneOrphansBetween() {
422
- const klass = 'ezoic-ad-between';
423
- const cfg = KIND[klass];
424
-
425
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
426
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
427
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
428
-
429
- const key = w.getAttribute(A_ANCHOR) ?? '';
430
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
431
- if (!sid) { mutate(() => dropWrap(w)); return; }
432
-
433
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
434
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
435
- });
436
- }
437
-
438
349
  // ── Injection ──────────────────────────────────────────────────────────────
439
350
 
440
- /**
441
- * Ordinal 0-based pour le calcul de l'intervalle d'injection.
442
- * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
443
- */
444
351
  function ordinal(klass, el) {
445
352
  const attr = KIND[klass]?.ordinalAttr;
446
353
  if (attr) {
@@ -459,18 +366,14 @@
459
366
  function injectBetween(klass, items, interval, showFirst, poolKey) {
460
367
  if (!items.length) return 0;
461
368
  let inserted = 0;
462
-
463
369
  for (const el of items) {
464
370
  if (inserted >= MAX_INSERTS_RUN) break;
465
371
  if (!el?.isConnected) continue;
466
-
467
372
  const ord = ordinal(klass, el);
468
373
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
469
374
  if (adjacentWrap(el)) continue;
470
-
471
375
  const key = anchorKey(klass, el);
472
376
  if (findWrap(key)) continue;
473
-
474
377
  const id = pickId(poolKey);
475
378
  if (id) {
476
379
  const w = insertAfter(el, id, klass, key);
@@ -487,7 +390,10 @@
487
390
  // ── IntersectionObserver & Show ────────────────────────────────────────────
488
391
 
489
392
  function getIO() {
490
- if (S.io) return S.io;
393
+ const mobile = isMobile();
394
+ if (S.io && _ioMobile === mobile) return S.io;
395
+ if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
396
+ _ioMobile = mobile;
491
397
  try {
492
398
  S.io = new IntersectionObserver(entries => {
493
399
  for (const e of entries) {
@@ -496,7 +402,7 @@
496
402
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
497
403
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
498
404
  }
499
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
405
+ }, { root: null, rootMargin: mobile ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
500
406
  } catch (_) { S.io = null; }
501
407
  return S.io;
502
408
  }
@@ -516,6 +422,19 @@
516
422
  startShow(id);
517
423
  }
518
424
 
425
+ function scheduleEmptyCheck(id, showTs) {
426
+ setTimeout(() => {
427
+ try {
428
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
429
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
430
+ if (!wrap || !ph?.isConnected) return;
431
+ // Ne pas écraser un showAds plus récent
432
+ if ((S.lastShow.get(id) ?? 0) > showTs) return;
433
+ wrap.classList.toggle('is-empty', !isFilled(ph));
434
+ } catch (_) {}
435
+ }, EMPTY_CHECK_MS);
436
+ }
437
+
519
438
  function drainQueue() {
520
439
  if (isBlocked()) return;
521
440
  while (S.inflight < MAX_INFLIGHT && S.pending.length) {
@@ -536,19 +455,16 @@
536
455
  drainQueue();
537
456
  };
538
457
  const timer = setTimeout(release, 7000);
539
-
540
458
  requestAnimationFrame(() => {
541
459
  try {
542
460
  if (isBlocked()) { clearTimeout(timer); return release(); }
543
461
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
544
462
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
545
-
546
463
  const t = ts();
547
464
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
548
465
  S.lastShow.set(id, t);
549
-
550
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
551
-
466
+ // Marquer le wrap avec le timestamp de fill pour bloquer le recyclage
467
+ try { const w = document.getElementById(`${PH_PREFIX}${id}`)?.closest?.(`.${WRAP_CLASS}`); if (w) w.setAttribute('data-ezoic-filled', String(t)); } catch (_) {}
552
468
  window.ezstandalone = window.ezstandalone || {};
553
469
  const ez = window.ezstandalone;
554
470
  const doShow = () => {
@@ -561,23 +477,10 @@
561
477
  });
562
478
  }
563
479
 
564
- function scheduleEmptyCheck(id, showTs) {
565
- setTimeout(() => {
566
- try {
567
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
568
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
569
- if (!wrap || !ph?.isConnected) return;
570
- if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
571
- wrap.classList.toggle('is-empty', !isFilled(ph));
572
- } catch (_) {}
573
- }, EMPTY_CHECK_MS);
574
- }
575
-
576
480
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
577
481
  //
578
- // Intercepte ez.showAds() pour :
579
- // ignorer les appels pendant blockedUntil
580
- // – filtrer les ids dont le placeholder n'est pas en DOM
482
+ // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
483
+ // et filtrer les ids dont le placeholder n'est pas connecté au DOM.
581
484
 
582
485
  function patchShowAds() {
583
486
  const apply = () => {
@@ -613,37 +516,19 @@
613
516
  async function runCore() {
614
517
  if (isBlocked()) return 0;
615
518
  patchShowAds();
616
-
617
519
  const cfg = await fetchConfig();
618
520
  if (!cfg || cfg.excluded) return 0;
619
521
  initPools(cfg);
620
-
621
522
  const kind = getKind();
622
523
  if (kind === 'other') return 0;
623
-
624
524
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
625
525
  if (!normBool(cfgEnable)) return 0;
626
526
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
627
527
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
628
528
  };
629
-
630
- if (kind === 'topic') return exec(
631
- 'ezoic-ad-message', getPosts,
632
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
633
- );
634
-
635
- if (kind === 'categoryTopics') {
636
- pruneOrphansBetween();
637
- return exec(
638
- 'ezoic-ad-between', getTopics,
639
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
640
- );
641
- }
642
-
643
- return exec(
644
- 'ezoic-ad-categories', getCategories,
645
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
646
- );
529
+ if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
530
+ if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
531
+ return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
647
532
  }
648
533
 
649
534
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -667,11 +552,9 @@
667
552
  S.lastBurstTs = t;
668
553
  S.pageKey = pageKey();
669
554
  S.burstDeadline = t + 2000;
670
-
671
555
  if (S.burstActive) return;
672
556
  S.burstActive = true;
673
557
  S.burstCount = 0;
674
-
675
558
  const step = () => {
676
559
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
677
560
  S.burstActive = false; return;
@@ -690,21 +573,27 @@
690
573
  function cleanup() {
691
574
  blockedUntil = ts() + 1500;
692
575
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
693
- S.cfg = null;
694
- S.poolsReady = false;
695
- S.pools = { topics: [], posts: [], categories: [] };
696
- S.cursors = { topics: 0, posts: 0, categories: 0 };
576
+ S.cfg = null;
577
+ _cfgErrorUntil = 0;
578
+ S.poolsReady = false;
579
+ S.pools = { topics: [], posts: [], categories: [] };
580
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
697
581
  S.mountedIds.clear();
698
582
  S.lastShow.clear();
699
583
  S.wrapByKey.clear();
700
- S.inflight = 0;
701
- S.pending = [];
584
+ S.recycling.clear();
585
+ S.inflight = 0;
586
+ S.pending = [];
702
587
  S.pendingSet.clear();
703
- S.burstActive = false;
704
- S.runQueued = false;
588
+ S.burstActive = false;
589
+ S.runQueued = false;
590
+ if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
591
+ // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
592
+ // exister en permanence — la déconnecter pendant la navigation cause
593
+ // des erreurs CMP postMessage et la disparition des pubs.
705
594
  }
706
595
 
707
- // ── MutationObserver ───────────────────────────────────────────────────────
596
+ // ── MutationObserver DOM ───────────────────────────────────────────────────
708
597
 
709
598
  function ensureDomObserver() {
710
599
  if (S.domObs) return;
@@ -714,9 +603,8 @@
714
603
  for (const m of muts) {
715
604
  for (const n of m.addedNodes) {
716
605
  if (n.nodeType !== 1) continue;
717
- // matches() d'abord (O(1)), querySelector() seulement si nécessaire
718
- if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
719
- allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
606
+ if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
607
+ allSel.some(s => { try { return !!n.querySelector(s); } catch (_) { return false; } })) {
720
608
  requestBurst(); return;
721
609
  }
722
610
  }
@@ -736,6 +624,7 @@
736
624
  'cannot call refresh on the same page',
737
625
  'no placeholders are currently defined in Refresh',
738
626
  'Debugger iframe already exists',
627
+ '[CMP] Error in custom getTCData',
739
628
  `with id ${PH_PREFIX}`,
740
629
  ];
741
630
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -758,9 +647,9 @@
758
647
  (document.body || document.documentElement).appendChild(f);
759
648
  };
760
649
  inject();
761
- if (!window.__nbbTcfObs) {
762
- window.__nbbTcfObs = new MutationObserver(inject);
763
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
650
+ if (!S.tcfObs) {
651
+ S.tcfObs = new MutationObserver(inject);
652
+ S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
764
653
  }
765
654
  } catch (_) {}
766
655
  }
@@ -792,22 +681,19 @@
792
681
  function bindNodeBB() {
793
682
  const $ = window.jQuery;
794
683
  if (!$) return;
795
-
796
684
  $(window).off('.nbbEzoic');
797
685
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
798
686
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
799
687
  S.pageKey = pageKey();
800
688
  blockedUntil = 0;
801
- muteConsole(); ensureTcfLocator(); warmNetwork();
689
+ muteConsole(); ensureTcfLocator();
802
690
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
803
691
  });
804
-
805
692
  const burstEvts = [
806
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
807
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
693
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
694
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
808
695
  ].map(e => `${e}.nbbEzoic`).join(' ');
809
696
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
-
811
697
  try {
812
698
  require(['hooks'], hooks => {
813
699
  if (typeof hooks?.on !== 'function') return;
@@ -826,6 +712,11 @@
826
712
  ticking = true;
827
713
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
828
714
  }, { passive: true });
715
+ let resizeTimer = 0;
716
+ window.addEventListener('resize', () => {
717
+ clearTimeout(resizeTimer);
718
+ resizeTimer = setTimeout(getIO, 500);
719
+ }, { passive: true });
829
720
  }
830
721
 
831
722
  // ── Boot ───────────────────────────────────────────────────────────────────
package/public/style.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * NodeBB Ezoic Infinite Ads — style.css (v20)
2
+ * NodeBB Ezoic Infinite Ads — style.css (v59)
3
3
  */
4
4
 
5
5
  /* ── Wrapper ──────────────────────────────────────────────────────────────── */
@@ -56,16 +56,26 @@
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
+
59
68
  /* ── État vide ────────────────────────────────────────────────────────────── */
60
69
  /*
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.
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.
63
73
  */
64
74
  .nodebb-ezoic-wrap.is-empty {
65
75
  display: block !important;
66
- height: 1px !important;
67
- min-height: 1px !important;
68
- max-height: 1px !important;
76
+ height: 0 !important;
77
+ min-height: 0 !important;
78
+ max-height: 0 !important;
69
79
  margin: 0 !important;
70
80
  padding: 0 !important;
71
81
  overflow: hidden !important;