nodebb-plugin-ezoic-infinite 1.8.16 → 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) {
@@ -21,33 +29,22 @@ function parseBool(v, def = false) {
21
29
  }
22
30
 
23
31
  async function getAllGroups() {
24
- const now = Date.now();
25
- if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
26
-
27
32
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
28
33
  if (!names || !names.length) {
29
34
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
30
35
  }
31
36
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
32
37
  const data = await groups.getGroupsData(filtered);
33
- const valid = (data || []).filter(g => g && g.name);
38
+ const valid = data.filter(g => g && g.name);
34
39
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
35
-
36
- _groupsCacheAt = now;
37
- _groupsCache = valid;
38
40
  return valid;
39
41
  }
40
42
 
41
- let _settingsCache = null;
42
- let _settingsCacheAt = 0;
43
- const SETTINGS_TTL = 30000; // 30s
44
-
45
- let _groupsCache = null;
46
- let _groupsCacheAt = 0;
47
- const GROUPS_TTL = 5 * 60 * 1000; // 5min
43
+ // ── Settings cache (30s TTL) ────────────────────────────────────────────────
48
44
 
49
- const _excludedCache = new Map(); // uid -> { at, excluded }
50
- const EXCLUDED_TTL = 30 * 1000; // 30s
45
+ let _settingsCache = null;
46
+ let _settingsCacheAt = 0;
47
+ const SETTINGS_TTL = 30_000;
51
48
 
52
49
  async function getSettings() {
53
50
  const now = Date.now();
@@ -55,101 +52,146 @@ async function getSettings() {
55
52
  const s = await meta.settings.get(SETTINGS_KEY);
56
53
  _settingsCacheAt = Date.now();
57
54
  _settingsCache = {
58
- // Between-post ads (simple blocks) in category topic list
59
- enableBetweenAds: parseBool(s.enableBetweenAds, true),
60
- showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
61
- placeholderIds: (s.placeholderIds || '').trim(),
62
- intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
63
-
64
- // Home/categories list ads (between categories on / or /categories)
65
- enableCategoryAds: parseBool(s.enableCategoryAds, false),
66
- 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),
67
61
  categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
68
- intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
69
-
70
- // "Ad message" between replies (looks like a post)
71
- enableMessageAds: parseBool(s.enableMessageAds, false),
72
- showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
73
- messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
74
- messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
75
-
76
- 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),
77
68
  };
78
69
  return _settingsCache;
79
70
  }
80
71
 
81
72
  async function isUserExcluded(uid, excludedGroups) {
82
73
  if (!uid || !excludedGroups.length) return false;
74
+ const userGroups = await groups.getUserGroups([uid]);
75
+ return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
76
+ }
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;
83
85
 
86
+ async function getAllGroupsCached() {
84
87
  const now = Date.now();
85
- const cached = _excludedCache.get(uid);
86
- if (cached && (now - cached.at) < EXCLUDED_TTL) return cached.excluded;
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
+ }
87
94
 
