nodebb-plugin-ezoic-infinite 1.8.17 → 1.8.18

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,16 +1,24 @@
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
8
  const plugin = {};
9
9
 
10
+ // ── Helpers ────────────────────────────────────────────────────────────────
11
+
10
12
  function normalizeExcludedGroups(value) {
11
13
  if (!value) return [];
12
14
  if (Array.isArray(value)) return value;
13
- return String(value).split(',').map(s => s.trim()).filter(Boolean);
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 (_) {}
19
+ }
20
+ // Fallback : séparation par virgule
21
+ return s.split(',').map(v => v.trim()).filter(Boolean);
14
22
  }
15
23
 
16
24
  function parseBool(v, def = false) {
@@ -27,14 +35,16 @@ async function getAllGroups() {
27
35
  }
28
36
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
37
  const data = await groups.getGroupsData(filtered);
30
- // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
38
  const valid = data.filter(g => g && g.name);
32
39
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
40
  return valid;
34
41
  }
35
- let _settingsCache = null;
42
+
43
+ // ── Settings cache (30s TTL) ────────────────────────────────────────────────
44
+
45
+ let _settingsCache = null;
36
46
  let _settingsCacheAt = 0;
37
- const SETTINGS_TTL = 30000; // 30s
47
+ const SETTINGS_TTL = 30_000;
38
48
 
39
49
  async function getSettings() {
40
50
  const now = Date.now();
@@ -42,25 +52,19 @@ async function getSettings() {
42
52
  const s = await meta.settings.get(SETTINGS_KEY);
43
53
  _settingsCacheAt = Date.now();
44
54
  _settingsCache = {
45
- // Between-post ads (simple blocks) in category topic list
46
- enableBetweenAds: parseBool(s.enableBetweenAds, true),
47
- showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
48
- placeholderIds: (s.placeholderIds || '').trim(),
49
- intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
50
-
51
- // Home/categories list ads (between categories on / or /categories)
52
- enableCategoryAds: parseBool(s.enableCategoryAds, false),
53
- showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
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),
54
61
  categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
55
- intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
56
-
57
- // "Ad message" between replies (looks like a post)
58
- enableMessageAds: parseBool(s.enableMessageAds, false),
59
- showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
60
- messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
61
- messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
62
-
63
- excludedGroups: normalizeExcludedGroups(s.excludedGroups),
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),
64
68
  };
65
69
  return _settingsCache;
66
70
  }
@@ -71,58 +75,123 @@ async function isUserExcluded(uid, excludedGroups) {
71
75
  return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
72
76
  }
73
77
 
