nodebb-plugin-ezoic-infinite 1.0.18 → 1.1.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 (3) hide show
  1. package/README.md +15 -0
  2. package/package.json +5 -4
  3. package/public/client.js +286 -382
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # NodeBB Plugin – Ezoic Infinite (Production)
2
+
3
+ This plugin injects Ezoic placeholders between topics and posts on NodeBB 4.x,
4
+ with full support for infinite scroll.
5
+
6
+ ## Key guarantees
7
+ - No duplicate ads back-to-back
8
+ - One showAds call per placeholder
9
+ - Fast reveal (MutationObserver on first child)
10
+ - Safe with ajaxify navigation
11
+ - Works with NodeBB 4.x + Harmony
12
+
13
+ ## Notes
14
+ - Placeholders must exist and be selected in Ezoic
15
+ - Use separate ID pools for topics vs messages
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.0.18",
4
- "description": "Ezoic ads with infinite scroll using a pool of placeholder IDs",
3
+ "version": "1.1.0",
4
+ "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -12,9 +12,10 @@
12
12
  "infinite-scroll"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "nodebb": ">=4.0.0"
16
16
  },
17
17
  "nbbpm": {
18
18
  "compatibility": "^4.0.0"
19
- }
19
+ },
20
+ "private": false
20
21
  }
