nodebb-plugin-ezoic-infinite 1.8.21 → 1.8.23

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 +566 -922
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,396 @@ 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;
768
-
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);
779
- }
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
-
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);
797
-
798
- requestAnimationFrame(() => {
799
- 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);
827
- }
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
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);
840
495
 
841
- function patchShowAds() {
842
- const apply = () => {
496
+ requestAnimationFrame(() => {
843
497
  try {
498
+ if (isBlocked()) { clearTimeout(guard); return release(); }
844
499
  window.ezstandalone = window.ezstandalone || {};
845
500
  const ez = window.ezstandalone;
846
- if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
847
- window.__nbbEzPatched = true;
848
- const orig = ez.showAds.bind(ez);
849
- ez.showAds = function (...args) {
850
- if (isBlocked()) return;
851
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
852
- const valid = [];
853
- const seen = new Set();
854
- for (const v of ids) {
855
- const id = parseInt(v, 10);
856
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
857
- if (!phEl(id)?.isConnected || !hasSinglePlaceholder(id)) continue;
858
- seen.add(id);
859
- valid.push(id);
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 (_) {}
860
523
  }
861
- if (!valid.length) return;
862
- try { orig(...valid); } catch (_) {
524
+ setTimeout(() => {
863
525
  for (const id of valid) {
864
- try { orig(id); } catch (_) {}
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
+ }
865
533
  }
866
- }
534
+ clearTimeout(guard);
535
+ release();
536
+ }, CFG.showReleaseMs);
867
537
  };
868
- } catch (_) {}
869
- };
870
- apply();
871
- if (!window.__nbbEzPatched) {
872
- window.ezstandalone = window.ezstandalone || {};
873
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
874
- }
875
- }
876
-
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 (_) {}
538
+ Array.isArray(ez.cmd) ? ez.cmd.push(run) : run();
539
+ } catch (_) {
540
+ clearTimeout(guard);
541
+ release();
542
+ }
927
543
  });
928
544
  }
929
545
 
930
- function requestBurst() {
546
+ function drainShowQueue() {
931
547
  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;
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
+ }
941
562
 
942
- const step = () => {
943
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
944
- S.burstActive = false; return;
945
- }
946
- S.burstCount++;
947
- scheduleRun(n => {
948
- if (!n && !S.pending.length) { S.burstActive = false; return; }
949
- setTimeout(step, n > 0 ? 80 : 180);
563
+ function getIO() {
564
+ if (S.io) return S.io;
565
+ try {
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);
575
+ }
576
+ }, {
577
+ root: null,
578
+ rootMargin: isMobile() ? CFG.ioMarginMobile : CFG.ioMarginDesktop,
579
+ threshold: 0,
950
580
  });
951
- };
952
- step();
581
+ } catch (_) { S.io = null; }
582
+ return S.io;
953
583
  }
954
584
 
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
585
 
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
586
 
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;
587
+ function healFalseEmpty(root) {
588
+ try {
589
+ const list = [];
590
+ if (root instanceof Element && root.classList.contains(WRAP_CLASS)) list.push(root);
591
+ if (root?.querySelectorAll) {
592
+ for (const w of root.querySelectorAll(`.${WRAP_CLASS}.is-empty`)) list.push(w);
593
+ }
594
+ for (const w of list) {
595
+ if (w.classList.contains('is-empty') && isFilled(w)) w.classList.remove('is-empty');
596
+ }
597
+ } catch (_) {}
999
598
  }
1000
599
 
