nodebb-plugin-ezoic-infinite 1.4.7 → 1.4.9

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
@@ -27,13 +27,21 @@ async function getAllGroups() {
27
27
  }
28
28
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
29
  const data = await groups.getGroupsData(filtered);
30
- // Sort alphabetically for ACP usability
31
- data.sort((a, b) => String(a.name).localeCompare(String(b.name), 'fr', { sensitivity: 'base' }));
32
- return data;
30
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
+ const valid = data.filter(g => g && g.name);
32
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
+ return valid;
33
34
  }
35
+ let _settingsCache = null;
36
+ let _settingsCacheAt = 0;
37
+ const SETTINGS_TTL = 30000; // 30s
38
+
34
39
  async function getSettings() {
40
+ const now = Date.now();
41
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
35
42
  const s = await meta.settings.get(SETTINGS_KEY);
36
- return {
43
+ _settingsCacheAt = Date.now();
44
+ _settingsCache = {
37
45
  // Between-post ads (simple blocks) in category topic list
38
46
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
39
47
  showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
@@ -54,6 +62,7 @@ async function getSettings() {
54
62
 
55
63
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
56
64
  };
65
+ return _settingsCache;
57
66
  }
58
67
 
59
68
  async function isUserExcluded(uid, excludedGroups) {
@@ -62,6 +71,13 @@ async function isUserExcluded(uid, excludedGroups) {
62
71
  return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
63
72
  }
64
73
 
74
+ plugin.onSettingsSet = function (data) {
75
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
76
+ if (data && data.hash === SETTINGS_KEY) {
77
+ _settingsCache = null;
78
+ }
79
+ };
80
+
65
81
  plugin.addAdminNavigation = async (header) => {
66
82
  header.plugins = header.plugins || [];
67
83
  header.plugins.push({
@@ -89,7 +105,7 @@ plugin.init = async ({ router, middleware }) => {
89
105
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
90
106
  router.get('/api/admin/plugins/ezoic-infinite', render);
91
107
 
92
- router.get('/api/plugins/ezoic-infinite/config', middleware.buildHeader, async (req, res) => {
108
+ router.get('/api/plugins/ezoic-infinite/config', middleware.authenticate, async (req, res) => {
93
109
  const settings = await getSettings();
94
110
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
95
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "hook": "filter:admin.header.build",
13
13
  "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "action:settings.set",
17
+ "method": "onSettingsSet"
14
18
  }
15
19
  ],
16
20
  "staticDirs": {
package/public/admin.js CHANGED
@@ -13,11 +13,10 @@
13
13
  e.preventDefault();
14
14
 
15
15
  Settings.save('ezoic-infinite', $form, function () {
16
- // Toast vert (NodeBB core)
17
16
  if (alerts && typeof alerts.success === 'function') {
18
- alerts.success('Enregistré');
17
+ alerts.success('[[admin/settings:saved]]');
19
18
  } else if (window.app && typeof window.app.alertSuccess === 'function') {
20
- window.app.alertSuccess('Enregistré');
19
+ window.app.alertSuccess('[[admin/settings:saved]]');
21
20
  }
22
21
  });
23
22
  });
package/public/client.js CHANGED
@@ -31,7 +31,6 @@
31
31
  liveBetween: [],
32
32
  liveMessage: [],
33
33
  liveCategory: [],
34
- usedCategories: new Set(),
35
34
 
36
35
  lastShowById: new Map(),
37
36
  pendingById: new Set(),
@@ -48,6 +47,8 @@
48
47
 
49
48
  obs: null,
50
49
  attempts: 0,
50
+ poolWaitAttempts: 0,
51
+ __scrollBound: false,
51
52
  };
52
53
 
53
54
  function normalizeBool(v) {
@@ -143,18 +144,9 @@
143
144
  else window.ezstandalone.cmd.push(call);
144
145
  } catch (e) {}
145
146
  }
146
- } catch (e) {}
147
- };
148
- try {
149
- window.ezstandalone = window.ezstandalone || {};
150
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
151
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
152
- else window.ezstandalone.cmd.push(call);
153
- } catch (e) {}
154
- }
155
147
 
156
148
  function getRecyclable(liveArr) {
157
- const margin = 1800;
149
+ const margin = 600;
158
150
  for (let i = 0; i < liveArr.length; i++) {
159
151
  const entry = liveArr[i];
160
152
  if (!entry || !entry.wrap || !entry.wrap.isConnected) { liveArr.splice(i, 1); i--; continue; }
@@ -167,17 +159,6 @@
167
159
  return null;
168
160
  }
169
161
 
170
- function moveWrapAfter(wrap, target, kindClass, afterPos) {
171
- try {
172
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
173
- wrap.setAttribute('data-ezoic-after', String(afterPos));
174
- target.insertAdjacentElement('afterend', wrap);
175
- return true;
176
- } catch (e) {
177
- return false;
178
- }
179
- }
180
-
181
162
  function pickId(pool, liveArr) {
182
163
  if (pool.length) return { id: pool.shift(), recycled: null };
183
164
  const recycled = getRecyclable(liveArr);
@@ -189,6 +170,8 @@
189
170
  function resetPlaceholderInWrap(wrap, id) {
190
171
  try {
191
172
  if (!wrap) return;
173
+ try { wrap.removeAttribute('data-ezoic-filled'); } catch (e) {}
174
+ try { if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; } } catch (e) {}
192
175
  const old = wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
193
176
  if (old) old.remove();
194
177
  // Remove any leftover markup inside wrapper
@@ -234,6 +217,7 @@
234
217
  if (findWrap(kindClass, afterPos)) return null;
235
218
  const wrap = buildWrap(id, kindClass, afterPos);
236
219
  target.insertAdjacentElement('afterend', wrap);
220
+ attachFillObserver(wrap, id);
237
221
  return wrap;
238
222
  }
239
223
 
@@ -242,30 +226,20 @@
242
226
  try {
243
227
  state.usedTopics.forEach((id) => ids.push(id));
244
228
  state.usedPosts.forEach((id) => ids.push(id));
245
- state.usedCategories && state.usedCategories.forEach((id) => ids.push(id));
229
+ state.usedCategories.forEach((id) => ids.push(id));
246
230
  } catch (e) {}
247
231
  destroyPlaceholderIds(ids);
248
232
  }
249
- } catch (e) {}
250
- };
251
-
252
- try {
253
- window.ezstandalone = window.ezstandalone || {};
254
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
255
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
256
- else window.ezstandalone.cmd.push(call);
257
- } catch (e) {}
258
- }
259
233
 
260
234
  function patchShowAds() {
261
235
  // Minimal safety net: batch showAds can be triggered by other scripts; split into individual calls.
262
236
  try {
263
237
  window.ezstandalone = window.ezstandalone || {};
264
238
  const ez = window.ezstandalone;
265
- if (ez.__nodebbEzoicPatched) return;
239
+ if (window.__nodebbEzoicPatched) return;
266
240
  if (typeof ez.showAds !== 'function') return;
267
241
 
268
- ez.__nodebbEzoicPatched = true;
242
+ window.__nodebbEzoicPatched = true;
269
243
  const orig = ez.showAds;
270
244
 
271
245
  ez.showAds = function (arg) {
@@ -285,14 +259,54 @@
285
259
  }
286
260
 
287
261
 
262
+
263
+ function markFilled(wrap) {
264
+ try {
265
+ if (!wrap) return;
266
+ // Disconnect the fill observer first (no need to remove+re-add the attribute)
267
+ try { if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; } } catch (e) {}
268
+ wrap.setAttribute('data-ezoic-filled', '1');
269
+ } catch (e) {}
270
+ }
271
+
272
+ function isWrapMarkedFilled(wrap) {
273
+ try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
274
+ }
275
+
276
+ function attachFillObserver(wrap, id) {
277
+ try {
278
+ const ph = wrap && wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
279
+ if (!ph) return;
280
+ // Already filled?
281
+ if (ph.childNodes && ph.childNodes.length > 0) {
282
+ markFilled(wrap);
283
+ state.definedIds && state.definedIds.add(id);
284
+ return;
285
+ }
286
+ const obs = new MutationObserver(() => {
287
+ if (ph.childNodes && ph.childNodes.length > 0) {
288
+ markFilled(wrap);
289
+ try { state.definedIds && state.definedIds.add(id); } catch (e) {}
290
+ try { obs.disconnect(); } catch (e) {}
291
+ }
292
+ });
293
+ obs.observe(ph, { childList: true, subtree: true });
294
+ // Keep a weak reference on the wrapper so we can disconnect on recycle/remove
295
+ wrap.__ezoicFillObs = obs;
296
+ } catch (e) {}
297
+ }
298
+
288
299
  function isPlaceholderFilled(id) {
289
300
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
290
301
  if (!ph || !ph.isConnected) return false;
291
302
 
292
- // Ezoic typically injects content inside the placeholder node.
303
+ const wrap = ph.parentElement;
304
+ if (wrap && isWrapMarkedFilled(wrap)) return true;
305
+
293
306
  const filled = !!(ph.childNodes && ph.childNodes.length > 0);
294
307
  if (filled) {
295
308
  try { state.definedIds && state.definedIds.add(id); } catch (e) {}
309
+ try { markFilled(wrap); } catch (e) {}
296
310
  }
297
311
  return filled;
298
312
  }
@@ -319,24 +333,19 @@
319
333
  const id = state.retryQueue.shift();
320
334
  if (!id) {
321
335
  state.retryQueueRunning = false;
322
- state.badIds = new Set();
323
- state.definedIds = new Set();
324
336
  return;
325
337
  }
326
338
  state.retryQueueSet.delete(id);
327
- // If this id was previously attempted and still empty, force a full reset before re-requesting.
328
339
  const attempts = (state.retryById.get(id) || 0);
329
340
  const phNow = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
330
341
  const wrapNow = phNow && phNow.parentElement;
331
- // If Ezoic already defined this id earlier but the placeholder is empty now, we must destroy+reset before re-showAds.
332
- if (wrapNow && wrapNow.isConnected && state.definedIds && state.definedIds.has(id) && !isPlaceholderFilled(id)) {
333
- destroyPlaceholderIds([id]);
334
- resetPlaceholderInWrap(wrapNow, id);
335
- }
336
- // If this id was previously attempted and still empty, force a full reset before re-requesting.
337
- if (attempts > 0 && wrapNow && wrapNow.isConnected && !isPlaceholderFilled(id)) {
342
+ // Reset before re-requesting if Ezoic already defined this id but placeholder is now empty,
343
+ // OR if a previous attempt already failed.
344
+ if (wrapNow && wrapNow.isConnected && !isPlaceholderFilled(id) && !isWrapMarkedFilled(wrapNow) &&
345
+ (state.definedIds.has(id) || attempts > 0)) {
338
346
  destroyPlaceholderIds([id]);
339
347
  resetPlaceholderInWrap(wrapNow, id);
348
+ attachFillObserver(wrapNow, id);
340
349
  }
341
350
  callShowAdsWhenReady(id);
342
351
  setTimeout(step, 1100);
@@ -360,6 +369,11 @@
360
369
  state.retryById.delete(id);
361
370
  continue;
362
371
  }
372
+ // If wrapper was marked filled, don't try to refill even if placeholder temporarily appears empty.
373
+ if (isWrapMarkedFilled(wrap)) {
374
+ state.retryById.delete(id);
375
+ continue;
376
+ }
363
377
 
364
378
  const tries = (state.retryById.get(id) || 0);
365
379
  if (tries >= 8) { state.badIds && state.badIds.add(id); continue; }
@@ -396,8 +410,11 @@
396
410
  return false;
397
411
  };
398
412
 
413
+ const startPageKey = state.pageKey;
399
414
  let attempts = 0;
400
415
  (function waitForPh() {
416
+ // Abort if the user navigated away since this showAds was scheduled
417
+ if (state.pageKey !== startPageKey) return;
401
418
  attempts += 1;
402
419
  const el = document.getElementById(phId);
403
420
  if (el && el.isConnected) {
@@ -420,6 +437,7 @@
420
437
 
421
438
  let tries = 0;
422
439
  (function tick() {
440
+ if (state.pageKey !== startPageKey) { state.pendingById.delete(id); return; }
423
441
  tries += 1;
424
442
  if (doCall() || tries >= 5) {
425
443
  if (tries >= 5) state.pendingById.delete(id);
@@ -434,30 +452,28 @@
434
452
  })();
435
453
  }
436
454
 
437
- function nextId(pool) {
438
- // backward compatible: the injector passes the pool array
439
- if (Array.isArray(pool) && pool.length) return pool.shift();
440
- return null;
441
- }
442
-
443
455
  async function fetchConfig() {
444
456
  if (state.cfg) return state.cfg;
445
457
  if (state.cfgPromise) return state.cfgPromise;
446
458
 
447
459
  state.cfgPromise = (async () => {
448
- try {
449
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
450
- if (!res.ok) return null;
451
- state.cfg = await res.json();
452
- return state.cfg;
453
- } catch (e) {
454
- return null;
455
- } finally {
456
- state.cfgPromise = null;
460
+ const MAX_TRIES = 3;
461
+ let delay = 800;
462
+ for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
463
+ try {
464
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
465
+ if (res.ok) {
466
+ state.cfg = await res.json();
467
+ return state.cfg;
468
+ }
469
+ } catch (e) {}
470
+ if (attempt < MAX_TRIES) await new Promise(r => setTimeout(r, delay));
471
+ delay *= 2;
457
472
  }
473
+ return null;
458
474
  })();
459
475
 
460
- return state.cfgPromise;
476
+ try { return await state.cfgPromise; } finally { state.cfgPromise = null; }
461
477
  }
462
478
 
463
479
  function initPools(cfg) {
@@ -512,9 +528,12 @@
512
528
  if (state.definedIds && state.definedIds.has(id)) {
513
529
  destroyPlaceholderIds([id]);
514
530
  }
515
- wrap = pick.recycled.wrap;
516
- if (!moveWrapAfter(wrap, el, kindClass, afterPos)) continue;
517
- resetPlaceholderInWrap(wrap, id);
531
+ // Remove the old wrapper entirely, then create a fresh wrapper at the new position (same id)
532
+ const oldWrap = pick.recycled.wrap;
533
+ try { if (oldWrap && oldWrap.__ezoicFillObs) { oldWrap.__ezoicFillObs.disconnect(); } } catch (e) {}
534
+ try { oldWrap && oldWrap.remove(); } catch (e) {}
535
+ wrap = insertAfter(el, id, kindClass, afterPos);
536
+ if (!wrap) continue;
518
537
  setTimeout(() => { enqueueRetry(id); }, 450);
519
538
  } else {
520
539
  usedSet.add(id);
@@ -524,14 +543,16 @@
524
543
 
525
544
  liveArr.push({ id, wrap });
526
545
  // If adjacency ended up happening (e.g. DOM shifts), rollback this placement.
527
- if (wrap && (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))) {
546
+ if (wrap && (
547
+ (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) ||
548
+ (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))
549
+ )) {
528
550
  try { wrap.remove(); } catch (e) {}
529
551
  // Put id back if it was newly consumed (not recycled)
530
552
  if (!(pick.recycled && pick.recycled.wrap)) {
531
553
  try { kindPool.unshift(id); } catch (e) {}
532
554
  try { usedSet.delete(id); } catch (e) {}
533
555
  }
534
- inserted -= 0; // no-op
535
556
  continue;
536
557
  }
537
558
  if (!(pick.recycled && pick.recycled.wrap)) {
@@ -562,19 +583,24 @@
562
583
  state.poolTopics = [];
563
584
  state.poolPosts = [];
564
585
  state.poolCategories = [];
565
- state.poolCategories = [];
566
586
  state.usedTopics.clear();
567
587
  state.usedPosts.clear();
568
- state.usedCategories && state.usedCategories.clear();
588
+ state.usedCategories.clear();
569
589
  state.liveBetween = [];
570
590
  state.liveMessage = [];
571
591
  state.liveCategory = [];
572
- state.usedCategories.clear();
573
592
 
574
593
  state.lastShowById = new Map();
575
594
  state.pendingById = new Set();
595
+ state.retryById = new Map();
596
+ state.retryQueue = [];
597
+ state.retryQueueSet = new Set();
598
+ state.retryQueueRunning = false;
599
+ state.badIds = new Set();
600
+ state.definedIds = new Set();
576
601
 
577
602
  state.attempts = 0;
603
+ state.poolWaitAttempts = 0;
578
604
 
579
605
  document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
580
606
 
@@ -582,13 +608,14 @@
582
608
  state.scheduled = false;
583
609
  clearTimeout(state.timer);
584
610
  state.timer = null;
611
+ clearTimeout(state.retryTimer);
612
+ state.retryTimer = null;
585
613
  }
586
614
 
587
615
  function ensureObserver() {
588
616
  if (state.obs) return;
589
617
  state.obs = new MutationObserver(() => scheduleRun('mutation'));
590
618
  try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
591
- setTimeout(() => { if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; } }, 15000);
592
619
  }
593
620
 
594
621
  async function runCore() {
@@ -607,7 +634,7 @@
607
634
  inserted = injectBetween('ezoic-ad-message', getPostContainers(),
608
635
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
609
636
  normalizeBool(cfg.showFirstMessageAd),
610
- 'message',
637
+ state.poolPosts,
611
638
  state.usedPosts);
612
639
  }
613
640
  } else if (kind === 'categoryTopics') {
@@ -615,7 +642,7 @@
615
642
  inserted = injectBetween('ezoic-ad-between', getTopicItems(),
616
643
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
617
644
  normalizeBool(cfg.showFirstTopicAd),
618
- 'between',
645
+ state.poolTopics,
619
646
  state.usedTopics);
620
647
  }
621
648
  } else if (kind === 'categories') {
@@ -623,7 +650,7 @@
623
650
  inserted = injectBetween('ezoic-ad-categories', getCategoryItems(),
624
651
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
625
652
  normalizeBool(cfg.showFirstCategoryAd),
626
- 'categories',
653
+ state.poolCategories,
627
654
  state.usedCategories);
628
655
  }
629
656
  }
@@ -643,7 +670,22 @@
643
670
  return;
644
671
  }
645
672
 
646
- if (inserted >= MAX_INSERTS_PER_RUN) setTimeout(() => scheduleRun('continue'), 140);
673
+ if (inserted >= MAX_INSERTS_PER_RUN) {
674
+ // Plus d'insertions possibles ce cycle, continuer immédiatement
675
+ setTimeout(() => scheduleRun('continue'), 140);
676
+ } else if (inserted === 0 && count > 0) {
677
+ // Pool épuisé ou recyclage pas encore disponible.
678
+ // Réessayer jusqu'à 8 fois (toutes les 400ms) pour laisser aux anciens wrappers
679
+ // le temps de défiler hors écran et devenir recyclables.
680
+ if (state.poolWaitAttempts < 8) {
681
+ state.poolWaitAttempts += 1;
682
+ setTimeout(() => scheduleRun('pool-wait'), 400);
683
+ } else {
684
+ state.poolWaitAttempts = 0;
685
+ }
686
+ } else if (inserted > 0) {
687
+ state.poolWaitAttempts = 0;
688
+ }
647
689
  }
648
690
 
649
691
  function scheduleRun() {
@@ -699,13 +741,6 @@
699
741
  });
700
742
  }
701
743
 
702
- cleanup();
703
- bind();
704
- ensureObserver();
705
- state.pageKey = getPageKey();
706
- scheduleRun();
707
- setTimeout(scheduleRun, 250);
708
- })()
709
744
  function bindScroll() {
710
745
  if (state.__scrollBound) return;
711
746
  state.__scrollBound = true;
@@ -715,10 +750,21 @@
715
750
  ticking = true;
716
751
  window.requestAnimationFrame(() => {
717
752
  ticking = false;
753
+ // Réinitialiser le compteur d'attente de recyclage à chaque reprise de scroll
754
+ state.poolWaitAttempts = 0;
718
755
  enforceNoAdjacentAds();
719
756
  scheduleRefill(200);
757
+ // Tenter aussi d'insérer de nouvelles pubs si de nouveaux items sont apparus
758
+ scheduleRun();
720
759
  });
721
760
  }, { passive: true });
722
761
  }
723
762
 
724
- ;
763
+ cleanup();
764
+ bind();
765
+ bindScroll();
766
+ ensureObserver();
767
+ state.pageKey = getPageKey();
768
+ scheduleRun();
769
+ setTimeout(scheduleRun, 250);
770
+ })();