88
- const userGroups = await groups.getUserGroups([uid]);
89
- const excluded = (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
95
+ // ── Exclusion cache (per uid, 30s TTL) ─────────────────────────────────────
90
96
 
91
- _excludedCache.set(uid, { at: now, excluded });
92
- // petite hygiene : éviter une map qui grossit sans limite
93
- if (_excludedCache.size > 5000) _excludedCache.clear();
97
+ const _excludedCache = new Map(); // uid -> { v:boolean, t:number, sig:string }
98
+ const EXCLUDED_TTL = 30_000;
99
+ const EXCLUDED_MAX = 10_000;
94
100
 
95
- return excluded;
101
+ function excludedSig(excludedGroups) {
102
+ // signature stable to invalidate when groups list changes
103
+ return excludedGroups.join('\u0001');
96
104
  }
97
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 ──────────────────────────────────────────────────────────────────
98
128
 
99
129
  plugin.onSettingsSet = function (data) {
100
- // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
101
- if (data && data.hash === SETTINGS_KEY) {
102
- _settingsCache = null;
103
- _excludedCache.clear();
104
- }
130
+ if (data && data.hash === SETTINGS_KEY) { _settingsCache = null; _groupsCache = null; _excludedCache.clear(); }
105
131
  };
106
132
 
107
-
108
133
  plugin.addAdminNavigation = async (header) => {
109
134
  header.plugins = header.plugins || [];
110
- header.plugins.push({
111
- route: '/plugins/ezoic-infinite',
112
- icon: 'fa-ad',
113
- name: 'Ezoic Infinite Ads'
114
- });
135
+ header.plugins.push({ route: '/plugins/ezoic-infinite', icon: 'fa-ad', name: 'Ezoic Infinite Ads' });
115
136
  return header;
116
137
  };
117
138
 
118
- plugin.init = async ({ router, middleware }) => {
119
- 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 {
120
151
  const settings = await getSettings();
121
- 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
+ };
122
161
 
162
+ plugin.init = async ({ router, middleware }) => {
163
+ async function render(req, res) {
164
+ const settings = await getSettings();
165
+ const allGroups = await getAllGroupsCached();
123
166
  res.render('admin/plugins/ezoic-infinite', {
124
167
  title: 'Ezoic Infinite Ads',
125
168
  ...settings,
126
169
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
127
- enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
170
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
128
171
  allGroups,
129
172
  });
130
173
  }
131
174
 
132
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
175
+ router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
133
176
  router.get('/api/admin/plugins/ezoic-infinite', render);
134
177
 
135
178
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
136
179
  const settings = await getSettings();
137
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
138
-
180
+ const excluded = await isUserExcludedCached(req.uid, settings.excludedGroups);
139
181
  res.json({
140
182
  excluded,
141
- enableBetweenAds: settings.enableBetweenAds,
142
- showFirstTopicAd: settings.showFirstTopicAd,
143
- placeholderIds: settings.placeholderIds,
144
- intervalPosts: settings.intervalPosts,
145
- enableCategoryAds: settings.enableCategoryAds,
146
- 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,
147
189
  categoryPlaceholderIds: settings.categoryPlaceholderIds,
148
- intervalCategories: settings.intervalCategories,
149
- enableMessageAds: settings.enableMessageAds,
150
- showFirstMessageAd: settings.showFirstMessageAd,
151
- messagePlaceholderIds: settings.messagePlaceholderIds,
152
- messageIntervalPosts: settings.messageIntervalPosts,
190
+ intervalCategories: settings.intervalCategories,
191
+ enableMessageAds: settings.enableMessageAds,
192
+ showFirstMessageAd: settings.showFirstMessageAd,
193
+ messagePlaceholderIds: settings.messagePlaceholderIds,
194
+ messageIntervalPosts: settings.messageIntervalPosts,
153
195
  });
154
196
  });
155
197
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.16",
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,42 +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
- poolSig: null,
84
-
85
- pools: { topics: [], posts: [], categories: [] },
86
- cursors: { topics: 0, posts: 0, categories: 0 },
87
- mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
88
- lastShow: new Map(), // id → timestamp dernier show
89
-
90
- io: null,
91
- ioMargin: null,
92
- domObs: null,
93
- mutGuard: 0, // compteur internalMutation
94
-
95
- inflight: 0,
96
- pending: [],
97
- pendingSet: new Set(),
98
-
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
99
130
  runQueued: false,
100
131
  burstActive: false,
101
132
  burstDeadline: 0,
@@ -104,8 +135,12 @@
104
135
  };
105
136
 
106
137
  let blockedUntil = 0;
107
- const isBlocked = () => Date.now() < blockedUntil;
108
- 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]'));
109
144
 
110
145
  function mutate(fn) {
111
146
  S.mutGuard++;
@@ -123,44 +158,22 @@
123
158
  return S.cfg;
124
159
  }
125
160
 
126
- function initPools(cfg) {
127
- // (Perf) Ne reparse les pools que si les strings ont changé.
128
- const sig = `${cfg.placeholderIds || ''}§${cfg.messagePlaceholderIds || ''}§${cfg.categoryPlaceholderIds || ''}`;
129
- if (S.poolSig === sig) return;
130
- S.poolSig = sig;
131
-
132
- S.pools.topics = parseIds(cfg.placeholderIds);
133
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
134
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
135
-
136
- // Réinitialise les curseurs si une pool change
137
- S.cursors.topics = 0;
138
- S.cursors.posts = 0;
139
- S.cursors.categories = 0;
140
- }
141
-
142
161
  function parseIds(raw) {
143
- // Accepte : un ID par ligne, ou séparés par virgules/espaces (ACP le mentionne).
144
162
  const out = [], seen = new Set();
145
- const parts = String(raw || '')
146
- .replace(/\r/g, '\n')
147
- .split(/[\n\s,]+/)
148
- .map(s => s.trim())
149
- .filter(Boolean);
150
-
151
- for (const v of parts) {
163
+ for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
152
164
  const n = parseInt(v, 10);
153
165
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
154
166
  }
155
167
  return out;
156
168
  }
157
169
 
158
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
159
-
160
- const isFilled = (n) =>
161
- !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
162
-
163
- 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
+ }
164
177
 
165
178
  // ── Page identity ──────────────────────────────────────────────────────────
166
179
 
@@ -184,13 +197,13 @@
184
197
  return 'other';
185
198
  }
186
199
 
187
- // ── DOM helpers ────────────────────────────────────────────────────────────
200
+ // ── Items DOM ──────────────────────────────────────────────────────────────
188
201
 
189
202
  function getPosts() {
190
203
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
191
204
  if (!el.isConnected) return false;
192
205
  if (!el.querySelector('[component="post/content"]')) return false;
193
- const p = el.parentElement?.closest('[component="post"][data-pid]');
206
+ const p = el.parentElement?.closest(SEL.post);
194
207
  if (p && p !== el) return false;
195
208
  return el.getAttribute('component') !== 'post/parent';
196
209
  });
@@ -199,53 +212,87 @@
199
212
  const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
200
213
  const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
201
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
+
202
254
  function adjacentWrap(el) {
203
- return !!(
204
- el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
205
- el.previousElementSibling?.classList?.contains(WRAP_CLASS)
206
- );
255
+ return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
207
256
  }
208
257
 
209
- // ── Ancres stables ────────────────────────────────────────────────────────
258
+ // ── Ancres stables ─────────────────────────────────────────────────────────
210
259
 
211
260
  /**
212
- * Retourne l'identifiant stable de l'élément selon son kindClass.
213
- * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
214
- * 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.
215
263
  */
216
- function stableId(kindClass, el) {
217
- const attr = KIND[kindClass]?.anchorAttr;
264
+ function stableId(klass, el) {
265
+ const attr = KIND[klass]?.anchorAttr;
218
266
  if (attr) {
219
267
  const v = el.getAttribute(attr);
220
268
  if (v !== null && v !== '') return v;
221
269
  }
222
- // Fallback : position dans le parent
223
- try {
224
- let i = 0;
225
- for (const s of el.parentElement?.children ?? []) {
226
- if (s === el) return `i${i}`;
227
- i++;
228
- }
229
- } catch (_) {}
270
+ let i = 0;
271
+ for (const s of el.parentElement?.children ?? []) {
272
+ if (s === el) return `i${i}`;
273
+ i++;
274
+ }
230
275
  return 'i0';
231
276
  }
232
277
 
233
- const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
278
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
234
279
 
235
- function findWrap(anchorKey) {
236
- try {
237
- return document.querySelector(
238
- `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
239
- );
240
- } catch (_) { return null; }
280
+ function findWrap(key) {
281
+ const w = S.wrapByKey.get(key);
282
+ return (w?.isConnected) ? w : null;
241
283
  }
242
284
 
243
285
  // ── Pool ───────────────────────────────────────────────────────────────────
244
286
 
287
+ /**
288
+ * Retourne le prochain id disponible dans le pool (round-robin),
289
+ * ou null si tous les ids sont montés.
290
+ */
245
291
  function pickId(poolKey) {
246
292
  const pool = S.pools[poolKey];
293
+ if (!pool.length) return null;
247
294
  for (let t = 0; t < pool.length; t++) {
248
- const i = S.cursors[poolKey] % pool.length;
295
+ const i = S.cursors[poolKey] % pool.length;
249
296
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
250
297
  const id = pool[i];
251
298
  if (!S.mountedIds.has(id)) return id;
@@ -253,7 +300,69 @@
253
300
  return null;
254
301
  }
255
302
 
256
- // ── 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 ────────────────────────────────────
257
366
 
258
367
  function makeWrap(id, klass, key) {
259
368
  const w = document.createElement('div');
@@ -261,6 +370,7 @@
261
370
  w.setAttribute(A_ANCHOR, key);
262
371
  w.setAttribute(A_WRAPID, String(id));
263
372
  w.setAttribute(A_CREATED, String(ts()));
373
+ w.setAttribute(A_SHOWN, '0');
264
374
  w.style.cssText = 'width:100%;display:block;';
265
375
  const ph = document.createElement('div');
266
376
  ph.id = `${PH_PREFIX}${id}`;
@@ -270,115 +380,85 @@
270
380
  }
271
381
 
272
382
  function insertAfter(el, id, klass, key) {
273
- if (!el?.insertAdjacentElement) return null;
274
- if (findWrap(key)) return null; // ancre déjà présente
275
- 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;
276
386
  if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
277
387
  const w = makeWrap(id, klass, key);
278
388
  mutate(() => el.insertAdjacentElement('afterend', w));
279
389
  S.mountedIds.add(id);
390
+ S.wrapByKey.set(key, w);
280
391
  return w;
281
392
  }
282
393
 
283
394
  function dropWrap(w) {
284
395
  try {
396
+ const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
397
+ if (ph instanceof Element) S.io?.unobserve(ph);
285
398
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
286
399
  if (Number.isFinite(id)) S.mountedIds.delete(id);
287
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
288
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
289
- // "parameter 1 is not of type Element" sur le prochain observe).
290
- try {
291
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
292
- if (ph instanceof Element) S.io?.unobserve(ph);
293
- } catch (_) {}
400
+ const key = w.getAttribute(A_ANCHOR);
401
+ if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
294
402
  w.remove();
295
403
  } catch (_) {}
296
404
  }
297
405
 
298
- // ── Prune ──────────────────────────────────────────────────────────────────
299
-
300
- /**
301
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
302
- *
303
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
304
- * Exemples :
305
- * ezoic-ad-message → cherche [data-pid="123"]
306
- * ezoic-ad-between → cherche [data-index="5"]
307
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
308
- *
309
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
310
- */
311
- function pruneOrphans(klass) {
312
- const meta = KIND[klass];
313
- if (!meta) return;
314
-
315
- // (Perf) Construire 1 set d'ancres présentes, au lieu de querySelector par wrap.
316
- const present = new Set();
317
- try {
318
- document.querySelectorAll(meta.sel).forEach(el => {
319
- const v = el.getAttribute(meta.anchorAttr);
320
- if (v !== null && v !== '') present.add(String(v));
321
- });
322
- } catch (_) {}
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
+ });
323
431
 
324
432
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
325
- 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
326
435
 
327
436
  const key = w.getAttribute(A_ANCHOR) ?? '';
328
- const sid = key.slice(klass.length + 1); // après "kindClass:"
437
+ const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
329
438
  if (!sid) { mutate(() => dropWrap(w)); return; }
330
439
 
331
- if (!present.has(String(sid))) mutate(() => dropWrap(w));
440
+ if (!anchors.has(String(sid))) mutate(() => dropWrap(w));
332
441
  });
333
442
  }
334
- // ── Decluster ──────────────────────────────────────────────────────────────
335
-
336
- /**
337
- * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
338
- * Priorité : filled > en grâce (fill en cours) > vide.
339
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
340
- */
341
- function decluster(klass) {
342
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
343
- // Grace sur le wrap courant : on le saute entièrement
344
- const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
345
- if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
346
-
347
- let prev = w.previousElementSibling, steps = 0;
348
- while (prev && steps++ < 3) {
349
- if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
350
-
351
- const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
352
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
353
-
354
- if (!isFilled(w)) mutate(() => dropWrap(w));
355
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
356
- break;
357
- }
358
- }
359
- }
360
443
 
361
444
  // ── Injection ──────────────────────────────────────────────────────────────
362
445
 
363
446
  /**
364
- * Ordinal 0-based pour le calcul de l'intervalle.
365
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
366
- * 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.
367
449
  */
368
450
  function ordinal(klass, el) {
369
- const di = el.getAttribute('data-index');
370
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
371
- // Fallback positionnel
372
- try {
373
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
374
- if (tag) {
375
- let i = 0;
376
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
377
- if (n === el) return i;
378
- i++;
379
- }
380
- }
381
- } 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
+ }
382
462
  return 0;
383
463
  }
384
464
 
@@ -387,23 +467,25 @@
387
467
  let inserted = 0;
388
468
 
389
469
  for (const el of items) {
390
- if (inserted >= MAX_INSERTS_PER_RUN) break;
470
+ if (inserted >= MAX_INSERTS_RUN) break;
391
471
  if (!el?.isConnected) continue;
392
472
 
393
- const ord = ordinal(klass, el);
394
- const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
395
- if (!isTarget) continue;
396
-
473
+ const ord = ordinal(klass, el);
474
+ if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
397
475
  if (adjacentWrap(el)) continue;
398
476
 
399
- const key = makeAnchorKey(klass, el);
400
- if (findWrap(key)) continue; // déjà là → pas de pickId inutile
477
+ const key = anchorKey(klass, el);
478
+ if (findWrap(key)) continue;
401
479
 
402
480
  const id = pickId(poolKey);
403
- if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
404
-
405
- const w = insertAfter(el, id, klass, key);
406
- 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
+ }
407
489
  }
408
490
  return inserted;
409
491
  }
@@ -411,14 +493,7 @@
411
493
  // ── IntersectionObserver & Show ────────────────────────────────────────────
412
494
 
413
495
  function getIO() {
414
- const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
415
- if (S.io && S.ioMargin === margin) return S.io;
416
- // Si la marge doit changer (resize/orientation), on recrée l'observer.
417
- if (S.io && S.ioMargin !== margin) {
418
- try { S.io.disconnect(); } catch (_) {}
419
- S.io = null;
420
- }
421
- S.ioMargin = margin;
496
+ if (S.io) return S.io;
422
497
  try {
423
498
  S.io = new IntersectionObserver(entries => {
424
499
  for (const e of entries) {
@@ -427,7 +502,7 @@
427
502
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
428
503
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
429
504
  }
430
- }, { root: null, rootMargin: margin, threshold: 0 });
505
+ }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
431
506
  } catch (_) { S.io = null; }
432
507
  return S.io;
433
508
  }
@@ -478,7 +553,6 @@
478
553
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
479
554
  S.lastShow.set(id, t);
480
555
 
481
- // Horodater le show sur le wrap pour grace period + emptyCheck
482
556
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
483
557
 
484
558
  window.ezstandalone = window.ezstandalone || {};
@@ -499,7 +573,6 @@
499
573
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
500
574
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
501
575
  if (!wrap || !ph?.isConnected) return;
502
- // Un show plus récent → ne pas toucher
503
576
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
504
577
  wrap.classList.toggle('is-empty', !isFilled(ph));
505
578
  } catch (_) {}
@@ -507,6 +580,10 @@
507
580
  }
508
581
 
509
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
510
587
 
511
588
  function patchShowAds() {
512
589
  const apply = () => {
@@ -518,7 +595,7 @@
518
595
  const orig = ez.showAds.bind(ez);
519
596
  ez.showAds = function (...args) {
520
597
  if (isBlocked()) return;
521
- 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;
522
599
  const seen = new Set();
523
600
  for (const v of ids) {
524
601
  const id = parseInt(v, 10);
@@ -537,7 +614,7 @@
537
614
  }
538
615
  }
539
616
 
540
- // ── Core run ───────────────────────────────────────────────────────────────
617
+ // ── Core ───────────────────────────────────────────────────────────────────
541
618
 
542
619
  async function runCore() {
543
620
  if (isBlocked()) return 0;
@@ -552,30 +629,30 @@
552
629
 
553
630
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
554
631
  if (!normBool(cfgEnable)) return 0;
555
- const items = getItems();
556
632
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
557
- pruneOrphans(klass);
558
- const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
559
- if (n) decluster(klass);
560
- return n;
633
+ return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
561
634
  };
562
635
 
563
636
  if (kind === 'topic') return exec(
564
637
  'ezoic-ad-message', getPosts,
565
638
  cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
566
639
  );
567
- if (kind === 'categoryTopics') return exec(
568
- 'ezoic-ad-between', getTopics,
569
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
570
- );
571
- 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(
572
650
  'ezoic-ad-categories', getCategories,
573
651
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
574
652
  );
575
- return 0;
576
653
  }
577
654
 
578
- // ── Scheduler / Burst ──────────────────────────────────────────────────────
655
+ // ── Scheduler ──────────────────────────────────────────────────────────────
579
656
 
580
657
  function scheduleRun(cb) {
581
658
  if (S.runQueued) return;
@@ -593,10 +670,8 @@
593
670
  if (isBlocked()) return;
594
671
  const t = ts();
595
672
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
596
- S.lastBurstTs = t;
597
-
598
- const pk = pageKey();
599
- S.pageKey = pk;
673
+ S.lastBurstTs = t;
674
+ S.pageKey = pageKey();
600
675
  S.burstDeadline = t + 2000;
601
676
 
602
677
  if (S.burstActive) return;
@@ -604,7 +679,7 @@
604
679
  S.burstCount = 0;
605
680
 
606
681
  const step = () => {
607
- if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
682
+ if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
608
683
  S.burstActive = false; return;
609
684
  }
610
685
  S.burstCount++;
@@ -616,17 +691,18 @@
616
691
  step();
617
692
  }
618
693
 
619
- // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
694
+ // ── Cleanup navigation ─────────────────────────────────────────────────────
620
695
 
621
696
  function cleanup() {
622
697
  blockedUntil = ts() + 1500;
623
698
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
624
699
  S.cfg = null;
625
- S.poolSig = null;
700
+ S.poolsReady = false;
626
701
  S.pools = { topics: [], posts: [], categories: [] };
627
702
  S.cursors = { topics: 0, posts: 0, categories: 0 };
628
703
  S.mountedIds.clear();
629
704
  S.lastShow.clear();
705
+ S.wrapByKey.clear();
630
706
  S.inflight = 0;
631
707
  S.pending = [];
632
708
  S.pendingSet.clear();
@@ -634,19 +710,19 @@
634
710
  S.runQueued = false;
635
711
  }
636
712
 
637
- // ── DOM Observer ───────────────────────────────────────────────────────────
713
+ // ── MutationObserver ───────────────────────────────────────────────────────
638
714
 
639
715
  function ensureDomObserver() {
640
716
  if (S.domObs) return;
717
+ const allSel = [SEL.post, SEL.topic, SEL.category];
641
718
  S.domObs = new MutationObserver(muts => {
642
719
  if (S.mutGuard > 0 || isBlocked()) return;
643
720
  for (const m of muts) {
644
- if (!m.addedNodes?.length) continue;
645
721
  for (const n of m.addedNodes) {
646
722
  if (n.nodeType !== 1) continue;
647
- if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
648
- n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
649
- 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;} })) {
650
726
  requestBurst(); return;
651
727
  }
652
728
  }
@@ -660,7 +736,14 @@
660
736
  function muteConsole() {
661
737
  if (window.__nbbEzMuted) return;
662
738
  window.__nbbEzMuted = true;
663
- 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
+ ];
664
747
  for (const m of ['log', 'info', 'warn', 'error']) {
665
748
  const orig = console[m];
666
749
  if (typeof orig !== 'function') continue;
@@ -672,29 +755,18 @@
672
755
  }
673
756
 
674
757
  function ensureTcfLocator() {
675
- // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
676
- // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
677
- // iframe du DOM (vidage partiel du body), ce qui provoque :
678
- // "Cannot read properties of null (reading 'postMessage')"
679
- // "Cannot set properties of null (setting 'addtlConsent')"
680
- // Solution : la recrée immédiatement si elle disparaît, via un observer.
681
758
  try {
682
759
  if (!window.__tcfapi && !window.__cmp) return;
683
-
684
760
  const inject = () => {
685
761
  if (document.getElementById('__tcfapiLocator')) return;
686
762
  const f = document.createElement('iframe');
687
763
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
688
764
  (document.body || document.documentElement).appendChild(f);
689
765
  };
690
-
691
766
  inject();
692
-
693
- // Observer dédié — si quelqu'un retire l'iframe, on la remet.
694
767
  if (!window.__nbbTcfObs) {
695
- window.__nbbTcfObs = new MutationObserver(() => inject());
696
- window.__nbbTcfObs.observe(document.documentElement,
697
- { childList: true, subtree: true });
768
+ window.__nbbTcfObs = new MutationObserver(inject);
769
+ window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
698
770
  }
699
771
  } catch (_) {}
700
772
  }
@@ -704,10 +776,10 @@
704
776
  const head = document.head;
705
777
  if (!head) return;
706
778
  for (const [rel, href, cors] of [
707
- ['preconnect', 'https://g.ezoic.net', true],
708
- ['preconnect', 'https://go.ezoic.net', true],
709
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
710
- ['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 ],
711
783
  ['dns-prefetch', 'https://g.ezoic.net', false],
712
784
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
713
785
  ]) {
@@ -721,7 +793,7 @@
721
793
  }
722
794
  }
723
795
 
724
- // ── Bindings NodeBB ────────────────────────────────────────────────────────
796
+ // ── Bindings ───────────────────────────────────────────────────────────────
725
797
 
726
798
  function bindNodeBB() {
727
799
  const $ = window.jQuery;
@@ -732,19 +804,16 @@
732
804
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
733
805
  S.pageKey = pageKey();
734
806
  blockedUntil = 0;
735
- muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
736
- getIO(); ensureDomObserver(); requestBurst();
807
+ muteConsole(); ensureTcfLocator(); warmNetwork();
808
+ patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
737
809
  });
738
810
 
739
- const BURST_EVENTS = [
740
- 'action:ajaxify.contentLoaded',
741
- 'action:posts.loaded', 'action:topics.loaded',
811
+ const burstEvts = [
812
+ 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
742
813
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
743
814
  ].map(e => `${e}.nbbEzoic`).join(' ');
815
+ $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
744
816
 
745
- $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
746
-
747
- // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
748
817
  try {
749
818
  require(['hooks'], hooks => {
750
819
  if (typeof hooks?.on !== 'function') return;
@@ -756,22 +825,6 @@
756
825
  } catch (_) {}
757
826
  }
758
827
 
759
-
760
- function bindResize() {
761
- let t = null;
762
- window.addEventListener('resize', () => {
763
- clearTimeout(t);
764
- t = setTimeout(() => {
765
- try { getIO(); } catch (_) {}
766
- // Ré-observer les placeholders existants (si IO recréé)
767
- try {
768
- document.querySelectorAll(`.${WRAP_CLASS} [id^="${PH_PREFIX}"]`).forEach(ph => {
769
- if (ph instanceof Element) { try { S.io?.observe(ph); } catch (_) {} }
770
- });
771
- } catch (_) {}
772
- }, 200);
773
- }, { passive: true });
774
- }
775
828
  function bindScroll() {
776
829
  let ticking = false;
777
830
  window.addEventListener('scroll', () => {
@@ -791,7 +844,6 @@
791
844
  getIO();
792
845
  ensureDomObserver();
793
846
  bindNodeBB();
794
- bindResize();
795
847
  bindScroll();
796
848
  blockedUntil = 0;
797
849
  requestBurst();