1001
- // ── MutationObserver ───────────────────────────────────────────────────────
1002
-
1003
- function ensureDomObserver() {
600
+ function ensureObserver() {
1004
601
  if (S.domObs) return;
1005
602
  S.domObs = new MutationObserver(muts => {
1006
- if (S.mutGuard > 0 || isBlocked()) return;
603
+ if (S.muting > 0 || isBlocked()) return;
604
+ let shouldRun = false;
1007
605
  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 (_) {}
606
+ for (const n of m.addedNodes) {
607
+ if (!(n instanceof Element)) continue;
608
+ healFalseEmpty(n);
609
+ const wrap = n.classList?.contains(WRAP_CLASS) ? n : n.querySelector?.(`.${WRAP_CLASS}`);
610
+ if (wrap) {
611
+ const id = parseInt(wrap.getAttribute(A_WRAPID), 10);
612
+ if (Number.isFinite(id)) {
613
+ const rec = ensureRegistry(id, wrap);
614
+ rec.lastMutationAt = now();
615
+ classifyWrap(rec);
616
+ }
617
+ }
618
+ if (n.matches?.(SEL.post) || n.matches?.(SEL.topic) || n.matches?.(SEL.category) ||
619
+ n.querySelector?.(SEL.post) || n.querySelector?.(SEL.topic) || n.querySelector?.(SEL.category)) {
620
+ shouldRun = true;
1017
621
  }
1018
622
  }
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;
623
+ for (const n of m.removedNodes) {
624
+ if (!(n instanceof Element)) continue;
625
+ if (n.classList?.contains(WRAP_CLASS) || n.querySelector?.(`.${WRAP_CLASS}`)) {
626
+ shouldRun = true;
1031
627
  }
1032
628
  }
1033
629
  }
630
+ if (shouldRun) {
631
+ scheduleRun();
632
+ scheduleMaintenance();
633
+ }
1034
634
  });
1035
635
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
1036
636
  }
1037
637
 
1038
- // ── Utilitaires ────────────────────────────────────────────────────────────
1039
-
1040
638
  function ensureTcfLocator() {
1041
639
  try {
1042
640
  if (!window.__tcfapi && !window.__cmp) return;
1043
641
  const inject = () => {
1044
642
  if (document.getElementById('__tcfapiLocator')) return;
1045
643
  const f = document.createElement('iframe');
1046
- f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
644
+ f.style.display = 'none';
645
+ f.id = f.name = '__tcfapiLocator';
1047
646
  (document.body || document.documentElement).appendChild(f);
1048
647
  };
1049
648
  inject();
1050
- if (!window.__nbbTcfObs) {
1051
- window.__nbbTcfObs = new MutationObserver(inject);
1052
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
649
+ if (!window.__nbbTcfObsV22) {
650
+ window.__nbbTcfObsV22 = new MutationObserver(inject);
651
+ window.__nbbTcfObsV22.observe(document.documentElement, { childList: true, subtree: true });
1053
652
  }
1054
653
  } catch (_) {}
1055
654
  }
1056
655
 
1057
- const _warmed = new Set();
656
+ const warmed = new Set();
1058
657
  function warmNetwork() {
1059
658
  const head = document.head;
1060
659
  if (!head) return;
1061
660
  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],
661
+ const defs = [
662
+ ['preconnect', 'https://g.ezoic.net', true],
663
+ ['preconnect', 'https://go.ezoic.net', true],
664
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
665
+ ['dns-prefetch', 'https://g.ezoic.net', false],
1068
666
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
1069
- ]) {
1070
- const k = `${rel}|${href}`;
1071
- if (_warmed.has(k)) continue;
1072
- _warmed.add(k);
667
+ ];
668
+ for (const [rel, href, cors] of defs) {
669
+ const key = `${rel}|${href}`;
670
+ if (warmed.has(key)) continue;
671
+ warmed.add(key);
1073
672
  const l = document.createElement('link');
1074
673
  l.rel = rel; l.href = href;
1075
674
  if (cors) l.crossOrigin = 'anonymous';
@@ -1078,33 +677,91 @@ function startShowBatch(ids) {
1078
677
  head.appendChild(frag);
1079
678
  }
1080
679
 
1081
- // ── Bindings ───────────────────────────────────────────────────────────────
680
+ async function runCore() {
681
+ if (isBlocked()) return 0;
682
+ const cfg = await fetchConfig();
683
+ if (!cfg || cfg.excluded) return 0;
684
+ initPools(cfg);
685
+
686
+ const kind = getKind();
687
+ if (kind === 'other') return 0;
688
+
689
+ if (kind === 'topic') {
690
+ if (!normBool(cfg.enableMessageAds)) return 0;
691
+ const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
692
+ return injectFor('ezoic-ad-message', getPosts(), interval, normBool(cfg.showFirstMessageAd));
693
+ }
694
+
695
+ if (kind === 'categoryTopics') {
696
+ if (!normBool(cfg.enableBetweenAds)) return 0;
697
+ const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 3);
698
+ return injectFor('ezoic-ad-between', getTopics(), interval, normBool(cfg.showFirstTopicAd));
699
+ }
700
+
701
+ if (!normBool(cfg.enableCategoryAds)) return 0;
702
+ const interval = Math.max(1, parseInt(cfg.intervalCategories, 10) || 3);
703
+ return injectFor('ezoic-ad-categories', getCategories(), interval, normBool(cfg.showFirstCategoryAd));
704
+ }
705
+
706
+ function scheduleRun() {
707
+ if (isBlocked()) return;
708
+ if (S.runTimer) return;
709
+ S.runTimer = setTimeout(async () => {
710
+ S.runTimer = 0;
711
+ const pk = pageKey();
712
+ if (S.pageKey && S.pageKey !== pk) return;
713
+ try { await runCore(); } catch (_) {}
714
+ scheduleMaintenance();
715
+ }, CFG.runDebounceMs);
716
+ }
717
+
718
+ function cleanup() {
719
+ S.blockedUntil = now() + 1500;
720
+ if (S.runTimer) { clearTimeout(S.runTimer); S.runTimer = 0; }
721
+ if (S.showTimer) { clearTimeout(S.showTimer); S.showTimer = 0; }
722
+ if (S.maintenanceTimer) { clearTimeout(S.maintenanceTimer); S.maintenanceTimer = 0; }
723
+ mutate(() => {
724
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) releaseWrap(w);
725
+ });
726
+ S.cfg = null;
727
+ S.poolsReady = false;
728
+ S.pools = { topics: [], posts: [], categories: [] };
729
+ S.cursors = { topics: 0, posts: 0, categories: 0 };
730
+ S.mountedIds.clear();
731
+ S.wrapByKey.clear();
732
+ S.regById.clear();
733
+ S.pendingShow = [];
734
+ S.pendingSet.clear();
735
+ S.lastShowById.clear();
736
+ S.inflightShow = 0;
737
+ }
1082
738
 
