nodebb-plugin-ezoic-infinite 1.5.5 → 1.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -68,7 +68,7 @@ async function getSettings() {
68
68
  async function isUserExcluded(uid, excludedGroups) {
69
69
  if (!uid || !excludedGroups.length) return false;
70
70
  const userGroups = await groups.getUserGroups([uid]);
71
- return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : g));
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : String(g)));
72
72
  }
73
73
 
74
74
  plugin.onSettingsSet = function (data) {
@@ -111,6 +111,7 @@ plugin.init = async ({ router, middleware }) => {
111
111
 
112
112
  res.json({
113
113
  excluded,
114
+ excludedGroups: settings.excludedGroups,
114
115
  enableBetweenAds: settings.enableBetweenAds,
115
116
  showFirstTopicAd: settings.showFirstTopicAd,
116
117
  placeholderIds: settings.placeholderIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
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,63 +1,50 @@
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
10
4
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
11
5
 
12
6
  const WRAP_CLASS = 'ezoic-ad';
13
7
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
8
 
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
9
  const SELECTORS = {
22
10
  topicItem: 'li[component="category/topic"]',
23
- postItem: '[component="post"][data-pid]',
24
11
  categoryItem: 'li[component="categories/category"]',
12
+ postItem: '[component="post"][data-pid]',
13
+ postContent: '[component="post/content"]',
25
14
  };
26
15
 
27
- // Hard block during navigation to avoid “placeholder does not exist” spam
28
- let EZOIC_BLOCKED = false;
29
-
16
+ // ----------------------------
17
+ // State
18
+ // ----------------------------
30
19
  const state = {
31
20
  pageKey: null,
21
+ pageToken: 0,
22
+ cfgPromise: null,
32
23
  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,
24
+ // throttle per id
25
+ lastShowAt: new Map(),
26
+ // observed placeholders -> ids
47
27
  io: null,
48
- runQueued: false,
49
-
50
- // hero
51
- heroDoneForPage: false,
28
+ mo: null,
29
+ scheduled: false,
52
30
  };
53
31
 
54
- const sessionDefinedIds = new Set();
55
- const insertingIds = new Set();
56
-
57
- // ---------- small utils ----------
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
+ }
58
45
 
59
46
  function normalizeBool(v) {
60
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
47
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on' || v === 'yes';
61
48
  }
62
49
 
63
50
  function uniqInts(lines) {
@@ -75,22 +62,7 @@
75
62
 
76
63
  function parsePool(raw) {
77
64
  if (!raw) return [];
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;
65
+ return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
94
66
  }
95
67
 
96
68
  function getKind() {
@@ -98,7 +70,6 @@
98
70
  if (/^\/topic\//.test(p)) return 'topic';
99
71
  if (/^\/category\//.test(p)) return 'categoryTopics';
100
72
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
101
-
102
73
  // fallback by DOM
103
74
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
104
75
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
@@ -118,135 +89,130 @@
118
89
  const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
119
90
  return nodes.filter((el) => {
120
91
  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]');
92
+ if (!el.querySelector(SELECTORS.postContent)) return false;
93
+ const parentPost = el.parentElement && el.parentElement.closest(SELECTORS.postItem);
123
94
  if (parentPost && parentPost !== el) return false;
124
95
  if (el.getAttribute('component') === 'post/parent') return false;
125
96
  return true;
126
97
  });
127
98
  }
128
99
 
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);
151
- }
152
- } catch (e) {}
100
+ function isPlaceholderPresent(id) {
101
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
102
+ return !!(el && el.isConnected);
153
103
  }
154
104
 
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
- }
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
+ });
195
112
  }
196
113
 
197
- // ---------- config & pools ----------
198
-
199
- async function fetchConfigOnce() {
200
- if (state.cfg) return state.cfg;
114
+ // ----------------------------
115
+ // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
+ // ----------------------------
117
+ function patchShowAds() {
201
118
  try {
202
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
203
- if (!res.ok) return null;
204
- state.cfg = await res.json();
205
- return state.cfg;
206
- } catch (e) {
207
- return null;
208
- }
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);
162
+ }
163
+ } catch (e) {}
209
164
  }
210
165
 
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);
166
+ function safeCmd(token, fn) {
167
+ 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) {}
216
177
  }
217
178
 
