nodebb-plugin-ezoic-infinite 1.5.6 → 1.5.8
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 +4 -4
- package/package.json +1 -1
- package/public/client.js +550 -327
- package/public/style.css +19 -7
package/library.js
CHANGED
|
@@ -68,7 +68,7 @@ async function getSettings() {
|
|
|
68
68
|
async function isUserExcluded(uid, excludedGroups) {
|
|
69
69
|
if (!uid || !excludedGroups.length) return false;
|
|
70
70
|
const userGroups = await groups.getUserGroups([uid]);
|
|
71
|
-
return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name :
|
|
71
|
+
return (userGroups[0] || []).some(g => excludedGroups.includes((g && g.name) ? g.name : g));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
plugin.onSettingsSet = function (data) {
|
|
@@ -105,13 +105,13 @@ plugin.init = async ({ router, middleware }) => {
|
|
|
105
105
|
router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
|
|
106
106
|
router.get('/api/admin/plugins/ezoic-infinite', render);
|
|
107
107
|
|
|
108
|
-
router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
|
|
108
|
+
router.get('/api/plugins/ezoic-infinite/config', middleware.authenticate, async (req, res) => {
|
|
109
109
|
const settings = await getSettings();
|
|
110
|
-
const
|
|
110
|
+
const uid = (typeof req.uid === 'number' ? req.uid : (req.user && typeof req.user.uid === 'number' ? req.user.uid : 0));
|
|
111
|
+
const excluded = await isUserExcluded(uid, settings.excludedGroups);
|
|
111
112
|
|
|
112
113
|
res.json({
|
|
113
114
|
excluded,
|
|
114
|
-
excludedGroups: settings.excludedGroups,
|
|
115
115
|
enableBetweenAds: settings.enableBetweenAds,
|
|
116
116
|
showFirstTopicAd: settings.showFirstTopicAd,
|
|
117
117
|
placeholderIds: settings.placeholderIds,
|
package/package.json
CHANGED
package/public/client.js
CHANGED
|
@@ -1,386 +1,609 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/**
|
|
3
|
-
* Ezoic Infinite Ads for NodeBB 4.x
|
|
4
|
-
* - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
|
|
5
|
-
* - Hard gate on server config (respects excluded groups BEFORE any injection)
|
|
6
|
-
* - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
|
|
7
|
-
* - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
(function () {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Safety stubs (do NOT stub showAds)
|
|
5
|
+
window._ezaq = window._ezaq || [];
|
|
6
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
7
|
+
window.ezstandalone.cmd = window.ezstandalone.cmd || [];
|
|
8
|
+
|
|
9
|
+
// NodeBB client context
|
|
10
|
+
const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
|
|
11
|
+
|
|
12
|
+
const WRAP_CLASS = 'ezoic-ad';
|
|
13
|
+
const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
|
|
14
|
+
|
|
15
|
+
// Insert at most N ads per run to keep the UI smooth on infinite scroll
|
|
16
|
+
const MAX_INSERTS_PER_RUN = 3;
|
|
17
|
+
|
|
18
|
+
// Preload before viewport (tune if you want even earlier)
|
|
19
|
+
const PRELOAD_ROOT_MARGIN = '1200px 0px';
|
|
20
|
+
|
|
21
|
+
const SELECTORS = {
|
|
22
|
+
topicItem: 'li[component="category/topic"]',
|
|
23
|
+
postItem: '[component="post"][data-pid]',
|
|
24
|
+
categoryItem: 'li[component="categories/category"]',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Hard block during navigation to avoid “placeholder does not exist” spam
|
|
28
|
+
let EZOIC_BLOCKED = false;
|
|
29
|
+
|
|
30
|
+
const state = {
|
|
31
|
+
pageKey: null,
|
|
32
|
+
cfg: null,
|
|
33
|
+
|
|
34
|
+
poolTopics: [],
|
|
35
|
+
poolPosts: [],
|
|
36
|
+
poolCategories: [],
|
|
37
|
+
|
|
38
|
+
usedTopics: new Set(),
|
|
39
|
+
usedPosts: new Set(),
|
|
40
|
+
usedCategories: new Set(),
|
|
41
|
+
|
|
42
|
+
// throttle per placeholder id
|
|
43
|
+
lastShowById: new Map(),
|
|
44
|
+
|
|
45
|
+
// observers / schedulers
|
|
46
|
+
domObs: null,
|
|
47
|
+
io: null,
|
|
48
|
+
runQueued: false,
|
|
49
|
+
|
|
50
|
+
// hero
|
|
51
|
+
heroDoneForPage: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sessionDefinedIds = new Set();
|
|
55
|
+
const insertingIds = new Set();
|
|
56
|
+
|
|
57
|
+
// ---------- small utils ----------
|
|
58
|
+
|
|
59
|
+
function normalizeBool(v) {
|
|
60
|
+
return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
|
|
23
61
|
}
|
|
24
62
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
let cfg = null;
|
|
34
|
-
|
|
35
|
-
// -------------------------
|
|
36
|
-
// Placeholder Registry (keeps IDs alive)
|
|
37
|
-
// -------------------------
|
|
38
|
-
const Pool = {
|
|
39
|
-
el: null,
|
|
40
|
-
ensurePool() {
|
|
41
|
-
if (this.el && document.body.contains(this.el)) return this.el;
|
|
42
|
-
const d = document.createElement('div');
|
|
43
|
-
d.id = 'ezoic-placeholder-pool';
|
|
44
|
-
d.style.display = 'none';
|
|
45
|
-
d.setAttribute('aria-hidden', 'true');
|
|
46
|
-
document.body.appendChild(d);
|
|
47
|
-
this.el = d;
|
|
48
|
-
return d;
|
|
49
|
-
},
|
|
50
|
-
ensurePlaceholder(id) {
|
|
51
|
-
if (!id) return null;
|
|
52
|
-
let ph = document.getElementById(id);
|
|
53
|
-
if (ph) return ph;
|
|
54
|
-
const pool = this.ensurePool();
|
|
55
|
-
ph = document.createElement('div');
|
|
56
|
-
ph.id = id;
|
|
57
|
-
pool.appendChild(ph);
|
|
58
|
-
return ph;
|
|
59
|
-
},
|
|
60
|
-
returnToPool(id) {
|
|
61
|
-
const ph = document.getElementById(id);
|
|
62
|
-
if (!ph) return;
|
|
63
|
-
const pool = this.ensurePool();
|
|
64
|
-
if (ph.parentElement !== pool) {
|
|
65
|
-
pool.appendChild(ph);
|
|
63
|
+
function uniqInts(lines) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const v of lines) {
|
|
67
|
+
const n = parseInt(v, 10);
|
|
68
|
+
if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
|
|
69
|
+
seen.add(n);
|
|
70
|
+
out.push(n);
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
|
|
78
|
-
return args.flat().map(String);
|
|
76
|
+
function parsePool(raw) {
|
|
77
|
+
if (!raw) return [];
|
|
78
|
+
const lines = String(raw)
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.map(s => s.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
return uniqInts(lines);
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
function
|
|
82
|
-
|
|
85
|
+
function getPageKey() {
|
|
86
|
+
try {
|
|
87
|
+
const ax = window.ajaxify;
|
|
88
|
+
if (ax && ax.data) {
|
|
89
|
+
if (ax.data.tid) return `topic:${ax.data.tid}`;
|
|
90
|
+
if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {}
|
|
93
|
+
return window.location.pathname;
|
|
94
|
+
}
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
function getKind() {
|
|
97
|
+
const p = window.location.pathname || '';
|
|
98
|
+
if (/^\/topic\//.test(p)) return 'topic';
|
|
99
|
+
if (/^\/category\//.test(p)) return 'categoryTopics';
|
|
100
|
+
if (p === '/' || /^\/categories/.test(p)) return 'categories';
|
|
101
|
+
|
|
102
|
+
// fallback by DOM
|
|
103
|
+
if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
|
|
104
|
+
if (document.querySelector(SELECTORS.postItem)) return 'topic';
|
|
105
|
+
if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
|
|
106
|
+
return 'other';
|
|
107
|
+
}
|
|
87
108
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
function getTopicItems() {
|
|
110
|
+
return Array.from(document.querySelectorAll(SELECTORS.topicItem));
|
|
111
|
+
}
|
|
91
112
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
function getCategoryItems() {
|
|
114
|
+
return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getPostContainers() {
|
|
118
|
+
const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
|
|
119
|
+
return nodes.filter((el) => {
|
|
120
|
+
if (!el || !el.isConnected) return false;
|
|
121
|
+
if (!el.querySelector('[component="post/content"]')) return false;
|
|
122
|
+
const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
|
|
123
|
+
if (parentPost && parentPost !== el) return false;
|
|
124
|
+
if (el.getAttribute('component') === 'post/parent') return false;
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- warm-up & patching ----------
|
|
130
|
+
|
|
131
|
+
const _warmLinksDone = new Set();
|
|
132
|
+
function warmUpNetwork() {
|
|
133
|
+
try {
|
|
134
|
+
const head = document.head || document.getElementsByTagName('head')[0];
|
|
135
|
+
if (!head) return;
|
|
136
|
+
const links = [
|
|
137
|
+
['preconnect', 'https://g.ezoic.net', true],
|
|
138
|
+
['dns-prefetch', 'https://g.ezoic.net', false],
|
|
139
|
+
['preconnect', 'https://go.ezoic.net', true],
|
|
140
|
+
['dns-prefetch', 'https://go.ezoic.net', false],
|
|
141
|
+
];
|
|
142
|
+
for (const [rel, href, cors] of links) {
|
|
143
|
+
const key = `${rel}|${href}`;
|
|
144
|
+
if (_warmLinksDone.has(key)) continue;
|
|
145
|
+
_warmLinksDone.add(key);
|
|
146
|
+
const link = document.createElement('link');
|
|
147
|
+
link.rel = rel;
|
|
148
|
+
link.href = href;
|
|
149
|
+
if (cors) link.crossOrigin = 'anonymous';
|
|
150
|
+
head.appendChild(link);
|
|
101
151
|
}
|
|
152
|
+
} catch (e) {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
|
|
156
|
+
function patchShowAds() {
|
|
157
|
+
const applyPatch = () => {
|
|
158
|
+
try {
|
|
159
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
160
|
+
const ez = window.ezstandalone;
|
|
161
|
+
if (window.__nodebbEzoicPatched) return;
|
|
162
|
+
if (typeof ez.showAds !== 'function') return;
|
|
163
|
+
|
|
164
|
+
window.__nodebbEzoicPatched = true;
|
|
165
|
+
const orig = ez.showAds;
|
|
166
|
+
|
|
167
|
+
ez.showAds = function (...args) {
|
|
168
|
+
if (EZOIC_BLOCKED) return;
|
|
169
|
+
|
|
170
|
+
let ids = [];
|
|
171
|
+
if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
|
|
172
|
+
else ids = args;
|
|
173
|
+
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
for (const v of ids) {
|
|
176
|
+
const id = parseInt(v, 10);
|
|
177
|
+
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
|
|
178
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
179
|
+
if (!ph || !ph.isConnected) continue;
|
|
180
|
+
seen.add(id);
|
|
181
|
+
try { orig.call(ez, id); } catch (e) {}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
} catch (e) {}
|
|
102
185
|
};
|
|
103
186
|
|
|
104
|
-
|
|
105
|
-
|
|
187
|
+
applyPatch();
|
|
188
|
+
if (!window.__nodebbEzoicPatched) {
|
|
189
|
+
try {
|
|
190
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
191
|
+
window.ezstandalone.cmd = window.ezstandalone.cmd || [];
|
|
192
|
+
window.ezstandalone.cmd.push(applyPatch);
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
}
|
|
106
195
|
}
|
|
107
196
|
|
|
108
|
-
|
|
109
|
-
// If ezstandalone already exists, wrap now.
|
|
110
|
-
if (window.ezstandalone && window.ezstandalone.showAds) {
|
|
111
|
-
window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
|
|
112
|
-
}
|
|
197
|
+
// ---------- config & pools ----------
|
|
113
198
|
|
|
114
|
-
|
|
199
|
+
async function fetchConfigOnce() {
|
|
200
|
+
if (state.cfg) return state.cfg;
|
|
115
201
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
202
|
+
const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
|
|
203
|
+
if (!res.ok) return null;
|
|
204
|
+
state.cfg = await res.json();
|
|
205
|
+
return state.cfg;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
118
210
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
211
|
+
function initPools(cfg) {
|
|
212
|
+
if (!cfg) return;
|
|
213
|
+
if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
|
|
214
|
+
if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
|
|
215
|
+
if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
|
|
216
|
+
}
|
|
122
217
|
|
|
123
|
-
|
|
124
|
-
Object.defineProperty(ez, 'showAds', {
|
|
125
|
-
configurable: true,
|
|
126
|
-
enumerable: true,
|
|
127
|
-
get() { return _showAds; },
|
|
128
|
-
set(v) { _showAds = wrapShowAds(v); }
|
|
129
|
-
});
|
|
218
|
+
// ---------- insertion primitives ----------
|
|
130
219
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
220
|
+
function isAdjacentAd(target) {
|
|
221
|
+
if (!target) return false;
|
|
222
|
+
const next = target.nextElementSibling;
|
|
223
|
+
if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
|
|
224
|
+
const prev = target.previousElementSibling;
|
|
225
|
+
if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildWrap(target, id, kindClass, afterPos) {
|
|
230
|
+
const tag = (target && target.tagName === 'LI') ? 'li' : 'div';
|
|
231
|
+
const wrap = document.createElement(tag);
|
|
232
|
+
if (tag === 'li') {
|
|
233
|
+
wrap.style.listStyle = 'none';
|
|
234
|
+
// preserve common NodeBB list styling
|
|
235
|
+
if (target && target.classList && target.classList.contains('list-group-item')) wrap.classList.add('list-group-item');
|
|
236
|
+
}
|
|
237
|
+
wrap.className = `${WRAP_CLASS} ${kindClass}`;
|
|
238
|
+
if (wrap.tagName === 'LI') {
|
|
239
|
+
wrap.setAttribute('role', 'presentation');
|
|
240
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
135
241
|
}
|
|
242
|
+
wrap.setAttribute('data-ezoic-after', String(afterPos));
|
|
243
|
+
wrap.style.width = '100%';
|
|
244
|
+
|
|
245
|
+
const ph = document.createElement('div');
|
|
246
|
+
ph.id = `${PLACEHOLDER_PREFIX}${id}`;
|
|
247
|
+
ph.setAttribute('data-ezoic-id', String(id));
|
|
248
|
+
wrap.appendChild(ph);
|
|
249
|
+
|
|
250
|
+
return wrap;
|
|
136
251
|
}
|
|
137
252
|
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
const tokenAtSchedule = navToken;
|
|
141
|
-
window.ezstandalone = window.ezstandalone || {};
|
|
142
|
-
window.ezstandalone.cmd = window.ezstandalone.cmd || [];
|
|
143
|
-
window.ezstandalone.cmd.push(function () {
|
|
144
|
-
if (tokenAtSchedule !== navToken) return;
|
|
145
|
-
try { fn(); } catch (e) {}
|
|
146
|
-
});
|
|
253
|
+
function findWrap(kindClass, afterPos) {
|
|
254
|
+
return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
|
|
147
255
|
}
|
|
148
256
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (cfgPromise) return cfgPromise;
|
|
154
|
-
|
|
155
|
-
cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
|
|
156
|
-
.then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
|
|
157
|
-
.then(data => {
|
|
158
|
-
cfg = data;
|
|
159
|
-
// Pre-create placeholders in pool so Ezoic never complains even before first injection
|
|
160
|
-
const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
|
|
161
|
-
ids.forEach(id => Pool.ensurePlaceholder(id));
|
|
162
|
-
return cfg;
|
|
163
|
-
})
|
|
164
|
-
.catch(() => {
|
|
165
|
-
cfg = null;
|
|
166
|
-
return null;
|
|
167
|
-
});
|
|
257
|
+
function insertAfter(target, id, kindClass, afterPos) {
|
|
258
|
+
if (!target || !target.insertAdjacentElement) return null;
|
|
259
|
+
if (findWrap(kindClass, afterPos)) return null;
|
|
260
|
+
if (insertingIds.has(id)) return null;
|
|
168
261
|
|
|
169
|
-
|
|
262
|
+
const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
263
|
+
if (existingPh && existingPh.isConnected) return null;
|
|
264
|
+
|
|
265
|
+
insertingIds.add(id);
|
|
266
|
+
try {
|
|
267
|
+
const wrap = buildWrap(target, id, kindClass, afterPos);
|
|
268
|
+
target.insertAdjacentElement('afterend', wrap);
|
|
269
|
+
return wrap;
|
|
270
|
+
} finally {
|
|
271
|
+
insertingIds.delete(id);
|
|
272
|
+
}
|
|
170
273
|
}
|
|
171
274
|
|
|
172
|
-
function
|
|
173
|
-
return
|
|
174
|
-
.split(',')
|
|
175
|
-
.map(x => x.trim())
|
|
176
|
-
.filter(Boolean);
|
|
275
|
+
function pickId(pool) {
|
|
276
|
+
return pool.length ? pool.shift() : null;
|
|
177
277
|
}
|
|
178
278
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// -------------------------
|
|
182
|
-
const Ad = {
|
|
183
|
-
// throttle showAds per placeholder id
|
|
184
|
-
lastShowAt: new Map(),
|
|
185
|
-
minShowIntervalMs: 1500,
|
|
279
|
+
function showAd(id) {
|
|
280
|
+
if (!id || EZOIC_BLOCKED) return;
|
|
186
281
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Bookkeeping
|
|
192
|
-
insertedKeys: new Set(), // avoid duplicate injection on re-renders
|
|
193
|
-
|
|
194
|
-
initObservers() {
|
|
195
|
-
if (!this.io) {
|
|
196
|
-
this.io = new IntersectionObserver((entries) => {
|
|
197
|
-
for (const e of entries) {
|
|
198
|
-
if (!e.isIntersecting) continue;
|
|
199
|
-
const id = e.target && e.target.getAttribute('data-ezoic-id');
|
|
200
|
-
if (id) this.requestShow(id);
|
|
201
|
-
}
|
|
202
|
-
}, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
|
|
203
|
-
}
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const last = state.lastShowById.get(id) || 0;
|
|
284
|
+
if (now - last < 1500) return; // basic throttle
|
|
204
285
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const root = document.querySelector('#content, #panel, main, body');
|
|
208
|
-
if (root) this.mo.observe(root, { childList: true, subtree: true });
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
|
|
212
|
-
disconnectObservers() {
|
|
213
|
-
try { this.io && this.io.disconnect(); } catch (e) {}
|
|
214
|
-
try { this.mo && this.mo.disconnect(); } catch (e) {}
|
|
215
|
-
this.io = null;
|
|
216
|
-
this.mo = null;
|
|
217
|
-
},
|
|
218
|
-
|
|
219
|
-
scheduleScan() {
|
|
220
|
-
if (rafScheduled) return;
|
|
221
|
-
rafScheduled = true;
|
|
222
|
-
requestAnimationFrame(() => {
|
|
223
|
-
rafScheduled = false;
|
|
224
|
-
this.scanAndInject();
|
|
225
|
-
});
|
|
226
|
-
},
|
|
227
|
-
|
|
228
|
-
async scanAndInject() {
|
|
229
|
-
const c = await prefetchConfig();
|
|
230
|
-
if (!c) return;
|
|
231
|
-
|
|
232
|
-
if (c.excluded) return; // HARD stop: never inject for excluded users
|
|
233
|
-
|
|
234
|
-
installShowAdsHook();
|
|
235
|
-
this.initObservers();
|
|
236
|
-
|
|
237
|
-
if (c.enableBetweenAds) this.injectBetweenTopics(c);
|
|
238
|
-
// Extend: categories ads if you use them
|
|
239
|
-
},
|
|
240
|
-
|
|
241
|
-
// NodeBB topic list injection (between li rows)
|
|
242
|
-
injectBetweenTopics(c) {
|
|
243
|
-
// NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
|
|
244
|
-
// Older themes may use .topic-list
|
|
245
|
-
const container =
|
|
246
|
-
document.querySelector('ul[component="category"].topics-list') ||
|
|
247
|
-
document.querySelector('ul[component="category"].topic-list') ||
|
|
248
|
-
document.querySelector('ul.topics-list[component="category"]') ||
|
|
249
|
-
document.querySelector('ul.topics-list') ||
|
|
250
|
-
document.querySelector('.category ul.topics-list') ||
|
|
251
|
-
document.querySelector('.category .topic-list') ||
|
|
252
|
-
document.querySelector('.topics .topic-list') ||
|
|
253
|
-
document.querySelector('.topic-list');
|
|
254
|
-
|
|
255
|
-
if (!container) return;
|
|
256
|
-
|
|
257
|
-
let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
|
|
258
|
-
if (!rows.length) {
|
|
259
|
-
// Fallback for older markups
|
|
260
|
-
rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
|
|
261
|
-
}
|
|
262
|
-
if (!rows.length) return;
|
|
286
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
287
|
+
if (!ph || !ph.isConnected) return;
|
|
263
288
|
|
|
264
|
-
|
|
265
|
-
if (!ids.length) return;
|
|
289
|
+
state.lastShowById.set(id, now);
|
|
266
290
|
|
|
267
|
-
|
|
291
|
+
try {
|
|
292
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
293
|
+
const ez = window.ezstandalone;
|
|
268
294
|
|
|
269
|
-
//
|
|
270
|
-
if (
|
|
271
|
-
|
|
295
|
+
// Fast path
|
|
296
|
+
if (typeof ez.showAds === 'function') {
|
|
297
|
+
ez.showAds(id);
|
|
298
|
+
sessionDefinedIds.add(id);
|
|
299
|
+
return;
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
302
|
+
// Queue once for when Ezoic is ready
|
|
303
|
+
ez.cmd = ez.cmd || [];
|
|
304
|
+
if (!ph.__ezoicQueued) {
|
|
305
|
+
ph.__ezoicQueued = true;
|
|
306
|
+
ez.cmd.push(() => {
|
|
307
|
+
try {
|
|
308
|
+
if (EZOIC_BLOCKED) return;
|
|
309
|
+
const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
310
|
+
if (!el || !el.isConnected) return;
|
|
311
|
+
window.ezstandalone.showAds(id);
|
|
312
|
+
sessionDefinedIds.add(id);
|
|
313
|
+
} catch (e) {}
|
|
314
|
+
});
|
|
279
315
|
}
|
|
280
|
-
}
|
|
316
|
+
} catch (e) {}
|
|
317
|
+
}
|
|
281
318
|
|
|
282
|
-
|
|
283
|
-
if (!rowEl || !placeholderId || !key) return;
|
|
284
|
-
if (this.insertedKeys.has(key)) return;
|
|
319
|
+
// ---------- preload / above-the-fold ----------
|
|
285
320
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
321
|
+
function ensurePreloadObserver() {
|
|
322
|
+
if (state.io) return state.io;
|
|
323
|
+
try {
|
|
324
|
+
state.io = new IntersectionObserver((entries) => {
|
|
325
|
+
for (const ent of entries) {
|
|
326
|
+
if (!ent.isIntersecting) continue;
|
|
327
|
+
const el = ent.target;
|
|
328
|
+
try { state.io && state.io.unobserve(el); } catch (e) {}
|
|
329
|
+
|
|
330
|
+
const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
|
|
331
|
+
const id = parseInt(idAttr, 10);
|
|
332
|
+
if (Number.isFinite(id) && id > 0) showAd(id);
|
|
333
|
+
}
|
|
334
|
+
}, { root: null, rootMargin: PRELOAD_ROOT_MARGIN, threshold: 0.01 });
|
|
335
|
+
} catch (e) {
|
|
336
|
+
state.io = null;
|
|
337
|
+
}
|
|
338
|
+
return state.io;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function observePlaceholder(id) {
|
|
342
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
343
|
+
if (!ph || !ph.isConnected) return;
|
|
344
|
+
const io = ensurePreloadObserver();
|
|
345
|
+
try { io && io.observe(ph); } catch (e) {}
|
|
346
|
+
|
|
347
|
+
// If already above fold, fire immediately
|
|
348
|
+
try {
|
|
349
|
+
const r = ph.getBoundingClientRect();
|
|
350
|
+
if (r.top < window.innerHeight * 1.5 && r.bottom > -200) showAd(id);
|
|
351
|
+
} catch (e) {}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------- insertion logic ----------
|
|
291
355
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
356
|
+
function computeTargets(count, interval, showFirst) {
|
|
357
|
+
const out = [];
|
|
358
|
+
if (count <= 0) return out;
|
|
359
|
+
if (showFirst) out.push(1);
|
|
360
|
+
for (let i = 1; i <= count; i++) {
|
|
361
|
+
if (i % interval === 0) out.push(i);
|
|
362
|
+
}
|
|
363
|
+
return Array.from(new Set(out)).sort((a, b) => a - b);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
|
|
367
|
+
if (!items.length) return 0;
|
|
368
|
+
|
|
369
|
+
const targets = computeTargets(items.length, interval, showFirst);
|
|
370
|
+
let inserted = 0;
|
|
371
|
+
|
|
372
|
+
for (const afterPos of targets) {
|
|
373
|
+
if (inserted >= MAX_INSERTS_PER_RUN) break;
|
|
297
374
|
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
375
|
+
const el = items[afterPos - 1];
|
|
376
|
+
if (!el || !el.isConnected) continue;
|
|
377
|
+
if (isAdjacentAd(el)) continue;
|
|
378
|
+
if (findWrap(kindClass, afterPos)) continue;
|
|
379
|
+
|
|
380
|
+
const id = pickId(pool);
|
|
381
|
+
if (!id) break;
|
|
382
|
+
|
|
383
|
+
usedSet.add(id);
|
|
384
|
+
const wrap = insertAfter(el, id, kindClass, afterPos);
|
|
385
|
+
if (!wrap) {
|
|
386
|
+
usedSet.delete(id);
|
|
387
|
+
pool.unshift(id);
|
|
388
|
+
continue;
|
|
301
389
|
}
|
|
302
390
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
try { wrapper.appendChild(ph); } catch (e) {}
|
|
391
|
+
observePlaceholder(id);
|
|
392
|
+
inserted += 1;
|
|
393
|
+
}
|
|
307
394
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
this.insertedKeys.add(key);
|
|
395
|
+
return inserted;
|
|
396
|
+
}
|
|
311
397
|
|
|
312
|
-
|
|
313
|
-
|
|
398
|
+
async function insertHeroAdEarly() {
|
|
399
|
+
if (state.heroDoneForPage) return;
|
|
400
|
+
const cfg = await fetchConfigOnce();
|
|
401
|
+
if (!cfg || cfg.excluded) return;
|
|
402
|
+
|
|
403
|
+
initPools(cfg);
|
|
404
|
+
|
|
405
|
+
const kind = getKind();
|
|
406
|
+
let items = [];
|
|
407
|
+
let pool = null;
|
|
408
|
+
let usedSet = null;
|
|
409
|
+
let kindClass = '';
|
|
410
|
+
|
|
411
|
+
if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
|
|
412
|
+
items = getPostContainers();
|
|
413
|
+
pool = state.poolPosts;
|
|
414
|
+
usedSet = state.usedPosts;
|
|
415
|
+
kindClass = 'ezoic-ad-message';
|
|
416
|
+
} else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
|
|
417
|
+
items = getTopicItems();
|
|
418
|
+
pool = state.poolTopics;
|
|
419
|
+
usedSet = state.usedTopics;
|
|
420
|
+
kindClass = 'ezoic-ad-between';
|
|
421
|
+
} else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
|
|
422
|
+
items = getCategoryItems();
|
|
423
|
+
pool = state.poolCategories;
|
|
424
|
+
usedSet = state.usedCategories;
|
|
425
|
+
kindClass = 'ezoic-ad-categories';
|
|
426
|
+
} else {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!items.length) return;
|
|
431
|
+
|
|
432
|
+
// Insert after the very first item (above-the-fold)
|
|
433
|
+
const afterPos = 1;
|
|
434
|
+
const el = items[afterPos - 1];
|
|
435
|
+
if (!el || !el.isConnected) return;
|
|
436
|
+
if (isAdjacentAd(el)) return;
|
|
437
|
+
if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
|
|
438
|
+
|
|
439
|
+
const id = pickId(pool);
|
|
440
|
+
if (!id) return;
|
|
441
|
+
|
|
442
|
+
usedSet.add(id);
|
|
443
|
+
const wrap = insertAfter(el, id, kindClass, afterPos);
|
|
444
|
+
if (!wrap) {
|
|
445
|
+
usedSet.delete(id);
|
|
446
|
+
pool.unshift(id);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
state.heroDoneForPage = true;
|
|
451
|
+
observePlaceholder(id);
|
|
452
|
+
}
|
|
314
453
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
454
|
+
async function runCore() {
|
|
455
|
+
if (EZOIC_BLOCKED) return;
|
|
456
|
+
|
|
457
|
+
patchShowAds();
|
|
458
|
+
|
|
459
|
+
const cfg = await fetchConfigOnce();
|
|
460
|
+
if (!cfg || cfg.excluded) return;
|
|
461
|
+
initPools(cfg);
|
|
462
|
+
|
|
463
|
+
const kind = getKind();
|
|
464
|
+
|
|
465
|
+
if (kind === 'topic') {
|
|
466
|
+
if (normalizeBool(cfg.enableMessageAds)) {
|
|
467
|
+
injectBetween(
|
|
468
|
+
'ezoic-ad-message',
|
|
469
|
+
getPostContainers(),
|
|
470
|
+
Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
|
|
471
|
+
normalizeBool(cfg.showFirstMessageAd),
|
|
472
|
+
state.poolPosts,
|
|
473
|
+
state.usedPosts
|
|
474
|
+
);
|
|
319
475
|
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
ezCmd(doShow);
|
|
342
|
-
} else {
|
|
343
|
-
doShow();
|
|
476
|
+
} else if (kind === 'categoryTopics') {
|
|
477
|
+
if (normalizeBool(cfg.enableBetweenAds)) {
|
|
478
|
+
injectBetween(
|
|
479
|
+
'ezoic-ad-between',
|
|
480
|
+
getTopicItems(),
|
|
481
|
+
Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
|
|
482
|
+
normalizeBool(cfg.showFirstTopicAd),
|
|
483
|
+
state.poolTopics,
|
|
484
|
+
state.usedTopics
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
} else if (kind === 'categories') {
|
|
488
|
+
if (normalizeBool(cfg.enableCategoryAds)) {
|
|
489
|
+
injectBetween(
|
|
490
|
+
'ezoic-ad-categories',
|
|
491
|
+
getCategoryItems(),
|
|
492
|
+
Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
|
|
493
|
+
normalizeBool(cfg.showFirstCategoryAd),
|
|
494
|
+
state.poolCategories,
|
|
495
|
+
state.usedCategories
|
|
496
|
+
);
|
|
344
497
|
}
|
|
345
|
-
},
|
|
346
|
-
|
|
347
|
-
// On navigation, return all placeholders to pool so they still exist
|
|
348
|
-
reclaimPlaceholders() {
|
|
349
|
-
const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
|
|
350
|
-
wrappers.forEach(w => {
|
|
351
|
-
const id = w.getAttribute('data-ezoic-id');
|
|
352
|
-
if (id) Pool.returnToPool(id);
|
|
353
|
-
});
|
|
354
|
-
// Let NodeBB wipe DOM; we do not aggressively remove nodes here.
|
|
355
|
-
this.insertedKeys.clear();
|
|
356
|
-
this.lastShowAt.clear();
|
|
357
498
|
}
|
|
358
|
-
}
|
|
499
|
+
}
|
|
359
500
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
501
|
+
function scheduleRun() {
|
|
502
|
+
if (state.runQueued) return;
|
|
503
|
+
state.runQueued = true;
|
|
504
|
+
window.requestAnimationFrame(() => {
|
|
505
|
+
state.runQueued = false;
|
|
506
|
+
const pk = getPageKey();
|
|
507
|
+
if (state.pageKey && pk !== state.pageKey) return;
|
|
508
|
+
runCore().catch(() => {});
|
|
509
|
+
});
|
|
367
510
|
}
|
|
368
511
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
512
|
+
// ---------- observers / lifecycle ----------
|
|
513
|
+
|
|
514
|
+
function cleanup() {
|
|
515
|
+
EZOIC_BLOCKED = true;
|
|
516
|
+
|
|
517
|
+
// remove all wrappers
|
|
518
|
+
try {
|
|
519
|
+
document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
|
|
520
|
+
try { el.remove(); } catch (e) {}
|
|
521
|
+
});
|
|
522
|
+
} catch (e) {}
|
|
523
|
+
|
|
524
|
+
// reset state
|
|
525
|
+
state.cfg = null;
|
|
526
|
+
state.poolTopics = [];
|
|
527
|
+
state.poolPosts = [];
|
|
528
|
+
state.poolCategories = [];
|
|
529
|
+
state.usedTopics.clear();
|
|
530
|
+
state.usedPosts.clear();
|
|
531
|
+
state.usedCategories.clear();
|
|
532
|
+
state.lastShowById.clear();
|
|
533
|
+
state.heroDoneForPage = false;
|
|
534
|
+
|
|
535
|
+
sessionDefinedIds.clear();
|
|
536
|
+
|
|
537
|
+
// keep observers alive (MutationObserver will re-trigger after navigation)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function ensureDomObserver() {
|
|
541
|
+
if (state.domObs) return;
|
|
542
|
+
state.domObs = new MutationObserver(() => {
|
|
543
|
+
if (!EZOIC_BLOCKED) scheduleRun();
|
|
544
|
+
});
|
|
545
|
+
try {
|
|
546
|
+
state.domObs.observe(document.body, { childList: true, subtree: true });
|
|
547
|
+
} catch (e) {}
|
|
373
548
|
}
|
|
374
549
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
550
|
+
function bindNodeBB() {
|
|
551
|
+
if (!$) return;
|
|
552
|
+
|
|
553
|
+
$(window).off('.ezoicInfinite');
|
|
554
|
+
|
|
555
|
+
$(window).on('action:ajaxify.start.ezoicInfinite', () => {
|
|
556
|
+
cleanup();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
$(window).on('action:ajaxify.end.ezoicInfinite', () => {
|
|
560
|
+
state.pageKey = getPageKey();
|
|
561
|
+
EZOIC_BLOCKED = false;
|
|
562
|
+
|
|
563
|
+
warmUpNetwork();
|
|
564
|
+
patchShowAds();
|
|
565
|
+
ensurePreloadObserver();
|
|
566
|
+
ensureDomObserver();
|
|
567
|
+
|
|
568
|
+
// Ultra-fast above-the-fold first
|
|
569
|
+
insertHeroAdEarly().catch(() => {});
|
|
570
|
+
|
|
571
|
+
// Then normal insertion
|
|
572
|
+
scheduleRun();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Infinite scroll / partial updates
|
|
576
|
+
$(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
|
|
577
|
+
if (EZOIC_BLOCKED) return;
|
|
578
|
+
scheduleRun();
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function bindScroll() {
|
|
583
|
+
let ticking = false;
|
|
584
|
+
window.addEventListener('scroll', () => {
|
|
585
|
+
if (ticking) return;
|
|
586
|
+
ticking = true;
|
|
587
|
+
window.requestAnimationFrame(() => {
|
|
588
|
+
ticking = false;
|
|
589
|
+
if (!EZOIC_BLOCKED) scheduleRun();
|
|
590
|
+
});
|
|
591
|
+
}, { passive: true });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------- boot ----------
|
|
595
|
+
|
|
596
|
+
state.pageKey = getPageKey();
|
|
597
|
+
warmUpNetwork();
|
|
598
|
+
patchShowAds();
|
|
599
|
+
ensurePreloadObserver();
|
|
600
|
+
ensureDomObserver();
|
|
378
601
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
installShowAdsHook();
|
|
382
|
-
prefetchConfig();
|
|
383
|
-
Ad.scheduleScan();
|
|
384
|
-
});
|
|
602
|
+
bindNodeBB();
|
|
603
|
+
bindScroll();
|
|
385
604
|
|
|
605
|
+
// First paint: try hero + run
|
|
606
|
+
EZOIC_BLOCKED = false;
|
|
607
|
+
insertHeroAdEarly().catch(() => {});
|
|
608
|
+
scheduleRun();
|
|
386
609
|
})();
|
package/public/style.css
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
.ezoic-ad
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
|
|
2
|
+
.ezoic-ad {
|
|
3
|
+
display: block;
|
|
4
|
+
width: 100%;
|
|
5
|
+
margin: 0 !important;
|
|
6
|
+
padding: 0 !important;
|
|
7
|
+
overflow: hidden;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
.ezoic-ad-
|
|
10
|
+
.ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
|
|
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
|
+
}
|