nodebb-plugin-ezoic-infinite 1.5.28 → 1.5.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.28",
3
+ "version": "1.5.30",
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
@@ -13,27 +13,23 @@
13
13
  // Preload before viewport (tune if you want even earlier)
14
14
  const PRELOAD_ROOT_MARGIN = '1200px 0px';
15
15
 
16
- // Windowing: keep ads mostly around the viewport to avoid DOM bloat + ID saturation
17
- const WINDOW_MIN_ITEMS = 24; // minimum scan window
18
- const WINDOW_MAX_ITEMS = 120; // maximum scan window
19
- const WINDOW_BUFFER_PX = 900; // how far outside viewport counts as "near"
20
- const PURGE_BUFFER_ITEMS = 12; // extra items outside the window where we keep wrappers
21
- const RECYCLE_COOLDOWN_MS = 1500; // anti-loop when at saturation / heavy churn
22
-
23
- // Soft-block during navigation or heavy DOM churn (avoid "placeholder does not exist" spam)
24
- let EZOIC_BLOCKED_UNTIL = 0;
25
-
26
16
  const SELECTORS = {
27
17
  topicItem: 'li[component="category/topic"]',
28
18
  postItem: '[component="post"][data-pid]',
29
19
  categoryItem: 'li[component="categories/category"]',
30
20
  };
31
21
 
22
+ // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
23
+ let blockedUntil = 0;
24
+ function isBlocked() {
25
+ return Date.now() < blockedUntil;
26
+ }
27
+
32
28
  const state = {
33
29
  pageKey: null,
34
30
  cfg: null,
35
31
 
36
- // Full ID lists + cursors (round-robin)
32
+ // Full lists (never consumed) + cursors for round-robin reuse
37
33
  allTopics: [],
38
34
  allPosts: [],
39
35
  allCategories: [],
@@ -43,6 +39,11 @@
43
39
 
44
40
  // throttle per placeholder id
45
41
  lastShowById: new Map(),
42
+ internalDomChange: 0,
43
+ lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
44
+
45
+ // track placeholders that have been shown at least once in this pageview
46
+ usedOnce: new Set(),
46
47
 
47
48
  // observers / schedulers
48
49
  domObs: null,
@@ -51,28 +52,22 @@
51
52
 
52
53
  // hero
53
54
  heroDoneForPage: false,
54
-
55
- // internal DOM changes (to ignore our own mutations)
56
- internalDomChange: 0,
57
-
58
- // recycle cooldown per kind
59
- lastRecycleAt: {
60
- topic: 0,
61
- categoryTopics: 0,
62
- categories: 0,
63
- },
64
55
  };
65
56
 
66
- // ---------- debug ----------
67
- function debugEnabled() {
68
- try { return window.localStorage && window.localStorage.getItem('ezoicInfiniteDebug') === '1'; } catch (e) { return false; }
69
- }
70
- function debug(...args) {
71
- if (!debugEnabled()) return;
72
- try { console.log('[ezoicInfinite]', ...args); } catch (e) {}
57
+ const insertingIds = new Set();
58
+
59
+ // Debug logs (enable with localStorage.ezoicInfiniteDebug = "1")
60
+ function dbg(...args) {
61
+ try {
62
+ if (window && window.localStorage && window.localStorage.getItem('ezoicInfiniteDebug') === '1') {
63
+ // eslint-disable-next-line no-console
64
+ console.log('[ezoicInfinite]', ...args);
65
+ }
66
+ } catch (e) {}
73
67
  }
74
68
 
75
69
  // ---------- small utils ----------
70
+
76
71
  function normalizeBool(v) {
77
72
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
78
73
  }
@@ -99,16 +94,6 @@
99
94
  return uniqInts(lines);
100
95
  }
101
96
 
102
- function isBlocked() {
103
- return Date.now() < EZOIC_BLOCKED_UNTIL;
104
- }
105
-
106
- function softBlock(ms) {
107
- const until = Date.now() + Math.max(0, ms || 0);
108
- if (until > EZOIC_BLOCKED_UNTIL) EZOIC_BLOCKED_UNTIL = until;
109
- debug('blocked', { until: EZOIC_BLOCKED_UNTIL });
110
- }
111
-
112
97
  function getPageKey() {
113
98
  try {
114
99
  const ax = window.ajaxify;
@@ -153,12 +138,8 @@
153
138
  });
