{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Sacar datos de un texto escrito por personas\n",
    "\n",
    "Expresiones regulares sobre 2.440 comentarios de clientes, con sus tildes que faltan y sus emojis.\n",
    "\n",
    "Cuaderno de práctica del capítulo 13 de **Python desde cero**, de Miss Yera.\n",
    "\n",
    "Corre de arriba abajo. Si lo abres en Google Colab no necesitas instalar nada.\n",
    "\n",
    "Capítulo completo: https://missyera.com/guias/python-desde-cero/texto-y-expresiones-regulares/\n",
    "\n",
    "Los ejercicios están al final y traen una celda vacía debajo de cada uno. Las\n",
    "respuestas viven en el cuaderno de soluciones, y merece la pena pelearse un\n",
    "rato antes de abrirlo 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Doce capítulos limpiando números. Y en una empresa peruana lo que más llega no\n",
    "son números: son mensajes escritos por personas 💬\n",
    "\n",
    "Reclamos por WhatsApp, correos de clientes, comentarios de una encuesta. Ahí\n",
    "dentro está el número de pedido, el monto y el motivo, y hay que sacarlos.\n",
    "\n",
    "Antes de empezar, una pregunta: **¿cómo le explicarías a Python que\n",
    "busque \"un número de cuatro cifras que venga después de la palabra\n",
    "pedido\"?** Con lo del capítulo 3 no se puede. Con\n",
    "lo de este capítulo son doce caracteres 🔎"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Mira lo que llega de verdad"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import re\n",
    "import pandas as pd\n",
    "\n",
    "URL = 'https://missyera.com/static/datasets/comentarios-miss-yera.csv'\n",
    "c = pd.read_csv(URL)\n",
    "print('comentarios:', len(c))\n",
    "for texto in c['comentario'].head(4):\n",
    "    print(repr(texto))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en que uso `repr` y no `print` a secas, que es un\n",
    "truco del capítulo 7: así se ven los saltos de línea y\n",
    "los espacios del principio, que con `print` normal son invisibles y\n",
    "son justo la mitad del problema 👀"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('con espacios de sobra :', int((c['comentario'] != c['comentario'].str.strip()).sum()))\n",
    "print('con salto de linea    :', int(c['comentario'].str.contains('\\n').sum()))\n",
    "print('en MAYUSCULAS enteras :', int((c['comentario'].str.strip()\n",
    "                                      == c['comentario'].str.strip().str.upper()).sum()))\n",
    "print('con emoji             :', int(c['comentario'].str.contains(\n",
    "    r'[\\U0001F300-\\U0001FAFF]', regex=True).sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "311 con espacios de sobra y 718 con emoji. Y las 184 en mayúsculas no son un\n",
    "defecto: **son gente enojada**, o sea información 😤"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo mínimo que hay que saber"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una expresión regular es un patrón: una forma de describir \"un texto que se\n",
    "parece a esto\". Se escribe con símbolos y hay como veinte, de los cuales estos\n",
    "cinco resuelven casi todo:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "| Símbolo | Qué significa | Ejemplo |\n",
    "|---|---|---|\n",
    "| `\\d` | Un dígito | `\\d\\d\\d\\d` encuentra 4471 |\n",
    "| `\\w` | Una letra, dígito o guion bajo | `\\w+` encuentra una palabra |\n",
    "| `\\s` | Un espacio, tabulación o salto de línea | `\\s+` encuentra varios seguidos |\n",
    "| `+` | Uno o más de lo anterior | `\\d+` encuentra 12 y también 4471 |\n",
    "| `( )` | Guárdame esta parte | Lo veremos en un momento y es lo bueno |"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "texto = 'ya van 12 dias y el pedido 4471 NO LLEGA, esto es el colmo'\n",
    "print(re.search(r'\\d+', texto))\n",
    "print(re.findall(r'\\d+', texto))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dos funciones y ya sabes la mitad. `re.search` devuelve\n",
    "**el primero** con su posición, y `re.findall` devuelve\n",
    "**todos** en una lista.\n",
    "\n",
    "Y ojo con la `r` de `r'\\d+'`, que no es decoración. Sin\n",
    "ella Python interpreta la barra invertida antes de que la expresión regular la\n",
    "vea, y el patrón deja de significar lo mismo. **Las expresiones regulares\n",
    "van siempre con `r` delante**, siempre, y así te ahorras un\n",
    "error que cuesta media tarde encontrar 🧯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los paréntesis, que es donde está el trabajo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Encontrar el número no basta: había dos y solo uno es el pedido. Los\n",
    "paréntesis dicen *qué parte me quiero llevar*:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "m = re.search(r'pedido\\s+(\\d{4})', texto)\n",
    "print('el trozo entero :', m.group(0))\n",
    "print('solo el numero  :', m.group(1))\n",
    "print('donde empieza   :', m.start())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Se lee de izquierda a derecha, como una frase: *la palabra \"pedido\", uno o\n",
    "más espacios, y cuatro dígitos que me guardo* 📌\n",
    "\n",
    "El `{4}` dice exactamente cuántos, y es más seguro que\n",
    "`+`: con `+` también valdría un número de nueve cifras, que\n",
    "en estos datos sería otra cosa.\n",
    "\n",
    "Y `group(0)` es todo lo que coincidió, `group(1)` es el\n",
    "primer paréntesis. Ese desfase de uno confunde al principio y después se vuelve\n",
    "automático."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Sobre las 2.440 filas a la vez"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "pandas trae las expresiones regulares metidas en las columnas de texto, así\n",
    "que no hace falta ningún bucle:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "c['pedido'] = c['comentario'].str.extract(r'pedido\\s+(\\d{4})')\n",
    "c['monto'] = c['comentario'].str.extract(r'S/\\s*([\\d.]+)')\n",
    "print('comentarios con numero de pedido:', int(c['pedido'].notna().sum()))\n",
    "print('comentarios con un monto        :', int(c['monto'].notna().sum()))\n",
    "for pedido, texto in c.loc[c['pedido'].notna(), ['pedido', 'comentario']].head(3).values:\n",
    "    print(f'  {pedido} <- {texto.strip()[:60]}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 2.440 mensajes sueltos salieron **265 números de pedido y 352\n",
    "montos**, en dos líneas 🎉\n",
    "\n",
    "Eso ya es una tabla que se puede cruzar con la base de pedidos del libro de\n",
    "[SQL desde cero](https://missyera.com/guias/sql-desde-cero/) y contestar cuántos reclamos\n",
    "tiene cada pedido 🗃️\n",
    "\n",
    "`str.extract` devuelve el primer paréntesis de cada fila, y\n",
    "`NaN` donde no encontró nada. Que devuelva nulo y no reviente es la\n",
    "decisión correcta: los otros 2.175 comentarios simplemente no traen pedido."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Anonimizar, que es lo que te van a pedir"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí viene el uso que más veces me ha tocado: **quitar los datos\n",
    "personales antes de que el texto salga a ningún lado**. Si vas a mandar\n",
    "estos comentarios a un modelo, a un proveedor o a un cuaderno compartido, esto no\n",
    "es opcional 🔒"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "CORREO = r'[\\w.+-]+@[\\w-]+\\.[\\w.]+'\n",
    "TELEFONO = r'\\b9\\d{8}\\b'\n",
    "c['anonimo'] = (c['comentario']\n",
    "                .str.replace(CORREO, '[correo]', regex=True)\n",
    "                .str.replace(TELEFONO, '[telefono]', regex=True))\n",
    "tocados = int((c['anonimo'] != c['comentario']).sum())\n",
    "print('comentarios anonimizados:', tocados)\n",
    "for antes, despues in zip(c.loc[c['anonimo'] != c['comentario'], 'comentario'].head(2),\n",
    "                          c.loc[c['anonimo'] != c['comentario'], 'anonimo'].head(2)):\n",
    "    print('  antes  :', antes.strip())\n",
    "    print('  despues:', despues.strip())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "288 comentarios tapados 🔒\n",
    "\n",
    "El `\\b` del teléfono es una **frontera de palabra**, y\n",
    "está ahí por una razón concreta: sin él, un número de trece cifras también\n",
    "coincidiría por dentro. `\\b` exige que ahí empiece y termine.\n",
    "\n",
    "Una advertencia honesta, porque esto es de las cosas donde equivocarse cuesta\n",
    "caro: **una expresión regular anonimiza lo que sabías buscar**. Si\n",
    "alguien escribió su teléfono con guiones o su DNI sin avisar, sigue ahí. Esto\n",
    "reduce el riesgo, no lo elimina, y hay que decirlo cuando entregas 🚩"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Normalizar, o cuatro facturas que eran una"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El otro trabajo grande del texto es que la misma palabra viene escrita de\n",
    "varias formas. Es lo mismo que le pasaba a la ciudad en el capítulo\n",
    "12, y se arregla igual:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import unicodedata\n",
    "\n",
    "def normaliza(s):\n",
    "    s = s.strip().lower()\n",
    "    s = re.sub(r'\\s+', ' ', s)\n",
    "    s = unicodedata.normalize('NFKD', s)\n",
    "    s = s.encode('ascii', 'ignore').decode('utf-8')\n",
    "    return s\n",
    "\n",
    "c['limpio'] = c['comentario'].map(normaliza)\n",
    "print('antes :', repr(c['comentario'].iloc[3]))\n",
    "print('despues:', repr(c['limpio'].iloc[3]))\n",
    "print('formas distintas de \"factura\" antes  :',\n",
    "      c['comentario'].str.findall(r'(?i)factura').explode().dropna().nunique())\n",
    "print('formas distintas de \"factura\" despues:',\n",
    "      c['limpio'].str.findall(r'factura').explode().dropna().nunique())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuatro líneas y cada una arregla una cosa distinta 🧽\n",
    "\n",
    "- ✂️ `strip` quita los espacios de los extremos, los 311 de arriba.\n",
    "\n",
    "- 🔡 `lower` junta FACTURA con factura.\n",
    "\n",
    "- 🌬️ `re.sub(r'\\s+', ' ', s)` convierte cualquier racha de espacios\n",
    "o saltos de línea en un espacio solo. Es la línea que más veces vas a\n",
    "reutilizar.\n",
    "\n",
    "- 🇵🇪 Y las dos últimas quitan las tildes, para que \"atendió\" y \"atendio\" sean\n",
    "la misma palabra.\n",
    "\n",
    "Lo de las tildes es **una decisión y no una obviedad**: en un\n",
    "buscador ayuda, y si lo que vas a hacer es mostrarle el texto a una persona, se\n",
    "lo estás rompiendo. Guarda siempre el original al lado, que es para lo que existe\n",
    "la columna `limpio` aparte 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El símbolo que se come todo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y ahora el error clásico, el que le pasa a todo el mundo la primera semana:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "codicioso = re.findall(r'\".+\"', 'el cliente dijo \"no llego\" y despues \"no responden\"')\n",
    "perezoso = re.findall(r'\".+?\"', 'el cliente dijo \"no llego\" y despues \"no responden\"')\n",
    "print('con .+   :', codicioso)\n",
    "print('con .+?  :', perezoso)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con `.+` sale **una sola cosa gigante** 😳\n",
    "\n",
    "Y no es un error de la librería: `.+` significa \"todo lo que\n",
    "puedas\", y lo que más puede es llegar hasta la última comilla. Se llama\n",
    "**codicioso**.\n",
    "\n",
    "La interrogación lo vuelve **perezoso**: \"todo lo que puedas, pero\n",
    "para en cuanto se pueda\". Esa interrogación de más es la diferencia entre dos\n",
    "resultados y uno."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una expresión regular hace lo que le pediste, no lo que querías. Y casi siempre pediste más de lo que creías."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error que sale cuando el patrón está mal escrito"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y este lo vas a ver el primer día, porque los paréntesis se olvidan solos:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Esto revienta a propósito.** Se ejecuta dentro de un `try` para que puedas seguir con \"ejecutar todo\" y aun así ver la queja."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    re.compile(r'(pedido \\d{4}')\n",
    "except Exception as e:\n",
    "    print(f'{type(e).__name__}: {e}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y la queja que tiene que salir es esta:\n",
    "\n",
    "```\n",
    "error: missing ), unterminated subpattern at position 0\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*unterminated subpattern*, o sea que abriste un paréntesis y no lo\n",
    "cerraste. La `position 0` te dice en qué carácter del patrón está el\n",
    "problema, no de tu archivo 📍\n",
    "\n",
    "Es de los errores agradecidos: revienta enseguida y te dice dónde. El\n",
    "peligroso es el otro, el del patrón bien escrito que encuentra menos de lo que\n",
    "crees y no se queja 🚩"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La negación, otra vez"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con el texto ya normalizado se puede contar de verdad. Y aquí sale lo mismo\n",
    "que en el libro de [deep learning desde\n",
    "cero](https://missyera.com/guias/deep-learning-desde-cero/), con otro disfraz:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(c[c['limpio'].str.contains(r'\\bno\\s+llego\\b')].shape[0], 'dicen que no llego')\n",
    "print(c[c['limpio'].str.contains(r'\\bllego\\b')].shape[0], 'dicen llego, contando los anteriores')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si cuentas la palabra \"llegó\" a secas te salen 383, y **55 de esos dicen\n",
    "justo lo contrario** 🙃\n",
    "\n",
    "Ese es el techo de contar palabras, y no se arregla con más expresiones\n",
    "regulares: se arregla mirando el orden, que es de lo que va el capítulo de\n",
    "recurrentes del otro libro. Aquí lo que hay que llevarse es *saber que el\n",
    "techo existe*."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuándo NO usar una expresión regular"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "| Si lo que tienes es | Usa |\n",
    "|---|---|\n",
    "| HTML de una página | Un lector de HTML de verdad. El HTML se anida y las expresiones regulares no saben de eso |\n",
    "| Un CSV o un JSON | `pandas` y `json`, que ya saben la estructura |\n",
    "| Buscar un texto fijo, sin patrón | `'palabra' in texto`, que es más rápido y se lee |\n",
    "| Validar un correo de verdad | Una librería. El patrón completo del estándar tiene cientos de caracteres |\n",
    "| Entender qué dice el mensaje | Nada de esto. Eso es procesamiento de lenguaje y empieza en el otro libro |"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y la regla que uso yo: **si el patrón no me cabe en un renglón, casi\n",
    "siempre es que la herramienta está mal elegida** 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Un equipo saca los montos de los reclamos para sumar cuánto se está reclamando en total. Corre sin errores y el número que sale se lleva a gerencia.\n",
    "\n",
    "```\n",
    "montos = c['comentario'].str.extract(r'S/\\s*([\\d.]+)')[0]\n",
    "total = pd.to_numeric(montos).sum()\n",
    "print(f'se reclaman S/ {total:,.2f} en total')\n",
    "```\n",
    "\n",
    "**¿Qué está mal?** La respuesta está en el cuaderno de soluciones. Míralo tú primero."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis, y el 4 es el que más se parece a un encargo de verdad. Intenta antes de\n",
    "abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Los que gritan\n",
    "\n",
    "Cuenta cuántos comentarios están escritos enteros en\n",
    "mayúsculas y mira si están más en un tema que en otro."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Contar los emojis\n",
    "\n",
    "Saca cuántos emojis tiene cada comentario y compara la media\n",
    "entre los molestos y los tranquilos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Las horas que nadie te dijo que estaban\n",
    "\n",
    "Busca patrones tipo `14:30` en los comentarios.\n",
    "Escribe el patrón antes de correrlo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. El informe de una semana\n",
    "\n",
    "Junta todo el capítulo: normaliza, extrae el pedido,\n",
    "anonimiza, y saca una tabla de cuántos reclamos hay por tema y ciudad, solo de\n",
    "los molestos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Rómpelo a propósito\n",
    "\n",
    "Quita la `r` de delante de un patrón con\n",
    "`\\d` y mira qué pasa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Tu propio buzón\n",
    "\n",
    "Sin dataset. Exporta veinte mensajes de un WhatsApp de\n",
    "trabajo tuyo y pásales `normaliza` y el anonimizador."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Te piden sacar el número de guía de remisión de 3.000 correos. Vienen como \"GR-0012345\", pero algunos escriben \"GR 0012345\" y otros \"gr0012345\". ¿Qué patrón escribes?\n",
    "\n",
    "a) `(?i)gr[-\\s]?(\\d{7})`\n",
    "\n",
    "b) `GR-(\\d{7})`\n",
    "\n",
    "c) `(?i)gr.*(\\d+)`\n",
    "\n",
    "d) Un bucle con `if` para cada forma"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🔎 `\\d`, `\\w`, `\\s`, `+` y los\n",
    "paréntesis resuelven casi todo.\n",
    "\n",
    "- 🅁 La `r` va siempre delante del patrón.\n",
    "\n",
    "- 📌 Los paréntesis dicen qué parte te llevas, y `str.extract` lo\n",
    "hace sobre la columna entera.\n",
    "\n",
    "- 🔒 Anonimizar es dos `str.replace`, y solo tapa lo que supiste\n",
    "buscar.\n",
    "\n",
    "- 🍽️ `.+` se come todo. `.+?` para en cuanto puede.\n",
    "\n",
    "- 🚫 Y si el patrón no cabe en un renglón, revisa si la herramienta es la\n",
    "correcta.\n",
    "\n",
    "Con esto ya puedes limpiar lo que llega en texto. Lo que viene después,\n",
    "entender qué *dice*, es otro oficio y empieza en el\n",
    "[libro de deep learning desde\n",
    "cero](https://missyera.com/guias/deep-learning-desde-cero/) 🧠\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 13 de **Python desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/python-desde-cero/texto-y-expresiones-regulares/\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
