nodebb-plugin-ezoic-infinite 1.4.90 → 1.4.92

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.
@@ -1,776 +1,827 @@
1
1
  (function () {
2
2
  'use strict';
3
-
4
3
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
-
6
4
  const SELECTORS = {
7
- topicItem: 'li[component="category/topic"]',
8
- postItem: '[component="post"][data-pid]',
9
- categoryItem: 'li[component="categories/category"]',
10
- };
5
+ topicItem: 'li[component="category/topic"]',
6
+ postItem: '[component="post"][data-pid]',
7
+ categoryItem: 'li[component="categories/category"]',
8
+ }, WRAP_CLASS = 'ezoic-ad';
9
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-', MAX_INSERTS_PER_RUN = 3;
11
10
 
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
-
15
- const MAX_INSERTS_PER_RUN = 2;
16
-
17
- const state = {
18
- pageKey: null,
19
- cfg: null,
20
- cfgPromise: null,
21
-
22
- poolTopics: [],
23
- poolPosts: [],
24
- poolCategories: [],
25
-
26
- usedTopics: new Set(),
27
- usedPosts: new Set(),
28
- usedCategories: new Set(),
29
-
30
- // wrappers currently on page (FIFO for recycling)
31
- liveBetween: [],
32
- liveMessage: [],
33
- liveCategory: [],
34
- usedCategories: new Set(),
35
-
36
- lastShowById: new Map(),
37
- pendingById: new Set(),
38
- retryById: new Map(),
39
- retryTimer: null,
40
- retryQueue: [],
41
- retryQueueSet: new Set(),
42
- retryQueueRunning: false,
43
- badIds: new Set(),
44
- definedIds: new Set(),
45
-
46
- scheduled: false,
47
- timer: null,
48
-
49
- obs: null,
50
- attempts: 0,
51
- };
11
+ // Nécessaire pour savoir si on doit appeler destroyPlaceholders avant recyclage.
12
+ const sessionDefinedIds = new Set();
13
+
14
+ const insertingIds = new Set(), state = {
15
+ pageKey: null,
16
+ cfg: null,
17
+ cfgPromise: null,
18
+
19
+ poolTopics: [],
20
+ poolPosts: [],
21
+ poolCategories: [],
22
+
23
+ usedTopics: new Set(),
24
+ usedPosts: new Set(),
25
+ usedCategories: new Set(),
26
+
27
+ lastShowById: new Map(),
28
+ pendingById: new Set(),
29
+ definedIds: new Set(),
30
+
31
+ scheduled: false,
32
+ timer: null,
33
+
34
+ obs: null,
35
+ activeTimeouts: new Set(),
36
+ lastScrollRun: 0, };
52
37
 
53
38
  function normalizeBool(v) {
54
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
39
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
55
40
  }
56
41
 
57
42
  function uniqInts(lines) {
58
- const out = [];
59
- const seen = new Set();
60
- for (const v of lines) {
61
- const n = parseInt(v, 10);
62
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
63
- seen.add(n);
64
- out.push(n);
65
- }
66
- }
67
- return out;
43
+ const out = [], seen = new Set();
44
+ for (const v of lines) {
45
+ const n = parseInt(v, 10);
46
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
47
+ seen.add(n);
48
+ out.push(n);
49
+ }
50
+ }
51
+ return out;
68
52
  }
69
53
 
70
54
  function parsePool(raw) {
71
- if (!raw) return [];
72
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
73
- return uniqInts(lines);
55
+ if (!raw) return [];
56
+ const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
57
+ return uniqInts(lines);
74
58
  }
75
59
 
76
60
  function getPageKey() {
77
- try {
78
- const ax = window.ajaxify;
79
- if (ax && ax.data) {
80
- if (ax.data.tid) return `topic:${ax.data.tid}`;
81
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
82
- }
83
- } catch (e) {}
84
- return window.location.pathname;
61
+ try {
62
+ const ax = window.ajaxify;
63
+ if (ax && ax.data) {
64
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
65
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
66
+ }
67
+ } catch (e) {}
68
+ return window.location.pathname;
85
69
  }
86
70
 
87
71
  function getKind() {
88
- const p = window.location.pathname || '';
89
- if (/^\/topic\//.test(p)) return 'topic';
90
- if (/^\/category\//.test(p)) return 'categoryTopics';
91
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
92
- // fallback by DOM
93
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
94
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
95
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
96
- return 'other';
72
+ const p = window.location.pathname || '';
73
+ if (/^\/topic\//.test(p)) return 'topic';
74
+ if (/^\/category\//.test(p)) return 'categoryTopics';
75
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
76
+ // fallback by DOM
77
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
78
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
79
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
80
+ return 'other';
97
81
  }
98
82
 
99
83
  function getTopicItems() {
100
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
84
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
101
85
  }
102
86
 
103
87
  function getCategoryItems() {
104
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
88
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
105
89
  }
106
90
 
107
91
  function getPostContainers() {
108
- const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
109
- return nodes.filter((el) => {
110
- if (!el || !el.isConnected) return false;
111
- if (!el.querySelector('[component="post/content"]')) return false;
112
- const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
113
- if (parentPost && parentPost !== el) return false;
114
- if (el.getAttribute('component') === 'post/parent') return false;
115
- return true;
116
- });
92
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
93
+ return nodes.filter((el) => {
94
+ if (!el || !el.isConnected) return false;
95
+ if (!el.querySelector('[component="post/content"]')) return false;
96
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
97
+ if (parentPost && parentPost !== el) return false;
98
+ if (el.getAttribute('component') === 'post/parent') return false;
99
+ return true;
100
+ });
117
101
  }
118
102
 
119
-
120
103
  function safeRect(el) {
121
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
104
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
122
105
  }
123
106
 
124
107
  function destroyPlaceholderIds(ids) {
125
- if (!ids || !ids.length) return;
126
- // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
127
- const filtered = ids.filter((id) => {
128
- try { return state.definedIds && state.definedIds.has(id); } catch (e) { return true; }
129
- });
130
- if (!filtered.length) return;
131
-
132
- const call = () => {
133
- try {
134
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
135
- window.ezstandalone.destroyPlaceholders(filtered);
136
- }
137
- } catch (e) {}
138
- };
139
- try {
140
- window.ezstandalone = window.ezstandalone || {};
141
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
142
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
143
- else window.ezstandalone.cmd.push(call);
144
- } catch (e) {}
145
- }
146
- };
147
- try {
148
- window.ezstandalone = window.ezstandalone || {};
149
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
150
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
151
- else window.ezstandalone.cmd.push(call);
152
- } catch (e) {}
108
+ if (!ids || !ids.length) return;
109
+ // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
110
+ const filtered = ids.filter((id) => {
111
+ // Utiliser sessionDefinedIds (survit aux navigations) plutôt que state.definedIds
112
+ try { return sessionDefinedIds.has(id); } catch (e) { return true; }
113
+ });
114
+ if (!filtered.length) return;
115
+
116
+ const call = () => {
117
+ try {
118
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
119
+ window.ezstandalone.destroyPlaceholders(filtered);
120
+ }
121
+ } catch (e) {}
122
+ };
123
+ try {
124
+ window.ezstandalone = window.ezstandalone || {};
125
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
126
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
127
+ else window.ezstandalone.cmd.push(call);
128
+ } catch (e) {}
129
+ }
130
+
131
+
132
+ // Nettoyer les wrappers vides (sans pub) pour éviter espaces verticaux
133
+ function cleanupEmptyWrappers() {
134
+ try {
135
+ document.querySelectorAll('.ezoic-ad').forEach(wrapper => {
136
+ const ph = wrapper.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
137
+ if (ph && ph.children.length === 0) {
138
+ // Placeholder vide après 3s = pub non chargée
139
+ setTimeout(() => {
140
+ if (ph.children.length === 0) {
141
+ wrapper.remove();
142
+ }
143
+ }, 3000);
144
+ }
145
+ });
146
+ } catch (e) {}
153
147
  }
154
148
 
155
149
  function getRecyclable(liveArr) {
156
- const margin = 1800;
157
- for (let i = 0; i < liveArr.length; i++) {
158
- const entry = liveArr[i];
159
- if (!entry || !entry.wrap || !entry.wrap.isConnected) { liveArr.splice(i, 1); i--; continue; }
160
- const r = safeRect(entry.wrap);
161
- if (r && r.bottom < -margin) {
162
- liveArr.splice(i, 1);
163
- return entry;
164
- }
165
- }
166
- return null;
167
- }
168
-
169
- function moveWrapAfter(wrap, target, kindClass, afterPos) {
170
- try {
171
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
172
- wrap.setAttribute('data-ezoic-after', String(afterPos));
173
- target.insertAdjacentElement('afterend', wrap);
174
- return true;
175
- } catch (e) {
176
- return false;
177
- }
150
+ const margin = 600;
151
+ for (let i = 0; i < liveArr.length; i++) {
152
+ const entry = liveArr[i];
153
+ if (!entry || !entry.wrap || !entry.wrap.isConnected) { liveArr.splice(i, 1); i--; continue; }
154
+ const r = safeRect(entry.wrap);
155
+ if (r && r.bottom < -margin) {
156
+ liveArr.splice(i, 1);
157
+ return entry;
158
+ }
159
+ }
160
+ return null;
178
161
  }
179
162
 
180
163
  function pickId(pool, liveArr) {
181
- if (pool.length) return { id: pool.shift(), recycled: null };
182
- const recycled = getRecyclable(liveArr);
183
- if (recycled) return { id: recycled.id, recycled };
184
- return { id: null, recycled: null };
164
+ if (pool.length) return { id: pool.shift(), recycled: null };
165
+ const recycled = getRecyclable(liveArr);
166
+ if (recycled) return { id: recycled.id, recycled };
167
+ return { id: null, recycled: null };
185
168
  }
186
169
 
187
-
188
170
  function resetPlaceholderInWrap(wrap, id) {
189
- try {
190
- if (!wrap) return;
191
- try { wrap.removeAttribute('data-ezoic-filled'); } catch (e) {}
192
- try { if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; } } catch (e) {}
193
- const old = wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
194
- if (old) old.remove();
195
- // Remove any leftover markup inside wrapper
196
- wrap.querySelectorAll && wrap.querySelectorAll('iframe, ins').forEach(n => n.remove());
197
- const ph = document.createElement('div');
198
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
199
- wrap.appendChild(ph);
200
- } catch (e) {}
171
+ try {
172
+ if (!wrap) return;
173
+ try { wrap.removeAttribute('data-ezoic-filled'); } catch (e) {}
174
+ if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
175
+ const old = wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
176
+ if (old) old.remove();
177
+ // Remove any leftover markup inside wrapper
178
+ wrap.querySelectorAll && wrap.querySelectorAll('iframe, ins').forEach(n => n.remove());
179
+ const ph = document.createElement('div');
180
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
181
+ wrap.appendChild(ph);
182
+ } catch (e) {}
201
183
  }
202
184
 
203
185
  function isAdjacentAd(target) {
204
- if (!target || !target.nextElementSibling) return false;
205
- const next = target.nextElementSibling;
206
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
207
- return false;
186
+ if (!target || !target.nextElementSibling) return false;
187
+ const next = target.nextElementSibling;
188
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
189
+ return false;
208
190
  }
209
191
 
210
192
  function isPrevAd(target) {
211
- if (!target || !target.previousElementSibling) return false;
212
- const prev = target.previousElementSibling;
213
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
214
- return false;
193
+ if (!target || !target.previousElementSibling) return false;
194
+ const prev = target.previousElementSibling;
195
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
196
+ return false;
215
197
  }
216
198
 
217
199
  function buildWrap(id, kindClass, afterPos) {
218
- const wrap = document.createElement('div');
219
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
220
- wrap.setAttribute('data-ezoic-after', String(afterPos));
221
- wrap.style.width = '100%';
200
+ const wrap = document.createElement('div');
201
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
202
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
203
+ wrap.style.width = '100%';
222
204
 
223
- const ph = document.createElement('div');
224
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
225
- wrap.appendChild(ph);
226
- return wrap;
205
+ const ph = document.createElement('div');
206
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
207
+ wrap.appendChild(ph);
208
+ return wrap;
227
209
  }
228
210
 
229
211
  function findWrap(kindClass, afterPos) {
230
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
212
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
231
213
  }
232
214
 
233
215
  function insertAfter(target, id, kindClass, afterPos) {
234
- if (!target || !target.insertAdjacentElement) return null;
235
- if (findWrap(kindClass, afterPos)) return null;
236
- const wrap = buildWrap(id, kindClass, afterPos);
237
- target.insertAdjacentElement('afterend', wrap);
238
- attachFillObserver(wrap, id);
239
- return wrap;
240
- }
216
+ if (!target || !target.insertAdjacentElement) return null;
217
+ if (findWrap(kindClass, afterPos)) return null;
241
218
 
