281 lines
11 KiB
Forth
281 lines
11 KiB
Forth
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<HtmlNode>
|
|
|> 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
|
|
|
|
|
|
|
|
|