nodebb-plugin-ezoic-infinite 1.8.10 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -71,8 +71,9 @@ async function getSettings() {
71
71
 
72
72
  async function isUserExcluded(uid, excludedGroups) {
73
73
  if (!uid || !excludedGroups.length) return false;
74
+ const excluded = new Set((excludedGroups || []).map(String));
74
75
  const userGroups = await groups.getUserGroups([uid]);
75
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
76
+ return (userGroups[0] || []).some(g => excluded.has(String(g.name)));
76
77
  }
77
78
 
78
79
  // ── Scripts Ezoic ──────────────────────────────────────────────────────────
@@ -113,13 +114,34 @@ plugin.injectEzoicHead = async (data) => {
113
114
  const uid = data.req?.uid ?? 0;
114
115
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
115
116
  if (!excluded) {
116
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
117
- data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
117
+ const html = data.templateData.customHTML || '';
118
+ if (!html.includes('data-nbb-ezoic-head="1"')) {
119
+ data.templateData.customHTML = `<meta data-nbb-ezoic-head="1">` + EZOIC_SCRIPTS + html;
120
+ }
118
121
  }
119
122
  } catch (_) {}
120
123
  return data;
121
124
  };
122
125
 
126
+
127
+ function publicConfigPayload(settings, excluded) {
128
+ return {
129
+ excluded,
130
+ enableBetweenAds: settings.enableBetweenAds,
131
+ showFirstTopicAd: settings.showFirstTopicAd,
132
+ placeholderIds: settings.placeholderIds,
133
+ intervalPosts: settings.intervalPosts,
134
+ enableCategoryAds: settings.enableCategoryAds,
135
+ showFirstCategoryAd: settings.showFirstCategoryAd,
136
+ categoryPlaceholderIds: settings.categoryPlaceholderIds,
137
+ intervalCategories: settings.intervalCategories,
138
+ enableMessageAds: settings.enableMessageAds,
139
+ showFirstMessageAd: settings.showFirstMessageAd,
140
+ messagePlaceholderIds: settings.messagePlaceholderIds,
141
+ messageIntervalPosts: settings.messageIntervalPosts,
142
+ };
143
+ }
144
+
123
145
  plugin.init = async ({ router, middleware }) => {
124
146
  async function render(req, res) {
125
147
  const settings = await getSettings();
@@ -139,21 +161,7 @@ plugin.init = async ({ router, middleware }) => {
139
161
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
140
162
  const settings = await getSettings();
141
163
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
142
- res.json({
143
- excluded,
144
- enableBetweenAds: settings.enableBetweenAds,
145
- showFirstTopicAd: settings.showFirstTopicAd,
146
- placeholderIds: settings.placeholderIds,
147
- intervalPosts: settings.intervalPosts,
148
- enableCategoryAds: settings.enableCategoryAds,
149
- showFirstCategoryAd: settings.showFirstCategoryAd,
150
- categoryPlaceholderIds: settings.categoryPlaceholderIds,
151
- intervalCategories: settings.intervalCategories,
152
- enableMessageAds: settings.enableMessageAds,
153
- showFirstMessageAd: settings.showFirstMessageAd,
154
- messagePlaceholderIds: settings.messagePlaceholderIds,
155
- messageIntervalPosts: settings.messageIntervalPosts,
156
- });
164
+ res.json(publicConfigPayload(settings, excluded));
157
165
  });
158
166
  };
159
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.10",
3
+ "version": "1.8.11",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -18,4 +18,4 @@
18
18
  "compatibility": "^4.0.0"
19
19
  },
20
20
  "private": false
21
- }
21
+ }
package/public/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v52
3
3
  *
4
4
  * Historique des corrections majeures
5
5
  * ────────────────────────────────────
@@ -32,6 +32,8 @@
32
32
  *
33
33
  * v34 moveDistantWrap — voir v38.
34
34
  *
35
+ * v52 Clean architecture interne (modules logiques, cache DOM, bridge Ezoic).
36
+ *
35
37
  * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
38
  * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
39
  * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
