nodebb-plugin-ezoic-infinite 1.2.5 → 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 +118 -125
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.2.5",
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,42 +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
 
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
+ }
355
+ }
356
+
370
357
  function cleanup() {
358
+ destroyUsedPlaceholders();
359
+
371
360
  state.pageKey = getPageKey();
372
361
  state.cfg = null;
373
362
  state.cfgPromise = null;
@@ -377,12 +366,11 @@
377
366
  state.usedTopics.clear();
378
367
  state.usedPosts.clear();
379
368
 
380
- state.seenTopicAnchors = new WeakSet();
381
- state.seenPostAnchors = new WeakSet();
382
-
383
369
  state.lastShowById = new Map();
384
370
  state.pendingById = new Set();
385
371
 
372
+ state.attempts = 0;
373
+
386
374
  document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
387
375
 
388
376
  if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; }
@@ -395,7 +383,7 @@
395
383
  if (state.obs) return;
396
384
  state.obs = new MutationObserver(() => scheduleRun('dom-mutation'));
397
385
  try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
398
- 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);
399
387
  }
400
388
 
401
389
  async function runCore() {
@@ -414,64 +402,69 @@
414
402
 
415
403
  enforceNoAdjacentAds();
416
404
 
417
- // 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
+
418
413
  if (inserted >= MAX_INSERTS_PER_RUN) {
419
- setTimeout(() => scheduleRun('continue-fill'), 120);
414
+ setTimeout(() => scheduleRun('continue-fill'), 140);
420
415
  }
421
416
  }
422
417
 
423
- function scheduleRun(reason) {
418
+ function scheduleRun() {
424
419
  if (state.scheduled) return;
425
420
  state.scheduled = true;
426
421
 
427
422
  clearTimeout(state.timer);
428
423
  state.timer = setTimeout(() => {
429
424
  state.scheduled = false;
430
- // Ensure we're still on same page
431
425
  const pk = getPageKey();
432
426
  if (state.pageKey && pk !== state.pageKey) return;
433
427
  runCore().catch(() => {});
434
428
  }, 80);
429
+ }
435
430
 
436
431
  function bind() {
437
432
  if (!$) return;
438
433
 
439
434
  $(window).off('.ezoicInfinite');
440
435
 
441
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
442
- cleanup();
443
- });
436
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
444
437
 
445
438
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
446
439
  state.pageKey = getPageKey();
447
440
  ensureObserver();
448
- scheduleRun('ajaxify.end');
449
- setTimeout(() => scheduleRun('ajaxify.end+250'), 250);
450
- setTimeout(() => scheduleRun('ajaxify.end+800'), 800);
441
+ scheduleRun();
442
+ setTimeout(scheduleRun, 200);
443
+ setTimeout(scheduleRun, 700);
451
444
  });
452
445
 
453
446
  $(window).on('action:category.loaded.ezoicInfinite', () => {
454
447
  ensureObserver();
455
- scheduleRun('category.loaded');
456
- setTimeout(() => scheduleRun('category.loaded+250'), 250);
448
+ scheduleRun();
449
+ setTimeout(scheduleRun, 250);
457
450
  });
458
451
 
459
452
  $(window).on('action:topics.loaded.ezoicInfinite', () => {
460
453
  ensureObserver();
461
- scheduleRun('topics.loaded');
462
- setTimeout(() => scheduleRun('topics.loaded+150'), 150);
454
+ scheduleRun();
455
+ setTimeout(scheduleRun, 150);
463
456
  });
464
457
 
465
458
  $(window).on('action:topic.loaded.ezoicInfinite', () => {
466
459
  ensureObserver();
467
- scheduleRun('topic.loaded');
468
- setTimeout(() => scheduleRun('topic.loaded+200'), 200);
460
+ scheduleRun();
461
+ setTimeout(scheduleRun, 200);
469
462
  });
470
463
 
471
464
  $(window).on('action:posts.loaded.ezoicInfinite', () => {
472
465
  ensureObserver();
473
- scheduleRun('posts.loaded');
474
- setTimeout(() => scheduleRun('posts.loaded+150'), 150);
466
+ scheduleRun();
467
+ setTimeout(scheduleRun, 150);
475
468
  });
476
469
  }
477
470
 
@@ -480,6 +473,6 @@
480
473
  bind();
481
474
  ensureObserver();
482
475
  state.pageKey = getPageKey();
483
- scheduleRun('boot');
484
- setTimeout(() => scheduleRun('boot+250'), 250);
476
+ scheduleRun();
477
+ setTimeout(scheduleRun, 250);
485
478
  })();