nodebb-plugin-ezoic-infinite 1.5.7 → 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, 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.7",
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,50 +1,63 @@
1
1
  (function () {
2
2
  'use strict';
3
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
4
10
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
11
 
6
12
  const WRAP_CLASS = 'ezoic-ad';
7
13
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
8
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
+
9
21
  const SELECTORS = {
10
22
  topicItem: 'li[component="category/topic"]',
11
- categoryItem: 'li[component="categories/category"]',
12
23
  postItem: '[component="post"][data-pid]',
13
- postContent: '[component="post/content"]',
24
+ categoryItem: 'li[component="categories/category"]',
14
25
  };
15
26
 
16
- // ----------------------------
17
- // State
18
- // ----------------------------
27
+ // Hard block during navigation to avoid “placeholder does not exist” spam
28
+ let EZOIC_BLOCKED = false;
29
+
19
30
  const state = {
20
31
  pageKey: null,
21
- pageToken: 0,
22
- cfgPromise: null,
23
32
  cfg: null,
24
- // throttle per id
25
- lastShowAt: new Map(),
26
- // observed placeholders -> ids
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,
27
47
  io: null,
28
- mo: null,
29
- scheduled: false,
48
+ runQueued: false,
49
+
50
+ // hero
51
+ heroDoneForPage: false,
30
52
  };
31
53
 
32
- // ----------------------------
33
- // Small utils
34
- // ----------------------------
35
- function getPageKey() {
36
- try {
37
- const ax = window.ajaxify;
38
- if (ax && ax.data) {
39
- if (ax.data.tid) return `topic:${ax.data.tid}`;
40
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
41
- }
42
- } catch (e) {}
43
- return window.location.pathname || '';
44
- }
54
+ const sessionDefinedIds = new Set();
55
+ const insertingIds = new Set();
56
+
57
+ // ---------- small utils ----------
45
58
 
46
59
  function normalizeBool(v) {
47
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on' || v === 'yes';
60
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
48
61
  }
49
62
 
50
63
  function uniqInts(lines) {
@@ -62,7 +75,22 @@
62
75
 
63
76
  function parsePool(raw) {
64
77
  if (!raw) return [];
65
- return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
78
+ const lines = String(raw)
79
+ .split(/\r?\n/)
80
+ .map(s => s.trim())
81
+ .filter(Boolean);
82
+ return uniqInts(lines);
83
+ }
84
+
85
+ function getPageKey() {
86
+ try {
87
+ const ax = window.ajaxify;
88
+ if (ax && ax.data) {
89
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
90
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
91
+ }
92
+ } catch (e) {}
93
+ return window.location.pathname;
66
94
  }
67
95
 
68
96
  function getKind() {
@@ -70,6 +98,7 @@
70
98
  if (/^\/topic\//.test(p)) return 'topic';
71
99
  if (/^\/category\//.test(p)) return 'categoryTopics';
72
100
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
101
+
73
102
  // fallback by DOM
74
103
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
75
104
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
@@ -89,130 +118,135 @@
89
118
  const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
90
119
  return nodes.filter((el) => {
91
120
  if (!el || !el.isConnected) return false;
92
- if (!el.querySelector(SELECTORS.postContent)) return false;
93
- const parentPost = el.parentElement && el.parentElement.closest(SELECTORS.postItem);
121
+ if (!el.querySelector('[component="post/content"]')) return false;
122
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
94
123
  if (parentPost && parentPost !== el) return false;
95
124
  if (el.getAttribute('component') === 'post/parent') return false;
96
125
  return true;
97
126
  });
98
127
  }
99
128
 
100
- function isPlaceholderPresent(id) {
101
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
102
- return !!(el && el.isConnected);
103
- }
129
+ // ---------- warm-up & patching ----------
104
130
 
105
- function schedule(fn) {
106
- if (state.scheduled) return;
107
- state.scheduled = true;
108
- requestAnimationFrame(() => {
109
- state.scheduled = false;
110
- try { fn(); } catch (e) {}
111
- });
112
- }
113
-
114
- // ----------------------------
115
- // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
- // ----------------------------
117
- function patchShowAds() {
131
+ const _warmLinksDone = new Set();
132
+ function warmUpNetwork() {
118
133
  try {
119
- window.ezstandalone = window.ezstandalone || {};
120
- const ez = window.ezstandalone;
121
- if (ez.__nodebbEzoicPatched) return;
122
-
123
- // If showAds isn't ready yet, patch when it appears via cmd (no polling)
124
- const apply = () => {
125
- try {
126
- if (!window.ezstandalone || typeof window.ezstandalone.showAds !== 'function') return;
127
- const ez2 = window.ezstandalone;
128
- if (ez2.__nodebbEzoicPatched) return;
129
-
130
- const orig = ez2.showAds;
131
- ez2.showAds = function () {
132
- // Normalize ids from:
133
- // - showAds([1,2])
134
- // - showAds(1,2,3)
135
- // - showAds(1)
136
- const ids = [];
137
- if (arguments.length === 1 && Array.isArray(arguments[0])) {
138
- for (const v of arguments[0]) ids.push(v);
139
- } else {
140
- for (let i = 0; i < arguments.length; i++) ids.push(arguments[i]);
141
- }
142
-
143
- const seen = new Set();
144
- for (const v of ids) {
145
- const id = parseInt(v, 10);
146
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
147
- if (!isPlaceholderPresent(id)) continue;
148
- seen.add(id);
149
- try { orig.call(ez2, id); } catch (e) {}
150
- }
151
- };
152
-
153
- ez2.__nodebbEzoicPatched = true;
154
- } catch (e) {}
155
- };
156
-
157
- apply();
158
- if (!window.ezstandalone.__nodebbEzoicPatchQueued) {
159
- window.ezstandalone.__nodebbEzoicPatchQueued = true;
160
- ez.cmd = ez.cmd || [];
161
- ez.cmd.push(apply);
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);
162
151
  }
163
152
  } catch (e) {}
164
153
  }
165
154
 
166
- function safeCmd(token, fn) {
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) {}
185
+ };
186
+
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
+ }
195
+ }
196
+
197
+ // ---------- config & pools ----------
198
+
199
+ async function fetchConfigOnce() {
200
+ if (state.cfg) return state.cfg;
167
201
  try {
168
- window.ezstandalone = window.ezstandalone || {};
169
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
170
- window.ezstandalone.cmd.push(function () {
171
- // Drop stale work after ajaxify navigation
172
- if (token !== state.pageToken) return;
173
- if (getPageKey() !== state.pageKey) return;
174
- try { fn(); } catch (e) {}
175
- });
176
- } catch (e) {}
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
+ }
177
209
  }
178
210
 
179
- function showAd(id) {
180
- if (!id) return;
181
- // throttle to avoid repeated calls during rerenders
182
- const now = Date.now();
183
- const last = state.lastShowAt.get(id) || 0;
184
- if (now - last < 1500) return;
185
- state.lastShowAt.set(id, now);
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
+ }
186
217
 
187
- const token = state.pageToken;
188
- safeCmd(token, () => {
189
- if (!isPlaceholderPresent(id)) return;
190
- patchShowAds();
191
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
192
- window.ezstandalone.showAds(id);
193
- }
194
- });
218
+ // ---------- insertion primitives ----------
219
+
220
+ function isAdjacentAd(target) {
221
+ if (!target) return false;
222
+ const next = target.nextElementSibling;
223
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
224
+ const prev = target.previousElementSibling;
225
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
226
+ return false;
195
227
  }
