Medición de la cantidad de texto y código en mis artículos

Estudio experimental midiendo la cantidad de código y texto en 68 artículos del blog en 4 lenguajes (Perl 5, Raku, Python, Rust) con análisis de rendimiento de E/S y HTTP Keep-Alive.

Daniel Gustaw

Daniel Gustaw

18 min read

Medición de la cantidad de texto y código en mis artículos

En 2017, por pura curiosidad, decidí comprobar qué proporción de mis artículos en el blog era texto escrito y qué parte correspondía a código fuente. En aquel momento, escribí un conciso programa de 21 líneas en Perl que me dio una respuesta rápida.

En 2021—con motivo de unos experimentos con el lenguaje Perl 6 (actualmente Raku)—reescribí dicho script y, para mi sorpresa, obtuve números completamente diferentes. Por falta de tiempo dejé el tema de lado, pero recientemente volví a él para llevar a cabo una investigación exhaustiva.

Resultó que un problema aparentemente trivial—“contar los caracteres de texto y código en una página HTML”—oculta bajo la superficie un fascinante mundo de matices de ingeniería. Desde errores en librerías abiertas de CPAN y sutilezas en el anidamiento del árbol DOM, hasta la especificación de Unicode (grafemas frente a puntos de código) y optimizaciones de rendimiento en la capa de red HTTP.

En este artículo, te guiaré a través de sucesivos intentos para conciliar los resultados en 4 lenguajes de programación (Perl 5, Raku, Python y Rust), presentando estadísticas completas para 68 artículos, gráficos de evolución de la proporción de código y resultados de rendimiento inesperados.


Acto I: El año 2017 y el script de 21 líneas en Perl 5

Mi enfoque inicial en 2017 se basaba en una premisa sencilla: obtener la página principal del blog, extraer los enlaces a los artículos desde los encabezados <h2>, descargar cada publicación individualmente y contar los caracteres en los elementos de texto (<h1>-<h4>, <p>, <li>) frente a los bloques de código (<pre>).

A continuación se muestra el script original utilizando la librería HTML::TagParser:

#!/usr/bin/env perl
use warnings;
use strict;
use HTML::TagParser;

my $url = 'https://gustawdaniel.com';
my @tags = ("h1 h2 h3 h4 li p", "pre");

print "|     text |     code | title \n";

my @list = HTML::TagParser->new( $url )->getElementsByTagName( "h2" );
foreach my $elem ( @list ) {
    my $post = HTML::TagParser->new( $url . $elem->firstChild()->getAttribute( "href" ) );
    my @str = ("", "");
    foreach my $i ( (0, 1) ) {
        my @elements = map { $post->getElementsByTagName($_) } split / /, $tags[$i];
        $str[$i] = join("", map { $_->innerText } @elements);
    }
    printf("| %8d | %8d | %-60s \n", (map { $str[$_] =~ y===c } (0, 1)), $elem->innerText);
}

Funcionó a la perfección… hasta que lo ejecuté de nuevo años más tarde en un entorno moderno y me encontré con dos problemas importantes.

Problema 1: Falta de soporte HTTPS

El primer error al volver a ejecutarlo en un entorno limpio fue:

URI::Fetch failed: Protocol scheme 'https' is not supported (LWP::Protocol::https not installed)

Resultó que HTML::TagParser utiliza internamente LWP::UserAgent. Por defecto en Perl, LWP solo admite http://. La instalación del paquete liblwp-protocol-https-perl (o mediante CPAN: cpanm LWP::Protocol::https) permitió establecer conexiones cifradas.

Problema 2: Anatomía de un error en CPAN (HTML::TagParser)

Tras solucionar HTTPS, el script funcionó, pero para ciertos artículos (por ejemplo, sobre la Ley de Zipf y la Ley de Benford) mostraba exactamente 0 caracteres de código, ¡a pesar de que dichos artículos contenían numerosos bloques <pre>!

La depuración reveló un fallo en el propio módulo HTML::TagParser (HTML/TagParser.pm, línea 257). Al analizar valores de atributos entre comillas dobles ("), el módulo utilizaba una expresión regular que truncaba la cadena del atributo en la primera aparición de… ¡una comilla simple (')!

Las URL de los artículos estaban estructuradas de la siguiente manera:

Al solicitar esas URL malformadas, el servidor devolvía páginas de error 404 (las cuales no contenían etiquetas <pre>).

La solución rudimentaria en Perl consistió en eludir el método defectuoso de la librería y acceder directamente a la estructura interna del arreglo de nodos de Perl ($node->[0]->[$node->[1]]->[2]), que almacena el texto sin procesar de los atributos HTML:

sub get_href {
    my ($node) = @_;
    my $raw = $node->[0]->[$node->[1]]->[2] // "";
    if ($raw =~ /href=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i) {
        return $1 // $2 // $3;
    }
    return $node->getAttribute("href");
}

Error de la librería resuelto, pero esto era solo el comienzo de nuestros descubrimientos.


Acto II: La reescritura en Raku y el desafío de anidamiento en el DOM

En 2021, intenté reescribir el algoritmo en Raku. En Raku, en lugar de HTML::TagParser, utilicé el moderno módulo DOM::Tiny con selectores CSS:

#!/usr/bin/env raku
use WWW;
use DOM::Tiny;

my $base_url = 'https://gustawdaniel.com';
my $dom = DOM::Tiny.parse(get($base_url));

for $dom.find('h3 a') -> $a {
    my $url  = $a.attr('href');
    my $post = DOM::Tiny.parse(get($url));

    my $text = $post.find('h1, h2, h3, h4, p, li').map(*.all-text).join;
    my $code = $post.find('pre').map(*.all-text).join;

    printf("| %8d | %8d | %-60s \n", $text.chars, $code.chars, $a.all-text.trim);
}

El código se redujo a solo 18 líneas y resultó sumamente limpio. Sin embargo, durante una auditoría en los 68 artículos del blog, noté discrepancias.

En un artículo antiguo de 2017 (“Application with FOSUserBundle and Google Maps API”), Raku devolvió 42,469 caracteres de código, mientras que otros analizadores indicaban 42,529 caracteres.

¿Por qué faltaban 60 caracteres?

Este artículo contenía 70 bloques de código <pre> con fragmentos de plantillas HTML y Twig. Dentro de algunos de esos bloques <pre> había elementos HTML anidados, como:

<pre class="astro-code"><code><div class="container eternity-form">...</div></code></pre>

El recorrido lineal del árbol en Raku, al no rastrear el contexto del nodo padre, encontraba la etiqueta <div> anidada, “olvidaba” que estaba dentro de un bloque <pre> y contabilizaba ese fragmento de HTML interno como texto del artículo en lugar de código.

La solución fue propagar recursivamente una bandera de contexto $is_pre a lo largo del árbol DOM:

sub collect-text($tree, $is_pre = False) {
    my $t = "";
    my $c = "";
    for $tree.children -> $node {
        if $node ~~ DOM::Tiny::HTML::Text {
            if $is_pre { $c ~= $node.text; } else { $t ~= $node.text; }
        } elsif $node ~~ DOM::Tiny::HTML::Tag {
            my $in_code = $is_pre || $node.tag eq "pre";
            my ($sub_t, $sub_c) = collect-text($node, $in_code);
            $t ~= $sub_t;
            $c ~= $sub_c;
        }
    }
    return ($t, $c);
}

Acto III: Sutilezas en el análisis HTML – Espacios y entidades

Al comparar diferentes librerías de análisis HTML en Python (selectolax), Rust (scraper), Perl 5 (Mojo::DOM) y Raku (DOM::Tiny), descubrí otras diferencias sutiles:

  1. Entidades HTML (&amp;, &lt;, &gt;, &quot;): Algunas librerías devuelven el texto plano con entidades sin escapar (donde &amp; cuenta como 5 caracteres), mientras que otras las decodifican a &, <, >, " (donde & es 1 carácter). Para lograr consistencia entre lenguajes, todos los parsers deben operar sobre texto decodificado.

  2. Etiquetas <span> de resaltadores de sintaxis: Los motores de blogs modernos (como Astro con Shiki o Prism) dividen los bloques de código en docenas de elementos <span class="line"><span class="token keyword">const</span>...</span> anidados. Algunos parsers HTML insertan espacios en blanco adicionales al unir nodos de texto <span> adyacentes durante la extracción de all_text. Si un analizador añade un espacio después de cada palabra clave resaltada, ¡el conteo de caracteres de código aumenta de forma no natural!


Acto IV: El misterio de Unicode – Grafemas frente a Puntos de Código y Bytes

El descubrimiento más fascinante ocurrió al auditar el artículo #13 (tRPC – super fast development cycle…).

Los resultados de conteo de caracteres de texto para este artículo fueron:

¿Por qué Raku reportaba 1 carácter menos? Escaneé el texto de la publicación carácter por carácter en busca de símbolos fuera de la tabla ASCII. El causante estaba en la frase:

“I learned tRPC today and fall in love ❤️ instantly…”

Analicemos el emoji del corazón rojo ❤️. En Unicode, este símbolo consta de dos Puntos de Código (Code Points):

  1. U+2764 (HEAVY BLACK HEART )
  2. U+FE0F (Modificador invisible VARIATION SELECTOR-16, que indica al renderizador mostrar un emoji en color).

¿Cómo cuentan caracteres los lenguajes de programación?

Para alinear Raku con Python, Rust y Perl 5 (contando Puntos de Código), simplemente sustituimos .chars por .codes:

# .codes cuenta Puntos de Código Unicode en lugar de grafemas NFG
printf("| %8d | %8d | %-60s \n", $text.codes, $code.codes, $title);

Con este cambio, Raku logró una coincidencia del 100% byte por byte con los demás lenguajes.


Acto V: Tabla de resultados para los 68 artículos

Con una lógica de conteo unificada y una gestión adecuada del anidamiento en el DOM, generé las estadísticas completas para los 68 artículos publicados en el blog.

Estadísticas resumidas:

Tabla de mediciones:

#Caracteres de TextoCaracteres de CódigoProporción de CódigoTítulo del Artículo
17,77010,78258.1%Leveraging SIMD in Rust for High-Performance Computing
210,7948,75444.8%From MLP to CNN. Neural Networks for MNIST Digit Recognition
37,90614,17264.2%Rust Wasm performance on snake game example
49,37010,51752.9%Activation Functions in Machine Learning
516,4216,08827.0%Machine Learning XOR from Scratch
65,3243,50439.7%LangChain Exemplary Use Cases
71,8358,46182.2%Fastify Prisma REST backend
81,1712,45467.7%Web Push Notifications
92,85410,89879.3%Svelte snake deployed on deno
104,47810,27769.6%Rust implementation of RFC 7396 - JSON Merge Patch
116,2191,59020.4%Tutorial for ESM + CommonJS package creators
122,86063818.2%How to Install Yay on a Pure Arch Linux Docker Image
134,3814569.4%Simplifying Linux Command Line with GPT-CLI (rust, open source)
1410,4248,60245.2%tRPC - super fast development cycle for fullstack typescript apps
151,73956724.6%How to install MongoDB 6 on Fedora 37
164,6852,32533.2%QuickSort implementation in Rust, Typescript and Go
172,6401,82340.8%ZeroMQ pull-push pattern for Node JS
184,0543,10343.4%New Google Identity in Nuxt 3
1917,9754,70320.7%Selected syntax in JavaScript ES2020, ES2021 and ES2022
206,2913,38935.0%CodinGame: Best fit to data - Rust - Regression Analysis
219,73413,41957.9%CodinGame: Derivative Time - Part 1, Recursion (Typescript)
227,97117,23868.4%CodinGame: Quaternion Multiplication - Rust, NodeJS - Parsing, Algebra
235,4537,56358.1%CodinGame: ASCI Art - Rust, NodeJs - Strings, Arrays, Loops
241,53880534.4%Overload Signatures in Typescript
2512,71215,48154.9%Login by Metamask - Rest Backend in Fastify (Node, Typescript, Prisma)
263,0512,71147.1%Login Component in Nuxt (Rest Strapi)
273,6425,46160.0%Maximum Inequality [Linear Search] rust and typescript
286,4275,63546.7%Pulumi - Infrastructure as a Code [ Digital Ocean ]
291,1532,01163.6%Last Occurrence [Linear Search] easy
305,5732,27028.9%Analysis of Zipf’s Law in Node.js
315,6282,18628.0%Retry Policy - How to Handle Random, Unpredictable Errors
322,0121,43841.7%Publishing an update of the package in the AUR repository
333,7261,73531.8%Least Common Multiple - Number Theory
348,0256,50144.8%How to configure SSL in local development
3513,7533,21718.9%Another installation guide for Arch Linux (i3)
3617,6455,76924.6%Benford’s Law for the Fibonacci Sequence in Java, Rust, and Node JS
375,2264888.5%Bolt (always) Lite - MITM, Proxy, Insomnia and Vue
3814,8015,19025.9%Process Control in Node JS
394,16878815.9%Xss attack using script style and image
408,1116,70445.2%Broadcast Channel API
415,95311,48865.9%Analysis of the frequency of altcoin names in the English language corpus
424,9775,39152.0%Scraping the most popular Twitter accounts
431,66700.0%How to create a free email account with custom domain?
442,54973522.4%Telegram Bot in Typescript
452,8292237.3%Installation of a renewable TLS certificate (certbot + apache on Ubuntu)
4611,0633,99726.5%Data scraping in Perl
4713,49410,56143.9%Scraping Facebook in 2021
486,68900.0%How the war for compatibility shaped the frontend?
495,7892,36429.0%We squeeze data from PDF like juice from a lemon
509,9007,39242.7%Fetch, Promise and Template String on example of To Do List in JavaScript
519,6474,42331.4%Communication between Vue components in Meteor
521,8451999.7%Git styled calendar with custom dates
536,2229,66360.8%How many families can fit on the plane - an algorithmics problem
542,71100.0%Scraping WordPress - 4300 court rulings in exchange rate lawsuits without a line of code
559,7725,33535.3%Ruby on Rails - quick introduction
562,63189725.4%Infrastructure as Code (Terraform + Digital Ocean)
572,4911,71840.8%Calculating the Difference Between JSON Files
584,1374,42151.7%Scraping of the Pharmacy Register
5911,2668,42942.8%How to download contact data for 20k lawyers in an hour
605,3994,31844.4%Scraping from money.pl in 30 lines of code.
6117,60216,39748.2%Data Structuring on the Example of CHF NBP Course
6223,41942,52964.5%Application with FOSUserBundle and Google Maps API
637,8762,91627.0%Compilation of PHP 7 interpreter in BunsenLabs
6417,06611,55040.4%Analysis of Apache logs with GoAccess
6511,2659,31345.3%The impact of indexing on search performance in MySQL database
6616,23522,12157.7%Tesseract-OCR and testing selects.
6712,06612,33950.6%Visualization of a dynamic correlation network.
688,13311,65258.9%Data logging in MySql, Ajax, and Behat

Gráficos de evolución de texto y código

Los siguientes gráficos ilustran los cambios en el volumen de los artículos y el porcentaje de proporción de código a lo largo del tiempo:

Evolución del volumen de texto frente a código en los 68 artículos

Evolución del porcentaje de proporción de código en los 68 artículos


Acto VI: Enfrentamiento en 4 lenguajes de programación

Desarrollé implementaciones equivalentes del algoritmo de conteo de caracteres en 4 lenguajes:

1. Python (httpx + selectolax)

#!/usr/bin/env python3
import httpx
from selectolax.parser import HTMLParser

BASE_URL = "https://gustawdaniel.com"
res = httpx.get(BASE_URL)
parser = HTMLParser(res.text)

for a in parser.css("h3 > a"):
    path = a.attributes.get("href", "")
    url = path if path.startswith("http") else f"{BASE_URL}{path}"
    post_parser = HTMLParser(httpx.get(url).text)

    code_str = "".join(pre.text(deep=True) for pre in post_parser.css("section pre"))
    for pre in post_parser.css("section pre"):
        pre.decompose()
    text_str = "".join(sec.text(deep=True) for sec in post_parser.css("section"))

    print(f"| {len(text_str):8d} | {len(code_str):8d} | {a.text().strip():<60} |")

2. Rust (ureq + scraper)

use ureq;
use scraper::{Html, Selector};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let body = ureq::get("https://gustawdaniel.com").call()?.into_string()?;
    let fragment = Html::parse_fragment(&body);

    for element in fragment.select(&Selector::parse("h3 > a")?) {
        let path = element.value().attr("href").unwrap_or("");
        let post_body = ureq::get(path).call()?.into_string()?;
        let post_fragment = Html::parse_fragment(&post_body);

        let mut code = String::new();
        let mut text = String::new();

        for section in post_fragment.select(&Selector::parse("section")?) {
            for node in section.descendants() {
                if let Some(t) = node.value().as_text() {
                    if node.parents().any(|p| p.value().as_element().map_or(false, |e| e.name() == "pre")) {
                        code.push_str(t);
                    } else {
                        text.push_str(t);
                    }
                }
            }
        }
        println!("| {:8} | {:8} | {:<60} |", text.chars().count(), code.chars().count(), element.text().collect::<String>());
    }
    Ok(())
}

