nodebb-plugin-ezoic-infinite 1.6.7 → 1.6.8

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 +134 -233
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.7",
3
+ "version": "1.6.8",
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
@@ -1351,43 +1351,52 @@ function buildOrdinalMap(items) {
1351
1351
 
1352
1352
  // ===== CLEAN REFRACTOR: visibility manager for Ezoic wraps =====
1353
1353
  (function () {
1354
- // v2.5 (position-enforcer):
1355
- // - Keep "no-redefine" (showAds only once per id, dedupe placeholders).
1356
- // - Fix pile-up at top when scrolling up: wraps are being detached and re-inserted near the first post.
1357
- // Adjacent-anchor can move with the wrap; so we anchor INSIDE the host post/topic element.
1354
+ // v2.6 (slot-recycler):
1355
+ // Observed:
1356
+ // - When scrolling up, ad wraps accumulate before first post.
1357
+ // - Ezoic logs: "Placeholder Id X already defined" + "No valid placeholders for loadMore"
1358
1358
  //
1359
- // Mechanism:
1360
- // 1) For each wrap, find a "host" element (closest previous post/topic item).
1361
- // 2) Insert an invisible marker span INSIDE that host (stays with host even if wrap is moved).
1362
- // 3) On scroll/interval, if wrap is not located right AFTER its host element, move it back.
1359
+ // Root cause:
1360
+ // - Infinite scroll / templating introduces MULTIPLE instances of the SAME placeholder id in DOM.
1361
+ // - Ezoic expects each placeholder id to be unique on the page. Duplicates cause warnings and loadMore failure.
1362
+ // - Some scripts then "re-home" the active slot near the top, causing pile-up.
1363
1363
  //
1364
- // This moves existing DOM nodes back (no placeholder recreation).
1364
+ // Fix strategy:
1365
+ // - Keep EXACTLY ONE DOM node per placeholder id ("canonical wrap").
1366
+ // - Replace any duplicate wraps with lightweight SLOT ANCHORS left in place.
1367
+ // - As user scrolls, MOVE the canonical wrap to the closest visible anchor for that id.
1368
+ // (Move, not recreate => no re-define; prevents pile-up; ad follows the viewport.)
1369
+ //
1370
+ // We only handle .nodebb-ezoic-wrap.* nodes; we do not change post markup.
1365
1371
 
1366
- var BETWEEN_SELECTOR = '.nodebb-ezoic-wrap.ezoic-ad-between';
1367
- var MESSAGE_SELECTOR = '.nodebb-ezoic-wrap.ezoic-ad-message';
1368
- var WRAP_SELECTOR = BETWEEN_SELECTOR + ', ' + MESSAGE_SELECTOR;
1372
+ var WRAP_SELECTOR = '.nodebb-ezoic-wrap';
1373
+ var PLACEHOLDER_SELECTOR = '[data-ezoic-id]';
1369
1374
 
1370
- var MAX_SHOW_PER_TICK = 4;
1375
+ // show tuning
1376
+ var MAX_SHOW_PER_TICK = 3;
1371
1377
 
1372
- // validation throttles
1373
- var ENFORCE_COOLDOWN_MS = 220;
1374
- var lastEnforce = 0;
1378
+ // recycler tuning
1379
+ var ROOT_MARGIN = 1400; // how far from viewport we consider an anchor "active"
1380
+ var MOVE_COOLDOWN_MS = 120; // avoid moving too often
1381
+ var SCAN_COOLDOWN_MS = 150; // throttle scroll scans
1382
+ var lastScan = 0;
1383
+ var lastMove = 0;
1375
1384
 
1376
- // internal state
1377
- var activatedById = Object.create(null);
1385
+ // state
1386
+ var canonicalById = Object.create(null); // id -> element
1387
+ var activatedById = Object.create(null); // id -> ts
1378
1388
  var showQueue = [];
1379
1389
  var showTicking = false;
1380
1390
 
1381
- function getIdFromWrap(w) {
1382
- try { return w.getAttribute('data-ezoic-wrapid'); } catch (e) { return null; }
1383
- }
1384
- function getIdFromPlaceholder(w) {
1391
+ function getId(w) {
1385
1392
  try {
1386
- var ph = w.querySelector('[data-ezoic-id]');
1387
- return ph ? ph.getAttribute('data-ezoic-id') : null;
1388
- } catch (e) { return null; }
1393
+ var id = w.getAttribute('data-ezoic-wrapid');
1394
+ if (id) return id;
1395
+ var ph = w.querySelector(PLACEHOLDER_SELECTOR);
1396
+ if (ph) return ph.getAttribute('data-ezoic-id');
1397
+ } catch (e) {}
1398
+ return null;
1389
1399
  }
1390
- function getId(w) { return getIdFromWrap(w) || getIdFromPlaceholder(w); }
1391
1400
 
1392
1401
  function isFilled(w) {
1393
1402
  try {
@@ -1426,268 +1435,160 @@ function buildOrdinalMap(items) {
1426
1435
  });
1427
1436
  }
1428
1437
 
1429
- // --- De-dupe placeholders to avoid Ezoic warnings ---
1430
- function dedupePlaceholders() {
1431
- var seen = Object.create(null);
1438
+ function createAnchorForDuplicate(w, id) {
1432
1439
  try {
1433
- document.querySelectorAll('.nodebb-ezoic-wrap [data-ezoic-id]').forEach(function (ph) {
1434
- try {
1435
- var id = ph.getAttribute('data-ezoic-id');
1436
- if (!id) return;
1437
- var wrap = ph.closest('.nodebb-ezoic-wrap');
1438
- if (!wrap) return;
1439
- if (seen[id]) {
1440
- wrap.remove();
1441
- } else {
1442
- seen[id] = true;
1443
- }
1444
- } catch (e) {}
1445
- });
1440
+ var a = document.createElement('span');
1441
+ a.className = 'nodebb-ezoic-slot-anchor';
1442
+ a.setAttribute('data-slot-for', String(id));
1443
+ // keep after/index metadata if present
1444
+ var after = w.getAttribute('data-ezoic-after');
1445
+ if (after) a.setAttribute('data-ezoic-after', after);
1446
+ a.style.display = 'block';
1447
+ a.style.width = '100%';
1448
+ // leave minimal height to keep spacing similar without large blank
1449
+ a.style.minHeight = '1px';
1450
+ w.parentNode.insertBefore(a, w);
1451
+ w.remove();
1446
1452
  } catch (e) {}
1447
1453
  }
1448
1454
 
1449
- // --- Position Enforcer ---
1450
- function ensureUid(w) {
1455
+ function normalizeDomOnce() {
1456
+ // Build canonical map; convert duplicates to anchors.
1451
1457
  try {
1452
- var uid = w.getAttribute('data-ezoic-wrapuid');
1453
- if (!uid) {
1454
- uid = String(Date.now()) + '-' + Math.floor(Math.random() * 1e9);
1455
- w.setAttribute('data-ezoic-wrapuid', uid);
1456
- }
1457
- return uid;
1458
- } catch (e) { return null; }
1459
- }
1460
-
1461
- function findHostForWrap(w) {
1462
- // Host should be a stable post/topic element, NOT the wrap itself.
1463
- // Heuristics: previous siblings that look like topic/post items.
1464
- try {
1465
- var cur = w.previousElementSibling;
1466
- // walk backward a bit
1467
- for (var i = 0; i < 18 && cur; i++) {
1468
- if (cur.classList && (cur.classList.contains('topic-item') || cur.classList.contains('posts-list-item') || cur.classList.contains('post') || cur.classList.contains('category-item'))) {
1469
- return cur;
1470
- }
1471
- if (cur.getAttribute) {
1472
- if (cur.getAttribute('data-pid') || cur.getAttribute('data-tid') || cur.getAttribute('data-topic-id') || cur.getAttribute('data-index')) return cur;
1473
- }
1474
- cur = cur.previousElementSibling;
1475
- }
1458
+ var wraps = document.querySelectorAll(WRAP_SELECTOR);
1459
+ for (var i = 0; i < wraps.length; i++) {
1460
+ var w = wraps[i];
1461
+ var id = getId(w);
1462
+ if (!id) continue;
1476
1463
 
1477
- // fallback: walk up from wrap to find nearest list container, then approximate by data-ezoic-after index
1478
- var afterPos = parseInt(w.getAttribute('data-ezoic-after') || '0', 10) || 0;
1479
- if (afterPos > 0) {
1480
- var list = w.parentElement;
1481
- for (var up = 0; up < 6 && list && list !== document.body; up++) {
1482
- if (list.classList && (list.classList.contains('topic-list') || list.classList.contains('topics') || list.classList.contains('posts') || list.classList.contains('category'))) break;
1483
- list = list.parentElement;
1484
- }
1485
- if (list && list.querySelectorAll) {
1486
- var items = list.querySelectorAll('.topic-item, .posts-list-item, .post, [data-pid], [data-tid], [data-topic-id]');
1487
- if (items && items.length >= afterPos) {
1488
- return items[Math.min(items.length - 1, afterPos - 1)];
1489
- }
1464
+ if (!canonicalById[id]) {
1465
+ canonicalById[id] = w;
1466
+ // if already filled, mark activated
1467
+ if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1468
+ } else if (canonicalById[id] !== w) {
1469
+ createAnchorForDuplicate(w, id);
1490
1470
  }
1491
1471
  }
1492
1472
  } catch (e) {}
1493
- return null;
1494
1473
  }
1495
1474
 
1496
- function ensureMarkerInHost(w) {
1475
+ function getCandidateAnchors(id) {
1497
1476
  try {
1498
- var uid = ensureUid(w);
1499
- if (!uid) return;
1500
-
1501
- var markerId = w.getAttribute('data-ezoic-markerid');
1502
- if (markerId) {
1503
- var existing = document.getElementById(markerId);
1504
- if (existing) return;
1505
- }
1506
-
1507
- var host = findHostForWrap(w);
1508
- if (!host) return;
1509
-
1510
- markerId = 'nodebb-ezoic-marker-' + uid;
1511
- // if already exists somewhere else, reuse
1512
- var m = document.getElementById(markerId);
1513
- if (!m) {
1514
- m = document.createElement('span');
1515
- m.id = markerId;
1516
- m.className = 'nodebb-ezoic-marker';
1517
- m.setAttribute('data-marker-for', uid);
1518
- m.style.display = 'none';
1519
- host.appendChild(m);
1520
- } else {
1521
- // ensure marker is inside host (if moved, re-append)
1522
- if (m.parentElement !== host) host.appendChild(m);
1523
- }
1524
-
1525
- w.setAttribute('data-ezoic-markerid', markerId);
1526
- } catch (e) {}
1477
+ return document.querySelectorAll('.nodebb-ezoic-slot-anchor[data-slot-for="' + String(id) + '"]');
1478
+ } catch (e) {
1479
+ return [];
1480
+ }
1527
1481
  }
1528
1482
 
1529
- function insertAfter(ref, node) {
1483
+ function anchorDistanceToViewport(a, vh) {
1530
1484
  try {
1531
- if (!ref || !ref.parentNode || !node) return;
1532
- if (ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling);
1533
- else ref.parentNode.appendChild(node);
1485
+ var r = a.getBoundingClientRect();
1486
+ // distance 0 if overlaps extended viewport, else min distance
1487
+ if (r.bottom >= -ROOT_MARGIN && r.top <= (vh + ROOT_MARGIN)) return 0;
1488
+ if (r.top > (vh + ROOT_MARGIN)) return r.top - (vh + ROOT_MARGIN);
1489
+ if (r.bottom < -ROOT_MARGIN) return (-ROOT_MARGIN) - r.bottom;
1534
1490
  } catch (e) {}
1491
+ return 1e12;
1535
1492
  }
1536
1493
 
1537
- function enforcePositions() {
1494
+ function moveCanonicalToAnchor(id, anchor) {
1538
1495
  var now = Date.now();
1539
- if (now - lastEnforce < ENFORCE_COOLDOWN_MS) return;
1540
- lastEnforce = now;
1496
+ if (now - lastMove < MOVE_COOLDOWN_MS) return;
1497
+ lastMove = now;
1541
1498
 
1542
1499
  try {
1543
- var wraps = document.querySelectorAll('.nodebb-ezoic-wrap');
1544
- for (var i = 0; i < wraps.length; i++) {
1545
- var w = wraps[i];
1546
- try {
1547
- ensureMarkerInHost(w);
1548
-
1549
- var markerId = w.getAttribute('data-ezoic-markerid');
1550
- if (!markerId) continue;
1500
+ var w = canonicalById[id];
1501
+ if (!w || !anchor || !anchor.parentNode) return;
1551
1502
 
1552
- var marker = document.getElementById(markerId);
1553
- if (!marker || !marker.parentElement) continue;
1503
+ // If already directly after anchor, nothing to do.
1504
+ if (w.parentNode === anchor.parentNode && w.previousSibling === anchor) return;
1554
1505
 
1555
- var host = marker.parentElement;
1556
- // desired: wrap should be directly after host (as sibling)
1557
- if (!host.parentElement) continue;
1558
- if (w.parentElement !== host.parentElement || w.previousElementSibling !== host) {
1559
- insertAfter(host, w);
1560
- }
1561
- } catch (e) {}
1562
- }
1506
+ // Insert canonical wrap right after anchor.
1507
+ if (anchor.nextSibling) anchor.parentNode.insertBefore(w, anchor.nextSibling);
1508
+ else anchor.parentNode.appendChild(w);
1563
1509
  } catch (e) {}
1564
1510
  }
1565
1511
 
1566
- // --- Visibility: trigger show once if empty ---
1567
- var io = null;
1568
- function installIO() {
1569
- if (io || typeof IntersectionObserver === 'undefined') return;
1570
-
1571
- io = new IntersectionObserver(function (entries) {
1572
- try {
1573
- entries.forEach(function (e) {
1574
- if (!e || !e.target) return;
1512
+ function recycleTick() {
1513
+ var now = Date.now();
1514
+ if (now - lastScan < SCAN_COOLDOWN_MS) return;
1515
+ lastScan = now;
1575
1516
 
1576
- // ensure host marker on sight
1577
- ensureMarkerInHost(e.target);
1517
+ // ensure dom normalized before recycling
1518
+ normalizeDomOnce();
1578
1519
 
1579
- if (e.isIntersecting) {
1580
- var id = getId(e.target);
1581
- if (!id) return;
1520
+ var vh = window.innerHeight || document.documentElement.clientHeight || 0;
1521
+ if (!vh) return;
1582
1522
 
1583
- if (isFilled(e.target)) {
1584
- activatedById[id] = activatedById[id] || Date.now();
1585
- return;
1586
- }
1587
- enqueueShow(id);
1523
+ try {
1524
+ for (var id in canonicalById) {
1525
+ if (!canonicalById[id]) continue;
1526
+ var anchors = getCandidateAnchors(id);
1527
+ if (!anchors || !anchors.length) continue;
1528
+
1529
+ // choose the first anchor that intersects extended viewport, otherwise closest
1530
+ var best = null;
1531
+ var bestDist = 1e12;
1532
+ for (var i = 0; i < anchors.length; i++) {
1533
+ var a = anchors[i];
1534
+ var d = anchorDistanceToViewport(a, vh);
1535
+ if (d === 0) { best = a; bestDist = 0; break; }
1536
+ if (d < bestDist) { bestDist = d; best = a; }
1537
+ }
1538
+ if (best) {
1539
+ moveCanonicalToAnchor(id, best);
1540
+
1541
+ // trigger show when canonical is near viewport and still empty
1542
+ var w = canonicalById[id];
1543
+ if (w) {
1544
+ try {
1545
+ var r = w.getBoundingClientRect();
1546
+ if (r.bottom >= -ROOT_MARGIN && r.top <= (vh + ROOT_MARGIN)) {
1547
+ if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1548
+ else enqueueShow(id);
1549
+ }
1550
+ } catch (e) {}
1588
1551
  }
1589
- });
1590
- } catch (e) {}
1591
- }, { root: null, rootMargin: '1200px 0px 1200px 0px', threshold: 0.01 });
1552
+ }
1553
+ }
1554
+ } catch (e) {}
1592
1555
 
1593
- try { document.querySelectorAll(WRAP_SELECTOR).forEach(function (w) { try { ensureMarkerInHost(w); io.observe(w); } catch(e) {} }); } catch (e) {}
1556
+ scheduleShowTick();
1594
1557
  }
1595
1558
 
1596
- // Observe newly added wraps and marker them
1559
+ // Observe new content; normalize duplicates immediately
1597
1560
  var moInstalled = false;
1598
1561
  function installMO() {
1599
1562
  if (moInstalled || typeof MutationObserver === 'undefined') return;
1600
1563
  moInstalled = true;
1601
1564
 
1602
1565
  var mo = new MutationObserver(function (muts) {
1603
- try { dedupePlaceholders(); } catch (e) {}
1604
- if (!io) return;
1605
-
1606
- try {
1607
- for (var i = 0; i < muts.length; i++) {
1608
- var m = muts[i];
1609
- if (!m.addedNodes) continue;
1610
- for (var j = 0; j < m.addedNodes.length; j++) {
1611
- var n = m.addedNodes[j];
1612
- if (!n || n.nodeType !== 1) continue;
1613
-
1614
- if (n.matches && n.matches(WRAP_SELECTOR)) {
1615
- ensureMarkerInHost(n);
1616
- try { io.observe(n); } catch (e) {}
1617
- var id = getId(n);
1618
- if (id && !isFilled(n)) enqueueShow(id);
1619
- else if (id) activatedById[id] = activatedById[id] || Date.now();
1620
- } else if (n.querySelectorAll) {
1621
- var inner = n.querySelectorAll(WRAP_SELECTOR);
1622
- for (var k = 0; k < inner.length; k++) {
1623
- ensureMarkerInHost(inner[k]);
1624
- try { io.observe(inner[k]); } catch (e) {}
1625
- }
1626
- }
1627
- }
1628
- }
1629
- } catch (e) {}
1630
-
1631
- // enforce after mutations (cheap throttle will apply)
1632
- enforcePositions();
1566
+ // When new nodes arrive, normalize and run one recycle tick.
1567
+ normalizeDomOnce();
1568
+ recycleTick();
1633
1569
  });
1634
1570
 
1635
1571
  try { mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch (e) {}
1636
1572
  }
1637
1573
 
1638
1574
  function init() {
1639
- dedupePlaceholders();
1640
-
1641
- try {
1642
- document.querySelectorAll('.nodebb-ezoic-wrap').forEach(function (w) { ensureMarkerInHost(w); });
1643
- } catch (e) {}
1644
-
1645
- installIO();
1575
+ normalizeDomOnce();
1646
1576
  installMO();
1647
1577
 
1648
- // initial eager show (bounded)
1649
- try {
1650
- var vh = window.innerHeight || document.documentElement.clientHeight || 0;
1651
- var margin = 900;
1652
- var wraps = document.querySelectorAll(WRAP_SELECTOR);
1653
- var budget = 12;
1654
- for (var i = 0; i < wraps.length && budget > 0; i++) {
1655
- var w = wraps[i];
1656
- ensureMarkerInHost(w);
1657
- var r = w.getBoundingClientRect();
1658
- if (r.bottom >= -margin && r.top <= (vh + margin)) {
1659
- var id = getId(w);
1660
- if (id) {
1661
- if (isFilled(w)) activatedById[id] = activatedById[id] || Date.now();
1662
- else enqueueShow(id);
1663
- }
1664
- budget--;
1665
- }
1666
- }
1667
- } catch (e) {}
1668
-
1669
- window.addEventListener('scroll', function () {
1670
- enforcePositions();
1671
- scheduleShowTick();
1672
- }, { passive: true });
1673
- window.addEventListener('resize', function () {
1674
- enforcePositions();
1675
- scheduleShowTick();
1676
- }, { passive: true });
1578
+ window.addEventListener('scroll', recycleTick, { passive: true });
1579
+ window.addEventListener('resize', recycleTick, { passive: true });
1677
1580
 
1678
1581
  if (window.jQuery) {
1679
1582
  window.jQuery(window).on('action:ajaxify.end action:infiniteScroll.loaded', function () {
1680
1583
  setTimeout(function () {
1681
- dedupePlaceholders();
1682
- try { document.querySelectorAll('.nodebb-ezoic-wrap').forEach(function (w) { ensureMarkerInHost(w); }); } catch (e) {}
1683
- enforcePositions();
1684
- scheduleShowTick();
1584
+ normalizeDomOnce();
1585
+ recycleTick();
1685
1586
  }, 0);
1686
1587
  });
1687
1588
  }
1688
1589
 
1689
- setInterval(enforcePositions, 1000);
1690
- setTimeout(function () { enforcePositions(); scheduleShowTick(); }, 0);
1590
+ setInterval(recycleTick, 900);
1591
+ setTimeout(recycleTick, 0);
1691
1592
  }
1692
1593
 
1693
1594
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);