nodebb-plugin-ezoic-infinite 1.8.33 → 1.8.35
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 +102 -80
- package/package.json +2 -2
- package/public/client.js +719 -508
- package/public/style.css +9 -30
package/public/client.js
CHANGED
|
@@ -1,218 +1,182 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — client.js
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — client.js v2.0.0
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ────────────────────────────────────
|
|
6
|
-
* v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
|
|
4
|
+
* Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
6
|
+
* Key changes from v1.x:
|
|
7
|
+
* - TCF locator: debounced recreation instead of per-mutation, prevents CMP postMessage null errors
|
|
8
|
+
* - MutationObserver: scoped to content containers instead of document.body subtree
|
|
9
|
+
* - Console muting: regex-free, prefix-based matching
|
|
10
|
+
* - showAds batching: microtask-based flush instead of setTimeout
|
|
11
|
+
* - Warm network: runs once per session, not per navigation
|
|
12
|
+
* - State machine: clear lifecycle for placeholders (idle → observed → queued → shown → recycled)
|
|
67
13
|
*/
|
|
68
14
|
(function nbbEzoicInfinite() {
|
|
69
15
|
'use strict';
|
|
70
16
|
|
|
71
|
-
// ──
|
|
17
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const WRAP_CLASS = 'nodebb-ezoic-wrap';
|
|
20
|
+
const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
|
|
72
21
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
22
|
+
// Data attributes
|
|
23
|
+
const ATTR = {
|
|
24
|
+
ANCHOR: 'data-ezoic-anchor',
|
|
25
|
+
WRAPID: 'data-ezoic-wrapid',
|
|
26
|
+
CREATED: 'data-ezoic-created',
|
|
27
|
+
SHOWN: 'data-ezoic-shown',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Timing
|
|
31
|
+
const TIMING = {
|
|
32
|
+
EMPTY_CHECK_MS: 20_000,
|
|
33
|
+
MIN_PRUNE_AGE_MS: 8_000,
|
|
34
|
+
SHOW_THROTTLE_MS: 900,
|
|
35
|
+
BURST_COOLDOWN_MS: 200,
|
|
36
|
+
BLOCK_DURATION_MS: 1_500,
|
|
37
|
+
SHOW_TIMEOUT_MS: 7_000,
|
|
38
|
+
SHOW_RELEASE_MS: 700,
|
|
39
|
+
BATCH_FLUSH_MS: 80,
|
|
40
|
+
RECYCLE_DELAY_MS: 450,
|
|
41
|
+
|
|
42
|
+
};
|
|
79
43
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
44
|
+
// Limits
|
|
45
|
+
const LIMITS = {
|
|
46
|
+
MAX_INSERTS_RUN: 6,
|
|
47
|
+
MAX_INFLIGHT: 4,
|
|
48
|
+
BATCH_SIZE: 3,
|
|
49
|
+
MAX_BURST_STEPS: 8,
|
|
50
|
+
BURST_WINDOW_MS: 2_000,
|
|
51
|
+
};
|
|
87
52
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
53
|
+
const IO_MARGIN = {
|
|
54
|
+
DESKTOP: '2500px 0px 2500px 0px',
|
|
55
|
+
MOBILE: '3500px 0px 3500px 0px',
|
|
56
|
+
};
|
|
91
57
|
|
|
58
|
+
// Selectors
|
|
92
59
|
const SEL = {
|
|
93
60
|
post: '[component="post"][data-pid]',
|
|
94
61
|
topic: 'li[component="category/topic"]',
|
|
95
62
|
category: 'li[component="categories/category"]',
|
|
96
63
|
};
|
|
97
64
|
|
|
98
|
-
|
|
99
|
-
* Table KIND — source de vérité par kindClass.
|
|
100
|
-
*
|
|
101
|
-
* sel sélecteur CSS complet des éléments cibles
|
|
102
|
-
* baseTag préfixe tag pour querySelector d'ancre
|
|
103
|
-
* (vide pour posts : le sélecteur commence par '[')
|
|
104
|
-
* anchorAttr attribut DOM stable → clé unique du wrap
|
|
105
|
-
* ordinalAttr attribut 0-based pour le calcul de l'intervalle
|
|
106
|
-
* null → fallback positionnel (catégories)
|
|
107
|
-
*/
|
|
65
|
+
// Kind configuration table — single source of truth per ad type
|
|
108
66
|
const KIND = {
|
|
109
67
|
'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
|
|
110
68
|
'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
|
|
111
69
|
'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
|
|
112
70
|
};
|
|
113
71
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
72
|
+
// Selector for detecting filled ad slots
|
|
73
|
+
const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
|
|
74
|
+
|
|
75
|
+
// ── Utility ────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const now = () => Date.now();
|
|
78
|
+
const isMobile = () => window.innerWidth < 768;
|
|
79
|
+
const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
|
|
80
|
+
|
|
81
|
+
function isFilled(node) {
|
|
82
|
+
return node?.querySelector?.(FILL_SEL) != null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isPlaceholderUsed(ph) {
|
|
86
|
+
if (!ph?.isConnected) return false;
|
|
87
|
+
return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseIds(raw) {
|
|
91
|
+
const out = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
|
|
94
|
+
const n = parseInt(line, 10);
|
|
95
|
+
if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
|
|
96
|
+
seen.add(n);
|
|
97
|
+
out.push(n);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const state = {
|
|
106
|
+
// Page context
|
|
107
|
+
pageKey: null,
|
|
108
|
+
kind: null,
|
|
109
|
+
cfg: null,
|
|
110
|
+
|
|
111
|
+
// Pools
|
|
112
|
+
poolsReady: false,
|
|
113
|
+
pools: { topics: [], posts: [], categories: [] },
|
|
114
|
+
cursors: { topics: 0, posts: 0, categories: 0 },
|
|
115
|
+
|
|
116
|
+
// Mounted placeholders
|
|
117
|
+
mountedIds: new Set(),
|
|
118
|
+
phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
|
|
119
|
+
lastShow: new Map(), // id → timestamp
|
|
120
|
+
|
|
121
|
+
// Wrap registry
|
|
122
|
+
wrapByKey: new Map(), // anchorKey → wrap element
|
|
123
|
+
wrapsByClass: new Map(), // kindClass → Set<wrap>
|
|
124
|
+
|
|
125
|
+
// Observers
|
|
126
|
+
io: null,
|
|
127
|
+
domObs: null,
|
|
128
|
+
|
|
129
|
+
// Guards
|
|
130
|
+
mutGuard: 0,
|
|
131
|
+
blockedUntil: 0,
|
|
132
|
+
|
|
133
|
+
// Show queue
|
|
134
|
+
inflight: 0,
|
|
135
|
+
pending: [],
|
|
136
|
+
pendingSet: new Set(),
|
|
137
|
+
|
|
138
|
+
// Scheduler
|
|
131
139
|
runQueued: false,
|
|
132
140
|
burstActive: false,
|
|
133
141
|
burstDeadline: 0,
|
|
134
142
|
burstCount: 0,
|
|
135
143
|
lastBurstTs: 0,
|
|
136
144
|
firstShown: false,
|
|
137
|
-
wrapsByClass: new Map(),
|
|
138
|
-
kind: null,
|
|
139
|
-
phState: new Map(), // id -> new|show-queued|shown|destroyed
|
|
140
145
|
};
|
|
141
146
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const ts = () => Date.now();
|
|
145
|
-
const isBlocked = () => ts() < blockedUntil;
|
|
146
|
-
const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
|
|
147
|
-
const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
|
|
148
|
-
const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
|
|
149
|
-
const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
|
|
150
|
-
function placeholderLooksUsed(ph) {
|
|
151
|
-
try {
|
|
152
|
-
if (!ph?.isConnected) return false;
|
|
153
|
-
return !!ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]');
|
|
154
|
-
} catch (_) { return false; }
|
|
155
|
-
}
|
|
156
|
-
|
|
147
|
+
const isBlocked = () => now() < state.blockedUntil;
|
|
157
148
|
|
|
158
149
|
function mutate(fn) {
|
|
159
|
-
|
|
160
|
-
try { fn(); } finally {
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
function clearEmptyIfFilled(wrap) {
|
|
165
|
-
try {
|
|
166
|
-
if (!wrap?.isConnected) return false;
|
|
167
|
-
const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
168
|
-
if (!ph) return false;
|
|
169
|
-
if (!isFilled(ph)) return false;
|
|
170
|
-
wrap.classList.remove('is-empty');
|
|
171
|
-
const id = parseInt(wrap.getAttribute(A_WRAPID) || '0', 10);
|
|
172
|
-
if (Number.isFinite(id) && id > 0) S.phState.set(id, 'shown');
|
|
173
|
-
return true;
|
|
174
|
-
} catch (_) { return false; }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function scheduleUncollapseChecksForWrap(wrap) {
|
|
178
|
-
if (!wrap) return;
|
|
179
|
-
for (const ms of [500, 1500, 3000, 7000, 15000]) {
|
|
180
|
-
setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
|
|
181
|
-
}
|
|
150
|
+
state.mutGuard++;
|
|
151
|
+
try { fn(); } finally { state.mutGuard--; }
|
|
182
152
|
}
|
|
183
153
|
|
|
184
154
|
// ── Config ─────────────────────────────────────────────────────────────────
|
|
185
155
|
|
|
186
156
|
async function fetchConfig() {
|
|
187
|
-
if (
|
|
157
|
+
if (state.cfg) return state.cfg;
|
|
158
|
+
// Prefer inline config injected by server (zero latency)
|
|
188
159
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
160
|
+
const inline = window.__nbbEzoicCfg;
|
|
161
|
+
if (inline && typeof inline === 'object') {
|
|
162
|
+
state.cfg = inline;
|
|
163
|
+
return state.cfg;
|
|
192
164
|
}
|
|
193
165
|
} catch (_) {}
|
|
166
|
+
// Fallback to API
|
|
194
167
|
try {
|
|
195
168
|
const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
|
|
196
|
-
if (r.ok)
|
|
169
|
+
if (r.ok) state.cfg = await r.json();
|
|
197
170
|
} catch (_) {}
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function parseIds(raw) {
|
|
202
|
-
const out = [], seen = new Set();
|
|
203
|
-
for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
|
|
204
|
-
const n = parseInt(v, 10);
|
|
205
|
-
if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
206
|
-
}
|
|
207
|
-
return out;
|
|
171
|
+
return state.cfg;
|
|
208
172
|
}
|
|
209
173
|
|
|
210
174
|
function initPools(cfg) {
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
175
|
+
if (state.poolsReady) return;
|
|
176
|
+
state.pools.topics = parseIds(cfg.placeholderIds);
|
|
177
|
+
state.pools.posts = parseIds(cfg.messagePlaceholderIds);
|
|
178
|
+
state.pools.categories = parseIds(cfg.categoryPlaceholderIds);
|
|
179
|
+
state.poolsReady = true;
|
|
216
180
|
}
|
|
217
181
|
|
|
218
182
|
// ── Page identity ──────────────────────────────────────────────────────────
|
|
@@ -231,6 +195,7 @@
|
|
|
231
195
|
if (/^\/topic\//.test(p)) return 'topic';
|
|
232
196
|
if (/^\/category\//.test(p)) return 'categoryTopics';
|
|
233
197
|
if (p === '/' || /^\/categories/.test(p)) return 'categories';
|
|
198
|
+
// DOM fallback
|
|
234
199
|
if (document.querySelector(SEL.category)) return 'categories';
|
|
235
200
|
if (document.querySelector(SEL.post)) return 'topic';
|
|
236
201
|
if (document.querySelector(SEL.topic)) return 'categoryTopics';
|
|
@@ -238,62 +203,95 @@
|
|
|
238
203
|
}
|
|
239
204
|
|
|
240
205
|
function getKind() {
|
|
241
|
-
|
|
242
|
-
S.kind = detectKind();
|
|
243
|
-
return S.kind;
|
|
206
|
+
return state.kind || (state.kind = detectKind());
|
|
244
207
|
}
|
|
245
208
|
|
|
246
|
-
// ──
|
|
209
|
+
// ── DOM queries ────────────────────────────────────────────────────────────
|
|
247
210
|
|
|
248
211
|
function getPosts() {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
212
|
+
const all = document.querySelectorAll(SEL.post);
|
|
213
|
+
const out = [];
|
|
214
|
+
for (let i = 0; i < all.length; i++) {
|
|
215
|
+
const el = all[i];
|
|
216
|
+
if (!el.isConnected) continue;
|
|
217
|
+
if (!el.querySelector('[component="post/content"]')) continue;
|
|
218
|
+
// Skip nested quotes / parent posts
|
|
219
|
+
const parent = el.parentElement?.closest(SEL.post);
|
|
220
|
+
if (parent && parent !== el) continue;
|
|
221
|
+
if (el.getAttribute('component') === 'post/parent') continue;
|
|
222
|
+
out.push(el);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
|
|
228
|
+
function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
|
|
229
|
+
|
|
230
|
+
// ── Anchor keys & wrap registry ────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function stableId(klass, el) {
|
|
233
|
+
const attr = KIND[klass]?.anchorAttr;
|
|
234
|
+
if (attr) {
|
|
235
|
+
const v = el.getAttribute(attr);
|
|
236
|
+
if (v != null && v !== '') return v;
|
|
237
|
+
}
|
|
238
|
+
// Positional fallback
|
|
239
|
+
const children = el.parentElement?.children;
|
|
240
|
+
if (!children) return 'i0';
|
|
241
|
+
for (let i = 0; i < children.length; i++) {
|
|
242
|
+
if (children[i] === el) return `i${i}`;
|
|
243
|
+
}
|
|
244
|
+
return 'i0';
|
|
256
245
|
}
|
|
257
246
|
|
|
258
|
-
const
|
|
259
|
-
|
|
247
|
+
const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
|
|
248
|
+
|
|
249
|
+
function findWrap(key) {
|
|
250
|
+
const w = state.wrapByKey.get(key);
|
|
251
|
+
return w?.isConnected ? w : null;
|
|
252
|
+
}
|
|
260
253
|
|
|
261
|
-
|
|
254
|
+
function getWrapSet(klass) {
|
|
255
|
+
let set = state.wrapsByClass.get(klass);
|
|
256
|
+
if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
|
|
257
|
+
return set;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Wrap lifecycle detection ───────────────────────────────────────────────
|
|
262
261
|
|
|
263
262
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
263
|
+
* Check if a wrap element still has its corresponding anchor in the DOM.
|
|
264
|
+
* Uses O(1) registry lookup first, then sibling scan, then global querySelector.
|
|
266
265
|
*/
|
|
267
266
|
function wrapIsLive(wrap) {
|
|
268
267
|
if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
|
|
269
|
-
const key = wrap.getAttribute(
|
|
268
|
+
const key = wrap.getAttribute(ATTR.ANCHOR);
|
|
270
269
|
if (!key) return false;
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
if (
|
|
274
|
-
|
|
270
|
+
|
|
271
|
+
// Fast path: registry match
|
|
272
|
+
if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
|
|
273
|
+
|
|
274
|
+
// Parse key
|
|
275
275
|
const colonIdx = key.indexOf(':');
|
|
276
276
|
const klass = key.slice(0, colonIdx);
|
|
277
277
|
const anchorId = key.slice(colonIdx + 1);
|
|
278
278
|
const cfg = KIND[klass];
|
|
279
279
|
if (!cfg) return false;
|
|
280
|
-
|
|
281
|
-
//
|
|
280
|
+
|
|
281
|
+
// Sibling scan (cheap for adjacent anchors)
|
|
282
282
|
const parent = wrap.parentElement;
|
|
283
283
|
if (parent) {
|
|
284
|
+
const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
|
|
284
285
|
for (const sib of parent.children) {
|
|
285
|
-
if (sib
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return sib.isConnected;
|
|
289
|
-
}
|
|
290
|
-
} catch (_) {}
|
|
286
|
+
if (sib !== wrap) {
|
|
287
|
+
try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
|
|
288
|
+
}
|
|
291
289
|
}
|
|
292
290
|
}
|
|
293
|
-
|
|
291
|
+
|
|
292
|
+
// Global fallback (expensive, rare)
|
|
294
293
|
try {
|
|
295
|
-
|
|
296
|
-
return !!(found?.isConnected);
|
|
294
|
+
return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
|
|
297
295
|
} catch (_) { return false; }
|
|
298
296
|
}
|
|
299
297
|
|
|
@@ -301,230 +299,234 @@
|
|
|
301
299
|
return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
|
|
302
300
|
}
|
|
303
301
|
|
|
304
|
-
// ──
|
|
302
|
+
// ── Fill detection ─────────────────────────────────────────────────────────
|
|
305
303
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
if (v !== null && v !== '') return v;
|
|
315
|
-
}
|
|
316
|
-
let i = 0;
|
|
317
|
-
for (const s of el.parentElement?.children ?? []) {
|
|
318
|
-
if (s === el) return `i${i}`;
|
|
319
|
-
i++;
|
|
320
|
-
}
|
|
321
|
-
return 'i0';
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
|
|
325
|
-
|
|
326
|
-
function findWrap(key) {
|
|
327
|
-
const w = S.wrapByKey.get(key);
|
|
328
|
-
return (w?.isConnected) ? w : null;
|
|
304
|
+
function clearEmptyIfFilled(wrap) {
|
|
305
|
+
if (!wrap?.isConnected) return false;
|
|
306
|
+
const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
307
|
+
if (!ph || !isFilled(ph)) return false;
|
|
308
|
+
wrap.classList.remove('is-empty');
|
|
309
|
+
const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
|
|
310
|
+
if (id > 0) state.phState.set(id, 'shown');
|
|
311
|
+
return true;
|
|
329
312
|
}
|
|
330
313
|
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
314
|
+
function scheduleUncollapseChecks(wrap) {
|
|
315
|
+
if (!wrap) return;
|
|
316
|
+
const delays = [500, 1500, 3000, 7000, 15000];
|
|
317
|
+
for (const ms of delays) {
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
try { clearEmptyIfFilled(wrap); } catch (_) {}
|
|
320
|
+
}, ms);
|
|
321
|
+
}
|
|
335
322
|
}
|
|
336
|
-
const registerWrap = (klass, w) => wrapsSet(klass).add(w);
|
|
337
|
-
const unregisterWrap = (klass, w) => S.wrapsByClass.get(klass)?.delete(w);
|
|
338
323
|
|
|
339
|
-
// ── Pool
|
|
324
|
+
// ── Pool management ────────────────────────────────────────────────────────
|
|
340
325
|
|
|
341
|
-
/**
|
|
342
|
-
* Retourne le prochain id disponible dans le pool (round-robin),
|
|
343
|
-
* ou null si tous les ids sont montés.
|
|
344
|
-
*/
|
|
345
326
|
function pickId(poolKey) {
|
|
346
|
-
const pool =
|
|
327
|
+
const pool = state.pools[poolKey];
|
|
347
328
|
if (!pool.length) return null;
|
|
348
329
|
for (let t = 0; t < pool.length; t++) {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
const id = pool[
|
|
352
|
-
if (!
|
|
330
|
+
const idx = state.cursors[poolKey] % pool.length;
|
|
331
|
+
state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
|
|
332
|
+
const id = pool[idx];
|
|
333
|
+
if (!state.mountedIds.has(id)) return id;
|
|
353
334
|
}
|
|
354
335
|
return null;
|
|
355
336
|
}
|
|
356
337
|
|
|
338
|
+
// ── Recycling ──────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
357
340
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
|
|
361
|
-
* displayMore = API Ezoic prévue pour l'infinite scroll.
|
|
362
|
-
* Priorité : wraps vides d'abord, remplis si nécessaire.
|
|
341
|
+
* When pool is exhausted, recycle a wrap far above the viewport.
|
|
342
|
+
* Sequence: destroy → delay → re-observe → enqueueShow
|
|
363
343
|
*/
|
|
364
|
-
function
|
|
344
|
+
function recycleWrap(klass, targetEl, newKey) {
|
|
365
345
|
const ez = window.ezstandalone;
|
|
366
346
|
if (typeof ez?.destroyPlaceholders !== 'function' ||
|
|
367
347
|
typeof ez?.define !== 'function' ||
|
|
368
348
|
typeof ez?.displayMore !== 'function') return null;
|
|
369
349
|
|
|
370
|
-
const vh
|
|
371
|
-
// Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
|
|
372
|
-
// après pour neutraliser l'IO — plus de showAds parasite possible.
|
|
350
|
+
const vh = window.innerHeight || 800;
|
|
373
351
|
const threshold = -vh;
|
|
374
|
-
let bestEmpty = null,
|
|
375
|
-
let
|
|
352
|
+
let bestEmpty = null, bestEmptyY = Infinity;
|
|
353
|
+
let bestFull = null, bestFullY = Infinity;
|
|
354
|
+
|
|
355
|
+
const wraps = state.wrapsByClass.get(klass);
|
|
356
|
+
if (!wraps) return null;
|
|
376
357
|
|
|
377
|
-
for (const wrap of
|
|
358
|
+
for (const wrap of wraps) {
|
|
378
359
|
try {
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
360
|
+
const bottom = wrap.getBoundingClientRect().bottom;
|
|
361
|
+
if (bottom > threshold) continue;
|
|
381
362
|
if (!isFilled(wrap)) {
|
|
382
|
-
if (
|
|
363
|
+
if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
|
|
383
364
|
} else {
|
|
384
|
-
if (
|
|
365
|
+
if (bottom < bestFullY) { bestFullY = bottom; bestFull = wrap; }
|
|
385
366
|
}
|
|
386
367
|
} catch (_) {}
|
|
387
368
|
}
|
|
388
369
|
|
|
389
|
-
const best = bestEmpty ??
|
|
370
|
+
const best = bestEmpty ?? bestFull;
|
|
390
371
|
if (!best) return null;
|
|
391
|
-
|
|
372
|
+
|
|
373
|
+
const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
|
|
392
374
|
if (!Number.isFinite(id)) return null;
|
|
393
375
|
|
|
394
|
-
const oldKey = best.getAttribute(
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
try {
|
|
376
|
+
const oldKey = best.getAttribute(ATTR.ANCHOR);
|
|
377
|
+
|
|
378
|
+
// Unobserve before moving to prevent stale showAds
|
|
379
|
+
try {
|
|
380
|
+
const ph = best.querySelector(`#${PH_PREFIX}${id}`);
|
|
381
|
+
if (ph) state.io?.unobserve(ph);
|
|
382
|
+
} catch (_) {}
|
|
383
|
+
|
|
384
|
+
// Move the wrap to new position
|
|
398
385
|
mutate(() => {
|
|
399
|
-
best.setAttribute(
|
|
400
|
-
best.setAttribute(
|
|
401
|
-
best.setAttribute(
|
|
386
|
+
best.setAttribute(ATTR.ANCHOR, newKey);
|
|
387
|
+
best.setAttribute(ATTR.CREATED, String(now()));
|
|
388
|
+
best.setAttribute(ATTR.SHOWN, '0');
|
|
402
389
|
best.classList.remove('is-empty');
|
|
403
390
|
best.replaceChildren();
|
|
391
|
+
|
|
404
392
|
const fresh = document.createElement('div');
|
|
405
393
|
fresh.id = `${PH_PREFIX}${id}`;
|
|
406
394
|
fresh.setAttribute('data-ezoic-id', String(id));
|
|
407
|
-
fresh.style.minHeight =
|
|
395
|
+
fresh.style.minHeight = '1px';
|
|
408
396
|
best.appendChild(fresh);
|
|
409
397
|
targetEl.insertAdjacentElement('afterend', best);
|
|
410
398
|
});
|
|
411
|
-
if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
|
|
412
|
-
S.wrapByKey.set(newKey, best);
|
|
413
399
|
|
|
414
|
-
//
|
|
415
|
-
|
|
400
|
+
// Update registry
|
|
401
|
+
if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
|
|
402
|
+
state.wrapByKey.set(newKey, best);
|
|
403
|
+
|
|
404
|
+
// Ezoic recycle sequence
|
|
416
405
|
const doDestroy = () => {
|
|
417
|
-
|
|
406
|
+
state.phState.set(id, 'destroyed');
|
|
418
407
|
try { ez.destroyPlaceholders(id); } catch (_) {
|
|
419
408
|
try { ez.destroyPlaceholders([id]); } catch (_) {}
|
|
420
409
|
}
|
|
421
410
|
setTimeout(() => {
|
|
422
|
-
try {
|
|
423
|
-
|
|
411
|
+
try { observePlaceholder(id); } catch (_) {}
|
|
412
|
+
state.phState.set(id, 'new');
|
|
424
413
|
try { enqueueShow(id); } catch (_) {}
|
|
425
|
-
},
|
|
414
|
+
}, TIMING.RECYCLE_DELAY_MS);
|
|
426
415
|
};
|
|
427
|
-
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
(typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
|
|
419
|
+
} catch (_) {}
|
|
428
420
|
|
|
429
421
|
return { id, wrap: best };
|
|
430
422
|
}
|
|
431
423
|
|
|
432
|
-
// ──
|
|
424
|
+
// ── Wrap DOM operations ────────────────────────────────────────────────────
|
|
433
425
|
|
|
434
426
|
function makeWrap(id, klass, key) {
|
|
435
427
|
const w = document.createElement('div');
|
|
436
428
|
w.className = `${WRAP_CLASS} ${klass}`;
|
|
437
|
-
w.setAttribute(
|
|
438
|
-
w.setAttribute(
|
|
439
|
-
w.setAttribute(
|
|
440
|
-
w.setAttribute(
|
|
441
|
-
w.style.cssText = 'width:100%;display:block
|
|
429
|
+
w.setAttribute(ATTR.ANCHOR, key);
|
|
430
|
+
w.setAttribute(ATTR.WRAPID, String(id));
|
|
431
|
+
w.setAttribute(ATTR.CREATED, String(now()));
|
|
432
|
+
w.setAttribute(ATTR.SHOWN, '0');
|
|
433
|
+
w.style.cssText = 'width:100%;display:block';
|
|
434
|
+
|
|
442
435
|
const ph = document.createElement('div');
|
|
443
436
|
ph.id = `${PH_PREFIX}${id}`;
|
|
444
437
|
ph.setAttribute('data-ezoic-id', String(id));
|
|
445
|
-
ph.style.minHeight =
|
|
438
|
+
ph.style.minHeight = '1px';
|
|
446
439
|
w.appendChild(ph);
|
|
447
440
|
return w;
|
|
448
441
|
}
|
|
449
442
|
|
|
450
443
|
function insertAfter(el, id, klass, key) {
|
|
451
|
-
if (!el?.insertAdjacentElement)
|
|
452
|
-
if (findWrap(key))
|
|
453
|
-
if (
|
|
454
|
-
|
|
444
|
+
if (!el?.insertAdjacentElement) return null;
|
|
445
|
+
if (findWrap(key)) return null;
|
|
446
|
+
if (state.mountedIds.has(id)) return null;
|
|
447
|
+
// Ensure no duplicate DOM element with same placeholder ID
|
|
448
|
+
const existing = document.getElementById(`${PH_PREFIX}${id}`);
|
|
449
|
+
if (existing?.isConnected) return null;
|
|
450
|
+
|
|
455
451
|
const w = makeWrap(id, klass, key);
|
|
456
452
|
mutate(() => el.insertAdjacentElement('afterend', w));
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
453
|
+
state.mountedIds.add(id);
|
|
454
|
+
state.phState.set(id, 'new');
|
|
455
|
+
state.wrapByKey.set(key, w);
|
|
456
|
+
getWrapSet(klass).add(w);
|
|
461
457
|
return w;
|
|
462
458
|
}
|
|
463
459
|
|
|
464
460
|
function dropWrap(w) {
|
|
465
461
|
try {
|
|
466
462
|
const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
467
|
-
if (ph instanceof Element)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
463
|
+
if (ph instanceof Element) state.io?.unobserve(ph);
|
|
464
|
+
|
|
465
|
+
const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
|
|
466
|
+
if (Number.isFinite(id)) {
|
|
467
|
+
state.mountedIds.delete(id);
|
|
468
|
+
state.phState.delete(id);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const key = w.getAttribute(ATTR.ANCHOR);
|
|
472
|
+
if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
|
|
473
|
+
|
|
474
|
+
// Find the kind class to unregister
|
|
475
|
+
for (const cls of w.classList) {
|
|
476
|
+
if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
|
|
477
|
+
state.wrapsByClass.get(cls)?.delete(w);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
474
481
|
w.remove();
|
|
475
482
|
} catch (_) {}
|
|
476
483
|
}
|
|
477
484
|
|
|
478
|
-
// ── Prune (
|
|
479
|
-
//
|
|
480
|
-
// Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
|
|
485
|
+
// ── Prune (category topic lists only) ──────────────────────────────────────
|
|
481
486
|
//
|
|
482
|
-
// Safe
|
|
483
|
-
//
|
|
484
|
-
// la session. Un wrap orphelin (ancre absente) signifie vraiment que le
|
|
485
|
-
// topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
|
|
486
|
-
// liste après un long scroll et bloquent les nouvelles injections.
|
|
487
|
-
//
|
|
488
|
-
// Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
|
|
489
|
-
// NodeBB virtualise les posts hors-viewport — il les retire puis les
|
|
490
|
-
// réinsère. pruneOrphans verrait des ancres temporairement absentes,
|
|
491
|
-
// supprimerait les wraps, et provoquerait une réinjection en haut.
|
|
487
|
+
// Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
|
|
488
|
+
// NOT safe for posts: NodeBB virtualizes posts off-viewport.
|
|
492
489
|
|
|
493
490
|
function pruneOrphansBetween() {
|
|
494
491
|
const klass = 'ezoic-ad-between';
|
|
495
492
|
const cfg = KIND[klass];
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
493
|
+
const wraps = state.wrapsByClass.get(klass);
|
|
494
|
+
if (!wraps?.size) return;
|
|
495
|
+
|
|
496
|
+
// Build set of live anchor IDs
|
|
497
|
+
const liveAnchors = new Set();
|
|
498
|
+
for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
|
|
499
|
+
const v = el.getAttribute(cfg.anchorAttr);
|
|
500
|
+
if (v) liveAnchors.add(v);
|
|
501
|
+
}
|
|
502
502
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
503
|
+
const t = now();
|
|
504
|
+
for (const w of wraps) {
|
|
505
|
+
const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
|
|
506
|
+
if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
|
|
506
507
|
|
|
507
|
-
|
|
508
|
+
const key = w.getAttribute(ATTR.ANCHOR) ?? '';
|
|
509
|
+
const sid = key.slice(klass.length + 1);
|
|
510
|
+
if (!sid || !liveAnchors.has(sid)) {
|
|
511
|
+
mutate(() => dropWrap(w));
|
|
512
|
+
}
|
|
508
513
|
}
|
|
509
514
|
}
|
|
510
515
|
|
|
511
516
|
// ── Injection ──────────────────────────────────────────────────────────────
|
|
512
517
|
|
|
513
|
-
/**
|
|
514
|
-
* Ordinal 0-based pour le calcul de l'intervalle d'injection.
|
|
515
|
-
* Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
|
|
516
|
-
*/
|
|
517
518
|
function ordinal(klass, el) {
|
|
518
519
|
const attr = KIND[klass]?.ordinalAttr;
|
|
519
520
|
if (attr) {
|
|
520
521
|
const v = el.getAttribute(attr);
|
|
521
|
-
if (v
|
|
522
|
+
if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
|
|
522
523
|
}
|
|
524
|
+
// Positional fallback
|
|
523
525
|
const fullSel = KIND[klass]?.sel ?? '';
|
|
524
526
|
let i = 0;
|
|
525
527
|
for (const s of el.parentElement?.children ?? []) {
|
|
526
528
|
if (s === el) return i;
|
|
527
|
-
if (!fullSel || s.matches
|
|
529
|
+
try { if (!fullSel || s.matches(fullSel)) i++; } catch (_) {}
|
|
528
530
|
}
|
|
529
531
|
return 0;
|
|
530
532
|
}
|
|
@@ -534,7 +536,7 @@
|
|
|
534
536
|
let inserted = 0;
|
|
535
537
|
|
|
536
538
|
for (const el of items) {
|
|
537
|
-
if (inserted >= MAX_INSERTS_RUN) break;
|
|
539
|
+
if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
|
|
538
540
|
if (!el?.isConnected) continue;
|
|
539
541
|
|
|
540
542
|
const ord = ordinal(klass, el);
|
|
@@ -548,101 +550,132 @@
|
|
|
548
550
|
if (id) {
|
|
549
551
|
const w = insertAfter(el, id, klass, key);
|
|
550
552
|
if (w) {
|
|
551
|
-
|
|
552
|
-
if (!
|
|
553
|
+
observePlaceholder(id);
|
|
554
|
+
if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
|
|
553
555
|
inserted++;
|
|
554
556
|
}
|
|
555
557
|
} else {
|
|
556
|
-
const recycled =
|
|
557
|
-
if (!recycled) break;
|
|
558
|
+
const recycled = recycleWrap(klass, el, key);
|
|
559
|
+
if (!recycled) break; // Pool truly exhausted
|
|
558
560
|
inserted++;
|
|
559
561
|
}
|
|
560
562
|
}
|
|
561
563
|
return inserted;
|
|
562
564
|
}
|
|
563
565
|
|
|
564
|
-
// ── IntersectionObserver
|
|
566
|
+
// ── IntersectionObserver ───────────────────────────────────────────────────
|
|
565
567
|
|
|
566
568
|
function getIO() {
|
|
567
|
-
if (
|
|
569
|
+
if (state.io) return state.io;
|
|
568
570
|
try {
|
|
569
|
-
|
|
570
|
-
for (const
|
|
571
|
-
if (!
|
|
572
|
-
if (
|
|
573
|
-
const id = parseInt(
|
|
574
|
-
if (
|
|
571
|
+
state.io = new IntersectionObserver(entries => {
|
|
572
|
+
for (const entry of entries) {
|
|
573
|
+
if (!entry.isIntersecting) continue;
|
|
574
|
+
if (entry.target instanceof Element) state.io?.unobserve(entry.target);
|
|
575
|
+
const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
|
|
576
|
+
if (id > 0) enqueueShow(id);
|
|
575
577
|
}
|
|
576
|
-
}, {
|
|
577
|
-
|
|
578
|
-
|
|
578
|
+
}, {
|
|
579
|
+
root: null,
|
|
580
|
+
rootMargin: isMobile() ? IO_MARGIN.MOBILE : IO_MARGIN.DESKTOP,
|
|
581
|
+
threshold: 0,
|
|
582
|
+
});
|
|
583
|
+
} catch (_) { state.io = null; }
|
|
584
|
+
return state.io;
|
|
579
585
|
}
|
|
580
586
|
|
|
581
|
-
function
|
|
587
|
+
function observePlaceholder(id) {
|
|
582
588
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
583
|
-
if (ph?.isConnected)
|
|
589
|
+
if (ph?.isConnected) {
|
|
590
|
+
try { getIO()?.observe(ph); } catch (_) {}
|
|
591
|
+
}
|
|
584
592
|
}
|
|
585
593
|
|
|
594
|
+
// ── Show queue ─────────────────────────────────────────────────────────────
|
|
595
|
+
|
|
586
596
|
function enqueueShow(id) {
|
|
587
597
|
if (!id || isBlocked()) return;
|
|
588
|
-
const st =
|
|
598
|
+
const st = state.phState.get(id);
|
|
589
599
|
if (st === 'show-queued' || st === 'shown') return;
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
600
|
+
if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
|
|
601
|
+
|
|
602
|
+
if (state.inflight >= LIMITS.MAX_INFLIGHT) {
|
|
603
|
+
if (!state.pendingSet.has(id)) {
|
|
604
|
+
state.pending.push(id);
|
|
605
|
+
state.pendingSet.add(id);
|
|
606
|
+
state.phState.set(id, 'show-queued');
|
|
607
|
+
}
|
|
593
608
|
return;
|
|
594
609
|
}
|
|
595
|
-
|
|
610
|
+
state.phState.set(id, 'show-queued');
|
|
596
611
|
startShow(id);
|
|
597
612
|
}
|
|
598
613
|
|
|
599
614
|
function drainQueue() {
|
|
600
615
|
if (isBlocked()) return;
|
|
601
|
-
while (
|
|
602
|
-
const id =
|
|
603
|
-
|
|
616
|
+
while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
|
|
617
|
+
const id = state.pending.shift();
|
|
618
|
+
state.pendingSet.delete(id);
|
|
604
619
|
startShow(id);
|
|
605
620
|
}
|
|
606
621
|
}
|
|
607
622
|
|
|
608
623
|
function startShow(id) {
|
|
609
624
|
if (!id || isBlocked()) return;
|
|
610
|
-
|
|
611
|
-
|
|
625
|
+
state.inflight++;
|
|
626
|
+
|
|
627
|
+
let released = false;
|
|
612
628
|
const release = () => {
|
|
613
|
-
if (
|
|
614
|
-
|
|
615
|
-
|
|
629
|
+
if (released) return;
|
|
630
|
+
released = true;
|
|
631
|
+
state.inflight = Math.max(0, state.inflight - 1);
|
|
616
632
|
drainQueue();
|
|
617
633
|
};
|
|
618
|
-
const timer = setTimeout(release,
|
|
634
|
+
const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
|
|
619
635
|
|
|
620
636
|
requestAnimationFrame(() => {
|
|
621
637
|
try {
|
|
622
638
|
if (isBlocked()) { clearTimeout(timer); return release(); }
|
|
639
|
+
|
|
623
640
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
624
|
-
if (!ph?.isConnected) {
|
|
625
|
-
|
|
641
|
+
if (!ph?.isConnected) {
|
|
642
|
+
state.phState.delete(id);
|
|
643
|
+
clearTimeout(timer);
|
|
644
|
+
return release();
|
|
645
|
+
}
|
|
626
646
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
647
|
+
if (isFilled(ph) || isPlaceholderUsed(ph)) {
|
|
648
|
+
state.phState.set(id, 'shown');
|
|
649
|
+
clearTimeout(timer);
|
|
650
|
+
return release();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const t = now();
|
|
654
|
+
if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
return release();
|
|
657
|
+
}
|
|
658
|
+
state.lastShow.set(id, t);
|
|
630
659
|
|
|
631
|
-
try { ph.closest
|
|
632
|
-
|
|
660
|
+
try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
|
|
661
|
+
state.phState.set(id, 'shown');
|
|
633
662
|
|
|
634
663
|
window.ezstandalone = window.ezstandalone || {};
|
|
635
664
|
const ez = window.ezstandalone;
|
|
665
|
+
|
|
636
666
|
const doShow = () => {
|
|
637
|
-
|
|
638
|
-
try { wrap = ph.closest?.(`.${WRAP_CLASS}`) || null; } catch (_) {}
|
|
667
|
+
const wrap = ph.closest(`.${WRAP_CLASS}`);
|
|
639
668
|
try { ez.showAds(id); } catch (_) {}
|
|
640
|
-
if (wrap)
|
|
669
|
+
if (wrap) scheduleUncollapseChecks(wrap);
|
|
641
670
|
scheduleEmptyCheck(id, t);
|
|
642
|
-
setTimeout(() => { clearTimeout(timer); release(); },
|
|
671
|
+
setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
|
|
643
672
|
};
|
|
673
|
+
|
|
644
674
|
Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
|
|
645
|
-
} catch (_) {
|
|
675
|
+
} catch (_) {
|
|
676
|
+
clearTimeout(timer);
|
|
677
|
+
release();
|
|
678
|
+
}
|
|
646
679
|
});
|
|
647
680
|
}
|
|
648
681
|
|
|
@@ -650,20 +683,22 @@
|
|
|
650
683
|
setTimeout(() => {
|
|
651
684
|
try {
|
|
652
685
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
653
|
-
const wrap = ph?.closest
|
|
686
|
+
const wrap = ph?.closest(`.${WRAP_CLASS}`);
|
|
654
687
|
if (!wrap || !ph?.isConnected) return;
|
|
655
|
-
|
|
688
|
+
// Skip if a newer show happened since
|
|
689
|
+
if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
|
|
656
690
|
if (clearEmptyIfFilled(wrap)) return;
|
|
657
691
|
wrap.classList.add('is-empty');
|
|
658
692
|
} catch (_) {}
|
|
659
|
-
}, EMPTY_CHECK_MS);
|
|
693
|
+
}, TIMING.EMPTY_CHECK_MS);
|
|
660
694
|
}
|
|
661
695
|
|
|
662
696
|
// ── Patch Ezoic showAds ────────────────────────────────────────────────────
|
|
663
697
|
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
698
|
+
// Intercepts ez.showAds() to:
|
|
699
|
+
// - block calls during navigation transitions
|
|
700
|
+
// - filter out disconnected placeholders
|
|
701
|
+
// - batch calls for efficiency
|
|
667
702
|
|
|
668
703
|
function patchShowAds() {
|
|
669
704
|
const apply = () => {
|
|
@@ -672,29 +707,33 @@
|
|
|
672
707
|
const ez = window.ezstandalone;
|
|
673
708
|
if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
|
|
674
709
|
window.__nbbEzPatched = true;
|
|
710
|
+
|
|
675
711
|
const orig = ez.showAds.bind(ez);
|
|
676
|
-
const
|
|
712
|
+
const queue = new Set();
|
|
677
713
|
let flushTimer = null;
|
|
678
|
-
|
|
679
|
-
const FLUSH_MS = 80;
|
|
714
|
+
|
|
680
715
|
const flush = () => {
|
|
681
716
|
flushTimer = null;
|
|
682
|
-
if (isBlocked() || !
|
|
683
|
-
|
|
684
|
-
|
|
717
|
+
if (isBlocked() || !queue.size) return;
|
|
718
|
+
|
|
719
|
+
const ids = Array.from(queue).sort((a, b) => a - b);
|
|
720
|
+
queue.clear();
|
|
721
|
+
|
|
685
722
|
const valid = ids.filter(id => {
|
|
686
723
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
687
|
-
if (!ph?.isConnected) {
|
|
688
|
-
if (
|
|
724
|
+
if (!ph?.isConnected) { state.phState.delete(id); return false; }
|
|
725
|
+
if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
|
|
689
726
|
return true;
|
|
690
727
|
});
|
|
691
|
-
|
|
692
|
-
|
|
728
|
+
|
|
729
|
+
for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
|
|
730
|
+
const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
|
|
693
731
|
try { orig(...chunk); } catch (_) {
|
|
694
|
-
for (const
|
|
732
|
+
for (const cid of chunk) { try { orig(cid); } catch (_) {} }
|
|
695
733
|
}
|
|
696
734
|
}
|
|
697
735
|
};
|
|
736
|
+
|
|
698
737
|
ez.showAds = function (...args) {
|
|
699
738
|
if (isBlocked()) return;
|
|
700
739
|
const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
|
|
@@ -703,15 +742,17 @@
|
|
|
703
742
|
if (!Number.isFinite(id) || id <= 0) continue;
|
|
704
743
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
705
744
|
if (!ph?.isConnected) continue;
|
|
706
|
-
if (
|
|
707
|
-
|
|
708
|
-
|
|
745
|
+
if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
|
|
746
|
+
state.phState.set(id, 'show-queued');
|
|
747
|
+
queue.add(id);
|
|
748
|
+
}
|
|
749
|
+
if (queue.size && !flushTimer) {
|
|
750
|
+
flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
|
|
709
751
|
}
|
|
710
|
-
if (!q.size) return;
|
|
711
|
-
if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
|
|
712
752
|
};
|
|
713
753
|
} catch (_) {}
|
|
714
754
|
};
|
|
755
|
+
|
|
715
756
|
apply();
|
|
716
757
|
if (!window.__nbbEzPatched) {
|
|
717
758
|
window.ezstandalone = window.ezstandalone || {};
|
|
@@ -723,7 +764,6 @@
|
|
|
723
764
|
|
|
724
765
|
async function runCore() {
|
|
725
766
|
if (isBlocked()) return 0;
|
|
726
|
-
patchShowAds();
|
|
727
767
|
|
|
728
768
|
const cfg = await fetchConfig();
|
|
729
769
|
if (!cfg || cfg.excluded) return 0;
|
|
@@ -738,10 +778,12 @@
|
|
|
738
778
|
return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
|
|
739
779
|
};
|
|
740
780
|
|
|
741
|
-
if (kind === 'topic')
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
781
|
+
if (kind === 'topic') {
|
|
782
|
+
return exec(
|
|
783
|
+
'ezoic-ad-message', getPosts,
|
|
784
|
+
cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
|
|
785
|
+
);
|
|
786
|
+
}
|
|
745
787
|
|
|
746
788
|
if (kind === 'categoryTopics') {
|
|
747
789
|
pruneOrphansBetween();
|
|
@@ -757,14 +799,14 @@
|
|
|
757
799
|
);
|
|
758
800
|
}
|
|
759
801
|
|
|
760
|
-
// ── Scheduler
|
|
802
|
+
// ── Scheduler & burst ──────────────────────────────────────────────────────
|
|
761
803
|
|
|
762
804
|
function scheduleRun(cb) {
|
|
763
|
-
if (
|
|
764
|
-
|
|
805
|
+
if (state.runQueued) return;
|
|
806
|
+
state.runQueued = true;
|
|
765
807
|
requestAnimationFrame(async () => {
|
|
766
|
-
|
|
767
|
-
if (
|
|
808
|
+
state.runQueued = false;
|
|
809
|
+
if (state.pageKey && pageKey() !== state.pageKey) return;
|
|
768
810
|
let n = 0;
|
|
769
811
|
try { n = await runCore(); } catch (_) {}
|
|
770
812
|
try { cb?.(n); } catch (_) {}
|
|
@@ -773,154 +815,297 @@
|
|
|
773
815
|
|
|
774
816
|
function requestBurst() {
|
|
775
817
|
if (isBlocked()) return;
|
|
776
|
-
const t =
|
|
777
|
-
if (t -
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
818
|
+
const t = now();
|
|
819
|
+
if (t - state.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
|
|
820
|
+
state.lastBurstTs = t;
|
|
821
|
+
state.pageKey = pageKey();
|
|
822
|
+
state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
|
|
781
823
|
|
|
782
|
-
if (
|
|
783
|
-
|
|
784
|
-
|
|
824
|
+
if (state.burstActive) return;
|
|
825
|
+
state.burstActive = true;
|
|
826
|
+
state.burstCount = 0;
|
|
785
827
|
|
|
786
828
|
const step = () => {
|
|
787
|
-
if (pageKey() !==
|
|
788
|
-
|
|
829
|
+
if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
|
|
830
|
+
state.burstActive = false;
|
|
831
|
+
return;
|
|
789
832
|
}
|
|
790
|
-
|
|
833
|
+
state.burstCount++;
|
|
791
834
|
scheduleRun(n => {
|
|
792
|
-
if (!n && !
|
|
835
|
+
if (!n && !state.pending.length) { state.burstActive = false; return; }
|
|
793
836
|
setTimeout(step, n > 0 ? 150 : 300);
|
|
794
837
|
});
|
|
795
838
|
};
|
|
796
839
|
step();
|
|
797
840
|
}
|
|
798
841
|
|
|
799
|
-
// ── Cleanup navigation
|
|
842
|
+
// ── Cleanup on navigation ──────────────────────────────────────────────────
|
|
800
843
|
|
|
801
844
|
function cleanup() {
|
|
802
|
-
blockedUntil =
|
|
803
|
-
mutate(() =>
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
845
|
+
state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
|
|
846
|
+
mutate(() => {
|
|
847
|
+
for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
|
|
848
|
+
dropWrap(w);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
state.cfg = null;
|
|
852
|
+
state.poolsReady = false;
|
|
853
|
+
state.pools = { topics: [], posts: [], categories: [] };
|
|
854
|
+
state.cursors = { topics: 0, posts: 0, categories: 0 };
|
|
855
|
+
state.mountedIds.clear();
|
|
856
|
+
state.lastShow.clear();
|
|
857
|
+
state.wrapByKey.clear();
|
|
858
|
+
state.wrapsByClass.clear();
|
|
859
|
+
state.kind = null;
|
|
860
|
+
state.phState.clear();
|
|
861
|
+
state.inflight = 0;
|
|
862
|
+
state.pending = [];
|
|
863
|
+
state.pendingSet.clear();
|
|
864
|
+
state.burstActive = false;
|
|
865
|
+
state.runQueued = false;
|
|
866
|
+
state.firstShown = false;
|
|
820
867
|
}
|
|
821
868
|
|
|
822
869
|
// ── MutationObserver ───────────────────────────────────────────────────────
|
|
870
|
+
//
|
|
871
|
+
// Scoped to detect: (1) ad fill events in wraps, (2) new content items
|
|
823
872
|
|
|
824
873
|
function ensureDomObserver() {
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
874
|
+
if (state.domObs) return;
|
|
875
|
+
|
|
876
|
+
state.domObs = new MutationObserver(muts => {
|
|
877
|
+
if (state.mutGuard > 0 || isBlocked()) return;
|
|
878
|
+
|
|
879
|
+
let needsBurst = false;
|
|
880
|
+
|
|
881
|
+
// Determine relevant selectors for current page kind
|
|
882
|
+
const kind = getKind();
|
|
883
|
+
const relevantSels =
|
|
884
|
+
kind === 'topic' ? [SEL.post] :
|
|
885
|
+
kind === 'categoryTopics'? [SEL.topic] :
|
|
886
|
+
kind === 'categories' ? [SEL.category] :
|
|
887
|
+
[SEL.post, SEL.topic, SEL.category];
|
|
888
|
+
|
|
828
889
|
for (const m of muts) {
|
|
829
890
|
if (m.type !== 'childList') continue;
|
|
830
891
|
for (const node of m.addedNodes) {
|
|
831
892
|
if (!(node instanceof Element)) continue;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
hasAd = !!(node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL));
|
|
835
|
-
} catch (_) {}
|
|
836
|
-
if (!hasAd) continue;
|
|
893
|
+
|
|
894
|
+
// Check for ad fill events in wraps
|
|
837
895
|
try {
|
|
838
|
-
|
|
839
|
-
|
|
896
|
+
if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
|
|
897
|
+
const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
|
|
898
|
+
m.target?.closest?.(`.${WRAP_CLASS}.is-empty`);
|
|
899
|
+
if (wrap) clearEmptyIfFilled(wrap);
|
|
900
|
+
}
|
|
840
901
|
} catch (_) {}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (n.nodeType !== 1) continue;
|
|
853
|
-
// matches() d'abord (O(1)), querySelector() seulement si nécessaire
|
|
854
|
-
if (relevant.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
|
|
855
|
-
relevant.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
|
|
856
|
-
requestBurst(); return;
|
|
902
|
+
|
|
903
|
+
// Check for new content items (posts, topics, categories)
|
|
904
|
+
if (!needsBurst) {
|
|
905
|
+
for (const sel of relevantSels) {
|
|
906
|
+
try {
|
|
907
|
+
if (node.matches(sel) || node.querySelector(sel)) {
|
|
908
|
+
needsBurst = true;
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
} catch (_) {}
|
|
912
|
+
}
|
|
857
913
|
}
|
|
858
914
|
}
|
|
915
|
+
if (needsBurst) break;
|
|
859
916
|
}
|
|
917
|
+
|
|
918
|
+
if (needsBurst) requestBurst();
|
|
860
919
|
});
|
|
861
|
-
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
state.domObs.observe(document.body, { childList: true, subtree: true });
|
|
923
|
+
} catch (_) {}
|
|
862
924
|
}
|
|
863
925
|
|
|
864
|
-
// ──
|
|
926
|
+
// ── TCF / CMP Protection ─────────────────────────────────────────────────
|
|
927
|
+
//
|
|
928
|
+
// Root cause of the CMP errors:
|
|
929
|
+
// "Cannot read properties of null (reading 'postMessage')"
|
|
930
|
+
// "Cannot set properties of null (setting 'addtlConsent')"
|
|
931
|
+
//
|
|
932
|
+
// The CMP (Gatekeeper Consent) communicates via postMessage on the
|
|
933
|
+
// __tcfapiLocator iframe's contentWindow. During NodeBB ajaxify navigation,
|
|
934
|
+
// jQuery's html() or empty() on the content area can cascade and remove
|
|
935
|
+
// iframes from <body>. The CMP then calls getTCData on a stale reference
|
|
936
|
+
// where contentWindow is null.
|
|
937
|
+
//
|
|
938
|
+
// Strategy (3 layers):
|
|
939
|
+
//
|
|
940
|
+
// 1. PROTECT: Move the locator iframe into <head> where ajaxify never
|
|
941
|
+
// touches it. The TCF spec only requires the iframe to exist in the
|
|
942
|
+
// document with name="__tcfapiLocator" — it works from <head>.
|
|
943
|
+
//
|
|
944
|
+
// 2. GUARD: Wrap __tcfapi and __cmp with a safety layer that catches
|
|
945
|
+
// errors in the CMP's internal getTCData, preventing the uncaught
|
|
946
|
+
// TypeError from propagating.
|
|
947
|
+
//
|
|
948
|
+
// 3. RESTORE: MutationObserver on <body> childList (not subtree) to
|
|
949
|
+
// immediately re-create the locator if something still removes it.
|
|
950
|
+
|
|
951
|
+
function ensureTcfLocator() {
|
|
952
|
+
if (!window.__tcfapi && !window.__cmp) return;
|
|
953
|
+
|
|
954
|
+
const LOCATOR_ID = '__tcfapiLocator';
|
|
955
|
+
|
|
956
|
+
// Create or relocate the locator iframe into <head> for protection
|
|
957
|
+
const ensureInHead = () => {
|
|
958
|
+
let existing = document.getElementById(LOCATOR_ID);
|
|
959
|
+
if (existing) {
|
|
960
|
+
// If it's in <body>, move it to <head> where ajaxify can't reach it
|
|
961
|
+
if (existing.parentElement !== document.head) {
|
|
962
|
+
try { document.head.appendChild(existing); } catch (_) {}
|
|
963
|
+
}
|
|
964
|
+
return existing;
|
|
965
|
+
}
|
|
966
|
+
// Create fresh
|
|
967
|
+
const f = document.createElement('iframe');
|
|
968
|
+
f.style.display = 'none';
|
|
969
|
+
f.id = f.name = LOCATOR_ID;
|
|
970
|
+
try { document.head.appendChild(f); } catch (_) {
|
|
971
|
+
// Fallback to body if head insertion fails
|
|
972
|
+
(document.body || document.documentElement).appendChild(f);
|
|
973
|
+
}
|
|
974
|
+
return f;
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
ensureInHead();
|
|
978
|
+
|
|
979
|
+
// Layer 2: Guard the CMP API calls against null contentWindow
|
|
980
|
+
if (!window.__nbbCmpGuarded) {
|
|
981
|
+
window.__nbbCmpGuarded = true;
|
|
982
|
+
|
|
983
|
+
// Wrap __tcfapi
|
|
984
|
+
if (typeof window.__tcfapi === 'function') {
|
|
985
|
+
const origTcf = window.__tcfapi;
|
|
986
|
+
window.__tcfapi = function (cmd, version, cb, param) {
|
|
987
|
+
try {
|
|
988
|
+
return origTcf.call(this, cmd, version, function (...args) {
|
|
989
|
+
try { cb?.(...args); } catch (_) {}
|
|
990
|
+
}, param);
|
|
991
|
+
} catch (e) {
|
|
992
|
+
// If the error is the null postMessage/addtlConsent, swallow it
|
|
993
|
+
if (e?.message?.includes('null')) {
|
|
994
|
+
// Re-ensure locator exists, then retry once
|
|
995
|
+
ensureInHead();
|
|
996
|
+
try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Wrap __cmp (legacy CMP v1 API)
|
|
1003
|
+
if (typeof window.__cmp === 'function') {
|
|
1004
|
+
const origCmp = window.__cmp;
|
|
1005
|
+
window.__cmp = function (...args) {
|
|
1006
|
+
try {
|
|
1007
|
+
return origCmp.apply(this, args);
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
if (e?.message?.includes('null')) {
|
|
1010
|
+
ensureInHead();
|
|
1011
|
+
try { return origCmp.apply(this, args); } catch (_) {}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Layer 3: MutationObserver to immediately restore if removed
|
|
1019
|
+
if (!window.__nbbTcfObs) {
|
|
1020
|
+
window.__nbbTcfObs = new MutationObserver(muts => {
|
|
1021
|
+
// Fast check: still in document?
|
|
1022
|
+
if (document.getElementById(LOCATOR_ID)) return;
|
|
1023
|
+
// Something removed it — restore immediately (no debounce)
|
|
1024
|
+
ensureInHead();
|
|
1025
|
+
});
|
|
1026
|
+
// Observe body direct children only (the most likely removal point)
|
|
1027
|
+
try {
|
|
1028
|
+
window.__nbbTcfObs.observe(document.body || document.documentElement, {
|
|
1029
|
+
childList: true,
|
|
1030
|
+
subtree: false,
|
|
1031
|
+
});
|
|
1032
|
+
} catch (_) {}
|
|
1033
|
+
// Also observe <head> in case something cleans it
|
|
1034
|
+
try {
|
|
1035
|
+
if (document.head) {
|
|
1036
|
+
window.__nbbTcfObs.observe(document.head, {
|
|
1037
|
+
childList: true,
|
|
1038
|
+
subtree: false,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
} catch (_) {}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── Console muting ─────────────────────────────────────────────────────────
|
|
1046
|
+
//
|
|
1047
|
+
// Mute noisy Ezoic warnings that are expected in infinite scroll context.
|
|
1048
|
+
// Uses startsWith checks instead of includes for performance.
|
|
865
1049
|
|
|
866
1050
|
function muteConsole() {
|
|
867
1051
|
if (window.__nbbEzMuted) return;
|
|
868
1052
|
window.__nbbEzMuted = true;
|
|
869
|
-
|
|
1053
|
+
|
|
1054
|
+
const PREFIXES = [
|
|
870
1055
|
'[EzoicAds JS]: Placeholder Id',
|
|
871
1056
|
'No valid placeholders for loadMore',
|
|
872
1057
|
'cannot call refresh on the same page',
|
|
873
1058
|
'no placeholders are currently defined in Refresh',
|
|
874
1059
|
'Debugger iframe already exists',
|
|
875
|
-
|
|
1060
|
+
'[CMP] Error in custom getTCData',
|
|
1061
|
+
'vignette: no interstitial API',
|
|
876
1062
|
];
|
|
877
|
-
|
|
878
|
-
|
|
1063
|
+
const PH_PATTERN = `with id ${PH_PREFIX}`;
|
|
1064
|
+
|
|
1065
|
+
for (const method of ['log', 'info', 'warn', 'error']) {
|
|
1066
|
+
const orig = console[method];
|
|
879
1067
|
if (typeof orig !== 'function') continue;
|
|
880
|
-
console[
|
|
881
|
-
if (typeof
|
|
882
|
-
|
|
1068
|
+
console[method] = function (...args) {
|
|
1069
|
+
if (typeof args[0] === 'string') {
|
|
1070
|
+
const msg = args[0];
|
|
1071
|
+
for (const prefix of PREFIXES) {
|
|
1072
|
+
if (msg.startsWith(prefix)) return;
|
|
1073
|
+
}
|
|
1074
|
+
if (msg.includes(PH_PATTERN)) return;
|
|
1075
|
+
}
|
|
1076
|
+
return orig.apply(console, args);
|
|
883
1077
|
};
|
|
884
1078
|
}
|
|
885
1079
|
}
|
|
886
1080
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
if (document.getElementById('__tcfapiLocator')) return;
|
|
892
|
-
const f = document.createElement('iframe');
|
|
893
|
-
f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
|
|
894
|
-
(document.body || document.documentElement).appendChild(f);
|
|
895
|
-
};
|
|
896
|
-
inject();
|
|
897
|
-
if (!window.__nbbTcfObs) {
|
|
898
|
-
window.__nbbTcfObs = new MutationObserver(inject);
|
|
899
|
-
window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
|
|
900
|
-
}
|
|
901
|
-
} catch (_) {}
|
|
902
|
-
}
|
|
1081
|
+
// ── Network warmup ─────────────────────────────────────────────────────────
|
|
1082
|
+
// Run once per session — preconnect hints are in <head> via server-side injection
|
|
1083
|
+
|
|
1084
|
+
let _networkWarmed = false;
|
|
903
1085
|
|
|
904
|
-
const _warmed = new Set();
|
|
905
1086
|
function warmNetwork() {
|
|
1087
|
+
if (_networkWarmed) return;
|
|
1088
|
+
_networkWarmed = true;
|
|
1089
|
+
|
|
906
1090
|
const head = document.head;
|
|
907
1091
|
if (!head) return;
|
|
908
|
-
|
|
1092
|
+
|
|
1093
|
+
const hints = [
|
|
909
1094
|
['preconnect', 'https://g.ezoic.net', true ],
|
|
910
1095
|
['preconnect', 'https://go.ezoic.net', true ],
|
|
911
1096
|
['preconnect', 'https://securepubads.g.doubleclick.net', true ],
|
|
912
1097
|
['preconnect', 'https://pagead2.googlesyndication.com', true ],
|
|
913
1098
|
['dns-prefetch', 'https://g.ezoic.net', false],
|
|
914
1099
|
['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
|
|
915
|
-
]
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
_warmed.add(k);
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
for (const [rel, href, cors] of hints) {
|
|
919
1103
|
if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1104
|
+
const link = document.createElement('link');
|
|
1105
|
+
link.rel = rel;
|
|
1106
|
+
link.href = href;
|
|
1107
|
+
if (cors) link.crossOrigin = 'anonymous';
|
|
1108
|
+
head.appendChild(link);
|
|
924
1109
|
}
|
|
925
1110
|
}
|
|
926
1111
|
|
|
@@ -931,26 +1116,49 @@
|
|
|
931
1116
|
if (!$) return;
|
|
932
1117
|
|
|
933
1118
|
$(window).off('.nbbEzoic');
|
|
1119
|
+
|
|
934
1120
|
$(window).on('action:ajaxify.start.nbbEzoic', cleanup);
|
|
1121
|
+
|
|
935
1122
|
$(window).on('action:ajaxify.end.nbbEzoic', () => {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
blockedUntil = 0;
|
|
939
|
-
|
|
940
|
-
|
|
1123
|
+
state.pageKey = pageKey();
|
|
1124
|
+
state.kind = null;
|
|
1125
|
+
state.blockedUntil = 0;
|
|
1126
|
+
|
|
1127
|
+
// Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
|
|
1128
|
+
try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
|
|
1129
|
+
|
|
1130
|
+
muteConsole();
|
|
1131
|
+
ensureTcfLocator();
|
|
1132
|
+
warmNetwork();
|
|
1133
|
+
patchShowAds();
|
|
1134
|
+
getIO();
|
|
1135
|
+
ensureDomObserver();
|
|
1136
|
+
requestBurst();
|
|
941
1137
|
});
|
|
942
1138
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
'action:
|
|
1139
|
+
// Content-loaded events trigger burst
|
|
1140
|
+
const burstEvents = [
|
|
1141
|
+
'action:ajaxify.contentLoaded',
|
|
1142
|
+
'action:posts.loaded',
|
|
1143
|
+
'action:topics.loaded',
|
|
1144
|
+
'action:categories.loaded',
|
|
1145
|
+
'action:category.loaded',
|
|
1146
|
+
'action:topic.loaded',
|
|
946
1147
|
].map(e => `${e}.nbbEzoic`).join(' ');
|
|
947
|
-
$(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
|
|
948
1148
|
|
|
1149
|
+
$(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
|
|
1150
|
+
|
|
1151
|
+
// Also bind via NodeBB hooks module (for compatibility)
|
|
949
1152
|
try {
|
|
950
1153
|
require(['hooks'], hooks => {
|
|
951
1154
|
if (typeof hooks?.on !== 'function') return;
|
|
952
|
-
for (const ev of [
|
|
953
|
-
|
|
1155
|
+
for (const ev of [
|
|
1156
|
+
'action:ajaxify.end',
|
|
1157
|
+
'action:posts.loaded',
|
|
1158
|
+
'action:topics.loaded',
|
|
1159
|
+
'action:categories.loaded',
|
|
1160
|
+
'action:topic.loaded',
|
|
1161
|
+
]) {
|
|
954
1162
|
try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
|
|
955
1163
|
}
|
|
956
1164
|
});
|
|
@@ -962,13 +1170,16 @@
|
|
|
962
1170
|
window.addEventListener('scroll', () => {
|
|
963
1171
|
if (ticking) return;
|
|
964
1172
|
ticking = true;
|
|
965
|
-
requestAnimationFrame(() => {
|
|
1173
|
+
requestAnimationFrame(() => {
|
|
1174
|
+
ticking = false;
|
|
1175
|
+
requestBurst();
|
|
1176
|
+
});
|
|
966
1177
|
}, { passive: true });
|
|
967
1178
|
}
|
|
968
1179
|
|
|
969
1180
|
// ── Boot ───────────────────────────────────────────────────────────────────
|
|
970
1181
|
|
|
971
|
-
|
|
1182
|
+
state.pageKey = pageKey();
|
|
972
1183
|
muteConsole();
|
|
973
1184
|
ensureTcfLocator();
|
|
974
1185
|
warmNetwork();
|
|
@@ -977,7 +1188,7 @@
|
|
|
977
1188
|
ensureDomObserver();
|
|
978
1189
|
bindNodeBB();
|
|
979
1190
|
bindScroll();
|
|
980
|
-
blockedUntil = 0;
|
|
1191
|
+
state.blockedUntil = 0;
|
|
981
1192
|
requestBurst();
|
|
982
1193
|
|
|
983
1194
|
})();
|