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
<#
  22 所上位机文件侧只读探查:工步方案(含可能的工步名称)在不在软件目录的文件里。
 
  为什么走文件侧:该机 SQL Server 的 5141 个用户库**全部**是 DB_<时间> 测试库、其他库 0 个,
  所以工步方案不可能在库里。上一轮现场已在运行目录见到 VariableMap.xml、UnitConvert.xml、
  DeviceConfiguration.xml、TaskModel.xml 等文件,另有遗留的 .sto 工步方案(gzip 压缩的 XML)。
 
  只读纪律:只列目录、只读文件内容;不写、不改厂商目录与文件;报告只写到本脚本所在目录。
 
  用法(在那台机器上):
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1 -Roots 'D:\22所5v10A测试柜'
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1 -SearchRoot 'D:\' -MaxDepth 4
#>
param(
    # 要探查的目录;留空则自动找(见下)。
    [string[]]$Roots = @(),
    [string]$SearchRoot = 'D:\',
    [int]$MaxDepth = 3,
    # 最多深入解析几个方案文件(只打印结构,不倒内容)。
    [int]$MaxFiles = 3,
    [int]$MaxHeadChars = 400,
    [int]$MaxNames = 25,
    [string]$ReportFile = ''
)
 
$ErrorActionPreference = 'Stop'
 
if (-not $ReportFile) {
    $base = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
    $ReportFile = Join-Path $base 'j22-introspect-files-report.txt'
}
$report = New-Object System.Collections.ArrayList
function Say([string]$text) {
    Write-Host $text
    [void]$report.Add($text)
}
 
# 方案文件的判据:.sto 一定是方案;名字里带 step/plan/task/recipe/scheme/program/工步/方案的也算。
# 其余文件(设备配置、单位换算表等)不看——它们存不下工步名称。
function Test-PlanFile([string]$name) {
    $extension = [System.IO.Path]::GetExtension($name)
    if ($extension -ieq '.sto') { return $true }
    return $name -imatch '(step|plan|task|recipe|scheme|program|工步|方案)'
}
 
# 读文件文本:gzip(.sto 是 gzip 压缩的 XML)自动解压;只读前若干字符,避免把大文件全读进来。
# 编码:有 BOM 按 BOM,没有 BOM 按 GBK(Windows 上的国产上位机多半是 GBK),否则中文名称会读成乱码。
function Read-PlanText([string]$path, [int]$maxChars = 200000) {
    $stream = [System.IO.File]::OpenRead($path)
    try {
        $head = New-Object byte[] 2
        $read = $stream.Read($head, 0, 2)
        $stream.Position = 0
        $maxBytes = [Math]::Max(4096, $maxChars * 3)
        if ($read -eq 2 -and $head[0] -eq 0x1F -and $head[1] -eq 0x8B) {
            $gzip = New-Object System.IO.Compression.GZipStream($stream, [System.IO.Compression.CompressionMode]::Decompress)
            try { $bytes = Read-Bytes $gzip $maxBytes }
            finally { $gzip.Dispose() }
        }
        else { $bytes = Read-Bytes $stream $maxBytes }
        return Convert-PlanBytes $bytes
    }
    finally { $stream.Dispose() }
}
 
# 最多读 $maxBytes 个字节;读到多少算多少(文件可能比上限大,也可能很小)。非文本文件也照读,
# 由调用方按"开头是不是 <"判断它是不是 XML,不在这里猜。
function Read-Bytes($stream, [int]$maxBytes) {
    $buffer = New-Object byte[] 8192
    $memory = New-Object System.IO.MemoryStream
    try {
        while ($memory.Length -lt $maxBytes) {
            $wanted = [Math]::Min($buffer.Length, $maxBytes - $memory.Length)
            $count = $stream.Read($buffer, 0, $wanted)
            if ($count -le 0) { break }
            $memory.Write($buffer, 0, $count)
        }
        return $memory.ToArray()
    }
    finally { $memory.Dispose() }
}
 
# 编码判定:先看 BOM;再看 XML 声明里的 encoding;都没有按 GBK 兜底——国产上位机在中文 Windows 上
# 多半写 GBK,按 UTF-8 硬解会把中文工步名称读成乱码(.sto 是 gzip,流不可回退,所以必须先读再判)。
function Convert-PlanBytes([byte[]]$bytes) {
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
    }
    if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
    }
    $asciiHead = [System.Text.Encoding]::ASCII.GetString($bytes, 0, [Math]::Min(200, $bytes.Length))
    if ($asciiHead -match '(?i)encoding\s*=\s*"([\w\-]+)"') {
        try { return [System.Text.Encoding]::GetEncoding($Matches[1]).GetString($bytes) }
        catch { }
    }
    return [System.Text.Encoding]::GetEncoding(936).GetString($bytes)
}
 
# XML 里出现过哪些元素名:只看结构,不打印内容。判断"有没有名称字段"靠这个。
function Get-ElementNames([string]$text) {
    $names = New-Object System.Collections.ArrayList
    foreach ($match in [regex]::Matches($text, '<\s*([A-Za-z_][\w.\-]*)')) {
        $name = [string]$match.Groups[1].Value
        if (-not $names.Contains($name)) { [void]$names.Add($name) }
    }
    return , $names
}
 
