Compare commits

...

3 Commits

4 changed files with 177 additions and 247 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ src/*.png
debug/ debug/
.DS_Store .DS_Store

21
AGENTS.md Normal file
View File

@@ -0,0 +1,21 @@
# Engineering & Scraping Guidelines
## 1. Git Commit & Repository Hygiene
- **Conventional Commits:** Write clear, concise commit messages prefixed with standard types (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
- **Exclude Transient Artifacts:** Never stage or commit generated debug files (e.g., `src/*.html`, `src/*.png`, large logs) to version control. Always maintain `.gitignore` to keep git packs lean and prevent remote unpacker errors on Gitea/GitHub.
## 2. Web Scraping & DOM Inspection Strategy
- **Rendered DOM Dumping for Debugging:** When debugging extraction failures on dynamic or anti-bot protected pages:
- Capture the post-render HTML via `await page.content()` and save it to `src/hotel-debug.html`.
- Capture a synchronized screenshot (`src/hotel-debug.png`).
- Inspect the raw rendered DOM to verify if the page was served as standard SSR, an A/B test SPA variant, or intercepted by a challenge (e.g. AWS WAF).
- **Multi-Tier Extraction Hierarchy:** Always implement fallbacks in this order:
1. **Deep JSON-LD Parsing:** Traverse all `<script type="application/ld+json">` tags, including `@graph` arrays and varied accommodation schemas (`Hotel`, `Resort`, `Apartment`, `Hostel`, `LodgingBusiness`, etc.).
2. **DOM Selectors:** Fallback to standard test IDs, classes, and element hierarchy (`data-testid`, header titles, score components).
3. **Meta Tags:** Fallback to OpenGraph and Twitter meta tags (`og:title`, `og:image`, `og:description`).
4. **URL Slug Extraction:** As an ultimate safety net, derive clean human-readable names from the URL path if the HTML body was obstructed.
- **Anti-Bot & WAF Handling:**
- Sanitize input URLs by stripping transient tokens (`chal_t`, `force_referer`, session IDs).
- Emulate full browser properties (`window.chrome`, `navigator.plugins`, `navigator.languages`).
- Actively detect WAF challenge interstitial pages and wait for client-side JavaScript token computation and automatic redirection before evaluating the DOM.

View File

@@ -23,7 +23,8 @@ const WESTERN_COUNTRIES = [
'United Kingdom', 'UK', 'Niederlande', 'Netherlands', 'Frankreich', 'France', 'United Kingdom', 'UK', 'Niederlande', 'Netherlands', 'Frankreich', 'France',
'Spanien', 'Spain', 'Italien', 'Italy', 'Kanada', 'Canada', 'Australien', 'Australia', 'Spanien', 'Spain', 'Italien', 'Italy', 'Kanada', 'Canada', 'Australien', 'Australia',
'Belgien', 'Belgium', 'Schweden', 'Sweden', 'Norwegen', 'Norway', 'Dänemark', 'Denmark', 'Belgien', 'Belgium', 'Schweden', 'Sweden', 'Norwegen', 'Norway', 'Dänemark', 'Denmark',
'Finnland', 'Finland', 'Irland', 'Ireland', 'Luxemburg', 'Luxembourg', 'Indien', 'India' 'Finnland', 'Finland', 'Irland', 'Ireland', 'Luxemburg', 'Luxembourg',
'Neuseeland', 'New Zealand'
]; ];
// ========================================== // ==========================================
@@ -139,12 +140,32 @@ function cleanBookingUrl(rawUrl) {
} }
} }
// ==========================================
// HILFSFUNKTION: SICHERE EVALUATION (RESISTENT GEGEN REDIRECTS)
// ==========================================
async function safeEvaluate(page, fn, fallback = null, arg = undefined) {
try {
return arg !== undefined ? await page.evaluate(fn, arg) : await page.evaluate(fn);
} catch (err) {
if (err.message && (err.message.includes('Execution context was destroyed') || err.message.includes('navigating') || err.message.includes('Target closed'))) {
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2500);
try {
return arg !== undefined ? await page.evaluate(fn, arg) : await page.evaluate(fn);
} catch (innerErr) {
return fallback;
}
}
return fallback;
}
}
// ========================================== // ==========================================
// BULLMQ WORKER: SCRAPING & REVIEWS // BULLMQ WORKER: SCRAPING & REVIEWS
// ========================================== // ==========================================
const worker = new Worker('scrape-jobs', async (job) => { const worker = new Worker('scrape-jobs', async (job) => {
const { url, webhookUrl, appWebhookUrl, hotelId, debug } = job.data; const { url, webhookUrl, appWebhookUrl, hotelId, debug, mode } = job.data;
const isReviewScan = Boolean(webhookUrl && webhookUrl.startsWith('http')); const isReviewScan = Boolean((webhookUrl && webhookUrl.startsWith('http')) || mode === 'reviews' || mode === 'full');
const isDebugMode = Boolean(debug || DEBUG_HTML); const isDebugMode = Boolean(debug || DEBUG_HTML);
console.log(`\n==================================================`); console.log(`\n==================================================`);
@@ -167,29 +188,21 @@ const worker = new Worker('scrape-jobs', async (job) => {
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled', '--disable-blink-features=AutomationControlled',
'--disable-infobars' '--window-size=1920,1080'
] ]
}); });
const context = await browser.newContext({ const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
viewport: { width: 1366, height: 900 }, viewport: { width: 1920, height: 1080 },
locale: 'de-DE', locale: 'de-DE',
timezoneId: 'Europe/Berlin', timezoneId: 'Europe/Berlin'
extraHTTPHeaders: {
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Upgrade-Insecure-Requests': '1',
'Sec-Ch-Ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"'
}
}); });
const page = await context.newPage();
// Verhindert Erkennung über navigator.webdriver und emuliert Standard-Browser-Eigenschaften // Verhindert Erkennung über navigator.webdriver und emuliert Standard-Browser-Eigenschaften
await page.addInitScript(() => { await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = { window.chrome = {
runtime: {}, runtime: {},
@@ -205,18 +218,20 @@ const worker = new Worker('scrape-jobs', async (job) => {
}); });
}); });
const page = await context.newPage();
let targetUrl = cleanBookingUrl(url); let targetUrl = cleanBookingUrl(url);
if (isReviewScan && !targetUrl.includes('#tab-reviews')) {
targetUrl = targetUrl.replace('#tab-main', '') + '#tab-reviews';
}
console.log(`[Job ${job.id}] 🌐 Bereinigte Ziel-URL: ${targetUrl}`); console.log(`[Job ${job.id}] 🌐 Bereinigte Ziel-URL: ${targetUrl}`);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Prüfen, ob AWS WAF Challenge aktiv ist // Prüfen, ob AWS WAF Challenge aktiv ist (sicher gegenüber gleichzeitiger Navigation)
const isWafChallenge = await page.evaluate(() => { let isWafChallenge = false;
try {
isWafChallenge = await page.evaluate(() => {
return Boolean( return Boolean(
document.querySelector('#challenge-container') || document.querySelector('#challenge-container') ||
document.querySelector('script[src*="challenge.js"]') || document.querySelector('script[src*="challenge.js"]') ||
@@ -224,6 +239,21 @@ const worker = new Worker('scrape-jobs', async (job) => {
(document.title === '' && (document.body ? document.body.innerText.includes('verify that you') : false)) (document.title === '' && (document.body ? document.body.innerText.includes('verify that you') : false))
); );
}); });
} catch (navErr) {
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2500);
try {
isWafChallenge = await page.evaluate(() => {
return Boolean(
document.querySelector('#challenge-container') ||
document.querySelector('script[src*="challenge.js"]') ||
window.AwsWafIntegration
);
});
} catch (e) {
isWafChallenge = false;
}
}
if (isWafChallenge) { if (isWafChallenge) {
console.log(`[Job ${job.id}] 🛡️ AWS WAF Challenge erkannt. Warte auf automatische Token-Weiterleitung...`); console.log(`[Job ${job.id}] 🛡️ AWS WAF Challenge erkannt. Warte auf automatische Token-Weiterleitung...`);
@@ -234,12 +264,14 @@ const worker = new Worker('scrape-jobs', async (job) => {
return !hasChallenge || Boolean(hasContent); return !hasChallenge || Boolean(hasContent);
}, { timeout: 25000 }); }, { timeout: 25000 });
console.log(`[Job ${job.id}] 🔓 Weiterleitung nach AWS WAF erfolgt!`); console.log(`[Job ${job.id}] 🔓 Weiterleitung nach AWS WAF erfolgt!`);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} catch (wafErr) { } catch (wafErr) {
console.log(`[Job ${job.id}] ⚠️ AWS WAF Timeout. Versuche direkten Reload der Basis-URL...`); console.log(`[Job ${job.id}] ⚠️ AWS WAF Timeout. Versuche direkten Reload der Basis-URL...`);
try { try {
const parsed = new URL(targetUrl); const parsed = new URL(targetUrl);
await page.goto(parsed.origin + parsed.pathname, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.goto(parsed.origin + parsed.pathname, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
} catch (reloadErr) {} } catch (reloadErr) {}
} }
@@ -252,21 +284,13 @@ const worker = new Worker('scrape-jobs', async (job) => {
// Cookie-Banner & störende Overlays für alle Scans sofort schließen // Cookie-Banner & störende Overlays für alle Scans sofort schließen
try { try {
const dismissSelectors = [ const dismissBtns = await page.$$(
'#onetrust-accept-btn-handler', 'button[aria-label*="schließen"], button[aria-label*="Schließen"], button[aria-label*="Close"], button[aria-label*="Dismiss"], #onetrust-accept-btn-handler, button:has-text("Akzeptieren"), button:has-text("Alle akzeptieren"), button[data-testid="selection-modal-close-btn"]'
'button#onetrust-reject-all-handler', );
'button[data-testid="selection-modal-close-btn"]', for (const btn of dismissBtns) {
'button[aria-label="Schließen"]',
'button[aria-label="Close"]',
'button.modal-mask-closeBtn'
];
for (const btnSel of dismissSelectors) {
const btn = await page.$(btnSel);
if (btn) {
await btn.click().catch(() => {}); await btn.click().catch(() => {});
await page.waitForTimeout(300); await page.waitForTimeout(300);
} }
}
} catch (e) {} } catch (e) {}
// HTML-Dump & Screenshot bei Debug-Modus sichern // HTML-Dump & Screenshot bei Debug-Modus sichern
@@ -290,7 +314,7 @@ const worker = new Worker('scrape-jobs', async (job) => {
} catch (e) {} } catch (e) {}
// 2. STAMMDATEN EXTRAHIEREN (JSON-LD + DOM + META FALLBACKS) // 2. STAMMDATEN EXTRAHIEREN (JSON-LD + DOM + META FALLBACKS)
hotelDetails = await page.evaluate(() => { hotelDetails = await safeEvaluate(page, () => {
let name = null; let name = null;
let rating = null; let rating = null;
let reviewCount = null; let reviewCount = null;
@@ -450,7 +474,7 @@ const worker = new Worker('scrape-jobs', async (job) => {
} }
return { name, rating, reviewCount, address, image }; return { name, rating, reviewCount, address, image };
}); }, { name: null, rating: null, reviewCount: null, address: null, image: null });
// Fallback: Name aus URL-Slug ableiten wenn im HTML nichts gefunden wurde // Fallback: Name aus URL-Slug ableiten wenn im HTML nichts gefunden wurde
if (!hotelDetails.name) { if (!hotelDetails.name) {
@@ -458,7 +482,7 @@ const worker = new Worker('scrape-jobs', async (job) => {
} }
// 3. DOM-DATEN (Nächte, Personen, Zimmergröße) // 3. DOM-DATEN (Nächte, Personen, Zimmergröße)
const extraDom = await page.evaluate(() => { const extraDom = await safeEvaluate(page, () => {
try { try {
const bodyText = document.body ? (document.body.innerText || '') : ''; const bodyText = document.body ? (document.body.innerText || '') : '';
const nMatch = bodyText.match(/(\d+)\s*Nächte?/i); const nMatch = bodyText.match(/(\d+)\s*Nächte?/i);
@@ -472,14 +496,14 @@ const worker = new Worker('scrape-jobs', async (job) => {
} catch (e) { } catch (e) {
return { dNights: 1, dPersons: 2, roomSize: null }; return { dNights: 1, dPersons: 2, roomSize: null };
} }
}); }, { dNights: 1, dPersons: 2, roomSize: null });
domData.dNights = extraDom.dNights; domData.dNights = extraDom.dNights;
domData.dPersons = extraDom.dPersons; domData.dPersons = extraDom.dPersons;
roomSizeM2 = extraDom.roomSize; roomSizeM2 = extraDom.roomSize;
// PREIS AUSLESEN // PREIS AUSLESEN
scrapedPrice = await page.evaluate(() => { scrapedPrice = await safeEvaluate(page, () => {
try { try {
const selectors = [ const selectors = [
'[data-testid="price-and-discounted-price"]', '[data-testid="price-and-discounted-price"]',
@@ -502,10 +526,10 @@ const worker = new Worker('scrape-jobs', async (job) => {
} }
} catch (e) {} } catch (e) {}
return null; return null;
}); }, null);
// GENIUS BADGE PRÜFUNG // GENIUS BADGE PRÜFUNG
hasGenius = await page.evaluate(() => { hasGenius = await safeEvaluate(page, () => {
try { try {
const geniusSelectors = [ const geniusSelectors = [
'.m-badge__genius', '.m-badge__genius',
@@ -519,7 +543,7 @@ const worker = new Worker('scrape-jobs', async (job) => {
} catch (e) { } catch (e) {
return false; return false;
} }
}); }, false);
console.log(`[Job ${job.id}] 📋 Extrahierte Stammdaten:`, { console.log(`[Job ${job.id}] 📋 Extrahierte Stammdaten:`, {
name: hotelDetails.name, name: hotelDetails.name,
@@ -542,78 +566,29 @@ const worker = new Worker('scrape-jobs', async (job) => {
if (isReviewScan) { if (isReviewScan) {
console.log(`[Job ${job.id}] 🚀 Starte gezielte Review-Extraction für "${hotelDetails.name || 'Hotel'}"...`); console.log(`[Job ${job.id}] 🚀 Starte gezielte Review-Extraction für "${hotelDetails.name || 'Hotel'}"...`);
// A. Cookie-Banner und störende Overlays schließen // B. Herunterscrollen um Lazy-Components zu hydrieren und Review-Dialog zu öffnen
try { console.log(`[Job ${job.id}] 📜 Scrolle herunter, um verzögert geladene Review-Komponenten zu aktivieren...`);
const dismissSelectors = [ await page.evaluate(() => window.scrollTo(0, 4000));
'#onetrust-accept-btn-handler', await page.waitForTimeout(2500);
'button#onetrust-reject-all-handler',
'button[data-testid="selection-modal-close-btn"]', // C. Klick auf Review-Trigger ausführen
'button[aria-label="Schließen"]', console.log(`[Job ${job.id}] 👉 Öffne Review-Dialog über 'Alle Bewertungen lesen' / 'Gästebewertungen'...`);
'button[aria-label="Close"]' await page.evaluate(() => {
]; const btn = document.querySelector('button[data-testid="fr-read-all-reviews"]') ||
for (const btnSel of dismissSelectors) { document.querySelector('button[data-testid="review-score-read-all-actionable"]') ||
const btn = await page.$(btnSel); document.getElementById('reviews-tab-trigger') ||
document.querySelector('[data-testid="Property-Header-Nav-Tab-Trigger-reviews"]');
if (btn) { if (btn) {
await btn.click().catch(() => {}); btn.scrollIntoView({ behavior: 'instant', block: 'center' });
await page.waitForTimeout(400); btn.click();
} btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}
} catch (e) {}
// B. 500px nach unten scrollen, damit Bookings Sticky-Header (#reviews-tab-trigger) überhaupt sichtbar & aktiv wird
await page.evaluate(() => window.scrollBy(0, 550));
await page.waitForTimeout(1500);
// C. Klick per echtem Maus-Event auf den Trigger ausführen
let clicked = false;
try {
// Liste möglicher Klick-Ziele (Header-Tab, Scorecard-Badge oder Text "Gästebewertungen")
const triggerLocators = [
page.locator('#reviews-tab-trigger').first(),
page.locator('[data-testid="Property-Header-Nav-Tab-Trigger-reviews"]').first(),
page.locator('a:has-text("Gästebewertungen")').first(),
page.locator('button:has-text("Gästebewertungen")').first(),
page.locator('[data-testid="review-score-right-component"]').first(),
page.locator('[data-testid="review-score-component"]').first()
];
for (const loc of triggerLocators) {
if (await loc.count() > 0 && await loc.isVisible()) {
console.log(`[Job ${job.id}] 👉 Sichtbaren Review-Trigger gefunden. Führe Klick aus...`);
// Reale Maus-Koordinaten ermitteln & klicken
const box = await loc.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
clicked = true;
await page.waitForTimeout(3500);
break;
} else {
await loc.click({ force: true }).catch(() => {});
clicked = true;
await page.waitForTimeout(3500);
break;
}
}
}
// Fallback: JS-Klick direkt auf das HTML-Element mit Text "Gästebewertungen"
if (!clicked) {
console.log(`[Job ${job.id}] ⚠️ Fallback: Klick per DOM-Suche nach 'Gästebewertungen'...`);
clicked = await page.evaluate(() => {
const el = Array.from(document.querySelectorAll('a, button, span, div'))
.find(e => e.innerText && e.innerText.includes('Gästebewertungen') && (e.id === 'reviews-tab-trigger' || e.closest('#reviews-tab-trigger') || e.getAttribute('data-testid')?.includes('reviews')));
if (el) {
const clickTarget = el.closest('a') || el.closest('button') || el;
clickTarget.click();
clickTarget.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return true; return true;
} }
return false; return false;
}).catch(() => false); }).catch(() => false);
if (clicked) await page.waitForTimeout(3500); // 4 Sekunden warten bis das Dialog-Panel mit den 1.000+ Bewertungen vollständig geöffnet ist
} await page.waitForTimeout(4000);
// Screenshot zur visuellen Kontrolle aktualisieren // Screenshot zur visuellen Kontrolle aktualisieren
try { try {
@@ -621,44 +596,13 @@ const worker = new Worker('scrape-jobs', async (job) => {
console.log(`[Job ${job.id}] 📸 Screenshot aktualisiert unter: scraper-service/src/review-debug.png`); console.log(`[Job ${job.id}] 📸 Screenshot aktualisiert unter: scraper-service/src/review-debug.png`);
} catch (e) {} } catch (e) {}
} catch (e) { // D. Sortierung auf "Neueste zuerst" (NEWEST_FIRST) umstellen
console.log(`[Job ${job.id}] ⚠️ Fehler beim Klick auf Review-Trigger:`, e.message);
}
// Warten bis Review-Karten im Sliding Panel oder DOM sichtbar sind
try {
await page.waitForSelector(
'[data-testid="review-card"], [data-testid="review-cards"], #hp-reviews-sliding, div[data-component="hotel/reviews/review-card"], li.review_item',
{ timeout: 8000 }
);
console.log(`[Job ${job.id}] 👁️ Review-Bereich erfolgreich gerendert.`);
} catch (e) {
console.log(`[Job ${job.id}] ⚠️ Wartezeit abgelaufen – starte Auslesen aus dem aktuellen DOM...`);
}
// C. Sortierung auf "Neueste zuerst" (NEWEST_FIRST) umstellen
try { try {
const sorterLocator = page.locator('#reviewListSorters, [data-testid="reviews-sorter-component"], select[name="reviewListSorters"]').first(); const sorterLocator = page.locator('#reviewListSorters, [data-testid="reviews-sorter-component"], select[name="reviewListSorters"]').first();
if (await sorterLocator.count() > 0) { if (await sorterLocator.count() > 0) {
console.log(`[Job ${job.id}] 🔄 Stelle Sortierung auf 'NEWEST_FIRST' (Neueste zuerst) um...`); console.log(`[Job ${job.id}] 🔄 Stelle Sortierung auf 'NEWEST_FIRST' (Neueste zuerst) um...`);
await sorterLocator.selectOption('NEWEST_FIRST'); await sorterLocator.selectOption('NEWEST_FIRST');
await page.waitForTimeout(2500); // Warten bis Booking die Liste per AJAX neu lädt await page.waitForTimeout(3000); // Warten bis Booking die Liste per AJAX neu lädt
} else {
// Fallback via JavaScript Event-Dispatch
const changed = await page.evaluate(() => {
const select = document.querySelector('#reviewListSorters, [data-testid="reviews-sorter-component"], select[name="reviewListSorters"]');
if (select) {
select.value = 'NEWEST_FIRST';
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
}).catch(() => false);
if (changed) {
console.log(`[Job ${job.id}] 🔄 Sortierung via JS-Event auf 'NEWEST_FIRST' umgestellt.`);
await page.waitForTimeout(2500);
}
} }
} catch (e) { } catch (e) {
console.log(`[Job ${job.id}] ⚠️ Sortierung konnte nicht angepasst werden:`, e.message); console.log(`[Job ${job.id}] ⚠️ Sortierung konnte nicht angepasst werden:`, e.message);
@@ -671,123 +615,87 @@ const worker = new Worker('scrape-jobs', async (job) => {
pageCount++; pageCount++;
console.log(`[Job ${job.id}] 📄 Scanne Bewertungsseite ${pageCount} (Bisher gesammelt: ${extractedReviews.length})...`); console.log(`[Job ${job.id}] 📄 Scanne Bewertungsseite ${pageCount} (Bisher gesammelt: ${extractedReviews.length})...`);
const evalResult = await page.evaluate(({ westernCountries }) => { // Extrahiere alle Review-Karten aus dem Dialog / DOM
let cards = Array.from(document.querySelectorAll([ const pageReviews = await page.evaluate(() => {
'[data-testid="review-card"]', const cards = Array.from(document.querySelectorAll(
'[data-testid="review-cards"] > li', '#reviewCardsSection [data-testid="review-card"], div[role="dialog"] [data-testid="review-card"], [data-testid="review-card"]'
'[data-testid="review-cards"] > div', ));
'div[data-testid*="review-card"]', const items = [];
'li[data-testid*="review-card"]', const currentYear = new Date().getFullYear();
'li.review_list_new_item_block',
'div.c-review-block__item',
'div[itemprop="review"]',
'li.review_item'
].join(',')));
if (cards.length === 0) { for (const card of cards) {
const container = document.querySelector('#hp-reviews-sliding, #reviewCardsSection, [data-testid="review-cards"], #blockdisplay4'); const titleEl = card.querySelector('[data-testid="review-title"], h4');
if (container) { const posEl = card.querySelector('[data-testid="review-positive-text"]');
cards = Array.from(container.querySelectorAll('li, div')).filter(el => (el.innerText || '').length > 40); const negEl = card.querySelector('[data-testid="review-negative-text"]');
} const dateEl = card.querySelector('[data-testid="review-date"]');
} const flagImg = card.querySelector('[data-testid="review-avatar"] .fff1944c52 img[alt], [data-testid="review-country"] img[alt], img.b8d1620349');
const countrySpan = card.querySelector('[data-testid="review-avatar"] .fff1944c52 span:not(.cec4c9cfef), [data-testid="review-country"]');
// Filterboxen und Scorecard-Zusammenfassungen explizit ausschließen const title = titleEl ? titleEl.innerText.trim() : '';
cards = cards.filter(el => { const positive = posEl ? posEl.innerText.trim().replace(/^„|“$/g, '').trim() : '';
const text = el.innerText || ''; const negative = negEl ? negEl.innerText.trim().replace(/^„|“$/g, '').trim() : '';
return text.length > 25 && const dateText = dateEl ? dateEl.innerText.trim() : 'Aktuell';
!text.includes('Wählen Sie Themen') && const country = (flagImg ? flagImg.getAttribute('alt') : (countrySpan ? countrySpan.innerText : '')).trim();
!text.includes('Kategorien:') &&
!el.classList.contains('review_score_breakdown');
});
const results = [];
const debugLogs = [];
const currentYear = 2026;
for (let idx = 0; idx < cards.length; idx++) {
const card = cards[idx];
const cardText = card.innerText || ''; const cardText = card.innerText || '';
if (!cardText.trim()) continue;
// 1. Herkunftsland des Gastes ermitteln
const countryEl = card.querySelector('[data-testid="review-country"], .bui-avatar-or-image__title, .user_country, .c-review__country');
const guestCountryText = countryEl ? countryEl.innerText : cardText;
const isWestern = westernCountries.some(c => guestCountryText.toLowerCase().includes(c.toLowerCase()));
if (!isWestern) {
const preview = guestCountryText.substring(0, 50).replace(/\n/g, ' ');
debugLogs.push(`Karte #${idx + 1}: ❌ Land nicht in Liste ("${preview}")`);
continue;
}
// 2. Datum / Jahr prüfen (letzte 2 Jahre)
const yearMatch = cardText.match(/\b(20\d{2})\b/); const yearMatch = cardText.match(/\b(20\d{2})\b/);
if (yearMatch) { if (yearMatch) {
const reviewYear = parseInt(yearMatch[1], 10); const reviewYear = parseInt(yearMatch[1], 10);
if (currentYear - reviewYear > 2) { if (currentYear - reviewYear > 1) {
debugLogs.push(`Karte #${idx + 1}: ❌ Zu alt (${reviewYear})`);
continue; continue;
} }
} }
// 3. Texte extrahieren
const titleEl = card.querySelector('[data-testid="review-title"], .c-review__title, h4, .review_title');
const posEl = card.querySelector('[data-testid="review-positive-text"], .c-review__row--positive, .review_pos');
const negEl = card.querySelector('[data-testid="review-negative-text"], .c-review__row--negative, .review_neg');
const dateEl = card.querySelector('[data-testid="review-date"], .c-review__date, .review_item_date');
const title = titleEl ? titleEl.innerText.trim() : '';
const positive = posEl ? posEl.innerText.trim() : '';
const negative = negEl ? negEl.innerText.trim() : '';
const dateText = dateEl ? dateEl.innerText.trim() : (yearMatch ? yearMatch[0] : 'Aktuell');
let fallbackText = ''; let fallbackText = '';
if (!positive && !negative) { if (!positive && !negative) {
fallbackText = cardText.replace(title, '').substring(0, 300).trim(); fallbackText = cardText.replace(title, '').substring(0, 300).trim();
} }
if (title || positive || negative || fallbackText) { if (title || positive || negative || fallbackText) {
results.push({ title, positive: positive || fallbackText, negative, date: dateText }); items.push({ title, positive: positive || fallbackText, negative, date: dateText, country });
debugLogs.push(`Karte #${idx + 1}: ✅ Akzeptiert ("${title || (positive ? positive.substring(0, 25) : 'Review')}")`);
} }
} }
return items;
}).catch(err => {
console.error(`[Job ${job.id}] Evaluate error on page ${pageCount}:`, err.message);
return [];
});
return { console.log(`[Job ${job.id}] 🔍 Echte Gästekarten auf Seite ${pageCount} gefunden: ${pageReviews.length}`);
totalCards: cards.length,
results,
debugLogs: debugLogs.slice(0, 8)
};
}, { westernCountries: WESTERN_COUNTRIES });
console.log(`[Job ${job.id}] 🔍 Echte Gästekarten gefunden: ${evalResult.totalCards}`); for (const rev of pageReviews) {
if (evalResult.debugLogs) { if (rev.country) {
evalResult.debugLogs.forEach(log => console.log(`[Job ${job.id}] └─ ${log}`)); const isWestern = WESTERN_COUNTRIES.some(c => rev.country.toLowerCase().includes(c.toLowerCase()));
if (!isWestern) {
console.log(`[Job ${job.id}] └─ ❌ Land nicht in Liste: "${rev.country}"`);
continue;
}
} }
// Deduplizierung
for (const rev of evalResult.results) {
const key = `${rev.title}-${rev.positive}-${rev.date}`; const key = `${rev.title}-${rev.positive}-${rev.date}`;
if (!seenKeys.has(key) && extractedReviews.length < 80) { if (!seenKeys.has(key) && extractedReviews.length < 80) {
seenKeys.add(key); seenKeys.add(key);
extractedReviews.push(rev); extractedReviews.push(rev);
console.log(`[Job ${job.id}] └─ ✅ Akzeptiert: "${rev.title || rev.positive.substring(0, 30)}" (${rev.country || 'International'})`);
} }
} }
if (extractedReviews.length >= 80) break; if (extractedReviews.length >= 80) break;
// Weiterblättern im Modal oder der Hauptseite // Weiterblättern im Modal
try { const nextResult = await page.evaluate(() => {
const nextBtn = await page.$( const nextBtn = document.querySelector('div[role="dialog"] button[aria-label="Nächste Seite"], button[aria-label="Nächste Seite"], button[aria-label*="Nächste Seite"]');
'#hp-reviews-sliding button[aria-label*="Nächste"], #hp-reviews-sliding button[aria-label*="Next"], #reviewCardsSection button:has(svg), button[aria-label*="Nächste"], button[aria-label*="Next"], a.paging-next, li.bui-pagination__next-arrow a' if (nextBtn && !nextBtn.disabled && !nextBtn.hasAttribute('disabled')) {
); nextBtn.scrollIntoView({ behavior: 'instant', block: 'center' });
if (nextBtn) { nextBtn.click();
const isDisabled = await nextBtn.getAttribute('disabled'); nextBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
if (isDisabled === null) { return true;
await nextBtn.click({ force: true }); }
await page.waitForTimeout(2500); return false;
} else break; }).catch(() => false);
} else break;
} catch (e) { if (nextResult) {
await page.waitForTimeout(3000);
} else {
break; break;
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB