nodebb-plugin-ezoic-infinite 1.7.65 → 1.7.67

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.65",
3
+ "version": "1.7.67",
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,42 +1,90 @@
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 Nettoyage prod final : S.recycling orphelin supprimé, helpers Ezoic
22
- * 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.
23
67
  */
24
68
  (function nbbEzoicInfinite() {
25
69
  'use strict';
26
70
 
27
71
  // ── Constantes ─────────────────────────────────────────────────────────────
28
72
 
29
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
30
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
31
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
32
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
33
-
34
- const EMPTY_CHECK_MS = 5_000; // collapse wrap vide 5s après insertion
35
- const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
36
- const MAX_INFLIGHT = 4; // showAds() simultanés max
37
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
38
- const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
39
-
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
40
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
41
89
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
42
90
 
@@ -48,10 +96,13 @@
48
96
 
49
97
  /**
50
98
  * Table KIND — source de vérité par kindClass.
51
- * sel sélecteur CSS des éléments cibles
99
+ *
100
+ * sel sélecteur CSS complet des éléments cibles
52
101
  * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
53
103
  * anchorAttr attribut DOM stable → clé unique du wrap
54
- * 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)
55
106
  */
56
107
  const KIND = {
57
108
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -62,38 +113,34 @@
62
113
  // ── État global ────────────────────────────────────────────────────────────
63
114
 
64
115
  const S = {
65
- pageKey: null,
66
- cfg: null,
67
- poolsReady: false,
68
- pools: { topics: [], posts: [], categories: [] },
69
- cursors: { topics: 0, posts: 0, categories: 0 },
70
- mountedIds: new Set(),
71
- lastShow: new Map(),
72
- io: null,
73
- domObs: null,
74
- tcfObs: null, // survit aux navigations ne jamais déconnecter
75
- mutGuard: 0,
76
- inflight: 0,
77
- pending: [],
78
- pendingSet: new Set(),
79
- wrapByKey: new Map(), // anchorKey → wrap DOM node
80
- runQueued: false,
81
- 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,
82
132
  burstDeadline: 0,
83
- burstCount: 0,
84
- lastBurstTs: 0,
133
+ burstCount: 0,
134
+ lastBurstTs: 0,
85
135
  };
86
136
 
87
- let blockedUntil = 0;
88
- let _cfgErrorUntil = 0;
89
- let _ioMobile = null;
137
+ let blockedUntil = 0;
90
138
 
91
- const ts = () => Date.now();
92
- const isBlocked = () => ts() < blockedUntil;
93
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
94
- const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
95
- const normBool = v => _BOOL_TRUE.has(v);
96
- 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]'));
97
144
 
98
145
  function mutate(fn) {
99
146
  S.mutGuard++;
@@ -104,12 +151,10 @@
104
151
 
105
152
  async function fetchConfig() {
106
153
  if (S.cfg) return S.cfg;
107
- if (Date.now() < _cfgErrorUntil) return null;
108
154
  try {
109
155
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
110
- if (r.ok) { S.cfg = await r.json(); }
111
- else { _cfgErrorUntil = Date.now() + 10_000; }
112
- } catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
156
+ if (r.ok) S.cfg = await r.json();
157
+ } catch (_) {}
113
158
  return S.cfg;
114
159
  }
115
160
 
@@ -128,26 +173,8 @@
128
173
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
129
174
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
130
175
  S.poolsReady = true;
131
- // Déclarer tous les ids en une seule fois — requis pour que showAds()
132
- // fonctionne sur des slots insérés dynamiquement (infinite scroll).
133
- const allIds = [...S.pools.topics, ...S.pools.posts, ...S.pools.categories];
134
- if (allIds.length) ezCmd(ez => ez.define(allIds));
135
- }
136
-
137
- // ── Helpers Ezoic ──────────────────────────────────────────────────────────
138
-
139
- function ezCmd(fn) {
140
- try {
141
- window.ezstandalone = window.ezstandalone || {};
142
- const ez = window.ezstandalone;
143
- const exec = () => { try { fn(ez); } catch (_) {} };
144
- typeof ez.cmd?.push === 'function' ? ez.cmd.push(exec) : exec();
145
- } catch (_) {}
146
176
  }
147
177
 
148
- function notifyEzoicSpa() { ezCmd(ez => ez.setIsSinglePageApplication?.(true)); }
149
- function notifyEzoicNewPage() { ezCmd(ez => ez.newPage?.()); }
150
-
151
178
  // ── Page identity ──────────────────────────────────────────────────────────
152
179
 
153
180
  function pageKey() {
@@ -187,37 +214,53 @@
187
214
 
188
215
  // ── Wraps — détection ──────────────────────────────────────────────────────
189
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
+ */
190
221
  function wrapIsLive(wrap) {
191
222
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
192
223
  const key = wrap.getAttribute(A_ANCHOR);
193
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).
194
227
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
195
229
  const colonIdx = key.indexOf(':');
196
230
  const klass = key.slice(0, colonIdx);
197
231
  const anchorId = key.slice(colonIdx + 1);
198
232
  const cfg = KIND[klass];
199
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.
200
236
  const parent = wrap.parentElement;
201
237
  if (parent) {
202
238
  for (const sib of parent.children) {
203
239
  if (sib === wrap) continue;
204
240
  try {
205
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
241
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
206
242
  return sib.isConnected;
243
+ }
207
244
  } catch (_) {}
208
245
  }
209
246
  }
247
+ // Dernier recours : querySelector global
210
248
  try {
211
249
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
212
250
  return !!(found?.isConnected);
213
251
  } catch (_) { return false; }
214
252
  }
215
253
 
216
- const adjacentWrap = el =>
217
- wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
254
+ function adjacentWrap(el) {
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
+ }
218
257
 
219
258
  // ── Ancres stables ─────────────────────────────────────────────────────────
220
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
+ */
221
264
  function stableId(klass, el) {
222
265
  const attr = KIND[klass]?.anchorAttr;
223
266
  if (attr) {
@@ -236,15 +279,18 @@
236
279
 
237
280
  function findWrap(key) {
238
281
  const w = S.wrapByKey.get(key);
239
- return w?.isConnected ? w : null;
282
+ return (w?.isConnected) ? w : null;
240
283
  }
241
284
 
242
285
  // ── Pool ───────────────────────────────────────────────────────────────────
243
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
244
291
  function pickId(poolKey) {
245
292
  const pool = S.pools[poolKey];
246
293
  if (!pool.length) return null;
247
- if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
248
294
  for (let t = 0; t < pool.length; t++) {
249
295
  const i = S.cursors[poolKey] % pool.length;
250
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -254,13 +300,77 @@
254
300
  return null;
255
301
  }
256
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
+
257
365
  // ── Wraps DOM — création / suppression ────────────────────────────────────
258
366
 
259
367
  function makeWrap(id, klass, key) {
260
368
  const w = document.createElement('div');
261
369
  w.className = `${WRAP_CLASS} ${klass}`;
262
- w.setAttribute(A_ANCHOR, key);
263
- 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');
264
374
  w.style.cssText = 'width:100%;display:block;';
265
375
  const ph = document.createElement('div');
266
376
  ph.id = `${PH_PREFIX}${id}`;
@@ -278,7 +388,6 @@
278
388
  mutate(() => el.insertAdjacentElement('afterend', w));
279
389
  S.mountedIds.add(id);
280
390
  S.wrapByKey.set(key, w);
281
- scheduleEmptyCheck(id);
282
391
  return w;
283
392
  }
284
393
 
@@ -294,8 +403,44 @@
294
403
  } catch (_) {}
295
404
  }
296
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
+
297
438
  // ── Injection ──────────────────────────────────────────────────────────────
298
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
+ */
299
444
  function ordinal(klass, el) {
300
445
  const attr = KIND[klass]?.ordinalAttr;
301
446
  if (attr) {
@@ -314,18 +459,27 @@
314
459
  function injectBetween(klass, items, interval, showFirst, poolKey) {
315
460
  if (!items.length) return 0;
316
461
  let inserted = 0;
462
+
317
463
  for (const el of items) {
318
464
  if (inserted >= MAX_INSERTS_RUN) break;
319
465
  if (!el?.isConnected) continue;
466
+
320
467
  const ord = ordinal(klass, el);
321
468
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
322
469
  if (adjacentWrap(el)) continue;
470
+
323
471
  const key = anchorKey(klass, el);
324
472
  if (findWrap(key)) continue;
473
+
325
474
  const id = pickId(poolKey);
326
- if (!id) break;
327
- const w = insertAfter(el, id, klass, key);
328
- 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
+ }
329
483
  }
330
484
  return inserted;
331
485
  }
@@ -333,10 +487,7 @@
333
487
  // ── IntersectionObserver & Show ────────────────────────────────────────────
334
488
 
335
489
  function getIO() {
336
- const mobile = isMobile();
337
- if (S.io && _ioMobile === mobile) return S.io;
338
- if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
339
- _ioMobile = mobile;
490
+ if (S.io) return S.io;
340
491
  try {
341
492
  S.io = new IntersectionObserver(entries => {
342
493
  for (const e of entries) {
@@ -345,7 +496,7 @@
345
496
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
346
497
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
347
498
  }
348
- }, { 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 });
349
500
  } catch (_) { S.io = null; }
350
501
  return S.io;
351
502
  }
@@ -355,17 +506,6 @@
355
506
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
356
507
  }
357
508
 
358
- function scheduleEmptyCheck(id) {
359
- setTimeout(() => {
360
- try {
361
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
362
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
363
- if (!wrap || !ph?.isConnected) return;
364
- if (!isFilled(wrap)) wrap.classList.add('is-empty');
365
- } catch (_) {}
366
- }, EMPTY_CHECK_MS);
367
- }
368
-
369
509
  function enqueueShow(id) {
370
510
  if (!id || isBlocked()) return;
371
511
  if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
@@ -396,27 +536,48 @@
396
536
  drainQueue();
397
537
  };
398
538
  const timer = setTimeout(release, 7000);
539
+
399
540
  requestAnimationFrame(() => {
400
541
  try {
401
542
  if (isBlocked()) { clearTimeout(timer); return release(); }
402
543
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
403
544
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
545
+
404
546
  const t = ts();
405
547
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
406
548
  S.lastShow.set(id, t);
407
- try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
408
- ezCmd(ez => {
549
+
550
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
551
+
552
+ window.ezstandalone = window.ezstandalone || {};
553
+ const ez = window.ezstandalone;
554
+ const doShow = () => {
409
555
  try { ez.showAds(id); } catch (_) {}
556
+ scheduleEmptyCheck(id, t);
410
557
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
411
- });
558
+ };
559
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
412
560
  } catch (_) { clearTimeout(timer); release(); }
413
561
  });
414
562
  }
415
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
+
416
576
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
417
577
  //
418
- // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
419
- // 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
420
581
 
421
582
  function patchShowAds() {
422
583
  const apply = () => {
@@ -452,19 +613,37 @@
452
613
  async function runCore() {
453
614
  if (isBlocked()) return 0;
454
615
  patchShowAds();
616
+
455
617
  const cfg = await fetchConfig();
456
618
  if (!cfg || cfg.excluded) return 0;
457
619
  initPools(cfg);
620
+
458
621
  const kind = getKind();
459
622
  if (kind === 'other') return 0;
623
+
460
624
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
461
625
  if (!normBool(cfgEnable)) return 0;
462
626
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
463
627
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
464
628
  };
465
- if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
466
- if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
467
- 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
+ );
468
647
  }
469
648
 
470
649
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -488,9 +667,11 @@
488
667
  S.lastBurstTs = t;
489
668
  S.pageKey = pageKey();
490
669
  S.burstDeadline = t + 2000;
670
+
491
671
  if (S.burstActive) return;
492
672
  S.burstActive = true;
493
673
  S.burstCount = 0;
674
+
494
675
  const step = () => {
495
676
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
496
677
  S.burstActive = false; return;
@@ -510,25 +691,20 @@
510
691
  blockedUntil = ts() + 1500;
511
692
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
512
693
  S.cfg = null;
513
- _cfgErrorUntil = 0;
514
694
  S.poolsReady = false;
515
695
  S.pools = { topics: [], posts: [], categories: [] };
516
- S.cursors = { topics: 0, posts: 0, categories: 0 };
696
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
517
697
  S.mountedIds.clear();
518
698
  S.lastShow.clear();
519
699
  S.wrapByKey.clear();
520
- S.inflight = 0;
521
- S.pending = [];
700
+ S.inflight = 0;
701
+ S.pending = [];
522
702
  S.pendingSet.clear();
523
- S.burstActive = false;
524
- S.runQueued = false;
525
- if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
526
- // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
527
- // rester en vie pendant toute la session — la déconnecter entre deux
528
- // navigations cause des erreurs CMP postMessage.
703
+ S.burstActive = false;
704
+ S.runQueued = false;
529
705
  }
