使用awk高效解析CSV的最健壮的方法是什么?

t2a7ltrp  于 2023-01-18  发布在  其他
关注(0)|答案(6)|浏览(169)

这个问题的目的是提供一个规范的答案。
给定Excel或其他工具生成的CSV,字段中嵌入换行符和/或双引号和/或逗号,空字段如下:

$ cat file.csv
"rec1, fld1",,"rec1"",""fld3.1
"",
fld3.2","rec1
fld4"
"rec2, fld1.1

fld1.2","rec2 fld2.1""fld2.2""fld2.3","",rec2 fld4
"""""","""rec3,fld2""",

使用awk来识别单独的记录和字段,最有效的方法是什么?

Record 1:
    $1=<rec1, fld1>
    $2=<>
    $3=<rec1","fld3.1
",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3,fld2">
    $3=<>
----

因此它可以被awk脚本的其余部分内部用作那些记录和字段。
有效的CSV应符合RFC 4180或可由MS-Excel生成。
该解决方案必须允许记录结尾仅为LF(\n),这是UNIX文件的典型值,而不是CRLF(\r\n),这是该标准所要求的,Excel或其他Windows工具将生成。它还允许无引号字段与带引号字段混合使用。特别是,它不需要允许在" s前使用反斜杠进行转义(即\"而不是""),因为一些其他的CSV格式允许-如果你有,然后添加一个gsub(/\\"/,"\"\"")的前面将处理它,并试图处理两个转义机制自动在一个脚本将使脚本不必要的脆弱和复杂。

ccrfmcuu

ccrfmcuu1#

如果你的CSV不能包含换行符,那么你所需要的就是(使用GNU awk for FPAT):

$ echo 'foo,"field,""with"",commas",bar' |
    awk -v FPAT='[^,]*|("([^"]|"")*")' '{for (i=1; i<=NF;i++) print i " <" $i ">"}'
1 <foo>
2 <"field,""with"",commas">
3 <bar>

或使用任何awk的等效物:

$ echo 'foo,"field,""with"",commas",bar' |
    awk -v fpat='[^,]*|("([^"]|"")*")' -v OFS=',' '{
        rec = $0
        $0 = ""
        i = 0
        while ( (rec!="") && match(rec,fpat) ) {
            $(++i) = substr(rec,RSTART,RLENGTH)
            rec = substr(rec,RSTART+RLENGTH+1)
        }
        for (i=1; i<=NF;i++) print i " <" $i ">"
    }'
1 <foo>
2 <"field,""with"",commas">
3 <bar>

有关https://www.gnu.org/software/gawk/manual/gawk.html#More-CSV我上面使用的特定FPAT设置的信息,请参见www.example.com。
如果你实际上想做的只是将CSV转换成单独的行,比如说,在带引号的字段中用空格替换换行符,用分号替换逗号,那么你所需要做的就是这样,再次使用GNU awk来处理多字符RS和RT:

$ awk -v RS='"([^"]|"")*"' -v ORS= '{gsub(/\n/," ",RT); gsub(/,/,";",RT); print $0 RT}' file.csv
"rec1; fld1",,"rec1"";""fld3.1 ""; fld3.2","rec1 fld4"
"rec2; fld1.1  fld1.2","rec2 fld2.1""fld2.2""fld2.3","",rec2 fld4
"""""","""rec3;fld2""",

但是,在其他方面,识别适用于任何现代awk* 的字段的通用、健壮、可移植的解决方案是:

