nodebb-plugin-ezoic-infinite 1.4.58 → 1.4.60

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 +728 -669
package/public/client.js CHANGED
@@ -1,809 +1,868 @@
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,
30
+
31
+ poolTopics: [],
32
+ poolPosts: [],
33
+ poolCategories: [],
18
34
 
19
- poolTopics: [],
20
- poolPosts: [],
21
- poolCategories: [],
35
+ usedTopics: new Set(),
36
+ usedPosts: new Set(),
37
+ usedCategories: new Set(),
22
38
 
23
- usedTopics: new Set(),
24
- usedPosts: new Set(),
25
- usedCategories: 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: [],
26
43
 
27
- lastShowById: new Map(),
28
- pendingById: new Set(),
29
- definedIds: new Set(),
44
+ // Throttle showAds calls per id.
45
+ lastShowById: new Map(),
30
46
 
31
- scheduled: false,
32
- timer: null,
47
+ // Ids for which we scheduled/attempted showAds and should not schedule again immediately.
48
+ pendingById: new Set(),
33
49
 
34
- obs: null,
35
- activeTimeouts: new Set(),
36
- lastScrollRun: 0, };
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
+ };
37
71
 
38
72
  function normalizeBool(v) {
39
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
73
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
40
74
  }
41
75
 
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);
76
+ function setTimeoutTracked(fn, ms) {
77
+ var id = setTimeout(fn, ms);
78
+ state.activeTimeouts.add(id);
79
+ return id;
49
80
  }
81
+
82
+ function clearAllTrackedTimeouts() {
83
+ state.activeTimeouts.forEach(function (id) {
84
+ try { clearTimeout(id); } catch (e) {}
85
+ });
86
+ state.activeTimeouts.clear();
50
87
  }
51
- return out;
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;
52
100
  }
53
101
 
54
102
  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);
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);
58
109
  }
59
110
 
60
111
  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;
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;
69
120
  }
70
121
 
71
122
  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';
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';
81
133
  }
82
134
 
83
135
  function getTopicItems() {
84
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
136
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
85
137
  }
86
138
 
87
139
  function getCategoryItems() {
88
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
140
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
89
141
  }
90
142
 
91
143
  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
- });
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
+ });
101
156
  }
102
157
 
103
158
  function safeRect(el) {
104
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
159
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
105
160
  }
106
161
 
107
162
  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) {}
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) {}
129
187
  }
130
188
 
131
189
  function getRecyclable(liveArr) {
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;
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;
143
205
  }
144
206
 
145
207
  function pickId(pool, liveArr) {
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 };
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 };
150
214
  }
151
215
 
152
216
  function resetPlaceholderInWrap(wrap, id) {
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;
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;
191
224
  }
192
225
 
193
- function findWrap(kindClass, afterPos) {
194
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
226
+ function isAdjacentAd(el) {
227
+ var next = el && el.nextElementSibling;
228
+ return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
195
229
  }
196
230
 
197
- function insertAfter(target, id, kindClass, afterPos) {
198
- if (!target || !target.insertAdjacentElement) return null;
199
- if (findWrap(kindClass, afterPos)) return null;
231
+ function isPrevAd(el) {
232
+ var prev = el && el.previousElementSibling;
233
+ return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
234
+ }
235
+
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
+ }
200
243
 
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;
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
+ }
204
248
 
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;
249
+ function insertAfter(el, id, kindClass, afterPos) {
250
+ try {
251
+ var wrap = buildWrap(id, kindClass);
252
+ wrap.setAttribute('data-after-pos', String(afterPos));
208
253
 
209
- // Acquérir le lock
210
- insertingIds.add(id);
254
+ if (!el || !el.parentNode) return null;
255
+ if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
256
+ else el.parentNode.appendChild(wrap);
211
257
 
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
- }
258
+ attachFillObserver(wrap, id);
259
+ return wrap;
260
+ } catch (e) {}
261
+ return null;
221
262
  }
222
263
 
223
264
  function destroyUsedPlaceholders() {
224
- const ids = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
225
- if (ids.length) destroyPlaceholderIds(ids);
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;
226
272
  }
227
273
 
228
274
  function patchShowAds() {
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
- };
255
-
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) {}
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) {}
264
281
  }
282
+
283
+ function markDefined(id) {
284
+ try { sessionDefinedIds.add(id); } catch (e) {}
265
285
  }
266
286
 
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) {}
287
+ function markFilled(id) {
288
+ try { sessionDefinedIds.add(id); } catch (e) {}
274
289
  }
275
290
 
276
291
  function isWrapMarkedFilled(wrap) {
277
- try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
292
+ try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
278
293
  }
279
294
 
280
295
  function attachFillObserver(wrap, id) {
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);
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) {}
366
351
  }
367
352
 
368
353
  function callShowAdsWhenReady(id) {
369
- if (!id) return;
370
-
371
- const now = Date.now(), last = state.lastShowById.get(id) || 0;
372
- if (now - last < 3500) return;
373
-
374
- const phId = `${PLACEHOLDER_PREFIX}${id}`, doCall = () => {
375
- try {
376
- window.ezstandalone = window.ezstandalone || {};
377
- if (typeof window.ezstandalone.showAds === 'function') {
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
+ }
378
414
 
379
- state.lastShowById.set(id, Date.now());
380
- window.ezstandalone.showAds(id);
381
- sessionDefinedIds.add(id);
382
- return true;
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);
383
419
  }
384
- } catch (e) {}
385
- return false;
386
- };
387
420
 
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;
421
+ function computeTargets(count, interval, showFirst) {
422
+ var out = [];
423
+ if (count <= 0) return out;
395
424
 
396
- attempts += 1;
397
- const el = document.getElementById(phId);
398
- if (el && el.isConnected) {
399
- // CRITIQUE: Vérifier que le placeholder est VISIBLE
425
+ if (showFirst) out.push(1);
400
426
 
401
- // Si on arrive ici, soit visible, soit timeout
427
+ for (var i = 1; i <= count; i++) {
428
+ if (i % interval === 0) out.push(i);
429
+ }
402
430
 
403
- // Si doCall() réussit, Ezoic est chargé et showAds a été appelé → sortir
404
- if (doCall()) {
405
- state.pendingById.delete(id);
406
- return;
431
+ // Unique + sorted.
432
+ return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
407
433
  }
408
434
 
409
- }
435
+ function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
436
+ if (!items || !items.length) return 0;
410
437
 
411
- if (attempts < 100) {
412
- const timeoutId = setTimeout(waitForPh, 50);
413
- state.activeTimeouts.add(timeoutId);
414
- }
415
- })();
416
- }
438
+ var targets = computeTargets(items.length, interval, showFirst);
439
+ var inserted = 0;
417
440
 
418
- async function fetchConfig() {
419
- if (state.cfg) return state.cfg;
420
- if (state.cfgPromise) return state.cfgPromise;
441
+ for (var t = 0; t < targets.length; t++) {
442
+ var afterPos = targets[t];
443
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
421
444
 
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
- })();
445
+ var el = items[afterPos - 1];
446
+ if (!el || !el.isConnected) continue;
438
447
 
439
- try { return await state.cfgPromise; } finally { state.cfgPromise = null; }
440
- }
448
+ // Prevent adjacent ads.
449
+ if (isAdjacentAd(el) || isPrevAd(el)) continue;
441
450
 
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
- }
451
+ // Prevent duplicates at same logical position.
452
+ if (findWrap(kindClass, afterPos - 1)) continue;
453
+ if (findWrap(kindClass, afterPos)) continue;
447
454
 
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
- }
455
+ var pick = pickId(kindPool, liveArr);
456
+ var id = pick.id;
457
+ if (!id) break;
457
458
 
458
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet) {
459
- if (!items.length) return 0;
460
- const targets = computeTargets(items.length, interval, showFirst);
459
+ var wrap = null;
461
460
 
462
- let inserted = 0;
463
- for (const afterPos of targets) {
464
- if (inserted >= MAX_INSERTS_PER_RUN) break;
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]);
465
465
 
466
- const el = items[afterPos - 1];
467
- if (!el || !el.isConnected) continue;
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) {}
468
470
 
469
- // Prevent adjacent ads (DOM-based, robust against virtualization)
470
- if (isAdjacentAd(el) || isPrevAd(el)) {
471
- continue;
472
- }
471
+ wrap = insertAfter(el, id, kindClass, afterPos);
472
+ if (!wrap) continue;
473
473
 
474
- // Prevent back-to-back at load
475
- const prevWrap = findWrap(kindClass, afterPos - 1);
476
- if (prevWrap) continue;
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;
477
480
 
478
- if (findWrap(kindClass, afterPos)) continue;
481
+ // Micro-delay to allow layout/DOM settle.
482
+ setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
483
+ }
479
484
 
480
- const pick = pickId(kindPool, []);
481
- const id = pick.id;
482
- if (!id) break;
485
+ liveArr.push({ id: id, wrap: wrap });
483
486
 
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
- }
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) {}
507
492
 
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;
493
+ if (!(pick.recycled && pick.recycled.wrap)) {
494
+ try { kindPool.unshift(id); } catch (e) {}
495
+ usedSet.delete(id);
496
+ }
497
+ continue;
498
+ }
499
+
500
+ inserted++;
501
+ }
502
+
503
+ return inserted;
524
504
  }
525
505
 
526
506
  function enforceNoAdjacentAds() {
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
- }
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
+ }
547
520
  }
548
521
 