218
- // ---------- insertion primitives ----------
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);
219
186
 
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;
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
+ });
227
195
  }
228
196
 
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
- }
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');
237
202
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
238
- if (wrap.tagName === 'LI') {
239
- wrap.setAttribute('role', 'presentation');
240
- wrap.setAttribute('aria-hidden', 'true');
241
- }
242
203
  wrap.setAttribute('data-ezoic-after', String(afterPos));
204
+ wrap.setAttribute('role', 'presentation');
243
205
  wrap.style.width = '100%';
244
206
 
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
+
245
213
  const ph = document.createElement('div');
246
214
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
247
- ph.setAttribute('data-ezoic-id', String(id));
248
215
  wrap.appendChild(ph);
249
-
250
216
  return wrap;
251
217
  }
252
218
 
@@ -257,353 +223,255 @@
257
223
  function insertAfter(target, id, kindClass, afterPos) {
258
224
  if (!target || !target.insertAdjacentElement) return null;
259
225
  if (findWrap(kindClass, afterPos)) return null;
260
- if (insertingIds.has(id)) return null;
261
226
 
262
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
263
- if (existingPh && existingPh.isConnected) return null;
227
+ // avoid duplicates if already exists
228
+ if (isPlaceholderPresent(id)) return null;
264
229
 
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);
230
+ const liLike = String(target.tagName).toUpperCase() === 'LI';
231
+ const wrap = buildWrap(id, kindClass, afterPos, liLike);
232
+
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');
272
236
  }
273
- }
274
237
 
275
- function pickId(pool) {
276
- return pool.length ? pool.shift() : null;
238
+ target.insertAdjacentElement('afterend', wrap);
239
+ return wrap;
277
240
  }
278
241
 
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
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 });
258
+ }
285
259
 
260
+ function observePlaceholder(id) {
261
+ ensureIO();
262
+ if (!state.io) return;
286
263
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
287
- if (!ph || !ph.isConnected) return;
264
+ if (!ph) return;
265
+ try { state.io.observe(ph); } catch (e) {}
266
+ }
288
267
 
289
- state.lastShowById.set(id, now);
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
+ }
290
273
 
291
- try {
292
- window.ezstandalone = window.ezstandalone || {};
293
- const ez = window.ezstandalone;
274
+ // ----------------------------
275
+ // Config / exclusion
276
+ // ----------------------------
277
+ async function fetchConfig() {
278
+ if (state.cfg) return state.cfg;
279
+ if (state.cfgPromise) return state.cfgPromise;
294
280
 
295
- // Fast path
296
- if (typeof ez.showAds === 'function') {
297
- ez.showAds(id);
298
- sessionDefinedIds.add(id);
299
- return;
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;
300
292
  }
293
+ })();
301
294
 
302
- // Queue once for when Ezoic is ready
303
- ez.cmd = ez.cmd || [];
304
- if (!ph.__ezoicQueued) {
305
- ph.__ezoicQueued = true;
306
- ez.cmd.push(() => {
307
- try {
308
- if (EZOIC_BLOCKED) return;
309
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
310
- if (!el || !el.isConnected) return;
311
- window.ezstandalone.showAds(id);
312
- sessionDefinedIds.add(id);
313
- } catch (e) {}
314
- });
315
- }
316
- } catch (e) {}
295
+ return state.cfgPromise;
317
296
  }
318
297
 
319
- // ---------- preload / above-the-fold ----------
320
-
321
- function ensurePreloadObserver() {
322
- if (state.io) return state.io;
298
+ function getUserGroupNamesFromAjaxify() {
323
299
  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;
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);
307
+ } catch (e) {}
308
+ return [];
339
309
  }
340
310
 
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) {}
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);
352
316
  }
353
317
 
354
- // ---------- insertion logic ----------
318
+ function isExcludedClientSide(cfg) {
319
+ // Prefer server decision if present
320
+ if (cfg && cfg.excluded === true) return true;
321
+
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;
355
327
 
328
+ const set = new Set(userGroups);
329
+ return excludedGroups.some(g => set.has(g));
330
+ }
331
+
332
+ // ----------------------------
333
+ // Core injection
334
+ // ----------------------------
356
335
  function computeTargets(count, interval, showFirst) {
357
336
  const out = [];
358
337
  if (count <= 0) return out;
359
338
  if (showFirst) out.push(1);
360
- for (let i = 1; i <= count; i++) {
361
- if (i % interval === 0) out.push(i);
362
- }
339
+ for (let i = interval; i <= count; i += interval) out.push(i);
363
340
  return Array.from(new Set(out)).sort((a, b) => a - b);
364
341
  }
