nodebb-plugin-ezoic-infinite 1.7.45 → 1.7.47

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 (3) hide show
  1. package/library.js +29 -13
  2. package/package.json +1 -1
  3. package/public/client.js +71 -101
package/library.js CHANGED
@@ -15,9 +15,11 @@ function normalizeExcludedGroups(value) {
15
15
  // NodeBB stocke les settings multi-valeurs comme string JSON "[\"group1\",\"group2\"]"
16
16
  const s = String(value).trim();
17
17
  if (s.startsWith('[')) {
18
- try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
18
+ try {
19
+ const parsed = JSON.parse(s);
20
+ if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
21
+ } catch (_) {}
19
22
  }
20
- // Fallback : séparation par virgule
21
23
  return s.split(',').map(v => v.trim()).filter(Boolean);
22
24
  }
23
25
 
@@ -28,7 +30,15 @@ function parseBool(v, def = false) {
28
30
  return s === '1' || s === 'true' || s === 'on' || s === 'yes';
29
31
  }
30
32
 
33
+ // ── Cache groupes (fix #7 : getAllGroups sans cache sur page ACP) ───────────
34
+
35
+ let _groupsCache = null;
36
+ let _groupsCacheAt = 0;
37
+ const GROUPS_TTL = 60_000; // 1 minute
38
+
31
39
  async function getAllGroups() {
40
+ const now = Date.now();
41
+ if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
32
42
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
33
43
  if (!names || !names.length) {
34
44
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
@@ -37,7 +47,9 @@ async function getAllGroups() {
37
47
  const data = await groups.getGroupsData(filtered);
38
48
  const valid = data.filter(g => g && g.name);
39
49
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
40
- return valid;
50
+ _groupsCache = valid;
51
+ _groupsCacheAt = now;
52
+ return _groupsCache;
41
53
  }
42
54
 
43
55
  // ── Settings cache (30s TTL) ────────────────────────────────────────────────
@@ -88,7 +100,10 @@ ezstandalone.cmd = ezstandalone.cmd || [];
88
100
  // ── Hooks ──────────────────────────────────────────────────────────────────
89
101
 
90
102
  plugin.onSettingsSet = function (data) {
91
- if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
103
+ if (data && data.hash === SETTINGS_KEY) {
104
+ _settingsCache = null;
105
+ _groupsCache = null; // invalider aussi le cache groupes
106
+ }
92
107
  };
93
108
 
94
109
  plugin.addAdminNavigation = async (header) => {
@@ -101,11 +116,11 @@ plugin.addAdminNavigation = async (header) => {
101
116
  * Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
102
117
  *
103
118
  * NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
104
- * (render.js ligne 232 : templateValues.customHTML = meta.config.customHTML).
105
- * Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
106
- * et est rendu via req.app.renderAsync('header', hookReturn.templateData).
107
- * On préfixe customHTML pour que nos scripts passent AVANT le customHTML admin,
108
- * tout en préservant ce dernier.
119
+ * (render.js : templateValues.customHTML = meta.config.customHTML).
120
+ * Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData.
121
+ * On préfixe customHTML pour passer AVANT le customHTML admin tout en le préservant.
122
+ *
123
+ * Fix #3 : erreurs loggées côté serveur plutôt qu'avalées silencieusement.
109
124
  */
110
125
  plugin.injectEzoicHead = async (data) => {
111
126
  try {
@@ -113,17 +128,18 @@ plugin.injectEzoicHead = async (data) => {
113
128
  const uid = data.req?.uid ?? 0;
114
129
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
115
130
  if (!excluded) {
116
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
117
131
  data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
118
132
  }
119
- } catch (_) {}
133
+ } catch (err) {
134
+ // Log l'erreur mais ne pas planter le rendu de la page
135
+ console.error('[ezoic-infinite] injectEzoicHead error:', err.message || err);
136
+ }
120
137
  return data;
121
138
  };
122
139
 
123
140
  plugin.init = async ({ router, middleware }) => {
124
141
  async function render(req, res) {
125
- const settings = await getSettings();
126
- const allGroups = await getAllGroups();
142
+ const [settings, allGroups] = await Promise.all([getSettings(), getAllGroups()]);
127
143
  res.render('admin/plugins/ezoic-infinite', {
128
144
  title: 'Ezoic Infinite Ads',
129
145
  ...settings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.45",
3
+ "version": "1.7.47",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,69 +1,30 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v51
3
3
  *
4
4
  * Historique des corrections majeures
5
5
  * ────────────────────────────────────
6
6
  * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
7
- *
8
- * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
- * la position dans le batch courant.
10
- *
7
+ * v19 Intervalle global basé sur l'ordinal absolu (data-index).
11
8
  * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
12
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
13
- * IO fixe (une instance, jamais recréée).
14
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
15
- *
16
- * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
17
- *
18
- * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
19
- *
20
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
21
- *
9
+ * IO fixe (une instance, jamais recréée). Fix TCF locator.
10
+ * v25 Fix scroll-up / virtualisation NodeBB.
11
+ * v26 Suppression recyclage d'id (causait réinjection en haut).
12
+ * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation posts).
22
13
  * v28 decluster supprimé. Wraps persistants pendant la session.
23
- *
24
- * v32 Retour anchorAttr = data-index pour ezoic-ad-between.
25
- * data-tid peut être absent clés invalides wraps empilés.
26
- * pruneOrphansBetween réactivé uniquement pour topics de catégorie :
27
- * NodeBB NE virtualise PAS les topics dans une liste de catégorie,
28
- * les ancres (data-index) restent en DOM prune safe et nécessaire
29
- * pour éviter l'empilement après scroll long.
30
- * Toujours désactivé pour les posts : NodeBB virtualise les posts
31
- * hors-viewport faux-orphelins bug réinjection en haut.
32
- *
33
- * v34 moveDistantWrap voir v38.
34
- *
35
- * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
- * après login filter:middleware.renderHeader re-évalue l'exclusion au
37
- * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
38
- *
39
- * v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
40
- *
41
- * v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
42
- *
43
- * v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
44
- *
45
- * v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
46
- * Séquence : destroy → 300ms → define → 300ms → displayMore.
47
- * Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
48
- *
49
- * v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
50
- * sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
51
- * déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
52
- * break propre dans injectBetween. muteConsole : ajout warnings refresh.
53
- *
54
- * v36 Optimisations chemin critique (scroll → injectBetween) :
55
- * – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
56
- * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
57
- * dropWrap et cleanup.
58
- * – wrapIsLive allégé : pour les voisins immédiats on vérifie les
59
- * attributs du nœud lui-même sans querySelector global.
60
- * – MutationObserver : matches() vérifié avant querySelector() pour
61
- * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
62
- *
63
- * v35 Revue complète prod-ready :
64
- * – initPools protégé contre ré-initialisation inutile (S.poolsReady).
65
- * – muteConsole élargit à "No valid placeholders for loadMore".
66
- * – Commentaires et historique nettoyés.
14
+ * v32 pruneOrphansBetween réactivé uniquement pour topics de catégorie.
15
+ * NodeBB NE virtualise PAS les topics → prune safe.
16
+ * Toujours désactivé pour posts (virtualisationfaux-orphelins).
17
+ * v35 initPools protégé (S.poolsReady). muteConsole élargi.
18
+ * v36 S.wrapByKey Map O(1). wrapIsLive allégé. MutationObserver optimisé.
19
+ * v38 ez.refresh() interdit supprimé. Pool épuisé break propre.
20
+ * v40 Recyclage via destroyPlaceholders+define+displayMore (délais 300ms).
21
+ * v43 Seuil recyclage -1vh + unobserve avant déplacement.
22
+ * v49 Fix normalizeExcludedGroups : JSON.parse du tableau NodeBB.
23
+ * v50 bindLoginCheck() supprimé (NodeBB recharge après login).
24
+ * v51 #2 fetchConfig() : backoff 10s sur échec réseau (évite spam API).
25
+ * #4 recycleAndMove : re-observe le placeholder après displayMore.
26
+ * #5 ensureTcfLocator : MutationObserver global disconnecté au cleanup.
27
+ * #6 getIO() recrée l'observer si le type d'écran change (resize).
67
28
  */
68
29
  (function nbbEzoicInfinite() {
69
30
  'use strict';
@@ -78,7 +39,6 @@
78
39
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
40
 
80
41
  const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
- const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
42
  const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
43
  const MAX_INFLIGHT = 4; // max showAds() simultanés
84
44
  const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
@@ -127,6 +87,8 @@
127
87
  pending: [], // ids en attente de slot inflight
128
88
  pendingSet: new Set(),
129
89
  wrapByKey: new Map(), // anchorKey → wrap DOM node
90
+ recycling: new Set(), // ids en cours de séquence destroy→define→displayMore
91
+ tcfObs: null, // MutationObserver TCF locator
130
92
  runQueued: false,
131
93
  burstActive: false,
132
94
  burstDeadline: 0,
@@ -149,12 +111,17 @@
149
111
 
150
112
  // ── Config ─────────────────────────────────────────────────────────────────
151
113
 
114
+ // Fix #2 : backoff 10s sur échec pour éviter de spammer l'API
115
+ // Fix #3 : _cfgErrorUntil au scope IIFE pour être réinitialisé dans cleanup()
116
+ let _cfgErrorUntil = 0;
152
117
  async function fetchConfig() {
153
118
  if (S.cfg) return S.cfg;
119
+ if (Date.now() < _cfgErrorUntil) return null;
154
120
  try {
155
121
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
156
- if (r.ok) S.cfg = await r.json();
157
- } catch (_) {}
122
+ if (r.ok) { S.cfg = await r.json(); }
123
+ else { _cfgErrorUntil = Date.now() + 10_000; }
124
+ } catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
158
125
  return S.cfg;
159
126
  }
160
127
 
@@ -291,6 +258,9 @@
291
258
  function pickId(poolKey) {
292
259
  const pool = S.pools[poolKey];
293
260
  if (!pool.length) return null;
261
+ // Fix #2 : early-exit si tous les ids sont déjà montés — évite O(n) inutile
262
+ if (S.mountedIds.size >= pool.length &&
263
+ pool.every(id => S.mountedIds.has(id))) return null;
294
264
  for (let t = 0; t < pool.length; t++) {
295
265
  const i = S.cursors[poolKey] % pool.length;
296
266
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
@@ -336,6 +306,9 @@
336
306
  if (!best) return null;
337
307
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
338
308
  if (!Number.isFinite(id)) return null;
309
+ // Fix #1 : éviter de recycler un slot dont la séquence Ezoic est en cours
310
+ if (S.recycling.has(id)) return null;
311
+ S.recycling.add(id);
339
312
 
340
313
  const oldKey = best.getAttribute(A_ANCHOR);
341
314
  // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
@@ -356,7 +329,16 @@
356
329
  // Délais requis : destroyPlaceholders est asynchrone en interne
357
330
  const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
358
331
  const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
359
- const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
332
+ // Fix #4 : re-observer le ph après displayMore pour déclencher scheduleEmptyCheck
333
+ // si la pub ne charge pas (détection wrap vide).
334
+ const doDisplay = () => {
335
+ try { ez.displayMore([id]); } catch (_) {}
336
+ S.recycling.delete(id); // Fix #1 : séquence terminée
337
+ observePh(id);
338
+ const t = ts();
339
+ try { best.setAttribute(A_SHOWN, String(t)); } catch (_) {}
340
+ scheduleEmptyCheck(id, t);
341
+ };
360
342
  try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
361
343
 
362
344
  return { id, wrap: best };
@@ -403,37 +385,6 @@
403
385
  } catch (_) {}
404
386
  }
405
387
 
406
- // ── Prune (topics de catégorie uniquement) ────────────────────────────────
407
- //
408
- // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
409
- //
410
- // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
411
- // les li[component="category/topic"] restent dans le DOM pendant toute
412
- // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
413
- // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
414
- // liste après un long scroll et bloquent les nouvelles injections.
415
- //
416
- // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
417
- // NodeBB virtualise les posts hors-viewport — il les retire puis les
418
- // réinsère. pruneOrphans verrait des ancres temporairement absentes,
419
- // supprimerait les wraps, et provoquerait une réinjection en haut.
420
-
421
- function pruneOrphansBetween() {
422
- const klass = 'ezoic-ad-between';
423
- const cfg = KIND[klass];
424
-
425
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
426
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
427
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
428
-
429
- const key = w.getAttribute(A_ANCHOR) ?? '';
430
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
431
- if (!sid) { mutate(() => dropWrap(w)); return; }
432
-
433
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
434
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
435
- });
436
- }
437
388
 
438
389
  // ── Injection ──────────────────────────────────────────────────────────────
439
390
 
@@ -486,8 +437,15 @@
486
437
 
487
438
  // ── IntersectionObserver & Show ────────────────────────────────────────────
488
439
 
440
+ // Fix #6 : recréer l'observer si le type d'écran change (rotation, resize).
441
+ // isMobile() est évalué à chaque appel pour détecter un changement.
442
+ let _ioMobile = null; // dernier état mobile/desktop pour lequel l'IO a été créé
489
443
  function getIO() {
490
- if (S.io) return S.io;
444
+ const mobile = isMobile();
445
+ if (S.io && _ioMobile === mobile) return S.io;
446
+ // Type d'écran changé ou première création : (re)créer l'observer
447
+ if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
448
+ _ioMobile = mobile;
491
449
  try {
492
450
  S.io = new IntersectionObserver(entries => {
493
451
  for (const e of entries) {
@@ -496,7 +454,7 @@
496
454
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
497
455
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
498
456
  }
499
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
457
+ }, { root: null, rootMargin: mobile ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
500
458
  } catch (_) { S.io = null; }
501
459
  return S.io;
502
460
  }
@@ -633,7 +591,6 @@
633
591
  );
634
592
 
635
593
  if (kind === 'categoryTopics') {
636
- pruneOrphansBetween();
637
594
  return exec(
638
595
  'ezoic-ad-between', getTopics,
639
596
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
@@ -691,6 +648,7 @@
691
648
  blockedUntil = ts() + 1500;
692
649
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
693
650
  S.cfg = null;
651
+ _cfgErrorUntil = 0; // Fix #3 : réinitialiser le backoff entre les pages
694
652
  S.poolsReady = false;
695
653
  S.pools = { topics: [], posts: [], categories: [] };
696
654
  S.cursors = { topics: 0, posts: 0, categories: 0 };
@@ -702,6 +660,9 @@
702
660
  S.pendingSet.clear();
703
661
  S.burstActive = false;
704
662
  S.runQueued = false;
663
+ S.recycling.clear();
664
+ if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
665
+ if (S.tcfObs) { S.tcfObs.disconnect(); S.tcfObs = null; }
705
666
  }
706
667
 
707
668
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -758,9 +719,10 @@
758
719
  (document.body || document.documentElement).appendChild(f);
759
720
  };
760
721
  inject();
761
- if (!window.__nbbTcfObs) {
762
- window.__nbbTcfObs = new MutationObserver(inject);
763
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
722
+ // Fix #5 : ref stockée dans S pour pouvoir déconnecter au cleanup
723
+ if (!S.tcfObs) {
724
+ S.tcfObs = new MutationObserver(inject);
725
+ S.tcfObs.observe(document.documentElement, { childList: true, subtree: true });
764
726
  }
765
727
  } catch (_) {}
766
728
  }
@@ -798,7 +760,9 @@
798
760
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
799
761
  S.pageKey = pageKey();
800
762
  blockedUntil = 0;
801
- muteConsole(); ensureTcfLocator(); warmNetwork();
763
+ // Fix #4 : muteConsole/ensureTcfLocator/warmNetwork déjà appelés au boot
764
+ // et sont idempotents — pas besoin de les rappeler à chaque navigation.
765
+ muteConsole();
802
766
  patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
803
767
  });
804
768
 
@@ -826,6 +790,12 @@
826
790
  ticking = true;
827
791
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
828
792
  }, { passive: true });
793
+ // Fix #6 : détecter rotation/resize pour recréer l'IO avec la bonne marge
794
+ let resizeTimer = 0;
795
+ window.addEventListener('resize', () => {
796
+ clearTimeout(resizeTimer);
797
+ resizeTimer = setTimeout(() => { getIO(); }, 500);
798
+ }, { passive: true });
829
799
  }
830
800
 
831
801
  // ── Boot ───────────────────────────────────────────────────────────────────