549
522
  function cleanup() {
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;
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
+ }
594
578
  }
595
579
 
596
580
  function ensureObserver() {
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) {}
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) {}
600
586
  }
601
587
 
602
- async function runCore() {
603
- // Attendre que canInsert soit true (protection race condition navigation)
604
- if (!state.canShowAds) {
605
- return;
606
- }
607
-
608
- patchShowAds();
609
-
610
- const cfg = await fetchConfig();
611
- if (!cfg || cfg.excluded) return;
612
-
613
- initPools(cfg);
588
+ function scheduleRun(/* reason */) {
589
+ if (state.scheduled) return;
590
+ state.scheduled = true;
614
591
 
615
- const kind = getKind();
616
- let inserted = 0;
617
-
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
- }
642
- }
592
+ if (state.timer) {
593
+ try { clearTimeout(state.timer); } catch (e) {}
594
+ state.timer = null;
595
+ }
643
596
 
644
- enforceNoAdjacentAds();
597
+ state.timer = setTimeoutTracked(function () {
598
+ state.scheduled = false;
645
599
 
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;
600
+ // If user navigated away, stop.
601
+ var pk = getPageKey();
602
+ if (state.pageKey && pk !== state.pageKey) return;
651
603
 
652
- if (count === 0 && 0 < 25) {
653
- setTimeout(() => scheduleRun('await-items'), 120);
654
- return;
604
+ runCore().catch(function () {});
605
+ }, 80);
655
606
  }
656
607
 
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
- }
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;
672
614
 
673
- function scheduleRun() {
674
- if (state.scheduled) return;
675
- state.scheduled = true;
615
+ if (count > 0) return true;
676
616
 
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);
617
+ if (state.awaitItemsAttempts < 25) {
618
+ state.awaitItemsAttempts++;
619
+ setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
620
+ }
621
+ return false;
684
622
  }
685
623
 
686
- function bind() {
687
- if (!$) return;
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
688
629
 
689
- $(window).off('.ezoicInfinite');
630
+ (function check() {
631
+ attempts++;
690
632
 
691
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
633
+ var text = '';
634
+ try { text = document.body.innerText || ''; } catch (e) {}
635
+ var wordCount = text.split(/\s+/).filter(Boolean).length;
692
636
 
693
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
694
- state.pageKey = getPageKey();
695
- ensureObserver();
637
+ if (wordCount >= MIN_WORDS) {
638
+ scheduleRun('content-ok');
639
+ return;
640
+ }
696
641
 
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
- });
642
+ if (attempts >= maxAttempts) {
643
+ scheduleRun('content-timeout');
644
+ return;
645
+ }
702
646
 
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
- });
647
+ setTimeoutTracked(check, 200);
648
+ })();
649
+ }
712
650
 
713
- $(window).on('action:topic.loaded.ezoicInfinite', () => {
714
- ensureObserver();
715
- waitForContentThenRun();
716
- });
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
+ }
717
779
 
718
- $(window).on('action:posts.loaded.ezoicInfinite', () => {
719
- ensureObserver();
720
- // posts.loaded = infinite scroll
721
- waitForContentThenRun();
722
- });
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
+ });
723
829
  }
724
830
 
725
831
  function bindScroll() {
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
- }
832
+ if (state.lastScrollRun > 0) return;
833
+ state.lastScrollRun = Date.now();
763
834
 
764
- // Pas assez de contenu
765
- if (attempts >= maxAttempts) {
766
- // Timeout après 4s → tenter quand même
767
- scheduleRun();
768
- return;
769
- }
835
+ var ticking = false;
836
+ window.addEventListener('scroll', function () {
837
+ if (ticking) return;
838
+ ticking = true;
770
839
 
771
- // Réessayer dans 200ms
772
- setTimeout(check, 200);
773
- })();
774
- }
840
+ window.requestAnimationFrame(function () {
841
+ ticking = false;
775
842
 
776
- // Fonction qui attend que Ezoic soit vraiment chargé
777
- function waitForEzoicThenRun() {
778
- let attempts = 0;
779
- const maxAttempts = 50; // 50 × 200ms = 10s max
843
+ enforceNoAdjacentAds();
780
844
 
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
- })();
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 });
799
853
  }
800
854
 
855
+ // Boot.
801
856
  cleanup();
802
857
  bind();
803
858
  bindScroll();
804
859
  ensureObserver();
860
+
805
861
  state.pageKey = getPageKey();
806
862
 
807
- // Attendre que Ezoic soit chargé avant d'insérer
808
- waitForEzoicThenRun();
809
- })();
863
+ // Direct page load: allow insertion after initial tick (no ajaxify.end).
864
+ setTimeoutTracked(function () {
865
+ state.canShowAds = true;
866
+ waitForEzoicThenRun();
867
+ }, 0);
868
+ })();