Get-WindowsServerReleases.ps1

<#PSScriptInfo
    .VERSION 1.0.2
    .GUID 857dbda9-4724-4c09-8969-8e2a576657ef
    .AUTHOR Erlend Westervik
    .COMPANYNAME
    .COPYRIGHT
    .TAGS Windows, Server, Update, Patch, Upgrade, Operating System, OS, Win, Release, Version, Servicing, LTSC, AC, Lifecycle, Build, OOB
    .LICENSEURI
    .PROJECTURI https://github.com/erlwes/Get-WindowsServerReleases
    .ICONURI
    .EXTERNALMODULEDEPENDENCIES
    .REQUIREDSCRIPTS
    .EXTERNALSCRIPTDEPENDENCIES
    .RELEASENOTES
        Version: 1.0.0 - Original published version
        Version: 1.0.2 - Updated script metadata, examples etc to match GitHub readme.md made with help from AI
 
#>


<#
.SYNOPSIS
    Get-WindowsServerReleases is a PowerShell script designed to retrieve and parse the Windows Server release history directly from Microsoft's official documentation page. It supports recent versions of Windows Server (2016 and newer) and uses local caching to minimize redundant web scraping.
 
.DESCRIPTION
    Get-WindowsServerReleases is a PowerShell script designed to retrieve and parse the Windows Server release history directly from Microsoft's official documentation page. It supports recent versions of Windows Server (2016 and newer) and uses local caching to minimize redundant web scraping.
 
.NOTES
    .
 
.PARAMETER PathLocalStore
    Path to store local CSV cache. Defaults to script location.
 
.PARAMETER ForceRebuild
    Forces a fresh web scrape and overwrites cached data regardless of age.
 
.PARAMETER VerboseLogging
    Outputs progress and activity to the console.
 
.EXAMPLE
    # Show all server versions in a grid view
    .\Get-WindowsServerReleases.ps1 | Out-GridView
 
.EXAMPLE
    # Get releases for Server 2025 with verbose logging
    .\Get-WindowsServerReleases.ps1 -WindowsServerVersion 'Server 2025' -VerboseLogging | Format-Table
 
.EXAMPLE
    # Rebuild and get info for Server 2019
    .\Get-WindowsServerReleases.ps1 -WindowsServerVersion 'Server 2019' | Format-Table
 
.EXAMPLE
    # Display cached data without fetching new content
    .\Get-WindowsServerReleases.ps1 -ShowCache
 
#>


Param(    
    [ValidateSet('Server 2025', 'Server 2022', 'Server 2019', 'Server 2016')]
    [String]$WindowsServerVersion,

    [String]$PathLocalStore = $PSScriptRoot,

    [Switch]$VerboseLogging,

    [Switch]$ForceRebuild,

    [Switch]$ShowCache
)

# DECLARATIONS
$Time = (Get-Date)

# FUNCTIONS
function ParseHtml($string) {
    $unicode = [System.Text.Encoding]::Unicode.GetBytes($string)
    $html = New-Object -Com 'HTMLFile'
    if ($html.PSObject.Methods.Name -Contains 'IHTMLDocument2_Write') {
        $html.IHTMLDocument2_Write($unicode)
    } 
    else {
        $html.write($Unicode)
    }
    $html.Close()
    $html
}

 Function Write-Log {
    param(
        [ValidateSet(0, 1, 2, 3, 4)]
        [int]$Level,

        [Parameter(Mandatory=$true)]
        [string]$Message
    )
    $Message = $Message.Replace("`r",'').Replace("`n",' ')
    switch ($Level) {
        0 { $Status = 'Info'    ;$FGColor = 'White'   }
        1 { $Status = 'Success' ;$FGColor = 'Green'   }
        2 { $Status = 'Warning' ;$FGColor = 'Yellow'  }
        3 { $Status = 'Error'   ;$FGColor = 'Red'     }
        4 { $Status = 'Console' ;$FGColor = 'Gray'    }
        Default { $Status = ''  ;$FGColor = 'Black'   }
    }
    if ($VerboseLogging) {
        Write-Host "$((Get-Date).ToString()) " -ForegroundColor 'DarkGray' -NoNewline
        Write-Host "$Status" -ForegroundColor $FGColor -NoNewline

        if ($level -eq 4) {
            Write-Host ("`t " + $Message) -ForegroundColor 'Cyan'
        }
        else {
            Write-Host ("`t " + $Message) -ForegroundColor 'White'
        }
    }
    if ($Level -eq 3) {
        $LogErrors += $Message
    }
}

