【问题标题】:Multiline data: Removing LF (but not CRLF) from CSV using Powershell多行数据:使用 Powershell 从 CSV 中删除 LF(但不是 CRLF)
【发布时间】:2020-09-29 15:31:00
【问题描述】:

我需要通过删除内联换行符和印刷引号等特殊字符来清理一些 CSV 数据。我觉得我可以使用 Python 或 Unix 实用程序来完成这项工作,但我被困在一个非常普通的 Windows 2012 机器上,所以尽管我缺乏使用 PowerShell v5 的经验,但我还是试了一下。

这是我想要实现的目标:

$InputFile:

"INCIDENT_NUMBER","FIRST_NAME","LAST_NAME","DESCRIPTION"{CRLF}
"00020306","John","Davis","Employee was not dressed appropriately."{CRLF}
"00020307","Brad","Miller","Employee told customer, ""Go shop somewhere else!"""{CRLF}
"00020308","Ted","Jones","Employee told supervisor, “That’s not my job”"{CRLF}
"00020309","Bob","Meyers","Employee did the following:{LF}
• Showed up late{LF}
• Did not complete assignments{LF}
• Left work early"{CRLF}
"00020310","John","Davis","Employee was not dressed appropriately."{CRLF}

$OutputFile:

"INCIDENT_NUMBER","FIRST_NAME","LAST_NAME","DESCRIPTION"{CRLF}
"00020307","Brad","Miller","Employee told customer, ""Go shop somewhere else!"""{CRLF}
"00020308","Ted","Jones","Employee told supervisor, ""That's not my job"""{CRLF}
"00020309","Bob","Meyers","Employee did the following: * Showed up late * Did not complete assignments * Left work early"{CRLF}
"00020310","John","Davis","Employee was not dressed appropriately."{CRLF}

以下代码有效:

