nodebb-plugin-ezoic-infinite 0.8.7 → 0.9.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Ezoic ads with infinite scroll using a pool of placeholder IDs",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -22,5 +22,8 @@
22
22
  "scripts": [
23
23
  "public/client.js"
24
24
  ],
25
+ "css": [
26
+ "public/style.css"
27
+ ],
25
28
  "templates": "public/templates"
26
29
  }
package/public/client.js CHANGED
@@ -3,61 +3,70 @@
3
3
 
4
4
  window.ezoicInfiniteLoaded = true;
5
5
 
6
- let cachedConfig;
6
+ let cachedConfig = null;
7
7
  let lastFetch = 0;
8
- let debounceTimer;
9
-
10
8
  let inFlight = false;
11
9
  let rerunRequested = false;
10
+ let debounceTimer = null;
12
11
 
13
- // Per-page state
14
12
  let pageKey = null;
15
13
 
16
- // Topic page: anchor ads to absolute post number
17
- let seenAfterPostNo = new Set(); // post numbers we've inserted after
18
- let usedIds = new Set(); // ids currently present in DOM
19
- let fifo = []; // [{afterPostNo, id}]
14
+ // Separate state per "mode" so category and topic don't leak ids
15
+ let usedBetween = new Set();
16
+ let usedMessages = new Set();
20
17
 
21
- // Category topic list page: anchor ads to absolute position
22
- let seenAfterTopicPos = new Set();
23
- let fifoCat = []; // [{afterPos, id}]
18
+ let seenBetweenAfter = new Set(); // category: after absolute topic position
19
+ let fifoBetween = []; // [{afterPos, id}]
20
+ let seenMsgAfter = new Set(); // topic: after absolute post number
21
+ let fifoMsg = []; // [{afterPostNo, id}]
24
22
 
25
- function resetState() {
26
- seenAfterPostNo = new Set();
27
- seenAfterTopicPos = new Set();
28
- usedIdsMsg = new Set();
29
- usedIdsBetween = new Set();
30
- fifo = [];
31
- fifoCat = [];
32
- }
23
+ // Destroy spam guard
24
+ window.__ezoicLastDestroy = window.__ezoicLastDestroy || {};
25
+ window.__ezoicRecycling = false;
33
26
 
