nodebb-plugin-ezoic-infinite 1.7.80 → 1.7.82

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.80",
3
+ "version": "1.7.82",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,49 +1,92 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v69
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
3
3
  *
4
- * Historique
5
- * ──────────
6
- * v18 Ancrage stable par data-pid / data-index.
7
- * v20 Table KIND. IO fixe. Fix TCF locator.
8
- * v25 Fix scroll-up / virtualisation NodeBB.
9
- * v28 Wraps persistants pendant la session.
10
- * v36 S.wrapByKey Map O(1). MutationObserver optimisé.
11
- * v38 Pool épuisé break propre (ez.refresh supprimé).
12
- * v49 Fix normalizeExcludedGroups (JSON.parse tableau NodeBB).
13
- * v51 fetchConfig backoff 10s. IO recrée au resize.
14
- * v52 pruneOrphansBetween supprimé (NodeBB virtualise les topics).
15
- * v56 scheduleEmptyCheck supprimé (collapse prématuré).
16
- * v58 tcfObs survit aux navigations (iframe CMP permanente).
17
- * v62 is-empty réintroduit, déclenché à l'insertion du wrap.
18
- * v64 recycleAndMove supprimé (slots restaient en 'unused' après destroy).
19
- * v67 define(allIds) au boot — Ezoic enregistre tous les slots en interne.
20
- * v68 setIsSinglePageApplication(true) + newPage() à chaque navigation.
21
- * v69 scheduleEmptyCheck déplacé dans startShow (après showAds, comme v50).
22
- * IO_MARGIN réduit (800px/1200px) : évite que AMP charge une pub trop
23
- * tôt et la retire immédiatement car déjà hors viewport au chargement.
24
- * Nettoyage prod final : S.recycling orphelin supprimé, helpers Ezoic
25
- * SPA extraits en fonctions dédiées, commentaires legacy retirés.
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
7
+ *
8
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
+ * la position dans le batch courant.
10
+ *
11
+ * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
12
+ * Fix fatal catégories : data-cid au lieu de data-index inexistant.
13
+ * IO fixe (une instance, jamais recréée).
14
+ * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
15
+ *
16
+ * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
17
+ *
18
+ * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
19
+ *
20
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
21
+ *
22
+ * v28 decluster supprimé. Wraps persistants pendant la session.
23
+ *
24
+ * v32 Retour anchorAttr = data-index pour ezoic-ad-between.
25
+ * data-tid peut être absent clés invalides → wraps empilés.
26
+ * pruneOrphansBetween réactivé uniquement pour topics de catégorie :
27
+ * – NodeBB NE virtualise PAS les topics dans une liste de catégorie,
28
+ * les ancres (data-index) restent en DOM → prune safe et nécessaire
29
+ * pour éviter l'empilement après scroll long.
30
+ * – Toujours désactivé pour les posts : NodeBB virtualise les posts
31
+ * hors-viewport → faux-orphelins → bug réinjection en haut.
32
+ *
33
+ * v34 moveDistantWrap — voir v38.
34
+ *
35
+ * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
+ * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
+ * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
38
+ *
39
+ * v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
40
+ *
41
+ * v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
42
+ *
43
+ * v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
44
+ *
45
+ * v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
46
+ * Séquence : destroy → 300ms → define → 300ms → displayMore.
47
+ * Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
48
+ *
49
+ * v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
50
+ * sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
51
+ * déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
52
+ * break propre dans injectBetween. muteConsole : ajout warnings refresh.
53
+ *
54
+ * v36 Optimisations chemin critique (scroll → injectBetween) :
55
+ * – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
56
+ * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
57
+ * dropWrap et cleanup.
58
+ * – wrapIsLive allégé : pour les voisins immédiats on vérifie les
59
+ * attributs du nœud lui-même sans querySelector global.
60
+ * – MutationObserver : matches() vérifié avant querySelector() pour
61
+ * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
62
+ *
63
+ * v35 Revue complète prod-ready :
64
+ * – initPools protégé contre ré-initialisation inutile (S.poolsReady).
65
+ * – muteConsole élargit à "No valid placeholders for loadMore".
66
+ * – Commentaires et historique nettoyés.
26
67
  */