1083
739
  function bindNodeBB() {
1084
740
  const $ = window.jQuery;
1085
741
  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();
742
+ $(window).off('.nbbEzoicV22');
743
+ $(window).on('action:ajaxify.start.nbbEzoicV22', cleanup);
744
+ $(window).on('action:ajaxify.end.nbbEzoicV22', () => {
745
+ S.pageKey = pageKey();
746
+ S.blockedUntil = 0;
747
+ ensureTcfLocator();
748
+ warmNetwork();
749
+ getIO();
750
+ ensureObserver();
751
+ scheduleRun();
1094
752
  });
1095
-
1096
- const burstEvts = [
753
+ const events = [
1097
754
  'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
1098
755
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1099
- ].map(e => `${e}.nbbEzoic`).join(' ');
1100
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
1101
-
756
+ ];
757
+ for (const ev of events) {
758
+ $(window).on(`${ev}.nbbEzoicV22`, () => { if (!isBlocked()) scheduleRun(); });
759
+ }
1102
760
  try {
1103
761
  require(['hooks'], hooks => {
1104
762
  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 (_) {}
763
+ for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded', 'action:topic.loaded']) {
764
+ try { hooks.on(ev, () => { if (!isBlocked()) scheduleRun(); }); } catch (_) {}
1108
765
  }
1109
766
  });
1110
767
  } catch (_) {}
@@ -1112,39 +769,26 @@ function startShowBatch(ids) {
1112
769
 
1113
770
  function bindScroll() {
1114
771
  let ticking = false;
1115
- try {
1116
- S.lastScrollY = window.scrollY || window.pageYOffset || 0;
1117
- S.lastScrollTs = ts();
1118
- } catch (_) {}
1119
772
  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
773
  if (ticking) return;
1132
774
  ticking = true;
1133
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
775
+ requestAnimationFrame(() => {
776
+ ticking = false;
777
+ if (!isBlocked()) {
778
+ scheduleMaintenance();
779
+ scheduleRun();
780
+ }
781
+ });
1134
782
  }, { passive: true });
1135
783
  }
1136
784
 
1137
- // ── Boot ───────────────────────────────────────────────────────────────────
1138
-
1139
785
  S.pageKey = pageKey();
1140
786
  ensureTcfLocator();
1141
787
  warmNetwork();
1142
- patchShowAds();
1143
788
  getIO();
1144
- ensureDomObserver();
789
+ ensureObserver();
1145
790
  bindNodeBB();
1146
791
  bindScroll();
1147
- blockedUntil = 0;
1148
- requestBurst();
1149
-
792
+ S.blockedUntil = 0;
793
+ scheduleRun();
1150
794
  })();