196
228
 
197
- // ----------------------------
198
- // DOM insertion (HTML-valid: if target is <li>, wrapper is <li>)
199
- // ----------------------------
200
- function buildWrap(id, kindClass, afterPos, liLike) {
201
- const wrap = document.createElement(liLike ? 'li' : 'div');
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
+ }
202
237
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
238
+ if (wrap.tagName === 'LI') {
239
+ wrap.setAttribute('role', 'presentation');
240
+ wrap.setAttribute('aria-hidden', 'true');
241
+ }
203
242
  wrap.setAttribute('data-ezoic-after', String(afterPos));
204
- wrap.setAttribute('role', 'presentation');
205
243
  wrap.style.width = '100%';
206
244
 
207
- // Keep list styling if we're inside list-group
208
- if (liLike && !wrap.classList.contains('list-group-item')) {
209
- const targetIsListGroup = wrap.parentElement && wrap.parentElement.classList && wrap.parentElement.classList.contains('list-group');
210
- // can't detect parent yet; we'll keep it minimal
211
- }
212
-
213
245
  const ph = document.createElement('div');
214
246
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
+ ph.setAttribute('data-ezoic-id', String(id));
215
248
  wrap.appendChild(ph);
249
+
216
250
  return wrap;
217
251
  }
218
252
 
