在 PowerShell 中利用正则表达式来解析文本块

需求

给定一段文本,如:

1, abcd [xxxx]
vkjl gas kje asld
gew wef
2, bbb [wefs]
oioias wmfjalkjs
3, ccc [wegas]
kzxlj kjlwiewe ii

要求分割成多段以数字开头的文本块,如:

第一块:

1, abcd [xxxx]
vkjl gas kje asld
gew wef

第二块:

2, bbb [wefs]
oioias wmfjalkjs

第三块:

3, ccc [wegas]
kzxlj kjlwiewe ii

思路

  • 定义我们要东西为 n 个“block”。
  • 每个“block”的特征是:
    • 以数字开头
    • block 之前可能是整段文本的起始也有可能是一个回车符。
    • block 之后可能是一个回车符+下一行的数字也有可能是整段文本的结束。
  • block 之前和之后的回车符是不需要的
  • block 应该尽可能“非贪婪”,遇到下一个符合条件的,算作一个新的 block 开始。

其中,“block 之前和之后的回车符是不需要的”可以用正则表达式的“零宽断言”来解决。

代码

$subject = @'
1, abcd [xxxx]
vkjl gas kje asld
gew wef
2, bbb [wefs]
oioias wmfjalkjs
3, ccc [wegas]
kzxlj kjlwiewe ii
'@

$resultlist = new-object System.Collections.Specialized.StringCollection
$regex = [regex]@'
(?snx)(^|(?<=\n))
(?<block>\d, .*?)
((?=\n\d, )|$)
'@
$match = $regex.Match($subject)
while ($match.Success) {
    $resultlist.Add($match.Groups['block'].Value) | out-null
    $match = $match.NextMatch()
}

$resultlist | ForEach-Object {
    echo $_
    echo ---
}

输出结果

1, abcd [xxxx]
vkjl gas kje asld
gew wef
---
2, bbb [wefs]
oioias wmfjalkjs
---
3, ccc [wegas]
kzxlj kjlwiewe ii
---

用 PowerShell 批量分割 QQ 聊天记录

纯文本文件有诸多的好处:

  • 通用
  • 易于管理
  • 易于搜索
  • 易于迁移

接下来我们用 PoewrShell 来处理 QQ 的聊天记录。目的是将所有的聊天记录按照“组名/对象名.txt”来分别保存每个好友、每个 QQ 群等的聊天记录。

我现在用的是 QQ 6.1 (11905) 版本。依次打开 QQ / 工具 / 消息管理器,点击右上角的倒三角按钮可以看到“导出全部消息记录”菜单项。我们在接下来的对话框里的保存类型中选择“文本文件(*.txt,不支持导入)”,并用默认的“全部消息记录.txt”文件名保存。保存之后的文件内容大概是如下格式:

消息记录(此消息记录为文本格式,不支持重新导入)

================================================================
消息分组:我的好友
================================================================
消息对象:Victor.Woo
================================================================

2010-01-06 16:57:28 Victor.Woo
http://pic4.nipic.com/20090728/1684061_175750076_2.jpg

2010-05-27 12:29:35 Victor.Woo
6块钱包月55
8000/月
中心端,用户端

================================================================
消息分组:技术.关注
================================================================
消息对象:*PowerShell技术交流
================================================================

2013-06-23 15:52:32 此消彼长,云过有痕<qq_g@163.com>
http://yun.baidu.com/buy/center?tag=4#FAQ02

百度亮了,自己找亮点

2013-06-23 18:42:35 Victor.Woo<victorwoo@gmail.com>
[表情]

观察它的规律:

  • ================================================================ 作为每一段的元数据开始。
  • 接下来依次是消息分组、分隔符、消息对象。
  • ================================================================ 作为元数据的结束。
  • 元数据之后,是正文部分,直到下一个元数据开始。
  • 文件头部还有两行无关内容。
  • 文件尺寸巨大,不适合整体用正则表达式来提取,只能一行一行解析。

我们的目标是生成 我的好友/Victor.Woo.txt技术.关注/.PowerShell技术交流.txt

根据这个规律,我们可以用类似“状态机”的思想来设计 PowerShell 脚本。在遍历源文件的所有行时,用一个 $status 变量来表示当前的状态,各个状态的含义如下:

状态 含义
INIT 初始状态
ENTER_BLOCK 进入一个元数据块
ENTER_GROUP “消息分组”解析完成
ENTER_SPLITTER 元数据中间的分隔符解析完成
ENTER_TARGET “消息对象”解析完成
LEAVE_BLOCK 元数据块解析完成
CONTENT 当前行是正文内容

然后用一个 switch 语句让 $status 变量在这些状态之间来回跳转,就能解析出一个一个独立的消息文件了。完整代码如下:

