nodebb-plugin-ezoic-infinite 1.5.3 → 1.5.5

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 : String(g)));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : g));
72
72
  }
73
73
 
74
74
  plugin.onSettingsSet = function (data) {
@@ -107,11 +107,10 @@ 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 || (req.user && req.user.uid)), settings.excludedGroups);
110
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
111
111
 
112
112
  res.json({
113
113
  excluded,
114
- excludedGroups: settings.excludedGroups,
115
114
  enableBetweenAds: settings.enableBetweenAds,
116
115
  showFirstTopicAd: settings.showFirstTopicAd,
117
116
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
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,373 +1,609 @@
1
- 'use strict';
2
-
3
- // Safety stub: some Ezoic scripts expect a global queue even when ads are disabled
4
- window._ezaq = window._ezaq || [];
5
- /**
6
- * Ezoic Infinite Ads for NodeBB 4.x
7
- * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
8
- * - Hard gate on server config (respects excluded groups BEFORE any injection)
9
- * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
10
- * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
11
- */
12
-
13
1
  (function () {
14
- const CFG_URL = '/api/plugins/ezoic-infinite/config';
15
-
16
- // -------------------------
17
- // Utilities
18
- // -------------------------
19
- const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
20
- const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
21
-
22
- function on(ev, fn) { document.addEventListener(ev, fn); }
23
- function once(ev, fn) {
24
- const h = (e) => { document.removeEventListener(ev, h); fn(e); };
25
- document.addEventListener(ev, h);
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
+ };
53
+
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';
26
61
  }
27
62
 
28
- // -------------------------
29
- // Global state / navigation token
30
- // -------------------------
31
- let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
32
- let rafScheduled = false;
33
-
34
- // resolved config (or null if failed)
35
- let cfgPromise = null;
36
- let cfg = null;
37
-
38
- // -------------------------
39
- // Placeholder Registry (keeps IDs alive)
40
- // -------------------------
41
- const Pool = {
42
- el: null,
43
- ensurePool() {
44
- if (this.el && document.body.contains(this.el)) return this.el;
45
- const d = document.createElement('div');
46
- d.id = 'ezoic-placeholder-pool';
47
- d.style.display = 'none';
48
- d.setAttribute('aria-hidden', 'true');
49
- document.body.appendChild(d);
50
- this.el = d;
51
- return d;
52
- },
53
- ensurePlaceholder(id) {
54
- if (!id) return null;
55
- let ph = document.getElementById(id);
56
- if (ph) return ph;
57
- const pool = this.ensurePool();
58
- ph = document.createElement('div');
59
- ph.id = id;
60
- pool.appendChild(ph);
61
- return ph;
62
- },
63
- returnToPool(id) {
64
- const ph = document.getElementById(id);
65
- if (!ph) return;
66
- const pool = this.ensurePool();
67
- if (ph.parentElement !== pool) {
68
- pool.appendChild(ph);
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);
69
71
  }
70
72
  }
71
- };
73
+ return out;
74
+ }
72
75
 
73
- // -------------------------
74
- // Ezoic showAds safe wrapper (installed even if defined later)
75
- // -------------------------
76
- function normalizeIds(argsLike) {
77
- const args = Array.from(argsLike);
78
- if (!args.length) return [];
79
- // showAds([id1,id2]) or showAds([[...]]) edge
80
- if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
81
- return args.flat().map(String);
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);
82
83
  }
83
84
 
84
- function wrapShowAds(original) {
85
- if (!original || original.__nodebbSafeWrapped) return original;
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
+ }
86
95
 
87
- const wrapped = function (...args) {
88
- const ids = normalizeIds(args);
89
- if (!ids.length) return;
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
+ }
90
108
 
91
- for (const id of ids) {
92
- // Ensure placeholder exists somewhere (pool), so ez-standalone won't log "does not exist"
93
- Pool.ensurePlaceholder(id);
109
+ function getTopicItems() {
110
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
111
+ }
94
112
 
95
- const el = document.getElementById(id);
96
- if (el && document.body.contains(el)) {
97
- try {
98
- // Call one-by-one to avoid batch logging on missing nodes
99
- original.call(window.ezstandalone, id);
100
- } catch (e) {
101
- // swallow: Ezoic can throw if called during transitions
102
- }
103
- }
113
+ function getCategoryItems() {
114
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
115
+ }
116
+
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
+ }
128
+
129
+ // ---------- warm-up & patching ----------
130
+
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);
104
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) {}
182
+ }
183
+ };
184
+ } catch (e) {}
105
185
  };
