Size
34.5 KB
Version
1.1.8
Created
Oct 17, 2025
Updated
6 days ago
1// ==UserScript==
2// @name Kleinanzeigen Mietdashboard mit Preisanalyse
3// @description A new userscript
4// @version 1.1.8
5// @match https://*.kleinanzeigen.de/*
6// @icon https://www.kleinanzeigen.de/favicon.svg
7// @grant GM.getValue
8// @grant GM.setValue
9// @grant GM.deleteValue
10// ==/UserScript==
11(function() {
12 'use strict';
13
14 // ============================================================================
15 // KONSTANTEN & STORAGE
16 // ============================================================================
17
18 const OLD_KEY = 'kleinanzeigenRegionalSqmPrices_3digit';
19 const STORAGE_KEY = 'ka_regional_sqm_array_v1';
20 const PREFS_KEY = 'ka_prefs_v1';
21 const PLZ_PREFIX_LENGTH = 3;
22
23 const CONFIG = {
24 DASHBOARD_ANCHOR_SELECTOR: '.srp-header.l-container-row',
25 AD_ITEM_SELECTOR: 'li.ad-listitem, .ad-listitem, article.aditem[data-adid], article.aditem',
26 DEBOUNCE_DELAY: 300,
27 AD_SCRIPT_PATTERNS: [
28 /ads\.js/i, /advertisement\.js/i, /adservice/i, /googlesyndication\.com/i,
29 /liberty.*\.js/i, /fbevent\.js/i, /teads.*\.js/i, /taboola.*\.js/i,
30 /criteo.*\.js/i, /bat\.bing\.com/i, /hotjar.*\.js/i
31 ],
32 AD_ELEMENT_SELECTORS: [
33 '.site-base--left-banner', '.site-base--right-banner', '#banner-skyscraper',
34 '.sticky-advertisement', 'div[id^="google_ads_iframe_"]', 'iframe[aria-label*="ad"]',
35 '[data-liberty-position-name*="banner"]', '[aria-label*="Advertisement"]',
36 '[aria-label*="Werbung"]', 'div[aria-label*="Gesponsert"]'
37 ]
38 };
39
40 // ============================================================================
41 // STORAGE MANAGEMENT (1000 Regionen)
42 // ============================================================================
43
44 async function ensureStorageInit() {
45 let arr = await GM.getValue(STORAGE_KEY, null);
46 if (!Array.isArray(arr) || arr.length !== 1000) {
47 arr = Array.from({ length: 1000 }, () => ({}));
48 const old = await GM.getValue(OLD_KEY, null);
49 if (old && typeof old === 'object') {
50 for (const prefix in old) {
51 if (!Object.prototype.hasOwnProperty.call(old, prefix)) continue;
52 const idx = parseInt(prefix, 10);
53 if (Number.isInteger(idx) && idx >= 0 && idx < 1000) {
54 Object.assign(arr[idx], old[prefix]);
55 }
56 }
57 await GM.deleteValue(OLD_KEY);
58 }
59 await GM.setValue(STORAGE_KEY, arr);
60 }
61 return arr;
62 }
63
64 async function saveStorage(arr) {
65 await GM.setValue(STORAGE_KEY, arr);
66 }
67
68 async function loadPrefs() {
69 return await GM.getValue(PREFS_KEY, { hideGesuche: true, hideTausch: true });
70 }
71
72 async function savePrefs(p) {
73 await GM.setValue(PREFS_KEY, p);
74 }
75
76 function calculateStorageInfoArray(arr) {
77 const json = JSON.stringify(arr);
78 const sizeBytes = new TextEncoder().encode(json).length;
79 const sizeMegabytes = (sizeBytes / (1024 * 1024)).toFixed(4);
80 let totalAdEntries = 0;
81 for (let i = 0; i < 1000; i++) totalAdEntries += Object.keys(arr[i]).length;
82 return { sizeMegabytes, totalAdEntries };
83 }
84
85 let regionalArray = null;
86 let prefs = null;
87
88 // ============================================================================
89 // PARSER FUNKTIONEN (gehärtet)
90 // ============================================================================
91
92 function getRegionIndexFromText(text) {
93 if (!text) return null;
94 // erste plausible dt. PLZ (01000–99999)
95 const match = text.match(/\b(0[1-9]\d{3}|[1-9]\d{4})\b/);
96 if (!match) return null;
97 const idx = parseInt(match[0].slice(0, PLZ_PREFIX_LENGTH), 10);
98 return (idx >= 0 && idx <= 999) ? idx : null;
99 }
100
101 function parsePriceFromText(text) {
102 if (!text) return null;
103 // Nur Gesamtpreis in €, NICHT "€/m²", "pro m²"
104 const re = /(\d{1,3}(?:\.\d{3})*(?:,\d{2})?)\s*€(?!\s*(?:\/\s*m²|pro\s*m²|\/m2))/i;
105 const m = re.exec(text);
106 if (!m) return null;
107 const cleaned = m[1].replace(/\./g, '').replace(',', '.');
108 const num = parseFloat(cleaned);
109 return Number.isFinite(num) ? num : null;
110 }
111
112 function parseAreaFromText(text) {
113 if (!text) return null;
114 const m = text.match(/(\d{1,3}(?:\.\d{3})*(?:,\d{1,2})?)\s*m²/i);
115 if (!m) return null;
116 const cleaned = m[1].replace(/\./g, '').replace(',', '.');
117 const val = parseFloat(cleaned);
118 return (Number.isFinite(val) && val > 0) ? val : null;
119 }
120
121 function safeBase64(s) {
122 try { return btoa(unescape(encodeURIComponent(s))); }
123 catch { return String(Date.now()); }
124 }
125
126 function getAdIdFromArticle(article) {
127 const link = article.querySelector('a[href*="/s-anzeige/"]');
128 if (link) {
129 const m = link.href.match(/\/(\d+)-\d+-\d+$/);
130 if (m) return m[1];
131 }
132 return 'ad_' + safeBase64((article.textContent || '').slice(0, 120))
133 .replace(/[^a-zA-Z0-9]/g, '').slice(0, 12);
134 }
135
136 // ============================================================================
137 // UPSERT HELPER
138 // ============================================================================
139
140 function upsertAd(regionIdx, adId, roundedPrice) {
141 if (!regionalArray[regionIdx]) regionalArray[regionIdx] = {};
142 if (regionalArray[regionIdx][adId] !== roundedPrice) {
143 regionalArray[regionIdx][adId] = roundedPrice;
144 return true;
145 }
146 return false;
147 }
148
149 // ============================================================================
150 // HAUPTLOGIK
151 // ============================================================================
152
153 const KleinanzeigenOptimizer = {
154 state: { lastUrl: location.href },
155
156 async init() {
157 regionalArray = await ensureStorageInit();
158 prefs = await loadPrefs();
159
160 this.injectStyles();
161 this.blockScripts();
162 this.registerMenuCommands();
163
164 setTimeout(() => this.run(), 250);
165 this.observeSPA();
166
167 window.addEventListener('error', e => console.error('[KA-SCRIPT] Globaler JS-Fehler:', e.error, e));
168 },
169
170 async run() {
171 console.log('[KA-SCRIPT] Starte Analyse für:', location.href);
172
173 // Prüfe ob wir auf einer Mietwohnungs-Seite sind
174 if (!this.isMietwohnungenPage()) {
175 console.log('[KA-SCRIPT] Nicht auf Mietwohnungs-Seite - Script wird nicht ausgeführt');
176 document.getElementById('ka-main-dashboard')?.remove();
177 return;
178 }
179
180 try {
181 this.removeAdElements();
182 this.applyFilterSectionStyles();
183 await this.processAdItems();
184 this.setupImgZoom();
185 } catch(e) {
186 console.error('[KA-SCRIPT] Ein schwerwiegender Fehler ist im run() aufgetreten:', e);
187 }
188 },
189
190 isMietwohnungenPage() {
191 // Prüfung 1: URL enthält /s-wohnung-mieten/ oder /c203
192 if (location.href.includes('/s-wohnung-mieten/') || location.href.includes('/c203')) {
193 console.log('[KA-SCRIPT] ✓ Mietwohnungs-Seite erkannt (URL)');
194 return true;
195 }
196
197 // Prüfung 2: Breadcrumb enthält "Mietwohnungen"
198 const breadcrumb = document.querySelector('.breadcrump-leaf');
199 if (breadcrumb && breadcrumb.textContent.trim() === 'Mietwohnungen') {
200 console.log('[KA-SCRIPT] ✓ Mietwohnungs-Seite erkannt (Breadcrumb)');
201 return true;
202 }
203
204 // Prüfung 3: Kategorie-Box zeigt "Mietwohnungen" als ausgewählt
205 const selectedCategory = document.querySelector('.browsebox-selected-itembox');
206 if (selectedCategory && selectedCategory.textContent.includes('Mietwohnungen')) {
207 console.log('[KA-SCRIPT] ✓ Mietwohnungs-Seite erkannt (Kategorie-Box)');
208 return true;
209 }
210
211 console.log('[KA-SCRIPT] ✗ Keine Mietwohnungs-Seite erkannt');
212 return false;
213 },
214
215 injectStyles() {
216 const style = document.createElement('style');
217 style.textContent = `
218 :root {
219 --ka-color-low: #38cb7f;
220 --ka-color-low-bg: #ecfaed;
221 --ka-color-mid: #ffd264;
222 --ka-color-mid-bg: #fff8d2;
223 --ka-color-high: #ff6363;
224 --ka-color-high-bg: #fff1ef;
225 --ka-price-color: #2342b2;
226 --ka-border-color: #e3e9f1;
227 }
228 #ka-main-dashboard {
229 margin: 0 0 16px 0;
230 display: flex;
231 gap: 1.3em;
232 background: #f4f7fb;
233 border: 2px solid var(--ka-border-color);
234 border-radius: 13px;
235 box-shadow: 0 2px 18px rgba(0,0,0,0.13);
236 padding: 1.2em 1.6em;
237 align-items: center;
238 flex-wrap: wrap;
239 }
240 .ka-dash-card {
241 flex: 1 1 0;
242 text-align: center;
243 border-radius: 10px;
244 background: #fff;
245 margin: 0 0.2em;
246 padding: 0.9em 0.4em;
247 box-shadow: 0 1px 8px rgba(0,0,0,0.08);
248 min-width: 120px;
249 }
250 .ka-dash-card.ka-low { border-left: 7px solid var(--ka-color-low); }
251 .ka-dash-card.ka-mid { border-left: 7px solid var(--ka-color-mid); }
252 .ka-dash-card.ka-high { border-left: 7px solid var(--ka-color-high); }
253 .ka-dash-title {
254 font-weight: 700;
255 font-size: 1.13em;
256 margin-bottom: 6px;
257 }
258 .ka-dash-value {
259 font-size: 1.77em;
260 margin: 0 0 0.3em 0;
261 display: block;
262 font-weight: bold;
263 }
264 .ka-dash-sub {
265 color: #777;
266 font-size: 0.97em;
267 line-height: 1.12;
268 }
269 #ka-dash-meta {
270 font-size: 0.94em;
271 color: #444;
272 margin-top: 6px;
273 flex-basis: 100%;
274 text-align: center;
275 }
276 #ka-dash-clear-btn {
277 margin-left: 0.7em;
278 font-size: 0.98em;
279 padding: 0.07em 0.55em;
280 cursor: pointer;
281 }
282 .ka-sqm-wrap {
283 text-align: right;
284 }
285 .ka-sqm-price-display {
286 font-size: 1.65em;
287 color: var(--ka-price-color);
288 font-weight: 800;
289 margin-left: 0.6em;
290 background: #eef4fc;
291 padding: 2px 16px 2px 13px;
292 border-radius: 5px;
293 float: none;
294 display: inline-block;
295 }
296 /* Ampel-Hinterlegung: breiter Fallback + linker Farbstreifen (gut sichtbar) */
297 .ad-listitem.ka-price-low, article.aditem.ka-price-low {
298 background: var(--ka-color-low-bg) !important;
299 box-shadow: inset 6px 0 0 var(--ka-color-low) !important;
300 }
301 .ad-listitem.ka-price-medium, article.aditem.ka-price-medium {
302 background: var(--ka-color-mid-bg) !important;
303 box-shadow: inset 6px 0 0 var(--ka-color-mid) !important;
304 }
305 .ad-listitem.ka-price-high, article.aditem.ka-price-high {
306 background: var(--ka-color-high-bg) !important;
307 box-shadow: inset 6px 0 0 var(--ka-color-high) !important;
308 }
309 .ad-listitem.ka-price-uniform, article.aditem.ka-price-uniform {
310 background: #eee !important;
311 box-shadow: inset 6px 0 0 #bbb !important;
312 }
313 .ka-hidden {
314 display: none !important;
315 }
316 .ka-overlay-img {
317 position: fixed;
318 left: 50%;
319 top: 50%;
320 max-width: 94vw;
321 max-height: 94vh;
322 transform: translate(-50%, -50%);
323 z-index: 29999;
324 border-radius: 12px;
325 box-shadow: 0 8px 40px 0 rgba(0,0,0,0.72);
326 background: #222;
327 opacity: 0;
328 pointer-events: none;
329 display: block;
330 object-fit: contain;
331 transition: opacity 0.16s cubic-bezier(0.19, 1, 0.22, 1);
332 }
333 `;
334 document.head.appendChild(style);
335 },
336
337 injectDashboard(stats) {
338 console.log('[KA-SCRIPT] Injiziere Dashboard mit folgenden Daten:', stats);
339 document.getElementById('ka-main-dashboard')?.remove();
340
341 const anchor = document.querySelector(CONFIG.DASHBOARD_ANCHOR_SELECTOR);
342
343 if (!anchor) {
344 console.error(`[KA-SCRIPT] Dashboard-Anker "${CONFIG.DASHBOARD_ANCHOR_SELECTOR}" nicht gefunden! Nutze Body als Fallback.`);
345 document.body.prepend(this.createDashboardPanel(stats));
346 return;
347 }
348
349 console.log('[KA-SCRIPT] Dashboard-Anker gefunden:', anchor);
350 const panel = this.createDashboardPanel(stats);
351 anchor.parentNode.insertBefore(panel, anchor.nextSibling);
352 },
353
354 createDashboardPanel(stats) {
355 // stats = { regions: [regionStat,...], adsOnPage: N, totalCacheAds: N }
356 const panel = document.createElement('div');
357 panel.id = 'ka-main-dashboard';
358
359 const title = document.createElement('div');
360 title.className = 'ka-dash-title';
361 title.style.cssText = 'flex-basis: 100%; text-align: center; margin-bottom: 8px; font-size: 1.1em;';
362 title.innerHTML = `📊 Top-Regionen: <b>${stats.regions.map(r => r.label).join(' · ')}</b>`;
363 panel.appendChild(title);
364
365 const createRegionCard = (r) => {
366 const card = document.createElement('div');
367 card.className = 'ka-dash-card';
368 card.style.cssText = 'flex-basis: 100%; min-width: auto; padding: 0.8em 1em; margin: 0.2em 0;';
369 card.innerHTML = `
370 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em;">
371 <div class="ka-dash-title" style="margin: 0; font-size: 1em;">Region ${r.label}</div>
372 <div class="ka-dash-sub" style="margin: 0; font-size: 0.85em;">Seite: <b>${r.onPage}</b> · Cache: <b>${r.totalInCache}</b> · Median: <b>${r.avg} €/m²</b></div>
373 </div>
374 <div style="display: flex; gap: 0.6em; justify-content: space-between;">
375 <div style="flex: 1; text-align: center; padding: 0.4em; background: var(--ka-color-low-bg); border-radius: 5px; border-left: 3px solid var(--ka-color-low);">
376 <div style="font-weight: 700; font-size: 0.8em; color: #555;">Günstig</div>
377 <div style="font-size: 1.3em; font-weight: bold; margin: 0.1em 0;">${r.low.count}</div>
378 <div style="font-size: 0.75em; color: #666;">${r.low.min}–${r.low.max} €/m²</div>
379 </div>
380 <div style="flex: 1; text-align: center; padding: 0.4em; background: var(--ka-color-mid-bg); border-radius: 5px; border-left: 3px solid var(--ka-color-mid);">
381 <div style="font-weight: 700; font-size: 0.8em; color: #555;">Mittel</div>
382 <div style="font-size: 1.3em; font-weight: bold; margin: 0.1em 0;">${r.mid.count}</div>
383 <div style="font-size: 0.75em; color: #666;">${r.mid.min}–${r.mid.max} €/m²</div>
384 </div>
385 <div style="flex: 1; text-align: center; padding: 0.4em; background: var(--ka-color-high-bg); border-radius: 5px; border-left: 3px solid var(--ka-color-high);">
386 <div style="font-weight: 700; font-size: 0.8em; color: #555;">Teuer</div>
387 <div style="font-size: 1.3em; font-weight: bold; margin: 0.1em 0;">${r.high.count}</div>
388 <div style="font-size: 0.75em; color: #666;">${r.high.min}–${r.high.max} €/m²</div>
389 </div>
390 </div>
391 `;
392 return card;
393 };
394
395 stats.regions.forEach(r => panel.appendChild(createRegionCard(r)));
396
397 const metaDiv = document.createElement('div');
398 metaDiv.id = 'ka-dash-meta';
399 metaDiv.style.cssText = 'flex-basis: 100%; text-align: center; margin-top: 6px; font-size: 0.85em; color: #666;';
400 metaDiv.innerHTML = `Anzeigen auf Seite: <b>${stats.adsOnPage}</b> · Anzeigen im Cache: <b>${stats.totalCacheAds}</b>`;
401
402 const clearBtn = document.createElement('button');
403 clearBtn.id = 'ka-dash-clear-btn';
404 clearBtn.textContent = '🗑️ Cache löschen';
405 clearBtn.onclick = () => this.confirmAndClearAllRegionalSqmPrices();
406 metaDiv.appendChild(clearBtn);
407
408 panel.appendChild(metaDiv);
409
410 return panel;
411 },
412
413 blockScripts() {
414 const observer = new MutationObserver(mutations => {
415 mutations.forEach(m => m.addedNodes.forEach(node => {
416 if (node.nodeType === 1 && node.tagName === 'SCRIPT' && node.src &&
417 CONFIG.AD_SCRIPT_PATTERNS.some(rx => rx.test(node.src))) {
418 console.warn('[KA-SCRIPT] Blockiere Werbe-Script:', node.src);
419 node.remove();
420 }
421 }));
422 });
423 observer.observe(document.documentElement, { childList: true, subtree: true });
424 },
425
426 removeAdElements() {
427 document.querySelectorAll(CONFIG.AD_ELEMENT_SELECTORS.join(', ')).forEach(el => el.remove());
428 document.querySelectorAll(CONFIG.AD_ITEM_SELECTOR).forEach(ad => {
429 if (/top anzeige|gesponsert|sponsored/i.test(ad.textContent)) {
430 ad.remove();
431 }
432 });
433 },
434
435 applyFilterSectionStyles() {
436 console.log('[KA-SCRIPT] Wende Filter-Sektions-Styles an');
437
438 // Finde alle Sektionen
439 const sections = document.querySelectorAll('section');
440
441 sections.forEach(section => {
442 const headline = section.querySelector('.sectionheadline');
443 if (!headline) return;
444
445 const headlineText = headline.textContent.trim();
446
447 // Tauschangebot-Sektion
448 if (headlineText === 'Tauschangebot') {
449 console.log('[KA-SCRIPT] ✓ Tauschangebot-Sektion gefunden');
450
451 // Finde "Kein Tausch" Link und klicke ihn (falls noch nicht aktiv)
452 const keinTauschLink = Array.from(section.querySelectorAll('a')).find(a =>
453 a.textContent.includes('Kein Tausch')
454 );
455
456 if (keinTauschLink && !location.href.includes('swap_s:nein')) {
457 console.log('[KA-SCRIPT] Klicke "Kein Tausch" Link');
458 keinTauschLink.click();
459 return; // Seite wird neu geladen
460 }
461
462 // Style die Sektion
463 section.style.cssText = `
464 opacity: 0.5;
465 background: linear-gradient(to right, rgba(255, 99, 99, 0.1), transparent);
466 border-left: 3px solid #ff6363;
467 padding-left: 8px;
468 pointer-events: none;
469 `;
470
471 // Deaktiviere alle Links
472 section.querySelectorAll('a').forEach(link => {
473 link.style.pointerEvents = 'none';
474 link.style.cursor = 'not-allowed';
475 link.style.textDecoration = 'line-through';
476 });
477 }
478
479 // Angebotstyp-Sektion
480 if (headlineText === 'Angebotstyp') {
481 console.log('[KA-SCRIPT] ✓ Angebotstyp-Sektion gefunden');
482
483 // Finde "Angebote" Link und klicke ihn (falls noch nicht aktiv)
484 const angeboteLink = Array.from(section.querySelectorAll('a')).find(a =>
485 a.textContent.includes('Angebote') && !a.textContent.includes('Gesuche')
486 );
487
488 if (angeboteLink && !location.href.includes('anzeige:angebote')) {
489 console.log('[KA-SCRIPT] Klicke "Angebote" Link');
490 angeboteLink.click();
491 return; // Seite wird neu geladen
492 }
493
494 // Style die Sektion
495 section.style.cssText = `
496 opacity: 0.5;
497 background: linear-gradient(to right, rgba(255, 99, 99, 0.1), transparent);
498 border-left: 3px solid #ff6363;
499 padding-left: 8px;
500 pointer-events: none;
501 `;
502
503 // Deaktiviere alle Links
504 section.querySelectorAll('a').forEach(link => {
505 link.style.pointerEvents = 'none';
506 link.style.cursor = 'not-allowed';
507 link.style.textDecoration = 'line-through';
508 });
509 }
510 });
511 },
512
513 async processAdItems() {
514 let mainStorageHasChanged = false;
515 const adElements = document.querySelectorAll(CONFIG.AD_ITEM_SELECTOR);
516
517 console.log(`[KA-SCRIPT] ${adElements.length} Anzeigenelemente gefunden.`);
518
519 if (adElements.length === 0) return;
520
521 const itemsByRegionOnPage = {};
522
523 Array.from(adElements).forEach(article => {
524 // Alte Klassen entfernen
525 article.classList.remove('ka-price-low', 'ka-price-medium', 'ka-price-high', 'ka-price-uniform', 'ka-hidden');
526 article.querySelector('.ka-sqm-wrap')?.remove();
527
528 const articleText = article.textContent || '';
529 const textLower = articleText.toLowerCase();
530
531 // Filter: Gesuche
532 const isGesuch = /\bgesuch\b/.test(textLower) || /\b(suche|gesucht)\b/.test(textLower);
533
534 // Filter: Tausch (aber nicht "kein Tausch")
535 const mentionsTausch = /\btausch|tausche|tauschen\b/.test(textLower);
536 const negatesTausch = /\bkein\s+tausch\b/.test(textLower);
537 const isTausch = mentionsTausch && !negatesTausch;
538
539 if ((prefs.hideGesuche && isGesuch) || (prefs.hideTausch && isTausch)) {
540 article.classList.add('ka-hidden');
541 return;
542 }
543
544 // Parse Daten
545 const regionIdx = getRegionIndexFromText(articleText);
546 const price = parsePriceFromText(articleText);
547 const area = parseAreaFromText(articleText);
548 const adId = getAdIdFromArticle(article);
549
550 if (regionIdx === null || price == null || area == null || price <= 0 || area <= 0) {
551 console.log('[KA-SCRIPT] Überspringe Anzeige (fehlende Daten):', { regionIdx, price, area, adId });
552 return;
553 }
554
555 const pricePerSqm = price / area;
556 const roundedPrice = Math.round(pricePerSqm);
557
558 // Speichern
559 const changedNow = upsertAd(regionIdx, adId, roundedPrice);
560 if (changedNow) mainStorageHasChanged = true;
561
562 // €/m² anzeigen
563 const pricebox = article.querySelector('.aditem-main--middle--price-shipping');
564 if (pricebox) {
565 const wrap = document.createElement('div');
566 wrap.className = 'ka-sqm-wrap';
567 wrap.innerHTML = `<span class="ka-sqm-price-display">${pricePerSqm.toFixed(2).replace('.', ',')} €/m²</span>`;
568 pricebox.appendChild(wrap);
569 }
570
571 // Für Farbcodierung sammeln
572 if (!itemsByRegionOnPage[regionIdx]) itemsByRegionOnPage[regionIdx] = [];
573 itemsByRegionOnPage[regionIdx].push({ element: article, adId });
574 });
575
576 console.log(`[KA-SCRIPT] Verarbeitet. Storage geändert: ${mainStorageHasChanged}`);
577
578 if (mainStorageHasChanged) {
579 await saveStorage(regionalArray);
580 }
581
582 // Farbcodierung anwenden (hyper-lokal pro Region)
583 for (const key of Object.keys(itemsByRegionOnPage)) {
584 const idx = parseInt(key, 10);
585 const items = itemsByRegionOnPage[idx];
586 const container = regionalArray[idx] || {};
587 const values = Object.values(container).filter(v => typeof v === 'number');
588
589 if (values.length < 1) continue;
590
591 const min = Math.min(...values);
592 const max = Math.max(...values);
593 const range = max - min;
594
595 if (range === 0) {
596 items.forEach(d => d.element.classList.add('ka-price-uniform'));
597 continue;
598 }
599
600 const lower = min + range / 3;
601 const upper = min + 2 * range / 3;
602
603 items.forEach(({ element, adId }) => {
604 const v = container[adId];
605 if (typeof v !== 'number') return;
606 if (v <= lower) element.classList.add('ka-price-low');
607 else if (v <= upper) element.classList.add('ka-price-medium');
608 else element.classList.add('ka-price-high');
609 });
610 }
611
612 // Dashboard aktualisieren
613 this.updateDashboard(itemsByRegionOnPage);
614 },
615
616 computeRegionStats(regionIdx, itemsForRegion) {
617 const container = regionalArray[regionIdx] || {};
618 const values = Object.values(container).filter(v => typeof v === 'number');
619
620 // Keine gespeicherten Werte? -> neutrale Rückgabe
621 if (values.length === 0) {
622 return {
623 idx: regionIdx,
624 label: `${regionIdx.toString().padStart(3,'0')}xx`,
625 low: { count: 0, min: 0, max: 0 },
626 mid: { count: 0, min: 0, max: 0 },
627 high: { count: 0, min: 0, max: 0 },
628 onPage: itemsForRegion.length,
629 totalInCache: 0,
630 avg: '0.00'
631 };
632 }
633
634 const min = Math.min(...values);
635 const max = Math.max(...values);
636 const range = max - min;
637 const lower = min + range / 3;
638 const upper = min + 2 * range / 3;
639
640 let lowCount = 0, midCount = 0, highCount = 0;
641 let pageVals = [];
642
643 for (const { adId } of itemsForRegion) {
644 const v = container[adId];
645 if (typeof v !== 'number') continue;
646 pageVals.push(v);
647 if (range === 0) { midCount++; continue; }
648 if (v <= lower) lowCount++;
649 else if (v <= upper) midCount++;
650 else highCount++;
651 }
652
653 const avg = pageVals.length ? (pageVals.reduce((s,x)=>s+x,0) / pageVals.length).toFixed(2) : '0.00';
654
655 return {
656 idx: regionIdx,
657 label: `${regionIdx.toString().padStart(3,'0')}xx`,
658 low: { count: lowCount, min: Math.round(min), max: Math.floor(lower) },
659 mid: { count: midCount, min: Math.floor(lower)+1, max: Math.floor(upper) },
660 high: { count: highCount, min: Math.floor(upper)+1, max: Math.round(max) },
661 onPage: itemsForRegion.length,
662 totalInCache: values.length,
663 avg
664 };
665 },
666
667 getTopRegions(itemsByRegionOnPage, limit = 3) {
668 // Sortiere Regionen nach Anzahl der Anzeigen auf der Seite (desc)
669 const pairs = Object.entries(itemsByRegionOnPage) // [regionIdxStr, items[]]
670 .map(([k, v]) => ({ idx: parseInt(k,10), count: v.length }))
671 .sort((a,b) => b.count - a.count)
672 .slice(0, limit);
673 return pairs.map(p => p.idx);
674 },
675
676 updateDashboard(itemsByRegionOnPage) {
677 // Ermittele Top-3 Regionen (nach Häufigkeit auf der Seite)
678 const top = this.getTopRegions(itemsByRegionOnPage, 3);
679 if (!top.length) {
680 document.getElementById('ka-main-dashboard')?.remove();
681 return;
682 }
683
684 const regions = top.map(idx => this.computeRegionStats(idx, itemsByRegionOnPage[idx]));
685 const adsOnPage = Object.values(itemsByRegionOnPage).reduce((s, arr) => s + arr.length, 0);
686
687 // Berechne Gesamt-Cache-Anzahl
688 let totalCacheAds = 0;
689 for (let i = 0; i < 1000; i++) {
690 totalCacheAds += Object.keys(regionalArray[i]).length;
691 }
692
693 this.injectDashboard({ regions, adsOnPage, totalCacheAds });
694 },
695
696 async confirmAndClearAllRegionalSqmPrices() {
697 const info = calculateStorageInfoArray(regionalArray);
698 if (confirm(`Alle regionalen m²-Preis-Daten löschen?\n\nAktuell: ${info.totalAdEntries} Einträge, ${info.sizeMegabytes} MB`)) {
699 await GM.deleteValue(STORAGE_KEY);
700 regionalArray = await ensureStorageInit();
701 this.run();
702 }
703 },
704
705 setupImgZoom() {
706 const list = document.querySelector('#srchrslt-adtable, #srchrslt-gallery');
707 if (!list || list.dataset.kaZoomBound) return;
708
709 list.dataset.kaZoomBound = 'y';
710
711 let overlayImg = document.querySelector('.ka-overlay-img');
712 if (!overlayImg) {
713 overlayImg = document.createElement('img');
714 overlayImg.className = 'ka-overlay-img';
715 document.body.appendChild(overlayImg);
716 overlayImg.onclick = () => {
717 overlayImg.style.opacity = '0';
718 overlayImg.style.pointerEvents = 'none';
719 };
720 }
721
722 list.addEventListener('mouseover', e => {
723 const img = e.target.closest('.imagebox img, .aditem-image img');
724 if (img) {
725 img.style.cursor = 'zoom-in';
726 overlayImg.src = img.src;
727 overlayImg.style.opacity = '1';
728 overlayImg.style.pointerEvents = 'auto';
729 }
730 });
731
732 list.addEventListener('mouseout', e => {
733 if (e.target.closest('.imagebox img, .aditem-image img')) {
734 overlayImg.style.opacity = '0';
735 }
736 });
737 },
738
739 observeSPA() {
740 const debouncedRun = this.utils.debounce(() => this.run(), CONFIG.DEBOUNCE_DELAY);
741
742 const observer = new MutationObserver((mutations) => {
743 for (const mutation of mutations) {
744 if (mutation.type === 'childList' && mutation.addedNodes.length) {
745 const hasNewArticles = Array.from(mutation.addedNodes).some(n =>
746 n.nodeType === Node.ELEMENT_NODE && (n.matches?.('article') || n.querySelector?.('article'))
747 );
748 if (hasNewArticles) {
749 setTimeout(() => this.processAdItems(), 300);
750 break;
751 }
752 }
753 }
754 });
755
756 observer.observe(document.body, { childList: true, subtree: true });
757 },
758
759 registerMenuCommands() {
760 // Menü-Kommandos werden dynamisch aktualisiert
761 const updateMenus = () => {
762 // Gesuche Filter
763 const gesucheText = prefs.hideGesuche
764 ? 'Gesuche ausblenden: AN (klicken zum AUS)'
765 : 'Gesuche ausblenden: AUS (klicken zum AN)';
766
767 // Tausch Filter
768 const tauschText = prefs.hideTausch
769 ? 'Tauschangebote ausblenden: AN (klicken zum AUS)'
770 : 'Tauschangebote ausblenden: AUS (klicken zum AN)';
771
772 console.log('[KA-SCRIPT] Menü-Status:', { gesucheText, tauschText });
773 };
774
775 // Initial anzeigen
776 updateMenus();
777
778 // Hinweis: GM.registerMenuCommand ist nicht verfügbar, daher nur Logging
779 console.log('[KA-SCRIPT] Filter-Status:', prefs);
780 },
781
782 utils: {
783 debounce(func, delay) {
784 let timeout;
785 return (...args) => {
786 clearTimeout(timeout);
787 timeout = setTimeout(() => func.apply(this, args), delay);
788 };
789 }
790 }
791 };
792
793 KleinanzeigenOptimizer.init();
794})();