3. Perl 5 (Mojo::UserAgent + Mojo::DOM)

#!/usr/bin/env perl
use warnings;
use strict;
use Mojo::DOM;
use Mojo::UserAgent;

my $ua   = Mojo::UserAgent->new;
my $main = $ua->get('https://gustawdaniel.com')->res->dom;

sub collect_text {
    my ($node) = @_;
    my ($t, $c) = ("", "");
    for my $child ($node->child_nodes->each) {
        if ($child->type eq "text") {
            $t .= $child->content;
        } elsif ($child->type eq "tag") {
            if ($child->tag eq "pre") {
                $c .= $child->all_text;
            } else {
                my ($st, $sc) = collect_text($child);
                $t .= $st; $c .= $sc;
            }
        }
    }
    return ($t, $c);
}

for my $elem ($main->find('h3 a')->each) {
    my $post = $ua->get($elem->attr('href'))->res->dom;
    my ($text, $code) = ("", "");
    for my $sec ($post->find('section')->each) {
        my ($t, $c) = collect_text($sec);
        $text .= $t; $code .= $c;
    }
    printf("| %8d | %8d | %-60s \n", length($text), length($code), $elem->all_text);
}

Auditoría de verificación para los 68 artículos:

Comparación de LenguajesPublicaciones con Resultados 100% IdenticosCoincidencia
Rust vs Python68 / 68100.00% (Línea base)
Rust vs Perl 5 (Mojo::DOM)68 / 68100.00% (Línea base)
Rust vs Raku (.codes)68 / 68100.00% (Línea base)

