nodebb-plugin-ezoic-infinite 1.5.48 → 1.5.49

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
@@ -27,13 +27,21 @@ async function getAllGroups() {
27
27
  }
28
28
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
29
  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;
30
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
+ const valid = data.filter(g => g && g.name);
32
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
+ return valid;
33
34
  }
35
+ let _settingsCache = null;
36
+ let _settingsCacheAt = 0;
37
+ const SETTINGS_TTL = 30000; // 30s
38
+
34
39
  async function getSettings() {
40
+ const now = Date.now();
41
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
35
42
  const s = await meta.settings.get(SETTINGS_KEY);
36
- return {
43
+ _settingsCacheAt = Date.now();
44
+ _settingsCache = {
37
45
  // Between-post ads (simple blocks) in category topic list
38
46
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
39
47
  showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
@@ -54,6 +62,7 @@ async function getSettings() {
54
62
 
55
63
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
56
64
  };
65
+ return _settingsCache;
57
66
  }
58
67
 
59
68
  async function isUserExcluded(uid, excludedGroups) {
@@ -62,6 +71,13 @@ async function isUserExcluded(uid, excludedGroups) {
62
71
  return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
63
72
  }
64
73
 
74
+ plugin.onSettingsSet = function (data) {
75
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
76
+ if (data && data.hash === SETTINGS_KEY) {
77
+ _settingsCache = null;
78
+ }
79
+ };
80
+
65
81
  plugin.addAdminNavigation = async (header) => {
66
82
  header.plugins = header.plugins || [];
67
83
  header.plugins.push({
@@ -89,7 +105,7 @@ plugin.init = async ({ router, middleware }) => {
89
105
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
90
106
  router.get('/api/admin/plugins/ezoic-infinite', render);
91
107
 
92
- router.get('/api/plugins/ezoic-infinite/config', middleware.buildHeader, async (req, res) => {
108
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
93
109
  const settings = await getSettings();
94
110
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
95
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.48",
3
+ "version": "1.5.49",
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,43 +1,135 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ // NodeBB client context
4
5
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
6
 
7
+ const WRAP_CLASS = 'ezoic-ad';
8
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
9
+
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 (earlier load for smoother scroll)
14
+ const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
15
+ const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
16
+
17
+ // When the user scrolls very fast, temporarily preload more aggressively.
18
+ // This helps ensure ads are already in-flight before the user reaches them.
19
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
20
+ const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
21
+ const BOOST_DURATION_MS = 2500;
22
+ const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
23
+
24
+ const MAX_INFLIGHT_DESKTOP = 4;
25
+ const MAX_INFLIGHT_MOBILE = 3;
26
+
27
+ function isBoosted() {
28
+ try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
29
+ }
30
+
31
+ function isMobile() {
32
+ try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
33
+ }
34
+
35
+ function getPreloadRootMargin() {
36
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
37
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
38
+ }
39
+
40
+ function getMaxInflight() {
41
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
42
+ return base + (isBoosted() ? 1 : 0);
43
+ }
44
+
6
45
  const SELECTORS = {
7
46
  topicItem: 'li[component="category/topic"]',
8
47
  postItem: '[component="post"][data-pid]',
9
48
  categoryItem: 'li[component="categories/category"]',
10
49
  };
11
50
 
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
- const MAX_INSERTS_PER_RUN = 3;
51
+ // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
52
+ let blockedUntil = 0;
53
+ function isBlocked() {
54
+ return Date.now() < blockedUntil;
55
+ }
15
56
 
16
57
  const state = {
17
58
  pageKey: null,
18
59
  cfg: null,
19
- cfgPromise: null,
20
- poolTopics: [],
21
- poolPosts: [],
22
- poolCategories: [],
23
- usedTopics: new Set(),
24
- usedPosts: new Set(),
25
- usedCategories: new Set(),
60
+
61
+ // Full lists (never consumed) + cursors for round-robin reuse
62
+ allTopics: [],
63
+ allPosts: [],
64
+ allCategories: [],
65
+ curTopics: 0,
66
+ curPosts: 0,
67
+ curCategories: 0,
68
+
69
+ // throttle per placeholder id
26
70
  lastShowById: new Map(),
27
- canShowAds: false,
28
- scheduled: false,
29
- timer: null,
30
- obs: null,
71
+ internalDomChange: 0,
72
+ lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
73
+
74
+ // track placeholders that have been shown at least once in this pageview
75
+ usedOnce: new Set(),
76
+
77
+ // observers / schedulers
78
+ domObs: null,
79
+ io: null,
80
+ runQueued: false,
81
+
82
+ // preloading budget
83
+ inflight: 0,
84
+ pending: [],
85
+ pendingSet: new Set(),
86
+
87
+ // fast scroll boosting
88
+ scrollBoostUntil: 0,
89
+ lastScrollY: 0,
90
+ lastScrollTs: 0,
91
+ ioMargin: null,
92
+
93
+ // hero)
94
+ heroDoneForPage: false,
31
95
  };
32
96
 
33
- const sessionDefinedIds = new Set();
97
+ const insertingIds = new Set();
98
+
99
+
100
+ function markEmptyWrapper(id) {
101
+ try {
102
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
103
+ if (!ph || !ph.isConnected) return;
104
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
105
+ if (!wrap) return;
106
+ // If still empty after a delay, collapse it.
107
+ setTimeout(() => {
108
+ try {
109
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
110
+ if (!ph2 || !ph2.isConnected) return;
111
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
112
+ if (!w2) return;
113
+ // consider empty if only whitespace and no iframes/ins/img
114
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
115
+ if (!hasAd) w2.classList.add('is-empty');
116
+ } catch (e) {}
117
+ }, 3500);
118
+ } catch (e) {}
119
+ }
120
+
121
+ // Production build: debug disabled
122
+ function dbg() {}
123
+
124
+ // ---------- small utils ----------
34
125
 
35
126
  function normalizeBool(v) {
36
127
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
37
128
  }
38
129
 
39
130
  function uniqInts(lines) {
40
- const out = [], seen = new Set();
131
+ const out = [];
132
+ const seen = new Set();
41
133
  for (const v of lines) {
42
134
  const n = parseInt(v, 10);
43
135
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
@@ -50,7 +142,10 @@
50
142
 
51
143
  function parsePool(raw) {
52
144
  if (!raw) return [];
53
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
145
+ const lines = String(raw)
146
+ .split(/\r?\n/)
147
+ .map(s => s.trim())
148
+ .filter(Boolean);
54
149
  return uniqInts(lines);
55
150
  }
56
151
 
@@ -70,6 +165,11 @@
70
165
  if (/^\/topic\//.test(p)) return 'topic';
71
166
  if (/^\/category\//.test(p)) return 'categoryTopics';
72
167
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
168
+
169
+ // fallback by DOM
170
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
171
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
172
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
73
173
  return 'other';
74
174
  }
75
175
 
@@ -77,76 +177,221 @@
77
177
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
78
178
  }
79
179
 
180
+ function getCategoryItems() {
181
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
182
+ }
183
+
80
184
  function getPostContainers() {
81
- return Array.from(document.querySelectorAll(SELECTORS.postItem));
185
+ const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
186
+ return nodes.filter((el) => {
187
+ if (!el || !el.isConnected) return false;
188
+ if (!el.querySelector('[component="post/content"]')) return false;
189
+ const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
190
+ if (parentPost && parentPost !== el) return false;
191
+ if (el.getAttribute('component') === 'post/parent') return false;
192
+ return true;
193
+ });
82
194
  }
83
195
 
84
- function getCategoryItems() {
85
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
196
+ // ---------- warm-up & patching ----------
197
+
198
+ const _warmLinksDone = new Set();
199
+ function warmUpNetwork() {
200
+ try {
201
+ const head = document.head || document.getElementsByTagName('head')[0];
202
+ if (!head) return;
203
+ const links = [
204
+ ['preconnect', 'https://g.ezoic.net', true],
205
+ ['dns-prefetch', 'https://g.ezoic.net', false],
206
+ ['preconnect', 'https://go.ezoic.net', true],
207
+ ['dns-prefetch', 'https://go.ezoic.net', false],
208
+ ];
209
+ for (const [rel, href, cors] of links) {
210
+ const key = `${rel}|${href}`;
211
+ if (_warmLinksDone.has(key)) continue;
212
+ _warmLinksDone.add(key);
213
+ const link = document.createElement('link');
214
+ link.rel = rel;
215
+ link.href = href;
216
+ if (cors) link.crossOrigin = 'anonymous';
217
+ head.appendChild(link);
218
+ }
219
+ } catch (e) {}
86
220
  }
87
221
 
88
- async function fetchConfig() {
89
- if (state.cfg) return state.cfg;
90
- if (state.cfgPromise) return state.cfgPromise;
222
+ // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
223
+ function patchShowAds() {
224
+ const applyPatch = () => {
225
+ try {
226
+ window.ezstandalone = window.ezstandalone || {};
227
+ const ez = window.ezstandalone;
228
+ if (window.__nodebbEzoicPatched) return;
229
+ if (typeof ez.showAds !== 'function') return;
230
+
231
+ window.__nodebbEzoicPatched = true;
232
+ const orig = ez.showAds;
233
+
234
+ ez.showAds = function (...args) {
235
+ if (isBlocked()) return;
236
+
237
+ let ids = [];
238
+ if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
239
+ else ids = args;
240
+
241
+ const seen = new Set();
242
+ for (const v of ids) {
243
+ const id = parseInt(v, 10);
244
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
245
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
246
+ if (!ph || !ph.isConnected) continue;
247
+ seen.add(id);
248
+ try { orig.call(ez, id); } catch (e) {}
249
+ }
250
+ };
251
+ } catch (e) {}
252
+ };
91
253
 
92
- state.cfgPromise = (async () => {
254
+ applyPatch();
255
+ if (!window.__nodebbEzoicPatched) {
93
256
  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
- })();
257
+ window.ezstandalone = window.ezstandalone || {};
258
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
259
+ window.ezstandalone.cmd.push(applyPatch);
260
+ } catch (e) {}
261
+ }
262
+ }
263
+
264
+ const RECYCLE_COOLDOWN_MS = 1500;
103
265
 
104
- return state.cfgPromise;
266
+ function kindKeyFromClass(kindClass) {
267
+ if (kindClass === 'ezoic-ad-message') return 'topic';
268
+ if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
269
+ if (kindClass === 'ezoic-ad-categories') return 'categories';
270
+ return 'topic';
271
+ }
272
+
273
+ function withInternalDomChange(fn) {
274
+ state.internalDomChange++;
275
+ try { fn(); } finally { state.internalDomChange--; }
276
+ }
277
+
278
+ function canRecycle(kind) {
279
+ const now = Date.now();
280
+ const last = state.lastRecycleAt[kind] || 0;
281
+ if (now - last < RECYCLE_COOLDOWN_MS) return false;
282
+ state.lastRecycleAt[kind] = now;
283
+ return true;
284
+ }
285
+ // ---------- config & pools ----------
286
+
287
+ async function fetchConfigOnce() {
288
+ if (state.cfg) return state.cfg;
289
+ try {
290
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
291
+ if (!res.ok) return null;
292
+ state.cfg = await res.json();
293
+ return state.cfg;
294
+ } catch (e) {
295
+ return null;
296
+ }
105
297
  }
106
298
 
107
299
  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);
300
+ if (!cfg) return;
301
+ if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
302
+ if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
303
+ if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
111
304
  }
112
305
 
113
- function destroyPlaceholderIds(ids) {
114
- if (!ids || !ids.length) return;
115
- const filtered = ids.filter(id => Number.isFinite(id) && id > 0);
116
- if (!filtered.length) return;
306
+ // ---------- insertion primitives ----------
117
307
 
308
+ function isAdjacentAd(target) {
309
+ if (!target) return false;
310
+ const next = target.nextElementSibling;
311
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
312
+ const prev = target.previousElementSibling;
313
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
314
+ return false;
315
+ }
316
+
317
+
318
+ function getWrapIdFromWrap(wrap) {
118
319
  try {
119
- window.ezstandalone = window.ezstandalone || {};
120
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
121
-
122
- const call = () => {
123
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
124
- window.ezstandalone.destroyPlaceholders(filtered);
125
- }
126
- };
320
+ const v = wrap.getAttribute('data-ezoic-wrapid');
321
+ if (v) return String(v);
322
+ const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
323
+ if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
324
+ } catch (e) {}
325
+ return null;
326
+ }
127
327
 
128
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
129
- call();
130
- } else {
131
- window.ezstandalone.cmd.push(call);
328
+ function safeDestroyById(id) {
329
+ try {
330
+ const ez = window.ezstandalone;
331
+ if (ez && typeof ez.destroyPlaceholders === 'function') {
332
+ ez.destroyPlaceholders([`${PLACEHOLDER_PREFIX}${id}`]);
132
333
  }
133
-
134
- // Recyclage: libérer après 100ms
135
- setTimeout(() => {
136
- filtered.forEach(id => sessionDefinedIds.delete(id));
137
- }, 100);
138
334
  } catch (e) {}
139
335
  }
140
336
 
141
- function buildWrap(id, kindClass, afterPos) {
337
+ function pruneOrphanWraps(kindClass, items) {
338
+ if (!items || !items.length) return 0;
339
+ const itemSet = new Set(items);
340
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
341
+ let removed = 0;
342
+
343
+ wraps.forEach((wrap) => {
344
+ // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
345
+ let ok = false;
346
+ let prev = wrap.previousElementSibling;
347
+ for (let i = 0; i < 3 && prev; i++) {
348
+ if (itemSet.has(prev)) { ok = true; break; }
349
+ prev = prev.previousElementSibling;
350
+ }
351
+
352
+ if (!ok) {
353
+ const id = getWrapIdFromWrap(wrap);
354
+ withInternalDomChange(() => {
355
+ try {
356
+ if (id) safeDestroyById(id);
357
+ wrap.remove();
358
+ } catch (e) {}
359
+ });
360
+ removed++;
361
+ }
362
+ });
363
+
364
+ if (removed) dbg('prune-orphan', kindClass, { removed });
365
+ return removed;
366
+ }
367
+
368
+ function refreshEmptyState(id) {
369
+ // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
370
+ window.setTimeout(() => {
371
+ try {
372
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
373
+ if (!ph || !ph.isConnected) return;
374
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
375
+ if (!wrap) return;
376
+ const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
377
+ if (hasContent) wrap.classList.remove('is-empty');
378
+ else wrap.classList.add('is-empty');
379
+ } catch (e) {}
380
+ }, 3500);
381
+ }
382
+
383
+ function buildWrap(id, kindClass, afterPos) {
142
384
  const wrap = document.createElement('div');
143
385
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
144
386
  wrap.setAttribute('data-ezoic-after', String(afterPos));
387
+ wrap.setAttribute('data-ezoic-wrapid', String(id));
145
388
  wrap.style.width = '100%';
146
389
 
147
390
  const ph = document.createElement('div');
148
391
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
392
+ ph.setAttribute('data-ezoic-id', String(id));
149
393
  wrap.appendChild(ph);
394
+
150
395
  return wrap;
151
396
  }
152
397
 
@@ -157,215 +402,505 @@
157
402
  function insertAfter(target, id, kindClass, afterPos) {
158
403
  if (!target || !target.insertAdjacentElement) return null;
159
404
  if (findWrap(kindClass, afterPos)) return null;
405
+ if (insertingIds.has(id)) return null;
406
+
407
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
+ if (existingPh && existingPh.isConnected) return null;
160
409
 
410
+ insertingIds.add(id);
161
411
  try {
162
412
  const wrap = buildWrap(id, kindClass, afterPos);
163
413
  target.insertAdjacentElement('afterend', wrap);
164
414
  return wrap;
415
+ } finally {
416
+ insertingIds.delete(id);
417
+ }
418
+ }
419
+
420
+ function pickIdFromAll(allIds, cursorKey) {
421
+ const n = allIds.length;
422
+ if (!n) return null;
423
+
424
+ // Try at most n ids to find one that's not already in the DOM
425
+ for (let tries = 0; tries < n; tries++) {
426
+ const idx = state[cursorKey] % n;
427
+ state[cursorKey] = (state[cursorKey] + 1) % n;
428
+
429
+ const id = allIds[idx];
430
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
431
+ if (ph && ph.isConnected) continue;
432
+
433
+ return id;
434
+ }
435
+ return null;
436
+ }
437
+
438
+
439
+ function removeOneOldWrap(kindClass) {
440
+ try {
441
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
442
+ if (!wraps.length) return false;
443
+
444
+ // Prefer a wrap far above the viewport
445
+ let victim = null;
446
+ for (const w of wraps) {
447
+ const r = w.getBoundingClientRect();
448
+ if (r.bottom < -2000) { victim = w; break; }
449
+ }
450
+ // Otherwise remove the earliest one in the document
451
+ if (!victim) victim = wraps[0];
452
+
453
+ // Unobserve placeholder if still observed
454
+ try {
455
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
456
+ if (ph && state.io) state.io.unobserve(ph);
457
+ } catch (e) {}
458
+
459
+ victim.remove();
460
+ return true;
165
461
  } catch (e) {
166
- return null;
462
+ return false;
167
463
  }
168
464
  }
169
465
 
170
- function pickId(pool) {
171
- if (!pool || !pool.length) return null;
172
- return pool.shift();
466
+ function enqueueShow(id) {
467
+ if (!id || isBlocked()) return;
468
+
469
+ // Basic per-id throttle (prevents rapid re-requests when DOM churns)
470
+ const now = Date.now();
471
+ const last = state.lastShowById.get(id) || 0;
472
+ if (now - last < 900) return;
473
+
474
+ const max = getMaxInflight();
475
+ if (state.inflight >= max) {
476
+ if (!state.pendingSet.has(id)) {
477
+ state.pending.push(id);
478
+ state.pendingSet.add(id);
479
+ }
480
+ return;
481
+ }
482
+ startShow(id);
483
+ }
484
+
485
+ function drainQueue() {
486
+ if (isBlocked()) return;
487
+ const max = getMaxInflight();
488
+ while (state.inflight < max && state.pending.length) {
489
+ const id = state.pending.shift();
490
+ state.pendingSet.delete(id);
491
+ startShow(id);
173
492
  }
493
+ }
494
+
495
+ function startShow(id) {
496
+ if (!id || isBlocked()) return;
497
+
498
+ state.inflight++;
499
+ let released = false;
500
+ const release = () => {
501
+ if (released) return;
502
+ released = true;
503
+ state.inflight = Math.max(0, state.inflight - 1);
504
+ drainQueue();
505
+ };
174
506
 
175
- function callShowAds(id) {
176
- if (!id) return;
177
-
507
+ const hardTimer = setTimeout(release, 6500);
508
+
509
+ requestAnimationFrame(() => {
178
510
  try {
511
+ if (isBlocked()) return;
512
+
513
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
514
+ if (!ph || !ph.isConnected) return;
515
+
516
+ const now2 = Date.now();
517
+ const last2 = state.lastShowById.get(id) || 0;
518
+ if (now2 - last2 < 900) return;
519
+ state.lastShowById.set(id, now2);
520
+
179
521
  window.ezstandalone = window.ezstandalone || {};
180
- 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);
522
+ const ez = window.ezstandalone;
523
+
524
+ const doShow = () => {
525
+ try {
526
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
527
+ try { ez.destroyPlaceholders(id); } catch (e) {}
528
+ }
529
+ } catch (e) {}
530
+
531
+ try { ez.showAds(id); } catch (e) {}
532
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
533
+ try { markEmptyWrapper(id); } catch (e) {}
534
+
535
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
536
+ };
537
+
538
+ if (Array.isArray(ez.cmd)) {
539
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
540
+ } else {
541
+ doShow();
542
+ }
543
+ } finally {
544
+ // If we returned early, hardTimer will release.
545
+ }
546
+ });
547
+ }
548
+
549
+
550
+ // ---------- preload / above-the-fold ----------
551
+
552
+ function ensurePreloadObserver() {
553
+ const desiredMargin = getPreloadRootMargin();
554
+ if (state.io && state.ioMargin === desiredMargin) return state.io;
555
+
556
+ // Rebuild IO if margin changed (e.g., scroll boost toggled)
557
+ if (state.io) {
558
+ try { state.io.disconnect(); } catch (e) {}
559
+ state.io = null;
560
+ }
561
+ try {
562
+ state.io = new IntersectionObserver((entries) => {
563
+ for (const ent of entries) {
564
+ if (!ent.isIntersecting) continue;
565
+ const el = ent.target;
566
+ try { state.io && state.io.unobserve(el); } catch (e) {}
567
+
568
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
569
+ const id = parseInt(idAttr, 10);
570
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
186
571
  }
187
- });
572
+ }, { root: null, rootMargin: desiredMargin, threshold: 0 });
573
+ state.ioMargin = desiredMargin;
574
+ } catch (e) {
575
+ state.io = null;
576
+ state.ioMargin = null;
577
+ }
578
+
579
+ // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
580
+ try {
581
+ if (state.io) {
582
+ const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
583
+ nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
584
+ }
188
585
  } catch (e) {}
586
+ return state.io;
189
587
  }
190
588
 
191
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
192
- if (!items || items.length === 0) return 0;
589
+ function observePlaceholder(id) {
590
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
591
+ if (!ph || !ph.isConnected) return;
592
+ const io = ensurePreloadObserver();
593
+ try { io && io.observe(ph); } catch (e) {}
193
594
 
194
- let inserted = 0;
195
- const targets = [];
595
+ // If already above fold, fire immediately
596
+ try {
597
+ const r = ph.getBoundingClientRect();
598
+ const screens = isBoosted() ? 5.0 : 3.0;
599
+ const minBottom = isBoosted() ? -1500 : -800;
600
+ if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
601
+ } catch (e) {}
602
+ }
603
+
604
+ // ---------- insertion logic ----------
196
605
 
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);
606
+ function computeTargets(count, interval, showFirst) {
607
+ const out = [];
608
+ if (count <= 0) return out;
609
+ if (showFirst) out.push(1);
610
+ for (let i = 1; i <= count; i++) {
611
+ if (i % interval === 0) out.push(i);
202
612
  }
613
+ return Array.from(new Set(out)).sort((a, b) => a - b);
614
+ }
615
+
616
+ function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
617
+ if (!items.length) return 0;
618
+
619
+ const targets = computeTargets(items.length, interval, showFirst);
620
+ let inserted = 0;
621
+ const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
203
622
 
204
623
  for (const afterPos of targets) {
205
- if (inserted >= MAX_INSERTS_PER_RUN) break;
624
+ if (inserted >= maxInserts) break;
206
625
 
207
626
  const el = items[afterPos - 1];
208
627
  if (!el || !el.isConnected) continue;
628
+ if (isAdjacentAd(el)) continue;
209
629
  if (findWrap(kindClass, afterPos)) continue;
210
630
 
211
- const id = pickId(pool);
212
- if (!id) break;
213
-
631
+ let id = pickIdFromAll(allIds, cursorKey);
632
+ if (!id) {
633
+ // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
634
+ // Guard against tight observer loops.
635
+ if (!canRecycle(kindKeyFromClass(kindClass))) {
636
+ dbg('recycle-skip-cooldown', kindClass);
637
+ break;
638
+ }
639
+ let recycled = false;
640
+ withInternalDomChange(() => {
641
+ recycled = removeOneOldWrap(kindClass);
642
+ });
643
+ dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
644
+ // Stop this run after a recycle; the next mutation/scroll will retry injection.
645
+ break;
646
+ }
214
647
  const wrap = insertAfter(el, id, kindClass, afterPos);
215
- if (!wrap) continue;
648
+ if (!wrap) {
649
+ continue;
650
+ }
216
651
 
217
- usedSet.add(id);
218
- callShowAds(id);
652
+ observePlaceholder(id);
219
653
  inserted += 1;
220
654
  }
221
655
 
222
656
  return inserted;
223
657
  }
224
658
 
225
- async function runCore() {
226
- if (!state.canShowAds) return;
227
-
228
- const cfg = await fetchConfig();
229
- if (!cfg || cfg.excluded) return;
659
+ async function insertHeroAdEarly() {
660
+ if (state.heroDoneForPage) return;
661
+ const cfg = await fetchConfigOnce();
662
+ if (!cfg) { dbg('no-config'); return; }
663
+ if (cfg.excluded) { dbg('excluded'); return; }
230
664
 
231
665
  initPools(cfg);
232
666
 
233
667
  const kind = getKind();
234
- let inserted = 0;
668
+ let items = [];
669
+ let allIds = [];
670
+ let cursorKey = '';
671
+ let kindClass = '';
672
+ let showFirst = false;
235
673
 
236
674
  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
- );
675
+ items = getPostContainers();
676
+ allIds = state.allPosts;
677
+ cursorKey = 'curPosts';
678
+ kindClass = 'ezoic-ad-message';
679
+ showFirst = normalizeBool(cfg.showFirstMessageAd);
245
680
  } 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
- );
681
+ items = getTopicItems();
682
+ allIds = state.allTopics;
683
+ cursorKey = 'curTopics';
684
+ kindClass = 'ezoic-ad-between';
685
+ showFirst = normalizeBool(cfg.showFirstTopicAd);
254
686
  } 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
- );
687
+ items = getCategoryItems();
688
+ allIds = state.allCategories;
689
+ cursorKey = 'curCategories';
690
+ kindClass = 'ezoic-ad-categories';
691
+ showFirst = normalizeBool(cfg.showFirstCategoryAd);
692
+ } else {
693
+ return;
263
694
  }
695
+
696
+ if (!items.length) return;
697
+ if (!showFirst) { state.heroDoneForPage = true; return; }
698
+
699
+ // Insert after the very first item (above-the-fold)
700
+ const afterPos = 1;
701
+ const el = items[afterPos - 1];
702
+ if (!el || !el.isConnected) return;
703
+ if (isAdjacentAd(el)) return;
704
+ if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
705
+
706
+ const id = pickIdFromAll(allIds, cursorKey);
707
+ if (!id) return;
708
+
709
+ const wrap = insertAfter(el, id, kindClass, afterPos);
710
+ if (!wrap) {
711
+ return;
712
+ }
713
+
714
+ state.heroDoneForPage = true;
715
+ observePlaceholder(id);
264
716
  }
265
717
 
266
- function scheduleRun() {
267
- if (state.scheduled) return;
268
- state.scheduled = true;
718
+ async function runCore() {
719
+ if (isBlocked()) { dbg('blocked'); return; }
720
+
721
+ patchShowAds();
269
722
 
270
- clearTimeout(state.timer);
271
- state.timer = setTimeout(() => {
272
- state.scheduled = false;
723
+ const cfg = await fetchConfigOnce();
724
+ if (!cfg) { dbg('no-config'); return; }
725
+ if (cfg.excluded) { dbg('excluded'); return; }
726
+ initPools(cfg);
727
+
728
+ const kind = getKind();
729
+
730
+ if (kind === 'topic') {
731
+ if (normalizeBool(cfg.enableMessageAds)) {
732
+ const __items = getPostContainers();
733
+ pruneOrphanWraps('ezoic-ad-message', __items);
734
+ injectBetween(
735
+ 'ezoic-ad-message',
736
+ __items,
737
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
738
+ normalizeBool(cfg.showFirstMessageAd),
739
+ state.allPosts,
740
+ 'curPosts'
741
+ );
742
+ }
743
+ } else if (kind === 'categoryTopics') {
744
+ if (normalizeBool(cfg.enableBetweenAds)) {
745
+ const __items = getTopicItems();
746
+ pruneOrphanWraps('ezoic-ad-between', __items);
747
+ injectBetween(
748
+ 'ezoic-ad-between',
749
+ __items,
750
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
751
+ normalizeBool(cfg.showFirstTopicAd),
752
+ state.allTopics,
753
+ 'curTopics'
754
+ );
755
+ }
756
+ } else if (kind === 'categories') {
757
+ if (normalizeBool(cfg.enableCategoryAds)) {
758
+ const __items = getCategoryItems();
759
+ pruneOrphanWraps('ezoic-ad-categories', __items);
760
+ injectBetween(
761
+ 'ezoic-ad-categories',
762
+ __items,
763
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
764
+ normalizeBool(cfg.showFirstCategoryAd),
765
+ state.allCategories,
766
+ 'curCategories'
767
+ );
768
+ }
769
+ }
770
+ }
771
+
772
+ function scheduleRun() {
773
+ if (state.runQueued) return;
774
+ state.runQueued = true;
775
+ window.requestAnimationFrame(() => {
776
+ state.runQueued = false;
273
777
  const pk = getPageKey();
274
778
  if (state.pageKey && pk !== state.pageKey) return;
275
779
  runCore().catch(() => {});
276
- }, 50);
780
+ });
277
781
  }
278
782
 
783
+ // ---------- observers / lifecycle ----------
784
+
279
785
  function cleanup() {
280
- const allIds = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
281
- if (allIds.length) destroyPlaceholderIds(allIds);
786
+ blockedUntil = Date.now() + 1200;
282
787
 
283
- document.querySelectorAll('.ezoic-ad').forEach(el => {
284
- try { el.remove(); } catch (e) {}
285
- });
788
+ // remove all wrappers
789
+ try {
790
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
791
+ try { el.remove(); } catch (e) {}
792
+ });
793
+ } catch (e) {}
286
794
 
287
- state.pageKey = getPageKey();
795
+ // reset state
288
796
  state.cfg = null;
289
- state.cfgPromise = null;
290
- state.poolTopics = [];
291
- state.poolPosts = [];
292
- state.poolCategories = [];
293
- state.usedTopics.clear();
294
- state.usedPosts.clear();
295
- state.usedCategories.clear();
797
+ state.allTopics = [];
798
+ state.allPosts = [];
799
+ state.allCategories = [];
800
+ state.curTopics = 0;
801
+ state.curPosts = 0;
802
+ state.curCategories = 0;
296
803
  state.lastShowById.clear();
297
- sessionDefinedIds.clear();
298
-
299
- if (state.obs) {
300
- state.obs.disconnect();
301
- state.obs = null;
302
- }
804
+ state.inflight = 0;
805
+ state.pending = [];
806
+ try { state.pendingSet && state.pendingSet.clear(); } catch (e) {}
807
+ try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
808
+ state.heroDoneForPage = false;
303
809
 
304
- state.scheduled = false;
305
- clearTimeout(state.timer);
306
- state.timer = null;
810
+ // keep observers alive (MutationObserver will re-trigger after navigation)
307
811
  }
308
812
 
309
- function ensureObserver() {
310
- if (state.obs) return;
311
- state.obs = new MutationObserver(() => scheduleRun());
813
+ function ensureDomObserver() {
814
+ if (state.domObs) return;
815
+ state.domObs = new MutationObserver(() => {
816
+ if (state.internalDomChange > 0) return;
817
+ if (!isBlocked()) scheduleRun();
818
+ });
312
819
  try {
313
- state.obs.observe(document.body, { childList: true, subtree: true });
820
+ state.domObs.observe(document.body, { childList: true, subtree: true });
314
821
  } catch (e) {}
315
822
  }
316
823
 
317
- 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();
326
- } else {
327
- setTimeout(check, 200);
328
- }
329
- };
330
- check();
331
- }
332
-
333
- function bind() {
824
+ function bindNodeBB() {
334
825
  if (!$) return;
335
826
 
336
827
  $(window).off('.ezoicInfinite');
337
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
828
+
829
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
830
+ cleanup();
831
+ });
832
+
338
833
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
339
834
  state.pageKey = getPageKey();
340
- ensureObserver();
341
- state.canShowAds = true;
342
- scheduleRun();
343
- });
835
+ blockedUntil = 0;
836
+
837
+ warmUpNetwork();
838
+ patchShowAds();
839
+ ensurePreloadObserver();
840
+ ensureDomObserver();
841
+
842
+ // Ultra-fast above-the-fold first
843
+ insertHeroAdEarly().catch(() => {});
344
844
 
345
- $(window).on('action:category.loaded.ezoicInfinite', () => {
346
- ensureObserver();
347
- waitForContentThenRun();
845
+ // Then normal insertion
846
+ scheduleRun();
348
847
  });
349
848
 
350
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
351
- ensureObserver();
352
- waitForContentThenRun();
849
+ // Infinite scroll / partial updates
850
+ $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
851
+ if (isBlocked()) return;
852
+ scheduleRun();
353
853
  });
354
854
  }
355
855
 
356
- function init() {
357
- state.pageKey = getPageKey();
358
- state.canShowAds = true;
359
- bind();
360
- ensureObserver();
361
- waitForContentThenRun();
856
+ function bindScroll() {
857
+ let ticking = false;
858
+ window.addEventListener('scroll', () => {
859
+ // Detect very fast scrolling and temporarily boost preload/parallelism.
860
+ try {
861
+ const now = Date.now();
862
+ const y = window.scrollY || window.pageYOffset || 0;
863
+ if (state.lastScrollTs) {
864
+ const dt = now - state.lastScrollTs;
865
+ const dy = Math.abs(y - (state.lastScrollY || 0));
866
+ if (dt > 0) {
867
+ const speed = dy / dt; // px/ms
868
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
869
+ const wasBoosted = isBoosted();
870
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
871
+ if (!wasBoosted) {
872
+ // margin changed -> rebuild IO so existing placeholders get earlier preload
873
+ ensurePreloadObserver();
874
+ }
875
+ }
876
+ }
877
+ }
878
+ state.lastScrollY = y;
879
+ state.lastScrollTs = now;
880
+ } catch (e) {}
881
+
882
+ if (ticking) return;
883
+ ticking = true;
884
+ window.requestAnimationFrame(() => {
885
+ ticking = false;
886
+ if (!isBlocked()) scheduleRun();
887
+ });
888
+ }, { passive: true });
362
889
  }
