yeg
5 天以前 691f882740b46c5354fb33404fef6efb370e05dc
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
<#
  22 所上位机文件侧只读探查:工步方案(含可能的工步名称)在不在软件目录的文件里。
 
  为什么走文件侧:该机 SQL Server 的 5141 个用户库**全部**是 DB_<时间> 测试库、其他库 0 个,
  所以工步方案不可能在库里。
 
  方案文件是 `.pcj`(2026-10-09 现场确认):`SysSchedule` 格式的 XML、**没有压缩**,声明 utf-8,
  每个工步一条 `<Step>`,带 `CtrlTypeName`(控制类型名)、`CtrlValue`(设定值)、`Limit`(截止条件)、
  `GotoStep`(跳转),方案名在 `<ScheduleName>`。遗留的 `.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,
    # 工步方案里打印前几个与后几个工步(中间省略),以及每个工步最多打印多少字。
    [int]$StepPreview = 4,
    [int]$MaxStepChars = 300,
    [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)
}
 
# 方案文件的判据:.pcj 是这台机器上软件的工步方案(SysSchedule XML,2026-10-09 现场确认),
# .sto 是遗留方案(gzip 压缩的 XML);名字里带 step/plan/task/recipe/scheme/program/工步/方案的也算。
# 其余文件(设备配置、单位换算表等)不看——它们存不下工步名称。
function Test-PlanFile([string]$name) {
    $extension = [System.IO.Path]::GetExtension($name)
    if ($extension -ieq '.pcj' -or $extension -ieq '.sto') { return $true }
    return $name -imatch '(step|plan|task|recipe|scheme|program|工步|方案)'
}
 
# 取一个 XML 标签的文本值;没有这个标签就返回空串。
function Get-TagValue([string]$text, [string]$tag) {
    return [regex]::Match($text, '<' + $tag + '>([^<]*)</' + $tag + '>').Groups[1].Value
}
 
# 读文件文本: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. 要找哪些目录
    # 要探查的目录放进自己的列表:-Roots 是 [string[]] 定长数组,不能往里 Add。
    $targets = New-Object System.Collections.ArrayList
    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]$targets.Add($directory)
        }
        if ($targets.Count -eq 0) {
            Say '  没有找到任何候选目录。请手工指定,例如:-Roots ''D:\<软件目录>'''
        }
    }
    else {
        Say '=== 1. 指定目录 ==='
        foreach ($root in $Roots) {
            # 用 -File 从命令行传数组时,多个目录可能被当成一个字符串('D:\a,D:\b'),按逗号再拆一次。
            foreach ($single in @($root -split ',')) {
                $trimmed = $single.Trim().Trim('"')
                if ($trimmed) { Say ("  指定目录:" + $trimmed); [void]$targets.Add($trimmed) }
            }
        }
    }
 
    # ---------------------------------------------------------------- 2. 方案候选文件
    $candidates = New-Object System.Collections.ArrayList
    foreach ($root in $targets) {
        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)
 
        # 工步方案(.pcj / SysSchedule):逐工步的设定值、截止条件与跳转都在这里。
        # 工步的"名称"通常就是每个工步的 CtrlTypeName(例如"恒流充电""搁置")——这一节就是要看它。
        if ($text -match '<Steps>') {
            $steps = [regex]::Matches($text, '(?s)<Step>.*?</Step>')
            Say ('    方案名: ' + (Get-TagValue $text 'ScheduleName') + '    任务目录: ' + (Get-TagValue $text 'SchedulePath'))
            Say ('    方案标题: ' + (Get-TagValue $text 'Caption') + '    说明: ' + (Get-TagValue $text 'Description') + '    最后修改: ' + (Get-TagValue $text 'LastUpdateTime'))
            Say ('    工步数: ' + $steps.Count)
            $index = 0
            foreach ($step in $steps) {
                $index++
                # 只打印前几个与后几个:中间的省略,否则一个方案就能刷满屏。
                if ($index -gt $StepPreview -and $index -le ($steps.Count - $StepPreview)) { continue }
                $flat = $step.Value -replace '\s+', ' '
                if ($flat.Length -gt $MaxStepChars) { $flat = $flat.Substring(0, $MaxStepChars) + ' …' }
                Say ('      工步 ' + $index + ': ' + $flat)
            }
        }
    }
 
    Say ''
    if ($parsed -eq 0) { Say '=== 结论:没有可解析的方案候选文件 ===' }
    else { Say '=== 结论:见上面各文件的"名称类元素或属性"与"元素名"两行 ===' }
    Say '  如果方案里根本没有名称,就只剩两条路:① 用推导方向当名称(标注"推导");'
    Say '  ② 在来源配置里声明「工步号 → 名称」,把甲方界面上对得上的名字配上。'
    Say ('报告同时写在:' + $ReportFile)
}
finally {
    # 中途出错也把已有内容落盘:控制台刷不完,文件可以慢慢复制。
    $report | Set-Content -LiteralPath $ReportFile -Encoding UTF8
}