// ==UserScript==
// @name Kleinanzeigen Flächen-Preisrechner (Aktualisiert 2025)
// @namespace http://tampermonkey.net/
// @version 3.0
// @description Zeigt €/m² für neue Kleinanzeigen-Struktur, färbt nach regionalen Preisen (3-stellig PLZ), Navigation a/d
// @author Du
// @license MIT
// @icon https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=http://kleinanzeigen.de&size=64
// @match https://www.kleinanzeigen.de/s-wohnung-mieten/*
// @match https://www.kleinanzeigen.de/s-haus-mieten/*
// @match https://www.kleinanzeigen.de/s-wohnung-kaufen/*
// @match https://www.kleinanzeigen.de/s-haus-kaufen/*
// @match https://www.kleinanzeigen.de/s-wg-zimmer/*
// @match https://www.kleinanzeigen.de/s-immobilien/*
// @match https://www.kleinanzeigen.de/s-grundstuecke-gaerten/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const REGIONAL_STORAGE_KEY = 'kleinanzeigenRegionalSqmPrices_3digit';
const PLZ_PREFIX_LENGTH = 3;
let globalSqmPricesByRegion = loadRegionalSqmPrices();
GM_addStyle(`
.ka-sqm-price-display {
font-size: 1.3em;
color: #2a67ca;
font-weight: bold;
margin-left: 8px;
display: inline-block;
background: rgba(42, 103, 202, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.ka-sqm-container {
margin: 4px 0;
}
/* Farbkodierung basierend auf regionalen Preisen */
.ka-price-low {
background-color: rgba(144, 238, 144, 0.3) !important;
border-left: 4px solid #90ee90;
}
.ka-price-medium {
background-color: rgba(255, 210, 100, 0.3) !important;
border-left: 4px solid #ffd264;
}
.ka-price-high {
background-color: rgba(255, 127, 127, 0.3) !important;
border-left: 4px solid #ff7f7f;
}
.ka-price-uniform {
background-color: rgba(200, 200, 200, 0.3) !important;
border-left: 4px solid #c8c8c8;
}
/* Artikelstil verbessern */
article {
padding: 8px !important;
margin: 4px 0 !important;
border-radius: 6px;
transition: all 0.2s ease;
}
`);
function loadRegionalSqmPrices() {
return GM_getValue(REGIONAL_STORAGE_KEY, {});
}
function saveRegionalSqmPrices() {
GM_setValue(REGIONAL_STORAGE_KEY, globalSqmPricesByRegion);
}
function calculateStorageInfo(data) {
const jsonString = JSON.stringify(data);
const sizeBytes = new TextEncoder().encode(jsonString).length;
const sizeMegabytes = (sizeBytes / (1024 * 1024)).toFixed(4);
let totalAdEntries = 0;
for (const region in data) {
if (data.hasOwnProperty(region)) {
totalAdEntries += Object.keys(data[region]).length;
}
}
return { sizeMegabytes, totalAdEntries };
}
function confirmAndClearAllRegionalSqmPrices() {
const currentData = GM_getValue(REGIONAL_STORAGE_KEY, {});
const { sizeMegabytes, totalAdEntries } = calculateStorageInfo(currentData);
const confirmationMessage = `Möchten Sie wirklich alle gespeicherten regionalen m² Preisdaten löschen?\n\n` +
`Anzahl erfasste Anzeigen: ${totalAdEntries}\n` +
`Speichergröße: ${sizeMegabytes} MB\n\n` +
`Diese Aktion kann nicht rückgängig gemacht werden.`;
if (confirm(confirmationMessage)) {
GM_deleteValue(REGIONAL_STORAGE_KEY);
globalSqmPricesByRegion = {};
alert('Alle regionalen m² Preise gelöscht. Seite wird neu eingefärbt.');
processAdItems();
} else {
alert('Löschvorgang abgebrochen.');
}
}
GM_registerMenuCommand("Alle regionalen m² Preise löschen...", confirmAndClearAllRegionalSqmPrices, "L");
function parsePrice(priceString) {
if (!priceString) return null;
const cleanedPrice = priceString.replace(/\s*VB\s*/i, '')
.replace(/\s*€\s*/g, '')
.replace(/\./g, '')
.replace(/,/g, '.')
.trim();
if (cleanedPrice.toLowerCase() === 'zu verschenken') return 0;
if (cleanedPrice.toLowerCase().includes('anfrage')) return null;
const price = parseFloat(cleanedPrice);
return isNaN(price) ? null : price;
}
function parseArea(areaString) {
if (!areaString) return null;
const match = areaString.match(/([\d\.,]+)\s*m²/i);
if (match && match[1]) {
const cleanedArea = match[1].replace(/\./g, '').replace(/,/g, '.');
const area = parseFloat(cleanedArea);
return isNaN(area) || area === 0 ? null : area;
}
return null;
}
function removeColorClasses(element) {
element.classList.remove('ka-price-low', 'ka-price-medium', 'ka-price-high', 'ka-price-uniform');
}
function getPlzPrefixFromText(text) {
if (!text) return null;
const plzMatch = text.match(/\b(\d{5})\b/);
if (plzMatch && plzMatch[1]) {
return plzMatch[1].substring(0, PLZ_PREFIX_LENGTH);
}
return null;
}
function getAdIdFromArticle(article) {
// Versuche Ad-ID aus href oder anderen Attributen zu extrahieren
const links = article.querySelectorAll('a[href*="/s-anzeige/"]');
if (links.length > 0) {
const href = links[0].href;
const match = href.match(/\/(\d+)-\d+-\d+$/);
if (match) return match[1];
}
// Fallback: Generiere eine ID basierend auf Inhalt
return 'ad_' + btoa(article.textContent.substring(0, 50)).replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
}
function processAdItems() {
globalSqmPricesByRegion = loadRegionalSqmPrices();
const articles = document.querySelectorAll('article');
let mainStorageHasChanged = false;
const itemsByPlzOnPage = {};
console.log(`[Kleinanzeigen Script] Verarbeite ${articles.length} Artikel`);
articles.forEach(article => {
removeColorClasses(article);
// Entferne bereits vorhandene Preisanzeigen
const existingSqmDisplay = article.querySelector('.ka-sqm-container');
if (existingSqmDisplay) existingSqmDisplay.remove();
const adId = getAdIdFromArticle(article);
const articleText = article.textContent;
// PLZ aus Text extrahieren
const plzPrefix = getPlzPrefixFromText(articleText);
if (!plzPrefix) return;
// Fläche aus Text extrahieren (Format: "XX m² · X Zi.")
const areaMatch = articleText.match(/(\d+(?:,\d+)?)\s*m²\s*·/);
const area = areaMatch ? parseArea(areaMatch[0]) : null;
// Preis extrahieren (kommt normalerweise nach der Fläche)
const priceMatches = articleText.match(/(\d{1,3}(?:\.\d{3})*(?:,\d{2})?)\s*€/g);
let price = null;
if (priceMatches && priceMatches.length > 0) {
// Nehme den ersten gefundenen Preis (Hauptpreis)
price = parsePrice(priceMatches[0]);
}
if (price !== null && area !== null && area > 0 && price > 0) {
const pricePerSqm = price / area;
const roundedPricePerSqm = Math.round(pricePerSqm);
// Regional storage initialisieren
if (!globalSqmPricesByRegion[plzPrefix]) {
globalSqmPricesByRegion[plzPrefix] = {};
}
if (!itemsByPlzOnPage[plzPrefix]) {
itemsByPlzOnPage[plzPrefix] = [];
}
// Preis speichern
if (globalSqmPricesByRegion[plzPrefix][adId] !== roundedPricePerSqm) {
globalSqmPricesByRegion[plzPrefix][adId] = roundedPricePerSqm;
mainStorageHasChanged = true;
}
itemsByPlzOnPage[plzPrefix].push({ element: article, adId: adId, precisePricePerSqm: pricePerSqm });
// Preis/m² Display hinzufügen
const sqmContainer = document.createElement('div');
sqmContainer.classList.add('ka-sqm-container');
const sqmPriceElement = document.createElement('span');
sqmPriceElement.classList.add('ka-sqm-price-display');
sqmPriceElement.textContent = `${pricePerSqm.toFixed(2).replace('.', ',')} €/m²`;
sqmContainer.appendChild(sqmPriceElement);
// Einfügen nach dem letzten Link im Artikel
const lastLink = article.querySelector('a[href*="/s-anzeige/"]:last-of-type');
if (lastLink) {
lastLink.parentNode.insertBefore(sqmContainer, lastLink.nextSibling);
} else {
article.appendChild(sqmContainer);
}
console.log(`[Kleinanzeigen Script] PLZ: ${plzPrefix}, Preis: ${price}€, Fläche: ${area}m², €/m²: ${pricePerSqm.toFixed(2)}`);
}
});
if (mainStorageHasChanged) {
saveRegionalSqmPrices();
}
// Farbkodierung anwenden
for (const plzKey in itemsByPlzOnPage) {
const itemsInThisRegionOnPage = itemsByPlzOnPage[plzKey];
const allStoredPricesInThisRegion = Object.values(globalSqmPricesByRegion[plzKey] || {}).filter(p => typeof p === 'number');
if (allStoredPricesInThisRegion.length < 1) continue;
const minRegionalPrice = Math.min(...allStoredPricesInThisRegion);
const maxRegionalPrice = Math.max(...allStoredPricesInThisRegion);
const regionalRange = maxRegionalPrice - minRegionalPrice;
if (regionalRange === 0) {
itemsInThisRegionOnPage.forEach(data => data.element.classList.add('ka-price-uniform'));
continue;
}
const lowerBound = minRegionalPrice + regionalRange / 3;
const upperBound = minRegionalPrice + (regionalRange * 2) / 3;
itemsInThisRegionOnPage.forEach(data => {
const itemElement = data.element;
const roundedPriceForThisAd = globalSqmPricesByRegion[plzKey][data.adId];
if (typeof roundedPriceForThisAd !== 'number') return;
if (roundedPriceForThisAd <= lowerBound) {
itemElement.classList.add('ka-price-low');
} else if (roundedPriceForThisAd <= upperBound) {
itemElement.classList.add('ka-price-medium');
} else {
itemElement.classList.add('ka-price-high');
}
});
console.log(`[Kleinanzeigen Script] PLZ ${plzKey}: ${allStoredPricesInThisRegion.length} gespeicherte Preise, Range: ${minRegionalPrice}-${maxRegionalPrice} €/m²`);
}
}
// Hauptverarbeitung starten
processAdItems();
// MutationObserver für dynamische Inhalte
const observer = new MutationObserver(function(mutationsList) {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
let hasNewArticles = false;
for (const addedNode of mutationsList) {
if (addedNode.nodeType === Node.ELEMENT_NODE &&
(addedNode.matches && (addedNode.matches('article') || addedNode.querySelector('article')))) {
hasNewArticles = true;
break;
}
}
if (hasNewArticles) {
setTimeout(processAdItems, 350);
return;
}
}
}
});
const observerTarget = document.body;
observer.observe(observerTarget, { childList: true, subtree: true });
// Tastaturnavigation (a/d)
document.addEventListener('keydown', function(event) {
const targetTagName = event.target.tagName.toLowerCase();
if (targetTagName === 'input' || targetTagName === 'textarea' || event.target.isContentEditable) {
return;
}
const key = event.key.toLowerCase();
if (key === 'a') {
// Vorherige Seite
const prevLinks = document.querySelectorAll('a[href*="seite:"]:not([href*="seite:2"]), a[href*="/c203"]:not([href*="seite:"])');
if (prevLinks.length > 0) {
prevLinks[prevLinks.length - 1].click(); // Letzter Link ist meist "Vorherige"
}
} else if (key === 'd') {
// Nächste Seite
const nextLinks = document.querySelectorAll('a[href*="seite:"]');
if (nextLinks.length > 0) {
// Finde den Link mit der nächsthöheren Seitenzahl
const currentPage = window.location.href.match(/seite:(\d+)/) ? parseInt(window.location.href.match(/seite:(\d+)/)[1]) : 1;
const targetPage = currentPage + 1;
const nextLink = Array.from(nextLinks).find(link => link.href.includes(`seite:${targetPage}`));
if (nextLink) {
nextLink.click();
} else if (nextLinks.length > 0) {
nextLinks[0].click(); // Fallback: erster Link
}
}
}
});
})();