Saltar a contenido

Further Improvements

Recordemos lo que discutimos al principio sobre importar módulos y que los scripts de Python se ejecutan de arriba hacia abajo, incluso cuando se importan. Esto significa que si alguien importara nuestro script, por ejemplo, para reutilizar algunas de nuestras funciones (podría ser incluso nosotros mismos), el código se ejecutaría tan pronto como se importe. La manera típica de evitar esto es colocar todo el código que "hace algo" dentro del bloque "main". Hagamos eso:

The "main" Block

if __name__ == '__main__':
    page_url = 'http://target:port'
    the_words = get_all_words_from(page_url)
    top_words = get_top_words_from(the_words)

    for i in range(10):
        print(top_words[i][0])

Lo más importante que hay que saber sobre esta declaración condicional es qué necesitamos escribir para que el código dentro de ella se ejecute siempre que lo ejecutemos con el binario de Python. Consulta la brillante respuesta en StackOverflow para una explicación detallada de lo que está sucediendo aquí. Lo más importante que hay que entender es que el código dentro de esta declaración condicional solo se ejecuta cuando el script es "run" y no cuando es "imported".

Sin embargo, podemos eliminar la constante PAGE_URL, que usamos antes, y tener todo dentro del bloque principal del código.

Otra mejora que podemos hacer es deshacernos por completo de la variable URL haciendo que el script sea flexible y acepte un argumento de entrada al ejecutar Python y el script. Por ejemplo, podríamos mejorar el programa para aceptar argumentos de entrada como:

Accepting Arguments

python3 wordextractor.py --url http://foo.bar/baz

Hacer esto nos permitiría extender el programa fácilmente, por ejemplo, con un límite de longitud de palabra, para evitar palabras no interesantes como and, is y that.

Veamos un módulo para ayudarnos a lograr esto: click.

Para entender qué hace click, necesitamos hablar sobre decorators. Sin embargo, dado que esto puede ser un poco complicado al principio, dejaremos esto como lectura opcional al final de esta sección. Todo lo que necesitamos saber sobre click en este punto son algunos conceptos específicos que son fáciles de memorizar. Comencemos instalándolo como siempre con pip: pip3 install click. El siguiente es un script de ejemplo tomado de la documentación oficial, pero ligeramente modificado para simplificar:

A Simple Click Script

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name', help='The person to greet.')
def hello(count, name):
    for i in range(count):
        click.echo('Hello %s!' % name)

if __name__ == '__main__':
    hello()

Aquí ocurrieron muchas cosas nuevas en relativamente pocas líneas de código. En primer lugar, tenemos los decorators que, en cierto sentido, "decoran funciones". Estos son los elementos que comienzan con un @. Necesitamos centrarnos en no volvernos demasiado técnicos demasiado rápido porque estos decoradores en click nos permiten configurar argumentos en línea de comandos para el script. Primero, especificamos el decorador @click.command(), indicando que tendremos una entrada por línea de comandos para esta función hello. Luego se especifican dos opciones @click.option. En este ejemplo, los parámetros son bastante directos: tenemos un default para el count, en caso de que no se especifique como argumento en línea de comandos, tenemos texto de help para la salida de --help, y tenemos un parámetro prompt. Esto le dice a Python que prompt al usuario para la entrada si no se da un argumento en línea de comandos.

Por último, y probablemente lo más importante, observa que toda la "parte principal" del código solo llama a la función hello(). Click requiere que llamemos a una función con estos decoradores especificados para que funcione. Además, observa que los nombres de los parámetros para la función hello y los nombres de los argumentos de entrada --count y --name coinciden si ignoramos el prefijo --.

Veamos algunos ejemplos para ilustrarlo mejor. Primero, una ejecución sin ningún argumento. Aquí se nos solicita una entrada para el parámetro --name:

Playing With Click

C:\Users\Birb> python click_test.py

Your name: Birb
Hello Birb!

Esto también podemos especificarlo explícitamente:

C:\Users\Birb> python click_test.py --name Birb

Hello Birb!

Además, el parámetro --count puede establecerse explícitamente en lugar de ser 1 por defecto:

C:\Users\Birb> python click_test.py --name Birb --count 3

Hello Birb!
Hello Birb!
Hello Birb!

Por último, aquí está la salida de --help:

C:\Users\Birb> python click_test.py --help

Usage: click_test.py [OPTIONS]

Options:
  --count INTEGER  Number of greetings.
  --name TEXT      The person to greet.
  --help           Show this message and exit.

Como podemos ver en el mensaje de ayuda, INTEGER y TEXT se imprimen. Sin embargo, nunca especificamos esto en el código. La razón es que click intentará adivinar el tipo correcto en función de cosas como el parámetro default. Para obtener más información, consulta la documentación (relativamente bien escrita).

En preparación para usar click, moveremos todo el código previamente ubicado en la "parte principal" del script a su propia función nueva llamada main(). El nombre de esta nueva función no es esencial siempre y cuando la función sea llamada.

Refactoring Into Separate Function

def main():
    page_url = 'http://target:port'
    the_words = get_all_words_from(page_url)
    top_words = get_top_words_from(the_words)

    for i in range(10):
        print(top_words[i][0])

if __name__ == '__main__':
    main()

Luego, necesitamos agregar la funcionalidad de click a la función, así como reemplazar variables donde sea necesario:

Adding Click Options

@click.command()
@click.option('--url', '-u', prompt='Web URL', help='URL of webpage to extract from.')
@click.option('--length', '-l', default=0, help='Minimum word length (default: 0, no limit).')
def main(url, length):
    the_words = get_all_words_from(url)
    top_words = get_top_words_from(the_words, length)
    # Remaining code omitted

Como se puede observar, agregamos dos parámetros a la función, url y length, y los usamos en lugar de la URL codificada que usamos antes. Los nombres de los parámetros deben ser los mismos que los de --name en la especificación de opciones para que click asigne automáticamente la entrada a esas variables. Si usáramos un argumento de entrada (es decir, un click.option) que tuviera que ser diferente al nombre del parámetro en Python, por razones estéticas, podríamos indicarle a click que asigne las variables agregando el nombre del parámetro de Python después de los nombres de opción, por ejemplo, @click.option('--url', '-u', 'target_uri', ...).

En este caso, podemos permitir que la función count_occurrences_in maneje el filtrado. Para hacerlo, primero debemos pasar la variable length a la función get_top_words_from, y dentro de esta, pasarla a la función count_occurrences_in. Hay muchas formas de hacer esto. Si quisiéramos agregar otra opción de filtrado, por ejemplo, encontrar solo palabras que coincidan con alguna regex, podría ser una buena idea mantener el filtrado como un proceso separado que pueda aplicar un filtro en una lista de palabras y devolver aquellas que coincidan. Las siguientes dos funciones fueron actualizadas:

def count_occurrences_in(word_list, min_length):
    word_count = {}

    for word in word_list:
        if len(word) < min_length:
            continue
        if word not in word_count:
            word_count[word] = 1
        else:
            current_count = word_count.get(word)
            word_count[word] = current_count + 1
    return word_count

def get_top_words_from(all_words, min_length):
    occurrences = count_occurrences_in(all_words, min_length)
    return sorted(occurrences.items(), key=lambda item: item[1], reverse=True)

Empezando desde abajo, pasamos el parámetro min_length a la primera función. Aquí agregamos una comprobación adicional: if len(word) < min_length. Si lo es, usamos continue, que en el contexto de un bucle significa "no te preocupes por el resto de este bloque, simplemente pasa al siguiente elemento y olvídate de este." Así que, si la palabra es más corta que nuestra longitud mínima, continuaremos con la siguiente palabra y, por lo tanto, no la agregaremos al diccionario word_count.

El script final se ve así:

The Final Script

import click
import requests
import re
from bs4 import BeautifulSoup

def get_html_of(url):
    resp = requests.get(url)

    if resp.status_code != 200:
        print(f'HTTP status code of {resp.status_code} returned, but 200 was expected. Exiting...')
        exit(1)

    return resp.content.decode()

def count_occurrences_in(word_list, min_length):
    word_count = {}

    for word in word_list:
        if len(word) < min_length:
            continue
        if word not in word_count:
            word_count[word] = 1
        else:
            current_count = word_count.get(word)
            word_count[word] = current_count + 1
    return word_count

def get_all_words_from(url):
    html = get_html_of(url)
    soup = BeautifulSoup(html, 'html.parser')
    raw_text = soup.get_text()
    return re.findall(r'\w+', raw_text)

def get_top_words_from(all_words, min_length):
    occurrences = count_occurrences_in(all_words, min_length)
    return sorted(occurrences.items(), key=lambda item: item[1], reverse=True)

@click.command()
@click.option('--url', '-u', prompt='Web URL', help='URL of webpage to extract from.')
@click.option('--length', '-l', default=0, help='Minimum word length (default: 0, no limit).')
def main(url, length):
    the_words = get_all_words_from(url)
    top_words = get_top_words_from(the_words, length)

    for i in range(10):
        print(top_words[i][0])

if __name__ == '__main__':
    main()

Para el estudiante más aventurero, aquí hay una lista de ideas para extender la herramienta:

  • Agregar un argumento --output / -o que nos permita definir un archivo de salida para imprimir en lugar de la consola (algo como with open('path.txt', 'w') as wr: y wr.write(word)).

  • Agregar mutaciones comunes de contraseñas a la salida, por ejemplo, Capitalizadas, minúsculas, MAYÚSCULAS, y con varios bits añadidos como el año actual o reciente, números aleatorios o símbolos, por ejemplo, 2019, 1!, 2!, 3!, 01, 123. Summer2021! y variaciones similares son contraseñas tristemente frecuentes.

  • Agregar un argumento --depth / -d que especifique la profundidad de rastreo del script. Esto implica la capacidad de capturar no solo palabras, sino también URLs en la(s) página(s) web, verificar si están dentro del alcance (por ejemplo, dominio), y agregarlas a una lista de páginas para rastrear a continuación.

  • El programa actualmente falla si se especifica una longitud mínima de 10 o más. Intenta averiguar por qué y corrígelo (pista: revisa ese último bucle for-loop).