nodebb-plugin-ezoic-infinite 1.5.20 → 1.5.21

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
@@ -9,7 +9,11 @@ const plugin = {};
9
9
 
10
10
  function normalizeExcludedGroups(value) {
11
11
  if (!value) return [];
12
- if (Array.isArray(value)) return value;
12
+ // NodeBB settings may return arrays, strings, or objects like {0:'a',1:'b'}
13
+ if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean);
14
+ if (typeof value === 'object') {
15
+ return Object.values(value).map(String).map(s => s.trim()).filter(Boolean);
16
+ }
13
17
  return String(value).split(',').map(s => s.trim()).filter(Boolean);
14
18
  }
15
19
 
@@ -27,13 +31,21 @@ async function getAllGroups() {
27
31
  }
28
32
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
33
  const data = await groups.getGroupsData(filtered);
30
- // Sort alphabetically for ACP usability
31
- data.sort((a, b) => String(a.name).localeCompare(String(b.name), 'fr', { sensitivity: 'base' }));
32
- return data;
34
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
35
+ const valid = data.filter(g => g && g.name);
36
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
37
+ return valid;
33
38
  }
39
+ let _settingsCache = null;
40
+ let _settingsCacheAt = 0;
41
+ const SETTINGS_TTL = 30000; // 30s
42
+
34
43
  async function getSettings() {
44
+ const now = Date.now();
45
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
35
46
  const s = await meta.settings.get(SETTINGS_KEY);
36
- return {
47
+ _settingsCacheAt = Date.now();
48
+ _settingsCache = {
37
49
  // Between-post ads (simple blocks) in category topic list
38
50
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
39
51
  showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
@@ -54,14 +66,32 @@ async function getSettings() {
54
66
 
55
67
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
56
68
  };
69
+ return _settingsCache;
57
70
  }
58
71
 
59
72
  async function isUserExcluded(uid, excludedGroups) {
60
- if (!uid || !excludedGroups.length) return false;
61
- const userGroups = await groups.getUserGroups([uid]);
62
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
73
+ const list = (excludedGroups || []).map(g => String(g).toLowerCase());
74
+ const id = Number(uid) || 0;
75
+
76
+ if (!list.length) return false;
77
+
78
+ // Guests (uid=0) are not in groups.getUserGroups; treat explicitly if configured
79
+ if (id === 0) {
80
+ return list.includes('guests') || list.includes('guest');
81
+ }
82
+
83
+ const userGroups = await groups.getUserGroups([id]);
84
+ const names = (userGroups[0] || []).map(g => (g && g.name) ? g.name : String(g));
85
+ return names.some(name => list.includes(String(name).toLowerCase()));
63
86
  }
64
87
 
88
+ plugin.onSettingsSet = function (data) {
89
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
90
+ if (data && data.hash === SETTINGS_KEY) {
91
+ _settingsCache = null;
92
+ }
93
+ };
94
+
65
95
  plugin.addAdminNavigation = async (header) => {
66
96
  header.plugins = header.plugins || [];
67
97
  header.plugins.push({
@@ -89,9 +119,13 @@ plugin.init = async ({ router, middleware }) => {
89
119
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
90
120
  router.get('/api/admin/plugins/ezoic-infinite', render);
91
121
 
92
- router.get('/api/plugins/ezoic-infinite/config', middleware.buildHeader, async (req, res) => {
122
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
93
123
  const settings = await getSettings();
94
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
124
+ const uid = (typeof req.uid !== 'undefined' && req.uid !== null) ? req.uid
125
+ : (req.user && req.user.uid) ? req.user.uid
126
+ : (res.locals && res.locals.uid) ? res.locals.uid
127
+ : 0;
128
+ const excluded = await isUserExcluded(uid, settings.excludedGroups);
95
129
 
96
130
  res.json({
97
131
  excluded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.20",
3
+ "version": "1.5.21",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "hook": "filter:admin.header.build",
13
13
  "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "action:settings.set",
17
+ "method": "onSettingsSet"
14
18
  }
15
19
  ],
16
20
  "staticDirs": {
package/public/admin.js CHANGED
@@ -13,11 +13,10 @@
13
13
  e.preventDefault();
14
14
 
15
15
  Settings.save('ezoic-infinite', $form, function () {
16
- // Toast vert (NodeBB core)
17
16
  if (alerts && typeof alerts.success === 'function') {
18
- alerts.success('Enregistré');
17
+ alerts.success('[[admin/settings:saved]]');
19
18
  } else if (window.app && typeof window.app.alertSuccess === 'function') {
20
- window.app.alertSuccess('Enregistré');
19
+ window.app.alertSuccess('[[admin/settings:saved]]');
21
20
  }
22
21
  });
23
22
  });
package/public/client.js CHANGED
@@ -1,45 +1,93 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
4
+ var $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
5
 
6
- const SELECTORS = {
6
+ var SELECTORS = {
7
7
  topicItem: 'li[component="category/topic"]',
8
8
  postItem: '[component="post"][data-pid]',
9
- categoryItem: 'li[component="categories/category"]',
9
+ categoryItem: 'li[component="categories/category"]'
10
10
  };
11
11
 
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
- const MAX_INSERTS_PER_RUN = 3;
12
+ var WRAP_CLASS = 'ezoic-ad';
13
+ var PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
15
14
 
16
- const state = {
15
+ // Hard limits to avoid runaway insertion during mutations / infinite scroll.
16
+ var MAX_INSERTS_PER_RUN = 3;
17
+
18
+ // Placeholders that have been "defined" (filled) at least once in this browser session.
19
+ // This survives ajaxify navigations, which is important for safe recycle/destroy logic.
20
+ var sessionDefinedIds = new Set();
21
+
22
+ // Prevent re-entrant insertion of the same id while Ezoic is processing it.
23
+ var insertingIds = new Set();
24
+
25
+ var state = {
17
26
  pageKey: null,
27
+
18
28
  cfg: null,
19
29
  cfgPromise: null,
30
+
20
31
  poolTopics: [],
21
32
  poolPosts: [],
22
33
  poolCategories: [],
34
+
23
35
  usedTopics: new Set(),
24
36
  usedPosts: new Set(),
25
37
  usedCategories: new Set(),
38
+
39
+ // Track wrappers that are still in the DOM to recycle ids once they are far above viewport.
40
+ liveTopics: [],
41
+ livePosts: [],
42
+ liveCategories: [],
43
+
44
+ // Throttle showAds calls per id.
26
45
  lastShowById: new Map(),
27
- canShowAds: false,
46
+
47
+ // Ids for which we scheduled/attempted showAds and should not schedule again immediately.
48
+ pendingById: new Set(),
49
+
50
+ // Timeouts created by this script (so we can cancel on ajaxify.start).
51
+ activeTimeouts: new Set(),
52
+
53
+ // Run scheduling / mutation observer.
28
54
  scheduled: false,
29
55
  timer: null,
30
56
  obs: null,
31
- };
32
57
 
33
- const sessionDefinedIds = new Set();
58
+ // Scroll throttling.
59
+ lastScrollRun: 0,
60
+
61
+ // Navigation safety gate: we only insert after ajaxify.end settles.
62
+ canShowAds: false,
63
+
64
+ // Retry counters.
65
+ poolWaitAttempts: 0,
66
+ awaitItemsAttempts: 0
67
+ };
34
68
 
35
69
  function normalizeBool(v) {
36
70
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
37
71
  }
38
72
 
73
+ function setTimeoutTracked(fn, ms) {
74
+ var id = setTimeout(fn, ms);
75
+ state.activeTimeouts.add(id);
76
+ return id;
77
+ }
78
+
79
+ function clearAllTrackedTimeouts() {
80
+ state.activeTimeouts.forEach(function (id) {
81
+ try { clearTimeout(id); } catch (e) {}
82
+ });
83
+ state.activeTimeouts.clear();
84
+ }
85
+
39
86
  function uniqInts(lines) {
40
- const out = [], seen = new Set();
41
- for (const v of lines) {
42
- const n = parseInt(v, 10);
87
+ var out = [];
88
+ var seen = new Set();
89
+ for (var i = 0; i < lines.length; i++) {
90
+ var n = parseInt(lines[i], 10);
43
91
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
44
92
  seen.add(n);
45
93
  out.push(n);
@@ -50,26 +98,34 @@
50
98
 
51
99
  function parsePool(raw) {
52
100
  if (!raw) return [];
53
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
101
+ var lines = String(raw)
102
+ .split(/\r?\n/)
103
+ .map(function (s) { return s.trim(); })
104
+ .filter(Boolean);
54
105
  return uniqInts(lines);
55
106
  }
56
107
 
57
108
  function getPageKey() {
58
109
  try {
59
- const ax = window.ajaxify;
110
+ var ax = window.ajaxify;
60
111
  if (ax && ax.data) {
61
- if (ax.data.tid) return `topic:${ax.data.tid}`;
62
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
112
+ if (ax.data.tid) return 'topic:' + ax.data.tid;
113
+ if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
63
114
  }
64
115
  } catch (e) {}
65
116
  return window.location.pathname;
66
117
  }
67
118
 
68
119
  function getKind() {
69
- const p = window.location.pathname || '';
120
+ var p = window.location.pathname || '';
70
121
  if (/^\/topic\//.test(p)) return 'topic';
71
122
  if (/^\/category\//.test(p)) return 'categoryTopics';
72
123
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
124
+
125
+ // Fallback by DOM.
126
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
127
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
128
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
73
129
  return 'other';
74
130
  }
75
131
 
@@ -77,295 +133,719 @@
77
133
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
78
134
  }
79
135
 
80
- function getPostContainers() {
81
- return Array.from(document.querySelectorAll(SELECTORS.postItem));
82
- }
83
-
84
136
  function getCategoryItems() {
85
137
  return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
86
138
  }
87
139
 
88
- async function fetchConfig() {
89
- if (state.cfg) return state.cfg;
90
- if (state.cfgPromise) return state.cfgPromise;
140
+ function getPostContainers() {
141
+ var nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
142
+ return nodes.filter(function (el) {
143
+ if (!el || !el.isConnected) return false;
144
+ if (!el.querySelector('[component="post/content"]')) return false;
91
145
 
92
- state.cfgPromise = (async () => {
93
- try {
94
- const res = await fetch('/api/ezoic-infinite/config');
95
- if (!res.ok) return null;
96
- const cfg = await res.json();
97
- state.cfg = cfg;
98
- return cfg;
99
- } catch (e) {
100
- return null;
101
- }
102
- })();
146
+ // Prevent nested / duplicated post wrappers.
147
+ var parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
148
+ if (parentPost && parentPost !== el) return false;
149
+ if (el.getAttribute('component') === 'post/parent') return false;
103
150
 
104
- return state.cfgPromise;
151
+ return true;
152
+ });
105
153
  }
106
154
 
107
- function initPools(cfg) {
108
- if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.poolTopics);
109
- if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.poolPosts);
110
- if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.poolCategories);
155
+ function safeRect(el) {
156
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
111
157
  }
112
158
 
113
159
  function destroyPlaceholderIds(ids) {
114
160
  if (!ids || !ids.length) return;
115
- const filtered = ids.filter(id => Number.isFinite(id) && id > 0);
161
+
162
+ // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
163
+ var filtered = ids.filter(function (id) {
164
+ try { return sessionDefinedIds.has(id); } catch (e) { return true; }
165
+ });
166
+
116
167
  if (!filtered.length) return;
117
168
 
169
+ var call = function () {
170
+ try {
171
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
172
+ window.ezstandalone.destroyPlaceholders(filtered);
173
+ }
174
+ } catch (e) {}
175
+ };
176
+
118
177
  try {
119
178
  window.ezstandalone = window.ezstandalone || {};
120
179
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
121
-
122
- const call = () => {
123
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
124
- window.ezstandalone.destroyPlaceholders(filtered);
125
- }
126
- };
180
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
181
+ else window.ezstandalone.cmd.push(call);
182
+ } catch (e) {}
183
+ }
127
184
 
128
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
129
- call();
130
- } else {
131
- window.ezstandalone.cmd.push(call);
185
+ function getRecyclable(liveArr) {
186
+ var margin = 600; // px above viewport
187
+ for (var i = 0; i < liveArr.length; i++) {
188
+ var entry = liveArr[i];
189
+ if (!entry || !entry.wrap || !entry.wrap.isConnected) {
190
+ liveArr.splice(i, 1);
191
+ i--;
192
+ continue;
132
193
  }
194
+ var r = safeRect(entry.wrap);
195
+ if (r && r.bottom < -margin) {
196
+ liveArr.splice(i, 1);
197
+ return entry;
198
+ }
199
+ }
200
+ return null;
201
+ }
133
202
 
134
- // Recyclage: libérer après 100ms
135
- setTimeout(() => {
136
- filtered.forEach(id => sessionDefinedIds.delete(id));
137
- }, 100);
138
- } catch (e) {}
203
+ function pickId(pool, liveArr) {
204
+ if (pool.length) return { id: pool.shift(), recycled: null };
205
+
206
+ var recycled = getRecyclable(liveArr);
207
+ if (recycled) return { id: recycled.id, recycled: recycled };
208
+
209
+ return { id: null, recycled: null };
139
210
  }
140
211
 
141
- function buildWrap(id, kindClass, afterPos) {
142
- const wrap = document.createElement('div');
143
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
144
- wrap.setAttribute('data-ezoic-after', String(afterPos));
145
- wrap.style.width = '100%';
212
+ function resetPlaceholderInWrap(wrap, id) {
213
+ if (!wrap) return null;
214
+ try { wrap.innerHTML = ''; } catch (e) {}
146
215
 
147
- const ph = document.createElement('div');
148
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
216
+ var ph = document.createElement('div');
217
+ ph.id = PLACEHOLDER_PREFIX + id;
149
218
  wrap.appendChild(ph);
219
+ return ph;
220
+ }
221
+
222
+ function isAdjacentAd(el) {
223
+ var next = el && el.nextElementSibling;
224
+ return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
225
+ }
226
+
227
+ function isPrevAd(el) {
228
+ var prev = el && el.previousElementSibling;
229
+ return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
230
+ }
231
+
232
+ function buildWrap(id, kindClass) {
233
+ var wrap = document.createElement('div');
234
+ wrap.className = WRAP_CLASS + ' ' + kindClass;
235
+ wrap.setAttribute('data-ezoic-id', String(id));
236
+ resetPlaceholderInWrap(wrap, id);
150
237
  return wrap;
151
238
  }
152
239
 
153
240
  function findWrap(kindClass, afterPos) {
154
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
241
+ // Search a wrapper marker that we set on insertion.
242
+ return document.querySelector('.' + kindClass + '[data-after-pos="' + afterPos + '"]');
155
243
  }
156
244
 
157
- function insertAfter(target, id, kindClass, afterPos) {
158
- if (!target || !target.insertAdjacentElement) return null;
159
- if (findWrap(kindClass, afterPos)) return null;
160
-
245
+ function insertAfter(el, id, kindClass, afterPos) {
161
246
  try {
162
- const wrap = buildWrap(id, kindClass, afterPos);
163
- target.insertAdjacentElement('afterend', wrap);
247
+ var wrap = buildWrap(id, kindClass);
248
+ wrap.setAttribute('data-after-pos', String(afterPos));
249
+
250
+ if (!el || !el.parentNode) return null;
251
+ if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
252
+ else el.parentNode.appendChild(wrap);
253
+
254
+ attachFillObserver(wrap, id);
164
255
  return wrap;
165
- } catch (e) {
166
- return null;
167
- }
256
+ } catch (e) {}
257
+ return null;
168
258
  }
169
259
 
170
- function pickId(pool) {
171
- if (!pool || !pool.length) return null;
172
- return pool.shift();
260
+ function destroyUsedPlaceholders() {
261
+ var ids = [];
262
+ state.usedTopics.forEach(function (id) { ids.push(id); });
263
+ state.usedPosts.forEach(function (id) { ids.push(id); });
264
+ state.usedCategories.forEach(function (id) { ids.push(id); });
265
+
266
+ // Only destroy placeholders that were filled at least once in this session.
267
+ destroyPlaceholderIds(ids);
173
268
  }
174
269
 
175
- function callShowAds(id) {
176
- if (!id) return;
177
-
270
+ function patchShowAds() {
271
+ // Some Ezoic setups require calling showAds via ezstandalone.cmd.
272
+ // We keep existing behavior but make it resilient.
178
273
  try {
179
274
  window.ezstandalone = window.ezstandalone || {};
180
275
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
181
-
182
- window.ezstandalone.cmd.push(function() {
183
- if (typeof window.ezstandalone.showAds === 'function') {
184
- window.ezstandalone.showAds(id);
185
- sessionDefinedIds.add(id);
276
+ } catch (e) {}
277
+ }
278
+
279
+ function markFilled(id) {
280
+ try { sessionDefinedIds.add(id); } catch (e) {}
281
+ }
282
+
283
+ function isWrapMarkedFilled(wrap) {
284
+ try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
285
+ }
286
+
287
+ function attachFillObserver(wrap, id) {
288
+ if (!wrap || !wrap.isConnected) return;
289
+
290
+ // If already filled, mark and return.
291
+ if (isPlaceholderFilled(wrap)) {
292
+ try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
293
+ markFilled(id);
294
+ return;
295
+ }
296
+
297
+ // Observe for Ezoic inserting ad content into placeholder.
298
+ try {
299
+ var obs = new MutationObserver(function () {
300
+ if (!wrap.isConnected) {
301
+ try { obs.disconnect(); } catch (e) {}
302
+ return;
303
+ }
304
+ if (isPlaceholderFilled(wrap)) {
305
+ try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
306
+ markFilled(id);
307
+ try { obs.disconnect(); } catch (e) {}
186
308
  }
187
309
  });
310
+ obs.observe(wrap, { childList: true, subtree: true });
311
+ wrap.__ezoicFillObs = obs;
188
312
  } catch (e) {}
189
313
  }
190
314
 
191
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
192
- if (!items || items.length === 0) return 0;
315
+ function isPlaceholderFilled(wrap) {
316
+ // Heuristic: placeholder exists AND has descendants or meaningful height.
317
+ try {
318
+ var ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
319
+ if (!ph) return false;
320
+ if (ph.children && ph.children.length) return true;
321
+ var r = safeRect(wrap);
322
+ if (r && r.height > 20) return true;
323
+ } catch (e) {}
324
+ return false;
325
+ }
193
326
 
194
- let inserted = 0;
195
- const targets = [];
327
+ function scheduleShowAdsBatch(ids) {
328
+ if (!ids || !ids.length) return;
196
329
 
197
- for (let i = 0; i < items.length; i++) {
198
- const afterPos = i + 1;
199
- if (afterPos === 1 && !showFirst) continue;
200
- if (afterPos % interval !== (showFirst ? 1 : 0)) continue;
201
- targets.push(afterPos);
330
+ // Ezoic expects DOM to be settled.
331
+ var call = function () {
332
+ try {
333
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
334
+ window.ezstandalone.showAds(ids);
335
+ }
336
+ } catch (e) {}
337
+ };
338
+
339
+ try {
340
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') call();
341
+ else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) window.ezstandalone.cmd.push(call);
342
+ } catch (e) {}
343
+ }
344
+
345
+ function callShowAdsWhenReady(id) {
346
+ if (!id) return;
347
+
348
+ // Throttle per-id.
349
+ var now = Date.now();
350
+ var last = state.lastShowById.get(id) || 0;
351
+ if (now - last < 1200) return;
352
+
353
+ if (state.pendingById.has(id)) return;
354
+ state.pendingById.add(id);
355
+ state.lastShowById.set(id, now);
356
+
357
+ // Guard against re-entrancy.
358
+ if (insertingIds.has(id)) {
359
+ state.pendingById.delete(id);
360
+ return;
361
+ }
362
+ insertingIds.add(id);
363
+
364
+ var attempts = 0;
365
+
366
+ (function waitForPh() {
367
+ attempts++;
368
+
369
+ // Navigation safety: if we navigated away, stop.
370
+ if (!state.canShowAds) {
371
+ state.pendingById.delete(id);
372
+ insertingIds.delete(id);
373
+ return;
374
+ }
375
+
376
+ var ph = document.getElementById(PLACEHOLDER_PREFIX + id);
377
+
378
+ var doCall = function () {
379
+ try {
380
+ // If placeholder is gone, stop.
381
+ if (!ph || !ph.isConnected) return false;
382
+ scheduleShowAdsBatch([id]);
383
+ return true;
384
+ } catch (e) {}
385
+ return false;
386
+ };
387
+
388
+ if (ph && ph.isConnected) {
389
+ doCall();
390
+ state.pendingById.delete(id);
391
+ insertingIds.delete(id);
392
+ return;
393
+ }
394
+
395
+ if (attempts < 100) {
396
+ setTimeoutTracked(waitForPh, 50);
397
+ return;
398
+ }
399
+
400
+ // Timeout: give up silently.
401
+ state.pendingById.delete(id);
402
+ insertingIds.delete(id);
403
+ })();
404
+ }
405
+
406
+ function initPools(cfg) {
407
+ if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.placeholderIds);
408
+ if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
409
+ if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
410
+ }
411
+
412
+ function computeTargets(count, interval, showFirst) {
413
+ var out = [];
414
+ if (count <= 0) return out;
415
+
416
+ if (showFirst) out.push(1);
417
+
418
+ for (var i = 1; i <= count; i++) {
419
+ if (i % interval === 0) out.push(i);
202
420
  }
203
421
 
204
- for (const afterPos of targets) {
422
+ // Unique + sorted.
423
+ return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
424
+ }
425
+
426
+ function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
427
+ if (!items || !items.length) return 0;
428
+
429
+ var targets = computeTargets(items.length, interval, showFirst);
430
+ var inserted = 0;
431
+
432
+ for (var t = 0; t < targets.length; t++) {
433
+ var afterPos = targets[t];
205
434
  if (inserted >= MAX_INSERTS_PER_RUN) break;
206
435
 
207
- const el = items[afterPos - 1];
436
+ var el = items[afterPos - 1];
208
437
  if (!el || !el.isConnected) continue;
438
+
439
+ // Prevent adjacent ads.
440
+ if (isAdjacentAd(el) || isPrevAd(el)) continue;
441
+
442
+ // Prevent duplicates at same logical position.
443
+ if (findWrap(kindClass, afterPos - 1)) continue;
209
444
  if (findWrap(kindClass, afterPos)) continue;
210
445
 
211
- const id = pickId(pool);
446
+ var pick = pickId(kindPool, liveArr);
447
+ var id = pick.id;
212
448
  if (!id) break;
213
449
 
214
- const wrap = insertAfter(el, id, kindClass, afterPos);
215
- if (!wrap) continue;
450
+ var wrap = null;
216
451
 
217
- usedSet.add(id);
218
- callShowAds(id);
219
- inserted += 1;
452
+ if (pick.recycled && pick.recycled.wrap) {
453
+ // Recycle: only destroy if Ezoic has actually defined this placeholder before.
454
+ if (sessionDefinedIds.has(id)) destroyPlaceholderIds([id]);
455
+
456
+ // Remove old wrapper.
457
+ var oldWrap = pick.recycled.wrap;
458
+ try { if (oldWrap && oldWrap.__ezoicFillObs) oldWrap.__ezoicFillObs.disconnect(); } catch (e) {}
459
+ try { if (oldWrap) oldWrap.remove(); } catch (e) {}
460
+
461
+ wrap = insertAfter(el, id, kindClass, afterPos);
462
+ if (!wrap) continue;
463
+
464
+ // Give Ezoic a moment after DOM insertion.
465
+ setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 700);
466
+ } else {
467
+ usedSet.add(id);
468
+ wrap = insertAfter(el, id, kindClass, afterPos);
469
+ if (!wrap) continue;
470
+
471
+ // Micro-delay to allow layout/DOM settle.
472
+ setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
473
+ }
474
+
475
+ liveArr.push({ id: id, wrap: wrap });
476
+
477
+ // Final safety: if adjacency happened due to DOM shifts, rollback.
478
+ var prev = wrap && wrap.previousElementSibling;
479
+ var next = wrap && wrap.nextElementSibling;
480
+ if (wrap && ((prev && prev.classList && prev.classList.contains(WRAP_CLASS)) || (next && next.classList && next.classList.contains(WRAP_CLASS)))) {
481
+ try { wrap.remove(); } catch (e) {}
482
+
483
+ if (!(pick.recycled && pick.recycled.wrap)) {
484
+ try { kindPool.unshift(id); } catch (e) {}
485
+ usedSet.delete(id);
486
+ }
487
+ continue;
488
+ }
489
+
490
+ inserted++;
220
491
  }
221
492
 
222
493
  return inserted;
223
494
  }
224
495
 
225
- async function runCore() {
226
- if (!state.canShowAds) return;
227
-
228
- const cfg = await fetchConfig();
229
- if (!cfg || cfg.excluded) return;
230
-
231
- initPools(cfg);
232
-
233
- const kind = getKind();
234
- let inserted = 0;
235
-
236
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
237
- inserted = injectBetween(
238
- 'ezoic-ad-message',
239
- getPostContainers(),
240
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
241
- normalizeBool(cfg.showFirstMessageAd),
242
- state.poolPosts,
243
- state.usedPosts
244
- );
245
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
246
- inserted = injectBetween(
247
- 'ezoic-ad-between',
248
- getTopicItems(),
249
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
250
- normalizeBool(cfg.showFirstTopicAd),
251
- state.poolTopics,
252
- state.usedTopics
253
- );
254
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
255
- inserted = injectBetween(
256
- 'ezoic-ad-category',
257
- getCategoryItems(),
258
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 6),
259
- normalizeBool(cfg.showFirstCategoryAd),
260
- state.poolCategories,
261
- state.usedCategories
262
- );
496
+ function enforceNoAdjacentAds() {
497
+ var ads = Array.from(document.querySelectorAll('.' + WRAP_CLASS));
498
+ for (var i = 0; i < ads.length; i++) {
499
+ var ad = ads[i];
500
+ var prev = ad.previousElementSibling;
501
+
502
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
503
+ // Remove adjacent wrapper (do not hide).
504
+ try {
505
+ var ph = ad.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
506
+ if (ph) {
507
+ var id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
508
+ if (Number.isFinite(id) && id > 0 && sessionDefinedIds.has(id)) {
509
+ destroyPlaceholderIds([id]);
510
+ }
511
+ }
512
+ ad.remove();
513
+ } catch (e) {}
514
+ }
263
515
  }
264
516
  }
265
517
 
266
- function scheduleRun() {
267
- if (state.scheduled) return;
268
- state.scheduled = true;
518
+ function cleanup() {
519
+ // Stop any insertion during navigation / DOM teardown.
520
+ state.canShowAds = false;
521
+ state.poolWaitAttempts = 0;
522
+ state.awaitItemsAttempts = 0;
269
523
 
270
- clearTimeout(state.timer);
271
- state.timer = setTimeout(() => {
272
- state.scheduled = false;
273
- const pk = getPageKey();
274
- if (state.pageKey && pk !== state.pageKey) return;
275
- runCore().catch(() => {});
276
- }, 50);
277
- }
524
+ // Cancel any pending showAds timeouts.
525
+ clearAllTrackedTimeouts();
278
526
 
279
- function cleanup() {
280
- const allIds = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
281
- if (allIds.length) destroyPlaceholderIds(allIds);
527
+ // Disconnect global observer to avoid mutations during teardown.
528
+ if (state.obs) {
529
+ try { state.obs.disconnect(); } catch (e) {}
530
+ state.obs = null;
531
+ }
282
532
 
283
- document.querySelectorAll('.ezoic-ad').forEach(el => {
284
- try { el.remove(); } catch (e) {}
285
- });
533
+ // Destroy placeholders that were used (only those that were actually defined).
534
+ destroyUsedPlaceholders();
286
535
 
536
+ // Remove wrappers from DOM (safe because insertion is now blocked).
537
+ try {
538
+ document.querySelectorAll('.' + WRAP_CLASS).forEach(function (el) {
539
+ try { if (el && el.__ezoicFillObs) el.__ezoicFillObs.disconnect(); } catch (e) {}
540
+ try { el.remove(); } catch (e) {}
541
+ });
542
+ } catch (e) {}
543
+
544
+ // Reset runtime caches.
287
545
  state.pageKey = getPageKey();
288
546
  state.cfg = null;
289
547
  state.cfgPromise = null;
548
+
290
549
  state.poolTopics = [];
291
550
  state.poolPosts = [];
292
551
  state.poolCategories = [];
552
+
293
553
  state.usedTopics.clear();
294
554
  state.usedPosts.clear();
295
555
  state.usedCategories.clear();
296
- state.lastShowById.clear();
297
- sessionDefinedIds.clear();
298
556
 
299
- if (state.obs) {
300
- state.obs.disconnect();
301
- state.obs = null;
302
- }
557
+ state.liveTopics = [];
558
+ state.livePosts = [];
559
+ state.liveCategories = [];
560
+
561
+ state.lastShowById.clear();
562
+ state.pendingById.clear();
563
+ insertingIds.clear();
303
564
 
304
565
  state.scheduled = false;
305
- clearTimeout(state.timer);
306
- state.timer = null;
566
+ if (state.timer) {
567
+ try { clearTimeout(state.timer); } catch (e) {}
568
+ state.timer = null;
569
+ }
307
570
  }
308
571
 
309
572
  function ensureObserver() {
310
573
  if (state.obs) return;
311
- state.obs = new MutationObserver(() => scheduleRun());
312
574
  try {
575
+ state.obs = new MutationObserver(function () { scheduleRun('mutation'); });
313
576
  state.obs.observe(document.body, { childList: true, subtree: true });
314
577
  } catch (e) {}
315
578
  }
316
579
 
580
+ function scheduleRun(/* reason */) {
581
+ if (state.scheduled) return;
582
+ state.scheduled = true;
583
+
584
+ if (state.timer) {
585
+ try { clearTimeout(state.timer); } catch (e) {}
586
+ state.timer = null;
587
+ }
588
+
589
+ state.timer = setTimeoutTracked(function () {
590
+ state.scheduled = false;
591
+
592
+ // If user navigated away, stop.
593
+ var pk = getPageKey();
594
+ if (state.pageKey && pk !== state.pageKey) return;
595
+
596
+ runCore().catch(function () {});
597
+ }, 80);
598
+ }
599
+
600
+ function waitForItemsThenRun(kind) {
601
+ // If list isn't in DOM yet (ajaxify transition), retry a bit.
602
+ var count = 0;
603
+ if (kind === 'topic') count = getPostContainers().length;
604
+ else if (kind === 'categoryTopics') count = getTopicItems().length;
605
+ else if (kind === 'categories') count = getCategoryItems().length;
606
+
607
+ if (count > 0) return true;
608
+
609
+ if (state.awaitItemsAttempts < 25) {
610
+ state.awaitItemsAttempts++;
611
+ setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
612
+ }
613
+ return false;
614
+ }
615
+
317
616
  function waitForContentThenRun() {
318
- const kind = getKind();
319
- let selector = SELECTORS.postItem;
320
- if (kind === 'categoryTopics') selector = SELECTORS.topicItem;
321
- else if (kind === 'categories') selector = SELECTORS.categoryItem;
322
-
323
- const check = () => {
324
- if (document.querySelector(selector)) {
325
- scheduleRun();
617
+ // Avoid inserting ads on pages with too little content.
618
+ var MIN_WORDS = 250;
619
+ var attempts = 0;
620
+ var maxAttempts = 20; // 20 × 200ms = 4s
621
+
622
+ (function check() {
623
+ attempts++;
624
+
625
+ var text = '';
626
+ try { text = document.body.innerText || ''; } catch (e) {}
627
+ var wordCount = text.split(/\s+/).filter(Boolean).length;
628
+
629
+ if (wordCount >= MIN_WORDS) {
630
+ scheduleRun('content-ok');
631
+ return;
632
+ }
633
+
634
+ if (attempts >= maxAttempts) {
635
+ scheduleRun('content-timeout');
636
+ return;
637
+ }
638
+
639
+ setTimeoutTracked(check, 200);
640
+ })();
641
+ }
642
+
643
+ function waitForEzoicThenRun() {
644
+ var attempts = 0;
645
+ var maxAttempts = 50; // 50 × 200ms = 10s
646
+
647
+ (function check() {
648
+ attempts++;
649
+
650
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
651
+ scheduleRun('ezoic-ready');
652
+ waitForContentThenRun();
653
+ return;
654
+ }
655
+
656
+ if (attempts >= maxAttempts) {
657
+ scheduleRun('ezoic-timeout');
658
+ return;
659
+ }
660
+
661
+ setTimeoutTracked(check, 200);
662
+ })();
663
+ }
664
+
665
+ function fetchConfig() {
666
+ if (state.cfg) return Promise.resolve(state.cfg);
667
+ if (state.cfgPromise) return state.cfgPromise;
668
+
669
+ state.cfgPromise = (function () {
670
+ var MAX_TRIES = 3;
671
+ var delay = 800;
672
+
673
+ function attemptFetch(attempt) {
674
+ return fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' })
675
+ .then(function (res) {
676
+ if (!res || !res.ok) throw new Error('bad response');
677
+ return res.json();
678
+ })
679
+ .then(function (json) {
680
+ state.cfg = json;
681
+ return json;
682
+ })
683
+ .catch(function () {
684
+ if (attempt >= MAX_TRIES) return null;
685
+ return new Promise(function (r) { setTimeoutTracked(r, delay); }).then(function () {
686
+ delay *= 2;
687
+ return attemptFetch(attempt + 1);
688
+ });
689
+ });
690
+ }
691
+
692
+ return attemptFetch(1).finally(function () { state.cfgPromise = null; });
693
+ })();
694
+
695
+ return state.cfgPromise;
696
+ }
697
+
698
+ function runCore() {
699
+ // Navigation safety: never insert during ajaxify teardown.
700
+ if (!state.canShowAds) return Promise.resolve();
701
+
702
+ patchShowAds();
703
+
704
+ return fetchConfig().then(function (cfg) {
705
+ if (!cfg || cfg.excluded) return;
706
+
707
+ initPools(cfg);
708
+
709
+ var kind = getKind();
710
+ var inserted = 0;
711
+
712
+ if (!waitForItemsThenRun(kind)) return;
713
+
714
+ if (kind === 'topic') {
715
+ if (normalizeBool(cfg.enableMessageAds)) {
716
+ inserted = injectBetween(
717
+ 'ezoic-ad-message',
718
+ getPostContainers(),
719
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
720
+ normalizeBool(cfg.showFirstMessageAd),
721
+ state.poolPosts,
722
+ state.usedPosts,
723
+ state.livePosts
724
+ );
725
+ }
726
+ } else if (kind === 'categoryTopics') {
727
+ if (normalizeBool(cfg.enableBetweenAds)) {
728
+ inserted = injectBetween(
729
+ 'ezoic-ad-between',
730
+ getTopicItems(),
731
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
732
+ normalizeBool(cfg.showFirstTopicAd),
733
+ state.poolTopics,
734
+ state.usedTopics,
735
+ state.liveTopics
736
+ );
737
+ }
738
+ } else if (kind === 'categories') {
739
+ if (normalizeBool(cfg.enableCategoryAds)) {
740
+ inserted = injectBetween(
741
+ 'ezoic-ad-categories',
742
+ getCategoryItems(),
743
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
744
+ normalizeBool(cfg.showFirstCategoryAd),
745
+ state.poolCategories,
746
+ state.usedCategories,
747
+ state.liveCategories
748
+ );
749
+ }
750
+ }
751
+
752
+ enforceNoAdjacentAds();
753
+
754
+ // Recycling: if pool is exhausted, retry a few times to allow old wrappers to scroll off-screen.
755
+ if (inserted === 0) {
756
+ if (state.poolWaitAttempts < 8) {
757
+ state.poolWaitAttempts++;
758
+ setTimeoutTracked(function () { scheduleRun('pool-wait'); }, 400);
759
+ }
326
760
  } else {
327
- setTimeout(check, 200);
761
+ // Reset pool wait attempts once we successfully insert something.
762
+ state.poolWaitAttempts = 0;
328
763
  }
329
- };
330
- check();
764
+
765
+ // If we hit max inserts, continue quickly.
766
+ if (inserted >= MAX_INSERTS_PER_RUN) {
767
+ setTimeoutTracked(function () { scheduleRun('continue'); }, 140);
768
+ }
769
+ }).catch(function () {});
331
770
  }
332
771
 
333
772
  function bind() {
334
773
  if (!$) return;
335
774
 
336
775
  $(window).off('.ezoicInfinite');
337
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
338
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
776
+
777
+ $(window).on('action:ajaxify.start.ezoicInfinite', function () {
778
+ cleanup();
779
+ });
780
+
781
+ $(window).on('action:ajaxify.end.ezoicInfinite', function () {
339
782
  state.pageKey = getPageKey();
340
783
  ensureObserver();
341
- state.canShowAds = true;
342
- scheduleRun();
784
+
785
+ // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
786
+ setTimeoutTracked(function () {
787
+ state.canShowAds = true;
788
+ waitForEzoicThenRun();
789
+ }, 300);
343
790
  });
344
791
 
345
- $(window).on('action:category.loaded.ezoicInfinite', () => {
792
+ // Infinite-scroll and "loaded" events.
793
+ $(window).on('action:category.loaded.ezoicInfinite', function () {
346
794
  ensureObserver();
347
795
  waitForContentThenRun();
348
796
  });
349
797
 
350
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
798
+ $(window).on('action:topics.loaded.ezoicInfinite', function () {
351
799
  ensureObserver();
352
800
  waitForContentThenRun();
353
801
  });
354
- }
355
802
 
356
- function init() {
357
- state.pageKey = getPageKey();
358
- state.canShowAds = true;
359
- bind();
360
- ensureObserver();
361
- waitForContentThenRun();
803
+ $(window).on('action:topic.loaded.ezoicInfinite', function () {
804
+ ensureObserver();
805
+ waitForContentThenRun();
806
+ });
807
+
808
+ $(window).on('action:posts.loaded.ezoicInfinite', function () {
809
+ ensureObserver();
810
+ waitForContentThenRun();
811
+ });
362
812
  }
363
813
 
364
- if ($ && $(document).ready) {
365
- $(document).ready(init);
366
- } else if (document.readyState === 'loading') {
367
- document.addEventListener('DOMContentLoaded', init);
368
- } else {
369
- init();
814
+ function bindScroll() {
815
+ if (state.lastScrollRun > 0) return;
816
+ state.lastScrollRun = Date.now();
817
+
818
+ var ticking = false;
819
+ window.addEventListener('scroll', function () {
820
+ if (ticking) return;
821
+ ticking = true;
822
+
823
+ window.requestAnimationFrame(function () {
824
+ ticking = false;
825
+
826
+ enforceNoAdjacentAds();
827
+
828
+ // Debounce scheduleRun (max once every 2s on scroll).
829
+ var now = Date.now();
830
+ if (!state.lastScrollRun || (now - state.lastScrollRun > 2000)) {
831
+ state.lastScrollRun = now;
832
+ scheduleRun('scroll');
833
+ }
834
+ });
835
+ }, { passive: true });
370
836
  }
837
+
838
+ // Boot.
839
+ cleanup();
840
+ bind();
841
+ bindScroll();
842
+ ensureObserver();
843
+
844
+ state.pageKey = getPageKey();
845
+
846
+ // Direct page load: allow insertion after initial tick (no ajaxify.end).
847
+ setTimeoutTracked(function () {
848
+ state.canShowAds = true;
849
+ waitForEzoicThenRun();
850
+ }, 0);
371
851
  })();
package/public/style.css CHANGED
@@ -1,11 +1,21 @@
1
+ /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
1
2
  .ezoic-ad {
3
+ display: block;
4
+ width: 100%;
5
+ margin: 0 !important;
2
6
  padding: 0 !important;
7
+ overflow: hidden;
8
+ }
9
+
10
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
3
11
  margin: 0 !important;
4
- min-height: 0 !important;
5
- min-width: 0 !important;
12
+ padding: 0 !important;
13
+ min-height: 1px; /* keeps placeholder measurable for IO */
6
14
  }
7
15
 
8
- .ezoic-ad * {
9
- min-height: 0 !important;
10
- min-width: 0 !important;
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;
11
21
  }