27
68
  (function nbbEzoicInfinite() {
28
69
  'use strict';
29
70
 
30
71
  // ── Constantes ─────────────────────────────────────────────────────────────
31
72
 
32
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
33
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
34
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
35
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
73
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
74
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
75
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
76
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
77
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
36
79
 
37
- const EMPTY_CHECK_MS = 15_000; // collapse wrap vide 15s après showAds
38
- const MAX_INSERTS_RUN = 6; // insertions max par appel runCore
39
- const MAX_INFLIGHT = 4; // showAds() simultanés max
40
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
41
- const BURST_COOLDOWN_MS = 200; // délai min entre deux bursts
80
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
84
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
42
86
 
43
- // Marge asymétrique : précharge 1200px en bas, 0px en haut.
44
- // Évite que AMP charge une pub déjà sortie du viewport vers le haut.
45
- const IO_MARGIN_DESKTOP = '0px 0px 1200px 0px';
46
- const IO_MARGIN_MOBILE = '0px 0px 1500px 0px';
87
+ // Marges IO larges et fixes observer créé une seule fois au boot
88
+ const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
89
+ const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
47
90
 
48
91
  const SEL = {
49
92
  post: '[component="post"][data-pid]',
@@ -53,10 +96,13 @@
53
96
 
54
97
  /**
55
98
  * Table KIND — source de vérité par kindClass.
56
- * sel sélecteur CSS des éléments cibles
99
+ *
100
+ * sel sélecteur CSS complet des éléments cibles
57
101
  * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
58
103
  * anchorAttr attribut DOM stable → clé unique du wrap
59
- * ordinalAttr attribut 0-based pour calcul de l'intervalle (null = fallback positionnel)
104
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
+ * null → fallback positionnel (catégories)
60
106
  */
61
107
  const KIND = {
62
108
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
@@ -67,43 +113,34 @@
67
113
  // ── État global ────────────────────────────────────────────────────────────
68
114
 
69
115
  const S = {
70
- pageKey: null,
71
- cfg: null,
72
- poolsReady: false,
73
- pools: { topics: [], posts: [], categories: [] },
74
- cursors: { topics: 0, posts: 0, categories: 0 },
75
- mountedIds: new Set(),
76
- lastShow: new Map(),
77
- io: null,
78
- domObs: null,
79
- tcfObs: null, // survit aux navigations ne jamais déconnecter
80
- mutGuard: 0,
81
- inflight: 0,
82
- pending: [],
83
- pendingSet: new Set(),
84
- wrapByKey: new Map(), // anchorKey → wrap DOM node
85
- runQueued: false,
86
- burstActive: false,
116
+ pageKey: null,
117
+ cfg: null,
118
+ poolsReady: false,
119
+ pools: { topics: [], posts: [], categories: [] },
120
+ cursors: { topics: 0, posts: 0, categories: 0 },
121
+ mountedIds: new Set(),
122
+ lastShow: new Map(),
123
+ io: null,
124
+ domObs: null,
125
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
126
+ inflight: 0, // showAds() en cours
127
+ pending: [], // ids en attente de slot inflight
128
+ pendingSet: new Set(),
129
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
130
+ runQueued: false,
131
+ burstActive: false,
87
132
  burstDeadline: 0,
88
- burstCount: 0,
89
- lastBurstTs: 0,
133
+ burstCount: 0,
134
+ lastBurstTs: 0,
90
135
  };
91
136
 
92
- let blockedUntil = 0;
93
- let _cfgErrorUntil = 0;
94
- let _ioMobile = null;
95
-
96
- const ts = () => Date.now();
97
- const isBlocked = () => ts() < blockedUntil;
98
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
99
- const _BOOL_TRUE = new Set([true, 'true', 1, '1', 'on']);
100
- const normBool = v => _BOOL_TRUE.has(v);
101
- // isFilled : exclut l'img reportline Ezoic (ezoicbwa.png) — faux positif
102
- // data-ez-filled : marqué par watchFill() dès qu'Ezoic touche au placeholder
103
- // (nécessaire pour les pubs AMP dont l'iframe est cross-origin et invisible)
104
- const isFilled = n => !!(n?.closest?.('.nodebb-ezoic-wrap')?.getAttribute('data-ez-filled'))
105
- || !!(n?.querySelector('iframe, ins, video, [data-google-container-id]'))
106
- || !!(n?.querySelector('img:not([src*="ezoicbwa"]):not([src*="ezodn.com"])'));
137
+ let blockedUntil = 0;
138
+
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
 
@@ -138,26 +173,8 @@
138
173
  S.pools.posts = parseIds(cfg.messagePlaceholderIds);
139
174
  S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
140
175
  S.poolsReady = true;
141
- // Déclarer tous les ids en une seule fois — requis pour que showAds()
142
- // fonctionne sur des slots insérés dynamiquement (infinite scroll).
143
- const allIds = [...S.pools.topics, ...S.pools.posts, ...S.pools.categories];
144
- if (allIds.length) ezCmd(ez => ez.define(allIds));
145
176
  }
146
177
 
147
- // ── Helpers Ezoic ──────────────────────────────────────────────────────────
148
-
149
- function ezCmd(fn) {
150
- try {
151
- window.ezstandalone = window.ezstandalone || {};
152
- const ez = window.ezstandalone;
153
- const exec = () => { try { fn(ez); } catch (_) {} };
154
- typeof ez.cmd?.push === 'function' ? ez.cmd.push(exec) : exec();
155
- } catch (_) {}
156
- }
157
-
158
- function notifyEzoicSpa() { ezCmd(ez => ez.setIsSinglePageApplication?.(true)); }
159
- function notifyEzoicNewPage() { ezCmd(ez => ez.newPage?.()); }
160
-
161
178
  // ── Page identity ──────────────────────────────────────────────────────────
162
179
 
163
180
  function pageKey() {
@@ -197,37 +214,53 @@
197
214
 
198
215
  // ── Wraps — détection ──────────────────────────────────────────────────────
199
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
+ */
200
221
  function wrapIsLive(wrap) {
201
222
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
202
223
  const key = wrap.getAttribute(A_ANCHOR);
203
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).
204
227
  if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
205
229
  const colonIdx = key.indexOf(':');
206
230
  const klass = key.slice(0, colonIdx);
207
231
  const anchorId = key.slice(colonIdx + 1);
208
232
  const cfg = KIND[klass];
209
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.
210
236
  const parent = wrap.parentElement;
211
237
  if (parent) {
212
238
  for (const sib of parent.children) {
213
239
  if (sib === wrap) continue;
214
240
  try {
215
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
241
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
216
242
  return sib.isConnected;
243
+ }
217
244
  } catch (_) {}
218
245
  }
219
246
  }
247
+ // Dernier recours : querySelector global
220
248
  try {
221
249
  const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
222
250
  return !!(found?.isConnected);
223
251
  } catch (_) { return false; }
224
252
  }
225
253
 
226
- const adjacentWrap = el =>
227
- wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
254
+ function adjacentWrap(el) {
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
256
+ }
228
257
 
229
258
  // ── Ancres stables ─────────────────────────────────────────────────────────
230
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
+ */
231
264
  function stableId(klass, el) {
232
265
  const attr = KIND[klass]?.anchorAttr;
233
266
  if (attr) {
@@ -246,15 +279,18 @@
246
279
 
247
280
  function findWrap(key) {
248
281
  const w = S.wrapByKey.get(key);
249
- return w?.isConnected ? w : null;
282
+ return (w?.isConnected) ? w : null;
250
283
  }
251
284
 
252
285
  // ── Pool ───────────────────────────────────────────────────────────────────
253
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
254
291
  function pickId(poolKey) {
255
292
  const pool = S.pools[poolKey];
256
293
  if (!pool.length) return null;
257
- if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
258
294
  for (let t = 0; t < pool.length; t++) {
259
295
  const i = S.cursors[poolKey] % pool.length;
260
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -264,13 +300,77 @@
264
300
  return null;
265
301
  }
266
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
+
267
365
  // ── Wraps DOM — création / suppression ────────────────────────────────────
268
366
 
269
367
  function makeWrap(id, klass, key) {
270
368
  const w = document.createElement('div');
271
369
  w.className = `${WRAP_CLASS} ${klass}`;
272
- w.setAttribute(A_ANCHOR, key);
273
- 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');
274
374
  w.style.cssText = 'width:100%;display:block;';
275
375
  const ph = document.createElement('div');
276
376
  ph.id = `${PH_PREFIX}${id}`;
@@ -279,30 +379,7 @@
279
379
  return w;
280
380
  }
281
381
 
282
- function watchFill(id, wrap) {
283
- // Phase 1 : attend qu'une vraie pub apparaisse (iframe/ins).
284
- // Phase 2 : si la pub disparaît (AMP lazy unload) → réobserve le placeholder
285
- // pour relancer showAds quand il repasse dans le viewport.
286
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
287
- if (!ph) return;
288
- let filled = false;
289
- const obs = new MutationObserver(() => {
290
- const hasPub = !!(ph.querySelector('iframe, ins'));
291
- if (!filled && hasPub) {
292
- filled = true;
293
- wrap.setAttribute('data-ez-filled', '1');
294
- wrap.classList.remove('is-empty');
295
- } else if (filled && !hasPub) {
296
- // Pub disparue → retirer data-ez-filled et réobserver pour reload
297
- wrap.removeAttribute('data-ez-filled');
298
- filled = false;
299
- try { getIO()?.observe(ph); } catch (_) {}
300
- }
301
- });
302
- obs.observe(ph, { childList: true, subtree: true });
303
- }
304
-
305
- function insertAfter(el, id, klass, key) {
382
+ function insertAfter(el, id, klass, key) {
306
383
  if (!el?.insertAdjacentElement) return null;
307
384
  if (findWrap(key)) return null;
308
385
  if (S.mountedIds.has(id)) return null;
@@ -311,7 +388,6 @@
311
388
  mutate(() => el.insertAdjacentElement('afterend', w));
312
389
  S.mountedIds.add(id);
313
390
  S.wrapByKey.set(key, w);
314
- watchFill(id, w);
315
391
  return w;
316
392
  }
317
393
 
@@ -327,8 +403,44 @@
327
403
  } catch (_) {}
328
404
  }
329
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
+
330
438
  // ── Injection ──────────────────────────────────────────────────────────────
331
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
+ */
332
444
  function ordinal(klass, el) {
333
445
  const attr = KIND[klass]?.ordinalAttr;
334
446
  if (attr) {
@@ -347,18 +459,27 @@
347
459
  function injectBetween(klass, items, interval, showFirst, poolKey) {
348
460
  if (!items.length) return 0;
349
461
  let inserted = 0;
462
+
350
463
  for (const el of items) {
351
464
  if (inserted >= MAX_INSERTS_RUN) break;
352
465
  if (!el?.isConnected) continue;
466
+
353
467
  const ord = ordinal(klass, el);
354
468
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
355
469
  if (adjacentWrap(el)) continue;
470
+
356
471
  const key = anchorKey(klass, el);
357
472
  if (findWrap(key)) continue;
473
+
358
474
  const id = pickId(poolKey);
359
- if (!id) break;
360
- const w = insertAfter(el, id, klass, key);
361
- 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
+ }
362
483
  }
363
484
  return inserted;
364
485
  }
@@ -366,10 +487,7 @@
366
487
  // ── IntersectionObserver & Show ────────────────────────────────────────────
367
488
 
368
489
  function getIO() {
369
- const mobile = isMobile();
370
- if (S.io && _ioMobile === mobile) return S.io;
371
- if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
372
- _ioMobile = mobile;
490
+ if (S.io) return S.io;
373
491
  try {
374
492
  S.io = new IntersectionObserver(entries => {
375
493
  for (const e of entries) {
@@ -378,7 +496,7 @@
378
496
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
379
497
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
380
498
  }
381
- }, { 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 });
382
500
  } catch (_) { S.io = null; }
383
501
  return S.io;
384
502
  }
@@ -388,22 +506,6 @@
388
506
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
389
507
  }
390
508
 
391
- function scheduleEmptyCheck(id) {
392
- setTimeout(() => {
393
- try {
394
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
395
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
396
- if (!wrap || !ph?.isConnected) return;
397
- // Ne pas colapser si watchFill a détecté une vraie pub (iframe/ins)
398
- if (wrap.getAttribute('data-ez-filled')) return;
399
- // Ne pas colapser si une iframe est présente (pub AMP cross-origin)
400
- if (wrap.querySelector('iframe, ins')) return;
401
- // Pas de pub détectée → collapse
402
- wrap.classList.add('is-empty');
403
- } catch (_) {}
404
- }, EMPTY_CHECK_MS);
405
- }
406
-
407
509
  function enqueueShow(id) {
408
510
  if (!id || isBlocked()) return;
409
511
  if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
@@ -434,35 +536,48 @@
434
536
  drainQueue();
435
537
  };
436
538
  const timer = setTimeout(release, 7000);
539
+
437
540
  requestAnimationFrame(() => {
438
541
  try {
439
542
  if (isBlocked()) { clearTimeout(timer); return release(); }
440
543
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
441
- if (!ph?.isConnected) { clearTimeout(timer); return release(); }
442
- const wrap = ph.closest?.(`.${WRAP_CLASS}`);
443
- if (isFilled(ph) || wrap?.getAttribute('data-ez-filled')) { clearTimeout(timer); return release(); }
544
+ if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
545
+
444
546
  const t = ts();
445
547
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
446
548
  S.lastShow.set(id, t);
447
- try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
448
- ezCmd(ez => {
449
- // displayMore = API Ezoic documentée pour l'infinite scroll
450
- // Évite le passage en 'unused' que showAds cause sur les slots dynamiques
451
- try {
452
- if (typeof ez.displayMore === 'function') ez.displayMore([id]);
453
- else ez.showAds(id);
454
- } catch (_) { try { ez.showAds(id); } catch (_) {} }
455
- scheduleEmptyCheck(id);
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 = () => {
555
+ try { ez.showAds(id); } catch (_) {}
556
+ scheduleEmptyCheck(id, t);
456
557
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
457
- });
558
+ };
559
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
458
560
  } catch (_) { clearTimeout(timer); release(); }
459
561
  });
460
562
  }
461
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
+
462
576
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
463
577
  //
464
- // Intercepte ez.showAds() pour ignorer les appels pendant blockedUntil
465
- // 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
466
581
 
467
582
  function patchShowAds() {
468
583
  const apply = () => {
@@ -498,19 +613,37 @@
498
613
  async function runCore() {
499
614
  if (isBlocked()) return 0;
500
615
  patchShowAds();
616
+
501
617
  const cfg = await fetchConfig();
502
618
  if (!cfg || cfg.excluded) return 0;
503
619
  initPools(cfg);
620
+
504
621
  const kind = getKind();
505
622
  if (kind === 'other') return 0;
623
+
506
624
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
507
625
  if (!normBool(cfgEnable)) return 0;
508
626
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
509
627
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
510
628
  };
511
- if (kind === 'topic') return exec('ezoic-ad-message', getPosts, cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
512
- if (kind === 'categoryTopics') return exec('ezoic-ad-between', getTopics, cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
513
- 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
+ );
514
647
  }
515
648
 
516
649
  // ── Scheduler ──────────────────────────────────────────────────────────────
@@ -534,9 +667,11 @@
534
667
  S.lastBurstTs = t;
535
668
  S.pageKey = pageKey();
536
669
  S.burstDeadline = t + 2000;
670
+
537
671
  if (S.burstActive) return;
538
672
  S.burstActive = true;
539
673
  S.burstCount = 0;
674
+
540
675
  const step = () => {
541
676
  if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
542
677
  S.burstActive = false; return;
@@ -556,25 +691,20 @@
556
691
  blockedUntil = ts() + 1500;
557
692
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
558
693
  S.cfg = null;
559
- _cfgErrorUntil = 0;
560
694
  S.poolsReady = false;
561
695
  S.pools = { topics: [], posts: [], categories: [] };
562
- S.cursors = { topics: 0, posts: 0, categories: 0 };
696
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
563
697
  S.mountedIds.clear();
564
698
  S.lastShow.clear();
565
699
  S.wrapByKey.clear();
566
- S.inflight = 0;
567
- S.pending = [];
700
+ S.inflight = 0;
701
+ S.pending = [];
568
702
  S.pendingSet.clear();
569
- S.burstActive = false;
570
- S.runQueued = false;
571
- if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
572
- // tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
573
- // rester en vie pendant toute la session — la déconnecter entre deux
574
- // navigations cause des erreurs CMP postMessage.
703
+ S.burstActive = false;
704
+ S.runQueued = false;
575
705
  }
576
706
 
577
- // ── MutationObserver DOM ───────────────────────────────────────────────────
707
+ // ── MutationObserver ───────────────────────────────────────────────────────
578
708
 
579
709
  function ensureDomObserver() {
580
710
  if (S.domObs) return;
@@ -584,8 +714,9 @@
584
714
  for (const m of muts) {
585
715
  for (const n of m.addedNodes) {
586
716
  if (n.nodeType !== 1) continue;
587
- if (allSel.some(s => { try { return n.matches(s); } catch (_) { return false; } }) ||
588
- 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;} })) {
589
720
  requestBurst(); return;
590
721
  }
591
722
  }
@@ -605,7 +736,6 @@
605
736
  'cannot call refresh on the same page',
606
737
  'no placeholders are currently defined in Refresh',
607
738
  'Debugger iframe already exists',
608
- '[CMP] Error in custom getTCData',
609
739
  `with id ${PH_PREFIX}`,
610
740
  ];
611
741
  for (const m of ['log', 'info', 'warn', 'error']) {
@@ -628,9 +758,9 @@
628
758
  (document.body || document.documentElement).appendChild(f);
629
759
  };
630
760
  inject();
631
- if (!S.tcfObs) {
632
- S.tcfObs = new MutationObserver(inject);
633
- 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 });
634
764
  }
635
765
  } catch (_) {}
