nodebb-plugin-ezoic-infinite 1.4.98 → 1.4.99

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.4.98",
3
+ "version": "1.4.99",
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,58 +1,50 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- // NodeBB client context
5
4
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
6
5
 
7
6
  const WRAP_CLASS = 'ezoic-ad';
8
7
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
9
8
 
10
- // Insert at most N ads per run to keep the UI smooth on infinite scroll
11
- const MAX_INSERTS_PER_RUN = 3;
12
-
13
- // Preload before viewport (tune if you want even earlier)
14
- const PRELOAD_ROOT_MARGIN = '1200px 0px';
15
-
16
9
  const SELECTORS = {
17
10
  topicItem: 'li[component="category/topic"]',
18
- postItem: '[component="post"][data-pid]',
19
11
  categoryItem: 'li[component="categories/category"]',
12
+ postItem: '[component="post"][data-pid]',
13
+ postContent: '[component="post/content"]',
20
14
  };
21
15
 
22
- // Hard block during navigation to avoid “placeholder does not exist” spam
23
- let EZOIC_BLOCKED = false;
24
-
16
+ // ----------------------------
17
+ // State
18
+ // ----------------------------
25
19
  const state = {
26
20
  pageKey: null,
21
+ pageToken: 0,
22
+ cfgPromise: null,
27
23
  cfg: null,
28
-
29
- poolTopics: [],
30
- poolPosts: [],
31
- poolCategories: [],
32
-
33
- usedTopics: new Set(),
34
- usedPosts: new Set(),
35
- usedCategories: new Set(),
36
-
37
- // throttle per placeholder id
38
- lastShowById: new Map(),
39
-
40
- // observers / schedulers
41
- domObs: null,
24
+ // throttle per id
25
+ lastShowAt: new Map(),
26
+ // observed placeholders -> ids
42
27
  io: null,
43
- runQueued: false,
44
-
45
- // hero
46
- heroDoneForPage: false,
28
+ mo: null,
29
+ scheduled: false,
47
30
  };
48
31
 
49
- const sessionDefinedIds = new Set();
50
- const insertingIds = new Set();
51
-
52
- // ---------- 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
+ }
53
45
 
54
46
  function normalizeBool(v) {
55
- 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';
56
48
  }
57
49
 
