nodebb-plugin-ezoic-infinite 1.5.6 → 1.5.8

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) {
@@ -105,13 +105,13 @@ plugin.init = async ({ router, middleware }) => {
105
105
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
106
  router.get('/api/admin/plugins/ezoic-infinite', render);
107
107
 
108
- router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
108
+ router.get('/api/plugins/ezoic-infinite/config', middleware.authenticate, 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 uid = (typeof req.uid === 'number' ? req.uid : (req.user && typeof req.user.uid === 'number' ? req.user.uid : 0));
111
+ const excluded = await isUserExcluded(uid, settings.excludedGroups);
111
112
 
112
113
  res.json({
113
114
  excluded,
114
- excludedGroups: settings.excludedGroups,
115
115
  enableBetweenAds: settings.enableBetweenAds,
116
116
  showFirstTopicAd: settings.showFirstTopicAd,
117
117
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
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,386 +1,609 @@
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
- */
9
-
10
1
  (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);
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';
23
61
  }
24
62
 
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);
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);
66
71
  }
67
72
  }
68
- };
73
+ return out;
74
+ }
69
75
 
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);
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);
79
83
  }
80
84
 
81
- function wrapShowAds(original) {
82
- 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
+ }
83
95
 
84
- const wrapped = function (...args) {
85
- const ids = normalizeIds(args);
86
- 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
+ }
87
108
 
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);
109
+ function getTopicItems() {
110
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
111
+ }
91
112
 
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
99
- }
100
- }
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);
101
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) {}
102
185
  };
103
186
 
104
- wrapped.__nodebbSafeWrapped = true;
105
- 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
+ }
106
195
  }
107
196
 
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);
112
- }
197
+ // ---------- config & pools ----------
113
198
 
114
- // Hook future assignment (no polling)
199
+ async function fetchConfigOnce() {
200
+ if (state.cfg) return state.cfg;
115
201
  try {
116
- if (!window.ezstandalone) window.ezstandalone = {};
117
- const ez = window.ezstandalone;
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
+ }
209
+ }
118
210
 
119
- // If already has a setter installed, do nothing
120
- const desc = Object.getOwnPropertyDescriptor(ez, 'showAds');
121
- if (desc && (desc.set || desc.get)) return;
211
+ function initPools(cfg) {
212
+ if (!cfg) return;
213
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
214
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
215
+ if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
216
+ }
122
217
 
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
- });
218
+ // ---------- insertion primitives ----------
130
219
 
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.
220
+ function isAdjacentAd(target) {
221
+ if (!target) return false;
222
+ const next = target.nextElementSibling;
223
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
224
+ const prev = target.previousElementSibling;
225
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
226
+ return false;
227
+ }
228
+
229
+ function buildWrap(target, id, kindClass, afterPos) {
230
+ const tag = (target && target.tagName === 'LI') ? 'li' : 'div';
231
+ const wrap = document.createElement(tag);
232
+ if (tag === 'li') {
233
+ wrap.style.listStyle = 'none';
234
+ // preserve common NodeBB list styling
235
+ if (target && target.classList && target.classList.contains('list-group-item')) wrap.classList.add('list-group-item');
236
+ }
237
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
238
+ if (wrap.tagName === 'LI') {
239
+ wrap.setAttribute('role', 'presentation');
240
+ wrap.setAttribute('aria-hidden', 'true');
135
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;
136
251
  }
137
252
 
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
- });
253
+ function findWrap(kindClass, afterPos) {
254
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
147
255
  }
148
256
 
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
- });
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;
168
261
 
169
- return cfgPromise;
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
+ }
170
273
  }
171
274
 
172
- function parsePlaceholderIds(s) {
173
- return String(s || '')
174
- .split(',')
175
- .map(x => x.trim())
176
- .filter(Boolean);
275
+ function pickId(pool) {
276
+ return pool.length ? pool.shift() : null;
177
277
  }
178
278
 
179
- // -------------------------
180
- // Ad insertion logic
181
- // -------------------------
182
- const Ad = {
183
- // throttle showAds per placeholder id
184
- lastShowAt: new Map(),
185
- minShowIntervalMs: 1500,
279
+ function showAd(id) {
280
+ if (!id || EZOIC_BLOCKED) return;
186
281
 
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 });
203
- }
282
+ const now = Date.now();
283
+ const last = state.lastShowById.get(id) || 0;
284
+ if (now - last < 1500) return; // basic throttle
204
285
 