Todos los scripts produjeron conteos idénticos hasta el último carácter.


Acto VII: Benchmark de rendimiento y reutilización de conexiones HTTP

Con 4 scripts funcionales y 100% consistentes, medí el rendimiento de ejecución en todos los programas (tiempo real de reloj, tiempo de CPU y consumo máximo de memoria RAM Max RSS).

Resultado inicial inesperado

En la ejecución inicial del benchmark, Perl 5 con Mojo::UserAgent tardó 11.39 s, mientras que Rust estándar tardó 5.21 s y Python 5.74 s.

Sin embargo, resultaba desconcertante por qué Python y Rust estándar eran solo ~2 veces más rápidos que Perl, dado que la CPU de Rust dedicó solo 0.37 s al análisis de código.

El cuello de botella residía en la capa de red y la persistencia HTTP Keep-Alive:

  1. Mojo::UserAgent en Perl reutiliza conexiones TCP/TLS persistentes.
  2. La versión estándar de Rust (rust/src/main.rs) invocaba la función estática ureq::get(&url) dentro de un bucle. Al no disponer de un agente compartido que gestionara un pool de conexiones, cada una de las 68 peticiones abría una nueva conexión TCP y un nuevo saludo TLS.
  3. En Python (count_text_and_code.py), se llamaba a httpx.get(url) dentro del bucle, creando un cliente efímero por cada petición. Si hubiéramos utilizado with httpx.Client() as client:, Python también habría mantenido un pool de conexiones Keep-Alive.

