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