Kleinanzeigen Mietdashboard mit Preisanalyse

A new userscript

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})();
Kleinanzeigen Mietdashboard mit Preisanalyse | Robomonkey