nodebb-plugin-ezoic-infinite 1.8.74 → 1.8.76

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/CLAUDE.md ADDED
@@ -0,0 +1,67 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ A NodeBB 4.x plugin that integrates Ezoic Infinite Ads with infinite scroll support. It injects Ezoic placeholder ads between topics, posts, and categories using a pool of placeholder IDs.
8
+
9
+ ## Development Setup
10
+
11
+ This plugin has no build step, no test suite, and no dependencies beyond NodeBB's core. To test it:
12
+
13
+ 1. Copy/symlink the plugin directory into a NodeBB instance's `node_modules/nodebb-plugin-ezoic-infinite/`
14
+ 2. Activate the plugin in NodeBB Admin > Extend > Plugins
15
+ 3. Configure placeholder IDs at Admin > Plugins > Ezoic Infinite Ads
16
+
17
+ To package for distribution:
18
+ ```bash
19
+ zip -r nodebb-plugin-onekite-ezoic.zip . --exclude "*.zip" --exclude ".git/*" --exclude "CLAUDE.md"
20
+ ```
21
+
22
+ ## Architecture
23
+
24
+ ### Server-side (`library.js`)
25
+ Single file exporting a `plugin` object with NodeBB hooks:
26
+ - `filter:middleware.renderHeader` → `injectEzoicHead`: Injects Ezoic scripts + inline config into `<head>`. For excluded users, injects a stub that stubs `ezstandalone` without loading ad scripts.
27
+ - `static:app.load` → `init`: Registers admin routes (`/admin/plugins/ezoic-infinite`) and a config API endpoint (`/api/plugins/ezoic-infinite/config`).
28
+ - `filter:admin.header.build` → `addAdminNavigation`: Adds the plugin to the ACP nav.
29
+ - `action:settings.set` → `onSettingsSet`: Invalidates the settings cache on save.
30
+
31
+ Settings are cached for 30s. Group membership for ad exclusion is cached for 60s per uid+groups combination (up to 1000 entries).
32
+
33
+ The inline config is serialized as `window.__nbbEzoicCfg` in the HTML head, so `client.js` can read it without an extra network request.
34
+
35
+ ### Client-side (`public/client.js`)
36
+ Self-contained IIFE with no external dependencies beyond jQuery (available globally in NodeBB) and the Ezoic `ezstandalone` API.
37
+
38
+ **Page type detection** (`detectKind`): Determines if the current page is a topic, category topic list, or categories index, then selects the corresponding ad kind (`ezoic-ad-message`, `ezoic-ad-between`, `ezoic-ad-categories`).
39
+
40
+ **Ad injection flow**:
41
+ 1. `requestBurst()` → `scheduleRun()` → `runCore()` → `injectBetween()`
42
+ 2. `injectBetween` iterates DOM items, checks ordinal position against the configured interval, inserts wrapper divs after qualifying elements using `insertAfter()`
43
+ 3. Each wrapper contains an Ezoic placeholder `<div id="ezoic-pub-ad-placeholder-{id}">`
44
+ 4. `IntersectionObserver` triggers `enqueueShow(id)` → `startShow(id)` → `ezstandalone.showAds(id)` when placeholders approach the viewport
45
+
46
+ **Pool/recycling**: Placeholder IDs are organized into three pools (topics, posts, categories). When all IDs in a pool are in use, `recycleWrap` finds the oldest off-screen wrap, calls `destroyPlaceholders`, and reuses it for the new position.
47
+
48
+ **State resets**: On `action:ajaxify.start` (SPA navigation), `cleanup()` destroys all wraps, resets all state. On `action:ajaxify.end`, re-initializes and triggers a burst.
49
+
50
+ ### Admin UI
51
+ - `public/templates/admin/plugins/ezoic-infinite.tpl`: NodeBB Benchpress template (French labels), uses `Settings.load`/`Settings.save` from NodeBB's ACP framework
52
+ - `public/admin.js`: Minimal ACP script wiring the save button to NodeBB's `settings` module
53
+ - `public/style.css`: Visual styles for the `.nodebb-ezoic-wrap` and `.is-empty` states
54
+
55
+ ### Key Constants (client.js)
56
+ - `WRAP_CLASS = 'nodebb-ezoic-wrap'`: CSS class on all injected wrappers
57
+ - `PH_PREFIX = 'ezoic-pub-ad-placeholder-'`: Prefix for placeholder element IDs
58
+ - `MAX_INSERTS_RUN = 6`: Max ad insertions per `runCore()` call
59
+ - `MAX_INFLIGHT = 4`: Max concurrent `showAds()` calls
60
+ - Three placeholder ID pools must not overlap between ad types
61
+ - `S.wrapTimers: Map<id, timerId[]>` tracks all pending timers per wrap — cancelled in `dropWrap()` and `cleanup()`
62
+ - `cleanup()` disconnects `S.domObs` and `S.io` so they are re-created fresh on next SPA page
63
+
64
+ ### Important Constraints
65
+ - `sa.min.js` must load **synchronously** (no `async` attribute) — loading it async causes "Timed out waiting for loadingStatus LOADING" because it may execute before the CMP is ready
66
+ - Placeholder IDs must be distinct across the three pools (topics, posts, categories)
67
+ - The `showAds()` function is monkey-patched to deduplicate calls and guard against disconnected placeholders
package/library.js CHANGED
@@ -16,7 +16,7 @@ function normalizeExcludedGroups(value) {
16
16
  if (s.startsWith('[')) {
17
17
  try {
18
18
  const parsed = JSON.parse(s);
19
- if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
19
+ if (Array.isArray(parsed)) return parsed.map(v => String(v).trim()).filter(Boolean);
20
20
  } catch (_) {}
21
21
  }
22
22
  return s.split(',').map(v => v.trim()).filter(Boolean);
@@ -30,15 +30,20 @@ function parseBool(v, def = false) {
30
30
  }
31
31
 
32
32
  async function getAllGroups() {
33
- let names = await db.getSortedSetRange('groups:createtime', 0, -1);
34
- if (!names?.length) {
35
- names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
33
+ try {
34
+ let names = await db.getSortedSetRange('groups:createtime', 0, -1);
35
+ if (!names?.length) {
36
+ names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
37
+ }
38
+ return (await groups.getGroupsData(
39
+ (names || []).filter(name => !groups.isPrivilegeGroup(name))
40
+ ))
41
+ .filter(g => g?.name)
42
+ .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
43
+ } catch (err) {
44
+ console.error('[ezoic-infinite] getAllGroups error:', err.message);
45
+ return [];
36
46
  }
37
- return (await groups.getGroupsData(
38
- (names || []).filter(name => !groups.isPrivilegeGroup(name))
39
- ))
40
- .filter(g => g?.name)
41
- .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
42
47
  }
43
48
 
44
49
  // ── Settings cache ───────────────────────────────────────────────────────────
@@ -88,7 +93,11 @@ async function isUserExcluded(uid, excludedGroups) {
88
93
 
89
94
  _excludeCache.set(key, { value, at: Date.now() });
90
95
  if (_excludeCache.size > 1000) {
91
- _excludeCache.delete(_excludeCache.keys().next().value);
96
+ let toDel = 100;
97
+ for (const k of _excludeCache.keys()) {
98
+ _excludeCache.delete(k);
99
+ if (--toDel <= 0) break;
100
+ }
92
101
  }
93
102
 
94
103
  return value;
@@ -200,9 +209,12 @@ plugin.init = async ({ router, middleware }) => {
200
209
  res.render('admin/plugins/ezoic-infinite', {
201
210
  title: 'Ezoic Infinite Ads',
202
211
  ...settings,
203
- enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
204
- enableCategoryAds_checked: settings.enableCategoryAds ? 'checked' : '',
205
- enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
212
+ enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
213
+ showFirstTopicAd_checked: settings.showFirstTopicAd ? 'checked' : '',
214
+ enableCategoryAds_checked: settings.enableCategoryAds ? 'checked' : '',
215
+ showFirstCategoryAd_checked: settings.showFirstCategoryAd ? 'checked' : '',
216
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
217
+ showFirstMessageAd_checked: settings.showFirstMessageAd ? 'checked' : '',
206
218
  allGroups,
207
219
  });
208
220
  }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.74",
3
+ "version": "1.8.76",
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,5 +1,5 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.4.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.5.0
3
3
  *
4
4
  * Architecture: proven v50 core + targeted improvements.
5
5
  * Ezoic API: showAds() + destroyPlaceholders() per official docs.
@@ -23,16 +23,17 @@
23
23
  };
24
24
 
25
25
  const TIMING = {
26
- EMPTY_CHECK_MS_1: 30_000,
27
- EMPTY_CHECK_MS_2: 60_000,
28
- MIN_PRUNE_AGE_MS: 8_000,
29
- RECYCLE_MIN_AGE_MS: 5_000,
30
- SHOW_THROTTLE_MS: 900,
31
- BURST_COOLDOWN_MS: 200,
32
- BLOCK_DURATION_MS: 1_500,
33
- SHOW_TIMEOUT_MS: 7_000,
34
- SHOW_RELEASE_MS: 700,
35
- RECYCLE_DELAY_MS: 450,
26
+ EMPTY_CHECK_MS_1: 30_000,
27
+ EMPTY_CHECK_MS_2: 60_000,
28
+ MIN_PRUNE_AGE_MS: 8_000,
29
+ RECYCLE_MIN_AGE_MS: 12_000,
30
+ SHOW_THROTTLE_MS: 900,
31
+ BURST_COOLDOWN_MS: 200,
32
+ BLOCK_DURATION_MS: 1_500,
33
+ SHOW_TIMEOUT_MS: 7_000,
34
+ SHOW_RELEASE_MS: 700,
35
+ RECYCLE_DELAY_MS: 450,
36
+ UNCOLLAPSE_CHECK_MS: [500, 3_000, 10_000],
36
37
  };
37
38
 
38
39
  const MAX_INSERTS_RUN = 6;
@@ -40,8 +41,8 @@
40
41
  const MAX_BURST_STEPS = 8;
41
42
  const BURST_WINDOW_MS = 2_000;
42
43
 
43
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
44
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
44
+ const IO_MARGIN_DESKTOP = '1200px 0px 1200px 0px';
45
+ const IO_MARGIN_MOBILE = '1500px 0px 1500px 0px';
45
46
 
46
47
  const SEL = {
47
48
  post: '[component="post"][data-pid]',
@@ -86,6 +87,7 @@
86
87
  lastShow: new Map(),
87
88
  wrapByKey: new Map(),
88
89
  wrapsByClass: new Map(),
90
+ wrapTimers: new Map(),
89
91
  io: null,
90
92
  domObs: null,
91
93
  mutGuard: 0,
@@ -116,7 +118,10 @@
116
118
  if (inline && typeof inline === 'object') { S.cfg = inline; return S.cfg; }
117
119
  } catch (_) {}
118
120
  try {
119
- const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
121
+ const ctrl = new AbortController();
122
+ const t = setTimeout(() => ctrl.abort(), 5_000);
123
+ const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin', signal: ctrl.signal });
124
+ clearTimeout(t);
120
125
  if (r.ok) S.cfg = await r.json();
121
126
  } catch (_) {}
122
127
  return S.cfg;
@@ -263,11 +268,13 @@
263
268
  return true;
264
269
  }
265
270
 
266
- function scheduleUncollapseChecks(wrap) {
267
- if (!wrap) return;
268
- for (const ms of [500, 3000, 10000]) {
269
- setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
271
+ function scheduleUncollapseChecks(wrap, id) {
272
+ if (!wrap || !id) return;
273
+ const ids = S.wrapTimers.get(id) || [];
274
+ for (const ms of TIMING.UNCOLLAPSE_CHECK_MS) {
275
+ ids.push(setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms));
270
276
  }
277
+ S.wrapTimers.set(id, ids);
271
278
  }
272
279
 
273
280
  // ── Pool management ────────────────────────────────────────────────────────
@@ -291,7 +298,7 @@
291
298
  if (typeof ez?.destroyPlaceholders !== 'function' ||
292
299
  typeof ez?.showAds !== 'function') return null;
293
300
 
294
- const threshold = -(3 * (window.innerHeight || 800));
301
+ const threshold = -(5 * (window.innerHeight || 800));
295
302
  const t = now();
296
303
  let bestEmpty = null, bestEmptyY = Infinity;
297
304
  let bestFull = null, bestFullY = Infinity;
@@ -302,7 +309,9 @@
302
309
  for (const wrap of wraps) {
303
310
  try {
304
311
  const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
305
- if (t - created < TIMING.RECYCLE_MIN_AGE_MS) continue;
312
+ const shown = parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10);
313
+ const refTs = shown > 0 ? Math.max(created, shown) : created;
314
+ if (t - refTs < TIMING.RECYCLE_MIN_AGE_MS) continue;
306
315
  const bottom = wrap.getBoundingClientRect().bottom;
307
316
  if (bottom > threshold) continue;
308
317
  if (!isFilled(wrap)) {
@@ -387,6 +396,8 @@
387
396
  if (Number.isFinite(id)) {
388
397
  S.mountedIds.delete(id);
389
398
  S.lastShow.delete(id);
399
+ const timers = S.wrapTimers.get(id);
400
+ if (timers) { for (const t of timers) clearTimeout(t); S.wrapTimers.delete(id); }
390
401
  }
391
402
  const key = w.getAttribute(ATTR.ANCHOR);
392
403
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
@@ -536,18 +547,19 @@
536
547
  const ez = window.ezstandalone;
537
548
  const doShow = () => {
538
549
  try { ez.showAds(id); } catch (_) {}
539
- if (wrap) scheduleUncollapseChecks(wrap);
550
+ if (wrap) scheduleUncollapseChecks(wrap, id);
540
551
  scheduleEmptyCheck(id, t);
541
552
  setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
542
553
  };
543
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
554
+ typeof ez.cmd?.push === 'function' ? ez.cmd.push(doShow) : doShow();
544
555
  } catch (_) { clearTimeout(timer); release(); }
545
556
  });
546
557
  }
