7.6. Caso de estudio: análisis de números de teléfono

Por ahora se ha concentrado en patrones completos. Cada patrón coincide, o no. Pero las expresiones regulares son mucho más potentes que eso. Cuando una expresión regular coincide, puede extraer partes concretas. Puede saber qué es lo que causó la coincidencia.

Este ejemplo sale de otro problema que he encontrado en el mundo real, de nuevo de un empleo anterior. El problema: analizar un número de teléfono norteamericano. El cliente quería ser capaz de introducir un número de forma libre (en un único campo), pero quería almacenar por separado el código de área, la troncal, el número y una extensión opcional en la base de datos de la compañía. Rastreando la Web encontré muchos ejemplo de expresiones regulares que supuestamente conseguían esto, pero ninguna era lo suficientemente permisiva.

Éstos son los números de teléfono que había de poder aceptar:

¡Qué gran variedad! En cada uno de estos casos, necesitaba saber que el código de área era 800, la troncal 555, y el resto del número de teléfono era 1212. Para aquellos con extensión, necesitaba saber que ésta era 1234.

Vamos a desarrollar una solución para el análisis de números de teléfono. Este ejemplo le muestra el primer paso.

Ejemplo 7.10. Finding Numbers

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$') 1
>>> phonePattern.search('800-555-1212').groups()            2
('800', '555', '1212')
>>> phonePattern.search('800-555-1212-1234')                3
>>> 
1 Lea siempre una expresión regular de izquierda a derecha. Ésta coincide con el comienzo de la cadena, y luego (\d{3}). ¿Qué es \d{3}? Bien, el {3} significa “coincidir con exactamente tres caracteres”; es una variante de la sintaxis {n,m} que vimos antes. \d significa “un dígito numérico” (de 0 a 9). Ponerlo entre paréntesis indica “coincide exactamente con tres dígitos numéricos y recuérdalos como un grupo que luego te los voy a pedir”. Luego coincide con un guión. Después con otro grupo de exactamente tres dígitos. Luego con otro guión. Entonces con otro grupo de exactamente cuatro dígitos. Y ahora con el final de la cadena.
2 Para acceder a los grupos que ha almacenado el analizador de expresiones por el camino, utilice el método groups() del objeto que devuelve la función search. Obtendrá una tupla de cuantos grupos haya definido en la expresión regular. En este caso, hemos definido tres grupos, un con tres dígitos, otro con tres dígitos, y otro más con cuatro dígitos.
3 Esta expresión regular no es la respuesta final, porque no trabaja con un número de teléfono con extensión al final. Para eso, hace falta aumentar la expresión.

Ejemplo 7.11. Búsqueda de la extensión

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$') 1
>>> phonePattern.search('800-555-1212-1234').groups()             2
('800', '555', '1212', '1234')
>>> phonePattern.search('800 555 1212 1234')                      3
>>> 
>>> phonePattern.search('800-555-1212')                           4
>>> 
1 Esta expresión regular es casi idéntica a la anterior. Igual que antes buscamos el comienzo de la cadena, luego recordamos un grupo de tres dígitos, luego un guión, un grupo de tres dígitos a recordar, un guión, un grupo de cuatro dígitos a recordar. Lo nuevo es que ahora buscamos otro guión, y un grupo a recordar de uno o más dígitos, y por último el final de la cadena.
2 El método groups() devuelve ahora una tupla de cuatro elementos, ya que la expresión regular define cuatro grupos a recordar.
3 Desafortunadamente, esta expresión regular tampoco es la respuesta definitiva, porque asume que las diferentes partes del número de teléfono las separan guiones. ¿Qué pasa si las separan espacios, comas o puntos? Necesita una solución más general para coincidir con varios tipos de separadores.
4 ¡Vaya! No sólo no hace todo lo que queremos esta expresión, sino que incluso es un paso atrás, porque ahora no podemos reconocer números sin una extensión. Eso no es lo que queríamos; si la extensión está ahí, queremos saberla, pero si no, aún queremos saber cuales son las diferentes partes del número principal.

El siguiente ejemplo muestra la expresión regular que maneja separadores entre diferentes partes del número de teléfono.

Ejemplo 7.12. Manejo de diferentes separadores

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$') 1
>>> phonePattern.search('800 555 1212 1234').groups()                   2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212-1234').groups()                   3
('800', '555', '1212', '1234')
>>> phonePattern.search('80055512121234')                               4
>>> 
>>> phonePattern.search('800-555-1212')                                 5
>>> 
1 Agárrese el sombrero. Estamos buscando el comienzo de la cadena, luego un grupo de tres dígitos, y después \D+. ¿Qué diantre es eso? Bien, \D coincide con cualquier carácter excepto un dígito numérico, y + significa “1 o más”. Así que \D+ coincide con uno o más caracteres que no sean dígitos. Esto es lo que vamos a usar en lugar de un guión, para admitir diferentes tipos de separadores.
2 Usar \D+ en lugar de - implica que ahora podemos reconocer números de teléfonos donde los números estén separados por espacios en lugar de guiones.
3 Por supuesto, los números separados por guión siguen funcionando.
4 Desafortunadamente, aún no hemos terminado, porque asume que hay separadores. ¿Qué pasa si se introduce el número de teléfono sin ningún tipo de separador?
4 ¡Vaya! Aún no hemos corregido el problema de la extensión obligatoria. Ahora tenemos dos problemas, pero podemos resolverlos ambos con la misma técnica.

El siguiente ejemplo muestra la expresión regular para manejar números de teléfonos sin separadores.

Ejemplo 7.13. Manejo de números sin separadores

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('80055512121234').groups()                      2
('800', '555', '1212', '1234')
>>> phonePattern.search('800.555.1212 x1234').groups()                  3
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                        4
('800', '555', '1212', '')
>>> phonePattern.search('(800)5551212 x1234')                           5
>>> 
1 La única modificación que hemos hecho desde el último paso es cambiar todos los + por *. En lugar de buscar \D+ entre las partes del número de teléfono, ahora busca \D*. ¿Recuerda que + significa “1 o más”? Bien, * significa “cero o más”. Así que ahora es capaz de reconocer números de teléfono incluso si no hay separadores de caracteres.
2 Espere un momento, esto funciona. ¿Por qué? Coincidimos con el principio de la cadena, y luego recordamos un grupo de tres dígitos (800), después cero caracteres no numéricos, luego un grupo de tres dígitos (555), ahora cero caracteres no numéricos, un grupo de cuatro dígitos (1212), cero caracteres no numéricos y un grupo arbitrario de dígitos (1234), y después el final de la cadena.
3 También funcionan otras variantes: puntos en lugar de guiones, y tanto espacios como una x antes de la extensión.
4 Por último, hemos resuelto el otro problema que nos ocupaba: las extensiones vuelven a ser opcionales. Si no se encuentra una extensión, el método groups() sigue devolviendo una tupla de 4 elementos, pero el cuarto es simplemente una cadena vacía.
5 Odio ser portador de malas noticias, pero aún no hemos terminado. ¿Cual es el problema aquí? Hay un carácter adicional antes del código de área, pero la expresión regular asume que el código de área es lo primero que hay al empezar la cadena. No hay problema, podemos usar la misma técnica de “cero o más caracteres no numéricos” para eliminar los caracteres del que hay antes del código de área.

El siguiente ejemplo muestra cómo manejar los caracteres antes del número de teléfono.

