What Changed: - Added VERIFIED-PRICES.md with honest assessment - Added BUDGET-REALITY.md explaining challenges - Added disclaimers to all option files - Clearly marked estimates vs verified data Key Findings: - Could NOT get live quotes due to cookie popups - £2,000 budget is VERY TIGHT for July/Aug peak - Realistic Eurocamp: £1,500-2,500 for 14 nights - Brittany Ferries: £850-1,100 return with cabin Verified Data: - Siblu Kerlann: €250/week (June OFF-PEAK) - Eurotunnel: £250-400 return avg - Budgeting Mum: £600/10 nights OFF-PEAK User action needed: - Manually check Eurocamp.co.uk - Consider shorter duration - Consider gîte instead of mobile home
338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
const { chromium } = require('playwright');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SCREENSHOT_DIR = path.join(process.env.HOME, 'holiday-planning', 'price-evidence');
|
|
const OUTPUT_DIR = path.join(process.env.HOME, 'holiday-planning', 'prices');
|
|
|
|
async function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function safeClick(page, selector, description) {
|
|
try {
|
|
const el = page.locator(selector).first();
|
|
if (await el.isVisible({ timeout: 3000 })) {
|
|
await el.click();
|
|
console.log(`Clicked: ${description}`);
|
|
await sleep(500);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.log(`Could not click ${description}: ${e.message}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Starting Brittany Ferries price search v2...');
|
|
console.log('Date: 18 July 2026 - 2 August 2026, Plymouth-Roscoff, 2 adults, 1 child (6), 1 car');
|
|
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
});
|
|
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1920, height: 1080 },
|
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
|
|
const results = {
|
|
searchDate: new Date().toISOString(),
|
|
route: 'Plymouth to Roscoff',
|
|
outboundDate: '2026-07-18',
|
|
returnDate: '2026-08-02',
|
|
passengers: { adults: 2, children: 1, childAges: [6] },
|
|
vehicle: '1 car',
|
|
screenshots: [],
|
|
status: 'in_progress',
|
|
steps: []
|
|
};
|
|
|
|
try {
|
|
// Step 1: Navigate to Brittany Ferries ferry booking page
|
|
console.log('\n=== Step 1: Navigate to booking page ===');
|
|
await page.goto('https://www.brittany-ferries.co.uk/ferry', { waitUntil: 'networkidle', timeout: 60000 });
|
|
await sleep(3000);
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-01-ferry-page.png'), fullPage: false });
|
|
results.screenshots.push('v2-01-ferry-page.png');
|
|
results.steps.push('Loaded ferry booking page');
|
|
|
|
// Accept cookies
|
|
console.log('\n=== Step 2: Accept cookies ===');
|
|
try {
|
|
const acceptBtn = page.locator('#onetrust-accept-btn-handler').first();
|
|
if (await acceptBtn.isVisible({ timeout: 3000 })) {
|
|
await acceptBtn.click();
|
|
console.log('Accepted cookies');
|
|
await sleep(1000);
|
|
results.steps.push('Accepted cookies');
|
|
}
|
|
} catch (e) {
|
|
console.log('No cookie banner visible');
|
|
}
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-02-after-cookies.png'), fullPage: false });
|
|
results.screenshots.push('v2-02-after-cookies.png');
|
|
|
|
// Step 3: Select route - Plymouth to Roscoff
|
|
console.log('\n=== Step 3: Select route ===');
|
|
|
|
// Look for route selection dropdowns - they might be custom Angular components
|
|
const routeSelectors = await page.locator('[class*="route"], [class*="departure"], [class*="arrival"]').all();
|
|
console.log(`Found ${routeSelectors.length} route-related elements`);
|
|
|
|
// Try to find and click the departure port dropdown
|
|
const departureSelectors = [
|
|
'mat-form-field:has-text("From")',
|
|
'mat-form-field:has-text("Departure")',
|
|
'[data-cy="departure-port"]',
|
|
'.departure-port-selector',
|
|
'mat-select:has-text("Select")'
|
|
];
|
|
|
|
let departureClicked = false;
|
|
for (const selector of departureSelectors) {
|
|
try {
|
|
const el = page.locator(selector).first();
|
|
if (await el.isVisible({ timeout: 2000 })) {
|
|
await el.click();
|
|
console.log(`Clicked departure selector: ${selector}`);
|
|
departureClicked = true;
|
|
await sleep(1000);
|
|
break;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Try clicking on mat-select elements
|
|
if (!departureClicked) {
|
|
const matSelects = await page.locator('mat-select').all();
|
|
console.log(`Found ${matSelects.length} mat-select elements`);
|
|
|
|
// First mat-select is likely departure port
|
|
if (matSelects.length > 0) {
|
|
await matSelects[0].click();
|
|
console.log('Clicked first mat-select (departure port)');
|
|
await sleep(1000);
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-03-departure-dropdown.png'), fullPage: false });
|
|
results.screenshots.push('v2-03-departure-dropdown.png');
|
|
|
|
// Look for Plymouth option
|
|
const plymouthOptions = [
|
|
'mat-option:has-text("Plymouth")',
|
|
'.mat-option:has-text("Plymouth")',
|
|
'span:has-text("Plymouth")'
|
|
];
|
|
|
|
for (const opt of plymouthOptions) {
|
|
try {
|
|
const optEl = page.locator(opt).first();
|
|
if (await optEl.isVisible({ timeout: 2000 })) {
|
|
await optEl.click();
|
|
console.log('Selected Plymouth');
|
|
results.steps.push('Selected Plymouth as departure');
|
|
break;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
await sleep(500);
|
|
}
|
|
}
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-04-after-plymouth.png'), fullPage: false });
|
|
results.screenshots.push('v2-04-after-plymouth.png');
|
|
|
|
// Select arrival port - Roscoff
|
|
const matSelects2 = await page.locator('mat-select').all();
|
|
if (matSelects2.length > 1) {
|
|
await matSelects2[1].click();
|
|
console.log('Clicked second mat-select (arrival port)');
|
|
await sleep(1000);
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-05-arrival-dropdown.png'), fullPage: false });
|
|
results.screenshots.push('v2-05-arrival-dropdown.png');
|
|
|
|
// Look for Roscoff option
|
|
const roscoffOptions = [
|
|
'mat-option:has-text("Roscoff")',
|
|
'.mat-option:has-text("Roscoff")',
|
|
'span:has-text("Roscoff")'
|
|
];
|
|
|
|
for (const opt of roscoffOptions) {
|
|
try {
|
|
const optEl = page.locator(opt).first();
|
|
if (await optEl.isVisible({ timeout: 2000 })) {
|
|
await optEl.click();
|
|
console.log('Selected Roscoff');
|
|
results.steps.push('Selected Roscoff as arrival');
|
|
break;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
await sleep(500);
|
|
}
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-06-after-roscoff.png'), fullPage: false });
|
|
results.screenshots.push('v2-06-after-roscoff.png');
|
|
|
|
// Step 4: Enter dates
|
|
console.log('\n=== Step 4: Enter dates ===');
|
|
|
|
// Look for date inputs
|
|
const dateInputs = await page.locator('input[placeholder*="date"], input[type="text"]').all();
|
|
console.log(`Found ${dateInputs.length} text inputs`);
|
|
|
|
// Find outbound date input
|
|
const outboundInput = page.locator('input[placeholder*="Outbound"]').first();
|
|
if (await outboundInput.isVisible({ timeout: 2000 })) {
|
|
await outboundInput.click();
|
|
await sleep(500);
|
|
await outboundInput.fill('18/07/2026');
|
|
console.log('Entered outbound date: 18/07/2026');
|
|
await page.keyboard.press('Enter');
|
|
await sleep(500);
|
|
results.steps.push('Entered outbound date: 18/07/2026');
|
|
}
|
|
|
|
// Find inbound date input
|
|
const inboundInput = page.locator('input[placeholder*="Inbound"], input[placeholder*="Return"]').first();
|
|
if (await inboundInput.isVisible({ timeout: 2000 })) {
|
|
await inboundInput.click();
|
|
await sleep(500);
|
|
await inboundInput.fill('02/08/2026');
|
|
console.log('Entered inbound date: 02/08/2026');
|
|
await page.keyboard.press('Enter');
|
|
await sleep(500);
|
|
results.steps.push('Entered inbound date: 02/08/2026');
|
|
}
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-07-dates-entered.png'), fullPage: false });
|
|
results.screenshots.push('v2-07-dates-entered.png');
|
|
|
|
// Step 5: Configure passengers and vehicle
|
|
console.log('\n=== Step 5: Configure passengers and vehicle ===');
|
|
|
|
// Look for passenger selector
|
|
const passengerSelectors = await page.locator('[class*="passenger"], mat-select, [data-cy*="passenger"]').all();
|
|
console.log(`Found ${passengerSelectors.length} potential passenger selectors`);
|
|
|
|
// Try to find and interact with passenger dropdowns
|
|
// Usually there's a dropdown for adults, children, and vehicles
|
|
|
|
// Check if there are specific passenger count controls
|
|
const adultPlus = page.locator('[class*="adult"] button:has-text("+"), [data-cy="adult-plus"]').first();
|
|
const adultMinus = page.locator('[class*="adult"] button:has-text("-"), [data-cy="adult-minus"]').first();
|
|
|
|
// We need 2 adults - check current state and adjust
|
|
// This is tricky without seeing the actual UI
|
|
|
|
// Look for any dropdown that might be for passengers
|
|
const allSelects = await page.locator('mat-select').all();
|
|
console.log(`Total mat-select elements: ${allSelects.length}`);
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-08-before-passengers.png'), fullPage: false });
|
|
results.screenshots.push('v2-08-before-passengers.png');
|
|
|
|
// Step 6: Submit search
|
|
console.log('\n=== Step 6: Submit search ===');
|
|
|
|
const searchButtons = [
|
|
'button:has-text("Search")',
|
|
'button:has-text("Get quotes")',
|
|
'button:has-text("Find ferries")',
|
|
'button[type="submit"]',
|
|
'.search-button',
|
|
'[data-cy="search-button"]'
|
|
];
|
|
|
|
for (const btn of searchButtons) {
|
|
try {
|
|
const searchBtn = page.locator(btn).first();
|
|
if (await searchBtn.isVisible({ timeout: 1000 })) {
|
|
await searchBtn.click();
|
|
console.log(`Clicked search button: ${btn}`);
|
|
results.steps.push('Clicked search button');
|
|
break;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Wait for results
|
|
console.log('Waiting for results...');
|
|
await sleep(5000);
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-09-results-page.png'), fullPage: true });
|
|
results.screenshots.push('v2-09-results-page.png');
|
|
|
|
// Step 7: Extract pricing information
|
|
console.log('\n=== Step 7: Extract pricing ===');
|
|
|
|
const pageContent = await page.content();
|
|
fs.writeFileSync(path.join(SCREENSHOT_DIR, 'v2-results-page.html'), pageContent);
|
|
|
|
const pageText = await page.evaluate(() => document.body.innerText);
|
|
fs.writeFileSync(path.join(SCREENSHOT_DIR, 'v2-results-page.txt'), pageText);
|
|
|
|
// Try to extract price information
|
|
const pricePatterns = [
|
|
/£\d+/g,
|
|
/\d+\.\d{2}/g,
|
|
/total.*?£?(\d+)/gi
|
|
];
|
|
|
|
const prices = [];
|
|
for (const pattern of pricePatterns) {
|
|
const matches = pageText.match(pattern);
|
|
if (matches) {
|
|
prices.push(...matches);
|
|
}
|
|
}
|
|
|
|
console.log('Found potential prices:', prices.slice(0, 20));
|
|
results.extractedPrices = [...new Set(prices)].slice(0, 20);
|
|
|
|
// Try to find specific price elements
|
|
const priceElements = await page.locator('[class*="price"], [data-cy*="price"]').all();
|
|
const elementPrices = [];
|
|
for (const el of priceElements.slice(0, 10)) {
|
|
try {
|
|
const text = await el.textContent();
|
|
elementPrices.push(text);
|
|
} catch (e) {}
|
|
}
|
|
console.log('Price elements found:', elementPrices);
|
|
results.priceElements = elementPrices;
|
|
|
|
results.url = page.url();
|
|
results.status = 'completed';
|
|
results.finalUrl = page.url();
|
|
|
|
} catch (error) {
|
|
console.error('Error during scraping:', error);
|
|
results.status = 'error';
|
|
results.error = error.message;
|
|
results.stack = error.stack;
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'v2-error.png'), fullPage: true });
|
|
results.screenshots.push('v2-error.png');
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// Save results
|
|
const outputPath = path.join(OUTPUT_DIR, 'brittany-ferries-plymouth-roscoff.json');
|
|
fs.writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
console.log(`\n=== Results saved to: ${outputPath} ===`);
|
|
|
|
return results;
|
|
}
|
|
|
|
main().catch(console.error);
|