nodebb-plugin-ezoic-infinite 1.2.6 → 1.3.0

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 +115 -156
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
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
@@ -2,9 +2,6 @@
2
2
  (function () {
3
3
  'use strict';
4
4
 
5
- // Optional debug switch
6
- const DEBUG = !!window.__ezoicInfiniteDebug;
7
-
8
5
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
9
6
 
10
7
  const SELECTORS = {
@@ -15,7 +12,6 @@
15
12
  const WRAP_CLASS = 'ezoic-ad';
16
13
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
17
14
 
18
- // Prevent “burst” injection: at most N inserts per run per kind
19
15
  const MAX_INSERTS_PER_RUN = 2;
20
16
 
21
17
  const state = {
@@ -23,33 +19,22 @@
23
19
  cfg: null,
24
20
  cfgPromise: null,
25
21
 
26
- // Per-page pools (refilled by recycling)
27
22
  poolTopics: [],
28
23
  poolPosts: [],
29
24
 
30
- // Track inserted ads per page
31
25
  usedTopics: new Set(),
32
26
  usedPosts: new Set(),
33
27
 
34
- // Track which anchors we already evaluated to avoid reprocessing everything on each event
35
- seenTopicAnchors: new WeakSet(),
36
- seenPostAnchors: new WeakSet(),
37
-
38
- // showAds anti-double
39
28
  lastShowById: new Map(),
40
29
  pendingById: new Set(),
41
30
 
42
- // debounce
43
31
  scheduled: false,
44
32
  timer: null,
45
33
 
46
- // observers
47
34
  obs: null,
35
+ attempts: 0,
48
36
  };
49
37
 
50
- function log(...args) { if (DEBUG) console.log('[ezoic-infinite]', ...args); }
51
- function warn(...args) { if (DEBUG) console.warn('[ezoic-infinite]', ...args); }
52
-
53
38
  function normalizeBool(v) {
54
39
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
55
40
  }
@@ -88,88 +73,77 @@
88
73
  const p = window.location.pathname || '';
89
74
  if (/^\/topic\//.test(p)) return 'topic';
90
75
  if (/^\/category\//.test(p)) return 'category';
91
- // fallback
92
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
76
+ if (document.querySelector('[component="topic"]') || document.querySelector('[component="post"][data-pid]')) return 'topic';
93
77
  return 'category';
94
78
  }
95
79
 
96
-
97
80
  function getTopicItems() {
98
81
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
99
82
  }
100
83
 
101
84
  function getPostContainers() {
102
- // Harmony can include multiple [component="post"] blocks (e.g. parent previews, nested structures).
103
- // We only want top-level post containers that actually contain post content.
104
85
  const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
105
86
  return nodes.filter((el) => {
106
87
  if (!el || !el.isConnected) return false;
107
-
108
- // Must contain post content
109
88
  if (!el.querySelector('[component="post/content"]')) return false;
110
-
111
- // Must not be nested within another post container
112
89
  const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
113
90
  if (parentPost && parentPost !== el) return false;
114
-
115
- // Avoid "parent/quote" blocks that use component="post/parent"
116
91
  if (el.getAttribute('component') === 'post/parent') return false;
117
-
118
92
  return true;
119
93
  });
120
94
  }
121
95
 
122
- function hasAdImmediatelyAfter(el) {
123
- const n = el && el.nextElementSibling;
124
- return !!(n && n.classList && n.classList.contains(WRAP_CLASS));
125
- }
126
-
127
- function enforceNoAdjacentAds() {
128
- // NodeBB can virtualize (remove) topics/posts from the DOM while keeping our ad wrappers,
129
- // which can temporarily make two ad wrappers adjacent. Hide the later one to avoid back-to-back ads.
130
- const ads = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
131
- for (let i = 0; i < ads.length; i++) {
132
- const ad = ads[i];
133
- const prev = ad.previousElementSibling;
134
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
135
- ad.style.display = 'none';
136
- } else {
137
- ad.style.display = '';
138
- }
139
- }
140
- }
141
-
142
-
143
- const n = el && el.nextElementSibling;
144
- return !!(n && n.classList && n.classList.contains(WRAP_CLASS));
145
- }
146
-
147
96
  function buildWrap(id, kind, afterPos) {
148
97
  const wrap = document.createElement('div');
149
98
  wrap.className = `${WRAP_CLASS} ${kind}`;
150
99
  wrap.setAttribute('data-ezoic-after', String(afterPos));
151
100
  wrap.style.width = '100%';
101
+
152
102
  const ph = document.createElement('div');
153
103
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
154
104
  wrap.appendChild(ph);
155
105
  return wrap;
156
106
  }
157
107
 
158
- function insertAfter(target, id, kind, afterPos) {
108
+ function findWrap(kindClass, afterPos) {
109
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
110
+ }
111
+
112
+ function insertAfter(target, id, kindClass, afterPos) {
159
113
  if (!target || !target.insertAdjacentElement) return null;
160
- const wrap = buildWrap(id, kind, afterPos);
114
+ if (findWrap(kindClass, afterPos)) return null;
115
+ const wrap = buildWrap(id, kindClass, afterPos);
161
116
  target.insertAdjacentElement('afterend', wrap);
162
117
  return wrap;
163
118
  }
164
119
 
165
- function safeRect(el) {
166
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
167
- }
120
+ function destroyUsedPlaceholders() {
121
+ const ids = [];
122
+ try {
123
+ state.usedTopics.forEach((id) => ids.push(id));
124
+ state.usedPosts.forEach((id) => ids.push(id));
125
+ } catch (e) {}
126
+
127
+ if (!ids.length) return;
128
+
129
+ const call = () => {
130
+ try {
131
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
132
+ window.ezstandalone.destroyPlaceholders(ids);
133
+ }
134
+ } catch (e) {}
135
+ };
136
+
137
+ try {
138
+ window.ezstandalone = window.ezstandalone || {};
139
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
140
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
141
+ else window.ezstandalone.cmd.push(call);
168
142
  } catch (e) {}
169
143
  }
170
144
 
171
- // Patch ezstandalone.showAds to split batch calls (we NEVER call batch from this plugin)
172
145
  function patchShowAds() {
146
+ // Minimal safety net: batch showAds can be triggered by other scripts; split into individual calls.
173
147
  try {
174
148
  window.ezstandalone = window.ezstandalone || {};
175
149
  const ez = window.ezstandalone;
@@ -179,11 +153,13 @@
179
153
  ez.__nodebbEzoicPatched = true;
180
154
  const orig = ez.showAds;
181
155
 
182
- ez.showAds = function patchedShowAds(arg) {
156
+ ez.showAds = function (arg) {
183
157
  if (Array.isArray(arg)) {
184
- try { warn('showAds(batch) detected. Splitting…'); } catch (e) {}
185
- const ids = uniqInts(arg);
186
- for (const id of ids) {
158
+ const seen = new Set();
159
+ for (const v of arg) {
160
+ const id = parseInt(v, 10);
161
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
162
+ seen.add(id);
187
163
  try { orig.call(ez, id); } catch (e) {}
188
164
  }
189
165
  return;
@@ -236,7 +212,6 @@
236
212
  } catch (e) {}
237
213
  });
238
214
 
239
- // short retries
240
215
  let tries = 0;
241
216
  (function tick() {
242
217
  tries += 1;
@@ -249,17 +224,14 @@
249
224
  return;
250
225
  }
251
226
 
252
- if (attempts < 40) setTimeout(waitForPh, 50);
227
+ if (attempts < 50) setTimeout(waitForPh, 50);
253
228
  })();
254
229
  }
255
- }
256
- return false;
257
- }
258
230
 
259
231
  function nextId(kind) {
260
232
  const pool = (kind === 'between') ? state.poolTopics : state.poolPosts;
261
233
  if (pool.length) return pool.shift();
262
- return null; // stop injecting when pool is empty
234
+ return null;
263
235
  }
264
236
 
265
237
  async function fetchConfig() {
@@ -283,47 +255,54 @@
283
255
  }
284
256
 
285
257
  function initPools(cfg) {
286
- // Re-init pools once per page
287
258
  if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
288
259
  if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
289
260
  }
290
261
 
262
+ function computeTargets(count, interval, showFirst) {
263
+ const out = [];
264
+ if (count <= 0) return out;
265
+ if (showFirst) out.push(1);
266
+ for (let i = 1; i <= count; i++) {
267
+ if (i % interval === 0) out.push(i);
268
+ }
269
+ // uniq + sort
270
+ return Array.from(new Set(out)).sort((a, b) => a - b);
271
+ }
272
+
291
273
  function injectTopics(cfg) {
292
274
  if (!normalizeBool(cfg.enableBetweenAds)) return 0;
293
275
 
294
276
  const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 6);
295
- const first = normalizeBool(cfg.showFirstTopicAd);
277
+ const showFirst = normalizeBool(cfg.showFirstTopicAd);
296
278
 
297
279
  const items = getTopicItems();
298
280
  if (!items.length) return 0;
299
281
 
282
+ const targets = computeTargets(items.length, interval, showFirst);
283
+
300
284
  let inserted = 0;
301
- for (let i = 0; i < items.length; i++) {
302
- const li = items[i];
303
- if (!li || !li.isConnected) continue;
285
+ for (const afterPos of targets) {
286
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
304
287
 
305
- // Avoid re-processing anchors already evaluated
306
- if (state.seenTopicAnchors.has(li)) continue;
288
+ const li = items[afterPos - 1];
289
+ if (!li || !li.isConnected) continue;
307
290
 
308
- const pos = i + 1;
309
- const ok = (first && pos === 1) || (pos % interval === 0);
310
- state.seenTopicAnchors.add(li);
311
- if (!ok) continue;
291
+ // Avoid back-to-back ads at load: if afterPos-1 already has an ad wrapper, skip
292
+ const prevWrap = findWrap('ezoic-ad-between', afterPos - 1);
293
+ if (prevWrap) continue;
312
294
 
313
- if (hasAdImmediatelyAfter(li)) continue;
295
+ if (findWrap('ezoic-ad-between', afterPos)) continue;
314
296
 
315
297
  const id = nextId('between');
316
298
  if (!id) break;
317
299
 
318
300
  state.usedTopics.add(id);
319
- const wrap = insertAfter(li, id, 'ezoic-ad-between', pos);
301
+ const wrap = insertAfter(li, id, 'ezoic-ad-between', afterPos);
320
302
  if (!wrap) continue;
321
303
 
322
- inserted += 1;
323
-
324
304
  callShowAdsWhenReady(id);
325
-
326
- if (inserted >= MAX_INSERTS_PER_RUN) break;
305
+ inserted += 1;
327
306
  }
328
307
  return inserted;
329
308
  }
@@ -332,75 +311,52 @@
332
311
  if (!normalizeBool(cfg.enableMessageAds)) return 0;
333
312
 
334
313
  const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
335
- const first = normalizeBool(cfg.showFirstMessageAd);
314
+ const showFirst = normalizeBool(cfg.showFirstMessageAd);
336
315
 
337
316
  const posts = getPostContainers();
338
317
  if (!posts.length) return 0;
339
318
 
319
+ const targets = computeTargets(posts.length, interval, showFirst);
320
+
340
321
  let inserted = 0;
341
- for (let i = 0; i < posts.length; i++) {
342
- const post = posts[i];
343
- if (!post || !post.isConnected) continue;
322
+ for (const afterPos of targets) {
323
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
344
324
 
345
- if (state.seenPostAnchors.has(post)) continue;
325
+ const post = posts[afterPos - 1];
326
+ if (!post || !post.isConnected) continue;
346
327
 
347
- const no = i + 1;
348
- const ok = (first && no === 1) || (no % interval === 0);
349
- state.seenPostAnchors.add(post);
350
- if (!ok) continue;
328
+ const prevWrap = findWrap('ezoic-ad-message', afterPos - 1);
329
+ if (prevWrap) continue;
351
330
 
352
- if (hasAdImmediatelyAfter(post)) continue;
331
+ if (findWrap('ezoic-ad-message', afterPos)) continue;
353
332
 
354
333
  const id = nextId('message');
355
334
  if (!id) break;
356
335
 
357
336
  state.usedPosts.add(id);
358
- const wrap = insertAfter(post, id, 'ezoic-ad-message', no);
337
+ const wrap = insertAfter(post, id, 'ezoic-ad-message', afterPos);
359
338
  if (!wrap) continue;
360
339
 
361
- inserted += 1;
362
-
363
340
  callShowAdsWhenReady(id);
364
-
365
- if (inserted >= MAX_INSERTS_PER_RUN) break;
341
+ inserted += 1;
366
342
  }
367
343
  return inserted;
368
344
  }
369
345
 
370
-
371
- function destroyUsedPlaceholders() {
372
- const ids = [];
373
- try {
374
- state.usedTopics.forEach((id) => ids.push(id));
375
- state.usedPosts.forEach((id) => ids.push(id));
376
- } catch (e) {}
377
-
378
- if (!ids.length) return;
379
-
380
- const call = () => {
381
- try {
382
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
383
- window.ezstandalone.destroyPlaceholders(ids);
384
- }
385
- } catch (e) {}
386
- };
387
-
388
- try {
389
- window.ezstandalone = window.ezstandalone || {};
390
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
391
-
392
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
393
- call();
394
- } else {
395
- // queue for when ezstandalone becomes ready
396
- window.ezstandalone.cmd.push(call);
397
- }
398
- } catch (e) {}
346
+ function enforceNoAdjacentAds() {
347
+ // If DOM changes produce adjacent wrappers (rare but possible), hide the later one.
348
+ const ads = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
349
+ for (let i = 0; i < ads.length; i++) {
350
+ const ad = ads[i];
351
+ const prev = ad.previousElementSibling;
352
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) ad.style.display = 'none';
353
+ else ad.style.display = '';
354
+ }
399
355
  }
400
356
 
401
357
  function cleanup() {
402
- // Destroy slots for IDs we used on the previous view before we reuse the same IDs on the next page
403
358
  destroyUsedPlaceholders();
359
+
404
360
  state.pageKey = getPageKey();
405
361
  state.cfg = null;
406
362
  state.cfgPromise = null;
@@ -410,12 +366,11 @@
410
366
  state.usedTopics.clear();
411
367
  state.usedPosts.clear();
412
368
 
413
- state.seenTopicAnchors = new WeakSet();
414
- state.seenPostAnchors = new WeakSet();
415
-
416
369
  state.lastShowById = new Map();
417
370
  state.pendingById = new Set();
418
371
 
372
+ state.attempts = 0;
373
+
419
374
  document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
420
375
 
421
376
  if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; }
@@ -428,12 +383,11 @@
428
383
  if (state.obs) return;
429
384
  state.obs = new MutationObserver(() => scheduleRun('dom-mutation'));
430
385
  try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
431
- setTimeout(() => { if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; } }, 12000);
386
+ setTimeout(() => { if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; } }, 15000);
432
387
  }
433
388
 
434
389
  async function runCore() {
435
390
  patchShowAds();
436
- patchShowAds();
437
391
 
438
392
  const cfg = await fetchConfig();
439
393
  if (!cfg || cfg.excluded) return;
@@ -448,64 +402,69 @@
448
402
 
449
403
  enforceNoAdjacentAds();
450
404
 
451
- // If we inserted max per run, schedule another pass to gradually fill (avoids “burst”)
405
+ // If nothing inserted and we have no items yet (first click), retry a few times
406
+ const itemCount = (kind === 'topic') ? getPostContainers().length : getTopicItems().length;
407
+ if (itemCount === 0 && state.attempts < 25) {
408
+ state.attempts += 1;
409
+ setTimeout(() => scheduleRun('await-items'), 120);
410
+ return;
411
+ }
412
+
452
413
  if (inserted >= MAX_INSERTS_PER_RUN) {
453
- setTimeout(() => scheduleRun('continue-fill'), 120);
414
+ setTimeout(() => scheduleRun('continue-fill'), 140);
454
415
  }
455
416
  }
456
417
 
457
- function scheduleRun(reason) {
418
+ function scheduleRun() {
458
419
  if (state.scheduled) return;
459
420
  state.scheduled = true;
460
421
 
461
422
  clearTimeout(state.timer);
462
423
  state.timer = setTimeout(() => {
463
424
  state.scheduled = false;
464
- // Ensure we're still on same page
465
425
  const pk = getPageKey();
466
426
  if (state.pageKey && pk !== state.pageKey) return;
467
427
  runCore().catch(() => {});
468
428
  }, 80);
429
+ }
469
430
 
470
431
  function bind() {
471
432
  if (!$) return;
472
433
 
473
434
  $(window).off('.ezoicInfinite');
474
435
 
475
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
476
- cleanup();
477
- });
436
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
478
437
 
479
438
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
480
439
  state.pageKey = getPageKey();
481
440
  ensureObserver();
482
- scheduleRun('ajaxify.end');
483
- setTimeout(() => scheduleRun('ajaxify.end+250'), 250);
484
- setTimeout(() => scheduleRun('ajaxify.end+800'), 800);
441
+ scheduleRun();
442
+ setTimeout(scheduleRun, 200);
443
+ setTimeout(scheduleRun, 700);
485
444
  });
486
445
 
487
446
  $(window).on('action:category.loaded.ezoicInfinite', () => {
488
447
  ensureObserver();
489
- scheduleRun('category.loaded');
490
- setTimeout(() => scheduleRun('category.loaded+250'), 250);
448
+ scheduleRun();
449
+ setTimeout(scheduleRun, 250);
491
450
  });
