PowerShell 技能连载 - Web 数据采集

适用于 PowerShell 7.0 及以上版本

在运维和数据分析工作中,经常需要从网页上采集数据——监控页面上的状态信息、采集竞争对手的价格数据、抓取内部系统的报表。这些场景看似简单,但手动操作既耗时又容易出错,尤其是当数据源多、更新频繁时,人工采集几乎无法持续。

PowerShell 通过 Invoke-WebRequest 和内置的 HTML 解析能力,可以快速构建轻量级的数据采集脚本。与 Python 的 Scrapy 或 BeautifulSoup 相比,PowerShell 方案无需额外安装解释器,直接在 Windows 或跨平台环境中即可运行,特别适合已经在使用 PowerShell 进行运维自动化的团队。

本文将从基础的 HTML 解析入手,逐步介绍表单提交与认证采集,最后实现批量并发采集与数据清洗的完整方案,帮助你构建可靠的数据采集管道。

HTML 解析与数据提取

Invoke-WebRequest 返回的对象中包含一个 ParsedHtml 属性,但跨平台场景下更推荐使用正则表达式或 HTML Agility Pack 来解析。下面演示如何从一个示例页面中提取表格数据。

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
# 从示例页面采集表格数据
$url = "https://example.com/status"
$response = Invoke-WebRequest -Uri $url -UseBasicParsing

# 方法一:使用正则表达式提取 HTML 表格中的数据
$pattern = '<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>\s*<td>(.*?)</td>\s*</tr>'
$matches = [regex]::Matches($response.Content, $pattern)

$results = foreach ($m in $matches) {
[PSCustomObject]@{
ServerName = $m.Groups[1].Value.Trim()
Status = $m.Groups[2].Value.Trim()
ResponseMs = $m.Groups[3].Value.Trim()
}
}

$results | Format-Table -AutoSize

# 方法二:使用 HTML Agility Pack 进行 CSS 选择器查询
# 先安装 HTML Agility Pack
Install-Module -Name HtmlAgilityPack -Force -Scope CurrentUser -ErrorAction SilentlyContinue
Add-Type -Path (Join-Path $env:USERPROFILE ".nuget\packages\htmlagilitypack\1.11.72\lib\netstandard2.0\HtmlAgilityPack.dll") -ErrorAction SilentlyContinue

# 使用 HtmlDocument 解析
$doc = [HtmlAgilityPack.HtmlDocument]::new()
$doc.LoadHtml($response.Content)

# 通过 XPath 精确定位
$nodes = $doc.DocumentNode.SelectNodes('//table[@class="status-table"]//tr[position()>1]')
foreach ($row in $nodes) {
$cells = $row.SelectNodes('.//td')
[PSCustomObject]@{
ServerName = $cells[0].InnerText.Trim()
Status = $cells[1].InnerText.Trim()
ResponseMs = $cells[2].InnerText.Trim()
}
}

执行结果示例:

1
2
3
4
5
6
7
ServerName Status   ResponseMs
---------- ------ ----------
WEB-01 Online 45
WEB-02 Online 38
DB-01 Online 120
DB-02 Offline N/A
CACHE-01 Online 12

表单提交与认证采集

很多数据藏在需要登录的系统后面。PowerShell 的 WebRequestSession 对象可以自动管理 Cookie,模拟完整的登录流程后访问受保护的页面。

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
# 创建会话变量,用于保持登录状态
$loginUrl = "https://internal.example.com/login"
$reportUrl = "https://internal.example.com/report/daily"

# 第一步:获取登录页面(同时初始化 Session)
$loginPage = Invoke-WebRequest -Uri $loginUrl -SessionVariable session -UseBasicParsing

# 第二步:提取隐藏的表单字段(如 CSRF Token)
$csrfPattern = 'name="__RequestVerificationToken".*?value="([^"]+)"'
$csrfMatch = [regex]::Match($loginPage.Content, $csrfPattern)
$csrfToken = if ($csrfMatch.Success) { $csrfMatch.Groups[1].Value } else { "" }

# 第三步:提交登录表单
$loginBody = @{
username = "admin"
password = "P@ssw0rd123!"
__RequestVerificationToken = $csrfToken
RememberMe = "true"
}

$loginResponse = Invoke-WebRequest -Uri $loginUrl -Method POST -Body $loginBody -WebSession $session -UseBasicParsing

# 第四步:使用已认证的会话访问报表页面
$reportResponse = Invoke-WebRequest -Uri $reportUrl -WebSession $session -UseBasicParsing

