diff --git a/src/lib/scrapers/geizhals.ts b/src/lib/scrapers/geizhals.ts new file mode 100644 index 0000000..98011c8 --- /dev/null +++ b/src/lib/scrapers/geizhals.ts @@ -0,0 +1,48 @@ +import * as cheerio from 'cheerio' +import type { PriceScraper, ScrapeResult } from './types' + +const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36' + +export const geizhalsScraper: PriceScraper = { + shop: 'geizhals', + async scrape(url: string): Promise { + try { + const res = await fetch(url, { + headers: { 'User-Agent': UA, 'Accept-Language': 'de-DE,de;q=0.9' }, + signal: AbortSignal.timeout(20_000), + }) + if (!res.ok) { + return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` } + } + const $ = cheerio.load(await res.text()) + + const priceTexts = [ + $('.gh_price').first().text(), + $('span.gh_price strong').first().text(), + $('[itemprop="price"]').attr('content'), + $('meta[itemprop="price"]').attr('content'), + ].filter(Boolean) as string[] + + const price = parsePrice(priceTexts[0] ?? '') + const name = ($('h1[itemprop="name"]').text() || $('h1').first().text() || '').trim() + const imageUrl = $('img.product-gallery__image').first().attr('src') + || $('meta[property="og:image"]').attr('content') + || undefined + + if (price === null) { + return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' } + } + + return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl } + } catch (err) { + return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message } + } + }, +} + +function parsePrice(text: string): number | null { + const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.') + if (!cleaned) return null + const n = parseFloat(cleaned) + return Number.isFinite(n) && n > 0 ? n : null +} diff --git a/tests/fixtures/geizhals-gpu.html b/tests/fixtures/geizhals-gpu.html new file mode 100644 index 0000000..ac6ef05 --- /dev/null +++ b/tests/fixtures/geizhals-gpu.html @@ -0,0 +1,3757 @@ +Cube Reaction Hybrid Performance 625 Allroad trapeze swampgrey'n'black Modell 2023 | Preisvergleich Geizhals Deutschland + + + + + + + + + + + + + + + + + + + + +Zum Hauptinhalt +
+ +
+
+
+ + + + +
+ +
+ + + + + +
+ +
+ +
+
+
+
+ +
+
+
+

Cube Reaction Hybrid Performance 625 Allroad trapeze swampgrey'n'black Modell 2023

633162
+
+
+
+
+ +
+ +Info beim Hersteller + + +
+
+ +Alle 24 Varianten anzeigen + + + +
+
+
+
Farben
+
grau, schwarz
+
+
+
Typ
+
Mountainbike (Hardtail)
+
+
+
Rahmenform
+
Trapez
+
+
+
Rahmenmaterial
+
Alu
+
+
+
Laufradgröße
+
27.5"/650B (XS, S)
29" (M, L, XL, XXL)
+
+
+
Gabeltyp
+
gefedert
+
+
+
Federgabel
+
Suntour NVX
+
+
+
Federweg
+
100mm
+
+
+
Lenkerform
+
gerade
+
+
+
Bremsentyp
+
Disc hydraulisch
+
+
+
Bremse
+
Shimano MT200
+
+
+
Schalthebel
+
Shimano Alivio M3100
+
+ +
+
+ +
+ +
+
+
+Aktueller Preisbereich +
+
+Derzeit keine Angebote +
+ +
+
+
+
+ +
+
    +
  • + + + + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
    +
  • +
    + + + +
    +
  • +
  • +
    + + + +
    +
  • +
+
+
+Anbieter aus: +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
    +
  • +
    + + + +
    +
  • +
  • +
    + + + +
    +
  • +
+
+
+ + +Filter zurücksetzen + +
+
+
+
+
+
+
+ + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+
+ + +
+ +
+
+ + + +
+
+
+
+
+
+
+
+ +
Anbieter
+
Händlerbewertung
+
+Verfügbarkeit / Versandkosten* +
+
Produktbezeichnung des Händlers
+
+
+
+
+Es gibt derzeit keine Anbieter für dieses Produkt +(mit diesen Filterkriterien) +in der gewählten Region. +
Bitte die EU-Übersicht +verwenden oder ggf. die Filterkriterien ändern. +
+
+
+
+
+
+

+Weitere Varianten (Top-10) +

+
+
+ + + + + + + + + + +
+ +
+
+
+

+Ähnliche Produkte +(Top-10 in Elektrofahrräder) +

+
+
+ + + + + + + + + + +
+ +
+
+
+
+
+
+ +
+
+

+ +26 + +Produkteigenschaften + + +

+ +
+
+
+
+
+
Farben
+
grau, schwarz
+
+
+
Typ
+
Mountainbike (Hardtail)
+
+
+
Rahmenform
+
Trapez
+
+
+
Rahmenmaterial
+
Alu
+
+
+
Laufradgröße
+
27.5"/650B (XS, S)
29" (M, L, XL, XXL)
+
+
+
Gabeltyp
+
gefedert
+
+
+
Federgabel
+
Suntour NVX
+
+
+
Federweg
+
100mm
+
+
+
Lenkerform
+
gerade
+
+
+
Bremsentyp
+
Disc hydraulisch
+
+
+
Bremse
+
Shimano MT200
+
+
+
Schalthebel
+
Shimano Alivio M3100
+
+
+
Schaltwerk
+
Shimano Alivio M3100
+
+
+
Kurbelsatz
+
ACID E-Crank (38)
+
+
+
Kassette
+
11-36
+
+
+
Gänge
+
9 (1x9)
+
+
+
Motorposition
+
Tretlager
+
+
+
Antriebssystem
+
Bosch Performance Line
+
+
+
Display
+
Bosch LED Remote
+
+
+
Motorleistung
+
250W, 75Nm
+
+
+
Akku
+
Li-Ion, 36V, 625Wh, 17.4Ah
+
+
+
Zulässiges Gesamtgewicht max.
+
135kg
+
+
+
Gewicht
+
25.10kg
+
+
+
Besonderheiten
+
interne Zugführung, Schutzblech vorne, Schutzblech hinten, Seitenständer
+
+
+
Letztes Preisupdate
+
-
+
+
+
Gelistet seit
+
02.04.2024, 16:11
+
+
+
+
+ +
+
+
+
+
+
+
Laden ...
+ +
+
+
+
+
+

+ +Bewertung für Cube Reaction Hybrid Performance 625 Allroad trapeze swampgrey'n'black Modell 2023 + +

+ +Teilen Sie uns Ihre Meinung zu Cube Reaction Hybrid Performance 625 Allroad trapeze swampgrey'n'black Modell 2023 mit oder lesen Sie die Bewertungen unserer Mitglieder, Experten und anderer Websites zu dessen Varianten. + +
+
+
+
Laden ...
+ +
+ +
+
+
+ +
+ +
+
+
+

Alle Angaben ohne Gewähr. Die gelisteten Angebote sind keine verbindlichen Werbeaussagen der Anbieter.

+

* Preise in Euro inkl. MwSt. zzgl. Versandkosten. Bei der Auswahl von "inkl. Versand" beinhaltet der dargestellte Preis die Kosten für den Versand. Die nicht angeführten Kosten in andere Länder entnimm bitte der Website des Händlers. Bei Sortierung nach einer anderen als der Landeswährung des Händlers basiert die Währungsumrechnung auf einem von uns ermittelten Tageskurs, der womöglich nicht mit dem im Shop angegebenen Preis exakt übereinstimmt. Bitte bedenke außerdem, dass die angeführten Preise periodisch erzeugte Momentaufnahmen darstellen und technisch bedingt teilweise veraltet sein können. Insbesondere sind Preiserhöhungen zwischen dem Zeitpunkt der Preisübernahme durch uns und dem späteren Besuch dieser Website möglich. Händler haben keine Möglichkeit die Darstellung der Preise direkt zu beeinflussen und sofortige Änderungen auf unserer Seite zu veranlassen. Der maßgebliche Preis für den Verkauf durch den Händler ist der tatsächliche Preis des Produkts, der zum Zeitpunkt des Kaufs auf der Website des Händlers steht.

+
+ + + + +
+
+
+ + + + + +
+ + + diff --git a/tests/scrapers/geizhals.test.ts b/tests/scrapers/geizhals.test.ts new file mode 100644 index 0000000..15f3367 --- /dev/null +++ b/tests/scrapers/geizhals.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { geizhalsScraper } from '@/lib/scrapers/geizhals' + +const fixture = readFileSync(join(__dirname, '../fixtures/geizhals-gpu.html'), 'utf-8') + +beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => fixture, + }) as unknown as typeof fetch +}) + +describe('geizhalsScraper', () => { + it('extracts price and name', async () => { + const r = await geizhalsScraper.scrape('https://geizhals.de/test') + expect(r.price).toBeGreaterThan(0) + expect(r.currency).toBe('EUR') + expect(r.name).toBeTruthy() + }) + + it('returns error on HTTP failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, status: 503, text: async () => '', + }) as unknown as typeof fetch + const r = await geizhalsScraper.scrape('https://geizhals.de/test') + expect(r.price).toBeNull() + expect(r.error).toMatch(/HTTP 503/) + }) +})