This commit is contained in:
2026-02-15 21:00:29 +10:00
commit 28613170a1
6 changed files with 388 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/.vs
.idea

16
NextFlight.sln Normal file
View File

@@ -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

263
NextFlight/Controller.fs Normal file
View File

@@ -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<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

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Types.fs" />
<Compile Include="Controller.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
<PackageReference Include="Saturn" Version="0.17.0" />
<PackageReference Include="Thoth.Json" Version="10.4.1" />
<PackageReference Include="Thoth.Json.Giraffe" Version="6.0.0" />
</ItemGroup>
</Project>

46
NextFlight/Program.fs Normal file
View File

@@ -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<Json.ISerializer>(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

34
NextFlight/Types.fs Normal file
View File

@@ -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
}