# 第五步:从报表页面提取数据
$dataPattern = '<tr[^>]*>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d,.]+)</td>\s*<td>([\d,.]+)</td>\s*</tr>'
$dataMatches = [regex]::Matches($reportResponse.Content, $dataPattern)

$reportData = foreach ($m in $dataMatches) {
[PSCustomObject]@{
Date = $m.Groups[1].Value
Revenue = $m.Groups[2].Value
Cost = $m.Groups[3].Value
}
}

$reportData | Format-Table -AutoSize
Write-Host "`n采集完成,共获取 $($reportData.Count) 条记录"

执行结果示例:

1
2
3
4
5
6
7
8
9
Date       Revenue       Cost
---- ------- ----
2026-01-30 125,680.00 89,340.00
2026-01-31 132,450.00 91,200.00
2026-02-01 118,930.00 87,650.00
2026-02-02 141,200.00 93,100.00
2026-02-03 156,780.00 95,420.00

采集完成,共获取 5 条记录

批量采集与数据清洗

当需要从多个页面或多个数据源采集数据时,可以利用 PowerShell 的 ForEach-Object -Parallel 实现并发采集,然后对原始数据进行清洗和标准化处理,最后导出为结构化格式。

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
# 定义采集目标列表
$targets = @(
@{ Name = "北京"; Url = "https://api.example.com/weather/beijing" }
@{ Name = "上海"; Url = "https://api.example.com/weather/shanghai" }
@{ Name = "广州"; Url = "https://api.example.com/weather/guangzhou" }
@{ Name = "深圳"; Url = "https://api.example.com/weather/shenzhen" }
@{ Name = "成都"; Url = "https://api.example.com/weather/chengdu" }
)

# 并发采集数据(限制并发数为 3)
$rawData = $targets | ForEach-Object -ThrottleLimit 3 -Parallel {
$target = $_
try {
$response = Invoke-WebRequest -Uri $target.Url -UseBasicParsing -TimeoutSec 10
# 解析 JSON 响应
$json = $response.Content | ConvertFrom-Json
[PSCustomObject]@{
City = $target.Name
Temperature = $json.temperature
Humidity = $json.humidity
Wind = $json.wind
AQI = $json.aqi
Status = "Success"
ErrorMsg = ""
}
}
catch {
[PSCustomObject]@{
City = $target.Name
Temperature = $null
Humidity = $null
Wind = $null
AQI = $null
Status = "Failed"
ErrorMsg = $_.Exception.Message
}
}
}

