Created
April 10, 2025 02:10
-
-
Save XTheocharis/bd3d9d1cda2e0ff9eea2acf77e6ac51b to your computer and use it in GitHub Desktop.
PowerShell functions for searching and downloading offline updates from https://www.catalog.update.microsoft.com. (PS 5.1 Compatible - No External Dependencies)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| PowerShell functions for searching and downloading offline updates from https://www.catalog.update.microsoft.com. (PS 5.1 Compatible - No External Dependencies) | |
| .DESCRIPTION | |
| This script provides functions to query the Microsoft Update Catalog and download selected updates using only native PowerShell 5.1 features. | |
| It includes logic for searching, sorting, pagination, downloading (with BITS option), and fetching download links/filenames. | |
| .NOTES | |
| Dependencies: Windows PowerShell 5.1 and .NET Framework 4.8.1. | |
| #> | |
| # Force Strict Mode to catch potential issues early | |
| Set-StrictMode -Version Latest | |
| # Constants | |
| $script:CatalogBaseUrl = "https://www.catalog.update.microsoft.com" | |
| $script:UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36" | |
| $script:DefaultAcceptHeaders = @{ | |
| 'Accept' = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' | |
| 'Accept-Language' = 'en-US,en;q=0.9' | |
| 'Cache-Control' = 'max-age=0' | |
| } | |
| #region Helper Functions | |
| function Get-RegexMatch { | |
| param( | |
| [string]$InputString, | |
| [string]$Pattern, | |
| [string]$GroupName = '1' | |
| ) | |
| $match = [System.Text.RegularExpressions.Regex]::Match( | |
| $InputString, | |
| $Pattern, | |
| [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline | |
| ) | |
| if ($match.Success) { | |
| $group = $match.Groups[$GroupName] | |
| if ($group -and $group.Success) { | |
| return $group.Value.Trim() | |
| } elseif ($GroupName -match '^\d+$' -and [int]$GroupName -lt $match.Groups.Count) { | |
| return $match.Groups[[int]$GroupName].Value.Trim() | |
| } | |
| } | |
| return $null | |
| } | |
| function Set-TempSecurityProtocol { | |
| [CmdletBinding()] | |
| param ([switch]$ResetToDefault) | |
| $scope = if (($PSCmdlet.MyInvocation.ScriptName -ne '') -or ($MyInvocation.MyCommand.CommandType -eq 'ExternalScript')) { 'Script' } else { 'Global' } | |
| if ($ResetToDefault) { | |
| if (Test-Path "variable:${scope}:MSCatalogSecProt") { | |
| $originalProtocols = Get-Variable -Name MSCatalogSecProt -Scope $scope -ValueOnly | |
| Write-Verbose "Resetting SecurityProtocol to: $($originalProtocols -join ', ')" | |
| if ($originalProtocols -is [System.Net.SecurityProtocolType]) { | |
| [Net.ServicePointManager]::SecurityProtocol = $originalProtocols | |
| } else { | |
| try { | |
| $protocolEnum = [System.Net.SecurityProtocolType]($originalProtocols -join ',') | |
| [Net.ServicePointManager]::SecurityProtocol = $protocolEnum | |
| } catch { | |
| Write-Warning "Could not restore SecurityProtocol. Resetting to system default. Error: $($_.Exception.Message)" | |
| [Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::SystemDefault | |
| } | |
| } | |
| Remove-Variable -Name MSCatalogSecProt -Scope $scope -Force -ErrorAction SilentlyContinue | |
| } | |
| } else { | |
| if (-not (Test-Path "variable:${scope}:MSCatalogSecProt")) { | |
| $currentProtocols = [Net.ServicePointManager]::SecurityProtocol | |
| Set-Variable -Name MSCatalogSecProt -Value $currentProtocols -Scope $scope -Force -Option ReadOnly | |
| if (-not ([System.Net.SecurityProtocolType]($currentProtocols) -band [System.Net.SecurityProtocolType]::Tls12)) { | |
| Write-Verbose "Tls12 not detected in current protocols. Setting SecurityProtocol to Tls12." | |
| [Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 | |
| } | |
| } | |
| } | |
| } | |
| function Invoke-ParseDate { | |
| param ([Parameter(Mandatory = $true)][String]$DateString) | |
| try { | |
| return [datetime]::ParseExact($DateString, 'M/d/yyyy', [System.Globalization.CultureInfo]::InvariantCulture) | |
| } catch [System.FormatException] { | |
| try { | |
| return [datetime]::Parse($DateString, [System.Globalization.CultureInfo]::InvariantCulture) | |
| } catch { | |
| Write-Warning "Failed to parse date string '$DateString'. Error: $($_.Exception.Message)" | |
| return $null | |
| } | |
| } catch { | |
| Write-Warning "Failed to parse date string '$DateString'. Error: $($_.Exception.Message)" | |
| return $null | |
| } | |
| } | |
| function Invoke-DownloadFile { | |
| [CmdLetBinding(SupportsShouldProcess=$true)] | |
| param ( | |
| [Parameter(Mandatory = $true)][uri]$Uri, | |
| [Parameter(Mandatory = $true)][string]$Path, | |
| [Parameter(Mandatory = $false)][switch]$UseBits | |
| ) | |
| $WebClient = $null | |
| $OriginalProgressPreference = $ProgressPreference | |
| $ProgressPreference = 'SilentlyContinue' | |
| try { | |
| $DestinationDir = Split-Path -Path $Path -Parent | |
| if (-not (Test-Path -Path $DestinationDir -PathType Container)) { | |
| New-Item -Path $DestinationDir -ItemType Directory -Force -ErrorAction Stop | Out-Null | |
| } | |
| if (Test-Path $Path -PathType Leaf) { | |
| try { | |
| $Signature = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop | |
| if ($Signature.Status -eq 'Valid') { | |
| Write-Verbose "Valid signature. Skipping download." | |
| return | |
| } | |
| } catch { | |
| Write-Warning "Signature check failed on existing file. Overwriting." | |
| } | |
| } | |
| if ($PSCmdlet.ShouldProcess($Path, "Download from $($Uri.OriginalString)")) { | |
| Set-TempSecurityProtocol | |
| if ($UseBits -and (Get-Command Start-BitsTransfer -ErrorAction SilentlyContinue)) { | |
| Import-Module BitsTransfer -ErrorAction SilentlyContinue | |
| $BitsJob = Start-BitsTransfer -Source $Uri -Destination $Path -Asynchronous -ErrorAction Stop | |
| $ProgressParams = @{ | |
| Activity = "Downloading via BITS" | |
| Status = "" | |
| CurrentOperation = "" | |
| PercentComplete = 0 | |
| } | |
| while ($BitsJob.JobState -in ('Connecting', 'Transferring', 'Queued')) { | |
| $Percent = if ($BitsJob.BytesTotal -gt 0) { ($BitsJob.BytesTransferred / $BitsJob.BytesTotal) * 100 } else { 0 } | |
| $ProgressParams.Status = "$($BitsJob.JobState)" | |
| $ProgressParams.CurrentOperation = "$($Uri.Segments[-1]) ($([Math]::Round($BitsJob.BytesTransferred / 1MB, 2)) MB / $([Math]::Round($BitsJob.BytesTotal / 1MB, 2)) MB)" | |
| $ProgressParams.PercentComplete = $Percent | |
| Write-Progress @ProgressParams | |
| Start-Sleep -Milliseconds 500 | |
| } | |
| $ProgressParams.Status = "$($BitsJob.JobState)" | |
| $ProgressParams.CurrentOperation = "$($Uri.Segments[-1]) ($([Math]::Round($BitsJob.BytesTransferred / 1MB, 2)) MB / $([Math]::Round($BitsJob.BytesTotal / 1MB, 2)) MB)" | |
| $ProgressParams.PercentComplete = 100 | |
| $ProgressParams.Completed = $true | |
| Write-Progress @ProgressParams | |
| if ($BitsJob.JobState -ne 'Transferred') { | |
| $BitsError = $null | |
| try { $BitsError = $BitsJob | Get-BitsTransferError -ErrorAction Stop } catch {} | |
| $ErrorDetails = if ($BitsError) { $BitsError.ErrorDescription } else { "Unknown BITS Error (State: $($BitsJob.JobState))" } | |
| Complete-BitsTransfer -BitsJob $BitsJob -ErrorAction SilentlyContinue | |
| throw "BITS transfer failed. State: $($BitsJob.JobState). Details: $ErrorDetails" | |
| } | |
| Complete-BitsTransfer -BitsJob $BitsJob | |
| } else { | |
| if ($UseBits) { Write-Warning "BITS module not available. Falling back to WebClient." } | |
| $WebClient = New-Object System.Net.WebClient | |
| # Progress reporting variables | |
| $StartTime = Get-Date | |
| $ReportInterval = [TimeSpan]::FromSeconds(1) | |
| $LastReportTime = $StartTime | |
| $LastBytes = 0 | |
| $ProgressAction = { | |
| param($Sender, $EventArgs) | |
| $Now = Get-Date | |
| if (($Now - $script:LastReportTime) -ge $script:ReportInterval -or $EventArgs.ProgressPercentage -eq 100) { | |
| $BytesSinceLast = $EventArgs.BytesReceived - $script:LastBytes | |
| $Rate = 0 | |
| if (($Now - $script:LastReportTime).TotalSeconds -gt 0.1) { | |
| $Rate = $BytesSinceLast / ($Now - $script:LastReportTime).TotalSeconds | |
| } else { | |
| $Rate = $EventArgs.BytesReceived / ($Now - $script:StartTime).TotalSeconds | |
| } | |
| $RateMbps = [Math]::Round(($Rate * 8) / 1MB, 2) | |
| $Status = "$($EventArgs.ProgressPercentage)% Complete" | |
| $CurrentOp = "$($using:Uri.Segments[-1]) ($([Math]::Round($EventArgs.BytesReceived / 1MB, 2)) MB / $([Math]::Round($EventArgs.TotalBytesToReceive / 1MB, 2)) MB) @ ${RateMbps} Mbps" | |
| Write-Progress -Activity "Downloading via WebClient" -Status $Status -CurrentOperation $CurrentOp -PercentComplete $EventArgs.ProgressPercentage | |
| $script:LastReportTime = $Now | |
| $script:LastBytes = $EventArgs.BytesReceived | |
| } | |
| } | |
| $script:StartTime = $StartTime | |
| $script:ReportInterval = $ReportInterval | |
| $script:LastReportTime = $LastReportTime | |
| $script:LastBytes = $LastBytes | |
| Register-ObjectEvent -InputObject $WebClient -EventName DownloadProgressChanged -SourceIdentifier WebClientProgress -Action $ProgressAction -ErrorAction SilentlyContinue | |
| $DownloadTask = $WebClient.DownloadFileTaskAsync($Uri, $Path) | |
| while (-not $DownloadTask.IsCompleted) { | |
| Start-Sleep -Milliseconds 200 | |
| if ($Host.Name -match 'ISE' -or $Host.Name -eq 'ConsoleHost') { | |
| try { | |
| if ([System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')) { | |
| [System.Windows.Forms.Application]::DoEvents() | |
| } | |
| } catch {} | |
| } | |
| } | |
| Unregister-Event -SourceIdentifier WebClientProgress -ErrorAction SilentlyContinue | |
| Write-Progress -Activity "Downloading via WebClient" -Status "Completed" -CurrentOperation "$($Uri.Segments[-1])" -PercentComplete 100 -Completed | |
| if ($DownloadTask.IsFaulted) { | |
| throw "WebClient download failed. Error: $($DownloadTask.Exception.InnerException.Message)" | |
| } | |
| elseif ($DownloadTask.IsCanceled) { | |
| throw "WebClient download canceled." | |
| } | |
| } | |
| # Signature Validation | |
| $Signature = Get-AuthenticodeSignature -FilePath $Path -ErrorAction SilentlyContinue | |
| if ($null -eq $Signature) { | |
| Write-Warning "Downloaded file '$Path' is not signed." | |
| } | |
| elseif ($Signature.Status -ne 'Valid') { | |
| throw "Signature validation failed for '$Path'. Status: $($Signature.Status)" | |
| } | |
| } | |
| } catch { | |
| Write-Error "Download or Validation error for '$($Uri.OriginalString)': $($_.Exception.Message)" | |
| if ($PSCmdlet.ShouldProcess($Path, "Remove incomplete/invalid file due to error")) { | |
| if(Test-Path $Path -PathType Leaf) { | |
| Remove-Item $Path -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| throw | |
| } finally { | |
| Set-TempSecurityProtocol -ResetToDefault | |
| if ($null -ne $WebClient) { | |
| Unregister-Event -SourceIdentifier WebClientProgress -ErrorAction SilentlyContinue | |
| $WebClient.Dispose() | |
| } | |
| Remove-Variable -Name StartTime, ReportInterval, LastReportTime, LastBytes -Scope Script -ErrorAction SilentlyContinue | |
| $ProgressPreference = $OriginalProgressPreference | |
| } | |
| } | |
| function Invoke-CatalogRequest { | |
| [CmdletBinding()] | |
| param ( | |
| [parameter(Mandatory = $true)][string]$Uri, | |
| [Parameter(Mandatory = $false)][ValidateSet("Get", "Post")][string]$Method = "Get", | |
| [Parameter(Mandatory = $false)][string]$EventArgument, | |
| [Parameter(Mandatory = $false)][string]$EventTarget, | |
| [Parameter(Mandatory = $false)][string]$EventValidation, | |
| [Parameter(Mandatory = $false)][string]$ViewState, | |
| [Parameter(Mandatory = $false)][string]$ViewStateGenerator | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| $OriginalProgressPreference = $ProgressPreference | |
| $ProgressPreference = 'SilentlyContinue' | |
| try { | |
| Set-TempSecurityProtocol | |
| $Params = @{ | |
| Uri = $Uri | |
| Method = $Method | |
| UseBasicParsing = $true | |
| UserAgent = $script:UserAgent | |
| TimeoutSec = 180 | |
| Headers = $script:DefaultAcceptHeaders | |
| } | |
| if ($Method -eq "Post") { | |
| if (-not ($EventValidation -and $ViewState -and $ViewStateGenerator) -and $EventTarget) { | |
| throw "POST request for '$EventTarget' missing required state parameters." | |
| } | |
| $ReqBody = @{ | |
| "__EVENTTARGET" = $EventTarget | |
| "__EVENTARGUMENT" = $EventArgument | |
| "__VIEWSTATE" = $ViewState | |
| "__EVENTVALIDATION" = $EventValidation | |
| "__VIEWSTATEGENERATOR" = $ViewStateGenerator | |
| } | |
| $Params.Body = $ReqBody | |
| $Params.ContentType = "application/x-www-form-urlencoded" | |
| } | |
| $Results = Invoke-WebRequest @Params | |
| $HtmlContent = $Results.Content | |
| # Check for "No Results" using Regex | |
| if ($HtmlContent -match 'id="ctl00_catalogBody_noResultText"') { | |
| $SearchTerm = "" | |
| if ($Uri -match '\?q=([^&]+)') { | |
| $SearchTerm = [System.Net.WebUtility]::UrlDecode($matches[1]) | |
| } | |
| throw "We did not find any results for '$SearchTerm'." | |
| } | |
| # Check for general error page indicators | |
| if ($HtmlContent -match 'id="errorPageDisplayedError"') { | |
| $ErrorText = "Catalog site returned an error page." | |
| $errorTextPattern = '<span[^>]+class=".*?errorText.*?"[^>]*>(.*?)</span>' | |
| $detailedErrorMatch = [System.Text.RegularExpressions.Regex]::Match( | |
| $HtmlContent, | |
| $errorTextPattern, | |
| ([System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) | |
| ) | |
| if ($detailedErrorMatch.Success) { | |
| $detailedError = [System.Net.WebUtility]::HtmlDecode($detailedErrorMatch.Groups[1].Value).Trim() | |
| $ErrorText += " Details: $detailedError" | |
| } | |
| throw $ErrorText | |
| } | |
| return [MSCatalogResponse]::new($HtmlContent) | |
| } catch [System.Net.WebException] { | |
| $StatusCode = $null | |
| $ResponseBody = "" | |
| $ErrorMessage = $_.Exception.Message | |
| if ($_.Exception.Response) { | |
| $StatusCode = [int]$_.Exception.Response.StatusCode | |
| try { | |
| $stream = $_.Exception.Response.GetResponseStream() | |
| $reader = New-Object System.IO.StreamReader($stream) | |
| $ResponseBody = $reader.ReadToEnd() | |
| $reader.Close() ; $stream.Close() | |
| if ($ResponseBody -and $StatusCode -ge 400) { | |
| $ErrorMessage += " Response: " + ($ResponseBody | Out-String | Select-Object -First 500) + "..." | |
| } | |
| } catch { | |
| $ResponseBody = "(Failed read response body: $($_.Exception.Message))" | |
| } | |
| Write-Error ("Web request failed (Code {0}). Uri: {1}. Error: {2}" -f $StatusCode, $Uri, $ErrorMessage) | |
| } else { | |
| Write-Error "Web request failed. Uri: $Uri. Error: $ErrorMessage" | |
| } | |
| throw | |
| } catch { | |
| $ErrorMessage = $_.Exception.Message | |
| Write-Error "Request failed for '$Uri': $ErrorMessage" | |
| throw | |
| } finally { | |
| Set-TempSecurityProtocol -ResetToDefault | |
| $ProgressPreference = $OriginalProgressPreference | |
| } | |
| } | |
| function Get-UpdateLinks { | |
| [CmdLetBinding()] | |
| param ([Parameter(Mandatory=$true, Position=0)][String]$Guid) | |
| $OriginalProgressPreference = $ProgressPreference | |
| $ProgressPreference = 'SilentlyContinue' | |
| try { | |
| Set-TempSecurityProtocol | |
| $PayloadObject = @{ updateID = $Guid; size = 0; uidInfo = $Guid } | |
| $JsonPayload = ConvertTo-Json -InputObject @($PayloadObject) -Compress | |
| $Body = @{ 'updateIDs' = $JsonPayload } | |
| $Params = @{ | |
| Uri = "$script:CatalogBaseUrl/DownloadDialog.aspx" | |
| Method = "Post" | |
| Body = $Body | |
| ContentType = "application/x-www-form-urlencoded" | |
| UseBasicParsing = $true | |
| UserAgent = $script:UserAgent | |
| TimeoutSec = 180 | |
| Headers = @{ 'Referer' = $script:CatalogBaseUrl } | |
| } | |
| $DownloadDialogResponse = Invoke-WebRequest @Params | |
| $RegexPattern = "downloadInformation\[\d+\]\.files\[\d+\]\.url\s*=\s*'([^']+)'" | |
| $RawContent = $DownloadDialogResponse.Content | |
| $matches = [System.Text.RegularExpressions.Regex]::Matches( | |
| $RawContent, | |
| $RegexPattern, | |
| [System.Text.RegularExpressions.RegexOptions]::IgnoreCase | |
| ) | |
| if ($matches.Count -eq 0) { | |
| Write-Warning "No download links found for GUID $Guid." | |
| return $null | |
| } | |
| $extractedLinks = [System.Collections.Generic.List[string]]::new() | |
| foreach ($match in $matches) { | |
| if ($match.Groups[1].Success) { | |
| $extractedLinks.Add($match.Groups[1].Value.Trim()) | |
| } | |
| } | |
| $UniqueLinks = $extractedLinks | Select-Object -Unique | |
| $UniqueLinksArray = @($UniqueLinks) | |
| return [PSCustomObject]@{ Matches = $UniqueLinksArray } | |
| } catch { | |
| $ErrorMessage = $_.Exception.Message | |
| Write-Error "Failed to get links for GUID $Guid. Error: $ErrorMessage" | |
| throw | |
| } finally { | |
| Set-TempSecurityProtocol -ResetToDefault | |
| $ProgressPreference = $OriginalProgressPreference | |
| } | |
| } | |
| function Sort-CatalogResults { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory = $true)][string]$Uri, | |
| [Parameter(Mandatory = $true)][ValidateSet("Title", "Products", "Classification", "LastUpdated", "Size")][string]$SortBy, | |
| [Parameter(Mandatory = $false)][switch]$Descending, | |
| [Parameter(Mandatory = $false)][string]$EventArgument, | |
| [Parameter(Mandatory = $true)][string]$EventValidation, | |
| [Parameter(Mandatory = $true)][string]$ViewState, | |
| [Parameter(Mandatory = $true)][string]$ViewStateGenerator | |
| ) | |
| # Determine the correct ASP.NET EventTarget ID for the sort link | |
| $EventTarget = switch ($SortBy) { | |
| "Title" { 'ctl00$catalogBody$updateMatches$ctl02$titleHeaderLink' } | |
| "Products" { 'ctl00$catalogBody$updateMatches$ctl02$productsHeaderLink' } | |
| "Classification"{ 'ctl00$catalogBody$updateMatches$ctl02$classificationComputedHeaderLink' } | |
| "LastUpdated" { 'ctl00$catalogBody$updateMatches$ctl02$dateComputedHeaderLink' } | |
| "Size" { 'ctl00$catalogBody$updateMatches$ctl02$sizeInBytesHeaderLink' } | |
| default { throw "Invalid SortBy value: '$SortBy'" } | |
| } | |
| # First sort POST request | |
| $FirstSortParams = @{ | |
| Uri = $Uri | |
| Method = "Post" | |
| EventTarget = $EventTarget | |
| EventArgument = $EventArgument | |
| EventValidation = $EventValidation | |
| ViewState = $ViewState | |
| ViewStateGenerator = $ViewStateGenerator | |
| } | |
| $Res = Invoke-CatalogRequest @FirstSortParams | |
| # Determine if a second POST is needed to toggle sort order | |
| $AssumedSortDirectionAfterFirstPost = ($SortBy -eq "LastUpdated") | |
| if ($Descending.IsPresent -ne $AssumedSortDirectionAfterFirstPost) { | |
| $SecondSortParams = @{ | |
| Uri = $Uri | |
| Method = "Post" | |
| EventTarget = $EventTarget | |
| EventArgument = $Res.EventArgument | |
| EventValidation = $Res.EventValidation | |
| ViewState = $Res.ViewState | |
| ViewStateGenerator = $Res.ViewStateGenerator | |
| } | |
| $Res = Invoke-CatalogRequest @SecondSortParams | |
| } | |
| return $Res | |
| } | |
| #endregion Helper Functions | |
| #region Classes | |
| class MSCatalogUpdate { | |
| [string] $Title | |
| [string] $Products | |
| [string] $Classification | |
| [datetime] $LastUpdated | |
| [string] $Version | |
| [string] $Size | |
| [string] $SizeInBytes | |
| [string] $Guid | |
| [string[]] $FileNames | |
| MSCatalogUpdate() {} | |
| MSCatalogUpdate($RowHtml, [bool]$FetchFileNames) { | |
| # Extract content from table cells | |
| $tdPattern = '<td.*?>(.*?)</td>' | |
| $cellMatches = [System.Text.RegularExpressions.Regex]::Matches( | |
| $RowHtml, | |
| $tdPattern, | |
| ([System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) | |
| ) | |
| if ($cellMatches.Count -ge 8) { | |
| $decodeAndTrim = { param($html) [System.Net.WebUtility]::HtmlDecode($html).Trim() } | |
| # Parse title from cell 1 | |
| $titleContent = $cellMatches[1].Groups[1].Value | |
| $this.Title = Get-RegexMatch -InputString $titleContent -Pattern '<a[^>]*?>(.*?)</a>' | |
| $this.Title = & $decodeAndTrim $this.Title | |
| # Parse remaining cells | |
| $this.Products = & $decodeAndTrim $cellMatches[2].Groups[1].Value | |
| $this.Classification = & $decodeAndTrim $cellMatches[3].Groups[1].Value | |
| # Parse date | |
| $dateString = & $decodeAndTrim $cellMatches[4].Groups[1].Value | |
| $parsedDate = Invoke-ParseDate -DateString $dateString | |
| if ($parsedDate) { | |
| $this.LastUpdated = $parsedDate | |
| } else { | |
| $this.LastUpdated = [datetime]::MinValue | |
| Write-Warning "Could not parse date '$dateString' for update '$($this.Title)'" | |
| } | |
| $this.Version = & $decodeAndTrim $cellMatches[5].Groups[1].Value | |
| # Parse size | |
| $sizeContent = $cellMatches[6].Groups[1].Value | |
| $this.Size = Get-RegexMatch -InputString $sizeContent -Pattern '<span[^>]+?id="[^"]*?_size"[^>]*?>(.*?)</span>' | |
| $this.SizeInBytes = Get-RegexMatch -InputString $sizeContent -Pattern '<span[^>]+?class="noDisplay"[^>]*?>(.*?)</span>' | |
| $this.Size = $this.Size.Trim() | |
| $this.SizeInBytes = $this.SizeInBytes.Trim() | |
| # Parse GUID | |
| $guidContent = $cellMatches[7].Groups[1].Value | |
| $this.Guid = Get-RegexMatch -InputString $guidContent -Pattern '<input[^>]+?id="([^"]+)"' | |
| $this.Guid = $this.Guid.Trim() | |
| # Clean up whitespace | |
| $this.Title = $this.Title -replace '\s+', ' ' | |
| $this.Products = $this.Products -replace '\s+', ' ' | |
| $this.Classification = $this.Classification -replace '\s+', ' ' | |
| $this.Version = $this.Version -replace '\s+', ' ' | |
| $this.Size = $this.Size -replace '\s+', ' ' | |
| } else { | |
| # Initialize properties on parse failure | |
| $this.Title = "Error Parsing Row" | |
| $this.Products = "" | |
| $this.Classification = "" | |
| $this.LastUpdated = [datetime]::MinValue | |
| $this.Version = "" | |
| $this.Size = "" | |
| $this.SizeInBytes = "0" | |
| $this.Guid = "" | |
| } | |
| # Fetch FileNames if requested | |
| if ($FetchFileNames -and -not [string]::IsNullOrEmpty($this.Guid)) { | |
| try { | |
| $LinksResult = Get-UpdateLinks -Guid $this.Guid | |
| if ($null -ne $LinksResult -and $LinksResult.PSObject.Properties['Matches'] -and $LinksResult.Matches.Count -gt 0) { | |
| $this.FileNames = foreach ($LinkUrl in $LinksResult.Matches) { | |
| try { [System.Net.WebUtility]::UrlDecode($LinkUrl.Split('/')[-1]) } catch { $LinkUrl.Split('/')[-1] } | |
| } | |
| } else { | |
| $this.FileNames = @() | |
| } | |
| } catch { | |
| $ErrorMessage = $_.Exception.Message | |
| Write-Warning "Error retrieving filenames for update $($this.Title) (GUID: $($this.Guid)): $ErrorMessage" | |
| $this.FileNames = @() | |
| } | |
| } else { | |
| $this.FileNames = @() | |
| } | |
| } | |
| } | |
| class MSCatalogResponse { | |
| [string[]] $RowHtmls | |
| [string] $EventArgument | |
| [string] $EventValidation | |
| [string] $ViewState | |
| [string] $ViewStateGenerator | |
| [bool] $HasNextPage | |
| MSCatalogResponse($HtmlContent) { | |
| # Extract state fields | |
| $statePattern = '<input[^>]+?name="{0}"[^>]+?value="([^"]*?)"' | |
| $this.EventArgument = Get-RegexMatch -InputString $HtmlContent -Pattern ($statePattern -f '__EVENTARGUMENT') | |
| $this.EventValidation = Get-RegexMatch -InputString $HtmlContent -Pattern ($statePattern -f '__EVENTVALIDATION') | |
| $this.ViewState = Get-RegexMatch -InputString $HtmlContent -Pattern ($statePattern -f '__VIEWSTATE') | |
| $this.ViewStateGenerator = Get-RegexMatch -InputString $HtmlContent -Pattern ($statePattern -f '__VIEWSTATEGENERATOR') | |
| # Extract table content | |
| $tablePattern = '<table[^>]+?id="ctl00_catalogBody_updateMatches"[^>]*?>(.*?)</table>' | |
| $tableContent = Get-RegexMatch -InputString $HtmlContent -Pattern $tablePattern | |
| if ($tableContent) { | |
| $rowPattern = '<tr[^>]*?id="(?!headerRow")[^"]*"[^>]*?>.*?</tr>' | |
| $rowMatches = [System.Text.RegularExpressions.Regex]::Matches( | |
| $tableContent, | |
| $rowPattern, | |
| ([System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) | |
| ) | |
| if ($rowMatches.Count -gt 0) { | |
| $this.RowHtmls = foreach ($match in $rowMatches) { $match.Value } | |
| } else { | |
| $this.RowHtmls = @() | |
| } | |
| } else { | |
| $this.RowHtmls = @() | |
| } | |
| # Check for Next Page | |
| $navContainerPattern = '<table[^>]+id="ctl00_catalogBody_navigationLinks"[^>]*?>(.*?)</table>' | |
| $navContainerHtml = Get-RegexMatch -InputString $HtmlContent -Pattern $navContainerPattern | |
| if ($navContainerHtml) { | |
| $nextPagePattern = '<span[^>]+id="ctl00_catalogBody_nextPage"[^>]*?>(.*?)</span>' | |
| $nextPageContent = Get-RegexMatch -InputString $navContainerHtml -Pattern $nextPagePattern | |
| $this.HasNextPage = ($nextPageContent -match '<a[^>]+href=') | |
| } else { | |
| $this.HasNextPage = $false | |
| } | |
| } | |
| } | |
| #endregion Classes | |
| #region Public Functions | |
| function Get-MSCatalogUpdate { | |
| <# | |
| .SYNOPSIS | |
| Query catalog.update.microsoft.com for available updates using native PowerShell 5.1 features. | |
| .DESCRIPTION | |
| Makes HTTP requests to catalog.update.microsoft.com and parses the HTML response to extract update information. | |
| .PARAMETER Search | |
| Specify a string to search for. This is a mandatory parameter. | |
| .PARAMETER SortBy | |
| Specify a field to sort the results by. Valid values are Title, Products, Classification, LastUpdated, Size. | |
| The default sort is by LastUpdated, Descending. | |
| .PARAMETER Descending | |
| Switch the sort order to descending. | |
| .PARAMETER Strict | |
| Performs client-side filtering to ensure 'Title' contains the exact search phrase (case-insensitive). | |
| .PARAMETER IncludeFileNames | |
| Include downloadable filenames for each update (requires additional requests). | |
| .PARAMETER ExcludePreview | |
| Filters out updates whose titles contain the word "Preview" (case-insensitive). | |
| .PARAMETER AllPages | |
| Retrieves all available pages of search results from the catalog. | |
| .EXAMPLE | |
| Get-MSCatalogUpdate -Search "Cumulative Update Windows 10 22H2 x64" | |
| .EXAMPLE | |
| Get-MSCatalogUpdate -Search ".NET Framework 4.8 Security" -SortBy "LastUpdated" -Descending -AllPages -ExcludePreview | |
| .OUTPUTS | |
| MSCatalogUpdate[] - An array of MSCatalogUpdate objects representing the found updates. | |
| #> | |
| [CmdLetBinding(DefaultParameterSetName = 'Default')] | |
| param ( | |
| [Parameter(Mandatory = $true, Position = 0)][string]$Search, | |
| [Parameter(Mandatory = $false)][ValidateSet("Title", "Products", "Classification", "LastUpdated", "Size")][string]$SortBy, | |
| [Parameter(Mandatory = $false)][switch]$Descending, | |
| [Parameter(Mandatory = $false)][switch]$Strict, | |
| [Parameter(Mandatory = $false)][switch]$IncludeFileNames, | |
| [Parameter(Mandatory = $false)][switch]$ExcludePreview, | |
| [Parameter(Mandatory = $false)][switch]$AllPages | |
| ) | |
| $OriginalProgressPreference = $ProgressPreference | |
| $AllResults = New-Object System.Collections.Generic.List[MSCatalogUpdate] | |
| try { | |
| $ProgressPreference = "SilentlyContinue" | |
| # Prepare search URL | |
| $EncodedSearch = [System.Net.WebUtility]::UrlEncode($Search) | |
| $BaseUri = "$script:CatalogBaseUrl/Search.aspx?q=$EncodedSearch" | |
| Write-Verbose "Searching for: $Search" | |
| # Initial GET request | |
| $InitialResponse = Invoke-CatalogRequest -Uri $BaseUri -Method Get | |
| # Apply sorting | |
| $EffectiveSortBy = if ($PSBoundParameters.ContainsKey("SortBy")) { $SortBy } else { "LastUpdated" } | |
| $DefaultDescendingForColumn = ($EffectiveSortBy -eq "LastUpdated") | |
| $EffectiveDescending = if ($PSBoundParameters.ContainsKey("Descending")) { $Descending.IsPresent } else { $DefaultDescendingForColumn } | |
| $SortParams = @{ | |
| Uri = $BaseUri | |
| SortBy = $EffectiveSortBy | |
| Descending = $EffectiveDescending | |
| EventArgument = $InitialResponse.EventArgument | |
| EventValidation = $InitialResponse.EventValidation | |
| ViewState = $InitialResponse.ViewState | |
| ViewStateGenerator = $InitialResponse.ViewStateGenerator | |
| } | |
| $CurrentResponse = Sort-CatalogResults @SortParams | |
| # Process pages and handle pagination | |
| $ProcessedPages = 0 | |
| $MaxPagesToFetch = 50 | |
| $NextPageEventTarget = 'ctl00$catalogBody$nextPageLinkText' | |
| while (($AllPages -and $CurrentResponse.HasNextPage -and $ProcessedPages -lt $MaxPagesToFetch) -or ($ProcessedPages -eq 0)) { | |
| # Process current page rows | |
| if ($CurrentResponse.RowHtmls -and $CurrentResponse.RowHtmls.Count -gt 0) { | |
| foreach ($rowHtml in $CurrentResponse.RowHtmls) { | |
| $shouldIncludeFiles = $IncludeFileNames.IsPresent | |
| $updateObject = [MSCatalogUpdate]::new($rowHtml, $shouldIncludeFiles) | |
| if (-not [string]::IsNullOrEmpty($updateObject.Guid)) { | |
| $AllResults.Add($updateObject) | |
| } | |
| } | |
| } elseif (-not $CurrentResponse.HasNextPage) { | |
| break | |
| } | |
| $ProcessedPages++ | |
| # Fetch next page if needed | |
| if ($AllPages -and $CurrentResponse.HasNextPage -and $ProcessedPages -lt $MaxPagesToFetch) { | |
| $NextParams = @{ | |
| Uri = $BaseUri | |
| Method = "Post" | |
| EventTarget = $NextPageEventTarget | |
| EventArgument = $CurrentResponse.EventArgument | |
| EventValidation = $CurrentResponse.EventValidation | |
| ViewState = $CurrentResponse.ViewState | |
| ViewStateGenerator = $CurrentResponse.ViewStateGenerator | |
| } | |
| $CurrentResponse = Invoke-CatalogRequest @NextParams | |
| Start-Sleep -Milliseconds 500 | |
| } else { | |
| break | |
| } | |
| } | |
| # Apply client-side filtering | |
| $FilteredResults = $AllResults.ToArray() | |
| if ($Strict) { | |
| $FilteredResults = $FilteredResults | Where-Object { $_.Title -like "*$Search*" } | |
| } | |
| if ($ExcludePreview) { | |
| $FilteredResults = $FilteredResults | Where-Object { $_.Title -notmatch 'Preview' } | |
| } | |
| # Output results | |
| if ($FilteredResults.Count -gt 0) { | |
| # Check if output is being piped to another command | |
| if ($MyInvocation.ExpectingInput -or [bool]($MyInvocation.PipelinePosition -and $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength)) { | |
| # Return objects directly when piping to other commands | |
| $FilteredResults | |
| } else { | |
| # Format as table for console output with custom date formatting | |
| $FilteredResults | Format-Table -Property @( | |
| 'Title', | |
| @{Name='LastUpdated'; Expression={$_.LastUpdated.ToString('yyyy-MM-dd')}}, | |
| 'Classification', | |
| 'Version', | |
| 'Size', | |
| 'Guid' | |
| ) | |
| } | |
| } else { | |
| Write-Warning "No updates found matching the specified criteria." | |
| } | |
| } catch { | |
| if ($_.Exception.Message -like "We did not find any results*") { | |
| Write-Warning $_.Exception.Message | |
| } else { | |
| Write-Error "Error in Get-MSCatalogUpdate: $($_.Exception.Message)" | |
| throw $_ | |
| } | |
| } finally { | |
| $ProgressPreference = $OriginalProgressPreference | |
| } | |
| } | |
| function Save-MSCatalogUpdate { | |
| <# | |
| .SYNOPSIS | |
| Downloads update files from the Microsoft Update Catalog using native PowerShell 5.1. | |
| .DESCRIPTION | |
| Takes MSCatalogUpdate objects or a specific update GUID and downloads the associated update files. | |
| .PARAMETER Update | |
| An MSCatalogUpdate object (or array) piped from Get-MSCatalogUpdate or passed directly. | |
| .PARAMETER Guid | |
| The GUID string identifying the update to download. | |
| .PARAMETER Destination | |
| The directory path where the update file(s) should be saved. Defaults to $env:TEMP. | |
| .PARAMETER Language | |
| Specify a language-country code (e.g., "en-us", "de-de") to attempt automatic selection. | |
| .PARAMETER UseBits | |
| Utilize Background Intelligent Transfer Service (BITS) for downloading, if available. | |
| .PARAMETER AcceptMultiFileUpdates | |
| If an update has multiple associated files, automatically download all files without prompting. | |
| .EXAMPLE | |
| Get-MSCatalogUpdate -Search "KB5034122 Windows 10 22H2 x64" | Save-MSCatalogUpdate -Destination "C:\Updates" -UseBits | |
| .EXAMPLE | |
| Save-MSCatalogUpdate -Guid "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -Destination "C:\Temp" | |
| #> | |
| [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "ByObject")] | |
| param ( | |
| [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ParameterSetName = "ByObject")][PSObject]$Update, | |
| [Parameter(Mandatory = $true, Position = 0, ParameterSetName = "ByGuid")][Alias('UpdateID')][String]$Guid, | |
| [Parameter(Mandatory = $false, Position = 1)][Alias('Path', 'DestinationPath')][String]$Destination, | |
| [Parameter(Mandatory = $false, Position = 2)][String]$Language, | |
| [Parameter(Mandatory = $false, Position = 3)][Switch]$UseBits, | |
| [Parameter(Mandatory = $false)][switch]$AcceptMultiFileUpdates | |
| ) | |
| begin { | |
| # Set default destination | |
| if (-not $PSBoundParameters.ContainsKey('Destination') -or [string]::IsNullOrWhiteSpace($Destination)) { | |
| $Destination = $env:TEMP | |
| } | |
| # Validate destination directory | |
| try { | |
| if (-not (Test-Path -Path $Destination -PathType Container)) { | |
| New-Item -Path $Destination -ItemType Directory -Force -ErrorAction Stop | Out-Null | |
| } | |
| $Destination = (Resolve-Path -Path $Destination -ErrorAction Stop).ProviderPath | |
| } catch { | |
| throw "Invalid destination path '$Destination': $($_.Exception.Message)" | |
| } | |
| } | |
| process { | |
| # Determine GUID and title | |
| $CurrentGuid = $null | |
| $UpdateTitle = $null | |
| $DisplayTitle = "Update" | |
| try { | |
| if ($PSCmdlet.ParameterSetName -eq "ByObject") { | |
| if ($Update -is [MSCatalogUpdate] -and $Update.PSObject.Properties['Guid']) { | |
| $CurrentGuid = $Update.Guid | |
| if ($Update.PSObject.Properties['Title']) { | |
| $UpdateTitle = $Update.Title | |
| $DisplayTitle = "'$UpdateTitle' (GUID: $CurrentGuid)" | |
| } else { | |
| $DisplayTitle = "Update (GUID: $CurrentGuid)" | |
| } | |
| } else { | |
| Write-Error "Input object is not a valid MSCatalogUpdate object or lacks a GUID property." | |
| return | |
| } | |
| } else { | |
| $CurrentGuid = $Guid | |
| $DisplayTitle = "Update (GUID: $CurrentGuid)" | |
| } | |
| if ([string]::IsNullOrWhiteSpace($CurrentGuid)) { | |
| Write-Error "Could not determine Update GUID for '$DisplayTitle'." | |
| return | |
| } | |
| # Get download links | |
| $LinksResult = Get-UpdateLinks -Guid $CurrentGuid -ErrorAction Stop | |
| if ($null -eq $LinksResult -or $LinksResult.Matches.Count -eq 0) { | |
| Write-Warning "No download links found for $DisplayTitle." | |
| return | |
| } | |
| $DownloadUrls = $LinksResult.Matches | |
| $FilesToDownload = [System.Collections.Generic.List[string]]::new() | |
| # Select file(s) to download | |
| if ($DownloadUrls.Count -eq 1) { | |
| $FilesToDownload.Add($DownloadUrls[0]) | |
| } elseif ($PSBoundParameters.ContainsKey('Language') -and -not [string]::IsNullOrWhiteSpace($Language)) { | |
| $LangPattern = "[_\./]$($Language)(?:[_\./]|$)" | |
| $MatchingUrls = $DownloadUrls | Where-Object { $_ -match $LangPattern } | |
| if ($MatchingUrls.Count -eq 1) { | |
| $FilesToDownload.Add($MatchingUrls[0]) | |
| } elseif ($MatchingUrls.Count -gt 1) { | |
| Write-Warning "Multiple files found matching language '$Language' for $DisplayTitle." | |
| $MatchingUrls | ForEach-Object { | |
| $fn = try { [System.Net.WebUtility]::UrlDecode($_.Split('/')[-1]) } catch { $_.Split('/')[-1] } | |
| Write-Warning "- $fn" | |
| } | |
| if ($AcceptMultiFileUpdates.IsPresent -or ($Host.UI.RawUI -eq $null)) { | |
| Write-Error "Multiple files match language '$Language' and cannot select automatically." | |
| return | |
| } | |
| } else { | |
| Write-Warning "No file found matching language code '$Language' for $DisplayTitle." | |
| $DownloadUrls | ForEach-Object { | |
| $fn = try { [System.Net.WebUtility]::UrlDecode($_.Split('/')[-1]) } catch { $_.Split('/')[-1] } | |
| Write-Warning "- $fn" | |
| } | |
| if ($AcceptMultiFileUpdates.IsPresent -or ($Host.UI.RawUI -eq $null)) { | |
| Write-Error "Specified language '$Language' not found and cannot prompt." | |
| return | |
| } | |
| } | |
| if ($FilesToDownload.Count -eq 0 -and ($AcceptMultiFileUpdates.IsPresent -or ($Host.UI.RawUI -eq $null))) { | |
| Write-Error "Failed to select file by language and cannot prompt." | |
| return | |
| } | |
| } | |
| # Handle multiple files or failed language match | |
| if ($FilesToDownload.Count -eq 0) { | |
| if ($AcceptMultiFileUpdates.IsPresent) { | |
| foreach ($Url in $DownloadUrls) { | |
| $FilesToDownload.Add($Url) | |
| } | |
| } elseif ($Host.UI.RawUI -eq $null) { | |
| Write-Error "Update has multiple files and requires selection, but environment is non-interactive." | |
| return | |
| } else { | |
| # Interactive prompt | |
| Write-Host "`nMultiple files found for update $DisplayTitle." -ForegroundColor Yellow | |
| Write-Host ("-" * 60) -ForegroundColor Yellow | |
| for ($i = 0; $i -lt $DownloadUrls.Count; $i++) { | |
| $FileName = try { [System.Net.WebUtility]::UrlDecode($DownloadUrls[$i].Split('/')[-1]) } catch { $DownloadUrls[$i].Split('/')[-1] } | |
| Write-Host ("{0,3}: {1}" -f $i, $FileName) | |
| } | |
| Write-Host ("-" * 60) -ForegroundColor Yellow | |
| # Get user selection | |
| while ($true) { | |
| try { | |
| $Choice = Read-Host "Enter the number to download, 'A' for All, or 'S' to Skip" | |
| if ($Choice -eq 'S') { | |
| Write-Host "Skipping download for $DisplayTitle." -ForegroundColor Cyan | |
| return | |
| } elseif ($Choice -eq 'A') { | |
| foreach ($Url in $DownloadUrls) { | |
| $FilesToDownload.Add($Url) | |
| } | |
| break | |
| } elseif (($Choice -match '^\d+$') -and ([int]$Choice -ge 0) -and ([int]$Choice -lt $DownloadUrls.Count)) { | |
| $FilesToDownload.Add($DownloadUrls[[int]$Choice]) | |
| break | |
| } else { | |
| Write-Warning "Invalid input. Please enter a valid number (0 to $($DownloadUrls.Count - 1)), 'A', or 'S'." | |
| } | |
| } catch { | |
| Write-Warning "Input error: $($_.Exception.Message). Try again or enter 'S' to skip." | |
| Start-Sleep -Seconds 1 | |
| } | |
| } | |
| } | |
| } | |
| # Perform downloads | |
| if ($FilesToDownload.Count -gt 0) { | |
| foreach ($FileUrl in $FilesToDownload) { | |
| $EncodedFileName = $FileUrl.Split('/')[-1] | |
| $DecodedFileName = try { [System.Net.WebUtility]::UrlDecode($EncodedFileName) } catch { $EncodedFileName } | |
| $SafeFileName = $DecodedFileName -replace '[<>:"/\\|?*]', '_' | |
| $DestinationFile = Join-Path -Path $Destination -ChildPath $SafeFileName | |
| Write-Host "`nDownloading '$SafeFileName' for $DisplayTitle..." -ForegroundColor Green | |
| $DownloadParams = @{ | |
| Uri = [uri]$FileUrl | |
| Path = $DestinationFile | |
| ErrorAction = 'Stop' | |
| } | |
| if ($UseBits.IsPresent) { | |
| $DownloadParams.UseBits = $true | |
| } | |
| if ($PSCmdlet.ShouldProcess($DestinationFile, "Download '$SafeFileName'")) { | |
| try { | |
| Invoke-DownloadFile @DownloadParams | |
| Write-Host "'$SafeFileName' downloaded successfully to '$Destination'." -ForegroundColor Green | |
| } catch { | |
| Write-Error "Failed to download '$SafeFileName': $($_.Exception.Message)" | |
| } | |
| } | |
| } | |
| } | |
| } catch { | |
| Write-Error "Error processing update '$DisplayTitle': $($_.Exception.Message)" | |
| } | |
| } | |
| } | |
| #endregion Public Functions |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment