Booking scraper service

This commit is contained in:
2026-08-09 20:31:33 +00:00
parent bd5b6ca427
commit 780b0b128c
4 changed files with 199 additions and 0 deletions

145
src/index.js Normal file
View File

@@ -0,0 +1,145 @@
const worker = new Worker('scrape-jobs', async (job) => {
const { url, webhookUrl } = job.data;
console.log(`[Job ${job.id}] Starte Scraping für: ${url}`);
let scrapedPrice = null;
let roomSizeM2 = null;
let hotelDetails = { name: null, rating: null, reviewCount: null, address: null, image: null };
let browser = null;
// 1. Fallback: Nächte aus URL-Checkin/Checkout berechnen
const urlObj = new URL(url);
const checkin = urlObj.searchParams.get('checkin');
const checkout = urlObj.searchParams.get('checkout');
const adultsParam = parseInt(urlObj.searchParams.get('group_adults') || '0', 10);
let nightsFromUrl = null;
if (checkin && checkout) {
const diffTime = Math.abs(new Date(checkout) - new Date(checkin));
nightsFromUrl = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 800 },
locale: 'de-DE',
timezoneId: 'Europe/Berlin'
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 });
await page.waitForTimeout(3000);
// 2. Hotel Details aus JSON-LD
hotelDetails = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
for (const script of scripts) {
try {
const json = JSON.parse(script.innerText);
if (json['@type'] === 'Hotel' || json['@type'] === 'LodgingBusiness' || json.name) {
return {
name: json.name || null,
rating: json.aggregateRating?.ratingValue || null,
reviewCount: json.aggregateRating?.reviewCount || null,
address: json.address?.streetAddress || json.address?.addressLocality || null,
image: json.image || null
};
}
} catch (e) {}
}
return { name: null, rating: null, reviewCount: null, address: null, image: null };
});
// 3. Nächte, Personen & Zimmergröße direkt aus den Booking-DOM-Selektoren lesen
const domData = await page.evaluate(() => {
let dNights = null;
let dPersons = null;
// Gezielte Suche in den von dir genannten Selektoren
const targetSelectors = [
'.bui-price-display__label',
'#searchbox-horizontal-occupancy-label',
'[data-testid="price-for-x-nights"]',
'#group_recommendation .bui-price-display__label',
'[data-testid="occupancy-config"]'
];
let combinedText = '';
for (const sel of targetSelectors) {
const els = document.querySelectorAll(sel);
els.forEach(el => {
if (el && el.innerText) combinedText += ' ' + el.innerText;
});
}
// Falls Selektoren leer sind, gesamten Text als Fallback nutzen
if (!combinedText.trim()) {
combinedText = document.body.innerText;
}
// Nächte matchen (z. B. "7 Nächte", "1 Nacht", "für 2 Nächte")
const nightMatch = combinedText.match(/(\d+)\s*Nächte?/i);
if (nightMatch) dNights = parseInt(nightMatch[1], 10);
// Personen matchen (z. B. "2 Erwachsene", "1 Erwachsener", "2 Gäste")
const personMatch = combinedText.match(/(\d+)\s*(?:Erwachsene|Erwachsener|Gäste|Personen)/i);
if (personMatch) dPersons = parseInt(personMatch[1], 10);
// Zimmergröße (m²)
const roomMatch = document.body.innerText.match(/(\d+)\s*m²/i);
const roomSize = roomMatch ? parseInt(roomMatch[1], 10) : null;
return { dNights, dPersons, roomSize };
});
roomSizeM2 = domData.roomSize;
// Finale Werte bestimmen (Priorität: URL -> DOM -> Standard-Fallback)
const finalNights = nightsFromUrl || domData.dNights || 1;
const finalPersons = adultsParam || domData.dPersons || 2;
// 4. Preis extrahieren
const priceSelectors = [
'[data-testid="price-and-discounted-price"]',
'.totalPrice .bui-price-display__value',
'.hprt-price-price'
];
for (const selector of priceSelectors) {
const el = await page.$(selector);
if (el) {
const text = await el.innerText();
if (text && /\d/.test(text)) {
scrapedPrice = text.trim();
break;
}
}
}
// 5. Webhook mit exakten Daten absenden
if (webhookUrl && webhookUrl.startsWith('http')) {
await axios.post(webhookUrl, {
jobId: job.id,
url,
hotelName: hotelDetails.name,
price: scrapedPrice,
roomSizeM2,
rating: hotelDetails.rating,
reviewCount: hotelDetails.reviewCount,
address: hotelDetails.address,
image: hotelDetails.image,
nights: finalNights,
persons: finalPersons,
timestamp: new Date().toISOString()
});
}
} catch (error) {
console.error(`[Job ${job.id}] Fehler:`, error.message);
} finally {
if (browser) await browser.close();
}
}, { ...REDIS_CONFIG, concurrency: 2 });