nodebb-plugin-ezoic-infinite 1.6.97 → 1.6.99
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 +109 -45
- package/package.json +1 -1
- package/public/client.js +789 -101
- package/public/style.css +79 -27
package/library.js
CHANGED
|
@@ -7,60 +7,124 @@ const db = require.main.require('./src/database');
|
|
|
7
7
|
const SETTINGS_KEY = 'ezoic-infinite';
|
|
8
8
|
const plugin = {};
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
function normalizeExcludedGroups(value) {
|
|
11
|
+
if (!value) return [];
|
|
12
|
+
if (Array.isArray(value)) return value;
|
|
13
|
+
return String(value).split(',').map(s => s.trim()).filter(Boolean);
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
function parseBool(v, def = false) {
|
|
17
|
+
if (v === undefined || v === null || v === '') return def;
|
|
18
|
+
if (typeof v === 'boolean') return v;
|
|
19
|
+
const s = String(v).toLowerCase();
|
|
20
|
+
return s === '1' || s === 'true' || s === 'on' || s === 'yes';
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
async function getAllGroups() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
let names = await db.getSortedSetRange('groups:createtime', 0, -1);
|
|
25
|
+
if (!names || !names.length) {
|
|
26
|
+
names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
|
|
27
|
+
}
|
|
28
|
+
const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
|
|
29
|
+
const data = await groups.getGroupsData(filtered);
|
|
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;
|
|
25
34
|
}
|
|
35
|
+
let _settingsCache = null;
|
|
36
|
+
let _settingsCacheAt = 0;
|
|
37
|
+
const SETTINGS_TTL = 30000; // 30s
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
39
|
+
async function getSettings() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
|
|
42
|
+
const s = await meta.settings.get(SETTINGS_KEY);
|
|
43
|
+
_settingsCacheAt = Date.now();
|
|
44
|
+
_settingsCache = {
|
|
45
|
+
// Between-post ads (simple blocks) in category topic list
|
|
46
|
+
enableBetweenAds: parseBool(s.enableBetweenAds, true),
|
|
47
|
+
showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
|
|
48
|
+
placeholderIds: (s.placeholderIds || '').trim(),
|
|
49
|
+
intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
|
|
50
|
+
|
|
51
|
+
// Home/categories list ads (between categories on / or /categories)
|
|
52
|
+
enableCategoryAds: parseBool(s.enableCategoryAds, false),
|
|
53
|
+
showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
|
|
54
|
+
categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
|
|
55
|
+
intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
|
|
56
|
+
|
|
57
|
+
// "Ad message" between replies (looks like a post)
|
|
58
|
+
enableMessageAds: parseBool(s.enableMessageAds, false),
|
|
59
|
+
showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
|
|
60
|
+
messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
|
|
61
|
+
messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
|
|
62
|
+
|
|
63
|
+
excludedGroups: normalizeExcludedGroups(s.excludedGroups),
|
|
64
|
+
};
|
|
65
|
+
return _settingsCache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function isUserExcluded(uid, excludedGroups) {
|
|
69
|
+
if (!uid || !excludedGroups.length) return false;
|
|
70
|
+
const userGroups = await groups.getUserGroups([uid]);
|
|
71
|
+
return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
|
|
72
|
+
}
|
|
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
|
+
}
|
|
59
79
|
};
|
|
60
80
|
|
|
61
81
|
plugin.addAdminNavigation = async (header) => {
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
header.plugins = header.plugins || [];
|
|
83
|
+
header.plugins.push({
|
|
84
|
+
route: '/plugins/ezoic-infinite',
|
|
85
|
+
icon: 'fa-ad',
|
|
86
|
+
name: 'Ezoic Infinite Ads'
|
|
87
|
+
});
|
|
88
|
+
return header;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
plugin.init = async ({ router, middleware }) => {
|
|
92
|
+
async function render(req, res) {
|
|
93
|
+
const settings = await getSettings();
|
|
94
|
+
const allGroups = await getAllGroups();
|
|
95
|
+
|
|
96
|
+
res.render('admin/plugins/ezoic-infinite', {
|
|
97
|
+
title: 'Ezoic Infinite Ads',
|
|
98
|
+
...settings,
|
|
99
|
+
enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
|
|
100
|
+
enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
|
|
101
|
+
allGroups,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
|
|
106
|
+
router.get('/api/admin/plugins/ezoic-infinite', render);
|
|
107
|
+
|
|
108
|
+
router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
|
|
109
|
+
const settings = await getSettings();
|
|
110
|
+
const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
|
|
111
|
+
|
|
112
|
+
res.json({
|
|
113
|
+
excluded,
|
|
114
|
+
enableBetweenAds: settings.enableBetweenAds,
|
|
115
|
+
showFirstTopicAd: settings.showFirstTopicAd,
|
|
116
|
+
placeholderIds: settings.placeholderIds,
|
|
117
|
+
intervalPosts: settings.intervalPosts,
|
|
118
|
+
enableCategoryAds: settings.enableCategoryAds,
|
|
119
|
+
showFirstCategoryAd: settings.showFirstCategoryAd,
|
|
120
|
+
categoryPlaceholderIds: settings.categoryPlaceholderIds,
|
|
121
|
+
intervalCategories: settings.intervalCategories,
|
|
122
|
+
enableMessageAds: settings.enableMessageAds,
|
|
123
|
+
showFirstMessageAd: settings.showFirstMessageAd,
|
|
124
|
+
messagePlaceholderIds: settings.messagePlaceholderIds,
|
|
125
|
+
messageIntervalPosts: settings.messageIntervalPosts,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
64
128
|
};
|
|
65
129
|
|
|
66
|
-
module.exports = plugin;
|
|
130
|
+
module.exports = plugin;
|
package/package.json
CHANGED
package/public/client.js
CHANGED
|
@@ -1,135 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — client.js (v18)
|
|
3
|
+
*
|
|
4
|
+
* Corrections majeures vs v17 :
|
|
5
|
+
* 1. ANCRAGE PAR pid/tid (data-pid, data-index) au lieu d'ordinalMap fragile.
|
|
6
|
+
* → Le même post garde toujours le même ID de wrap, quelle que soit la virtualisation.
|
|
7
|
+
* 2. CLEANUP COMPLET à chaque navigation ajaxify (wraps + curseurs + état pools).
|
|
8
|
+
* → Supprime les fantômes qui se réaffichaient après scroll up/down.
|
|
9
|
+
* 3. DEDUPLICATION par ancrage : on vérifie `data-ezoic-anchor` avant d'insérer.
|
|
10
|
+
* → Empêche la création de doublons lors de multiples passes DOM.
|
|
11
|
+
* 4. PAS de recyclage de wraps (moveWrapAfter supprimé).
|
|
12
|
+
* → La cause n°1 des pubs "qui sautent n'importe où".
|
|
13
|
+
* 5. pruneOrphanWraps simplifié : suppression réelle (remove) au lieu de hide.
|
|
14
|
+
* → Un wrap orphelin = mort, pas caché. Libère l'id dans le curseur.
|
|
15
|
+
* 6. Déduplication de l'id Ezoic par wrap : un id ne peut être monté qu'une fois.
|
|
16
|
+
* 7. V17 pile-fix SUPPRIMÉ (was conflicting with main logic).
|
|
17
|
+
* 8. Factorisation : helpers partagés, pas de duplication de logique entre kinds.
|
|
18
|
+
*/
|
|
1
19
|
(function () {
|
|
2
20
|
'use strict';
|
|
3
21
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
23
|
+
const WRAP_CLASS = 'nodebb-ezoic-wrap';
|
|
24
|
+
const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
|
|
25
|
+
const ANCHOR_ATTR = 'data-ezoic-anchor'; // unique key = kind:anchorId
|
|
26
|
+
const WRAPID_ATTR = 'data-ezoic-wrapid';
|
|
27
|
+
const CREATED_ATTR = 'data-ezoic-created';
|
|
28
|
+
|
|
29
|
+
const MAX_INSERTS_PER_RUN = 6;
|
|
30
|
+
const EMPTY_WRAP_TTL_MS = 90_000; // 90 s avant de colapser un wrap vide
|
|
31
|
+
const FILL_WATCH_MS = 7_000;
|
|
32
|
+
|
|
33
|
+
const PRELOAD_MARGIN = {
|
|
34
|
+
desktop: '2000px 0px 2000px 0px',
|
|
35
|
+
mobile: '3000px 0px 3000px 0px',
|
|
36
|
+
desktopBoosted:'4500px 0px 4500px 0px',
|
|
37
|
+
mobileBoosted: '4500px 0px 4500px 0px',
|
|
38
|
+
};
|
|
39
|
+
const BOOST_DURATION_MS = 2500;
|
|
40
|
+
const BOOST_SPEED_PX_PER_MS = 2.2;
|
|
41
|
+
const MAX_INFLIGHT_DESKTOP = 4;
|
|
42
|
+
const MAX_INFLIGHT_MOBILE = 3;
|
|
43
|
+
const SHOW_THROTTLE_MS = 900;
|
|
44
|
+
|
|
45
|
+
const SELECTORS = {
|
|
46
|
+
topicItem: 'li[component="category/topic"]',
|
|
47
|
+
postItem: '[component="post"][data-pid]',
|
|
48
|
+
categoryItem: 'li[component="categories/category"]',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
52
|
+
const now = () => Date.now();
|
|
53
|
+
const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
|
|
54
|
+
const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
|
|
55
|
+
|
|
56
|
+
function uniqInts(raw) {
|
|
57
|
+
const out = [], seen = new Set();
|
|
58
|
+
for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
|
|
59
|
+
const n = parseInt(v, 10);
|
|
60
|
+
if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isFilledNode(node) {
|
|
66
|
+
return !!(node?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── State ────────────────────────────────────────────────────────────────
|
|
70
|
+
const state = {
|
|
71
|
+
pageKey: null,
|
|
72
|
+
cfg: null,
|
|
73
|
+
|
|
74
|
+
// ID pools + curseurs rotatifs
|
|
75
|
+
pools: { topics: [], posts: [], categories: [] },
|
|
76
|
+
cursors: { topics: 0, posts: 0, categories: 0 },
|
|
77
|
+
|
|
78
|
+
// Suivi des IDs Ezoic actuellement montés dans le DOM (évite les doublons)
|
|
79
|
+
mountedIds: new Set(),
|
|
80
|
+
|
|
81
|
+
// Throttle par id
|
|
82
|
+
lastShowById: new Map(),
|
|
83
|
+
|
|
84
|
+
// Observers
|
|
85
|
+
domObs: null,
|
|
86
|
+
io: null,
|
|
87
|
+
ioMargin: null,
|
|
88
|
+
|
|
89
|
+
// Guard contre nos propres mutations
|
|
90
|
+
internalMutation: 0,
|
|
91
|
+
|
|
92
|
+
// File de show
|
|
93
|
+
inflight: 0,
|
|
94
|
+
pending: [],
|
|
95
|
+
pendingSet: new Set(),
|
|
96
|
+
|
|
97
|
+
// Scroll boost
|
|
98
|
+
scrollBoostUntil: 0,
|
|
99
|
+
lastScrollY: 0,
|
|
100
|
+
lastScrollTs: 0,
|
|
101
|
+
|
|
102
|
+
// Scheduler
|
|
103
|
+
runQueued: false,
|
|
104
|
+
burstActive: false,
|
|
105
|
+
burstDeadline: 0,
|
|
106
|
+
burstCount: 0,
|
|
107
|
+
lastBurstReqTs: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let blockedUntil = 0;
|
|
111
|
+
const isBlocked = () => now() < blockedUntil;
|
|
112
|
+
const isBoosted = () => now() < state.scrollBoostUntil;
|
|
113
|
+
|
|
114
|
+
function withInternalMutation(fn) {
|
|
115
|
+
state.internalMutation++;
|
|
116
|
+
try { fn(); } finally { state.internalMutation--; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Config ───────────────────────────────────────────────────────────────
|
|
120
|
+
async function fetchConfig() {
|
|
121
|
+
if (state.cfg) return state.cfg;
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
|
|
124
|
+
if (!res.ok) return null;
|
|
125
|
+
state.cfg = await res.json();
|
|
126
|
+
} catch (_) { state.cfg = null; }
|
|
127
|
+
return state.cfg;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function initPools(cfg) {
|
|
131
|
+
if (!cfg) return;
|
|
132
|
+
state.pools.topics = uniqInts(cfg.placeholderIds || '');
|
|
133
|
+
state.pools.posts = uniqInts(cfg.messagePlaceholderIds || '');
|
|
134
|
+
state.pools.categories = uniqInts(cfg.categoryPlaceholderIds || '');
|
|
135
|
+
// Ne pas réinitialiser les curseurs ici (ils sont remis à 0 dans cleanup).
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Page / Kind ──────────────────────────────────────────────────────────
|
|
139
|
+
function getPageKey() {
|
|
140
|
+
try {
|
|
141
|
+
const ax = window.ajaxify?.data;
|
|
142
|
+
if (ax?.tid) return `topic:${ax.tid}`;
|
|
143
|
+
if (ax?.cid) return `cid:${ax.cid}:${location.pathname}`;
|
|
144
|
+
} catch (_) {}
|
|
145
|
+
return location.pathname;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getKind() {
|
|
149
|
+
const p = location.pathname;
|
|
150
|
+
if (/^\/topic\//.test(p)) return 'topic';
|
|
151
|
+
if (/^\/category\//.test(p)) return 'categoryTopics';
|
|
152
|
+
if (p === '/' || /^\/categories/.test(p)) return 'categories';
|
|
153
|
+
if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
|
|
154
|
+
if (document.querySelector(SELECTORS.postItem)) return 'topic';
|
|
155
|
+
if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
|
|
156
|
+
return 'other';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── DOM helpers ──────────────────────────────────────────────────────────
|
|
160
|
+
function getPostContainers() {
|
|
161
|
+
return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter(el => {
|
|
162
|
+
if (!el.isConnected) return false;
|
|
163
|
+
if (!el.querySelector('[component="post/content"]')) return false;
|
|
164
|
+
const parentPost = el.parentElement?.closest('[component="post"][data-pid]');
|
|
165
|
+
if (parentPost && parentPost !== el) return false;
|
|
166
|
+
if (el.getAttribute('component') === 'post/parent') return false;
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
|
|
172
|
+
function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Calcule l'ancre stable d'un élément : on préfère data-pid (posts) puis
|
|
176
|
+
* data-index, sinon on retombe sur l'index dans le tableau.
|
|
177
|
+
* La clé est préfixée par kindClass pour éviter les collisions.
|
|
178
|
+
*/
|
|
179
|
+
function getAnchorKey(kindClass, el, fallbackIndex) {
|
|
180
|
+
const pid = el.getAttribute('data-pid');
|
|
181
|
+
const index = el.getAttribute('data-index') ?? el.getAttribute('data-idx');
|
|
182
|
+
const id = pid ?? index ?? String(fallbackIndex);
|
|
183
|
+
return `${kindClass}:${id}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findWrapByAnchor(anchorKey) {
|
|
187
|
+
return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${CSS.escape(anchorKey)}"]`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function hasAdjacentWrap(el) {
|
|
191
|
+
const next = el.nextElementSibling;
|
|
192
|
+
if (next?.classList?.contains(WRAP_CLASS)) return true;
|
|
193
|
+
const prev = el.previousElementSibling;
|
|
194
|
+
if (prev?.classList?.contains(WRAP_CLASS)) return true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── ID pool / rotation ───────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Retourne le prochain id disponible du pool (non déjà monté dans le DOM),
|
|
201
|
+
* en avançant le curseur rotatif.
|
|
202
|
+
*/
|
|
203
|
+
function pickId(poolKey) {
|
|
204
|
+
const pool = state.pools[poolKey];
|
|
205
|
+
const n = pool.length;
|
|
206
|
+
if (!n) return null;
|
|
207
|
+
|
|
208
|
+
for (let tries = 0; tries < n; tries++) {
|
|
209
|
+
const idx = state.cursors[poolKey] % n;
|
|
210
|
+
state.cursors[poolKey] = (state.cursors[poolKey] + 1) % n;
|
|
211
|
+
const id = pool[idx];
|
|
212
|
+
if (!state.mountedIds.has(id)) return id;
|
|
20
213
|
}
|
|
21
|
-
return
|
|
214
|
+
return null; // Tous les IDs déjà montés
|
|
22
215
|
}
|
|
23
216
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
217
|
+
// ─── Wrap construction ────────────────────────────────────────────────────
|
|
218
|
+
function buildWrap(id, kindClass, anchorKey) {
|
|
219
|
+
const wrap = document.createElement('div');
|
|
220
|
+
wrap.className = `${WRAP_CLASS} ${kindClass}`;
|
|
221
|
+
wrap.setAttribute(ANCHOR_ATTR, anchorKey);
|
|
222
|
+
wrap.setAttribute(WRAPID_ATTR, String(id));
|
|
223
|
+
wrap.setAttribute(CREATED_ATTR, String(now()));
|
|
224
|
+
wrap.style.cssText = 'width:100%;display:block;';
|
|
28
225
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
226
|
+
const ph = document.createElement('div');
|
|
227
|
+
ph.id = `${PLACEHOLDER_PREFIX}${id}`;
|
|
228
|
+
ph.setAttribute('data-ezoic-id', String(id));
|
|
229
|
+
wrap.appendChild(ph);
|
|
230
|
+
|
|
231
|
+
return wrap;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function insertWrapAfter(el, id, kindClass, anchorKey) {
|
|
235
|
+
if (!el?.insertAdjacentElement) return null;
|
|
236
|
+
if (findWrapByAnchor(anchorKey)) return null; // déjà présent
|
|
237
|
+
if (state.mountedIds.has(id)) return null; // id déjà monté
|
|
238
|
+
|
|
239
|
+
const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
240
|
+
if (existingPh?.isConnected) {
|
|
241
|
+
// Cet id a déjà un placeholder dans le DOM → on ne peut pas le dupliquer
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const wrap = buildWrap(id, kindClass, anchorKey);
|
|
246
|
+
withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
|
|
247
|
+
state.mountedIds.add(id);
|
|
248
|
+
return wrap;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function removeWrap(wrap) {
|
|
252
|
+
try {
|
|
253
|
+
const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
|
|
254
|
+
if (Number.isFinite(id)) state.mountedIds.delete(id);
|
|
255
|
+
wrap.remove();
|
|
256
|
+
} catch (_) {}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Prune & Decluster ────────────────────────────────────────────────────
|
|
260
|
+
/**
|
|
261
|
+
* Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
|
|
262
|
+
* On supprime proprement (remove) plutôt que de cacher → libère l'id.
|
|
263
|
+
*/
|
|
264
|
+
function pruneOrphans(kindClass) {
|
|
265
|
+
const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
|
|
266
|
+
let removed = 0;
|
|
267
|
+
|
|
268
|
+
wraps.forEach(wrap => {
|
|
269
|
+
// Ne jamais pruner un wrap tout juste créé (fill lent côté Ezoic)
|
|
270
|
+
const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
|
|
271
|
+
if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
|
|
272
|
+
|
|
273
|
+
const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
|
|
274
|
+
if (!anchorKey) {
|
|
275
|
+
withInternalMutation(() => removeWrap(wrap));
|
|
276
|
+
removed++;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// L'ancre existe-t-elle encore dans le DOM ?
|
|
281
|
+
const [, anchorId] = anchorKey.split(':');
|
|
282
|
+
const isPost = kindClass === 'ezoic-ad-message';
|
|
283
|
+
let anchorEl = null;
|
|
284
|
+
|
|
285
|
+
if (isPost) {
|
|
286
|
+
anchorEl = document.querySelector(`[component="post"][data-pid="${CSS.escape(anchorId)}"]`);
|
|
34
287
|
} else {
|
|
35
|
-
|
|
288
|
+
anchorEl = document.querySelector(`${SELECTORS.topicItem}[data-index="${CSS.escape(anchorId)}"]`)
|
|
289
|
+
?? document.querySelector(`${SELECTORS.categoryItem}[data-index="${CSS.escape(anchorId)}"]`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!anchorEl || !anchorEl.isConnected) {
|
|
293
|
+
// Ancre disparue → si rempli on garde (scrolling back), si vide on supprime
|
|
294
|
+
if (!isFilledNode(wrap)) {
|
|
295
|
+
withInternalMutation(() => removeWrap(wrap));
|
|
296
|
+
removed++;
|
|
297
|
+
}
|
|
36
298
|
}
|
|
37
|
-
activeIds.add(pid);
|
|
38
299
|
});
|
|
300
|
+
|
|
301
|
+
return removed;
|
|
39
302
|
}
|
|
40
303
|
|
|
41
|
-
|
|
42
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Si deux wraps se retrouvent consécutifs (sans item entre eux),
|
|
306
|
+
* on supprime le plus récent des deux s'il est vide.
|
|
307
|
+
*/
|
|
308
|
+
function decluster(kindClass) {
|
|
309
|
+
const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
|
|
310
|
+
let removed = 0;
|
|
43
311
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
312
|
+
for (const wrap of wraps) {
|
|
313
|
+
let prev = wrap.previousElementSibling;
|
|
314
|
+
let steps = 0;
|
|
315
|
+
while (prev && steps < 3) {
|
|
316
|
+
if (prev.classList?.contains(WRAP_CLASS)) {
|
|
317
|
+
// Deux wraps consécutifs : supprimer le plus vide/récent
|
|
318
|
+
const wCreated = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
|
|
319
|
+
const pCreated = parseInt(prev.getAttribute(CREATED_ATTR) || '0', 10);
|
|
320
|
+
const wFilled = isFilledNode(wrap);
|
|
321
|
+
const pFilled = isFilledNode(prev);
|
|
47
322
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
323
|
+
if (!wFilled) {
|
|
324
|
+
withInternalMutation(() => removeWrap(wrap));
|
|
325
|
+
removed++;
|
|
326
|
+
} else if (!pFilled) {
|
|
327
|
+
withInternalMutation(() => removeWrap(prev));
|
|
328
|
+
removed++;
|
|
329
|
+
}
|
|
330
|
+
// Si les deux sont remplis, laisser en place
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
prev = prev.previousElementSibling;
|
|
334
|
+
steps++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return removed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Injection ────────────────────────────────────────────────────────────
|
|
342
|
+
function computeTargetIndices(count, interval, showFirst) {
|
|
343
|
+
const targets = new Set();
|
|
344
|
+
if (showFirst && count > 0) targets.add(0);
|
|
345
|
+
for (let i = interval - 1; i < count; i += interval) targets.add(i);
|
|
346
|
+
return [...targets].sort((a, b) => a - b);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function injectBetween(kindClass, items, interval, showFirst, poolKey) {
|
|
350
|
+
if (!items.length) return 0;
|
|
351
|
+
|
|
352
|
+
const targets = computeTargetIndices(items.length, interval, showFirst);
|
|
353
|
+
const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
|
|
354
|
+
let inserted = 0;
|
|
355
|
+
|
|
356
|
+
for (const idx of targets) {
|
|
357
|
+
if (inserted >= maxIns) break;
|
|
358
|
+
|
|
359
|
+
const el = items[idx];
|
|
360
|
+
if (!el?.isConnected) continue;
|
|
361
|
+
if (hasAdjacentWrap(el)) continue;
|
|
362
|
+
|
|
363
|
+
const anchorKey = getAnchorKey(kindClass, el, idx);
|
|
364
|
+
if (findWrapByAnchor(anchorKey)) continue; // déjà là
|
|
51
365
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
366
|
+
const id = pickId(poolKey);
|
|
367
|
+
if (!id) break; // pool épuisé pour cette passe
|
|
368
|
+
|
|
369
|
+
const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
|
|
370
|
+
if (!wrap) continue;
|
|
371
|
+
|
|
372
|
+
observePlaceholder(id);
|
|
373
|
+
inserted++;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return inserted;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── Preload / Show ───────────────────────────────────────────────────────
|
|
380
|
+
function getPreloadMargin() {
|
|
381
|
+
const m = isMobile() ? 'mobile' : 'desktop';
|
|
382
|
+
return PRELOAD_MARGIN[m + (isBoosted() ? 'Boosted' : '')];
|
|
55
383
|
}
|
|
56
384
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
385
|
+
function getMaxInflight() {
|
|
386
|
+
return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
|
|
387
|
+
}
|
|
60
388
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const next = item.nextElementSibling;
|
|
389
|
+
function ensurePreloadObserver() {
|
|
390
|
+
const margin = getPreloadMargin();
|
|
391
|
+
if (state.io && state.ioMargin === margin) return state.io;
|
|
65
392
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (available) {
|
|
69
|
-
isInternalChange = true;
|
|
70
|
-
|
|
71
|
-
// Création d'un conteneur qui respecte le type de parent (li ou div)
|
|
72
|
-
const wrapper = document.createElement(wrapperTag);
|
|
73
|
-
wrapper.className = WRAP_CLASS + ' ezoic-added-content';
|
|
74
|
-
wrapper.setAttribute('data-kind', kind);
|
|
75
|
-
wrapper.setAttribute('data-placeholder-id', available.getAttribute('data-placeholder-id'));
|
|
76
|
-
wrapper.innerHTML = available.innerHTML;
|
|
393
|
+
state.io?.disconnect();
|
|
394
|
+
state.io = null;
|
|
77
395
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
396
|
+
try {
|
|
397
|
+
state.io = new IntersectionObserver(entries => {
|
|
398
|
+
for (const ent of entries) {
|
|
399
|
+
if (!ent.isIntersecting) continue;
|
|
400
|
+
state.io?.unobserve(ent.target);
|
|
401
|
+
const id = parseInt(ent.target.getAttribute('data-ezoic-id'), 10);
|
|
402
|
+
if (Number.isFinite(id) && id > 0) enqueueShow(id);
|
|
82
403
|
}
|
|
404
|
+
}, { root: null, rootMargin: margin, threshold: 0 });
|
|
405
|
+
state.ioMargin = margin;
|
|
406
|
+
} catch (_) { state.io = null; state.ioMargin = null; }
|
|
407
|
+
|
|
408
|
+
// Ré-observer les placeholders déjà dans le DOM
|
|
409
|
+
try {
|
|
410
|
+
document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
|
|
411
|
+
try { state.io?.observe(n); } catch (_) {}
|
|
412
|
+
});
|
|
413
|
+
} catch (_) {}
|
|
414
|
+
|
|
415
|
+
return state.io;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function observePlaceholder(id) {
|
|
419
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
420
|
+
if (!ph?.isConnected) return;
|
|
421
|
+
try { state.io?.observe(ph); } catch (_) {}
|
|
422
|
+
|
|
423
|
+
// Si déjà proche du viewport → show immédiat
|
|
424
|
+
try {
|
|
425
|
+
const r = ph.getBoundingClientRect();
|
|
426
|
+
const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
|
|
427
|
+
if (r.top < window.innerHeight * screens && r.bottom > -window.innerHeight * 2) {
|
|
428
|
+
enqueueShow(id);
|
|
83
429
|
}
|
|
430
|
+
} catch (_) {}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function enqueueShow(id) {
|
|
434
|
+
if (!id || isBlocked()) return;
|
|
435
|
+
const t = now();
|
|
436
|
+
if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
|
|
437
|
+
|
|
438
|
+
if (state.inflight >= getMaxInflight()) {
|
|
439
|
+
if (!state.pendingSet.has(id)) {
|
|
440
|
+
state.pending.push(id);
|
|
441
|
+
state.pendingSet.add(id);
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
startShow(id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function drainQueue() {
|
|
449
|
+
if (isBlocked()) return;
|
|
450
|
+
while (state.inflight < getMaxInflight() && state.pending.length) {
|
|
451
|
+
const id = state.pending.shift();
|
|
452
|
+
state.pendingSet.delete(id);
|
|
453
|
+
startShow(id);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function startShow(id) {
|
|
458
|
+
if (!id || isBlocked()) return;
|
|
459
|
+
state.inflight++;
|
|
460
|
+
let done = false;
|
|
461
|
+
|
|
462
|
+
const release = () => {
|
|
463
|
+
if (done) return;
|
|
464
|
+
done = true;
|
|
465
|
+
state.inflight = Math.max(0, state.inflight - 1);
|
|
466
|
+
drainQueue();
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const timeout = setTimeout(release, 6500);
|
|
470
|
+
|
|
471
|
+
requestAnimationFrame(() => {
|
|
472
|
+
try {
|
|
473
|
+
if (isBlocked()) return release();
|
|
474
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
475
|
+
if (!ph?.isConnected) return release();
|
|
476
|
+
if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
|
|
477
|
+
|
|
478
|
+
const t = now();
|
|
479
|
+
if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return release();
|
|
480
|
+
state.lastShowById.set(id, t);
|
|
481
|
+
|
|
482
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
483
|
+
const ez = window.ezstandalone;
|
|
484
|
+
|
|
485
|
+
const doShow = () => {
|
|
486
|
+
try { ez.showAds(id); } catch (_) {}
|
|
487
|
+
scheduleEmptyCheck(id);
|
|
488
|
+
setTimeout(release, 650);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
|
|
492
|
+
else doShow();
|
|
493
|
+
} finally { /* timeout covers us */ }
|
|
84
494
|
});
|
|
85
495
|
}
|
|
86
496
|
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
497
|
+
function scheduleEmptyCheck(id) {
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
try {
|
|
500
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
501
|
+
if (!ph?.isConnected) return;
|
|
502
|
+
const wrap = ph.closest(`.${WRAP_CLASS}`);
|
|
503
|
+
if (!wrap) return;
|
|
504
|
+
|
|
505
|
+
const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
|
|
506
|
+
if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
|
|
507
|
+
|
|
508
|
+
if (!isFilledNode(ph)) wrap.classList.add('is-empty');
|
|
509
|
+
else wrap.classList.remove('is-empty');
|
|
510
|
+
} catch (_) {}
|
|
511
|
+
}, 15_000);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── Patch Ezoic showAds ──────────────────────────────────────────────────
|
|
515
|
+
function patchShowAds() {
|
|
516
|
+
const apply = () => {
|
|
517
|
+
try {
|
|
518
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
519
|
+
const ez = window.ezstandalone;
|
|
520
|
+
if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
|
|
521
|
+
|
|
522
|
+
window.__nodebbEzoicPatched = true;
|
|
523
|
+
const orig = ez.showAds.bind(ez);
|
|
524
|
+
|
|
525
|
+
ez.showAds = function (...args) {
|
|
526
|
+
if (isBlocked()) return;
|
|
527
|
+
const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
for (const v of ids) {
|
|
530
|
+
const id = parseInt(v, 10);
|
|
531
|
+
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
|
|
532
|
+
const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
|
|
533
|
+
if (!ph?.isConnected) continue;
|
|
534
|
+
seen.add(id);
|
|
535
|
+
try { orig(id); } catch (_) {}
|
|
536
|
+
}
|
|
102
537
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
538
|
+
} catch (_) {}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
apply();
|
|
542
|
+
if (!window.__nodebbEzoicPatched) {
|
|
543
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
544
|
+
window.ezstandalone.cmd = window.ezstandalone.cmd || [];
|
|
545
|
+
window.ezstandalone.cmd.push(apply);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Core run ─────────────────────────────────────────────────────────────
|
|
550
|
+
async function runCore() {
|
|
551
|
+
if (isBlocked()) return 0;
|
|
552
|
+
patchShowAds();
|
|
553
|
+
|
|
554
|
+
const cfg = await fetchConfig();
|
|
555
|
+
if (!cfg || cfg.excluded) return 0;
|
|
556
|
+
initPools(cfg);
|
|
557
|
+
|
|
558
|
+
const kind = getKind();
|
|
559
|
+
let inserted = 0;
|
|
560
|
+
|
|
561
|
+
const run = (kindClass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
|
|
562
|
+
if (!normBool(cfgEnable)) return 0;
|
|
563
|
+
const items = getItems();
|
|
564
|
+
pruneOrphans(kindClass);
|
|
565
|
+
const n = injectBetween(kindClass, items, Math.max(1, parseInt(cfgInterval, 10) || 3), normBool(cfgShowFirst), poolKey);
|
|
566
|
+
if (n) decluster(kindClass);
|
|
567
|
+
return n;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
if (kind === 'topic') {
|
|
571
|
+
inserted += run('ezoic-ad-message', getPostContainers,
|
|
572
|
+
cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
|
|
573
|
+
} else if (kind === 'categoryTopics') {
|
|
574
|
+
inserted += run('ezoic-ad-between', getTopicItems,
|
|
575
|
+
cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
|
|
576
|
+
} else if (kind === 'categories') {
|
|
577
|
+
inserted += run('ezoic-ad-categories', getCategoryItems,
|
|
578
|
+
cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return inserted;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ─── Scheduler / Burst ────────────────────────────────────────────────────
|
|
585
|
+
function scheduleRun(delayMs = 0, cb) {
|
|
586
|
+
if (state.runQueued) return;
|
|
587
|
+
state.runQueued = true;
|
|
588
|
+
|
|
589
|
+
const run = async () => {
|
|
590
|
+
state.runQueued = false;
|
|
591
|
+
if (state.pageKey && getPageKey() !== state.pageKey) return;
|
|
592
|
+
let n = 0;
|
|
593
|
+
try { n = await runCore(); } catch (_) {}
|
|
594
|
+
try { cb?.(n); } catch (_) {}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const doRun = () => requestAnimationFrame(run);
|
|
598
|
+
if (delayMs > 0) setTimeout(doRun, delayMs);
|
|
599
|
+
else doRun();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function requestBurst() {
|
|
603
|
+
if (isBlocked()) return;
|
|
604
|
+
const t = now();
|
|
605
|
+
if (t - state.lastBurstReqTs < 100) return;
|
|
606
|
+
state.lastBurstReqTs = t;
|
|
607
|
+
|
|
608
|
+
const pk = getPageKey();
|
|
609
|
+
state.pageKey = pk;
|
|
610
|
+
state.burstDeadline = t + 1800;
|
|
611
|
+
|
|
612
|
+
if (state.burstActive) return;
|
|
613
|
+
state.burstActive = true;
|
|
614
|
+
state.burstCount = 0;
|
|
615
|
+
|
|
616
|
+
const step = () => {
|
|
617
|
+
if (getPageKey() !== pk) { state.burstActive = false; return; }
|
|
618
|
+
if (isBlocked()) { state.burstActive = false; return; }
|
|
619
|
+
if (now() > state.burstDeadline) { state.burstActive = false; return; }
|
|
620
|
+
if (state.burstCount >= 8) { state.burstActive = false; return; }
|
|
621
|
+
|
|
622
|
+
state.burstCount++;
|
|
623
|
+
scheduleRun(0, (n) => {
|
|
624
|
+
if (!n && !state.pending.length) { state.burstActive = false; return; }
|
|
625
|
+
setTimeout(step, n > 0 ? 120 : 250);
|
|
113
626
|
});
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
step();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ─── Cleanup (ajaxify navigation) ─────────────────────────────────────────
|
|
633
|
+
function cleanup() {
|
|
634
|
+
// Bloquer toute injection pendant la transition
|
|
635
|
+
blockedUntil = now() + 1500;
|
|
636
|
+
|
|
637
|
+
// Supprimer tous les wraps injectés → libère les IDs
|
|
638
|
+
withInternalMutation(() => {
|
|
639
|
+
document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Réinitialiser l'état complet
|
|
643
|
+
state.cfg = null;
|
|
644
|
+
state.pools = { topics: [], posts: [], categories: [] };
|
|
645
|
+
state.cursors = { topics: 0, posts: 0, categories: 0 };
|
|
646
|
+
state.mountedIds.clear();
|
|
647
|
+
state.lastShowById.clear();
|
|
648
|
+
state.inflight = 0;
|
|
649
|
+
state.pending = [];
|
|
650
|
+
state.pendingSet.clear();
|
|
651
|
+
|
|
652
|
+
state.burstActive = false;
|
|
653
|
+
state.runQueued = false;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── DOM Observer ─────────────────────────────────────────────────────────
|
|
657
|
+
function shouldReact(mutations) {
|
|
658
|
+
for (const m of mutations) {
|
|
659
|
+
if (!m.addedNodes?.length) continue;
|
|
660
|
+
for (const n of m.addedNodes) {
|
|
661
|
+
if (n.nodeType !== 1) continue;
|
|
662
|
+
if (
|
|
663
|
+
n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
|
|
664
|
+
n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
|
|
665
|
+
n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem)
|
|
666
|
+
) return true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function ensureDomObserver() {
|
|
673
|
+
if (state.domObs) return;
|
|
674
|
+
state.domObs = new MutationObserver(mutations => {
|
|
675
|
+
if (state.internalMutation > 0) return;
|
|
676
|
+
if (isBlocked()) return;
|
|
677
|
+
if (!shouldReact(mutations)) return;
|
|
678
|
+
requestBurst();
|
|
679
|
+
});
|
|
680
|
+
try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
|
|
114
681
|
}
|
|
115
682
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
683
|
+
// ─── Utilities: console mute + TCF + network warm ─────────────────────────
|
|
684
|
+
function muteNoisyConsole() {
|
|
685
|
+
if (window.__nodebbEzoicConsoleMuted) return;
|
|
686
|
+
window.__nodebbEzoicConsoleMuted = true;
|
|
687
|
+
const MUTED = [
|
|
688
|
+
'[EzoicAds JS]: Placeholder Id',
|
|
689
|
+
'Debugger iframe already exists',
|
|
690
|
+
'HTML element with id ezoic-pub-ad-placeholder-',
|
|
691
|
+
];
|
|
692
|
+
['log', 'info', 'warn', 'error'].forEach(m => {
|
|
693
|
+
const orig = console[m];
|
|
694
|
+
if (typeof orig !== 'function') return;
|
|
695
|
+
console[m] = function (...args) {
|
|
696
|
+
const s = typeof args[0] === 'string' ? args[0] : '';
|
|
697
|
+
if (MUTED.some(p => s.includes(p))) return;
|
|
698
|
+
orig.apply(console, args);
|
|
699
|
+
};
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function ensureTcfLocator() {
|
|
704
|
+
try {
|
|
705
|
+
if (!window.__tcfapi && !window.__cmp) return;
|
|
706
|
+
if (document.getElementById('__tcfapiLocator')) return;
|
|
707
|
+
const f = Object.assign(document.createElement('iframe'), {
|
|
708
|
+
style: 'display:none', id: '__tcfapiLocator', name: '__tcfapiLocator',
|
|
122
709
|
});
|
|
710
|
+
(document.body || document.documentElement).appendChild(f);
|
|
711
|
+
} catch (_) {}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const _warmedLinks = new Set();
|
|
715
|
+
function warmNetwork() {
|
|
716
|
+
const head = document.head;
|
|
717
|
+
if (!head) return;
|
|
718
|
+
const links = [
|
|
719
|
+
['preconnect', 'https://g.ezoic.net', true],
|
|
720
|
+
['preconnect', 'https://go.ezoic.net', true],
|
|
721
|
+
['preconnect', 'https://securepubads.g.doubleclick.net', true],
|
|
722
|
+
['preconnect', 'https://pagead2.googlesyndication.com', true],
|
|
723
|
+
['dns-prefetch', 'https://g.ezoic.net', false],
|
|
724
|
+
['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
|
|
725
|
+
];
|
|
726
|
+
for (const [rel, href, cors] of links) {
|
|
727
|
+
const key = `${rel}|${href}`;
|
|
728
|
+
if (_warmedLinks.has(key)) continue;
|
|
729
|
+
_warmedLinks.add(key);
|
|
730
|
+
const link = document.createElement('link');
|
|
731
|
+
link.rel = rel; link.href = href;
|
|
732
|
+
if (cors) link.crossOrigin = 'anonymous';
|
|
733
|
+
head.appendChild(link);
|
|
123
734
|
}
|
|
124
|
-
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
|
|
738
|
+
function bindNodeBB() {
|
|
739
|
+
const $ = window.jQuery;
|
|
740
|
+
if (!$) return;
|
|
741
|
+
|
|
742
|
+
$(window).off('.ezoicInfinite');
|
|
743
|
+
|
|
744
|
+
$(window).on('action:ajaxify.start.ezoicInfinite', () => {
|
|
745
|
+
cleanup();
|
|
746
|
+
});
|
|
125
747
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
748
|
+
$(window).on('action:ajaxify.end.ezoicInfinite', () => {
|
|
749
|
+
state.pageKey = getPageKey();
|
|
750
|
+
blockedUntil = 0;
|
|
751
|
+
muteNoisyConsole();
|
|
752
|
+
ensureTcfLocator();
|
|
753
|
+
warmNetwork();
|
|
754
|
+
patchShowAds();
|
|
755
|
+
ensurePreloadObserver();
|
|
756
|
+
ensureDomObserver();
|
|
757
|
+
requestBurst();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const burstEvents = [
|
|
761
|
+
'action:ajaxify.contentLoaded',
|
|
762
|
+
'action:posts.loaded',
|
|
763
|
+
'action:topics.loaded',
|
|
764
|
+
'action:categories.loaded',
|
|
765
|
+
'action:category.loaded',
|
|
766
|
+
'action:topic.loaded',
|
|
767
|
+
].map(e => `${e}.ezoicInfinite`).join(' ');
|
|
768
|
+
|
|
769
|
+
$(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
|
|
770
|
+
|
|
771
|
+
// Hooks AMD (NodeBB 4.x)
|
|
772
|
+
try {
|
|
773
|
+
require(['hooks'], hooks => {
|
|
774
|
+
if (typeof hooks?.on !== 'function') return;
|
|
775
|
+
[
|
|
776
|
+
'action:ajaxify.end', 'action:ajaxify.contentLoaded',
|
|
777
|
+
'action:posts.loaded', 'action:topics.loaded',
|
|
778
|
+
'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
|
|
779
|
+
].forEach(ev => {
|
|
780
|
+
try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
} catch (_) {}
|
|
784
|
+
}
|
|
129
785
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
786
|
+
function bindScroll() {
|
|
787
|
+
let ticking = false;
|
|
788
|
+
window.addEventListener('scroll', () => {
|
|
789
|
+
// Scroll boost
|
|
790
|
+
try {
|
|
791
|
+
const t = now();
|
|
792
|
+
const y = window.scrollY || window.pageYOffset || 0;
|
|
793
|
+
if (state.lastScrollTs) {
|
|
794
|
+
const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
|
|
795
|
+
if (speed >= BOOST_SPEED_PX_PER_MS) {
|
|
796
|
+
const wasBoosted = isBoosted();
|
|
797
|
+
state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
|
|
798
|
+
if (!wasBoosted) ensurePreloadObserver();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
state.lastScrollY = y;
|
|
802
|
+
state.lastScrollTs = t;
|
|
803
|
+
} catch (_) {}
|
|
804
|
+
|
|
805
|
+
if (ticking) return;
|
|
806
|
+
ticking = true;
|
|
807
|
+
requestAnimationFrame(() => { ticking = false; requestBurst(); });
|
|
808
|
+
}, { passive: true });
|
|
134
809
|
}
|
|
135
|
-
|
|
810
|
+
|
|
811
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────
|
|
812
|
+
state.pageKey = getPageKey();
|
|
813
|
+
muteNoisyConsole();
|
|
814
|
+
ensureTcfLocator();
|
|
815
|
+
warmNetwork();
|
|
816
|
+
patchShowAds();
|
|
817
|
+
ensurePreloadObserver();
|
|
818
|
+
ensureDomObserver();
|
|
819
|
+
bindNodeBB();
|
|
820
|
+
bindScroll();
|
|
821
|
+
blockedUntil = 0;
|
|
822
|
+
requestBurst();
|
|
823
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -1,37 +1,89 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
NodeBB Ezoic Infinite Ads — style.css (v18)
|
|
3
|
+
Corrections :
|
|
4
|
+
- Suppression de line-height:0/font-size:0 global (cassait le texte adjacent)
|
|
5
|
+
- Ciblage précis pour éviter les effets de bord sur les composants NodeBB
|
|
6
|
+
- Règles is-empty revues pour ne pas interférer avec les slots en cours de fill
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* ── Wrapper principal ───────────────────────────────────────────────────── */
|
|
2
10
|
.nodebb-ezoic-wrap {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
display: block;
|
|
12
|
+
width: 100%;
|
|
13
|
+
margin: 0 !important;
|
|
14
|
+
padding: 0 !important;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
contain: layout style; /* isolation des reflows */
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Placeholder : measurable pour l'IntersectionObserver, invisible si vide */
|
|
20
|
+
.nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
|
|
21
|
+
margin: 0 !important;
|
|
22
|
+
padding: 0 !important;
|
|
23
|
+
min-height: 1px;
|
|
24
|
+
display: block;
|
|
10
25
|
}
|
|
11
26
|
|
|
12
|
-
/*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
/* ── Ciblage précis des noeuds Ezoic à l'intérieur de nos wraps ─────────── */
|
|
28
|
+
|
|
29
|
+
/* Killer du gap "baseline" sous les iframes */
|
|
30
|
+
.nodebb-ezoic-wrap iframe,
|
|
31
|
+
.nodebb-ezoic-wrap div[id$="__container__"] iframe {
|
|
32
|
+
display: block !important;
|
|
33
|
+
vertical-align: top !important;
|
|
34
|
+
line-height: 0 !important;
|
|
35
|
+
font-size: 0 !important;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
border-radius: 10px;
|
|
38
|
+
.nodebb-ezoic-wrap div[id$="__container__"] {
|
|
39
|
+
display: block !important;
|
|
40
|
+
line-height: 0 !important;
|
|
41
|
+
font-size: 0 !important;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
/*
|
|
27
|
-
.nodebb-ezoic-wrap
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
/* Kill de la réserve 400px qu'Ezoic injecte en inline style */
|
|
45
|
+
.nodebb-ezoic-wrap .ezoic-ad,
|
|
46
|
+
.nodebb-ezoic-wrap span.ezoic-ad {
|
|
47
|
+
margin: 0 !important;
|
|
48
|
+
padding: 0 !important;
|
|
49
|
+
min-height: 1px !important; /* écrase le 400px Ezoic */
|
|
50
|
+
height: auto !important;
|
|
31
51
|
}
|
|
32
52
|
|
|
33
|
-
/*
|
|
53
|
+
/* Reportline en absolu pour ne pas impacter le layout */
|
|
54
|
+
.nodebb-ezoic-wrap .reportline {
|
|
55
|
+
position: absolute !important;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Neutralise sticky à l'intérieur de nos wraps (évite l'effet "gliding") */
|
|
59
|
+
.nodebb-ezoic-wrap .ezads-sticky-intradiv {
|
|
60
|
+
position: static !important;
|
|
61
|
+
top: auto !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ── État "vide" ─────────────────────────────────────────────────────────── */
|
|
65
|
+
/*
|
|
66
|
+
is-empty est ajouté 15s après le show si aucun fill détecté.
|
|
67
|
+
On collapse à 1px (et non 0) pour rester visible à l'IO (évite le déclenchement
|
|
68
|
+
trop tard si le fill arrive après le collapse).
|
|
69
|
+
*/
|
|
70
|
+
.nodebb-ezoic-wrap.is-empty {
|
|
71
|
+
display: block !important;
|
|
72
|
+
height: 1px !important;
|
|
73
|
+
min-height: 1px !important;
|
|
74
|
+
max-height: 1px !important;
|
|
75
|
+
margin: 0 !important;
|
|
76
|
+
padding: 0 !important;
|
|
77
|
+
overflow: hidden !important;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Ezoic global (hors de nos wraps) ───────────────────────────────────── */
|
|
34
81
|
.ezoic-ad {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
82
|
+
margin: 0 !important;
|
|
83
|
+
padding: 0 !important;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── Orphan hidden (compatibilité transitoire) ───────────────────────────── */
|
|
87
|
+
.ez-orphan-hidden {
|
|
88
|
+
display: none !important;
|
|
89
|
+
}
|