Lo hice y lo entendí

El blog de Vicente Navarro
13 abr

Expresiones Regulares en la shell. Ejemplos de uso con grep, awk y sed.

Estaba intentando hacer un CGI muy sencillo usando bash (como lo que quería hacer era algo muy específico de bash, no he querido usar ni Perl, ni PHP). La única dificultad que he encontrado ha sido obtener las distintas variables que el método GET pasa al CGI a través de la variable $QUERY_STRING, que normalmente tiene este aspecto:

QUERY_STRING='parametro1=valor1&parametro2=valor2&parametro3=valor3'

Estaba yo ya comenzando a darle vueltas a una Expresión Regular para interpretar dicha salida cuando he decidido buscar en Google para ver si alguien tenía una solución más completa que la que yo ya tenía a medias. He llegado al artículo CGI Scripting Tips for Bash or SH que propone lo siguiente para extraer del QUERY_STRING la variable que nos interese:

PARAMETROX=`echo "$QUERY_STRING" | grep -oE "(^|[?&])parametrox=[^&]+" | sed "s/%20/ /g" | cut -f 2 -d "="`

Y es que con bash y sus compañeros sed, awk, cat, grep, etc. aún se puede hacer casi de todo, incluso un weblog o un servidor web. Como las Expresiones Regulares son tan excepcionalmente útiles como complicadas a la hora de usarlas, me he decidido a comentar algunos ejemplos, comenzando por el que me ha llevado a este artículo.

Y antes de seguir conviene comentar que la opción -o del grep del comando anterior indica que la salida del comando ha de contener únicamente la parte de texto que coincide con el patrón, no toda la línea. La opción -E indica que se va a usar una Expresión Regular Extendida (egrep es lo mismo que grep -E).

Pasemos a analizar la expresión regular… (^|[?&])parametrox=[^&]+ significa que buscamos algo que:

^|[?&] está a principio de línea ^ o | que comienza por un caracter ? o un &
parametrox=   seguido por la secuencia de caracteres parametrox=
[^&]+ y que acaba con uno o más caracteres + que no sean &

Ejemplos de Expresiones Regulares

La página http://www.regular-expressions.info es un excelente sitio para aprender sobre expresiones regulares. Allí he encontrado algunos ejemplos que me han gustado Éstos son tres de ellos especialmente útiles:

Buscar una dirección de e-mail
\b[A-Z0-9._%-]+@[A-Z0-9.-]+\.[A-Z]{2,4}\b
\b[A-Z0-9._%-]+   Buscamos un comienzo de palabra \b que contenga uno o más caracteres + dentro del conjunto A a Z, 0 a 9,., _, % y -, que son los caracteres admitidos para un nombre de usuario
@ seguido del símbolo @
[A-Z0-9.-]+ seguido por uno o más + caracteres A a Z, 0 a 9,. y -, que son los caracteres admitidos para un dominio
\. seguido por un punto escapado con \, ya que el punto solo significa cualquier caracter
[A-Z]{2,4}\b seguido por entre 2 y 4 caracteres {2,4} entre el conjunto A-Z y además éste tiene que ser el final de una palabra \b

Por tanto, si usáramos esa expresión regular con egrep sobre un texto como este:

En un lugar de la Mancha, de cuyo nombre no quiero acordarme, pero a cuyo ayuntamiento podíamos enviar e-mails a la dirección ayuntamiento@lugardelamancha.es, no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor cuyo e-mail era don.quijote@del.toboso.edu.

obtendríamos:

$ egrep -oi '\b[A-Z0-9._%-]+@[A-Z0-9.-]+\.[A-Z]{2,4}' donqui.txt
ayuntamiento@lugardelamancha.es
don.quijote@del.toboso.edu
Buscar dos palabras cercanas en un párrafo

Si consideramos que dos palabras cercanas son aquellas que están separadas entre 1 y 8 palabras, esta expresión regular es perfecta:

\bpalabra1\W+(w+\W+){1,8}palabra2\b
\bpalabra1 Buscamos un comienzo de palabra con la sucesión de caracteres palabra1
\W+ seguido por uno o varios caracteres no alfanuméricos \W
(\w+\W+){1,8}   seguido de entre 1 y 8 grupos {1,8} de uno o más caracteres alfanuméricos \w y uno o más caracteres no alfanuméricos \W
palabra2\b acabado un la sucesión de caracteres palabra2