242
- function destroyUsedPlaceholders() {
243
- const ids = [];
244
- try {
245
- state.usedTopics.forEach((id) => ids.push(id));
246
- state.usedPosts.forEach((id) => ids.push(id));
247
- state.usedCategories && state.usedCategories.forEach((id) => ids.push(id));
248
- } catch (e) {}
249
- destroyPlaceholderIds(ids);
250
- }
251
- } catch (e) {}
252
- };
219
+ // CRITICAL: Double-lock pour éviter race conditions sur les doublons
220
+ // 1. Vérifier qu'aucun autre thread n'est en train d'insérer cet ID
221
+ if (insertingIds.has(id)) return null;
222
+
223
+ // 2. Vérifier qu'aucun placeholder avec cet ID n'existe déjà dans le DOM
224
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
225
+ if (existingPh && existingPh.isConnected) return null;
253
226
 
254
- try {
255
- window.ezstandalone = window.ezstandalone || {};
256
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
257
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
258
- else window.ezstandalone.cmd.push(call);
259
- } catch (e) {}
227
+ // Acquérir le lock
228
+ insertingIds.add(id);
229
+
230
+ try {
231
+ const wrap = buildWrap(id, kindClass, afterPos);
232
+ target.insertAdjacentElement('afterend', wrap);
233
+ attachFillObserver(wrap, id);
234
+ return wrap;
235
+ } finally {
236
+ // Libérer le lock après 100ms (le temps que le DOM soit stable)
237
+ setTimeout(() => insertingIds.delete(id), 50);
238
+ }
260
239
  }
261
240
 
262
- function patchShowAds() {
263
- // Minimal safety net: batch showAds can be triggered by other scripts; split into individual calls.
264
- try {
265
- window.ezstandalone = window.ezstandalone || {};
266
- const ez = window.ezstandalone;
267
- if (ez.__nodebbEzoicPatched) return;
268
- if (typeof ez.showAds !== 'function') return;
269
-
270
- ez.__nodebbEzoicPatched = true;
271
- const orig = ez.showAds;
272
-
273
- ez.showAds = function (arg) {
274
- if (Array.isArray(arg)) {
275
- const seen = new Set();
276
- for (const v of arg) {
277
- const id = parseInt(v, 10);
278
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
279
- seen.add(id);
280
- try { orig.call(ez, id); } catch (e) {}
281
- }
282
- return;
283
- }
284
- return orig.apply(ez, arguments);
285
- };
286
- } catch (e) {}
241
+ function destroyUsedPlaceholders() {
242
+ const ids = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
243
+ if (ids.length) destroyPlaceholderIds(ids);
287
244
  }
288
245
 
246
+ function patchShowAds() {
247
+ // Minimal safety net: batch showAds can be triggered by other scripts; split into individual calls.
248
+ // Also ensures the patch is applied even if Ezoic loads after our script.
249
+ const applyPatch = () => {
250
+ try {
251
+ window.ezstandalone = window.ezstandalone || {}, ez = window.ezstandalone;
252
+ if (window.__nodebbEzoicPatched) return;
253
+ if (typeof ez.showAds !== 'function') return;
254
+
255
+ window.__nodebbEzoicPatched = true;
256
+ const orig = ez.showAds;
257
+
258
+ ez.showAds = function (arg) {
259
+ if (Array.isArray(arg)) {
260
+ const seen = new Set();
261
+ for (const v of arg) {
262
+ const id = parseInt(v, 10);
263
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
264
+ seen.add(id);
265
+ try { orig.call(ez, id); } catch (e) {}
266
+ }
267
+ return;
268
+ }
269
+ return orig.apply(ez, arguments);
270
+ };
271
+ } catch (e) {}
272
+ };
289
273
 
274
+ applyPatch();
275
+ // Si Ezoic n'est pas encore chargé, appliquer le patch via sa cmd queue
276
+ if (!window.__nodebbEzoicPatched) {
277
+ try {
278
+ window.ezstandalone = window.ezstandalone || {};
279
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
280
+ window.ezstandalone.cmd.push(applyPatch);
281
+ } catch (e) {}
282
+ }
283
+ }
290
284
 
291
285
  function markFilled(wrap) {
292
- try {
293
- if (!wrap) return;
294
- try { wrap.removeAttribute('data-ezoic-filled'); } catch (e) {}
295
- try { if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; } } catch (e) {}
296
- wrap.setAttribute('data-ezoic-filled', '1');
297
- } catch (e) {}
286
+ try {
287
+ if (!wrap) return;
288
+ // Disconnect the fill observer first (no need to remove+re-add the attribute)
289
+ if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
290
+ wrap.setAttribute('data-ezoic-filled', '1');
291
+ } catch (e) {}
298
292
  }
299
293
 
300
294
  function isWrapMarkedFilled(wrap) {
301
- try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
295
+ try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
302
296
  }
303
297
 
304
298
  function attachFillObserver(wrap, id) {
305
- try {
306
- const ph = wrap && wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
307
- if (!ph) return;
308
- // Already filled?
309
- if (ph.childNodes && ph.childNodes.length > 0) {
310
- markFilled(wrap);
311
- state.definedIds && state.definedIds.add(id);
312
- return;
313
- }
314
- const obs = new MutationObserver(() => {
315
- if (ph.childNodes && ph.childNodes.length > 0) {
316
- markFilled(wrap);
317
- try { state.definedIds && state.definedIds.add(id); } catch (e) {}
318
- try { obs.disconnect(); } catch (e) {}
319
- }
320
- });
321
- obs.observe(ph, { childList: true, subtree: true });
322
- // Keep a weak reference on the wrapper so we can disconnect on recycle/remove
323
- wrap.__ezoicFillObs = obs;
324
- } catch (e) {}
299
+ try {
300
+ const ph = wrap && wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
301
+ if (!ph) return;
302
+ // Already filled?
303
+ if (ph.childNodes && ph.childNodes.length > 0) {
304
+ markFilled(wrap);
305
+ state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id);
306
+ return;
307
+ }
308
+ const obs = new MutationObserver(() => {
309
+ if (ph.childNodes && ph.childNodes.length > 0) {
310
+ markFilled(wrap);
311
+ try { state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id); } catch (e) {}
312
+ try { obs.disconnect(); } catch (e) {}
313
+ }
314
+ });
315
+ obs.observe(ph, { childList: true, subtree: true });
316
+ // Keep a weak reference on the wrapper so we can disconnect on recycle/remove
317
+ wrap.__ezoicFillObs = obs;
318
+ } catch (e) {}
325
319
  }
326
320
 
