使用awk或perl从CSV中提取特定列(解析)

7

背景 - 我想从一个csv文件中提取特定的列。该csv文件以逗号分隔,使用双引号作为文本限定符(可选,但当字段包含特殊字符时,限定符将存在-请参见示例),并使用反斜杠作为转义字符。某些字段为空也是可能的。


示例输入和期望输出 - 例如,我只希望输出文件中有第1、3和4列。从csv文件提取的最终列应与原始文件的格式相匹配。不应删除转义字符或添加额外的引号等。

输入

"John \"Super\" Doe",25,"123 ABC Street",123-456-7890,"M",A
"Jane, Mary","",132 CBS Street,333-111-5332,"F",B
"Smith \"Jr.\", Jane",35,,555-876-1233,"F",
"Lee, Jack",22,123 Sesame St,"","M",D

期望的输出

"John \"Super\" Doe","123 ABC Street",123-456-7890
"Jane, Mary",132 CBS Street,333-111-5332
"Smith \"Jr.\", Jane",,555-876-1233
"Lee, Jack",123 Sesame St,""

初步脚本(awk) - 下面是我找到的一个初步脚本,它大部分情况下都有效,但我注意到它在某些特定情况下无法工作,可能还有其他我没有看到或想到的情况。

#!/usr/xpg4/bin/awk -f

BEGIN{  OFS = FS = ","  }

/"/{
    for(i=1;i<=NF;i++){
        if($i ~ /^"[^"]+$/){
            for(x=i+1;x<=NF;x++){
                $i=$i","$x
                if($i ~ /"+$/){
                    z = x - (i + 1) + 1
                    for(y=i+1;y<=NF;y++)
                        $y = $(y + z)
                    break
                }
            }
            NF = NF - z
            i=x
        }
    }
print $1,$3,$4
}

以上方法在遇到包含转义双引号和逗号的字段时,会出现解析错误并导致输出结果不正确。


问题/评论 - 我了解到awk不是解析csv文件的最佳选项,建议使用perl。然而,我完全不懂perl。我找到了一些perl脚本示例,但它们没有给出我想要的输出结果,而且我不知道如何轻松地编辑脚本以满足我的需求。

至于awk,我熟悉它并偶尔使用其基本功能,但我不了解一些高级功能,例如上面脚本中使用的某些命令。是否可以只使用awk来实现我的期望输出?如果可以,是否可以编辑上面的脚本以解决我遇到的问题?能否有人逐行解释一下脚本正在做什么?

感谢任何帮助!


建议使用Perl而不是awk的原因是因为前者具有执行向前/向后查找断言以区分字段分隔符和内部字段值的能力。 - SiegeX
4
抱歉,@SiegeX,你的看法是完全错误的。建议使用Perl而不是awk,因为CPAN上有100%可用、完整(或几乎完整)调试的稳定生产质量CSV解析模块,这样就不必重新发明(效果差的)轮子了。具体而言,Text::CSV通常被认为是一个经典模块。 - DVK
有没有特别的原因禁止“额外添加引号”部分?此外,对于输入文件中的字段,引号是否遵守某些100%不变规则标准?(例如:“引用包含空格、逗号或引号的字段”)? - DVK
@DVK,没有这样的规定。使用引号与否是随机的。 - ikegami
@DVK - 没有理由禁止添加额外的引号,正如ikegami所提到的那样。我只是想强调我希望输出文件尽可能接近原始格式。 - yousir
7个回答

10

我不打算重新发明轮子

use Text::CSV_XS;

my $csv = Text::CSV_XS->new({
   binary      => 1,
   escape_char => '\\',
   eol         => "\n",
});

my $fh_in  = \*STDIN;
my $fh_out = \*STDOUT;

while (my $row = $csv->getline($fh_in)) {
   $csv->print($fh_out, [ @{$row}[0,2,3] ])
      or die("".$csv->error_diag());
}

$csv->eof()
   or die("".$csv->error_diag());

输出:

"John \"Super\" Doe","123 ABC Street",123-456-7890
"Jane, Mary","132 CBS Street",333-111-5332
"Smith \"Jr.\", Jane",,555-876-1233
"Lee, Jack","123 Sesame St",

它在没有引号的地址周围添加引号,但由于有些地址已经有引号了,所以你显然可以处理它们。


重复造轮子:

my $field = qr/"(?:[^"\\]|\\.)*"|[^"\\,]*/s;
while (<>) {
   my @fields = /^($field),$field,($field),($field),/
      or die;
   print(join(',', @fields), "\n");
}

输出:

"John \"Super\" Doe","123 ABC Street",123-456-7890
"Jane, Mary",132 CBS Street,333-111-5332
"Smith \"Jr.\", Jane",,555-876-1233
"Lee, Jack",123 Sesame St,""

谢谢你的解决方案。不幸的是,我无法使用你的第一个解决方案,因为我使用的机器没有Text::CSV_XS模块,我也无法安装它。第二个(重新设计的)解决方案适用于我所需的内容。但是,唯一的问题是指定要打印哪些列的部分。是否有一种类似于第一个解决方案的方式来指定要使用哪些列,只需列出列号即可?可能,我的csv文件可以有数百列,我需要能够轻松更改要解析的列。 - yousir
1
@yousir - 你可以使用 Text::CSV 替代。它是纯 Perl 的。 - DVK
@yousir,我没有让你选择其他列,因为那不是你的问题。但实际上,动态构建模式以选择其他列是微不足道的。 - ikegami
@ikegami - 抱歉,我应该表述得更清楚。我试图概括这个概念。我该如何使用Perl生成该列表?例如,我有一个变量"columns",以以下格式列出要打印的确切列:"1,3,4,6,99,etc"。但我在将其合并到您的脚本中遇到了问题。 - yousir
1
@ikegami - 除非我跟错了指示,否则我确实需要额外的权限才能从CPAN安装模块。不过,无论如何,我已经找到了一种解决方法来“安装”Text:CSV并使用您的第一个脚本来实现我想要的功能。我只需要将Text:CSV源代码中的CSV.pm和CSV_PP.pm放在名为“Text”的文件夹中,放在脚本的工作目录中即可。 - yousir
显示剩余5条评论