Y podemos probar la expresión sobre el texto anterior:

$ egrep -o '\bhidalgo\W+(\w+\W+){1,8}lanza\b' donqui.txt
hidalgo de los de lanza

Por supuesto, puede ser que no sepamos si palabra1 va antes que palabra2, así que podemos especificar que una cosa o | la otra:

\b(palabra1\W+(\w+\W+){1,8}palabra2|palabra2\W+(\w+\W+){1,8}palabra1)\b
Buscar una dirección IP
\b[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\b

Creo que este ejemplo ya no necesita explicación, aunque tiene un defecto: la expresión encajaría también con 999.999.999.999. Para corregirlo, podríamos sustituir cada bloque [0-9]{1,3} por (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) que significa que cada número puede ser del 250 al 255 25[0-5] o del 200 al 249 2[0-4][0-9] o del 0 al 199, contemplando la posibilidad de que por ejemplo un 7 se escriba 7, 07 o 007 [01]?[0-9][0-9]?. El símbolo ? indica uno o ninguno.

Podemos obtener fácilmente una lista de IPs que han visitado nuestro servidor web con el siguiente comando:

$ egrep -o '\b[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\b' access.log | sort -u

Ejemplos de uso de las Expresiones Regulares en la shell

En el uso diario de la shell, las expresiones regulares son nuestra navaja suiza. Ya hemos visto algunos ejemplos con grep, pero sigamos con algunos más de cosecha propia que usan grep, awk y sed.

Lista de usuarios con password a partir del /etc/shadow

Resulta que queremos saber los usuarios del fichero /etc/shadow que pueden logearse al sistema (que no tienen un caracter ! o * en el campo del password). La expresión regular a usar sería:

# egrep '^[^:]+:[^*!]+:' /etc/shadow
fulano:$1$PFC1F2pj$0KEflTjNmDQg9.rtFT/DK0:12793:0:99999:7:::
mengano:$1$aAUa4js/$c2LEh.HeT0JvCG3B.34O.:12793:0:99999:7:::
^[^:]+ Buscamos uno o más caracteres + que no sean : [^:] a principio de línea ^
: seguido de un caracter :
[^*!]+ seguido por una secuencia de uno o más caracteres que no sean ! o *
: seguido de otro caracter :

Podemos hacerlo un poco mejor, porque sólo nos interesa ver el nombre del usuario. Para ello podríamos hacer un cut -f 1 -d : sobre la salida anterior, pero usando el awk somos capaces de hacer lo anterior en un sólo comando:

# awk -F":" '$2~/[^!*]+/ { print $1 }' /etc/shadow
fulano
mengano

En el comando anterior le decimos a awk que, usando como separador de campos el caracter : (-F”:”) queremos que nos saque el primer campo { print $1 } siempre que el segundo campo cumpla la regla de que contenga uno o más caracteres que no sean ! o * $2~/[^!*]+/.

Extraer los referer del access.log

Si tenemos líneas como la siguiente (LogFormat combined) en el access.log de nuestro apache:

192.168.1.3 - - [12/Apr/2007:22:15:14 +0200] "GET /blog/ HTTP/1.1" 200 46503 "http://www.google.es/search?hl=es&q=vicente+navarro" "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.8.1.3) Gecko/20070310 Iceweasel/2.0.0.3 (Debian-2.0.0.3-1)"
192.168.1.3 - - [12/Apr/2007:22:24:07 +0200] "GET /blog/ HTTP/1.1" 200 46503 "http://www.google.es/search?q=super+coco&hl=es&start=10&sa=N" "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.8.1.3) Gecko/20070310 Iceweasel/2.0.0.3 (Debian-2.0.0.3-1)"

podemos obtener un listado de los referers que han traído a los visitantes a la página con el siguiente comando:

$ egrep -o '^[0-9.]+ - - \[.+\] \"[^"]+\" [0-9-]+ [0-9-]+ \"[^"]+\"' access.log | egrep -o '\"[^"-]+\"$' | egrep -o '[^"]+' | sort -u
...

http://www.google.es/search?hl=es&q=vicente+navarro


http://www.google.es/search?q=super+coco&hl=es&start=10&sa=N

...

Hay dos grep’s, porque estamos identificando y extrayendo todo el texto hasta llegar al referer para dejarlo al final y después extraerlo con el segundo grep. Pero aunque sirva para ilustrar la entrada, resulta que nos hemos complicado muchísimo la vida, porque el trozo del referer es el único que comienza con http://. Teniendo en cuenta esto, el comando, con una Expresión Regular mucho más simple, queda:

$ egrep -o 'http://[^"]+' access.log | sort -u

Y usando sólo awk:

awk '$0~/http/ { print $11 }' access.log | awk -F'"' '{ print $2 }' | sort -u

Pero ahora queremos saber a partir de los referers que hemos obtenido anteriormente, con qué parámetros de búsqueda y en qué posición aparece nuestra página en Google. Para ello, podríamos usar la Expresión Regular del principio para obtener los parámetros q= y start= de la URL de Google, pero hagámoslo con awk:

$ egrep -o 'http://[^"]+' big | sort -u | awk -F "[?&=]" '$1~/google.+search/ { TERMINOBUSQUEDA=""; POSICION="0"; for (i=2; i<=NF; i++) { if ($(i-1)~"(^|^as_)q$") { TERMINOBUSQUEDA=$i; } if ($(i-1)~"^start$") { POSICION=$i; } } print TERMINOBUSQUEDA " " POSICION; }'
super+coco 10
vicente+navarro 0

Es decir, si la línea es una búsqueda de Google, el awk ha de hacer:

TERMINOBUSQUEDA="";
POSICION="0"; 
for (i=2; i<=NF; i++)  {
   if ($(i-1)~"(^|^as_)q$") { TERMINOBUSQUEDA=$i;}
   if ($(i-1)~"^start$") { POSICION=$i; }
}
print TERMINOBUSQUEDA " " POSICION;
Expresiones Regulares de Perl (PCRE)

Las Expresiones Regulares de Perl (PCRE) son más potentes que las Expresiones Regulares POSIX. En algunas distribuciones, el grep -P nos permite usarlas, pero en Debian no es posible usar la opción -P de grep. En cambio, tenemos el pcregrep que sí que nos lo permite. Y una de las cosas más interesantes que se puede hacer con estas reglas es Lookahead y Lookbehind. Supongamos que queremos sacar de las URL de Google el valor del parámetro q= con un sólo comando, a diferencia de la expresión del principio que requería un cut adicional:

$ echo "http://www.google.es/search?q=super+coco&hl=es&start=10&sa=N" | pcregrep -o '(?<=[&?]q=)[^&?]+'
super+coco

Usando PCRE, algo como (?<=delante)detras significa que para coincidir, la cadena tiene que ser delantedetras, pero delante no aparecerá en la cadena coincidente. ¡Muy útil! La pena es que este tipo de expresiones no se puede usar con cadenas de longitud variable:

$ pcregrep -o '(?<=\w+)prueba' fichero.txt
pcregrep: Error in command-line regex at offset 7: lookbehind assertion is not fixed length
Ejemplos con sed y el /etc/passwd

sed es otra utilidad muy versátil. Su función más típica es la de sustitución, aunque también puede hacer cosas sencillas pero útiles como mostrar sólo un conjunto de líneas de un fichero o eliminarlas, insertar texto al final de una línea determinada o reemplazar el texto de esa línea..

La sintaxis típica de sed para reemplazos es:

sed -r 's/reemplazada/reemplazo/g'

El -r es paraque el sed use Expresiones Regulares Extendidas o POSIX y el g significa que la sustitución se hará todas las veces que se encuentre la Expresión Regular en la línea, no sólo la primera vez. Si nos viene mal usar el carácter / para no tener que escapar, por ejemplo, muchas barras de path, podríamos usar cualquier otro carácter: s_reemplazada_reemplazo_g.

Por ejemplo, si quisiéramos cambiar la shell por defecto a todos los usuarios que aparecen en /etc/passwd, podríamos hacer:

$ sed -r 's/[^:]+$/:\/bin\/bash/g' /etc/passwd
...
mail:x:8:8:mail:/var/mail::/bin/bash
news:x:9:9:news:/var/spool/news::/bin/bash
uucp:x:10:10:uucp:/var/spool/uucp::/bin/bash
...

Y si queremos añadir una cabecera de “usuario_” a todos los nombres de usuario del sistema, podemos usar el carácter & para usar la parte coincidente en la cadena de reemplazo:

$ sed -r 's/^[^:]+/usuario_&/g' /etc/passwd
...
usuario_mail:x:8:8:mail:/var/mail:/bin/sh
usuario_news:x:9:9:news:/var/spool/news:/bin/sh
usuario_uucp:x:10:10:uucp:/var/spool/uucp:/bin/sh
...

Y como lo que pongamos entre paréntesis () en la cadena reemplazada, el sed nos lo guarda para usar en la cadena de reemplazo en \1, \2,… \9, supongamos que queremos mover todo a un nuevo sistema de ficheros y queremos cambiar tanto el home del usuario como su shell:

$ sed -r 's/([^:]+):([^:]+)$/:\/newfs\1:\/newfs\2/g' /etc/passwd
...
mail:x:8:8:mail::/newfs/var/mail:/newfs/bin/sh
news:x:9:9:news::/newfs/var/spool/news:/newfs/bin/sh
uucp:x:10:10:uucp::/newfs/var/spool/uucp:/newfs/bin/sh
...

Enlaces relacionados

Actualización 14/4/07: Un pobrecito hablador (¡muchas gracias!) comenta en la bitácora de Super Coco en Barrapunto otra interesante forma de obtener un parámetro de la QUERY_STRING:

PARAMETROX=`echo "$QUERY_STRING" | sed -r 's/.*\bparametroX=([^&]+).*/\1/'`

Actualización 9/11/07:

Supongamos que encontramos una página que ofrece muchos enlaces a ficheros para descargar de la red P2P eDonkey y nosotros queremos usar el aMule para hacerlo sin tener que copiar o pegar todos los enlaces o hacer click sobre todos ellos. Los enlaces ed2k aparecen en el código HTML de la página así:

<a href="ed2k://|file|ficheroparadescargar.ext|41496576|8709C0CF7F2EC1520F2E31274233E854|/">fichero de la red eDonkey</a>

Pues bien, tras guardar el HTML de la página en cuestión a disco, queremos extraer dichos enlaces para usarlos en el comando ed2k de aMule. Si supiéramos que no hay más de un enlace por línea podríamos hacerlo simplemente con un grep:

ed2k $(grep -E -o 'ed2k://[^/]+/' pagina.html)

Pero como es muy posible que no sea así, casi mejor lo hacemos con un awk:

ed2k $(awk -F '"' '/ed2k/ { for (i=1; i<=NF; i++) if ($i~"^ed2k") { print $i } }' pagina.html)

Actualización 26/11/07:

Extraer el texto existente entre etiquetas <strong></strong> de un fichero HTML:

# pcregrep -o '(?<=<strong>)(.*?)(?=</strong>)' index.html
prueba

Si la probamos con el pcregrep, sólo sacará como máximo una coincidencia por línea y no funcionará si las etiquetas de principio y final están en líneas diferentes.

Cuando la etiqueta de principio tenga algún parámetro como por ejemplo: <strong style="color: black;">, la expresión no funcionará. Sin embargo, con algo como:

# pcregrep -o '<strong\b[^>]*>(.*?)</strong>' index.html
<strong style="color: black;">prueba</strong>

podríamos extraer el texto con las etiquetas, pero hemos tenido que quitar los lookahead y lookbehind porque si no:

# pcregrep -o '(?<=<strong\b[^>]*>)(.*?)(?=</strong>)' index.html
pcregrep: Error in command-line regex at offset 19: lookbehind assertion is not fixed length

Entradas relacionadas

9 Comentarios a “Expresiones Regulares en la shell. Ejemplos de uso con grep, awk y sed.”

Trackbacks y pingbacks:

Tema LHYLE09, creado por Vicente Navarro