nodebb-plugin-ezoic-infinite 1.7.59 → 1.7.60

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.59",
3
+ "version": "1.7.60",
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,51 +1,90 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v62
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
- * 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
- * v62 is-empty réintroduit : collapse 60s après insertion du wrap (pas après
22
- * showAds) si isFilled est toujours false. Évite les trous permanents.
23
- * v61 recycleAndMove : ne pas recycler un wrap rempli depuis moins de 30s.
24
- * Empêche qu'une pub qui vient de charger soit déplacée immédiatement.
25
- * v59 CSS : min-height 90px sur ezoic-ad-between (anti-CLS AMP ads).
26
- * v58 tcfObs survit aux navigations : ne plus déconnecter dans cleanup().
27
- * L'iframe __tcfapiLocator doit exister en permanence pour le CMP
28
- * la fenêtre entre cleanup() et ajaxify.end causait des erreurs
29
- * "Cannot read properties of null (postMessage)" et disparition des pubs.
30
- * v57 Nettoyage prod : A_SHOWN/A_CREATED supprimés, normBool Set O(1),
31
- * muteConsole étend aux erreurs CMP getTCData, code nettoyé.
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.
32
67
  */
33
68
  (function nbbEzoicInfinite() {
34
69
  'use strict';
35
70
 
36
71
  // ── Constantes ─────────────────────────────────────────────────────────────
37
72
 
38
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
39
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
40
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
41
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
42
-
43
- const EMPTY_CHECK_MS = 60_000;
44
- const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
45
- const MAX_INFLIGHT = 4; // showAds() simultanés max
46
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
47
- const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
48
-
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
49
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
50
89
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
51
90
 
@@ -57,10 +96,13 @@
57
96
 
58
97
  /**
59
98
  * Table KIND — source de vérité par kindClass.
60
- * sel sélecteur CSS des éléments cibles
99
+ *
100
+ * sel sélecteur CSS complet des éléments cibles
61
101
  * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
62
103
  * anchorAttr attribut DOM stable → clé unique du wrap
63
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle (null = fallback positionnel)
104
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
+ * null → fallback positionnel (catégories)
64
106
  */
65
107
  const KIND = {
66
108
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -71,39 +113,34 @@
71
113
  // ── État global ────────────────────────────────────────────────────────────
72
114
 
73
115
  const S = {
74
- pageKey: null,
75
- cfg: null,
76
- poolsReady: false,
77
- pools: { topics: [], posts: [], categories: [] },
78
- cursors: { topics: 0, posts: 0, categories: 0 },
79
- mountedIds: new Set(),
80
- lastShow: new Map(),
81
- io: null,
82
- domObs: null,
83
- tcfObs: null,
84
- mutGuard: 0,
85
- inflight: 0,
86
- pending: [],
87
- pendingSet: new Set(),
88
- wrapByKey: new Map(), // anchorKey → wrap DOM node
89
- recycling: new Set(), // ids en cours de séquence destroy→define→displayMore
90
- runQueued: false,
91
- 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,
92
132
  burstDeadline: 0,
93
- burstCount: 0,
94
- lastBurstTs: 0,
133
+ burstCount: 0,
134
+ lastBurstTs: 0,
95
135
  };
96
136
 
97
- let blockedUntil = 0;
98
- let _cfgErrorUntil = 0;
99
- let _ioMobile = null;
137
+ let blockedUntil = 0;
100
138
 
101
- const ts = () => Date.now();
102
- const isBlocked = () => ts() < blockedUntil;
103
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
104
- const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
105
- const normBool = v => _BOOL_TRUE.has(v);
106
- const isFilled = n => !!(n?.querySelector('iframe, ins, img, video, [data-google-container-id]'));
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]'));
107
144
 
108
145
  function mutate(fn) {
109
146
  S.mutGuard++;
@@ -114,12 +151,10 @@
114
151
 
115
152
  async function fetchConfig() {
116
153
  if (S.cfg) return S.cfg;
117
- if (Date.now() < _cfgErrorUntil) return null;
118
154
  try {
119
155
  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; }
156
+ if (r.ok) S.cfg = await r.json();
157
+ } catch (_) {}
123
158
  return S.cfg;
124
159
  }
125
160
 
@@ -179,37 +214,53 @@
179
214
 
180
215
  // ── Wraps — détection ──────────────────────────────────────────────────────
181
216
 
217
+ /**
218
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
219
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
220
+ */
182
221
  function wrapIsLive(wrap) {
183
222
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
184
223
  const key = wrap.getAttribute(A_ANCHOR);
185
224
  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).
186
227
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
187
229
  const colonIdx = key.indexOf(':');
188
230
  const klass = key.slice(0, colonIdx);
189
231
  const anchorId = key.slice(colonIdx + 1);
190
232
  const cfg = KIND[klass];
191
233
  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.
192
236
  const parent = wrap.parentElement;
193
237
  if (parent) {
194
238
  for (const sib of parent.children) {
195
239
  if (sib === wrap) continue;
196
240
  try {
197
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
241
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
198
242
  return sib.isConnected;
243
+ }
199
244
  } catch (_) {}
200
245
  }
201
246
  }
247
+ // Dernier recours : querySelector global
202
248
  try {
203
249
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
204
250
  return !!(found?.isConnected);
205
251
  } catch (_) { return false; }
206
252
  }
207
253
 
208
- const adjacentWrap = el =>
209
- wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
254
+ function adjacentWrap(el) {
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
+ }
210
257
 
211
258
  // ── Ancres stables ─────────────────────────────────────────────────────────
212
259
 
260
+ /**
261
+ * Retourne la valeur de l'attribut stable pour cet élément,
262
+ * ou un fallback positionnel si l'attribut est absent.
263
+ */
213
264
  function stableId(klass, el) {
214
265
  const attr = KIND[klass]?.anchorAttr;
215
266
  if (attr) {
@@ -228,15 +279,18 @@
228
279
 
229
280
  function findWrap(key) {
230
281
  const w = S.wrapByKey.get(key);
231
- return w?.isConnected ? w : null;
282
+ return (w?.isConnected) ? w : null;
232
283
  }
233
284
 
234
285
  // ── Pool ───────────────────────────────────────────────────────────────────
235
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
236
291
  function pickId(poolKey) {
237
292
  const pool = S.pools[poolKey];
238
293
  if (!pool.length) return null;
239
- if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
240
294
  for (let t = 0; t < pool.length; t++) {
241
295
  const i = S.cursors[poolKey] % pool.length;
242
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -246,13 +300,77 @@
246
300
  return null;
247
301
  }
248
302
 
303
+ /**
304
+ * 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.
308
+ * Priorité : wraps vides d'abord, remplis si nécessaire.
309
+ */
310
+ function recycleAndMove(klass, targetEl, newKey) {
311
+ const ez = window.ezstandalone;
312
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
313
+ typeof ez?.define !== 'function' ||
314
+ typeof ez?.displayMore !== 'function') return null;
315
+
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;
320
+ let bestEmpty = null, bestEmptyBottom = Infinity;
321
+ let bestFilled = null, bestFilledBottom = Infinity;
322
+
323
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
324
+ try {
325
+ const rect = wrap.getBoundingClientRect();
326
+ if (rect.bottom > threshold) return;
327
+ if (!isFilled(wrap)) {
328
+ if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
329
+ } else {
330
+ if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
331
+ }
332
+ } catch (_) {}
333
+ });
334
+
335
+ const best = bestEmpty ?? bestFilled;
336
+ if (!best) return null;
337
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
338
+ if (!Number.isFinite(id)) return null;
339
+
340
+ 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
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
344
+ 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');
349
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
350
+ if (ph) ph.innerHTML = '';
351
+ targetEl.insertAdjacentElement('afterend', best);
352
+ });
353
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
354
+ S.wrapByKey.set(newKey, best);
355
+
356
+ // Délais requis : destroyPlaceholders est asynchrone en interne
357
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
+ 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 (_) {}
361
+
362
+ return { id, wrap: best };
363
+ }
364
+
249
365
  // ── Wraps DOM — création / suppression ────────────────────────────────────
250
366
 
251
367
  function makeWrap(id, klass, key) {
252
368
  const w = document.createElement('div');
253
369
  w.className = `${WRAP_CLASS} ${klass}`;
254
- w.setAttribute(A_ANCHOR, key);
255
- w.setAttribute(A_WRAPID, String(id));
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');
256
374
  w.style.cssText = 'width:100%;display:block;';
257
375
  const ph = document.createElement('div');
258
376
  ph.id = `${PH_PREFIX}${id}`;
@@ -270,7 +388,6 @@
270
388
  mutate(() => el.insertAdjacentElement('afterend', w));
271
389
  S.mountedIds.add(id);
272
390
  S.wrapByKey.set(key, w);
273
- scheduleEmptyCheck(id);
274
391
  return w;
275
392
  }
276
393
 
@@ -286,8 +403,44 @@
286
403
  } catch (_) {}
287
404
  }
288
405
 
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
+
289
438
  // ── Injection ──────────────────────────────────────────────────────────────
