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:
@@ -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 30–120 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.
|
||||
@@ -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 []; }
|
||||
@@ -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 []; }
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user