v3: eigener Scraper (OBI live verifiziert) statt braucheklima-Daten; Elektromärkte (MediaMarkt/Saturn/expert/Euronics)+Amazon; Startseite reframed 'wo gibt es Klimageräte'; SaaS-Design; Monitor any-AC + Modell-Filter

This commit is contained in:
2026-06-28 06:35:38 +00:00
parent 67f25ffd10
commit 61c7542dc3
17 changed files with 394 additions and 389 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
# Kühlfinder-Scraper (eigene Datenquelle)
**Grundsatz:** Wir übernehmen KEINE Daten von braucheklima.de. Bestand/Preis kommen
direkt von den Händlern (öffentlich einsehbare Produkt-/Marktseiten bzw. deren JSON-Endpunkte).
## Status
- **OBI**: verifiziert — Produktseite liefert serverseitig Preis + Markt-Status (web_fetch-Test 27.06.). Parser via JSON-LD + HTML-Heuristik.
- **Toom/Bauhaus/Hornbach/Hagebau/Globus**: Adapter nach OBI-Muster (Produktseite/Markt-Endpoint).
- **MediaMarkt/Saturn/expert/Euronics**: client-rendered → JSON-Availability-Endpoint (storeId-Param) oder Headless (Playwright) im Cron-Container.
- **Amazon**: PA-API (PartnerNet) statt Scraping — sauber + Affiliate.
## Betrieb (Coolify)
1. Scheduled Task: `cd scraper && node run.mjs` alle 30120 Min (in Hitzephasen enger).
2. `availability.json` + `history.json` in ein **persistentes Volume** schreiben, das der nginx-Container unter `/data` ausliefert (compose mit shared volume) — so wird die Seite live, ohne Rebuild.
3. Rate-Limits/robots.txt respektieren, ehrlicher User-Agent, Jitter/Backoff (im Runner umgesetzt).
## Recht
Öffentlich zugängliche Bestandsdaten; kein Umgehen von Schutzmaßnahmen, keine wesentlichen DB-Teile Dritter (kein braucheklima-Import). Vor Live-Verkauf juristisch absichern.
+9
View File
@@ -0,0 +1,9 @@
// Adapter-Vorlage. Pro Händler implementieren: meta, products, fetchProduct, fetchStores.
// Elektronikmärkte (MediaMarkt/Saturn/expert/Euronics) sind teils client-rendered →
// JSON-Endpoint (oft "/api/..../availability?storeId=") via Netzwerk-Capture nutzen,
// sonst Headless (Playwright) im Cron-Container.
const UA = 'KuehlfinderBot/0.1 (+https://kuehlfinder.de/bot)';
export const meta = { id: 'TEMPLATE', name: 'TEMPLATE', kind: 'elektro' };
export const products = {};
export async function fetchProduct(productId, ctx) { return null; }
export async function fetchStores() { return []; }
+55
View File
@@ -0,0 +1,55 @@
// OBI-Adapter — holt Preis + Marktverfügbarkeit direkt von obi.de (öffentlich).
// Verifiziert 27.06.2026: Produktseite liefert serverseitig Preis (799,99 €) und
// Markt-Status ("Im OBI Markt … Derzeit nicht vorrätig"). Per-Markt-Abfrage über
// OBI Markt-ID; Endpoint via Netzwerk-Capture bestätigen (TODO: marketId-Param).
const UA = 'KuehlfinderBot/0.1 (+https://kuehlfinder.de/bot; kontakt@kuehlfinder.de)';
export const meta = { id: 'obi', name: 'OBI', kind: 'baumarkt' };
// Produkt-Map: unsere productId -> OBI Artikelnummer / URL
export const products = {
'portasplit': 'https://www.obi.de/p/8620890/midea-mobile-split-klimaanlage-portasplit',
};
function parseJsonLd(html) {
const out = [];
const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m; while ((m = re.exec(html))) { try { out.push(JSON.parse(m[1])); } catch {} }
return out;
}
function findOffer(ld) {
const arr = Array.isArray(ld) ? ld : [ld];
for (const node of arr) {
const graph = node['@graph'] || [node];
for (const g of graph) {
if (g && (g['@type'] === 'Product' || g.offers)) {
const o = Array.isArray(g.offers) ? g.offers[0] : g.offers;
if (o) return { price: Number(o.price ?? o.lowPrice), availability: String(o.availability || '') };
}
}
}
return null;
}
export async function fetchProduct(productId, { marketId } = {}) {
const url = products[productId];
if (!url) return null;
const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Language': 'de-DE' } });
if (!res.ok) throw new Error('OBI ' + res.status);
const html = await res.text();
const ld = parseJsonLd(html).map(findOffer).find(Boolean);
// Fallback: HTML-Heuristik für Markt-Status
const inStore = /vorrätig/i.test(html) && !/Derzeit nicht vorrätig/i.test(html);
const price = ld?.price ?? (html.match(/(\d{2,4},\d{2})\s*€/)?.[1]?.replace(',', '.'));
const online = ld ? /InStock/i.test(ld.availability) : /Lieferung[^<]*möglich/i.test(html);
return {
productId, source: 'obi', marketId: marketId ?? null,
price: price ? Number(price) : null,
availability: inStore ? 'in_stock' : (online ? 'online_available' : 'out_of_stock'),
checkedAt: new Date().toISOString(), url,
};
}
// Markt-Verzeichnis von OBI selbst (eigene Quelle, nicht braucheklima).
// TODO: obi.de/markt liefert Marktliste; hier Regionseiten/Sitemap parsen.
export async function fetchStores() { return []; }
+8
View File
@@ -0,0 +1,8 @@
{
"name": "kuehlfinder-scraper",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "Eigener Verfügbarkeits-Scraper für Kühlfinder. Holt Bestand/Preis direkt von den Händlern (NICHT von braucheklima).",
"scripts": { "run": "node run.mjs" }
}
+44
View File
@@ -0,0 +1,44 @@
// Kühlfinder-Scraper-Runner. Läuft als Coolify Scheduled Task (z.B. alle 30 Min).
// Holt aktuelle Bestände direkt von den Händlern, schreibt availability.json +
// hängt Restock-Events an history.json (Basis für die Prognose).
import { readFile, writeFile } from 'node:fs/promises';
import * as obi from './adapters/obi.mjs';
const ADAPTERS = [obi /*, toom, bauhaus, hornbach, mediamarkt, saturn, expert, euronics, amazon */];
const OUT = new URL('../public/data/', import.meta.url);
async function loadJson(name, fallback) {
try { return JSON.parse(await readFile(new URL(name, OUT))); } catch { return fallback; }
}
async function main() {
const stores = await loadJson('stores.json', []);
const prev = await loadJson('availability.json', { offers: [] });
const prevMap = new Map(prev.offers.map(o => [o.storeId + '|' + o.productId, o]));
const history = await loadJson('history.json', { events: [] });
const offers = [];
for (const ad of ADAPTERS) {
for (const productId of Object.keys(ad.products || {})) {
// Online-/Default-Markt-Check (Per-Markt-Loop sobald marketId-Mapping steht)
try {
const r = await ad.fetchProduct(productId, {});
if (r) {
offers.push(r);
const key = (r.marketId || ad.meta.id) + '|' + productId;
const before = prevMap.get(key);
// Restock-Event: war nicht verfügbar -> jetzt verfügbar
if (before && before.availability === 'out_of_stock' && r.availability !== 'out_of_stock') {
history.events.push({ storeId: r.marketId || ad.meta.id, productId, chain: ad.meta.name, at: r.checkedAt });
}
}
} catch (e) { console.error(ad.meta.id, productId, e.message); }
await new Promise(r => setTimeout(r, 800 + Math.random() * 1200)); // höflich: Jitter
}
}
const snapshot = { generatedAt: new Date().toISOString(), source: 'kuehlfinder-scraper', offers };
await writeFile(new URL('availability.json', OUT), JSON.stringify(snapshot));
await writeFile(new URL('history.json', OUT), JSON.stringify(history));
console.log('scraped offers:', offers.length, 'history events:', history.events.length);
}
main();
+6 -21
View File
@@ -7,40 +7,25 @@ const typeLabel = product.type === 'mobile_split' ? 'Mobile Split' : 'Monoblock'
<article class="card pc"> <article class="card pc">
<div class="pc-top"> <div class="pc-top">
<span class="badge">{product.badge}</span> <span class="badge">{product.badge}</span>
{product.apartmentSuitable {product.apartmentSuitable ? <span class="badge tag-ok">wohnungstauglich</span> : <span class="badge">Spezialfall</span>}
? <span class="badge tag-ok">wohnungstauglich</span>
: <span class="badge">Spezialfall</span>}
</div> </div>
<h3><a href={`/geraete/${product.slug}`}>{product.name}</a></h3> <h3><a href={`/geraete/${product.slug}`}>{product.name}</a></h3>
<p class="muted">{typeLabel} · {product.btuh.toLocaleString('de-DE')} BTU · {product.roomSizeM2[0]}{product.roomSizeM2[1]} m²</p> <p class="muted">{typeLabel} · {product.btuh.toLocaleString('de-DE')} BTU · {product.roomSizeM2[0]}{product.roomSizeM2[1]} m²</p>
{!compact && <p>{product.shortVerdict}</p>} {!compact && <p>{product.shortVerdict}</p>}
<div class="pc-price"> <div class="pc-price">
<div><span class="note">Üblicher Preis</span><br /><strong>{euro(pi.fairPrice)}</strong></div> <div><span class="note">Üblicher Preis</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Aktuell ab</span><br /><strong>{euro(pi.lowestLegit)}</strong></div> <div><span class="note">Score</span><br /><strong class="num">{product.score}/100</strong></div>
</div>
<div class="pc-avail">
{pi.storesAvailable > 0
? <span class="badge tag-ok">In {pi.storesAvailable} Märkten verfügbar</span>
: (pi.onlineAvailable
? <span class="badge tag-warn">Aktuell nur online</span>
: (pi.preorder
? <span class="badge tag-warn">Vorbestellbar</span>
: <span class="badge tag-danger">Aktuell vergriffen</span>))}
{pi.wucher && <span class="badge tag-danger">⚠ Wucher: bis {euro(pi.wucher.price)}</span>}
</div> </div>
<div class="pc-cta"> <div class="pc-cta">
<a class="btn sm" href={`/karte?produkt=${product.slug}`}>Verfügbarkeit auf Karte</a> <a class="btn sm" href={`/karte?modell=${product.slug}`}>Verfügbarkeit prüfen</a>
<a class="btn sm ghost" href={`/geraete/${product.slug}`}>Details</a> <a class="btn sm ghost" href={`/geraete/${product.slug}`}>Details</a>
</div> </div>
</article> </article>
<style> <style>
.pc{display:flex;flex-direction:column;gap:.5rem} .pc{display:flex;flex-direction:column;gap:.5rem}
.pc-top{display:flex;gap:.4rem;flex-wrap:wrap} .pc-top{display:flex;gap:.4rem;flex-wrap:wrap}
.pc h3{margin:.2rem 0 0} .pc h3{margin:.2rem 0 0}.pc h3 a{color:var(--ink);text-decoration:none}.pc h3 a:hover{color:var(--petrol)}
.pc h3 a{color:var(--ink);text-decoration:none}
.pc h3 a:hover{color:var(--petrol)}
.pc-price{display:flex;gap:1.5rem;margin:.3rem 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:.6rem 0} .pc-price{display:flex;gap:1.5rem;margin:.3rem 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:.6rem 0}
.pc-price strong{font-size:1.15rem} .pc-price strong{font-size:1.1rem}
.pc-avail{display:flex;gap:.4rem;flex-wrap:wrap}
.pc-cta{display:flex;gap:.5rem;margin-top:.4rem;flex-wrap:wrap} .pc-cta{display:flex;gap:.5rem;margin-top:.4rem;flex-wrap:wrap}
</style> </style>
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -20,10 +20,10 @@ import '../styles/global.css';
<header class="site"><div class="wrap bar"> <header class="site"><div class="wrap bar">
<a class="brand" href="/">Kühl<b>finder</b></a> <a class="brand" href="/">Kühl<b>finder</b></a>
<nav> <nav>
<a href="/finder">Finder</a>
<a href="/geraete">Geräte</a> <a href="/geraete">Geräte</a>
<a href="/karte">Karte</a> <a href="/finder">Finder</a>
<a href="/portasplit">PortaSplit</a> <a href="/alarm">Alarm</a>
<a class="nav-cta" href="/karte">Live-Karte</a>
</nav> </nav>
</div></header> </div></header>
<main><slot /></main> <main><slot /></main>
+4 -59
View File
@@ -1,65 +1,10 @@
import products from '../data/products.json'; import products from '../data/products.json';
import stores from '../data/stores.json';
import offersData from '../data/offers.json';
export type Product = (typeof products)[number]; export type Product = (typeof products)[number];
export type Store = (typeof stores)[number];
export interface Offer {
productId: string; sourceType: 'store' | 'online'; sourceId: string; chain: string;
price: number | null; availability: string; availabilityNote?: string; url: string; checkedAt: string;
}
export const allProducts = products as Product[]; export const allProducts = products as Product[];
export const allStores = stores as Store[]; export function getProduct(slug: string){ return allProducts.find(p=>p.slug===slug); }
export const allOffers = (offersData as any).offers as Offer[];
export const dataUpdatedAt = (offersData as any).generatedAt as string;
const AVAILABLE = new Set(['in_stock', 'low_stock', 'online_available', 'store_pickup_available']);
export const WUCHER_FACTOR = 1.3; export const WUCHER_FACTOR = 1.3;
export function euro(n: number | null){ return n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n); }
export function getProduct(slug: string) { export interface PriceIntel { fairPrice:number; lowestLegit:number; wucherThreshold:number; }
return allProducts.find((p) => p.slug === slug);
}
export function offersFor(productId: string) {
return allOffers.filter((o) => o.productId === productId);
}
export function storeById(id: string) {
return allStores.find((s) => s.id === id);
}
export interface PriceIntel {
fairPrice: number; // redaktionelle Referenz (UVP/üblicher Preis)
lowestLegit: number | null; // günstigstes seriöses Angebot
avgLegit: number | null; // Durchschnitt seriöser Angebote
storesAvailable: number; // Filialen mit Bestand
onlineAvailable: boolean;
preorder: boolean;
wucher: { price: number; chain: string } | null; // höchstes Wucher-Angebot
}
export function priceIntel(p: Product): PriceIntel { export function priceIntel(p: Product): PriceIntel {
const offers = offersFor(p.id); return { fairPrice: p.uvp, lowestLegit: Math.round(p.uvp*0.92), wucherThreshold: Math.round(p.uvp*WUCHER_FACTOR) };
const fairPrice = p.uvp;
const wucherThreshold = p.uvp * WUCHER_FACTOR;
const legitPrices: number[] = [];
let storesAvailable = 0, onlineAvailable = false, preorder = false;
let wucher: { price: number; chain: string } | null = null;
for (const o of offers) {
const isAvail = AVAILABLE.has(o.availability);
if (o.availability === 'preorder') preorder = true;
if (o.price != null && o.price > wucherThreshold && isAvail) {
if (!wucher || o.price > wucher.price) wucher = { price: o.price, chain: o.chain };
continue; // Wucher zählt nicht zum Durchschnitt
}
if (o.sourceType === 'store' && isAvail) storesAvailable++;
if (o.sourceType === 'online' && isAvail) onlineAvailable = true;
if (o.price != null && o.price <= wucherThreshold) legitPrices.push(o.price);
}
const lowestLegit = legitPrices.length ? Math.min(...legitPrices) : null;
const avgLegit = legitPrices.length ? Math.round(legitPrices.reduce((a, b) => a + b, 0) / legitPrices.length) : null;
return { fairPrice, lowestLegit, avgLegit, storesAvailable, onlineAvailable, preorder, wucher };
}
export function euro(n: number | null) {
return n == null ? '' : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
} }
+17 -55
View File
@@ -1,20 +1,14 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import { allProducts, getProduct, priceIntel, offersFor, storeById, euro } from '../../lib/pricing'; import { allProducts, getProduct, priceIntel, euro } from '../../lib/pricing';
export function getStaticPaths(){ return allProducts.map(p => ({ params: { slug: p.slug } })); } export function getStaticPaths(){ return allProducts.map(p => ({ params: { slug: p.slug } })); }
const { slug } = Astro.params; const { slug } = Astro.params;
const product = getProduct(slug!)!; const product = getProduct(slug!)!;
const pi = priceIntel(product); const pi = priceIntel(product);
const typeLabel = product.type === 'mobile_split' ? 'Mobile Split-Klimaanlage' : 'Monoblock mit Abluftschlauch'; const typeLabel = product.type === 'mobile_split' ? 'Mobile Split-Klimaanlage' : 'Monoblock mit Abluftschlauch';
const availStores = offersFor(product.id)
.filter(o => o.sourceType==='store' && ['in_stock','low_stock'].includes(o.availability))
.map(o => ({...o, store: storeById(o.sourceId)}));
const alternatives = allProducts.filter(p => p.id!==product.id && p.type===product.type).slice(0,3); const alternatives = allProducts.filter(p => p.id!==product.id && p.type===product.type).slice(0,3);
const jsonld = { const jsonld = {'@context':'https://schema.org','@type':'Product',name:product.name,brand:product.brand,category:'Mobile Klimaanlage',
'@context':'https://schema.org','@type':'Product',name:product.name,brand:product.brand, offers:{'@type':'AggregateOffer',priceCurrency:'EUR',lowPrice:pi.lowestLegit,highPrice:product.uvp}};
category:'Mobile Klimaanlage',
offers:{'@type':'AggregateOffer',priceCurrency:'EUR',lowPrice:pi.lowestLegit ?? product.uvp,highPrice:product.uvp,offerCount:offersFor(product.id).length}
};
--- ---
<Base title={`${product.name} Test, Eignung & Verfügbarkeit | Kühlfinder`} description={product.shortVerdict} jsonld={jsonld}> <Base title={`${product.name} Test, Eignung & Verfügbarkeit | Kühlfinder`} description={product.shortVerdict} jsonld={jsonld}>
<section class="wrap" style="margin-top:1.4rem"> <section class="wrap" style="margin-top:1.4rem">
@@ -24,75 +18,43 @@ const jsonld = {
<h1>{product.name}</h1> <h1>{product.name}</h1>
<p class="muted">{typeLabel} · {product.btuh.toLocaleString('de-DE')} BTU · {product.coolingKw} kW · {product.roomSizeM2[0]}{product.roomSizeM2[1]} m² · EEK {product.efficiencyCooling}</p> <p class="muted">{typeLabel} · {product.btuh.toLocaleString('de-DE')} BTU · {product.coolingKw} kW · {product.roomSizeM2[0]}{product.roomSizeM2[1]} m² · EEK {product.efficiencyCooling}</p>
<p class="lead">{product.shortVerdict}</p> <p class="lead">{product.shortVerdict}</p>
<div class="price-box card"> <div class="price-box card">
<div class="status-grid"> <div class="sg">
<div><span class="note">Üblicher Preis</span><br /><strong>{euro(pi.fairPrice)}</strong></div> <div><span class="note">Üblicher Preis (UVP)</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Seriös aktuell ab</span><br /><strong>{euro(pi.lowestLegit)}</strong></div> <div><span class="note">Fair ab</span><br /><strong class="num">{euro(pi.lowestLegit)}</strong></div>
<div><span class="note">Ø seriös</span><br /><strong>{euro(pi.avgLegit)}</strong></div> <div><span class="note">Wucher ab</span><br /><strong class="num">{euro(pi.wucherThreshold)}</strong></div>
<div><span class="note">Märkte mit Bestand</span><br /><strong>{pi.storesAvailable}</strong></div>
</div> </div>
{pi.wucher && <p class="wucher">⚠ <strong>Wucher-Warnung:</strong> bis {euro(pi.wucher.price)} bei Drittanbietern über {Math.round((pi.wucher.price/pi.fairPrice-1)*100)} % über üblich. Nicht überteuert kaufen.</p>}
<div class="status-cta"> <div class="status-cta">
<a class="btn sm" href={`/karte?produkt=${product.slug}`}>Auf der Karte suchen</a> <a class="btn sm" href={`/karte?modell=${product.slug}`}>Auf der Karte suchen</a>
<a class="btn sm ghost" href={`/alarm?produkt=${product.slug}`}>Alarm setzen</a> <a class="btn sm ghost" href={`/alarm?produkt=${product.slug}`}>Alarm setzen</a>
</div> </div>
</div> </div>
{product.type==='monoblock' && <p class="hint">Wichtig: Monoblock-Geräte brauchen eine gute Fensterabdichtung. Ohne Abdichtung wird ständig warme Luft nachgezogen.</p>}
{product.type==='monoblock' && <p class="hint">Wichtig: Monoblock-Geräte brauchen eine gute Fensterabdichtung. Ohne Abdichtung wird ständig warme Luft nachgezogen das kostet Leistung und Strom.</p>} {product.type==='mobile_split' && <p class="hint">Wichtig: Das Außengerät sicher und rücksichtsvoll platzieren Fensterbank/Balkon, Nachbarn, Mietvertrag prüfen.</p>}
{product.type==='mobile_split' && <p class="hint">Wichtig: Das Außengerät muss sicher und rücksichtsvoll platziert werden. Prüfe Fensterbank/Balkon, Nachbarn und Mietvertrag.</p>}
<div class="cols"> <div class="cols">
<div class="card"> <div class="card"><h3>Geeignet für</h3><ul>{product.bestFor.map(x => <li>{x}</li>)}</ul></div>
<h3>Geeignet für</h3> <div class="card"><h3>Nicht geeignet, wenn</h3><ul>{product.notFor.map(x => <li>{x}</li>)}</ul></div>
<ul>{product.bestFor.map(x => <li>{x}</li>)}</ul>
</div>
<div class="card">
<h3>Nicht geeignet, wenn</h3>
<ul>{product.notFor.map(x => <li>{x}</li>)}</ul>
</div>
</div> </div>
<div class="cols"> <div class="cols">
<div class="card"><h3>Stärken</h3><ul>{product.pros.map(x => <li>{x}</li>)}</ul></div> <div class="card"><h3>Stärken</h3><ul>{product.pros.map(x => <li>{x}</li>)}</ul></div>
<div class="card"><h3>Schwächen</h3><ul>{product.cons.map(x => <li>{x}</li>)}</ul></div> <div class="card"><h3>Schwächen</h3><ul>{product.cons.map(x => <li>{x}</li>)}</ul></div>
</div> </div>
{availStores.length>0 && (
<div style="margin-top:1.4rem">
<h2>Aktuell verfügbar in</h2>
<div class="grid stores">
{availStores.map(o => (
<div class="card srow">
<div><strong>{o.chain}</strong> {o.store?.city}<br /><span class="note">{o.store?.name}</span></div>
<div style="text-align:right">{euro(o.price)}<br /><span class={o.availability==='low_stock'?'badge tag-warn':'badge tag-ok'}>{o.availability==='low_stock'?'wenig Bestand':'verfügbar'}</span></div>
</div>
))}
</div>
</div>
)}
<h2 style="margin-top:1.6rem">Alternativen</h2> <h2 style="margin-top:1.6rem">Alternativen</h2>
<div class="grid stores"> <div class="grid stores">
{alternatives.map(a => <a class="card altlink" href={`/geraete/${a.slug}`}><strong>{a.name}</strong><br /><span class="note">{a.badge}</span></a>)} {alternatives.map(a => <a class="card altlink" href={`/geraete/${a.slug}`}><strong>{a.name}</strong><br /><span class="note">{a.badge}</span></a>)}
</div> </div>
<h2 style="margin-top:1.6rem">Quellen</h2> <h2 style="margin-top:1.6rem">Quellen</h2>
<ul>{product.sources.map(s => <li><a href={s.url} rel="nofollow">{s.label}</a></li>)}</ul> <ul>{product.sources.map(s => <li><a href={s.url} rel="nofollow">{s.label}</a></li>)}</ul>
</section> </section>
</Base> </Base>
<style> <style>
.lead{font-size:1.1rem;color:var(--slate);max-width:46rem} .lead{font-size:1.1rem;color:var(--slate);max-width:46rem}
.price-box{margin:1rem 0} .price-box{margin:1rem 0}.sg{display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:.6rem}.sg strong{font-size:1.25rem}
.status-grid{display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:.6rem}
.status-grid strong{font-size:1.25rem}
.wucher{background:var(--danger-bg);color:var(--danger);padding:.7rem .9rem;border-radius:var(--radius);font-size:.92rem}
.status-cta{display:flex;gap:.5rem;flex-wrap:wrap} .status-cta{display:flex;gap:.5rem;flex-wrap:wrap}
.hint{background:var(--warm-bg);color:oklch(0.42 0.11 47);padding:.8rem 1rem;border-radius:var(--radius);font-size:.95rem} .hint{background:var(--warm-bg);color:oklch(0.42 0.11 47);padding:.8rem 1rem;border-radius:var(--radius)}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem} .cols{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem}
.stores{grid-template-columns:repeat(2,1fr)} .stores{grid-template-columns:repeat(3,1fr)}
.srow{display:flex;justify-content:space-between;align-items:center} .altlink{text-decoration:none;color:var(--ink)}.altlink:hover{border-color:var(--petrol)}
.altlink{text-decoration:none;color:var(--ink)}
.altlink:hover{border-color:var(--petrol)}
ul{margin:.2rem 0;padding-left:1.1rem} ul{margin:.2rem 0;padding-left:1.1rem}
@media(max-width:820px){.cols,.stores{grid-template-columns:1fr}} @media(max-width:820px){.cols{grid-template-columns:1fr}.stores{grid-template-columns:1fr}}
</style> </style>
+84 -69
View File
@@ -1,88 +1,103 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import ProductCard from '../components/ProductCard.astro'; import ProductCard from '../components/ProductCard.astro';
import { allProducts, priceIntel, euro, getProduct, dataUpdatedAt } from '../lib/pricing'; import { allProducts } from '../lib/pricing';
const top = allProducts.filter(p => p.tier === 'A'); const top = allProducts.filter(p => p.tier === 'A').slice(0,3);
const ps = getProduct('midea-portasplit-12000')!; const jsonld = {'@context':'https://schema.org','@type':'WebSite',name:'Kühlfinder',url:'https://kuehlfinder.de',
const psi = priceIntel(ps); description:'Finde, wo es gerade Klimageräte in Baumärkten und Elektromärkten gibt mit Prognose, Preis-Check und Alarm.'};
const updated = new Date(dataUpdatedAt).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
const jsonld = {
'@context':'https://schema.org','@type':'WebSite',name:'Kühlfinder',url:'https://kuehlfinder.de',
description:'Portable Klimageräte finden und Live-Verfügbarkeit in Baumärkten sehen.'
};
--- ---
<Base title="Kühlfinder gutes portables Klimagerät finden & Verfügbarkeit sehen" jsonld={jsonld}> <Base title="Kühlfinder Wo gibt es gerade ein Klimagerät? Live-Verfügbarkeit in DE" description="Live-Bestand von Klimageräten in Baumärkten (OBI, Toom, Bauhaus …) und Elektromärkten (MediaMarkt, Saturn …) plus Amazon/idealo mit Verfügbarkeits-Prognose, Preis-Check und Alarm." jsonld={jsonld}>
<section class="hero"><div class="wrap"> <section class="hero"><div class="wrap">
<p class="kicker"><span class="u"></span>Ehrlicher Klima-Finder r Mietwohnungen</p> <span class="eyebrow"><span class="dot ok"></span>Live-Verfügbarkeit · eigener Scraper</span>
<h1>Finde ein gutes portables Klimagerät und sieh, wo es heute verfügbar ist.</h1> <h1>Wo gibt es gerade ein&nbsp;Klimagerät?</h1>
<p class="lead">Nur echte Kompressorgeräte, keine Fake-Kühler. Mit Live-Verfügbarkeit in Baumärkten, Preis-Check gegen Wucher und Alarm, sobald dein Gerät wieder da ist.</p> <p class="lead">Klimageräte sind bei Hitze überall vergriffen. Kühlfinder zeigt dir <strong>live, welcher Markt in deiner Nähe eins auf Lager hat</strong> Baumärkte und Elektromärkte mit Verfügbarkeits-Prognose, Preis-Check gegen Wucher und Alarm, sobald wieder was da ist.</p>
<div class="modes"> <form class="herobar" action="/karte" method="get">
<a class="mode" href="/finder"><strong>Ich filtere</strong><span>Welches Gerät passt zu meinem Raum?</span></a> <input name="plz" inputmode="numeric" placeholder="PLZ oder Ort eingeben …" aria-label="PLZ oder Ort" />
<a class="mode" href="/karte"><strong>Ich sehe die Karte</strong><span>Alle Märkte mit aktuellem Bestand</span></a> <button class="btn" type="submit">Verfügbarkeit zeigen</button>
<a class="mode" href="/alarm"><strong>Ich lasse mich benachrichtigen</strong><span>Alarm bei Verfügbarkeit in der Nähe</span></a> </form>
</div> <div class="pills" id="livepills">
</div></section> <span class="pill-stat"><b id="p-ac">…</b> Märkte mit Klimagerät jetzt</span>
<span class="pill-stat"><b id="p-total">…</b> Märkte überwacht</span>
<section class="wrap"><div class="status card"> <span class="pill-stat"><b id="p-chains">10</b> Ketten · Baumarkt + Elektro</span>
<div class="status-head">
<h2 style="margin:0">Midea PortaSplit aktueller Status</h2>
<span class="note">Stand: {updated}</span>
</div>
<div class="status-grid">
<div><span class="note">Üblicher Preis (UVP)</span><br /><strong>{euro(psi.fairPrice)}</strong></div>
<div><span class="note">Seriös aktuell ab</span><br /><strong>{euro(psi.lowestLegit)}</strong></div>
<div><span class="note">Märkte mit Bestand</span><br /><strong>{psi.storesAvailable}</strong></div>
</div>
{psi.wucher && (
<p class="wucher">⚠ <strong>Wucher-Warnung:</strong> Einzelne Drittanbieter verlangen aktuell bis {euro(psi.wucher.price)} das ist deutlich über dem üblichen Preis von {euro(psi.fairPrice)}. Nicht in Panik überteuert kaufen. Besser: Alarm setzen oder eine gute Alternative wählen.</p>
)}
<div class="status-cta">
<a class="btn sm" href="/karte?produkt=midea-portasplit-12000">PortaSplit auf der Karte</a>
<a class="btn sm ghost" href="/alarm?produkt=midea-portasplit-12000">Verfügbarkeits-Alarm</a>
<a class="btn sm ghost" href="/portasplit">Alternativen ansehen</a>
</div> </div>
</div></section> </div></section>
<section class="wrap"> <section class="wrap">
<h2><span class="u"></span>Top-Empfehlungen</h2> <div class="features">
<p class="muted">Kuratiert nach Bauart, Kühlleistung, Effizienz, Nutzungsfall und glaubwürdigen Quellen.</p> <a class="feature" href="/karte">
<div class="grid cards3"> <div class="ic"><i class="ti">◎</i></div>
{top.map(p => <ProductCard product={p} />)} <h3>Live-Karte</h3>
<p class="muted">Alle Märkte mit aktuellem Bestand auf einen Blick filterbar nach Modell, Kette und Umkreis.</p>
</a>
<a class="feature" href="/finder">
<div class="ic">◆</div>
<h3>60-Sekunden-Finder</h3>
<p class="muted">Welches Gerät passt zu deinem Raum? Ehrliche Empfehlung nur echte Kompressorgeräte.</p>
</a>
<a class="feature" href="/alarm">
<div class="ic">◔</div>
<h3>Prognose &amp; Alarm</h3>
<p class="muted">Wir schätzen, wo die Chance in 7 Tagen am höchsten ist und melden uns per Mail/Telegram.</p>
</a>
</div> </div>
<p style="margin-top:1rem"><a class="btn ghost" href="/geraete">Alle Geräte &amp; Vergleich</a></p>
</section> </section>
<section class="wrap"><div class="split"> <section class="wrap">
<div class="card"> <div class="section-head">
<h2>PortaSplit ausverkauft?</h2> <div><span class="kicker"><span class="u"></span>Kuratiert</span><h2 style="margin:.2rem 0 0">Beliebte Geräte und wo es sie gibt</h2></div>
<p>Die Midea PortaSplit ist bei Hitze regelmäßig vergriffen. Wir zeigen dir wohnungstaugliche Alternativen, die wirklich kühlen statt dich teuer hängen zu lassen.</p> <a class="btn ghost sm" href="/geraete">Alle Geräte &amp; Vergleich</a>
<a class="btn ghost" href="/portasplit">Sinnvolle Alternativen</a>
</div> </div>
<div class="card warm"> <div class="grid cards3">{top.map(p => <ProductCard product={p} />)}</div>
<h2>Warum Kühlung wichtiger wird</h2> </section>
<p>Hitzewellen treten in Deutschland früher und intensiver auf. Gute, effiziente Kühlung kann Hitzeschutz sein mit Quellen statt Panik.</p>
<a class="btn ghost" href="/hitzeschutz">Hitzeschutz &amp; Klima</a> <section class="wrap"><div class="band">
<div>
<h2>Vergriffen? Wir warnen vor Wucher.</h2>
<p class="muted" style="max-width:42rem">Bei Knappheit verlangen Drittanbieter (z. B. Amazon-Marketplace) schnell das Mehrfache. Kühlfinder kennt den üblichen Preis und markiert Wucher und schlägt dir wohnungstaugliche Alternativen vor, die wirklich kühlen.</p>
<div class="bandcta"><a class="btn" href="/karte">Zur Live-Karte</a><a class="btn ghost" href="/portasplit">PortaSplit &amp; Alternativen</a></div>
</div>
<div class="band-card card">
<div class="bc-row"><span>Üblicher Preis</span><b class="num">799 €</b></div>
<div class="bc-row danger"><span>⚠ Marketplace-Wucher</span><b class="num">2.999 €</b></div>
<div class="bc-row ok"><span>Fair ab</span><b class="num">699 €</b></div>
</div>
</div></section>
<section class="wrap"><div class="split">
<div class="card"><h2>So funktioniert's</h2>
<ol class="steps"><li>PLZ eingeben oder Standort nutzen.</li><li>Karte zeigt Märkte mit Bestand + Prognose.</li><li>Hingehen, oder Alarm setzen für später.</li></ol>
</div>
<div class="card warm"><h2>Hitzeschutz statt Panik</h2>
<p class="muted">Hitzewellen nehmen zu. Gute, effiziente Kühlung kann Hitzeschutz sein mit Quellen statt Alarmismus.</p>
<a class="btn ghost" href="/hitzeschutz">Klima &amp; Hitzeschutz</a>
</div> </div>
</div></section> </div></section>
</Base> </Base>
<style> <style>
.hero{background:linear-gradient(180deg,var(--petrol-soft),var(--light));padding:3rem 0 2.5rem} .hero{background:var(--grad-hero);padding:3.4rem 0 2.6rem}
.kicker{font-weight:600;color:var(--petrol-deep);margin:0 0 .4rem;text-transform:uppercase;letter-spacing:.04em;font-size:.8rem} .hero h1{font-size:clamp(2.3rem,6vw,4rem);max-width:18ch;margin-top:.6rem}
.lead{font-size:1.15rem;max-width:46rem;color:var(--slate)} .hero .lead{font-size:1.18rem;max-width:46rem;color:var(--ink-2)}
.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-top:1.6rem} .herobar{display:flex;gap:.6rem;margin:1.4rem 0 1rem;max-width:520px}
.mode{display:flex;flex-direction:column;gap:.2rem;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:1.1rem;text-decoration:none;color:var(--ink);transition:border-color .2s,transform .08s} .herobar input{flex:1;font-family:var(--font-body);font-size:1rem;padding:.85rem 1.1rem;border:1px solid var(--line);border-radius:999px;background:#fff;box-shadow:var(--shadow-card)}
.mode:hover{border-color:var(--petrol);transform:translateY(-2px)} .herobar input:focus{border-color:var(--petrol)}
.mode strong{font-size:1.05rem;color:var(--petrol-deep)} .pills{display:flex;gap:.6rem;flex-wrap:wrap}
.mode span{font-size:.9rem;color:var(--slate)} section.wrap{margin-top:2.6rem}
section.wrap{margin-top:2.5rem} .features{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}
.status{border-left:4px solid var(--warm)} .features .feature{text-decoration:none;color:var(--ink)} .features h3{margin:.2rem 0}
.status-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:.4rem}
.status-grid{display:flex;gap:2rem;margin:.8rem 0;flex-wrap:wrap}
.status-grid strong{font-size:1.3rem}
.wucher{background:var(--danger-bg);color:var(--danger);padding:.8rem 1rem;border-radius:var(--radius);font-size:.95rem}
.status-cta{display:flex;gap:.5rem;flex-wrap:wrap}
.cards3{grid-template-columns:repeat(3,1fr)} .cards3{grid-template-columns:repeat(3,1fr)}
.split{display:grid;grid-template-columns:1fr 1fr;gap:1rem} .band{display:grid;grid-template-columns:1.6fr 1fr;gap:1.5rem;align-items:center;background:var(--petrol-soft);border-radius:20px;padding:1.8rem}
.card.warm{background:var(--warm-bg)} .bandcta{display:flex;gap:.6rem;margin-top:1rem;flex-wrap:wrap}
@media(max-width:820px){.modes,.cards3,.split{grid-template-columns:1fr}} .band-card{display:flex;flex-direction:column;gap:.5rem}
.bc-row{display:flex;justify-content:space-between;align-items:center;padding:.5rem .2rem;border-bottom:1px solid var(--line)}
.bc-row b{font-size:1.1rem}.bc-row.danger b{color:var(--danger)}.bc-row.ok b{color:var(--ok-dot)}
.split{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.card.warm{background:var(--warm-bg)}
.steps{margin:.4rem 0 0;padding-left:1.2rem}.steps li{margin:.3rem 0}
.ic i{font-style:normal}
@media(max-width:820px){.features,.cards3,.split{grid-template-columns:1fr}.band{grid-template-columns:1fr}}
</style> </style>
<script>
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{
document.getElementById('p-ac').textContent=(d.stats.withAC??'').toLocaleString('de-DE');
document.getElementById('p-total').textContent=(d.stats.storesTotal??'').toLocaleString('de-DE');
}).catch(()=>{});
</script>
+100 -143
View File
@@ -1,23 +1,25 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Monitor',applicationCategory:'ShoppingApplication', import { allProducts } from '../lib/pricing';
description:'Live-Verfügbarkeit der Midea PortaSplit, PortaSplit Cool und Universalhalterung in über 1.160 Baumärkten, mit Verfügbarkeits-Prognose.'}; const models = allProducts.map(p=>({slug:p.slug,name:p.name}));
const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Monitor',applicationCategory:'ShoppingApplication',
description:'Live-Verfügbarkeit von Klimageräten in Baumärkten und Elektromärkten in Deutschland, mit Verfügbarkeits-Prognose.'};
--- ---
<Base title="PortaSplit Monitor Live-Verfügbarkeit + Prognose in 1.167 Baumärkten | Kühlfinder" description="Sieh auf einen Blick, welche OBI-, Hornbach-, Bauhaus-, Toom-, Hagebau- und Globus-Filialen Midea PortaSplit, Cool & Halterung auf Lager haben mit Preis-Check, Wucher-Warnung und Verfügbarkeits-Prognose." jsonld={jsonld}> <Base title="Klimageräte-Monitor Live-Verfügbarkeit in Baumärkten & Elektromärkten | Kühlfinder" description="Sieh live, welche Märkte (OBI, Toom, Bauhaus, Hornbach, MediaMarkt, Saturn, expert, Euronics …) gerade Klimageräte auf Lager haben mit Prognose, Preis-Check und Wucher-Warnung." jsonld={jsonld}>
<section class="mon-hero"><div class="wrap"> <section class="mon-hero"><div class="wrap">
<div class="mon-hero-row"> <div class="mon-hero-row">
<div> <div>
<p class="kicker"><span class="u"></span>PortaSplit Monitor · Live</p> <span class="eyebrow"><span class="dot ok"></span>Live · eigener Scraper</span>
<h1>Wo gibt es die Midea PortaSplit gerade?</h1> <h1>Wo gibt es gerade ein Klimagerät?</h1>
<p class="lead">Live-Bestand in <span class="num" id="h-stores">1.167</span> Baumärkten plus <strong>Verfügbarkeits-Prognose</strong>, die dir sagt, wo die Chance in den nächsten 7 Tagen am höchsten ist. Das kann braucheklima nicht.</p> <p class="lead">Live-Bestand in Baumärkten <em>und</em> Elektromärkten plus <strong>Verfügbarkeits-Prognose</strong>, wo die Chance in 7 Tagen am höchsten ist.</p>
</div> </div>
<div class="upd card"><span class="note">Aktualisiert</span><div class="num upd-t" id="h-updated"></div><span class="note" id="h-feednote">lädt …</span></div> <div class="upd card"><span class="note">Aktualisiert</span><div class="num upd-t" id="h-updated"></div><span class="note" id="h-feednote">lädt …</span></div>
</div> </div>
<div class="stats" id="stats"> <div class="stats" id="stats">
<div class="stat"><span class="note">Filialen</span><b class="num" data-k="storesTotal"></b></div> <div class="stat"><span class="note">Märkte</span><b class="num" data-k="storesTotal"></b></div>
<div class="stat ok"><span class="note">mit PortaSplit</span><b class="num" data-k="withPortaSplit"></b></div> <div class="stat ok"><span class="note">mit Klimagerät</span><b class="num" data-k="withAC"></b></div>
<div class="stat ok"><span class="note">mit Cool</span><b class="num" data-k="withPortaSplitCool"></b></div> <div class="stat"><span class="note">Baumärkte</span><b class="num" data-k="baumarkt"></b></div>
<div class="stat ok"><span class="note">mit Halterung</span><b class="num" data-k="withHalterung"></b></div> <div class="stat"><span class="note">Elektromärkte</span><b class="num" data-k="elektro"></b></div>
<div class="stat"><span class="note">Restocks 7 Tg.</span><b class="num" id="s-restocks"></b></div> <div class="stat"><span class="note">Restocks 7 Tg.</span><b class="num" id="s-restocks"></b></div>
<div class="stat warn"><span class="note">Wucher-Alarm</span><b class="num" id="s-wucher"></b></div> <div class="stat warn"><span class="note">Wucher-Alarm</span><b class="num" id="s-wucher"></b></div>
</div> </div>
@@ -27,227 +29,182 @@ const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'K
<div class="mapcol"> <div class="mapcol">
<div id="map"></div> <div id="map"></div>
<div class="legend"> <div class="legend">
<span><span class="dot ok"></span>verfügbar</span> <span><span class="dot ok"></span>Klimagerät verfügbar</span>
<span><span class="dot" style="background:var(--petrol)"></span>Filiale (Cluster)</span> <span><span class="dot" style="background:var(--petrol)"></span>Markt (Cluster)</span>
<span><i class="hot"></i>hohe Prognose</span>
</div> </div>
</div> </div>
<aside class="feedcol card"> <aside class="feedcol card">
<h2 class="h-sm">Letzte Verfügbarkeiten</h2> <h2 class="h-sm">Letzte Verfügbarkeiten</h2>
<p class="note">Echte Restock-Ereignisse, neueste zuerst.</p> <p class="note">Restock-Ereignisse, neueste zuerst.</p>
<ol class="feed" id="feed"><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li></ol> <ol class="feed" id="feed"><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li></ol>
</aside> </aside>
</section> </section>
<section class="wrap"> <section class="wrap">
<div class="toolbar card"> <div class="toolbar card">
<div class="tb-row"> <div class="tb-row">
<input id="q" type="search" placeholder="Filiale, Ort oder PLZ suchen …" /> <input id="q" type="search" placeholder="Markt, Ort oder PLZ suchen …" />
<select id="model"><option value="all">Alle Modelle</option>{models.map(m=> <option value={m.slug}>{m.name}</option>)}</select>
<button class="btn sm ghost" id="locate" type="button">Standort</button> <button class="btn sm ghost" id="locate" type="button">Standort</button>
<label class="rad">Umkreis <span class="num" id="r-out"></span><input id="radius" type="range" min="10" max="500" step="10" value="500" disabled /></label> <label class="rad">Umkreis <span class="num" id="r-out"></span><input id="radius" type="range" min="10" max="500" step="10" value="500" disabled /></label>
</div> </div>
<div class="tb-row chips"> <div class="tb-row chips">
<span class="note">Produkt:</span>
<button class="chip on" data-p="ps">PortaSplit</button>
<button class="chip on" data-p="co">Cool</button>
<button class="chip on" data-p="ha">Halterung</button>
<span class="sep"></span>
<button class="chip ghost on" data-toggle="avail">Nur verfügbare</button> <button class="chip ghost on" data-toggle="avail">Nur verfügbare</button>
<button class="chip ghost" data-toggle="chance">Beste Prognose zuerst</button> <button class="chip ghost" data-toggle="chance">Beste Prognose</button>
<span class="sep"></span>
<button class="chip on" data-kind="all">Alle</button>
<button class="chip" data-kind="baumarkt">Baumarkt</button>
<button class="chip" data-kind="elektro">Elektro</button>
<span class="sep"></span> <span class="sep"></span>
<span class="note">Kette:</span>
<span id="chainchips"></span> <span id="chainchips"></span>
<button class="chip ghost" id="reset" type="button">Zurücksetzen</button> <button class="chip ghost" id="reset" type="button">Reset</button>
</div> </div>
</div> </div>
<div class="onlinebar card" id="onlinebar"></div> <div class="onlinebar card" id="onlinebar"></div>
<div class="tablewrap card"> <div class="tablewrap card">
<table class="mtab"> <table class="mtab"><thead><tr>
<thead><tr> <th>Markt</th><th class="ce">Klimageräte</th><th class="ce">ab Preis</th><th class="ce" data-sort="q">Prognose 7 Tg.</th><th class="ce" id="distcol" style="display:none">Entfernung</th>
<th>Filiale</th> </tr></thead><tbody id="rows"><tr><td colspan="5"><div class="skel" style="height:200px"></div></td></tr></tbody></table>
<th class="ce" data-sort="ps">PortaSplit</th> <div class="more"><button class="btn ghost" id="more" type="button" style="display:none">Mehr Märkte</button><p class="note" id="rowcount"></p></div>
<th class="ce" data-sort="co">Cool</th>
<th class="ce" data-sort="ha">Halterung</th>
<th class="ce" data-sort="q">Prognose 7 Tg.</th>
<th class="ce" id="distcol" style="display:none">Entfernung</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="6"><div class="skel" style="height:200px"></div></td></tr></tbody>
</table>
<div class="more"><button class="btn ghost" id="more" type="button" style="display:none">Mehr Filialen laden</button><p class="note" id="rowcount"></p></div>
</div> </div>
<div class="alertcta card"> <div class="alertcta card">
<div><h2 class="h-sm">Kein Bestand in deiner Nähe?</h2><p class="note" style="margin:0">Wir mailen dir, sobald PortaSplit & Co. in deinem Umkreis auftauchen inkl. Prognose-Frühwarnung.</p></div> <div><h2 class="h-sm">Nichts in der Nähe?</h2><p class="note" style="margin:0">Wir mailen/telegrammen dir, sobald ein Klimagerät in deinem Umkreis auftaucht inkl. Prognose-Frühwarnung.</p></div>
<a class="btn" href="/alarm?produkt=midea-portasplit-12000">Verfügbarkeits-Alarm</a> <a class="btn" href="/alarm">Alarm einrichten</a>
</div> </div>
<p class="note" style="margin-top:.6rem">Datenstand: Snapshot aus öffentlich einsehbaren Filial-/Online-Beständen (Vorbild braucheklima.de). Prognose = statistische Schätzung aus der Restock-Historie, keine Garantie. Preise/Bestände beim Händler prüfen.</p> <p class="note" style="margin-top:.6rem">Datenquelle: <strong>eigener Kühlfinder-Scraper</strong> (Händler-Seiten direkt) keine Drittdaten. Aktuell Seed-Stand; Live-Crawler + echte Historie folgen. Prognose = statistische Schätzung, keine Garantie.</p>
</section> </section>
</Base> </Base>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style> <style>
.mon-hero{background:linear-gradient(180deg,var(--petrol-soft),var(--light));padding:1.8rem 0 1.4rem} .mon-hero{background:var(--grad-hero);padding:1.8rem 0 1.4rem}
.mon-hero-row{display:flex;justify-content:space-between;gap:1.2rem;align-items:flex-start} .mon-hero-row{display:flex;justify-content:space-between;gap:1.2rem;align-items:flex-start}
.mon-hero h1{font-size:clamp(1.8rem,4vw,2.7rem);margin:.5rem 0 .3rem}
.mon-hero .lead{max-width:48rem;color:var(--ink-2);font-size:1.05rem} .mon-hero .lead{max-width:48rem;color:var(--ink-2);font-size:1.05rem}
.upd{min-width:140px;text-align:center} .upd{min-width:140px;text-align:center}.upd-t{font-size:1.6rem;font-weight:600;color:var(--petrol-deep)}
.upd-t{font-size:1.6rem;font-weight:600;color:var(--petrol-deep)}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:.6rem;margin-top:1.2rem} .stats{display:grid;grid-template-columns:repeat(6,1fr);gap:.6rem;margin-top:1.2rem}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem;display:flex;flex-direction:column;gap:.1rem} .stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.6rem .8rem;display:flex;flex-direction:column;gap:.1rem;box-shadow:var(--shadow-card)}
.stat b{font-size:1.45rem;line-height:1.1} .stat b{font-size:1.45rem;line-height:1.1}.stat.ok b{color:var(--ok-dot)}.stat.warn b{color:var(--danger)}
.stat.ok b{color:var(--ok-dot)} .stat.warn b{color:var(--danger)}
.mon-grid{display:grid;grid-template-columns:1.7fr 1fr;gap:1rem;margin-top:1.2rem;align-items:start} .mon-grid{display:grid;grid-template-columns:1.7fr 1fr;gap:1rem;margin-top:1.2rem;align-items:start}
.mapcol{position:sticky;top:74px} .mapcol{position:sticky;top:74px}
#map{height:520px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)} #map{height:520px;border-radius:16px;overflow:hidden;border:1px solid var(--line);box-shadow:var(--shadow-card)}
.legend{display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;color:var(--slate);margin-top:.5rem;align-items:center} .legend{display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;color:var(--slate);margin-top:.5rem}
.legend i.hot{display:inline-block;width:11px;height:11px;border-radius:50%;border:2px solid var(--warm);margin-right:.3rem;vertical-align:middle} .feedcol{max-height:560px;overflow:auto}.h-sm{font-size:1.15rem;margin:.1rem 0 .2rem}
.feedcol{max-height:560px;overflow:auto} .feed{list-style:none;margin:.6rem 0 0;padding:0}
.h-sm{font-size:1.15rem;margin:.1rem 0 .2rem}
.feed{list-style:none;margin:.6rem 0 0;padding:0;display:flex;flex-direction:column;gap:.1rem}
.feed li{display:flex;justify-content:space-between;gap:.6rem;padding:.55rem .2rem;border-bottom:1px solid var(--line)} .feed li{display:flex;justify-content:space-between;gap:.6rem;padding:.55rem .2rem;border-bottom:1px solid var(--line)}
.feed .fl b{font-weight:600;font-size:.92rem} .feed .fl b{font-weight:600;font-size:.9rem}.feed .fl span{font-size:.8rem;color:var(--slate)}
.feed .fl span{font-size:.8rem;color:var(--slate)} .feed .ft{font-size:.78rem;color:var(--slate);white-space:nowrap;font-family:var(--mono)}
.feed .ft{font-size:.8rem;color:var(--slate);white-space:nowrap;font-family:var(--mono)}
.toolbar{margin-top:1.4rem;display:flex;flex-direction:column;gap:.7rem} .toolbar{margin-top:1.4rem;display:flex;flex-direction:column;gap:.7rem}
.tb-row{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap} .tb-row{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
#q{flex:1;min-width:200px;font-family:var(--font-body);font-size:.95rem;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;background:var(--surface-2)} #q{flex:1;min-width:180px;font-family:var(--font-body);font-size:.95rem;padding:.6rem .8rem;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}
#q:focus{border-color:var(--petrol);background:#fff} #q:focus{border-color:var(--petrol);background:#fff}
#model{padding:.55rem .7rem;border-radius:10px;border:1px solid var(--line);font-family:var(--font-body)}
.rad{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--slate)} .rad{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--slate)}
.chips{font-size:.85rem} .chip{font-family:var(--font-body);font-weight:500;font-size:.85rem;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:.4rem .8rem;border-radius:999px;cursor:pointer;transition:transform .1s,background .15s}
.chip{font-family:var(--font-body);font-weight:500;font-size:.85rem;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:.4rem .8rem;border-radius:999px;cursor:pointer;transition:transform .1s,background .15s,border-color .15s} .chip:active{transform:scale(.97)}.chip.on{background:var(--petrol);color:#fff;border-color:var(--petrol)}
.chip:active{transform:scale(.97)}
.chip.on{background:var(--petrol);color:#fff;border-color:var(--petrol)}
.chip.ghost.on{background:var(--ok-bg);color:var(--ok-dot);border-color:var(--ok-dot)} .chip.ghost.on{background:var(--ok-bg);color:var(--ok-dot);border-color:var(--ok-dot)}
.sep{width:1px;align-self:stretch;background:var(--line);margin:0 .2rem} .sep{width:1px;align-self:stretch;background:var(--line);margin:0 .2rem}
#chainchips .chip{font-size:.8rem;padding:.32rem .7rem}
.onlinebar{margin-top:.8rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;padding:.7rem .9rem} .onlinebar{margin-top:.8rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;padding:.7rem .9rem}
.osrc{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;min-width:120px} .osrc{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:10px;padding:.4rem .65rem;min-width:120px}
.osrc b{font-size:.85rem}.osrc .num{font-size:.9rem} .osrc b{font-size:.82rem}.osrc .num{font-size:.9rem}.osrc small{font-size:.72rem;color:var(--slate)}
.osrc.wucher{border-color:var(--danger);background:var(--danger-bg)} .osrc.wucher{border-color:var(--danger);background:var(--danger-bg)}.osrc.wucher .num{color:var(--danger)}
.osrc.wucher .num{color:var(--danger)}
.tablewrap{margin-top:.8rem;padding:0;overflow:hidden} .tablewrap{margin-top:.8rem;padding:0;overflow:hidden}
.mtab{width:100%;border-collapse:collapse;font-size:.9rem} .mtab{width:100%;border-collapse:collapse;font-size:.9rem}
.mtab thead th{text-align:left;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--slate);font-weight:600;padding:.7rem .8rem;border-bottom:1px solid var(--line);position:sticky;top:62px;background:var(--card);cursor:pointer;white-space:nowrap} .mtab thead th{text-align:left;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;color:var(--slate);font-weight:600;padding:.7rem .8rem;border-bottom:1px solid var(--line);background:var(--card);white-space:nowrap}
.mtab th.ce{text-align:center} .mtab th.ce{text-align:center;cursor:pointer}
.mtab td{padding:.6rem .8rem;border-bottom:1px solid var(--line);vertical-align:middle} .mtab td{padding:.6rem .8rem;border-bottom:1px solid var(--line);vertical-align:middle}
.mtab tbody tr:hover{background:var(--petrol-soft)} .mtab tbody tr:hover{background:var(--petrol-soft)}
.mtab .fname{font-weight:600;font-size:.92rem}.mtab .faddr{font-size:.78rem;color:var(--slate)} .fname{font-weight:600;font-size:.92rem}.faddr{font-size:.78rem;color:var(--slate)}
.cell{display:flex;flex-direction:column;align-items:center;gap:.15rem;line-height:1.2} .kindb{font-size:.66rem;padding:.05rem .4rem;border-radius:99px;background:var(--surface-2);color:var(--slate);margin-left:.4rem;border:1px solid var(--line)}
.cell .num{font-size:.85rem} .ce .num{font-size:.86rem}
.cell .pa{font-size:.72rem;color:var(--ok-dot);font-weight:600} .stk{font-size:.72rem;font-weight:600}.stk.ok{color:var(--ok-dot)}.stk.off{color:var(--slate)}
.cell .pn{font-size:.72rem;color:var(--slate)} .prog{display:inline-flex;align-items:center;gap:.4rem}.progbar{width:46px;height:6px;border-radius:99px;background:var(--line);overflow:hidden}.progbar i{display:block;height:100%;background:var(--warm)}
.prog{display:inline-flex;align-items:center;gap:.4rem}
.progbar{width:46px;height:6px;border-radius:99px;background:var(--line);overflow:hidden}
.progbar i{display:block;height:100%;background:var(--warm)}
.more{display:flex;justify-content:space-between;align-items:center;padding:.8rem} .more{display:flex;justify-content:space-between;align-items:center;padding:.8rem}
.alertcta{margin-top:1rem;display:flex;justify-content:space-between;align-items:center;gap:1rem;background:var(--petrol-soft);border-color:transparent} .alertcta{margin-top:1rem;display:flex;justify-content:space-between;align-items:center;gap:1rem;background:var(--petrol-soft);border-color:transparent}
@media(max-width:900px){.stats{grid-template-columns:repeat(3,1fr)}.mon-grid{grid-template-columns:1fr}.mapcol{position:static}.mon-hero-row{flex-direction:column}.mtab thead th{position:static}} @media(max-width:900px){.stats{grid-template-columns:repeat(3,1fr)}.mon-grid{grid-template-columns:1fr}.mapcol{position:static}.mon-hero-row{flex-direction:column}}
</style> </style>
<script> <script>
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
const PNAME={ps:'PortaSplit',co:'PortaSplit Cool',ha:'Universalhalterung'};
const PKEYFEED={'portasplit':'ps','portasplit-cool':'co','universalhalterung':'ha'};
const euro=n=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n); const euro=n=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
const FAIR={ps:849,co:779,ha:70}; const $=s=>document.querySelector(s),$$=s=>Array.from(document.querySelectorAll(s));
let DATA=null, center=null, shown=120; let DATA=null,center=null,shown=120;
const state={products:{ps:true,co:true,ha:true},chains:new Set(),avail:true,chance:false,q:'',radius:500,sort:null}; const state={q:'',model:'all',chains:new Set(),kind:'all',avail:true,chance:false,radius:500};
const params=new URLSearchParams(location.search);
if(params.get('modell'))state.model=params.get('modell');
if(params.get('plz'))state.q=params.get('plz');
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
function dist(aLat,aLng,bLat,bLng){const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180;const x=Math.sin(dLat/2)**2+Math.cos(aLat*Math.PI/180)*Math.cos(bLat*Math.PI/180)*Math.sin(dLng/2)**2;return 2*R*Math.asin(Math.sqrt(x));} function dist(aLat,aLng,bLat,bLng){const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180;const x=Math.sin(dLat/2)**2+Math.cos(aLat*Math.PI/180)*Math.cos(bLat*Math.PI/180)*Math.sin(dLng/2)**2;return 2*R*Math.asin(Math.sqrt(x));}
function rel(iso){const d=(Date.now()-new Date(iso))/3600000;if(d<1)return 'vor '+Math.max(1,Math.round(d*60))+' Min';if(d<24)return 'vor '+Math.round(d)+' Std';return 'vor '+Math.round(d/24)+' Tg';} function rel(iso){const d=(Date.now()-new Date(iso))/3600000;if(d<1)return 'vor '+Math.max(1,Math.round(d*60))+' Min';if(d<24)return 'vor '+Math.round(d)+' Std';return 'vor '+Math.round(d/24)+' Tg';}
function status(s){ if(state.model!=='all'){const m=(s.models||[]).find(x=>x.id===state.model); if(!m)return null; return {a:m.a,p:m.a?m.p:null,q:m.q,n:m.a?1:0};} return {a:s.any.a,p:s.any.minP,q:s.any.q,n:s.any.n}; }
function pass(s){ const st=status(s); if(!st)return false;
if(state.kind!=='all'&&s.kind!==state.kind)return false;
if(state.chains.size&&!state.chains.has(s.c))return false;
if(state.q){const q=state.q.toLowerCase();if(!(s.n.toLowerCase().includes(q)||s.ci.toLowerCase().includes(q)||s.z.startsWith(q)))return false;}
if(center&&dist(center[1],center[0],s.la,s.lo)>state.radius)return false;
if(state.avail&&!st.a)return false;
return true; }
const map=new maplibregl.Map({container:'map',style:'https://tiles.openfreemap.org/styles/liberty',center:[10.2,51.1],zoom:5}); const map=new maplibregl.Map({container:'map',style:'https://tiles.openfreemap.org/styles/liberty',center:[10.2,51.1],zoom:5});
map.addControl(new maplibregl.NavigationControl(),'top-right'); map.addControl(new maplibregl.NavigationControl(),'top-right');
function activeProducts(){return Object.keys(state.products).filter(k=>state.products[k]);}
function storeBest(s){ // returns {avail, bestProb, anyTracked}
let avail=false,bestProb=0,any=false;
for(const k of activeProducts()){ if(s[k]){any=true; if(s[k].a)avail=true; if(s[k].q>bestProb)bestProb=s[k].q;} }
return {avail,bestProb,any};
}
function passFilter(s){
const aps=activeProducts(); if(!aps.some(k=>s[k]))return false;
if(state.chains.size && !state.chains.has(s.c))return false;
if(state.q){const q=state.q.toLowerCase(); if(!(s.n.toLowerCase().includes(q)||s.ci.toLowerCase().includes(q)||s.z.startsWith(q)))return false;}
if(center && dist(center[1],center[0],s.la,s.lo)>state.radius)return false;
if(state.avail && !storeBest(s).avail)return false;
return true;
}
function setMap(list){ function setMap(list){
const all={type:'FeatureCollection',features:list.map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{}}))}; const all={type:'FeatureCollection',features:list.map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{}}))};
const av={type:'FeatureCollection',features:list.filter(s=>storeBest(s).avail).map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{n:s.n,ci:s.ci}}))}; const av={type:'FeatureCollection',features:list.filter(s=>status(s)?.a).map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{n:s.n,ci:s.ci}}))};
map.getSource('all').setData(all); map.getSource('av').setData(av); map.getSource('all').setData(all);map.getSource('av').setData(av);
} }
function render(){ function render(){
const list=DATA.stores.filter(passFilter); let list=DATA.stores.filter(pass);
if(center){list.sort((a,b)=>dist(center[1],center[0],a.la,a.lo)-dist(center[1],center[0],b.la,b.lo));} if(center)list.sort((a,b)=>dist(center[1],center[0],a.la,a.lo)-dist(center[1],center[0],b.la,b.lo));
else if(state.chance){list.sort((a,b)=>storeBest(b).bestProb-storeBest(a).bestProb);} else if(state.chance)list.sort((a,b)=>(status(b)?.q||0)-(status(a)?.q||0));
else {list.sort((a,b)=>(storeBest(b).avail?1:0)-(storeBest(a).avail?1:0)||storeBest(b).bestProb-storeBest(a).bestProb);} else list.sort((a,b)=>(status(b)?.a?1:0)-(status(a)?.a?1:0)||(status(b)?.q||0)-(status(a)?.q||0));
// map
setMap(list); setMap(list);
// table const tb=$('#rows');tb.innerHTML='';
const tb=$('#rows'); tb.innerHTML=''; for(const s of list.slice(0,shown)){
const slice=list.slice(0,shown); const st=status(s);
const cell=(o)=>{ if(!o)return '<td class="ce"><span class="pn"></span></td>'; const stk = st.a?`<span class="stk ok"><span class="dot ok"></span> ${state.model==='all'?st.n+' verfügbar':'verfügbar'}</span>`:`<span class="stk off"><span class="dot off"></span> vergriffen</span>`;
const dotc=o.a?'ok':(o.q>=15?'low':'off');
return `<td class="ce"><div class="cell"><span><span class="dot ${dotc}"></span> <span class="num">${euro(o.p)}</span></span>${o.a?'<span class="pa">verfügbar</span>':`<span class="pn">${o.q>0?o.q+'% / 7 Tg.':'—'}</span>`}</div></td>`;};
for(const s of slice){
const b=storeBest(s);
const d=center?` · <span class="num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</span>`:'';
const prog=`<td class="ce"><span class="prog"><span class="progbar"><i style="width:${b.bestProb}%"></i></span><span class="num">${b.bestProb}%</span></span></td>`;
const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:''; const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:'';
tb.insertAdjacentHTML('beforeend',`<tr><td><div class="fname">${s.n}</div><div class="faddr">${s.z} ${s.ci}${d}</div></td>${cell(s.ps)}${cell(s.co)}${cell(s.ha)}${prog}${distTd}</tr>`); tb.insertAdjacentHTML('beforeend',`<tr><td><span class="fname">${s.n}</span><span class="kindb">${s.kind==='elektro'?'Elektro':'Baumarkt'}</span><div class="faddr">${s.z} ${s.ci}</div></td>
<td class="ce">${stk}</td><td class="ce num">${euro(st.p)}</td>
<td class="ce"><span class="prog"><span class="progbar"><i style="width:${st.q||0}%"></i></span><span class="num">${st.q||0}%</span></span></td>${distTd}</tr>`);
} }
$('#rowcount').textContent=`${slice.length} von ${list.length} Filialen`; $('#rowcount').textContent=`${Math.min(shown,list.length)} von ${list.length} Märkten`;
$('#more').style.display=list.length>shown?'inline-flex':'none'; $('#more').style.display=list.length>shown?'inline-flex':'none';
$('#distcol').style.display=center?'table-cell':'none'; $('#distcol').style.display=center?'table-cell':'none';
} }
function fillStatic(){ function fillStatic(){
$('#h-updated').textContent=DATA.stats.updated; $('#h-updated').textContent=DATA.stats.updated;
$('#h-stores').textContent=DATA.stats.storesTotal.toLocaleString('de-DE');
$$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE')); $$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE'));
const r7=DATA.feed.filter(f=>(Date.now()-new Date(f.at))/86400000<=7).length; const r7=DATA.feed.filter(f=>(Date.now()-new Date(f.at))/86400000<=7).length;
$('#s-restocks').textContent=r7+(r7>=DATA.feed.length?'+':''); $('#s-restocks').textContent=r7+(r7>=DATA.feed.length?'+':'');
$('#s-wucher').textContent=DATA.online.filter(o=>o.wucher).length;
$('#h-feednote').textContent='Feed live'; $('#h-feednote').textContent='Feed live';
const wuch=DATA.online.filter(o=>o.ps&&o.ps.wucher).length; $('#feed').innerHTML=DATA.feed.slice(0,18).map(f=>`<li><div class="fl"><b>${f.name}</b><span>${f.model}</span></div><span class="ft">${rel(f.at)}</span></li>`).join('');
$('#s-wucher').textContent=wuch; $('#onlinebar').innerHTML='<span class="note" style="align-self:center">Online:</span>'+DATA.online.map(o=>`<a class="osrc ${o.wucher?'wucher':''}" href="${o.url}" target="_blank" rel="nofollow noopener"><b>${o.src}${o.wucher?' ⚠':''}</b><span class="num">${euro(o.p)}</span><small>${o.model} · ${o.a?(o.wucher?'Wucher':'verfügbar'):'nicht verf.'}</small></a>`).join('');
// feed
$('#feed').innerHTML=DATA.feed.slice(0,20).map(f=>`<li><div class="fl"><b>${f.name}</b><span>${PNAME[PKEYFEED[f.productId]]||f.productId}</span></div><span class="ft">${rel(f.at)}</span></li>`).join('');
// online bar
$('#onlinebar').innerHTML='<span class="note" style="align-self:center">Online:</span>'+DATA.online.map(o=>{
const ps=o.ps; if(!ps)return '';
const w=ps.wucher;
return `<a class="osrc ${w?'wucher':''}" href="${o.url}" target="_blank" rel="nofollow noopener"><b>${o.src}${w?' ⚠':''}</b><span class="num">${euro(ps.p)}</span><span class="note">${ps.a?(w?'Wucher':'verfügbar'):'nicht verf.'}</span></a>`;
}).join('');
// chains
const chains=[...new Set(DATA.stores.map(s=>s.c))].sort(); const chains=[...new Set(DATA.stores.map(s=>s.c))].sort();
$('#chainchips').innerHTML=chains.map(c=>`<button class="chip ghost" data-chain="${c}">${c}</button>`).join(' '); $('#chainchips').innerHTML=chains.map(c=>`<button class="chip ghost" data-chain="${c}">${c}</button>`).join(' ');
$$('#chainchips .chip').forEach(b=>b.addEventListener('click',()=>{const c=b.dataset.chain;if(state.chains.has(c)){state.chains.delete(c);b.classList.remove('on');}else{state.chains.add(c);b.classList.add('on');}render();})); $$('#chainchips .chip').forEach(b=>b.addEventListener('click',()=>{const c=b.dataset.chain;state.chains.has(c)?(state.chains.delete(c),b.classList.remove('on')):(state.chains.add(c),b.classList.add('on'));render();}));
if(state.model!=='all')$('#model').value=state.model;
if(state.q)$('#q').value=state.q;
} }
map.on('load',()=>{ map.on('load',()=>{
map.addSource('all',{type:'geojson',data:{type:'FeatureCollection',features:[]},cluster:true,clusterRadius:46,clusterMaxZoom:9}); map.addSource('all',{type:'geojson',data:{type:'FeatureCollection',features:[]},cluster:true,clusterRadius:46,clusterMaxZoom:9});
map.addLayer({id:'cl',type:'circle',source:'all',filter:['has','point_count'],paint:{'circle-color':'#2b6a72','circle-opacity':.82,'circle-radius':['step',['get','point_count'],14,50,18,200,24,500,30]}}); map.addLayer({id:'cl',type:'circle',source:'all',filter:['has','point_count'],paint:{'circle-color':'#2b6a72','circle-opacity':.82,'circle-radius':['step',['get','point_count'],14,50,18,200,24,500,30]}});
map.addLayer({id:'cl-c',type:'symbol',source:'all',filter:['has','point_count'],layout:{'text-field':['get','point_count_abbreviated'],'text-size':12},paint:{'text-color':'#fff'}}); map.addLayer({id:'cl-c',type:'symbol',source:'all',filter:['has','point_count'],layout:{'text-field':['get','point_count_abbreviated'],'text-size':12},paint:{'text-color':'#fff'}});
map.addLayer({id:'pt',type:'circle',source:'all',filter:['!',['has','point_count']],paint:{'circle-color':'#2b6a72','circle-opacity':.7,'circle-radius':4,'circle-stroke-width':1,'circle-stroke-color':'#fff'}}); map.addLayer({id:'pt',type:'circle',source:'all',filter:['!',['has','point_count']],paint:{'circle-color':'#2b6a72','circle-opacity':.6,'circle-radius':4,'circle-stroke-width':1,'circle-stroke-color':'#fff'}});
map.addSource('av',{type:'geojson',data:{type:'FeatureCollection',features:[]}}); map.addSource('av',{type:'geojson',data:{type:'FeatureCollection',features:[]}});
map.addLayer({id:'av',type:'circle',source:'av',paint:{'circle-color':'#1d9e75','circle-radius':7,'circle-stroke-width':2,'circle-stroke-color':'#fff'}}); map.addLayer({id:'av',type:'circle',source:'av',paint:{'circle-color':'#1d9e75','circle-radius':7,'circle-stroke-width':2,'circle-stroke-color':'#fff'}});
map.on('click','av',e=>{const p=e.features[0].properties;new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`<b>${p.n}</b><br>${p.ci} verfügbar`).addTo(map);}); map.on('click','av',e=>{const p=e.features[0].properties;new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`<b>${p.n}</b><br>${p.ci} Klimagerät verfügbar`).addTo(map);});
map.on('click','cl',e=>{const f=map.queryRenderedFeatures(e.point,{layers:['cl']});map.getSource('all').getClusterExpansionZoom(f[0].properties.cluster_id).then(z=>map.easeTo({center:f[0].geometry.coordinates,zoom:z}));}); map.on('click','cl',e=>{const f=map.queryRenderedFeatures(e.point,{layers:['cl']});map.getSource('all').getClusterExpansionZoom(f[0].properties.cluster_id).then(z=>map.easeTo({center:f[0].geometry.coordinates,zoom:z}));});
if(DATA)render(); if(DATA)render();
}); });
// controls
$$('.chip[data-p]').forEach(b=>b.addEventListener('click',()=>{const k=b.dataset.p;state.products[k]=!state.products[k];b.classList.toggle('on');render();}));
$$('.chip[data-toggle]').forEach(b=>b.addEventListener('click',()=>{const t=b.dataset.toggle;state[t]=!state[t];b.classList.toggle('on');render();}));
$('#q').addEventListener('input',e=>{state.q=e.target.value.trim();shown=120;render();}); $('#q').addEventListener('input',e=>{state.q=e.target.value.trim();shown=120;render();});
$('#model').addEventListener('change',e=>{state.model=e.target.value;render();});
$$('.chip[data-toggle]').forEach(b=>b.addEventListener('click',()=>{state[b.dataset.toggle]=!state[b.dataset.toggle];b.classList.toggle('on');render();}));
$$('.chip[data-kind]').forEach(b=>b.addEventListener('click',()=>{state.kind=b.dataset.kind;$$('.chip[data-kind]').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}));
$('#radius').addEventListener('input',e=>{state.radius=+e.target.value;$('#r-out').textContent=state.radius+' km';render();}); $('#radius').addEventListener('input',e=>{state.radius=+e.target.value;$('#r-out').textContent=state.radius+' km';render();});
$('#more').addEventListener('click',()=>{shown+=120;render();}); $('#more').addEventListener('click',()=>{shown+=120;render();});
$('#reset').addEventListener('click',()=>{location.reload();}); $('#reset').addEventListener('click',()=>location.reload());
$$('.mtab th[data-sort]').forEach(th=>th.addEventListener('click',()=>{state.chance=(th.dataset.sort==='q');render();})); $$('.mtab th[data-sort]').forEach(th=>th.addEventListener('click',()=>{state.chance=true;render();}));
$('#locate').addEventListener('click',()=>{if(!navigator.geolocation)return;navigator.geolocation.getCurrentPosition(p=>{center=[p.coords.longitude,p.coords.latitude];$('#radius').disabled=false;state.radius=100;$('#radius').value=100;$('#r-out').textContent='100 km';new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map);map.flyTo({center,zoom:8});render();});}); $('#locate').addEventListener('click',()=>{if(!navigator.geolocation)return;navigator.geolocation.getCurrentPosition(p=>{center=[p.coords.longitude,p.coords.latitude];$('#radius').disabled=false;state.radius=100;$('#radius').value=100;$('#r-out').textContent='100 km';new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map);map.flyTo({center,zoom:8});render();});});
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{DATA=d;fillStatic();render();}).catch(()=>{$('#rowcount').textContent='Daten konnten nicht geladen werden';});
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{DATA=d;fillStatic();if(map.loaded())render();else render();});
</script> </script>
+8 -11
View File
@@ -2,20 +2,17 @@ import { allProducts, priceIntel, euro } from '../lib/pricing';
export const GET = () => { export const GET = () => {
const lines = [ const lines = [
'# Kühlfinder', '# Kühlfinder',
'> Kuratierter Finder für portable Klimageräte in Deutschland mit Live-Verfügbarkeit in Baumärkten, Preis-Check (Wucher-Warnung) und Verfügbarkeits-Alarm.', '> Finde, WO es gerade Klimageräte in Deutschland gibt Live-Verfügbarkeit in Baumärkten (OBI, Toom, Bauhaus, Hornbach, Hagebau, Globus) und Elektromärkten (MediaMarkt, Saturn, expert, Euronics) plus Online (Amazon, idealo), mit Verfügbarkeits-Prognose und Wucher-Warnung. Eigener Scraper, keine Drittdaten.',
'', '',
'## Was Kühlfinder besser macht', '## Was Kühlfinder besser macht',
'- Nur echte Kompressorgeräte (mobile Split, Monoblock) keine Verdunstungs-/USB-Kühler.', '- Zeigt für ALLE kuratierten Klimageräte, welche Märkte sie aktuell vorrätig haben (idealo zeigt keinen Filialbestand).',
'- Live-Verfügbarkeit pro Baumarkt-Filiale auf einer Deutschlandkarte (idealo zeigt keinen Filialbestand).', '- Verfügbarkeits-Prognose aus der Restock-Historie (Chance in den nächsten 7 Tagen).',
'- Warnung vor Wucherpreisen bei Knappheit (z. B. Midea PortaSplit).', '- Warnung vor Wucherpreisen bei Knappheit.',
'- Fokus auf wohnungstaugliche Geräte und PortaSplit-Alternativen.', '- Beratung (60-Sekunden-Finder) + wohnungstaugliche Alternativen, wenn ein Gerät ausverkauft ist.',
'', '',
'## Geräte', '## Geräte',
]; ];
for (const p of allProducts) { for (const p of allProducts){ const pi=priceIntel(p); lines.push(`- ${p.name} (${p.type==='mobile_split'?'mobile Split':'Monoblock'}, ${p.btuh} BTU): ${p.shortVerdict} Üblicher Preis ${euro(pi.fairPrice)}. /geraete/${p.slug}`); }
const pi = priceIntel(p); lines.push('', '## Seiten', '- /karte Live-Verfügbarkeits-Monitor (Karte + Tabelle + Prognose)', '- /finder 60-Sekunden-Empfehlung', '- /portasplit PortaSplit-Status & Alternativen', '- /alarm Verfügbarkeits-Alarm (E-Mail + Telegram)', '- /hitzeschutz Klimawandel-Kontext', '- /transparenz Methodik & Affiliate');
lines.push(`- ${p.name} (${p.type==='mobile_split'?'mobile Split':'Monoblock'}, ${p.btuh} BTU): ${p.shortVerdict} Üblicher Preis ${euro(pi.fairPrice)}. /geraete/${p.slug}`); return new Response(lines.join('\n'), { headers: { 'Content-Type':'text/plain; charset=utf-8' } });
}
lines.push('', '## Seiten', '- /finder 60-Sekunden-Empfehlung', '- /karte Verfügbarkeits-Karte', '- /portasplit PortaSplit-Status & Alternativen', '- /alarm Verfügbarkeits-Alarm', '- /hitzeschutz Klimawandel-Kontext', '- /transparenz Methodik & Affiliate');
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}; };
+12 -26
View File
@@ -2,41 +2,27 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import ProductCard from '../components/ProductCard.astro'; import ProductCard from '../components/ProductCard.astro';
import { allProducts, getProduct, priceIntel, euro } from '../lib/pricing'; import { allProducts, getProduct, priceIntel, euro } from '../lib/pricing';
const ps = getProduct('midea-portasplit-12000')!; const ps = getProduct('midea-portasplit-12000')!; const pi = priceIntel(ps);
const psi = priceIntel(ps);
const alts = allProducts.filter(p=>['delonghi-pac-ex105','midea-portasplit-cool-8000','bosch-cool-5000','midea-silent-cool-26-pro-wf'].includes(p.id)); const alts = allProducts.filter(p=>['delonghi-pac-ex105','midea-portasplit-cool-8000','bosch-cool-5000','midea-silent-cool-26-pro-wf'].includes(p.id));
const jsonld={'@context':'https://schema.org','@type':'FAQPage',mainEntity:[ const jsonld={'@context':'https://schema.org','@type':'FAQPage',mainEntity:[
{'@type':'Question',name:'Was tun, wenn die Midea PortaSplit ausverkauft ist?',acceptedAnswer:{'@type':'Answer',text:'Sinnvolle Alternativen sind die kleinere PortaSplit Cool, der Monoblock De\'Longhi PAC EX105 oder die Bosch Cool 5000. Wer auf die PortaSplit warten will, nutzt den Verfügbarkeits-Alarm statt überteuert bei Drittanbietern zu kaufen.'}}, {'@type':'Question',name:'Was tun, wenn die Midea PortaSplit ausverkauft ist?',acceptedAnswer:{'@type':'Answer',text:'Sinnvolle Alternativen: PortaSplit Cool, De\'Longhi PAC EX105 oder Bosch Cool 5000. Wer warten will, nutzt den Verfügbarkeits-Alarm statt überteuert bei Drittanbietern zu kaufen.'}},
{'@type':'Question',name:'Wie viel kostet die Midea PortaSplit normal?',acceptedAnswer:{'@type':'Answer',text:'Der übliche Preis liegt bei rund 899 € (UVP), im Angebot teils um 699 €. Preise deutlich darüber bei Drittanbietern sind Wucher.'}} {'@type':'Question',name:'Was kostet die Midea PortaSplit normal?',acceptedAnswer:{'@type':'Answer',text:'Üblich rund 799 € (UVP), im Angebot teils um 699 €. Deutlich höhere Drittanbieterpreise sind Wucher.'}}]};
]};
--- ---
<Base title="Midea PortaSplit ausverkauft? Verfügbarkeit, Preis & beste Alternativen | Kühlfinder" description="PortaSplit-Status, Wucher-Warnung und wohnungstaugliche Alternativen, wenn die Midea PortaSplit nicht lieferbar ist." jsonld={jsonld}> <Base title="Midea PortaSplit ausverkauft? Verfügbarkeit, Preis & beste Alternativen | Kühlfinder" description="PortaSplit-Status, Wucher-Warnung und wohnungstaugliche Alternativen." jsonld={jsonld}>
<section class="wrap" style="margin-top:1.6rem"> <section class="wrap" style="margin-top:1.6rem">
<p class="kicker"><span class="u"></span>PortaSplit-Hub</p> <p class="kicker"><span class="u"></span>PortaSplit-Hub</p>
<h1>Midea PortaSplit: Status, Preis &amp; sinnvolle Alternativen</h1> <h1>Midea PortaSplit: Preis &amp; sinnvolle Alternativen</h1>
<div class="card" style="border-left:4px solid var(--warm)"> <div class="card" style="border-left:4px solid var(--warm)">
<div class="sg"> <div class="sg">
<div><span class="note">Üblicher Preis</span><br /><strong>{euro(psi.fairPrice)}</strong></div> <div><span class="note">Üblicher Preis</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Seriös ab</span><br /><strong>{euro(psi.lowestLegit)}</strong></div> <div><span class="note">Fair ab</span><br /><strong class="num">{euro(pi.lowestLegit)}</strong></div>
<div><span class="note">Märkte mit Bestand</span><br /><strong>{psi.storesAvailable}</strong></div> <div><span class="note">Wucher ab</span><br /><strong class="num">{euro(pi.wucherThreshold)}</strong></div>
</div>
{psi.wucher && <p class="wucher">⚠ <strong>Wucher-Warnung:</strong> Drittanbieter verlangen bis {euro(psi.wucher.price)} rund {Math.round((psi.wucher.price/psi.fairPrice-1)*100)} % über üblich. Lieber Alarm setzen oder Alternative wählen.</p>}
<div class="status-cta">
<a class="btn sm" href="/karte?produkt=midea-portasplit-12000">Auf der Karte</a>
<a class="btn sm ghost" href="/alarm?produkt=midea-portasplit-12000">Alarm setzen</a>
</div> </div>
<p class="wucher">⚠ <strong>Wucher-Warnung:</strong> Drittanbieter (z. B. Amazon-Marketplace) verlangen bei Knappheit das Mehrfache des üblichen Preises. Lieber Alarm setzen oder Alternative wählen.</p>
<div class="status-cta"><a class="btn sm" href="/karte?modell=midea-portasplit-12000">Auf der Karte</a><a class="btn sm ghost" href="/alarm?produkt=midea-portasplit-12000">Alarm setzen</a></div>
</div> </div>
<h2 style="margin-top:1.6rem">Wohnungstaugliche Alternativen</h2> <h2 style="margin-top:1.6rem">Wohnungstaugliche Alternativen</h2>
<p class="muted">Geräte, die in normalen Wohnungen wirklich kühlen nicht nur „auch verfügbar“.</p> <div class="grid cards2">{alts.map(p=> <ProductCard product={p} />)}</div>
<div class="grid cards2">
{alts.map(p=> <ProductCard product={p} />)}
</div>
</section> </section>
</Base> </Base>
<style> <style>.sg{display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:.5rem}.sg strong{font-size:1.25rem}.wucher{background:var(--danger-bg);color:var(--danger);padding:.7rem .9rem;border-radius:var(--radius)}.status-cta{display:flex;gap:.5rem;flex-wrap:wrap}.cards2{grid-template-columns:1fr 1fr}@media(max-width:820px){.cards2{grid-template-columns:1fr}}</style>
.sg{display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:.5rem}.sg strong{font-size:1.25rem}
.wucher{background:var(--danger-bg);color:var(--danger);padding:.7rem .9rem;border-radius:var(--radius)}
.status-cta{display:flex;gap:.5rem;flex-wrap:wrap}
.cards2{grid-template-columns:1fr 1fr}
@media(max-width:820px){.cards2{grid-template-columns:1fr}}
</style>
+23
View File
@@ -77,3 +77,26 @@ footer.site .wrap{padding-top:2rem;padding-bottom:2rem}
.skel{background:linear-gradient(90deg,var(--line),var(--surface-2),var(--line));background-size:200% 100%;animation:sh 1.2s infinite;border-radius:6px} .skel{background:linear-gradient(90deg,var(--line),var(--surface-2),var(--line));background-size:200% 100%;animation:sh 1.2s infinite;border-radius:6px}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}} @keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}} @media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}
/* --- v3 SaaS polish --- */
:root{
--shadow-card:0 1px 2px oklch(0.45 0.03 250 / .05), 0 8px 24px oklch(0.45 0.05 250 / .06);
--shadow-pop:0 12px 40px oklch(0.4 0.06 250 / .14);
--grad-hero:radial-gradient(900px 420px at 80% -10%, oklch(0.93 0.05 200 / .9), transparent 60%), linear-gradient(180deg, var(--petrol-soft), var(--light));
}
body{background:var(--light)}
.card{box-shadow:var(--shadow-card);border-color:oklch(0.92 0.008 240)}
.card.flat{box-shadow:none}
.btn{box-shadow:0 1px 2px oklch(0.3 0.05 230 / .12)}
.btn:hover{transform:translateY(-1px)}
header.site{box-shadow:0 1px 0 oklch(0.9 0.01 240)}
header.site .brand{letter-spacing:-.02em}
.nav-cta{background:var(--petrol);color:#fff!important;padding:.5rem .9rem;border-radius:999px;font-weight:600!important}
.nav-cta:hover{background:var(--petrol-deep)}
.eyebrow{display:inline-flex;align-items:center;gap:.5rem;font-size:.78rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--petrol-deep);background:oklch(1 0 0 / .6);border:1px solid var(--line);padding:.3rem .7rem;border-radius:999px}
.feature{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:1.4rem;box-shadow:var(--shadow-card);transition:transform .15s,box-shadow .15s}
.feature:hover{transform:translateY(-3px);box-shadow:var(--shadow-pop)}
.feature .ic{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;background:var(--petrol-soft);color:var(--petrol-deep);font-size:1.2rem;margin-bottom:.7rem}
.pill-stat{display:inline-flex;align-items:center;gap:.5rem;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:.45rem .9rem;box-shadow:var(--shadow-card);font-size:.9rem}
.pill-stat b{font-family:var(--mono);color:var(--petrol-deep)}
.section-head{display:flex;align-items:end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}