106
186
 
107
- wrapped.__nodebbSafeWrapped = true;
108
- return wrapped;
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
+ }
109
195
  }
110
196
 
111
-
112
- function installShowAdsHook() {
113
- // NOTE: Do NOT use Object.defineProperty accessor tricks on `ezstandalone.showAds`.
114
- // Ezoic's standalone loader may validate that property as a plain writable function,
115
- // and accessor hooks can cause `defineScript failed` / `displayScript failed`.
116
- // Instead, we only wrap it once it exists (event-driven paths will call this again).
117
- const ez = window.ezstandalone;
118
- if (!ez) return;
119
- if (!Array.isArray(ez.cmd)) ez.cmd = [];
120
- if (typeof ez.showAds === 'function' && !ez.showAds.__ezoicInfiniteWrapped) {
121
- ez.showAds = wrapShowAds(ez.showAds);
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;
208
+ }
122
209
  }
123
- }
124
-
125
- function ezCmd(fn) {
126
- // Tokenize so queued callbacks don't run after navigation
127
- const tokenAtSchedule = navToken;
128
- window.ezstandalone = window.ezstandalone || {};
129
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
130
- window.ezstandalone.cmd.push(function () {
131
- if (tokenAtSchedule !== navToken) return;
132
- try { fn(); } catch (e) {}
133
- });
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);
134
216
  }
135
217
 
136
- // -------------------------
137
- // Config (hard gate)
138
- // -------------------------
139
- function prefetchConfig() {
140
- if (cfgPromise) return cfgPromise;
141
-
142
- cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
143
- .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
144
- .then(data => {
145
- cfg = data;
146
- // Pre-create placeholders in pool so Ezoic never complains even before first injection
147
- const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
148
- ids.forEach(id => Pool.ensurePlaceholder(id));
149
- return cfg;
150
- })
151
- .catch(() => {
152
- cfg = null;
153
- return null;
154
- });
218
+ // ---------- insertion primitives ----------
155
219
 
156
- return cfgPromise;
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;
157
227
  }
158
228
 
159
- function parsePlaceholderIds(s) {
160
- return String(s || '')
161
- .split(',')
162
- .map(x => x.trim())
163
- .filter(Boolean);
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;
164
251
  }
165
252
 
