Skip to content

Instantly share code, notes, and snippets.

@XTheocharis
Created April 10, 2025 02:10
Show Gist options
  • Select an option

  • Save XTheocharis/bd3d9d1cda2e0ff9eea2acf77e6ac51b to your computer and use it in GitHub Desktop.

Select an option

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)
<#
.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