nodebb-plugin-ezoic-infinite 1.7.67 → 1.7.69

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.67",
3
+ "version": "1.7.69",
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,92 +1,47 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v69
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
+ * v36 S.wrapByKey Map O(1). MutationObserver optimisé.
11
+ * v38 Pool épuisé break propre (ez.refresh supprimé).
12
+ * v49 Fix normalizeExcludedGroups (JSON.parse tableau NodeBB).
13
+ * v51 fetchConfig backoff 10s. IO recrée au resize.
14
+ * v52 pruneOrphansBetween supprimé (NodeBB virtualise les topics).
15
+ * v56 scheduleEmptyCheck supprimé (collapse prématuré).
16
+ * v58 tcfObs survit aux navigations (iframe CMP permanente).
17
+ * v62 is-empty réintroduit, déclenché à l'insertion du wrap.
18
+ * v64 recycleAndMove supprimé (slots restaient en 'unused' après destroy).
19
+ * v67 define(allIds) au boot — Ezoic enregistre tous les slots en interne.
20
+ * v68 setIsSinglePageApplication(true) + newPage() à chaque navigation.
21
+ * v69 scheduleEmptyCheck déplacé dans startShow (après showAds, comme v50).
22
+ * IO_MARGIN réduit (800px/1200px) : évite que AMP charge une pub trop
23
+ * tôt et la retire immédiatement car déjà hors viewport au chargement.
24
+ * Nettoyage prod final : S.recycling orphelin supprimé, helpers Ezoic
25
+ * SPA extraits en fonctions dédiées, commentaires legacy retirés.
67
26
  */
68
27
  (function nbbEzoicInfinite() {
69
28
  'use strict';
70
29
 
71
30
  // ── Constantes ─────────────────────────────────────────────────────────────
72
31
 
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
32
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
33
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
34
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
35
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
79
36
 
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
37
+ const EMPTY_CHECK_MS = 5_000; // collapse wrap vide 5s après insertion
38
+ const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
39
+ const MAX_INFLIGHT = 4; // showAds() simultanés max
40
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
41
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
86
42
 
87
- // Marges IO larges et fixes — observer créé une seule fois au boot
88
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
89
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
43
+ const IO_MARGIN_DESKTOP = '800px 0px 800px 0px';
44
+ const IO_MARGIN_MOBILE = '1200px 0px 1200px 0px';
90
45
 
91
46
  const SEL = {
92
47
  post: '[component="post"][data-pid]',
@@ -96,13 +51,10 @@
96
51
 
97
52
  /**
98
53
  * Table KIND — source de vérité par kindClass.
99
- *
100
- * sel sélecteur CSS complet des éléments cibles
54
+ * sel sélecteur CSS des éléments cibles
101
55
  * baseTag préfixe tag pour querySelector d'ancre
102
- * (vide pour posts : le sélecteur commence par '[')
103
56
  * 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)
57
+ * ordinalAttr attribut 0-based pour calcul de l'intervalle (null = fallback positionnel)
106
58
  */
107
59
  const KIND = {
108
60
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -113,34 +65,40 @@
113
65
  // ── État global ────────────────────────────────────────────────────────────
114
66
 
115
67
  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,
68
+ pageKey: null,
69
+ cfg: null,
70
+ poolsReady: false,
71
+ pools: { topics: [], posts: [], categories: [] },
72
+ cursors: { topics: 0, posts: 0, categories: 0 },
73
+ mountedIds: new Set(),
74
+ lastShow: new Map(),
75
+ io: null,
76
+ domObs: null,
77
+ tcfObs: null, // survit aux navigations ne jamais déconnecter
78
+ mutGuard: 0,
79
+ inflight: 0,
80
+ pending: [],
81
+ pendingSet: new Set(),
82
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
83
+ runQueued: false,
84
+ burstActive: false,
132
85
  burstDeadline: 0,
133
- burstCount: 0,
134
- lastBurstTs: 0,
86
+ burstCount: 0,
87
+ lastBurstTs: 0,
135
88
  };
136
89
 
137
- let blockedUntil = 0;
90
+ let blockedUntil = 0;
91
+ let _cfgErrorUntil = 0;
92
+ let _ioMobile = null;
138
93
 
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]'));
94
+ const ts = () => Date.now();
95
+ const isBlocked = () => ts() < blockedUntil;
96
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
97
+ const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
98
+ const normBool = v => _BOOL_TRUE.has(v);
99
+ // isFilled : exclut l'img reportline Ezoic (ezoicbwa.png) — faux positif
100
+ const isFilled = n => !!(n?.querySelector('iframe, ins, video, [data-google-container-id]'))
101
+ || !!(n?.querySelector('img:not([src*="ezoicbwa"]):not([src*="ezodn.com"])'));
144
102
 
145
103
  function mutate(fn) {
146
104
  S.mutGuard++;
@@ -151,10 +109,12 @@
151
109
 
152
110
  async function fetchConfig() {
153
111
  if (S.cfg) return S.cfg;
112
+ if (Date.now() < _cfgErrorUntil) return null;
154
113
  try {
155
114
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
156
- if (r.ok) S.cfg = await r.json();
157
- } catch (_) {}
115
+ if (r.ok) { S.cfg = await r.json(); }
116
+ else { _cfgErrorUntil = Date.now() + 10_000; }
117
+ } catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
158
118
  return S.cfg;
159
119
  }
160
120
 
@@ -173,8 +133,26 @@
173
133
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
174
134
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
175
135
  S.poolsReady = true;
136
+ // Déclarer tous les ids en une seule fois — requis pour que showAds()
137
+ // fonctionne sur des slots insérés dynamiquement (infinite scroll).
138
+ const allIds = [...S.pools.topics, ...S.pools.posts, ...S.pools.categories];
139
+ if (allIds.length) ezCmd(ez => ez.define(allIds));
140
+ }
141
+
142
+ // ── Helpers Ezoic ──────────────────────────────────────────────────────────
143
+
144
+ function ezCmd(fn) {
145
+ try {
146
+ window.ezstandalone = window.ezstandalone || {};
147
+ const ez = window.ezstandalone;
148
+ const exec = () => { try { fn(ez); } catch (_) {} };
149
+ typeof ez.cmd?.push === 'function' ? ez.cmd.push(exec) : exec();
150
+ } catch (_) {}
176
151
  }
177
152
 
153
+ function notifyEzoicSpa() { ezCmd(ez => ez.setIsSinglePageApplication?.(true)); }
154
+ function notifyEzoicNewPage() { ezCmd(ez => ez.newPage?.()); }
155
+
178
156
  // ── Page identity ──────────────────────────────────────────────────────────
179
157
 
180
158
  function pageKey() {
@@ -214,53 +192,37 @@
214
192
 
215
193
  // ── Wraps — détection ──────────────────────────────────────────────────────
216
194
 
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
195
  function wrapIsLive(wrap) {
222
196
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
223
197
  const key = wrap.getAttribute(A_ANCHOR);
224
198
  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
199
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
- // Fallback : registre pas encore à jour ou wrap non enregistré.
229
200
  const colonIdx = key.indexOf(':');
230
201
  const klass = key.slice(0, colonIdx);
231
202
  const anchorId = key.slice(colonIdx + 1);
232
203
  const cfg = KIND[klass];
233
204
  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
205
  const parent = wrap.parentElement;
237
206
  if (parent) {
238
207
  for (const sib of parent.children) {
239
208
  if (sib === wrap) continue;
240
209
  try {
241
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
210
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
242
211
  return sib.isConnected;
243
- }
244
212
  } catch (_) {}
245
213
  }
246
214
  }
247
- // Dernier recours : querySelector global
248
215
  try {
249
216
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
250
217
  return !!(found?.isConnected);
251
218
  } catch (_) { return false; }
252
219
  }
253
220
 
254
- function adjacentWrap(el) {
255
- return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
- }
221
+ const adjacentWrap = el =>
222
+ wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
257
223
 
258
224
  // ── Ancres stables ─────────────────────────────────────────────────────────
259
225
 
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
226
  function stableId(klass, el) {
265
227
  const attr = KIND[klass]?.anchorAttr;
266
228
  if (attr) {
@@ -279,18 +241,15 @@
279
241
 
280
242
  function findWrap(key) {
281
243
  const w = S.wrapByKey.get(key);
282
- return (w?.isConnected) ? w : null;
244
+ return w?.isConnected ? w : null;
283
245
  }
284
246
 
285
247
  // ── Pool ───────────────────────────────────────────────────────────────────
286
248
 
287
- /**
288
- * Retourne le prochain id disponible dans le pool (round-robin),
289
- * ou null si tous les ids sont montés.
290
- */
291
249
  function pickId(poolKey) {
292
250
  const pool = S.pools[poolKey];
293
251
  if (!pool.length) return null;
252
+ if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
294
253
  for (let t = 0; t < pool.length; t++) {
295
254
  const i = S.cursors[poolKey] % pool.length;
296
255
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -300,77 +259,13 @@
300
259
  return null;
301
260
  }
302
261
 
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
-
365
262
  // ── Wraps DOM — création / suppression ────────────────────────────────────
366
263
 
367
264
  function makeWrap(id, klass, key) {
368
265
  const w = document.createElement('div');
369
266
  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');
267
+ w.setAttribute(A_ANCHOR, key);
268
+ w.setAttribute(A_WRAPID, String(id));
374
269
  w.style.cssText = 'width:100%;display:block;';
375
270
  const ph = document.createElement('div');
376
271
  ph.id = `${PH_PREFIX}${id}`;
@@ -403,44 +298,8 @@
403
298
  } catch (_) {}
404
299
  }
405
300
 
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
301
  // ── Injection ──────────────────────────────────────────────────────────────
439
302
 
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
303
  function ordinal(klass, el) {
445
304
  const attr = KIND[klass]?.ordinalAttr;
446
305
  if (attr) {
@@ -459,27 +318,18 @@
459
318
  function injectBetween(klass, items, interval, showFirst, poolKey) {
460
319
  if (!items.length) return 0;
461
320
  let inserted = 0;
462
-
463
321
  for (const el of items) {
464
322
  if (inserted >= MAX_INSERTS_RUN) break;
465
323
  if (!el?.isConnected) continue;
466
-
467
324
  const ord = ordinal(klass, el);
468
325
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
469
326
  if (adjacentWrap(el)) continue;
470
-
471
327
  const key = anchorKey(klass, el);
472
328
  if (findWrap(key)) continue;
473
-
474
329
  const id = pickId(poolKey);
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
- }
330
+ if (!id) break;
331
+ const w = insertAfter(el, id, klass, key);
332
+ if (w) { observePh(id); inserted++; }
483
333
  }
484
334
  return inserted;
485
335
  }
@@ -487,7 +337,10 @@
487
337
  // ── IntersectionObserver & Show ────────────────────────────────────────────
488
338
 
489
339
  function getIO() {
490
- if (S.io) return S.io;
340
+ const mobile = isMobile();
341
+ if (S.io && _ioMobile === mobile) return S.io;
342
+ if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
343
+ _ioMobile = mobile;
491
344
  try {
492
345
  S.io = new IntersectionObserver(entries => {
493
346
  for (const e of entries) {
@@ -496,7 +349,7 @@
496
349
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
497
350
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
498
351
  }
499
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
352
+ }, { root: null, rootMargin: mobile ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
500
353
  } catch (_) { S.io = null; }
501
354
  return S.io;
502
355
  }
@@ -506,6 +359,17 @@
506
359
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
507
360
  }
508
361
 
362
+ function scheduleEmptyCheck(id) {
363
+ setTimeout(() => {
364
+ try {
365
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
366
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
367
+ if (!wrap || !ph?.isConnected) return;
368
+ if (!isFilled(wrap)) wrap.classList.add('is-empty');
369
+ } catch (_) {}
370
+ }, EMPTY_CHECK_MS);
371
+ }
372
+
509
373
  function enqueueShow(id) {
510
374
  if (!id || isBlocked()) return;
511
375
  if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
@@ -536,48 +400,28 @@
536
400
  drainQueue();
537
401
  };
538
402
  const timer = setTimeout(release, 7000);
539
-
540
403
  requestAnimationFrame(() => {
541
404
  try {
542
405
  if (isBlocked()) { clearTimeout(timer); return release(); }
543
406
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
544
407
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
545
-
546
408
  const t = ts();
547
409
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
548
410
  S.lastShow.set(id, t);
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 = () => {
411
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
412
+ ezCmd(ez => {
555
413
  try { ez.showAds(id); } catch (_) {}
556
- scheduleEmptyCheck(id, t);
414
+ scheduleEmptyCheck(id);
557
415
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
558
- };
559
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
416
+ });
560
417
  } catch (_) { clearTimeout(timer); release(); }
561
418
  });
562
419
  }
563
420
 
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
421
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
577
422
  //
578
- // Intercepte ez.showAds() pour :
579
- // ignorer les appels pendant blockedUntil
580
- // – filtrer les ids dont le placeholder n'est pas en DOM
423
+ // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
424
+ // et filtrer les ids dont le placeholder n'est pas connecté au DOM.
581
425
 
582
426
  function patchShowAds() {
583
427
  const apply = () => {
@@ -613,37 +457,19 @@
613
457
  async function runCore() {
614
458
  if (isBlocked()) return 0;
615
459
  patchShowAds();
616
-
617
460
  const cfg = await fetchConfig();
618
461
  if (!cfg || cfg.excluded) return 0;
619
462
  initPools(cfg);
620
-
621
463
  const kind = getKind();
622
464
  if (kind === 'other') return 0;
623
-
624
465
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
625
466
  if (!normBool(cfgEnable)) return 0;
626
467
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
627
468
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
628
469
  };
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
- );
470
+ if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
471
+ if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
472
+ return exec('ezoic-ad-categories', getCategories, cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
647
473
  }
648
474
 
649
475
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -667,11 +493,9 @@
667
493
  S.lastBurstTs = t;
668
494
  S.pageKey = pageKey();
669
495
  S.burstDeadline = t + 2000;
670
-
671
496
  if (S.burstActive) return;
672
497
  S.burstActive = true;
673
498
  S.burstCount = 0;
674
-
675
499
  const step = () => {
676
500
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
677
501
  S.burstActive = false; return;
@@ -691,20 +515,25 @@
691
515
  blockedUntil = ts() + 1500;
692
516
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
693
517
  S.cfg = null;
518
+ _cfgErrorUntil = 0;
694
519
  S.poolsReady = false;
695
520
  S.pools = { topics: [], posts: [], categories: [] };
696
- S.cursors = { topics: 0, posts: 0, categories: 0 };
521
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
697
522
  S.mountedIds.clear();
698
523
  S.lastShow.clear();
699
524
  S.wrapByKey.clear();
700
- S.inflight = 0;
701
- S.pending = [];
525
+ S.inflight = 0;
526
+ S.pending = [];
702
527
  S.pendingSet.clear();
703
- S.burstActive = false;
704
- S.runQueued = false;
528
+ S.burstActive = false;
529
+ S.runQueued = false;
530
+ if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
531
+ // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
532
+ // rester en vie pendant toute la session — la déconnecter entre deux
533
+ // navigations cause des erreurs CMP postMessage.
705
534
  }
706
535
 
707
- // ── MutationObserver ───────────────────────────────────────────────────────
536
+ // ── MutationObserver DOM ───────────────────────────────────────────────────
708
537
 
709
538
  function ensureDomObserver() {
710
539
  if (S.domObs) return;
@@ -714,9 +543,8 @@
714
543
  for (const m of muts) {
715
544
  for (const n of m.addedNodes) {
716
545
  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;} })) {
546
+ if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
547
+ allSel.some(s => { try { return !!n.querySelector(s); } catch (_) { return false; } })) {
720
548
  requestBurst(); return;
721
549
  }
722
550
  }
@@ -736,6 +564,7 @@
736
564
  'cannot call refresh on the same page',
737
565
  'no placeholders are currently defined in Refresh',
738
566
  'Debugger iframe already exists',
567
+ '[CMP] Error in custom getTCData',
739
568
  `with id ${PH_PREFIX}`,
740
569
  ];
741
570
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -758,9 +587,9 @@
758
587
  (document.body || document.documentElement).appendChild(f);
759
588
  };
760
589
  inject();
761
- if (!window.__nbbTcfObs) {
762
- window.__nbbTcfObs = new MutationObserver(inject);
763
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
590
+ if (!S.tcfObs) {
591
+ S.tcfObs = new MutationObserver(inject);
592
+ S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
764
593
  }
765
594
  } catch (_) {}
766
595
  }