Optimización en Rust: rust/src/bin/connection_pool.rs

Instanciamos un agente explícito ureq::Agent::new_with_defaults() que mantiene un pool de sockets abiertos y los reutiliza en peticiones sucesivas:

// rust/src/bin/connection_pool.rs - agente explícito con pool de conexiones
let agent = ureq::Agent::new_with_defaults();

let body: String = agent.get(BASE_URL).call()?.into_string()?;

for element in fragment.select(&selector) {
    // Reutilización de conexiones TLS existentes mediante la misma instancia de agente:
    let post_body: String = agent.get(&url).call()?.into_string()?;
}

Métricas finales de rendimiento

La siguiente tabla presenta las mediciones precisas de tiempo y consumo de RAM registradas en todas las implementaciones:

Lenguaje / VarianteCliente HTTP / ParserConnection PoolTiempo Real (real)Tiempo CPU (user+sys)RAM Máxima (Max RSS)
🥇 Rust (connection_pool)ureq::Agent + scraper (Release)3.34 s0.28 s15.05 MB
🥈 Rust (rust Estándar)ureq::get + scraper (Release)No5.21 s0.37 s14.50 MB
🥉 Python (httpx)httpx.get + selectolaxNo5.74 s0.82 s85.16 MB
Perl 5Mojo::UserAgent + Mojo::DOM11.39 s3.14 s42.29 MB
RakuWWW (get) + DOM::TinyNo37.63 s31.93 s388.90 MB

