From 28613170a114dea8b613352e0a81d0d2d4af9325 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 15 Feb 2026 21:00:29 +1000 Subject: [PATCH] Init --- .gitignore | 7 + NextFlight.sln | 16 +++ NextFlight/Controller.fs | 263 +++++++++++++++++++++++++++++++++++ NextFlight/NextFlight.fsproj | 22 +++ NextFlight/Program.fs | 46 ++++++ NextFlight/Types.fs | 34 +++++ 6 files changed, 388 insertions(+) create mode 100644 .gitignore create mode 100644 NextFlight.sln create mode 100644 NextFlight/Controller.fs create mode 100644 NextFlight/NextFlight.fsproj create mode 100644 NextFlight/Program.fs create mode 100644 NextFlight/Types.fs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a307a6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +/.vs +.idea \ No newline at end of file diff --git a/NextFlight.sln b/NextFlight.sln new file mode 100644 index 0000000..ca19eeb --- /dev/null +++ b/NextFlight.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NextFlight", "NextFlight\NextFlight.fsproj", "{82BA4314-D861-481C-AF78-78430CA40C24}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {82BA4314-D861-481C-AF78-78430CA40C24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {82BA4314-D861-481C-AF78-78430CA40C24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82BA4314-D861-481C-AF78-78430CA40C24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {82BA4314-D861-481C-AF78-78430CA40C24}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/NextFlight/Controller.fs b/NextFlight/Controller.fs new file mode 100644 index 0000000..f79a925 --- /dev/null +++ b/NextFlight/Controller.fs @@ -0,0 +1,263 @@ +module Controller +open System +open System.Net.Http +open System.Text.RegularExpressions +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 + +// 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 + airlineName = raw.Airline + aircraftType = raw.Aircraft + } + +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" + + // 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 + 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 + + + + \ No newline at end of file diff --git a/NextFlight/NextFlight.fsproj b/NextFlight/NextFlight.fsproj new file mode 100644 index 0000000..ac542eb --- /dev/null +++ b/NextFlight/NextFlight.fsproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + + + + + + + + + + + + + + + + + diff --git a/NextFlight/Program.fs b/NextFlight/Program.fs new file mode 100644 index 0000000..7e2e87c --- /dev/null +++ b/NextFlight/Program.fs @@ -0,0 +1,46 @@ +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open Saturn +open Giraffe + +open Thoth.Json.Giraffe +open System +open System.Net +open System.Net.Sockets +open System.IO +open NextFlight.Types + +module Program = + let router = router { + getf "/next-flight/%s/%s/%s" (fun (username, timezoneCountry, timeZoneCity) -> json(Controller.GetNextFlight username timezoneCountry timeZoneCity)) + getf "/next-flight/%s" (fun username -> json(Controller.GetNextFlight username "Australia" "Brisbane")) + } + + let ServiceConfig (services: IServiceCollection) = + // Get the server IP address + services.AddSingleton(ThothSerializer()) |> ignore + let serverIpAddress = + match Dns.GetHostEntry(Dns.GetHostName()).AddressList |> Array.tryFind(fun ip -> ip.AddressFamily = AddressFamily.InterNetwork) with + | Some ip -> ip.ToString() + | None -> "IP address not found" + + let boldCode = "\u001b[1m" + let greenCode = "\u001b[32m" + let resetCode = "\u001b[0m" + + // Print the server IP address + printfn $"{boldCode}Now Running On: {greenCode}%s{serverIpAddress}{resetCode}" + services.AddHttpContextAccessor() + + + let app = + application { + use_mime_types [(".woff", "application/font-woff")] + use_router router + use_static (Path.Combine(AppContext.BaseDirectory, "wwwroot")) + use_developer_exceptions + service_config ServiceConfig + url "http://0.0.0.0:5001" + } + + run app \ No newline at end of file diff --git a/NextFlight/Types.fs b/NextFlight/Types.fs new file mode 100644 index 0000000..a3be485 --- /dev/null +++ b/NextFlight/Types.fs @@ -0,0 +1,34 @@ +module NextFlight.Types +open System + +type RawFlight = { + Date: DateTime + FlightNumber: string + Registration: string + From: string + FromCode: string + To: string + ToCode: string + Distance: int + DepartureTime: string + ArrivalTime: string + Airline: string + Aircraft: string + Seat: string + Class: string + Reason: string +} + +type Flight = { + departureAirportCode: string + departureCity: string + departureDateReadable: string + departureTime: string + arrivalAirportCode: string + arrivalCity: string + arrivalDateReadable: string + arrivalTime: string + flightNumber: string + airlineName: string + aircraftType: string +} \ No newline at end of file