nodebb-plugin-ezoic-infinite 1.4.60 → 1.4.61

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