154
139
  }
155
140
 
156
- function withInternalDomChange(fn) {
157
- state.internalDomChange++;
158
- try { return fn(); } finally { state.internalDomChange--; }
159
- }
160
-
161
141
  // ---------- warm-up & patching ----------
142
+
162
143
  const _warmLinksDone = new Set();
163
144
  function warmUpNetwork() {
164
145
  try {
@@ -225,7 +206,29 @@
225
206
  }
226
207
  }
227
208
 
228
- // ---------- config & ids ----------
209
+ const RECYCLE_COOLDOWN_MS = 1500;
210
+
211
+ function kindKeyFromClass(kindClass) {
212
+ if (kindClass === 'ezoic-ad-message') return 'topic';
213
+ if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
214
+ if (kindClass === 'ezoic-ad-categories') return 'categories';
215
+ return 'topic';
216
+ }
217
+
218
+ function withInternalDomChange(fn) {
219
+ state.internalDomChange++;
220
+ try { fn(); } finally { state.internalDomChange--; }
221
+ }
222
+
223
+ function canRecycle(kind) {
224
+ const now = Date.now();
225
+ const last = state.lastRecycleAt[kind] || 0;
226
+ if (now - last < RECYCLE_COOLDOWN_MS) return false;
227
+ state.lastRecycleAt[kind] = now;
228
+ return true;
229
+ }
230
+ // ---------- config & pools ----------
231
+
229
232
  async function fetchConfigOnce() {
230
233
  if (state.cfg) return state.cfg;
231
234
  try {
@@ -238,7 +241,7 @@
238
241
  }
239
242
  }
240
243
 
241
- function initIds(cfg) {
244
+ function initPools(cfg) {
242
245
  if (!cfg) return;
243
246
  if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
244
247
  if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
@@ -246,6 +249,7 @@
246
249
  }
247
250
 
248
251
  // ---------- insertion primitives ----------
252
+
249
253
  function isAdjacentAd(target) {
250
254
  if (!target) return false;
251
255
  const next = target.nextElementSibling;
@@ -255,9 +259,75 @@
255
259
  return false;
256
260
  }
257
261
 
258
- function buildWrap(id, kindClass, afterPos) {
262
+
263
+ function getWrapIdFromWrap(wrap) {
264
+ try {
265
+ const v = wrap.getAttribute('data-ezoic-wrapid');
266
+ if (v) return String(v);
267
+ const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
268
+ if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
269
+ } catch (e) {}
270
+ return null;
271
+ }
272
+
273
+ function safeDestroyById(id) {
274
+ try {
275
+ const ez = window.ezstandalone;
276
+ if (ez && typeof ez.destroyPlaceholders === 'function') {
277
+ ez.destroyPlaceholders([`${PLACEHOLDER_PREFIX}${id}`]);
278
+ }
279
+ } catch (e) {}
280
+ }
281
+
282
+ function pruneOrphanWraps(kindClass, items) {
283
+ if (!items || !items.length) return 0;
284
+ const itemSet = new Set(items);
285
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
286
+ let removed = 0;
287
+
288
+ wraps.forEach((wrap) => {
289
+ // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
290
+ let ok = false;
291
+ let prev = wrap.previousElementSibling;
292
+ for (let i = 0; i < 3 && prev; i++) {
293
+ if (itemSet.has(prev)) { ok = true; break; }
294
+ prev = prev.previousElementSibling;
295
+ }
296
+
297
+ if (!ok) {
298
+ const id = getWrapIdFromWrap(wrap);
299
+ withInternalDomChange(() => {
300
+ try {
301
+ if (id) safeDestroyById(id);
302
+ wrap.remove();
303
+ } catch (e) {}
304
+ });
305
+ removed++;
306
+ }
307
+ });
308
+
309
+ if (removed) dbg('prune-orphan', kindClass, { removed });
310
+ return removed;
311
+ }
312
+
313
+ function refreshEmptyState(id) {
314
+ // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
315
+ window.setTimeout(() => {
316
+ try {
317
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
318
+ if (!ph || !ph.isConnected) return;
319
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
320
+ if (!wrap) return;
321
+ const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
322
+ if (hasContent) wrap.classList.remove('is-empty');
323
+ else wrap.classList.add('is-empty');
324
+ } catch (e) {}
325
+ }, 3500);
326
+ }
327
+
328
+ function buildWrap(id, kindClass, afterPos) {
259
329
  const wrap = document.createElement('div');
260
- wrap.className = `${WRAP_CLASS} ${kindClass} is-empty`;
330
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
261
331
  wrap.setAttribute('data-ezoic-after', String(afterPos));
262
332
  wrap.setAttribute('data-ezoic-wrapid', String(id));
263
333
  wrap.style.width = '100%';
@@ -277,68 +347,26 @@
277
347
  function insertAfter(target, id, kindClass, afterPos) {
278
348
  if (!target || !target.insertAdjacentElement) return null;
279
349
  if (findWrap(kindClass, afterPos)) return null;
350
+ if (insertingIds.has(id)) return null;
280
351
 
281
352
  const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
282
353
  if (existingPh && existingPh.isConnected) return null;
283
354
 
284
- return withInternalDomChange(() => {
355
+ insertingIds.add(id);
356
+ try {
285
357
  const wrap = buildWrap(id, kindClass, afterPos);
286
358
  target.insertAdjacentElement('afterend', wrap);
287
359
  return wrap;
288
- });
289
- }
290
-
291
- function destroyPlaceholderId(id) {
292
- try {
293
- window.ezstandalone = window.ezstandalone || {};
294
- const ez = window.ezstandalone;
295
- if (typeof ez.destroyPlaceholders === 'function') {
296
- try { ez.destroyPlaceholders(id); } catch (e) {}
297
- } else if (ez.cmd && Array.isArray(ez.cmd)) {
298
- ez.cmd.push(() => {
299
- try {
300
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
301
- window.ezstandalone.destroyPlaceholders(id);
302
- }
303
- } catch (e) {}
304
- });
305
- }
306
- } catch (e) {}
307
- }
308
-
309
- function canRecycle(kind) {
310
- const now = Date.now();
311
- if (now - (state.lastRecycleAt[kind] || 0) < RECYCLE_COOLDOWN_MS) return false;
312
- state.lastRecycleAt[kind] = now;
313
- return true;
314
- }
315
-
316
- function purgeOutsideWindow(kindClass, keepFrom, keepTo) {
317
- // Remove wrappers too far away to free placeholder ids + reduce DOM bloat
318
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
319
- if (!wraps.length) return 0;
320
-
321
- let removed = 0;
322
- withInternalDomChange(() => {
323
- for (const w of wraps) {
324
- const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '', 10);
325
- if (!Number.isFinite(afterPos)) continue;
326
- if (afterPos >= keepFrom && afterPos <= keepTo) continue;
327
-
328
- const id = parseInt(w.getAttribute('data-ezoic-wrapid') || '', 10);
329
- if (Number.isFinite(id) && id > 0) destroyPlaceholderId(id);
330
-
331
- try { w.remove(); removed++; } catch (e) {}
332
- }
333
- });
334
- return removed;
360
+ } finally {
361
+ insertingIds.delete(id);
362
+ }
335
363
  }
336
364
 
337
365
  function pickIdFromAll(allIds, cursorKey) {
338
366
  const n = allIds.length;
339
367
  if (!n) return null;
340
368
 
341
- // Try at most n IDs to find one not currently present
369
+ // Try at most n ids to find one that's not already in the DOM
342
370
  for (let tries = 0; tries < n; tries++) {
343
371
  const idx = state[cursorKey] % n;
344
372
  state[cursorKey] = (state[cursorKey] + 1) % n;
@@ -346,24 +374,38 @@
346
374
  const id = allIds[idx];
347
375
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
348
376
  if (ph && ph.isConnected) continue;
377
+
349
378
  return id;
350
379
  }
351
380
  return null;
352
381
  }
353
382
 
354
- function markEmptyLater(id) {
355
- // Collapse empty blocks if ad never fills
356
- window.setTimeout(() => {
383
+
384
+ function removeOneOldWrap(kindClass) {
385
+ try {
386
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
387
+ if (!wraps.length) return false;
388
+
389
+ // Prefer a wrap far above the viewport
390
+ let victim = null;
391
+ for (const w of wraps) {
392
+ const r = w.getBoundingClientRect();
393
+ if (r.bottom < -2000) { victim = w; break; }
394
+ }
395
+ // Otherwise remove the earliest one in the document
396
+ if (!victim) victim = wraps[0];
397
+
398
+ // Unobserve placeholder if still observed
357
399
  try {
358
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
359
- if (!ph || !ph.isConnected) return;
360
- const wrap = ph.closest(`.${WRAP_CLASS}`);
361
- if (!wrap) return;
362
- const hasContent = ph.childElementCount > 0;
363
- if (hasContent) wrap.classList.remove('is-empty');
364
- else wrap.classList.add('is-empty');
400
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
401
+ if (ph && state.io) state.io.unobserve(ph);
365
402
  } catch (e) {}
366
- }, 1800);
403
+
404
+ victim.remove();
405
+ return true;
406
+ } catch (e) {
407
+ return false;
408
+ }
367
409
  }
368
410
 
369
411
  function showAd(id) {
@@ -373,27 +415,36 @@
373
415
  const last = state.lastShowById.get(id) || 0;
374
416
  if (now - last < 1500) return; // basic throttle
375
417
 
376
- const doShow = () => {
418
+ // Defer one frame so the placeholder is definitely in DOM after insertion/recycle
419
+ requestAnimationFrame(() => {
377
420
  if (isBlocked()) return;
421
+
378
422
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
379
423
  if (!ph || !ph.isConnected) return;
380
424
 
381
- state.lastShowById.set(id, Date.now());
382
-
383
- // mark empty by default; remove later if filled
384
- try {
385
- const wrap = ph.closest(`.${WRAP_CLASS}`);
386
- if (wrap) wrap.classList.add('is-empty');
387
- } catch (e) {}
425
+ const now2 = Date.now();
426
+ const last2 = state.lastShowById.get(id) || 0;
427
+ if (now2 - last2 < 1200) return;
428
+ state.lastShowById.set(id, now2);
388
429
 
389
430
  try {
390
431
  window.ezstandalone = window.ezstandalone || {};
391
432
  const ez = window.ezstandalone;
392
433
 
434
+ const doShow = () => {
435
+ try {
436
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
437
+ // Avoid Ezoic caching state for reused placeholders
438
+ ez.destroyPlaceholders(id);
439
+ }
440
+ } catch (e) {}
441
+ try { ez.showAds(id); } catch (e) {}
442
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
443
+ };
444
+
393
445
  // Fast path
394
446
  if (typeof ez.showAds === 'function') {
395
- ez.showAds(id);
396
- markEmptyLater(id);
447
+ doShow();
397
448
  return;
398
449
  }
399
450
 
@@ -406,19 +457,25 @@
406
457
  if (isBlocked()) return;
407
458
  const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
459
  if (!el || !el.isConnected) return;
409
- window.ezstandalone.showAds(id);
410
- markEmptyLater(id);
460
+ const ez2 = window.ezstandalone;
461
+ if (!ez2 || typeof ez2.showAds !== 'function') return;
462
+ try {
463
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez2.destroyPlaceholders === 'function') {
464
+ ez2.destroyPlaceholders(id);
465
+ }
466
+ } catch (e) {}
467
+ try { ez2.showAds(id); } catch (e) {}
468
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
411
469
  } catch (e) {}
412
470
  });
413
471
  }
414
472
  } catch (e) {}
415
- };
416
-
417
- // Defer one frame to reduce "element does not exist" warnings
418
- window.requestAnimationFrame(doShow);
473
+ });
419
474
  }
420
475
 
476
+
421
477
  // ---------- preload / above-the-fold ----------
478
+
422
479
  function ensurePreloadObserver() {
423
480
  if (state.io) return state.io;
424
481
  try {
@@ -452,72 +509,22 @@
452
509
  } catch (e) {}
453
510
  }
454
511
 
455
- // ---------- windowed insertion logic ----------
456
- function estimateWindowItems(interval, idsCount) {
457
- // Ensure we can roughly keep "every X" within the window using available ids
458
- // If ids are low, keep a smaller window to avoid constant recycling.
459
- const base = Math.max(WINDOW_MIN_ITEMS, Math.floor((idsCount || 1) * Math.max(1, interval) * 1.1));
460
- return Math.min(WINDOW_MAX_ITEMS, base);
461
- }
462
-
463
- function getVisibleRange(items) {
464
- // Returns [startIdx, endIdx] (0-based, inclusive) for items near viewport
465
- let start = 0;
466
- let end = items.length - 1;
467
- let foundAny = false;
468
-
469
- const topBound = -WINDOW_BUFFER_PX;
470
- const bottomBound = window.innerHeight + WINDOW_BUFFER_PX;
471
-
472
- for (let i = 0; i < items.length; i++) {
473
- const el = items[i];
474
- if (!el || !el.isConnected) continue;
475
- let r;
476
- try { r = el.getBoundingClientRect(); } catch (e) { continue; }
477
- if (r.bottom < topBound) { start = i + 1; continue; }
478
- if (r.top > bottomBound) { end = i - 1; break; }
479
- foundAny = true;
480
- }
481
-
482
- if (!foundAny) return [0, Math.min(items.length - 1, 30)];
483
- start = Math.max(0, Math.min(start, items.length - 1));
484
- end = Math.max(start, Math.min(end, items.length - 1));
485
- return [start, end];
486
- }
512
+ // ---------- insertion logic ----------
487
513
 
488
- function computeTargetsInRange(fromPos, toPos, interval, showFirst) {
514
+ function computeTargets(count, interval, showFirst) {
489
515
  const out = [];
490
- if (toPos < fromPos) return out;
491
-
492
- if (showFirst && 1 >= fromPos && 1 <= toPos) out.push(1);
493
-
494
- const startK = Math.ceil(fromPos / interval) * interval;
495
- for (let p = startK; p <= toPos; p += interval) out.push(p);
496
-
497
- return out;
516
+ if (count <= 0) return out;
517
+ if (showFirst) out.push(1);
518
+ for (let i = 1; i <= count; i++) {
519
+ if (i % interval === 0) out.push(i);
520
+ }
521
+ return Array.from(new Set(out)).sort((a, b) => a - b);
498
522
  }
499
523
 
500
- function injectWindowed(kind, kindClass, items, interval, showFirst, allIds, cursorKey) {
524
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
501
525
  if (!items.length) return 0;
502
526
 
503
- const idsCount = allIds.length;
504
- const windowItems = estimateWindowItems(interval, idsCount);
505
-
506
- const [visStart, visEnd] = getVisibleRange(items);
507
- const mid = Math.floor((visStart + visEnd) / 2);
508
- const winStartIdx = Math.max(0, mid - Math.floor(windowItems / 2));
509
- const winEndIdx = Math.min(items.length - 1, winStartIdx + windowItems - 1);
510
-
511
- const keepFrom = Math.max(1, (winStartIdx + 1) - PURGE_BUFFER_ITEMS);
512
- const keepTo = Math.min(items.length, (winEndIdx + 1) + PURGE_BUFFER_ITEMS);
513
-
514
- const purged = purgeOutsideWindow(kindClass, keepFrom, keepTo);
515
- if (purged) debug('purge', kindClass, { purged, keepFrom, keepTo });
516
-
517
- const fromPos = winStartIdx + 1;
518
- const toPos = winEndIdx + 1;
519
-
520
- const targets = computeTargetsInRange(fromPos, toPos, interval, showFirst);
527
+ const targets = computeTargets(items.length, interval, showFirst);
521
528
  let inserted = 0;
522
529
 
523
530
  for (const afterPos of targets) {
@@ -529,25 +536,25 @@
529
536
  if (findWrap(kindClass, afterPos)) continue;
530
537
 
531
538
  let id = pickIdFromAll(allIds, cursorKey);
532
-
533
- // If no ID is currently free, try a purge (only occasionally) then try again
534
539
  if (!id) {
535
- if (!canRecycle(kind)) {
536
- debug('recycle-skip-cooldown', kindClass);
540
+ // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
541
+ // Guard against tight observer loops.
542
+ if (!canRecycle(kindKeyFromClass(kindClass))) {
543
+ dbg('recycle-skip-cooldown', kindClass);
537
544
  break;
538
545
  }
539
- const extraKeepFrom = Math.max(1, keepFrom + PURGE_BUFFER_ITEMS);
540
- const extraKeepTo = Math.min(items.length, keepTo - PURGE_BUFFER_ITEMS);
541
- const purgedMore = purgeOutsideWindow(kindClass, extraKeepFrom, extraKeepTo);
542
- debug('recycle-needed', kindClass, { recycled: purgedMore > 0, ids: idsCount });
543
-
544
- // After a recycle attempt, stop this run and wait for next tick to stabilize DOM
545
- softBlock(400);
546
+ let recycled = false;
547
+ withInternalDomChange(() => {
548
+ recycled = removeOneOldWrap(kindClass);
549
+ });
550
+ dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
551
+ // Stop this run after a recycle; the next mutation/scroll will retry injection.
546
552
  break;
547
553
  }
548
-
549
554
  const wrap = insertAfter(el, id, kindClass, afterPos);
550
- if (!wrap) continue;
555
+ if (!wrap) {
556
+ continue;
557
+ }
551
558
 
552
559
  observePlaceholder(id);
553
560
  inserted += 1;
@@ -559,9 +566,10 @@
559
566
  async function insertHeroAdEarly() {
560
567
  if (state.heroDoneForPage) return;
561
568
  const cfg = await fetchConfigOnce();
562
- if (!cfg || cfg.excluded) return;
569
+ if (!cfg) { dbg('no-config'); return; }
570
+ if (cfg.excluded) { dbg('excluded'); return; }
563
571
 
564
- initIds(cfg);
572
+ initPools(cfg);
565
573
 
566
574
  const kind = getKind();
567
575
  let items = [];
@@ -571,32 +579,31 @@
571
579
  let showFirst = false;
572
580
 
573
581
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
574
- showFirst = normalizeBool(cfg.showFirstMessageAd);
575
- if (!showFirst) return;
576
582
  items = getPostContainers();
577
583
  allIds = state.allPosts;
578
584
  cursorKey = 'curPosts';
579
585
  kindClass = 'ezoic-ad-message';
586
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
580
587
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
581
- showFirst = normalizeBool(cfg.showFirstTopicAd);
582
- if (!showFirst) return;
583
588
  items = getTopicItems();
584
589
  allIds = state.allTopics;
585
590
  cursorKey = 'curTopics';
586
591
  kindClass = 'ezoic-ad-between';
592
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
587
593
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
588
- showFirst = normalizeBool(cfg.showFirstCategoryAd);
589
- if (!showFirst) return;
590
594
  items = getCategoryItems();
591
595
  allIds = state.allCategories;
592
596
  cursorKey = 'curCategories';
593
597
  kindClass = 'ezoic-ad-categories';
598
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
594
599
  } else {
595
600
  return;
596
601
  }
597
602
 
598
603
  if (!items.length) return;
604
+ if (!showFirst) { state.heroDoneForPage = true; return; }
599
605
 
606
+ // Insert after the very first item (above-the-fold)
600
607
  const afterPos = 1;
601
608
  const el = items[afterPos - 1];
602
609
  if (!el || !el.isConnected) return;
@@ -607,29 +614,33 @@
607
614
  if (!id) return;
608
615
 
609
616
  const wrap = insertAfter(el, id, kindClass, afterPos);
610
- if (!wrap) return;
617
+ if (!wrap) {
618
+ return;
619
+ }
611
620
 
612
621
  state.heroDoneForPage = true;
613
622
  observePlaceholder(id);
614
623
  }
615
624
 
616
625
  async function runCore() {
617
- if (isBlocked()) return;
626
+ if (isBlocked()) { dbg('blocked'); return; }
618
627
 
619
628
  patchShowAds();
620
629
 
621
630
  const cfg = await fetchConfigOnce();
622
- if (!cfg || cfg.excluded) return;
623
- initIds(cfg);
631
+ if (!cfg) { dbg('no-config'); return; }
632
+ if (cfg.excluded) { dbg('excluded'); return; }
633
+ initPools(cfg);
624
634
 
625
635
  const kind = getKind();
626
636
 
627
637
  if (kind === 'topic') {
628
638
  if (normalizeBool(cfg.enableMessageAds)) {
629
- injectWindowed(
630
- 'topic',
639
+ const __items = getPostContainers();
640
+ pruneOrphanWraps('ezoic-ad-message', __items);
641
+ injectBetween(
631
642
  'ezoic-ad-message',
632
- getPostContainers(),
643
+ __items,
633
644
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
634
645
  normalizeBool(cfg.showFirstMessageAd),
635
646
  state.allPosts,
@@ -638,10 +649,11 @@
638
649
  }
639
650
  } else if (kind === 'categoryTopics') {
640
651
  if (normalizeBool(cfg.enableBetweenAds)) {
641
- injectWindowed(
642
- 'categoryTopics',
652
+ const __items = getTopicItems();
653
+ pruneOrphanWraps('ezoic-ad-between', __items);
654
+ injectBetween(
643
655
  'ezoic-ad-between',
644
- getTopicItems(),
656
+ __items,
645
657
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
646
658
  normalizeBool(cfg.showFirstTopicAd),
647
659
  state.allTopics,
@@ -650,10 +662,11 @@
650
662
  }
651
663
  } else if (kind === 'categories') {
652
664
  if (normalizeBool(cfg.enableCategoryAds)) {
653
- injectWindowed(
654
- 'categories',
665
+ const __items = getCategoryItems();
666
+ pruneOrphanWraps('ezoic-ad-categories', __items);
667
+ injectBetween(
655
668
  'ezoic-ad-categories',
656
- getCategoryItems(),
669
+ __items,
657
670
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
658
671
  normalizeBool(cfg.showFirstCategoryAd),
659
672
  state.allCategories,
@@ -675,19 +688,14 @@
675
688
  }
676
689
 
677
690
  // ---------- observers / lifecycle ----------
691
+
678
692
  function cleanup() {
679
- softBlock(2000);
693
+ blockedUntil = Date.now() + 1200;
680
694
 
681
695
  // remove all wrappers
682
696
  try {
683
- withInternalDomChange(() => {
684
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
685
- try {
686
- const id = parseInt(el.getAttribute('data-ezoic-wrapid') || '', 10);
687
- if (Number.isFinite(id) && id > 0) destroyPlaceholderId(id);
688
- } catch (e) {}
689
- try { el.remove(); } catch (e) {}
690
- });
697
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
698
+ try { el.remove(); } catch (e) {}
691
699
  });
692
700
  } catch (e) {}
693
701
 
@@ -699,20 +707,18 @@
699
707
  state.curTopics = 0;
700
708
  state.curPosts = 0;
701
709
  state.curCategories = 0;
702
-
703
710
  state.lastShowById.clear();
711
+ try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
704
712
  state.heroDoneForPage = false;
705
- state.lastRecycleAt.topic = 0;
706
- state.lastRecycleAt.categoryTopics = 0;
707
- state.lastRecycleAt.categories = 0;
713
+
714
+ // keep observers alive (MutationObserver will re-trigger after navigation)
708
715
  }
709
716
 
710
717
  function ensureDomObserver() {
711
718
  if (state.domObs) return;
712
719
  state.domObs = new MutationObserver(() => {
713
720
  if (state.internalDomChange > 0) return;
714
- if (isBlocked()) return;
715
- scheduleRun();
721
+ if (!isBlocked()) scheduleRun();
716
722
  });
717
723
  try {
718
724
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -730,7 +736,7 @@
730
736
 
731
737
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
732
738
  state.pageKey = getPageKey();
733
- softBlock(500);
739
+ blockedUntil = 0;
734
740
 
735
741
  warmUpNetwork();
736
742
  patchShowAds();
@@ -764,6 +770,7 @@
764
770
  }
765
771
 
766
772
  // ---------- boot ----------
773
+
767
774
  state.pageKey = getPageKey();
768
775
  warmUpNetwork();
769
776
  patchShowAds();
@@ -774,7 +781,7 @@
774
781
  bindScroll();
775
782
 
776
783
  // First paint: try hero + run
777
- softBlock(300);
784
+ blockedUntil = 0;
778
785
  insertHeroAdEarly().catch(() => {});
779
786
  scheduleRun();
780
- })();
787
+ })();
package/public/style.css CHANGED
@@ -23,11 +23,12 @@
23
23
 
24
24
  /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
25
25
  .ezoic-ad.is-empty {
26
- display: none !important;
26
+ display: block !important;
27
27
  margin: 0 !important;
28
28
  padding: 0 !important;
29
29
  height: 0 !important;
30
30
  min-height: 0 !important;
31
+ overflow: hidden !important;
31
32
  }
32
33
 
33
34
  .ezoic-ad {