34
- function getPageKey() {
35
- try {
36
- if (ajaxify && ajaxify.data) {
37
- if (ajaxify.data.tid) return 'topic:' + ajaxify.data.tid;
38
- if (ajaxify.data.cid) return 'category:' + ajaxify.data.cid;
39
- }
40
- } catch (e) {}
41
- return window.location.pathname;
42
- }
27
+ // ---------- Config ----------
28
+ async function fetchConfig() {
29
+ const now = Date.now();
30
+ if (cachedConfig && (now - lastFetch) < 5000) return cachedConfig;
31
+ lastFetch = now;
43
32
 
44
- function parsePool(raw) {
45
- if (!raw) return [];
46
- return Array.from(new Set(
47
- String(raw).split(/[\n,;\s]+/)
48
- .map(x => parseInt(x, 10))
49
- .filter(n => Number.isFinite(n) && n > 0)
50
- ));
33
+ try {
34
+ const res = await fetch('/api/admin/settings/ezoic-infinite', { credentials: 'same-origin' });
35
+ const data = await res.json();
36
+ cachedConfig = {
37
+ excluded: !!data.excluded,
38
+ enableBetweenAds: data.enableBetweenAds !== false,
39
+ placeholderIds: String(data.placeholderIds || '').trim(),
40
+ intervalPosts: parseInt(data.intervalPosts, 10) || 6,
41
+
42
+ enableMessageAds: data.enableMessageAds !== false,
43
+ messagePlaceholderIds: String(data.messagePlaceholderIds || '').trim(),
44
+ messageIntervalPosts: parseInt(data.messageIntervalPosts, 10) || 3,
45
+ };
46
+ return cachedConfig;
47
+ } catch (e) {
48
+ return cachedConfig || {
49
+ excluded: false,
50
+ enableBetweenAds: true,
51
+ placeholderIds: '',
52
+ intervalPosts: 6,
53
+ enableMessageAds: true,
54
+ messagePlaceholderIds: '',
55
+ messageIntervalPosts: 3,
56
+ };
57
+ }
51
58
  }
52
59
 
53
- async function fetchConfig() {
54
- if (cachedConfig && Date.now() - lastFetch < 8000) return cachedConfig;
55
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
56
- cachedConfig = await res.json();
57
- lastFetch = Date.now();
58
- return cachedConfig;
60
+ function parsePool(text) {
61
+ return String(text || '')
62
+ .split(/\r?\n|,|;/g)
63
+ .map(s => s.trim())
64
+ .filter(Boolean)
65
+ .map(s => parseInt(s, 10))
66
+ .filter(n => Number.isFinite(n) && n > 0);
59
67
  }
60
68
 
69
+ // ---------- Page detection ----------
61
70
  function isTopicPage() {
62
71
  try {
63
72
  if (ajaxify && ajaxify.data && ajaxify.data.tid) return true;
@@ -66,246 +75,100 @@ function isTopicPage() {
66
75
  }
67
76
 
68
77
  function isCategoryTopicListPage() {
69
- return $('li[component="category/topic"]').length > 0;
70
- }
71
-
72
- function getTopicPosts() {
73
- const $primary = $('[component="post"][data-pid]');
74
- if ($primary.length) return $primary;
75
-
76
- // Fallback: top-level nodes with post/content (avoid nested)
77
- return $('[data-pid]').filter(function () {
78
- const $el = $(this);
79
- const hasContent = $el.find('[component="post/content"]').length > 0;
80
- const nested = $el.parents('[data-pid]').length > 0;
81
- return hasContent && !nested;
82
- });
83
- }
84
-
85
- function getCategoryTopicItems() {
86
- return $('li[component="category/topic"]');
78
+ return document.querySelectorAll('li[component="category/topic"]').length > 0;
87
79
  }
88
80
 
89
- // If target's parent is UL/OL, wrapper must be LI
90
- function wrapperTagFor($target) {
91
- if (!$target || !$target.length) return 'div';
92
- const parentTag = ($target.parent().prop('tagName') || '').toUpperCase();
93
- if (parentTag === 'UL' || parentTag === 'OL') return 'li';
94
- const selfTag = ($target.prop('tagName') || '').toUpperCase();
95
- if (selfTag === 'LI') return 'li';
96
- return 'div';
81
+ function getPageKey() {
82
+ try {
83
+ if (ajaxify && ajaxify.data) {
84
+ if (ajaxify.data.tid) return `topic:${ajaxify.data.tid}`;
85
+ if (ajaxify.data.cid) return `category:${ajaxify.data.cid}`;
86
+ }
87
+ } catch (e) {}
88
+ return `path:${window.location.pathname}`;
97
89
  }
98
90
 
99
- function makeWrapperLike($target, classes, innerHtml, attrs) {
100
- const tag = wrapperTagFor($target);
101
- const attrStr = attrs ? ' ' + attrs : '';
102
- if (tag === 'li') {
103
- return '<li class="' + classes + ' list-unstyled"' + attrStr + '>' + innerHtml + '</li>';
104
- }
105
- return '<div class="' + classes + '"' + attrStr + '>' + innerHtml + '</div>';
91
+ function resetState() {
92
+ usedBetween = new Set();
93
+ usedMessages = new Set();
94
+ seenBetweenAfter = new Set();
95
+ seenMsgAfter = new Set();
96
+ fifoBetween = [];
97
+ fifoMsg = [];
98
+ window.__ezoicLastShowKey = null;
99
+ window.__ezoicLastShowAt = 0;
106
100
  }
107
101
 
108
102
  function cleanupOnNav() {
109
- $('.ezoic-ad-post, .ezoic-ad-topic').remove();
110
- }
111
-
112
- function pickNextId(pool, usedSet) {
113
- for (const id of pool) {
114
- if (!usedSet.has(id)) return id;
115
- }
116
- return null;
117
- } finally {
118
- window.__ezoicRecycling = false;
119
- }
103
+ document.querySelectorAll('.ezoic-ad-post, .ezoic-ad-topic, .ezoic-ad').forEach(el => el.remove());
120
104
  }
121
105
 
106
+ // ---------- Ezoic helpers ----------
122
107
  function destroyEzoicId(id) {
123
- window.__ezoicLastDestroy = window.__ezoicLastDestroy || {};
124
108
  const now = Date.now();
125
- if (window.__ezoicLastDestroy[id] && now - window.__ezoicLastDestroy[id] < 2000) {
126
- return;
127
- }
109
+ if (window.__ezoicLastDestroy[id] && (now - window.__ezoicLastDestroy[id]) < 2000) return;
128
110
  window.__ezoicLastDestroy[id] = now;
111
+
129
112
  try {
130
113
  window.ezstandalone = window.ezstandalone || {};
114
+ if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
115
+ window.ezstandalone.destroyPlaceholders(id);
116
+ return;
117
+ }
131
118
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
132
- const fn = function () {
119
+ window.ezstandalone.cmd.push(function () {
133
120
  try {
134
121
  if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
135
122
  window.ezstandalone.destroyPlaceholders(id);
136
123
  }
137
124
  } catch (e) {}
138
- };
139
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') fn();
140
- else window.ezstandalone.cmd.push(fn);
125
+ });
141
126
  } catch (e) {}
142
127
  }
143
128
 
144
- function recycleOldestTopicId($posts) {
145
- if (window.__ezoicRecycling) return null;
146
- window.__ezoicRecycling = true;
147
- try {
148
- if (!fifo.length) return null;
149
- fifo.sort((a, b) => a.afterPostNo - b.afterPostNo);
150
-
151
- while (fifo.length) {
152
- const old = fifo.shift();
153
- const sel = '.ezoic-ad-post[data-ezoic-after="' + old.afterPostNo + '"][data-ezoic-id="' + old.id + '"]';
154
- const $el = $(sel);
155
- if (!$el.length) continue;
156
-
157
- // Never recycle the ad that is right after the last real post (sentinel)
158
- try {
159
- const $last = $posts.last();
160
- if ($last.length && $el.prev().is($last)) {
161
- fifo.push(old);
162
- return null;
163
- }
164
- } catch (e) {}
165
-
166
- $el.remove();
167
- usedIdsMsg.delete(old.id);
168
- destroyEzoicId(old.id);
169
- return old.id;
170
- }
171
- return null;
172
- } finally {
173
- window.__ezoicRecycling = false;
174
- }
175
- }
176
-
177
- function recycleOldestCategoryId($items) {
178
- if (window.__ezoicRecycling) return null;
179
- window.__ezoicRecycling = true;
180
- try {
181
- if (!fifoCat.length) return null;
182
- fifoCat.sort((a, b) => a.afterPos - b.afterPos);
183
-
184
- while (fifoCat.length) {
185
- const old = fifoCat.shift();
186
- const sel = '.ezoic-ad-topic[data-ezoic-after="' + old.afterPos + '"][data-ezoic-id="' + old.id + '"]';
187
- const $el = $(sel);
188
- if (!$el.length) continue;
189
-
190
- try {
191
- const $last = $items.last();
192
- if ($last.length && $el.prev().is($last)) {
193
- fifoCat.push(old);
194
- return null;
195
- }
196
- } catch (e) {}
197
-
198
- $el.remove();
199
- usedIdsBetween.delete(old.id);
200
- destroyEzoicId(old.id);
201
- return old.id;
202
- }
203
- return null;
204
- }
205
-
129
+ // Auto-height: hide until filled
206
130
  function setupAdAutoHeight() {
207
131
  if (window.__ezoicAutoHeightAttached) return;
208
132
  window.__ezoicAutoHeightAttached = true;
209
133
 
210
- try {
211
- const ro = new ResizeObserver(function () {
212
- document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
213
- // Collapse until content is injected
214
- if (!wrap.classList.contains('ezoic-filled')) {
215
- wrap.style.display = 'none';
216
- }
217
- wrap.style.minHeight = '0px';
218
- wrap.style.height = 'auto';
219
- });
134
+ const attach = function () {
135
+ document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
136
+ if (!wrap.classList.contains('ezoic-filled')) {
137
+ wrap.style.display = 'none';
138
+ }
139
+ const ph = wrap.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
140
+ if (ph && ph.children && ph.children.length) {
141
+ wrap.classList.add('ezoic-filled');
142
+ wrap.style.display = '';
143
+ }
220
144
  });
145
+ };
221
146
 
222
- function attach() {
223
- document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
224
- // Collapse until content is injected
225
- if (!wrap.classList.contains('ezoic-filled')) {
226
- wrap.style.display = 'none';
227
- }
228
- if (wrap.__ezoicRO) return;
229
- wrap.__ezoicRO = true;
230
- ro.observe(wrap);
231
- });
232
- }
233
-
234
- attach();
235
- setTimeout(attach, 600);
236
- setTimeout(attach, 1600);
237
- } catch (e) {}
147
+ attach();
148
+ setTimeout(attach, 500);
149
+ setTimeout(attach, 1500);
150
+ setTimeout(attach, 3000);
151
+ setInterval(attach, 1000);
238
152
  }