166
- // -------------------------
167
- // Ad insertion logic
168
- // -------------------------
169
- const Ad = {
170
- // throttle showAds per placeholder id
171
- lastShowAt: new Map(),
172
- minShowIntervalMs: 1500,
253
+ function findWrap(kindClass, afterPos) {
254
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
255
+ }
173
256
 
174
- // Observers
175
- io: null,
176
- mo: null,
177
-
178
- // Bookkeeping
179
- insertedKeys: new Set(), // avoid duplicate injection on re-renders
180
-
181
- initObservers() {
182
- if (!this.io) {
183
- this.io = new IntersectionObserver((entries) => {
184
- for (const e of entries) {
185
- if (!e.isIntersecting) continue;
186
- const id = e.target && e.target.getAttribute('data-ezoic-id');
187
- if (id) this.requestShow(id);
188
- }
189
- }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
190
- }
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
+ }
191
274
 
192
- if (!this.mo) {
193
- this.mo = new MutationObserver(() => this.scheduleScan());
194
- const root = document.querySelector('#content, #panel, main, body');
195
- if (root) this.mo.observe(root, { childList: true, subtree: true });
196
- }
197
- },
198
-
199
- disconnectObservers() {
200
- try { this.io && this.io.disconnect(); } catch (e) {}
201
- try { this.mo && this.mo.disconnect(); } catch (e) {}
202
- this.io = null;
203
- this.mo = null;
204
- },
205
-
206
- scheduleScan() {
207
- if (rafScheduled) return;
208
- rafScheduled = true;
209
- requestAnimationFrame(() => {
210
- rafScheduled = false;
211
- this.scanAndInject();
212
- });
213
- },
214
-
215
- async scanAndInject() {
216
- const c = await prefetchConfig();
217
- if (!c) return;
218
-
219
- if (c.excluded) return; // HARD stop: never inject for excluded users
220
-
221
- installShowAdsHook();
222
- this.initObservers();
223
-
224
- if (c.enableBetweenAds) this.injectBetweenTopics(c);
225
- // Extend: categories ads if you use them
226
- },
227
-
228
- // NodeBB topic list injection (between li rows)
229
- injectBetweenTopics(c) {
230
- // NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
231
- // Older themes may use .topic-list
232
- const container =
233
- document.querySelector('ul[component="category"].topics-list') ||
234
- document.querySelector('ul[component="category"].topic-list') ||
235
- document.querySelector('ul.topics-list[component="category"]') ||
236
- document.querySelector('ul.topics-list') ||
237
- document.querySelector('.category ul.topics-list') ||
238
- document.querySelector('.category .topic-list') ||
239
- document.querySelector('.topics .topic-list') ||
240
- document.querySelector('.topic-list');
241
-
242
- if (!container) return;
243
-
244
- let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
245
- if (!rows.length) {
246
- // Fallback for older markups
247
- rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
248
- }
249
- if (!rows.length) return;
275
+ function pickId(pool) {
276
+ return pool.length ? pool.shift() : null;
277
+ }
250
278
 
251
- const ids = parsePlaceholderIds(c.placeholderIds);
252
- if (!ids.length) return;
279
+ function showAd(id) {
280
+ if (!id || EZOIC_BLOCKED) return;
253
281
 
254
- const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
282
+ const now = Date.now();
283
+ const last = state.lastShowById.get(id) || 0;
284
+ if (now - last < 1500) return; // basic throttle
255
285
 
256
- // HERO: early, above-the-fold (first eligible insertion point)
257
- if (c.showFirstTopicAd) {
258
- this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
259
- }
286
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
+ if (!ph || !ph.isConnected) return;
260
288
 
261
- // Between rows
262
- let idIndex = 0;
263
- for (let i = interval; i < rows.length; i += interval) {
264
- idIndex = (idIndex + 1) % ids.length;
265
- this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
266
- }
267
- },
289
+ state.lastShowById.set(id, now);
268
290
 
269
- insertAdAfterRow(rowEl, placeholderId, key) {
270
- if (!rowEl || !placeholderId || !key) return;
271
- if (this.insertedKeys.has(key)) return;
291
+ try {
292
+ window.ezstandalone = window.ezstandalone || {};
293
+ const ez = window.ezstandalone;
272
294
 
273
- // If already present nearby, skip
274
- if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
275
- this.insertedKeys.add(key);
295
+ // Fast path
296
+ if (typeof ez.showAds === 'function') {
297
+ ez.showAds(id);
298
+ sessionDefinedIds.add(id);
276
299
  return;
277
300
  }
278
301
 
279
- const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
280
- const wrapper = document.createElement(isLi ? 'li' : 'div');
281
- wrapper.className = 'ezoic-ad-wrapper';
282
- wrapper.setAttribute('role', 'presentation');
283
- wrapper.setAttribute('data-ezoic-id', placeholderId);
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 ----------
320
+
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 });
335
+ } catch (e) {
336
+ state.io = null;
337
+ }
338
+ return state.io;
339
+ }
340
+
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) {}
352
+ }
353
+
354
+ // ---------- insertion logic ----------
355
+
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);
364
+ }
365
+
366
+ function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
+ if (!items.length) return 0;
284
368
 
285
- // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
286
- if (isLi && rowEl.classList.contains('list-group-item')) {
287
- wrapper.classList.add('list-group-item');
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;
379
+
380
+ const id = pickId(pool);
381
+ if (!id) break;
382
+
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;
288
389
  }
289
390
 
