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