2
我建议使用 Python 的 csv 模块:
#!/usr/bin/env python3
import csv
rdr = csv.reader(open('input.csv'), escapechar='\\')
wtr = csv.writer(open('output.csv', 'w'), escapechar='\\', doublequote=False)
for row in rdr:
    wtr.writerow(row[0:1]+row[2:4])

output.csv

John \"Super\" Doe,123 ABC Street,123-456-7890
"Jane, Mary",132 CBS Street,333-111-5332
"Smith \"Jr.\", Jane",,555-876-1233
"Lee, Jack",123 Sesame St,

在存在双引号的情况下删除它们比在没有任何引号的情况下添加一些更糟糕。 - ikegami

0

GNU awk 解决方案。只需将轮子用作轮子即可。您可以使用FPAT定义字段的外观,就像这样:

$ awk -vFPAT='[^,]+|"[^"]*"' -vOFS=, '{print $1, $3, $4}' file

这导致:

"John \"Super\" Doe","123 ABC Street",123-456-7890
"Jane, Mary",132 CBS Street,333-111-5332
"Smith \"Jr.\",35,555-876-1233
"Lee, Jack",123 Sesame St,""

正则表达式的解释:

[^,]+           # 1 or more occurrences of anything that's not a comma, 
|               # OR
"[^"]*"         # 0 or more characters unequal to '"' enclosed by '"'

阅读gawk手册中有关FPAT的内容。

现在,让我们来逐步了解你的脚本。基本上它试图重写你的字段外观。首先,你通过“,”进行分割,这显然会引起一些问题。接下来,它查找没有被“"”正确关闭的字段。

BEGIN{OFS=FS =","}                        # set field sep (FS) and output field 
                                          #   sep to ,
/"/{                                      # for each line matching '"'
    for(i=1;i<=NF;i++){                   # loop through fields 1 to NF
        if($i ~ /^"[^"]+$/){              # IF field $i start with '"', followed by
                                          #   non-quotes
            for(x=i+1;x<=NF;x++){         # loop through ALL following fields
                $i=$i","$x                # concatenate field $i with ALL following 
                                          #   fields, separated by ","
                if($i ~ /"+$/){           # IF field $i ends with '"'
                    z = x - (i + 1) + 1   # z is index of field we're looking at next
                    for(y=i+1;y<=NF;y++)  
                        $y = $(y + z)     # change contents of following fields to 
                                          #   contents of field, z steps further
                                          #   down the line
                    break                 # break out of for(x) loop
                }
            }
            NF = NF - z                   # reset number of fields
            i=x                           # continue loop for(i) at index x
        }
    }
 print $1,$3,$4
}

你的脚本在这一行输入上失败了:

"Smith \"Jr.\", Jane",35,,555-876-1233,"F",

仅仅是因为$i ~ /^"[^"]+$/在$1上失败了。

我希望你同意我的观点,重新编写这些字段可能会很棘手。更重要的是,这就像“哦,我喜欢awk,但我要像C / perl / python一样使用它。” 使用FPAT是一个更简短的解决方案,可以说是至少如此。


0

在我发布之前,我现在看到这是一个被已删除的答案推动的旧问题,但是,我认为我仍然可以利用这个机会展示Tie::Array::CSV,它使得CSV文件操作就像使用Perl数组一样容易。完全披露:我是作者。

无论如何,这里是脚本。OP的数据需要更改转义字符和Perl索引从0开始的数组,但除此之外,这应该相当易读。

#!/usr/bin/env perl

use strict;
use warnings;

use Tie::Array::CSV;

my $opts = { text_csv => { escape_char => '\\' } };

tie my @input,  'Tie::Array::CSV', 'data', $opts or die "Cannot open file 'data': $!";
tie my @output, 'Tie::Array::CSV', 'out',  $opts or die "Cannot open file 'out': $!";

for my $row (@input) {
  my @slice = @{ $row }[0,2,3];
  push @output, \@slice;
}

话虽如此,如果我将最后一个循环转换为(在我看来)更令人印象深刻的形式,我认为它不会失去太多可读性:

push @output, [ @{$_}[0,2,3] ] for @input;

0

csvkit 是一种处理 csv 文件的工具,可以执行诸如此类的操作(以及其他功能)。

另请参见 csvcut。它的命令行界面紧凑,可以处理多种 csv 格式(tsv、其他分隔符、编码、转义字符等)

您所要求的可以使用以下方式完成:

csvcut --columns 0,2,3 input.csv

0
以下命令将从sample.csv文件中提取由逗号分隔的所需字段(例如第一、三和四个字段),并在控制台中显示输出。 cut -f1,3,4 -d',' sample.txt 如果您想将输出存储到新的csv文件中,则可以将输出重定向到文件中,如下所示 cut -f1,3,4 -d',' sample.txt > newSample.csv


0

我犯了一些错误,希望现在已经纠正过来了。

awk '{sub(/y",""/,"y\42")sub(/,2.|,3./,"")sub(/,".",.*/,"")}1' file

"John \"Super\" Doe","123 ABC Street",123-456-7890
"Jane, Mary",132 CBS Street,333-111-5332
"Smith \"Jr.\", Jane",,555-876-1233
"Lee, Jack",123 Sesame St,""

第二行的输出与 OP 的要求不符。 - Marc Lambrichs

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