nodebb-plugin-ezoic-infinite 1.8.18 → 1.8.20

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,198 +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>`;
11
17
 
12
- function normalizeExcludedGroups(value) {
13
- 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 (_) {}
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;
19
56
  }
20
- // Fallback : séparation par virgule
21
- return s.split(',').map(v => v.trim()).filter(Boolean);
22
57
  }
23
58
 
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';
59
+ function toPositiveInt(value, fallback) {
60
+ const parsed = Number.parseInt(value, 10);
61
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
29
62
  }
30
63
 
31
- async function getAllGroups() {
32
- let names = await db.getSortedSetRange('groups:createtime', 0, -1);
33
- if (!names || !names.length) {
34
- names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
35
- }
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;
64
+ function toStringTrim(value) {
65
+ return typeof value === 'string' ? value.trim() : '';
41
66
  }
42
67
 
43
- // ── Settings cache (30s TTL) ────────────────────────────────────────────────
68
+ function normalizeExcludedGroups(value) {
69
+ if (!value) return [];
70
+ if (Array.isArray(value)) {
71
+ return value.map(String).map(v => v.trim()).filter(Boolean);
72
+ }
44
73
 
45
- let _settingsCache = null;
46
- let _settingsCacheAt = 0;
47
- const SETTINGS_TTL = 30_000;
74
+ const raw = String(value).trim();
75
+ if (!raw) return [];
48
76
 
49
- async function getSettings() {
50
- 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
- }
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
+ }
71
85
 
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));
86
+ return raw.split(',').map(v => v.trim()).filter(Boolean);
76
87
  }
77
88
 
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
+ };
105
+ }
78
106
 
107
+ async function listNonPrivilegeGroups() {
108
+ let names = await db.getSortedSetRange('groups:createtime', 0, -1);
109
+ if (!Array.isArray(names) || !names.length) {
110
+ names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
111
+ }
79
112
 
80
- // ── Groups cache (5 min TTL) ───────────────────────────────────────────────
81
-
82
- let _groupsCache = null;
83
- let _groupsCacheAt = 0;
84
- const GROUPS_TTL = 5 * 60_000;
113
+ const publicNames = (names || []).filter(name => !groups.isPrivilegeGroup(name));
114
+ const groupData = await groups.getGroupsData(publicNames);
85
115
 
86
- async function getAllGroupsCached() {
87
- const now = Date.now();
88
- if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
89
- const g = await getAllGroups();
90
- _groupsCache = g;
91
- _groupsCacheAt = Date.now();
92
- return _groupsCache;
116
+ return (groupData || [])
117
+ .filter(group => group && group.name)
118
+ .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
93
119
  }
94
120
 
95
- // ── Exclusion cache (per uid, 30s TTL) ─────────────────────────────────────
121
+ async function getSettings() {
122
+ const now = Date.now();
123
+ if (plugin._settingsCache && (now - plugin._settingsCacheAt) < SETTINGS_TTL_MS) {
124
+ return plugin._settingsCache;
125
+ }
96
126
 
97
- const _excludedCache = new Map(); // uid -> { v:boolean, t:number, sig:string }
98
- const EXCLUDED_TTL = 30_000;
99
- const EXCLUDED_MAX = 10_000;
127
+ const raw = await meta.settings.get(SETTINGS_KEY);
128
+ const settings = buildSettings(raw);
100
129
 
101
- function excludedSig(excludedGroups) {
102
- // signature stable to invalidate when groups list changes
103
- return excludedGroups.join('\u0001');
130
+ plugin._settingsCache = settings;
131
+ plugin._settingsCacheAt = now;
132
+ return settings;
104
133
  }
105
134
 
106
- async function isUserExcludedCached(uid, excludedGroups) {
107
- if (!uid || !excludedGroups.length) return false;
108
- const now = Date.now();
109
- const sig = excludedSig(excludedGroups);
110
- const hit = _excludedCache.get(uid);
111
- if (hit && hit.sig === sig && (now - hit.t) < EXCLUDED_TTL) return hit.v;
112
- const v = await isUserExcluded(uid, excludedGroups);
113
- if (_excludedCache.size > EXCLUDED_MAX) _excludedCache.clear();
114
- _excludedCache.set(uid, { v, t: now, sig });
115
- return v;
116
- }
117
- // ── Scripts Ezoic ──────────────────────────────────────────────────────────
135
+ async function isUserExcluded(uid, excludedGroups) {
136
+ if (!uid || !Array.isArray(excludedGroups) || !excludedGroups.length) return false;
118
137
 
119
- const EZOIC_SCRIPTS = `<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>
120
- <script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>
121
- <script async src="//www.ezojs.com/ezoic/sa.min.js"></script>
122
- <script>
123
- window.ezstandalone = window.ezstandalone || {};
124
- ezstandalone.cmd = ezstandalone.cmd || [];
125
- </script>`;
138
+ const userGroups = await groups.getUserGroups([uid]);
139
+ const names = (userGroups && userGroups[0]) || [];
140
+ const excludedSet = new Set(excludedGroups);
126
141
 
127
- // ── Hooks ──────────────────────────────────────────────────────────────────
142
+ return names.some(group => group && excludedSet.has(group.name));
143
+ }
128
144
 
129
- plugin.onSettingsSet = function (data) {
130
- if (data && data.hash === SETTINGS_KEY) { _settingsCache = null; _groupsCache = null; _excludedCache.clear(); }
145
+ plugin.onSettingsSet = function onSettingsSet(data) {
146
+ if (data && data.hash === SETTINGS_KEY) {
147
+ plugin._settingsCache = null;
148
+ plugin._settingsCacheAt = 0;
149
+ }
131
150
  };
132
151
 
133
- plugin.addAdminNavigation = async (header) => {
152
+ plugin.addAdminNavigation = async function addAdminNavigation(header) {
134
153
  header.plugins = header.plugins || [];
135
- 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
+ });
136
159
  return header;
137
160
  };
138
161
 
139
- /**
140
- * Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
141
- *
142
- * NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
143
- * (render.js ligne 232 : templateValues.customHTML = meta.config.customHTML).
144
- * Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
145
- * et est rendu via req.app.renderAsync('header', hookReturn.templateData).
146
- * On préfixe customHTML pour que nos scripts passent AVANT le customHTML admin,
147
- * tout en préservant ce dernier.
148
- */
149
- plugin.injectEzoicHead = async (data) => {
162
+ plugin.injectEzoicHead = async function injectEzoicHead(data) {
150
163
  try {
151
164
  const settings = await getSettings();
152
- const uid = data.req?.uid ?? 0;
153
- const excluded = await isUserExcludedCached(uid, settings.excludedGroups);
154
- if (!excluded) {
155
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
156
- 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;
157
168
  }
158
- } catch (_) {}
169
+
170
+ const templateData = data.templateData || (data.templateData = {});
171
+ templateData.customHTML = `${EZOIC_SCRIPTS}${templateData.customHTML || ''}`;
172
+ } catch {}
173
+
159
174
  return data;
160
175
  };
161
176
 
162
- plugin.init = async ({ router, middleware }) => {
163
- async function render(req, res) {
164
- const settings = await getSettings();
165
- const allGroups = await getAllGroupsCached();
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
+
166
184
  res.render('admin/plugins/ezoic-infinite', {
167
185
  title: 'Ezoic Infinite Ads',
168
186
  ...settings,
169
187
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
170
- enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
188
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
171
189
  allGroups,
172
190
  });
173
191
  }
174
192
 
175
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
176
- 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);
177
195
 
178
196
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
179
197
  const settings = await getSettings();
180
- const excluded = await isUserExcludedCached(req.uid, settings.excludedGroups);
181
- res.json({
182
- excluded,
183
- enableBetweenAds: settings.enableBetweenAds,
184
- showFirstTopicAd: settings.showFirstTopicAd,
185
- placeholderIds: settings.placeholderIds,
186
- intervalPosts: settings.intervalPosts,
187
- enableCategoryAds: settings.enableCategoryAds,
188
- showFirstCategoryAd: settings.showFirstCategoryAd,
189
- categoryPlaceholderIds: settings.categoryPlaceholderIds,
190
- intervalCategories: settings.intervalCategories,
191
- enableMessageAds: settings.enableMessageAds,
192
- showFirstMessageAd: settings.showFirstMessageAd,
193
- messagePlaceholderIds: settings.messagePlaceholderIds,
194
- messageIntervalPosts: settings.messageIntervalPosts,
195
- });
198
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
199
+
200
+ const payload = { excluded };
201
+ for (const key of CONFIG_FIELDS) payload[key] = settings[key];
202
+ res.json(payload);
196
203
  });
197
204
  };
198
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.18",
3
+ "version": "1.8.20",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -18,4 +18,4 @@
18
18
  "compatibility": "^4.0.0"
19
19
  },
20
20
  "private": false
21
- }
21
+ }
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
 
@@ -77,12 +10,18 @@
77
10
  const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
11
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
12
 
80
- const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
13
+ // Tunables (stables en prod)
81
14
  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
15
+ const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
16
+ const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
17
+ const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
18
+ const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
19
+ const SHOW_RELEASE_MS = 300; // relâche inflight après showAds() batché
20
+ const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
21
+ const BATCH_FLUSH_MS = 30; // micro-buffer pour regrouper les ids proches
22
+ const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
23
+ const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
24
+ const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
86
25
 
87
26
  // Marges IO larges et fixes — observer créé une seule fois au boot
88
27
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
@@ -93,6 +32,9 @@
93
32
  topic: 'li[component="category/topic"]',
94
33
  category: 'li[component="categories/category"]',
95
34
  };
35
+ const WRAP_SEL = `.${WRAP_CLASS}`;
36
+ const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id]';
37
+ const CONTENT_SEL_LIST = [SEL.post, SEL.topic, SEL.category];
96
38
 
97
39
  /**
98
40
  * Table KIND — source de vérité par kindClass.
@@ -126,7 +68,18 @@
126
68
  inflight: 0, // showAds() en cours
127
69
  pending: [], // ids en attente de slot inflight
128
70
  pendingSet: new Set(),
71
+ showBatchTimer: 0,
72
+ destroyBatchTimer: 0,
73
+ destroyPending: [],
74
+ destroyPendingSet: new Set(),
75
+ sweepQueued: false,
129
76
  wrapByKey: new Map(), // anchorKey → wrap DOM node
77
+ ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
78
+ ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
79
+ scrollDir: 1, // 1=bas, -1=haut
80
+ scrollSpeed: 0, // px/s approx (EMA)
81
+ lastScrollY: 0,
82
+ lastScrollTs: 0,
130
83
  runQueued: false,
131
84
  burstActive: false,
132
85
  burstDeadline: 0,
@@ -140,12 +93,121 @@
140
93
  const isBlocked = () => ts() < blockedUntil;
141
94
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
142
95
  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]'));
96
+ const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
97
+
98
+ function healFalseEmpty(root = document) {
99
+ try {
100
+ const list = [];
101
+ if (root instanceof Element && root.classList?.contains(WRAP_CLASS)) list.push(root);
102
+ const found = root.querySelectorAll ? root.querySelectorAll(`.${WRAP_CLASS}.is-empty`) : [];
103
+ for (const w of found) list.push(w);
104
+ for (const w of list) {
105
+ if (!w?.classList?.contains('is-empty')) continue;
106
+ if (isFilled(w)) w.classList.remove('is-empty');
107
+ }
108
+ } catch (_) {}
109
+ }
110
+
111
+ function phEl(id) {
112
+ return document.getElementById(`${PH_PREFIX}${id}`);
113
+ }
114
+
115
+ function hasSinglePlaceholder(id) {
116
+ try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
117
+ }
118
+
119
+ function canShowPlaceholderId(id, now = ts()) {
120
+ const n = parseInt(id, 10);
121
+ if (!Number.isFinite(n) || n <= 0) return false;
122
+ if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
123
+ const ph = phEl(n);
124
+ if (!ph?.isConnected || isFilled(ph)) return false;
125
+ if (!hasSinglePlaceholder(n)) return false;
126
+ return true;
127
+ }
128
+
129
+ function queueSweepDeadWraps() {
130
+ if (S.sweepQueued) return;
131
+ S.sweepQueued = true;
132
+ requestAnimationFrame(() => {
133
+ S.sweepQueued = false;
134
+ sweepDeadWraps();
135
+ healFalseEmpty();
136
+ });
137
+ }
138
+
139
+ function getDynamicShowBatchMax() {
140
+ const speed = S.scrollSpeed || 0;
141
+ const pend = S.pending.length;
142
+ // Scroll très rapide => petits batches (réduit le churn/unused)
143
+ if (speed > 2600) return 2;
144
+ if (speed > 1400) return 3;
145
+ // Peu de candidats => flush plus vite, inutile d'attendre 4
146
+ if (pend <= 1) return 1;
147
+ if (pend <= 3) return 2;
148
+ // Par défaut compromis dynamique
149
+ return 3;
150
+ }
144
151
 
145
152
  function mutate(fn) {
146
153
  S.mutGuard++;
147
154
  try { fn(); } finally { S.mutGuard--; }
148
155
  }
156
+ function scheduleDestroyFlush() {
157
+ if (S.destroyBatchTimer) return;
158
+ S.destroyBatchTimer = setTimeout(() => {
159
+ S.destroyBatchTimer = 0;
160
+ flushDestroyBatch();
161
+ }, DESTROY_FLUSH_MS);
162
+ }
163
+
164
+ function flushDestroyBatch() {
165
+ if (!S.destroyPending.length) return;
166
+ const ids = [];
167
+ while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
168
+ const id = S.destroyPending.shift();
169
+ S.destroyPendingSet.delete(id);
170
+ if (!Number.isFinite(id) || id <= 0) continue;
171
+ ids.push(id);
172
+ }
173
+ if (ids.length) {
174
+ try {
175
+ const ez = window.ezstandalone;
176
+ const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
177
+ try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
178
+ } catch (_) {}
179
+ }
180
+ if (S.destroyPending.length) scheduleDestroyFlush();
181
+ }
182
+
183
+ function destroyEzoicId(id) {
184
+ if (!Number.isFinite(id) || id <= 0) return;
185
+ if (!S.ezActiveIds.has(id)) return;
186
+ S.ezActiveIds.delete(id);
187
+ if (!S.destroyPendingSet.has(id)) {
188
+ S.destroyPending.push(id);
189
+ S.destroyPendingSet.add(id);
190
+ }
191
+ scheduleDestroyFlush();
192
+ }
193
+
194
+ function destroyBeforeReuse(ids) {
195
+ const out = [];
196
+ const toDestroy = [];
197
+ const seen = new Set();
198
+ for (const raw of (ids || [])) {
199
+ const id = parseInt(raw, 10);
200
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
201
+ seen.add(id);
202
+ out.push(id);
203
+ if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
204
+ }
205
+ if (toDestroy.length) {
206
+ try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
207
+ for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
208
+ }
209
+ return out;
210
+ }
149
211
 
150
212
  // ── Config ─────────────────────────────────────────────────────────────────
151
213
 
@@ -160,7 +222,7 @@
160
222
 
161
223
  function parseIds(raw) {
162
224
  const out = [], seen = new Set();
163
- for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
225
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
164
226
  const n = parseInt(v, 10);
165
227
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
166
228
  }
@@ -300,6 +362,27 @@
300
362
  return null;
301
363
  }
302
364
 
365
+ function sweepDeadWraps() {
366
+ // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
367
+ // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
368
+ let changed = false;
369
+ for (const [key, wrap] of S.wrapByKey) {
370
+ if (wrap?.isConnected) continue;
371
+ changed = true;
372
+ S.wrapByKey.delete(key);
373
+ const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
374
+ if (Number.isFinite(id)) {
375
+ S.mountedIds.delete(id);
376
+ S.pendingSet.delete(id);
377
+ S.lastShow.delete(id);
378
+ S.ezActiveIds.delete(id);
379
+ }
380
+ }
381
+ if (changed && S.pending.length) {
382
+ S.pending = S.pending.filter(id => S.pendingSet.has(id));
383
+ }
384
+ }
385
+
303
386
  /**
304
387
  * Pool épuisé : recycle un wrap loin au-dessus du viewport.
305
388
  * Séquence avec délais (destroyPlaceholders est asynchrone) :
@@ -307,60 +390,83 @@
307
390
  * displayMore = API Ezoic prévue pour l'infinite scroll.
308
391
  * Priorité : wraps vides d'abord, remplis si nécessaire.
309
392
  */
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; }
393
+ function recycleAndMove(klass, targetEl, newKey) {
394
+ const ez = window.ezstandalone;
395
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
396
+ typeof ez?.define !== 'function' ||
397
+ typeof ez?.displayMore !== 'function') return null;
398
+
399
+ const vh = window.innerHeight || 800;
400
+ const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
401
+ const farAbove = -vh;
402
+ const farBelow = vh * 2;
403
+
404
+ let bestPrefEmpty = null, bestPrefMetric = Infinity;
405
+ let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
406
+ let bestAnyEmpty = null, bestAnyMetric = Infinity;
407
+ let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
408
+
409
+ for (const wrap of S.wrapByKey.values()) {
410
+ if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
411
+ try {
412
+ const rect = wrap.getBoundingClientRect();
413
+ const isAbove = rect.bottom <= farAbove;
414
+ const isBelow = rect.top >= farBelow;
415
+ const anyFar = isAbove || isBelow;
416
+ if (!anyFar) continue;
417
+
418
+ const qualifies = preferAbove ? isAbove : isBelow;
419
+ const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
420
+ const filled = isFilled(wrap);
421
+
422
+ if (qualifies) {
423
+ if (!filled) {
424
+ if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
329
425
  } else {
330
- if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
426
+ if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
331
427
  }
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);
428
+ }
429
+ if (!filled) {
430
+ if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
431
+ } else {
432
+ if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
433
+ }
434
+ } catch (_) {}
435
+ }
355
436
 
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 (_) {}
437
+ const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
438
+ if (!best) return null;
439
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
440
+ if (!Number.isFinite(id)) return null;
441
+
442
+ const oldKey = best.getAttribute(A_ANCHOR);
443
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
444
+ mutate(() => {
445
+ best.setAttribute(A_ANCHOR, newKey);
446
+ best.setAttribute(A_CREATED, String(ts()));
447
+ best.setAttribute(A_SHOWN, '0');
448
+ best.classList.remove('is-empty');
449
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
450
+ if (ph) ph.innerHTML = '';
451
+ targetEl.insertAdjacentElement('afterend', best);
452
+ });
453
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
454
+ S.wrapByKey.set(newKey, best);
455
+
456
+ const doDestroy = () => {
457
+ if (S.ezShownSinceDestroy.has(id)) {
458
+ try { ez.destroyPlaceholders([id]); } catch (_) {}
459
+ S.ezShownSinceDestroy.delete(id);
460
+ }
461
+ S.ezActiveIds.delete(id);
462
+ setTimeout(doDefine, 330);
463
+ };
464
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
465
+ const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
466
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
361
467
 
362
- return { id, wrap: best };
363
- }
468
+ return { id, wrap: best };
469
+ }
364
470
 
365
471
  // ── Wraps DOM — création / suppression ────────────────────────────────────
366
472
 
@@ -396,7 +502,7 @@
396
502
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
397
503
  if (ph instanceof Element) S.io?.unobserve(ph);
398
504
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
399
- if (Number.isFinite(id)) S.mountedIds.delete(id);
505
+ if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
400
506
  const key = w.getAttribute(A_ANCHOR);
401
507
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
402
508
  w.remove();
@@ -422,14 +528,7 @@
422
528
  const klass = 'ezoic-ad-between';
423
529
  const cfg = KIND[klass];
424
530
 
425
- // Build a fast lookup of existing anchors once (avoid querySelector per wrap)
426
- const anchors = new Set();
427
- document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`).forEach(el => {
428
- const v = el.getAttribute(cfg.anchorAttr);
429
- if (v) anchors.add(String(v));
430
- });
431
-
432
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
531
+ document.querySelectorAll(`${WRAP_SEL}.${klass}`).forEach(w => {
433
532
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
434
533
  if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
435
534
 
@@ -437,7 +536,8 @@
437
536
  const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
438
537
  if (!sid) { mutate(() => dropWrap(w)); return; }
439
538
 
440
- if (!anchors.has(String(sid))) mutate(() => dropWrap(w));
539
+ const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
540
+ if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
441
541
  });
442
542
  }
443
543
 
@@ -477,7 +577,8 @@
477
577
  const key = anchorKey(klass, el);
478
578
  if (findWrap(key)) continue;
479
579
 
480
- const id = pickId(poolKey);
580
+ let id = pickId(poolKey);
581
+ if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
481
582
  if (id) {
482
583
  const w = insertAfter(el, id, klass, key);
483
584
  if (w) { observePh(id); inserted++; }
@@ -508,76 +609,106 @@
508
609
  }
509
610
 
510
611
  function observePh(id) {
511
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
612
+ const ph = phEl(id);
512
613
  if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
614
+ // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
615
+ try {
616
+ if (!ph?.isConnected) return;
617
+ const rect = ph.getBoundingClientRect();
618
+ const vh = window.innerHeight || 800;
619
+ const preload = isMobile() ? 1400 : 1000;
620
+ if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
621
+ } catch (_) {}
513
622
  }
514
623
 
515
- function enqueueShow(id) {
516
- if (!id || isBlocked()) return;
517
- if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
518
- if (S.inflight >= MAX_INFLIGHT) {
519
- if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); }
520
- return;
521
- }
522
- startShow(id);
523
- }
624
+ function enqueueShow(id) {
625
+ if (!id || isBlocked()) return;
626
+ const n = parseInt(id, 10);
627
+ if (!Number.isFinite(n) || n <= 0) return;
628
+ if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
629
+ if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
630
+ scheduleDrainQueue();
631
+ }
632
+
633
+ function scheduleDrainQueue() {
634
+ if (isBlocked()) return;
635
+ if (S.showBatchTimer) return;
636
+ S.showBatchTimer = setTimeout(() => {
637
+ S.showBatchTimer = 0;
638
+ drainQueue();
639
+ }, BATCH_FLUSH_MS);
640
+ }
641
+
642
+ function drainQueue() {
643
+ if (isBlocked()) return;
644
+ const free = Math.max(0, MAX_INFLIGHT - S.inflight);
645
+ if (!free || !S.pending.length) return;
646
+
647
+ const picked = [];
648
+ const seen = new Set();
649
+ const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
650
+ while (S.pending.length && picked.length < batchCap) {
651
+ const id = S.pending.shift();
652
+ S.pendingSet.delete(id);
653
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
654
+ if (!phEl(id)?.isConnected) continue;
655
+ seen.add(id);
656
+ picked.push(id);
657
+ }
658
+ if (picked.length) startShowBatch(picked);
659
+ if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
660
+ }
661
+
662
+ function startShowBatch(ids) {
663
+ if (!ids?.length || isBlocked()) return;
664
+ const reserve = ids.length;
665
+ S.inflight += reserve;
666
+
667
+ let done = false;
668
+ const release = () => {
669
+ if (done) return;
670
+ done = true;
671
+ S.inflight = Math.max(0, S.inflight - reserve);
672
+ drainQueue();
673
+ };
674
+ const timer = setTimeout(release, SHOW_FAILSAFE_MS);
524
675
 
525
- function drainQueue() {
526
- if (isBlocked()) return;
527
- while (S.inflight < MAX_INFLIGHT && S.pending.length) {
528
- const id = S.pending.shift();
529
- S.pendingSet.delete(id);
530
- startShow(id);
531
- }
532
- }
676
+ requestAnimationFrame(() => {
677
+ try {
678
+ if (isBlocked()) { clearTimeout(timer); return release(); }
533
679
 
534
- function startShow(id) {
535
- if (!id || isBlocked()) return;
536
- S.inflight++;
537
- let done = false;
538
- const release = () => {
539
- if (done) return;
540
- done = true;
541
- S.inflight = Math.max(0, S.inflight - 1);
542
- drainQueue();
543
- };
544
- const timer = setTimeout(release, 7000);
680
+ const valid = [];
681
+ const t = ts();
545
682
 
546
- requestAnimationFrame(() => {
547
- try {
548
- if (isBlocked()) { clearTimeout(timer); return release(); }
549
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
550
- if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
683
+ for (const raw of ids) {
684
+ const id = parseInt(raw, 10);
685
+ if (!Number.isFinite(id) || id <= 0) continue;
686
+ const ph = phEl(id);
687
+ if (!canShowPlaceholderId(id, t)) continue;
551
688
 
552
- const t = ts();
553
- if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
554
689
  S.lastShow.set(id, t);
690
+ try { ph.closest?.(WRAP_SEL)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
691
+ valid.push(id);
692
+ }
555
693
 
556
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
694
+ if (!valid.length) { clearTimeout(timer); return release(); }
557
695
 
558
- window.ezstandalone = window.ezstandalone || {};
559
- const ez = window.ezstandalone;
560
- const doShow = () => {
561
- try { ez.showAds(id); } catch (_) {}
562
- scheduleEmptyCheck(id, t);
563
- setTimeout(() => { clearTimeout(timer); release(); }, 700);
564
- };
565
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
566
- } catch (_) { clearTimeout(timer); release(); }
567
- });
568
- }
569
-
570
- function scheduleEmptyCheck(id, showTs) {
571
- setTimeout(() => {
572
- try {
573
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
574
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
575
- if (!wrap || !ph?.isConnected) return;
576
- if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
577
- wrap.classList.toggle('is-empty', !isFilled(ph));
578
- } catch (_) {}
579
- }, EMPTY_CHECK_MS);
580
- }
696
+ window.ezstandalone = window.ezstandalone || {};
697
+ const ez = window.ezstandalone;
698
+ const doShow = () => {
699
+ const prepared = destroyBeforeReuse(valid);
700
+ if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
701
+ try { ez.showAds(...prepared); } catch (_) {}
702
+ for (const id of prepared) {
703
+ S.ezActiveIds.add(id);
704
+ S.ezShownSinceDestroy.add(id);
705
+ }
706
+ setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
707
+ };
708
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
709
+ } catch (_) { clearTimeout(timer); release(); }
710
+ });
711
+ }
581
712
 
582
713
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
583
714
  //
@@ -595,14 +726,21 @@
595
726
  const orig = ez.showAds.bind(ez);
596
727
  ez.showAds = function (...args) {
597
728
  if (isBlocked()) return;
598
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
729
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
730
+ const valid = [];
599
731
  const seen = new Set();
600
732
  for (const v of ids) {
601
733
  const id = parseInt(v, 10);
602
734
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
603
- if (!document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) continue;
735
+ if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
604
736
  seen.add(id);
605
- try { orig(id); } catch (_) {}
737
+ valid.push(id);
738
+ }
739
+ if (!valid.length) return;
740
+ try { orig(...valid); } catch (_) {
741
+ for (const id of valid) {
742
+ try { orig(id); } catch (_) {}
743
+ }
606
744
  }
607
745
  };
608
746
  } catch (_) {}
