nodebb-plugin-ezoic-infinite 1.5.13 → 1.5.16

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