205
- if (!this.mo) {
206
- this.mo = new MutationObserver(() => this.scheduleScan());
207
- const root = document.querySelector('#content, #panel, main, body');
208
- if (root) this.mo.observe(root, { childList: true, subtree: true });
209
- }
210
- },
211
-
212
- disconnectObservers() {
213
- try { this.io && this.io.disconnect(); } catch (e) {}
214
- try { this.mo && this.mo.disconnect(); } catch (e) {}
215
- this.io = null;
216
- this.mo = null;
217
- },
218
-
219
- scheduleScan() {
220
- if (rafScheduled) return;
221
- rafScheduled = true;
222
- requestAnimationFrame(() => {
223
- rafScheduled = false;
224
- this.scanAndInject();
225
- });
226
- },
227
-
228
- async scanAndInject() {
229
- const c = await prefetchConfig();
230
- if (!c) return;
231
-
232
- if (c.excluded) return; // HARD stop: never inject for excluded users
233
-
234
- installShowAdsHook();
235
- this.initObservers();
236
-
237
- if (c.enableBetweenAds) this.injectBetweenTopics(c);
238
- // Extend: categories ads if you use them
239
- },
240
-
241
- // NodeBB topic list injection (between li rows)
242
- injectBetweenTopics(c) {
243
- // NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
244
- // Older themes may use .topic-list
245
- const container =
246
- document.querySelector('ul[component="category"].topics-list') ||
247
- document.querySelector('ul[component="category"].topic-list') ||
248
- document.querySelector('ul.topics-list[component="category"]') ||
249
- document.querySelector('ul.topics-list') ||
250
- document.querySelector('.category ul.topics-list') ||
251
- document.querySelector('.category .topic-list') ||
252
- document.querySelector('.topics .topic-list') ||
253
- document.querySelector('.topic-list');
254
-
255
- if (!container) return;
256
-
257
- let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
258
- if (!rows.length) {
259
- // Fallback for older markups
260
- rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
261
- }
262
- if (!rows.length) return;
286
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
+ if (!ph || !ph.isConnected) return;
263
288
 
264
- const ids = parsePlaceholderIds(c.placeholderIds);
265
- if (!ids.length) return;
289
+ state.lastShowById.set(id, now);
266
290
 
267
- const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
291
+ try {
292
+ window.ezstandalone = window.ezstandalone || {};
293
+ const ez = window.ezstandalone;
268
294
 
269
- // HERO: early, above-the-fold (first eligible insertion point)
270
- if (c.showFirstTopicAd) {
271
- this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
295
+ // Fast path
296
+ if (typeof ez.showAds === 'function') {
297
+ ez.showAds(id);
298
+ sessionDefinedIds.add(id);
299
+ return;
272
300
  }
273
301
 
274
- // Between rows
275
- let idIndex = 0;
276
- for (let i = interval; i < rows.length; i += interval) {
277
- idIndex = (idIndex + 1) % ids.length;
278
- this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
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
+ });
279
315
  }
280
- },
316
+ } catch (e) {}
317
+ }
281
318
 
282
- insertAdAfterRow(rowEl, placeholderId, key) {
283
- if (!rowEl || !placeholderId || !key) return;
284
- if (this.insertedKeys.has(key)) return;
319
+ // ---------- preload / above-the-fold ----------
285
320
 
286
- // If already present nearby, skip
287
- if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
288
- this.insertedKeys.add(key);
289
- return;
290
- }
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 ----------
291
355
 
292
- const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
293
- const wrapper = document.createElement(isLi ? 'li' : 'div');
294
- wrapper.className = 'ezoic-ad-wrapper';
295
- wrapper.setAttribute('role', 'presentation');
296
- wrapper.setAttribute('data-ezoic-id', placeholderId);
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;
368
+
369
+ const targets = computeTargets(items.length, interval, showFirst);
370
+ let inserted = 0;
371
+
372
+ for (const afterPos of targets) {
373
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
297
374
 
298
- // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
299
- if (isLi && rowEl.classList.contains('list-group-item')) {
300
- wrapper.classList.add('list-group-item');
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;
301
389
  }
302
390
 
303
- // Ensure placeholder exists (in pool) then move it into wrapper
304
- const ph = Pool.ensurePlaceholder(placeholderId);
305
- // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
306
- try { wrapper.appendChild(ph); } catch (e) {}
391
+ observePlaceholder(id);
392
+ inserted += 1;
393
+ }
307
394
 