Ejemplo 7.14. Manipulación de caracteres iniciales

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('(800)5551212 ext. 1234').groups()                 2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                           3
('800', '555', '1212', '')
>>> phonePattern.search('work 1-(800) 555.1212 #1234')                     4
>>> 
1 Esto es lo mismo que en el ejemplo anterior, excepto que ahora empezamos con \D*, cero o más caracteres no numéricos, antes del primer grupo que hay que recordar (el código de área). Observe que no estamos recordando estos caracteres no numéricos (no están entre paréntesis). Si los encontramos, simplemente los descartaremos y empezaremos a recordar el código de área en cuanto lleguemos a él.
2 Puede analizar con éxito el número de teléfono, incluso con el paréntesis abierto a la izquierda del código de área. (El paréntesis de la derecha también se tiene en cuenta; se le trata como un separador no numérico y coincide con el \D* tras el primer grupo a recordar).
3 Un simple control para asegurarnos de no haber roto nada que ya funcionase. Como los caracteres iniciales son totalmente opcionales, esto coincide con el principio de la cadena, luego vienen cero caracteres no numéricos, después un grupo de tres dígitos a recordar (800), luego un carácter no numérico (el guión), un grupo de tres dígitos a recordar (555), un carácter no numérico (el guión), un grupo de cuatro dígitos a recordar (1212), cero caracteres no numéricos, un grupo a recordar de cero dígitos, y el final de la cadena.
4 Aquí es cuando las expresiones regulares me hacen desear sacarme los ojos con algún objeto romo. ¿Por qué no funciona con este número de teléfono? Porque hay un 1 antes del código de área, pero asumimos que todos los caracteres antes de ese código son no numéricos (\D*). Aggghhh.

Parémonos a pensar por un momento. La expresión regular hasta ahora ha buscado coincidencias partiendo siempre del inicio de la cadena. Pero ahora vemos que hay una cantidad indeterminada de cosas al principio de la cadena que queremos ignorar. En lugar de intentar ajustarlo todo para simplemente ignorarlo, tomemos un enfoque diferente: no vamos a buscar coincidencias explícitamente desde el principio de la cadena. Esto lo mostramos en el siguiente ejemplo.

Ejemplo 7.15. Número de teléfono, dónde te he de encontrar

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                3
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234')                              4
('800', '555', '1212', '1234')
1 Observe la ausencia de ^ en esta expresión regular. Ya no buscamos el principio de la cadena. No hay nada que diga que tenemos que hacer que la entrada completa coincida con nuestra expresión regular. El motor de expresiones regulares hará el trabajo duro averiguando desde dónde ha de empezar a comparar en la cadena de entrada, y seguirá de ahí en adelante.
2 Ahora ya podemos analizar con éxito un número teléfono que contenga otros caracteres y dígitos antes, además de cualquier cantidad de cualquier tipo de separadores entre cada parte del número de teléfono.
3 Comprobación de seguridad. Esto aún funciona.
4 Esto también funciona.

¿Ve lo rápido que pueden descontrolarse las expresiones regulares? Eche un vistazo rápido a cualquiera de las iteraciones anteriores. ¿Podría decir la diferencia entre ésa y la siguiente?

Aunque entienda la respuesta final (y es la definitiva; si ha descubierto un caso que no se ajuste, yo no quiero saber nada), escribámoslo como una expresión regular prolija, antes de que olvidemos por qué hicimos cada elección.

Ejemplo 7.16. Análisis de números de teléfono (versión final)

>>> phonePattern = re.compile(r'''
                # don't match beginning of string, number can start anywhere
    (\d{3})     # area code is 3 digits (e.g. '800')
    \D*         # optional separator is any number of non-digits
    (\d{3})     # trunk is 3 digits (e.g. '555')
    \D*         # optional separator
    (\d{4})     # rest of number is 4 digits (e.g. '1212')
    \D*         # optional separator
    (\d*)       # extension is optional and can be any number of digits
    $           # end of string
    ''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        1
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                2
('800', '555', '1212', '')
1 Aparte de ocupar varias líneas, ésta es exactamente la misma expresión regular del paso anterior, de manera que no sorprende que analice las mismas entradas.
2 Comprobación de seguridad final. Sí, sigue funcionando. Lo hemos conseguido.

Lecturas complementarias sobre expresiones regulares