363
890
 
364
- if ($ && $(document).ready) {
365
- $(document).ready(init);
366
- } else if (document.readyState === 'loading') {
367
- document.addEventListener('DOMContentLoaded', init);
368
- } else {
369
- init();
370
- }
891
+ // ---------- boot ----------
892
+
893
+ state.pageKey = getPageKey();
894
+ warmUpNetwork();
895
+ patchShowAds();
896
+ ensurePreloadObserver();
897
+ ensureDomObserver();
898
+
899
+ bindNodeBB();
900
+ bindScroll();
901
+
902
+ // First paint: try hero + run
903
+ blockedUntil = 0;
904
+ insertHeroAdEarly().catch(() => {});
905
+ scheduleRun();
371
906
  })();
package/public/style.css CHANGED
@@ -1,11 +1,40 @@
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;
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
+ }
22
+
23
+
24
+ /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
25
+ .ezoic-ad.is-empty {
26
+ display: block !important;
27
+ margin: 0 !important;
28
+ padding: 0 !important;
29
+ height: 0 !important;
30
+ min-height: 0 !important;
31
+ overflow: hidden !important;
32
+ }
33
+
34
+ .ezoic-ad {
4
35
  min-height: 0 !important;
5
- min-width: 0 !important;
6
36
  }
7
37
 
8
- .ezoic-ad * {
38
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
9
39
  min-height: 0 !important;
10
- min-width: 0 !important;
11
40
  }