# 数据清洗:过滤失败记录,标准化数值
$cleanData = $rawData | Where-Object { $_.Status -eq "Success" } | ForEach-Object {
# 温度转摄氏度(假设原始数据为华氏度)
$tempC = [math]::Round(($_.Temperature - 32) * 5 / 9, 1)
# 湿度取整
$humidity = [int]$_.Humidity

[PSCustomObject]@{
City = $_.City
TemperatureC = $tempC
HumidityPct = $humidity
WindSpeed = $_.Wind
AQI = [int]$_.AQI
AQILevel = switch ([int]$_.AQI) {
{ $_ -le 50 } { "优" }
{ $_ -le 100 } { "良" }
{ $_ -le 150 } { "轻度污染" }
{ $_ -le 200 } { "中度污染" }
default { "重度污染" }
}
CollectTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}

# 显示结果
$cleanData | Format-Table -AutoSize

# 导出到 CSV
$csvPath = Join-Path $env:USERPROFILE "Desktop\weather_data.csv"
$cleanData | Export-Csv -Path $csvPath -NoTypeInformation -Encoding Utf8
Write-Host "CSV 已导出至: $csvPath"

# 导出到 JSON
$jsonPath = Join-Path $env:USERPROFILE "Desktop\weather_data.json"
$cleanData | ConvertTo-Json -Depth 3 | Set-Content -Path $jsonPath -Encoding Utf8
Write-Host "JSON 已导出至: $jsonPath"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
City  TemperatureC HumidityPct WindSpeed AQI AQILevel CollectTime
---- ------------ ----------- --------- --- -------- -----------
北京 2.3 45 3.2m/s 82 良 2026-02-05 08:15:32
上海 6.8 62 4.1m/s 68 良 2026-02-05 08:15:33
广州 15.2 78 2.8m/s 55 良 2026-02-05 08:15:33
深圳 17.6 75 3.0m/s 48 优 2026-02-05 08:15:33
成都 8.1 70 1.5m/s 92 良 2026-02-05 08:15:34

CSV 已导出至: C:\Users\admin\Desktop\weather_data.csv
JSON 已导出至: C:\Users\admin\Desktop\weather_data.json

注意事项

  1. 遵守 robots.txt 和法律合规:采集前检查目标网站的 robots.txt 文件,尊重网站的爬取规则。对敏感数据或受版权保护的内容,务必获得授权后再采集,避免触犯法律。

  2. 请求频率控制:高频率请求可能对目标服务器造成压力,甚至触发 IP 封禁。建议在循环中加入 Start-Sleep -Milliseconds 500 等延迟,并发采集时将 ThrottleLimit 控制在 3-5 之间。

  3. 异常处理与重试机制:网络请求天然不稳定,务必使用 try/catch 包裹所有请求代码,并实现指数退避重试策略,确保脚本在偶发网络故障时不会中断。

  4. User-Agent 伪装:部分网站会拒绝默认的 PowerShell User-Agent。可以通过 -Headers @{ 'User-Agent' = 'Mozilla/5.0 ...' } 设置合理的浏览器标识,但这并不意味着可以绕过反爬机制去做不当采集。

  5. 编码与字符集处理:中文网页常见 GBK、GB2312 等编码,PowerShell 默认使用 UTF-8。采集后需要用 [System.Text.Encoding]::GetEncoding('GBK').GetString(...) 进行转码,否则会出现乱码。

  6. 数据验证与清洗:从网页提取的原始数据通常包含空白字符、HTML 实体(如 &amp;&nbsp;)和格式不一致的数值。建议封装一个 Invoke-DataClean 函数统一处理这些常见问题,确保导出数据的规范性。

PowerShell 技能连载 - 年终报告自动生成

适用于 PowerShell 5.1 及以上版本

每到年底,IT 部门都需要向管理层提交各类运维报告:服务器可用性统计、安全事件汇总、资源使用趋势分析、成本核算等。这些数据往往分散在事件日志、CSV 文件、数据库、REST API 等多个来源中,手动汇总整理费时费力,而且容易出现遗漏和统计口径不一致的问题。

借助 PowerShell 强大的数据采集与处理能力,我们可以编写脚本自动从多个数据源采集原始数据,按照预定义的维度进行聚合统计,再结合 HTML 模板引擎生成包含表格、图表和趋势线的高质量报告。整个过程无需手动干预,一条命令即可产出一份数据完整、格式专业的年终总结。

本文将围绕多源数据采集与聚合、HTML 报告渲染(含内联图表)、以及报告分发与归档三个核心环节,展示如何用 PowerShell 构建一套可复用的年终报告自动生成流水线。

多源数据采集与聚合

年终报告的第一步是从多个数据源采集原始数据并进行统一聚合。下面的脚本演示如何从 Windows 事件日志、CSV 日志文件和自定义数据结构中提取关键指标,生成结构化的年度汇总数据集。

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
function Get-YearEndReportData {
<#
.SYNOPSIS
从多个数据源采集并聚合年终报告所需的数据
.PARAMETER Year
统计年份,默认为当前年份
#>
param(
[int]$Year = (Get-Date).Year
)

$startDate = Get-Date -Year $Year -Month 1 -Day 1
$endDate = Get-Date -Year $Year -Month 12 -Day 31

# ---- 1. 从 Windows 事件日志采集安全事件统计 ----
$securityEvents = @()
try {
$logonEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4624, 4625
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$logonSuccess = ($logonEvents | Where-Object Id -eq 4624 | Measure-Object).Count
$logonFailed = ($logonEvents | Where-Object Id -eq 4625 | Measure-Object).Count

$securityEvents = [PSCustomObject]@{
成功登录 = $logonSuccess
失败登录 = $logonFailed
登录失败率 = if ($logonSuccess + $logonFailed -gt 0) {
[math]::Round($logonFailed / ($logonSuccess + $logonFailed) * 100, 2)
} else { 0 }
}
} catch {
$securityEvents = [PSCustomObject]@{
成功登录 = 0; 失败登录 = 0; 登录失败率 = 0
}
}

# ---- 2. 从系统事件日志采集可用性数据 ----
$rebootEvents = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Id = 1074, 6008
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$plannedReboots = ($rebootEvents | Where-Object Id -eq 1074 | Measure-Object).Count
$unexpectedShutdowns = ($rebootEvents | Where-Object Id -eq 6008 | Measure-Object).Count

# ---- 3. 从 CSV 日志文件采集资源使用数据 ----
$csvPath = ".\server-metrics-$Year.csv"
$monthlyStats = @()

if (Test-Path $csvPath) {
$metrics = Import-Csv -Path $csvPath -Encoding UTF8
$monthlyStats = $metrics | Group-Object { $_.Date.Substring(0, 7) } | ForEach-Object {
$avgCpu = [math]::Round(($_.Group | Measure-Object -Property CPU -Average).Average, 1)
$avgMem = [math]::Round(($_.Group | Measure-Object -Property Memory -Average).Average, 1)
$maxDisk = [math]::Round(($_.Group | Measure-Object -Property DiskUsage -Maximum).Maximum, 1)

[PSCustomObject]@{
月份 = $_.Name
平均CPU使用率 = "$avgCpu%"
平均内存使用率 = "$avgMem%"
最大磁盘使用率 = "$maxDisk%"
}
}
} else {
# 模拟数据用于演示
1..12 | ForEach-Object {
$month = "{0:D2}" -f $_
$monthlyStats += [PSCustomObject]@{
月份 = "$Year-$month"
平均CPU使用率 = "$([math]::Round((Get-Random -Min 25 -Max 75) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
平均内存使用率 = "$([math]::Round((Get-Random -Min 40 -Max 85) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
最大磁盘使用率 = "$([math]::Round((Get-Random -Min 50 -Max 90) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
}
}
}

# ---- 4. 汇总输出 ----
[PSCustomObject]@{
报告年份 = $Year
数据周期 = "$($startDate.ToString('yyyy-MM-dd')) 至 $($endDate.ToString('yyyy-MM-dd'))"
安全事件统计 = $securityEvents
计划重启次数 = $plannedReboots
非计划关机次数 = $unexpectedShutdowns
月度资源统计 = $monthlyStats
生成时间 = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# 采集 2025 年度报告数据
$reportData = Get-YearEndReportData -Year 2025

Write-Host "报告年份: $($reportData.报告年份)" -ForegroundColor Cyan
Write-Host "数据周期: $($reportData.数据周期)" -ForegroundColor Cyan
Write-Host "安全事件: 成功登录 $($reportData.安全事件统计.成功登录) 次, 失败登录 $($reportData.安全事件统计.失败登录) 次" -ForegroundColor Yellow
Write-Host "月度数据条数: $($reportData.月度资源统计.Count)" -ForegroundColor Green

执行结果示例:

1
2
3
4
报告年份: 2025
数据周期: 2025-01-01 至 2025-12-31
安全事件: 成功登录 15238 次, 失败登录 347 次
月度数据条数: 12

生成 HTML 报告(含 CSS 样式与内联图表)

数据采集完成后,下一步是将结构化数据渲染为美观的 HTML 报告。下面的脚本使用 PowerShell 的 here-string 构建 HTML 模板,内嵌 CSS 样式,并通过纯 CSS 的柱状图直观展示月度趋势数据。

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
function New-YearEndHtmlReport {
<#
.SYNOPSIS
将年度报告数据渲染为带样式和内联图表的 HTML 文件
.PARAMETER ReportData
由 Get-YearEndReportData 返回的数据对象
.PARAMETER OutputPath
HTML 报告输出路径
#>
param(
[Parameter(Mandatory)]
$ReportData,
[string]$OutputPath = ".\YearEnd-Report-$($ReportData.报告年份).html"
)

# 构建月度资源表格行
$tableRows = $ReportData.月度资源统计 | ForEach-Object {
" <tr><td>$($_.月份)</td><td>$($_.平均CPU使用率)</td><td>$($_.平均内存使用率)</td><td>$($_.最大磁盘使用率)</td></tr>"
}
$tableRowsHtml = $tableRows -join "`n"

# 构建简易柱状图(纯 CSS 实现,无需 JavaScript 库)
$chartBars = $ReportData.月度资源统计 | ForEach-Object {
$cpuVal = [int]($_.平均CPU使用率 -replace '%', '')
$barHeight = $cpuVal * 2
$label = $_.月份.Substring(5)
" <div class='bar-col'><div class='bar' style='height:${barHeight}px'></div><div class='bar-label'>$label</div></div>"
}
$chartHtml = $chartBars -join "`n"

$sec = $ReportData.安全事件统计

# HTML 报告内容
$htmlContent = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>IT 运维年度报告 - $($ReportData.报告年份)</title>
<style>
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; margin: 40px; background: #f5f7fa; color: #333; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2c3e50; margin-top: 30px; }
.summary-box { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 200px; }
.stat-card h3 { margin: 0 0 10px 0; color: #7f8c8d; font-size: 14px; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #2c3e50; }
table { border-collapse: collapse; width: 100%; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; }
th { background: #3498db; color: white; padding: 12px; text-align: left; }
td { padding: 10px 12px; border-bottom: 1px solid #ecf0f1; }
tr:hover { background: #ebf5fb; }
.chart-container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 20px 0; }
.chart-area { display: flex; align-items: flex-end; gap: 8px; height: 200px; padding: 10px 0; }
.bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; }
.bar { background: linear-gradient(to top, #3498db, #2ecc71); border-radius: 4px 4px 0 0; width: 100%; max-width: 40px; }
.bar-label { font-size: 11px; margin-top: 5px; color: #7f8c8d; }
.footer { margin-top: 30px; color: #95a5a6; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<h1>IT 运维年度报告 - $($ReportData.报告年份)</h1>
<p>报告周期:$($ReportData.数据周期) &nbsp;|&nbsp; 生成时间:$($ReportData.生成时间)</p>

<h2>核心指标概览</h2>
<div class="summary-box">
<div class="stat-card"><h3>成功登录</h3><div class="value">$($sec.成功登录)</div></div>
<div class="stat-card"><h3>失败登录</h3><div class="value" style="color:#e74c3c">$($sec.失败登录)</div></div>
<div class="stat-card"><h3>登录失败率</h3><div class="value">$($sec.登录失败率)%</div></div>
<div class="stat-card"><h3>计划重启</h3><div class="value">$($ReportData.计划重启次数)</div></div>
<div class="stat-card"><h3>非计划关机</h3><div class="value" style="color:#e67e22">$($ReportData.非计划关机次数)</div></div>
</div>

<h2>月度 CPU 使用趋势</h2>
<div class="chart-container">
<div class="chart-area">
$chartHtml
</div>
</div>

<h2>月度资源使用明细</h2>
<table>
<thead><tr><th>月份</th><th>平均 CPU 使用率</th><th>平均内存使用率</th><th>最大磁盘使用率</th></tr></thead>
<tbody>
$tableRowsHtml
</tbody>
</table>

<div class="footer">本报告由 PowerShell 自动生成 | $($ReportData.生成时间)</div>
</body>
</html>
"@

$htmlContent | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "HTML 报告已生成: $OutputPath" -ForegroundColor Green
return (Resolve-Path $OutputPath).Path
}

# 生成 HTML 报告
$reportPath = New-YearEndHtmlReport -ReportData $reportData

执行结果示例:

1
HTML 报告已生成: /Users/user/reports/YearEnd-Report-2025.html

生成的 HTML 文件包含卡片式核心指标概览、纯 CSS 柱状图展示月度 CPU 趋势、以及带悬停效果的资源使用明细表格。整个报告无需任何 JavaScript 依赖,打开即可查看完整内容,也可通过浏览器”另存为 PDF”直接导出。

报告分发与归档

报告生成后,还需要将其自动发送给相关干系人并归档保存。下面的脚本实现了邮件分发、PDF 转换和按月份归档的完整流程。

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
function Send-YearEndReport {
<#
.SYNOPSIS
通过邮件发送年终报告并归档到指定目录
.PARAMETER ReportPath
HTML 报告文件路径
.PARAMETER Recipients
收件人邮箱地址数组
.PARAMETER ArchiveBasePath
归档根目录路径
#>
param(
[Parameter(Mandatory)]
[string]$ReportPath,
[string[]]$Recipients = @("manager@company.com", "it-ops@company.com"),
[string]$ArchiveBasePath = ".\Reports\Archive"
)

# ---- 1. 创建归档目录结构 ----
$year = (Get-Date).Year
$archiveDir = Join-Path $ArchiveBasePath "$year"
if (-not (Test-Path $archiveDir)) {
New-Item -Path $archiveDir -ItemType Directory -Force | Out-Null
Write-Host "已创建归档目录: $archiveDir" -ForegroundColor Cyan
}

# 复制报告到归档目录(带时间戳)
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archiveName = "YearEnd-Report-$year-$timestamp.html"
$archivePath = Join-Path $archiveDir $archiveName
Copy-Item -Path $ReportPath -Destination $archivePath
Write-Host "报告已归档: $archivePath" -ForegroundColor Green

# ---- 2. 生成 PDF 副本(利用 Chrome 无头模式) ----
$pdfPath = $archivePath -replace '\.html$', '.pdf'
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe"
)
$chrome = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1

if ($chrome) {
$pdfArgs = @(
"--headless",
"--disable-gpu",
"--no-sandbox",
"--print-to-pdf=$pdfPath",
"--print-to-pdf-no-header",
$archivePath
)
Start-Process -FilePath $chrome -ArgumentList $pdfArgs -Wait -NoNewWindow
Write-Host "PDF 已生成: $pdfPath" -ForegroundColor Green
} else {
Write-Host "未找到 Chrome,跳过 PDF 生成。可手动在浏览器中另存为 PDF。" -ForegroundColor Yellow
$pdfPath = $null
}

# ---- 3. 发送邮件 ----
$mailParams = @{
From = "it-automation@company.com"
To = $Recipients
Subject = "[IT 运维] $year 年度报告"
Body = "各位好,`n`n附件为 $year 年度 IT 运维报告。`n`n报告由系统自动生成,如有疑问请联系 IT 运维团队。`n`n祝好"
SmtpServer = "smtp.company.com"
Port = 587
Encoding = [System.Text.Encoding]::UTF8
}

# 添加 HTML 报告作为附件
$mailParams["Attachments"] = @($archivePath)
if ($pdfPath -and (Test-Path $pdfPath)) {
$mailParams["Attachments"] += $pdfPath
}

try {
Send-MailMessage @mailParams -ErrorAction Stop
Write-Host "邮件已发送至: $($Recipients -join ', ')" -ForegroundColor Green
} catch {
Write-Host "邮件发送失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "请检查 SMTP 配置或网络连接" -ForegroundColor Yellow
}

# ---- 4. 生成归档索引 ----
$indexEntry = [PSCustomObject]@{
报告名称 = $archiveName
生成时间 = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
文件大小KB = [math]::Round((Get-Item $archivePath).Length / 1KB, 1)
归档路径 = $archivePath
PDF路径 = $pdfPath
}

$indexPath = Join-Path $archiveDir "index.csv"
if (Test-Path $indexPath) {
$indexEntry | Export-Csv -Path $indexPath -Append -NoTypeInformation -Encoding UTF8
} else {
$indexEntry | Export-Csv -Path $indexPath -NoTypeInformation -Encoding UTF8
}
Write-Host "归档索引已更新: $indexPath" -ForegroundColor Green

return $indexEntry
}

# 执行完整的分发与归档流程
$result = Send-YearEndReport -ReportPath $reportPath `
-Recipients @("cto@company.com", "it-manager@company.com") `
-ArchiveBasePath ".\Reports\Archive"

$result | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建归档目录: .\Reports\Archive\2025
报告已归档: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF 已生成: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf
邮件已发送至: cto@company.com, it-manager@company.com
归档索引已更新: .\Reports\Archive\2025\index.csv

报告名称 : YearEnd-Report-2025-20251229-143022.html
生成时间 : 2025-12-29 14:30:22
文件大小KB : 4.2
归档路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf

注意事项

  1. 事件日志权限:读取 Windows 安全日志(Security Log)需要管理员权限。在生产环境中运行采集脚本时,应以提升权限启动 PowerShell,或者通过计划任务配置为以 SYSTEM 账户运行,确保能正常读取所有事件源。

  2. 大数据量性能:如果事件日志条目超过数十万条,直接使用 Get-WinEvent 可能占用大量内存。建议按月份分批查询,或使用 -MaxEvents 参数配合 -FilterHashtable 的时间范围分段采集,避免一次性加载过多数据导致内存溢出。

  3. CSV 数据格式约定:资源指标 CSV 文件应包含 DateCPUMemoryDiskUsage 等标准列名。如果监控系统导出的字段名不同,需在采集函数中添加字段映射逻辑,或使用 Select-Object 的计算属性进行重命名。

  4. SMTP 认证配置:示例中的 Send-MailMessage 使用了简化的参数。在生产环境中,SMTP 服务器通常需要身份认证和 TLS 加密。建议将凭据存储在 Windows 凭据管理器中,通过 Get-StoredCredential 或环境变量获取,不要在脚本中硬编码密码。

  5. Chrome 无头模式依赖:PDF 转换功能依赖 Chrome 或 Chromium 浏览器。在无 GUI 的 Windows Server 上,需要安装 Chrome 并确保无头模式可以正常运行。如果无法安装 Chrome,也可以考虑使用 wkhtmltopdf 等轻量级替代方案,或者直接使用 ConvertTo-PDF 模块。

  6. 报告模板维护:HTML 报告模板中的样式和结构应与企业管理规范保持一致。建议将模板抽取为独立的 HTML 文件,通过 PowerShell 的 -replace 操作符替换占位符,而非在脚本中硬编码整个模板。这样设计人员可以独立调整样式,开发人员只需关注数据填充逻辑。

PowerShell 技能连载 - HTML 报告生成

适用于 PowerShell 5.1 及以上版本

运维报告是沟通技术状态的桥梁——无论是每日健康检查报告、月度容量报告还是安全审计报告,格式清晰、内容直观的 HTML 报告远比纯文本或截图有效。PowerShell 的 ConvertTo-Html 命令可以将任何对象集合转为 HTML 表格,结合自定义 CSS 样式和 Here-String 模板,可以快速生成专业级的报告。

本文将讲解 HTML 报告的生成技巧、样式定制、邮件发送,以及实用的报告模板。

基础 HTML 报告

1
2
3
4
5
6
7
8
9
10
# 使用 ConvertTo-Html 生成简单报告
$processes = Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10

$htmlReport = $processes | Select-Object Name, Id,
@{N='内存MB'; E={[math]::Round($_.WorkingSet64/1MB, 1)}},
@{N='CPU(s)'; E={[math]::Round($_.CPU, 2)}} |
ConvertTo-Html -Title "进程报告" -Property Name, Id, '内存MB', 'CPU(s)'

$htmlReport | Set-Content "C:\Reports\processes.html" -Encoding UTF8
Write-Host "报告已生成" -ForegroundColor Green

执行结果示例:

1
报告已生成

带样式的完整报告

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
function New-DailyHealthReport {
<#
.SYNOPSIS
生成每日系统健康 HTML 报告
#>
param(
[string]$OutputPath = "C:\Reports\daily-health.html"
)

# 采集数据
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3"
$topProcesses = Get-Process | Sort-Object WorkingSet64 -Descending |
Select-Object -First 10
$services = Get-Service | Where-Object {
$_.StartType -eq 'Automatic' -and $_.Status -ne 'Running'
}

# CSS 样式
$css = @"
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 20px; background: #f5f6fa; color: #2d3436; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #2d3436; border-bottom: 3px solid #0984e3; padding-bottom: 10px; }
h2 { color: #636e72; margin-top: 30px; border-left: 4px solid #0984e3; padding-left: 10px; }
.summary { display: flex; flex-wrap: wrap; gap: 15px; margin: 20px 0; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 200px; flex: 1; }
.card .label { font-size: 12px; color: #b2bec3; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: bold; color: #0984e3; margin-top: 5px; }
.card.warning .value { color: #fdcb6e; }
.card.danger .value { color: #d63031; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 10px 0; }
th { background: #0984e3; color: white; padding: 12px 15px; text-align: left; font-size: 13px; }
td { padding: 10px 15px; border-bottom: 1px solid #dfe6e9; }
tr:hover { background: #f0f8ff; }
.bar { height: 8px; background: #dfe6e9; border-radius: 4px; overflow: hidden; min-width: 80px; }
.bar-fill { height: 100%; border-radius: 4px; }
.good { background: #00b894; }
.warning { background: #fdcb6e; }
.danger { background: #d63031; }
.footer { text-align: center; color: #b2bec3; margin-top: 30px; font-size: 12px; }
.status-badge { padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; }
.status-ok { background: #e8f5e9; color: #27ae60; }
.status-error { background: #ffebee; color: #c0392b; }
</style>
"@

# 构建报告
$report = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统健康报告 - $($os.CSName)</title>
$css
</head>
<body>
<div class="container">
<h1>系统健康报告</h1>
<p>计算机:<strong>$($os.CSName)</strong> | 操作系统:$($os.Caption) |
报告时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>

<div class="summary">
<div class="card">
<div class="label">CPU 使用率</div>
<div class="value">$($cpu.LoadPercentage)%</div>
</div>
<div class="card">
<div class="label">内存使用率</div>
<div class="value">$([math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 1))%</div>
</div>
<div class="card">
<div class="label">运行时间</div>
<div class="value">$([math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1)) 天</div>
</div>
<div class="card">
<div class="label">异常服务</div>
<div class="value">$($services.Count)</div>
</div>
</div>

<h2>磁盘状态</h2>
<table>
<tr><th>驱动器</th><th>文件系统</th><th>总容量</th><th>已使用</th><th>可用</th><th>使用率</th></tr>
"@

foreach ($disk in $disks) {
$totalGB = [math]::Round($disk.Size / 1GB, 2)
$freeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
$usedGB = [math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 2)
$usedPct = [math]::Round(($disk.Size - $disk.FreeSpace) / $disk.Size * 100, 1)
$barClass = if ($usedPct -gt 90) { 'danger' } elseif ($usedPct -gt 70) { 'warning' } else { 'good' }

$report += @"
<tr>
<td>$($disk.DeviceID)</td>
<td>$($disk.FileSystem)</td>
<td>${totalGB} GB</td>
<td>${usedGB} GB</td>
<td>${freeGB} GB</td>
<td>
<div class="bar"><div class="bar-fill $barClass" style="width:$usedPct%"></div></div>
$usedPct%
</td>
</tr>
"@
}

$report += @"
</table>

<h2>内存占用 Top 10</h2>
<table>
<tr><th>进程名</th><th>PID</th><th>内存 (MB)</th><th>CPU (s)</th></tr>
"@

foreach ($proc in $topProcesses) {
$memMB = [math]::Round($proc.WorkingSet64 / 1MB, 1)
$cpuS = [math]::Round($proc.CPU, 2)
$report += " <tr><td>$($proc.Name)</td><td>$($proc.Id)</td><td>$memMB</td><td>$cpuS</td></tr>`n"
}

$report += @"
</table>
"@

if ($services) {
$report += @"
<h2>异常自动启动服务</h2>
<table>
<tr><th>服务名</th><th>显示名</th><th>状态</th></tr>
"@
foreach ($svc in $services) {
$report += " <tr><td>$($svc.Name)</td><td>$($svc.DisplayName)</td><td><span class=`"status-badge status-error`">$($svc.Status)</span></td></tr>`n"
}
$report += " </table>`n"
}

$report += @"
<div class="footer">
报告由 PowerShell 自动生成 | $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
</div>
</div>
</body>
</html>
"@

$report | Set-Content $OutputPath -Encoding UTF8
Write-Host "健康报告已生成:$OutputPath" -ForegroundColor Green
}

New-DailyHealthReport

执行结果示例:

1
健康报告已生成:C:\Reports\daily-health.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function Send-HtmlReport {
param(
[Parameter(Mandatory)]
[string]$ReportPath,

[Parameter(Mandatory)]
[string[]]$To,

[string]$Subject = "PowerShell 自动报告 - $(Get-Date -Format 'yyyy-MM-dd')",

[string]$SmtpServer = "mail.contoso.com",

[int]$Port = 587,

[System.Management.Automation.PSCredential]$Credential
)

$body = Get-Content $ReportPath -Raw

$params = @{
From = "reports@contoso.com"
To = $To
Subject = $Subject
Body = $body
BodyAsHtml = $true
SmtpServer = $SmtpServer
Port = $Port
Encoding = [System.Text.Encoding]::UTF8
}

if ($Credential) {
$params.Credential = $Credential
}

Send-MailMessage @params
Write-Host "报告已发送给:$($To -join ', ')" -ForegroundColor Green
}

# 发送报告邮件
$cred = Get-Credential -Message "输入邮件发送账户凭据"
Send-HtmlReport -ReportPath "C:\Reports\daily-health.html" `
-To @("admin@contoso.com", "team@contoso.com") `
-Subject "每日系统健康报告 - $(Get-Date -Format 'yyyy-MM-dd')" `
-Credential $cred

执行结果示例:

1
报告已发送给:admin@contoso.com, team@contoso.com

注意事项

  1. UTF-8 编码:始终使用 UTF-8 编码保存 HTML 文件,并在 <head> 中声明 <meta charset="UTF-8">
  2. 邮件客户端兼容:邮件中的 CSS 支持有限,避免使用 Flexbox、Grid 等现代布局,使用表格布局更安全
  3. 内联 CSS:邮件中的 CSS 最好内联写在元素上,而不是放在 <style> 标签中
  4. 图片嵌入:HTML 报告中的图片可以使用 Base64 内嵌或 CID 附件方式
  5. 报告大小:控制报告文件大小,大量数据应分页或限制展示条数
  6. 安全邮件:生产环境中使用 TLS 加密发送邮件,凭据应存储在密钥库中