使用gawk解析CSV文件

14

如何使用gawk解析CSV文件?仅设置FS=","是不够的,因为包含逗号的引用字段将被视为多个字段。

以下示例使用FS=","无法正常工作:

文件内容:

one,two,"three, four",five
"six, seven",eight,"nine"

gawk脚本:

BEGIN { FS="," }
{
  for (i=1; i<=NF; i++) printf "field #%d: %s\n", i, $(i)
  printf "---------------------------\n"
}

错误的输出:

field #1: one
field #2: two
field #3: "three
field #4:  four"
field #5: five
---------------------------
field #1: "six
field #2:  seven"
field #3: eight
field #4: "nine"
---------------------------

期望的输出结果:

field #1: one
field #2: two
field #3: "three, four"
field #4: five
---------------------------
field #1: "six, seven"
field #2: eight
field #3: "nine"
---------------------------

1
请参见:https://dev59.com/XlcO5IYBdhLWcg3wrTcs - Sundeep
9个回答

16

gawk版本4手册建议使用FPAT = "([^,]*)|(\"[^\"]+\")"

当定义FPAT时,它会禁用FS并按内容指定字段,而不是按分隔符。


1
FPAT的概念很有趣。但是所引用的正则表达式不允许在引号字符串内包含双引号。这需要一个更复杂的正则表达式,例如:FPAT="([^,]*)|(\"([^\"]|\"\")+\"[^,]*)"。最后的[^,]*允许出现以引号开头的格式错误字段,例如"abc"def,;它将def视为该字段的一部分。在双引号内,可以接受两个连续的双引号。这些东西非常棘手,这就是为什么除非CSV数据干净简单,否则CSV特定模块通常是处理CSV数据的最佳方式。 - Jonathan Leffler
2
FPAT 需要 gawk 4。花了我一些时间... ;) - Richard Kiefer
我已经使用gawk设置了一个别名,以便轻松运行gawk在CSV上:alias awkcsv="gawk -v FPAT='([^,]+)|(\"[^\"]+\")'" - DeegC

14
简短的回答是,“如果CSV文件包含棘手的数据,例如CSV字段数据中有逗号,我不会使用gawk来解析CSV”,其中“棘手”意味着 CSV 数据的复杂性。
接下来的问题是:“您将进行什么其他处理”,因为这将影响您使用的其他方法。
我可能会使用Perl和Text::CSV或Text::CSV_XS模块来读取和处理数据。请记住,Perl最初在某种程度上是作为awk和sed的替代品而编写的 - 这就是为什么Perl中仍然分发a2p和s2p程序,它们可以将awk和sed脚本(分别)转换为Perl的原因。

1
请参阅 csvfix 程序。当然,Python(以及Ruby、Tcl和大多数其他可扩展的脚本语言)可以代替Perl使用;这取决于个人口味或公司强制要求(霍布森选择)。 - Jonathan Leffler
我在过去的一个小时里一直使用gawk中的FPAT,但它无法处理许多现实世界中的情况,比如空字段和字段内的双引号,正如所指出的那样。是时候转向更强大的工具了! - pedz
如果你不特别想要写Perl,并且了解awk,那么frawk比Perl更适合。 - undefined
@saolof — 提供一个frawk的链接以及一个简要解释为什么它可以处理CSV会很有帮助。我注意到One True Awk有一个直接处理CSV的选项。世界在2008年以后发生了一些变化,但也有很多事情仍然非常相似。 - undefined
Ah,frawk基本上是一个类似AWK的方言,恰好内置了对CSV的支持:https://github.com/ezrosent/frawk它有改变AWK的优点和缺点,为了使其能够高效编译(如果没有变量存在时间超过一行,则可以并行化),因此在处理大型CSV文件时非常快速,并且提供了更好的错误消息。大部分AWK书籍仍然可以在其上运行,但与nawk和gawk不同,它以稍微不太动态的方式扩展了规范的解释。 - undefined
对于大多数人来说,我建议使用一个广泛使用的posix AWK。我只是建议不要使用Perl,部分原因是因为回溯正则表达式的风险,但也因为AWK方言的发展更好,而且AWK在何时应该转向其他工具方面有更清晰的范围。 - undefined

