v5: Mollie (EU) statt Stripe, Sitemap + llms-full.txt, /prognose-Methodik, ausgebauter Datenschutz/Transparenz, Scraper-Adapter toom/bauhaus/hornbach + compose/cron-ready, checkedAt-Stempel im Monitor

This commit is contained in:
2026-06-28 07:26:21 +00:00
parent 7636a00d9f
commit a266b14482
18 changed files with 265 additions and 11 deletions
+2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://kuehlfinder.de',
output: 'static',
integrations: [sitemap()],
build: { inlineStylesheets: 'auto' }
});
+76
View File
@@ -8,6 +8,7 @@
"name": "kuehlfinder",
"version": "0.1.0",
"dependencies": {
"@astrojs/sitemap": "^3.2.0",
"@fontsource/fraunces": "^5.0.0",
"@fontsource/public-sans": "^5.0.0",
"astro": "^5.7.0",
@@ -67,6 +68,26 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/@astrojs/sitemap": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
"integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
"license": "MIT",
"dependencies": {
"sitemap": "^9.0.0",
"stream-replace-string": "^2.0.0",
"zod": "^4.3.6"
}
},
"node_modules/@astrojs/sitemap/node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@astrojs/telemetry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
@@ -1649,12 +1670,30 @@
"@types/unist": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@@ -1787,6 +1826,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -4611,6 +4656,25 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/sitemap": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.9.2",
"@types/sax": "^1.2.1",
"arg": "^5.0.0",
"sax": "^1.4.1"
},
"bin": {
"sitemap": "dist/esm/cli.js"
},
"engines": {
"node": ">=20.19.5",
"npm": ">=10.8.2"
}
},
"node_modules/smol-toml": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
@@ -4642,6 +4706,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stream-replace-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -4851,6 +4921,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+2 -1
View File
@@ -12,6 +12,7 @@
"astro": "^5.7.0",
"@fontsource/fraunces": "^5.0.0",
"@fontsource/public-sans": "^5.0.0",
"maplibre-gl": "^4.7.1"
"maplibre-gl": "^4.7.1",
"@astrojs/sitemap": "^3.2.0"
}
}
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY scraper/ ./scraper/
COPY public/data ./public/data
WORKDIR /app/scraper
# Coolify Scheduled Task: "node run.mjs" (z.B. */30 * * * *)
CMD ["node","run.mjs"]
+18
View File
@@ -0,0 +1,18 @@
// Bauhaus-Adapter (Muster wie OBI: Produktseite → Preis via JSON-LD + Markt-Status-Heuristik).
// TODO: products{} mit echten Produkt-URLs/Artikelnummern füllen; Per-Markt-Endpoint ergänzen.
const UA = 'KuehlfinderBot/0.1 (+https://kuehlfinder.de/bot)';
export const meta = { id: 'bauhaus', name: 'Bauhaus', kind: 'baumarkt' };
export const products = { /* 'portasplit': 'https://www....' */ };
function jsonld(html){const o=[];const re=/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;let m;while((m=re.exec(html))){try{o.push(JSON.parse(m[1]));}catch{}}return o;}
function offer(ld){for(const n of (Array.isArray(ld)?ld:[ld])){const g=n['@graph']||[n];for(const x of g){if(x&&(x['@type']==='Product'||x.offers)){const of=Array.isArray(x.offers)?x.offers[0]:x.offers;if(of)return{price:Number(of.price??of.lowPrice),availability:String(of.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('Bauhaus '+res.status);
const html=await res.text(); const o=jsonld(html).map(offer).find(Boolean);
const inStore=/vorrätig|verfügbar/i.test(html)&&!/nicht (vorrätig|verfügbar)/i.test(html);
const price=o?.price ?? Number((html.match(/(\d{2,4})[,.](\d{2})\s*€/)||[])[0]?.replace(/[^\d,]/g,'').replace(',','.'));
return { productId, source:'bauhaus', marketId:marketId??null, price: price||null,
availability: inStore?'in_stock':(o&&/InStock/i.test(o.availability)?'online_available':'out_of_stock'),
checkedAt:new Date().toISOString(), url };
}
export async function fetchStores(){ return []; }
+18
View File
@@ -0,0 +1,18 @@
// Hornbach-Adapter (Muster wie OBI: Produktseite → Preis via JSON-LD + Markt-Status-Heuristik).
// TODO: products{} mit echten Produkt-URLs/Artikelnummern füllen; Per-Markt-Endpoint ergänzen.
const UA = 'KuehlfinderBot/0.1 (+https://kuehlfinder.de/bot)';
export const meta = { id: 'hornbach', name: 'Hornbach', kind: 'baumarkt' };
export const products = { /* 'portasplit': 'https://www....' */ };
function jsonld(html){const o=[];const re=/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;let m;while((m=re.exec(html))){try{o.push(JSON.parse(m[1]));}catch{}}return o;}
function offer(ld){for(const n of (Array.isArray(ld)?ld:[ld])){const g=n['@graph']||[n];for(const x of g){if(x&&(x['@type']==='Product'||x.offers)){const of=Array.isArray(x.offers)?x.offers[0]:x.offers;if(of)return{price:Number(of.price??of.lowPrice),availability:String(of.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('Hornbach '+res.status);
const html=await res.text(); const o=jsonld(html).map(offer).find(Boolean);
const inStore=/vorrätig|verfügbar/i.test(html)&&!/nicht (vorrätig|verfügbar)/i.test(html);
const price=o?.price ?? Number((html.match(/(\d{2,4})[,.](\d{2})\s*€/)||[])[0]?.replace(/[^\d,]/g,'').replace(',','.'));
return { productId, source:'hornbach', marketId:marketId??null, price: price||null,
availability: inStore?'in_stock':(o&&/InStock/i.test(o.availability)?'online_available':'out_of_stock'),
checkedAt:new Date().toISOString(), url };
}
export async function fetchStores(){ return []; }
+18
View File
@@ -0,0 +1,18 @@
// Toom-Adapter (Muster wie OBI: Produktseite → Preis via JSON-LD + Markt-Status-Heuristik).
// TODO: products{} mit echten Produkt-URLs/Artikelnummern füllen; Per-Markt-Endpoint ergänzen.
const UA = 'KuehlfinderBot/0.1 (+https://kuehlfinder.de/bot)';
export const meta = { id: 'toom', name: 'Toom', kind: 'baumarkt' };
export const products = { /* 'portasplit': 'https://www....' */ };
function jsonld(html){const o=[];const re=/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;let m;while((m=re.exec(html))){try{o.push(JSON.parse(m[1]));}catch{}}return o;}
function offer(ld){for(const n of (Array.isArray(ld)?ld:[ld])){const g=n['@graph']||[n];for(const x of g){if(x&&(x['@type']==='Product'||x.offers)){const of=Array.isArray(x.offers)?x.offers[0]:x.offers;if(of)return{price:Number(of.price??of.lowPrice),availability:String(of.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('Toom '+res.status);
const html=await res.text(); const o=jsonld(html).map(offer).find(Boolean);
const inStore=/vorrätig|verfügbar/i.test(html)&&!/nicht (vorrätig|verfügbar)/i.test(html);
const price=o?.price ?? Number((html.match(/(\d{2,4})[,.](\d{2})\s*€/)||[])[0]?.replace(/[^\d,]/g,'').replace(',','.'));
return { productId, source:'toom', marketId:marketId??null, price: price||null,
availability: inStore?'in_stock':(o&&/InStock/i.test(o.availability)?'online_available':'out_of_stock'),
checkedAt:new Date().toISOString(), url };
}
export async function fetchStores(){ return []; }
+19
View File
@@ -0,0 +1,19 @@
# Cron-ready Setup: Scraper schreibt availability/history in ein gemeinsames Volume,
# das der nginx-Webcontainer unter /data ausliefert → Seite wird live ohne Rebuild.
# In Coolify als "Docker Compose"-Resource deploybar (ersetzt später den reinen static-Deploy).
services:
web:
build: { context: .., dockerfile: Dockerfile }
volumes:
- livedata:/usr/share/nginx/html/data
ports: ["80"]
scraper:
build: { context: ., dockerfile: Dockerfile.scraper }
environment:
- TZ=Europe/Berlin
volumes:
- livedata:/app/public/data
# Cron via Coolify Scheduled Task: command = node run.mjs (alle 30 Min)
restart: unless-stopped
volumes:
livedata:
+4 -1
View File
@@ -3,8 +3,11 @@
// 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';
import * as toom from './adapters/toom.mjs';
import * as bauhaus from './adapters/bauhaus.mjs';
import * as hornbach from './adapters/hornbach.mjs';
const ADAPTERS = [obi /*, toom, bauhaus, hornbach, mediamarkt, saturn, expert, euronics, amazon */];
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) {
+1 -1
View File
@@ -58,7 +58,7 @@ import '../styles/global.css';
<footer class="site"><div class="wrap">
<p class="note"><strong>Transparenz:</strong> Einige Links sind Affiliate-Links kaufst du darüber, erhalten wir ggf. eine Provision, für dich ändert sich der Preis nicht. Empfehlungen basieren auf Nutzungsfall, Technik, Testquellen und redaktioneller Einschätzung nicht auf Provision.</p>
<p class="note">Preise und Verfügbarkeit ändern sich während Hitzewellen sehr schnell. Wir halten Daten so aktuell wie möglich, geben aber keine Garantie. Bitte beim Händler final prüfen.</p>
<p class="note"><a href="/rechner">Klima-Rechner</a> · <a href="/soforthilfe">Hitze-Soforthilfe</a> · <a href="/klimaanlage-installieren">Fest installieren</a> · <a href="/portasplit">PortaSplit</a> · <a href="/hitzeschutz">Hitzeschutz</a></p>
<p class="note"><a href="/rechner">Klima-Rechner</a> · <a href="/soforthilfe">Hitze-Soforthilfe</a> · <a href="/klimaanlage-installieren">Fest installieren</a> · <a href="/portasplit">PortaSplit</a> · <a href="/hitzeschutz">Hitzeschutz</a> · <a href="/prognose">Prognose-Methodik</a></p>
<p class="note"><a href="/transparenz">Transparenz &amp; Methodik</a> · <a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a> · © {new Date().getFullYear()} Kühlfinder</p>
</div></footer>
</body>
+10
View File
@@ -0,0 +1,10 @@
// Zahlungsanbieter: Mollie (EU, Niederlande) europäische Stripe-Alternative.
// Einmalzahlung Pro-Pass via Mollie Payments API; Webhook bestätigt → Pro-Flag/Token.
// Alternative bei USt-Komplexität: Paddle (Merchant of Record, übernimmt EU-VAT).
// TODO: MOLLIE_API_KEY (live/test) als Server-Env setzen; Checkout-Endpoint im Backend.
export const PAYMENT = {
provider: 'mollie' as const,
proPassEUR: 4.9,
// Server-seitig: createPayment({amount:{currency:'EUR',value:'4.90'}, description:'Kühlfinder Pro-Pass', redirectUrl, webhookUrl})
apiKeyEnv: 'MOLLIE_API_KEY',
};
+21 -3
View File
@@ -2,9 +2,27 @@
import Base from '../layouts/Base.astro';
---
<Base title="Datenschutz | Kühlfinder">
<section class="wrap prose" style="margin-top:1.6rem;max-width:720px">
<section class="wrap prose" style="margin-top:1.6rem;max-width:740px">
<h1>Datenschutz</h1>
<p><em>Platzhalter, vor Go-Live finalisieren.</em></p>
<p>Diese Seite wird statisch ausgeliefert. Kartenkacheln werden von OpenFreeMap geladen; dabei wird deine IP an den Tile-Server übertragen (vor Go-Live ggf. self-hosten). Standortabfrage erfolgt nur nach deiner ausdrücklichen Zustimmung im Browser. Der Verfügbarkeits-Alarm speichert E-Mail und PLZ ausschließlich zur Benachrichtigung (Backend folgt, EU-Hosting).</p>
<p class="note"><em>Vorlage vor Go-Live mit Verantwortlichem/Rechtsberatung finalisieren.</em></p>
<h2>Verantwortlicher</h2>
<p>Heidrich Digital, [Adresse], mail@tillheidrich.de. (Platzhalter)</p>
<h2>Hosting</h2>
<p>Die Seite wird auf Servern in der EU (Hetzner, Deutschland) via Coolify ausgeliefert. Es werden Server-Logs zur Sicherheit verarbeitet.</p>
<h2>Analyse: Umami</h2>
<p>Wir nutzen das selbst gehostete, <strong>cookielose</strong> Analyse-Tool Umami (EU-Server). Es speichert keine personenbezogenen Profile und setzt keine Tracking-Cookies; erfasst werden anonymisierte Nutzungsdaten (Seitenaufrufe, Klick-Events wie „Preis prüfen"). Daher i. d. R. kein Cookie-Banner nötig.</p>
<h2>Karte &amp; Standort</h2>
<p>Kartenkacheln werden von <strong>OpenFreeMap</strong> geladen (dabei wird deine IP an den Tile-Server übertragen). Die Standortbestimmung erfolgt nur nach deiner ausdrücklichen Zustimmung im Browser und wird nicht gespeichert.</p>
<h2>Wetter</h2>
<p>Für den Wetterhinweis rufen wir die <strong>Open-Meteo</strong>-API auf (Standort grob bzw. Default). Es werden keine personenbezogenen Daten dauerhaft gespeichert.</p>
<h2>Alarm, Preiswecker &amp; Konto</h2>
<p>Für Benachrichtigungen speichern wir E-Mail, PLZ/Umkreis und deine Watchlist ausschließlich zur Zustellung. Versand über EU-Dienst (Listmonk, self-hosted) und optional Telegram. Abbestellung jederzeit.</p>
<h2>Zahlung</h2>
<p>Der Pro-Pass wird über <strong>Mollie</strong> (Mollie B.V., Niederlande/EU) abgewickelt. Zahlungsdaten verarbeitet Mollie als Zahlungsdienstleister; wir erhalten nur den Zahlungsstatus.</p>
<h2>Affiliate-Links</h2>
<p>Ausgehende Kauf-/Preis-Links können Partner-Parameter (Affiliate/UTM) enthalten. Erst beim Klick wird zum jeweiligen Händler weitergeleitet; dort gelten dessen Datenschutzbestimmungen.</p>
<h2>Deine Rechte</h2>
<p>Auskunft, Berichtigung, Löschung, Widerspruch Kontakt: mail@tillheidrich.de.</p>
</section>
</Base>
<style>.prose h2{margin-top:1.3rem;font-size:1.15rem}</style>
+3 -3
View File
@@ -166,7 +166,7 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
map.addControl(new maplibregl.NavigationControl(),'top-right');
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=>status(s)?.a).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,t:s.t||null}}))};
map.getSource('all').setData(all);map.getSource('av').setData(av);
}
function priceStrip(){
@@ -191,7 +191,7 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
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><span class="flag">${s.flag||FLAG[s.cc]||''}</span> <span class="fname">${s.n}</span><span class="kindb">${s.kind==='elektro'?'Elektro':'Baumarkt'}</span><div class="faddr">${s.z} ${s.ci}</div></td>
tb.insertAdjacentHTML('beforeend',`<tr><td><span class="flag">${s.flag||FLAG[s.cc]||''}</span> <span class="fname">${s.n}</span><span class="kindb">${s.kind==='elektro'?'Elektro':'Baumarkt'}</span><div class="faddr">${s.z} ${s.ci} · <span title="zuletzt geprüft">Stand vor ${s.t||'?'} Min</span></div></td>
<td class="ce">${stk}</td><td class="ce num">${fmt(st.p,s.cur)}</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>`);
}
@@ -219,7 +219,7 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
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} verfügbar<br><span style=\"color:#666;font-size:.8em\">Stand vor ${p.t||'?'} Min</span>`).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();
});
+32
View File
@@ -0,0 +1,32 @@
import { allProducts, priceIntel, euro } from '../lib/pricing';
export const GET = () => {
const L:string[] = [
'# Kühlfinder Vollständige Referenz',
'> Kontrollzentrum für Klimagerät-Verfügbarkeit in DE/AT/CH/NL/PL/DK: Live-Bestand in Bau- und Elektromärkten + Online/Amazon/eBay, Verfügbarkeits-Prognose, Preis-Check mit Wucher-Warnung, Custom-Alarm + Preiswecker. Eigener Scraper, keine Drittdaten.',
'',
'## Kernfunktionen',
'- Live-Karte & Tabelle aller Märkte mit Bestand (idealo zeigt keinen Filialbestand).',
'- Verfügbarkeits-Prognose (Chance 7 Tage) aus Restock-Historie. Methodik: /prognose',
'- Preis-Intelligenz: günstigster/üblicher/teuerster Preis, eBay-Range, Retoure/refurbished, Wucher ab UVP×1,3.',
'- Custom Multi-Artikel-Watchlist, Alarm + Preiswecker (E-Mail kostenlos, Telegram/Echtzeit im Pro-Pass).',
'- Beratung: 60-Sekunden-Finder, Klima-Rechner (kW/BTU), Soforthilfe, feste Anlage (SHK).',
'',
'## Geräte (kuratiert)',
];
for (const p of allProducts){ const pi=priceIntel(p); L.push(`- ${p.name}${p.type==='mobile_split'?'mobile Split':'Monoblock'}, ${p.btuh} BTU, ${p.roomSizeM2[0]}${p.roomSizeM2[1]} m², EEK ${p.efficiencyCooling}. ${p.shortVerdict} Üblicher Preis ${euro(pi.fairPrice)}. /geraete/${p.slug}`); }
L.push('', '## Seiten',
'- / Startseite (availability-first)',
'- /karte Live-Monitor (Karte, Tabelle, Prognose, Preis-Strip)',
'- /rechner Klima-Rechner (Leistung + Empfehlung)',
'- /finder 60-Sekunden-Empfehlung',
'- /geraete alle Geräte; /geraete/<slug> Details',
'- /portasplit PortaSplit-Status & Alternativen',
'- /alarm Custom-Alarm + Preiswecker',
'- /pro Pro-Pass (Echtzeit, Telegram, Frühwarnung)',
'- /soforthilfe Hitze-Tipps (Dachgeschoss/Haus)',
'- /klimaanlage-installieren feste Anlage (SHK), Preise & Partner',
'- /prognose Prognose-Methodik',
'- /hitzeschutz Klimawandel-Kontext',
'- /transparenz Methodik, Affiliate, Tracking, Zahlung');
return new Response(L.join('\n'), { headers: { 'Content-Type':'text/plain; charset=utf-8' } });
};
+1 -1
View File
@@ -36,7 +36,7 @@ const jsonld={'@context':'https://schema.org','@type':'FAQPage',mainEntity:[
<li>★ Beste-Chancen-Route in deinem Umkreis</li>
</ul>
<a class="btn" href="/alarm" data-umami-event="pro_pass_cta">Pro-Pass holen</a>
<p class="note" style="margin:.5rem 0 0">Zahlung per Stripe (folgt). Keine automatische Verlängerung.</p>
<p class="note" style="margin:.5rem 0 0">Zahlung per <strong>Mollie</strong> (EU, NL) iDEAL, SEPA, Karte, Apple/Google Pay. Folgt. Keine automatische Verlängerung.</p>
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
---
import Base from '../layouts/Base.astro';
const jsonld={'@context':'https://schema.org','@type':'FAQPage',mainEntity:[
{'@type':'Question',name:'Wie funktioniert die Verfügbarkeits-Prognose?',acceptedAnswer:{'@type':'Answer',text:'Wir protokollieren, wann ein Markt ein Gerät wieder auf Lager hatte (Restock-Ereignisse). Aus der Häufigkeit der letzten 30 Tage schätzen wir die Wahrscheinlichkeit, dass es in den nächsten 7 Tagen wieder verfügbar ist.'}}]};
---
<Base title="Prognose-Methodik: Wie Kühlfinder Verfügbarkeit schätzt | Kühlfinder" description="Transparent erklärt: Wie die Verfügbarkeits-Prognose (Chance in 7 Tagen) aus der Restock-Historie berechnet wird inklusive Grenzen." jsonld={jsonld}>
<section class="wrap prose" style="margin-top:1.6rem;max-width:720px">
<span class="eyebrow">Methodik · transparent</span>
<h1>Wie die Verfügbarkeits-Prognose funktioniert</h1>
<p class="lead">Kein Orakel, sondern Statistik aus echten Daten. So entsteht die „Chance in 7 Tagen".</p>
<h2>1. Wir messen Restocks</h2>
<p>Unser Scraper prüft regelmäßig Bestand und Preis direkt bei den Händlern. Wechselt ein Markt für ein Gerät von „vergriffen" auf „verfügbar", protokollieren wir ein <strong>Restock-Ereignis</strong> mit Zeitstempel.</p>
<h2>2. Wir schätzen die Rate</h2>
<p>Aus den Ereignissen der letzten 30 Tage berechnen wir je Markt &amp; Gerät eine Restock-Rate (Ereignisse pro Woche). Daraus ergibt sich die Wahrscheinlichkeit für die nächsten 7 Tage nach dem Modell <code>P = 1 e^(Rate × 1&nbsp;Woche)</code>.</p>
<h2>3. Wir zeigen den besten Zeitpunkt</h2>
<p>Wo Daten es zulassen, erkennen wir typische Liefer-Wochentage/-zeiten. Pro-Nutzer bekommen die <strong>Frühwarnung</strong> bereits, wenn ein typisches Restock-Fenster bevorsteht nicht erst, wenn es (kurz) verfügbar ist.</p>
<h2>Grenzen</h2>
<ul>
<li>Eine Prognose ist eine <strong>Schätzung</strong>, keine Garantie Lieferungen schwanken.</li>
<li>Neue Märkte/Geräte ohne Historie zeigen niedrige/keine Prognose, bis Daten vorliegen.</li>
<li>Aktuell teils Seed-Daten; mit dem Live-Crawler wird die Historie echt und die Prognose schärfer.</li>
</ul>
<p class="note">Quellen: eigener Kühlfinder-Scraper (Händlerseiten direkt). Wetterkontext: <a href="https://open-meteo.com/" rel="nofollow">Open-Meteo</a>.</p>
</section>
</Base>
<style>.prose .lead{font-size:1.15rem;color:var(--ink-2)}.prose h2{margin-top:1.5rem}.prose ul{padding-left:1.2rem}.prose code{font-family:var(--mono);background:var(--surface-2);padding:.1rem .3rem;border-radius:4px}</style>
+6
View File
@@ -11,6 +11,12 @@ import { WUCHER_FACTOR } from '../lib/pricing';
<p>Als „üblicher Preis“ dient die UVP bzw. der normale Marktpreis. Liegt ein verfügbares Angebot mehr als {Math.round((WUCHER_FACTOR-1)*100)} % darüber, markieren wir es als Wucher. Diese Angebote zählen nicht in den Durchschnitt.</p>
<h2>Verfügbarkeit</h2>
<p>Wir zeigen Verfügbarkeit pro Markt so aktuell wie möglich. Wenn Daten nicht automatisch geprüft sind, behaupten wir nie „verfügbar“, sondern nur „prüfen“. Preise und Bestände ändern sich während Hitzewellen sehr schnell.</p>
<h2>Tracking</h2>
<p>Cookielose Analyse mit selbst gehostetem Umami (EU). Keine Werbe-Cookies, keine Weitergabe an Werbenetzwerke.</p>
<h2>Zahlung</h2>
<p>Pro-Pass über Mollie (EU/Niederlande) europäische Zahlungsabwicklung. Kein Abo, keine automatische Verlängerung.</p>
<h2>Partner-Empfehlungen</h2>
<p>Bei festen Anlagen empfehlen wir regionale Fachbetriebe (z. B. Stöver, TGA Solutions) mit UTM-Verlinkung als redaktionelle Empfehlung, nicht als bezahlte Platzierung.</p>
<h2>Affiliate</h2>
<p>Einige Links sind Affiliate-Links. Kaufst du darüber, erhalten wir ggf. eine Provision für dich ändert sich der Preis nicht. Empfehlungen hängen nicht von der Provision ab.</p>
</section>