nodebb-plugin-ezoic-infinite 1.5.18 → 1.5.21

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