547
558
 
548
559
  function scheduleEmptyCheck(id, showTs) {
560
+ const ids = S.wrapTimers.get(id) || [];
549
561
  for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
550
- setTimeout(() => {
562
+ ids.push(setTimeout(() => {
551
563
  try {
552
564
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
553
565
  const wrap = ph?.closest(`.${WRAP_CLASS}`);
@@ -558,8 +570,9 @@
558
570
  if (ph.offsetHeight > 10) return;
559
571
  wrap.classList.add('is-empty');
560
572
  } catch (_) {}
561
- }, delay);
573
+ }, delay));
562
574
  }
575
+ S.wrapTimers.set(id, ids);
563
576
  }
564
577
 
565
578
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
@@ -599,7 +612,6 @@
599
612
  async function runCore() {
600
613
  if (isBlocked()) return 0;
601
614
  patchShowAds();
602
- try { gcDisconnectedWraps(); } catch (_) {}
603
615
 
604
616
  const cfg = await fetchConfig();
605
617
  if (!cfg || cfg.excluded) return 0;
@@ -666,6 +678,10 @@
666
678
  mutate(() => {
667
679
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
668
680
  });
681
+ for (const timers of S.wrapTimers.values()) { for (const t of timers) clearTimeout(t); }
682
+ S.wrapTimers.clear();
683
+ S.domObs?.disconnect(); S.domObs = null;
684
+ S.io?.disconnect(); S.io = null;
669
685
  S.cfg = null;
670
686
  S.poolsReady = false;
671
687
  S.pools = { topics: [], posts: [], categories: [] };
@@ -894,7 +910,7 @@
894
910
  _retries++;
895
911
  patchShowAds();
896
912
  if (!isBlocked() && !S.burstActive) {
897
- S.lastBurstTs = 0;
913
+ S.lastBurstTs = now() - TIMING.BURST_COOLDOWN_MS;
898
914
  requestBurst();
899
915
  }
900
916
  setTimeout(retryBoot, _retries <= 4 ? 300 : 1000);
@@ -8,7 +8,7 @@
8
8
  <input class="form-check-input" type="checkbox" id="enableBetweenAds" name="enableBetweenAds" {enableBetweenAds_checked}>
9
9
  <label class="form-check-label" for="enableBetweenAds">Activer les pubs entre les posts</label>
10
10
  <div class="form-check mt-2">
11
- <input class="form-check-input" type="checkbox" name="showFirstTopicAd" />
11
+ <input class="form-check-input" type="checkbox" name="showFirstTopicAd" {showFirstTopicAd_checked} />
12
12
  <label class="form-check-label">Afficher une pub après le 1er sujet</label>
13
13
  </div>
14
14
  </div>
@@ -24,9 +24,6 @@
24
24
  <input type="number" id="intervalPosts" name="intervalPosts" class="form-control" value="{intervalPosts}" min="1">
25
25
  </div>
26
26
 
27
- <hr/>
28
-
29
-
30
27
  <hr/>
31
28
 
32
29
  <h4 class="mt-3">Pubs entre les catégories (page d’accueil)</h4>
@@ -36,7 +33,7 @@
36
33
  <input class="form-check-input" type="checkbox" id="enableCategoryAds" name="enableCategoryAds">
37
34
  <label class="form-check-label" for="enableCategoryAds">Activer les pubs entre les catégories</label>
38
35
  <div class="form-check mt-2">
39
- <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" />
36
+ <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" {showFirstCategoryAd_checked} />
40
37
  <label class="form-check-label">Afficher une pub après la 1ère catégorie</label>
41
38
  </div>
42
39
  </div>
@@ -59,7 +56,7 @@
59
56
  <input class="form-check-input" type="checkbox" id="enableMessageAds" name="enableMessageAds" {enableMessageAds_checked}>
60
57
  <label class="form-check-label" for="enableMessageAds">Activer les pubs “message”</label>
61
58
  <div class="form-check mt-2">
62
- <input class="form-check-input" type="checkbox" name="showFirstMessageAd" />
59
+ <input class="form-check-input" type="checkbox" name="showFirstMessageAd" {showFirstMessageAd_checked} />
63
60
  <label class="form-check-label">Afficher une pub après le 1er message</label>
64
61
  </div>
65
62
  </div>