58
50
  function uniqInts(lines) {
@@ -70,22 +62,7 @@
70
62
 
71
63
  function parsePool(raw) {
72
64
  if (!raw) return [];
73
- const lines = String(raw)
74
- .split(/\r?\n/)
75
- .map(s => s.trim())
76
- .filter(Boolean);
77
- return uniqInts(lines);
78
- }
79
-
80
- function getPageKey() {
81
- try {
82
- const ax = window.ajaxify;
83
- if (ax && ax.data) {
84
- if (ax.data.tid) return `topic:${ax.data.tid}`;
85
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
86
- }
87
- } catch (e) {}
88
- return window.location.pathname;
65
+ return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
89
66
  }
90
67
 
91
68
  function getKind() {
@@ -93,7 +70,6 @@
93
70
  if (/^\/topic\//.test(p)) return 'topic';
94
71
  if (/^\/category\//.test(p)) return 'categoryTopics';
95
72
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
96
-
97
73
  // fallback by DOM
98
74
  if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
99
75
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
@@ -113,135 +89,130 @@
113
89
  const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
114
90
  return nodes.filter((el) => {
115
91
  if (!el || !el.isConnected) return false;
116
- if (!el.querySelector('[component="post/content"]')) return false;
117
- const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
92
+ if (!el.querySelector(SELECTORS.postContent)) return false;
93
+ const parentPost = el.parentElement && el.parentElement.closest(SELECTORS.postItem);
118
94
  if (parentPost && parentPost !== el) return false;
119
95
  if (el.getAttribute('component') === 'post/parent') return false;
120
96
  return true;
121
97
  });
122
98
  }
123
99
 
124
- // ---------- warm-up & patching ----------
125
-
126
- const _warmLinksDone = new Set();
127
- function warmUpNetwork() {
128
- try {
129
- const head = document.head || document.getElementsByTagName('head')[0];
130
- if (!head) return;
131
- const links = [
132
- ['preconnect', 'https://g.ezoic.net', true],
133
- ['dns-prefetch', 'https://g.ezoic.net', false],
134
- ['preconnect', 'https://go.ezoic.net', true],
135
- ['dns-prefetch', 'https://go.ezoic.net', false],
136
- ];
137
- for (const [rel, href, cors] of links) {
138
- const key = `${rel}|${href}`;
139
- if (_warmLinksDone.has(key)) continue;
140
- _warmLinksDone.add(key);
141
- const link = document.createElement('link');
142
- link.rel = rel;
143
- link.href = href;
144
- if (cors) link.crossOrigin = 'anonymous';
145
- head.appendChild(link);
146
- }
147
- } catch (e) {}
100
+ function isPlaceholderPresent(id) {
101
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
102
+ return !!(el && el.isConnected);
148
103
  }
149
104
 
150
- // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
151
- function patchShowAds() {
152
- const applyPatch = () => {
153
- try {
154
- window.ezstandalone = window.ezstandalone || {};
155
- const ez = window.ezstandalone;
156
- if (window.__nodebbEzoicPatched) return;
157
- if (typeof ez.showAds !== 'function') return;
158
-
159
- window.__nodebbEzoicPatched = true;
160
- const orig = ez.showAds;
161
-
162
- ez.showAds = function (...args) {
163
- if (EZOIC_BLOCKED) return;
164
-
165
- let ids = [];
166
- if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
167
- else ids = args;
168
-
169
- const seen = new Set();
170
- for (const v of ids) {
171
- const id = parseInt(v, 10);
172
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
173
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
174
- if (!ph || !ph.isConnected) continue;
175
- seen.add(id);
176
- try { orig.call(ez, id); } catch (e) {}
177
- }
178
- };
179
- } catch (e) {}
180
- };
181
-
182
- applyPatch();
183
- if (!window.__nodebbEzoicPatched) {
184
- try {
185
- window.ezstandalone = window.ezstandalone || {};
186
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
187
- window.ezstandalone.cmd.push(applyPatch);
188
- } catch (e) {}
189
- }
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
+ });
190
112
  }
191
113
 
192
- // ---------- config & pools ----------
193
-
194
- async function fetchConfigOnce() {
195
- if (state.cfg) return state.cfg;
114
+ // ----------------------------
115
+ // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
+ // ----------------------------
117
+ function patchShowAds() {
196
118
  try {
197
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
198
- if (!res.ok) return null;
199
- state.cfg = await res.json();
200
- return state.cfg;
201
- } catch (e) {
202
- return null;
203
- }
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) {}
204
164
  }
205
165
 
206
- function initPools(cfg) {
207
- if (!cfg) return;
208
- if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
209
- if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
210
- if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
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) {}
211
177
  }
212
178
 
213
- // ---------- 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);
214
186
 
215
- function isAdjacentAd(target) {
216
- if (!target) return false;
217
- const next = target.nextElementSibling;
218
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
219
- const prev = target.previousElementSibling;
220
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
221
- return false;
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
+ });
222
195
  }
223
196
 
224
- function buildWrap(target, id, kindClass, afterPos) {
225
- const tag = (target && target.tagName === 'LI') ? 'li' : 'div';
226
- const wrap = document.createElement(tag);
227
- if (tag === 'li') {
228
- wrap.style.listStyle = 'none';
229
- // preserve common NodeBB list styling
230
- if (target && target.classList && target.classList.contains('list-group-item')) wrap.classList.add('list-group-item');
231
- }
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');
232
202
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
233
- if (wrap.tagName === 'LI') {
234
- wrap.setAttribute('role', 'presentation');
235
- wrap.setAttribute('aria-hidden', 'true');
236
- }
237
203
  wrap.setAttribute('data-ezoic-after', String(afterPos));
204
+ wrap.setAttribute('role', 'presentation');
238
205
  wrap.style.width = '100%';
239
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
+
240
213
  const ph = document.createElement('div');
241
214
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
242
- ph.setAttribute('data-ezoic-id', String(id));
243
215
  wrap.appendChild(ph);
244
-
245
216
  return wrap;
246
217
  }
247
218
 
@@ -252,353 +223,255 @@
252
223
  function insertAfter(target, id, kindClass, afterPos) {
253
224
  if (!target || !target.insertAdjacentElement) return null;
254
225
  if (findWrap(kindClass, afterPos)) return null;
255
- if (insertingIds.has(id)) return null;
256
226
 
257
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
258
- if (existingPh && existingPh.isConnected) return null;
227
+ // avoid duplicates if already exists
228
+ if (isPlaceholderPresent(id)) return null;
259
229
 
260
- insertingIds.add(id);
261
- try {
262
- const wrap = buildWrap(target, id, kindClass, afterPos);
263
- target.insertAdjacentElement('afterend', wrap);
264
- return wrap;
265
- } finally {
266
- insertingIds.delete(id);
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');
267
236
  }
268
- }
269
237
 