327
321
  function isPlaceholderFilled(id) {
328
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
329
- if (!ph || !ph.isConnected) return false;
330
-
331
- const wrap = ph.parentElement;
332
- if (wrap && isWrapMarkedFilled(wrap)) return true;
333
-
334
- const filled = !!(ph.childNodes && ph.childNodes.length > 0);
335
- if (filled) {
336
- try { state.definedIds && state.definedIds.add(id); } catch (e) {}
337
- try { markFilled(wrap); } catch (e) {}
338
- }
339
- return filled;
340
- }
341
- return filled;
342
- }
343
-
344
- function scheduleRefill(delay = 350) {
345
- clearTimeout(state.retryTimer);
346
- state.retryTimer = setTimeout(refillUnfilled, delay);
347
- }
348
-
349
- function enqueueRetry(id) {
350
- if (!id) return;
351
- if (state.badIds && state.badIds.has(id)) return;
352
- if (state.retryQueueSet.has(id)) return;
353
- state.retryQueueSet.add(id);
354
- state.retryQueue.push(id);
355
- processRetryQueue();
356
- }
357
-
358
- function processRetryQueue() {
359
- if (state.retryQueueRunning) return;
360
- state.retryQueueRunning = true;
361
-
362
- const step = () => {
363
- const id = state.retryQueue.shift();
364
- if (!id) {
365
- state.retryQueueRunning = false;
366
- state.badIds = new Set();
367
- state.definedIds = new Set();
368
- return;
369
- }
370
- state.retryQueueSet.delete(id);
371
- // If this id was previously attempted and still empty, force a full reset before re-requesting.
372
- const attempts = (state.retryById.get(id) || 0);
373
- const phNow = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
374
- const wrapNow = phNow && phNow.parentElement;
375
- // If Ezoic already defined this id earlier but the placeholder is empty now, we must destroy+reset before re-showAds.
376
- if (wrapNow && wrapNow.isConnected && state.definedIds && state.definedIds.has(id) && !isPlaceholderFilled(id) && !isWrapMarkedFilled(wrapNow)) {
377
- destroyPlaceholderIds([id]);
378
- resetPlaceholderInWrap(wrapNow, id);
379
- }
380
- // If this id was previously attempted and still empty, force a full reset before re-requesting.
381
- if (attempts > 0 && wrapNow && wrapNow.isConnected && !isPlaceholderFilled(id) && !isWrapMarkedFilled(wrapNow)) {
382
- destroyPlaceholderIds([id]);
383
- resetPlaceholderInWrap(wrapNow, id);
384
- }
385
- callShowAdsWhenReady(id);
386
- setTimeout(step, 1100);
387
- };
388
-
389
- step();
390
- }
391
-
392
-
393
- function refillUnfilled() {
394
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
395
- let scheduledAny = false;
396
-
397
- for (const wrap of wraps) {
398
- const ph = wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
399
- if (!ph) continue;
400
- const id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
401
- if (!Number.isFinite(id) || id <= 0) continue;
402
-
403
- if (isPlaceholderFilled(id)) {
404
- state.retryById.delete(id);
405
- continue;
406
- }
407
- // If wrapper was marked filled, don't try to refill even if placeholder temporarily appears empty.
408
- if (isWrapMarkedFilled(wrap)) {
409
- state.retryById.delete(id);
410
- continue;
411
- }
412
-
413
- const tries = (state.retryById.get(id) || 0);
414
- if (tries >= 8) { state.badIds && state.badIds.add(id); continue; }
415
-
416
- const r = safeRect(wrap);
417
- if (r && (r.top > window.innerHeight + 1200 || r.bottom < -1200)) continue;
418
-
419
- state.retryById.set(id, tries + 1);
420
- enqueueRetry(id);
421
- scheduledAny = true;
422
- }
423
-
424
- if (scheduledAny) scheduleRefill(700);
322
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
323
+ if (!ph || !ph.isConnected) return false;
324
+
325
+ const wrap = ph.parentElement;
326
+ if (wrap && isWrapMarkedFilled(wrap)) return true;
327
+
328
+ const filled = !!(ph.childNodes && ph.childNodes.length > 0);
329
+ if (filled) {
330
+ try { state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id); } catch (e) {}
331
+ try { markFilled(wrap); } catch (e) {}
332
+ }
333
+ return filled;
334
+ }
335
+
336
+ // Appeler showAds() en batch selon recommandations Ezoic
337
+ // Au lieu de showAds(id1), showAds(id2)... faire showAds(id1, id2, id3...)
338
+ let batchShowAdsTimer = null;
339
+ const pendingShowAdsIds = new Set();
340
+
341
+ function scheduleShowAdsBatch(id) {
342
+ if (!id) return;
343
+
344
+ // CRITIQUE: Si cet ID a déjà été défini (sessionDefinedIds), le détruire d'abord
345
+ if (sessionDefinedIds.has(id)) {
346
+ try {
347
+ destroyPlaceholderIds([id]);
348
+ sessionDefinedIds.delete(id);
349
+ } catch (e) {}
350
+ }
351
+
352
+ // Throttle: ne pas rappeler le même ID trop vite
353
+ const now = Date.now(), last = state.lastShowById.get(id) || 0;
354
+ if (now - last < 3500) return;
355
+
356
+ // Ajouter à la batch
357
+ pendingShowAdsIds.add(id);
358
+
359
+ // Debounce: attendre 100ms pour collecter tous les IDs
360
+ clearTimeout(batchShowAdsTimer);
361
+ batchShowAdsTimer = setTimeout(() => {
362
+ if (pendingShowAdsIds.size === 0) return;
363
+
364
+ const idsArray = Array.from(pendingShowAdsIds);
365
+ pendingShowAdsIds.clear();
366
+
367
+ // Appeler showAds avec TOUS les IDs en une fois
368
+ try {
369
+ window.ezstandalone = window.ezstandalone || {};
370
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
371
+ window.ezstandalone.cmd.push(function() {
372
+ if (typeof window.ezstandalone.showAds === 'function') {
373
+ // Appel batch: showAds(id1, id2, id3...)
374
+ window.ezstandalone.showAds(...idsArray);
375
+ // Tracker tous les IDs
376
+ idsArray.forEach(id => {
377
+ state.lastShowById.set(id, Date.now());
378
+ sessionDefinedIds.add(id);
379
+ });
380
+ }
381
+ });
382
+ } catch (e) {}
383
+ }, 100);
425
384
  }
426
385
 