@@ -223,255 +257,353 @@
223
257
  function insertAfter(target, id, kindClass, afterPos) {
224
258
  if (!target || !target.insertAdjacentElement) return null;
225
259
  if (findWrap(kindClass, afterPos)) return null;
260
+ if (insertingIds.has(id)) return null;
226
261
 
227
- // avoid duplicates if already exists
228
- if (isPlaceholderPresent(id)) return null;
229
-
230
- const liLike = String(target.tagName).toUpperCase() === 'LI';
231
- const wrap = buildWrap(id, kindClass, afterPos, liLike);
262
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
263
+ if (existingPh && existingPh.isConnected) return null;
232
264
 
233
- // If list-group-item exists on target, mirror it to keep theme markup consistent
234
- if (liLike && target.classList && target.classList.contains('list-group-item')) {
235
- wrap.classList.add('list-group-item');
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);
236
272
  }
237
-
238
- target.insertAdjacentElement('afterend', wrap);
239
- return wrap;
240
273
  }
241
274
 
242
- // ----------------------------
243
- // Observers: preload + rerun on NodeBB DOM changes
244
- // ----------------------------
245
- function ensureIO() {
246
- if (state.io) return;
247
- if (!('IntersectionObserver' in window)) return;
248
-
249
- state.io = new IntersectionObserver((entries) => {
250
- for (const e of entries) {
251
- if (!e.isIntersecting) continue;
252
- const ph = e.target;
253
- const id = parseInt(String(ph.id).replace(PLACEHOLDER_PREFIX, ''), 10);
254
- if (Number.isFinite(id)) showAd(id);
255
- try { state.io.unobserve(ph); } catch (err) {}
256
- }
257
- }, { root: null, rootMargin: '1200px 0px', threshold: 0 });
275
+ function pickId(pool) {
276
+ return pool.length ? pool.shift() : null;
258
277
  }
259
278
 
260
- function observePlaceholder(id) {
261
- ensureIO();
262
- if (!state.io) return;
279
+ function showAd(id) {
280
+ if (!id || EZOIC_BLOCKED) return;
281
+
282
+ const now = Date.now();
283
+ const last = state.lastShowById.get(id) || 0;
284
+ if (now - last < 1500) return; // basic throttle
285
+
263
286
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
264
- if (!ph) return;
265
- try { state.io.observe(ph); } catch (e) {}
266
- }
287
+ if (!ph || !ph.isConnected) return;
267
288
 
268
- function ensureMO() {
269
- if (state.mo) return;
270
- state.mo = new MutationObserver(() => schedule(runCore));
271
- try { state.mo.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
272
- }
289
+ state.lastShowById.set(id, now);
273
290
 
274
- // ----------------------------
275
- // Config / exclusion
276
- // ----------------------------
277
- async function fetchConfig() {
278
- if (state.cfg) return state.cfg;
279
- if (state.cfgPromise) return state.cfgPromise;
291
+ try {
292
+ window.ezstandalone = window.ezstandalone || {};
293
+ const ez = window.ezstandalone;
280
294
 
281
- state.cfgPromise = (async () => {
282
- try {
283
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
284
- if (!res.ok) return null;
285
- const cfg = await res.json();
286
- state.cfg = cfg;
287
- return cfg;
288
- } catch (e) {
289
- return null;
290
- } finally {
291
- state.cfgPromise = null;
295
+ // Fast path
296
+ if (typeof ez.showAds === 'function') {
297
+ ez.showAds(id);
298
+ sessionDefinedIds.add(id);
299
+ return;
292
300
  }
293
- })();
294
301
 
295
- return state.cfgPromise;
296
- }
297
-
298
- function getUserGroupNamesFromAjaxify() {
299
- try {
300
- const ax = window.ajaxify;
301
- const u = ax && ax.data && (ax.data.user || ax.data.profile || null);
302
- if (!u) return [];
303
- // NodeBB varies by route/theme; handle multiple shapes
304
- const groupsA = u.groups || u.group_names || u.groupNames;
305
- if (Array.isArray(groupsA)) return groupsA.map(g => (g && g.name) ? g.name : String(g)).filter(Boolean);
306
- if (typeof groupsA === 'string') return groupsA.split(',').map(s => s.trim()).filter(Boolean);
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
+ }
307
316
  } catch (e) {}
308
- return [];
309
317
  }
310
318
 
311
- function parseExcludedGroupsFromCfg(cfg) {
312
- const v = cfg && (cfg.excludedGroups || cfg.excludedGroupNames || cfg.excluded_groups);
313
- if (!v) return [];
314
- if (Array.isArray(v)) return v.map(x => (x && x.name) ? x.name : String(x)).filter(Boolean);
315
- return String(v).split(',').map(s => s.trim()).filter(Boolean);
316
- }
319
+ // ---------- preload / above-the-fold ----------
317
320
 
318
- function isExcludedClientSide(cfg) {
319
- // Prefer server decision if present
320
- if (cfg && cfg.excluded === true) return true;
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
+ }
321
340
 
322
- // Extra safety: if cfg contains excluded group names, cross-check client-side.
323
- const excludedGroups = parseExcludedGroupsFromCfg(cfg);
324
- if (!excludedGroups.length) return false;
325
- const userGroups = getUserGroupNamesFromAjaxify();
326
- if (!userGroups.length) return false;
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) {}
327
346
 
328
- const set = new Set(userGroups);
329
- return excludedGroups.some(g => set.has(g));
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) {}
330
352
  }
331
353
 
332
- // ----------------------------
333
- // Core injection
334
- // ----------------------------
354
+ // ---------- insertion logic ----------
355
+
335
356
  function computeTargets(count, interval, showFirst) {
336
357
  const out = [];
337
358
  if (count <= 0) return out;
338
359
  if (showFirst) out.push(1);
339
- for (let i = interval; i <= count; i += interval) out.push(i);
360
+ for (let i = 1; i <= count; i++) {
361
+ if (i % interval === 0) out.push(i);
362
+ }
340
363
  return Array.from(new Set(out)).sort((a, b) => a - b);
341
364
  }
342
365
 
343
- function injectBetween(kindClass, items, interval, showFirst, pool) {
344
- if (!items.length || !pool.length) return [];
366
+ function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
+ if (!items.length) return 0;
345
368
 
346
369
  const targets = computeTargets(items.length, interval, showFirst);
347
- const insertedIds = [];
370
+ let inserted = 0;
371
+
348
372
  for (const afterPos of targets) {
373
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
374
+
349
375
  const el = items[afterPos - 1];
350
376
  if (!el || !el.isConnected) continue;
351
- if (!pool.length) break;
377
+ if (isAdjacentAd(el)) continue;
352
378
  if (findWrap(kindClass, afterPos)) continue;
353
379
 
354
- const id = pool.shift();
380
+ const id = pickId(pool);
381
+ if (!id) break;
382
+
383
+ usedSet.add(id);
355
384
  const wrap = insertAfter(el, id, kindClass, afterPos);
356
385
  if (!wrap) {
357
- // push back if couldn't insert
386
+ usedSet.delete(id);
358
387
  pool.unshift(id);
359
388
  continue;
360
389
  }
361
390
 
362
- insertedIds.push(id);
363
-
364
- // Above-the-fold: immediate attempt
365
- const rect = wrap.getBoundingClientRect ? wrap.getBoundingClientRect() : null;
366
- const vh = window.innerHeight || 800;
367
- if (rect && rect.top < (vh * 1.5)) {
368
- showAd(id);
369
- } else {
370
- observePlaceholder(id);
371
- }
391
+ observePlaceholder(id);
392
+ inserted += 1;
372
393
  }
373
- return insertedIds;
374
- }
375
394
 
376
- function removeAllAds() {
377
- try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
395
+ return inserted;
378
396
  }
379
397
 
380
- async function runCore() {
381
- state.pageKey = getPageKey();
398
+ async function insertHeroAdEarly() {
399
+ if (state.heroDoneForPage) return;
400
+ const cfg = await fetchConfigOnce();
401
+ if (!cfg || cfg.excluded) return;
382
402
 
383
- patchShowAds();
384
-
385
- const cfg = await fetchConfig();
386
- if (!cfg) return;
387
-
388
- // If excluded: ensure we remove any previously injected wrappers
389
- if (isExcludedClientSide(cfg)) {
390
- removeAllAds();
391
- return;
392
- }
403
+ initPools(cfg);
393
404
 
394
405
  const kind = getKind();
406
+ let items = [];
407
+ let pool = null;
408
+ let usedSet = null;
409
+ let kindClass = '';
395
410
 
396
411
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
397
- const pool = parsePool(cfg.messagePlaceholderIds);
398
- injectBetween(
399
- 'ezoic-ad-message',
400
- getPostContainers(),
401
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
402
- normalizeBool(cfg.showFirstMessageAd),
403
- pool
404
- );
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 {
405
427
  return;
406
428
  }
407
429
 
408
- if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
409
- const pool = parsePool(cfg.placeholderIds);
410
- injectBetween(
411
- 'ezoic-ad-between',
412
- getTopicItems(),
413
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
414
- normalizeBool(cfg.showFirstTopicAd),
415
- pool
416
- );
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);
417
447
  return;
418
448
  }
419
449
 
420
- if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
421
- const pool = parsePool(cfg.categoryPlaceholderIds);
422
- injectBetween(
423
- 'ezoic-ad-categories',
424
- getCategoryItems(),
425
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
426
- normalizeBool(cfg.showFirstCategoryAd),
427
- pool
428
- );
450
+ state.heroDoneForPage = true;
451
+ observePlaceholder(id);
452
+ }
453
+
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
+ );
475
+ }
476
+ } else if (kind === 'categoryTopics') {
477
+ if (normalizeBool(cfg.enableBetweenAds)) {
478
+ injectBetween(
479
+ 'ezoic-ad-between',
480
+ getTopicItems(),
481
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
482
+ normalizeBool(cfg.showFirstTopicAd),
483
+ state.poolTopics,
484
+ state.usedTopics
485
+ );
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
+ );
497
+ }
429
498
  }