270
- function pickId(pool) {
271
- return pool.length ? pool.shift() : null;
238
+ target.insertAdjacentElement('afterend', wrap);
239
+ return wrap;
272
240
  }
273
241
 
274
- function showAd(id) {
275
- if (!id || EZOIC_BLOCKED) return;
276
-
277
- const now = Date.now();
278
- const last = state.lastShowById.get(id) || 0;
279
- if (now - last < 1500) return; // basic throttle
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
+ }
280
259
 
260
+ function observePlaceholder(id) {
261
+ ensureIO();
262
+ if (!state.io) return;
281
263
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
282
- if (!ph || !ph.isConnected) return;
264
+ if (!ph) return;
265
+ try { state.io.observe(ph); } catch (e) {}
266
+ }
283
267
 
284
- 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
+ }
285
273
 
286
- try {
287
- window.ezstandalone = window.ezstandalone || {};
288
- 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;
289
280
 
290
- // Fast path
291
- if (typeof ez.showAds === 'function') {
292
- ez.showAds(id);
293
- sessionDefinedIds.add(id);
294
- 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;
295
292
  }
293
+ })();
296
294
 
297
- // Queue once for when Ezoic is ready
298
- ez.cmd = ez.cmd || [];
299
- if (!ph.__ezoicQueued) {
300
- ph.__ezoicQueued = true;
301
- ez.cmd.push(() => {
302
- try {
303
- if (EZOIC_BLOCKED) return;
304
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
305
- if (!el || !el.isConnected) return;
306
- window.ezstandalone.showAds(id);
307
- sessionDefinedIds.add(id);
308
- } catch (e) {}
309
- });
310
- }
311
- } catch (e) {}
295
+ return state.cfgPromise;
312
296
  }
313
297
 
314
- // ---------- preload / above-the-fold ----------
315
-
316
- function ensurePreloadObserver() {
317
- if (state.io) return state.io;
298
+ function getUserGroupNamesFromAjaxify() {
318
299
  try {
319
- state.io = new IntersectionObserver((entries) => {
320
- for (const ent of entries) {
321
- if (!ent.isIntersecting) continue;
322
- const el = ent.target;
323
- try { state.io && state.io.unobserve(el); } catch (e) {}
324
-
325
- const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
326
- const id = parseInt(idAttr, 10);
327
- if (Number.isFinite(id) && id > 0) showAd(id);
328
- }
329
- }, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
330
- } catch (e) {
331
- state.io = null;
332
- }
333
- return state.io;
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 [];
334
309
  }
335
310
 
336
- function observePlaceholder(id) {
337
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
338
- if (!ph || !ph.isConnected) return;
339
- const io = ensurePreloadObserver();
340
- try { io && io.observe(ph); } catch (e) {}
341
-
342
- // If already above fold, fire immediately
343
- try {
344
- const r = ph.getBoundingClientRect();
345
- if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
346
- } catch (e) {}
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);
347
316
  }
348
317
 
349
- // ---------- insertion logic ----------
318
+ function isExcludedClientSide(cfg) {
319
+ // Prefer server decision if present
320
+ if (cfg && cfg.excluded === true) return true;
350
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;
327
+
328
+ const set = new Set(userGroups);
329
+ return excludedGroups.some(g => set.has(g));
330
+ }
331
+
332
+ // ----------------------------
333
+ // Core injection
334
+ // ----------------------------
351
335
  function computeTargets(count, interval, showFirst) {
352
336
  const out = [];
353
337
  if (count <= 0) return out;
354
338
  if (showFirst) out.push(1);
355
- for (let i = 1; i <= count; i++) {
356
- if (i % interval === 0) out.push(i);
357
- }
339
+ for (let i = interval; i <= count; i += interval) out.push(i);
358
340
  return Array.from(new Set(out)).sort((a, b) => a - b);
359
341
  }
360
342
 
361
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
362
- if (!items.length) return 0;
343
+ function injectBetween(kindClass, items, interval, showFirst, pool) {
344
+ if (!items.length || !pool.length) return [];
363
345
 
364
346
  const targets = computeTargets(items.length, interval, showFirst);
365
- let inserted = 0;
366
-
347
+ const insertedIds = [];
367
348
  for (const afterPos of targets) {
368
- if (inserted >= MAX_INSERTS_PER_RUN) break;
369
-
370
349
  const el = items[afterPos - 1];
371
350
  if (!el || !el.isConnected) continue;
372
- if (isAdjacentAd(el)) continue;
351
+ if (!pool.length) break;
373
352
  if (findWrap(kindClass, afterPos)) continue;
374
353
 
375
- const id = pickId(pool);
376
- if (!id) break;
377
-
378
- usedSet.add(id);
354
+ const id = pool.shift();
379
355
  const wrap = insertAfter(el, id, kindClass, afterPos);
380
356
  if (!wrap) {
381
- usedSet.delete(id);
357
+ // push back if couldn't insert
382
358
  pool.unshift(id);
383
359
  continue;
384
360
  }
385
361
 
386
- observePlaceholder(id);
387
- 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
+ }
388
372
  }
373
+ return insertedIds;
374
+ }
389
375
 
