nodebb-plugin-ezoic-infinite 1.4.56 → 1.4.58

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 +665 -707
package/public/client.js CHANGED
@@ -1,851 +1,809 @@
1
1
  (function () {
2
2
  'use strict';
3
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
4
+ const SELECTORS = {
5
+ topicItem: 'li[component="category/topic"]',
6
+ postItem: '[component="post"][data-pid]',
7
+ categoryItem: 'li[component="categories/category"]',
8
+ }, WRAP_CLASS = 'ezoic-ad';
9
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-', MAX_INSERTS_PER_RUN = 3;
3
10
 
4
- var $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
11
+ // Nécessaire pour savoir si on doit appeler destroyPlaceholders avant recyclage.
12
+ const sessionDefinedIds = new Set();
5
13
 
6
- var SELECTORS = {
7
- topicItem: 'li[component="category/topic"]',
8
- postItem: '[component="post"][data-pid]',
9
- categoryItem: 'li[component="categories/category"]'
10
- };
11
-
12
- var WRAP_CLASS = 'ezoic-ad';
13
- var PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
-
15
- // Hard limits to avoid runaway insertion during mutations / infinite scroll.
16
- var MAX_INSERTS_PER_RUN = 3;
17
-
18
- // Placeholders that have been "defined" (filled) at least once in this browser session.
19
- // This survives ajaxify navigations, which is important for safe recycle/destroy logic.
20
- var sessionDefinedIds = new Set();
21
-
22
- // Prevent re-entrant insertion of the same id while Ezoic is processing it.
23
- var insertingIds = new Set();
24
-
25
- var state = {
26
- pageKey: null,
27
-
28
- cfg: null,
29
- cfgPromise: null,
14
+ const insertingIds = new Set(), state = {
15
+ pageKey: null,
16
+ cfg: null,
17
+ cfgPromise: null,
30
18
 
31
- poolTopics: [],
32
- poolPosts: [],
33
- poolCategories: [],
19
+ poolTopics: [],
20
+ poolPosts: [],
21
+ poolCategories: [],
34
22
 
35
- usedTopics: new Set(),
36
- usedPosts: new Set(),
37
- usedCategories: new Set(),
23
+ usedTopics: new Set(),
24
+ usedPosts: new Set(),
25
+ usedCategories: new Set(),
38
26
 
39
- // Track wrappers that are still in the DOM to recycle ids once they are far above viewport.
40
- liveTopics: [],
41
- livePosts: [],
42
- liveCategories: [],
27
+ lastShowById: new Map(),
28
+ pendingById: new Set(),
29
+ definedIds: new Set(),
43
30
 
44
- // Throttle showAds calls per id.
45
- lastShowById: new Map(),
31
+ scheduled: false,
32
+ timer: null,
46
33
 
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
- };
34
+ obs: null,
35
+ activeTimeouts: new Set(),
36
+ lastScrollRun: 0, };
68
37
 
69
38
  function normalizeBool(v) {
70
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
39
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
71
40
  }
72
41
 
73
- function setTimeoutTracked(fn, ms) {
74
- var id = setTimeout(fn, ms);
75
- state.activeTimeouts.add(id);
76
- return id;
42
+ function uniqInts(lines) {
43
+ const out = [], seen = new Set();
44
+ for (const v of lines) {
45
+ const n = parseInt(v, 10);
46
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
47
+ seen.add(n);
48
+ out.push(n);
77
49
  }
78
-
79
- function clearAllTrackedTimeouts() {
80
- state.activeTimeouts.forEach(function (id) {
81
- try { clearTimeout(id); } catch (e) {}
82
- });
83
- state.activeTimeouts.clear();
84
50
  }
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;
51
+ return out;
97
52
  }
98
53
 
99
54
  function parsePool(raw) {
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);
55
+ if (!raw) return [];
56
+ const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
57
+ return uniqInts(lines);
106
58
  }
107
59
 
108
60
  function getPageKey() {
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;
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;
117
69
  }
118
70
 
119
71
  function getKind() {
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';
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';
130
81
  }
131
82
 
132
83
  function getTopicItems() {
133
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
84
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
134
85
  }
135
86
 
136
87
  function getCategoryItems() {
137
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
88
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
138
89
  }
139
90
 
140
91
  function getPostContainers() {
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
- });
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
+ });
153
101
  }