@@ -81,11 +83,11 @@
81
83
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
84
  const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
83
85
  const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
84
- const MAX_SHOW_BATCH = 3; // ids max par appel showAds(...ids)
86
+ const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
85
87
  const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
86
88
  const SHOW_RELEASE_MS = 300; // relâche inflight après showAds() batché
87
89
  const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
88
- const BATCH_FLUSH_MS = 40; // micro-buffer pour regrouper les ids proches
90
+ const BATCH_FLUSH_MS = 30; // micro-buffer pour regrouper les ids proches
89
91
  const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
90
92
  const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
91
93
  const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
@@ -149,6 +151,9 @@
149
151
  burstDeadline: 0,
150
152
  burstCount: 0,
151
153
  lastBurstTs: 0,
154
+ ioViewportMode: isMobile() ? 'm' : 'd',
155
+ domRev: 0,
156
+ domCache: new Map(),
152
157
  };
153
158
 
154
159
  let blockedUntil = 0;
@@ -157,7 +162,61 @@
157
162
  const isBlocked = () => ts() < blockedUntil;
158
163
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
159
164
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
160
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
165
+ const DEBUG = (() => { try { return localStorage.getItem('nbbEzDebug') === '1'; } catch (_) { return false; } })();
166
+ const dbg = (...a) => { if (DEBUG) { try { console.debug('[nbb-ezoic]', ...a); } catch (_) {} } };
167
+ const isFilled = n => {
168
+ try {
169
+ if (!n?.querySelector) return false;
170
+ if (n.querySelector('iframe, ins, img, video, [data-google-container-id], .ezoic-ad, [id$="__container__"]')) return true;
171
+ const r = n.getBoundingClientRect?.();
172
+ return !!(r && r.height > 8);
173
+ } catch (_) { return false; }
174
+ };
175
+
176
+ const Arch = {
177
+ cache: {
178
+ invalidate(reason) {
179
+ S.domRev++;
180
+ S.domCache.clear();
181
+ dbg('dom-cache.invalidate', reason || '');
182
+ },
183
+ queryAll(key, selector, mapFn) {
184
+ const cacheKey = `${S.domRev}:${key}`;
185
+ if (S.domCache.has(cacheKey)) return S.domCache.get(cacheKey);
186
+ const arr = Array.from(document.querySelectorAll(selector));
187
+ const out = typeof mapFn === 'function' ? arr.filter(Boolean).filter(mapFn) : arr;
188
+ S.domCache.set(cacheKey, out);
189
+ return out;
190
+ },
191
+ get(key, buildFn) {
192
+ const cacheKey = `${S.domRev}:${key}`;
193
+ if (S.domCache.has(cacheKey)) return S.domCache.get(cacheKey);
194
+ const v = buildFn();
195
+ S.domCache.set(cacheKey, v);
196
+ return v;
197
+ },
198
+ },
199
+ ezoic: {
200
+ ensure() {
201
+ window.ezstandalone = window.ezstandalone || {};
202
+ return window.ezstandalone;
203
+ },
204
+ run(fn) {
205
+ const ez = this.ensure();
206
+ try { return (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(fn) : fn(); } catch (_) {}
207
+ },
208
+ call(method, ...args) {
209
+ const ez = this.ensure();
210
+ const fn = ez?.[method];
211
+ if (typeof fn !== 'function') return;
212
+ try { return fn.apply(ez, args); } catch (_) {}
213
+ },
214
+ },
215
+ page: {
216
+ kind() { return getKind(); },
217
+ key() { return pageKey(); },
218
+ },
219
+ };
161
220
 
162
221
  function healFalseEmpty(root = document) {
163
222
  try {
@@ -215,7 +274,8 @@
215
274
 
216
275
  function mutate(fn) {
217
276
  S.mutGuard++;
218
- try { fn(); } finally { S.mutGuard--; }
277
+ try { fn(); }
278
+ finally { S.mutGuard--; Arch.cache.invalidate('mutate'); }
219
279
  }
220
280
  function scheduleDestroyFlush() {
221
281
  if (S.destroyBatchTimer) return;
@@ -235,11 +295,7 @@ function flushDestroyBatch() {
235
295
  ids.push(id);
236
296
  }
237
297
  if (ids.length) {
238
- try {
239
- const ez = window.ezstandalone;
240
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
241
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
242
- } catch (_) {}
298
+ try { Arch.ezoic.run(() => Arch.ezoic.call('destroyPlaceholders', ids)); } catch (_) {}
243
299
  }
244
300
  if (S.destroyPending.length) scheduleDestroyFlush();
245
301
  }
@@ -255,20 +311,28 @@ function destroyEzoicId(id) {
255
311
  scheduleDestroyFlush();
256
312
  }
257
313
 
258
- function destroyBeforeReuse(ids) {
314
+ function queueDestroyIds(ids) {
315
+ for (const raw of (ids || [])) {
316
+ const id = parseInt(raw, 10);
317
+ if (!Number.isFinite(id) || id <= 0) continue;
318
+ if (!S.ezActiveIds.has(id) && !S.ezShownSinceDestroy.has(id)) continue;
319
+ if (S.destroyPendingSet.has(id)) continue;
320
+ S.destroyPending.push(id);
321
+ S.destroyPendingSet.add(id);
322
+ S.ezActiveIds.delete(id);
323
+ S.ezShownSinceDestroy.delete(id);
324
+ }
325
+ if (S.destroyPending.length) scheduleDestroyFlush();
326
+ }
327
+
328
+ function prepareIdsForShow(ids) {
259
329
  const out = [];
260
- const toDestroy = [];
261
330
  const seen = new Set();
262
331
  for (const raw of (ids || [])) {
263
332
  const id = parseInt(raw, 10);
264
333
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
265
334
  seen.add(id);
266
335
  out.push(id);
267
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
268
- }
269
- if (toDestroy.length) {
270
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
271
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
272
336
  }
273
337
  return out;
274
338
  }
@@ -314,6 +378,7 @@ function destroyBeforeReuse(ids) {
314
378
  }
315
379
 
316
380
  function getKind() {
381
+ return Arch.cache.get('kind', () => {
317
382
  const p = location.pathname;
318
383
  if (/^\/topic\//.test(p)) return 'topic';
319
384
  if (/^\/category\//.test(p)) return 'categoryTopics';
@@ -322,12 +387,13 @@ function destroyBeforeReuse(ids) {
322
387
  if (document.querySelector(SEL.post)) return 'topic';
323
388
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
324
389
  return 'other';
390
+ });
325
391
  }
326
392
 
327
393
  // ── Items DOM ──────────────────────────────────────────────────────────────
328
394
 
329
395
  function getPosts() {
330
- return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
396
+ return Arch.cache.queryAll('posts', SEL.post, el => {
331
397
  if (!el.isConnected) return false;
332
398
  if (!el.querySelector('[component="post/content"]')) return false;
333
399
  const p = el.parentElement?.closest(SEL.post);
@@ -336,8 +402,8 @@ function destroyBeforeReuse(ids) {
336
402
  });
337
403
  }
338
404
 
339
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
340
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
405
+ const getTopics = () => Arch.cache.queryAll('topics', SEL.topic);
406
+ const getCategories = () => Arch.cache.queryAll('categories', SEL.category);
341
407
 
342
408
  // ── Wraps — détection ──────────────────────────────────────────────────────
343
409
 
@@ -438,6 +504,7 @@ function destroyBeforeReuse(ids) {
438
504
  S.mountedIds.delete(id);
439
505
  S.pendingSet.delete(id);
440
506
  S.lastShow.delete(id);
507
+ queueDestroyIds([id]);
441
508
  S.ezActiveIds.delete(id);
442
509
  }
443
510
  }
@@ -454,7 +521,7 @@ function destroyBeforeReuse(ids) {
454
521
  * Priorité : wraps vides d'abord, remplis si nécessaire.
455
522
  */
456
523
  function recycleAndMove(klass, targetEl, newKey) {
457
- const ez = window.ezstandalone;
524
+ const ez = Arch.ezoic.ensure();
458
525
  if (typeof ez?.destroyPlaceholders !== 'function' ||
459
526
  typeof ez?.define !== 'function' ||
460
527
  typeof ez?.displayMore !== 'function') return null;
@@ -526,7 +593,7 @@ function recycleAndMove(klass, targetEl, newKey) {
526
593
  };
527
594
  const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
528
595
  const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
529
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
596
+ try { Arch.ezoic.run(doDestroy); } catch (_) {}
530
597
 
531
598
  return { id, wrap: best };
532
599
  }
@@ -566,7 +633,7 @@ function recycleAndMove(klass, targetEl, newKey) {
566
633
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
567
634
  if (ph instanceof Element) S.io?.unobserve(ph);
568
635
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
569
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
636
+ if (Number.isFinite(id)) { queueDestroyIds([id]); S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
570
637
  const key = w.getAttribute(A_ANCHOR);
571
638
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
572
639
  w.remove();
@@ -589,19 +656,18 @@ function recycleAndMove(klass, targetEl, newKey) {
589
656
  // supprimerait les wraps, et provoquerait une réinjection en haut.
590
657
 
591
658
  function pruneOrphansBetween() {
659
+ if (S.burstActive || (S.scrollSpeed || 0) > 1800) return;
592
660
  const klass = 'ezoic-ad-between';
593
661
  const cfg = KIND[klass];
662
+ const liveAnchors = new Set(Arch.cache.queryAll('prune-between-anchors', cfg.sel).map(el => el.getAttribute(cfg.anchorAttr)).filter(Boolean));
594
663
 
595
664
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
596
665
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
597
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
598
-
666
+ if (ts() - created < MIN_PRUNE_AGE_MS) return;
599
667
  const key = w.getAttribute(A_ANCHOR) ?? '';
600
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
668
+ const sid = key.slice(klass.length + 1);
601
669
  if (!sid) { mutate(() => dropWrap(w)); return; }
602
-
603
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
604
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
670
+ if (!liveAnchors.has(sid)) mutate(() => dropWrap(w));
605
671
  });
606
672
  }
607
673
 
@@ -647,6 +713,8 @@ function recycleAndMove(klass, targetEl, newKey) {
647
713
  const w = insertAfter(el, id, klass, key);
648
714
  if (w) { observePh(id); inserted++; }
649
715
  } else {
716
+ // Recyclage agressif = source fréquente de churn visuel (surtout sticky/fixed)
717
+ if (klass === 'ezoic-ad-message') break;
650
718
  const recycled = recycleAndMove(klass, el, key);
651
719
  if (!recycled) break;
652
720
  inserted++;
@@ -657,15 +725,31 @@ function recycleAndMove(klass, targetEl, newKey) {
657
725
 
658
726
  // ── IntersectionObserver & Show ────────────────────────────────────────────
659
727
 
728
+ function ensureIOViewportMode() {
729
+ const mode = isMobile() ? 'm' : 'd';
730
+ if (S.io && S.ioViewportMode !== mode) {
731
+ try { S.io.disconnect(); } catch (_) {}
732
+ S.io = null;
733
+ }
734
+ S.ioViewportMode = mode;
735
+ }
736
+
660
737
  function getIO() {
738
+ ensureIOViewportMode();
661
739
  if (S.io) return S.io;
662
740
  try {
663
741
  S.io = new IntersectionObserver(entries => {
664
742
  for (const e of entries) {
665
743
  if (!e.isIntersecting) continue;
666
- if (e.target instanceof Element) S.io?.unobserve(e.target);
667
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
668
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
744
+ const target = e.target instanceof Element ? e.target : null;
745
+ const id = parseInt(target?.getAttribute('data-ezoic-id'), 10);
746
+ if (!Number.isFinite(id) || id <= 0) continue;
747
+ const accepted = enqueueShow(id);
748
+ if (accepted) {
749
+ try { S.io?.unobserve(target); } catch (_) {}
750
+ } else if (target?.isConnected) {
751
+ setTimeout(() => { try { if (target.isConnected) S.io?.observe(target); } catch (_) {} }, 300);
752
+ }
669
753
  }
670
754
  }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
671
755
  } catch (_) { S.io = null; }
@@ -686,12 +770,15 @@ function recycleAndMove(klass, targetEl, newKey) {
686
770
  }
687
771
 
688
772
  function enqueueShow(id) {
689
- if (!id || isBlocked()) return;
773
+ if (!id || isBlocked()) return false;
690
774
  const n = parseInt(id, 10);
691
- if (!Number.isFinite(n) || n <= 0) return;
692
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
775
+ if (!Number.isFinite(n) || n <= 0) return false;
776
+ if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
777
+ const ph = phEl(n);
778
+ if (!ph?.isConnected || isFilled(ph) || !hasSinglePlaceholder(n)) return false;
693
779
  if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
694
780
  scheduleDrainQueue();
781
+ return true;
695
782
  }
696
783
 
697
784
  function scheduleDrainQueue() {
@@ -757,19 +844,18 @@ function startShowBatch(ids) {
757
844
 
758
845
  if (!valid.length) { clearTimeout(timer); return release(); }
759
846
 
760
- window.ezstandalone = window.ezstandalone || {};
761
- const ez = window.ezstandalone;
847
+ const ez = Arch.ezoic.ensure();
762
848
  const doShow = () => {
763
- const prepared = destroyBeforeReuse(valid);
849
+ const prepared = prepareIdsForShow(valid);
764
850
  if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
765
- try { ez.showAds(...prepared); } catch (_) {}
851
+ try { ez.showAds(...prepared); } catch (_) { dbg('showAds failed', prepared); }
766
852
  for (const id of prepared) {
767
853
  S.ezActiveIds.add(id);
768
854
  S.ezShownSinceDestroy.add(id);
769
855
  }
770
856
  setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
771
857
  };
772
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
858
+ Arch.ezoic.run(doShow);
773
859
  } catch (_) { clearTimeout(timer); release(); }
774
860
  });
775
861
  }
@@ -784,8 +870,7 @@ function startShowBatch(ids) {
784
870
  function patchShowAds() {
785
871
  const apply = () => {
786
872
  try {
787
- window.ezstandalone = window.ezstandalone || {};
788
- const ez = window.ezstandalone;
873
+ const ez = Arch.ezoic.ensure();
789
874
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
790
875
  window.__nbbEzPatched = true;
791
876
  const orig = ez.showAds.bind(ez);
@@ -812,8 +897,7 @@ function startShowBatch(ids) {
812
897
  };
813
898
  apply();
814
899
  if (!window.__nbbEzPatched) {
815
- window.ezstandalone = window.ezstandalone || {};
816
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
900
+ Arch.ezoic.run(apply);
817
901
  }
818
902
  }
819
903
 
@@ -922,6 +1006,7 @@ function startShowBatch(ids) {
922
1006
  S.scrollSpeed = 0;
923
1007
  S.lastScrollY = 0;
924
1008
  S.lastScrollTs = 0;
1009
+ Arch.cache.invalidate('cleanup');
925
1010
  }
926
1011
 
927
1012
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -939,14 +1024,14 @@ function startShowBatch(ids) {
939
1024
  sawWrapRemoval = true;
940
1025
  }
941
1026
  }
942
- if (sawWrapRemoval) queueSweepDeadWraps();
1027
+ if (sawWrapRemoval) { Arch.cache.invalidate('wrap-removed'); queueSweepDeadWraps(); }
943
1028
  for (const n of m.addedNodes) {
944
1029
  if (n.nodeType !== 1) continue;
945
1030
  try { healFalseEmpty(n); } catch (_) {}
946
1031
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
947
1032
  if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
948
1033
  allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
949
- requestBurst(); return;
1034
+ Arch.cache.invalidate('dom-added'); requestBurst(); return;
950
1035
  }
951
1036
  }
952
1037
  }
@@ -1025,7 +1110,8 @@ function startShowBatch(ids) {
1025
1110
  $(window).off('.nbbEzoic');
1026
1111
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1027
1112
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1028
- S.pageKey = pageKey();
1113
+ S.pageKey = Arch.page.key();
1114
+ Arch.cache.invalidate('ajaxify.end');
1029
1115
  blockedUntil = 0;
1030
1116
  muteConsole(); ensureTcfLocator(); warmNetwork();
1031
1117
  patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
@@ -1048,6 +1134,17 @@ function startShowBatch(ids) {
1048
1134
  } catch (_) {}
1049
1135
  }
1050
1136
 
1137
+ function bindResize() {
1138
+ let rT = 0;
1139
+ window.addEventListener('resize', () => {
1140
+ if (rT) clearTimeout(rT);
1141
+ rT = setTimeout(() => {
1142
+ ensureIOViewportMode();
1143
+ requestBurst();
1144
+ }, 120);
1145
+ }, { passive: true });
1146
+ }
1147
+
1051
1148
  function bindScroll() {
1052
1149
  let ticking = false;
1053
1150
  try {
@@ -1074,16 +1171,29 @@ function startShowBatch(ids) {
1074
1171
 
1075
1172
  // ── Boot ───────────────────────────────────────────────────────────────────
1076
1173
 
1077
- S.pageKey = pageKey();
1078
- muteConsole();
1079
- ensureTcfLocator();
1080
- warmNetwork();
1081
- patchShowAds();
1082
- getIO();
1083
- ensureDomObserver();
1084
- bindNodeBB();
1085
- bindScroll();
1086
- blockedUntil = 0;
1087
- requestBurst();
1174
+ const App = {
1175
+ modules: {
1176
+ infra: { muteConsole, ensureTcfLocator, warmNetwork, patchShowAds },
1177
+ lifecycle: { ensureDomObserver, bindNodeBB, cleanup },
1178
+ viewport: { getIO, bindResize, bindScroll },
1179
+ scheduler: { requestBurst },
1180
+ },
1181
+ boot() {
1182
+ this.modules.infra.muteConsole();
1183
+ this.modules.infra.ensureTcfLocator();
1184
+ this.modules.infra.warmNetwork();
1185
+ this.modules.infra.patchShowAds();
1186
+ this.modules.viewport.getIO();
1187
+ this.modules.lifecycle.ensureDomObserver();
1188
+ this.modules.lifecycle.bindNodeBB();
1189
+ this.modules.viewport.bindResize();
1190
+ this.modules.viewport.bindScroll();
1191
+ blockedUntil = 0;
1192
+ this.modules.scheduler.requestBurst();
1193
+ },
1194
+ };
1195
+
1196
+ S.pageKey = Arch.page.key();
1197
+ App.boot();
1088
1198
 
1089
1199
  })();
package/public/style.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * NodeBB Ezoic Infinite Ads — style.css (v20)
2
+ * NodeBB Ezoic Infinite Ads — style.css (v21)
3
3
  */
4
4
 
5
5
  /* ── Wrapper ──────────────────────────────────────────────────────────────── */
@@ -8,8 +8,9 @@
8
8
  width: 100%;
9
9
  margin: 0 !important;
10
10
  padding: 0 !important;
11
- overflow: hidden;
12
- contain: layout style;
11
+ overflow: visible;
12
+ contain: none;
13
+ position: relative;
13
14
  }
14
15
 
15
16
  /* Placeholder : 1px minimum pour rester visible par l'IntersectionObserver */
@@ -50,11 +51,7 @@
50
51
  position: absolute !important;
51
52
  }
52
53
 
53
- /* Neutralise sticky dans nos wraps (évite l'effet "gliding") */
54
- .nodebb-ezoic-wrap .ezads-sticky-intradiv {
55
- position: static !important;
56
- top: auto !important;
57
- }
54
+ /* Compat sticky/fixed Ezoic : ne pas neutraliser globalement ici. */
58
55
 
59
56
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
60
57
  .ezoic-ad {