390
- return inserted;
376
+ function removeAllAds() {
377
+ try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
391
378
  }
392
379
 
393
- async function insertHeroAdEarly() {
394
- if (state.heroDoneForPage) return;
395
- const cfg = await fetchConfigOnce();
396
- if (!cfg || cfg.excluded) return;
380
+ async function runCore() {
381
+ state.pageKey = getPageKey();
397
382
 
398
- initPools(cfg);
383
+ patchShowAds();
399
384
 
400
- const kind = getKind();
401
- let items = [];
402
- let pool = null;
403
- let usedSet = null;
404
- let kindClass = '';
385
+ const cfg = await fetchConfig();
386
+ if (!cfg) return;
405
387
 
406
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
407
- items = getPostContainers();
408
- pool = state.poolPosts;
409
- usedSet = state.usedPosts;
410
- kindClass = 'ezoic-ad-message';
411
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
412
- items = getTopicItems();
413
- pool = state.poolTopics;
414
- usedSet = state.usedTopics;
415
- kindClass = 'ezoic-ad-between';
416
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
417
- items = getCategoryItems();
418
- pool = state.poolCategories;
419
- usedSet = state.usedCategories;
420
- kindClass = 'ezoic-ad-categories';
421
- } else {
388
+ // If excluded: ensure we remove any previously injected wrappers
389
+ if (isExcludedClientSide(cfg)) {
390
+ removeAllAds();
422
391
  return;
423
392
  }
424
393
 
425
- if (!items.length) return;
426
-
427
- // Insert after the very first item (above-the-fold)
428
- const afterPos = 1;
429
- const el = items[afterPos - 1];
430
- if (!el || !el.isConnected) return;
431
- if (isAdjacentAd(el)) return;
432
- if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
433
-
434
- const id = pickId(pool);
435
- if (!id) return;
394
+ const kind = getKind();
436
395
 
437
- usedSet.add(id);
438
- const wrap = insertAfter(el, id, kindClass, afterPos);
439
- if (!wrap) {
440
- usedSet.delete(id);
441
- 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
+ );
442
405
  return;
443
406
  }
444
407
 
445
- state.heroDoneForPage = true;
446
- observePlaceholder(id);
447
- }
448
-
449
- async function runCore() {
450
- if (EZOIC_BLOCKED) return;
451
-
452
- patchShowAds();
453
-
454
- const cfg = await fetchConfigOnce();
455
- if (!cfg || cfg.excluded) return;
456
- initPools(cfg);
457
-
458
- const kind = getKind();
459
-
460
- if (kind === 'topic') {
461
- if (normalizeBool(cfg.enableMessageAds)) {
462
- injectBetween(
463
- 'ezoic-ad-message',
464
- getPostContainers(),
465
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
466
- normalizeBool(cfg.showFirstMessageAd),
467
- state.poolPosts,
468
- state.usedPosts
469
- );
470
- }
471
- } else if (kind === 'categoryTopics') {
472
- if (normalizeBool(cfg.enableBetweenAds)) {
473
- injectBetween(
474
- 'ezoic-ad-between',
475
- getTopicItems(),
476
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
477
- normalizeBool(cfg.showFirstTopicAd),
478
- state.poolTopics,
479
- state.usedTopics
480
- );
481
- }
482
- } else if (kind === 'categories') {
483
- if (normalizeBool(cfg.enableCategoryAds)) {
484
- injectBetween(
485
- 'ezoic-ad-categories',
486
- getCategoryItems(),
487
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
488
- normalizeBool(cfg.showFirstCategoryAd),
489
- state.poolCategories,
490
- state.usedCategories
491
- );
492
- }
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;
493
418
  }
494
- }
495
419
 
