nodebb-plugin-ezoic-infinite 1.8.19 → 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,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.20",
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
 
@@ -99,6 +32,9 @@
99
32
  topic: 'li[component="category/topic"]',
100
33
  category: 'li[component="categories/category"]',
101
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];
102
38
 
103
39
  /**
104
40
  * Table KIND — source de vérité par kindClass.
@@ -157,7 +93,7 @@
157
93
  const isBlocked = () => ts() < blockedUntil;
158
94
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
159
95
  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]'));
96
+ const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
161
97
 
162
98
  function healFalseEmpty(root = document) {
163
99
  try {
@@ -196,7 +132,7 @@
196
132
  requestAnimationFrame(() => {
197
133
  S.sweepQueued = false;
198
134
  sweepDeadWraps();
199
- healFalseEmpty();
135
+ healFalseEmpty();
200
136
  });
201
137
  }
202
138
 
@@ -273,7 +209,6 @@ function destroyBeforeReuse(ids) {
273
209
  return out;
274
210
  }
275
211
 
276
-
277
212
  // ── Config ─────────────────────────────────────────────────────────────────
278
213
 
279
214
  async function fetchConfig() {
@@ -430,8 +365,10 @@ function destroyBeforeReuse(ids) {
430
365
  function sweepDeadWraps() {
431
366
  // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
432
367
  // 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())) {
368
+ let changed = false;
369
+ for (const [key, wrap] of S.wrapByKey) {
434
370
  if (wrap?.isConnected) continue;
371
+ changed = true;
435
372
  S.wrapByKey.delete(key);
436
373
  const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
437
374
  if (Number.isFinite(id)) {
@@ -441,7 +378,7 @@ function destroyBeforeReuse(ids) {
441
378
  S.ezActiveIds.delete(id);
442
379
  }
443
380
  }
444
- if (S.pending.length) {
381
+ if (changed && S.pending.length) {
445
382
  S.pending = S.pending.filter(id => S.pendingSet.has(id));
446
383
  }
447
384
  }
@@ -531,7 +468,6 @@ function recycleAndMove(klass, targetEl, newKey) {
531
468
  return { id, wrap: best };
532
469
  }
533
470
 
534
-
535
471
  // ── Wraps DOM — création / suppression ────────────────────────────────────
536
472
 
537
473
  function makeWrap(id, klass, key) {
@@ -592,7 +528,7 @@ function recycleAndMove(klass, targetEl, newKey) {
592
528
  const klass = 'ezoic-ad-between';
593
529
  const cfg = KIND[klass];
594
530
 
595
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
531
+ document.querySelectorAll(`${WRAP_SEL}.${klass}`).forEach(w => {
596
532
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
597
533
  if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
598
534
 
@@ -751,7 +687,7 @@ function startShowBatch(ids) {
751
687
  if (!canShowPlaceholderId(id, t)) continue;
752
688
 
753
689
  S.lastShow.set(id, t);
754
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
690
+ try { ph.closest?.(WRAP_SEL)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
755
691
  valid.push(id);
756
692
  }
757
693
 
@@ -774,7 +710,6 @@ function startShowBatch(ids) {
774
710
  });
775
711
  }
776
712
 
777
-
778
713
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
779
714
  //
780
715
  // Intercepte ez.showAds() pour :
@@ -899,7 +834,7 @@ function startShowBatch(ids) {
899
834
 
900
835
  function cleanup() {
901
836
  blockedUntil = ts() + 1500;
902
- mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
837
+ mutate(() => document.querySelectorAll(WRAP_SEL).forEach(dropWrap));
903
838
  S.cfg = null;
904
839
  S.poolsReady = false;
905
840
  S.pools = { topics: [], posts: [], categories: [] };
@@ -924,18 +859,33 @@ function startShowBatch(ids) {
924
859
  S.lastScrollTs = 0;
925
860
  }
926
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;
876
+ }
877
+
927
878
  // ── MutationObserver ───────────────────────────────────────────────────────
928
879
 
929
880
  function ensureDomObserver() {
930
881
  if (S.domObs) return;
931
- const allSel = [SEL.post, SEL.topic, SEL.category];
932
882
  S.domObs = new MutationObserver(muts => {
933
883
  if (S.mutGuard > 0 || isBlocked()) return;
934
884
  for (const m of muts) {
935
885
  let sawWrapRemoval = false;
936
886
  for (const n of m.removedNodes) {
937
887
  if (n.nodeType !== 1) continue;
938
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || (n.querySelector && n.querySelector(`.${WRAP_CLASS}`))) {
888
+ if (nodeMatchesAny(n, [WRAP_SEL]) || nodeContainsAny(n, [WRAP_SEL])) {
939
889
  sawWrapRemoval = true;
940
890
  }
941
891
  }
@@ -944,8 +894,7 @@ function startShowBatch(ids) {
944
894
  if (n.nodeType !== 1) continue;
945
895
  try { healFalseEmpty(n); } catch (_) {}
946
896
  // 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;} })) {
897
+ if (nodeMatchesAny(n, CONTENT_SEL_LIST) || nodeContainsAny(n, CONTENT_SEL_LIST)) {
949
898
  requestBurst(); return;
950
899
  }
951
900
  }
@@ -956,27 +905,6 @@ function startShowBatch(ids) {
956
905
 
957
906
  // ── Utilitaires ────────────────────────────────────────────────────────────
958
907
 
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
908
  function ensureTcfLocator() {
981
909
  try {
982
910
  if (!window.__tcfapi && !window.__cmp) return;
@@ -998,6 +926,7 @@ function startShowBatch(ids) {
998
926
  function warmNetwork() {
999
927
  const head = document.head;
1000
928
  if (!head) return;
929
+ const frag = document.createDocumentFragment();
1001
930
  for (const [rel, href, cors] of [
1002
931
  ['preconnect', 'https://g.ezoic.net', true ],
1003
932
  ['preconnect', 'https://go.ezoic.net', true ],
@@ -1012,8 +941,9 @@ function startShowBatch(ids) {
1012
941
  const l = document.createElement('link');
1013
942
  l.rel = rel; l.href = href;
1014
943
  if (cors) l.crossOrigin = 'anonymous';
1015
- head.appendChild(l);
944
+ frag.appendChild(l);
1016
945
  }
946
+ head.appendChild(frag);
1017
947
  }
1018
948
 
1019
949
  // ── Bindings ───────────────────────────────────────────────────────────────
@@ -1027,7 +957,7 @@ function startShowBatch(ids) {
1027
957
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1028
958
  S.pageKey = pageKey();
1029
959
  blockedUntil = 0;
1030
- muteConsole(); ensureTcfLocator(); warmNetwork();
960
+ ensureTcfLocator(); warmNetwork();
1031
961
  patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
1032
962
  });
1033
963
 
@@ -1075,7 +1005,6 @@ function startShowBatch(ids) {
1075
1005
  // ── Boot ───────────────────────────────────────────────────────────────────
1076
1006
 
1077
1007
  S.pageKey = pageKey();
1078
- muteConsole();
1079
1008
  ensureTcfLocator();
1080
1009
  warmNetwork();
1081
1010
  patchShowAds();