530
706
 
531
- // ── MutationObserver DOM ───────────────────────────────────────────────────
707
+ // ── MutationObserver ───────────────────────────────────────────────────────
532
708
 
533
709
  function ensureDomObserver() {
534
710
  if (S.domObs) return;
@@ -538,8 +714,9 @@
538
714
  for (const m of muts) {
539
715
  for (const n of m.addedNodes) {
540
716
  if (n.nodeType !== 1) continue;
541
- if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
542
- 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;} })) {
543
720
  requestBurst(); return;
544
721
  }
545
722
  }
@@ -559,7 +736,6 @@
559
736
  'cannot call refresh on the same page',
560
737
  'no placeholders are currently defined in Refresh',
561
738
  'Debugger iframe already exists',
562
- '[CMP] Error in custom getTCData',
563
739
  `with id ${PH_PREFIX}`,
564
740
  ];
565
741
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -582,9 +758,9 @@
582
758
  (document.body || document.documentElement).appendChild(f);
583
759
  };
584
760
  inject();
585
- if (!S.tcfObs) {
586
- S.tcfObs = new MutationObserver(inject);
587
- 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 });
588
764
  }
589
765
  } catch (_) {}
590
766
  }
@@ -616,21 +792,22 @@
616
792
  function bindNodeBB() {
617
793
  const $ = window.jQuery;
618
794
  if (!$) return;
795
+
619
796
  $(window).off('.nbbEzoic');
620
797
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
621
798
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
622
799
  S.pageKey = pageKey();
623
800
  blockedUntil = 0;
624
- muteConsole();
625
- ensureTcfLocator();
626
- notifyEzoicNewPage();
801
+ muteConsole(); ensureTcfLocator(); warmNetwork();
627
802
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
628
803
  });
804
+
629
805
  const burstEvts = [
630
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
631
- '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',
632
808
  ].map(e => `${e}.nbbEzoic`).join(' ');
633
809
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
+
634
811
  try {
635
812
  require(['hooks'], hooks => {
636
813
  if (typeof hooks?.on !== 'function') return;
@@ -649,11 +826,6 @@
649
826
  ticking = true;
650
827
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
651
828
  }, { passive: true });
652
- let resizeTimer = 0;
653
- window.addEventListener('resize', () => {
654
- clearTimeout(resizeTimer);
655
- resizeTimer = setTimeout(getIO, 500);
656
- }, { passive: true });
657
829
  }
658
830
 
659
831
  // ── Boot ───────────────────────────────────────────────────────────────────
@@ -662,7 +834,6 @@
662
834
  muteConsole();
663
835
  ensureTcfLocator();
664
836
  warmNetwork();
665
- notifyEzoicSpa(); // NodeBB est une SPA — Ezoic ajuste son cycle interne
666
837
  patchShowAds();
667
838
  getIO();
668
839
  ensureDomObserver();
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;