290
439
 
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
+ */
291
444
  function ordinal(klass, el) {
292
445
  const attr = KIND[klass]?.ordinalAttr;
293
446
  if (attr) {
@@ -306,18 +459,27 @@
306
459
  function injectBetween(klass, items, interval, showFirst, poolKey) {
307
460
  if (!items.length) return 0;
308
461
  let inserted = 0;
462
+
309
463
  for (const el of items) {
310
464
  if (inserted >= MAX_INSERTS_RUN) break;
311
465
  if (!el?.isConnected) continue;
466
+
312
467
  const ord = ordinal(klass, el);
313
468
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
314
469
  if (adjacentWrap(el)) continue;
470
+
315
471
  const key = anchorKey(klass, el);
316
472
  if (findWrap(key)) continue;
473
+
317
474
  const id = pickId(poolKey);
318
- if (!id) break;
319
- const w = insertAfter(el, id, klass, key);
320
- if (w) { observePh(id); inserted++; }
475
+ if (id) {
476
+ const w = insertAfter(el, id, klass, key);
477
+ if (w) { observePh(id); inserted++; }
478
+ } else {
479
+ const recycled = recycleAndMove(klass, el, key);
480
+ if (!recycled) break;
481
+ inserted++;
482
+ }
321
483
  }
322
484
  return inserted;
323
485
  }
@@ -325,10 +487,7 @@
325
487
  // ── IntersectionObserver & Show ────────────────────────────────────────────
326
488
 
327
489
  function getIO() {
328
- const mobile = isMobile();
329
- if (S.io && _ioMobile === mobile) return S.io;
330
- if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
331
- _ioMobile = mobile;
490
+ if (S.io) return S.io;
332
491
  try {
333
492
  S.io = new IntersectionObserver(entries => {
334
493
  for (const e of entries) {
@@ -337,7 +496,7 @@
337
496
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
338
497
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
339
498
  }
340
- }, { root: null, rootMargin: mobile ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
499
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
341
500
  } catch (_) { S.io = null; }
342
501
  return S.io;
343
502
  }
@@ -357,18 +516,6 @@
357
516
  startShow(id);
358
517
  }
359
518
 
360
- function scheduleEmptyCheck(id) {
361
- setTimeout(() => {
362
- try {
363
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
364
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
365
- if (!wrap || !ph?.isConnected) return;
366
- // Collapse uniquement si vraiment vide après 60s
367
- if (!isFilled(wrap)) wrap.classList.add('is-empty');
368
- } catch (_) {}
369
- }, EMPTY_CHECK_MS);
370
- }
371
-
372
519
  function drainQueue() {
373
520
  if (isBlocked()) return;
374
521
  while (S.inflight < MAX_INFLIGHT && S.pending.length) {
@@ -389,21 +536,24 @@
389
536
  drainQueue();
390
537
  };
391
538
  const timer = setTimeout(release, 7000);
539
+
392
540
  requestAnimationFrame(() => {
393
541
  try {
394
542
  if (isBlocked()) { clearTimeout(timer); return release(); }
395
543
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
396
544
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
545
+
397
546
  const t = ts();
398
547
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
399
548
  S.lastShow.set(id, t);
400
- // Marquer le wrap avec le timestamp de fill pour bloquer le recyclage
401
- // Si la pub charge après is-empty, retirer le collapse
402
- try { document.getElementById(`${PH_PREFIX}${id}`)?.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
549
+
550
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
551
+
403
552
  window.ezstandalone = window.ezstandalone || {};
404
553
  const ez = window.ezstandalone;
405
554
  const doShow = () => {
406
555
  try { ez.showAds(id); } catch (_) {}
556
+ scheduleEmptyCheck(id, t);
407
557
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
408
558
  };
409
559
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
@@ -411,10 +561,23 @@
411
561
  });
412
562
  }
413
563
 
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
+
414
576
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
415
577
  //
416
- // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
417
- // et filtrer les ids dont le placeholder n'est pas connecté au DOM.
578
+ // Intercepte ez.showAds() pour :
579
+ // ignorer les appels pendant blockedUntil
580
+ // – filtrer les ids dont le placeholder n'est pas en DOM
418
581
 
419
582
  function patchShowAds() {
420
583
  const apply = () => {
@@ -450,19 +613,37 @@
450
613
  async function runCore() {
451
614
  if (isBlocked()) return 0;
452
615
  patchShowAds();
616
+
453
617
  const cfg = await fetchConfig();
454
618
  if (!cfg || cfg.excluded) return 0;
455
619
  initPools(cfg);
620
+
456
621
  const kind = getKind();
457
622
  if (kind === 'other') return 0;
623
+
458
624
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
459
625
  if (!normBool(cfgEnable)) return 0;
460
626
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
461
627
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
462
628
  };
463
- if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
464
- if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
465
- return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
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
+ );
466
647
  }
467
648
 
468
649
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -486,9 +667,11 @@
486
667
  S.lastBurstTs = t;
487
668
  S.pageKey = pageKey();
488
669
  S.burstDeadline = t + 2000;
670
+
489
671
  if (S.burstActive) return;
490
672
  S.burstActive = true;
491
673
  S.burstCount = 0;
674
+
492
675
  const step = () => {
493
676
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
494
677
  S.burstActive = false; return;
@@ -507,26 +690,21 @@
507
690
  function cleanup() {
508
691
  blockedUntil = ts() + 1500;
509
692
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
510
- S.cfg = null;
511
- _cfgErrorUntil = 0;
512
- S.poolsReady = false;
513
- S.pools = { topics: [], posts: [], categories: [] };
514
- S.cursors = { topics: 0, posts: 0, categories: 0 };
693
+ S.cfg = null;
694
+ S.poolsReady = false;
695
+ S.pools = { topics: [], posts: [], categories: [] };
696
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
515
697
  S.mountedIds.clear();
516
698
  S.lastShow.clear();
517
699
  S.wrapByKey.clear();
518
- S.inflight = 0;
519
- S.pending = [];
700
+ S.inflight = 0;
701
+ S.pending = [];
520
702
  S.pendingSet.clear();
521
- S.burstActive = false;
522
- S.runQueued = false;
523
- if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
524
- // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
525
- // exister en permanence — la déconnecter pendant la navigation cause
526
- // des erreurs CMP postMessage et la disparition des pubs.
703
+ S.burstActive = false;
704
+ S.runQueued = false;
527
705
  }
528
706
 
529
- // ── MutationObserver DOM ───────────────────────────────────────────────────
707
+ // ── MutationObserver ───────────────────────────────────────────────────────
530
708
 
531
709
  function ensureDomObserver() {
532
710
  if (S.domObs) return;
@@ -536,8 +714,9 @@
536
714
  for (const m of muts) {
537
715
  for (const n of m.addedNodes) {
538
716
  if (n.nodeType !== 1) continue;
539
- if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
540
- allSel.some(s => { try { return !!n.querySelector(s); } catch (_) { return false; } })) {
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;} })) {
541
720
  requestBurst(); return;
542
721
  }
543
722
  }
@@ -557,7 +736,6 @@
557
736
  'cannot call refresh on the same page',
558
737
  'no placeholders are currently defined in Refresh',
559
738
  'Debugger iframe already exists',
560
- '[CMP] Error in custom getTCData',
561
739
  `with id ${PH_PREFIX}`,
562
740
  ];
563
741
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -580,9 +758,9 @@
580
758
  (document.body || document.documentElement).appendChild(f);
581
759
  };
582
760
  inject();
583
- if (!S.tcfObs) {
584
- S.tcfObs = new MutationObserver(inject);
585
- S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
761
+ if (!window.__nbbTcfObs) {
762
+ window.__nbbTcfObs = new MutationObserver(inject);
763
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
586
764
  }
587
765
  } catch (_) {}
588
766
  }
@@ -614,19 +792,22 @@
614
792
  function bindNodeBB() {
615
793
  const $ = window.jQuery;
616
794
  if (!$) return;
795
+
617
796
  $(window).off('.nbbEzoic');
618
797
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
619
798
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
620
799
  S.pageKey = pageKey();
621
800
  blockedUntil = 0;
622
- muteConsole(); ensureTcfLocator();
801
+ muteConsole(); ensureTcfLocator(); warmNetwork();
623
802
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
624
803
  });
804
+
625
805
  const burstEvts = [
626
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
627
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
806
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
807
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
628
808
  ].map(e => `${e}.nbbEzoic`).join(' ');
629
809
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
+
630
811
  try {
631
812
  require(['hooks'], hooks => {
632
813
  if (typeof hooks?.on !== 'function') return;
@@ -645,11 +826,6 @@
645
826
  ticking = true;
646
827
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
647
828
  }, { passive: true });
648
- let resizeTimer = 0;
649
- window.addEventListener('resize', () => {
650
- clearTimeout(resizeTimer);
651
- resizeTimer = setTimeout(getIO, 500);
652
- }, { passive: true });
653
829
  }
654
830
 
655
831
  // ── 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,26 +56,16 @@
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
65
  display: block !important;
76
- height: 0 !important;
77
- min-height: 0 !important;
78
- max-height: 0 !important;
66
+ height: 1px !important;
67
+ min-height: 1px !important;
68
+ max-height: 1px !important;
79
69
  margin: 0 !important;
80
70
  padding: 0 !important;
81
71
  overflow: hidden !important;