427
386
  function callShowAdsWhenReady(id) {
428
- if (!id) return;
429
-
430
- const now = Date.now();
431
- const last = state.lastShowById.get(id) || 0;
432
- if (now - last < 3500) return;
433
-
434
- const phId = `${PLACEHOLDER_PREFIX}${id}`;
435
-
436
- const doCall = () => {
437
- try {
438
- window.ezstandalone = window.ezstandalone || {};
439
- if (typeof window.ezstandalone.showAds === 'function') {
440
- state.lastShowById.set(id, Date.now());
441
- window.ezstandalone.showAds(id);
442
- return true;
443
- }
444
- } catch (e) {}
445
- return false;
446
- };
447
-
448
- let attempts = 0;
449
- (function waitForPh() {
450
- attempts += 1;
451
- const el = document.getElementById(phId);
452
- if (el && el.isConnected) {
453
- if (doCall()) return;
454
-
455
- if (state.pendingById.has(id)) return;
456
- state.pendingById.add(id);
457
-
458
- window.ezstandalone = window.ezstandalone || {};
459
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
460
- window.ezstandalone.cmd.push(() => {
461
- try {
462
- if (typeof window.ezstandalone.showAds === 'function') {
463
- state.pendingById.delete(id);
464
- state.lastShowById.set(id, Date.now());
465
- window.ezstandalone.showAds(id);
466
- }
467
- } catch (e) {}
468
- });
469
-
470
- let tries = 0;
471
- (function tick() {
472
- tries += 1;
473
- if (doCall() || tries >= 5) {
474
- if (tries >= 5) state.pendingById.delete(id);
475
- return;
476
- }
477
- setTimeout(tick, 700);
478
- })();
479
- return;
480
- }
481
-
482
- if (attempts < 50) setTimeout(waitForPh, 50);
483
- })();
484
- }
485
-
486
- function nextId(pool) {
487
- // backward compatible: the injector passes the pool array
488
- if (Array.isArray(pool) && pool.length) return pool.shift();
489
- return null;
387
+ if (!id) return;
388
+
389
+ const now = Date.now(), last = state.lastShowById.get(id) || 0;
390
+ if (now - last < 3500) return;
391
+
392
+ const phId = `${PLACEHOLDER_PREFIX}${id}`, doCall = () => {
393
+ try {
394
+ window.ezstandalone = window.ezstandalone || {};
395
+ if (typeof window.ezstandalone.showAds === 'function') {
396
+
397
+ state.lastShowById.set(id, Date.now());
398
+ window.ezstandalone.showAds(id);
399
+ sessionDefinedIds.add(id);
400
+ return true;
401
+ }
402
+ } catch (e) {}
403
+ return false;
404
+ };
405
+
406
+ const startPageKey = state.pageKey;
407
+ let attempts = 0;
408
+ (function waitForPh() {
409
+ // Abort if the user navigated away since this showAds was scheduled
410
+ if (state.pageKey !== startPageKey) return;
411
+ // Abort if another concurrent call is already handling this id
412
+ if (state.pendingById.has(id)) return;
413
+
414
+ attempts += 1;
415
+ const el = document.getElementById(phId);
416
+ if (el && el.isConnected) {
417
+ // CRITIQUE: Vérifier que le placeholder est VISIBLE
418
+
419
+ // Si on arrive ici, soit visible, soit timeout
420
+
421
+ // Si doCall() réussit, Ezoic est chargé et showAds a été appelé → sortir
422
+ if (doCall()) {
423
+ state.pendingById.delete(id);
424
+ return;
425
+ }
426
+
427
+ }
428
+
429
+ if (attempts < 100) {
430
+ const timeoutId = setTimeout(waitForPh, 50);
431
+ state.activeTimeouts.add(timeoutId);
432
+ }
433
+ })();
490
434
  }
491
435
 
492
436
  async function fetchConfig() {
493
- if (state.cfg) return state.cfg;
494
- if (state.cfgPromise) return state.cfgPromise;
437
+ if (state.cfg) return state.cfg;
438
+ if (state.cfgPromise) return state.cfgPromise;
495
439
 
496
- state.cfgPromise = (async () => {
497
- try {
498
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
499
- if (!res.ok) return null;
500
- state.cfg = await res.json();
501
- return state.cfg;
502
- } catch (e) {
503
- return null;
504
- } finally {
505
- state.cfgPromise = null;
506
- }
507
- })();
440
+ state.cfgPromise = (async () => {
441
+ const MAX_TRIES = 3;
442
+ let delay = 800;
443
+ for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
444
+ try {
445
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
446
+ if (res.ok) {
447
+ state.cfg = await res.json();
448
+ return state.cfg;
449
+ }
450
+ } catch (e) {}
451
+ if (attempt < MAX_TRIES) await new Promise(r => setTimeout(r, delay));
452
+ delay *= 2;
453
+ }
454
+ return null;
455
+ })();
508
456
 
509
- return state.cfgPromise;
457
+ try { return await state.cfgPromise; } finally { state.cfgPromise = null; }
510
458
  }
511
459
 
512
460
  function initPools(cfg) {
513
- if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
514
- if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
515
- if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
461
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
462
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
463
+ if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
516
464
  }
517
465
 
518
466
  function computeTargets(count, interval, showFirst) {
519
- const out = [];
520
- if (count <= 0) return out;
521
- if (showFirst) out.push(1);
522
- for (let i = 1; i <= count; i++) {
523
- if (i % interval === 0) out.push(i);
524
- }
525
- return Array.from(new Set(out)).sort((a, b) => a - b);
467
+ const out = [];
468
+ if (count <= 0) return out;
469
+ if (showFirst) out.push(1);
470
+ for (let i = 1; i <= count; i++) {
471
+ if (i % interval === 0) out.push(i);
472
+ }
473
+ return Array.from(new Set(out)).sort((a, b) => a - b);
526
474
  }
527
475
 
528
476
  function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet) {
529
- if (!items.length) return 0;
530
- const targets = computeTargets(items.length, interval, showFirst);
531
-
532
- let inserted = 0;
533
- for (const afterPos of targets) {
534
- if (inserted >= MAX_INSERTS_PER_RUN) break;
535
-
536
- const el = items[afterPos - 1];
537
- if (!el || !el.isConnected) continue;
538
-
539
- // Prevent adjacent ads (DOM-based, robust against virtualization)
540
- if (isAdjacentAd(el) || isPrevAd(el)) {
541
- continue;
542
- }
543
-
544
- // Prevent back-to-back at load
545
- const prevWrap = findWrap(kindClass, afterPos - 1);
546
- if (prevWrap) continue;
547
-
548
- if (findWrap(kindClass, afterPos)) continue;
549
-
550
- const liveArr = (kindClass === 'ezoic-ad-between') ? state.liveBetween
551
- : (kindClass === 'ezoic-ad-message') ? state.liveMessage
552
- : state.liveCategory;
553
-
554
- const pick = pickId(kindPool, liveArr);
555
- const id = pick.id;
556
- if (!id) break;
557
-
558
- let wrap = null;
559
- if (pick.recycled && pick.recycled.wrap) {
560
- // Only destroy if Ezoic has actually defined this placeholder before
561
- if (state.definedIds && state.definedIds.has(id)) {
562
- destroyPlaceholderIds([id]);
563
- }
564
- // Remove the old wrapper entirely, then create a fresh wrapper at the new position (same id)
565
- const oldWrap = pick.recycled.wrap;
566
- try { if (oldWrap && oldWrap.__ezoicFillObs) { oldWrap.__ezoicFillObs.disconnect(); } } catch (e) {}
567
- try { oldWrap && oldWrap.remove(); } catch (e) {}
568
- wrap = insertAfter(el, id, kindClass, afterPos);
569
- if (!wrap) continue;
570
- setTimeout(() => { enqueueRetry(id); }, 450);
571
- } else {
572
- usedSet.add(id);
573
- wrap = insertAfter(el, id, kindClass, afterPos);
574
- if (!wrap) continue;
575
- }
576
-
577
- liveArr.push({ id, wrap });
578
- // If adjacency ended up happening (e.g. DOM shifts), rollback this placement.
579
- if (wrap && (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))) {
580
- try { wrap.remove(); } catch (e) {}
581
- // Put id back if it was newly consumed (not recycled)
582
- if (!(pick.recycled && pick.recycled.wrap)) {
583
- try { kindPool.unshift(id); } catch (e) {}
584
- try { usedSet.delete(id); } catch (e) {}
585
- }
586
- inserted -= 0; // no-op
587
- continue;
588
- }
589
- if (!(pick.recycled && pick.recycled.wrap)) {
590
- callShowAdsWhenReady(id);
591
- }
592
- inserted += 1;
593
- }
594
- return inserted;
477
+ if (!items.length) return 0;
478
+ const targets = computeTargets(items.length, interval, showFirst);
479
+
480
+ let inserted = 0;
481
+ for (const afterPos of targets) {
482
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
483
+
484
+ const el = items[afterPos - 1];
485
+ if (!el || !el.isConnected) continue;
486
+
487
+ // Prevent adjacent ads (DOM-based, robust against virtualization)
488
+ if (isAdjacentAd(el) || isPrevAd(el)) {
489
+ continue;
490
+ }
491
+
492
+ // Prevent back-to-back at load
493
+ const prevWrap = findWrap(kindClass, afterPos - 1);
494
+ if (prevWrap) continue;
495
+
496
+ if (findWrap(kindClass, afterPos)) continue;
497
+
498
+ const pick = pickId(kindPool, []);
499
+ const id = pick.id;
500
+ if (!id) break;
501
+
502
+ let wrap = null;
503
+ if (pick.recycled && pick.recycled.wrap) {
504
+ // Only destroy if Ezoic has actually defined this placeholder before
505
+ if (sessionDefinedIds.has(id)) {
506
+ destroyPlaceholderIds([id]);
507
+ }
508
+ // Remove the old wrapper entirely, then create a fresh wrapper at the new position (same id)
509
+ const oldWrap = pick.recycled.wrap;
510
+ try { if (oldWrap && oldWrap.__ezoicFillObs) { oldWrap.__ezoicFillObs.disconnect(); } } catch (e) {}
511
+ try { oldWrap && oldWrap.remove(); } catch (e) {}
512
+ wrap = insertAfter(el, id, kindClass, afterPos);
513
+ if (!wrap) continue;
514
+ // Attendre que le wrapper soit dans le DOM puis appeler showAds
515
+ setTimeout(() => {
516
+ callShowAdsWhenReady(id);
517
+ }, 50);
518
+ } else {
519
+ usedSet.add(id);
520
+ wrap = insertAfter(el, id, kindClass, afterPos);
521
+ if (!wrap) continue;
522
+ // Micro-délai pour laisser le DOM se synchroniser
523
+ // Appel immédiat au lieu de 10ms delay
524
+ callShowAdsWhenReady(id);
525
+ }
526
+
527
+ liveArr.push({ id, wrap });
528
+ // If adjacency ended up happening (e.g. DOM shifts), rollback this placement.
529
+ if (wrap && (
530
+ (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))
531
+ )) {
532
+ try { wrap.remove(); } catch (e) {}
533
+ // Put id back if it was newly consumed (not recycled)
534
+ if (!(pick.recycled && pick.recycled.wrap)) {
535
+ try { kindPool.unshift(id); } catch (e) {}
536
+ usedSet.delete(id);
537
+ }
538
+ continue;
539
+ }
540
+ inserted += 1;
541
+ }
542
+ return inserted;
595
543
  }
596
544
 
597
545
  function enforceNoAdjacentAds() {
598
- const ads = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
599
- for (let i = 0; i < ads.length; i++) {
600
- const ad = ads[i];
601
- const prev = ad.previousElementSibling;
602
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) ad.style.display = 'none';
603
- else ad.style.display = '';
604
- }
546
+ const ads = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
547
+ for (let i = 0; i < ads.length; i++) {
548
+ const ad = ads[i], prev = ad.previousElementSibling;
549
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
550
+ // Supprimer le wrapper adjacent au lieu de le cacher
551
+ try {
552
+ const ph = ad.querySelector && ad.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
553
+ if (ph) {
554
+ const id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
555
+ if (Number.isFinite(id) && id > 0) {
556
+ // Détruire le placeholder si Ezoic l'a déjà défini
557
+ if (sessionDefinedIds.has(id)) {
558
+ destroyPlaceholderIds([id]);
559
+ }
560
+ }
561
+ }
562
+ ad.remove();
563
+ } catch (e) {}
564
+ }
565
+ }
605
566
  }
606
567
 
607
568
  function cleanup() {
608
- destroyUsedPlaceholders();
569
+ destroyUsedPlaceholders();
570
+
571
+ // CRITIQUE: Supprimer TOUS les wrappers .ezoic-ad du DOM
572
+ // Sinon ils restent et deviennent "unused" sur la nouvelle page
573
+ document.querySelectorAll('.ezoic-ad').forEach(el => {
574
+ try { el.remove(); } catch (e) {}
575
+ });
576
+
577
+ state.pageKey = getPageKey();
578
+ state.cfg = null;
579
+ state.cfgPromise = null;
580
+
581
+ state.poolTopics = [];
582
+ state.poolPosts = [];
583
+ state.poolCategories = [];
584
+ state.usedTopics.clear();
585
+ state.usedPosts.clear();
586
+ state.usedCategories.clear();
587
+ state.lastShowById.clear();
588
+ // CRITIQUE: Vider pendingById pour annuler tous les showAds en cours
589
+ // Sinon Ezoic essaie d'accéder aux placeholders pendant que NodeBB vide le DOM
590
+ state.pendingById.clear();
591
+ state.definedIds.clear();
592
+
593
+ // NE PAS supprimer les wrappers Ezoic ici - ils seront supprimés naturellement
594
+ // quand NodeBB vide le DOM lors de la navigation ajaxify
595
+ // Les supprimer manuellement cause des problèmes avec l'état interne d'Ezoic
596
+
597
+ // CRITIQUE: Annuler TOUS les timeouts en cours pour éviter que les anciens
598
+ // showAds() continuent à s'exécuter après la navigation
599
+ state.activeTimeouts.forEach(id => {
600
+ try { clearTimeout(id); } catch (e) {}
601
+ });
602
+ state.activeTimeouts.clear();
603
+
604
+ // Vider aussi pendingById pour annuler les showAds en attente
605
+ state.pendingById.clear();
606
+
607
+ if (state.obs) { state.obs.disconnect(); state.obs = null; }
608
+
609
+ state.scheduled = false;
610
+ clearTimeout(state.timer);
611
+ state.timer = null;
612
+ }
613
+
614
+ function ensureObserver() {
615
+ if (state.obs) return;
616
+ state.obs = new MutationObserver(() => scheduleRun('mutation'));
617
+ try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
618
+ }
609
619
 
610
- state.pageKey = getPageKey();
611
- state.cfg = null;
612
- state.cfgPromise = null;
620
+ async function runCore() {
621
+ // Attendre que canInsert soit true (protection race condition navigation)
622
+ if (!state.canShowAds) {
623
+ return;
624
+ }
613
625
 
614
- state.poolTopics = [];
615
- state.poolPosts = [];
616
- state.poolCategories = [];
617
- state.poolCategories = [];
618
- state.usedTopics.clear();
619
- state.usedPosts.clear();
620
- state.usedCategories && state.usedCategories.clear();
621
- state.liveBetween = [];
622
- state.liveMessage = [];
623
- state.liveCategory = [];
624
- state.usedCategories.clear();
626
+ patchShowAds();
625
627
 
626
- state.lastShowById = new Map();
627
- state.pendingById = new Set();
628
+ const cfg = await fetchConfig();
629
+ if (!cfg || cfg.excluded) return;
628
630
 
629
- state.attempts = 0;
631
+ initPools(cfg);
630
632
 
631
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
633
+ const kind = getKind();
634
+ let inserted = 0;
632
635
 
633
- if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; }
634
- state.scheduled = false;
635
- clearTimeout(state.timer);
636
- state.timer = null;
636
+ if (kind === 'topic') {
637
+ if (normalizeBool(cfg.enableMessageAds)) {
638
+ inserted = injectBetween('ezoic-ad-message', getPostContainers(),
639
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
640
+ normalizeBool(cfg.showFirstMessageAd),
641
+ state.poolPosts,
642
+ state.usedPosts);
643
+ }
644
+ } else if (kind === 'categoryTopics') {
645
+ if (normalizeBool(cfg.enableBetweenAds)) {
646
+ inserted = injectBetween('ezoic-ad-between', getTopicItems(),
647
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
648
+ normalizeBool(cfg.showFirstTopicAd),
649
+ state.poolTopics,
650
+ state.usedTopics);
651
+ }
652
+ } else if (kind === 'categories') {
653
+ if (normalizeBool(cfg.enableCategoryAds)) {
654
+ inserted = injectBetween('ezoic-ad-categories', getCategoryItems(),
655
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
656
+ normalizeBool(cfg.showFirstCategoryAd),
657
+ state.poolCategories,
658
+ state.usedCategories);
659
+ }
637
660
  }
638
661
 
639
- function ensureObserver() {
640
- if (state.obs) return;
641
- state.obs = new MutationObserver(() => scheduleRun('mutation'));
642
- try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
643
- setTimeout(() => { if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; } }, 15000);
662
+ enforceNoAdjacentAds();
663
+
664
+ // If nothing inserted and list isn't in DOM yet (first click), retry a bit
665
+ let count = 0;
666
+ if (kind === 'topic') count = getPostContainers().length;
667
+ else if (kind === 'categoryTopics') count = getTopicItems().length;
668
+ else if (kind === 'categories') count = getCategoryItems().length;
669
+
670
+ if (count === 0 && 0 < 25) {
671
+ setTimeout(arguments[0], 50);
672
+ return;
644
673
  }
645
674
 
646
- async function runCore() {
647
- patchShowAds();
648
-
649
- const cfg = await fetchConfig();
650
- if (!cfg || cfg.excluded) return;
651
-
652
- initPools(cfg);
653
-
654
- const kind = getKind();
655
- let inserted = 0;
656
-
657
- if (kind === 'topic') {
658
- if (normalizeBool(cfg.enableMessageAds)) {
659
- inserted = injectBetween('ezoic-ad-message', getPostContainers(),
660
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
661
- normalizeBool(cfg.showFirstMessageAd),
662
- 'message',
663
- state.usedPosts);
664
- }
665
- } else if (kind === 'categoryTopics') {
666
- if (normalizeBool(cfg.enableBetweenAds)) {
667
- inserted = injectBetween('ezoic-ad-between', getTopicItems(),
668
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
669
- normalizeBool(cfg.showFirstTopicAd),
670
- 'between',
671
- state.usedTopics);
672
- }
673
- } else if (kind === 'categories') {
674
- if (normalizeBool(cfg.enableCategoryAds)) {
675
- inserted = injectBetween('ezoic-ad-categories', getCategoryItems(),
676
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
677
- normalizeBool(cfg.showFirstCategoryAd),
678
- 'categories',
679
- state.usedCategories);
680
- }
681
- }
682
-
683
- enforceNoAdjacentAds();
684
- scheduleRefill(250);
685
-
686
- // If nothing inserted and list isn't in DOM yet (first click), retry a bit
687
- let count = 0;
688
- if (kind === 'topic') count = getPostContainers().length;
689
- else if (kind === 'categoryTopics') count = getTopicItems().length;
690
- else if (kind === 'categories') count = getCategoryItems().length;
691
-
692
- if (count === 0 && state.attempts < 25) {
693
- state.attempts += 1;
694
- setTimeout(() => scheduleRun('await-items'), 120);
695
- return;
696
- }
697
-
698
- if (inserted >= MAX_INSERTS_PER_RUN) setTimeout(() => scheduleRun('continue'), 140);
675
+ if (inserted >= MAX_INSERTS_PER_RUN) {
676
+ // Plus d'insertions possibles ce cycle, continuer immédiatement
677
+ setTimeout(arguments[0], 50);
678
+ } else if (inserted === 0 && count > 0) {
679
+ // Pool épuisé ou recyclage pas encore disponible.
680
+ // Réessayer jusqu'à 8 fois (toutes les 400ms) pour laisser aux anciens wrappers
681
+ // le temps de défiler hors écran et devenir recyclables.
682
+ if (state.poolWaitAttempts < 8) {
683
+ state.poolWaitAttempts += 1;
684
+ setTimeout(arguments[0], 50);
685
+ } else {
686
+ }
687
+ } else if (inserted > 0) {
688
+ }
699
689
  }
700
690
 
