TrackGpo.psm1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 |
function Invoke-GpoTracking { <# .SYNOPSIS This function will export all GPO info into restorable objects and place them into a folder for easy access .DESCRIPTION This function aims to make life easy for SysAdmins everywhere who work with Group Policy Objects (GPOs) and want to be able to audit/detect changes to them. Each folder backup is a GroupPolicy compatible backup that can be restored at will in order to revert changes or restore an accidently deleted GPO. They also contain a summary document that makes it easy to digest the state of each folder if you need to dig that deep. The default settings provide a resilient backup snapshot for all GPOs in your domain. Though you can override many settings as needed, you generally shouldn't need to. This includes disabling the git repo functionality, or deleting policies entirely from the backup folder when they get deleted from your domain. Defaults include the following settings: * Will update the Git repo ONLY if less than 10% of GPOs have changed since the last run * When a GPO is deleted from the domain: -all versions of the GPO get removed from the git repo -all versions of the GPO remain in the folder * Any change diff will include 3 lines of context above and below the change and also the common stuff about revision number and modified date This cmdlet depends on having Git installed and available for its diff capabilities. In order to make the MOST of this function, you need to create your own functions for two external events that can happen: * Normal GPO additions, changes, and deletions * Errors during processing Do so by creating a function or module named New-TrackGpoTicket_External and New-TrackGpoError_External respectively. The private functions in this repo will pass the same parameters to your external version of the function if you create one. You can also install the PSGallery module TrackGpo_Builtin for some samples to work off of. See: https://gitlab.com/devirich/trackgpo_builtin .PARAMETER GpoRepo The folder path to store the backed up GPOs to. .PARAMETER WorkingDir The folder path to temporarily store fresh backup for all GPOs for comparison. .PARAMETER Initialize By default, this function does NOT create folders or init a git repo. Enable this switch to turn on these features .PARAMETER ChangeRemovePercentMaxDelta Maximum percentage of change allowed in removals or additions before the script throws an error .PARAMETER RemoveOldPolicyVersions When a GPO is changed, this function will keep both folders. This switch makes it so that all old versions of the policy get removed. .PARAMETER RemoveDeletedPolicies Enable this switch if you want the backups folder to ONLY contain GPOs that are live on your domain. .PARAMETER DisableGitRepo Enable this switch if you hate git repos or have a legit worry about storing GPOs in a git repo history. .PARAMETER SkipCommonChanges Enable this switch if you don't want to see the modified date and revision number in the change documentation. See the wiki for an edge case to be aware of. .PARAMETER GpoChangeContext When a GPO is changed, the diff by default will show context around the actual changed lines. This parameter affects how many lines of context to show. .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -Initialize -WhatIf What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore". What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore_working". What if: Performing the operation "Push-Location" on target "C:\Protected\GpoStore". What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*". What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore". What if: Performing the operation "Compare GroupPolicy to repo" on target "C:\Protected\GpoStore". What if: Back up all the GPOs in the domain.test domain to the following location: C:\Protected\GpoStore_working. (Backup-GPO) What if: Performing the operation "Set-Content" on target "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)". What if: Performing the operation "Compare GPO GUIDs and reconcile changes" on target "`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`". What if: Performing the operation "Process any new GPO objects." on target "$WorkingDir\<FOLDER>". What if: Performing the operation "Process any updated GPO objects." on target "$WorkingDir\<FOLDER(S)>". What if: Performing the operation "Process any removed GPO objects." on target "$GpoRepo\<FOLDERS>". What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*". What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore". Enabling -Initialize to automatically create folders and using -WhatIf to get a view of what actions will be taken and where. Confirm that these actions and paths are what you expect. Remove -WhatIf and let the script run! .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working Use this for subsequent runs if you're ok with the defaults. .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -RemoveOldPolicyVersions -RemoveDeletedPolicies -DisableGitRepo -GpoChangeContext 0 -SkipCommonChanges This example will: Disable git functionality and make the repo folder a 1:1 match of live group policies in the domain. It also will set the context around each GPO diff to 0 lines above and below and skip showing the GPO revision number and modified date. .NOTES Original Publish date: 31Oct2018 Hope you like it! #> [cmdletbinding( SupportsShouldProcess, ConfirmImpact = "Medium" )] Param( [string]$GpoRepo, [string]$WorkingDir, [switch]$Initialize, [ValidateRange(0, 100)] [int]$ChangeRemovePercentMaxDelta = 10, [switch]$RemoveOldPolicyVersions, [switch]$RemoveDeletedPolicies, [switch]$DisableGitRepo, [switch]$SkipCommonChanges, [int]$GpoChangeContext = 3 ) $ErrorActionPreference = "Stop" # Need to ensure that Git is installed and accessible as expected try { git | Out-Null } catch { $Message = "Unable to run `git`. Exiting. Install Git or create an alias to run `git` if installed" New-TrackGpoError $Message throw $Message } $GpoRepo = Resolve-Path_Force $GpoRepo $WorkingDir = Resolve-Path_Force $WorkingDir if (Test-Path $GpoRepo) {} # Good to go. I just hate nested if statements elseif ($Initialize) { mkdir $GpoRepo } else { $Message = "$GpoRepo does not exist and -Initialize was not specified. Exiting." New-TrackGpoError $Message throw $Message } if (Test-Path $WorkingDir) {} # Good to go. I just hate nested if statements elseif ($Initialize) { mkdir $WorkingDir } else { $Message = "WorkingDir does not exist and -Initialize was not specified. Exiting." New-TrackGpoError $Message throw $Message } try { if ($pscmdlet.ShouldProcess($GpoRepo, 'Push-Location')) { Push-Location $GpoRepo } try { $Status = git -C $GpoRepo status 2>$null } catch {} if ($DisableGitRepo -or $Status) {} # Good to go. elseif ($Initialize) { git init } else { throw "GpoRepo is not a git repo and -Initialize was not specified. Exiting." } # Want working dir and git repo in a fresh state if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) { Remove-Item $WorkingDir\* -Recurse -Force } if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) { git reset --hard | Out-Null } if ($pscmdlet.ShouldProcess($GpoRepo, 'Compare GroupPolicy to repo')) { # Get the newest version of each GPO based on GUID if ($PSBoundParameters.DisableGitRepo) { $GpoRepo_LatestGpos = Get-ChildItem $GpoRepo\*\*.summary } else { $GpoRepo_LatestGpos = git ls-files *\*.summary | Get-ChildItem } Write-Verbose "Found $($GpoRepo_LatestGpos.count) items in repo" $GpoRepo_LatestGpos = $GpoRepo_LatestGpos | Sort-Object -Desc LastWriteTime | Group-Object Name | ForEach-Object { $_.Group[0] } Write-Verbose "Found $($GpoRepo_LatestGpos.count) items in repo" $PercentChanged = Get-TrackGpoDeltaPercent -GpoRepo_LatestGpos $GpoRepo_LatestGpos if ($PercentChanged -gt $ChangeRemovePercentMaxDelta -and -not $PSBoundParameters.Initialize) { throw "Too many added or removed GPOs. $PercentChanged% of existing $($GpoRepo_LatestGpos.count) policies have been added or deleted. It should be at or under $ChangeRemovePercentMaxDelta% changed. Change -ChangeRemovePercentMaxDelta or determine why so many are listed as having added/removed." } } try { #Region Export GPOs and Summary files to working dir $i = 0 Backup-GPO -All -Path $WorkingDir | ForEach-Object { $i++ Write-Progress -Activity "Backing up GPO reports" -Status "Processing policy number: $i" -CurrentOperation $_.DisplayName Get-GPOReport -ReportType Html -Guid $_.GpoId | Select-Object -OutVariable GpoReport_html | Out-Null Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).htm" -Value $GpoReport_html # Data collected data will always be different. Need to remove it before storing summary for comparison: $GpoReport_html = $GpoReport_html -replace '<td id="dtstamp">.*</td>' # We want the comparison to be as neat as possible. Strip HTML data- $GpoReport = Remove-HtmlContent $GpoReport_html Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary" -Value $GpoReport } #These commands are needed because -WhatIf processing will NOT reach the inner loop of the above foreach. if ($pscmdlet.ShouldProcess('$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)', "Set-Content")) {} #EndRegion Export GPOs and Summary files to working dir } catch { throw "Backing up GPO's failed. Exiting immediately: $($_.Exception.Message)" } if ($pscmdlet.ShouldProcess('`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`', "Compare GPO GUIDs and reconcile changes")) { $WorkingFileIO = Get-ChildItem $WorkingDir\*\*.summary if ($GpoRepo_LatestGpos -and $WorkingFileIO) { $i = 0 $ComparisonCases = Compare-Object $GpoRepo_LatestGpos $WorkingFileIO -prop Name -IncludeEqual | Sort-Object SideIndicator foreach ($Comparison in $ComparisonCases) { $i++ Write-Progress -Activity "Comparing policies" -CurrentOperation $Comparison.Name -PercentComplete ($i / $ComparisonCases.Count * 100) $Gpo = $Comparison switch ($Comparison.SideIndicator) { "<=" { #Previously existed. Not present anymore. $VersionsOfGpo = Get-ChildItem $GpoRepo\*\$($Gpo.Name) $LatestVersionOfGpo = $VersionsOfGpo | Sort-Object -Desc LastWriteTime | Select-Object -First 1 $GpoInfo = Get-GpoInfo ($LatestVersionOfGpo.FullName -replace "summary", "htm") Write-Verbose "Removing GPO: $($GpoInfo.Title)" $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Remove $VersionsOfGpo | ForEach-Object { if ($RemoveDeletedPolicies -or $DisableGitRepo) { Remove-Item -Recurse -Force $_.DirectoryName } if (-not $DisableGitRepo -and (git ls-files $_.DirectoryName)) { git rm --cached -r $_.DirectoryName } } if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Remove: {0}" -f $GpoInfo.Title } git commit -m $CommitMessage } } "=>" { #Just created! $GpoReport = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name $HeadGpoFolder = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru $HeadGpoReport = Get-ChildItem -Path $HeadGpoFolder\*.summary $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm") Write-Verbose "Adding GPO: $($GpoInfo.Title)" $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Add if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Add: {0}" -f $GpoInfo.Title } git add $HeadGpoReport.DirectoryName git commit -m $CommitMessage } } "==" { # Exists previously and still exists. # Most of the time, this is what gets run. $ExistingGpoObject = $GpoRepo_LatestGpos | Where-Object Name -EQ $Gpo.Name $UpdatedGpoObject = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name $Format = "U$GpoChangeContext" $DiffResults = git diff --shortstat --no-index -$Format -p --ignore-all-space $ExistingGpoObject.FullName $UpdatedGpoObject.FullName if ($DiffResults) { # need to pull out the stats on line 1 and discard line number 2 so that the results are ready for parsing $DiffStats, $null, $DiffResults = $DiffResults $Diff = ConvertFrom-Diff $DiffResults if ($SkipCommonChanges) { $DiffResults = $Diff.ToString("-", "User Revisions|Computer Revisions", 2) } else { $DiffResults = $Diff.ToString() } $GpoInfo = Get-GpoInfo ($UpdatedGpoObject.FullName -replace "summary", "htm") $Splat = @{ GpoInfo = $GpoInfo Type = "Change" Diff = $DiffResults Stats = $DiffStats } $CommitMessage = New-TrackGpoTicket @Splat Move-Item $UpdatedGpoObject.DirectoryName $GpoRepo if ($RemoveOldPolicyVersions) { Get-ChildItem $GpoRepo\*\$($Gpo.Name) | Sort-Object -Desc LastWriteTime | Select-Object -Skip 1 | ForEach-Object { Write-Verbose "Removing previous version of GPO backup: $($_.Directory)" $_.Directory | Remove-Item -Recurse -Force if (-not $DisableGitRepo) { git rm -r $_.DirectoryName } } } if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Add: {0}" -f $GpoInfo.Title } Write-Verbose "Adding modified GPO to repo - $($Gpo.BaseName)" git add (Split-Path -Leaf $UpdatedGpoObject.DirectoryName) git commit -m $CommitMessage } } else { Write-Verbose "GPO has not changed. Removing from working." Remove-Item $UpdatedGpoObject.DirectoryName -Recurse -Force } } } } } elseif ($GpoRepo_LatestGpos) { throw "I'm scared: All GPOs removed from domain??! Or other error. Ya, you should look carefully at what is causing this." } elseif ($WorkingFileIO) { Write-Verbose "No GPOs currently exist in head! Adding all GPOs to git." foreach ($GpoReport in $WorkingFileIO) { $MovedItem = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru $HeadGpoReport = Get-ChildItem -Path $MovedItem\*.summary $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm") if (-not $DisableGitRepo) { Write-Verbose "Committing to head with comment: $($GpoInfo['Title'])" git add $HeadGpoReport.DirectoryName git commit -m "Init domain: $($GpoInfo.Title)" } } } else { throw "No files in head or working! What's going on here anyway!?!" } } if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER>', "Process any new GPO objects.")) {} if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER(S)>', "Process any updated GPO objects.")) {} if ($pscmdlet.ShouldProcess('$GpoRepo\<FOLDERS>', "Process any removed GPO objects.")) {} if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) { Remove-Item $WorkingDir\* -Recurse -Force } if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) { git reset --hard | Out-Null } Pop-Location } catch { Pop-Location $Message = $_.Exception.Message New-TrackGpoError $Message throw $Message } } function ConvertFrom-Diff { [CmdletBinding()] param ( [string[]]$In ) $String = $In -join "`n" $Sections = $String -replace "`r" -split "(?m)`n(?=^diff)" $Sections | ForEach-Object { [Diff]::new($_) } } class Diff { [string]$Header [string[]]$ExtendedHeaders [string]$From [string]$To [array[]]$Hunk Diff() {} Diff([string[]]$String) { # Need to ensure that whether you input an array of strings, or a string with multiple lines, or a combo, # that it ends up as an array of strings in a queue collection: $In = $String -join "`n" [System.Collections.Generic.Queue[string]]$Q = $In -split "`n" # Need to make sure that Q is properly populated. If so, convert to our object! if ($Q.Count -and $Q.Peek() -match "^diff") { $this.Header = $Q.Dequeue() $this.ExtendedHeaders = while ($Q.Peek() -notmatch "^---") { $Q.Dequeue() } $this.From = $Q.Dequeue() $this.To = $Q.Dequeue() $this.Hunk = $Q -join "`n" -split "(?m)`n(?=^@@)" } } [string[]] ToString() { return $this.ToString($null, $null, $null) } [string[]] ToString([string]$ExcludeHunkPattern) { return $this.ToString("-", $ExcludeHunkPattern, 2) } [string[]] ToString([string]$Modifier, [string]$HunkPattern, [int]$SearchScope) { [string[]]$Out = @() # SearchScope is used with -SkipCommonChanges to search in the first couple lines for the Computer or User revisions fields. # This feels a bit like a hack. Cause it is. switch ($Modifier) { "+" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -match $HunkPattern } } "-" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -notmatch $HunkPattern } } default { $out += $this.Hunk | Select-Object -First $SearchScope } } $out += $this.Hunk | Select-Object -Skip $SearchScope return $out | ForEach-Object { $_ -split "`n" } } } function Get-GpoInfo ([string]$FilePath) { $GpoContents = Get-Content $FilePath $GpoInfo = [ordered]@{ Title = [regex]::Match($GpoContents, '(?<=<title>).*?(?=</title>)').Value Created = [regex]::Match($GpoContents, '(?<="row">Created</td><td>).*?(?=</td></tr>)').Value Modified = [regex]::Match($GpoContents, '(?<="row">Modified</td><td>).*?(?=</td></tr>)').Value GUID = [regex]::Match($GpoContents, '(?<="row">Unique ID</td><td>).*?(?=</td></tr>)').Value 'GPO Status' = [regex]::Match($GpoContents, '(?<="row">GPO Status</td><td>).*?(?=</td></tr>)').Value 'Enabled Links' = [regex]::Matches($GpoContents, '(?<=<td>Enabled</td><td>)feb.com/.*?(?=</td>)').Value -join "`n" } $GpoInfo } function Get-TrackGpoDeltaPercent { <# .SYNOPSIS Returns a percentage as 0-100 of how many GPOs are not common between the domain and a list of GPO ids .PARAMETER GpoRepo_LatestGpos Array of GPOs that was the last current snapshot of the domain #> [CmdletBinding()] [OutputType([int])] param ( [Parameter()] $GpoRepo_LatestGpos ) # Need a baseline of all previous GPOs in order to track possible failres if ($DomainGpos = Get-GPO -All) { # This check is placed inside the Get-GPO block to ensure that Get-GPO works even when there # are no existing GPO's getting tracked. if ($GpoRepo_LatestGpos) { Write-Verbose "Comparing $($DomainGpos.ID.Count) domain GPOs to $($GpoRepo_LatestGpos.BaseName.count) Repo GPOs" $Same,$Diff = (Compare-Object $DomainGpos.ID $GpoRepo_LatestGpos.BaseName -IncludeEqual).Where({$_.SideIndicator -eq "=="},"Split") $Diff.Count / ($Same.Count + $Diff.Count) * 100 } else { # When there are no existing GPOs getting tracked, everything is changed. Return 100% Write-Verbose "No repo GPOs. 100% changed!" 100 } } else { throw "Could not get domain Group Policy Objects (GPOs). Please fix this issue. Permissions?" } } function New-TrackGpoError { param( [parameter(Mandatory)] [String]$Message ) $Splat = @{ Message = $Message } $CommandName = $MyInvocation.InvocationName + "_External" if (Get-Command $CommandName -ea silent) { [string]$Message = & $CommandName @Splat $Message } } function New-TrackGpoTicket { param( [ValidateSet("Add", "Remove", "Change")] [parameter(Mandatory)]$Type, [parameter(Mandatory)]$GpoInfo, $Diff, $Stats ) $Splat = @{ Type = $Type GpoInfo = $GpoInfo } if ($Diff) { $Splat.Add("Diff", $Diff) } if ($Stats) { $Splat.Add("Stats", $Stats) } $CommandName = $MyInvocation.InvocationName + "_External" if (Get-Command $CommandName -ea silent) { [string]$Message = & $CommandName @Splat $Message } } function Remove-HtmlContent { param([System.String[]] $html) # Adapted from: http://winstonfassett.com/blog/2010/09/21/html-to-text-conversion-in-powershell/ # This function makes use of the single line (?s) regex modifier to make . apply to newlines # This function makes use of the multiline (?m) regex modifier to make ^|$ apply to newlines # Want to preserve line breaks for pretty formatting later, but need a single string with only newlines: $html = $html -replace "`r" -join "`n" # remove invisible content @('head', 'script', 'style', 'object', 'embed', 'applet', 'noframes', 'noscript', 'noembed') | ForEach-Object { $html = $html -replace "(?ms)<$_[^>]*?>.*?^</$_>", "" } # write-verbose "removed invisible blocks: `n`n$html`n" # Condense extra whitespace $html = $html -replace "( )+", " " # write-verbose "condensed whitespace: `n`n$html`n" # Remove the window styles $html = $html -replace '(?ms)<div id="explainText_windowStyles.*?</div>' # Add line breaks @('div', 'p', 'blockquote', 'h[1-9]', 'tr') | ForEach-Object { $html = $html -replace "(?ms)</?$_[^>]*?>.*?</$_>", ("`n" + '$0' ) } # Add line breaks for self-closing tags @('div', 'p', 'blockquote', 'h[1-9]', 'br') | ForEach-Object { $html = $html -replace "(?ms)<$_[^>]*?/>", ('$0' + "`n") } # write-verbose "added line breaks: `n`n$html`n" # table cells deserve a tab after them $html = $html -replace "</td>|</th>", " `t" #strip tags $html = $html -replace "<[^>]*?>", "" # write-verbose "removed tags: `n`n$html`n" # replace common entities @( @(" ", " "), @("&bull;", " * "), @("&lsaquo;", "<"), @("&rsaquo;", ">"), @("&(rsquo|lsquo|#39|#039);", "'"), @("�?39;", "'"), @("&(quot|ldquo|rdquo);", '"'), @("&trade;", "(tm)"), @("&frasl;", "/"), @("&(quot|#34|#034|#x22);", '"'), @('&(amp|#38|#038|#x26);', "&"), @("&(lt|#60|#060|#x3c);", "<"), @("&(gt|#62|#062|#x3e);", ">"), @('&(copy|#169);', "(c)"), @("&(reg|#174);", "(r)"), @("&nbsp;", " "), @("&(.{2,6});", "") ) | ForEach-Object { $html = $html -replace $_[0], $_[1] } # write-verbose "replaced entities: `n`n$html`n" # Extra lines should get condensed $html = $html -replace "`n+", "`n" $html -split "`n" } function Resolve-Path_Force { <# .SYNOPSIS Calls Resolve-Path but works for files that don't exist. .REMARKS From http://devhawk.net/blog/2010/1/22/fixing-powershells-busted-resolve-path-cmdlet #> param ( [string] $FileName ) $FileName = Resolve-Path $FileName -ErrorAction SilentlyContinue -ErrorVariable _frperror if (-not($FileName)) { $FileName = $_frperror[0].TargetObject } return $FileName } |