154
102
 
155
103
  function safeRect(el) {
156
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
104
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
157
105
  }
158
106
 
159
107
  function destroyPlaceholderIds(ids) {
160
- if (!ids || !ids.length) return;
161
-
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) {}
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) {}
183
129
  }
184
130
 
185
131
  function getRecyclable(liveArr) {
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;
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;
201
143
  }
202
144
 
203
145
  function pickId(pool, liveArr) {
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 };
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 };
210
150
  }
211
151
 
212
152
  function resetPlaceholderInWrap(wrap, id) {
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;
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;
220
191
  }
221
192
 
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));
193
+ function findWrap(kindClass, afterPos) {
194
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
230
195
  }
231
196
 
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
- }
197
+ function insertAfter(target, id, kindClass, afterPos) {
198
+ if (!target || !target.insertAdjacentElement) return null;
199
+ if (findWrap(kindClass, afterPos)) return null;
239
200
 
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
- }
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
204
 
245
- function insertAfter(el, id, kindClass, afterPos) {
246
- try {
247
- var wrap = buildWrap(id, kindClass);
248
- wrap.setAttribute('data-after-pos', String(afterPos));
205
+ // 2. Vérifier qu'aucun placeholder avec cet ID n'existe déjà dans le DOM
206
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
207
+ if (existingPh && existingPh.isConnected) return null;
249
208
 
250
- if (!el || !el.parentNode) return null;
251
- if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
252
- else el.parentNode.appendChild(wrap);
209
+ // Acquérir le lock
210
+ insertingIds.add(id);
253
211
 
254
- attachFillObserver(wrap, id);
255
- return wrap;
256
- } catch (e) {}
257
- return null;
212
+ try {
213
+ const wrap = buildWrap(id, kindClass, afterPos);
214
+ target.insertAdjacentElement('afterend', wrap);
215
+ attachFillObserver(wrap, id);
216
+ return wrap;
217
+ } finally {
218
+ // Libérer le lock après 100ms (le temps que le DOM soit stable)
219
+ setTimeout(() => insertingIds.delete(id), 100);
220
+ }
258
221
  }
259
222
 
260
223
  function destroyUsedPlaceholders() {
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);
224
+ const ids = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
225
+ if (ids.length) destroyPlaceholderIds(ids);
268
226
  }
269
227
 
270
228
  function patchShowAds() {
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) {}
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) {}
264
+ }
277
265
  }
278
266
 
279
- function markFilled(id) {
280
- try { sessionDefinedIds.add(id); } catch (e) {}
267
+ function markFilled(wrap) {
268
+ try {
269
+ if (!wrap) return;
270
+ // Disconnect the fill observer first (no need to remove+re-add the attribute)
271
+ if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
272
+ wrap.setAttribute('data-ezoic-filled', '1');
273
+ } catch (e) {}
281
274
  }
282
275
 
283
276
  function isWrapMarkedFilled(wrap) {
284
- try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
277
+ try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
285
278
  }
286
279
 
287
280
  function attachFillObserver(wrap, id) {
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) {}
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);
343
366
  }
344
367
 
345
368
  function callShowAdsWhenReady(id) {
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
- }
369
+ if (!id) return;
405
370
 
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);
410
- }
371
+ const now = Date.now(), last = state.lastShowById.get(id) || 0;
372
+ if (now - last < 3500) return;
411
373
 