492
451
 
493
452
  $(window).on('action:topics.loaded.ezoicInfinite', () => {
494
453
  ensureObserver();
495
- scheduleRun('topics.loaded');
496
- setTimeout(() => scheduleRun('topics.loaded+150'), 150);
454
+ scheduleRun();
455
+ setTimeout(scheduleRun, 150);
497
456
  });
498
457
 
499
458
  $(window).on('action:topic.loaded.ezoicInfinite', () => {
500
459
  ensureObserver();
501
- scheduleRun('topic.loaded');
502
- setTimeout(() => scheduleRun('topic.loaded+200'), 200);
460
+ scheduleRun();
461
+ setTimeout(scheduleRun, 200);
503
462
  });
504
463
 
505
464
  $(window).on('action:posts.loaded.ezoicInfinite', () => {
506
465
  ensureObserver();
507
- scheduleRun('posts.loaded');
508
- setTimeout(() => scheduleRun('posts.loaded+150'), 150);
466
+ scheduleRun();
467
+ setTimeout(scheduleRun, 150);
509
468
  });
510
469
  }
511
470
 
@@ -514,6 +473,6 @@
514
473
  bind();
515
474
  ensureObserver();
516
475
  state.pageKey = getPageKey();
517
- scheduleRun('boot');
518
- setTimeout(() => scheduleRun('boot+250'), 250);
476
+ scheduleRun();
477
+ setTimeout(scheduleRun, 250);
519
478
  })();