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 +67 -0
- package/library.js +25 -13
- package/nodebb-plugin-onekite-ezoic.zip +0 -0
- package/package.json +1 -1
- package/public/client.js +42 -26
- 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: 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 = '
|
|
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 ────────────────────────────────────────────────────────
|
|
@@ -291,7 +298,7 @@
|
|
|
291
298
|
if (typeof ez?.destroyPlaceholders !== 'function' ||
|
|
292
299
|
typeof ez?.showAds !== 'function') return null;
|
|
293
300
|
|
|
294
|
-
const threshold = -(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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>
|