239
153
 
240
154
  function callEzoic(ids) {
241
155
  window.ezstandalone = window.ezstandalone || {};
242
156
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
243
157
 
244
- const markAndFilterByIds = function (list) {
245
- const out = [];
246
- list.forEach(function (id) {
247
- const sel = '.ezoic-ad[data-ezoic-id="' + id + '"]';
248
- const wraps = document.querySelectorAll(sel);
249
- let anyUnrendered = false;
250
- wraps.forEach(function (w) {
251
- if (w.getAttribute('data-ezoic-rendered') !== '1') {
252
- anyUnrendered = true;
253
- }
254
- });
255
- if (!wraps.length) {
256
- // fallback: if wrapper is missing, allow showAds (Ezoic might still handle placeholder)
257
- out.push(id);
258
- return;
259
- }
260
- if (anyUnrendered) {
261
- wraps.forEach(function (w) { w.setAttribute('data-ezoic-rendered', '1'); });
262
- out.push(id);
263
- }
264
- });
265
- return out;
266
- };
267
-
268
- const collect = function () {
269
- const list = [];
270
- document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
271
- // Collapse until content is injected
272
- if (!wrap.classList.contains('ezoic-filled')) {
273
- wrap.style.display = 'none';
274
- }
275
- if (wrap.getAttribute('data-ezoic-rendered') === '1') return;
276
- const ph = wrap.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
277
- if (ph && ph.children && ph.children.length) {
278
- // Content injected
279
- wrap.classList.add('ezoic-filled');
280
- wrap.style.display = '';
281
- }
282
- if (!ph) return;
283
- const idStr = ph.id.replace('ezoic-pub-ad-placeholder-', '');
284
- const id = parseInt(idStr, 10);
285
- if (!Number.isFinite(id) || id <= 0) return;
286
- wrap.setAttribute('data-ezoic-rendered', '1');
287
- list.push(id);
288
- });
289
- return Array.from(new Set(list));
290
- };
291
-
292
- const input = (ids && ids.length) ? Array.from(new Set(ids)) : null;
293
- const toShow = input ? markAndFilterByIds(input) : collect();
294
- if (!toShow.length) return;
158
+ const uniq = Array.from(new Set(ids || []));
159
+ if (!uniq.length) return;
295
160
 