636
766
  }
@@ -662,20 +792,22 @@
662
792
  function bindNodeBB() {
663
793
  const $ = window.jQuery;
664
794
  if (!$) return;
795
+
665
796
  $(window).off('.nbbEzoic');
666
797
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
667
798
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
668
799
  S.pageKey = pageKey();
669
800
  blockedUntil = 0;
670
- muteConsole();
671
- ensureTcfLocator();
801
+ muteConsole(); ensureTcfLocator(); warmNetwork();
672
802
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
673
803
  });
804
+
674
805
  const burstEvts = [
675
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
676
- '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',
677
808
  ].map(e => `${e}.nbbEzoic`).join(' ');
678
809
  $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
810
+
679
811
  try {
680
812
  require(['hooks'], hooks => {
681
813
  if (typeof hooks?.on !== 'function') return;
@@ -694,11 +826,6 @@
694
826
  ticking = true;
695
827
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
696
828
  }, { passive: true });
697
- let resizeTimer = 0;
698
- window.addEventListener('resize', () => {
699
- clearTimeout(resizeTimer);
700
- resizeTimer = setTimeout(getIO, 500);
701
- }, { passive: true });
702
829
  }
703
830
 
704
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,23 +56,19 @@
56
56
  top: auto !important;
57
57
  }
58
58
 
59
- /* ── Réservation d'espace anti-CLS ───────────────────────────────────────── */
60
- /*
61
- Réserve 90px avant que la pub charge (hauteur standard leaderboard).
62
- Évite le saut de layout (CLS) quand AMP/Ezoic redimensionne l'iframe.
63
- */
64
- .nodebb-ezoic-wrap.ezoic-ad-between {
65
- min-height: 90px;
66
- }
67
-
68
59
  /* ── État vide ────────────────────────────────────────────────────────────── */
69
60
  /*
70
- Ajouté 60s après showAds si aucun fill détecté (délai généreux pour CMP/enchères).
71
- Collapse à 0 : évite la ligne de quelques pixels visible quand Ezoic
72
- injecte un conteneur vide mais ne sert pas de pub.
61
+ Ajouté 20s après showAds si aucun fill détecté.
62
+ Collapse à 1px (pas 0) : reste observable par l'IO si le fill arrive tard.
73
63
  */
74
64
  .nodebb-ezoic-wrap.is-empty {
75
- display: none !important;
65
+ display: block !important;
66
+ height: 1px !important;
67
+ min-height: 1px !important;
68
+ max-height: 1px !important;
69
+ margin: 0 !important;
70
+ padding: 0 !important;
71
+ overflow: hidden !important;
76
72
  }
77
73
 
78
74
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */