Init
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
|
/.vs
|
||||||
|
.idea
|
||||||
16
NextFlight.sln
Normal file
16
NextFlight.sln
Normal 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
263
NextFlight/Controller.fs
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
22
NextFlight/NextFlight.fsproj
Normal file
22
NextFlight/NextFlight.fsproj
Normal 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
46
NextFlight/Program.fs
Normal 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
34
NextFlight/Types.fs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user