(Get-Content $InputFile -Raw) `
    -replace '(?<!\x0d)\x0a',' ' `
    -replace "[‘’´]","'" `
    -replace '[“”]','""' `
    -replace "\xa0"," " `
    -replace '[•·]','*' | Set-Content $OutputFile -Encoding ASCII

但是,我正在处理的实际数据是一个 4GB 的文件,其中包含超过一百万行。 Get-Content -Raw 内存不足。我试过Get-Content -ReadCount 10000,但这会删除所有换行符,大概是因为它按行读取。

更多谷歌搜索将我带到 Import-Csv,我从 here 获得:

Import-Csv $InputFile | ForEach {
    $_.notes = $_.notes -replace '(?<!\x0d)\x0a',' '
    $_
} | Export-Csv $OutputFile -NoTypeInformation -Encoding ASCII

但我的对象上似乎没有 notes 属性:

Exception setting "notes": "The property 'notes' cannot be found on this object. Verify that the property exists and can be set."
At C:\convert.ps1:53 char:5
+     $_.notes= $_.notes -replace '(?<!\x0d)\x0a',' '
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], SetValueInvocationException
    + FullyQualifiedErrorId : ExceptionWhenSetting

我找到了另一个使用 Value 属性的示例,但我得到了同样的错误。

我尝试在每个对象上运行 Get-Member,看起来它是根据文件中的标头分配属性,就像我可以使用 $_.DESCRIPTION 获得它一样,但我不知道足够的 PowerShell 来运行所有属性的替换:(

请帮忙?谢谢!

更新:

我最终放弃了 PS 并在 AutoIT 中编码。它不是很好,而且维护起来会更加困难,尤其是在 2.5 年没有新版本的情况下。但它可以工作,并且可以在 4 分钟内处理 prod 文件。

不幸的是,我也不能轻松地键入 LF,所以我最终采用基于 ^"[^",] 的逻辑创建新行(行以引号开头,第二个字符不是引号或逗号) .

这是 AutoIT 代码:

#include <FileConstants.au3>

If $CmdLine[0] <> 2 Then
   ConsoleWriteError("Error in parameters" & @CRLF)
   Exit 1
EndIf

Local Const $sInputFilePath = $CmdLine[1]
Local Const $sOutputFilePath = $CmdLine[2]

ConsoleWrite("Input file: " & $sInputFilePath & @CRLF)
ConsoleWrite("Output file: " & $sOutputFilePath & @CRLF)
ConsoleWrite("***** WARNING *****" & @CRLF)
ConsoleWrite($sOutputFilePath & " is being OVERWRITTEN!" & @CRLF & @CRLF)

Local $bFirstLine = True

Local $hInputFile = FileOpen($sInputFilePath, $FO_ANSI)
   If $hInputFile = -1 Then
        ConsoleWriteError("An error occurred when reading the file.")
        Exit 1
     EndIf

Local $hOutputFile = FileOpen($sOutputFilePath, $FO_OVERWRITE + $FO_ANSI)
   If $hOutputFile = -1 Then
        ConsoleWriteError"An error occurred when opening the output file.")
        Exit 1
     EndIf

ConsoleWrite("Processing..." &@CRLF)

While True
   $sLine = FileReadLine($hInputFile)
   If @error = -1 Then ExitLoop

   ;Replace typographic single quotes and backtick with apostrophe
   $sLine = StringRegExpReplace($sLine, "[‘’´]","'")

   ;Replace typographic double quotes with normal quote (doubled for in-field CSV)
   $sLine = StringRegExpReplace($sLine, '[“”]','""')

   ;Replace bullet and middot with asterisk
   $sLine = StringRegExpReplace($sLine, '[•·]','*')

   ;Replace non-breaking space (0xA0) and delete (0x7F) with space
   $sLine = StringRegExpReplace($sLine, "[\xa0\x7f]"," ")

   If $bFirstLine = False Then
      If StringRegExp($sLine,'^"[^",]') Then
         $sLine = @CRLF & $sLine
      Else
         $sLine = " " & $sLine
      EndIf
   Else
      $bFirstLine = False
   EndIf

   FileWrite($hOutputFile, $sLine)

WEnd

ConsoleWrite("Done!" &@CRLF)
FileClose($hInputFile)
FileClose($hOutputFile)

【问题讨论】:

  • 只需将$_.notes 替换为$_.DESCRIPTION,就可以了
  • 谢谢!问题是实际数据每行包含 600 个字段,其中数百个可能包含换行符和特殊字符。
  • 看到这个:Reading large text files with Powershell,你可以用简洁的正则表达式或模式替换所有这些 -replace。例如 "'(?
  • @postanote 感谢您的链接!这实际上是我找到删除所有换行符的 -ReadCount 选项的地方。

标签: powershell csv newline


【解决方案1】:

第一个答案可能比这更好,因为我不确定 PS 是否需要以这种方式将所有内容加载到内存中(尽管我认为确实如此),但是从上面开始的内容开始,我一直在思考这一行……​​

# Import CSV into a variable
$InputFile = Import-Csv $InputFilePath

# Gets all field names, stores in $Fields
$InputFile | Get-Member -MemberType NoteProperty | 
Select-Object Name | Set-Variable Fields

# Updates each field entry
$InputFile | ForEach-Object {
    $thisLine = $_
    $Fields | ForEach-Object {
            ($thisLine).($_.Name) = ($thisLine).($_.Name) `
                -replace '(?<!\x0d)\x0a',' ' `
                -replace "[‘’´]","'" `
                -replace '[“”]','""' `
                -replace "\xa0"," " `
                -replace '[•·]','*'
            }
    $thisLine | Export-Csv $OutputFile -NoTypeInformation -Encoding ASCII -Append
} 