308
- // Insert into DOM
309
- rowEl.insertAdjacentElement('afterend', wrapper);
310
- this.insertedKeys.add(key);
395
+ return inserted;
396
+ }
311
397
 
312
- // Observe for preloading and request a show
313
- if (this.io) this.io.observe(wrapper);
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
+ }
314
453
 
315
- // If above fold, request immediately (no timeout)
316
- const rect = wrapper.getBoundingClientRect();
317
- if (rect.top < (window.innerHeight * 1.5)) {
318
- this.requestShow(placeholderId);
454
+ async function runCore() {
455
+ if (EZOIC_BLOCKED) return;
456
+
457
+ patchShowAds();
458
+
459
+ const cfg = await fetchConfigOnce();
460
+ if (!cfg || cfg.excluded) return;
461
+ initPools(cfg);
462
+
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
+ );
319
475
  }
320
- },
321
-
322
- requestShow(placeholderId) {
323
- if (!placeholderId) return;
324
-
325
- const last = this.lastShowAt.get(placeholderId) || 0;
326
- const t = now();
327
- if ((t - last) < this.minShowIntervalMs) return;
328
- this.lastShowAt.set(placeholderId, t);
329
-
330
- // Ensure placeholder exists in DOM (pool or wrapper)
331
- Pool.ensurePlaceholder(placeholderId);
332
-
333
- // Use ez cmd queue if available
334
- const doShow = () => {
335
- if (!window.ezstandalone || !window.ezstandalone.showAds) return;
336
- // showAds is wrapped; calling with one id is safest
337
- window.ezstandalone.showAds(placeholderId);
338
- };
339
-
340
- if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
341
- ezCmd(doShow);
342
- } else {
343
- doShow();
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
+ );
344
497
  }
345
- },
346
-
347
- // On navigation, return all placeholders to pool so they still exist
348
- reclaimPlaceholders() {
349
- const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
350
- wrappers.forEach(w => {
351
- const id = w.getAttribute('data-ezoic-id');
352
- if (id) Pool.returnToPool(id);
353
- });
354
- // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
355
- this.insertedKeys.clear();
356
- this.lastShowAt.clear();
357
498
  }
358
- };
499
+ }
359
500
 
360
- // -------------------------
361
- // NodeBB hooks
362
- // -------------------------
363
- function onAjaxifyStart() {
364
- navToken++;
365
- Ad.disconnectObservers();
366
- Ad.reclaimPlaceholders();
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
+ });
367
510
  }
368
511
 
369
- function onAjaxifyEnd() {
370
- // Kick scan for new page
371
- prefetchConfig();
372
- Ad.scheduleScan();
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
+ }
539
+
540
+ function ensureDomObserver() {
541
+ if (state.domObs) return;
542
+ state.domObs = new MutationObserver(() => {
543
+ if (!EZOIC_BLOCKED) scheduleRun();
544
+ });
545
+ try {
546
+ state.domObs.observe(document.body, { childList: true, subtree: true });
547
+ } catch (e) {}
373
548
  }
374
549
 
375
- // NodeBB exposes ajaxify events on document
376
- on('ajaxify.start', onAjaxifyStart);
377
- on('ajaxify.end', onAjaxifyEnd);
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
+ });
580
+ }
581
+
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();
378
601
 
379
- // First load
380
- once('DOMContentLoaded', () => {
381
- installShowAdsHook();
382
- prefetchConfig();
383
- Ad.scheduleScan();
384
- });
602
+ bindNodeBB();
603
+ bindScroll();
385
604
 
605
+ // First paint: try hero + run
606
+ EZOIC_BLOCKED = false;
607
+ insertHeroAdEarly().catch(() => {});
608
+ scheduleRun();
386
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
+ }