Me llamó un viernes por la noche. La voz tranquila, pero en ella podía escuchar el cansancio de alguien que lleva semanas luchando con un problema sobre el que no tiene control. Dirige una empresa de servicios que atiende a clientes en cuatro países europeos. Pagó a una agencia por una plataforma web profesional. Recibió algo que parecía una plataforma profesional. Bajo la superficie se escondía una catástrofe.
Por un acuerdo de confidencialidad no puedo revelar el nombre de la empresa ni el sector. En cambio, puedo contar exactamente lo que encontré, lo que hice y por qué elegí unas tecnologías y no otras. Esta historia es un aviso para cualquiera que encargue la construcción de una plataforma a una agencia externa.
Auditoría de una plataforma PHP 5.6 obsoleta
El primer paso es siempre la auditoría. No evalúo, no critico: recojo hechos. Tras tres días de análisis tenía un panorama completo.
Código espagueti en PHP 5.6: la agencia no usó ningún framework. Toda la plataforma es código procedural monolítico en PHP 5.6 (fin de soporte en 2018) con consultas SQL pegadas directamente en las plantillas HTML. Sin ORM, sin capa de abstracción, sin enrutador. Archivos de 3000 líneas mezclando lógica de negocio con presentación.
// Código encontrado -- consulta SQL directamente en la plantilla (anonimizado)
<?php
$result = mysql_query("SELECT * FROM services
WHERE category = '" . $_GET['cat'] . "'
ORDER BY id DESC");
// SQL injection -- sin ninguna validación de datos de entrada
while ($row = mysql_fetch_assoc($result)) {
echo "<div class='service'>";
echo "<h2>" . $row['title'] . "</h2>"; // XSS -- sin escapado
echo "<p>" . $row['description'] . "</p>";
echo "</div>";
}
?>MySQL 5.5 sin índices: base de datos con 47 tablas, ninguna con índices salvo las claves primarias. Una consulta que listaba servicios con filtros ejecutaba un full table scan sobre 200 000 registros, con un tiempo medio de respuesta de 4.7 segundos.
jQuery 1.x + Bootstrap 3: frontend del año 2014. Doce archivos jQuery cargados en cada página, incluyendo tres versiones distintas de la librería. Sin minificación, sin bundler, sin tree-shaking. Peso total de los scripts: 2.8 MB.
FTP como “despliegue”: sin repositorio Git, sin CI/CD, sin entorno de staging. La agencia subía los archivos directamente por FTP al servidor de producción. Sin sistema de control de versiones. Sin tests.
Cero seguridad: contraseñas de usuarios almacenadas en MD5 sin sal. Sesiones en archivos en un servidor compartido. SQL injection en 23 puntos. XSS en formularios. Sin tokens CSRF. Sin HTTPS en el panel de inicio de sesión.
Métricas de la plataforma antes de la reconstrucción
| Métrica | Valor | Evaluación |
|---|---|---|
| PageSpeed (móvil) | No superado | Crítico |
| LCP | Rojo | Crítico |
| INP | Rojo | Crítico |
| CLS | Rojo | Crítico |
| TTFB | Segundos | Crítico |
| Peso de la página | Varios megabytes | Excesivo |
| Tiempo de respuesta API | 4.7s (promedio) | Crítico |
| Tráfico orgánico | En caída interanual | Crítico |
| Vulnerabilidades detectadas | 23 SQL injection, 14 XSS | Crítico |
Lo peor era que el cliente no sabía nada de ninguno de estos problemas. La agencia le enviaba durante un año informes de “optimizaciones” que no tenían ningún reflejo en la realidad.
Por qué el código antiguo con mysql_query no se puede actualizar sin más a PHP 8
La primera pregunta del propietario tras la auditoría fue si no bastaba con mover el código existente a una versión de PHP con soporte. No bastaba, y el motivo está en la documentación oficial de PHP. PHP 5.6 llegó al final de su vida el 31 de diciembre de 2018 y no recibe correcciones de seguridad (php.net, ramas sin soporte). Toda la capa de datos se apoyaba en mysql_query() y mysql_fetch_assoc(). El manual de PHP indica que esta extensión quedó obsoleta en PHP 5.5.0 y se eliminó en PHP 7.0.0, y remite a MySQLi o PDO_MySQL como alternativa (php.net, mysql_query). En PHP 7 u 8 cada una de esas llamadas termina con un error fatal por función no definida.
Una actualización habría obligado, por tanto, a reescribir de todos modos cada llamada a la base de datos. Como las consultas se montaban directamente a partir de $_GET, cada llamada tenía que convertirse además en una consulta preparada para cerrar los 23 puntos de inyección. Cuando hay que reescribir cada línea de la capa de datos y no existe ni un solo test que detecte regresiones, una reconstrucción con capas bien separadas deja de ser la opción cara.
Cómo elegir el stack tecnológico para una reconstrucción
La decisión sobre la arquitectura objetivo es el momento más importante del proyecto. El cliente tenía dudas razonables: la agencia anterior prometió “una solución moderna” y entregó código del año 2014. Tenía que elegir tecnologías que resolvieran problemas concretos, no las que estaban de moda.
Analicé los requisitos y ajusté las herramientas a las tareas:
Python + Django (API backend): el cliente necesitaba un backend sólido con panel de administración, autenticación, validación de datos y REST API. Django ofrece todo eso de serie. Django REST Framework es un ecosistema maduro y estable con documentación excelente. El cliente atiende a 4 mercados europeos, y Django tiene internacionalización integrada.
PostgreSQL (base de datos): migrar de MySQL 5.5 a PostgreSQL no es un capricho. PostgreSQL ofrece mejores índices (GIN, GiST para búsqueda de texto completo), mejores tipos de datos (JSONB, arrays), particionamiento de tablas maduro y transacciones ACID fiables. Para 200 000 registros con búsqueda de texto completo multilingüe es la elección natural.
Redis (caché y colas): el tiempo de respuesta de la API desde 4.7 segundos tenía que bajar por debajo de 100 milisegundos. Redis cachea los resultados de las consultas, almacena las sesiones de usuario y gestiona las colas de tareas asíncronas (Celery). Una herramienta, tres funciones críticas.
React + TypeScript (frontend interactivo): el panel del cliente, el buscador con filtros, los formularios multietapa, todo eso requiere una UI reactiva. React con TypeScript ofrece componentes tipados, excelentes herramientas de desarrollo y un ecosistema de librerías enorme.
Rust (microservicio de alto rendimiento): indexar la búsqueda de 200 000 registros en 4 idiomas, procesar archivos CSV/Excel de los clientes, transformar datos. Estas tareas requerían rendimiento puro. Rust procesa el índice de búsqueda en 1.8 segundos en lugar de los 47 segundos de la antigua implementación PHP. No es una diferencia porcentual, sino una diferencia de un orden de magnitud.
Astro (sitio de marketing): la página principal, el blog, la oferta, las páginas de servicios son contenidos estáticos que no necesitan JavaScript. Astro genera HTML limpio con coste de ejecución cero. Los elementos interactivos (buscador, formulario de contacto) funcionan como islas React aisladas. Como especialista en desarrollo Astro, este framework fue la elección natural para el sitio público.
Arquitectura objetivo:
┌─────────────────────────────────────────────────────┐
│ Cloudflare CDN │
├──────────────┬───────────────┬────────────────────────┤
│ Astro SSG │ React SPA │ Django REST API │
│ (marketing) │ (dashboard) │ (backend) │
│ HTML/CSS │ TypeScript │ Python 3.12 │
├──────────────┴───────────────┴────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ Rust service │ │
│ │ (primary DB) │ │ (cache) │ │ (search index, │ │
│ │ │ │ (queue) │ │ data processing│ │
│ └─────────────┘ └──────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Python AI pipeline (content processing, NLP) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘Crear una API REST con Django REST Framework
El núcleo de la nueva plataforma es Django con Django REST Framework. Construí una API que gestiona el catálogo multilingüe de servicios, el sistema de consultas de clientes, la autenticación JWT y el panel de administración.
# services/models.py -- modelo de servicio con multilingüismo
from django.db import models
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
class Service(models.Model):
slug = models.SlugField(max_length=200, unique=True)
category = models.ForeignKey(
'Category', on_delete=models.PROTECT, related_name='services'
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
class ServiceTranslation(models.Model):
service = models.ForeignKey(
Service, on_delete=models.CASCADE, related_name='translations'
)
language = models.CharField(max_length=5, choices=[
('pl', 'Polski'), ('en', 'English'),
('de', 'Deutsch'), ('fr', 'Français'),
])
title = models.CharField(max_length=200)
description = models.TextField()
meta_title = models.CharField(max_length=70)
meta_description = models.CharField(max_length=160)
search_vector = SearchVectorField(null=True)
class Meta:
unique_together = ['service', 'language']
indexes = [
GinIndex(fields=['search_vector']),
models.Index(fields=['language', 'service']),
]
# services/serializers.py -- serializador con validación
from rest_framework import serializers
class ServiceSerializer(serializers.ModelSerializer):
translations = ServiceTranslationSerializer(many=True, read_only=True)
category_name = serializers.CharField(
source='category.name', read_only=True
)
class Meta:
model = Service
fields = [
'id', 'slug', 'category_name',
'is_active', 'translations', 'created_at',
]
# services/views.py -- vistas API con caché Redis
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Service.objects.filter(
is_active=True
).select_related(
'category'
).prefetch_related(
'translations'
)
serializer_class = ServiceSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['category__slug']
search_fields = ['translations__title', 'translations__description']
@method_decorator(cache_page(60 * 15)) # Cache 15 minut
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)Configuración de Redis como backend de caché y broker de colas Celery:
# settings.py -- konfiguracja Redis
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/0',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',
'CONNECTION_POOL_KWARGS': {'max_connections': 50},
},
'KEY_PREFIX': 'platform',
'TIMEOUT': 900, # 15 minut domyślnie
}
}
# Sesje w Redis (szybsze niż baza danych)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# Celery z Redis jako broker
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/1'
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/2'
CELERY_TASK_SERIALIZER = 'json'Impacto tras el despliegue de Django + PostgreSQL + Redis: el tiempo medio de respuesta de la API cayó de 4.7 segundos a 45 milisegundos. Las consultas servidas desde la caché de Redis se resuelven en 3-5 milisegundos.
Cómo migrar contraseñas MD5 sin sal a Django sin restablecerlas
La auditoría encontró contraseñas de usuarios guardadas en MD5 sin sal. Obligar a todos los usuarios a restablecer la contraseña el día del lanzamiento añade fricción a una migración que debería ser invisible para ellos. Django documenta un patrón para este caso en la sección sobre actualizar contraseñas sin necesidad de iniciar sesión (documentación de Django, gestión de contraseñas).
La idea es un hasher envolvente. El hash MD5 antiguo no se revierte; pasa a ser la entrada de un algoritmo fuerte, de modo que la base de datos guarda PBKDF2(MD5(contraseña)). El ejemplo de la documentación define una clase PBKDF2WrappedMD5PasswordHasher que hereda de PBKDF2PasswordHasher, una migración de datos que reescribe en una sola pasada cada registro que empieza por md5$, y un ajuste PASSWORD_HASHERS con el hasher PBKDF2 por defecto en primer lugar y el envolvente en segundo. Al iniciar sesión, Django calcula el MD5 de la contraseña introducida, después el PBKDF2, y compara el resultado con el valor guardado.
En la práctica importan dos detalles. La documentación advierte de que una migración así tarda del orden de varios minutos para varios miles de usuarios, según el hardware, por lo que pertenece al plan de despliegue y no a una petición. Además, el ejemplo oficial supone hashes en el formato propio de Django, md5$sal$hash. Los hashes copiados directamente de una base de datos PHP antigua no tienen sal, así que el hasher debe adaptarse a ese formato; la documentación indica que el patrón puede modificarse para cualquier algoritmo.
Búsqueda de texto completo rápida con Rust y Tantivy
El reto técnico más interesante fue el buscador. El cliente tiene un catálogo de 200 000 registros en 4 idiomas. La antigua implementación PHP ejecutaba LIKE '%term%' sobre MySQL sin índices: 47 segundos por consulta. Inutilizable.
PostgreSQL con índices GIN y tsvector resolvió el problema para las consultas estándar. Pero el cliente también necesitaba:
- búsqueda con tolerancia a errores tipográficos (fuzzy matching),
- filtrado por múltiples atributos a la vez con resultados instantáneos,
- reconstrucción del índice tras la importación de datos desde archivos CSV/Excel.
Para estas tareas construí un microservicio en Rust con la librería Tantivy (equivalente a Apache Lucene para Rust):
// search-service/src/indexer.rs -- indeksowanie wyszukiwania w Rust
use tantivy::{
schema::*, doc, Index, IndexWriter,
tokenizer::NgramTokenizer,
};
use serde::Deserialize;
use std::time::Instant;
#[derive(Deserialize)]
pub struct ServiceRecord {
pub id: i64,
pub slug: String,
pub title: String,
pub description: String,
pub category: String,
pub language: String,
pub attributes: Vec<String>,
}
pub struct SearchIndexer {
index: Index,
schema: Schema,
}
impl SearchIndexer {
pub fn new(index_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut schema_builder = Schema::builder();
schema_builder.add_i64_field("id", STORED | INDEXED);
schema_builder.add_text_field("slug", STORED);
schema_builder.add_text_field("title", TEXT | STORED);
schema_builder.add_text_field("description", TEXT | STORED);
schema_builder.add_text_field("category", STRING | STORED);
schema_builder.add_text_field("language", STRING | STORED);
schema_builder.add_text_field("attributes", TEXT | STORED);
// N-gram field for fuzzy/partial matching
schema_builder.add_text_field("title_ngram", TEXT);
let schema = schema_builder.build();
let index = Index::create_in_dir(index_path, schema.clone())?;
// Register n-gram tokenizer for typo tolerance
let ngram_tokenizer = NgramTokenizer::new(2, 4, false)
.expect("Failed to create ngram tokenizer");
index
.tokenizers()
.register("ngram", ngram_tokenizer);
Ok(Self { index, schema })
}
pub fn build_index(
&self,
records: Vec<ServiceRecord>,
) -> Result<usize, Box<dyn std::error::Error>> {
let start = Instant::now();
let mut writer: IndexWriter = self.index.writer(128_000_000)?; // 128MB buffer
let title_field = self.schema.get_field("title").unwrap();
let description_field = self.schema.get_field("description").unwrap();
let title_ngram_field = self.schema.get_field("title_ngram").unwrap();
let count = records.len();
for record in records {
writer.add_document(doc!(
self.schema.get_field("id").unwrap() => record.id,
self.schema.get_field("slug").unwrap() => record.slug,
title_field => record.title.clone(),
description_field => record.description,
self.schema.get_field("category").unwrap() => record.category,
self.schema.get_field("language").unwrap() => record.language,
self.schema.get_field("attributes").unwrap() =>
record.attributes.join(" "),
title_ngram_field => record.title,
))?;
}
writer.commit()?;
let duration = start.elapsed();
println!(
"Indexed {} records in {:.2}s",
count,
duration.as_secs_f64()
);
Ok(count)
}
}API HTTP en Rust con el framework Actix-web:
// search-service/src/main.rs -- API wyszukiwania
use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct SearchQuery {
q: String,
lang: Option<String>,
category: Option<String>,
limit: Option<usize>,
}
#[derive(Serialize)]
struct SearchResult {
id: i64,
slug: String,
title: String,
excerpt: String,
category: String,
score: f32,
}
async fn search(
query: web::Query<SearchQuery>,
indexer: web::Data<SearchIndexer>,
) -> HttpResponse {
let limit = query.limit.unwrap_or(20);
let lang = query.lang.as_deref().unwrap_or("pl");
let results = indexer.search(
&query.q, lang, query.category.as_deref(), limit
);
HttpResponse::Ok().json(results)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let indexer = SearchIndexer::new("./search_index")
.expect("Failed to create indexer");
let indexer_data = web::Data::new(indexer);
HttpServer::new(move || {
App::new()
.app_data(indexer_data.clone())
.route("/search", web::get().to(search))
.route("/health", web::get().to(|| async {
HttpResponse::Ok().body("ok")
}))
})
.bind("127.0.0.1:8081")?
.run()
.await
}Resultados del benchmark del microservicio Rust:
| Operación | PHP antiguo | Rust nuevo | Mejora |
|---|---|---|---|
| Construcción del índice (200k registros) | 47s | 1.8s | 26x más rápido |
| Búsqueda simple | 4.7s | 2ms | 2350x más rápido |
| Búsqueda con filtros | 8.3s | 5ms | 1660x más rápido |
| Fuzzy matching (errores tipográficos) | No disponible | 8ms | Nueva función |
| Uso de memoria | 512 MB | 84 MB | 6x menos |
Rust no fue una elección “porque está de moda”. Fue una elección porque para esta tarea concreta, procesar 200 000 registros con indexación n-gram y fuzzy matching, ofrece un rendimiento inalcanzable para los intérpretes.
Cómo integrar un panel React en Astro
El panel del cliente requería una UI reactiva: tablas con ordenación, filtros multinivel, formularios multietapa, gráficos con datos en tiempo real. React con TypeScript es la elección natural.
// src/components/ServiceSearch.tsx -- wyszukiwarka z filtrami
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
interface SearchResult {
id: number;
slug: string;
title: string;
excerpt: string;
category: string;
score: number;
}
interface SearchFilters {
category: string | null;
language: string;
}
export function ServiceSearch({ locale }: { locale: string }) {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({
category: null,
language: locale,
});
const debouncedQuery = useDebounce(query, 300);
const { data: results, isLoading } = useQuery<SearchResult[]>({
queryKey: ['search', debouncedQuery, filters],
queryFn: async () => {
const params = new URLSearchParams({
q: debouncedQuery,
lang: filters.language,
...(filters.category && { category: filters.category }),
});
const res = await fetch(`/api/search?${params}`);
return res.json();
},
enabled: debouncedQuery.length >= 2,
staleTime: 5 * 60 * 1000, // 5 minut cache
});
const handleCategoryChange = useCallback((category: string | null) => {
setFilters(prev => ({ ...prev, category }));
}, []);
return (
<div className="search-container">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={locale === 'pl' ? 'Szukaj usług...' : 'Search services...'}
className="w-full px-4 py-3 rounded-lg border border-gray-200
dark:border-gray-700 bg-white dark:bg-gray-800
focus:ring-2 focus:ring-emerald-500 focus:outline-none"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<span className="animate-spin h-5 w-5 border-2
border-emerald-500 border-t-transparent rounded-full
inline-block" />
</div>
)}
</div>
{results && results.length > 0 && (
<div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{results.map((result) => (
<a
key={result.id}
href={`/${locale}/${result.slug}/`}
className="block p-4 rounded-lg border border-gray-100
dark:border-gray-700 hover:border-emerald-500
transition-colors"
>
<span className="text-xs font-medium text-emerald-600
dark:text-emerald-400">
{result.category}
</span>
<h3 className="mt-1 font-semibold text-gray-900
dark:text-white">
{result.title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400
line-clamp-2">
{result.excerpt}
</p>
</a>
))}
</div>
)}
</div>
);
}El componente React se incrusta en la página Astro como una isla interactiva:
---
// src/pages/[lang]/services.astro -- pagina de servicios
import { ServiceSearch } from '../../components/ServiceSearch';
import Layout from '../../layouts/Layout.astro';
---
<Layout title="Servicios">
<section class="services-hero">
<h1>Nuestros servicios</h1>
</section>
<!-- Isla React: se carga al hacer scroll, no bloquea el resto de la pagina -->
<ServiceSearch client:visible locale={Astro.params.lang} />
</Layout>Gracias a la arquitectura Islands, el sitio de marketing carga 0 KB de JavaScript por defecto. El componente del buscador (38 KB gzipped) se carga solo cuando el usuario hace scroll hasta esa sección.
Cómo migrar contenido HTML a Markdown con IA
Migrar 1200 páginas de contenido desde HTML sucio a Markdown limpio requería automatización. Muchas páginas tenían metadatos SEO incompletos, descripciones ausentes, encabezados no optimizados. En lugar de corregirlos a mano, construí un pipeline en Python que aprovecha un modelo de lenguaje personalizado.
El pipeline funcionaba en tres fases:
# ai_content_pipeline.py -- przetwarzanie treści z AI
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import markdownify
@dataclass
class ContentAnalysis:
entities: list[dict] = field(default_factory=list)
meta_description: str = ''
heading_issues: list[str] = field(default_factory=list)
word_count: int = 0
is_thin: bool = False
suggested_internal_links: list[str] = field(default_factory=list)
def clean_legacy_html(raw_html: str) -> str:
"""Faza 1: Czyszczenie brudnego HTML z legacy platformy."""
# Usuwanie inline styles, komentarzy warunkowych IE, pustych tagów
cleaned = re.sub(r'style="[^"]*"', '', raw_html)
cleaned = re.sub(r'<!--\[if.*?\]>.*?<!\[endif\]-->', '', cleaned,
flags=re.DOTALL)
cleaned = re.sub(r'<(div|span|p)[^>]*>\s*</\1>', '', cleaned)
cleaned = re.sub(r' ', ' ', cleaned)
return cleaned
def convert_to_markdown(html: str) -> str:
"""Faza 2: Konwersja HTML na Markdown."""
return markdownify.markdownify(
html,
heading_style="ATX",
strip=['script', 'style', 'iframe', 'object', 'embed'],
convert=['h1', 'h2', 'h3', 'h4', 'p', 'a', 'img',
'ul', 'ol', 'li', 'strong', 'em', 'table'],
)
def analyze_with_ai(content: str, model_client) -> ContentAnalysis:
"""Faza 3: Analiza treści za pomocą niestándardowego modelu AI."""
prompt = f"""Przeanalizuj poniższą treść strony internetowej.
Zwróć JSON z polami:
- entities: lista obiektów {{name, type, relevance_score}}
- meta_description: optymalny opis meta (max 155 znaków, po polsku)
- heading_issues: lista problemów ze strukturą nagłówków
- word_count: liczba słów
- suggested_internal_links: sugerowane frazy do linkowania wewnętrznego
Treść:
{content[:4000]}"""
response = model_client.generate(prompt, max_tokens=1024)
data = json.loads(response.text)
return ContentAnalysis(
entities=data.get('entities', []),
meta_description=data.get('meta_description', ''),
heading_issues=data.get('heading_issues', []),
word_count=data.get('word_count', 0),
is_thin=data.get('word_count', 0) < 300,
suggested_internal_links=data.get('suggested_internal_links', []),
)
def process_batch(
content_dir: Path, model_client, max_workers: int = 4
) -> dict:
"""Przetwarzanie wsadowe z wielowątkowością."""
stats = {'processed': 0, 'thin': 0, 'entities': 0}
def process_file(md_file: Path) -> ContentAnalysis:
content = md_file.read_text(encoding='utf-8')
return analyze_with_ai(content, model_client)
files = list(content_dir.glob('*.md'))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = executor.map(process_file, files)
for analysis in results:
stats['processed'] += 1
if analysis.is_thin:
stats['thin'] += 1
stats['entities'] += len(analysis.entities)
return statsEl pipeline procesó 1247 páginas en 4 horas sobre infraestructura propia, no en la nube ni a través de ninguna API externa. Encontró 3400 entidades únicas, generó las meta descripciones faltantes para 680 páginas e identificó 89 páginas con thin content que necesitaban ampliación.
El modelo de IA funcionaba en un servidor dedicado con GPU, sin usar AWS, Azure ni ninguna solución cloud gestionada. Control total sobre los datos del cliente, cero transmisión de contenido a APIs externas.
Migración de datos de MySQL a PostgreSQL
La migración de la base de datos de MySQL 5.5 a PostgreSQL requería no solo trasladar los datos, sino una reconstrucción fundamental del esquema. El esquema antiguo no tenía relaciones, índices ni restricciones de integridad.
-- Migracja schematu: od chaosu do porządku
-- Stary MySQL (brak relacji, brak indeksów)
-- CREATE TABLE services (id INT AUTO_INCREMENT, title VARCHAR(255), ...);
-- CREATE TABLE categories (id INT AUTO_INCREMENT, name VARCHAR(100), ...);
-- Brak FOREIGN KEY, brak INDEX poza PK
-- Nowy PostgreSQL z prawidłową strukturą
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
parent_id INTEGER REFERENCES categories(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE services (
id SERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE service_translations (
id SERIAL PRIMARY KEY,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
language VARCHAR(5) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
meta_title VARCHAR(70) NOT NULL,
meta_description VARCHAR(160) NOT NULL,
search_vector TSVECTOR,
UNIQUE(service_id, language)
);
-- Indeksy dla wydajnego wyszukiwania
CREATE INDEX idx_translations_search ON service_translations
USING GIN(search_vector);
CREATE INDEX idx_translations_lang ON service_translations(language);
CREATE INDEX idx_services_category ON services(category_id)
WHERE is_active = true;
-- Trigger automatycznej aktualizacji search_vector
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_search_vector
BEFORE INSERT OR UPDATE ON service_translations
FOR EACH ROW EXECUTE FUNCTION update_search_vector();Migración SEO: redirecciones y datos estructurados
El elemento más crítico de la migración es preservar las posiciones en Google. A pesar de que la plataforma antigua estaba perdiendo tráfico, seguía teniendo cientos de URL indexadas y decenas de backlinks.
Mapeé cada URL antigua a la nueva y desplegué redirecciones 301 a nivel de Cloudflare Workers:
// redirects.ts -- mapa przekierowań ze starej platformy
const redirectMap: Record<string, string> = {
'/uslugi.php?id=1': '/pl/uslugi/konsulting/',
'/services.php?id=1': '/en/services/consulting/',
'/index.php?page=about': '/pl/o-nas/',
'/kontakt.php': '/pl/kontakt/',
'/leistungen.php': '/de/dienstleistungen/',
// ... 963 przekierowania wygenerowane automatycznie
};
export function handleRedirects(url: URL): Response | null {
// Sprawdź dokładne dopasowanie
const exactMatch = redirectMap[url.pathname + url.search];
if (exactMatch) {
return new Response(null, {
status: 301,
headers: { Location: exactMatch },
});
}
// Sprawdź dopasowanie bez query string
const pathMatch = redirectMap[url.pathname];
if (pathMatch) {
return new Response(null, {
status: 301,
headers: { Location: pathMatch },
});
}
return null;
}Implementé datos estructurados Schema.org completos con hreflang para cada versión lingüística. La plataforma antigua no tenía ningún dato estructurado, por lo que Google no entendía de qué trataba la página.
Cuánto tiempo mantener las redirecciones 301 tras una migración
La guía de Google sobre traslados de sitios con cambios de URL responde a la pregunta que surge justo después del lanzamiento (Google Search Central, traslado de sitios con cambios de URL). Google recomienda redirecciones permanentes del lado del servidor, 301 o 308, y mantenerlas en general durante al menos un año. Los enlaces internos del nuevo sitio deben apuntar directamente a las nuevas URL en lugar de pasar por redirecciones, y cada página nueva debe llevar una etiqueta canónica que apunte a sí misma. El nuevo sitemap se envía en Search Console; el antiguo se puede eliminar después, y los avisos sobre URL redirigidas en él son normales.
La misma guía fija expectativas sobre el tráfico: en un sitio de tamaño medio pueden pasar algunas semanas o más hasta que Google empiece a mostrar gradualmente las nuevas URL en lugar de las antiguas, y las posiciones fluctúan en ese periodo. Un mapa de redirecciones mantenido en el código, como el de arriba, facilita cumplir el horizonte de un año, porque vive en el repositorio y pasa revisión como cualquier otro cambio, en lugar de quedarse en un panel de hosting.
Rendimiento y tráfico antes y después de la reconstrucción
| Métrica | Antes | Después |
|---|---|---|
| PageSpeed (móvil) | No superado | Superado |
| LCP | Rojo | Verde |
| INP | Rojo | Verde |
| CLS | Rojo | Verde |
| TTFB | Segundos | Milisegundos |
| Peso de la página | Varios megabytes | Menos de un megabyte |
| Tiempo de respuesta API | Segundos | Milisegundos |
| Búsqueda (200k registros) | Decenas de segundos | Milisegundos |
| Tráfico orgánico | En caída interanual | Más que triplicado |
| Consultas de clientes | Esporádicas | Varias por semana |
| Vulnerabilidades de seguridad | Decenas abiertas | Corregidas |
Arquitectura tecnológica completa del proyecto:
- API backend: Python 3.12, Django 5, Django REST Framework, Celery
- Base de datos: PostgreSQL 16 con índices GIN y búsqueda de texto completo
- Caché y colas: Redis 7 (caché, sesiones, broker Celery)
- Microservicio de búsqueda: Rust, Actix-web, Tantivy
- Frontend interactivo: React 19, TypeScript, TanStack Query, Tailwind CSS
- Sitio de marketing: Astro 5 con islas React
- Pipeline de IA: Python, modelo de lenguaje personalizado en GPU dedicada
- Despliegue: Cloudflare Pages + Workers, GitHub Actions CI/CD
- Monitoreo: Sentry, Prometheus, Grafana
Lecciones de la reconstrucción de una plataforma obsoleta
No juzgues, diagnostica. El cliente llegó herido por una mala experiencia con una agencia. Lo último que necesitaba era otro “experto” diciéndole lo mal que estaban las cosas. En su lugar, presenté los hechos en forma de informe, expliqué los riesgos y propuse un plan de acción con un calendario claro.
Elige la herramienta para la tarea, no la tarea para la herramienta. Django para la API, React para la UI interactiva, Rust para el procesamiento de datos, Astro para las páginas estáticas. Cada tecnología resuelve un problema concreto. Un framework monolítico para todo es una receta para compromisos continuos.
La IA es una herramienta, no magia. El pipeline de IA procesó 1247 páginas en 4 horas, un trabajo que a mano habría llevado semanas. Pero cada resultado requería verificación humana. La IA generaba sugerencias, la persona tomaba decisiones. Un modelo personalizado en servidor propio da control total sobre los datos del cliente.
Rust se justifica en casos concretos. No escribí toda la plataforma en Rust. Escribí en Rust un único microservicio que procesa 200 000 registros, y ahí la diferencia de rendimiento es de un orden de magnitud. El resto del sistema funciona perfectamente en Python y TypeScript.
La migración SEO no es opcional, es obligatoria. Sin mapeo de URL y redirecciones 301, el cliente habría perdido los últimos restos de tráfico orgánico. Gracias a la migración correcta, el tráfico orgánico se triplicó con creces en 4 meses.
Qué hacer tras una mala experiencia con una agencia web
Si tu plataforma fue construida con tecnologías obsoletas, es lenta, insegura o simplemente no funciona como debería, contacta con WPPoland. Realizaré una auditoría inicial gratuita y presentaré un plan de acción con un calendario claro y un alcance de trabajo definido.
Cada proyecto de rescate empieza con una llamada. Este cliente llamó un viernes por la noche. El lunes por la mañana tenía el informe. Ocho semanas después tenía una plataforma de la que está orgulloso.
Para sitios WordPress y WooCommerce atrapados en el exceso de plugins de IA sin una reconstrucción completa, consulta el rescate de webs hechas por IA y el rescate de Core Web Vitals para sitios hechos con IA.