412
- function computeTargets(count, interval, showFirst) {
413
- var out = [];
414
- if (count <= 0) return out;
374
+ const phId = `${PLACEHOLDER_PREFIX}${id}`, doCall = () => {
375
+ try {
376
+ window.ezstandalone = window.ezstandalone || {};
377
+ if (typeof window.ezstandalone.showAds === 'function') {
415
378
 
416
- if (showFirst) out.push(1);
379
+ state.lastShowById.set(id, Date.now());
380
+ window.ezstandalone.showAds(id);
381
+ sessionDefinedIds.add(id);
382
+ return true;
383
+ }
384
+ } catch (e) {}
385
+ return false;
386
+ };
417
387
 
418
- for (var i = 1; i <= count; i++) {
419
- if (i % interval === 0) out.push(i);
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
395
 
422
- // Unique + sorted.
423
- return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
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
400
 
426
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
427
- if (!items || !items.length) return 0;
401
+ // Si on arrive ici, soit visible, soit timeout
428
402
 
429
- var targets = computeTargets(items.length, interval, showFirst);
430
- var inserted = 0;
403
+ // Si doCall() réussit, Ezoic est chargé et showAds a été appelé → sortir
404
+ if (doCall()) {
405
+ state.pendingById.delete(id);
406
+ return;
407
+ }
431
408
 
432
- for (var t = 0; t < targets.length; t++) {
433
- var afterPos = targets[t];
434
- if (inserted >= MAX_INSERTS_PER_RUN) break;
409
+ }
435
410
 
436
- var el = items[afterPos - 1];
437
- if (!el || !el.isConnected) continue;
411
+ if (attempts < 100) {
412
+ const timeoutId = setTimeout(waitForPh, 50);
413
+ state.activeTimeouts.add(timeoutId);
414
+ }
415
+ })();
416
+ }
438
417
 
439
- // Prevent adjacent ads.
440
- if (isAdjacentAd(el) || isPrevAd(el)) continue;
418
+ async function fetchConfig() {
419
+ if (state.cfg) return state.cfg;
420
+ if (state.cfgPromise) return state.cfgPromise;
441
421
 
442
- // Prevent duplicates at same logical position.
443
- if (findWrap(kindClass, afterPos - 1)) continue;
444
- if (findWrap(kindClass, afterPos)) continue;
422
+ state.cfgPromise = (async () => {
423
+ const MAX_TRIES = 3;
424
+ let delay = 800;
425
+ for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
426
+ try {
427
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
428
+ if (res.ok) {
429
+ state.cfg = await res.json();
430
+ return state.cfg;
431
+ }
432
+ } catch (e) {}
433
+ if (attempt < MAX_TRIES) await new Promise(r => setTimeout(r, delay));
434
+ delay *= 2;
435
+ }
436
+ return null;
437
+ })();
445
438
 
446
- var pick = pickId(kindPool, liveArr);
447
- var id = pick.id;
448
- if (!id) break;
439
+ try { return await state.cfgPromise; } finally { state.cfgPromise = null; }
440
+ }
449
441
 
450
- var wrap = null;
442
+ function initPools(cfg) {
443
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
444
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
445
+ if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
446
+ }
451
447
 
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]);
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
457
 
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) {}
458
+ function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet) {
459
+ if (!items.length) return 0;
460
+ const targets = computeTargets(items.length, interval, showFirst);
460
461
 
461
- wrap = insertAfter(el, id, kindClass, afterPos);
462
- if (!wrap) continue;
462
+ let inserted = 0;
463
+ for (const afterPos of targets) {
464
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
463
465
 
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;
466
+ const el = items[afterPos - 1];
467
+ if (!el || !el.isConnected) continue;
470
468
 
471
- // Micro-delay to allow layout/DOM settle.
472
- setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
473
- }
469
+ // Prevent adjacent ads (DOM-based, robust against virtualization)
470
+ if (isAdjacentAd(el) || isPrevAd(el)) {
471
+ continue;
472
+ }
474
473
 
475
- liveArr.push({ id: id, wrap: wrap });
474
+ // Prevent back-to-back at load
475
+ const prevWrap = findWrap(kindClass, afterPos - 1);
476
+ if (prevWrap) continue;
476
477
 
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) {}
478
+ if (findWrap(kindClass, afterPos)) continue;
482
479
 
483
- if (!(pick.recycled && pick.recycled.wrap)) {
484
- try { kindPool.unshift(id); } catch (e) {}
485
- usedSet.delete(id);
486
- }
487
- continue;
488
- }
480
+ const pick = pickId(kindPool, []);
481
+ const id = pick.id;
482
+ if (!id) break;
489
483
 
490
- inserted++;
491
- }
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
+ }
492
507
 