78
+
79
+
80
+ // ── Groups cache (5 min TTL) ───────────────────────────────────────────────
81
+
82
+ let _groupsCache = null;
83
+ let _groupsCacheAt = 0;
84
+ const GROUPS_TTL = 5 * 60_000;
85
+
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;
93
+ }
94
+
95
+ // ── Exclusion cache (per uid, 30s TTL) ─────────────────────────────────────
96
+
97
+ const _excludedCache = new Map(); // uid -> { v:boolean, t:number, sig:string }
98
+ const EXCLUDED_TTL = 30_000;
99
+ const EXCLUDED_MAX = 10_000;
100
+
101
+ function excludedSig(excludedGroups) {
102
+ // signature stable to invalidate when groups list changes
103
+ return excludedGroups.join('\u0001');
104
+ }
105
+
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 ──────────────────────────────────────────────────────────
118
+
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>`;
126
+
127
+ // ── Hooks ──────────────────────────────────────────────────────────────────
128
+
74
129
  plugin.onSettingsSet = function (data) {
75
- // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
76
- if (data && data.hash === SETTINGS_KEY) {
77
- _settingsCache = null;
78
- }
130
+ if (data && data.hash === SETTINGS_KEY) { _settingsCache = null; _groupsCache = null; _excludedCache.clear(); }
79
131
  };
80
132
 
81
133
  plugin.addAdminNavigation = async (header) => {
82
134
  header.plugins = header.plugins || [];
83
- header.plugins.push({
84
- route: '/plugins/ezoic-infinite',
85
- icon: 'fa-ad',
86
- name: 'Ezoic Infinite Ads'
87
- });
135
+ header.plugins.push({ route: '/plugins/ezoic-infinite', icon: 'fa-ad', name: 'Ezoic Infinite Ads' });
88
136
  return header;
89
137
  };
90
138
 
91
- plugin.init = async ({ router, middleware }) => {
92
- async function render(req, res) {
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) => {
150
+ try {
93
151
  const settings = await getSettings();
94
- const allGroups = await getAllGroups();
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 || '');
157
+ }
158
+ } catch (_) {}
159
+ return data;
160
+ };
95
161
 
162
+ plugin.init = async ({ router, middleware }) => {
163
+ async function render(req, res) {
164
+ const settings = await getSettings();
165
+ const allGroups = await getAllGroupsCached();
96
166
  res.render('admin/plugins/ezoic-infinite', {
97
167
  title: 'Ezoic Infinite Ads',
98
168
  ...settings,
99
169
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
100
- enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
170
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
101
171
  allGroups,
102
172
  });
103
173
  }
104
174
 
105
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
175
+ router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
176
  router.get('/api/admin/plugins/ezoic-infinite', render);
107
177
 
108
178
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
179
  const settings = await getSettings();
110
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
111
-
180
+ const excluded = await isUserExcludedCached(req.uid, settings.excludedGroups);
112
181
  res.json({
113
182
  excluded,
114
- enableBetweenAds: settings.enableBetweenAds,
115
- showFirstTopicAd: settings.showFirstTopicAd,
116
- placeholderIds: settings.placeholderIds,
117
- intervalPosts: settings.intervalPosts,
118
- enableCategoryAds: settings.enableCategoryAds,
119
- showFirstCategoryAd: settings.showFirstCategoryAd,
183
+ enableBetweenAds: settings.enableBetweenAds,
184
+ showFirstTopicAd: settings.showFirstTopicAd,
185
+ placeholderIds: settings.placeholderIds,
186
+ intervalPosts: settings.intervalPosts,
187
+ enableCategoryAds: settings.enableCategoryAds,
188
+ showFirstCategoryAd: settings.showFirstCategoryAd,
120
189
  categoryPlaceholderIds: settings.categoryPlaceholderIds,
121
- intervalCategories: settings.intervalCategories,
122
- enableMessageAds: settings.enableMessageAds,
123
- showFirstMessageAd: settings.showFirstMessageAd,
124
- messagePlaceholderIds: settings.messagePlaceholderIds,
125
- messageIntervalPosts: settings.messageIntervalPosts,
190
+ intervalCategories: settings.intervalCategories,
191
+ enableMessageAds: settings.enableMessageAds,
192
+ showFirstMessageAd: settings.showFirstMessageAd,
193
+ messagePlaceholderIds: settings.messagePlaceholderIds,
194
+ messageIntervalPosts: settings.messageIntervalPosts,
126
195
  });
127
196
  });
128
197
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.17",
3
+ "version": "1.8.18",
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/plugin.json CHANGED
@@ -4,30 +4,14 @@
4
4
  "description": "Ezoic ads with infinite scroll using a pool of placeholder IDs",
5
5
  "library": "./library.js",
6
6
  "hooks": [
7
- {
8
- "hook": "static:app.load",
9
- "method": "init"
10
- },
11
- {
12
- "hook": "filter:admin.header.build",
13
- "method": "addAdminNavigation"
14
- },
15
- {
16
- "hook": "action:settings.set",
17
- "method": "onSettingsSet"
18
- }
7
+ { "hook": "static:app.load", "method": "init" },
8
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
9
+ { "hook": "action:settings.set", "method": "onSettingsSet" },
10
+ { "hook": "filter:middleware.renderHeader","method": "injectEzoicHead" }
19
11
  ],
20
- "staticDirs": {
21
- "public": "public"
22
- },
23
- "acpScripts": [
24
- "public/admin.js"
25
- ],
26
- "scripts": [
27
- "public/client.js"
28
- ],
29
- "templates": "public/templates",
30
- "css": [
31
- "public/style.css"
32
- ]
33
- }
12
+ "staticDirs": { "public": "public" },
13
+ "acpScripts": [ "public/admin.js" ],
14
+ "scripts": [ "public/client.js" ],
15
+ "templates": "public/templates",
16
+ "css": [ "public/style.css" ]
17
+ }
package/public/client.js CHANGED
@@ -1,55 +1,90 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js (v20)
2
+ * NodeBB Ezoic Infinite Ads — client.js v36
3
3
  *
4
- * Correctifs critiques vs v19
5
- * ───────────────────────────
6
- * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
- * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
- * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
- * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
- * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
- * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
4
+ * Historique des corrections majeures
5
+ * ────────────────────────────────────
6
+ * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
12
7
  *
13
- * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
- * Fix : on skip uniquement le wrap courant, pas toute la boucle.
8
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
+ * la position dans le batch courant.
15
10
  *
16
- * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
- * existants sur les items suivants. Fix : `continue` au lieu de `break`.
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.
18
15
  *
19
- * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
- * Fix : marge large fixe par device, observer créé une seule fois.
16
+ * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
21
17
  *
22
- * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
- * Fix : 200ms.
18
+ * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
24
19
  *
25
- * Nettoyage
26
- * ─────────
27
- * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
- * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
- * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
- * - Commentaires internes allégés (code auto-documenté)
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.
31
67
  */
32
- (function () {
68
+ (function nbbEzoicInfinite() {
33
69
  'use strict';
34
70
 
35
71
  // ── Constantes ─────────────────────────────────────────────────────────────
36
72
 
37
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
- const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
-
44
- const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
- const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
- const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
- const MAX_INSERTS_PER_RUN = 6;
48
- const MAX_INFLIGHT = 4;
49
- const SHOW_THROTTLE_MS = 900;
50
- const BURST_COOLDOWN_MS = 200;
51
-
52
- // Marges IO larges et fixes (pas de reconstruction d'observer)
73
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
74
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
75
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
76
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
77
+ const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
+
80
+ const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
+ const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
+ const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
+ const MAX_INFLIGHT = 4; // max showAds() simultanés
84
+ const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
+ const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
86
+
87
+ // Marges IO larges et fixes — observer créé une seule fois au boot
53
88
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
54
89
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
55
90
 
@@ -60,40 +95,38 @@
60
95
  };
61
96
 
62
97
  /**
63
- * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
98
+ * Table KIND source de vérité par kindClass.
64
99
  *
65
- * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
- * quelle que soit la page ou la virtualisation :
67
- * posts data-pid (id du message, unique et permanent)
68
- * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
- * catégories data-cid (id de la catégorie, unique et permanent)
70
- * C'était le bug v19 : on cherchait data-index ici
100
+ * sel sélecteur CSS complet des éléments cibles
101
+ * baseTag préfixe tag pour querySelector d'ancre
102
+ * (vide pour posts : le sélecteur commence par '[')
103
+ * anchorAttr attribut DOM stable clé unique du wrap
104
+ * ordinalAttr attribut 0-based pour le calcul de l'intervalle
105
+ * null fallback positionnel (catégories)
71
106
  */
72
107
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
108
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
109
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
110
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
111
  };
77
112
 
78
- // ── État ───────────────────────────────────────────────────────────────────
113
+ // ── État global ────────────────────────────────────────────────────────────
79
114
 
80
115
  const S = {
81
- pageKey: null,
82
- cfg: null,
83
-
84
- pools: { topics: [], posts: [], categories: [] },
85
- cursors: { topics: 0, posts: 0, categories: 0 },
86
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
- lastShow: new Map(), // id → timestamp dernier show
88
-
89
- io: null,
90
- domObs: null,
91
- mutGuard: 0, // compteur internalMutation
92
-
93
- inflight: 0,
94
- pending: [],
95
- pendingSet: new Set(),
96
-
116
+ pageKey: null,
117
+ cfg: null,
118
+ poolsReady: false,
119
+ pools: { topics: [], posts: [], categories: [] },
120
+ cursors: { topics: 0, posts: 0, categories: 0 },
121
+ mountedIds: new Set(),
122
+ lastShow: new Map(),
123
+ io: null,
124
+ domObs: null,
125
+ mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
126
+ inflight: 0, // showAds() en cours
127
+ pending: [], // ids en attente de slot inflight
128
+ pendingSet: new Set(),
129
+ wrapByKey: new Map(), // anchorKey → wrap DOM node
97
130
  runQueued: false,
98
131
  burstActive: false,
99
132
  burstDeadline: 0,
@@ -102,8 +135,12 @@
102
135
  };
103
136
 
104
137
  let blockedUntil = 0;
105
- const isBlocked = () => Date.now() < blockedUntil;
106
- const ts = () => Date.now();
138
+
139
+ const ts = () => Date.now();
140
+ const isBlocked = () => ts() < blockedUntil;
141
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
142
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
143
+ const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
107
144
 
108
145
  function mutate(fn) {
109
146
  S.mutGuard++;
@@ -121,27 +158,22 @@
121
158
  return S.cfg;
122
159
  }
123
160
 
124
- function initPools(cfg) {
125
- S.pools.topics = parseIds(cfg.placeholderIds);
126
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
127
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
128
- }
129
-
130
161
  function parseIds(raw) {
131
162
  const out = [], seen = new Set();
132
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
163
+ for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
133
164
  const n = parseInt(v, 10);
134
165
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
135
166
  }
136
167
  return out;
137
168
  }
138
169
 
139
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
140
-
141
- const isFilled = (n) =>
142
- !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
143
-
144
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
170
+ function initPools(cfg) {
171
+ if (S.poolsReady) return;
172
+ S.pools.topics = parseIds(cfg.placeholderIds);
173
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
174
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
175
+ S.poolsReady = true;
176
+ }
145
177
 
146
178
  // ── Page identity ──────────────────────────────────────────────────────────
147
179
 
@@ -165,13 +197,13 @@
165
197
  return 'other';
166
198
  }
167
199
 
168
- // ── DOM helpers ────────────────────────────────────────────────────────────
200
+ // ── Items DOM ──────────────────────────────────────────────────────────────
169
201
 
170
202
  function getPosts() {
171
203
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
172
204
  if (!el.isConnected) return false;
173
205
  if (!el.querySelector('[component="post/content"]')) return false;
174
- const p = el.parentElement?.closest('[component="post"][data-pid]');
206
+ const p = el.parentElement?.closest(SEL.post);
175
207
  if (p && p !== el) return false;
176
208
  return el.getAttribute('component') !== 'post/parent';
177
209
  });
@@ -180,53 +212,87 @@
180
212
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
181
213
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
182
214
 
215
+ // ── Wraps — détection ──────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Vérifie qu'un wrap a encore son ancre dans le DOM.
219
+ * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
220
+ */
221
+ function wrapIsLive(wrap) {
222
+ if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
223
+ const key = wrap.getAttribute(A_ANCHOR);
224
+ if (!key) return false;
225
+ // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
226
+ // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
227
+ if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
228
+ // Fallback : registre pas encore à jour ou wrap non enregistré.
229
+ const colonIdx = key.indexOf(':');
230
+ const klass = key.slice(0, colonIdx);
231
+ const anchorId = key.slice(colonIdx + 1);
232
+ const cfg = KIND[klass];
233
+ if (!cfg) return false;
234
+ // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
235
+ // de querySelector global — on cherche parmi les voisins immédiats.
236
+ const parent = wrap.parentElement;
237
+ if (parent) {
238
+ for (const sib of parent.children) {
239
+ if (sib === wrap) continue;
240
+ try {
241
+ if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
242
+ return sib.isConnected;
243
+ }
244
+ } catch (_) {}
245
+ }
246
+ }
247
+ // Dernier recours : querySelector global
248
+ try {
249
+ const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
250
+ return !!(found?.isConnected);
251
+ } catch (_) { return false; }
252
+ }
253
+
183
254
  function adjacentWrap(el) {
184
- return !!(
185
- el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
186
- el.previousElementSibling?.classList?.contains(WRAP_CLASS)
187
- );
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
188
256
  }
189
257
 
190
- // ── Ancres stables ────────────────────────────────────────────────────────
258
+ // ── Ancres stables ─────────────────────────────────────────────────────────
191
259
 
192
260
  /**
193
- * Retourne l'identifiant stable de l'élément selon son kindClass.
194
- * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
195
- * Fallback positionnel si l'attribut est absent.
261
+ * Retourne la valeur de l'attribut stable pour cet élément,
262
+ * ou un fallback positionnel si l'attribut est absent.
196
263
  */
197
- function stableId(kindClass, el) {
198
- const attr = KIND[kindClass]?.anchorAttr;
264
+ function stableId(klass, el) {
265
+ const attr = KIND[klass]?.anchorAttr;
199
266
  if (attr) {
200
267
  const v = el.getAttribute(attr);
201
268
  if (v !== null && v !== '') return v;
202
269
  }
203
- // Fallback : position dans le parent
204
- try {
205
- let i = 0;
206
- for (const s of el.parentElement?.children ?? []) {
207
- if (s === el) return `i${i}`;
208
- i++;
209
- }
210
- } catch (_) {}
270
+ let i = 0;
271
+ for (const s of el.parentElement?.children ?? []) {
272
+ if (s === el) return `i${i}`;
273
+ i++;
274
+ }
211
275
  return 'i0';
212
276
  }
213
277
 
214
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
278
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
215
279
 
216
- function findWrap(anchorKey) {
217
- try {
218
- return document.querySelector(
219
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
220
- );
221
- } catch (_) { return null; }
280
+ function findWrap(key) {
281
+ const w = S.wrapByKey.get(key);
282
+ return (w?.isConnected) ? w : null;
222
283
  }
223
284
 
224
285
  // ── Pool ───────────────────────────────────────────────────────────────────
225
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
226
291
  function pickId(poolKey) {
227
292
  const pool = S.pools[poolKey];
293
+ if (!pool.length) return null;
228
294
  for (let t = 0; t < pool.length; t++) {
229
- const i = S.cursors[poolKey] % pool.length;
295
+ const i = S.cursors[poolKey] % pool.length;
230
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
231
297
  const id = pool[i];
232
298
  if (!S.mountedIds.has(id)) return id;
@@ -234,7 +300,69 @@
234
300
  return null;
235
301
  }
236
302
 
237
- // ── Wraps DOM ──────────────────────────────────────────────────────────────
303
+ /**
304
+ * Pool épuisé : recycle un wrap loin au-dessus du viewport.
305
+ * Séquence avec délais (destroyPlaceholders est asynchrone) :
306
+ * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
307
+ * displayMore = API Ezoic prévue pour l'infinite scroll.
308
+ * Priorité : wraps vides d'abord, remplis si nécessaire.
309
+ */
310
+ function recycleAndMove(klass, targetEl, newKey) {
311
+ const ez = window.ezstandalone;
312
+ if (typeof ez?.destroyPlaceholders !== 'function' ||
313
+ typeof ez?.define !== 'function' ||
314
+ typeof ez?.displayMore !== 'function') return null;
315
+
316
+ const vh = window.innerHeight || 800;
317
+ // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
318
+ // après pour neutraliser l'IO — plus de showAds parasite possible.
319
+ const threshold = -vh;
320
+ let bestEmpty = null, bestEmptyBottom = Infinity;
321
+ let bestFilled = null, bestFilledBottom = Infinity;
322
+
323
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
324
+ try {
325
+ const rect = wrap.getBoundingClientRect();
326
+ if (rect.bottom > threshold) return;
327
+ if (!isFilled(wrap)) {
328
+ if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
329
+ } else {
330
+ if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
331
+ }
332
+ } catch (_) {}
333
+ });
334
+
335
+ const best = bestEmpty ?? bestFilled;
336
+ if (!best) return null;
337
+ const id = parseInt(best.getAttribute(A_WRAPID), 10);
338
+ if (!Number.isFinite(id)) return null;
339
+
340
+ const oldKey = best.getAttribute(A_ANCHOR);
341
+ // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
342
+ // parasite si le nœud était encore dans la zone IO_MARGIN.
343
+ try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
344
+ mutate(() => {
345
+ best.setAttribute(A_ANCHOR, newKey);
346
+ best.setAttribute(A_CREATED, String(ts()));
347
+ best.setAttribute(A_SHOWN, '0');
348
+ best.classList.remove('is-empty');
349
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
350
+ if (ph) ph.innerHTML = '';
351
+ targetEl.insertAdjacentElement('afterend', best);
352
+ });
353
+ if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
354
+ S.wrapByKey.set(newKey, best);
355
+
356
+ // Délais requis : destroyPlaceholders est asynchrone en interne
357
+ const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
+ const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
359
+ const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
360
+ try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
361
+
362
+ return { id, wrap: best };
363
+ }
364
+
365
+ // ── Wraps DOM — création / suppression ────────────────────────────────────
238
366
 
239
367
  function makeWrap(id, klass, key) {
240
368
  const w = document.createElement('div');
@@ -242,6 +370,7 @@
242
370
  w.setAttribute(A_ANCHOR, key);
243
371
  w.setAttribute(A_WRAPID, String(id));
244
372
  w.setAttribute(A_CREATED, String(ts()));
373
+ w.setAttribute(A_SHOWN, '0');
245
374
  w.style.cssText = 'width:100%;display:block;';
246
375
  const ph = document.createElement('div');
247
376
  ph.id = `${PH_PREFIX}${id}`;
@@ -251,112 +380,85 @@
251
380
  }
252
381
 
253
382
  function insertAfter(el, id, klass, key) {
254
- if (!el?.insertAdjacentElement) return null;
255
- if (findWrap(key)) return null; // ancre déjà présente
256
- if (S.mountedIds.has(id)) return null; // id déjà monté
383
+ if (!el?.insertAdjacentElement) return null;
384
+ if (findWrap(key)) return null;
385
+ if (S.mountedIds.has(id)) return null;
257
386
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
258
387
  const w = makeWrap(id, klass, key);
259
388
  mutate(() => el.insertAdjacentElement('afterend', w));
260
389
  S.mountedIds.add(id);
390
+ S.wrapByKey.set(key, w);
261
391
  return w;
262
392
  }
263
393
 
264
394
  function dropWrap(w) {
265
395
  try {
396
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
397
+ if (ph instanceof Element) S.io?.unobserve(ph);
266
398
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
399
  if (Number.isFinite(id)) S.mountedIds.delete(id);
268
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
- // "parameter 1 is not of type Element" sur le prochain observe).
271
- try {
272
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
- if (ph instanceof Element) S.io?.unobserve(ph);
274
- } catch (_) {}
400
+ const key = w.getAttribute(A_ANCHOR);
401
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
275
402
  w.remove();
276
403
  } catch (_) {}
277
404
  }
278
405
 
279
- // ── Prune ──────────────────────────────────────────────────────────────────
280
-
281
- /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
283
- *
284
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
- * Exemples :
286
- * ezoic-ad-message → cherche [data-pid="123"]
287
- * ezoic-ad-between → cherche [data-index="5"]
288
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
289
- *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
291
- */
292
- function pruneOrphans(klass) {
293
- const meta = KIND[klass];
294
- if (!meta) return;
295
-
296
- const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
406
+ // ── Prune (topics de catégorie uniquement) ────────────────────────────────
407
+ //
408
+ // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
409
+ //
410
+ // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
411
+ // les li[component="category/topic"] restent dans le DOM pendant toute
412
+ // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
413
+ // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
414
+ // liste après un long scroll et bloquent les nouvelles injections.
415
+ //
416
+ // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
417
+ // NodeBB virtualise les posts hors-viewport il les retire puis les
418
+ // réinsère. pruneOrphans verrait des ancres temporairement absentes,
419
+ // supprimerait les wraps, et provoquerait une réinjection en haut.
420
+
421
+ function pruneOrphansBetween() {
422
+ const klass = 'ezoic-ad-between';
423
+ const cfg = KIND[klass];
424
+
425
+ // 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
+ });
297
431
 
298
432
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
299
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
433
+ const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
434
+ if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
300
435
 
301
436
  const key = w.getAttribute(A_ANCHOR) ?? '';
302
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
437
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
303
438
  if (!sid) { mutate(() => dropWrap(w)); return; }
304
439
 
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
308
- if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
440
+ if (!anchors.has(String(sid))) mutate(() => dropWrap(w));
309
441
  });
310
442
  }
311
443
 
312
- // ── Decluster ──────────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
316
- * Priorité : filled > en grâce (fill en cours) > vide.
317
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
318
- */
319
- function decluster(klass) {
320
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
322
- const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
-
325
- let prev = w.previousElementSibling, steps = 0;
326
- while (prev && steps++ < 3) {
327
- if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
-
329
- const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
331
-
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
334
- break;
335
- }
336
- }
337
- }
338
-
339
444
  // ── Injection ──────────────────────────────────────────────────────────────
340
445
 
341
446
  /**
342
- * Ordinal 0-based pour le calcul de l'intervalle.
343
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
447
+ * Ordinal 0-based pour le calcul de l'intervalle d'injection.
448
+ * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
345
449
  */
346
450
  function ordinal(klass, el) {
347
- const di = el.getAttribute('data-index');
348
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
349
- // Fallback positionnel
350
- try {
351
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
352
- if (tag) {
353
- let i = 0;
354
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
355
- if (n === el) return i;
356
- i++;
357
- }
358
- }
359
- } catch (_) {}
451
+ const attr = KIND[klass]?.ordinalAttr;
452
+ if (attr) {
453
+ const v = el.getAttribute(attr);
454
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
455
+ }
456
+ const fullSel = KIND[klass]?.sel ?? '';
457
+ let i = 0;
458
+ for (const s of el.parentElement?.children ?? []) {
459
+ if (s === el) return i;
460
+ if (!fullSel || s.matches?.(fullSel)) i++;
461
+ }
360
462
  return 0;
361
463
  }
362
464
 
@@ -365,23 +467,25 @@
365
467
  let inserted = 0;
366
468
 
367
469
  for (const el of items) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
470
+ if (inserted >= MAX_INSERTS_RUN) break;
369
471
  if (!el?.isConnected) continue;
370
472
 
371
- const ord = ordinal(klass, el);
372
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
373
- if (!isTarget) continue;
374
-
473
+ const ord = ordinal(klass, el);
474
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
375
475
  if (adjacentWrap(el)) continue;
376
476
 
377
- const key = makeAnchorKey(klass, el);
378
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
477
+ const key = anchorKey(klass, el);
478
+ if (findWrap(key)) continue;
379
479
 
380
480
  const id = pickId(poolKey);
381
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
382
-
383
- const w = insertAfter(el, id, klass, key);
384
- if (w) { observePh(id); inserted++; }
481
+ if (id) {
482
+ const w = insertAfter(el, id, klass, key);
483
+ if (w) { observePh(id); inserted++; }
484
+ } else {
485
+ const recycled = recycleAndMove(klass, el, key);
486
+ if (!recycled) break;
487
+ inserted++;
488
+ }
385
489
  }
386
490
  return inserted;
387
491
  }
@@ -390,7 +494,6 @@
390
494
 
391
495
  function getIO() {
392
496
  if (S.io) return S.io;
393
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
394
497
  try {
395
498
  S.io = new IntersectionObserver(entries => {
396
499
  for (const e of entries) {
@@ -399,7 +502,7 @@
399
502
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
400
503
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
401
504
  }
402
- }, { root: null, rootMargin: margin, threshold: 0 });
505
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
403
506
  } catch (_) { S.io = null; }
404
507
  return S.io;
405
508
  }
@@ -450,7 +553,6 @@
450
553
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
451
554
  S.lastShow.set(id, t);
452
555
 
453
- // Horodater le show sur le wrap pour grace period + emptyCheck
454
556
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
455
557
 
456
558
  window.ezstandalone = window.ezstandalone || {};
@@ -471,7 +573,6 @@
471
573
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
472
574
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
473
575
  if (!wrap || !ph?.isConnected) return;
474
- // Un show plus récent → ne pas toucher
475
576
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
476
577
  wrap.classList.toggle('is-empty', !isFilled(ph));
477
578
  } catch (_) {}
@@ -479,6 +580,10 @@
479
580
  }
480
581
 
481
582
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
583
+ //
584
+ // Intercepte ez.showAds() pour :
585
+ // – ignorer les appels pendant blockedUntil
586
+ // – filtrer les ids dont le placeholder n'est pas en DOM
482
587
 
483
588
  function patchShowAds() {
484
589
  const apply = () => {
@@ -490,7 +595,7 @@
490
595
  const orig = ez.showAds.bind(ez);
491
596
  ez.showAds = function (...args) {
492
597
  if (isBlocked()) return;
493
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
598
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
494
599
  const seen = new Set();
495
600
  for (const v of ids) {
496
601
  const id = parseInt(v, 10);
@@ -509,7 +614,7 @@
509
614
  }
510
615
  }
511
616
 
512
- // ── Core run ───────────────────────────────────────────────────────────────
617
+ // ── Core ───────────────────────────────────────────────────────────────────
513
618
 
514
619
  async function runCore() {
515
620
  if (isBlocked()) return 0;
@@ -524,30 +629,30 @@
524
629
 
525
630
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
526
631
  if (!normBool(cfgEnable)) return 0;
527
- const items = getItems();
528
632
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
529
- pruneOrphans(klass);
530
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
531
- if (n) decluster(klass);
532
- return n;
633
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
533
634
  };
534
635
 
535
636
  if (kind === 'topic') return exec(
536
637
  'ezoic-ad-message', getPosts,
537
638
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
538
639
  );
539
- if (kind === 'categoryTopics') return exec(
540
- 'ezoic-ad-between', getTopics,
541
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
542
- );
543
- if (kind === 'categories') return exec(
640
+
641
+ if (kind === 'categoryTopics') {
642
+ pruneOrphansBetween();
643
+ return exec(
644
+ 'ezoic-ad-between', getTopics,
645
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
646
+ );
647
+ }
648
+
649
+ return exec(
544
650
  'ezoic-ad-categories', getCategories,
545
651
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
546
652
  );
547
- return 0;
548
653
  }
549
654
 
550
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
655
+ // ── Scheduler ──────────────────────────────────────────────────────────────
551
656
 
552
657
  function scheduleRun(cb) {
553
658
  if (S.runQueued) return;
@@ -565,10 +670,8 @@
565
670
  if (isBlocked()) return;
566
671
  const t = ts();
567
672
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
568
- S.lastBurstTs = t;
569
-
570
- const pk = pageKey();
571
- S.pageKey = pk;
673
+ S.lastBurstTs = t;
674
+ S.pageKey = pageKey();
572
675
  S.burstDeadline = t + 2000;
573
676
 
574
677
  if (S.burstActive) return;
@@ -576,7 +679,7 @@
576
679
  S.burstCount = 0;
577
680
 
578
681
  const step = () => {
579
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
682
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
580
683
  S.burstActive = false; return;
581
684
  }
582
685
  S.burstCount++;
@@ -588,16 +691,18 @@
588
691
  step();
589
692
  }
590
693
 
591
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
694
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
592
695
 
593
696
  function cleanup() {
594
697
  blockedUntil = ts() + 1500;
595
698
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
596
699
  S.cfg = null;
700
+ S.poolsReady = false;
597
701
  S.pools = { topics: [], posts: [], categories: [] };
598
702
  S.cursors = { topics: 0, posts: 0, categories: 0 };
599
703
  S.mountedIds.clear();
600
704
  S.lastShow.clear();
705
+ S.wrapByKey.clear();
601
706
  S.inflight = 0;
602
707
  S.pending = [];
603
708
  S.pendingSet.clear();
@@ -605,19 +710,19 @@
605
710
  S.runQueued = false;
606
711
  }
607
712
 
608
- // ── DOM Observer ───────────────────────────────────────────────────────────
713
+ // ── MutationObserver ───────────────────────────────────────────────────────
609
714
 
610
715
  function ensureDomObserver() {
611
716
  if (S.domObs) return;
717
+ const allSel = [SEL.post, SEL.topic, SEL.category];
612
718
  S.domObs = new MutationObserver(muts => {
613
719
  if (S.mutGuard > 0 || isBlocked()) return;
614
720
  for (const m of muts) {
615
- if (!m.addedNodes?.length) continue;
616
721
  for (const n of m.addedNodes) {
617
722
  if (n.nodeType !== 1) continue;
618
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
619
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
620
- n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
723
+ // 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;} })) {
621
726
  requestBurst(); return;
622
727
  }
623
728
  }
@@ -631,7 +736,14 @@
631
736
  function muteConsole() {
632
737
  if (window.__nbbEzMuted) return;
633
738
  window.__nbbEzMuted = true;
634
- const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
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
+ ];
635
747
  for (const m of ['log', 'info', 'warn', 'error']) {
636
748
  const orig = console[m];
637
749
  if (typeof orig !== 'function') continue;
@@ -643,29 +755,18 @@
643
755
  }
644
756
 
645
757
  function ensureTcfLocator() {
646
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
647
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
648
- // iframe du DOM (vidage partiel du body), ce qui provoque :
649
- // "Cannot read properties of null (reading 'postMessage')"
650
- // "Cannot set properties of null (setting 'addtlConsent')"
651
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
652
758
  try {
653
759
  if (!window.__tcfapi && !window.__cmp) return;
654
-
655
760
  const inject = () => {
656
761
  if (document.getElementById('__tcfapiLocator')) return;
657
762
  const f = document.createElement('iframe');
658
763
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
659
764
  (document.body || document.documentElement).appendChild(f);
660
765
  };
661
-
662
766
  inject();
663
-
664
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
665
767
  if (!window.__nbbTcfObs) {
666
- window.__nbbTcfObs = new MutationObserver(() => inject());
667
- window.__nbbTcfObs.observe(document.documentElement,
668
- { childList: true, subtree: true });
768
+ window.__nbbTcfObs = new MutationObserver(inject);
769
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
669
770
  }
670
771
  } catch (_) {}
671
772
  }
@@ -675,10 +776,10 @@
675
776
  const head = document.head;
676
777
  if (!head) return;
677
778
  for (const [rel, href, cors] of [
678
- ['preconnect', 'https://g.ezoic.net', true],
679
- ['preconnect', 'https://go.ezoic.net', true],
680
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
681
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
779
+ ['preconnect', 'https://g.ezoic.net', true ],
780
+ ['preconnect', 'https://go.ezoic.net', true ],
781
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
782
+ ['preconnect', 'https://pagead2.googlesyndication.com', true ],
682
783
  ['dns-prefetch', 'https://g.ezoic.net', false],
683
784
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
684
785
  ]) {
@@ -692,7 +793,7 @@
692
793
  }
693
794
  }
694
795
 
695
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
796
+ // ── Bindings ───────────────────────────────────────────────────────────────
696
797
 
697
798
  function bindNodeBB() {
698
799
  const $ = window.jQuery;
@@ -703,19 +804,16 @@
703
804
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
704
805
  S.pageKey = pageKey();
705
806
  blockedUntil = 0;
706
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
707
- getIO(); ensureDomObserver(); requestBurst();
807
+ muteConsole(); ensureTcfLocator(); warmNetwork();
808
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
708
809
  });
709
810
 
710
- const BURST_EVENTS = [
711
- 'action:ajaxify.contentLoaded',
712
- 'action:posts.loaded', 'action:topics.loaded',
811
+ const burstEvts = [
812
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
713
813
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
714
814
  ].map(e => `${e}.nbbEzoic`).join(' ');
815
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
715
816
 
716
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
717
-
718
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
719
817
  try {
720
818
  require(['hooks'], hooks => {
721
819
  if (typeof hooks?.on !== 'function') return;