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