@@ -619,6 +757,7 @@
619
757
  async function runCore() {
620
758
  if (isBlocked()) return 0;
621
759
  patchShowAds();
760
+ sweepDeadWraps();
622
761
 
623
762
  const cfg = await fetchConfig();
624
763
  if (!cfg || cfg.excluded) return 0;
@@ -685,7 +824,7 @@
685
824
  S.burstCount++;
686
825
  scheduleRun(n => {
687
826
  if (!n && !S.pending.length) { S.burstActive = false; return; }
688
- setTimeout(step, n > 0 ? 150 : 300);
827
+ setTimeout(step, n > 0 ? 80 : 180);
689
828
  });
690
829
  };
691
830
  step();
@@ -695,7 +834,7 @@
695
834
 
696
835
  function cleanup() {
697
836
  blockedUntil = ts() + 1500;
698
- mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
837
+ mutate(() => document.querySelectorAll(WRAP_SEL).forEach(dropWrap));
699
838
  S.cfg = null;
700
839
  S.poolsReady = false;
701
840
  S.pools = { topics: [], posts: [], categories: [] };
@@ -703,26 +842,59 @@
703
842
  S.mountedIds.clear();
704
843
  S.lastShow.clear();
705
844
  S.wrapByKey.clear();
845
+ S.ezActiveIds.clear();
846
+ S.ezShownSinceDestroy.clear();
706
847
  S.inflight = 0;
707
848
  S.pending = [];
708
849
  S.pendingSet.clear();
850
+ if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
851
+ if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
852
+ S.destroyPending = [];
853
+ S.destroyPendingSet.clear();
709
854
  S.burstActive = false;
710
855
  S.runQueued = false;
856
+ S.sweepQueued = false;
857
+ S.scrollSpeed = 0;
858
+ S.lastScrollY = 0;
859
+ S.lastScrollTs = 0;
860
+ }
861
+
862
+ function nodeMatchesAny(node, selectors) {
863
+ if (!(node instanceof Element)) return false;
864
+ for (const sel of selectors) {
865
+ try { if (node.matches(sel)) return true; } catch (_) {}
866
+ }
867
+ return false;
868
+ }
869
+
870
+ function nodeContainsAny(node, selectors) {
871
+ if (!(node instanceof Element)) return false;
872
+ for (const sel of selectors) {
873
+ try { if (node.querySelector(sel)) return true; } catch (_) {}
874
+ }
875
+ return false;
711
876
  }
712
877
 
713
878
  // ── MutationObserver ───────────────────────────────────────────────────────
714
879
 
715
880
  function ensureDomObserver() {
716
881
  if (S.domObs) return;
717
- const allSel = [SEL.post, SEL.topic, SEL.category];
718
882
  S.domObs = new MutationObserver(muts => {
719
883
  if (S.mutGuard > 0 || isBlocked()) return;
720
884
  for (const m of muts) {
885
+ let sawWrapRemoval = false;
886
+ for (const n of m.removedNodes) {
887
+ if (n.nodeType !== 1) continue;
888
+ if (nodeMatchesAny(n, [WRAP_SEL]) || nodeContainsAny(n, [WRAP_SEL])) {
889
+ sawWrapRemoval = true;
890
+ }
891
+ }
892
+ if (sawWrapRemoval) queueSweepDeadWraps();
721
893
  for (const n of m.addedNodes) {
722
894
  if (n.nodeType !== 1) continue;
895
+ try { healFalseEmpty(n); } catch (_) {}
723
896
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
724
- if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
725
- allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
897
+ if (nodeMatchesAny(n, CONTENT_SEL_LIST) || nodeContainsAny(n, CONTENT_SEL_LIST)) {
726
898
  requestBurst(); return;
727
899
  }
728
900
  }
@@ -733,27 +905,6 @@
733
905
 
734
906
  // ── Utilitaires ────────────────────────────────────────────────────────────
735
907
 
736
- function muteConsole() {
737
- if (window.__nbbEzMuted) return;
738
- window.__nbbEzMuted = true;
739
- const MUTED = [
740
- '[EzoicAds JS]: Placeholder Id',
741
- 'No valid placeholders for loadMore',
742
- 'cannot call refresh on the same page',
743
- 'no placeholders are currently defined in Refresh',
744
- 'Debugger iframe already exists',
745
- `with id ${PH_PREFIX}`,
746
- ];
747
- for (const m of ['log', 'info', 'warn', 'error']) {
748
- const orig = console[m];
749
- if (typeof orig !== 'function') continue;
750
- console[m] = function (...a) {
751
- if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
752
- orig.apply(console, a);
753
- };
754
- }
755
- }
756
-
757
908
  function ensureTcfLocator() {
758
909
  try {
759
910
  if (!window.__tcfapi && !window.__cmp) return;
@@ -775,6 +926,7 @@
775
926
  function warmNetwork() {
776
927
  const head = document.head;
777
928
  if (!head) return;
929
+ const frag = document.createDocumentFragment();
778
930
  for (const [rel, href, cors] of [
779
931
  ['preconnect', 'https://g.ezoic.net', true ],
780
932
  ['preconnect', 'https://go.ezoic.net', true ],
@@ -789,8 +941,9 @@
789
941
  const l = document.createElement('link');
790
942
  l.rel = rel; l.href = href;
791
943
  if (cors) l.crossOrigin = 'anonymous';
792
- head.appendChild(l);
944
+ frag.appendChild(l);
793
945
  }
946
+ head.appendChild(frag);
794
947
  }
795
948
 
796
949
  // ── Bindings ───────────────────────────────────────────────────────────────
@@ -804,8 +957,8 @@
804
957
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
805
958
  S.pageKey = pageKey();
806
959
  blockedUntil = 0;
807
- muteConsole(); ensureTcfLocator(); warmNetwork();
808
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
960
+ ensureTcfLocator(); warmNetwork();
961
+ patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
809
962
  });
810
963
 
811
964
  const burstEvts = [
@@ -827,7 +980,22 @@
827
980
 
828
981
  function bindScroll() {
829
982
  let ticking = false;
983
+ try {
984
+ S.lastScrollY = window.scrollY || window.pageYOffset || 0;
985
+ S.lastScrollTs = ts();
986
+ } catch (_) {}
830
987
  window.addEventListener('scroll', () => {
988
+ try {
989
+ const y = window.scrollY || window.pageYOffset || 0;
990
+ const t = ts();
991
+ const dy = y - (S.lastScrollY || 0);
992
+ const dt = Math.max(1, t - (S.lastScrollTs || t));
993
+ if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
994
+ const inst = Math.abs(dy) * 1000 / dt;
995
+ S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
996
+ S.lastScrollY = y;
997
+ S.lastScrollTs = t;
998
+ } catch (_) {}
831
999
  if (ticking) return;
832
1000
  ticking = true;
833
1001
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
@@ -837,7 +1005,6 @@
837
1005
  // ── Boot ───────────────────────────────────────────────────────────────────
838
1006
 
839
1007
  S.pageKey = pageKey();
840
- muteConsole();
841
1008
  ensureTcfLocator();
842
1009
  warmNetwork();
843
1010
  patchShowAds();
package/public/style.css CHANGED
@@ -56,23 +56,17 @@
56
56
  top: auto !important;
57
57
  }
58
58
 
59
- /* ── État vide ────────────────────────────────────────────────────────────── */
60
- /*
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.
63
- */
64
- .nodebb-ezoic-wrap.is-empty {
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;
72
- }
73
-
74
59
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
75
60
  .ezoic-ad {
76
61
  margin: 0 !important;
77
62
  padding: 0 !important;
78
63
  }
64
+
65
+
66
+ /* Filet anti faux-empty : si la pub est rendue, ne pas laisser le wrap replié */
67
+ .nodebb-ezoic-wrap.is-empty:has(iframe, [data-google-container-id], [id^="google_ads_iframe_"]) {
68
+ height: auto !important;
69
+ min-height: 1px !important;
70
+ max-height: none !important;
71
+ overflow: visible !important;
72
+ }