290
- // Ensure placeholder exists (in pool) then move it into wrapper
291
- const ph = Pool.ensurePlaceholder(placeholderId);
292
- // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
293
- try { wrapper.appendChild(ph); } catch (e) {}
391
+ observePlaceholder(id);
392
+ inserted += 1;
393
+ }
394
+
395
+ return inserted;
396
+ }
397
+
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
+ }
429
+
430
+ if (!items.length) return;
431
+
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; }
438
+
439
+ const id = pickId(pool);
440
+ if (!id) return;
441
+
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
+ }
449
+
450
+ state.heroDoneForPage = true;
451
+ observePlaceholder(id);
452
+ }
453
+
454
+ async function runCore() {
455
+ if (EZOIC_BLOCKED) return;
294
456
 
295
- // Insert into DOM
296
- rowEl.insertAdjacentElement('afterend', wrapper);
297
- this.insertedKeys.add(key);
457
+ patchShowAds();
298
458
 
299
- // Observe for preloading and request a show
300
- if (this.io) this.io.observe(wrapper);
459
+ const cfg = await fetchConfigOnce();
460
+ if (!cfg || cfg.excluded) return;
461
+ initPools(cfg);
301
462
 
302
- // If above fold, request immediately (no timeout)
303
- const rect = wrapper.getBoundingClientRect();
304
- if (rect.top < (window.innerHeight * 1.5)) {
305
- this.requestShow(placeholderId);
463
+ const kind = getKind();
464
+
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
+ );
306
475
  }
307
- },
308
-
309
- requestShow(placeholderId) {
310
- if (!placeholderId) return;
311
-
312
- const last = this.lastShowAt.get(placeholderId) || 0;
313
- const t = now();
314
- if ((t - last) < this.minShowIntervalMs) return;
315
- this.lastShowAt.set(placeholderId, t);
316
-
317
- // Ensure placeholder exists in DOM (pool or wrapper)
318
- Pool.ensurePlaceholder(placeholderId);
319
-
320
- // Use ez cmd queue if available
321
- const doShow = () => {
322
- if (!window.ezstandalone || !window.ezstandalone.showAds) return;
323
- // showAds is wrapped; calling with one id is safest
324
- window.ezstandalone.showAds(placeholderId);
325
- };
326
-
327
- if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
328
- ezCmd(doShow);
329
- } else {
330
- doShow();
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
+ );
486
+ }
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
+ );
331
497
  }
332
- },
333
-
334
- // On navigation, return all placeholders to pool so they still exist
335
- reclaimPlaceholders() {
336
- const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
337
- wrappers.forEach(w => {
338
- const id = w.getAttribute('data-ezoic-id');
339
- if (id) Pool.returnToPool(id);
340
- });
341
- // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
342
- this.insertedKeys.clear();
343
- this.lastShowAt.clear();
344
498
  }
345
- };
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) {}
521
+ });
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
+ }
346
539
 
347
- // -------------------------
348
- // NodeBB hooks
349
- // -------------------------
350
- function onAjaxifyStart() {
351
- navToken++;
352
- Ad.disconnectObservers();
353
- Ad.reclaimPlaceholders();
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) {}
354
548
  }
355
549
 
356
- function onAjaxifyEnd() {
357
- // Kick scan for new page
358
- prefetchConfig();
359
- Ad.scheduleScan();
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
+ });
574
+
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
580
  }
361
581
 
362
- // NodeBB exposes ajaxify events on document
363
- on('ajaxify.start', onAjaxifyStart);
364
- on('ajaxify.end', onAjaxifyEnd);
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 });
592
+ }
593
+
594
+ // ---------- boot ----------
595
+
596
+ state.pageKey = getPageKey();
597
+ warmUpNetwork();
598
+ patchShowAds();
599
+ ensurePreloadObserver();
600
+ ensureDomObserver();
365
601
 
366
- // First load
367
- once('DOMContentLoaded', () => {
368
- installShowAdsHook();
369
- prefetchConfig();
370
- Ad.scheduleScan();
371
- });
602
+ bindNodeBB();
603
+ bindScroll();
372
604
 
605
+ // First paint: try hero + run
606
+ EZOIC_BLOCKED = false;
607
+ insertHeroAdEarly().catch(() => {});
608
+ scheduleRun();
373
609
  })();
package/public/style.css CHANGED
@@ -1,9 +1,21 @@
1
- .ezoic-ad,
2
- .ezoic-ad *,
3
- span.ezoic-ad,
4
- span[class*="ezoic"] {
5
- min-height: 0 !important;
6
- min-width: 0 !important;
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;
7
8
  }
8
9
 
9
- .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}
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
+ }