¿Cuál es la forma más robusta de analizar de forma eficiente CSV utilizando awk?
(1)
La intención de esta pregunta es proporcionar una respuesta canónica.
Dado un CSV como podría ser generado por Excel u otras herramientas con líneas nuevas incorporadas, comillas dobles incrustadas y campos vacíos como:
$ 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
¿Cuál es la manera más robusta de usar eficientemente awk para identificar los registros y campos separados?
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>
----
por lo que se puede utilizar como esos registros y campos internamente por el resto de la secuencia de comandos awk.
Un CSV válido sería aquel que cumple con RFC 4180 o puede ser generado por MS-Excel.
La solución debe tolerar que el final del registro sea LF ( /n
) como es típico para los archivos UNIX en lugar de CRLF ( /r/n
) como requiere ese estándar y se generarían Excel u otras herramientas de Windows. También tolerará campos sin comillas combinados con campos cotizados. Específicamente, no será necesario tolerar escapes con una barra inclinada anterior (es decir, /"
lugar de ""
) como lo permiten otros formatos CSV. Si tiene eso, agregue un gsub(///"/,"/"/"")
desde el principio lo manejaría y tratar de manejar ambos mecanismos de escape automáticamente en un script haría innecesariamente frágil y complicada la secuencia de comandos.
Si su CSV no puede contener líneas nuevas o comillas dobles escapadas, entonces todo lo que necesita es (con GNU awk para 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>
De lo contrario, sin embargo, la solución más general, robusta y portátil que funcionará con cualquier awk moderno es:
$ cat decsv.awk
function buildRec( i,orig,fpat,done) {
$0 = PrevSeg $0
if ( gsub(/"/,"&") % 2 ) {
PrevSeg = $0 RS
done = 0
}
else {
PrevSeg = ""
gsub(/@/,"@A"); gsub(/""/,"@B") # <"x@foo""bar"> -> <"x@Afoo@Bbar">
orig = $0; $0 = "" # Save $0 and empty it
fpat = "([^" FS "]*)|(/"[^/"]+/")" # Mimic GNU awk FPAT meaning
while ( (orig!="") && match(orig,fpat) ) { # Find the next string matching fpat
$(++i) = substr(orig,RSTART,RLENGTH) # Create a field in new $0
gsub(/@B/,"/"",$i); gsub(/@A/,"@",$i) # <"x@Afoo@Bbar"> -> <"x@foo"bar">
gsub(/^"|"$/,"",$i) # <"x@foo"bar"> -> <x@foo"bar>
orig = substr(orig,RSTART+RLENGTH+1) # Move past fpat+sep in orig $0
}
done = 1
}
return done
}
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>
----
Lo anterior supone terminaciones de línea UNIX de /n
. Con las terminaciones de línea de Windows /r/n
es mucho más simple ya que las "nuevas líneas" dentro de cada campo serán en realidad alimentaciones de línea (es decir, /n
ns) y entonces puede establecer RS="/r/n"
y luego /n
s dentro de los campos no se tratarán como terminaciones de línea.
Funciona simplemente contando cuántos "
s "
están presentes hasta ahora en el registro actual cada vez que se encuentra con el RS
. Si es un número impar, entonces el RS
(presumiblemente /n
pero no tiene que ser así) está en el medio campo y entonces seguir construyendo el registro actual, pero si es par, entonces es el final del registro actual y así podemos continuar con el resto del script procesando el registro ahora completo.
El gsub(/@/,"@A"); gsub(/""/,"@B")
gsub(/@/,"@A"); gsub(/""/,"@B")
convierte cada par de comillas dobles en el registro completo (tenga en cuenta que estos ""
pares solo se pueden aplicar dentro de los campos entrecomillados) a una cadena @B
que no contenga una comilla doble, por lo tanto que cuando dividimos el registro en campos, la coincidencia () no se dispara por citas que aparecen dentro de los campos. El gsub(/@B/,"/"",$i); gsub(/@A/,"@",$i)
restaura las comillas dentro de cada campo individualmente y también convierte las ""
s en "
s realmente representar