// ==UserScript==
// @name LATAM Flight Scraper
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Scrape LATAM Airlines flight information
// @author Your Name
// @match https://www.latamairlines.com/*/*
// @match https://www.latamairlines.com/*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_download
// @grant GM_addStyle
// @grant GM_getResourceText
// @grant unsafeWindow
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://code.jquery.com/ui/1.13.2/jquery-ui.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/daterangepicker.min.js
// @resource DATERANGEPICKER_CSS https://cdn.jsdelivr.net/npm/[email protected]/daterangepicker.css
// @resource JQUERYUI_CSS https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css
// ==/UserScript==
(function() {
'use strict';
// Add daterangepicker and jQuery UI CSS
GM_addStyle(GM_getResourceText('DATERANGEPICKER_CSS'));
GM_addStyle(GM_getResourceText('JQUERYUI_CSS'));
// Add custom CSS for z-index fixes and resizable styles
GM_addStyle(`
.daterangepicker {
z-index: 100000 !important;
}
.ui-resizable-handle {
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 3px;
}
.ui-resizable-se {
width: 12px;
height: 12px;
right: -5px;
bottom: -5px;
background-color: #fff;
box-shadow: 0 0 3px rgba(0,0,0,0.2);
}
#scraper-container {
min-width: 300px;
min-height: 400px;
resize: both;
overflow: auto;
}
.scraper-header {
cursor: move;
padding: 8px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
margin: -15px -15px 15px -15px;
border-radius: 5px 5px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.scraper-header h3 {
margin: 0;
font-size: 16px;
color: #495057;
}
.form-content {
overflow-y: auto;
height: calc(100% - 50px);
padding-right: 5px;
}
.form-content::-webkit-scrollbar {
width: 8px;
}
.form-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.form-content::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.form-content::-webkit-scrollbar-thumb:hover {
background: #555;
}
.status-section {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 9999;
background: white;
padding: 15px;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
width: 300px;
font-family: Arial, sans-serif;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #ccc;
}
.status-content {
max-height: 200px;
overflow-y: auto;
}
.status-item {
margin: 5px 0;
padding: 5px;
border-radius: 3px;
}
.status-success {
background-color: #d4edda;
color: #155724;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
}
.status-warning {
background-color: #fff3cd;
color: #856404;
}
.status-info {
background-color: #cce5ff;
color: #004085;
}
.step-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.step-success { background-color: #28a745; }
.step-error { background-color: #dc3545; }
.step-pending { background-color: #ffc107; }
.step-inactive { background-color: #6c757d; }
`);
// Ensure jQuery is loaded and available
let $ = window.jQuery || unsafeWindow.jQuery;
// Configuration object to store search parameters
const config = {
searches: [],
currentSearchIndex: 0,
currentDateIndex: 0,
isProcessing: false,
results: []
};
// Load saved state
function loadState() {
const savedState = GM_getValue('scraperState');
if (savedState) {
Object.assign(config, JSON.parse(savedState));
logger.log('Loaded saved state', 'info');
logger.log(`Current search: ${config.currentSearchIndex}, Current date: ${config.currentDateIndex}`);
logger.log(`Results collected so far: ${config.results.length}`);
}
}
// Save current state
function saveState() {
GM_setValue('scraperState', JSON.stringify(config));
logger.log('State saved', 'info');
}
// Clear saved state
function clearState() {
GM_deleteValue('scraperState');
Object.assign(config, {
searches: [],
currentSearchIndex: 0,
currentDateIndex: 0,
isProcessing: false,
results: []
});
logger.log('State cleared', 'info');
}
// Logger class for tracking progress and issues
class Logger {
constructor() {
this.container = null;
this.logArea = null;
this.maxLogs = 100;
this.setupLogger();
}
setupLogger() {
// Create logger container
this.container = document.createElement('div');
this.container.style.cssText = `
position: fixed;
bottom: 10px;
right: 10px;
z-index: 9999;
background: white;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
width: 400px;
max-height: 300px;
display: flex;
flex-direction: column;
`;
// Add header
const header = document.createElement('div');
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
`;
const title = document.createElement('span');
title.textContent = 'Scraper Log';
title.style.fontWeight = 'bold';
const clearButton = document.createElement('button');
clearButton.textContent = 'Clear';
clearButton.style.cssText = `
padding: 2px 8px;
background: #dc3545;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
`;
clearButton.onclick = () => this.clear();
header.appendChild(title);
header.appendChild(clearButton);
this.container.appendChild(header);
// Create log area
this.logArea = document.createElement('div');
this.logArea.style.cssText = `
overflow-y: auto;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
flex-grow: 1;
`;
this.container.appendChild(this.logArea);
// Add to document
document.body.appendChild(this.container);
}
log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.style.cssText = `
margin: 2px 0;
padding: 2px 5px;
border-radius: 3px;
`;
switch(type) {
case 'error':
logEntry.style.backgroundColor = '#ffebee';
logEntry.style.color = '#c62828';
break;
case 'success':
logEntry.style.backgroundColor = '#e8f5e9';
logEntry.style.color = '#2e7d32';
break;
case 'warning':
logEntry.style.backgroundColor = '#fff3e0';
logEntry.style.color = '#ef6c00';
break;
default:
logEntry.style.backgroundColor = '#e3f2fd';
logEntry.style.color = '#1565c0';
}
logEntry.textContent = `[${timestamp}] ${message}`;
this.logArea.appendChild(logEntry);
this.logArea.scrollTop = this.logArea.scrollHeight;
// Limit the number of log entries
while (this.logArea.children.length > this.maxLogs) {
this.logArea.removeChild(this.logArea.firstChild);
}
// Also log to console for debugging
console.log(`[${type.toUpperCase()}] ${message}`);
}
clear() {
while (this.logArea.firstChild) {
this.logArea.removeChild(this.logArea.firstChild);
}
}
}
// Create logger instance
const logger = new Logger();
// Helper function to format dates
function formatDate(date) {
// Ensure we're working with a valid date string in YYYY-MM-DD format
if (typeof date === 'string') {
// If it's already in YYYY-MM-DD format, return as is
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
return date;
}
// If it's a Date object, convert to YYYY-MM-DD
date = new Date(date);
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Generate array of dates between start and end
function generateDateRange(startDate, endDate) {
const dates = [];
const start = moment(startDate).startOf('day');
const end = moment(endDate).endOf('day');
const current = start.clone();
while (current.isSameOrBefore(end, 'day')) {
dates.push(current.format('YYYY-MM-DD'));
current.add(1, 'days');
}
return dates;
}
// Generate all possible date combinations for round trips
function generateDateCombinations(outboundDates, returnDates) {
const combinations = [];
const outboundRange = generateDateRange(outboundDates.start, outboundDates.end);
const returnRange = generateDateRange(returnDates.start, returnDates.end);
for (const outboundDate of outboundRange) {
const outboundDateTime = new Date(outboundDate);
outboundDateTime.setUTCHours(0, 0, 0, 0);
for (const returnDate of returnRange) {
const returnDateTime = new Date(returnDate);
returnDateTime.setUTCHours(0, 0, 0, 0);
if (returnDateTime > outboundDateTime) {
combinations.push({
outbound: outboundDate,
return: returnDate
});
}
}
}
combinations.sort((a, b) => {
const dateCompare = new Date(a.outbound) - new Date(b.outbound);
if (dateCompare === 0) {
return new Date(a.return) - new Date(b.return);
}
return dateCompare;
});
return combinations;
}
// Function to check if flight cards are present
function waitForFlightCards(maxAttempts = 10) {
return new Promise((resolve, reject) => {
let attempts = 0;
const checkForCards = () => {
const flightCards = document.querySelectorAll('[data-testid^="flight-card-"], [data-testid^="flight-info-"]');
if (flightCards.length > 0) {
resolve(true);
} else if (attempts >= maxAttempts) {
resolve(false);
} else {
attempts++;
setTimeout(checkForCards, 2000);
}
};
checkForCards();
});
}
// Helper function to parse price values
function parsePriceValue(price) {
if (!price) return Infinity;
if (price.includes('pontos')) {
// Handle points format: "130.000 pontos + R$ 63,90"
const pointsMatch = price.match(/(\d+[\d,.]*)\s*pontos/);
return pointsMatch ? parseFloat(pointsMatch[1].replace(/[.,]/g, '')) : Infinity;
} else {
// Handle regular price format: "R$ 2.530,90"
const priceMatch = price.match(/R\$\s*(\d+[\d,.]*)/);
return priceMatch ? parseFloat(priceMatch[1].replace(/\./g, '').replace(',', '.')) : Infinity;
}
}
// Extract flight information from the page
function extractFlightInfo() {
const flights = [];
const currentSearch = config.searches[config.currentSearchIndex];
logger.log(`Attempting to extract flights for ${currentSearch.origin} to ${currentSearch.destination} on ${currentSearch.currentDate}`);
// Check if we're on the return flight page
const returnTitle = document.querySelector('#titleSelectFlightDesktop .route-title');
const isReturnPage = returnTitle && returnTitle.textContent.trim().toLowerCase() === 'voo de volta';
// Try both possible selectors for flight cards
const flightCards = document.querySelectorAll('[data-testid^="flight-card-"], [data-testid^="flight-info-"], [data-testid^="wrapper-card-flight-"]');
if (!flightCards.length) {
return null;
}
// For round trips, we need to handle both outbound and inbound flights
const isRoundTrip = currentSearch.trip_type === 'round_trip';
const flightType = isRoundTrip ? (isReturnPage ? 'return' : 'outbound') : 'one_way';
// Generate a unique ID for this flight combination
let combinationId;
if (isRoundTrip) {
// For round trips, use both outbound and return dates in the combination ID
combinationId = `${currentSearch.origin}_${currentSearch.destination}_${currentSearch.currentDate}_${currentSearch.currentReturnDate}_${currentSearch.use_points ? 'points' : 'cash'}_${currentSearch.trip_type}`;
} else {
// For one-way flights
combinationId = `${currentSearch.origin}_${currentSearch.destination}_${currentSearch.currentDate}_${currentSearch.use_points ? 'points' : 'cash'}_${currentSearch.trip_type}`;
}
logger.log(`Processing combination ID: ${combinationId}`, 'info');
flightCards.forEach((card, index) => {
try {
// Extract origin info
const originInfo = card.querySelector('[data-testid$="-origin"]');
const departureTime = originInfo?.querySelector('.flightInfostyles__TextHourFlight-sc__sc-edlvrg-4')?.textContent.trim();
const originAirport = originInfo?.querySelector('.flightInfostyles__TextIATA-sc__sc-edlvrg-5')?.textContent.trim();
// Extract destination info
const destinationInfo = card.querySelector('[data-testid$="-destination"]');
const arrivalTimeElement = destinationInfo?.querySelector('.flightInfostyles__TextHourFlight-sc__sc-edlvrg-4');
let arrivalTime = arrivalTimeElement?.textContent.trim();
const nextDayIndicator = arrivalTimeElement?.querySelector('.flightInfostyles__TextDaysDifference-sc__sc-edlvrg-6')?.textContent.trim();
const destinationAirport = destinationInfo?.querySelector('.flightInfostyles__TextIATA-sc__sc-edlvrg-5')?.textContent.trim();
// Extract duration
const duration = card.querySelector('[data-testid$="-duration"] span:last-child')?.textContent.trim();
// Extract price information
const priceInfo = card.querySelector('[data-testid$="-amount"]');
let price = '';
let taxInfo = '';
if (currentSearch.use_points) {
// Points format
const pointsAmount = priceInfo?.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2')?.textContent.trim();
const additionalFees = priceInfo?.querySelector('.displayCurrencystyle__Description-sc__sc-hel5vp-5')?.textContent.trim();
price = `${pointsAmount || ''} ${additionalFees || ''}`.trim();
} else {
// Regular price format
price = priceInfo?.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2')?.textContent.trim() || '';
}
// Get taxes info if available
taxInfo = priceInfo?.querySelector('.flightInfostyles__TaxesFeesIncludedText-sc__sc-edlvrg-10')?.textContent.trim();
const flightInfo = {
combination_id: combinationId,
trip_type: currentSearch.trip_type,
flight_type: flightType,
outbound_date: currentSearch.currentDate,
return_date: isRoundTrip ? currentSearch.currentReturnDate : '',
date: isReturnPage ? currentSearch.currentReturnDate : currentSearch.currentDate,
origin: originAirport || (isReturnPage ? currentSearch.destination : currentSearch.origin),
destination: destinationAirport || (isReturnPage ? currentSearch.origin : currentSearch.destination),
departure_time: departureTime,
arrival_time: arrivalTime,
next_day: nextDayIndicator ? true : false,
duration: duration,
price: price,
taxes_included: taxInfo ? true : false,
use_points: currentSearch.use_points,
raw_price_value: parsePriceValue(price)
};
flights.push(flightInfo);
} catch (e) {
logger.log(`Error extracting flight ${index + 1}: ${e.message}`, 'error');
}
});
// Sort flights by price and get only the cheapest one
if (flights.length > 0) {
const cheapestFlight = flights
.sort((a, b) => a.raw_price_value - b.raw_price_value)[0];
// Remove the raw_price_value before returning
const { raw_price_value, ...cleanFlight } = cheapestFlight;
logger.log(`Selected cheapest ${flightType} flight: ${cleanFlight.departure_time} -> ${cleanFlight.arrival_time} - ${cleanFlight.price}`, 'success');
return [cleanFlight]; // Return array with single cheapest flight
}
return null;
}
// Save results to CSV file
function saveToCSV() {
if (!config.results.length) {
logger.log('No results to save', 'warning');
return;
}
logger.log(`Preparing to save ${config.results.length} flights to CSV`);
try {
// Define headers for both outbound and return flights
const headers = [
'combination_id',
'trip_type',
'use_points',
'status',
'retries',
'total_price',
// Outbound flight fields
'outbound_date',
'outbound_origin',
'outbound_destination',
'outbound_departure_time',
'outbound_arrival_time',
'outbound_next_day',
'outbound_duration',
'outbound_price',
'outbound_taxes_included',
// Return flight fields
'return_date',
'return_origin',
'return_destination',
'return_departure_time',
'return_arrival_time',
'return_next_day',
'return_duration',
'return_price',
'return_taxes_included'
];
// Create CSV content with proper line endings
let csvRows = [];
// Add headers
csvRows.push(headers.join(','));
// Group flights by combination_id
const flightGroups = {};
config.results.forEach(flight => {
if (!flightGroups[flight.combination_id]) {
flightGroups[flight.combination_id] = {
outbound: null,
return: null,
status: flight.status || 'success',
retries: flight.retries || 0,
trip_type: flight.trip_type,
use_points: flight.use_points
};
}
if (flight.status === 'failed') {
flightGroups[flight.combination_id].status = 'failed';
flightGroups[flight.combination_id].retries = flight.retries;
} else if (flight.flight_type === 'outbound' || flight.flight_type === 'one_way') {
flightGroups[flight.combination_id].outbound = flight;
} else if (flight.flight_type === 'return') {
flightGroups[flight.combination_id].return = flight;
}
});
// Process each flight group into a single row
Object.entries(flightGroups).forEach(([combinationId, group]) => {
const outbound = group.outbound;
const return_flight = group.return;
const status = group.status;
const retries = group.retries;
// Calculate total price only for successful searches
let total_price = '';
if (status === 'success' && outbound?.price) {
if (outbound.trip_type === 'one_way' || return_flight?.price) {
if (outbound.trip_type === 'one_way') {
total_price = outbound.price;
} else {
// For round trips, combine prices if both are available
const outPrice = parsePriceValue(outbound.price);
const returnPrice = parsePriceValue(return_flight.price);
if (outbound.use_points) {
total_price = `${outPrice + returnPrice} pontos`;
} else {
total_price = `R$ ${(outPrice + returnPrice).toFixed(2)}`;
}
}
}
}
const row = [
combinationId,
group.trip_type || '',
group.use_points ? 'Yes' : 'No',
status,
retries,
total_price,
// Outbound flight data (empty if failed)
status === 'success' ? (outbound?.date || '') : '',
status === 'success' ? (outbound?.origin || '') : '',
status === 'success' ? (outbound?.destination || '') : '',
status === 'success' ? (outbound?.departure_time || '') : '',
status === 'success' ? (outbound?.arrival_time || '') : '',
status === 'success' ? (outbound?.next_day ? 'Yes' : 'No') : '',
status === 'success' ? (outbound?.duration || '') : '',
status === 'success' ? (outbound?.price || '') : '',
status === 'success' ? (outbound?.taxes_included ? 'Yes' : 'No') : '',
// Return flight data (empty if failed or one-way)
status === 'success' ? (return_flight?.date || '') : '',
status === 'success' ? (return_flight?.origin || '') : '',
status === 'success' ? (return_flight?.destination || '') : '',
status === 'success' ? (return_flight?.departure_time || '') : '',
status === 'success' ? (return_flight?.arrival_time || '') : '',
status === 'success' ? (return_flight?.next_day ? 'Yes' : 'No') : '',
status === 'success' ? (return_flight?.duration || '') : '',
status === 'success' ? (return_flight?.price || '') : '',
status === 'success' ? (return_flight?.taxes_included ? 'Yes' : 'No') : ''
].map(value => {
// Handle values that might contain commas
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
csvRows.push(row.join(','));
});
// Create the final CSV content with BOM for Excel
const BOM = new Uint8Array([0xEF, 0xBB, 0xBF]);
const csvContent = csvRows.join('\r\n');
const blob = new Blob([BOM, csvContent], { type: 'text/csv;charset=utf-8' });
// Generate filename using the first flight's information
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const firstResult = config.results[0];
const filename = `flights_${firstResult.origin}_${firstResult.destination}_${firstResult.use_points ? 'points' : 'cash'}_${firstResult.trip_type}_${timestamp}.csv`;
// Create object URL and trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
logger.log(`Successfully saved results to ${filename}`, 'success');
} catch (e) {
logger.log(`Error saving CSV: ${e.message}`, 'error');
}
}
// Process next search or date
function processNext() {
if (!config.isProcessing) {
logger.log('Processing is not active', 'info');
return;
}
const currentSearch = config.searches[config.currentSearchIndex];
if (!currentSearch) {
logger.log('All searches completed', 'success');
config.isProcessing = false;
saveState();
saveToCSV();
clearState(); // Clear state after completion
return;
}
logger.log(`Processing search ${config.currentSearchIndex + 1} of ${config.searches.length}`, 'info');
let datesToProcess;
if (currentSearch.trip_type === 'round_trip') {
// For round trips, generate all date combinations if not already generated
if (!currentSearch.dateCombinations) {
currentSearch.dateCombinations = generateDateCombinations(
currentSearch.outbound_dates,
currentSearch.return_dates
);
logger.log(`Generated ${currentSearch.dateCombinations.length} date combinations for round trip`, 'info');
}
datesToProcess = currentSearch.dateCombinations;
} else {
// For one-way trips, generate all dates in the range if not already generated
if (!currentSearch.dateRange) {
currentSearch.dateRange = generateDateRange(
currentSearch.outbound_dates.start,
currentSearch.outbound_dates.end
);
logger.log(`Generated ${currentSearch.dateRange.length} dates for one-way trip`, 'info');
}
datesToProcess = currentSearch.dateRange.map(date => ({ outbound: date }));
}
if (config.currentDateIndex >= datesToProcess.length) {
logger.log(`Completed search for ${currentSearch.origin} to ${currentSearch.destination}`, 'success');
config.currentSearchIndex++;
config.currentDateIndex = 0;
saveState();
processNext();
return;
}
const currentDateCombo = datesToProcess[config.currentDateIndex];
currentSearch.currentDate = currentDateCombo.outbound;
if (currentSearch.trip_type === 'round_trip') {
currentSearch.currentReturnDate = currentDateCombo.return;
logger.log(`Processing combination ${config.currentDateIndex + 1} of ${datesToProcess.length}`, 'info');
} else {
logger.log(`Processing date ${config.currentDateIndex + 1} of ${datesToProcess.length}`, 'info');
}
logger.log(`Processing ${currentSearch.origin} to ${currentSearch.destination}`, 'info');
logger.log(`Outbound: ${currentSearch.currentDate}${currentSearch.trip_type === 'round_trip' ? `, Return: ${currentSearch.currentReturnDate}` : ''}`, 'info');
// Save state before navigation
saveState();
// Construct URL for the current search
const baseUrl = 'https://www.latamairlines.com/br/pt/oferta-voos';
// Create params in the exact order as LATAM's website
const params = new URLSearchParams();
params.append('origin', currentSearch.origin);
params.append('outbound', `${currentSearch.currentDate}T00:00:00.000Z`);
params.append('destination', currentSearch.destination);
// For round trips, add inbound date right after destination
if (currentSearch.trip_type === 'round_trip') {
params.append('inbound', `${currentSearch.currentReturnDate}T00:00:00.000Z`);
}
// Add remaining parameters in LATAM's order
params.append('adt', '1');
params.append('chd', '0');
params.append('inf', '0');
params.append('trip', currentSearch.trip_type === 'round_trip' ? 'RT' : 'OW');
params.append('cabin', currentSearch.cabin_class);
params.append('redemption', currentSearch.use_points.toString());
params.append('sort', 'RECOMMENDED');
const url = `${baseUrl}?${params.toString()}`;
window.location.href = url;
}
// Main function to start scraping
function startScraping(searchConfig) {
logger.log('Starting new scraping session', 'info');
logger.log(`Loaded ${searchConfig.searches.length} searches to process`);
// Clear any existing state
clearState();
config.searches = searchConfig.searches;
config.currentSearchIndex = 0;
config.currentDateIndex = 0;
config.isProcessing = true;
config.results = [];
// Save initial state
saveState();
processNext();
}
// Function to select the cheapest flight card
async function selectCheapestFlightCard() {
const flightCards = document.querySelectorAll('[data-testid^="flight-card-"], [data-testid^="flight-info-"]');
if (!flightCards.length) {
logger.log('No flight cards found to select', 'error');
return false;
}
let cheapestCard = null;
let cheapestPrice = Infinity;
flightCards.forEach(card => {
const priceInfo = card.querySelector('[data-testid$="-amount"]');
const priceText = priceInfo?.textContent.trim() || '';
const priceValue = parsePriceValue(priceText);
if (priceValue < cheapestPrice) {
cheapestPrice = priceValue;
cheapestCard = card;
}
});
if (cheapestCard) {
logger.log('Found cheapest flight card, clicking to reveal tariffs...', 'info');
cheapestCard.click();
return true;
}
return false;
}
// Function to select the cheapest tariff option
async function selectCheapestTariff() {
return new Promise((resolve, reject) => {
let navigationAttempts = 0;
const maxNavigationAttempts = 20;
let waitingForButton = true;
let buttonCheckAttempts = 0;
const maxButtonCheckAttempts = 10;
const checkForTariffs = () => {
// Look for the tariff list using the correct selector
const tariffList = document.querySelector('ol');
if (!tariffList) {
setTimeout(checkForTariffs, 1000);
return;
}
// Find all tariff items (li elements)
const tariffItems = tariffList.querySelectorAll('li[data-brand]');
if (!tariffItems.length) {
logger.log('No tariff options found', 'error');
resolve(false);
return;
}
logger.log(`Found ${tariffItems.length} tariff options`, 'info');
let cheapestTariff = null;
let cheapestPrice = Infinity;
tariffItems.forEach((item, index) => {
// Find the price element within the tariff item
const priceElement = item.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2');
if (priceElement) {
const priceText = priceElement.textContent.trim();
const priceValue = parsePriceValue(priceText);
if (priceValue < cheapestPrice) {
cheapestPrice = priceValue;
cheapestTariff = item;
}
}
});
if (cheapestTariff) {
const waitForButton = () => {
// Find the "Escolher" button using the correct selector
const selectButton = cheapestTariff.querySelector('button[data-testid$="-flight-select"]');
if (selectButton && selectButton.offsetParent !== null) { // Check if button is visible
selectButton.click();
waitingForButton = false;
// After clicking, wait for return flight page to load
const waitForReturnPage = () => {
// Check for the specific return flight title
const returnTitle = document.querySelector('#titleSelectFlightDesktop .route-title');
const hasReturnTitle = returnTitle && returnTitle.textContent.trim().toLowerCase() === 'voo de volta';
// Check for return flight cards
const returnCards = document.querySelectorAll('[data-testid^="wrapper-card-flight-"]');
const hasReturnCards = returnCards.length > 0;
// Check if price elements are loaded in the return cards
const allPricesLoaded = Array.from(returnCards).every(card => {
const priceElement = card.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2');
return priceElement && priceElement.textContent.trim() !== '';
});
if (hasReturnTitle && hasReturnCards && allPricesLoaded) {
logger.log('Return flight page loaded successfully with all elements', 'success');
// Extract return flight information
const returnFlights = extractFlightInfo();
if (returnFlights && returnFlights.length) {
config.results.push(...returnFlights);
logger.log(`Successfully extracted ${returnFlights.length} return flights`, 'success');
saveState();
// Move to next date
config.currentDateIndex++;
logger.log('Moving to next date after saving return flight info...');
saveState();
setTimeout(processNext, 2000);
} else {
logger.log('Failed to extract return flights, moving to next date', 'error');
config.currentDateIndex++;
saveState();
setTimeout(processNext, 2000);
}
resolve(true);
return;
}
if (navigationAttempts >= maxNavigationAttempts) {
logger.log('Max attempts reached waiting for return flight page', 'error');
resolve(false);
return;
}
navigationAttempts++;
setTimeout(waitForReturnPage, 1000);
};
setTimeout(waitForReturnPage, 1000);
return;
}
if (buttonCheckAttempts >= maxButtonCheckAttempts) {
logger.log('Max attempts reached waiting for "Escolher" button', 'error');
resolve(false);
return;
}
buttonCheckAttempts++;
setTimeout(waitForButton, 1000);
};
waitForButton();
} else {
logger.log('Could not find cheapest tariff', 'error');
resolve(false);
}
};
checkForTariffs();
});
}
// Function to check if we're on the return flight page and wait for it to load
async function waitForReturnFlightPage(maxAttempts = 20) {
return new Promise((resolve) => {
let attempts = 0;
const checkForReturnPage = () => {
// First check for the return flight header
const returnHeader = document.querySelector('h1, h2, h3, h4, h5, h6');
const isReturnPage = returnHeader && returnHeader.textContent.includes('voo de volta');
if (!isReturnPage) {
if (attempts >= maxAttempts) {
logger.log('Not on return flight page', 'info');
resolve(false);
return;
}
attempts++;
setTimeout(checkForReturnPage, 1000);
return;
}
// Then check for flight cards
const flightCards = document.querySelectorAll('[data-testid^="wrapper-card-flight-"]');
const hasFlightCards = flightCards.length > 0;
// Check if price elements are loaded
const allPricesLoaded = Array.from(flightCards).every(card => {
const priceElement = card.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2');
return priceElement && priceElement.textContent.trim() !== '';
});
if (hasFlightCards && allPricesLoaded) {
logger.log('Return flight page fully loaded with prices', 'success');
resolve(true);
return;
}
if (attempts >= maxAttempts) {
logger.log('Return flight page elements not fully loaded', 'error');
resolve(false);
return;
}
attempts++;
setTimeout(checkForReturnPage, 1000);
};
checkForReturnPage();
});
}
// Function to wait for tariff list to be fully loaded
function waitForTariffList(maxAttempts = 20) {
return new Promise((resolve) => {
let attempts = 0;
const checkForTariffList = () => {
const tariffList = document.querySelector('ol');
const tariffItems = tariffList?.querySelectorAll('li[data-brand]');
if (tariffItems?.length > 0) {
// Check if all price elements are loaded
const allPricesLoaded = Array.from(tariffItems).every(item => {
const priceElement = item.querySelector('.displayCurrencystyle__CurrencyAmount-sc__sc-hel5vp-2');
return priceElement && priceElement.textContent.trim() !== '';
});
if (allPricesLoaded) {
resolve(true);
return;
}
}
if (attempts >= maxAttempts) {
resolve(false);
return;
}
attempts++;
setTimeout(checkForTariffList, 1000);
};
checkForTariffList();
});
}
// Create status display
function createStatusDisplay() {
const statusContainer = document.createElement('div');
statusContainer.className = 'status-section';
statusContainer.innerHTML = `
<div class="status-header">
<strong>Scraping Status</strong>
<button class="minimize-status" style="padding: 2px 8px;">_</button>
</div>
<div class="status-content"></div>
`;
document.body.appendChild(statusContainer);
// Add minimize functionality
const minimizeBtn = statusContainer.querySelector('.minimize-status');
const statusContent = statusContainer.querySelector('.status-content');
let isMinimized = false;
minimizeBtn.addEventListener('click', () => {
if (isMinimized) {
statusContent.style.display = 'block';
minimizeBtn.textContent = '_';
} else {
statusContent.style.display = 'none';
minimizeBtn.textContent = '□';
}
isMinimized = !isMinimized;
});
return statusContainer;
}
// Update status display
function updateStatus(message, type = 'info', steps = null) {
const statusContent = document.querySelector('.status-content');
if (!statusContent) return;
const statusItem = document.createElement('div');
statusItem.className = `status-item status-${type}`;
let stepsHtml = '';
if (steps) {
stepsHtml = '<div style="margin-top: 5px;">';
const stepNames = ['Outbound Flight', 'Tariff Selection', 'Return Flight'];
stepNames.forEach((step, index) => {
const status = steps[index] === true ? 'success' :
steps[index] === false ? 'error' :
steps[index] === 'pending' ? 'pending' : 'inactive';
stepsHtml += `
<div style="margin: 2px 0;">
<span class="step-indicator step-${status}"></span>
${step}: ${steps[index] === true ? '✓' :
steps[index] === false ? '✗' :
steps[index] === 'pending' ? '...' : '-'}
</div>`;
});
stepsHtml += '</div>';
}
statusItem.innerHTML = `
<div>${message}</div>
${stepsHtml}
`;
statusContent.appendChild(statusItem);
statusContent.scrollTop = statusContent.scrollHeight;
}
// Modified handlePageLoad function
async function handlePageLoad() {
loadState();
if (!config.isProcessing) return;
// Check for error message
const errorDiv = document.querySelector('.error-message, .error-title, .error-description');
if (errorDiv && errorDiv.textContent.includes('Não foi possível encontrar voos')) {
const currentSearch = config.searches[config.currentSearchIndex];
const retryCount = currentSearch.retryCount || 0;
updateStatus(
`No flights found for ${currentSearch.origin} to ${currentSearch.destination} on ${currentSearch.currentDate}`,
'error',
[false, null, null]
);
if (retryCount < 3) {
currentSearch.retryCount = retryCount + 1;
updateStatus(`Retrying search (${retryCount + 1}/3)...`, 'warning');
saveState();
setTimeout(processNext, 5000);
return;
} else {
updateStatus('Max retries reached, moving to next search', 'error');
const failedFlight = {
combination_id: `${currentSearch.origin}_${currentSearch.destination}_${currentSearch.currentDate}_${currentSearch.currentReturnDate || ''}_${currentSearch.use_points ? 'points' : 'cash'}_${currentSearch.trip_type}`,
trip_type: currentSearch.trip_type,
flight_type: currentSearch.trip_type === 'round_trip' ? 'outbound' : 'one_way',
outbound_date: currentSearch.currentDate,
return_date: currentSearch.currentReturnDate || '',
date: currentSearch.currentDate,
origin: currentSearch.origin,
destination: currentSearch.destination,
status: 'failed',
retries: 3,
use_points: currentSearch.use_points
};
config.results.push(failedFlight);
currentSearch.retryCount = 0;
config.currentDateIndex++;
saveState();
setTimeout(processNext, 2000);
return;
}
}
// Wait for flight cards to appear
waitForFlightCards().then(async found => {
if (found) {
const currentSearch = config.searches[config.currentSearchIndex];
const isRoundTrip = currentSearch.trip_type === 'round_trip';
currentSearch.retryCount = 0;
if (isRoundTrip) {
const returnTitle = document.querySelector('#titleSelectFlightDesktop .route-title');
const isReturnPage = returnTitle && returnTitle.textContent.trim().toLowerCase() === 'voo de volta';
if (isReturnPage) {
updateStatus(
`Processing return flight for ${currentSearch.destination} to ${currentSearch.origin}`,
'info',
[true, true, 'pending']
);
await waitForFlightCards();
const flights = extractFlightInfo();
if (flights && flights.length) {
config.results.push(...flights);
updateStatus(
`Successfully extracted return flight information`,
'success',
[true, true, true]
);
saveState();
config.currentDateIndex++;
setTimeout(processNext, 2000);
} else {
updateStatus(
`Failed to extract return flight information`,
'error',
[true, true, false]
);
const retryCount = currentSearch.retryCount || 0;
if (retryCount < 3) {
currentSearch.retryCount = retryCount + 1;
updateStatus(`Retrying search (${retryCount + 1}/3)...`, 'warning');
setTimeout(processNext, 5000);
} else {
config.currentDateIndex++;
setTimeout(processNext, 2000);
}
}
} else {
updateStatus(
`Processing outbound flight for ${currentSearch.origin} to ${currentSearch.destination}`,
'info',
[null, null, null]
);
const outboundFlights = extractFlightInfo();
if (outboundFlights && outboundFlights.length) {
config.results.push(...outboundFlights);
updateStatus(
`Successfully extracted outbound flight information`,
'success',
[true, 'pending', null]
);
saveState();
const cardSelected = await selectCheapestFlightCard();
if (cardSelected) {
updateStatus(
`Selected outbound flight`,
'info',
[true, 'pending', null]
);
const tariffListLoaded = await waitForTariffList();
if (tariffListLoaded) {
const tariffSelected = await selectCheapestTariff();
if (tariffSelected) {
updateStatus(
`Selected tariff`,
'success',
[true, true, 'pending']
);
} else {
updateStatus(
`Failed to select tariff`,
'error',
[true, false, null]
);
const retryCount = currentSearch.retryCount || 0;
if (retryCount < 3) {
currentSearch.retryCount = retryCount + 1;
updateStatus(`Retrying search (${retryCount + 1}/3)...`, 'warning');
setTimeout(processNext, 5000);
} else {
config.currentDateIndex++;
setTimeout(processNext, 2000);
}
}
}
}
}
}
} else {
updateStatus(
`Processing one-way flight for ${currentSearch.origin} to ${currentSearch.destination}`,
'info',
[null, null, null]
);
const flights = extractFlightInfo();
if (flights && flights.length) {
config.results.push(...flights);
updateStatus(
`Successfully extracted flight information`,
'success',
[true, null, null]
);
saveState();
} else {
updateStatus(
`Failed to extract flight information`,
'error',
[false, null, null]
);
const retryCount = currentSearch.retryCount || 0;
if (retryCount < 3) {
currentSearch.retryCount = retryCount + 1;
updateStatus(`Retrying search (${retryCount + 1}/3)...`, 'warning');
setTimeout(processNext, 5000);
return;
}
}
config.currentDateIndex++;
setTimeout(processNext, 2000);
}
} else {
const currentSearch = config.searches[config.currentSearchIndex];
updateStatus(
`No flight cards found for ${currentSearch.origin} to ${currentSearch.destination}`,
'error',
[false, null, null]
);
const retryCount = currentSearch.retryCount || 0;
if (retryCount < 3) {
currentSearch.retryCount = retryCount + 1;
updateStatus(`Retrying search (${retryCount + 1}/3)...`, 'warning');
setTimeout(processNext, 5000);
} else {
config.currentDateIndex++;
setTimeout(processNext, 2000);
}
}
});
}
// Add UI elements
function addUI() {
const container = document.createElement('div');
container.id = 'scraper-container';
container.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
z-index: 9999;
background: white;
padding: 15px;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
width: 350px;
height: 600px;
font-family: Arial, sans-serif;
`;
// Add draggable header
const header = document.createElement('div');
header.className = 'scraper-header';
header.innerHTML = `
<h3>LATAM Flight Scraper</h3>
<div class="header-buttons">
<button class="minimize-btn" style="padding: 2px 8px; margin-left: 5px;">_</button>
<button class="close-btn" style="padding: 2px 8px; margin-left: 5px;">×</button>
</div>
`;
container.appendChild(header);
// Create content wrapper for scrolling
const contentWrapper = document.createElement('div');
contentWrapper.className = 'form-content';
container.appendChild(contentWrapper);
// Create tabs
const tabContainer = document.createElement('div');
tabContainer.style.cssText = `
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #ccc;
`;
const formTab = document.createElement('div');
const jsonTab = document.createElement('div');
[formTab, jsonTab].forEach(tab => {
tab.style.cssText = `
padding: 8px 15px;
cursor: pointer;
border-radius: 5px 5px 0 0;
margin-right: 5px;
`;
});
formTab.textContent = 'Form Input';
jsonTab.textContent = 'JSON Input';
// Content containers
const formContent = document.createElement('div');
const jsonContent = document.createElement('div');
// Function to switch tabs
function switchTab(activeTab, activeContent, inactiveTab, inactiveContent) {
activeTab.style.backgroundColor = '#007bff';
activeTab.style.color = 'white';
activeContent.style.display = 'block';
inactiveTab.style.backgroundColor = '#f8f9fa';
inactiveTab.style.color = '#333';
inactiveContent.style.display = 'none';
}
formTab.onclick = () => switchTab(formTab, formContent, jsonTab, jsonContent);
jsonTab.onclick = () => switchTab(jsonTab, jsonContent, formTab, formContent);
// Create form content
const form = document.createElement('form');
form.style.cssText = `
display: flex;
flex-direction: column;
gap: 15px;
`;
// Basic flight info section
const basicInfoSection = document.createElement('div');
basicInfoSection.className = 'form-section';
basicInfoSection.innerHTML = `
<h4>Flight Information</h4>
<div class="input-group-vertical">
<div class="input-field">
<label for="origin">Origin Airport</label>
<input type="text" id="origin" placeholder="e.g., GRU" required>
</div>
<div class="input-field">
<label for="destination">Destination Airport</label>
<input type="text" id="destination" placeholder="e.g., MAD" required>
</div>
</div>
`;
form.appendChild(basicInfoSection);
// Trip type radio buttons
const tripTypeContainer = document.createElement('div');
tripTypeContainer.className = 'form-section';
tripTypeContainer.innerHTML = `
<h4>Trip Type</h4>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="trip_type" value="one_way" checked>
<span>One Way</span>
</label>
<label class="radio-label">
<input type="radio" name="trip_type" value="round_trip">
<span>Round Trip</span>
</label>
</div>
`;
form.appendChild(tripTypeContainer);
// Cabin class radio buttons
const cabinClassContainer = document.createElement('div');
cabinClassContainer.className = 'form-section';
cabinClassContainer.innerHTML = `
<h4>Cabin Class</h4>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="cabin_class" value="Economy" checked>
<span>Economy</span>
</label>
<label class="radio-label">
<input type="radio" name="cabin_class" value="Business">
<span>Business</span>
</label>
</div>
`;
form.appendChild(cabinClassContainer);
// Outbound dates section
const outboundSection = document.createElement('div');
outboundSection.className = 'form-section';
outboundSection.innerHTML = `
<h4>Outbound Flight</h4>
<div class="date-range">
<div class="input-field">
<label for="outbound_date">Select Dates</label>
<input type="text" id="outbound_dates" class="daterangepicker-input" placeholder="Select date range" required>
</div>
</div>
`;
form.appendChild(outboundSection);
// Return dates section (hidden by default)
const returnSection = document.createElement('div');
returnSection.className = 'form-section return-section';
returnSection.style.display = 'none';
returnSection.innerHTML = `
<h4>Return Flight</h4>
<div class="date-range">
<div class="input-field">
<label for="return_date">Select Dates</label>
<input type="text" id="return_dates" class="daterangepicker-input" placeholder="Select date range">
</div>
</div>
`;
form.appendChild(returnSection);
// Points checkbox
const pointsContainer = document.createElement('div');
pointsContainer.className = 'form-section';
pointsContainer.innerHTML = `
<div class="checkbox-field">
<label class="checkbox-label">
<input type="checkbox" id="use_points">
<span>Search using points</span>
</label>
</div>
`;
form.appendChild(pointsContainer);
// Add event listener for trip type change
const tripTypeRadios = form.querySelectorAll('input[name="trip_type"]');
tripTypeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
returnSection.style.display = e.target.value === 'round_trip' ? 'block' : 'none';
// Toggle required attribute for return date inputs
const returnInputs = returnSection.querySelectorAll('input[type="date"]');
returnInputs.forEach(input => {
input.required = e.target.value === 'round_trip';
});
});
});
// Create JSON content
const jsonInput = document.createElement('textarea');
jsonInput.placeholder = 'Paste your JSON configuration here or use the "Load JSON File" button';
jsonInput.style.cssText = `
width: 100%;
height: 200px;
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 3px;
font-family: monospace;
`;
// File input for JSON
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.style.display = 'none';
const fileButton = document.createElement('button');
fileButton.textContent = 'Load JSON File';
fileButton.style.cssText = `
padding: 5px 10px;
background: #6c757d;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
margin-bottom: 10px;
width: 100%;
`;
fileButton.onclick = (e) => {
e.preventDefault();
fileInput.click();
};
fileInput.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
jsonInput.value = JSON.stringify(json, null, 2);
} catch (error) {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
}
};
// Action buttons
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
margin-top: 15px;
`;
const startButton = document.createElement('button');
startButton.textContent = 'Start Scraping';
startButton.style.cssText = `
padding: 8px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
flex: 1;
font-weight: bold;
`;
const stopButton = document.createElement('button');
stopButton.textContent = 'Stop Scraping';
stopButton.style.cssText = `
padding: 8px 15px;
background: #dc3545;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
flex: 1;
font-weight: bold;
`;
startButton.onclick = (e) => {
e.preventDefault();
let searchConfig;
if (jsonContent.style.display === 'block') {
// Use JSON input
try {
searchConfig = JSON.parse(jsonInput.value);
} catch (e) {
alert('Invalid JSON configuration: ' + e.message);
return;
}
} else {
// Use form input
const tripType = form.querySelector('input[name="trip_type"]:checked').value;
const outboundDates = document.getElementById('outbound_dates').value.split(' - ');
const outboundStartDate = outboundDates[0];
const outboundEndDate = outboundDates[1];
if (!outboundStartDate || !outboundEndDate) {
alert('Please select outbound date range.');
return;
}
const origin = document.getElementById('origin').value.toUpperCase();
const destination = document.getElementById('destination').value.toUpperCase();
if (!origin || !destination) {
alert('Please fill in both origin and destination airports.');
return;
}
const search = {
origin: origin,
destination: destination,
trip_type: tripType,
outbound_dates: {
start: outboundStartDate,
end: outboundEndDate
},
use_points: document.getElementById('use_points').checked
};
// Add return dates if round trip is selected
if (tripType === 'round_trip') {
const returnDates = document.getElementById('return_dates').value.split(' - ');
const returnStartDate = returnDates[0];
const returnEndDate = returnDates[1];
if (!returnStartDate || !returnEndDate) {
alert('Please select return date range for round trip flights.');
return;
}
search.return_dates = {
start: returnStartDate,
end: returnEndDate
};
}
// Add cabin class to the search configuration
search.cabin_class = form.querySelector('input[name="cabin_class"]:checked').value;
// Create proper search configuration
searchConfig = {
searches: [search]
};
// Log the search configuration for debugging
logger.log(`Starting search with configuration: ${JSON.stringify(searchConfig)}`, 'info');
}
// Clear any existing state before starting
clearState();
startScraping(searchConfig);
};
stopButton.onclick = () => {
clearState();
logger.log('Scraping stopped by user', 'warning');
window.location.reload();
};
// Assemble the UI
buttonContainer.appendChild(startButton);
buttonContainer.appendChild(stopButton);
jsonContent.appendChild(fileButton);
jsonContent.appendChild(jsonInput);
formContent.appendChild(form);
tabContainer.appendChild(formTab);
tabContainer.appendChild(jsonTab);
// Move all content into the wrapper
contentWrapper.appendChild(tabContainer);
contentWrapper.appendChild(formContent);
contentWrapper.appendChild(jsonContent);
contentWrapper.appendChild(buttonContainer);
document.body.appendChild(container);
// Initialize draggable and resizable
$(container).draggable({
handle: '.scraper-header',
containment: 'window'
}).resizable({
handles: 'all',
minWidth: 300,
minHeight: 400,
containment: 'window'
});
// Add minimize/maximize functionality
let isMinimized = false;
const originalHeight = container.style.height;
const minimizeBtn = container.querySelector('.minimize-btn');
const closeBtn = container.querySelector('.close-btn');
minimizeBtn.addEventListener('click', () => {
if (isMinimized) {
contentWrapper.style.display = 'block';
container.style.height = originalHeight;
minimizeBtn.textContent = '_';
} else {
contentWrapper.style.display = 'none';
container.style.height = 'auto';
minimizeBtn.textContent = '□';
}
isMinimized = !isMinimized;
});
closeBtn.addEventListener('click', () => {
container.remove();
});
// Save position and size
function saveUIState() {
const state = {
position: $(container).position(),
size: {
width: $(container).width(),
height: $(container).height()
},
isMinimized
};
GM_setValue('uiState', JSON.stringify(state));
}
// Load saved position and size
const savedState = GM_getValue('uiState');
if (savedState) {
const state = JSON.parse(savedState);
container.style.left = state.position.left + 'px';
container.style.top = state.position.top + 'px';
container.style.width = state.size.width + 'px';
container.style.height = state.size.height + 'px';
if (state.isMinimized) {
contentWrapper.style.display = 'none';
container.style.height = 'auto';
minimizeBtn.textContent = '□';
isMinimized = true;
}
}
// Save state when dragged or resized
$(container).on('dragstop resizestop', saveUIState);
}
// Initialize date pickers
function initializeDatePickers() {
try {
const commonOptions = {
autoApply: true,
locale: {
format: 'YYYY-MM-DD'
},
opens: 'left',
showDropdowns: true,
minDate: moment(),
maxDate: moment().add(1, 'year'),
parentEl: 'body'
};
if ($('#outbound_dates').length) {
$('#outbound_dates').daterangepicker(commonOptions);
}
if ($('#return_dates').length) {
$('#return_dates').daterangepicker(commonOptions);
}
$('#outbound_dates').on('apply.daterangepicker', function(ev, picker) {
const returnPicker = $('#return_dates').data('daterangepicker');
if (returnPicker) {
returnPicker.minDate = picker.endDate;
returnPicker.startDate = picker.endDate.clone().add(1, 'day');
returnPicker.endDate = picker.endDate.clone().add(7, 'days');
$('#return_dates').val(
returnPicker.startDate.format('YYYY-MM-DD') + ' - ' +
returnPicker.endDate.format('YYYY-MM-DD')
);
}
});
} catch (error) {
console.error('Error initializing date pickers:', error);
}
}
// Initialize
window.addEventListener('load', () => {
// Wait for jQuery to be properly loaded
const checkJQuery = setInterval(() => {
if (window.jQuery && window.moment && window.jQuery.fn.daterangepicker) {
clearInterval(checkJQuery);
addUI();
createStatusDisplay();
handlePageLoad();
setTimeout(initializeDatePickers, 500);
}
}, 100);
// Set a timeout to prevent infinite checking
setTimeout(() => {
clearInterval(checkJQuery);
if (!window.jQuery || !window.moment || !window.jQuery.fn.daterangepicker) {
updateStatus('Failed to initialize required libraries', 'error');
}
}, 10000);
});
})();