$ cat decsv.awk
function buildRec(      fpat,fldNr,fldStr,done) {
    CurrRec = CurrRec $0
    if ( gsub(/"/,"&",CurrRec) % 2 ) {
        # The string built so far in CurrRec has an odd number
        # of "s and so is not yet a complete record.
        CurrRec = CurrRec RS
        done = 0
    }
    else {
        # If CurrRec ended with a null field we would exit the
        # loop below before handling it so ensure that cannot happen.
        # We use a regexp comparison using a bracket expression here
        # and in fpat so it will work even if FS is a regexp metachar
        # or a multi-char string like "\\\\" for \-separated fields.
        CurrRec = CurrRec ( CurrRec ~ ("[" FS "]$") ? "\"\"" : "" )
        $0 = ""
        fpat = "([^" FS "]*)|(\"([^\"]|\"\")+\")"
        while ( (CurrRec != "") && match(CurrRec,fpat) ) {
            fldStr = substr(CurrRec,RSTART,RLENGTH)
            # Convert <"foo"> to <foo> and <"foo""bar"> to <foo"bar>
            if ( gsub(/^"|"$/,"",fldStr) ) {
                gsub(/""/, "\"", fldStr)
            }
            $(++fldNr) = fldStr
            CurrRec = substr(CurrRec,RSTART+RLENGTH+1)
        }
        CurrRec = ""
        done = 1
    }
    return done
}

# If your input has \-separated fields, use FS="\\\\"; OFS="\\"
BEGIN { FS=OFS="," }
!buildRec() { next }
{
    printf "Record %d:\n", ++recNr
    for (i=1;i<=NF;i++) {
        # To replace newlines with blanks add gsub(/\n/," ",$i) here
        printf "    $%d=<%s>\n", i, $i
    }
    print "----"
}

$ awk -f decsv.awk file.csv
Record 1:
    $1=<rec1, fld1>
    $2=<>
    $3=<rec1","fld3.1
",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3,fld2">
    $3=<>
----

上面假设UNIX行尾为\n,使用Windows \r\n行尾要简单得多,因为每个字段中的“换行符”实际上只是换行符(即\n s),所以您可以设置RS="\r\n"(使用GNU awk实现多字符RS),然后字段中的\n s将不会被视为行尾。
它的工作原理是,每当遇到RS时,只需计算当前记录中迄今为止存在多少个"-如果是奇数,则为RS(大概是\n,但不一定是)是中场,所以我们继续创造目前的记录,但如果它甚至那么它'It“这是当前记录的结尾,因此我们可以继续执行脚本的其余部分,处理现在已完成的记录。

  • 我说“现代鹰”以上,因为显然有非常古老的(即大约2000年)版本的tawk和mawk 1仍然在他们的gsub()实现中有bug,这样gsub(/^"|"$/,"",fldStr)就不会从fldStr中删除开始/结束"。如果你正在使用其中的一个,那么**得到一个新的awk,最好是gawk,**因为它们也可能有其他问题,但如果这不是一个选项,那么我希望您可以通过更改以下内容来解决该特定错误:
if ( gsub(/^"|"$/,"",fldStr) ) {

改为:

if ( sub(/^"/,"",fldStr) && sub(/"$/,"",fldStr) ) {

感谢以下人员确定并建议解决本答案原始版本中所述问题的解决方案:
1.@mosvy表示字段内的转义双引号。
1.@datatraveller1表示字段中的多个连续转义引号对和记录末尾的空字段。
相关:另请参见How do I use awk under cygwin to print fields from an excel spreadsheet?了解如何从Excel电子表格生成CSV。

bqucvtff

bqucvtff2#

对@EdMorton的FPAT解决方案的改进,该解决方案应该能够处理通过加倍(""--CSV standard允许)转义的双引号(")。

gawk -v FPAT='[^,]*|("[^"]*")+' ...

这仍然
1.不能处理带引号的字段中的换行符,而这在标准CSV文件中是完全合法的。
1.假设GNU awkgawk),标准的awk就不行了。
示例:

$ echo 'a,,"","y""ck","""x,y,z"," ",12' |
gawk -v OFS='|' -v FPAT='[^,]*|("[^"]*")+' '{$1=$1}1'
a||""|"y""ck"|"""x,y,z"|" "|12

$ echo 'a,,"","y""ck","""x,y,z"," ",12' |
gawk -v FPAT='[^,]*|("[^"]*")+' '{
  for(i=1; i<=NF;i++){
    if($i~/"/){ $i = substr($i, 2, length($i)-2); gsub(/""/,"\"", $i) }
    print "<"$i">"
  }
}'
<a>
<>
<>
<y"ck>
<"x,y,z>
< >
<12>
ru9i0ody

ru9i0ody3#

这正是csvquote的用途--它使awk和其他命令行数据处理工具的工作变得简单。
有些东西很难用awk来表达,不是运行单个awk命令并试图让awk来处理带引号的、嵌入逗号和换行符的字段,而是通过csvquote来为awk准备数据,这样awk就可以始终将它找到的逗号和换行符解释为字段分隔符和记录分隔符,这使得管道的awk部分变得更简单,一旦awk处理完数据,它返回csvquote -u以恢复带引号的字段中嵌入的逗号和换行符。

csvquote file.csv | awk -f my_awk_script | csvquote -u

编辑:
有关csvquote的完整说明,请参见:它是如何工作的。这也解释了在有回车的地方显示的字符。

csvquote file.csv | awk -f decsv.awk | csvquote -u

(for decsv.awk来源参见Ed Morton的回答)输出:

Record 1:
    $1=<rec1 fld1>
    $2=<>
    $3=<rec1","fld3.1",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3fld2">
    $3=<>
----
j9per5c4

j9per5c44#

我发现csvkit是一个非常有用的工具包,可以在命令行中处理csv文件。

line='test,t2,t3,"t5,"'
echo $line | csvcut -c 4
"t5,"
echo 'foo,"field,""with"",commas",bar'  | csvcut -c 3
bar

它还包含csvstatcsvstack等工具,这些工具也非常方便。
一个二个一个一个

rm5edbpk

rm5edbpk5#

Awk(gawk)实际上提供了一些扩展,其中之一是csv processing,在我看来,这是使用 gawk 最健壮的方式,它可以处理许多陷阱,并为您解析csv。
假设安装了该扩展,您可以使用awk显示特定csv字段与123匹配的所有行。
假设test.csv包含以下内容:

Name,Phone
"Woo, John",425-555-1212
"James T. Kirk",123

以下代码将打印Phone(也称为第二个字段)等于123的所有行:

gawk -l csv 'csvsplit($0,a) && a[2] == 123 {print a[1]}'

输出为:

James T. Kirk

它是如何工作的?

  • -l csv要求gawk通过在$AWKLIBPATH中查找csv扩展来加载它;
  • csvsplit($0, a)拆分当前行,并将每个字段存储到名为a的新数组中
  • && a[2] == 123检查第二个字段是否为123
  • 如果两个条件都为真,则它{ print a[1] },aka打印该行的第一个csv字段。
hvvq6cgz

hvvq6cgz6#

如果你使用的是一个常见的AWK解释器(Gawk、onetrueawk、mawk),那么其他的解决方案是你最好的选择。然而,如果你能够使用不同的解释器,frawk和GoAWK都有内置的CSV支持。
frawk是用Rust编写的一个非常快的AWK实现。使用-i csv来处理CSV模式下的输入。注意,frawk并不完全兼容POSIX(查看差异)。
GoAWK是一个用Go语言编写的POSIX兼容的AWK实现,也支持-i csv模式,以及@"named_field"语法(read more)的-H(解析头行)。* 免责声明:我是GoAWK的作者。
对于问题中的file.csv,您可以简单地使用AWK脚本,在字段上执行常规for循环,如下所示:

$ cat records.awk
{
    printf "Record %d:\n", NR
    for (i=1; i<=NF; i++)
        printf "    $%d=<%s>\n", i, $i
    print "----"
}

然后使用frawk -i csvgoawk -i csv获取预期输出。例如:

$ frawk -i csv -f records.awk file.csv
Record 1:
    $1=<rec1, fld1>
    $2=<>
    $3=<rec1","fld3.1
",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3,fld2">
    $3=<>
----

$ goawk -i csv -f records.awk file.csv 
Record 1:
... same as above ...
----

相关问题