nodebb-plugin-ezoic-infinite 1.7.17 → 1.7.19

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
@@ -9,10 +9,8 @@ const plugin = {};
9
9
 
10
10
  function normalizeExcludedGroups(value) {
11
11
  if (!value) return [];
12
- const arr = Array.isArray(value) ? value : String(value).split(',');
13
- return arr
14
- .map(s => String(s).trim())
15
- .filter(Boolean);
12
+ if (Array.isArray(value)) return value;
13
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
16
14
  }
17
15
 
18
16
  function parseBool(v, def = false) {
@@ -22,30 +20,18 @@ function parseBool(v, def = false) {
22
20
  return s === '1' || s === 'true' || s === 'on' || s === 'yes';
23
21
  }
24
22
 
25
- let _groupsCache = null;
26
- let _groupsCacheAt = 0;
27
- const GROUPS_TTL = 60000; // 60s
28
-
29
23
  async function getAllGroups() {
30
- const now = Date.now();
31
- if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
32
-
33
24
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
34
25
  if (!names || !names.length) {
35
26
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
36
27
  }
37
28
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
38
29
  const data = await groups.getGroupsData(filtered);
39
-
40
30
  // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
41
- const valid = (data || []).filter(g => g && g.name);
31
+ const valid = data.filter(g => g && g.name);
42
32
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
43
-
44
- _groupsCache = valid;
45
- _groupsCacheAt = now;
46
- return _groupsCache;
33
+ return valid;
47
34
  }
48
-
49
35
  let _settingsCache = null;
50
36
  let _settingsCacheAt = 0;
51
37
  const SETTINGS_TTL = 30000; // 30s
@@ -53,13 +39,8 @@ const SETTINGS_TTL = 30000; // 30s
53
39
  async function getSettings() {
54
40
  const now = Date.now();
55
41
  if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
56
- let s = {};
57
- try {
58
- s = await meta.settings.get(SETTINGS_KEY);
59
- } catch (err) {
60
- s = {};
61
- }
62
- _settingsCacheAt = now;
42
+ const s = await meta.settings.get(SETTINGS_KEY);
43
+ _settingsCacheAt = Date.now();
63
44
  _settingsCache = {
64
45
  // Between-post ads (simple blocks) in category topic list
65
46
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
@@ -79,9 +60,6 @@ async function getSettings() {
79
60
  messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
80
61
  messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
81
62
 
82
- // Avoid globally muting console unless explicitly enabled
83
- muteEzoicConsole: parseBool(s.muteEzoicConsole, false),
84
-
85
63
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
86
64
  };
87
65
  return _settingsCache;
@@ -90,8 +68,7 @@ async function getSettings() {
90
68
  async function isUserExcluded(uid, excludedGroups) {
91
69
  if (!uid || !excludedGroups.length) return false;
92
70
  const userGroups = await groups.getUserGroups([uid]);
93
- const excluded = new Set(excludedGroups.map(n => String(n).toLowerCase().trim()).filter(Boolean));
94
- return (userGroups[0] || []).some(g => excluded.has(String(g.name).toLowerCase().trim()));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
95
72
  }
96
73
 
97
74
  plugin.onSettingsSet = function (data) {
@@ -116,53 +93,21 @@ plugin.init = async ({ router, middleware }) => {
116
93
  const settings = await getSettings();
117
94
  const allGroups = await getAllGroups();
118
95
 
119
- const excludedSet = new Set((settings.excludedGroups || []).map(n => String(n).toLowerCase().trim()).filter(Boolean));
120
- const allGroupsWithSelected = (allGroups || []).map(g => ({
121
- ...g,
122
- selected: excludedSet.has(String(g.name).toLowerCase().trim()) ? 'selected' : '',
123
- }));
124
-
125
96
  res.render('admin/plugins/ezoic-infinite', {
126
97
  title: 'Ezoic Infinite Ads',
127
98
  ...settings,
128
-
129
- // SSR-friendly checkbox states
130
99
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
131
- showFirstTopicAd_checked: settings.showFirstTopicAd ? 'checked' : '',
132
-
133
- enableCategoryAds_checked: settings.enableCategoryAds ? 'checked' : '',
134
- showFirstCategoryAd_checked: settings.showFirstCategoryAd ? 'checked' : '',
135
-
136
100
  enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
137
- showFirstMessageAd_checked: settings.showFirstMessageAd ? 'checked' : '',
138
-
139
- muteEzoicConsole_checked: settings.muteEzoicConsole ? 'checked' : '',
140
-
141
- allGroups: allGroupsWithSelected,
101
+ allGroups,
142
102
  });
143
103
  }
144
104
 
145
- router.get('/admin/plugins/ezoic-infinite', middleware.ensureLoggedIn, middleware.admin.checkPrivileges, middleware.admin.buildHeader, render);
146
- router.get('/api/admin/plugins/ezoic-infinite', middleware.ensureLoggedIn, middleware.admin.checkPrivileges, render);
105
+ router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
+ router.get('/api/admin/plugins/ezoic-infinite', render);
147
107
 
148
108
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
149
- let settings;
150
- try {
151
- settings = await getSettings();
152
- } catch (err) {
153
- settings = null;
154
- }
155
-
156
- if (!settings) {
157
- return res.json({ excluded: false });
158
- }
159
-
160
- let excluded = false;
161
- try {
162
- excluded = await isUserExcluded(req.uid, settings.excludedGroups);
163
- } catch (err) {
164
- excluded = false;
165
- }
109
+ const settings = await getSettings();
110
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
166
111
 
167
112
  res.json({
168
113
  excluded,
@@ -178,7 +123,6 @@ plugin.init = async ({ router, middleware }) => {
178
123
  showFirstMessageAd: settings.showFirstMessageAd,
179
124
  messagePlaceholderIds: settings.messagePlaceholderIds,
180
125
  messageIntervalPosts: settings.messageIntervalPosts,
181
- muteEzoicConsole: settings.muteEzoicConsole,
182
126
  });
183
127
  });
184
128
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.17",
3
+ "version": "1.7.19",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -12,19 +12,10 @@
12
12
  "infinite-scroll"
13
13
  ],
14
14
  "engines": {
15
- "nodebb": ">=4.0.0",
16
- "node": ">=18"
15
+ "nodebb": ">=4.0.0"
17
16
  },
18
17
  "nbbpm": {
19
18
  "compatibility": "^4.0.0"
20
19
  },
21
- "private": false,
22
- "files": [
23
- "library.js",
24
- "plugin.json",
25
- "public/"
26
- ],
27
- "scripts": {
28
- "lint": "node -c library.js && node -c public/admin.js && node -c public/client.js"
29
- }
30
- }
20
+ "private": false
21
+ }
package/public/client.js CHANGED
@@ -1,55 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v25
3
3
  *
4
- * Correctifs critiques vs v19
5
- * ───────────────────────────
6
- * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
- * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
- * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
- * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
- * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
- * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
+ * Suppression du recyclage de wraps (moveWrapAfter). Cleanup complet navigation.
12
8
  *
13
- * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
- * Fix : on skip uniquement le wrap courant, pas toute la boucle.
9
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
+ * la position dans le batch courant.
15
11
  *
16
- * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
- * existants sur les items suivants. Fix : `continue` au lieu de `break`.
12
+ * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
13
+ * Fix fatal catégories : data-cid au lieu de data-index inexistant.
14
+ * Fix posts : baseTag vide → ordinal fallback cassé → pubs jamais injectées.
15
+ * IO fixe (une instance, jamais recréée). Burst cooldown 200 ms.
16
+ * Fix unobserve(null) → corruption IO → pubads error au scroll retour.
17
+ * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
18
18
  *
19
- * [PERF] IntersectionObserver recréé à chaque scroll boost → très coûteux mobile.
20
- * Fix : marge large fixe par device, observer créé une seule fois.
21
- *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
24
- *
25
- * Nettoyage
26
- * ─────────
27
- * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
- * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
- * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
- * - Commentaires internes allégés (code auto-documenté)
19
+ * v25 Base v20.1 avec :
20
+ *Fix scroll-up / virtualisation NodeBB :
21
+ * – pruneOrphans : PRUNE_STABLE_MS = 45 s, isFilled guard en premier.
22
+ * decluster : isFilled en premier, A_CREATED grace period (FILL_GRACE_MS).
23
+ * Recyclage d'id (pool épuisé en infinite scroll) :
24
+ * – pickRecyclableWrap() : sélectionne le wrap vide le plus loin au-dessus
25
+ * du viewport (seuil -6 × vh), jamais pour ezoic-ad-message.
26
+ * moveWrapAfter() : déplace le wrap vers sa nouvelle ancre.
27
+ * scrollDir tracking pour n'autoriser le recyclage qu'en scroll down.
28
+ * Table KIND unifiée avec baseTag + ordinalAttr + recyclable flag.
29
+ * ordinal() : utilise KIND[klass].ordinalAttr, fallback positionnel propre.
31
30
  */
32
31
  (function () {
33
32
  'use strict';
34
33
 
35
34
  // ── Constantes ─────────────────────────────────────────────────────────────
36
35
 
37
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
- const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
-
44
- const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
- const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
- const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
- const MAX_INSERTS_PER_RUN = 6;
48
- const MAX_INFLIGHT = 4;
49
- const SHOW_THROTTLE_MS = 900;
50
- const BURST_COOLDOWN_MS = 200;
51
-
52
- // Marges IO larges et fixes (pas de reconstruction d'observer)
36
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
37
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
38
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
39
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
40
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
41
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
42
+
43
+ const PRUNE_STABLE_MS = 45_000; // délai avant pruning (évite faux-orphelins scroll-up)
44
+ const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
45
+ const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
46
+ const RECYCLE_THRESHOLD = 6; // nb de viewports au-dessus du seuil de recyclage
47
+ const MAX_INSERTS_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // IO : marges larges fixes — une seule instance, jamais recréée
53
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
55
 
@@ -60,51 +60,54 @@
60
60
  };
61
61
 
62
62
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
63
+ * Table KIND source de vérité par kindClass.
64
64
  *
65
- * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
- * quelle que soit la page ou la virtualisation :
67
- * posts data-pid (id du message, unique et permanent)
68
- * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
- * catégories data-cid (id de la catégorie, unique et permanent)
70
- * C'était le bug v19 : on cherchait data-index ici
65
+ * sel : sélecteur CSS complet
66
+ * baseTag : préfixe tag pour querySelector d'ancre
67
+ * (vide pour posts car sélecteur commence par '[')
68
+ * anchorAttr : attribut DOM stable clé unique du wrap
69
+ * data-pid posts / data-index topics / data-cid catégories
70
+ * ordinalAttr: attribut 0-based pour calcul de l'intervalle
71
+ * null → fallback positionnel (catégories)
72
+ * recyclable : autoriser le recyclage d'id quand le pool est épuisé
73
+ * false pour ezoic-ad-message (sauts visuels indésirables)
71
74
  */
72
75
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
76
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index', recyclable: false },
77
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index', recyclable: true },
78
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null, recyclable: true },
76
79
  };
77
80
 
78
81
  // ── État ───────────────────────────────────────────────────────────────────
79
82
 
80
83
  const S = {
81
- pageKey: null,
82
- cfg: null,
83
-
84
- pools: { topics: [], posts: [], categories: [] },
85
- cursors: { topics: 0, posts: 0, categories: 0 },
86
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
- reuseSeq: new Map(), // id → compteur pour générer des IDs DOM uniques
88
- lastShow: new Map(), // id → timestamp dernier show
89
-
90
- io: null,
91
- domObs: null,
92
- mutGuard: 0, // compteur internalMutation
93
-
94
- inflight: 0,
95
- pending: [],
96
- pendingSet: new Set(),
97
-
84
+ pageKey: null,
85
+ cfg: null,
86
+ pools: { topics: [], posts: [], categories: [] },
87
+ cursors: { topics: 0, posts: 0, categories: 0 },
88
+ mountedIds: new Set(),
89
+ lastShow: new Map(),
90
+ io: null,
91
+ domObs: null,
92
+ mutGuard: 0,
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
98
96
  runQueued: false,
99
97
  burstActive: false,
100
98
  burstDeadline: 0,
101
99
  burstCount: 0,
102
100
  lastBurstTs: 0,
101
+ scrollDir: 1, // 1 = down, -1 = up
102
+ lastScrollY: 0,
103
103
  };