4

4
你可以使用一个简单的包装函数csvquote来清理输入数据,并在awk处理完后进行还原。在开始和结束时将数据通过它进行传输,一切都应该能够正常工作:

之前:


gawk -f mypgoram.awk input.csv

之后:

csvquote input.csv | gawk -f mypgoram.awk | csvquote -u

请查看https://github.com/dbro/csvquote获取代码和文档。

2

csv2delim.awk

# csv2delim.awk converts comma delimited files with optional quotes to delim separated file
#     delim can be any character, defaults to tab
# assumes no repl characters in text, any delim in line converts to repl
#     repl can be any character, defaults to ~
# changes two consecutive quotes within quotes to '

# usage: gawk -f csv2delim.awk [-v delim=d] [-v repl=`"] input-file > output-file
#       -v delim    delimiter, defaults to tab
#       -v repl     replacement char, defaults to ~

# e.g. gawk -v delim=; -v repl=` -f csv2delim.awk test.csv > test.txt

# abe 2-28-7
# abe 8-8-8 1.0 fixed empty fields, added replacement option
# abe 8-27-8 1.1 used split
# abe 8-27-8 1.2 inline rpl and "" = '
# abe 8-27-8 1.3 revert to 1.0 as it is much faster, split most of the time
# abe 8-29-8 1.4 better message if delim present

BEGIN {
    if (delim == "") delim = "\t"
    if (repl == "") repl = "~"
    print "csv2delim.awk v.m 1.4 run at " strftime() > "/dev/stderr" ###########################################
}

{
    #if ($0 ~ repl) {
    #   print "Replacement character " repl " is on line " FNR ":" lineIn ";" > "/dev/stderr"
    #}
    if ($0 ~ delim) {
        print "Temp delimiter character " delim " is on line " FNR ":" lineIn ";" > "/dev/stderr"
        print "    replaced by " repl > "/dev/stderr"
    }
    gsub(delim, repl)

    $0 = gensub(/([^,])\"\"/, "\\1'", "g")
#   $0 = gensub(/\"\"([^,])/, "'\\1", "g")  # not needed above covers all cases

    out = ""
    #for (i = 1;  i <= length($0);  i++)
    n = length($0)
    for (i = 1;  i <= n;  i++)
        if ((ch = substr($0, i, 1)) == "\"")
            inString = (inString) ? 0 : 1 # toggle inString
        else
            out = out ((ch == "," && ! inString) ? delim : ch)
    print out
}

END {
    print NR " records processed from " FILENAME " at " strftime() > "/dev/stderr"
}

test.csv

"first","second","third"
"fir,st","second","third"
"first","sec""ond","third"
" first ",sec   ond,"third"
"first" , "second","th  ird"
"first","sec;ond","third"
"first","second","th;ird"
1,2,3
,2,3
1,2,
,2,
1,,2
1,"2",3
"1",2,"3"
"1",,"3"
1,"",3
"","",""
"","""aiyn","oh"""
"""","""",""""
11,2~2,3

test.bat

rem test csv2delim
rem default is: -v delim={tab} -v repl=~
gawk                      -f csv2delim.awk test.csv > test.txt
gawk -v delim=;           -f csv2delim.awk test.csv > testd.txt
gawk -v delim=; -v repl=` -f csv2delim.awk test.csv > testdr.txt
gawk            -v repl=` -f csv2delim.awk test.csv > testr.txt

1

我不确定这是否是正确的做法。我宁愿在一个csv文件中工作,其中所有值都要么被引用,要么都不被引用。顺便说一下,awk允许使用正则表达式作为字段分隔符。检查一下是否有用。


我也会选择正则表达式方法,并尝试让它匹配类似于^"|","|"$的内容(这只是一个快速尝试,当然你需要转义双引号,我希望保持简单)。 - flolo

1
{
  ColumnCount = 0
  $0 = $0 ","                           # Assures all fields end with comma
  while($0)                             # Get fields by pattern, not by delimiter
  {
    match($0, / *"[^"]*" *,|[^,]*,/)    # Find a field with its delimiter suffix
    Field = substr($0, RSTART, RLENGTH) # Get the located field with its delimiter
    gsub(/^ *"?|"? *,$/, "", Field)     # Strip delimiter text: comma/space/quote
    Column[++ColumnCount] = Field       # Save field without delimiter in an array
    $0 = substr($0, RLENGTH + 1)        # Remove processed text from the raw data
  }
}

遵循此模式的模式可以访问Column[]中的字段。 ColumnCount指示在Column[]中找到的元素数量。 如果不是所有行都包含相同数量的列,则在处理较短的行时,Column[]在Column[ColumnCount]之后包含额外的数据。

此实现速度较慢,但它似乎模拟了先前答案中提到的gawk >= 4.0.0中发现的FPAT/patsplit()功能。

参考资料


0

Perl有Text::CSV_XS模块,专门处理带引号的逗号问题。
或者尝试使用Text::CSV模块。

perl -MText::CSV_XS -ne 'BEGIN{$csv=Text::CSV_XS->new()} if($csv->parse($_)){@f=$csv->fields();for $n (0..$#f) {print "field #$n: $f[$n]\n"};print "---\n"}' file.csv

生成此输出:

field #0: one
field #1: two
field #2: three, four
field #3: five
---
field #0: six, seven
field #1: eight
field #2: nine
---

以下是易于人类阅读的版本。
将其保存为parsecsv,赋予执行权限chmod +x,并以“parsecsv file.csv”运行。

#!/usr/bin/perl
use warnings;
use strict;
use Text::CSV_XS;
my $csv = Text::CSV_XS->new();
open(my $data, '<', $ARGV[0]) or die "Could not open '$ARGV[0]' $!\n";
while (my $line = <$data>) {
    if ($csv->parse($line)) {
        my @f = $csv->fields();
        for my $n (0..$#f) {
            print "field #$n: $f[$n]\n";
        }
        print "---\n";
    }
}

你可能需要在你的机器上指向不同版本的perl,因为Text::CSV_XS模块可能没有安装在你默认的perl版本上。

Can't locate Text/CSV_XS.pm in @INC (@INC contains: /home/gnu/lib/perl5/5.6.1/i686-linux /home/gnu/lib/perl5/5.6.1 /home/gnu/lib/perl5/site_perl/5.6.1/i686-linux /home/gnu/lib/perl5/site_perl/5.6.1 /home/gnu/lib/perl5/site_perl .).
BEGIN failed--compilation aborted.

如果您的Perl版本中没有安装Text::CSV_XS,您需要执行以下操作:
sudo apt-get install cpanminus
sudo cpanm Text::CSV_XS


0

这是我想出来的方案。欢迎提出评论和/或更好的解决方案。

BEGIN { FS="," }
{
  for (i=1; i<=NF; i++) {
    f[++n] = $i
    if (substr(f[n],1,1)=="\"") {
      while (substr(f[n], length(f[n]))!="\"" || substr(f[n], length(f[n])-1, 1)=="\\") {
        f[n] = sprintf("%s,%s", f[n], $(++i))
      }
    }
  }
  for (i=1; i<=n; i++) printf "field #%d: %s\n", i, f[i]
  print "----------------------------------\n"
}

基本思路是我遍历字段,任何以引号开头但未以引号结尾的字段都会被附加上下一个字段。

这看起来更像是C语言.. 我们是否在用正确的工具做正确的工作?虽然我是awk的新手,但我想不出任何简单直接的解决方案.. - Vijay Dev
@Vijay Dev,"novice" 的意思是初学者,而不是专家。 - Robert Gamble
啊,我的英语!!我想说的是 - “我是一个新手,所以我想不出任何简单直接的解决方案”。 - Vijay Dev
请注意,这段代码可以运行,但需要在最后一行加上“n=0”才能正确处理多行文件。 - GoldenBoy
请注意,有效的字段可能是:"""Jump"", he said!"。这将在逗号处分割,但逗号前面的字符是双引号。脚本在逗号处分割,尽管不应该,因为逗号嵌入在引用字段中。在逗号之前的奇数个双引号表示字段结束;偶数个则不表示。 - Jonathan Leffler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接