package/public/client.js CHANGED
@@ -1,251 +1,150 @@
1
- 'use strict';
2
-
3
- /* globals ajaxify */
1
+ /* eslint-disable no-console */
4
2
  (function () {
5
- try {
6
- if (window.ezoicInfiniteLoaded) return;
7
- window.ezoicInfiniteLoaded = true;
8
-
9
- let cachedConfig;
10
- let lastFetch = 0;
11
- let debounceTimer;
12
-
13
- let inFlight = false;
14
- let rerunRequested = false;
15
-
16
- // per page state
17
- let pageKey = null;
18
-
19
- // separate pools/state
20
- let usedBetween = new Set(); // between topics list
21
- let usedMessage = new Set(); // between replies
22
- let fifoBetween = [];
23
- let fifoMessage = [];
24
- // reset serialized showAds queues
25
- try {
26
- if (typeof queues !== 'undefined') { queues.between.length = 0; queues.message.length = 0; }
27
- if (typeof queueState !== 'undefined') { queueState.between = false; queueState.message = false; }
28
- } catch (e) {}
29
-
30
- function getPageKey() {
31
- try {
32
- const ax = window.ajaxify;
33
- if (ax && ax.data) {
34
- if (ax.data.tid) return 'topic:' + ax.data.tid;
35
- if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
36
- }
37
- } catch (e) {}
38
- return window.location.pathname;
39
- }
40
-
41
- function isTopicPage() {
42
- try {
43
- const ax = window.ajaxify;
44
- return !!(ax && ax.data && ax.data.tid);
45
- } catch (e) {}
46
- return /^\/topic\//.test(window.location.pathname);
47
- }
48
-
49
- function isCategoryTopicList() {
50
- return document.querySelectorAll('li[component="category/topic"]').length > 0 && !isTopicPage();
51
- }
52
-
3
+ 'use strict';
4
+
5
+ // NodeBB client env provides jQuery. We keep it optional.
6
+ const $w = (typeof window.jQuery === 'function') ? window.jQuery(window) : null;
7
+
8
+ const SELECTORS = {
9
+ topicListItem: 'li[component="category/topic"]',
10
+ postItem: '[component="post"][data-pid]',
11
+ postContent: '[component="post/content"]',
12
+ };
13
+
14
+ const WRAP_CLASS = 'ezoic-ad';
15
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
16
+
17
+ const state = {
18
+ pageKey: null,
19
+ cfg: null,
20
+ cfgPromise: null,
21
+ // pools and used ids
22
+ usedBetween: new Set(),
23
+ usedMessage: new Set(),
24
+ fifoBetween: [],
25
+ fifoMessage: [],
26
+ // showAds anti-double
27
+ lastShowById: {},
28
+ pendingById: {},
29
+ // retry bookkeeping
30
+ retryCount: {},
31
+ observers: {},
32
+ };
53
33
 
54
34
  function normalizeBool(v) {
55
- return v === true || v === 1 || v === '1' || v === 'on' || v === 'true' || v === 'yes';
56
- }
57
-
58
- function normalizeInterval(v, fallback) {
59
- const n = parseInt(v, 10);
60
- return Number.isFinite(n) && n > 0 ? n : fallback;
61
- }
62
-
63
- function getPoolValue(v) {
64
- // Accept string, array of numbers/strings
65
- if (Array.isArray(v)) return v.join('\n');
66
- return v;
35
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
67
36
  }
68
37
 
69
38
  function parsePool(raw) {
70
- raw = getPoolValue(raw);
71
-
72
- raw = getPoolValue(raw);
73
-
74
39
  if (!raw) return [];
75
- // accept newline, comma, space, semicolon
76
- const arr = String(raw).split(/[\n,;\s]+/)
77
- .map(x => parseInt(x, 10))
78
- .filter(n => Number.isFinite(n) && n > 0);
79
- // unique while preserving order
80
- return Array.from(new Set(arr));
81
- }
40
+ const lines = String(raw)
41
+ .split(/\r?\n/)
42
+ .map(s => s.trim())
43
+ .filter(Boolean);
82
44
 
83
- async function fetchConfig() {
84
- if (cachedConfig && Date.now() - lastFetch < 10000) return cachedConfig;
85
- const base = (window.config && window.config.relative_path) ? window.config.relative_path : '';
86
- const res = await fetch(base + '/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
87
- const json = await res.json();
88
- cachedConfig = json;
89
- lastFetch = Date.now();
90
- return json;
91
- }
45
+ const ids = lines
46
+ .map(s => parseInt(s, 10))
47
+ .filter(n => Number.isFinite(n) && n > 0);
92
48
 
93
- function cleanupForNewPage() {
94
- document.querySelectorAll('.ezoic-ad').forEach(el => el.remove());
95
- usedBetween = new Set();
96
- usedMessage = new Set();
97
- fifoBetween = [];
98
- fifoMessage = [];
99
- // reset serialized showAds queues
100
- try {
101
- if (typeof queues !== 'undefined') { queues.between.length = 0; queues.message.length = 0; }
102
- if (typeof queueState !== 'undefined') { queueState.between = false; queueState.message = false; }
103
- } catch (e) {}
49
+ // unique preserve order
50
+ const out = [];
51
+ const seen = new Set();
52
+ for (const id of ids) {
53
+ if (!seen.has(id)) { seen.add(id); out.push(id); }
54
+ }
55
+ return out;
104
56
  }
105
57
 
106
- function destroyPlaceholder(id) {
58
+ function getPageKey() {
107
59
  try {
108
- window.ezstandalone = window.ezstandalone || {};
109
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
110
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
111
- window.ezstandalone.destroyPlaceholders(id);
112
- return;
60
+ const ax = window.ajaxify;
61
+ if (ax && ax.data) {
62
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
63
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
113
64
  }
114
- window.ezstandalone.cmd.push(function () {
115
- try { window.ezstandalone.destroyPlaceholders(id); } catch (e) {}
116
- });
117
65
  } catch (e) {}
66
+ return window.location.pathname;
118
67
  }
119
68
 
120
- function ensureUniquePlaceholder(id) {
121
- const existing = document.getElementById('ezoic-pub-ad-placeholder-' + id);
122
- if (!existing) return;
123
- const wrap = existing.closest('.ezoic-ad');
124
- if (wrap) wrap.remove();
125
- else existing.remove();
126
- destroyPlaceholder(id);
69
+ function getPageKind() {
70
+ const p = window.location.pathname || '';
71
+ if (/^\/topic\//.test(p)) return 'topic';
72
+ if (/^\/category\//.test(p)) return 'category';
73
+ // fallback hints
74
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
75
+ if (document.querySelector(SELECTORS.topicListItem)) return 'category';
76
+ return 'category';
127
77
  }
128
78
 
129
- // Ensure placeholders are filled in the same order they were inserted.
130
- // Ezoic can fill asynchronously; we serialize showAds calls per page-type queue.
131
- const queues = { between: [], message: [] };
132
- const queueState = { between: false, message: false };
133
-
134
- function isFilled(placeholderEl) {
135
- if (!placeholderEl) return false;
136
- if (placeholderEl.children && placeholderEl.children.length) return true;
137
- const html = placeholderEl.innerHTML || '';
138
- if (html.trim().length > 0) return true;
139
- const r = placeholderEl.getBoundingClientRect ? placeholderEl.getBoundingClientRect() : null;
140
- if (r && r.height > 10) return true;
141
- return false;
79
+ function safeGetRect(el) {
80
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
142
81
  }
143
82
 
144
- function waitForFilled(placeholderEl, timeoutMs) {
145
- return new Promise((resolve) => {
146
- if (!placeholderEl) return resolve(false);
147
- if (isFilled(placeholderEl)) return resolve(true);
148
-
149
- let done = false;
150
- const finish = (ok) => {
151
- if (done) return;
152
- done = true;
153
- try { obs.disconnect(); } catch (e) {}
154
- resolve(ok);
155
- };
156
-
157
- const obs = new MutationObserver(() => {
158
- if (isFilled(placeholderEl)) finish(true);
159
- });
160
-
161
- try {
162
- obs.observe(placeholderEl, { childList: true, subtree: true, attributes: true });
163
- } catch (e) {}
164
-
165
- setTimeout(() => finish(isFilled(placeholderEl)), timeoutMs);
166
- });
83
+ function hasAdImmediatelyAfter(targetEl) {
84
+ if (!targetEl) return false;
85
+ const next = targetEl.nextElementSibling;
86
+ return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
167
87
  }
168
88
 
169
- async function processQueue(kind) {
170
- if (queueState[kind]) return;
171
- queueState[kind] = true;
172
-
173
- try {
174
- while (queues[kind].length) {
175
- const item = queues[kind][0];
176
- const wrap = item.wrapper;
177
- const id = item.id;
178
-
179
- // if removed (recycled) before fill, drop it
180
- if (!wrap || !wrap.isConnected) {
181
- queues[kind].shift();
182
- continue;
183
- }
184
-
185
- // call showAds once
186
- callShowAdsSingle(id);
89
+ function buildWrap(id, kind, afterPos) {
90
+ const wrap = document.createElement('div');
91
+ wrap.className = `${WRAP_CLASS} ${kind}`;
92
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
93
+ wrap.setAttribute('data-ezoic-kind', kind);
94
+ wrap.style.width = '100%';
187
95
 
188
- // wait for fill (or timeout) then proceed
189
- const ph = document.getElementById('ezoic-pub-ad-placeholder-' + id);
190
- await waitForFilled(ph, 5000);
96
+ const ph = document.createElement('div');
97
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
98
+ wrap.appendChild(ph);
191
99
 
192
- queues[kind].shift();
193
- }
194
- } finally {
195
- queueState[kind] = false;
196
- }
100
+ return wrap;
197
101
  }
198
102
 
199
- function enqueueShowAds(kind, wrapperEl, id) {
200
- if (!wrapperEl || !id) return;
201
- if (wrapperEl.getAttribute('data-ezoic-shown') === '1') return;
202
- wrapperEl.setAttribute('data-ezoic-shown', '1');
203
- queues[kind].push({ wrapper: wrapperEl, id });
204
- processQueue(kind);
103
+ function insertAfter(targetEl, id, kind, afterPos) {
104
+ if (!targetEl || !targetEl.insertAdjacentElement) return null;
105
+ const wrap = buildWrap(id, kind, afterPos);
106
+ targetEl.insertAdjacentElement('afterend', wrap);
107
+ return wrap;
205
108
  }
206
109
 
207
- function showAdsOnceForElement(wrapperEl, id) {
208
- if (!wrapperEl || !id) return;
209
- if (wrapperEl.getAttribute('data-ezoic-shown') === '1') return;
210
- wrapperEl.setAttribute('data-ezoic-shown', '1');
211
- callShowAdsSingle(id);
110
+ function destroyPlaceholder(id) {
111
+ try {
112
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
113
+ window.ezstandalone.destroyPlaceholders([id]);
114
+ }
115
+ } catch (e) {}
212
116
  }
213
117
 
214
118
  function callShowAdsSingle(id) {
215
119
  if (!id) return;
216
-
217
- // Normalize id key
218
120
  const key = String(id);
219
121
 
220
- // Ensure we never call showAds twice for the same id within a short window
221
122
  const now = Date.now();
222
- window.__ezoicLastSingle = window.__ezoicLastSingle || {};
223
- const last = window.__ezoicLastSingle[key] || 0;
123
+ const last = state.lastShowById[key] || 0;
224
124
  if (now - last < 4000) return;
225
125
 
226
- // If showAds is ready now, call once and exit (no cmd + no retry)
126
+ // If showAds is ready, call once and return
227
127
  try {
228
128
  window.ezstandalone = window.ezstandalone || {};
229
129
  if (typeof window.ezstandalone.showAds === 'function') {
230
- window.__ezoicLastSingle[key] = now;
130
+ state.lastShowById[key] = now;
231
131
  window.ezstandalone.showAds(id);
232
132
  return;
233
133
  }
234
134
  } catch (e) {}
235
135
 
236
- // Otherwise: schedule a single pending call via cmd + retries until it succeeds
237
- window.__ezoicPending = window.__ezoicPending || {};
238
- if (window.__ezoicPending[key]) return;
239
- window.__ezoicPending[key] = true;
136
+ // Otherwise, queue a single pending attempt (per id)
137
+ if (state.pendingById[key]) return;
138
+ state.pendingById[key] = true;
240
139
 
241
140
  window.ezstandalone = window.ezstandalone || {};
242
141
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
243
142
 
244
- const tryRun = function () {
143
+ const tryRun = () => {
245
144
  try {
246
145
  if (typeof window.ezstandalone.showAds === 'function') {
247
- window.__ezoicLastSingle[key] = Date.now();
248
- delete window.__ezoicPending[key];
146
+ state.lastShowById[key] = Date.now();
147
+ delete state.pendingById[key];
249
148
  window.ezstandalone.showAds(id);
250
149
  return true;
251
150
  }
@@ -253,272 +152,277 @@
253
152
  return false;
254
153
  };
255
154
 
256
- // When Ezoic finishes loading, it will drain cmd; run once there
257
- window.ezstandalone.cmd.push(function () { tryRun(); });
155
+ window.ezstandalone.cmd.push(() => { tryRun(); });
258
156
 
259
- // Retry a few times in case cmd doesn't fire (race conditions)
260
157
  let tries = 0;
261
158
  (function tick() {
262
- tries++;
159
+ tries += 1;
263
160
  if (tryRun() || tries >= 8) {
264
- if (tries >= 8) delete window.__ezoicPending[key];
161
+ if (tries >= 8) delete state.pendingById[key];
265
162
  return;
266
163
  }
267
164
  setTimeout(tick, 800);
268
165
  })();
269
166
  }
270
167
 
271
- function pickNextId(pool, usedSet) {
272
- for (const id of pool) if (!usedSet.has(id)) return id;
273
- return null;
274
- }
275
-
276
- function recycle(fifo, usedSet, selector) {
277
- // Recycle only ads that are safely outside the viewport (to avoid ads “disappearing” while reading)
278
- const margin = 1200; // px
168
+ function recycleIfNeeded(pool, usedSet, fifo, selectorFn) {
169
+ // only recycle ads far above the viewport, so they don't "disappear" while reading
170
+ const margin = 1200;
279
171
  const vpTop = -margin;
280
172
 
281
173
  fifo.sort((a, b) => a.after - b.after);
282
174
 
283
- // Prefer recycling ads far ABOVE the current viewport (user has already passed them)
284
175
  for (let i = 0; i < fifo.length; i++) {
285
176
  const old = fifo[i];
286
- const el = document.querySelector(selector(old));
177
+ const el = document.querySelector(selectorFn(old));
287
178
  if (!el) {
288
- fifo.splice(i, 1);
289
- i--;
179
+ fifo.splice(i, 1); i--;
290
180
  continue;
291
181
  }
292
- const r = el.getBoundingClientRect();
293
- if (r.bottom < vpTop) {
182
+ const r = safeGetRect(el);
183
+ if (r && r.bottom < vpTop) {
294
184
  fifo.splice(i, 1);
295
185
  el.remove();
296
186
  usedSet.delete(old.id);
297
187
  destroyPlaceholder(old.id);
298
- return old.id;
188
+ pool.push(old.id);
189
+ return true;
299
190
  }
300
191
  }
192
+ return false;
193
+ }
301
194
 
302
- // If nothing is safely above, do NOT recycle.
195
+ function nextId(pool, usedSet, fifo, selectorFn) {
196
+ if (pool.length) return pool.shift();
197
+ // try recycling one and then use
198
+ const recycled = recycleIfNeeded(pool, usedSet, fifo, selectorFn);
199
+ if (recycled && pool.length) return pool.shift();
303
200
  return null;
304
201
  }
305
202
 
306
- function insertAfter(targetEl, id, cls, afterVal) {
307
- ensureUniquePlaceholder(id);
308
- const wrap = document.createElement('div');
309
- wrap.className = 'ezoic-ad ' + cls;
310
- wrap.setAttribute('data-ezoic-id', String(id));
311
- wrap.setAttribute('data-ezoic-after', String(afterVal));
312
- wrap.innerHTML = '<div class="ezoic-ad-inner"><div id="ezoic-pub-ad-placeholder-' + id + '"></div></div>';
313
- targetEl.insertAdjacentElement('afterend', wrap);
314
- return wrap;
203
+ function getTopicItems() {
204
+ return Array.from(document.querySelectorAll(SELECTORS.topicListItem));
315
205
  }
316
206
 
317
- function injectBetweenTopics(cfg) {
318
- if (!normalizeBool(cfg.enableBetweenAds)) return;
319
- const interval = normalizeInterval(cfg.intervalPosts ?? cfg.intervalTopics, 6);
320
- const pool = parsePool(cfg.placeholderIds);
321
- if (!pool.length) return;
322
-
323
- const items = Array.from(document.querySelectorAll('li[component="category/topic"]'));
324
- if (!items.length) return;
325
-
326
- items.forEach((li, idx) => {
327
- const pos = idx + 1;
328
- const firstEnabled = normalizeBool(cfg.showFirstTopicAd);
329
- if (!(firstEnabled && pos === 1) && (pos % interval !== 0)) return;
330
- if (idx === items.length - 1) return;
331
-
332
- const next = li.nextElementSibling;
333
- if (next && next.classList && next.classList.contains('ezoic-ad-between')) return;
334
-
335
- let id = pickNextId(pool, usedBetween);
336
- if (!id) {
337
- id = recycle(fifoBetween, usedBetween, (old) => '.ezoic-ad-between[data-ezoic-id="' + old.id + '"][data-ezoic-after="' + old.after + '"]');
338
- if (!id) return; // pool empty and nothing recyclable => stop
339
- }
340
-
341
- usedBetween.add(id);
342
- fifoBetween.push({ id, after: pos });
343
-
344
- const wrap = insertAfter(li, id, 'ezoic-ad-between', pos);
345
- enqueueShowAds('between', wrap, id);
346
- });
207
+ function getPostItems() {
208
+ // NodeBB harmony: component=post with data-pid is stable
209
+ return Array.from(document.querySelectorAll(SELECTORS.postItem));
347
210
  }
348
211
 
349
- function injectBetweenMessages(cfg) {
350
- if (!normalizeBool(cfg.enableMessageAds)) return;
351
- const interval = normalizeInterval(cfg.messageIntervalPosts, 3);
352
- const pool = parsePool(cfg.messagePlaceholderIds);
353
- if (!pool.length) return;
212
+ function cleanupForNewPage() {
213
+ state.pageKey = getPageKey();
214
+ state.cfg = null;
215
+ state.cfgPromise = null;
354
216
 
355
- const posts = Array.from(document.querySelectorAll('[component="post"][data-pid]'));
356
- if (!posts.length) return;
217
+ state.usedBetween.clear();
218
+ state.usedMessage.clear();
219
+ state.fifoBetween = [];
220
+ state.fifoMessage = [];
357
221
 
358
- posts.forEach((post, idx) => {
359
- const no = idx + 1;
360
- const firstEnabled = normalizeBool(cfg.showFirstMessageAd);
361
- if (!(firstEnabled && no === 1) && (no % interval !== 0)) return;
362
- if (idx === posts.length - 1) return;
222
+ state.lastShowById = {};
223
+ state.pendingById = {};
363
224
 
364
- const next = post.nextElementSibling;
365
- if (next && next.classList && next.classList.contains('ezoic-ad-message')) return;
366
-
367
- let id = pickNextId(pool, usedMessage);
368
- if (!id) {
369
- id = recycle(fifoMessage, usedMessage, (old) => '.ezoic-ad-message[data-ezoic-id="' + old.id + '"][data-ezoic-after="' + old.after + '"]');
370
- if (!id) return;
371
- }
225
+ // Remove injected wrappers
226
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
372
227
 
373
- usedMessage.add(id);
374
- fifoMessage.push({ id, after: no });
228
+ // Disconnect observers
229
+ Object.values(state.observers).forEach(obs => { try { obs.disconnect(); } catch (e) {} });
230
+ state.observers = {};
375
231
 
376
- const wrap = insertAfter(post, id, 'ezoic-ad-message', no);
377
- enqueueShowAds('between', wrap, id);
378
- });
232
+ state.retryCount = {};
379
233
  }
380
234
 
235
+ async function fetchConfig() {
236
+ if (state.cfg) return state.cfg;
237
+ if (state.cfgPromise) return state.cfgPromise;
381
238
 
382
- function getTopicListItemsFast() {
383
- return document.querySelectorAll('li[component="category/topic"]').length;
384
- }
239
+ state.cfgPromise = (async () => {
240
+ try {
241
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
242
+ if (!res.ok) return null;
243
+ const cfg = await res.json();
244
+ state.cfg = cfg;
245
+ return cfg;
246
+ } catch (e) {
247
+ return null;
248
+ } finally {
249
+ state.cfgPromise = null;
250
+ }
251
+ })();
385
252
 
386
- function getPostItemsFast() {
387
- return document.querySelectorAll('[component="post"][data-pid]').length;
253
+ return state.cfgPromise;
388
254
  }
389
255
 
390
- function observeUntilTargets(pageType, cb) {
391
- // pageType: 'topic' or 'category'
392
- const key = pageType + ':' + getPageKey();
393
- window.__ezoicObs = window.__ezoicObs || {};
394
- if (window.__ezoicObs[key]) return;
256
+ function observeUntilTargets(kind, cb) {
257
+ const key = `${kind}:${getPageKey()}`;
258
+ if (state.observers[key]) return;
395
259
 
396
- const check = function () {
397
- if (pageType === 'topic') return getPostItemsFast() > 0;
398
- return getTopicListItemsFast() > 0;
260
+ const hasTargets = () => {
261
+ if (kind === 'topic') return document.querySelectorAll(SELECTORS.postItem).length > 0;
262
+ return document.querySelectorAll(SELECTORS.topicListItem).length > 0;
399
263
  };
400
264
 
401
- if (check()) {
265
+ if (hasTargets()) {
402
266
  cb();
403
267
  return;
404
268
  }
405
269
 
406
- const obs = new MutationObserver(function () {
407
- if (check()) {
270
+ const obs = new MutationObserver(() => {
271
+ if (hasTargets()) {
408
272
  try { obs.disconnect(); } catch (e) {}
409
- delete window.__ezoicObs[key];
273
+ delete state.observers[key];
410
274
  cb();
411
275
  }
412
276
  });
413
277
 
414
- window.__ezoicObs[key] = obs;
278
+ state.observers[key] = obs;
415
279
  try {
416
280
  obs.observe(document.body, { childList: true, subtree: true });
417
- } catch (e) {
418
- // ignore
419
- }
281
+ } catch (e) {}
420
282
 
421
- // hard stop after 6s
422
- setTimeout(function () {
283
+ setTimeout(() => {
423
284
  try { obs.disconnect(); } catch (e) {}
424
- delete window.__ezoicObs[key];
285
+ delete state.observers[key];
425
286
  }, 6000);
426
287
  }
427
288
 
428
- function scheduleRetry(flagKey) {
429
- window.__ezoicRetry = window.__ezoicRetry || {};
430
- window.__ezoicRetry[flagKey] = window.__ezoicRetry[flagKey] || 0;
431
- if (window.__ezoicRetry[flagKey] >= 24) return;
432
- window.__ezoicRetry[flagKey]++;
289
+ function scheduleRetry(kind) {
290
+ const key = `${kind}:${getPageKey()}`;
291
+ state.retryCount[key] = state.retryCount[key] || 0;
292
+ if (state.retryCount[key] >= 24) return;
293
+ state.retryCount[key] += 1;
433
294
  setTimeout(run, 250);
434
295
  }
435
296
 
436
- async function run() {
437
- if (inFlight) { rerunRequested = true; return; }
438
- inFlight = true;
297
+ function injectBetweenTopics(cfg) {
298
+ if (!normalizeBool(cfg.enableBetweenAds)) return;
439
299
 
440
- try {
441
- const key = getPageKey();
442
- if (pageKey !== key) {
443
- pageKey = key;
444
- cleanupForNewPage();
445
- window.__ezoicCatRetry = 0;
446
- }
300
+ const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 6);
301
+ const firstEnabled = normalizeBool(cfg.showFirstTopicAd);
302
+
303
+ const pool = parsePool(cfg.placeholderIds);
304
+ if (!pool.length) return;
305
+
306
+ const items = getTopicItems();
307
+ if (!items.length) return;
308
+
309
+ for (let idx = 0; idx < items.length; idx++) {
310
+ const li = items[idx];
311
+ const pos = idx + 1;
447
312
 
313
+ if (!li || !li.isConnected) continue;
314
+
315
+ // position rule
316
+ const ok = (firstEnabled && pos === 1) || (pos % interval === 0);
317
+ if (!ok) continue;
318
+
319
+ if (hasAdImmediatelyAfter(li)) continue;
320
+
321
+ const id = nextId(pool, state.usedBetween, state.fifoBetween, (old) => `.${WRAP_CLASS}.ezoic-ad-between[data-ezoic-after="${old.after}"]`);
322
+ if (!id) break;
323
+
324
+ state.usedBetween.add(id);
325
+ const wrap = insertAfter(li, id, 'ezoic-ad-between', pos);
326
+ if (!wrap) continue;
327
+
328
+ state.fifoBetween.push({ id, after: pos });
329
+
330
+ callShowAdsSingle(id);
331
+ }
332
+ }
333
+
334
+ function injectBetweenMessages(cfg) {
335
+ if (!normalizeBool(cfg.enableMessageAds)) return;
336
+
337
+ const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
338
+ const firstEnabled = normalizeBool(cfg.showFirstMessageAd);
339
+
340
+ const pool = parsePool(cfg.messagePlaceholderIds);
341
+ if (!pool.length) return;
342
+
343
+ const posts = getPostItems();
344
+ if (!posts.length) return;
345
+
346
+ for (let idx = 0; idx < posts.length; idx++) {
347
+ const post = posts[idx];
348
+ const no = idx + 1;
349
+ if (!post || !post.isConnected) continue;
350
+
351
+ const ok = (firstEnabled && no === 1) || (no % interval === 0);
352
+ if (!ok) continue;
353
+
354
+ if (hasAdImmediatelyAfter(post)) continue;
355
+
356
+ const id = nextId(pool, state.usedMessage, state.fifoMessage, (old) => `.${WRAP_CLASS}.ezoic-ad-message[data-ezoic-after="${old.after}"]`);
357
+ if (!id) break;
358
+
359
+ state.usedMessage.add(id);
360
+ const wrap = insertAfter(post, id, 'ezoic-ad-message', no);
361
+ if (!wrap) continue;
362
+
363
+ state.fifoMessage.push({ id, after: no });
364
+
365
+ callShowAdsSingle(id);
366
+ }
367
+ }
368
+
369
+ async function run() {
370
+ try {
448
371
  const cfg = await fetchConfig();
449
372
  if (!cfg || cfg.excluded) return;
450
373
 
451
-
452
- if (isTopicPage()) {
453
- const hasPosts = getPostItemsFast() > 0;
454
- if (hasPosts) {
455
- injectBetweenMessages(cfg);
456
- } else {
457
- observeUntilTargets('topic', function () { run(); });
458
- scheduleRetry('topic:' + getPageKey());
459
- }
374
+ const kind = getPageKind();
375
+
376
+ if (kind === 'topic') {
377
+ const hasPosts = document.querySelectorAll(SELECTORS.postItem).length > 0;
378
+ if (hasPosts) injectBetweenMessages(cfg);
379
+ else { observeUntilTargets('topic', run); scheduleRetry('topic'); }
460
380
  } else {
461
- const hasList = getTopicListItemsFast() > 0;
462
- if (hasList) {
463
- injectBetweenTopics(cfg);
464
- } else {
465
- observeUntilTargets('category', function () { run(); });
466
- scheduleRetry('category:' + getPageKey());
467
- }
381
+ const hasList = document.querySelectorAll(SELECTORS.topicListItem).length > 0;
382
+ if (hasList) injectBetweenTopics(cfg);
383
+ else { observeUntilTargets('category', run); scheduleRetry('category'); }
468
384
  }
469
385
  } catch (e) {
470
- // silent
471
- } finally {
472
- inFlight = false;
473
- if (rerunRequested) {
474
- rerunRequested = false;
475
- setTimeout(run, 50);
476
- }
386
+ // Never break NodeBB UI
477
387
  }
478
388
  }
479
389
 
480
- function scheduleRun() {
481
- clearTimeout(debounceTimer);
482
- debounceTimer = setTimeout(run, 150);
483
- }
390
+ function bindNodeBBEvents() {
391
+ if (!$w) return;
484
392
 
485
- function bindNodeBBEvents() {
486
- // NodeBB triggers these events through jQuery on window
487
- if (window.jQuery) {
488
- const $w = window.jQuery(window);
489
- $w.off('.ezoicInfinite');
490
- $w.on('action:ajaxify.end.ezoicInfinite', function(){ scheduleRun(); setTimeout(scheduleRun, 600); });
491
- $w.on('action:posts.loaded.ezoicInfinite', scheduleRun);
492
- $w.on('action:topic.loaded.ezoicInfinite', scheduleRun);
493
- $w.on('action:topics.loaded.ezoicInfinite', scheduleRun);
494
- $w.on('action:category.loaded.ezoicInfinite', scheduleRun);
495
- $w.on('action:ajaxify.start.ezoicInfinite', function () {
496
- pageKey = null;
497
- cleanupForNewPage();
498
- window.__ezoicCatRetry = 0;
499
- });
500
- }
501
- }
393
+ // Prevent duplicate binding
394
+ $w.off('.ezoicInfinite');
502
395
 
503
- bindNodeBBEvents();
396
+ $w.on('action:ajaxify.start.ezoicInfinite', () => {
397
+ cleanupForNewPage();
398
+ });
504
399
 
505
- // Run immediately, so it works on first ajaxify navigation too
506
- run();
507
- setTimeout(function(){
508
- // only retry if nothing was injected yet
509
- if (!document.querySelector('.ezoic-ad')) run();
510
- }, 1400);
511
-
512
- // Also run on hard-refresh initial load
513
- if (document.readyState === 'loading') {
514
- document.addEventListener('DOMContentLoaded', function () {
515
- bindNodeBBEvents();
400
+ $w.on('action:ajaxify.end.ezoicInfinite', () => {
516
401
  run();
517
- setTimeout(run, 1200);
402
+ setTimeout(run, 200);
403
+ setTimeout(run, 800);
518
404
  });
405
+
406
+ $w.on('action:category.loaded.ezoicInfinite', () => {
407
+ run();
408
+ setTimeout(run, 300);
409
+ });
410
+
411
+ $w.on('action:topic.loaded.ezoicInfinite', () => {
412
+ run();
413
+ setTimeout(run, 300);
414
+ });
415
+
416
+ // Infinite scroll events (varies by route)
417
+ $w.on('action:topics.loaded.ezoicInfinite', run);
418
+ $w.on('action:posts.loaded.ezoicInfinite', run);
519
419
  }
520
- ;
521
- } catch (e) {
522
- // fail silently to avoid breaking NodeBB
523
- }
420
+
421
+ // Boot
422
+ cleanupForNewPage();
423
+ bindNodeBBEvents();
424
+
425
+ // First load (non-ajaxify)
426
+ run();
427
+ setTimeout(run, 250);
524
428
  })();