Write-Log -Level 0 -Message "Start"
Write-Log -Level 0 -Message "Local cache - Using '$PathLocalStore' as local store"

if ($ShowCache) {
    Write-Log -Level 0 -Message "Local cache - Parameter -ShowCache used. Looking for existing CSV-cacge in this path: '$PathLocalStore'."
    $Cache = @()
    if ($WindowsServerVersion) {
        $CSVFiles = Get-Item -Path "$PathLocalStore\Windows $WindowsServerVersion (OS build*.csv"    
    }
    else {
        $CSVFiles = Get-Item -Path "$PathLocalStore\Windows Server * (OS build*.csv"    
    }
    
    if ($CSVFiles.count -ge 1) {
        Write-Log -Level 1 -Message "Local cache - $($CSVFiles.count) CSV-files found."
        Foreach ($File in $CSVFiles) {
            $Cache += Import-Csv -Path $File.FullName -Delimiter ';' -Encoding utf8
        }
        $Cache
    }
    else {
        Write-Log -Level 0 -Message "Local cache - Not found (while using -ShowCache). Can not continue."
    }
    Write-Log -Level 0 -Message "End"
    Break
}
else {
    # WEB REQUEST
    $WR = Invoke-WebRequest -Uri 'https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info'
    if ($WR.StatusCode -eq 200) {
        Write-Log -Level 1 -Message 'Invoke-WebRequest - Status 200. Content received.'
    }
    else {
        Write-Log -Level 3 -Message "Invoke-WebRequest - Status $($WR.StatusCode)"
    }

    # HIGH LEVEL PARSING - TABLES + HEADERS
    if ($host.version.Major -gt 5) {
        Write-Log -Level 0 -Message "Parse HTML - PowerShell Core detected ($($host.version.Major).$($host.version.Minor)). Parsing HTML using 'ParseHTLM function'"
        $Document = ParseHtml $WR.Content
        $Tables = $Document.getElementsByTagName('table') | Where-Object {$_.id -match 'historyTable'}
        $ServerOSTitles = $Document.getElementsByTagName('strong') | Where-Object {$_.innerText -match 'Windows Server'} | Select-Object -ExpandProperty innerText -Unique
    }
    else {
        Write-Log -Level 0 -Message "Parse HTML - Windows PowerShell detected ($($host.version.Major).$($host.version.Minor)). Using built in 'parsedHtml'"
        $Tables = $WR.ParsedHtml.getElementsByTagName('table') | Where-Object {$_.id -match 'historyTable'}
        $ServerOSTitles = $WR.ParsedHtml.getElementsByTagName('strong') |  Where-Object {$_.IHTMLElement_innerText -match 'Windows Server'} | Select-Object -ExpandProperty IHTMLElement_innerText -Unique 
    }
    Write-Log -Level 0 -Message "Parse HTML - Found $($Tables.count) relevant tables"
    Write-Log -Level 0 -Message "Parse HTML - Found $($ServerOSTitles.count) relevant headers"

    # SANITY CHECKS ON RESULTS
    if ($Tables.count -lt 1 -or $ServerOSTitles.count -lt 1) {
        Write-Log -Level 2 -Message 'Parse HTML - Missing headers and/or titles. Can not continue.'
        Break
    }
    if ($Tables.count -ne $ServerOSTitles.count) {
        Write-Log -Level 2 -Message 'Parse HTML - In-equal count of headers vs. tables. Will not continue.'
        Break
    }

    # MATCH SERVER VERSION TO TABLES AND DETERMINE CACHE LOCATIONS
    $CSVFiles = @()
    if ($WindowsServerVersion -eq 'Server 2016') {
        $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_3'}
        $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv"
    }
    elseif ($WindowsServerVersion -eq 'Server 2019') {
        $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_2'}
        $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv"
    }
    elseif ($WindowsServerVersion -eq 'Server 2022') {
        $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_1'}
        $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv"
    }
    elseif ($WindowsServerVersion -eq 'Server 2025') {
        $Tables = $Tables | Where-Object {$_.id -eq 'historyTable_0'}
        $CSVFiles += "$PathLocalStore\$($ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion}).csv"
    }
    else {
        $ServerOSTitles | Where-Object {$_ -match 'Windows Server'} | % {
            $CSVFiles += "$PathLocalStore\$_.csv"
        }
    }

    # DETERMINE IF CACHE EXIST AND CAN BE RE-USED
    if (!$ForceRebuild) {
        $ServerOSTitles | Where-Object {$_ -match $WindowsServerVersion} | ForEach-Object {
            $ServerOSTitle = $_
            $CSVFile = "$PathLocalStore\$ServerOSTitle.csv"
            if (Test-Path $CSVFile) {
                Write-Log -Level 1 -Message "Check cache - $ServerOSTitle - File exist ('$CSVFile')"
                [datetime]$FileDate = Get-Item -Path $CSVFile | Select-Object -ExpandProperty LastWriteTime
                $TimeDiff = ($Time - $FileDate)
                if ($TimeDiff.TotalHours -ge 24) {
                    Write-Log -Level 2 -Message "Check cache date - $ServerOSTitle - Older than 24h ($($TimeDiff.TotalHours)). Setting '-ForceRebuild' switch."
                    $ForceRebuild = $true
                }
                else {
                    Write-Log -Level 1 -Message "Check cache date - $ServerOSTitle - More recent than 24h ($($TimeDiff.TotalHours))."
                }
            }
            else {
                Write-Log -Level 2 -Message "Check cache - $ServerOSTitle - No file ('$CSVFile'). Setting '-ForceRebuild' switch."
                $ForceRebuild = $true
            }
            Clear-Variable ServerOSTitle, CSVFile
        }
    }

    # BUILD/RE-BUILD CACHE
    $Result = @()
    if ($ForceRebuild) {
        foreach ($table in $Tables) {

            # Get the headers (cells in row 0)
            $Headers = ($Table.Rows[0].Cells | Select-Object -ExpandProperty innerText).Trim()

            # Find matching Windows Server version for the table contents
            $Index = [int]($Table.id -replace '^.+_')
            $ServerTitle = $ServerOSTitles[$Index]

            Write-Log -Level 0 -Message "Convert HTML to PSObject - $ServerTitle - Begin"
            $M = Measure-Command {        
                $Object = [System.Collections.Generic.List[object]]::new()

                # Loop through rows (skip header)

                foreach ($Tablerow in ($Table.Rows | Select-Object -Skip 1)) {
                    $Cells = ($Tablerow.Cells | Select-Object -ExpandProperty innerText).Trim()

                    # Build hashtable
                    $RowHash = @{}
                    $RowHash['Windows server version'] = $ServerTitle
                    for ($i = 0; $i -lt $Headers.Count; $i++) {
                        if ($i -lt $Cells.Count) {
                            $RowHash[$Headers[$i]] = $Cells[$i]
                        }
                    }

                    # Convert to PSObject and add to list
                    $Object.Add([PSCustomObject]$RowHash)
                }
            }
            Write-Log -Level 0 -Message "Convert HTML to PSObject - $ServerTitle - Done in $($M.TotalSeconds) seconds ($($Object.Count) releases)"

            #Sanity checks before saving and poteltially overwriting last cache
            if ($Object.Build -notmatch '\d') {
                Write-Log -Level 3 -Message "Convert HTML to PSObject - $ServerTitle - The produced PSObject is not valid. Build-property is not present or has invalid values. Aborting script."
                Break
            }

            # Export to CSV (save cache)
            try {
                $Object | Export-Csv "$PathLocalStore\$ServerTitle.csv" -Delimiter ';' -Encoding utf8 -Confirm:$false -Force -ErrorAction Stop
                $Result += $Object
                Write-Log -Level 1 -Message "Export-Csv - Saved '$PathLocalStore\$ServerTitle.csv'"
            }
            catch {
                Write-Log -Level 3 -Message "Export-Csv - Failed to save '$PathLocalStore\$ServerTitle.csv'. Error: $($_.Exception.Message)"
            }
            
            # Clear variables for next table in loop
            Clear-Variable headers, index, serverTitle, m
        }
    }
    else {
        # If all needed caches are newer than 24h, just import the cache and present the data.
        $CSVFiles | ForEach-Object {
            $Result += Import-Csv -Path $_ -Delimiter ';' -Encoding utf8
        }
    }
    Write-Log -Level 0 -Message "End"
    $Result
}