# 名称类元素或属性及其取值:这是"方案里到底有没有工步名称"的直接证据。
# 先取名字再筛关键字——不能把关键字塞进正则的主体里:属性名就叫 Name 时关键字前面没有字符,
# 那种写法一条也匹配不到(演练里踩过)。
function Get-NameEvidence([string]$text) {
    $evidence = New-Object System.Collections.ArrayList
    $keyword = '(?i)(name|desc|title|remark|说明|名称|备注)'
    foreach ($match in [regex]::Matches($text, '<\s*([A-Za-z_][\w.\-]*)\s*>([^<]{1,200})</')) {
        $name = [string]$match.Groups[1].Value
        if ($name -match $keyword) {
            [void]$evidence.Add('<' + $name + '> = ' + [string]$match.Groups[2].Value)
        }
    }
    foreach ($match in [regex]::Matches($text, '([A-Za-z_][\w.\-]*)\s*=\s*"([^"]{1,200})"')) {
        $name = [string]$match.Groups[1].Value
        if ($name -match $keyword) {
            [void]$evidence.Add($name + ' = ' + [string]$match.Groups[2].Value)
        }
    }
    return , $evidence
}
 
try {
    # ---------------------------------------------------------------- 1. 要找哪些目录
    if (-not $Roots -or $Roots.Count -eq 0) {
        Say '=== 1. 自动查找目录 ==='
        $found = New-Object System.Collections.ArrayList
        # 运行目录的特征文件:VariableMap.xml / TaskModel.xml / config.ini。
        foreach ($marker in @('VariableMap.xml', 'TaskModel.xml', 'config.ini')) {
            try {
                $hits = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Depth $MaxDepth -Filter $marker -File `
                    -ErrorAction SilentlyContinue | Select-Object -First 5
            }
            catch { $hits = @() }
            foreach ($hit in $hits) {
                $directory = $hit.DirectoryName
                if (-not $found.Contains($directory)) { [void]$found.Add($directory) }
            }
        }
        # 现场的遗留方案目录(存在就一起看)。
        foreach ($legacy in @('D:\22所5v10A测试柜', 'D:\22所5v10A测试柜\')) {
            if (Test-Path -LiteralPath $legacy) { if (-not $found.Contains($legacy)) { [void]$found.Add($legacy) } }
        }
        foreach ($directory in $found) {
            Say ("  候选目录:" + $directory)
            [void]$Roots.Add($directory)
        }
        if ($Roots.Count -eq 0) {
            Say '  没有找到任何候选目录。请手工指定,例如:-Roots ''D:\<软件目录>'''
        }
    }
    else { Say '=== 1. 指定目录 ===' }
 
    # ---------------------------------------------------------------- 2. 方案候选文件
    $candidates = New-Object System.Collections.ArrayList
    foreach ($root in $Roots) {
        if (-not (Test-Path -LiteralPath $root)) { Say ("  目录不存在:" + $root); continue }
        Say ''
        Say ("=== 2. 方案候选文件:" + $root + " ===")
        $files = Get-ChildItem -LiteralPath $root -Recurse -Depth 1 -File -ErrorAction SilentlyContinue |
            Where-Object { Test-PlanFile $_.Name } | Sort-Object LastWriteTime -Descending
        $shown = 0
        foreach ($file in $files) {
            if ($shown -lt 30) { Say ("  " + $file.Name + "  " + [math]::Round($file.Length / 1KB, 1) + " KB  " + $file.LastWriteTime) }
            $shown++
            [void]$candidates.Add($file)
        }
        if ($shown -eq 0) { Say '  (没有匹配到方案候选文件)' }
        elseif ($shown -gt 30) { Say ("  …… 共 " + $shown + " 个,只列了前 30 个") }
    }
 
    # ---------------------------------------------------------------- 3. 解析最像方案的几个
    Say ''
    Say ("=== 3. 结构解析(最多 " + $MaxFiles + " 个,按修改时间倒序) ===")
    $parsed = 0
    foreach ($file in ($candidates | Sort-Object LastWriteTime -Descending)) {
        if ($parsed -ge $MaxFiles) { break }
        $parsed++
        Say ''
        Say ("  " + $file.FullName + "  (" + [math]::Round($file.Length / 1KB, 1) + " KB)")
        try { $text = Read-PlanText $file.FullName }
        catch { Say ('    读取失败:' + $_.Exception.Message); continue }
        if ([string]::IsNullOrWhiteSpace($text)) { Say '    (空文件或解压后为空)'; continue }
        if (-not $text.TrimStart().StartsWith('<')) { Say '    (不是 XML 文本,可能不是方案文件)' }
        $names = Get-ElementNames $text
        $limited = @($names | Select-Object -First $MaxNames)
        Say ('    元素名(前 ' + $limited.Count + ' / 共 ' + $names.Count + '):' + ($limited -join ', '))
        $evidence = Get-NameEvidence $text
        if ($evidence.Count -gt 0) {
            Say ('    名称类元素或属性(最多 5 条)——**有名称就直接抄下来**:')
            foreach ($line in @($evidence | Select-Object -First 5)) { Say ('      ' + $line) }
        }
        else { Say '    没有发现名称类元素或属性。' }
        $head = $text.Trim()
        if ($head.Length -gt $MaxHeadChars) { $head = $head.Substring(0, $MaxHeadChars) + ' …' }
        Say ('    文本开头:' + $head)
    }
 
    Say ''
    if ($parsed -eq 0) { Say '=== 结论:没有可解析的方案候选文件 ===' }
    else { Say '=== 结论:见上面各文件的"名称类元素或属性"与"元素名"两行 ===' }
    Say '  如果方案里根本没有名称,就只剩两条路:① 用推导方向当名称(标注"推导");'
    Say '  ② 在来源配置里声明「工步号 → 名称」,把甲方界面上对得上的名字配上。'
    Say ('报告同时写在:' + $ReportFile)
}
finally {
    # 中途出错也把已有内容落盘:控制台刷不完,文件可以慢慢复制。
    $report | Set-Content -LiteralPath $ReportFile -Encoding UTF8
}