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">
<div class="pc-top">
<span class="badge">{product.badge}</span>
{product.apartmentSuitable
? <span class="badge tag-ok">wohnungstauglich</span>
: <span class="badge">Spezialfall</span>}
{product.apartmentSuitable ? <span class="badge tag-ok">wohnungstauglich</span> : <span class="badge">Spezialfall</span>}
</div>
<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>
{!compact && <p>{product.shortVerdict}</p>}
<div class="pc-price">
<div><span class="note">Üblicher Preis</span><br /><strong>{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Aktuell ab</span><br /><strong>{euro(pi.lowestLegit)}</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><span class="note">Üblicher Preis</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Score</span><br /><strong class="num">{product.score}/100</strong></div>
</div>
<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>
</div>
</article>
<style>
.pc{display:flex;flex-direction:column;gap:.5rem}
.pc-top{display:flex;gap:.4rem;flex-wrap:wrap}
.pc h3{margin:.2rem 0 0}
.pc h3 a{color:var(--ink);text-decoration:none}
.pc h3 a:hover{color:var(--petrol)}
.pc h3{margin:.2rem 0 0}.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 strong{font-size:1.15rem}
.pc-avail{display:flex;gap:.4rem;flex-wrap:wrap}
.pc-price strong{font-size:1.1rem}
.pc-cta{display:flex;gap:.5rem;margin-top:.4rem;flex-wrap:wrap}
</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">
<a class="brand" href="/">Kühl<b>finder</b></a>
<nav>
<a href="/finder">Finder</a>
<a href="/geraete">Geräte</a>
<a href="/karte">Karte</a>
<a href="/portasplit">PortaSplit</a>
<a href="/finder">Finder</a>
<a href="/alarm">Alarm</a>
<a class="nav-cta" href="/karte">Live-Karte</a>
</nav>
</div></header>
<main><slot /></main>
+4 -59
View File
@@ -1,65 +1,10 @@
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 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 allStores = stores as Store[];
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 function getProduct(slug: string){ return allProducts.find(p=>p.slug===slug); }
export const WUCHER_FACTOR = 1.3;
export function getProduct(slug: string) {
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 euro(n: number | null){ return n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n); }
export interface PriceIntel { fairPrice:number; lowestLegit:number; wucherThreshold:number; }
export function priceIntel(p: Product): PriceIntel {
const offers = offersFor(p.id);
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);
return { fairPrice: p.uvp, lowestLegit: Math.round(p.uvp*0.92), wucherThreshold: Math.round(p.uvp*WUCHER_FACTOR) };
}
+17 -55
View File
@@ -1,20 +1,14 @@
---
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 } })); }
const { slug } = Astro.params;
const product = getProduct(slug!)!;
const pi = priceIntel(product);
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 jsonld = {
'@context':'https://schema.org','@type':'Product',name:product.name,brand:product.brand,
category:'Mobile Klimaanlage',
offers:{'@type':'AggregateOffer',priceCurrency:'EUR',lowPrice:pi.lowestLegit ?? product.uvp,highPrice:product.uvp,offerCount:offersFor(product.id).length}
};
const jsonld = {'@context':'https://schema.org','@type':'Product',name:product.name,brand:product.brand,category:'Mobile Klimaanlage',
offers:{'@type':'AggregateOffer',priceCurrency:'EUR',lowPrice:pi.lowestLegit,highPrice:product.uvp}};
---
<Base title={`${product.name} Test, Eignung & Verfügbarkeit | Kühlfinder`} description={product.shortVerdict} jsonld={jsonld}>
<section class="wrap" style="margin-top:1.4rem">
@@ -24,75 +18,43 @@ const jsonld = {
<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="lead">{product.shortVerdict}</p>
<div class="price-box card">
<div class="status-grid">
<div><span class="note">Üblicher Preis</span><br /><strong>{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">Ø seriös</span><br /><strong>{euro(pi.avgLegit)}</strong></div>
<div><span class="note">Märkte mit Bestand</span><br /><strong>{pi.storesAvailable}</strong></div>
<div class="sg">
<div><span class="note">Üblicher Preis (UVP)</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Fair ab</span><br /><strong class="num">{euro(pi.lowestLegit)}</strong></div>
<div><span class="note">Wucher ab</span><br /><strong class="num">{euro(pi.wucherThreshold)}</strong></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">
<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>
</div>
</div>
{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 muss sicher und rücksichtsvoll platziert werden. Prüfe Fensterbank/Balkon, Nachbarn und Mietvertrag.</p>}
{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==='mobile_split' && <p class="hint">Wichtig: Das Außengerät sicher und rücksichtsvoll platzieren Fensterbank/Balkon, Nachbarn, Mietvertrag prüfen.</p>}
<div class="cols">
<div class="card">
<h3>Geeignet für</h3>
<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 class="card"><h3>Geeignet für</h3><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 class="cols">
<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>
{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>
<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>)}
</div>
<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>
</section>
</Base>
<style>
.lead{font-size:1.1rem;color:var(--slate);max-width:46rem}
.price-box{margin:1rem 0}
.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}
.price-box{margin:1rem 0}.sg{display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:.6rem}.sg strong{font-size:1.25rem}
.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}
.stores{grid-template-columns:repeat(2,1fr)}
.srow{display:flex;justify-content:space-between;align-items:center}
.altlink{text-decoration:none;color:var(--ink)}
.altlink:hover{border-color:var(--petrol)}
.stores{grid-template-columns:repeat(3,1fr)}
.altlink{text-decoration:none;color:var(--ink)}.altlink:hover{border-color:var(--petrol)}
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>
+84 -69
View File
@@ -1,88 +1,103 @@
---
import Base from '../layouts/Base.astro';
import ProductCard from '../components/ProductCard.astro';
import { allProducts, priceIntel, euro, getProduct, dataUpdatedAt } from '../lib/pricing';
const top = allProducts.filter(p => p.tier === 'A');
const ps = getProduct('midea-portasplit-12000')!;
const psi = priceIntel(ps);
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.'
};
import { allProducts } from '../lib/pricing';
const top = allProducts.filter(p => p.tier === 'A').slice(0,3);
const jsonld = {'@context':'https://schema.org','@type':'WebSite',name:'Kühlfinder',url:'https://kuehlfinder.de',
description:'Finde, wo es gerade Klimageräte in Baumärkten und Elektromärkten gibt mit Prognose, Preis-Check und Alarm.'};
---
<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">
<p class="kicker"><span class="u"></span>Ehrlicher Klima-Finder r Mietwohnungen</p>
<h1>Finde ein gutes portables Klimagerät und sieh, wo es heute verfügbar ist.</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>
<div class="modes">
<a class="mode" href="/finder"><strong>Ich filtere</strong><span>Welches Gerät passt zu meinem Raum?</span></a>
<a class="mode" href="/karte"><strong>Ich sehe die Karte</strong><span>Alle Märkte mit aktuellem Bestand</span></a>
<a class="mode" href="/alarm"><strong>Ich lasse mich benachrichtigen</strong><span>Alarm bei Verfügbarkeit in der Nähe</span></a>
</div>
</div></section>
<section class="wrap"><div class="status card">
<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>
<span class="eyebrow"><span class="dot ok"></span>Live-Verfügbarkeit · eigener Scraper</span>
<h1>Wo gibt es gerade ein&nbsp;Klimagerät?</h1>
<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>
<form class="herobar" action="/karte" method="get">
<input name="plz" inputmode="numeric" placeholder="PLZ oder Ort eingeben …" aria-label="PLZ oder Ort" />
<button class="btn" type="submit">Verfügbarkeit zeigen</button>
</form>
<div class="pills" id="livepills">
<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>
<span class="pill-stat"><b id="p-chains">10</b> Ketten · Baumarkt + Elektro</span>
</div>
</div></section>
<section class="wrap">
<h2><span class="u"></span>Top-Empfehlungen</h2>
<p class="muted">Kuratiert nach Bauart, Kühlleistung, Effizienz, Nutzungsfall und glaubwürdigen Quellen.</p>
<div class="grid cards3">
{top.map(p => <ProductCard product={p} />)}
<div class="features">
<a class="feature" href="/karte">
<div class="ic"><i class="ti">◎</i></div>
<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>
<p style="margin-top:1rem"><a class="btn ghost" href="/geraete">Alle Geräte &amp; Vergleich</a></p>
</section>
<section class="wrap"><div class="split">
<div class="card">
<h2>PortaSplit ausverkauft?</h2>
<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" href="/portasplit">Sinnvolle Alternativen</a>
<section class="wrap">
<div class="section-head">
<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>
<a class="btn ghost sm" href="/geraete">Alle Geräte &amp; Vergleich</a>
</div>
<div class="card warm">
<h2>Warum Kühlung wichtiger wird</h2>
<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>
<div class="grid cards3">{top.map(p => <ProductCard product={p} />)}</div>
</section>
<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></section>
</Base>
<style>
.hero{background:linear-gradient(180deg,var(--petrol-soft),var(--light));padding:3rem 0 2.5rem}
.kicker{font-weight:600;color:var(--petrol-deep);margin:0 0 .4rem;text-transform:uppercase;letter-spacing:.04em;font-size:.8rem}
.lead{font-size:1.15rem;max-width:46rem;color:var(--slate)}
.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-top:1.6rem}
.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}
.mode:hover{border-color:var(--petrol);transform:translateY(-2px)}
.mode strong{font-size:1.05rem;color:var(--petrol-deep)}
.mode span{font-size:.9rem;color:var(--slate)}
section.wrap{margin-top:2.5rem}
.status{border-left:4px solid var(--warm)}
.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}
.hero{background:var(--grad-hero);padding:3.4rem 0 2.6rem}
.hero h1{font-size:clamp(2.3rem,6vw,4rem);max-width:18ch;margin-top:.6rem}
.hero .lead{font-size:1.18rem;max-width:46rem;color:var(--ink-2)}
.herobar{display:flex;gap:.6rem;margin:1.4rem 0 1rem;max-width:520px}
.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)}
.herobar input:focus{border-color:var(--petrol)}
.pills{display:flex;gap:.6rem;flex-wrap:wrap}
section.wrap{margin-top:2.6rem}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}
.features .feature{text-decoration:none;color:var(--ink)} .features h3{margin:.2rem 0}
.cards3{grid-template-columns:repeat(3,1fr)}
.split{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.card.warm{background:var(--warm-bg)}
@media(max-width:820px){.modes,.cards3,.split{grid-template-columns:1fr}}
.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}
.bandcta{display:flex;gap:.6rem;margin-top:1rem;flex-wrap:wrap}
.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>
<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';
const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Monitor',applicationCategory:'ShoppingApplication',
description:'Live-Verfügbarkeit der Midea PortaSplit, PortaSplit Cool und Universalhalterung in über 1.160 Baumärkten, mit Verfügbarkeits-Prognose.'};
import { allProducts } from '../lib/pricing';
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">
<div class="mon-hero-row">
<div>
<p class="kicker"><span class="u"></span>PortaSplit Monitor · Live</p>
<h1>Wo gibt es die Midea PortaSplit gerade?</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>
<span class="eyebrow"><span class="dot ok"></span>Live · eigener Scraper</span>
<h1>Wo gibt es gerade ein Klimagerät?</h1>
<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 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 class="stats" id="stats">
<div class="stat"><span class="note">Filialen</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 Cool</span><b class="num" data-k="withPortaSplitCool"></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">Märkte</span><b class="num" data-k="storesTotal"></b></div>
<div class="stat ok"><span class="note">mit Klimagerät</span><b class="num" data-k="withAC"></b></div>
<div class="stat"><span class="note">Baumärkte</span><b class="num" data-k="baumarkt"></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 warn"><span class="note">Wucher-Alarm</span><b class="num" id="s-wucher"></b></div>
</div>
@@ -27,227 +29,182 @@ const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'K
<div class="mapcol">
<div id="map"></div>
<div class="legend">
<span><span class="dot ok"></span>verfügbar</span>
<span><span class="dot" style="background:var(--petrol)"></span>Filiale (Cluster)</span>
<span><i class="hot"></i>hohe Prognose</span>
<span><span class="dot ok"></span>Klimagerät verfügbar</span>
<span><span class="dot" style="background:var(--petrol)"></span>Markt (Cluster)</span>
</div>
</div>
<aside class="feedcol card">
<h2 class="h-sm">Letzte Verfügbarkeiten</h2>
<p class="note">Echte 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>
<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></ol>
</aside>
</section>
<section class="wrap">
<div class="toolbar card">
<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>
<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 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" 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="note">Kette:</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 class="onlinebar card" id="onlinebar"></div>
<div class="tablewrap card">
<table class="mtab">
<thead><tr>
<th>Filiale</th>
<th class="ce" data-sort="ps">PortaSplit</th>
<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>
<table class="mtab"><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>
</tr></thead><tbody id="rows"><tr><td colspan="5"><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 Märkte</button><p class="note" id="rowcount"></p></div>
</div>
<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>
<a class="btn" href="/alarm?produkt=midea-portasplit-12000">Verfügbarkeits-Alarm</a>
<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">Alarm einrichten</a>
</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>
</Base>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<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 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}
.upd{min-width:140px;text-align:center}
.upd-t{font-size:1.6rem;font-weight:600;color:var(--petrol-deep)}
.upd{min-width:140px;text-align:center}.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}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem;display:flex;flex-direction:column;gap:.1rem}
.stat b{font-size:1.45rem;line-height:1.1}
.stat.ok b{color:var(--ok-dot)} .stat.warn b{color:var(--danger)}
.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.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}
.mapcol{position:sticky;top:74px}
#map{height:520px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)}
.legend{display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;color:var(--slate);margin-top:.5rem;align-items:center}
.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}
.feed{list-style:none;margin:.6rem 0 0;padding:0;display:flex;flex-direction:column;gap:.1rem}
#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}
.feedcol{max-height:560px;overflow:auto}.h-sm{font-size:1.15rem;margin:.1rem 0 .2rem}
.feed{list-style:none;margin:.6rem 0 0;padding:0}
.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 span{font-size:.8rem;color:var(--slate)}
.feed .ft{font-size:.8rem;color:var(--slate);white-space:nowrap;font-family:var(--mono)}
.feed .fl b{font-weight:600;font-size:.9rem}.feed .fl span{font-size:.8rem;color:var(--slate)}
.feed .ft{font-size:.78rem;color:var(--slate);white-space:nowrap;font-family:var(--mono)}
.toolbar{margin-top:1.4rem;display:flex;flex-direction:column;gap:.7rem}
.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}
#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)}
.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,border-color .15s}
.chip:active{transform:scale(.97)}
.chip.on{background:var(--petrol);color:#fff;border-color:var(--petrol)}
.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: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)}
.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}
.osrc{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;min-width:120px}
.osrc b{font-size:.85rem}.osrc .num{font-size:.9rem}
.osrc.wucher{border-color:var(--danger);background:var(--danger-bg)}
.osrc.wucher .num{color:var(--danger)}
.osrc{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:10px;padding:.4rem .65rem;min-width:120px}
.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 .num{color:var(--danger)}
.tablewrap{margin-top:.8rem;padding:0;overflow:hidden}
.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 th.ce{text-align:center}
.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;cursor:pointer}
.mtab td{padding:.6rem .8rem;border-bottom:1px solid var(--line);vertical-align:middle}
.mtab tbody tr:hover{background:var(--petrol-soft)}
.mtab .fname{font-weight:600;font-size:.92rem}.mtab .faddr{font-size:.78rem;color:var(--slate)}
.cell{display:flex;flex-direction:column;align-items:center;gap:.15rem;line-height:1.2}
.cell .num{font-size:.85rem}
.cell .pa{font-size:.72rem;color:var(--ok-dot);font-weight:600}
.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)}
.fname{font-weight:600;font-size:.92rem}.faddr{font-size:.78rem;color:var(--slate)}
.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)}
.ce .num{font-size:.86rem}
.stk{font-size:.72rem;font-weight:600}.stk.ok{color:var(--ok-dot)}.stk.off{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)}
.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}
@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>
<script>
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 FAIR={ps:849,co:779,ha:70};
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 $=s=>document.querySelector(s),$$=s=>Array.from(document.querySelectorAll(s));
let DATA=null,center=null,shown=120;
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 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});
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){
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}}))};
map.getSource('all').setData(all); map.getSource('av').setData(av);
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);
}
function render(){
const list=DATA.stores.filter(passFilter);
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 {list.sort((a,b)=>(storeBest(b).avail?1:0)-(storeBest(a).avail?1:0)||storeBest(b).bestProb-storeBest(a).bestProb);}
// map
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));
else if(state.chance)list.sort((a,b)=>(status(b)?.q||0)-(status(a)?.q||0));
else list.sort((a,b)=>(status(b)?.a?1:0)-(status(a)?.a?1:0)||(status(b)?.q||0)-(status(a)?.q||0));
setMap(list);
// table
const tb=$('#rows'); tb.innerHTML='';
const slice=list.slice(0,shown);
const cell=(o)=>{ if(!o)return '<td class="ce"><span class="pn"></span></td>';
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 tb=$('#rows');tb.innerHTML='';
for(const s of list.slice(0,shown)){
const st=status(s);
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 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';
$('#distcol').style.display=center?'table-cell':'none';
}
function fillStatic(){
$('#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'));
const r7=DATA.feed.filter(f=>(Date.now()-new Date(f.at))/86400000<=7).length;
$('#s-restocks').textContent=r7+(r7>=DATA.feed.length?'+':'');
$('#s-wucher').textContent=DATA.online.filter(o=>o.wucher).length;
$('#h-feednote').textContent='Feed live';
const wuch=DATA.online.filter(o=>o.ps&&o.ps.wucher).length;
$('#s-wucher').textContent=wuch;
// 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
$('#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('');
$('#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('');
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 .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.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-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.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}));});
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();});
$('#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();});
$('#more').addEventListener('click',()=>{shown+=120;render();});
$('#reset').addEventListener('click',()=>{location.reload();});
$$('.mtab th[data-sort]').forEach(th=>th.addEventListener('click',()=>{state.chance=(th.dataset.sort==='q');render();}));
$('#reset').addEventListener('click',()=>location.reload());
$$('.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();});});
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{DATA=d;fillStatic();if(map.loaded())render();else render();});
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{DATA=d;fillStatic();render();}).catch(()=>{$('#rowcount').textContent='Daten konnten nicht geladen werden';});
</script>
+8 -11
View File
@@ -2,20 +2,17 @@ import { allProducts, priceIntel, euro } from '../lib/pricing';
export const GET = () => {
const lines = [
'# 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',
'- Nur echte Kompressorgeräte (mobile Split, Monoblock) keine Verdunstungs-/USB-Kühler.',
'- Live-Verfügbarkeit pro Baumarkt-Filiale auf einer Deutschlandkarte (idealo zeigt keinen Filialbestand).',
'- Warnung vor Wucherpreisen bei Knappheit (z. B. Midea PortaSplit).',
'- Fokus auf wohnungstaugliche Geräte und PortaSplit-Alternativen.',
'- Zeigt für ALLE kuratierten Klimageräte, welche Märkte sie aktuell vorrätig haben (idealo zeigt keinen Filialbestand).',
'- Verfügbarkeits-Prognose aus der Restock-Historie (Chance in den nächsten 7 Tagen).',
'- Warnung vor Wucherpreisen bei Knappheit.',
'- Beratung (60-Sekunden-Finder) + wohnungstaugliche Alternativen, wenn ein Gerät ausverkauft ist.',
'',
'## Geräte',
];
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}`);
}
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' } });
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}`); }
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');
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 ProductCard from '../components/ProductCard.astro';
import { allProducts, getProduct, priceIntel, euro } from '../lib/pricing';
const ps = getProduct('midea-portasplit-12000')!;
const psi = priceIntel(ps);
const ps = getProduct('midea-portasplit-12000')!; const pi = 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 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:'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 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:'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">
<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="sg">
<div><span class="note">Üblicher Preis</span><br /><strong>{euro(psi.fairPrice)}</strong></div>
<div><span class="note">Seriös 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> 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><span class="note">Üblicher Preis</span><br /><strong class="num">{euro(pi.fairPrice)}</strong></div>
<div><span class="note">Fair ab</span><br /><strong class="num">{euro(pi.lowestLegit)}</strong></div>
<div><span class="note">Wucher ab</span><br /><strong class="num">{euro(pi.wucherThreshold)}</strong></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>
<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>
</Base>
<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>
<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}
@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}}
/* --- 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}