PowerShell 技能连载 - 使用 HTML 来创建 PDF 报告(第 2 部分)

HTML 是一种将数据格式化为输出报告的简单方法。在第二部分中,我们说明了如何将包含数组的属性转换为字符串列表。数组无法正确显示为文本,因此此问题适用于 HTML 报告和将数据导出到 CSV。

请看:下面的代码将您的所有服务生成 HTML 报告,这是我们在第一部分结束时停下的地方:

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
$path = "$env:temp\report.html"

# get data from any cmdlet you wish
$data = Get-Service | Sort-Object -Property Status, Name

# compose style sheet
$stylesheet = "
<style>
body { background-color:#AAEEEE;
font-family:Monospace;
font-size:10pt; }
table,td, th { border:1px solid blue;}
th { color:#00008B;
background-color:#EEEEAA;
font-size: 12pt;}
table { margin-left:30px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
h1{color:#DC143C;}
h5{color:#DC143C;}
</style>
"

# output to HTML
$data | ConvertTo-Html -Title Report -Head $stylesheet | Set-Content -Path $path -Encoding UTF8

Invoke-Item -Path $path

当您查看报告时,您会注意到某些列包含数据类型而不是数据,即 RequiredServicesDependentServices。 原因是因为这些属性包含数组。要正确显示属性内容,您需要首先将数组转换为字符串列表。

这是一个自动检测包含数组的属性并用字符串列表代替这些属性的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Convert-ArrayToStringList
{
param
(
[Parameter(Mandatory, ValueFromPipeline)]
$PipelineObject
)
process
{
$Property = $PipelineObject.psobject.Properties |
Where-Object { $_.Value -is [Array] } |
Select-Object -ExpandProperty Name

foreach ($item in $Property)
{
$PipelineObject.$item = $PipelineObject.$item -join ','
}

return $PipelineObject
}
}

为此,您首先必须通过 Select-Object 获取可以操纵的对象的副本。Convert-ArrayToStringList 也对创建 CSV 导出非常有帮助。下面的代码将服务列表创建为 CSV 文件,并确保所有属性都是可读的,然后将 CSV 文件加载到 Microsoft Excel 中:

1
2
3
4
5
6
7
8
$Path = "$env:temp\report.csv"

Get-Service |
Select-Object -Property * |
Convert-ArrayToStringList |
Export-Csv -NoTypeInformation -Encoding UTF8 -Path $Path -UseCulture

Start-Process -FilePath excel -ArgumentList $Path

这是一个完整的脚本,能创建一个具有所有可读属性的服务报告:

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
$path = "$env:temp\report.html"

# get data from any cmdlet you wish
$data = Get-Service | Sort-Object -Property Status, Name

# helper function to convert arrays to string lists
function Convert-ArrayToStringList
{
param
(
[Parameter(Mandatory, ValueFromPipeline)]
$PipelineObject
)
process
{
$Property = $PipelineObject.psobject.Properties |
Where-Object { $_.Value -is [Array] } |
Select-Object -ExpandProperty Name

foreach ($item in $Property)
{
$PipelineObject.$item = $PipelineObject.$item -join ','


}

return $PipelineObject
}
}


# compose style sheet
$stylesheet = "
<style>
body { background-color:#AAEEEE;
font-family:Monospace;
font-size:10pt; }
table,td, th { border:1px solid blue;}
th { color:#00008B;
background-color:#EEEEAA;
font-size: 12pt;}
table { margin-left:30px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
h1{color:#DC143C;}
h5{color:#DC143C;}
</style>
"

# output to HTML
$data |
# make sure you use Select-Object to copy the objects
Select-Object -Property * |
Convert-ArrayToStringList |
ConvertTo-Html -Title Report -Head $stylesheet |
Set-Content -Path $path -Encoding UTF8

Invoke-Item -Path $path

PowerShell 技能连载 - 使用 HTML 来创建 PDF 报告(第 1 部分)

可以通过 HTML 轻松地将格式化的数据转换成输出报告。在这个三部分的系列中,我们首先说明您如何撰写 HTML 报告,然后展示一种将这些 HTML 报告转换为 PDF 文档的简单方法。

PowerShell 中有一个 ConvertTo-Html cmdlet,可以轻松将输出保存到 HTML 表格:

1
2
3
4
5
6
7
8
9
$path = "$env:temp\report.html"

# get data from any cmdlet you wish
$data = Get-Service | Sort-Object -Property Status, Name

# output to HTML
$data | ConvertTo-Html | Set-Content -Path $path -Encoding UTF8

Invoke-Item -Path $path

最终的报告可能仍然很丑陋,但是添加 HTML 样式表可以轻松美化报告并添加您的公司设计:

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
$path = "$env:temp\report.html"

# get data from any cmdlet you wish
$data = Get-Service | Sort-Object -Property Status, Name

# compose style sheet
$stylesheet = "
<style>
body { background-color:#AAEEEE;
font-family:Monospace;
font-size:10pt; }
table,td, th { border:1px solid blue;}
th { color:#00008B;
background-color:#EEEEAA;
font-size: 12pt;}
table { margin-left:30px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
h1{color:#DC143C;}
h5{color:#DC143C;}
</style>
"

# output to HTML
$data | ConvertTo-Html -Title Report -Head $stylesheet | Set-Content -Path $path -Encoding UTF8

Invoke-Item -Path $path

PowerShell 技能连载 - 更新帮助

如果您有时使用 Get-Help 和本地帮助,则应该偶尔运行 Update-Help 以更新本地帮助文件。在 Windows PowerShell 上,这需要本地管理特权,因为帮助文件存储在受保护的 Windows 文件夹中:

1
PS> Update-Help -UICulture en-us -Force

在 PowerShell 7 上,Update-Help 现在有一个附加的参数 -Scope CurrentUser,因此您也可以在没有管理员特权的情况下更新本地帮助。

更新本地帮助很重要,因为每当您运行它时,它都会动态地查看机器上存在的 PowerShell 模块,并仅下载关于它们的帮助。如果以后添加更多模块,则不要忘记再次运行更新——还可以下载新模块的帮助文件——前提是该模块尚未发布它们。

请注意,Update-Module 可能会在结束时发出红色错误消息。不用担心:通常,它仅是由几个没有帮助的模块引起的。错误消息并未表明整个更新过程有问题。

PowerShell 技能连载 - 利用用户配置文件的优势

当 PowerShell 启动时,它会自动查找一个特殊的自动启动脚本。默认情况下该脚本不存在,并且对于每个 PowerShell 环境是不同的。$profile 变量体现它的路径。这是在我机器的 Windows PowerShell 控制台环境中的路径:

1
C:\Users\tobias\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1

可以快速地检测这个文件是否存在,如果不存在的话,用 PowerShell 创建它:

1
2
3
4
5
6
7
$exists = Test-Path -Path $profile
if ($exists -eq $false)
{
$null = New-Item -Path $profile -ItemType File -Force
}

notepad $profile

有了这样的自启动脚本后,您可以在您的每个 PowerShell 会话中添加各种有用的东西。例如,创建一个较短的命令行提示符:

1
2
3
4
5
function prompt
{
'PS> '
$host.UI.RawUI.WindowTitle = Get-Location
}

或通过简化的登录使生活更轻松:

1
2
3
4
5
6
7
8
9
10
function in365
{
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName 'youremailhere'
}

function out365
{
Disconnect-ExchangeOnline -Confirm:$false
}

只需确保您保存更改以及执行策略允许脚本运行即可。

PowerShell 技能连载 - 请担心 -match 运算符

-match 运算符经常在脚本中使用,但是似乎并不是每个人都了解它实际的工作方式。它可能是一个非常危险的过滤器操作符。

让我们先创建一些示例数据:

1
2
3
4
5
6
$list = 'ServerName, Location, Status
Test1, Hannover, Up
Test2, New York, Up
Test11, Sydney, Up' | ConvertFrom-Csv

$list

结果是假设服务器的列表:

ServerName Location Status
---------- -------- ------
Test1      Hannover Up
Test2      New York Up
Test11     Sydney   Up

假设您希望 PowerShell 脚本从列表中选择服务器并操作它,例如关闭电源:

1
2
3
4
# server to work with:
$filter = 'Test2'
# pick filter from list:
$list | Where-Object ServerName -match $filter

一切看起来工作正常:

ServerName Location Status
---------- -------- ------
Test2      New York Up

但是, -match 期望的是正则表达式,而不仅仅是纯文本。另外,如果在文本中的任意位置找到了匹配的表达式,则它将返回 $true。将 $filter 改为 “Test1” 以选择服务器 “Test1” 时,以下是执行结果:

ServerName Location Status
---------- -------- ------
Test1      Hannover Up
Test11     Sydney   Up

您会意外选择了两个服务器,因为 “Test11” 也包含了文本 “Test1”。

更糟糕的是:如果出于某种愚蠢的原因,$filter 是空白的,则会选择所有内容——因为“空白”能匹配任何内容。请自己尝试,并将 $filter 的值设为 ''

选择比较运算符时要非常小心,并且使用 -match 时要格外小心。在上面的示例中,-eq运算符(等于)会更合适,如果您必须使用通配符,那么 -like 使用起来更明确,因为它需要明确的 “*” 通配符,如果您真的只想比较数值的一部分。

PowerShell 技能连载 - 记录变量类型

作为调试和质量控制的一部分,您可能需要记录赋值给一个变量的数据。例如,您可能需要找出赋值给指定变量的实际数据类型是什么,以便以后可以以强类型的方式访问变量以增加安全性。

这是一个自定义验证器类,可以用于这类分析。只需先运行下面的代码。除了声明新属性外,它不做任何事情:

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
# create a new custom validation attribute named "LogVariableAttribute":
class IdentifyTypeAttribute : System.Management.Automation.ValidateArgumentsAttribute
{


# this gets called whenever a new value is assigned to the variable:

[void]Validate([object]$value, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics)
{
# get the global variable that logs all changes:
[System.Management.Automation.PSVariable]$variable = Get-Variable "loggedTypes" -Scope global -ErrorAction Ignore
# if the variable exists and does not contain an ArrayList, delete it:
if ($variable -ne $null -and $variable.Value -isnot [System.Collections.ArrayList]) { $variable = $null }
# if the variable does not exist, set up an empty new ArrayList:
if ($variable -eq $null) { $variable = Set-Variable -Name "loggedTypes" -Value ([System.Collections.ArrayList]@()) -Scope global -PassThru }

[string]$line = (Get-PSCallStack)[-1].Position.Text
$pattern = '\$(\w{1,})'
$match = [regex]::Match($line, $pattern)
if ($match.success)
{

# log the type contained in the variable
$null = $variable.Value.Add([PSCustomObject]@{
# use the optional source name that can be defined by the attribute:
Value = $value.GetType()
Timestamp = Get-Date
# use the callstack to find out where the assignment took place:
Name = [regex]::Match($line, $pattern).Groups[1]
Position = [regex]::Match($line, $pattern).Groups[1].Index + (Get-PSCallStack)[-1].Position.StartOffset
Line = (Get-PSCallStack).ScriptLineNumber | Select-Object -Last 1
Path = (Get-PSCallStack).ScriptName | Select-Object -Last 1
})

}
}
}

现在,在您的脚本中,在开始时,通过添加新属性来初始化要跟踪的所有变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[IdentifyType()]$test = 1
[IdentifyType()]$x = 0

# start using the variables:
for ($x = 1000; $x -lt 3000; $x += 300)
{
"Frequency $x Hz"
[Console]::Beep($x, 500)
}

& {
$test = Get-Date
}


$test = "Hello"
Start-Sleep -Seconds 1
$test = 1,2,3

然后正常运行脚本。结果将记录到全局变量 $loggedTypes 中,通过它可以查看所有结果:

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
# looking at the log results:
$loggedTypes | Out-GridView



PS C:\> $loggedTypes


Value : System.Int32
Timestamp : 04.07.2022 09:42:46
Name : test
Position : 17
Line : 1
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:46
Name : x
Position : 44
Line : 2
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:46
Name : x
Position : 89
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:46
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:47
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:47
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:48
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:48
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:49
Name : x
Position : 113
Line : 5
Path :

Value : System.Int32
Timestamp : 04.07.2022 09:42:49
Name : x
Position : 113
Line : 5
Path :

Value : System.String
Timestamp : 04.07.2022 09:42:49
Name : test
Position : 215
Line : 16
Path :

Value : System.Object[]
Timestamp : 04.07.2022 09:42:50
Name : test
Position : 258
Line : 18
Path :

PowerShell 技能连载 - 将语言 ID 转为语言名称

在我们以前的迷你系列中,我们展示了使用不同的 PowerShell 方法来获取安装 OS 语言名称的几种方法。结果都是语言 ID 的列表,类似这样:

de-DE
en-US
fr-FR

如果我需要将它们转换为完整的国家名称怎么办?幸运的是,这只是数据类型的问题。我们演示的所有方法都可以以字符串的形式返回安装的语言包。让我们以 WMI 示例为例:

1
2
$os = Get-CIMInstance -ClassName Win32_OperatingSystem
$os.MUILanguages

当您将结果转换到更合适的数据类型时,将获得合适的数据。相对于字符串,让我们使用表示国家名称的数据类型:CultureInfo

1
2
$os = Get-CIMInstance -ClassName Win32_OperatingSystem
[CultureInfo[]]$os.MUILanguages

瞬间,相同的数据现在可以以更丰富的格式表示:

LCID             Name             DisplayName
----             ----             -----------
1031             de-DE            German (Germany)
1033             en-US            English (United States)
1036             fr-FR            French (France)

PowerShell 技能连载 - 确定语言包(第 3 部分)

在本系列的第二部分中,您已经看到了使用 WMI 与使用命令行工具(如 dism.exe)相比,使用 WMI 查询安装的操作系统语言列表要容易且快速得多。但是,WMI 仍然需要您知道适当的 WMI 类名称。

这就是为什么 PowerShell 全能的 Get-ComputerInfo 的原因。它为您查询各种与计算机相关的信息,然后由您决定需要哪个信息。我们也可以通过这种方法解决这个问题:

1
2
$a = Get-ComputerInfo
$a.OsMuiLanguages

不好的方面是,Get-ComputerInfo 总是查询完整的信息集,这使得执行起来很慢。不过总比没有好,甚至比 dism.exe 更好,但是第二部分的直接 WMI 查询仍然是最高效的方法。

PowerShell 技能连载 - 确定语言包(第 2 部分)

在本系列的第二部分中,我们希望通过使用内置的 PowerShell 功能来解决我们的难题 - 获得安装的语言包。在第一部分中,我们使用了可行的控制台应用程序 (dism.exe),但很复杂,需要管理员特权。

Windows 机器上的面向对象的方法通常是 WMI,您可以在其中查询描述所需信息的类,并在不做字符串转换的情况下获取信息。WMI 的困难部分是找到合适的类名。

这是我们的解决方案:

1
2
$os = Get-CIMInstance -ClassName Win32_OperatingSystem
$os.MUILanguages

当您使用 dism.exe 将其与我们第一部分的解决方案进行比较时,您会立即发现它的速度更快,更方便。但是,两种方法最后都返回相同的信息。

PowerShell 技能连载 - 确定语言包(第 1 部分)

假设您需要查找 Windows 计算机已安装的语言包。在这个三部分的系列中,我们使用 PowerShell 的功能来解决此问题。

在第一部分中,我们只是尝试通过寻找可以利用的原生非 PowerShell 命令来解决问题。

事实证明,命令 dism.exe 可以为您找到该信息。但是,与许多二进制控制台命令一样,结果是 (a)本地化的,(b)字符串数据,(c)通常需要管理员特权。

如果我们别无选择,作为管理员,您可以运行以下代码:

1
DISM.exe /Online /Get-Intl /English

接下来,您可以使用 PowerShell 功能过滤和解析信息,直到找到所需的内容为止。

例如,使用 Select-String 仅选择那些对您的任务有趣的行:

1
2
DISM.exe /Online /Get-Intl /English |
Select-String -SimpleMatch 'Installed language(s)'

结果看起来像这样:

Installed language(s): de-DE
Installed language(s): en-US
Installed language(s): fr-FR

接下来,使用 PowerShell 管道机制处理每个结果并提取您真正需要的信息:

1
2
3
DISM.exe /Online /Get-Intl /English |
Select-String -SimpleMatch 'Installed language(s)' |
ForEach-Object { $_.Line.Split(':')[-1].Trim() }

在这里,每次取出一行,然后由冒号分隔,然后取出最后一部分(右)部分,删除所有空格。这很麻烦,但最后能够获取原生字符串的数据的结果。

实际上,Select-String 支持正则表达方式。因此,如果您了解正则表达式,则可以立即查询所需的信息:

1
2
3
DISM.exe /Online /Get-Intl /English |
Select-String -Pattern 'Installed language\(s\):\s(.*?)$' |
ForEach-Object { $_.Matches[0].Groups[1].Value }

请注意,在代码示例中如何使用 -Pattern 参数并忽略 -SimplePattern 参数,从而告诉 Select-String 我们正在使用完整的正则表达式。现在,搜索模式使用 “\“ 来转义所有特殊字符,并定义一组括号来定义我们要寻找的值,位于搜索文本之后和行末的 “$“ 之后)。

然后,Select-String 在其属性 “Matches“ 中返回正则表达式匹配,因此我们可以从这里以面向对象的方式访问发现的匹配和组。我们正在寻找的信息是在第一个匹配项中(每行,index 为 0),在第二组中(索引号 0 代表整个匹配,索引号 1 是第一个括号内的匹配项)。

如果这个操作对你来说太低级了,请期待下一个技能!