365
342
 
366
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
367
- if (!items.length) return 0;
343
+ function injectBetween(kindClass, items, interval, showFirst, pool) {
344
+ if (!items.length || !pool.length) return [];
368
345
 
369
346
  const targets = computeTargets(items.length, interval, showFirst);
370
- let inserted = 0;
371
-
347
+ const insertedIds = [];
372
348
  for (const afterPos of targets) {
373
- if (inserted >= MAX_INSERTS_PER_RUN) break;
374
-
375
349
  const el = items[afterPos - 1];
376
350
  if (!el || !el.isConnected) continue;
377
- if (isAdjacentAd(el)) continue;
351
+ if (!pool.length) break;
378
352
  if (findWrap(kindClass, afterPos)) continue;
379
353
 
380
- const id = pickId(pool);
381
- if (!id) break;
382
-
383
- usedSet.add(id);
354
+ const id = pool.shift();
384
355
  const wrap = insertAfter(el, id, kindClass, afterPos);
385
356
  if (!wrap) {
386
- usedSet.delete(id);
357
+ // push back if couldn't insert
387
358
  pool.unshift(id);
388
359
  continue;
389
360
  }
390
361
 
391
- observePlaceholder(id);
392
- inserted += 1;
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
+ }
393
372
  }
373
+ return insertedIds;
374
+ }
394
375
 
395
- return inserted;
376
+ function removeAllAds() {
377
+ try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
396
378
  }
397
379
 
398
- async function insertHeroAdEarly() {
399
- if (state.heroDoneForPage) return;
400
- const cfg = await fetchConfigOnce();
401
- if (!cfg || cfg.excluded) return;
380
+ async function runCore() {
381
+ state.pageKey = getPageKey();
402
382
 
403
- initPools(cfg);
383
+ patchShowAds();
404
384
 
405
- const kind = getKind();
406
- let items = [];
407
- let pool = null;
408
- let usedSet = null;
409
- let kindClass = '';
385
+ const cfg = await fetchConfig();
386
+ if (!cfg) return;
410
387
 
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 {
388
+ // If excluded: ensure we remove any previously injected wrappers
389
+ if (isExcludedClientSide(cfg)) {
390
+ removeAllAds();
427
391
  return;
428
392
  }
429
393
 
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;
394
+ const kind = getKind();
441
395
 
442
- usedSet.add(id);
443
- const wrap = insertAfter(el, id, kindClass, afterPos);
444
- if (!wrap) {
445
- usedSet.delete(id);
446
- pool.unshift(id);
396
+ 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
+ );
447
405
  return;
448
406
  }
449
407
 
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
- }
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
+ );
417
+ return;
498
418
  }
499
- }
500
419
 
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
- });
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
+ );
429
+ }
510
430
  }
511
431
 
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
432
+ function cleanupForNav() {
433
+ // New token => any pending safeCmd work becomes stale
434
+ state.pageToken += 1;
525
435
  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
- }
436
+ state.cfgPromise = null;
437
+ state.lastShowAt.clear();
539
438
 
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) {}
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;
444
+
445
+ removeAllAds();
548
446
  }
549
447
 
550
- function bindNodeBB() {
448
+ // ----------------------------
449
+ // Bind to NodeBB 4.x ajaxify events
450
+ // ----------------------------
451
+ function bind() {
551
452
  if (!$) return;
552
453
 
553
454
  $(window).off('.ezoicInfinite');
554
455
 
555
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
556
- cleanup();
557
- });
456
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanupForNav());
558
457
 
559
458
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
560
459
  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();
460
+ ensureMO();
461
+ schedule(runCore);
573
462
  });
574
463
 
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
- });
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));
580
469
  }
581
470
 
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
-
471
+ // Boot
472
+ cleanupForNav();
473
+ bind();
474
+ ensureMO();
596
475
  state.pageKey = getPageKey();
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
- })();
476
+ schedule(runCore);
477
+ })();
package/public/style.css CHANGED
@@ -1,21 +1,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
- }
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
+ }