nodebb-plugin-ezoic-infinite 1.7.53 → 1.7.54
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 +13 -29
- package/package.json +1 -1
- package/public/client.js +261 -127
- package/public/style.css +12 -8
package/library.js
CHANGED
|
@@ -15,11 +15,9 @@ function normalizeExcludedGroups(value) {
|
|
|
15
15
|
// NodeBB stocke les settings multi-valeurs comme string JSON "[\"group1\",\"group2\"]"
|
|
16
16
|
const s = String(value).trim();
|
|
17
17
|
if (s.startsWith('[')) {
|
|
18
|
-
try {
|
|
19
|
-
const parsed = JSON.parse(s);
|
|
20
|
-
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
|
|
21
|
-
} catch (_) {}
|
|
18
|
+
try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
|
|
22
19
|
}
|
|
20
|
+
// Fallback : séparation par virgule
|
|
23
21
|
return s.split(',').map(v => v.trim()).filter(Boolean);
|
|
24
22
|
}
|
|
25
23
|
|
|
@@ -30,15 +28,7 @@ function parseBool(v, def = false) {
|
|
|
30
28
|
return s === '1' || s === 'true' || s === 'on' || s === 'yes';
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
// ── Cache groupes (fix #7 : getAllGroups sans cache sur page ACP) ───────────
|
|
34
|
-
|
|
35
|
-
let _groupsCache = null;
|
|
36
|
-
let _groupsCacheAt = 0;
|
|
37
|
-
const GROUPS_TTL = 60_000; // 1 minute
|
|
38
|
-
|
|
39
31
|
async function getAllGroups() {
|
|
40
|
-
const now = Date.now();
|
|
41
|
-
if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
|
|
42
32
|
let names = await db.getSortedSetRange('groups:createtime', 0, -1);
|
|
43
33
|
if (!names || !names.length) {
|
|
44
34
|
names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
|
|
@@ -47,9 +37,7 @@ async function getAllGroups() {
|
|
|
47
37
|
const data = await groups.getGroupsData(filtered);
|
|
48
38
|
const valid = data.filter(g => g && g.name);
|
|
49
39
|
valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
|
|
50
|
-
|
|
51
|
-
_groupsCacheAt = now;
|
|
52
|
-
return _groupsCache;
|
|
40
|
+
return valid;
|
|
53
41
|
}
|
|
54
42
|
|
|
55
43
|
// ── Settings cache (30s TTL) ────────────────────────────────────────────────
|
|
@@ -100,10 +88,7 @@ ezstandalone.cmd = ezstandalone.cmd || [];
|
|
|
100
88
|
// ── Hooks ──────────────────────────────────────────────────────────────────
|
|
101
89
|
|
|
102
90
|
plugin.onSettingsSet = function (data) {
|
|
103
|
-
if (data && data.hash === SETTINGS_KEY)
|
|
104
|
-
_settingsCache = null;
|
|
105
|
-
_groupsCache = null; // invalider aussi le cache groupes
|
|
106
|
-
}
|
|
91
|
+
if (data && data.hash === SETTINGS_KEY) _settingsCache = null;
|
|
107
92
|
};
|
|
108
93
|
|
|
109
94
|
plugin.addAdminNavigation = async (header) => {
|
|
@@ -116,11 +101,11 @@ plugin.addAdminNavigation = async (header) => {
|
|
|
116
101
|
* Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
|
|
117
102
|
*
|
|
118
103
|
* NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
|
|
119
|
-
* (render.js : templateValues.customHTML = meta.config.customHTML).
|
|
120
|
-
* Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
104
|
+
* (render.js ligne 232 : templateValues.customHTML = meta.config.customHTML).
|
|
105
|
+
* Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
|
|
106
|
+
* et est rendu via req.app.renderAsync('header', hookReturn.templateData).
|
|
107
|
+
* On préfixe customHTML pour que nos scripts passent AVANT le customHTML admin,
|
|
108
|
+
* tout en préservant ce dernier.
|
|
124
109
|
*/
|
|
125
110
|
plugin.injectEzoicHead = async (data) => {
|
|
126
111
|
try {
|
|
@@ -128,18 +113,17 @@ plugin.injectEzoicHead = async (data) => {
|
|
|
128
113
|
const uid = data.req?.uid ?? 0;
|
|
129
114
|
const excluded = await isUserExcluded(uid, settings.excludedGroups);
|
|
130
115
|
if (!excluded) {
|
|
116
|
+
// Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
|
|
131
117
|
data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
|
|
132
118
|
}
|
|
133
|
-
} catch (
|
|
134
|
-
// Log l'erreur mais ne pas planter le rendu de la page
|
|
135
|
-
console.error('[ezoic-infinite] injectEzoicHead error:', err.message || err);
|
|
136
|
-
}
|
|
119
|
+
} catch (_) {}
|
|
137
120
|
return data;
|
|
138
121
|
};
|
|
139
122
|
|
|
140
123
|
plugin.init = async ({ router, middleware }) => {
|
|
141
124
|
async function render(req, res) {
|
|
142
|
-
const
|
|
125
|
+
const settings = await getSettings();
|
|
126
|
+
const allGroups = await getAllGroups();
|
|
143
127
|
res.render('admin/plugins/ezoic-infinite', {
|
|
144
128
|
title: 'Ezoic Infinite Ads',
|
|
145
129
|
...settings,
|
package/package.json
CHANGED
package/public/client.js
CHANGED
|
@@ -1,46 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — client.js
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — client.js v36
|
|
3
3
|
*
|
|
4
|
-
* Historique
|
|
5
|
-
*
|
|
6
|
-
* v18 Ancrage stable par data-pid / data-index.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
4
|
+
* Historique des corrections majeures
|
|
5
|
+
* ────────────────────────────────────
|
|
6
|
+
* v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
|
|
7
|
+
*
|
|
8
|
+
* v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
|
|
9
|
+
* la position dans le batch courant.
|
|
10
|
+
*
|
|
11
|
+
* v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
|
|
12
|
+
* Fix fatal catégories : data-cid au lieu de data-index inexistant.
|
|
13
|
+
* IO fixe (une instance, jamais recréée).
|
|
14
|
+
* Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
|
|
15
|
+
*
|
|
16
|
+
* v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
|
|
17
|
+
*
|
|
18
|
+
* v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
|
|
19
|
+
*
|
|
20
|
+
* v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
|
|
21
|
+
*
|
|
22
|
+
* v28 decluster supprimé. Wraps persistants pendant la session.
|
|
23
|
+
*
|
|
24
|
+
* v32 Retour anchorAttr = data-index pour ezoic-ad-between.
|
|
25
|
+
* data-tid peut être absent → clés invalides → wraps empilés.
|
|
26
|
+
* pruneOrphansBetween réactivé uniquement pour topics de catégorie :
|
|
27
|
+
* – NodeBB NE virtualise PAS les topics dans une liste de catégorie,
|
|
28
|
+
* les ancres (data-index) restent en DOM → prune safe et nécessaire
|
|
29
|
+
* pour éviter l'empilement après scroll long.
|
|
30
|
+
* – Toujours désactivé pour les posts : NodeBB virtualise les posts
|
|
31
|
+
* hors-viewport → faux-orphelins → bug réinjection en haut.
|
|
32
|
+
*
|
|
33
|
+
* v34 moveDistantWrap — voir v38.
|
|
34
|
+
*
|
|
35
|
+
* v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
|
|
36
|
+
* après login — filter:middleware.renderHeader re-évalue l'exclusion au
|
|
37
|
+
* rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
|
|
38
|
+
*
|
|
39
|
+
* v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
|
|
40
|
+
*
|
|
41
|
+
* v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
|
|
42
|
+
*
|
|
43
|
+
* v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
|
|
44
|
+
*
|
|
45
|
+
* v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
|
|
46
|
+
* Séquence : destroy → 300ms → define → 300ms → displayMore.
|
|
47
|
+
* Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
|
|
48
|
+
*
|
|
49
|
+
* v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
|
|
50
|
+
* sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
|
|
51
|
+
* déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
|
|
52
|
+
* break propre dans injectBetween. muteConsole : ajout warnings refresh.
|
|
53
|
+
*
|
|
54
|
+
* v36 Optimisations chemin critique (scroll → injectBetween) :
|
|
55
|
+
* – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
|
|
56
|
+
* sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
|
|
57
|
+
* dropWrap et cleanup.
|
|
58
|
+
* – wrapIsLive allégé : pour les voisins immédiats on vérifie les
|
|
59
|
+
* attributs du nœud lui-même sans querySelector global.
|
|
60
|
+
* – MutationObserver : matches() vérifié avant querySelector() pour
|
|
61
|
+
* court-circuiter les sous-arbres entiers ajoutés par NodeBB.
|
|
62
|
+
*
|
|
63
|
+
* v35 Revue complète prod-ready :
|
|
64
|
+
* – initPools protégé contre ré-initialisation inutile (S.poolsReady).
|
|
65
|
+
* – muteConsole élargit à "No valid placeholders for loadMore".
|
|
66
|
+
* – Commentaires et historique nettoyés.
|
|
28
67
|
*/
|
|
29
68
|
(function nbbEzoicInfinite() {
|
|
30
69
|
'use strict';
|
|
31
70
|
|
|
32
71
|
// ── Constantes ─────────────────────────────────────────────────────────────
|
|
33
72
|
|
|
34
|
-
const WRAP_CLASS
|
|
35
|
-
const PH_PREFIX
|
|
36
|
-
const A_ANCHOR
|
|
37
|
-
const A_WRAPID
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
73
|
+
const WRAP_CLASS = 'nodebb-ezoic-wrap';
|
|
74
|
+
const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
|
|
75
|
+
const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
|
|
76
|
+
const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
|
|
77
|
+
const A_CREATED = 'data-ezoic-created'; // timestamp création ms
|
|
78
|
+
const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
|
|
79
|
+
|
|
80
|
+
const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
|
|
81
|
+
const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
|
|
82
|
+
const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
|
|
83
|
+
const MAX_INFLIGHT = 4; // max showAds() simultanés
|
|
84
|
+
const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
|
|
85
|
+
const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
|
|
86
|
+
|
|
87
|
+
// Marges IO larges et fixes — observer créé une seule fois au boot
|
|
44
88
|
const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
|
|
45
89
|
const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
|
|
46
90
|
|
|
@@ -52,10 +96,13 @@
|
|
|
52
96
|
|
|
53
97
|
/**
|
|
54
98
|
* Table KIND — source de vérité par kindClass.
|
|
55
|
-
*
|
|
99
|
+
*
|
|
100
|
+
* sel sélecteur CSS complet des éléments cibles
|
|
56
101
|
* baseTag préfixe tag pour querySelector d'ancre
|
|
102
|
+
* (vide pour posts : le sélecteur commence par '[')
|
|
57
103
|
* anchorAttr attribut DOM stable → clé unique du wrap
|
|
58
|
-
* ordinalAttr attribut 0-based pour le calcul de l'intervalle
|
|
104
|
+
* ordinalAttr attribut 0-based pour le calcul de l'intervalle
|
|
105
|
+
* null → fallback positionnel (catégories)
|
|
59
106
|
*/
|
|
60
107
|
const KIND = {
|
|
61
108
|
'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
|
|
@@ -66,39 +113,34 @@
|
|
|
66
113
|
// ── État global ────────────────────────────────────────────────────────────
|
|
67
114
|
|
|
68
115
|
const S = {
|
|
69
|
-
pageKey:
|
|
70
|
-
cfg:
|
|
71
|
-
poolsReady:
|
|
72
|
-
pools:
|
|
73
|
-
cursors:
|
|
74
|
-
mountedIds:
|
|
75
|
-
lastShow:
|
|
76
|
-
io:
|
|
77
|
-
domObs:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
runQueued: false,
|
|
86
|
-
burstActive: false,
|
|
116
|
+
pageKey: null,
|
|
117
|
+
cfg: null,
|
|
118
|
+
poolsReady: false,
|
|
119
|
+
pools: { topics: [], posts: [], categories: [] },
|
|
120
|
+
cursors: { topics: 0, posts: 0, categories: 0 },
|
|
121
|
+
mountedIds: new Set(),
|
|
122
|
+
lastShow: new Map(),
|
|
123
|
+
io: null,
|
|
124
|
+
domObs: null,
|
|
125
|
+
mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
|
|
126
|
+
inflight: 0, // showAds() en cours
|
|
127
|
+
pending: [], // ids en attente de slot inflight
|
|
128
|
+
pendingSet: new Set(),
|
|
129
|
+
wrapByKey: new Map(), // anchorKey → wrap DOM node
|
|
130
|
+
runQueued: false,
|
|
131
|
+
burstActive: false,
|
|
87
132
|
burstDeadline: 0,
|
|
88
|
-
burstCount:
|
|
89
|
-
lastBurstTs:
|
|
133
|
+
burstCount: 0,
|
|
134
|
+
lastBurstTs: 0,
|
|
90
135
|
};
|
|
91
136
|
|
|
92
|
-
let blockedUntil
|
|
93
|
-
let _cfgErrorUntil = 0;
|
|
94
|
-
let _ioMobile = null;
|
|
137
|
+
let blockedUntil = 0;
|
|
95
138
|
|
|
96
|
-
const ts
|
|
97
|
-
const isBlocked
|
|
98
|
-
const isMobile
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const isFilled = n => !!(n?.querySelector('iframe, ins, img, video, [data-google-container-id]'));
|
|
139
|
+
const ts = () => Date.now();
|
|
140
|
+
const isBlocked = () => ts() < blockedUntil;
|
|
141
|
+
const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
|
|
142
|
+
const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
|
|
143
|
+
const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
|
|
102
144
|
|
|
103
145
|
function mutate(fn) {
|
|
104
146
|
S.mutGuard++;
|
|
@@ -109,12 +151,10 @@
|
|
|
109
151
|
|
|
110
152
|
async function fetchConfig() {
|
|
111
153
|
if (S.cfg) return S.cfg;
|
|
112
|
-
if (Date.now() < _cfgErrorUntil) return null;
|
|
113
154
|
try {
|
|
114
155
|
const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
|
|
115
|
-
if (r.ok)
|
|
116
|
-
|
|
117
|
-
} catch (_) { _cfgErrorUntil = Date.now() + 10_000; }
|
|
156
|
+
if (r.ok) S.cfg = await r.json();
|
|
157
|
+
} catch (_) {}
|
|
118
158
|
return S.cfg;
|
|
119
159
|
}
|
|
120
160
|
|
|
@@ -174,37 +214,53 @@
|
|
|
174
214
|
|
|
175
215
|
// ── Wraps — détection ──────────────────────────────────────────────────────
|
|
176
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Vérifie qu'un wrap a encore son ancre dans le DOM.
|
|
219
|
+
* Utilisé par adjacentWrap pour ignorer les wraps orphelins.
|
|
220
|
+
*/
|
|
177
221
|
function wrapIsLive(wrap) {
|
|
178
222
|
if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
|
|
179
223
|
const key = wrap.getAttribute(A_ANCHOR);
|
|
180
224
|
if (!key) return false;
|
|
225
|
+
// Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
|
|
226
|
+
// et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
|
|
181
227
|
if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
|
|
228
|
+
// Fallback : registre pas encore à jour ou wrap non enregistré.
|
|
182
229
|
const colonIdx = key.indexOf(':');
|
|
183
230
|
const klass = key.slice(0, colonIdx);
|
|
184
231
|
const anchorId = key.slice(colonIdx + 1);
|
|
185
232
|
const cfg = KIND[klass];
|
|
186
233
|
if (!cfg) return false;
|
|
234
|
+
// Optimisation : si l'ancre est un frère direct du wrap, pas besoin
|
|
235
|
+
// de querySelector global — on cherche parmi les voisins immédiats.
|
|
187
236
|
const parent = wrap.parentElement;
|
|
188
237
|
if (parent) {
|
|
189
238
|
for (const sib of parent.children) {
|
|
190
239
|
if (sib === wrap) continue;
|
|
191
240
|
try {
|
|
192
|
-
if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`))
|
|
241
|
+
if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
|
|
193
242
|
return sib.isConnected;
|
|
243
|
+
}
|
|
194
244
|
} catch (_) {}
|
|
195
245
|
}
|
|
196
246
|
}
|
|
247
|
+
// Dernier recours : querySelector global
|
|
197
248
|
try {
|
|
198
249
|
const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
|
|
199
250
|
return !!(found?.isConnected);
|
|
200
251
|
} catch (_) { return false; }
|
|
201
252
|
}
|
|
202
253
|
|
|
203
|
-
|
|
204
|
-
wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
|
|
254
|
+
function adjacentWrap(el) {
|
|
255
|
+
return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
|
|
256
|
+
}
|
|
205
257
|
|
|
206
258
|
// ── Ancres stables ─────────────────────────────────────────────────────────
|
|
207
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Retourne la valeur de l'attribut stable pour cet élément,
|
|
262
|
+
* ou un fallback positionnel si l'attribut est absent.
|
|
263
|
+
*/
|
|
208
264
|
function stableId(klass, el) {
|
|
209
265
|
const attr = KIND[klass]?.anchorAttr;
|
|
210
266
|
if (attr) {
|
|
@@ -223,15 +279,18 @@
|
|
|
223
279
|
|
|
224
280
|
function findWrap(key) {
|
|
225
281
|
const w = S.wrapByKey.get(key);
|
|
226
|
-
return w?.isConnected ? w : null;
|
|
282
|
+
return (w?.isConnected) ? w : null;
|
|
227
283
|
}
|
|
228
284
|
|
|
229
285
|
// ── Pool ───────────────────────────────────────────────────────────────────
|
|
230
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Retourne le prochain id disponible dans le pool (round-robin),
|
|
289
|
+
* ou null si tous les ids sont montés.
|
|
290
|
+
*/
|
|
231
291
|
function pickId(poolKey) {
|
|
232
292
|
const pool = S.pools[poolKey];
|
|
233
293
|
if (!pool.length) return null;
|
|
234
|
-
if (S.mountedIds.size >= pool.length && pool.every(id => S.mountedIds.has(id))) return null;
|
|
235
294
|
for (let t = 0; t < pool.length; t++) {
|
|
236
295
|
const i = S.cursors[poolKey] % pool.length;
|
|
237
296
|
S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
|
|
@@ -243,7 +302,9 @@
|
|
|
243
302
|
|
|
244
303
|
/**
|
|
245
304
|
* Pool épuisé : recycle un wrap loin au-dessus du viewport.
|
|
246
|
-
* Séquence
|
|
305
|
+
* Séquence avec délais (destroyPlaceholders est asynchrone) :
|
|
306
|
+
* destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
|
|
307
|
+
* displayMore = API Ezoic prévue pour l'infinite scroll.
|
|
247
308
|
* Priorité : wraps vides d'abord, remplis si nécessaire.
|
|
248
309
|
*/
|
|
249
310
|
function recycleAndMove(klass, targetEl, newKey) {
|
|
@@ -252,7 +313,10 @@
|
|
|
252
313
|
typeof ez?.define !== 'function' ||
|
|
253
314
|
typeof ez?.displayMore !== 'function') return null;
|
|
254
315
|
|
|
255
|
-
const
|
|
316
|
+
const vh = window.innerHeight || 800;
|
|
317
|
+
// Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
|
|
318
|
+
// après pour neutraliser l'IO — plus de showAds parasite possible.
|
|
319
|
+
const threshold = -vh;
|
|
256
320
|
let bestEmpty = null, bestEmptyBottom = Infinity;
|
|
257
321
|
let bestFilled = null, bestFilledBottom = Infinity;
|
|
258
322
|
|
|
@@ -272,13 +336,16 @@
|
|
|
272
336
|
if (!best) return null;
|
|
273
337
|
const id = parseInt(best.getAttribute(A_WRAPID), 10);
|
|
274
338
|
if (!Number.isFinite(id)) return null;
|
|
275
|
-
if (S.recycling.has(id)) return null;
|
|
276
|
-
S.recycling.add(id);
|
|
277
339
|
|
|
278
340
|
const oldKey = best.getAttribute(A_ANCHOR);
|
|
341
|
+
// Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
|
|
342
|
+
// parasite si le nœud était encore dans la zone IO_MARGIN.
|
|
279
343
|
try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
|
|
280
344
|
mutate(() => {
|
|
281
|
-
best.setAttribute(A_ANCHOR,
|
|
345
|
+
best.setAttribute(A_ANCHOR, newKey);
|
|
346
|
+
best.setAttribute(A_CREATED, String(ts()));
|
|
347
|
+
best.setAttribute(A_SHOWN, '0');
|
|
348
|
+
best.classList.remove('is-empty');
|
|
282
349
|
const ph = best.querySelector(`#${PH_PREFIX}${id}`);
|
|
283
350
|
if (ph) ph.innerHTML = '';
|
|
284
351
|
targetEl.insertAdjacentElement('afterend', best);
|
|
@@ -286,14 +353,11 @@
|
|
|
286
353
|
if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
|
|
287
354
|
S.wrapByKey.set(newKey, best);
|
|
288
355
|
|
|
356
|
+
// Délais requis : destroyPlaceholders est asynchrone en interne
|
|
289
357
|
const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
|
|
290
358
|
const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
|
|
291
|
-
const doDisplay = () => {
|
|
292
|
-
|
|
293
|
-
S.recycling.delete(id);
|
|
294
|
-
observePh(id);
|
|
295
|
-
};
|
|
296
|
-
try { typeof ez.cmd?.push === 'function' ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
|
|
359
|
+
const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
|
|
360
|
+
try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
|
|
297
361
|
|
|
298
362
|
return { id, wrap: best };
|
|
299
363
|
}
|
|
@@ -303,8 +367,10 @@
|
|
|
303
367
|
function makeWrap(id, klass, key) {
|
|
304
368
|
const w = document.createElement('div');
|
|
305
369
|
w.className = `${WRAP_CLASS} ${klass}`;
|
|
306
|
-
w.setAttribute(A_ANCHOR,
|
|
307
|
-
w.setAttribute(A_WRAPID,
|
|
370
|
+
w.setAttribute(A_ANCHOR, key);
|
|
371
|
+
w.setAttribute(A_WRAPID, String(id));
|
|
372
|
+
w.setAttribute(A_CREATED, String(ts()));
|
|
373
|
+
w.setAttribute(A_SHOWN, '0');
|
|
308
374
|
w.style.cssText = 'width:100%;display:block;';
|
|
309
375
|
const ph = document.createElement('div');
|
|
310
376
|
ph.id = `${PH_PREFIX}${id}`;
|
|
@@ -337,8 +403,44 @@
|
|
|
337
403
|
} catch (_) {}
|
|
338
404
|
}
|
|
339
405
|
|
|
406
|
+
// ── Prune (topics de catégorie uniquement) ────────────────────────────────
|
|
407
|
+
//
|
|
408
|
+
// Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
|
|
409
|
+
//
|
|
410
|
+
// Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
|
|
411
|
+
// les li[component="category/topic"] restent dans le DOM pendant toute
|
|
412
|
+
// la session. Un wrap orphelin (ancre absente) signifie vraiment que le
|
|
413
|
+
// topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
|
|
414
|
+
// liste après un long scroll et bloquent les nouvelles injections.
|
|
415
|
+
//
|
|
416
|
+
// Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
|
|
417
|
+
// NodeBB virtualise les posts hors-viewport — il les retire puis les
|
|
418
|
+
// réinsère. pruneOrphans verrait des ancres temporairement absentes,
|
|
419
|
+
// supprimerait les wraps, et provoquerait une réinjection en haut.
|
|
420
|
+
|
|
421
|
+
function pruneOrphansBetween() {
|
|
422
|
+
const klass = 'ezoic-ad-between';
|
|
423
|
+
const cfg = KIND[klass];
|
|
424
|
+
|
|
425
|
+
document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
|
|
426
|
+
const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
|
|
427
|
+
if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
|
|
428
|
+
|
|
429
|
+
const key = w.getAttribute(A_ANCHOR) ?? '';
|
|
430
|
+
const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
|
|
431
|
+
if (!sid) { mutate(() => dropWrap(w)); return; }
|
|
432
|
+
|
|
433
|
+
const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
|
|
434
|
+
if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
340
438
|
// ── Injection ──────────────────────────────────────────────────────────────
|
|
341
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Ordinal 0-based pour le calcul de l'intervalle d'injection.
|
|
442
|
+
* Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
|
|
443
|
+
*/
|
|
342
444
|
function ordinal(klass, el) {
|
|
343
445
|
const attr = KIND[klass]?.ordinalAttr;
|
|
344
446
|
if (attr) {
|
|
@@ -357,14 +459,18 @@
|
|
|
357
459
|
function injectBetween(klass, items, interval, showFirst, poolKey) {
|
|
358
460
|
if (!items.length) return 0;
|
|
359
461
|
let inserted = 0;
|
|
462
|
+
|
|
360
463
|
for (const el of items) {
|
|
361
464
|
if (inserted >= MAX_INSERTS_RUN) break;
|
|
362
465
|
if (!el?.isConnected) continue;
|
|
466
|
+
|
|
363
467
|
const ord = ordinal(klass, el);
|
|
364
468
|
if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
|
|
365
469
|
if (adjacentWrap(el)) continue;
|
|
470
|
+
|
|
366
471
|
const key = anchorKey(klass, el);
|
|
367
472
|
if (findWrap(key)) continue;
|
|
473
|
+
|
|
368
474
|
const id = pickId(poolKey);
|
|
369
475
|
if (id) {
|
|
370
476
|
const w = insertAfter(el, id, klass, key);
|
|
@@ -381,10 +487,7 @@
|
|
|
381
487
|
// ── IntersectionObserver & Show ────────────────────────────────────────────
|
|
382
488
|
|
|
383
489
|
function getIO() {
|
|
384
|
-
|
|
385
|
-
if (S.io && _ioMobile === mobile) return S.io;
|
|
386
|
-
if (S.io) { try { S.io.disconnect(); } catch (_) {} S.io = null; }
|
|
387
|
-
_ioMobile = mobile;
|
|
490
|
+
if (S.io) return S.io;
|
|
388
491
|
try {
|
|
389
492
|
S.io = new IntersectionObserver(entries => {
|
|
390
493
|
for (const e of entries) {
|
|
@@ -393,7 +496,7 @@
|
|
|
393
496
|
const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
|
|
394
497
|
if (Number.isFinite(id) && id > 0) enqueueShow(id);
|
|
395
498
|
}
|
|
396
|
-
}, { root: null, rootMargin:
|
|
499
|
+
}, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
|
|
397
500
|
} catch (_) { S.io = null; }
|
|
398
501
|
return S.io;
|
|
399
502
|
}
|
|
@@ -433,18 +536,24 @@
|
|
|
433
536
|
drainQueue();
|
|
434
537
|
};
|
|
435
538
|
const timer = setTimeout(release, 7000);
|
|
539
|
+
|
|
436
540
|
requestAnimationFrame(() => {
|
|
437
541
|
try {
|
|
438
542
|
if (isBlocked()) { clearTimeout(timer); return release(); }
|
|
439
543
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
440
544
|
if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
|
|
545
|
+
|
|
441
546
|
const t = ts();
|
|
442
547
|
if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
|
|
443
548
|
S.lastShow.set(id, t);
|
|
549
|
+
|
|
550
|
+
try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
|
|
551
|
+
|
|
444
552
|
window.ezstandalone = window.ezstandalone || {};
|
|
445
553
|
const ez = window.ezstandalone;
|
|
446
554
|
const doShow = () => {
|
|
447
555
|
try { ez.showAds(id); } catch (_) {}
|
|
556
|
+
scheduleEmptyCheck(id, t);
|
|
448
557
|
setTimeout(() => { clearTimeout(timer); release(); }, 700);
|
|
449
558
|
};
|
|
450
559
|
Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
|
|
@@ -452,10 +561,23 @@
|
|
|
452
561
|
});
|
|
453
562
|
}
|
|
454
563
|
|
|
564
|
+
function scheduleEmptyCheck(id, showTs) {
|
|
565
|
+
setTimeout(() => {
|
|
566
|
+
try {
|
|
567
|
+
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
568
|
+
const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
|
|
569
|
+
if (!wrap || !ph?.isConnected) return;
|
|
570
|
+
if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
|
|
571
|
+
wrap.classList.toggle('is-empty', !isFilled(ph));
|
|
572
|
+
} catch (_) {}
|
|
573
|
+
}, EMPTY_CHECK_MS);
|
|
574
|
+
}
|
|
575
|
+
|
|
455
576
|
// ── Patch Ezoic showAds ────────────────────────────────────────────────────
|
|
456
577
|
//
|
|
457
|
-
// Intercepte ez.showAds() pour
|
|
458
|
-
//
|
|
578
|
+
// Intercepte ez.showAds() pour :
|
|
579
|
+
// – ignorer les appels pendant blockedUntil
|
|
580
|
+
// – filtrer les ids dont le placeholder n'est pas en DOM
|
|
459
581
|
|
|
460
582
|
function patchShowAds() {
|
|
461
583
|
const apply = () => {
|
|
@@ -491,19 +613,37 @@
|
|
|
491
613
|
async function runCore() {
|
|
492
614
|
if (isBlocked()) return 0;
|
|
493
615
|
patchShowAds();
|
|
616
|
+
|
|
494
617
|
const cfg = await fetchConfig();
|
|
495
618
|
if (!cfg || cfg.excluded) return 0;
|
|
496
619
|
initPools(cfg);
|
|
620
|
+
|
|
497
621
|
const kind = getKind();
|
|
498
622
|
if (kind === 'other') return 0;
|
|
623
|
+
|
|
499
624
|
const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
|
|
500
625
|
if (!normBool(cfgEnable)) return 0;
|
|
501
626
|
const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
|
|
502
627
|
return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
|
|
503
628
|
};
|
|
504
|
-
|
|
505
|
-
if (kind === '
|
|
506
|
-
|
|
629
|
+
|
|
630
|
+
if (kind === 'topic') return exec(
|
|
631
|
+
'ezoic-ad-message', getPosts,
|
|
632
|
+
cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (kind === 'categoryTopics') {
|
|
636
|
+
pruneOrphansBetween();
|
|
637
|
+
return exec(
|
|
638
|
+
'ezoic-ad-between', getTopics,
|
|
639
|
+
cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return exec(
|
|
644
|
+
'ezoic-ad-categories', getCategories,
|
|
645
|
+
cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
|
|
646
|
+
);
|
|
507
647
|
}
|
|
508
648
|
|
|
509
649
|
// ── Scheduler ──────────────────────────────────────────────────────────────
|
|
@@ -527,9 +667,11 @@
|
|
|
527
667
|
S.lastBurstTs = t;
|
|
528
668
|
S.pageKey = pageKey();
|
|
529
669
|
S.burstDeadline = t + 2000;
|
|
670
|
+
|
|
530
671
|
if (S.burstActive) return;
|
|
531
672
|
S.burstActive = true;
|
|
532
673
|
S.burstCount = 0;
|
|
674
|
+
|
|
533
675
|
const step = () => {
|
|
534
676
|
if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
|
|
535
677
|
S.burstActive = false; return;
|
|
@@ -548,27 +690,21 @@
|
|
|
548
690
|
function cleanup() {
|
|
549
691
|
blockedUntil = ts() + 1500;
|
|
550
692
|
mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
|
|
551
|
-
S.cfg
|
|
552
|
-
|
|
553
|
-
S.
|
|
554
|
-
S.
|
|
555
|
-
S.cursors = { topics: 0, posts: 0, categories: 0 };
|
|
693
|
+
S.cfg = null;
|
|
694
|
+
S.poolsReady = false;
|
|
695
|
+
S.pools = { topics: [], posts: [], categories: [] };
|
|
696
|
+
S.cursors = { topics: 0, posts: 0, categories: 0 };
|
|
556
697
|
S.mountedIds.clear();
|
|
557
698
|
S.lastShow.clear();
|
|
558
699
|
S.wrapByKey.clear();
|
|
559
|
-
S.
|
|
560
|
-
S.
|
|
561
|
-
S.pending = [];
|
|
700
|
+
S.inflight = 0;
|
|
701
|
+
S.pending = [];
|
|
562
702
|
S.pendingSet.clear();
|
|
563
|
-
S.burstActive
|
|
564
|
-
S.runQueued
|
|
565
|
-
if (S.domObs) { S.domObs.disconnect(); S.domObs = null; }
|
|
566
|
-
// tcfObs intentionnellement préservé : l'iframe __tcfapiLocator doit
|
|
567
|
-
// exister en permanence — la déconnecter pendant la navigation cause
|
|
568
|
-
// des erreurs CMP postMessage et la disparition des pubs.
|
|
703
|
+
S.burstActive = false;
|
|
704
|
+
S.runQueued = false;
|
|
569
705
|
}
|
|
570
706
|
|
|
571
|
-
// ── MutationObserver
|
|
707
|
+
// ── MutationObserver ───────────────────────────────────────────────────────
|
|
572
708
|
|
|
573
709
|
function ensureDomObserver() {
|
|
574
710
|
if (S.domObs) return;
|
|
@@ -578,8 +714,9 @@
|
|
|
578
714
|
for (const m of muts) {
|
|
579
715
|
for (const n of m.addedNodes) {
|
|
580
716
|
if (n.nodeType !== 1) continue;
|
|
581
|
-
|
|
582
|
-
|
|
717
|
+
// matches() d'abord (O(1)), querySelector() seulement si nécessaire
|
|
718
|
+
if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
|
|
719
|
+
allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
|
|
583
720
|
requestBurst(); return;
|
|
584
721
|
}
|
|
585
722
|
}
|
|
@@ -599,7 +736,6 @@
|
|
|
599
736
|
'cannot call refresh on the same page',
|
|
600
737
|
'no placeholders are currently defined in Refresh',
|
|
601
738
|
'Debugger iframe already exists',
|
|
602
|
-
'[CMP] Error in custom getTCData',
|
|
603
739
|
`with id ${PH_PREFIX}`,
|
|
604
740
|
];
|
|
605
741
|
for (const m of ['log', 'info', 'warn', 'error']) {
|
|
@@ -622,9 +758,9 @@
|
|
|
622
758
|
(document.body || document.documentElement).appendChild(f);
|
|
623
759
|
};
|
|
624
760
|
inject();
|
|
625
|
-
if (!
|
|
626
|
-
|
|
627
|
-
|
|
761
|
+
if (!window.__nbbTcfObs) {
|
|
762
|
+
window.__nbbTcfObs = new MutationObserver(inject);
|
|
763
|
+
window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
|
|
628
764
|
}
|
|
629
765
|
} catch (_) {}
|
|
630
766
|
}
|
|
@@ -656,19 +792,22 @@
|
|
|
656
792
|
function bindNodeBB() {
|
|
657
793
|
const $ = window.jQuery;
|
|
658
794
|
if (!$) return;
|
|
795
|
+
|
|
659
796
|
$(window).off('.nbbEzoic');
|
|
660
797
|
$(window).on('action:ajaxify.start.nbbEzoic', cleanup);
|
|
661
798
|
$(window).on('action:ajaxify.end.nbbEzoic', () => {
|
|
662
799
|
S.pageKey = pageKey();
|
|
663
800
|
blockedUntil = 0;
|
|
664
|
-
muteConsole(); ensureTcfLocator();
|
|
801
|
+
muteConsole(); ensureTcfLocator(); warmNetwork();
|
|
665
802
|
patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
|
|
666
803
|
});
|
|
804
|
+
|
|
667
805
|
const burstEvts = [
|
|
668
|
-
'action:ajaxify.contentLoaded', 'action:posts.loaded',
|
|
669
|
-
'action:categories.loaded',
|
|
806
|
+
'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
|
|
807
|
+
'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
|
|
670
808
|
].map(e => `${e}.nbbEzoic`).join(' ');
|
|
671
809
|
$(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
|
|
810
|
+
|
|
672
811
|
try {
|
|
673
812
|
require(['hooks'], hooks => {
|
|
674
813
|
if (typeof hooks?.on !== 'function') return;
|
|
@@ -687,11 +826,6 @@
|
|
|
687
826
|
ticking = true;
|
|
688
827
|
requestAnimationFrame(() => { ticking = false; requestBurst(); });
|
|
689
828
|
}, { passive: true });
|
|
690
|
-
let resizeTimer = 0;
|
|
691
|
-
window.addEventListener('resize', () => {
|
|
692
|
-
clearTimeout(resizeTimer);
|
|
693
|
-
resizeTimer = setTimeout(getIO, 500);
|
|
694
|
-
}, { passive: true });
|
|
695
829
|
}
|
|
696
830
|
|
|
697
831
|
// ── Boot ───────────────────────────────────────────────────────────────────
|
package/public/style.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — style.css (
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — style.css (v20)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/* ── Wrapper ──────────────────────────────────────────────────────────────── */
|
|
@@ -56,15 +56,19 @@
|
|
|
56
56
|
top: auto !important;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/* ──
|
|
59
|
+
/* ── État vide ────────────────────────────────────────────────────────────── */
|
|
60
60
|
/*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
ezoic-ad-message (posts) et ezoic-ad-categories n'ont pas de hauteur fixe
|
|
64
|
-
car leur format varie — seul between (entre topics) est standardisé.
|
|
61
|
+
Ajouté 20s après showAds si aucun fill détecté.
|
|
62
|
+
Collapse à 1px (pas 0) : reste observable par l'IO si le fill arrive tard.
|
|
65
63
|
*/
|
|
66
|
-
.nodebb-ezoic-wrap.
|
|
67
|
-
|
|
64
|
+
.nodebb-ezoic-wrap.is-empty {
|
|
65
|
+
display: block !important;
|
|
66
|
+
height: 1px !important;
|
|
67
|
+
min-height: 1px !important;
|
|
68
|
+
max-height: 1px !important;
|
|
69
|
+
margin: 0 !important;
|
|
70
|
+
padding: 0 !important;
|
|
71
|
+
overflow: hidden !important;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
|