104
104
 
105
105
  let blockedUntil = 0;
106
- const isBlocked = () => Date.now() < blockedUntil;
107
- const ts = () => Date.now();
106
+ const ts = () => Date.now();
107
+ const isBlocked = () => ts() < blockedUntil;
108
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
109
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
110
+ const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
108
111
 
109
112
  function mutate(fn) {
110
113
  S.mutGuard++;
@@ -122,30 +125,20 @@
122
125
  return S.cfg;
123
126
  }
124
127
 
125
- function initPools(cfg) {
126
- S.pools.topics = parseIds(cfg.placeholderIds);
127
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
128
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
129
- }
130
-
131
128
  function parseIds(raw) {
132
129
  const out = [], seen = new Set();
133
- for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
130
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
134
131
  const n = parseInt(v, 10);
135
132
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
136
133
  }
137
134
  return out;
138
135
  }
139
136
 
140
- // La réutilisation des IDs du pool est toujours autorisée pour éviter les
141
- // situations "pool épuisé". Les IDs HTML des placeholders sont rendus uniques
142
- // (suffixés) afin de ne pas dupliquer d'ID dans le DOM.
143
- const allowReuse = () => true;
144
-
145
- const isFilled = (n) =>
146
- !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
147
-
148
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
137
+ function initPools(cfg) {
138
+ S.pools.topics = parseIds(cfg.placeholderIds);
139
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
140
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
141
+ }
149
142
 
150
143
  // ── Page identity ──────────────────────────────────────────────────────────
151
144
 
@@ -169,13 +162,13 @@
169
162
  return 'other';
170
163
  }
171
164
 
172
- // ── DOM helpers ────────────────────────────────────────────────────────────
165
+ // ── Items DOM ──────────────────────────────────────────────────────────────
173
166
 
174
167
  function getPosts() {
175
168
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
176
169
  if (!el.isConnected) return false;
177
170
  if (!el.querySelector('[component="post/content"]')) return false;
178
- const p = el.parentElement?.closest('[component="post"][data-pid]');
171
+ const p = el.parentElement?.closest(SEL.post);
179
172
  if (p && p !== el) return false;
180
173
  return el.getAttribute('component') !== 'post/parent';
181
174
  });
@@ -191,36 +184,28 @@
191
184
  );
192
185
  }
193
186
 
194
- // ── Ancres stables ────────────────────────────────────────────────────────
187
+ // ── Ancres stables ─────────────────────────────────────────────────────────
195
188
 
196
- /**
197
- * Retourne l'identifiant stable de l'élément selon son kindClass.
198
- * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
199
- * Fallback positionnel si l'attribut est absent.
200
- */
201
- function stableId(kindClass, el) {
202
- const attr = KIND[kindClass]?.anchorAttr;
189
+ function stableId(klass, el) {
190
+ const attr = KIND[klass]?.anchorAttr;
203
191
  if (attr) {
204
192
  const v = el.getAttribute(attr);
205
193
  if (v !== null && v !== '') return v;
206
194
  }
207
- // Fallback : position dans le parent
208
- try {
209
- let i = 0;
210
- for (const s of el.parentElement?.children ?? []) {
211
- if (s === el) return `i${i}`;
212
- i++;
213
- }
214
- } catch (_) {}
195
+ let i = 0;
196
+ for (const s of el.parentElement?.children ?? []) {
197
+ if (s === el) return `i${i}`;
198
+ i++;
199
+ }
215
200
  return 'i0';
216
201
  }
217
202
 
218
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
203
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
219
204
 
220
- function findWrap(anchorKey) {
205
+ function findWrap(key) {
221
206
  try {
222
207
  return document.querySelector(
223
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
208
+ `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
224
209
  );
225
210
  } catch (_) { return null; }
226
211
  }
@@ -230,39 +215,78 @@
230
215
  function pickId(poolKey) {
231
216
  const pool = S.pools[poolKey];
232
217
  for (let t = 0; t < pool.length; t++) {
233
- const i = S.cursors[poolKey] % pool.length;
218
+ const i = S.cursors[poolKey] % pool.length;
234
219
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
235
220
  const id = pool[i];
236
- return id;
221
+ if (!S.mountedIds.has(id)) return id;
237
222
  }
238
223
  return null;
239
224
  }
240
225
 
241
- // ── Wraps DOM ──────────────────────────────────────────────────────────────
226
+ // ── Recyclage d'id ─────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Sélectionne le wrap vide le plus éloigné au-dessus du viewport.
230
+ * Conditions : kindClass.recyclable = true, scroll vers le bas,
231
+ * wrap vide (non filled), rect.bottom < -(RECYCLE_THRESHOLD × vh).
232
+ */
233
+ function pickRecyclableWrap(klass) {
234
+ if (!KIND[klass]?.recyclable) return null;
235
+ if (S.scrollDir < 0) return null;
236
+
237
+ const vh = Math.max(300, window.innerHeight || 800);
238
+ const threshold = -(vh * RECYCLE_THRESHOLD);
239
+ let best = null, bestBottom = Infinity;
240
+
241
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
242
+ if (!w.isConnected || isFilled(w)) continue;
243
+ try {
244
+ const rect = w.getBoundingClientRect();
245
+ if (rect.bottom < threshold && rect.bottom < bestBottom) {
246
+ bestBottom = rect.bottom;
247
+ best = w;
248
+ }
249
+ } catch (_) {}
250
+ }
251
+ return best;
252
+ }
242
253
 
243
- function nextDomPlaceholderId(id) {
244
- const cur = (S.reuseSeq.get(id) || 0) + 1;
245
- S.reuseSeq.set(id, cur);
246
- return `${PH_PREFIX}${id}-${cur}`;
254
+ /**
255
+ * Déplace un wrap recyclé vers sa nouvelle ancre el.
256
+ * Réinitialise A_ANCHOR, A_CREATED, supprime A_SHOWN.
257
+ */
258
+ function moveWrapAfter(el, wrap, newKey) {
259
+ try {
260
+ if (!el || !wrap?.isConnected) return null;
261
+ wrap.setAttribute(A_ANCHOR, newKey);
262
+ wrap.setAttribute(A_CREATED, String(ts()));
263
+ wrap.removeAttribute(A_SHOWN);
264
+ mutate(() => el.insertAdjacentElement('afterend', wrap));
265
+ return wrap;
266
+ } catch (_) { return null; }
247
267
  }
248
268
 
269
+ // ── Wraps DOM ──────────────────────────────────────────────────────────────
270
+
249
271
  function makeWrap(id, klass, key) {
250
- const w = document.createElement('div');
272
+ const w = document.createElement('div');
251
273
  w.className = `${WRAP_CLASS} ${klass}`;
252
274
  w.setAttribute(A_ANCHOR, key);
253
275
  w.setAttribute(A_WRAPID, String(id));
254
276
  w.setAttribute(A_CREATED, String(ts()));
255
277
  w.style.cssText = 'width:100%;display:block;';
256
278
  const ph = document.createElement('div');
257
- ph.id = nextDomPlaceholderId(id);
279
+ ph.id = `${PH_PREFIX}${id}`;
258
280
  ph.setAttribute('data-ezoic-id', String(id));
259
281
  w.appendChild(ph);
260
282
  return w;
261
283
  }
262
284
 
263
285
  function insertAfter(el, id, klass, key) {
264
- if (!el?.insertAdjacentElement) return null;
265
- if (findWrap(key)) return null; // ancre déjà présente
286
+ if (!el?.insertAdjacentElement) return null;
287
+ if (findWrap(key)) return null;
288
+ if (S.mountedIds.has(id)) return null;
289
+ if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
266
290
  const w = makeWrap(id, klass, key);
267
291
  mutate(() => el.insertAdjacentElement('afterend', w));
268
292
  S.mountedIds.add(id);
@@ -271,15 +295,10 @@
271
295
 
272
296
  function dropWrap(w) {
273
297
  try {
298
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
299
+ if (ph instanceof Element) S.io?.unobserve(ph);
274
300
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
275
301
  if (Number.isFinite(id)) S.mountedIds.delete(id);
276
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
277
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
278
- // "parameter 1 is not of type Element" sur le prochain observe).
279
- try {
280
- const ph = w.querySelector('[data-ezoic-id]') || w.querySelector(`[id^="${PH_PREFIX}"]`);
281
- if (ph instanceof Element) S.io?.unobserve(ph);
282
- } catch (_) {}
283
302
  w.remove();
284
303
  } catch (_) {}
285
304
  }
@@ -287,58 +306,59 @@
287
306
  // ── Prune ──────────────────────────────────────────────────────────────────
288
307
 
289
308
  /**
290
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
309
+ * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
291
310
  *
292
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
293
- * Exemples :
294
- * ezoic-ad-message → cherche [data-pid="123"]
295
- * ezoic-ad-between → cherche [data-index="5"]
296
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
297
- *
298
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
311
+ * isFilled en premier : un wrap rempli n'est JAMAIS supprimé.
312
+ * PRUNE_STABLE_MS (45 s) : pendant cette fenêtre une ancre absente est
313
+ * considérée comme virtualisée par NodeBB (scroll up), pas comme orphelin réel.
299
314
  */
300
315
  function pruneOrphans(klass) {
301
316
  const meta = KIND[klass];
302
317
  if (!meta) return;
303
318
 
304
- const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
305
-
306
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
307
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
319
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
320
+ if (isFilled(w)) continue;
321
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < PRUNE_STABLE_MS) continue;
308
322
 
309
323
  const key = w.getAttribute(A_ANCHOR) ?? '';
310
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
311
- if (!sid) { mutate(() => dropWrap(w)); return; }
324
+ const sid = key.slice(klass.length + 1);
325
+ if (!sid) { mutate(() => dropWrap(w)); continue; }
312
326
 
313
- const anchorEl = document.querySelector(
314
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
315
- );
327
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
328
+ const anchorEl = document.querySelector(sel);
316
329
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
317
- });
330
+ }
318
331
  }
319
332
 
320
333
  // ── Decluster ──────────────────────────────────────────────────────────────
321
334
 
322
335
  /**
323
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
324
- * Priorité : filled > en grâce (fill en cours) > vide.
325
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
336
+ * Deux wraps adjacents → supprimer le courant s'il est vide et hors grâce.
337
+ * Guards dans l'ordre :
338
+ * 1. isFilled(w) → jamais toucher un wrap rempli
339
+ * 2. A_CREATED < FILL_GRACE → wrap trop récent (pas encore showAds'd)
340
+ * 3. A_SHOWN grace → fill en cours
341
+ * 4. isFilled(prev) → voisin rempli, intouchable → break
342
+ * 5. A_CREATED prev grace → voisin trop récent → break
343
+ * 6. A_SHOWN prev grace → break
344
+ * → les deux vides et hors grâce : supprimer le courant
326
345
  */
327
346
  function decluster(klass) {
328
347
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
329
- // Grace sur le wrap courant : on le saute entièrement
348
+ if (isFilled(w)) continue;
349
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) continue;
330
350
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
331
351
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
332
352
 
333
353
  let prev = w.previousElementSibling, steps = 0;
334
354
  while (prev && steps++ < 3) {
335
355
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
336
-
356
+ if (isFilled(prev)) break;
357
+ if (ts() - parseInt(prev.getAttribute(A_CREATED) || '0', 10) < FILL_GRACE_MS) break;
337
358
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
338
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
359
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
339
360
 
340
- if (!isFilled(w)) mutate(() => dropWrap(w));
341
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
361
+ mutate(() => dropWrap(w));
342
362
  break;
343
363
  }
344
364
  }
@@ -348,23 +368,21 @@
348
368
 
349
369
  /**
350
370
  * Ordinal 0-based pour le calcul de l'intervalle.
351
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
352
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
371
+ * Utilise KIND[klass].ordinalAttr (data-index pour posts/topics).
372
+ * Catégories : ordinalAttr = null fallback positionnel.
353
373
  */
354
374
  function ordinal(klass, el) {
355
- const di = el.getAttribute('data-index');
356
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
357
- // Fallback positionnel
358
- try {
359
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
360
- if (tag) {
361
- let i = 0;
362
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
363
- if (n === el) return i;
364
- i++;
365
- }
366
- }
367
- } catch (_) {}
375
+ const attr = KIND[klass]?.ordinalAttr;
376
+ if (attr) {
377
+ const v = el.getAttribute(attr);
378
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
379
+ }
380
+ const fullSel = KIND[klass]?.sel ?? '';
381
+ let i = 0;
382
+ for (const s of el.parentElement?.children ?? []) {
383
+ if (s === el) return i;
384
+ if (!fullSel || s.matches?.(fullSel)) i++;
385
+ }
368
386
  return 0;
369
387
  }
370
388
 
@@ -373,23 +391,33 @@
373
391
  let inserted = 0;
374
392
 
375
393
  for (const el of items) {
376
- if (inserted >= MAX_INSERTS_PER_RUN) break;
394
+ if (inserted >= MAX_INSERTS_RUN) break;
377
395
  if (!el?.isConnected) continue;
378
396
 
379
- const ord = ordinal(klass, el);
380
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
381
- if (!isTarget) continue;
382
-
397
+ const ord = ordinal(klass, el);
398
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
383
399
  if (adjacentWrap(el)) continue;
384
400
 
385
- const key = makeAnchorKey(klass, el);
386
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
401
+ const key = anchorKey(klass, el);
402
+ if (findWrap(key)) continue;
387
403
 
404
+ // 1. Tentative pool normal
388
405
  const id = pickId(poolKey);
389
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
406
+ if (id) {
407
+ const w = insertAfter(el, id, klass, key);
408
+ if (w) { observePh(id); inserted++; }
409
+ continue;
410
+ }
390
411
 
391
- const w = insertAfter(el, id, klass, key);
392
- if (w) { observePh(id); inserted++; }
412
+ // 2. Pool épuisé tentative de recyclage
413
+ const recyclable = pickRecyclableWrap(klass);
414
+ if (recyclable) {
415
+ const rid = parseInt(recyclable.getAttribute(A_WRAPID), 10);
416
+ const w = moveWrapAfter(el, recyclable, key);
417
+ if (w && Number.isFinite(rid)) { observePh(rid); inserted++; }
418
+ }
419
+ // Pool épuisé et pas de recyclage : on continue (items suivants peuvent
420
+ // avoir un wrap existant via findWrap, on ne break pas)
393
421
  }
394
422
  return inserted;
395
423
  }
@@ -398,7 +426,6 @@
398
426
 
399
427
  function getIO() {
400
428
  if (S.io) return S.io;
401
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
402
429
  try {
403
430
  S.io = new IntersectionObserver(entries => {
404
431
  for (const e of entries) {
@@ -407,7 +434,7 @@
407
434
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
408
435
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
409
436
  }
410
- }, { root: null, rootMargin: margin, threshold: 0 });
437
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
411
438
  } catch (_) { S.io = null; }
412
439
  return S.io;
413
440
  }
@@ -458,7 +485,6 @@
458
485
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
459
486
  S.lastShow.set(id, t);
460
487
 
461
- // Horodater le show sur le wrap pour grace period + emptyCheck
462
488
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
463
489
 
464
490
  window.ezstandalone = window.ezstandalone || {};
@@ -479,7 +505,6 @@
479
505
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
480
506
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
481
507
  if (!wrap || !ph?.isConnected) return;
482
- // Un show plus récent → ne pas toucher
483
508
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
484
509
  wrap.classList.toggle('is-empty', !isFilled(ph));
485
510
  } catch (_) {}
@@ -498,7 +523,7 @@
498
523
  const orig = ez.showAds.bind(ez);
499
524
  ez.showAds = function (...args) {
500
525
  if (isBlocked()) return;
501
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
526
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
502
527
  const seen = new Set();
503
528
  for (const v of ids) {
504
529
  const id = parseInt(v, 10);
@@ -517,7 +542,7 @@
517
542
  }
518
543
  }
519
544
 
520
- // ── Core run ───────────────────────────────────────────────────────────────
545
+ // ── Core ───────────────────────────────────────────────────────────────────
521
546
 
522
547
  async function runCore() {
523
548
  if (isBlocked()) return 0;
@@ -532,10 +557,9 @@
532
557
 
533
558
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
534
559
  if (!normBool(cfgEnable)) return 0;
535
- const items = getItems();
536
560
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
537
561
  pruneOrphans(klass);
538
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
562
+ const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
539
563
  if (n) decluster(klass);
540
564
  return n;
541
565
  };
@@ -548,14 +572,13 @@
548
572
  'ezoic-ad-between', getTopics,
549
573
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
550
574
  );
551
- if (kind === 'categories') return exec(
575
+ return exec(
552
576
  'ezoic-ad-categories', getCategories,
553
577
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
554
578
  );
555
- return 0;
556
579
  }
557
580
 
558
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
581
+ // ── Scheduler ──────────────────────────────────────────────────────────────
559
582
 
560
583
  function scheduleRun(cb) {
561
584
  if (S.runQueued) return;
@@ -573,10 +596,8 @@
573
596
  if (isBlocked()) return;
574
597
  const t = ts();
575
598
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
576
- S.lastBurstTs = t;
577
-
578
- const pk = pageKey();
579
- S.pageKey = pk;
599
+ S.lastBurstTs = t;
600
+ S.pageKey = pageKey();
580
601
  S.burstDeadline = t + 2000;
581
602
 
582
603
  if (S.burstActive) return;
@@ -584,7 +605,7 @@
584
605
  S.burstCount = 0;
585
606
 
586
607
  const step = () => {
587
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
608
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
588
609
  S.burstActive = false; return;
589
610
  }
590
611
  S.burstCount++;
@@ -596,14 +617,10 @@
596
617
  step();
597
618
  }
598
619
 
599
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
620
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
600
621
 
601
622
  function cleanup() {
602
623
  blockedUntil = ts() + 1500;
603
-
604
- // Disconnect observers to avoid doing work while ajaxify swaps content
605
- try { if (S.domObs) { S.domObs.disconnect(); S.domObs = null; } } catch (_) {}
606
- try { if (window.__nbbTcfObs) { window.__nbbTcfObs.disconnect(); window.__nbbTcfObs = null; } } catch (_) {}
607
624
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
608
625
  S.cfg = null;
609
626
  S.pools = { topics: [], posts: [], categories: [] };
@@ -617,19 +634,17 @@
617
634
  S.runQueued = false;
618
635
  }
619
636
 
620
- // ── DOM Observer ───────────────────────────────────────────────────────────
637
+ // ── MutationObserver ───────────────────────────────────────────────────────
621
638
 
622
639
  function ensureDomObserver() {
623
640
  if (S.domObs) return;
641
+ const allSel = [SEL.post, SEL.topic, SEL.category];
624
642
  S.domObs = new MutationObserver(muts => {
625
643
  if (S.mutGuard > 0 || isBlocked()) return;
626
644
  for (const m of muts) {
627
- if (!m.addedNodes?.length) continue;
628
645
  for (const n of m.addedNodes) {
629
646
  if (n.nodeType !== 1) continue;
630
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
631
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
632
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
647
+ if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
633
648
  requestBurst(); return;
634
649
  }
635
650
  }
@@ -641,7 +656,6 @@
641
656
  // ── Utilitaires ────────────────────────────────────────────────────────────
642
657
 
643
658
  function muteConsole() {
644
- if (!S.cfg || !S.cfg.muteEzoicConsole) return;
645
659
  if (window.__nbbEzMuted) return;
646
660
  window.__nbbEzMuted = true;
647
661
  const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
@@ -656,29 +670,18 @@
656
670
  }
657
671
 
658
672
  function ensureTcfLocator() {
659
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
660
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
661
- // iframe du DOM (vidage partiel du body), ce qui provoque :
662
- // "Cannot read properties of null (reading 'postMessage')"
663
- // "Cannot set properties of null (setting 'addtlConsent')"
664
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
665
673
  try {
666
674
  if (!window.__tcfapi && !window.__cmp) return;
667
-
668
675
  const inject = () => {
669
676
  if (document.getElementById('__tcfapiLocator')) return;
670
677
  const f = document.createElement('iframe');
671
678
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
672
679
  (document.body || document.documentElement).appendChild(f);
673
680
  };
674
-
675
681
  inject();
676
-
677
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
678
682
  if (!window.__nbbTcfObs) {
679
- window.__nbbTcfObs = new MutationObserver(() => inject());
680
- window.__nbbTcfObs.observe(document.documentElement,
681
- { childList: true, subtree: true });
683
+ window.__nbbTcfObs = new MutationObserver(inject);
684
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
682
685
  }
683
686
  } catch (_) {}
684
687
  }
@@ -688,10 +691,10 @@
688
691
  const head = document.head;
689
692
  if (!head) return;
690
693
  for (const [rel, href, cors] of [
691
- ['preconnect', 'https://g.ezoic.net', true],
692
- ['preconnect', 'https://go.ezoic.net', true],
693
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
694
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
694
+ ['preconnect', 'https://g.ezoic.net', true ],
695
+ ['preconnect', 'https://go.ezoic.net', true ],
696
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
697
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
695
698
  ['dns-prefetch', 'https://g.ezoic.net', false],
696
699
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
697
700
  ]) {
@@ -705,7 +708,7 @@
705
708
  }
706
709
  }
707
710
 
708
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
711
+ // ── Bindings ───────────────────────────────────────────────────────────────
709
712
 
710
713
  function bindNodeBB() {
711
714
  const $ = window.jQuery;
@@ -716,19 +719,16 @@
716
719
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
717
720
  S.pageKey = pageKey();
718
721
  blockedUntil = 0;
719
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
720
- getIO(); ensureDomObserver(); requestBurst();
722
+ muteConsole(); ensureTcfLocator(); warmNetwork();
723
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
721
724
  });
722
725
 
723
- const BURST_EVENTS = [
724
- 'action:ajaxify.contentLoaded',
725
- 'action:posts.loaded', 'action:topics.loaded',
726
+ const burstEvts = [
727
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
726
728
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
727
729
  ].map(e => `${e}.nbbEzoic`).join(' ');
730
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
728
731
 
729
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
730
-
731
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
732
732
  try {
733
733
  require(['hooks'], hooks => {
734
734
  if (typeof hooks?.on !== 'function') return;
@@ -743,12 +743,14 @@
743
743
  function bindScroll() {
744
744
  let ticking = false;
745
745
  window.addEventListener('scroll', () => {
746
- if (ticking) return;
747
- // Scroll bursts only matter on pages where content streams in
748
- const kind = getKind();
749
- if (kind !== 'topic' && kind !== 'categoryTopics' && kind !== 'categories') return;
750
- if (isBlocked()) return;
746
+ // Suivi direction du scroll (nécessaire pour le recyclage conditionnel)
747
+ try {
748
+ const y = window.scrollY || window.pageYOffset || 0;
749
+ const d = y - S.lastScrollY;
750
+ if (Math.abs(d) > 4) { S.scrollDir = d > 0 ? 1 : -1; S.lastScrollY = y; }
751
+ } catch (_) {}
751
752
 
753
+ if (ticking) return;
752
754
  ticking = true;
753
755
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
754
756
  }, { passive: true });
@@ -757,6 +759,7 @@
757
759
  // ── Boot ───────────────────────────────────────────────────────────────────
758
760
 
759
761
  S.pageKey = pageKey();
762
+ try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) {}
760
763
  muteConsole();
761
764
  ensureTcfLocator();
762
765
  warmNetwork();
package/public/style.css CHANGED
@@ -71,8 +71,8 @@
71
71
  overflow: hidden !important;
72
72
  }
73
73
 
74
- /* ── Note ───────────────────────────────────────────────────────────── */
75
- /*
76
- On évite volontairement de cibler .ezoic-ad globalement,
77
- pour ne pas impacter d'autres emplacements/optimisations Ezoic hors plugin.
78
- */
74
+ /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
75
+ .ezoic-ad {
76
+ margin: 0 !important;
77
+ padding: 0 !important;
78
+ }
@@ -8,7 +8,7 @@
8
8
  <input class="form-check-input" type="checkbox" id="enableBetweenAds" name="enableBetweenAds" {enableBetweenAds_checked}>
9
9
  <label class="form-check-label" for="enableBetweenAds">Activer les pubs entre les posts</label>
10
10
  <div class="form-check mt-2">
11
- <input class="form-check-input" type="checkbox" name="showFirstTopicAd" {showFirstTopicAd_checked} />
11
+ <input class="form-check-input" type="checkbox" name="showFirstTopicAd" />
12
12
  <label class="form-check-label">Afficher une pub après le 1er sujet</label>
13
13
  </div>
14
14
  </div>
@@ -16,7 +16,7 @@
16
16
  <div class="mb-3">
17
17
  <label class="form-label" for="placeholderIds">Pool d’IDs Ezoic (entre posts)</label>
18
18
  <textarea id="placeholderIds" name="placeholderIds" class="form-control" rows="4">{placeholderIds}</textarea>
19
- <p class="form-text">Un ID par ligne (ou séparé par virgules/espaces).</p>
19
+ <p class="form-text">Un ID par ligne (ou séparé par virgules/espaces). Le nombre d’IDs = nombre max de pubs simultanées.</p>
20
20
  </div>
21
21
 
22
22
  <div class="mb-3">
@@ -33,10 +33,10 @@
33
33
  <p class="form-text">Insère des pubs entre les catégories sur la page d’accueil (liste des catégories).</p>
34
34
 
35
35
  <div class="form-check mb-3">
36
- <input class="form-check-input" type="checkbox" id="enableCategoryAds" name="enableCategoryAds" {enableCategoryAds_checked}>
36
+ <input class="form-check-input" type="checkbox" id="enableCategoryAds" name="enableCategoryAds">
37
37
  <label class="form-check-label" for="enableCategoryAds">Activer les pubs entre les catégories</label>
38
38
  <div class="form-check mt-2">
39
- <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" {showFirstCategoryAd_checked} />
39
+ <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" />
40
40
  <label class="form-check-label">Afficher une pub après la 1ère catégorie</label>
41
41
  </div>
42
42
  </div>
@@ -44,7 +44,7 @@
44
44
  <div class="mb-3">
45
45
  <label class="form-label" for="categoryPlaceholderIds">Pool d’IDs Ezoic (catégories)</label>
46
46
  <textarea id="categoryPlaceholderIds" name="categoryPlaceholderIds" class="form-control" rows="4">{categoryPlaceholderIds}</textarea>
47
- <p class="form-text">IDs numériques, un par ligne (ou séparés par virgules/espaces). Utilise un pool dédié (différent des pools topics/messages).</p>
47
+ <p class="form-text">IDs numériques, un par ligne. Utilise un pool dédié (différent des pools topics/messages).</p>
48
48
  </div>
49
49
 
50
50
  <div class="mb-3">
@@ -59,7 +59,7 @@
59
59
  <input class="form-check-input" type="checkbox" id="enableMessageAds" name="enableMessageAds" {enableMessageAds_checked}>
60
60
  <label class="form-check-label" for="enableMessageAds">Activer les pubs “message”</label>
61
61
  <div class="form-check mt-2">
62
- <input class="form-check-input" type="checkbox" name="showFirstMessageAd" {showFirstMessageAd_checked} />
62
+ <input class="form-check-input" type="checkbox" name="showFirstMessageAd" />
63
63
  <label class="form-check-label">Afficher une pub après le 1er message</label>
64
64
  </div>
65
65
  </div>
@@ -67,7 +67,7 @@
67
67
  <div class="mb-3">
68
68
  <label class="form-label" for="messagePlaceholderIds">Pool d’IDs Ezoic (message)</label>
69
69
  <textarea id="messagePlaceholderIds" name="messagePlaceholderIds" class="form-control" rows="4">{messagePlaceholderIds}</textarea>
70
- <p class="form-text">Pool séparé recommandé pour limiter la réutilisation d’IDs entre emplacements.</p>
70
+ <p class="form-text">Pool séparé recommandé pour éviter la réutilisation d’IDs. IMPORTANT : ne réutilise pas les mêmes IDs dans les deux pools.</p>
71
71
  </div>
72
72
 
73
73
  <div class="mb-3">
@@ -82,22 +82,12 @@
82
82
  <label class="form-label" for="excludedGroups">Groupes exclus</label>
83
83
  <select id="excludedGroups" name="excludedGroups" class="form-select" multiple>
84
84
  <!-- BEGIN allGroups -->
85
- <option value="{allGroups.name}" {allGroups.selected}>{allGroups.name}</option>
85
+ <option value="{allGroups.name}">{allGroups.name}</option>
86
86
  <!-- END allGroups -->
87
87
  </select>
88
88
  <p class="form-text">Si l’utilisateur appartient à un de ces groupes, aucune pub n’est injectée.</p>
89
89
  </div>
90
90
 
91
-
92
- <hr/>
93
-
94
- <h4 class="mt-3">Options avancées</h4>
95
-
96
- <div class="form-check mb-3">
97
- <input class="form-check-input" type="checkbox" id="muteEzoicConsole" name="muteEzoicConsole" {muteEzoicConsole_checked}>
98
- <label class="form-check-label" for="muteEzoicConsole">Filtrer certains logs Ezoic dans la console (évite le bruit)</label>
99
- <p class="form-text">Désactivé par défaut pour ne pas impacter les logs d’autres plugins.</p>
100
- </div>
101
91
  <button id="save" class="btn btn-primary">Enregistrer</button>
102
92
  </form>
103
93
  </div>