nodebb-plugin-ezoic-infinite 1.8.19 → 1.8.21

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
@@ -1,159 +1,205 @@
1
1
  'use strict';
2
2
 
3
- const meta = require.main.require('./src/meta');
3
+ const meta = require.main.require('./src/meta');
4
4
  const groups = require.main.require('./src/groups');
5
- const db = require.main.require('./src/database');
5
+ const db = require.main.require('./src/database');
6
6
 
7
7
  const SETTINGS_KEY = 'ezoic-infinite';
8
- const plugin = {};
8
+ const SETTINGS_TTL_MS = 30_000;
9
9
 
10
- // ── Helpers ────────────────────────────────────────────────────────────────
10
+ const EZOIC_SCRIPTS = `<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>
11
+ <script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>
12
+ <script async src="//www.ezojs.com/ezoic/sa.min.js"></script>
13
+ <script>
14
+ window.ezstandalone = window.ezstandalone || {};
15
+ ezstandalone.cmd = ezstandalone.cmd || [];
16
+ </script>`;
17
+
18
+ const DEFAULTS = Object.freeze({
19
+ enableBetweenAds: true,
20
+ showFirstTopicAd: false,
21
+ placeholderIds: '',
22
+ intervalPosts: 6,
23
+ enableCategoryAds: false,
24
+ showFirstCategoryAd: false,
25
+ categoryPlaceholderIds: '',
26
+ intervalCategories: 4,
27
+ enableMessageAds: false,
28
+ showFirstMessageAd: false,
29
+ messagePlaceholderIds: '',
30
+ messageIntervalPosts: 3,
31
+ excludedGroups: [],
32
+ });
33
+
34
+ const CONFIG_FIELDS = Object.freeze([
35
+ 'enableBetweenAds', 'showFirstTopicAd', 'placeholderIds', 'intervalPosts',
36
+ 'enableCategoryAds', 'showFirstCategoryAd', 'categoryPlaceholderIds', 'intervalCategories',
37
+ 'enableMessageAds', 'showFirstMessageAd', 'messagePlaceholderIds', 'messageIntervalPosts',
38
+ ]);
39
+
40
+ const plugin = {
41
+ _settingsCache: null,
42
+ _settingsCacheAt: 0,
43
+ };
44
+
45
+ function toBool(value, fallback = false) {
46
+ if (value === undefined || value === null || value === '') return fallback;
47
+ if (typeof value === 'boolean') return value;
48
+ switch (String(value).trim().toLowerCase()) {
49
+ case '1':
50
+ case 'true':
51
+ case 'on':
52
+ case 'yes':
53
+ return true;
54
+ default:
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function toPositiveInt(value, fallback) {
60
+ const parsed = Number.parseInt(value, 10);
61
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
62
+ }
63
+
64
+ function toStringTrim(value) {
65
+ return typeof value === 'string' ? value.trim() : '';
66
+ }
11
67
 
12
68
  function normalizeExcludedGroups(value) {
13
69
  if (!value) return [];
14
- if (Array.isArray(value)) return value;
15
- // NodeBB stocke les settings multi-valeurs comme string JSON "[\"group1\",\"group2\"]"
16
- const s = String(value).trim();
17
- if (s.startsWith('[')) {
18
- try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
70
+ if (Array.isArray(value)) {
71
+ return value.map(String).map(v => v.trim()).filter(Boolean);
19
72
  }
20
- // Fallback : séparation par virgule
21
- return s.split(',').map(v => v.trim()).filter(Boolean);
73
+
74
+ const raw = String(value).trim();
75
+ if (!raw) return [];
76
+
77
+ if (raw.startsWith('[')) {
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ if (Array.isArray(parsed)) {
81
+ return parsed.map(String).map(v => v.trim()).filter(Boolean);
82
+ }
83
+ } catch {}
84
+ }
85
+
86
+ return raw.split(',').map(v => v.trim()).filter(Boolean);
22
87
  }
23
88
 
24
- function parseBool(v, def = false) {
25
- if (v === undefined || v === null || v === '') return def;
26
- if (typeof v === 'boolean') return v;
27
- const s = String(v).toLowerCase();
28
- return s === '1' || s === 'true' || s === 'on' || s === 'yes';
89
+ function buildSettings(raw = {}) {
90
+ return {
91
+ enableBetweenAds: toBool(raw.enableBetweenAds, DEFAULTS.enableBetweenAds),
92
+ showFirstTopicAd: toBool(raw.showFirstTopicAd, DEFAULTS.showFirstTopicAd),
93
+ placeholderIds: toStringTrim(raw.placeholderIds),
94
+ intervalPosts: toPositiveInt(raw.intervalPosts, DEFAULTS.intervalPosts),
95
+ enableCategoryAds: toBool(raw.enableCategoryAds, DEFAULTS.enableCategoryAds),
96
+ showFirstCategoryAd: toBool(raw.showFirstCategoryAd, DEFAULTS.showFirstCategoryAd),
97
+ categoryPlaceholderIds: toStringTrim(raw.categoryPlaceholderIds),
98
+ intervalCategories: toPositiveInt(raw.intervalCategories, DEFAULTS.intervalCategories),
99
+ enableMessageAds: toBool(raw.enableMessageAds, DEFAULTS.enableMessageAds),
100
+ showFirstMessageAd: toBool(raw.showFirstMessageAd, DEFAULTS.showFirstMessageAd),
101
+ messagePlaceholderIds: toStringTrim(raw.messagePlaceholderIds),
102
+ messageIntervalPosts: toPositiveInt(raw.messageIntervalPosts, DEFAULTS.messageIntervalPosts),
103
+ excludedGroups: normalizeExcludedGroups(raw.excludedGroups),
104
+ };
29
105
  }
30
106
 
31
- async function getAllGroups() {
107
+ async function listNonPrivilegeGroups() {
32
108
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
33
- if (!names || !names.length) {
109
+ if (!Array.isArray(names) || !names.length) {
34
110
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
35
111
  }
36
- const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
37
- const data = await groups.getGroupsData(filtered);
38
- const valid = data.filter(g => g && g.name);
39
- valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
40
- return valid;
41
- }
42
112
 
43
- // ── Settings cache (30s TTL) ────────────────────────────────────────────────
113
+ const publicNames = (names || []).filter(name => !groups.isPrivilegeGroup(name));
114
+ const groupData = await groups.getGroupsData(publicNames);
44
115
 
45
- let _settingsCache = null;
46
- let _settingsCacheAt = 0;
47
- const SETTINGS_TTL = 30_000;
116
+ return (groupData || [])
117
+ .filter(group => group && group.name)
118
+ .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
119
+ }
48
120
 
49
121
  async function getSettings() {
50
122
  const now = Date.now();
51
- if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
52
- const s = await meta.settings.get(SETTINGS_KEY);
53
- _settingsCacheAt = Date.now();
54
- _settingsCache = {
55
- enableBetweenAds: parseBool(s.enableBetweenAds, true),
56
- showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
57
- placeholderIds: (s.placeholderIds || '').trim(),
58
- intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
59
- enableCategoryAds: parseBool(s.enableCategoryAds, false),
60
- showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
61
- categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
62
- intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
63
- enableMessageAds: parseBool(s.enableMessageAds, false),
64
- showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
65
- messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
66
- messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
67
- excludedGroups: normalizeExcludedGroups(s.excludedGroups),
68
- };
69
- return _settingsCache;
70
- }
123
+ if (plugin._settingsCache && (now - plugin._settingsCacheAt) < SETTINGS_TTL_MS) {
124
+ return plugin._settingsCache;
125
+ }
71
126
 
72
- async function isUserExcluded(uid, excludedGroups) {
73
- if (!uid || !excludedGroups.length) return false;
74
- const userGroups = await groups.getUserGroups([uid]);
75
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
127
+ const raw = await meta.settings.get(SETTINGS_KEY);
128
+ const settings = buildSettings(raw);
129
+
130
+ plugin._settingsCache = settings;
131
+ plugin._settingsCacheAt = now;
132
+ return settings;
76
133
  }
77
134
 
78
- // ── Scripts Ezoic ──────────────────────────────────────────────────────────
135
+ async function isUserExcluded(uid, excludedGroups) {
136
+ if (!uid || !Array.isArray(excludedGroups) || !excludedGroups.length) return false;
79
137
 
80
- const EZOIC_SCRIPTS = `<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>
81
- <script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>
82
- <script async src="//www.ezojs.com/ezoic/sa.min.js"></script>
83
- <script>
84
- window.ezstandalone = window.ezstandalone || {};
85
- ezstandalone.cmd = ezstandalone.cmd || [];
86
- </script>`;
138
+ const userGroups = await groups.getUserGroups([uid]);
139
+ const names = (userGroups && userGroups[0]) || [];
140
+ const excludedSet = new Set(excludedGroups);
87
141
 
88
- // ── Hooks ──────────────────────────────────────────────────────────────────
142
+ return names.some(group => group && excludedSet.has(group.name));
143
+ }
89
144
 
90
- plugin.onSettingsSet = function (data) {
91
- if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
145
+ plugin.onSettingsSet = function onSettingsSet(data) {
146
+ if (data && data.hash === SETTINGS_KEY) {
147
+ plugin._settingsCache = null;
148
+ plugin._settingsCacheAt = 0;
149
+ }
92
150
  };
93
151
 
94
- plugin.addAdminNavigation = async (header) => {
152
+ plugin.addAdminNavigation = async function addAdminNavigation(header) {
95
153
  header.plugins = header.plugins || [];
96
- header.plugins.push({ route: '/plugins/ezoic-infinite', icon: 'fa-ad', name: 'Ezoic Infinite Ads' });
154
+ header.plugins.push({
155
+ route: '/plugins/ezoic-infinite',
156
+ icon: 'fa-ad',
157
+ name: 'Ezoic Infinite Ads',
158
+ });
97
159
  return header;
98
160
  };
99
161
 
100
- /**
101
- * Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
102
- *
103
- * 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.
109
- */
110
- plugin.injectEzoicHead = async (data) => {
162
+ plugin.injectEzoicHead = async function injectEzoicHead(data) {
111
163
  try {
112
164
  const settings = await getSettings();
113
- const uid = data.req?.uid ?? 0;
114
- const excluded = await isUserExcluded(uid, settings.excludedGroups);
115
- if (!excluded) {
116
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
117
- data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
165
+ const uid = data?.req?.uid || 0;
166
+ if (await isUserExcluded(uid, settings.excludedGroups)) {
167
+ return data;
118
168
  }
119
- } catch (_) {}
169
+
170
+ const templateData = data.templateData || (data.templateData = {});
171
+ templateData.customHTML = `${EZOIC_SCRIPTS}${templateData.customHTML || ''}`;
172
+ } catch {}
173
+
120
174
  return data;
121
175
  };
122
176
 
123
- plugin.init = async ({ router, middleware }) => {
124
- async function render(req, res) {
125
- const settings = await getSettings();
126
- const allGroups = await getAllGroups();
177
+ plugin.init = async function init({ router, middleware }) {
178
+ async function renderAdmin(req, res) {
179
+ const [settings, allGroups] = await Promise.all([
180
+ getSettings(),
181
+ listNonPrivilegeGroups(),
182
+ ]);
183
+
127
184
  res.render('admin/plugins/ezoic-infinite', {
128
185
  title: 'Ezoic Infinite Ads',
129
186
  ...settings,
130
187
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
131
- enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
188
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
132
189
  allGroups,
133
190
  });
134
191
  }
135
192
 
136
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
137
- router.get('/api/admin/plugins/ezoic-infinite', render);
193
+ router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, renderAdmin);
194
+ router.get('/api/admin/plugins/ezoic-infinite', renderAdmin);
138
195
 
139
196
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
140
197
  const settings = await getSettings();
141
198
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
142
- res.json({
143
- excluded,
144
- enableBetweenAds: settings.enableBetweenAds,
145
- showFirstTopicAd: settings.showFirstTopicAd,
146
- placeholderIds: settings.placeholderIds,
147
- intervalPosts: settings.intervalPosts,
148
- enableCategoryAds: settings.enableCategoryAds,
149
- showFirstCategoryAd: settings.showFirstCategoryAd,
150
- categoryPlaceholderIds: settings.categoryPlaceholderIds,
151
- intervalCategories: settings.intervalCategories,
152
- enableMessageAds: settings.enableMessageAds,
153
- showFirstMessageAd: settings.showFirstMessageAd,
154
- messagePlaceholderIds: settings.messagePlaceholderIds,
155
- messageIntervalPosts: settings.messageIntervalPosts,
156
- });
199
+
200
+ const payload = { excluded };
201
+ for (const key of CONFIG_FIELDS) payload[key] = settings[key];
202
+ res.json(payload);
157
203
  });
158
204
  };
159
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.19",
3
+ "version": "1.8.21",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/admin.js CHANGED
@@ -1,28 +1,44 @@
1
1
  /* globals ajaxify */
2
2
  'use strict';
3
3
 
4
- (function () {
5
- function init() {
6
- const $form = $('.ezoic-infinite-settings');
7
- if (!$form.length) return;
4
+ (function initAdminEzoicSettings() {
5
+ const FORM_SELECTOR = '.ezoic-infinite-settings';
6
+ const SAVE_SELECTOR = '#save';
7
+ const SETTINGS_KEY = 'ezoic-infinite';
8
+ const EVENT_NS = '.ezoicInfinite';
9
+
10
+ function showSaved(alerts) {
11
+ if (alerts && typeof alerts.success === 'function') {
12
+ alerts.success('[[admin/settings:saved]]');
13
+ return;
14
+ }
15
+ if (window.app && typeof window.app.alertSuccess === 'function') {
16
+ window.app.alertSuccess('[[admin/settings:saved]]');
17
+ }
18
+ }
8
19
 
9
- require(['settings', 'alerts'], function (Settings, alerts) {
10
- Settings.load('ezoic-infinite', $form);
20
+ function bind(Settings, alerts, $form) {
21
+ Settings.load(SETTINGS_KEY, $form);
11
22
 
12
- $('#save').off('click.ezoicInfinite').on('click.ezoicInfinite', function (e) {
23
+ $(SAVE_SELECTOR)
24
+ .off(`click${EVENT_NS}`)
25
+ .on(`click${EVENT_NS}`, function onSave(e) {
13
26
  e.preventDefault();
14
-
15
- Settings.save('ezoic-infinite', $form, function () {
16
- if (alerts && typeof alerts.success === 'function') {
17
- alerts.success('[[admin/settings:saved]]');
18
- } else if (window.app && typeof window.app.alertSuccess === 'function') {
19
- window.app.alertSuccess('[[admin/settings:saved]]');
20
- }
27
+ Settings.save(SETTINGS_KEY, $form, function onSaved() {
28
+ showSaved(alerts);
21
29
  });
22
30
  });
31
+ }
32
+
33
+ function boot() {
34
+ const $form = $(FORM_SELECTOR);
35
+ if (!$form.length) return;
36
+
37
+ require(['settings', 'alerts'], function onModules(Settings, alerts) {
38
+ bind(Settings, alerts, $form);
23
39
  });
24
40
  }
25
41
 
26
- $(document).ready(init);
27
- $(window).on('action:ajaxify.end', init);
42
+ $(document).ready(boot);
43
+ $(window).on('action:ajaxify.end', boot);
28
44
  })();
package/public/client.js CHANGED
@@ -1,70 +1,3 @@
1
- /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
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.
67
- */
68
1
  (function nbbEzoicInfinite() {
69
2
  'use strict';
70
3
 
@@ -89,6 +22,11 @@
89
22
  const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
90
23
  const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
91
24
  const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
25
+ const CLEANUP_GRACE_MS = 3_500; // délai mini avant cleanup d'un wrap candidat
26
+ const SPECIAL_GRACE_MS = 30_000; // délai allongé pour sticky/fixed/adhesion
27
+ const RECENT_WRAP_ACTIVITY_MS = 5_000; // protège un wrap récemment muté/rafraîchi
28
+ const VIEWPORT_BUFFER_DESKTOP = 500;
29
+ const VIEWPORT_BUFFER_MOBILE = 250;
92
30
 
93
31
  // Marges IO larges et fixes — observer créé une seule fois au boot
94
32
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
@@ -99,6 +37,9 @@
99
37
  topic: 'li[component="category/topic"]',
100
38
  category: 'li[component="categories/category"]',
101
39
  };
40
+ const WRAP_SEL = `.${WRAP_CLASS}`;
41
+ const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id]';
42
+ const CONTENT_SEL_LIST = [SEL.post, SEL.topic, SEL.category];
102
43
 
103
44
  /**
104
45
  * Table KIND — source de vérité par kindClass.
@@ -140,6 +81,8 @@
140
81
  wrapByKey: new Map(), // anchorKey → wrap DOM node
141
82
  ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
142
83
  ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
84
+ wrapActivityAt: new Map(), // key/id -> ts activité DOM récente
85
+ lastWrapHeightByClass: new Map(),
143
86
  scrollDir: 1, // 1=bas, -1=haut
144
87
  scrollSpeed: 0, // px/s approx (EMA)
145
88
  lastScrollY: 0,
@@ -157,7 +100,103 @@
157
100
  const isBlocked = () => ts() < blockedUntil;
158
101
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
159
102
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
160
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
103
+ const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
104
+
105
+ function viewportBufferPx() {
106
+ return isMobile() ? VIEWPORT_BUFFER_MOBILE : VIEWPORT_BUFFER_DESKTOP;
107
+ }
108
+
109
+ function textSig(el) {
110
+ if (!(el instanceof Element)) return '';
111
+ const parts = [el.id || '', String(el.className || ''), el.getAttribute?.('name') || ''];
112
+ try {
113
+ for (const a of ['data-google-query-id', 'data-google-container-id', 'data-slot', 'data-ad-slot']) {
114
+ const v = el.getAttribute?.(a);
115
+ if (v) parts.push(v);
116
+ }
117
+ } catch (_) {}
118
+ return parts.join(' ').toLowerCase();
119
+ }
120
+
121
+ function isSpecialSlotLike(el) {
122
+ const sig = textSig(el);
123
+ return /adhesion|interstitial|anchor|sticky|outofpage/.test(sig);
124
+ }
125
+
126
+ function hasSpecialSlotMarkers(root) {
127
+ if (!(root instanceof Element)) return false;
128
+ if (isSpecialSlotLike(root)) return true;
129
+ try {
130
+ const nodes = root.querySelectorAll('[id],[class],[name],[data-slot],[data-ad-slot]');
131
+ for (const n of nodes) if (isSpecialSlotLike(n)) return true;
132
+ } catch (_) {}
133
+ return false;
134
+ }
135
+
136
+ function hasFixedLikeNode(root, maxScan = 24) {
137
+ if (!(root instanceof Element)) return false;
138
+ const q = [root];
139
+ let seen = 0;
140
+ while (q.length && seen < maxScan) {
141
+ const n = q.shift();
142
+ seen++;
143
+ try {
144
+ const cs = window.getComputedStyle(n);
145
+ if (cs.position === 'fixed' || cs.position === 'sticky') return true;
146
+ } catch (_) {}
147
+ for (const c of n.children || []) q.push(c);
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function markWrapActivity(wrapOrId) {
153
+ const t = ts();
154
+ try {
155
+ if (wrapOrId instanceof Element) {
156
+ const key = wrapOrId.getAttribute(A_ANCHOR);
157
+ const id = parseInt(wrapOrId.getAttribute(A_WRAPID), 10);
158
+ if (key) S.wrapActivityAt.set(`k:${key}`, t);
159
+ if (Number.isFinite(id)) S.wrapActivityAt.set(`i:${id}`, t);
160
+ return;
161
+ }
162
+ const id = parseInt(wrapOrId, 10);
163
+ if (Number.isFinite(id)) S.wrapActivityAt.set(`i:${id}`, t);
164
+ } catch (_) {}
165
+ }
166
+
167
+ function wrapRecentActivity(w) {
168
+ try {
169
+ const key = w?.getAttribute?.(A_ANCHOR);
170
+ const id = parseInt(w?.getAttribute?.(A_WRAPID), 10);
171
+ const t1 = key ? (S.wrapActivityAt.get(`k:${key}`) || 0) : 0;
172
+ const t2 = Number.isFinite(id) ? (S.wrapActivityAt.get(`i:${id}`) || 0) : 0;
173
+ const t3 = parseInt(w?.getAttribute?.(A_SHOWN) || '0', 10) || 0;
174
+ return (ts() - Math.max(t1, t2, t3)) < RECENT_WRAP_ACTIVITY_MS;
175
+ } catch (_) { return false; }
176
+ }
177
+
178
+ function wrapCleanupGraceMs(w) {
179
+ return (hasSpecialSlotMarkers(w) || hasFixedLikeNode(w)) ? SPECIAL_GRACE_MS : CLEANUP_GRACE_MS;
180
+ }
181
+
182
+ function wrapNearViewport(w) {
183
+ try {
184
+ const r = w.getBoundingClientRect();
185
+ const b = viewportBufferPx();
186
+ const vh = window.innerHeight || 800;
187
+ return r.bottom > -b && r.top < vh + b;
188
+ } catch (_) { return true; }
189
+ }
190
+
191
+ function rememberWrapHeight(w) {
192
+ try {
193
+ if (!(w instanceof Element)) return;
194
+ const klass = [...(w.classList || [])].find(c => c.startsWith('ezoic-ad-'));
195
+ if (!klass) return;
196
+ const h = Math.round(w.getBoundingClientRect().height || 0);
197
+ if (h >= 40) S.lastWrapHeightByClass.set(klass, h);
198
+ } catch (_) {}
199
+ }
161
200
 
162
201
  function healFalseEmpty(root = document) {
163
202
  try {
@@ -196,7 +235,7 @@
196
235
  requestAnimationFrame(() => {
197
236
  S.sweepQueued = false;
198
237
  sweepDeadWraps();
199
- healFalseEmpty();
238
+ healFalseEmpty();
200
239
  });
201
240
  }
202
241
 
@@ -264,16 +303,25 @@ function destroyBeforeReuse(ids) {
264
303
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
265
304
  seen.add(id);
266
305
  out.push(id);
306
+ try {
307
+ const wrap = phEl(id)?.closest?.(WRAP_SEL) || null;
308
+ if (wrap && (wrapRecentActivity(wrap) || hasSpecialSlotMarkers(wrap) || hasFixedLikeNode(wrap))) {
309
+ continue;
310
+ }
311
+ } catch (_) {}
267
312
  if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
268
313
  }
269
314
  if (toDestroy.length) {
270
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
315
+ try {
316
+ const ez = window.ezstandalone;
317
+ const run = () => { try { ez?.destroyPlaceholders?.(toDestroy); } catch (_) {} };
318
+ try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
319
+ } catch (_) {}
271
320
  for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
272
321
  }
273
322
  return out;
274
323
  }
275
324
 
276
-
277
325
  // ── Config ─────────────────────────────────────────────────────────────────
278
326
 
279
327
  async function fetchConfig() {
@@ -430,8 +478,10 @@ function destroyBeforeReuse(ids) {
430
478
  function sweepDeadWraps() {
431
479
  // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
432
480
  // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
433
- for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
481
+ let changed = false;
482
+ for (const [key, wrap] of S.wrapByKey) {
434
483
  if (wrap?.isConnected) continue;
484
+ changed = true;
435
485
  S.wrapByKey.delete(key);
436
486
  const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
437
487
  if (Number.isFinite(id)) {
@@ -441,7 +491,7 @@ function destroyBeforeReuse(ids) {
441
491
  S.ezActiveIds.delete(id);
442
492
  }
443
493
  }
444
- if (S.pending.length) {
494
+ if (changed && S.pending.length) {
445
495
  S.pending = S.pending.filter(id => S.pendingSet.has(id));
446
496
  }
447
497
  }
@@ -471,6 +521,8 @@ function recycleAndMove(klass, targetEl, newKey) {
471
521
 
472
522
  for (const wrap of S.wrapByKey.values()) {
473
523
  if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
524
+ if (wrapRecentActivity(wrap)) continue;
525
+ if (hasSpecialSlotMarkers(wrap) || hasFixedLikeNode(wrap)) continue;
474
526
  try {
475
527
  const rect = wrap.getBoundingClientRect();
476
528
  const isAbove = rect.bottom <= farAbove;
@@ -505,6 +557,7 @@ function recycleAndMove(klass, targetEl, newKey) {
505
557
  const oldKey = best.getAttribute(A_ANCHOR);
506
558
  try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
507
559
  mutate(() => {
560
+ rememberWrapHeight(best);
508
561
  best.setAttribute(A_ANCHOR, newKey);
509
562
  best.setAttribute(A_CREATED, String(ts()));
510
563
  best.setAttribute(A_SHOWN, '0');
@@ -531,7 +584,6 @@ function recycleAndMove(klass, targetEl, newKey) {
531
584
  return { id, wrap: best };
532
585
  }
533
586
 
534
-
535
587
  // ── Wraps DOM — création / suppression ────────────────────────────────────
536
588
 
537
589
  function makeWrap(id, klass, key) {
@@ -542,6 +594,8 @@ function recycleAndMove(klass, targetEl, newKey) {
542
594
  w.setAttribute(A_CREATED, String(ts()));
543
595
  w.setAttribute(A_SHOWN, '0');
544
596
  w.style.cssText = 'width:100%;display:block;';
597
+ const cachedH = S.lastWrapHeightByClass.get(klass);
598
+ if (Number.isFinite(cachedH) && cachedH > 0) w.style.minHeight = `${cachedH}px`;
545
599
  const ph = document.createElement('div');
546
600
  ph.id = `${PH_PREFIX}${id}`;
547
601
  ph.setAttribute('data-ezoic-id', String(id));
@@ -563,11 +617,14 @@ function recycleAndMove(klass, targetEl, newKey) {
563
617
 
564
618
  function dropWrap(w) {
565
619
  try {
620
+ rememberWrapHeight(w);
566
621
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
567
622
  if (ph instanceof Element) S.io?.unobserve(ph);
568
623
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
569
624
  if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
570
625
  const key = w.getAttribute(A_ANCHOR);
626
+ if (key) S.wrapActivityAt.delete(`k:${key}`);
627
+ if (Number.isFinite(id)) S.wrapActivityAt.delete(`i:${id}`);
571
628
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
572
629
  w.remove();
573
630
  } catch (_) {}
@@ -592,9 +649,10 @@ function recycleAndMove(klass, targetEl, newKey) {
592
649
  const klass = 'ezoic-ad-between';
593
650
  const cfg = KIND[klass];
594
651
 
595
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
652
+ document.querySelectorAll(`${WRAP_SEL}.${klass}`).forEach(w => {
596
653
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
597
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
654
+ if (ts() - created < Math.max(MIN_PRUNE_AGE_MS, wrapCleanupGraceMs(w))) return;
655
+ if (wrapRecentActivity(w) || wrapNearViewport(w)) return;
598
656
 
599
657
  const key = w.getAttribute(A_ANCHOR) ?? '';
600
658
  const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
@@ -751,7 +809,7 @@ function startShowBatch(ids) {
751
809
  if (!canShowPlaceholderId(id, t)) continue;
752
810
 
753
811
  S.lastShow.set(id, t);
754
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
812
+ try { const wrap = ph.closest?.(WRAP_SEL); wrap?.setAttribute(A_SHOWN, String(t)); if (wrap) markWrapActivity(wrap); } catch (_) {}
755
813
  valid.push(id);
756
814
  }
757
815
 
@@ -774,7 +832,6 @@ function startShowBatch(ids) {
774
832
  });
775
833
  }
776
834
 
777
-
778
835
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
779
836
  //
780
837
  // Intercepte ez.showAds() pour :
@@ -899,7 +956,7 @@ function startShowBatch(ids) {
899
956
 
900
957
  function cleanup() {
901
958
  blockedUntil = ts() + 1500;
902
- mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
959
+ mutate(() => document.querySelectorAll(WRAP_SEL).forEach(dropWrap));
903
960
  S.cfg = null;
904
961
  S.poolsReady = false;
905
962
  S.pools = { topics: [], posts: [], categories: [] };
@@ -909,6 +966,7 @@ function startShowBatch(ids) {
909
966
  S.wrapByKey.clear();
910
967
  S.ezActiveIds.clear();
911
968
  S.ezShownSinceDestroy.clear();
969
+ S.wrapActivityAt.clear();
912
970
  S.inflight = 0;
913
971
  S.pending = [];
914
972
  S.pendingSet.clear();
@@ -924,28 +982,51 @@ function startShowBatch(ids) {
924
982
  S.lastScrollTs = 0;
925
983
  }
926
984
 
985
+ function nodeMatchesAny(node, selectors) {
986
+ if (!(node instanceof Element)) return false;
987
+ for (const sel of selectors) {
988
+ try { if (node.matches(sel)) return true; } catch (_) {}
989
+ }
990
+ return false;
991
+ }
992
+
993
+ function nodeContainsAny(node, selectors) {
994
+ if (!(node instanceof Element)) return false;
995
+ for (const sel of selectors) {
996
+ try { if (node.querySelector(sel)) return true; } catch (_) {}
997
+ }
998
+ return false;
999
+ }
1000
+
927
1001
  // ── MutationObserver ───────────────────────────────────────────────────────
928
1002
 
929
1003
  function ensureDomObserver() {
930
1004
  if (S.domObs) return;
931
- const allSel = [SEL.post, SEL.topic, SEL.category];
932
1005
  S.domObs = new MutationObserver(muts => {
933
1006
  if (S.mutGuard > 0 || isBlocked()) return;
934
1007
  for (const m of muts) {
935
1008
  let sawWrapRemoval = false;
936
1009
  for (const n of m.removedNodes) {
937
1010
  if (n.nodeType !== 1) continue;
938
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
1011
+ if (nodeMatchesAny(n, [WRAP_SEL]) || nodeContainsAny(n, [WRAP_SEL])) {
939
1012
  sawWrapRemoval = true;
1013
+ try {
1014
+ if (n instanceof Element && n.classList?.contains(WRAP_CLASS)) markWrapActivity(n);
1015
+ else if (n instanceof Element) { const inner = n.querySelector?.(WRAP_SEL); if (inner) markWrapActivity(inner); }
1016
+ } catch (_) {}
940
1017
  }
941
1018
  }
942
1019
  if (sawWrapRemoval) queueSweepDeadWraps();
943
1020
  for (const n of m.addedNodes) {
944
1021
  if (n.nodeType !== 1) continue;
945
1022
  try { healFalseEmpty(n); } catch (_) {}
1023
+ try {
1024
+ const w = (n instanceof Element && (n.classList?.contains(WRAP_CLASS) ? n : n.closest?.(WRAP_SEL))) || null;
1025
+ if (w) markWrapActivity(w);
1026
+ else if (n instanceof Element) { const inner = n.querySelector?.(WRAP_SEL); if (inner) markWrapActivity(inner); }
1027
+ } catch (_) {}
946
1028
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
947
- if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
948
- allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
1029
+ if (nodeMatchesAny(n, CONTENT_SEL_LIST) || nodeContainsAny(n, CONTENT_SEL_LIST)) {
949
1030
  requestBurst(); return;
950
1031
  }
951
1032
  }
@@ -956,27 +1037,6 @@ function startShowBatch(ids) {
956
1037
 
957
1038
  // ── Utilitaires ────────────────────────────────────────────────────────────
958
1039
 
959
- function muteConsole() {
960
- if (window.__nbbEzMuted) return;
961
- window.__nbbEzMuted = true;
962
- const MUTED = [
963
- '[EzoicAds JS]: Placeholder Id',
964
- 'No valid placeholders for loadMore',
965
- 'cannot call refresh on the same page',
966
- 'no placeholders are currently defined in Refresh',
967
- 'Debugger iframe already exists',
968
- `with id ${PH_PREFIX}`,
969
- ];
970
- for (const m of ['log', 'info', 'warn', 'error']) {
971
- const orig = console[m];
972
- if (typeof orig !== 'function') continue;
973
- console[m] = function (...a) {
974
- if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
975
- orig.apply(console, a);
976
- };
977
- }
978
- }
979
-
980
1040
  function ensureTcfLocator() {
981
1041
  try {
982
1042
  if (!window.__tcfapi && !window.__cmp) return;
@@ -998,6 +1058,7 @@ function startShowBatch(ids) {
998
1058
  function warmNetwork() {
999
1059
  const head = document.head;
1000
1060
  if (!head) return;
1061
+ const frag = document.createDocumentFragment();
1001
1062
  for (const [rel, href, cors] of [
1002
1063
  ['preconnect', 'https://g.ezoic.net', true ],
1003
1064
  ['preconnect', 'https://go.ezoic.net', true ],
@@ -1012,8 +1073,9 @@ function startShowBatch(ids) {
1012
1073
  const l = document.createElement('link');
1013
1074
  l.rel = rel; l.href = href;
1014
1075
  if (cors) l.crossOrigin = 'anonymous';
1015
- head.appendChild(l);
1076
+ frag.appendChild(l);
1016
1077
  }
1078
+ head.appendChild(frag);
1017
1079
  }
1018
1080
 
1019
1081
  // ── Bindings ───────────────────────────────────────────────────────────────
@@ -1027,7 +1089,7 @@ function startShowBatch(ids) {
1027
1089
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1028
1090
  S.pageKey = pageKey();
1029
1091
  blockedUntil = 0;
1030
- muteConsole(); ensureTcfLocator(); warmNetwork();
1092
+ ensureTcfLocator(); warmNetwork();
1031
1093
  patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
1032
1094
  });
1033
1095
 
@@ -1075,7 +1137,6 @@ function startShowBatch(ids) {
1075
1137
  // ── Boot ───────────────────────────────────────────────────────────────────
1076
1138
 
1077
1139
  S.pageKey = pageKey();
1078
- muteConsole();
1079
1140
  ensureTcfLocator();
1080
1141
  warmNetwork();
1081
1142
  patchShowAds();