701
691
  function scheduleRun() {
702
- if (state.scheduled) return;
703
- state.scheduled = true;
692
+ if (state.scheduled) return;
693
+ state.scheduled = true;
704
694
 
705
- clearTimeout(state.timer);
706
- state.timer = setTimeout(() => {
707
- state.scheduled = false;
708
- const pk = getPageKey();
709
- if (state.pageKey && pk !== state.pageKey) return;
710
- runCore().catch(() => {});
711
- }, 80);
695
+ clearTimeout(state.timer);
696
+ state.timer = setTimeout(() => {
697
+ state.scheduled = false;
698
+ const pk = getPageKey();
699
+ if (state.pageKey && pk !== state.pageKey) return;
700
+ runCore().catch(() => {});
701
+ }, 50);
712
702
  }
713
703
 
714
704
  function bind() {
715
- if (!$) return;
705
+ if (!$) return;
706
+
707
+ $(window).off('.ezoicInfinite');
708
+
709
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
716
710
 
717
- $(window).off('.ezoicInfinite');
711
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
712
+ state.pageKey = getPageKey();
713
+ ensureObserver();
714
+
715
+ // CRITIQUE: Attendre 300ms avant de permettre l'insertion de nouveaux placeholders
716
+ // pour laisser les anciens showAds() (en cours) se terminer ou échouer proprement
717
+ // Sinon race condition: NodeBB vide le DOM pendant que Ezoic essaie d'accéder aux placeholders
718
+ state.canShowAds = true;
719
+ });
720
+
721
+ $(window).on('action:category.loaded.ezoicInfinite', () => {
722
+ ensureObserver();
723
+ // category.loaded = infinite scroll, Ezoic déjà chargé normalement
724
+ waitForContentThenRun();
725
+ });
726
+ $(window).on('action:topics.loaded.ezoicInfinite', () => {
727
+ ensureObserver();
728
+ waitForContentThenRun();
729
+ });
730
+
731
+ $(window).on('action:topic.loaded.ezoicInfinite', () => {
732
+ ensureObserver();
733
+ waitForContentThenRun();
734
+ });
735
+
736
+ $(window).on('action:posts.loaded.ezoicInfinite', () => {
737
+ ensureObserver();
738
+ // posts.loaded = infinite scroll
739
+ waitForContentThenRun();
740
+ });
741
+ }
742
+
743
+ function bindScroll() {
744
+ if (state.lastScrollRun > 0) return;
745
+ state.lastScrollRun = Date.now();
746
+ let ticking = false;
747
+ window.addEventListener('scroll', () => {
748
+ if (ticking) return;
749
+ ticking = true;
750
+ window.requestAnimationFrame(() => {
751
+ ticking = false;
752
+ enforceNoAdjacentAds();
753
+ // Debounce scheduleRun - une fois toutes les 2 secondes max au scroll
754
+ const now = Date.now();
755
+ if (!state.lastScrollRun || now - state.lastScrollRun > 2000) {
756
+ state.lastScrollRun = now;
757
+ scheduleRun();
758
+ }
759
+ });
760
+ }, { passive: true });
761
+ }
718
762
 
719
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
763
+ // Fonction qui attend que la page ait assez de contenu avant d'insérer les pubs
764
+ function waitForContentThenRun() {
765
+ const MIN_WORDS = 250;
766
+ let attempts = 0;
767
+ const maxAttempts = 20; // 20 × 200ms = 4s max
720
768
 
721
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
722
- state.pageKey = getPageKey();
723
- ensureObserver();
724
- scheduleRun();
725
- setTimeout(scheduleRun, 200);
726
- setTimeout(scheduleRun, 700);
727
- });
769
+ (function check() {
770
+ attempts++;
728
771
 
729
- $(window).on('action:category.loaded.ezoicInfinite', () => {
730
- ensureObserver();
731
- scheduleRun();
732
- setTimeout(scheduleRun, 250);
733
- });
772
+ // Compter les mots sur la page
773
+ const text = document.body.innerText || '';
774
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
734
775
 
735
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
736
- ensureObserver();
737
- scheduleRun();
738
- setTimeout(scheduleRun, 150);
739
- });
776
+ if (wordCount >= MIN_WORDS) {
777
+ // Assez de contenu → lancer l'insertion
778
+ scheduleRun();
779
+ return;
780
+ }
740
781
 
741
- $(window).on('action:topic.loaded.ezoicInfinite', () => {
742
- ensureObserver();
743
- scheduleRun();
744
- setTimeout(scheduleRun, 200);
745
- });
782
+ // Pas assez de contenu
783
+ if (attempts >= maxAttempts) {
784
+ // Timeout après 4s → tenter quand même
785
+ scheduleRun();
786
+ return;
787
+ }
746
788
 
747
- $(window).on('action:posts.loaded.ezoicInfinite', () => {
748
- ensureObserver();
749
- scheduleRun();
750
- setTimeout(scheduleRun, 150);
751
- });
789
+ // Réessayer dans 200ms
790
+ setTimeout(check, 50);
791
+ })();
792
+ }
793
+
794
+ // Fonction qui attend que Ezoic soit vraiment chargé
795
+ function waitForEzoicThenRun() {
796
+ let attempts = 0;
797
+ const maxAttempts = 50; // 50 × 200ms = 10s max
798
+
799
+ (function check() {
800
+ attempts++;
801
+ // Vérifier si Ezoic est chargé
802
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
803
+ // Ezoic est prêt → lancer l'insertion
804
+ scheduleRun();
805
+ waitForContentThenRun();
806
+ return;
807
+ }
808
+ // Ezoic pas encore prêt
809
+ if (attempts >= maxAttempts) {
810
+ // Tenter quand même
811
+ scheduleRun();
812
+ return;
813
+ }
814
+ // Réessayer dans 200ms
815
+ setTimeout(check, 50);
816
+ })();
752
817
  }
753
818
 
754
819
  cleanup();
755
820
  bind();
821
+ bindScroll();
756
822
  ensureObserver();
757
823
  state.pageKey = getPageKey();
758
- scheduleRun();
759
- setTimeout(scheduleRun, 250);
760
- })()
761
- function bindScroll() {
762
- if (state.__scrollBound) return;
763
- state.__scrollBound = true;
764
- let ticking = false;
765
- window.addEventListener('scroll', () => {
766
- if (ticking) return;
767
- ticking = true;
768
- window.requestAnimationFrame(() => {
769
- ticking = false;
770
- enforceNoAdjacentAds();
771
- scheduleRefill(200);
772
- });
773
- }, { passive: true });
774
- }
775
-
776
- ;
824
+
825
+ // Attendre que Ezoic soit chargé avant d'insérer
826
+ waitForEzoicThenRun();
827
+ })();