Gráficos comparativos de rendimiento

Tiempo Real de Ejecución de Reloj (Wall-Clock Real Time)

Tiempo Real de Ejecución

Tiempo de Procesador de CPU

Tiempo de Procesador CPU

Consumo Máximo de Memoria RAM

Consumo Máximo de Memoria RAM


Resumen

Lo que comenzó como un simple cálculo de métricas se convirtió en un valioso estudio de ingeniería de software:

  1. Particularidades de las librerías: Incluso paquetes populares en CPAN pueden contener errores en expresiones regulares que truncan atributos en comillas simples.
  2. Jerarquía del DOM: Rastrea de forma recursiva el contexto del nodo (como <pre>) para evitar clasificar etiquetas de código anidadas como texto del cuerpo.
  3. Especificación Unicode: Antes de comparar longitudes de cadenas entre lenguajes, verifica si estás midiendo grafemas (NFG), Puntos de Código o bytes UTF-8.
  4. Rendimiento y Memoria: Rust con Connection Pool logra un tiempo de ejecución de 3.34 s requiriendo solo 15 MB de RAM (5.7 veces menos que Python y 26 veces menos que Raku).
  5. Optimización de E/S de red: Reutilizar sesiones HTTP Keep-Alive tiene un impacto drásticamente mayor en el tiempo total de ejecución para tareas orientadas a red que la elección del lenguaje de programación o la velocidad pura del analizador HTML.

Other articles

You can find interesting also.