module Controller open System open System.Net.Http open NextFlight.Types open FSharp.Collections open HtmlAgilityPack let private getInnerText (node: HtmlNode option) = match node with | Some n -> n.InnerText.Trim() | None -> "" let private getAttributeValue (node: HtmlNode option) (attribute: string) = match node with | Some n -> let attr = n.GetAttributeValue(attribute, "") attr | None -> "" let private formatDateReadable (date: DateTime) (timezone: string) = let timezoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone) let now = TimeZoneInfo.ConvertTime(DateTime.Now, timezoneInfo) let today = now.Date let tomorrow = today.AddDays(1.0) if date.Date = today then "Today" elif date.Date = tomorrow then "Tomorrow" else date.ToString("MMMM d") // Helper to extract city from "City / Airport" format let private extractCity (location: string) = match location.Split('/') with | parts when parts.Length > 0 -> parts[0].Trim() | _ -> location let private calculateArrivalDate (departureDate: DateTime) (departureTime: string) (arrivalTime: string) = try // Parse times (format: "HH:mm") let depParts = departureTime.Split(':') let arrParts = arrivalTime.Split(':') if depParts.Length = 2 && arrParts.Length = 2 then let depHour = Int32.Parse(depParts[0]) let depMinute = Int32.Parse(depParts[1]) let arrHour = Int32.Parse(arrParts[0]) let arrMinute = Int32.Parse(arrParts[1]) let depTimeOfDay = TimeSpan(depHour, depMinute, 0) let arrTimeOfDay = TimeSpan(arrHour, arrMinute, 0) // If arrival time is earlier than departure time, it's the next day if arrTimeOfDay < depTimeOfDay then departureDate.AddDays(1.0) else departureDate else // If we can't parse, assume same day departureDate with | _ -> // If any error in parsing, assume same day departureDate let getLogoUrl rawFlight = $"https://content.airhex.com/content/logos/airlines_{rawFlight.AirlineCode}_60_60_t.png" let removeDuplicateWords (string: string) = string.Split(' ') |> Array.fold (fun acc word -> match acc with | [] -> [word] | head :: _ when head = word -> acc | _ -> word :: acc ) [] |> List.rev |> String.concat " " // Map RawFlight to Flight let private mapToFlight (raw: RawFlight) (timezone: string) : Flight = let arrivalDate = calculateArrivalDate raw.Date raw.DepartureTime raw.ArrivalTime { departureAirportCode = raw.FromCode departureCity = extractCity raw.From departureDateReadable = formatDateReadable raw.Date timezone departureTime = raw.DepartureTime arrivalAirportCode = raw.ToCode arrivalCity = extractCity raw.To arrivalDateReadable = formatDateReadable arrivalDate timezone arrivalTime = raw.ArrivalTime flightNumber = raw.FlightNumber airlineCode = raw.AirlineCode airlineName = raw.Airline aircraftType = removeDuplicateWords raw.Aircraft logoUrl = getLogoUrl raw } let private parseTableRow (row: HtmlNode) : RawFlight option = try // Get all td cells let cells = row.SelectNodes(".//td") if cells = null || cells.Count < 13 then None else // Extract date let dateNode = cells[0].SelectSingleNode(".//span[@class='inner-date']") if dateNode = null then None else let dateStr = dateNode.InnerText.Trim() let date = DateTime.Parse(dateStr) // Flight number let flightNumber = getInnerText (Some cells[1]) // Registration let registration = getInnerText (Some cells[2]) // From airport let fromNode = cells[3].SelectSingleNode(".//span[@class='tooltip']") let fromCode = getInnerText (Some fromNode) let fromFull = getAttributeValue (Some fromNode) "data-tooltip-value" // To airport let toNode = cells[4].SelectSingleNode(".//span[@class='tooltip']") let toCode = getInnerText (Some toNode) let toFull = getAttributeValue (Some toNode) "data-tooltip-value" // Distance (remove commas) let distanceStr = getInnerText (Some cells[5]) let distance = if String.IsNullOrWhiteSpace(distanceStr) then 0 else Int32.Parse(distanceStr.Replace(",", "")) // Times let depTime = getInnerText (Some cells[6]) let arrTime = getInnerText (Some cells[7]) // Airline let airlineNode = cells[8].SelectSingleNode(".//span[@class='tooltip']") let airline = getAttributeValue (Some airlineNode) "data-tooltip-value" let airlineCode = getInnerText (Some cells[8]) // Aircraft let aircraftNode = cells[9].SelectSingleNode(".//span[@class='tooltip']") let aircraft = getAttributeValue (Some aircraftNode) "data-tooltip-value" // Seat - look for pattern like "19A" in the seat cell let seatText = cells[10].InnerText let seatMatch = System.Text.RegularExpressions.Regex.Match(seatText, @"\d+[A-Z]") let seat = if seatMatch.Success then seatMatch.Value else "" // Class and Reason from icons column let iconsCell = cells[12] // Class let classNode = iconsCell.SelectSingleNode(".//span[contains(@class, 'class-')]") let flightClass = if classNode <> null then let classAttr = classNode.GetAttributeValue("class", "") if classAttr.Contains("class-economy") then "Economy" elif classAttr.Contains("class-business") then "Business" elif classAttr.Contains("class-first") then "First" elif classAttr.Contains("class-private") then "Private" else "Unknown" else "Unknown" // Reason let reasonNode = iconsCell.SelectSingleNode(".//span[contains(@class, 'reason-')]") let reason = if reasonNode <> null then let reasonAttr = reasonNode.GetAttributeValue("class", "") if reasonAttr.Contains("reason-leisure") then "Leisure" elif reasonAttr.Contains("reason-business") then "Business" elif reasonAttr.Contains("reason-other") then "Other" else "Unknown" else "Unknown" Some { Date = date FlightNumber = flightNumber Registration = registration From = fromFull FromCode = fromCode To = toFull ToCode = toCode Distance = distance DepartureTime = depTime ArrivalTime = arrTime Airline = airline AirlineCode = airlineCode Aircraft = aircraft Seat = seat Class = flightClass Reason = reason } with | ex -> printfn "Error parsing row: %s" ex.Message None let private parseHtmlTable (html: string) : RawFlight list = let doc = HtmlDocument() doc.LoadHtml(html) // Find all table rows with data-row-number attribute let rows = doc.DocumentNode.SelectNodes("//tr[@data-row-number]") if rows = null then [] else rows |> Seq.cast |> Seq.map parseTableRow |> Seq.choose id |> Seq.toList let GetNextFlights (username: string) (timezoneCountry: string) (timezoneCity: string) : Flight list = let myFr24Url = $"https://my.flightradar24.com/{username}/flights" let timezone = timezoneCountry + "/" + timezoneCity use client = new HttpClient() let html = client.GetStringAsync(myFr24Url).Result let allRawFlights = parseHtmlTable html printfn "Parsed %d total flights" allRawFlights.Length // Get timezone-aware current date and time let timezoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone) let now = TimeZoneInfo.ConvertTime(DateTime.Now, timezoneInfo) printfn "Current date/time in %s: %s" timezone (now.ToString("yyyy-MM-dd HH:mm")) // Filter for future flights (comparing full date+time in YOUR timezone) let futureRawFlights = allRawFlights |> List.filter (fun flight -> try let timeParts = flight.DepartureTime.Split(':') if timeParts.Length = 2 then let hour = Int32.Parse(timeParts[0]) let minute = Int32.Parse(timeParts[1]) // Create departure DateTime in YOUR timezone let departureDateTime = DateTime(flight.Date.Year, flight.Date.Month, flight.Date.Day, hour, minute, 0) departureDateTime > now else flight.Date >= now.Date with | _ -> flight.Date >= now.Date ) |> List.sortBy (fun flight -> // Parse time to create full DateTime for sorting try let timeParts = flight.DepartureTime.Split(':') if timeParts.Length = 2 then let hour = Int32.Parse(timeParts[0]) let minute = Int32.Parse(timeParts[1]) DateTime(flight.Date.Year, flight.Date.Month, flight.Date.Day, hour, minute, 0) else flight.Date with | _ -> flight.Date ) printfn "Found %d future flights" futureRawFlights.Length // Map to Flight type futureRawFlights |> List.map (fun f -> mapToFlight f timezone) let GetNextFlight (username: string) (timezoneCountry: string) (timezoneCity: string) = let futureFlights = GetNextFlights username timezoneCountry timezoneCity futureFlights |> List.tryHead