430
499
  }
431
500
 
432
- function cleanupForNav() {
433
- // New token => any pending safeCmd work becomes stale
434
- state.pageToken += 1;
435
- state.cfg = null;
436
- state.cfgPromise = null;
437
- state.lastShowAt.clear();
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 ----------
438
513
 
439
- // Disconnect observers for old DOM
440
- try { if (state.io) state.io.disconnect(); } catch (e) {}
441
- state.io = null;
442
- try { if (state.mo) state.mo.disconnect(); } catch (e) {}
443
- state.mo = null;
514
+ function cleanup() {
515
+ EZOIC_BLOCKED = true;
444
516
 
445
- removeAllAds();
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)
446
538
  }
447
539
 
448
- // ----------------------------
449
- // Bind to NodeBB 4.x ajaxify events
450
- // ----------------------------
451
- function bind() {
540
+ function ensureDomObserver() {
541
+ if (state.domObs) return;
542
+ state.domObs = new MutationObserver(() => {
543
+ if (!EZOIC_BLOCKED) scheduleRun();
544
+ });
545
+ try {
546
+ state.domObs.observe(document.body, { childList: true, subtree: true });
547
+ } catch (e) {}
548
+ }
549
+
550
+ function bindNodeBB() {
452
551
  if (!$) return;
453
552
 
454
553
  $(window).off('.ezoicInfinite');
455
554
 
456
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanupForNav());
555
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
556
+ cleanup();
557
+ });
457
558
 
