nodebb-plugin-ezoic-infinite 1.5.28 → 1.5.29

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 +234 -234
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.29",
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,7 +259,66 @@
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
+ const prev = wrap.previousElementSibling;
290
+ if (!prev || !itemSet.has(prev)) {
291
+ const id = getWrapIdFromWrap(wrap);
292
+ withInternalDomChange(() => {
293
+ try {
294
+ if (id) safeDestroyById(id);
295
+ wrap.remove();
296
+ } catch (e) {}
297
+ });
298
+ removed++;
299
+ }
300
+ });
301
+
302
+ if (removed) dbg('prune-orphan', kindClass, { removed });
303
+ return removed;
304
+ }
305
+
306
+ function refreshEmptyState(id) {
307
+ // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
308
+ window.setTimeout(() => {
309
+ try {
310
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
311
+ if (!ph || !ph.isConnected) return;
312
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
313
+ if (!wrap) return;
314
+ const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
315
+ if (hasContent) wrap.classList.remove('is-empty');
316
+ else wrap.classList.add('is-empty');
317
+ } catch (e) {}
318
+ }, 1800);
319
+ }
320
+
321
+ function buildWrap(id, kindClass, afterPos) {
259
322
  const wrap = document.createElement('div');
260
323
  wrap.className = `${WRAP_CLASS} ${kindClass} is-empty`;
261
324
  wrap.setAttribute('data-ezoic-after', String(afterPos));
@@ -277,68 +340,26 @@
277
340
  function insertAfter(target, id, kindClass, afterPos) {
278
341
  if (!target || !target.insertAdjacentElement) return null;
279
342
  if (findWrap(kindClass, afterPos)) return null;
343
+ if (insertingIds.has(id)) return null;
280
344
 
281
345
  const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
282
346
  if (existingPh && existingPh.isConnected) return null;
283
347
 
284
- return withInternalDomChange(() => {
348
+ insertingIds.add(id);
349
+ try {
285
350
  const wrap = buildWrap(id, kindClass, afterPos);
286
351
  target.insertAdjacentElement('afterend', wrap);
287
352
  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;
353
+ } finally {
354
+ insertingIds.delete(id);
355
+ }
335
356
  }
336
357
 
337
358
  function pickIdFromAll(allIds, cursorKey) {
338
359
  const n = allIds.length;
339
360
  if (!n) return null;
340
361
 
341
- // Try at most n IDs to find one not currently present
362
+ // Try at most n ids to find one that's not already in the DOM
342
363
  for (let tries = 0; tries < n; tries++) {
343
364
  const idx = state[cursorKey] % n;
344
365
  state[cursorKey] = (state[cursorKey] + 1) % n;
@@ -346,24 +367,38 @@
346
367
  const id = allIds[idx];
347
368
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
348
369
  if (ph && ph.isConnected) continue;
370
+
349
371
  return id;
350
372
  }
351
373
  return null;
352
374
  }
353
375
 
354
- function markEmptyLater(id) {
355
- // Collapse empty blocks if ad never fills
356
- window.setTimeout(() => {
376
+
377
+ function removeOneOldWrap(kindClass) {
378
+ try {
379
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
380
+ if (!wraps.length) return false;
381
+
382
+ // Prefer a wrap far above the viewport
383
+ let victim = null;
384
+ for (const w of wraps) {
385
+ const r = w.getBoundingClientRect();
386
+ if (r.bottom < -2000) { victim = w; break; }
387
+ }
388
+ // Otherwise remove the earliest one in the document
389
+ if (!victim) victim = wraps[0];
390
+
391
+ // Unobserve placeholder if still observed
357
392
  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');
393
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
394
+ if (ph && state.io) state.io.unobserve(ph);
365
395
  } catch (e) {}
366
- }, 1800);
396
+
397
+ victim.remove();
398
+ return true;
399
+ } catch (e) {
400
+ return false;
401
+ }
367
402
  }
368
403
 
369
404
  function showAd(id) {
@@ -373,27 +408,36 @@
373
408
  const last = state.lastShowById.get(id) || 0;
374
409
  if (now - last < 1500) return; // basic throttle
375
410
 
376
- const doShow = () => {
411
+ // Defer one frame so the placeholder is definitely in DOM after insertion/recycle
412
+ requestAnimationFrame(() => {
377
413
  if (isBlocked()) return;
414
+
378
415
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
379
416
  if (!ph || !ph.isConnected) return;
380
417
 
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) {}
418
+ const now2 = Date.now();
419
+ const last2 = state.lastShowById.get(id) || 0;
420
+ if (now2 - last2 < 1200) return;
421
+ state.lastShowById.set(id, now2);
388
422
 
389
423
  try {
390
424
  window.ezstandalone = window.ezstandalone || {};
391
425
  const ez = window.ezstandalone;
392
426
 
427
+ const doShow = () => {
428
+ try {
429
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
430
+ // Avoid Ezoic caching state for reused placeholders
431
+ ez.destroyPlaceholders(id);
432
+ }
433
+ } catch (e) {}
434
+ try { ez.showAds(id); } catch (e) {}
435
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
436
+ };
437
+
393
438
  // Fast path
394
439
  if (typeof ez.showAds === 'function') {
395
- ez.showAds(id);
396
- markEmptyLater(id);
440
+ doShow();
397
441
  return;
398
442
  }
399
443
 
@@ -406,19 +450,25 @@
406
450
  if (isBlocked()) return;
407
451
  const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
452
  if (!el || !el.isConnected) return;
409
- window.ezstandalone.showAds(id);
410
- markEmptyLater(id);
453
+ const ez2 = window.ezstandalone;
454
+ if (!ez2 || typeof ez2.showAds !== 'function') return;
455
+ try {
456
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez2.destroyPlaceholders === 'function') {
457
+ ez2.destroyPlaceholders(id);
458
+ }
459
+ } catch (e) {}
460
+ try { ez2.showAds(id); } catch (e) {}
461
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
411
462
  } catch (e) {}
412
463
  });
413
464
  }
414
465
  } catch (e) {}
415
- };
416
-
417
- // Defer one frame to reduce "element does not exist" warnings
418
- window.requestAnimationFrame(doShow);
466
+ });
419
467
  }
420
468
 
469
+
421
470
  // ---------- preload / above-the-fold ----------
471
+
422
472
  function ensurePreloadObserver() {
423
473
  if (state.io) return state.io;
424
474
  try {
@@ -452,72 +502,22 @@
452
502
  } catch (e) {}
453
503
  }
454
504
 
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
- }
505
+ // ---------- insertion logic ----------
487
506
 
488
- function computeTargetsInRange(fromPos, toPos, interval, showFirst) {
507
+ function computeTargets(count, interval, showFirst) {
489
508
  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;
509
+ if (count <= 0) return out;
510
+ if (showFirst) out.push(1);
511
+ for (let i = 1; i <= count; i++) {
512
+ if (i % interval === 0) out.push(i);
513
+ }
514
+ return Array.from(new Set(out)).sort((a, b) => a - b);
498
515
  }
499
516
 
500
- function injectWindowed(kind, kindClass, items, interval, showFirst, allIds, cursorKey) {
517
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
501
518
  if (!items.length) return 0;
502
519
 
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);
520
+ const targets = computeTargets(items.length, interval, showFirst);
521
521
  let inserted = 0;
522
522
 
523
523
  for (const afterPos of targets) {
@@ -529,25 +529,25 @@
529
529
  if (findWrap(kindClass, afterPos)) continue;
530
530
 
531
531
  let id = pickIdFromAll(allIds, cursorKey);
532
-
533
- // If no ID is currently free, try a purge (only occasionally) then try again
534
532
  if (!id) {
535
- if (!canRecycle(kind)) {
536
- debug('recycle-skip-cooldown', kindClass);
533
+ // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
534
+ // Guard against tight observer loops.
535
+ if (!canRecycle(kindKeyFromClass(kindClass))) {
536
+ dbg('recycle-skip-cooldown', kindClass);
537
537
  break;
538
538
  }
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);
539
+ let recycled = false;
540
+ withInternalDomChange(() => {
541
+ recycled = removeOneOldWrap(kindClass);
542
+ });
543
+ dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
544
+ // Stop this run after a recycle; the next mutation/scroll will retry injection.
546
545
  break;
547
546
  }
548
-
549
547
  const wrap = insertAfter(el, id, kindClass, afterPos);
550
- if (!wrap) continue;
548
+ if (!wrap) {
549
+ continue;
550
+ }
551
551
 
552
552
  observePlaceholder(id);
553
553
  inserted += 1;
@@ -559,9 +559,10 @@
559
559
  async function insertHeroAdEarly() {
560
560
  if (state.heroDoneForPage) return;
561
561
  const cfg = await fetchConfigOnce();
562
- if (!cfg || cfg.excluded) return;
562
+ if (!cfg) { dbg('no-config'); return; }
563
+ if (cfg.excluded) { dbg('excluded'); return; }
563
564
 
564
- initIds(cfg);
565
+ initPools(cfg);
565
566
 
566
567
  const kind = getKind();
567
568
  let items = [];
@@ -571,32 +572,31 @@
571
572
  let showFirst = false;
572
573
 
573
574
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
574
- showFirst = normalizeBool(cfg.showFirstMessageAd);
575
- if (!showFirst) return;
576
575
  items = getPostContainers();
577
576
  allIds = state.allPosts;
578
577
  cursorKey = 'curPosts';
579
578
  kindClass = 'ezoic-ad-message';
579
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
580
580
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
581
- showFirst = normalizeBool(cfg.showFirstTopicAd);
582
- if (!showFirst) return;
583
581
  items = getTopicItems();
584
582
  allIds = state.allTopics;
585
583
  cursorKey = 'curTopics';
586
584
  kindClass = 'ezoic-ad-between';
585
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
587
586
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
588
- showFirst = normalizeBool(cfg.showFirstCategoryAd);
589
- if (!showFirst) return;
590
587
  items = getCategoryItems();
591
588
  allIds = state.allCategories;
592
589
  cursorKey = 'curCategories';
593
590
  kindClass = 'ezoic-ad-categories';
591
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
594
592
  } else {
595
593
  return;
596
594
  }
597
595
 
598
596
  if (!items.length) return;
597
+ if (!showFirst) { state.heroDoneForPage = true; return; }
599
598
 
599
+ // Insert after the very first item (above-the-fold)
600
600
  const afterPos = 1;
601
601
  const el = items[afterPos - 1];
602
602
  if (!el || !el.isConnected) return;
@@ -607,29 +607,33 @@
607
607
  if (!id) return;
608
608
 
609
609
  const wrap = insertAfter(el, id, kindClass, afterPos);
610
- if (!wrap) return;
610
+ if (!wrap) {
611
+ return;
612
+ }
611
613
 
612
614
  state.heroDoneForPage = true;
613
615
  observePlaceholder(id);
614
616
  }
615
617
 
616
618
  async function runCore() {
617
- if (isBlocked()) return;
619
+ if (isBlocked()) { dbg('blocked'); return; }
618
620
 
619
621
  patchShowAds();
620
622
 
621
623
  const cfg = await fetchConfigOnce();
622
- if (!cfg || cfg.excluded) return;
623
- initIds(cfg);
624
+ if (!cfg) { dbg('no-config'); return; }
625
+ if (cfg.excluded) { dbg('excluded'); return; }
626
+ initPools(cfg);
624
627
 
625
628
  const kind = getKind();
626
629
 
627
630
  if (kind === 'topic') {
628
631
  if (normalizeBool(cfg.enableMessageAds)) {
629
- injectWindowed(
630
- 'topic',
632
+ const __items = getPostContainers();
633
+ pruneOrphanWraps('ezoic-ad-message', __items);
634
+ injectBetween(
631
635
  'ezoic-ad-message',
632
- getPostContainers(),
636
+ __items,
633
637
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
634
638
  normalizeBool(cfg.showFirstMessageAd),
635
639
  state.allPosts,
@@ -638,10 +642,11 @@
638
642
  }
639
643
  } else if (kind === 'categoryTopics') {
640
644
  if (normalizeBool(cfg.enableBetweenAds)) {
641
- injectWindowed(
642
- 'categoryTopics',
645
+ const __items = getTopicItems();
646
+ pruneOrphanWraps('ezoic-ad-between', __items);
647
+ injectBetween(
643
648
  'ezoic-ad-between',
644
- getTopicItems(),
649
+ __items,
645
650
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
646
651
  normalizeBool(cfg.showFirstTopicAd),
647
652
  state.allTopics,
@@ -650,10 +655,11 @@
650
655
  }
651
656
  } else if (kind === 'categories') {
652
657
  if (normalizeBool(cfg.enableCategoryAds)) {
653
- injectWindowed(
654
- 'categories',
658
+ const __items = getCategoryItems();
659
+ pruneOrphanWraps('ezoic-ad-categories', __items);
660
+ injectBetween(
655
661
  'ezoic-ad-categories',
656
- getCategoryItems(),
662
+ __items,
657
663
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
658
664
  normalizeBool(cfg.showFirstCategoryAd),
659
665
  state.allCategories,
@@ -675,19 +681,14 @@
675
681
  }
676
682
 
677
683
  // ---------- observers / lifecycle ----------
684
+
678
685
  function cleanup() {
679
- softBlock(2000);
686
+ blockedUntil = Date.now() + 1200;
680
687
 
681
688
  // remove all wrappers
682
689
  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
- });
690
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
691
+ try { el.remove(); } catch (e) {}
691
692
  });
692
693
  } catch (e) {}
693
694
 
@@ -699,20 +700,18 @@
699
700
  state.curTopics = 0;
700
701
  state.curPosts = 0;
701
702
  state.curCategories = 0;
702
-
703
703
  state.lastShowById.clear();
704
+ try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
704
705
  state.heroDoneForPage = false;
705
- state.lastRecycleAt.topic = 0;
706
- state.lastRecycleAt.categoryTopics = 0;
707
- state.lastRecycleAt.categories = 0;
706
+
707
+ // keep observers alive (MutationObserver will re-trigger after navigation)
708
708
  }
709
709
 
710
710
  function ensureDomObserver() {
711
711
  if (state.domObs) return;
712
712
  state.domObs = new MutationObserver(() => {
713
713
  if (state.internalDomChange > 0) return;
714
- if (isBlocked()) return;
715
- scheduleRun();
714
+ if (!isBlocked()) scheduleRun();
716
715
  });
717
716
  try {
718
717
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -730,7 +729,7 @@
730
729
 
731
730
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
732
731
  state.pageKey = getPageKey();
733
- softBlock(500);
732
+ blockedUntil = 0;
734
733
 
735
734
  warmUpNetwork();
736
735
  patchShowAds();
@@ -764,6 +763,7 @@
764
763
  }
765
764
 
766
765
  // ---------- boot ----------
766
+
767
767
  state.pageKey = getPageKey();
768
768
  warmUpNetwork();
769
769
  patchShowAds();
@@ -774,7 +774,7 @@
774
774
  bindScroll();
775
775
 
776
776
  // First paint: try hero + run
777
- softBlock(300);
777
+ blockedUntil = 0;
778
778
  insertHeroAdEarly().catch(() => {});
779
779
  scheduleRun();
780
- })();
780
+ })();