296
- // De-dupe rapid duplicate calls (observer/poller can fire twice)
297
- const key = toShow.join(',');
161
+ // De-dupe rapid duplicates
162
+ const key = uniq.join(',');
298
163
  const now = Date.now();
299
- if (window.__ezoicLastShowKey === key && (now - (window.__ezoicLastShowAt || 0)) < 1200) {
300
- return;
301
- }
164
+ if (window.__ezoicLastShowKey === key && (now - (window.__ezoicLastShowAt || 0)) < 1200) return;
302
165
  window.__ezoicLastShowKey = key;
303
166
  window.__ezoicLastShowAt = now;
304
167
 
305
168
  const run = function () {
306
169
  try {
307
170
  if (typeof window.ezstandalone.showAds === 'function') {
308
- window.ezstandalone.showAds.apply(window.ezstandalone, toShow);
171
+ window.ezstandalone.showAds.apply(window.ezstandalone, uniq);
309
172
  return true;
310
173
  }
311
174
  } catch (e) {}
@@ -316,230 +179,237 @@ function callEzoic(ids) {
316
179
  window.ezstandalone.cmd.push(function () { run(); });
317
180
 
318
181
  // Retry only if showAds isn't available yet
319
- let tries = 0;
320
- const maxTries = 6;
321
- const retry = function () {
322
- tries++;
323
- const ok = run();
324
- if (ok) return;
325
- try {
326
- if (typeof window.ezstandalone.showAds === 'function') return;
327
- } catch (e) {}
328
- if (tries < maxTries) setTimeout(retry, 800);
329
- };
330
-
331
182
  try {
332
183
  if (typeof window.ezstandalone.showAds !== 'function') {
184
+ let tries = 0;
185
+ const retry = function () {
186
+ tries++;
187
+ if (run()) return;
188
+ if (tries < 6) setTimeout(retry, 800);
189
+ };
333
190
  setTimeout(retry, 800);
334
191
  }
335
- } catch (e) {
336
- setTimeout(retry, 800);
337
- }
192
+ } catch (e) {}
338
193
  }
