CatalogUpdateDownloader.psm1
#requires -Modules BitsTransfer try { # Check if the required assembly has already been loaded (by the user or another module) # If not, we load it from the module folder. # We do this to avoid assembly conflicts which seem more likely than breaking changes in the assembly itself. $null = [HtmlAgilityPack.HtmlDocument] } catch { Add-Type -LiteralPath "$PSScriptRoot\HtmlAgilityPack.dll" -ErrorAction Stop } #region Classes class CatalogUpdate { [string] $Title [datetime] $ReleaseDate [uint64] $Size [string] $Classification [string] $Products [string] $UpdateId } class CatalogDownloadInfo { [string] $Title [string] $UpdateId [string] $DownloadLink [string] $Sha1 [string] $Sha256 [bool] $IsPrimary } #endregion #region Public functions <# .SYNOPSIS Finds updates in the Microsoft Update Catalog. .DESCRIPTION Finds updates in the Microsoft Update Catalog. .EXAMPLE Find-CatalogUpdate -UpdateKind CumulativeNET -OperatingSystem Windows11 -OsVersion 24H2 -Architecture x64 -SortBy Date -Descending -First 1 This finds the most recent .NET framework update for Windows 11 24H2 x64 .EXAMPLE Find-CatalogUpdate -SearchText 'PCI\VEN_10DE&DEV_2488&SUBSYS_88251043' -First 1 This uses the free text function to search for a device driver using the hardware ID. In this case it's an Nvidia GPU. .PARAMETER UpdateKind The type of update to search for. eg. Cumulative OS/.NET update, Microsoft Malicious Software Removal Tool, etc. .PARAMETER OperatingSystem The operating system to find updates for. For Server 2022 and up specify "WindowsServer" + the OsVersion parameter. This parameter only applies if UpdateKind is set to one of the Cumulative* or MSRT options. .PARAMETER OsVersion The specific OS version, eg. 24H2, 1809, etc. This parameter only applies if UpdateKind is set to one of the Cumulative* options. .PARAMETER Architecture The architecture, eg. x64, arm64, etc. This parameter only applies if UpdateKind is set to one of the Cumulative* options. .PARAMETER DateString The year and month in the format yyyy-MM to find updates for. This parameter only applies if UpdateKind is set to one of the Cumulative* options. .PARAMETER KB The specific KB to search for. This parameter only applies if UpdateKind is set to one of the Cumulative* options. .PARAMETER SearchText The custom text filter used to search the update catalog with. Use quotes to search for specific text. Use + and - to include/exclude items based on the specified phrases. For example: Find-CatalogUpdate -SearchText '"Cumulative update"-"Dynamic"-"Framework"+"Windows 10"' This searches for the phrase "Cumulative update", excludes items with the phrases "Dynamic" and "Framework" and includes items with "Windows 10". .PARAMETER SortBy The property to sort the items by. The sorting happens serverside. .PARAMETER Descending Reverses the sorting order from Ascending to Descending. This parameter only applies if SortBy has also been specified. #> function Find-CatalogUpdate { [OutputType([CatalogUpdate])] [CmdletBinding(SupportsPaging, DefaultParameterSetName = "Custom")] Param ( [Parameter(Mandatory, ParameterSetName = "Predefined")] [ValidateSet("CumulativeOS", "CumulativeOSPreview", "CumulativeNET", "CumulativeNETPreview", "MSRT", "SecurityPlatform")] [string] $UpdateKind, [Parameter(ParameterSetName = "Predefined")] [ValidateSet("Windows10", "Windows11", "WindowsServer", "WindowsServer2019", "WindowsServer2016")] [string] $OperatingSystem, [Parameter(ParameterSetName = "Predefined")] [ValidateNotNullOrEmpty()] [ArgumentCompleter( { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $TrimmedWord = $wordToComplete.Trim(("'",'"')) $Versions = @( @{Text = "25H2"; List = "25H2"} @{Text = "24H2"; List = "24H2 (Server 2025)"} @{Text = "23H2"; List = "23H2"} @{Text = "22H2"; List = "22H2"} @{Text = "21H2"; List = "21H2 (Server 2022)"} ) foreach ($Item in $Versions) { if ($Item.Text -like "$TrimmedWord*") { $CompletionText = $Item.Text $ListItemText = $Item.List $ResultType = [System.Management.Automation.CompletionResultType]::ParameterValue $ToolTip = $Item.List [System.Management.Automation.CompletionResult]::new($CompletionText,$ListItemText,$ResultType,$ToolTip) } } } )] [string] $OsVersion, [Parameter(ParameterSetName = "Predefined")] [ValidateSet("x64", "x86", "arm64")] [string] $Architecture, [Parameter(ParameterSetName = "Predefined")] [ValidatePattern('^\d{4}-\d{2}$')] [ArgumentCompleter( { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $TrimmedWord = $wordToComplete.Trim(("'",'"')) $DateNow = Get-Date $Dates = if ($TrimmedWord -match "^(\d{4})-?$" -and $Matches[1] -ne $DateNow.Year) { 12..1 | ForEach-Object -Process {"$($Matches[1])-$("$_".PadLeft(2, "0"))"} } else { 0..-11 | ForEach-Object -Process {$DateNow.AddMonths($_).ToString("yyyy-MM")} } foreach ($Item in $Dates) { if ($Item -like "$TrimmedWord*") { $CompletionText = $Item $ListItemText = $Item $ResultType = [System.Management.Automation.CompletionResultType]::ParameterValue $ToolTip = $Item [System.Management.Automation.CompletionResult]::new($CompletionText,$ListItemText,$ResultType,$ToolTip) } } } )] [string] $DateString, [Parameter(ParameterSetName = "Predefined")] [ValidatePattern("^KB\d+$")] [string] $KB, [Parameter(Mandatory, ParameterSetName = "Custom")] [string] $SearchText, [Parameter()] [ValidateSet("Title", "Products", "Classification", "Date", "Size")] [string] $SortBy, [Parameter()] [switch] $Descending ) $UrlBuilder = [System.Text.StringBuilder]::new("https://www.catalog.update.microsoft.com/Search.aspx?q=") if ($SearchText) { $null = $UrlBuilder.Append($SearchText) } else { $OsString = switch ($OperatingSystem) { 'Windows10' {'Windows 10'} 'Windows11' {'Windows 11'} 'WindowsServer' {'Microsoft server operating system'} 'WindowsServer2019' {'Windows Server 2019'} 'WindowsServer2016' {'Windows Server 2016'} default {''} } if ($UpdateKind -like "Cumulative*") { $UpdateText = switch ($UpdateKind) { 'CumulativeOS' {"$DateString Cumulative Update for $OsString"} 'CumulativeOSPreview' {"$DateString Cumulative Update Preview for $OsString"} 'CumulativeNET' {"$DateString Cumulative Update for .NET Framework"} 'CumulativeNETPreview' {"$DateString Cumulative Update Preview for .NET Framework"} } $null = $UrlBuilder.Append("`"$($UpdateText.Trim())`"") if ($UpdateKind -like "CumulativeOS*") { # Exclude dynamic and framework updates. This is needed if the date and/or OS name is not included in the searchstring $null = $UrlBuilder.Append('-"Dynamic"-"Framework"') } if ($UpdateKind -like "CumulativeNET*" -and $OperatingSystem) { $null = $UrlBuilder.Append("+`"$OsString`"") } if ($OsVersion) { $null = $UrlBuilder.Append("+`"version $OsVersion`"") } if ($Architecture) { $null = $UrlBuilder.Append("+`"for $Architecture`"") } if ($KB) { $null = $UrlBuilder.Append("+`"$KB`"") } } elseif ($UpdateKind -eq "MSRT") { $null = $UrlBuilder.Append('"Windows Malicious Software Removal Tool"') if ($OperatingSystem) { $null = $UrlBuilder.Append("+`"$OsString`"") } } elseif ($UpdateKind -eq 'SecurityPlatform') { $null = $UrlBuilder.Append('"Update for Windows Security platform"') } } if ($SortBy) { $InternalSortName = switch ($SortBy) { 'Classification' {'ClassificationComputed'} 'Date' {'DateComputed'} 'Size' {'SizeInBytes'} default {$_} } $null = $UrlBuilder.Append("&scol=$InternalSortName") if ($Descending) { $null = $UrlBuilder.Append("&sdir=desc") } else { $null = $UrlBuilder.Append("&sdir=asc") } } $BaseUrl = $UrlBuilder.ToString() $WriteTotalCount = $PSCmdlet.PagingParameters.IncludeTotalCount $SkipCounter = 0 $UpdateCounter = 0 $PageCounter = 0 do { $PageCounter++ $Url = if ($PageCounter -eq 1) { $BaseUrl } else { "$BaseUrl&p=$PageCounter" } Write-Verbose -Message "URL: $Url" $Response = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop -Headers @{ "accept-language"="en-US;q=0.8,en;q=0.7" } $Document = [HtmlAgilityPack.HtmlDocument]::new() $Document.LoadHtml($Response.Content) $Table = $Document.GetElementbyId('ctl00_catalogBody_updateMatches') if ($null -eq $Table) { # Found no updates if ($PageCounter -eq 1 -and $WriteTotalCount) { $PSCmdlet.PagingParameters.NewTotalCount(0, 1) } return } if ($PageCounter -eq 1 -and $WriteTotalCount) { $PageInfo = $Document.GetElementbyId('ctl00_catalogBody_searchDuration') $TotalCount = [regex]::Match($PageInfo.InnerText, '\d+ - \d+ of (\d+)').Groups[1].Value $PSCmdlet.PagingParameters.NewTotalCount($TotalCount, 1) } foreach ($Row in $Table.SelectNodes('tr') | Select-Object -Skip 1) { if ($PSCmdlet.PagingParameters.Skip -gt $SkipCounter++) { continue } $Data = $Row.SelectNodes('td') $Title = $Data[1].InnerText.Trim() $Products = $Data[2].InnerText.Trim() $Classification = $Data[3].InnerText.Trim() $UpdateDate = [datetime]::Parse($Data[4].InnerText.Trim(), [cultureinfo]::InvariantCulture) $Size = [uint64]([regex]::Match($Data[6].InnerText.Trim(), '\d+$').Value) $UpdateId = $Data[7].SelectSingleNode('input').Id [CatalogUpdate]@{ Title = $Title ReleaseDate = $UpdateDate Size = $Size Classification = $Classification Products = $Products UpdateId = $UpdateId } if (++$UpdateCounter -eq $PSCmdlet.PagingParameters.First) { return } } $HasNextPage = $null -ne $Document.GetElementbyId('ctl00_catalogBody_nextPageLink') } while ($HasNextPage) } <# .SYNOPSIS Retrives download info (Download link, filehash, etc.) for the specified updates. .DESCRIPTION Retrives download info (Download link, filehash, etc.) for the specified updates. .EXAMPLE Find-CatalogUpdate -UpdateKind CumulativeNETPreview -First 1 -OperatingSystem WindowsServer2019 -SortBy Date -Descending | Get-CatalogUpdateDownloadInfo Finds the most recent .NET preview update for 2019 and retrieves the download info. .PARAMETER UpdateId The ID of the update to get info for. The ID can be found with Find-CatalogUpdate. #> function Get-CatalogUpdateDownloadInfo { [OutputType([CatalogDownloadInfo])] Param ( [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [string[]] $UpdateId ) Process { $RequestParams = @{ UseBasicParsing = $true Uri = "https://www.catalog.update.microsoft.com/DownloadDialog.aspx" Method = "Post" ContentType = "application/x-www-form-urlencoded" Headers = @{ "accept-language" = "en-US;q=0.8,en;q=0.7" } } foreach ($ID in $UpdateId) { $Response = Invoke-WebRequest @RequestParams -Body "updateIDs=$(ConvertTo-Json -InputObject (,@{updateID = $ID}) -Compress)" $FoundLinks = ParseDownloadDialogResponse -Content $Response.Content -ID $ID if ($null -eq $FoundLinks) { Write-Error -Message "Failed to find downloadinfo for update: $ID" continue } $FoundLinks } } } <# .SYNOPSIS Downloads the specified update IDs to the specified folder. .DESCRIPTION Downloads the specified update IDs to the specified folder. .EXAMPLE Find-CatalogUpdate -UpdateKind CumulativeNETPreview -First 1 -OperatingSystem WindowsServer2019 -SortBy Date -Descending | Save-CatalogUpdate -OutputDirectory $Home Finds the most recent .NET preview update for 2019 and downloads it to the user folder. .PARAMETER UpdateId The ID of the update to download. The ID can be found with Find-CatalogUpdate. .PARAMETER OutputDirectory The directory where the files should be downloaded. If it doesn't exist it will be created. .PARAMETER PrimaryOnly Specifies that only the primary update should be downloaded. Some updates include multiple file downloads. The primary download link is defined as the following: A file with a KB number that matches the overall update package or if that is not found, the first file in the list. .PARAMETER Async Specifies that the downloads should happen asynchronously via Bits jobs. #> function Save-CatalogUpdate { [OutputType([Microsoft.BackgroundIntelligentTransfer.Management.BitsJob])] Param ( [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [string[]] $UpdateId, [Parameter(Mandatory)] [string] $OutputDirectory, [Parameter()] [switch] $PrimaryOnly, [Parameter()] [switch] $Async ) begin { $OutputPath = (New-Item -Path $OutputDirectory -ItemType Directory -Force -ErrorAction Stop).FullName } Process { foreach ($ID in $UpdateId) { $DownloadInfo = Get-CatalogUpdateDownloadInfo -UpdateId $ID foreach ($Item in $DownloadInfo) { if ($PrimaryOnly -and !$Item.IsPrimary) { continue } Start-BitsTransfer -Asynchronous:$Async -Source $Item.DownloadLink -Destination $OutputPath } } } } #endregion #region Private functions function ParseDownloadDialogResponse { [OutputType([CatalogDownloadInfo])] param ( [Parameter(Mandatory)] [string] $Content, [Parameter(Mandatory)] [string] $ID ) $UpdateTitle = $null $KB = $null if ($Content -match "downloadInformation\[0]\.enTitle\s='(.+)';") { $UpdateTitle = $Matches[1] if ($UpdateTitle -match 'KB\d+') { $KB = $Matches[0] } } $i = 0 $FoundLinks = while ($true) { if ($Content -match "downloadInformation\[0]\.files\[$i]\.url\s=\s'(.+)';") { $Url = $Matches[1] } else { break } if ($Content -match "downloadInformation\[0]\.files\[$i]\.digest\s=\s'(.+)';") { $Sha1 = [System.BitConverter]::ToString([System.Convert]::FromBase64String($Matches[1])) -replace '-' } else { $Sha1 = '' } if ($Content -match "downloadInformation\[0]\.files\[$i]\.sha256\s=\s'(.+)';") { $Sha256 = [System.BitConverter]::ToString([System.Convert]::FromBase64String($Matches[1])) -replace '-' } else { $Sha256 = '' } [CatalogDownloadInfo]@{ Title = $UpdateTitle UpdateId = $ID DownloadLink = $Url Sha1 = $Sha1 Sha256 = $Sha256 IsPrimary = $Url -match $KB } $i++ } if ($FoundLinks.Count -ge 1 -and $FoundLinks.IsPrimary -notcontains $true) { $FoundLinks[0].IsPrimary = $true } $FoundLinks } #endregion |