【讨论】:

  • 谢谢!这行得通!不幸的是,它也比其他方法慢很多。我有一个一直在测试的 300 行样本。其他方法在几秒钟内就完成了,但由于某种原因,这个方法花了 3.5 分钟。
  • 是的,正如我所怀疑的那样,内存太密集了。如果我们运行它并逐行附加更新会有所帮助吗? (请参阅上面的编辑答案。)
  • 您所做的只是移动大括号以在 ForEach 字段中包含 Export,对吗?这实际上花费了更长的时间,3.75 秒。 :(
  • @b-frid:使用与自定义对象进行序列化的 cmdlet 以及管道的使用,尤其是 ForEach-Object,使您的方法变得非常缓慢 - 这是不幸的,因为它使一个概念上更优雅的解决方案。有关 PowerShell 性能提示,请参阅 this answer。顺便说一句:-replace '\n', ' ' 可以 - 字段内没有 CR。
  • @b-frid:这主要是 580 个字段的嵌套管道,并且必须为每个字段执行一个脚本块,而不是 -replace 操作本身。您可以使用.ForEach() 方法 而不是ForEach-Object cmdlet 来加快速度,但总体而言,这种基于对象的解决方案将比非管道,纯文本解决方案。
【解决方案2】:

这是另一个“逐行”尝试,有点类似于 mklement0 的回答。 假定没有“行继续”行以“ 开头。希望它的性能更好!

# Clear contents of file (Not sure if you need/want this...)
if (Test-Path -type leaf $OutputFile) { Clear-Content $OutputFile }

# Flag for first entry, since no data manipulation needed there
$firstEntry = $true

foreach($line in [System.IO.File]::ReadLines($InputFile)) {
    if ($firstEntry) {
        Add-Content -Path $OutputFile -Value $line -NoNewline
        $firstEntry = $false
    }
    else {
        if ($line[0] -eq '"') { Add-Content -Path $OutputFile "`r`n" -NoNewline}
        else { Add-Content -Path $OutputFile " " -NoNewline}
        $sanitizedLine = $line -replace '(?<!\x0d)\x0a',' ' `
                               -replace "[‘’´]","'" `
                               -replace '[“”]','""' `
                               -replace "\xa0"," " `
                               -replace '[•·]','*'
        Add-Content -Path $OutputFile -Value $sanitizedLine -NoNewline
    }
}

该技术基于其他答案及其 cmets:https://stackoverflow.com/a/47146987/7649168

(也感谢 mklement0 解释了我之前回答的性能问题。)

【讨论】:

  • 在性能方面,[System.IO.File]::ReadLines()switch 语句相当,尽管后者具有内置支持多分支条件的优势(相等匹配(默认),通配符匹配(-Wildcard),正则表达式匹配(-Regex)。但是,Add-Content 的使用是另一个瓶颈,尤其是因为每次调用都会打开和关闭文件。[System.IO.StreamWriter] 实例,如我的回答所示,解决了这个问题。
  • 我今天能够重新开始测试。不幸的是,虽然这个解决方案比以前的解决方案快得多,但它仍然不适用于生产数据。 300 行样本,约占实时数据的 0.03%,使用此方法耗时 10 秒,因此实时数据超过 8 小时。不过,我真的很感谢您的帮助!
【解决方案3】:

注意:

  • 请参阅我的other answer 以获得强大的 解决方案。

  • 下面的答案对于性能良好的一般逐行处理解决方案可能仍然感兴趣,尽管它也总是将仅 LF 实例视为行分隔符(它已更新为使用相同的正则表达式来区分行开头的行和您在添加到问题的 AutoIt 解决方案中使用的行的延续)。


鉴于您的文件大小,出于性能原因,我建议坚持使用纯文本处理:

  • switch 语句可实现快速的逐行处理;它把 CRLF 和 LF 都识别为换行符,就像 PowerShell 通常做的那样。但是请注意,鉴于返回的每一行都删除了尾随换行符,您将无法判断输入行是否以 CRLF 结尾,或者只是 LF。

  • 直接使用 .NET 类型 System.IO.StreamWriter,绕过管道并启用对输出文件的快速写入。

  • 有关一般 PowerShell 性能提示,请参阅this answer

$inputFile = 'in.csv'
$outputFile = 'out.csv'

# Create a stream writer for the output file.
# Default to BOM-less UTF-8, but you can pass a [System.Text.Encoding]
# instance as the second argument.
# Note: Pass a *full* path, because .NET's working dir. usually differs from PowerShell's
$outFileWriter = [System.IO.StreamWriter]::new("$PWD/$outputFile")

# Use a `switch` statement to read the input file line by line.
$outLine = ''
switch -File $inputFile -Regex {
  '^"[^",]' { # (Start of) a new row.
    if ($outLine) { # write previous, potentially synthesized line
      $outFileWriter.WriteLine($outLine)
    }
    $outLine = $_ -replace "[‘’´]", "'" -replace '[“”]', '""' -replace '\u00a0', ' '
  }
  default { # Continuation of a row.
    $outLine += ' ' + $_ -replace "[‘’´]", "'" -replace '[“”]', '""' -replace '\u00a0', ' ' `
      -replace '[•·]', '*' -replace '\n'
  }
}
# Write the last line.
$outFileWriter.WriteLine($outLine)

$outFileWriter.Close()

注意:上面的假设没有行continuation也匹配正则表达式模式'^"[^",]',希望它足够健壮(你认为它是,因为您的 AutoIt 解决方案基于它)。

行的开头和后续行的延续之间的这种简单区别消除了对较低级别文件 I/O 的需要,以便区分 CRLF 和 LF 换行符,我的other answer 就是这样做的。

【讨论】:

  • 我今天终于有时间测试了。在我的 300 行测试数据(实际数据超过 1M 行)上,第一种方法耗时 0.16 秒,第二种方法耗时 4 秒。在 9.5 分钟内针对实时数据运行第一个。尝试了第二个,但我在 3.5 小时后将其杀死。既然我正在计算数字,我敢打赌它已经接近完成了:(但是,结果数据的行数比预期的少 16000 行,我真的不知道为什么。不幸的是,虽然我真的非常感谢你帮助,我从你的代码中学到了很多东西,我必须继续前进。非常感谢。
  • @JosephSmith:感谢关于Invoke-Expression 解决方案性能的反馈,这使我从答案中删除该解决方案。我已经更新了原始解决方案以匹配您添加到问题中的 AutoIt 解决方案(这是一个简单的调整,感谢 switch -Regex)。不过,此后我也发布了fully robust 解决方案。
【解决方案4】:

以下两种方法原则上可以使用,但是对于像您这样的大型输入文件太慢

  • 面向对象的处理Import-Csv / Export-Csv

    • 使用Import-Csv 将CSV 解析为对象,修改对象的DESCRIPTION 属性值,然后使用Export-Csv 重新导出。由于行内部的 LF-only 换行符位于 双引号 字段内,因此它们被识别为同一行的一部分。

    • 虽然是一种健壮且概念上优雅的方法,但它是迄今为止最慢且非常占用内存的方法 - 请参阅 GitHub issue #7603,其中讨论了原因,以及 GiHub feature request #11027 通过输出 哈希表 em> 而不是 自定义对象 ([pscustomobject])。

  • 纯文本处理Get-Content / Set-Content

    • 使用Get-Content -Delimiter "`r`n"将文本文件仅按CRLF分割成行,而不是LF,根据需要转换每一行并使用Set-Content将其保存到输出文件中。

    • 虽然您通常为使用 管道 的概念优雅付出了性能损失,这使得使用Set-Content 逐行保存结果有点慢,Get-Content 尤其如此慢,因为它用有关原始文件的附加属性装饰每个输出字符串(行),这很昂贵。请参阅绿灯但尚未实现的GitHub feature request #7537,通过省略此装饰来提高性能(和内存使用)。


解决方案

  • 出于性能原因,因此需要直接使用 .NET API
    • 注意:如果 PowerShell 解决方案仍然太慢,请考虑通过使用 Add-Type 对 C# 代码进行临时编译来创建帮助程序类;当然,最终,只使用编译后的代码效果最好。
  • 虽然没有直接等效于 Get-Content -Delimiter "`r`n",但您可以读取 固定大小的字符块(数组)中的文本文件,使用 System.IO.StreamReader.ReadBlock() 方法(.NET Framework 4.5+ / .NET Core 1+),然后您可以在其上执行所需的转换,如下所示。

注意:

  • 为获得最佳性能,请在下方选择较高的$BUFSIZE 值,以尽量减少读取次数和处理迭代次数;显然,必须选择该值,以免内存不足。

  • 甚至不需要解析读入 CRLF 换行符的块,因为您可以简单地使用正则表达式来定位仅 LF 行,该正则表达式是原始方法 '(?&lt;!\r|^)\n' 的修改版本(参见代码 cmets下面)。

  • 为简洁起见,省略了错误处理,但关闭文件的.Close() 调用通常应放在try / catch / finally 语句的finally 块中。

# In- and output file paths.
# Note: Be sure to use *full* paths, because .NET's working dir. usually
#       differs from PowerShell's.
$inFile = "$PWD/in.csv"
$outFile = "$PWD/out.csv"

# How many characters to read at once.
# This is a tradeoff between execution speed and memory use.
$BUFSIZE = 100MB
$buf = [char[]]::new($BUFSIZE)

$inStream = [IO.StreamReader]::new($inFile)
$outStream = [IO.StreamWriter]::new($outFile)
  
# Process the file in fixed-size blocks of characters.
while ($charsRead = $inStream.ReadBlock($buf, 0, $BUFSIZE)) {
  # Convert the array of chars. to a string.
  $block = [string]::new($buf, 0, $charsRead)
  # Transform the block and write it to the output file.
  $outStream.Write(
    # Transform block-internal LF-only newlines to spaces and perform other
    # subsitutions.
    # Note: The |^ part inside the negative lookbehind is to deal with the
    #       case where the block starts with "`n" due to the block boundaries
    #       accidentally having split a CRLF sequence.
    ($block -replace '(?<!\r|^)\n', ' ' -replace "[‘’´]", "'" -replace '[“”]', '""' -replace '\u00a0', ' ' -replace '[•·]', '*')
  )
}

$inStream.Close()
$outStream.Close()

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2022-01-22
    • 2021-07-21
    • 1970-01-01
    • 2020-02-02
    • 1970-01-01
    • 2021-09-06
    • 1970-01-01
    • 2011-06-18
    相关资源
    最近更新 更多