458
559
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
459
560
  state.pageKey = getPageKey();
460
- ensureMO();
461
- schedule(runCore);
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();
462
573
  });
463
574
 
464
- // Infinite scroll hooks
465
- $(window).on('action:posts.loaded.ezoicInfinite', () => schedule(runCore));
466
- $(window).on('action:topics.loaded.ezoicInfinite', () => schedule(runCore));
467
- $(window).on('action:category.loaded.ezoicInfinite', () => schedule(runCore));
468
- $(window).on('action:topic.loaded.ezoicInfinite', () => schedule(runCore));
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
+ });
469
580
  }
470
581
 
471
- // Boot
472
- cleanupForNav();
473
- bind();
474
- ensureMO();
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
+
475
596
  state.pageKey = getPageKey();
476
- schedule(runCore);
477
- })();
597
+ warmUpNetwork();
598
+ patchShowAds();
599
+ ensurePreloadObserver();
600
+ ensureDomObserver();
601
+
602
+ bindNodeBB();
603
+ bindScroll();
604
+
605
+ // First paint: try hero + run
606
+ EZOIC_BLOCKED = false;
607
+ insertHeroAdEarly().catch(() => {});
608
+ scheduleRun();
609
+ })();
package/public/style.css CHANGED
@@ -1,7 +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;
7
- }
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;
8
+ }
9
+
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
+ }