nodebb-plugin-ezoic-infinite 1.7.2 → 1.7.4

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 +110 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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
@@ -60,19 +60,25 @@
60
60
  };
61
61
 
62
62
  /**
63
- * Table centrale : kindClass → { selector DOM, attribut d'ancre stable }
63
+ * Table centrale par kindClass :
64
64
  *
65
- * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
- * quelle que soit la page ou la virtualisation :
67
- * posts → data-pid (id du message, unique et permanent)
68
- * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
- * catégories data-cid (id de la catégorie, unique et permanent)
70
- * C'était le bug v19 : on cherchait data-index ici
65
+ * sel : sélecteur CSS complet de l'élément cible
66
+ * baseTag : tag CSS à préfixer dans les querySelector de recherche
67
+ * (déduit manuellement évite le fragile split('[')[0])
68
+ * anchorAttr : attribut DOM STABLE qui identifie l'élément de façon unique
69
+ * et permanente, utilisé comme clé d'ancre du wrap.
70
+ * data-pid pour posts (id message, immuable)
71
+ * → data-index pour topics (position dans la liste)
72
+ * → data-cid pour catégories (id catégorie, immuable)
73
+ * ordinalAttr: attribut utilisé pour calculer l'intervalle d'injection.
74
+ * Doit être un entier 0-based fourni par NodeBB.
75
+ * Pour posts ET topics : data-index (0-based, toujours présent).
76
+ * Pour catégories : pas d'infinite scroll → fallback positionnel.
71
77
  */
72
78
  const KIND = {
73
- 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
- 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
- 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
79
+ 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
80
+ 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
81
+ 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
76
82
  };
77
83
 
78
84
  // ── État ───────────────────────────────────────────────────────────────────
@@ -261,17 +267,45 @@
261
267
  return w;
262
268
  }
263
269
 
270
+ /**
271
+ * Retire proprement un wrap du DOM.
272
+ *
273
+ * Sans précaution, retirer un wrap contenant un player vidéo Ezoic (wyvern.js)
274
+ * déclenche des erreurs async sur des nœuds détachés :
275
+ * "Cannot read properties of null (reading 'paused')"
276
+ * "Cannot read properties of null (reading 'offsetWidth')"
277
+ * "Invalid target for null#trigger / null#on"
278
+ *
279
+ * On pause les media et on tente de notifier l'API wyvern avant remove().
280
+ */
264
281
  function dropWrap(w) {
265
282
  try {
266
- const id = parseInt(w.getAttribute(A_WRAPID), 10);
267
- if (Number.isFinite(id)) S.mountedIds.delete(id);
268
- // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
269
- // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
270
- // "parameter 1 is not of type Element" sur le prochain observe).
283
+ // 1. Pause tous les media actifs avant détachement
284
+ try {
285
+ w.querySelectorAll('video, audio').forEach(m => {
286
+ try { if (!m.paused) m.pause(); } catch (_) {}
287
+ });
288
+ } catch (_) {}
289
+
290
+ // 2. Notifier l'API wyvern si disponible
291
+ try {
292
+ if (window.wyvern && typeof window.wyvern === 'object') {
293
+ w.querySelectorAll('[id^="ezoic-"],[class*="wyvern"],[class*="ezoic-video"]')
294
+ .forEach(n => { try { window.wyvern.destroy?.(n); } catch (_) {} });
295
+ }
296
+ } catch (_) {}
297
+
298
+ // 3. Unobserve (guard instanceof Element — unobserve(null) corrompt l'IO)
271
299
  try {
272
300
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
273
301
  if (ph instanceof Element) S.io?.unobserve(ph);
274
302
  } catch (_) {}
303
+
304
+ // 4. Libérer l'id du pool
305
+ const id = parseInt(w.getAttribute(A_WRAPID), 10);
306
+ if (Number.isFinite(id)) S.mountedIds.delete(id);
307
+
308
+ // 5. Retrait DOM
275
309
  w.remove();
276
310
  } catch (_) {}
277
311
  }
@@ -279,32 +313,33 @@
279
313
  // ── Prune ──────────────────────────────────────────────────────────────────
280
314
 
281
315
  /**
282
- * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
283
- *
284
- * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
285
- * Exemples :
286
- * ezoic-ad-message → cherche [data-pid="123"]
287
- * ezoic-ad-between → cherche [data-index="5"]
288
- * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
316
+ * Supprime les wraps VIDES dont l'élément-ancre n'est plus dans le DOM.
289
317
  *
290
- * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
318
+ * Règles :
319
+ * 1. Jamais avant MIN_PRUNE_AGE_MS (DOM post-batch pas encore stabilisé).
320
+ * 2. Jamais un wrap rempli (player wyvern potentiellement actif).
321
+ * 3. L'ancre est retrouvée via KIND[klass].anchorAttr (stable par design) :
322
+ * ezoic-ad-message → [data-pid="123"]
323
+ * ezoic-ad-between → li[data-index="5"]
324
+ * ezoic-ad-categories → li[data-cid="7"]
291
325
  */
292
326
  function pruneOrphans(klass) {
293
327
  const meta = KIND[klass];
294
328
  if (!meta) return;
295
329
 
296
- const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
297
-
298
330
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
299
331
  if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
332
+ if (isFilled(w)) return; // jamais supprimer un wrap rempli (wyvern)
300
333
 
301
334
  const key = w.getAttribute(A_ANCHOR) ?? '';
302
- const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
335
+ const sid = key.slice(klass.length + 1);
303
336
  if (!sid) { mutate(() => dropWrap(w)); return; }
304
337
 
305
- const anchorEl = document.querySelector(
306
- `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
307
- );
338
+ // Construire le sélecteur avec baseTag explicite depuis KIND
339
+ // baseTag='' pour posts → sélecteur : [data-pid="123"] (correct, sans tag ambigu)
340
+ // baseTag='li' pour topics/categories → li[data-index="5"], li[data-cid="7"]
341
+ const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
342
+ const anchorEl = document.querySelector(sel);
308
343
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
309
344
  });
310
345
  }
@@ -314,11 +349,12 @@
314
349
  /**
315
350
  * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
316
351
  * Priorité : filled > en grâce (fill en cours) > vide.
317
- * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
352
+ *
353
+ * Règle de sécurité wyvern : on ne supprime JAMAIS un wrap rempli.
354
+ * Si les deux wraps adjacents sont remplis, on laisse en place (edge case rare).
318
355
  */
319
356
  function decluster(klass) {
320
357
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
321
- // Grace sur le wrap courant : on le saute entièrement
322
358
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
323
359
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
324
360
 
@@ -327,10 +363,16 @@
327
363
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
328
364
 
329
365
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
330
- if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
366
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break;
331
367
 
332
- if (!isFilled(w)) mutate(() => dropWrap(w));
333
- else if (!isFilled(prev)) mutate(() => dropWrap(prev));
368
+ const wFilled = isFilled(w);
369
+ const pFilled = isFilled(prev);
370
+
371
+ // Ne jamais retirer un wrap rempli (player actif potentiel)
372
+ if (!wFilled && !pFilled) mutate(() => dropWrap(w)); // les deux vides → retirer le courant
373
+ else if (!wFilled && pFilled) mutate(() => dropWrap(w)); // courant vide, précédent rempli → retirer le courant
374
+ else if ( wFilled && !pFilled) mutate(() => dropWrap(prev)); // courant rempli, précédent vide → retirer le précédent
375
+ // les deux remplis → rien (on ne touche pas)
334
376
  break;
335
377
  }
336
378
  }
@@ -339,24 +381,35 @@
339
381
  // ── Injection ──────────────────────────────────────────────────────────────
340
382
 
341
383
  /**
342
- * Ordinal 0-based pour le calcul de l'intervalle.
343
- * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
344
- * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
384
+ * Ordinal 0-based d'un élément pour le calcul de l'intervalle d'injection.
385
+ *
386
+ * Utilise KIND[klass].ordinalAttr en priorité (data-index pour posts et topics,
387
+ * null pour catégories). Si absent, fallback positionnel dans le parent.
388
+ *
389
+ * Pour les posts : KIND.baseTag='' donc le fallback itère sur les enfants directs
390
+ * du parent en filtrant par sélecteur complet (pas juste le tag).
345
391
  */
346
392
  function ordinal(klass, el) {
347
- const di = el.getAttribute('data-index');
348
- if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
349
- // Fallback positionnel
393
+ const meta = KIND[klass];
394
+
395
+ // 1. Attribut ordinal explicite (data-index sur posts et topics)
396
+ if (meta?.ordinalAttr) {
397
+ const v = el.getAttribute(meta.ordinalAttr);
398
+ if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
399
+ }
400
+
401
+ // 2. Fallback positionnel — compte parmi les frères qui matchent le même sélecteur
350
402
  try {
351
- const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
352
- if (tag) {
353
- let i = 0;
354
- for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
355
- if (n === el) return i;
356
- i++;
357
- }
403
+ let i = 0;
404
+ const siblings = el.parentElement?.children ?? [];
405
+ const fullSel = meta?.sel ?? '';
406
+ for (const s of siblings) {
407
+ if (s === el) return i;
408
+ // Compter uniquement les éléments du même type (pas les wraps ou autres)
409
+ if (!fullSel || s.matches?.(fullSel)) i++;
358
410
  }
359
411
  } catch (_) {}
412
+
360
413
  return 0;
361
414
  }
362
415
 
@@ -592,6 +645,16 @@
592
645
 
593
646
  function cleanup() {
594
647
  blockedUntil = ts() + 1500;
648
+
649
+ // Pause tous les media dans nos wraps AVANT de les retirer du DOM.
650
+ // Empêche wyvern.js de continuer d'exécuter ses callbacks async sur des
651
+ // nœuds détachés (erreurs "Cannot read 'paused'", "offsetWidth"…).
652
+ try {
653
+ document.querySelectorAll(`.${WRAP_CLASS} video, .${WRAP_CLASS} audio`).forEach(m => {
654
+ try { if (!m.paused) m.pause(); } catch (_) {}
655
+ });
656
+ } catch (_) {}
657
+
595
658
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
596
659
  S.cfg = null;
597
660
  S.pools = { topics: [], posts: [], categories: [] };