nodebb-plugin-ezoic-infinite 1.5.5 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -68,7 +68,7 @@ async function getSettings() {
68
68
  async function isUserExcluded(uid, excludedGroups) {
69
69
  if (!uid || !excludedGroups.length) return false;
70
70
  const userGroups = await groups.getUserGroups([uid]);
71
- return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : g));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : String(g)));
72
72
  }
73
73
 
74
74
  plugin.onSettingsSet = function (data) {
@@ -107,10 +107,11 @@ plugin.init = async ({ router, middleware }) => {
107
107
 
108
108
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
109
  const settings = await getSettings();
110
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
110
+ const excluded = await isUserExcluded((req.uid || (req.user && req.user.uid)), settings.excludedGroups);
111
111
 
112
112
  res.json({
113
113
  excluded,
114
+ excludedGroups: settings.excludedGroups,
114
115
  enableBetweenAds: settings.enableBetweenAds,
115
116
  showFirstTopicAd: settings.showFirstTopicAd,
116
117
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.5",
3
+ "version": "1.5.6",
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
@@ -1,609 +1,386 @@
1
- (function () {
2
- 'use strict';
3
-
4
- // Safety stubs (do NOT stub showAds)
5
- window._ezaq = window._ezaq || [];
6
- window.ezstandalone = window.ezstandalone || {};
7
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
8
-
9
- // NodeBB client context
10
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
11
-
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
-
15
- // Insert at most N ads per run to keep the UI smooth on infinite scroll
16
- const MAX_INSERTS_PER_RUN = 3;
17
-
18
- // Preload before viewport (tune if you want even earlier)
19
- const PRELOAD_ROOT_MARGIN = '1200px 0px';
20
-
21
- const SELECTORS = {
22
- topicItem: 'li[component="category/topic"]',
23
- postItem: '[component="post"][data-pid]',
24
- categoryItem: 'li[component="categories/category"]',
25
- };
26
-
27
- // Hard block during navigation to avoid “placeholder does not exist” spam
28
- let EZOIC_BLOCKED = false;
29
-
30
- const state = {
31
- pageKey: null,
32
- cfg: null,
33
-
34
- poolTopics: [],
35
- poolPosts: [],
36
- poolCategories: [],
37
-
38
- usedTopics: new Set(),
39
- usedPosts: new Set(),
40
- usedCategories: new Set(),
41
-
42
- // throttle per placeholder id
43
- lastShowById: new Map(),
44
-
45
- // observers / schedulers
46
- domObs: null,
47
- io: null,
48
- runQueued: false,
49
-
50
- // hero
51
- heroDoneForPage: false,
52
- };
1
+ 'use strict';
2
+ /**
3
+ * Ezoic Infinite Ads for NodeBB 4.x
4
+ * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
5
+ * - Hard gate on server config (respects excluded groups BEFORE any injection)
6
+ * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
7
+ * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
8
+ */
53
9
 
54
- const sessionDefinedIds = new Set();
55
- const insertingIds = new Set();
56
-
57
- // ---------- small utils ----------
58
-
59
- function normalizeBool(v) {
60
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
10
+ (function () {
11
+ const CFG_URL = '/api/plugins/ezoic-infinite/config';
12
+
13
+ // -------------------------
14
+ // Utilities
15
+ // -------------------------
16
+ const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
17
+ const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
18
+
19
+ function on(ev, fn) { document.addEventListener(ev, fn); }
20
+ function once(ev, fn) {
21
+ const h = (e) => { document.removeEventListener(ev, h); fn(e); };
22
+ document.addEventListener(ev, h);
61
23
  }
62
24
 
63
- function uniqInts(lines) {
64
- const out = [];
65
- const seen = new Set();
66
- for (const v of lines) {
67
- const n = parseInt(v, 10);
68
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
69
- seen.add(n);
70
- out.push(n);
25
+ // -------------------------
26
+ // Global state / navigation token
27
+ // -------------------------
28
+ let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
29
+ let rafScheduled = false;
30
+
31
+ // resolved config (or null if failed)
32
+ let cfgPromise = null;
33
+ let cfg = null;
34
+
35
+ // -------------------------
36
+ // Placeholder Registry (keeps IDs alive)
37
+ // -------------------------
38
+ const Pool = {
39
+ el: null,
40
+ ensurePool() {
41
+ if (this.el && document.body.contains(this.el)) return this.el;
42
+ const d = document.createElement('div');
43
+ d.id = 'ezoic-placeholder-pool';
44
+ d.style.display = 'none';
45
+ d.setAttribute('aria-hidden', 'true');
46
+ document.body.appendChild(d);
47
+ this.el = d;
48
+ return d;
49
+ },
50
+ ensurePlaceholder(id) {
51
+ if (!id) return null;
52
+ let ph = document.getElementById(id);
53
+ if (ph) return ph;
54
+ const pool = this.ensurePool();
55
+ ph = document.createElement('div');
56
+ ph.id = id;
57
+ pool.appendChild(ph);
58
+ return ph;
59
+ },
60
+ returnToPool(id) {
61
+ const ph = document.getElementById(id);
62
+ if (!ph) return;
63
+ const pool = this.ensurePool();
64
+ if (ph.parentElement !== pool) {
65
+ pool.appendChild(ph);
71
66
  }
72
67
  }
73
- return out;
74
- }
75
-
76
- function parsePool(raw) {
77
- if (!raw) return [];
78
- const lines = String(raw)
79
- .split(/\r?\n/)
80
- .map(s => s.trim())
81
- .filter(Boolean);
82
- return uniqInts(lines);
83
- }
84
-
85
- function getPageKey() {
86
- try {
87
- const ax = window.ajaxify;
88
- if (ax && ax.data) {
89
- if (ax.data.tid) return `topic:${ax.data.tid}`;
90
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
91
- }
92
- } catch (e) {}
93
- return window.location.pathname;
94
- }
95
-
96
- function getKind() {
97
- const p = window.location.pathname || '';
98
- if (/^\/topic\//.test(p)) return 'topic';
99
- if (/^\/category\//.test(p)) return 'categoryTopics';
100
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
101
-
102
- // fallback by DOM
103
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
104
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
105
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
106
- return 'other';
107
- }
68
+ };
108
69
 
109
- function getTopicItems() {
110
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
70
+ // -------------------------
71
+ // Ezoic showAds safe wrapper (installed even if defined later)
72
+ // -------------------------
73
+ function normalizeIds(argsLike) {
74
+ const args = Array.from(argsLike);
75
+ if (!args.length) return [];
76
+ // showAds([id1,id2]) or showAds([[...]]) edge
77
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
78
+ return args.flat().map(String);
111
79
  }
112
80
 
113
- function getCategoryItems() {
114
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
115
- }
81
+ function wrapShowAds(original) {
82
+ if (!original || original.__nodebbSafeWrapped) return original;
116
83
 
117
- function getPostContainers() {
118
- const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
119
- return nodes.filter((el) => {
120
- if (!el || !el.isConnected) return false;
121
- if (!el.querySelector('[component="post/content"]')) return false;
122
- const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
123
- if (parentPost && parentPost !== el) return false;
124
- if (el.getAttribute('component') === 'post/parent') return false;
125
- return true;
126
- });
127
- }
84
+ const wrapped = function (...args) {
85
+ const ids = normalizeIds(args);
86
+ if (!ids.length) return;
128
87
 
129
- // ---------- warm-up & patching ----------
88
+ for (const id of ids) {
89
+ // Ensure placeholder exists somewhere (pool), so ez-standalone won't log "does not exist"
90
+ Pool.ensurePlaceholder(id);
130
91
 
131
- const _warmLinksDone = new Set();
132
- function warmUpNetwork() {
133
- try {
134
- const head = document.head || document.getElementsByTagName('head')[0];
135
- if (!head) return;
136
- const links = [
137
- ['preconnect', 'https://g.ezoic.net', true],
138
- ['dns-prefetch', 'https://g.ezoic.net', false],
139
- ['preconnect', 'https://go.ezoic.net', true],
140
- ['dns-prefetch', 'https://go.ezoic.net', false],
141
- ];
142
- for (const [rel, href, cors] of links) {
143
- const key = `${rel}|${href}`;
144
- if (_warmLinksDone.has(key)) continue;
145
- _warmLinksDone.add(key);
146
- const link = document.createElement('link');
147
- link.rel = rel;
148
- link.href = href;
149
- if (cors) link.crossOrigin = 'anonymous';
150
- head.appendChild(link);
151
- }
152
- } catch (e) {}
153
- }
154
-
155
- // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
156
- function patchShowAds() {
157
- const applyPatch = () => {
158
- try {
159
- window.ezstandalone = window.ezstandalone || {};
160
- const ez = window.ezstandalone;
161
- if (window.__nodebbEzoicPatched) return;
162
- if (typeof ez.showAds !== 'function') return;
163
-
164
- window.__nodebbEzoicPatched = true;
165
- const orig = ez.showAds;
166
-
167
- ez.showAds = function (...args) {
168
- if (EZOIC_BLOCKED) return;
169
-
170
- let ids = [];
171
- if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
172
- else ids = args;
173
-
174
- const seen = new Set();
175
- for (const v of ids) {
176
- const id = parseInt(v, 10);
177
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
178
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
179
- if (!ph || !ph.isConnected) continue;
180
- seen.add(id);
181
- try { orig.call(ez, id); } catch (e) {}
92
+ const el = document.getElementById(id);
93
+ if (el && document.body.contains(el)) {
94
+ try {
95
+ // Call one-by-one to avoid batch logging on missing nodes
96
+ original.call(window.ezstandalone, id);
97
+ } catch (e) {
98
+ // swallow: Ezoic can throw if called during transitions
182
99
  }
183
- };
184
- } catch (e) {}
100
+ }
101
+ }
185
102
  };
186
103
 
187
- applyPatch();
188
- if (!window.__nodebbEzoicPatched) {
189
- try {
190
- window.ezstandalone = window.ezstandalone || {};
191
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
192
- window.ezstandalone.cmd.push(applyPatch);
193
- } catch (e) {}
194
- }
104
+ wrapped.__nodebbSafeWrapped = true;
105
+ return wrapped;
195
106
  }
196
107
 
197
- // ---------- config & pools ----------
198
-
199
- async function fetchConfigOnce() {
200
- if (state.cfg) return state.cfg;
201
- try {
202
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
203
- if (!res.ok) return null;
204
- state.cfg = await res.json();
205
- return state.cfg;
206
- } catch (e) {
207
- return null;
108
+ function installShowAdsHook() {
109
+ // If ezstandalone already exists, wrap now.
110
+ if (window.ezstandalone && window.ezstandalone.showAds) {
111
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
208
112
  }
209
- }
210
-
211
- function initPools(cfg) {
212
- if (!cfg) return;
213
- if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
214
- if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
215
- if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
216
- }
217
-
218
- // ---------- insertion primitives ----------
219
-
220
- function isAdjacentAd(target) {
221
- if (!target) return false;
222
- const next = target.nextElementSibling;
223
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
224
- const prev = target.previousElementSibling;
225
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
226
- return false;
227
- }
228
-
229
- function buildWrap(target, id, kindClass, afterPos) {
230
- const tag = (target && target.tagName === 'LI') ? 'li' : 'div';
231
- const wrap = document.createElement(tag);
232
- if (tag === 'li') {
233
- wrap.style.listStyle = 'none';
234
- // preserve common NodeBB list styling
235
- if (target && target.classList && target.classList.contains('list-group-item')) wrap.classList.add('list-group-item');
236
- }
237
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
238
- if (wrap.tagName === 'LI') {
239
- wrap.setAttribute('role', 'presentation');
240
- wrap.setAttribute('aria-hidden', 'true');
241
- }
242
- wrap.setAttribute('data-ezoic-after', String(afterPos));
243
- wrap.style.width = '100%';
244
-
245
- const ph = document.createElement('div');
246
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
- ph.setAttribute('data-ezoic-id', String(id));
248
- wrap.appendChild(ph);
249
-
250
- return wrap;
251
- }
252
-
253
- function findWrap(kindClass, afterPos) {
254
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
255
- }
256
-
257
- function insertAfter(target, id, kindClass, afterPos) {
258
- if (!target || !target.insertAdjacentElement) return null;
259
- if (findWrap(kindClass, afterPos)) return null;
260
- if (insertingIds.has(id)) return null;
261
-
262
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
263
- if (existingPh && existingPh.isConnected) return null;
264
-
265
- insertingIds.add(id);
266
- try {
267
- const wrap = buildWrap(target, id, kindClass, afterPos);
268
- target.insertAdjacentElement('afterend', wrap);
269
- return wrap;
270
- } finally {
271
- insertingIds.delete(id);
272
- }
273
- }
274
-
275
- function pickId(pool) {
276
- return pool.length ? pool.shift() : null;
277
- }
278
-
279
- function showAd(id) {
280
- if (!id || EZOIC_BLOCKED) return;
281
-
282
- const now = Date.now();
283
- const last = state.lastShowById.get(id) || 0;
284
- if (now - last < 1500) return; // basic throttle
285
-
286
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
- if (!ph || !ph.isConnected) return;
288
-
289
- state.lastShowById.set(id, now);
290
113
 
114
+ // Hook future assignment (no polling)
291
115
  try {
292
- window.ezstandalone = window.ezstandalone || {};
116
+ if (!window.ezstandalone) window.ezstandalone = {};
293
117
  const ez = window.ezstandalone;
294
118
 
295
- // Fast path
296
- if (typeof ez.showAds === 'function') {
297
- ez.showAds(id);
298
- sessionDefinedIds.add(id);
299
- return;
300
- }
119
+ // If already has a setter installed, do nothing
120
+ const desc = Object.getOwnPropertyDescriptor(ez, 'showAds');
121
+ if (desc && (desc.set || desc.get)) return;
301
122
 
302
- // Queue once for when Ezoic is ready
303
- ez.cmd = ez.cmd || [];
304
- if (!ph.__ezoicQueued) {
305
- ph.__ezoicQueued = true;
306
- ez.cmd.push(() => {
307
- try {
308
- if (EZOIC_BLOCKED) return;
309
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
310
- if (!el || !el.isConnected) return;
311
- window.ezstandalone.showAds(id);
312
- sessionDefinedIds.add(id);
313
- } catch (e) {}
314
- });
315
- }
316
- } catch (e) {}
317
- }
318
-
319
- // ---------- preload / above-the-fold ----------
123
+ let _showAds = ez.showAds;
124
+ Object.defineProperty(ez, 'showAds', {
125
+ configurable: true,
126
+ enumerable: true,
127
+ get() { return _showAds; },
128
+ set(v) { _showAds = wrapShowAds(v); }
129
+ });
320
130
 
321
- function ensurePreloadObserver() {
322
- if (state.io) return state.io;
323
- try {
324
- state.io = new IntersectionObserver((entries) => {
325
- for (const ent of entries) {
326
- if (!ent.isIntersecting) continue;
327
- const el = ent.target;
328
- try { state.io && state.io.unobserve(el); } catch (e) {}
329
-
330
- const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
331
- const id = parseInt(idAttr, 10);
332
- if (Number.isFinite(id) && id > 0) showAd(id);
333
- }
334
- }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
131
+ // Re-assign current value through setter
132
+ ez.showAds = _showAds;
335
133
  } catch (e) {
336
- state.io = null;
134
+ // If defineProperty fails, best-effort wrap is still in place above.
337
135
  }
338
- return state.io;
339
136
  }
340
137
 
341
- function observePlaceholder(id) {
342
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
343
- if (!ph || !ph.isConnected) return;
344
- const io = ensurePreloadObserver();
345
- try { io && io.observe(ph); } catch (e) {}
346
-
347
- // If already above fold, fire immediately
348
- try {
349
- const r = ph.getBoundingClientRect();
350
- if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
351
- } catch (e) {}
138
+ function ezCmd(fn) {
139
+ // Tokenize so queued callbacks don't run after navigation
140
+ const tokenAtSchedule = navToken;
141
+ window.ezstandalone = window.ezstandalone || {};
142
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
143
+ window.ezstandalone.cmd.push(function () {
144
+ if (tokenAtSchedule !== navToken) return;
145
+ try { fn(); } catch (e) {}
146
+ });
352
147
  }
353
148
 
354
- // ---------- insertion logic ----------
149
+ // -------------------------
150
+ // Config (hard gate)
151
+ // -------------------------
152
+ function prefetchConfig() {
153
+ if (cfgPromise) return cfgPromise;
154
+
155
+ cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
156
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
157
+ .then(data => {
158
+ cfg = data;
159
+ // Pre-create placeholders in pool so Ezoic never complains even before first injection
160
+ const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
161
+ ids.forEach(id => Pool.ensurePlaceholder(id));
162
+ return cfg;
163
+ })
164
+ .catch(() => {
165
+ cfg = null;
166
+ return null;
167
+ });
355
168
 
356
- function computeTargets(count, interval, showFirst) {
357
- const out = [];
358
- if (count <= 0) return out;
359
- if (showFirst) out.push(1);
360
- for (let i = 1; i <= count; i++) {
361
- if (i % interval === 0) out.push(i);
362
- }
363
- return Array.from(new Set(out)).sort((a, b) => a - b);
169
+ return cfgPromise;
364
170
  }
365
171
 
366
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
- if (!items.length) return 0;
368
-
369
- const targets = computeTargets(items.length, interval, showFirst);
370
- let inserted = 0;
371
-
372
- for (const afterPos of targets) {
373
- if (inserted >= MAX_INSERTS_PER_RUN) break;
374
-
375
- const el = items[afterPos - 1];
376
- if (!el || !el.isConnected) continue;
377
- if (isAdjacentAd(el)) continue;
378
- if (findWrap(kindClass, afterPos)) continue;
172
+ function parsePlaceholderIds(s) {
173
+ return String(s || '')
174
+ .split(',')
175
+ .map(x => x.trim())
176
+ .filter(Boolean);
177
+ }
379
178
 
380
- const id = pickId(pool);
381
- if (!id) break;
179
+ // -------------------------
180
+ // Ad insertion logic
181
+ // -------------------------
182
+ const Ad = {
183
+ // throttle showAds per placeholder id
184
+ lastShowAt: new Map(),
185
+ minShowIntervalMs: 1500,
382
186
 
383
- usedSet.add(id);
384
- const wrap = insertAfter(el, id, kindClass, afterPos);
385
- if (!wrap) {
386
- usedSet.delete(id);
387
- pool.unshift(id);
388
- continue;
187
+ // Observers
188
+ io: null,
189
+ mo: null,
190
+
191
+ // Bookkeeping
192
+ insertedKeys: new Set(), // avoid duplicate injection on re-renders
193
+
194
+ initObservers() {
195
+ if (!this.io) {
196
+ this.io = new IntersectionObserver((entries) => {
197
+ for (const e of entries) {
198
+ if (!e.isIntersecting) continue;
199
+ const id = e.target && e.target.getAttribute('data-ezoic-id');
200
+ if (id) this.requestShow(id);
201
+ }
202
+ }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
389
203
  }
390
204
 
391
- observePlaceholder(id);
392
- inserted += 1;
393
- }
205
+ if (!this.mo) {
206
+ this.mo = new MutationObserver(() => this.scheduleScan());
207
+ const root = document.querySelector('#content, #panel, main, body');
208
+ if (root) this.mo.observe(root, { childList: true, subtree: true });
209
+ }
210
+ },
211
+
212
+ disconnectObservers() {
213
+ try { this.io && this.io.disconnect(); } catch (e) {}
214
+ try { this.mo && this.mo.disconnect(); } catch (e) {}
215
+ this.io = null;
216
+ this.mo = null;
217
+ },
218
+
219
+ scheduleScan() {
220
+ if (rafScheduled) return;
221
+ rafScheduled = true;
222
+ requestAnimationFrame(() => {
223
+ rafScheduled = false;
224
+ this.scanAndInject();
225
+ });
226
+ },
227
+
228
+ async scanAndInject() {
229
+ const c = await prefetchConfig();
230
+ if (!c) return;
231
+
232
+ if (c.excluded) return; // HARD stop: never inject for excluded users
233
+
234
+ installShowAdsHook();
235
+ this.initObservers();
236
+
237
+ if (c.enableBetweenAds) this.injectBetweenTopics(c);
238
+ // Extend: categories ads if you use them
239
+ },
240
+
241
+ // NodeBB topic list injection (between li rows)
242
+ injectBetweenTopics(c) {
243
+ // NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
244
+ // Older themes may use .topic-list
245
+ const container =
246
+ document.querySelector('ul[component="category"].topics-list') ||
247
+ document.querySelector('ul[component="category"].topic-list') ||
248
+ document.querySelector('ul.topics-list[component="category"]') ||
249
+ document.querySelector('ul.topics-list') ||
250
+ document.querySelector('.category ul.topics-list') ||
251
+ document.querySelector('.category .topic-list') ||
252
+ document.querySelector('.topics .topic-list') ||
253
+ document.querySelector('.topic-list');
254
+
255
+ if (!container) return;
256
+
257
+ let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
258
+ if (!rows.length) {
259
+ // Fallback for older markups
260
+ rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
261
+ }
262
+ if (!rows.length) return;
394
263
 
395
- return inserted;
396
- }
264
+ const ids = parsePlaceholderIds(c.placeholderIds);
265
+ if (!ids.length) return;
397
266
 
398
- async function insertHeroAdEarly() {
399
- if (state.heroDoneForPage) return;
400
- const cfg = await fetchConfigOnce();
401
- if (!cfg || cfg.excluded) return;
402
-
403
- initPools(cfg);
404
-
405
- const kind = getKind();
406
- let items = [];
407
- let pool = null;
408
- let usedSet = null;
409
- let kindClass = '';
410
-
411
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
412
- items = getPostContainers();
413
- pool = state.poolPosts;
414
- usedSet = state.usedPosts;
415
- kindClass = 'ezoic-ad-message';
416
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
417
- items = getTopicItems();
418
- pool = state.poolTopics;
419
- usedSet = state.usedTopics;
420
- kindClass = 'ezoic-ad-between';
421
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
422
- items = getCategoryItems();
423
- pool = state.poolCategories;
424
- usedSet = state.usedCategories;
425
- kindClass = 'ezoic-ad-categories';
426
- } else {
427
- return;
428
- }
267
+ const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
429
268
 
430
- if (!items.length) return;
269
+ // HERO: early, above-the-fold (first eligible insertion point)
270
+ if (c.showFirstTopicAd) {
271
+ this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
272
+ }
431
273
 
432
- // Insert after the very first item (above-the-fold)
433
- const afterPos = 1;
434
- const el = items[afterPos - 1];
435
- if (!el || !el.isConnected) return;
436
- if (isAdjacentAd(el)) return;
437
- if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
274
+ // Between rows
275
+ let idIndex = 0;
276
+ for (let i = interval; i < rows.length; i += interval) {
277
+ idIndex = (idIndex + 1) % ids.length;
278
+ this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
279
+ }
280
+ },
438
281
 
439
- const id = pickId(pool);
440
- if (!id) return;
282
+ insertAdAfterRow(rowEl, placeholderId, key) {
283
+ if (!rowEl || !placeholderId || !key) return;
284
+ if (this.insertedKeys.has(key)) return;
441
285
 
442
- usedSet.add(id);
443
- const wrap = insertAfter(el, id, kindClass, afterPos);
444
- if (!wrap) {
445
- usedSet.delete(id);
446
- pool.unshift(id);
447
- return;
448
- }
286
+ // If already present nearby, skip
287
+ if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
288
+ this.insertedKeys.add(key);
289
+ return;
290
+ }
449
291
 
450
- state.heroDoneForPage = true;
451
- observePlaceholder(id);
452
- }
292
+ const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
293
+ const wrapper = document.createElement(isLi ? 'li' : 'div');
294
+ wrapper.className = 'ezoic-ad-wrapper';
295
+ wrapper.setAttribute('role', 'presentation');
296
+ wrapper.setAttribute('data-ezoic-id', placeholderId);
453
297
 
454
- async function runCore() {
455
- if (EZOIC_BLOCKED) return;
298
+ // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
299
+ if (isLi && rowEl.classList.contains('list-group-item')) {
300
+ wrapper.classList.add('list-group-item');
301
+ }
456
302
 
457
- patchShowAds();
303
+ // Ensure placeholder exists (in pool) then move it into wrapper
304
+ const ph = Pool.ensurePlaceholder(placeholderId);
305
+ // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
306
+ try { wrapper.appendChild(ph); } catch (e) {}
458
307
 
459
- const cfg = await fetchConfigOnce();
460
- if (!cfg || cfg.excluded) return;
461
- initPools(cfg);
308
+ // Insert into DOM
309
+ rowEl.insertAdjacentElement('afterend', wrapper);
310
+ this.insertedKeys.add(key);
462
311
 
463
- const kind = getKind();
312
+ // Observe for preloading and request a show
313
+ if (this.io) this.io.observe(wrapper);
464
314
 
465
- if (kind === 'topic') {
466
- if (normalizeBool(cfg.enableMessageAds)) {
467
- injectBetween(
468
- 'ezoic-ad-message',
469
- getPostContainers(),
470
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
471
- normalizeBool(cfg.showFirstMessageAd),
472
- state.poolPosts,
473
- state.usedPosts
474
- );
475
- }
476
- } else if (kind === 'categoryTopics') {
477
- if (normalizeBool(cfg.enableBetweenAds)) {
478
- injectBetween(
479
- 'ezoic-ad-between',
480
- getTopicItems(),
481
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
482
- normalizeBool(cfg.showFirstTopicAd),
483
- state.poolTopics,
484
- state.usedTopics
485
- );
315
+ // If above fold, request immediately (no timeout)
316
+ const rect = wrapper.getBoundingClientRect();
317
+ if (rect.top < (window.innerHeight * 1.5)) {
318
+ this.requestShow(placeholderId);
486
319
  }
487
- } else if (kind === 'categories') {
488
- if (normalizeBool(cfg.enableCategoryAds)) {
489
- injectBetween(
490
- 'ezoic-ad-categories',
491
- getCategoryItems(),
492
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
493
- normalizeBool(cfg.showFirstCategoryAd),
494
- state.poolCategories,
495
- state.usedCategories
496
- );
320
+ },
321
+
322
+ requestShow(placeholderId) {
323
+ if (!placeholderId) return;
324
+
325
+ const last = this.lastShowAt.get(placeholderId) || 0;
326
+ const t = now();
327
+ if ((t - last) < this.minShowIntervalMs) return;
328
+ this.lastShowAt.set(placeholderId, t);
329
+
330
+ // Ensure placeholder exists in DOM (pool or wrapper)
331
+ Pool.ensurePlaceholder(placeholderId);
332
+
333
+ // Use ez cmd queue if available
334
+ const doShow = () => {
335
+ if (!window.ezstandalone || !window.ezstandalone.showAds) return;
336
+ // showAds is wrapped; calling with one id is safest
337
+ window.ezstandalone.showAds(placeholderId);
338
+ };
339
+
340
+ if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
341
+ ezCmd(doShow);
342
+ } else {
343
+ doShow();
497
344
  }
498
- }
499
- }
500
-
501
- function scheduleRun() {
502
- if (state.runQueued) return;
503
- state.runQueued = true;
504
- window.requestAnimationFrame(() => {
505
- state.runQueued = false;
506
- const pk = getPageKey();
507
- if (state.pageKey && pk !== state.pageKey) return;
508
- runCore().catch(() => {});
509
- });
510
- }
511
-
512
- // ---------- observers / lifecycle ----------
513
-
514
- function cleanup() {
515
- EZOIC_BLOCKED = true;
516
-
517
- // remove all wrappers
518
- try {
519
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
520
- try { el.remove(); } catch (e) {}
345
+ },
346
+
347
+ // On navigation, return all placeholders to pool so they still exist
348
+ reclaimPlaceholders() {
349
+ const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
350
+ wrappers.forEach(w => {
351
+ const id = w.getAttribute('data-ezoic-id');
352
+ if (id) Pool.returnToPool(id);
521
353
  });
522
- } catch (e) {}
523
-
524
- // reset state
525
- state.cfg = null;
526
- state.poolTopics = [];
527
- state.poolPosts = [];
528
- state.poolCategories = [];
529
- state.usedTopics.clear();
530
- state.usedPosts.clear();
531
- state.usedCategories.clear();
532
- state.lastShowById.clear();
533
- state.heroDoneForPage = false;
534
-
535
- sessionDefinedIds.clear();
536
-
537
- // keep observers alive (MutationObserver will re-trigger after navigation)
538
- }
539
-
540
- function ensureDomObserver() {
541
- if (state.domObs) return;
542
- state.domObs = new MutationObserver(() => {
543
- if (!EZOIC_BLOCKED) scheduleRun();
544
- });
545
- try {
546
- state.domObs.observe(document.body, { childList: true, subtree: true });
547
- } catch (e) {}
548
- }
549
-
550
- function bindNodeBB() {
551
- if (!$) return;
552
-
553
- $(window).off('.ezoicInfinite');
554
-
555
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
556
- cleanup();
557
- });
558
-
559
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
560
- state.pageKey = getPageKey();
561
- EZOIC_BLOCKED = false;
562
-
563
- warmUpNetwork();
564
- patchShowAds();
565
- ensurePreloadObserver();
566
- ensureDomObserver();
567
-
568
- // Ultra-fast above-the-fold first
569
- insertHeroAdEarly().catch(() => {});
570
-
571
- // Then normal insertion
572
- scheduleRun();
573
- });
354
+ // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
355
+ this.insertedKeys.clear();
356
+ this.lastShowAt.clear();
357
+ }
358
+ };
574
359
 
575
- // Infinite scroll / partial updates
576
- $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
577
- if (EZOIC_BLOCKED) return;
578
- scheduleRun();
579
- });
360
+ // -------------------------
361
+ // NodeBB hooks
362
+ // -------------------------
363
+ function onAjaxifyStart() {
364
+ navToken++;
365
+ Ad.disconnectObservers();
366
+ Ad.reclaimPlaceholders();
580
367
  }
581
368
 
582
- function bindScroll() {
583
- let ticking = false;
584
- window.addEventListener('scroll', () => {
585
- if (ticking) return;
586
- ticking = true;
587
- window.requestAnimationFrame(() => {
588
- ticking = false;
589
- if (!EZOIC_BLOCKED) scheduleRun();
590
- });
591
- }, { passive: true });
369
+ function onAjaxifyEnd() {
370
+ // Kick scan for new page
371
+ prefetchConfig();
372
+ Ad.scheduleScan();
592
373
  }
593
374
 
594
- // ---------- boot ----------
595
-
596
- state.pageKey = getPageKey();
597
- warmUpNetwork();
598
- patchShowAds();
599
- ensurePreloadObserver();
600
- ensureDomObserver();
375
+ // NodeBB exposes ajaxify events on document
376
+ on('ajaxify.start', onAjaxifyStart);
377
+ on('ajaxify.end', onAjaxifyEnd);
601
378
 
602
- bindNodeBB();
603
- bindScroll();
379
+ // First load
380
+ once('DOMContentLoaded', () => {
381
+ installShowAdsHook();
382
+ prefetchConfig();
383
+ Ad.scheduleScan();
384
+ });
604
385
 
605
- // First paint: try hero + run
606
- EZOIC_BLOCKED = false;
607
- insertHeroAdEarly().catch(() => {});
608
- scheduleRun();
609
386
  })();
package/public/style.css CHANGED
@@ -1,21 +1,9 @@
1
- /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
2
- .ezoic-ad {
3
- display: block;
4
- width: 100%;
5
- margin: 0 !important;
6
- padding: 0 !important;
7
- overflow: hidden;
1
+ .ezoic-ad,
2
+ .ezoic-ad *,
3
+ span.ezoic-ad,
4
+ span[class*="ezoic"] {
5
+ min-height: 0 !important;
6
+ min-width: 0 !important;
8
7
  }
9
8
 
10
- .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
- margin: 0 !important;
12
- padding: 0 !important;
13
- min-height: 1px; /* keeps placeholder measurable for IO */
14
- }
15
-
16
- /* Ezoic sometimes wraps in extra spans/divs with margins */
17
- .ezoic-ad span.ezoic-ad,
18
- .ezoic-ad .ezoic-ad {
19
- margin: 0 !important;
20
- padding: 0 !important;
21
- }
9
+ .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}