nodebb-plugin-ezoic-infinite 1.5.9 → 1.5.10

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