nodebb-plugin-ezoic-infinite 1.8.74 → 1.8.75
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 +67 -0
- package/library.js +25 -13
- package/nodebb-plugin-onekite-ezoic.zip +0 -0
- package/package.json +1 -1
- package/public/client.js +38 -24
- package/public/templates/admin/plugins/ezoic-infinite.tpl +3 -6
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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:
|
|
204
|
-
|
|
205
|
-
|
|
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
package/public/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — client.js v2.
|
|
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:
|
|
27
|
-
EMPTY_CHECK_MS_2:
|
|
28
|
-
MIN_PRUNE_AGE_MS:
|
|
29
|
-
RECYCLE_MIN_AGE_MS:
|
|
30
|
-
SHOW_THROTTLE_MS:
|
|
31
|
-
BURST_COOLDOWN_MS:
|
|
32
|
-
BLOCK_DURATION_MS:
|
|
33
|
-
SHOW_TIMEOUT_MS:
|
|
34
|
-
SHOW_RELEASE_MS:
|
|
35
|
-
RECYCLE_DELAY_MS:
|
|
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,
|
|
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 = '
|
|
44
|
-
const IO_MARGIN_MOBILE = '
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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 ────────────────────────────────────────────────────────
|
|
@@ -387,6 +394,8 @@
|
|
|
387
394
|
if (Number.isFinite(id)) {
|
|
388
395
|
S.mountedIds.delete(id);
|
|
389
396
|
S.lastShow.delete(id);
|
|
397
|
+
const timers = S.wrapTimers.get(id);
|
|
398
|
+
if (timers) { for (const t of timers) clearTimeout(t); S.wrapTimers.delete(id); }
|
|
390
399
|
}
|
|
391
400
|
const key = w.getAttribute(ATTR.ANCHOR);
|
|
392
401
|
if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
|
|
@@ -536,18 +545,19 @@
|
|
|
536
545
|
const ez = window.ezstandalone;
|
|
537
546
|
const doShow = () => {
|
|
538
547
|
try { ez.showAds(id); } catch (_) {}
|
|
539
|
-
if (wrap) scheduleUncollapseChecks(wrap);
|
|
548
|
+
if (wrap) scheduleUncollapseChecks(wrap, id);
|
|
540
549
|
scheduleEmptyCheck(id, t);
|
|
541
550
|
setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
|
|
542
551
|
};
|
|
543
|
-
|
|
552
|
+
typeof ez.cmd?.push === 'function' ? ez.cmd.push(doShow) : doShow();
|
|
544
553
|
} catch (_) { clearTimeout(timer); release(); }
|
|
545
554
|
});
|
|
546
555
|
}
|
|
547
556
|
|
|
548
557
|
function scheduleEmptyCheck(id, showTs) {
|
|
558
|
+
const ids = S.wrapTimers.get(id) || [];
|
|
549
559
|
for (const delay of [TIMING.EMPTY_CHECK_MS_1, TIMING.EMPTY_CHECK_MS_2]) {
|
|
550
|
-
setTimeout(() => {
|
|
560
|
+
ids.push(setTimeout(() => {
|
|
551
561
|
try {
|
|
552
562
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
553
563
|
const wrap = ph?.closest(`.${WRAP_CLASS}`);
|
|
@@ -558,8 +568,9 @@
|
|
|
558
568
|
if (ph.offsetHeight > 10) return;
|
|
559
569
|
wrap.classList.add('is-empty');
|
|
560
570
|
} catch (_) {}
|
|
561
|
-
}, delay);
|
|
571
|
+
}, delay));
|
|
562
572
|
}
|
|
573
|
+
S.wrapTimers.set(id, ids);
|
|
563
574
|
}
|
|
564
575
|
|
|
565
576
|
// ── Patch Ezoic showAds ────────────────────────────────────────────────────
|
|
@@ -599,7 +610,6 @@
|
|
|
599
610
|
async function runCore() {
|
|
600
611
|
if (isBlocked()) return 0;
|
|
601
612
|
patchShowAds();
|
|
602
|
-
try { gcDisconnectedWraps(); } catch (_) {}
|
|
603
613
|
|
|
604
614
|
const cfg = await fetchConfig();
|
|
605
615
|
if (!cfg || cfg.excluded) return 0;
|
|
@@ -666,6 +676,10 @@
|
|
|
666
676
|
mutate(() => {
|
|
667
677
|
for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
|
|
668
678
|
});
|
|
679
|
+
for (const timers of S.wrapTimers.values()) { for (const t of timers) clearTimeout(t); }
|
|
680
|
+
S.wrapTimers.clear();
|
|
681
|
+
S.domObs?.disconnect(); S.domObs = null;
|
|
682
|
+
S.io?.disconnect(); S.io = null;
|
|
669
683
|
S.cfg = null;
|
|
670
684
|
S.poolsReady = false;
|
|
671
685
|
S.pools = { topics: [], posts: [], categories: [] };
|
|
@@ -894,7 +908,7 @@
|
|
|
894
908
|
_retries++;
|
|
895
909
|
patchShowAds();
|
|
896
910
|
if (!isBlocked() && !S.burstActive) {
|
|
897
|
-
S.lastBurstTs =
|
|
911
|
+
S.lastBurstTs = now() - TIMING.BURST_COOLDOWN_MS;
|
|
898
912
|
requestBurst();
|
|
899
913
|
}
|
|
900
914
|
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>
|