339
194
 
340
- function getPostNumber($post) {
341
- // Harmony: <a class="post-index">#72</a>
342
- const txt = ($post.find('a.post-index').first().text() || '').trim();
343
- const m = txt.match(/#\s*(\d+)/);
344
- if (m) return parseInt(m[1], 10);
195
+ // ---------- Pool logic ----------
196
+ function pickNextId(pool, usedSet) {
197
+ for (const id of pool) {
198
+ if (!usedSet.has(id)) return id;
199
+ }
200
+ return null;
201
+ }
345
202
 
346
- // fallback data-index (often 0/1 based)
347
- const di = parseInt($post.attr('data-index'), 10);
348
- if (Number.isFinite(di) && di >= 0) return di + 1;
203
+ function recycleOldest(fifo, usedSet, selector, avoidAfterNode) {
204
+ if (window.__ezoicRecycling) return null;
205
+ window.__ezoicRecycling = true;
206
+ try {
207
+ fifo.sort((a, b) => (a.after - b.after));
208
+ while (fifo.length) {
209
+ const old = fifo.shift();
210
+ const el = document.querySelector(selector(old));
211
+ if (!el) continue;
349
212
 
350
- return NaN;
351
- }
213
+ // Don't recycle if it is right after the last real item (protect sentinel)
214
+ try {
215
+ if (avoidAfterNode && el.previousElementSibling === avoidAfterNode) {
216
+ fifo.push(old);
217
+ return null;
218
+ }
219
+ } catch (e) {}
352
220
 
353
- function getTopicPos($item) {
354
- const pos = parseInt($item.attr('data-index'), 10);
355
- if (Number.isFinite(pos) && pos >= 0) return pos + 1;
356
- const schemaPos = parseInt($item.find('meta[itemprop="position"]').attr('content'), 10);
357
- if (Number.isFinite(schemaPos) && schemaPos > 0) return schemaPos;
358
- return NaN;
221
+ el.remove();
222
+ usedSet.delete(old.id);
223
+ destroyEzoicId(old.id);
224
+ return old.id;
225
+ }
226
+ return null;
227
+ } finally {
228
+ window.__ezoicRecycling = false;
229
+ }
359
230
  }
360
231
 
361
- function injectTopicMessageAds($posts, pool, interval) {
362
- const newIds = [];
232
+ // ---------- Injection ----------
233
+ function injectBetweenAds(config) {
234
+ if (!config.enableBetweenAds) return;
235
+ const pool = parsePool(config.placeholderIds);
236
+ if (!pool.length) return;
363
237
 
364
- $posts.each(function () {
365
- const $p = $(this);
366
- // Never insert after the last post (sentinel)
367
- if ($p.is($posts.last())) return;
238
+ const interval = Math.max(1, config.intervalPosts);
239
+ const items = Array.from(document.querySelectorAll('li[component="category/topic"]'));
240
+ if (!items.length) return;
368
241
 
369
- const postNo = getPostNumber($p);
370
- if (!Number.isFinite(postNo) || postNo <= 0) return;
242
+ const newIds = [];
243
+ const lastItem = items[items.length - 1];
371
244
 
372
- if (postNo % interval !== 0) return;
373
- if (seenAfterPostNo.has(postNo)) return;
245
+ for (let i = 0; i < items.length; i++) {
246
+ const pos = i + 1; // absolute position in loaded list
247
+ if (pos % interval !== 0) continue;
248
+ if (seenBetweenAfter.has(pos)) continue;
374
249
 
375
- let id = pickNextId(pool, usedIdsMsg);
250
+ let id = pickNextId(pool, usedBetween);
376
251
  if (!id) {
377
- id = recycleOldestTopicId($posts);
378
- if (!id) return;
252
+ // recycle oldest
253
+ id = recycleOldest(
254
+ fifoBetween,
255
+ usedBetween,
256
+ (o) => `.ezoic-ad-topic[data-ezoic-after="${o.after}"][data-ezoic-id="${o.id}"]`,
257
+ lastItem
258
+ );
259
+ if (!id) break;
379
260
  }
380
261
 
381
- const inner = '<div class="ezoic-ad-message-inner"><div id="ezoic-pub-ad-placeholder-' + id + '"></div></div>';
382
- const html = makeWrapperLike(
383
- $p,
384
- 'ezoic-ad-post ezoic-ad',
385
- inner,
386
- 'data-ezoic-after="' + postNo + '" data-ezoic-id="' + id + '"'
387
- );
262
+ const anchor = items[i];
263
+ const wrap = document.createElement('li');
264
+ wrap.className = 'ezoic-ad ezoic-ad-topic';
265
+ wrap.setAttribute('data-ezoic-after', String(pos));
266
+ wrap.setAttribute('data-ezoic-id', String(id));
267
+ wrap.innerHTML = `<div id="ezoic-pub-ad-placeholder-${id}"></div>`;
388
268
 
389
- $p.after(html);
269
+ anchor.insertAdjacentElement('afterend', wrap);
390
270
 
391
- seenAfterPostNo.add(postNo);
392
- usedIdsMsg.add(id);
393
- fifo.push({ afterPostNo: postNo, id: id });
271
+ seenBetweenAfter.add(pos);
272
+ usedBetween.add(id);
273
+ fifoBetween.push({ after: pos, id });
394
274
  newIds.push(id);
395
- });
275
+ }
396
276
 
397
- return newIds;
277
+ if (newIds.length) callEzoic(newIds);
398
278
  }
399
279
 
400
- function injectCategoryBetweenAds($items, pool, interval) {
401
- const newIds = [];
280
+ function getPostNumberFromPost(postEl) {
281
+ // Prefer post-index text (#72)
282
+ try {
283
+ const idx = postEl.querySelector('a.post-index');
284
+ if (idx) {
285
+ const n = parseInt(String(idx.textContent || '').replace('#', '').trim(), 10);
286
+ if (Number.isFinite(n)) return n;
287
+ }
288
+ } catch (e) {}
402
289
 
403
- $items.each(function () {
404
- const $it = $(this);
405
- // Never insert after the last topic item (sentinel)
406
- if ($it.is($items.last())) return;
290
+ // Fallback: pid-based ordering not reliable, but DOM order is ok for interval within loaded window.
291
+ return null;
292
+ }
407
293
 
408
- const pos = getTopicPos($it);
409
- if (!Number.isFinite(pos) || pos <= 0) return;
294
+ function injectMessageAds(config) {
295
+ if (!config.enableMessageAds) return;
296
+ const pool = parsePool(config.messagePlaceholderIds);
297
+ if (!pool.length) return;
410
298
 
411
- if (pos % interval !== 0) return;
412
- if (seenAfterTopicPos.has(pos)) return;
299
+ const interval = Math.max(1, config.messageIntervalPosts);
300
+ const posts = Array.from(document.querySelectorAll('[component="post"][data-pid]'));
301
+ if (!posts.length) return;
413
302
 
414
- let id = pickNextId(pool, usedIdsBetween);
303
+ const newIds = [];
304
+ const lastPost = posts[posts.length - 1];
305
+
306
+ // Determine absolute post numbers if available; else use DOM position
307
+ let numbers = posts.map((p, i) => ({ el: p, no: getPostNumberFromPost(p) || (i + 1) }));
308
+ // Ensure strictly increasing by DOM
309
+ numbers = numbers.map((x, i) => ({ el: x.el, no: x.no || (i + 1) }));
310
+
311
+ for (const entry of numbers) {
312
+ const afterNo = entry.no;
313
+ if (afterNo % interval !== 0) continue;
314
+ if (seenMsgAfter.has(afterNo)) continue;
315
+
316
+ let id = pickNextId(pool, usedMessages);
415
317
  if (!id) {
416
- id = recycleOldestCategoryId($items);
417
- if (!id) return;
318
+ id = recycleOldest(
319
+ fifoMsg,
320
+ usedMessages,
321
+ (o) => `.ezoic-ad-post[data-ezoic-after="${o.after}"][data-ezoic-id="${o.id}"]`,
322
+ lastPost
323
+ );
324
+ if (!id) break;
418
325
  }
419
326
 
420
- const placeholder = '<div id="ezoic-pub-ad-placeholder-' + id + '"></div>';
421
- const html = makeWrapperLike(
422
- $it,
423
- 'ezoic-ad-topic ezoic-ad',
424
- placeholder,
425
- 'data-ezoic-after="' + pos + '" data-ezoic-id="' + id + '"'
426
- );
327
+ const wrap = document.createElement('div');
328
+ wrap.className = 'ezoic-ad ezoic-ad-post';
329
+ wrap.setAttribute('data-ezoic-after', String(afterNo));
330
+ wrap.setAttribute('data-ezoic-id', String(id));
331
+ wrap.innerHTML = `<div class="ezoic-ad-message-inner"><div id="ezoic-pub-ad-placeholder-${id}"></div></div>`;
427
332
 
428
- $it.after(html);
333
+ entry.el.insertAdjacentElement('afterend', wrap);
429
334
 
430
- seenAfterTopicPos.add(pos);
431
- usedIdsBetween.add(id);
432
- fifoCat.push({ afterPos: pos, id: id });
335
+ seenMsgAfter.add(afterNo);
336
+ usedMessages.add(id);
337
+ fifoMsg.push({ after: afterNo, id });
433
338
  newIds.push(id);
434
- });
339
+ }
435
340
 
436
- return newIds;
341
+ if (newIds.length) callEzoic(newIds);
437
342
  }
438
343
 
344
+ // ---------- Main refresh ----------
439
345
  async function refreshAds() {
440
- const now = Date.now();
441
- if (window.__ezoicRefreshThrottle && now - window.__ezoicRefreshThrottle < 250) return;
442
- window.__ezoicRefreshThrottle = now;
443
- const key = getPageKey();
444
- if (pageKey !== key) {
445
- pageKey = key;
446
- resetState();
447
- cleanupOnNav();
346
+ if (inFlight) {
347
+ rerunRequested = true;
348
+ return;
448
349
  }
449
-
450
- if (inFlight) { rerunRequested = true; return; }
451
350
  inFlight = true;
351
+ rerunRequested = false;
452
352
 
453
353
  try {
454
354
  const cfg = await fetchConfig();
455
- if (!cfg || cfg.excluded) return;
456
-
457
- const betweenPool = parsePool(cfg.placeholderIds);
458
- const betweenInterval = Math.max(1, parseInt(cfg.intervalPosts, 10) || 6);
355
+ if (cfg.excluded) return;
459
356
 
460
- const messagePool = parsePool(cfg.messagePlaceholderIds);
461
- const messageInterval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
357
+ const key = getPageKey();
358
+ if (key !== pageKey) {
359
+ pageKey = key;
360
+ resetState();
361
+ cleanupOnNav();
362
+ }
462
363
 
463
- const hasTopicList = isCategoryTopicListPage();
464
364
  const onTopic = isTopicPage();
465
- const onCategory = hasTopicList && !onTopic;
466
-
467
- const newIds = [];
365
+ const onCategory = isCategoryTopicListPage() && !onTopic;
468
366
 
469
- // Rule: topic list => between only ; topic page => message only
470
- if (onCategory) {
471
- const $items = getCategoryTopicItems();
472
- if (cfg.enableBetweenAds && betweenPool.length && $items.length) {
473
- newIds.push(...injectCategoryBetweenAds($items, betweenPool, betweenInterval));
474
- }
475
- callEzoic(newIds);
476
- // also ensure any unrendered placeholders are rendered
477
- callEzoic();
478
- return;
479
- }
480
-
481
- if (onTopic) {
482
- const $posts = getTopicPosts();
483
- if (cfg.enableMessageAds && messagePool.length && $posts.length) {
484
- newIds.push(...injectTopicMessageAds($posts, messagePool, messageInterval));
485
- }
486
- callEzoic(newIds);
487
- }
367
+ if (onCategory) injectBetweenAds(cfg);
368
+ if (onTopic) injectMessageAds(cfg);
488
369
  } finally {
489
370
  inFlight = false;
490
- if (rerunRequested) {
491
- rerunRequested = false;
492
- setTimeout(refreshAds, 160);
493
- }
371
+ if (rerunRequested) setTimeout(refreshAds, 50);
494
372
  }
495
373
  }
496
374
 
497
375
  function debounceRefresh() {
498
- clearTimeout(debounceTimer);
499
- debounceTimer = setTimeout(refreshAds, 220);
376
+ if (debounceTimer) clearTimeout(debounceTimer);
377
+ debounceTimer = setTimeout(refreshAds, 250);
500
378
  }
501
379
 
502
- $(document).ready(function () { setupAdAutoHeight(); debounceRefresh(); });
503
-
504
- $(window).on('action:ajaxify.start', function (ev, data) {
505
- // IMPORTANT: do not cleanup on infinite scroll loads; only when navigating to a different page.
506
- try {
507
- const targetUrl = (data && (data.url || data.href)) ? String(data.url || data.href) : '';
508
- if (targetUrl) {
509
- const a = document.createElement('a');
510
- a.href = targetUrl;
511
- const targetPath = a.pathname || targetUrl;
512
- // If we're staying on the same logical page (same path), don't wipe ads/state
513
- if (targetPath === window.location.pathname) {
514
- return;
380
+ // ---------- Observers / Events ----------
381
+ (function setupTriggers() {
382
+ // Ajaxify navigation: only cleanup when URL changes
383
+ $(window).on('action:ajaxify.start', function (ev, data) {
384
+ try {
385
+ const targetUrl = (data && (data.url || data.href)) ? String(data.url || data.href) : '';
386
+ if (targetUrl) {
387
+ const a = document.createElement('a');
388
+ a.href = targetUrl;
389
+ const targetPath = a.pathname || targetUrl;
390
+ if (targetPath === window.location.pathname) return;
515
391
  }
516
- }
517
- } catch (e) {}
518
- pageKey = null;
519
- resetState();
520
- cleanupOnNav();
521
- });
522
- $(window).on('action:ajaxify.end action:posts.loaded action:topic.loaded action:topics.loaded action:category.loaded', debounceRefresh);
392
+ } catch (e) {}
393
+ pageKey = null;
394
+ resetState();
395
+ cleanupOnNav();
396
+ });
523
397
 
524
- setTimeout(function () { setupAdAutoHeight(); debounceRefresh(); }, 2200);
398
+ $(window).on('action:ajaxify.end action:posts.loaded action:topics.loaded action:topic.loaded action:category.loaded', debounceRefresh);
525
399
 
526
- // Observer + poller: ensure we rerun when NodeBB injects new items
527
- (function setupEzoicObserver() {
528
- if (window.__ezoicInfiniteObserver) return;
400
+ // MutationObserver (new posts/topics appended)
529
401
  try {
530
- const trigger = function () { debounceRefresh(); };
531
-
532
402
  const obs = new MutationObserver(function (mutations) {
533
403
  for (const m of mutations) {
534
- if (!m.addedNodes || !m.addedNodes.length) continue;
404
+ if (!m.addedNodes) continue;
535
405
  for (const n of m.addedNodes) {
536
406
  if (!n || n.nodeType !== 1) continue;
537
407
  if (n.matches && (n.matches('[component="post"][data-pid]') || n.matches('li[component="category/topic"]'))) {
538
- trigger();
408
+ debounceRefresh();
539
409
  return;
540
410
  }
541
411
  if (n.querySelector && (n.querySelector('[component="post"][data-pid]') || n.querySelector('li[component="category/topic"]'))) {
542
- trigger();
412
+ debounceRefresh();
543
413
  return;
544
414
  }
545
415
  }
@@ -547,19 +417,27 @@ setTimeout(function () { setupAdAutoHeight(); debounceRefresh(); }, 2200);
547
417
  });
548
418
  obs.observe(document.body, { childList: true, subtree: true });
549
419
  window.__ezoicInfiniteObserver = obs;
550
-
551
- let lastPostCount = 0;
552
- let lastTopicCount = 0;
553
- setInterval(function () {
554
- try {
555
- const posts = document.querySelectorAll('[component="post"][data-pid]').length;
556
- const topics = document.querySelectorAll('li[component="category/topic"]').length;
557
- if (posts !== lastPostCount || topics !== lastTopicCount) {
558
- lastPostCount = posts;
559
- lastTopicCount = topics;
560
- trigger();
561
- }
562
- } catch (e) {}
563
- }, 1500);
564
420
  } catch (e) {}
421
+
422
+ // Poller fallback (count changes)
423
+ let lastPosts = 0;
424
+ let lastTopics = 0;
425
+ setInterval(function () {
426
+ const p = document.querySelectorAll('[component="post"][data-pid]').length;
427
+ const t = document.querySelectorAll('li[component="category/topic"]').length;
428
+ if (p !== lastPosts || t !== lastTopics) {
429
+ lastPosts = p; lastTopics = t;
430
+ debounceRefresh();
431
+ }
432
+ }, 1500);
433
+
434
+ // First run(s) - important for hard load
435
+ document.addEventListener('DOMContentLoaded', function () {
436
+ debounceRefresh();
437
+ setTimeout(debounceRefresh, 1500);
438
+ setTimeout(debounceRefresh, 5000);
439
+ setTimeout(debounceRefresh, 10000);
440
+ });
441
+ // In case this script loads after DOMContentLoaded
442
+ setTimeout(debounceRefresh, 800);
565
443
  })();