nodebb-plugin-ezoic-infinite 1.4.67 → 1.4.69

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": "1.4.67",
3
+ "version": "1.4.69",
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
@@ -1,810 +1,450 @@
1
1
  (function () {
2
2
  'use strict';
3
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
4
- const SELECTORS = {
5
- topicItem: 'li[component="category/topic"]',
6
- postItem: '[component="post"][data-pid]',
7
- categoryItem: 'li[component="categories/category"]',
8
- }, WRAP_CLASS = 'ezoic-ad';
9
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-', MAX_INSERTS_PER_RUN = 3;
10
-
11
- const sessionDefinedIds = new Set();
12
-
13
- const insertingIds = new Set(), state = {
14
- pageKey: null,
15
- cfg: null,
16
- cfgPromise: null,
17
3
 
18
- poolTopics: [],
19
- poolPosts: [],
20
- poolCategories: [],
4
+ /**
5
+ * NodeBB + Ezoic infinite ad injector
6
+ * Production-ready refactor:
7
+ * - No blank space: wrappers hidden until filled, removed on timeout
8
+ * - Bounded work per tick + throttled MutationObserver
9
+ * - Clean ajaxify navigation handling
10
+ */
21
11
 
22
- usedTopics: new Set(),
23
- usedPosts: new Set(),
24
- usedCategories: new Set(),
12
+ const SELECTORS = {
13
+ topicItem: 'li[component="category/topic"]',
14
+ categoryItem: 'li[component="categories/category"]',
15
+ postItem: '[component="post"][data-pid]',
16
+ postContent: '[component="post/content"]',
17
+ };
25
18
 
26
- lastShowById: new Map(),
27
- pendingById: new Set(),
28
- definedIds: new Set(),
19
+ const CLASS_WRAP = 'ezoic-ad';
20
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
29
21
 
30
- scheduled: false,
31
- timer: null,
22
+ const MAX_INSERTS_PER_TICK = 3;
23
+ const FILL_TIMEOUT_MS = 3500;
24
+ const RECYCLE_MARGIN_PX = 600;
25
+ const SHOW_DEBOUNCE_MS = 500;
32
26
 
33
- obs: null,
34
- activeTimeouts: new Set(),
35
- lastScrollRun: 0, };
27
+ const state = {
28
+ pageKey: null,
29
+ cfg: null,
30
+ cfgPromise: null,
36
31
 
37
- function normalizeBool(v) {
38
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
39
- }
32
+ pools: { topics: [], categories: [], messages: [] },
33
+ live: { topics: [], categories: [], messages: [] },
40
34
 
41
- function uniqInts(lines) {
42
- const out = [], seen = new Set();
43
- for (const v of lines) {
44
- const n = parseInt(v, 10);
45
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
46
- seen.add(n);
47
- out.push(n);
48
- }
49
- }
50
- return out;
51
- }
35
+ scheduled: false,
36
+ lastRunAt: 0,
37
+ timeouts: new Set(),
38
+ observer: null,
39
+ lastShowById: new Map(),
40
+ };
52
41
 
53
- function parsePool(raw) {
54
- if (!raw) return [];
55
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
56
- return uniqInts(lines);
57
- }
42
+ function now() { return Date.now(); }
58
43
 
59
44
  function getPageKey() {
60
- try {
61
- const ax = window.ajaxify;
62
- if (ax && ax.data) {
63
- if (ax.data.tid) return `topic:${ax.data.tid}`;
64
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
65
- }
66
- } catch (e) {}
67
- return window.location.pathname;
45
+ try {
46
+ const ax = window.ajaxify;
47
+ if (ax && ax.data) {
48
+ if (ax.data.tid) return `topic:${ax.data.tid}`;
49
+ if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
50
+ }
51
+ } catch (e) {}
52
+ return window.location.pathname || '';
68
53
  }
69
54
 
70
55
  function getKind() {
71
- const p = window.location.pathname || '';
72
- if (/^\/topic\//.test(p)) return 'topic';
73
- if (/^\/category\//.test(p)) return 'categoryTopics';
74
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
75
- // fallback by DOM
76
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
77
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
78
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
79
- return 'other';
80
- }
81
-
82
- function getTopicItems() {
83
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
84
- }
85
-
86
- function getCategoryItems() {
87
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
88
- }
89
-
90
- function getPostContainers() {
91
- const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
92
- return nodes.filter((el) => {
93
- if (!el || !el.isConnected) return false;
94
- if (!el.querySelector('[component="post/content"]')) return false;
95
- const parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
96
- if (parentPost && parentPost !== el) return false;
97
- if (el.getAttribute('component') === 'post/parent') return false;
98
- return true;
99
- });
100
- }
101
-
102
- function safeRect(el) {
103
- try { return el.getBoundingClientRect(); } catch (e) { return null; }
104
- }
105
-
106
- function destroyPlaceholderIds(ids) {
107
- if (!ids || !ids.length) return;
108
- const filtered = ids.filter((id) => {
109
- try { return sessionDefinedIds.has(id); } catch (e) { return true; }
110
- });
111
- if (!filtered.length) return;
112
-
113
- const call = () => {
114
- try {
115
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
116
- window.ezstandalone.destroyPlaceholders(filtered);
117
- }
118
- } catch (e) {}
119
-
120
- // Recyclage: libérer IDs après 100ms
121
- setTimeout(() => {
122
- filtered.forEach(id => sessionDefinedIds.delete(id));
123
- }, 100);
124
- };
125
- try {
126
- window.ezstandalone = window.ezstandalone || {};
127
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
128
- if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') call();
129
- else window.ezstandalone.cmd.push(call);
130
- } catch (e) {}
131
-
132
- // Recyclage: libérer IDs après 100ms
133
- setTimeout(() => {
134
- filtered.forEach(id => sessionDefinedIds.delete(id));
135
- }, 100);
136
- }
137
-
138
- // Nettoyer éléments Ezoic invisibles qui créent espace vertical
139
- function cleanupInvisibleEzoicElements() {
140
- try {
141
- document.querySelectorAll('.ezoic-ad').forEach(wrapper => {
142
- // Supprimer éléments invisibles après le placeholder
143
- wrapper.querySelectorAll('*').forEach(el => {
144
- if (el.id && el.id.startsWith('ezoic-pub-ad-placeholder-')) return;
145
-
146
- const rect = el.getBoundingClientRect();
147
- const computed = window.getComputedStyle(el);
148
-
149
- // Élément invisible mais prend de l'espace
150
- if (
151
- (computed.display !== 'none' && computed.visibility !== 'hidden') &&
152
- (rect.height === 0 || computed.opacity === '0') &&
153
- el.children.length === 0
154
- ) {
155
- el.remove();
156
- }
157
- });
158
- });
159
- } catch (e) {}
160
- }
161
-
162
- function cleanupEmptyWrappers() {
163
- try {
164
- document.querySelectorAll('.ezoic-ad').forEach(wrapper => {
165
- const ph = wrapper.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
166
- if (ph && ph.children.length === 0) {
167
- // Placeholder vide après 3s = pub non chargée
168
- setTimeout(() => {
169
- if (ph.children.length === 0) {
170
- wrapper.remove();
171
- }
172
- }, 3000);
173
- }
174
- });
175
- } catch (e) {}
176
- }
177
-
178
- function getRecyclable(liveArr) {
179
- const margin = 600;
180
- for (let i = 0; i < liveArr.length; i++) {
181
- const entry = liveArr[i];
182
- if (!entry || !entry.wrap || !entry.wrap.isConnected) { liveArr.splice(i, 1); i--; continue; }
183
- const r = safeRect(entry.wrap);
184
- if (r && r.bottom < -margin) {
185
- liveArr.splice(i, 1);
186
- return entry;
187
- }
188
- }
189
- return null;
190
- }
191
-
192
- function pickId(pool, liveArr) {
193
- if (pool.length) return { id: pool.shift(), recycled: null };
194
- const recycled = getRecyclable(liveArr);
195
- if (recycled) return { id: recycled.id, recycled };
196
- return { id: null, recycled: null };
197
- }
198
-
199
- function resetPlaceholderInWrap(wrap, id) {
200
- try {
201
- if (!wrap) return;
202
- try { wrap.removeAttribute('data-ezoic-filled'); } catch (e) {}
203
- if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
204
- const old = wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
205
- if (old) old.remove();
206
- // Remove any leftover markup inside wrapper
207
- wrap.querySelectorAll && wrap.querySelectorAll('iframe, ins').forEach(n => n.remove());
208
- const ph = document.createElement('div');
209
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
210
- wrap.appendChild(ph);
211
- } catch (e) {}
56
+ const p = window.location.pathname || '';
57
+ if (/^\/topic\//.test(p)) return 'topic';
58
+ if (/^\/category\//.test(p)) return 'categoryTopics';
59
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
60
+
61
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
62
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
63
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
64
+ return 'other';
65
+ }
66
+
67
+ function parseIdPool(raw) {
68
+ if (!raw) return [];
69
+ const tokens = String(raw)
70
+ .split(/[\s,;]+/)
71
+ .map(s => s.trim())
72
+ .filter(Boolean);
73
+
74
+ const out = [];
75
+ const seen = new Set();
76
+ for (const t of tokens) {
77
+ const n = parseInt(t, 10);
78
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
79
+ seen.add(n);
80
+ out.push(n);
81
+ }
82
+ }
83
+ return out;
212
84
  }
213
85
 
214
- function isAdjacentAd(target) {
215
- if (!target || !target.nextElementSibling) return false;
216
- const next = target.nextElementSibling;
217
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
218
- return false;
219
- }
220
-
221
- function isPrevAd(target) {
222
- if (!target || !target.previousElementSibling) return false;
223
- const prev = target.previousElementSibling;
224
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
225
- return false;
226
- }
227
-
228
- function buildWrap(id, kindClass, afterPos) {
229
- const wrap = document.createElement('div');
230
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
231
- wrap.setAttribute('data-ezoic-after', String(afterPos));
232
- wrap.style.width = '100%';
233
-
234
- const ph = document.createElement('div');
235
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
236
- wrap.appendChild(ph);
237
- return wrap;
238
- }
239
-
240
- function findWrap(kindClass, afterPos) {
241
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
242
- }
243
-
244
- function insertAfter(target, id, kindClass, afterPos) {
245
- if (!target || !target.insertAdjacentElement) return null;
246
- if (findWrap(kindClass, afterPos)) return null;
247
-
248
- // CRITICAL: Double-lock pour éviter race conditions sur les doublons
249
- if (insertingIds.has(id)) return null;
250
-
251
- const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
252
- if (existingPh && existingPh.isConnected) return null;
253
-
254
- // Acquérir le lock
255
- insertingIds.add(id);
256
-
257
- try {
258
- const wrap = buildWrap(id, kindClass, afterPos);
259
- target.insertAdjacentElement('afterend', wrap);
260
- attachFillObserver(wrap, id);
261
- return wrap;
262
- } finally {
263
- setTimeout(() => insertingIds.delete(id), 50);
264
- }
265
- }
266
-
267
- function destroyUsedPlaceholders() {
268
- const ids = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
269
- if (ids.length) destroyPlaceholderIds(ids);
270
- }
271
-
272
- function patchShowAds() {
273
- const applyPatch = () => {
274
- try {
275
- window.ezstandalone = window.ezstandalone || {}, ez = window.ezstandalone;
276
- if (window.__nodebbEzoicPatched) return;
277
- if (typeof ez.showAds !== 'function') return;
278
-
279
- window.__nodebbEzoicPatched = true;
280
- const orig = ez.showAds;
281
-
282
- ez.showAds = function (arg) {
283
- if (Array.isArray(arg)) {
284
- const seen = new Set();
285
- for (const v of arg) {
286
- const id = parseInt(v, 10);
287
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
288
- seen.add(id);
289
- try { orig.call(ez, id); } catch (e) {}
290
- }
291
- return;
292
- }
293
- return orig.apply(ez, arguments);
294
- };
295
- } catch (e) {}
296
- };
297
-
298
- applyPatch();
299
- if (!window.__nodebbEzoicPatched) {
300
- try {
301
- window.ezstandalone = window.ezstandalone || {};
302
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
303
- window.ezstandalone.cmd.push(applyPatch);
304
- } catch (e) {}
305
- }
86
+ async function fetchConfig() {
87
+ if (state.cfg) return state.cfg;
88
+ if (state.cfgPromise) return state.cfgPromise;
89
+
90
+ state.cfgPromise = fetch('/api/plugins/ezoic-infinite/config', {
91
+ credentials: 'same-origin',
92
+ headers: { 'Accept': 'application/json' },
93
+ })
94
+ .then(r => (r && r.ok ? r.json() : null))
95
+ .catch(() => null)
96
+ .then((cfg) => {
97
+ state.cfg = cfg || { excluded: true };
98
+ state.pools.topics = parseIdPool(state.cfg.placeholderIds);
99
+ state.pools.categories = parseIdPool(state.cfg.categoryPlaceholderIds);
100
+ state.pools.messages = parseIdPool(state.cfg.messagePlaceholderIds);
101
+ return state.cfg;
102
+ })
103
+ .finally(() => { state.cfgPromise = null; });
104
+
105
+ return state.cfgPromise;
106
+ }
107
+
108
+ function rect(el) {
109
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
110
+ }
111
+
112
+ function takeRecyclable(kind) {
113
+ const arr = state.live[kind];
114
+ for (let i = 0; i < arr.length; i++) {
115
+ const entry = arr[i];
116
+ if (!entry || !entry.wrap || !entry.wrap.isConnected) {
117
+ arr.splice(i, 1); i--; continue;
118
+ }
119
+ const r = rect(entry.wrap);
120
+ if (r && r.bottom < -RECYCLE_MARGIN_PX) {
121
+ arr.splice(i, 1);
122
+ return entry;
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function pickId(kind) {
129
+ const pool = state.pools[kind];
130
+ if (pool && pool.length) return { id: pool.shift(), recycled: null };
131
+ const recycled = takeRecyclable(kind);
132
+ if (recycled) return { id: recycled.id, recycled };
133
+ return { id: null, recycled: null };
134
+ }
135
+
136
+ function buildWrap(kind, afterIndex, id) {
137
+ const wrap = document.createElement('div');
138
+ wrap.className = `${CLASS_WRAP} ${CLASS_WRAP}--${kind}`;
139
+ wrap.dataset.ezoicKind = kind;
140
+ wrap.dataset.ezoicAfter = String(afterIndex);
141
+ wrap.dataset.ezoicId = String(id);
142
+
143
+ const ph = document.createElement('div');
144
+ ph.id = `${PH_PREFIX}${id}`;
145
+ wrap.appendChild(ph);
146
+
147
+ return wrap;
148
+ }
149
+
150
+ function findWrap(kind, afterIndex) {
151
+ return document.querySelector(`.${CLASS_WRAP}[data-ezoic-kind="${kind}"][data-ezoic-after="${afterIndex}"]`);
152
+ }
153
+
154
+ function resetWrapPlaceholder(wrap, id) {
155
+ if (!wrap) return;
156
+ if (wrap.__ezoicObs) {
157
+ try { wrap.__ezoicObs.disconnect(); } catch (e) {}
158
+ wrap.__ezoicObs = null;
159
+ }
160
+ wrap.removeAttribute('data-ezoic-filled');
161
+ wrap.dataset.ezoicId = String(id);
162
+
163
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
164
+ const ph = document.createElement('div');
165
+ ph.id = `${PH_PREFIX}${id}`;
166
+ wrap.appendChild(ph);
167
+ }
168
+
169
+ function forcePlaceholderAutoHeight(wrap, id) {
170
+ const ph = wrap && wrap.querySelector ? wrap.querySelector(`#${PH_PREFIX}${id}`) : null;
171
+ if (!ph) return;
172
+ ph.style.setProperty('height', 'auto', 'important');
173
+ ph.style.setProperty('min-height', '0px', 'important');
174
+ requestAnimationFrame(() => {
175
+ ph.style.setProperty('height', 'auto', 'important');
176
+ ph.style.setProperty('min-height', '0px', 'important');
177
+ });
306
178
  }
307
179
 
308
180
  function markFilled(wrap) {
309
- try {
310
- if (!wrap) return;
311
- if (wrap.__ezoicFillObs) { wrap.__ezoicFillObs.disconnect(); wrap.__ezoicFillObs = null; }
312
- wrap.setAttribute('data-ezoic-filled', '1');
313
- } catch (e) {}
314
- }
181
+ if (!wrap || !wrap.isConnected) return;
182
+ if (wrap.getAttribute('data-ezoic-filled') === '1') return;
183
+ wrap.setAttribute('data-ezoic-filled', '1');
184
+
185
+ const id = parseInt(wrap.dataset.ezoicId, 10);
186
+ if (Number.isFinite(id) && id > 0) forcePlaceholderAutoHeight(wrap, id);
187
+ }
188
+
189
+ function removeWrap(wrap) {
190
+ if (!wrap) return;
191
+ if (wrap.__ezoicObs) {
192
+ try { wrap.__ezoicObs.disconnect(); } catch (e) {}
193
+ wrap.__ezoicObs = null;
194
+ }
195
+ try { wrap.remove(); } catch (e) {}
196
+ }
197
+
198
+ function showEzoicAd(id) {
199
+ if (!id || id <= 0) return;
200
+
201
+ const last = state.lastShowById.get(id) || 0;
202
+ if (now() - last < SHOW_DEBOUNCE_MS) return;
203
+ state.lastShowById.set(id, now());
204
+
205
+ const call = () => {
206
+ try {
207
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
208
+ window.ezstandalone.showAds(id);
209
+ }
210
+ } catch (e) {}
211
+ };
212
+
213
+ try {
214
+ window.ezstandalone = window.ezstandalone || {};
215
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
216
+ if (typeof window.ezstandalone.showAds === 'function') call();
217
+ else window.ezstandalone.cmd.push(call);
218
+ } catch (e) {}
219
+ }
220
+
221
+ function observeFill(wrap, id) {
222
+ const ph = wrap.querySelector(`#${PH_PREFIX}${id}`);
223
+ if (!ph) return;
224
+
225
+ // immediate
226
+ if (ph.querySelector('iframe, ins, img') || ph.childElementCount > 0) {
227
+ markFilled(wrap);
228
+ return;
229
+ }
315
230
 
316
- function isWrapMarkedFilled(wrap) {
317
- try { return wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'; } catch (e) { return false; }
318
- }
231
+ const obs = new MutationObserver(() => {
232
+ if (!wrap.isConnected) return;
233
+ const hasCreative = !!ph.querySelector('iframe, ins, img');
234
+ const r = rect(ph);
235
+ if (hasCreative || (r && r.height > 20)) {
236
+ markFilled(wrap);
237
+ try { obs.disconnect(); } catch (e) {}
238
+ wrap.__ezoicObs = null;
239
+ }
240
+ });
319
241
 
320
- function attachFillObserver(wrap, id) {
321
- try {
322
- const ph = wrap && wrap.querySelector && wrap.querySelector(`#${PLACEHOLDER_PREFIX}${id}`);
323
- if (!ph) return;
324
- // Already filled?
325
- if (ph.childNodes && ph.childNodes.length > 0) {
326
- markFilled(wrap); // Afficher wrapper
327
- sessionDefinedIds.add(id);
328
- return;
329
- }
330
- const obs = new MutationObserver(() => {
331
- if (ph.childNodes && ph.childNodes.length > 0) {
332
- markFilled(wrap); // CRITIQUE: Afficher wrapper maintenant
333
- try { sessionDefinedIds.add(id); } catch (e) {}
334
- try { obs.disconnect(); } catch (e) {}
335
- }
336
- });
337
- obs.observe(ph, { childList: true, subtree: true });
338
- wrap.__ezoicFillObs = obs;
339
- } catch (e) {}
340
- }
242
+ wrap.__ezoicObs = obs;
243
+ try { obs.observe(ph, { childList: true, subtree: true, attributes: true }); } catch (e) {}
244
+ }
341
245
 
342
- function isPlaceholderFilled(id) {
343
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
344
- if (!ph || !ph.isConnected) return false;
345
-
346
- const wrap = ph.parentElement;
347
- if (wrap && isWrapMarkedFilled(wrap)) return true;
348
-
349
- const filled = !!(ph.childNodes && ph.childNodes.length > 0);
350
- if (filled) {
351
- try { state.definedIds && state.definedIds.add(id); sessionDefinedIds.add(id); } catch (e) {}
352
- try { markFilled(wrap); } catch (e) {}
246
+ function scheduleRemovalIfUnfilled(wrap, kind, id) {
247
+ const t = setTimeout(() => {
248
+ state.timeouts.delete(t);
249
+ if (!wrap || !wrap.isConnected) return;
250
+ if (wrap.getAttribute('data-ezoic-filled') === '1') return;
251
+ removeWrap(wrap);
252
+ if (state.pools[kind] && typeof id === 'number' && id > 0) state.pools[kind].push(id);
253
+ }, FILL_TIMEOUT_MS);
254
+ state.timeouts.add(t);
353
255
  }
354
- return filled;
256
+
257
+ function insertAfter(target, wrap) {
258
+ if (!target || !wrap) return false;
259
+ const next = target.nextElementSibling;
260
+ if (next && next.classList && next.classList.contains(CLASS_WRAP)) return false;
261
+ try {
262
+ target.insertAdjacentElement('afterend', wrap);
263
+ return true;
264
+ } catch (e) {
265
+ return false;
266
+ }
355
267
  }
356
-
357
- let batchShowAdsTimer = null;
358
- const pendingShowAdsIds = new Set();
359
-
360
- function scheduleShowAdsBatch(id) {
361
- if (!id) return;
362
-
363
- if (sessionDefinedIds.has(id)) {
364
- try {
365
- destroyPlaceholderIds([id]);
366
- sessionDefinedIds.delete(id);
367
- } catch (e) {}
268
+
269
+ function computeAfterIndexes(count, interval, showFirst) {
270
+ const out = [];
271
+ const step = Math.max(1, parseInt(interval, 10) || 1);
272
+ const start = showFirst ? 1 : step;
273
+ for (let i = start; i < count; i += step) out.push(i);
274
+ return out;
368
275
  }
369
276
 
370
- // Throttle: ne pas rappeler le même ID trop vite
371
- const now = Date.now(), last = state.lastShowById.get(id) || 0;
372
- if (now - last < 3500) return;
373
-
374
- // Ajouter à la batch
375
- pendingShowAdsIds.add(id);
376
-
377
- clearTimeout(batchShowAdsTimer);
378
- batchShowAdsTimer = setTimeout(() => {
379
- if (pendingShowAdsIds.size === 0) return;
380
-
381
- const idsArray = Array.from(pendingShowAdsIds);
382
- pendingShowAdsIds.clear();
383
-
384
- // Appeler showAds avec TOUS les IDs en une fois
385
- try {
386
- window.ezstandalone = window.ezstandalone || {};
387
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
388
- window.ezstandalone.cmd.push(function() {
389
- if (typeof window.ezstandalone.showAds === 'function') {
390
- // Appel batch: showAds(id1, id2, id3...)
391
- window.ezstandalone.showAds(...idsArray);
392
- // Tracker tous les IDs
393
- idsArray.forEach(id => {
394
- state.lastShowById.set(id, Date.now());
395
- sessionDefinedIds.add(id);
396
- });
397
- }
398
- });
399
- } catch (e) {}
400
- }, 100);
401
- }
277
+ function runCategoryTopics(cfg) {
278
+ if (!cfg.enableBetweenAds) return;
402
279
 
403
- function callShowAdsWhenReady(id) {
404
- if (!id) return;
280
+ const items = Array.from(document.querySelectorAll(SELECTORS.topicItem));
281
+ if (!items.length) return;
405
282
 
406
- const now = Date.now(), last = state.lastShowById.get(id) || 0;
407
- if (now - last < 3500) return;
283
+ const after = computeAfterIndexes(items.length, cfg.intervalPosts || 6, !!cfg.showFirstTopicAd);
284
+ let inserts = 0;
408
285
 
409
- const phId = `${PLACEHOLDER_PREFIX}${id}`, doCall = () => {
410
- try {
411
- window.ezstandalone = window.ezstandalone || {};
412
- if (typeof window.ezstandalone.showAds === 'function') {
286
+ for (const afterIndex of after) {
287
+ if (inserts >= MAX_INSERTS_PER_TICK) break;
288
+ const anchor = items[afterIndex - 1];
289
+ if (!anchor) continue;
290
+ if (findWrap('topics', afterIndex)) continue;
413
291
 
414
- state.lastShowById.set(id, Date.now());
415
- window.ezstandalone.showAds(id);
416
- sessionDefinedIds.add(id);
417
- return true;
418
- }
419
- } catch (e) {}
420
- return false;
421
- };
292
+ const { id, recycled } = pickId('topics');
293
+ if (!id) break;
422
294
 
423
- const startPageKey = state.pageKey;
424
- let attempts = 0;
425
- (function waitForPh() {
426
- if (state.pageKey !== startPageKey) return;
427
- if (state.pendingById.has(id)) return;
295
+ const wrap = recycled ? recycled.wrap : buildWrap('topics', afterIndex, id);
296
+ if (recycled) resetWrapPlaceholder(wrap, id);
428
297
 
429
- attempts += 1;
430
- const el = document.getElementById(phId);
431
- if (el && el.isConnected) {
298
+ if (!insertAfter(anchor, wrap)) { state.pools.topics.push(id); continue; }
432
299
 
433
- // Si on arrive ici, soit visible, soit timeout
434
-
435
- if (doCall()) {
436
- state.pendingById.delete(id);
437
- return;
438
- }
439
-
440
- }
441
-
442
- if (attempts < 100) {
443
- const timeoutId = setTimeout(waitForPh, 50);
444
- state.activeTimeouts.add(timeoutId);
445
- }
446
- })();
447
- }
448
-
449
- async function fetchConfig() {
450
- if (state.cfg) return state.cfg;
451
- if (state.cfgPromise) return state.cfgPromise;
452
-
453
- state.cfgPromise = (async () => {
454
- const MAX_TRIES = 3;
455
- let delay = 800;
456
- for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
457
- try {
458
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
459
- if (res.ok) {
460
- state.cfg = await res.json();
461
- return state.cfg;
462
- }
463
- } catch (e) {}
464
- if (attempt < MAX_TRIES) await new Promise(r => setTimeout(r, delay));
465
- delay *= 2;
300
+ state.live.topics.push({ id, wrap });
301
+ observeFill(wrap, id);
302
+ scheduleRemovalIfUnfilled(wrap, 'topics', id);
303
+ if (wrap.querySelector(`#${PH_PREFIX}${id}`)) showEzoicAd(id);
304
+ inserts++;
305
+ }
466
306
  }
467
- return null;
468
- })();
469
307
 
470
- try { return await state.cfgPromise; } finally { state.cfgPromise = null; }
471
- }
472
-
473
- function initPools(cfg) {
474
- if (state.poolTopics.length === 0) state.poolTopics = parsePool(cfg.placeholderIds);
475
- if (state.poolPosts.length === 0) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
476
- if (state.poolCategories.length === 0) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
477
- }
478
-
479
- function computeTargets(count, interval, showFirst) {
480
- const out = [];
481
- if (count <= 0) return out;
482
- if (showFirst) out.push(1);
483
- for (let i = 1; i <= count; i++) {
484
- if (i % interval === 0) out.push(i);
485
- }
486
- return Array.from(new Set(out)).sort((a, b) => a - b);
487
- }
488
-
489
- function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet) {
490
- if (!items.length) return 0;
491
- const targets = computeTargets(items.length, interval, showFirst);
308
+ function runCategories(cfg) {
309
+ if (!cfg.enableCategoryAds) return;
492
310
 
493
- let inserted = 0;
494
- for (const afterPos of targets) {
495
- if (inserted >= MAX_INSERTS_PER_RUN) break;
311
+ const items = Array.from(document.querySelectorAll(SELECTORS.categoryItem));
312
+ if (!items.length) return;
496
313
 
497
- const el = items[afterPos - 1];
498
- if (!el || !el.isConnected) continue;
314
+ const after = computeAfterIndexes(items.length, cfg.intervalCategories || 4, !!cfg.showFirstCategoryAd);
315
+ let inserts = 0;
499
316
 
500
- if (isAdjacentAd(el) || isPrevAd(el)) {
501
- continue;
502
- }
317
+ for (const afterIndex of after) {
318
+ if (inserts >= MAX_INSERTS_PER_TICK) break;
319
+ const anchor = items[afterIndex - 1];
320
+ if (!anchor) continue;
321
+ if (findWrap('categories', afterIndex)) continue;
503
322
 
504
- // Prevent back-to-back at load
505
- const prevWrap = findWrap(kindClass, afterPos - 1);
506
- if (prevWrap) continue;
323
+ const { id, recycled } = pickId('categories');
324
+ if (!id) break;
507
325
 
508
- if (findWrap(kindClass, afterPos)) continue;
326
+ const wrap = recycled ? recycled.wrap : buildWrap('categories', afterIndex, id);
327
+ if (recycled) resetWrapPlaceholder(wrap, id);
509
328
 
510
- const pick = pickId(kindPool, []);
511
- const id = pick.id;
512
- if (!id) break;
329
+ if (!insertAfter(anchor, wrap)) { state.pools.categories.push(id); continue; }
513
330
 
514
- let wrap = null;
515
- if (pick.recycled && pick.recycled.wrap) {
516
- if (sessionDefinedIds.has(id)) {
517
- destroyPlaceholderIds([id]);
518
- }
519
- const oldWrap = pick.recycled.wrap;
520
- try { if (oldWrap && oldWrap.__ezoicFillObs) { oldWrap.__ezoicFillObs.disconnect(); } } catch (e) {}
521
- try { oldWrap && oldWrap.remove(); } catch (e) {}
522
- wrap = insertAfter(el, id, kindClass, afterPos);
523
- if (!wrap) continue;
524
- setTimeout(() => {
525
- callShowAdsWhenReady(id);
526
- }, 50);
527
- } else {
528
- usedSet.add(id);
529
- wrap = insertAfter(el, id, kindClass, afterPos);
530
- if (!wrap) continue;
531
- // Micro-délai pour laisser le DOM se synchroniser
532
- // Appel immédiat au lieu de 10ms delay
533
- callShowAdsWhenReady(id);
331
+ state.live.categories.push({ id, wrap });
332
+ observeFill(wrap, id);
333
+ scheduleRemovalIfUnfilled(wrap, 'categories', id);
334
+ if (wrap.querySelector(`#${PH_PREFIX}${id}`)) showEzoicAd(id);
335
+ inserts++;
336
+ }
534
337
  }
535
338
 
536
- liveArr.push({ id, wrap });
537
- if (wrap && (
538
- (wrap.previousElementSibling && wrap.previousElementSibling.classList && wrap.previousElementSibling.classList.contains(WRAP_CLASS)) || (wrap.nextElementSibling && wrap.nextElementSibling.classList && wrap.nextElementSibling.classList.contains(WRAP_CLASS))
539
- )) {
540
- try { wrap.remove(); } catch (e) {}
541
- if (!(pick.recycled && pick.recycled.wrap)) {
542
- try { kindPool.unshift(id); } catch (e) {}
543
- usedSet.delete(id);
544
- }
545
- continue;
546
- }
547
- inserted += 1;
548
- }
549
- return inserted;
550
- }
551
-
552
- function enforceNoAdjacentAds() {
553
- const ads = Array.from(document.querySelectorAll(`.${WRAP_CLASS}`));
554
- for (let i = 0; i < ads.length; i++) {
555
- const ad = ads[i], prev = ad.previousElementSibling;
556
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
557
- try {
558
- const ph = ad.querySelector && ad.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
559
- if (ph) {
560
- const id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
561
- if (Number.isFinite(id) && id > 0) {
562
- // Détruire le placeholder si Ezoic l'a déjà défini
563
- if (sessionDefinedIds.has(id)) {
564
- destroyPlaceholderIds([id]);
565
- }
566
- }
567
- }
568
- ad.remove();
569
- } catch (e) {}
570
- }
571
- }
572
- }
573
-
574
- function cleanup() {
575
- destroyUsedPlaceholders();
576
-
577
- document.querySelectorAll('.ezoic-ad').forEach(el => {
578
- try { el.remove(); } catch (e) {}
579
- });
580
-
581
- state.pageKey = getPageKey();
582
- state.cfg = null;
583
- state.cfgPromise = null;
584
-
585
- state.poolTopics = [];
586
- state.poolPosts = [];
587
- state.poolCategories = [];
588
- state.usedTopics.clear();
589
- state.usedPosts.clear();
590
- state.usedCategories.clear();
591
- state.lastShowById.clear();
592
- state.pendingById.clear();
593
- state.definedIds.clear();
594
-
595
- state.activeTimeouts.forEach(id => {
596
- try { clearTimeout(id); } catch (e) {}
597
- });
598
- state.activeTimeouts.clear();
599
-
600
- state.pendingById.clear();
601
-
602
- if (state.obs) { state.obs.disconnect(); state.obs = null; }
603
-
604
- state.scheduled = false;
605
- clearTimeout(state.timer);
606
- state.timer = null;
607
- }
339
+ function runMessageAds(cfg) {
340
+ if (!cfg.enableMessageAds) return;
608
341
 
609
- function ensureObserver() {
610
- if (state.obs) return;
611
- state.obs = new MutationObserver(() => scheduleRun('mutation'));
612
- try { state.obs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
613
- }
342
+ const posts = Array.from(document.querySelectorAll(SELECTORS.postItem))
343
+ .filter(p => p && p.isConnected && p.querySelector(SELECTORS.postContent));
344
+ if (!posts.length) return;
614
345
 
615
- async function runCore() {
616
- if (!state.canShowAds) {
617
- return;
618
- }
346
+ const after = computeAfterIndexes(posts.length, cfg.messageIntervalPosts || 3, !!cfg.showFirstMessageAd);
347
+ let inserts = 0;
619
348
 
620
- patchShowAds();
349
+ for (const afterIndex of after) {
350
+ if (inserts >= MAX_INSERTS_PER_TICK) break;
351
+ const anchor = posts[afterIndex - 1];
352
+ if (!anchor) continue;
353
+ if (findWrap('messages', afterIndex)) continue;
621
354
 
622
- const cfg = await fetchConfig();
623
- if (!cfg || cfg.excluded) return;
355
+ const { id, recycled } = pickId('messages');
356
+ if (!id) break;
624
357
 
625
- initPools(cfg);
358
+ const wrap = recycled ? recycled.wrap : buildWrap('messages', afterIndex, id);
359
+ wrap.classList.add('ezoic-ad--message');
360
+ if (recycled) resetWrapPlaceholder(wrap, id);
626
361
 
627
- const kind = getKind();
628
- let inserted = 0;
362
+ if (!insertAfter(anchor, wrap)) { state.pools.messages.push(id); continue; }
629
363
 
630
- if (kind === 'topic') {
631
- if (normalizeBool(cfg.enableMessageAds)) {
632
- inserted = injectBetween('ezoic-ad-message', getPostContainers(),
633
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
634
- normalizeBool(cfg.showFirstMessageAd),
635
- state.poolPosts,
636
- state.usedPosts);
637
- }
638
- } else if (kind === 'categoryTopics') {
639
- if (normalizeBool(cfg.enableBetweenAds)) {
640
- inserted = injectBetween('ezoic-ad-between', getTopicItems(),
641
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
642
- normalizeBool(cfg.showFirstTopicAd),
643
- state.poolTopics,
644
- state.usedTopics);
645
- }
646
- } else if (kind === 'categories') {
647
- if (normalizeBool(cfg.enableCategoryAds)) {
648
- inserted = injectBetween('ezoic-ad-categories', getCategoryItems(),
649
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
650
- normalizeBool(cfg.showFirstCategoryAd),
651
- state.poolCategories,
652
- state.usedCategories);
364
+ state.live.messages.push({ id, wrap });
365
+ observeFill(wrap, id);
366
+ scheduleRemovalIfUnfilled(wrap, 'messages', id);
367
+ if (wrap.querySelector(`#${PH_PREFIX}${id}`)) showEzoicAd(id);
368
+ inserts++;
369
+ }
653
370
  }
654
- }
655
-
656
- enforceNoAdjacentAds();
657
371
 
658
- let count = 0;
659
- if (kind === 'topic') count = getPostContainers().length;
660
- else if (kind === 'categoryTopics') count = getTopicItems().length;
661
- else if (kind === 'categories') count = getCategoryItems().length;
372
+ function cleanupForNavigation() {
373
+ for (const t of state.timeouts) clearTimeout(t);
374
+ state.timeouts.clear();
662
375
 
663
- if (count === 0 && 0 < 25) {
664
- setTimeout(arguments[0], 50);
665
- return;
666
- }
376
+ if (state.observer) {
377
+ try { state.observer.disconnect(); } catch (e) {}
378
+ state.observer = null;
379
+ }
667
380
 
668
- if (inserted >= MAX_INSERTS_PER_RUN) {
669
- setTimeout(arguments[0], 50);
670
- } else if (inserted === 0 && count > 0) {
671
- // Pool épuisé ou recyclage pas encore disponible.
672
- if (state.poolWaitAttempts < 8) {
673
- state.poolWaitAttempts += 1;
674
- setTimeout(arguments[0], 50);
675
- } else {
676
- }
677
- } else if (inserted > 0) {
678
- }
381
+ state.live.topics = [];
382
+ state.live.categories = [];
383
+ state.live.messages = [];
384
+ state.lastShowById.clear();
679
385
  }
680
386
 
681
387
  function scheduleRun() {
682
- if (state.scheduled) return;
683
- state.scheduled = true;
684
-
685
- clearTimeout(state.timer);
686
- state.timer = setTimeout(() => {
687
- state.scheduled = false;
688
- const pk = getPageKey();
689
- if (state.pageKey && pk !== state.pageKey) return;
690
- runCore().catch(() => {});
691
- }, 50);
692
- }
693
-
694
- function bind() {
695
- if (!$) return;
696
-
697
- $(window).off('.ezoicInfinite');
698
-
699
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
700
-
701
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
702
- state.pageKey = getPageKey();
703
- ensureObserver();
704
-
705
- state.canShowAds = true;
706
- });
707
-
708
- $(window).on('action:category.loaded.ezoicInfinite', () => {
709
- ensureObserver();
710
- waitForContentThenRun();
711
- });
712
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
713
- ensureObserver();
714
- waitForContentThenRun();
715
- });
716
-
717
- $(window).on('action:topic.loaded.ezoicInfinite', () => {
718
- ensureObserver();
719
- waitForContentThenRun();
720
- });
721
-
722
- $(window).on('action:posts.loaded.ezoicInfinite', () => {
723
- ensureObserver();
724
- // posts.loaded = infinite scroll
725
- waitForContentThenRun();
726
- });
727
- }
728
-
729
- function bindScroll() {
730
- if (state.lastScrollRun > 0) return;
731
- state.lastScrollRun = Date.now();
732
- let ticking = false;
733
- window.addEventListener('scroll', () => {
734
- if (ticking) return;
735
- ticking = true;
736
- window.requestAnimationFrame(() => {
737
- ticking = false;
738
- enforceNoAdjacentAds();
739
- const now = Date.now();
740
- if (!state.lastScrollRun || now - state.lastScrollRun > 2000) {
741
- state.lastScrollRun = now;
742
- scheduleRun();
743
- }
744
- });
745
- }, { passive: true });
746
- }
747
-
748
- function waitForContentThenRun() {
749
- const MIN_WORDS = 250;
750
- let attempts = 0;
751
- const maxAttempts = 20; // 20 × 200ms = 4s max
752
-
753
- (function check() {
754
- attempts++;
755
-
756
- // Compter les mots sur la page
757
- const text = document.body.innerText || '';
758
- const wordCount = text.split(/\s+/).filter(Boolean).length;
759
-
760
- if (wordCount >= MIN_WORDS) {
761
- // Assez de contenu → lancer l'insertion
762
- scheduleRun();
763
- return;
764
- }
765
-
766
- // Pas assez de contenu
767
- if (attempts >= maxAttempts) {
768
- // Timeout après 4s → tenter quand même
769
- scheduleRun();
770
- return;
771
- }
772
-
773
- // Réessayer dans 200ms
774
- setTimeout(check, 50);
775
- })();
776
- }
777
-
778
- function waitForEzoicThenRun() {
779
- let attempts = 0;
780
- const maxAttempts = 50; // 50 × 200ms = 10s max
781
-
782
- (function check() {
783
- attempts++;
784
- // Vérifier si Ezoic est chargé
785
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
786
- // Ezoic est prêt → lancer l'insertion
787
- scheduleRun();
788
- waitForContentThenRun();
789
- return;
790
- }
791
- // Ezoic pas encore prêt
792
- if (attempts >= maxAttempts) {
793
- // Tenter quand même
794
- scheduleRun();
795
- return;
796
- }
797
- // Réessayer dans 200ms
798
- setTimeout(check, 50);
799
- })();
388
+ if (state.scheduled) return;
389
+ state.scheduled = true;
390
+ requestAnimationFrame(() => {
391
+ state.scheduled = false;
392
+ run().catch(() => {});
393
+ });
394
+ }
395
+
396
+ async function run() {
397
+ const pk = getPageKey();
398
+ if (pk !== state.pageKey) {
399
+ state.pageKey = pk;
400
+ cleanupForNavigation();
401
+ state.cfg = null;
402
+ state.cfgPromise = null;
403
+ }
404
+
405
+ const cfg = await fetchConfig();
406
+ if (!cfg || cfg.excluded) return;
407
+
408
+ const kind = getKind();
409
+ if (kind === 'categoryTopics') runCategoryTopics(cfg);
410
+ if (kind === 'categories') runCategories(cfg);
411
+ if (kind === 'topic') runMessageAds(cfg);
412
+
413
+ // remove obvious empty spacer nodes injected by networks
414
+ try {
415
+ document.querySelectorAll(`.${CLASS_WRAP} > div:empty`).forEach(n => n.remove());
416
+ } catch (e) {}
417
+ }
418
+
419
+ function start() {
420
+ scheduleRun();
421
+
422
+ state.observer = new MutationObserver(() => {
423
+ const t = now();
424
+ if (t - state.lastRunAt < 200) return;
425
+ state.lastRunAt = t;
426
+ scheduleRun();
427
+ });
428
+
429
+ try { state.observer.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
430
+
431
+ // NodeBB ajaxify hook
432
+ if (window.jQuery && window.jQuery(window).on) {
433
+ window.jQuery(window).on('action:ajaxify.end', () => {
434
+ state.pageKey = null;
435
+ scheduleRun();
436
+ });
437
+ } else {
438
+ window.addEventListener('popstate', () => {
439
+ state.pageKey = null;
440
+ scheduleRun();
441
+ });
442
+ }
443
+ }
444
+
445
+ if (document.readyState === 'loading') {
446
+ document.addEventListener('DOMContentLoaded', start);
447
+ } else {
448
+ start();
800
449
  }
801
-
802
- cleanup();
803
- bind();
804
- bindScroll();
805
- ensureObserver();
806
- state.pageKey = getPageKey();
807
-
808
- // Attendre que Ezoic soit chargé avant d'insérer
809
- waitForEzoicThenRun();
810
- })();
450
+ })();
package/public/style.css CHANGED
@@ -1,10 +1,41 @@
1
+ /*
2
+ Ezoic Infinite Ads
3
+ - Wrappers are hidden until ad is detected as filled (data-ezoic-filled="1").
4
+ - Prevent reserved heights/margins creating blank space under creatives.
5
+ */
6
+
1
7
  .ezoic-ad {
2
- height: auto !important;
3
- padding: 0 !important;
4
- margin: 0 !important;
8
+ display: none;
9
+ width: 100%;
10
+ clear: both;
11
+ margin: 0;
12
+ padding: 0;
5
13
  }
6
14
 
7
- .ezoic-ad * {
15
+ .ezoic-ad[data-ezoic-filled="1"] {
16
+ display: block;
17
+ }
18
+
19
+ /* Do not let the placeholder reserve fixed height */
20
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
21
+ height: auto !important;
22
+ min-height: 0 !important;
8
23
  margin: 0 !important;
9
24
  padding: 0 !important;
10
- }
25
+ }
26
+
27
+ /* Avoid baseline gaps under iframes/ins */
28
+ .ezoic-ad iframe,
29
+ .ezoic-ad ins {
30
+ display: block !important;
31
+ }
32
+
33
+ /* Remove empty spacer divs that can appear after injection */
34
+ .ezoic-ad > div:empty {
35
+ display: none !important;
36
+ }
37
+
38
+ /* Optional: message-style ad spacing (looks nicer between posts) */
39
+ .ezoic-ad--message[data-ezoic-filled="1"] {
40
+ margin: 0.75rem 0;
41
+ }