nodebb-plugin-ezoic-infinite 1.8.35 → 1.8.37
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 +17 -2
- package/package.json +1 -1
- package/public/client.js +145 -140
package/library.js
CHANGED
|
@@ -173,7 +173,23 @@ plugin.injectEzoicHead = async (data) => {
|
|
|
173
173
|
const settings = await getSettings();
|
|
174
174
|
const uid = data.req?.uid ?? 0;
|
|
175
175
|
const excluded = await isUserExcluded(uid, settings.excludedGroups);
|
|
176
|
-
|
|
176
|
+
|
|
177
|
+
if (excluded) {
|
|
178
|
+
// Even for excluded users, inject a minimal stub so that any script
|
|
179
|
+
// referencing ezstandalone doesn't throw a ReferenceError, plus
|
|
180
|
+
// the config with excluded=true so client.js bails early.
|
|
181
|
+
const cfg = buildClientConfig(settings, true);
|
|
182
|
+
const stub = [
|
|
183
|
+
'<script>',
|
|
184
|
+
'window.ezstandalone = window.ezstandalone || {};',
|
|
185
|
+
'ezstandalone.cmd = ezstandalone.cmd || [];',
|
|
186
|
+
'</script>',
|
|
187
|
+
].join('\n');
|
|
188
|
+
data.templateData.customHTML =
|
|
189
|
+
stub + '\n' +
|
|
190
|
+
serializeInlineConfig(cfg) +
|
|
191
|
+
(data.templateData.customHTML || '');
|
|
192
|
+
} else {
|
|
177
193
|
const cfg = buildClientConfig(settings, false);
|
|
178
194
|
data.templateData.customHTML =
|
|
179
195
|
HEAD_PRECONNECTS + '\n' +
|
|
@@ -182,7 +198,6 @@ plugin.injectEzoicHead = async (data) => {
|
|
|
182
198
|
(data.templateData.customHTML || '');
|
|
183
199
|
}
|
|
184
200
|
} catch (err) {
|
|
185
|
-
// Log but don't break rendering
|
|
186
201
|
console.error('[ezoic-infinite] injectEzoicHead error:', err.message);
|
|
187
202
|
}
|
|
188
203
|
return data;
|
package/package.json
CHANGED
package/public/client.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* NodeBB Ezoic Infinite Ads — client.js v2.
|
|
2
|
+
* NodeBB Ezoic Infinite Ads — client.js v2.1.0
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* - Warm network: runs once per session, not per navigation
|
|
12
|
-
* - State machine: clear lifecycle for placeholders (idle → observed → queued → shown → recycled)
|
|
4
|
+
* Fixes in v2.1:
|
|
5
|
+
* - Early bail when user is excluded (no ezstandalone reference errors)
|
|
6
|
+
* - aria-hidden on <body>: MutationObserver removes it continuously
|
|
7
|
+
* - Recycle threshold raised to -3×vh to prevent mobile fast-scroll blank ads
|
|
8
|
+
* - Scroll-up: re-observe placeholders whose wraps come back into DOM
|
|
9
|
+
* - adsbygoogle "already have ads" error: clear ins before recycle
|
|
10
|
+
* - TCF/CMP 3-layer protection (head iframe, API guard, MutationObserver)
|
|
13
11
|
*/
|
|
14
12
|
(function nbbEzoicInfinite() {
|
|
15
13
|
'use strict';
|
|
@@ -19,7 +17,6 @@
|
|
|
19
17
|
const WRAP_CLASS = 'nodebb-ezoic-wrap';
|
|
20
18
|
const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
|
|
21
19
|
|
|
22
|
-
// Data attributes
|
|
23
20
|
const ATTR = {
|
|
24
21
|
ANCHOR: 'data-ezoic-anchor',
|
|
25
22
|
WRAPID: 'data-ezoic-wrapid',
|
|
@@ -27,7 +24,6 @@
|
|
|
27
24
|
SHOWN: 'data-ezoic-shown',
|
|
28
25
|
};
|
|
29
26
|
|
|
30
|
-
// Timing
|
|
31
27
|
const TIMING = {
|
|
32
28
|
EMPTY_CHECK_MS: 20_000,
|
|
33
29
|
MIN_PRUNE_AGE_MS: 8_000,
|
|
@@ -38,10 +34,8 @@
|
|
|
38
34
|
SHOW_RELEASE_MS: 700,
|
|
39
35
|
BATCH_FLUSH_MS: 80,
|
|
40
36
|
RECYCLE_DELAY_MS: 450,
|
|
41
|
-
|
|
42
37
|
};
|
|
43
38
|
|
|
44
|
-
// Limits
|
|
45
39
|
const LIMITS = {
|
|
46
40
|
MAX_INSERTS_RUN: 6,
|
|
47
41
|
MAX_INFLIGHT: 4,
|
|
@@ -55,21 +49,18 @@
|
|
|
55
49
|
MOBILE: '3500px 0px 3500px 0px',
|
|
56
50
|
};
|
|
57
51
|
|
|
58
|
-
// Selectors
|
|
59
52
|
const SEL = {
|
|
60
53
|
post: '[component="post"][data-pid]',
|
|
61
54
|
topic: 'li[component="category/topic"]',
|
|
62
55
|
category: 'li[component="categories/category"]',
|
|
63
56
|
};
|
|
64
57
|
|
|
65
|
-
// Kind configuration table — single source of truth per ad type
|
|
66
58
|
const KIND = {
|
|
67
59
|
'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
|
|
68
60
|
'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
|
|
69
61
|
'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
|
|
70
62
|
};
|
|
71
63
|
|
|
72
|
-
// Selector for detecting filled ad slots
|
|
73
64
|
const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
|
|
74
65
|
|
|
75
66
|
// ── Utility ────────────────────────────────────────────────────────────────
|
|
@@ -103,45 +94,37 @@
|
|
|
103
94
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
104
95
|
|
|
105
96
|
const state = {
|
|
106
|
-
// Page context
|
|
107
97
|
pageKey: null,
|
|
108
98
|
kind: null,
|
|
109
99
|
cfg: null,
|
|
110
100
|
|
|
111
|
-
// Pools
|
|
112
101
|
poolsReady: false,
|
|
113
102
|
pools: { topics: [], posts: [], categories: [] },
|
|
114
103
|
cursors: { topics: 0, posts: 0, categories: 0 },
|
|
115
104
|
|
|
116
|
-
// Mounted placeholders
|
|
117
105
|
mountedIds: new Set(),
|
|
118
|
-
phState: new Map(),
|
|
119
|
-
lastShow: new Map(),
|
|
106
|
+
phState: new Map(),
|
|
107
|
+
lastShow: new Map(),
|
|
120
108
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
wrapsByClass: new Map(), // kindClass → Set<wrap>
|
|
109
|
+
wrapByKey: new Map(),
|
|
110
|
+
wrapsByClass: new Map(),
|
|
124
111
|
|
|
125
|
-
// Observers
|
|
126
112
|
io: null,
|
|
127
113
|
domObs: null,
|
|
128
114
|
|
|
129
|
-
|
|
130
|
-
mutGuard: 0,
|
|
115
|
+
mutGuard: 0,
|
|
131
116
|
blockedUntil: 0,
|
|
132
117
|
|
|
133
|
-
// Show queue
|
|
134
118
|
inflight: 0,
|
|
135
119
|
pending: [],
|
|
136
120
|
pendingSet: new Set(),
|
|
137
121
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
burstActive: false,
|
|
122
|
+
runQueued: false,
|
|
123
|
+
burstActive: false,
|
|
141
124
|
burstDeadline: 0,
|
|
142
|
-
burstCount:
|
|
143
|
-
lastBurstTs:
|
|
144
|
-
firstShown:
|
|
125
|
+
burstCount: 0,
|
|
126
|
+
lastBurstTs: 0,
|
|
127
|
+
firstShown: false,
|
|
145
128
|
};
|
|
146
129
|
|
|
147
130
|
const isBlocked = () => now() < state.blockedUntil;
|
|
@@ -155,7 +138,6 @@
|
|
|
155
138
|
|
|
156
139
|
async function fetchConfig() {
|
|
157
140
|
if (state.cfg) return state.cfg;
|
|
158
|
-
// Prefer inline config injected by server (zero latency)
|
|
159
141
|
try {
|
|
160
142
|
const inline = window.__nbbEzoicCfg;
|
|
161
143
|
if (inline && typeof inline === 'object') {
|
|
@@ -163,7 +145,6 @@
|
|
|
163
145
|
return state.cfg;
|
|
164
146
|
}
|
|
165
147
|
} catch (_) {}
|
|
166
|
-
// Fallback to API
|
|
167
148
|
try {
|
|
168
149
|
const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
|
|
169
150
|
if (r.ok) state.cfg = await r.json();
|
|
@@ -195,7 +176,6 @@
|
|
|
195
176
|
if (/^\/topic\//.test(p)) return 'topic';
|
|
196
177
|
if (/^\/category\//.test(p)) return 'categoryTopics';
|
|
197
178
|
if (p === '/' || /^\/categories/.test(p)) return 'categories';
|
|
198
|
-
// DOM fallback
|
|
199
179
|
if (document.querySelector(SEL.category)) return 'categories';
|
|
200
180
|
if (document.querySelector(SEL.post)) return 'topic';
|
|
201
181
|
if (document.querySelector(SEL.topic)) return 'categoryTopics';
|
|
@@ -215,7 +195,6 @@
|
|
|
215
195
|
const el = all[i];
|
|
216
196
|
if (!el.isConnected) continue;
|
|
217
197
|
if (!el.querySelector('[component="post/content"]')) continue;
|
|
218
|
-
// Skip nested quotes / parent posts
|
|
219
198
|
const parent = el.parentElement?.closest(SEL.post);
|
|
220
199
|
if (parent && parent !== el) continue;
|
|
221
200
|
if (el.getAttribute('component') === 'post/parent') continue;
|
|
@@ -235,7 +214,6 @@
|
|
|
235
214
|
const v = el.getAttribute(attr);
|
|
236
215
|
if (v != null && v !== '') return v;
|
|
237
216
|
}
|
|
238
|
-
// Positional fallback
|
|
239
217
|
const children = el.parentElement?.children;
|
|
240
218
|
if (!children) return 'i0';
|
|
241
219
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -257,28 +235,21 @@
|
|
|
257
235
|
return set;
|
|
258
236
|
}
|
|
259
237
|
|
|
260
|
-
// ── Wrap lifecycle
|
|
238
|
+
// ── Wrap lifecycle ─────────────────────────────────────────────────────────
|
|
261
239
|
|
|
262
|
-
/**
|
|
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.
|
|
265
|
-
*/
|
|
266
240
|
function wrapIsLive(wrap) {
|
|
267
241
|
if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
|
|
268
242
|
const key = wrap.getAttribute(ATTR.ANCHOR);
|
|
269
243
|
if (!key) return false;
|
|
270
244
|
|
|
271
|
-
// Fast path: registry match
|
|
272
245
|
if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
|
|
273
246
|
|
|
274
|
-
// Parse key
|
|
275
247
|
const colonIdx = key.indexOf(':');
|
|
276
248
|
const klass = key.slice(0, colonIdx);
|
|
277
249
|
const anchorId = key.slice(colonIdx + 1);
|
|
278
250
|
const cfg = KIND[klass];
|
|
279
251
|
if (!cfg) return false;
|
|
280
252
|
|
|
281
|
-
// Sibling scan (cheap for adjacent anchors)
|
|
282
253
|
const parent = wrap.parentElement;
|
|
283
254
|
if (parent) {
|
|
284
255
|
const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
|
|
@@ -289,7 +260,6 @@
|
|
|
289
260
|
}
|
|
290
261
|
}
|
|
291
262
|
|
|
292
|
-
// Global fallback (expensive, rare)
|
|
293
263
|
try {
|
|
294
264
|
return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
|
|
295
265
|
} catch (_) { return false; }
|
|
@@ -313,8 +283,7 @@
|
|
|
313
283
|
|
|
314
284
|
function scheduleUncollapseChecks(wrap) {
|
|
315
285
|
if (!wrap) return;
|
|
316
|
-
const
|
|
317
|
-
for (const ms of delays) {
|
|
286
|
+
for (const ms of [500, 1500, 3000, 7000, 15000]) {
|
|
318
287
|
setTimeout(() => {
|
|
319
288
|
try { clearEmptyIfFilled(wrap); } catch (_) {}
|
|
320
289
|
}, ms);
|
|
@@ -336,11 +305,17 @@
|
|
|
336
305
|
}
|
|
337
306
|
|
|
338
307
|
// ── Recycling ──────────────────────────────────────────────────────────────
|
|
308
|
+
//
|
|
309
|
+
// v2.1 changes:
|
|
310
|
+
// - Threshold raised to -(3 × vh) — on mobile fast scroll, -vh was too close
|
|
311
|
+
// and wraps got recycled before ads had time to load
|
|
312
|
+
// - Never recycle a wrap whose showAds is still inflight (show-queued state)
|
|
313
|
+
// - Clear ins.adsbygoogle children before re-creating placeholder to avoid
|
|
314
|
+
// "All ins elements already have ads" error
|
|
315
|
+
// - Skip wraps that were created less than 5s ago (give ads time to fill)
|
|
316
|
+
|
|
317
|
+
const RECYCLE_MIN_AGE_MS = 5_000;
|
|
339
318
|
|
|
340
|
-
/**
|
|
341
|
-
* When pool is exhausted, recycle a wrap far above the viewport.
|
|
342
|
-
* Sequence: destroy → delay → re-observe → enqueueShow
|
|
343
|
-
*/
|
|
344
319
|
function recycleWrap(klass, targetEl, newKey) {
|
|
345
320
|
const ez = window.ezstandalone;
|
|
346
321
|
if (typeof ez?.destroyPlaceholders !== 'function' ||
|
|
@@ -348,7 +323,9 @@
|
|
|
348
323
|
typeof ez?.displayMore !== 'function') return null;
|
|
349
324
|
|
|
350
325
|
const vh = window.innerHeight || 800;
|
|
351
|
-
|
|
326
|
+
// Conservative threshold: 3× viewport height above the top of the screen
|
|
327
|
+
const threshold = -(3 * vh);
|
|
328
|
+
const t = now();
|
|
352
329
|
let bestEmpty = null, bestEmptyY = Infinity;
|
|
353
330
|
let bestFull = null, bestFullY = Infinity;
|
|
354
331
|
|
|
@@ -357,8 +334,17 @@
|
|
|
357
334
|
|
|
358
335
|
for (const wrap of wraps) {
|
|
359
336
|
try {
|
|
337
|
+
// Don't recycle wraps that are too young (ads might still be loading)
|
|
338
|
+
const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
|
|
339
|
+
if (t - created < RECYCLE_MIN_AGE_MS) continue;
|
|
340
|
+
|
|
341
|
+
// Don't recycle wraps with inflight showAds
|
|
342
|
+
const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
|
|
343
|
+
if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
|
|
344
|
+
|
|
360
345
|
const bottom = wrap.getBoundingClientRect().bottom;
|
|
361
346
|
if (bottom > threshold) continue;
|
|
347
|
+
|
|
362
348
|
if (!isFilled(wrap)) {
|
|
363
349
|
if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
|
|
364
350
|
} else {
|
|
@@ -375,18 +361,20 @@
|
|
|
375
361
|
|
|
376
362
|
const oldKey = best.getAttribute(ATTR.ANCHOR);
|
|
377
363
|
|
|
378
|
-
// Unobserve before moving
|
|
364
|
+
// Unobserve before moving
|
|
379
365
|
try {
|
|
380
366
|
const ph = best.querySelector(`#${PH_PREFIX}${id}`);
|
|
381
367
|
if (ph) state.io?.unobserve(ph);
|
|
382
368
|
} catch (_) {}
|
|
383
369
|
|
|
384
|
-
// Move the wrap to new position
|
|
370
|
+
// Move the wrap to new position with a clean placeholder
|
|
385
371
|
mutate(() => {
|
|
386
372
|
best.setAttribute(ATTR.ANCHOR, newKey);
|
|
387
373
|
best.setAttribute(ATTR.CREATED, String(now()));
|
|
388
374
|
best.setAttribute(ATTR.SHOWN, '0');
|
|
389
375
|
best.classList.remove('is-empty');
|
|
376
|
+
// Clear ALL children including ins.adsbygoogle to avoid
|
|
377
|
+
// "All ins elements already have ads" error on re-show
|
|
390
378
|
best.replaceChildren();
|
|
391
379
|
|
|
392
380
|
const fresh = document.createElement('div');
|
|
@@ -397,11 +385,10 @@
|
|
|
397
385
|
targetEl.insertAdjacentElement('afterend', best);
|
|
398
386
|
});
|
|
399
387
|
|
|
400
|
-
// Update registry
|
|
401
388
|
if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
|
|
402
389
|
state.wrapByKey.set(newKey, best);
|
|
403
390
|
|
|
404
|
-
// Ezoic
|
|
391
|
+
// Ezoic destroy → re-define → re-show sequence
|
|
405
392
|
const doDestroy = () => {
|
|
406
393
|
state.phState.set(id, 'destroyed');
|
|
407
394
|
try { ez.destroyPlaceholders(id); } catch (_) {
|
|
@@ -444,7 +431,6 @@
|
|
|
444
431
|
if (!el?.insertAdjacentElement) return null;
|
|
445
432
|
if (findWrap(key)) return null;
|
|
446
433
|
if (state.mountedIds.has(id)) return null;
|
|
447
|
-
// Ensure no duplicate DOM element with same placeholder ID
|
|
448
434
|
const existing = document.getElementById(`${PH_PREFIX}${id}`);
|
|
449
435
|
if (existing?.isConnected) return null;
|
|
450
436
|
|
|
@@ -471,7 +457,6 @@
|
|
|
471
457
|
const key = w.getAttribute(ATTR.ANCHOR);
|
|
472
458
|
if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
|
|
473
459
|
|
|
474
|
-
// Find the kind class to unregister
|
|
475
460
|
for (const cls of w.classList) {
|
|
476
461
|
if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
|
|
477
462
|
state.wrapsByClass.get(cls)?.delete(w);
|
|
@@ -483,9 +468,6 @@
|
|
|
483
468
|
}
|
|
484
469
|
|
|
485
470
|
// ── Prune (category topic lists only) ──────────────────────────────────────
|
|
486
|
-
//
|
|
487
|
-
// Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
|
|
488
|
-
// NOT safe for posts: NodeBB virtualizes posts off-viewport.
|
|
489
471
|
|
|
490
472
|
function pruneOrphansBetween() {
|
|
491
473
|
const klass = 'ezoic-ad-between';
|
|
@@ -493,7 +475,6 @@
|
|
|
493
475
|
const wraps = state.wrapsByClass.get(klass);
|
|
494
476
|
if (!wraps?.size) return;
|
|
495
477
|
|
|
496
|
-
// Build set of live anchor IDs
|
|
497
478
|
const liveAnchors = new Set();
|
|
498
479
|
for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
|
|
499
480
|
const v = el.getAttribute(cfg.anchorAttr);
|
|
@@ -504,7 +485,6 @@
|
|
|
504
485
|
for (const w of wraps) {
|
|
505
486
|
const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
|
|
506
487
|
if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
|
|
507
|
-
|
|
508
488
|
const key = w.getAttribute(ATTR.ANCHOR) ?? '';
|
|
509
489
|
const sid = key.slice(klass.length + 1);
|
|
510
490
|
if (!sid || !liveAnchors.has(sid)) {
|
|
@@ -521,7 +501,6 @@
|
|
|
521
501
|
const v = el.getAttribute(attr);
|
|
522
502
|
if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
|
|
523
503
|
}
|
|
524
|
-
// Positional fallback
|
|
525
504
|
const fullSel = KIND[klass]?.sel ?? '';
|
|
526
505
|
let i = 0;
|
|
527
506
|
for (const s of el.parentElement?.children ?? []) {
|
|
@@ -556,7 +535,7 @@
|
|
|
556
535
|
}
|
|
557
536
|
} else {
|
|
558
537
|
const recycled = recycleWrap(klass, el, key);
|
|
559
|
-
if (!recycled) break;
|
|
538
|
+
if (!recycled) break;
|
|
560
539
|
inserted++;
|
|
561
540
|
}
|
|
562
541
|
}
|
|
@@ -571,7 +550,10 @@
|
|
|
571
550
|
state.io = new IntersectionObserver(entries => {
|
|
572
551
|
for (const entry of entries) {
|
|
573
552
|
if (!entry.isIntersecting) continue;
|
|
574
|
-
|
|
553
|
+
// DON'T unobserve — we need to re-trigger on scroll-up when NodeBB
|
|
554
|
+
// re-inserts virtualized posts back into the DOM.
|
|
555
|
+
// Instead, the show throttle (SHOW_THROTTLE_MS) and phState check
|
|
556
|
+
// in enqueueShow prevent duplicate calls.
|
|
575
557
|
const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
|
|
576
558
|
if (id > 0) enqueueShow(id);
|
|
577
559
|
}
|
|
@@ -685,7 +667,6 @@
|
|
|
685
667
|
const ph = document.getElementById(`${PH_PREFIX}${id}`);
|
|
686
668
|
const wrap = ph?.closest(`.${WRAP_CLASS}`);
|
|
687
669
|
if (!wrap || !ph?.isConnected) return;
|
|
688
|
-
// Skip if a newer show happened since
|
|
689
670
|
if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
|
|
690
671
|
if (clearEmptyIfFilled(wrap)) return;
|
|
691
672
|
wrap.classList.add('is-empty');
|
|
@@ -694,11 +675,6 @@
|
|
|
694
675
|
}
|
|
695
676
|
|
|
696
677
|
// ── Patch Ezoic showAds ────────────────────────────────────────────────────
|
|
697
|
-
//
|
|
698
|
-
// Intercepts ez.showAds() to:
|
|
699
|
-
// - block calls during navigation transitions
|
|
700
|
-
// - filter out disconnected placeholders
|
|
701
|
-
// - batch calls for efficiency
|
|
702
678
|
|
|
703
679
|
function patchShowAds() {
|
|
704
680
|
const apply = () => {
|
|
@@ -760,6 +736,44 @@
|
|
|
760
736
|
}
|
|
761
737
|
}
|
|
762
738
|
|
|
739
|
+
// ── Re-observe on scroll-up ────────────────────────────────────────────────
|
|
740
|
+
//
|
|
741
|
+
// NodeBB virtualizes posts: removes them from DOM when off-viewport, then
|
|
742
|
+
// re-inserts them when scrolling back up. Our wraps stay in the DOM but
|
|
743
|
+
// the placeholder was already unobserved by IO (in v2.0) or the phState
|
|
744
|
+
// was 'shown'. We need to check if wraps that are back in viewport have
|
|
745
|
+
// unfilled placeholders and re-trigger show for them.
|
|
746
|
+
|
|
747
|
+
function reobserveVisibleWraps() {
|
|
748
|
+
const vh = window.innerHeight || 800;
|
|
749
|
+
for (const [key, wrap] of state.wrapByKey) {
|
|
750
|
+
if (!wrap.isConnected) continue;
|
|
751
|
+
const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
|
|
752
|
+
if (!id || id <= 0) continue;
|
|
753
|
+
|
|
754
|
+
const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
755
|
+
if (!ph?.isConnected) continue;
|
|
756
|
+
|
|
757
|
+
// Already filled — nothing to do
|
|
758
|
+
if (isFilled(ph) || isPlaceholderUsed(ph)) continue;
|
|
759
|
+
|
|
760
|
+
// Check if the wrap is in or near the viewport
|
|
761
|
+
const rect = wrap.getBoundingClientRect();
|
|
762
|
+
if (rect.bottom < -vh || rect.top > 2 * vh) continue;
|
|
763
|
+
|
|
764
|
+
// This wrap is visible-ish but unfilled — re-trigger
|
|
765
|
+
const st = state.phState.get(id);
|
|
766
|
+
if (st === 'shown' || st === 'show-queued') {
|
|
767
|
+
// Reset state so enqueueShow accepts it again
|
|
768
|
+
state.phState.set(id, 'new');
|
|
769
|
+
state.lastShow.delete(id);
|
|
770
|
+
}
|
|
771
|
+
// Re-observe in IO (safe to call multiple times)
|
|
772
|
+
try { getIO()?.observe(ph); } catch (_) {}
|
|
773
|
+
enqueueShow(id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
763
777
|
// ── Core ───────────────────────────────────────────────────────────────────
|
|
764
778
|
|
|
765
779
|
async function runCore() {
|
|
@@ -772,6 +786,9 @@
|
|
|
772
786
|
const kind = getKind();
|
|
773
787
|
if (kind === 'other') return 0;
|
|
774
788
|
|
|
789
|
+
// Re-observe wraps that came back into viewport (scroll-up fix)
|
|
790
|
+
reobserveVisibleWraps();
|
|
791
|
+
|
|
775
792
|
const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
|
|
776
793
|
if (!normBool(cfgEnable)) return 0;
|
|
777
794
|
const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
|
|
@@ -867,8 +884,6 @@
|
|
|
867
884
|
}
|
|
868
885
|
|
|
869
886
|
// ── MutationObserver ───────────────────────────────────────────────────────
|
|
870
|
-
//
|
|
871
|
-
// Scoped to detect: (1) ad fill events in wraps, (2) new content items
|
|
872
887
|
|
|
873
888
|
function ensureDomObserver() {
|
|
874
889
|
if (state.domObs) return;
|
|
@@ -878,13 +893,12 @@
|
|
|
878
893
|
|
|
879
894
|
let needsBurst = false;
|
|
880
895
|
|
|
881
|
-
// Determine relevant selectors for current page kind
|
|
882
896
|
const kind = getKind();
|
|
883
897
|
const relevantSels =
|
|
884
|
-
kind === 'topic'
|
|
885
|
-
kind === 'categoryTopics'? [SEL.topic] :
|
|
886
|
-
kind === 'categories'
|
|
887
|
-
|
|
898
|
+
kind === 'topic' ? [SEL.post] :
|
|
899
|
+
kind === 'categoryTopics' ? [SEL.topic] :
|
|
900
|
+
kind === 'categories' ? [SEL.category] :
|
|
901
|
+
[SEL.post, SEL.topic, SEL.category];
|
|
888
902
|
|
|
889
903
|
for (const m of muts) {
|
|
890
904
|
if (m.type !== 'childList') continue;
|
|
@@ -900,7 +914,7 @@
|
|
|
900
914
|
}
|
|
901
915
|
} catch (_) {}
|
|
902
916
|
|
|
903
|
-
// Check for new content items
|
|
917
|
+
// Check for new content items
|
|
904
918
|
if (!needsBurst) {
|
|
905
919
|
for (const sel of relevantSels) {
|
|
906
920
|
try {
|
|
@@ -923,52 +937,30 @@
|
|
|
923
937
|
} catch (_) {}
|
|
924
938
|
}
|
|
925
939
|
|
|
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>.
|
|
940
|
+
// ── TCF / CMP Protection ───────────────────────────────────────────────────
|
|
943
941
|
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// 3. RESTORE: MutationObserver on <body> childList (not subtree) to
|
|
949
|
-
// immediately re-create the locator if something still removes it.
|
|
942
|
+
// 3 layers:
|
|
943
|
+
// 1. PROTECT: locator iframe in <head> (ajaxify never touches <head>)
|
|
944
|
+
// 2. GUARD: wrap __tcfapi/__cmp to catch null contentWindow errors
|
|
945
|
+
// 3. RESTORE: MutationObserver for immediate re-creation
|
|
950
946
|
|
|
951
947
|
function ensureTcfLocator() {
|
|
952
948
|
if (!window.__tcfapi && !window.__cmp) return;
|
|
953
949
|
|
|
954
950
|
const LOCATOR_ID = '__tcfapiLocator';
|
|
955
951
|
|
|
956
|
-
// Create or relocate the locator iframe into <head> for protection
|
|
957
952
|
const ensureInHead = () => {
|
|
958
953
|
let existing = document.getElementById(LOCATOR_ID);
|
|
959
954
|
if (existing) {
|
|
960
|
-
// If it's in <body>, move it to <head> where ajaxify can't reach it
|
|
961
955
|
if (existing.parentElement !== document.head) {
|
|
962
956
|
try { document.head.appendChild(existing); } catch (_) {}
|
|
963
957
|
}
|
|
964
958
|
return existing;
|
|
965
959
|
}
|
|
966
|
-
// Create fresh
|
|
967
960
|
const f = document.createElement('iframe');
|
|
968
961
|
f.style.display = 'none';
|
|
969
962
|
f.id = f.name = LOCATOR_ID;
|
|
970
963
|
try { document.head.appendChild(f); } catch (_) {
|
|
971
|
-
// Fallback to body if head insertion fails
|
|
972
964
|
(document.body || document.documentElement).appendChild(f);
|
|
973
965
|
}
|
|
974
966
|
return f;
|
|
@@ -976,11 +968,10 @@
|
|
|
976
968
|
|
|
977
969
|
ensureInHead();
|
|
978
970
|
|
|
979
|
-
//
|
|
971
|
+
// Guard CMP API calls
|
|
980
972
|
if (!window.__nbbCmpGuarded) {
|
|
981
973
|
window.__nbbCmpGuarded = true;
|
|
982
974
|
|
|
983
|
-
// Wrap __tcfapi
|
|
984
975
|
if (typeof window.__tcfapi === 'function') {
|
|
985
976
|
const origTcf = window.__tcfapi;
|
|
986
977
|
window.__tcfapi = function (cmd, version, cb, param) {
|
|
@@ -989,9 +980,7 @@
|
|
|
989
980
|
try { cb?.(...args); } catch (_) {}
|
|
990
981
|
}, param);
|
|
991
982
|
} catch (e) {
|
|
992
|
-
// If the error is the null postMessage/addtlConsent, swallow it
|
|
993
983
|
if (e?.message?.includes('null')) {
|
|
994
|
-
// Re-ensure locator exists, then retry once
|
|
995
984
|
ensureInHead();
|
|
996
985
|
try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
|
|
997
986
|
}
|
|
@@ -999,7 +988,6 @@
|
|
|
999
988
|
};
|
|
1000
989
|
}
|
|
1001
990
|
|
|
1002
|
-
// Wrap __cmp (legacy CMP v1 API)
|
|
1003
991
|
if (typeof window.__cmp === 'function') {
|
|
1004
992
|
const origCmp = window.__cmp;
|
|
1005
993
|
window.__cmp = function (...args) {
|
|
@@ -1015,37 +1003,54 @@
|
|
|
1015
1003
|
}
|
|
1016
1004
|
}
|
|
1017
1005
|
|
|
1018
|
-
// Layer 3: MutationObserver to immediately restore if removed
|
|
1019
1006
|
if (!window.__nbbTcfObs) {
|
|
1020
|
-
window.__nbbTcfObs = new MutationObserver(
|
|
1021
|
-
// Fast check: still in document?
|
|
1007
|
+
window.__nbbTcfObs = new MutationObserver(() => {
|
|
1022
1008
|
if (document.getElementById(LOCATOR_ID)) return;
|
|
1023
|
-
// Something removed it — restore immediately (no debounce)
|
|
1024
1009
|
ensureInHead();
|
|
1025
1010
|
});
|
|
1026
|
-
// Observe body direct children only (the most likely removal point)
|
|
1027
1011
|
try {
|
|
1028
1012
|
window.__nbbTcfObs.observe(document.body || document.documentElement, {
|
|
1029
|
-
childList: true,
|
|
1030
|
-
subtree: false,
|
|
1013
|
+
childList: true, subtree: false,
|
|
1031
1014
|
});
|
|
1032
1015
|
} catch (_) {}
|
|
1033
|
-
// Also observe <head> in case something cleans it
|
|
1034
1016
|
try {
|
|
1035
1017
|
if (document.head) {
|
|
1036
1018
|
window.__nbbTcfObs.observe(document.head, {
|
|
1037
|
-
childList: true,
|
|
1038
|
-
subtree: false,
|
|
1019
|
+
childList: true, subtree: false,
|
|
1039
1020
|
});
|
|
1040
1021
|
}
|
|
1041
1022
|
} catch (_) {}
|
|
1042
1023
|
}
|
|
1043
1024
|
}
|
|
1044
1025
|
|
|
1045
|
-
// ──
|
|
1026
|
+
// ── aria-hidden protection ─────────────────────────────────────────────────
|
|
1027
|
+
//
|
|
1028
|
+
// The CMP modal sets aria-hidden="true" on <body> when opening, and may not
|
|
1029
|
+
// remove it after ajaxify navigation. A simple removeAttribute in ajaxify.end
|
|
1030
|
+
// isn't enough because the CMP can re-set it asynchronously.
|
|
1046
1031
|
//
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1032
|
+
// Use a MutationObserver on <body> attributes to remove it immediately.
|
|
1033
|
+
|
|
1034
|
+
function protectAriaHidden() {
|
|
1035
|
+
if (window.__nbbAriaObs) return;
|
|
1036
|
+
const remove = () => {
|
|
1037
|
+
try {
|
|
1038
|
+
if (document.body.getAttribute('aria-hidden') === 'true') {
|
|
1039
|
+
document.body.removeAttribute('aria-hidden');
|
|
1040
|
+
}
|
|
1041
|
+
} catch (_) {}
|
|
1042
|
+
};
|
|
1043
|
+
remove();
|
|
1044
|
+
window.__nbbAriaObs = new MutationObserver(remove);
|
|
1045
|
+
try {
|
|
1046
|
+
window.__nbbAriaObs.observe(document.body, {
|
|
1047
|
+
attributes: true,
|
|
1048
|
+
attributeFilter: ['aria-hidden'],
|
|
1049
|
+
});
|
|
1050
|
+
} catch (_) {}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── Console muting ─────────────────────────────────────────────────────────
|
|
1049
1054
|
|
|
1050
1055
|
function muteConsole() {
|
|
1051
1056
|
if (window.__nbbEzMuted) return;
|
|
@@ -1060,7 +1065,10 @@
|
|
|
1060
1065
|
'[CMP] Error in custom getTCData',
|
|
1061
1066
|
'vignette: no interstitial API',
|
|
1062
1067
|
];
|
|
1063
|
-
const
|
|
1068
|
+
const PATTERNS = [
|
|
1069
|
+
`with id ${PH_PREFIX}`,
|
|
1070
|
+
'adsbygoogle.push() error: All',
|
|
1071
|
+
];
|
|
1064
1072
|
|
|
1065
1073
|
for (const method of ['log', 'info', 'warn', 'error']) {
|
|
1066
1074
|
const orig = console[method];
|
|
@@ -1071,7 +1079,9 @@
|
|
|
1071
1079
|
for (const prefix of PREFIXES) {
|
|
1072
1080
|
if (msg.startsWith(prefix)) return;
|
|
1073
1081
|
}
|
|
1074
|
-
|
|
1082
|
+
for (const pat of PATTERNS) {
|
|
1083
|
+
if (msg.includes(pat)) return;
|
|
1084
|
+
}
|
|
1075
1085
|
}
|
|
1076
1086
|
return orig.apply(console, args);
|
|
1077
1087
|
};
|
|
@@ -1079,7 +1089,6 @@
|
|
|
1079
1089
|
}
|
|
1080
1090
|
|
|
1081
1091
|
// ── Network warmup ─────────────────────────────────────────────────────────
|
|
1082
|
-
// Run once per session — preconnect hints are in <head> via server-side injection
|
|
1083
1092
|
|
|
1084
1093
|
let _networkWarmed = false;
|
|
1085
1094
|
|
|
@@ -1116,19 +1125,16 @@
|
|
|
1116
1125
|
if (!$) return;
|
|
1117
1126
|
|
|
1118
1127
|
$(window).off('.nbbEzoic');
|
|
1119
|
-
|
|
1120
1128
|
$(window).on('action:ajaxify.start.nbbEzoic', cleanup);
|
|
1121
1129
|
|
|
1122
1130
|
$(window).on('action:ajaxify.end.nbbEzoic', () => {
|
|
1123
|
-
state.pageKey
|
|
1124
|
-
state.kind
|
|
1131
|
+
state.pageKey = pageKey();
|
|
1132
|
+
state.kind = null;
|
|
1125
1133
|
state.blockedUntil = 0;
|
|
1126
1134
|
|
|
1127
|
-
// Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
|
|
1128
|
-
try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
|
|
1129
|
-
|
|
1130
1135
|
muteConsole();
|
|
1131
1136
|
ensureTcfLocator();
|
|
1137
|
+
protectAriaHidden();
|
|
1132
1138
|
warmNetwork();
|
|
1133
1139
|
patchShowAds();
|
|
1134
1140
|
getIO();
|
|
@@ -1136,7 +1142,6 @@
|
|
|
1136
1142
|
requestBurst();
|
|
1137
1143
|
});
|
|
1138
1144
|
|
|
1139
|
-
// Content-loaded events trigger burst
|
|
1140
1145
|
const burstEvents = [
|
|
1141
1146
|
'action:ajaxify.contentLoaded',
|
|
1142
1147
|
'action:posts.loaded',
|
|
@@ -1148,7 +1153,6 @@
|
|
|
1148
1153
|
|
|
1149
1154
|
$(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
|
|
1150
1155
|
|
|
1151
|
-
// Also bind via NodeBB hooks module (for compatibility)
|
|
1152
1156
|
try {
|
|
1153
1157
|
require(['hooks'], hooks => {
|
|
1154
1158
|
if (typeof hooks?.on !== 'function') return;
|
|
@@ -1182,6 +1186,7 @@
|
|
|
1182
1186
|
state.pageKey = pageKey();
|
|
1183
1187
|
muteConsole();
|
|
1184
1188
|
ensureTcfLocator();
|
|
1189
|
+
protectAriaHidden();
|
|
1185
1190
|
warmNetwork();
|
|
1186
1191
|
patchShowAds();
|
|
1187
1192
|
getIO();
|