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/package.json +1 -1
- package/public/client.js +567 -1409
- package/public/style.css +47 -46
- package/public/test.txt +0 -1
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
|
-
//
|
|
5
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const
|
|
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:
|
|
59
|
-
postItem:
|
|
58
|
+
topicItem: 'li[component="category/topic"]',
|
|
59
|
+
postItem: '[component="post"][data-pid]',
|
|
60
60
|
categoryItem: 'li[component="categories/category"]',
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
+
const isBlocked = () => now() < blockedUntil;
|
|
109
|
+
const isBoosted = () => now() < state.scrollBoostUntil;
|
|
376
110
|
|
|
377
|
-
function
|
|
378
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
400
|
-
if (!
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
|
458
|
-
if (
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 (
|
|
515
|
-
}
|
|
203
|
+
} catch (_) {}
|
|
516
204
|
|
|
517
|
-
|
|
518
|
-
try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
|
|
205
|
+
return 0;
|
|
519
206
|
}
|
|
520
207
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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(
|
|
643
|
-
wrap.setAttribute(
|
|
644
|
-
wrap.setAttribute(
|
|
645
|
-
|
|
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
|
|
662
|
-
if (!
|
|
663
|
-
if (
|
|
664
|
-
if (
|
|
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
|
|
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
|
-
|
|
266
|
+
function removeWrap(wrap) {
|
|
669
267
|
try {
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
698
|
-
|
|
297
|
+
// Retrouver l'ordinal depuis la clé
|
|
298
|
+
const ordStr = anchorKey.split(':').slice(1).join(':');
|
|
299
|
+
const ord = parseInt(ordStr, 10);
|
|
699
300
|
|
|
700
|
-
|
|
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
|
-
|
|
306
|
+
if (!anchorEl || !anchorEl.isConnected) {
|
|
307
|
+
// Ancre disparue → suppression inconditionnelle
|
|
308
|
+
withInternalMutation(() => removeWrap(wrap));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
704
311
|
}
|
|
705
312
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
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
|
|
721
|
-
// NodeBB/skins can inject separators/spacers; be tolerant.
|
|
321
|
+
for (const wrap of wraps) {
|
|
722
322
|
let prev = wrap.previousElementSibling;
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
if (itemSet.has(next)) return true;
|
|
730
|
-
next = next.nextElementSibling;
|
|
731
|
-
}
|
|
732
|
-
return false;
|
|
733
|
-
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
734
349
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
775
|
-
removed++;
|
|
776
|
-
});
|
|
374
|
+
const ord = getGlobalOrdinal(el, selector);
|
|
777
375
|
|
|
778
|
-
|
|
779
|
-
|
|
376
|
+
// Est-ce une position cible ?
|
|
377
|
+
const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
|
|
378
|
+
if (!isTarget) continue;
|
|
780
379
|
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
831
|
-
|
|
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
|
-
|
|
394
|
+
|
|
395
|
+
return inserted;
|
|
843
396
|
}
|
|
844
397
|
|
|
845
|
-
//
|
|
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
|
|
849
|
-
if (state.io && state.ioMargin ===
|
|
409
|
+
const margin = getPreloadMargin();
|
|
410
|
+
if (state.io && state.ioMargin === margin) return state.io;
|
|
850
411
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
state.io = null;
|
|
854
|
-
}
|
|
412
|
+
state.io?.disconnect();
|
|
413
|
+
state.io = null;
|
|
855
414
|
|
|
856
415
|
try {
|
|
857
|
-
state.io = new IntersectionObserver(
|
|
416
|
+
state.io = new IntersectionObserver(entries => {
|
|
858
417
|
for (const ent of entries) {
|
|
859
418
|
if (!ent.isIntersecting) continue;
|
|
860
|
-
|
|
861
|
-
|
|
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:
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
889
|
-
|
|
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
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
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
|
|
474
|
+
let done = false;
|
|
475
|
+
|
|
978
476
|
const release = () => {
|
|
979
|
-
if (
|
|
980
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1006
|
-
|
|
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 (
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
if (!anchorEl || !wrap || !wrap.isConnected) return null;
|
|
551
|
+
window.__nodebbEzoicPatched = true;
|
|
552
|
+
const orig = ez.showAds.bind(ez);
|
|
1161
553
|
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
try {
|
|
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 <
|
|
646
|
+
if (t - state.lastBurstReqTs < 100) return;
|
|
1318
647
|
state.lastBurstReqTs = t;
|
|
1319
648
|
|
|
1320
649
|
const pk = getPageKey();
|
|
1321
|
-
state.pageKey
|
|
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
|
|
655
|
+
state.burstCount = 0;
|
|
1328
656
|
|
|
1329
657
|
const step = () => {
|
|
1330
|
-
if (getPageKey() !== pk)
|
|
1331
|
-
if (isBlocked())
|
|
1332
|
-
if (now() > state.burstDeadline)
|
|
1333
|
-
if (state.burstCount >= 8)
|
|
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, (
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
//
|
|
1349
|
-
|
|
673
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────────
|
|
1350
674
|
function cleanup() {
|
|
1351
|
-
blockedUntil = now() +
|
|
675
|
+
blockedUntil = now() + 1500;
|
|
1352
676
|
|
|
1353
|
-
|
|
1354
|
-
document.querySelectorAll(`.${WRAP_CLASS}`).forEach(
|
|
1355
|
-
}
|
|
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.
|
|
1367
|
-
state.
|
|
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.
|
|
1371
|
-
|
|
1372
|
-
// keep observers alive
|
|
689
|
+
state.burstActive = false;
|
|
690
|
+
state.runQueued = false;
|
|
1373
691
|
}
|
|
1374
692
|
|
|
1375
|
-
|
|
1376
|
-
|
|
693
|
+
// ─── DOM Observer ─────────────────────────────────────────────────────────
|
|
694
|
+
function shouldReact(mutations) {
|
|
1377
695
|
for (const m of mutations) {
|
|
1378
|
-
if (!m.addedNodes
|
|
696
|
+
if (!m.addedNodes?.length) continue;
|
|
1379
697
|
for (const n of m.addedNodes) {
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
1409
|
-
|
|
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
|
|
1424
|
-
|
|
783
|
+
blockedUntil = 0;
|
|
1425
784
|
muteNoisyConsole();
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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'],
|
|
1454
|
-
if (
|
|
1455
|
-
[
|
|
1456
|
-
|
|
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 (
|
|
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
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
1495
|
-
|
|
840
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────
|
|
1496
841
|
state.pageKey = getPageKey();
|
|
1497
842
|
muteNoisyConsole();
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|