493
- return inserted;
508
+ liveArr.push({ id, wrap });
509
+ // If adjacency ended up happening (e.g. DOM shifts), rollback this placement.
510
+ if (wrap && (
511
+ (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))
512
+ )) {
513
+ try { wrap.remove(); } catch (e) {}
514
+ // Put id back if it was newly consumed (not recycled)
515
+ if (!(pick.recycled && pick.recycled.wrap)) {
516
+ try { kindPool.unshift(id); } catch (e) {}
517
+ usedSet.delete(id);
518
+ }
519
+ continue;
520
+ }
521
+ inserted += 1;
522
+ }
523
+ return inserted;
494
524
  }
495
525
 
496
526
  function enforceNoAdjacentAds() {
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
- }
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) {}
516
545
  }
517
-
518
- function cleanup() {
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
- }
570
546
  }
571
-
572
- function ensureObserver() {
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) {}
578
547
  }
579
548
 
580
- function scheduleRun(/* reason */) {
581
- if (state.scheduled) return;
582
- state.scheduled = true;
549
+ function cleanup() {
550
+ destroyUsedPlaceholders();
583
551
 
584
- if (state.timer) {
585
- try { clearTimeout(state.timer); } catch (e) {}
586
- state.timer = null;
587
- }
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
+ });
588
557
 
589
- state.timer = setTimeoutTracked(function () {
590
- state.scheduled = false;
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;
594
+ }
591
595
 
592
- // If user navigated away, stop.
593
- var pk = getPageKey();
594
- if (state.pageKey && pk !== state.pageKey) return;
596
+ 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) {}
600
+ }
595
601
 
596
- runCore().catch(function () {});
597
- }, 80);
602
+ async function runCore() {
603
+ // Attendre que canInsert soit true (protection race condition navigation)
604
+ if (!state.canShowAds) {
605
+ return;
598
606
  }
599
607
 
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;
608
+ patchShowAds();
606
609
 
607
- if (count > 0) return true;
610
+ const cfg = await fetchConfig();
611
+ if (!cfg || cfg.excluded) return;
608
612
 
609
- if (state.awaitItemsAttempts < 25) {
610
- state.awaitItemsAttempts++;
611
- setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
612
- }
613
- return false;
614
- }
613
+ initPools(cfg);
615
614
 
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
615
+ const kind = getKind();
616
+ let inserted = 0;
621
617
 
622
- (function check() {
623
- attempts++;
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
+ }
624
643
 
625
- var text = '';
626
- try { text = document.body.innerText || ''; } catch (e) {}
627
- var wordCount = text.split(/\s+/).filter(Boolean).length;
644
+ enforceNoAdjacentAds();
628
645
 
629
- if (wordCount >= MIN_WORDS) {
630
- scheduleRun('content-ok');
631
- return;
632
- }
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;
633
651
 
634
- if (attempts >= maxAttempts) {
635
- scheduleRun('content-timeout');
636
- return;
637
- }
652
+ if (count === 0 && 0 < 25) {
653
+ setTimeout(() => scheduleRun('await-items'), 120);
654
+ return;
655
+ }
638
656
 
639
- setTimeoutTracked(check, 200);
640
- })();
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
+ }
641
671
  }
642
672
 
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 () {});
673
+ function scheduleRun() {
674
+ if (state.scheduled) return;
675
+ state.scheduled = true;
676
+
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);
770
684
  }
771
685
 
772
686
  function bind() {
773
- if (!$) return;
687
+ if (!$) return;
774
688
 
775
- $(window).off('.ezoicInfinite');
689
+ $(window).off('.ezoicInfinite');
776
690
 
777
- $(window).on('action:ajaxify.start.ezoicInfinite', function () {
778
- cleanup();
779
- });
691
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
780
692
 
781
- $(window).on('action:ajaxify.end.ezoicInfinite', function () {
782
- state.pageKey = getPageKey();
783
- ensureObserver();
784
-
785
- // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
786
- setTimeoutTracked(function () {
787
- state.canShowAds = true;
788
- waitForEzoicThenRun();
789
- }, 300);
790
- });
693
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
694
+ state.pageKey = getPageKey();
695
+ ensureObserver();
791
696
 
792
- // Infinite-scroll and "loaded" events.
793
- $(window).on('action:category.loaded.ezoicInfinite', function () {
794
- ensureObserver();
795
- waitForContentThenRun();
796
- });
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
+ });
797
702
 
