nodebb-plugin-ezoic-infinite 1.6.98 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/client.js CHANGED
@@ -1,1308 +1,638 @@
1
+ /**
2
+ * NodeBB Ezoic Infinite Ads — client.js (v19)
3
+ *
4
+ * Correctifs v19 vs v18 :
5
+ *
6
+ * [BUG 1] Pubs regroupées en haut après scroll up/down
7
+ * Cause : pruneOrphans gardait les wraps "filled" même quand leur ancre DOM
8
+ * avait disparu (post virtualisé). Ces wraps sans parent flottaient et
9
+ * NodeBB les réordonnait arbitrairement.
10
+ * Fix : un wrap dont l'ancre est absente du DOM EST supprimé, rempli ou non.
11
+ * Exception : si l'ancre est simplement hors-viewport mais still connected
12
+ * (NodeBB ne virtualise pas toujours le DOM), on la conserve.
13
+ *
14
+ * [BUG 2] Pub qui apparaît puis disparaît
15
+ * Cause : decluster() supprimait un wrap "vide" pendant la fenêtre de fill
16
+ * async d'Ezoic. Le guard TTL de 90s était calculé depuis la création,
17
+ * mais le show() peut avoir été appelé bien après la création.
18
+ * Fix : on ajoute data-ezoic-shown (timestamp du show). decluster ne touche
19
+ * pas un wrap dont le show date de moins de FILL_GRACE_MS (20s).
20
+ *
21
+ * [BUG 3] Intervalle 1/x non respecté sur infinite scroll
22
+ * Cause : computeTargetIndices utilisait l'index dans le tableau courant
23
+ * (items[0..N]), qui recommence à 0 à chaque batch de posts chargés.
24
+ * Fix : on utilise l'ordinal GLOBAL du post (data-index fourni par NodeBB,
25
+ * ou data-pid comme fallback numérique). L'intervalle est appliqué sur cet
26
+ * ordinal global → pub tous les X posts absolus, quel que soit le batch.
27
+ */
1
28
  (function () {
2
29
  'use strict';
3
30
 
4
- // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
5
- // Recycling while scrolling up is a common cause of ads "bunching" and a "disappearing too fast" feeling.
6
- let lastScrollY = 0;
7
- let scrollDir = 1; // 1 = down, -1 = up
8
- try {
9
- lastScrollY = window.scrollY || 0;
10
- window.addEventListener(
11
- 'scroll',
12
- () => {
13
- const y = window.scrollY || 0;
14
- const d = y - lastScrollY;
15
- if (Math.abs(d) > 4) {
16
- scrollDir = d > 0 ? 1 : -1;
17
- lastScrollY = y;
18
- }
19
- },
20
- { passive: true }
21
- );
22
- } catch (e) {}
23
-
24
- // NodeBB client context
25
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
26
-
27
- // IMPORTANT: must NOT collide with Ezoic's own markup (they use `.ezoic-ad`).
28
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
31
+ // ─── Constants ────────────────────────────────────────────────────────────
32
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
29
33
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
30
- const POOL_ID = 'nodebb-ezoic-placeholder-pool';
31
-
32
- // Smoothness caps
33
- // Limit how many placements we inject per scan pass.
34
- // Too low = you end up with only a handful of placeholders after ajaxify.
35
- // Too high = jank on very long pages.
36
- const MAX_INSERTS_PER_RUN = 8;
37
-
38
- // Keep empty (unfilled) wraps alive for a while. Topics/messages can fill late (auction/CMP).
39
- // Pruning too early makes ads look like they "disappear" while scrolling.
40
- // Keep empty wraps alive; mobile fills can be slow.
41
- function keepEmptyWrapMs() { return isMobile() ? 120000 : 60000; }
42
-
43
- // Preload margins
44
- const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
45
- // Mobile: larger preload window so ad fill requests start earlier and
46
- // users don't scroll past empty placeholders.
47
- const PRELOAD_MARGIN_MOBILE = '3200px 0px 3200px 0px';
48
- const PRELOAD_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
49
- const PRELOAD_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
50
-
51
- const BOOST_DURATION_MS = 2500;
52
- const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
53
-
54
- const MAX_INFLIGHT_DESKTOP = 4;
55
- const MAX_INFLIGHT_MOBILE = 3;
34
+ const ANCHOR_ATTR = 'data-ezoic-anchor'; // "kindClass:globalOrdinal"
35
+ const WRAPID_ATTR = 'data-ezoic-wrapid'; // ezoic placeholder id
36
+ const CREATED_ATTR = 'data-ezoic-created'; // timestamp création
37
+ const SHOWN_ATTR = 'data-ezoic-shown'; // timestamp dernier showAds
38
+
39
+ const MAX_INSERTS_PER_RUN = 6;
40
+ // Après un showAds(), ne pas decluster pendant ce délai (fill async Ezoic)
41
+ const FILL_GRACE_MS = 20_000;
42
+ // Collapse is-empty seulement après ce délai post-show
43
+ const EMPTY_CHECK_DELAY = 18_000;
44
+
45
+ const PRELOAD_MARGIN = {
46
+ desktop: '2000px 0px 2000px 0px',
47
+ mobile: '3000px 0px 3000px 0px',
48
+ desktopBoosted: '4500px 0px 4500px 0px',
49
+ mobileBoosted: '4500px 0px 4500px 0px',
50
+ };
51
+ const BOOST_DURATION_MS = 2500;
52
+ const BOOST_SPEED_PX_PER_MS = 2.2;
53
+ const MAX_INFLIGHT_DESKTOP = 4;
54
+ const MAX_INFLIGHT_MOBILE = 3;
55
+ const SHOW_THROTTLE_MS = 900;
56
56
 
57
57
  const SELECTORS = {
58
- topicItem: 'li[component="category/topic"]',
59
- postItem: '[component="post"][data-pid]',
58
+ topicItem: 'li[component="category/topic"]',
59
+ postItem: '[component="post"][data-pid]',
60
60
  categoryItem: 'li[component="categories/category"]',
61
61
  };
62
62
 
63
- // Production build: debug disabled
64
- function dbg() {}
65
-
66
- // Ezoic (and some partner scripts) can be very noisy in console on SPA/Ajaxify setups.
67
- // These warnings are not actionable for end-users and can flood the console.
68
- // We selectively silence the known spam patterns while keeping other warnings intact.
69
- function muteNoisyConsole() {
70
- try {
71
- if (window.__nodebbEzoicConsoleMuted) return;
72
- window.__nodebbEzoicConsoleMuted = true;
73
-
74
- const shouldMute = (args) => {
75
- try {
76
- if (!args || !args.length) return false;
77
- const s0 = typeof args[0] === 'string' ? args[0] : '';
78
- // Duplicate placeholder definition spam (common when reusing ids in SPA/Ajaxify).
79
- if (s0.includes('[EzoicAds JS]: Placeholder Id') && s0.includes('has already been defined')) return true;
80
- // Ezoic debugger iframe spam.
81
- if (s0.includes('Debugger iframe already exists')) return true;
82
- // Missing placeholder spam (we already guard showAds; Ezoic still logs sometimes).
83
- if (s0.includes('HTML element with id ezoic-pub-ad-placeholder-') && s0.includes('does not exist')) return true;
84
- return false;
85
- } catch (e) {
86
- return false;
87
- }
88
- };
89
-
90
- const wrap = (method) => {
91
- const orig = console[method];
92
- if (typeof orig !== 'function') return;
93
- console[method] = function (...args) {
94
- if (shouldMute(args)) return;
95
- return orig.apply(console, args);
96
- };
97
- };
98
-
99
- wrap('log');
100
- wrap('info');
101
- wrap('warn');
102
- wrap('error');
103
- } catch (e) {}
104
- }
105
-
106
- // Some CMP/TCF stubs rely on a hidden iframe named `__tcfapiLocator` to route postMessage calls.
107
- // In SPA/Ajaxify navigations, that iframe can be removed/replaced unexpectedly, causing noisy
108
- // "postMessage" / "addtlConsent" null errors. Ensuring it's present makes the environment stable.
109
- function ensureTcfApiLocator() {
110
- try {
111
- // If a CMP is not present, do nothing.
112
- if (typeof window.__tcfapi !== 'function' && typeof window.__cmp !== 'function') return;
113
- if (document.getElementById('__tcfapiLocator')) return;
114
- const f = document.createElement('iframe');
115
- f.style.display = 'none';
116
- f.id = '__tcfapiLocator';
117
- f.name = '__tcfapiLocator';
118
- (document.body || document.documentElement).appendChild(f);
119
- } catch (e) {}
120
- }
121
-
122
-
123
-
124
- function isFilledNode(node) {
125
- return !!(node && node.querySelector && node.querySelector('iframe, ins, img, video, [data-google-container-id]'));
126
- }
127
-
128
- // Ezoic injects inline `min-height:400px !important` on one or more nested wrappers.
129
- // If the creative is 250px, this leaves ~150px empty space. Because it's inline+important,
130
- // CSS alone cannot fix it reliably — we must rewrite the inline style.
131
- function tightenEzoicMinHeight(wrap) {
132
- try {
133
- if (!wrap || !wrap.querySelector) return;
134
-
135
- const iframes = wrap.querySelectorAll('iframe');
136
- if (!iframes || !iframes.length) return;
137
-
138
- // Find the closest "big" ezoic container that carries the 400px min-height.
139
- const firstIframe = iframes[0];
140
- let refNode = null;
141
- let p = firstIframe && firstIframe.parentElement;
142
- while (p && p !== wrap) {
143
- if (p.classList && p.classList.contains('ezoic-ad')) {
144
- const st = (p.getAttribute('style') || '').toLowerCase();
145
- if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
146
- refNode = p;
147
- break;
148
- }
149
- }
150
- p = p.parentElement;
151
- }
152
- if (!refNode) {
153
- refNode = wrap.querySelector('.ezoic-ad-adaptive') || wrap.querySelector('.ezoic-ad') || wrap;
154
- }
155
-
156
- let refTop = 0;
157
- try { refTop = refNode.getBoundingClientRect().top; } catch (e) { refTop = 0; }
158
-
159
- // Compute the rendered height needed inside refNode (visible iframes only).
160
- let maxBottom = 0;
161
- iframes.forEach((f) => {
162
- if (!f || !f.getBoundingClientRect) return;
163
- const rect = f.getBoundingClientRect();
164
- if (rect.width <= 1 || rect.height <= 1) return;
165
- const bottom = rect.bottom - refTop;
166
- maxBottom = Math.max(maxBottom, bottom);
167
- });
168
-
169
- // Fallback to attr/offset if layout metrics are not available.
170
- if (!maxBottom) {
171
- iframes.forEach((f) => {
172
- const ah = parseInt(f.getAttribute('height') || '0', 10);
173
- const oh = f.offsetHeight || 0;
174
- maxBottom = Math.max(maxBottom, ah, oh);
175
- });
176
- }
177
- if (!maxBottom) return;
63
+ // ─── Helpers ──────────────────────────────────────────────────────────────
64
+ const now = () => Date.now();
65
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
66
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
67
+ const isFilledNode = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
178
68
 
179
- // NOTE: Do NOT add the Ezoic badge (reportline) height here.
180
- // It is absolutely positioned and should not reserve layout space.
181
-
182
- const h = Math.max(1, Math.ceil(maxBottom));
183
-
184
- const tightenNode = (node) => {
185
- if (!node || !node.style) return;
186
- try { node.style.setProperty('min-height', h + 'px', 'important'); } catch (e) { node.style.minHeight = h + 'px'; }
187
- try { node.style.setProperty('height', 'auto', 'important'); } catch (e) {}
188
- try { node.style.setProperty('line-height', '0', 'important'); } catch (e) {}
189
- };
190
-
191
- // Tighten refNode and any ancestor ezoic-ad nodes with the problematic min-height.
192
- let cur = refNode;
193
- while (cur && cur !== wrap) {
194
- if (cur.classList && cur.classList.contains('ezoic-ad')) {
195
- const st = (cur.getAttribute('style') || '').toLowerCase();
196
- if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
197
- tightenNode(cur);
198
- }
199
- }
200
- cur = cur.parentElement;
201
- }
202
- tightenNode(refNode);
203
-
204
- // Tighten any nested wrappers that also have the 400px min-height inline.
205
- refNode.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach((n) => {
206
- const st = (n.getAttribute('style') || '').toLowerCase();
207
- if (st.includes('min-height:400') || st.includes('min-height: 400') || st.includes('min-height:400px')) {
208
- tightenNode(n);
209
- }
210
- });
211
-
212
- // Mobile friendliness: avoid giant fixed widths causing overflow/reflow.
213
- if (isMobile()) {
214
- [refNode].forEach((n) => {
215
- try { n.style.setProperty('width', '100%', 'important'); } catch (e) {}
216
- try { n.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
217
- try { n.style.setProperty('min-width', '0', 'important'); } catch (e) {}
218
- });
69
+ function uniqInts(raw) {
70
+ const out = [], seen = new Set();
71
+ for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
72
+ const n = parseInt(v, 10);
73
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
219
74
  }
220
- } catch (e) {}
221
- }
222
-
223
- function watchWrapForFill(wrap) {
224
- try {
225
- if (!wrap || wrap.__ezFillObs) return;
226
-
227
- // Ezoic can (re)apply inline styles after fill; keep tightening for a short window.
228
- const start = now();
229
- const tightenBurst = () => {
230
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
231
- if (now() - start < 6000) {
232
- setTimeout(tightenBurst, 350);
233
- }
234
- };
235
-
236
- const obs = new MutationObserver((muts) => {
237
- // If anything that looks like ad content appears, treat as filled.
238
- if (isFilledNode(wrap)) {
239
- wrap.classList.remove('is-empty');
240
- tightenBurst();
241
- }
242
-
243
- // If Ezoic changes inline style on descendants (min-height:400!important), tighten again.
244
- for (const m of muts) {
245
- if (m.type === 'attributes' && m.attributeName === 'style') {
246
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
247
- break;
248
- }
249
- }
250
-
251
- // Disconnect only after the burst window to avoid missing late style rewrites.
252
- if (now() - start > 7000) {
253
- try { obs.disconnect(); } catch (e) {}
254
- wrap.__ezFillObs = null;
255
- }
256
- });
257
-
258
- obs.observe(wrap, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
259
- wrap.__ezFillObs = obs;
260
- } catch (e) {}
261
- }
262
-
263
- // Global safety net: sometimes Ezoic swaps nodes in ways that bypass our per-wrap observers.
264
- // When we see an Ezoic container with min-height:400!important inside posts/topics, shrink it.
265
- function globalGapFixInit() {
266
- try {
267
- if (window.__nodebbEzoicGapFix) return;
268
- window.__nodebbEzoicGapFix = true;
269
-
270
- // Observe only the main content area to minimize overhead.
271
- const root = document.getElementById('content') || document.querySelector('[component="content"], #panel') || document.body;
272
-
273
- const inPostArea = (el) => {
274
- try {
275
- return !!(el && el.closest && el.closest('[component="post"], .topic, .posts, [component="topic"]'));
276
- } catch (e) { return false; }
277
- };
278
-
279
- const maybeFix = (root) => {
280
- if (!root || !root.querySelectorAll) return;
281
- const nodes = root.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"]');
282
- nodes.forEach((n) => {
283
- const st = (n.getAttribute('style') || '').toLowerCase();
284
- if (!st.includes('min-height:400')) return;
285
- if (!inPostArea(n)) return;
286
- try {
287
- const tmpWrap = n.closest('.' + WRAP_CLASS) || n.parentElement;
288
- tightenEzoicMinHeight(tmpWrap || n);
289
- } catch (e) {}
290
- });
291
- };
292
-
293
- requestAnimationFrame(() => maybeFix(root));
294
-
295
- // Batch DOM mutation processing into a single rAF to avoid doing work per mutation.
296
- const pending = new Set();
297
- let scheduled = false;
298
- const scheduleFlush = () => {
299
- if (scheduled) return;
300
- scheduled = true;
301
- requestAnimationFrame(() => {
302
- scheduled = false;
303
- pending.forEach((n) => {
304
- try { maybeFix(n); } catch (e) {}
305
- });
306
- pending.clear();
307
- });
308
- };
309
-
310
- const obs = new MutationObserver((muts) => {
311
- for (const m of muts) {
312
- if (m.type === 'attributes') {
313
- const t = m.target && m.target.nodeType === 1 ? m.target : m.target && m.target.parentElement;
314
- if (t) pending.add(t);
315
- } else if (m.addedNodes && m.addedNodes.length) {
316
- m.addedNodes.forEach((n) => {
317
- if (n && n.nodeType === 1) pending.add(n);
318
- });
319
- }
320
- }
321
- if (pending.size) scheduleFlush();
322
- });
323
- obs.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
324
- } catch (e) {}
325
- }
326
-
327
- // ---------------- state ----------------
75
+ return out;
76
+ }
328
77
 
78
+ // ─── State ────────────────────────────────────────────────────────────────
329
79
  const state = {
330
80
  pageKey: null,
331
81
  cfg: null,
332
82
 
333
- // pools (full lists) + cursors
334
- allTopics: [],
335
- allPosts: [],
336
- allCategories: [],
337
- curTopics: 0,
338
- curPosts: 0,
339
- curCategories: 0,
83
+ pools: { topics: [], posts: [], categories: [] },
84
+ cursors: { topics: 0, posts: 0, categories: 0 },
85
+
86
+ // IDs Ezoic actuellement montés dans le DOM (Set<number>)
87
+ mountedIds: new Set(),
340
88
 
341
- // per-id throttle
342
89
  lastShowById: new Map(),
343
90
 
344
- // observers / schedulers
345
91
  domObs: null,
346
- io: null,
347
- ioMargin: null,
92
+ io: null, ioMargin: null,
348
93
 
349
- // internal mutations guard
350
- internalDomChange: 0,
94
+ internalMutation: 0,
351
95
 
352
- // preloading budget
353
96
  inflight: 0,
354
- pending: [],
355
- pendingSet: new Set(),
97
+ pending: [], pendingSet: new Set(),
356
98
 
357
- // scroll boost
358
99
  scrollBoostUntil: 0,
359
- lastScrollY: 0,
360
- lastScrollTs: 0,
361
-
362
- // hero
363
- heroDoneForPage: false,
100
+ lastScrollY: 0, lastScrollTs: 0,
364
101
 
365
- // run scheduler
366
102
  runQueued: false,
367
- burstActive: false,
368
- burstDeadline: 0,
369
- burstCount: 0,
370
- lastBurstReqTs: 0,
103
+ burstActive: false, burstDeadline: 0,
104
+ burstCount: 0, lastBurstReqTs: 0,
371
105
  };
372
106
 
373
- // Soft block during navigation / heavy DOM churn
374
107
  let blockedUntil = 0;
375
- const insertingIds = new Set();
108
+ const isBlocked = () => now() < blockedUntil;
109
+ const isBoosted = () => now() < state.scrollBoostUntil;
376
110
 
377
- function now() { return Date.now(); }
378
- function isBlocked() { return now() < blockedUntil; }
379
-
380
- // ---------------- utils ----------------
381
-
382
- function normalizeBool(v) {
383
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
111
+ function withInternalMutation(fn) {
112
+ state.internalMutation++;
113
+ try { fn(); } finally { state.internalMutation--; }
384
114
  }
385
115
 
386
- function uniqInts(lines) {
387
- const out = [];
388
- const seen = new Set();
389
- for (const v of lines) {
390
- const n = parseInt(v, 10);
391
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
392
- seen.add(n);
393
- out.push(n);
394
- }
395
- }
396
- return out;
116
+ // ─── Config ───────────────────────────────────────────────────────────────
117
+ async function fetchConfig() {
118
+ if (state.cfg) return state.cfg;
119
+ try {
120
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
121
+ if (!res.ok) return null;
122
+ state.cfg = await res.json();
123
+ } catch (_) { state.cfg = null; }
124
+ return state.cfg;
397
125
  }
398
126
 
399
- function parsePool(raw) {
400
- if (!raw) return [];
401
- const lines = String(raw)
402
- .split(/\r?\n/)
403
- .map(s => s.trim())
404
- .filter(Boolean);
405
- return uniqInts(lines);
127
+ function initPools(cfg) {
128
+ if (!cfg) return;
129
+ // Réinitialise à chaque page (cleanup() remet les curseurs à 0)
130
+ state.pools.topics = uniqInts(cfg.placeholderIds);
131
+ state.pools.posts = uniqInts(cfg.messagePlaceholderIds);
132
+ state.pools.categories = uniqInts(cfg.categoryPlaceholderIds);
406
133
  }
407
134
 
135
+ // ─── Page / Kind ──────────────────────────────────────────────────────────
408
136
  function getPageKey() {
409
137
  try {
410
- const ax = window.ajaxify;
411
- if (ax && ax.data) {
412
- if (ax.data.tid) return `topic:${ax.data.tid}`;
413
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
414
- }
415
- } catch (e) {}
416
- return window.location.pathname;
417
- }
418
-
419
- function isMobile() {
420
- try { return window.innerWidth < 768; } catch (e) { return false; }
421
- }
422
-
423
- function isBoosted() {
424
- return now() < (state.scrollBoostUntil || 0);
425
- }
426
-
427
- function getPreloadRootMargin() {
428
- if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
429
- return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
430
- }
431
-
432
- function getMaxInflight() {
433
- const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
434
- return base + (isBoosted() ? 1 : 0);
435
- }
436
-
437
- function withInternalDomChange(fn) {
438
- state.internalDomChange++;
439
- try { fn(); } finally { state.internalDomChange--; }
138
+ const ax = window.ajaxify?.data;
139
+ if (ax?.tid) return `topic:${ax.tid}`;
140
+ if (ax?.cid) return `cid:${ax.cid}:${location.pathname}`;
141
+ } catch (_) {}
142
+ return location.pathname;
440
143
  }
441
144
 
442
- // ---------------- DOM helpers ----------------
443
-
444
- function getTopicItems() {
445
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
446
- }
447
-
448
- function getCategoryItems() {
449
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
145
+ function getKind() {
146
+ const p = location.pathname;
147
+ if (/^\/topic\//.test(p)) return 'topic';
148
+ if (/^\/category\//.test(p)) return 'categoryTopics';
149
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
150
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
151
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
152
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
153
+ return 'other';
450
154
  }
451
155
 
156
+ // ─── DOM helpers ──────────────────────────────────────────────────────────
452
157
  function getPostContainers() {
453
- const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
454
- return nodes.filter((el) => {
455
- if (!el || !el.isConnected) return false;
158
+ return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter(el => {
159
+ if (!el.isConnected) return false;
456
160
  if (!el.querySelector('[component="post/content"]')) return false;
457
- const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
458
- if (parentPost && parentPost !== el) return false;
161
+ const parent = el.parentElement?.closest('[component="post"][data-pid]');
162
+ if (parent && parent !== el) return false;
459
163
  if (el.getAttribute('component') === 'post/parent') return false;
460
164
  return true;
461
165
  });
462
166
  }
463
167
 
464
- function getKind() {
465
- const p = window.location.pathname || '';
466
- if (/^\/topic\//.test(p)) return 'topic';
467
- if (/^\/category\//.test(p)) return 'categoryTopics';
468
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
469
-
470
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
471
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
472
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
473
- return 'other';
474
- }
475
-
476
- function isAdjacentAd(target) {
477
- if (!target) return false;
478
- const next = target.nextElementSibling;
479
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
480
- const prev = target.previousElementSibling;
481
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
482
- return false;
483
- }
484
-
485
- function findWrap(kindClass, afterPos) {
486
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
487
- }
488
-
489
- // ---------------- placeholder pool ----------------
490
-
491
- function getPoolEl() {
492
- let el = document.getElementById(POOL_ID);
493
- if (el) return el;
494
- el = document.createElement('div');
495
- el.id = POOL_ID;
496
- el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
497
- (document.body || document.documentElement).appendChild(el);
498
- return el;
499
- }
500
-
501
- function primePlaceholderPool(allIds) {
168
+ function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
169
+ function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
170
+
171
+ function hasAdjacentWrap(el) {
172
+ return !!(el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
173
+ el.previousElementSibling?.classList?.contains(WRAP_CLASS));
174
+ }
175
+
176
+ // ─── Ordinal global (BUG FIX #3) ──────────────────────────────────────────
177
+ /**
178
+ * Retourne l'ordinal ABSOLU d'un élément dans la page complète (pas le batch).
179
+ *
180
+ * Pour les posts (topic) : NodeBB expose data-index (0-based) sur chaque
181
+ * [component="post"]. On l'utilise directement.
182
+ *
183
+ * Pour les topics (liste catégorie) : idem, data-index sur le <li>.
184
+ *
185
+ * Fallback : on parcourt le DOM pour compter la position réelle de l'élément
186
+ * parmi ses frères de même type.
187
+ */
188
+ function getGlobalOrdinal(el, selector) {
189
+ // 1. data-index (NodeBB 3+/4+) — 0-based → on retourne 1-based
190
+ const di = el.getAttribute('data-index');
191
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10); // déjà 0-based, on le garde
192
+
193
+ // 2. Compter dans le DOM parmi les frères du même type
502
194
  try {
503
- if (!Array.isArray(allIds) || !allIds.length) return;
504
- const pool = getPoolEl();
505
- for (const id of allIds) {
506
- if (!id) continue;
507
- const domId = `${PLACEHOLDER_PREFIX}${id}`;
508
- if (document.getElementById(domId)) continue;
509
- const ph = document.createElement('div');
510
- ph.id = domId;
511
- ph.setAttribute('data-ezoic-id', String(id));
512
- pool.appendChild(ph);
195
+ const all = el.parentElement?.querySelectorAll?.(':scope > ' + selector.split('[')[0]);
196
+ if (all) {
197
+ let i = 0;
198
+ for (const node of all) {
199
+ if (node === el) return i;
200
+ i++;
201
+ }
513
202
  }
514
- } catch (e) {}
515
- }
203
+ } catch (_) {}
516
204
 
517
- function isInPool(ph) {
518
- try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
205
+ return 0;
519
206
  }
520
207
 
521
- function releaseWrapNode(wrap) {
522
- try {
523
- const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
524
- if (ph) {
525
- try { getPoolEl().appendChild(ph); } catch (e) {}
526
- try { state.io && state.io.unobserve(ph); } catch (e) {}
527
- }
528
- } catch (e) {}
529
- try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
208
+ /**
209
+ * Clé d'ancre unique et stable pour un élément donné.
210
+ * Format : "kindClass:globalOrdinal"
211
+ * Identique au scroll up/down, identique entre batches.
212
+ */
213
+ function getAnchorKey(kindClass, el, selector) {
214
+ const ord = getGlobalOrdinal(el, selector);
215
+ return `${kindClass}:${ord}`;
530
216
  }
531
217
 
532
- // ---------------- network warmup ----------------
533
-
534
- const _warmLinksDone = new Set();
535
- function warmUpNetwork() {
218
+ function findWrapByAnchor(anchorKey) {
219
+ // CSS.escape pour les : dans la clé
536
220
  try {
537
- const head = document.head || document.getElementsByTagName('head')[0];
538
- if (!head) return;
539
- const links = [
540
- // Ezoic
541
- ['preconnect', 'https://g.ezoic.net', true],
542
- ['dns-prefetch', 'https://g.ezoic.net', false],
543
- ['preconnect', 'https://go.ezoic.net', true],
544
- ['dns-prefetch', 'https://go.ezoic.net', false],
545
- // Google ads
546
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
547
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
548
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
549
- ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
550
- ['preconnect', 'https://tpc.googlesyndication.com', true],
551
- ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
552
- ];
553
- for (const [rel, href, cors] of links) {
554
- const key = `${rel}|${href}`;
555
- if (_warmLinksDone.has(key)) continue;
556
- _warmLinksDone.add(key);
557
- const link = document.createElement('link');
558
- link.rel = rel;
559
- link.href = href;
560
- if (cors) link.crossOrigin = 'anonymous';
561
- head.appendChild(link);
562
- }
563
- } catch (e) {}
564
- }
565
-
566
- // ---------------- Ezoic bridge ----------------
567
-
568
- // Patch showAds to silently skip ids not in DOM. This prevents console spam.
569
- function patchShowAds() {
570
- const applyPatch = () => {
571
- try {
572
- window.ezstandalone = window.ezstandalone || {};
573
- const ez = window.ezstandalone;
574
- if (window.__nodebbEzoicPatched) return;
575
- if (typeof ez.showAds !== 'function') return;
576
-
577
- window.__nodebbEzoicPatched = true;
578
- const orig = ez.showAds;
579
-
580
- ez.showAds = function (...args) {
581
- if (isBlocked()) return;
582
-
583
- let ids = [];
584
- if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
585
- else ids = args;
586
-
587
- const seen = new Set();
588
- for (const v of ids) {
589
- const id = parseInt(v, 10);
590
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
591
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
592
- if (!ph || !ph.isConnected) continue;
593
- seen.add(id);
594
- try { orig.call(ez, id); } catch (e) {}
595
- }
596
- };
597
- } catch (e) {}
598
- };
599
-
600
- applyPatch();
601
- if (!window.__nodebbEzoicPatched) {
602
- try {
603
- window.ezstandalone = window.ezstandalone || {};
604
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
605
- window.ezstandalone.cmd.push(applyPatch);
606
- } catch (e) {}
607
- }
608
- }
609
-
610
- // ---------------- config ----------------
611
-
612
- async function fetchConfigOnce() {
613
- if (state.cfg) return state.cfg;
614
- try {
615
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
616
- if (!res.ok) return null;
617
- state.cfg = await res.json();
618
- return state.cfg;
619
- } catch (e) {
620
- return null;
221
+ return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${anchorKey.replace(/"/g, '\\"')}"]`);
222
+ } catch (_) { return null; }
223
+ }
224
+
225
+ // ─── Pool rotation ────────────────────────────────────────────────────────
226
+ function pickId(poolKey) {
227
+ const pool = state.pools[poolKey];
228
+ if (!pool.length) return null;
229
+ for (let tries = 0; tries < pool.length; tries++) {
230
+ const idx = state.cursors[poolKey] % pool.length;
231
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
232
+ const id = pool[idx];
233
+ if (!state.mountedIds.has(id)) return id;
621
234
  }
235
+ return null;
622
236
  }
623
237
 
624
- function initPools(cfg) {
625
- if (!cfg) return;
626
- if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
627
- if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
628
- if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
629
-
630
- // IMPORTANT:
631
- // We do NOT prime a DOM pool anymore.
632
- // Keeping placeholders connected (even offscreen) can lead Ezoic/GPT to
633
- // pre-define slots, which then causes "Placeholder Id X has already been defined".
634
- // Instead, we create the placeholder element only when we actually inject its wrapper.
635
- }
636
-
637
- // ---------------- insertion primitives ----------------
638
-
639
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
238
+ // ─── Wrap build / insert / remove ─────────────────────────────────────────
239
+ function buildWrap(id, kindClass, anchorKey) {
640
240
  const wrap = document.createElement('div');
641
241
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
642
- wrap.setAttribute('data-ezoic-after', String(afterPos));
643
- wrap.setAttribute('data-ezoic-wrapid', String(id));
644
- wrap.setAttribute('data-created', String(now()));
645
- // "Pinned" placements (after the first item) should remain stable.
646
- if (afterPos === 1) {
647
- wrap.setAttribute('data-ezoic-pin', '1');
648
- }
649
- wrap.style.width = '100%';
650
-
651
- if (createPlaceholder) {
652
- const ph = document.createElement('div');
653
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
654
- ph.setAttribute('data-ezoic-id', String(id));
655
- wrap.appendChild(ph);
656
- }
242
+ wrap.setAttribute(ANCHOR_ATTR, anchorKey);
243
+ wrap.setAttribute(WRAPID_ATTR, String(id));
244
+ wrap.setAttribute(CREATED_ATTR, String(now()));
245
+ wrap.style.cssText = 'width:100%;display:block;';
657
246
 
247
+ const ph = document.createElement('div');
248
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
249
+ ph.setAttribute('data-ezoic-id', String(id));
250
+ wrap.appendChild(ph);
658
251
  return wrap;
659
252
  }
660
253
 
661
- function insertAfter(target, id, kindClass, afterPos) {
662
- if (!target || !target.insertAdjacentElement) return null;
663
- if (findWrap(kindClass, afterPos)) return null;
664
- if (insertingIds.has(id)) return null;
254
+ function insertWrapAfter(el, id, kindClass, anchorKey) {
255
+ if (!el?.insertAdjacentElement) return null;
256
+ if (findWrapByAnchor(anchorKey)) return null; // déjà inséré
257
+ if (state.mountedIds.has(id)) return null; // id déjà monté
258
+ if (document.getElementById(`${PLACEHOLDER_PREFIX}${id}`)?.isConnected) return null;
665
259
 
666
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
260
+ const wrap = buildWrap(id, kindClass, anchorKey);
261
+ withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
262
+ state.mountedIds.add(id);
263
+ return wrap;
264
+ }
667
265
 
668
- insertingIds.add(id);
266
+ function removeWrap(wrap) {
669
267
  try {
670
- const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
671
- target.insertAdjacentElement('afterend', wrap);
672
-
673
- // If placeholder exists elsewhere (including pool), move it into the wrapper.
674
- if (existingPh) {
675
- try {
676
- existingPh.setAttribute('data-ezoic-id', String(id));
677
- if (!wrap.firstElementChild) wrap.appendChild(existingPh);
678
- else wrap.replaceChild(existingPh, wrap.firstElementChild);
679
- } catch (e) {}
680
- }
681
-
682
- return wrap;
683
- } finally {
684
- insertingIds.delete(id);
685
- }
686
- }
268
+ const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
269
+ if (Number.isFinite(id)) state.mountedIds.delete(id);
270
+ try { state.io?.unobserve(wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`)); } catch (_) {}
271
+ wrap.remove();
272
+ } catch (_) {}
273
+ }
274
+
275
+ // ─── Prune (BUG FIX #1) ───────────────────────────────────────────────────
276
+ /**
277
+ * Supprime les wraps dont l'ancre DOM n'est plus connectée.
278
+ *
279
+ * Règle simple et sans exception "filled" :
280
+ * - Si l'élément ancre est présent et connecté → wrap OK, rien à faire.
281
+ * - Si l'élément ancre est absent (virtualisé/retiré) → wrap supprimé,
282
+ * qu'il soit rempli ou non. Cela libère l'ID pour réutilisation.
283
+ *
284
+ * On ne touche PAS aux wraps fraîchement créés (< 5s) pour laisser le
285
+ * temps à NodeBB de finir d'insérer les posts du batch.
286
+ */
287
+ function pruneOrphans(kindClass, selector) {
288
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
687
289
 
688
- function pickIdFromAll(allIds, cursorKey) {
689
- const n = allIds.length;
690
- if (!n) return null;
290
+ wraps.forEach(wrap => {
291
+ const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
292
+ if (now() - created < 5_000) return; // trop récent, on laisse
691
293
 
692
- for (let tries = 0; tries < n; tries++) {
693
- const idx = state[cursorKey] % n;
694
- state[cursorKey] = (state[cursorKey] + 1) % n;
695
- const id = allIds[idx];
294
+ const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
295
+ if (!anchorKey) { withInternalMutation(() => removeWrap(wrap)); return; }
696
296
 
697
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
698
- if (ph && ph.isConnected && !isInPool(ph)) continue;
297
+ // Retrouver l'ordinal depuis la clé
298
+ const ordStr = anchorKey.split(':').slice(1).join(':');
299
+ const ord = parseInt(ordStr, 10);
699
300
 
700
- return id;
701
- }
301
+ // Chercher l'élément ancre par son ordinal global (data-index)
302
+ const anchorEl = isNaN(ord)
303
+ ? null
304
+ : document.querySelector(`${selector.split('[')[0]}[data-index="${ord}"]`);
702
305
 
703
- return null;
306
+ if (!anchorEl || !anchorEl.isConnected) {
307
+ // Ancre disparue → suppression inconditionnelle
308
+ withInternalMutation(() => removeWrap(wrap));
309
+ }
310
+ });
704
311
  }
705
312
 
706
- function pruneOrphanWraps(kindClass, items) {
707
- // Topic pages can be virtualized (posts removed from DOM as you scroll).
708
- // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
709
- // nearby post containers, which leads to ads clustering together when scrolling back up.
710
- // We prune only *true* orphans that are far offscreen to keep the UI stable.
711
- if (!items || !items.length) return 0;
712
- const itemSet = new Set(items);
713
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
714
- let removed = 0;
715
-
716
- const isFilled = (wrap) => {
717
- return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
718
- };
313
+ // ─── Decluster (BUG FIX #2) ───────────────────────────────────────────────
314
+ /**
315
+ * Supprime les doublons adjacents.
316
+ * Ne touche JAMAIS un wrap qui vient de recevoir un showAds() (fill async).
317
+ */
318
+ function decluster(kindClass) {
319
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
719
320
 
720
- const hasNearbyItem = (wrap) => {
721
- // NodeBB/skins can inject separators/spacers; be tolerant.
321
+ for (const wrap of wraps) {
722
322
  let prev = wrap.previousElementSibling;
723
- for (let i = 0; i < 14 && prev; i++) {
724
- if (itemSet.has(prev)) return true;
323
+ let steps = 0;
324
+ while (prev && steps < 3) {
325
+ if (prev.classList?.contains(WRAP_CLASS)) {
326
+ const wShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
327
+ const pShown = parseInt(prev.getAttribute(SHOWN_ATTR) || '0', 10);
328
+ const wFilled = isFilledNode(wrap);
329
+ const pFilled = isFilledNode(prev);
330
+
331
+ // Ne jamais toucher un wrap en cours de fill (dans les FILL_GRACE_MS après show)
332
+ const wInGrace = wShown && (now() - wShown) < FILL_GRACE_MS;
333
+ const pInGrace = pShown && (now() - pShown) < FILL_GRACE_MS;
334
+
335
+ if (wInGrace || pInGrace) break; // les deux en grace → rien
336
+
337
+ if (!wFilled && !wInGrace) {
338
+ withInternalMutation(() => removeWrap(wrap));
339
+ } else if (!pFilled && !pInGrace) {
340
+ withInternalMutation(() => removeWrap(prev));
341
+ }
342
+ break;
343
+ }
725
344
  prev = prev.previousElementSibling;
345
+ steps++;
726
346
  }
727
- let next = wrap.nextElementSibling;
728
- for (let i = 0; i < 14 && next; i++) {
729
- if (itemSet.has(next)) return true;
730
- next = next.nextElementSibling;
731
- }
732
- return false;
733
- };
347
+ }
348
+ }
734
349
 
735
- wraps.forEach((wrap) => {
736
- // Never prune pinned placements.
737
- try {
738
- if (wrap.getAttribute('data-ezoic-pin') === '1') return;
739
- } catch (e) {}
350
+ // ─── Injection (BUG FIX #3) ───────────────────────────────────────────────
351
+ /**
352
+ * Calcule les positions cibles basées sur l'ordinal GLOBAL de chaque item.
353
+ *
354
+ * interval=3 → pub après les posts dont (globalOrdinal % interval === interval-1)
355
+ * c.-à-d. après les posts globaux 2, 5, 8, 11… (0-based)
356
+ *
357
+ * showFirst=true → aussi après le post global 0.
358
+ *
359
+ * Ce calcul est STABLE entre les batches : si les posts 0-19 sont en DOM,
360
+ * les cibles sont 2, 5, 8, 11, 14, 17. Si les posts 20-39 arrivent,
361
+ * les cibles deviennent 20 (si 20%3===2? non), 23, 26, 29, 32, 35, 38.
362
+ * Jamais de recalcul depuis 0.
363
+ */
364
+ function injectBetween(kindClass, items, interval, showFirst, poolKey, selector) {
365
+ if (!items.length) return 0;
740
366
 
741
- // For message/topic pages we may prune filled or empty orphans if they are far away,
742
- // otherwise consecutive "stacks" can appear when posts are virtualized.
743
- const isMessage = (kindClass === 'ezoic-ad-message');
744
- if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
367
+ const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
368
+ let inserted = 0;
745
369
 
746
- // Never prune a fresh wrap: it may fill late.
747
- try {
748
- const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
749
- if (created && (now() - created) < keepEmptyWrapMs()) return;
750
- } catch (e) {}
751
-
752
- if (hasNearbyItem(wrap)) {
753
- try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
754
- return;
755
- }
756
-
757
- // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
758
- // back-to-back while scrolling. We'll recycle it when its anchor comes back.
759
- try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
760
-
761
- // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
762
- if (isMessage) {
763
- try {
764
- const r = wrap.getBoundingClientRect();
765
- const vh = Math.max(1, window.innerHeight || 1);
766
- const farAbove = r.bottom < -vh * 2;
767
- const farBelow = r.top > vh * 4;
768
- if (!farAbove && !farBelow) return;
769
- } catch (e) {
770
- return;
771
- }
772
- }
370
+ for (const el of items) {
371
+ if (inserted >= maxIns) break;
372
+ if (!el?.isConnected) continue;
773
373
 
774
- withInternalDomChange(() => releaseWrapNode(wrap));
775
- removed++;
776
- });
374
+ const ord = getGlobalOrdinal(el, selector);
777
375
 
778
- return removed;
779
- }
376
+ // Est-ce une position cible ?
377
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
378
+ if (!isTarget) continue;
780
379
 
781
- function decluster(kindClass) {
782
- // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
783
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
784
- if (wraps.length < 2) return 0;
380
+ if (hasAdjacentWrap(el)) continue;
785
381
 
786
- const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
787
-
788
- const isFilled = (wrap) => {
789
- return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
790
- };
382
+ const anchorKey = `${kindClass}:${ord}`;
383
+ if (findWrapByAnchor(anchorKey)) continue;
791
384
 
792
- const isFresh = (wrap) => {
793
- try {
794
- const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
795
- return created && (now() - created) < keepEmptyWrapMs();
796
- } catch (e) {
797
- return false;
798
- }
799
- };
385
+ const id = pickId(poolKey);
386
+ if (!id) break;
800
387
 
801
- let removed = 0;
802
- for (const w of wraps) {
803
- // Never decluster pinned placements.
804
- try {
805
- if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
806
- } catch (e) {}
807
-
808
- let prev = w.previousElementSibling;
809
- for (let i = 0; i < 3 && prev; i++) {
810
- if (isWrap(prev)) {
811
- // If the previous wrap is pinned, keep this one (spacing is intentional).
812
- try {
813
- if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
814
- } catch (e) {}
815
-
816
- // Never remove a wrap that is already filled; otherwise it looks like
817
- // ads "disappear" while scrolling. Only remove the empty neighbour.
818
- const prevFilled = isFilled(prev);
819
- const curFilled = isFilled(w);
820
-
821
- if (curFilled) {
822
- // If the previous one is empty (and not fresh), drop the previous instead.
823
- if (!prevFilled && !isFresh(prev)) {
824
- withInternalDomChange(() => releaseWrapNode(prev));
825
- removed++;
826
- }
827
- break;
828
- }
388
+ const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
389
+ if (!wrap) continue;
829
390
 
830
- // Current is empty.
831
- // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
832
- // Only decluster when previous is filled, or when current is stale.
833
- if (prevFilled || !isFresh(w)) {
834
- withInternalDomChange(() => releaseWrapNode(w));
835
- removed++;
836
- }
837
- break;
838
- }
839
- prev = prev.previousElementSibling;
840
- }
391
+ observePlaceholder(id);
392
+ inserted++;
841
393
  }
842
- return removed;
394
+
395
+ return inserted;
843
396
  }
844
397
 
845
- // ---------------- show (preload / fast fill) ----------------
398
+ // ─── Preload / Show ───────────────────────────────────────────────────────
399
+ function getPreloadMargin() {
400
+ const m = isMobile() ? 'mobile' : 'desktop';
401
+ return PRELOAD_MARGIN[m + (isBoosted() ? 'Boosted' : '')];
402
+ }
403
+
404
+ function getMaxInflight() {
405
+ return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
406
+ }
846
407
 
847
408
  function ensurePreloadObserver() {
848
- const desiredMargin = getPreloadRootMargin();
849
- if (state.io && state.ioMargin === desiredMargin) return state.io;
409
+ const margin = getPreloadMargin();
410
+ if (state.io && state.ioMargin === margin) return state.io;
850
411
 
851
- if (state.io) {
852
- try { state.io.disconnect(); } catch (e) {}
853
- state.io = null;
854
- }
412
+ state.io?.disconnect();
413
+ state.io = null;
855
414
 
856
415
  try {
857
- state.io = new IntersectionObserver((entries) => {
416
+ state.io = new IntersectionObserver(entries => {
858
417
  for (const ent of entries) {
859
418
  if (!ent.isIntersecting) continue;
860
- const el = ent.target;
861
- try { state.io && state.io.unobserve(el); } catch (e) {}
862
-
863
- const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
864
- const id = parseInt(idAttr, 10);
419
+ state.io?.unobserve(ent.target);
420
+ const id = parseInt(ent.target.getAttribute('data-ezoic-id'), 10);
865
421
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
866
422
  }
867
- }, { root: null, rootMargin: desiredMargin, threshold: 0 });
868
-
869
- state.ioMargin = desiredMargin;
870
- } catch (e) {
871
- state.io = null;
872
- state.ioMargin = null;
873
- }
423
+ }, { root: null, rootMargin: margin, threshold: 0 });
424
+ state.ioMargin = margin;
425
+ } catch (_) { state.io = null; state.ioMargin = null; }
874
426
 
875
- // Re-observe current placeholders
876
427
  try {
877
- if (state.io) {
878
- const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
879
- nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
880
- }
881
- } catch (e) {}
428
+ document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
429
+ try { state.io?.observe(n); } catch (_) {}
430
+ });
431
+ } catch (_) {}
882
432
 
883
433
  return state.io;
884
434
  }
885
435
 
886
436
  function observePlaceholder(id) {
887
437
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
888
- if (!ph || !ph.isConnected) return;
889
- ph.setAttribute('data-ezoic-id', String(id));
438
+ if (!ph?.isConnected) return;
439
+ try { state.io?.observe(ph); } catch (_) {}
890
440
 
891
- const io = ensurePreloadObserver();
892
- try { io && io.observe(ph); } catch (e) {}
893
-
894
- // If already near viewport, fire immediately.
895
- // Mobile tends to scroll faster + has slower auctions, so we fire earlier.
896
441
  try {
897
442
  const r = ph.getBoundingClientRect();
898
- const mobile = isMobile();
899
- const screens = isBoosted()
900
- ? (mobile ? 9.0 : 5.0)
901
- : (mobile ? 6.0 : 3.0);
902
- const minBottom = isBoosted()
903
- ? (mobile ? -2600 : -1500)
904
- : (mobile ? -1400 : -800);
905
- if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
906
- } catch (e) {}
443
+ const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
444
+ if (r.top < window.innerHeight * screens && r.bottom > -window.innerHeight * 2) {
445
+ enqueueShow(id);
446
+ }
447
+ } catch (_) {}
907
448
  }
908
449
 
909
450
  function enqueueShow(id) {
910
451
  if (!id || isBlocked()) return;
911
-
912
- // per-id throttle
913
452
  const t = now();
914
- const last = state.lastShowById.get(id) || 0;
915
- if (t - last < 900) return;
916
-
917
- const max = getMaxInflight();
918
- if (state.inflight >= max) {
919
- if (!state.pendingSet.has(id)) {
920
- state.pending.push(id);
921
- state.pendingSet.add(id);
922
- }
453
+ if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
454
+
455
+ if (state.inflight >= getMaxInflight()) {
456
+ if (!state.pendingSet.has(id)) { state.pending.push(id); state.pendingSet.add(id); }
923
457
  return;
924
458
  }
925
-
926
459
  startShow(id);
927
460
  }
928
461
 
929
462
  function drainQueue() {
930
463
  if (isBlocked()) return;
931
- const max = getMaxInflight();
932
- while (state.inflight < max && state.pending.length) {
464
+ while (state.inflight < getMaxInflight() && state.pending.length) {
933
465
  const id = state.pending.shift();
934
466
  state.pendingSet.delete(id);
935
467
  startShow(id);
936
468
  }
937
469
  }
938
470
 
939
- function markEmptyWrapper(id) {
940
- // If still empty after delay, mark empty for CSS (1px)
941
- try {
942
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
943
- if (!ph || !ph.isConnected) return;
944
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
945
- if (!wrap) return;
946
-
947
- setTimeout(() => {
948
- try {
949
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
950
- if (!ph2 || !ph2.isConnected) return;
951
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
952
- if (!w2) return;
953
-
954
- // Don't collapse "fresh" placements; slow auctions/CMP can fill late.
955
- try {
956
- const created = parseInt(w2.getAttribute('data-created') || '0', 10);
957
- if (created && (now() - created) < keepEmptyWrapMs()) return;
958
- } catch (e) {}
959
-
960
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
961
- if (!hasAd) {
962
- w2.classList.add('is-empty');
963
- watchWrapForFill(w2);
964
- } else {
965
- w2.classList.remove('is-empty');
966
- tightenEzoicMinHeight(w2);
967
- }
968
- } catch (e) {}
969
- }, 15000);
970
- } catch (e) {}
971
- }
972
-
973
471
  function startShow(id) {
974
472
  if (!id || isBlocked()) return;
975
-
976
473
  state.inflight++;
977
- let released = false;
474
+ let done = false;
475
+
978
476
  const release = () => {
979
- if (released) return;
980
- released = true;
477
+ if (done) return;
478
+ done = true;
981
479
  state.inflight = Math.max(0, state.inflight - 1);
982
480
  drainQueue();
983
481
  };
984
482
 
985
- const hardTimer = setTimeout(release, 6500);
483
+ const timeout = setTimeout(release, 6500);
986
484
 
987
485
  requestAnimationFrame(() => {
988
486
  try {
989
- if (isBlocked()) return;
487
+ if (isBlocked()) { clearTimeout(timeout); return release(); }
990
488
 
991
489
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
992
- if (!ph || !ph.isConnected) return;
993
-
994
- // If the placeholder already has creative, avoid re-showing.
995
- // Re-showing is a common source of "Placeholder Id X has already been defined".
996
- try {
997
- if (ph.querySelector && ph.querySelector('iframe, ins, img, video, [data-google-container-id]')) {
998
- clearTimeout(hardTimer);
999
- release();
1000
- return;
1001
- }
1002
- } catch (e) {}
490
+ if (!ph?.isConnected) { clearTimeout(timeout); return release(); }
491
+ if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
1003
492
 
1004
493
  const t = now();
1005
- const last = state.lastShowById.get(id) || 0;
1006
- if (t - last < 900) return;
494
+ if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) {
495
+ clearTimeout(timeout); return release();
496
+ }
1007
497
  state.lastShowById.set(id, t);
1008
498
 
499
+ // Marquer le timestamp du show sur le wrap (pour decluster grace period)
500
+ try {
501
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
502
+ if (wrap) wrap.setAttribute(SHOWN_ATTR, String(t));
503
+ } catch (_) {}
504
+
1009
505
  window.ezstandalone = window.ezstandalone || {};
1010
506
  const ez = window.ezstandalone;
1011
507
 
1012
508
  const doShow = () => {
1013
- try { ez.showAds(id); } catch (e) {}
1014
- try { markEmptyWrapper(id); } catch (e) {}
1015
- try {
1016
- const phw = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
1017
- const ww = phw && phw.closest ? phw.closest(`.${WRAP_CLASS}`) : null;
1018
- if (ww) {
1019
- watchWrapForFill(ww);
1020
- setTimeout(() => { try { tightenEzoicMinHeight(ww); } catch (e) {} }, 900);
1021
- setTimeout(() => { try { tightenEzoicMinHeight(ww); } catch (e) {} }, 2200);
1022
- }
1023
- } catch (e) {}
1024
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
509
+ try { ez.showAds(id); } catch (_) {}
510
+ scheduleEmptyCheck(id, t);
511
+ setTimeout(() => { clearTimeout(timeout); release(); }, 650);
1025
512
  };
1026
513
 
1027
- if (Array.isArray(ez.cmd)) {
1028
- try { ez.cmd.push(doShow); } catch (e) { doShow(); }
1029
- } else {
1030
- doShow();
1031
- }
1032
- } finally {
1033
- // hardTimer releases on early return
1034
- }
514
+ if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
515
+ else doShow();
516
+ } catch (_) { clearTimeout(timeout); release(); }
1035
517
  });
1036
518
  }
1037
519
 
1038
- // ---------------- core injection ----------------
1039
-
1040
- function getItemOrdinal(el, fallbackIndex) {
1041
- try {
1042
- if (!el) return fallbackIndex + 1;
1043
- const di = el.getAttribute('data-index') || (el.dataset && (el.dataset.index || el.dataset.postIndex));
1044
- if (di !== null && di !== undefined && di !== '' && !isNaN(di)) {
1045
- const n = parseInt(di, 10);
1046
- if (Number.isFinite(n) && n >= 0) return n + 1;
1047
- }
1048
- const d1 = el.getAttribute('data-idx') || el.getAttribute('data-position') || (el.dataset && (el.dataset.idx || el.dataset.position));
1049
- if (d1 !== null && d1 !== undefined && d1 !== '' && !isNaN(d1)) {
1050
- const n = parseInt(d1, 10);
1051
- if (Number.isFinite(n) && n > 0) return n;
1052
- }
1053
- } catch (e) {}
1054
- return fallbackIndex + 1;
1055
- }
1056
-
1057
- function buildOrdinalMap(items) {
1058
- const map = new Map();
1059
- let max = 0;
1060
- for (let i = 0; i < items.length; i++) {
1061
- const el = items[i];
1062
- const ord = getItemOrdinal(el, i);
1063
- map.set(ord, el);
1064
- if (ord > max) max = ord;
1065
- }
1066
- return { map, max };
1067
- }
1068
-
1069
-
1070
- function computeTargets(count, interval, showFirst) {
1071
- const out = [];
1072
- if (count <= 0) return out;
1073
- if (showFirst) out.push(1);
1074
- for (let i = 1; i <= count; i++) {
1075
- if (i % interval === 0) out.push(i);
1076
- }
1077
- // unique + sorted
1078
- return Array.from(new Set(out)).sort((a, b) => a - b);
1079
- }
1080
-
1081
- function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
1082
- if (!items.length) return 0;
1083
-
1084
- const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
1085
- const targets = computeTargets(maxOrdinal, interval, showFirst);
1086
- let inserted = 0;
1087
- const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
1088
-
1089
- for (const afterPos of targets) {
1090
- if (inserted >= maxInserts) break;
1091
- const el = ordinalMap.get(afterPos);
1092
- if (!el) continue;
1093
- if (!el || !el.isConnected) continue;
1094
- if (isAdjacentAd(el)) continue;
1095
- if (findWrap(kindClass, afterPos)) continue;
1096
-
1097
- let id = pickIdFromAll(allIds, cursorKey);
1098
- let recycledWrap = null;
1099
-
1100
- // If the pool is exhausted (all placeholder ids already mounted), recycle a wrap that is far
1101
- // above the viewport by moving it to the new target instead of creating a new placeholder.
1102
- // This avoids "Placeholder Id X has already been defined" and also ensures ads keep
1103
- // appearing on very long infinite scroll sessions.
1104
- if (!id) {
1105
- // Safe mode: disable recycling for topic message ads to prevent visual "jumping"
1106
- // (ads seemingly moving back under the first post during virtualized scroll).
1107
- const allowRecycle = kindClass !== 'ezoic-ad-message';
1108
- recycledWrap = (allowRecycle && scrollDir > 0) ? pickRecyclableWrap(kindClass) : null;
1109
- if (recycledWrap) {
1110
- id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
1111
- }
1112
- }
1113
-
1114
- if (!id) break;
520
+ /**
521
+ * Vérifie si le wrap est toujours vide après EMPTY_CHECK_DELAY.
522
+ * On compare avec le timestamp du show pour éviter de colapser
523
+ * un wrap qui aurait reçu un nouveau show entre-temps.
524
+ */
525
+ function scheduleEmptyCheck(id, showTs) {
526
+ setTimeout(() => {
527
+ try {
528
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
529
+ if (!ph?.isConnected) return;
530
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
531
+ if (!wrap) return;
1115
532
 
1116
- const wrap = recycledWrap
1117
- ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
1118
- : insertAfter(el, id, kindClass, afterPos);
1119
- if (!wrap) continue;
533
+ // Si un nouveau show a eu lieu après celui-ci, ne pas colapser
534
+ const lastShown = parseInt(wrap.getAttribute(SHOWN_ATTR) || '0', 10);
535
+ if (lastShown > showTs) return;
1120
536
 
1121
- // observePlaceholder is safe for existing ids (it is idempotent) and helps with "late fill"
1122
- // after ajaxify/infinite scroll mutations.
1123
- observePlaceholder(id);
1124
- inserted++;
1125
- }
1126
-
1127
- return inserted;
537
+ if (!isFilledNode(ph)) wrap.classList.add('is-empty');
538
+ else wrap.classList.remove('is-empty');
539
+ } catch (_) {}
540
+ }, EMPTY_CHECK_DELAY);
1128
541
  }
1129
542
 
1130
- function pickRecyclableWrap(kindClass) {
1131
- // Only recycle wrappers that are well above the viewport to avoid visible "disappearing".
1132
- // With very small id pools (e.g. 6 ids), recycling is the only way to keep ads appearing
1133
- // on long topics without redefining placeholders.
1134
- const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
1135
- if (!wraps || !wraps.length) return null;
1136
-
1137
- const vh = Math.max(300, window.innerHeight || 800);
1138
- // Recycle only when the wrapper is far above the viewport.
1139
- // This keeps ads on-screen longer (especially on mobile) and reduces "blink".
1140
- const threshold = -Math.min(9000, Math.round(vh * 6));
1141
-
1142
- let best = null;
1143
- let bestBottom = Infinity;
1144
- for (const w of wraps) {
1145
- if (!w || !w.isConnected) continue;
1146
- if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
1147
- const rect = w.getBoundingClientRect();
1148
- if (rect.bottom < threshold) {
1149
- if (rect.bottom < bestBottom) {
1150
- bestBottom = rect.bottom;
1151
- best = w;
1152
- }
1153
- }
1154
- }
1155
- return best;
1156
- }
543
+ // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
544
+ function patchShowAds() {
545
+ const apply = () => {
546
+ try {
547
+ window.ezstandalone = window.ezstandalone || {};
548
+ const ez = window.ezstandalone;
549
+ if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
1157
550
 
1158
- function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
1159
- try {
1160
- if (!anchorEl || !wrap || !wrap.isConnected) return null;
551
+ window.__nodebbEzoicPatched = true;
552
+ const orig = ez.showAds.bind(ez);
1161
553
 
1162
- wrap.setAttribute('data-ezoic-after', String(afterPos));
1163
- anchorEl.insertAdjacentElement('afterend', wrap);
554
+ ez.showAds = function (...args) {
555
+ if (isBlocked()) return;
556
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
557
+ const seen = new Set();
558
+ for (const v of ids) {
559
+ const id = parseInt(v, 10);
560
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
561
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
562
+ if (!ph?.isConnected) continue;
563
+ seen.add(id);
564
+ try { orig(id); } catch (_) {}
565
+ }
566
+ };
567
+ } catch (_) {}
568
+ };
1164
569
 
1165
- // Ensure minimal layout impact.
1166
- try { wrap.style.contain = 'layout style paint'; } catch (e) {}
1167
- try { tightenStickyIn(wrap); } catch (e) {}
1168
- return wrap;
1169
- } catch (e) {
1170
- return null;
570
+ apply();
571
+ if (!window.__nodebbEzoicPatched) {
572
+ window.ezstandalone = window.ezstandalone || {};
573
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
574
+ window.ezstandalone.cmd.push(apply);
1171
575
  }
1172
576
  }
1173
577
 
578
+ // ─── Core run ─────────────────────────────────────────────────────────────
1174
579
  async function runCore() {
1175
580
  if (isBlocked()) return 0;
1176
-
1177
581
  patchShowAds();
1178
582
 
1179
- const cfg = await fetchConfigOnce();
583
+ const cfg = await fetchConfig();
1180
584
  if (!cfg || cfg.excluded) return 0;
1181
585
  initPools(cfg);
1182
586
 
1183
587
  const kind = getKind();
1184
588
  let inserted = 0;
1185
589
 
590
+ /**
591
+ * @param {string} kindClass
592
+ * @param {() => Element[]} getItems
593
+ * @param {string} selector - sélecteur CSS de base (pour ordinal fallback)
594
+ * @param {*} cfgEnable
595
+ * @param {number} cfgInterval
596
+ * @param {*} cfgShowFirst
597
+ * @param {string} poolKey
598
+ */
599
+ const run = (kindClass, getItems, selector, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
600
+ if (!normBool(cfgEnable)) return 0;
601
+ const items = getItems();
602
+ const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
603
+ const first = normBool(cfgShowFirst);
604
+
605
+ pruneOrphans(kindClass, selector);
606
+ const n = injectBetween(kindClass, items, interval, first, poolKey, selector);
607
+ if (n) decluster(kindClass);
608
+ return n;
609
+ };
610
+
1186
611
  if (kind === 'topic') {
1187
- if (normalizeBool(cfg.enableMessageAds)) {
1188
- const items = getPostContainers();
1189
- pruneOrphanWraps('ezoic-ad-message', items);
1190
- inserted += injectBetween(
1191
- 'ezoic-ad-message',
1192
- items,
1193
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
1194
- normalizeBool(cfg.showFirstMessageAd),
1195
- state.allPosts,
1196
- 'curPosts'
1197
- );
1198
- decluster('ezoic-ad-message');
1199
- }
612
+ inserted += run('ezoic-ad-message', getPostContainers, SELECTORS.postItem,
613
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
1200
614
  } else if (kind === 'categoryTopics') {
1201
- if (normalizeBool(cfg.enableBetweenAds)) {
1202
- const items = getTopicItems();
1203
- pruneOrphanWraps('ezoic-ad-between', items);
1204
- inserted += injectBetween(
1205
- 'ezoic-ad-between',
1206
- items,
1207
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
1208
- normalizeBool(cfg.showFirstTopicAd),
1209
- state.allTopics,
1210
- 'curTopics'
1211
- );
1212
- decluster('ezoic-ad-between');
1213
- }
615
+ inserted += run('ezoic-ad-between', getTopicItems, SELECTORS.topicItem,
616
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
1214
617
  } else if (kind === 'categories') {
1215
- if (normalizeBool(cfg.enableCategoryAds)) {
1216
- const items = getCategoryItems();
1217
- pruneOrphanWraps('ezoic-ad-categories', items);
1218
- inserted += injectBetween(
1219
- 'ezoic-ad-categories',
1220
- items,
1221
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
1222
- normalizeBool(cfg.showFirstCategoryAd),
1223
- state.allCategories,
1224
- 'curCategories'
1225
- );
1226
- decluster('ezoic-ad-categories');
1227
- }
618
+ inserted += run('ezoic-ad-categories', getCategoryItems, SELECTORS.categoryItem,
619
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
1228
620
  }
1229
621
 
1230
622
  return inserted;
1231
623
  }
1232
624
 
1233
- async function insertHeroAdEarly() {
1234
- if (state.heroDoneForPage || isBlocked()) return;
1235
-
1236
- const cfg = await fetchConfigOnce();
1237
- if (!cfg || cfg.excluded) return;
1238
- initPools(cfg);
1239
-
1240
- const kind = getKind();
1241
-
1242
- let items = [];
1243
- let allIds = [];
1244
- let cursorKey = '';
1245
- let kindClass = '';
1246
- let showFirst = false;
1247
-
1248
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
1249
- items = getPostContainers();
1250
- allIds = state.allPosts;
1251
- cursorKey = 'curPosts';
1252
- kindClass = 'ezoic-ad-message';
1253
- showFirst = normalizeBool(cfg.showFirstMessageAd);
1254
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
1255
- items = getTopicItems();
1256
- allIds = state.allTopics;
1257
- cursorKey = 'curTopics';
1258
- kindClass = 'ezoic-ad-between';
1259
- showFirst = normalizeBool(cfg.showFirstTopicAd);
1260
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
1261
- items = getCategoryItems();
1262
- allIds = state.allCategories;
1263
- cursorKey = 'curCategories';
1264
- kindClass = 'ezoic-ad-categories';
1265
- showFirst = normalizeBool(cfg.showFirstCategoryAd);
1266
- } else {
1267
- return;
1268
- }
1269
-
1270
- if (!items.length) return;
1271
- if (!showFirst) { state.heroDoneForPage = true; return; }
1272
-
1273
- const afterPos = 1;
1274
- const el = items[0];
1275
- if (!el || !el.isConnected) return;
1276
- if (isAdjacentAd(el)) return;
1277
- if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
1278
-
1279
- const id = pickIdFromAll(allIds, cursorKey);
1280
- if (!id) return;
1281
-
1282
- const wrap = insertAfter(el, id, kindClass, afterPos);
1283
- if (!wrap) return;
1284
-
1285
- state.heroDoneForPage = true;
1286
- observePlaceholder(id);
1287
- // Hero placement is expected to be visible right away (after first item),
1288
- // so kick a fill request immediately instead of waiting only for IO callbacks.
1289
- enqueueShow(id);
1290
- startShowQueue();
1291
- }
1292
-
1293
- // ---------------- scheduler ----------------
1294
-
1295
- function scheduleRun(delayMs = 0, cb) {
625
+ // ─── Scheduler / Burst ────────────────────────────────────────────────────
626
+ function scheduleRun(delayMs, cb) {
1296
627
  if (state.runQueued) return;
1297
628
  state.runQueued = true;
1298
629
 
1299
630
  const run = async () => {
1300
631
  state.runQueued = false;
1301
- const pk = getPageKey();
1302
- if (state.pageKey && pk !== state.pageKey) return;
1303
- let inserted = 0;
1304
- try { inserted = await runCore(); } catch (e) { inserted = 0; }
1305
- try { cb && cb(inserted); } catch (e) {}
632
+ if (state.pageKey && getPageKey() !== state.pageKey) return;
633
+ let n = 0;
634
+ try { n = await runCore(); } catch (_) {}
635
+ try { cb?.(n); } catch (_) {}
1306
636
  };
1307
637
 
1308
638
  const doRun = () => requestAnimationFrame(run);
@@ -1312,83 +642,64 @@ function buildOrdinalMap(items) {
1312
642
 
1313
643
  function requestBurst() {
1314
644
  if (isBlocked()) return;
1315
-
1316
645
  const t = now();
1317
- if (t - state.lastBurstReqTs < 120) return;
646
+ if (t - state.lastBurstReqTs < 100) return;
1318
647
  state.lastBurstReqTs = t;
1319
648
 
1320
649
  const pk = getPageKey();
1321
- state.pageKey = pk;
1322
-
650
+ state.pageKey = pk;
1323
651
  state.burstDeadline = t + 1800;
1324
- if (state.burstActive) return;
1325
652
 
653
+ if (state.burstActive) return;
1326
654
  state.burstActive = true;
1327
- state.burstCount = 0;
655
+ state.burstCount = 0;
1328
656
 
1329
657
  const step = () => {
1330
- if (getPageKey() !== pk) { state.burstActive = false; return; }
1331
- if (isBlocked()) { state.burstActive = false; return; }
1332
- if (now() > state.burstDeadline) { state.burstActive = false; return; }
1333
- if (state.burstCount >= 8) { state.burstActive = false; return; }
658
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
659
+ if (isBlocked()) { state.burstActive = false; return; }
660
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
661
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
1334
662
 
1335
663
  state.burstCount++;
1336
- scheduleRun(0, (inserted) => {
1337
- // Continue while we are still inserting or we have pending shows.
1338
- const hasWork = inserted > 0 || state.pending.length > 0;
1339
- if (!hasWork) { state.burstActive = false; return; }
1340
- // Short delay keeps UI smooth while catching late DOM waves.
1341
- setTimeout(step, inserted > 0 ? 120 : 220);
664
+ scheduleRun(0, (n) => {
665
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
666
+ setTimeout(step, n > 0 ? 120 : 250);
1342
667
  });
1343
668
  };
1344
669
 
1345
670
  step();
1346
671
  }
1347
672
 
1348
- // ---------------- lifecycle ----------------
1349
-
673
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
1350
674
  function cleanup() {
1351
- blockedUntil = now() + 1200;
675
+ blockedUntil = now() + 1500;
1352
676
 
1353
- try {
1354
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => releaseWrapNode(el));
1355
- } catch (e) {}
1356
-
1357
- state.cfg = null;
1358
- state.allTopics = [];
1359
- state.allPosts = [];
1360
- state.allCategories = [];
1361
- state.curTopics = 0;
1362
- state.curPosts = 0;
1363
- state.curCategories = 0;
1364
- state.lastShowById.clear();
677
+ withInternalMutation(() => {
678
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
679
+ });
1365
680
 
1366
- state.inflight = 0;
1367
- state.pending = [];
681
+ state.cfg = null;
682
+ state.pools = { topics: [], posts: [], categories: [] };
683
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
684
+ state.mountedIds.clear();
685
+ state.lastShowById.clear();
686
+ state.inflight = 0;
687
+ state.pending = [];
1368
688
  state.pendingSet.clear();
1369
-
1370
- state.heroDoneForPage = false;
1371
-
1372
- // keep observers alive
689
+ state.burstActive = false;
690
+ state.runQueued = false;
1373
691
  }
1374
692
 
1375
- function shouldReactToMutations(mutations) {
1376
- // Fast filter: only react if relevant nodes were added/removed.
693
+ // ─── DOM Observer ─────────────────────────────────────────────────────────
694
+ function shouldReact(mutations) {
1377
695
  for (const m of mutations) {
1378
- if (!m.addedNodes || m.addedNodes.length === 0) continue;
696
+ if (!m.addedNodes?.length) continue;
1379
697
  for (const n of m.addedNodes) {
1380
- if (!n || n.nodeType !== 1) continue;
1381
- const el = /** @type {Element} */ (n);
1382
- if (
1383
- el.matches?.(SELECTORS.postItem) ||
1384
- el.matches?.(SELECTORS.topicItem) ||
1385
- el.matches?.(SELECTORS.categoryItem) ||
1386
- el.querySelector?.(SELECTORS.postItem) ||
1387
- el.querySelector?.(SELECTORS.topicItem) ||
1388
- el.querySelector?.(SELECTORS.categoryItem)
1389
- ) {
698
+ if (n.nodeType !== 1) continue;
699
+ if (n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
700
+ n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
701
+ n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem))
1390
702
  return true;
1391
- }
1392
703
  }
1393
704
  }
1394
705
  return false;
@@ -1396,299 +707,146 @@ function buildOrdinalMap(items) {
1396
707
 
1397
708
  function ensureDomObserver() {
1398
709
  if (state.domObs) return;
1399
-
1400
- state.domObs = new MutationObserver((mutations) => {
1401
- if (state.internalDomChange > 0) return;
710
+ state.domObs = new MutationObserver(mutations => {
711
+ if (state.internalMutation > 0) return;
1402
712
  if (isBlocked()) return;
1403
- if (!shouldReactToMutations(mutations)) return;
713
+ if (!shouldReact(mutations)) return;
1404
714
  requestBurst();
1405
715
  });
716
+ try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
717
+ }
1406
718
 
719
+ // ─── Utilities ────────────────────────────────────────────────────────────
720
+ function muteNoisyConsole() {
721
+ if (window.__nodebbEzoicConsoleMuted) return;
722
+ window.__nodebbEzoicConsoleMuted = true;
723
+ const MUTED = [
724
+ '[EzoicAds JS]: Placeholder Id',
725
+ 'Debugger iframe already exists',
726
+ 'HTML element with id ezoic-pub-ad-placeholder-',
727
+ ];
728
+ ['log', 'info', 'warn', 'error'].forEach(m => {
729
+ const orig = console[m];
730
+ if (typeof orig !== 'function') return;
731
+ console[m] = function (...args) {
732
+ const s = typeof args[0] === 'string' ? args[0] : '';
733
+ if (MUTED.some(p => s.includes(p))) return;
734
+ orig.apply(console, args);
735
+ };
736
+ });
737
+ }
738
+
739
+ function ensureTcfLocator() {
1407
740
  try {
1408
- state.domObs.observe(document.body, { childList: true, subtree: true });
1409
- } catch (e) {}
741
+ if (!window.__tcfapi && !window.__cmp) return;
742
+ if (document.getElementById('__tcfapiLocator')) return;
743
+ const f = document.createElement('iframe');
744
+ f.style.display = 'none'; f.id = '__tcfapiLocator'; f.name = '__tcfapiLocator';
745
+ (document.body || document.documentElement).appendChild(f);
746
+ } catch (_) {}
747
+ }
748
+
749
+ const _warmedLinks = new Set();
750
+ function warmNetwork() {
751
+ const head = document.head;
752
+ if (!head) return;
753
+ const links = [
754
+ ['preconnect', 'https://g.ezoic.net', true],
755
+ ['preconnect', 'https://go.ezoic.net', true],
756
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
757
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
758
+ ['dns-prefetch', 'https://g.ezoic.net', false],
759
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
760
+ ];
761
+ for (const [rel, href, cors] of links) {
762
+ const key = `${rel}|${href}`;
763
+ if (_warmedLinks.has(key)) continue;
764
+ _warmedLinks.add(key);
765
+ const link = document.createElement('link');
766
+ link.rel = rel; link.href = href;
767
+ if (cors) link.crossOrigin = 'anonymous';
768
+ head.appendChild(link);
769
+ }
1410
770
  }
1411
771
 
772
+ // ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
1412
773
  function bindNodeBB() {
774
+ const $ = window.jQuery;
1413
775
  if (!$) return;
1414
776
 
1415
777
  $(window).off('.ezoicInfinite');
1416
778
 
1417
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
1418
- cleanup();
1419
- });
779
+ $(window).on('action:ajaxify.start.ezoicInfinite', cleanup);
1420
780
 
1421
781
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
1422
782
  state.pageKey = getPageKey();
1423
- blockedUntil = 0;
1424
-
783
+ blockedUntil = 0;
1425
784
  muteNoisyConsole();
1426
- ensureTcfApiLocator();
1427
- warmUpNetwork();
785
+ ensureTcfLocator();
786
+ warmNetwork();
1428
787
  patchShowAds();
1429
- globalGapFixInit();
1430
788
  ensurePreloadObserver();
1431
789
  ensureDomObserver();
1432
-
1433
- insertHeroAdEarly().catch(() => {});
1434
790
  requestBurst();
1435
791
  });
1436
792
 
1437
- // Some setups populate content in multiple phases; ensure we re-scan.
1438
- $(window).on('action:ajaxify.contentLoaded.ezoicInfinite', () => {
1439
- if (isBlocked()) return;
1440
- requestBurst();
1441
- });
793
+ const burstEvents = [
794
+ 'action:ajaxify.contentLoaded',
795
+ 'action:posts.loaded',
796
+ 'action:topics.loaded',
797
+ 'action:categories.loaded',
798
+ 'action:category.loaded',
799
+ 'action:topic.loaded',
800
+ ].map(e => `${e}.ezoicInfinite`).join(' ');
1442
801
 
1443
- $(window).on(
1444
- 'action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:categories.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite',
1445
- () => {
1446
- if (isBlocked()) return;
1447
- requestBurst();
1448
- }
1449
- );
802
+ $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1450
803
 
1451
- // Also listen through NodeBB's AMD hooks module when available.
1452
804
  try {
1453
- require(['hooks'], (hooks) => {
1454
- if (!hooks || typeof hooks.on !== 'function') return;
1455
- ['action:ajaxify.end', 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded'].forEach((ev) => {
1456
- try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (e) {}
805
+ require(['hooks'], hooks => {
806
+ if (typeof hooks?.on !== 'function') return;
807
+ [
808
+ 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
809
+ 'action:posts.loaded', 'action:topics.loaded',
810
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
811
+ ].forEach(ev => {
812
+ try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
1457
813
  });
1458
814
  });
1459
- } catch (e) {}
815
+ } catch (_) {}
1460
816
  }
1461
817
 
1462
818
  function bindScroll() {
1463
819
  let ticking = false;
1464
820
  window.addEventListener('scroll', () => {
1465
- // Fast-scroll boost
1466
821
  try {
1467
- const t = now();
1468
- const y = window.scrollY || window.pageYOffset || 0;
822
+ const t = now(), y = window.scrollY || window.pageYOffset || 0;
1469
823
  if (state.lastScrollTs) {
1470
- const dt = t - state.lastScrollTs;
1471
- const dy = Math.abs(y - (state.lastScrollY || 0));
1472
- if (dt > 0) {
1473
- const speed = dy / dt;
1474
- if (speed >= BOOST_SPEED_PX_PER_MS) {
1475
- const wasBoosted = isBoosted();
1476
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
1477
- if (!wasBoosted) ensurePreloadObserver();
1478
- }
824
+ const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
825
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
826
+ const was = isBoosted();
827
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
828
+ if (!was) ensurePreloadObserver();
1479
829
  }
1480
830
  }
1481
- state.lastScrollY = y;
1482
- state.lastScrollTs = t;
1483
- } catch (e) {}
831
+ state.lastScrollY = y; state.lastScrollTs = t;
832
+ } catch (_) {}
1484
833
 
1485
834
  if (ticking) return;
1486
835
  ticking = true;
1487
- requestAnimationFrame(() => {
1488
- ticking = false;
1489
- requestBurst();
1490
- });
836
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
1491
837
  }, { passive: true });
1492
838
  }
1493
839
 
1494
- // ---------------- boot ----------------
1495
-
840
+ // ─── Boot ─────────────────────────────────────────────────────────────────
1496
841
  state.pageKey = getPageKey();
1497
842
  muteNoisyConsole();
1498
- ensureTcfApiLocator();
1499
- warmUpNetwork();
843
+ ensureTcfLocator();
844
+ warmNetwork();
1500
845
  patchShowAds();
1501
846
  ensurePreloadObserver();
1502
847
  ensureDomObserver();
1503
-
1504
848
  bindNodeBB();
1505
849
  bindScroll();
1506
-
1507
850
  blockedUntil = 0;
1508
- insertHeroAdEarly().catch(() => {});
1509
851
  requestBurst();
1510
852
  })();
1511
-
1512
-
1513
-
1514
- // ===== V17 minimal pile-fix (no insert hooks) =====
1515
- (function () {
1516
- // Goal: keep ad injection intact. Only repair when we detect "pile-up" of between wraps.
1517
- var TOPIC_LI_SEL = 'li[component="category/topic"]';
1518
- var BETWEEN_WRAP_SEL = 'div.nodebb-ezoic-wrap.ezoic-ad-between';
1519
- var HOST_CLASS = 'nodebb-ezoic-host';
1520
-
1521
- var scheduled = false;
1522
- var lastRun = 0;
1523
- var COOLDOWN = 180;
1524
-
1525
- function getTopicList() {
1526
- try {
1527
- var li = document.querySelector(TOPIC_LI_SEL);
1528
- if (!li) return null;
1529
- return li.closest ? li.closest('ul,ol') : null;
1530
- } catch (e) { return null; }
1531
- }
1532
-
1533
- function isHost(node) {
1534
- return !!(node && node.nodeType === 1 && node.tagName === 'LI' && node.classList && node.classList.contains(HOST_CLASS));
1535
- }
1536
-
1537
- function ensureHostForWrap(wrap, ul) {
1538
- try {
1539
- if (!wrap || wrap.nodeType !== 1) return null;
1540
- if (!(wrap.matches && wrap.matches(BETWEEN_WRAP_SEL))) return null;
1541
-
1542
- var host = wrap.closest ? wrap.closest('li.' + HOST_CLASS) : null;
1543
- if (host) return host;
1544
-
1545
- if (!ul) ul = wrap.closest ? wrap.closest('ul,ol') : null;
1546
- if (!ul || !(ul.tagName === 'UL' || ul.tagName === 'OL')) return null;
1547
-
1548
- // Only wrap if direct child of list (invalid / fragile)
1549
- if (wrap.parentElement === ul) {
1550
- host = document.createElement('li');
1551
- host.className = HOST_CLASS;
1552
- host.setAttribute('role', 'listitem');
1553
- host.style.listStyle = 'none';
1554
- host.style.width = '100%';
1555
- ul.insertBefore(host, wrap);
1556
- host.appendChild(wrap);
1557
- try { wrap.style.width = '100%'; } catch (e) {}
1558
- return host;
1559
- }
1560
- } catch (e) {}
1561
- return null;
1562
- }
1563
-
1564
- function previousTopicLi(node) {
1565
- try {
1566
- var prev = node.previousElementSibling;
1567
- while (prev) {
1568
- if (prev.matches && prev.matches(TOPIC_LI_SEL)) return prev;
1569
- // skip other hosts/wraps
1570
- prev = prev.previousElementSibling;
1571
- }
1572
- } catch (e) {}
1573
- return null;
1574
- }
1575
-
1576
- function detectPileUp(ul) {
1577
- // Pile-up signature: 2+ between wraps/hosts adjacent with no topics between, often near top.
1578
- try {
1579
- var kids = ul.children;
1580
- var run = 0;
1581
- var maxRun = 0;
1582
- for (var i = 0; i < kids.length; i++) {
1583
- var el = kids[i];
1584
- var isBetween = false;
1585
- if (isHost(el)) {
1586
- isBetween = !!(el.querySelector && el.querySelector(BETWEEN_WRAP_SEL));
1587
- } else if (el.matches && el.matches(BETWEEN_WRAP_SEL)) {
1588
- isBetween = true;
1589
- }
1590
- if (isBetween) {
1591
- run++;
1592
- if (run > maxRun) maxRun = run;
1593
- } else if (el.matches && el.matches(TOPIC_LI_SEL)) {
1594
- run = 0;
1595
- } else {
1596
- // other nodes reset lightly
1597
- run = 0;
1598
- }
1599
- }
1600
- return maxRun >= 2;
1601
- } catch (e) {}
1602
- return false;
1603
- }
1604
-
1605
- function redistribute(ul) {
1606
- try {
1607
- if (!ul) return;
1608
-
1609
- // Step 1: wrap any direct child between DIVs into LI hosts (makes list stable)
1610
- ul.querySelectorAll(':scope > ' + BETWEEN_WRAP_SEL).forEach(function(w){ ensureHostForWrap(w, ul); });
1611
-
1612
- // Step 2: only act if we see pile-up (avoid touching infinite scroll during normal flow)
1613
- if (!detectPileUp(ul)) return;
1614
-
1615
- // Move each host to immediately after the closest previous topic LI at its current position.
1616
- var hosts = ul.querySelectorAll(':scope > li.' + HOST_CLASS);
1617
- hosts.forEach(function(host){
1618
- try {
1619
- var wrap = host.querySelector && host.querySelector(BETWEEN_WRAP_SEL);
1620
- if (!wrap) return;
1621
-
1622
- var anchor = previousTopicLi(host);
1623
- if (!anchor) return; // if none, don't move (prevents yanking to top/bottom)
1624
-
1625
- if (host.previousElementSibling !== anchor) {
1626
- anchor.insertAdjacentElement('afterend', host);
1627
- }
1628
- } catch (e) {}
1629
- });
1630
- } catch (e) {}
1631
- }
1632
-
1633
- function schedule(reason) {
1634
- var now = Date.now();
1635
- if (now - lastRun < COOLDOWN) return;
1636
- if (scheduled) return;
1637
- scheduled = true;
1638
- requestAnimationFrame(function () {
1639
- scheduled = false;
1640
- lastRun = Date.now();
1641
- try {
1642
- var ul = getTopicList();
1643
- if (!ul) return;
1644
- redistribute(ul);
1645
- } catch (e) {}
1646
- });
1647
- }
1648
-
1649
- function init() {
1650
- schedule('init');
1651
-
1652
- // Observe only the topic list once available
1653
- try {
1654
- if (typeof MutationObserver !== 'undefined') {
1655
- var observeList = function(ul){
1656
- if (!ul) return;
1657
- var mo = new MutationObserver(function(muts){
1658
- // schedule on any change; redistribute() itself is guarded by pile-up detection
1659
- schedule('mo');
1660
- });
1661
- mo.observe(ul, { childList: true, subtree: true });
1662
- };
1663
-
1664
- var ul = getTopicList();
1665
- if (ul) observeList(ul);
1666
- else {
1667
- var mo2 = new MutationObserver(function(){
1668
- var u2 = getTopicList();
1669
- if (u2) {
1670
- try { observeList(u2); } catch(e){}
1671
- try { mo2.disconnect(); } catch(e){}
1672
- }
1673
- });
1674
- mo2.observe(document.documentElement || document.body, { childList: true, subtree: true });
1675
- }
1676
- }
1677
- } catch (e) {}
1678
-
1679
- // NodeBB events: run after infinite scroll batches
1680
- if (window.jQuery) {
1681
- try {
1682
- window.jQuery(window).on('action:ajaxify.end action:infiniteScroll.loaded', function(){
1683
- setTimeout(function(){ schedule('event'); }, 50);
1684
- setTimeout(function(){ schedule('event2'); }, 400);
1685
- });
1686
- } catch (e) {}
1687
- }
1688
- }
1689
-
1690
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
1691
- else init();
1692
- })();
1693
- // ===== /V17 =====
1694
-