nodebb-plugin-ezoic-infinite 1.8.21 → 1.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +590 -908
package/public/client.js CHANGED
@@ -1,328 +1,114 @@
1
- (function nbbEzoicInfinite() {
1
+ (function nbbEzoicInfiniteV22() {
2
2
  'use strict';
3
3
 
4
- // ── Constantes ─────────────────────────────────────────────────────────────
5
-
6
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
7
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
8
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
9
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
10
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
11
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
12
-
13
- // Tunables (stables en prod)
14
- const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
15
- const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
16
- const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
17
- const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
18
- const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
19
- const SHOW_RELEASE_MS = 300; // relâche inflight après showAds() batché
20
- const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
21
- const BATCH_FLUSH_MS = 30; // micro-buffer pour regrouper les ids proches
22
- const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
23
- const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
24
- const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
25
- const CLEANUP_GRACE_MS = 3_500; // délai mini avant cleanup d'un wrap candidat
26
- const SPECIAL_GRACE_MS = 30_000; // délai allongé pour sticky/fixed/adhesion
27
- const RECENT_WRAP_ACTIVITY_MS = 5_000; // protège un wrap récemment muté/rafraîchi
28
- const VIEWPORT_BUFFER_DESKTOP = 500;
29
- const VIEWPORT_BUFFER_MOBILE = 250;
30
-
31
- // Marges IO larges et fixes — observer créé une seule fois au boot
32
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
33
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
4
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
5
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
6
+ const A_ANCHOR = 'data-ezoic-anchor';
7
+ const A_WRAPID = 'data-ezoic-wrapid';
8
+ const A_CREATED = 'data-ezoic-created';
9
+ const A_SHOWN = 'data-ezoic-shown';
10
+ const A_STATE = 'data-ezoic-state';
34
11
 
35
12
  const SEL = {
36
- post: '[component="post"][data-pid]',
37
- topic: 'li[component="category/topic"]',
13
+ post: '[component="post"][data-pid]',
14
+ topic: 'li[component="category/topic"]',
38
15
  category: 'li[component="categories/category"]',
39
16
  };
40
- const WRAP_SEL = `.${WRAP_CLASS}`;
41
- const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id]';
42
- const CONTENT_SEL_LIST = [SEL.post, SEL.topic, SEL.category];
43
17
 
44
- /**
45
- * Table KIND — source de vérité par kindClass.
46
- *
47
- * sel sélecteur CSS complet des éléments cibles
48
- * baseTag préfixe tag pour querySelector d'ancre
49
- * (vide pour posts : le sélecteur commence par '[')
50
- * anchorAttr attribut DOM stable → clé unique du wrap
51
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle
52
- * null → fallback positionnel (catégories)
53
- */
54
18
  const KIND = {
55
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
56
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
57
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
19
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index', pool: 'posts' },
20
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index', pool: 'topics' },
21
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null, pool: 'categories' },
58
22
  };
59
23
 
60
- // ── État global ────────────────────────────────────────────────────────────
24
+ const CFG = {
25
+ maxInsertsPerRun: 8,
26
+ maxShowInflight: 1,
27
+ maxShowBatch: 1,
28
+ showThrottleMs: 1500,
29
+ showCooldownMs: 15000,
30
+ showReleaseMs: 350,
31
+ showFailsafeMs: 8000,
32
+ batchFlushMs: 80,
33
+ runDebounceMs: 80,
34
+ orphanGraceMs: 12000,
35
+ retireGraceMs: 25000,
36
+ recentActivityMs: 6000,
37
+ viewportBufferDesktop: 700,
38
+ viewportBufferMobile: 350,
39
+ ioMarginDesktop: '1800px 0px 2200px 0px',
40
+ ioMarginMobile: '2400px 0px 2800px 0px',
41
+ maintenanceEveryMs: 1200,
42
+ minHeightRememberMin: 40,
43
+ };
61
44
 
62
45
  const S = {
63
- pageKey: null,
64
- cfg: null,
65
- poolsReady: false,
66
- pools: { topics: [], posts: [], categories: [] },
67
- cursors: { topics: 0, posts: 0, categories: 0 },
68
- mountedIds: new Set(),
69
- lastShow: new Map(),
70
- io: null,
71
- domObs: null,
72
- mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
73
- inflight: 0, // showAds() en cours
74
- pending: [], // ids en attente de slot inflight
75
- pendingSet: new Set(),
76
- showBatchTimer: 0,
77
- destroyBatchTimer: 0,
78
- destroyPending: [],
79
- destroyPendingSet: new Set(),
80
- sweepQueued: false,
81
- wrapByKey: new Map(), // anchorKey → wrap DOM node
82
- ezActiveIds: new Set(), // ids actifs côté plugin (wrap présent / récemment show)
83
- ezShownSinceDestroy: new Set(), // ids déjà show depuis le dernier destroy Ezoic
84
- wrapActivityAt: new Map(), // key/id -> ts activité DOM récente
46
+ cfg: null,
47
+ poolsReady: false,
48
+ pools: { topics: [], posts: [], categories: [] },
49
+ cursors: { topics: 0, posts: 0, categories: 0 },
50
+ pageKey: null,
51
+ blockedUntil: 0,
52
+ runTimer: 0,
53
+ maintenanceTimer: 0,
54
+ io: null,
55
+ domObs: null,
56
+ muting: 0,
57
+ mountedIds: new Set(),
58
+ wrapByKey: new Map(),
59
+ regById: new Map(),
60
+ lastShowById: new Map(),
85
61
  lastWrapHeightByClass: new Map(),
86
- scrollDir: 1, // 1=bas, -1=haut
87
- scrollSpeed: 0, // px/s approx (EMA)
88
- lastScrollY: 0,
89
- lastScrollTs: 0,
90
- runQueued: false,
91
- burstActive: false,
92
- burstDeadline: 0,
93
- burstCount: 0,
94
- lastBurstTs: 0,
62
+ pendingShow: [],
63
+ pendingSet: new Set(),
64
+ showTimer: 0,
65
+ inflightShow: 0,
95
66
  };
96
67
 
97
- let blockedUntil = 0;
98
-
99
- const ts = () => Date.now();
100
- const isBlocked = () => ts() < blockedUntil;
101
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
102
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
103
- const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
104
-
105
- function viewportBufferPx() {
106
- return isMobile() ? VIEWPORT_BUFFER_MOBILE : VIEWPORT_BUFFER_DESKTOP;
107
- }
108
-
109
- function textSig(el) {
110
- if (!(el instanceof Element)) return '';
111
- const parts = [el.id || '', String(el.className || ''), el.getAttribute?.('name') || ''];
112
- try {
113
- for (const a of ['data-google-query-id', 'data-google-container-id', 'data-slot', 'data-ad-slot']) {
114
- const v = el.getAttribute?.(a);
115
- if (v) parts.push(v);
116
- }
117
- } catch (_) {}
118
- return parts.join(' ').toLowerCase();
119
- }
120
-
121
- function isSpecialSlotLike(el) {
122
- const sig = textSig(el);
123
- return /adhesion|interstitial|anchor|sticky|outofpage/.test(sig);
124
- }
125
-
126
- function hasSpecialSlotMarkers(root) {
127
- if (!(root instanceof Element)) return false;
128
- if (isSpecialSlotLike(root)) return true;
129
- try {
130
- const nodes = root.querySelectorAll('[id],[class],[name],[data-slot],[data-ad-slot]');
131
- for (const n of nodes) if (isSpecialSlotLike(n)) return true;
132
- } catch (_) {}
133
- return false;
134
- }
135
-
136
- function hasFixedLikeNode(root, maxScan = 24) {
137
- if (!(root instanceof Element)) return false;
138
- const q = [root];
139
- let seen = 0;
140
- while (q.length && seen < maxScan) {
141
- const n = q.shift();
142
- seen++;
143
- try {
144
- const cs = window.getComputedStyle(n);
145
- if (cs.position === 'fixed' || cs.position === 'sticky') return true;
146
- } catch (_) {}
147
- for (const c of n.children || []) q.push(c);
148
- }
149
- return false;
150
- }
151
-
152
- function markWrapActivity(wrapOrId) {
153
- const t = ts();
154
- try {
155
- if (wrapOrId instanceof Element) {
156
- const key = wrapOrId.getAttribute(A_ANCHOR);
157
- const id = parseInt(wrapOrId.getAttribute(A_WRAPID), 10);
158
- if (key) S.wrapActivityAt.set(`k:${key}`, t);
159
- if (Number.isFinite(id)) S.wrapActivityAt.set(`i:${id}`, t);
160
- return;
161
- }
162
- const id = parseInt(wrapOrId, 10);
163
- if (Number.isFinite(id)) S.wrapActivityAt.set(`i:${id}`, t);
164
- } catch (_) {}
165
- }
166
-
167
- function wrapRecentActivity(w) {
168
- try {
169
- const key = w?.getAttribute?.(A_ANCHOR);
170
- const id = parseInt(w?.getAttribute?.(A_WRAPID), 10);
171
- const t1 = key ? (S.wrapActivityAt.get(`k:${key}`) || 0) : 0;
172
- const t2 = Number.isFinite(id) ? (S.wrapActivityAt.get(`i:${id}`) || 0) : 0;
173
- const t3 = parseInt(w?.getAttribute?.(A_SHOWN) || '0', 10) || 0;
174
- return (ts() - Math.max(t1, t2, t3)) < RECENT_WRAP_ACTIVITY_MS;
175
- } catch (_) { return false; }
176
- }
177
-
178
- function wrapCleanupGraceMs(w) {
179
- return (hasSpecialSlotMarkers(w) || hasFixedLikeNode(w)) ? SPECIAL_GRACE_MS : CLEANUP_GRACE_MS;
180
- }
181
-
182
- function wrapNearViewport(w) {
183
- try {
184
- const r = w.getBoundingClientRect();
185
- const b = viewportBufferPx();
186
- const vh = window.innerHeight || 800;
187
- return r.bottom > -b && r.top < vh + b;
188
- } catch (_) { return true; }
189
- }
190
-
191
- function rememberWrapHeight(w) {
192
- try {
193
- if (!(w instanceof Element)) return;
194
- const klass = [...(w.classList || [])].find(c => c.startsWith('ezoic-ad-'));
195
- if (!klass) return;
196
- const h = Math.round(w.getBoundingClientRect().height || 0);
197
- if (h >= 40) S.lastWrapHeightByClass.set(klass, h);
198
- } catch (_) {}
199
- }
200
-
201
- function healFalseEmpty(root = document) {
202
- try {
203
- const list = [];
204
- if (root instanceof Element && root.classList?.contains(WRAP_CLASS)) list.push(root);
205
- const found = root.querySelectorAll ? root.querySelectorAll(`.${WRAP_CLASS}.is-empty`) : [];
206
- for (const w of found) list.push(w);
207
- for (const w of list) {
208
- if (!w?.classList?.contains('is-empty')) continue;
209
- if (isFilled(w)) w.classList.remove('is-empty');
210
- }
211
- } catch (_) {}
212
- }
213
-
214
- function phEl(id) {
215
- return document.getElementById(`${PH_PREFIX}${id}`);
216
- }
217
-
218
- function hasSinglePlaceholder(id) {
219
- try { return document.querySelectorAll(`#${PH_PREFIX}${id}`).length === 1; } catch (_) { return false; }
220
- }
221
-
222
- function canShowPlaceholderId(id, now = ts()) {
223
- const n = parseInt(id, 10);
224
- if (!Number.isFinite(n) || n <= 0) return false;
225
- if (now - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
226
- const ph = phEl(n);
227
- if (!ph?.isConnected || isFilled(ph)) return false;
228
- if (!hasSinglePlaceholder(n)) return false;
229
- return true;
230
- }
231
-
232
- function queueSweepDeadWraps() {
233
- if (S.sweepQueued) return;
234
- S.sweepQueued = true;
235
- requestAnimationFrame(() => {
236
- S.sweepQueued = false;
237
- sweepDeadWraps();
238
- healFalseEmpty();
239
- });
240
- }
241
-
242
- function getDynamicShowBatchMax() {
243
- const speed = S.scrollSpeed || 0;
244
- const pend = S.pending.length;
245
- // Scroll très rapide => petits batches (réduit le churn/unused)
246
- if (speed > 2600) return 2;
247
- if (speed > 1400) return 3;
248
- // Peu de candidats => flush plus vite, inutile d'attendre 4
249
- if (pend <= 1) return 1;
250
- if (pend <= 3) return 2;
251
- // Par défaut compromis dynamique
252
- return 3;
253
- }
68
+ const now = () => Date.now();
69
+ const isBlocked = () => now() < S.blockedUntil;
70
+ const isMobile = () => {
71
+ try { return window.innerWidth < 768; } catch (_) { return false; }
72
+ };
73
+ const viewportBuffer = () => (isMobile() ? CFG.viewportBufferMobile : CFG.viewportBufferDesktop);
74
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
254
75
 
255
76
  function mutate(fn) {
256
- S.mutGuard++;
257
- try { fn(); } finally { S.mutGuard--; }
77
+ S.muting++;
78
+ try { return fn(); } finally { S.muting = Math.max(0, S.muting - 1); }
258
79
  }
259
- function scheduleDestroyFlush() {
260
- if (S.destroyBatchTimer) return;
261
- S.destroyBatchTimer = setTimeout(() => {
262
- S.destroyBatchTimer = 0;
263
- flushDestroyBatch();
264
- }, DESTROY_FLUSH_MS);
265
- }
266
80
 
267
- function flushDestroyBatch() {
268
- if (!S.destroyPending.length) return;
269
- const ids = [];
270
- while (S.destroyPending.length && ids.length < MAX_DESTROY_BATCH) {
271
- const id = S.destroyPending.shift();
272
- S.destroyPendingSet.delete(id);
273
- if (!Number.isFinite(id) || id <= 0) continue;
274
- ids.push(id);
275
- }
276
- if (ids.length) {
81
+ function pageKey() {
277
82
  try {
278
- const ez = window.ezstandalone;
279
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
280
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
83
+ const d = window.ajaxify?.data;
84
+ if (d?.tid) return `t:${d.tid}`;
85
+ if (d?.cid) return `c:${d.cid}`;
281
86
  } catch (_) {}
87
+ return location.pathname;
282
88
  }
283
- if (S.destroyPending.length) scheduleDestroyFlush();
284
- }
285
89
 
286
- function destroyEzoicId(id) {
287
- if (!Number.isFinite(id) || id <= 0) return;
288
- if (!S.ezActiveIds.has(id)) return;
289
- S.ezActiveIds.delete(id);
290
- if (!S.destroyPendingSet.has(id)) {
291
- S.destroyPending.push(id);
292
- S.destroyPendingSet.add(id);
90
+ function getKind() {
91
+ const p = location.pathname;
92
+ if (/^\/topic\//.test(p)) return 'topic';
93
+ if (/^\/category\//.test(p)) return 'categoryTopics';
94
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
95
+ if (document.querySelector(SEL.post)) return 'topic';
96
+ if (document.querySelector(SEL.topic)) return 'categoryTopics';
97
+ if (document.querySelector(SEL.category)) return 'categories';
98
+ return 'other';
293
99
  }
294
- scheduleDestroyFlush();
295
- }
296
100
 
297
- function destroyBeforeReuse(ids) {
298
- const out = [];
299
- const toDestroy = [];
300
- const seen = new Set();
301
- for (const raw of (ids || [])) {
302
- const id = parseInt(raw, 10);
303
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
304
- seen.add(id);
305
- out.push(id);
306
- try {
307
- const wrap = phEl(id)?.closest?.(WRAP_SEL) || null;
308
- if (wrap && (wrapRecentActivity(wrap) || hasSpecialSlotMarkers(wrap) || hasFixedLikeNode(wrap))) {
309
- continue;
310
- }
311
- } catch (_) {}
312
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
313
- }
314
- if (toDestroy.length) {
315
- try {
316
- const ez = window.ezstandalone;
317
- const run = () => { try { ez?.destroyPlaceholders?.(toDestroy); } catch (_) {} };
318
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
319
- } catch (_) {}
320
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
101
+ function getPosts() {
102
+ return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
103
+ if (!el.isConnected) return false;
104
+ if (!el.querySelector('[component="post/content"]')) return false;
105
+ const p = el.parentElement?.closest?.(SEL.post);
106
+ if (p && p !== el) return false;
107
+ return el.getAttribute('component') !== 'post/parent';
108
+ });
321
109
  }
322
- return out;
323
- }
324
-
325
- // ── Config ─────────────────────────────────────────────────────────────────
110
+ const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
111
+ const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
326
112
 
327
113
  async function fetchConfig() {
328
114
  if (S.cfg) return S.cfg;
@@ -334,9 +120,10 @@ function destroyBeforeReuse(ids) {
334
120
  }
335
121
 
336
122
  function parseIds(raw) {
337
- const out = [], seen = new Set();
338
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
339
- const n = parseInt(v, 10);
123
+ const out = [];
124
+ const seen = new Set();
125
+ for (const line of String(raw || '').split(/\r?\n/)) {
126
+ const n = parseInt(line.trim(), 10);
340
127
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
341
128
  }
342
129
  return out;
@@ -344,258 +131,147 @@ function destroyBeforeReuse(ids) {
344
131
 
345
132
  function initPools(cfg) {
346
133
  if (S.poolsReady) return;
347
- S.pools.topics = parseIds(cfg.placeholderIds);
348
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
349
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
134
+ S.pools = {
135
+ topics: parseIds(cfg.placeholderIds),
136
+ posts: parseIds(cfg.messagePlaceholderIds),
137
+ categories: parseIds(cfg.categoryPlaceholderIds),
138
+ };
350
139
  S.poolsReady = true;
351
140
  }
352
141
 
353
- // ── Page identity ──────────────────────────────────────────────────────────
142
+ function textSig(el) {
143
+ if (!(el instanceof Element)) return '';
144
+ const parts = [el.id || '', String(el.className || ''), el.getAttribute('name') || ''];
145
+ for (const a of ['data-slot', 'data-ad-slot', 'data-google-container-id']) {
146
+ try { const v = el.getAttribute(a); if (v) parts.push(v); } catch (_) {}
147
+ }
148
+ return parts.join(' ').toLowerCase();
149
+ }
354
150
 
355
- function pageKey() {
151
+ function isSpecialLike(root) {
152
+ if (!(root instanceof Element)) return false;
153
+ const test = el => /adhesion|interstitial|anchor|sticky|outofpage/.test(textSig(el));
154
+ if (test(root)) return true;
356
155
  try {
357
- const d = window.ajaxify?.data;
358
- if (d?.tid) return `t:${d.tid}`;
359
- if (d?.cid) return `c:${d.cid}`;
156
+ const nodes = root.querySelectorAll('[id],[class],[name],[data-slot],[data-ad-slot]');
157
+ for (const n of nodes) if (test(n)) return true;
360
158
  } catch (_) {}
361
- return location.pathname;
362
- }
363
-
364
- function getKind() {
365
- const p = location.pathname;
366
- if (/^\/topic\//.test(p)) return 'topic';
367
- if (/^\/category\//.test(p)) return 'categoryTopics';
368
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
369
- if (document.querySelector(SEL.category)) return 'categories';
370
- if (document.querySelector(SEL.post)) return 'topic';
371
- if (document.querySelector(SEL.topic)) return 'categoryTopics';
372
- return 'other';
373
- }
374
-
375
- // ── Items DOM ──────────────────────────────────────────────────────────────
376
-
377
- function getPosts() {
378
- return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
379
- if (!el.isConnected) return false;
380
- if (!el.querySelector('[component="post/content"]')) return false;
381
- const p = el.parentElement?.closest(SEL.post);
382
- if (p && p !== el) return false;
383
- return el.getAttribute('component') !== 'post/parent';
384
- });
159
+ return false;
385
160
  }
386
161
 
387
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
388
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
389
-
390
- // ── Wraps — détection ──────────────────────────────────────────────────────
391
-
392
- /**
393
- * Vérifie qu'un wrap a encore son ancre dans le DOM.
394
- * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
395
- */
396
- function wrapIsLive(wrap) {
397
- if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
398
- const key = wrap.getAttribute(A_ANCHOR);
399
- if (!key) return false;
400
- // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
401
- // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
402
- if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
403
- // Fallback : registre pas encore à jour ou wrap non enregistré.
404
- const colonIdx = key.indexOf(':');
405
- const klass = key.slice(0, colonIdx);
406
- const anchorId = key.slice(colonIdx + 1);
407
- const cfg = KIND[klass];
408
- if (!cfg) return false;
409
- // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
410
- // de querySelector global — on cherche parmi les voisins immédiats.
411
- const parent = wrap.parentElement;
412
- if (parent) {
413
- for (const sib of parent.children) {
414
- if (sib === wrap) continue;
415
- try {
416
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
417
- return sib.isConnected;
418
- }
419
- } catch (_) {}
420
- }
162
+ function hasFixedLike(root, max = 16) {
163
+ if (!(root instanceof Element)) return false;
164
+ const q = [root];
165
+ let seen = 0;
166
+ while (q.length && seen < max) {
167
+ const n = q.shift();
168
+ seen++;
169
+ try {
170
+ const cs = window.getComputedStyle(n);
171
+ if (cs.position === 'fixed' || cs.position === 'sticky') return true;
172
+ } catch (_) {}
173
+ for (const c of n.children || []) q.push(c);
421
174
  }
422
- // Dernier recours : querySelector global
423
- try {
424
- const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
425
- return !!(found?.isConnected);
426
- } catch (_) { return false; }
175
+ return false;
427
176
  }
428
177
 
429
- function adjacentWrap(el) {
430
- return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
178
+ function isFilled(wrap) {
179
+ try { return !!wrap?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'); } catch (_) { return false; }
431
180
  }
432
181
 
433
- // ── Ancres stables ─────────────────────────────────────────────────────────
182
+ function phEl(id) { return document.getElementById(`${PH_PREFIX}${id}`); }
434
183
 
435
- /**
436
- * Retourne la valeur de l'attribut stable pour cet élément,
437
- * ou un fallback positionnel si l'attribut est absent.
438
- */
439
- function stableId(klass, el) {
184
+ function anchorStableId(klass, el) {
440
185
  const attr = KIND[klass]?.anchorAttr;
441
186
  if (attr) {
442
187
  const v = el.getAttribute(attr);
443
188
  if (v !== null && v !== '') return v;
444
189
  }
445
190
  let i = 0;
446
- for (const s of el.parentElement?.children ?? []) {
447
- if (s === el) return `i${i}`;
448
- i++;
449
- }
191
+ for (const s of el.parentElement?.children || []) { if (s === el) return `i${i}`; i++; }
450
192
  return 'i0';
451
193
  }
194
+ const anchorKey = (klass, el) => `${klass}:${anchorStableId(klass, el)}`;
452
195
 
453
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
454
-
455
- function findWrap(key) {
196
+ function findWrapByKey(key) {
456
197
  const w = S.wrapByKey.get(key);
457
198
  return (w?.isConnected) ? w : null;
458
199
  }
459
200
 
460
- // ── Pool ───────────────────────────────────────────────────────────────────
201
+ function rememberWrapHeight(wrap) {
202
+ try {
203
+ if (!(wrap instanceof Element)) return;
204
+ const klass = [...(wrap.classList || [])].find(c => c.startsWith('ezoic-ad-'));
205
+ if (!klass) return;
206
+ const h = Math.round(wrap.getBoundingClientRect().height || 0);
207
+ if (h >= CFG.minHeightRememberMin) S.lastWrapHeightByClass.set(klass, h);
208
+ } catch (_) {}
209
+ }
461
210
 
462
- /**
463
- * Retourne le prochain id disponible dans le pool (round-robin),
464
- * ou null si tous les ids sont montés.
465
- */
466
- function pickId(poolKey) {
467
- const pool = S.pools[poolKey];
468
- if (!pool.length) return null;
469
- for (let t = 0; t < pool.length; t++) {
470
- const i = S.cursors[poolKey] % pool.length;
471
- S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
472
- const id = pool[i];
473
- if (!S.mountedIds.has(id)) return id;
474
- }
475
- return null;
211
+ function wrapNearViewport(wrap) {
212
+ try {
213
+ const r = wrap.getBoundingClientRect();
214
+ const b = viewportBuffer();
215
+ const vh = window.innerHeight || 800;
216
+ return r.bottom > -b && r.top < vh + b;
217
+ } catch (_) { return true; }
476
218
  }
477
219
 
478
- function sweepDeadWraps() {
479
- // NodeBB peut retirer des nœuds sans passer par dropWrap() (virtualisation / rerender).
480
- // On libère alors les IDs/keys fantômes pour éviter l'épuisement du pool.
481
- let changed = false;
482
- for (const [key, wrap] of S.wrapByKey) {
483
- if (wrap?.isConnected) continue;
484
- changed = true;
485
- S.wrapByKey.delete(key);
486
- const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
487
- if (Number.isFinite(id)) {
488
- S.mountedIds.delete(id);
489
- S.pendingSet.delete(id);
490
- S.lastShow.delete(id);
491
- S.ezActiveIds.delete(id);
492
- }
220
+ function ensureRegistry(id, wrap) {
221
+ let rec = S.regById.get(id);
222
+ if (!rec) {
223
+ rec = {
224
+ id,
225
+ wrap,
226
+ key: wrap?.getAttribute(A_ANCHOR) || '',
227
+ state: 'idle',
228
+ typeClass: [...(wrap?.classList || [])].find(c => c.startsWith('ezoic-ad-')) || '',
229
+ isSpecial: false,
230
+ isFixedLike: false,
231
+ createdAt: now(),
232
+ shownAt: 0,
233
+ lastSeenAt: 0,
234
+ lastMutationAt: now(),
235
+ cooldownUntil: 0,
236
+ };
237
+ S.regById.set(id, rec);
493
238
  }
494
- if (changed && S.pending.length) {
495
- S.pending = S.pending.filter(id => S.pendingSet.has(id));
239
+ if (wrap && rec.wrap !== wrap) rec.wrap = wrap;
240
+ if (wrap) {
241
+ rec.key = wrap.getAttribute(A_ANCHOR) || rec.key;
242
+ rec.typeClass = [...(wrap.classList || [])].find(c => c.startsWith('ezoic-ad-')) || rec.typeClass;
496
243
  }
244
+ return rec;
497
245
  }
498
246
 
499
- /**
500
- * Pool épuisé : recycle un wrap loin au-dessus du viewport.
501
- * Séquence avec délais (destroyPlaceholders est asynchrone) :
502
- * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
503
- * displayMore = API Ezoic prévue pour l'infinite scroll.
504
- * Priorité : wraps vides d'abord, remplis si nécessaire.
505
- */
506
- function recycleAndMove(klass, targetEl, newKey) {
507
- const ez = window.ezstandalone;
508
- if (typeof ez?.destroyPlaceholders !== 'function' ||
509
- typeof ez?.define !== 'function' ||
510
- typeof ez?.displayMore !== 'function') return null;
511
-
512
- const vh = window.innerHeight || 800;
513
- const preferAbove = S.scrollDir >= 0; // scroll bas => recycle en haut
514
- const farAbove = -vh;
515
- const farBelow = vh * 2;
516
-
517
- let bestPrefEmpty = null, bestPrefMetric = Infinity;
518
- let bestPrefFilled = null, bestPrefFilledMetric = Infinity;
519
- let bestAnyEmpty = null, bestAnyMetric = Infinity;
520
- let bestAnyFilled = null, bestAnyFilledMetric = Infinity;
521
-
522
- for (const wrap of S.wrapByKey.values()) {
523
- if (!wrap?.isConnected || !wrap.classList?.contains(WRAP_CLASS) || !wrap.classList.contains(klass)) continue;
524
- if (wrapRecentActivity(wrap)) continue;
525
- if (hasSpecialSlotMarkers(wrap) || hasFixedLikeNode(wrap)) continue;
526
- try {
527
- const rect = wrap.getBoundingClientRect();
528
- const isAbove = rect.bottom <= farAbove;
529
- const isBelow = rect.top >= farBelow;
530
- const anyFar = isAbove || isBelow;
531
- if (!anyFar) continue;
532
-
533
- const qualifies = preferAbove ? isAbove : isBelow;
534
- const metric = preferAbove ? Math.abs(rect.bottom) : Math.abs(rect.top - vh);
535
- const filled = isFilled(wrap);
536
-
537
- if (qualifies) {
538
- if (!filled) {
539
- if (metric < bestPrefMetric) { bestPrefMetric = metric; bestPrefEmpty = wrap; }
540
- } else {
541
- if (metric < bestPrefFilledMetric) { bestPrefFilledMetric = metric; bestPrefFilled = wrap; }
542
- }
543
- }
544
- if (!filled) {
545
- if (metric < bestAnyMetric) { bestAnyMetric = metric; bestAnyEmpty = wrap; }
546
- } else {
547
- if (metric < bestAnyFilledMetric) { bestAnyFilledMetric = metric; bestAnyFilled = wrap; }
548
- }
549
- } catch (_) {}
247
+ function updateWrapState(rec, state) {
248
+ rec.state = state;
249
+ try { rec.wrap?.setAttribute?.(A_STATE, state); } catch (_) {}
550
250
  }
551
251
 
552
- const best = bestPrefEmpty ?? bestPrefFilled ?? bestAnyEmpty ?? bestAnyFilled;
553
- if (!best) return null;
554
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
555
- if (!Number.isFinite(id)) return null;
556
-
557
- const oldKey = best.getAttribute(A_ANCHOR);
558
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
559
- mutate(() => {
560
- rememberWrapHeight(best);
561
- best.setAttribute(A_ANCHOR, newKey);
562
- best.setAttribute(A_CREATED, String(ts()));
563
- best.setAttribute(A_SHOWN, '0');
564
- best.classList.remove('is-empty');
565
- const ph = best.querySelector(`#${PH_PREFIX}${id}`);
566
- if (ph) ph.innerHTML = '';
567
- targetEl.insertAdjacentElement('afterend', best);
568
- });
569
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
570
- S.wrapByKey.set(newKey, best);
571
-
572
- const doDestroy = () => {
573
- if (S.ezShownSinceDestroy.has(id)) {
574
- try { ez.destroyPlaceholders([id]); } catch (_) {}
575
- S.ezShownSinceDestroy.delete(id);
252
+ function pickId(poolKey) {
253
+ const pool = S.pools[poolKey] || [];
254
+ if (!pool.length) return null;
255
+ for (let t = 0; t < pool.length; t++) {
256
+ const idx = S.cursors[poolKey] % pool.length;
257
+ S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
258
+ const id = pool[idx];
259
+ if (!S.mountedIds.has(id)) return id;
576
260
  }
577
- S.ezActiveIds.delete(id);
578
- setTimeout(doDefine, 330);
579
- };
580
- const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
581
- const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
582
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
583
-
584
- return { id, wrap: best };
585
- }
586
-
587
- // ── Wraps DOM — création / suppression ────────────────────────────────────
261
+ return null;
262
+ }
588
263
 
589
264
  function makeWrap(id, klass, key) {
590
265
  const w = document.createElement('div');
591
266
  w.className = `${WRAP_CLASS} ${klass}`;
592
- w.setAttribute(A_ANCHOR, key);
593
- w.setAttribute(A_WRAPID, String(id));
594
- w.setAttribute(A_CREATED, String(ts()));
595
- w.setAttribute(A_SHOWN, '0');
596
- w.style.cssText = 'width:100%;display:block;';
597
- const cachedH = S.lastWrapHeightByClass.get(klass);
598
- if (Number.isFinite(cachedH) && cachedH > 0) w.style.minHeight = `${cachedH}px`;
267
+ w.setAttribute(A_ANCHOR, key);
268
+ w.setAttribute(A_WRAPID, String(id));
269
+ w.setAttribute(A_CREATED, String(now()));
270
+ w.setAttribute(A_SHOWN, '0');
271
+ w.setAttribute(A_STATE, 'idle');
272
+ w.style.cssText = 'display:block;width:100%;';
273
+ const h = S.lastWrapHeightByClass.get(klass);
274
+ if (Number.isFinite(h) && h > 0) w.style.minHeight = `${h}px`;
599
275
  const ph = document.createElement('div');
600
276
  ph.id = `${PH_PREFIX}${id}`;
601
277
  ph.setAttribute('data-ezoic-id', String(id));
@@ -603,473 +279,431 @@ function recycleAndMove(klass, targetEl, newKey) {
603
279
  return w;
604
280
  }
605
281
 
606
- function insertAfter(el, id, klass, key) {
607
- if (!el?.insertAdjacentElement) return null;
608
- if (findWrap(key)) return null;
609
- if (S.mountedIds.has(id)) return null;
610
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
282
+ function insertWrapAfter(anchorEl, id, klass, key) {
283
+ if (!anchorEl?.insertAdjacentElement) return null;
284
+ if (findWrapByKey(key)) return null;
285
+ if (S.mountedIds.has(id)) return null;
286
+ if (phEl(id)?.isConnected) return null;
611
287
  const w = makeWrap(id, klass, key);
612
- mutate(() => el.insertAdjacentElement('afterend', w));
288
+ mutate(() => anchorEl.insertAdjacentElement('afterend', w));
613
289
  S.mountedIds.add(id);
614
290
  S.wrapByKey.set(key, w);
291
+ const rec = ensureRegistry(id, w);
292
+ rec.key = key;
293
+ rec.createdAt = now();
294
+ rec.lastMutationAt = now();
295
+ rec.isSpecial = false;
296
+ rec.isFixedLike = false;
297
+ updateWrapState(rec, 'idle');
298
+ observePlaceholder(id);
615
299
  return w;
616
300
  }
617
301
 
618
- function dropWrap(w) {
302
+ function releaseWrap(wrap) {
619
303
  try {
620
- rememberWrapHeight(w);
621
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
622
- if (ph instanceof Element) S.io?.unobserve(ph);
623
- const id = parseInt(w.getAttribute(A_WRAPID), 10);
624
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
625
- const key = w.getAttribute(A_ANCHOR);
626
- if (key) S.wrapActivityAt.delete(`k:${key}`);
627
- if (Number.isFinite(id)) S.wrapActivityAt.delete(`i:${id}`);
628
- if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
629
- w.remove();
304
+ rememberWrapHeight(wrap);
305
+ const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
306
+ const key = wrap.getAttribute(A_ANCHOR) || '';
307
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
308
+ if (ph) try { S.io?.unobserve(ph); } catch (_) {}
309
+ if (key && S.wrapByKey.get(key) === wrap) S.wrapByKey.delete(key);
310
+ if (Number.isFinite(id)) {
311
+ S.mountedIds.delete(id);
312
+ S.pendingSet.delete(id);
313
+ S.lastShowById.delete(id);
314
+ const rec = S.regById.get(id);
315
+ if (rec) {
316
+ rec.wrap = null;
317
+ rec.state = 'retired';
318
+ rec.cooldownUntil = Math.max(rec.cooldownUntil || 0, now() + 3000);
319
+ }
320
+ }
321
+ wrap.remove();
630
322
  } catch (_) {}
631
323
  }
632
324
 
633
- // ── Prune (topics de catégorie uniquement) ────────────────────────────────
634
- //
635
- // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
636
- //
637
- // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
638
- // les li[component="category/topic"] restent dans le DOM pendant toute
639
- // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
640
- // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
641
- // liste après un long scroll et bloquent les nouvelles injections.
642
- //
643
- // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
644
- // NodeBB virtualise les posts hors-viewport il les retire puis les
645
- // réinsère. pruneOrphans verrait des ancres temporairement absentes,
646
- // supprimerait les wraps, et provoquerait une réinjection en haut.
647
-
648
- function pruneOrphansBetween() {
649
- const klass = 'ezoic-ad-between';
650
- const cfg = KIND[klass];
325
+ function wrapHasAnchor(wrap) {
326
+ const key = wrap?.getAttribute?.(A_ANCHOR);
327
+ if (!key) return false;
328
+ const i = key.indexOf(':');
329
+ const klass = key.slice(0, i);
330
+ const sid = key.slice(i + 1);
331
+ const def = KIND[klass];
332
+ if (!def) return false;
333
+ try {
334
+ const sel = `${def.sel}[${def.anchorAttr}="${sid}"]`;
335
+ return !!document.querySelector(sel);
336
+ } catch (_) { return false; }
337
+ }
651
338
 
652
- document.querySelectorAll(`${WRAP_SEL}.${klass}`).forEach(w => {
653
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
654
- if (ts() - created < Math.max(MIN_PRUNE_AGE_MS, wrapCleanupGraceMs(w))) return;
655
- if (wrapRecentActivity(w) || wrapNearViewport(w)) return;
339
+ function sweepDisconnected() {
340
+ for (const [key, wrap] of [...S.wrapByKey.entries()]) {
341
+ if (wrap?.isConnected) continue;
342
+ S.wrapByKey.delete(key);
343
+ const id = parseInt(wrap?.getAttribute?.(A_WRAPID), 10);
344
+ if (Number.isFinite(id)) {
345
+ S.mountedIds.delete(id);
346
+ S.pendingSet.delete(id);
347
+ }
348
+ }
349
+ }
656
350
 
657
- const key = w.getAttribute(A_ANCHOR) ?? '';
658
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
659
- if (!sid) { mutate(() => dropWrap(w)); return; }
351
+ function classifyWrap(rec) {
352
+ const w = rec.wrap;
353
+ if (!w?.isConnected) return;
354
+ if (!rec.isSpecial) rec.isSpecial = isSpecialLike(w);
355
+ if (!rec.isFixedLike) rec.isFixedLike = hasFixedLike(w);
356
+ }
660
357
 
661
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
662
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
663
- });
358
+ function maintenance() {
359
+ if (isBlocked()) return;
360
+ sweepDisconnected();
361
+ const t = now();
362
+ for (const [id, rec] of S.regById) {
363
+ const w = rec.wrap;
364
+ if (!w?.isConnected) continue;
365
+ classifyWrap(rec);
366
+ if (wrapNearViewport(w)) {
367
+ rec.lastSeenAt = t;
368
+ if (rec.state === 'retiring') updateWrapState(rec, 'live');
369
+ continue;
370
+ }
371
+ const created = parseInt(w.getAttribute(A_CREATED) || '0', 10) || rec.createdAt || 0;
372
+ const shown = parseInt(w.getAttribute(A_SHOWN) || '0', 10) || rec.shownAt || 0;
373
+ const age = t - Math.max(created, shown || 0, rec.lastMutationAt || 0);
374
+ const activeRecently = (t - (rec.lastMutationAt || 0)) < CFG.recentActivityMs;
375
+ const filled = isFilled(w);
376
+ if (filled) { rec.lastMutationAt = t; rec.shownAt = Math.max(rec.shownAt || 0, shown || 0); }
377
+ if (rec.isSpecial || rec.isFixedLike) continue; // never retire plugin-side
378
+ if (activeRecently) continue;
379
+ if (filled) continue; // keep rendered slots stable; no aggressive destroy/recycle
380
+ if (age < CFG.retireGraceMs) continue;
381
+ if (rec.state !== 'retiring') {
382
+ updateWrapState(rec, 'retiring');
383
+ continue;
384
+ }
385
+ // second pass confirmation
386
+ if (wrapNearViewport(w) || isFilled(w)) { updateWrapState(rec, 'live'); continue; }
387
+ releaseWrap(w);
388
+ }
664
389
  }
665
390
 
666
- // ── Injection ──────────────────────────────────────────────────────────────
391
+ function scheduleMaintenance() {
392
+ if (S.maintenanceTimer) return;
393
+ S.maintenanceTimer = setTimeout(() => {
394
+ S.maintenanceTimer = 0;
395
+ maintenance();
396
+ }, CFG.maintenanceEveryMs);
397
+ }
667
398
 
668
- /**
669
- * Ordinal 0-based pour le calcul de l'intervalle d'injection.
670
- * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
671
- */
672
399
  function ordinal(klass, el) {
673
400
  const attr = KIND[klass]?.ordinalAttr;
674
401
  if (attr) {
675
402
  const v = el.getAttribute(attr);
676
403
  if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
677
404
  }
678
- const fullSel = KIND[klass]?.sel ?? '';
679
405
  let i = 0;
680
- for (const s of el.parentElement?.children ?? []) {
406
+ const fullSel = KIND[klass]?.sel || '';
407
+ for (const s of el.parentElement?.children || []) {
681
408
  if (s === el) return i;
682
409
  if (!fullSel || s.matches?.(fullSel)) i++;
683
410
  }
684
411
  return 0;
685
412
  }
686
413
 
687
- function injectBetween(klass, items, interval, showFirst, poolKey) {
688
- if (!items.length) return 0;
689
- let inserted = 0;
414
+ function adjacentManagedWrap(el) {
415
+ const isManaged = n => !!(n?.classList?.contains(WRAP_CLASS) && n.isConnected);
416
+ return isManaged(el.nextElementSibling) || isManaged(el.previousElementSibling);
417
+ }
690
418
 
419
+ function injectFor(klass, items, interval, showFirst) {
420
+ let inserted = 0;
421
+ const poolKey = KIND[klass].pool;
691
422
  for (const el of items) {
692
- if (inserted >= MAX_INSERTS_RUN) break;
423
+ if (inserted >= CFG.maxInsertsPerRun) break;
693
424
  if (!el?.isConnected) continue;
694
-
695
425
  const ord = ordinal(klass, el);
696
426
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
697
- if (adjacentWrap(el)) continue;
698
-
427
+ if (adjacentManagedWrap(el)) continue;
699
428
  const key = anchorKey(klass, el);
700
- if (findWrap(key)) continue;
701
-
702
- let id = pickId(poolKey);
703
- if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
704
- if (id) {
705
- const w = insertAfter(el, id, klass, key);
706
- if (w) { observePh(id); inserted++; }
707
- } else {
708
- const recycled = recycleAndMove(klass, el, key);
709
- if (!recycled) break;
710
- inserted++;
711
- }
429
+ if (findWrapByKey(key)) continue;
430
+ const id = pickId(poolKey);
431
+ if (!id) break; // no recycle = anti-churn by design
432
+ if (insertWrapAfter(el, id, klass, key)) inserted++;
712
433
  }
713
434
  return inserted;
714
435
  }
715
436
 
716
- // ── IntersectionObserver & Show ────────────────────────────────────────────
437
+ function canShowId(id) {
438
+ const t = now();
439
+ const last = S.lastShowById.get(id) || 0;
440
+ if (t - last < CFG.showThrottleMs) return false;
441
+ const rec = S.regById.get(id);
442
+ if (rec && rec.cooldownUntil && t < rec.cooldownUntil) return false;
443
+ const ph = phEl(id);
444
+ if (!ph?.isConnected) return false;
445
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
446
+ if (!wrap?.isConnected) return false;
447
+ if (!wrapNearViewport(wrap)) {
448
+ // allow preloading within IO margin only if reasonably close
449
+ try {
450
+ const r = ph.getBoundingClientRect();
451
+ const vh = window.innerHeight || 800;
452
+ const preload = isMobile() ? 1800 : 1400;
453
+ if (r.top > vh + preload || r.bottom < -preload) return false;
454
+ } catch (_) {}
455
+ }
456
+ return true;
457
+ }
717
458
 
718
- function getIO() {
719
- if (S.io) return S.io;
720
- try {
721
- S.io = new IntersectionObserver(entries => {
722
- for (const e of entries) {
723
- if (!e.isIntersecting) continue;
724
- if (e.target instanceof Element) S.io?.unobserve(e.target);
725
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
726
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
727
- }
728
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
729
- } catch (_) { S.io = null; }
730
- return S.io;
459
+ function enqueueShow(id) {
460
+ if (isBlocked()) return;
461
+ const n = parseInt(id, 10);
462
+ if (!Number.isFinite(n) || n <= 0) return;
463
+ if (!S.pendingSet.has(n)) { S.pendingSet.add(n); S.pendingShow.push(n); }
464
+ if (!S.showTimer) {
465
+ S.showTimer = setTimeout(() => {
466
+ S.showTimer = 0;
467
+ drainShowQueue();
468
+ }, CFG.batchFlushMs);
469
+ }
731
470
  }
732
471
 
733
- function observePh(id) {
472
+ function observePlaceholder(id) {
734
473
  const ph = phEl(id);
735
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
736
- // Fast-path : si déjà proche viewport, ne pas attendre un callback IO complet
474
+ if (!ph?.isConnected) return;
475
+ try { getIO()?.observe(ph); } catch (_) {}
737
476
  try {
738
- if (!ph?.isConnected) return;
739
- const rect = ph.getBoundingClientRect();
477
+ const r = ph.getBoundingClientRect();
740
478
  const vh = window.innerHeight || 800;
741
- const preload = isMobile() ? 1400 : 1000;
742
- if (rect.top <= vh + preload && rect.bottom >= -preload) enqueueShow(id);
479
+ const preload = isMobile() ? 1600 : 1200;
480
+ if (r.top <= vh + preload && r.bottom >= -preload) enqueueShow(id);
743
481
  } catch (_) {}
744
482
  }
745
483
 
746
- function enqueueShow(id) {
747
- if (!id || isBlocked()) return;
748
- const n = parseInt(id, 10);
749
- if (!Number.isFinite(n) || n <= 0) return;
750
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
751
- if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
752
- scheduleDrainQueue();
753
- }
754
-
755
- function scheduleDrainQueue() {
756
- if (isBlocked()) return;
757
- if (S.showBatchTimer) return;
758
- S.showBatchTimer = setTimeout(() => {
759
- S.showBatchTimer = 0;
760
- drainQueue();
761
- }, BATCH_FLUSH_MS);
762
- }
763
-
764
- function drainQueue() {
765
- if (isBlocked()) return;
766
- const free = Math.max(0, MAX_INFLIGHT - S.inflight);
767
- if (!free || !S.pending.length) return;
484
+ function startShow(ids) {
485
+ if (!ids.length || isBlocked()) return;
486
+ S.inflightShow += ids.length;
487
+ let released = false;
488
+ const release = () => {
489
+ if (released) return;
490
+ released = true;
491
+ S.inflightShow = Math.max(0, S.inflightShow - ids.length);
492
+ drainShowQueue();
493
+ };
494
+ const guard = setTimeout(release, CFG.showFailsafeMs);
768
495
 
769
- const picked = [];
770
- const seen = new Set();
771
- const batchCap = Math.max(1, Math.min(MAX_SHOW_BATCH, free, getDynamicShowBatchMax()));
772
- while (S.pending.length && picked.length < batchCap) {
773
- const id = S.pending.shift();
774
- S.pendingSet.delete(id);
775
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
776
- if (!phEl(id)?.isConnected) continue;
777
- seen.add(id);
778
- picked.push(id);
496
+ requestAnimationFrame(() => {
497
+ try {
498
+ if (isBlocked()) { clearTimeout(guard); return release(); }
499
+ window.ezstandalone = window.ezstandalone || {};
500
+ const ez = window.ezstandalone;
501
+ const valid = [];
502
+ const t = now();
503
+ for (const id of ids) {
504
+ if (!canShowId(id)) continue;
505
+ const ph = phEl(id);
506
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
507
+ if (!wrap) continue;
508
+ wrap.setAttribute(A_SHOWN, String(t));
509
+ const rec = ensureRegistry(id, wrap);
510
+ rec.wrap = wrap;
511
+ rec.shownAt = t;
512
+ rec.lastMutationAt = t;
513
+ rec.cooldownUntil = t + CFG.showCooldownMs;
514
+ classifyWrap(rec);
515
+ updateWrapState(rec, 'showing');
516
+ S.lastShowById.set(id, t);
517
+ valid.push(id);
518
+ }
519
+ if (!valid.length) { clearTimeout(guard); return release(); }
520
+ const run = () => {
521
+ try { ez.showAds(...valid); } catch (_) {
522
+ try { ez.showAds(valid); } catch (_) {}
523
+ }
524
+ setTimeout(() => {
525
+ for (const id of valid) {
526
+ const rec = S.regById.get(id);
527
+ if (!rec) continue;
528
+ const w = rec.wrap;
529
+ if (w?.isConnected) {
530
+ if (isFilled(w)) rec.lastMutationAt = now();
531
+ updateWrapState(rec, 'live');
532
+ }
533
+ }
534
+ clearTimeout(guard);
535
+ release();
536
+ }, CFG.showReleaseMs);
537
+ };
538
+ Array.isArray(ez.cmd) ? ez.cmd.push(run) : run();
539
+ } catch (_) {
540
+ clearTimeout(guard);
541
+ release();
542
+ }
543
+ });
779
544
  }
780
- if (picked.length) startShowBatch(picked);
781
- if (S.pending.length && (MAX_INFLIGHT - S.inflight) > 0) scheduleDrainQueue();
782
- }
783
-
784
- function startShowBatch(ids) {
785
- if (!ids?.length || isBlocked()) return;
786
- const reserve = ids.length;
787
- S.inflight += reserve;
788
545
 
789
- let done = false;
790
- const release = () => {
791
- if (done) return;
792
- done = true;
793
- S.inflight = Math.max(0, S.inflight - reserve);
794
- drainQueue();
795
- };
796
- const timer = setTimeout(release, SHOW_FAILSAFE_MS);
546
+ function drainShowQueue() {
547
+ if (isBlocked()) return;
548
+ if (S.inflightShow >= CFG.maxShowInflight) return;
549
+ if (!S.pendingShow.length) return;
550
+ const cap = Math.min(CFG.maxShowBatch, CFG.maxShowInflight - S.inflightShow);
551
+ const picked = [];
552
+ while (S.pendingShow.length && picked.length < cap) {
553
+ const id = S.pendingShow.shift();
554
+ S.pendingSet.delete(id);
555
+ if (!Number.isFinite(id) || id <= 0) continue;
556
+ if (!phEl(id)?.isConnected) continue;
557
+ picked.push(id);
558
+ }
559
+ if (picked.length) startShow(picked);
560
+ if (S.pendingShow.length) scheduleMaintenance();
561
+ }
797
562
 
798
- requestAnimationFrame(() => {
563
+ function getIO() {
564
+ if (S.io) return S.io;
799
565
  try {
800
- if (isBlocked()) { clearTimeout(timer); return release(); }
801
-
802
- const valid = [];
803
- const t = ts();
804
-
805
- for (const raw of ids) {
806
- const id = parseInt(raw, 10);
807
- if (!Number.isFinite(id) || id <= 0) continue;
808
- const ph = phEl(id);
809
- if (!canShowPlaceholderId(id, t)) continue;
810
-
811
- S.lastShow.set(id, t);
812
- try { const wrap = ph.closest?.(WRAP_SEL); wrap?.setAttribute(A_SHOWN, String(t)); if (wrap) markWrapActivity(wrap); } catch (_) {}
813
- valid.push(id);
814
- }
815
-
816
- if (!valid.length) { clearTimeout(timer); return release(); }
817
-
818
- window.ezstandalone = window.ezstandalone || {};
819
- const ez = window.ezstandalone;
820
- const doShow = () => {
821
- const prepared = destroyBeforeReuse(valid);
822
- if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
823
- try { ez.showAds(...prepared); } catch (_) {}
824
- for (const id of prepared) {
825
- S.ezActiveIds.add(id);
826
- S.ezShownSinceDestroy.add(id);
566
+ S.io = new IntersectionObserver(entries => {
567
+ for (const e of entries) {
568
+ const t = e.target;
569
+ if (!(t instanceof Element)) continue;
570
+ const id = parseInt(t.getAttribute('data-ezoic-id'), 10);
571
+ if (!Number.isFinite(id) || id <= 0) continue;
572
+ const rec = S.regById.get(id);
573
+ if (rec) rec.lastSeenAt = now();
574
+ if (e.isIntersecting) enqueueShow(id);
827
575
  }
828
- setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
829
- };
830
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
831
- } catch (_) { clearTimeout(timer); release(); }
832
- });
833
- }
834
-
835
- // ── Patch Ezoic showAds ────────────────────────────────────────────────────
836
- //
837
- // Intercepte ez.showAds() pour :
838
- // – ignorer les appels pendant blockedUntil
839
- // – filtrer les ids dont le placeholder n'est pas en DOM
576
+ }, {
577
+ root: null,
578
+ rootMargin: isMobile() ? CFG.ioMarginMobile : CFG.ioMarginDesktop,
579
+ threshold: 0,
580
+ });
581
+ } catch (_) { S.io = null; }
582
+ return S.io;
583
+ }
840
584
 
841
585
  function patchShowAds() {
842
- const apply = () => {
586
+ const install = () => {
843
587
  try {
844
588
  window.ezstandalone = window.ezstandalone || {};
845
589
  const ez = window.ezstandalone;
846
- if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
847
- window.__nbbEzPatched = true;
590
+ if (window.__nbbEzPatchedV22 || typeof ez.showAds !== 'function') return;
591
+ window.__nbbEzPatchedV22 = true;
848
592
  const orig = ez.showAds.bind(ez);
849
593
  ez.showAds = function (...args) {
850
594
  if (isBlocked()) return;
851
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
595
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
852
596
  const valid = [];
853
597
  const seen = new Set();
854
598
  for (const v of ids) {
855
599
  const id = parseInt(v, 10);
856
600
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
857
- if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
601
+ const ph = phEl(id);
602
+ if (!ph?.isConnected) continue;
603
+ const wrap = ph.closest?.(`.${WRAP_CLASS}`);
604
+ if (!wrap?.isConnected) continue;
605
+ const rec = ensureRegistry(id, wrap);
606
+ if (rec.cooldownUntil && now() < rec.cooldownUntil && (now() - (rec.shownAt || 0)) < 500) continue;
858
607
  seen.add(id);
859
608
  valid.push(id);
860
609
  }
861
610
  if (!valid.length) return;
862
- try { orig(...valid); } catch (_) {
863
- for (const id of valid) {
864
- try { orig(id); } catch (_) {}
865
- }
866
- }
611
+ try { return orig(...valid); } catch (_) { try { return orig(valid); } catch (_) {} }
867
612
  };
868
613
  } catch (_) {}
869
614
  };
870
- apply();
871
- if (!window.__nbbEzPatched) {
615
+ install();
616
+ if (!window.__nbbEzPatchedV22) {
872
617
  window.ezstandalone = window.ezstandalone || {};
873
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
618
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(install);
874
619
  }
875
620
  }
876
621
 
877
- // ── Core ───────────────────────────────────────────────────────────────────
878
-
879
- async function runCore() {
880
- if (isBlocked()) return 0;
881
- patchShowAds();
882
- sweepDeadWraps();
883
-
884
- const cfg = await fetchConfig();
885
- if (!cfg || cfg.excluded) return 0;
886
- initPools(cfg);
887
-
888
- const kind = getKind();
889
- if (kind === 'other') return 0;
890
-
891
- const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
892
- if (!normBool(cfgEnable)) return 0;
893
- const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
894
- return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
895
- };
896
-
897
- if (kind === 'topic') return exec(
898
- 'ezoic-ad-message', getPosts,
899
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
900
- );
901
-
902
- if (kind === 'categoryTopics') {
903
- pruneOrphansBetween();
904
- return exec(
905
- 'ezoic-ad-between', getTopics,
906
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
907
- );
908
- }
909
-
910
- return exec(
911
- 'ezoic-ad-categories', getCategories,
912
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
913
- );
914
- }
915
-
916
- // ── Scheduler ──────────────────────────────────────────────────────────────
917
-
918
- function scheduleRun(cb) {
919
- if (S.runQueued) return;
920
- S.runQueued = true;
921
- requestAnimationFrame(async () => {
922
- S.runQueued = false;
923
- if (S.pageKey && pageKey() !== S.pageKey) return;
924
- let n = 0;
925
- try { n = await runCore(); } catch (_) {}
926
- try { cb?.(n); } catch (_) {}
927
- });
928
- }
929
-
930
- function requestBurst() {
931
- if (isBlocked()) return;
932
- const t = ts();
933
- if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
934
- S.lastBurstTs = t;
935
- S.pageKey = pageKey();
936
- S.burstDeadline = t + 2000;
937
-
938
- if (S.burstActive) return;
939
- S.burstActive = true;
940
- S.burstCount = 0;
941
-
942
- const step = () => {
943
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
944
- S.burstActive = false; return;
622
+ function healFalseEmpty(root) {
623
+ try {
624
+ const list = [];
625
+ if (root instanceof Element && root.classList.contains(WRAP_CLASS)) list.push(root);
626
+ if (root?.querySelectorAll) {
627
+ for (const w of root.querySelectorAll(`.${WRAP_CLASS}.is-empty`)) list.push(w);
945
628
  }
946
- S.burstCount++;
947
- scheduleRun(n => {
948
- if (!n && !S.pending.length) { S.burstActive = false; return; }
949
- setTimeout(step, n > 0 ? 80 : 180);
950
- });
951
- };
952
- step();
953
- }
954
-
955
- // ── Cleanup navigation ─────────────────────────────────────────────────────
956
-
957
- function cleanup() {
958
- blockedUntil = ts() + 1500;
959
- mutate(() => document.querySelectorAll(WRAP_SEL).forEach(dropWrap));
960
- S.cfg = null;
961
- S.poolsReady = false;
962
- S.pools = { topics: [], posts: [], categories: [] };
963
- S.cursors = { topics: 0, posts: 0, categories: 0 };
964
- S.mountedIds.clear();
965
- S.lastShow.clear();
966
- S.wrapByKey.clear();
967
- S.ezActiveIds.clear();
968
- S.ezShownSinceDestroy.clear();
969
- S.wrapActivityAt.clear();
970
- S.inflight = 0;
971
- S.pending = [];
972
- S.pendingSet.clear();
973
- if (S.showBatchTimer) { clearTimeout(S.showBatchTimer); S.showBatchTimer = 0; }
974
- if (S.destroyBatchTimer) { clearTimeout(S.destroyBatchTimer); S.destroyBatchTimer = 0; }
975
- S.destroyPending = [];
976
- S.destroyPendingSet.clear();
977
- S.burstActive = false;
978
- S.runQueued = false;
979
- S.sweepQueued = false;
980
- S.scrollSpeed = 0;
981
- S.lastScrollY = 0;
982
- S.lastScrollTs = 0;
983
- }
984
-
985
- function nodeMatchesAny(node, selectors) {
986
- if (!(node instanceof Element)) return false;
987
- for (const sel of selectors) {
988
- try { if (node.matches(sel)) return true; } catch (_) {}
989
- }
990
- return false;
991
- }
992
-
993
- function nodeContainsAny(node, selectors) {
994
- if (!(node instanceof Element)) return false;
995
- for (const sel of selectors) {
996
- try { if (node.querySelector(sel)) return true; } catch (_) {}
997
- }
998
- return false;
629
+ for (const w of list) {
630
+ if (w.classList.contains('is-empty') && isFilled(w)) w.classList.remove('is-empty');
631
+ }
632
+ } catch (_) {}
999
633
  }
1000
634
 
1001
- // ── MutationObserver ───────────────────────────────────────────────────────
1002
-
1003
- function ensureDomObserver() {
635
+ function ensureObserver() {
1004
636
  if (S.domObs) return;
1005
637
  S.domObs = new MutationObserver(muts => {
1006
- if (S.mutGuard > 0 || isBlocked()) return;
638
+ if (S.muting > 0 || isBlocked()) return;
639
+ let shouldRun = false;
1007
640
  for (const m of muts) {
1008
- let sawWrapRemoval = false;
1009
- for (const n of m.removedNodes) {
1010
- if (n.nodeType !== 1) continue;
1011
- if (nodeMatchesAny(n, [WRAP_SEL]) || nodeContainsAny(n, [WRAP_SEL])) {
1012
- sawWrapRemoval = true;
1013
- try {
1014
- if (n instanceof Element && n.classList?.contains(WRAP_CLASS)) markWrapActivity(n);
1015
- else if (n instanceof Element) { const inner = n.querySelector?.(WRAP_SEL); if (inner) markWrapActivity(inner); }
1016
- } catch (_) {}
641
+ for (const n of m.addedNodes) {
642
+ if (!(n instanceof Element)) continue;
643
+ healFalseEmpty(n);
644
+ const wrap = n.classList?.contains(WRAP_CLASS) ? n : n.querySelector?.(`.${WRAP_CLASS}`);
645
+ if (wrap) {
646
+ const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
647
+ if (Number.isFinite(id)) {
648
+ const rec = ensureRegistry(id, wrap);
649
+ rec.lastMutationAt = now();
650
+ classifyWrap(rec);
651
+ }
652
+ }
653
+ if (n.matches?.(SEL.post) || n.matches?.(SEL.topic) || n.matches?.(SEL.category) ||
654
+ n.querySelector?.(SEL.post) || n.querySelector?.(SEL.topic) || n.querySelector?.(SEL.category)) {
655
+ shouldRun = true;
1017
656
  }
1018
657
  }
1019
- if (sawWrapRemoval) queueSweepDeadWraps();
1020
- for (const n of m.addedNodes) {
1021
- if (n.nodeType !== 1) continue;
1022
- try { healFalseEmpty(n); } catch (_) {}
1023
- try {
1024
- const w = (n instanceof Element && (n.classList?.contains(WRAP_CLASS) ? n : n.closest?.(WRAP_SEL))) || null;
1025
- if (w) markWrapActivity(w);
1026
- else if (n instanceof Element) { const inner = n.querySelector?.(WRAP_SEL); if (inner) markWrapActivity(inner); }
1027
- } catch (_) {}
1028
- // matches() d'abord (O(1)), querySelector() seulement si nécessaire
1029
- if (nodeMatchesAny(n, CONTENT_SEL_LIST) || nodeContainsAny(n, CONTENT_SEL_LIST)) {
1030
- requestBurst(); return;
658
+ for (const n of m.removedNodes) {
659
+ if (!(n instanceof Element)) continue;
660
+ if (n.classList?.contains(WRAP_CLASS) || n.querySelector?.(`.${WRAP_CLASS}`)) {
661
+ shouldRun = true;
1031
662
  }
1032
663
  }
1033
664
  }
665
+ if (shouldRun) {
666
+ scheduleRun();
667
+ scheduleMaintenance();
668
+ }
1034
669
  });
1035
670
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
1036
671
  }
1037
672
 
1038
- // ── Utilitaires ────────────────────────────────────────────────────────────
1039
-
1040
673
  function ensureTcfLocator() {
1041
674
  try {
1042
675
  if (!window.__tcfapi && !window.__cmp) return;
1043
676
  const inject = () => {
1044
677
  if (document.getElementById('__tcfapiLocator')) return;
1045
678
  const f = document.createElement('iframe');
1046
- f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
679
+ f.style.display = 'none';
680
+ f.id = f.name = '__tcfapiLocator';
1047
681
  (document.body || document.documentElement).appendChild(f);
1048
682
  };
1049
683
  inject();
1050
- if (!window.__nbbTcfObs) {
1051
- window.__nbbTcfObs = new MutationObserver(inject);
1052
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
684
+ if (!window.__nbbTcfObsV22) {
685
+ window.__nbbTcfObsV22 = new MutationObserver(inject);
686
+ window.__nbbTcfObsV22.observe(document.documentElement, { childList: true, subtree: true });
1053
687
  }
1054
688
  } catch (_) {}
1055
689
  }
1056
690
 
1057
- const _warmed = new Set();
691
+ const warmed = new Set();
1058
692
  function warmNetwork() {
1059
693
  const head = document.head;
1060
694
  if (!head) return;
1061
695
  const frag = document.createDocumentFragment();
1062
- for (const [rel, href, cors] of [
1063
- ['preconnect', 'https://g.ezoic.net', true ],
1064
- ['preconnect', 'https://go.ezoic.net', true ],
1065
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
1066
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
1067
- ['dns-prefetch', 'https://g.ezoic.net', false],
696
+ const defs = [
697
+ ['preconnect', 'https://g.ezoic.net', true],
698
+ ['preconnect', 'https://go.ezoic.net', true],
699
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
700
+ ['dns-prefetch', 'https://g.ezoic.net', false],
1068
701
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1069
- ]) {
1070
- const k = `${rel}|${href}`;
1071
- if (_warmed.has(k)) continue;
1072
- _warmed.add(k);
702
+ ];
703
+ for (const [rel, href, cors] of defs) {
704
+ const key = `${rel}|${href}`;
705
+ if (warmed.has(key)) continue;
706
+ warmed.add(key);
1073
707
  const l = document.createElement('link');
1074
708
  l.rel = rel; l.href = href;
1075
709
  if (cors) l.crossOrigin = 'anonymous';
@@ -1078,33 +712,93 @@ function startShowBatch(ids) {
1078
712
  head.appendChild(frag);
1079
713
  }
1080
714
 
1081
- // ── Bindings ───────────────────────────────────────────────────────────────
715
+ async function runCore() {
716
+ if (isBlocked()) return 0;
717
+ patchShowAds();
718
+ const cfg = await fetchConfig();
719
+ if (!cfg || cfg.excluded) return 0;
720
+ initPools(cfg);
721
+
722
+ const kind = getKind();
723
+ if (kind === 'other') return 0;
724
+
725
+ if (kind === 'topic') {
726
+ if (!normBool(cfg.enableMessageAds)) return 0;
727
+ const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
728
+ return injectFor('ezoic-ad-message', getPosts(), interval, normBool(cfg.showFirstMessageAd));
729
+ }
730
+
731
+ if (kind === 'categoryTopics') {
732
+ if (!normBool(cfg.enableBetweenAds)) return 0;
733
+ const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 3);
734
+ return injectFor('ezoic-ad-between', getTopics(), interval, normBool(cfg.showFirstTopicAd));
735
+ }
736
+
737
+ if (!normBool(cfg.enableCategoryAds)) return 0;
738
+ const interval = Math.max(1, parseInt(cfg.intervalCategories, 10) || 3);
739
+ return injectFor('ezoic-ad-categories', getCategories(), interval, normBool(cfg.showFirstCategoryAd));
740
+ }
741
+
742
+ function scheduleRun() {
743
+ if (isBlocked()) return;
744
+ if (S.runTimer) return;
745
+ S.runTimer = setTimeout(async () => {
746
+ S.runTimer = 0;
747
+ const pk = pageKey();
748
+ if (S.pageKey && S.pageKey !== pk) return;
749
+ try { await runCore(); } catch (_) {}
750
+ scheduleMaintenance();
751
+ }, CFG.runDebounceMs);
752
+ }
753
+
754
+ function cleanup() {
755
+ S.blockedUntil = now() + 1500;
756
+ if (S.runTimer) { clearTimeout(S.runTimer); S.runTimer = 0; }
757
+ if (S.showTimer) { clearTimeout(S.showTimer); S.showTimer = 0; }
758
+ if (S.maintenanceTimer) { clearTimeout(S.maintenanceTimer); S.maintenanceTimer = 0; }
759
+ mutate(() => {
760
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) releaseWrap(w);
761
+ });
762
+ S.cfg = null;
763
+ S.poolsReady = false;
764
+ S.pools = { topics: [], posts: [], categories: [] };
765
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
766
+ S.mountedIds.clear();
767
+ S.wrapByKey.clear();
768
+ S.regById.clear();
769
+ S.pendingShow = [];
770
+ S.pendingSet.clear();
771
+ S.lastShowById.clear();
772
+ S.inflightShow = 0;
773
+ }
1082
774
 
1083
775
  function bindNodeBB() {
1084
776
  const $ = window.jQuery;
1085
777
  if (!$) return;
1086
-
1087
- $(window).off('.nbbEzoic');
1088
- $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1089
- $(window).on('action:ajaxify.end.nbbEzoic', () => {
1090
- S.pageKey = pageKey();
1091
- blockedUntil = 0;
1092
- ensureTcfLocator(); warmNetwork();
1093
- patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
778
+ $(window).off('.nbbEzoicV22');
779
+ $(window).on('action:ajaxify.start.nbbEzoicV22', cleanup);
780
+ $(window).on('action:ajaxify.end.nbbEzoicV22', () => {
781
+ S.pageKey = pageKey();
782
+ S.blockedUntil = 0;
783
+ ensureTcfLocator();
784
+ warmNetwork();
785
+ patchShowAds();
786
+ getIO();
787
+ ensureObserver();
788
+ scheduleRun();
1094
789
  });
1095
-
1096
- const burstEvts = [
790
+ const events = [
1097
791
  'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
1098
792
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1099
- ].map(e => `${e}.nbbEzoic`).join(' ');
1100
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
1101
-
793
+ ];
794
+ for (const ev of events) {
795
+ $(window).on(`${ev}.nbbEzoicV22`, () => { if (!isBlocked()) scheduleRun(); });
796
+ }
1102
797
  try {
1103
798
  require(['hooks'], hooks => {
1104
799
  if (typeof hooks?.on !== 'function') return;
1105
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
1106
- 'action:categories.loaded', 'action:topic.loaded']) {
1107
- try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
800
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:topic.loaded']) {
801
+ try { hooks.on(ev, () => { if (!isBlocked()) scheduleRun(); }); } catch (_) {}
1108
802
  }
1109
803
  });
1110
804
  } catch (_) {}
@@ -1112,39 +806,27 @@ function startShowBatch(ids) {
1112
806
 
1113
807
  function bindScroll() {
1114
808
  let ticking = false;
1115
- try {
1116
- S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1117
- S.lastScrollTs = ts();
1118
- } catch (_) {}
1119
809
  window.addEventListener('scroll', () => {
1120
- try {
1121
- const y = window.scrollY || window.pageYOffset || 0;
1122
- const t = ts();
1123
- const dy = y - (S.lastScrollY || 0);
1124
- const dt = Math.max(1, t - (S.lastScrollTs || t));
1125
- if (Math.abs(dy) > 1) S.scrollDir = dy >= 0 ? 1 : -1;
1126
- const inst = Math.abs(dy) * 1000 / dt;
1127
- S.scrollSpeed = S.scrollSpeed ? (S.scrollSpeed * 0.7 + inst * 0.3) : inst;
1128
- S.lastScrollY = y;
1129
- S.lastScrollTs = t;
1130
- } catch (_) {}
1131
810
  if (ticking) return;
1132
811
  ticking = true;
1133
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
812
+ requestAnimationFrame(() => {
813
+ ticking = false;
814
+ if (!isBlocked()) {
815
+ scheduleMaintenance();
816
+ scheduleRun();
817
+ }
818
+ });
1134
819
  }, { passive: true });
1135
820
  }
1136
821
 
1137
- // ── Boot ───────────────────────────────────────────────────────────────────
1138
-
1139
822
  S.pageKey = pageKey();
1140
823
  ensureTcfLocator();
1141
824
  warmNetwork();
1142
825
  patchShowAds();
1143
826
  getIO();
1144
- ensureDomObserver();
827
+ ensureObserver();
1145
828
  bindNodeBB();
1146
829
  bindScroll();
1147
- blockedUntil = 0;
1148
- requestBurst();
1149
-
830
+ S.blockedUntil = 0;
831
+ scheduleRun();
1150
832
  })();