function Get-Status($status, $textLine, $lineNumber, $block) {
    $splitter = '================================================================'
    switch ($status) {
        'INIT' {
            if ($textLine -eq $splitter) {
                $status = 'ENTER_BLOCK'
            }
        }
        'ENTER_BLOCK' {
            if ($textLine -cmatch '消息分组:(.*)') {
              $block.Group = $matches[1]
                $block.Target = $null
                $status = 'ENTER_GROUP'
                break
            } else {
              Write-Error "[$lineNumber] [$status] $textLine"
                exit
            }
        }
        'ENTER_GROUP' {
            if ($textLine -eq $splitter) {
                $status = 'ENTER_SPLITTER'
                break
            } else {
                Write-Error "[$lineNumber] [$status] $textLine"
                exit
            }
        }
        'ENTER_SPLITTER' {
            if ($textLine -cmatch '消息对象:(.*)') {
              $block.Target = $matches[1]
                $status = 'ENTER_TARGET'
                break
            } else {
              Write-Error "[$lineNumber] [$status] $textLine"
                exit
            }
        }
        'ENTER_TARGET' {
            if ($textLine -eq $splitter) {
                $status = 'LEAVE_BLOCK'
                break
            } else {
                Write-Error "[$lineNumber] [$status] $textLine"
                exit
            }
        }
        'LEAVE_BLOCK' {
            if ($textLine -eq $splitter) {
                $status = 'ENTER_BLOCK'
                break
            } else {
                $status = 'CONTENT'
            }
        }
        'CONTENT' {
            if ($textLine -eq $splitter) {
                $status = 'ENTER_BLOCK'
                break
            } else {
                $status = 'CONTENT'
            }
        }
    }

    return $status
}

$status = 'INIT'
$lineNumber = 0
$block = @{}
$targetPath = $null
cat 全部消息记录.txt -Encoding UTF8 | foreach {
    $textLine = $_
    $lineNumber++
    $status = Get-Status $status $textLine $lineNumber $block
    switch ($status) {
        'LEAVE_BLOCK' {
            if ($block.Target -eq '最近联系人') {
                break
            }
            $dirName = $block.Group.Replace('*', '.')
            if (!(Test-Path $dirName)) {
                md $dirName | Out-Null
            }

            $fileName = $block.Target.Replace('*', '.')

            $targetPath = (Join-Path $dirName $fileName) + '.txt'
            if (Test-Path $targetPath) {
                del $targetPath
            }

            echo $targetPath
        }
        'CONTENT' {
            #echo $textLine
            if ($block.Target -eq '最近联系人') {
                break
            }
            Out-File -InputObject $textLine -Encoding utf8 -LiteralPath $targetPath -Append
        }
    }
}

您也可以在这里下载完成后的版本。

用 PowerShell 输出中文到剪贴板

方法一 通过 clip.exe

用 PowerShell 将字符串输出到剪贴板的最简单方式是:

'abc' | clip.exe

不过直接这么使用的话,如果待输出的字符串是包含中文的,那么剪贴板里的内容会出现“乱码”:

'abc中文def' | clip.exe

剪贴板里的内容变成:

abc??def

这是因为为了兼容旧程序,管道操作缺省将字符串采用 ASCII 编码,因此对于中文字符,被转换成了“??”。解决方案如下:

$OutputEncoding = [Console]::OutputEncoding
'abc中文def' | clip.exe

方法二 通过 WPF 方法

-sta 参数启动 PowerShell 后,执行以下代码:

Add-Type -Assembly PresentationCore
[Windows.Clipboard]::SetText('abc中文def')

PowerShell 2.0 的控制台,缺省设置是 MTAPowerShell 3.0 的控制台,缺省设置是 STA。

关于 -sta 的知识,请参见PowerShell中的 STA和MTA

参考材料:

用 PowerShell 处理纯文本 - 4

命题

QQ 群里的史瑞克朋友提出的一个命题:

$txt="192.168.1
192.168.2
192.168.3
172.19.3
192.16.1
192.16.2
192.16.11
192.16.3
10.0.4
192.16.29
192.16.9
192.16.99
192.16.100"

要求输出:

10.0.4
172.19.3
192.16.1-192.16.3
192.16.9
192.16.11
192.16.29
192.16.99-192.16.100
192.168.1-192.168.3

问题解析

* 将各个 IP 段补充为三位的格式
* 按字符串排序
* 遍历每一行,按照以下规则处理:
    * 如果和上一行连续,则上一段可能没有结束,更新 `$endIP`
    * 如果和上一行不连续
        * 若 `$startIP` 和 `$endIP` 相同,说明是单个 IP,将单个 IP 加入 $result
        * 若 `$startIP` 和 `$endIP` 不同,说明是一段 IP,将一段 IP 加入 $result
        * 更新 `$startIP` 和 `$endIP`
* 最后一行需要特殊处理

PowerShell 实现

$DebugPreference = "Continue"

$txt="192.168.1
192.168.2
192.168.3
172.19.3
192.16.1
192.16.2
192.16.11
192.16.3
10.0.4
192.16.29
192.16.9
192.16.99
192.16.100"
$txt += "`n999.999.999"

$startIP = @(0, 0, 0)
$endIP = @(0, 0, 0)
$result = @()

-split $txt | % {
    $fullSegments = ($_ -split "\." | % {
        "{0:D3}" -f [int]$_
    })
    $fullSegments -join "."
} | sort | % {
    Write-Debug "Processing $_"
    $segments = @($_ -split "\." | % {
        [int]$_
    })

    if ($endIP[0] -eq $segments[0] -and
        $endIP[1] -eq $segments[1] -and
        $endIP[2] + 1 -eq $segments[2]) {
        Write-Debug '和上一个IP连续'
        $endIP = $segments
    } else {
        Write-Debug '和上一个IP不连续'
        if (($startIP -join ".") -eq ($endIP -join ".")) {
            Write-Debug '单个IP'
            $result += $startIP -join "."
        } else {
            Write-Debug '一段IP'
            $result += ($startIP -join ".") + "-" + ($endIP -join ".")
        }

        $startIP = $segments
        $endIP = $segments
    }
}

$result | select -Skip 1

源代码请在这里下载

用 PowerShell 处理纯文本 - 3

命题:

怎样把字符 “ABC-EFGH-XYZ” 替换为 “012-3456-789”

为了解决这个 case,先归纳它的规律:

  • 注意字符集和数字之间的对应关系,我们可以自定义一个字符集来表示。
  • 保留 - 号。
  • 不区分大小写,提高适应性。

根据以上规律编写 PowerShell 代码:

$charSet = 'ABCEFGHXYZ'.ToCharArray()

$text = 'ABC-EFGH-XYZ'

$array = ($text.ToUpper().ToCharArray() | % {
    if ($_ -eq '-') {
        '-'
    } else {
        [string]([System.Array]::IndexOf($charSet, $_))
    }
})

$array -join ''

结果是:

012-3456-789

用 PowerShell 处理纯文本 - 2

应朋友要求,帮忙解决一例 PowerShell 问题:

有一个 CSV 文件,其中有一个 Photo 字段存的是 BASE64 编码的字符串,这个字符串包含换行符。在 Import-Csv 的时候,Photo 字段不会作为一个整体值,而是会变成每行一个。文件的内容是这样的:

StaffNum,LogonName,ObjName,Title,Office,Department,Photo
03138,wangjunhao,王俊豪,流程优化主管,宜山路,运营平台中心/项目部,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a
HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy
...
PyoooGSr0qRCdtFFMBy9qkFFFJCYo60w85oopgNzjoBTto9KKKQj/9k=

为了解决这个 case,先归纳它的规律:

  1. BASE64 字符串的字符集为 0..9、a..z、A..Z,以及/和=。
  2. BASE64 字符串每一行不超过 76 个字符。
  3. 如果某一行从第一个字符到最后一个字符,都符合上述 2 条规律,说明前一行并没有结束。应当把当前行拼接到前一行中。

根据以上规律编写 PowerShell 代码:

$fileName = 'AllUsers.csv'

$currentLine = ''
gc $fileName | % -process {
        if ($_ -cmatch '^[a-zA-Z0-9/+=]{1,76}$') {
            # 如果符合 BASE64 特征,说明上一行未结束。
            $currentLine += $_
        } else {
            # 如果不符合 BASE64 特征,说明上一行是完整的。
            Write-Output $currentLine
            $currentLine = $_
        }
    } -end {
        $currentLine
    } |
ConvertFrom-Csv

完整的 .CSV 及 .PS1 文件请在这里下载

用 PowerShell 处理纯文本 - 1

原始文本:”data1”:111,”data2”:22,”data3”:3,”data4”:4444444,”data5”:589
要求:转换成对象

方法一,采用字符串运算及 ConvertFrom-StringData 命令:

$rawTxt='"data1":111,"data2":22,"data3":3,"data4":4444444'
$rawTxt -split ',' | ForEach-Object {
   $temp= $_ -split ':'
   "{0}={1}" -f $temp[0].Substring(1,$temp[0].Length-2),$temp[1]
} | ConvertFrom-StringData

方法二,采用正则表达式,使用.NET的方法:

$rawTxt = '"data1":111,"data2":22,"data3":3,"data4":4444444,"data5":589'
$regex = [regex] '"(?<name>\w*)":(?<value>\d*),?'
$match = $regex.Match($rawTxt)
while ($match.Success) {
    [PSCustomObject]@{
        Name = $match.Groups['name'].Value
        Value = $match.Groups['value'].Value
    }
    $match = $match.NextMatch()
}

方法三,采用正则表达式,使用 Select-String Cmdlet:

Select-String -InputObject $rawTxt -Pattern $regex -AllMatches | % {
    $_.Matches
} | % {
   [PSCustomObject]@{
        Name = $_.Groups['name'].Value
        Value = $_.Groups['value'].Value
    }
}

三者的执行结果都是这样:

Name          Value
----          -----
data1         111
data2         22
data3         3
data4         4444444
data5         589

原命题参见:[PowerShell 文本处理实例(三)] 1