nodebb-plugin-ezoic-infinite 1.1.6 → 1.2.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +229 -355
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.1.6",
3
+ "version": "1.2.3",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -2,93 +2,77 @@
2
2
  (function () {
3
3
  'use strict';
4
4
 
5
- // NodeBB client env provides jQuery. We keep it optional.
6
- const $w = (typeof window.jQuery === 'function') ? window.jQuery(window) : null;
5
+ // Optional debug switch
6
+ const DEBUG = !!window.__ezoicInfiniteDebug;
7
+
8
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
7
9
 
8
10
  const SELECTORS = {
9
- topicListItem: 'li[component="category/topic"]',
11
+ topicItem: 'li[component="category/topic"]',
10
12
  postItem: '[component="post"][data-pid]',
11
- postContent: '[component="post/content"]',
12
13
  };
13
14
 
14
15
  const WRAP_CLASS = 'ezoic-ad';
15
16
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
16
17
 
18
+ // Prevent “burst” injection: at most N inserts per run per kind
19
+ const MAX_INSERTS_PER_RUN = 2;
20
+
17
21
  const state = {
18
22
  pageKey: null,
19
23
  cfg: null,
20
24
  cfgPromise: null,
21
- // pools and used ids
22
- usedBetween: new Set(),
23
- usedMessage: new Set(),
24
- fifoBetween: [],
25
- fifoMessage: [],
26
- // showAds anti-double
27
- lastShowById: {},
28
- pendingById: {},
29
- // retry bookkeeping
30
- retryCount: {},
31
- observers: {},
32
- };
33
25
 
34
- function patchShowAds() {
35
- try {
36
- window.ezstandalone = window.ezstandalone || {};
37
- const ez = window.ezstandalone;
38
- if (ez.__nodebbEzoicPatched) return;
39
- if (typeof ez.showAds !== 'function') return;
26
+ // Per-page pools (refilled by recycling)
27
+ poolTopics: [],
28
+ poolPosts: [],
40
29
 
41
- ez.__nodebbEzoicPatched = true;
42
- const orig = ez.showAds;
30
+ // Track inserted ads per page
31
+ usedTopics: new Set(),
32
+ usedPosts: new Set(),
43
33
 
44
- ez.showAds = function showAdsPatched(arg) {
45
- if (Array.isArray(arg)) {
46
- // This batch call is NOT coming from this plugin (we never call showAds with arrays).
47
- // Split into individual calls to avoid Ezoic loadMore/placeholder issues.
48
- try {
49
- if (window.__ezoicInfiniteDebug) {
50
- console.warn('[ezoic-infinite] showAds(batch) detected. Source stack:', new Error().stack);
51
- }
52
- } catch (e) {}
34
+ // Track which anchors we already evaluated to avoid reprocessing everything on each event
35
+ seenTopicAnchors: new WeakSet(),
36
+ seenPostAnchors: new WeakSet(),
53
37
 
54
- const seen = new Set();
55
- for (const v of arg) {
56
- const id = parseInt(v, 10);
57
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
58
- seen.add(id);
59
- try { orig.call(ez, id); } catch (e) {}
60
- }
61
- return;
62
- }
63
- return orig.apply(ez, arguments);
64
- };
65
- } catch (e) {}
66
- }
38
+ // showAds anti-double
39
+ lastShowById: new Map(),
40
+ pendingById: new Set(),
41
+
42
+ // debounce
43
+ scheduled: false,
44
+ timer: null,
45
+
46
+ // observers
47
+ obs: null,
48
+ };
49
+
50
+ function log(...args) { if (DEBUG) console.log('[ezoic-infinite]', ...args); }
51
+ function warn(...args) { if (DEBUG) console.warn('[ezoic-infinite]', ...args); }
67
52
 
68
53
  function normalizeBool(v) {
69
54
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
70
55
  }
71
56
 
72
- function parsePool(raw) {
73
- if (!raw) return [];
74
- const lines = String(raw)
75
- .split(/\r?\n/)
76
- .map(s => s.trim())
77
- .filter(Boolean);
78
-
79
- const ids = lines
80
- .map(s => parseInt(s, 10))
81
- .filter(n => Number.isFinite(n) && n > 0);
82
-
83
- // unique preserve order
57
+ function uniqInts(lines) {
84
58
  const out = [];
85
59
  const seen = new Set();
86
- for (const id of ids) {
87
- if (!seen.has(id)) { seen.add(id); out.push(id); }
60
+ for (const v of lines) {
61
+ const n = parseInt(v, 10);
62
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
63
+ seen.add(n);
64
+ out.push(n);
65
+ }
88
66
  }
89
67
  return out;
90
68
  }
91
69
 
70
+ function parsePool(raw) {
71
+ if (!raw) return [];
72
+ const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
73
+ return uniqInts(lines);
74
+ }
75
+
92
76
  function getPageKey() {
93
77
  try {
94
78
  const ax = window.ajaxify;
@@ -100,65 +84,83 @@
100
84
  return window.location.pathname;
101
85
  }
102
86
 
103
- function getPageKind() {
87
+ function getKind() {
104
88
  const p = window.location.pathname || '';
105
89
  if (/^\/topic\//.test(p)) return 'topic';
106
90
  if (/^\/category\//.test(p)) return 'category';
107
- // fallback hints
91
+ // fallback
108
92
  if (document.querySelector(SELECTORS.postItem)) return 'topic';
109
- if (document.querySelector(SELECTORS.topicListItem)) return 'category';
110
93
  return 'category';
111
94
  }
112
95
 
113
- function safeGetRect(el) {
114
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
115
- }
116
-
117
- function hasAdImmediatelyAfter(targetEl) {
118
- if (!targetEl) return false;
119
- const next = targetEl.nextElementSibling;
120
- return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
96
+ function hasAdImmediatelyAfter(el) {
97
+ const n = el && el.nextElementSibling;
98
+ return !!(n && n.classList && n.classList.contains(WRAP_CLASS));
121
99
  }
122
100
 
123
101
  function buildWrap(id, kind, afterPos) {
124
102
  const wrap = document.createElement('div');
125
103
  wrap.className = `${WRAP_CLASS} ${kind}`;
126
104
  wrap.setAttribute('data-ezoic-after', String(afterPos));
127
- wrap.setAttribute('data-ezoic-kind', kind);
128
105
  wrap.style.width = '100%';
129
-
130
106
  const ph = document.createElement('div');
131
107
  ph.id = `${PLACEHOLDER_PREFIX}${id}`;
132
108
  wrap.appendChild(ph);
133
-
134
109
  return wrap;
135
110
  }
136
111
 
137
- function insertAfter(targetEl, id, kind, afterPos) {
138
- if (!targetEl || !targetEl.insertAdjacentElement) return null;
112
+ function insertAfter(target, id, kind, afterPos) {
113
+ if (!target || !target.insertAdjacentElement) return null;
139
114
  const wrap = buildWrap(id, kind, afterPos);
140
- targetEl.insertAdjacentElement('afterend', wrap);
115
+ target.insertAdjacentElement('afterend', wrap);
141
116
  return wrap;
142
117
  }
143
118
 
144
- function destroyPlaceholder(id) {
119
+ function safeRect(el) {
120
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
121
+ }
122
+ } catch (e) {}
123
+ }
124
+
125
+ // Patch ezstandalone.showAds to split batch calls (we NEVER call batch from this plugin)
126
+ function patchShowAds() {
145
127
  try {
146
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
147
- window.ezstandalone.destroyPlaceholders([id]);
148
- }
128
+ window.ezstandalone = window.ezstandalone || {};
129
+ const ez = window.ezstandalone;
130
+ if (ez.__nodebbEzoicPatched) return;
131
+ if (typeof ez.showAds !== 'function') return;
132
+
133
+ ez.__nodebbEzoicPatched = true;
134
+ const orig = ez.showAds;
135
+
136
+ ez.showAds = function patchedShowAds(arg) {
137
+ if (Array.isArray(arg)) {
138
+ try { warn('showAds(batch) detected. Splitting…'); } catch (e) {}
139
+ const ids = uniqInts(arg);
140
+ for (const id of ids) {
141
+ try { orig.call(ez, id); } catch (e) {}
142
+ }
143
+ return;
144
+ }
145
+ return orig.apply(ez, arguments);
146
+ };
149
147
  } catch (e) {}
150
148
  }
151
149
 
152
- function callShowAdsSingle(id) {
150
+ function callShowAdsWhenReady(id) {
153
151
  if (!id) return;
154
- const key = String(id);
155
- const placeholderId = `${PLACEHOLDER_PREFIX}${id}`;
152
+
153
+ const now = Date.now();
154
+ const last = state.lastShowById.get(id) || 0;
155
+ if (now - last < 3500) return;
156
+
157
+ const phId = `${PLACEHOLDER_PREFIX}${id}`;
156
158
 
157
159
  const doCall = () => {
158
160
  try {
159
161
  window.ezstandalone = window.ezstandalone || {};
160
162
  if (typeof window.ezstandalone.showAds === 'function') {
161
- state.lastShowById[key] = Date.now();
163
+ state.lastShowById.set(id, Date.now());
162
164
  window.ezstandalone.showAds(id);
163
165
  return true;
164
166
  }
@@ -166,122 +168,52 @@
166
168
  return false;
167
169
  };
168
170
 
169
- // Wait until the placeholder exists in the DOM (critical on ajaxify navigation)
170
171
  let attempts = 0;
171
- (function waitForDom() {
172
+ (function waitForPh() {
172
173
  attempts += 1;
173
-
174
- const el = document.getElementById(placeholderId);
174
+ const el = document.getElementById(phId);
175
175
  if (el && el.isConnected) {
176
- const now = Date.now();
177
- const last = state.lastShowById[key] || 0;
178
- if (now - last < 4000) return;
179
-
180
176
  if (doCall()) return;
181
177
 
182
- // showAds not ready: queue once per id
183
- if (state.pendingById[key]) return;
184
- state.pendingById[key] = true;
178
+ if (state.pendingById.has(id)) return;
179
+ state.pendingById.add(id);
185
180
 
186
181
  window.ezstandalone = window.ezstandalone || {};
187
182
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
188
183
  window.ezstandalone.cmd.push(() => {
189
184
  try {
190
185
  if (typeof window.ezstandalone.showAds === 'function') {
191
- delete state.pendingById[key];
192
- state.lastShowById[key] = Date.now();
186
+ state.pendingById.delete(id);
187
+ state.lastShowById.set(id, Date.now());
193
188
  window.ezstandalone.showAds(id);
194
189
  }
195
190
  } catch (e) {}
196
191
  });
197
192
 
198
- // also retry a few times in case cmd is late
193
+ // short retries
199
194
  let tries = 0;
200
195
  (function tick() {
201
196
  tries += 1;
202
- if (doCall() || tries >= 6) {
203
- if (tries >= 6) delete state.pendingById[key];
197
+ if (doCall() || tries >= 5) {
198
+ if (tries >= 5) state.pendingById.delete(id);
204
199
  return;
205
200
  }
206
- setTimeout(tick, 800);
201
+ setTimeout(tick, 700);
207
202
  })();
208
-
209
203
  return;
210
204
  }
211
205
 
212
- if (attempts < 40) { // ~2s
213
- setTimeout(waitForDom, 50);
214
- }
206
+ if (attempts < 40) setTimeout(waitForPh, 50);
215
207
  })();
216
208
  }
217
-
218
-
219
- function recycleIfNeeded(pool, usedSet, fifo, selectorFn) {
220
- // only recycle ads far above the viewport, so they don't "disappear" while reading
221
- const margin = 1200;
222
- const vpTop = -margin;
223
-
224
- fifo.sort((a, b) => a.after - b.after);
225
-
226
- for (let i = 0; i < fifo.length; i++) {
227
- const old = fifo[i];
228
- const el = document.querySelector(selectorFn(old));
229
- if (!el) {
230
- fifo.splice(i, 1); i--;
231
- continue;
232
- }
233
- const r = safeGetRect(el);
234
- if (r && r.bottom < vpTop) {
235
- fifo.splice(i, 1);
236
- el.remove();
237
- usedSet.delete(old.id);
238
- destroyPlaceholder(old.id);
239
- pool.push(old.id);
240
- return true;
241
- }
242
209
  }
243
210
  return false;
244
211
  }
245
212
 
246
- function nextId(pool, usedSet, fifo, selectorFn) {
213
+ function nextId(kind) {
214
+ const pool = (kind === 'between') ? state.poolTopics : state.poolPosts;
247
215
  if (pool.length) return pool.shift();
248
- // try recycling one and then use
249
- const recycled = recycleIfNeeded(pool, usedSet, fifo, selectorFn);
250
- if (recycled && pool.length) return pool.shift();
251
- return null;
252
- }
253
-
254
- function getTopicItems() {
255
- return Array.from(document.querySelectorAll(SELECTORS.topicListItem));
256
- }
257
-
258
- function getPostItems() {
259
- // NodeBB harmony: component=post with data-pid is stable
260
- return Array.from(document.querySelectorAll(SELECTORS.postItem));
261
- }
262
-
263
- function cleanupForNewPage() {
264
- state.pageKey = getPageKey();
265
- state.cfg = null;
266
- state.cfgPromise = null;
267
-
268
- state.usedBetween.clear();
269
- state.usedMessage.clear();
270
- state.fifoBetween = [];
271
- state.fifoMessage = [];
272
-
273
- state.lastShowById = {};
274
- state.pendingById = {};
275
-
276
- // Remove injected wrappers
277
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
278
-
279
- // Disconnect observers
280
- Object.values(state.observers).forEach(obs => { try { obs.disconnect(); } catch (e) {} });
281
- state.observers = {};
282
-
283
- state.retryCount = {};
284
-
216
+ return null; // stop injecting when pool is empty
285
217
  }
286
218
 
287
219
  async function fetchConfig() {
@@ -292,9 +224,8 @@
292
224
  try {
293
225
  const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
294
226
  if (!res.ok) return null;
295
- const cfg = await res.json();
296
- state.cfg = cfg;
297
- return cfg;
227
+ state.cfg = await res.json();
228
+ return state.cfg;
298
229
  } catch (e) {
299
230
  return null;
300
231
  } finally {
@@ -305,259 +236,202 @@
305
236
  return state.cfgPromise;
306
237
  }
307
238
 
308
- function observeUntilTargets(kind, cb) {
309
- const key = `${kind}:${getPageKey()}`;
310
- if (state.observers[key]) return;
311
-
312
- const hasTargets = () => {
313
- if (kind === 'topic') return document.querySelectorAll(SELECTORS.postItem).length > 0;
314
- return document.querySelectorAll(SELECTORS.topicListItem).length > 0;
315
- };
316
-
317
- if (hasTargets()) {
318
- cb();
319
- return;
320
- }
321
-
322
- const obs = new MutationObserver(() => {
323
- if (hasTargets()) {
324
- try { obs.disconnect(); } catch (e) {}
325
- delete state.observers[key];
326
- cb();
327
- }
328
- });
329
-
330
- state.observers[key] = obs;
331
- try {
332
- obs.observe(document.body, { childList: true, subtree: true });
333
- } catch (e) {}
334
-
335
- setTimeout(() => {
336
- try { obs.disconnect(); } catch (e) {}
337
- delete state.observers[key];
338
- }, 15000);
239
+ function initPools(cfg) {
240
+ // Re-init pools once per page
241
+ if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
242
+ if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
339
243
  }
340
244
 
341
- function scheduleRetry(kind) {
342
- const key = `${kind}:${getPageKey()}`;
343
- state.retryCount[key] = state.retryCount[key] || 0;
344
- if (state.retryCount[key] >= 24) return;
345
- state.retryCount[key] += 1;
346
- setTimeout(run, 250);
347
- }
348
- } catch (e) {}
349
- };
350
-
351
- state.predeclared[kind].add(key);
352
-
353
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
354
- call();
355
- return;
356
- }
357
-
358
- window.ezstandalone = window.ezstandalone || {};
359
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
360
- window.ezstandalone.cmd.push(call);
361
- } catch (e) {}
362
- }
363
-
364
- function injectBetweenTopics(cfg) {
365
- if (!normalizeBool(cfg.enableBetweenAds)) return;
245
+ function injectTopics(cfg) {
246
+ if (!normalizeBool(cfg.enableBetweenAds)) return 0;
366
247
 
367
248
  const interval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 6);
368
- const firstEnabled = normalizeBool(cfg.showFirstTopicAd);
369
-
370
- const pool = parsePool(cfg.placeholderIds);
371
- if (!pool.length) return;
249
+ const first = normalizeBool(cfg.showFirstTopicAd);
372
250
 
373
- const items = getTopicItems();
374
- if (!items.length) return;
375
-
376
- for (let idx = 0; idx < items.length; idx++) {
377
- const li = items[idx];
378
- const pos = idx + 1;
251
+ const items = Array.from(document.querySelectorAll(SELECTORS.topicItem));
252
+ if (!items.length) return 0;
379
253
 
254
+ let inserted = 0;
255
+ for (let i = 0; i < items.length; i++) {
256
+ const li = items[i];
380
257
  if (!li || !li.isConnected) continue;
381
258
 
382
- // position rule
383
- const ok = (firstEnabled && pos === 1) || (pos % interval === 0);
259
+ // Avoid re-processing anchors already evaluated
260
+ if (state.seenTopicAnchors.has(li)) continue;
261
+
262
+ const pos = i + 1;
263
+ const ok = (first && pos === 1) || (pos % interval === 0);
264
+ state.seenTopicAnchors.add(li);
384
265
  if (!ok) continue;
385
266
 
386
267
  if (hasAdImmediatelyAfter(li)) continue;
387
268
 
388
- const id = nextId(pool, state.usedBetween, state.fifoBetween, (old) => `.${WRAP_CLASS}.ezoic-ad-between[data-ezoic-after="${old.after}"]`);
269
+ const id = nextId('between');
389
270
  if (!id) break;
390
271
 
391
- state.usedBetween.add(id);
272
+ state.usedTopics.add(id);
392
273
  const wrap = insertAfter(li, id, 'ezoic-ad-between', pos);
393
274
  if (!wrap) continue;
394
275
 
395
- state.fifoBetween.push({ id, after: pos });
276
+ inserted += 1;
277
+
278
+ callShowAdsWhenReady(id);
396
279
 
397
- callShowAdsSingle(id);
280
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
398
281
  }
282
+ return inserted;
399
283
  }
400
284
 
401
- function injectBetweenMessages(cfg) {
402
- if (!normalizeBool(cfg.enableMessageAds)) return;
285
+ function injectPosts(cfg) {
286
+ if (!normalizeBool(cfg.enableMessageAds)) return 0;
403
287
 
404
288
  const interval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
405
- const firstEnabled = normalizeBool(cfg.showFirstMessageAd);
289
+ const first = normalizeBool(cfg.showFirstMessageAd);
406
290
 
407
- const pool = parsePool(cfg.messagePlaceholderIds);
408
- if (!pool.length) return;
291
+ const posts = Array.from(document.querySelectorAll(SELECTORS.postItem));
292
+ if (!posts.length) return 0;
409
293
 
410
- const posts = getPostItems();
411
- if (!posts.length) return;
412
-
413
- for (let idx = 0; idx < posts.length; idx++) {
414
- const post = posts[idx];
415
- const no = idx + 1;
294
+ let inserted = 0;
295
+ for (let i = 0; i < posts.length; i++) {
296
+ const post = posts[i];
416
297
  if (!post || !post.isConnected) continue;
417
298
 
418
- const ok = (firstEnabled && no === 1) || (no % interval === 0);
299
+ if (state.seenPostAnchors.has(post)) continue;
300
+
301
+ const no = i + 1;
302
+ const ok = (first && no === 1) || (no % interval === 0);
303
+ state.seenPostAnchors.add(post);
419
304
  if (!ok) continue;
420
305
 
421
306
  if (hasAdImmediatelyAfter(post)) continue;
422
307
 
423
- const id = nextId(pool, state.usedMessage, state.fifoMessage, (old) => `.${WRAP_CLASS}.ezoic-ad-message[data-ezoic-after="${old.after}"]`);
308
+ const id = nextId('message');
424
309
  if (!id) break;
425
310
 
426
- state.usedMessage.add(id);
311
+ state.usedPosts.add(id);
427
312
  const wrap = insertAfter(post, id, 'ezoic-ad-message', no);
428
313
  if (!wrap) continue;
429
314
 
430
- state.fifoMessage.push({ id, after: no });
315
+ inserted += 1;
431
316
 
432
- callShowAdsSingle(id);
317
+ callShowAdsWhenReady(id);
318
+
319
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
433
320
  }
321
+ return inserted;
434
322
  }
435
323
 
436
- let scheduled = false;
437
- let scheduledTimer = null;
438
-
439
- function scheduleRun(reason) {
440
- if (scheduled) return;
441
- scheduled = true;
442
-
443
- const startKey = getPageKey();
444
-
445
- const settleAndRun = () => {
446
- // If page changed, reschedule for new page
447
- if (getPageKey() !== startKey) { scheduled = false; scheduleRun('page-changed'); return; }
448
-
449
- // Wait until the relevant DOM count is stable for a short window
450
- const kind = getPageKind();
451
- const sel = (kind === 'topic') ? SELECTORS.postItem : SELECTORS.topicListItem;
324
+ function cleanup() {
325
+ state.pageKey = getPageKey();
326
+ state.cfg = null;
327
+ state.cfgPromise = null;
452
328
 
453
- let lastCount = document.querySelectorAll(sel).length;
454
- let stableTicks = 0;
329
+ state.poolTopics = [];
330
+ state.poolPosts = [];
331
+ state.usedTopics.clear();
332
+ state.usedPosts.clear();
455
333
 
456
- const step = () => {
457
- if (getPageKey() !== startKey) { scheduled = false; scheduleRun('page-changed'); return; }
334
+ state.seenTopicAnchors = new WeakSet();
335
+ state.seenPostAnchors = new WeakSet();
458
336
 
459
- const c = document.querySelectorAll(sel).length;
460
- if (c === lastCount && c > 0) stableTicks += 1;
461
- else { stableTicks = 0; lastCount = c; }
337
+ state.lastShowById = new Map();
338
+ state.pendingById = new Set();
462
339
 
463
- if (stableTicks >= 2) { // ~2 frames stable
464
- scheduled = false;
465
- run();
466
- return;
467
- }
468
- requestAnimationFrame(step);
469
- };
470
-
471
- requestAnimationFrame(step);
472
- };
340
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach(el => el.remove());
473
341
 
474
- // small delay to coalesce multiple NodeBB events
475
- clearTimeout(scheduledTimer);
476
- scheduledTimer = setTimeout(settleAndRun, 60);
342
+ if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; }
343
+ state.scheduled = false;
344
+ clearTimeout(state.timer);
345
+ state.timer = null;
477
346
  }
478
347
 
479
- function runAfterPaint() {
480
- // Ensure we run after the browser has painted the newly inserted DOM (Harmony/category topics list can be late)
481
- requestAnimationFrame(() => {
482
- requestAnimationFrame(() => {
483
- run();
484
- });
485
- });
348
+ function ensureObserver() {
349
+ if (state.obs) return;
350
+ state.obs = new MutationObserver(() => scheduleRun('dom-mutation'));
351
+ try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
352
+ setTimeout(() => { if (state.obs) { try { state.obs.disconnect(); } catch (e) {} state.obs = null; } }, 12000);
486
353
  }
487
354
 
488
- async function run() {
489
- try {
490
- const cfg = await fetchConfig();
491
- if (!cfg || cfg.excluded) return;
355
+ async function runCore() {
356
+ patchShowAds();
492
357
 
493
- // If Ezoic is not ready yet, schedule a single rerun via ezstandalone.cmd
494
- try {
495
- window.ezstandalone = window.ezstandalone || {};
496
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
497
- if (typeof window.ezstandalone.showAds !== 'function') {
498
- const rk = 'ezoicReadyRerun:' + getPageKey();
499
- if (!state.retryCount[rk]) {
500
- state.retryCount[rk] = 1;
501
- window.ezstandalone.cmd.push(() => { setTimeout(run, 0); });
502
- }
503
- }
504
- } catch (e) {}
358
+ const cfg = await fetchConfig();
359
+ if (!cfg || cfg.excluded) return;
505
360
 
506
- const kind = getPageKind();
361
+ initPools(cfg);
507
362
 
508
- if (kind === 'topic') {
509
- const hasPosts = document.querySelectorAll(SELECTORS.postItem).length > 0;
510
- if (hasPosts) { injectBetweenMessages(cfg); }
511
- else { observeUntilTargets('topic', run); scheduleRetry('topic'); }
512
- } else {
513
- const hasList = document.querySelectorAll(SELECTORS.topicListItem).length > 0;
514
- if (hasList) { injectBetweenTopics(cfg); }
515
- else { observeUntilTargets('category', run); scheduleRetry('category'); }
516
- }
517
- } catch (e) {
518
- // Never break NodeBB UI
363
+ const kind = getKind();
364
+ let inserted = 0;
365
+
366
+ if (kind === 'topic') inserted = injectPosts(cfg);
367
+ else inserted = injectTopics(cfg);
368
+
369
+ // If we inserted max per run, schedule another pass to gradually fill (avoids “burst”)
370
+ if (inserted >= MAX_INSERTS_PER_RUN) {
371
+ setTimeout(() => scheduleRun('continue-fill'), 120);
519
372
  }
520
373
  }
521
374
 
522
- function bindNodeBBEvents() {
523
- if (!$w) return;
375
+ function scheduleRun(reason) {
376
+ if (state.scheduled) return;
377
+ state.scheduled = true;
378
+
379
+ clearTimeout(state.timer);
380
+ state.timer = setTimeout(() => {
381
+ state.scheduled = false;
382
+ // Ensure we're still on same page
383
+ const pk = getPageKey();
384
+ if (state.pageKey && pk !== state.pageKey) return;
385
+ runCore().catch(() => {});
386
+ }, 80);
387
+
388
+ function bind() {
389
+ if (!$) return;
524
390
 
525
- // Prevent duplicate binding
526
- $w.off('.ezoicInfinite');
391
+ $(window).off('.ezoicInfinite');
527
392
 
528
- $w.on('action:ajaxify.start.ezoicInfinite', () => {
529
- cleanupForNewPage();
393
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
394
+ cleanup();
530
395
  });
531
396
 
532
- $w.on('action:ajaxify.end.ezoicInfinite', () => {
533
- patchShowAds();
397
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
398
+ state.pageKey = getPageKey();
399
+ ensureObserver();
534
400
  scheduleRun('ajaxify.end');
535
401
  setTimeout(() => scheduleRun('ajaxify.end+250'), 250);
536
- setTimeout(() => scheduleRun('ajaxify.end+900'), 900);
402
+ setTimeout(() => scheduleRun('ajaxify.end+800'), 800);
537
403
  });
538
404
 
539
- $w.on('action:category.loaded.ezoicInfinite', () => {
405
+ $(window).on('action:category.loaded.ezoicInfinite', () => {
406
+ ensureObserver();
540
407
  scheduleRun('category.loaded');
541
- setTimeout(() => scheduleRun('category.loaded+200'), 200);
542
- setTimeout(() => scheduleRun('category.loaded+700'), 700);
408
+ setTimeout(() => scheduleRun('category.loaded+250'), 250);
543
409
  });
544
410
 
545
- $w.on('action:topic.loaded.ezoicInfinite', () => {
411
+ $(window).on('action:topics.loaded.ezoicInfinite', () => {
412
+ ensureObserver();
413
+ scheduleRun('topics.loaded');
414
+ setTimeout(() => scheduleRun('topics.loaded+150'), 150);
415
+ });
416
+
417
+ $(window).on('action:topic.loaded.ezoicInfinite', () => {
418
+ ensureObserver();
546
419
  scheduleRun('topic.loaded');
547
- setTimeout(() => scheduleRun('topic.loaded+300'), 300);
420
+ setTimeout(() => scheduleRun('topic.loaded+200'), 200);
548
421
  });
549
422
 
550
- // Infinite scroll events (varies by route)
551
- $w.on('action:topics.loaded.ezoicInfinite', () => { runAfterPaint(); setTimeout(run, 150); setTimeout(run, 600); });
552
- $w.on('action:posts.loaded.ezoicInfinite', () => { scheduleRun('posts.loaded'); setTimeout(() => scheduleRun('posts.loaded+250'), 250); });
423
+ $(window).on('action:posts.loaded.ezoicInfinite', () => {
424
+ ensureObserver();
425
+ scheduleRun('posts.loaded');
426
+ setTimeout(() => scheduleRun('posts.loaded+150'), 150);
427
+ });
553
428
  }
554
429
 
555
430
  // Boot
556
- patchShowAds();
557
- cleanupForNewPage();
558
- bindNodeBBEvents();
559
-
560
- // First load (non-ajaxify)
561
- scheduleRun('first-load');
562
- setTimeout(() => scheduleRun('first-load+250'), 250);
431
+ cleanup();
432
+ bind();
433
+ ensureObserver();
434
+ state.pageKey = getPageKey();
435
+ scheduleRun('boot');
436
+ setTimeout(() => scheduleRun('boot+250'), 250);
563
437
  })();