nodebb-plugin-ezoic-infinite 1.1.6 → 1.2.2

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