496
- function scheduleRun() {
497
- if (state.runQueued) return;
498
- state.runQueued = true;
499
- window.requestAnimationFrame(() => {
500
- state.runQueued = false;
501
- const pk = getPageKey();
502
- if (state.pageKey && pk !== state.pageKey) return;
503
- runCore().catch(() => {});
504
- });
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
+ }
505
430
  }
506
431
 
507
- // ---------- observers / lifecycle ----------
508
-
509
- function cleanup() {
510
- EZOIC_BLOCKED = true;
511
-
512
- // remove all wrappers
513
- try {
514
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
515
- try { el.remove(); } catch (e) {}
516
- });
517
- } catch (e) {}
518
-
519
- // reset state
432
+ function cleanupForNav() {
433
+ // New token => any pending safeCmd work becomes stale
434
+ state.pageToken += 1;
520
435
  state.cfg = null;
521
- state.poolTopics = [];
522
- state.poolPosts = [];
523
- state.poolCategories = [];
524
- state.usedTopics.clear();
525
- state.usedPosts.clear();
526
- state.usedCategories.clear();
527
- state.lastShowById.clear();
528
- state.heroDoneForPage = false;
529
-
530
- sessionDefinedIds.clear();
531
-
532
- // keep observers alive (MutationObserver will re-trigger after navigation)
533
- }
436
+ state.cfgPromise = null;
437
+ state.lastShowAt.clear();
534
438
 
535
- function ensureDomObserver() {
536
- if (state.domObs) return;
537
- state.domObs = new MutationObserver(() => {
538
- if (!EZOIC_BLOCKED) scheduleRun();
539
- });
540
- try {
541
- state.domObs.observe(document.body, { childList: true, subtree: true });
542
- } catch (e) {}
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();
543
446
  }
544
447
 
545
- function bindNodeBB() {
448
+ // ----------------------------
449
+ // Bind to NodeBB 4.x ajaxify events
450
+ // ----------------------------
451
+ function bind() {
546
452
  if (!$) return;
547
453
 
548
454
  $(window).off('.ezoicInfinite');
549
455
 
550
- $(window).on('action:ajaxify.start.ezoicInfinite', () => {
551
- cleanup();
552
- });
456
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanupForNav());
553
457
 
554
458
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
555
459
  state.pageKey = getPageKey();
556
- EZOIC_BLOCKED = false;
557
-
558
- warmUpNetwork();
559
- patchShowAds();
560
- ensurePreloadObserver();
561
- ensureDomObserver();
562
-
563
- // Ultra-fast above-the-fold first
564
- insertHeroAdEarly().catch(() => {});
565
-
566
- // Then normal insertion
567
- scheduleRun();
460
+ ensureMO();
461
+ schedule(runCore);
568
462
  });
569
463
 
570
- // Infinite scroll / partial updates
571
- $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
572
- if (EZOIC_BLOCKED) return;
573
- scheduleRun();
574
- });
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
469
  }
576
470
 
577
- function bindScroll() {
578
- let ticking = false;
579
- window.addEventListener('scroll', () => {
580
- if (ticking) return;
581
- ticking = true;
582
- window.requestAnimationFrame(() => {
583
- ticking = false;
584
- if (!EZOIC_BLOCKED) scheduleRun();
585
- });
586
- }, { passive: true });
587
- }
588
-
589
- // ---------- boot ----------
590
-
471
+ // Boot
472
+ cleanupForNav();
473
+ bind();
474
+ ensureMO();
591
475
  state.pageKey = getPageKey();
592
- warmUpNetwork();
593
- patchShowAds();
594
- ensurePreloadObserver();
595
- ensureDomObserver();
596
-
597
- bindNodeBB();
598
- bindScroll();
599
-
600
- // First paint: try hero + run
601
- EZOIC_BLOCKED = false;
602
- insertHeroAdEarly().catch(() => {});
603
- scheduleRun();
604
- })();
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
+ }