798
- $(window).on('action:topics.loaded.ezoicInfinite', function () {
799
- ensureObserver();
800
- waitForContentThenRun();
801
- });
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
+ });
802
712
 
803
- $(window).on('action:topic.loaded.ezoicInfinite', function () {
804
- ensureObserver();
805
- waitForContentThenRun();
806
- });
713
+ $(window).on('action:topic.loaded.ezoicInfinite', () => {
714
+ ensureObserver();
715
+ waitForContentThenRun();
716
+ });
807
717
 
808
- $(window).on('action:posts.loaded.ezoicInfinite', function () {
809
- ensureObserver();
810
- waitForContentThenRun();
811
- });
718
+ $(window).on('action:posts.loaded.ezoicInfinite', () => {
719
+ ensureObserver();
720
+ // posts.loaded = infinite scroll
721
+ waitForContentThenRun();
722
+ });
812
723
  }
813
724
 
814
725
  function bindScroll() {
815
- if (state.lastScrollRun > 0) return;
816
- state.lastScrollRun = Date.now();
726
+ if (state.lastScrollRun > 0) return;
727
+ state.lastScrollRun = Date.now();
728
+ let ticking = false;
729
+ window.addEventListener('scroll', () => {
730
+ if (ticking) return;
731
+ ticking = true;
732
+ window.requestAnimationFrame(() => {
733
+ ticking = false;
734
+ enforceNoAdjacentAds();
735
+ // Debounce scheduleRun - une fois toutes les 2 secondes max au scroll
736
+ const now = Date.now();
737
+ if (!state.lastScrollRun || now - state.lastScrollRun > 2000) {
738
+ state.lastScrollRun = now;
739
+ scheduleRun();
740
+ }
741
+ });
742
+ }, { passive: true });
743
+ }
744
+
745
+ // Fonction qui attend que la page ait assez de contenu avant d'insérer les pubs
746
+ function waitForContentThenRun() {
747
+ const MIN_WORDS = 250;
748
+ let attempts = 0;
749
+ const maxAttempts = 20; // 20 × 200ms = 4s max
817
750
 
818
- var ticking = false;
819
- window.addEventListener('scroll', function () {
820
- if (ticking) return;
821
- ticking = true;
751
+ (function check() {
752
+ attempts++;
822
753
 
823
- window.requestAnimationFrame(function () {
824
- ticking = false;
754
+ // Compter les mots sur la page
755
+ const text = document.body.innerText || '';
756
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
825
757
 
826
- enforceNoAdjacentAds();
758
+ if (wordCount >= MIN_WORDS) {
759
+ // Assez de contenu → lancer l'insertion
760
+ scheduleRun();
761
+ return;
762
+ }
827
763
 
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 });
764
+ // Pas assez de contenu
765
+ if (attempts >= maxAttempts) {
766
+ // Timeout après 4s tenter quand même
767
+ scheduleRun();
768
+ return;
769
+ }
770
+
771
+ // Réessayer dans 200ms
772
+ setTimeout(check, 200);
773
+ })();
774
+ }
775
+
776
+ // Fonction qui attend que Ezoic soit vraiment chargé
777
+ function waitForEzoicThenRun() {
778
+ let attempts = 0;
779
+ const maxAttempts = 50; // 50 × 200ms = 10s max
780
+
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
+ })();
836
799
  }
837
800
 
838
- // Boot.
839
801
  cleanup();
840
802
  bind();
841
803
  bindScroll();
842
804
  ensureObserver();
843
-
844
805
  state.pageKey = getPageKey();
845
806
 
846
- // Direct page load: allow insertion after initial tick (no ajaxify.end).
847
- setTimeoutTracked(function () {
848
- state.canShowAds = true;
849
- waitForEzoicThenRun();
850
- }, 0);
851
- })();
807
+ // Attendre que Ezoic soit chargé avant d'insérer
808
+ waitForEzoicThenRun();
809
+ })();