nodebb-plugin-ezoic-infinite 1.8.17 → 1.8.18
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 +120 -51
- package/package.json +2 -2
- package/plugin.json +10 -26
- package/public/client.js +353 -255
package/library.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const meta
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
4
4
|
const groups = require.main.require('./src/groups');
|
|
5
|
-
const db
|
|
5
|
+
const db = require.main.require('./src/database');
|
|
6
6
|
|
|
7
7
|
const SETTINGS_KEY = 'ezoic-infinite';
|
|
8
8
|
const plugin = {};
|
|
9
9
|
|
|
10
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
10
12
|
function normalizeExcludedGroups(value) {
|
|
11
13
|
if (!value) return [];
|
|
12
14
|
if (Array.isArray(value)) return value;
|
|
13
|
-
|
|
15
|
+
// NodeBB stocke les settings multi-valeurs comme string JSON "[\"group1\",\"group2\"]"
|
|
16
|
+
const s = String(value).trim();
|
|
17
|
+
if (s.startsWith('[')) {
|
|
18
|
+
try { const parsed = JSON.parse(s); if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); } catch (_) {}
|
|
19
|
+
}
|
|
20
|
+
// Fallback : séparation par virgule
|
|
21
|
+
return s.split(',').map(v => v.trim()).filter(Boolean);
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
function parseBool(v, def = false) {
|
|
@@ -27,14 +35,16 @@ async function getAllGroups() {
|
|
|
27
35
|
}
|
|
28
36
|
const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
|
|
29
37
|
const data = await groups.getGroupsData(filtered);
|
|
30
|
-
// Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
|
|
31
38
|
const valid = data.filter(g => g && g.name);
|
|
32
39
|
valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
|
|
33
40
|
return valid;
|
|
34
41
|
}
|
|
35
|
-
|
|
42
|
+
|
|
43
|
+
// ── Settings cache (30s TTL) ────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
let _settingsCache = null;
|
|
36
46
|
let _settingsCacheAt = 0;
|
|
37
|
-
const SETTINGS_TTL
|
|
47
|
+
const SETTINGS_TTL = 30_000;
|
|
38
48
|
|
|
39
49
|
async function getSettings() {
|
|
40
50
|
const now = Date.now();
|
|
@@ -42,25 +52,19 @@ async function getSettings() {
|
|
|
42
52
|
const s = await meta.settings.get(SETTINGS_KEY);
|
|
43
53
|
_settingsCacheAt = Date.now();
|
|
44
54
|
_settingsCache = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Home/categories list ads (between categories on / or /categories)
|
|
52
|
-
enableCategoryAds: parseBool(s.enableCategoryAds, false),
|
|
53
|
-
showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
|
|
55
|
+
enableBetweenAds: parseBool(s.enableBetweenAds, true),
|
|
56
|
+
showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
|
|
57
|
+
placeholderIds: (s.placeholderIds || '').trim(),
|
|
58
|
+
intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
|
|
59
|
+
enableCategoryAds: parseBool(s.enableCategoryAds, false),
|
|
60
|
+
showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
|
|
54
61
|
categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
|
|
55
|
-
intervalCategories:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
|
|
62
|
-
|
|
63
|
-
excludedGroups: normalizeExcludedGroups(s.excludedGroups),
|
|
62
|
+
intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
|
|
63
|
+
enableMessageAds: parseBool(s.enableMessageAds, false),
|
|
64
|
+
showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
|
|
65
|
+
messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
|
|
66
|
+
messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
|
|
67
|
+
excludedGroups: normalizeExcludedGroups(s.excludedGroups),
|
|
64
68
|
};
|
|
65
69
|
return _settingsCache;
|
|
66
70
|
}
|
|
@@ -71,58 +75,123 @@ async function isUserExcluded(uid, excludedGroups) {
|
|
|
71
75
|
return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
// ── Groups cache (5 min TTL) ───────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
let _groupsCache = null;
|
|
83
|
+
let _groupsCacheAt = 0;
|
|
84
|
+
const GROUPS_TTL = 5 * 60_000;
|
|
85
|
+
|
|
86
|
+
async function getAllGroupsCached() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
|
|
89
|
+
const g = await getAllGroups();
|
|
90
|
+
_groupsCache = g;
|
|
91
|
+
_groupsCacheAt = Date.now();
|
|
92
|
+
return _groupsCache;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Exclusion cache (per uid, 30s TTL) ─────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const _excludedCache = new Map(); // uid -> { v:boolean, t:number, sig:string }
|
|
98
|
+
const EXCLUDED_TTL = 30_000;
|
|
99
|
+
const EXCLUDED_MAX = 10_000;
|
|
100
|
+
|
|
101
|
+
function excludedSig(excludedGroups) {
|
|
102
|
+
// signature stable to invalidate when groups list changes
|
|
103
|
+
return excludedGroups.join('\u0001');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function isUserExcludedCached(uid, excludedGroups) {
|
|
107
|
+
if (!uid || !excludedGroups.length) return false;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const sig = excludedSig(excludedGroups);
|
|
110
|
+
const hit = _excludedCache.get(uid);
|
|
111
|
+
if (hit && hit.sig === sig && (now - hit.t) < EXCLUDED_TTL) return hit.v;
|
|
112
|
+
const v = await isUserExcluded(uid, excludedGroups);
|
|
113
|
+
if (_excludedCache.size > EXCLUDED_MAX) _excludedCache.clear();
|
|
114
|
+
_excludedCache.set(uid, { v, t: now, sig });
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
// ── Scripts Ezoic ──────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const EZOIC_SCRIPTS = `<script data-cfasync="false" src="https://cmp.gatekeeperconsent.com/min.js"></script>
|
|
120
|
+
<script data-cfasync="false" src="https://the.gatekeeperconsent.com/cmp.min.js"></script>
|
|
121
|
+
<script async src="//www.ezojs.com/ezoic/sa.min.js"></script>
|
|
122
|
+
<script>
|
|
123
|
+
window.ezstandalone = window.ezstandalone || {};
|
|
124
|
+
ezstandalone.cmd = ezstandalone.cmd || [];
|
|
125
|
+
</script>`;
|
|
126
|
+
|
|
127
|
+
// ── Hooks ──────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
74
129
|
plugin.onSettingsSet = function (data) {
|
|
75
|
-
|
|
76
|
-
if (data && data.hash === SETTINGS_KEY) {
|
|
77
|
-
_settingsCache = null;
|
|
78
|
-
}
|
|
130
|
+
if (data && data.hash === SETTINGS_KEY) { _settingsCache = null; _groupsCache = null; _excludedCache.clear(); }
|
|
79
131
|
};
|
|
80
132
|
|
|
81
133
|
plugin.addAdminNavigation = async (header) => {
|
|
82
134
|
header.plugins = header.plugins || [];
|
|
83
|
-
header.plugins.push({
|
|
84
|
-
route: '/plugins/ezoic-infinite',
|
|
85
|
-
icon: 'fa-ad',
|
|
86
|
-
name: 'Ezoic Infinite Ads'
|
|
87
|
-
});
|
|
135
|
+
header.plugins.push({ route: '/plugins/ezoic-infinite', icon: 'fa-ad', name: 'Ezoic Infinite Ads' });
|
|
88
136
|
return header;
|
|
89
137
|
};
|
|
90
138
|
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Injecte les scripts Ezoic dans le <head> via templateData.customHTML.
|
|
141
|
+
*
|
|
142
|
+
* NodeBB v4 / thème Harmony : header.tpl contient {{customHTML}} dans le <head>
|
|
143
|
+
* (render.js ligne 232 : templateValues.customHTML = meta.config.customHTML).
|
|
144
|
+
* Le hook filter:middleware.renderHeader reçoit templateData = headerFooterData
|
|
145
|
+
* et est rendu via req.app.renderAsync('header', hookReturn.templateData).
|
|
146
|
+
* On préfixe customHTML pour que nos scripts passent AVANT le customHTML admin,
|
|
147
|
+
* tout en préservant ce dernier.
|
|
148
|
+
*/
|
|
149
|
+
plugin.injectEzoicHead = async (data) => {
|
|
150
|
+
try {
|
|
93
151
|
const settings = await getSettings();
|
|
94
|
-
const
|
|
152
|
+
const uid = data.req?.uid ?? 0;
|
|
153
|
+
const excluded = await isUserExcludedCached(uid, settings.excludedGroups);
|
|
154
|
+
if (!excluded) {
|
|
155
|
+
// Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
|
|
156
|
+
data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
|
|
157
|
+
}
|
|
158
|
+
} catch (_) {}
|
|
159
|
+
return data;
|
|
160
|
+
};
|
|
95
161
|
|
|
162
|
+
plugin.init = async ({ router, middleware }) => {
|
|
163
|
+
async function render(req, res) {
|
|
164
|
+
const settings = await getSettings();
|
|
165
|
+
const allGroups = await getAllGroupsCached();
|
|
96
166
|
res.render('admin/plugins/ezoic-infinite', {
|
|
97
167
|
title: 'Ezoic Infinite Ads',
|
|
98
168
|
...settings,
|
|
99
169
|
enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
|
|
100
|
-
enableMessageAds_checked:
|
|
170
|
+
enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
|
|
101
171
|
allGroups,
|
|
102
172
|
});
|
|
103
173
|
}
|
|
104
174
|
|
|
105
|
-
router.get('/admin/plugins/ezoic-infinite',
|
|
175
|
+
router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
|
|
106
176
|
router.get('/api/admin/plugins/ezoic-infinite', render);
|
|
107
177
|
|
|
108
178
|
router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
|
|
109
179
|
const settings = await getSettings();
|
|
110
|
-
const excluded = await
|
|
111
|
-
|
|
180
|
+
const excluded = await isUserExcludedCached(req.uid, settings.excludedGroups);
|
|
112
181
|
res.json({
|
|
113
182
|
excluded,
|
|
114
|
-
enableBetweenAds:
|
|
115
|
-
showFirstTopicAd:
|
|
116
|
-
placeholderIds:
|
|
117
|
-
intervalPosts:
|
|
118
|
-
enableCategoryAds:
|
|
119
|
-
showFirstCategoryAd:
|
|
183
|
+
enableBetweenAds: settings.enableBetweenAds,
|
|
184
|
+
showFirstTopicAd: settings.showFirstTopicAd,
|
|
185
|
+
placeholderIds: settings.placeholderIds,
|
|
186
|
+
intervalPosts: settings.intervalPosts,
|
|
187
|
+
enableCategoryAds: settings.enableCategoryAds,
|
|
188
|
+
showFirstCategoryAd: settings.showFirstCategoryAd,
|
|
120
189
|
categoryPlaceholderIds: settings.categoryPlaceholderIds,
|
|
121
|
-
intervalCategories:
|
|
122
|
-
enableMessageAds:
|
|
123
|
-
showFirstMessageAd:
|
|
124
|
-
messagePlaceholderIds:
|
|
125
|
-
messageIntervalPosts:
|
|
190
|
+
intervalCategories: settings.intervalCategories,
|
|
191
|
+
enableMessageAds: settings.enableMessageAds,
|
|
192
|
+
showFirstMessageAd: settings.showFirstMessageAd,
|
|
193
|
+
messagePlaceholderIds: settings.messagePlaceholderIds,
|
|
194
|
+
messageIntervalPosts: settings.messageIntervalPosts,
|
|
126
195
|
});
|
|
127
196
|
});
|
|
128
197
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebb-plugin-ezoic-infinite",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.18",
|
|
4
4
|
"description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
|
|
5
5
|
"main": "library.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,4 +18,4 @@
|
|
|
18
18
|
"compatibility": "^4.0.0"
|
|
19
19
|
},
|
|
20
20
|
"private": false
|
|
21
|
-
}
|
|
21
|
+
}
|
package/plugin.json
CHANGED
|
@@ -4,30 +4,14 @@
|
|
|
4
4
|
"description": "Ezoic ads with infinite scroll using a pool of placeholder IDs",
|
|
5
5
|
"library": "./library.js",
|
|
6
6
|
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
{
|
|
12
|
-
"hook": "filter:admin.header.build",
|
|
13
|
-
"method": "addAdminNavigation"
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"hook": "action:settings.set",
|
|
17
|
-
"method": "onSettingsSet"
|
|
18
|
-
}
|
|
7
|
+
{ "hook": "static:app.load", "method": "init" },
|
|
8
|
+
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
|
|
9
|
+
{ "hook": "action:settings.set", "method": "onSettingsSet" },
|
|
10
|
+
{ "hook": "filter:middleware.renderHeader","method": "injectEzoicHead" }
|
|
19
11
|
],
|
|
20
|
-
"staticDirs": {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"scripts": [
|
|
27
|
-
"public/client.js"
|
|
28
|
-
],
|
|
29
|
-
"templates": "public/templates",
|
|
30
|
-
"css": [
|
|
31
|
-
"public/style.css"
|
|
32
|
-
]
|
|
33
|
-
}
|
|
12
|
+
"staticDirs": { "public": "public" },
|
|
13
|
+
"acpScripts": [ "public/admin.js" ],
|
|
14
|
+
"scripts": [ "public/client.js" ],
|
|
15
|
+
"templates": "public/templates",
|
|
16
|
+
"css": [ "public/style.css" ]
|
|
17
|
+
}
|
package/public/client.js
CHANGED
|
@@ -1,55 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — client.js
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — client.js v36
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
|
|
8
|
-
* `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
|
|
9
|
-
* → anchorEl toujours null → suppression à chaque runCore() → disparition.
|
|
10
|
-
* Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
|
|
11
|
-
* stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
|
|
4
|
+
* Historique des corrections majeures
|
|
5
|
+
* ────────────────────────────────────
|
|
6
|
+
* v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
|
|
12
7
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
|
|
9
|
+
* la position dans le batch courant.
|
|
15
10
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
* Fix : marge large fixe par device, observer créé une seule fois.
|
|
16
|
+
* v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
|
|
21
17
|
*
|
|
22
|
-
*
|
|
23
|
-
* Fix : 200ms.
|
|
18
|
+
* v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
|
|
24
19
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
67
|
*/
|
|
32
|
-
(function () {
|
|
68
|
+
(function nbbEzoicInfinite() {
|
|
33
69
|
'use strict';
|
|
34
70
|
|
|
35
71
|
// ── Constantes ─────────────────────────────────────────────────────────────
|
|
36
72
|
|
|
37
|
-
const WRAP_CLASS
|
|
38
|
-
const PH_PREFIX
|
|
39
|
-
const A_ANCHOR
|
|
40
|
-
const A_WRAPID
|
|
41
|
-
const A_CREATED
|
|
42
|
-
const A_SHOWN
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Marges IO larges et fixes (pas de reconstruction d'observer)
|
|
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
|
|
53
88
|
const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
|
|
54
89
|
const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
|
|
55
90
|
|
|
@@ -60,40 +95,38 @@
|
|
|
60
95
|
};
|
|
61
96
|
|
|
62
97
|
/**
|
|
63
|
-
* Table
|
|
98
|
+
* Table KIND — source de vérité par kindClass.
|
|
64
99
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
100
|
+
* sel sélecteur CSS complet des éléments cibles
|
|
101
|
+
* baseTag préfixe tag pour querySelector d'ancre
|
|
102
|
+
* (vide pour posts : le sélecteur commence par '[')
|
|
103
|
+
* anchorAttr attribut DOM stable → clé unique du wrap
|
|
104
|
+
* ordinalAttr attribut 0-based pour le calcul de l'intervalle
|
|
105
|
+
* null → fallback positionnel (catégories)
|
|
71
106
|
*/
|
|
72
107
|
const KIND = {
|
|
73
|
-
'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
|
|
74
|
-
'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
|
|
75
|
-
'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
|
|
108
|
+
'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
|
|
109
|
+
'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
|
|
110
|
+
'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
|
|
76
111
|
};
|
|
77
112
|
|
|
78
|
-
// ── État
|
|
113
|
+
// ── État global ────────────────────────────────────────────────────────────
|
|
79
114
|
|
|
80
115
|
const S = {
|
|
81
|
-
pageKey:
|
|
82
|
-
cfg:
|
|
83
|
-
|
|
84
|
-
pools:
|
|
85
|
-
cursors:
|
|
86
|
-
mountedIds:
|
|
87
|
-
lastShow:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
pendingSet: new Set(),
|
|
96
|
-
|
|
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
|
|
97
130
|
runQueued: false,
|
|
98
131
|
burstActive: false,
|
|
99
132
|
burstDeadline: 0,
|
|
@@ -102,8 +135,12 @@
|
|
|
102
135
|
};
|
|
103
136
|
|
|
104
137
|
let blockedUntil = 0;
|
|
105
|
-
|
|
106
|
-
const ts
|
|
138
|
+
|
|
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]'));
|
|
107
144
|
|
|
108
145
|
function mutate(fn) {
|
|
109
146
|
S.mutGuard++;
|
|
@@ -121,27 +158,22 @@
|
|
|
121
158
|
return S.cfg;
|
|
122
159
|
}
|
|
123
160
|
|
|
124
|
-
function initPools(cfg) {
|
|
125
|
-
S.pools.topics = parseIds(cfg.placeholderIds);
|
|
126
|
-
S.pools.posts = parseIds(cfg.messagePlaceholderIds);
|
|
127
|
-
S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
161
|
function parseIds(raw) {
|
|
131
162
|
const out = [], seen = new Set();
|
|
132
|
-
for (const v of String(raw || '').split(
|
|
163
|
+
for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
|
|
133
164
|
const n = parseInt(v, 10);
|
|
134
165
|
if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
135
166
|
}
|
|
136
167
|
return out;
|
|
137
168
|
}
|
|
138
169
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
function initPools(cfg) {
|
|
171
|
+
if (S.poolsReady) return;
|
|
172
|
+
S.pools.topics = parseIds(cfg.placeholderIds);
|
|
173
|
+
S.pools.posts = parseIds(cfg.messagePlaceholderIds);
|
|
174
|
+
S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
|
|
175
|
+
S.poolsReady = true;
|
|
176
|
+
}
|
|
145
177
|
|
|
146
178
|
// ── Page identity ──────────────────────────────────────────────────────────
|
|
147
179
|
|
|
@@ -165,13 +197,13 @@
|
|
|
165
197
|
return 'other';
|
|
166
198
|
}
|
|
167
199
|
|
|
168
|
-
// ── DOM
|
|
200
|
+
// ── Items DOM ──────────────────────────────────────────────────────────────
|
|
169
201
|
|
|
170
202
|
function getPosts() {
|
|
171
203
|
return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
|
|
172
204
|
if (!el.isConnected) return false;
|
|
173
205
|
if (!el.querySelector('[component="post/content"]')) return false;
|
|
174
|
-
const p = el.parentElement?.closest(
|
|
206
|
+
const p = el.parentElement?.closest(SEL.post);
|
|
175
207
|
if (p && p !== el) return false;
|
|
176
208
|
return el.getAttribute('component') !== 'post/parent';
|
|
177
209
|
});
|
|
@@ -180,53 +212,87 @@
|
|
|
180
212
|
const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
|
|
181
213
|
const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
|
|
182
214
|
|
|
215
|
+
// ── Wraps — détection ──────────────────────────────────────────────────────
|
|
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
|
+
*/
|
|
221
|
+
function wrapIsLive(wrap) {
|
|
222
|
+
if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
|
|
223
|
+
const key = wrap.getAttribute(A_ANCHOR);
|
|
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).
|
|
227
|
+
if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
|
|
228
|
+
// Fallback : registre pas encore à jour ou wrap non enregistré.
|
|
229
|
+
const colonIdx = key.indexOf(':');
|
|
230
|
+
const klass = key.slice(0, colonIdx);
|
|
231
|
+
const anchorId = key.slice(colonIdx + 1);
|
|
232
|
+
const cfg = KIND[klass];
|
|
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.
|
|
236
|
+
const parent = wrap.parentElement;
|
|
237
|
+
if (parent) {
|
|
238
|
+
for (const sib of parent.children) {
|
|
239
|
+
if (sib === wrap) continue;
|
|
240
|
+
try {
|
|
241
|
+
if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
|
|
242
|
+
return sib.isConnected;
|
|
243
|
+
}
|
|
244
|
+
} catch (_) {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Dernier recours : querySelector global
|
|
248
|
+
try {
|
|
249
|
+
const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
|
|
250
|
+
return !!(found?.isConnected);
|
|
251
|
+
} catch (_) { return false; }
|
|
252
|
+
}
|
|
253
|
+
|
|
183
254
|
function adjacentWrap(el) {
|
|
184
|
-
return
|
|
185
|
-
el.nextElementSibling?.classList?.contains(WRAP_CLASS) ||
|
|
186
|
-
el.previousElementSibling?.classList?.contains(WRAP_CLASS)
|
|
187
|
-
);
|
|
255
|
+
return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
|
|
188
256
|
}
|
|
189
257
|
|
|
190
|
-
// ── Ancres stables
|
|
258
|
+
// ── Ancres stables ─────────────────────────────────────────────────────────
|
|
191
259
|
|
|
192
260
|
/**
|
|
193
|
-
* Retourne
|
|
194
|
-
*
|
|
195
|
-
* Fallback positionnel si l'attribut est absent.
|
|
261
|
+
* Retourne la valeur de l'attribut stable pour cet élément,
|
|
262
|
+
* ou un fallback positionnel si l'attribut est absent.
|
|
196
263
|
*/
|
|
197
|
-
function stableId(
|
|
198
|
-
const attr = KIND[
|
|
264
|
+
function stableId(klass, el) {
|
|
265
|
+
const attr = KIND[klass]?.anchorAttr;
|
|
199
266
|
if (attr) {
|
|
200
267
|
const v = el.getAttribute(attr);
|
|
201
268
|
if (v !== null && v !== '') return v;
|
|
202
269
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
i++;
|
|
209
|
-
}
|
|
210
|
-
} catch (_) {}
|
|
270
|
+
let i = 0;
|
|
271
|
+
for (const s of el.parentElement?.children ?? []) {
|
|
272
|
+
if (s === el) return `i${i}`;
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
211
275
|
return 'i0';
|
|
212
276
|
}
|
|
213
277
|
|
|
214
|
-
const
|
|
278
|
+
const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
|
|
215
279
|
|
|
216
|
-
function findWrap(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
|
|
220
|
-
);
|
|
221
|
-
} catch (_) { return null; }
|
|
280
|
+
function findWrap(key) {
|
|
281
|
+
const w = S.wrapByKey.get(key);
|
|
282
|
+
return (w?.isConnected) ? w : null;
|
|
222
283
|
}
|
|
223
284
|
|
|
224
285
|
// ── Pool ───────────────────────────────────────────────────────────────────
|
|
225
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Retourne le prochain id disponible dans le pool (round-robin),
|
|
289
|
+
* ou null si tous les ids sont montés.
|
|
290
|
+
*/
|
|
226
291
|
function pickId(poolKey) {
|
|
227
292
|
const pool = S.pools[poolKey];
|
|
293
|
+
if (!pool.length) return null;
|
|
228
294
|
for (let t = 0; t < pool.length; t++) {
|
|
229
|
-
const i
|
|
295
|
+
const i = S.cursors[poolKey] % pool.length;
|
|
230
296
|
S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
|
|
231
297
|
const id = pool[i];
|
|
232
298
|
if (!S.mountedIds.has(id)) return id;
|
|
@@ -234,7 +300,69 @@
|
|
|
234
300
|
return null;
|
|
235
301
|
}
|
|
236
302
|
|
|
237
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Pool épuisé : recycle un wrap loin au-dessus du viewport.
|
|
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.
|
|
308
|
+
* Priorité : wraps vides d'abord, remplis si nécessaire.
|
|
309
|
+
*/
|
|
310
|
+
function recycleAndMove(klass, targetEl, newKey) {
|
|
311
|
+
const ez = window.ezstandalone;
|
|
312
|
+
if (typeof ez?.destroyPlaceholders !== 'function' ||
|
|
313
|
+
typeof ez?.define !== 'function' ||
|
|
314
|
+
typeof ez?.displayMore !== 'function') return null;
|
|
315
|
+
|
|
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;
|
|
320
|
+
let bestEmpty = null, bestEmptyBottom = Infinity;
|
|
321
|
+
let bestFilled = null, bestFilledBottom = Infinity;
|
|
322
|
+
|
|
323
|
+
document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
|
|
324
|
+
try {
|
|
325
|
+
const rect = wrap.getBoundingClientRect();
|
|
326
|
+
if (rect.bottom > threshold) return;
|
|
327
|
+
if (!isFilled(wrap)) {
|
|
328
|
+
if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
|
|
329
|
+
} else {
|
|
330
|
+
if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
|
|
331
|
+
}
|
|
332
|
+
} catch (_) {}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const best = bestEmpty ?? bestFilled;
|
|
336
|
+
if (!best) return null;
|
|
337
|
+
const id = parseInt(best.getAttribute(A_WRAPID), 10);
|
|
338
|
+
if (!Number.isFinite(id)) return null;
|
|
339
|
+
|
|
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.
|
|
343
|
+
try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
|
|
344
|
+
mutate(() => {
|
|
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');
|
|
349
|
+
const ph = best.querySelector(`#${PH_PREFIX}${id}`);
|
|
350
|
+
if (ph) ph.innerHTML = '';
|
|
351
|
+
targetEl.insertAdjacentElement('afterend', best);
|
|
352
|
+
});
|
|
353
|
+
if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
|
|
354
|
+
S.wrapByKey.set(newKey, best);
|
|
355
|
+
|
|
356
|
+
// Délais requis : destroyPlaceholders est asynchrone en interne
|
|
357
|
+
const doDestroy = () => { try { ez.destroyPlaceholders([id]); } catch (_) {} setTimeout(doDefine, 300); };
|
|
358
|
+
const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
|
|
359
|
+
const doDisplay = () => { try { ez.displayMore([id]); } catch (_) {} };
|
|
360
|
+
try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
|
|
361
|
+
|
|
362
|
+
return { id, wrap: best };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Wraps DOM — création / suppression ────────────────────────────────────
|
|
238
366
|
|
|
239
367
|
function makeWrap(id, klass, key) {
|
|
240
368
|
const w = document.createElement('div');
|
|
@@ -242,6 +370,7 @@
|
|
|
242
370
|
w.setAttribute(A_ANCHOR, key);
|
|
243
371
|
w.setAttribute(A_WRAPID, String(id));
|
|
244
372
|
w.setAttribute(A_CREATED, String(ts()));
|
|
373
|
+
w.setAttribute(A_SHOWN, '0');
|
|
245
374
|
w.style.cssText = 'width:100%;display:block;';
|
|
246
375
|
const ph = document.createElement('div');
|
|
247
376
|
ph.id = `${PH_PREFIX}${id}`;
|
|
@@ -251,112 +380,85 @@
|
|
|
251
380
|
}
|
|
252
381
|
|
|
253
382
|
function insertAfter(el, id, klass, key) {
|
|
254
|
-
if (!el?.insertAdjacentElement)
|
|
255
|
-
if (findWrap(key))
|
|
256
|
-
if (S.mountedIds.has(id))
|
|
383
|
+
if (!el?.insertAdjacentElement) return null;
|
|
384
|
+
if (findWrap(key)) return null;
|
|
385
|
+
if (S.mountedIds.has(id)) return null;
|
|
257
386
|
if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
|
|
258
387
|
const w = makeWrap(id, klass, key);
|
|
259
388
|
mutate(() => el.insertAdjacentElement('afterend', w));
|
|
260
389
|
S.mountedIds.add(id);
|
|
390
|
+
S.wrapByKey.set(key, w);
|
|
261
391
|
return w;
|
|
262
392
|
}
|
|
263
393
|
|
|
264
394
|
function dropWrap(w) {
|
|
265
395
|
try {
|
|
396
|
+
const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
397
|
+
if (ph instanceof Element) S.io?.unobserve(ph);
|
|
266
398
|
const id = parseInt(w.getAttribute(A_WRAPID), 10);
|
|
267
399
|
if (Number.isFinite(id)) S.mountedIds.delete(id);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// "parameter 1 is not of type Element" sur le prochain observe).
|
|
271
|
-
try {
|
|
272
|
-
const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
273
|
-
if (ph instanceof Element) S.io?.unobserve(ph);
|
|
274
|
-
} catch (_) {}
|
|
400
|
+
const key = w.getAttribute(A_ANCHOR);
|
|
401
|
+
if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
|
|
275
402
|
w.remove();
|
|
276
403
|
} catch (_) {}
|
|
277
404
|
}
|
|
278
405
|
|
|
279
|
-
// ── Prune
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
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
|
+
// Build a fast lookup of existing anchors once (avoid querySelector per wrap)
|
|
426
|
+
const anchors = new Set();
|
|
427
|
+
document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`).forEach(el => {
|
|
428
|
+
const v = el.getAttribute(cfg.anchorAttr);
|
|
429
|
+
if (v) anchors.add(String(v));
|
|
430
|
+
});
|
|
297
431
|
|
|
298
432
|
document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
|
|
299
|
-
|
|
433
|
+
const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
|
|
434
|
+
if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
|
|
300
435
|
|
|
301
436
|
const key = w.getAttribute(A_ANCHOR) ?? '';
|
|
302
|
-
const sid = key.slice(klass.length + 1); //
|
|
437
|
+
const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
|
|
303
438
|
if (!sid) { mutate(() => dropWrap(w)); return; }
|
|
304
439
|
|
|
305
|
-
|
|
306
|
-
`${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
|
|
307
|
-
);
|
|
308
|
-
if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
|
|
440
|
+
if (!anchors.has(String(sid))) mutate(() => dropWrap(w));
|
|
309
441
|
});
|
|
310
442
|
}
|
|
311
443
|
|
|
312
|
-
// ── Decluster ──────────────────────────────────────────────────────────────
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
|
|
316
|
-
* Priorité : filled > en grâce (fill en cours) > vide.
|
|
317
|
-
* Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
|
|
318
|
-
*/
|
|
319
|
-
function decluster(klass) {
|
|
320
|
-
for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
|
|
321
|
-
// Grace sur le wrap courant : on le saute entièrement
|
|
322
|
-
const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
|
|
323
|
-
if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
|
|
324
|
-
|
|
325
|
-
let prev = w.previousElementSibling, steps = 0;
|
|
326
|
-
while (prev && steps++ < 3) {
|
|
327
|
-
if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
|
|
328
|
-
|
|
329
|
-
const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
|
|
330
|
-
if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
|
|
331
|
-
|
|
332
|
-
if (!isFilled(w)) mutate(() => dropWrap(w));
|
|
333
|
-
else if (!isFilled(prev)) mutate(() => dropWrap(prev));
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
444
|
// ── Injection ──────────────────────────────────────────────────────────────
|
|
340
445
|
|
|
341
446
|
/**
|
|
342
|
-
* Ordinal 0-based pour le calcul de l'intervalle.
|
|
343
|
-
*
|
|
344
|
-
* Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
|
|
447
|
+
* Ordinal 0-based pour le calcul de l'intervalle d'injection.
|
|
448
|
+
* Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
|
|
345
449
|
*/
|
|
346
450
|
function ordinal(klass, el) {
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
} catch (_) {}
|
|
451
|
+
const attr = KIND[klass]?.ordinalAttr;
|
|
452
|
+
if (attr) {
|
|
453
|
+
const v = el.getAttribute(attr);
|
|
454
|
+
if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
|
|
455
|
+
}
|
|
456
|
+
const fullSel = KIND[klass]?.sel ?? '';
|
|
457
|
+
let i = 0;
|
|
458
|
+
for (const s of el.parentElement?.children ?? []) {
|
|
459
|
+
if (s === el) return i;
|
|
460
|
+
if (!fullSel || s.matches?.(fullSel)) i++;
|
|
461
|
+
}
|
|
360
462
|
return 0;
|
|
361
463
|
}
|
|
362
464
|
|
|
@@ -365,23 +467,25 @@
|
|
|
365
467
|
let inserted = 0;
|
|
366
468
|
|
|
367
469
|
for (const el of items) {
|
|
368
|
-
if (inserted >=
|
|
470
|
+
if (inserted >= MAX_INSERTS_RUN) break;
|
|
369
471
|
if (!el?.isConnected) continue;
|
|
370
472
|
|
|
371
|
-
const ord
|
|
372
|
-
|
|
373
|
-
if (!isTarget) continue;
|
|
374
|
-
|
|
473
|
+
const ord = ordinal(klass, el);
|
|
474
|
+
if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
|
|
375
475
|
if (adjacentWrap(el)) continue;
|
|
376
476
|
|
|
377
|
-
const key =
|
|
378
|
-
if (findWrap(key)) continue;
|
|
477
|
+
const key = anchorKey(klass, el);
|
|
478
|
+
if (findWrap(key)) continue;
|
|
379
479
|
|
|
380
480
|
const id = pickId(poolKey);
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
481
|
+
if (id) {
|
|
482
|
+
const w = insertAfter(el, id, klass, key);
|
|
483
|
+
if (w) { observePh(id); inserted++; }
|
|
484
|
+
} else {
|
|
485
|
+
const recycled = recycleAndMove(klass, el, key);
|
|
486
|
+
if (!recycled) break;
|
|
487
|
+
inserted++;
|
|
488
|
+
}
|
|
385
489
|
}
|
|
386
490
|
return inserted;
|
|
387
491
|
}
|
|
@@ -390,7 +494,6 @@
|
|
|
390
494
|
|
|
391
495
|
function getIO() {
|
|
392
496
|
if (S.io) return S.io;
|
|
393
|
-
const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
|
|
394
497
|
try {
|
|
395
498
|
S.io = new IntersectionObserver(entries => {
|
|
396
499
|
for (const e of entries) {
|
|
@@ -399,7 +502,7 @@
|
|
|
399
502
|
const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
|
|
400
503
|
if (Number.isFinite(id) && id > 0) enqueueShow(id);
|
|
401
504
|
}
|
|
402
|
-
}, { root: null, rootMargin:
|
|
505
|
+
}, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
|
|
403
506
|
} catch (_) { S.io = null; }
|
|
404
507
|
return S.io;
|
|
405
508
|
}
|
|
@@ -450,7 +553,6 @@
|
|
|
450
553
|
if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
|
|
451
554
|
S.lastShow.set(id, t);
|
|
452
555
|
|
|
453
|
-
// Horodater le show sur le wrap pour grace period + emptyCheck
|
|
454
556
|
try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
|
|
455
557
|
|
|
456
558
|
window.ezstandalone = window.ezstandalone || {};
|
|
@@ -471,7 +573,6 @@
|
|
|
471
573
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
472
574
|
const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
|
|
473
575
|
if (!wrap || !ph?.isConnected) return;
|
|
474
|
-
// Un show plus récent → ne pas toucher
|
|
475
576
|
if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
|
|
476
577
|
wrap.classList.toggle('is-empty', !isFilled(ph));
|
|
477
578
|
} catch (_) {}
|
|
@@ -479,6 +580,10 @@
|
|
|
479
580
|
}
|
|
480
581
|
|
|
481
582
|
// ── Patch Ezoic showAds ────────────────────────────────────────────────────
|
|
583
|
+
//
|
|
584
|
+
// Intercepte ez.showAds() pour :
|
|
585
|
+
// – ignorer les appels pendant blockedUntil
|
|
586
|
+
// – filtrer les ids dont le placeholder n'est pas en DOM
|
|
482
587
|
|
|
483
588
|
function patchShowAds() {
|
|
484
589
|
const apply = () => {
|
|
@@ -490,7 +595,7 @@
|
|
|
490
595
|
const orig = ez.showAds.bind(ez);
|
|
491
596
|
ez.showAds = function (...args) {
|
|
492
597
|
if (isBlocked()) return;
|
|
493
|
-
const ids
|
|
598
|
+
const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
|
|
494
599
|
const seen = new Set();
|
|
495
600
|
for (const v of ids) {
|
|
496
601
|
const id = parseInt(v, 10);
|
|
@@ -509,7 +614,7 @@
|
|
|
509
614
|
}
|
|
510
615
|
}
|
|
511
616
|
|
|
512
|
-
// ── Core
|
|
617
|
+
// ── Core ───────────────────────────────────────────────────────────────────
|
|
513
618
|
|
|
514
619
|
async function runCore() {
|
|
515
620
|
if (isBlocked()) return 0;
|
|
@@ -524,30 +629,30 @@
|
|
|
524
629
|
|
|
525
630
|
const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
|
|
526
631
|
if (!normBool(cfgEnable)) return 0;
|
|
527
|
-
const items = getItems();
|
|
528
632
|
const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
|
|
529
|
-
|
|
530
|
-
const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
|
|
531
|
-
if (n) decluster(klass);
|
|
532
|
-
return n;
|
|
633
|
+
return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
|
|
533
634
|
};
|
|
534
635
|
|
|
535
636
|
if (kind === 'topic') return exec(
|
|
536
637
|
'ezoic-ad-message', getPosts,
|
|
537
638
|
cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
|
|
538
639
|
);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
640
|
+
|
|
641
|
+
if (kind === 'categoryTopics') {
|
|
642
|
+
pruneOrphansBetween();
|
|
643
|
+
return exec(
|
|
644
|
+
'ezoic-ad-between', getTopics,
|
|
645
|
+
cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return exec(
|
|
544
650
|
'ezoic-ad-categories', getCategories,
|
|
545
651
|
cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
|
|
546
652
|
);
|
|
547
|
-
return 0;
|
|
548
653
|
}
|
|
549
654
|
|
|
550
|
-
// ── Scheduler
|
|
655
|
+
// ── Scheduler ──────────────────────────────────────────────────────────────
|
|
551
656
|
|
|
552
657
|
function scheduleRun(cb) {
|
|
553
658
|
if (S.runQueued) return;
|
|
@@ -565,10 +670,8 @@
|
|
|
565
670
|
if (isBlocked()) return;
|
|
566
671
|
const t = ts();
|
|
567
672
|
if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
|
|
568
|
-
S.lastBurstTs
|
|
569
|
-
|
|
570
|
-
const pk = pageKey();
|
|
571
|
-
S.pageKey = pk;
|
|
673
|
+
S.lastBurstTs = t;
|
|
674
|
+
S.pageKey = pageKey();
|
|
572
675
|
S.burstDeadline = t + 2000;
|
|
573
676
|
|
|
574
677
|
if (S.burstActive) return;
|
|
@@ -576,7 +679,7 @@
|
|
|
576
679
|
S.burstCount = 0;
|
|
577
680
|
|
|
578
681
|
const step = () => {
|
|
579
|
-
if (pageKey() !==
|
|
682
|
+
if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
|
|
580
683
|
S.burstActive = false; return;
|
|
581
684
|
}
|
|
582
685
|
S.burstCount++;
|
|
@@ -588,16 +691,18 @@
|
|
|
588
691
|
step();
|
|
589
692
|
}
|
|
590
693
|
|
|
591
|
-
// ── Cleanup
|
|
694
|
+
// ── Cleanup navigation ─────────────────────────────────────────────────────
|
|
592
695
|
|
|
593
696
|
function cleanup() {
|
|
594
697
|
blockedUntil = ts() + 1500;
|
|
595
698
|
mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
|
|
596
699
|
S.cfg = null;
|
|
700
|
+
S.poolsReady = false;
|
|
597
701
|
S.pools = { topics: [], posts: [], categories: [] };
|
|
598
702
|
S.cursors = { topics: 0, posts: 0, categories: 0 };
|
|
599
703
|
S.mountedIds.clear();
|
|
600
704
|
S.lastShow.clear();
|
|
705
|
+
S.wrapByKey.clear();
|
|
601
706
|
S.inflight = 0;
|
|
602
707
|
S.pending = [];
|
|
603
708
|
S.pendingSet.clear();
|
|
@@ -605,19 +710,19 @@
|
|
|
605
710
|
S.runQueued = false;
|
|
606
711
|
}
|
|
607
712
|
|
|
608
|
-
// ──
|
|
713
|
+
// ── MutationObserver ───────────────────────────────────────────────────────
|
|
609
714
|
|
|
610
715
|
function ensureDomObserver() {
|
|
611
716
|
if (S.domObs) return;
|
|
717
|
+
const allSel = [SEL.post, SEL.topic, SEL.category];
|
|
612
718
|
S.domObs = new MutationObserver(muts => {
|
|
613
719
|
if (S.mutGuard > 0 || isBlocked()) return;
|
|
614
720
|
for (const m of muts) {
|
|
615
|
-
if (!m.addedNodes?.length) continue;
|
|
616
721
|
for (const n of m.addedNodes) {
|
|
617
722
|
if (n.nodeType !== 1) continue;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
723
|
+
// matches() d'abord (O(1)), querySelector() seulement si nécessaire
|
|
724
|
+
if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
|
|
725
|
+
allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
|
|
621
726
|
requestBurst(); return;
|
|
622
727
|
}
|
|
623
728
|
}
|
|
@@ -631,7 +736,14 @@
|
|
|
631
736
|
function muteConsole() {
|
|
632
737
|
if (window.__nbbEzMuted) return;
|
|
633
738
|
window.__nbbEzMuted = true;
|
|
634
|
-
const MUTED = [
|
|
739
|
+
const MUTED = [
|
|
740
|
+
'[EzoicAds JS]: Placeholder Id',
|
|
741
|
+
'No valid placeholders for loadMore',
|
|
742
|
+
'cannot call refresh on the same page',
|
|
743
|
+
'no placeholders are currently defined in Refresh',
|
|
744
|
+
'Debugger iframe already exists',
|
|
745
|
+
`with id ${PH_PREFIX}`,
|
|
746
|
+
];
|
|
635
747
|
for (const m of ['log', 'info', 'warn', 'error']) {
|
|
636
748
|
const orig = console[m];
|
|
637
749
|
if (typeof orig !== 'function') continue;
|
|
@@ -643,29 +755,18 @@
|
|
|
643
755
|
}
|
|
644
756
|
|
|
645
757
|
function ensureTcfLocator() {
|
|
646
|
-
// Le CMP utilise une iframe nommée __tcfapiLocator pour router les
|
|
647
|
-
// postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
|
|
648
|
-
// iframe du DOM (vidage partiel du body), ce qui provoque :
|
|
649
|
-
// "Cannot read properties of null (reading 'postMessage')"
|
|
650
|
-
// "Cannot set properties of null (setting 'addtlConsent')"
|
|
651
|
-
// Solution : la recrée immédiatement si elle disparaît, via un observer.
|
|
652
758
|
try {
|
|
653
759
|
if (!window.__tcfapi && !window.__cmp) return;
|
|
654
|
-
|
|
655
760
|
const inject = () => {
|
|
656
761
|
if (document.getElementById('__tcfapiLocator')) return;
|
|
657
762
|
const f = document.createElement('iframe');
|
|
658
763
|
f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
|
|
659
764
|
(document.body || document.documentElement).appendChild(f);
|
|
660
765
|
};
|
|
661
|
-
|
|
662
766
|
inject();
|
|
663
|
-
|
|
664
|
-
// Observer dédié — si quelqu'un retire l'iframe, on la remet.
|
|
665
767
|
if (!window.__nbbTcfObs) {
|
|
666
|
-
window.__nbbTcfObs = new MutationObserver(
|
|
667
|
-
window.__nbbTcfObs.observe(document.documentElement,
|
|
668
|
-
{ childList: true, subtree: true });
|
|
768
|
+
window.__nbbTcfObs = new MutationObserver(inject);
|
|
769
|
+
window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
|
|
669
770
|
}
|
|
670
771
|
} catch (_) {}
|
|
671
772
|
}
|
|
@@ -675,10 +776,10 @@
|
|
|
675
776
|
const head = document.head;
|
|
676
777
|
if (!head) return;
|
|
677
778
|
for (const [rel, href, cors] of [
|
|
678
|
-
['preconnect', 'https://g.ezoic.net', true],
|
|
679
|
-
['preconnect', 'https://go.ezoic.net', true],
|
|
680
|
-
['preconnect', 'https://securepubads.g.doubleclick.net', true],
|
|
681
|
-
['preconnect', 'https://pagead2.googlesyndication.com', true],
|
|
779
|
+
['preconnect', 'https://g.ezoic.net', true ],
|
|
780
|
+
['preconnect', 'https://go.ezoic.net', true ],
|
|
781
|
+
['preconnect', 'https://securepubads.g.doubleclick.net', true ],
|
|
782
|
+
['preconnect', 'https://pagead2.googlesyndication.com', true ],
|
|
682
783
|
['dns-prefetch', 'https://g.ezoic.net', false],
|
|
683
784
|
['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
|
|
684
785
|
]) {
|
|
@@ -692,7 +793,7 @@
|
|
|
692
793
|
}
|
|
693
794
|
}
|
|
694
795
|
|
|
695
|
-
// ── Bindings
|
|
796
|
+
// ── Bindings ───────────────────────────────────────────────────────────────
|
|
696
797
|
|
|
697
798
|
function bindNodeBB() {
|
|
698
799
|
const $ = window.jQuery;
|
|
@@ -703,19 +804,16 @@
|
|
|
703
804
|
$(window).on('action:ajaxify.end.nbbEzoic', () => {
|
|
704
805
|
S.pageKey = pageKey();
|
|
705
806
|
blockedUntil = 0;
|
|
706
|
-
muteConsole(); ensureTcfLocator(); warmNetwork();
|
|
707
|
-
getIO(); ensureDomObserver(); requestBurst();
|
|
807
|
+
muteConsole(); ensureTcfLocator(); warmNetwork();
|
|
808
|
+
patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
|
|
708
809
|
});
|
|
709
810
|
|
|
710
|
-
const
|
|
711
|
-
'action:ajaxify.contentLoaded',
|
|
712
|
-
'action:posts.loaded', 'action:topics.loaded',
|
|
811
|
+
const burstEvts = [
|
|
812
|
+
'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
|
|
713
813
|
'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
|
|
714
814
|
].map(e => `${e}.nbbEzoic`).join(' ');
|
|
815
|
+
$(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
|
|
715
816
|
|
|
716
|
-
$(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
|
|
717
|
-
|
|
718
|
-
// Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
|
|
719
817
|
try {
|
|
720
818
|
require(['hooks'], hooks => {
|
|
721
819
|
if (typeof hooks?.on !== 'function') return;
|