@@ -792,22 +621,21 @@
792
621
  function bindNodeBB() {
793
622
  const $ = window.jQuery;
794
623
  if (!$) return;
795
-
796
624
  $(window).off('.nbbEzoic');
797
625
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
798
626
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
799
627
  S.pageKey = pageKey();
800
628
  blockedUntil = 0;
801
- muteConsole(); ensureTcfLocator(); warmNetwork();
629
+ muteConsole();
630
+ ensureTcfLocator();
631
+ notifyEzoicNewPage();
802
632
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
803
633
  });
804
-
805
634
  const burstEvts = [
806
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
807
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
635
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
636
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
808
637
  ].map(e => `${e}.nbbEzoic`).join(' ');
809
638
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
-
811
639
  try {
812
640
  require(['hooks'], hooks => {
813
641
  if (typeof hooks?.on !== 'function') return;
@@ -826,6 +654,11 @@
826
654
  ticking = true;
827
655
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
828
656
  }, { passive: true });
657
+ let resizeTimer = 0;
658
+ window.addEventListener('resize', () => {
659
+ clearTimeout(resizeTimer);
660
+ resizeTimer = setTimeout(getIO, 500);
661
+ }, { passive: true });
829
662
  }
830
663
 
831
664
  // ── Boot ───────────────────────────────────────────────────────────────────
@@ -834,6 +667,7 @@
834
667
  muteConsole();
835
668
  ensureTcfLocator();
836
669
  warmNetwork();
670
+ notifyEzoicSpa(); // NodeBB est une SPA — Ezoic ajuste son cycle interne
837
671
  patchShowAds();
838
672
  getIO();
839
673
  ensureDomObserver();
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;